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 { rm, rmdir } from "node:fs/promises";
import { resolve, sep } from "node:path"; import { dirname, resolve, sep } from "node:path";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
// DELETE /api/library/[id] — remove an owned album: delete its folder on disk, drop the // 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 { try {
await rm(target, { recursive: true, force: true }); 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 { } catch {
return Response.json({ error: "failed to delete files on disk" }, { status: 500 }); return Response.json({ error: "failed to delete files on disk" }, { status: 500 });
} }
@@ -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<string, string>, 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);
});
});
+86
View File
@@ -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 });
}
+6
View File
@@ -204,6 +204,12 @@ nav.contents .sep { flex: 1; }
font-family: var(--mono); font-size: 0.62rem; letter-spacing: 0.14em; text-transform: uppercase; font-family: var(--mono); font-size: 0.62rem; letter-spacing: 0.14em; text-transform: uppercase;
color: var(--graphite); margin: 0 0 10px; 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 ──────────── */ /* ── Phase 2: page headers, list rows, tools ──────────── */
.page-head { margin: 6px 0 26px; } .page-head { margin: 6px 0 26px; }
+132
View File
@@ -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<File[]>([]);
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<HTMLInputElement>(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 (
<>
<button type="button" className="btn accent" onClick={() => setOpen(true)}>
Add album
</button>
{open ? (
<Modal open onClose={close} title="Add an album">
<form onSubmit={submit} className="import-form">
<div
className={`dropzone${drag ? " over" : ""}`}
onDragOver={(e) => {
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"}
<input
ref={inputRef}
type="file"
multiple
accept="audio/*,.flac,.m4a,.opus,image/*"
hidden
onChange={(e) => addFiles(e.target.files)}
/>
</div>
<div className="field-grid">
<label className="field">
<span>Artist</span>
<input aria-label="import artist" value={artist} onChange={(e) => setArtist(e.target.value)} />
</label>
<label className="field">
<span>Album</span>
<input aria-label="import album" value={album} onChange={(e) => setAlbum(e.target.value)} />
</label>
<label className="field">
<span>Year (optional)</span>
<input aria-label="import year" value={year} onChange={(e) => setYear(e.target.value)} placeholder="2020" />
</label>
</div>
<p className="help">
Files are written into the library as-is (add a cover.jpg by including an image). Run a{" "}
<b>Scan</b> in Settings afterwards to fetch cover art and match MusicBrainz.
</p>
<div className="save-row">
<button type="submit" className="btn accent" disabled={busy || !ready}>
{busy ? "Importing…" : "Import"}
</button>
{files.length > 0 ? (
<button type="button" className="btn ghost sm" onClick={reset}>
Clear files
</button>
) : null}
</div>
</form>
</Modal>
) : null}
</>
);
}
+6 -1
View File
@@ -6,6 +6,7 @@ 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"; import { toast } from "../_ui/toast";
import { ImportAlbum } from "./import-album";
type Album = { type Album = {
id: string; id: string;
@@ -44,10 +45,13 @@ export function LibraryClient() {
} }
} }
useEffect(() => { function refresh() {
fetch("/api/library") fetch("/api/library")
.then((r) => r.json()) .then((r) => r.json())
.then((d) => setAlbums(d.albums ?? [])); .then((d) => setAlbums(d.albums ?? []));
}
useEffect(() => {
refresh();
}, []); }, []);
const shown = useMemo(() => { const shown = useMemo(() => {
@@ -61,6 +65,7 @@ export function LibraryClient() {
<PageHead title="Library" eyebrow="The pressings · everything on disk" /> <PageHead title="Library" eyebrow="The pressings · everything on disk" />
<form className="request-form" onSubmit={(e) => e.preventDefault()}> <form className="request-form" onSubmit={(e) => e.preventDefault()}>
<ImportAlbum onImported={refresh} />
<label className="field"> <label className="field">
<span>Filter</span> <span>Filter</span>
<input aria-label="filter library" placeholder="Artist or album…" value={q} onChange={(e) => setQ(e.target.value)} /> <input aria-label="filter library" placeholder="Artist or album…" value={q} onChange={(e) => setQ(e.target.value)} />