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>
This commit is contained in:
164
lib/musicbrainz/client.ts
Normal file
164
lib/musicbrainz/client.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* 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 (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 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>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user