package engine import ( "context" "errors" "fmt" "io/fs" "os" "path/filepath" "sort" "strings" "time" "github.com/cdnutter/playlink/internal/fsops" "github.com/cdnutter/playlink/internal/notify" "github.com/cdnutter/playlink/internal/store" ) // Folders without music are what linking, renames and manual clean-ups leave // behind: empty album folders, or ones holding only a cover or a playlist. // A scan lists them; removing them needs the user's confirmation and moves // each folder into the quarantine (never deletes it), so it can be undone // until the retention period purges it. // Library sides. const ( SideMaster = "master" SideSlave = "slave" ) func sideRoot(l *libs, side string) (fsops.Root, error) { switch side { case SideMaster: return l.master, nil case SideSlave: return l.slave, nil } return fsops.Root{}, fmt.Errorf("%w: side must be master or slave", ErrInvalidBulk) } // NoMusicFolder is a folder with no music file anywhere inside it. type NoMusicFolder struct { Rel string `json:"rel"` Files int `json:"files"` // other files inside (covers, playlists, ...) Folders int `json:"folders"` // subfolders inside Bytes int64 `json:"bytes"` Sample []string `json:"sample"` // a few of the files, relative to the folder } // NoMusicScan is the result of the latest scan for folders without music. type NoMusicScan struct { Side string `json:"side"` CreatedAt int64 `json:"created_at"` Folders []NoMusicFolder `json:"folders"` } const noMusicSample = 5 // isMusic reports whether a file counts as music: one of the pair's // extensions, and not a macOS "._" metadata file. func (l *libs) isMusic(name string) bool { return l.hasExt(name) && !strings.HasPrefix(name, "._") } // findNoMusic lists the top-most folders under a library root that contain // no music. The root itself, the quarantine and anything containing it, and // hidden folders are never listed. func findNoMusic(ctx context.Context, l *libs, root fsops.Root) ([]NoMusicFolder, error) { type dirInfo struct { music bool files, folders int bytes int64 sample []string } dirs := map[string]*dirInfo{} var order []string err := filepath.WalkDir(root.Path, func(p string, d fs.DirEntry, err error) error { if err != nil { if p == root.Path { return err } return nil } if ctx.Err() != nil { return ctx.Err() } if d.IsDir() { if p == l.quarantine { return filepath.SkipDir } dirs[p] = &dirInfo{} order = append(order, p) // Count the folder in every ancestor. for a := filepath.Dir(p); p != root.Path && fsops.Within(root.Path, a); a = filepath.Dir(a) { if di := dirs[a]; di != nil { di.folders++ } if a == root.Path { break } } return nil } // Files (and symlinks, which are never followed) count towards every // ancestor; music marks every ancestor as having music. music := d.Type().IsRegular() && l.isMusic(d.Name()) var size int64 if fi, err := d.Info(); err == nil { size = fi.Size() } for a := filepath.Dir(p); fsops.Within(root.Path, a); a = filepath.Dir(a) { di := dirs[a] if di == nil { break } if music { di.music = true } else { di.files++ di.bytes += size if len(di.sample) < noMusicSample { if rel, err := filepath.Rel(a, p); err == nil { di.sample = append(di.sample, rel) } } } if a == root.Path { break } } return nil }) if err != nil { return nil, err } var out []NoMusicFolder for _, p := range order { di := dirs[p] if p == root.Path || di.music || strings.HasPrefix(filepath.Base(p), ".") || fsops.Within(p, l.quarantine) { continue } // Only the top-most folder: its parent must have music (or be the root). if parent := dirs[filepath.Dir(p)]; filepath.Dir(p) != root.Path && parent != nil && !parent.music { continue } rel, err := root.Rel(p) if err != nil { continue } out = append(out, NoMusicFolder{Rel: rel, Files: di.files, Folders: di.folders, Bytes: di.bytes, Sample: di.sample}) } sort.Slice(out, func(i, j int) bool { return out[i].Rel < out[j].Rel }) if out == nil { out = []NoMusicFolder{} } return out, nil } // StartNoMusicScan looks for folders without music in one library. func (e *Engine) StartNoMusicScan(pairID int64, side string) (*Job, error) { if side != SideMaster && side != SideSlave { return nil, fmt.Errorf("%w: side must be master or slave", ErrInvalidBulk) } return e.startJob(pairID, "find-empty-folders", func(ctx context.Context, j *Job) error { l, err := e.loadPair(ctx, pairID) if err != nil { return err } root, _ := sideRoot(l, side) j.phase("Looking for folders without music ("+side+")", 0) found, err := findNoMusic(ctx, l, root) if err != nil { return err } e.emptyMu.Lock() if e.empty == nil { e.empty = map[int64]*NoMusicScan{} } e.empty[pairID] = &NoMusicScan{Side: side, CreatedAt: time.Now().Unix(), Folders: found} e.emptyMu.Unlock() j.update(func(j *Job) { j.Message = fmt.Sprintf("%d folders without music in the %s library", len(found), side) }) return nil }) } // NoMusicResult returns the latest scan for folders without music. func (e *Engine) NoMusicResult(pairID int64) *NoMusicScan { e.emptyMu.Lock() defer e.emptyMu.Unlock() return e.empty[pairID] } // StartRemoveFolders moves the chosen folders of the latest scan into the // quarantine. Only folders that scan listed can be chosen, so a request can // never name arbitrary paths, and each is checked again for music first. func (e *Engine) StartRemoveFolders(pairID int64, rels []string) (*Job, error) { e.emptyMu.Lock() scan := e.empty[pairID] e.emptyMu.Unlock() if scan == nil { return nil, fmt.Errorf("%w: look for folders without music first", ErrInvalidBulk) } listed := map[string]bool{} for _, f := range scan.Folders { listed[f.Rel] = true } var chosen []string for _, r := range rels { if !listed[r] { return nil, fmt.Errorf("%w: %q is not in the latest scan", ErrInvalidBulk, r) } chosen = append(chosen, r) } if len(chosen) == 0 { return nil, fmt.Errorf("%w: no folders selected", ErrInvalidBulk) } side := scan.Side return e.startJob(pairID, "remove-empty-folders", func(ctx context.Context, j *Job) error { l, err := e.loadPair(ctx, pairID) if err != nil { return err } j.phase("Moving folders to the quarantine", int64(len(chosen))) for _, rel := range chosen { if ctx.Err() != nil { return ctx.Err() } if err := e.removeFolder(ctx, l, side, rel, "manual", false); err != nil { j.skip(rel + ": " + err.Error()) } else if e.st.DryRun(ctx) { j.count("planned") } else { j.count("removed") } j.tick() } e.emptyMu.Lock() delete(e.empty, pairID) // stale now e.emptyMu.Unlock() return nil }) } // autoCleanup runs after a scan when the pair opts in. It only removes // slave folders with nothing in them at all (or only empty subfolders); // folders holding any file, such as a cover or a playlist, are left for the // user in Cleanup. Folders changed within the settle time are left too, as // a download may be about to fill them. func (e *Engine) autoCleanup(ctx context.Context, j *Job, l *libs, trigger string) error { j.phase("Looking for empty folders", 0) found, err := findNoMusic(ctx, l, l.slave) if err != nil { return err } cutoff := time.Now().Add(-time.Duration(l.pair.SettleMinutes) * time.Minute) var empty []string withFiles := 0 for _, f := range found { switch { case f.Files > 0: withFiles++ case changedSince(filepath.Join(l.slave.Path, f.Rel), cutoff): j.skip(f.Rel + ": changed recently, waiting for it to settle") default: empty = append(empty, f.Rel) } } if len(empty) == 0 { return nil } if e.st.DryRun(ctx) { j.update(func(j *Job) { j.Message += fmt.Sprintf(". Dry run is on: %s would be removed.", plural(len(empty), "empty folder", "empty folders")) }) return nil } if err := e.BeforeAction(ctx, "auto-cleanup"); err != nil { return err } j.phase("Removing empty folders", int64(len(empty))) removed := 0 for _, rel := range empty { if ctx.Err() != nil { break } if err := e.removeFolder(ctx, l, SideSlave, rel, "auto:"+trigger, true); err != nil { j.skip(rel + ": " + err.Error()) } else { j.count("empty folders removed") removed++ } j.tick() } if removed > 0 { e.emptyMu.Lock() delete(e.empty, l.pair.ID) // a listed folder may be gone now e.emptyMu.Unlock() msg := fmt.Sprintf("Moved %s from the slave library to the quarantine.", plural(removed, "empty folder", "empty folders")) if withFiles > 0 { msg += fmt.Sprintf(" %s without music still hold files and were left for you in Cleanup.", plural(withFiles, "folder", "folders")) } e.emit(ctx, l, notify.Event{Kind: notify.EventCleanup, Title: "Empty folders cleaned up", Message: msg, Path: fmt.Sprintf("#/pair/%d/cleanup", l.pair.ID)}) } return ctx.Err() } // changedSince reports whether a folder or any folder inside it was // modified after t. func changedSince(abs string, t time.Time) bool { changed := false _ = filepath.WalkDir(abs, func(p string, d fs.DirEntry, err error) error { if err != nil || !d.IsDir() { return nil } if fi, err := d.Info(); err == nil && fi.ModTime().After(t) { changed = true return filepath.SkipAll } return nil }) return changed } // removeFolder moves one folder without music into the quarantine. With // emptyOnly, it refuses a folder that holds any file. func (e *Engine) removeFolder(ctx context.Context, l *libs, side, rel, trigger string, emptyOnly bool) error { e.mu.Lock() defer e.mu.Unlock() root, err := sideRoot(l, side) if err != nil { return err } abs, err := root.Resolve(rel) if err != nil { return err } fi, err := os.Lstat(abs) switch { case err != nil: return errors.New("the folder no longer exists") case fi.Mode()&os.ModeSymlink != 0 || !fi.IsDir(): return errors.New("is not a folder") case abs == root.Path || fsops.Within(abs, l.quarantine): return errors.New("can't remove the library or the quarantine") } // Music (or, for an empty-only removal, any file) may have arrived // since the scan: check again, right before moving. var music, files bool _ = filepath.WalkDir(abs, func(p string, d fs.DirEntry, err error) error { if err != nil { files = true // can't see inside: never treat it as empty return nil } if d.IsDir() { return nil } files = true if d.Type().IsRegular() && l.isMusic(d.Name()) { music = true return filepath.SkipAll } return nil }) if music { return errors.New("it contains music now; skipped") } if emptyOnly && files { return errors.New("it contains files now; skipped") } o := store.Operation{PairID: l.pair.ID, Action: store.ActRemoveFolder, QuarantineSide: side, Trigger: trigger} setSide(&o, side, rel) if err := e.begin(ctx, &o); err != nil { return err } if o.DryRun { return nil } q := quarantinePath(l, o.ID, side, rel) err = func() error { // Recorded first, so the folder is always findable from History. if _, err := e.st.DB.ExecContext(ctx, `UPDATE operations SET quarantine_path = ? WHERE id = ?`, q, o.ID); err != nil { return err } if err := os.MkdirAll(filepath.Dir(q), 0o775); err != nil { return err } if err := os.Rename(abs, q); err != nil { return err } return fsops.SyncDir(filepath.Dir(abs)) }() return e.finish(ctx, &o, err) } func setSide(o *store.Operation, side, rel string) { if side == SideMaster { o.RelMaster = rel } else { o.RelSlave = rel } } // undoRemoveFolder moves a folder back from the quarantine. func undoRemoveFolder(l *libs, o store.Operation) error { root, err := sideRoot(l, o.QuarantineSide) if err != nil { return err } rel := o.RelSlave if o.QuarantineSide == SideMaster { rel = o.RelMaster } dst, err := root.Resolve(rel) if err != nil { return err } if !fsops.Within(l.quarantine, filepath.Clean(o.QuarantinePath)) { return fsops.ErrOutsideRoot } if fi, err := os.Lstat(o.QuarantinePath); err != nil || !fi.IsDir() { return errors.New("the folder is no longer in the quarantine") } if _, err := os.Lstat(dst); err == nil { return fmt.Errorf("%q exists again: %w", rel, fsops.ErrExists) } if err := fsops.MkdirAllLike(filepath.Dir(dst)); err != nil { return err } return os.Rename(o.QuarantinePath, dst) }