diff --git a/web/src/app/_ui/cover-art.tsx b/web/src/app/_ui/cover-art.tsx index ddd20c5..f9ba30d 100644 --- a/web/src/app/_ui/cover-art.tsx +++ b/web/src/app/_ui/cover-art.tsx @@ -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 {alt} setFailed(true)} diff --git a/web/src/app/api/cover/[rgMbid]/route.test.ts b/web/src/app/api/cover/[rgMbid]/route.test.ts new file mode 100644 index 0000000..bd2b5dd --- /dev/null +++ b/web/src/app/api/cover/[rgMbid]/route.test.ts @@ -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 + }); +}); diff --git a/web/src/app/api/cover/[rgMbid]/route.ts b/web/src/app/api/cover/[rgMbid]/route.ts new file mode 100644 index 0000000..13e2ba2 --- /dev/null +++ b/web/src/app/api/cover/[rgMbid]/route.ts @@ -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 }); + } +} diff --git a/web/src/app/queue.tsx b/web/src/app/queue.tsx index 6fc0e2a..3ee4c43 100644 Binary files a/web/src/app/queue.tsx and b/web/src/app/queue.tsx differ