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 { 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 { dirname, resolve, sep } from "node:path";
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { albumDir, yearFromPath } from "@/lib/library-path";
|
import { albumDir, yearFromPath } from "@/lib/library-path";
|
||||||
import { searchReleaseGroup } from "@/lib/musicbrainz";
|
import { searchReleaseGroup } from "@/lib/musicbrainz";
|
||||||
|
import { removeLibraryItem } from "@/lib/library-ops";
|
||||||
|
|
||||||
function insideRoot(root: string, target: string): boolean {
|
function insideRoot(root: string, target: string): boolean {
|
||||||
return target !== root && target.startsWith(root + sep);
|
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
|
// 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.
|
// 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 }> }) {
|
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const item = await prisma.libraryItem.findUnique({ where: { id } });
|
const r = await removeLibraryItem(id);
|
||||||
if (!item) return Response.json({ error: "not found" }, { status: 404 });
|
return r.ok ? Response.json({ ok: true }) : Response.json({ error: r.error }, { status: r.status });
|
||||||
|
|
||||||
// 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 });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
.save-row { display: flex; align-items: center; gap: 14px; margin-top: 8px; }
|
||||||
.edit-meta { display: flex; flex-direction: column; gap: 8px; width: 100%; }
|
.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; }
|
.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 { 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; }
|
.help b { color: var(--ink); font-weight: 600; }
|
||||||
|
|||||||
@@ -42,6 +42,43 @@ export function LibraryClient() {
|
|||||||
const [duplicates, setDuplicates] = useState<DupItem[][]>([]);
|
const [duplicates, setDuplicates] = useState<DupItem[][]>([]);
|
||||||
const [integrity, setIntegrity] = useState<{ checked: number; issues: IntegrityIssue[] } | null>(null);
|
const [integrity, setIntegrity] = useState<{ checked: number; issues: IntegrityIssue[] } | null>(null);
|
||||||
const [checking, setChecking] = useState(false);
|
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 [q, setQ] = useState("");
|
||||||
const [sort, setSort] = useState<SortKey>("added");
|
const [sort, setSort] = useState<SortKey>("added");
|
||||||
const [open, setOpen] = useState<Album | null>(null);
|
const [open, setOpen] = useState<Album | null>(null);
|
||||||
@@ -185,13 +222,46 @@ export function LibraryClient() {
|
|||||||
) : (
|
) : (
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Pressings"
|
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 ? (
|
{albums.length > 0 ? (
|
||||||
<div className="album-grid">
|
<div className="album-grid">
|
||||||
{shown.map((a) => (
|
{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} />
|
<CoverArt rgMbid={a.rgMbid} alt={a.album} size={250} />
|
||||||
<div className="ac-title">{a.album}</div>
|
<div className="ac-title">{a.album}</div>
|
||||||
<div className="ac-artist">{a.artist}</div>
|
<div className="ac-artist">{a.artist}</div>
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { rm, rmdir } from "node:fs/promises";
|
||||||
|
import { dirname, resolve, sep } from "node:path";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
export type OpResult = { ok: boolean; status: number; error?: string };
|
||||||
|
|
||||||
|
/** Delete an owned album: remove its folder on disk (guarded to inside the library root),
|
||||||
|
* drop the LibraryItem, and stop monitoring the matching release so auto-monitor won't
|
||||||
|
* immediately re-grab it. Shared by the single DELETE route and bulk operations. */
|
||||||
|
export async function removeLibraryItem(id: string): Promise<OpResult> {
|
||||||
|
const item = await prisma.libraryItem.findUnique({ where: { id } });
|
||||||
|
if (!item) return { ok: false, status: 404, error: "not found" };
|
||||||
|
|
||||||
|
const root = resolve(process.env.LYRA_LIBRARY_ROOT ?? "/music");
|
||||||
|
const target = resolve(item.path);
|
||||||
|
if (target === root || !target.startsWith(root + sep)) {
|
||||||
|
return { ok: false, status: 400, error: "refusing to delete a path outside the library" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await rm(target, { recursive: true, force: true });
|
||||||
|
const parent = dirname(target);
|
||||||
|
if (parent !== root) await rmdir(parent).catch(() => {}); // prune emptied artist folder
|
||||||
|
} catch {
|
||||||
|
return { ok: false, status: 500, error: "failed to delete files on disk" };
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.libraryItem.delete({ where: { id } }),
|
||||||
|
prisma.monitoredRelease.updateMany({
|
||||||
|
where: { artistName: item.artist, album: item.album },
|
||||||
|
data: { monitored: false, currentQualityClass: null },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return { ok: true, status: 200 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mark the release(s) matching an owned album do-not-monitor, so it drops off Wanted and is
|
||||||
|
* never enqueued. No-op (still ok) if the album has no matching release. */
|
||||||
|
export async function ignoreLibraryItem(id: string): Promise<OpResult> {
|
||||||
|
const item = await prisma.libraryItem.findUnique({ where: { id } });
|
||||||
|
if (!item) return { ok: false, status: 404, error: "not found" };
|
||||||
|
await prisma.monitoredRelease.updateMany({
|
||||||
|
where: item.rgMbid
|
||||||
|
? { rgMbid: item.rgMbid }
|
||||||
|
: { artistName: item.artist, album: item.album },
|
||||||
|
data: { ignored: true },
|
||||||
|
});
|
||||||
|
return { ok: true, status: 200 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user