Files
clicktrack/lib/db/client.ts
AJ Avezzano 5e686fc9c4 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>
2026-04-03 19:25:04 -04:00

163 lines
5.1 KiB
TypeScript

import { Pool, PoolClient, QueryResult, QueryResultRow } from "pg";
// ─── Connection pool ──────────────────────────────────────────────────────────
let pool: Pool | null = null;
function getPool(): Pool {
if (!pool) {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error("DATABASE_URL environment variable is not set.");
}
pool = new Pool({
connectionString,
max: 10,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
});
pool.on("error", (err) => {
console.error("[db] Unexpected pool error:", err);
});
}
return pool;
}
// ─── Query helpers ────────────────────────────────────────────────────────────
export async function query<T extends QueryResultRow = Record<string, unknown>>(
text: string,
params?: unknown[]
): Promise<QueryResult<T>> {
return getPool().query<T>(text, params);
}
/**
* Runs a callback inside a transaction.
* Commits on success, rolls back on any thrown error.
*/
export async function withTransaction<T>(
fn: (client: PoolClient) => Promise<T>
): Promise<T> {
const client = await getPool().connect();
try {
await client.query("BEGIN");
const result = await fn(client);
await client.query("COMMIT");
return result;
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
// ─── Song queries ─────────────────────────────────────────────────────────────
export interface SongRow {
mbid: string;
title: string;
artist: string;
duration_seconds: number | null;
acousticbrainz_bpm: number | null;
acousticbrainz_time_sig_num: number | null;
source: string;
created_at: Date;
updated_at: Date;
}
export async function getSongByMbid(mbid: string): Promise<SongRow | null> {
const { rows } = await query<SongRow>("SELECT * FROM songs WHERE mbid = $1", [mbid]);
return rows[0] ?? null;
}
export async function searchSongs(q: string, limit = 20): Promise<SongRow[]> {
const { rows } = await query<SongRow>(
`SELECT * FROM songs
WHERE to_tsvector('english', title || ' ' || artist) @@ plainto_tsquery('english', $1)
ORDER BY ts_rank(to_tsvector('english', title || ' ' || artist), plainto_tsquery('english', $1)) DESC
LIMIT $2`,
[q, limit]
);
return rows;
}
export async function upsertSong(song: Omit<SongRow, "created_at" | "updated_at">): Promise<void> {
await query(
`INSERT INTO songs (mbid, title, artist, duration_seconds, acousticbrainz_bpm, acousticbrainz_time_sig_num, source)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (mbid) DO UPDATE SET
title = EXCLUDED.title,
artist = EXCLUDED.artist,
duration_seconds = EXCLUDED.duration_seconds,
acousticbrainz_bpm = COALESCE(EXCLUDED.acousticbrainz_bpm, songs.acousticbrainz_bpm),
acousticbrainz_time_sig_num = COALESCE(EXCLUDED.acousticbrainz_time_sig_num, songs.acousticbrainz_time_sig_num),
source = EXCLUDED.source`,
[
song.mbid,
song.title,
song.artist,
song.duration_seconds,
song.acousticbrainz_bpm,
song.acousticbrainz_time_sig_num,
song.source,
]
);
}
/**
* Updates only the BPM and time-signature fields of a song row.
* No-ops if the song doesn't exist.
* Used by the async MusicBrainz tag enrichment path.
*/
export async function updateSongBpm(
mbid: string,
bpm: number | null,
timeSigNum: number | null
): Promise<void> {
await query(
`UPDATE songs
SET acousticbrainz_bpm = COALESCE($2, acousticbrainz_bpm),
acousticbrainz_time_sig_num = COALESCE($3, acousticbrainz_time_sig_num),
updated_at = NOW()
WHERE mbid = $1`,
[mbid, bpm, timeSigNum]
);
}
// ─── Tempo map queries ────────────────────────────────────────────────────────
export interface TempoMapRow {
id: string;
song_mbid: string;
ctp_data: Record<string, unknown>;
contributed_by: string;
verified: boolean;
upvotes: number;
created_at: Date;
updated_at: Date;
}
export async function getTempoMapsForSong(mbid: string): Promise<TempoMapRow[]> {
const { rows } = await query<TempoMapRow>(
`SELECT * FROM tempo_maps WHERE song_mbid = $1
ORDER BY verified DESC, upvotes DESC, created_at ASC`,
[mbid]
);
return rows;
}
export async function insertTempoMap(
map: Pick<TempoMapRow, "song_mbid" | "ctp_data" | "contributed_by">
): Promise<TempoMapRow> {
const { rows } = await query<TempoMapRow>(
`INSERT INTO tempo_maps (song_mbid, ctp_data, contributed_by)
VALUES ($1, $2, $3)
RETURNING *`,
[map.song_mbid, JSON.stringify(map.ctp_data), map.contributed_by]
);
return rows[0];
}