Your music, beautifully tracked. All yours. (coming soon) teal.fm
teal-fm atproto
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514/** * MBID resolution functions for the evaluation harness. * * Key design: every function that returns an MBID also returns its *source* * (MBIDSource), so callers can decide whether it is safe to use as ground * truth. Only "lastfm_track_info", "lastfm_search", and "track_data" are * independent of MusicBrainz search; "musicbrainz_search" is circular. */
import { MUSICBRAINZ_BASE_URL, USER_AGENT, RATE_LIMIT_DELAY, type APIMetrics, type MBIDResult, type MBIDSource,} from "./types.js";import { cleanTrackName, cleanArtistName,} from "../../apps/amethyst/lib/musicbrainzCleaner.js";import { escapeLucene } from "../../apps/amethyst/lib/musicbrainzSearchUtils.js";import type { LastFMCache } from "./evaluate-lastfm-cache.js";import { generateSignature } from "./auth.js";import { getTrackCorrection, searchTracksOnLastFM,} from "./lastfm-api.js";
// ── MusicBrainz search (source: "musicbrainz_search") ─────────────────
/** * Search MusicBrainz for a recording MBID using multiple strategies. * Previously named getMBIDViaISRC (misleading -- no ISRC logic). * * WARNING: Results from this function are search-derived and MUST NOT * be used as ground truth for evaluating search quality (circular). */async function searchMusicBrainzForMBID( track: string, artist: string,): Promise<string | null> { const cleanedTrack = cleanTrackName(track); const cleanedArtist = cleanArtistName(artist);
// Strategy 1: Exact match with cleaned names if (cleanedTrack && cleanedArtist) { const q = `recording:"${escapeLucene(cleanedTrack)}" AND artist:"${escapeLucene(cleanedArtist)}"`; const mbid = await tryMusicBrainzSearch(q, cleanedTrack, cleanedArtist); if (mbid) return mbid; }
// Strategy 2: Exact match with original names const q2 = `recording:"${escapeLucene(track)}" AND artist:"${escapeLucene(artist)}"`; let mbid = await tryMusicBrainzSearch(q2, track, artist); if (mbid) return mbid;
// Strategy 3: Fuzzy match const searchTrack = cleanedTrack || track; const searchArtist = cleanedArtist || artist; const trackWords = searchTrack.split(" "); const artistWords = searchArtist.split(" ");
if (trackWords.length > 1) { const fuzzyTrackQuery = `recording:"${escapeLucene(searchTrack)}"~3`; const fuzzyArtistQuery = artistWords.length > 1 ? `artist:"${escapeLucene(searchArtist)}"~3` : `artist:${escapeLucene(searchArtist)}~`; mbid = await tryMusicBrainzSearch( `${fuzzyTrackQuery} AND ${fuzzyArtistQuery}`, searchTrack, searchArtist, ); } else { mbid = await tryMusicBrainzSearch( `recording:${escapeLucene(searchTrack)}~ AND artist:${escapeLucene(searchArtist)}~`, searchTrack, searchArtist, ); } if (mbid) return mbid;
// Strategy 4: Partial match (word-by-word) mbid = await tryMusicBrainzSearch( `recording:${escapeLucene(searchTrack)} AND artist:${escapeLucene(searchArtist)}`, searchTrack, searchArtist, ); if (mbid) return mbid;
// Strategy 5: Artist-only search mbid = await tryMusicBrainzSearch( `artist:"${escapeLucene(searchArtist)}"`, searchTrack, searchArtist, true, ); return mbid;}
/** Helper: try a single MusicBrainz search query. */async function tryMusicBrainzSearch( query: string, originalTrack: string, originalArtist: string, checkISRC: boolean = false,): Promise<string | null> { try { await new Promise((resolve) => setTimeout(resolve, RATE_LIMIT_DELAY)); const url = `${MUSICBRAINZ_BASE_URL}/recording?query=${encodeURIComponent(query)}&limit=10&fmt=json`; const res = await fetch(url, { headers: { "User-Agent": USER_AGENT } });
if (res.ok) { const data = await res.json(); if (data.recordings?.length > 0) { for (const recording of data.recordings) { if (checkISRC) { const recUrl = `${MUSICBRAINZ_BASE_URL}/recording/${recording.id}?inc=isrcs&fmt=json`; await new Promise((resolve) => setTimeout(resolve, RATE_LIMIT_DELAY), ); const recRes = await fetch(recUrl, { headers: { "User-Agent": USER_AGENT }, }); if (recRes.ok) { const recData = await recRes.json(); if (recData.isrcs?.length > 0) return recording.id; } } if (!checkISRC) return recording.id; } return data.recordings[0].id; } } } catch { // Ignore errors, try next strategy } return null;}
// ── MBID validation ────────────────────────────────────────────────────
/** * Validate that an MBID exists in MusicBrainz. * Retries on 503 with exponential backoff. */export async function validateMBID( mbid: string, cache?: LastFMCache, apiMetrics?: APIMetrics,): Promise<boolean> { if (cache) { const cached = cache.getMBIDValidationDetailed(mbid); if (cached !== undefined) { if (cached.httpStatus === 200 || cached.httpStatus === 301 || cached.httpStatus === 404) { return cached.isValid; } } }
const MAX_RETRIES = 3; for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { try { const delay = RATE_LIMIT_DELAY * (attempt === 0 ? 1 : Math.pow(2, attempt)); await new Promise((resolve) => setTimeout(resolve, delay));
const lookupUrl = `${MUSICBRAINZ_BASE_URL}/recording/${mbid}?fmt=json`; const apiCallStart = Date.now(); // Use redirect: "manual" to detect 301 (merged recordings) const res = await fetch(lookupUrl, { headers: { "User-Agent": USER_AGENT }, redirect: "manual", }); const apiCallTime = Date.now() - apiCallStart;
if (apiMetrics) { apiMetrics.musicbrainzCalls++; apiMetrics.totalAPICallTime += apiCallTime; }
if (res.status === 404) { if (cache) cache.setMBIDValidation(mbid, false, 404, "404 Not Found"); return false; }
if (res.status === 301) { // Merged recording -- follow redirect to get canonical MBID // MB redirects: /ws/2/recording/<old>?fmt=json -> /ws/2/recording/<new>?fmt=json const location = res.headers.get("location"); let canonicalMbid: string | undefined; if (location) { const match = location.match(/\/recording\/([0-9a-f-]{36})/); if (match) canonicalMbid = match[1]; } if (!canonicalMbid) { // Fallback: follow the redirect to read the response body await new Promise((resolve) => setTimeout(resolve, RATE_LIMIT_DELAY)); const followRes = await fetch(lookupUrl, { headers: { "User-Agent": USER_AGENT }, redirect: "follow", }); if (apiMetrics) apiMetrics.musicbrainzCalls++; if (followRes.ok) { const data = await followRes.json(); canonicalMbid = data.id; } } if (cache) cache.setMBIDValidation(mbid, true, 301, `merged -> ${canonicalMbid || "unknown"}`, canonicalMbid); return true; }
if (res.ok) { if (cache) cache.setMBIDValidation(mbid, true, 200); return true; } if (res.status === 503) { if (apiMetrics) apiMetrics.musicbrainzRateLimits++; if (attempt < MAX_RETRIES - 1) continue; return true; // conservative fallback, uncached }
if (apiMetrics) apiMetrics.musicbrainzErrors++; if (attempt < MAX_RETRIES - 1) continue; return true; } catch { if (apiMetrics) apiMetrics.musicbrainzErrors++; if (attempt < MAX_RETRIES - 1) continue; return true; } } return true;}
// ── Work-equivalence ───────────────────────────────────────────────────
/** * Fetch the work ID linked to a MusicBrainz recording. */export async function getRecordingWorkId( recordingMbid: string, cache?: LastFMCache, apiMetrics?: APIMetrics,): Promise<string | null> { if (cache) { const cached = cache.getRecordingWork(recordingMbid); if (cached !== undefined) return cached.workId; }
try { await new Promise((resolve) => setTimeout(resolve, RATE_LIMIT_DELAY)); const url = `${MUSICBRAINZ_BASE_URL}/recording/${recordingMbid}?inc=work-rels&fmt=json`; const res = await fetch(url, { headers: { "User-Agent": USER_AGENT } }); if (apiMetrics) apiMetrics.musicbrainzCalls++;
if (!res.ok) { if (res.status === 503 && apiMetrics) apiMetrics.musicbrainzRateLimits++; return null; }
const data = (await res.json()) as any; const workRel = data.relations?.find( (r: any) => r.type === "performance" && r.work, ); const workId = workRel?.work?.id ?? null; const workTitle = workRel?.work?.title ?? null;
if (cache) cache.setRecordingWork(recordingMbid, workId, workTitle); return workId; } catch { return null; }}
/** * Check if two recordings are work-equivalent (same underlying composition). */export async function areRecordingsWorkEquivalent( mbidA: string, mbidB: string, cache?: LastFMCache, apiMetrics?: APIMetrics,): Promise<boolean> { const workA = await getRecordingWorkId(mbidA, cache, apiMetrics); if (!workA) return false; const workB = await getRecordingWorkId(mbidB, cache, apiMetrics); if (!workB) return false; return workA === workB;}
// ── Main MBID resolution ──────────────────────────────────────────────
/** * Get track MBID from Last.fm, with source provenance tracking. * * Returns {mbid, source} where source indicates how the MBID was obtained. * Only MBIDs with source !== "musicbrainz_search" are safe as ground truth. * * Methods tried in order: * 0. track.getCorrection (canonical name) * 1. track.getInfo (Last.fm authenticated) -> source: "lastfm_track_info" * 2. track.search (Last.fm search) -> source: "lastfm_search" * 3. MusicBrainz recording search (if useAdditionalAPIs) -> source: "musicbrainz_search" */export async function getTrackMBID( track: string, artist: string, apiKey: string, apiSecret: string, sessionKey: string, cache?: LastFMCache, useAdditionalAPIs: boolean = false, apiMetrics?: APIMetrics,): Promise<MBIDResult> { // Check cache first if (cache) { const cached = cache.getMBIDWithSource(track, artist); if (cached !== undefined) { return { mbid: cached.mbid, source: cached.source }; } }
// Method 0: Try track.getCorrection first (get canonical name) const correction = await getTrackCorrection( track, artist, apiKey, apiSecret, sessionKey, cache, apiMetrics, ); let searchTrack = track; let searchArtist = artist; if (correction) { searchTrack = correction.track; searchArtist = correction.artist; if (apiMetrics) apiMetrics.lastfmCalls++; }
// Method 1: Try track.getInfo (Last.fm authenticated) if (apiMetrics) apiMetrics.lastfmCalls++; const params: Record<string, string> = { method: "track.getInfo", track: searchTrack, artist: searchArtist, api_key: apiKey, sk: sessionKey, format: "json", };
const sig = await generateSignature(params, apiSecret); params.api_sig = sig;
const queryString = new URLSearchParams(params).toString(); const url = `https://ws.audioscrobbler.com/2.0/?${queryString}`;
try { const res = await fetch(url); if (res.ok) { const data = await res.json(); if (data.track?.mbid) { const mbid = data.track.mbid; if (useAdditionalAPIs) { const isValid = await validateMBID(mbid, cache, apiMetrics); if (isValid) { if (cache) { cache.setMBIDWithSource(track, artist, mbid, "lastfm_track_info"); if (correction) cache.setMBIDWithSource( searchTrack, searchArtist, mbid, "lastfm_track_info", ); } return { mbid, source: "lastfm_track_info" }; } } else { if (cache) { cache.setMBIDWithSource(track, artist, mbid, "lastfm_track_info"); if (correction) cache.setMBIDWithSource( searchTrack, searchArtist, mbid, "lastfm_track_info", ); } return { mbid, source: "lastfm_track_info" }; } }
// Try corrected name from response if (data.track?.name && data.track.name !== searchTrack) { const correctedParams: Record<string, string> = { method: "track.getInfo", track: data.track.name, artist: data.track.artist?.name || searchArtist, api_key: apiKey, sk: sessionKey, format: "json", }; const correctedSig = await generateSignature(correctedParams, apiSecret); correctedParams.api_sig = correctedSig; const correctedUrl = `https://ws.audioscrobbler.com/2.0/?${new URLSearchParams(correctedParams).toString()}`; const correctedRes = await fetch(correctedUrl); if (correctedRes.ok) { const correctedData = await correctedRes.json(); if (correctedData.track?.mbid) { const mbid = correctedData.track.mbid; if (useAdditionalAPIs) { const isValid = await validateMBID(mbid, cache, apiMetrics); if (isValid) { if (cache) cache.setMBIDWithSource( track, artist, mbid, "lastfm_track_info", ); return { mbid, source: "lastfm_track_info" }; } } else { if (cache) cache.setMBIDWithSource( track, artist, mbid, "lastfm_track_info", ); return { mbid, source: "lastfm_track_info" }; } } } } } } catch { // fall through }
// Method 2: Try track.search (Last.fm search) if (apiMetrics) apiMetrics.lastfmCalls++; const searchResults = await searchTracksOnLastFM( searchTrack, searchArtist, apiKey, apiSecret, sessionKey, 10, cache, apiMetrics, ); for (const result of searchResults) { const trackMatch = result.track.toLowerCase() === searchTrack.toLowerCase() || result.track.toLowerCase().includes(searchTrack.toLowerCase()) || searchTrack.toLowerCase().includes(result.track.toLowerCase()); const artistMatch = result.artist.toLowerCase() === searchArtist.toLowerCase() || result.artist.toLowerCase().includes(searchArtist.toLowerCase()) || searchArtist.toLowerCase().includes(result.artist.toLowerCase());
if (trackMatch && artistMatch && result.mbid) { if (useAdditionalAPIs && apiMetrics) { const isValid = await validateMBID(result.mbid, cache, apiMetrics); if (isValid) { if (cache) cache.setMBIDWithSource( track, artist, result.mbid, "lastfm_search", ); return { mbid: result.mbid, source: "lastfm_search" }; } } else { if (cache) cache.setMBIDWithSource( track, artist, result.mbid, "lastfm_search", ); return { mbid: result.mbid, source: "lastfm_search" }; } } }
// Method 3: MusicBrainz search (CIRCULAR -- marked as such) if (useAdditionalAPIs) { const mbidViaMB = await searchMusicBrainzForMBID(track, artist); if (mbidViaMB) { const isValid = await validateMBID(mbidViaMB, cache, apiMetrics); if (isValid) { if (cache) cache.setMBIDWithSource( track, artist, mbidViaMB, "musicbrainz_search", ); return { mbid: mbidViaMB, source: "musicbrainz_search" }; } } }
// No MBID found if (cache) cache.setMBIDWithSource(track, artist, null, null); return { mbid: null, source: null };}