From 788d43a4cbc0e18e8748828199c791a65a292467 Mon Sep 17 00:00:00 2001 From: Technomancer702 Date: Mon, 12 Oct 2026 16:11:34 -0700 Subject: [PATCH] Apply AAC remux filters only to matching audio tracks Adapted from MikaelKW/nodecast-tv-plus commit 25f7cb636a07a3efb2c409ff182920ae0fbe01bd. Co-authored-by: Mikael --- public/js/components/VideoPlayer.js | 23 +++++++-- public/js/pages/WatchPage.js | 23 ++++++++- server/routes/probe.js | 2 + server/routes/remux.js | 13 +++-- server/services/remuxAudio.js | 17 +++++++ test/remux-playback.test.js | 73 +++++++++++++++++++++++++++++ test/remux.test.js | 34 +++++++++++++- 7 files changed, 174 insertions(+), 11 deletions(-) create mode 100644 server/services/remuxAudio.js create mode 100644 test/remux-playback.test.js diff --git a/public/js/components/VideoPlayer.js b/public/js/components/VideoPlayer.js index 0cd2eef..076018c 100644 --- a/public/js/components/VideoPlayer.js +++ b/public/js/components/VideoPlayer.js @@ -859,6 +859,8 @@ class VideoPlayer { // Stop current playback this.stop(); + const playbackGeneration = this.playbackGeneration; + let probeInfo = null; this.updateTranscodeStatus('hidden'); // Hide "select a channel" overlay @@ -877,6 +879,8 @@ class VideoPlayer { try { const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}`); const info = await probeRes.json(); + if (this.playbackGeneration !== playbackGeneration) return; + probeInfo = info; console.log(`[Player] Probe result: video=${info.video}, audio=${info.audio}, ${info.width}x${info.height}, compatible=${info.compatible}`); // Store probe result for quality badge display @@ -936,7 +940,7 @@ class VideoPlayer { // Raw .ts container - use remux console.log('[Player] Auto: Using remux (.ts container)'); this.updateTranscodeStatus('remuxing', 'Remux (Auto)'); - const remuxUrl = `/api/remux?url=${encodeURIComponent(streamUrl)}`; + const remuxUrl = this.getRemuxUrl(streamUrl, info.audioCodecs || [info.audio]); this.currentUrl = remuxUrl; this.video.src = remuxUrl; this.video.play().catch(e => { @@ -1055,7 +1059,17 @@ class VideoPlayer { console.log('[Player] Force Remux enabled. Routing through FFmpeg remux...'); console.log('[Player] Stream type:', isRawTs ? 'Raw TS' : 'Extension-less (assumed TS)'); this.updateTranscodeStatus('remuxing', 'Remux (Force)'); - const remuxUrl = this.getRemuxUrl(streamUrl); + if (!probeInfo) { + try { + const response = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}`); + probeInfo = await response.json(); + } catch (error) { + console.warn('[Player] Remux probe failed:', error.message); + } + } + if (this.playbackGeneration !== playbackGeneration) return; + const remuxUrl = this.getRemuxUrl(streamUrl, probeInfo?.audioCodecs || (probeInfo?.audio ? [probeInfo.audio] : [])); + this.currentUrl = remuxUrl; this.video.src = remuxUrl; this.video.play().catch(e => { if (e.name !== 'AbortError') console.log('[Player] Autoplay prevented:', e); @@ -1369,8 +1383,8 @@ class VideoPlayer { * Get remuxed URL for a stream (container conversion only, no re-encoding) * Used for raw .ts streams that browsers can't play directly */ - getRemuxUrl(url) { - return `/api/remux?url=${encodeURIComponent(url)}`; + getRemuxUrl(url, audioCodecs = []) { + return `/api/remux?url=${encodeURIComponent(url)}&audioCodecs=${encodeURIComponent(audioCodecs.join(','))}`; } /** @@ -1389,6 +1403,7 @@ class VideoPlayer { * Stop playback */ stop() { + this.playbackGeneration = (this.playbackGeneration || 0) + 1; // Stop any running transcode session first this.stopTranscodeSession(); diff --git a/public/js/pages/WatchPage.js b/public/js/pages/WatchPage.js index 2a38c69..a229438 100644 --- a/public/js/pages/WatchPage.js +++ b/public/js/pages/WatchPage.js @@ -415,6 +415,8 @@ class WatchPage { // Stop any existing playback this.stop(); + const playbackGeneration = this.playbackGeneration; + let probeInfo = null; // Show loading spinner this.showLoading(); @@ -439,6 +441,8 @@ class WatchPage { const ua = settings.userAgentPreset === 'custom' ? settings.userAgentCustom : settings.userAgentPreset; const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(url)}&ua=${encodeURIComponent(ua || '')}`); const info = await probeRes.json(); + if (this.playbackGeneration !== playbackGeneration) return; + probeInfo = info; console.log(`[WatchPage] Probe result: video=${info.video}, audio=${info.audio}, ${info.width}x${info.height}, compatible=${info.compatible}`); // Store early probe info for quality display @@ -471,7 +475,7 @@ class WatchPage { // TODO: Move remux to session logic if seeking is needed for TS files console.log('[WatchPage] Auto: Using remux (.ts container)'); this.updateTranscodeStatus('remuxing', 'Remux (Auto)'); - const finalUrl = `/api/remux?url=${encodeURIComponent(url)}`; + const finalUrl = this.getRemuxUrl(url, info.audioCodecs || [info.audio]); this.video.src = finalUrl; this.video.play().catch(e => { if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e); @@ -529,7 +533,17 @@ class WatchPage { if (settings.forceRemux && isRawTs) { console.log('[WatchPage] Force Remux enabled'); this.updateTranscodeStatus('remuxing', 'Remux (Force)'); - const finalUrl = `/api/remux?url=${encodeURIComponent(url)}`; + if (!probeInfo) { + try { + const ua = settings.userAgentPreset === 'custom' ? settings.userAgentCustom : settings.userAgentPreset; + const response = await fetch(`/api/probe?url=${encodeURIComponent(url)}&ua=${encodeURIComponent(ua || '')}`); + probeInfo = await response.json(); + } catch (error) { + console.warn('[WatchPage] Remux probe failed:', error.message); + } + } + if (this.playbackGeneration !== playbackGeneration) return; + const finalUrl = this.getRemuxUrl(url, probeInfo?.audioCodecs || (probeInfo?.audio ? [probeInfo.audio] : [])); this.video.src = finalUrl; this.video.play().catch(e => { if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e); @@ -617,7 +631,12 @@ class WatchPage { if (this.volumeSlider) this.volumeSlider.value = savedVolume; } + getRemuxUrl(url, audioCodecs = []) { + return `/api/remux?url=${encodeURIComponent(url)}&audioCodecs=${encodeURIComponent(audioCodecs.join(','))}`; + } + stop() { + this.playbackGeneration = (this.playbackGeneration || 0) + 1; // Stop history tracking and save final progress this.stopHistoryTracking(); this.saveProgress(); diff --git a/server/routes/probe.js b/server/routes/probe.js index a085871..1d5c9b8 100644 --- a/server/routes/probe.js +++ b/server/routes/probe.js @@ -133,6 +133,8 @@ function analyzeProbeResult(probeResult, url) { return { video: videoCodec, audio: audioCodec, + audioCodecs: streams.filter(s => s.codec_type === 'audio') + .map(s => s.codec_name?.toLowerCase() || 'unknown'), width: videoStream?.width || 0, height: videoStream?.height || 0, audioChannels: audioStream?.channels || 0, // For Smart Audio Copy diff --git a/server/routes/remux.js b/server/routes/remux.js index 34b442f..46fa78a 100644 --- a/server/routes/remux.js +++ b/server/routes/remux.js @@ -2,6 +2,7 @@ const express = require('express'); const router = express.Router(); const { spawn } = require('child_process'); const db = require('../db'); +const { parseAudioCodecs, audioFilterArgs } = require('../services/remuxAudio'); /** * Allowed input URL schemes for the remux endpoint. @@ -70,6 +71,13 @@ router.get('/', async (req, res) => { return res.status(400).json({ error: 'URL must be an http:// or https:// stream URL' }); } + let audioCodecs; + try { + audioCodecs = parseAudioCodecs(req.query.audioCodecs); + } catch (error) { + return res.status(400).json({ error: error.message }); + } + const ffmpegPath = req.app.locals.ffmpegPath || 'ffmpeg'; // Get User-Agent from settings @@ -118,9 +126,8 @@ router.get('/', async (req, res) => { '-c', 'copy', // Ensure extradata is correctly extracted/converted (fixes Annex B -> AVCC issues in Firefox) '-bsf:v', 'dump_extra', - // NOTE: We intentionally do NOT use -bsf:a aac_adtstoasc here - // That filter only works for AAC audio and breaks AC3/EAC3/MP3. - // If AAC audio from MPEG-TS fails in MP4, use /api/transcode instead. + // Convert ADTS only on probed AAC tracks; leave MP3/AC3/EAC3 untouched. + ...audioFilterArgs(audioCodecs), // Handle timestamp discontinuities at output '-fps_mode', 'passthrough', '-max_muxing_queue_size', '1024', diff --git a/server/services/remuxAudio.js b/server/services/remuxAudio.js new file mode 100644 index 0000000..d06cc16 --- /dev/null +++ b/server/services/remuxAudio.js @@ -0,0 +1,17 @@ +// Preserve audio stream order: -map 0:a maps every input audio track. +function parseAudioCodecs(value) { + if (value === undefined || value === '') return []; + if (typeof value !== 'string') throw new Error('audioCodecs must be a comma-separated string'); + const codecs = value.split(',').map(codec => codec.trim().toLowerCase()); + if (codecs.length > 16 || codecs.some(codec => !/^[a-z0-9][a-z0-9_.-]{0,31}$/.test(codec))) { + throw new Error('audioCodecs contains invalid codec names'); + } + return codecs; +} + +function audioFilterArgs(codecs) { + return codecs.flatMap((codec, index) => codec === 'aac' + ? [`-bsf:a:${index}`, 'aac_adtstoasc'] : []); +} + +module.exports = { parseAudioCodecs, audioFilterArgs }; diff --git a/test/remux-playback.test.js b/test/remux-playback.test.js new file mode 100644 index 0000000..d69efed --- /dev/null +++ b/test/remux-playback.test.js @@ -0,0 +1,73 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { createRequire } = require('node:module'); + +test('probe preserves every audio track index including unknown codecs', () => { + const filename = path.join(__dirname, '..', 'server/routes/probe.js'); + const context = vm.createContext({ require: createRequire(filename), module: { exports: {} }, console }); + vm.runInContext(fs.readFileSync(filename, 'utf8'), context, { filename }); + const info = context.analyzeProbeResult({ streams: [ + { codec_type: 'video', codec_name: 'h264' }, + { codec_type: 'audio', codec_name: 'mp3' }, + { codec_type: 'subtitle', codec_name: 'srt' }, + { codec_type: 'audio' }, { codec_type: 'audio', codec_name: 'AAC' } + ], format: { format_name: 'mpegts' } }, 'http://provider.test/media.ts'); + assert.deepEqual(Array.from(info.audioCodecs), ['mp3', 'unknown', 'aac']); + assert.equal(info.needsRemux, true); +}); + +function playerFixture(name, settings, probe) { + const window = { dispatchEvent() {} }; + const context = { window, console: { log() {}, warn() {}, error() {} }, + CustomEvent: class {}, document: { getElementById() { return null; } }, + API: { settings: { get: async () => settings } }, fetch: probe }; + const file = name === 'VideoPlayer' ? 'components/VideoPlayer.js' : 'pages/WatchPage.js'; + vm.runInNewContext(fs.readFileSync(path.join(__dirname, '..', 'public/js', file), 'utf8'), context); + const player = Object.create(window[name].prototype); + const element = { classList: { add() {}, remove() {} } }; + Object.assign(player, { settings, video: { src: '', play: async () => {}, querySelectorAll: () => [] }, + overlay: element, nowPlaying: element, + stop() { this.playbackGeneration = (this.playbackGeneration || 0) + 1; this.video.src = ''; }, + updateTranscodeStatus() {}, updateQualityBadge() {}, updateNowPlaying() {}, + showNowPlayingOverlay() {}, fetchEpgData() {}, setVolumeFromStorage() {}, showLoading() {}, + showError(message) { throw new Error(message); } + }); + player.start = url => name === 'VideoPlayer' ? player.play({ id: url }, url) : player.loadVideo(url); + return player; +} + +for (const name of ['VideoPlayer', 'WatchPage']) { + for (const mode of ['autoTranscode', 'forceRemux']) { + test(`${name} ${mode} propagates mixed audio codecs to the remux request`, async () => { + const requests = []; + const player = playerFixture(name, { [mode]: true }, async url => { + requests.push(url); + return { json: async () => ({ audio: 'mp3', audioCodecs: ['mp3', 'aac'], needsRemux: true }) }; + }); + const url = 'http://provider.test/media.ts?password=abc/'; + await player.start(url); + const remux = new URL(player.video.src, 'http://app.test'); + assert.equal(remux.pathname, '/api/remux'); + assert.equal(remux.searchParams.get('url'), url); + assert.equal(remux.searchParams.get('audioCodecs'), 'mp3,aac'); + assert.equal(requests.length, 1); + }); + } + test(`${name} ignores a remux probe that completes after playback stops`, async () => { + let complete, started; + const probing = new Promise(resolve => { started = resolve; }); + const player = playerFixture(name, { forceRemux: true }, () => { + started(); + return new Promise(resolve => { complete = resolve; }); + }); + const playing = player.start('http://provider.test/media.ts'); + await probing; + player.stop(); + complete({ json: async () => ({ audioCodecs: ['aac'] }) }); + await playing; + assert.equal(player.video.src, ''); + }); +} diff --git a/test/remux.test.js b/test/remux.test.js index 62b13b1..16300f8 100644 --- a/test/remux.test.js +++ b/test/remux.test.js @@ -112,6 +112,22 @@ test('remux accepts HTTP(S), preserves query credentials, and restricts nested p } }); +test('remux filters only AAC output tracks and rejects malformed codec hints', async t => { + const { base, calls } = await routeFixture(t); + const url = `${base}/api/remux?url=http%3A%2F%2Fprovider.test%2Fmedia.ts`; + assert.equal((await request(`${url}&audioCodecs=mp3%2CAAC%2Cunknown%2Caac%2Cac3`)).status, 200); + const args = calls.at(-1).args; + assert.equal(args[args.indexOf('-bsf:a:1') + 1], 'aac_adtstoasc'); + assert.equal(args[args.indexOf('-bsf:a:3') + 1], 'aac_adtstoasc'); + assert.equal(args.filter(arg => arg.startsWith('-bsf:a')).length, 2); + for (const query of ['audioCodecs=aac&audioCodecs=mp3', 'audioCodecs[x]=aac', + 'audioCodecs=aac,,mp3', 'audioCodecs=-i', + `audioCodecs=${Array(17).fill('aac').join(',')}`]) { + assert.equal((await request(`${url}&${query}`)).status, 400, query); + } + assert.equal(calls.length, 1); +}); + function findFFmpeg() { // Match server/index.js: prefer system FFmpeg, then the optional binary. if (spawnSync('ffmpeg', ['-version'], { stdio: 'ignore' }).status === 0) return 'ffmpeg'; @@ -153,11 +169,17 @@ test('real FFmpeg remuxes encrypted HLS over HTTP and blocks local HLS segments' '-c:v', 'libx264', '-threads', '1', '-preset', 'ultrafast', '-tune', 'zerolatency', '-c:a', 'libmp3lame', '-f', 'mpegts', sample]); assert.equal(generated.code, 0, generated.stderr); + const mixedSample = path.join(dir, 'mixed.ts'); + const mixedGenerated = await runFFmpeg(ffmpeg, ['-hide_banner', '-loglevel', 'error', '-y', + '-i', sample, '-map', '0:v', '-map', '0:a', '-map', '0:a', + '-c:v', 'copy', '-c:a:0', 'libmp3lame', '-c:a:1', 'aac', '-f', 'mpegts', mixedSample]); + assert.equal(mixedGenerated.code, 0, mixedGenerated.stderr); const media = fs.readFileSync(sample), key = Buffer.alloc(16, 0x5a), iv = Buffer.alloc(16); const cipher = crypto.createCipheriv('aes-128-cbc', key, iv); const header = '#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:12\n#EXT-X-MEDIA-SEQUENCE:0\n'; const resources = { '/media.ts': media, + '/mixed.ts': fs.readFileSync(mixedSample), '/key.bin': key, '/encrypted.ts': Buffer.concat([cipher.update(media), cipher.final()]), '/plain.m3u8': Buffer.from(`${header}#EXTINF:12,\nmedia.ts\n#EXT-X-ENDLIST\n`), @@ -174,9 +196,10 @@ test('real FFmpeg remuxes encrypted HLS over HTTP and blocks local HLS segments' t.after(() => new Promise(resolve => server.close(resolve))); const base = await listen(server); const { base: routeBase, calls } = await routeFixture(t); - for (const file of ['media.ts', 'plain.m3u8', 'encrypted.m3u8', 'local.m3u8']) { + for (const file of ['media.ts', 'mixed.ts', 'plain.m3u8', 'encrypted.m3u8', 'local.m3u8']) { const url = `${base}/${file}`; - assert.equal((await request(`${routeBase}/api/remux?url=${encodeURIComponent(url)}`)).status, 200); + const hints = file === 'mixed.ts' ? '&audioCodecs=mp3,aac' : ''; + assert.equal((await request(`${routeBase}/api/remux?url=${encodeURIComponent(url)}${hints}`)).status, 200); const result = await runFFmpeg(ffmpeg, calls.at(-1).args); assert.equal(result.timedOut, false, file); if (file === 'local.m3u8') { @@ -187,6 +210,13 @@ test('real FFmpeg remuxes encrypted HLS over HTTP and blocks local HLS segments' assert.equal(result.code, 0, `${file} (${ffmpeg}, signal=${result.signal}): ${result.stderr}`); assert.ok(result.stdout.includes(Buffer.from('ftyp')), `${file}: no MP4 output`); assert.ok(result.stdout.includes(Buffer.from('mdat')), `${file}: no media packets`); + if (file === 'mixed.ts') { + const output = path.join(dir, 'mixed.mp4'); + fs.writeFileSync(output, result.stdout); + const decoded = await runFFmpeg(ffmpeg, ['-hide_banner', '-loglevel', 'error', + '-i', output, '-map', '0:a', '-f', 'null', '-']); + assert.equal(decoded.code, 0, decoded.stderr); + } } } });