From 93ba9c7e16838f51b964f5b80e8689c32e0287d7 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Tue, 14 Jul 2026 14:00:35 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20Library=20Management=20=E2=80=94?= =?UTF-8?q?=20manual=20album=20import=20(drag-and-drop)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Also: DELETE now prunes an emptied artist folder (best-effort). --- web/src/app/api/library/[id]/route.ts | 7 +- web/src/app/api/library/import/route.test.ts | 64 +++++++++ web/src/app/api/library/import/route.ts | 86 ++++++++++++ web/src/app/design.css | 6 + web/src/app/library/import-album.tsx | 132 +++++++++++++++++++ web/src/app/library/library-client.tsx | 7 +- 6 files changed, 299 insertions(+), 3 deletions(-) create mode 100644 web/src/app/api/library/import/route.test.ts create mode 100644 web/src/app/api/library/import/route.ts create mode 100644 web/src/app/library/import-album.tsx diff --git a/web/src/app/api/library/[id]/route.ts b/web/src/app/api/library/[id]/route.ts index 90fbed6..ffa85b7 100644 --- a/web/src/app/api/library/[id]/route.ts +++ b/web/src/app/api/library/[id]/route.ts @@ -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 }); } diff --git a/web/src/app/api/library/import/route.test.ts b/web/src/app/api/library/import/route.test.ts new file mode 100644 index 0000000..a54a8db --- /dev/null +++ b/web/src/app/api/library/import/route.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, existsSync, readFileSync, readdirSync, 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-imp-")); + 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(fields: Record, files: [string, string][]) { + const fd = new FormData(); + for (const [k, v] of Object.entries(fields)) fd.set(k, v); + for (const [name, content] of files) fd.append("files", new File([content], name)); + return new Request("http://x", { method: "POST", body: fd }); +} + +describe("POST /api/library/import", () => { + it("writes the album to disk and records a LibraryItem with on-disk tracks", async () => { + const res = await POST(req( + { artist: "Portishead", album: "Dummy", year: "1994" }, + [["02 Sour Times.flac", "b"], ["01 Mysterons.flac", "a"], ["cover.jpg", "img"]], + )); + expect(res.status).toBe(201); + expect((await res.json()).tracks).toBe(2); + + const dir = join(root, "Portishead", "Dummy (1994)"); + expect(readdirSync(dir).sort()).toEqual(["01 Mysterons.flac", "02 Sour Times.flac", "cover.jpg"]); + expect(readFileSync(join(dir, "01 Mysterons.flac"), "utf8")).toBe("a"); + expect(existsSync(dir + ".importing")).toBe(false); // temp dir renamed away + + const item = await prisma.libraryItem.findFirst({ where: { artist: "Portishead", album: "Dummy" } }); + expect(item).toMatchObject({ source: "manual", format: "FLAC", qualityClass: 2 }); + expect(item!.trackNames).toEqual(["01 Mysterons.flac", "02 Sour Times.flac"]); // sorted + }); + + it("400s without artist/album or without audio", async () => { + expect((await POST(req({ album: "X" }, [["01.flac", "a"]]))).status).toBe(400); + expect((await POST(req({ artist: "A", album: "X" }, [["notes.txt", "a"]]))).status).toBe(400); + }); + + it("409s when the album is already in the library", async () => { + await prisma.libraryItem.create({ + data: { artist: "A", album: "X", path: join(root, "A", "X"), source: "scan", format: "FLAC", qualityClass: 2 }, + }); + expect((await POST(req({ artist: "A", album: "X" }, [["01.flac", "a"]]))).status).toBe(409); + }); + + it("sanitizes filesystem-illegal characters in the folder name", async () => { + const res = await POST(req({ artist: "AC/DC", album: 'Back in "Black"' }, [["01.flac", "a"]])); + expect(res.status).toBe(201); + expect(existsSync(join(root, "AC_DC", 'Back in _Black_'))).toBe(true); + }); +}); diff --git a/web/src/app/api/library/import/route.ts b/web/src/app/api/library/import/route.ts new file mode 100644 index 0000000..0b07ef3 --- /dev/null +++ b/web/src/app/api/library/import/route.ts @@ -0,0 +1,86 @@ +import { mkdir, rename, writeFile, rm } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { resolve, join, extname } from "node:path"; +import { prisma } from "@/lib/db"; + +// Manually import an album (a ripped CD / files already on the PC): upload the audio (+ an +// optional cover) with artist/album/year, and Lyra writes it into the library. The web +// container bind-mounts /music, so it can write directly. A later library Scan will +// MB-resolve it (cover art, canonical types) and re-probe quality. + +const AUDIO_EXT = new Set([".flac", ".mp3", ".m4a", ".opus", ".ogg", ".aac", ".wav"]); +const LOSSLESS_EXT = new Set([".flac", ".wav"]); +// filesystem-illegal path chars → "_" (matches the worker's safe_name; control chars can't +// reach here through a form field, so the range is omitted). +const ILLEGAL = /[<>:"/\\|?*]/g; + +function safeName(s: string): string { + const cleaned = s.replace(ILLEGAL, "_").trim().replace(/^\.+|\.+$/g, "").trim(); + return cleaned || "Unknown"; +} + +export async function POST(request: Request) { + let form: FormData; + try { + form = await request.formData(); + } catch { + return Response.json({ error: "expected multipart form data" }, { status: 400 }); + } + + const artist = String(form.get("artist") ?? "").trim(); + const album = String(form.get("album") ?? "").trim(); + const year = String(form.get("year") ?? "").trim(); + if (!artist || !album) return Response.json({ error: "artist and album are required" }, { status: 400 }); + if (year && !/^\d{4}$/.test(year)) return Response.json({ error: "year must be 4 digits" }, { status: 400 }); + + const files = form.getAll("files").filter((f): f is File => f instanceof File); + const audio = files + .filter((f) => AUDIO_EXT.has(extname(f.name).toLowerCase())) + .sort((a, b) => a.name.localeCompare(b.name)); + if (audio.length === 0) return Response.json({ error: "no audio files provided" }, { status: 400 }); + const cover = files.find((f) => /\.(jpe?g|png)$/i.test(f.name)); + + const root = resolve(process.env.LYRA_LIBRARY_ROOT ?? "/music"); + const albumFolder = year ? `${safeName(album)} (${year})` : safeName(album); + const finalDir = join(root, safeName(artist), albumFolder); + + if (existsSync(finalDir)) { + return Response.json({ error: "a folder for that album already exists on disk" }, { status: 409 }); + } + if (await prisma.libraryItem.findFirst({ where: { artist, album } })) { + return Response.json({ error: "this album is already in the library" }, { status: 409 }); + } + + // Assemble in a sibling temp dir then rename into place, so a failed/partial upload never + // leaves a half-written album in the library. + const tmpDir = finalDir + ".importing"; + await rm(tmpDir, { recursive: true, force: true }); + await mkdir(tmpDir, { recursive: true }); + try { + for (const f of audio) { + await writeFile(join(tmpDir, safeName(f.name)), Buffer.from(await f.arrayBuffer())); + } + if (cover) await writeFile(join(tmpDir, "cover.jpg"), Buffer.from(await cover.arrayBuffer())); + await rename(tmpDir, finalDir); + } catch { + await rm(tmpDir, { recursive: true, force: true }); + return Response.json({ error: "failed to write the album to disk" }, { status: 500 }); + } + + const firstExt = extname(audio[0].name).toLowerCase(); + await prisma.libraryItem.create({ + data: { + artist, + album, + path: finalDir, + source: "manual", + format: firstExt.replace(".", "").toUpperCase(), + // rough class from the extension (2 = CD-lossless, 1 = lossy); a later scan re-probes and + // can upgrade a hi-res file to class 3. + qualityClass: LOSSLESS_EXT.has(firstExt) ? 2 : 1, + trackNames: audio.map((f) => safeName(f.name)), + }, + }); + + return Response.json({ ok: true, tracks: audio.length }, { status: 201 }); +} diff --git a/web/src/app/design.css b/web/src/app/design.css index 2583061..23a40e0 100644 --- a/web/src/app/design.css +++ b/web/src/app/design.css @@ -204,6 +204,12 @@ nav.contents .sep { flex: 1; } font-family: var(--mono); font-size: 0.62rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--graphite); margin: 0 0 10px; } +.import-form { display: flex; flex-direction: column; gap: 6px; } +.dropzone { + border: 1.5px dashed var(--rule-2); padding: 28px 20px; text-align: center; cursor: pointer; + font-family: var(--mono); font-size: 0.8rem; color: var(--graphite); margin-bottom: 8px; +} +.dropzone:hover, .dropzone.over { border-color: var(--accent); color: var(--accent); } /* ── Phase 2: page headers, list rows, tools ──────────── */ .page-head { margin: 6px 0 26px; } diff --git a/web/src/app/library/import-album.tsx b/web/src/app/library/import-album.tsx new file mode 100644 index 0000000..6234722 --- /dev/null +++ b/web/src/app/library/import-album.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useRef, useState } from "react"; +import { Modal } from "../_ui/modal"; +import { toast } from "../_ui/toast"; + +const AUDIO_RE = /\.(flac|mp3|m4a|opus|ogg|aac|wav)$/i; + +/** Manually add an album (ripped CD / local files): drag-drop the audio + fill in + * artist/album/year, POST to /api/library/import. */ +export function ImportAlbum({ onImported }: { onImported: () => void }) { + const [open, setOpen] = useState(false); + const [files, setFiles] = useState([]); + const [artist, setArtist] = useState(""); + const [album, setAlbum] = useState(""); + const [year, setYear] = useState(""); + const [busy, setBusy] = useState(false); + const [drag, setDrag] = useState(false); + const inputRef = useRef(null); + + const audioCount = files.filter((f) => AUDIO_RE.test(f.name)).length; + const ready = !!artist.trim() && !!album.trim() && audioCount > 0; + + function reset() { + setFiles([]); + setArtist(""); + setAlbum(""); + setYear(""); + } + function close() { + setOpen(false); + reset(); + } + function addFiles(list: FileList | null) { + if (list) setFiles((prev) => [...prev, ...Array.from(list)]); + } + + async function submit(e: React.FormEvent) { + e.preventDefault(); + if (!ready) return; + setBusy(true); + try { + const fd = new FormData(); + fd.set("artist", artist.trim()); + fd.set("album", album.trim()); + if (year.trim()) fd.set("year", year.trim()); + for (const f of files) fd.append("files", f); + const res = await fetch("/api/library/import", { method: "POST", body: fd }).catch(() => null); + if (res?.ok) { + const d = (await res.json()) as { tracks: number }; + toast(`Imported ${album.trim()} · ${d.tracks} tracks`); + onImported(); + close(); + } else { + const d = res ? ((await res.json().catch(() => ({}))) as { error?: string }) : {}; + toast(d.error || "Import failed"); + } + } finally { + setBusy(false); + } + } + + return ( + <> + + {open ? ( + +
+
{ + e.preventDefault(); + setDrag(true); + }} + onDragLeave={() => setDrag(false)} + onDrop={(e) => { + e.preventDefault(); + setDrag(false); + addFiles(e.dataTransfer.files); + }} + onClick={() => inputRef.current?.click()} + > + {audioCount > 0 + ? `${audioCount} audio file${audioCount === 1 ? "" : "s"} selected${ + files.length > audioCount ? " (+ cover)" : "" + }` + : "Drop audio files here, or click to choose"} + addFiles(e.target.files)} + /> +
+
+ + + +
+

+ Files are written into the library as-is (add a cover.jpg by including an image). Run a{" "} + Scan in Settings afterwards to fetch cover art and match MusicBrainz. +

+
+ + {files.length > 0 ? ( + + ) : null} +
+
+
+ ) : null} + + ); +} diff --git a/web/src/app/library/library-client.tsx b/web/src/app/library/library-client.tsx index 6761810..b0cf157 100644 --- a/web/src/app/library/library-client.tsx +++ b/web/src/app/library/library-client.tsx @@ -6,6 +6,7 @@ import { SectionHeader } from "../_ui/section-header"; import { CoverArt } from "../_ui/cover-art"; import { AlbumModal } from "../_ui/album-modal"; import { toast } from "../_ui/toast"; +import { ImportAlbum } from "./import-album"; type Album = { id: string; @@ -44,10 +45,13 @@ export function LibraryClient() { } } - useEffect(() => { + function refresh() { fetch("/api/library") .then((r) => r.json()) .then((d) => setAlbums(d.albums ?? [])); + } + useEffect(() => { + refresh(); }, []); const shown = useMemo(() => { @@ -61,6 +65,7 @@ export function LibraryClient() {
e.preventDefault()}> +