0147975b14
A manually-imported album had no release-group MBID, so it showed the ◉ placeholder until a Scan matched it. Now the import resolves the release group (searchReleaseGroup, via the shared MB cache) and stores it on the new LibraryItem.rgMbid column (migration add_library_rg_mbid). The library route prefers LibraryItem.rgMbid, falling back to the MonitoredRelease join for scanned items — so a manual album gets cover art immediately. Best-effort: a no-match / MB outage just leaves rgMbid null. web 164 tests, tsc + build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
135 lines
5.3 KiB
TypeScript
135 lines
5.3 KiB
TypeScript
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<string, string> = {};
|
|
const audioNames: string[] = [];
|
|
let hasCover = false;
|
|
|
|
try {
|
|
await new Promise<void>((res, rej) => {
|
|
const bb = Busboy({ headers: { "content-type": ct } });
|
|
const writes: Promise<void>[] = [];
|
|
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 });
|
|
}
|