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>
This commit is contained in:
Jonathan
2026-07-14 20:03:36 +02:00
parent 36a34181c9
commit d52517ef39
4 changed files with 101 additions and 4 deletions
+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 });
}
}
Binary file not shown.