feat: Add FFmpeg audio transcoding for Dolby/AC3/EAC3 compatibility
- Add /api/transcode endpoint for audio-only transcoding - Video passthrough (no re-encoding) for fast performance - Audio transcode to AAC for browser compatibility - Add 'Force Audio Transcode' setting toggle in UI - Add ffmpeg-static as optional dependency - Handle Electron asar.unpacked path for packaged apps
This commit is contained in:
@@ -11,11 +11,28 @@ const PORT = process.env.PORT || 3000;
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
app.use(express.static(path.join(__dirname, '..', 'public')));
|
||||
|
||||
// FFMPEG Configuration (optional - for transcoding support)
|
||||
try {
|
||||
let ffmpegPath = require('ffmpeg-static');
|
||||
|
||||
// In packaged Electron apps, ffmpeg-static returns path inside .asar archive
|
||||
// but the binary is actually unpacked to app.asar.unpacked
|
||||
if (ffmpegPath && ffmpegPath.includes('app.asar')) {
|
||||
ffmpegPath = ffmpegPath.replace('app.asar', 'app.asar.unpacked');
|
||||
}
|
||||
|
||||
app.locals.ffmpegPath = ffmpegPath;
|
||||
console.log('FFmpeg binary configured at:', ffmpegPath);
|
||||
} catch (err) {
|
||||
console.warn('FFmpeg not available - transcoding will be disabled. Install ffmpeg-static for transcoding support.');
|
||||
}
|
||||
|
||||
// API Routes
|
||||
app.use('/api/sources', require('./routes/sources'));
|
||||
app.use('/api/proxy', require('./routes/proxy'));
|
||||
app.use('/api/channels', require('./routes/channels'));
|
||||
app.use('/api/favorites', require('./routes/favorites'));
|
||||
app.use('/api/transcode', require('./routes/transcode'));
|
||||
|
||||
// SPA fallback - serve index.html for all non-API routes
|
||||
app.get('*', (req, res) => {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
/**
|
||||
* Transcode stream
|
||||
* GET /api/transcode?url=...
|
||||
*
|
||||
* Transcodes audio to AAC for browser compatibility while passing video through.
|
||||
* This fixes playback issues with Dolby/AC3/EAC3 audio that browsers can't decode.
|
||||
*/
|
||||
router.get('/', (req, res) => {
|
||||
const { url } = req.query;
|
||||
if (!url) {
|
||||
return res.status(400).json({ error: 'URL parameter is required' });
|
||||
}
|
||||
|
||||
const ffmpegPath = req.app.locals.ffmpegPath || 'ffmpeg';
|
||||
console.log(`[Transcode] Starting transcoding for: ${url}`);
|
||||
console.log(`[Transcode] Using binary: ${ffmpegPath}`);
|
||||
|
||||
// FFmpeg arguments for transcoding
|
||||
// Video: Copy (passthrough) - most sources are already H264/HEVC which browsers support
|
||||
// Audio: Transcode to AAC - fixes Dolby/AC3/EAC3 that browsers can't play
|
||||
const args = [
|
||||
'-hide_banner',
|
||||
'-loglevel', 'info',
|
||||
'-fflags', '+genpts',
|
||||
'-i', url,
|
||||
'-c:v', 'copy', // Video passthrough (no re-encoding = fast!)
|
||||
'-c:a', 'aac', // Transcode audio to browser-compatible AAC
|
||||
'-ac', '2',
|
||||
'-ar', '48000',
|
||||
'-b:a', '128k',
|
||||
'-af', 'aresample=48000:async=1',
|
||||
// Fragmented MP4 for streaming (browser-compatible)
|
||||
'-f', 'mp4',
|
||||
'-movflags', 'frag_keyframe+empty_moov+default_base_moof',
|
||||
'-' // Output to stdout
|
||||
];
|
||||
|
||||
console.log(`[Transcode] Full command: ${ffmpegPath} ${args.join(' ')}`);
|
||||
|
||||
let ffmpeg;
|
||||
try {
|
||||
ffmpeg = spawn(ffmpegPath, args);
|
||||
} catch (spawnErr) {
|
||||
console.error('[Transcode] Failed to spawn FFmpeg:', spawnErr);
|
||||
return res.status(500).json({ error: 'FFmpeg spawn failed', details: spawnErr.message });
|
||||
}
|
||||
|
||||
// Collect stderr for error reporting
|
||||
let stderrBuffer = '';
|
||||
|
||||
// Set headers for fragmented MP4
|
||||
res.setHeader('Content-Type', 'video/mp4');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
|
||||
// Pipe stdout to response
|
||||
ffmpeg.stdout.pipe(res);
|
||||
|
||||
// Log stderr (useful for debugging transcoding failures)
|
||||
ffmpeg.stderr.on('data', (data) => {
|
||||
const msg = data.toString();
|
||||
stderrBuffer += msg;
|
||||
console.log(`[FFmpeg] ${msg}`);
|
||||
});
|
||||
|
||||
// Cleanup on client disconnect
|
||||
req.on('close', () => {
|
||||
console.log('[Transcode] Client disconnected, killing FFmpeg process');
|
||||
ffmpeg.kill('SIGKILL');
|
||||
});
|
||||
|
||||
// Handle process exit
|
||||
ffmpeg.on('exit', (code) => {
|
||||
if (code !== null && code !== 0 && code !== 255) { // 255 is often returned on kill
|
||||
console.error(`[Transcode] FFmpeg exited with code ${code}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle spawn errors
|
||||
ffmpeg.on('error', (err) => {
|
||||
console.error('[Transcode] Failed to spawn FFmpeg:', err);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Transcoding failed to start' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user