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:
1 parent
b11d0806c1
commit
428935dc58
19 files changed
+3503
-23
No files matched your search
+1
-1
@@ -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 />;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)"
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
Reference in new issue
Block a user