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