/** * 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://git.avezzano.io/the_og/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[]; tags?: MBTag[]; 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 MBTag { name: string; count: number; } 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; } /** * Looks up a single recording by MBID, requesting artist-credits, releases, * and community tags. Use this when you also want BPM or time-signature tags. */ export async function lookupRecordingWithTags(mbid: string): Promise { const params = new URLSearchParams({ inc: "artist-credits+releases+tags", fmt: "json", }); const url = `${MB_BASE}/recording/${encodeURIComponent(mbid)}?${params}`; const response = await rateLimitedFetch(url); return response.json() as Promise; } /** * Parses a MusicBrainz tag list and extracts a BPM value if present. * * MusicBrainz users tag recordings with strings like: * "bpm: 174", "174 bpm", "bpm:174", "tempo: 174" * * Returns the most-voted BPM value (highest tag count), or null if none found. */ export function extractBpmFromTags(tags: MBTag[]): number | null { const bpmPattern = /(?:bpm|tempo)\s*:?\s*(\d{2,3})|(\d{2,3})\s*bpm/i; let bestBpm: number | null = null; let bestCount = 0; for (const tag of tags) { const match = bpmPattern.exec(tag.name); if (match) { const value = parseInt(match[1] ?? match[2], 10); if (value >= 20 && value <= 400 && tag.count > bestCount) { bestBpm = value; bestCount = tag.count; } } } return bestBpm; } /** * Parses a MusicBrainz tag list and extracts a time signature numerator if present. * * Users tag recordings like "3/4", "5/4", "6/8", "time signature: 3/4". * We only store the numerator since that's what the CTP denominator-agnostic * count-in uses. * * Returns the most-voted numerator, or null if none found. */ export function extractTimeSigFromTags(tags: MBTag[]): number | null { const timeSigPattern = /(?:time\s*signature\s*:?\s*)?(\d{1,2})\/(\d{1,2})/i; let bestNum: number | null = null; let bestCount = 0; for (const tag of tags) { const match = timeSigPattern.exec(tag.name); if (match) { const numerator = parseInt(match[1], 10); if (numerator >= 1 && numerator <= 32 && tag.count > bestCount) { bestNum = numerator; bestCount = tag.count; } } } return bestNum; } /** * Returns a normalised recording object suitable for passing to `upsertSong`. * Includes BPM and time-signature from community tags when available. */ export async function getMusicBrainzRecording(mbid: string): Promise<{ title: string; artist: string; duration_seconds: number | null; bpm: number | null; timeSigNum: number | null; }> { const rec = await lookupRecordingWithTags(mbid); return { title: rec.title, artist: formatArtistCredit(rec["artist-credit"]), duration_seconds: mbDurationToSeconds(rec.length), bpm: rec.tags ? extractBpmFromTags(rec.tags) : null, timeSigNum: rec.tags ? extractTimeSigFromTags(rec.tags) : null, }; } /** * 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, })); }