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 });
}
@@ -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 });
}