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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+12
-1
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
@@ -37,6 +37,7 @@ const actionLabel: Record<Operation["action"], string> = {
|
||||
build: "Built into slave",
|
||||
remove: "Removed from slave",
|
||||
rename: "Renamed slave folder",
|
||||
"remove-folder": "Removed folder without music",
|
||||
};
|
||||
|
||||
const statusTone: Record<Operation["status"], "green" | "red" | "blue" | "amber" | "gray"> = {
|
||||
@@ -83,20 +84,23 @@ export function OpsTable({ pairId, quarantine }: { pairId?: number; quarantine?:
|
||||
</Empty>
|
||||
)}
|
||||
{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 (
|
||||
<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="text-sm">{actionLabel[o.action]}</div>
|
||||
<div className="text-sm">{actionLabel[o.action] ?? o.action}</div>
|
||||
<div className="text-[11px] text-muted">
|
||||
{formatAgo(o.created_at)} · {o.trigger}
|
||||
{!pairId && names[o.pair_id] && ` · ${names[o.pair_id]}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate mono text-[13px]" title={o.rel_slave}>
|
||||
{o.rel_slave}
|
||||
<div className="truncate mono text-[13px]" title={o.rel_slave || o.rel_master}>
|
||||
{o.action === "remove-folder" && <span className="mr-1.5 text-muted">{o.quarantine_side}:</span>}
|
||||
{o.rel_slave || o.rel_master}
|
||||
</div>
|
||||
{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>
|
||||
@@ -104,7 +108,7 @@ export function OpsTable({ pairId, quarantine }: { pairId?: number; quarantine?:
|
||||
{o.error && <div className="text-xs text-red-300">{o.error}</div>}
|
||||
{inQuarantine && (
|
||||
<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>
|
||||
@@ -145,7 +149,8 @@ export function OpsTable({ pairId, quarantine }: { pairId?: number; quarantine?:
|
||||
<p className="text-sm text-gray-300">
|
||||
{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>
|
||||
</>
|
||||
) : (
|
||||
@@ -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.`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" && <Review pair={pair} />}
|
||||
{tab === "build" && <Build pair={pair} />}
|
||||
{tab === "cleanup" && <Cleanup pair={pair} />}
|
||||
{tab === "history" && <OpsTable pairId={pair.id} />}
|
||||
|
||||
<PairForm pair={pair} open={editing} onClose={() => setEditing(false)} />
|
||||
|
||||
Reference in new issue
Block a user