import { DatabaseSync } from "node:sqlite"; import * as fs from "node:fs"; import * as path from "node:path"; import { DEFAULT_TOPICS, SEED_VIDEOS } from "./seed_data.js"; export function createDatabase(dbPath) { const dir = path.dirname(dbPath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } const db = new DatabaseSync(dbPath); // Initialize schema db.exec(` CREATE TABLE IF NOT EXISTS topics ( id TEXT PRIMARY KEY, name TEXT NOT NULL, type TEXT NOT NULL DEFAULT 'topic', query TEXT NOT NULL, active INTEGER DEFAULT 1, weight REAL DEFAULT 1.0, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS videos ( id TEXT PRIMARY KEY, title TEXT NOT NULL, channel TEXT NOT NULL, channel_id TEXT, thumbnail TEXT, duration TEXT, views TEXT, published_at TEXT, published_relative TEXT, topic_id TEXT, description TEXT, url TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS seen_videos ( video_id TEXT PRIMARY KEY, seen_at TEXT NOT NULL, batch_id TEXT ); CREATE TABLE IF NOT EXISTS feedback ( video_id TEXT PRIMARY KEY, channel TEXT, topic_id TEXT, action TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS nopes ( id TEXT PRIMARY KEY, type TEXT NOT NULL, target TEXT NOT NULL, reason TEXT, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS watchlist ( video_id TEXT PRIMARY KEY, title TEXT NOT NULL, channel TEXT NOT NULL, thumbnail TEXT, duration TEXT, url TEXT NOT NULL, topic_id TEXT, watched INTEGER DEFAULT 0, notes TEXT, added_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); `); // Populate default settings if missing const getSettingStmt = db.prepare("SELECT value FROM settings WHERE key = ?"); const setSettingStmt = db.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)"); if (!getSettingStmt.get("recency_bias")) { setSettingStmt.run("recency_bias", "recent"); // recent, fresh, ultra_fresh, broad, any } if (!getSettingStmt.get("batch_size")) { setSettingStmt.run("batch_size", "12"); } // Populate default topics if empty const countTopics = db.prepare("SELECT COUNT(*) AS count FROM topics").get(); if (countTopics.count === 0) { const insertTopic = db.prepare(` INSERT INTO topics (id, name, type, query, active, weight, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) `); const now = new Date().toISOString(); for (const t of DEFAULT_TOPICS) { insertTopic.run(t.id, t.name, t.type, t.query, t.active, t.weight, now); } } // Populate seed videos if empty const countVideos = db.prepare("SELECT COUNT(*) AS count FROM videos").get(); if (countVideos.count === 0) { const insertVideo = db.prepare(` INSERT OR REPLACE INTO videos (id, title, channel, channel_id, thumbnail, duration, views, published_at, published_relative, topic_id, description, url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); for (const v of SEED_VIDEOS) { insertVideo.run( v.id, v.title, v.channel, v.channel_id || "", v.thumbnail || `https://i.ytimg.com/vi/${v.id}/hqdefault.jpg`, v.duration || "", v.views || "", v.published_at || new Date().toISOString(), v.published_relative || "recent", v.topic_id || "homelab-selfhost", v.description || "", v.url || `https://www.youtube.com/watch?v=${v.id}` ); } } return { rawDb: db, // Topics API getTopics() { return db.prepare("SELECT * FROM topics ORDER BY weight DESC, name ASC").all(); }, getActiveTopics() { return db.prepare("SELECT * FROM topics WHERE active = 1 ORDER BY weight DESC").all(); }, getTopicById(id) { return db.prepare("SELECT * FROM topics WHERE id = ?").get(id); }, addTopic(topic) { const now = new Date().toISOString(); const id = topic.id || "topic-" + Date.now() + "-" + Math.random().toString(36).slice(2, 6); const stmt = db.prepare(` INSERT INTO topics (id, name, type, query, active, weight, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) `); stmt.run( id, topic.name, topic.type || "topic", topic.query || topic.name, topic.active ?? 1, topic.weight ?? 1.0, now ); return this.getTopicById(id); }, updateTopic(id, updates) { const topic = this.getTopicById(id); if (!topic) return null; const name = updates.name !== undefined ? updates.name : topic.name; const type = updates.type !== undefined ? updates.type : topic.type; const query = updates.query !== undefined ? updates.query : topic.query; const active = updates.active !== undefined ? (updates.active ? 1 : 0) : topic.active; const weight = updates.weight !== undefined ? Number(updates.weight) : topic.weight; db.prepare(` UPDATE topics SET name = ?, type = ?, query = ?, active = ?, weight = ? WHERE id = ? `).run(name, type, query, active, weight, id); return this.getTopicById(id); }, deleteTopic(id) { const info = db.prepare("DELETE FROM topics WHERE id = ?").run(id); return info.changes > 0; }, // Videos upsertVideo(v) { const stmt = db.prepare(` INSERT INTO videos (id, title, channel, channel_id, thumbnail, duration, views, published_at, published_relative, topic_id, description, url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET title = excluded.title, channel = excluded.channel, channel_id = COALESCE(excluded.channel_id, videos.channel_id), thumbnail = excluded.thumbnail, duration = excluded.duration, views = excluded.views, published_at = COALESCE(excluded.published_at, videos.published_at), published_relative = COALESCE(excluded.published_relative, videos.published_relative), topic_id = COALESCE(excluded.topic_id, videos.topic_id), description = COALESCE(excluded.description, videos.description), url = excluded.url `); stmt.run( v.id, v.title, v.channel, v.channel_id || "", v.thumbnail || `https://i.ytimg.com/vi/${v.id}/hqdefault.jpg`, v.duration || "", v.views || "", v.published_at || new Date().toISOString(), v.published_relative || "recent", v.topic_id || "", v.description || "", v.url || `https://www.youtube.com/watch?v=${v.id}` ); }, getVideoById(id) { return db.prepare("SELECT * FROM videos WHERE id = ?").get(id); }, getAllVideos() { return db.prepare("SELECT * FROM videos").all(); }, // Seen tracking (No Repeats) recordSeenVideos(videoIds, batchId = null) { const now = new Date().toISOString(); const stmt = db.prepare(` INSERT OR REPLACE INTO seen_videos (video_id, seen_at, batch_id) VALUES (?, ?, ?) `); for (const id of videoIds) { stmt.run(id, now, batchId); } }, getSeenVideoIds() { const rows = db.prepare("SELECT video_id FROM seen_videos").all(); return new Set(rows.map(r => r.video_id)); }, getSeenCount() { const res = db.prepare("SELECT COUNT(*) as count FROM seen_videos").get(); return res.count; }, clearSeenHistory() { db.prepare("DELETE FROM seen_videos").run(); return true; }, // Feedback & Steering recordFeedback(videoId, action, meta = {}) { const now = new Date().toISOString(); const video = this.getVideoById(videoId) || {}; const channel = meta.channel || video.channel || ""; const topicId = meta.topic_id || video.topic_id || ""; // Save feedback db.prepare(` INSERT OR REPLACE INTO feedback (video_id, channel, topic_id, action, created_at) VALUES (?, ?, ?, ?, ?) `).run(videoId, channel, topicId, action, now); // Apply steering adjustments to topics if (topicId) { const topic = this.getTopicById(topicId); if (topic) { let delta = 0; if (action === "thumbs_up") delta = 0.25; if (action === "thumbs_down") delta = -0.2; if (action === "big_nope") delta = -0.4; const newWeight = Math.max(0.1, Math.min(3.0, (topic.weight || 1.0) + delta)); db.prepare("UPDATE topics SET weight = ? WHERE id = ?").run(newWeight, topicId); } } // If Big Nope, also automatically add channel & video to nopes table if (action === "big_nope") { if (channel) { this.addNope("channel", channel, `Big Nope on video: ${videoId}`); } this.addNope("video", videoId, `Direct Big Nope on video`); } return { success: true, action, videoId, channel, topicId }; }, getFeedback() { return db.prepare("SELECT * FROM feedback ORDER BY created_at DESC").all(); }, getFeedbackSummary() { const up = db.prepare("SELECT COUNT(*) as count FROM feedback WHERE action = 'thumbs_up'").get().count; const down = db.prepare("SELECT COUNT(*) as count FROM feedback WHERE action = 'thumbs_down'").get().count; const nope = db.prepare("SELECT COUNT(*) as count FROM feedback WHERE action = 'big_nope'").get().count; return { thumbsUp: up, thumbsDown: down, bigNope: nope }; }, // Nopes (Blocklist) getNopes() { return db.prepare("SELECT * FROM nopes ORDER BY created_at DESC").all(); }, isNoped(videoId, channel = "", title = "") { const videoNope = db.prepare("SELECT 1 FROM nopes WHERE type = 'video' AND target = ?").get(videoId); if (videoNope) return true; if (channel) { const channelNope = db.prepare("SELECT 1 FROM nopes WHERE type = 'channel' AND LOWER(target) = LOWER(?)").get(channel); if (channelNope) return true; } // Check keyword nopes const keywordNopes = db.prepare("SELECT target FROM nopes WHERE type = 'keyword'").all(); const lowerTitle = (title || "").toLowerCase(); for (const k of keywordNopes) { if (lowerTitle.includes(k.target.toLowerCase())) return true; } return false; }, addNope(type, target, reason = "") { const id = "nope-" + Date.now() + "-" + Math.random().toString(36).slice(2, 6); const now = new Date().toISOString(); const existing = db.prepare("SELECT id FROM nopes WHERE type = ? AND LOWER(target) = LOWER(?)").get(type, target); if (existing) return existing; db.prepare(` INSERT INTO nopes (id, type, target, reason, created_at) VALUES (?, ?, ?, ?, ?) `).run(id, type, target, reason, now); return { id, type, target, reason, created_at: now }; }, removeNope(id) { const info = db.prepare("DELETE FROM nopes WHERE id = ?").run(id); return info.changes > 0; }, // Watchlist API (Independent & Preserved across regeneration) getWatchlist(filter = "all") { let query = "SELECT * FROM watchlist"; if (filter === "unwatched") { query += " WHERE watched = 0"; } else if (filter === "watched") { query += " WHERE watched = 1"; } query += " ORDER BY added_at DESC"; return db.prepare(query).all(); }, getWatchlistIds() { const rows = db.prepare("SELECT video_id FROM watchlist").all(); return new Set(rows.map(r => r.video_id)); }, addToWatchlist(video) { const now = new Date().toISOString(); db.prepare(` INSERT INTO watchlist (video_id, title, channel, thumbnail, duration, url, topic_id, watched, notes, added_at) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?) ON CONFLICT(video_id) DO UPDATE SET title = excluded.title, channel = excluded.channel, thumbnail = excluded.thumbnail, duration = excluded.duration, url = excluded.url, topic_id = COALESCE(excluded.topic_id, watchlist.topic_id) `).run( video.id || video.video_id, video.title, video.channel, video.thumbnail || `https://i.ytimg.com/vi/${video.id || video.video_id}/hqdefault.jpg`, video.duration || "", video.url || `https://www.youtube.com/watch?v=${video.id || video.video_id}`, video.topic_id || "", video.notes || "", now ); return db.prepare("SELECT * FROM watchlist WHERE video_id = ?").get(video.id || video.video_id); }, removeFromWatchlist(videoId) { const info = db.prepare("DELETE FROM watchlist WHERE video_id = ?").run(videoId); return info.changes > 0; }, toggleWatchlistWatched(videoId) { const item = db.prepare("SELECT watched FROM watchlist WHERE video_id = ?").get(videoId); if (!item) return null; const nextState = item.watched === 1 ? 0 : 1; db.prepare("UPDATE watchlist SET watched = ? WHERE video_id = ?").run(nextState, videoId); return db.prepare("SELECT * FROM watchlist WHERE video_id = ?").get(videoId); }, // Settings getSettings() { const rows = db.prepare("SELECT key, value FROM settings").all(); const settings = {}; for (const r of rows) settings[r.key] = r.value; return settings; }, setSetting(key, value) { db.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)").run(key, String(value)); return { key, value }; } }; }