From 0f64e3c83aaa085f8899d50b7781d7d38425c2e0 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Tue, 14 Jul 2026 21:37:01 +0200 Subject: [PATCH] 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) --- web/src/app/api/library/[id]/route.ts | 37 ++--------- web/src/app/api/library/bulk/route.test.ts | 54 ++++++++++++++++ web/src/app/api/library/bulk/route.ts | 36 +++++++++++ web/src/app/design.css | 6 ++ web/src/app/library/library-client.tsx | 74 +++++++++++++++++++++- web/src/lib/library-ops.ts | 50 +++++++++++++++ 6 files changed, 224 insertions(+), 33 deletions(-) create mode 100644 web/src/app/api/library/bulk/route.test.ts create mode 100644 web/src/app/api/library/bulk/route.ts create mode 100644 web/src/lib/library-ops.ts diff --git a/web/src/app/api/library/[id]/route.ts b/web/src/app/api/library/[id]/route.ts index e87f781..177019e 100644 --- a/web/src/app/api/library/[id]/route.ts +++ b/web/src/app/api/library/[id]/route.ts @@ -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 }); } diff --git a/web/src/app/api/library/bulk/route.test.ts b/web/src/app/api/library/bulk/route.test.ts new file mode 100644 index 0000000..a24fc9e --- /dev/null +++ b/web/src/app/api/library/bulk/route.test.ts @@ -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); + }); +}); diff --git a/web/src/app/api/library/bulk/route.ts b/web/src/app/api/library/bulk/route.ts new file mode 100644 index 0000000..5c5eba0 --- /dev/null +++ b/web/src/app/api/library/bulk/route.ts @@ -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 Promise> = { + 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 }); +} diff --git a/web/src/app/design.css b/web/src/app/design.css index eace3ff..8487efc 100644 --- a/web/src/app/design.css +++ b/web/src/app/design.css @@ -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; } diff --git a/web/src/app/library/library-client.tsx b/web/src/app/library/library-client.tsx index 82696f0..f299e20 100644 --- a/web/src/app/library/library-client.tsx +++ b/web/src/app/library/library-client.tsx @@ -42,6 +42,43 @@ export function LibraryClient() { const [duplicates, setDuplicates] = useState([]); const [integrity, setIntegrity] = useState<{ checked: number; issues: IntegrityIssue[] } | null>(null); const [checking, setChecking] = useState(false); + const [selectMode, setSelectMode] = useState(false); + const [selected, setSelected] = useState>(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("added"); const [open, setOpen] = useState(null); @@ -185,13 +222,46 @@ export function LibraryClient() { ) : ( + {q ? `${shown.length} of ${albums.length}` : `${albums.length} albums`} + + + } /> )} + + {selectMode ? ( +
+ {selected.size} selected + {bulkConfirmDelete ? ( + <> + Delete {selected.size} album{selected.size === 1 ? "" : "s"} and their files? + + + + ) : ( + <> + + + + )} +
+ ) : null} + {albums.length > 0 ? (
{shown.map((a) => ( -