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

+434
View File
@@ -0,0 +1,434 @@
// Package backup makes, lists and restores backups of playlink's database
// (settings, library pairs, scan results and history, not music files).
//
// A backup is a zip holding a consistent snapshot of the database (SQLite
// "VACUUM INTO", safe while the app runs) and a small backup.json. Restoring
// stages the database next to the live one; it is swapped in at the next
// start (see ApplyStaged), and the replaced database is kept.
package backup
import (
"archive/zip"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/cdnutter/playlink/internal/store"
)
const (
TypeManual = "manual"
TypeScheduled = "scheduled"
TypeAction = "action" // taken automatically before an action in live mode
dbName = "playlink.db"
stagedName = "playlink.db.restore"
metaName = "backup.json"
keepActions = 10
)
var nameRe = regexp.MustCompile(`^playlink_backup_(manual|scheduled|action)_\d{4}\.\d{2}\.\d{2}_\d{2}\.\d{2}\.\d{2}(_\d+)?\.zip$`)
var ErrBadName = errors.New("not a playlink backup file name")
// Settings stored in the settings table.
const (
KeyFolder = "backup_folder"
KeyIntervalDays = "backup_interval_days"
KeyRetentionDays = "backup_retention_days"
KeyBeforeActions = "backup_before_actions"
)
type Manager struct {
st *store.Store
dataDir string
version string
log *slog.Logger
mu sync.Mutex // one backup or restore at a time
}
func New(st *store.Store, dataDir, version string, log *slog.Logger) *Manager {
return &Manager{st: st, dataDir: dataDir, version: version, log: log}
}
// Config is the user-facing backup configuration.
type Config struct {
Folder string `json:"folder"`
IntervalDays int `json:"interval_days"` // 0 = scheduled backups off
RetentionDays int `json:"retention_days"` // scheduled backups older than this are removed
BeforeActions bool `json:"before_actions"`
}
func (m *Manager) Config(ctx context.Context) Config {
c := Config{
Folder: m.st.Setting(ctx, KeyFolder, filepath.Join(m.dataDir, "backups")),
BeforeActions: m.st.Setting(ctx, KeyBeforeActions, "0") == "1",
}
c.IntervalDays, _ = strconv.Atoi(m.st.Setting(ctx, KeyIntervalDays, "7"))
c.RetentionDays, _ = strconv.Atoi(m.st.Setting(ctx, KeyRetentionDays, "28"))
return c
}
func (m *Manager) SetConfig(ctx context.Context, c Config) error {
c.Folder = filepath.Clean(strings.TrimSpace(c.Folder))
if !filepath.IsAbs(c.Folder) {
return errors.New("the backup folder must be an absolute path")
}
if c.IntervalDays < 0 || c.IntervalDays > 365 || c.RetentionDays < 1 || c.RetentionDays > 3650 {
return errors.New("interval must be 0-365 days and retention 1-3650 days")
}
if err := os.MkdirAll(c.Folder, 0o750); err != nil {
return fmt.Errorf("backup folder: %w", err)
}
probe := filepath.Join(c.Folder, ".playlink-write-test")
if err := os.WriteFile(probe, nil, 0o600); err != nil {
return fmt.Errorf("backup folder is not writable: %w", err)
}
_ = os.Remove(probe)
before := "0"
if c.BeforeActions {
before = "1"
}
for k, v := range map[string]string{KeyFolder: c.Folder, KeyIntervalDays: strconv.Itoa(c.IntervalDays),
KeyRetentionDays: strconv.Itoa(c.RetentionDays), KeyBeforeActions: before} {
if err := m.st.SetSetting(ctx, k, v); err != nil {
return err
}
}
return nil
}
// Backup is one backup file.
type Backup struct {
Name string `json:"name"`
Type string `json:"type"`
Size int64 `json:"size"`
Created int64 `json:"created"`
}
type meta struct {
App string `json:"app"`
Version string `json:"version"`
Schema int `json:"schema"`
Type string `json:"type"`
Reason string `json:"reason,omitempty"`
Created int64 `json:"created"`
}
// Create writes a new backup and applies retention.
func (m *Manager) Create(ctx context.Context, typ, reason string) (Backup, error) {
m.mu.Lock()
defer m.mu.Unlock()
cfg := m.Config(ctx)
if err := os.MkdirAll(cfg.Folder, 0o750); err != nil {
return Backup{}, err
}
now := time.Now()
name := fmt.Sprintf("playlink_backup_%s_%s.zip", typ, now.Format("2006.01.02_15.04.05"))
for i := 2; fileExists(filepath.Join(cfg.Folder, name)); i++ {
name = fmt.Sprintf("playlink_backup_%s_%s_%d.zip", typ, now.Format("2006.01.02_15.04.05"), i)
}
snap := filepath.Join(cfg.Folder, ".snapshot-"+strconv.FormatInt(now.UnixNano(), 10)+".db")
defer os.Remove(snap)
if _, err := m.st.DB.ExecContext(ctx, `VACUUM INTO ?`, snap); err != nil {
return Backup{}, fmt.Errorf("snapshot: %w", err)
}
var schema int
_ = m.st.DB.QueryRowContext(ctx, `SELECT COALESCE(MAX(v), 0) FROM schema_version`).Scan(&schema)
tmp := filepath.Join(cfg.Folder, "."+name+".tmp")
if err := writeZip(tmp, snap, meta{App: "playlink", Version: m.version, Schema: schema, Type: typ, Reason: reason, Created: now.Unix()}); err != nil {
_ = os.Remove(tmp)
return Backup{}, err
}
final := filepath.Join(cfg.Folder, name)
if err := os.Rename(tmp, final); err != nil {
_ = os.Remove(tmp)
return Backup{}, err
}
fi, _ := os.Stat(final)
m.log.Info("backup created", "name", name, "reason", reason)
m.prune(cfg)
return Backup{Name: name, Type: typ, Size: fi.Size(), Created: now.Unix()}, nil
}
func writeZip(path, dbFile string, md meta) error {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o640)
if err != nil {
return err
}
zw := zip.NewWriter(f)
fail := func(e error) error { zw.Close(); f.Close(); return e }
w, err := zw.Create(dbName)
if err != nil {
return fail(err)
}
src, err := os.Open(dbFile)
if err != nil {
return fail(err)
}
_, err = io.Copy(w, src)
src.Close()
if err != nil {
return fail(err)
}
w, err = zw.Create(metaName)
if err != nil {
return fail(err)
}
if err := json.NewEncoder(w).Encode(md); err != nil {
return fail(err)
}
if err := zw.Close(); err != nil {
f.Close()
return err
}
if err := f.Sync(); err != nil {
f.Close()
return err
}
return f.Close()
}
func fileExists(p string) bool { _, err := os.Stat(p); return err == nil }
// List returns backups, newest first.
func (m *Manager) List(ctx context.Context) ([]Backup, error) {
cfg := m.Config(ctx)
ents, err := os.ReadDir(cfg.Folder)
if errors.Is(err, os.ErrNotExist) {
return []Backup{}, nil
}
if err != nil {
return nil, err
}
out := []Backup{}
for _, en := range ents {
sub := nameRe.FindStringSubmatch(en.Name())
if sub == nil || !en.Type().IsRegular() {
continue
}
fi, err := en.Info()
if err != nil {
continue
}
out = append(out, Backup{Name: en.Name(), Type: sub[1], Size: fi.Size(), Created: createdOf(en.Name(), fi.ModTime())})
}
sort.Slice(out, func(i, j int) bool {
return out[i].Created > out[j].Created || (out[i].Created == out[j].Created && out[i].Name > out[j].Name)
})
return out, nil
}
var stampRe = regexp.MustCompile(`\d{4}\.\d{2}\.\d{2}_\d{2}\.\d{2}\.\d{2}`)
func createdOf(name string, fallback time.Time) int64 {
if t, err := time.ParseInLocation("2006.01.02_15.04.05", stampRe.FindString(name), time.Local); err == nil {
return t.Unix()
}
return fallback.Unix()
}
// Path returns the absolute path of a backup, refusing anything that is not
// a backup file name (no path traversal).
func (m *Manager) Path(ctx context.Context, name string) (string, error) {
if !nameRe.MatchString(name) {
return "", ErrBadName
}
p := filepath.Join(m.Config(ctx).Folder, name)
if !fileExists(p) {
return "", os.ErrNotExist
}
return p, nil
}
func (m *Manager) Delete(ctx context.Context, name string) error {
p, err := m.Path(ctx, name)
if err != nil {
return err
}
return os.Remove(p)
}
// prune removes scheduled backups older than the retention period and all
// but the newest action backups. Manual backups are kept until deleted.
func (m *Manager) prune(cfg Config) {
list, err := m.List(context.Background())
if err != nil {
return
}
cutoff := time.Now().Add(-time.Duration(cfg.RetentionDays) * 24 * time.Hour).Unix()
actions := 0
for _, b := range list { // newest first
remove := false
switch b.Type {
case TypeScheduled:
remove = b.Created < cutoff
case TypeAction:
actions++
remove = actions > keepActions
}
if remove {
if err := os.Remove(filepath.Join(cfg.Folder, b.Name)); err == nil {
m.log.Info("old backup removed", "name", b.Name)
}
}
}
}
// Due makes a scheduled backup when the interval has passed.
func (m *Manager) Due(ctx context.Context) {
cfg := m.Config(ctx)
if cfg.IntervalDays <= 0 {
return
}
list, err := m.List(ctx)
if err != nil {
return
}
for _, b := range list {
if b.Type == TypeScheduled {
if time.Since(time.Unix(b.Created, 0)) < time.Duration(cfg.IntervalDays)*24*time.Hour {
return
}
break
}
}
if _, err := m.Create(ctx, TypeScheduled, "schedule"); err != nil {
m.log.Warn("scheduled backup failed", "err", err)
}
}
// BeforeAction makes an action backup when that option is on. An error means
// the action must not run.
func (m *Manager) BeforeAction(ctx context.Context, reason string) error {
if !m.Config(ctx).BeforeActions {
return nil
}
if _, err := m.Create(ctx, TypeAction, reason); err != nil {
return fmt.Errorf("the backup before this action failed, so the action was not started: %w", err)
}
return nil
}
// ---- restore ----
// Stage validates a backup zip and places its database next to the live one,
// to be swapped in at the next start.
func (m *Manager) Stage(ctx context.Context, zipPath string, maxSchema int) error {
m.mu.Lock()
defer m.mu.Unlock()
zr, err := zip.OpenReader(zipPath)
if err != nil {
return fmt.Errorf("not a valid backup zip: %w", err)
}
defer zr.Close()
var dbf *zip.File
for _, f := range zr.File {
if f.Name == dbName {
dbf = f
}
}
if dbf == nil {
return errors.New("the zip does not contain a playlink database")
}
tmp := filepath.Join(m.dataDir, stagedName+".tmp")
_ = os.Remove(tmp)
defer os.Remove(tmp)
if err := extract(dbf, tmp); err != nil {
return err
}
if err := validate(ctx, tmp, maxSchema); err != nil {
return err
}
if err := os.Rename(tmp, filepath.Join(m.dataDir, stagedName)); err != nil {
return err
}
m.log.Warn("backup staged for restore; restarting to apply it", "from", filepath.Base(zipPath))
return nil
}
func extract(f *zip.File, dst string) error {
rc, err := f.Open()
if err != nil {
return err
}
defer rc.Close()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o640)
if err != nil {
return err
}
// Cap the size so a hostile zip cannot fill the disk.
if _, err := io.Copy(out, io.LimitReader(rc, 20<<30)); err != nil {
out.Close()
return err
}
if err := out.Sync(); err != nil {
out.Close()
return err
}
return out.Close()
}
// validate opens a database file read-only and checks it is an intact
// playlink database this version can use.
func validate(ctx context.Context, path string, maxSchema int) error {
db, err := sql.Open("sqlite", "file:"+path+"?mode=ro")
if err != nil {
return err
}
defer db.Close()
var ok string
if err := db.QueryRowContext(ctx, `PRAGMA integrity_check`).Scan(&ok); err != nil || ok != "ok" {
return fmt.Errorf("the backup database is damaged (%v %s)", err, ok)
}
var schema int
if err := db.QueryRowContext(ctx, `SELECT COALESCE(MAX(v), 0) FROM schema_version`).Scan(&schema); err != nil {
return errors.New("the backup is not a playlink database")
}
if schema < 1 || schema > maxSchema {
return fmt.Errorf("the backup is from a newer version of playlink (schema %d, this version supports up to %d)", schema, maxSchema)
}
var n int
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN ('pairs', 'users', 'operations')`).Scan(&n); err != nil || n != 3 {
return errors.New("the backup is not a playlink database")
}
return nil
}
// ApplyStaged swaps a staged restore in before the database is opened. The
// database it replaces is kept as playlink.db.pre-restore-<time>.
func ApplyStaged(dataDir string, log *slog.Logger) error {
staged := filepath.Join(dataDir, stagedName)
if !fileExists(staged) {
return nil
}
live := filepath.Join(dataDir, dbName)
keep := live + ".pre-restore-" + time.Now().Format("2006.01.02_15.04.05")
if fileExists(live) {
if err := os.Rename(live, keep); err != nil {
return err
}
for _, sfx := range []string{"-wal", "-shm"} {
if fileExists(live + sfx) {
_ = os.Rename(live+sfx, keep+sfx)
}
}
}
if err := os.Rename(staged, live); err != nil {
return err
}
log.Warn("database restored from backup", "previous database kept as", filepath.Base(keep))
return nil
}
+170
View File
@@ -0,0 +1,170 @@
package backup
import (
"archive/zip"
"context"
"errors"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/cdnutter/playlink/internal/store"
)
var ctx = context.Background()
func setup(t *testing.T) (*store.Store, *Manager, string) {
t.Helper()
dir := t.TempDir()
st, err := store.Open(dir)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.DB.Close() })
return st, New(st, dir, "test", slog.New(slog.NewTextHandler(io.Discard, nil))), dir
}
func TestCreateListDownloadPath(t *testing.T) {
st, m, _ := setup(t)
st.SetSetting(ctx, "marker", "before")
b, err := m.Create(ctx, TypeManual, "manual")
if err != nil {
t.Fatal(err)
}
list, _ := m.List(ctx)
if len(list) != 1 || list[0].Name != b.Name || list[0].Type != TypeManual || list[0].Size == 0 {
t.Fatalf("list: %+v", list)
}
p, err := m.Path(ctx, b.Name)
if err != nil {
t.Fatal(err)
}
zr, err := zip.OpenReader(p)
if err != nil {
t.Fatal(err)
}
names := []string{}
for _, f := range zr.File {
names = append(names, f.Name)
}
zr.Close()
if strings.Join(names, ",") != "playlink.db,backup.json" {
t.Fatalf("zip contents: %v", names)
}
for _, bad := range []string{"../playlink.db", "evil.zip", "playlink_backup_manual_x.zip", "playlink_backup_manual_2026.01.01_00.00.00.zip/../../x"} {
if _, err := m.Path(ctx, bad); !errors.Is(err, ErrBadName) {
t.Errorf("%q must be refused, got %v", bad, err)
}
}
}
func TestRestoreRoundTrip(t *testing.T) {
st, m, dir := setup(t)
st.SetSetting(ctx, "marker", "before")
b, _ := m.Create(ctx, TypeManual, "manual")
st.SetSetting(ctx, "marker", "after")
p, _ := m.Path(ctx, b.Name)
if err := m.Stage(ctx, p, store.SchemaVersion()); err != nil {
t.Fatal(err)
}
st.DB.Close()
if err := ApplyStaged(dir, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
t.Fatal(err)
}
st2, err := store.Open(dir)
if err != nil {
t.Fatal(err)
}
defer st2.DB.Close()
if got := st2.Setting(ctx, "marker", ""); got != "before" {
t.Fatalf("restore should bring back the old value, got %q", got)
}
kept, _ := filepath.Glob(filepath.Join(dir, "playlink.db.pre-restore-*"))
if len(kept) == 0 {
t.Fatal("the replaced database must be kept")
}
}
func TestStageRejectsBadBackups(t *testing.T) {
_, m, dir := setup(t)
notZip := filepath.Join(dir, "x.zip")
os.WriteFile(notZip, []byte("nope"), 0o644)
if err := m.Stage(ctx, notZip, store.SchemaVersion()); err == nil {
t.Error("a non-zip must be rejected")
}
mk := func(name string, content []byte) string {
p := filepath.Join(dir, name)
f, _ := os.Create(p)
zw := zip.NewWriter(f)
w, _ := zw.Create("playlink.db")
w.Write(content)
zw.Close()
f.Close()
return p
}
if err := m.Stage(ctx, mk("junk.zip", []byte("not a database")), store.SchemaVersion()); err == nil {
t.Error("a damaged database must be rejected")
}
b, _ := m.Create(ctx, TypeManual, "manual")
p, _ := m.Path(ctx, b.Name)
if err := m.Stage(ctx, p, 1); err == nil || !strings.Contains(err.Error(), "newer version") {
t.Errorf("a backup from a newer schema must be rejected, got %v", err)
}
if _, err := os.Stat(filepath.Join(dir, stagedName)); err == nil {
t.Error("nothing may be staged after a rejected backup")
}
}
func TestRetentionScheduleAndBeforeAction(t *testing.T) {
_, m, _ := setup(t)
cfg := m.Config(ctx)
if cfg.IntervalDays != 7 || cfg.RetentionDays != 28 || cfg.BeforeActions {
t.Fatalf("defaults: %+v", cfg)
}
// Off by default: no backup before actions.
if err := m.BeforeAction(ctx, "link"); err != nil {
t.Fatal(err)
}
if l, _ := m.List(ctx); len(l) != 0 {
t.Fatal("no action backup while the option is off")
}
cfg.BeforeActions = true
if err := m.SetConfig(ctx, cfg); err != nil {
t.Fatal(err)
}
for i := 0; i < 12; i++ {
if err := m.BeforeAction(ctx, "link"); err != nil {
t.Fatal(err)
}
}
l, _ := m.List(ctx)
if len(l) != keepActions {
t.Fatalf("only the newest %d action backups are kept, got %d", keepActions, len(l))
}
// An old scheduled backup is removed by retention; Due makes a new one.
old := filepath.Join(cfg.Folder, "playlink_backup_scheduled_"+time.Now().AddDate(0, 0, -60).Format("2006.01.02_15.04.05")+".zip")
os.WriteFile(old, []byte("x"), 0o644)
m.Due(ctx)
if _, err := os.Stat(old); !errors.Is(err, os.ErrNotExist) {
t.Error("scheduled backups past retention must be removed")
}
sched := 0
l, _ = m.List(ctx)
for _, b := range l {
if b.Type == TypeScheduled {
sched++
}
}
m.Due(ctx) // not due again yet
l2, _ := m.List(ctx)
if sched != 1 || len(l2) != len(l) {
t.Fatalf("want exactly one scheduled backup, got %d (then %d total vs %d)", sched, len(l2), len(l))
}
if err := m.SetConfig(ctx, Config{Folder: "relative/path", IntervalDays: 1, RetentionDays: 1}); err == nil {
t.Error("a relative backup folder must be rejected")
}
}