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
+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 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;
});
}