Files
clicktrack/lib/musicbrainz/client.ts
AJ Avezzano 5e686fc9c4 feat: MusicBrainz BPM enrichment + improved AI prompts
- lookupRecordingWithTags, extractBpmFromTags, extractTimeSigFromTags, getMusicBrainzRecording added to MB client
- upsertSong preserves existing BPM via COALESCE on conflict
- updateSongBpm helper for async enrichment writes
- AnalysisInput gains confirmedBpm / confirmedTimeSigNum fields
- POST /api/analyze fetches confirmed BPM from DB then MB tags before generation
- All three AI providers use confirmedBpm as authoritative and build enriched userMessage
- POST /api/tracks auto-registration now fetches tags via getMusicBrainzRecording
- Updated User-Agent and MB client fallback URL to Gitea

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 19:25:04 -04:00

265 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<Response> {
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 (0100)
}
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 (1100, default 25)
* @param offset Pagination offset
*/
export async function searchRecordings(
q: string,
limit = 25,
offset = 0
): Promise<MBSearchResult> {
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<MBSearchResult>;
}
/**
* Looks up a single recording by its MusicBrainz ID.
* Includes artist-credit and release information.
*/
export async function lookupRecording(mbid: string): Promise<MBRecording> {
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<MBRecording>;
}
/**
* 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<MBRecording> {
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<MBRecording>;
}
/**
* 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,
}));
}