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:
@@ -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 (0–100)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user