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 };