feat(web): Library Management — manual album import (drag-and-drop)

Second Library Management feature. An "Add album" button on the Library page
opens a modal: drag-drop (or pick) the audio files (+ optional cover image),
enter artist/album/year, and Lyra writes the album into the library.

- POST /api/library/import (auth-gated, multipart): sanitizes the folder name
  like the worker's safe_name, assembles into a sibling ".importing" temp dir
  then renames into place (never a half-written album), writes a cover.jpg from
  any image, and records a source="manual" LibraryItem with the on-disk
  trackNames + a rough quality class from the extension (a later Scan re-probes /
  MB-resolves for cover art + hi-res detection). Guards: 400 (missing
  artist/album or no audio), 409 (folder exists / already in library).
- Library page: ImportAlbum drop-zone modal wired to refresh the grid.

web 161 tests (write-to-disk + trackNames + guards + filename sanitization).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Also: DELETE now prunes an emptied artist folder (best-effort).
This commit is contained in:
Jonathan
2026-07-14 14:00:35 +02:00
parent 0d3fe8d907
commit 93ba9c7e16
6 changed files with 299 additions and 3 deletions
+5 -2
View File
@@ -1,5 +1,5 @@
import { rm } from "node:fs/promises";
import { resolve, sep } from "node:path";
import { rm, rmdir } from "node:fs/promises";
import { dirname, 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
@@ -18,6 +18,9 @@ export async function DELETE(_request: Request, { params }: { params: Promise<{
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 });
}