Apply AAC remux filters only to matching audio tracks
Adapted from MikaelKW/nodecast-tv-plus commit 25f7cb636a07a3efb2c409ff182920ae0fbe01bd. Co-authored-by: Mikael <mikaelkwessel@gmail.com>
This commit is contained in:
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
17
server/services/remuxAudio.js
Normal file
17
server/services/remuxAudio.js
Normal 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 };
|
||||||
73
test/remux-playback.test.js
Normal file
73
test/remux-playback.test.js
Normal 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, '');
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user