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;