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:
1 parent
d06b6d546d
commit
5b088bb4a6
19 files changed
+339
-326
No files matched your search
+8
-8
@@ -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;
|
||||
|
||||
@@ -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.";
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user