From 8aff9271a422ab2024097ef91d1b8c571d555a7e Mon Sep 17 00:00:00 2001 From: Dorian Date: Sat, 14 Feb 2026 10:08:14 +0000 Subject: [PATCH] feat: enhance HLS error handling and key URL rewriting in VideoPlayer - Introduced recovery attempts for HLS errors to prevent infinite loops. - Added detailed logging for HLS errors, including fragment URLs and response statuses. - Implemented a custom loader to rewrite AES-128 key URLs to resolve against the app backend. - Improved user feedback for playback errors based on recovery attempts. Co-authored-by: Cursor --- src/components/VideoPlayer.vue | 68 +++++++++++++++++++++++++++++----- 1 file changed, 58 insertions(+), 10 deletions(-) diff --git a/src/components/VideoPlayer.vue b/src/components/VideoPlayer.vue index 0796c76..e723569 100644 --- a/src/components/VideoPlayer.vue +++ b/src/components/VideoPlayer.vue @@ -260,6 +260,8 @@ let controlsTimeout: ReturnType | null = null // ── HLS instance ──────────────────────────────────────────────────── let hls: Hls | null = null +let hlsRecoveryAttempts = 0 +const MAX_HLS_RECOVERY_ATTEMPTS = 3 /** * Build the YouTube embed URL for partner/YouTube content. @@ -363,6 +365,7 @@ async function fetchStream() { function initPlayer(url: string) { destroyHls() + hlsRecoveryAttempts = 0 const video = videoEl.value if (!video) return @@ -370,9 +373,29 @@ function initPlayer(url: string) { console.log(`[VideoPlayer] initPlayer — isHls=${isHls}, url=${url.substring(0, 120)}...`) if (isHls && Hls.isSupported()) { + // Custom loader: rewrite AES-128 key URLs so they resolve + // against the app backend, not the CDN/S3 origin. + // The manifest references keys as "/api/contents/:id/key" which + // resolves against the manifest host (CDN). We redirect to the app. + const BaseLoader = Hls.DefaultConfig.loader as any + class KeyRewriteLoader extends BaseLoader { + load(context: any, config: any, callbacks: any) { + if (context.url?.includes('/contents/') && context.url?.includes('/key')) { + try { + const parsed = new URL(context.url) + const correctedUrl = `${window.location.origin}${parsed.pathname}` + console.log('[VideoPlayer] Rewriting key URL:', context.url, '→', correctedUrl) + context.url = correctedUrl + } catch { /* keep original URL */ } + } + super.load(context, config, callbacks) + } + } + hls = new Hls({ enableWorker: true, lowLatencyMode: false, + loader: KeyRewriteLoader, // Attach auth tokens to key requests so paid content can be decrypted xhrSetup(xhr: XMLHttpRequest, xhrUrl: string) { if (xhrUrl.includes('/key')) { @@ -392,18 +415,43 @@ function initPlayer(url: string) { }) }) hls.on(Hls.Events.ERROR, (_event, data) => { - console.error('[VideoPlayer] HLS error:', data.type, data.details, data.fatal ? '(FATAL)' : '') - if (data.fatal) { - if (data.type === Hls.ErrorTypes.NETWORK_ERROR) { - console.warn('[VideoPlayer] HLS network error, retrying...') - hls?.startLoad() - } else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) { - console.warn('[VideoPlayer] HLS media error, recovering...') - hls?.recoverMediaError() + // Log detailed error info including the fragment URL when available + const fragUrl = (data as any).frag?.url || (data as any).url || '' + console.error( + '[VideoPlayer] HLS error:', data.type, data.details, + data.fatal ? '(FATAL)' : '', + fragUrl ? `frag=${fragUrl.substring(0, 120)}` : '', + (data as any).response ? `status=${(data as any).response.code}` : '', + ) + + if (!data.fatal) return + + // Track recovery attempts to avoid infinite loops + hlsRecoveryAttempts++ + + if (hlsRecoveryAttempts > MAX_HLS_RECOVERY_ATTEMPTS) { + console.error(`[VideoPlayer] Max recovery attempts (${MAX_HLS_RECOVERY_ATTEMPTS}) exceeded, giving up.`) + + // fragParsingError usually means the segments are encrypted or + // the file is corrupt / served incorrectly + if (data.details === 'fragParsingError') { + streamError.value = 'Unable to play this video. The stream segments could not be decoded — they may be encrypted or the file may still be processing.' } else { - streamError.value = 'Playback error. Please try again.' - destroyHls() + streamError.value = 'Playback failed after multiple attempts. Please try again later.' } + destroyHls() + return + } + + if (data.type === Hls.ErrorTypes.NETWORK_ERROR) { + console.warn(`[VideoPlayer] HLS network error, retrying... (attempt ${hlsRecoveryAttempts}/${MAX_HLS_RECOVERY_ATTEMPTS})`) + hls?.startLoad() + } else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) { + console.warn(`[VideoPlayer] HLS media error, recovering... (attempt ${hlsRecoveryAttempts}/${MAX_HLS_RECOVERY_ATTEMPTS})`) + hls?.recoverMediaError() + } else { + streamError.value = 'Playback error. Please try again.' + destroyHls() } }) } else if (isHls && video.canPlayType('application/vnd.apple.mpegurl')) {