From 0d3fe8d907a4f8c238b284f0697792a322f9e829 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Tue, 14 Jul 2026 13:45:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20Library=20Management=20=E2=80=94?= =?UTF-8?q?=20delete=20an=20album=20(files=20+=20record)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First Library Management feature. On the Library album modal, "Delete album" (two-step confirm) removes the album: - deletes the folder on disk (guarded to only ever remove a path strictly inside the library root, LYRA_LIBRARY_ROOT, default /music), - drops the LibraryItem, - sets the matching MonitoredRelease monitored=false + currentQualityClass=null so the monitor won't immediately re-download it (re-Want later to restore). Requires the web container to see the library, so docker-compose now bind-mounts MUSIC_DIR at /music (rw) for web too. New DELETE /api/library/[id] (auth-gated, path-guarded). web 157 tests (delete happy-path + 404 + outside-root guard). Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 8 +++ docker-compose.yml | 7 +++ web/src/app/api/library/[id]/route.test.ts | 61 ++++++++++++++++++++++ web/src/app/api/library/[id]/route.ts | 36 +++++++++++++ web/src/app/library/library-client.tsx | 41 ++++++++++++++- 5 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 web/src/app/api/library/[id]/route.test.ts create mode 100644 web/src/app/api/library/[id]/route.ts diff --git a/README.md b/README.md index dc8dfaa..f34fed3 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,14 @@ reach the live DB automatically. Enter Qobuz / Soulseek / Last.fm credentials in **Settings** (they persist across rebuilds), then follow an artist or request an album to feed The Floor. +## Managing the library + +Open an album on the **Library** page and use **Delete album** to remove it: the folder is +deleted from disk and the album is dropped and un-monitored (so auto-monitor won't re-grab +it — re-Want it from the artist page if you change your mind). This is why the `web` service +also bind-mounts `MUSIC_DIR` at `/music` (read-write) — deletion is guarded to only ever +remove folders strictly inside the library root. + ## Backup & restore **All** durable state — library metadata, monitored/wanted lists, discovery data, diff --git a/docker-compose.yml b/docker-compose.yml index 9b65646..28f8bd2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,6 +27,13 @@ services: # Next's standalone server binds to HOSTNAME; default is the container hostname (its own # IP only), so loopback healthchecks can't reach it. Bind all interfaces instead. HOSTNAME: 0.0.0.0 + # Library root inside the container (matches the /music mount below) — used by the + # library-management delete to locate + guard which folders it may remove. + LYRA_LIBRARY_ROOT: /music + volumes: + # Library management (delete albums, and later manual import) needs the web to read/write + # the library, so mount it the same way the worker does. + - ${MUSIC_DIR:-./music}:/music ports: # host:container — host 8770 avoids the Quill app on 3000 (and Lidarr on 8686) - "8770:3000" diff --git a/web/src/app/api/library/[id]/route.test.ts b/web/src/app/api/library/[id]/route.test.ts new file mode 100644 index 0000000..0847fe1 --- /dev/null +++ b/web/src/app/api/library/[id]/route.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, writeFileSync, existsSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { prisma } from "@/lib/db"; +import { DELETE } from "./route"; + +let root: string; +const original = process.env.LYRA_LIBRARY_ROOT; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "lyra-lib-")); + 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 req(id: string) { + return { params: Promise.resolve({ id }) }; +} + +describe("DELETE /api/library/[id]", () => { + it("deletes the folder, the record, and stops monitoring the release", async () => { + const dir = join(root, "Artist", "Album (2020)"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "01 Track.flac"), "audio"); + + const item = await prisma.libraryItem.create({ + data: { artist: "Artist", album: "Album", path: dir, source: "scan", format: "FLAC", qualityClass: 3 }, + }); + const wa = await prisma.watchedArtist.create({ data: { mbid: "a1", name: "Artist" } }); + await prisma.monitoredRelease.create({ + data: { watchedArtistId: wa.id, artistMbid: "a1", artistName: "Artist", rgMbid: "rg1", + album: "Album", secondaryTypes: [], monitored: true, state: "fulfilled", currentQualityClass: 3 }, + }); + + const res = await DELETE(new Request("http://x"), req(item.id)); + expect(res.status).toBe(200); + expect(existsSync(dir)).toBe(false); // files gone + expect(await prisma.libraryItem.findUnique({ where: { id: item.id } })).toBeNull(); + const mr = await prisma.monitoredRelease.findFirst({ where: { rgMbid: "rg1" } }); + expect(mr).toMatchObject({ monitored: false, currentQualityClass: null }); // won't be re-grabbed + }); + + it("404s for an unknown id", async () => { + expect((await DELETE(new Request("http://x"), req("nope"))).status).toBe(404); + }); + + it("refuses to delete a path outside the library root", async () => { + const item = await prisma.libraryItem.create({ + data: { artist: "X", album: "Y", path: "/etc", source: "scan", format: "FLAC", qualityClass: 1 }, + }); + const res = await DELETE(new Request("http://x"), req(item.id)); + expect(res.status).toBe(400); + // the record is preserved when the path guard trips + expect(await prisma.libraryItem.findUnique({ where: { id: item.id } })).not.toBeNull(); + }); +}); diff --git a/web/src/app/api/library/[id]/route.ts b/web/src/app/api/library/[id]/route.ts new file mode 100644 index 0000000..90fbed6 --- /dev/null +++ b/web/src/app/api/library/[id]/route.ts @@ -0,0 +1,36 @@ +import { rm } from "node:fs/promises"; +import { resolve, sep } from "node:path"; +import { prisma } from "@/lib/db"; + +// 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 }); + } 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 }); +} diff --git a/web/src/app/library/library-client.tsx b/web/src/app/library/library-client.tsx index 3e4e662..6761810 100644 --- a/web/src/app/library/library-client.tsx +++ b/web/src/app/library/library-client.tsx @@ -5,6 +5,7 @@ import { PageHead } from "../_ui/page-head"; import { SectionHeader } from "../_ui/section-header"; import { CoverArt } from "../_ui/cover-art"; import { AlbumModal } from "../_ui/album-modal"; +import { toast } from "../_ui/toast"; type Album = { id: string; @@ -23,6 +24,25 @@ export function LibraryClient() { const [albums, setAlbums] = useState([]); const [q, setQ] = useState(""); const [open, setOpen] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(false); + + function show(a: Album | null) { + setOpen(a); + setConfirmDelete(false); + } + + async function remove() { + if (!open) return; + const target = open; + const res = await fetch(`/api/library/${target.id}`, { method: "DELETE" }).catch(() => null); + if (res?.ok) { + setAlbums((list) => list.filter((a) => a.id !== target.id)); + toast(`Deleted ${target.album}`); + show(null); + } else { + toast(`Couldn't delete ${target.album}`); + } + } useEffect(() => { fetch("/api/library") @@ -58,7 +78,7 @@ export function LibraryClient() { {albums.length > 0 ? (
{shown.map((a) => ( - + + + ) : ( + + ) + } /> ) : null}