import { createWriteStream, existsSync } from "node:fs"; import { mkdir, rename, rm } from "node:fs/promises"; import { resolve, join, dirname, extname } from "node:path"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; import { randomUUID } from "node:crypto"; import Busboy from "busboy"; import { prisma } from "@/lib/db"; import { isAuthed } from "@/lib/auth"; import { searchReleaseGroup } from "@/lib/musicbrainz"; // 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 MB-resolves it // (cover art, canonical types) and re-probes quality. // // The body is STREAMED to disk with busboy rather than buffered via request.formData(), which // caps out around ~15MB — real FLAC albums are hundreds of MB. const AUDIO_EXT = new Set([".flac", ".mp3", ".m4a", ".opus", ".ogg", ".aac", ".wav"]); const LOSSLESS_EXT = new Set([".flac", ".wav"]); 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) { // This route is excluded from the auth middleware (to avoid the 10MB body cap), so it must // authenticate itself. if (!(await isAuthed(request))) { return Response.json({ error: "unauthorized" }, { status: 401 }); } const ct = request.headers.get("content-type") ?? ""; if (!ct.includes("multipart/form-data") || !request.body) { return Response.json({ error: "expected multipart form data" }, { status: 400 }); } const root = resolve(process.env.LYRA_LIBRARY_ROOT ?? "/music"); // stage under a hidden dir on the library filesystem so the final move is an atomic rename // (the scan skips dot-dirs). Cleaned up on any failure. const staging = join(root, `.import-${randomUUID()}`); await mkdir(staging, { recursive: true }); const fields: Record = {}; const audioNames: string[] = []; let hasCover = false; try { await new Promise((res, rej) => { const bb = Busboy({ headers: { "content-type": ct } }); const writes: Promise[] = []; bb.on("field", (name, val) => { fields[name] = val; }); bb.on("file", (_name, stream, info) => { const ext = extname(info.filename).toLowerCase(); if (AUDIO_EXT.has(ext)) { const safe = safeName(info.filename); audioNames.push(safe); writes.push(pipeline(stream, createWriteStream(join(staging, safe)))); } else if (/\.(jpe?g|png)$/i.test(info.filename) && !hasCover) { hasCover = true; writes.push(pipeline(stream, createWriteStream(join(staging, "cover.jpg")))); } else { stream.resume(); // discard anything that isn't audio or a cover } }); bb.on("error", rej); bb.on("close", () => Promise.all(writes).then(() => res(), rej)); Readable.fromWeb(request.body as import("node:stream/web").ReadableStream).pipe(bb); }); } catch { await rm(staging, { recursive: true, force: true }); return Response.json({ error: "failed to write the upload to disk" }, { status: 500 }); } const artist = (fields.artist ?? "").trim(); const album = (fields.album ?? "").trim(); const year = (fields.year ?? "").trim(); const audio = audioNames.sort((a, b) => a.localeCompare(b)); const bad = !artist || !album ? "artist and album are required" : audio.length === 0 ? "no audio files provided" : year && !/^\d{4}$/.test(year) ? "year must be 4 digits" : null; if (bad) { await rm(staging, { recursive: true, force: true }); return Response.json({ error: bad }, { status: 400 }); } const albumFolder = year ? `${safeName(album)} (${year})` : safeName(album); const finalDir = join(root, safeName(artist), albumFolder); if (existsSync(finalDir) || (await prisma.libraryItem.findFirst({ where: { artist, album } }))) { await rm(staging, { recursive: true, force: true }); return Response.json({ error: "this album is already in the library" }, { status: 409 }); } await mkdir(dirname(finalDir), { recursive: true }); await rename(staging, finalDir); // Resolve the release group so the album gets cover art immediately (best-effort — a // no-match just leaves rgMbid null; a later Scan can still match it). Uses the shared MB cache. let rgMbid: string | null = null; try { rgMbid = (await searchReleaseGroup(artist, album))?.rgMbid ?? null; } catch { /* MB unavailable — carry on without cover art */ } const firstExt = extname(audio[0]).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, rgMbid, trackNames: audio, }, }); return Response.json({ ok: true, tracks: audio.length }, { status: 201 }); }