Files
KeySelector/server/db.js
T

228 lines
7.6 KiB
JavaScript

const { Pool } = require('pg');
const fs = require('fs');
const path = require('path');
let pool = null;
let isConnected = false;
function getPool() {
if (pool) return pool;
const config = process.env.DATABASE_URL
? { connectionString: process.env.DATABASE_URL }
: {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'postgres',
database: process.env.DB_NAME || 'keyselector',
};
pool = new Pool(config);
pool.on('error', (err) => {
console.error('[PostgreSQL] Unexpected pool error:', err.message);
});
return pool;
}
async function initDb() {
try {
const p = getPool();
const client = await p.connect();
isConnected = true;
console.log('[PostgreSQL] Successfully connected to database.');
const schemaPath = path.join(__dirname, '../db/schema.sql');
if (fs.existsSync(schemaPath)) {
const sql = fs.readFileSync(schemaPath, 'utf8');
await client.query(sql);
console.log('[PostgreSQL] Schema and seed data verified/applied.');
}
client.release();
} catch (err) {
isConnected = false;
console.warn('[PostgreSQL] Database connection failed:', err.message);
console.warn('[PostgreSQL] Running with in-memory fallback until DB is available.');
}
}
// In-memory fallback if Postgres is temporarily down
const memoryTracks = new Map();
async function query(text, params = []) {
if (!isConnected) {
return { rows: [] };
}
return getPool().query(text, params);
}
// Find track by artist and title (case insensitive)
async function findTrack(artist, title) {
if (!artist || !title) return null;
if (isConnected) {
try {
const res = await getPool().query(
`SELECT * FROM tracks
WHERE LOWER(artist) = LOWER($1) AND LOWER(title) = LOWER($2)
LIMIT 1`,
[artist.trim(), title.trim()]
);
if (res.rows.length > 0) return res.rows[0];
} catch (e) {
console.error('[DB] findTrack error:', e.message);
}
}
const key = `${artist.trim().toLowerCase()} - ${title.trim().toLowerCase()}`;
return memoryTracks.get(key) || null;
}
// Save or update track
async function saveTrack({ title, artist, camelot_key, bpm, music_key, cover_url, preview_url, tags = [], is_corrected = false, source = 'manual' }) {
if (!title || !artist || !camelot_key || !bpm) return null;
if (isConnected) {
try {
const res = await getPool().query(
`INSERT INTO tracks (title, artist, camelot_key, bpm, music_key, cover_url, preview_url, tags, is_corrected, source, play_count, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 1, CURRENT_TIMESTAMP)
ON CONFLICT (artist, title) DO UPDATE
SET play_count = tracks.play_count + 1,
cover_url = COALESCE(EXCLUDED.cover_url, tracks.cover_url),
preview_url = COALESCE(EXCLUDED.preview_url, tracks.preview_url),
camelot_key = CASE WHEN tracks.is_corrected THEN tracks.camelot_key ELSE EXCLUDED.camelot_key END,
bpm = CASE WHEN tracks.is_corrected THEN tracks.bpm ELSE EXCLUDED.bpm END,
music_key = CASE WHEN tracks.is_corrected THEN tracks.music_key ELSE EXCLUDED.music_key END,
updated_at = CURRENT_TIMESTAMP
RETURNING *`,
[title.trim(), artist.trim(), camelot_key, Math.round(bpm), music_key, cover_url, preview_url, tags, is_corrected, source]
);
return res.rows[0];
} catch (e) {
console.error('[DB] saveTrack error:', e.message);
}
}
const track = { title, artist, camelot_key, bpm: Math.round(bpm), music_key, cover_url, preview_url, tags, is_corrected, source };
const key = `${artist.trim().toLowerCase()} - ${title.trim().toLowerCase()}`;
memoryTracks.set(key, track);
return track;
}
// Save explicit user correction
async function correctTrack({ artist, title, camelot_key, bpm, music_key }) {
if (!artist || !title || !camelot_key || !bpm) return null;
if (isConnected) {
try {
const res = await getPool().query(
`INSERT INTO tracks (title, artist, camelot_key, bpm, music_key, is_corrected, updated_at)
VALUES ($1, $2, $3, $4, $5, TRUE, CURRENT_TIMESTAMP)
ON CONFLICT (artist, title) DO UPDATE
SET camelot_key = EXCLUDED.camelot_key,
bpm = EXCLUDED.bpm,
music_key = EXCLUDED.music_key,
is_corrected = TRUE,
updated_at = CURRENT_TIMESTAMP
RETURNING *`,
[title.trim(), artist.trim(), camelot_key, Math.round(bpm), music_key]
);
return res.rows[0];
} catch (e) {
console.error('[DB] correctTrack error:', e.message);
}
}
const existing = await findTrack(artist, title) || { title, artist };
existing.camelot_key = camelot_key;
existing.bpm = Math.round(bpm);
existing.music_key = music_key;
existing.is_corrected = true;
const key = `${artist.trim().toLowerCase()} - ${title.trim().toLowerCase()}`;
memoryTracks.set(key, existing);
return existing;
}
// Get suggestions matching compatible camelot keys and BPM range
async function getSuggestions(compatibleKeys = [], minBpm = 80, maxBpm = 180, limit = 10, excludeArtist = '', excludeTitle = '') {
if (isConnected) {
try {
const res = await getPool().query(
`SELECT id, title, artist, camelot_key, bpm, music_key, cover_url, preview_url, tags, play_count
FROM tracks
WHERE camelot_key = ANY($1)
AND bpm BETWEEN $2 AND $3
AND NOT (LOWER(artist) = LOWER($4) AND LOWER(title) = LOWER($5))
ORDER BY play_count DESC, id DESC
LIMIT $6`,
[compatibleKeys, Math.round(minBpm), Math.round(maxBpm), excludeArtist, excludeTitle, limit]
);
return res.rows;
} catch (e) {
console.error('[DB] getSuggestions error:', e.message);
}
}
// Fallback search in memory
const results = [];
for (const track of memoryTracks.values()) {
if (compatibleKeys.includes(track.camelot_key) && track.bpm >= minBpm && track.bpm <= maxBpm) {
if (excludeArtist && track.artist.toLowerCase() === excludeArtist.toLowerCase()) continue;
results.push(track);
if (results.length >= limit) break;
}
}
return results;
}
// Search tracks by query
async function searchTracks(queryStr, limit = 10) {
if (!queryStr) return [];
const term = `%${queryStr.trim().toLowerCase()}%`;
if (isConnected) {
try {
const res = await getPool().query(
`SELECT id, title, artist, camelot_key, bpm, music_key, cover_url, is_corrected
FROM tracks
WHERE LOWER(title) LIKE $1
OR LOWER(artist) LIKE $1
OR LOWER(artist || ' ' || title) LIKE $1
OR LOWER(title || ' ' || artist) LIKE $1
ORDER BY is_corrected DESC, play_count DESC
LIMIT $2`,
[term, limit]
);
return res.rows;
} catch (e) {
console.error('[DB] searchTracks error:', e.message);
}
}
const results = [];
const q = queryStr.toLowerCase();
for (const track of memoryTracks.values()) {
const combined1 = `${track.artist} ${track.title}`.toLowerCase();
const combined2 = `${track.title} ${track.artist}`.toLowerCase();
if (track.title.toLowerCase().includes(q) || track.artist.toLowerCase().includes(q) || combined1.includes(q) || combined2.includes(q)) {
results.push(track);
if (results.length >= limit) break;
}
}
return results;
}
module.exports = {
initDb,
query,
findTrack,
saveTrack,
correctTrack,
getSuggestions,
searchTracks,
isDbConnected: () => isConnected
};