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:
1 parent
d8ac25a55d
commit
b11d0806c1
7 files changed
+462
-100
No files matched your search
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user