/** * MusicBrainz API client * * Rate limit: 1 request per second per MusicBrainz ToS. * https://musicbrainz.org/doc/MusicBrainz_API * * All responses use the JSON format (?fmt=json). */ const MB_BASE = "https://musicbrainz.org/ws/2"; const USER_AGENT = process.env.MUSICBRAINZ_USER_AGENT ?? "ClickTrack/0.1 ( https://github.com/your-org/clicktrack )"; // ─── Rate limiter ───────────────────────────────────────────────────────────── let lastRequestTime = 0; async function rateLimitedFetch(url: string): Promise { const now = Date.now(); const elapsed = now - lastRequestTime; const minInterval = 1050; // slightly over 1 s to be safe if (elapsed < minInterval) { await new Promise((resolve) => setTimeout(resolve, minInterval - elapsed)); } lastRequestTime = Date.now(); const response = await fetch(url, { headers: { "User-Agent": USER_AGENT, Accept: "application/json", }, }); if (!response.ok) { const body = await response.text().catch(() => ""); throw new MusicBrainzError( `MusicBrainz API error ${response.status}: ${body}`, response.status ); } return response; } export class MusicBrainzError extends Error { constructor(message: string, public readonly statusCode: number) { super(message); this.name = "MusicBrainzError"; } } // ─── Types ──────────────────────────────────────────────────────────────────── export interface MBRecording { id: string; // UUID title: string; length?: number; // duration in milliseconds "artist-credit": MBArtistCredit[]; releases?: MBRelease[]; score?: number; // search relevance (0–100) } export interface MBArtistCredit { name?: string; artist: { id: string; name: string; }; joinphrase?: string; } export interface MBRelease { id: string; title: string; date?: string; status?: string; } export interface MBSearchResult { created: string; count: number; offset: number; recordings: MBRecording[]; } // ─── Helpers ────────────────────────────────────────────────────────────────── /** * Returns a display string for the artist credit, e.g. "The Beatles". */ export function formatArtistCredit(credits: MBArtistCredit[]): string { return credits .map((c) => (c.name ?? c.artist.name) + (c.joinphrase ?? "")) .join("") .trim(); } /** * Converts MusicBrainz millisecond duration to seconds. * Returns null if the value is absent. */ export function mbDurationToSeconds(ms: number | undefined): number | null { return ms != null ? ms / 1000 : null; } // ─── API methods ────────────────────────────────────────────────────────────── /** * Searches for recordings matching a free-text query. * * @param q Free-text search string, e.g. "Bohemian Rhapsody Queen" * @param limit Max results (1–100, default 25) * @param offset Pagination offset */ export async function searchRecordings( q: string, limit = 25, offset = 0 ): Promise { const params = new URLSearchParams({ query: q, limit: String(Math.min(100, Math.max(1, limit))), offset: String(offset), fmt: "json", }); const url = `${MB_BASE}/recording?${params}`; const response = await rateLimitedFetch(url); return response.json() as Promise; } /** * Looks up a single recording by its MusicBrainz ID. * Includes artist-credit and release information. */ export async function lookupRecording(mbid: string): Promise { const params = new URLSearchParams({ inc: "artist-credits+releases", fmt: "json", }); const url = `${MB_BASE}/recording/${encodeURIComponent(mbid)}?${params}`; const response = await rateLimitedFetch(url); return response.json() as Promise; } /** * Convenience function: searches MusicBrainz and returns results normalised * for storage in the `songs` table. */ export async function searchSongs(q: string, limit = 20) { const result = await searchRecordings(q, limit); return result.recordings.map((rec) => ({ mbid: rec.id, title: rec.title, artist: formatArtistCredit(rec["artist-credit"]), duration_seconds: mbDurationToSeconds(rec.length), score: rec.score ?? 0, })); }