Allow naming without an artist folder and per-folder naming rules

The artist folder can be left empty for Album/01 Title (no artist tag
needed), and folders such as Video Games can have their own formats.
Files in a rule folder are only renamed inside it; other files are never
moved into it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-30 08:29:17 -07:00
1 parent d8ac25a55d
commit b11d0806c1
7 files changed
+462 -100

No files matched your search

+10
View File
@@ -249,6 +249,16 @@ export type Naming = {
multi_disc_format: string;
colon: "smart" | "delete" | "dash" | "space_dash" | "space_dash_space";
illegal: "replace" | "remove";
folders: FolderNaming[];
};
/** Its own naming for one folder of a library, e.g. soundtracks in "Video Games". */
export type FolderNaming = {
folder: string;
artist_folder: string;
album_folder: string;
track_format: string;
multi_disc_format: string;
};
export type OrganizeItem = { id: number; side: Side; from: string; to?: string; move: boolean; reason?: string; sidecars?: string[] };
+144 -33
View File
@@ -1,18 +1,21 @@
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 { AlertTriangle, ArrowRight, ChevronDown, ChevronRight, Folder, FolderPlus, FolderTree, Tags as TagsIcon, Trash2 } from "lucide-react";
import { api, type FolderNaming, type Job, type Naming, type OrganizeItem, type OrganizePlan, type Pair, type Side } from "../api";
import { useInfo } from "../App";
import { Alert, Badge, Button, Card, Field, Modal, Spinner, cx, inputCls } from "../ui";
import { useToast } from "../toast";
import { TagEditor } from "./Tags";
type NamingInfo = { defaults: Naming; presets: { name: string; config: Naming }[]; tokens: [string, string][] };
type NamingInfo = { defaults: Naming; presets: { name: string; config: Naming }[]; tokens: [string, string][]; folder_default: FolderNaming };
type FormatKey = "artist_folder" | "album_folder" | "track_format" | "multi_disc_format";
// Where a token is inserted: a main format, or one of a folder rule's (rule = its index).
type FieldRef = { rule: number | null; key: FormatKey; el: HTMLInputElement };
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." },
const FORMAT_FIELDS: { key: FormatKey; label: string; hint: string }[] = [
{ key: "artist_folder", label: "Artist folder", hint: "One folder name. Leave empty for no artist folder (Album/01 Title)." },
{ key: "album_folder", label: "Album folder", hint: "One folder name, inside the artist folder if there is one." },
{ 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." },
];
@@ -38,7 +41,7 @@ function NamingCard({ pair }: { pair: Pair }) {
const [cfg, setCfg] = useState<Naming>(pair.naming);
const [auto, setAuto] = useState(pair.auto_organize);
const [showTokens, setShowTokens] = useState(false);
const focused = useRef<{ key: keyof Naming; el: HTMLInputElement } | null>(null);
const focused = useRef<FieldRef | null>(null);
const saved = JSON.stringify(pair.naming);
useEffect(() => {
setCfg(pair.naming);
@@ -82,13 +85,16 @@ function NamingCard({ pair }: { pair: Pair }) {
onError: (e) => toast("err", e.message),
});
const folders = cfg.folders ?? [];
const setField = (rule: number | null, key: FormatKey, v: string) =>
setCfg(rule === null ? { ...cfg, [key]: v } : { ...cfg, folders: folders.map((r, i) => (i === rule ? { ...r, [key]: v } : r)) });
const insert = (token: string) => {
const f = focused.current;
if (!f) return;
const v = cfg[f.key] as string;
const v = f.rule === null ? cfg[f.key] : (folders[f.rule]?.[f.key] ?? "");
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) });
setField(f.rule, f.key, v.slice(0, start) + token + v.slice(end));
requestAnimationFrame(() => {
f.el.focus();
f.el.setSelectionRange(start + token.length, start + token.length);
@@ -118,9 +124,9 @@ function NamingCard({ pair }: { pair: Pair }) {
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",
sameFormats(p.config, cfg) ? "border-accent bg-accent/15 text-white" : "border-line bg-panel-2 text-gray-300 hover:border-accent",
)}
onClick={() => setCfg(p.config)}
onClick={() => setCfg({ ...p.config, folders })}
>
{p.name}
</button>
@@ -128,24 +134,68 @@ function NamingCard({ pair }: { pair: Pair }) {
</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 })}
<FormatFields values={cfg} onChange={(k, v) => setField(null, k, v)} onFocus={(key, el) => (focused.current = { rule: null, key, el })} />
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-sm font-medium">Folders with their own naming</h3>
<p className="text-xs text-muted">
For part of a library that is organized differently, such as game soundtracks in{" "}
<span className="mono">Video Games/Album/01 Title</span>. Files in the folder stay in it, and other files are never moved into it.
</p>
</div>
<Button
size="sm"
onClick={() =>
info.data &&
setCfg({
...cfg,
folders: [...folders, { ...info.data.folder_default, folder: folders.length ? "" : info.data.folder_default.folder }],
})
}
>
<FolderPlus className="size-4" /> Add folder
</Button>
</div>
{folders.map((r, i) => (
<div key={i} className="space-y-3 rounded-lg border border-line bg-panel-2/40 p-3">
<div className="flex items-start gap-2">
<div className="flex-1">
<Field label="Folder" hint="Relative to the library, e.g. Video Games or Music/Soundtracks.">
<input
className={inputCls + " mono text-xs"}
value={r.folder}
placeholder="Video Games"
spellCheck={false}
onChange={(e) => setCfg({ ...cfg, folders: folders.map((x, j) => (j === i ? { ...x, folder: e.target.value } : x)) })}
/>
</Field>
</div>
<Button
className="mt-7"
variant="ghost"
size="sm"
title="Remove this folder rule"
onClick={() => setCfg({ ...cfg, folders: folders.filter((_, j) => j !== i) })}
>
<Trash2 className="size-4" />
</Button>
</div>
<FormatFields
values={r}
inside={r.folder || "the folder"}
onChange={(k, v) => setField(i, k, v)}
onFocus={(key, el) => (focused.current = { rule: i, key, el })}
/>
</Field>
</div>
))}
</div>
<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)
{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">
@@ -162,8 +212,8 @@ function NamingCard({ pair }: { pair: Pair }) {
</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>).
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>
)}
@@ -208,8 +258,8 @@ function NamingCard({ 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 in History and can be undone; files with missing tags or clashing
names are left alone. Dry run still applies.
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>
@@ -305,7 +355,10 @@ function RenameFilesDialog({ pair, scope, onClose }: { pair: Pair; scope: Scope;
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.`);
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();
},
@@ -350,7 +403,10 @@ function RenameFilesDialog({ pair, scope, onClose }: { pair: Pair; scope: Scope;
<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")}
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>
@@ -365,7 +421,12 @@ function RenameFilesDialog({ pair, scope, onClose }: { pair: Pair; scope: Scope;
type="checkbox"
className="accent-violet-500"
checked={sel.size === moves.length}
onChange={(e) => toggle(moves.map((m) => m.id), e.target.checked)}
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>
@@ -387,8 +448,16 @@ function RenameFilesDialog({ pair, scope, onClose }: { pair: Pair; scope: Scope;
{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)} />
<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" />
@@ -442,7 +511,11 @@ function RenameFilesDialog({ pair, scope, onClose }: { pair: Pair; scope: Scope;
<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>}
{!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>
)}
@@ -472,3 +545,41 @@ function Stat({ label, value, tone }: { label: string; value: number; tone?: str
</div>
);
}
function sameFormats(a: Naming, b: Naming) {
return (["artist_folder", "album_folder", "track_format", "multi_disc_format", "colon", "illegal"] as const).every((k) => a[k] === b[k]);
}
/** The four path formats, for the library or for one folder rule. */
function FormatFields({
values,
inside,
onChange,
onFocus,
}: {
values: Record<FormatKey, string>;
inside?: string;
onChange: (key: FormatKey, v: string) => void;
onFocus: (key: FormatKey, el: HTMLInputElement) => void;
}) {
return (
<div className="grid gap-4 md:grid-cols-2">
{FORMAT_FIELDS.map((f) => (
<Field
key={f.key}
label={f.label}
hint={inside && f.key === "artist_folder" ? `Inside ${inside}. Leave empty for no artist folder.` : f.hint}
>
<input
className={inputCls + " mono text-xs"}
value={values[f.key]}
placeholder={f.key === "artist_folder" ? "(none)" : undefined}
spellCheck={false}
onFocus={(e) => onFocus(f.key, e.currentTarget)}
onChange={(e) => onChange(f.key, e.target.value)}
/>
</Field>
))}
</div>
);
}