import { Router } from 'express'; import { join } from 'path'; import { existsSync } from 'fs'; import { execFile, spawn } from 'child_process'; import { promisify } from 'util'; import { getSetting } from './db.js'; const execFileAsync = promisify(execFile); const router = Router(); const MEDIA_PATH = process.env.MEDIA_PATH || './data/media'; const SEGMENT_DURATION = 10; function isHlsEnabled() { return (getSetting('hls_enabled') || process.env.HLS_ENABLED) === 'true'; } function validatePath(folder, filename) { if (folder.includes('..') || filename.includes('..')) return null; if (folder.includes('/') || folder.includes('\\')) return null; if (filename.includes('/') || filename.includes('\\')) return null; const filePath = join(MEDIA_PATH, folder, filename); if (!existsSync(filePath)) return null; return filePath; } // GET /api/hls/:folder/:filename/master.m3u8 router.get('/api/hls/:folder/:filename/master.m3u8', async (req, res) => { if (!isHlsEnabled()) { return res.status(404).json({ error: 'HLS not enabled' }); } const { folder, filename } = req.params; const filePath = validatePath(folder, filename); if (!filePath) { return res.status(400).json({ error: 'Invalid path' }); } try { const { stdout } = await execFileAsync('ffprobe', [ '-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', filePath, ]); const duration = parseFloat(stdout.trim()); if (isNaN(duration) || duration <= 0) { return res.status(500).json({ error: 'Could not determine video duration' }); } const segmentCount = Math.ceil(duration / SEGMENT_DURATION); let playlist = '#EXTM3U\n#EXT-X-VERSION:3\n'; playlist += `#EXT-X-TARGETDURATION:${SEGMENT_DURATION}\n`; playlist += '#EXT-X-MEDIA-SEQUENCE:0\n'; for (let i = 0; i < segmentCount; i++) { const remaining = duration - i * SEGMENT_DURATION; const segDuration = Math.min(SEGMENT_DURATION, remaining); playlist += `#EXTINF:${segDuration.toFixed(3)},\n`; playlist += `segment-${i}.ts\n`; } playlist += '#EXT-X-ENDLIST\n'; res.setHeader('Content-Type', 'application/vnd.apple.mpegurl'); res.send(playlist); } catch (err) { console.error('[hls] ffprobe error:', err.message); res.status(500).json({ error: 'Failed to probe video' }); } }); // GET /api/hls/:folder/:filename/segment-:index.ts router.get('/api/hls/:folder/:filename/segment-:index.ts', (req, res) => { if (!isHlsEnabled()) { return res.status(404).json({ error: 'HLS not enabled' }); } const { folder, filename, index } = req.params; const filePath = validatePath(folder, filename); if (!filePath) { return res.status(400).json({ error: 'Invalid path' }); } const segIndex = parseInt(index, 10); if (isNaN(segIndex) || segIndex < 0) { return res.status(400).json({ error: 'Invalid segment index' }); } const offset = segIndex * SEGMENT_DURATION; const ffmpeg = spawn('ffmpeg', [ '-ss', String(offset), '-i', filePath, '-t', String(SEGMENT_DURATION), '-c', 'copy', '-f', 'mpegts', 'pipe:1', ], { stdio: ['ignore', 'pipe', 'ignore'] }); res.setHeader('Content-Type', 'video/MP2T'); ffmpeg.stdout.pipe(res); req.on('close', () => { ffmpeg.kill('SIGKILL'); }); ffmpeg.on('error', (err) => { console.error('[hls] ffmpeg error:', err.message); if (!res.headersSent) { res.status(500).json({ error: 'Transcoding failed' }); } }); }); export default router;