fix(playback): structure HLS diagnostics

This commit is contained in:
4gray committed 2026-07-30 22:12:01 +02:00
1 parent 7a189d7146
commit 5bbfbd24a7
16 files changed
+880 -201

No files matched your search

@@ -96,8 +96,14 @@ export class MockMpegTsPlayer {
export const createMpegTsPlayer = jest.fn(() => new MockMpegTsPlayer());
const actualHlsModule =
jest.requireActual<typeof import('hls.js')>('hls.js');
jest.unstable_mockModule('hls.js', () => ({
...actualHlsModule,
default: MockHls,
ErrorDetails: actualHlsModule.ErrorDetails,
ErrorTypes: actualHlsModule.ErrorTypes,
}));
jest.unstable_mockModule('mpegts.js', () => ({
@@ -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,
@@ -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(
@@ -75,12 +75,18 @@ class MockHls {
static isSupported = jest.fn(() => true);
}
const actualHlsModule =
jest.requireActual<typeof import('hls.js')>('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', () => ({
@@ -14,12 +14,18 @@ import {
resetArtPlayerSpecFixtures,
} from './art-player.component.spec-fixtures';
const actualHlsModule =
jest.requireActual<typeof import('hls.js')>('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', () => ({
@@ -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(
@@ -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<typeof import('hls.js')>('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' },
@@ -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', () => {
@@ -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> = {}): 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,
};
}
@@ -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<string>(Object.values(ErrorTypes));
const ENGINE_DETAILS = new Set<string>(Object.values(ErrorDetails));
const MANIFEST_DETAILS = new Set<ErrorDetails>([
ErrorDetails.MANIFEST_LOAD_ERROR,
ErrorDetails.MANIFEST_LOAD_TIMEOUT,
ErrorDetails.MANIFEST_PARSING_ERROR,
ErrorDetails.MANIFEST_INCOMPATIBLE_CODECS_ERROR,
]);
const LEVEL_DETAILS = new Set<ErrorDetails>([
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>([
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>([
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>([
ErrorDetails.KEY_LOAD_ERROR,
ErrorDetails.KEY_LOAD_TIMEOUT,
...KEY_SYSTEM_DETAILS,
]);
const MEDIA_DETAILS = new Set<ErrorDetails>([
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>([
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>([
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>([
ErrorDetails.MANIFEST_INCOMPATIBLE_CODECS_ERROR,
ErrorDetails.BUFFER_ADD_CODEC_ERROR,
ErrorDetails.BUFFER_INCOMPATIBLE_CODECS_ERROR,
]);
const DRM_DETAILS = new Set<ErrorDetails>([
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;
}
@@ -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;
}
@@ -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<typeof classifyHlsPlaybackIssue>[1]
): ReturnType<typeof classifyHlsPlaybackIssue> {
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', () => {
@@ -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),
@@ -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(' ');
}
@@ -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 ?? '',
@@ -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,
};
}