feat(web): Library Management — delete an album (files + record)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
**Settings** (they persist across rebuilds), then follow an artist or request an album
|
||||||
to feed The Floor.
|
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
|
## Backup & restore
|
||||||
|
|
||||||
**All** durable state — library metadata, monitored/wanted lists, discovery data,
|
**All** durable state — library metadata, monitored/wanted lists, discovery data,
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ services:
|
|||||||
# Next's standalone server binds to HOSTNAME; default is the container hostname (its own
|
# 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.
|
# IP only), so loopback healthchecks can't reach it. Bind all interfaces instead.
|
||||||
HOSTNAME: 0.0.0.0
|
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:
|
ports:
|
||||||
# host:container — host 8770 avoids the Quill app on 3000 (and Lidarr on 8686)
|
# host:container — host 8770 avoids the Quill app on 3000 (and Lidarr on 8686)
|
||||||
- "8770:3000"
|
- "8770:3000"
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { PageHead } from "../_ui/page-head";
|
|||||||
import { SectionHeader } from "../_ui/section-header";
|
import { SectionHeader } from "../_ui/section-header";
|
||||||
import { CoverArt } from "../_ui/cover-art";
|
import { CoverArt } from "../_ui/cover-art";
|
||||||
import { AlbumModal } from "../_ui/album-modal";
|
import { AlbumModal } from "../_ui/album-modal";
|
||||||
|
import { toast } from "../_ui/toast";
|
||||||
|
|
||||||
type Album = {
|
type Album = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -23,6 +24,25 @@ export function LibraryClient() {
|
|||||||
const [albums, setAlbums] = useState<Album[]>([]);
|
const [albums, setAlbums] = useState<Album[]>([]);
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [open, setOpen] = useState<Album | null>(null);
|
const [open, setOpen] = useState<Album | null>(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(() => {
|
useEffect(() => {
|
||||||
fetch("/api/library")
|
fetch("/api/library")
|
||||||
@@ -58,7 +78,7 @@ export function LibraryClient() {
|
|||||||
{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={() => setOpen(a)} aria-label={`Open ${a.album}`}>
|
<button key={a.id} className="album-card" onClick={() => show(a)} aria-label={`Open ${a.album}`}>
|
||||||
<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>
|
||||||
@@ -75,9 +95,26 @@ export function LibraryClient() {
|
|||||||
{open ? (
|
{open ? (
|
||||||
<AlbumModal
|
<AlbumModal
|
||||||
open
|
open
|
||||||
onClose={() => setOpen(null)}
|
onClose={() => show(null)}
|
||||||
album={{ rgMbid: open.rgMbid, album: open.album, artist: open.artist, year: open.year, type: open.primaryType, artistMbid: open.artistMbid, trackNames: open.trackNames }}
|
album={{ rgMbid: open.rgMbid, album: open.album, artist: open.artist, year: open.year, type: open.primaryType, artistMbid: open.artistMbid, trackNames: open.trackNames }}
|
||||||
status={<span className="chip done">In library · {open.format}</span>}
|
status={<span className="chip done">In library · {open.format}</span>}
|
||||||
|
actions={
|
||||||
|
confirmDelete ? (
|
||||||
|
<>
|
||||||
|
<span className="rmeta">Delete this album and its files from disk?</span>
|
||||||
|
<button className="btn sm accent" onClick={remove}>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<button className="btn sm ghost" onClick={() => setConfirmDelete(false)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button className="btn sm ghost" onClick={() => setConfirmDelete(true)}>
|
||||||
|
Delete album
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user