Merge pull request #166 from technomancer702/codex/ad-break-audio

Recover Chromium HLS audio at commercial transitions
This commit is contained in:
Technomancer702
2026-10-13 17:41:09 -07:00
committed by GitHub
14 changed files with 476 additions and 216 deletions

View File

@ -228,6 +228,10 @@ All transcoding and stream processing settings are found in **Settings → Trans
| No audio at all | Dolby/AC3/EAC3 audio | Enable **"Force Audio Transcode"** (overrides Auto detection) |
| Audio out of sync | Stream encoding issue | Try changing stream format to TS in Settings |
### Low-Pitched Audio During Commercials
On Chromium browsers (including Chrome, Brave, and Edge), the player refreshes the media decoder when playback crosses an HLS discontinuity, which is commonly used at ad transitions. This keeps the current playback position and subtitle selection, but may briefly rebuffer. Streams without discontinuity markers cannot use this workaround. If distortion persists, try Firefox or enable **Force Audio Transcode** in Settings.
### Buffering Issues
| Symptom | Likely Cause | Solution |

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "nodecast-tv",
"version": "2.2.1",
"version": "2.2.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nodecast-tv",
"version": "2.2.1",
"version": "2.2.2",
"license": "GPL-3.0-only",
"dependencies": {
"@ffprobe-installer/ffprobe": "^2.1.2",

View File

@ -1,6 +1,6 @@
{
"name": "nodecast-tv",
"version": "2.2.1",
"version": "2.2.2",
"description": "Modern IPTV Player",
"license": "GPL-3.0-only",
"main": "server/index.js",

View File

@ -1099,8 +1099,9 @@
<script src="/js/api.js?v=auth-2"></script>
<script src="/js/components/PlaybackSessions.js?v=1"></script>
<script src="/js/components/VodDuration.js?v=1"></script>
<script src="/js/components/CaptionTracks.js?v=1"></script>
<script src="/js/components/VideoPlayer.js?v=4"></script>
<script src="/js/components/CaptionTracks.js?v=2"></script>
<script src="/js/components/HlsAudioRecovery.js?v=1"></script>
<script src="/js/components/VideoPlayer.js?v=5"></script>
<script src="/js/components/ChannelList.js?v=3"></script>
<script src="/js/components/SourceManager.js?v=2"></script>
<script src="/js/components/EpgGuide.js?v=2"></script>
@ -1110,7 +1111,7 @@
<script src="/js/pages/MoviesPage.js?v=4"></script>
<script src="/js/pages/SeriesPage.js?v=1"></script>
<script src="/js/pages/Settings.js?v=2"></script>
<script src="/js/pages/WatchPage.js?v=4"></script>
<script src="/js/pages/WatchPage.js?v=5"></script>
<script src="/js/app.js?v=5"></script>
</body>

View File

@ -9,6 +9,7 @@ class CaptionTracks {
this.hlsTracks = new Map();
this.retired = new Set();
this.generation = 0;
this.selectionRevision = 0;
this.video.textTracks?.addEventListener('change', onChange);
this.video.textTracks?.addEventListener('addtrack', onChange);
this.video.textTracks?.addEventListener('removetrack', onChange);
@ -55,6 +56,32 @@ class CaptionTracks {
this.probe = { subtitles: info?.subtitles || [], url };
}
prepareMediaRecovery() {
const tracks = Array.from(this.hlsTracks, ([key, entry]) => ({ key, entry,
cues: Array.from(entry.element.track.cues || []), mode: entry.element.track.mode }));
const hasRemoteTracks = this.remoteTracks.length > 0;
if (!hasRemoteTracks && !tracks.length) return;
const selected = this.remoteTracks.findIndex(element => element.track.mode === 'showing');
const generation = this.generation, revision = this.selectionRevision;
return () => {
if (this.generation !== generation) return;
// HLS detachment clears all native cues, including our owned caption tracks.
for (const { key, entry, cues, mode } of tracks) {
if (this.hlsTracks.get(key) !== entry) continue;
this.appendHlsCues(entry, cues);
if (this.selectionRevision === revision) entry.element.track.mode = mode;
}
if (hasRemoteTracks) {
const remoteSelection = this.selectionRevision === revision ? selected :
this.remoteTracks.findIndex(element => element.track.mode === 'showing');
this.removeRemoteTracks();
this.addRemoteTracks();
if (remoteSelection >= 0 && this.remoteTracks[remoteSelection]) this.remoteTracks[remoteSelection].track.mode = 'showing';
}
this.onChange();
};
}
useNative({ allowProbe = true } = {}) {
this.generation++;
this.hls = null;
@ -203,6 +230,7 @@ class CaptionTracks {
active: hls.subtitleDisplay !== false && hls.subtitleTrack === index,
select: () => {
if (this.hls !== hls || hls.subtitleTracks[index] !== track) return;
this.selectionRevision++;
this.disableNativeTracks();
hls.subtitleDisplay = true;
hls.subtitleTrack = index;
@ -238,6 +266,7 @@ class CaptionTracks {
}
off() {
this.selectionRevision++;
if (this.hls) {
this.hls.subtitleTrack = -1;
this.hls.subtitleDisplay = false;

View File

@ -0,0 +1,107 @@
/** Chromium can retain an invalid audio decoder state across HLS ad discontinuities. */
class HlsAudioRecovery {
constructor(hls, video, events, isCurrent, options = {}) {
this.hls = hls;
this.video = video;
this.events = events;
this.isCurrent = isCurrent;
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : '';
this.chromium = options.chromium ?? /(?:Chrome|Chromium|Edg)\//.test(userAgent);
this.now = options.now || (() => Date.now());
this.beforeRecovery = options.beforeRecovery;
this.lastCC = null;
this.lastRecovery = -Infinity;
this.attempts = [];
this.pendingBoundary = false;
this.disposed = false;
this.onFragment = (event, data) => {
if (!this.active() || !data?.frag || data.frag.sn === 'initSegment') return;
const cc = data.frag.cc;
if (!Number.isInteger(cc) || cc === this.lastCC) return;
const previous = this.lastCC;
this.lastCC = cc;
if (previous === null || !this.chromium) return;
this.pendingBoundary = true;
this.onPlaying();
};
this.onPlaying = () => {
if (!this.active() || !this.pendingBoundary || this.video.paused) return;
this.pendingBoundary = false;
this.requestRecovery('audio discontinuity');
};
this.onDestroy = () => this.dispose();
this.onManifest = () => {
if (!this.active()) return;
this.lastCC = null;
this.pendingBoundary = false;
this.cancelRecovery();
};
hls.on(events.FRAG_CHANGED, this.onFragment);
hls.on(events.DESTROYING, this.onDestroy);
hls.on(events.MANIFEST_LOADING, this.onManifest);
video.addEventListener?.('playing', this.onPlaying);
}
active() {
return !this.disposed && this.isCurrent();
}
requestRecovery(reason) {
if (!this.active()) return false;
if (this.timer !== undefined || this.onAttached) return true;
const now = this.now();
this.attempts = this.attempts.filter(time => now - time < 30000);
if (now - this.lastRecovery < 2000 || this.attempts.length >= 3) return false;
// Let HLS finish dispatching the fragment/error event before detaching its media.
this.timer = setTimeout(() => {
this.timer = undefined;
if (!this.active()) return;
const position = this.video.currentTime;
const resume = !this.video.paused && !this.video.ended;
const restoreCaptions = this.beforeRecovery?.();
this.previousStartPosition = this.hls.config.startPosition;
if (Number.isFinite(position) && position >= 0) this.hls.config.startPosition = position;
this.lastRecovery = this.now();
this.attempts.push(this.lastRecovery);
this.onAttached = () => {
if (!this.active() || !this.onAttached) return;
this.hls.off(this.events.MEDIA_ATTACHED, this.onAttached);
this.onAttached = null;
this.hls.config.startPosition = this.previousStartPosition;
if (!this.active()) return;
restoreCaptions?.();
if (resume) this.video.play().catch(() => {});
else this.video.pause();
};
this.hls.on(this.events.MEDIA_ATTACHED, this.onAttached);
console.info(`[HLS] Refreshing media decoder: ${reason}`);
this.hls.recoverMediaError();
}, 0);
return true;
}
cancelRecovery() {
clearTimeout(this.timer);
this.timer = undefined;
if (this.onAttached) {
this.hls.off(this.events.MEDIA_ATTACHED, this.onAttached);
this.hls.config.startPosition = this.previousStartPosition;
this.onAttached = null;
}
}
dispose() {
if (this.disposed) return;
this.disposed = true;
this.cancelRecovery();
this.hls.off(this.events.FRAG_CHANGED, this.onFragment);
this.hls.off(this.events.DESTROYING, this.onDestroy);
this.hls.off(this.events.MANIFEST_LOADING, this.onManifest);
this.video.removeEventListener?.('playing', this.onPlaying);
}
}
if (typeof window !== 'undefined') window.HlsAudioRecovery = HlsAudioRecovery;
if (typeof module !== 'undefined') module.exports = HlsAudioRecovery;

View File

@ -570,145 +570,6 @@ 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.type, data.details);
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
// Track network retry attempts
this.networkRetryCount = (this.networkRetryCount || 0) + 1;
const now = Date.now();
const timeSinceLastNetworkError = now - (this.lastNetworkErrorTime || 0);
this.lastNetworkErrorTime = now;
// Reset retry count if it's been more than 30 seconds since last error
if (timeSinceLastNetworkError > 30000) {
this.networkRetryCount = 1;
}
console.log(`Network error (attempt ${this.networkRetryCount}/3):`, data.details);
if (this.networkRetryCount <= 3 && !this.isUsingProxy) {
// Retry with increasing delay (1s, 2s, 3s)
const retryDelay = this.networkRetryCount * 1000;
console.log(`[HLS] Retrying in ${retryDelay}ms...`);
setTimeout(() => {
if (this.hls) {
this.hls.startLoad();
}
}, retryDelay);
} else if (!this.isUsingProxy) {
// After 3 retries, try proxy
console.log('[HLS] Max retries reached, switching to proxy...');
this.networkRetryCount = 0;
this.isUsingProxy = true;
const proxiedUrl = this.getProxiedUrl(this.currentUrl);
this.hls.loadSource(proxiedUrl);
this.hls.startLoad();
} else {
// Already using proxy, just retry
console.log('[HLS] Network error on proxy, retrying...');
this.hls.startLoad();
}
break;
case Hls.ErrorTypes.MEDIA_ERROR:
console.log('Media error, attempting recovery...');
this.hls.recoverMediaError();
break;
default:
this.stop();
break;
}
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
// Non-fatal media error - try to recover with cooldown to prevent loops
const now = Date.now();
const timeSinceLastRecovery = now - (this.lastRecoveryAttempt || 0);
// Track consecutive media errors for escalated recovery
if (timeSinceLastRecovery < 5000) {
this.mediaErrorCount = (this.mediaErrorCount || 0) + 1;
} else {
this.mediaErrorCount = 1;
}
// Only attempt recovery if more than 2 seconds since last attempt
if (timeSinceLastRecovery > 2000) {
console.log(`Non-fatal media error (${this.mediaErrorCount}x):`, data.details, '- attempting recovery');
this.lastRecoveryAttempt = now;
// If repeated errors, try swapAudioCodec which can fix audio glitches
if (this.mediaErrorCount >= 3) {
console.log('[HLS] Multiple errors detected, trying swapAudioCodec...');
this.hls.swapAudioCodec();
this.mediaErrorCount = 0;
}
this.hls.recoverMediaError();
// If fragParsingError, also seek forward slightly to skip corrupted segment
if (data.details === 'fragParsingError' && !this.video.paused && this.video.currentTime > 0) {
console.log('[HLS] Seeking past corrupted segment...');
setTimeout(() => {
if (this.video && !this.video.paused) {
this.video.currentTime += 1;
}
}, 200);
}
} else {
// Too many errors in quick succession - log but don't spam recovery
console.log('Non-fatal media error (cooldown):', data.details);
}
} else if (data.details === 'bufferAppendError') {
// Buffer errors during ad transitions - try recovery
console.log('Buffer append error, recovering...');
this.hls.recoverMediaError();
}
});
// Detect audio track switches (can cause audio glitches on some streams)
this.hls.on(Hls.Events.AUDIO_TRACK_SWITCHED, (event, data) => {
console.log('Audio track switched:', data);
});
// Detect buffer stalls which may indicate codec issues
this.hls.on(Hls.Events.BUFFER_STALLED_ERROR, () => {
console.log('Buffer stalled, attempting recovery...');
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.getCaptionTracks().bindHls(this.hls, Hls.Events);
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
this.video.play().catch(e => console.log('Autoplay prevented:', e));
});
}
// Keyboard controls
document.addEventListener('keydown', (e) => this.handleKeyboard(e));
@ -988,66 +849,7 @@ class VideoPlayer {
if (looksLikeHls && Hls.isSupported()) {
this.updateTranscodeStatus('direct', 'Direct HLS');
// Use playHls helper logic here (or extract it)
// For now, let's just use existing logic but wrapped/modularized if possible?
// The HLS init logic is quite complex with error handling
// I'll inline the Hls init here as per original but mindful of proxy vs local
this.hls = new Hls(this.getHlsConfig());
this.getCaptionTracks().bindHls(this.hls, Hls.Events);
this.hls.loadSource(finalUrl);
this.hls.attachMedia(this.video);
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
this.video.play().catch(e => {
if (e.name !== 'AbortError') console.log('Autoplay prevented:', e);
});
});
// Re-attach error handler for the new Hls instance
this.hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) {
const isCorsLikely = data.type === Hls.ErrorTypes.NETWORK_ERROR ||
(data.type === Hls.ErrorTypes.MEDIA_ERROR && data.details === 'fragParsingError');
// Don't proxy if it's already a local API URL
const isLocalApi = this.currentUrl.startsWith('/api/');
if (isCorsLikely && !this.isUsingProxy && !isLocalApi) {
console.log('CORS/Network error detected, retrying via proxy...', data.details);
this.isUsingProxy = true;
this.hls.loadSource(this.getProxiedUrl(this.currentUrl));
this.hls.startLoad();
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
// Fatal media error - try recovery with cooldown
const now = Date.now();
if (now - (this.lastRecoveryAttempt || 0) > 2000) {
console.log('Fatal media error, attempting recovery...');
this.lastRecoveryAttempt = now;
this.hls.recoverMediaError();
}
} else {
console.error('Fatal HLS error:', data);
}
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
// Non-fatal media error - already handled in init(), skip duplicate handling
}
});
// Detect discontinuity changes (ad transitions) for logging only
this.lastDiscontinuity = -1;
this.hls.on(Hls.Events.FRAG_CHANGED, (event, data) => {
const frag = data.frag;
if (frag && frag.sn !== 'initSegment') {
// Log discontinuity changes for debugging
if (frag.cc !== undefined && frag.cc !== this.lastDiscontinuity) {
console.log(`[HLS] Discontinuity detected: CC ${this.lastDiscontinuity} -> ${frag.cc}`);
this.lastDiscontinuity = frag.cc;
// Note: maxAudioFramesDrift: 4 handles audio sync naturally
// No manual seeking needed - it can cause more issues than it solves
}
}
});
this.playHls(finalUrl);
} else if (this.video.canPlayType('application/vnd.apple.mpegurl') === 'probably' ||
this.video.canPlayType('application/vnd.apple.mpegurl') === 'maybe') {
// Priority 2: Native HLS support (Safari on iOS/macOS where HLS.js may not work)
@ -1103,6 +905,10 @@ class VideoPlayer {
this.hls = new Hls(this.getHlsConfig());
const hls = this.hls, generation = this.playbackGeneration;
this.audioRecovery?.dispose();
this.audioRecovery = new HlsAudioRecovery(hls, this.video, Hls.Events,
() => this.hls === hls && this.playbackGeneration === generation,
{ beforeRecovery: () => this.getCaptionTracks().prepareMediaRecovery() });
this.getCaptionTracks().bindHls(hls, Hls.Events);
this.hls.loadSource(url);
this.hls.attachMedia(this.video);
@ -1114,14 +920,25 @@ class VideoPlayer {
});
});
this.hls.on(Hls.Events.ERROR, (event, data) => {
hls.on(Hls.Events.ERROR, (event, data) => {
if (this.hls !== hls || this.playbackGeneration !== generation) return;
if (data.fatal) {
// Simple error handling for forced HLS/transcode modes
console.error('Fatal HLS error in transcode mode:', data);
this.hls.destroy();
this.stopTranscodeSession();
const mediaError = data.type === Hls.ErrorTypes.MEDIA_ERROR;
const needsRecovery = mediaError && (data.fatal || ['bufferAppendError', 'bufferAppendingError', 'fragParsingError'].includes(data.details));
if (needsRecovery && this.audioRecovery.requestRecovery('media error')) return;
if (!data.fatal) return;
const networkError = data.type === Hls.ErrorTypes.NETWORK_ERROR;
if (networkError && !this.isUsingProxy && !url.startsWith('/api/')) {
this.isUsingProxy = true;
hls.loadSource(this.getProxiedUrl(url));
hls.startLoad();
return;
}
console.error('Fatal HLS error:', data.details);
this.audioRecovery.dispose();
hls.destroy();
this.hls = null;
this.stopTranscodeSession();
this.showError('Playback failed. Try selecting the channel again.');
});
}
@ -1301,6 +1118,8 @@ class VideoPlayer {
*/
stop() {
this.playbackGeneration = (this.playbackGeneration || 0) + 1;
this.audioRecovery?.dispose();
this.audioRecovery = null;
// Stop any running transcode session first
this.stopTranscodeSession();

View File

@ -606,6 +606,10 @@ class WatchPage {
});
const hls = this.hls, generation = this.playbackGeneration;
this.audioRecovery?.dispose();
this.audioRecovery = new HlsAudioRecovery(hls, this.video, Hls.Events,
() => this.hls === hls && this.playbackGeneration === generation,
{ beforeRecovery: () => this.getCaptionTracks().prepareMediaRecovery() });
this.getCaptionTracks().bindHls(hls, Hls.Events);
this.hls.loadSource(url);
this.hls.attachMedia(this.video);
@ -619,6 +623,9 @@ class WatchPage {
this.hls.on(Hls.Events.ERROR, (event, data) => {
if (this.hls !== hls || this.playbackGeneration !== generation) return;
const mediaError = data.type === Hls.ErrorTypes.MEDIA_ERROR;
const needsRecovery = mediaError && (data.fatal || ['bufferAppendError', 'bufferAppendingError', 'fragParsingError'].includes(data.details));
if (needsRecovery && this.audioRecovery.requestRecovery('media error')) return;
if (data.fatal) {
console.error('[WatchPage] HLS fatal error:', data);
// Try proxy on CORS error (only if not already proxied/transcoded)
@ -646,6 +653,8 @@ class WatchPage {
stop() {
this.playbackGeneration = (this.playbackGeneration || 0) + 1;
this.audioRecovery?.dispose();
this.audioRecovery = null;
// Stop history tracking and save final progress
this.stopHistoryTracking();
this.saveProgress();

View File

@ -24,7 +24,7 @@ test('both subtitle menus select real HLS cues, discard stale tracks and preserv
assert.equal(generated.status, 0, generated.stderr);
const root = path.join(__dirname, '../public');
const scripts = ['/js/components/CaptionTracks.js', '/js/components/VideoPlayer.js', '/js/pages/WatchPage.js'];
const scripts = ['/js/components/CaptionTracks.js', '/js/components/HlsAudioRecovery.js', '/js/components/VideoPlayer.js', '/js/pages/WatchPage.js'];
const master = names => '#EXTM3U\n' + names.map(([name, extra, language = 'en'], index) =>
`#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="${name}",LANGUAGE="${language}",DEFAULT=NO,AUTOSELECT=NO,${extra}URI="/sub${index}.m3u8"\n`).join('') +
'#EXT-X-STREAM-INF:BANDWIDTH=100000,CODECS="avc1.42c00a",SUBTITLES="subs"\n/video.m3u8\n';
@ -104,6 +104,11 @@ test('both subtitle menus select real HLS cues, discard stale tracks and preserv
});
assert.equal(await page.locator('#list button.active').count(), 1);
}
await page.evaluate(() => window.player.audioRecovery.requestRecovery('caption recovery fixture'));
await page.waitForFunction(() => window.player.audioRecovery.lastRecovery > 0 && !window.player.audioRecovery.onAttached);
await page.waitForFunction(() => Array.from(window.player.video.textTracks).some(track =>
track.mode === 'showing' && Array.from(track.activeCues || []).some(cue => cue.text === 'Cue from /sub2.vtt')));
assert.equal(await page.evaluate(() => window.player.video.paused), true);
await page.locator('#list button').first().click();
assert.equal(await page.evaluate(() => window.player.hls.subtitleTrack), -1);
assert.equal(await page.evaluate(() => Array.from(window.player.video.textTracks).every(track => track.mode === 'disabled')), true);
@ -162,6 +167,11 @@ test('both subtitle menus select real HLS cues, discard stale tracks and preserv
return track.mode === 'showing' && track.cues?.length === 1 && track.cues[0].text === 'CEA caption';
});
await page.evaluate(() => window.player.audioRecovery.requestRecovery('CEA recovery fixture'));
await page.waitForFunction(() => window.player.audioRecovery.lastRecovery > 0 && !window.player.audioRecovery.onAttached);
await page.waitForFunction(() => Array.from(window.player.video.textTracks).some(track =>
track.label === 'English CC' && track.mode === 'showing' && track.cues?.length === 1 && track.cues[0].text === 'CEA caption'));
await page.evaluate(() => {
const player = window.player;
player.stop();

121
test/hls-ad-break.test.js Normal file
View File

@ -0,0 +1,121 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const http = require('node:http');
const { spawnSync } = require('node:child_process');
test('Chromium plays program/ad/program audio with decoder resets at played boundaries',
{ skip: process.env.NODECAST_BROWSER_TESTS !== '1' ? 'Browser tests enabled in CI' : false, timeout: 90000 }, async t => {
const { chromium } = require('playwright');
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nodecast-ad-audio-'));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
let binary = 'ffmpeg';
if (spawnSync(binary, ['-version'], { stdio: 'ignore' }).status !== 0) binary = require('ffmpeg-static');
const files = new Map();
const manifest = ['#EXTM3U', '#EXT-X-TARGETDURATION:1', '#EXT-X-MEDIA-SEQUENCE:0'];
for (let phase = 0; phase < 3; phase++) {
const rate = phase === 1 ? 44100 : 48000;
const channels = phase === 1 ? 2 : 6;
const generated = spawnSync(binary, ['-hide_banner', '-loglevel', 'error', '-y',
'-f', 'lavfi', '-i', 'color=c=black:s=160x90:r=10',
'-f', 'lavfi', '-i', `sine=frequency=1000:sample_rate=${rate}`, '-t', '3',
'-c:v', 'libx264', '-threads', '1', '-preset', 'ultrafast', '-g', '10',
'-c:a', 'aac', '-ar', String(rate), '-ac', String(channels), '-b:a', '160k',
'-f', 'hls', '-hls_time', '1', '-hls_list_size', '0',
'-hls_segment_filename', path.join(dir, `phase${phase}-%d.ts`), path.join(dir, `phase${phase}.m3u8`)],
{ encoding: 'utf8', timeout: 15000 });
assert.equal(generated.status, 0, generated.stderr);
if (phase) manifest.push('#EXT-X-DISCONTINUITY');
const lines = fs.readFileSync(path.join(dir, `phase${phase}.m3u8`), 'utf8').trim().split(/\r?\n/);
for (const line of lines) {
if (line.startsWith('#EXTINF:')) manifest.push(line);
else if (line.endsWith('.ts')) { manifest.push('/' + line); files.set('/' + line, fs.readFileSync(path.join(dir, line))); }
}
}
manifest.push('#EXT-X-ENDLIST');
const root = path.join(__dirname, '../public');
const scripts = ['/js/components/CaptionTracks.js', '/js/components/HlsAudioRecovery.js',
'/js/components/VideoPlayer.js', '/js/pages/WatchPage.js'];
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://fixture.test');
const send = (type, data) => { res.writeHead(200, { 'Content-Type': type }); res.end(data); };
if (url.pathname === '/') return send('text/html', `<video></video><div id="ui"></div><div id="list"></div><script src="/hls.js"></script>${scripts.map(src => `<script src="${src}"></script>`).join('')}`);
if (url.pathname === '/hls.js') return send('application/javascript', fs.readFileSync(require.resolve('hls.js/dist/hls.min.js')));
if (scripts.includes(url.pathname)) return send('application/javascript', fs.readFileSync(path.join(root, url.pathname)));
if (url.pathname === '/ads.m3u8') return send('application/vnd.apple.mpegurl', manifest.join('\n') + '\n');
if (url.pathname === '/api/subtitle') return send('text/vtt', 'WEBVTT\n\n00:00:00.000 --> 00:00:09.000\nTest caption\n\n');
if (files.has(url.pathname)) return send('video/mp2t', files.get(url.pathname));
res.writeHead(404); res.end();
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
t.after(() => new Promise(resolve => { server.close(resolve); server.closeAllConnections(); }));
const browser = await chromium.launch({ headless: true, args: ['--autoplay-policy=no-user-gesture-required'],
executablePath: process.env.NODECAST_BROWSER_EXECUTABLE || undefined });
t.after(() => browser.close());
const base = `http://127.0.0.1:${server.address().port}`;
for (const name of ['VideoPlayer', 'WatchPage']) {
const page = await browser.newPage();
const errors = [];
page.on('pageerror', error => errors.push(error.message));
await page.goto(base);
await page.evaluate(async ({ name, base }) => {
const ui = document.getElementById('ui');
const video = document.querySelector('video');
const player = window.player = Object.create(window[name].prototype);
Object.assign(player, { video, settings: {}, currentUrl: `${base}/ads.m3u8`, playbackGeneration: 1,
captionsList: document.getElementById('list'), captionsMenu: ui, overlay: ui, nowPlaying: ui,
stopTranscodeSession() {}, stopHistoryTracking() {}, saveProgress() {}, hideNowPlaying() {},
updateTranscodeStatus() {}, updateNowPlaying() {}, showNowPlayingOverlay() {}, fetchEpgData() {},
showError(message) { throw Error(message); } });
const context = window.audioContext = new AudioContext();
const analyser = context.createAnalyser();
analyser.fftSize = 4096;
context.createMediaElementSource(video).connect(analyser);
analyser.connect(context.destination);
await context.resume();
window.measurements = { recoveries: [], tones: [], formats: [], played: [], errors: [] };
let cc = 0;
if (name === 'VideoPlayer') await player.play({ id: 'fixture', name: 'Ad fixture' }, `${base}/ads.m3u8`);
else player.playHls(`${base}/ads.m3u8`);
player.getCaptionTracks().setProbe({ subtitles: [{ index: 1, title: 'Extracted', language: 'en' }] }, 'http://provider.test/source');
const hls = player.hls;
const recover = hls.recoverMediaError.bind(hls);
hls.recoverMediaError = () => { window.measurements.recoveries.push({ time: video.currentTime, cc }); recover(); };
hls.on(Hls.Events.FRAG_CHANGED, (event, data) => { cc = data.frag.cc; window.measurements.played.push({ cc, time: video.currentTime }); });
hls.on(Hls.Events.FRAG_PARSING_INIT_SEGMENT, (event, data) => {
if (data.tracks.audio) window.measurements.formats.push({ cc: data.frag.cc, channels: data.tracks.audio.metadata.channelCount });
});
hls.on(Hls.Events.ERROR, (event, data) => { if (data.fatal) window.measurements.errors.push(data.details); });
const bins = new Float32Array(analyser.frequencyBinCount);
window.sampleTimer = setInterval(() => {
if (video.paused || video.readyState < 3) return;
analyser.getFloatFrequencyData(bins);
let peak = 0;
for (let i = 10; i < bins.length; i++) if (bins[i] > bins[peak]) peak = i;
if (bins[peak] > -65) window.measurements.tones.push({ cc, time: video.currentTime,
frequency: peak * context.sampleRate / analyser.fftSize,
captions: Array.from(video.textTracks).some(track => track.mode === 'showing' && track.activeCues?.length > 0) });
}, 100);
}, { name, base });
await page.waitForFunction(() => document.querySelectorAll('#list button').length === 2);
await page.locator('#list button').nth(1).click();
await page.waitForFunction(() => window.player.video.ended, null, { timeout: 30000 });
const measured = await page.evaluate(() => { clearInterval(window.sampleTimer); return window.measurements; });
assert.deepEqual(errors, [], name);
assert.deepEqual(measured.errors, [], `${name}: ${JSON.stringify(measured)}`);
assert.equal(measured.recoveries.length, 2, `${name}: ${JSON.stringify(measured)}`);
assert.deepEqual(measured.recoveries.map(item => item.cc), [1, 2]);
assert.ok(measured.recoveries[0].time >= 2.9 && measured.recoveries[1].time >= 5.9, 'recover at playback, not when ads prebuffer');
for (const period of [0, 1, 2]) {
assert.ok(measured.formats.some(format => format.cc === period && format.channels === (period === 1 ? 2 : 6)));
const tones = measured.tones.filter(sample => sample.cc === period && sample.time % 3 > 0.5 && sample.time % 3 < 2.5);
assert.ok(tones.length > 0, `${name}: missing audio in period ${period}: ${JSON.stringify(measured)}`);
assert.ok(tones.every(sample => Math.abs(sample.frequency - 1000) < 100), `${name}: wrong pitch in period ${period}: ${JSON.stringify(tones)}`);
assert.ok(tones.some(sample => sample.captions), `${name}: captions lost in period ${period}`);
}
await page.evaluate(() => { window.player.stop(); window.audioContext.close(); });
await page.close();
}
});

View File

@ -0,0 +1,160 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const HlsAudioRecovery = require('../public/js/components/HlsAudioRecovery');
const events = { FRAG_CHANGED: 'fragment', DESTROYING: 'destroying', MEDIA_ATTACHED: 'attached', MANIFEST_LOADING: 'manifest-loading' };
const tick = () => new Promise(resolve => setTimeout(resolve, 10));
function fixture(t, chromium = true) {
let current = true, now = 10000;
const video = new EventEmitter();
Object.assign(video, { currentTime: 83.5, paused: false, volume: 0.6, muted: true, playbackRate: 1.25,
addEventListener: video.on.bind(video), removeEventListener: video.off.bind(video),
play: async () => { video.paused = false; video.plays++; },
pause: () => { video.paused = true; }, plays: 0 });
const hls = new EventEmitter();
hls.config = { startPosition: -1 };
hls.positions = [];
hls.recoverMediaError = () => { hls.positions.push(hls.config.startPosition); video.pause(); hls.emit(events.MEDIA_ATTACHED); };
const recovery = new HlsAudioRecovery(hls, video, events, () => current, { chromium, now: () => now });
t.after(() => recovery.dispose());
return { recovery, hls, video, fragment: (cc, sn = 1) => hls.emit(events.FRAG_CHANGED, 'fragment', { frag: { cc, sn } }),
advance: time => { now += time; }, supersede: () => { current = false; } };
}
test('Chromium resets once at each played discontinuity, preserving time and playback settings', async t => {
const f = fixture(t);
f.fragment(0); f.fragment(0); f.fragment(0, 'initSegment');
await tick();
assert.deepEqual(f.hls.positions, []);
f.fragment(1); f.fragment(1);
assert.deepEqual(f.hls.positions, [], 'do not detach inside HLS event dispatch');
await tick();
assert.deepEqual(f.hls.positions, [83.5]);
assert.equal(f.hls.config.startPosition, -1);
assert.equal(f.video.paused, false);
assert.deepEqual([f.video.volume, f.video.muted, f.video.playbackRate], [0.6, true, 1.25]);
f.fragment(1);
await tick();
assert.equal(f.hls.positions.length, 1, 'reattachment does not create a recovery loop');
f.advance(3000); f.video.currentTime = 89;
f.fragment(2);
await tick();
assert.deepEqual(f.hls.positions, [83.5, 89]);
});
test('Firefox skips proactive discontinuity resets but supports requested media-error recovery', async t => {
const f = fixture(t, false);
f.fragment(0); f.fragment(1);
await tick();
assert.deepEqual(f.hls.positions, []);
assert.equal(f.recovery.requestRecovery('media error'), true);
await tick();
assert.deepEqual(f.hls.positions, [83.5]);
});
test('paused boundaries wait for playback and error recovery does not start paused video', async t => {
const f = fixture(t);
f.video.pause();
f.fragment(0); f.fragment(1);
await tick();
assert.deepEqual(f.hls.positions, []);
f.video.paused = false; f.video.emit('playing');
await tick();
assert.equal(f.hls.positions.length, 1);
f.advance(3000); f.video.pause();
f.recovery.requestRecovery('media error');
await tick();
assert.equal(f.video.paused, true);
assert.equal(f.video.plays, 1);
});
test('superseded or destroyed players cancel queued recovery and late attachment callbacks', async t => {
const f = fixture(t);
f.fragment(0); f.fragment(1);
f.supersede();
await tick();
assert.deepEqual(f.hls.positions, []);
const other = fixture(t);
other.hls.recoverMediaError = () => { other.video.pause(); };
other.recovery.requestRecovery('media error');
await tick();
const late = other.hls.listeners(events.MEDIA_ATTACHED)[0];
other.hls.emit(events.DESTROYING);
late();
assert.equal(other.video.plays, 0);
assert.equal(other.hls.config.startPosition, -1);
assert.equal(other.video.listenerCount('playing'), 0);
assert.equal(other.hls.listenerCount(events.MEDIA_ATTACHED), 0);
});
test('cooldown and retry budget bound frequent discontinuities and media errors', async t => {
const f = fixture(t);
assert.equal(f.recovery.requestRecovery('media error'), true);
assert.equal(f.recovery.requestRecovery('media error'), true, 'coalesce simultaneous requests');
await tick();
assert.equal(f.recovery.requestRecovery('media error'), false);
for (let i = 0; i < 2; i++) { f.advance(3000); f.recovery.requestRecovery('media error'); await tick(); }
f.advance(3000);
assert.equal(f.recovery.requestRecovery('media error'), false);
assert.equal(f.hls.positions.length, 3);
f.advance(30000);
assert.equal(f.recovery.requestRecovery('media error'), true);
await tick();
assert.equal(f.hls.positions.length, 4);
});
test('loading a new manifest cancels old recovery and establishes a fresh discontinuity baseline', async t => {
const f = fixture(t);
f.fragment(0); f.fragment(1);
f.hls.emit(events.MANIFEST_LOADING);
await tick();
assert.deepEqual(f.hls.positions, []);
f.fragment(20);
await tick();
assert.deepEqual(f.hls.positions, []);
f.fragment(21);
await tick();
assert.deepEqual(f.hls.positions, [83.5]);
});
for (const name of ['VideoPlayer', 'WatchPage']) {
test(`${name} recovers append errors on the active instance and cancels superseded errors`, async () => {
const window = {};
class Hls extends EventEmitter {
static Events = { ...events, ERROR: 'error', MANIFEST_PARSED: 'manifest',
SUBTITLE_TRACKS_UPDATED: 'subtitles', SUBTITLE_TRACK_SWITCH: 'switch',
NON_NATIVE_TEXT_TRACKS_FOUND: 'non-native', CUES_PARSED: 'cues' };
static ErrorTypes = { MEDIA_ERROR: 'media', NETWORK_ERROR: 'network' };
constructor(config) { super(); this.config = config; this.positions = []; }
loadSource() {} attachMedia() {}
destroy() { this.emit(events.DESTROYING); }
recoverMediaError() { this.positions.push(this.config.startPosition); this.emit(events.MEDIA_ATTACHED); }
}
const file = name === 'VideoPlayer' ? 'components/VideoPlayer.js' : 'pages/WatchPage.js';
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../public/js', file), 'utf8'), {
window, Hls, HlsAudioRecovery, CaptionTracks: require('../public/js/components/CaptionTracks'), console
});
const player = Object.create(window[name].prototype);
Object.assign(player, { playbackGeneration: 1, video: { currentTime: 42, paused: false,
play: async () => {}, pause() {}, querySelectorAll: () => [] } });
player.playHls('/api/transcode/session/playlist.m3u8');
const active = player.hls;
const error = { type: 'media', fatal: false, details: 'bufferAppendError' };
active.emit('error', 'error', error);
await tick();
assert.deepEqual(active.positions, [42]);
const obsoleteHandler = active.listeners('error')[0];
active.emit('error', 'error', error);
player.playbackGeneration++;
player.playHls('/api/transcode/replacement/playlist.m3u8');
obsoleteHandler('error', { ...error, fatal: true });
await tick();
assert.deepEqual(player.hls.positions, []);
assert.equal(player.hls.config.startPosition, undefined);
player.hls.destroy();
});
}

View File

@ -262,12 +262,12 @@ for (const name of ['VideoPlayer', 'WatchPage']) {
test(`${name} ignores callbacks from an obsolete HLS instance`, () => {
const window = {};
class Hls extends EventEmitter {
static Events = { MANIFEST_PARSED: 'manifest', ERROR: 'error', SUBTITLE_TRACKS_UPDATED: 'subtitles', SUBTITLE_TRACK_SWITCH: 'switch', NON_NATIVE_TEXT_TRACKS_FOUND: 'non-native', CUES_PARSED: 'cues' };
static Events = { MANIFEST_PARSED: 'manifest', ERROR: 'error', SUBTITLE_TRACKS_UPDATED: 'subtitles', SUBTITLE_TRACK_SWITCH: 'switch', NON_NATIVE_TEXT_TRACKS_FOUND: 'non-native', CUES_PARSED: 'cues', FRAG_CHANGED: 'fragment', DESTROYING: 'destroying', MEDIA_ATTACHED: 'attached', MANIFEST_LOADING: 'manifest-loading' };
static ErrorTypes = { NETWORK_ERROR: 'network', MEDIA_ERROR: 'media' };
loadSource() {} attachMedia() {} destroy() { this.destroyed = true; }
}
const file = name === 'VideoPlayer' ? 'components/VideoPlayer.js' : 'pages/WatchPage.js';
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../public/js', file), 'utf8'), { window, Hls, CaptionTracks: require('../public/js/components/CaptionTracks'), console: { log() {}, error() {} } });
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../public/js', file), 'utf8'), { window, Hls, HlsAudioRecovery: require('../public/js/components/HlsAudioRecovery'), CaptionTracks: require('../public/js/components/CaptionTracks'), console: { log() {}, error() {} } });
const player = Object.create(window[name].prototype);
let plays = 0;
player.video = { play: async () => { plays++; } };

View File

@ -21,7 +21,7 @@ test('probe preserves every audio track index including unknown codecs', () => {
function playerFixture(name, settings, probe) {
const window = { dispatchEvent() {} };
const context = { window, CaptionTracks: require('../public/js/components/CaptionTracks'), VodDuration: require('../public/js/components/VodDuration'), console: { log() {}, warn() {}, error() {} },
const context = { window, HlsAudioRecovery: require('../public/js/components/HlsAudioRecovery'), CaptionTracks: require('../public/js/components/CaptionTracks'), VodDuration: require('../public/js/components/VodDuration'), 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';

View File

@ -16,7 +16,7 @@ test('VOD duration understands provider fields, clock strings and invalid values
test('Watch displays provider totals for sessions, browser totals for direct media, and resets between content', () => {
const window = {};
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../public/js/pages/WatchPage.js'), 'utf8'), { window, VodDuration, CaptionTracks: require('../public/js/components/CaptionTracks') });
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../public/js/pages/WatchPage.js'), 'utf8'), { window, VodDuration, HlsAudioRecovery: require('../public/js/components/HlsAudioRecovery'), CaptionTracks: require('../public/js/components/CaptionTracks') });
const watch = Object.create(window.WatchPage.prototype);
let hidden;
Object.assign(watch, { video: { duration: Infinity }, durationEl: {},