- Multi-provider AI analysis (Anthropic, OpenAI, Ollama, Algorithmic) - server-only guards on all provider files; client bundle fix - /settings page with provider status, Ollama model picker, preferences - Song search box on /analyze replacing raw MBID input (debounced, keyboard nav) - Auto-register song via MusicBrainz on POST /api/tracks (no more 404) - Fix WAV duration bug: last section songEnd was double-counting elapsed time - Registry sync comment updated for self-hosted HTTPS git servers Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
143 lines
4.5 KiB
TypeScript
143 lines
4.5 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 = EXCLUDED.acousticbrainz_bpm,
|
|
acousticbrainz_time_sig_num = EXCLUDED.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,
|
|
]
|
|
);
|
|
}
|
|
|
|
// ─── 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];
|
|
}
|