Add album art search, auto-match and a whole-album tag editor

Search iTunes, Deezer, MusicBrainz, DuckDuckGo and Google for square covers,
embed them and/or save cover.jpg, auto-match covers for albums, and edit an
album's shared tags and track list in one place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-29 15:30:19 -07:00
1 parent 5fb195f0d6
commit d8ac25a55d
21 files changed
+2719 -38

No files matched your search

+12
View File
@@ -50,6 +50,7 @@ export type Job = {
error: string;
results: Record<string, number>;
skipped: string[];
notes?: string[];
started_at: number;
finished_at?: number;
};
@@ -253,3 +254,14 @@ export type Naming = {
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}` : ""}`;
+287
View File
@@ -0,0 +1,287 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Image as ImageIcon, Link2, ListOrdered, RotateCcw, UserRound } from "lucide-react";
import { api, coverURL, type Album, type AlbumSong, type PreparedArt, type Side } from "../api";
import { useInfo } from "../App";
import { Alert, Button, Modal, Spinner, Toggle, cx, inputCls } from "../ui";
import { useToast } from "../toast";
import { ArtPicker, ChosenArt, CoverImage } from "./Art";
// Album-level fields apply to every song; row fields are per song.
const ALBUM_FIELDS: { id: string; label: string; wide?: boolean }[] = [
{ id: "album", label: "Album", wide: true },
{ id: "album_artist", label: "Album artist", wide: true },
{ id: "date", label: "Year / date" },
{ id: "genre", label: "Genre" },
{ id: "disc_total", label: "Discs" },
{ id: "composer", label: "Composer", wide: true },
{ id: "comment", label: "Comment", wide: true },
];
const ROW_FIELDS = ["disc", "track", "title", "artist"] as const;
const num = (s?: string) => parseInt(s ?? "", 10) || 0;
/**
* Edits a whole album at once, AudioShell-style: album fields for every song,
* a grid for each song's number, title and artist, and the album art.
*/
export function AlbumEditor({ pairId, side, folder, onClose }: { pairId: number; side: Side; folder: string; onClose: () => void }) {
const qc = useQueryClient();
const toast = useToast();
const dry = useInfo().data?.dry_run ?? true;
const data = useQuery({
queryKey: ["album", pairId, side, folder],
queryFn: () => api.get<Album>(`/pairs/${pairId}/album?side=${side}&path=${encodeURIComponent(folder)}`),
gcTime: 0,
});
const [albumEdits, setAlbumEdits] = useState<Record<string, string>>({});
const [rowEdits, setRowEdits] = useState<Record<string, Record<string, string>>>({});
const [art, setArt] = useState<PreparedArt | null>(null);
const [picking, setPicking] = useState(false);
const [embed, setEmbed] = useState(true);
const [folderFile, setFolderFile] = useState(true);
const [separate, setSeparate] = useState(false);
const songs = useMemo(() => {
const s = (data.data?.songs ?? []).filter((x) => x.editable);
return [...s].sort(
(a, b) =>
num(a.fields.disc) - num(b.fields.disc) || num(a.fields.track) - num(b.fields.track) || a.rel.localeCompare(b.rel),
);
}, [data.data]);
const blocked = (data.data?.songs ?? []).filter((x) => !x.editable);
const common = useMemo(() => {
const c: Record<string, { value: string; mixed: boolean }> = {};
for (const f of ALBUM_FIELDS) {
const vals = songs.map((s) => s.fields[f.id] ?? "");
c[f.id] = { value: vals[0] ?? "", mixed: vals.some((v) => v !== vals[0]) };
}
return c;
}, [songs]);
const albumValue = (id: string) => albumEdits[id] ?? (common[id]?.mixed ? "" : common[id]?.value ?? "");
const rowValue = (s: AlbumSong, id: string) => rowEdits[s.rel]?.[id] ?? s.fields[id] ?? "";
const setRow = (rel: string, id: string, v: string) => setRowEdits((r) => ({ ...r, [rel]: { ...r[rel], [id]: v } }));
const albumChanged = Object.keys(albumEdits).filter((id) => common[id]?.mixed || albumEdits[id] !== common[id]?.value);
const rowChanges = songs
.map((s) => ({
path: s.rel,
set: Object.fromEntries(Object.entries(rowEdits[s.rel] ?? {}).filter(([id, v]) => v !== (s.fields[id] ?? ""))),
}))
.filter((r) => Object.keys(r.set).length > 0);
const hasChanges = albumChanged.length > 0 || rowChanges.length > 0 || !!art;
// "Number tracks in order": 1..n within each disc, with the disc's total.
const numberTracks = () => {
const byDisc = new Map<number, AlbumSong[]>();
for (const s of songs) {
const d = num(rowValue(s, "disc")) || 1;
byDisc.set(d, [...(byDisc.get(d) ?? []), s]);
}
const next: Record<string, Record<string, string>> = { ...rowEdits };
for (const list of byDisc.values()) {
list.forEach((s, i) => {
next[s.rel] = { ...next[s.rel], track: String(i + 1), track_total: String(list.length) };
});
}
setRowEdits(next);
if (byDisc.size > 1) setAlbumEdits((a) => ({ ...a, disc_total: String(byDisc.size) }));
};
const artistFromAlbumArtist = () => {
const aa = albumValue("album_artist");
if (!aa) return;
setRowEdits((r) => Object.fromEntries(songs.map((s) => [s.rel, { ...r[s.rel], artist: aa }])));
};
const save = useMutation({
mutationFn: () =>
api.post(`/pairs/${pairId}/tags`, {
side,
paths: songs.map((s) => s.rel),
set: Object.fromEntries(albumChanged.map((id) => [id, albumEdits[id].trim()])),
files: rowChanges.map((r) => ({ path: r.path, set: Object.fromEntries(Object.entries(r.set).map(([k, v]) => [k, v.trim()])) })),
art: art ? { token: art.token, embed, folder_file: folderFile } : undefined,
separate,
}),
onSuccess: () => {
toast("ok", dry ? "Started (dry run): recorded as planned only" : `Saving the album (${songs.length} songs)…`);
qc.invalidateQueries({ queryKey: ["files"] });
qc.invalidateQueries({ queryKey: ["pair", pairId] });
onClose();
},
onError: (e) => toast("err", e.message),
});
const shared = songs.filter((s) => s.links > 1).length;
const embeddable = songs.filter((s) => s.embed_cover).length;
const currentCover = data.data
? data.data.folder_cover
? coverURL(pairId, side, folder)
: songs.find((s) => s.has_cover)
? coverURL(pairId, side, songs.find((s) => s.has_cover)!.rel)
: null
: null;
useEffect(() => setPicking(false), [art]);
return (
<Modal open onClose={onClose} title={`Edit album: ${folder.split("/").pop() || folder}`} wide>
{data.isLoading && <Spinner />}
{data.error && <Alert tone="red">{data.error.message}</Alert>}
{data.data && songs.length === 0 && <Alert tone="amber">No editable songs in this folder.</Alert>}
{data.data && songs.length > 0 && (
<div className="space-y-4 text-sm">
<div className="text-xs text-muted">
<span className="mono">{folder}</span> · {songs.length} songs{blocked.length > 0 && `, ${blocked.length} not editable`}
</div>
<div className="grid gap-4 md:grid-cols-[11rem_1fr]">
<div className="space-y-2">
{art ? <ChosenArt art={art} onClear={() => setArt(null)} /> : <CoverImage src={currentCover} className="aspect-square w-full" />}
{!art && (
<Button size="sm" className="w-full" onClick={() => setPicking(!picking)}>
<ImageIcon className="size-3.5" /> {picking ? "Close search" : "Change art…"}
</Button>
)}
{art && (
<div className="space-y-1.5 text-xs">
<Toggle checked={embed} onChange={setEmbed} label={`Embed in songs (${embeddable})`} />
<Toggle checked={folderFile} onChange={setFolderFile} label="Save cover.jpg in folder" />
{embeddable < songs.length && (
<p className="text-[11px] text-muted">OGG/Opus songs can't hold embedded art; they use the folder's cover.jpg.</p>
)}
</div>
)}
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-2.5 md:grid-cols-4">
{ALBUM_FIELDS.map((f) => {
const changed = albumChanged.includes(f.id);
return (
<label key={f.id} className={cx("block space-y-1", f.wide ? "col-span-2" : "col-span-1")}>
<span className="flex justify-between text-xs font-medium text-gray-300">
{f.label}
{changed && (
<button type="button" className="text-muted hover:text-white cursor-pointer" onClick={() => setAlbumEdits(({ [f.id]: _, ...r }) => r)}>
<RotateCcw className="size-3" />
</button>
)}
</span>
<input
className={cx(inputCls, "h-8 text-xs", changed && "border-accent ring-1 ring-accent/40")}
value={albumValue(f.id)}
placeholder={common[f.id]?.mixed ? "(differs per song: keep)" : ""}
onChange={(e) => setAlbumEdits((a) => ({ ...a, [f.id]: e.target.value }))}
/>
</label>
);
})}
</div>
</div>
{picking && !art && (
<div className="rounded-lg border border-line bg-panel-2/40 p-3">
<ArtPicker artist={albumValue("album_artist") || songs[0]?.fields.artist || ""} album={albumValue("album")} onChoose={setArt} />
</div>
)}
<div>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<span className="text-xs font-medium text-gray-300">Tracks</span>
<div className="flex gap-1.5">
<Button size="sm" variant="ghost" onClick={numberTracks} title="Number the tracks 1, 2, 3… in the order shown, per disc">
<ListOrdered className="size-3.5" /> Number tracks in order
</Button>
<Button size="sm" variant="ghost" onClick={artistFromAlbumArtist} disabled={!albumValue("album_artist")}>
<UserRound className="size-3.5" /> Artist = album artist
</Button>
{rowChanges.length > 0 && (
<Button size="sm" variant="ghost" onClick={() => setRowEdits({})}>
<RotateCcw className="size-3.5" /> Revert tracks
</Button>
)}
</div>
</div>
<div className="max-h-[40vh] overflow-auto rounded-lg border border-line">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-panel-2 text-left text-muted">
<tr>
<th className="w-14 px-2 py-1.5 font-medium">Disc</th>
<th className="w-14 px-2 py-1.5 font-medium">#</th>
<th className="px-2 py-1.5 font-medium">Title</th>
<th className="w-44 px-2 py-1.5 font-medium">Artist</th>
<th className="w-40 px-2 py-1.5 font-medium">File</th>
</tr>
</thead>
<tbody>
{songs.map((s) => (
<tr key={s.rel} className="border-t border-line/60">
{ROW_FIELDS.map((id) => {
const v = rowValue(s, id);
const changed = v !== (s.fields[id] ?? "");
return (
<td key={id} className="px-1 py-0.5">
<input
className={cx(
"w-full rounded border border-transparent bg-transparent px-1.5 py-1 outline-none hover:border-line focus:border-accent",
changed && "border-accent/60 bg-accent/10",
(id === "disc" || id === "track") && "text-right tabular-nums",
)}
value={v}
onChange={(e) => setRow(s.rel, id, e.target.value)}
/>
</td>
);
})}
<td className="max-w-40 truncate px-2 py-1 mono text-[11px] text-muted" title={s.rel}>
{s.links > 1 && <Link2 className="mr-1 inline size-3 text-emerald-400" />}
{s.rel.split("/").pop()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{shared > 0 && (
<Alert tone="blue" icon={<Link2 className="mt-0.5 size-4 shrink-0" />}>
<div>
{shared} of these songs are <b>hardlinked</b> to the {side === "slave" ? "master" : "slave"} library, so edits show up in both
libraries and the files stay linked.
</div>
<label className="mt-2 flex items-start gap-2 text-xs cursor-pointer">
<input type="checkbox" className="mt-0.5 accent-violet-500" checked={separate} onChange={(e) => setSeparate(e.target.checked)} />
<span>Change only the {side} library's copies instead (breaks the links and uses extra disk space).</span>
</label>
</Alert>
)}
<p className="text-xs text-muted">
Only tags and art change; the audio is verified to be identical. Previous versions go to quarantine, and every change can be undone
from History.
</p>
{dry && <Alert tone="blue">Dry run is on, so the changes are recorded as planned in Activity. No files change.</Alert>}
</div>
)}
<div className="mt-5 flex items-center justify-end gap-2">
{hasChanges && (
<span className="mr-auto text-xs text-muted">
{[
albumChanged.length && `${albumChanged.length} album field${albumChanged.length > 1 ? "s" : ""}`,
rowChanges.length && `${rowChanges.length} song${rowChanges.length > 1 ? "s" : ""} changed`,
art && "new cover",
]
.filter(Boolean)
.join(" · ")}
</span>
)}
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button variant="primary" disabled={!hasChanges || (!!art && !embed && !folderFile && albumChanged.length + rowChanges.length === 0)} loading={save.isPending} onClick={() => save.mutate()}>
Save album
</Button>
</div>
</Modal>
);
}
+229
View File
@@ -0,0 +1,229 @@
import { useEffect, useRef, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { ImageOff, Link2, Search, Upload } from "lucide-react";
import { api, artThumb, type ArtResult, type ArtSettings, type PreparedArt } from "../api";
import { Alert, Badge, Button, Spinner, cx, formatBytes, inputCls } from "../ui";
const SOURCE_LABEL: Record<string, string> = {
itunes: "iTunes",
deezer: "Deezer",
musicbrainz: "MusicBrainz",
duckduckgo: "DuckDuckGo",
google: "Google",
};
/** Shows a cover image, or a placeholder when there is none. */
export function CoverImage({ src, className }: { src?: string | null; className?: string }) {
const [failed, setFailed] = useState(false);
useEffect(() => setFailed(false), [src]);
if (!src || failed)
return (
<div className={cx("grid place-items-center rounded-lg border border-line bg-panel-2 text-muted", className)}>
<ImageOff className="size-6" />
</div>
);
return <img src={src} alt="" className={cx("rounded-lg border border-line object-cover", className)} onError={() => setFailed(true)} />;
}
/**
* Finds a new cover: search the art sources (square images only), paste an
* image address, or upload a file. The chosen image is prepared on the server
* (converted to JPEG, resized) and returned as a token to apply later.
*/
export function ArtPicker({
artist,
album,
onChoose,
}: {
artist: string;
album: string;
onChoose: (p: PreparedArt) => void;
}) {
const settings = useQuery({ queryKey: ["art-settings"], queryFn: () => api.get<{ settings: ArtSettings }>("/art/settings") });
const [q, setQ] = useState({ artist, album, text: "" });
const [free, setFree] = useState(false);
const [sources, setSources] = useState<string[] | null>(null);
const [url, setUrl] = useState("");
const [broken, setBroken] = useState<Set<string>>(new Set());
const fileRef = useRef<HTMLInputElement>(null);
const enabled = settings.data?.settings.sources ?? [];
const googleReady = !!settings.data?.settings.google_key && !!settings.data?.settings.google_cx;
const usable = enabled.filter((s) => s !== "google" || googleReady);
const active = sources ?? usable;
const search = useMutation({
mutationFn: () => {
const p = new URLSearchParams({ sources: active.join(",") });
if (free) p.set("q", q.text);
else {
p.set("artist", q.artist);
p.set("album", q.album);
}
return api.get<{ results: ArtResult[]; errors: Record<string, string> }>(`/art/search?${p}`);
},
});
// Search right away with the album's own tags.
useEffect(() => {
if (settings.data && (artist || album)) search.mutate();
}, [settings.data]); // eslint-disable-line react-hooks/exhaustive-deps
const prepare = useMutation({
mutationFn: (u: string) => api.post<PreparedArt>("/art/prepare", { url: u }),
onSuccess: onChoose,
});
const upload = useMutation({
mutationFn: async (f: File) => {
const form = new FormData();
form.append("image", f);
const res = await fetch("/api/art/upload", { method: "POST", credentials: "same-origin", headers: { "X-Playlink-Request": "1" }, body: form });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error ?? res.statusText);
return data as PreparedArt;
},
onSuccess: onChoose,
});
const busy = prepare.isPending || upload.isPending;
return (
<div className="space-y-3">
<form
className="flex flex-wrap items-end gap-2"
onSubmit={(e) => {
e.preventDefault();
search.mutate();
}}
>
{free ? (
<input className={inputCls + " min-w-0 flex-1"} placeholder="Search text" value={q.text} onChange={(e) => setQ({ ...q, text: e.target.value })} />
) : (
<>
<input className={inputCls + " min-w-0 flex-1"} placeholder="Artist" value={q.artist} onChange={(e) => setQ({ ...q, artist: e.target.value })} />
<input className={inputCls + " min-w-0 flex-1"} placeholder="Album" value={q.album} onChange={(e) => setQ({ ...q, album: e.target.value })} />
</>
)}
<Button type="submit" variant="primary" loading={search.isPending}>
<Search className="size-4" /> Search
</Button>
</form>
<div className="flex flex-wrap items-center gap-1.5 text-xs">
<button type="button" className="mr-2 text-accent hover:underline cursor-pointer" onClick={() => setFree(!free)}>
{free ? "Search by artist and album" : "Free-text search"}
</button>
{enabled.map((s) => {
const disabled = s === "google" && !googleReady;
const on = active.includes(s) && !disabled;
return (
<button
key={s}
type="button"
disabled={disabled}
title={disabled ? "Add a Google API key and search engine id in Settings → Album art" : undefined}
onClick={() => setSources(on ? active.filter((x) => x !== s) : [...active, s])}
className={cx(
"rounded-full border px-2 py-0.5",
on ? "border-accent bg-accent/15 text-white" : "border-line text-muted",
disabled ? "cursor-not-allowed opacity-40" : "cursor-pointer",
)}
>
{SOURCE_LABEL[s] ?? s}
</button>
);
})}
<span className="text-muted">· square images only</span>
</div>
{search.data && Object.keys(search.data.errors).length > 0 && (
<Alert tone="amber">
{Object.entries(search.data.errors).map(([s, e]) => (
<div key={s} className="text-xs">
{SOURCE_LABEL[s] ?? s}: {e}
</div>
))}
</Alert>
)}
{search.isPending && <Spinner />}
{search.data && search.data.results.length === 0 && <div className="py-4 text-center text-sm text-muted">No square covers found. Try other words.</div>}
{search.data && search.data.results.length > 0 && (
<div className="grid max-h-[45vh] grid-cols-3 gap-2 overflow-y-auto sm:grid-cols-4 md:grid-cols-5">
{search.data.results.filter((r) => !broken.has(r.url)).map((r) => (
<button
key={r.url}
type="button"
disabled={busy}
onClick={() => prepare.mutate(r.url)}
title={[r.artist, r.title].filter(Boolean).join(" – ")}
className="group relative overflow-hidden rounded-lg border border-line text-left hover:border-accent cursor-pointer disabled:opacity-50"
>
<img
src={artThumb(r.thumb || r.url)}
alt=""
loading="lazy"
className="aspect-square w-full object-cover"
// Some web images vanish or refuse hotlinking; drop them from the grid.
onError={() => setBroken((b) => new Set(b).add(r.url))}
/>
<div className="absolute inset-x-0 bottom-0 bg-black/70 px-1.5 py-1 text-[10px] leading-tight">
<div className="truncate text-gray-100">{r.title || "untitled"}</div>
<div className="flex justify-between text-gray-400">
<span>{SOURCE_LABEL[r.source] ?? r.source}</span>
<span>
{r.width}×{r.height}
</span>
</div>
</div>
</button>
))}
</div>
)}
<div className="flex flex-wrap items-center gap-2 border-t border-line pt-3">
<div className="relative min-w-0 flex-1">
<Link2 className="absolute left-2.5 top-2.5 size-4 text-muted" />
<input className={inputCls + " pl-8 text-xs"} placeholder="Or paste an image address (https://…)" value={url} onChange={(e) => setUrl(e.target.value)} />
</div>
<Button disabled={!url.trim() || busy} onClick={() => prepare.mutate(url.trim())}>
Use address
</Button>
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) upload.mutate(f);
e.target.value = "";
}}
/>
<Button disabled={busy} onClick={() => fileRef.current?.click()}>
<Upload className="size-4" /> Upload…
</Button>
</div>
{busy && (
<div className="flex items-center gap-2 text-xs text-muted">
<Spinner className="size-3.5" /> Downloading and preparing the image…
</div>
)}
{(prepare.error || upload.error) && <Alert tone="red">{(prepare.error || upload.error)!.message}</Alert>}
</div>
);
}
/** A chosen cover waiting to be saved. */
export function ChosenArt({ art, onClear }: { art: PreparedArt; onClear: () => void }) {
return (
<div className="flex items-center gap-3">
<CoverImage src={`/api/art/prepared/${art.token}`} className="size-20" />
<div className="text-xs">
<div className="font-medium text-gray-100">New cover</div>
<div className="text-muted">
{art.width}×{art.height} · {formatBytes(art.size)} JPEG
</div>
{!art.square && <Badge tone="amber">not square</Badge>}
<button className="mt-1 block text-accent hover:underline cursor-pointer" onClick={onClear}>
Keep the current cover
</button>
</div>
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
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>
);
}
+3
View File
@@ -5,6 +5,7 @@ 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();
@@ -79,6 +80,8 @@ export function SettingsPage() {
</p>
</Card>
<ArtSettingsCard />
<BackupsCard />
<Card className="space-y-2 p-5">
+183 -10
View File
@@ -1,9 +1,11 @@
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 { 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, cx, formatBytes, inputCls } from "../ui";
import { Alert, Badge, Button, Card, Empty, Modal, Spinner, Toggle, cx, formatBytes, inputCls } from "../ui";
import { useToast } from "../toast";
// ---- field layout ----
@@ -33,6 +35,8 @@ export function TagsTab({ pair }: { pair: Pair }) {
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({
@@ -82,9 +86,19 @@ export function TagsTab({ pair }: { pair: Pair }) {
))}
</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 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">
@@ -119,6 +133,9 @@ export function TagsTab({ pair }: { pair: Pair }) {
<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>
</>
) : (
<>
@@ -157,8 +174,8 @@ export function TagsTab({ pair }: { pair: Pair }) {
))}
</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.
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
@@ -169,6 +186,8 @@ export function TagsTab({ pair }: { pair: Pair }) {
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>
);
}
@@ -201,6 +220,8 @@ export function TagEditor({
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);
@@ -220,7 +241,14 @@ export function TagEditor({
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 }),
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] });
@@ -323,6 +351,41 @@ export function TagEditor({
</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>
@@ -355,7 +418,7 @@ export function TagEditor({
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button variant="primary" disabled={changed.length === 0 || editable.length === 0} loading={save.isPending} onClick={() => save.mutate()}>
<Button variant="primary" disabled={(changed.length === 0 && !art) || editable.length === 0} loading={save.isPending} onClick={() => save.mutate()}>
Save tags
</Button>
</div>
@@ -374,3 +437,113 @@ function commonFolder(rels: string[]) {
}
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>
);
}