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:
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user