- 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>
265 lines
7.5 KiB
TypeScript
265 lines
7.5 KiB
TypeScript
/**
|
||
* 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 (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<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,
|
||
}));
|
||
}
|