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
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user