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/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); 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 }) } // removeFolder moves one folder without music into the quarantine. func (e *Engine) removeFolder(ctx context.Context, l *libs, side, rel string) 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 may have arrived since the scan: check again, right before moving. var music bool _ = filepath.WalkDir(abs, func(p string, d fs.DirEntry, err error) error { if err == nil && !d.IsDir() && d.Type().IsRegular() && l.isMusic(d.Name()) { music = true return filepath.SkipAll } return nil }) if music { return errors.New("it contains music now; skipped") } o := store.Operation{PairID: l.pair.ID, Action: store.ActRemoveFolder, QuarantineSide: side, Trigger: "manual"} 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) }