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:
1 parent
7b180e26db
commit
e0617047f7
44 files changed
+103
-10456
No files matched your search
+1
-1
@@ -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
@@ -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}` : ""}`;
|
||||
@@ -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.`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)"
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user