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:
1 parent
96ea856dfd
commit
49fc32096a
18 files changed
+1235
-47
No files matched your search
@@ -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")
|
||||
}
|
||||
Reference in new issue
Block a user