Files
playlink/web/src/pages/PairForm.tsx
T
cdnutterandClaude Opus 5.5 428935dc58 Add the step-by-step fixer and library types
Each pair is a Music (Artist/Album/01 Title) or Soundtracks (Album/01 Title)
library. The new Fixer tab takes the master, then the slave, through four
reviewed steps: folder structure, album tags (optional MusicBrainz lookup;
the slave reuses the fixed master's tags), folder names and file names,
then links the two libraries. Every fix is an undoable operation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 09:10:48 -07:00

324 lines
15 KiB
TypeScript

import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronLeft, Folder, FolderOpen } from "lucide-react";
import { api, type LibraryType, type Pair, type PairInput, type ScanWarning } from "../api";
import { WarningList } from "./Warnings";
import { useInfo } from "../App";
import { Alert, Button, Field, Modal, Toggle, cx, inputCls } from "../ui";
import { useToast } from "../toast";
export const LIBRARY_TYPES: { id: LibraryType; label: string; layout: string; hint: string }[] = [
{ id: "music", label: "Music", layout: "Artist/Album/01 Title.flac", hint: "Albums by artist." },
{ id: "soundtracks", label: "Soundtracks", layout: "Album/01 Title.flac", hint: "Video game and film soundtracks, named by game or film." },
];
type Validation = { ok: boolean; error?: string; warnings: ScanWarning[] };
export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean; onClose: () => void }) {
const info = useInfo();
const qc = useQueryClient();
const toast = useToast();
const blank: PairInput = {
name: "Music",
master_root: "",
slave_root: "",
quarantine_dir: "",
extensions: [],
auto_link_identical: false,
scan_interval_minutes: 0,
retention_days: 30,
settle_minutes: 10,
approx_matching: false,
approx_threshold: 85,
auto_organize: "off",
library_type: "music",
};
const [f, setF] = useState<PairInput>(pair ?? blank);
const [exts, setExts] = useState((pair?.extensions ?? []).join(", "));
const [picker, setPicker] = useState<null | "master_root" | "slave_root" | "quarantine_dir">(null);
const [validation, setValidation] = useState<Validation | null>(null);
useEffect(() => {
if (open) {
setF(pair ?? blank);
setExts((pair?.extensions ?? info.data?.default_extensions ?? []).join(", "));
setValidation(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, pair?.id]);
// Send only editable fields: the API rejects unknown keys.
const body = (): PairInput => ({
name: f.name,
master_root: f.master_root,
slave_root: f.slave_root,
quarantine_dir: f.quarantine_dir,
auto_link_identical: f.auto_link_identical,
scan_interval_minutes: f.scan_interval_minutes,
retention_days: f.retention_days,
settle_minutes: f.settle_minutes,
approx_matching: f.approx_matching,
approx_threshold: f.approx_threshold,
// Edited on the Organize tab; sent unchanged so saving here keeps it.
auto_organize: f.auto_organize,
library_type: f.library_type,
extensions: exts.split(",").map((s) => s.trim()).filter(Boolean),
});
const set = <K extends keyof PairInput>(k: K, v: PairInput[K]) => {
setF((p) => ({ ...p, [k]: v }));
setValidation(null);
};
const validate = useMutation({
mutationFn: () => api.post<Validation>("/pairs/validate", body()),
onSuccess: setValidation,
});
const save = useMutation({
mutationFn: () => (pair ? api.put<{ warnings: ScanWarning[] }>(`/pairs/${pair.id}`, body()) : api.post<{ warnings: ScanWarning[] }>("/pairs", body())),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["pairs"] });
qc.invalidateQueries({ queryKey: ["pair"] });
toast("ok", pair ? "Library pair updated" : "Library pair created. Start with the Fixer, master library first.");
onClose();
},
});
return (
<Modal open={open} onClose={onClose} title={pair ? "Edit library pair" : "New library pair"} wide>
<div className="space-y-4">
<Field label="Name">
<input className={inputCls} value={f.name} onChange={(e) => set("name", e.target.value)} />
</Field>
<Field label="Library type" hint="Sets the recommended folder layout; you can change the naming later in Organize.">
<div className="grid gap-2 sm:grid-cols-2">
{LIBRARY_TYPES.map((t) => (
<button
key={t.id}
type="button"
onClick={() => set("library_type", t.id)}
className={cx(
"rounded-lg border p-3 text-left text-sm cursor-pointer",
f.library_type === t.id ? "border-accent bg-accent/10" : "border-line bg-panel-2 hover:border-accent/60",
)}
>
<div className="font-medium">{t.label}</div>
<div className="mono mt-0.5 text-xs text-gray-300">{t.layout}</div>
<div className="mt-1 text-xs text-muted">{t.hint}</div>
</button>
))}
</div>
</Field>
<div className="grid gap-4 md:grid-cols-2">
<PathField
label="Master library (keeps the files)"
hint="Example: Albums. Its files are the source of every hardlink."
value={f.master_root}
onChange={(v) => set("master_root", v)}
onBrowse={() => setPicker("master_root")}
/>
<PathField
label="Slave library (gets hardlinks)"
hint="Example: your curated Singles library. Matching files here are replaced with hardlinks."
value={f.slave_root}
onChange={(v) => set("slave_root", v)}
onBrowse={() => setPicker("slave_root")}
/>
</div>
<Alert tone="blue">
Both libraries should use the same folder layout (for example <code className="mono">Artist/Album/Title.ext</code>), because files are matched by
their relative path first. Any layout works as long as it is the same on both sides.
Both must be on the same filesystem. On Unraid, mount one common parent folder into the container instead of two separate volumes.
</Alert>
<PathField
label="Quarantine folder"
hint="Originals replaced by a non-identical file are moved here so you can restore them. Default: <slave>/.playlink-trash"
value={f.quarantine_dir}
placeholder="(default)"
onChange={(v) => set("quarantine_dir", v)}
onBrowse={() => setPicker("quarantine_dir")}
/>
<Field label="File extensions" hint="Comma-separated. Other files (covers, lyrics, playlists) are never touched.">
<input className={inputCls} value={exts} onChange={(e) => setExts(e.target.value)} />
</Field>
<div className="grid gap-4 md:grid-cols-3">
<Field label="Scan interval (minutes)" hint="0 = manual only, otherwise at least 5">
<input className={inputCls} type="number" min={0} value={f.scan_interval_minutes} onChange={(e) => set("scan_interval_minutes", Number(e.target.value))} />
</Field>
<Field label="Quarantine retention (days)" hint="0 = keep until purged manually">
<input className={inputCls} type="number" min={0} value={f.retention_days} onChange={(e) => set("retention_days", Number(e.target.value))} />
</Field>
<Field label="Settle time (minutes)" hint="Auto-link skips files modified more recently than this">
<input className={inputCls} type="number" min={0} value={f.settle_minutes} onChange={(e) => set("settle_minutes", Number(e.target.value))} />
</Field>
</div>
<div className="rounded-lg border border-line bg-panel-2 p-3.5">
<Toggle
checked={f.auto_link_identical}
onChange={(v) => set("auto_link_identical", v)}
label={<span className="font-medium">Automatically link byte-identical files after each scan</span>}
/>
<p className="mt-1.5 pl-11.5 text-xs text-muted">
Only files whose relative paths match and whose bytes are identical (verified with a full compare right before linking).
Anything else always waits for manual review. Dry run still applies.
</p>
</div>
<div className="rounded-lg border border-line bg-panel-2 p-3.5">
<Toggle
checked={f.approx_matching}
onChange={(v) => set("approx_matching", v)}
label={<span className="font-medium">Approximate matching</span>}
/>
<div className="mt-1.5 space-y-3 pl-11.5 text-xs text-muted">
<p>
Finds songs whose file name or folder doesn't line up exactly with the master library, such as a single that also appears on an
album, and recommends linking them to the master's album version.
</p>
<div className="space-y-2 rounded-md border border-line bg-bg/60 p-3">
{approxExamples.map((ex) => (
<div key={ex.label}>
<div className="font-medium text-gray-300">{ex.label}</div>
<div className="mt-0.5 grid grid-cols-[auto_1fr] gap-x-2 mono text-[11px] leading-relaxed">
<span className="text-gray-500">slave</span>
<span className="break-all">{ex.slave}</span>
<span className="text-gray-500">master</span>
<span className="break-all text-gray-200">{ex.master}</span>
</div>
</div>
))}
</div>
<ul className="list-disc space-y-0.5 pl-4">
<li>Different recordings never match: live, remix, acoustic, instrumental and similar versions are kept apart.</li>
<li>When a song appears on several releases, the studio album is preferred over singles, EPs and compilations.</li>
<li>Matches outside the artist's folder need matching artist tags. Anything ambiguous is skipped.</li>
<li>Approximate matches are never linked automatically. Each one waits in Review.</li>
</ul>
</div>
{f.approx_matching && (
<div className="mt-4 pl-11.5">
<div className="flex items-center gap-3">
<label className="whitespace-nowrap text-sm font-medium" htmlFor="approx-threshold">
Minimum title similarity
</label>
<input
id="approx-threshold"
type="range"
min={60}
max={100}
step={1}
value={f.approx_threshold}
onChange={(e) => set("approx_threshold", Number(e.target.value))}
className="w-40 accent-violet-500"
/>
<span className="w-10 text-sm tabular-nums">{f.approx_threshold}%</span>
</div>
<p className="mt-1 text-xs text-muted">Lower finds more matches but risks more false ones. 85% is a good default.</p>
</div>
)}
</div>
{validation && !validation.ok && <Alert tone="red">{validation.error}</Alert>}
{validation?.ok && validation.warnings.length === 0 && <Alert tone="green">Looks good. Both folders are valid and on the same filesystem.</Alert>}
{validation?.ok && <WarningList warnings={validation.warnings} />}
{save.error && <Alert tone="red">{save.error.message}</Alert>}
<div className="flex justify-end gap-2 pt-2">
<Button onClick={onClose} variant="ghost">
Cancel
</Button>
<Button onClick={() => validate.mutate()} loading={validate.isPending}>
Validate
</Button>
<Button variant="primary" onClick={() => save.mutate()} loading={save.isPending}>
{pair ? "Save" : "Create"}
</Button>
</div>
</div>
{picker && (
<FolderPicker
start={f[picker] || ""}
onPick={(p) => {
set(picker, p);
setPicker(null);
}}
onClose={() => setPicker(null)}
/>
)}
</Modal>
);
}
const approxExamples = [
{
label: "Single → album version",
slave: "Avenged Sevenfold/Hail to the King - Single/Hail to the King.mp3",
master: "Avenged Sevenfold/Hail to the King/01 Hail to the King.flac",
},
{
label: "Disc folders ignored",
slave: "Avenged Sevenfold/Nightmare/04 Buried Alive.mp3",
master: "Avenged Sevenfold/Nightmare/Disc 1/04 Buried Alive.flac",
},
{
label: "Small title differences",
slave: "Avenged Sevenfold/City of Evil/Bat Country (Remastered).mp3",
master: "Avenged Sevenfold/City of Evil/03 Bat Country.flac",
},
];
function PathField(props: { label: string; hint: string; value: string; placeholder?: string; onChange: (v: string) => void; onBrowse: () => void }) {
return (
<Field label={props.label} hint={props.hint}>
<div className="flex gap-2">
<input className={inputCls + " mono text-xs"} value={props.value} placeholder={props.placeholder ?? "/data/..."} onChange={(e) => props.onChange(e.target.value)} />
<Button type="button" onClick={props.onBrowse} title="Browse">
<FolderOpen className="size-4" />
</Button>
</div>
</Field>
);
}
function FolderPicker({ start, onPick, onClose }: { start: string; onPick: (p: string) => void; onClose: () => void }) {
const [path, setPath] = useState(start);
const q = useQuery({
queryKey: ["browse", path],
queryFn: () => api.get<{ path: string; parent: string; dirs: string[] }>(`/browse?path=${encodeURIComponent(path)}`),
retry: false,
});
return (
<Modal open onClose={onClose} title="Choose folder">
<div className="mb-3 flex items-center gap-2">
<Button size="sm" disabled={!path} onClick={() => setPath(q.data?.parent ?? "")}>
<ChevronLeft className="size-3.5" />
</Button>
<code className="mono truncate text-xs text-muted">{q.data?.path || "Allowed roots"}</code>
</div>
{q.error && (
<Alert tone="red">
{q.error.message}{" "}
<button className="underline cursor-pointer" onClick={() => setPath("")}>
Back to roots
</button>
</Alert>
)}
<div className="max-h-80 overflow-y-auto rounded-lg border border-line">
{q.data?.dirs.map((d) => (
<button key={d} onClick={() => setPath(d)} className="flex w-full items-center gap-2 border-b border-line px-3 py-2 text-left text-sm last:border-0 hover:bg-panel-2 cursor-pointer">
<Folder className="size-4 text-accent" />
<span className="truncate">{q.data.path ? d.split("/").pop() : d}</span>
</button>
))}
{q.data && q.data.dirs.length === 0 && <div className="p-4 text-center text-sm text-muted">No subfolders</div>}
</div>
<div className="mt-4 flex justify-end gap-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button variant="primary" disabled={!q.data?.path} onClick={() => onPick(q.data!.path)}>
Use this folder
</Button>
</div>
</Modal>
);
}