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:
Jonathan
2026-07-14 13:45:57 +02:00
parent 426b36775f
commit 0d3fe8d907
5 changed files with 151 additions and 2 deletions
+39 -2
View File
@@ -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<Album[]>([]);
const [q, setQ] = useState("");
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(() => {
fetch("/api/library")
@@ -58,7 +78,7 @@ export function LibraryClient() {
{albums.length > 0 ? (
<div className="album-grid">
{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} />
<div className="ac-title">{a.album}</div>
<div className="ac-artist">{a.artist}</div>
@@ -75,9 +95,26 @@ export function LibraryClient() {
{open ? (
<AlbumModal
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 }}
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}
</div>