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:
Trey T
2026-04-16 07:48:10 -05:00
parent 4903b84aef
commit 236f36aae6
54 changed files with 9986 additions and 420 deletions
+218 -33
View File
@@ -8,6 +8,7 @@ import {
getPostDateByFilename, getSetting,
upsertMediaFileBatch, removeMediaFile, removeStaleFiles,
getMediaFolders, getMediaFiles, getMediaFileCount, getAllIndexedFolders,
getNewMediaCount, getUserFolderAccess,
} from './db.js';
const execFileAsync = promisify(execFile);
@@ -107,20 +108,49 @@ export function scanMediaFiles() {
console.log(`[gallery] Index scan complete: ${totalFiles} files in ${scannedFolders.size} folders (${elapsed}s). DB total: ${dbCount}`);
}
// Helper: get allowed folders for current user (null = all access)
function getAllowedFolders(req) {
if (!req.user || req.user.role === 'admin') return null;
return getUserFolderAccess(req.user.id);
}
function checkFolderAccess(req, folder) {
const allowed = getAllowedFolders(req);
if (allowed === null) return true; // admin or no restrictions
return allowed.includes(folder);
}
// GET /api/gallery/new-count — count of media added since last gallery visit
router.get('/api/gallery/new-count', (req, res, next) => {
try {
const lastSeen = getSetting('gallery_last_seen');
if (!lastSeen) return res.json({ count: 0 });
const count = getNewMediaCount(lastSeen);
res.json({ count });
} catch (err) {
next(err);
}
});
// GET /api/gallery/folders — list all folders with file counts (from DB index)
router.get('/api/gallery/folders', (req, res, next) => {
try {
const folders = getMediaFolders();
let folders = getMediaFolders();
const allowed = getAllowedFolders(req);
if (allowed !== null) {
const set = new Set(allowed);
folders = folders.filter(f => set.has(f.name));
}
res.json(folders);
} catch (err) {
next(err);
}
});
// GET /api/gallery/files?folder=&type=&sort=&offset=&limit= (from DB index)
// GET /api/gallery/files?folder=&type=&sort=&offset=&limit=&dateFrom=&dateTo=&minSize=&maxSize=&search= (from DB index)
router.get('/api/gallery/files', (req, res, next) => {
try {
const { folder, type, sort, offset, limit } = req.query;
const { folder, type, sort, offset, limit, dateFrom, dateTo, minSize, maxSize, search } = req.query;
const foldersParam = req.query.folders;
const foldersArr = foldersParam
? foldersParam.split(',').map((f) => f.trim()).filter(Boolean)
@@ -130,13 +160,45 @@ router.get('/api/gallery/files', (req, res, next) => {
const limitNum = parseInt(limit || '50', 10);
const hlsEnabled = (getSetting('hls_enabled') || process.env.HLS_ENABLED) === 'true';
// Enforce folder access for non-admin users
const allowed = getAllowedFolders(req);
let effectiveFolder = folder || undefined;
let effectiveFolders = foldersArr;
if (allowed !== null) {
const allowedSet = new Set(allowed);
if (effectiveFolder) {
// Requested specific folder — must be allowed
if (!allowedSet.has(effectiveFolder)) {
return res.json({ total: 0, offset: offsetNum, limit: limitNum, files: [] });
}
} else if (effectiveFolders && effectiveFolders.length > 0) {
// Requested multiple folders — intersect with allowed
effectiveFolders = effectiveFolders.filter(f => allowedSet.has(f));
if (effectiveFolders.length === 0) {
return res.json({ total: 0, offset: offsetNum, limit: limitNum, files: [] });
}
} else {
// No folder filter — restrict to allowed folders only
effectiveFolders = allowed;
if (effectiveFolders.length === 0) {
return res.json({ total: 0, offset: offsetNum, limit: limitNum, files: [] });
}
}
}
const { total, rows } = getMediaFiles({
folder: folder || undefined,
folders: foldersArr,
folder: effectiveFolder,
folders: effectiveFolders,
type: type || 'all',
sort: sort || 'latest',
offset: offsetNum,
limit: limitNum,
dateFrom: dateFrom || undefined,
dateTo: dateTo || undefined,
minSize: minSize || undefined,
maxSize: maxSize || undefined,
search: search || undefined,
});
const files = rows.map((r) => {
@@ -197,6 +259,10 @@ router.get('/api/gallery/media/:folder/:filename', (req, res) => {
return res.status(400).json({ error: 'Invalid path' });
}
if (!checkFolderAccess(req, folder)) {
return res.status(403).json({ error: 'Access denied' });
}
const filePath = join(MEDIA_PATH, folder, filename);
res.sendFile(filePath, { root: '/' }, (err) => {
if (err && !res.headersSent) {
@@ -225,19 +291,54 @@ async function generateThumb(folder, filename) {
const promise = (async () => {
try {
// Skip corrupt/empty files
try {
const st = statSync(videoPath);
if (st.size < 1000) return null;
} catch { return null; }
if (!existsSync(thumbDir)) mkdirSync(thumbDir, { recursive: true });
// Check if file has a video stream and get duration
let hasVideo = false;
let probeFailed = false;
let duration = 0;
try {
const probe = await execFileAsync('ffprobe', [
'-v', 'error',
'-select_streams', 'v',
'-show_entries', 'stream=codec_type',
'-show_entries', 'format=duration',
'-of', 'json',
videoPath,
], { timeout: 15000 });
const info = JSON.parse(probe.stdout);
hasVideo = info.streams && info.streams.length > 0;
duration = parseFloat(info.format?.duration || '0');
} catch {
probeFailed = true;
}
if (!hasVideo && !probeFailed) return 'audio-only'; // confirmed audio-only, skip
if (!hasVideo && probeFailed) return null; // probe failed, count as error
const seekTime = duration > 1.5 ? '1' : '0';
await execFileAsync('ffmpeg', [
'-ss', '1',
'-ss', seekTime,
'-i', videoPath,
'-frames:v', '1',
'-vf', 'scale=320:-1',
'-pix_fmt', 'yuvj420p',
'-q:v', '6',
'-y',
'-update', '1',
thumbPath,
], { timeout: 10000 });
], { timeout: 30000 });
return thumbPath;
} catch (err) {
console.error(`[gallery] thumb failed for ${key}:`, err.message);
if (err.stderr) console.error(`[gallery] ffmpeg stderr:`, err.stderr.trim());
return null;
} finally {
thumbInFlight.delete(key);
@@ -248,44 +349,116 @@ async function generateThumb(folder, filename) {
return promise;
}
// GET /api/gallery/thumb/:folder/:filename — serve or generate a video thumbnail
async function generateImageThumb(folder, filename) {
const imagePath = join(MEDIA_PATH, folder, filename);
const { thumbDir, thumbPath } = getThumbPath(folder, filename);
if (existsSync(thumbPath)) return thumbPath;
const key = `img:${folder}/${filename}`;
if (thumbInFlight.has(key)) return thumbInFlight.get(key);
const promise = (async () => {
try {
// Skip corrupt/empty files
try {
const st = statSync(imagePath);
if (st.size < 100) return null;
} catch { return null; }
if (!existsSync(thumbDir)) mkdirSync(thumbDir, { recursive: true });
await execFileAsync('ffmpeg', [
'-i', imagePath,
'-vf', 'scale=480:-1',
'-q:v', '4',
'-y',
'-update', '1',
thumbPath,
], { timeout: 30000 });
return thumbPath;
} catch (err) {
console.error(`[gallery] image thumb failed for ${folder}/${filename}:`, err.message);
if (err.stderr) console.error(`[gallery] ffmpeg stderr:`, err.stderr.trim());
return null;
} finally {
thumbInFlight.delete(key);
}
})();
thumbInFlight.set(key, promise);
return promise;
}
function serveFile(filePath, res) {
try {
const st = statSync(filePath);
res.writeHead(200, {
'Content-Type': 'image/jpeg',
'Content-Length': st.size,
'Cache-Control': 'public, max-age=86400',
});
createReadStream(filePath).pipe(res);
} catch {
if (!res.headersSent) {
res.set('Cache-Control', 'no-cache');
res.status(404).json({ error: 'Not found' });
}
}
}
// GET /api/gallery/thumb/:folder/:filename — serve or generate a thumbnail (video or image)
router.get('/api/gallery/thumb/:folder/:filename', async (req, res) => {
const { folder, filename } = req.params;
if (folder.includes('..') || filename.includes('..')) {
return res.status(400).json({ error: 'Invalid path' });
}
if (!checkFolderAccess(req, folder)) {
return res.status(403).json({ error: 'Access denied' });
}
const { thumbPath } = getThumbPath(folder, filename);
// Serve cached thumb immediately
if (existsSync(thumbPath)) {
return res.sendFile(thumbPath, { root: '/' }, (err) => {
if (err && !res.headersSent) res.status(404).json({ error: 'Not found' });
});
return serveFile(thumbPath, res);
}
// Determine type by extension
const ext = filename.toLowerCase().split('.').pop();
const isVideo = ['mp4', 'mov', 'avi', 'mkv', 'webm', 'wmv', 'flv', 'm4v'].includes(ext);
const isImage = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'tiff'].includes(ext);
let result;
if (isVideo) {
result = await generateThumb(folder, filename);
} else if (isImage) {
result = await generateImageThumb(folder, filename);
}
// Generate on-demand
const result = await generateThumb(folder, filename);
if (result && existsSync(result)) {
res.sendFile(result, { root: '/' }, (err) => {
if (err && !res.headersSent) res.status(500).json({ error: 'Failed to serve thumbnail' });
});
serveFile(result, res);
} else if (isImage) {
// Fallback: serve original image
const origPath = join(MEDIA_PATH, folder, filename);
serveFile(origPath, res);
} else {
res.set('Cache-Control', 'no-cache');
res.status(500).json({ error: 'Thumbnail generation failed' });
}
});
// Bulk thumbnail generation state
let thumbGenState = { running: false, total: 0, done: 0, errors: 0 };
let thumbGenState = { running: false, total: 0, done: 0, errors: 0, skipped: 0 };
// POST /api/gallery/generate-thumbs — bulk generate all video thumbnails
// POST /api/gallery/generate-thumbs — bulk generate all thumbnails (videos + images)
router.post('/api/gallery/generate-thumbs', (req, res) => {
if (thumbGenState.running) {
return res.json({ status: 'already_running', ...thumbGenState });
}
// Collect all videos
const videos = [];
// Collect all media needing thumbs
const mediaItems = [];
const dirs = readdirSync(MEDIA_PATH, { withFileTypes: true })
.filter((e) => e.isDirectory() && !e.name.startsWith('.') && !e.name.startsWith('_'));
@@ -296,36 +469,39 @@ router.post('/api/gallery/generate-thumbs', (req, res) => {
for (const file of files) {
if (file.startsWith('.')) continue;
const ext = extname(file).toLowerCase();
if (VIDEO_EXTS.has(ext)) {
if (VIDEO_EXTS.has(ext) || IMAGE_EXTS.has(ext)) {
const { thumbPath } = getThumbPath(dir.name, file);
if (!existsSync(thumbPath)) {
videos.push({ folder: dir.name, filename: file });
mediaItems.push({ folder: dir.name, filename: file, type: VIDEO_EXTS.has(ext) ? 'video' : 'image' });
}
}
}
} catch { continue; }
}
if (videos.length === 0) {
if (mediaItems.length === 0) {
return res.json({ status: 'done', total: 0, done: 0, errors: 0, message: 'All thumbnails already exist' });
}
thumbGenState = { running: true, total: videos.length, done: 0, errors: 0 };
res.json({ status: 'started', total: videos.length });
thumbGenState = { running: true, total: mediaItems.length, done: 0, errors: 0, skipped: 0 };
res.json({ status: 'started', total: mediaItems.length });
// Run in background with concurrency limit
(async () => {
const CONCURRENCY = 3;
const CONCURRENCY = 2;
let i = 0;
const next = async () => {
while (i < videos.length) {
const { folder, filename } = videos[i++];
const result = await generateThumb(folder, filename);
if (result) thumbGenState.done++;
while (i < mediaItems.length) {
const { folder, filename, type } = mediaItems[i++];
const result = type === 'video'
? await generateThumb(folder, filename)
: await generateImageThumb(folder, filename);
if (result === 'audio-only') thumbGenState.skipped++;
else if (result) thumbGenState.done++;
else thumbGenState.errors++;
}
};
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, videos.length) }, () => next()));
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, mediaItems.length) }, () => next()));
thumbGenState.running = false;
})();
});
@@ -351,12 +527,15 @@ function hashFilePartial(filePath, bytes = 65536) {
}
// POST /api/gallery/scan-duplicates — start background duplicate scan
// Query param: mode=everywhere (default) or mode=same-folder
router.post('/api/gallery/scan-duplicates', (req, res) => {
if (duplicateScanState.running) {
return res.json({ status: 'already_running', ...duplicateScanState });
}
// Phase 1: group all files by size
const mode = req.query.mode || req.body?.mode || 'everywhere';
// Phase 1: group all files by size (optionally scoped per folder)
const bySize = new Map();
const dirs = readdirSync(MEDIA_PATH, { withFileTypes: true })
.filter((e) => e.isDirectory() && !e.name.startsWith('.') && !e.name.startsWith('_'));
@@ -372,7 +551,8 @@ router.post('/api/gallery/scan-duplicates', (req, res) => {
const filePath = join(dirPath, file);
try {
const stat = statSync(filePath);
const key = stat.size;
// For same-folder mode, scope the size key by folder name
const key = mode === 'same-folder' ? `${dir.name}:${stat.size}` : stat.size;
if (!bySize.has(key)) bySize.set(key, []);
bySize.get(key).push({ folder: dir.name, filename: file, type: mediaType, size: stat.size, modified: stat.mtimeMs, filePath });
} catch { continue; }
@@ -440,6 +620,10 @@ router.delete('/api/gallery/media/:folder/:filename', (req, res) => {
return res.status(400).json({ error: 'Invalid path' });
}
if (!checkFolderAccess(req, folder)) {
return res.status(403).json({ error: 'Access denied' });
}
const filePath = join(MEDIA_PATH, folder, filename);
if (!existsSync(filePath)) {
return res.status(404).json({ error: 'File not found' });
@@ -484,6 +668,7 @@ router.post('/api/gallery/duplicates/clean', (req, res) => {
try {
if (existsSync(filePath)) {
unlinkSync(filePath);
removeMediaFile(file.folder, file.filename);
freed += file.size;
deleted++;
}