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>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-30 09:10:48 -07:00
1 parent b11d0806c1
commit 428935dc58
19 files changed
+3503 -23

No files matched your search

+1 -1
View File
@@ -65,7 +65,7 @@ function Shell({ username }: { username: string }) {
];
let page;
if (route[0] === "pair" && route[1]) page = <PairPage id={Number(route[1])} tab={route[2] ?? "review"} />;
if (route[0] === "pair" && route[1]) page = <PairPage id={Number(route[1])} tab={route[2] ?? "fixer"} />;
else if (route[0] === "activity") page = <ActivityPage />;
else if (route[0] === "settings") page = <SettingsPage />;
else page = <Dashboard />;
+68
View File
@@ -78,7 +78,9 @@ export type Pair = {
approx_matching: boolean;
approx_threshold: number;
auto_organize: "off" | "slave" | "master" | "both";
library_type: LibraryType;
naming: Naming;
fixer: FixerState;
last_scan_at: number | null;
last_scan_error: string;
last_scan_warnings: ScanWarning[];
@@ -100,8 +102,74 @@ export type PairInput = Pick<
| "approx_matching"
| "approx_threshold"
| "auto_organize"
| "library_type"
>;
export type LibraryType = "music" | "soundtracks";
// ---- step-by-step fixer ----
export type FixStep = "structure" | "tags" | "folders" | "files";
export type StepStatus = { state: "" | "done" | "skipped"; at?: number; note?: string };
export type FixerState = { master: Partial<Record<FixStep, StepStatus>>; slave: Partial<Record<FixStep, StepStatus>>; link: StepStatus };
export type FixPlan = {
id: number;
side: Side;
step: FixStep;
created_at: number;
songs: number;
albums: number;
fixable: number;
blocked: number;
notices: string[];
applied: boolean;
};
export type FixItem = {
id: number;
kind: "move-folder" | "move-file" | "tags" | "album-tags" | "problem";
group: string;
from?: string;
to?: string;
why: string;
blocked?: string;
changes?: Record<string, [string, string]>;
unsure?: boolean;
};
export type MbRelease = {
id: string;
title: string;
artist: string;
date: string;
country: string;
status: string;
type: string;
disambiguation?: string;
formats: string;
track_count: number;
discs: number;
score: number;
};
export type AlbumFix = {
id: number;
folder: string;
artist: string;
album: string;
source: "musicbrainz" | "master";
release?: MbRelease;
candidates: MbRelease[];
confidence: number;
matched: number;
files: { path: string; track?: string; changes: Record<string, [string, string]> }[];
album_changes: Record<string, [string, string]>;
error?: string;
};
export type Fixer = { state: FixerState; steps: FixStep[]; plan: FixPlan | null; items: FixItem[]; albums: AlbumFix[] };
export type ProbeInfo = {
codec: string;
codec_long?: string;
+864
View File
@@ -0,0 +1,864 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
ArrowRight,
Check,
ChevronDown,
ChevronRight,
CircleDashed,
FileAudio,
FolderInput,
FolderTree,
Link2,
Minus,
Pencil,
RotateCcw,
Search,
SkipForward,
Tags as TagsIcon,
} from "lucide-react";
import { api, type AlbumFix, type FixItem, type FixStep, type Fixer, type Pair, type Side, type StepStatus } from "../api";
import { Alert, Badge, Button, Card, Field, Spinner, Toggle, cx, formatAgo, inputCls } from "../ui";
import { useToast } from "../toast";
import { useInfo } from "../App";
import { JobBar, ScanButton } from "./PairPage";
import { WarningList } from "./Warnings";
import { AlbumEditor } from "./Album";
// The fixer takes each library through the same steps, master first, then
// links the two. Each step is checked, reviewed and applied here.
type Sel = { side: Side; step: FixStep } | "link";
const STEPS: { id: FixStep; title: string; icon: typeof FolderTree; optional?: boolean }[] = [
{ id: "structure", title: "Folder structure", icon: FolderTree },
{ id: "tags", title: "Album tags", icon: TagsIcon, optional: true },
{ id: "folders", title: "Folder names", icon: FolderInput },
{ id: "files", title: "File names", icon: FileAudio },
];
const SIDES: { id: Side; title: string }[] = [
{ id: "master", title: "Master library" },
{ id: "slave", title: "Slave library" },
];
const FIELD_LABEL: Record<string, string> = {
title: "Title",
artist: "Artist",
album: "Album",
album_artist: "Album artist",
track: "Track",
track_total: "Tracks",
disc: "Disc",
disc_total: "Discs",
date: "Date",
genre: "Genre",
};
const finished = (s?: StepStatus) => s?.state === "done" || s?.state === "skipped";
/** layoutOf shows where albums go, e.g. "{Artist Name}/{Album Title}/{track:00} {Track Title}". */
function layoutOf(pair: Pair) {
const n = pair.naming;
return [n.artist_folder, n.album_folder, n.track_format].filter(Boolean).join("/");
}
function firstOpen(pair: Pair): Sel {
for (const s of SIDES) for (const st of STEPS) if (!finished(pair.fixer[s.id][st.id])) return { side: s.id, step: st.id };
return "link";
}
export function FixerTab({ pair }: { pair: Pair }) {
const info = useInfo();
const [sel, setSel] = useState<Sel>(() => firstOpen(pair));
const fixer = useQuery({ queryKey: ["fixer", pair.id], queryFn: () => api.get<Fixer>(`/pairs/${pair.id}/fixer`) });
if (info.data && !info.data.tag_editing)
return <Alert tone="amber">The fixer reads and writes tags with ffmpeg and ffprobe, which were not found. The Docker image includes both.</Alert>;
const isSel = (s: Side, st: FixStep) => sel !== "link" && sel.side === s && sel.step === st;
return (
<div className="grid gap-5 lg:grid-cols-[16rem_1fr]">
<nav className="space-y-4">
<Card className="p-3 text-xs">
<div className="text-muted">{pair.library_type === "soundtracks" ? "Soundtrack library" : "Music library"}</div>
<div className="mono mt-1 text-gray-200">
{layoutOf(pair)
.split("/")
.map((part, i) => (
<span key={i} className="whitespace-nowrap">
{i > 0 && "/"}
{part}
<wbr />
</span>
))}
</div>
<a href={`#/pair/${pair.id}/organize`} className="mt-1 inline-block text-accent hover:underline">
Change the naming
</a>
</Card>
{SIDES.map((s, si) => (
<div key={s.id}>
<div className="mb-1.5 px-1 text-xs font-medium uppercase tracking-wide text-muted">
{si + 1}. {s.title}
</div>
<div className="space-y-0.5">
{STEPS.map((st) => (
<StepLink
key={st.id}
status={pair.fixer[s.id][st.id]}
active={isSel(s.id, st.id)}
onClick={() => setSel({ side: s.id, step: st.id })}
>
{st.title}
{st.optional && <span className="ml-1 text-muted">(optional)</span>}
</StepLink>
))}
</div>
</div>
))}
<div>
<div className="mb-1.5 px-1 text-xs font-medium uppercase tracking-wide text-muted">3. Both libraries</div>
<StepLink status={pair.fixer.link} active={sel === "link"} onClick={() => setSel("link")}>
Link the libraries
</StepLink>
<a href={`#/pair/${pair.id}/review`} className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-gray-300 hover:bg-panel-2">
<ArrowRight className="size-4 text-muted" /> Review differences
{pair.summary.review > 0 && <Badge tone="amber">{pair.summary.review}</Badge>}
</a>
</div>
</nav>
<div className="min-w-0">
{sel === "link" ? (
<LinkPanel pair={pair} />
) : (
<StepPanel
key={`${sel.side}-${sel.step}`}
pair={pair}
side={sel.side}
step={sel.step}
fixer={fixer.data}
onNext={() => setSel(firstOpen(pair))}
/>
)}
</div>
</div>
);
}
function StepLink({ status, active, onClick, children }: { status?: StepStatus; active: boolean; onClick: () => void; children: ReactNode }) {
const icon =
status?.state === "done" ? (
<Check className="size-4 text-emerald-400" />
) : status?.state === "skipped" ? (
<Minus className="size-4 text-muted" />
) : (
<CircleDashed className={cx("size-4", active ? "text-accent" : "text-muted")} />
);
return (
<button
onClick={onClick}
className={cx(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm cursor-pointer",
active ? "bg-accent/15 text-white" : "text-gray-300 hover:bg-panel-2",
)}
>
{icon}
<span className="min-w-0 flex-1">{children}</span>
</button>
);
}
// ---- one step of one library ----
function stepText(step: FixStep, pair: Pair, side: Side): ReactNode {
const layout = <code className="mono text-gray-200">{layoutOf(pair)}</code>;
switch (step) {
case "structure":
return (
<>
Puts every album in its own folder at the right depth for {layout}. Songs outside an album folder are moved into one named from their tags;
extra folders around an album (a composer folder around a soundtrack, a <span className="mono">FLAC</span> folder inside an album) are
removed; songs in disc folders get their disc number; and missing titles, track numbers, albums and artists are filled in from the folder
and file names. Names themselves are fixed in the next steps.
</>
);
case "tags":
return (
<>
Looks each album up on MusicBrainz and shows corrected titles, track and disc numbers, album artist and date for you to review.
{side === "slave" && " Albums that are also in the master take the master's tags instead, so both libraries match."} Nothing changes until
you apply the albums you selected. This step is optional and can take a while: MusicBrainz allows about one lookup a second.
</>
);
case "folders":
return (
<>
Renames artist and album folders from the tags, following {layout}. Whole album folders move, so covers, booklets and other files stay with
their album.
</>
);
case "files":
return (
<>
Renames each song from its tags (<code className="mono text-gray-200">{pair.naming.track_format}</code>). Albums with several discs get{" "}
<span className="mono">Disc 01</span>, <span className="mono">Disc 02</span> folders. Lyrics files move with their song.
</>
);
}
}
function StepPanel({ pair, side, step, fixer, onNext }: { pair: Pair; side: Side; step: FixStep; fixer?: Fixer; onNext: () => void }) {
const qc = useQueryClient();
const toast = useToast();
const [folder, setFolder] = useState("");
const [onlyIncomplete, setOnlyIncomplete] = useState(true);
const meta = STEPS.find((s) => s.id === step)!;
const status = pair.fixer[side][step];
const plan = fixer?.plan && fixer.plan.side === side && fixer.plan.step === step ? fixer.plan : null;
const running = pair.job?.state === "running";
const ourJob = running && pair.job!.kind.startsWith("fix-");
const refresh = () => {
qc.invalidateQueries({ queryKey: ["pair", pair.id] });
qc.invalidateQueries({ queryKey: ["fixer", pair.id] });
};
const check = useMutation({
mutationFn: () => api.post(`/pairs/${pair.id}/fixer/check`, { side, step, folder: folder.trim(), only_incomplete: onlyIncomplete }),
onSuccess: refresh,
onError: (e) => toast("err", e.message),
});
const mark = useMutation({
mutationFn: (state: StepStatus["state"]) => api.put(`/pairs/${pair.id}/fixer/step`, { side, step, state }),
onSuccess: (_, state) => {
refresh();
if (state) setTimeout(onNext, 300);
},
onError: (e) => toast("err", e.message),
});
const masterOpen = side === "slave" && STEPS.some((s) => !finished(pair.fixer.master[s.id]));
return (
<Card className="space-y-4 p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-xs uppercase tracking-wide text-muted">{side === "master" ? "Master library" : "Slave library"}</div>
<h2 className="flex items-center gap-2 text-lg font-semibold">
<meta.icon className="size-5 text-accent" /> {meta.title}
{meta.optional && <Badge>optional</Badge>}
{status?.state === "done" && <Badge tone="green">done {formatAgo(status.at)}</Badge>}
{status?.state === "skipped" && <Badge>skipped</Badge>}
</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
{finished(status) ? (
<Button size="sm" variant="ghost" onClick={() => mark.mutate("")}>
<RotateCcw className="size-3.5" /> Reopen
</Button>
) : (
<>
<Button size="sm" variant="ghost" onClick={() => mark.mutate("skipped")} title="Leave this step out">
<SkipForward className="size-3.5" /> Skip
</Button>
{plan && !plan.applied && (
<Button size="sm" onClick={() => mark.mutate("done")}>
<Check className="size-3.5" /> Mark done
</Button>
)}
</>
)}
<Button
size="sm"
variant={plan && !plan.applied ? "secondary" : "primary"}
onClick={() => check.mutate()}
disabled={running}
loading={check.isPending}
>
<Search className="size-3.5" /> {plan ? "Check again" : `Check the ${side}`}
</Button>
</div>
</div>
<p className="text-sm text-muted">{stepText(step, pair, side)}</p>
{masterOpen && (
<Alert tone="blue">
The master library isn't finished yet. Fixing the master first is recommended: the slave's album tags and links are matched against it.
</Alert>
)}
{step === "tags" && (
<div className="grid gap-3 rounded-lg border border-line bg-panel-2/40 p-3 md:grid-cols-2">
<Field label="Only this folder" hint="Optional, e.g. Avenged Sevenfold. Empty: the whole library.">
<input className={inputCls + " mono text-xs"} value={folder} placeholder="(whole library)" onChange={(e) => setFolder(e.target.value)} />
</Field>
<div className="pt-6">
<Toggle checked={onlyIncomplete} onChange={setOnlyIncomplete} label="Skip albums whose tags are already complete" />
<p className="mt-1 pl-11.5 text-xs text-muted">Complete: every song has a title, track number, album and artist, and they agree.</p>
</div>
</div>
)}
{ourJob ? (
<JobBar job={pair.job!} />
) : !plan ? (
<div className="rounded-lg border border-dashed border-line p-6 text-center text-sm text-muted">
Check the {side} library to see what needs fixing. Nothing is changed by checking.
</div>
) : plan.applied ? (
<Alert tone="green">
Fixes applied{pair.job?.results?.skipped ? `, ${pair.job.results.skipped} skipped (see above)` : ""}. Every change is in History and can be
undone. {finished(status) ? "This step is done." : "Check again to see what's left, or mark the step done."}
</Alert>
) : step === "tags" ? (
<AlbumResults pair={pair} side={side} fixer={fixer!} onApplied={refresh} />
) : (
<ItemResults pair={pair} side={side} fixer={fixer!} onApplied={refresh} />
)}
</Card>
);
}
function PlanStats({ fixer }: { fixer: Fixer }) {
const p = fixer.plan!;
const stats: [string, number, string?][] = [
["songs", p.songs],
["albums", p.albums],
["to fix", p.fixable, "text-accent"],
["need attention", p.blocked, p.blocked ? "text-amber-300" : undefined],
];
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{stats.map(([label, n, tone]) => (
<div key={label} className="rounded-lg border border-line bg-panel-2/50 px-3 py-2">
<div className={cx("text-lg font-semibold tabular-nums", tone)}>{n}</div>
<div className="text-xs text-muted">{label}</div>
</div>
))}
</div>
{p.notices.map((n, i) => (
<Alert key={i} tone="amber" icon={<AlertTriangle className="mt-0.5 size-4 shrink-0" />}>
{n}
</Alert>
))}
</div>
);
}
function useSelection(fixer: Fixer) {
const initial = () => new Set(fixer.items.filter((i) => !i.blocked && i.kind !== "problem" && !i.unsure).map((i) => i.id));
const [sel, setSel] = useState<Set<number>>(initial);
useEffect(() => setSel(initial()), [fixer.plan?.id]); // eslint-disable-line react-hooks/exhaustive-deps
const toggle = (ids: number[], on: boolean) =>
setSel((s) => {
const n = new Set(s);
ids.forEach((id) => (on ? n.add(id) : n.delete(id)));
return n;
});
return { sel, toggle };
}
function ApplyBar({
pair,
plan,
count,
total,
ids,
onApplied,
}: {
pair: Pair;
plan: number;
count: number;
total: number;
ids: number[];
onApplied: () => void;
}) {
const toast = useToast();
const dry = useInfo().data?.dry_run ?? true;
const apply = useMutation({
mutationFn: () => api.post(`/pairs/${pair.id}/fixer/apply`, { plan, ids }),
onSuccess: onApplied,
onError: (e) => toast("err", e.message),
});
return (
<div className="sticky bottom-0 -mx-5 -mb-5 flex flex-wrap items-center gap-3 rounded-b-xl border-t border-line bg-panel/95 px-5 py-3 backdrop-blur">
<Button variant="primary" disabled={count === 0 || pair.job?.state === "running"} loading={apply.isPending} onClick={() => apply.mutate()}>
<Check className="size-4" /> Apply {count} {count === 1 ? "fix" : "fixes"}
</Button>
<span className="text-sm text-muted">
{count} of {total} selected{dry && " · dry run: changes are only recorded"}
</span>
</div>
);
}
// ---- moves and tag fixes ----
const GROUP_PAGE = 60;
function ItemResults({ pair, side, fixer, onApplied }: { pair: Pair; side: Side; fixer: Fixer; onApplied: () => void }) {
const { sel, toggle } = useSelection(fixer);
const [shown, setShown] = useState(GROUP_PAGE);
const [album, setAlbum] = useState<string | null>(null);
const fixable = fixer.items.filter((i) => !i.blocked && i.kind !== "problem");
const attention = fixer.items.filter((i) => i.blocked || i.kind === "problem");
const groups = useMemo(() => {
const m = new Map<string, FixItem[]>();
for (const it of fixable) m.set(it.group, [...(m.get(it.group) ?? []), it]);
return [...m.entries()];
}, [fixer.items]); // eslint-disable-line react-hooks/exhaustive-deps
const plan = fixer.plan!;
return (
<div className="space-y-4">
<PlanStats fixer={fixer} />
{plan.fixable === 0 && plan.blocked === 0 && <Alert tone="green">Nothing to fix here. Mark the step done to continue.</Alert>}
{groups.length > 0 && (
<div className="overflow-hidden rounded-lg border border-line">
<div className="flex items-center gap-2 border-b border-line bg-panel-2 px-3 py-2 text-xs text-muted">
<input
type="checkbox"
className="accent-violet-500"
checked={fixable.every((i) => sel.has(i.id))}
onChange={(e) =>
toggle(
fixable.map((i) => i.id),
e.target.checked,
)
}
/>
Select all · grouped by where they end up
</div>
<div className="divide-y divide-line">
{groups.slice(0, shown).map(([g, items]) => (
<FixGroup key={g} group={g} items={items} sel={sel} toggle={toggle} />
))}
</div>
{groups.length > shown && (
<button
className="w-full border-t border-line py-2 text-xs text-accent hover:bg-panel-2 cursor-pointer"
onClick={() => setShown(shown + GROUP_PAGE)}
>
Show more ({groups.length - shown} folders left)
</button>
)}
</div>
)}
{attention.length > 0 && (
<div>
<h3 className="mb-1.5 flex items-center gap-1.5 text-sm font-medium text-amber-200">
<AlertTriangle className="size-4" /> Needs attention ({attention.length})
</h3>
<div className="divide-y divide-amber-500/10 overflow-hidden rounded-lg border border-amber-500/25 bg-amber-500/5">
{attention.slice(0, 200).map((it) => (
<div key={it.id} className="flex flex-wrap items-start gap-x-3 gap-y-1 px-3 py-2 text-xs">
<div className="min-w-0 flex-1">
<div className="mono break-all text-gray-100">{it.from}</div>
<div className="text-amber-200/90">
{it.why}
{it.blocked && <span className="text-amber-200/60"> · {it.blocked}</span>}
</div>
</div>
{it.from && (
<Button size="sm" variant="ghost" onClick={() => setAlbum(it.kind === "move-file" ? it.group : it.group || it.from!)}>
<Pencil className="size-3.5" /> Edit album
</Button>
)}
</div>
))}
{attention.length > 200 && <div className="px-3 py-2 text-xs text-muted">and {attention.length - 200} more</div>}
</div>
</div>
)}
{fixable.length > 0 && (
<ApplyBar
pair={pair}
plan={plan.id}
count={fixable.filter((i) => sel.has(i.id)).length}
total={fixable.length}
ids={fixable.filter((i) => sel.has(i.id)).map((i) => i.id)}
onApplied={onApplied}
/>
)}
{album !== null && <AlbumEditor pairId={pair.id} side={side} folder={album} onClose={() => setAlbum(null)} />}
</div>
);
}
function FixGroup({
group,
items,
sel,
toggle,
}: {
group: string;
items: FixItem[];
sel: Set<number>;
toggle: (ids: number[], on: boolean) => void;
}) {
const [open, setOpen] = useState(items.length <= 3);
const ids = items.map((i) => i.id);
const n = ids.filter((id) => sel.has(id)).length;
const single = items.length === 1 ? items[0] : null;
return (
<div>
<div className="flex items-center gap-2 px-3 py-2">
<input type="checkbox" className="accent-violet-500" checked={n === ids.length} onChange={(e) => toggle(ids, e.target.checked)} />
<button className="flex min-w-0 flex-1 items-center gap-1.5 text-left text-sm cursor-pointer" onClick={() => setOpen(!open)}>
{open ? <ChevronDown className="size-3.5 shrink-0 text-muted" /> : <ChevronRight className="size-3.5 shrink-0 text-muted" />}
<span className="mono truncate text-gray-100">{group || "(library root)"}</span>
<span className="shrink-0 text-xs text-muted">
{items.length} {items.length === 1 ? "fix" : "fixes"}
{n !== ids.length && ` · ${n} selected`}
</span>
{items.some((i) => i.unsure) && <Badge tone="amber">check</Badge>}
</button>
</div>
{(open || single) && (
<div className="space-y-1 pb-2 pl-9 pr-3">
{items.map((it) => (
<FixRow key={it.id} it={it} checked={sel.has(it.id)} onChange={(on) => toggle([it.id], on)} />
))}
</div>
)}
</div>
);
}
function FixRow({ it, checked, onChange }: { it: FixItem; checked: boolean; onChange: (on: boolean) => void }) {
return (
<label className="flex items-start gap-2 rounded-md px-2 py-1 text-xs hover:bg-panel-2 cursor-pointer">
<input type="checkbox" className="mt-0.5 accent-violet-500" checked={checked} onChange={(e) => onChange(e.target.checked)} />
<div className="min-w-0 flex-1">
{it.kind === "tags" ? (
<>
<div className="mono break-all text-gray-300">{it.from}</div>
<Changes changes={it.changes ?? {}} />
</>
) : (
<div className="flex flex-wrap items-center gap-x-1.5 mono break-all">
<span className="text-gray-400">{it.from}</span>
<ArrowRight className="size-3 shrink-0 text-muted" />
<span className="text-gray-100">{it.to}</span>
{it.kind === "move-folder" && <Badge>folder</Badge>}
</div>
)}
<div className="text-muted">
{it.why}
{it.unsure && <span className="text-amber-300"> · not selected: check it first</span>}
</div>
</div>
</label>
);
}
function Changes({ changes }: { changes: Record<string, [string, string]> }) {
const keys = Object.keys(changes).sort((a, b) => Object.keys(FIELD_LABEL).indexOf(a) - Object.keys(FIELD_LABEL).indexOf(b));
return (
<div className="flex flex-wrap gap-x-3 gap-y-0.5">
{keys.map((k) => (
<span key={k}>
<span className="text-muted">{FIELD_LABEL[k] ?? k}: </span>
{changes[k][0] ? <span className="text-red-300/80 line-through">{changes[k][0]}</span> : <span className="text-muted">(empty)</span>}
<span className="text-muted"> → </span>
<span className="text-emerald-300">{changes[k][1]}</span>
</span>
))}
</div>
);
}
// ---- album tags (online lookup) ----
function AlbumResults({ pair, side, fixer, onApplied }: { pair: Pair; side: Side; fixer: Fixer; onApplied: () => void }) {
const { sel, toggle } = useSelection(fixer);
const [shown, setShown] = useState(40);
const [album, setAlbum] = useState<string | null>(null);
const blocked = new Set(fixer.items.filter((i) => i.blocked).map((i) => i.id));
const ordered = [...fixer.albums].sort((a, b) => Number(blocked.has(a.id)) - Number(blocked.has(b.id)) || a.folder.localeCompare(b.folder));
const usable = fixer.albums.filter((a) => !blocked.has(a.id));
return (
<div className="space-y-4">
<PlanStats fixer={fixer} />
{fixer.albums.length === 0 && <Alert tone="green">Every album checked already matches. Mark the step done to continue.</Alert>}
<div className="space-y-2">
{ordered.slice(0, shown).map((a) => (
<AlbumCard
key={a.id}
pair={pair}
a={a}
blocked={blocked.has(a.id)}
checked={sel.has(a.id)}
onCheck={(on) => toggle([a.id], on)}
onEdit={() => setAlbum(a.folder)}
/>
))}
{ordered.length > shown && (
<Button size="sm" variant="ghost" onClick={() => setShown(shown + 40)}>
Show more ({ordered.length - shown} albums left)
</Button>
)}
</div>
{usable.length > 0 && (
<ApplyBar
pair={pair}
plan={fixer.plan!.id}
count={usable.filter((a) => sel.has(a.id)).length}
total={usable.length}
ids={usable.filter((a) => sel.has(a.id)).map((a) => a.id)}
onApplied={onApplied}
/>
)}
{album !== null && <AlbumEditor pairId={pair.id} side={side} folder={album} onClose={() => setAlbum(null)} />}
</div>
);
}
function Confidence({ c }: { c: number }) {
if (c >= 0.85) return <Badge tone="green">good match</Badge>;
if (c >= 0.6) return <Badge tone="amber">check</Badge>;
return <Badge tone="red">weak match</Badge>;
}
function AlbumCard({
pair,
a,
blocked,
checked,
onCheck,
onEdit,
}: {
pair: Pair;
a: AlbumFix;
blocked: boolean;
checked: boolean;
onCheck: (on: boolean) => void;
onEdit: () => void;
}) {
const qc = useQueryClient();
const toast = useToast();
const [open, setOpen] = useState(false);
const choose = useMutation({
mutationFn: (release: string) => api.post<AlbumFix>(`/pairs/${pair.id}/fixer/release`, { album: a.id, release }),
onSuccess: (res) => {
qc.invalidateQueries({ queryKey: ["fixer", pair.id] });
if (!res.error) onCheck(true);
},
onError: (e) => toast("err", e.message),
});
const changed = a.files.filter((f) => Object.keys(f.changes).length > 0).length;
const r = a.release;
return (
<div className={cx("rounded-lg border", blocked ? "border-amber-500/25 bg-amber-500/5" : "border-line")}>
<div className="flex flex-wrap items-start gap-3 px-3 py-2.5">
<input
type="checkbox"
className="mt-1 accent-violet-500"
disabled={blocked}
checked={checked && !blocked}
onChange={(e) => onCheck(e.target.checked)}
/>
<div className="min-w-0 flex-1 space-y-1">
<div className="mono break-all text-sm text-gray-100">{a.folder}</div>
{r && !blocked ? (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
<span className="text-gray-200">
{r.artist} – {r.title}
</span>
<span className="text-muted">
{[r.date, r.country, r.formats, r.track_count ? `${r.track_count} tracks` : ""].filter(Boolean).join(" · ")}
</span>
{a.source === "master" ? <Badge tone="violet">from master</Badge> : <Badge>MusicBrainz</Badge>}
<Confidence c={a.confidence} />
<span className="text-muted">
{a.matched} of {a.files.length} songs matched · {changed} change
</span>
</div>
) : (
<div className="text-xs text-amber-200">{a.error || "No confident match"}</div>
)}
</div>
<div className="flex flex-wrap items-center gap-1.5">
{a.source === "master" ? (
<Button size="sm" variant="ghost" loading={choose.isPending} onClick={() => choose.mutate("search")}>
<Search className="size-3.5" /> Use MusicBrainz
</Button>
) : (
a.candidates.length > 0 && (
<select
className={inputCls + " h-8 w-56 text-xs"}
value={r?.id ?? ""}
disabled={choose.isPending}
onChange={(e) => e.target.value && choose.mutate(e.target.value)}
title="Pick another release"
>
<option value="">{r ? "Other releases…" : "Pick a release…"}</option>
{a.candidates.map((c) => (
<option key={c.id} value={c.id}>
{c.artist} – {c.title} ({[c.date?.slice(0, 4), c.country, c.formats, `${c.track_count} tr`].filter(Boolean).join(", ")})
</option>
))}
</select>
)
)}
{choose.isPending && <Spinner className="size-4" />}
<Button size="sm" variant="ghost" onClick={onEdit} title="Edit the album's tags by hand">
<Pencil className="size-3.5" /> Edit album
</Button>
{!blocked && (
<Button size="sm" variant="ghost" onClick={() => setOpen(!open)}>
{open ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />} Changes
</Button>
)}
</div>
</div>
{open && !blocked && (
<div className="border-t border-line px-3 py-2">
{Object.keys(a.album_changes).length > 0 && (
<div className="mb-2 text-xs">
<span className="mr-2 text-muted">Every song:</span>
<Changes changes={a.album_changes} />
</div>
)}
<table className="w-full text-xs">
<thead className="text-left text-muted">
<tr>
<th className="py-1 pr-3 font-normal">File</th>
<th className="py-1 pr-3 font-normal">Matched track</th>
<th className="py-1 font-normal">Changes</th>
</tr>
</thead>
<tbody className="divide-y divide-line">
{a.files.map((f) => {
const own = Object.fromEntries(Object.entries(f.changes).filter(([k]) => !(k in a.album_changes)));
return (
<tr key={f.path} className="align-top">
<td className="mono break-all py-1 pr-3 text-gray-300">{f.path.slice(a.folder.length + 1)}</td>
<td className="py-1 pr-3 text-gray-300">{f.track ?? <span className="text-amber-300">not matched</span>}</td>
<td className="py-1">{Object.keys(own).length ? <Changes changes={own} /> : <span className="text-muted">—</span>}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
// ---- linking ----
function LinkPanel({ pair }: { pair: Pair }) {
const qc = useQueryClient();
const toast = useToast();
const [confirm, setConfirm] = useState(false);
const c = pair.summary.counts;
const open = SIDES.flatMap((s) => STEPS.filter((st) => !finished(pair.fixer[s.id][st.id])).map((st) => `${s.title}: ${st.title}`));
const refresh = () => {
qc.invalidateQueries({ queryKey: ["pair", pair.id] });
qc.invalidateQueries({ queryKey: ["pairs"] });
};
const link = useMutation({
mutationFn: () => api.post(`/pairs/${pair.id}/link-identical`),
onSuccess: () => {
setConfirm(false);
refresh();
},
onError: (e) => toast("err", e.message),
});
const mark = useMutation({
mutationFn: (state: StepStatus["state"]) => api.put(`/pairs/${pair.id}/fixer/step`, { side: "", step: "link", state }),
onSuccess: refresh,
onError: (e) => toast("err", e.message),
});
const running = pair.job?.state === "running";
const stat = (label: string, n: number | undefined, tone?: string) => (
<div className="rounded-lg border border-line bg-panel-2/50 px-3 py-2">
<div className={cx("text-lg font-semibold tabular-nums", tone)}>{n ?? 0}</div>
<div className="text-xs text-muted">{label}</div>
</div>
);
return (
<Card className="space-y-5 p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-xs uppercase tracking-wide text-muted">Both libraries</div>
<h2 className="flex items-center gap-2 text-lg font-semibold">
<Link2 className="size-5 text-accent" /> Link the libraries
{pair.fixer.link?.state === "done" && <Badge tone="green">done {formatAgo(pair.fixer.link.at)}</Badge>}
</h2>
</div>
{pair.fixer.link?.state === "done" ? (
<Button size="sm" variant="ghost" onClick={() => mark.mutate("")}>
<RotateCcw className="size-3.5" /> Reopen
</Button>
) : (
<Button size="sm" onClick={() => mark.mutate("done")}>
<Check className="size-3.5" /> Mark done
</Button>
)}
</div>
<p className="text-sm text-muted">
With both libraries organized the same way, songs match by path. Identical songs are replaced in the slave with a hardlink to the master, so
they take up space only once. Songs that differ (another quality, other tags) are left for you in Review.
</p>
{open.length > 0 && (
<Alert tone="amber" icon={<AlertTriangle className="mt-0.5 size-4 shrink-0" />}>
Not finished yet: {open.join(", ")}. Linking works anyway, but songs in folders that are still named differently won't match.
</Alert>
)}
<section className="space-y-2">
<h3 className="text-sm font-medium">1. Scan both libraries</h3>
<div className="flex flex-wrap items-center gap-3 text-sm">
<ScanButton pair={pair} />
<span className="text-muted">Last scan {formatAgo(pair.last_scan_at)}</span>
</div>
{running && pair.job && <JobBar job={pair.job} />}
</section>
{pair.last_scan_warnings.length > 0 && (
<section className="space-y-2">
<h3 className="text-sm font-medium">2. Folders named differently</h3>
<WarningList warnings={pair.last_scan_warnings} pair={pair} />
</section>
)}
<section className="space-y-2">
<h3 className="text-sm font-medium">{pair.last_scan_warnings.length > 0 ? "3" : "2"}. Link identical songs</h3>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-5">
{stat("already linked", c.linked, "text-emerald-300")}
{stat("identical", c.identical, "text-accent")}
{stat("different", (c.different ?? 0) + (c.similar ?? 0), "text-amber-300")}
{stat("only in slave", c.unmatched)}
{stat("errors", c.error, c.error ? "text-red-300" : undefined)}
</div>
{confirm ? (
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-accent/40 bg-accent/10 px-3 py-2 text-sm">
Replace {c.identical ?? 0} slave songs with hardlinks to the identical master songs? Each is compared byte by byte first.
<Button size="sm" variant="primary" loading={link.isPending} onClick={() => link.mutate()}>
Link them
</Button>
<Button size="sm" variant="ghost" onClick={() => setConfirm(false)}>
Cancel
</Button>
</div>
) : (
<Button variant="primary" disabled={!c.identical || running} onClick={() => setConfirm(true)}>
<Link2 className="size-4" /> Link {c.identical ?? 0} identical songs
</Button>
)}
</section>
<section className="space-y-2">
<h3 className="text-sm font-medium">{pair.last_scan_warnings.length > 0 ? "4" : "3"}. Review the differences</h3>
<p className="text-sm text-muted">Upgrades, other formats and approximate matches are reviewed one album or song at a time.</p>
<a href={`#/pair/${pair.id}/review`}>
<Button>
Open Review <ArrowRight className="size-4" />
{pair.summary.review > 0 && <Badge tone="amber">{pair.summary.review}</Badge>}
</Button>
</a>
</section>
</Card>
);
}
+29 -3
View File
@@ -1,12 +1,17 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronLeft, Folder, FolderOpen } from "lucide-react";
import { api, type Pair, type PairInput, type ScanWarning } from "../api";
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, inputCls } from "../ui";
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 }) {
@@ -26,6 +31,7 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
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(", "));
@@ -55,6 +61,7 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
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]) => {
@@ -71,7 +78,7 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["pairs"] });
qc.invalidateQueries({ queryKey: ["pair"] });
toast("ok", pair ? "Library pair updated" : "Library pair created. Run a scan to find matches.");
toast("ok", pair ? "Library pair updated" : "Library pair created. Start with the Fixer, master library first.");
onClose();
},
});
@@ -82,6 +89,25 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
<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)"
+4
View File
@@ -12,6 +12,7 @@ import { OpsTable } from "./Activity";
import { WarningList } from "./Warnings";
import { TagsTab } from "./Tags";
import { OrganizeTab } from "./Organize";
import { FixerTab } from "./Fixer";
export function usePair(id: number) {
return useQuery({
@@ -41,6 +42,7 @@ function useJobFinished(pair: Pair | undefined) {
qc.invalidateQueries({ queryKey: ["ops"] });
qc.invalidateQueries({ queryKey: ["pairs"] });
qc.invalidateQueries({ queryKey: ["files"] });
qc.invalidateQueries({ queryKey: ["fixer", pair.id] });
const j = pair.job!;
if (st === "done") toast("ok", `${j.kind} finished${j.message ? `: ${j.message}` : ""}`);
else toast("err", `${j.kind} ${st}: ${j.error}`);
@@ -123,6 +125,7 @@ function LastJob({ job }: { job: Job }) {
}
const tabs = [
{ id: "fixer", label: "Fixer" },
{ id: "review", label: "Review" },
{ id: "build", label: "Build" },
{ id: "tags", label: "Tags" },
@@ -205,6 +208,7 @@ export function PairPage({ id, tab }: { id: number; tab: string }) {
))}
</div>
{tab === "fixer" && <FixerTab pair={pair} />}
{tab === "review" && <Review pair={pair} />}
{tab === "build" && <Build pair={pair} />}
{tab === "tags" && <TagsTab pair={pair} />}