const express = require('express'); const router = express.Router(); const { spawn } = require('child_process'); const streamInput = require('../utils/streamInput'); /** * Probe endpoint - detects stream codecs and container * GET /api/probe?url=... * * Returns: * { * video: "h264", * audio: "aac", * container: "mpegts", * compatible: true, * needsRemux: false, * needsTranscode: false * } */ // Probe cache (URL → result) const probeCache = new Map(); const CACHE_TTL = 5 * 60 * 1000; // 5 minutes // Browser-compatible codecs const BROWSER_VIDEO_CODECS = ['h264', 'avc', 'avc1']; const BROWSER_AUDIO_CODECS = ['aac', 'mp3', 'opus', 'vorbis']; /** * Probe stream with ffprobe */ function probeStream(url, ffprobePath, userAgent = null, timeout = 15000) { return new Promise((resolve, reject) => { const sizing = streamInput.probeSizingForUrl(url); const referer = streamInput.deriveRefererFromUrl(url); const effectiveTimeout = sizing.heavy ? Math.max(timeout, 45000) : timeout; const args = [ '-v', 'error', '-user_agent', userAgent || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36' ]; if (referer) { args.push('-referer', referer); } args.push( '-print_format', 'json', '-show_streams', '-show_format', '-probesize', sizing.probesize, '-analyzeduration', sizing.analyzeduration, url ); const proc = spawn(ffprobePath, args); let stdout = ''; let stderr = ''; const timer = setTimeout(() => { proc.kill('SIGKILL'); reject(new Error('Probe timeout')); }, effectiveTimeout); proc.stdout.on('data', (data) => { stdout += data; }); proc.stderr.on('data', (data) => { stderr += data; }); proc.on('close', (code) => { clearTimeout(timer); if (code !== 0) { reject(new Error(`ffprobe exited with code ${code}: ${stderr}`)); return; } try { const result = JSON.parse(stdout); resolve(result); } catch (e) { reject(new Error('Failed to parse ffprobe output')); } }); proc.on('error', (err) => { clearTimeout(timer); reject(err); }); }); } /** * Analyze probe result and determine compatibility */ function analyzeProbeResult(probeResult, url) { const streams = probeResult.streams || []; const format = probeResult.format || {}; const videoStream = streams.find(s => s.codec_type === 'video'); const audioStream = streams.find(s => s.codec_type === 'audio'); const videoCodec = videoStream?.codec_name?.toLowerCase() || 'unknown'; const audioCodec = audioStream?.codec_name?.toLowerCase() || 'unknown'; const container = format.format_name?.toLowerCase() || 'unknown'; // Check codec compatibility const videoOk = BROWSER_VIDEO_CODECS.some(c => videoCodec.includes(c)); const audioOk = BROWSER_AUDIO_CODECS.some(c => audioCodec.includes(c)); // Browser-safe containers // Note: We exclude 'webm' because ffprobe reports MKV as "matroska,webm", // and H.264/AAC in MKV/WebM is not universally supported. Best to remux to MP4. const BROWSER_CONTAINERS = ['hls', 'mp4', 'mov']; const containerOk = BROWSER_CONTAINERS.some(c => container.includes(c)); // Check if it's a raw TS stream (not HLS) const isRawTs = (container.includes('mpegts') || url.endsWith('.ts')) && !url.includes('.m3u8'); // Extract subtitle tracks const subtitles = streams .filter(s => s.codec_type === 'subtitle' && s.codec_name !== 'timed_id3' && s.codec_name !== 'bin_data') .map(s => ({ index: s.index, language: s.tags?.language || 'und', title: s.tags?.title || s.tags?.language || `Track ${s.index}`, codec: s.codec_name })); // Determine what processing is needed // 4. MKV files often cause OOM/decoding issues in browser fMP4 remux, // so we force them to "needsTranscode" which uses HLS (more robust). // The frontend will still use "copy" mode if codecs are compatible. const isMkv = container.includes('matroska') || container.includes('webm') || url.endsWith('.mkv'); // 1. Incompatible audio/video OR MKV -> Transcode (or HLS Copy) const needsTranscode = !audioOk || !videoOk || isMkv; // 2. Compatible audio/video but incompatible container (non-MKV) -> Remux (fMP4 pipe) const needsRemux = !needsTranscode && (!containerOk || isRawTs); const compatible = !needsTranscode && !needsRemux; return { video: videoCodec, audio: audioCodec, width: videoStream?.width || 0, height: videoStream?.height || 0, audioChannels: audioStream?.channels || 0, // For Smart Audio Copy container: container, compatible: compatible, needsRemux: needsRemux, needsTranscode: needsTranscode, subtitles: subtitles }; } router.get('/', async (req, res) => { const { url, ua } = req.query; if (!url) { return res.status(400).json({ error: 'URL parameter is required' }); } const ffprobePath = req.app.locals.ffprobePath; const cacheKey = `${url}${ua ? `|${ua}` : ''}`; if (!ffprobePath) { // No ffprobe available - assume needs transcoding to be safe console.log('[Probe] FFprobe not available, assuming transcode needed'); return res.json({ video: 'unknown', audio: 'unknown', container: 'unknown', compatible: false, needsRemux: false, needsTranscode: true }); } // Check cache const cached = probeCache.get(cacheKey); if (cached && (Date.now() - cached.timestamp < CACHE_TTL)) { console.log(`[Probe] Cache hit for: ${url.substring(0, 50)}...`); return res.json(cached.result); } console.log(`[Probe] Probing: ${url.substring(0, 80)}... ${ua ? `(UA: ${ua})` : ''}`); // Retry probe up to 3 times on 5XX errors (provider slot not released yet) const MAX_PROBE_RETRIES = 3; const PROBE_RETRY_DELAY_MS = 2500; let lastErr = null; for (let attempt = 1; attempt <= MAX_PROBE_RETRIES; attempt++) { try { const probeResult = await probeStream(url, ffprobePath, ua); const analysis = analyzeProbeResult(probeResult, url); // Cache successful result probeCache.set(cacheKey, { result: analysis, timestamp: Date.now() }); console.log(`[Probe] Result (attempt ${attempt}): video=${analysis.video}, audio=${analysis.audio}, ` + `container=${analysis.container}, compatible=${analysis.compatible}, ` + `needsRemux=${analysis.needsRemux}, needsTranscode=${analysis.needsTranscode}`); return res.json(analysis); } catch (err) { lastErr = err; const is5xx = /5[0-9]{2}|Server Error/i.test(err.message); if (is5xx && attempt < MAX_PROBE_RETRIES) { console.warn(`[Probe] 5XX from provider (attempt ${attempt}/${MAX_PROBE_RETRIES}), retrying in ${PROBE_RETRY_DELAY_MS}ms...`); await new Promise(r => setTimeout(r, PROBE_RETRY_DELAY_MS)); continue; } break; } } console.error('[Probe] Failed after retries:', lastErr.message); // If there's a stale cached result, use it rather than assuming needsTranscode const stale = probeCache.get(cacheKey); if (stale) { console.warn('[Probe] Using stale cached result as fallback'); return res.json({ ...stale.result, stale: true }); } // Final fallback: assume transcode needed res.json({ video: 'unknown', audio: 'unknown', container: 'unknown', compatible: false, needsRemux: false, needsTranscode: true, error: lastErr.message }); }); module.exports = router;