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