Add app auth, dashboard, scheduler, video management, and new scrapers
- 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>
This commit is contained in:
+549
-7
@@ -60,6 +60,64 @@ db.exec(`
|
||||
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
|
||||
@@ -68,6 +126,18 @@ 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;
|
||||
@@ -177,15 +247,19 @@ export function getMediaFolders() {
|
||||
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 }) {
|
||||
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);
|
||||
@@ -199,17 +273,89 @@ export function getMediaFiles({ folder, folders, type, sort, offset, limit }) {
|
||||
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;
|
||||
|
||||
let orderBy;
|
||||
const effectiveLimit = limit || 50;
|
||||
const effectiveOffset = offset || 0;
|
||||
|
||||
// Equal-mix shuffle: when shuffling with multiple folders, sample equally from each
|
||||
if (sort === 'shuffle') {
|
||||
orderBy = 'ORDER BY RANDOM()';
|
||||
} else {
|
||||
// 'latest' — prefer posted_at, fall back to modified
|
||||
orderBy = 'ORDER BY COALESCE(posted_at, datetime(modified / 1000, \'unixepoch\')) DESC';
|
||||
// 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(`
|
||||
@@ -218,7 +364,7 @@ export function getMediaFiles({ folder, folders, type, sort, offset, limit }) {
|
||||
${where}
|
||||
${orderBy}
|
||||
LIMIT ? OFFSET ?
|
||||
`).all(...params, limit || 50, offset || 0);
|
||||
`).all(...params, effectiveLimit, effectiveOffset);
|
||||
|
||||
return { total, rows };
|
||||
}
|
||||
@@ -244,3 +390,399 @@ export function removeStaleFiles(folder, existingFilenames) {
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user