Files
OFApp/server/videos.js
T
Trey T 236f36aae6 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>
2026-04-16 07:48:10 -05:00

446 lines
13 KiB
JavaScript

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;