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