Rename host library to master throughout

The two libraries are now "master" and "slave" everywhere: UI text, API
JSON fields (master_root, rel_master, master_size, ...), Go identifiers,
database columns, quality verdicts, quarantine subfolders and docs.

Existing databases are upgraded by a new migration that renames the
columns in place and rewrites stored "host" values, so pairs, history,
cached verdicts and restorable quarantine entries carry over. Network
"host" usages (SplitHostPort, the Origin/Host check) are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-22 16:50:55 -07:00
1 parent d06b6d546d
commit 5b088bb4a6
19 files changed
+339 -326

No files matched your search

+8 -8
View File
@@ -62,7 +62,7 @@ export type Summary = {
export type Pair = {
id: number;
name: string;
host_root: string;
master_root: string;
slave_root: string;
quarantine_dir: string;
extensions: string[];
@@ -80,7 +80,7 @@ export type Pair = {
export type PairInput = Pick<
Pair,
| "name"
| "host_root"
| "master_root"
| "slave_root"
| "quarantine_dir"
| "extensions"
@@ -106,7 +106,7 @@ export type ProbeInfo = {
error?: string;
};
export type Verdict = { better: "host" | "slave" | "equal" | "unknown"; duration_delta: number; notes: string[] | null };
export type Verdict = { better: "master" | "slave" | "equal" | "unknown"; duration_delta: number; notes: string[] | null };
export type Status = "linked" | "identical" | "different" | "similar" | "unmatched" | "error";
@@ -114,14 +114,14 @@ export type Match = {
id: number;
pair_id: number;
rel_slave: string;
rel_host: string;
rel_master: string;
kind: "path" | "path_case" | "extension" | "fuzzy" | "elsewhere" | "none";
status: Status;
host_size: number;
host_mtime: number;
master_size: number;
master_mtime: number;
slave_size: number;
slave_mtime: number;
host_probe?: ProbeInfo;
master_probe?: ProbeInfo;
slave_probe?: ProbeInfo;
verdict?: Verdict;
note: string;
@@ -132,7 +132,7 @@ export type Operation = {
id: number;
pair_id: number;
action: "link" | "promote" | "build" | "remove";
rel_host: string;
rel_master: string;
rel_slave: string;
rel_new: string;
quarantine_path: string;
+5 -5
View File
@@ -32,8 +32,8 @@ export function ActivityPage() {
}
const actionLabel: Record<Operation["action"], string> = {
link: "Linked slave → host",
promote: "Promoted slave → host",
link: "Linked slave → master",
promote: "Promoted slave → master",
build: "Built into slave",
remove: "Removed from slave",
};
@@ -97,7 +97,7 @@ export function OpsTable({ pairId, quarantine }: { pairId?: number; quarantine?:
<div className="truncate mono text-[13px]" title={o.rel_slave}>
{o.rel_slave}
</div>
{o.rel_new && o.rel_new !== o.rel_slave && o.rel_new !== o.rel_host && (
{o.rel_new && o.rel_new !== o.rel_slave && o.rel_new !== o.rel_master && (
<div className="truncate text-xs text-muted mono">now {o.rel_new}</div>
)}
{o.error && <div className="text-xs text-red-300">{o.error}</div>}
@@ -172,9 +172,9 @@ function undoText(o: Operation) {
? "The original slave file is restored from quarantine and the hardlink is removed."
: "The slave gets its own independent copy of the file again, which uses disk space.";
case "promote":
return "The original host file is restored from quarantine. The slave keeps its own file.";
return "The original master file is restored from quarantine. The slave keeps its own file.";
case "build":
return "The hardlink is removed from the slave library. The host file is not affected.";
return "The hardlink is removed from the slave library. The master file is not affected.";
case "remove":
return "The hardlink is recreated in the slave library.";
}
+5 -5
View File
@@ -39,15 +39,15 @@ export function Build({ pair }: { pair: Pair }) {
return (
<div className="space-y-4">
<Alert tone="blue">
Build the slave library from the host. Browse the host library, pick folders or songs, and playlink hardlinks them into the slave
Build the slave library from the master. Browse the master library, pick folders or songs, and playlink hardlinks them into the slave
library at the same relative path. No extra space is used. Existing slave files are never
overwritten, and <b>Remove</b> only deletes slave files that are hardlinks of the host file, so the host copy is always kept.
overwritten, and <b>Remove</b> only deletes slave files that are hardlinks of the master file, so the master copy is always kept.
</Alert>
<div className="flex flex-wrap items-center justify-between gap-3">
<nav className="flex min-w-0 flex-wrap items-center gap-1 text-sm">
<button className="text-accent hover:underline cursor-pointer" onClick={() => setPath("")}>
host
master
</button>
{crumbs.map((c, i) => (
<span key={i} className="flex items-center gap-1">
@@ -117,8 +117,8 @@ export function Build({ pair }: { pair: Pair }) {
</>
) : (
<>
Slave files in the {sel.size} selected item(s) that are <b>hardlinks of the host file</b> will be deleted from{" "}
<code className="mono">{pair.slave_root}</code>. The host copy is kept. Any other slave file is left untouched.
Slave files in the {sel.size} selected item(s) that are <b>hardlinks of the master file</b> will be deleted from{" "}
<code className="mono">{pair.slave_root}</code>. The master copy is kept. Any other slave file is left untouched.
</>
)}
</p>
+4 -4
View File
@@ -19,7 +19,7 @@ export function Dashboard() {
<div className="flex items-end justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Libraries</h1>
<p className="text-sm text-muted">Each pair links matching songs from a slave library to the host library's copy.</p>
<p className="text-sm text-muted">Each pair links matching songs from a slave library to the master library's copy.</p>
</div>
<Button variant="primary" onClick={() => setCreating(true)}>
<Plus className="size-4" /> New pair
@@ -30,8 +30,8 @@ export function Dashboard() {
{pairs.data?.length === 0 && (
<Card>
<Empty icon={<Library className="size-10" />} title="No library pairs yet">
Create a pair with a <b>host</b> library (e.g. Albums) and a <b>slave</b> library (e.g. your curated Singles). playlink finds songs in
both and replaces the slave copy with a hardlink to the host copy, so each song is stored only once.
Create a pair with a <b>master</b> library (e.g. Albums) and a <b>slave</b> library (e.g. your curated Singles). playlink finds songs in
both and replaces the slave copy with a hardlink to the master copy, so each song is stored only once.
<div className="mt-4">
<Button variant="primary" onClick={() => setCreating(true)}>
<Plus className="size-4" /> Create your first pair
@@ -66,7 +66,7 @@ function PairCard({ pair }: { pair: Pair }) {
</a>
<div className="mt-1 space-y-0.5 text-xs">
<div className="truncate">
<span className="text-muted">host</span> <code className="mono">{pair.host_root}</code>
<span className="text-muted">master</span> <code className="mono">{pair.master_root}</code>
</div>
<div className="truncate">
<span className="text-muted">slave</span> <code className="mono">{pair.slave_root}</code>
+7 -7
View File
@@ -14,7 +14,7 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
const toast = useToast();
const blank: PairInput = {
name: "Music",
host_root: "",
master_root: "",
slave_root: "",
quarantine_dir: "",
extensions: [],
@@ -25,7 +25,7 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
};
const [f, setF] = useState<PairInput>(pair ?? blank);
const [exts, setExts] = useState((pair?.extensions ?? []).join(", "));
const [picker, setPicker] = useState<null | "host_root" | "slave_root" | "quarantine_dir">(null);
const [picker, setPicker] = useState<null | "master_root" | "slave_root" | "quarantine_dir">(null);
const [validation, setValidation] = useState<Validation | null>(null);
useEffect(() => {
@@ -40,7 +40,7 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
// Send only editable fields: the API rejects unknown keys.
const body = (): PairInput => ({
name: f.name,
host_root: f.host_root,
master_root: f.master_root,
slave_root: f.slave_root,
quarantine_dir: f.quarantine_dir,
auto_link_identical: f.auto_link_identical,
@@ -76,11 +76,11 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
</Field>
<div className="grid gap-4 md:grid-cols-2">
<PathField
label="Host library (keeps the files)"
label="Master library (keeps the files)"
hint="Example: Albums. Its files are the source of every hardlink."
value={f.host_root}
onChange={(v) => set("host_root", v)}
onBrowse={() => setPicker("host_root")}
value={f.master_root}
onChange={(v) => set("master_root", v)}
onBrowse={() => setPicker("master_root")}
/>
<PathField
label="Slave library (gets hardlinks)"
+1 -1
View File
@@ -149,7 +149,7 @@ export function PairPage({ id, tab }: { id: number; tab: string }) {
<h1 className="text-2xl font-semibold tracking-tight">{pair.name}</h1>
<div className="mt-1 flex flex-col gap-0.5 text-xs sm:flex-row sm:gap-4">
<span className="truncate">
<Badge tone="violet">host</Badge> <code className="mono">{pair.host_root}</code>
<Badge tone="violet">master</Badge> <code className="mono">{pair.master_root}</code>
</span>
<span className="truncate">
<Badge>slave</Badge> <code className="mono">{pair.slave_root}</code>
+24 -24
View File
@@ -94,7 +94,7 @@ export function Review({ pair }: { pair: Pair }) {
<Card className="flex flex-wrap items-center justify-between gap-3 p-4">
<div className="text-sm">
<b>{identical}</b> byte-identical files ({formatBytes(pair.summary.reclaimable_bytes)}) can be replaced with hardlinks.
<div className="text-xs text-muted">Each one is compared byte for byte again right before linking. No quarantine is needed because the data stays in the host.</div>
<div className="text-xs text-muted">Each one is compared byte for byte again right before linking. No quarantine is needed because the data stays in the master.</div>
</div>
<Button variant="primary" onClick={() => setConfirmBulk(true)} disabled={running}>
<CopyCheck className="size-4" /> Link all identical
@@ -134,8 +134,8 @@ export function Review({ pair }: { pair: Pair }) {
<Modal open={confirmBulk} onClose={() => setConfirmBulk(false)} title="Link all identical files?">
<p className="text-sm text-gray-300">
Each of the <b>{identical}</b> slave files will be compared byte for byte with its host counterpart once more, then atomically
replaced with a hardlink to the host file. Files whose paths differ only by letter case are included.
Each of the <b>{identical}</b> slave files will be compared byte for byte with its master counterpart once more, then atomically
replaced with a hardlink to the master file. Files whose paths differ only by letter case are included.
</p>
<div className="mt-5 flex justify-end gap-2">
<Button variant="ghost" onClick={() => setConfirmBulk(false)}>
@@ -151,15 +151,15 @@ export function Review({ pair }: { pair: Pair }) {
}
function PathLine({ m }: { m: Match }) {
const same = m.rel_host === m.rel_slave || !m.rel_host;
const same = m.rel_master === m.rel_slave || !m.rel_master;
return (
<div className="min-w-0 text-sm">
<div className="truncate mono text-[13px]" title={m.rel_slave}>
{m.rel_slave}
</div>
{!same && (
<div className="flex min-w-0 items-center gap-1 text-xs text-muted" title={m.rel_host}>
<ArrowRight className="size-3 shrink-0" /> <span className="truncate mono">{m.rel_host}</span>
<div className="flex min-w-0 items-center gap-1 text-xs text-muted" title={m.rel_master}>
<ArrowRight className="size-3 shrink-0" /> <span className="truncate mono">{m.rel_master}</span>
</div>
)}
</div>
@@ -215,15 +215,15 @@ function MatchRow({ m }: { m: Match }) {
</div>
{reviewable && (
<div className="hidden items-center gap-1.5 text-xs md:flex">
<span className="text-muted">{qualityLabel(m.host_probe)}</span>
<span className="text-muted">{qualityLabel(m.master_probe)}</span>
<span className="text-gray-600">vs</span>
<span className="text-muted">{qualityLabel(m.slave_probe)}</span>
{better === "slave" && (
<Badge tone="amber">
<AlertTriangle className="size-3" /> host lower
<AlertTriangle className="size-3" /> master lower
</Badge>
)}
{better === "host" && <Badge tone="green">host better</Badge>}
{better === "master" && <Badge tone="green">master better</Badge>}
{better === "equal" && <Badge>equal</Badge>}
</div>
)}
@@ -266,10 +266,10 @@ const rows: [string, (p: ProbeInfo, size: number) => string][] = [
function ComparePanel({ m, onDone }: { m: Match; onDone: () => void }) {
const toast = useToast();
const [confirm, setConfirm] = useState<null | "downgrade" | "promote">(null);
const hp = m.host_probe;
const hp = m.master_probe;
const sp = m.slave_probe;
const v = m.verdict;
const renamed = m.rel_host.split("/").pop() !== m.rel_slave.split("/").pop();
const renamed = m.rel_master.split("/").pop() !== m.rel_slave.split("/").pop();
const link = useMutation({
mutationFn: (allow: boolean) => api.post<{ dry_run: boolean }>(`/matches/${m.id}/link`, { allow_downgrade: allow }),
@@ -287,7 +287,7 @@ function ComparePanel({ m, onDone }: { m: Match; onDone: () => void }) {
mutationFn: () => api.post<{ dry_run: boolean }>(`/matches/${m.id}/promote`, { confirm: true }),
onSuccess: (op) => {
setConfirm(null);
toast("ok", op.dry_run ? "Planned (dry run): nothing changed" : "Slave file promoted to host. The old host file is in quarantine.");
toast("ok", op.dry_run ? "Planned (dry run): nothing changed" : "Slave file promoted to master. The old master file is in quarantine.");
onDone();
},
onError: (e) => toast("err", e.message),
@@ -303,7 +303,7 @@ function ComparePanel({ m, onDone }: { m: Match; onDone: () => void }) {
<tr className="text-left text-xs text-muted">
<th className="py-1 pr-4 font-normal"></th>
<th className="py-1 pr-4 font-medium">
<Badge tone="violet">host</Badge> {better(v?.better === "host")}
<Badge tone="violet">master</Badge> {better(v?.better === "master")}
</th>
<th className="py-1 font-medium">
<Badge>slave</Badge> {better(v?.better === "slave")}
@@ -312,7 +312,7 @@ function ComparePanel({ m, onDone }: { m: Match; onDone: () => void }) {
</thead>
<tbody>
{rows.map(([label, f]) => {
const a = hp.error ? "–" : f(hp, m.host_size);
const a = hp.error ? "–" : f(hp, m.master_size);
const b = sp.error ? "–" : f(sp, m.slave_size);
return (
<tr key={label} className="border-t border-line/60">
@@ -334,30 +334,30 @@ function ComparePanel({ m, onDone }: { m: Match; onDone: () => void }) {
))}
{v?.better === "slave" && (
<Alert tone="amber" icon={<Crown className="mt-0.5 size-4 shrink-0" />}>
The host copy is lower quality. The host is normally kept because it belongs to a complete album. You can still link to it (the
slave's better copy goes to quarantine), or override and make the slave's copy the host file.
The master copy is lower quality. The master is normally kept because it belongs to a complete album. You can still link to it (the
slave's better copy goes to quarantine), or override and make the slave's copy the master file.
</Alert>
)}
{renamed && (
<Alert tone="blue">
File names differ. Linking puts <code className="mono">{m.rel_host.split("/").pop()}</code> into the slave folder in place of{" "}
File names differ. Linking puts <code className="mono">{m.rel_master.split("/").pop()}</code> into the slave folder in place of{" "}
<code className="mono">{m.rel_slave.split("/").pop()}</code>, so playlists that point at the old name will need updating.
</Alert>
)}
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button variant="primary" onClick={() => (v?.better === "slave" ? setConfirm("downgrade") : link.mutate(false))} loading={link.isPending && !confirm}>
<Link2 className="size-4" /> Link to host copy
<Link2 className="size-4" /> Link to master copy
</Button>
<Button variant={v?.better === "slave" ? "warn" : "secondary"} onClick={() => setConfirm("promote")}>
<Crown className="size-4" /> Use slave copy as host
<Crown className="size-4" /> Use slave copy as master
</Button>
</div>
<p className="mt-2 text-xs text-muted">Whatever gets replaced is kept in quarantine and can be restored from History.</p>
<Modal open={confirm === "downgrade"} onClose={() => setConfirm(null)} title="Link to a lower-quality host file?">
<Modal open={confirm === "downgrade"} onClose={() => setConfirm(null)} title="Link to a lower-quality master file?">
<p className="text-sm text-gray-300">
The slave has the better copy ({qualityLabel(sp)} vs {qualityLabel(hp)}). Linking replaces it with the host's{" "}
The slave has the better copy ({qualityLabel(sp)} vs {qualityLabel(hp)}). Linking replaces it with the master's{" "}
{qualityLabel(hp)} file. The slave's original goes to quarantine and can be restored until it is purged.
</p>
<div className="mt-5 flex justify-end gap-2">
@@ -369,10 +369,10 @@ function ComparePanel({ m, onDone }: { m: Match; onDone: () => void }) {
</Button>
</div>
</Modal>
<Modal open={confirm === "promote"} onClose={() => setConfirm(null)} title="Replace the host file with the slave copy?">
<Modal open={confirm === "promote"} onClose={() => setConfirm(null)} title="Replace the master file with the slave copy?">
<p className="text-sm text-gray-300">
This <b>changes the host library</b>. The host file <code className="mono break-all">{m.rel_host}</code> goes to quarantine, and the
slave's file (<code className="mono">{qualityLabel(sp)}</code>) is hardlinked into the host album in its place
This <b>changes the master library</b>. The master file <code className="mono break-all">{m.rel_master}</code> goes to quarantine, and the
slave's file (<code className="mono">{qualityLabel(sp)}</code>) is hardlinked into the master album in its place
{renamed && (
<>
{" "}as <code className="mono">{m.rel_slave.split("/").pop()}</code>