feat(web): MB-resolve manual imports for instant cover art

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>
This commit is contained in:
Jonathan
2026-07-14 20:05:59 +02:00
parent d52517ef39
commit 0147975b14
5 changed files with 18 additions and 1 deletions
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "LibraryItem" ADD COLUMN "rgMbid" TEXT;
+3
View File
@@ -82,6 +82,9 @@ model LibraryItem {
format String format String
qualityClass Int qualityClass Int
importedAt DateTime @default(now()) importedAt DateTime @default(now())
// MusicBrainz release-group MBID when known (resolved at manual import) — gives the album
// cover art without depending on a matching MonitoredRelease row.
rgMbid String?
// The album's on-disk audio filenames ("## Title.ext"), captured at import + scan, so the // The album's on-disk audio filenames ("## Title.ext"), captured at import + scan, so the
// Library modal can show the REAL tracks on disk (revealing incomplete/edition mismatches) // Library modal can show the REAL tracks on disk (revealing incomplete/edition mismatches)
// instead of MusicBrainz's canonical listing. Empty for items imported before this existed // instead of MusicBrainz's canonical listing. Empty for items imported before this existed
+2 -1
View File
@@ -1,4 +1,5 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
vi.mock("@/lib/musicbrainz", () => ({ searchReleaseGroup: vi.fn(async () => null) }));
import { mkdtempSync, existsSync, readFileSync, readdirSync, rmSync } from "node:fs"; import { mkdtempSync, existsSync, readFileSync, readdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
+11
View File
@@ -7,6 +7,7 @@ import { randomUUID } from "node:crypto";
import Busboy from "busboy"; import Busboy from "busboy";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { isAuthed } from "@/lib/auth"; 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 // 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 // optional cover) with artist/album/year, and Lyra writes it into the library. The web
@@ -104,6 +105,15 @@ export async function POST(request: Request) {
await mkdir(dirname(finalDir), { recursive: true }); await mkdir(dirname(finalDir), { recursive: true });
await rename(staging, finalDir); 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(); const firstExt = extname(audio[0]).toLowerCase();
await prisma.libraryItem.create({ await prisma.libraryItem.create({
data: { data: {
@@ -115,6 +125,7 @@ export async function POST(request: Request) {
// rough class from the extension (2 = CD-lossless, 1 = lossy); a later scan re-probes and // 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. // can upgrade a hi-res file to class 3.
qualityClass: LOSSLESS_EXT.has(firstExt) ? 2 : 1, qualityClass: LOSSLESS_EXT.has(firstExt) ? 2 : 1,
rgMbid,
trackNames: audio, trackNames: audio,
}, },
}); });
Binary file not shown.