const express = require('express'); const router = express.Router(); const { spawn, execFile } = require('child_process'); const path = require('path'); const fs = require('fs').promises; const db = require('../db'); const transcodeSession = require('../services/transcodeSession'); const streamInput = require('../utils/streamInput'); /** * Probe a stream URL with FFprobe to get its total duration in seconds. * VOD pesado usa probes mayores y timeout más alto (MKV Jellyfin/remux). */ function probeDuration(url, ffprobePath, userAgent) { return new Promise((resolve) => { const sizing = streamInput.probeSizingForUrl(url); const referer = streamInput.deriveRefererFromUrl(url); const args = [ '-v', 'quiet', '-print_format', 'json', '-show_entries', 'format=duration', '-user_agent', userAgent || 'Mozilla/5.0', '-probesize', sizing.probesize, '-analyzeduration', sizing.analyzeduration, ]; if (referer) { args.push('-referer', referer); } args.push(url); const timeoutMs = sizing.heavy ? 45000 : 8000; const proc = execFile(ffprobePath || 'ffprobe', args, { timeout: timeoutMs }, (err, stdout) => { if (err) { resolve(0); return; } try { const data = JSON.parse(stdout); const dur = parseFloat(data?.format?.duration); resolve(Number.isFinite(dur) && dur > 0 ? Math.round(dur) : 0); } catch { resolve(0); } }); // Ensure the process is killed if execFile timeout fires proc.on('error', () => resolve(0)); }); } /** * Transcode Routes * * Direct streaming (backward compatible): * GET /api/transcode?url=... * * HLS session-based (new, supports seeking): * POST /api/transcode/session - Create new session * GET /api/transcode/:id/stream.m3u8 - Get HLS playlist * GET /api/transcode/:id/:segment.ts - Get segment file * DELETE /api/transcode/:id - Stop and cleanup session * GET /api/transcode/sessions - List all sessions (debug) */ // Start session cleanup interval transcodeSession.startCleanupInterval(); /** * Create a new transcode session * POST /api/transcode/session * Body: { url: string, seekOffset?: number } */ router.post('/session', async (req, res) => { const { url, seekOffset, videoMode, videoCodec, audioCodec, audioChannels } = req.body; if (!url) { return res.status(400).json({ error: 'URL is required' }); } const ffmpegPath = req.app.locals.ffmpegPath || 'ffmpeg'; const settings = await db.settings.get(); const userAgent = db.getUserAgent(settings); try { const ffprobePath = (ffmpegPath || 'ffmpeg').replace(/ffmpeg$/, 'ffprobe'); // Probe duration and start session in parallel (don't block on duration) const [session, durationSec] = await Promise.all([ transcodeSession.createSession(url, { ffmpegPath, userAgent, seekOffset: seekOffset || 0, hwEncoder: settings.hwEncoder || 'software', maxResolution: settings.maxResolution || '1080p', quality: settings.quality || 'medium', audioMixPreset: settings.audioMixPreset || 'auto', upscaleEnabled: settings.upscaleEnabled || false, upscaleMethod: settings.upscaleMethod || 'hardware', upscaleTarget: settings.upscaleTarget || '1080p', videoMode: videoMode, videoCodec: videoCodec, audioCodec: audioCodec, audioChannels: audioChannels }).then(async s => { await s.start(); return s; }), probeDuration(url, ffprobePath, userAgent) ]); // Wait for playlist to be ready (first segments generated) // VOD pesado (4K HEVC/remux) puede tardar >15 s en generar el primer segmento const waitTimeout = streamInput.isHeavyVodUrl(url) ? 60000 : 15000; let ready = await session.waitForPlaylist(waitTimeout); // Fallback a software si el encoder HW falla inmediatamente (error rápido) if (!ready && session.status === 'error' && (settings.hwEncoder || 'software') !== 'software') { console.warn(`[Transcode] HW encoder (${settings.hwEncoder}) falló, reintentando con software libx264...`); await transcodeSession.removeSession(session.id); const swSession = await transcodeSession.createSession(url, { ffmpegPath, userAgent, seekOffset: seekOffset || 0, hwEncoder: 'software', maxResolution: settings.maxResolution || '1080p', quality: settings.quality || 'medium', audioMixPreset: settings.audioMixPreset || 'auto', upscaleEnabled: false, videoMode: videoMode, videoCodec: videoCodec, audioCodec: audioCodec, audioChannels: audioChannels }); await swSession.start(); ready = await swSession.waitForPlaylist(waitTimeout); if (ready) { console.log(`[Transcode] Software fallback OK → session ${swSession.id}`); return res.json({ sessionId: swSession.id, playlistUrl: `/api/transcode/${swSession.id}/stream.m3u8`, status: swSession.status, durationSec: durationSec || 0 }); } await transcodeSession.removeSession(swSession.id); } if (!ready) { await transcodeSession.removeSession(session.id); return res.status(500).json({ error: 'Transcoding failed to start', reason: 'Playlist not generated in time' }); } console.log(`[Transcode] Session ${session.id} ready. Source duration: ${durationSec}s`); res.json({ sessionId: session.id, playlistUrl: `/api/transcode/${session.id}/stream.m3u8`, status: session.status, durationSec: durationSec || 0 }); } catch (err) { console.error('[Transcode] Session creation failed:', err); res.status(500).json({ error: 'Failed to create session', details: err.message }); } }); /** * Get HLS playlist for a session * GET /api/transcode/:sessionId/stream.m3u8 */ router.get('/:sessionId/stream.m3u8', async (req, res) => { const { sessionId } = req.params; const session = transcodeSession.getSession(sessionId); if (!session) { return res.status(404).json({ error: 'Session not found' }); } const playlist = await session.getPlaylist(); if (!playlist) { return res.status(404).json({ error: 'Playlist not ready' }); } res.setHeader('Content-Type', 'application/vnd.apple.mpegurl'); res.setHeader('Cache-Control', 'no-cache'); res.send(playlist); }); /** * Get a segment file for a session * GET /api/transcode/:sessionId/:segment.ts */ router.get('/:sessionId/:segment', async (req, res) => { const { sessionId, segment } = req.params; // Only handle .ts files if (!segment.endsWith('.ts')) { return res.status(404).json({ error: 'Invalid segment' }); } const session = transcodeSession.getSession(sessionId); if (!session) { return res.status(404).json({ error: 'Session not found' }); } const segmentPath = await session.getSegment(segment); if (!segmentPath) { return res.status(404).json({ error: 'Segment not found' }); } res.setHeader('Content-Type', 'video/MP2T'); res.setHeader('Cache-Control', 'public, max-age=31536000'); // Cache forever (immutable) res.sendFile(segmentPath); }); /** * Stop and cleanup a session * DELETE /api/transcode/:sessionId */ router.delete('/:sessionId', async (req, res) => { const { sessionId } = req.params; try { await transcodeSession.removeSession(sessionId); res.json({ success: true }); } catch (err) { res.status(500).json({ error: 'Failed to remove session', details: err.message }); } }); /** * Heartbeat — keeps a session alive while the player is active. * POST /api/transcode/:sessionId/heartbeat */ router.post('/:sessionId/heartbeat', (req, res) => { const session = transcodeSession.getSession(req.params.sessionId); if (!session) return res.status(404).json({ error: 'Session not found' }); session.lastAccess = Date.now(); res.json({ ok: true }); }); /** * Stop session via POST (for sendBeacon on page close) * POST /api/transcode/:sessionId/stop */ router.post('/:sessionId/stop', async (req, res) => { try { await transcodeSession.removeSession(req.params.sessionId); res.json({ success: true }); } catch (err) { res.status(500).json({ error: 'Failed to remove session', details: err.message }); } }); /** * List all active sessions (for debugging) * GET /api/transcode/sessions */ router.get('/sessions', (req, res) => { res.json(transcodeSession.getAllSessions()); }); /** * Direct transcode stream (backward compatible, no seeking) * GET /api/transcode?url=... * * Transcodes audio to AAC for browser compatibility while passing video through. * This fixes playback issues with Dolby/AC3/EAC3 audio that browsers can't decode. */ router.get('/', async (req, res) => { const { url } = req.query; if (!url) { return res.status(400).json({ error: 'URL parameter is required' }); } const ffmpegPath = req.app.locals.ffmpegPath || 'ffmpeg'; // Get User-Agent from settings const settings = await db.settings.get(); const userAgent = db.getUserAgent(settings); console.log(`[Transcode] Starting transcoding for: ${url}`); console.log(`[Transcode] Using User-Agent: ${settings.userAgentPreset}`); console.log(`[Transcode] Using binary: ${ffmpegPath}`); const sizing = streamInput.probeSizingForUrl(url); const referer = streamInput.deriveRefererFromUrl(url); // FFmpeg arguments for transcoding // Optimized for VOD content with incompatible audio (Dolby/AC3/EAC3) // Also works for live streams with ad stitching (Pluto TV, etc.) const args = [ '-hide_banner', '-loglevel', 'warning', '-user_agent', userAgent, ]; if (referer) { args.push('-referer', referer); } args.push( '-probesize', sizing.probesize, '-analyzeduration', sizing.analyzeduration, // Error resilience: generate timestamps, discard corrupt packets '-fflags', '+genpts+discardcorrupt+nobuffer', // Ignore errors in stream and continue '-err_detect', 'ignore_err', // Limit max demux delay to prevent buffering issues '-max_delay', '2000000', // Reconnect settings for network drops (useful for live streams) '-reconnect', '1', '-reconnect_streamed', '1', '-reconnect_delay_max', '3', // Prevent Range/HEAD requests that some providers reject with 405 '-seekable', '0', '-i', url ); args.push( // Map only first video and audio stream (avoid subtitle streams causing issues) '-map', '0:v:0', '-map', '0:a:0?', // ? makes audio optional if not present // Video: passthrough (no re-encoding = fast!) '-c:v', 'copy', // Audio: Transcode to browser-compatible AAC '-c:a', 'aac', '-ar', '48000', '-b:a', '192k', // Handle async audio/video using async filter '-af', 'aresample=async=1:min_hard_comp=0.100000:first_pts=0', // Timestamp handling '-fps_mode', 'passthrough', '-async', '1', '-max_muxing_queue_size', '2048', // Fragmented MP4 for streaming (browser-compatible) '-f', 'mp4', '-movflags', 'frag_keyframe+empty_moov+default_base_moof+faststart', '-flush_packets', '1', // Send data immediately '-' // Output to stdout ); console.log(`[Transcode] Full command: ${ffmpegPath} ${args.join(' ')}`); let ffmpeg; try { ffmpeg = spawn(ffmpegPath, args); } catch (spawnErr) { console.error('[Transcode] Failed to spawn FFmpeg:', spawnErr); return res.status(500).json({ error: 'FFmpeg spawn failed', details: spawnErr.message }); } // Collect stderr for error reporting let stderrBuffer = ''; // Set headers for fragmented MP4 res.setHeader('Content-Type', 'video/mp4'); res.setHeader('Access-Control-Allow-Origin', '*'); // Pipe stdout to response ffmpeg.stdout.pipe(res); // Log stderr (useful for debugging transcoding failures) ffmpeg.stderr.on('data', (data) => { const msg = data.toString(); stderrBuffer += msg; console.log(`[FFmpeg] ${msg}`); }); // Cleanup on client disconnect req.on('close', () => { console.log('[Transcode] Client disconnected, killing FFmpeg process'); ffmpeg.kill('SIGKILL'); }); // Handle process exit ffmpeg.on('exit', (code) => { if (code !== null && code !== 0 && code !== 255) { // 255 is often returned on kill console.error(`[Transcode] FFmpeg exited with code ${code}`); } }); // Handle spawn errors ffmpeg.on('error', (err) => { console.error('[Transcode] Failed to spawn FFmpeg:', err); if (!res.headersSent) { res.status(500).json({ error: 'Transcoding failed to start' }); } }); }); module.exports = router;