Add finding and removing folders without music
The new Cleanup tab lists the top-most folders in either library that contain no music file (per the pair's extensions). Removing them needs confirmation, plus an explicit acknowledgement when they still hold files; each folder is re-checked for music and moved into the quarantine (undoable from History, purged after the retention period). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e0617047f7
commit
30fab3a208
11 files changed
+760
-8
No files matched your search
@@ -0,0 +1,336 @@
|
||||
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)
|
||||
}
|
||||
Reference in new issue
Block a user