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
+130 -1
View File
@@ -9,11 +9,11 @@
"version": "1.0.0", "version": "1.0.0",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"dependencies": { "dependencies": {
"@ffprobe-installer/ffprobe": "^2.1.2",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"better-sqlite3": "^12.5.0", "better-sqlite3": "^12.5.0",
"express": "^4.18.2", "express": "^4.18.2",
"express-session": "^1.18.2", "express-session": "^1.18.2",
"ffmpeg-static": "^5.3.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
@@ -44,6 +44,135 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/@ffprobe-installer/darwin-arm64": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@ffprobe-installer/darwin-arm64/-/darwin-arm64-5.0.1.tgz",
"integrity": "sha512-vwNCNjokH8hfkbl6m95zICHwkSzhEvDC3GVBcUp5HX8+4wsX10SP3B+bGur7XUzTIZ4cQpgJmEIAx6TUwRepMg==",
"cpu": [
"arm64"
],
"hasInstallScript": true,
"license": "LGPL-2.1",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@ffprobe-installer/darwin-x64": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@ffprobe-installer/darwin-x64/-/darwin-x64-5.1.0.tgz",
"integrity": "sha512-J+YGscZMpQclFg31O4cfVRGmDpkVsQ2fZujoUdMAAYcP0NtqpC49Hs3SWJpBdsGB4VeqOt5TTm1vSZQzs1NkhA==",
"cpu": [
"x64"
],
"hasInstallScript": true,
"license": "GPL-3.0",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@ffprobe-installer/ffprobe": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@ffprobe-installer/ffprobe/-/ffprobe-2.1.2.tgz",
"integrity": "sha512-ZNvwk4f2magF42Zji2Ese16SMj9BS7Fui4kRjg6gTYTxY3gWZNpg85n4MIfQyI9nimHg4x/gT6FVkp/bBDuBwg==",
"license": "LGPL-2.1",
"engines": {
"node": ">=14.21.2"
},
"optionalDependencies": {
"@ffprobe-installer/darwin-arm64": "5.0.1",
"@ffprobe-installer/darwin-x64": "5.1.0",
"@ffprobe-installer/linux-arm": "5.2.0",
"@ffprobe-installer/linux-arm64": "5.2.0",
"@ffprobe-installer/linux-ia32": "5.2.0",
"@ffprobe-installer/linux-x64": "5.2.0",
"@ffprobe-installer/win32-ia32": "5.1.0",
"@ffprobe-installer/win32-x64": "5.1.0"
}
},
"node_modules/@ffprobe-installer/linux-arm": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@ffprobe-installer/linux-arm/-/linux-arm-5.2.0.tgz",
"integrity": "sha512-PF5HqEhCY7WTWHtLDYbA/+rLS+rhslWvyBlAG1Fk8VzVlnRdl93o6hy7DE2kJgxWQbFaR3ZktPQGEzfkrmQHvQ==",
"cpu": [
"arm"
],
"hasInstallScript": true,
"license": "GPL-3.0",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@ffprobe-installer/linux-arm64": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@ffprobe-installer/linux-arm64/-/linux-arm64-5.2.0.tgz",
"integrity": "sha512-X1VvWtlLs6ScP73biVLuHD5ohKJKsMTa0vafCESOen4mOoNeLAYbxOVxDWAdFz9cpZgRiloFj5QD6nDj8E28yQ==",
"cpu": [
"arm64"
],
"hasInstallScript": true,
"license": "GPL-3.0",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@ffprobe-installer/linux-ia32": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@ffprobe-installer/linux-ia32/-/linux-ia32-5.2.0.tgz",
"integrity": "sha512-TFVK5sasXyXhbIG7LtPRDmtkrkOsInwKcL43iEvEw+D9vCS2rc//mn9/0Q+BR0UoJEiMK4+ApYr/3LLVUBPOCQ==",
"cpu": [
"ia32"
],
"hasInstallScript": true,
"license": "GPL-3.0",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@ffprobe-installer/linux-x64": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@ffprobe-installer/linux-x64/-/linux-x64-5.2.0.tgz",
"integrity": "sha512-D3UeqTLYPNs7pBWPLUYGehPdRVqU8eACox4OZy3pZUZatxye2YKlvBwEfaLdL1v2Z4FOAlLUhms0kY8m8kqSRA==",
"cpu": [
"x64"
],
"hasInstallScript": true,
"license": "GPL-3.0",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@ffprobe-installer/win32-ia32": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@ffprobe-installer/win32-ia32/-/win32-ia32-5.1.0.tgz",
"integrity": "sha512-5O3vOoNRxmut0/Nu9vSazTdSHasrr+zPT2B3Hm7kjmO3QVFcIfVImS6ReQnZeSy8JPJOqXts5kX5x/3KOX54XQ==",
"cpu": [
"ia32"
],
"license": "GPL-3.0",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@ffprobe-installer/win32-x64": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@ffprobe-installer/win32-x64/-/win32-x64-5.1.0.tgz",
"integrity": "sha512-jMGYeAgkrdn4e2vvYt/qakgHRE3CPju4bn5TmdPfoAm1BlX1mY9cyMd8gf5vSzI8gH8Zq5WQAyAkmekX/8TSTg==",
"cpu": [
"x64"
],
"license": "GPL-3.0",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "10.17.60", "version": "10.17.60",
"resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz",
+1
View File
@@ -9,6 +9,7 @@
"dev": "node --watch server/index.js" "dev": "node --watch server/index.js"
}, },
"dependencies": { "dependencies": {
"@ffprobe-installer/ffprobe": "^2.1.2",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"better-sqlite3": "^12.5.0", "better-sqlite3": "^12.5.0",
"express": "^4.18.2", "express": "^4.18.2",
+11
View File
@@ -451,6 +451,17 @@
<div class="settings-section"> <div class="settings-section">
<h3>Streaming</h3> <h3>Streaming</h3>
<div class="setting-item">
<label class="setting-toggle">
<input type="checkbox" id="setting-auto-transcode">
<span class="toggle-slider"></span>
</label>
<div class="setting-info">
<span class="setting-label">Auto Transcode (Smart)</span>
<span class="setting-hint">Automatically detect stream codecs and only transcode/remux when needed.
Adds ~1-3s probe delay on first play.</span>
</div>
</div>
<div class="setting-item"> <div class="setting-item">
<label class="setting-toggle"> <label class="setting-toggle">
<input type="checkbox" id="setting-force-proxy"> <input type="checkbox" id="setting-force-proxy">
+47 -1
View File
@@ -45,6 +45,7 @@ class VideoPlayer {
forceProxy: false, forceProxy: false,
forceTranscode: false, forceTranscode: false,
forceRemux: false, forceRemux: false,
autoTranscode: true,
streamFormat: 'm3u8', streamFormat: 'm3u8',
epgRefreshInterval: '24' epgRefreshInterval: '24'
}; };
@@ -667,10 +668,55 @@ class VideoPlayer {
// Determine if HLS or direct stream // Determine if HLS or direct stream
this.currentUrl = streamUrl; this.currentUrl = streamUrl;
// CHECK: Auto Transcode (Smart) - probe first, then decide
if (this.settings.autoTranscode) {
console.log('[Player] Auto Transcode enabled. Probing stream...');
try {
const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}`);
const info = await probeRes.json();
console.log(`[Player] Probe result: video=${info.video}, audio=${info.audio}, compatible=${info.compatible}`);
if (info.needsTranscode) {
// Incompatible audio (AC3/EAC3/DTS) - use transcode
console.log('[Player] Auto: Using transcode (incompatible audio)');
const transcodeUrl = `/api/transcode?url=${encodeURIComponent(streamUrl)}`;
this.currentUrl = transcodeUrl;
this.video.src = transcodeUrl;
this.video.play().catch(e => {
if (e.name !== 'AbortError') console.log('[Player] Autoplay prevented:', e);
});
this.updateNowPlaying(channel);
this.showNowPlayingOverlay();
this.fetchEpgData(channel);
window.dispatchEvent(new CustomEvent('channelChanged', { detail: channel }));
return;
} else if (info.needsRemux) {
// Raw .ts container - use remux
console.log('[Player] Auto: Using remux (.ts container)');
const remuxUrl = `/api/remux?url=${encodeURIComponent(streamUrl)}`;
this.currentUrl = remuxUrl;
this.video.src = remuxUrl;
this.video.play().catch(e => {
if (e.name !== 'AbortError') console.log('[Player] Autoplay prevented:', e);
});
this.updateNowPlaying(channel);
this.showNowPlayingOverlay();
this.fetchEpgData(channel);
window.dispatchEvent(new CustomEvent('channelChanged', { detail: channel }));
return;
}
// Compatible - fall through to normal HLS.js path
console.log('[Player] Auto: Using HLS.js (compatible)');
} catch (err) {
console.warn('[Player] Probe failed, using normal playback:', err.message);
// Continue with normal playback on probe failure
}
}
// CHECK: Force Transcode Priority - transcoded streams bypass HLS.js // CHECK: Force Transcode Priority - transcoded streams bypass HLS.js
if (this.settings.forceTranscode) { if (this.settings.forceTranscode) {
console.log('[Player] Force Transcode enabled. Routing through ffmpeg...'); console.log('[Player] Force Transcode enabled. Routing through ffmpeg...');
const transcodeUrl = this.getTranscodeUrl(streamUrl); const transcodeUrl = `/api/transcode?url=${encodeURIComponent(streamUrl)}`;
this.currentUrl = transcodeUrl; this.currentUrl = transcodeUrl;
// Transcoded streams are fragmented MP4 - play directly with <video> element // Transcoded streams are fragmented MP4 - play directly with <video> element
+12
View File
@@ -34,6 +34,7 @@ class SettingsPage {
const forceProxyToggle = document.getElementById('setting-force-proxy'); const forceProxyToggle = document.getElementById('setting-force-proxy');
const forceTranscodeToggle = document.getElementById('setting-force-transcode'); const forceTranscodeToggle = document.getElementById('setting-force-transcode');
const forceRemuxToggle = document.getElementById('setting-force-remux'); const forceRemuxToggle = document.getElementById('setting-force-remux');
const autoTranscodeToggle = document.getElementById('setting-auto-transcode');
// Load current settings // Load current settings
if (this.app.player?.settings) { if (this.app.player?.settings) {
@@ -52,6 +53,9 @@ class SettingsPage {
if (forceRemuxToggle) { if (forceRemuxToggle) {
forceRemuxToggle.checked = this.app.player.settings.forceRemux || false; forceRemuxToggle.checked = this.app.player.settings.forceRemux || false;
} }
if (autoTranscodeToggle) {
autoTranscodeToggle.checked = this.app.player.settings.autoTranscode || false;
}
} }
// Arrow keys toggle // Arrow keys toggle
@@ -106,6 +110,12 @@ class SettingsPage {
this.app.player.saveSettings(); this.app.player.saveSettings();
}); });
// Auto transcode (smart) toggle
autoTranscodeToggle?.addEventListener('change', () => {
this.app.player.settings.autoTranscode = autoTranscodeToggle.checked;
this.app.player.saveSettings();
});
// EPG refresh interval // EPG refresh interval
const epgRefreshSelect = document.getElementById('epg-refresh-interval'); const epgRefreshSelect = document.getElementById('epg-refresh-interval');
if (epgRefreshSelect && this.app.player?.settings) { if (epgRefreshSelect && this.app.player?.settings) {
@@ -271,6 +281,7 @@ class SettingsPage {
const forceProxyToggle = document.getElementById('setting-force-proxy'); const forceProxyToggle = document.getElementById('setting-force-proxy');
const forceTranscodeToggle = document.getElementById('setting-force-transcode'); const forceTranscodeToggle = document.getElementById('setting-force-transcode');
const forceRemuxToggle = document.getElementById('setting-force-remux'); const forceRemuxToggle = document.getElementById('setting-force-remux');
const autoTranscodeToggle = document.getElementById('setting-auto-transcode');
const epgRefreshSelect = document.getElementById('epg-refresh-interval'); const epgRefreshSelect = document.getElementById('epg-refresh-interval');
const streamFormatSelect = document.getElementById('setting-stream-format'); const streamFormatSelect = document.getElementById('setting-stream-format');
@@ -283,6 +294,7 @@ class SettingsPage {
if (forceProxyToggle) forceProxyToggle.checked = s.forceProxy || false; if (forceProxyToggle) forceProxyToggle.checked = s.forceProxy || false;
if (forceTranscodeToggle) forceTranscodeToggle.checked = s.forceTranscode || false; if (forceTranscodeToggle) forceTranscodeToggle.checked = s.forceTranscode || false;
if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false; if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false;
if (autoTranscodeToggle) autoTranscodeToggle.checked = s.autoTranscode || false;
if (epgRefreshSelect) epgRefreshSelect.value = s.epgRefreshInterval || '24'; if (epgRefreshSelect) epgRefreshSelect.value = s.epgRefreshInterval || '24';
if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'm3u8'; if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'm3u8';
} }
+35
View File
@@ -229,6 +229,41 @@ class WatchPage {
const isRawTs = url.includes('.ts') && !url.includes('.m3u8'); const isRawTs = url.includes('.ts') && !url.includes('.m3u8');
const isDirectVideo = url.includes('.mp4') || url.includes('.mkv') || url.includes('.avi'); const isDirectVideo = url.includes('.mp4') || url.includes('.mkv') || url.includes('.avi');
// Priority 0: Auto Transcode (Smart) - probe first, then decide
if (settings.autoTranscode) {
console.log('[WatchPage] Auto Transcode enabled. Probing stream...');
try {
const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(url)}`);
const info = await probeRes.json();
console.log(`[WatchPage] Probe result: video=${info.video}, audio=${info.audio}, compatible=${info.compatible}`);
if (info.needsTranscode) {
console.log('[WatchPage] Auto: Using transcode (incompatible audio)');
const finalUrl = `/api/transcode?url=${encodeURIComponent(url)}`;
this.video.src = finalUrl;
this.video.play().catch(e => {
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
});
this.setVolumeFromStorage();
return;
} else if (info.needsRemux) {
console.log('[WatchPage] Auto: Using remux (.ts container)');
const finalUrl = `/api/remux?url=${encodeURIComponent(url)}`;
this.video.src = finalUrl;
this.video.play().catch(e => {
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
});
this.setVolumeFromStorage();
return;
}
// Compatible - fall through to normal playback
console.log('[WatchPage] Auto: Using normal playback (compatible)');
} catch (err) {
console.warn('[WatchPage] Probe failed, using normal playback:', err.message);
// Continue with normal playback on probe failure
}
}
// Priority 1: Force Transcode - route through FFmpeg // Priority 1: Force Transcode - route through FFmpeg
if (settings.forceTranscode) { if (settings.forceTranscode) {
console.log('[WatchPage] Force Transcode enabled'); console.log('[WatchPage] Force Transcode enabled');
+1
View File
@@ -65,6 +65,7 @@ function getDefaultSettings() {
forceProxy: false, forceProxy: false,
forceTranscode: false, forceTranscode: false,
forceRemux: false, forceRemux: false,
autoTranscode: true,
streamFormat: 'm3u8', streamFormat: 'm3u8',
epgRefreshInterval: '24' 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.ffmpegPath = findFFmpeg();
app.locals.ffprobePath = findFFprobe();
// API Routes // API Routes
app.use('/api/auth', require('./routes/auth')); 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/favorites', require('./routes/favorites'));
app.use('/api/transcode', require('./routes/transcode')); app.use('/api/transcode', require('./routes/transcode'));
app.use('/api/remux', require('./routes/remux')); app.use('/api/remux', require('./routes/remux'));
app.use('/api/probe', require('./routes/probe'));
app.use('/api/settings', require('./routes/settings')); app.use('/api/settings', require('./routes/settings'));
// SPA fallback - serve index.html for all non-API routes // 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_streamed', '1',
'-reconnect_delay_max', '5', '-reconnect_delay_max', '5',
'-i', url, '-i', url,
// Map all streams explicitly // STRICT MAPPING: Only map video and audio, ignore subtitles/data/attachments
'-map', '0', // This prevents remux failure when source container has incompatible subtitle tracks (e.g. MKV -> MP4)
// Copy ALL streams without re-encoding '-map', '0:v',
'-map', '0:a',
// Drop subtitles (-sn) and data (-dn) explicitly
'-sn', '-dn',
// Copy streams without re-encoding
'-c', 'copy', '-c', 'copy',
// NOTE: We intentionally do NOT use -bsf:a aac_adtstoasc here // NOTE: We intentionally do NOT use -bsf:a aac_adtstoasc here
// That filter only works for AAC audio and breaks AC3/EAC3/MP3. // That filter only works for AAC audio and breaks AC3/EAC3/MP3.