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:
cdnutterandClaude Opus 5.5 committed 2026-09-30 12:24:47 -07:00
1 parent e0617047f7
commit 30fab3a208
11 files changed
+760 -8

No files matched your search

+119
View File
@@ -0,0 +1,119 @@
package engine
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/cdnutter/playlink/internal/store"
)
func (ev *env) noMusic(t *testing.T, side string) *NoMusicScan {
t.Helper()
if _, err := ev.e.StartNoMusicScan(ev.pair.ID, side); err != nil {
t.Fatal(err)
}
ev.wait()
s := ev.e.NoMusicResult(ev.pair.ID)
if s == nil {
t.Fatal("no scan result")
}
return s
}
func rels(s *NoMusicScan) []string {
var out []string
for _, f := range s.Folders {
out = append(out, f.Rel)
}
return out
}
func TestFindFoldersWithoutMusic(t *testing.T) {
ev := setup(t, false)
s := ev.slave
ev.write(s, "A/B/song.flac", "music")
ev.write(s, "A/B/extra/cover.jpg", "art") // inside an album that has music
os.MkdirAll(filepath.Join(s, "A/Empty"), 0o755)
ev.write(s, "A/C/cover.jpg", "art")
ev.write(s, "D/E/F/notes.txt", "text") // only D is listed, not D/E or D/E/F
ev.write(s, "G/._song.flac", "mac metadata")
ev.write(s, ".hidden/stuff.txt", "x")
ev.write(s, "Loose.txt", "x") // a file at the root is not a folder
scan := ev.noMusic(t, SideSlave)
got := strings.Join(rels(scan), ",")
if got != "A/B/extra,A/C,A/Empty,D,G" {
t.Fatalf("got %s", got)
}
for _, f := range scan.Folders {
if f.Rel == "D" && (f.Files != 1 || f.Folders != 2 || f.Sample[0] != "E/F/notes.txt") {
t.Fatalf("D: %+v", f)
}
}
// The quarantine never shows up, even when it isn't hidden.
if strings.Contains(got, "trash") {
t.Fatal("quarantine listed")
}
}
func TestRemoveFoldersWithoutMusic(t *testing.T) {
ev := setup(t, false)
s := ev.slave
ev.write(s, "A/B/song.flac", "music")
os.MkdirAll(filepath.Join(s, "A/Empty"), 0o755)
ev.write(s, "D/E/notes.txt", "text")
ev.write(s, "H/cover.jpg", "art")
ev.noMusic(t, SideSlave)
if _, err := ev.e.StartRemoveFolders(ev.pair.ID, []string{"A/B"}); err == nil {
t.Fatal("a folder that wasn't listed must be refused")
}
// Music added after the scan: that folder is skipped.
ev.write(s, "H/new.flac", "music")
if _, err := ev.e.StartRemoveFolders(ev.pair.ID, []string{"A/Empty", "D", "H"}); err != nil {
t.Fatal(err)
}
j := ev.wait()
if j.Results["removed"] != 2 || len(j.Skipped) != 1 || !strings.Contains(j.Skipped[0], "contains music") {
t.Fatalf("results %+v skipped %v", j.Results, j.Skipped)
}
for _, p := range []string{"A/Empty", "D"} {
if exists(filepath.Join(s, p)) {
t.Errorf("%s should be gone", p)
}
}
if !exists(filepath.Join(s, "A/B/song.flac")) || !exists(filepath.Join(s, "H/new.flac")) {
t.Fatal("music must never be touched")
}
ops, _, _ := ev.st.Ops(ctx, store.OpFilter{PairID: ev.pair.ID})
var dOp store.Operation
for _, o := range ops {
if o.Action == store.ActRemoveFolder && o.RelSlave == "D" {
dOp = o
}
}
if dOp.ID == 0 || !exists(filepath.Join(dOp.QuarantinePath, "E/notes.txt")) {
t.Fatalf("D should be in the quarantine: %+v", dOp)
}
// Undo brings it back with its contents; the scan result is stale now.
if err := ev.e.Undo(ctx, dOp.ID); err != nil {
t.Fatal(err)
}
if b, _ := os.ReadFile(filepath.Join(s, "D/E/notes.txt")); string(b) != "text" {
t.Fatal("undo should restore the folder")
}
if _, err := ev.e.StartRemoveFolders(ev.pair.ID, []string{"D"}); err == nil {
t.Fatal("removing needs a fresh scan")
}
}
func TestRemoveFoldersDryRun(t *testing.T) {
ev := setup(t, true)
os.MkdirAll(filepath.Join(ev.master, "Empty"), 0o755)
ev.noMusic(t, SideMaster)
ev.e.StartRemoveFolders(ev.pair.ID, []string{"Empty"})
if j := ev.wait(); j.Results["planned"] != 1 || !exists(filepath.Join(ev.master, "Empty")) {
t.Fatalf("dry run must only record: %+v", j.Results)
}
}