diff --git a/package-lock.json b/package-lock.json
index e0686c7..2d5ed73 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,8 +14,34 @@
},
"engines": {
"node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "ffmpeg-static": "^5.2.0"
}
},
+ "node_modules/@derhuerst/http-basic": {
+ "version": "8.2.4",
+ "resolved": "https://registry.npmjs.org/@derhuerst/http-basic/-/http-basic-8.2.4.tgz",
+ "integrity": "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "caseless": "^0.12.0",
+ "concat-stream": "^2.0.0",
+ "http-response-object": "^3.0.1",
+ "parse-cache-control": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "10.17.60",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz",
+ "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -29,6 +55,44 @@
"node": ">= 0.6"
}
},
+ "node_modules/agent-base": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
+ "node_modules/agent-base/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/agent-base/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -59,6 +123,13 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
+ "node_modules/buffer-from": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -97,6 +168,29 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/caseless": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
+ "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==",
+ "license": "Apache-2.0",
+ "optional": true
+ },
+ "node_modules/concat-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
+ "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
+ "engines": [
+ "node >= 6.0"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "buffer-from": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.0.2",
+ "typedarray": "^0.0.6"
+ }
+ },
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -190,6 +284,16 @@
"node": ">= 0.8"
}
},
+ "node_modules/env-paths": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
+ "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -281,6 +385,23 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/ffmpeg-static": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/ffmpeg-static/-/ffmpeg-static-5.3.0.tgz",
+ "integrity": "sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==",
+ "hasInstallScript": true,
+ "license": "GPL-3.0-or-later",
+ "optional": true,
+ "dependencies": {
+ "@derhuerst/http-basic": "^8.2.0",
+ "env-paths": "^2.2.0",
+ "https-proxy-agent": "^5.0.0",
+ "progress": "^2.0.3"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -419,6 +540,55 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/http-response-object": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz",
+ "integrity": "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@types/node": "^10.0.3"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+ "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/https-proxy-agent/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/https-proxy-agent/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -554,6 +724,12 @@
"node": ">= 0.8"
}
},
+ "node_modules/parse-cache-control": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz",
+ "integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==",
+ "optional": true
+ },
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -569,6 +745,16 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
+ "node_modules/progress": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
+ "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -621,6 +807,21 @@
"node": ">= 0.8"
}
},
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -785,6 +986,16 @@
"node": ">= 0.8"
}
},
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -807,6 +1018,13 @@
"node": ">= 0.6"
}
},
+ "node_modules/typedarray": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
+ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -816,6 +1034,13 @@
"node": ">= 0.8"
}
},
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
diff --git a/package.json b/package.json
index 9aae42b..0f23544 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,9 @@
"express": "^4.18.2",
"xml2js": "^0.6.2"
},
+ "optionalDependencies": {
+ "ffmpeg-static": "^5.2.0"
+ },
"engines": {
"node": ">=18.0.0"
}
diff --git a/public/index.html b/public/index.html
index 647ee8e..843c147 100644
--- a/public/index.html
+++ b/public/index.html
@@ -343,6 +343,17 @@
directly.
+
+
+
+
+
+
+ Force Audio Transcode
+ Transcode audio to AAC for browser compatibility. Enable if you get video
+ but no audio (fixes Dolby/AC3/EAC3). Requires ffmpeg-static.
+
+
Stream Output Format
diff --git a/public/js/components/VideoPlayer.js b/public/js/components/VideoPlayer.js
index 1d76801..3eaef0f 100644
--- a/public/js/components/VideoPlayer.js
+++ b/public/js/components/VideoPlayer.js
@@ -33,7 +33,8 @@ class VideoPlayer {
rememberVolume: true, // Persist volume between sessions
lastVolume: 80, // Last used volume (if rememberVolume is true)
autoPlayNextEpisode: false, // Auto-play next episode in series
- forceProxy: false // Force all streams through backend proxy
+ forceProxy: false, // Force all streams through backend proxy
+ forceTranscode: false // Force audio transcoding (fixes Dolby/AC3 audio)
};
try {
const saved = localStorage.getItem('nodecast_tv_player_settings');
@@ -67,12 +68,16 @@ class VideoPlayer {
maxBufferLength: 30, // Buffer up to 30 seconds of content
maxMaxBufferLength: 60, // Absolute max buffer 60 seconds
maxBufferSize: 60 * 1000 * 1000, // 60MB max buffer size
+ maxBufferHole: 0.5, // Allow 0.5s holes in buffer (helps with discontinuities)
// Live stream settings - stay further from live edge for stability
liveSyncDurationCount: 3, // Stay 3 segments behind live
liveMaxLatencyDurationCount: 10, // Allow up to 10 segments behind before catching up
- // Handle audio discontinuities (fixes garbled audio during ad transitions)
+ liveBackBufferLength: 30, // Keep 30s of back buffer for seeking
+ // Audio discontinuity handling (fixes garbled audio during ad transitions)
stretchShortVideoTrack: true, // Stretch short segments to avoid gaps
forceKeyFrameOnDiscontinuity: true, // Force keyframe sync on discontinuity
+ // More aggressive discontinuity recovery
+ maxAudioFramesDrift: 1, // Allow minimal audio drift before correction
// Faster recovery from errors
levelLoadingMaxRetry: 4,
manifestLoadingMaxRetry: 4,
@@ -100,9 +105,10 @@ class VideoPlayer {
// Initialize HLS.js if supported
if (Hls.isSupported()) {
this.hls = new Hls(this.getHlsConfig());
+ this.lastDiscontinuity = -1; // Track discontinuity changes
this.hls.on(Hls.Events.ERROR, (event, data) => {
- console.error('HLS error:', data);
+ console.error('HLS error:', data.type, data.details);
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
@@ -130,6 +136,10 @@ class VideoPlayer {
// Non-fatal media error - try to recover (handles audio codec issues)
console.log('Non-fatal media error, attempting recovery...');
this.hls.recoverMediaError();
+ } else if (data.details === 'bufferAppendError' || data.details === 'fragParsingError') {
+ // Buffer/parsing errors during ad transitions - try recovery
+ console.log('Buffer/parsing error during segment, recovering...');
+ this.hls.recoverMediaError();
}
});
@@ -144,6 +154,27 @@ class VideoPlayer {
this.hls.recoverMediaError();
});
+ // Detect discontinuity changes (ad transitions) and help decoder reset
+ this.hls.on(Hls.Events.FRAG_CHANGED, (event, data) => {
+ const frag = data.frag;
+ // Debug: log every fragment change
+ console.log(`[HLS] FRAG_CHANGED: sn=${frag?.sn}, cc=${frag?.cc}, level=${frag?.level}`);
+
+ if (frag && frag.sn !== 'initSegment') {
+ // Check if we crossed a discontinuity boundary using CC (Continuity Counter)
+ if (frag.cc !== undefined && frag.cc !== this.lastDiscontinuity) {
+ console.log(`[HLS] Discontinuity detected: CC ${this.lastDiscontinuity} -> ${frag.cc}`);
+ this.lastDiscontinuity = frag.cc;
+
+ // Small nudge to help decoder sync (only if playing)
+ if (!this.video.paused && this.video.currentTime > 0) {
+ const nudgeAmount = 0.01;
+ this.video.currentTime += nudgeAmount;
+ }
+ }
+ }
+ });
+
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
this.video.play().catch(e => console.log('Autoplay prevented:', e));
});
@@ -202,6 +233,25 @@ class VideoPlayer {
// Determine if HLS or direct stream
this.currentUrl = streamUrl;
+ // CHECK: Force Transcode Priority - transcoded streams bypass HLS.js
+ if (this.settings.forceTranscode) {
+ console.log('[Player] Force Transcode enabled. Routing through ffmpeg...');
+ const transcodeUrl = this.getTranscodeUrl(streamUrl);
+ this.currentUrl = transcodeUrl;
+
+ // Transcoded streams are fragmented MP4 - play directly with element
+ console.log('[Player] Playing transcoded stream directly:', transcodeUrl);
+ this.video.src = transcodeUrl;
+ this.video.play().catch(e => console.log('[Player] Autoplay prevented:', e));
+
+ // Update UI and dispatch events
+ this.updateNowPlaying(channel);
+ this.showNowPlayingOverlay();
+ this.fetchEpgData(channel);
+ window.dispatchEvent(new CustomEvent('channelChanged', { detail: channel }));
+ return; // Exit early - don't use HLS.js path
+ }
+
// Proactively use proxy for:
// 1. User enabled "Force Proxy" in settings
// 2. Known CORS-restricted domains (like Pluto TV)
@@ -245,6 +295,28 @@ class VideoPlayer {
} else {
console.error('Fatal HLS error:', data);
}
+ } else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
+ // Non-fatal media error - just log it (auto-recovery can cause black screens)
+ console.log('Non-fatal media error:', data.details);
+ }
+ });
+
+ // Detect discontinuity changes (ad transitions) and help decoder reset
+ this.lastDiscontinuity = -1;
+ this.hls.on(Hls.Events.FRAG_CHANGED, (event, data) => {
+ const frag = data.frag;
+ if (frag && frag.sn !== 'initSegment') {
+ // Check if we crossed a discontinuity boundary using CC (Continuity Counter)
+ if (frag.cc !== undefined && frag.cc !== this.lastDiscontinuity) {
+ console.log(`[HLS] Discontinuity detected: CC ${this.lastDiscontinuity} -> ${frag.cc}`);
+ this.lastDiscontinuity = frag.cc;
+
+ // Small nudge to help decoder sync (only if playing)
+ if (!this.video.paused && this.video.currentTime > 0) {
+ const nudgeAmount = 0.01;
+ this.video.currentTime += nudgeAmount;
+ }
+ }
}
});
} else if (this.video.canPlayType('application/vnd.apple.mpegurl') === 'probably' ||
@@ -377,6 +449,13 @@ class VideoPlayer {
return `/api/proxy/stream?url=${encodeURIComponent(url)}`;
}
+ /**
+ * Get transcoded URL for a stream (audio transcoding for browser compatibility)
+ */
+ getTranscodeUrl(url) {
+ return `/api/transcode?url=${encodeURIComponent(url)}`;
+ }
+
/**
* Decode base64 EPG data
*/
diff --git a/public/js/pages/Settings.js b/public/js/pages/Settings.js
index 18dc27b..e3d2208 100644
--- a/public/js/pages/Settings.js
+++ b/public/js/pages/Settings.js
@@ -29,6 +29,7 @@ class SettingsPage {
const rememberVolumeToggle = document.getElementById('setting-remember-volume');
const autoPlayNextToggle = document.getElementById('setting-autoplay-next');
const forceProxyToggle = document.getElementById('setting-force-proxy');
+ const forceTranscodeToggle = document.getElementById('setting-force-transcode');
// Load current settings
if (this.app.player?.settings) {
@@ -41,6 +42,9 @@ class SettingsPage {
if (forceProxyToggle) {
forceProxyToggle.checked = this.app.player.settings.forceProxy || false;
}
+ if (forceTranscodeToggle) {
+ forceTranscodeToggle.checked = this.app.player.settings.forceTranscode || false;
+ }
}
// Arrow keys toggle
@@ -83,6 +87,12 @@ class SettingsPage {
this.app.player.saveSettings();
});
+ // Force transcode toggle
+ forceTranscodeToggle?.addEventListener('change', () => {
+ this.app.player.settings.forceTranscode = forceTranscodeToggle.checked;
+ this.app.player.saveSettings();
+ });
+
// EPG refresh interval
const epgRefreshSelect = document.getElementById('epg-refresh-interval');
if (epgRefreshSelect) {
diff --git a/server/index.js b/server/index.js
index 4337769..cc478e7 100644
--- a/server/index.js
+++ b/server/index.js
@@ -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) => {
diff --git a/server/routes/transcode.js b/server/routes/transcode.js
new file mode 100644
index 0000000..093ae39
--- /dev/null
+++ b/server/routes/transcode.js
@@ -0,0 +1,93 @@
+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
+ // Optimized for VOD content with incompatible audio (Dolby/AC3/EAC3)
+ // NOT recommended for live streams - use HLS.js directly for those
+ const args = [
+ '-hide_banner',
+ '-loglevel', 'warning',
+ '-fflags', '+genpts',
+ '-i', url,
+ // Video: passthrough (no re-encoding = fast!)
+ '-c:v', 'copy',
+ // Audio: Transcode to browser-compatible AAC with consistent parameters
+ '-c:a', '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;