236f36aae6
- JWT-based app authentication with user roles, folder/route access control - Dashboard with storage stats, health checks, and recent activity - Auto-download/scrape scheduler (12h interval) with per-user and per-job configs - Video upload, tagging, HLS transcoding, and detail pages - New scrapers: LeakGallery, Mega (megajs), yt-dlp - FlareSolverr integration for Cloudflare-protected sites - Gallery: advanced filtering (date, size, search), sort modes, equal-mix shuffle - Forum sites management with stored cookies/auth - GridWall/GridCell components for responsive media grid - Media API with folder-access permissions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
789 lines
25 KiB
JavaScript
789 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');
|
|
}
|
|
|
|
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'];
|
|
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);
|
|
}
|