diff --git a/libs/ui/playback/src/lib/art-player/art-player-source-session.dash.spec.ts b/libs/ui/playback/src/lib/art-player/art-player-source-session.dash.spec.ts index dd48c9565..e0c839e19 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-source-session.dash.spec.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-source-session.dash.spec.ts @@ -1,4 +1,10 @@ import type { ChannelDrm } from '@iptvnator/shared/interfaces'; +import { + InlinePlaybackPlayer, + type PlaybackDiagnostic, + PlaybackDiagnosticCode, + PlaybackDiagnosticSource, +} from '../playback-diagnostics/playback-diagnostics.model'; import { createFakeShakaEnvironment, flushShakaMicrotasks, @@ -82,4 +88,62 @@ describe('ArtPlayerSourceSession DASH (mpd custom type)', () => { await flushShakaMicrotasks(); expect(fakeShaka.instances).toHaveLength(1); }); + + it('routes only sanitized terminal Shaka evidence through the ArtPlayer adapter', async () => { + const secret = 'artplayer-shaka-secret'; + const issues: PlaybackDiagnostic[] = []; + const fakeShaka = createFakeShakaEnvironment({ + onCreate: (shakaPlayer) => { + shakaPlayer.loadResult = Promise.reject({ + severity: 1, + category: 1, + code: 1001, + message: `https://provider.example/?token=${secret}`, + data: [ + `https://provider.example/manifest.mpd?token=${secret}`, + 503, + `provider body ${secret}`, + { Authorization: `Bearer ${secret}` }, + ], + }); + }, + }); + const { session, player, video } = createSession({ + sharedControls: true, + emitPlaybackIssue: (issue) => issues.push(issue), + loadShaka: fakeShaka.loader, + }); + session.attach(player); + + session.customType['mpd']?.( + video, + 'https://example.test/failing.mpd', + player + ); + await flushShakaMicrotasks(); + + expect(issues).toHaveLength(1); + expect(issues[0]).toEqual( + expect.objectContaining({ + source: PlaybackDiagnosticSource.Shaka, + player: InlinePlaybackPlayer.ArtPlayer, + code: PlaybackDiagnosticCode.NetworkError, + httpStatus: 503, + shaka: { + severity: 'recoverable', + category: 'network', + engineCode: 1001, + disposition: 'terminal', + stage: 'unknown', + failure: 'network', + httpStatus: 503, + }, + }) + ); + expect(JSON.stringify(issues[0].shaka)).not.toContain(secret); + expect(JSON.stringify(issues[0].shaka)).not.toContain( + 'provider.example' + ); + expect(JSON.stringify(issues[0].shaka)).not.toContain('Authorization'); + }); }); 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 54c164b0b..df9185068 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 @@ -173,6 +173,76 @@ export interface HlsPlaybackEvidence { readonly httpStatus?: number; } +export const ShakaPlaybackSeverity = { + Recoverable: 'recoverable', + Critical: 'critical', + Unknown: 'unknown', +} as const; + +export type ShakaPlaybackSeverity = + (typeof ShakaPlaybackSeverity)[keyof typeof ShakaPlaybackSeverity]; + +export const ShakaPlaybackCategory = { + Network: 'network', + Text: 'text', + Media: 'media', + Manifest: 'manifest', + Streaming: 'streaming', + Drm: 'drm', + Player: 'player', + Cast: 'cast', + Storage: 'storage', + Ads: 'ads', + Unknown: 'unknown', +} as const; + +export type ShakaPlaybackCategory = + (typeof ShakaPlaybackCategory)[keyof typeof ShakaPlaybackCategory]; + +export const ShakaPlaybackDisposition = { + Terminal: 'terminal', + Recoverable: 'recoverable', +} as const; + +export type ShakaPlaybackDisposition = + (typeof ShakaPlaybackDisposition)[keyof typeof ShakaPlaybackDisposition]; + +export const ShakaPlaybackStage = { + Manifest: 'manifest', + Segment: 'segment', + Media: 'media', + License: 'license', + Unknown: 'unknown', +} as const; + +export type ShakaPlaybackStage = + (typeof ShakaPlaybackStage)[keyof typeof ShakaPlaybackStage]; + +export const ShakaPlaybackFailure = { + Network: 'network', + Drm: 'drm', + Manifest: 'manifest', + Media: 'media', + Unknown: 'unknown', +} as const; + +export type ShakaPlaybackFailure = + (typeof ShakaPlaybackFailure)[keyof typeof ShakaPlaybackFailure]; + +export const ShakaPlaybackUnknownCode = 'unknown' as const; + +export type ShakaPlaybackEngineCode = number | typeof ShakaPlaybackUnknownCode; + +export interface ShakaPlaybackEvidence { + readonly severity: ShakaPlaybackSeverity; + readonly category: ShakaPlaybackCategory; + readonly engineCode: ShakaPlaybackEngineCode; + readonly disposition: ShakaPlaybackDisposition; + readonly stage: ShakaPlaybackStage; + readonly failure: ShakaPlaybackFailure; + readonly httpStatus?: number; +} + export interface MpegTsPlaybackErrorInput { readonly type?: string; readonly details?: string; @@ -196,6 +266,7 @@ export interface PlaybackDiagnostic { readonly nativeErrorType?: string; readonly vhs?: VhsPlaybackEvidence; readonly hls?: HlsPlaybackEvidence; + readonly shaka?: ShakaPlaybackEvidence; readonly externalFallbackRecommended: boolean; } 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 6512a7f0f..6e62f1bc5 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,6 +6,7 @@ import type { PlaybackDiagnosticCode, PlaybackDiagnosticSource, PlaybackSourceMetadata, + ShakaPlaybackEvidence, VhsPlaybackEngineType as VhsPlaybackEngineTypeValue, VhsPlaybackEvidence, } from './playback-diagnostics.model'; @@ -257,6 +258,7 @@ export function createPlaybackDiagnostic(options: { readonly nativeErrorType?: string; readonly vhs?: VhsPlaybackEvidence; readonly hls?: HlsPlaybackEvidence; + readonly shaka?: ShakaPlaybackEvidence; /** Overrides the code-derived recommendation, e.g. when external players * are known to be unable to handle the stream either. */ readonly externalFallbackRecommended?: boolean; @@ -272,6 +274,7 @@ export function createPlaybackDiagnostic(options: { nativeErrorType, vhs, hls, + shaka, } = options; return { @@ -290,6 +293,7 @@ export function createPlaybackDiagnostic(options: { nativeErrorType, vhs, hls, + shaka, externalFallbackRecommended: options.externalFallbackRecommended ?? isExternalFallbackRecommended(code), diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.spec.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.spec.ts index 9dbe76aca..529dbc9d4 100644 --- a/libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.spec.ts +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.spec.ts @@ -9,91 +9,213 @@ import { createUnsupportedDrmDiagnostic, } from './shaka-error-classifier'; +interface StructuredShakaDiagnostic { + readonly code: string; + readonly details?: string; + readonly httpStatus?: number; + readonly shaka?: { + readonly severity: string; + readonly category: string; + readonly engineCode: number | string; + readonly disposition: string; + readonly stage: string; + readonly failure: string; + readonly httpStatus?: number; + }; +} + +type StructuredClassifier = ( + error: Record | null | undefined, + sourceMetadata: typeof metadata, + disposition: 'terminal' | 'recoverable' +) => StructuredShakaDiagnostic | null; + const metadata = createPlaybackSourceMetadata({ url: 'http://example.com/stream.mpd', mimeType: 'application/dash+xml', player: InlinePlaybackPlayer.Html5, }); +const classify = classifyShakaPlaybackIssue as StructuredClassifier; + describe('classifyShakaPlaybackIssue', () => { it.each([ - ['DRM category', { category: 6, code: 6001 }], - ['restrictions-cannot-be-met', { category: 4, code: 4012 }], + ['DRM category/code pair', { severity: 2, category: 6, code: 6001 }], + ['manifest key-system code', { severity: 2, category: 4, code: 4008 }], + ])('maps exact %s to DrmOrEncryption', (_label, error) => { + const issue = classify(error, metadata, 'terminal'); + expect(issue).not.toBeNull(); + expect(issue?.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption); + expect(issue?.shaka?.failure).toBe('drm'); + }); + + it('maps an exact terminal network error and retains only safe status evidence', () => { + const issue = classify( + { + severity: 1, + category: 1, + code: 1001, + message: + 'Blocked by CORS at https://provider.example/?token=secret', + data: [ + 'https://provider.example/manifest.mpd?token=secret', + 503, + 'provider response secret', + { Authorization: 'Bearer secret' }, + ], + }, + metadata, + 'terminal' + ); + + expect(issue).toEqual( + expect.objectContaining({ + code: PlaybackDiagnosticCode.NetworkError, + source: PlaybackDiagnosticSource.Shaka, + httpStatus: 503, + details: undefined, + shaka: { + severity: 'recoverable', + category: 'network', + engineCode: 1001, + disposition: 'terminal', + stage: 'unknown', + failure: 'network', + httpStatus: 503, + }, + }) + ); + const serialized = JSON.stringify(issue); + expect(serialized).not.toContain('secret'); + expect(serialized).not.toContain('provider.example'); + expect(serialized).not.toContain('Authorization'); + expect(serialized).not.toContain('response'); + }); + + it.each([3014, 3015, 3016])( + 'maps exact media pipeline code %s to MediaDecodeError', + (code) => { + const issue = classify( + { severity: 2, category: 3, code }, + metadata, + 'terminal' + ); + expect(issue?.code).toBe(PlaybackDiagnosticCode.MediaDecodeError); + } + ); + + it('maps only the exact DASH unsupported-container code to UnsupportedContainer', () => { + const issue = classify( + { severity: 2, category: 4, code: 4006 }, + metadata, + 'terminal' + ); + expect(issue?.code).toBe(PlaybackDiagnosticCode.UnsupportedContainer); + }); + + it.each([ [ - 'license keyword in message', - { category: 5, code: 5001, message: 'license request failed' }, + 'manifest parsing', + { severity: 2, category: 4, code: 4001 }, + 'manifest', ], - ])('maps %s to DrmOrEncryption', (_label, error) => { - const issue = classifyShakaPlaybackIssue(error, metadata); - expect(issue.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption); - expect(issue.source).toBe(PlaybackDiagnosticSource.Shaka); - }); + [ + 'ambiguous browser content support', + { severity: 2, category: 4, code: 4032 }, + 'media', + ], + [ + 'ambiguous restrictions', + { severity: 2, category: 4, code: 4012 }, + 'unknown', + ], + [ + 'generic media parsing', + { severity: 2, category: 3, code: 3005 }, + 'media', + ], + [ + 'mismatched category/code pair', + { severity: 2, category: 1, code: 6001 }, + 'unknown', + ], + [ + 'mismatched media code pair', + { severity: 2, category: 1, code: 3014 }, + 'unknown', + ], + [ + 'mismatched container code pair', + { severity: 2, category: 3, code: 4006 }, + 'unknown', + ], + [ + 'provider extension values', + { severity: 3, category: 11, code: 123456 }, + 'unknown', + ], + ])( + 'keeps %s as UnknownPlaybackError with structured failure=%s', + (_label, error, failure) => { + const issue = classify( + { + ...error, + message: + 'CORS codec DRM unsupported container license request', + data: [{ provider: 'secret-payload' }], + }, + metadata, + 'terminal' + ); - it('maps network category to NetworkError', () => { - const issue = classifyShakaPlaybackIssue( - { category: 1, code: 1001 }, - metadata + expect(issue?.code).toBe( + PlaybackDiagnosticCode.UnknownPlaybackError + ); + expect(issue?.shaka?.failure).toBe(failure); + expect(issue?.details).toBeUndefined(); + } + ); + + it('does not turn a recoverable Shaka event into a terminal diagnostic', () => { + const issue = classify( + { severity: 1, category: 1, code: 1002 }, + metadata, + 'recoverable' ); - expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError); + expect(issue).toBeNull(); }); - it('maps CORS-flavored network failures to BrowserAccessError', () => { - const issue = classifyShakaPlaybackIssue( - { category: 1, code: 1002, message: 'Blocked by CORS policy' }, - metadata + it('creates unknown structured evidence when no public Shaka error is available', () => { + const issue = classify(null, metadata, 'terminal'); + expect(issue).toEqual( + expect.objectContaining({ + code: PlaybackDiagnosticCode.UnknownPlaybackError, + details: undefined, + shaka: { + severity: 'unknown', + category: 'unknown', + engineCode: 'unknown', + disposition: 'terminal', + stage: 'unknown', + failure: 'unknown', + }, + }) ); - expect(issue.code).toBe(PlaybackDiagnosticCode.BrowserAccessError); - }); - - it('maps media category to MediaDecodeError', () => { - const issue = classifyShakaPlaybackIssue( - { category: 3, code: 3016 }, - metadata - ); - expect(issue.code).toBe(PlaybackDiagnosticCode.MediaDecodeError); - }); - - it('maps codec-flavored media failures to UnsupportedCodec', () => { - const issue = classifyShakaPlaybackIssue( - { category: 3, code: 3005, message: 'addCodec failed for hvc1' }, - metadata - ); - expect(issue.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec); - }); - - it('maps manifest category to UnsupportedContainer', () => { - const issue = classifyShakaPlaybackIssue( - { category: 4, code: 4001 }, - metadata - ); - expect(issue.code).toBe(PlaybackDiagnosticCode.UnsupportedContainer); - }); - - it('falls back to UnknownPlaybackError and keeps details', () => { - const issue = classifyShakaPlaybackIssue( - { category: 7, code: 7002, message: 'boom' }, - metadata - ); - expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError); - expect(issue.details).toContain('boom'); - expect(issue.details).toContain('7002'); - }); - - it('tolerates missing error objects', () => { - const issue = classifyShakaPlaybackIssue(null, metadata); - expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError); }); }); describe('createUnsupportedDrmDiagnostic', () => { - it('creates a DRM diagnostic without recommending unusable external fallbacks', () => { + it('creates a safe DRM diagnostic without echoing provider license data', () => { + const secret = 'unsupported-drm-secret'; const issue = createUnsupportedDrmDiagnostic( - 'com.widevine.alpha', + `https://provider.example/license?token=${secret}`, metadata ); expect(issue.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption); expect(issue.source).toBe(PlaybackDiagnosticSource.Shaka); - expect(issue.details).toContain('com.widevine.alpha'); + expect(issue.details).toBe('Unsupported DRM license configuration'); + expect(JSON.stringify(issue)).not.toContain(secret); + expect(JSON.stringify(issue)).not.toContain('provider.example'); // MPV/VLC cannot receive KODIPROP license config, so the diagnostic // must not offer them as a fallback. expect(issue.externalFallbackRecommended).toBe(false); diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.ts index 221384c31..9bc96d8ef 100644 --- a/libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.ts +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.ts @@ -1,116 +1,93 @@ import type { PlaybackDiagnostic, + PlaybackDiagnosticCode, PlaybackSourceMetadata, + ShakaPlaybackDisposition, + ShakaPlaybackEvidence, } from '../playback-diagnostics/playback-diagnostics.model'; import { PlaybackDiagnosticCode as DiagnosticCode, PlaybackDiagnosticSource as DiagnosticSource, + ShakaPlaybackCategory, + ShakaPlaybackDisposition as ShakaDisposition, + ShakaPlaybackFailure, } from '../playback-diagnostics/playback-diagnostics.model'; import { createPlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util'; -import { - isBrowserAccessFailure, - isCodecFailure, - isDrmOrEncryptionFailure, -} from '../playback-diagnostics/playback-error-patterns.util'; +import { SHAKA_ERROR_CODE } from './shaka-error-contract'; +import { createShakaPlaybackEvidence } from './shaka-playback-evidence.util'; import type { ShakaErrorLike } from './shaka-module.types'; -/** - * Numeric `shaka.util.Error.Category` values (stable public Shaka API). Kept - * local so classification stays a pure function that does not need the lazily - * loaded module. - */ -const SHAKA_CATEGORY = { - Network: 1, - Text: 2, - Media: 3, - Manifest: 4, - Streaming: 5, - Drm: 6, -} as const; +export { + SHAKA_DIAGNOSTIC_VERSION, + SHAKA_ERROR_CATEGORY, + SHAKA_ERROR_CODE, + SHAKA_ERROR_SEVERITY, +} from './shaka-error-contract'; +export { createShakaPlaybackEvidence } from './shaka-playback-evidence.util'; -/** `shaka.util.Error.Code.RESTRICTIONS_CANNOT_BE_MET` — in practice this fires - * when every variant is restricted by unusable decryption keys. */ -const RESTRICTIONS_CANNOT_BE_MET = 4012; +const MEDIA_DECODE_CODES = new Set([ + SHAKA_ERROR_CODE.MEDIA_SOURCE_OPERATION_FAILED, + SHAKA_ERROR_CODE.MEDIA_SOURCE_OPERATION_THREW, + SHAKA_ERROR_CODE.VIDEO_ERROR, +]); export function classifyShakaPlaybackIssue( error: Partial | null | undefined, - metadata: PlaybackSourceMetadata -): PlaybackDiagnostic { - const details = formatShakaErrorDetails(error); - const lowerDetails = details.toLowerCase(); - const category = error?.category; - - if ( - category === SHAKA_CATEGORY.Drm || - error?.code === RESTRICTIONS_CANNOT_BE_MET || - isDrmOrEncryptionFailure(lowerDetails) - ) { - return createPlaybackDiagnostic({ - code: DiagnosticCode.DrmOrEncryption, - source: DiagnosticSource.Shaka, - metadata, - details, - }); - } - - if (category === SHAKA_CATEGORY.Network) { - return createPlaybackDiagnostic({ - code: isBrowserAccessFailure(lowerDetails) - ? DiagnosticCode.BrowserAccessError - : DiagnosticCode.NetworkError, - source: DiagnosticSource.Shaka, - metadata, - details, - }); - } - - if ( - category === SHAKA_CATEGORY.Media || - category === SHAKA_CATEGORY.Streaming - ) { - return createPlaybackDiagnostic({ - code: isCodecFailure(lowerDetails) - ? DiagnosticCode.UnsupportedCodec - : DiagnosticCode.MediaDecodeError, - source: DiagnosticSource.Shaka, - metadata, - details, - }); - } - - if (category === SHAKA_CATEGORY.Manifest) { - return createPlaybackDiagnostic({ - code: DiagnosticCode.UnsupportedContainer, - source: DiagnosticSource.Shaka, - metadata, - details, - }); + metadata: PlaybackSourceMetadata, + disposition: ShakaPlaybackDisposition +): PlaybackDiagnostic | null { + const evidence = createShakaPlaybackEvidence(error, disposition); + if (evidence.disposition === ShakaDisposition.Recoverable) { + return null; } return createPlaybackDiagnostic({ - code: DiagnosticCode.UnknownPlaybackError, + code: getDiagnosticCode(evidence), source: DiagnosticSource.Shaka, metadata, - details, + httpStatus: evidence.httpStatus, + shaka: evidence, }); } +function getDiagnosticCode( + evidence: ShakaPlaybackEvidence +): PlaybackDiagnosticCode { + if (evidence.failure === ShakaPlaybackFailure.Network) { + return DiagnosticCode.NetworkError; + } + if (evidence.failure === ShakaPlaybackFailure.Drm) { + return DiagnosticCode.DrmOrEncryption; + } + if ( + evidence.category === ShakaPlaybackCategory.Manifest && + evidence.engineCode === SHAKA_ERROR_CODE.DASH_UNSUPPORTED_CONTAINER + ) { + return DiagnosticCode.UnsupportedContainer; + } + if ( + evidence.category === ShakaPlaybackCategory.Media && + typeof evidence.engineCode === 'number' && + MEDIA_DECODE_CODES.has(evidence.engineCode) + ) { + return DiagnosticCode.MediaDecodeError; + } + return DiagnosticCode.UnknownPlaybackError; +} + /** * Diagnostic for `.mpd` channels that declare a DRM system the app cannot - * handle (Widevine, PlayReady, malformed ClearKey config, …). Emitted before - * any Shaka engine is started. + * handle. Provider-supplied license strings are intentionally not retained. */ export function createUnsupportedDrmDiagnostic( - licenseType: string, + _licenseType: string, metadata: PlaybackSourceMetadata ): PlaybackDiagnostic { return createPlaybackDiagnostic({ code: DiagnosticCode.DrmOrEncryption, source: DiagnosticSource.Shaka, metadata, - details: licenseType - ? `Unsupported DRM license configuration: ${licenseType}` - : 'Unsupported DRM license configuration', + details: 'Unsupported DRM license configuration', // External MPV/VLC cannot receive the KODIPROP license config either, // so offering them as a fallback would just fail differently. externalFallbackRecommended: false, @@ -118,56 +95,15 @@ export function createUnsupportedDrmDiagnostic( } /** Narrows an unknown rejection to a Shaka-error-like shape, if it is one. */ -export function asShakaError( - error: unknown -): Partial | null { +export function asShakaError(error: unknown): Partial | null { if (!error || typeof error !== 'object') { return null; } const candidate = error as Partial; return typeof candidate.code === 'number' || - typeof candidate.category === 'number' + typeof candidate.category === 'number' || + typeof candidate.severity === 'number' ? candidate : null; } - -export function toErrorMessage(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - - return typeof error === 'string' ? error : String(error); -} - -function formatShakaErrorDetails( - error: Partial | null | undefined -): string { - if (!error) { - return ''; - } - - const parts = [ - typeof error.category === 'number' - ? `Shaka category ${error.category}` - : '', - typeof error.code === 'number' ? `code ${error.code}` : '', - error.message ?? '', - stringifyErrorData(error.data), - ]; - - return parts.filter((part) => part.length > 0).join(' '); -} - -function stringifyErrorData(data: unknown[] | undefined): string { - if (!data || data.length === 0) { - return ''; - } - - try { - const serialized = JSON.stringify(data); - return serialized === '[]' ? '' : serialized; - } catch { - return ''; - } -} diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-error-contract.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-error-contract.ts new file mode 100644 index 000000000..4e644c1ef --- /dev/null +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-error-contract.ts @@ -0,0 +1,114 @@ +/** + * Public Shaka error values audited against the locked 5.2.2 runtime. + * + * Keep the version assertion in the contract spec: a Shaka upgrade must stop + * here for a new audit instead of silently accepting new error layouts. + */ +export const SHAKA_DIAGNOSTIC_VERSION = 'v5.2.2'; + +export const SHAKA_ERROR_SEVERITY = { + RECOVERABLE: 1, + CRITICAL: 2, +} as const; + +export const SHAKA_ERROR_CATEGORY = { + NETWORK: 1, + TEXT: 2, + MEDIA: 3, + MANIFEST: 4, + STREAMING: 5, + DRM: 6, + PLAYER: 7, + CAST: 8, + STORAGE: 9, + ADS: 10, +} as const; + +/** + * Public online-playback codes whose numeric value may cross the evidence + * boundary. Retired gaps and offline/cast/ad-only codes are intentionally + * absent. + */ +export const SHAKA_ERROR_CODE = { + UNSUPPORTED_SCHEME: 1000, + BAD_HTTP_STATUS: 1001, + HTTP_ERROR: 1002, + TIMEOUT: 1003, + MALFORMED_DATA_URI: 1004, + REQUEST_FILTER_ERROR: 1006, + RESPONSE_FILTER_ERROR: 1007, + SEGMENT_MISSING: 1011, + BUFFER_READ_OUT_OF_BOUNDS: 3000, + JS_INTEGER_OVERFLOW: 3001, + EBML_OVERFLOW: 3002, + EBML_BAD_FLOATING_POINT_SIZE: 3003, + MP4_SIDX_WRONG_BOX_TYPE: 3004, + MP4_SIDX_INVALID_TIMESCALE: 3005, + MP4_SIDX_TYPE_NOT_SUPPORTED: 3006, + WEBM_CUES_ELEMENT_MISSING: 3007, + WEBM_EBML_HEADER_ELEMENT_MISSING: 3008, + WEBM_SEGMENT_ELEMENT_MISSING: 3009, + WEBM_INFO_ELEMENT_MISSING: 3010, + WEBM_DURATION_ELEMENT_MISSING: 3011, + WEBM_CUE_TRACK_POSITIONS_ELEMENT_MISSING: 3012, + WEBM_CUE_TIME_ELEMENT_MISSING: 3013, + MEDIA_SOURCE_OPERATION_FAILED: 3014, + MEDIA_SOURCE_OPERATION_THREW: 3015, + VIDEO_ERROR: 3016, + QUOTA_EXCEEDED_ERROR: 3017, + TRANSMUXING_FAILED: 3018, + CONTENT_TRANSFORMATION_FAILED: 3019, + TRANSMUXING_NO_VIDEO_DATA: 3023, + STREAMING_NOT_ALLOWED: 3024, + BUFFER_WRITE_OUT_OF_BOUNDS: 3025, + UNABLE_TO_GUESS_MANIFEST_TYPE: 4000, + DASH_INVALID_XML: 4001, + DASH_NO_SEGMENT_INFO: 4002, + DASH_EMPTY_ADAPTATION_SET: 4003, + DASH_EMPTY_PERIOD: 4004, + DASH_WEBM_MISSING_INIT: 4005, + DASH_UNSUPPORTED_CONTAINER: 4006, + DASH_PSSH_BAD_ENCODING: 4007, + DASH_NO_COMMON_KEY_SYSTEM: 4008, + DASH_MULTIPLE_KEY_IDS_NOT_SUPPORTED: 4009, + DASH_CONFLICTING_KEY_IDS: 4010, + RESTRICTIONS_CANNOT_BE_MET: 4012, + DASH_DUPLICATE_REPRESENTATION_ID: 4018, + DASH_UNSUPPORTED_XLINK_ACTUATE: 4027, + DASH_XLINK_DEPTH_LIMIT: 4028, + CONTENT_UNSUPPORTED_BY_BROWSER: 4032, + NO_VARIANTS: 4036, + PERIOD_FLATTENING_FAILED: 4037, + INCONSISTENT_DRM_ACROSS_PERIODS: 4038, + NO_WEB_CRYPTO_API: 4042, + AES_128_INVALID_IV_LENGTH: 4048, + AES_128_INVALID_KEY_LENGTH: 4049, + DASH_CONFLICTING_AES_128: 4050, + DASH_UNSUPPORTED_AES_128: 4051, + DASH_INVALID_PATCH: 4052, + DASH_MSE_ENCRYPTED_LEGACY_APPLE_MEDIA_KEYS_NOT_SUPPORTED: 4054, + WEBTRANSPORT_NOT_AVAILABLE: 4056, + WEBTRANSPORT_INITIALIZATION_FAILED: 4057, + DASH_INVALID_JSON: 4061, + DASH_UNSUPPORTED_ESSENTIAL_PROPERTY: 4063, + NO_RECOGNIZED_KEY_SYSTEMS: 6000, + REQUESTED_KEY_SYSTEM_CONFIG_UNAVAILABLE: 6001, + FAILED_TO_CREATE_CDM: 6002, + FAILED_TO_ATTACH_TO_VIDEO: 6003, + INVALID_SERVER_CERTIFICATE: 6004, + FAILED_TO_CREATE_SESSION: 6005, + FAILED_TO_GENERATE_LICENSE_REQUEST: 6006, + LICENSE_REQUEST_FAILED: 6007, + LICENSE_RESPONSE_REJECTED: 6008, + ENCRYPTED_CONTENT_WITHOUT_DRM_INFO: 6010, + NO_LICENSE_SERVER_GIVEN: 6012, + OFFLINE_SESSION_REMOVED: 6013, + EXPIRED: 6014, + SERVER_CERTIFICATE_REQUIRED: 6015, + INIT_DATA_TRANSFORM_ERROR: 6016, + SERVER_CERTIFICATE_REQUEST_FAILED: 6017, + MIN_HDCP_VERSION_NOT_MATCH: 6018, + ERROR_CHECKING_HDCP_VERSION: 6019, + MISSING_EME_SUPPORT: 6020, + LOAD_INTERRUPTED: 7000, +} as const; diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-error-lifecycle.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-error-lifecycle.ts new file mode 100644 index 000000000..b58c0a87c --- /dev/null +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-error-lifecycle.ts @@ -0,0 +1,29 @@ +import { ShakaPlaybackDisposition as ShakaDisposition } from '../playback-diagnostics/playback-diagnostics.model'; +import { + SHAKA_ERROR_CATEGORY, + SHAKA_ERROR_CODE, + SHAKA_ERROR_SEVERITY, +} from './shaka-error-contract'; +import type { ShakaErrorLike } from './shaka-module.types'; + +export function getShakaErrorEventDisposition( + error: Partial | null +): (typeof ShakaDisposition)[keyof typeof ShakaDisposition] | null { + if (error?.severity === SHAKA_ERROR_SEVERITY.RECOVERABLE) { + return ShakaDisposition.Recoverable; + } + if (error?.severity === SHAKA_ERROR_SEVERITY.CRITICAL) { + return ShakaDisposition.Terminal; + } + return null; +} + +export function isShakaLoadInterrupted( + error: Partial | null +): boolean { + return ( + error?.severity === SHAKA_ERROR_SEVERITY.CRITICAL && + error.category === SHAKA_ERROR_CATEGORY.PLAYER && + error.code === SHAKA_ERROR_CODE.LOAD_INTERRUPTED + ); +} diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-error-mapping.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-error-mapping.ts new file mode 100644 index 000000000..67be7c43f --- /dev/null +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-error-mapping.ts @@ -0,0 +1,121 @@ +import { SHAKA_ERROR_CODE } from './shaka-error-contract'; + +export const SHAKA_PUBLIC_CODES = new Set( + Object.values(SHAKA_ERROR_CODE) +); + +export const SHAKA_NETWORK_CODES = new Set([ + SHAKA_ERROR_CODE.UNSUPPORTED_SCHEME, + SHAKA_ERROR_CODE.BAD_HTTP_STATUS, + SHAKA_ERROR_CODE.HTTP_ERROR, + SHAKA_ERROR_CODE.TIMEOUT, + SHAKA_ERROR_CODE.MALFORMED_DATA_URI, + SHAKA_ERROR_CODE.REQUEST_FILTER_ERROR, + SHAKA_ERROR_CODE.RESPONSE_FILTER_ERROR, + SHAKA_ERROR_CODE.SEGMENT_MISSING, +]); + +export const SHAKA_MEDIA_CODES = new Set([ + SHAKA_ERROR_CODE.BUFFER_READ_OUT_OF_BOUNDS, + SHAKA_ERROR_CODE.JS_INTEGER_OVERFLOW, + SHAKA_ERROR_CODE.EBML_OVERFLOW, + SHAKA_ERROR_CODE.EBML_BAD_FLOATING_POINT_SIZE, + SHAKA_ERROR_CODE.MP4_SIDX_WRONG_BOX_TYPE, + SHAKA_ERROR_CODE.MP4_SIDX_INVALID_TIMESCALE, + SHAKA_ERROR_CODE.MP4_SIDX_TYPE_NOT_SUPPORTED, + SHAKA_ERROR_CODE.WEBM_CUES_ELEMENT_MISSING, + SHAKA_ERROR_CODE.WEBM_EBML_HEADER_ELEMENT_MISSING, + SHAKA_ERROR_CODE.WEBM_SEGMENT_ELEMENT_MISSING, + SHAKA_ERROR_CODE.WEBM_INFO_ELEMENT_MISSING, + SHAKA_ERROR_CODE.WEBM_DURATION_ELEMENT_MISSING, + SHAKA_ERROR_CODE.WEBM_CUE_TRACK_POSITIONS_ELEMENT_MISSING, + SHAKA_ERROR_CODE.WEBM_CUE_TIME_ELEMENT_MISSING, + SHAKA_ERROR_CODE.MEDIA_SOURCE_OPERATION_FAILED, + SHAKA_ERROR_CODE.MEDIA_SOURCE_OPERATION_THREW, + SHAKA_ERROR_CODE.VIDEO_ERROR, + SHAKA_ERROR_CODE.QUOTA_EXCEEDED_ERROR, + SHAKA_ERROR_CODE.TRANSMUXING_FAILED, + SHAKA_ERROR_CODE.CONTENT_TRANSFORMATION_FAILED, + SHAKA_ERROR_CODE.TRANSMUXING_NO_VIDEO_DATA, + SHAKA_ERROR_CODE.STREAMING_NOT_ALLOWED, + SHAKA_ERROR_CODE.BUFFER_WRITE_OUT_OF_BOUNDS, +]); + +export const SHAKA_MANIFEST_DRM_CODES = new Set([ + SHAKA_ERROR_CODE.DASH_PSSH_BAD_ENCODING, + SHAKA_ERROR_CODE.DASH_NO_COMMON_KEY_SYSTEM, + SHAKA_ERROR_CODE.DASH_MULTIPLE_KEY_IDS_NOT_SUPPORTED, + SHAKA_ERROR_CODE.DASH_CONFLICTING_KEY_IDS, + SHAKA_ERROR_CODE.INCONSISTENT_DRM_ACROSS_PERIODS, + SHAKA_ERROR_CODE.NO_WEB_CRYPTO_API, + SHAKA_ERROR_CODE.AES_128_INVALID_IV_LENGTH, + SHAKA_ERROR_CODE.AES_128_INVALID_KEY_LENGTH, + SHAKA_ERROR_CODE.DASH_CONFLICTING_AES_128, + SHAKA_ERROR_CODE.DASH_UNSUPPORTED_AES_128, + SHAKA_ERROR_CODE.DASH_MSE_ENCRYPTED_LEGACY_APPLE_MEDIA_KEYS_NOT_SUPPORTED, +]); + +export const SHAKA_MANIFEST_MEDIA_CODES = new Set([ + SHAKA_ERROR_CODE.DASH_UNSUPPORTED_CONTAINER, + SHAKA_ERROR_CODE.CONTENT_UNSUPPORTED_BY_BROWSER, +]); + +export const SHAKA_AMBIGUOUS_MANIFEST_CODES = new Set([ + SHAKA_ERROR_CODE.RESTRICTIONS_CANNOT_BE_MET, +]); + +export const SHAKA_MANIFEST_CODES = new Set( + Object.values(SHAKA_ERROR_CODE).filter( + (code) => code >= 4000 && code < 5000 + ) +); + +export const SHAKA_DRM_CODES = new Set( + Object.values(SHAKA_ERROR_CODE).filter( + (code) => code >= 6000 && code < 7000 + ) +); + +export const SHAKA_SEGMENT_STAGE_CODES = new Set([ + SHAKA_ERROR_CODE.SEGMENT_MISSING, + SHAKA_ERROR_CODE.BUFFER_READ_OUT_OF_BOUNDS, + SHAKA_ERROR_CODE.JS_INTEGER_OVERFLOW, + SHAKA_ERROR_CODE.EBML_OVERFLOW, + SHAKA_ERROR_CODE.EBML_BAD_FLOATING_POINT_SIZE, + SHAKA_ERROR_CODE.MP4_SIDX_WRONG_BOX_TYPE, + SHAKA_ERROR_CODE.MP4_SIDX_INVALID_TIMESCALE, + SHAKA_ERROR_CODE.MP4_SIDX_TYPE_NOT_SUPPORTED, + SHAKA_ERROR_CODE.WEBM_CUES_ELEMENT_MISSING, + SHAKA_ERROR_CODE.WEBM_EBML_HEADER_ELEMENT_MISSING, + SHAKA_ERROR_CODE.WEBM_SEGMENT_ELEMENT_MISSING, + SHAKA_ERROR_CODE.WEBM_INFO_ELEMENT_MISSING, + SHAKA_ERROR_CODE.WEBM_DURATION_ELEMENT_MISSING, + SHAKA_ERROR_CODE.WEBM_CUE_TRACK_POSITIONS_ELEMENT_MISSING, + SHAKA_ERROR_CODE.WEBM_CUE_TIME_ELEMENT_MISSING, +]); + +export const SHAKA_MEDIA_STAGE_CODES = new Set([ + SHAKA_ERROR_CODE.MEDIA_SOURCE_OPERATION_FAILED, + SHAKA_ERROR_CODE.MEDIA_SOURCE_OPERATION_THREW, + SHAKA_ERROR_CODE.VIDEO_ERROR, + SHAKA_ERROR_CODE.QUOTA_EXCEEDED_ERROR, + SHAKA_ERROR_CODE.TRANSMUXING_FAILED, + SHAKA_ERROR_CODE.CONTENT_TRANSFORMATION_FAILED, + SHAKA_ERROR_CODE.TRANSMUXING_NO_VIDEO_DATA, + SHAKA_ERROR_CODE.STREAMING_NOT_ALLOWED, + SHAKA_ERROR_CODE.BUFFER_WRITE_OUT_OF_BOUNDS, +]); + +export const SHAKA_LICENSE_STAGE_CODES = new Set([ + SHAKA_ERROR_CODE.FAILED_TO_GENERATE_LICENSE_REQUEST, + SHAKA_ERROR_CODE.LICENSE_REQUEST_FAILED, + SHAKA_ERROR_CODE.LICENSE_RESPONSE_REJECTED, + SHAKA_ERROR_CODE.NO_LICENSE_SERVER_GIVEN, + SHAKA_ERROR_CODE.EXPIRED, + SHAKA_ERROR_CODE.SERVER_CERTIFICATE_REQUEST_FAILED, +]); + +export const SHAKA_NESTED_HTTP_CODES = new Set([ + SHAKA_ERROR_CODE.LICENSE_REQUEST_FAILED, + SHAKA_ERROR_CODE.SERVER_CERTIFICATE_REQUEST_FAILED, +]); diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-module.types.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-module.types.ts index 50421098a..353d5d8c6 100644 --- a/libs/ui/playback/src/lib/shaka-engine/shaka-module.types.ts +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-module.types.ts @@ -26,8 +26,7 @@ export interface ShakaErrorLike { severity: number; category: number; code: number; - message?: string; - data?: unknown[]; + data?: readonly unknown[]; } export interface ShakaPlayerLike { @@ -66,10 +65,7 @@ export type ShakaModuleLoader = () => Promise; */ export const loadShakaModule: ShakaModuleLoader = async () => { const module = (await import('shaka-player')) as unknown as - | { default?: ShakaModuleLike } - | ShakaModuleLike; - return ( - ((module as { default?: ShakaModuleLike }).default ?? - module) as ShakaModuleLike - ); + { default?: ShakaModuleLike } | ShakaModuleLike; + return ((module as { default?: ShakaModuleLike }).default ?? + module) as ShakaModuleLike; }; diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-playback-evidence.util.spec.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-playback-evidence.util.spec.ts new file mode 100644 index 000000000..1a4e6f390 --- /dev/null +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-playback-evidence.util.spec.ts @@ -0,0 +1,328 @@ +import { execFileSync } from 'node:child_process'; +import * as shakaDiagnostics from './shaka-error-classifier'; + +interface ShakaErrorInput { + readonly severity?: unknown; + readonly category?: unknown; + readonly code?: unknown; + readonly message?: unknown; + readonly data?: readonly unknown[]; + readonly [key: string]: unknown; +} + +interface ExpectedShakaPlaybackEvidence { + readonly severity: string; + readonly category: string; + readonly engineCode: number | string; + readonly disposition: string; + readonly stage: string; + readonly failure: string; + readonly httpStatus?: number; +} + +interface InstalledShakaContract { + readonly version: string; + readonly severity: Readonly>; + readonly category: Readonly>; + readonly code: Readonly>; +} + +interface ShakaEvidenceExports { + readonly SHAKA_DIAGNOSTIC_VERSION?: string; + readonly SHAKA_ERROR_SEVERITY?: Readonly>; + readonly SHAKA_ERROR_CATEGORY?: Readonly>; + readonly SHAKA_ERROR_CODE?: Readonly>; + readonly createShakaPlaybackEvidence?: ( + error: ShakaErrorInput | null | undefined, + disposition: 'terminal' | 'recoverable' + ) => ExpectedShakaPlaybackEvidence; +} + +const exportsUnderTest = shakaDiagnostics as ShakaEvidenceExports; + +describe('Shaka playback evidence', () => { + it('matches the installed public Shaka 5.2.2 error contract', () => { + const installed = getInstalledShakaContract(); + + expect(exportsUnderTest.SHAKA_DIAGNOSTIC_VERSION).toBe( + installed.version + ); + expect(exportsUnderTest.SHAKA_ERROR_SEVERITY).toEqual( + installed.severity + ); + expect(exportsUnderTest.SHAKA_ERROR_CATEGORY).toEqual( + installed.category + ); + expect(exportsUnderTest.SHAKA_ERROR_CODE).toBeDefined(); + + for (const [name, value] of Object.entries( + exportsUnderTest.SHAKA_ERROR_CODE ?? {} + )) { + expect(installed.code[name]).toBe(value); + } + }); + + it('extracts only the documented status from a real bad-status shape', () => { + const secret = 'shaka-direct-secret'; + const evidence = createEvidence( + { + severity: 1, + category: 1, + code: 1001, + message: `request failed at https://user:${secret}@provider.example`, + data: [ + `https://provider.example/manifest.mpd?token=${secret}`, + 503, + `provider response body ${secret}`, + { Authorization: `Bearer ${secret}` }, + 0, + `https://provider.example/final?token=${secret}`, + ], + providerPayload: { key: secret }, + }, + 'terminal' + ); + const serialized = JSON.stringify(evidence); + + expect(evidence).toEqual({ + severity: 'recoverable', + category: 'network', + engineCode: 1001, + disposition: 'terminal', + stage: 'unknown', + failure: 'network', + httpStatus: 503, + }); + expect(serialized).not.toContain(secret); + expect(serialized).not.toContain('provider.example'); + expect(serialized).not.toContain('Authorization'); + expect(serialized).not.toContain('response body'); + expect(Object.keys(evidence).sort()).toEqual( + [ + 'category', + 'disposition', + 'engineCode', + 'failure', + 'httpStatus', + 'severity', + 'stage', + ].sort() + ); + }); + + it.each([ + { code: 6007, label: 'license request' }, + { code: 6017, label: 'server certificate request' }, + ])( + 'extracts a nested bad status from the documented $label layout', + ({ code }) => { + const secret = 'shaka-nested-secret'; + const evidence = createEvidence( + { + severity: 2, + category: 6, + code, + data: [ + { + severity: 1, + category: 1, + code: 1001, + data: [ + `https://provider.example/license?token=${secret}`, + 403, + `license response ${secret}`, + { Authorization: `Bearer ${secret}` }, + ], + }, + { + licenseRequest: secret, + sessionMetadata: secret, + }, + ], + }, + 'terminal' + ); + const serialized = JSON.stringify(evidence); + + expect(evidence).toEqual({ + severity: 'critical', + category: 'drm', + engineCode: code, + disposition: 'terminal', + stage: 'license', + failure: 'drm', + httpStatus: 403, + }); + expect(serialized).not.toContain(secret); + expect(serialized).not.toContain('provider.example'); + expect(serialized).not.toContain('Authorization'); + } + ); + + it.each([ + { status: 99, expected: undefined }, + { status: 100, expected: 100 }, + { status: 599, expected: 599 }, + { status: 600, expected: undefined }, + { status: 404.5, expected: undefined }, + { status: '503', expected: undefined }, + ])( + 'retains documented HTTP status $status only inside the protocol range', + ({ status, expected }) => { + const evidence = createEvidence( + { + severity: 1, + category: 1, + code: 1001, + data: ['https://provider.example/manifest.mpd', status], + }, + 'terminal' + ); + + expect(evidence.httpStatus).toBe(expected); + } + ); + + it('ignores status-like data on every undocumented layout', () => { + const evidence = createEvidence( + { + severity: 2, + category: 4, + code: 4001, + data: [503, 504, { status: 505 }], + }, + 'terminal' + ); + + expect(evidence.httpStatus).toBeUndefined(); + }); + + it.each([ + { + label: 'manifest parsing', + error: { severity: 2, category: 4, code: 4001 }, + stage: 'manifest', + failure: 'manifest', + }, + { + label: 'segment index parsing', + error: { severity: 2, category: 3, code: 3004 }, + stage: 'segment', + failure: 'media', + }, + { + label: 'media source operation', + error: { severity: 2, category: 3, code: 3014 }, + stage: 'media', + failure: 'media', + }, + { + label: 'license response', + error: { severity: 2, category: 6, code: 6008 }, + stage: 'license', + failure: 'drm', + }, + { + label: 'network without proven request stage', + error: { severity: 2, category: 1, code: 1002 }, + stage: 'unknown', + failure: 'network', + }, + ])( + 'maps exact $label evidence without reading messages', + ({ error, stage, failure }) => { + const evidence = createEvidence( + { + ...error, + message: + 'CORS codec manifest license segment provider guess', + }, + 'terminal' + ); + + expect(evidence.stage).toBe(stage); + expect(evidence.failure).toBe(failure); + } + ); + + it.each([ + { + label: 'ambiguous restrictions', + error: { severity: 2, category: 4, code: 4012 }, + stage: 'manifest', + }, + { + label: 'mismatched public pair', + error: { severity: 2, category: 1, code: 6001 }, + stage: 'unknown', + }, + { + label: 'mismatched media stage pair', + error: { severity: 2, category: 1, code: 3004 }, + stage: 'unknown', + }, + { + label: 'unknown provider code', + error: { severity: 3, category: 11, code: 123456 }, + stage: 'unknown', + }, + ])('keeps $label failure evidence unknown', ({ error, stage }) => { + const evidence = createEvidence(error, 'terminal'); + + expect(evidence.failure).toBe('unknown'); + expect(evidence.stage).toBe(stage); + }); + + it('validates severity, category, code, and explicit disposition independently', () => { + expect( + createEvidence( + { severity: 99, category: 99, code: 999999 }, + 'recoverable' + ) + ).toEqual({ + severity: 'unknown', + category: 'unknown', + engineCode: 'unknown', + disposition: 'recoverable', + stage: 'unknown', + failure: 'unknown', + }); + }); +}); + +function createEvidence( + error: ShakaErrorInput | null | undefined, + disposition: 'terminal' | 'recoverable' +): ExpectedShakaPlaybackEvidence { + const factory = exportsUnderTest.createShakaPlaybackEvidence; + + expect(factory).toBeDefined(); + if (!factory) { + throw new Error('createShakaPlaybackEvidence is not exported'); + } + return factory(error, disposition); +} + +function getInstalledShakaContract(): InstalledShakaContract { + const script = ` +global.self = globalThis; +import('shaka-player').then((module) => { + const shaka = module.default ?? module; + process.stdout.write(JSON.stringify({ + version: shaka.Player.version, + severity: shaka.util.Error.Severity, + category: shaka.util.Error.Category, + code: shaka.util.Error.Code, + })); +}).catch((error) => { + console.error(error); + process.exit(1); +}); +`; + return JSON.parse( + execFileSync(process.execPath, ['-e', script], { + cwd: process.cwd(), + encoding: 'utf8', + }) + ) as InstalledShakaContract; +} diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-playback-evidence.util.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-playback-evidence.util.ts new file mode 100644 index 000000000..479788259 --- /dev/null +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-playback-evidence.util.ts @@ -0,0 +1,235 @@ +import { + type ShakaPlaybackCategory as ShakaPlaybackCategoryValue, + type ShakaPlaybackDisposition, + type ShakaPlaybackEvidence, + type ShakaPlaybackFailure as ShakaPlaybackFailureValue, + type ShakaPlaybackStage as ShakaPlaybackStageValue, + ShakaPlaybackCategory, + ShakaPlaybackFailure, + ShakaPlaybackSeverity, + ShakaPlaybackStage, + ShakaPlaybackUnknownCode, +} from '../playback-diagnostics/playback-diagnostics.model'; +import { + SHAKA_ERROR_CATEGORY, + SHAKA_ERROR_CODE, + SHAKA_ERROR_SEVERITY, +} from './shaka-error-contract'; +import { + SHAKA_AMBIGUOUS_MANIFEST_CODES, + SHAKA_DRM_CODES, + SHAKA_LICENSE_STAGE_CODES, + SHAKA_MANIFEST_CODES, + SHAKA_MANIFEST_DRM_CODES, + SHAKA_MANIFEST_MEDIA_CODES, + SHAKA_MEDIA_CODES, + SHAKA_MEDIA_STAGE_CODES, + SHAKA_NESTED_HTTP_CODES, + SHAKA_NETWORK_CODES, + SHAKA_PUBLIC_CODES, + SHAKA_SEGMENT_STAGE_CODES, +} from './shaka-error-mapping'; +import type { ShakaErrorLike } from './shaka-module.types'; + +export function createShakaPlaybackEvidence( + error: Partial | null | undefined, + disposition: ShakaPlaybackDisposition +): ShakaPlaybackEvidence { + const category = getCategory(error?.category); + const engineCode = getEngineCode(error?.code); + const httpStatus = getHttpStatus(error); + const evidence: ShakaPlaybackEvidence = { + severity: getSeverity(error?.severity), + category, + engineCode, + disposition, + stage: getStage(category, engineCode), + failure: getFailure(category, engineCode), + }; + + return httpStatus === undefined ? evidence : { ...evidence, httpStatus }; +} + +function getSeverity(value: unknown): ShakaPlaybackEvidence['severity'] { + if (value === SHAKA_ERROR_SEVERITY.RECOVERABLE) { + return ShakaPlaybackSeverity.Recoverable; + } + if (value === SHAKA_ERROR_SEVERITY.CRITICAL) { + return ShakaPlaybackSeverity.Critical; + } + return ShakaPlaybackSeverity.Unknown; +} + +function getCategory(value: unknown): ShakaPlaybackCategoryValue { + switch (value) { + case SHAKA_ERROR_CATEGORY.NETWORK: + return ShakaPlaybackCategory.Network; + case SHAKA_ERROR_CATEGORY.TEXT: + return ShakaPlaybackCategory.Text; + case SHAKA_ERROR_CATEGORY.MEDIA: + return ShakaPlaybackCategory.Media; + case SHAKA_ERROR_CATEGORY.MANIFEST: + return ShakaPlaybackCategory.Manifest; + case SHAKA_ERROR_CATEGORY.STREAMING: + return ShakaPlaybackCategory.Streaming; + case SHAKA_ERROR_CATEGORY.DRM: + return ShakaPlaybackCategory.Drm; + case SHAKA_ERROR_CATEGORY.PLAYER: + return ShakaPlaybackCategory.Player; + case SHAKA_ERROR_CATEGORY.CAST: + return ShakaPlaybackCategory.Cast; + case SHAKA_ERROR_CATEGORY.STORAGE: + return ShakaPlaybackCategory.Storage; + case SHAKA_ERROR_CATEGORY.ADS: + return ShakaPlaybackCategory.Ads; + default: + return ShakaPlaybackCategory.Unknown; + } +} + +function getEngineCode(value: unknown): ShakaPlaybackEvidence['engineCode'] { + return typeof value === 'number' && + Number.isInteger(value) && + SHAKA_PUBLIC_CODES.has(value) + ? value + : ShakaPlaybackUnknownCode; +} + +function getFailure( + category: ShakaPlaybackCategoryValue, + engineCode: ShakaPlaybackEvidence['engineCode'] +): ShakaPlaybackFailureValue { + if ( + category === ShakaPlaybackCategory.Network && + typeof engineCode === 'number' && + SHAKA_NETWORK_CODES.has(engineCode) + ) { + return ShakaPlaybackFailure.Network; + } + if ( + category === ShakaPlaybackCategory.Drm && + typeof engineCode === 'number' && + SHAKA_DRM_CODES.has(engineCode) + ) { + return ShakaPlaybackFailure.Drm; + } + if ( + category === ShakaPlaybackCategory.Media && + typeof engineCode === 'number' && + SHAKA_MEDIA_CODES.has(engineCode) + ) { + return ShakaPlaybackFailure.Media; + } + if ( + category === ShakaPlaybackCategory.Manifest && + typeof engineCode === 'number' + ) { + if (SHAKA_MANIFEST_DRM_CODES.has(engineCode)) { + return ShakaPlaybackFailure.Drm; + } + if (SHAKA_MANIFEST_MEDIA_CODES.has(engineCode)) { + return ShakaPlaybackFailure.Media; + } + if ( + SHAKA_MANIFEST_CODES.has(engineCode) && + !SHAKA_AMBIGUOUS_MANIFEST_CODES.has(engineCode) + ) { + return ShakaPlaybackFailure.Manifest; + } + } + return ShakaPlaybackFailure.Unknown; +} + +function getStage( + category: ShakaPlaybackCategoryValue, + engineCode: ShakaPlaybackEvidence['engineCode'] +): ShakaPlaybackStageValue { + if (typeof engineCode !== 'number') { + return ShakaPlaybackStage.Unknown; + } + if ( + category === ShakaPlaybackCategory.Manifest && + SHAKA_MANIFEST_CODES.has(engineCode) + ) { + return ShakaPlaybackStage.Manifest; + } + if ( + (category === ShakaPlaybackCategory.Network && + engineCode === SHAKA_ERROR_CODE.SEGMENT_MISSING) || + (category === ShakaPlaybackCategory.Media && + SHAKA_SEGMENT_STAGE_CODES.has(engineCode)) + ) { + return ShakaPlaybackStage.Segment; + } + if ( + category === ShakaPlaybackCategory.Media && + SHAKA_MEDIA_STAGE_CODES.has(engineCode) + ) { + return ShakaPlaybackStage.Media; + } + if ( + category === ShakaPlaybackCategory.Drm && + SHAKA_LICENSE_STAGE_CODES.has(engineCode) + ) { + return ShakaPlaybackStage.License; + } + return ShakaPlaybackStage.Unknown; +} + +function getHttpStatus( + error: Partial | null | undefined +): number | undefined { + if ( + error?.category === SHAKA_ERROR_CATEGORY.NETWORK && + error.code === SHAKA_ERROR_CODE.BAD_HTTP_STATUS + ) { + return asHttpStatus(error.data?.[1]); + } + + if ( + error?.category !== SHAKA_ERROR_CATEGORY.DRM || + typeof error.code !== 'number' || + !SHAKA_NESTED_HTTP_CODES.has(error.code) + ) { + return undefined; + } + + const nested = asShakaErrorData(error.data?.[0]); + return nested?.category === SHAKA_ERROR_CATEGORY.NETWORK && + nested.code === SHAKA_ERROR_CODE.BAD_HTTP_STATUS + ? asHttpStatus(nested.data?.[1]) + : undefined; +} + +function asShakaErrorData(value: unknown): Partial | undefined { + if (!value || typeof value !== 'object') { + return undefined; + } + const candidate = value as { + readonly severity?: unknown; + readonly category?: unknown; + readonly code?: unknown; + readonly data?: unknown; + }; + return { + severity: + typeof candidate.severity === 'number' + ? candidate.severity + : undefined, + category: + typeof candidate.category === 'number' + ? candidate.category + : undefined, + code: typeof candidate.code === 'number' ? candidate.code : undefined, + data: Array.isArray(candidate.data) ? candidate.data : undefined, + }; +} + +function asHttpStatus(value: unknown): number | undefined { + return typeof value === 'number' && + Number.isInteger(value) && + value >= 100 && + value <= 599 + ? value + : undefined; +} diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.spec.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.spec.ts index a568e6f66..0e79ea33d 100644 --- a/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.spec.ts +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.spec.ts @@ -90,7 +90,8 @@ describe('ShakaVideoSession', () => { expect(issues).toHaveLength(1); expect(issues[0].code).toBe(PlaybackDiagnosticCode.DrmOrEncryption); expect(issues[0].source).toBe(PlaybackDiagnosticSource.Shaka); - expect(issues[0].details).toContain('com.widevine.alpha'); + expect(issues[0].details).toBe('Unsupported DRM license configuration'); + expect(JSON.stringify(issues[0])).not.toContain('com.widevine.alpha'); }); it('classifies critical shaka error events and ignores recoverable ones', async () => { @@ -107,6 +108,14 @@ describe('ShakaVideoSession', () => { player.dispatch('error', { severity: 2, category: 6, code: 6001 }); expect(issues).toHaveLength(1); expect(issues[0].code).toBe(PlaybackDiagnosticCode.DrmOrEncryption); + expect(issues[0].shaka).toEqual({ + severity: 'critical', + category: 'drm', + engineCode: 6001, + disposition: 'terminal', + stage: 'unknown', + failure: 'drm', + }); // Without KODIPROP config the DRM hint may still help externally. expect(issues[0].externalFallbackRecommended).toBe(true); // Critical errors end playback: the dead engine must be torn down. @@ -114,6 +123,25 @@ describe('ShakaVideoSession', () => { expect(session.getPlayer()).toBeNull(); }); + it('ignores error events whose public severity does not prove terminal state', async () => { + const environment = createFakeShakaEnvironment(); + const session = createSession(environment); + session.start(video, 'http://example.com/a.mpd'); + await flush(); + + const player = environment.instances[0]; + player.dispatch('error', { + severity: 99, + category: 1, + code: 1002, + message: 'CORS codec license provider guess', + }); + + expect(issues).toEqual([]); + expect(player.destroyCount).toBe(0); + expect(session.getPlayer()).toBe(player); + }); + it('drops the external-fallback hint for every failure on ClearKey channels', async () => { const environment = createFakeShakaEnvironment(); const session = createSession(environment); @@ -168,14 +196,158 @@ describe('ShakaVideoSession', () => { expect(issues).toHaveLength(1); expect(issues[0].code).toBe( - PlaybackDiagnosticCode.UnsupportedContainer + PlaybackDiagnosticCode.UnknownPlaybackError ); expect(issues[0].sourceUrl).toBe('http://example.com/bad.mpd'); + expect(issues[0].shaka).toEqual({ + severity: 'critical', + category: 'manifest', + engineCode: 4001, + disposition: 'terminal', + stage: 'manifest', + failure: 'manifest', + }); // The failed engine must not stay attached or exposed to controls. expect(environment.instances[0].destroyCount).toBe(1); expect(session.getPlayer()).toBeNull(); }); + it('treats a recoverable-severity load rejection as terminal lifecycle evidence', async () => { + const secret = 'load-http-secret'; + const environment = createFakeShakaEnvironment({ + onCreate: (player) => { + player.loadResult = Promise.reject({ + severity: 1, + category: 1, + code: 1001, + message: `https://provider.example/?token=${secret}`, + data: [ + `https://provider.example/manifest.mpd?token=${secret}`, + 503, + `provider body ${secret}`, + { Authorization: `Bearer ${secret}` }, + ], + }); + }, + }); + const session = createSession(environment); + session.start(video, 'http://example.com/retry-exhausted.mpd'); + await flush(); + + expect(issues).toHaveLength(1); + expect(issues[0]).toEqual( + expect.objectContaining({ + code: PlaybackDiagnosticCode.NetworkError, + httpStatus: 503, + shaka: { + severity: 'recoverable', + category: 'network', + engineCode: 1001, + disposition: 'terminal', + stage: 'unknown', + failure: 'network', + httpStatus: 503, + }, + }) + ); + const serialized = JSON.stringify(issues[0].shaka); + expect(serialized).not.toContain(secret); + expect(serialized).not.toContain('provider.example'); + expect(serialized).not.toContain('Authorization'); + expect(environment.instances[0].destroyCount).toBe(1); + expect(session.getPlayer()).toBeNull(); + }); + + it('emits a critical in-flight error once before teardown interrupts load', async () => { + const environment = createFakeShakaEnvironment({ + onCreate: (player) => { + player.stallNextLoad = true; + }, + }); + const session = createSession(environment); + session.start(video, 'http://example.com/stalled.mpd'); + await flush(); + + const player = environment.instances[0]; + player.dispatch('error', { + severity: 2, + category: 6, + code: 6008, + }); + await flush(); + + expect(issues).toHaveLength(1); + expect(issues[0].shaka).toEqual( + expect.objectContaining({ + engineCode: 6008, + disposition: 'terminal', + }) + ); + expect(player.destroyCount).toBe(1); + expect(session.getPlayer()).toBeNull(); + }); + + it('does not suppress an arbitrary load rejection that only reuses code 7000', async () => { + const environment = createFakeShakaEnvironment({ + onCreate: (player) => { + player.loadResult = Promise.reject({ + severity: 2, + category: 1, + code: 7000, + }); + }, + }); + const session = createSession(environment); + session.start(video, 'http://example.com/not-interrupted.mpd'); + await flush(); + + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe( + PlaybackDiagnosticCode.UnknownPlaybackError + ); + expect(issues[0].shaka).toEqual({ + severity: 'critical', + category: 'network', + engineCode: 7000, + disposition: 'terminal', + stage: 'unknown', + failure: 'unknown', + }); + }); + + it('drops arbitrary module-loader rejection messages', async () => { + const secret = 'module-loader-secret'; + const session = new ShakaVideoSession({ + player: InlinePlaybackPlayer.Html5, + emitPlaybackIssue: (issue) => issues.push(issue), + loadShaka: () => + Promise.reject( + new Error( + `https://user:${secret}@provider.example/shaka.js` + ) + ), + }); + + session.start(video, 'http://example.com/a.mpd'); + await flush(); + + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe( + PlaybackDiagnosticCode.UnknownPlaybackError + ); + expect(issues[0].details).toBeUndefined(); + expect(issues[0].shaka).toEqual({ + severity: 'unknown', + category: 'unknown', + engineCode: 'unknown', + disposition: 'terminal', + stage: 'unknown', + failure: 'unknown', + }); + expect(JSON.stringify(issues[0])).not.toContain(secret); + expect(JSON.stringify(issues[0])).not.toContain('provider.example'); + }); + it('recovers from a stalled load: stop() interrupts it and the next start proceeds', async () => { const environment = createFakeShakaEnvironment({ onCreate: (player, index) => { @@ -274,8 +446,16 @@ describe('ShakaVideoSession', () => { expect(environment.instances).toHaveLength(0); expect(issues).toHaveLength(1); expect(issues[0].code).toBe( - PlaybackDiagnosticCode.UnsupportedContainer + PlaybackDiagnosticCode.UnknownPlaybackError ); + expect(issues[0].shaka).toEqual({ + severity: 'unknown', + category: 'unknown', + engineCode: 'unknown', + disposition: 'terminal', + stage: 'unknown', + failure: 'unknown', + }); }); it('destroy tears down the engine and blocks later starts', async () => { diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts index 6a25c597d..b64386f1e 100644 --- a/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts @@ -4,18 +4,17 @@ import type { PlaybackDiagnostic, PlaybackSourceMetadata, } from '../playback-diagnostics/playback-diagnostics.model'; -import { PlaybackDiagnosticCode as DiagnosticCode } from '../playback-diagnostics/playback-diagnostics.model'; -import { PlaybackDiagnosticSource as DiagnosticSource } from '../playback-diagnostics/playback-diagnostics.model'; -import { - createPlaybackDiagnostic, - createPlaybackSourceMetadata, -} from '../playback-diagnostics/playback-diagnostics.util'; +import { ShakaPlaybackDisposition as ShakaDisposition } from '../playback-diagnostics/playback-diagnostics.model'; +import { createPlaybackSourceMetadata } from '../playback-diagnostics/playback-diagnostics.util'; import { asShakaError, classifyShakaPlaybackIssue, createUnsupportedDrmDiagnostic, - toErrorMessage, } from './shaka-error-classifier'; +import { + getShakaErrorEventDisposition, + isShakaLoadInterrupted, +} from './shaka-error-lifecycle'; import { ShakaTextTrackSuppression } from './shaka-text-track-suppression'; import { loadShakaModule, @@ -33,11 +32,6 @@ export interface ShakaVideoSessionConfig { } const DASH_MIME_TYPE = 'application/dash+xml'; -/** `shaka.util.Error.Severity.RECOVERABLE` — Shaka retries these itself. */ -const SHAKA_SEVERITY_RECOVERABLE = 1; -/** `shaka.util.Error.Code.LOAD_INTERRUPTED` — expected when a newer - * `load()`/`destroy()` supersedes an in-flight one. */ -const SHAKA_LOAD_INTERRUPTED = 7000; const PLAYER_REFRESH_EVENTS = [ 'loaded', @@ -139,14 +133,8 @@ export class ShakaVideoSession { let module: ShakaModuleLike; try { module = await this.loadModule(); - } catch (error: unknown) { - this.emitIfCurrent( - generation, - classifyShakaPlaybackIssue( - { message: toErrorMessage(error) }, - this.createMetadata(url) - ) - ); + } catch { + this.emitTerminalIfCurrent(generation, null, url); return; } @@ -155,15 +143,7 @@ export class ShakaVideoSession { } if (!module.Player.isBrowserSupported()) { - this.emitIfCurrent( - generation, - createPlaybackDiagnostic({ - code: DiagnosticCode.UnsupportedContainer, - source: DiagnosticSource.Shaka, - metadata: this.createMetadata(url), - details: 'Shaka Player is not supported in this browser', - }) - ); + this.emitTerminalIfCurrent(generation, null, url); return; } @@ -228,20 +208,21 @@ export class ShakaVideoSession { if ( this.isStale(generation) || this.player !== player || - shakaError?.code === SHAKA_LOAD_INTERRUPTED + isShakaLoadInterrupted(shakaError) ) { return; } - this.config.emitPlaybackIssue( - this.withoutUnusableDrmFallback( - classifyShakaPlaybackIssue( - shakaError ?? { message: toErrorMessage(error) }, - this.createMetadata(url) - ), - drmProvided - ) + const issue = classifyShakaPlaybackIssue( + shakaError, + this.createMetadata(url), + ShakaDisposition.Terminal ); + if (issue) { + this.config.emitPlaybackIssue( + this.withoutUnusableDrmFallback(issue, drmProvided) + ); + } // Never leave a non-functional engine attached to the media element // or exposed to the shared-controls bridge. this.beginPlayerTeardown(); @@ -274,20 +255,24 @@ export class ShakaVideoSession { return; } - const detail = (event as { detail?: Partial }) - .detail; - if (detail?.severity === SHAKA_SEVERITY_RECOVERABLE) { + const detail = (event as { detail?: unknown }).detail; + const shakaError = asShakaError(detail); + const disposition = getShakaErrorEventDisposition(shakaError); + if (!disposition) { + return; + } + + const issue = classifyShakaPlaybackIssue( + shakaError, + this.createMetadata(url), + disposition + ); + if (!issue) { return; } this.config.emitPlaybackIssue( - this.withoutUnusableDrmFallback( - classifyShakaPlaybackIssue( - detail, - this.createMetadata(url) - ), - drmProvided - ) + this.withoutUnusableDrmFallback(issue, drmProvided) ); // Critical errors end playback; never leave the dead engine // attached or exposed to the shared-controls bridge. @@ -371,15 +356,27 @@ export class ShakaVideoSession { return this.destroyed || generation !== this.generation; } - private emitIfCurrent( - generation: number, - issue: PlaybackDiagnostic - ): void { + private emitIfCurrent(generation: number, issue: PlaybackDiagnostic): void { if (!this.isStale(generation)) { this.config.emitPlaybackIssue(issue); } } + private emitTerminalIfCurrent( + generation: number, + error: Partial | null, + url: string + ): void { + const issue = classifyShakaPlaybackIssue( + error, + this.createMetadata(url), + ShakaDisposition.Terminal + ); + if (issue) { + this.emitIfCurrent(generation, issue); + } + } + private notifyRefresh(): void { for (const listener of [...this.refreshListeners]) { listener(); 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 aec1c0e20..08d536456 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,10 @@ export function getDiagnosticDetails( }, { labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_NATIVE_ERROR_MESSAGE', - value: issue.vhs ? '' : (issue.nativeErrorMessage ?? ''), + value: + issue.vhs || issue.shaka + ? '' + : (issue.nativeErrorMessage ?? ''), }, { labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_ERROR_DETAILS', @@ -92,6 +95,22 @@ export function getDiagnosticDetails( } function formatDiagnosticErrorDetails(issue: PlaybackDiagnostic): string { + if (issue.shaka) { + return [ + `stage=${issue.shaka.stage}`, + `failure=${issue.shaka.failure}`, + `severity=${issue.shaka.severity}`, + `category=${issue.shaka.category}`, + `code=${issue.shaka.engineCode}`, + `disposition=${issue.shaka.disposition}`, + issue.shaka.httpStatus === undefined + ? '' + : `HTTP ${issue.shaka.httpStatus}`, + ] + .filter((value) => value.length > 0) + .join(' · '); + } + if (issue.vhs) { return [ `stage=${issue.vhs.stage}`, 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 8b275e075..8d550fb13 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 @@ -371,6 +371,36 @@ describe('WebPlayerViewComponent', () => { expect(renderedDetails).not.toContain('response body'); }); + it('renders only sanitized structured Shaka evidence in technical details', () => { + const issue = createStructuredShakaDiagnostic(); + + 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: 'Shaka Player', + }, + { + labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_ERROR_DETAILS', + value: + 'stage=unknown · failure=network · ' + + 'severity=recoverable · category=network · ' + + 'code=1001 · disposition=terminal · HTTP 503', + }, + ]) + ); + expect(renderedDetails).not.toContain('shaka-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'; @@ -980,3 +1010,31 @@ function createStructuredVhsDiagnostic(): PlaybackDiagnostic { externalFallbackRecommended: false, }; } + +function createStructuredShakaDiagnostic(): PlaybackDiagnostic { + return { + code: PlaybackDiagnosticCode.NetworkError, + source: PlaybackDiagnosticSource.Shaka, + sourceUrl: + 'https://provider.example/live.mpd?token=shaka-render-secret', + container: 'mpd', + mimeType: 'application/dash+xml', + player: 'artplayer', + audioCodecs: [], + videoCodecs: [], + details: 'Authorization response body shaka-render-secret', + nativeErrorMessage: + 'https://provider.example/error?token=shaka-render-secret', + httpStatus: 503, + shaka: { + severity: 'recoverable', + category: 'network', + engineCode: 1001, + disposition: 'terminal', + stage: 'unknown', + failure: 'network', + httpStatus: 503, + }, + externalFallbackRecommended: false, + }; +}