From 742915f389398ed75328a975a4be21be897a0529 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 30 Jul 2026 09:25:06 +0200 Subject: [PATCH] fix(playback): classify native source errors from evidence --- .../playback-diagnostics.model.ts | 8 ++ .../playback-diagnostics.util.spec.ts | 105 +++++++++++++++++- .../playback-diagnostics.util.ts | 46 +++++++- 3 files changed, 156 insertions(+), 3 deletions(-) diff --git a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.model.ts b/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.model.ts index 1079ab2f1..01a75c62a 100644 --- a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.model.ts +++ b/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.model.ts @@ -54,9 +54,15 @@ export interface PlaybackSourceMetadata { readonly videoCodecs: readonly string[]; } +export interface NativePlaybackErrorMetadataInput { + readonly errorType?: unknown; +} + export interface NativePlaybackErrorInput { readonly code?: number; readonly message?: string; + readonly status?: number; + readonly metadata?: NativePlaybackErrorMetadataInput; } export interface HlsPlaybackErrorInput { @@ -88,6 +94,8 @@ export interface PlaybackDiagnostic { readonly details?: string; readonly nativeErrorCode?: number; readonly nativeErrorMessage?: string; + readonly httpStatus?: number; + readonly nativeErrorType?: string; readonly externalFallbackRecommended: boolean; } 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 0dbf6413f..5fcaba8b6 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 @@ -88,7 +88,7 @@ describe('playback diagnostics', () => { expect(issue.container).toBe('x-msvideo'); }); - it('does not classify MPEG-TS MIME-only failures as unsupported containers', () => { + it('keeps MPEG-TS MIME-only source failures unknown without codec evidence', () => { const issue = classifyNativePlaybackIssue( { code: 4, message: 'source not supported' }, createPlaybackSourceMetadata({ @@ -98,8 +98,109 @@ describe('playback diagnostics', () => { }) ); - expect(issue.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec); + expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError); expect(issue.container).toBe('mp2t'); + expect(issue.externalFallbackRecommended).toBe(false); + }); + + it('classifies native HTTP source failures from a safe status and error type', () => { + const issue = classifyNativePlaybackIssue( + { + code: 4, + message: 'source not supported', + status: 404, + metadata: { errorType: 'networkrequestfailed' }, + }, + createPlaybackSourceMetadata({ + url: 'https://example.com/live/missing.m3u8', + player: 'videojs', + }) + ); + + expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError); + expect(issue.httpStatus).toBe(404); + expect(issue.nativeErrorType).toBe('networkrequestfailed'); + expect(issue.externalFallbackRecommended).toBe(false); + }); + + it('keeps native code four HLS source failures unknown without status or container evidence', () => { + const issue = classifyNativePlaybackIssue( + { code: 4, message: 'source not supported' }, + createPlaybackSourceMetadata({ + url: 'https://example.com/live/missing.m3u8', + player: 'videojs', + }) + ); + + expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError); + expect(issue.externalFallbackRecommended).toBe(false); + expect(issue.httpStatus).toBeUndefined(); + }); + + it('does not retain unsafe native status or metadata error types', () => { + const issue = classifyNativePlaybackIssue( + { + code: 4, + message: 'source not supported', + status: 0, + metadata: { errorType: 'request failed: token=secret value' }, + }, + createPlaybackSourceMetadata({ + url: 'https://example.com/live/missing.m3u8', + player: 'videojs', + }) + ); + + expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError); + expect(issue.httpStatus).toBeUndefined(); + expect(issue.nativeErrorType).toBeUndefined(); + expect(issue.externalFallbackRecommended).toBe(false); + }); + + it.each([ + { status: 399, accepted: false }, + { status: 400, accepted: true }, + { status: 599, accepted: true }, + { status: 600, accepted: false }, + { status: 404.5, accepted: false }, + ])('accepts native HTTP status $status only when it is a 4xx or 5xx integer', ({ + status, + accepted, + }) => { + const issue = classifyNativePlaybackIssue( + { code: 4, status }, + createPlaybackSourceMetadata({ + url: 'https://example.com/live/missing.m3u8', + player: 'videojs', + }) + ); + + expect(issue.code).toBe( + accepted + ? PlaybackDiagnosticCode.NetworkError + : PlaybackDiagnosticCode.UnknownPlaybackError + ); + expect(issue.httpStatus).toBe(accepted ? status : undefined); + }); + + it.each([ + { length: 128, accepted: true }, + { length: 129, accepted: false }, + ])('retains native error type identifiers up to $length characters', ({ + length, + accepted, + }) => { + const errorType = 'a'.repeat(length); + const issue = classifyNativePlaybackIssue( + { code: 4, metadata: { errorType } }, + createPlaybackSourceMetadata({ + url: 'https://example.com/live/missing.m3u8', + player: 'videojs', + }) + ); + + expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError); + expect(issue.nativeErrorType).toBe(accepted ? errorType : undefined); }); it('classifies HLS network errors without claiming codec incompatibility', () => { 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 298f054bb..33efff939 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 @@ -32,6 +32,7 @@ export { const SOURCE_NOT_SUPPORTED_CODE = 4; const DECODE_ERROR_CODE = 3; const NETWORK_ERROR_CODE = 2; +const NATIVE_ERROR_TYPE_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/; export function classifyNativePlaybackIssue( error: NativePlaybackErrorInput | MediaError | null | undefined, @@ -39,8 +40,25 @@ export function classifyNativePlaybackIssue( ): PlaybackDiagnostic { const nativeErrorCode = error?.code; const nativeErrorMessage = error?.message || undefined; + const nativeErrorInput = error as NativePlaybackErrorInput | null | undefined; + const httpStatus = getNativeHttpStatus(nativeErrorInput?.status); + const nativeErrorType = getNativeErrorType( + nativeErrorInput?.metadata?.errorType + ); const lowerNativeErrorMessage = nativeErrorMessage?.toLowerCase() ?? ''; + if (httpStatus !== undefined) { + return createPlaybackDiagnostic({ + code: DiagnosticCode.NetworkError, + source: DiagnosticSource.Native, + metadata, + nativeErrorCode, + nativeErrorMessage, + httpStatus, + nativeErrorType, + }); + } + if (nativeErrorCode === NETWORK_ERROR_CODE) { // Native MediaError details are often opaque for browser security // failures. Only classify browser access when the runtime exposes a @@ -53,6 +71,7 @@ export function classifyNativePlaybackIssue( metadata, nativeErrorCode, nativeErrorMessage, + nativeErrorType, }); } @@ -63,6 +82,7 @@ export function classifyNativePlaybackIssue( metadata, nativeErrorCode, nativeErrorMessage, + nativeErrorType, }); } @@ -70,11 +90,12 @@ export function classifyNativePlaybackIssue( return createPlaybackDiagnostic({ code: isLikelyContainerIssue(metadata) ? DiagnosticCode.UnsupportedContainer - : DiagnosticCode.UnsupportedCodec, + : DiagnosticCode.UnknownPlaybackError, source: DiagnosticSource.Native, metadata, nativeErrorCode, nativeErrorMessage, + nativeErrorType, }); } @@ -84,6 +105,7 @@ export function classifyNativePlaybackIssue( metadata, nativeErrorCode, nativeErrorMessage, + nativeErrorType, }); } @@ -237,6 +259,8 @@ export function createPlaybackDiagnostic(options: { readonly details?: string; readonly nativeErrorCode?: number; readonly nativeErrorMessage?: string; + readonly httpStatus?: number; + readonly nativeErrorType?: string; /** Overrides the code-derived recommendation, e.g. when external players * are known to be unable to handle the stream either. */ readonly externalFallbackRecommended?: boolean; @@ -248,6 +272,8 @@ export function createPlaybackDiagnostic(options: { details, nativeErrorCode, nativeErrorMessage, + httpStatus, + nativeErrorType, } = options; return { @@ -262,12 +288,30 @@ export function createPlaybackDiagnostic(options: { details: details || undefined, nativeErrorCode, nativeErrorMessage, + httpStatus, + nativeErrorType, externalFallbackRecommended: options.externalFallbackRecommended ?? isExternalFallbackRecommended(code), }; } +function getNativeHttpStatus(status: unknown): number | undefined { + return typeof status === 'number' && + Number.isInteger(status) && + status >= 400 && + status <= 599 + ? status + : undefined; +} + +function getNativeErrorType(errorType: unknown): string | undefined { + return typeof errorType === 'string' && + NATIVE_ERROR_TYPE_PATTERN.test(errorType) + ? errorType + : undefined; +} + function isExternalFallbackRecommended(code: PlaybackDiagnosticCode): boolean { return ( code === DiagnosticCode.UnsupportedContainer ||