/* ============================================================================ * db.js — libsql (embedded SQLite) database setup and query helpers * * The DB file lives at $DB_PATH (mounted volume in Docker so data persists * across container rebuilds). libsql is an open-source SQLite fork by Turso * with WAL-by-default for concurrent reads. * * Schema (three small tables): * users — one row per fingerprint; tracks last app version seen * playlists — one row per fingerprint; full playlist JSON blob * video_history — one row per (fingerprint, video_id); recent 200 entries * ========================================================================== */ import { createClient } from '@libsql/client'; import { join } from 'node:path'; const DB_PATH = process.env.DB_PATH || join(process.cwd(), 'data', 'ytplayer.db'); export const db = createClient({ url: 'file:' + DB_PATH, }); // ---- Schema ---------------------------------------------------------------- export async function initDb() { await db.executeMultiple(` PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA foreign_keys = ON; CREATE TABLE IF NOT EXISTS users ( fingerprint TEXT PRIMARY KEY, last_version TEXT, last_seen INTEGER NOT NULL DEFAULT (unixepoch()) ); CREATE TABLE IF NOT EXISTS playlists ( fingerprint TEXT PRIMARY KEY, data TEXT NOT NULL DEFAULT '[]', updated_at INTEGER NOT NULL DEFAULT (unixepoch()) ); CREATE TABLE IF NOT EXISTS video_history ( fingerprint TEXT NOT NULL, video_id TEXT NOT NULL, title TEXT, channel TEXT, thumbnail TEXT, duration REAL DEFAULT 0, accessed_at INTEGER NOT NULL DEFAULT (unixepoch()), PRIMARY KEY (fingerprint, video_id) ); CREATE INDEX IF NOT EXISTS idx_vh_fp_time ON video_history (fingerprint, accessed_at DESC); CREATE TABLE IF NOT EXISTS profiles ( name TEXT PRIMARY KEY, data TEXT NOT NULL DEFAULT '{}', created_at INTEGER NOT NULL DEFAULT (unixepoch()), updated_at INTEGER NOT NULL DEFAULT (unixepoch()) ); `); } // ---- Profiles (named cross-device sync; the name acts as the passkey) ------ // Insert a new profile. Returns false when the name is already taken. export async function createProfile(name, dataJson) { try { await db.execute({ sql: `INSERT INTO profiles (name, data, created_at, updated_at) VALUES (?, ?, unixepoch(), unixepoch())`, args: [name, dataJson], }); return true; } catch (err) { const msg = String(err && err.message || err); if (msg.includes('UNIQUE') || msg.includes('PRIMARY KEY')) return false; throw err; } } export async function getProfile(name) { const r = await db.execute({ sql: 'SELECT data, updated_at FROM profiles WHERE name = ?', args: [name], }); const row = r.rows[0]; if (!row) return null; return { data: row.data, updatedAt: Number(row.updated_at) }; } // Update an EXISTING profile's data blob. Returns false when it doesn't exist // (saving must never implicitly create a profile — creation is a deliberate, // uniqueness-checked act). export async function saveProfile(name, dataJson) { const r = await db.execute({ sql: 'UPDATE profiles SET data = ?, updated_at = unixepoch() WHERE name = ?', args: [dataJson, name], }); return (r.rowsAffected || 0) > 0; } // ---- Helpers --------------------------------------------------------------- // Upsert the users row and optionally update playlists. export async function upsertUser({ fingerprint, appVersion, playlists }) { await db.execute({ sql: `INSERT INTO users (fingerprint, last_version, last_seen) VALUES (?, ?, unixepoch()) ON CONFLICT(fingerprint) DO UPDATE SET last_version = excluded.last_version, last_seen = excluded.last_seen`, args: [fingerprint, appVersion || null], }); if (Array.isArray(playlists)) { await db.execute({ sql: `INSERT INTO playlists (fingerprint, data, updated_at) VALUES (?, ?, unixepoch()) ON CONFLICT(fingerprint) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`, args: [fingerprint, JSON.stringify(playlists)], }); } } // Record a video access (insert or bump accessed_at). export async function recordVideoAccess(fingerprint, video) { await db.execute({ sql: `INSERT INTO video_history (fingerprint, video_id, title, channel, thumbnail, duration, accessed_at) VALUES (?, ?, ?, ?, ?, ?, unixepoch()) ON CONFLICT(fingerprint, video_id) DO UPDATE SET title = excluded.title, channel = excluded.channel, thumbnail = excluded.thumbnail, duration = excluded.duration, accessed_at = excluded.accessed_at`, args: [ fingerprint, video.id, video.title || null, video.channel || null, video.thumbnail || null, video.duration || 0, ], }); // Keep only the 200 most recent entries per user to avoid unbounded growth await db.execute({ sql: `DELETE FROM video_history WHERE fingerprint = ? AND video_id NOT IN ( SELECT video_id FROM video_history WHERE fingerprint = ? ORDER BY accessed_at DESC LIMIT 200 )`, args: [fingerprint, fingerprint], }); } // Fetch stored data for a fingerprint. export async function getUserData(fingerprint) { const [userRow, plRow, histRows] = await Promise.all([ db.execute({ sql: 'SELECT last_version FROM users WHERE fingerprint = ?', args: [fingerprint] }), db.execute({ sql: 'SELECT data FROM playlists WHERE fingerprint = ?', args: [fingerprint] }), db.execute({ sql: `SELECT video_id AS id, title, channel, thumbnail, duration, accessed_at FROM video_history WHERE fingerprint = ? ORDER BY accessed_at DESC LIMIT 50`, args: [fingerprint], }), ]); const lastVersion = userRow.rows[0]?.last_version || null; const playlists = plRow.rows[0]?.data ? JSON.parse(plRow.rows[0].data) : []; const history = histRows.rows.map((r) => ({ id: r.id, title: r.title, channel: r.channel, thumbnail: r.thumbnail, duration: r.duration, })); return { lastVersion, playlists, history }; }