import Database from 'better-sqlite3'; import { readFileSync, mkdirSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { randomUUID } from 'node:crypto'; const __dirname = dirname(fileURLToPath(import.meta.url)); let db; export function initDb(dbPath) { const abs = resolve(dbPath); mkdirSync(dirname(abs), { recursive: true }); db = new Database(abs); db.pragma('journal_mode = WAL'); db.pragma('foreign_keys = ON'); const schema = readFileSync(resolve(__dirname, 'schema.sql'), 'utf8'); db.exec(schema); runMigrations(db); return db; } export function getDb() { if (!db) throw new Error('DB not initialized'); return db; } // Idempotent migrations for upgrading older DBs that pre-date a column. function runMigrations(db) { const stationCols = new Set(db.prepare("PRAGMA table_info(stations)").all().map((c) => c.name)); if (!stationCols.has('uuid')) { db.exec('ALTER TABLE stations ADD COLUMN uuid TEXT'); } if (!stationCols.has('category')) { db.exec('ALTER TABLE stations ADD COLUMN category TEXT'); } if (!stationCols.has('image_path')) { db.exec('ALTER TABLE stations ADD COLUMN image_path TEXT'); } if (!stationCols.has('image_source')) { db.exec('ALTER TABLE stations ADD COLUMN image_source TEXT'); } const streamCols = new Set(db.prepare("PRAGMA table_info(streams)").all().map((c) => c.name)); if (!streamCols.has('uuid')) { db.exec('ALTER TABLE streams ADD COLUMN uuid TEXT'); } // Backfill UUIDs. For RB stations, prefer the existing source_ref so the // public UUID matches the upstream Radio-Browser stationuuid. const setStationUuid = db.prepare('UPDATE stations SET uuid = ? WHERE id = ?'); for (const row of db.prepare("SELECT id, source, source_ref FROM stations WHERE uuid IS NULL OR uuid = ''").all()) { const u = (row.source === 'radiobrowser' && row.source_ref) ? row.source_ref : randomUUID(); setStationUuid.run(u, row.id); } const setStreamUuid = db.prepare('UPDATE streams SET uuid = ? WHERE id = ?'); for (const row of db.prepare("SELECT id FROM streams WHERE uuid IS NULL OR uuid = ''").all()) { setStreamUuid.run(randomUUID(), row.id); } db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_stations_uuid ON stations(uuid)'); db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_streams_uuid ON streams(uuid)'); db.exec('CREATE INDEX IF NOT EXISTS idx_stations_category ON stations(category)'); // station_plays gained session/listen-time aggregates so the leaderboard // can rank by actual playtime, not just play-button taps. const playCols = new Set(db.prepare("PRAGMA table_info(station_plays)").all().map((c) => c.name)); if (!playCols.has('sessions')) { db.exec('ALTER TABLE station_plays ADD COLUMN sessions INTEGER NOT NULL DEFAULT 0'); } if (!playCols.has('total_play_ms')) { db.exec('ALTER TABLE station_plays ADD COLUMN total_play_ms INTEGER NOT NULL DEFAULT 0'); } // Multi-user kiosk: per-user metadata + designation of "main" identity. const userCols = new Set(db.prepare("PRAGMA table_info(users)").all().map((c) => c.name)); if (!userCols.has('is_main')) { db.exec('ALTER TABLE users ADD COLUMN is_main INTEGER NOT NULL DEFAULT 0'); } if (!userCols.has('avatar_color')) { db.exec('ALTER TABLE users ADD COLUMN avatar_color TEXT'); } if (!userCols.has('avatar_emoji')) { db.exec('ALTER TABLE users ADD COLUMN avatar_emoji TEXT'); } db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_only_one_main ON users(is_main) WHERE is_main = 1'); // Cross-client stream sync: when a room enters 'play', record the wall-clock // moment so every client can target the same playhead. const stateCols = new Set(db.prepare("PRAGMA table_info(room_state)").all().map((c) => c.name)); if (!stateCols.has('started_at')) { db.exec('ALTER TABLE room_state ADD COLUMN started_at INTEGER'); } }