Compare commits

...

2 Commits

Author SHA1 Message Date
Jonathan 0147975b14 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>
2026-07-14 20:05:59 +02:00
Jonathan d52517ef39 feat(web): cache cover art + dedupe Recently Pressed
- Cover art now loads through GET /api/cover/[rgMbid], which fetches from the
  Cover Art Archive once, caches the image on disk (LYRA_COVER_CACHE, default
  /tmp/lyra-covers), and serves it with a 30-day Cache-Control — so the browser
  stops re-fetching it on every Library / Recently-Pressed render. Missing covers
  get a negative marker (re-checked daily). CoverArt points at the endpoint.
- Recently Pressed dedupes by artist+album (re-requesting/re-downloading an album
  left multiple completed Requests showing the same album repeatedly); newest of
  each is kept.

web 164 tests, tsc + build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 20:03:36 +02:00
9 changed files with 119 additions and 5 deletions
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "LibraryItem" ADD COLUMN "rgMbid" TEXT;
+3
View File
@@ -82,6 +82,9 @@ model LibraryItem {
format String
qualityClass Int
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
// 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
+4 -4
View File
@@ -2,9 +2,9 @@
import { useState } from "react";
/** Album art from the Cover Art Archive (keyed by release-group MBID), loaded straight from
* their CDN by the browser — no backend, no MB API rate limit. Falls back to a placeholder
* when there's no MBID or no art. */
/** Album art keyed by release-group MBID, served through /api/cover (which caches it on disk +
* sends a long Cache-Control, so it isn't re-fetched from the Cover Art Archive on every
* Library / Recently-Pressed render). Falls back to a placeholder when there's no MBID or art. */
export function CoverArt({
rgMbid,
alt,
@@ -29,7 +29,7 @@ export function CoverArt({
// eslint-disable-next-line @next/next/no-img-element
<img
className={cls}
src={`https://coverartarchive.org/release-group/${rgMbid}/front-${size}`}
src={`/api/cover/${rgMbid}?size=${size}`}
alt={alt}
loading="lazy"
onError={() => setFailed(true)}
@@ -0,0 +1,52 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, rmSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { GET } from "./route";
let cache: string;
const original = process.env.LYRA_COVER_CACHE;
const RG = "735bcee6-6be6-3957-af15-d21ba2bd355b";
beforeEach(() => {
cache = mkdtempSync(join(tmpdir(), "lyra-cov-"));
process.env.LYRA_COVER_CACHE = cache;
});
afterEach(() => {
vi.unstubAllGlobals();
if (original === undefined) delete process.env.LYRA_COVER_CACHE;
else process.env.LYRA_COVER_CACHE = original;
rmSync(cache, { recursive: true, force: true });
});
const req = (rg: string) => new Request(`http://x/api/cover/${rg}?size=250`);
const params = (rg: string) => ({ params: Promise.resolve({ rgMbid: rg }) });
describe("GET /api/cover/[rgMbid]", () => {
it("400s a non-uuid", async () => {
expect((await GET(req("not-a-uuid"), params("not-a-uuid"))).status).toBe(400);
});
it("fetches from CAA once, then serves from the disk cache", async () => {
const fetchMock = vi.fn(async () => new Response(new Uint8Array([1, 2, 3]), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const r1 = await GET(req(RG), params(RG));
expect(r1.status).toBe(200);
expect(r1.headers.get("cache-control")).toContain("max-age");
expect(existsSync(join(cache, `${RG}-250.jpg`))).toBe(true);
const r2 = await GET(req(RG), params(RG));
expect(r2.status).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(1); // second served from cache
});
it("caches a miss (404) so it isn't re-fetched", async () => {
const fetchMock = vi.fn(async () => new Response(null, { status: 404 }));
vi.stubGlobal("fetch", fetchMock);
expect((await GET(req(RG), params(RG))).status).toBe(404);
expect((await GET(req(RG), params(RG))).status).toBe(404);
expect(fetchMock).toHaveBeenCalledTimes(1); // negative cache hit on the second
});
});
+45
View File
@@ -0,0 +1,45 @@
import { existsSync } from "node:fs";
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { join } from "node:path";
// Cover art proxy + cache. The browser used to hit the Cover Art Archive CDN directly on every
// Library / Recently-Pressed render; now it hits this, which caches the image on disk and serves
// it with a long Cache-Control so the browser caches it too. A negative marker caps re-fetching
// covers that don't exist.
const CACHEABLE = "public, max-age=2592000"; // 30 days
const NEG_CACHE = "public, max-age=86400"; // re-check a missing cover daily
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export async function GET(request: Request, { params }: { params: Promise<{ rgMbid: string }> }) {
const { rgMbid } = await params;
if (!UUID.test(rgMbid)) return new Response(null, { status: 400 });
const size = new URL(request.url).searchParams.get("size") === "500" ? "500" : "250";
const CACHE = process.env.LYRA_COVER_CACHE ?? "/tmp/lyra-covers";
const key = `${rgMbid}-${size}`;
const file = join(CACHE, `${key}.jpg`);
const miss = join(CACHE, `${key}.miss`);
try {
if (existsSync(file)) {
return new Response(await readFile(file), {
headers: { "Content-Type": "image/jpeg", "Cache-Control": CACHEABLE },
});
}
if (existsSync(miss)) {
return new Response(null, { status: 404, headers: { "Cache-Control": NEG_CACHE } });
}
const res = await fetch(`https://coverartarchive.org/release-group/${rgMbid}/front-${size}`, {
headers: { "User-Agent": "Lyra/0.1 ( https://git.jger.nl/Jonathan/Lyra )" },
});
await mkdir(CACHE, { recursive: true });
if (!res.ok) {
await writeFile(miss, "").catch(() => {});
return new Response(null, { status: 404, headers: { "Cache-Control": NEG_CACHE } });
}
const buf = Buffer.from(await res.arrayBuffer());
await writeFile(file, buf).catch(() => {});
return new Response(buf, { headers: { "Content-Type": "image/jpeg", "Cache-Control": CACHEABLE } });
} catch {
return new Response(null, { status: 502 });
}
}
+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 { tmpdir } from "node:os";
import { join } from "node:path";
+11
View File
@@ -7,6 +7,7 @@ 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
@@ -104,6 +105,15 @@ export async function POST(request: Request) {
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: {
@@ -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
// can upgrade a hi-res file to class 3.
qualityClass: LOSSLESS_EXT.has(firstExt) ? 2 : 1,
rgMbid,
trackNames: audio,
},
});
Binary file not shown.
Binary file not shown.