diff --git a/libs/ui/playback/src/lib/art-player/art-player-source-session.spec-fixtures.ts b/libs/ui/playback/src/lib/art-player/art-player-source-session.spec-fixtures.ts index cbc8b0ce5..4d97dec50 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-source-session.spec-fixtures.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-source-session.spec-fixtures.ts @@ -96,8 +96,14 @@ export class MockMpegTsPlayer { export const createMpegTsPlayer = jest.fn(() => new MockMpegTsPlayer()); +const actualHlsModule = + jest.requireActual('hls.js'); + jest.unstable_mockModule('hls.js', () => ({ + ...actualHlsModule, default: MockHls, + ErrorDetails: actualHlsModule.ErrorDetails, + ErrorTypes: actualHlsModule.ErrorTypes, })); jest.unstable_mockModule('mpegts.js', () => ({ diff --git a/libs/ui/playback/src/lib/art-player/art-player-source-session.spec.ts b/libs/ui/playback/src/lib/art-player/art-player-source-session.spec.ts index 05b7f031f..3533dfd45 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-source-session.spec.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-source-session.spec.ts @@ -206,6 +206,56 @@ describe('ArtPlayerSourceSession', () => { ]); }); + it('reports only structured evidence for a fatal HLS manifest HTTP failure', () => { + const emitted: PlaybackDiagnostic[] = []; + const { session, player, video } = createSession({ + sharedControls: true, + emitPlaybackIssue: (issue) => emitted.push(issue), + }); + const secret = 'art-hls-secret-sentinel'; + session.attach(player); + session.customType['m3u8']?.( + video, + 'https://example.test/live.m3u8', + player + ); + + hlsInstances[0].emit(MockHls.Events.ERROR, null, { + type: 'networkError', + details: 'manifestLoadError', + fatal: true, + error: new Error(`provider message ${secret}`), + reason: `provider reason ${secret}`, + response: { + code: 503, + url: `https://provider.example/error?token=${secret}`, + text: secret, + data: { body: secret }, + }, + context: { + url: `https://provider.example/context?token=${secret}`, + headers: { Authorization: `Bearer ${secret}` }, + }, + }); + + expect(emitted).toEqual([ + expect.objectContaining({ + code: 'network-error', + source: 'hls', + httpStatus: 503, + hls: { + engineType: 'networkError', + engineDetails: 'manifestLoadError', + disposition: 'fatal', + stage: 'manifest', + failure: 'http', + httpStatus: 503, + }, + }), + ]); + expect(JSON.stringify(emitted[0].hls)).not.toContain(secret); + }); + it('ignores a delayed customType callback after its player was destroyed', () => { const { session, player, video } = createSession({ sharedControls: true, diff --git a/libs/ui/playback/src/lib/art-player/art-player-source-session.ts b/libs/ui/playback/src/lib/art-player/art-player-source-session.ts index eb6cedb3a..b5cda67c8 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-source-session.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-source-session.ts @@ -9,6 +9,7 @@ import { classifyHlsPlaybackIssue, classifyMpegTsPlaybackIssue, classifyUnsupportedHlsManifestCodecs, + createHlsPlaybackEvidence, createPlaybackSourceMetadata, } from '../playback-diagnostics/playback-diagnostics.util'; import type { WebVideoControlsAdapter } from '../player-controls'; @@ -312,22 +313,15 @@ export class ArtPlayerSourceSession { } private handleHlsError(url: string, data: ErrorData): void { - if (!data.fatal) { + const issue = classifyHlsPlaybackIssue( + createHlsPlaybackEvidence(data), + this.createSourceMetadata(url, 'application/x-mpegURL') + ); + if (!issue) { return; } - this.config.emitPlaybackIssue( - classifyHlsPlaybackIssue( - { - type: data.type, - details: data.details, - fatal: data.fatal, - message: data.error?.message, - error: data.error, - }, - this.createSourceMetadata(url, 'application/x-mpegURL') - ) - ); + this.config.emitPlaybackIssue(issue); } private createSourceMetadata( diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.shared-controls.spec.ts b/libs/ui/playback/src/lib/art-player/art-player.component.shared-controls.spec.ts index e5da6c9f9..a780ba53e 100644 --- a/libs/ui/playback/src/lib/art-player/art-player.component.shared-controls.spec.ts +++ b/libs/ui/playback/src/lib/art-player/art-player.component.shared-controls.spec.ts @@ -75,12 +75,18 @@ class MockHls { static isSupported = jest.fn(() => true); } +const actualHlsModule = + jest.requireActual('hls.js'); + jest.unstable_mockModule('artplayer', () => ({ default: MockArtplayer, })); jest.unstable_mockModule('hls.js', () => ({ + ...actualHlsModule, default: MockHls, + ErrorDetails: actualHlsModule.ErrorDetails, + ErrorTypes: actualHlsModule.ErrorTypes, })); jest.unstable_mockModule('mpegts.js', () => ({ diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts b/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts index a122c07c6..8ad9a83b7 100644 --- a/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts +++ b/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts @@ -14,12 +14,18 @@ import { resetArtPlayerSpecFixtures, } from './art-player.component.spec-fixtures'; +const actualHlsModule = + jest.requireActual('hls.js'); + jest.unstable_mockModule('artplayer', () => ({ default: MockArtplayer, })); jest.unstable_mockModule('hls.js', () => ({ + ...actualHlsModule, default: MockHls, + ErrorDetails: actualHlsModule.ErrorDetails, + ErrorTypes: actualHlsModule.ErrorTypes, })); jest.unstable_mockModule('mpegts.js', () => ({ diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player-diagnostics.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player-diagnostics.ts index 4c5284892..3d4ffa8bd 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player-diagnostics.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player-diagnostics.ts @@ -6,6 +6,7 @@ import { classifyHlsPlaybackIssue, classifyMpegTsPlaybackIssue, classifyUnsupportedHlsManifestCodecs, + createHlsPlaybackEvidence, createPlaybackSourceMetadata, } from '../playback-diagnostics/playback-diagnostics.util'; @@ -55,22 +56,15 @@ export function emitFatalHlsPlaybackError( data: ErrorData, emitPlaybackIssue: (issue: PlaybackDiagnostic) => void ): void { - if (!data.fatal) { + const issue = classifyHlsPlaybackIssue( + createHlsPlaybackEvidence(data), + createHtml5SourceMetadata(url, 'application/x-mpegURL') + ); + if (!issue) { return; } - emitPlaybackIssue( - classifyHlsPlaybackIssue( - { - type: data.type, - details: data.details, - fatal: data.fatal, - message: data.error?.message, - error: data.error, - }, - createHtml5SourceMetadata(url, 'application/x-mpegURL') - ) - ); + emitPlaybackIssue(issue); } export function emitMpegTsPlaybackError( diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.spec-fixtures.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.spec-fixtures.ts index ade52f86e..65bce6152 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.spec-fixtures.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.spec-fixtures.ts @@ -111,7 +111,15 @@ export class MockMpegTsPlayer { export const mpegTsCreatePlayer = jest.fn(() => new MockMpegTsPlayer()); export const mpegTsIsSupported = jest.fn(() => false); -jest.unstable_mockModule('hls.js', () => ({ default: MockHls })); +const actualHlsModule = + jest.requireActual('hls.js'); + +jest.unstable_mockModule('hls.js', () => ({ + ...actualHlsModule, + default: MockHls, + ErrorDetails: actualHlsModule.ErrorDetails, + ErrorTypes: actualHlsModule.ErrorTypes, +})); jest.unstable_mockModule('mpegts.js', () => ({ default: { Events: { ERROR: 'error' }, diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts index 63304da58..91d8a8b75 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts @@ -4,6 +4,7 @@ import { By } from '@angular/platform-browser'; import { TranslateModule } from '@ngx-translate/core'; import { DataService } from '@iptvnator/services'; import { Channel } from '@iptvnator/shared/interfaces'; +import { ErrorDetails, ErrorTypes, type ErrorData } from 'hls.js'; import { PlayerControlsComponent, WebVideoControlsAdapter, @@ -240,19 +241,11 @@ describe('HtmlVideoPlayerComponent', () => { ( component as unknown as { - handleHlsError: ( - url: string, - data: { - type: string; - details: string; - fatal: boolean; - error?: Error; - } - ) => void; + handleHlsError: (url: string, data: ErrorData) => void; } ).handleHlsError('https://example.com/live/playlist.m3u8', { - type: 'networkError', - details: 'fragLoadError', + type: ErrorTypes.NETWORK_ERROR, + details: ErrorDetails.FRAG_LOAD_ERROR, fatal: false, error: new Error('segment retry'), }); @@ -260,36 +253,51 @@ describe('HtmlVideoPlayerComponent', () => { expect(issues).toEqual([]); }); - it('keeps raw HLS error object context in emitted playback issue details', () => { - const issues: Array<{ details?: string }> = []; + it('emits only structured HLS evidence for a fatal manifest HTTP failure', () => { + const issues: Array<{ + readonly code?: string; + readonly httpStatus?: number; + readonly hls?: unknown; + }> = []; component.playbackIssue.subscribe((issue) => { if (issue) issues.push(issue); }); + const secret = 'html-hls-secret-sentinel'; ( component as unknown as { - handleHlsError: ( - url: string, - data: { - type: string; - details: string; - fatal: boolean; - error?: unknown; - } - ) => void; + handleHlsError: (url: string, data: ErrorData) => void; } ).handleHlsError('https://example.com/live/playlist.m3u8', { - type: 'networkError', - details: 'manifestLoadError', + type: ErrorTypes.NETWORK_ERROR, + details: ErrorDetails.MANIFEST_LOAD_ERROR, fatal: true, - error: { - context: 'xhr setup failed', - status: 0, + error: new Error(`provider message ${secret}`), + reason: `provider reason ${secret}`, + response: { + code: 404, + url: `https://provider.example/error?token=${secret}`, + text: secret, + data: { body: secret }, }, + networkDetails: { responseText: secret }, }); - expect(issues[0].details).toContain('xhr setup failed'); - expect(issues[0].details).toContain('"status":0'); + expect(issues[0]).toEqual( + expect.objectContaining({ + code: 'network-error', + httpStatus: 404, + hls: { + engineType: ErrorTypes.NETWORK_ERROR, + engineDetails: ErrorDetails.MANIFEST_LOAD_ERROR, + disposition: 'fatal', + stage: 'manifest', + failure: 'http', + httpStatus: 404, + }, + }) + ); + expect(JSON.stringify(issues[0].hls)).not.toContain(secret); }); it('emits playbackEnded exactly once for a native ended event and not during reload or destroy', () => { diff --git a/libs/ui/playback/src/lib/playback-diagnostics/hls-playback-evidence.util.spec.ts b/libs/ui/playback/src/lib/playback-diagnostics/hls-playback-evidence.util.spec.ts new file mode 100644 index 000000000..b53899c1f --- /dev/null +++ b/libs/ui/playback/src/lib/playback-diagnostics/hls-playback-evidence.util.spec.ts @@ -0,0 +1,213 @@ +import { + ErrorDetails, + ErrorTypes, + type ErrorData, + type LoaderResponse, +} from 'hls.js'; +import * as diagnostics from './playback-diagnostics.util'; + +interface ExpectedHlsPlaybackEvidence { + readonly engineType: string; + readonly engineDetails: string; + readonly disposition: string; + readonly stage: string; + readonly failure: string; + readonly httpStatus?: number; +} + +type EvidenceFactory = (data: ErrorData) => ExpectedHlsPlaybackEvidence; + +describe('HLS playback evidence', () => { + it('extracts a fatal manifest HTTP failure from the hls.js response shape', () => { + const evidence = createEvidence( + createErrorData({ + type: ErrorTypes.NETWORK_ERROR, + details: ErrorDetails.MANIFEST_LOAD_ERROR, + response: createResponse(404), + }) + ); + + expect(evidence).toEqual({ + engineType: ErrorTypes.NETWORK_ERROR, + engineDetails: ErrorDetails.MANIFEST_LOAD_ERROR, + disposition: 'fatal', + stage: 'manifest', + failure: 'http', + httpStatus: 404, + }); + }); + + it('extracts exact timeout and level-stage evidence', () => { + const evidence = createEvidence( + createErrorData({ + type: ErrorTypes.NETWORK_ERROR, + details: ErrorDetails.LEVEL_LOAD_TIMEOUT, + }) + ); + + expect(evidence).toEqual({ + engineType: ErrorTypes.NETWORK_ERROR, + engineDetails: ErrorDetails.LEVEL_LOAD_TIMEOUT, + disposition: 'fatal', + stage: 'level', + failure: 'timeout', + }); + }); + + it('marks a non-fatal fragment load event as recoverable network evidence', () => { + const evidence = createEvidence( + createErrorData({ + type: ErrorTypes.NETWORK_ERROR, + details: ErrorDetails.FRAG_LOAD_ERROR, + fatal: false, + }) + ); + + expect(evidence).toEqual({ + engineType: ErrorTypes.NETWORK_ERROR, + engineDetails: ErrorDetails.FRAG_LOAD_ERROR, + disposition: 'recoverable', + stage: 'segment', + failure: 'network', + }); + }); + + it.each([ + { + details: ErrorDetails.MANIFEST_PARSING_ERROR, + stage: 'manifest', + }, + { details: ErrorDetails.AUDIO_TRACK_LOAD_ERROR, stage: 'level' }, + { details: ErrorDetails.FRAG_DECRYPT_ERROR, stage: 'segment' }, + { details: ErrorDetails.KEY_LOAD_ERROR, stage: 'key' }, + { details: ErrorDetails.BUFFER_ADD_CODEC_ERROR, stage: 'media' }, + { details: ErrorDetails.INTERNAL_EXCEPTION, stage: 'unknown' }, + ])('maps $details to the exact $stage stage', ({ details, stage }) => { + expect(createEvidence(createErrorData({ details })).stage).toBe(stage); + }); + + it('keeps a successful response status as evidence without calling it an HTTP failure', () => { + const evidence = createEvidence( + createErrorData({ + type: ErrorTypes.NETWORK_ERROR, + details: ErrorDetails.MANIFEST_PARSING_ERROR, + response: createResponse(200), + }) + ); + + expect(evidence.httpStatus).toBe(200); + expect(evidence.failure).toBe('network'); + }); + + it.each([ + { status: 99, expected: undefined }, + { status: 100, expected: 100 }, + { status: 599, expected: 599 }, + { status: 600, expected: undefined }, + { status: 404.5, expected: undefined }, + ])( + 'retains HTTP status $status only inside the protocol range', + ({ status, expected }) => { + const evidence = createEvidence( + createErrorData({ + response: createResponse(status), + }) + ); + + expect(evidence.httpStatus).toBe(expected); + } + ); + + it('uses unknown for unrecognized runtime engine identifiers', () => { + const evidence = createEvidence( + createErrorData({ + type: 'providerNetworkCodecError' as ErrorTypes, + details: 'providerStatusCodecFailure' as ErrorDetails, + }) + ); + + expect(evidence).toEqual({ + engineType: 'unknown', + engineDetails: ErrorDetails.UNKNOWN, + disposition: 'fatal', + stage: 'unknown', + failure: 'unknown', + }); + }); + + it('drops URLs, headers, bodies, credentials, and arbitrary provider payloads', () => { + const secret = 'evidence-secret-sentinel'; + const evidence = createEvidence( + createErrorData({ + type: ErrorTypes.NETWORK_ERROR, + details: ErrorDetails.MANIFEST_LOAD_ERROR, + error: new Error(`provider message ${secret}`), + reason: `provider reason ${secret}`, + url: `https://user:${secret}@provider.example/manifest.m3u8`, + response: { + code: 503, + url: `https://provider.example/error?token=${secret}`, + text: `response text ${secret}`, + data: { body: secret }, + }, + context: { + url: `https://provider.example/context?token=${secret}`, + responseType: 'text', + headers: { Authorization: `Bearer ${secret}` }, + } as ErrorData['context'], + networkDetails: { + responseURL: `https://provider.example/xhr?token=${secret}`, + responseText: secret, + }, + }) + ); + const serialized = JSON.stringify(evidence); + + expect(evidence.httpStatus).toBe(503); + expect(serialized).not.toContain(secret); + expect(serialized).not.toContain('provider.example'); + expect(serialized).not.toContain('Authorization'); + expect(serialized).not.toContain('response text'); + expect(Object.keys(evidence).sort()).toEqual( + [ + 'disposition', + 'engineDetails', + 'engineType', + 'failure', + 'httpStatus', + 'stage', + ].sort() + ); + }); +}); + +function createEvidence(data: ErrorData): ExpectedHlsPlaybackEvidence { + const factory = ( + diagnostics as unknown as { + readonly createHlsPlaybackEvidence?: EvidenceFactory; + } + ).createHlsPlaybackEvidence; + + expect(factory).toBeDefined(); + if (!factory) { + throw new Error('createHlsPlaybackEvidence is not exported'); + } + return factory(data); +} + +function createErrorData(overrides: Partial = {}): ErrorData { + return { + type: ErrorTypes.OTHER_ERROR, + details: ErrorDetails.UNKNOWN, + error: new Error('provider payload must not survive'), + fatal: true, + ...overrides, + }; +} + +function createResponse(code: number): LoaderResponse { + return { + url: 'https://provider.example/response-must-not-survive', + code, + }; +} diff --git a/libs/ui/playback/src/lib/playback-diagnostics/hls-playback-evidence.util.ts b/libs/ui/playback/src/lib/playback-diagnostics/hls-playback-evidence.util.ts new file mode 100644 index 000000000..36ad0d732 --- /dev/null +++ b/libs/ui/playback/src/lib/playback-diagnostics/hls-playback-evidence.util.ts @@ -0,0 +1,234 @@ +import { + ErrorDetails, + ErrorTypes, + type ErrorData, +} from 'hls.js'; +import { + type HlsPlaybackEngineType, + type HlsPlaybackEvidence, + HlsPlaybackDisposition, + HlsPlaybackFailure, + HlsPlaybackStage, + HlsPlaybackUnknownEngineType, + type PlaybackDiagnosticCode, + PlaybackDiagnosticCode as DiagnosticCode, +} from './playback-diagnostics.model'; + +const ENGINE_TYPES = new Set(Object.values(ErrorTypes)); +const ENGINE_DETAILS = new Set(Object.values(ErrorDetails)); + +const MANIFEST_DETAILS = new Set([ + ErrorDetails.MANIFEST_LOAD_ERROR, + ErrorDetails.MANIFEST_LOAD_TIMEOUT, + ErrorDetails.MANIFEST_PARSING_ERROR, + ErrorDetails.MANIFEST_INCOMPATIBLE_CODECS_ERROR, +]); + +const LEVEL_DETAILS = new Set([ + ErrorDetails.LEVEL_EMPTY_ERROR, + ErrorDetails.LEVEL_LOAD_ERROR, + ErrorDetails.LEVEL_LOAD_TIMEOUT, + ErrorDetails.LEVEL_PARSING_ERROR, + ErrorDetails.LEVEL_SWITCH_ERROR, + ErrorDetails.AUDIO_TRACK_LOAD_ERROR, + ErrorDetails.AUDIO_TRACK_LOAD_TIMEOUT, + ErrorDetails.SUBTITLE_LOAD_ERROR, + ErrorDetails.SUBTITLE_TRACK_LOAD_TIMEOUT, +]); + +const SEGMENT_DETAILS = new Set([ + ErrorDetails.FRAG_LOAD_ERROR, + ErrorDetails.FRAG_LOAD_TIMEOUT, + ErrorDetails.FRAG_DECRYPT_ERROR, + ErrorDetails.FRAG_PARSING_ERROR, + ErrorDetails.FRAG_GAP, +]); + +const KEY_SYSTEM_DETAILS = new Set([ + ErrorDetails.KEY_SYSTEM_NO_KEYS, + ErrorDetails.KEY_SYSTEM_NO_ACCESS, + ErrorDetails.KEY_SYSTEM_NO_SESSION, + ErrorDetails.KEY_SYSTEM_NO_CONFIGURED_LICENSE, + ErrorDetails.KEY_SYSTEM_LICENSE_REQUEST_FAILED, + ErrorDetails.KEY_SYSTEM_SERVER_CERTIFICATE_REQUEST_FAILED, + ErrorDetails.KEY_SYSTEM_SERVER_CERTIFICATE_UPDATE_FAILED, + ErrorDetails.KEY_SYSTEM_SESSION_UPDATE_FAILED, + ErrorDetails.KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED, + ErrorDetails.KEY_SYSTEM_STATUS_INTERNAL_ERROR, + ErrorDetails.KEY_SYSTEM_DESTROY_MEDIA_KEYS_ERROR, + ErrorDetails.KEY_SYSTEM_DESTROY_CLOSE_SESSION_ERROR, + ErrorDetails.KEY_SYSTEM_DESTROY_REMOVE_SESSION_ERROR, +]); + +const KEY_DETAILS = new Set([ + ErrorDetails.KEY_LOAD_ERROR, + ErrorDetails.KEY_LOAD_TIMEOUT, + ...KEY_SYSTEM_DETAILS, +]); + +const MEDIA_DETAILS = new Set([ + ErrorDetails.REMUX_ALLOC_ERROR, + ErrorDetails.BUFFER_ADD_CODEC_ERROR, + ErrorDetails.BUFFER_INCOMPATIBLE_CODECS_ERROR, + ErrorDetails.BUFFER_APPEND_ERROR, + ErrorDetails.BUFFER_APPENDING_ERROR, + ErrorDetails.BUFFER_STALLED_ERROR, + ErrorDetails.BUFFER_FULL_ERROR, + ErrorDetails.BUFFER_SEEK_OVER_HOLE, + ErrorDetails.BUFFER_NUDGE_ON_STALL, + ErrorDetails.ATTACH_MEDIA_ERROR, +]); + +const TIMEOUT_DETAILS = new Set([ + ErrorDetails.MANIFEST_LOAD_TIMEOUT, + ErrorDetails.LEVEL_LOAD_TIMEOUT, + ErrorDetails.AUDIO_TRACK_LOAD_TIMEOUT, + ErrorDetails.SUBTITLE_TRACK_LOAD_TIMEOUT, + ErrorDetails.FRAG_LOAD_TIMEOUT, + ErrorDetails.KEY_LOAD_TIMEOUT, + ErrorDetails.ASSET_LIST_LOAD_TIMEOUT, +]); + +const NETWORK_DETAILS = new Set([ + ErrorDetails.MANIFEST_LOAD_ERROR, + ErrorDetails.LEVEL_LOAD_ERROR, + ErrorDetails.AUDIO_TRACK_LOAD_ERROR, + ErrorDetails.SUBTITLE_LOAD_ERROR, + ErrorDetails.FRAG_LOAD_ERROR, + ErrorDetails.KEY_LOAD_ERROR, + ErrorDetails.ASSET_LIST_LOAD_ERROR, +]); + +const CODEC_DETAILS = new Set([ + ErrorDetails.MANIFEST_INCOMPATIBLE_CODECS_ERROR, + ErrorDetails.BUFFER_ADD_CODEC_ERROR, + ErrorDetails.BUFFER_INCOMPATIBLE_CODECS_ERROR, +]); + +const DRM_DETAILS = new Set([ + ErrorDetails.FRAG_DECRYPT_ERROR, + ...KEY_SYSTEM_DETAILS, +]); + +export function createHlsPlaybackEvidence( + data: ErrorData +): HlsPlaybackEvidence { + const engineType = getEngineType(data.type); + const engineDetails = getEngineDetails(data.details); + const httpStatus = getHttpStatus(data.response?.code); + const evidence: HlsPlaybackEvidence = { + engineType, + engineDetails, + disposition: + data.fatal === true + ? HlsPlaybackDisposition.Fatal + : HlsPlaybackDisposition.Recoverable, + stage: getStage(engineDetails), + failure: getFailure(engineType, engineDetails, httpStatus), + }; + + return httpStatus === undefined + ? evidence + : { ...evidence, httpStatus }; +} + +export function getHlsPlaybackDiagnosticCode( + evidence: HlsPlaybackEvidence +): PlaybackDiagnosticCode { + if (CODEC_DETAILS.has(evidence.engineDetails)) { + return DiagnosticCode.UnsupportedCodec; + } + + if ( + evidence.engineType === ErrorTypes.KEY_SYSTEM_ERROR || + DRM_DETAILS.has(evidence.engineDetails) + ) { + return DiagnosticCode.DrmOrEncryption; + } + + if (evidence.failure === HlsPlaybackFailure.Access) { + return DiagnosticCode.BrowserAccessError; + } + + if ( + evidence.engineType === ErrorTypes.NETWORK_ERROR || + evidence.failure === HlsPlaybackFailure.Http || + evidence.failure === HlsPlaybackFailure.Timeout || + evidence.failure === HlsPlaybackFailure.Network + ) { + return DiagnosticCode.NetworkError; + } + + if ( + evidence.engineType === ErrorTypes.MEDIA_ERROR || + evidence.engineType === ErrorTypes.MUX_ERROR + ) { + return DiagnosticCode.MediaDecodeError; + } + + return DiagnosticCode.UnknownPlaybackError; +} + +function getEngineType(value: unknown): HlsPlaybackEngineType { + return typeof value === 'string' && ENGINE_TYPES.has(value) + ? (value as ErrorTypes) + : HlsPlaybackUnknownEngineType; +} + +function getEngineDetails(value: unknown): ErrorDetails { + return typeof value === 'string' && ENGINE_DETAILS.has(value) + ? (value as ErrorDetails) + : ErrorDetails.UNKNOWN; +} + +function getHttpStatus(value: unknown): number | undefined { + return typeof value === 'number' && + Number.isInteger(value) && + value >= 100 && + value <= 599 + ? value + : undefined; +} + +function getStage(details: ErrorDetails): HlsPlaybackEvidence['stage'] { + if (MANIFEST_DETAILS.has(details)) { + return HlsPlaybackStage.Manifest; + } + if (LEVEL_DETAILS.has(details)) { + return HlsPlaybackStage.Level; + } + if (SEGMENT_DETAILS.has(details)) { + return HlsPlaybackStage.Segment; + } + if (KEY_DETAILS.has(details)) { + return HlsPlaybackStage.Key; + } + if (MEDIA_DETAILS.has(details)) { + return HlsPlaybackStage.Media; + } + return HlsPlaybackStage.Unknown; +} + +function getFailure( + engineType: HlsPlaybackEngineType, + details: ErrorDetails, + httpStatus: number | undefined +): HlsPlaybackEvidence['failure'] { + if (TIMEOUT_DETAILS.has(details)) { + return HlsPlaybackFailure.Timeout; + } + if ( + httpStatus !== undefined && + httpStatus >= 400 && + httpStatus <= 599 + ) { + return HlsPlaybackFailure.Http; + } + if ( + engineType === ErrorTypes.NETWORK_ERROR || + NETWORK_DETAILS.has(details) + ) { + return HlsPlaybackFailure.Network; + } + return HlsPlaybackFailure.Unknown; +} 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 01a75c62a..f1136c352 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 @@ -2,6 +2,7 @@ import type { ExternalPlayerName, ResolvedPortalPlayback, } from '@iptvnator/shared/interfaces'; +import type { ErrorDetails, ErrorTypes } from 'hls.js'; export const PlaybackDiagnosticCode = { UnsupportedContainer: 'unsupported-container', @@ -65,14 +66,50 @@ export interface NativePlaybackErrorInput { readonly metadata?: NativePlaybackErrorMetadataInput; } -export interface HlsPlaybackErrorInput { - readonly type?: string; - readonly details?: string; - readonly fatal?: boolean; - readonly message?: string; - readonly error?: unknown; - readonly audioCodecs?: readonly string[]; - readonly videoCodecs?: readonly string[]; +export const HlsPlaybackDisposition = { + Fatal: 'fatal', + Recoverable: 'recoverable', +} as const; + +export type HlsPlaybackDisposition = + (typeof HlsPlaybackDisposition)[keyof typeof HlsPlaybackDisposition]; + +export const HlsPlaybackStage = { + Manifest: 'manifest', + Level: 'level', + Segment: 'segment', + Key: 'key', + Media: 'media', + Unknown: 'unknown', +} as const; + +export type HlsPlaybackStage = + (typeof HlsPlaybackStage)[keyof typeof HlsPlaybackStage]; + +export const HlsPlaybackFailure = { + Http: 'http', + Timeout: 'timeout', + Network: 'network', + Access: 'access', + Unknown: 'unknown', +} as const; + +export type HlsPlaybackFailure = + (typeof HlsPlaybackFailure)[keyof typeof HlsPlaybackFailure]; + +export const HlsPlaybackUnknownEngineType = 'unknown' as const; + +export type HlsPlaybackEngineType = + | ErrorTypes + | typeof HlsPlaybackUnknownEngineType; + +export interface HlsPlaybackEvidence { + readonly engineType: HlsPlaybackEngineType; + readonly engineDetails: ErrorDetails; + readonly disposition: HlsPlaybackDisposition; + readonly stage: HlsPlaybackStage; + readonly failure: HlsPlaybackFailure; + readonly httpStatus?: number; } export interface MpegTsPlaybackErrorInput { @@ -96,6 +133,7 @@ export interface PlaybackDiagnostic { readonly nativeErrorMessage?: string; readonly httpStatus?: number; readonly nativeErrorType?: string; + 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 5fcaba8b6..d8a8a2592 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,3 +1,4 @@ +import { ErrorDetails, ErrorTypes } from 'hls.js'; import { PlaybackDiagnosticCode, classifyHlsPlaybackIssue, @@ -8,13 +9,32 @@ import { getPlaybackMediaExtensionFromUrl, } from './playback-diagnostics.util'; +interface StructuredHlsEvidenceInput { + readonly engineType: string; + readonly engineDetails: string; + readonly disposition: string; + readonly stage: string; + readonly failure: string; + readonly httpStatus?: number; +} + +function classifyStructuredHlsPlaybackIssue( + evidence: StructuredHlsEvidenceInput, + metadata: Parameters[1] +): ReturnType { + return classifyHlsPlaybackIssue(evidence as never, metadata); +} + describe('playback diagnostics', () => { it('classifies HLS incompatible codec errors as unsupported codec fallbacks', () => { - const issue = classifyHlsPlaybackIssue( + const issue = classifyStructuredHlsPlaybackIssue( { - type: 'mediaError', - details: 'manifestIncompatibleCodecsError', - fatal: true, + engineType: ErrorTypes.MEDIA_ERROR, + engineDetails: + ErrorDetails.MANIFEST_INCOMPATIBLE_CODECS_ERROR, + disposition: 'fatal', + stage: 'manifest', + failure: 'unknown', }, createPlaybackSourceMetadata({ url: 'https://example.com/live/index.m3u8', @@ -25,18 +45,21 @@ describe('playback diagnostics', () => { }) ); - expect(issue.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec); - expect(issue.externalFallbackRecommended).toBe(true); - expect(issue.audioCodecs).toEqual(['ac-3']); - expect(issue.videoCodecs).toEqual(['avc1.64001f']); + expect(issue?.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec); + expect(issue?.externalFallbackRecommended).toBe(true); + expect(issue?.audioCodecs).toEqual(['ac-3']); + expect(issue?.videoCodecs).toEqual(['avc1.64001f']); }); it('classifies HLS buffer codec errors as unsupported codec fallbacks', () => { - const issue = classifyHlsPlaybackIssue( + const issue = classifyStructuredHlsPlaybackIssue( { - type: 'mediaError', - details: 'bufferIncompatibleCodecsError', - fatal: true, + engineType: ErrorTypes.MEDIA_ERROR, + engineDetails: + ErrorDetails.BUFFER_INCOMPATIBLE_CODECS_ERROR, + disposition: 'fatal', + stage: 'media', + failure: 'unknown', }, createPlaybackSourceMetadata({ url: 'https://example.com/live/index.m3u8', @@ -44,8 +67,8 @@ describe('playback diagnostics', () => { }) ); - expect(issue.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec); - expect(issue.externalFallbackRecommended).toBe(true); + expect(issue?.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec); + expect(issue?.externalFallbackRecommended).toBe(true); }); it('classifies native decode and unsupported source errors without using network wording', () => { @@ -204,11 +227,13 @@ describe('playback diagnostics', () => { }); it('classifies HLS network errors without claiming codec incompatibility', () => { - const issue = classifyHlsPlaybackIssue( + const issue = classifyStructuredHlsPlaybackIssue( { - type: 'networkError', - details: 'manifestLoadError', - fatal: true, + engineType: ErrorTypes.NETWORK_ERROR, + engineDetails: ErrorDetails.MANIFEST_LOAD_ERROR, + disposition: 'fatal', + stage: 'manifest', + failure: 'network', }, createPlaybackSourceMetadata({ url: 'https://example.com/live/index.m3u8', @@ -216,16 +241,51 @@ describe('playback diagnostics', () => { }) ); - expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError); - expect(issue.externalFallbackRecommended).toBe(false); + expect(issue?.code).toBe(PlaybackDiagnosticCode.NetworkError); + expect(issue?.externalFallbackRecommended).toBe(false); }); - it('does not treat provider-side blocked messages as browser access errors', () => { - const issue = classifyHlsPlaybackIssue( + it('retains structured HLS HTTP and stage evidence', () => { + const issue = classifyStructuredHlsPlaybackIssue( { - type: 'networkError', - details: 'manifestLoadError Request blocked by rate limiter', - fatal: true, + engineType: ErrorTypes.NETWORK_ERROR, + engineDetails: ErrorDetails.MANIFEST_LOAD_ERROR, + disposition: 'fatal', + stage: 'manifest', + failure: 'http', + httpStatus: 404, + }, + createPlaybackSourceMetadata({ + url: 'https://example.com/live/index.m3u8', + player: 'videojs', + }) + ); + + expect(issue).toEqual( + expect.objectContaining({ + code: PlaybackDiagnosticCode.NetworkError, + httpStatus: 404, + hls: expect.objectContaining({ + engineType: ErrorTypes.NETWORK_ERROR, + engineDetails: ErrorDetails.MANIFEST_LOAD_ERROR, + disposition: 'fatal', + stage: 'manifest', + failure: 'http', + httpStatus: 404, + }), + externalFallbackRecommended: false, + }) + ); + }); + + it('does not create terminal diagnostics for recoverable HLS events', () => { + const issue = classifyStructuredHlsPlaybackIssue( + { + engineType: ErrorTypes.NETWORK_ERROR, + engineDetails: ErrorDetails.FRAG_LOAD_ERROR, + disposition: 'recoverable', + stage: 'segment', + failure: 'network', }, createPlaybackSourceMetadata({ url: 'https://provider.example/live/index.m3u8', @@ -233,65 +293,109 @@ describe('playback diagnostics', () => { }) ); - expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError); - expect(issue.externalFallbackRecommended).toBe(false); + expect(issue).toBeNull(); }); - it('keeps raw HLS error object context in diagnostic details', () => { - const issue = classifyHlsPlaybackIssue( + it('keeps status-zero HLS failures as network evidence rather than guessing access', () => { + const issue = classifyStructuredHlsPlaybackIssue( { - type: 'networkError', - details: 'manifestLoadError', - fatal: true, - error: { - context: 'xhr setup failed', - status: 0, + engineType: ErrorTypes.NETWORK_ERROR, + engineDetails: ErrorDetails.MANIFEST_LOAD_ERROR, + disposition: 'fatal', + stage: 'manifest', + failure: 'network', + }, + createPlaybackSourceMetadata({ + url: 'http://provider.example/live.m3u8', + player: 'videojs', + }) + ); + + expect(issue?.code).toBe(PlaybackDiagnosticCode.NetworkError); + expect(issue?.httpStatus).toBeUndefined(); + expect(issue?.externalFallbackRecommended).toBe(false); + }); + + it('classifies exact HLS decrypt evidence as DRM or encryption', () => { + const issue = classifyStructuredHlsPlaybackIssue( + { + engineType: ErrorTypes.MEDIA_ERROR, + engineDetails: ErrorDetails.FRAG_DECRYPT_ERROR, + disposition: 'fatal', + stage: 'segment', + failure: 'unknown', + }, + createPlaybackSourceMetadata({ + url: 'https://provider.example/live.m3u8', + player: 'html5', + }) + ); + + expect(issue?.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption); + expect(issue?.externalFallbackRecommended).toBe(true); + }); + + it('classifies exact HLS key load failures as network errors', () => { + const issue = classifyStructuredHlsPlaybackIssue( + { + engineType: ErrorTypes.NETWORK_ERROR, + engineDetails: ErrorDetails.KEY_LOAD_ERROR, + disposition: 'fatal', + stage: 'key', + failure: 'network', + }, + createPlaybackSourceMetadata({ + url: 'https://provider.example/live.m3u8', + player: 'artplayer', + }) + ); + + expect(issue?.code).toBe(PlaybackDiagnosticCode.NetworkError); + expect(issue?.externalFallbackRecommended).toBe(false); + }); + + it.each([ErrorTypes.MEDIA_ERROR, ErrorTypes.MUX_ERROR])( + 'classifies exact HLS %s evidence as a media decode error', + (engineType) => { + const issue = classifyStructuredHlsPlaybackIssue( + { + engineType, + engineDetails: ErrorDetails.FRAG_PARSING_ERROR, + disposition: 'fatal', + stage: 'segment', + failure: 'unknown', }, - }, - createPlaybackSourceMetadata({ - url: 'https://provider.example/live/index.m3u8', - player: 'videojs', - }) - ); + createPlaybackSourceMetadata({ + url: 'https://provider.example/live.m3u8', + player: 'html5', + }) + ); - expect(issue.details).toContain('xhr setup failed'); - expect(issue.details).toContain('"status":0'); - }); + expect(issue?.code).toBe( + PlaybackDiagnosticCode.MediaDecodeError + ); + } + ); - it('classifies HLS browser access blocks separately from provider network failures', () => { - const issue = classifyHlsPlaybackIssue( + it('keeps unknown structured HLS evidence unknown', () => { + const issue = classifyStructuredHlsPlaybackIssue( { - type: 'networkError', - details: - 'manifestLoadError Mixed Content: The page at https://app.example was loaded over HTTPS, but requested an insecure stream http://provider.example/live.m3u8. This request has been blocked.', - fatal: true, + engineType: 'unknown', + engineDetails: ErrorDetails.UNKNOWN, + disposition: 'fatal', + stage: 'unknown', + failure: 'unknown', }, createPlaybackSourceMetadata({ - url: 'http://provider.example/live.m3u8', - player: 'videojs', + url: 'https://provider.example/live.m3u8?codec=network', + player: 'html5', }) ); - expect(issue.code).toBe('browser-access-error'); - expect(issue.externalFallbackRecommended).toBe(true); - }); - - it('classifies browser security policy blocks as browser access errors', () => { - const issue = classifyHlsPlaybackIssue( - { - type: 'networkError', - details: - 'manifestLoadError Refused to connect because it violates the following Content Security Policy directive: "connect-src"', - fatal: true, - }, - createPlaybackSourceMetadata({ - url: 'http://provider.example/live.m3u8', - player: 'videojs', - }) + expect(issue?.code).toBe( + PlaybackDiagnosticCode.UnknownPlaybackError ); - - expect(issue.code).toBe(PlaybackDiagnosticCode.BrowserAccessError); - expect(issue.externalFallbackRecommended).toBe(true); + expect(issue?.externalFallbackRecommended).toBe(false); }); it('classifies native CORS failures as browser access errors', () => { 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 33efff939..37ee8dbd8 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,5 +1,5 @@ import type { - HlsPlaybackErrorInput, + HlsPlaybackEvidence, MpegTsPlaybackErrorInput, NativePlaybackErrorInput, PlaybackDiagnostic, @@ -8,21 +8,19 @@ import type { PlaybackSourceMetadata, } 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 { getHlsPlaybackDiagnosticCode } from './hls-playback-evidence.util'; import { isBrowserAccessFailure, - isCodecFailure, - isDrmOrEncryptionFailure, isEarlyEofFailure, isNetworkFailure, normalizeErrorDetails, } from './playback-error-patterns.util'; -import { - isLikelyContainerIssue, - mergeCodecMetadata, -} from './playback-media-source.util'; +import { isLikelyContainerIssue } from './playback-media-source.util'; export * from './playback-diagnostics.model'; +export { createHlsPlaybackEvidence } from './hls-playback-evidence.util'; export { createPlaybackSourceMetadata, getLikelyBrowserUnsupportedCodecLabels, @@ -110,60 +108,19 @@ export function classifyNativePlaybackIssue( } export function classifyHlsPlaybackIssue( - error: HlsPlaybackErrorInput, + evidence: HlsPlaybackEvidence, metadata: PlaybackSourceMetadata -): PlaybackDiagnostic { - const details = normalizeErrorDetails(error); - const lowerDetails = details.toLowerCase(); - const lowerType = (error.type ?? '').toLowerCase(); - const mergedMetadata = mergeCodecMetadata(metadata, { - audioCodecs: error.audioCodecs, - videoCodecs: error.videoCodecs, - }); - - if (isNetworkFailure(lowerType, lowerDetails)) { - return createPlaybackDiagnostic({ - code: isBrowserAccessFailure(lowerDetails) - ? DiagnosticCode.BrowserAccessError - : DiagnosticCode.NetworkError, - source: DiagnosticSource.Hls, - metadata: mergedMetadata, - details, - }); - } - - if (isDrmOrEncryptionFailure(lowerDetails)) { - return createPlaybackDiagnostic({ - code: DiagnosticCode.DrmOrEncryption, - source: DiagnosticSource.Hls, - metadata: mergedMetadata, - details, - }); - } - - if (isCodecFailure(lowerDetails)) { - return createPlaybackDiagnostic({ - code: DiagnosticCode.UnsupportedCodec, - source: DiagnosticSource.Hls, - metadata: mergedMetadata, - details, - }); - } - - if (lowerType.includes('media') || lowerType.includes('mux')) { - return createPlaybackDiagnostic({ - code: DiagnosticCode.MediaDecodeError, - source: DiagnosticSource.Hls, - metadata: mergedMetadata, - details, - }); +): PlaybackDiagnostic | null { + if (evidence.disposition === HlsPlaybackDisposition.Recoverable) { + return null; } return createPlaybackDiagnostic({ - code: DiagnosticCode.UnknownPlaybackError, + code: getHlsPlaybackDiagnosticCode(evidence), source: DiagnosticSource.Hls, - metadata: mergedMetadata, - details, + metadata, + httpStatus: evidence.httpStatus, + hls: evidence, }); } @@ -261,6 +218,7 @@ export function createPlaybackDiagnostic(options: { readonly nativeErrorMessage?: string; readonly httpStatus?: number; readonly nativeErrorType?: string; + readonly hls?: HlsPlaybackEvidence; /** Overrides the code-derived recommendation, e.g. when external players * are known to be unable to handle the stream either. */ readonly externalFallbackRecommended?: boolean; @@ -274,6 +232,7 @@ export function createPlaybackDiagnostic(options: { nativeErrorMessage, httpStatus, nativeErrorType, + hls, } = options; return { @@ -290,6 +249,7 @@ export function createPlaybackDiagnostic(options: { nativeErrorMessage, httpStatus, nativeErrorType, + hls, externalFallbackRecommended: options.externalFallbackRecommended ?? isExternalFallbackRecommended(code), 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 22f3b9724..996268692 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,17 +1,11 @@ -import type { - HlsPlaybackErrorInput, - MpegTsPlaybackErrorInput, -} from './playback-diagnostics.model'; +import type { MpegTsPlaybackErrorInput } from './playback-diagnostics.model'; export function normalizeErrorDetails( - error: HlsPlaybackErrorInput | MpegTsPlaybackErrorInput + error: MpegTsPlaybackErrorInput ): string { - const hlsError = 'error' in error ? error.error : undefined; - const mpegTsInfo = 'info' in error ? error.info : undefined; - const errorMessage = normalizeErrorPayload(hlsError); - const info = normalizeErrorPayload(mpegTsInfo); + const info = normalizeErrorPayload(error.info); - return [error.details, error.message, errorMessage, info] + return [error.details, error.message, info] .filter((part): part is string => Boolean(part)) .join(' '); } 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 60d5b8800..b66075ca6 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 @@ -92,6 +92,21 @@ export function getDiagnosticDetails( } function formatDiagnosticErrorDetails(issue: PlaybackDiagnostic): string { + if (issue.hls) { + return [ + `stage=${issue.hls.stage}`, + `failure=${issue.hls.failure}`, + `type=${issue.hls.engineType}`, + `details=${issue.hls.engineDetails}`, + `disposition=${issue.hls.disposition}`, + issue.hls.httpStatus === undefined + ? '' + : `HTTP ${issue.hls.httpStatus}`, + ] + .filter((value) => value.length > 0) + .join(' · '); + } + return [ issue.httpStatus !== undefined ? `HTTP ${issue.httpStatus}` : '', issue.nativeErrorType ?? '', 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 340e2d423..5bb8138c8 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 @@ -15,6 +15,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { of, Subject } from 'rxjs'; import { VideoPlayer } from '@iptvnator/shared/interfaces'; import { RuntimeCapabilitiesService } from '@iptvnator/services'; +import { ErrorDetails, ErrorTypes } from 'hls.js'; import type { WebPlayerViewComponent as WebPlayerViewComponentInstance } from './web-player-view.component'; import { PlaybackDiagnostic, @@ -317,6 +318,30 @@ describe('WebPlayerViewComponent', () => { ); }); + it('renders only sanitized structured HLS evidence in technical details', () => { + const issue = createStructuredHlsDiagnostic(); + + 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_ERROR_DETAILS', + value: + 'stage=manifest · failure=http · ' + + 'type=networkError · details=manifestLoadError · ' + + 'disposition=fatal · HTTP 404', + }, + ]) + ); + expect(renderedDetails).not.toContain('diagnostic-url-secret'); + expect(renderedDetails).not.toContain('provider.example'); + }); + it('keeps query-declared HLS streams on the HLS mime type', () => { const streamUrl = 'https://example.com/play?extension=m3u8&token=signed'; @@ -877,3 +902,27 @@ function createHttpDiagnostic(): PlaybackDiagnostic { externalFallbackRecommended: false, }; } + +function createStructuredHlsDiagnostic(): PlaybackDiagnostic { + return { + code: PlaybackDiagnosticCode.NetworkError, + source: PlaybackDiagnosticSource.Hls, + sourceUrl: + 'https://provider.example/live.m3u8?token=diagnostic-url-secret', + container: 'm3u8', + mimeType: 'application/x-mpegURL', + player: 'html5', + audioCodecs: [], + videoCodecs: [], + httpStatus: 404, + hls: { + engineType: ErrorTypes.NETWORK_ERROR, + engineDetails: ErrorDetails.MANIFEST_LOAD_ERROR, + disposition: 'fatal', + stage: 'manifest', + failure: 'http', + httpStatus: 404, + }, + externalFallbackRecommended: false, + }; +}