feat: added smart auto-transcoding with probe-based routing and MKV/remux support

This commit is contained in:
Trevor Mears
2026-01-10 00:35:42 -08:00
parent 53a54c760c
commit 346a0519e9
10 changed files with 452 additions and 5 deletions
+1
View File
@@ -65,6 +65,7 @@ function getDefaultSettings() {
forceProxy: false,
forceTranscode: false,
forceRemux: false,
autoTranscode: true,
streamFormat: 'm3u8',
epgRefreshInterval: '24'
};
+27
View File
@@ -52,7 +52,33 @@ function findFFmpeg() {
}
}
function findFFprobe() {
// Try system ffprobe first
try {
execSync('ffprobe -version', { stdio: 'ignore' });
console.log('FFprobe binary configured at: ffprobe (system)');
return 'ffprobe';
} catch (e) {
// Not found in system
}
// Try @ffprobe-installer/ffprobe package
try {
const ffprobePath = require('@ffprobe-installer/ffprobe').path;
if (ffprobePath) {
console.log('FFprobe binary configured at:', ffprobePath);
return ffprobePath;
}
} catch (err) {
// Package not available
}
console.warn('FFprobe not available - auto transcode will fallback to always transcode');
return null;
}
app.locals.ffmpegPath = findFFmpeg();
app.locals.ffprobePath = findFFprobe();
// API Routes
app.use('/api/auth', require('./routes/auth'));
@@ -62,6 +88,7 @@ app.use('/api/channels', require('./routes/channels'));
app.use('/api/favorites', require('./routes/favorites'));
app.use('/api/transcode', require('./routes/transcode'));
app.use('/api/remux', require('./routes/remux'));
app.use('/api/probe', require('./routes/probe'));
app.use('/api/settings', require('./routes/settings'));
// SPA fallback - serve index.html for all non-API routes
+181
View File
@@ -0,0 +1,181 @@
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, timeout = 15000) {
return new Promise((resolve, reject) => {
const args = [
'-v', 'error',
'-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');
// Determine what processing is needed
// 1. Incompatible audio -> Transcode (highest priority)
const needsTranscode = !audioOk;
// 2. Compatible audio/video but incompatible container -> Remux
// This catches raw TS, MKV, AVI, etc. that have H.264/AAC but wrong container
const needsRemux = !needsTranscode && (!containerOk || isRawTs);
const compatible = !needsTranscode && !needsRemux;
return {
video: videoCodec,
audio: audioCodec,
container: container,
compatible: compatible,
needsRemux: needsRemux,
needsTranscode: needsTranscode
};
}
router.get('/', async (req, res) => {
const { url } = req.query;
if (!url) {
return res.status(400).json({ error: 'URL parameter is required' });
}
const ffprobePath = req.app.locals.ffprobePath;
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(url);
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)}...`);
try {
const probeResult = await probeStream(url, ffprobePath);
const analysis = analyzeProbeResult(probeResult, url);
// Cache result
probeCache.set(url, { result: analysis, timestamp: Date.now() });
console.log(`[Probe] Result: video=${analysis.video}, audio=${analysis.audio}, ` +
`container=${analysis.container}, compatible=${analysis.compatible}, ` +
`needsRemux=${analysis.needsRemux}, needsTranscode=${analysis.needsTranscode}`);
res.json(analysis);
} catch (err) {
console.error('[Probe] Failed:', err.message);
// On error, assume transcode needed to be safe
res.json({
video: 'unknown',
audio: 'unknown',
container: 'unknown',
compatible: false,
needsRemux: false,
needsTranscode: true,
error: err.message
});
}
});
module.exports = router;
+7 -3
View File
@@ -40,9 +40,13 @@ router.get('/', (req, res) => {
'-reconnect_streamed', '1',
'-reconnect_delay_max', '5',
'-i', url,
// Map all streams explicitly
'-map', '0',
// Copy ALL streams without re-encoding
// STRICT MAPPING: Only map video and audio, ignore subtitles/data/attachments
// This prevents remux failure when source container has incompatible subtitle tracks (e.g. MKV -> MP4)
'-map', '0:v',
'-map', '0:a',
// Drop subtitles (-sn) and data (-dn) explicitly
'-sn', '-dn',
// Copy streams without re-encoding
'-c', 'copy',
// NOTE: We intentionally do NOT use -bsf:a aac_adtstoasc here
// That filter only works for AAC audio and breaks AC3/EAC3/MP3.