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>
324 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|