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:
+218
-33
@@ -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++;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user