Add a built-in tag editor for both libraries
A new Tags tab browses either library; songs can be edited one at a time
or several at once (e.g. a whole album, where per-song fields are locked
and differing values are kept unless changed). Review's compare panel
links to the editor for either side.
Tags are read with ffprobe and written with ffmpeg (MP3/ID3, FLAC, M4A,
OGG, Opus). An existing tag is always written under the name the file
already uses, so edits replace values instead of duplicating them (e.g.
raw Vorbis ALBUMARTIST/TRACKNUMBER), totals follow the file's style
("3/12" or a separate TRACKTOTAL), stale alias tags (YEAR next to DATE)
are removed, other tags and cover art are kept, and no encoder tag is
added. Every written file is verified: values must read back and the
decoded audio must be bit-identical.
Hardlinks are respected: a file used by one library is replaced
atomically (old file to quarantine); a file shared with the other
library is updated in place so the link survives, after a verified
backup copy goes to quarantine, with an option to edit only one
library's copy instead. Edits are operations (old -> new values shown in
History) that can be undone; an interrupted in-place edit is rolled back
on startup. Dry run applies. Migration 6 stores operation details.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1da0132d5e
commit
3eb02e2c8d
17 files changed
+1842
-33
No files matched your search
+18
-1
@@ -34,6 +34,8 @@ export type Info = {
|
||||
default_extensions: string[];
|
||||
dry_run: boolean;
|
||||
plex_ignore: boolean;
|
||||
tag_editing: boolean;
|
||||
tag_fields: string[];
|
||||
};
|
||||
|
||||
export type Job = {
|
||||
@@ -139,7 +141,7 @@ export type Match = {
|
||||
export type Operation = {
|
||||
id: number;
|
||||
pair_id: number;
|
||||
action: "link" | "promote" | "build" | "remove" | "rename";
|
||||
action: "link" | "promote" | "build" | "remove" | "rename" | "tags";
|
||||
rel_master: string;
|
||||
rel_slave: string;
|
||||
rel_new: string;
|
||||
@@ -154,6 +156,7 @@ 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]> };
|
||||
};
|
||||
|
||||
export type TreeEntry = {
|
||||
@@ -211,3 +214,17 @@ 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;
|
||||
};
|
||||
@@ -37,6 +37,7 @@ const actionLabel: Record<Operation["action"], string> = {
|
||||
build: "Built into slave",
|
||||
remove: "Removed from slave",
|
||||
rename: "Renamed slave folder",
|
||||
tags: "Edited tags",
|
||||
};
|
||||
|
||||
const statusTone: Record<Operation["status"], "green" | "red" | "blue" | "amber" | "gray"> = {
|
||||
@@ -95,9 +96,19 @@ 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_slave}
|
||||
<div className="truncate mono text-[13px]" title={o.rel_slave || o.rel_master}>
|
||||
{o.action === "tags" && <span className="mr-1 font-sans text-[11px] text-muted">{o.detail?.side}</span>}
|
||||
{o.rel_slave || o.rel_master}
|
||||
</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>
|
||||
)}
|
||||
@@ -178,6 +189,10 @@ 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 "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.`;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Review } from "./Review";
|
||||
import { Build } from "./Build";
|
||||
import { OpsTable } from "./Activity";
|
||||
import { WarningList } from "./Warnings";
|
||||
import { TagsTab } from "./Tags";
|
||||
|
||||
export function usePair(id: number) {
|
||||
return useQuery({
|
||||
@@ -122,6 +123,7 @@ function LastJob({ job }: { job: Job }) {
|
||||
const tabs = [
|
||||
{ id: "review", label: "Review" },
|
||||
{ id: "build", label: "Build" },
|
||||
{ id: "tags", label: "Tags" },
|
||||
{ id: "history", label: "History" },
|
||||
];
|
||||
|
||||
@@ -202,6 +204,7 @@ export function PairPage({ id, tab }: { id: number; tab: string }) {
|
||||
|
||||
{tab === "review" && <Review pair={pair} />}
|
||||
{tab === "build" && <Build pair={pair} />}
|
||||
{tab === "tags" && <TagsTab pair={pair} />}
|
||||
{tab === "history" && <OpsTable pairId={pair.id} />}
|
||||
|
||||
<PairForm pair={pair} open={editing} onClose={() => setEditing(false)} />
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
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 { 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 { 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";
|
||||
@@ -375,6 +376,7 @@ 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;
|
||||
@@ -489,6 +491,13 @@ 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>
|
||||
|
||||
@@ -526,6 +535,14 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronDown, ChevronRight, FileAudio, Folder, Link2, RotateCcw, Tags as TagsIcon, X } from "lucide-react";
|
||||
import { api, type FileEntry, type Pair, type Side, type TagFile } from "../api";
|
||||
import { useInfo } from "../App";
|
||||
import { Alert, Badge, Button, Card, Empty, Modal, Spinner, 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);
|
||||
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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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, or tick several (for example a whole album) to edit the tags they share. <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())}
|
||||
/>
|
||||
)}
|
||||
</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 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 }),
|
||||
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>
|
||||
)}
|
||||
|
||||
{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 || 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";
|
||||
}
|
||||
Reference in new issue
Block a user