bdbf9d237e
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>
171 lines
7.1 KiB
TypeScript
171 lines
7.1 KiB
TypeScript
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;
|
||
});
|
||
}
|