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 });
}