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:
@@ -0,0 +1,445 @@
|
||||
import { Router } from 'express';
|
||||
import multer from 'multer';
|
||||
import { join, extname, basename } from 'path';
|
||||
import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync, createReadStream, rmSync } from 'fs';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import {
|
||||
insertVideo, getVideoById, getVideoByPath, updateVideo, deleteVideoById,
|
||||
searchVideos, getOrCreateTag, getAllTags, setVideoTags, getVideoTags,
|
||||
} from './db.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const router = Router();
|
||||
|
||||
const VIDEOS_PATH = process.env.VIDEOS_PATH || '/data/videos';
|
||||
const VIDEO_EXTS = new Set(['.mp4', '.mov', '.avi', '.webm', '.mkv', '.m4v', '.wmv', '.flv', '.ts']);
|
||||
|
||||
// Ensure videos dir exists
|
||||
if (!existsSync(VIDEOS_PATH)) {
|
||||
mkdirSync(VIDEOS_PATH, { recursive: true });
|
||||
}
|
||||
|
||||
// Multer config for uploads
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, VIDEOS_PATH),
|
||||
filename: (req, file, cb) => {
|
||||
// Preserve original name, avoid collisions
|
||||
let name = file.originalname;
|
||||
const filePath = join(VIDEOS_PATH, name);
|
||||
if (existsSync(filePath)) {
|
||||
const ext = extname(name);
|
||||
const base = basename(name, ext);
|
||||
name = `${base}_${Date.now()}${ext}`;
|
||||
}
|
||||
cb(null, name);
|
||||
},
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
fileFilter: (req, file, cb) => {
|
||||
const ext = extname(file.originalname).toLowerCase();
|
||||
if (VIDEO_EXTS.has(ext)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error(`Unsupported file type: ${ext}`));
|
||||
}
|
||||
},
|
||||
limits: { fileSize: 50 * 1024 * 1024 * 1024 }, // 50 GB
|
||||
});
|
||||
|
||||
// --- ffprobe helper ---
|
||||
|
||||
async function probeVideo(filePath) {
|
||||
const { stdout } = await execFileAsync('ffprobe', [
|
||||
'-v', 'error',
|
||||
'-show_entries', 'format=duration,bit_rate',
|
||||
'-show_entries', 'stream=codec_name,width,height,r_frame_rate,codec_type',
|
||||
'-of', 'json',
|
||||
filePath,
|
||||
], { timeout: 60000 });
|
||||
|
||||
const info = JSON.parse(stdout);
|
||||
const videoStream = info.streams?.find(s => s.codec_type === 'video');
|
||||
const audioStream = info.streams?.find(s => s.codec_type === 'audio');
|
||||
const duration = parseFloat(info.format?.duration || '0');
|
||||
const bitrate = parseInt(info.format?.bit_rate || '0', 10);
|
||||
|
||||
let fps = null;
|
||||
if (videoStream?.r_frame_rate) {
|
||||
const [num, den] = videoStream.r_frame_rate.split('/');
|
||||
if (den && parseInt(den, 10) > 0) {
|
||||
fps = Math.round((parseInt(num, 10) / parseInt(den, 10)) * 100) / 100;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
duration: duration || null,
|
||||
width: videoStream?.width || null,
|
||||
height: videoStream?.height || null,
|
||||
fps,
|
||||
codec: videoStream?.codec_name || null,
|
||||
bitrate: bitrate || null,
|
||||
has_audio: audioStream ? 1 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
// --- thumbnail generation ---
|
||||
|
||||
async function generateVideoThumbnail(filePath, outputPath) {
|
||||
const dir = join(VIDEOS_PATH, '.thumbnails');
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
|
||||
// Seek 1s in for a better frame
|
||||
let duration = 0;
|
||||
try {
|
||||
const { stdout } = await execFileAsync('ffprobe', [
|
||||
'-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', filePath,
|
||||
], { timeout: 15000 });
|
||||
duration = parseFloat(stdout.trim()) || 0;
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const seekTime = duration > 2 ? '1' : '0';
|
||||
|
||||
await execFileAsync('ffmpeg', [
|
||||
'-ss', seekTime,
|
||||
'-i', filePath,
|
||||
'-frames:v', '1',
|
||||
'-vf', 'scale=480:-1',
|
||||
'-q:v', '4',
|
||||
'-y',
|
||||
'-update', '1',
|
||||
outputPath,
|
||||
], { timeout: 30000 });
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
// --- Scan state ---
|
||||
|
||||
let scanState = { running: false, total: 0, done: 0, added: 0, skipped: 0, errors: 0 };
|
||||
|
||||
// GET /api/videos — browse/search
|
||||
router.get('/api/videos', (req, res, next) => {
|
||||
try {
|
||||
const { search, sort, offset, limit, minDuration, maxDuration, minWidth } = req.query;
|
||||
const tagsParam = req.query.tags;
|
||||
const tagsArr = tagsParam
|
||||
? tagsParam.split(',').map(t => t.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
const result = searchVideos({
|
||||
search: search || undefined,
|
||||
tags: tagsArr,
|
||||
minDuration: minDuration || undefined,
|
||||
maxDuration: maxDuration || undefined,
|
||||
minWidth: minWidth || undefined,
|
||||
sort: sort || 'latest',
|
||||
offset: parseInt(offset || '0', 10),
|
||||
limit: parseInt(limit || '48', 10),
|
||||
});
|
||||
|
||||
// Attach tags to each video
|
||||
const videos = result.rows.map(v => ({
|
||||
...v,
|
||||
tags: getVideoTags(v.id),
|
||||
}));
|
||||
|
||||
res.json({ total: result.total, offset: parseInt(offset || '0', 10), videos });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/videos/tags — all tags with counts
|
||||
router.get('/api/videos/tags', (req, res, next) => {
|
||||
try {
|
||||
const { search } = req.query;
|
||||
const tags = getAllTags(search || undefined);
|
||||
res.json(tags);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/videos/scan/status
|
||||
router.get('/api/videos/scan/status', (req, res) => {
|
||||
res.json(scanState);
|
||||
});
|
||||
|
||||
// GET /api/videos/:id
|
||||
router.get('/api/videos/:id', (req, res, next) => {
|
||||
try {
|
||||
const video = getVideoById(parseInt(req.params.id, 10));
|
||||
if (!video) return res.status(404).json({ error: 'Video not found' });
|
||||
video.tags = getVideoTags(video.id);
|
||||
res.json(video);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/videos/:id — update title, description, tags
|
||||
router.put('/api/videos/:id', (req, res, next) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const video = getVideoById(id);
|
||||
if (!video) return res.status(404).json({ error: 'Video not found' });
|
||||
|
||||
const { title, description, tags } = req.body;
|
||||
const updates = {};
|
||||
if (title !== undefined) updates.title = title;
|
||||
if (description !== undefined) updates.description = description;
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
updateVideo(id, updates);
|
||||
}
|
||||
if (Array.isArray(tags)) {
|
||||
setVideoTags(id, tags);
|
||||
}
|
||||
|
||||
const updated = getVideoById(id);
|
||||
updated.tags = getVideoTags(id);
|
||||
res.json(updated);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/videos/:id
|
||||
router.delete('/api/videos/:id', (req, res, next) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const video = getVideoById(id);
|
||||
if (!video) return res.status(404).json({ error: 'Video not found' });
|
||||
|
||||
// Delete file
|
||||
if (existsSync(video.file_path)) {
|
||||
try { unlinkSync(video.file_path); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Delete thumbnail
|
||||
if (video.thumbnail_path && existsSync(video.thumbnail_path)) {
|
||||
try { unlinkSync(video.thumbnail_path); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Delete HLS cache
|
||||
const hlsCacheDir = join(VIDEOS_PATH, '.hls-cache', String(id));
|
||||
if (existsSync(hlsCacheDir)) {
|
||||
try { rmSync(hlsCacheDir, { recursive: true }); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
deleteVideoById(id);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/videos/upload — multipart file upload
|
||||
router.post('/api/videos/upload', upload.single('video'), async (req, res) => {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No video file provided' });
|
||||
}
|
||||
|
||||
const filePath = req.file.path;
|
||||
const filename = req.file.filename;
|
||||
|
||||
try {
|
||||
// Check for dupe
|
||||
const existing = getVideoByPath(filePath);
|
||||
if (existing) {
|
||||
return res.json({ video: existing, duplicate: true });
|
||||
}
|
||||
|
||||
const stat = statSync(filePath);
|
||||
const probe = await probeVideo(filePath);
|
||||
|
||||
// Generate thumbnail
|
||||
const thumbName = filename.replace(/\.[^.]+$/, '.jpg');
|
||||
const thumbPath = join(VIDEOS_PATH, '.thumbnails', thumbName);
|
||||
let thumbResult = null;
|
||||
try {
|
||||
thumbResult = await generateVideoThumbnail(filePath, thumbPath);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const title = basename(filename, extname(filename))
|
||||
.replace(/[_.-]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
const videoId = insertVideo({
|
||||
title,
|
||||
filename,
|
||||
file_path: filePath,
|
||||
file_size: stat.size,
|
||||
...probe,
|
||||
thumbnail_path: thumbResult || null,
|
||||
status: 'ready',
|
||||
});
|
||||
|
||||
const video = getVideoById(videoId);
|
||||
video.tags = [];
|
||||
res.json({ video });
|
||||
} catch (err) {
|
||||
console.error('[videos] Upload processing failed:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/videos/scan — scan VIDEOS_PATH for new files
|
||||
router.post('/api/videos/scan', (req, res) => {
|
||||
if (scanState.running) {
|
||||
return res.json({ status: 'already_running', ...scanState });
|
||||
}
|
||||
|
||||
scanState = { running: true, total: 0, done: 0, added: 0, skipped: 0, errors: 0 };
|
||||
res.json({ status: 'started' });
|
||||
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
// Collect all video files
|
||||
const videoFiles = [];
|
||||
const collectFiles = (dir) => {
|
||||
let entries;
|
||||
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
const fullPath = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
collectFiles(fullPath);
|
||||
} else {
|
||||
const ext = extname(entry.name).toLowerCase();
|
||||
if (VIDEO_EXTS.has(ext)) {
|
||||
videoFiles.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
collectFiles(VIDEOS_PATH);
|
||||
|
||||
scanState.total = videoFiles.length;
|
||||
console.log(`[videos] Scan found ${videoFiles.length} video files`);
|
||||
|
||||
for (const filePath of videoFiles) {
|
||||
try {
|
||||
// Skip if already indexed
|
||||
const existing = getVideoByPath(filePath);
|
||||
if (existing) {
|
||||
scanState.skipped++;
|
||||
scanState.done++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const stat = statSync(filePath);
|
||||
const filename = basename(filePath);
|
||||
|
||||
// Probe metadata
|
||||
let probe;
|
||||
try {
|
||||
probe = await probeVideo(filePath);
|
||||
} catch (err) {
|
||||
console.error(`[videos] Probe failed for ${filename}:`, err.message);
|
||||
scanState.errors++;
|
||||
scanState.done++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Generate thumbnail
|
||||
const thumbName = `${Date.now()}_${filename.replace(/\.[^.]+$/, '.jpg')}`;
|
||||
const thumbPath = join(VIDEOS_PATH, '.thumbnails', thumbName);
|
||||
let thumbResult = null;
|
||||
try {
|
||||
thumbResult = await generateVideoThumbnail(filePath, thumbPath);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const title = basename(filename, extname(filename))
|
||||
.replace(/[_.-]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
insertVideo({
|
||||
title,
|
||||
filename,
|
||||
file_path: filePath,
|
||||
file_size: stat.size,
|
||||
...probe,
|
||||
thumbnail_path: thumbResult || null,
|
||||
status: 'ready',
|
||||
});
|
||||
|
||||
scanState.added++;
|
||||
scanState.done++;
|
||||
} catch (err) {
|
||||
console.error(`[videos] Scan error for ${filePath}:`, err.message);
|
||||
scanState.errors++;
|
||||
scanState.done++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[videos] Scan complete: ${scanState.added} added, ${scanState.skipped} skipped, ${scanState.errors} errors`);
|
||||
} catch (err) {
|
||||
console.error('[videos] Scan failed:', err.message);
|
||||
} finally {
|
||||
scanState.running = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/videos/:id/thumbnail
|
||||
router.get('/api/videos/:id/thumbnail', (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const video = getVideoById(id);
|
||||
if (!video) return res.status(404).json({ error: 'Video not found' });
|
||||
|
||||
if (video.thumbnail_path && existsSync(video.thumbnail_path)) {
|
||||
const stat = statSync(video.thumbnail_path);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'image/jpeg',
|
||||
'Content-Length': stat.size,
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
});
|
||||
createReadStream(video.thumbnail_path).pipe(res);
|
||||
} else {
|
||||
// Return a placeholder
|
||||
res.status(404).json({ error: 'No thumbnail' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/videos/:id/stream — direct file serve for grid wall playback
|
||||
router.get('/api/videos/:id/stream', (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const video = getVideoById(id);
|
||||
if (!video) return res.status(404).json({ error: 'Video not found' });
|
||||
if (!existsSync(video.file_path)) return res.status(404).json({ error: 'File not found' });
|
||||
|
||||
const stat = statSync(video.file_path);
|
||||
const ext = extname(video.file_path).toLowerCase();
|
||||
const mimeTypes = { '.mp4': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime', '.mkv': 'video/x-matroska', '.m4v': 'video/mp4' };
|
||||
const contentType = mimeTypes[ext] || 'video/mp4';
|
||||
|
||||
// Support range requests
|
||||
const range = req.headers.range;
|
||||
if (range) {
|
||||
const parts = range.replace(/bytes=/, '').split('-');
|
||||
const start = parseInt(parts[0], 10);
|
||||
const end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1;
|
||||
res.writeHead(206, {
|
||||
'Content-Range': `bytes ${start}-${end}/${stat.size}`,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': end - start + 1,
|
||||
'Content-Type': contentType,
|
||||
});
|
||||
createReadStream(video.file_path, { start, end }).pipe(res);
|
||||
} else {
|
||||
res.writeHead(200, {
|
||||
'Content-Length': stat.size,
|
||||
'Content-Type': contentType,
|
||||
'Accept-Ranges': 'bytes',
|
||||
});
|
||||
createReadStream(video.file_path).pipe(res);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user