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>
This commit is contained in:
AJ Avezzano
2026-04-03 19:25:04 -04:00
parent 7ba4381bff
commit 5e686fc9c4
8 changed files with 223 additions and 26 deletions

View File

@@ -10,7 +10,7 @@
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 )";
"ClickTrack/0.1 ( https://git.avezzano.io/the_og/clicktrack )";
// ─── Rate limiter ─────────────────────────────────────────────────────────────
@@ -60,6 +60,7 @@ export interface MBRecording {
length?: number; // duration in milliseconds
"artist-credit": MBArtistCredit[];
releases?: MBRelease[];
tags?: MBTag[];
score?: number; // search relevance (0100)
}
@@ -79,6 +80,11 @@ export interface MBRelease {
status?: string;
}
export interface MBTag {
name: string;
count: number;
}
export interface MBSearchResult {
created: string;
count: number;
@@ -147,6 +153,100 @@ export async function lookupRecording(mbid: string): Promise<MBRecording> {
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.