diff --git a/web/prisma/migrations/20260714090112_add_api_cache/migration.sql b/web/prisma/migrations/20260714090112_add_api_cache/migration.sql new file mode 100644 index 0000000..7f26018 --- /dev/null +++ b/web/prisma/migrations/20260714090112_add_api_cache/migration.sql @@ -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"); diff --git a/web/prisma/schema.prisma b/web/prisma/schema.prisma index f73d998..cbc6bdf 100644 --- a/web/prisma/schema.prisma +++ b/web/prisma/schema.prisma @@ -182,3 +182,15 @@ model ScanWorkItem { @@unique([scanId, path]) @@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]) +} diff --git a/web/src/app/_ui/album-modal.tsx b/web/src/app/_ui/album-modal.tsx index e4a9fe8..235644a 100644 --- a/web/src/app/_ui/album-modal.tsx +++ b/web/src/app/_ui/album-modal.tsx @@ -6,6 +6,10 @@ import { CoverArt } from "./cover-art"; 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(); + function fmt(ms: number | null): string { if (ms == null) return ""; const s = Math.round(ms / 1000); @@ -39,15 +43,25 @@ export function AlbumModal({ useEffect(() => { if (!open) return; - if (!album.rgMbid) { + const rgMbid = album.rgMbid; + if (!rgMbid) { setTracks("error"); return; } + const hit = trackCache.get(rgMbid); + if (hit) { + setTracks(hit); + return; + } setTracks("loading"); 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((d) => !cancelled && setTracks(d.tracks)) + .then((d) => { + if (cancelled) return; + trackCache.set(rgMbid, d.tracks); + setTracks(d.tracks); + }) .catch(() => !cancelled && setTracks("error")); return () => { cancelled = true; diff --git a/web/src/lib/apicache.test.ts b/web/src/lib/apicache.test.ts new file mode 100644 index 0000000..42c633e --- /dev/null +++ b/web/src/lib/apicache.test.ts @@ -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"); + }); +}); diff --git a/web/src/lib/apicache.ts b/web/src/lib/apicache.ts new file mode 100644 index 0000000..53a769c --- /dev/null +++ b/web/src/lib/apicache.ts @@ -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(key: string, ttlSec: number, fetchFresh: () => Promise): Promise { + 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(); +} diff --git a/web/src/lib/musicbrainz.ts b/web/src/lib/musicbrainz.ts index 612d259..921268f 100644 --- a/web/src/lib/musicbrainz.ts +++ b/web/src/lib/musicbrainz.ts @@ -1,3 +1,5 @@ +import { cached, normKey, CACHE_TTL } from "@/lib/apicache"; + const MB_BASE = "https://musicbrainz.org/ws/2"; 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 { - const data = await mbGet(`/artist?query=${encodeURIComponent(query)}&fmt=json&limit=8`); - return (data.artists ?? []).map((a: any) => ({ - mbid: a.id, - name: a.name ?? "", - disambiguation: a.disambiguation ?? "", - })); + const q = normKey(query); + if (!q) return []; + return cached(`artist-search:${q}`, CACHE_TTL.DAY, async () => { + const data = await mbGet(`/artist?query=${encodeURIComponent(query)}&fmt=json&limit=8`); + return (data.artists ?? []).map((a: any) => ({ + mbid: a.id, + name: a.name ?? "", + disambiguation: a.disambiguation ?? "", + })); + }); } export async function browseReleaseGroups(artistMbid: string): Promise { - const out: ReleaseGroupInfo[] = []; - let offset = 0; - for (;;) { - 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)); - offset += groups.length; - const total = data["release-group-count"] ?? offset; - if (groups.length === 0 || offset >= total) break; - } - return out; + return cached(`artist-rgs:${artistMbid}`, CACHE_TTL.MONTH, async () => { + const out: ReleaseGroupInfo[] = []; + let offset = 0; + for (;;) { + 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)); + offset += groups.length; + const total = data["release-group-count"] ?? offset; + if (groups.length === 0 || offset >= total) break; + } + return out; + }); } function escapeLucenePhrase(s: string): string { @@ -94,6 +102,12 @@ function albumPreferenceRank(g: any): number { } export async function searchReleaseGroup(artist: string, album: string): Promise { + return cached(`rg-search:${normKey(artist)}|${normKey(album)}`, CACHE_TTL.WEEK, () => + _searchReleaseGroup(artist, album), + ); +} + +async function _searchReleaseGroup(artist: string, album: string): Promise { const art = escapeLucenePhrase(artist); const stripped = stripEditionQualifiers(album); // 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 async function getArtistName(mbid: string): Promise { - const data = await mbGet(`/artist/${encodeURIComponent(mbid)}?fmt=json`); - return data.name ?? ""; + return cached(`artist-name:${mbid}`, CACHE_TTL.MONTH, async () => { + const data = await mbGet(`/artist/${encodeURIComponent(mbid)}?fmt=json`); + return data.name ?? ""; + }); } export async function browseReleaseGroupTracks(rgMbid: string): Promise { - 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 []; - // Prefer an Official release, then the earliest date. - const pick = [...releases].sort((a, b) => { - const ao = a.status === "Official" ? 0 : 1; - const bo = b.status === "Official" ? 0 : 1; - if (ao !== bo) return ao - bo; - return (a.date ?? "9999").localeCompare(b.date ?? "9999"); - })[0]; - const tracks: Track[] = []; - for (const medium of pick.media ?? []) { - for (const t of medium.tracks ?? []) { - tracks.push({ - position: Number(t.position ?? tracks.length + 1), - title: t.title ?? t.recording?.title ?? "", - lengthMs: t.length ?? t.recording?.length ?? null, - }); + return cached(`rg-tracks:${rgMbid}`, CACHE_TTL.MONTH, async () => { + 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 []; + // Prefer an Official release, then the earliest date. + const pick = [...releases].sort((a, b) => { + const ao = a.status === "Official" ? 0 : 1; + const bo = b.status === "Official" ? 0 : 1; + if (ao !== bo) return ao - bo; + return (a.date ?? "9999").localeCompare(b.date ?? "9999"); + })[0]; + const tracks: Track[] = []; + for (const medium of pick.media ?? []) { + for (const t of medium.tracks ?? []) { + tracks.push({ + position: Number(t.position ?? tracks.length + 1), + title: t.title ?? t.recording?.title ?? "", + lengthMs: t.length ?? t.recording?.length ?? null, + }); + } } - } - return tracks; + return tracks; + }); } diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts index 59bef8c..2d4ac38 100644 --- a/web/src/test/setup.ts +++ b/web/src/test/setup.ts @@ -17,4 +17,5 @@ beforeEach(async () => { await prisma.watchedArtist.deleteMany(); await prisma.discoverySuggestion.deleteMany(); await prisma.config.deleteMany(); + await prisma.apiCache.deleteMany(); // read-through cache must not leak across tests });