Files
Lyra/web/src/lib/musicbrainz.ts
T
Jonathan bdbf9d237e 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>
2026-07-14 11:17:51 +02:00

171 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 )";
export type ArtistHit = { mbid: string; name: string; disambiguation: string };
export type ReleaseGroupInfo = {
rgMbid: string;
title: string;
primaryType: string | null;
secondaryTypes: string[];
firstReleaseDate: string | null;
};
export type ReleaseGroupMatch = ReleaseGroupInfo & { artistMbid: string; artistName: string };
async function mbGet(path: string): Promise<any> {
const res = await fetch(`${MB_BASE}${path}`, {
headers: { "User-Agent": USER_AGENT, Accept: "application/json" },
});
if (!res.ok) throw new Error(`MusicBrainz request failed: ${res.status}`);
return res.json();
}
function toReleaseGroup(g: any): ReleaseGroupInfo {
return {
rgMbid: g.id,
title: g.title ?? "",
primaryType: g["primary-type"] ?? null,
secondaryTypes: g["secondary-types"] ?? [],
firstReleaseDate: g["first-release-date"] || null,
};
}
export async function searchArtists(query: string): Promise<ArtistHit[]> {
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[]> {
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 {
return s.replace(/[\\"]/g, (c) => "\\" + c);
}
// Last.fm album names carry per-RELEASE edition/version qualifiers ("(Legacy Edition)",
// " - Remastered 2011", "[Deluxe]") that are NOT part of the release-GROUP title, so a
// strict phrase search finds nothing. Strip a trailing parenthetical/bracket or " - "
// suffix ONLY when it names an edition/version (never a plain parenthetical like
// "(What's the Story) Morning Glory?", which isn't trailing anyway).
const EDITION_RE =
/\b(deluxe|expanded|legacy|remaster(ed)?|anniversary|reissue|bonus|special|collector'?s?|edition|version|mono|stereo|explicit|clean)\b/i;
export function stripEditionQualifiers(album: string): string {
let s = album.trim();
for (;;) {
const before = s;
// trailing (...) or [...]
s = s.replace(/\s*[([][^()[\]]*[)\]]\s*$/, (m) => (EDITION_RE.test(m) ? "" : m)).trimEnd();
// trailing " - ..." / " ..." / " — ..." suffix
s = s.replace(/\s+[-–—]\s+[^-–—]*$/, (m) => (EDITION_RE.test(m) ? "" : m)).trimEnd();
if (s === before) break;
}
return s.trim() || album.trim(); // never collapse to empty
}
// When several release groups share the searched title (e.g. MusicBrainz ties the
// "Thriller" single with the album at the same score, listing the single first), prefer
// the canonical studio album. Lower rank = better; MB's own ordering breaks ties within
// a tier. A title that only exists as a single still resolves (it's the best available).
function albumPreferenceRank(g: any): number {
const primary = g["primary-type"] ?? "";
const hasSecondary = (g["secondary-types"] ?? []).length > 0;
if (primary === "Album" && !hasSecondary) return 0; // clean studio album
if (primary === "EP" && !hasSecondary) return 1;
if (primary === "Single" && !hasSecondary) return 2;
if (primary === "Album") return 3; // album with a secondary type (compilation, live, demo…)
return 4;
}
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
// title, then a loose (unquoted, OR'd terms) fallback. Stop at the first that yields hits
// so the clean case still costs one request.
const attempts = [`releasegroup:"${escapeLucenePhrase(album)}" AND artist:"${art}"`];
if (stripped !== album) attempts.push(`releasegroup:"${escapeLucenePhrase(stripped)}" AND artist:"${art}"`);
attempts.push(`releasegroup:(${escapeLucenePhrase(stripped)}) AND artist:"${art}"`);
let groups: any[] = [];
for (const query of attempts) {
const data = await mbGet(`/release-group?query=${encodeURIComponent(query)}&fmt=json&limit=10`);
groups = data["release-groups"] ?? [];
if (groups.length > 0) break;
}
if (groups.length === 0) return null;
// groups arrive in MB relevance order; keep that as the tiebreak (reduce only switches on
// a strictly better rank, so equal-ranked earlier hits win).
const g = groups.reduce((best, cur) =>
albumPreferenceRank(cur) < albumPreferenceRank(best) ? cur : best);
const credit = g["artist-credit"]?.[0]?.artist ?? {};
return { ...toReleaseGroup(g), artistMbid: credit.id ?? "", artistName: credit.name ?? artist };
}
export type Track = { position: number; title: string; lengthMs: number | null };
export async function getArtistName(mbid: string): Promise<string> {
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[]> {
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;
});
}