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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user