Files
clicktrack/lib/musicbrainz/client.ts
AJ Avezzano 5b772655c6 feat: initial scaffold for ClickTrack monorepo
Full self-hosted click track generator for cover bands.

Core technical pieces implemented:
- CTP (Click Track Protocol) TypeScript schema, Zod validator, and WAV
  renderer (44.1 kHz, 16-bit PCM, accented downbeats, ramp sections)
- MusicBrainz API client with 1 req/s rate limiting
- PostgreSQL schema (songs, tempo_maps, registry_sync_log) with triggers
- Git registry sync logic (clone/pull → validate CTP → upsert DB)
- Next.js 14 App Router: search page, track page, API routes
  (/api/songs, /api/tracks, /api/generate)
- UI components: SearchBar, SongResult, TempoMapEditor, ClickTrackPlayer
  (Web Audio API in-browser playback + WAV download)
- Docker Compose stack: app + postgres + redis + nginx + registry-sync
- Multi-stage Dockerfile with standalone Next.js output
- .env.example documenting all configuration variables
- README with setup instructions, CTP format spec, and API reference

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 11:14:46 -04:00

165 lines
4.7 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://github.com/your-org/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[];
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 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>;
}
/**
* 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,
}));
}