0f64e3c83a
Add a Select mode on /library: album cards become multi-select, with a bulk
bar to Delete selected (2-step confirm) or Ignore selected. POST
/api/library/bulk { action, ids } applies the op per id and reports ok/failed
counts.
Extracted the delete + ignore logic into lib/library-ops.ts
(removeLibraryItem, ignoreLibraryItem); the single DELETE route now reuses
removeLibraryItem. web 185 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
454 lines
17 KiB
TypeScript
454 lines
17 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { PageHead } from "../_ui/page-head";
|
||
import { SectionHeader } from "../_ui/section-header";
|
||
import { CoverArt } from "../_ui/cover-art";
|
||
import { AlbumModal } from "../_ui/album-modal";
|
||
import { toast } from "../_ui/toast";
|
||
import { ImportAlbum } from "./import-album";
|
||
|
||
type Album = {
|
||
id: string;
|
||
artist: string;
|
||
album: string;
|
||
format: string;
|
||
qualityClass: number;
|
||
importedAt: string;
|
||
rgMbid: string | null;
|
||
year: string | null;
|
||
primaryType: string | null;
|
||
artistMbid: string | null;
|
||
monitoredReleaseId: string | null;
|
||
trackNames: string[];
|
||
};
|
||
|
||
type SortKey = "added" | "release" | "album" | "artist";
|
||
const SORTS: { key: SortKey; label: string; cmp: (a: Album, b: Album) => number }[] = [
|
||
{ key: "added", label: "Recently added", cmp: (a, b) => b.importedAt.localeCompare(a.importedAt) },
|
||
// Release date, newest first; albums with an unknown year sort to the bottom.
|
||
{ key: "release", label: "Release date", cmp: (a, b) => (b.year ?? "0000").localeCompare(a.year ?? "0000") || a.album.localeCompare(b.album) },
|
||
{ key: "album", label: "Album A–Z", cmp: (a, b) => a.album.localeCompare(b.album) },
|
||
{ key: "artist", label: "Artist A–Z", cmp: (a, b) => a.artist.localeCompare(b.artist) || (a.year ?? "").localeCompare(b.year ?? "") },
|
||
];
|
||
|
||
type Unmatched = { id: string; artist: string; album: string; path: string; reason: string };
|
||
type DupItem = { id: string; artist: string; album: string; format: string; qualityClass: number; year: string | null };
|
||
type IntegrityIssue = { id: string; artist: string; album: string; issues: string[] };
|
||
|
||
export function LibraryClient() {
|
||
const [albums, setAlbums] = useState<Album[]>([]);
|
||
const [unmatched, setUnmatched] = useState<Unmatched[]>([]);
|
||
const [duplicates, setDuplicates] = useState<DupItem[][]>([]);
|
||
const [integrity, setIntegrity] = useState<{ checked: number; issues: IntegrityIssue[] } | null>(null);
|
||
const [checking, setChecking] = useState(false);
|
||
const [selectMode, setSelectMode] = useState(false);
|
||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||
const [bulkConfirmDelete, setBulkConfirmDelete] = useState(false);
|
||
|
||
function toggleSelect(id: string) {
|
||
setSelected((s) => {
|
||
const n = new Set(s);
|
||
if (n.has(id)) n.delete(id); else n.add(id);
|
||
return n;
|
||
});
|
||
}
|
||
function exitSelect() {
|
||
setSelectMode(false);
|
||
setSelected(new Set());
|
||
setBulkConfirmDelete(false);
|
||
}
|
||
|
||
async function bulk(action: "delete" | "ignore") {
|
||
const ids = [...selected];
|
||
if (ids.length === 0) return;
|
||
const res = await fetch("/api/library/bulk", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ action, ids }),
|
||
}).catch(() => null);
|
||
if (res?.ok) {
|
||
const body = await res.json();
|
||
toast(
|
||
`${action === "delete" ? "Deleted" : "Ignoring"} ${body.ok} album${body.ok === 1 ? "" : "s"}` +
|
||
(body.failed?.length ? ` · ${body.failed.length} failed` : ""),
|
||
);
|
||
exitSelect();
|
||
refresh();
|
||
} else {
|
||
toast(`Bulk ${action} failed`);
|
||
}
|
||
}
|
||
const [q, setQ] = useState("");
|
||
const [sort, setSort] = useState<SortKey>("added");
|
||
const [open, setOpen] = useState<Album | null>(null);
|
||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||
const [editing, setEditing] = useState(false);
|
||
const [edit, setEdit] = useState({ artist: "", album: "", year: "" });
|
||
const [saving, setSaving] = useState(false);
|
||
|
||
function show(a: Album | null) {
|
||
setOpen(a);
|
||
setConfirmDelete(false);
|
||
setEditing(false);
|
||
}
|
||
|
||
function startEdit() {
|
||
if (!open) return;
|
||
setEdit({ artist: open.artist, album: open.album, year: open.year ?? "" });
|
||
setEditing(true);
|
||
}
|
||
|
||
async function saveEdit() {
|
||
if (!open) return;
|
||
if (!edit.artist.trim() || !edit.album.trim()) {
|
||
toast("Artist and album are required");
|
||
return;
|
||
}
|
||
setSaving(true);
|
||
try {
|
||
const res = await fetch(`/api/library/${open.id}`, {
|
||
method: "PATCH",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ artist: edit.artist.trim(), album: edit.album.trim(), year: edit.year.trim() }),
|
||
}).catch(() => null);
|
||
if (res?.ok) {
|
||
toast(`Updated ${edit.album.trim()}`);
|
||
setEditing(false);
|
||
show(null);
|
||
refresh();
|
||
} else {
|
||
toast((res && (await res.json().catch(() => null))?.error) || "Couldn't update album");
|
||
}
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
async function remove() {
|
||
if (!open) return;
|
||
const target = open;
|
||
const res = await fetch(`/api/library/${target.id}`, { method: "DELETE" }).catch(() => null);
|
||
if (res?.ok) {
|
||
setAlbums((list) => list.filter((a) => a.id !== target.id));
|
||
toast(`Deleted ${target.album}`);
|
||
show(null);
|
||
} else {
|
||
toast(`Couldn't delete ${target.album}`);
|
||
}
|
||
}
|
||
|
||
function refresh() {
|
||
fetch("/api/library")
|
||
.then((r) => r.json())
|
||
.then((d) => setAlbums(d.albums ?? []));
|
||
fetch("/api/library/unmatched")
|
||
.then((r) => r.json())
|
||
.then((d) => setUnmatched(d.unmatched ?? []));
|
||
fetch("/api/library/duplicates")
|
||
.then((r) => r.json())
|
||
.then((d) => setDuplicates(d.duplicates ?? []));
|
||
}
|
||
useEffect(() => {
|
||
refresh();
|
||
}, []);
|
||
|
||
async function upgrade() {
|
||
if (!open) return;
|
||
const res = await fetch(`/api/library/${open.id}/upgrade`, { method: "POST" }).catch(() => null);
|
||
if (res && (res.status === 202 || res.ok)) {
|
||
const body = await res.json().catch(() => null);
|
||
toast(body?.enqueued === false ? `Already searching for ${open.album}` : `Re-acquiring ${open.album} — keeps it only if better`);
|
||
show(null);
|
||
} else {
|
||
toast((res && (await res.json().catch(() => null))?.error) || "Couldn't start the upgrade");
|
||
}
|
||
}
|
||
|
||
async function checkIntegrity() {
|
||
setChecking(true);
|
||
try {
|
||
const res = await fetch("/api/library/integrity").catch(() => null);
|
||
if (res?.ok) {
|
||
setIntegrity(await res.json());
|
||
} else {
|
||
toast("Couldn't run the integrity check");
|
||
}
|
||
} finally {
|
||
setChecking(false);
|
||
}
|
||
}
|
||
|
||
async function dismissUnmatched(u: Unmatched) {
|
||
const res = await fetch(`/api/library/unmatched?id=${u.id}`, { method: "DELETE" }).catch(() => null);
|
||
if (res?.ok) {
|
||
setUnmatched((list) => list.filter((x) => x.id !== u.id));
|
||
} else {
|
||
toast("Couldn't dismiss");
|
||
}
|
||
}
|
||
|
||
const shown = useMemo(() => {
|
||
const needle = q.trim().toLowerCase();
|
||
const filtered = needle
|
||
? albums.filter((a) => `${a.artist} ${a.album}`.toLowerCase().includes(needle))
|
||
: albums;
|
||
const cmp = (SORTS.find((s) => s.key === sort) ?? SORTS[0]).cmp;
|
||
return [...filtered].sort(cmp);
|
||
}, [albums, q, sort]);
|
||
|
||
return (
|
||
<div>
|
||
<PageHead title="Library" eyebrow="The pressings · everything on disk" />
|
||
|
||
<form className="request-form" onSubmit={(e) => e.preventDefault()}>
|
||
<ImportAlbum onImported={refresh} />
|
||
<label className="field">
|
||
<span>Filter</span>
|
||
<input aria-label="filter library" placeholder="Artist or album…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||
</label>
|
||
<label className="field">
|
||
<span>Sort</span>
|
||
<select aria-label="sort library" value={sort} onChange={(e) => setSort(e.target.value as SortKey)}>
|
||
{SORTS.map((s) => (
|
||
<option key={s.key} value={s.key}>{s.label}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
</form>
|
||
|
||
{albums.length === 0 ? (
|
||
<p className="empty">Nothing pressed yet. Scan your library in Settings, or queue a release from The Floor.</p>
|
||
) : (
|
||
<SectionHeader
|
||
title="Pressings"
|
||
note={
|
||
<span className="sel-tools">
|
||
<span>{q ? `${shown.length} of ${albums.length}` : `${albums.length} albums`}</span>
|
||
<button className="btn sm ghost" onClick={() => (selectMode ? exitSelect() : setSelectMode(true))}>
|
||
{selectMode ? "Cancel" : "Select"}
|
||
</button>
|
||
</span>
|
||
}
|
||
/>
|
||
)}
|
||
|
||
{selectMode ? (
|
||
<div className="bulk-bar">
|
||
<span>{selected.size} selected</span>
|
||
{bulkConfirmDelete ? (
|
||
<>
|
||
<span className="rmeta">Delete {selected.size} album{selected.size === 1 ? "" : "s"} and their files?</span>
|
||
<button className="btn sm accent" onClick={() => bulk("delete")} disabled={selected.size === 0}>Delete</button>
|
||
<button className="btn sm ghost" onClick={() => setBulkConfirmDelete(false)}>Cancel</button>
|
||
</>
|
||
) : (
|
||
<>
|
||
<button className="btn sm ghost" onClick={() => setBulkConfirmDelete(true)} disabled={selected.size === 0}>Delete selected</button>
|
||
<button className="btn sm ghost" onClick={() => bulk("ignore")} disabled={selected.size === 0}>Ignore selected</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
|
||
{albums.length > 0 ? (
|
||
<div className="album-grid">
|
||
{shown.map((a) => (
|
||
<button
|
||
key={a.id}
|
||
className={`album-card${selectMode && selected.has(a.id) ? " selected" : ""}`}
|
||
onClick={() => (selectMode ? toggleSelect(a.id) : show(a))}
|
||
aria-label={selectMode ? `Select ${a.album}` : `Open ${a.album}`}
|
||
aria-pressed={selectMode ? selected.has(a.id) : undefined}
|
||
>
|
||
{selectMode ? <span className="sel-check" aria-hidden>{selected.has(a.id) ? "✓" : ""}</span> : null}
|
||
<CoverArt rgMbid={a.rgMbid} alt={a.album} size={250} />
|
||
<div className="ac-title">{a.album}</div>
|
||
<div className="ac-artist">{a.artist}</div>
|
||
<div className="ac-meta">
|
||
{a.format}
|
||
{a.qualityClass >= 3 ? " · hi-res" : ""}
|
||
{a.year ? ` · ${a.year}` : ""}
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
|
||
{albums.length > 0 ? (
|
||
<>
|
||
<SectionHeader
|
||
title="Integrity"
|
||
note={
|
||
<button className="btn sm ghost" onClick={checkIntegrity} disabled={checking}>
|
||
{checking ? "Checking…" : "Check now"}
|
||
</button>
|
||
}
|
||
/>
|
||
{integrity ? (
|
||
integrity.issues.length === 0 ? (
|
||
<p className="muted-note">Checked {integrity.checked} albums — no problems found.</p>
|
||
) : (
|
||
<ul className="list">
|
||
{integrity.issues.map((it) => {
|
||
const album = albums.find((a) => a.id === it.id);
|
||
return (
|
||
<li key={it.id} className="list-row">
|
||
<div className="main">
|
||
{album ? (
|
||
<button className="linkish rtitle" onClick={() => show(album)}>
|
||
{it.artist} — {it.album}
|
||
</button>
|
||
) : (
|
||
<div className="rtitle">{it.artist} — {it.album}</div>
|
||
)}
|
||
<div className="rmeta">
|
||
<span className="score">{it.issues.join(" · ")}</span>
|
||
</div>
|
||
</div>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
)
|
||
) : (
|
||
<p className="muted-note">
|
||
Check every album folder on disk for missing folders, empty/truncated files, and
|
||
track-count drift.
|
||
</p>
|
||
)}
|
||
</>
|
||
) : null}
|
||
|
||
{duplicates.length > 0 ? (
|
||
<>
|
||
<SectionHeader title="Possible duplicates" note={`${duplicates.length}`} />
|
||
<p className="muted-note">
|
||
Albums that look like the same release held more than once (same MusicBrainz release,
|
||
or the same title ignoring edition wording). Open one to edit or delete it.
|
||
</p>
|
||
{duplicates.map((group, gi) => (
|
||
<ul className="list" key={gi} style={{ marginBottom: 10 }}>
|
||
{group.map((d) => {
|
||
const album = albums.find((a) => a.id === d.id);
|
||
return (
|
||
<li key={d.id} className="list-row">
|
||
<div className="main">
|
||
{album ? (
|
||
<button className="linkish rtitle" onClick={() => show(album)}>
|
||
{d.artist} — {d.album}
|
||
</button>
|
||
) : (
|
||
<div className="rtitle">{d.artist} — {d.album}</div>
|
||
)}
|
||
<div className="rmeta">
|
||
<span className="score">
|
||
{d.format}
|
||
{d.qualityClass >= 3 ? " · hi-res" : ""}
|
||
{d.year ? ` · ${d.year}` : ""}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
))}
|
||
</>
|
||
) : null}
|
||
|
||
{unmatched.length > 0 ? (
|
||
<>
|
||
<SectionHeader title="Couldn’t match" note={`${unmatched.length}`} />
|
||
<p className="muted-note">
|
||
Album folders on disk the scan couldn’t resolve to a MusicBrainz release. Fix the
|
||
folder name (Artist / Album (Year)) or re-add them manually, then re-scan. Dismiss to
|
||
hide an entry.
|
||
</p>
|
||
<ul className="list">
|
||
{unmatched.map((u) => (
|
||
<li key={u.id} className="list-row">
|
||
<div className="main">
|
||
<div className="rtitle">
|
||
{u.artist ? `${u.artist} — ` : ""}
|
||
{u.album}
|
||
</div>
|
||
<div className="rmeta">
|
||
<span className="dim">{u.path}</span> <span className="dot">·</span>{" "}
|
||
<span className="score">{u.reason}</span>
|
||
</div>
|
||
</div>
|
||
<div className="actions">
|
||
<button className="btn sm ghost" onClick={() => dismissUnmatched(u)}>
|
||
Dismiss
|
||
</button>
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</>
|
||
) : null}
|
||
|
||
{open ? (
|
||
<AlbumModal
|
||
open
|
||
onClose={() => show(null)}
|
||
album={{ rgMbid: open.rgMbid, album: open.album, artist: open.artist, year: open.year, type: open.primaryType, artistMbid: open.artistMbid, trackNames: open.trackNames }}
|
||
status={<span className="chip done">In library · {open.format}</span>}
|
||
actions={
|
||
editing ? (
|
||
<div className="edit-meta">
|
||
<label className="field">
|
||
<span>Artist</span>
|
||
<input aria-label="edit artist" value={edit.artist} onChange={(e) => setEdit((s) => ({ ...s, artist: e.target.value }))} />
|
||
</label>
|
||
<label className="field">
|
||
<span>Album</span>
|
||
<input aria-label="edit album" value={edit.album} onChange={(e) => setEdit((s) => ({ ...s, album: e.target.value }))} />
|
||
</label>
|
||
<label className="field">
|
||
<span>Year</span>
|
||
<input aria-label="edit year" placeholder="YYYY" value={edit.year} onChange={(e) => setEdit((s) => ({ ...s, year: e.target.value }))} />
|
||
</label>
|
||
<div className="save-row">
|
||
<button className="btn sm accent" onClick={saveEdit} disabled={saving}>
|
||
{saving ? "Saving…" : "Save"}
|
||
</button>
|
||
<button className="btn sm ghost" onClick={() => setEditing(false)} disabled={saving}>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
<p className="muted-note">Renames the folder on disk and re-resolves cover art. Embedded file tags are left as-is.</p>
|
||
</div>
|
||
) : confirmDelete ? (
|
||
<>
|
||
<span className="rmeta">Delete this album and its files from disk?</span>
|
||
<button className="btn sm accent" onClick={remove}>
|
||
Delete
|
||
</button>
|
||
<button className="btn sm ghost" onClick={() => setConfirmDelete(false)}>
|
||
Cancel
|
||
</button>
|
||
</>
|
||
) : (
|
||
<>
|
||
{open.monitoredReleaseId ? (
|
||
<button className="btn sm ghost" onClick={upgrade}>
|
||
Replace / upgrade
|
||
</button>
|
||
) : null}
|
||
<button className="btn sm ghost" onClick={startEdit}>
|
||
Edit metadata
|
||
</button>
|
||
<button className="btn sm ghost" onClick={() => setConfirmDelete(true)}>
|
||
Delete album
|
||
</button>
|
||
</>
|
||
)
|
||
}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|