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:
1 parent
5fb195f0d6
commit
d8ac25a55d
21 files changed
+2719
-38
No files matched your search
@@ -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}` : ""}`;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user