228 lines
7.6 KiB
JavaScript
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
|
|
};
|