Add database backups and two-digit disc numbers

Disc numbers: the organizer writes two-digit disc numbers by default
(Disc 01, 01-04 Title, {disc}), with an option for the plain style
(Disc 2, 2-04). Migration 8 adds the per-pair setting.

Backups (Settings -> Backups), similar to Sonarr:
- A backup is a zip with a consistent snapshot of the database (VACUUM
  INTO, safe while running) and backup.json. Configurable folder,
  schedule (off/1/3/7/14 days) and retention for scheduled backups;
  manual backups are kept until deleted. List, download, delete.
- Restore from a listed backup or an uploaded zip: validated first
  (integrity check, playlink tables, not a newer schema), staged next to
  the live database, then the server restarts; at startup the staged
  database is swapped in and the previous one kept as
  playlink.db.pre-restore-<time>. Refused while a job runs. Backup names
  are validated, so downloads/deletes cannot reach other files.
- Optional backup before every action in live mode: once per action the
  user starts (link, promote, bulk, build/remove, tag edit, folder
  renames, undo, purge) and before scans auto-link or auto-organize,
  never per file and never in dry run. If the backup fails the action
  does not run. The newest 10 action backups are kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-29 14:26:34 -07:00
1 parent 96ea856dfd
commit 49fc32096a
18 files changed
+1235 -47

No files matched your search

+19 -2
View File
@@ -42,8 +42,9 @@ type Engine struct {
// the same files.
mu sync.Mutex
tagTools tags.Tools
tagCache tagCache
tagTools tags.Tools
beforeAction func(ctx context.Context, reason string) error
tagCache tagCache
jobsMu sync.Mutex
jobs map[int64]*Job
@@ -358,3 +359,19 @@ func sortedKeys(m map[string]fsops.Info) []string {
sort.Strings(keys)
return keys
}
// SetBeforeAction installs the hook that backs up the database before an
// action in live mode.
func (e *Engine) SetBeforeAction(f func(ctx context.Context, reason string) error) {
e.beforeAction = f
}
// BeforeAction runs the pre-action backup, once per user action (not per
// file). It does nothing in dry run, where no files change. An error means
// the action must not run.
func (e *Engine) BeforeAction(ctx context.Context, reason string) error {
if e.beforeAction == nil || e.st.DryRun(ctx) {
return nil
}
return e.beforeAction(ctx, reason)
}
+37
View File
@@ -528,3 +528,40 @@ func TestPromoteRenamedMasterStaysLinked(t *testing.T) {
t.Fatalf("undo should restore the original match, got %s -> %q", got.Status, got.RelMaster)
}
}
// The pre-action backup runs once per action (not per file) and never in
// dry run.
func TestBeforeActionOncePerScan(t *testing.T) {
ev := setup(t, false)
calls := 0
ev.e.SetBeforeAction(func(context.Context, string) error { calls++; return nil })
ev.pair.AutoLinkIdentical = true
ev.pair.SettleMinutes = 0
ev.st.SavePair(ctx, &ev.pair)
for i := 0; i < 5; i++ {
rel := filepath.Join("A/B", string(rune('a'+i))+".flac")
ev.write(ev.master, rel, "same")
ev.write(ev.slave, rel, "same")
}
ev.scan()
if calls != 1 {
t.Fatalf("auto-linking 5 files should back up once, got %d", calls)
}
ev.st.SetSetting(ctx, "dry_run", "1")
if err := ev.e.BeforeAction(ctx, "link"); err != nil || calls != 1 {
t.Fatal("no backup in dry run")
}
ev.st.SetSetting(ctx, "dry_run", "0")
ev.e.SetBeforeAction(func(context.Context, string) error { return errors.New("disk full") })
ev.write(ev.master, "A/B/z.flac", "same")
ev.write(ev.slave, "A/B/z.flac", "same")
if _, err := ev.e.StartScan(ev.pair.ID, "test"); err != nil {
t.Fatal(err)
}
for ev.e.Job(ev.pair.ID).State == "running" {
time.Sleep(10 * time.Millisecond)
}
if sameInode(t, filepath.Join(ev.master, "A/B/z.flac"), filepath.Join(ev.slave, "A/B/z.flac")) {
t.Fatal("a failed backup must stop the action")
}
}
+19 -5
View File
@@ -76,6 +76,13 @@ func normalizeOrganize(p *store.Pair) error {
default:
return errors.New("multi-disc style must be prefix, folder or none")
}
switch p.OrganizeDiscDigits {
case 0:
p.OrganizeDiscDigits = 2
case 1, 2:
default:
return errors.New("disc numbers must use 1 or 2 digits")
}
switch p.AutoOrganize {
case "":
p.AutoOrganize = "off"
@@ -130,7 +137,11 @@ func leadingNumber(s string) int {
// renderName builds the organized path for a file from its tags. It returns
// the missing tag when a required one is empty.
func renderName(tpl, multiDisc, ext string, f map[string]string) (string, string) {
func renderName(tpl, multiDisc string, discDigits int, ext string, f map[string]string) (string, string) {
if discDigits < 1 {
discDigits = 2
}
discStr := func(d int) string { return fmt.Sprintf("%0*d", discDigits, d) }
artist := f[tags.AlbumArtist]
if strings.TrimSpace(artist) == "" {
artist = f[tags.Artist]
@@ -147,7 +158,7 @@ func renderName(tpl, multiDisc, ext string, f map[string]string) (string, string
}
trackStr = fmt.Sprintf("%0*d", width, track)
if multi && multiDisc == "prefix" && disc > 0 {
trackStr = fmt.Sprintf("%d-%s", disc, trackStr)
trackStr = discStr(disc) + "-" + trackStr
}
}
year := ""
@@ -156,7 +167,7 @@ func renderName(tpl, multiDisc, ext string, f map[string]string) (string, string
}
values := map[string]string{
"artist": artist, "track_artist": f[tags.Artist], "album": f[tags.Album], "track": trackStr,
"title": f[tags.Title], "disc": strconv.Itoa(disc), "year": year, "genre": f[tags.Genre],
"title": f[tags.Title], "disc": discStr(disc), "year": year, "genre": f[tags.Genre],
}
if disc == 0 {
values["disc"] = ""
@@ -188,7 +199,7 @@ func renderName(tpl, multiDisc, ext string, f map[string]string) (string, string
}
if multi && multiDisc == "folder" && disc > 0 {
last := len(parts) - 1
parts = append(parts[:last], fmt.Sprintf("Disc %d", disc), parts[last])
parts = append(parts[:last], "Disc "+discStr(disc), parts[last])
}
return filepath.Join(parts...) + strings.ToLower(ext), ""
}
@@ -293,7 +304,7 @@ func (e *Engine) planOrganize(ctx context.Context, j *Job, l *libs, sides []stri
item.Reason = "file type without editable tags"
} else if fields, err := e.tagCache.get(ctx, e.tagTools, filepath.Join(root.Path, rel), in); err != nil {
item.Reason = "tags could not be read"
} else if to, missing := renderName(l.pair.OrganizeTemplate, l.pair.OrganizeMultiDisc, filepath.Ext(rel), fields); missing != "" {
} else if to, missing := renderName(l.pair.OrganizeTemplate, l.pair.OrganizeMultiDisc, l.pair.OrganizeDiscDigits, filepath.Ext(rel), fields); missing != "" {
item.Reason = "missing " + missing + " tag"
} else if to == rel {
plan.Conforming++
@@ -616,6 +627,9 @@ func (e *Engine) autoOrganize(ctx context.Context, j *Job, l *libs) error {
if len(moves) == 0 {
return nil
}
if err := e.BeforeAction(ctx, "auto-organize"); err != nil {
return err
}
e.log.Info("auto-organizing", "pair", l.pair.Name, "files", len(moves))
return e.applyOrganize(ctx, j, l, moves, "auto-organize")
}
+15 -12
View File
@@ -20,23 +20,26 @@ func TestRenderName(t *testing.T) {
}
cases := []struct {
name, multi, ext string
digits int
fields map[string]string
want, missing string
}{
{"basic", "prefix", ".mp3", f("artist", "Avenged Sevenfold", "album", "Nightmare", "track", "1", "title", "Nightmare"), "Avenged Sevenfold/Nightmare/01 Nightmare.mp3", ""},
{"album artist wins", "prefix", ".flac", f("artist", "Avenged Sevenfold feat. X", "album_artist", "Avenged Sevenfold", "album", "A", "track", "3", "title", "T"), "Avenged Sevenfold/A/03 T.flac", ""},
{"no track number", "prefix", ".mp3", f("artist", "A", "album", "B", "title", "Song"), "A/B/Song.mp3", ""},
{"missing album", "prefix", ".mp3", f("artist", "A", "title", "Song"), "", "album"},
{"missing title", "prefix", ".mp3", f("artist", "A", "album", "B", "track", "1"), "", "title"},
{"multi-disc prefix", "prefix", ".mp3", f("artist", "A", "album", "B", "track", "4", "disc", "2", "disc_total", "2", "title", "S"), "A/B/2-04 S.mp3", ""},
{"multi-disc folder", "folder", ".mp3", f("artist", "A", "album", "B", "track", "4", "disc", "2", "title", "S"), "A/B/Disc 2/04 S.mp3", ""},
{"single disc of one", "prefix", ".mp3", f("artist", "A", "album", "B", "track", "4", "disc", "1", "disc_total", "1", "title", "S"), "A/B/04 S.mp3", ""},
{"unsafe characters", "prefix", ".MP3", f("artist", "AC/DC", "album", "Who Made Who?", "track", "7", "title", "Title: Sub \"Live\"..."), "AC-DC/Who Made Who/07 Title - Sub 'Live'.mp3", ""},
{"no hidden names", "prefix", ".mp3", f("artist", ".hidden", "album", "B", "title", "S"), "hidden/B/S.mp3", ""},
{"big albums use 3 digits", "prefix", ".mp3", f("artist", "A", "album", "B", "track", "7", "track_total", "120", "title", "S"), "A/B/007 S.mp3", ""},
{"basic", "prefix", ".mp3", 0, f("artist", "Avenged Sevenfold", "album", "Nightmare", "track", "1", "title", "Nightmare"), "Avenged Sevenfold/Nightmare/01 Nightmare.mp3", ""},
{"album artist wins", "prefix", ".flac", 0, f("artist", "Avenged Sevenfold feat. X", "album_artist", "Avenged Sevenfold", "album", "A", "track", "3", "title", "T"), "Avenged Sevenfold/A/03 T.flac", ""},
{"no track number", "prefix", ".mp3", 0, f("artist", "A", "album", "B", "title", "Song"), "A/B/Song.mp3", ""},
{"missing album", "prefix", ".mp3", 0, f("artist", "A", "title", "Song"), "", "album"},
{"missing title", "prefix", ".mp3", 0, f("artist", "A", "album", "B", "track", "1"), "", "title"},
{"multi-disc prefix", "prefix", ".mp3", 0, f("artist", "A", "album", "B", "track", "4", "disc", "2", "disc_total", "2", "title", "S"), "A/B/02-04 S.mp3", ""},
{"multi-disc folder", "folder", ".mp3", 0, f("artist", "A", "album", "B", "track", "4", "disc", "2", "title", "S"), "A/B/Disc 02/04 S.mp3", ""},
{"single disc of one", "prefix", ".mp3", 0, f("artist", "A", "album", "B", "track", "4", "disc", "1", "disc_total", "1", "title", "S"), "A/B/04 S.mp3", ""},
{"unsafe characters", "prefix", ".MP3", 0, f("artist", "AC/DC", "album", "Who Made Who?", "track", "7", "title", "Title: Sub \"Live\"..."), "AC-DC/Who Made Who/07 Title - Sub 'Live'.mp3", ""},
{"no hidden names", "prefix", ".mp3", 0, f("artist", ".hidden", "album", "B", "title", "S"), "hidden/B/S.mp3", ""},
{"unpadded disc prefix", "prefix", ".mp3", 1, f("artist", "A", "album", "B", "track", "4", "disc", "2", "disc_total", "2", "title", "S"), "A/B/2-04 S.mp3", ""},
{"unpadded disc folder", "folder", ".mp3", 1, f("artist", "A", "album", "B", "track", "4", "disc", "2", "title", "S"), "A/B/Disc 2/04 S.mp3", ""},
{"big albums use 3 digits", "prefix", ".mp3", 0, f("artist", "A", "album", "B", "track", "7", "track_total", "120", "title", "S"), "A/B/007 S.mp3", ""},
}
for _, c := range cases {
got, missing := renderName(DefaultOrganizeTemplate, c.multi, c.ext, c.fields)
got, missing := renderName(DefaultOrganizeTemplate, c.multi, c.digits, c.ext, c.fields)
if got != c.want || missing != c.missing {
t.Errorf("%s: got %q (missing %q), want %q (missing %q)", c.name, got, missing, c.want, c.missing)
}
+3
View File
@@ -395,6 +395,9 @@ func (e *Engine) autoLink(ctx context.Context, j *Job, l *libs, trigger string)
})
return nil
}
if err := e.BeforeAction(ctx, "auto-link"); err != nil {
return err
}
return e.linkIDs(ctx, j, l.pair.ID, ids, "auto:"+trigger, true)
}