Redo file naming in the style of Lidarr and add "Rename now"
Naming: separate artist folder, album folder, track and multi-disc track
formats with presets, Lidarr-style tokens ({Artist Name}, {Artist
NameThe}, {Artist CleanName}, {Artist NameFirstCharacter}, {Album
Title}, {Album TitleThe}, {Album CleanTitle}, {Release Year},
{track:00}, {medium:00}, {Medium Format}, {Track Title}, {Track
CleanTitle}, {Track ArtistName}, {Genre}) with Lidarr's word-separator
and case variants, colon replacement modes and replace/remove for other
illegal characters. Empty brackets and separators left by missing values
are cleaned up. Defaults to Artist/Album/01 Title with Disc 01 folders
for multi-disc albums; earlier pattern/disc settings are converted.
Examples are now rendered by the server with the same code as real
renames, fixing the settings page showing paths that differed from what
would happen (e.g. disc 1 of a multi-disc album outside its Disc folder).
"Rename now..." checks one or both libraries, then lists every change
grouped by destination folder with per-file and per-folder selection,
skipped files with reasons and a Fix tags button, and warnings; applying
re-checks each file, renames, and rescans. The pre-action backup runs
first when enabled. Migration 9 stores the naming as JSON.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
49fc32096a
commit
5fb195f0d6
12 files changed
+1297
-355
No files matched your search
+14
-6
@@ -76,10 +76,8 @@ export type Pair = {
|
||||
settle_minutes: number;
|
||||
approx_matching: boolean;
|
||||
approx_threshold: number;
|
||||
organize_template: string;
|
||||
organize_multidisc: "prefix" | "folder" | "none";
|
||||
auto_organize: "off" | "slave" | "master" | "both";
|
||||
organize_disc_digits: number;
|
||||
naming: Naming;
|
||||
last_scan_at: number | null;
|
||||
last_scan_error: string;
|
||||
last_scan_warnings: ScanWarning[];
|
||||
@@ -100,10 +98,7 @@ export type PairInput = Pick<
|
||||
| "settle_minutes"
|
||||
| "approx_matching"
|
||||
| "approx_threshold"
|
||||
| "organize_template"
|
||||
| "organize_multidisc"
|
||||
| "auto_organize"
|
||||
| "organize_disc_digits"
|
||||
>;
|
||||
|
||||
export type ProbeInfo = {
|
||||
@@ -245,3 +240,16 @@ export type TagFile = {
|
||||
|
||||
export type BackupConfig = { folder: string; interval_days: number; retention_days: number; before_actions: boolean };
|
||||
export type Backup = { name: string; type: "manual" | "scheduled" | "action"; size: number; created: number };
|
||||
|
||||
export type Naming = {
|
||||
artist_folder: string;
|
||||
album_folder: string;
|
||||
track_format: string;
|
||||
multi_disc_format: string;
|
||||
colon: "smart" | "delete" | "dash" | "space_dash" | "space_dash_space";
|
||||
illegal: "replace" | "remove";
|
||||
};
|
||||
|
||||
export type OrganizeItem = { id: number; side: Side; from: string; to?: string; move: boolean; reason?: string; sidecars?: string[] };
|
||||
|
||||
export type OrganizePlan = { sides: Side[]; created_at: number; scanned: number; conforming: number; moves: number; skips: Record<string, number> };
|
||||
+396
-91
@@ -1,70 +1,61 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, FolderTree } from "lucide-react";
|
||||
import { api, type Pair } from "../api";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, ArrowRight, ChevronDown, ChevronRight, Folder, FolderTree, Tags as TagsIcon } from "lucide-react";
|
||||
import { api, type Job, type Naming, type OrganizeItem, type OrganizePlan, type Pair, type Side } from "../api";
|
||||
import { useInfo } from "../App";
|
||||
import { Alert, Button, Card, Field, inputCls } from "../ui";
|
||||
import { Alert, Badge, Button, Card, Field, Modal, Spinner, cx, inputCls } from "../ui";
|
||||
import { useToast } from "../toast";
|
||||
import { TagEditor } from "./Tags";
|
||||
|
||||
const TOKENS: [string, string][] = [
|
||||
["artist", "album artist, or artist"],
|
||||
["album", "album"],
|
||||
["track", "track number, 01"],
|
||||
["title", "title"],
|
||||
["year", "year"],
|
||||
["disc", "disc number"],
|
||||
["track_artist", "the song's artist"],
|
||||
["genre", "genre"],
|
||||
type NamingInfo = { defaults: Naming; presets: { name: string; config: Naming }[]; tokens: [string, string][] };
|
||||
type Scope = Side | "both";
|
||||
|
||||
const FORMAT_FIELDS: { key: keyof Naming; label: string; hint: string }[] = [
|
||||
{ key: "artist_folder", label: "Artist folder", hint: "One folder name." },
|
||||
{ key: "album_folder", label: "Album folder", hint: "One folder name, inside the artist folder." },
|
||||
{ key: "track_format", label: "Track", hint: "File name inside the album folder. May add subfolders with /." },
|
||||
{ key: "multi_disc_format", label: "Multi-disc track", hint: "Used when an album has more than one disc." },
|
||||
];
|
||||
|
||||
const SAMPLE: Record<string, string> = {
|
||||
artist: "Avenged Sevenfold",
|
||||
album: "Nightmare",
|
||||
track: "01",
|
||||
title: "Nightmare",
|
||||
year: "2010",
|
||||
disc: "1",
|
||||
track_artist: "Avenged Sevenfold",
|
||||
genre: "Metal",
|
||||
};
|
||||
|
||||
/** Renders the pattern for a sample song; disc 2 shows the multi-disc style. */
|
||||
function example(tpl: string, multi?: Pair["organize_multidisc"], digits = 2) {
|
||||
const d = digits === 1 ? "2" : "02";
|
||||
const values: Record<string, string> = multi
|
||||
? { ...SAMPLE, track: multi === "prefix" ? `${d}-04` : "04", title: "Victim", disc: d }
|
||||
: { ...SAMPLE, disc: digits === 1 ? "1" : "01" };
|
||||
let parts = tpl.split("/").map((part) => part.replace(/\{([a-z_]+)\}/g, (_, k: string) => values[k] ?? `{${k}}`));
|
||||
if (multi === "folder") parts = [...parts.slice(0, -1), `Disc ${d}`, parts[parts.length - 1]];
|
||||
return parts.join("/") + ".flac";
|
||||
}
|
||||
|
||||
export function OrganizeTab({ pair }: { pair: Pair }) {
|
||||
const info = useInfo();
|
||||
if (info.data && !info.data.tag_editing)
|
||||
return <Alert tone="amber">Organizing reads tags with ffmpeg and ffprobe, which were not found. The Docker image includes both.</Alert>;
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<NamingSettings pair={pair} />
|
||||
<RenameNowCard pair={pair} />
|
||||
<NamingCard pair={pair} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NamingSettings({ pair }: { pair: Pair }) {
|
||||
// ---- naming settings ----
|
||||
|
||||
function NamingCard({ pair }: { pair: Pair }) {
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [tpl, setTpl] = useState(pair.organize_template);
|
||||
const [multi, setMulti] = useState(pair.organize_multidisc);
|
||||
const info = useQuery({ queryKey: ["naming-info"], queryFn: () => api.get<NamingInfo>("/naming"), staleTime: Infinity });
|
||||
const [cfg, setCfg] = useState<Naming>(pair.naming);
|
||||
const [auto, setAuto] = useState(pair.auto_organize);
|
||||
const [digits, setDigits] = useState(pair.organize_disc_digits || 2);
|
||||
const [showTokens, setShowTokens] = useState(false);
|
||||
const focused = useRef<{ key: keyof Naming; el: HTMLInputElement } | null>(null);
|
||||
const saved = JSON.stringify(pair.naming);
|
||||
useEffect(() => {
|
||||
setTpl(pair.organize_template);
|
||||
setMulti(pair.organize_multidisc);
|
||||
setCfg(pair.naming);
|
||||
setAuto(pair.auto_organize);
|
||||
setDigits(pair.organize_disc_digits || 2);
|
||||
}, [pair.id, pair.organize_template, pair.organize_multidisc, pair.auto_organize, pair.organize_disc_digits]);
|
||||
const dirty =
|
||||
tpl !== pair.organize_template || multi !== pair.organize_multidisc || auto !== pair.auto_organize || digits !== (pair.organize_disc_digits || 2);
|
||||
}, [pair.id, saved, pair.auto_organize]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const dirty = JSON.stringify(cfg) !== saved || auto !== pair.auto_organize;
|
||||
|
||||
// Examples are rendered by the server with the same code as real renames.
|
||||
const [debounced, setDebounced] = useState(cfg);
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebounced(cfg), 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [cfg]);
|
||||
const examples = useQuery({
|
||||
queryKey: ["naming-examples", JSON.stringify(debounced)],
|
||||
queryFn: () => api.post<{ examples: { label: string; path: string }[]; error?: string }>("/naming/examples", debounced),
|
||||
});
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
@@ -80,19 +71,30 @@ function NamingSettings({ pair }: { pair: Pair }) {
|
||||
settle_minutes: pair.settle_minutes,
|
||||
approx_matching: pair.approx_matching,
|
||||
approx_threshold: pair.approx_threshold,
|
||||
organize_template: tpl,
|
||||
organize_multidisc: multi,
|
||||
auto_organize: auto,
|
||||
organize_disc_digits: digits,
|
||||
naming: cfg,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast("ok", "Naming settings saved. Preview again to see the effect.");
|
||||
toast("ok", "Naming saved");
|
||||
qc.invalidateQueries({ queryKey: ["pair", pair.id] });
|
||||
qc.invalidateQueries({ queryKey: ["pairs"] });
|
||||
},
|
||||
onError: (e) => toast("err", e.message),
|
||||
});
|
||||
|
||||
const insert = (token: string) => {
|
||||
const f = focused.current;
|
||||
if (!f) return;
|
||||
const v = cfg[f.key] as string;
|
||||
const start = f.el.selectionStart ?? v.length;
|
||||
const end = f.el.selectionEnd ?? v.length;
|
||||
setCfg({ ...cfg, [f.key]: v.slice(0, start) + token + v.slice(end) });
|
||||
requestAnimationFrame(() => {
|
||||
f.el.focus();
|
||||
f.el.setSelectionRange(start + token.length, start + token.length);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="space-y-4 p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
@@ -100,57 +102,104 @@ 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 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>
|
||||
<p className="text-sm text-muted">How files are named from their tags, in the style of Lidarr. Only the name and folder change.</p>
|
||||
</div>
|
||||
<Button variant="primary" size="sm" disabled={!dirty} loading={save.isPending} onClick={() => save.mutate()}>
|
||||
<Button variant="primary" size="sm" disabled={!dirty || !!examples.data?.error} loading={save.isPending} onClick={() => save.mutate()}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
<Field label="Naming pattern" hint={<>Folders are separated by <code className="mono">/</code>. The file extension is added automatically.</>}>
|
||||
<input className={inputCls + " mono"} value={tpl} onChange={(e) => setTpl(e.target.value)} spellCheck={false} />
|
||||
</Field>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{TOKENS.map(([t, help]) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
title={help}
|
||||
className="rounded-md border border-line bg-panel-2 px-2 py-0.5 text-xs mono text-gray-300 hover:border-accent cursor-pointer"
|
||||
onClick={() => setTpl((v) => v + `{${t}}`)}
|
||||
>
|
||||
{`{${t}}`}
|
||||
</button>
|
||||
|
||||
{info.data && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<span className="text-muted">Presets:</span>
|
||||
{info.data.presets.map((p) => (
|
||||
<button
|
||||
key={p.name}
|
||||
type="button"
|
||||
className={cx(
|
||||
"rounded-md border px-2 py-0.5 mono cursor-pointer",
|
||||
JSON.stringify(p.config) === JSON.stringify(cfg) ? "border-accent bg-accent/15 text-white" : "border-line bg-panel-2 text-gray-300 hover:border-accent",
|
||||
)}
|
||||
onClick={() => setCfg(p.config)}
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{FORMAT_FIELDS.map((f) => (
|
||||
<Field key={f.key} label={f.label} hint={f.hint}>
|
||||
<input
|
||||
className={inputCls + " mono text-xs"}
|
||||
value={cfg[f.key] as string}
|
||||
spellCheck={false}
|
||||
onFocus={(e) => (focused.current = { key: f.key, el: e.currentTarget })}
|
||||
onChange={(e) => setCfg({ ...cfg, [f.key]: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-lg border border-line bg-bg/60 p-3 text-xs">
|
||||
<div className="text-muted">Example</div>
|
||||
<div className="mono break-all text-gray-100">{example(tpl, undefined, digits)}</div>
|
||||
{multi !== "none" && (
|
||||
<div className="mono break-all text-gray-400">
|
||||
{example(tpl, multi, digits)} <span className="font-sans text-muted">(disc 2 of a multi-disc album)</span>
|
||||
|
||||
<div>
|
||||
<button className="flex items-center gap-1 text-xs text-muted hover:text-white cursor-pointer" onClick={() => setShowTokens(!showTokens)}>
|
||||
{showTokens ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />} Tokens (click one to insert it into the field you
|
||||
last clicked)
|
||||
</button>
|
||||
{showTokens && info.data && (
|
||||
<div className="mt-2 grid gap-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{info.data.tokens.map(([tok, ex]) => (
|
||||
<button
|
||||
key={tok}
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => insert(tok)}
|
||||
className="flex items-center justify-between gap-2 rounded-md border border-line bg-panel-2 px-2 py-1 text-left text-xs hover:border-accent cursor-pointer"
|
||||
>
|
||||
<span className="mono text-gray-200">{tok}</span>
|
||||
<span className="truncate text-muted">{ex}</span>
|
||||
</button>
|
||||
))}
|
||||
<p className="text-[11px] text-muted sm:col-span-2 lg:col-span-3">
|
||||
Write a token with dots or underscores to use them between words (<code className="mono">{"{Artist.Name}"}</code> →
|
||||
Avenged.Sevenfold), or in capitals or lower case to change the case (<code className="mono">{"{ARTIST NAME}"}</code>).
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="Multi-disc albums" hint="Keeps disc 1 and disc 2 tracks from getting the same name.">
|
||||
<select className={inputCls} value={multi} onChange={(e) => setMulti(e.target.value as Pair["organize_multidisc"])}>
|
||||
<option value="prefix">Disc number before the track ({digits === 1 ? "2" : "02"}-04 Title)</option>
|
||||
<option value="folder">A folder per disc (Disc {digits === 1 ? "2" : "02"}/04 Title)</option>
|
||||
<option value="none">Nothing (clashing names are skipped)</option>
|
||||
|
||||
<div className="rounded-lg border border-line bg-bg/60 p-3 text-xs">
|
||||
<div className="mb-1 text-muted">Examples</div>
|
||||
{examples.data?.error && <div className="text-red-300">{examples.data.error}</div>}
|
||||
{!examples.data?.error &&
|
||||
examples.data?.examples.map((ex) => (
|
||||
<div key={ex.label} className="grid grid-cols-[11rem_1fr] gap-2 py-0.5">
|
||||
<span className="text-muted">{ex.label}</span>
|
||||
<span className="mono break-all text-gray-100">{ex.path}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Field label="Colons" hint="Colons can't be used in names on Windows/SMB shares.">
|
||||
<select className={inputCls} value={cfg.colon} onChange={(e) => setCfg({ ...cfg, colon: e.target.value as Naming["colon"] })}>
|
||||
<option value="smart">Smart: "A: B" → "A - B"</option>
|
||||
<option value="space_dash_space">Space dash space: "A - B"</option>
|
||||
<option value="space_dash">Space dash: "A -B"</option>
|
||||
<option value="dash">Dash: "A-B"</option>
|
||||
<option value="delete">Delete: "A B"</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Disc numbers" hint="Also used for the {disc} token.">
|
||||
<select className={inputCls} value={digits} onChange={(e) => setDigits(Number(e.target.value))}>
|
||||
<option value={2}>Two digits: Disc 01, 01-04</option>
|
||||
<option value={1}>As is: Disc 1, 1-04</option>
|
||||
<Field label="Other illegal characters" hint={'/ \\ | * ? " < >'}>
|
||||
<select className={inputCls} value={cfg.illegal} onChange={(e) => setCfg({ ...cfg, illegal: e.target.value as Naming["illegal"] })}>
|
||||
<option value="replace">Replace (AC/DC → AC-DC)</option>
|
||||
<option value="remove">Remove (AC/DC → ACDC)</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Organize automatically" hint="Renames run at the start of every scan.">
|
||||
<Field label="Organize automatically" hint="Renames 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: nothing is renamed</option>
|
||||
<option value="off">Off: only with Rename now</option>
|
||||
<option value="slave">Slave library</option>
|
||||
<option value="master">Master library</option>
|
||||
<option value="both">Both libraries</option>
|
||||
@@ -159,11 +208,267 @@ function NamingSettings({ pair }: { pair: Pair }) {
|
||||
</div>
|
||||
{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 (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."}
|
||||
Files are renamed during each scan without asking. Every rename is in History and can be undone; files with missing tags or clashing
|
||||
names are left alone. Dry run still applies.
|
||||
</Alert>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- rename now ----
|
||||
|
||||
function RenameNowCard({ pair }: { pair: Pair }) {
|
||||
const [scope, setScope] = useState<Scope>("both");
|
||||
const [open, setOpen] = useState(false);
|
||||
const running = pair.job?.state === "running";
|
||||
return (
|
||||
<Card className="flex flex-wrap items-center justify-between gap-3 p-5">
|
||||
<div>
|
||||
<h2 className="font-semibold">Rename now</h2>
|
||||
<p className="text-sm text-muted">Check the library against the naming below and review every change before anything is renamed.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select className={inputCls + " h-9 w-auto"} 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 variant="primary" onClick={() => setOpen(true)} disabled={running}>
|
||||
<FolderTree className="size-4" /> Rename now…
|
||||
</Button>
|
||||
</div>
|
||||
{open && <RenameFilesDialog pair={pair} scope={scope} onClose={() => setOpen(false)} />}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
type Stage = { kind: "checking"; job?: Job } | { kind: "ready" } | { kind: "error"; message: string };
|
||||
|
||||
function RenameFilesDialog({ pair, scope, onClose }: { pair: Pair; scope: Scope; onClose: () => void }) {
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const dry = useInfo().data?.dry_run ?? true;
|
||||
const [stage, setStage] = useState<Stage>({ kind: "checking" });
|
||||
const [data, setData] = useState<{ plan: OrganizePlan; items: OrganizeItem[] } | null>(null);
|
||||
const [view, setView] = useState<"changes" | "skipped">("changes");
|
||||
const [sel, setSel] = useState<Set<number>>(new Set());
|
||||
const [editTags, setEditTags] = useState<OrganizeItem | null>(null);
|
||||
|
||||
// Start a check and follow its job until it finishes.
|
||||
const check = async () => {
|
||||
setStage({ kind: "checking" });
|
||||
try {
|
||||
const job = await api.post<Job>(`/pairs/${pair.id}/organize/preview`, { side: scope });
|
||||
for (;;) {
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
const j = await api.get<Job | null>(`/pairs/${pair.id}/job`);
|
||||
if (!j || j.id !== job.id) break;
|
||||
if (j.state !== "running") {
|
||||
if (j.state !== "done") throw new Error(j.error || j.state);
|
||||
break;
|
||||
}
|
||||
setStage({ kind: "checking", job: j });
|
||||
}
|
||||
const res = await api.get<{ plan: OrganizePlan | null; items: OrganizeItem[] }>(`/pairs/${pair.id}/organize/plan`);
|
||||
if (!res.plan) throw new Error("The check did not produce a result. Try again.");
|
||||
setData({ plan: res.plan, items: res.items });
|
||||
setSel(new Set(res.items.filter((i) => i.move).map((i) => i.id)));
|
||||
setStage({ kind: "ready" });
|
||||
} catch (e) {
|
||||
setStage({ kind: "error", message: (e as Error).message });
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
check();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const moves = useMemo(() => (data?.items ?? []).filter((i) => i.move), [data]);
|
||||
const skipped = useMemo(() => (data?.items ?? []).filter((i) => !i.move), [data]);
|
||||
// Group the changes by the folder they end up in.
|
||||
const groups = useMemo(() => {
|
||||
const m = new Map<string, OrganizeItem[]>();
|
||||
for (const it of moves) {
|
||||
const key = `${it.side}\u0000${it.to!.split("/").slice(0, -1).join("/")}`;
|
||||
m.set(key, [...(m.get(key) ?? []), it]);
|
||||
}
|
||||
return [...m.entries()].sort(([a], [b]) => a.localeCompare(b));
|
||||
}, [moves]);
|
||||
const both = (data?.plan.sides.length ?? 0) > 1;
|
||||
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;
|
||||
});
|
||||
|
||||
const apply = useMutation({
|
||||
mutationFn: () => api.post(`/pairs/${pair.id}/organize/apply`, { ids: sel.size === moves.length ? [] : [...sel] }),
|
||||
onSuccess: () => {
|
||||
toast("ok", dry ? "Started (dry run): renames are recorded as planned only" : `Renaming ${sel.size} files. The library is rescanned afterwards.`);
|
||||
qc.invalidateQueries({ queryKey: ["pair", pair.id] });
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast("err", e.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal open onClose={onClose} title="Rename files to match the naming" wide>
|
||||
{stage.kind === "checking" && (
|
||||
<div className="space-y-2 py-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-300">
|
||||
<Spinner className="size-4" /> {stage.job?.phase || "Checking files"}…
|
||||
</div>
|
||||
{stage.job && stage.job.total > 0 && (
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-line">
|
||||
<div className="h-full bg-accent transition-all" style={{ width: `${Math.round((stage.job.done / stage.job.total) * 100)}%` }} />
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted">Reading each file's tags. Nothing is renamed until you confirm.</p>
|
||||
</div>
|
||||
)}
|
||||
{stage.kind === "error" && (
|
||||
<div className="space-y-3">
|
||||
<Alert tone="red">{stage.message}</Alert>
|
||||
<Button onClick={check}>Try again</Button>
|
||||
</div>
|
||||
)}
|
||||
{stage.kind === "ready" && data && (
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<Stat label="checked" value={data.plan.scanned} />
|
||||
<Stat label="already named right" value={data.plan.conforming} tone="text-emerald-300" />
|
||||
<Stat label="to rename" value={data.plan.moves} tone="text-accent" />
|
||||
<Stat label="skipped" value={skipped.length} tone={skipped.length ? "text-amber-300" : undefined} />
|
||||
</div>
|
||||
|
||||
{moves.length === 0 && skipped.length === 0 && <Alert tone="green">Everything already follows the naming.</Alert>}
|
||||
|
||||
{(moves.length > 0 || skipped.length > 0) && (
|
||||
<div className="flex gap-1 border-b border-line">
|
||||
{(["changes", "skipped"] 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 === "changes" ? `Changes (${moves.length})` : `Skipped (${skipped.length})`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === "changes" && moves.length > 0 && (
|
||||
<div className="overflow-hidden rounded-lg border border-line">
|
||||
<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.size === moves.length}
|
||||
onChange={(e) => toggle(moves.map((m) => m.id), e.target.checked)}
|
||||
/>
|
||||
{sel.size} of {moves.length} selected, in {groups.length} folder{groups.length !== 1 && "s"}
|
||||
</label>
|
||||
<div className="max-h-[50vh] overflow-y-auto">
|
||||
{groups.map(([key, items]) => {
|
||||
const [side, folder] = key.split("\u0000");
|
||||
const ids = items.map((i) => i.id);
|
||||
return (
|
||||
<div key={key} className="border-b border-line last:border-0">
|
||||
<label className="flex items-center gap-2 bg-panel-2/50 px-3 py-1.5 text-xs cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-violet-500"
|
||||
checked={ids.every((id) => sel.has(id))}
|
||||
onChange={(e) => toggle(ids, e.target.checked)}
|
||||
/>
|
||||
<Folder className="size-3.5 text-accent" />
|
||||
<span className="mono text-gray-100">{folder}</span>
|
||||
{both && <Badge tone={side === "master" ? "violet" : "gray"}>{side}</Badge>}
|
||||
</label>
|
||||
{items.map((it) => (
|
||||
<label key={it.id} className={cx("flex items-start gap-2 py-1 pl-9 pr-3 text-xs cursor-pointer", !sel.has(it.id) && "opacity-50")}>
|
||||
<input type="checkbox" className="mt-0.5 accent-violet-500" checked={sel.has(it.id)} onChange={(e) => toggle([it.id], e.target.checked)} />
|
||||
<div className="grid min-w-0 flex-1 grid-cols-[1fr_auto_1fr] items-start gap-2 mono">
|
||||
<span className="break-all text-gray-500">{it.from}</span>
|
||||
<ArrowRight className="mt-0.5 size-3 text-muted" />
|
||||
<span className="break-all text-gray-100">
|
||||
{it.to!.split("/").pop()}
|
||||
{it.sidecars?.length ? <span className="ml-1 font-sans text-[10px] text-muted">+ lyrics</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === "skipped" && (
|
||||
<div className="space-y-2">
|
||||
<ul className="text-xs text-muted">
|
||||
{Object.entries(data.plan.skips).map(([why, n]) => (
|
||||
<li key={why}>
|
||||
<b className="tabular-nums text-gray-300">{n}</b> {why}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="max-h-[45vh] overflow-y-auto rounded-lg border border-line">
|
||||
{skipped.map((it) => (
|
||||
<div key={it.id} className="flex items-center gap-2 border-b border-line px-3 py-1.5 text-xs last:border-0">
|
||||
{both && <Badge tone={it.side === "master" ? "violet" : "gray"}>{it.side}</Badge>}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mono break-all text-gray-300">{it.from}</div>
|
||||
<div className="text-amber-300">{it.reason}</div>
|
||||
</div>
|
||||
{it.reason?.startsWith("missing") && (
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditTags(it)}>
|
||||
<TagsIcon className="size-3.5" /> Fix tags
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted">After fixing tags, run Rename now again to include those files.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{moves.length > 0 && (
|
||||
<ul className="list-disc space-y-0.5 pl-5 text-xs text-muted">
|
||||
<li>Files move inside their own 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 className="text-gray-300">Playlists or apps that point at the old paths will need updating.</b>
|
||||
</li>
|
||||
{!both && <li>Only the {data.plan.sides[0]} library is renamed; paths may then differ from the other library (hardlinked songs stay paired).</li>}
|
||||
<li>Each rename is in History and can be undone. The library is rescanned when it finishes.</li>
|
||||
</ul>
|
||||
)}
|
||||
{dry && moves.length > 0 && <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={onClose}>
|
||||
{stage.kind === "ready" && moves.length === 0 ? "Close" : "Cancel"}
|
||||
</Button>
|
||||
{stage.kind === "ready" && moves.length > 0 && (
|
||||
<Button variant="primary" disabled={sel.size === 0} loading={apply.isPending} onClick={() => apply.mutate()}>
|
||||
<FolderTree className="size-4" /> Rename {sel.size} file{sel.size !== 1 && "s"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{editTags && <TagEditor pairId={pair.id} side={editTags.side} paths={[editTags.from]} onClose={() => setEditTags(null)} />}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -25,10 +25,7 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
|
||||
settle_minutes: 10,
|
||||
approx_matching: false,
|
||||
approx_threshold: 85,
|
||||
organize_template: "",
|
||||
organize_multidisc: "prefix",
|
||||
auto_organize: "off",
|
||||
organize_disc_digits: 2,
|
||||
};
|
||||
const [f, setF] = useState<PairInput>(pair ?? blank);
|
||||
const [exts, setExts] = useState((pair?.extensions ?? []).join(", "));
|
||||
@@ -56,11 +53,8 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
|
||||
settle_minutes: f.settle_minutes,
|
||||
approx_matching: f.approx_matching,
|
||||
approx_threshold: f.approx_threshold,
|
||||
// Edited on the Organize tab; sent unchanged so saving here keeps them.
|
||||
organize_template: f.organize_template,
|
||||
organize_multidisc: f.organize_multidisc,
|
||||
// Edited on the Organize tab; sent unchanged so saving here keeps it.
|
||||
auto_organize: f.auto_organize,
|
||||
organize_disc_digits: f.organize_disc_digits,
|
||||
extensions: exts.split(",").map((s) => s.trim()).filter(Boolean),
|
||||
});
|
||||
const set = <K extends keyof PairInput>(k: K, v: PairInput[K]) => {
|
||||
|
||||
Reference in new issue
Block a user