Files
Lyra/web/src/app/library/library-client.tsx
T
Jonathan 0f64e3c83a feat(library): bulk operations (delete / ignore)
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>
2026-07-14 21:37:01 +02:00

454 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 AZ", cmp: (a, b) => a.album.localeCompare(b.album) },
{ key: "artist", label: "Artist AZ", 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="Couldnt match" note={`${unmatched.length}`} />
<p className="muted-note">
Album folders on disk the scan couldnt 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>
);
}