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 { 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 { 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 { 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 { return cached(`rg-search:${normKey(artist)}|${normKey(album)}`, CACHE_TTL.WEEK, () => _searchReleaseGroup(artist, album), ); } async function _searchReleaseGroup(artist: string, album: string): Promise { 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 { 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 { 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; }); }