Files
NodecastV2/server/routes/probe.js
2026-10-13 19:02:15 -07:00

194 lines
8.4 KiB
JavaScript

const { ProbeLeases, parseProbeLease, requestCancellation, cancelled } = require('../services/probeLeases');
const probeLeases = new ProbeLeases();
const { parseProgram, selectProgramStreams } = require('../services/mpegTsProgram');
const { redactUrl, redactText, validateHttpUrl, FFMPEG_PROTOCOL_WHITELIST } = require('../services/urlSecurity');
const express = require('express');
const router = express.Router();
const { spawn } = require('child_process');
/**
* 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, signal) {
return new Promise((resolve, reject) => {
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',
'-multiple_requests', '0',
'-reconnect', '1',
'-reconnect_streamed', '1',
'-reconnect_on_http_error', '4xx,5xx',
'-reconnect_delay_max', '10',
'-print_format', 'json',
'-show_streams',
'-show_programs',
'-show_format',
'-protocol_whitelist', FFMPEG_PROTOCOL_WHITELIST,
'-probesize', '5000000',
'-analyzeduration', '5000000',
url
];
if (signal?.aborted) { reject(cancelled()); return; }
const proc = spawn(ffprobePath, args, { windowsHide: true });
let stdout = '', stderr = '', failure;
const stop = error => {
if (failure) return;
failure = error;
proc.kill('SIGKILL');
};
const abort = () => stop(cancelled());
const timer = setTimeout(() => stop(new Error('Probe timeout')), timeout);
signal?.addEventListener('abort', abort, { once: true });
if (signal?.aborted) abort();
proc.stdout.on('data', data => { stdout += data; });
proc.stderr.on('data', data => { stderr = (stderr + data).slice(-65536); });
proc.on('error', error => { failure ||= error; });
proc.once('close', code => {
clearTimeout(timer);
signal?.removeEventListener('abort', abort);
if (failure) { reject(failure); return; }
if (code !== 0) { reject(new Error(`ffprobe exited with code ${code}: ${redactText(stderr)}`)); return; }
try { resolve(JSON.parse(stdout)); }
catch { reject(new Error('Failed to parse ffprobe output')); }
});
});
}
/**
* Analyze probe result and determine compatibility
*/
function analyzeProbeResult(probeResult, url, program = null) {
const streams = selectProgramStreams(probeResult, program);
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,
duration: Number.isFinite(Number(format.duration)) && Number(format.duration) > 0
? Number(format.duration) : 0,
audioCodecs: streams.filter(s => s.codec_type === 'audio')
.map(s => s.codec_name?.toLowerCase() || 'unknown'),
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;
let program, lease;
try {
program = parseProgram(req.query.program);
lease = parseProbeLease(req.query);
validateHttpUrl(url);
if (ua !== undefined && (typeof ua !== 'string' || ua.length > 1024 || /[\x00-\x1f\x7f]/.test(ua))) throw new Error('Invalid probe user agent');
} catch (error) { return res.status(400).json({ error: error.message }); }
const cancellation = requestCancellation(req, res);
const cacheKey = JSON.stringify([url, ua || '', program]);
try {
const result = await probeLeases.run(req.user?.id ?? null, lease, cancellation.signal, async signal => {
const ffprobePath = req.app.locals.ffprobePath;
if (!ffprobePath) return { video: 'unknown', audio: 'unknown', container: 'unknown', compatible: false, needsRemux: false, needsTranscode: true };
const cached = probeCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) return cached.result;
console.log(`[Probe] Probing: ${redactUrl(url)}...`);
try {
const data = await probeStream(url, ffprobePath, ua, req.app.locals.probeTimeoutMs || 15000, signal);
if (signal.aborted) throw cancelled();
const analysis = analyzeProbeResult(data, url, program);
probeCache.set(cacheKey, { result: analysis, timestamp: Date.now() });
return analysis;
} catch (error) {
if (signal.aborted || error.name === 'AbortError') throw cancelled();
const message = redactText(error.message);
console.error('[Probe] Failed:', message);
const fallback = { video: 'unknown', audio: 'unknown', container: 'unknown', compatible: false,
needsRemux: false, needsTranscode: true, error: message };
probeCache.set(cacheKey, { result: fallback, timestamp: Date.now() - (CACHE_TTL - 30000) });
return fallback;
}
});
if (!cancellation.signal.aborted && !res.destroyed) res.json(result);
} catch (error) {
if (cancellation.signal.aborted || res.destroyed) return;
if (error.name === 'AbortError') return res.status(409).json({ error: 'Playback probe was superseded' });
res.status(error.code === 'PROBE_LIMIT' ? 503 : 500).json({ error: 'Unable to probe stream' });
} finally { cancellation.dispose(); }
});
module.exports = router;