From fc8fc4bdae115f1ca1018081ce14d795b3c82f5b Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 1 Aug 2026 12:00:44 +0200 Subject: [PATCH] fix(playback): structure mpegts error evidence --- .../mpegts-playback-evidence.model.ts | 64 +++++ .../mpegts-playback-evidence.util.spec.ts | 239 ++++++++++++++++++ .../mpegts-playback-evidence.util.ts | 167 ++++++++++++ .../playback-diagnostics.model.ts | 11 +- .../playback-diagnostics.util.spec.ts | 39 +-- .../playback-diagnostics.util.ts | 99 +++----- .../playback-error-patterns.util.ts | 72 ------ 7 files changed, 533 insertions(+), 158 deletions(-) create mode 100644 libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.model.ts create mode 100644 libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.util.spec.ts create mode 100644 libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.util.ts diff --git a/libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.model.ts b/libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.model.ts new file mode 100644 index 000000000..072334560 --- /dev/null +++ b/libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.model.ts @@ -0,0 +1,64 @@ +export const MpegTsPlaybackEngineType = { + Network: 'NetworkError', + Media: 'MediaError', + Other: 'OtherError', + Unknown: 'unknown', +} as const; + +export type MpegTsPlaybackEngineType = + (typeof MpegTsPlaybackEngineType)[keyof typeof MpegTsPlaybackEngineType]; + +export const MpegTsPlaybackEngineDetails = { + NetworkException: 'Exception', + HttpStatusCodeInvalid: 'HttpStatusCodeInvalid', + ConnectingTimeout: 'ConnectingTimeout', + UnrecoverableEarlyEof: 'UnrecoverableEarlyEof', + MediaMseError: 'MediaMSEError', + FormatError: 'FormatError', + FormatUnsupported: 'FormatUnsupported', + CodecUnsupported: 'CodecUnsupported', + Unknown: 'unknown', +} as const; + +export type MpegTsPlaybackEngineDetails = + (typeof MpegTsPlaybackEngineDetails)[keyof typeof MpegTsPlaybackEngineDetails]; + +export const MpegTsPlaybackDisposition = { + Terminal: 'terminal', +} as const; + +export type MpegTsPlaybackDisposition = + (typeof MpegTsPlaybackDisposition)[keyof typeof MpegTsPlaybackDisposition]; + +export const MpegTsPlaybackStage = { + Loader: 'loader', + Demux: 'demux', + MediaSource: 'media-source', + Unknown: 'unknown', +} as const; + +export type MpegTsPlaybackStage = + (typeof MpegTsPlaybackStage)[keyof typeof MpegTsPlaybackStage]; + +export const MpegTsPlaybackFailure = { + Http: 'http', + Timeout: 'timeout', + Network: 'network', + TruncatedStream: 'truncated-stream', + Format: 'format', + Codec: 'codec', + MediaSource: 'media-source', + Unknown: 'unknown', +} as const; + +export type MpegTsPlaybackFailure = + (typeof MpegTsPlaybackFailure)[keyof typeof MpegTsPlaybackFailure]; + +export interface MpegTsPlaybackEvidence { + readonly engineType: MpegTsPlaybackEngineType; + readonly engineDetails: MpegTsPlaybackEngineDetails; + readonly disposition: MpegTsPlaybackDisposition; + readonly stage: MpegTsPlaybackStage; + readonly failure: MpegTsPlaybackFailure; + readonly httpStatus?: number; +} diff --git a/libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.util.spec.ts b/libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.util.spec.ts new file mode 100644 index 000000000..583f4cd34 --- /dev/null +++ b/libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.util.spec.ts @@ -0,0 +1,239 @@ +import mpegts from 'mpegts.js'; +import { + InlinePlaybackPlayer, + PlaybackDiagnosticCode, + classifyMpegTsPlaybackIssue, + createPlaybackSourceMetadata, +} from './playback-diagnostics.util'; +import { + MpegTsPlaybackEngineDetails, + MpegTsPlaybackEngineType, +} from './mpegts-playback-evidence.model'; +import { + MPEGTS_DIAGNOSTIC_VERSION, + createMpegTsPlaybackEvidence, +} from './mpegts-playback-evidence.util'; + +const METADATA = createPlaybackSourceMetadata({ + url: 'https://example.test/live.ts', + mimeType: 'video/mp2t', + player: InlinePlaybackPlayer.VideoJs, +}); + +describe('mpegts.js playback evidence', () => { + it('locks the accepted public contract to mpegts.js 1.8.0', () => { + expect(mpegts.version).toBe(MPEGTS_DIAGNOSTIC_VERSION); + expect(mpegts.ErrorTypes).toEqual({ + NETWORK_ERROR: MpegTsPlaybackEngineType.Network, + MEDIA_ERROR: MpegTsPlaybackEngineType.Media, + OTHER_ERROR: MpegTsPlaybackEngineType.Other, + }); + expect(mpegts.ErrorDetails).toEqual({ + NETWORK_EXCEPTION: + MpegTsPlaybackEngineDetails.NetworkException, + NETWORK_STATUS_CODE_INVALID: + MpegTsPlaybackEngineDetails.HttpStatusCodeInvalid, + NETWORK_TIMEOUT: MpegTsPlaybackEngineDetails.ConnectingTimeout, + NETWORK_UNRECOVERABLE_EARLY_EOF: + MpegTsPlaybackEngineDetails.UnrecoverableEarlyEof, + MEDIA_MSE_ERROR: MpegTsPlaybackEngineDetails.MediaMseError, + MEDIA_FORMAT_ERROR: MpegTsPlaybackEngineDetails.FormatError, + MEDIA_FORMAT_UNSUPPORTED: + MpegTsPlaybackEngineDetails.FormatUnsupported, + MEDIA_CODEC_UNSUPPORTED: + MpegTsPlaybackEngineDetails.CodecUnsupported, + }); + }); + + it.each([ + [ + 'NetworkError', + 'HttpStatusCodeInvalid', + 'loader', + 'http', + ], + ['NetworkError', 'ConnectingTimeout', 'loader', 'timeout'], + ['NetworkError', 'Exception', 'loader', 'network'], + [ + 'NetworkError', + 'UnrecoverableEarlyEof', + 'loader', + 'truncated-stream', + ], + ['MediaError', 'FormatError', 'demux', 'format'], + ['MediaError', 'FormatUnsupported', 'demux', 'format'], + ['MediaError', 'CodecUnsupported', 'demux', 'codec'], + [ + 'MediaError', + 'MediaMSEError', + 'media-source', + 'media-source', + ], + ])( + 'maps %s + %s to %s/%s', + (engineType, engineDetails, stage, failure) => { + expect( + createMpegTsPlaybackEvidence( + engineType, + engineDetails, + undefined + ) + ).toEqual({ + engineType, + engineDetails, + disposition: 'terminal', + stage, + failure, + }); + } + ); + + it.each([ + ['OtherError', 'CodecUnsupported'], + ['NetworkError', 'CodecUnsupported'], + ['MediaError', 'ConnectingTimeout'], + ['networkerror', 'HttpStatusCodeInvalid'], + ['NetworkError', 'httpstatuscodeinvalid'], + [{ type: 'NetworkError' }, 'Exception'], + ['MediaError', { details: 'FormatError' }], + ])('keeps inconsistent or malformed %p + %p evidence unknown', (type, details) => { + const evidence = createMpegTsPlaybackEvidence(type, details, { + code: 503, + }); + + expect(evidence.stage).toBe('unknown'); + expect(evidence.failure).toBe('unknown'); + expect(evidence.httpStatus).toBeUndefined(); + }); + + it.each([399, 600, 404.5, '404', null, undefined])( + 'rejects invalid HTTP status %p', + (code) => { + const evidence = createMpegTsPlaybackEvidence( + 'NetworkError', + 'HttpStatusCodeInvalid', + { code } + ); + + expect(evidence.httpStatus).toBeUndefined(); + } + ); + + it('reads HTTP status only from the exact top-level public slot', () => { + expect( + createMpegTsPlaybackEvidence( + 'NetworkError', + 'HttpStatusCodeInvalid', + { code: 404 } + ).httpStatus + ).toBe(404); + expect( + createMpegTsPlaybackEvidence('NetworkError', 'Exception', { + code: 503, + }).httpStatus + ).toBeUndefined(); + expect( + createMpegTsPlaybackEvidence( + 'NetworkError', + 'HttpStatusCodeInvalid', + { response: { code: 503 } } + ).httpStatus + ).toBeUndefined(); + }); + + it('does not inspect or retain arbitrary engine information', () => { + const secret = 'mpegts-info-secret'; + const circularInfo: Record = { + code: 404, + msg: `Not Found ${secret}`, + url: `https://provider.example/error?token=${secret}`, + headers: { Authorization: secret }, + }; + circularInfo['self'] = circularInfo; + + const evidence = createMpegTsPlaybackEvidence( + 'NetworkError', + 'HttpStatusCodeInvalid', + circularInfo + ); + + expect(evidence.httpStatus).toBe(404); + expect(JSON.stringify(evidence)).not.toContain(secret); + expect(JSON.stringify(evidence)).not.toContain('provider.example'); + }); + + it.each([ + ['NetworkError', 'HttpStatusCodeInvalid', 'network-error', false], + ['NetworkError', 'ConnectingTimeout', 'network-error', false], + ['NetworkError', 'Exception', 'network-error', false], + [ + 'NetworkError', + 'UnrecoverableEarlyEof', + 'media-decode-error', + true, + ], + ['MediaError', 'FormatError', 'media-decode-error', true], + [ + 'MediaError', + 'FormatUnsupported', + 'unsupported-container', + true, + ], + [ + 'MediaError', + 'CodecUnsupported', + 'unsupported-codec', + true, + ], + ['MediaError', 'MediaMSEError', 'media-decode-error', true], + ['OtherError', 'Exception', 'unknown-playback-error', false], + ])( + 'classifies %s + %s as %s with fallback=%s', + (type, details, code, externalFallbackRecommended) => { + const issue = classifyMpegTsPlaybackIssue( + createMpegTsPlaybackEvidence(type, details, undefined), + METADATA + ); + + expect(issue.code).toBe(code); + expect(issue.externalFallbackRecommended).toBe( + externalFallbackRecommended + ); + expect(issue.details).toBeUndefined(); + } + ); + + it('preserves an exact HTTP failure without retaining provider details', () => { + const secret = 'mpegts-http-secret'; + const issue = classifyMpegTsPlaybackIssue( + createMpegTsPlaybackEvidence( + 'NetworkError', + 'HttpStatusCodeInvalid', + { + code: 404, + msg: `Not Found ${secret}`, + url: `https://provider.example/error?token=${secret}`, + } + ), + METADATA + ); + + expect(issue).toEqual( + expect.objectContaining({ + code: PlaybackDiagnosticCode.NetworkError, + httpStatus: 404, + mpegTs: expect.objectContaining({ + engineType: 'NetworkError', + engineDetails: 'HttpStatusCodeInvalid', + disposition: 'terminal', + stage: 'loader', + failure: 'http', + httpStatus: 404, + }), + externalFallbackRecommended: false, + }) + ); + expect(issue.details).toBeUndefined(); + expect(JSON.stringify(issue)).not.toContain(secret); + }); +}); diff --git a/libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.util.ts b/libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.util.ts new file mode 100644 index 000000000..f816fcfab --- /dev/null +++ b/libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.util.ts @@ -0,0 +1,167 @@ +import { + type MpegTsPlaybackEngineDetails as MpegTsPlaybackEngineDetailsValue, + type MpegTsPlaybackEngineType as MpegTsPlaybackEngineTypeValue, + type MpegTsPlaybackEvidence, + type MpegTsPlaybackFailure as MpegTsPlaybackFailureValue, + type MpegTsPlaybackStage as MpegTsPlaybackStageValue, + MpegTsPlaybackDisposition, + MpegTsPlaybackEngineDetails, + MpegTsPlaybackEngineType, + MpegTsPlaybackFailure, + MpegTsPlaybackStage, +} from './mpegts-playback-evidence.model'; + +export const MPEGTS_DIAGNOSTIC_VERSION = '1.8.0'; + +interface MpegTsPlaybackCause { + readonly stage: MpegTsPlaybackStageValue; + readonly failure: MpegTsPlaybackFailureValue; +} + +export function createMpegTsPlaybackEvidence( + type: unknown, + details: unknown, + info: unknown +): MpegTsPlaybackEvidence { + const engineType = normalizeEngineType(type); + const engineDetails = normalizeEngineDetails(details); + const cause = deriveMpegTsPlaybackCause(engineType, engineDetails); + const httpStatus = + engineType === MpegTsPlaybackEngineType.Network && + engineDetails === + MpegTsPlaybackEngineDetails.HttpStatusCodeInvalid + ? readHttpStatus(info) + : undefined; + + return { + engineType, + engineDetails, + disposition: MpegTsPlaybackDisposition.Terminal, + ...cause, + ...(httpStatus === undefined ? {} : { httpStatus }), + }; +} + +function normalizeEngineType(value: unknown): MpegTsPlaybackEngineTypeValue { + switch (value) { + case MpegTsPlaybackEngineType.Network: + case MpegTsPlaybackEngineType.Media: + case MpegTsPlaybackEngineType.Other: + return value; + default: + return MpegTsPlaybackEngineType.Unknown; + } +} + +function normalizeEngineDetails( + value: unknown +): MpegTsPlaybackEngineDetailsValue { + switch (value) { + case MpegTsPlaybackEngineDetails.NetworkException: + case MpegTsPlaybackEngineDetails.HttpStatusCodeInvalid: + case MpegTsPlaybackEngineDetails.ConnectingTimeout: + case MpegTsPlaybackEngineDetails.UnrecoverableEarlyEof: + case MpegTsPlaybackEngineDetails.MediaMseError: + case MpegTsPlaybackEngineDetails.FormatError: + case MpegTsPlaybackEngineDetails.FormatUnsupported: + case MpegTsPlaybackEngineDetails.CodecUnsupported: + return value; + default: + return MpegTsPlaybackEngineDetails.Unknown; + } +} + +function deriveMpegTsPlaybackCause( + type: MpegTsPlaybackEngineTypeValue, + details: MpegTsPlaybackEngineDetailsValue +): MpegTsPlaybackCause { + if (type === MpegTsPlaybackEngineType.Network) { + return deriveNetworkCause(details); + } + if (type === MpegTsPlaybackEngineType.Media) { + return deriveMediaCause(details); + } + return unknownCause(); +} + +function deriveNetworkCause( + details: MpegTsPlaybackEngineDetailsValue +): MpegTsPlaybackCause { + switch (details) { + case MpegTsPlaybackEngineDetails.HttpStatusCodeInvalid: + return cause( + MpegTsPlaybackStage.Loader, + MpegTsPlaybackFailure.Http + ); + case MpegTsPlaybackEngineDetails.ConnectingTimeout: + return cause( + MpegTsPlaybackStage.Loader, + MpegTsPlaybackFailure.Timeout + ); + case MpegTsPlaybackEngineDetails.NetworkException: + return cause( + MpegTsPlaybackStage.Loader, + MpegTsPlaybackFailure.Network + ); + case MpegTsPlaybackEngineDetails.UnrecoverableEarlyEof: + return cause( + MpegTsPlaybackStage.Loader, + MpegTsPlaybackFailure.TruncatedStream + ); + default: + return unknownCause(); + } +} + +function deriveMediaCause( + details: MpegTsPlaybackEngineDetailsValue +): MpegTsPlaybackCause { + switch (details) { + case MpegTsPlaybackEngineDetails.FormatError: + case MpegTsPlaybackEngineDetails.FormatUnsupported: + return cause( + MpegTsPlaybackStage.Demux, + MpegTsPlaybackFailure.Format + ); + case MpegTsPlaybackEngineDetails.CodecUnsupported: + return cause( + MpegTsPlaybackStage.Demux, + MpegTsPlaybackFailure.Codec + ); + case MpegTsPlaybackEngineDetails.MediaMseError: + return cause( + MpegTsPlaybackStage.MediaSource, + MpegTsPlaybackFailure.MediaSource + ); + default: + return unknownCause(); + } +} + +function cause( + stage: MpegTsPlaybackStageValue, + failure: MpegTsPlaybackFailureValue +): MpegTsPlaybackCause { + return { stage, failure }; +} + +function unknownCause(): MpegTsPlaybackCause { + return cause( + MpegTsPlaybackStage.Unknown, + MpegTsPlaybackFailure.Unknown + ); +} + +function readHttpStatus(info: unknown): number | undefined { + if (typeof info !== 'object' || info === null || Array.isArray(info)) { + return undefined; + } + + const code = (info as Readonly>)['code']; + return typeof code === 'number' && + Number.isInteger(code) && + code >= 400 && + code <= 599 + ? code + : undefined; +} 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 df9185068..31c3a94df 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 @@ -3,6 +3,9 @@ import type { ResolvedPortalPlayback, } from '@iptvnator/shared/interfaces'; import type { ErrorDetails, ErrorTypes } from 'hls.js'; +import type { MpegTsPlaybackEvidence } from './mpegts-playback-evidence.model'; + +export * from './mpegts-playback-evidence.model'; export const PlaybackDiagnosticCode = { UnsupportedContainer: 'unsupported-container', @@ -243,13 +246,6 @@ export interface ShakaPlaybackEvidence { readonly httpStatus?: number; } -export interface MpegTsPlaybackErrorInput { - readonly type?: string; - readonly details?: string; - readonly message?: string; - readonly info?: unknown; -} - export interface PlaybackDiagnostic { readonly code: PlaybackDiagnosticCode; readonly source: PlaybackDiagnosticSource; @@ -266,6 +262,7 @@ export interface PlaybackDiagnostic { readonly nativeErrorType?: string; readonly vhs?: VhsPlaybackEvidence; readonly hls?: HlsPlaybackEvidence; + readonly mpegTs?: MpegTsPlaybackEvidence; readonly shaka?: ShakaPlaybackEvidence; 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 535ad86ff..bda83e268 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 @@ -10,6 +10,7 @@ import { classifyHlsPlaybackIssue, classifyMpegTsPlaybackIssue, classifyNativePlaybackIssue, + createMpegTsPlaybackEvidence, createPlaybackSourceMetadata, getLikelyBrowserUnsupportedCodecLabels, getPlaybackMediaExtensionFromUrl, @@ -584,13 +585,12 @@ describe('playback diagnostics', () => { expect(issue.externalFallbackRecommended).toBe(false); }); - it('classifies mpegts browser fetch restrictions separately from generic network errors', () => { + it('does not guess mpegts browser access from exception messages', () => { + const secret = 'blocked by CORS policy'; const issue = classifyMpegTsPlaybackIssue( - { - type: 'NetworkError', - details: - 'Fetch blocked by access-control policy while loading segment', - }, + createMpegTsPlaybackEvidence('NetworkError', 'Exception', { + msg: secret, + }), createPlaybackSourceMetadata({ url: 'https://provider.example/live/channel.ts', mimeType: 'video/mp2t', @@ -598,17 +598,18 @@ describe('playback diagnostics', () => { }) ); - expect(issue.code).toBe('browser-access-error'); - expect(issue.externalFallbackRecommended).toBe(true); + expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError); + expect(issue.externalFallbackRecommended).toBe(false); + expect(JSON.stringify(issue)).not.toContain(secret); }); it('classifies mpegts early EOF failures as fallback-actionable media errors', () => { const issue = classifyMpegTsPlaybackIssue( - { - type: 'NetworkError', - details: 'UnrecoverableEarlyEof', - info: { msg: 'Fetch stream meet Early-EOF' }, - }, + createMpegTsPlaybackEvidence( + 'NetworkError', + 'UnrecoverableEarlyEof', + { msg: 'Fetch stream meet Early-EOF' } + ), createPlaybackSourceMetadata({ url: 'https://provider.example/movie/123.ts', mimeType: 'video/mp2t', @@ -618,15 +619,17 @@ describe('playback diagnostics', () => { expect(issue.code).toBe(PlaybackDiagnosticCode.MediaDecodeError); expect(issue.externalFallbackRecommended).toBe(true); - expect(issue.details).toContain('Early-EOF'); + expect(issue.mpegTs?.failure).toBe('truncated-stream'); + expect(issue.details).toBeUndefined(); }); it('classifies mpegts codec errors as unsupported codec fallbacks', () => { const issue = classifyMpegTsPlaybackIssue( - { - type: 'MediaError', - details: 'MediaCodecUnsupported', - }, + createMpegTsPlaybackEvidence( + 'MediaError', + 'CodecUnsupported', + undefined + ), createPlaybackSourceMetadata({ url: 'https://example.com/live/channel.ts', mimeType: 'video/mp2t', 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 6e62f1bc5..60bf2c312 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 @@ -1,6 +1,6 @@ import type { HlsPlaybackEvidence, - MpegTsPlaybackErrorInput, + MpegTsPlaybackEvidence, NativePlaybackErrorInput, PlaybackDiagnostic, PlaybackDiagnosticCode, @@ -12,23 +12,24 @@ import type { } from './playback-diagnostics.model'; import { PlaybackDiagnosticCode as DiagnosticCode } from './playback-diagnostics.model'; import { HlsPlaybackDisposition } from './playback-diagnostics.model'; +import { + MpegTsPlaybackEngineDetails, + MpegTsPlaybackFailure, +} from './mpegts-playback-evidence.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, - isEarlyEofFailure, - isNetworkFailure, - normalizeErrorDetails, -} from './playback-error-patterns.util'; +import { createMpegTsPlaybackEvidence } from './mpegts-playback-evidence.util'; +import { isBrowserAccessFailure } 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 { createMpegTsPlaybackEvidence } from './mpegts-playback-evidence.util'; export { createVhsPlaybackEvidence } from './vhs-playback-evidence.util'; export { createPlaybackSourceMetadata, @@ -163,65 +164,15 @@ export function classifyHlsPlaybackIssue( } export function classifyMpegTsPlaybackIssue( - error: MpegTsPlaybackErrorInput, + evidence: MpegTsPlaybackEvidence, metadata: PlaybackSourceMetadata ): PlaybackDiagnostic { - const details = normalizeErrorDetails(error); - const lowerDetails = details.toLowerCase(); - const lowerType = (error.type ?? '').toLowerCase(); - - if (isEarlyEofFailure(lowerDetails)) { - return createPlaybackDiagnostic({ - code: DiagnosticCode.MediaDecodeError, - source: DiagnosticSource.MpegTs, - metadata, - details, - }); - } - - if (isNetworkFailure(lowerType, lowerDetails)) { - return createPlaybackDiagnostic({ - code: isBrowserAccessFailure(lowerDetails) - ? DiagnosticCode.BrowserAccessError - : DiagnosticCode.NetworkError, - source: DiagnosticSource.MpegTs, - metadata, - details, - }); - } - - if (lowerDetails.includes('codec')) { - return createPlaybackDiagnostic({ - code: DiagnosticCode.UnsupportedCodec, - source: DiagnosticSource.MpegTs, - metadata, - details, - }); - } - - if (lowerDetails.includes('format') || lowerDetails.includes('mse')) { - return createPlaybackDiagnostic({ - code: DiagnosticCode.UnsupportedContainer, - source: DiagnosticSource.MpegTs, - metadata, - details, - }); - } - - if (lowerType.includes('media')) { - return createPlaybackDiagnostic({ - code: DiagnosticCode.MediaDecodeError, - source: DiagnosticSource.MpegTs, - metadata, - details, - }); - } - return createPlaybackDiagnostic({ - code: DiagnosticCode.UnknownPlaybackError, + code: getMpegTsPlaybackDiagnosticCode(evidence), source: DiagnosticSource.MpegTs, metadata, - details, + httpStatus: evidence.httpStatus, + mpegTs: evidence, }); } @@ -258,6 +209,7 @@ export function createPlaybackDiagnostic(options: { readonly nativeErrorType?: string; readonly vhs?: VhsPlaybackEvidence; readonly hls?: HlsPlaybackEvidence; + readonly mpegTs?: MpegTsPlaybackEvidence; readonly shaka?: ShakaPlaybackEvidence; /** Overrides the code-derived recommendation, e.g. when external players * are known to be unable to handle the stream either. */ @@ -274,6 +226,7 @@ export function createPlaybackDiagnostic(options: { nativeErrorType, vhs, hls, + mpegTs, shaka, } = options; @@ -293,6 +246,7 @@ export function createPlaybackDiagnostic(options: { nativeErrorType, vhs, hls, + mpegTs, shaka, externalFallbackRecommended: options.externalFallbackRecommended ?? @@ -300,6 +254,29 @@ export function createPlaybackDiagnostic(options: { }; } +function getMpegTsPlaybackDiagnosticCode( + evidence: MpegTsPlaybackEvidence +): PlaybackDiagnosticCode { + switch (evidence.failure) { + case MpegTsPlaybackFailure.Http: + case MpegTsPlaybackFailure.Timeout: + case MpegTsPlaybackFailure.Network: + return DiagnosticCode.NetworkError; + case MpegTsPlaybackFailure.TruncatedStream: + case MpegTsPlaybackFailure.MediaSource: + return DiagnosticCode.MediaDecodeError; + case MpegTsPlaybackFailure.Codec: + return DiagnosticCode.UnsupportedCodec; + case MpegTsPlaybackFailure.Format: + return evidence.engineDetails === + MpegTsPlaybackEngineDetails.FormatUnsupported + ? DiagnosticCode.UnsupportedContainer + : DiagnosticCode.MediaDecodeError; + default: + return DiagnosticCode.UnknownPlaybackError; + } +} + function getVhsPlaybackDiagnosticCode( evidence: VhsPlaybackEvidence, metadata: PlaybackSourceMetadata diff --git a/libs/ui/playback/src/lib/playback-diagnostics/playback-error-patterns.util.ts b/libs/ui/playback/src/lib/playback-diagnostics/playback-error-patterns.util.ts index 996268692..cd2428fd4 100644 --- a/libs/ui/playback/src/lib/playback-diagnostics/playback-error-patterns.util.ts +++ b/libs/ui/playback/src/lib/playback-diagnostics/playback-error-patterns.util.ts @@ -1,25 +1,3 @@ -import type { MpegTsPlaybackErrorInput } from './playback-diagnostics.model'; - -export function normalizeErrorDetails( - error: MpegTsPlaybackErrorInput -): string { - const info = normalizeErrorPayload(error.info); - - return [error.details, error.message, info] - .filter((part): part is string => Boolean(part)) - .join(' '); -} - -export function isNetworkFailure(type: string, details: string): boolean { - return ( - type.includes('network') || - details.includes('network') || - details.includes('loaderror') || - details.includes('timeout') || - details.includes('status') - ); -} - export function isBrowserAccessFailure(details: string): boolean { return ( details.includes('cors') || @@ -38,53 +16,3 @@ export function isBrowserAccessFailure(details: string): boolean { details.includes('err_cleartext') ); } - -export function isEarlyEofFailure(details: string): boolean { - const compactDetails = details.replace(/[^a-z0-9]/g, ''); - return compactDetails.includes('earlyeof'); -} - -export function isCodecFailure(details: string): boolean { - return ( - details.includes('codec') || - details.includes('incompatiblecodecs') || - details.includes('addcodec') - ); -} - -export function isDrmOrEncryptionFailure(details: string): boolean { - return ( - details.includes('decrypt') || - details.includes('keysystem') || - details.includes('keyload') || - details.includes('license') || - details.includes('drm') - ); -} - -function normalizeErrorPayload(payload: unknown): string { - if (!payload) { - return ''; - } - - if (typeof payload === 'string') { - return payload; - } - - if (payload instanceof Error) { - const extraDetails = stringifyUnknown(payload); - return [payload.message, extraDetails === '{}' ? '' : extraDetails] - .filter(Boolean) - .join(' '); - } - - return stringifyUnknown(payload); -} - -function stringifyUnknown(value: unknown): string { - try { - return JSON.stringify(value) || ''; - } catch { - return String(value); - } -}