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

+7
View File
@@ -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 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). (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 **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 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 or 14 days) and a retention period for scheduled backups; **Back up now** makes a manual one (kept until deleted). Backups can be
+46
View File
@@ -101,6 +101,9 @@ func (s *Server) Handler() http.Handler {
r.Post("/renames", s.handleRenames) r.Post("/renames", s.handleRenames)
r.Post("/link-identical", s.handleLinkIdentical) r.Post("/link-identical", s.handleLinkIdentical)
r.Get("/tree", s.handleTree) 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("/build", s.handleBuild)
r.Post("/remove", s.handleRemove) r.Post("/remove", s.handleRemove)
}) })
@@ -740,3 +743,46 @@ func (s *Server) preAction(w http.ResponseWriter, r *http.Request, pairID int64,
} }
return true 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)
}
+336
View File
@@ -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)
}
+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)
}
}
+3
View File
@@ -43,6 +43,9 @@ type Engine struct {
beforeAction func(ctx context.Context, reason string) error 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 jobsMu sync.Mutex
jobs map[int64]*Job jobs map[int64]*Job
nextID int64 nextID int64
+12 -1
View File
@@ -622,7 +622,7 @@ func (e *Engine) Undo(ctx context.Context, opID int64) error {
return errors.New("the quarantined original was already purged") return errors.New("the quarantined original was already purged")
} }
switch o.Action { 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: default:
// History can hold operations of features that were removed (tag // History can hold operations of features that were removed (tag
// edits, file moves); undoing them as a link would be wrong. // 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 { if err != nil {
return err 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 o.Action == store.ActRename {
if err := undoRename(l, o); err != nil { if err := undoRename(l, o); err != nil {
return err return err
+2
View File
@@ -653,6 +653,8 @@ const (
ActBuild = "build" // master file linked into the slave (fresh library) ActBuild = "build" // master file linked into the slave (fresh library)
ActRemove = "remove" // slave hardlink removed (master copy kept) ActRemove = "remove" // slave hardlink removed (master copy kept)
ActRename = "rename" // slave folder renamed to the master's spelling ActRename = "rename" // slave folder renamed to the master's spelling
ActRemoveFolder = "remove-folder" // folder without music moved to the quarantine
) )
// Operation statuses. // Operation statuses.
+1 -1
View File
@@ -139,7 +139,7 @@ export type Match = {
export type Operation = { export type Operation = {
id: number; id: number;
pair_id: number; pair_id: number;
action: "link" | "promote" | "build" | "remove" | "rename"; action: "link" | "promote" | "build" | "remove" | "rename" | "remove-folder";
rel_master: string; rel_master: string;
rel_slave: string; rel_slave: string;
rel_new: string; rel_new: string;
+13 -6
View File
@@ -37,6 +37,7 @@ const actionLabel: Record<Operation["action"], string> = {
build: "Built into slave", build: "Built into slave",
remove: "Removed from slave", remove: "Removed from slave",
rename: "Renamed slave folder", rename: "Renamed slave folder",
"remove-folder": "Removed folder without music",
}; };
const statusTone: Record<Operation["status"], "green" | "red" | "blue" | "amber" | "gray"> = { const statusTone: Record<Operation["status"], "green" | "red" | "blue" | "amber" | "gray"> = {
@@ -83,20 +84,23 @@ export function OpsTable({ pairId, quarantine }: { pairId?: number; quarantine?:
</Empty> </Empty>
)} )}
{items.map((o) => { {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"); const inQuarantine = !!o.quarantine_path && !o.purged_at && !o.undone_at && (o.status === "done" || o.status === "interrupted");
return ( return (
<div key={o.id} className="flex flex-wrap items-center gap-3 border-b border-line px-4 py-2.5 last:border-0"> <div key={o.id} className="flex flex-wrap items-center gap-3 border-b border-line px-4 py-2.5 last:border-0">
<div className="w-40 shrink-0"> <div className="w-40 shrink-0">
<div className="text-sm">{actionLabel[o.action]}</div> <div className="text-sm">{actionLabel[o.action] ?? o.action}</div>
<div className="text-[11px] text-muted"> <div className="text-[11px] text-muted">
{formatAgo(o.created_at)} · {o.trigger} {formatAgo(o.created_at)} · {o.trigger}
{!pairId && names[o.pair_id] && ` · ${names[o.pair_id]}`} {!pairId && names[o.pair_id] && ` · ${names[o.pair_id]}`}
</div> </div>
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate mono text-[13px]" title={o.rel_slave}> <div className="truncate mono text-[13px]" title={o.rel_slave || o.rel_master}>
{o.rel_slave} {o.action === "remove-folder" && <span className="mr-1.5 text-muted">{o.quarantine_side}:</span>}
{o.rel_slave || o.rel_master}
</div> </div>
{o.rel_new && o.rel_new !== o.rel_slave && o.rel_new !== o.rel_master && ( {o.rel_new && o.rel_new !== o.rel_slave && o.rel_new !== o.rel_master && (
<div className="truncate text-xs text-muted mono">now {o.rel_new}</div> <div className="truncate text-xs text-muted mono">now {o.rel_new}</div>
@@ -104,7 +108,7 @@ export function OpsTable({ pairId, quarantine }: { pairId?: number; quarantine?:
{o.error && <div className="text-xs text-red-300">{o.error}</div>} {o.error && <div className="text-xs text-red-300">{o.error}</div>}
{inQuarantine && ( {inQuarantine && (
<div className="truncate text-xs text-amber-300/80 mono" title={o.quarantine_path}> <div className="truncate text-xs text-amber-300/80 mono" title={o.quarantine_path}>
{o.quarantine_side} original kept in quarantine {o.action === "remove-folder" ? "folder" : `${o.quarantine_side} original`} kept in quarantine
</div> </div>
)} )}
</div> </div>
@@ -145,7 +149,8 @@ export function OpsTable({ pairId, quarantine }: { pairId?: number; quarantine?:
<p className="text-sm text-gray-300"> <p className="text-sm text-gray-300">
{confirm.kind === "purge" ? ( {confirm.kind === "purge" ? (
<> <>
The quarantined {confirm.op.quarantine_side} original of <code className="mono break-all">{confirm.op.rel_slave}</code> will be deleted.{" "} The quarantined {confirm.op.action === "remove-folder" ? "folder" : `${confirm.op.quarantine_side} original of`}{" "}
<code className="mono break-all">{confirm.op.rel_slave || confirm.op.rel_master}</code> will be deleted.{" "}
<b>This cannot be undone.</b> <b>This cannot be undone.</b>
</> </>
) : ( ) : (
@@ -180,5 +185,7 @@ function undoText(o: Operation) {
return "The hardlink is recreated in the slave library."; return "The hardlink is recreated in the slave library.";
case "rename": case "rename":
return `The slave folder is renamed back from "${o.rel_new}" to "${o.rel_slave}", and the library is rescanned.`; 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.`;
} }
} }
+217
View File
@@ -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<Side>("slave");
const [sel, setSel] = useState<Set<string>>(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 (
<div className="space-y-4">
<Card className="space-y-4 p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="max-w-2xl">
<h2 className="flex items-center gap-2 font-semibold">
<FolderX className="size-4 text-accent" /> Folders without music
</h2>
<p className="mt-1 text-sm text-muted">
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.
</p>
</div>
<div className="flex items-center gap-2">
<select className={inputCls + " h-9 w-auto"} value={side} onChange={(e) => setSide(e.target.value as Side)}>
<option value="slave">Slave library</option>
<option value="master">Master library</option>
</select>
<Button variant="primary" onClick={() => find.mutate()} disabled={running} loading={find.isPending}>
<Search className="size-4" /> Find folders
</Button>
</div>
</div>
{ourJob && <JobBar job={pair.job!} />}
</Card>
{scan && !ourJob && (
<Card className="overflow-hidden">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-line px-4 py-3">
<div className="text-sm">
<span className="font-medium">{folders.length}</span> folders without music in the <b>{scan.side}</b> library
<span className="text-muted"> · checked {formatAgo(scan.created_at)}</span>
</div>
{folders.length > 0 && (
<Button variant="danger" disabled={chosen.length === 0 || running} onClick={() => setConfirm(true)}>
<FolderX className="size-4" /> Remove {chosen.length} {chosen.length === 1 ? "folder" : "folders"}…
</Button>
)}
</div>
{folders.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-muted">Every folder in the {scan.side} library contains music.</div>
) : (
<>
<label className="flex items-center gap-2 border-b border-line bg-panel-2 px-4 py-2 text-xs text-muted cursor-pointer">
<input
type="checkbox"
className="accent-violet-500"
checked={allOn}
onChange={() => setSel(allOn ? new Set() : new Set(folders.map((f) => f.rel)))}
/>
Select all · empty folders are selected to start with; folders that still hold files are not
</label>
<div className="max-h-[60vh] divide-y divide-line overflow-y-auto">
{folders.map((f) => (
<label key={f.rel} className="flex items-start gap-3 px-4 py-2 hover:bg-panel-2 cursor-pointer">
<input
type="checkbox"
className="mt-1 accent-violet-500"
checked={sel.has(f.rel)}
onChange={() =>
setSel((s) => {
const n = new Set(s);
n.has(f.rel) ? n.delete(f.rel) : n.add(f.rel);
return n;
})
}
/>
<div className="min-w-0 flex-1">
<div className="mono break-all text-sm text-gray-100">{f.rel}</div>
{f.files > 0 && (
<div className="mono truncate text-xs text-muted" title={(f.sample ?? []).join("\n")}>
{(f.sample ?? []).join(", ")}
{f.files > (f.sample?.length ?? 0) && ` and ${f.files - (f.sample?.length ?? 0)} more`}
</div>
)}
</div>
{f.files === 0 ? (
<Badge>empty</Badge>
) : (
<Badge tone="amber">
{f.files} {f.files === 1 ? "file" : "files"} · {formatBytes(f.bytes)}
</Badge>
)}
</label>
))}
</div>
</>
)}
</Card>
)}
{confirm && scan && (
<ConfirmRemove
pair={pair}
side={scan.side}
folders={chosen}
dry={data.data?.dry_run ?? true}
onClose={() => setConfirm(false)}
onDone={() => {
setConfirm(false);
refresh();
}}
/>
)}
</div>
);
}
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 (
<Modal open onClose={onClose} title={`Remove ${folders.length} ${folders.length === 1 ? "folder" : "folders"}?`}>
<div className="space-y-4 text-sm">
<p className="text-gray-300">
These folders in the <b>{side}</b> 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"}.
</p>
<div className="max-h-48 overflow-y-auto rounded-lg border border-line bg-bg/60 px-3 py-2 mono text-xs text-gray-300">
{folders.map((f) => (
<div key={f.rel} className={cx("truncate", f.files > 0 && "text-amber-200")}>
{f.rel}
{f.files > 0 && ` (${f.files} ${f.files === 1 ? "file" : "files"})`}
</div>
))}
</div>
{withFiles.length > 0 && (
<Alert tone="amber" icon={<AlertTriangle className="mt-0.5 size-4 shrink-0" />}>
<div className="space-y-2">
<div>
{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.
</div>
<Toggle checked={checked} onChange={setChecked} label="I've checked these folders and want them removed" />
</div>
</Alert>
)}
{dry && <Alert tone="blue">Dry run is on: the removals are only recorded in Activity, and no folder moves.</Alert>}
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button variant="danger" disabled={!checked} loading={remove.isPending} onClick={() => remove.mutate()}>
<FolderX className="size-4" /> Remove {folders.length} {folders.length === 1 ? "folder" : "folders"}
</Button>
</div>
</div>
</Modal>
);
}
+4
View File
@@ -10,6 +10,7 @@ import { Review } from "./Review";
import { Build } from "./Build"; import { Build } from "./Build";
import { OpsTable } from "./Activity"; import { OpsTable } from "./Activity";
import { WarningList } from "./Warnings"; import { WarningList } from "./Warnings";
import { Cleanup } from "./Cleanup";
export function usePair(id: number) { export function usePair(id: number) {
return useQuery({ return useQuery({
@@ -38,6 +39,7 @@ function useJobFinished(pair: Pair | undefined) {
qc.invalidateQueries({ queryKey: ["tree"] }); qc.invalidateQueries({ queryKey: ["tree"] });
qc.invalidateQueries({ queryKey: ["ops"] }); qc.invalidateQueries({ queryKey: ["ops"] });
qc.invalidateQueries({ queryKey: ["pairs"] }); qc.invalidateQueries({ queryKey: ["pairs"] });
qc.invalidateQueries({ queryKey: ["empty-folders"] });
const j = pair.job!; const j = pair.job!;
if (st === "done") toast("ok", `${j.kind} finished${j.message ? `: ${j.message}` : ""}`); if (st === "done") toast("ok", `${j.kind} finished${j.message ? `: ${j.message}` : ""}`);
else toast("err", `${j.kind} ${st}: ${j.error}`); else toast("err", `${j.kind} ${st}: ${j.error}`);
@@ -122,6 +124,7 @@ function LastJob({ job }: { job: Job }) {
const tabs = [ const tabs = [
{ id: "review", label: "Review" }, { id: "review", label: "Review" },
{ id: "build", label: "Build" }, { id: "build", label: "Build" },
{ id: "cleanup", label: "Cleanup" },
{ id: "history", label: "History" }, { id: "history", label: "History" },
]; ];
@@ -202,6 +205,7 @@ export function PairPage({ id, tab }: { id: number; tab: string }) {
{tab === "review" && <Review pair={pair} />} {tab === "review" && <Review pair={pair} />}
{tab === "build" && <Build pair={pair} />} {tab === "build" && <Build pair={pair} />}
{tab === "cleanup" && <Cleanup pair={pair} />}
{tab === "history" && <OpsTable pairId={pair.id} />} {tab === "history" && <OpsTable pairId={pair.id} />}
<PairForm pair={pair} open={editing} onClose={() => setEditing(false)} /> <PairForm pair={pair} open={editing} onClose={() => setEditing(false)} />