Files
OFApp/server/db.js
T
Trey T aa4f1157d1 Route SimpCity forum scraping through FlareSolverr + add turbo.cr resolver
DDoS-Guard now binds session cookies to the issuing browser's fingerprint, so
direct Node fetch returns 403 even with valid cookies. Page HTML for any
forum_site with stored cookies is now fetched via a FlareSolverr browser
session opened once per scrape job.

- Hybrid cookie refresh: FlareSolverr clears the DDoS-Guard captcha, those
  cookies seed undetected_chromedriver, Turnstile auto-solves in the real
  browser, login form submits, final cookies + browser UA persist to forum_sites
- Per-site user_agent column so subsequent scraper requests match the UA the
  cookies were issued for (DDoS-Guard rejects UA mismatches)
- XenForo search rewritten as proper CSRF POST /search/search → results page
  parse, replacing the broken ?q=... GET that only returned the search form
- Pagination regex fallback in detectMaxPage catches XenForo pages that
  cheerio's class-based selectors miss
- New scrapers/turbo.js handles turbo.cr /embed/ and /a/ URLs by rendering
  the page via FlareSolverr and grabbing the signed mp4 from the resolved
  <video src> attribute (gallery-dl can't extract these — obfuscated WASM)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 19:33:54 -05:00

792 lines
25 KiB
JavaScript

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);
}