From 30fab3a20804324cb6d34f1ab7b5fce0676102c9 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 30 Sep 2026 12:24:47 -0700 Subject: [PATCH] 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 --- README.md | 7 + internal/api/server.go | 46 ++++ internal/engine/emptydirs.go | 336 ++++++++++++++++++++++++++++++ internal/engine/emptydirs_test.go | 119 +++++++++++ internal/engine/engine.go | 3 + internal/engine/ops.go | 13 +- internal/store/models.go | 2 + web/src/api.ts | 2 +- web/src/pages/Activity.tsx | 19 +- web/src/pages/Cleanup.tsx | 217 +++++++++++++++++++ web/src/pages/PairPage.tsx | 4 + 11 files changed, 760 insertions(+), 8 deletions(-) create mode 100644 internal/engine/emptydirs.go create mode 100644 internal/engine/emptydirs_test.go create mode 100644 web/src/pages/Cleanup.tsx diff --git a/README.md b/README.md index 08e030a..7b36429 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,13 @@ Each row has the match status and type, a recommendation, both paths, where link bit depth and duration for both sides, the quality verdict and any notes. The file opens cleanly in Excel, Numbers and LibreOffice (UTF-8, and cells a spreadsheet would treat as formulas are escaped). +**Folders without music** (the *Cleanup* tab). Choose the slave or master library and **Find folders**: playlink lists the top-most +folders with no music file anywhere inside them (music = the pair's file extensions), such as album folders left empty after linking or +ones holding only a cover or playlist, with what they still contain. Empty folders are pre-selected, folders that still hold files are +not. **Remove** asks for confirmation (and an extra acknowledgement when the folders still hold files), re-checks each folder for music +right before touching it, and moves it into the quarantine instead of deleting it: it can be restored from History until the retention +period purges it. The library root, the quarantine and hidden folders are never listed, and dry run applies. + **Backups** (Settings → Backups), similar to Sonarr. A backup is a zip of playlink's database (settings, library pairs, scan results and history, not music files) taken safely while the app runs. Choose a folder (default `/config/backups`), a schedule (off, every 1, 3, 7 or 14 days) and a retention period for scheduled backups; **Back up now** makes a manual one (kept until deleted). Backups can be diff --git a/internal/api/server.go b/internal/api/server.go index 912cb87..f23435b 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -101,6 +101,9 @@ func (s *Server) Handler() http.Handler { r.Post("/renames", s.handleRenames) r.Post("/link-identical", s.handleLinkIdentical) r.Get("/tree", s.handleTree) + r.Get("/empty-folders", s.handleEmptyFolders) + r.Post("/empty-folders/scan", s.handleEmptyFolderScan) + r.Post("/empty-folders/remove", s.handleRemoveEmptyFolders) r.Post("/build", s.handleBuild) r.Post("/remove", s.handleRemove) }) @@ -740,3 +743,46 @@ func (s *Server) preAction(w http.ResponseWriter, r *http.Request, pairID int64, } return true } + +// ---- folders without music ---- + +func (s *Server) handleEmptyFolders(w http.ResponseWriter, r *http.Request) { + id, _ := idParam(r) + writeJSON(w, 200, map[string]any{"scan": s.eng.NoMusicResult(id), "dry_run": s.st.DryRun(r.Context())}) +} + +func (s *Server) handleEmptyFolderScan(w http.ResponseWriter, r *http.Request) { + id, _ := idParam(r) + var body struct { + Side string `json:"side"` + } + if !readJSON(w, r, &body) { + return + } + j, err := s.eng.StartNoMusicScan(id, body.Side) + if err != nil { + opErr(w, err) + return + } + writeJSON(w, 202, j) +} + +func (s *Server) handleRemoveEmptyFolders(w http.ResponseWriter, r *http.Request) { + id, _ := idParam(r) + var body struct { + Folders []string `json:"folders"` // must come from the latest scan + } + if !readJSON(w, r, &body) { + return + } + if !s.preAction(w, r, id, "remove folders without music") { + return + } + j, err := s.eng.StartRemoveFolders(id, body.Folders) + if err != nil { + opErr(w, err) + return + } + s.log.Info("removing folders without music", "pair", id, "folders", len(body.Folders)) + writeJSON(w, 202, j) +} diff --git a/internal/engine/emptydirs.go b/internal/engine/emptydirs.go new file mode 100644 index 0000000..1d6fa45 --- /dev/null +++ b/internal/engine/emptydirs.go @@ -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) +} diff --git a/internal/engine/emptydirs_test.go b/internal/engine/emptydirs_test.go new file mode 100644 index 0000000..1536fbb --- /dev/null +++ b/internal/engine/emptydirs_test.go @@ -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) + } +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 4110913..3c8e432 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -43,6 +43,9 @@ type Engine struct { beforeAction func(ctx context.Context, reason string) error + emptyMu sync.Mutex + empty map[int64]*NoMusicScan // latest scan for folders without music, per pair + jobsMu sync.Mutex jobs map[int64]*Job nextID int64 diff --git a/internal/engine/ops.go b/internal/engine/ops.go index d5be608..fda0083 100644 --- a/internal/engine/ops.go +++ b/internal/engine/ops.go @@ -622,7 +622,7 @@ func (e *Engine) Undo(ctx context.Context, opID int64) error { return errors.New("the quarantined original was already purged") } switch o.Action { - case store.ActLink, store.ActPromote, store.ActBuild, store.ActRemove, store.ActRename: + case store.ActLink, store.ActPromote, store.ActBuild, store.ActRemove, store.ActRename, store.ActRemoveFolder: default: // History can hold operations of features that were removed (tag // edits, file moves); undoing them as a link would be wrong. @@ -632,6 +632,17 @@ func (e *Engine) Undo(ctx context.Context, opID int64) error { if err != nil { return err } + if o.Action == store.ActRemoveFolder { + if err := undoRemoveFolder(l, o); err != nil { + return err + } + if err := e.st.MarkUndone(ctx, o.ID); err != nil { + return err + } + e.log.Info("folder restored from quarantine", "op", o.ID) + pruneEmpty(filepath.Dir(o.QuarantinePath), l.quarantine) + return nil + } if o.Action == store.ActRename { if err := undoRename(l, o); err != nil { return err diff --git a/internal/store/models.go b/internal/store/models.go index 2414a36..dfc82ec 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -653,6 +653,8 @@ const ( ActBuild = "build" // master file linked into the slave (fresh library) ActRemove = "remove" // slave hardlink removed (master copy kept) ActRename = "rename" // slave folder renamed to the master's spelling + + ActRemoveFolder = "remove-folder" // folder without music moved to the quarantine ) // Operation statuses. diff --git a/web/src/api.ts b/web/src/api.ts index ead5aa5..1bd757b 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -139,7 +139,7 @@ export type Match = { export type Operation = { id: number; pair_id: number; - action: "link" | "promote" | "build" | "remove" | "rename"; + action: "link" | "promote" | "build" | "remove" | "rename" | "remove-folder"; rel_master: string; rel_slave: string; rel_new: string; diff --git a/web/src/pages/Activity.tsx b/web/src/pages/Activity.tsx index 91c4d45..f6ae22a 100644 --- a/web/src/pages/Activity.tsx +++ b/web/src/pages/Activity.tsx @@ -37,6 +37,7 @@ const actionLabel: Record = { build: "Built into slave", remove: "Removed from slave", rename: "Renamed slave folder", + "remove-folder": "Removed folder without music", }; const statusTone: Record = { @@ -83,20 +84,23 @@ export function OpsTable({ pairId, quarantine }: { pairId?: number; quarantine?: )} {items.map((o) => { - const canUndo = o.status === "done" && !o.undone_at && !o.purged_at; + // History may hold actions of removed features: shown, never undone. + const known = o.action in actionLabel; + const canUndo = known && o.status === "done" && !o.undone_at && !o.purged_at; const inQuarantine = !!o.quarantine_path && !o.purged_at && !o.undone_at && (o.status === "done" || o.status === "interrupted"); return (
-
{actionLabel[o.action]}
+
{actionLabel[o.action] ?? o.action}
{formatAgo(o.created_at)} · {o.trigger} {!pairId && names[o.pair_id] && ` · ${names[o.pair_id]}`}
-
- {o.rel_slave} +
+ {o.action === "remove-folder" && {o.quarantine_side}:} + {o.rel_slave || o.rel_master}
{o.rel_new && o.rel_new !== o.rel_slave && o.rel_new !== o.rel_master && (
now {o.rel_new}
@@ -104,7 +108,7 @@ export function OpsTable({ pairId, quarantine }: { pairId?: number; quarantine?: {o.error &&
{o.error}
} {inQuarantine && (
- {o.quarantine_side} original kept in quarantine + {o.action === "remove-folder" ? "folder" : `${o.quarantine_side} original`} kept in quarantine
)}
@@ -145,7 +149,8 @@ export function OpsTable({ pairId, quarantine }: { pairId?: number; quarantine?:

{confirm.kind === "purge" ? ( <> - The quarantined {confirm.op.quarantine_side} original of {confirm.op.rel_slave} will be deleted.{" "} + The quarantined {confirm.op.action === "remove-folder" ? "folder" : `${confirm.op.quarantine_side} original of`}{" "} + {confirm.op.rel_slave || confirm.op.rel_master} will be deleted.{" "} This cannot be undone. ) : ( @@ -180,5 +185,7 @@ function undoText(o: Operation) { return "The hardlink is recreated in the slave library."; case "rename": return `The slave folder is renamed back from "${o.rel_new}" to "${o.rel_slave}", and the library is rescanned.`; + case "remove-folder": + return `The folder "${o.rel_slave || o.rel_master}" is moved back from the quarantine into the ${o.quarantine_side} library, with everything that was in it.`; } } diff --git a/web/src/pages/Cleanup.tsx b/web/src/pages/Cleanup.tsx new file mode 100644 index 0000000..1d6a1d2 --- /dev/null +++ b/web/src/pages/Cleanup.tsx @@ -0,0 +1,217 @@ +import { useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { AlertTriangle, FolderX, Search } from "lucide-react"; +import { api, type Pair } from "../api"; +import { Alert, Badge, Button, Card, Modal, Toggle, cx, formatAgo, formatBytes, inputCls } from "../ui"; +import { useToast } from "../toast"; +import { JobBar } from "./PairPage"; + +type Side = "slave" | "master"; +type NoMusicFolder = { rel: string; files: number; folders: number; bytes: number; sample: string[] | null }; +type NoMusicScan = { side: Side; created_at: number; folders: NoMusicFolder[] }; + +export function Cleanup({ pair }: { pair: Pair }) { + const qc = useQueryClient(); + const toast = useToast(); + const [side, setSide] = useState("slave"); + const [sel, setSel] = useState>(new Set()); + const [confirm, setConfirm] = useState(false); + const data = useQuery({ + queryKey: ["empty-folders", pair.id], + queryFn: () => api.get<{ scan: NoMusicScan | null; dry_run: boolean }>(`/pairs/${pair.id}/empty-folders`), + }); + const scan = data.data?.scan ?? null; + const running = pair.job?.state === "running"; + const ourJob = running && pair.job!.kind.includes("empty-folders"); + const refresh = () => { + qc.invalidateQueries({ queryKey: ["pair", pair.id] }); + qc.invalidateQueries({ queryKey: ["empty-folders", pair.id] }); + }; + // Empty folders are selected by default; folders holding files are not. + useEffect(() => { + setSel(new Set((scan?.folders ?? []).filter((f) => f.files === 0).map((f) => f.rel))); + }, [scan?.created_at, scan?.side]); // eslint-disable-line react-hooks/exhaustive-deps + + const find = useMutation({ + mutationFn: () => api.post(`/pairs/${pair.id}/empty-folders/scan`, { side }), + onSuccess: refresh, + onError: (e) => toast("err", e.message), + }); + const folders = scan?.folders ?? []; + const chosen = folders.filter((f) => sel.has(f.rel)); + const allOn = folders.length > 0 && chosen.length === folders.length; + + return ( +

+ +
+
+

+ Folders without music +

+

+ Finds folders with no music file anywhere inside them (music = this pair's file extensions), such as album folders left empty after + linking, or ones holding only a cover or a playlist. Nothing is removed without your confirmation, and removed folders go to the + quarantine, so they can be restored from History. +

+
+
+ + +
+
+ {ourJob && } +
+ + {scan && !ourJob && ( + +
+
+ {folders.length} folders without music in the {scan.side} library + · checked {formatAgo(scan.created_at)} +
+ {folders.length > 0 && ( + + )} +
+ {folders.length === 0 ? ( +
Every folder in the {scan.side} library contains music.
+ ) : ( + <> + +
+ {folders.map((f) => ( + + ))} +
+ + )} +
+ )} + + {confirm && scan && ( + setConfirm(false)} + onDone={() => { + setConfirm(false); + refresh(); + }} + /> + )} +
+ ); +} + +function ConfirmRemove({ + pair, + side, + folders, + dry, + onClose, + onDone, +}: { + pair: Pair; + side: Side; + folders: NoMusicFolder[]; + dry: boolean; + onClose: () => void; + onDone: () => void; +}) { + const toast = useToast(); + const withFiles = folders.filter((f) => f.files > 0); + const files = withFiles.reduce((n, f) => n + f.files, 0); + const bytes = withFiles.reduce((n, f) => n + f.bytes, 0); + const [checked, setChecked] = useState(withFiles.length === 0); + const remove = useMutation({ + mutationFn: () => api.post(`/pairs/${pair.id}/empty-folders/remove`, { folders: folders.map((f) => f.rel) }), + onSuccess: onDone, + onError: (e) => toast("err", e.message), + }); + return ( + +
+

+ These folders in the {side} library are moved into the quarantine. Each one is checked for music again right before it moves, and + skipped if music appeared. You can restore them from History + {pair.retention_days > 0 ? `; they are deleted for good after ${pair.retention_days} days` : "; they stay in the quarantine until you purge them"}. +

+
+ {folders.map((f) => ( +
0 && "text-amber-200")}> + {f.rel} + {f.files > 0 && ` (${f.files} ${f.files === 1 ? "file" : "files"})`} +
+ ))} +
+ {withFiles.length > 0 && ( + }> +
+
+ {withFiles.length} of these folders still hold files that aren't music ({files} files, {formatBytes(bytes)}), such as covers, lyrics or + playlists. They move to the quarantine together with their folder. +
+ +
+
+ )} + {dry && Dry run is on: the removals are only recorded in Activity, and no folder moves.} +
+ + +
+
+
+ ); +} diff --git a/web/src/pages/PairPage.tsx b/web/src/pages/PairPage.tsx index d704835..18b3c98 100644 --- a/web/src/pages/PairPage.tsx +++ b/web/src/pages/PairPage.tsx @@ -10,6 +10,7 @@ import { Review } from "./Review"; import { Build } from "./Build"; import { OpsTable } from "./Activity"; import { WarningList } from "./Warnings"; +import { Cleanup } from "./Cleanup"; export function usePair(id: number) { return useQuery({ @@ -38,6 +39,7 @@ function useJobFinished(pair: Pair | undefined) { qc.invalidateQueries({ queryKey: ["tree"] }); qc.invalidateQueries({ queryKey: ["ops"] }); qc.invalidateQueries({ queryKey: ["pairs"] }); + qc.invalidateQueries({ queryKey: ["empty-folders"] }); const j = pair.job!; if (st === "done") toast("ok", `${j.kind} finished${j.message ? `: ${j.message}` : ""}`); else toast("err", `${j.kind} ${st}: ${j.error}`); @@ -122,6 +124,7 @@ function LastJob({ job }: { job: Job }) { const tabs = [ { id: "review", label: "Review" }, { id: "build", label: "Build" }, + { id: "cleanup", label: "Cleanup" }, { id: "history", label: "History" }, ]; @@ -202,6 +205,7 @@ export function PairPage({ id, tab }: { id: number; tab: string }) { {tab === "review" && } {tab === "build" && } + {tab === "cleanup" && } {tab === "history" && } setEditing(false)} />