mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
fix(playback): structure HLS diagnostics
This commit is contained in:
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(
|
||||
|
||||
+9
-1
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user