Go back to the hardlink deduplicator (1da0132), keeping backups

Restores the code of 1da0132 ("Add a setting to hide the quarantine from
Plex") and removes everything added after it: the tag editor, album art,
organizing/naming, the step-by-step tune-up and library types.

Kept:
- Database backups (Settings -> Backups): scheduled, manual, download,
  restore, and the optional backup before every action.
- The .env-driven docker-compose.yml for Arcane.

Databases that already ran the later migrations keep working: migrations
6-10 stay in the list (they only add columns) so numbering never goes
backwards, and Undo refuses History entries of removed features instead
of treating them as links.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-30 10:43:29 -07:00
1 parent 7b180e26db
commit e0617047f7
44 files changed
+103 -10456

No files matched your search

+1 -1
View File
@@ -65,7 +65,7 @@ function Shell({ username }: { username: string }) {
];
let page;
if (route[0] === "pair" && route[1]) page = <PairPage id={Number(route[1])} tab={!route[2] || route[2] === "fixer" ? "tune-up" : route[2]} />;
if (route[0] === "pair" && route[1]) page = <PairPage id={Number(route[1])} tab={route[2] ?? "review"} />;
else if (route[0] === "activity") page = <ActivityPage />;
else if (route[0] === "settings") page = <SettingsPage />;
else page = <Dashboard />;
+2 -131
View File
@@ -34,8 +34,6 @@ export type Info = {
default_extensions: string[];
dry_run: boolean;
plex_ignore: boolean;
tag_editing: boolean;
tag_fields: string[];
};
export type Job = {
@@ -50,7 +48,6 @@ export type Job = {
error: string;
results: Record<string, number>;
skipped: string[];
notes?: string[];
started_at: number;
finished_at?: number;
};
@@ -77,10 +74,6 @@ export type Pair = {
settle_minutes: number;
approx_matching: boolean;
approx_threshold: number;
auto_organize: "off" | "slave" | "master" | "both";
library_type: LibraryType;
naming: Naming;
fixer: FixerState;
last_scan_at: number | null;
last_scan_error: string;
last_scan_warnings: ScanWarning[];
@@ -101,75 +94,8 @@ export type PairInput = Pick<
| "settle_minutes"
| "approx_matching"
| "approx_threshold"
| "auto_organize"
| "library_type"
>;
export type LibraryType = "music" | "soundtracks";
// ---- tune-up (step-by-step library fixing) ----
export type FixStep = "structure" | "tags" | "folders" | "files";
export type StepStatus = { state: "" | "done" | "skipped"; at?: number; note?: string };
export type FixerState = { master: Partial<Record<FixStep, StepStatus>>; slave: Partial<Record<FixStep, StepStatus>>; link: StepStatus };
export type FixPlan = {
id: number;
side: Side;
step: FixStep;
created_at: number;
songs: number;
albums: number;
fixable: number;
blocked: number;
notices: string[];
applied: boolean;
};
export type FixItem = {
id: number;
kind: "move-folder" | "move-file" | "tags" | "album-tags" | "problem";
group: string;
from?: string;
to?: string;
why: string;
blocked?: string;
changes?: Record<string, [string, string]>;
unsure?: boolean;
};
export type MbRelease = {
id: string;
title: string;
artist: string;
date: string;
country: string;
status: string;
type: string;
disambiguation?: string;
formats: string;
track_count: number;
discs: number;
score: number;
};
export type AlbumFix = {
id: number;
folder: string;
artist: string;
album: string;
source: "musicbrainz" | "master";
release?: MbRelease;
candidates: MbRelease[];
confidence: number;
matched: number;
files: { path: string; track?: string; changes: Record<string, [string, string]> }[];
album_changes: Record<string, [string, string]>;
error?: string;
};
export type Fixer = { state: FixerState; steps: FixStep[]; plan: FixPlan | null; items: FixItem[]; albums: AlbumFix[] };
export type ProbeInfo = {
codec: string;
codec_long?: string;
@@ -195,7 +121,7 @@ export type Match = {
pair_id: number;
rel_slave: string;
rel_master: string;
kind: "path" | "path_case" | "extension" | "fuzzy" | "elsewhere" | "approximate" | "inode" | "none";
kind: "path" | "path_case" | "extension" | "fuzzy" | "elsewhere" | "approximate" | "none";
status: Status;
master_size: number;
master_mtime: number;
@@ -213,7 +139,7 @@ export type Match = {
export type Operation = {
id: number;
pair_id: number;
action: "link" | "promote" | "build" | "remove" | "rename" | "tags" | "move";
action: "link" | "promote" | "build" | "remove" | "rename";
rel_master: string;
rel_slave: string;
rel_new: string;
@@ -228,13 +154,6 @@ 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]>;
sidecars?: [string, string][];
art?: boolean;
};
};
export type TreeEntry = {
@@ -293,53 +212,5 @@ export type ScanWarning = {
export type FolderRename = { slave: string; master: string; ready: boolean; reason?: string };
export type Side = "master" | "slave";
export type FileEntry = { name: string; rel: string; dir: boolean; size?: number; links?: number; editable?: boolean };
export type TagFile = {
rel: string;
editable: boolean;
reason?: string;
fields: Record<string, string>;
other: Record<string, string>;
links: number;
size: number;
};
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";
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[] };
export type OrganizePlan = { sides: Side[]; created_at: number; scanned: number; conforming: number; moves: number; skips: Record<string, number> };
export type ArtResult = { source: string; title: string; artist?: string; url: string; thumb: string; width: number; height: number };
export type PreparedArt = { token: string; width: number; height: number; size: number; square: boolean };
export type ArtSettings = { sources: string[]; google_key: string; google_cx: string; max_size: number; min_size: number };
export type AlbumSong = TagFile & { has_cover: boolean; embed_cover: boolean };
export type Album = { folder: string; songs: AlbumSong[]; folder_cover?: string };
export type ArtApply = { token: string; embed: boolean; folder_file: boolean };
export const artThumb = (u: string) => `/api/art/thumb?u=${encodeURIComponent(u)}`;
export const coverURL = (pairId: number, side: Side, path: string, bust = "") =>
`/api/pairs/${pairId}/cover?side=${side}&path=${encodeURIComponent(path)}${bust ? `&v=${bust}` : ""}`;
+2 -20
View File
@@ -37,8 +37,6 @@ const actionLabel: Record<Operation["action"], string> = {
build: "Built into slave",
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,19 +95,9 @@ export function OpsTable({ pairId, quarantine }: { pairId?: number; quarantine?:
</div>
</div>
<div className="min-w-0 flex-1">
<div className="truncate mono text-[13px]" title={o.rel_slave || o.rel_master}>
{(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 className="truncate mono text-[13px]" title={o.rel_slave}>
{o.rel_slave}
</div>
{o.detail?.changes && (
<div className="text-xs text-muted">
{Object.entries(o.detail.changes).map(([k, [from, to]]) => (
<div key={k} className="truncate">
<span className="text-gray-400">{k.replace("_", " ")}</span>: {from || "(none)"} → <span className="text-gray-200">{to || "(removed)"}</span>
</div>
))}
</div>
)}
{o.rel_new && o.rel_new !== o.rel_slave && o.rel_new !== o.rel_master && (
<div className="truncate text-xs text-muted mono">now {o.rel_new}</div>
)}
@@ -190,12 +178,6 @@ 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."
: "The previous file is put back from quarantine, restoring the old tags.";
case "rename":
return `The slave folder is renamed back from "${o.rel_new}" to "${o.rel_slave}", and the library is rescanned.`;
}
-287
View File
@@ -1,287 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Image as ImageIcon, Link2, ListOrdered, RotateCcw, UserRound } from "lucide-react";
import { api, coverURL, type Album, type AlbumSong, type PreparedArt, type Side } from "../api";
import { useInfo } from "../App";
import { Alert, Button, Modal, Spinner, Toggle, cx, inputCls } from "../ui";
import { useToast } from "../toast";
import { ArtPicker, ChosenArt, CoverImage } from "./Art";
// Album-level fields apply to every song; row fields are per song.
const ALBUM_FIELDS: { id: string; label: string; wide?: boolean }[] = [
{ id: "album", label: "Album", wide: true },
{ id: "album_artist", label: "Album artist", wide: true },
{ id: "date", label: "Year / date" },
{ id: "genre", label: "Genre" },
{ id: "disc_total", label: "Discs" },
{ id: "composer", label: "Composer", wide: true },
{ id: "comment", label: "Comment", wide: true },
];
const ROW_FIELDS = ["disc", "track", "title", "artist"] as const;
const num = (s?: string) => parseInt(s ?? "", 10) || 0;
/**
* Edits a whole album at once, AudioShell-style: album fields for every song,
* a grid for each song's number, title and artist, and the album art.
*/
export function AlbumEditor({ pairId, side, folder, onClose }: { pairId: number; side: Side; folder: string; onClose: () => void }) {
const qc = useQueryClient();
const toast = useToast();
const dry = useInfo().data?.dry_run ?? true;
const data = useQuery({
queryKey: ["album", pairId, side, folder],
queryFn: () => api.get<Album>(`/pairs/${pairId}/album?side=${side}&path=${encodeURIComponent(folder)}`),
gcTime: 0,
});
const [albumEdits, setAlbumEdits] = useState<Record<string, string>>({});
const [rowEdits, setRowEdits] = useState<Record<string, Record<string, string>>>({});
const [art, setArt] = useState<PreparedArt | null>(null);
const [picking, setPicking] = useState(false);
const [embed, setEmbed] = useState(true);
const [folderFile, setFolderFile] = useState(true);
const [separate, setSeparate] = useState(false);
const songs = useMemo(() => {
const s = (data.data?.songs ?? []).filter((x) => x.editable);
return [...s].sort(
(a, b) =>
num(a.fields.disc) - num(b.fields.disc) || num(a.fields.track) - num(b.fields.track) || a.rel.localeCompare(b.rel),
);
}, [data.data]);
const blocked = (data.data?.songs ?? []).filter((x) => !x.editable);
const common = useMemo(() => {
const c: Record<string, { value: string; mixed: boolean }> = {};
for (const f of ALBUM_FIELDS) {
const vals = songs.map((s) => s.fields[f.id] ?? "");
c[f.id] = { value: vals[0] ?? "", mixed: vals.some((v) => v !== vals[0]) };
}
return c;
}, [songs]);
const albumValue = (id: string) => albumEdits[id] ?? (common[id]?.mixed ? "" : common[id]?.value ?? "");
const rowValue = (s: AlbumSong, id: string) => rowEdits[s.rel]?.[id] ?? s.fields[id] ?? "";
const setRow = (rel: string, id: string, v: string) => setRowEdits((r) => ({ ...r, [rel]: { ...r[rel], [id]: v } }));
const albumChanged = Object.keys(albumEdits).filter((id) => common[id]?.mixed || albumEdits[id] !== common[id]?.value);
const rowChanges = songs
.map((s) => ({
path: s.rel,
set: Object.fromEntries(Object.entries(rowEdits[s.rel] ?? {}).filter(([id, v]) => v !== (s.fields[id] ?? ""))),
}))
.filter((r) => Object.keys(r.set).length > 0);
const hasChanges = albumChanged.length > 0 || rowChanges.length > 0 || !!art;
// "Number tracks in order": 1..n within each disc, with the disc's total.
const numberTracks = () => {
const byDisc = new Map<number, AlbumSong[]>();
for (const s of songs) {
const d = num(rowValue(s, "disc")) || 1;
byDisc.set(d, [...(byDisc.get(d) ?? []), s]);
}
const next: Record<string, Record<string, string>> = { ...rowEdits };
for (const list of byDisc.values()) {
list.forEach((s, i) => {
next[s.rel] = { ...next[s.rel], track: String(i + 1), track_total: String(list.length) };
});
}
setRowEdits(next);
if (byDisc.size > 1) setAlbumEdits((a) => ({ ...a, disc_total: String(byDisc.size) }));
};
const artistFromAlbumArtist = () => {
const aa = albumValue("album_artist");
if (!aa) return;
setRowEdits((r) => Object.fromEntries(songs.map((s) => [s.rel, { ...r[s.rel], artist: aa }])));
};
const save = useMutation({
mutationFn: () =>
api.post(`/pairs/${pairId}/tags`, {
side,
paths: songs.map((s) => s.rel),
set: Object.fromEntries(albumChanged.map((id) => [id, albumEdits[id].trim()])),
files: rowChanges.map((r) => ({ path: r.path, set: Object.fromEntries(Object.entries(r.set).map(([k, v]) => [k, v.trim()])) })),
art: art ? { token: art.token, embed, folder_file: folderFile } : undefined,
separate,
}),
onSuccess: () => {
toast("ok", dry ? "Started (dry run): recorded as planned only" : `Saving the album (${songs.length} songs)…`);
qc.invalidateQueries({ queryKey: ["files"] });
qc.invalidateQueries({ queryKey: ["pair", pairId] });
onClose();
},
onError: (e) => toast("err", e.message),
});
const shared = songs.filter((s) => s.links > 1).length;
const embeddable = songs.filter((s) => s.embed_cover).length;
const currentCover = data.data
? data.data.folder_cover
? coverURL(pairId, side, folder)
: songs.find((s) => s.has_cover)
? coverURL(pairId, side, songs.find((s) => s.has_cover)!.rel)
: null
: null;
useEffect(() => setPicking(false), [art]);
return (
<Modal open onClose={onClose} title={`Edit album: ${folder.split("/").pop() || folder}`} wide>
{data.isLoading && <Spinner />}
{data.error && <Alert tone="red">{data.error.message}</Alert>}
{data.data && songs.length === 0 && <Alert tone="amber">No editable songs in this folder.</Alert>}
{data.data && songs.length > 0 && (
<div className="space-y-4 text-sm">
<div className="text-xs text-muted">
<span className="mono">{folder}</span> · {songs.length} songs{blocked.length > 0 && `, ${blocked.length} not editable`}
</div>
<div className="grid gap-4 md:grid-cols-[11rem_1fr]">
<div className="space-y-2">
{art ? <ChosenArt art={art} onClear={() => setArt(null)} /> : <CoverImage src={currentCover} className="aspect-square w-full" />}
{!art && (
<Button size="sm" className="w-full" onClick={() => setPicking(!picking)}>
<ImageIcon className="size-3.5" /> {picking ? "Close search" : "Change art…"}
</Button>
)}
{art && (
<div className="space-y-1.5 text-xs">
<Toggle checked={embed} onChange={setEmbed} label={`Embed in songs (${embeddable})`} />
<Toggle checked={folderFile} onChange={setFolderFile} label="Save cover.jpg in folder" />
{embeddable < songs.length && (
<p className="text-[11px] text-muted">OGG/Opus songs can't hold embedded art; they use the folder's cover.jpg.</p>
)}
</div>
)}
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-2.5 md:grid-cols-4">
{ALBUM_FIELDS.map((f) => {
const changed = albumChanged.includes(f.id);
return (
<label key={f.id} className={cx("block space-y-1", f.wide ? "col-span-2" : "col-span-1")}>
<span className="flex justify-between text-xs font-medium text-gray-300">
{f.label}
{changed && (
<button type="button" className="text-muted hover:text-white cursor-pointer" onClick={() => setAlbumEdits(({ [f.id]: _, ...r }) => r)}>
<RotateCcw className="size-3" />
</button>
)}
</span>
<input
className={cx(inputCls, "h-8 text-xs", changed && "border-accent ring-1 ring-accent/40")}
value={albumValue(f.id)}
placeholder={common[f.id]?.mixed ? "(differs per song: keep)" : ""}
onChange={(e) => setAlbumEdits((a) => ({ ...a, [f.id]: e.target.value }))}
/>
</label>
);
})}
</div>
</div>
{picking && !art && (
<div className="rounded-lg border border-line bg-panel-2/40 p-3">
<ArtPicker artist={albumValue("album_artist") || songs[0]?.fields.artist || ""} album={albumValue("album")} onChoose={setArt} />
</div>
)}
<div>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<span className="text-xs font-medium text-gray-300">Tracks</span>
<div className="flex gap-1.5">
<Button size="sm" variant="ghost" onClick={numberTracks} title="Number the tracks 1, 2, 3… in the order shown, per disc">
<ListOrdered className="size-3.5" /> Number tracks in order
</Button>
<Button size="sm" variant="ghost" onClick={artistFromAlbumArtist} disabled={!albumValue("album_artist")}>
<UserRound className="size-3.5" /> Artist = album artist
</Button>
{rowChanges.length > 0 && (
<Button size="sm" variant="ghost" onClick={() => setRowEdits({})}>
<RotateCcw className="size-3.5" /> Revert tracks
</Button>
)}
</div>
</div>
<div className="max-h-[40vh] overflow-auto rounded-lg border border-line">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-panel-2 text-left text-muted">
<tr>
<th className="w-14 px-2 py-1.5 font-medium">Disc</th>
<th className="w-14 px-2 py-1.5 font-medium">#</th>
<th className="px-2 py-1.5 font-medium">Title</th>
<th className="w-44 px-2 py-1.5 font-medium">Artist</th>
<th className="w-40 px-2 py-1.5 font-medium">File</th>
</tr>
</thead>
<tbody>
{songs.map((s) => (
<tr key={s.rel} className="border-t border-line/60">
{ROW_FIELDS.map((id) => {
const v = rowValue(s, id);
const changed = v !== (s.fields[id] ?? "");
return (
<td key={id} className="px-1 py-0.5">
<input
className={cx(
"w-full rounded border border-transparent bg-transparent px-1.5 py-1 outline-none hover:border-line focus:border-accent",
changed && "border-accent/60 bg-accent/10",
(id === "disc" || id === "track") && "text-right tabular-nums",
)}
value={v}
onChange={(e) => setRow(s.rel, id, e.target.value)}
/>
</td>
);
})}
<td className="max-w-40 truncate px-2 py-1 mono text-[11px] text-muted" title={s.rel}>
{s.links > 1 && <Link2 className="mr-1 inline size-3 text-emerald-400" />}
{s.rel.split("/").pop()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{shared > 0 && (
<Alert tone="blue" icon={<Link2 className="mt-0.5 size-4 shrink-0" />}>
<div>
{shared} of these songs are <b>hardlinked</b> to the {side === "slave" ? "master" : "slave"} library, so edits show up in both
libraries and the files stay linked.
</div>
<label className="mt-2 flex items-start gap-2 text-xs cursor-pointer">
<input type="checkbox" className="mt-0.5 accent-violet-500" checked={separate} onChange={(e) => setSeparate(e.target.checked)} />
<span>Change only the {side} library's copies instead (breaks the links and uses extra disk space).</span>
</label>
</Alert>
)}
<p className="text-xs text-muted">
Only tags and art change; the audio is verified to be identical. Previous versions go to quarantine, and every change can be undone
from History.
</p>
{dry && <Alert tone="blue">Dry run is on, so the changes are recorded as planned in Activity. No files change.</Alert>}
</div>
)}
<div className="mt-5 flex items-center justify-end gap-2">
{hasChanges && (
<span className="mr-auto text-xs text-muted">
{[
albumChanged.length && `${albumChanged.length} album field${albumChanged.length > 1 ? "s" : ""}`,
rowChanges.length && `${rowChanges.length} song${rowChanges.length > 1 ? "s" : ""} changed`,
art && "new cover",
]
.filter(Boolean)
.join(" · ")}
</span>
)}
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button variant="primary" disabled={!hasChanges || (!!art && !embed && !folderFile && albumChanged.length + rowChanges.length === 0)} loading={save.isPending} onClick={() => save.mutate()}>
Save album
</Button>
</div>
</Modal>
);
}
-229
View File
@@ -1,229 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { ImageOff, Link2, Search, Upload } from "lucide-react";
import { api, artThumb, type ArtResult, type ArtSettings, type PreparedArt } from "../api";
import { Alert, Badge, Button, Spinner, cx, formatBytes, inputCls } from "../ui";
const SOURCE_LABEL: Record<string, string> = {
itunes: "iTunes",
deezer: "Deezer",
musicbrainz: "MusicBrainz",
duckduckgo: "DuckDuckGo",
google: "Google",
};
/** Shows a cover image, or a placeholder when there is none. */
export function CoverImage({ src, className }: { src?: string | null; className?: string }) {
const [failed, setFailed] = useState(false);
useEffect(() => setFailed(false), [src]);
if (!src || failed)
return (
<div className={cx("grid place-items-center rounded-lg border border-line bg-panel-2 text-muted", className)}>
<ImageOff className="size-6" />
</div>
);
return <img src={src} alt="" className={cx("rounded-lg border border-line object-cover", className)} onError={() => setFailed(true)} />;
}
/**
* Finds a new cover: search the art sources (square images only), paste an
* image address, or upload a file. The chosen image is prepared on the server
* (converted to JPEG, resized) and returned as a token to apply later.
*/
export function ArtPicker({
artist,
album,
onChoose,
}: {
artist: string;
album: string;
onChoose: (p: PreparedArt) => void;
}) {
const settings = useQuery({ queryKey: ["art-settings"], queryFn: () => api.get<{ settings: ArtSettings }>("/art/settings") });
const [q, setQ] = useState({ artist, album, text: "" });
const [free, setFree] = useState(false);
const [sources, setSources] = useState<string[] | null>(null);
const [url, setUrl] = useState("");
const [broken, setBroken] = useState<Set<string>>(new Set());
const fileRef = useRef<HTMLInputElement>(null);
const enabled = settings.data?.settings.sources ?? [];
const googleReady = !!settings.data?.settings.google_key && !!settings.data?.settings.google_cx;
const usable = enabled.filter((s) => s !== "google" || googleReady);
const active = sources ?? usable;
const search = useMutation({
mutationFn: () => {
const p = new URLSearchParams({ sources: active.join(",") });
if (free) p.set("q", q.text);
else {
p.set("artist", q.artist);
p.set("album", q.album);
}
return api.get<{ results: ArtResult[]; errors: Record<string, string> }>(`/art/search?${p}`);
},
});
// Search right away with the album's own tags.
useEffect(() => {
if (settings.data && (artist || album)) search.mutate();
}, [settings.data]); // eslint-disable-line react-hooks/exhaustive-deps
const prepare = useMutation({
mutationFn: (u: string) => api.post<PreparedArt>("/art/prepare", { url: u }),
onSuccess: onChoose,
});
const upload = useMutation({
mutationFn: async (f: File) => {
const form = new FormData();
form.append("image", f);
const res = await fetch("/api/art/upload", { method: "POST", credentials: "same-origin", headers: { "X-Playlink-Request": "1" }, body: form });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error ?? res.statusText);
return data as PreparedArt;
},
onSuccess: onChoose,
});
const busy = prepare.isPending || upload.isPending;
return (
<div className="space-y-3">
<form
className="flex flex-wrap items-end gap-2"
onSubmit={(e) => {
e.preventDefault();
search.mutate();
}}
>
{free ? (
<input className={inputCls + " min-w-0 flex-1"} placeholder="Search text" value={q.text} onChange={(e) => setQ({ ...q, text: e.target.value })} />
) : (
<>
<input className={inputCls + " min-w-0 flex-1"} placeholder="Artist" value={q.artist} onChange={(e) => setQ({ ...q, artist: e.target.value })} />
<input className={inputCls + " min-w-0 flex-1"} placeholder="Album" value={q.album} onChange={(e) => setQ({ ...q, album: e.target.value })} />
</>
)}
<Button type="submit" variant="primary" loading={search.isPending}>
<Search className="size-4" /> Search
</Button>
</form>
<div className="flex flex-wrap items-center gap-1.5 text-xs">
<button type="button" className="mr-2 text-accent hover:underline cursor-pointer" onClick={() => setFree(!free)}>
{free ? "Search by artist and album" : "Free-text search"}
</button>
{enabled.map((s) => {
const disabled = s === "google" && !googleReady;
const on = active.includes(s) && !disabled;
return (
<button
key={s}
type="button"
disabled={disabled}
title={disabled ? "Add a Google API key and search engine id in Settings → Album art" : undefined}
onClick={() => setSources(on ? active.filter((x) => x !== s) : [...active, s])}
className={cx(
"rounded-full border px-2 py-0.5",
on ? "border-accent bg-accent/15 text-white" : "border-line text-muted",
disabled ? "cursor-not-allowed opacity-40" : "cursor-pointer",
)}
>
{SOURCE_LABEL[s] ?? s}
</button>
);
})}
<span className="text-muted">· square images only</span>
</div>
{search.data && Object.keys(search.data.errors).length > 0 && (
<Alert tone="amber">
{Object.entries(search.data.errors).map(([s, e]) => (
<div key={s} className="text-xs">
{SOURCE_LABEL[s] ?? s}: {e}
</div>
))}
</Alert>
)}
{search.isPending && <Spinner />}
{search.data && search.data.results.length === 0 && <div className="py-4 text-center text-sm text-muted">No square covers found. Try other words.</div>}
{search.data && search.data.results.length > 0 && (
<div className="grid max-h-[45vh] grid-cols-3 gap-2 overflow-y-auto sm:grid-cols-4 md:grid-cols-5">
{search.data.results.filter((r) => !broken.has(r.url)).map((r) => (
<button
key={r.url}
type="button"
disabled={busy}
onClick={() => prepare.mutate(r.url)}
title={[r.artist, r.title].filter(Boolean).join(" – ")}
className="group relative overflow-hidden rounded-lg border border-line text-left hover:border-accent cursor-pointer disabled:opacity-50"
>
<img
src={artThumb(r.thumb || r.url)}
alt=""
loading="lazy"
className="aspect-square w-full object-cover"
// Some web images vanish or refuse hotlinking; drop them from the grid.
onError={() => setBroken((b) => new Set(b).add(r.url))}
/>
<div className="absolute inset-x-0 bottom-0 bg-black/70 px-1.5 py-1 text-[10px] leading-tight">
<div className="truncate text-gray-100">{r.title || "untitled"}</div>
<div className="flex justify-between text-gray-400">
<span>{SOURCE_LABEL[r.source] ?? r.source}</span>
<span>
{r.width}×{r.height}
</span>
</div>
</div>
</button>
))}
</div>
)}
<div className="flex flex-wrap items-center gap-2 border-t border-line pt-3">
<div className="relative min-w-0 flex-1">
<Link2 className="absolute left-2.5 top-2.5 size-4 text-muted" />
<input className={inputCls + " pl-8 text-xs"} placeholder="Or paste an image address (https://…)" value={url} onChange={(e) => setUrl(e.target.value)} />
</div>
<Button disabled={!url.trim() || busy} onClick={() => prepare.mutate(url.trim())}>
Use address
</Button>
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) upload.mutate(f);
e.target.value = "";
}}
/>
<Button disabled={busy} onClick={() => fileRef.current?.click()}>
<Upload className="size-4" /> Upload…
</Button>
</div>
{busy && (
<div className="flex items-center gap-2 text-xs text-muted">
<Spinner className="size-3.5" /> Downloading and preparing the image…
</div>
)}
{(prepare.error || upload.error) && <Alert tone="red">{(prepare.error || upload.error)!.message}</Alert>}
</div>
);
}
/** A chosen cover waiting to be saved. */
export function ChosenArt({ art, onClear }: { art: PreparedArt; onClear: () => void }) {
return (
<div className="flex items-center gap-3">
<CoverImage src={`/api/art/prepared/${art.token}`} className="size-20" />
<div className="text-xs">
<div className="font-medium text-gray-100">New cover</div>
<div className="text-muted">
{art.width}×{art.height} · {formatBytes(art.size)} JPEG
</div>
{!art.square && <Badge tone="amber">not square</Badge>}
<button className="mt-1 block text-accent hover:underline cursor-pointer" onClick={onClear}>
Keep the current cover
</button>
</div>
</div>
);
}
-97
View File
@@ -1,97 +0,0 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Image as ImageIcon } from "lucide-react";
import { api, type ArtSettings } from "../api";
import { Button, Card, Field, cx, inputCls } from "../ui";
import { useToast } from "../toast";
const SOURCES: { id: string; label: string; hint: string }[] = [
{ id: "itunes", label: "iTunes", hint: "Album covers, 1000 px, no key needed" },
{ id: "deezer", label: "Deezer", hint: "Album covers, 1000 px, no key needed" },
{ id: "musicbrainz", label: "MusicBrainz", hint: "Cover Art Archive, no key needed" },
{ id: "duckduckgo", label: "DuckDuckGo", hint: "Web images; unofficial, may stop working" },
{ id: "google", label: "Google", hint: "Web images; needs an API key and search engine id" },
];
export function ArtSettingsCard() {
const qc = useQueryClient();
const toast = useToast();
const data = useQuery({ queryKey: ["art-settings"], queryFn: () => api.get<{ settings: ArtSettings }>("/art/settings") });
const [s, setS] = useState<ArtSettings | null>(null);
useEffect(() => {
if (data.data) setS(data.data.settings);
}, [data.data]);
const dirty = s && data.data && JSON.stringify(s) !== JSON.stringify(data.data.settings);
const save = useMutation({
mutationFn: () => api.put("/art/settings", s),
onSuccess: () => {
toast("ok", "Album art settings saved");
qc.invalidateQueries({ queryKey: ["art-settings"] });
},
onError: (e) => toast("err", e.message),
});
if (!s) return null;
const toggle = (id: string) => setS({ ...s, sources: s.sources.includes(id) ? s.sources.filter((x) => x !== id) : [...s.sources, id] });
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">
<ImageIcon className="size-4 text-accent" /> Album art
</h2>
<p className="text-sm text-muted">Where the tag editor searches for covers. Only square images are shown.</p>
</div>
{dirty && (
<Button size="sm" variant="primary" onClick={() => save.mutate()} loading={save.isPending}>
Save
</Button>
)}
</div>
<div className="grid gap-2 sm:grid-cols-2">
{SOURCES.map((src) => (
<label key={src.id} className={cx("flex items-start gap-2 rounded-lg border border-line p-2.5 text-sm cursor-pointer", s.sources.includes(src.id) && "border-accent/50 bg-accent/5")}>
<input type="checkbox" className="mt-0.5 accent-violet-500" checked={s.sources.includes(src.id)} onChange={() => toggle(src.id)} />
<span>
<span className="font-medium">{src.label}</span>
<span className="block text-xs text-muted">{src.hint}</span>
</span>
</label>
))}
</div>
{s.sources.includes("google") && (
<div className="grid gap-3 md:grid-cols-2">
<Field label="Google API key" hint="From Google Cloud, with the Custom Search API enabled.">
<input className={inputCls + " mono text-xs"} type="password" autoComplete="off" value={s.google_key} onChange={(e) => setS({ ...s, google_key: e.target.value })} />
</Field>
<Field label="Search engine id (cx)" hint="A Programmable Search Engine with image search on.">
<input className={inputCls + " mono text-xs"} value={s.google_cx} onChange={(e) => setS({ ...s, google_cx: e.target.value })} />
</Field>
</div>
)}
<div className="grid gap-3 md:grid-cols-2">
<Field label="Saved cover size" hint="Covers are converted to JPEG at this size.">
<select className={inputCls} value={s.max_size} onChange={(e) => setS({ ...s, max_size: Number(e.target.value) })}>
<option value={600}>600 × 600</option>
<option value={1000}>1000 × 1000 (recommended)</option>
<option value={1400}>1400 × 1400</option>
<option value={3000}>3000 × 3000</option>
<option value={0}>Original size</option>
</select>
</Field>
<Field label="Smallest search result" hint="Hide covers smaller than this.">
<select className={inputCls} value={s.min_size} onChange={(e) => setS({ ...s, min_size: Number(e.target.value) })}>
<option value={300}>300 px</option>
<option value={500}>500 px</option>
<option value={800}>800 px</option>
<option value={1000}>1000 px</option>
</select>
</Field>
</div>
<p className="text-xs text-muted">
Searching sends the artist and album (or your search text) to the selected services. Images are downloaded by playlink itself, never from
addresses on your local network.
</p>
</Card>
);
}
-585
View File
@@ -1,585 +0,0 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
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][]; 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: 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." },
];
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">
<RenameNowCard pair={pair} />
<NamingCard pair={pair} />
</div>
);
}
// ---- naming settings ----
function NamingCard({ pair }: { pair: Pair }) {
const qc = useQueryClient();
const toast = useToast();
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 [showTokens, setShowTokens] = useState(false);
const focused = useRef<FieldRef | null>(null);
const saved = JSON.stringify(pair.naming);
useEffect(() => {
setCfg(pair.naming);
setAuto(pair.auto_organize);
}, [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: () =>
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,
auto_organize: auto,
naming: cfg,
}),
onSuccess: () => {
toast("ok", "Naming saved");
qc.invalidateQueries({ queryKey: ["pair", pair.id] });
qc.invalidateQueries({ queryKey: ["pairs"] });
},
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 = 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;
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);
});
};
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">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 || !!examples.data?.error} loading={save.isPending} onClick={() => save.mutate()}>
Save
</Button>
</div>
{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",
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, folders })}
>
{p.name}
</button>
))}
</div>
)}
<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 })}
/>
</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)
</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="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="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 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: only with Rename now</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 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>
);
}
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>
);
}
+3 -32
View File
@@ -1,17 +1,12 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronLeft, Folder, FolderOpen } from "lucide-react";
import { api, type LibraryType, type Pair, type PairInput, type ScanWarning } from "../api";
import { api, type Pair, type PairInput, type ScanWarning } from "../api";
import { WarningList } from "./Warnings";
import { useInfo } from "../App";
import { Alert, Button, Field, Modal, Toggle, cx, inputCls } from "../ui";
import { Alert, Button, Field, Modal, Toggle, inputCls } from "../ui";
import { useToast } from "../toast";
export const LIBRARY_TYPES: { id: LibraryType; label: string; layout: string; hint: string }[] = [
{ id: "music", label: "Music", layout: "Artist/Album/01 Title.flac", hint: "Albums by artist." },
{ id: "soundtracks", label: "Soundtracks", layout: "Album/01 Title.flac", hint: "Video game and film soundtracks, named by game or film." },
];
type Validation = { ok: boolean; error?: string; warnings: ScanWarning[] };
export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean; onClose: () => void }) {
@@ -30,8 +25,6 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
settle_minutes: 10,
approx_matching: false,
approx_threshold: 85,
auto_organize: "off",
library_type: "music",
};
const [f, setF] = useState<PairInput>(pair ?? blank);
const [exts, setExts] = useState((pair?.extensions ?? []).join(", "));
@@ -59,9 +52,6 @@ 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 it.
auto_organize: f.auto_organize,
library_type: f.library_type,
extensions: exts.split(",").map((s) => s.trim()).filter(Boolean),
});
const set = <K extends keyof PairInput>(k: K, v: PairInput[K]) => {
@@ -78,7 +68,7 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["pairs"] });
qc.invalidateQueries({ queryKey: ["pair"] });
toast("ok", pair ? "Library pair updated" : "Library pair created. Start with the Tune-up, master library first.");
toast("ok", pair ? "Library pair updated" : "Library pair created. Run a scan to find matches.");
onClose();
},
});
@@ -89,25 +79,6 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
<Field label="Name">
<input className={inputCls} value={f.name} onChange={(e) => set("name", e.target.value)} />
</Field>
<Field label="Library type" hint="Sets the recommended folder layout; you can change the naming later in Organize.">
<div className="grid gap-2 sm:grid-cols-2">
{LIBRARY_TYPES.map((t) => (
<button
key={t.id}
type="button"
onClick={() => set("library_type", t.id)}
className={cx(
"rounded-lg border p-3 text-left text-sm cursor-pointer",
f.library_type === t.id ? "border-accent bg-accent/10" : "border-line bg-panel-2 hover:border-accent/60",
)}
>
<div className="font-medium">{t.label}</div>
<div className="mono mt-0.5 text-xs text-gray-300">{t.layout}</div>
<div className="mt-1 text-xs text-muted">{t.hint}</div>
</button>
))}
</div>
</Field>
<div className="grid gap-4 md:grid-cols-2">
<PathField
label="Master library (keeps the files)"
-11
View File
@@ -10,9 +10,6 @@ import { Review } from "./Review";
import { Build } from "./Build";
import { OpsTable } from "./Activity";
import { WarningList } from "./Warnings";
import { TagsTab } from "./Tags";
import { OrganizeTab } from "./Organize";
import { TuneUpTab } from "./TuneUp";
export function usePair(id: number) {
return useQuery({
@@ -41,8 +38,6 @@ function useJobFinished(pair: Pair | undefined) {
qc.invalidateQueries({ queryKey: ["tree"] });
qc.invalidateQueries({ queryKey: ["ops"] });
qc.invalidateQueries({ queryKey: ["pairs"] });
qc.invalidateQueries({ queryKey: ["files"] });
qc.invalidateQueries({ queryKey: ["fixer", pair.id] });
const j = pair.job!;
if (st === "done") toast("ok", `${j.kind} finished${j.message ? `: ${j.message}` : ""}`);
else toast("err", `${j.kind} ${st}: ${j.error}`);
@@ -125,11 +120,8 @@ function LastJob({ job }: { job: Job }) {
}
const tabs = [
{ id: "tune-up", label: "Tune-up" },
{ id: "review", label: "Review" },
{ id: "build", label: "Build" },
{ id: "tags", label: "Tags" },
{ id: "organize", label: "Organize" },
{ id: "history", label: "History" },
];
@@ -208,11 +200,8 @@ export function PairPage({ id, tab }: { id: number; tab: string }) {
))}
</div>
{tab === "tune-up" && <TuneUpTab pair={pair} />}
{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)} />
+2 -20
View File
@@ -1,8 +1,7 @@
import { useEffect, useState } from "react";
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, ArrowRight, ArrowUpCircle, Disc3, Download, ListMusic, Tags as TagsIcon, ChevronDown, ChevronRight, CopyCheck, EyeOff, Eye, Link2, Search, Crown } from "lucide-react";
import { ApiError, api, type Match, type Page, type Pair, type ProbeInfo, type Side } from "../api";
import { TagEditor } from "./Tags";
import { AlertTriangle, ArrowRight, ArrowUpCircle, Disc3, Download, ListMusic, ChevronDown, ChevronRight, CopyCheck, EyeOff, Eye, Link2, Search, Crown } from "lucide-react";
import { ApiError, api, type Match, type Page, type Pair, type ProbeInfo } from "../api";
import { Alert, Badge, Button, Card, Empty, Modal, Spinner, cx, formatBytes, formatDuration, inputCls, statusTone } from "../ui";
import { useToast } from "../toast";
import { AlbumView, BulkBar, TriCheckbox, toggleIDs, type Selection } from "./Bulk";
@@ -25,7 +24,6 @@ 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",
};
@@ -377,7 +375,6 @@ const rows: [string, (p: ProbeInfo, size: number) => string][] = [
function ComparePanel({ m, onDone }: { m: Match; onDone: () => void }) {
const toast = useToast();
const [confirm, setConfirm] = useState<null | "downgrade" | "promote">(null);
const [tagSide, setTagSide] = useState<Side | null>(null);
const mp = m.master_probe;
const sp = m.slave_probe;
const v = m.verdict;
@@ -492,13 +489,6 @@ function ComparePanel({ m, onDone }: { m: Match; onDone: () => void }) {
<Button variant={v?.better === "slave" ? "warn" : "secondary"} onClick={() => setConfirm("promote")}>
<Crown className="size-4" /> Use slave copy as master
</Button>
<span className="mx-1 w-px self-stretch bg-line" aria-hidden />
<Button variant="ghost" onClick={() => setTagSide("slave")}>
<TagsIcon className="size-4" /> Slave tags
</Button>
<Button variant="ghost" onClick={() => setTagSide("master")}>
<TagsIcon className="size-4" /> Master tags
</Button>
</div>
<p className="mt-2 text-xs text-muted">Whatever gets replaced is kept in quarantine and can be restored from History.</p>
@@ -536,14 +526,6 @@ function ComparePanel({ m, onDone }: { m: Match; onDone: () => void }) {
</Button>
</div>
</Modal>
{tagSide && (
<TagEditor
pairId={m.pair_id}
side={tagSide}
paths={[tagSide === "slave" ? m.rel_slave : m.rel_master]}
onClose={() => setTagSide(null)}
/>
)}
</div>
);
}
-3
View File
@@ -5,7 +5,6 @@ import { useInfo } from "../App";
import { Alert, Button, Card, Field, Toggle, inputCls } from "../ui";
import { useToast } from "../toast";
import { BackupsCard } from "./Backups";
import { ArtSettingsCard } from "./ArtSettings";
export function SettingsPage() {
const info = useInfo();
@@ -80,8 +79,6 @@ export function SettingsPage() {
</p>
</Card>
<ArtSettingsCard />
<BackupsCard />
<Card className="space-y-2 p-5">
-549
View File
@@ -1,549 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronDown, ChevronRight, Disc3, FileAudio, Folder, Link2, RotateCcw, Tags as TagsIcon, Wand2, X } from "lucide-react";
import { api, coverURL, type FileEntry, type Job, type Pair, type PreparedArt, type Side, type TagFile } from "../api";
import { AlbumEditor } from "./Album";
import { ArtPicker, ChosenArt, CoverImage } from "./Art";
import { useInfo } from "../App";
import { Alert, Badge, Button, Card, Empty, Modal, Spinner, Toggle, cx, formatBytes, inputCls } from "../ui";
import { useToast } from "../toast";
// ---- field layout ----
type FieldDef = { id: string; label: string; span?: 2; perSong?: boolean; multiline?: boolean };
const FIELDS: FieldDef[] = [
{ id: "title", label: "Title", span: 2, perSong: true },
{ id: "artist", label: "Artist" },
{ id: "album_artist", label: "Album artist" },
{ id: "album", label: "Album", span: 2 },
{ id: "track", label: "Track", perSong: true },
{ id: "track_total", label: "of" },
{ id: "disc", label: "Disc" },
{ id: "disc_total", label: "of" },
{ id: "date", label: "Year / date" },
{ id: "genre", label: "Genre" },
{ id: "composer", label: "Composer", span: 2 },
{ id: "comment", label: "Comment", span: 2, multiline: true },
];
// ---- browser tab ----
export function TagsTab({ pair }: { pair: Pair }) {
const info = useInfo();
const [side, setSide] = useState<Side>("slave");
const [path, setPath] = useState("");
const [sel, setSel] = useState<Set<string>>(new Set());
const [editing, setEditing] = useState<string[] | null>(null);
const [albumFolder, setAlbumFolder] = useState<string | null>(null);
const [autoMatch, setAutoMatch] = useState(false);
useEffect(() => setSel(new Set()), [side, path]);
const files = useQuery({
queryKey: ["files", pair.id, side, path],
queryFn: () => api.get<FileEntry[]>(`/pairs/${pair.id}/files?side=${side}&path=${encodeURIComponent(path)}`),
});
if (info.data && !info.data.tag_editing)
return <Alert tone="amber">Tag editing needs ffmpeg and ffprobe, which were not found. The Docker image includes both.</Alert>;
const entries = files.data ?? [];
const songs = entries.filter((e) => !e.dir && e.editable);
const crumbs = path ? path.split("/") : [];
const allSelected = songs.length > 0 && songs.every((s) => sel.has(s.rel));
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex rounded-lg border border-line p-0.5" role="tablist" aria-label="Library">
{(["slave", "master"] as const).map((s) => (
<button
key={s}
role="tab"
aria-selected={side === s}
onClick={() => {
setSide(s);
setPath("");
}}
className={cx("rounded-md px-3 py-1 text-sm capitalize cursor-pointer", side === s ? "bg-panel-2 text-white" : "text-muted hover:text-white")}
>
{s}
</button>
))}
</div>
<nav className="flex min-w-0 flex-wrap items-center gap-1 text-sm">
<button className="text-accent hover:underline cursor-pointer" onClick={() => setPath("")}>
{side}
</button>
{crumbs.map((c, i) => (
<span key={i} className="flex items-center gap-1">
<ChevronRight className="size-3.5 text-muted" />
<button className="hover:underline cursor-pointer" onClick={() => setPath(crumbs.slice(0, i + 1).join("/"))}>
{c}
</button>
</span>
))}
</nav>
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="ghost" onClick={() => setAutoMatch(true)} title="Find covers for the albums in this folder">
<Wand2 className="size-3.5" /> Auto-match art…
</Button>
{songs.length > 0 && (
<Button size="sm" onClick={() => setAlbumFolder(path)}>
<Disc3 className="size-3.5" /> Edit album
</Button>
)}
<Button variant="primary" size="sm" disabled={sel.size === 0} onClick={() => setEditing([...sel])}>
<TagsIcon className="size-3.5" /> Edit tags{sel.size > 0 && ` (${sel.size})`}
</Button>
</div>
</div>
<Card className="overflow-hidden">
{files.isLoading && (
<div className="p-8">
<Spinner />
</div>
)}
{files.error && (
<div className="p-4">
<Alert tone="red">{files.error.message}</Alert>
</div>
)}
{files.data?.length === 0 && <Empty icon={<Folder className="size-8" />} title="Empty folder" />}
{songs.length > 0 && (
<label className="flex items-center gap-3 border-b border-line bg-panel-2 px-4 py-2 text-xs text-muted cursor-pointer">
<input
type="checkbox"
className="accent-violet-500"
checked={allSelected}
onChange={() => setSel(allSelected ? new Set() : new Set(songs.map((s) => s.rel)))}
/>
Select all {songs.length} songs in this folder
</label>
)}
{entries.map((e) => (
<div key={e.rel} className={cx("flex items-center gap-3 border-b border-line px-4 py-2 last:border-0", sel.has(e.rel) && "bg-accent/5")}>
{e.dir ? (
<>
<span className="w-4" />
<Folder className="size-4 shrink-0 text-accent" />
<button className="min-w-0 flex-1 truncate text-left text-sm hover:underline cursor-pointer" onClick={() => setPath(e.rel)}>
{e.name}
</button>
<Button size="sm" variant="ghost" onClick={() => setAlbumFolder(e.rel)} title="Edit this folder as an album">
<Disc3 className="size-3.5" /> Edit album
</Button>
</>
) : (
<>
<input
type="checkbox"
className="accent-violet-500"
disabled={!e.editable}
checked={sel.has(e.rel)}
onChange={(ev) =>
setSel((s) => {
const n = new Set(s);
if (ev.target.checked) n.add(e.rel);
else n.delete(e.rel);
return n;
})
}
/>
<FileAudio className="size-4 shrink-0 text-muted" />
<button
className={cx("min-w-0 flex-1 truncate text-left text-sm", e.editable ? "hover:underline cursor-pointer" : "text-muted cursor-not-allowed")}
disabled={!e.editable}
title={e.editable ? "Edit tags" : "Editing tags of this file type is not supported"}
onClick={() => setEditing([e.rel])}
>
{e.name}
</button>
{(e.links ?? 1) > 1 && (
<Badge tone="green" className="shrink-0">
<Link2 className="size-3" /> shared
</Badge>
)}
<span className="w-20 text-right text-xs tabular-nums text-muted">{formatBytes(e.size ?? 0)}</span>
</>
)}
</div>
))}
</Card>
<p className="text-xs text-muted">
Click a song to edit its tags, tick several to edit the tags they share, or use <b>Edit album</b> to edit a whole album (album fields,
every track's number and title, and the cover). <b>shared</b> marks files that are hardlinked to the other library.
</p>
{editing && (
<TagEditor
pairId={pair.id}
side={side}
paths={editing}
onClose={() => setEditing(null)}
onSaved={() => setSel(new Set())}
/>
)}
{albumFolder !== null && <AlbumEditor pairId={pair.id} side={side} folder={albumFolder} onClose={() => setAlbumFolder(null)} />}
{autoMatch && <AutoMatchDialog pairId={pair.id} side={side} folder={path} onClose={() => setAutoMatch(false)} />}
</div>
);
}
// ---- editor ----
type Common = Record<string, { value: string; mixed: boolean; present: boolean }>;
export function TagEditor({
pairId,
side,
paths,
onClose,
onSaved,
}: {
pairId: number;
side: Side;
paths: string[];
onClose: () => void;
onSaved?: () => void;
}) {
const qc = useQueryClient();
const toast = useToast();
const dry = useInfo().data?.dry_run ?? true;
const read = useQuery({
queryKey: ["tags", pairId, side, paths.join("\n")],
queryFn: () => api.post<{ files: TagFile[] }>(`/pairs/${pairId}/tags/read`, { side, paths }),
gcTime: 0,
});
const [edits, setEdits] = useState<Record<string, string>>({});
const [separate, setSeparate] = useState(false);
const [showOther, setShowOther] = useState(false);
const [art, setArt] = useState<PreparedArt | null>(null);
const [picking, setPicking] = useState(false);
const editable = useMemo(() => (read.data?.files ?? []).filter((f) => f.editable), [read.data]);
const blocked = (read.data?.files ?? []).filter((f) => !f.editable);
const multi = editable.length > 1;
const shared = editable.filter((f) => f.links > 1).length;
const common: Common = useMemo(() => {
const c: Common = {};
for (const f of FIELDS) {
const vals = editable.map((file) => file.fields[f.id] ?? "");
c[f.id] = { value: vals[0] ?? "", mixed: vals.some((v) => v !== vals[0]), present: vals.some((v) => v !== "") };
}
return c;
}, [editable]);
const changed = Object.keys(edits).filter((id) => common[id]?.mixed || edits[id] !== common[id]?.value);
const set = Object.fromEntries(changed.map((id) => [id, edits[id].trim()]));
const save = useMutation({
mutationFn: () =>
api.post(`/pairs/${pairId}/tags`, {
side,
paths: editable.map((f) => f.rel),
set,
separate,
art: art ? { token: art.token, embed: true, folder_file: false } : undefined,
}),
onSuccess: () => {
toast("ok", dry ? "Started (dry run): the edit is recorded as planned only" : `Saving tags for ${editable.length} song${editable.length > 1 ? "s" : ""}…`);
qc.invalidateQueries({ queryKey: ["pair", pairId] });
qc.invalidateQueries({ queryKey: ["files"] });
onSaved?.();
onClose();
},
onError: (e) => toast("err", e.message),
});
const title = multi ? `Edit tags of ${editable.length} songs` : `Edit tags: ${paths[0].split("/").pop()}`;
return (
<Modal open onClose={onClose} title={title} wide>
{read.isLoading && <Spinner />}
{read.error && <Alert tone="red">{read.error.message}</Alert>}
{read.data && (
<div className="space-y-4 text-sm">
<div className="text-xs text-muted">
<Badge tone={side === "master" ? "violet" : "gray"}>{side}</Badge>{" "}
<span className="mono">{multi ? `${editable.length} files in ${commonFolder(editable.map((f) => f.rel))}` : paths[0]}</span>
</div>
{blocked.length > 0 && (
<Alert tone="amber">
{blocked.length} file{blocked.length > 1 && "s"} can't be edited and will be left alone:
<ul className="mt-1 text-xs">
{blocked.slice(0, 5).map((f) => (
<li key={f.rel}>
<span className="mono">{f.rel.split("/").pop()}</span>: {f.reason}
</li>
))}
</ul>
</Alert>
)}
{editable.length > 0 && (
<div className="grid grid-cols-2 gap-x-4 gap-y-3 md:grid-cols-4">
{FIELDS.map((f) => {
const c = common[f.id];
const isChanged = changed.includes(f.id);
const lockedPerSong = multi && f.perSong;
const value = edits[f.id] ?? (c.mixed ? "" : c.value);
const removing = isChanged && edits[f.id] === "" && (c.present || c.mixed);
const Input = f.multiline ? "textarea" : "input";
return (
<label key={f.id} className={cx("block space-y-1", f.span === 2 ? "col-span-2" : "col-span-1")}>
<span className="flex items-center justify-between text-xs font-medium text-gray-300">
{f.label}
{isChanged && (
<button type="button" className="flex items-center gap-0.5 text-[11px] text-muted hover:text-white cursor-pointer" onClick={() => setEdits(({ [f.id]: _, ...rest }) => rest)}>
<RotateCcw className="size-3" /> revert
</button>
)}
</span>
<div className="relative">
<Input
className={cx(inputCls, f.multiline && "h-16 py-2", isChanged && "border-accent ring-1 ring-accent/40", "pr-7")}
value={value}
disabled={lockedPerSong}
placeholder={lockedPerSong ? "different per song" : c.mixed ? "(multiple values: keep)" : ""}
onChange={(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => setEdits((s) => ({ ...s, [f.id]: e.target.value }))}
/>
{!lockedPerSong && (value !== "" || c.mixed) && (
<button
type="button"
title="Remove this tag"
className="absolute right-2 top-2.5 text-muted hover:text-white cursor-pointer"
onClick={() => setEdits((s) => ({ ...s, [f.id]: "" }))}
>
<X className="size-3.5" />
</button>
)}
</div>
{removing && <span className="text-[11px] text-amber-300">will be removed</span>}
</label>
);
})}
</div>
)}
{!multi && editable[0] && Object.keys(editable[0].other).length > 0 && (
<div>
<button className="flex items-center gap-1 text-xs text-muted hover:text-white cursor-pointer" onClick={() => setShowOther(!showOther)}>
{showOther ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
{Object.keys(editable[0].other).length} other tags (kept as they are)
</button>
{showOther && (
<table className="mt-2 w-full text-xs">
<tbody>
{Object.entries(editable[0].other).map(([k, v]) => (
<tr key={k} className="border-t border-line/60">
<td className="py-1 pr-3 mono text-muted">{k}</td>
<td className="py-1 break-all">{v}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
{editable.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-3">
{art ? (
<ChosenArt art={art} onClear={() => setArt(null)} />
) : (
<>
<CoverImage src={coverURL(pairId, side, editable[0].rel)} className="size-16" />
<div className="text-xs">
<div className="font-medium text-gray-300">Album art{multi && " (first song)"}</div>
<button className="text-accent hover:underline cursor-pointer" onClick={() => setPicking(!picking)}>
{picking ? "Close search" : "Change art…"}
</button>
</div>
</>
)}
</div>
{picking && !art && (
<div className="rounded-lg border border-line bg-panel-2/40 p-3">
<ArtPicker
artist={common.album_artist?.value || common.artist?.value || ""}
album={common.album?.mixed ? "" : common.album?.value ?? ""}
onChoose={(p) => {
setArt(p);
setPicking(false);
}}
/>
</div>
)}
{art && editable.some((f) => /\.(ogg|oga|opus)$/i.test(f.rel)) && (
<p className="text-[11px] text-muted">OGG/Opus files can't hold embedded art; use Edit album to save a folder cover.jpg for them.</p>
)}
</div>
)}
{shared > 0 && (
<Alert tone="blue" icon={<Link2 className="mt-0.5 size-4 shrink-0" />}>
<div>
{shared === editable.length ? (multi ? "These files are" : "This file is") : `${shared} of these files are`} <b>hardlinked</b>:{" "}
the {side === "slave" ? "master" : "slave"} library uses the same file. Editing changes the tags in <b>both</b> libraries, and the
files stay linked.
</div>
<label className="mt-2 flex items-start gap-2 text-xs cursor-pointer">
<input type="checkbox" className="mt-0.5 accent-violet-500" checked={separate} onChange={(e) => setSeparate(e.target.checked)} />
<span>
Change only the {side} library's copy instead. This breaks the link: the {side} gets its own file, which uses extra disk space.
</span>
</label>
</Alert>
)}
<p className="text-xs text-muted">
Only the tags are rewritten; the audio is verified to be identical afterwards. The previous version goes to quarantine and the edit can
be undone from History.
</p>
{dry && <Alert tone="blue">Dry run is on, so the edit is recorded as planned in Activity. No files change.</Alert>}
</div>
)}
<div className="mt-5 flex items-center justify-end gap-2">
{changed.length > 0 && (
<span className="mr-auto text-xs text-muted">
{changed.length} field{changed.length > 1 && "s"} will change on {editable.length} song{editable.length > 1 && "s"}
</span>
)}
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button variant="primary" disabled={(changed.length === 0 && !art) || editable.length === 0} loading={save.isPending} onClick={() => save.mutate()}>
Save tags
</Button>
</div>
</Modal>
);
}
function commonFolder(rels: string[]) {
const dirs = rels.map((r) => r.split("/").slice(0, -1));
const first = dirs[0] ?? [];
let n = first.length;
for (const d of dirs) {
let i = 0;
while (i < n && d[i] === first[i]) i++;
n = i;
}
return first.slice(0, n).join("/") || "the library root";
}
// ---- automatic art matching ----
function AutoMatchDialog({ pairId, side, folder, onClose }: { pairId: number; side: Side; folder: string; onClose: () => void }) {
const qc = useQueryClient();
const dry = useInfo().data?.dry_run ?? true;
const [onlyMissing, setOnlyMissing] = useState(true);
const [embed, setEmbed] = useState(true);
const [folderFile, setFolderFile] = useState(true);
const [job, setJob] = useState<Job | null>(null);
const [error, setError] = useState("");
const running = job?.state === "running";
const start = async () => {
setError("");
try {
const j = await api.post<Job>(`/pairs/${pairId}/art/auto`, { side, folder, only_missing: onlyMissing, embed, folder_file: folderFile });
setJob(j);
for (;;) {
await new Promise((r) => setTimeout(r, 800));
const cur = await api.get<Job | null>(`/pairs/${pairId}/job`);
if (!cur || cur.id !== j.id) break;
setJob(cur);
if (cur.state !== "running") break;
}
qc.invalidateQueries({ queryKey: ["files"] });
qc.invalidateQueries({ queryKey: ["pair", pairId] });
} catch (e) {
setError((e as Error).message);
}
};
return (
<Modal open onClose={onClose} title="Auto-match album art" wide>
{!job && (
<div className="space-y-3 text-sm">
<p className="text-gray-300">
Finds covers for every album in <code className="mono">{folder || `the ${side} library`}</code> from iTunes, Deezer and MusicBrainz,
using each album's artist and album tags. A cover is only applied when both clearly match, and only square covers are used.
</p>
<div className="space-y-2">
<Toggle checked={onlyMissing} onChange={setOnlyMissing} label="Only albums that have no art yet" />
<Toggle checked={embed} onChange={setEmbed} label="Embed in songs (MP3, FLAC, M4A)" />
<Toggle checked={folderFile} onChange={setFolderFile} label="Save cover.jpg in each album folder" />
</div>
<p className="text-xs text-muted">
The album names are sent to those services to search. Every change goes through History and can be undone.
</p>
{dry && <Alert tone="blue">Dry run is on, so matches are recorded as planned in Activity. No files change.</Alert>}
{error && <Alert tone="red">{error}</Alert>}
</div>
)}
{job && (
<div className="space-y-3 text-sm">
{running ? (
<div className="space-y-2">
<div className="flex items-center gap-2 text-gray-300">
<Spinner className="size-4" /> {job.phase || "Working"}… {job.total > 0 && `${job.done} / ${job.total}`}
</div>
{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((job.done / job.total) * 100)}%` }} />
</div>
)}
</div>
) : (
<Alert tone={job.state === "done" ? "green" : "red"}>
{job.state === "done"
? Object.entries(job.results)
.map(([k, v]) => `${v} ${k}`)
.join(" · ") || "Nothing to do"
: job.error}
</Alert>
)}
{(job.notes?.length ?? 0) > 0 && (
<div>
<div className="mb-1 text-xs font-medium text-gray-300">Matched</div>
<ul className="max-h-40 space-y-0.5 overflow-y-auto text-xs text-emerald-200/90">
{job.notes!.map((n, i) => (
<li key={i}>{n}</li>
))}
</ul>
</div>
)}
{job.skipped.length > 0 && (
<div>
<div className="mb-1 text-xs font-medium text-gray-300">Not matched</div>
<ul className="max-h-40 space-y-0.5 overflow-y-auto text-xs text-amber-200/90">
{job.skipped.map((n, i) => (
<li key={i}>{n}</li>
))}
</ul>
<p className="mt-1 text-[11px] text-muted">Use Edit album → Change art… to search those by hand.</p>
</div>
)}
</div>
)}
<div className="mt-5 flex justify-end gap-2">
<Button variant="ghost" onClick={onClose}>
{job && !running ? "Close" : "Cancel"}
</Button>
{!job && (
<Button variant="primary" disabled={!embed && !folderFile} onClick={start}>
<Wand2 className="size-4" /> Find covers
</Button>
)}
</div>
</Modal>
);
}
-864
View File
@@ -1,864 +0,0 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
ArrowRight,
Check,
ChevronDown,
ChevronRight,
CircleDashed,
FileAudio,
FolderInput,
FolderTree,
Link2,
Minus,
Pencil,
RotateCcw,
Search,
SkipForward,
Tags as TagsIcon,
} from "lucide-react";
import { api, type AlbumFix, type FixItem, type FixStep, type Fixer, type Pair, type Side, type StepStatus } from "../api";
import { Alert, Badge, Button, Card, Field, Spinner, Toggle, cx, formatAgo, inputCls } from "../ui";
import { useToast } from "../toast";
import { useInfo } from "../App";
import { JobBar, ScanButton } from "./PairPage";
import { WarningList } from "./Warnings";
import { AlbumEditor } from "./Album";
// The tune-up takes each library through the same steps, master first, then
// links the two. Each step is checked, reviewed and applied here.
type Sel = { side: Side; step: FixStep } | "link";
const STEPS: { id: FixStep; title: string; icon: typeof FolderTree; optional?: boolean }[] = [
{ id: "structure", title: "Folder structure", icon: FolderTree },
{ id: "tags", title: "Album tags", icon: TagsIcon, optional: true },
{ id: "folders", title: "Folder names", icon: FolderInput },
{ id: "files", title: "File names", icon: FileAudio },
];
const SIDES: { id: Side; title: string }[] = [
{ id: "master", title: "Master library" },
{ id: "slave", title: "Slave library" },
];
const FIELD_LABEL: Record<string, string> = {
title: "Title",
artist: "Artist",
album: "Album",
album_artist: "Album artist",
track: "Track",
track_total: "Tracks",
disc: "Disc",
disc_total: "Discs",
date: "Date",
genre: "Genre",
};
const finished = (s?: StepStatus) => s?.state === "done" || s?.state === "skipped";
/** layoutOf shows where albums go, e.g. "{Artist Name}/{Album Title}/{track:00} {Track Title}". */
function layoutOf(pair: Pair) {
const n = pair.naming;
return [n.artist_folder, n.album_folder, n.track_format].filter(Boolean).join("/");
}
function firstOpen(pair: Pair): Sel {
for (const s of SIDES) for (const st of STEPS) if (!finished(pair.fixer[s.id][st.id])) return { side: s.id, step: st.id };
return "link";
}
export function TuneUpTab({ pair }: { pair: Pair }) {
const info = useInfo();
const [sel, setSel] = useState<Sel>(() => firstOpen(pair));
const fixer = useQuery({ queryKey: ["fixer", pair.id], queryFn: () => api.get<Fixer>(`/pairs/${pair.id}/fixer`) });
if (info.data && !info.data.tag_editing)
return <Alert tone="amber">The tune-up reads and writes tags with ffmpeg and ffprobe, which were not found. The Docker image includes both.</Alert>;
const isSel = (s: Side, st: FixStep) => sel !== "link" && sel.side === s && sel.step === st;
return (
<div className="grid gap-5 lg:grid-cols-[16rem_1fr]">
<nav className="space-y-4">
<Card className="p-3 text-xs">
<div className="text-muted">{pair.library_type === "soundtracks" ? "Soundtrack library" : "Music library"}</div>
<div className="mono mt-1 text-gray-200">
{layoutOf(pair)
.split("/")
.map((part, i) => (
<span key={i} className="whitespace-nowrap">
{i > 0 && "/"}
{part}
<wbr />
</span>
))}
</div>
<a href={`#/pair/${pair.id}/organize`} className="mt-1 inline-block text-accent hover:underline">
Change the naming
</a>
</Card>
{SIDES.map((s, si) => (
<div key={s.id}>
<div className="mb-1.5 px-1 text-xs font-medium uppercase tracking-wide text-muted">
{si + 1}. {s.title}
</div>
<div className="space-y-0.5">
{STEPS.map((st) => (
<StepLink
key={st.id}
status={pair.fixer[s.id][st.id]}
active={isSel(s.id, st.id)}
onClick={() => setSel({ side: s.id, step: st.id })}
>
{st.title}
{st.optional && <span className="ml-1 text-muted">(optional)</span>}
</StepLink>
))}
</div>
</div>
))}
<div>
<div className="mb-1.5 px-1 text-xs font-medium uppercase tracking-wide text-muted">3. Both libraries</div>
<StepLink status={pair.fixer.link} active={sel === "link"} onClick={() => setSel("link")}>
Link the libraries
</StepLink>
<a href={`#/pair/${pair.id}/review`} className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-gray-300 hover:bg-panel-2">
<ArrowRight className="size-4 text-muted" /> Review differences
{pair.summary.review > 0 && <Badge tone="amber">{pair.summary.review}</Badge>}
</a>
</div>
</nav>
<div className="min-w-0">
{sel === "link" ? (
<LinkPanel pair={pair} />
) : (
<StepPanel
key={`${sel.side}-${sel.step}`}
pair={pair}
side={sel.side}
step={sel.step}
fixer={fixer.data}
onNext={() => setSel(firstOpen(pair))}
/>
)}
</div>
</div>
);
}
function StepLink({ status, active, onClick, children }: { status?: StepStatus; active: boolean; onClick: () => void; children: ReactNode }) {
const icon =
status?.state === "done" ? (
<Check className="size-4 text-emerald-400" />
) : status?.state === "skipped" ? (
<Minus className="size-4 text-muted" />
) : (
<CircleDashed className={cx("size-4", active ? "text-accent" : "text-muted")} />
);
return (
<button
onClick={onClick}
className={cx(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm cursor-pointer",
active ? "bg-accent/15 text-white" : "text-gray-300 hover:bg-panel-2",
)}
>
{icon}
<span className="min-w-0 flex-1">{children}</span>
</button>
);
}
// ---- one step of one library ----
function stepText(step: FixStep, pair: Pair, side: Side): ReactNode {
const layout = <code className="mono text-gray-200">{layoutOf(pair)}</code>;
switch (step) {
case "structure":
return (
<>
Puts every album in its own folder at the right depth for {layout}. Songs outside an album folder are moved into one named from their tags;
extra folders around an album (a composer folder around a soundtrack, a <span className="mono">FLAC</span> folder inside an album) are
removed; songs in disc folders get their disc number; and missing titles, track numbers, albums and artists are filled in from the folder
and file names. Names themselves are fixed in the next steps.
</>
);
case "tags":
return (
<>
Looks each album up on MusicBrainz and shows corrected titles, track and disc numbers, album artist and date for you to review.
{side === "slave" && " Albums that are also in the master take the master's tags instead, so both libraries match."} Nothing changes until
you apply the albums you selected. This step is optional and can take a while: MusicBrainz allows about one lookup a second.
</>
);
case "folders":
return (
<>
Renames artist and album folders from the tags, following {layout}. Whole album folders move, so covers, booklets and other files stay with
their album.
</>
);
case "files":
return (
<>
Renames each song from its tags (<code className="mono text-gray-200">{pair.naming.track_format}</code>). Albums with several discs get{" "}
<span className="mono">Disc 01</span>, <span className="mono">Disc 02</span> folders. Lyrics files move with their song.
</>
);
}
}
function StepPanel({ pair, side, step, fixer, onNext }: { pair: Pair; side: Side; step: FixStep; fixer?: Fixer; onNext: () => void }) {
const qc = useQueryClient();
const toast = useToast();
const [folder, setFolder] = useState("");
const [onlyIncomplete, setOnlyIncomplete] = useState(true);
const meta = STEPS.find((s) => s.id === step)!;
const status = pair.fixer[side][step];
const plan = fixer?.plan && fixer.plan.side === side && fixer.plan.step === step ? fixer.plan : null;
const running = pair.job?.state === "running";
const ourJob = running && pair.job!.kind.startsWith("tune-up");
const refresh = () => {
qc.invalidateQueries({ queryKey: ["pair", pair.id] });
qc.invalidateQueries({ queryKey: ["fixer", pair.id] });
};
const check = useMutation({
mutationFn: () => api.post(`/pairs/${pair.id}/fixer/check`, { side, step, folder: folder.trim(), only_incomplete: onlyIncomplete }),
onSuccess: refresh,
onError: (e) => toast("err", e.message),
});
const mark = useMutation({
mutationFn: (state: StepStatus["state"]) => api.put(`/pairs/${pair.id}/fixer/step`, { side, step, state }),
onSuccess: (_, state) => {
refresh();
if (state) setTimeout(onNext, 300);
},
onError: (e) => toast("err", e.message),
});
const masterOpen = side === "slave" && STEPS.some((s) => !finished(pair.fixer.master[s.id]));
return (
<Card className="space-y-4 p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-xs uppercase tracking-wide text-muted">{side === "master" ? "Master library" : "Slave library"}</div>
<h2 className="flex items-center gap-2 text-lg font-semibold">
<meta.icon className="size-5 text-accent" /> {meta.title}
{meta.optional && <Badge>optional</Badge>}
{status?.state === "done" && <Badge tone="green">done {formatAgo(status.at)}</Badge>}
{status?.state === "skipped" && <Badge>skipped</Badge>}
</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
{finished(status) ? (
<Button size="sm" variant="ghost" onClick={() => mark.mutate("")}>
<RotateCcw className="size-3.5" /> Reopen
</Button>
) : (
<>
<Button size="sm" variant="ghost" onClick={() => mark.mutate("skipped")} title="Leave this step out">
<SkipForward className="size-3.5" /> Skip
</Button>
{plan && !plan.applied && (
<Button size="sm" onClick={() => mark.mutate("done")}>
<Check className="size-3.5" /> Mark done
</Button>
)}
</>
)}
<Button
size="sm"
variant={plan && !plan.applied ? "secondary" : "primary"}
onClick={() => check.mutate()}
disabled={running}
loading={check.isPending}
>
<Search className="size-3.5" /> {plan ? "Check again" : `Check the ${side}`}
</Button>
</div>
</div>
<p className="text-sm text-muted">{stepText(step, pair, side)}</p>
{masterOpen && (
<Alert tone="blue">
The master library isn't finished yet. Fixing the master first is recommended: the slave's album tags and links are matched against it.
</Alert>
)}
{step === "tags" && (
<div className="grid gap-3 rounded-lg border border-line bg-panel-2/40 p-3 md:grid-cols-2">
<Field label="Only this folder" hint="Optional, e.g. Avenged Sevenfold. Empty: the whole library.">
<input className={inputCls + " mono text-xs"} value={folder} placeholder="(whole library)" onChange={(e) => setFolder(e.target.value)} />
</Field>
<div className="pt-6">
<Toggle checked={onlyIncomplete} onChange={setOnlyIncomplete} label="Skip albums whose tags are already complete" />
<p className="mt-1 pl-11.5 text-xs text-muted">Complete: every song has a title, track number, album and artist, and they agree.</p>
</div>
</div>
)}
{ourJob ? (
<JobBar job={pair.job!} />
) : !plan ? (
<div className="rounded-lg border border-dashed border-line p-6 text-center text-sm text-muted">
Check the {side} library to see what needs fixing. Nothing is changed by checking.
</div>
) : plan.applied ? (
<Alert tone="green">
Fixes applied{pair.job?.results?.skipped ? `, ${pair.job.results.skipped} skipped (see above)` : ""}. Every change is in History and can be
undone. {finished(status) ? "This step is done." : "Check again to see what's left, or mark the step done."}
</Alert>
) : step === "tags" ? (
<AlbumResults pair={pair} side={side} fixer={fixer!} onApplied={refresh} />
) : (
<ItemResults pair={pair} side={side} fixer={fixer!} onApplied={refresh} />
)}
</Card>
);
}
function PlanStats({ fixer }: { fixer: Fixer }) {
const p = fixer.plan!;
const stats: [string, number, string?][] = [
["songs", p.songs],
["albums", p.albums],
["to fix", p.fixable, "text-accent"],
["need attention", p.blocked, p.blocked ? "text-amber-300" : undefined],
];
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{stats.map(([label, n, tone]) => (
<div key={label} className="rounded-lg border border-line bg-panel-2/50 px-3 py-2">
<div className={cx("text-lg font-semibold tabular-nums", tone)}>{n}</div>
<div className="text-xs text-muted">{label}</div>
</div>
))}
</div>
{p.notices.map((n, i) => (
<Alert key={i} tone="amber" icon={<AlertTriangle className="mt-0.5 size-4 shrink-0" />}>
{n}
</Alert>
))}
</div>
);
}
function useSelection(fixer: Fixer) {
const initial = () => new Set(fixer.items.filter((i) => !i.blocked && i.kind !== "problem" && !i.unsure).map((i) => i.id));
const [sel, setSel] = useState<Set<number>>(initial);
useEffect(() => setSel(initial()), [fixer.plan?.id]); // eslint-disable-line react-hooks/exhaustive-deps
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;
});
return { sel, toggle };
}
function ApplyBar({
pair,
plan,
count,
total,
ids,
onApplied,
}: {
pair: Pair;
plan: number;
count: number;
total: number;
ids: number[];
onApplied: () => void;
}) {
const toast = useToast();
const dry = useInfo().data?.dry_run ?? true;
const apply = useMutation({
mutationFn: () => api.post(`/pairs/${pair.id}/fixer/apply`, { plan, ids }),
onSuccess: onApplied,
onError: (e) => toast("err", e.message),
});
return (
<div className="sticky bottom-0 -mx-5 -mb-5 flex flex-wrap items-center gap-3 rounded-b-xl border-t border-line bg-panel/95 px-5 py-3 backdrop-blur">
<Button variant="primary" disabled={count === 0 || pair.job?.state === "running"} loading={apply.isPending} onClick={() => apply.mutate()}>
<Check className="size-4" /> Apply {count} {count === 1 ? "fix" : "fixes"}
</Button>
<span className="text-sm text-muted">
{count} of {total} selected{dry && " · dry run: changes are only recorded"}
</span>
</div>
);
}
// ---- moves and tag fixes ----
const GROUP_PAGE = 60;
function ItemResults({ pair, side, fixer, onApplied }: { pair: Pair; side: Side; fixer: Fixer; onApplied: () => void }) {
const { sel, toggle } = useSelection(fixer);
const [shown, setShown] = useState(GROUP_PAGE);
const [album, setAlbum] = useState<string | null>(null);
const fixable = fixer.items.filter((i) => !i.blocked && i.kind !== "problem");
const attention = fixer.items.filter((i) => i.blocked || i.kind === "problem");
const groups = useMemo(() => {
const m = new Map<string, FixItem[]>();
for (const it of fixable) m.set(it.group, [...(m.get(it.group) ?? []), it]);
return [...m.entries()];
}, [fixer.items]); // eslint-disable-line react-hooks/exhaustive-deps
const plan = fixer.plan!;
return (
<div className="space-y-4">
<PlanStats fixer={fixer} />
{plan.fixable === 0 && plan.blocked === 0 && <Alert tone="green">Nothing to fix here. Mark the step done to continue.</Alert>}
{groups.length > 0 && (
<div className="overflow-hidden rounded-lg border border-line">
<div className="flex items-center gap-2 border-b border-line bg-panel-2 px-3 py-2 text-xs text-muted">
<input
type="checkbox"
className="accent-violet-500"
checked={fixable.every((i) => sel.has(i.id))}
onChange={(e) =>
toggle(
fixable.map((i) => i.id),
e.target.checked,
)
}
/>
Select all · grouped by where they end up
</div>
<div className="divide-y divide-line">
{groups.slice(0, shown).map(([g, items]) => (
<FixGroup key={g} group={g} items={items} sel={sel} toggle={toggle} />
))}
</div>
{groups.length > shown && (
<button
className="w-full border-t border-line py-2 text-xs text-accent hover:bg-panel-2 cursor-pointer"
onClick={() => setShown(shown + GROUP_PAGE)}
>
Show more ({groups.length - shown} folders left)
</button>
)}
</div>
)}
{attention.length > 0 && (
<div>
<h3 className="mb-1.5 flex items-center gap-1.5 text-sm font-medium text-amber-200">
<AlertTriangle className="size-4" /> Needs attention ({attention.length})
</h3>
<div className="divide-y divide-amber-500/10 overflow-hidden rounded-lg border border-amber-500/25 bg-amber-500/5">
{attention.slice(0, 200).map((it) => (
<div key={it.id} className="flex flex-wrap items-start gap-x-3 gap-y-1 px-3 py-2 text-xs">
<div className="min-w-0 flex-1">
<div className="mono break-all text-gray-100">{it.from}</div>
<div className="text-amber-200/90">
{it.why}
{it.blocked && <span className="text-amber-200/60"> · {it.blocked}</span>}
</div>
</div>
{it.from && (
<Button size="sm" variant="ghost" onClick={() => setAlbum(it.kind === "move-file" ? it.group : it.group || it.from!)}>
<Pencil className="size-3.5" /> Edit album
</Button>
)}
</div>
))}
{attention.length > 200 && <div className="px-3 py-2 text-xs text-muted">and {attention.length - 200} more</div>}
</div>
</div>
)}
{fixable.length > 0 && (
<ApplyBar
pair={pair}
plan={plan.id}
count={fixable.filter((i) => sel.has(i.id)).length}
total={fixable.length}
ids={fixable.filter((i) => sel.has(i.id)).map((i) => i.id)}
onApplied={onApplied}
/>
)}
{album !== null && <AlbumEditor pairId={pair.id} side={side} folder={album} onClose={() => setAlbum(null)} />}
</div>
);
}
function FixGroup({
group,
items,
sel,
toggle,
}: {
group: string;
items: FixItem[];
sel: Set<number>;
toggle: (ids: number[], on: boolean) => void;
}) {
const [open, setOpen] = useState(items.length <= 3);
const ids = items.map((i) => i.id);
const n = ids.filter((id) => sel.has(id)).length;
const single = items.length === 1 ? items[0] : null;
return (
<div>
<div className="flex items-center gap-2 px-3 py-2">
<input type="checkbox" className="accent-violet-500" checked={n === ids.length} onChange={(e) => toggle(ids, e.target.checked)} />
<button className="flex min-w-0 flex-1 items-center gap-1.5 text-left text-sm cursor-pointer" onClick={() => setOpen(!open)}>
{open ? <ChevronDown className="size-3.5 shrink-0 text-muted" /> : <ChevronRight className="size-3.5 shrink-0 text-muted" />}
<span className="mono truncate text-gray-100">{group || "(library root)"}</span>
<span className="shrink-0 text-xs text-muted">
{items.length} {items.length === 1 ? "fix" : "fixes"}
{n !== ids.length && ` · ${n} selected`}
</span>
{items.some((i) => i.unsure) && <Badge tone="amber">check</Badge>}
</button>
</div>
{(open || single) && (
<div className="space-y-1 pb-2 pl-9 pr-3">
{items.map((it) => (
<FixRow key={it.id} it={it} checked={sel.has(it.id)} onChange={(on) => toggle([it.id], on)} />
))}
</div>
)}
</div>
);
}
function FixRow({ it, checked, onChange }: { it: FixItem; checked: boolean; onChange: (on: boolean) => void }) {
return (
<label className="flex items-start gap-2 rounded-md px-2 py-1 text-xs hover:bg-panel-2 cursor-pointer">
<input type="checkbox" className="mt-0.5 accent-violet-500" checked={checked} onChange={(e) => onChange(e.target.checked)} />
<div className="min-w-0 flex-1">
{it.kind === "tags" ? (
<>
<div className="mono break-all text-gray-300">{it.from}</div>
<Changes changes={it.changes ?? {}} />
</>
) : (
<div className="flex flex-wrap items-center gap-x-1.5 mono break-all">
<span className="text-gray-400">{it.from}</span>
<ArrowRight className="size-3 shrink-0 text-muted" />
<span className="text-gray-100">{it.to}</span>
{it.kind === "move-folder" && <Badge>folder</Badge>}
</div>
)}
<div className="text-muted">
{it.why}
{it.unsure && <span className="text-amber-300"> · not selected: check it first</span>}
</div>
</div>
</label>
);
}
function Changes({ changes }: { changes: Record<string, [string, string]> }) {
const keys = Object.keys(changes).sort((a, b) => Object.keys(FIELD_LABEL).indexOf(a) - Object.keys(FIELD_LABEL).indexOf(b));
return (
<div className="flex flex-wrap gap-x-3 gap-y-0.5">
{keys.map((k) => (
<span key={k}>
<span className="text-muted">{FIELD_LABEL[k] ?? k}: </span>
{changes[k][0] ? <span className="text-red-300/80 line-through">{changes[k][0]}</span> : <span className="text-muted">(empty)</span>}
<span className="text-muted"> → </span>
<span className="text-emerald-300">{changes[k][1]}</span>
</span>
))}
</div>
);
}
// ---- album tags (online lookup) ----
function AlbumResults({ pair, side, fixer, onApplied }: { pair: Pair; side: Side; fixer: Fixer; onApplied: () => void }) {
const { sel, toggle } = useSelection(fixer);
const [shown, setShown] = useState(40);
const [album, setAlbum] = useState<string | null>(null);
const blocked = new Set(fixer.items.filter((i) => i.blocked).map((i) => i.id));
const ordered = [...fixer.albums].sort((a, b) => Number(blocked.has(a.id)) - Number(blocked.has(b.id)) || a.folder.localeCompare(b.folder));
const usable = fixer.albums.filter((a) => !blocked.has(a.id));
return (
<div className="space-y-4">
<PlanStats fixer={fixer} />
{fixer.albums.length === 0 && <Alert tone="green">Every album checked already matches. Mark the step done to continue.</Alert>}
<div className="space-y-2">
{ordered.slice(0, shown).map((a) => (
<AlbumCard
key={a.id}
pair={pair}
a={a}
blocked={blocked.has(a.id)}
checked={sel.has(a.id)}
onCheck={(on) => toggle([a.id], on)}
onEdit={() => setAlbum(a.folder)}
/>
))}
{ordered.length > shown && (
<Button size="sm" variant="ghost" onClick={() => setShown(shown + 40)}>
Show more ({ordered.length - shown} albums left)
</Button>
)}
</div>
{usable.length > 0 && (
<ApplyBar
pair={pair}
plan={fixer.plan!.id}
count={usable.filter((a) => sel.has(a.id)).length}
total={usable.length}
ids={usable.filter((a) => sel.has(a.id)).map((a) => a.id)}
onApplied={onApplied}
/>
)}
{album !== null && <AlbumEditor pairId={pair.id} side={side} folder={album} onClose={() => setAlbum(null)} />}
</div>
);
}
function Confidence({ c }: { c: number }) {
if (c >= 0.85) return <Badge tone="green">good match</Badge>;
if (c >= 0.6) return <Badge tone="amber">check</Badge>;
return <Badge tone="red">weak match</Badge>;
}
function AlbumCard({
pair,
a,
blocked,
checked,
onCheck,
onEdit,
}: {
pair: Pair;
a: AlbumFix;
blocked: boolean;
checked: boolean;
onCheck: (on: boolean) => void;
onEdit: () => void;
}) {
const qc = useQueryClient();
const toast = useToast();
const [open, setOpen] = useState(false);
const choose = useMutation({
mutationFn: (release: string) => api.post<AlbumFix>(`/pairs/${pair.id}/fixer/release`, { album: a.id, release }),
onSuccess: (res) => {
qc.invalidateQueries({ queryKey: ["fixer", pair.id] });
if (!res.error) onCheck(true);
},
onError: (e) => toast("err", e.message),
});
const changed = a.files.filter((f) => Object.keys(f.changes).length > 0).length;
const r = a.release;
return (
<div className={cx("rounded-lg border", blocked ? "border-amber-500/25 bg-amber-500/5" : "border-line")}>
<div className="flex flex-wrap items-start gap-3 px-3 py-2.5">
<input
type="checkbox"
className="mt-1 accent-violet-500"
disabled={blocked}
checked={checked && !blocked}
onChange={(e) => onCheck(e.target.checked)}
/>
<div className="min-w-0 flex-1 space-y-1">
<div className="mono break-all text-sm text-gray-100">{a.folder}</div>
{r && !blocked ? (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
<span className="text-gray-200">
{r.artist} – {r.title}
</span>
<span className="text-muted">
{[r.date, r.country, r.formats, r.track_count ? `${r.track_count} tracks` : ""].filter(Boolean).join(" · ")}
</span>
{a.source === "master" ? <Badge tone="violet">from master</Badge> : <Badge>MusicBrainz</Badge>}
<Confidence c={a.confidence} />
<span className="text-muted">
{a.matched} of {a.files.length} songs matched · {changed} change
</span>
</div>
) : (
<div className="text-xs text-amber-200">{a.error || "No confident match"}</div>
)}
</div>
<div className="flex flex-wrap items-center gap-1.5">
{a.source === "master" ? (
<Button size="sm" variant="ghost" loading={choose.isPending} onClick={() => choose.mutate("search")}>
<Search className="size-3.5" /> Use MusicBrainz
</Button>
) : (
a.candidates.length > 0 && (
<select
className={inputCls + " h-8 w-56 text-xs"}
value={r?.id ?? ""}
disabled={choose.isPending}
onChange={(e) => e.target.value && choose.mutate(e.target.value)}
title="Pick another release"
>
<option value="">{r ? "Other releases…" : "Pick a release…"}</option>
{a.candidates.map((c) => (
<option key={c.id} value={c.id}>
{c.artist} – {c.title} ({[c.date?.slice(0, 4), c.country, c.formats, `${c.track_count} tr`].filter(Boolean).join(", ")})
</option>
))}
</select>
)
)}
{choose.isPending && <Spinner className="size-4" />}
<Button size="sm" variant="ghost" onClick={onEdit} title="Edit the album's tags by hand">
<Pencil className="size-3.5" /> Edit album
</Button>
{!blocked && (
<Button size="sm" variant="ghost" onClick={() => setOpen(!open)}>
{open ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />} Changes
</Button>
)}
</div>
</div>
{open && !blocked && (
<div className="border-t border-line px-3 py-2">
{Object.keys(a.album_changes).length > 0 && (
<div className="mb-2 text-xs">
<span className="mr-2 text-muted">Every song:</span>
<Changes changes={a.album_changes} />
</div>
)}
<table className="w-full text-xs">
<thead className="text-left text-muted">
<tr>
<th className="py-1 pr-3 font-normal">File</th>
<th className="py-1 pr-3 font-normal">Matched track</th>
<th className="py-1 font-normal">Changes</th>
</tr>
</thead>
<tbody className="divide-y divide-line">
{a.files.map((f) => {
const own = Object.fromEntries(Object.entries(f.changes).filter(([k]) => !(k in a.album_changes)));
return (
<tr key={f.path} className="align-top">
<td className="mono break-all py-1 pr-3 text-gray-300">{f.path.slice(a.folder.length + 1)}</td>
<td className="py-1 pr-3 text-gray-300">{f.track ?? <span className="text-amber-300">not matched</span>}</td>
<td className="py-1">{Object.keys(own).length ? <Changes changes={own} /> : <span className="text-muted">—</span>}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
// ---- linking ----
function LinkPanel({ pair }: { pair: Pair }) {
const qc = useQueryClient();
const toast = useToast();
const [confirm, setConfirm] = useState(false);
const c = pair.summary.counts;
const open = SIDES.flatMap((s) => STEPS.filter((st) => !finished(pair.fixer[s.id][st.id])).map((st) => `${s.title}: ${st.title}`));
const refresh = () => {
qc.invalidateQueries({ queryKey: ["pair", pair.id] });
qc.invalidateQueries({ queryKey: ["pairs"] });
};
const link = useMutation({
mutationFn: () => api.post(`/pairs/${pair.id}/link-identical`),
onSuccess: () => {
setConfirm(false);
refresh();
},
onError: (e) => toast("err", e.message),
});
const mark = useMutation({
mutationFn: (state: StepStatus["state"]) => api.put(`/pairs/${pair.id}/fixer/step`, { side: "", step: "link", state }),
onSuccess: refresh,
onError: (e) => toast("err", e.message),
});
const running = pair.job?.state === "running";
const stat = (label: string, n: number | undefined, tone?: string) => (
<div className="rounded-lg border border-line bg-panel-2/50 px-3 py-2">
<div className={cx("text-lg font-semibold tabular-nums", tone)}>{n ?? 0}</div>
<div className="text-xs text-muted">{label}</div>
</div>
);
return (
<Card className="space-y-5 p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-xs uppercase tracking-wide text-muted">Both libraries</div>
<h2 className="flex items-center gap-2 text-lg font-semibold">
<Link2 className="size-5 text-accent" /> Link the libraries
{pair.fixer.link?.state === "done" && <Badge tone="green">done {formatAgo(pair.fixer.link.at)}</Badge>}
</h2>
</div>
{pair.fixer.link?.state === "done" ? (
<Button size="sm" variant="ghost" onClick={() => mark.mutate("")}>
<RotateCcw className="size-3.5" /> Reopen
</Button>
) : (
<Button size="sm" onClick={() => mark.mutate("done")}>
<Check className="size-3.5" /> Mark done
</Button>
)}
</div>
<p className="text-sm text-muted">
With both libraries organized the same way, songs match by path. Identical songs are replaced in the slave with a hardlink to the master, so
they take up space only once. Songs that differ (another quality, other tags) are left for you in Review.
</p>
{open.length > 0 && (
<Alert tone="amber" icon={<AlertTriangle className="mt-0.5 size-4 shrink-0" />}>
Not finished yet: {open.join(", ")}. Linking works anyway, but songs in folders that are still named differently won't match.
</Alert>
)}
<section className="space-y-2">
<h3 className="text-sm font-medium">1. Scan both libraries</h3>
<div className="flex flex-wrap items-center gap-3 text-sm">
<ScanButton pair={pair} />
<span className="text-muted">Last scan {formatAgo(pair.last_scan_at)}</span>
</div>
{running && pair.job && <JobBar job={pair.job} />}
</section>
{pair.last_scan_warnings.length > 0 && (
<section className="space-y-2">
<h3 className="text-sm font-medium">2. Folders named differently</h3>
<WarningList warnings={pair.last_scan_warnings} pair={pair} />
</section>
)}
<section className="space-y-2">
<h3 className="text-sm font-medium">{pair.last_scan_warnings.length > 0 ? "3" : "2"}. Link identical songs</h3>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-5">
{stat("already linked", c.linked, "text-emerald-300")}
{stat("identical", c.identical, "text-accent")}
{stat("different", (c.different ?? 0) + (c.similar ?? 0), "text-amber-300")}
{stat("only in slave", c.unmatched)}
{stat("errors", c.error, c.error ? "text-red-300" : undefined)}
</div>
{confirm ? (
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-accent/40 bg-accent/10 px-3 py-2 text-sm">
Replace {c.identical ?? 0} slave songs with hardlinks to the identical master songs? Each is compared byte by byte first.
<Button size="sm" variant="primary" loading={link.isPending} onClick={() => link.mutate()}>
Link them
</Button>
<Button size="sm" variant="ghost" onClick={() => setConfirm(false)}>
Cancel
</Button>
</div>
) : (
<Button variant="primary" disabled={!c.identical || running} onClick={() => setConfirm(true)}>
<Link2 className="size-4" /> Link {c.identical ?? 0} identical songs
</Button>
)}
</section>
<section className="space-y-2">
<h3 className="text-sm font-medium">{pair.last_scan_warnings.length > 0 ? "4" : "3"}. Review the differences</h3>
<p className="text-sm text-muted">Upgrades, other formats and approximate matches are reviewed one album or song at a time.</p>
<a href={`#/pair/${pair.id}/review`}>
<Button>
Open Review <ArrowRight className="size-4" />
{pair.summary.review > 0 && <Badge tone="amber">{pair.summary.review}</Badge>}
</Button>
</a>
</section>
</Card>
);
}