Remove the organizer's Preview and apply section
The preview/apply flow on the Organize tab did not work as expected, so it is removed along with its backend: the stored per-pair preview, the /organize/preview, /organize/plan and /organize/apply endpoints and their UI and types. The File naming card stays, and renaming now happens only through "Organize automatically" during scans, which keeps the underlying planning/moving code (now internal), undo, crash recovery and dry run. The rename-folders warning is unchanged. Tests now drive the kept planning and moving code directly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dbcf562f7e
commit
96ea856dfd
8 files changed
+76
-462
No files matched your search
@@ -240,16 +240,3 @@ export type TagFile = {
|
||||
links: number;
|
||||
size: number;
|
||||
};
|
||||
|
||||
export type OrganizeItem = { id: number; side: Side; from: string; to?: string; move: boolean; reason?: string; sidecars?: string[] };
|
||||
|
||||
export type OrganizePlan = {
|
||||
pair_id: number;
|
||||
sides: Side[];
|
||||
template: string;
|
||||
created_at: number;
|
||||
scanned: number;
|
||||
conforming: number;
|
||||
moves: number;
|
||||
skips: Record<string, number>;
|
||||
};
|
||||
+11
-223
@@ -1,11 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, ArrowRight, FolderTree, Tags as TagsIcon } from "lucide-react";
|
||||
import { api, type OrganizeItem, type OrganizePlan, type Pair, type Side } from "../api";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, FolderTree } from "lucide-react";
|
||||
import { api, type Pair } from "../api";
|
||||
import { useInfo } from "../App";
|
||||
import { Alert, Badge, Button, Card, Empty, Field, Modal, Spinner, cx, formatAgo, inputCls } from "../ui";
|
||||
import { Alert, Button, Card, Field, inputCls } from "../ui";
|
||||
import { useToast } from "../toast";
|
||||
import { TagEditor } from "./Tags";
|
||||
|
||||
const TOKENS: [string, string][] = [
|
||||
["artist", "album artist, or artist"],
|
||||
@@ -39,8 +38,6 @@ function example(tpl: string, multi?: Pair["organize_multidisc"]) {
|
||||
return parts.join("/") + ".flac";
|
||||
}
|
||||
|
||||
type Scope = Side | "both";
|
||||
|
||||
export function OrganizeTab({ pair }: { pair: Pair }) {
|
||||
const info = useInfo();
|
||||
if (info.data && !info.data.tag_editing)
|
||||
@@ -48,7 +45,6 @@ export function OrganizeTab({ pair }: { pair: Pair }) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<NamingSettings pair={pair} />
|
||||
<Preview pair={pair} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -99,7 +95,10 @@ function NamingSettings({ pair }: { pair: Pair }) {
|
||||
<h2 className="flex items-center gap-2 font-semibold">
|
||||
<FolderTree className="size-4 text-accent" /> File naming
|
||||
</h2>
|
||||
<p className="text-sm text-muted">Files are renamed from their tags into this layout. Nothing else about a file changes.</p>
|
||||
<p className="text-sm text-muted">
|
||||
Files that don't follow this layout are renamed from their tags during scans, when <b>Organize automatically</b> is on. Nothing else about
|
||||
a file changes.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="primary" size="sm" disabled={!dirty} loading={save.isPending} onClick={() => save.mutate()}>
|
||||
Save
|
||||
@@ -138,9 +137,9 @@ function NamingSettings({ pair }: { pair: Pair }) {
|
||||
<option value="none">Nothing (clashing names are skipped)</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Organize automatically" hint="During every scan, clean renames are applied without a preview.">
|
||||
<Field label="Organize automatically" hint="Renames run at the start of every scan.">
|
||||
<select className={inputCls} value={auto} onChange={(e) => setAuto(e.target.value as Pair["auto_organize"])}>
|
||||
<option value="off">Off: preview and apply by hand</option>
|
||||
<option value="off">Off: nothing is renamed</option>
|
||||
<option value="slave">Slave library</option>
|
||||
<option value="master">Master library</option>
|
||||
<option value="both">Both libraries</option>
|
||||
@@ -150,221 +149,10 @@ function NamingSettings({ pair }: { pair: Pair }) {
|
||||
{auto !== "off" && (
|
||||
<Alert tone="amber" icon={<AlertTriangle className="mt-0.5 size-4 shrink-0" />}>
|
||||
Files are renamed during each scan without asking. Every rename is listed in History and can be undone, and files with missing tags or
|
||||
clashing names are left alone. Dry run still applies.
|
||||
clashing names are left alone (fix their tags in the Tags tab). Lyrics files and cover art move along. Dry run still applies.
|
||||
{auto !== "both" && " Organizing only one library can make paths differ from the other; hardlinked songs stay paired either way."}
|
||||
</Alert>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Preview({ pair }: { pair: Pair }) {
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const dry = useInfo().data?.dry_run ?? true;
|
||||
const [scope, setScope] = useState<Scope>("both");
|
||||
const [view, setView] = useState<"move" | "skip">("move");
|
||||
const [sel, setSel] = useState<Set<number> | null>(null); // null = all
|
||||
const [confirm, setConfirm] = useState(false);
|
||||
const [editTags, setEditTags] = useState<OrganizeItem | null>(null);
|
||||
const running = pair.job?.state === "running";
|
||||
|
||||
const plan = useQuery({
|
||||
queryKey: ["organize", pair.id, view],
|
||||
queryFn: () =>
|
||||
api.get<{ plan: OrganizePlan | null; items?: OrganizeItem[]; total?: number }>(`/pairs/${pair.id}/organize/plan?filter=${view}&limit=500`),
|
||||
});
|
||||
useEffect(() => setSel(null), [plan.data?.plan?.created_at]);
|
||||
|
||||
const preview = useMutation({
|
||||
mutationFn: () => api.post(`/pairs/${pair.id}/organize/preview`, { side: scope }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["pair", pair.id] }),
|
||||
onError: (e) => toast("err", e.message),
|
||||
});
|
||||
const apply = useMutation({
|
||||
mutationFn: () => api.post(`/pairs/${pair.id}/organize/apply`, { ids: sel ? [...sel] : [] }),
|
||||
onSuccess: () => {
|
||||
setConfirm(false);
|
||||
toast("ok", dry ? "Started (dry run): renames are recorded as planned only" : "Renaming files. The library is rescanned afterwards.");
|
||||
qc.invalidateQueries({ queryKey: ["pair", pair.id] });
|
||||
},
|
||||
onError: (e) => toast("err", e.message),
|
||||
});
|
||||
|
||||
const p = plan.data?.plan;
|
||||
const items = plan.data?.items ?? [];
|
||||
const skipped = p ? Object.values(p.skips).reduce((a, b) => a + b, 0) : 0;
|
||||
const chosen = sel ? sel.size : (p?.moves ?? 0);
|
||||
const both = (p?.sides.length ?? 0) > 1;
|
||||
|
||||
return (
|
||||
<Card className="space-y-4 p-5">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold">Preview and apply</h2>
|
||||
<p className="text-sm text-muted">See what would be renamed before anything changes.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select className={inputCls + " h-8 w-auto text-xs"} value={scope} onChange={(e) => setScope(e.target.value as Scope)}>
|
||||
<option value="both">Both libraries (recommended)</option>
|
||||
<option value="slave">Slave library</option>
|
||||
<option value="master">Master library</option>
|
||||
</select>
|
||||
<Button size="sm" onClick={() => preview.mutate()} loading={preview.isPending} disabled={running}>
|
||||
Preview renames
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{running && pair.job?.kind.startsWith("organize") && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted">
|
||||
<Spinner className="size-4" /> {pair.job.phase || "Working"}… {pair.job.total > 0 && `${pair.job.done} / ${pair.job.total}`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!p && !running && (
|
||||
<Empty icon={<FolderTree className="size-8" />} title="No preview yet">
|
||||
Choose which library to organize and click <b>Preview renames</b>. Organizing both keeps their paths in step, so songs keep matching.
|
||||
</Empty>
|
||||
)}
|
||||
|
||||
{p && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<Stat label="checked" value={p.scanned} />
|
||||
<Stat label="already organized" value={p.conforming} tone="text-emerald-300" />
|
||||
<Stat label="to rename" value={p.moves} tone="text-accent" />
|
||||
<Stat label="skipped" value={skipped} tone={skipped ? "text-amber-300" : undefined} />
|
||||
</div>
|
||||
<div className="text-xs text-muted">
|
||||
Preview of the {p.sides.join(" and ")} {both ? "libraries" : "library"} · {formatAgo(p.created_at)} · pattern{" "}
|
||||
<code className="mono">{p.template}</code>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 border-b border-line">
|
||||
{(["move", "skip"] as const).map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => setView(v)}
|
||||
className={cx("-mb-px border-b-2 px-3 py-1.5 text-sm cursor-pointer", view === v ? "border-accent text-white" : "border-transparent text-muted hover:text-white")}
|
||||
>
|
||||
{v === "move" ? `To rename (${p.moves})` : `Skipped (${skipped})`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{view === "skip" && skipped > 0 && (
|
||||
<ul className="space-y-0.5 text-xs text-muted">
|
||||
{Object.entries(p.skips).map(([why, n]) => (
|
||||
<li key={why}>
|
||||
<b className="tabular-nums text-gray-300">{n}</b> {why}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-line">
|
||||
{view === "move" && items.length > 0 && (
|
||||
<label className="flex items-center gap-3 border-b border-line bg-panel-2 px-3 py-2 text-xs text-muted cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-violet-500"
|
||||
checked={sel === null}
|
||||
onChange={(e) => setSel(e.target.checked ? null : new Set())}
|
||||
/>
|
||||
{sel === null ? `All ${p.moves} selected` : `${sel.size} selected`}
|
||||
</label>
|
||||
)}
|
||||
<div className="max-h-[55vh] overflow-y-auto">
|
||||
{items.length === 0 && <div className="p-6 text-center text-sm text-muted">Nothing here.</div>}
|
||||
{items.map((it) => (
|
||||
<div key={it.id} className="flex items-start gap-3 border-b border-line px-3 py-2 last:border-0">
|
||||
{it.move && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 accent-violet-500"
|
||||
checked={sel === null || sel.has(it.id)}
|
||||
onChange={(e) =>
|
||||
setSel((s) => {
|
||||
const n = new Set(s ?? items.filter((x) => x.move).map((x) => x.id));
|
||||
if (e.target.checked) n.add(it.id);
|
||||
else n.delete(it.id);
|
||||
return n;
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{both && <Badge tone={it.side === "master" ? "violet" : "gray"}>{it.side}</Badge>}
|
||||
<div className="min-w-0 flex-1 text-xs mono">
|
||||
{it.move ? (
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-start gap-2">
|
||||
<span className="break-all text-gray-400">{it.from}</span>
|
||||
<ArrowRight className="mt-0.5 size-3 text-muted" />
|
||||
<span className="break-all text-gray-100">{it.to}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="break-all text-gray-300">{it.from}</div>
|
||||
)}
|
||||
{it.sidecars?.length ? <div className="mt-0.5 font-sans text-[11px] text-muted">+ lyrics file moves along</div> : null}
|
||||
{!it.move && <div className="font-sans text-[11px] text-amber-300">{it.reason}</div>}
|
||||
</div>
|
||||
{!it.move && it.reason?.startsWith("missing") && (
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditTags(it)}>
|
||||
<TagsIcon className="size-3.5" /> Fix tags
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{(plan.data?.total ?? 0) > items.length && (
|
||||
<p className="text-xs text-muted">Showing the first {items.length} of {plan.data?.total}. Applying all includes every file.</p>
|
||||
)}
|
||||
|
||||
{p.moves > 0 && (
|
||||
<div className="flex justify-end">
|
||||
<Button variant="primary" disabled={chosen === 0 || running} onClick={() => setConfirm(true)}>
|
||||
<FolderTree className="size-4" /> Rename {chosen} file{chosen !== 1 && "s"}…
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Modal open={confirm} onClose={() => setConfirm(false)} title={`Rename ${chosen} file${chosen !== 1 ? "s" : ""}?`}>
|
||||
<ul className="list-disc space-y-1 pl-5 text-sm text-gray-300">
|
||||
<li>Files are moved to their new names inside the same library. Their contents and hardlinks don't change.</li>
|
||||
<li>Lyrics files with the same name move along; cover art follows when a whole folder moves. Emptied folders are removed.</li>
|
||||
<li>
|
||||
<b>Playlists or apps that point at the old paths will need updating.</b>
|
||||
</li>
|
||||
{p && !both && <li>Only the {p.sides[0]} library is renamed. Hardlinked songs stay paired, but paths may differ from the other library.</li>}
|
||||
<li>Each rename is listed in History and can be undone. The library is rescanned when it finishes.</li>
|
||||
</ul>
|
||||
{dry && (
|
||||
<div className="mt-3">
|
||||
<Alert tone="blue">Dry run is on, so the renames are recorded as planned in Activity. No files move.</Alert>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setConfirm(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" loading={apply.isPending} onClick={() => apply.mutate()}>
|
||||
Rename {chosen} file{chosen !== 1 && "s"}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
{editTags && <TagEditor pairId={pair.id} side={editTags.side} paths={[editTags.from]} onClose={() => setEditTags(null)} />}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, tone }: { label: string; value: number; tone?: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-line bg-panel-2 px-3 py-2">
|
||||
<div className={cx("text-xl font-semibold tabular-nums", tone)}>{value}</div>
|
||||
<div className="text-xs text-muted">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -40,7 +40,6 @@ function useJobFinished(pair: Pair | undefined) {
|
||||
qc.invalidateQueries({ queryKey: ["tree"] });
|
||||
qc.invalidateQueries({ queryKey: ["ops"] });
|
||||
qc.invalidateQueries({ queryKey: ["pairs"] });
|
||||
qc.invalidateQueries({ queryKey: ["organize"] });
|
||||
qc.invalidateQueries({ queryKey: ["files"] });
|
||||
const j = pair.job!;
|
||||
if (st === "done") toast("ok", `${j.kind} finished${j.message ? `: ${j.message}` : ""}`);
|
||||
|
||||
Reference in new issue
Block a user