Merge pull request #157 from technomancer702/codex/aac-remux

Remux AAC tracks correctly without filtering other audio codecs
This commit is contained in:
Technomancer702
2026-10-12 16:13:46 -07:00
committed by GitHub
7 changed files with 174 additions and 11 deletions

View File

@ -859,6 +859,8 @@ class VideoPlayer {
// Stop current playback // Stop current playback
this.stop(); this.stop();
const playbackGeneration = this.playbackGeneration;
let probeInfo = null;
this.updateTranscodeStatus('hidden'); this.updateTranscodeStatus('hidden');
// Hide "select a channel" overlay // Hide "select a channel" overlay
@ -877,6 +879,8 @@ class VideoPlayer {
try { try {
const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}`); const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}`);
const info = await probeRes.json(); 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}`); 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 // Store probe result for quality badge display
@ -936,7 +940,7 @@ class VideoPlayer {
// Raw .ts container - use remux // Raw .ts container - use remux
console.log('[Player] Auto: Using remux (.ts container)'); console.log('[Player] Auto: Using remux (.ts container)');
this.updateTranscodeStatus('remuxing', 'Remux (Auto)'); 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.currentUrl = remuxUrl;
this.video.src = remuxUrl; this.video.src = remuxUrl;
this.video.play().catch(e => { this.video.play().catch(e => {
@ -1055,7 +1059,17 @@ class VideoPlayer {
console.log('[Player] Force Remux enabled. Routing through FFmpeg remux...'); console.log('[Player] Force Remux enabled. Routing through FFmpeg remux...');
console.log('[Player] Stream type:', isRawTs ? 'Raw TS' : 'Extension-less (assumed TS)'); console.log('[Player] Stream type:', isRawTs ? 'Raw TS' : 'Extension-less (assumed TS)');
this.updateTranscodeStatus('remuxing', 'Remux (Force)'); 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.src = remuxUrl;
this.video.play().catch(e => { this.video.play().catch(e => {
if (e.name !== 'AbortError') console.log('[Player] Autoplay prevented:', 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) * Get remuxed URL for a stream (container conversion only, no re-encoding)
* Used for raw .ts streams that browsers can't play directly * Used for raw .ts streams that browsers can't play directly
*/ */
getRemuxUrl(url) { getRemuxUrl(url, audioCodecs = []) {
return `/api/remux?url=${encodeURIComponent(url)}`; return `/api/remux?url=${encodeURIComponent(url)}&audioCodecs=${encodeURIComponent(audioCodecs.join(','))}`;
} }
/** /**
@ -1389,6 +1403,7 @@ class VideoPlayer {
* Stop playback * Stop playback
*/ */
stop() { stop() {
this.playbackGeneration = (this.playbackGeneration || 0) + 1;
// Stop any running transcode session first // Stop any running transcode session first
this.stopTranscodeSession(); this.stopTranscodeSession();

View File

@ -415,6 +415,8 @@ class WatchPage {
// Stop any existing playback // Stop any existing playback
this.stop(); this.stop();
const playbackGeneration = this.playbackGeneration;
let probeInfo = null;
// Show loading spinner // Show loading spinner
this.showLoading(); this.showLoading();
@ -439,6 +441,8 @@ class WatchPage {
const ua = settings.userAgentPreset === 'custom' ? settings.userAgentCustom : settings.userAgentPreset; const ua = settings.userAgentPreset === 'custom' ? settings.userAgentCustom : settings.userAgentPreset;
const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(url)}&ua=${encodeURIComponent(ua || '')}`); const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(url)}&ua=${encodeURIComponent(ua || '')}`);
const info = await probeRes.json(); 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}`); 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 // 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 // TODO: Move remux to session logic if seeking is needed for TS files
console.log('[WatchPage] Auto: Using remux (.ts container)'); console.log('[WatchPage] Auto: Using remux (.ts container)');
this.updateTranscodeStatus('remuxing', 'Remux (Auto)'); 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.src = finalUrl;
this.video.play().catch(e => { this.video.play().catch(e => {
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e); if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
@ -529,7 +533,17 @@ class WatchPage {
if (settings.forceRemux && isRawTs) { if (settings.forceRemux && isRawTs) {
console.log('[WatchPage] Force Remux enabled'); console.log('[WatchPage] Force Remux enabled');
this.updateTranscodeStatus('remuxing', 'Remux (Force)'); 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.src = finalUrl;
this.video.play().catch(e => { this.video.play().catch(e => {
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e); if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
@ -617,7 +631,12 @@ class WatchPage {
if (this.volumeSlider) this.volumeSlider.value = savedVolume; if (this.volumeSlider) this.volumeSlider.value = savedVolume;
} }
getRemuxUrl(url, audioCodecs = []) {
return `/api/remux?url=${encodeURIComponent(url)}&audioCodecs=${encodeURIComponent(audioCodecs.join(','))}`;
}
stop() { stop() {
this.playbackGeneration = (this.playbackGeneration || 0) + 1;
// Stop history tracking and save final progress // Stop history tracking and save final progress
this.stopHistoryTracking(); this.stopHistoryTracking();
this.saveProgress(); this.saveProgress();

View File

@ -133,6 +133,8 @@ function analyzeProbeResult(probeResult, url) {
return { return {
video: videoCodec, video: videoCodec,
audio: audioCodec, audio: audioCodec,
audioCodecs: streams.filter(s => s.codec_type === 'audio')
.map(s => s.codec_name?.toLowerCase() || 'unknown'),
width: videoStream?.width || 0, width: videoStream?.width || 0,
height: videoStream?.height || 0, height: videoStream?.height || 0,
audioChannels: audioStream?.channels || 0, // For Smart Audio Copy audioChannels: audioStream?.channels || 0, // For Smart Audio Copy

View File

@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const { spawn } = require('child_process'); const { spawn } = require('child_process');
const db = require('../db'); const db = require('../db');
const { parseAudioCodecs, audioFilterArgs } = require('../services/remuxAudio');
/** /**
* Allowed input URL schemes for the remux endpoint. * 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' }); 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'; const ffmpegPath = req.app.locals.ffmpegPath || 'ffmpeg';
// Get User-Agent from settings // Get User-Agent from settings
@ -118,9 +126,8 @@ router.get('/', async (req, res) => {
'-c', 'copy', '-c', 'copy',
// Ensure extradata is correctly extracted/converted (fixes Annex B -> AVCC issues in Firefox) // Ensure extradata is correctly extracted/converted (fixes Annex B -> AVCC issues in Firefox)
'-bsf:v', 'dump_extra', '-bsf:v', 'dump_extra',
// NOTE: We intentionally do NOT use -bsf:a aac_adtstoasc here // Convert ADTS only on probed AAC tracks; leave MP3/AC3/EAC3 untouched.
// That filter only works for AAC audio and breaks AC3/EAC3/MP3. ...audioFilterArgs(audioCodecs),
// If AAC audio from MPEG-TS fails in MP4, use /api/transcode instead.
// Handle timestamp discontinuities at output // Handle timestamp discontinuities at output
'-fps_mode', 'passthrough', '-fps_mode', 'passthrough',
'-max_muxing_queue_size', '1024', '-max_muxing_queue_size', '1024',

View File

@ -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 };

View File

@ -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, '');
});
}

View File

@ -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() { function findFFmpeg() {
// Match server/index.js: prefer system FFmpeg, then the optional binary. // Match server/index.js: prefer system FFmpeg, then the optional binary.
if (spawnSync('ffmpeg', ['-version'], { stdio: 'ignore' }).status === 0) return 'ffmpeg'; 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:v', 'libx264', '-threads', '1', '-preset', 'ultrafast', '-tune', 'zerolatency',
'-c:a', 'libmp3lame', '-f', 'mpegts', sample]); '-c:a', 'libmp3lame', '-f', 'mpegts', sample]);
assert.equal(generated.code, 0, generated.stderr); 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 media = fs.readFileSync(sample), key = Buffer.alloc(16, 0x5a), iv = Buffer.alloc(16);
const cipher = crypto.createCipheriv('aes-128-cbc', key, iv); 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 header = '#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:12\n#EXT-X-MEDIA-SEQUENCE:0\n';
const resources = { const resources = {
'/media.ts': media, '/media.ts': media,
'/mixed.ts': fs.readFileSync(mixedSample),
'/key.bin': key, '/key.bin': key,
'/encrypted.ts': Buffer.concat([cipher.update(media), cipher.final()]), '/encrypted.ts': Buffer.concat([cipher.update(media), cipher.final()]),
'/plain.m3u8': Buffer.from(`${header}#EXTINF:12,\nmedia.ts\n#EXT-X-ENDLIST\n`), '/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))); t.after(() => new Promise(resolve => server.close(resolve)));
const base = await listen(server); const base = await listen(server);
const { base: routeBase, calls } = await routeFixture(t); 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}`; 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); const result = await runFFmpeg(ffmpeg, calls.at(-1).args);
assert.equal(result.timedOut, false, file); assert.equal(result.timedOut, false, file);
if (file === 'local.m3u8') { 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.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('ftyp')), `${file}: no MP4 output`);
assert.ok(result.stdout.includes(Buffer.from('mdat')), `${file}: no media packets`); 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);
}
} }
} }
}); });