feat: added smart auto-transcoding with probe-based routing and MKV/remux support
This commit is contained in:
@@ -65,6 +65,7 @@ function getDefaultSettings() {
|
||||
forceProxy: false,
|
||||
forceTranscode: false,
|
||||
forceRemux: false,
|
||||
autoTranscode: true,
|
||||
streamFormat: 'm3u8',
|
||||
epgRefreshInterval: '24'
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user