Add organizing files into Artist/Album/01 Title from their tags

A new Organize tab renames audio files from their tags into a per-pair
naming pattern, by default {artist}/{album}/{track} {title} (album
artist, falling back to artist). Multi-disc albums get a disc prefix
(2-04) or a Disc N folder; names are made safe for Windows/SMB shares.

- Preview first: every rename, files already organized, and files
  skipped with the reason (missing tags, with a button into the tag
  editor; two files resolving to the same name; an existing file).
- Applying re-checks each file against the preview, moves it within its
  library (hardlinks and contents untouched, case-only renames via a
  temporary name, completed on startup after a crash), carries .lrc
  lyrics and folder cover art along, and removes emptied folders. Each
  move is an operation that can be undone; the library is rescanned.
- Optional automatic organizing of the slave, master or both during
  scans (off by default). Dry run applies.
- Scans now recognise a slave file that is a hardlink of a master file
  at a different path as linked ("same file, other path") instead of
  unmatched.

Migration 7 adds the per-pair naming settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-29 12:23:41 -07:00
1 parent 3eb02e2c8d
commit dbcf562f7e
16 files changed
+1480 -11

No files matched your search

+28 -3
View File
@@ -76,6 +76,9 @@ 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";
last_scan_at: number | null;
last_scan_error: string;
last_scan_warnings: ScanWarning[];
@@ -96,6 +99,9 @@ export type PairInput = Pick<
| "settle_minutes"
| "approx_matching"
| "approx_threshold"
| "organize_template"
| "organize_multidisc"
| "auto_organize"
>;
export type ProbeInfo = {
@@ -123,7 +129,7 @@ export type Match = {
pair_id: number;
rel_slave: string;
rel_master: string;
kind: "path" | "path_case" | "extension" | "fuzzy" | "elsewhere" | "approximate" | "none";
kind: "path" | "path_case" | "extension" | "fuzzy" | "elsewhere" | "approximate" | "inode" | "none";
status: Status;
master_size: number;
master_mtime: number;
@@ -141,7 +147,7 @@ export type Match = {
export type Operation = {
id: number;
pair_id: number;
action: "link" | "promote" | "build" | "remove" | "rename" | "tags";
action: "link" | "promote" | "build" | "remove" | "rename" | "tags" | "move";
rel_master: string;
rel_slave: string;
rel_new: string;
@@ -156,7 +162,13 @@ export type Operation = {
finished_at: number | null;
undone_at: number | null;
purged_at: number | null;
detail?: { side?: "master" | "slave"; mode?: "replace" | "inplace"; changes?: Record<string, [string, string]> };
detail?: {
side?: "master" | "slave";
mode?: "replace" | "inplace";
changes?: Record<string, [string, string]>;
sidecars?: [string, string][];
art?: boolean;
};
};
export type TreeEntry = {
@@ -228,3 +240,16 @@ 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>;
};
+4 -1
View File
@@ -38,6 +38,7 @@ const actionLabel: Record<Operation["action"], string> = {
remove: "Removed from slave",
rename: "Renamed slave folder",
tags: "Edited tags",
move: "Renamed file",
};
const statusTone: Record<Operation["status"], "green" | "red" | "blue" | "amber" | "gray"> = {
@@ -97,7 +98,7 @@ export function OpsTable({ pairId, quarantine }: { pairId?: number; quarantine?:
</div>
<div className="min-w-0 flex-1">
<div className="truncate mono text-[13px]" title={o.rel_slave || o.rel_master}>
{o.action === "tags" && <span className="mr-1 font-sans text-[11px] text-muted">{o.detail?.side}</span>}
{(o.action === "tags" || o.action === "move") && <span className="mr-1 font-sans text-[11px] text-muted">{o.detail?.side}</span>}
{o.rel_slave || o.rel_master}
</div>
{o.detail?.changes && (
@@ -189,6 +190,8 @@ function undoText(o: Operation) {
return "The hardlink is removed from the slave library. The master file is not affected.";
case "remove":
return "The hardlink is recreated in the slave library.";
case "move":
return `The file is moved back from "${o.rel_new}" to "${o.rel_slave || o.rel_master}"${o.detail?.sidecars?.length ? ", with its lyrics file" : ""}, and the library is rescanned.`;
case "tags":
return o.detail?.mode === "inplace"
? "The previous version of the file is written back from quarantine, restoring the old tags everywhere the file is linked."
+370
View File
@@ -0,0 +1,370 @@
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 { useInfo } from "../App";
import { Alert, Badge, Button, Card, Empty, Field, Modal, Spinner, cx, formatAgo, 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"],
];
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"]) {
const values: Record<string, string> = multi
? { ...SAMPLE, track: multi === "prefix" ? "2-04" : "04", title: "Victim", disc: "2" }
: SAMPLE;
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 2", parts[parts.length - 1]];
return parts.join("/") + ".flac";
}
type Scope = Side | "both";
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} />
<Preview pair={pair} />
</div>
);
}
function NamingSettings({ pair }: { pair: Pair }) {
const qc = useQueryClient();
const toast = useToast();
const [tpl, setTpl] = useState(pair.organize_template);
const [multi, setMulti] = useState(pair.organize_multidisc);
const [auto, setAuto] = useState(pair.auto_organize);
useEffect(() => {
setTpl(pair.organize_template);
setMulti(pair.organize_multidisc);
setAuto(pair.auto_organize);
}, [pair.id, pair.organize_template, pair.organize_multidisc, pair.auto_organize]);
const dirty = tpl !== pair.organize_template || multi !== pair.organize_multidisc || auto !== pair.auto_organize;
const save = useMutation({
mutationFn: () =>
api.put(`/pairs/${pair.id}`, {
name: pair.name,
master_root: pair.master_root,
slave_root: pair.slave_root,
quarantine_dir: pair.quarantine_dir,
extensions: pair.extensions,
auto_link_identical: pair.auto_link_identical,
scan_interval_minutes: pair.scan_interval_minutes,
retention_days: pair.retention_days,
settle_minutes: pair.settle_minutes,
approx_matching: pair.approx_matching,
approx_threshold: pair.approx_threshold,
organize_template: tpl,
organize_multidisc: multi,
auto_organize: auto,
}),
onSuccess: () => {
toast("ok", "Naming settings saved. Preview again to see the effect.");
qc.invalidateQueries({ queryKey: ["pair", pair.id] });
qc.invalidateQueries({ queryKey: ["pairs"] });
},
onError: (e) => toast("err", e.message),
});
return (
<Card className="space-y-4 p-5">
<div className="flex items-start justify-between gap-3">
<div>
<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>
</div>
<Button variant="primary" size="sm" disabled={!dirty} 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>
))}
</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)}</div>
{multi !== "none" && (
<div className="mono break-all text-gray-400">
{example(tpl, multi)} <span className="font-sans text-muted">(disc 2 of a multi-disc album)</span>
</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 (2-04 Title)</option>
<option value="folder">A folder per disc (Disc 2/04 Title)</option>
<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.">
<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="slave">Slave library</option>
<option value="master">Master library</option>
<option value="both">Both libraries</option>
</select>
</Field>
</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. 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>
);
}
+7
View File
@@ -25,6 +25,9 @@ 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",
};
const [f, setF] = useState<PairInput>(pair ?? blank);
const [exts, setExts] = useState((pair?.extensions ?? []).join(", "));
@@ -52,6 +55,10 @@ 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,
auto_organize: f.auto_organize,
extensions: exts.split(",").map((s) => s.trim()).filter(Boolean),
});
const set = <K extends keyof PairInput>(k: K, v: PairInput[K]) => {
+5
View File
@@ -11,6 +11,7 @@ import { Build } from "./Build";
import { OpsTable } from "./Activity";
import { WarningList } from "./Warnings";
import { TagsTab } from "./Tags";
import { OrganizeTab } from "./Organize";
export function usePair(id: number) {
return useQuery({
@@ -39,6 +40,8 @@ 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}` : ""}`);
else toast("err", `${j.kind} ${st}: ${j.error}`);
@@ -124,6 +127,7 @@ const tabs = [
{ id: "review", label: "Review" },
{ id: "build", label: "Build" },
{ id: "tags", label: "Tags" },
{ id: "organize", label: "Organize" },
{ id: "history", label: "History" },
];
@@ -205,6 +209,7 @@ export function PairPage({ id, tab }: { id: number; tab: string }) {
{tab === "review" && <Review pair={pair} />}
{tab === "build" && <Build pair={pair} />}
{tab === "tags" && <TagsTab pair={pair} />}
{tab === "organize" && <OrganizeTab pair={pair} />}
{tab === "history" && <OpsTable pairId={pair.id} />}
<PairForm pair={pair} open={editing} onClose={() => setEditing(false)} />
+1
View File
@@ -25,6 +25,7 @@ const kindLabel: Record<Match["kind"], string> = {
fuzzy: "similar title",
elsewhere: "same top folder, other subfolder",
approximate: "approximate match",
inode: "same file, other path",
none: "no match",
};