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:
@@ -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");
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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<string, Track[]>();
|
||||
|
||||
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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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<ArtistHit[]> {
|
||||
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<ReleaseGroupInfo[]> {
|
||||
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<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 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<string> {
|
||||
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<Track[]> {
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user