import Database from 'better-sqlite3'; import { mkdirSync, existsSync } from 'fs'; import { dirname } from 'path'; const DB_PATH = process.env.DB_PATH || './data/db/ofapp.db'; const dir = dirname(DB_PATH); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } const db = new Database(DB_PATH); db.pragma('journal_mode = WAL'); db.exec(` CREATE TABLE IF NOT EXISTS auth_config ( user_id TEXT, cookie TEXT, x_bc TEXT, app_token TEXT, x_of_rev TEXT, user_agent TEXT ); CREATE TABLE IF NOT EXISTS download_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT, post_id TEXT, media_id TEXT, media_type TEXT, filename TEXT, downloaded_at TEXT ); CREATE TABLE IF NOT EXISTS download_cursors ( user_id TEXT UNIQUE, cursor TEXT, posts_downloaded INTEGER ); CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT ); CREATE TABLE IF NOT EXISTS media_files ( id INTEGER PRIMARY KEY AUTOINCREMENT, folder TEXT NOT NULL, filename TEXT NOT NULL, type TEXT NOT NULL, size INTEGER NOT NULL, modified REAL NOT NULL, posted_at TEXT, created_at TEXT DEFAULT (datetime('now')), UNIQUE(folder, filename) ); CREATE INDEX IF NOT EXISTS idx_media_folder ON media_files(folder); CREATE INDEX IF NOT EXISTS idx_media_type ON media_files(type); CREATE INDEX IF NOT EXISTS idx_media_modified ON media_files(modified); CREATE INDEX IF NOT EXISTS idx_media_posted_at ON media_files(posted_at); CREATE TABLE IF NOT EXISTS auto_download_users ( user_id TEXT PRIMARY KEY, username TEXT NOT NULL, enabled INTEGER DEFAULT 1, last_run TEXT, created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS auto_scrape_jobs ( id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, url TEXT NOT NULL, folder_name TEXT NOT NULL, config TEXT NOT NULL, enabled INTEGER DEFAULT 1, last_run TEXT, created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS app_users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE COLLATE NOCASE, password_hash TEXT NOT NULL, display_name TEXT DEFAULT '', role TEXT NOT NULL DEFAULT 'user', enabled INTEGER DEFAULT 1, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS user_folder_access ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, folder TEXT NOT NULL, UNIQUE(user_id, folder), FOREIGN KEY (user_id) REFERENCES app_users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS user_route_access ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, route_key TEXT NOT NULL, UNIQUE(user_id, route_key), FOREIGN KEY (user_id) REFERENCES app_users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_user_folder_user ON user_folder_access(user_id); CREATE INDEX IF NOT EXISTS idx_user_route_user ON user_route_access(user_id); CREATE TABLE IF NOT EXISTS forum_sites ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, base_url TEXT DEFAULT '', cookies TEXT DEFAULT '', created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) ); `); // Migration: add posted_at column if missing const cols = db.prepare("PRAGMA table_info(download_history)").all().map((c) => c.name); if (!cols.includes('posted_at')) { db.exec('ALTER TABLE download_history ADD COLUMN posted_at TEXT'); } // Migration: add username, password, cookie_expires_at columns to forum_sites const forumCols = db.prepare("PRAGMA table_info(forum_sites)").all().map((c) => c.name); if (!forumCols.includes('username')) { db.exec("ALTER TABLE forum_sites ADD COLUMN username TEXT DEFAULT ''"); } if (!forumCols.includes('password')) { db.exec("ALTER TABLE forum_sites ADD COLUMN password TEXT DEFAULT ''"); } if (!forumCols.includes('cookie_expires_at')) { db.exec('ALTER TABLE forum_sites ADD COLUMN cookie_expires_at TEXT'); } if (!forumCols.includes('user_agent')) { db.exec("ALTER TABLE forum_sites ADD COLUMN user_agent TEXT DEFAULT ''"); } export function getAuthConfig() { const row = db.prepare('SELECT * FROM auth_config LIMIT 1').get(); return row || null; } export function saveAuthConfig(config) { const del = db.prepare('DELETE FROM auth_config'); const ins = db.prepare( 'INSERT INTO auth_config (user_id, cookie, x_bc, app_token, x_of_rev, user_agent) VALUES (?, ?, ?, ?, ?, ?)' ); const upsert = db.transaction((c) => { del.run(); ins.run(c.user_id, c.cookie, c.x_bc, c.app_token, c.x_of_rev, c.user_agent); }); upsert(config); } export function isMediaDownloaded(mediaId) { const row = db.prepare('SELECT 1 FROM download_history WHERE media_id = ? LIMIT 1').get(String(mediaId)); return !!row; } export function recordDownload(userId, postId, mediaId, mediaType, filename, postedAt) { db.prepare( 'INSERT INTO download_history (user_id, post_id, media_id, media_type, filename, downloaded_at, posted_at) VALUES (?, ?, ?, ?, ?, ?, ?)' ).run(String(userId), String(postId), String(mediaId), mediaType, filename, new Date().toISOString(), postedAt || null); } export function getDownloadHistory(userId) { return db.prepare('SELECT * FROM download_history WHERE user_id = ? ORDER BY downloaded_at DESC').all(String(userId)); } export function saveCursor(userId, cursor, postsDownloaded) { db.prepare( 'INSERT INTO download_cursors (user_id, cursor, posts_downloaded) VALUES (?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET cursor = excluded.cursor, posts_downloaded = excluded.posts_downloaded' ).run(String(userId), cursor, postsDownloaded); } export function getCursor(userId) { return db.prepare('SELECT cursor, posts_downloaded FROM download_cursors WHERE user_id = ?').get(String(userId)) || null; } export function clearCursor(userId) { db.prepare('DELETE FROM download_cursors WHERE user_id = ?').run(String(userId)); } export function getPostDateByFilename(filename) { const row = db.prepare('SELECT posted_at FROM download_history WHERE filename = ? LIMIT 1').get(filename); return row?.posted_at || null; } export function getSetting(key) { const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key); return row ? row.value : null; } export function setSetting(key, value) { db.prepare( 'INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value' ).run(key, value); } export function getAllSettings() { const rows = db.prepare('SELECT key, value FROM settings').all(); const obj = {}; for (const row of rows) obj[row.key] = row.value; return obj; } export function getDownloadStats() { return db.prepare( 'SELECT user_id, COUNT(*) as file_count, MAX(downloaded_at) as last_download FROM download_history GROUP BY user_id' ).all(); } // --- media_files helpers --- const upsertMediaStmt = db.prepare(` INSERT INTO media_files (folder, filename, type, size, modified, posted_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(folder, filename) DO UPDATE SET size = excluded.size, modified = excluded.modified, posted_at = COALESCE(excluded.posted_at, media_files.posted_at) `); export function upsertMediaFile(folder, filename, type, size, modified, postedAt) { upsertMediaStmt.run(folder, filename, type, size, modified, postedAt || null); } export const upsertMediaFileBatch = db.transaction((files) => { for (const f of files) { upsertMediaStmt.run(f.folder, f.filename, f.type, f.size, f.modified, f.postedAt || null); } }); export function removeMediaFile(folder, filename) { db.prepare('DELETE FROM media_files WHERE folder = ? AND filename = ?').run(folder, filename); } export function getMediaFolders() { return db.prepare(` SELECT folder AS name, COUNT(*) AS total, SUM(CASE WHEN type = 'image' THEN 1 ELSE 0 END) AS images, SUM(CASE WHEN type = 'video' THEN 1 ELSE 0 END) AS videos FROM media_files WHERE folder NOT LIKE '\\_%' ESCAPE '\\' GROUP BY folder ORDER BY folder `).all(); } export function getMediaFiles({ folder, folders, type, sort, offset, limit, dateFrom, dateTo, minSize, maxSize, search }) { const conditions = []; const params = []; // Always exclude folders starting with _ conditions.push("folder NOT LIKE '\\_%' ESCAPE '\\'"); if (folder) { conditions.push('folder = ?'); params.push(folder); } else if (folders && folders.length > 0) { conditions.push(`folder IN (${folders.map(() => '?').join(',')})`); params.push(...folders); } if (type && type !== 'all') { conditions.push('type = ?'); params.push(type); } if (dateFrom) { conditions.push("COALESCE(posted_at, datetime(modified / 1000, 'unixepoch')) >= ?"); params.push(dateFrom); } if (dateTo) { conditions.push("COALESCE(posted_at, datetime(modified / 1000, 'unixepoch')) <= ?"); params.push(dateTo + 'T23:59:59'); } if (minSize) { conditions.push('size >= ?'); params.push(parseInt(minSize, 10)); } if (maxSize) { conditions.push('size <= ?'); params.push(parseInt(maxSize, 10)); } if (search) { conditions.push('(filename LIKE ? OR folder LIKE ?)'); params.push(`%${search}%`, `%${search}%`); } const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; const countRow = db.prepare(`SELECT COUNT(*) AS total FROM media_files ${where}`).get(...params); const total = countRow.total; const effectiveLimit = limit || 50; const effectiveOffset = offset || 0; // Equal-mix shuffle: when shuffling with multiple folders, sample equally from each if (sort === 'shuffle') { // Count distinct folders in the result set const folderCountRow = db.prepare( `SELECT COUNT(DISTINCT folder) AS cnt FROM media_files ${where}` ).get(...params); const numFolders = folderCountRow?.cnt || 1; if (numFolders > 1) { // Use ROW_NUMBER to pick equal random samples per folder const perFolder = Math.ceil(effectiveLimit / numFolders); const rows = db.prepare(` WITH ranked AS ( SELECT folder, filename, type, size, modified, posted_at, ROW_NUMBER() OVER (PARTITION BY folder ORDER BY RANDOM()) AS rn FROM media_files ${where} ) SELECT folder, filename, type, size, modified, posted_at FROM ranked WHERE rn <= ? ORDER BY RANDOM() LIMIT ? OFFSET ? `).all(...params, perFolder, effectiveLimit, effectiveOffset); return { total, rows }; } // Single folder or no folder filter — plain random const rows = db.prepare(` SELECT folder, filename, type, size, modified, posted_at FROM media_files ${where} ORDER BY RANDOM() LIMIT ? OFFSET ? `).all(...params, effectiveLimit, effectiveOffset); return { total, rows }; } let orderBy; switch (sort) { case 'oldest': orderBy = "ORDER BY COALESCE(posted_at, datetime(modified / 1000, 'unixepoch')) ASC"; break; case 'largest': orderBy = 'ORDER BY size DESC'; break; case 'smallest': orderBy = 'ORDER BY size ASC'; break; case 'name': orderBy = 'ORDER BY filename ASC'; break; default: orderBy = "ORDER BY COALESCE(posted_at, datetime(modified / 1000, 'unixepoch')) DESC"; } const rows = db.prepare(` SELECT folder, filename, type, size, modified, posted_at FROM media_files ${where} ${orderBy} LIMIT ? OFFSET ? `).all(...params, effectiveLimit, effectiveOffset); return { total, rows }; } export function getAllIndexedFolders() { return db.prepare('SELECT DISTINCT folder FROM media_files').all().map(r => r.folder); } export function removeStaleFiles(folder, existingFilenames) { const rows = db.prepare('SELECT filename FROM media_files WHERE folder = ?').all(folder); const existing = new Set(existingFilenames); const toDelete = rows.filter(r => !existing.has(r.filename)); if (toDelete.length > 0) { const del = db.prepare('DELETE FROM media_files WHERE folder = ? AND filename = ?'); const batch = db.transaction((files) => { for (const f of files) del.run(folder, f.filename); }); batch(toDelete); } return toDelete.length; } export function getMediaFileCount() { return db.prepare('SELECT COUNT(*) AS count FROM media_files').get().count; } export function getNewMediaCount(since) { return db.prepare('SELECT COUNT(*) AS count FROM media_files WHERE created_at > ?').get(since).count; } // --- auto_download_users helpers --- export function getAutoDownloadUsers() { return db.prepare('SELECT * FROM auto_download_users WHERE enabled = 1').all(); } export function addAutoDownloadUser(userId, username) { db.prepare( 'INSERT INTO auto_download_users (user_id, username) VALUES (?, ?) ON CONFLICT(user_id) DO UPDATE SET username = excluded.username, enabled = 1' ).run(String(userId), username); } export function removeAutoDownloadUser(userId) { db.prepare('DELETE FROM auto_download_users WHERE user_id = ?').run(String(userId)); } export function isAutoDownloadUser(userId) { return !!db.prepare('SELECT 1 FROM auto_download_users WHERE user_id = ? AND enabled = 1').get(String(userId)); } export function updateAutoDownloadLastRun(userId) { db.prepare('UPDATE auto_download_users SET last_run = datetime(\'now\') WHERE user_id = ?').run(String(userId)); } // --- auto_scrape_jobs helpers --- export function getAutoScrapeJobs() { return db.prepare('SELECT * FROM auto_scrape_jobs WHERE enabled = 1').all(); } export function addAutoScrapeJob(type, url, folderName, config) { db.prepare( 'INSERT INTO auto_scrape_jobs (type, url, folder_name, config) VALUES (?, ?, ?, ?)' ).run(type, url, folderName, JSON.stringify(config)); } export function removeAutoScrapeJob(id) { db.prepare('DELETE FROM auto_scrape_jobs WHERE id = ?').run(id); } export function updateAutoScrapeLastRun(id) { db.prepare('UPDATE auto_scrape_jobs SET last_run = datetime(\'now\') WHERE id = ?').run(id); } // --- Dashboard / stats helpers --- export function getStorageStats() { return db.prepare(` SELECT folder, COUNT(*) AS file_count, SUM(size) AS total_size, SUM(CASE WHEN type = 'image' THEN 1 ELSE 0 END) AS images, SUM(CASE WHEN type = 'video' THEN 1 ELSE 0 END) AS videos FROM media_files GROUP BY folder ORDER BY SUM(size) DESC `).all(); } export function getTotalStorageSize() { const row = db.prepare('SELECT SUM(size) AS total FROM media_files').get(); return row?.total || 0; } // --- videos tables --- db.exec(` CREATE TABLE IF NOT EXISTS videos ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, description TEXT DEFAULT '', filename TEXT NOT NULL, file_path TEXT NOT NULL, file_size INTEGER NOT NULL DEFAULT 0, duration REAL, width INTEGER, height INTEGER, fps REAL, codec TEXT, bitrate INTEGER, has_audio INTEGER DEFAULT 1, thumbnail_path TEXT, status TEXT DEFAULT 'pending', error_message TEXT, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')), UNIQUE(file_path) ); CREATE TABLE IF NOT EXISTS tags ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE COLLATE NOCASE, created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS video_tags ( video_id INTEGER NOT NULL, tag_id INTEGER NOT NULL, PRIMARY KEY (video_id, tag_id), FOREIGN KEY (video_id) REFERENCES videos(id) ON DELETE CASCADE, FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE ); `); // --- video helpers --- export function insertVideo(data) { const stmt = db.prepare(` INSERT INTO videos (title, description, filename, file_path, file_size, duration, width, height, fps, codec, bitrate, has_audio, thumbnail_path, status, error_message) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); const result = stmt.run( data.title, data.description || '', data.filename, data.file_path, data.file_size || 0, data.duration || null, data.width || null, data.height || null, data.fps || null, data.codec || null, data.bitrate || null, data.has_audio ?? 1, data.thumbnail_path || null, data.status || 'pending', data.error_message || null ); return result.lastInsertRowid; } export function getVideoById(id) { return db.prepare('SELECT * FROM videos WHERE id = ?').get(id) || null; } export function getVideoByPath(filePath) { return db.prepare('SELECT * FROM videos WHERE file_path = ?').get(filePath) || null; } export function updateVideo(id, data) { const fields = []; const values = []; for (const [key, val] of Object.entries(data)) { if (key === 'id') continue; fields.push(`${key} = ?`); values.push(val); } if (fields.length === 0) return; fields.push("updated_at = datetime('now')"); values.push(id); db.prepare(`UPDATE videos SET ${fields.join(', ')} WHERE id = ?`).run(...values); } export function deleteVideoById(id) { db.prepare('DELETE FROM video_tags WHERE video_id = ?').run(id); db.prepare('DELETE FROM videos WHERE id = ?').run(id); } export function searchVideos({ search, tags, minDuration, maxDuration, minWidth, sort, offset, limit }) { const conditions = []; const params = []; if (search) { conditions.push('(v.title LIKE ? OR v.description LIKE ? OR v.filename LIKE ?)'); params.push(`%${search}%`, `%${search}%`, `%${search}%`); } if (minDuration) { conditions.push('v.duration >= ?'); params.push(parseFloat(minDuration)); } if (maxDuration) { conditions.push('v.duration <= ?'); params.push(parseFloat(maxDuration)); } if (minWidth) { conditions.push('v.width >= ?'); params.push(parseInt(minWidth, 10)); } if (tags && tags.length > 0) { conditions.push(`v.id IN ( SELECT vt.video_id FROM video_tags vt JOIN tags t ON t.id = vt.tag_id WHERE t.name IN (${tags.map(() => '?').join(',')}) )`); params.push(...tags); } conditions.push("v.status = 'ready'"); const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; const countRow = db.prepare(`SELECT COUNT(*) AS total FROM videos v ${where}`).get(...params); const total = countRow.total; let orderBy; switch (sort) { case 'oldest': orderBy = 'ORDER BY v.created_at ASC'; break; case 'longest': orderBy = 'ORDER BY v.duration DESC'; break; case 'shortest': orderBy = 'ORDER BY v.duration ASC'; break; case 'largest': orderBy = 'ORDER BY v.file_size DESC'; break; case 'title': orderBy = 'ORDER BY v.title ASC'; break; case 'shuffle': orderBy = 'ORDER BY RANDOM()'; break; default: orderBy = 'ORDER BY v.created_at DESC'; } const effectiveLimit = limit || 48; const effectiveOffset = offset || 0; const rows = db.prepare(` SELECT v.* FROM videos v ${where} ${orderBy} LIMIT ? OFFSET ? `).all(...params, effectiveLimit, effectiveOffset); return { total, rows }; } export function getOrCreateTag(name) { const trimmed = name.trim(); if (!trimmed) return null; let row = db.prepare('SELECT id FROM tags WHERE name = ? COLLATE NOCASE').get(trimmed); if (!row) { const result = db.prepare('INSERT INTO tags (name) VALUES (?)').run(trimmed); return result.lastInsertRowid; } return row.id; } export function getAllTags(search) { if (search) { return db.prepare(` SELECT t.id, t.name, COUNT(vt.video_id) AS count FROM tags t LEFT JOIN video_tags vt ON vt.tag_id = t.id WHERE t.name LIKE ? GROUP BY t.id ORDER BY count DESC, t.name ASC `).all(`%${search}%`); } return db.prepare(` SELECT t.id, t.name, COUNT(vt.video_id) AS count FROM tags t LEFT JOIN video_tags vt ON vt.tag_id = t.id GROUP BY t.id ORDER BY count DESC, t.name ASC `).all(); } export function setVideoTags(videoId, tagNames) { const setTags = db.transaction((names) => { db.prepare('DELETE FROM video_tags WHERE video_id = ?').run(videoId); for (const name of names) { const tagId = getOrCreateTag(name); if (tagId) { db.prepare('INSERT OR IGNORE INTO video_tags (video_id, tag_id) VALUES (?, ?)').run(videoId, tagId); } } }); setTags(tagNames); } export function getVideoTags(videoId) { return db.prepare(` SELECT t.id, t.name FROM tags t JOIN video_tags vt ON vt.tag_id = t.id WHERE vt.video_id = ? ORDER BY t.name ASC `).all(videoId); } export function getDownloadsToday() { // created_at is stored in UTC via SQLite datetime('now'). // Compute local midnight in UTC-relative format so "today" matches the server's local day. const now = new Date(); const localMidnight = new Date(now.getFullYear(), now.getMonth(), now.getDate()); // Format as SQLite-compatible "YYYY-MM-DD HH:MM:SS" const y = localMidnight.getUTCFullYear(); const mo = String(localMidnight.getUTCMonth() + 1).padStart(2, '0'); const d = String(localMidnight.getUTCDate()).padStart(2, '0'); const h = String(localMidnight.getUTCHours()).padStart(2, '0'); const mi = String(localMidnight.getUTCMinutes()).padStart(2, '0'); const s = String(localMidnight.getUTCSeconds()).padStart(2, '0'); const todayUtc = `${y}-${mo}-${d} ${h}:${mi}:${s}`; const row = db.prepare('SELECT COUNT(*) AS count FROM media_files WHERE created_at >= ?').get(todayUtc); return row?.count || 0; } export function getRecentDownloads(limit = 10) { // Merge recent items from both download_history and media_files (for scrapes) return db.prepare(` SELECT filename, type AS media_type, folder AS user_id, created_at AS downloaded_at FROM media_files ORDER BY created_at DESC LIMIT ? `).all(limit); } // --- app_users helpers --- export function createAppUser(username, passwordHash, role = 'user', displayName = '') { const result = db.prepare( 'INSERT INTO app_users (username, password_hash, role, display_name) VALUES (?, ?, ?, ?)' ).run(username, passwordHash, role, displayName); return result.lastInsertRowid; } export function getAppUserByUsername(username) { return db.prepare('SELECT * FROM app_users WHERE username = ?').get(username) || null; } export function getAppUserById(id) { return db.prepare('SELECT * FROM app_users WHERE id = ?').get(id) || null; } export function getAllAppUsers() { return db.prepare('SELECT id, username, display_name, role, enabled, created_at, updated_at FROM app_users ORDER BY id').all(); } export function updateAppUser(id, fields) { const allowed = ['username', 'password_hash', 'display_name', 'role', 'enabled']; const sets = []; const values = []; for (const [key, val] of Object.entries(fields)) { if (!allowed.includes(key)) continue; sets.push(`${key} = ?`); values.push(val); } if (sets.length === 0) return; sets.push("updated_at = datetime('now')"); values.push(id); db.prepare(`UPDATE app_users SET ${sets.join(', ')} WHERE id = ?`).run(...values); } export function deleteAppUser(id) { db.prepare('DELETE FROM app_users WHERE id = ?').run(id); } export function getAppUserCount() { return db.prepare('SELECT COUNT(*) AS count FROM app_users').get().count; } export function getUserFolderAccess(userId) { return db.prepare('SELECT folder FROM user_folder_access WHERE user_id = ?').all(userId).map(r => r.folder); } export function setUserFolderAccess(userId, folders) { const update = db.transaction((flds) => { db.prepare('DELETE FROM user_folder_access WHERE user_id = ?').run(userId); const ins = db.prepare('INSERT INTO user_folder_access (user_id, folder) VALUES (?, ?)'); for (const f of flds) { ins.run(userId, f); } }); update(folders); } export function getUserRouteAccess(userId) { return db.prepare('SELECT route_key FROM user_route_access WHERE user_id = ?').all(userId).map(r => r.route_key); } export function setUserRouteAccess(userId, routes) { const update = db.transaction((rts) => { db.prepare('DELETE FROM user_route_access WHERE user_id = ?').run(userId); const ins = db.prepare('INSERT INTO user_route_access (user_id, route_key) VALUES (?, ?)'); for (const r of rts) { ins.run(userId, r); } }); update(routes); } // --- Forum Sites --- export function getForumSites() { return db.prepare('SELECT * FROM forum_sites ORDER BY name').all(); } export function getForumSiteById(id) { return db.prepare('SELECT * FROM forum_sites WHERE id = ?').get(id); } export function createForumSite(name, baseUrl, cookies, username, password) { const result = db.prepare('INSERT INTO forum_sites (name, base_url, cookies, username, password) VALUES (?, ?, ?, ?, ?)').run(name, baseUrl || '', cookies || '', username || '', password || ''); return result.lastInsertRowid; } export function updateForumSite(id, fields) { const allowed = ['name', 'base_url', 'cookies', 'username', 'password', 'cookie_expires_at', 'user_agent']; const sets = []; const vals = []; for (const [k, v] of Object.entries(fields)) { if (allowed.includes(k)) { sets.push(`${k} = ?`); vals.push(v); } } if (sets.length === 0) return; sets.push("updated_at = datetime('now')"); vals.push(id); db.prepare(`UPDATE forum_sites SET ${sets.join(', ')} WHERE id = ?`).run(...vals); } export function deleteForumSite(id) { db.prepare('DELETE FROM forum_sites WHERE id = ?').run(id); }