diff --git a/README.md b/README.md index 40dc59f..24d8941 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/package-lock.json b/package-lock.json index 5744bb5..2416188 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 7b22d0c..0e738c0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/index.html b/public/index.html index 540d0ba..4cd63c4 100644 --- a/public/index.html +++ b/public/index.html @@ -1105,8 +1105,9 @@ - - + + + @@ -1116,7 +1117,7 @@ - + diff --git a/public/js/components/CaptionTracks.js b/public/js/components/CaptionTracks.js index f1622f1..e86f7a8 100644 --- a/public/js/components/CaptionTracks.js +++ b/public/js/components/CaptionTracks.js @@ -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; diff --git a/public/js/components/HlsAudioRecovery.js b/public/js/components/HlsAudioRecovery.js new file mode 100644 index 0000000..14c2f2e --- /dev/null +++ b/public/js/components/HlsAudioRecovery.js @@ -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; diff --git a/public/js/components/VideoPlayer.js b/public/js/components/VideoPlayer.js index 1e561e1..b2efc2c 100644 --- a/public/js/components/VideoPlayer.js +++ b/public/js/components/VideoPlayer.js @@ -576,145 +576,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)); @@ -1000,66 +861,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) @@ -1115,6 +917,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); @@ -1126,14 +932,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.'); }); } @@ -1313,6 +1130,8 @@ class VideoPlayer { */ stop() { this.playbackGeneration = (this.playbackGeneration || 0) + 1; + this.audioRecovery?.dispose(); + this.audioRecovery = null; // Stop any running transcode session first this.stopTranscodeSession(); diff --git a/public/js/pages/WatchPage.js b/public/js/pages/WatchPage.js index b21d800..9b7527b 100644 --- a/public/js/pages/WatchPage.js +++ b/public/js/pages/WatchPage.js @@ -609,6 +609,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); @@ -622,6 +626,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) @@ -649,6 +656,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(); diff --git a/test/caption-tracks.test.js b/test/caption-tracks.test.js index 86c2b46..5f66c41 100644 --- a/test/caption-tracks.test.js +++ b/test/caption-tracks.test.js @@ -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(); diff --git a/test/hls-ad-break.test.js b/test/hls-ad-break.test.js new file mode 100644 index 0000000..b2876ca --- /dev/null +++ b/test/hls-ad-break.test.js @@ -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', `
${scripts.map(src => ``).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(); + } + }); diff --git a/test/hls-audio-recovery.test.js b/test/hls-audio-recovery.test.js new file mode 100644 index 0000000..c579596 --- /dev/null +++ b/test/hls-audio-recovery.test.js @@ -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(); + }); +} diff --git a/test/playback-lifecycle.test.js b/test/playback-lifecycle.test.js index 484a521..5f89112 100644 --- a/test/playback-lifecycle.test.js +++ b/test/playback-lifecycle.test.js @@ -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++; } }; diff --git a/test/remux-playback.test.js b/test/remux-playback.test.js index ef5ef92..fbb3fa9 100644 --- a/test/remux-playback.test.js +++ b/test/remux-playback.test.js @@ -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'; diff --git a/test/vod-duration.test.js b/test/vod-duration.test.js index fb326c4..c0d801f 100644 --- a/test/vod-duration.test.js +++ b/test/vod-duration.test.js @@ -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: {},