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:
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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.
Reference in New Issue
Block a user