diff --git a/.changes/playback-structured-videojs-diagnostics.md b/.changes/playback-structured-videojs-diagnostics.md new file mode 100644 index 000000000..e8370df30 --- /dev/null +++ b/.changes/playback-structured-videojs-diagnostics.md @@ -0,0 +1,9 @@ +--- +type: fix +area: playback +--- + +The default web player now reports safer, more accurate streaming errors: +confirmed network and encrypted-segment failures keep structured details, +while ambiguous Video.js errors remain unknown instead of suggesting the +wrong cause. diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 3900dd612..cfd18c01a 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -335,6 +335,38 @@ Video.js HTTP error is `network-error` and shows its status. Because an HTTP status is server/network evidence rather than decoding evidence, external decoding is not presented as a likely fix. +Video.js `8.23.9`, the default web player, runs HTTP streaming through bundled +VHS `3.17.5`. Its terminal `Player#error` crosses a separate allowlisted +boundary built only from the public Video.js `MediaError` code/status, +`metadata.errorType`, and the documented `player.tech().vhs` runtime property. +The engine type must exactly match an installed `videojs.Error` value; +unrecognized values remain unknown. Exact network identifiers, HTTP 4xx/5xx +status, and standard network code 2 produce `network-error`; exact +`streamingfailedtodecryptsegment` or standard encrypted code 5 produce +`drm-or-encryption`. A generic VHS code 3 remains +`unknown-playback-error` because VHS also assigns code 3 to terminal internal +objects and strings that do not establish a decode cause. Non-VHS native code +3 keeps its standard media-decode meaning. + +VHS stage evidence is derived only where the public engine identifier names +the operation: HLS playlist parsing is `playlist`, DASH manifest parsing is +`manifest`, and select/decrypt/transmux/append segment errors are `segment`; +everything else is `unknown`. In particular, IPTVnator does not read internal +`requestType` values to guess manifest, playlist, segment, or key stages. VHS +handles retries, rendition exclusions/re-inclusions, segment timeout recovery, +and request aborts before a final player error; IPTVnator observes only the +public terminal event and does not subscribe to undocumented recovery events +or private loaders. + +Video.js/VHS error messages, request or response URLs, headers, xhr objects, +response text/bodies, credentials, request types, and arbitrary metadata are +neither retained nor rendered. Technical details contain only the sanitized +stage, exact/unknown engine type, standard/unknown media error code, terminal +disposition, and validated HTTP status. The active playback URL remains +available only through the pre-existing playback metadata used by Retry, Copy +URL, and explicit external-player actions; it is never copied from VHS error +metadata. + HLS.js errors cross one shared sanitizer boundary before HTML5 or ArtPlayer can emit a diagnostic. The boundary retains only allowlisted engine `type` and `details` identifiers, the final fatal/recoverable disposition, a stage derived @@ -360,7 +392,15 @@ they do not include provider-supplied channel names or source URLs. mpegts.js `Early-EOF` failures on MPEG-TS streams are classified as `media-decode-error` instead of generic `network-error`. These failures usually mean the fetch stream ended before mpegts.js expected a complete transport stream, and external players may still handle the same URL more tolerant of short reads or malformed TS boundaries. -The diagnostic surface covers the inline player viewport when playback fails, with a compact warning badge, a native-player fallback headline, and player-card actions for configured external players. It exposes technical details on demand: diagnostic code, reporting player/source, detected container/MIME, video/audio codecs, native browser error fields, sanitized structured HLS evidence, and existing mpegts details. HLS manifest codec metadata also drives a concise browser-support hint for codecs that Chromium/Electron commonly cannot decode inline, such as HEVC, AC-3, E-AC-3, DTS, and MPEG-2 video. +The diagnostic surface covers the inline player viewport when playback fails, +with a compact warning badge, a native-player fallback headline, and +player-card actions for configured external players. It exposes technical +details on demand: diagnostic code, reporting player/source, detected +container/MIME, video/audio codecs, native browser error fields, sanitized +structured Video.js/VHS and HLS evidence, and existing mpegts details. HLS +manifest codec metadata also drives a concise browser-support hint for codecs +that Chromium/Electron commonly cannot decode inline, such as HEVC, AC-3, +E-AC-3, DTS, and MPEG-2 video. URL extension metadata is filtered before diagnostics and player selection use it. Web script extensions such as `.php` are not shown as stream containers; explicit media query metadata such as `extension=ts` or `format=m3u8` is preferred when present. 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 f1136c352..54c164b0b 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 @@ -20,6 +20,7 @@ export type PlaybackDiagnosticCode = export const PlaybackDiagnosticSource = { Source: 'source', Native: 'native', + Vhs: 'vhs', Hls: 'hls', MpegTs: 'mpegts', Shaka: 'shaka', @@ -66,6 +67,67 @@ export interface NativePlaybackErrorInput { readonly metadata?: NativePlaybackErrorMetadataInput; } +export const VhsPlaybackEngineType = { + NetworkBadStatus: 'networkbadstatus', + NetworkRequestFailed: 'networkrequestfailed', + NetworkRequestAborted: 'networkrequestaborted', + NetworkRequestTimeout: 'networkrequesttimeout', + NetworkBodyParserFailed: 'networkbodyparserfailed', + StreamingHlsPlaylistParserError: 'streaminghlsplaylistparsererror', + StreamingDashManifestParserError: 'streamingdashmanifestparsererror', + StreamingContentSteeringParserError: 'streamingcontentsteeringparsererror', + StreamingVttParserError: 'streamingvttparsererror', + StreamingFailedToSelectNextSegment: 'streamingfailedtoselectnextsegment', + StreamingFailedToDecryptSegment: 'streamingfailedtodecryptsegment', + StreamingFailedToTransmuxSegment: 'streamingfailedtotransmuxsegment', + StreamingFailedToAppendSegment: 'streamingfailedtoappendsegment', + StreamingCodecsChangeError: 'streamingcodecschangeerror', +} as const; + +export const VhsPlaybackUnknownEngineType = 'unknown' as const; + +export type VhsPlaybackEngineType = + | (typeof VhsPlaybackEngineType)[keyof typeof VhsPlaybackEngineType] + | typeof VhsPlaybackUnknownEngineType; + +export const VhsPlaybackMediaErrorCode = { + Custom: 0, + Aborted: 1, + Network: 2, + Decode: 3, + SourceNotSupported: 4, + Encrypted: 5, + Unknown: 'unknown', +} as const; + +export type VhsPlaybackMediaErrorCode = + (typeof VhsPlaybackMediaErrorCode)[keyof typeof VhsPlaybackMediaErrorCode]; + +export const VhsPlaybackDisposition = { + Terminal: 'terminal', +} as const; + +export type VhsPlaybackDisposition = + (typeof VhsPlaybackDisposition)[keyof typeof VhsPlaybackDisposition]; + +export const VhsPlaybackStage = { + Manifest: 'manifest', + Playlist: 'playlist', + Segment: 'segment', + Unknown: 'unknown', +} as const; + +export type VhsPlaybackStage = + (typeof VhsPlaybackStage)[keyof typeof VhsPlaybackStage]; + +export interface VhsPlaybackEvidence { + readonly engineType: VhsPlaybackEngineType; + readonly mediaErrorCode: VhsPlaybackMediaErrorCode; + readonly disposition: VhsPlaybackDisposition; + readonly stage: VhsPlaybackStage; + readonly httpStatus?: number; +} + export const HlsPlaybackDisposition = { Fatal: 'fatal', Recoverable: 'recoverable', @@ -100,8 +162,7 @@ export type HlsPlaybackFailure = export const HlsPlaybackUnknownEngineType = 'unknown' as const; export type HlsPlaybackEngineType = - | ErrorTypes - | typeof HlsPlaybackUnknownEngineType; + ErrorTypes | typeof HlsPlaybackUnknownEngineType; export interface HlsPlaybackEvidence { readonly engineType: HlsPlaybackEngineType; @@ -133,6 +194,7 @@ export interface PlaybackDiagnostic { readonly nativeErrorMessage?: string; readonly httpStatus?: number; readonly nativeErrorType?: string; + readonly vhs?: VhsPlaybackEvidence; readonly hls?: HlsPlaybackEvidence; 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 d8a8a2592..535ad86ff 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 @@ -1,4 +1,10 @@ import { ErrorDetails, ErrorTypes } from 'hls.js'; +import type { + NativePlaybackErrorInput, + PlaybackDiagnostic, + PlaybackSourceMetadata, +} from './playback-diagnostics.model'; +import * as diagnostics from './playback-diagnostics.util'; import { PlaybackDiagnosticCode, classifyHlsPlaybackIssue, @@ -25,13 +31,17 @@ function classifyStructuredHlsPlaybackIssue( return classifyHlsPlaybackIssue(evidence as never, metadata); } +type VhsClassifier = ( + error: NativePlaybackErrorInput, + metadata: PlaybackSourceMetadata +) => PlaybackDiagnostic; + describe('playback diagnostics', () => { it('classifies HLS incompatible codec errors as unsupported codec fallbacks', () => { const issue = classifyStructuredHlsPlaybackIssue( { engineType: ErrorTypes.MEDIA_ERROR, - engineDetails: - ErrorDetails.MANIFEST_INCOMPATIBLE_CODECS_ERROR, + engineDetails: ErrorDetails.MANIFEST_INCOMPATIBLE_CODECS_ERROR, disposition: 'fatal', stage: 'manifest', failure: 'unknown', @@ -55,8 +65,7 @@ describe('playback diagnostics', () => { const issue = classifyStructuredHlsPlaybackIssue( { engineType: ErrorTypes.MEDIA_ERROR, - engineDetails: - ErrorDetails.BUFFER_INCOMPATIBLE_CODECS_ERROR, + engineDetails: ErrorDetails.BUFFER_INCOMPATIBLE_CODECS_ERROR, disposition: 'fatal', stage: 'media', failure: 'unknown', @@ -146,6 +155,150 @@ describe('playback diagnostics', () => { expect(issue.externalFallbackRecommended).toBe(false); }); + it('classifies exact VHS network evidence without using provider text', () => { + const issue = classifyVhsPlaybackIssue( + { + code: 4, + message: + 'provider says codec DRM CORS at ' + + 'https://provider.example/live.m3u8?token=secret', + metadata: { errorType: 'networkrequestfailed' }, + }, + createPlaybackSourceMetadata({ + url: 'https://example.com/live/missing.m3u8', + player: 'videojs', + }) + ); + + expect(issue).toEqual( + expect.objectContaining({ + code: PlaybackDiagnosticCode.NetworkError, + source: 'vhs', + httpStatus: undefined, + nativeErrorMessage: undefined, + externalFallbackRecommended: false, + vhs: { + engineType: 'networkrequestfailed', + mediaErrorCode: 4, + disposition: 'terminal', + stage: 'unknown', + }, + }) + ); + }); + + it('classifies exact VHS decrypt evidence without inspecting messages', () => { + const issue = classifyVhsPlaybackIssue( + { + code: 3, + message: 'provider network timeout', + metadata: { + errorType: 'streamingfailedtodecryptsegment', + }, + }, + createPlaybackSourceMetadata({ + url: 'https://example.com/live/encrypted.m3u8', + player: 'videojs', + }) + ); + + expect(issue.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption); + expect(issue.source).toBe('vhs'); + expect(issue.externalFallbackRecommended).toBe(true); + expect(issue.nativeErrorMessage).toBeUndefined(); + expect(issue.vhs).toEqual({ + engineType: 'streamingfailedtodecryptsegment', + mediaErrorCode: 3, + disposition: 'terminal', + stage: 'segment', + }); + }); + + it('classifies the public encrypted MediaError code without DRM text', () => { + const issue = classifyVhsPlaybackIssue( + { code: 5, message: 'provider supplied message' }, + createPlaybackSourceMetadata({ + url: 'https://example.com/live/encrypted.m3u8', + player: 'videojs', + }) + ); + + expect(issue.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption); + expect(issue.nativeErrorMessage).toBeUndefined(); + }); + + it('keeps generic VHS code three unknown while native code three remains decode evidence', () => { + const metadata = createPlaybackSourceMetadata({ + url: 'https://example.com/live/index.m3u8', + player: 'videojs', + }); + const vhsIssue = classifyVhsPlaybackIssue( + { + code: 3, + message: + 'Playback cannot continue. No available working or supported playlists.', + }, + metadata + ); + const nativeIssue = classifyNativePlaybackIssue( + { code: 3, message: 'native decode failed' }, + metadata + ); + + expect(vhsIssue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError); + expect(vhsIssue.externalFallbackRecommended).toBe(false); + expect(vhsIssue.nativeErrorMessage).toBeUndefined(); + expect(nativeIssue.code).toBe(PlaybackDiagnosticCode.MediaDecodeError); + }); + + it('retains only safe VHS HTTP and allowlisted engine evidence', () => { + const secret = 'vhs-classifier-secret'; + const issue = classifyVhsPlaybackIssue( + { + code: 4, + status: 503, + message: `https://provider.example/?token=${secret}`, + metadata: { + errorType: 'networkbadstatus', + requestType: 'hls-key', + headers: { Authorization: secret }, + responseBody: secret, + }, + } as NativePlaybackErrorInput, + createPlaybackSourceMetadata({ + url: 'https://example.com/live/index.m3u8', + player: 'videojs', + }) + ); + const serializedEvidence = JSON.stringify(issue.vhs); + + expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError); + expect(issue.httpStatus).toBe(503); + expect(issue.nativeErrorMessage).toBeUndefined(); + expect(serializedEvidence).not.toContain(secret); + expect(serializedEvidence).not.toContain('provider.example'); + expect(serializedEvidence).not.toContain('hls-key'); + expect(serializedEvidence).not.toContain('Authorization'); + }); + + it('keeps unknown VHS metadata unknown despite cause-shaped provider text', () => { + const issue = classifyVhsPlaybackIssue( + { + code: 3, + message: 'CORS codec DRM network timeout', + metadata: { errorType: 'providerCodecCorsDrmError' }, + }, + createPlaybackSourceMetadata({ + url: 'https://example.com/live/index.m3u8', + player: 'videojs', + }) + ); + + expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError); + expect(issue.vhs?.engineType).toBe('unknown'); + expect(issue.nativeErrorMessage).toBeUndefined(); + }); + it('keeps native code four HLS source failures unknown without status or container evidence', () => { const issue = classifyNativePlaybackIssue( { code: 4, message: 'source not supported' }, @@ -186,45 +339,49 @@ describe('playback diagnostics', () => { { 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', - }) - ); + ])( + '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); - }); + 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', - }) - ); + ])( + '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); - }); + expect(issue.code).toBe( + PlaybackDiagnosticCode.UnknownPlaybackError + ); + expect(issue.nativeErrorType).toBe( + accepted ? errorType : undefined + ); + } + ); it('classifies HLS network errors without claiming codec incompatibility', () => { const issue = classifyStructuredHlsPlaybackIssue( @@ -371,9 +528,7 @@ describe('playback diagnostics', () => { }) ); - expect(issue?.code).toBe( - PlaybackDiagnosticCode.MediaDecodeError - ); + expect(issue?.code).toBe(PlaybackDiagnosticCode.MediaDecodeError); } ); @@ -392,9 +547,7 @@ describe('playback diagnostics', () => { }) ); - expect(issue?.code).toBe( - PlaybackDiagnosticCode.UnknownPlaybackError - ); + expect(issue?.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError); expect(issue?.externalFallbackRecommended).toBe(false); }); @@ -586,3 +739,20 @@ describe('playback diagnostics', () => { ).toEqual(['HEVC', 'AC-3', 'E-AC-3']); }); }); + +function classifyVhsPlaybackIssue( + error: NativePlaybackErrorInput, + metadata: PlaybackSourceMetadata +): PlaybackDiagnostic { + const classifier = ( + diagnostics as unknown as { + readonly classifyVhsPlaybackIssue?: VhsClassifier; + } + ).classifyVhsPlaybackIssue; + + expect(classifier).toBeDefined(); + if (!classifier) { + throw new Error('classifyVhsPlaybackIssue is not exported'); + } + return classifier(error, metadata); +} 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 37ee8dbd8..6512a7f0f 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 @@ -6,10 +6,16 @@ import type { PlaybackDiagnosticCode, PlaybackDiagnosticSource, PlaybackSourceMetadata, + VhsPlaybackEngineType as VhsPlaybackEngineTypeValue, + VhsPlaybackEvidence, } from './playback-diagnostics.model'; import { PlaybackDiagnosticCode as DiagnosticCode } from './playback-diagnostics.model'; import { HlsPlaybackDisposition } from './playback-diagnostics.model'; import { PlaybackDiagnosticSource as DiagnosticSource } from './playback-diagnostics.model'; +import { + VhsPlaybackEngineType, + VhsPlaybackMediaErrorCode, +} from './playback-diagnostics.model'; import { getHlsPlaybackDiagnosticCode } from './hls-playback-evidence.util'; import { isBrowserAccessFailure, @@ -18,9 +24,11 @@ import { normalizeErrorDetails, } from './playback-error-patterns.util'; import { isLikelyContainerIssue } from './playback-media-source.util'; +import { createVhsPlaybackEvidence } from './vhs-playback-evidence.util'; export * from './playback-diagnostics.model'; export { createHlsPlaybackEvidence } from './hls-playback-evidence.util'; +export { createVhsPlaybackEvidence } from './vhs-playback-evidence.util'; export { createPlaybackSourceMetadata, getLikelyBrowserUnsupportedCodecLabels, @@ -31,6 +39,14 @@ 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}$/; +const VHS_NETWORK_ERROR_TYPES: ReadonlySet = + new Set([ + VhsPlaybackEngineType.NetworkBadStatus, + VhsPlaybackEngineType.NetworkRequestFailed, + VhsPlaybackEngineType.NetworkRequestAborted, + VhsPlaybackEngineType.NetworkRequestTimeout, + VhsPlaybackEngineType.NetworkBodyParserFailed, + ]); export function classifyNativePlaybackIssue( error: NativePlaybackErrorInput | MediaError | null | undefined, @@ -38,7 +54,8 @@ export function classifyNativePlaybackIssue( ): PlaybackDiagnostic { const nativeErrorCode = error?.code; const nativeErrorMessage = error?.message || undefined; - const nativeErrorInput = error as NativePlaybackErrorInput | null | undefined; + const nativeErrorInput = error as + NativePlaybackErrorInput | null | undefined; const httpStatus = getNativeHttpStatus(nativeErrorInput?.status); const nativeErrorType = getNativeErrorType( nativeErrorInput?.metadata?.errorType @@ -107,6 +124,26 @@ export function classifyNativePlaybackIssue( }); } +export function classifyVhsPlaybackIssue( + error: NativePlaybackErrorInput, + metadata: PlaybackSourceMetadata +): PlaybackDiagnostic { + const evidence = createVhsPlaybackEvidence(error); + const code = getVhsPlaybackDiagnosticCode(evidence, metadata); + + return createPlaybackDiagnostic({ + code, + source: DiagnosticSource.Vhs, + metadata, + nativeErrorCode: + typeof evidence.mediaErrorCode === 'number' + ? evidence.mediaErrorCode + : undefined, + httpStatus: evidence.httpStatus, + vhs: evidence, + }); +} + export function classifyHlsPlaybackIssue( evidence: HlsPlaybackEvidence, metadata: PlaybackSourceMetadata @@ -218,6 +255,7 @@ export function createPlaybackDiagnostic(options: { readonly nativeErrorMessage?: string; readonly httpStatus?: number; readonly nativeErrorType?: string; + readonly vhs?: VhsPlaybackEvidence; readonly hls?: HlsPlaybackEvidence; /** Overrides the code-derived recommendation, e.g. when external players * are known to be unable to handle the stream either. */ @@ -232,6 +270,7 @@ export function createPlaybackDiagnostic(options: { nativeErrorMessage, httpStatus, nativeErrorType, + vhs, hls, } = options; @@ -249,6 +288,7 @@ export function createPlaybackDiagnostic(options: { nativeErrorMessage, httpStatus, nativeErrorType, + vhs, hls, externalFallbackRecommended: options.externalFallbackRecommended ?? @@ -256,6 +296,37 @@ export function createPlaybackDiagnostic(options: { }; } +function getVhsPlaybackDiagnosticCode( + evidence: VhsPlaybackEvidence, + metadata: PlaybackSourceMetadata +): PlaybackDiagnosticCode { + if ( + evidence.httpStatus !== undefined || + VHS_NETWORK_ERROR_TYPES.has(evidence.engineType) || + evidence.mediaErrorCode === VhsPlaybackMediaErrorCode.Network + ) { + return DiagnosticCode.NetworkError; + } + + if ( + evidence.engineType === + VhsPlaybackEngineType.StreamingFailedToDecryptSegment || + evidence.mediaErrorCode === VhsPlaybackMediaErrorCode.Encrypted + ) { + return DiagnosticCode.DrmOrEncryption; + } + + if ( + evidence.mediaErrorCode === + VhsPlaybackMediaErrorCode.SourceNotSupported && + isLikelyContainerIssue(metadata) + ) { + return DiagnosticCode.UnsupportedContainer; + } + + return DiagnosticCode.UnknownPlaybackError; +} + function getNativeHttpStatus(status: unknown): number | undefined { return typeof status === 'number' && Number.isInteger(status) && diff --git a/libs/ui/playback/src/lib/playback-diagnostics/vhs-playback-evidence.util.spec.ts b/libs/ui/playback/src/lib/playback-diagnostics/vhs-playback-evidence.util.spec.ts new file mode 100644 index 000000000..15a3fe8f2 --- /dev/null +++ b/libs/ui/playback/src/lib/playback-diagnostics/vhs-playback-evidence.util.spec.ts @@ -0,0 +1,297 @@ +import { execFileSync } from 'node:child_process'; +import type { NativePlaybackErrorInput } from './playback-diagnostics.model'; +import * as diagnostics from './playback-diagnostics.util'; + +interface ExpectedVhsPlaybackEvidence { + readonly engineType: string; + readonly mediaErrorCode: number | string; + readonly disposition: string; + readonly stage: string; + readonly httpStatus?: number; +} + +type EvidenceFactory = ( + error: NativePlaybackErrorInput +) => ExpectedVhsPlaybackEvidence; + +interface VideoJsErrorConstants { + readonly [name: string]: string; +} + +describe('VHS playback evidence', () => { + it('matches the installed public videojs.Error identifiers', () => { + expect(Object.values(getEngineTypes()).sort()).toEqual( + Object.values(getInstalledVideoJsErrors()).sort() + ); + }); + + it('extracts only allowlisted fields from a real VHS bad-status shape', () => { + const secret = 'vhs-evidence-secret'; + const evidence = createEvidence({ + code: 4, + status: 503, + message: + 'HLS playlist request error at URL: ' + + `https://provider.example/live.m3u8?token=${secret}`, + metadata: { + errorType: getInstalledVideoJsErrors()['NetworkBadStatus'], + requestType: 'hls-playlist', + uri: `https://provider.example/live.m3u8?token=${secret}`, + headers: { Authorization: `Bearer ${secret}` }, + responseText: `provider body ${secret}`, + }, + } as NativePlaybackErrorInput); + const serialized = JSON.stringify(evidence); + + expect(evidence).toEqual({ + engineType: 'networkbadstatus', + mediaErrorCode: 4, + disposition: 'terminal', + stage: 'unknown', + httpStatus: 503, + }); + expect(serialized).not.toContain(secret); + expect(serialized).not.toContain('provider.example'); + expect(serialized).not.toContain('Authorization'); + expect(serialized).not.toContain('hls-playlist'); + expect(Object.keys(evidence).sort()).toEqual( + [ + 'disposition', + 'engineType', + 'httpStatus', + 'mediaErrorCode', + 'stage', + ].sort() + ); + }); + + it.each([ + { + engineType: 'streaminghlsplaylistparsererror', + stage: 'playlist', + }, + { + engineType: 'streamingdashmanifestparsererror', + stage: 'manifest', + }, + { + engineType: 'streamingfailedtoselectnextsegment', + stage: 'segment', + }, + { + engineType: 'streamingfailedtodecryptsegment', + stage: 'segment', + }, + { + engineType: 'streamingfailedtotransmuxsegment', + stage: 'segment', + }, + { + engineType: 'streamingfailedtoappendsegment', + stage: 'segment', + }, + { + engineType: 'networkrequestfailed', + stage: 'unknown', + }, + { + engineType: 'streamingcontentsteeringparsererror', + stage: 'unknown', + }, + { + engineType: 'streamingvttparsererror', + stage: 'unknown', + }, + { + engineType: 'streamingcodecschangeerror', + stage: 'unknown', + }, + ])( + 'maps public $engineType evidence only to the proven $stage stage', + ({ engineType, stage }) => { + expect( + createEvidence({ + code: 3, + metadata: { errorType: engineType }, + }).stage + ).toBe(stage); + } + ); + + it.each([ + { code: 0, expected: 0 }, + { code: 5, expected: 5 }, + { code: -1, expected: 'unknown' }, + { code: 6, expected: 'unknown' }, + { code: 3.5, expected: 'unknown' }, + ])( + 'retains MediaError code $code only inside the standard range', + ({ code, expected }) => { + expect(createEvidence({ code }).mediaErrorCode).toBe(expected); + } + ); + + it.each([ + { status: 399, expected: undefined }, + { status: 400, expected: 400 }, + { status: 599, expected: 599 }, + { status: 600, expected: undefined }, + { status: 404.5, expected: undefined }, + ])( + 'retains HTTP status $status only for integer 4xx and 5xx failures', + ({ status, expected }) => { + expect(createEvidence({ code: 2, status }).httpStatus).toBe( + expected + ); + } + ); + + it('keeps unknown identifiers unknown without inspecting messages or request types', () => { + const evidence = createEvidence({ + code: 3, + status: 0, + message: + 'CORS codec DRM failure in hls-key request at provider URL', + metadata: { + errorType: 'providerCorsCodecDrmFailure', + requestType: 'hls-key', + }, + } as NativePlaybackErrorInput); + + expect(evidence).toEqual({ + engineType: 'unknown', + mediaErrorCode: 3, + disposition: 'terminal', + stage: 'unknown', + }); + }); + + it('populates player.error before the installed Video.js error listener runs', () => { + expect(readInstalledVideoJsErrorFromListener()).toEqual({ + code: 4, + status: 503, + errorType: 'networkbadstatus', + }); + }); +}); + +function createEvidence( + error: NativePlaybackErrorInput +): ExpectedVhsPlaybackEvidence { + const factory = ( + diagnostics as unknown as { + readonly createVhsPlaybackEvidence?: EvidenceFactory; + } + ).createVhsPlaybackEvidence; + + expect(factory).toBeDefined(); + if (!factory) { + throw new Error('createVhsPlaybackEvidence is not exported'); + } + return factory(error); +} + +function getEngineTypes(): VideoJsErrorConstants { + const engineTypes = ( + diagnostics as unknown as { + readonly VhsPlaybackEngineType?: VideoJsErrorConstants; + } + ).VhsPlaybackEngineType; + + expect(engineTypes).toBeDefined(); + if (!engineTypes) { + throw new Error('VhsPlaybackEngineType is not exported'); + } + return engineTypes; +} + +function getInstalledVideoJsErrors(): VideoJsErrorConstants { + const script = + "import videoJs from 'video.js';" + + 'process.stdout.write(JSON.stringify(videoJs.Error));'; + const output = execFileSync( + process.execPath, + ['--input-type=module', '-e', script], + { + cwd: process.cwd(), + encoding: 'utf8', + } + ); + + return JSON.parse(output) as VideoJsErrorConstants; +} + +function readInstalledVideoJsErrorFromListener(): { + readonly code: number; + readonly status: number; + readonly errorType: string; +} { + const script = ` + import { createRequire } from 'node:module'; + const rootRequire = createRequire(import.meta.url); + const environmentPath = rootRequire.resolve( + 'jest-environment-jsdom/package.json' + ); + const environmentRequire = createRequire(environmentPath); + const { JSDOM } = environmentRequire('jsdom'); + const dom = new JSDOM( + '', + { url: 'http://localhost/', pretendToBeVisual: true } + ); + dom.window.HTMLMediaElement.prototype.load = () => {}; + for (const name of [ + 'window', + 'document', + 'navigator', + 'Element', + 'HTMLElement', + 'HTMLVideoElement', + 'HTMLMediaElement', + 'Event', + 'CustomEvent', + 'Node', + ]) { + Object.defineProperty(globalThis, name, { + value: dom.window[name], + configurable: true, + }); + } + const { default: videoJs } = await import('video.js'); + videoJs.log.level('off'); + const video = document.createElement('video'); + document.body.append(video); + const player = videoJs(video); + let observed = null; + player.on('error', () => { + observed = player.error(); + }); + player.error({ + code: 4, + status: 503, + metadata: { + errorType: videoJs.Error.NetworkBadStatus, + }, + }); + process.stdout.write(JSON.stringify({ + code: observed.code, + status: observed.status, + errorType: observed.metadata.errorType, + })); + player.dispose(); + dom.window.close(); + `; + const output = execFileSync( + process.execPath, + ['--input-type=module', '-e', script], + { + cwd: process.cwd(), + encoding: 'utf8', + } + ); + + return JSON.parse(output) as { + readonly code: number; + readonly status: number; + readonly errorType: string; + }; +} diff --git a/libs/ui/playback/src/lib/playback-diagnostics/vhs-playback-evidence.util.ts b/libs/ui/playback/src/lib/playback-diagnostics/vhs-playback-evidence.util.ts new file mode 100644 index 000000000..cb1215e8d --- /dev/null +++ b/libs/ui/playback/src/lib/playback-diagnostics/vhs-playback-evidence.util.ts @@ -0,0 +1,76 @@ +import type { + NativePlaybackErrorInput, + VhsPlaybackEngineType as VhsPlaybackEngineTypeValue, + VhsPlaybackEvidence, + VhsPlaybackMediaErrorCode as VhsPlaybackMediaErrorCodeValue, + VhsPlaybackStage as VhsPlaybackStageValue, +} from './playback-diagnostics.model'; +import { + VhsPlaybackDisposition, + VhsPlaybackEngineType, + VhsPlaybackMediaErrorCode, + VhsPlaybackStage, + VhsPlaybackUnknownEngineType, +} from './playback-diagnostics.model'; + +const ENGINE_TYPES = new Set(Object.values(VhsPlaybackEngineType)); +const SEGMENT_ENGINE_TYPES = new Set([ + VhsPlaybackEngineType.StreamingFailedToSelectNextSegment, + VhsPlaybackEngineType.StreamingFailedToDecryptSegment, + VhsPlaybackEngineType.StreamingFailedToTransmuxSegment, + VhsPlaybackEngineType.StreamingFailedToAppendSegment, +]); + +export function createVhsPlaybackEvidence( + error: NativePlaybackErrorInput +): VhsPlaybackEvidence { + const engineType = getEngineType(error.metadata?.errorType); + const httpStatus = getHttpStatus(error.status); + const evidence: VhsPlaybackEvidence = { + engineType, + mediaErrorCode: getMediaErrorCode(error.code), + disposition: VhsPlaybackDisposition.Terminal, + stage: getStage(engineType), + }; + + return httpStatus === undefined ? evidence : { ...evidence, httpStatus }; +} + +function getEngineType(value: unknown): VhsPlaybackEngineTypeValue { + return typeof value === 'string' && ENGINE_TYPES.has(value) + ? (value as VhsPlaybackEngineTypeValue) + : VhsPlaybackUnknownEngineType; +} + +function getMediaErrorCode(value: unknown): VhsPlaybackMediaErrorCodeValue { + return typeof value === 'number' && + Number.isInteger(value) && + value >= VhsPlaybackMediaErrorCode.Custom && + value <= VhsPlaybackMediaErrorCode.Encrypted + ? (value as VhsPlaybackMediaErrorCodeValue) + : VhsPlaybackMediaErrorCode.Unknown; +} + +function getHttpStatus(value: unknown): number | undefined { + return typeof value === 'number' && + Number.isInteger(value) && + value >= 400 && + value <= 599 + ? value + : undefined; +} + +function getStage( + engineType: VhsPlaybackEngineTypeValue +): VhsPlaybackStageValue { + if (engineType === VhsPlaybackEngineType.StreamingHlsPlaylistParserError) { + return VhsPlaybackStage.Playlist; + } + if (engineType === VhsPlaybackEngineType.StreamingDashManifestParserError) { + return VhsPlaybackStage.Manifest; + } + if (SEGMENT_ENGINE_TYPES.has(engineType)) { + return VhsPlaybackStage.Segment; + } + return VhsPlaybackStage.Unknown; +} diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts index b6fdac918..c5c37e6f5 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts @@ -204,6 +204,7 @@ describe('VjsPlayerComponent', () => { it('preserves Video.js HTTP error context in a playback diagnostic', () => { const issues: Array = []; component.playbackIssue.subscribe((issue) => issues.push(issue)); + harness.vhsActive = true; render({ sources: [ { @@ -224,15 +225,112 @@ describe('VjsPlayerComponent', () => { expect(issues.at(-1)).toEqual( expect.objectContaining({ code: 'network-error', - source: 'native', + source: 'vhs', sourceUrl: 'https://example.test/missing/playlist.m3u8', httpStatus: 404, - nativeErrorType: 'networkrequestfailed', + nativeErrorMessage: undefined, + vhs: { + engineType: 'networkrequestfailed', + mediaErrorCode: 4, + disposition: 'terminal', + stage: 'unknown', + httpStatus: 404, + }, externalFallbackRecommended: false, }) ); }); + it('keeps a generic terminal VHS code three unknown', () => { + const issues: Array = []; + component.playbackIssue.subscribe((issue) => issues.push(issue)); + harness.vhsActive = true; + render({ + sources: [ + { + src: 'https://example.test/live/playlist.m3u8', + type: 'application/x-mpegURL', + }, + ], + }); + harness.currentError = { + code: 3, + message: + 'Playback cannot continue. No available working or supported playlists.', + }; + + harness.emit('error'); + + expect(issues.at(-1)).toEqual( + expect.objectContaining({ + code: 'unknown-playback-error', + source: 'vhs', + nativeErrorMessage: undefined, + vhs: { + engineType: 'unknown', + mediaErrorCode: 3, + disposition: 'terminal', + stage: 'unknown', + }, + externalFallbackRecommended: false, + }) + ); + }); + + it('keeps non-VHS native code three as media decode evidence', () => { + const issues: Array = []; + component.playbackIssue.subscribe((issue) => issues.push(issue)); + render({ + sources: [ + { + src: 'https://example.test/archive/movie.mp4', + type: 'video/mp4', + }, + ], + }); + harness.currentError = { + code: 3, + message: 'Native media decode failed', + }; + + harness.emit('error'); + + expect(issues.at(-1)).toEqual( + expect.objectContaining({ + code: 'media-decode-error', + source: 'native', + nativeErrorMessage: 'Native media decode failed', + externalFallbackRecommended: true, + }) + ); + }); + + it('does not publish a diagnostic before the terminal VHS player error', () => { + const issues: Array = []; + component.playbackIssue.subscribe((issue) => issues.push(issue)); + harness.vhsActive = true; + render({ + sources: [ + { + src: 'https://example.test/live/playlist.m3u8', + type: 'application/x-mpegURL', + }, + ], + }); + harness.currentError = { + code: 2, + metadata: { errorType: 'networkrequestfailed' }, + }; + + harness.emit('retryplaylist'); + + expect(issues).toEqual([]); + + harness.emit('error'); + + expect(issues.at(-1)?.vhs?.disposition).toBe('terminal'); + }); + it('rebinds native ended handling after playerreset', () => { const events: string[] = []; component.playbackEnded.subscribe(() => events.push('ended')); @@ -312,6 +410,7 @@ function createPlayerHarness() { const harness = { currentVideo: document.createElement('video'), currentError: null as NativePlaybackErrorInput | null, + vhsActive: false, paused: true, pauseCompletesImmediately: true, ready: () => undefined, @@ -355,7 +454,10 @@ function createPlayerHarness() { paused: jest.fn(() => harness.paused), reset: harness.reset, src: harness.src, - tech: jest.fn(() => ({ el: () => harness.currentVideo })), + tech: jest.fn(() => ({ + el: () => harness.currentVideo, + ...(harness.vhsActive ? { vhs: {} } : {}), + })), volume: harness.volume, dispose: jest.fn(), qualitySelectorHls: jest.fn(), diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts index 9911dd61a..b7cf69e15 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts @@ -21,6 +21,7 @@ import { InlinePlaybackPlayer, type PlaybackDiagnostic, classifyNativePlaybackIssue, + classifyVhsPlaybackIssue, createPlaybackSourceMetadata, } from '../playback-diagnostics/playback-diagnostics.util'; import { @@ -48,6 +49,7 @@ import { type VideoPlayerOptions, type VideoPlayerSource, getVideoJsTechVideo, + hasActiveVhsSourceHandler, } from './vjs-player.types'; import { VjsVideoElementSession } from './vjs-video-element-session'; @@ -239,16 +241,19 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { typeof this.player.error === 'function' ? this.player.error() : null; + const metadata = createPlaybackSourceMetadata({ + url: source?.src ?? video.currentSrc ?? '', + mimeType: source?.type, + player: InlinePlaybackPlayer.VideoJs, + }); this.mpegTsSession.syncDuration(); this.playbackIssue.emit( - classifyNativePlaybackIssue( - playerError ?? video.error, - createPlaybackSourceMetadata({ - url: source?.src ?? video.currentSrc ?? '', - mimeType: source?.type, - player: InlinePlaybackPlayer.VideoJs, - }) - ) + playerError && hasActiveVhsSourceHandler(this.player) + ? classifyVhsPlaybackIssue(playerError, metadata) + : classifyNativePlaybackIssue( + playerError ?? video.error, + metadata + ) ); }; diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.types.spec.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.types.spec.ts index 9bfa1600e..0c2869f42 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.types.spec.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.types.spec.ts @@ -1,4 +1,5 @@ import { type VideoJsPlayer, getVideoJsTechVideo } from './vjs-player.types'; +import * as playerTypes from './vjs-player.types'; describe('getVideoJsTechVideo', () => { it('returns the current Video.js Tech video element', () => { @@ -28,3 +29,44 @@ describe('getVideoJsTechVideo', () => { ).toBeNull(); }); }); + +describe('hasActiveVhsSourceHandler', () => { + it('detects the documented Video.js Tech VHS runtime property', () => { + expect( + hasActiveVhsSourceHandler({ + tech: () => ({ vhs: {} }), + } as unknown as VideoJsPlayer) + ).toBe(true); + expect( + hasActiveVhsSourceHandler({ + tech: () => ({ el: () => document.createElement('video') }), + } as unknown as VideoJsPlayer) + ).toBe(false); + }); + + it('fails closed when Tech access is transiently unavailable', () => { + expect( + hasActiveVhsSourceHandler({ + tech: () => { + throw new Error('Tech unavailable'); + }, + } as unknown as VideoJsPlayer) + ).toBe(false); + }); +}); + +function hasActiveVhsSourceHandler(player: VideoJsPlayer): boolean { + const helper = ( + playerTypes as unknown as { + readonly hasActiveVhsSourceHandler?: ( + candidate: VideoJsPlayer + ) => boolean; + } + ).hasActiveVhsSourceHandler; + + expect(helper).toBeDefined(); + if (!helper) { + throw new Error('hasActiveVhsSourceHandler is not exported'); + } + return helper(player); +} diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.types.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.types.ts index a6a2d25c3..5b9387bc0 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.types.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.types.ts @@ -97,3 +97,14 @@ export function getVideoJsTechVideo( return null; } } + +export function hasActiveVhsSourceHandler( + player: Pick +): boolean { + try { + const vhs = player.tech()?.vhs; + return typeof vhs === 'object' && vhs !== null; + } catch { + return false; + } +} diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view-diagnostics.utils.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view-diagnostics.utils.ts index b66075ca6..aec1c0e20 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view-diagnostics.utils.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view-diagnostics.utils.ts @@ -82,7 +82,7 @@ export function getDiagnosticDetails( }, { labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_NATIVE_ERROR_MESSAGE', - value: issue.nativeErrorMessage ?? '', + value: issue.vhs ? '' : (issue.nativeErrorMessage ?? ''), }, { labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_ERROR_DETAILS', @@ -92,6 +92,20 @@ export function getDiagnosticDetails( } function formatDiagnosticErrorDetails(issue: PlaybackDiagnostic): string { + if (issue.vhs) { + return [ + `stage=${issue.vhs.stage}`, + `type=${issue.vhs.engineType}`, + `code=${issue.vhs.mediaErrorCode}`, + `disposition=${issue.vhs.disposition}`, + issue.vhs.httpStatus === undefined + ? '' + : `HTTP ${issue.vhs.httpStatus}`, + ] + .filter((value) => value.length > 0) + .join(' · '); + } + if (issue.hls) { return [ `stage=${issue.hls.stage}`, @@ -151,6 +165,8 @@ function formatPlayer(player: PlaybackDiagnostic['player']): string { function formatDiagnosticSource(source: PlaybackDiagnostic['source']): string { switch (source) { + case 'vhs': + return 'Video.js / VHS'; case 'hls': return 'HLS.js'; case 'mpegts': diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts index 5bb8138c8..8b275e075 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts @@ -342,6 +342,35 @@ describe('WebPlayerViewComponent', () => { expect(renderedDetails).not.toContain('provider.example'); }); + it('renders only sanitized structured VHS evidence in technical details', () => { + const issue = createStructuredVhsDiagnostic(); + + component.handlePlaybackIssue(issue); + fixture.detectChanges(); + + const details = component.getDiagnosticDetails(issue); + const renderedDetails = details.map(({ value }) => value).join(' '); + + expect(details).toEqual( + expect.arrayContaining([ + { + labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_SOURCE', + value: 'Video.js / VHS', + }, + { + labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_ERROR_DETAILS', + value: + 'stage=unknown · type=networkbadstatus · code=4 · ' + + 'disposition=terminal · HTTP 503', + }, + ]) + ); + expect(renderedDetails).not.toContain('vhs-render-secret'); + expect(renderedDetails).not.toContain('provider.example'); + expect(renderedDetails).not.toContain('Authorization'); + expect(renderedDetails).not.toContain('response body'); + }); + it('keeps query-declared HLS streams on the HLS mime type', () => { const streamUrl = 'https://example.com/play?extension=m3u8&token=signed'; @@ -370,8 +399,7 @@ describe('WebPlayerViewComponent', () => { }); it('uses the Matroska mime type for query-declared MKV streams', () => { - const streamUrl = - 'https://example.com/play?container=mkv&token=signed'; + const streamUrl = 'https://example.com/play?container=mkv&token=signed'; component.setVjsOptions(streamUrl); @@ -926,3 +954,29 @@ function createStructuredHlsDiagnostic(): PlaybackDiagnostic { externalFallbackRecommended: false, }; } + +function createStructuredVhsDiagnostic(): PlaybackDiagnostic { + return { + code: PlaybackDiagnosticCode.NetworkError, + source: PlaybackDiagnosticSource.Vhs, + sourceUrl: 'https://provider.example/live.m3u8?token=vhs-render-secret', + container: 'm3u8', + mimeType: 'application/x-mpegURL', + player: 'videojs', + audioCodecs: [], + videoCodecs: [], + details: 'Authorization response body vhs-render-secret', + nativeErrorCode: 4, + nativeErrorMessage: + 'https://provider.example/error?token=vhs-render-secret', + httpStatus: 503, + vhs: { + engineType: 'networkbadstatus', + mediaErrorCode: 4, + disposition: 'terminal', + stage: 'unknown', + httpStatus: 503, + }, + externalFallbackRecommended: false, + }; +}