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 -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")
}