feat(web): shared Postgres read-through cache for MusicBrainz (ApiCache)

Reopening an artist (discography), an album (tracklist), or repeating a lookup
re-hit MusicBrainz every time with no cache and no throttle. Adds a generic
Postgres-backed read-through cache keyed on the LOGICAL operation (not the
request URL) so the worker and web app share entries.

- New ApiCache(key, json, fetchedAt) model + migration (generic name so the
  Last.fm browse cache can ride the same table).
- lib/apicache.ts cached(): read-through, TTL from fetchedAt (tunable, no
  migration), serve-stale-on-outage, never caches null.
- Wrap the high-level MB fns with logical keys + TTLs: discography &
  tracklists & artist-name 30d, searchReleaseGroup 7d, searchArtists 1d.
  Search keys normalized (trim+lowercase) to match the worker byte-for-byte.
- album-modal: a session-scoped Map<rgMbid,Track[]> so reopening is instant
  with zero network (complements the DB cache).
- Test setup clears ApiCache between tests. web 147 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 11:17:51 +02:00
parent d275a0ddd6
commit bdbf9d237e
7 changed files with 195 additions and 46 deletions
@@ -0,0 +1,11 @@
-- CreateTable
CREATE TABLE "ApiCache" (
"key" TEXT NOT NULL,
"json" JSONB NOT NULL,
"fetchedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ApiCache_pkey" PRIMARY KEY ("key")
);
-- CreateIndex
CREATE INDEX "ApiCache_fetchedAt_idx" ON "ApiCache"("fetchedAt");
+12
View File
@@ -182,3 +182,15 @@ model ScanWorkItem {
@@unique([scanId, path]) @@unique([scanId, path])
@@index([scanId, done, artist, album]) @@index([scanId, done, artist, album])
} }
// Generic read-through cache for external API responses shared by web + worker (keyed on
// the LOGICAL operation, not the request URL, so both processes share entries). TTL is
// applied at read time from fetchedAt in code (no expiresAt column → tunable, no migration).
// Named generically so the Last.fm browse cache can ride the same table.
model ApiCache {
key String @id
json Json
fetchedAt DateTime @default(now())
@@index([fetchedAt])
}
+17 -3
View File
@@ -6,6 +6,10 @@ import { CoverArt } from "./cover-art";
type Track = { position: number; title: string; lengthMs: number | null }; type Track = { position: number; title: string; lengthMs: number | null };
// Session-scoped, per-release-group tracklist cache: reopening the same album modal is
// instant with zero network. Complements the server-side ApiCache (which spans sessions).
const trackCache = new Map<string, Track[]>();
function fmt(ms: number | null): string { function fmt(ms: number | null): string {
if (ms == null) return ""; if (ms == null) return "";
const s = Math.round(ms / 1000); const s = Math.round(ms / 1000);
@@ -39,15 +43,25 @@ export function AlbumModal({
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
if (!album.rgMbid) { const rgMbid = album.rgMbid;
if (!rgMbid) {
setTracks("error"); setTracks("error");
return; return;
} }
const hit = trackCache.get(rgMbid);
if (hit) {
setTracks(hit);
return;
}
setTracks("loading"); setTracks("loading");
let cancelled = false; let cancelled = false;
fetch(`/api/mb/release-groups/${album.rgMbid}/tracks`) fetch(`/api/mb/release-groups/${rgMbid}/tracks`)
.then((r) => (r.ok ? r.json() : Promise.reject())) .then((r) => (r.ok ? r.json() : Promise.reject()))
.then((d) => !cancelled && setTracks(d.tracks)) .then((d) => {
if (cancelled) return;
trackCache.set(rgMbid, d.tracks);
setTracks(d.tracks);
})
.catch(() => !cancelled && setTracks("error")); .catch(() => !cancelled && setTracks("error"));
return () => { return () => {
cancelled = true; cancelled = true;
+54
View File
@@ -0,0 +1,54 @@
import { describe, it, expect, vi } from "vitest";
import { cached, normKey } from "./apicache";
import { prisma } from "@/lib/db";
describe("cached (read-through)", () => {
it("fetches once, then serves the cached value without re-fetching", async () => {
const fetchFresh = vi.fn(async () => ({ v: 1 }));
const first = await cached("k1", 3600, fetchFresh);
const second = await cached("k1", 3600, fetchFresh);
expect(first).toEqual({ v: 1 });
expect(second).toEqual({ v: 1 });
expect(fetchFresh).toHaveBeenCalledTimes(1); // second served from cache
});
it("re-fetches once the TTL has elapsed", async () => {
const fetchFresh = vi.fn(async () => ({ v: 2 }));
await cached("k2", 3600, fetchFresh);
// backdate the row beyond the ttl
await prisma.apiCache.update({ where: { key: "k2" }, data: { fetchedAt: new Date(Date.now() - 7200_000) } });
await cached("k2", 3600, fetchFresh);
expect(fetchFresh).toHaveBeenCalledTimes(2);
});
it("serves a stale value when the fresh fetch throws", async () => {
await cached("k3", 3600, async () => ({ v: "old" }));
await prisma.apiCache.update({ where: { key: "k3" }, data: { fetchedAt: new Date(Date.now() - 7200_000) } });
const result = await cached("k3", 3600, async () => {
throw new Error("upstream down");
});
expect(result).toEqual({ v: "old" }); // stale-on-outage
});
it("rethrows when the fetch fails and there is no cached row", async () => {
await expect(
cached("k4", 3600, async () => {
throw new Error("boom");
}),
).rejects.toThrow("boom");
// nothing was cached
expect(await prisma.apiCache.findUnique({ where: { key: "k4" } })).toBeNull();
});
it("does not cache null results", async () => {
const fetchFresh = vi.fn(async () => null);
await cached("k5", 3600, fetchFresh);
await cached("k5", 3600, fetchFresh);
expect(fetchFresh).toHaveBeenCalledTimes(2); // null never cached → always re-fetched
expect(await prisma.apiCache.findUnique({ where: { key: "k5" } })).toBeNull();
});
it("normKey trims and lowercases", () => {
expect(normKey(" Radiohead ")).toBe("radiohead");
});
});
+39
View File
@@ -0,0 +1,39 @@
import { prisma } from "@/lib/db";
// Read-through cache over the shared ApiCache table. Keyed on the LOGICAL operation (not the
// request URL) so the worker and web app share entries. TTL is applied here from fetchedAt,
// so TTLs are tunable in code with no migration. On a fetch failure a stale row is served.
// Null/undefined results are NOT cached (keeps negative caching out + avoids Prisma JsonNull).
export const CACHE_TTL = {
DAY: 86_400,
WEEK: 7 * 86_400,
MONTH: 30 * 86_400,
};
export async function cached<T>(key: string, ttlSec: number, fetchFresh: () => Promise<T>): Promise<T> {
const hit = await prisma.apiCache.findUnique({ where: { key } });
if (hit && (Date.now() - hit.fetchedAt.getTime()) / 1000 < ttlSec) {
return hit.json as T;
}
try {
const fresh = await fetchFresh();
if (fresh !== null && fresh !== undefined) {
await prisma.apiCache.upsert({
where: { key },
create: { key, json: fresh as object },
update: { json: fresh as object, fetchedAt: new Date() },
});
}
return fresh;
} catch (e) {
if (hit) return hit.json as T; // serve stale on an upstream outage
throw e;
}
}
/** Normalize a free-text search term so casing/whitespace don't fragment the cache. MUST
* match the worker's normalization byte-for-byte (both: trim + lowercase). */
export function normKey(s: string): string {
return s.trim().toLowerCase();
}
+61 -43
View File
@@ -1,3 +1,5 @@
import { cached, normKey, CACHE_TTL } from "@/lib/apicache";
const MB_BASE = "https://musicbrainz.org/ws/2"; const MB_BASE = "https://musicbrainz.org/ws/2";
const USER_AGENT = "Lyra/0.1 ( https://git.jger.nl/Jonathan/Lyra )"; const USER_AGENT = "Lyra/0.1 ( https://git.jger.nl/Jonathan/Lyra )";
@@ -30,28 +32,34 @@ function toReleaseGroup(g: any): ReleaseGroupInfo {
} }
export async function searchArtists(query: string): Promise<ArtistHit[]> { export async function searchArtists(query: string): Promise<ArtistHit[]> {
const data = await mbGet(`/artist?query=${encodeURIComponent(query)}&fmt=json&limit=8`); const q = normKey(query);
return (data.artists ?? []).map((a: any) => ({ if (!q) return [];
mbid: a.id, return cached(`artist-search:${q}`, CACHE_TTL.DAY, async () => {
name: a.name ?? "", const data = await mbGet(`/artist?query=${encodeURIComponent(query)}&fmt=json&limit=8`);
disambiguation: a.disambiguation ?? "", return (data.artists ?? []).map((a: any) => ({
})); mbid: a.id,
name: a.name ?? "",
disambiguation: a.disambiguation ?? "",
}));
});
} }
export async function browseReleaseGroups(artistMbid: string): Promise<ReleaseGroupInfo[]> { export async function browseReleaseGroups(artistMbid: string): Promise<ReleaseGroupInfo[]> {
const out: ReleaseGroupInfo[] = []; return cached(`artist-rgs:${artistMbid}`, CACHE_TTL.MONTH, async () => {
let offset = 0; const out: ReleaseGroupInfo[] = [];
for (;;) { let offset = 0;
const data = await mbGet( for (;;) {
`/release-group?artist=${encodeURIComponent(artistMbid)}&fmt=json&limit=100&offset=${offset}`, const data = await mbGet(
); `/release-group?artist=${encodeURIComponent(artistMbid)}&fmt=json&limit=100&offset=${offset}`,
const groups: any[] = data["release-groups"] ?? []; );
for (const g of groups) out.push(toReleaseGroup(g)); const groups: any[] = data["release-groups"] ?? [];
offset += groups.length; for (const g of groups) out.push(toReleaseGroup(g));
const total = data["release-group-count"] ?? offset; offset += groups.length;
if (groups.length === 0 || offset >= total) break; const total = data["release-group-count"] ?? offset;
} if (groups.length === 0 || offset >= total) break;
return out; }
return out;
});
} }
function escapeLucenePhrase(s: string): string { function escapeLucenePhrase(s: string): string {
@@ -94,6 +102,12 @@ function albumPreferenceRank(g: any): number {
} }
export async function searchReleaseGroup(artist: string, album: string): Promise<ReleaseGroupMatch | null> { export async function searchReleaseGroup(artist: string, album: string): Promise<ReleaseGroupMatch | null> {
return cached(`rg-search:${normKey(artist)}|${normKey(album)}`, CACHE_TTL.WEEK, () =>
_searchReleaseGroup(artist, album),
);
}
async function _searchReleaseGroup(artist: string, album: string): Promise<ReleaseGroupMatch | null> {
const art = escapeLucenePhrase(artist); const art = escapeLucenePhrase(artist);
const stripped = stripEditionQualifiers(album); const stripped = stripEditionQualifiers(album);
// Decreasing precision: exact phrase on the given title, then on the edition-stripped // Decreasing precision: exact phrase on the given title, then on the edition-stripped
@@ -121,32 +135,36 @@ export async function searchReleaseGroup(artist: string, album: string): Promise
export type Track = { position: number; title: string; lengthMs: number | null }; export type Track = { position: number; title: string; lengthMs: number | null };
export async function getArtistName(mbid: string): Promise<string> { export async function getArtistName(mbid: string): Promise<string> {
const data = await mbGet(`/artist/${encodeURIComponent(mbid)}?fmt=json`); return cached(`artist-name:${mbid}`, CACHE_TTL.MONTH, async () => {
return data.name ?? ""; const data = await mbGet(`/artist/${encodeURIComponent(mbid)}?fmt=json`);
return data.name ?? "";
});
} }
export async function browseReleaseGroupTracks(rgMbid: string): Promise<Track[]> { export async function browseReleaseGroupTracks(rgMbid: string): Promise<Track[]> {
const data = await mbGet( return cached(`rg-tracks:${rgMbid}`, CACHE_TTL.MONTH, async () => {
`/release?release-group=${encodeURIComponent(rgMbid)}&inc=recordings&fmt=json&limit=25`, const data = await mbGet(
); `/release?release-group=${encodeURIComponent(rgMbid)}&inc=recordings&fmt=json&limit=25`,
const releases: any[] = data.releases ?? []; );
if (releases.length === 0) return []; const releases: any[] = data.releases ?? [];
// Prefer an Official release, then the earliest date. if (releases.length === 0) return [];
const pick = [...releases].sort((a, b) => { // Prefer an Official release, then the earliest date.
const ao = a.status === "Official" ? 0 : 1; const pick = [...releases].sort((a, b) => {
const bo = b.status === "Official" ? 0 : 1; const ao = a.status === "Official" ? 0 : 1;
if (ao !== bo) return ao - bo; const bo = b.status === "Official" ? 0 : 1;
return (a.date ?? "9999").localeCompare(b.date ?? "9999"); if (ao !== bo) return ao - bo;
})[0]; return (a.date ?? "9999").localeCompare(b.date ?? "9999");
const tracks: Track[] = []; })[0];
for (const medium of pick.media ?? []) { const tracks: Track[] = [];
for (const t of medium.tracks ?? []) { for (const medium of pick.media ?? []) {
tracks.push({ for (const t of medium.tracks ?? []) {
position: Number(t.position ?? tracks.length + 1), tracks.push({
title: t.title ?? t.recording?.title ?? "", position: Number(t.position ?? tracks.length + 1),
lengthMs: t.length ?? t.recording?.length ?? null, title: t.title ?? t.recording?.title ?? "",
}); lengthMs: t.length ?? t.recording?.length ?? null,
});
}
} }
} return tracks;
return tracks; });
} }
+1
View File
@@ -17,4 +17,5 @@ beforeEach(async () => {
await prisma.watchedArtist.deleteMany(); await prisma.watchedArtist.deleteMany();
await prisma.discoverySuggestion.deleteMany(); await prisma.discoverySuggestion.deleteMany();
await prisma.config.deleteMany(); await prisma.config.deleteMany();
await prisma.apiCache.deleteMany(); // read-through cache must not leak across tests
}); });