diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.ts b/libs/ui/playback/src/lib/art-player/art-player.component.ts index ef9cbf359..5168e27b4 100644 --- a/libs/ui/playback/src/lib/art-player/art-player.component.ts +++ b/libs/ui/playback/src/lib/art-player/art-player.component.ts @@ -279,6 +279,7 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges { details: data.details, fatal: data.fatal, message: data.error?.message, + error: data.error, }, this.createSourceMetadata(url, 'application/x-mpegURL') ) diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts index b95cbcc27..da1d25328 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts @@ -109,4 +109,36 @@ describe('HtmlVideoPlayerComponent', () => { expect(issues).toEqual([]); }); + + it('keeps raw HLS error object context in emitted playback issue details', () => { + const issues: Array<{ details?: string }> = []; + component.playbackIssue.subscribe((issue) => { + if (issue) issues.push(issue); + }); + + ( + component as unknown as { + handleHlsError: ( + url: string, + data: { + type: string; + details: string; + fatal: boolean; + error?: unknown; + } + ) => void; + } + ).handleHlsError('https://example.com/live/playlist.m3u8', { + type: 'networkError', + details: 'manifestLoadError', + fatal: true, + error: { + context: 'xhr setup failed', + status: 0, + }, + }); + + expect(issues[0].details).toContain('xhr setup failed'); + expect(issues[0].details).toContain('"status":0'); + }); }); diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts index 0ce93d9f1..099bd8e1e 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts @@ -288,6 +288,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { details: data.details, fatal: data.fatal, message: data.error?.message, + error: data.error, }, this.createSourceMetadata(url, 'application/x-mpegURL') ) diff --git a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.spec.ts b/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.spec.ts index 6fcbf71cd..d368b63c4 100644 --- a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.spec.ts +++ b/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.spec.ts @@ -74,6 +74,20 @@ describe('playback diagnostics', () => { expect(unsupportedSourceIssue.externalFallbackRecommended).toBe(true); }); + it('classifies MIME-only unsupported containers as container diagnostics', () => { + const issue = classifyNativePlaybackIssue( + { code: 4, message: 'source not supported' }, + createPlaybackSourceMetadata({ + url: 'https://example.com/archive/stream', + mimeType: 'video/x-msvideo', + player: 'html5', + }) + ); + + expect(issue.code).toBe(PlaybackDiagnosticCode.UnsupportedContainer); + expect(issue.container).toBe('x-msvideo'); + }); + it('classifies HLS network errors without claiming codec incompatibility', () => { const issue = classifyHlsPlaybackIssue( { @@ -91,6 +105,44 @@ describe('playback diagnostics', () => { expect(issue.externalFallbackRecommended).toBe(false); }); + it('does not treat provider-side blocked messages as browser access errors', () => { + const issue = classifyHlsPlaybackIssue( + { + type: 'networkError', + details: 'manifestLoadError Request blocked by rate limiter', + fatal: true, + }, + createPlaybackSourceMetadata({ + url: 'https://provider.example/live/index.m3u8', + player: 'videojs', + }) + ); + + expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError); + expect(issue.externalFallbackRecommended).toBe(false); + }); + + it('keeps raw HLS error object context in diagnostic details', () => { + const issue = classifyHlsPlaybackIssue( + { + type: 'networkError', + details: 'manifestLoadError', + fatal: true, + error: { + context: 'xhr setup failed', + status: 0, + }, + }, + createPlaybackSourceMetadata({ + url: 'https://provider.example/live/index.m3u8', + player: 'videojs', + }) + ); + + expect(issue.details).toContain('xhr setup failed'); + expect(issue.details).toContain('"status":0'); + }); + it('classifies HLS browser access blocks separately from provider network failures', () => { const issue = classifyHlsPlaybackIssue( { diff --git a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts b/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts index 104c37822..84ca939d0 100644 --- a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts +++ b/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts @@ -115,6 +115,15 @@ const UNSUPPORTED_CONTAINER_EXTENSIONS = new Set([ 'wmv', ]); +const UNSUPPORTED_CONTAINER_NAMES = new Set([ + ...UNSUPPORTED_CONTAINER_EXTENSIONS, + 'matroska', + 'mp2t', + 'quicktime', + 'x-matroska', + 'x-msvideo', +]); + const DECLARED_MEDIA_EXTENSION_QUERY_KEYS = [ 'extension', 'ext', @@ -525,7 +534,8 @@ function inferContainerFromMimeType(mimeType: string | undefined): string { function isLikelyContainerIssue(metadata: PlaybackSourceMetadata): boolean { return ( - UNSUPPORTED_CONTAINER_EXTENSIONS.has(metadata.extension) || + UNSUPPORTED_CONTAINER_NAMES.has(metadata.extension) || + UNSUPPORTED_CONTAINER_NAMES.has(metadata.container) || metadata.mimeType === 'video/matroska' ); } @@ -535,24 +545,41 @@ function normalizeErrorDetails( ): string { const hlsError = 'error' in error ? error.error : undefined; const mpegTsInfo = 'info' in error ? error.info : undefined; - const errorMessage = - hlsError instanceof Error - ? hlsError.message - : typeof hlsError === 'string' - ? hlsError - : ''; - const info = - typeof mpegTsInfo === 'string' - ? mpegTsInfo - : mpegTsInfo - ? JSON.stringify(mpegTsInfo) - : ''; + const errorMessage = normalizeErrorPayload(hlsError); + const info = normalizeErrorPayload(mpegTsInfo); return [error.details, error.message, errorMessage, info] .filter((part): part is string => Boolean(part)) .join(' '); } +function normalizeErrorPayload(payload: unknown): string { + if (!payload) { + return ''; + } + + if (typeof payload === 'string') { + return payload; + } + + if (payload instanceof Error) { + const extraDetails = stringifyUnknown(payload); + return [payload.message, extraDetails === '{}' ? '' : extraDetails] + .filter(Boolean) + .join(' '); + } + + return stringifyUnknown(payload); +} + +function stringifyUnknown(value: unknown): string { + try { + return JSON.stringify(value) || ''; + } catch { + return String(value); + } +} + function isNetworkFailure(type: string, details: string): boolean { return ( type.includes('network') || @@ -573,8 +600,9 @@ function isBrowserAccessFailure(details: string): boolean { details.includes('content security policy') || details.includes('mixed content') || details.includes('private network access') || - details.includes('blocked by') || - details.includes('has been blocked') || + details.includes('blocked by content security') || + details.includes('blocked by cors') || + details.includes('blocked by mixed content') || details.includes('not allowed to load local resource') || details.includes('err_blocked') || details.includes('err_cleartext')