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>
This commit is contained in:
Jonathan
2026-07-14 21:37:01 +02:00
parent 8c8a34e320
commit 0f64e3c83a
6 changed files with 224 additions and 33 deletions
+6 -31
View File
@@ -1,10 +1,11 @@
import { existsSync } from "node:fs";
import { mkdir, rename, rm, rmdir } from "node:fs/promises";
import { mkdir, rename, rmdir } from "node:fs/promises";
import { dirname, resolve, sep } from "node:path";
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/db";
import { albumDir, yearFromPath } from "@/lib/library-path";
import { searchReleaseGroup } from "@/lib/musicbrainz";
import { removeLibraryItem } from "@/lib/library-ops";
function insideRoot(root: string, target: string): boolean {
return target !== root && target.startsWith(root + sep);
@@ -99,38 +100,12 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
}
}
// DELETE /api/library/[id] — remove an owned album: delete its folder on disk, drop the
// LibraryItem, and stop monitoring the matching release so the monitor doesn't re-grab it.
// DELETE /api/library/[id] — remove an owned album: delete its folder on disk, drop the
// LibraryItem, and stop monitoring the matching release so the monitor doesn't re-grab it.
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const item = await prisma.libraryItem.findUnique({ where: { id } });
if (!item) return Response.json({ error: "not found" }, { status: 404 });
// Guard: only ever delete a folder strictly inside the library root.
const root = resolve(process.env.LYRA_LIBRARY_ROOT ?? "/music");
const target = resolve(item.path);
if (target === root || !target.startsWith(root + sep)) {
return Response.json({ error: "refusing to delete a path outside the library" }, { status: 400 });
}
try {
await rm(target, { recursive: true, force: true });
// prune the now-possibly-empty artist folder (best-effort; rmdir fails if it has other albums)
const parent = dirname(target);
if (parent !== root) await rmdir(parent).catch(() => {});
} catch {
return Response.json({ error: "failed to delete files on disk" }, { status: 500 });
}
// Drop the record + stop monitoring so auto-monitor won't immediately re-download it. The
// release stays in the artist's discography (dormant) and can be re-wanted later.
await prisma.$transaction([
prisma.libraryItem.delete({ where: { id } }),
prisma.monitoredRelease.updateMany({
where: { artistName: item.artist, album: item.album },
data: { monitored: false, currentQualityClass: null },
}),
]);
return Response.json({ ok: true });
const r = await removeLibraryItem(id);
return r.ok ? Response.json({ ok: true }) : Response.json({ error: r.error }, { status: r.status });
}
@@ -0,0 +1,54 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { prisma } from "@/lib/db";
import { POST } from "./route";
let root: string;
const original = process.env.LYRA_LIBRARY_ROOT;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "lyra-bulk-"));
process.env.LYRA_LIBRARY_ROOT = root;
});
afterEach(() => {
if (original === undefined) delete process.env.LYRA_LIBRARY_ROOT;
else process.env.LYRA_LIBRARY_ROOT = original;
rmSync(root, { recursive: true, force: true });
});
function post(body: unknown) {
return POST(new Request("http://x", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }));
}
describe("POST /api/library/bulk", () => {
it("deletes several albums and their folders", async () => {
const ids: string[] = [];
for (const [artist, album] of [["A", "One"], ["B", "Two"]]) {
const dir = join(root, artist, album);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "01.flac"), "x");
ids.push((await prisma.libraryItem.create({ data: { artist, album, path: dir, source: "scan", format: "FLAC", qualityClass: 2 } })).id);
}
const res = await post({ action: "delete", ids });
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({ action: "delete", ok: 2, failed: [] });
expect(await prisma.libraryItem.count()).toBe(0);
expect(existsSync(join(root, "A"))).toBe(false);
});
it("ignores the matching releases for the selected albums", async () => {
const mr = await prisma.monitoredRelease.create({
data: { artistMbid: "a1", artistName: "Adele", rgMbid: "rg-21", album: "21", secondaryTypes: [], monitored: true, state: "wanted" },
});
const li = await prisma.libraryItem.create({ data: { artist: "Adele", album: "21", path: join(root, "Adele", "21"), source: "scan", format: "FLAC", qualityClass: 2, rgMbid: "rg-21" } });
const res = await post({ action: "ignore", ids: [li.id] });
expect((await res.json()).ok).toBe(1);
expect((await prisma.monitoredRelease.findUnique({ where: { id: mr.id } }))!.ignored).toBe(true);
});
it("rejects a bad action or empty ids", async () => {
expect((await post({ action: "nuke", ids: ["x"] })).status).toBe(400);
expect((await post({ action: "delete", ids: [] })).status).toBe(400);
});
});
+36
View File
@@ -0,0 +1,36 @@
import { removeLibraryItem, ignoreLibraryItem, type OpResult } from "@/lib/library-ops";
// POST /api/library/bulk — apply an action to several owned albums at once.
// Body: { action: "delete" | "ignore", ids: string[] }. Returns per-id success counts.
const ACTIONS: Record<string, (id: string) => Promise<OpResult>> = {
delete: removeLibraryItem,
ignore: ignoreLibraryItem,
};
export async function POST(request: Request) {
let body: unknown;
try {
body = await request.json();
} catch {
return Response.json({ error: "invalid JSON" }, { status: 400 });
}
const { action, ids } = (body ?? {}) as { action?: unknown; ids?: unknown };
if (typeof action !== "string" || !(action in ACTIONS)) {
return Response.json({ error: "action must be 'delete' or 'ignore'" }, { status: 400 });
}
if (!Array.isArray(ids) || ids.length === 0 || !ids.every((x) => typeof x === "string")) {
return Response.json({ error: "ids must be a non-empty string array" }, { status: 400 });
}
const op = ACTIONS[action];
// Run sequentially — deletes touch the filesystem and prune shared artist folders; serial
// keeps that predictable and avoids hammering the mount.
let ok = 0;
const failed: string[] = [];
for (const id of ids as string[]) {
const r = await op(id);
if (r.ok) ok++;
else failed.push(id);
}
return Response.json({ action, ok, failed });
}
+6
View File
@@ -285,6 +285,12 @@ nav.contents .sep { flex: 1; }
}
.save-row { display: flex; align-items: center; gap: 14px; margin-top: 8px; }
.edit-meta { display: flex; flex-direction: column; gap: 8px; width: 100%; }
.sel-tools { display: inline-flex; align-items: center; gap: 10px; }
.bulk-bar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin: 4px 0 12px; padding: 8px 12px; border: 1px solid var(--rule); background: var(--paper-2); }
.album-card { position: relative; }
.album-card.selected { outline: 2px solid var(--accent); outline-offset: 2px; }
.sel-check { position: absolute; top: 6px; left: 6px; z-index: 1; width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; background: var(--accent); color: var(--paper); font-weight: 700; border: 1px solid var(--rule); }
.album-card:not(.selected) .sel-check { background: var(--paper-2); color: transparent; }
.settings-section { margin-bottom: 30px; }
.help { font-size: 0.8rem; color: var(--graphite); line-height: 1.5; margin: 4px 0 14px; max-width: 62ch; }
.help b { color: var(--ink); font-weight: 600; }
+72 -2
View File
@@ -42,6 +42,43 @@ export function LibraryClient() {
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);
@@ -185,13 +222,46 @@ export function LibraryClient() {
) : (
<SectionHeader
title="Pressings"
note={q ? `${shown.length} of ${albums.length} albums` : `${albums.length} albums`}
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" onClick={() => show(a)} aria-label={`Open ${a.album}`}>
<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>