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

+5
View File
@@ -79,6 +79,7 @@ export type Pair = {
organize_template: string;
organize_multidisc: "prefix" | "folder" | "none";
auto_organize: "off" | "slave" | "master" | "both";
organize_disc_digits: number;
last_scan_at: number | null;
last_scan_error: string;
last_scan_warnings: ScanWarning[];
@@ -102,6 +103,7 @@ export type PairInput = Pick<
| "organize_template"
| "organize_multidisc"
| "auto_organize"
| "organize_disc_digits"
>;
export type ProbeInfo = {
@@ -240,3 +242,6 @@ export type TagFile = {
links: number;
size: number;
};
export type BackupConfig = { folder: string; interval_days: number; retention_days: number; before_actions: boolean };
export type Backup = { name: string; type: "manual" | "scheduled" | "action"; size: number; created: number };
+250
View File
@@ -0,0 +1,250 @@
import { useEffect, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Archive, Download, RotateCcw, Trash2, Upload } from "lucide-react";
import { api, type Backup, type BackupConfig } from "../api";
import { Alert, Badge, Button, Card, Field, Modal, Spinner, Toggle, formatAgo, formatBytes, inputCls } from "../ui";
import { useToast } from "../toast";
const typeTone = { manual: "violet", scheduled: "blue", action: "gray" } as const;
const typeLabel = { manual: "manual", scheduled: "scheduled", action: "before action" } as const;
export function BackupsCard() {
const qc = useQueryClient();
const toast = useToast();
const data = useQuery({ queryKey: ["backups"], queryFn: () => api.get<{ config: BackupConfig; backups: Backup[] }>("/backups") });
const [cfg, setCfg] = useState<BackupConfig | null>(null);
const [restore, setRestore] = useState<{ name?: string; file?: File } | null>(null);
const [del, setDel] = useState<Backup | null>(null);
const [restarting, setRestarting] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (data.data) setCfg(data.data.config);
}, [data.data]);
const saved = data.data?.config;
const dirty = cfg && saved && JSON.stringify(cfg) !== JSON.stringify(saved);
const saveCfg = useMutation({
mutationFn: () => api.put<BackupConfig>("/backups/config", cfg),
onSuccess: () => {
toast("ok", "Backup settings saved");
qc.invalidateQueries({ queryKey: ["backups"] });
},
onError: (e) => toast("err", e.message),
});
const create = useMutation({
mutationFn: () => api.post<Backup>("/backups"),
onSuccess: (b) => {
toast("ok", `Backup created: ${b.name}`);
qc.invalidateQueries({ queryKey: ["backups"] });
},
onError: (e) => toast("err", e.message),
});
const remove = useMutation({
mutationFn: (b: Backup) => api.del(`/backups/${encodeURIComponent(b.name)}`),
onSuccess: () => {
setDel(null);
qc.invalidateQueries({ queryKey: ["backups"] });
},
onError: (e) => toast("err", e.message),
});
const doRestore = useMutation({
mutationFn: async () => {
if (restore?.name) return api.post(`/backups/${encodeURIComponent(restore.name)}/restore`);
const form = new FormData();
form.append("backup", restore!.file!);
const res = await fetch("/api/backups/restore-upload", {
method: "POST",
credentials: "same-origin",
headers: { "X-Playlink-Request": "1" },
body: form,
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? res.statusText);
},
onSuccess: () => {
setRestore(null);
setRestarting(true);
},
onError: (e) => toast("err", e.message),
});
return (
<Card className="space-y-4 p-5">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="flex items-center gap-2 font-semibold">
<Archive className="size-4 text-accent" /> Backups
</h2>
<p className="text-sm text-muted">
Backups hold playlink's database: settings, library pairs, scan results and history. They do <b>not</b> contain music files; file
changes are undone from History.
</p>
</div>
<Button size="sm" variant="primary" onClick={() => create.mutate()} loading={create.isPending}>
Back up now
</Button>
</div>
{!cfg && <Spinner />}
{cfg && (
<>
<div className="grid gap-4 md:grid-cols-3">
<Field label="Backup folder" hint="Inside the container, e.g. /config/backups">
<input className={inputCls + " mono text-xs"} value={cfg.folder} onChange={(e) => setCfg({ ...cfg, folder: e.target.value })} />
</Field>
<Field label="Scheduled backups">
<select className={inputCls} value={cfg.interval_days} onChange={(e) => setCfg({ ...cfg, interval_days: Number(e.target.value) })}>
<option value={0}>Off</option>
<option value={1}>Every day</option>
<option value={3}>Every 3 days</option>
<option value={7}>Every 7 days</option>
<option value={14}>Every 14 days</option>
</select>
</Field>
<Field label="Retention (days)" hint="For scheduled backups. Manual ones are kept until deleted.">
<input className={inputCls} type="number" min={1} value={cfg.retention_days} onChange={(e) => setCfg({ ...cfg, retention_days: Number(e.target.value) })} />
</Field>
</div>
<div className="rounded-lg border border-line bg-panel-2 p-3.5">
<Toggle
checked={cfg.before_actions}
onChange={(v) => setCfg({ ...cfg, before_actions: v })}
label={<span className="font-medium">Back up before every action in live mode</span>}
/>
<p className="mt-1.5 pl-11.5 text-xs text-muted">
One backup per action you start (a link, a bulk action, a tag edit, renaming folders, an undo…) and before scans change files, not one
per file. If the backup fails, the action doesn't run. The newest 10 are kept.
</p>
</div>
{dirty && (
<div className="flex justify-end">
<Button size="sm" variant="primary" onClick={() => saveCfg.mutate()} loading={saveCfg.isPending}>
Save backup settings
</Button>
</div>
)}
</>
)}
<div className="overflow-hidden rounded-lg border border-line">
{data.data?.backups.length === 0 && <div className="p-4 text-center text-sm text-muted">No backups yet.</div>}
{data.data?.backups.map((b) => (
<div key={b.name} className="flex flex-wrap items-center gap-3 border-b border-line px-3 py-2 last:border-0">
<Badge tone={typeTone[b.type]}>{typeLabel[b.type]}</Badge>
<div className="min-w-0 flex-1">
<div className="truncate text-sm mono" title={b.name}>
{b.name}
</div>
<div className="text-xs text-muted">
{new Date(b.created * 1000).toLocaleString()} · {formatAgo(b.created)} · {formatBytes(b.size)}
</div>
</div>
<a
className="inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs text-gray-300 hover:bg-panel-2"
href={`/api/backups/${encodeURIComponent(b.name)}/download`}
download
>
<Download className="size-3.5" /> Download
</a>
<Button size="sm" onClick={() => setRestore({ name: b.name })}>
<RotateCcw className="size-3.5" /> Restore
</Button>
<Button size="sm" variant="ghost" onClick={() => setDel(b)} title="Delete backup">
<Trash2 className="size-3.5" />
</Button>
</div>
))}
</div>
<div className="flex items-center gap-2 text-xs text-muted">
<input
ref={fileRef}
type="file"
accept=".zip,application/zip"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) setRestore({ file: f });
e.target.value = "";
}}
/>
<Button size="sm" variant="ghost" onClick={() => fileRef.current?.click()}>
<Upload className="size-3.5" /> Restore from a file…
</Button>
a backup .zip downloaded earlier
</div>
<Modal open={!!restore} onClose={() => setRestore(null)} title="Restore this backup?">
<div className="space-y-3 text-sm text-gray-300">
<p>
playlink's database is replaced with <code className="mono break-all">{restore?.name ?? restore?.file?.name}</code>: settings, library
pairs, scan results and history go back to how they were then.
</p>
<ul className="list-disc space-y-1 pl-5 text-xs text-muted">
<li>The backup is checked first; a damaged backup or one from a newer playlink version is refused.</li>
<li>The current database is kept next to it (playlink.db.pre-restore-…), so this can be reversed by hand.</li>
<li>Music files are not changed. History entries made after the backup are no longer listed, so undo them first if needed.</li>
<li>
playlink restarts to finish. In Docker it comes back by itself; if you run it directly, start it again.
</li>
</ul>
</div>
<div className="mt-5 flex justify-end gap-2">
<Button variant="ghost" onClick={() => setRestore(null)}>
Cancel
</Button>
<Button variant="warn" loading={doRestore.isPending} onClick={() => doRestore.mutate()}>
Restore and restart
</Button>
</div>
</Modal>
<Modal open={!!del} onClose={() => setDel(null)} title="Delete backup?">
<p className="text-sm text-gray-300">
<code className="mono break-all">{del?.name}</code> is deleted permanently.
</p>
<div className="mt-5 flex justify-end gap-2">
<Button variant="ghost" onClick={() => setDel(null)}>
Cancel
</Button>
<Button variant="danger" loading={remove.isPending} onClick={() => del && remove.mutate(del)}>
Delete
</Button>
</div>
</Modal>
{restarting && <Restarting />}
</Card>
);
}
/** Waits for playlink to come back after a restore, then reloads the page. */
function Restarting() {
const [slow, setSlow] = useState(false);
useEffect(() => {
const started = Date.now();
let wentDown = false;
const t = setInterval(async () => {
try {
const res = await fetch("/healthz", { cache: "no-store" });
if (res.ok && (wentDown || Date.now() - started > 5000)) window.location.reload();
if (!res.ok) wentDown = true;
} catch {
wentDown = true;
}
if (Date.now() - started > 60000) setSlow(true);
}, 1000);
return () => clearInterval(t);
}, []);
return (
<Modal open onClose={() => {}} title="Restoring backup…">
<div className="flex items-center gap-3 text-sm text-gray-300">
<Spinner /> playlink is restarting with the restored database. This page reloads when it is back.
</div>
{slow && (
<div className="mt-3">
<Alert tone="amber">
playlink hasn't come back yet. If you run it without Docker (or without a restart policy), start it again; the restore is applied when
it starts.
</Alert>
</div>
)}
</Modal>
);
}
+21 -10
View File
@@ -29,12 +29,13 @@ const SAMPLE: Record<string, string> = {
};
/** Renders the pattern for a sample song; disc 2 shows the multi-disc style. */
function example(tpl: string, multi?: Pair["organize_multidisc"]) {
function example(tpl: string, multi?: Pair["organize_multidisc"], digits = 2) {
const d = digits === 1 ? "2" : "02";
const values: Record<string, string> = multi
? { ...SAMPLE, track: multi === "prefix" ? "2-04" : "04", title: "Victim", disc: "2" }
: SAMPLE;
? { ...SAMPLE, track: multi === "prefix" ? `${d}-04` : "04", title: "Victim", disc: d }
: { ...SAMPLE, disc: digits === 1 ? "1" : "01" };
let parts = tpl.split("/").map((part) => part.replace(/\{([a-z_]+)\}/g, (_, k: string) => values[k] ?? `{${k}}`));
if (multi === "folder") parts = [...parts.slice(0, -1), "Disc 2", parts[parts.length - 1]];
if (multi === "folder") parts = [...parts.slice(0, -1), `Disc ${d}`, parts[parts.length - 1]];
return parts.join("/") + ".flac";
}
@@ -55,12 +56,15 @@ function NamingSettings({ pair }: { pair: Pair }) {
const [tpl, setTpl] = useState(pair.organize_template);
const [multi, setMulti] = useState(pair.organize_multidisc);
const [auto, setAuto] = useState(pair.auto_organize);
const [digits, setDigits] = useState(pair.organize_disc_digits || 2);
useEffect(() => {
setTpl(pair.organize_template);
setMulti(pair.organize_multidisc);
setAuto(pair.auto_organize);
}, [pair.id, pair.organize_template, pair.organize_multidisc, pair.auto_organize]);
const dirty = tpl !== pair.organize_template || multi !== pair.organize_multidisc || auto !== pair.auto_organize;
setDigits(pair.organize_disc_digits || 2);
}, [pair.id, pair.organize_template, pair.organize_multidisc, pair.auto_organize, pair.organize_disc_digits]);
const dirty =
tpl !== pair.organize_template || multi !== pair.organize_multidisc || auto !== pair.auto_organize || digits !== (pair.organize_disc_digits || 2);
const save = useMutation({
mutationFn: () =>
@@ -79,6 +83,7 @@ function NamingSettings({ pair }: { pair: Pair }) {
organize_template: tpl,
organize_multidisc: multi,
auto_organize: auto,
organize_disc_digits: digits,
}),
onSuccess: () => {
toast("ok", "Naming settings saved. Preview again to see the effect.");
@@ -122,21 +127,27 @@ function NamingSettings({ pair }: { pair: Pair }) {
</div>
<div className="rounded-lg border border-line bg-bg/60 p-3 text-xs">
<div className="text-muted">Example</div>
<div className="mono break-all text-gray-100">{example(tpl)}</div>
<div className="mono break-all text-gray-100">{example(tpl, undefined, digits)}</div>
{multi !== "none" && (
<div className="mono break-all text-gray-400">
{example(tpl, multi)} <span className="font-sans text-muted">(disc 2 of a multi-disc album)</span>
{example(tpl, multi, digits)} <span className="font-sans text-muted">(disc 2 of a multi-disc album)</span>
</div>
)}
</div>
<div className="grid gap-4 md:grid-cols-2">
<Field label="Multi-disc albums" hint="Keeps disc 1 and disc 2 tracks from getting the same name.">
<select className={inputCls} value={multi} onChange={(e) => setMulti(e.target.value as Pair["organize_multidisc"])}>
<option value="prefix">Disc number before the track (2-04 Title)</option>
<option value="folder">A folder per disc (Disc 2/04 Title)</option>
<option value="prefix">Disc number before the track ({digits === 1 ? "2" : "02"}-04 Title)</option>
<option value="folder">A folder per disc (Disc {digits === 1 ? "2" : "02"}/04 Title)</option>
<option value="none">Nothing (clashing names are skipped)</option>
</select>
</Field>
<Field label="Disc numbers" hint="Also used for the {disc} token.">
<select className={inputCls} value={digits} onChange={(e) => setDigits(Number(e.target.value))}>
<option value={2}>Two digits: Disc 01, 01-04</option>
<option value={1}>As is: Disc 1, 1-04</option>
</select>
</Field>
<Field label="Organize automatically" hint="Renames run at the start of every scan.">
<select className={inputCls} value={auto} onChange={(e) => setAuto(e.target.value as Pair["auto_organize"])}>
<option value="off">Off: nothing is renamed</option>
+2
View File
@@ -28,6 +28,7 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
organize_template: "",
organize_multidisc: "prefix",
auto_organize: "off",
organize_disc_digits: 2,
};
const [f, setF] = useState<PairInput>(pair ?? blank);
const [exts, setExts] = useState((pair?.extensions ?? []).join(", "));
@@ -59,6 +60,7 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
organize_template: f.organize_template,
organize_multidisc: f.organize_multidisc,
auto_organize: f.auto_organize,
organize_disc_digits: f.organize_disc_digits,
extensions: exts.split(",").map((s) => s.trim()).filter(Boolean),
});
const set = <K extends keyof PairInput>(k: K, v: PairInput[K]) => {
+4 -1
View File
@@ -4,6 +4,7 @@ import { api } from "../api";
import { useInfo } from "../App";
import { Alert, Button, Card, Field, Toggle, inputCls } from "../ui";
import { useToast } from "../toast";
import { BackupsCard } from "./Backups";
export function SettingsPage() {
const info = useInfo();
@@ -36,7 +37,7 @@ export function SettingsPage() {
});
return (
<div className="max-w-2xl space-y-5">
<div className="max-w-3xl space-y-5">
<h1 className="text-2xl font-semibold tracking-tight">Settings</h1>
<Card className="space-y-3 p-5">
@@ -78,6 +79,8 @@ export function SettingsPage() {
</p>
</Card>
<BackupsCard />
<Card className="space-y-2 p-5">
<h2 className="font-semibold">Environment</h2>
<div className="text-sm">