Files
KiraTV-APP-NodeCast-Modific…/server/routes/probe.js
T
2026-07-13 15:19:13 +00:00

225 lines
7.7 KiB
JavaScript

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) {
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',
'-print_format', 'json',
'-show_streams',
'-show_format',
'-probesize', '5000000',
'-analyzeduration', '5000000',
url
];
const proc = spawn(ffprobePath, args);
let stdout = '';
let stderr = '';
const timer = setTimeout(() => {
proc.kill('SIGKILL');
reject(new Error('Probe timeout'));
}, timeout);
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;