mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(playback): recommend recovery actions (#1374)
* docs(playback): design recovery recommendations * docs(playback): plan recovery recommendations * refactor(playback): extract diagnostic utilities * feat(playback): define recovery recommendation contracts * feat(playback): rank recovery recommendations * feat(playback): track session recovery attempts * feat(playback): identify content recovery sessions * feat(ui): add ranked playback diagnostic panel * feat(playback): switch temporarily to recommended players * test(playback): cover temporary player recommendation * test(playback): verify recommendation capability guards * docs(playback): document recovery recommendations * fix(playback): keep recovery keys credential-free * fix(playback): remove derived tracking ownership * fix(playback): preserve distinct recovery fallbacks * fix(playback): reset resume for new sources * fix(playback): preserve desktop recovery guidance * docs(playback): clarify recovery policy exceptions * fix(playback): reject stale progress updates * fix(playback): keep protected recovery guidance neutral * test(playback): cover stale progress output * fix(playback): neutralize protected diagnostic copy * fix(playback): harden runtime guidance ownership * fix(playback): stabilize recovery application ownership * fix(ci): classify playback util coverage * fix(e2e): preserve playback fixture bytes
This commit is contained in:
1 parent
5e4f2ca3dd
commit
fd96b85c19
176 files changed
+15228
-1908
No files matched your search
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "playback-util",
|
||||
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/playback/util/src",
|
||||
"prefix": "lib",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:shared", "domain:playback", "type:util"],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "nx:run-commands",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"command": [
|
||||
"node",
|
||||
"./tools/testing/run-web-esm-lib-tests.mjs",
|
||||
"libs/playback/util/src"
|
||||
],
|
||||
"env": {
|
||||
"NODE_OPTIONS": "--experimental-vm-modules"
|
||||
},
|
||||
"forwardAllArgs": true
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export * from './lib/diagnostics/playback-diagnostics.util';
|
||||
export * from './lib/diagnostics/shaka-error-classifier';
|
||||
export * from './lib/diagnostics/shaka-error-contract';
|
||||
export * from './lib/diagnostics/shaka-error-lifecycle';
|
||||
export type * from './lib/diagnostics/shaka-error.types';
|
||||
export * from './lib/playback-recommendation.model';
|
||||
export * from './lib/playback-recommendation-policy';
|
||||
export * from './lib/playback-session-key';
|
||||
export * from './lib/playback-target-capabilities';
|
||||
@@ -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 unknown 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;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
export const MpegTsPlaybackEngineType = {
|
||||
Network: 'NetworkError',
|
||||
Media: 'MediaError',
|
||||
Other: 'OtherError',
|
||||
Unknown: 'unknown',
|
||||
} as const;
|
||||
|
||||
export type MpegTsPlaybackEngineType =
|
||||
(typeof MpegTsPlaybackEngineType)[keyof typeof MpegTsPlaybackEngineType];
|
||||
|
||||
export const MpegTsPlaybackEngineDetails = {
|
||||
NetworkException: 'Exception',
|
||||
HttpStatusCodeInvalid: 'HttpStatusCodeInvalid',
|
||||
ConnectingTimeout: 'ConnectingTimeout',
|
||||
UnrecoverableEarlyEof: 'UnrecoverableEarlyEof',
|
||||
MediaMseError: 'MediaMSEError',
|
||||
FormatError: 'FormatError',
|
||||
FormatUnsupported: 'FormatUnsupported',
|
||||
CodecUnsupported: 'CodecUnsupported',
|
||||
Unknown: 'unknown',
|
||||
} as const;
|
||||
|
||||
export type MpegTsPlaybackEngineDetails =
|
||||
(typeof MpegTsPlaybackEngineDetails)[keyof typeof MpegTsPlaybackEngineDetails];
|
||||
|
||||
export const MpegTsPlaybackDisposition = {
|
||||
Terminal: 'terminal',
|
||||
} as const;
|
||||
|
||||
export type MpegTsPlaybackDisposition =
|
||||
(typeof MpegTsPlaybackDisposition)[keyof typeof MpegTsPlaybackDisposition];
|
||||
|
||||
export const MpegTsPlaybackStage = {
|
||||
Loader: 'loader',
|
||||
Demux: 'demux',
|
||||
MediaSource: 'media-source',
|
||||
Unknown: 'unknown',
|
||||
} as const;
|
||||
|
||||
export type MpegTsPlaybackStage =
|
||||
(typeof MpegTsPlaybackStage)[keyof typeof MpegTsPlaybackStage];
|
||||
|
||||
export const MpegTsPlaybackFailure = {
|
||||
Http: 'http',
|
||||
Timeout: 'timeout',
|
||||
Network: 'network',
|
||||
TruncatedStream: 'truncated-stream',
|
||||
Format: 'format',
|
||||
Codec: 'codec',
|
||||
MediaSource: 'media-source',
|
||||
Unknown: 'unknown',
|
||||
} as const;
|
||||
|
||||
export type MpegTsPlaybackFailure =
|
||||
(typeof MpegTsPlaybackFailure)[keyof typeof MpegTsPlaybackFailure];
|
||||
|
||||
export interface MpegTsPlaybackEvidence {
|
||||
readonly engineType: MpegTsPlaybackEngineType;
|
||||
readonly engineDetails: MpegTsPlaybackEngineDetails;
|
||||
readonly disposition: MpegTsPlaybackDisposition;
|
||||
readonly stage: MpegTsPlaybackStage;
|
||||
readonly failure: MpegTsPlaybackFailure;
|
||||
readonly httpStatus?: number;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import mpegts from 'mpegts.js';
|
||||
import {
|
||||
InlinePlaybackPlayer,
|
||||
PlaybackDiagnosticCode,
|
||||
classifyMpegTsPlaybackIssue,
|
||||
createPlaybackSourceMetadata,
|
||||
} from './playback-diagnostics.util';
|
||||
import {
|
||||
MpegTsPlaybackEngineDetails,
|
||||
MpegTsPlaybackEngineType,
|
||||
} from './mpegts-playback-evidence.model';
|
||||
import {
|
||||
MPEGTS_DIAGNOSTIC_VERSION,
|
||||
createMpegTsPlaybackEvidence,
|
||||
} from './mpegts-playback-evidence.util';
|
||||
|
||||
const METADATA = createPlaybackSourceMetadata({
|
||||
url: 'https://example.test/live.ts',
|
||||
mimeType: 'video/mp2t',
|
||||
player: InlinePlaybackPlayer.VideoJs,
|
||||
});
|
||||
|
||||
describe('mpegts.js playback evidence', () => {
|
||||
it('locks the accepted public contract to mpegts.js 1.8.0', () => {
|
||||
expect(mpegts.version).toBe(MPEGTS_DIAGNOSTIC_VERSION);
|
||||
expect(mpegts.ErrorTypes).toEqual({
|
||||
NETWORK_ERROR: MpegTsPlaybackEngineType.Network,
|
||||
MEDIA_ERROR: MpegTsPlaybackEngineType.Media,
|
||||
OTHER_ERROR: MpegTsPlaybackEngineType.Other,
|
||||
});
|
||||
expect(mpegts.ErrorDetails).toEqual({
|
||||
NETWORK_EXCEPTION: MpegTsPlaybackEngineDetails.NetworkException,
|
||||
NETWORK_STATUS_CODE_INVALID:
|
||||
MpegTsPlaybackEngineDetails.HttpStatusCodeInvalid,
|
||||
NETWORK_TIMEOUT: MpegTsPlaybackEngineDetails.ConnectingTimeout,
|
||||
NETWORK_UNRECOVERABLE_EARLY_EOF:
|
||||
MpegTsPlaybackEngineDetails.UnrecoverableEarlyEof,
|
||||
MEDIA_MSE_ERROR: MpegTsPlaybackEngineDetails.MediaMseError,
|
||||
MEDIA_FORMAT_ERROR: MpegTsPlaybackEngineDetails.FormatError,
|
||||
MEDIA_FORMAT_UNSUPPORTED:
|
||||
MpegTsPlaybackEngineDetails.FormatUnsupported,
|
||||
MEDIA_CODEC_UNSUPPORTED:
|
||||
MpegTsPlaybackEngineDetails.CodecUnsupported,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['NetworkError', 'HttpStatusCodeInvalid', 'loader', 'http'],
|
||||
['NetworkError', 'ConnectingTimeout', 'loader', 'timeout'],
|
||||
['NetworkError', 'Exception', 'loader', 'network'],
|
||||
['NetworkError', 'UnrecoverableEarlyEof', 'loader', 'truncated-stream'],
|
||||
['MediaError', 'FormatError', 'demux', 'format'],
|
||||
['MediaError', 'FormatUnsupported', 'demux', 'format'],
|
||||
['MediaError', 'CodecUnsupported', 'demux', 'codec'],
|
||||
['MediaError', 'MediaMSEError', 'media-source', 'media-source'],
|
||||
])('maps %s + %s to %s/%s', (engineType, engineDetails, stage, failure) => {
|
||||
expect(
|
||||
createMpegTsPlaybackEvidence(engineType, engineDetails, undefined)
|
||||
).toEqual({
|
||||
engineType,
|
||||
engineDetails,
|
||||
disposition: 'terminal',
|
||||
stage,
|
||||
failure,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['OtherError', 'CodecUnsupported'],
|
||||
['NetworkError', 'CodecUnsupported'],
|
||||
['MediaError', 'ConnectingTimeout'],
|
||||
['networkerror', 'HttpStatusCodeInvalid'],
|
||||
['NetworkError', 'httpstatuscodeinvalid'],
|
||||
[{ type: 'NetworkError' }, 'Exception'],
|
||||
['MediaError', { details: 'FormatError' }],
|
||||
])(
|
||||
'keeps inconsistent or malformed %p + %p evidence unknown',
|
||||
(type, details) => {
|
||||
const evidence = createMpegTsPlaybackEvidence(type, details, {
|
||||
code: 503,
|
||||
});
|
||||
|
||||
expect(evidence.stage).toBe('unknown');
|
||||
expect(evidence.failure).toBe('unknown');
|
||||
expect(evidence.httpStatus).toBeUndefined();
|
||||
}
|
||||
);
|
||||
|
||||
it.each([399, 600, 404.5, '404', null, undefined])(
|
||||
'rejects invalid HTTP status %p',
|
||||
(code) => {
|
||||
const evidence = createMpegTsPlaybackEvidence(
|
||||
'NetworkError',
|
||||
'HttpStatusCodeInvalid',
|
||||
{ code }
|
||||
);
|
||||
|
||||
expect(evidence.httpStatus).toBeUndefined();
|
||||
}
|
||||
);
|
||||
|
||||
it('reads HTTP status only from the exact top-level public slot', () => {
|
||||
expect(
|
||||
createMpegTsPlaybackEvidence(
|
||||
'NetworkError',
|
||||
'HttpStatusCodeInvalid',
|
||||
{ code: 404 }
|
||||
).httpStatus
|
||||
).toBe(404);
|
||||
expect(
|
||||
createMpegTsPlaybackEvidence('NetworkError', 'Exception', {
|
||||
code: 503,
|
||||
}).httpStatus
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
createMpegTsPlaybackEvidence(
|
||||
'NetworkError',
|
||||
'HttpStatusCodeInvalid',
|
||||
{ response: { code: 503 } }
|
||||
).httpStatus
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not inspect or retain arbitrary engine information', () => {
|
||||
const secret = 'mpegts-info-secret';
|
||||
const circularInfo: Record<string, unknown> = {
|
||||
code: 404,
|
||||
msg: `Not Found ${secret}`,
|
||||
url: `https://provider.example/error?token=${secret}`,
|
||||
headers: { Authorization: secret },
|
||||
};
|
||||
circularInfo['self'] = circularInfo;
|
||||
|
||||
const evidence = createMpegTsPlaybackEvidence(
|
||||
'NetworkError',
|
||||
'HttpStatusCodeInvalid',
|
||||
circularInfo
|
||||
);
|
||||
|
||||
expect(evidence.httpStatus).toBe(404);
|
||||
expect(JSON.stringify(evidence)).not.toContain(secret);
|
||||
expect(JSON.stringify(evidence)).not.toContain('provider.example');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['NetworkError', 'HttpStatusCodeInvalid', 'network-error'],
|
||||
['NetworkError', 'ConnectingTimeout', 'network-error'],
|
||||
['NetworkError', 'Exception', 'network-error'],
|
||||
['NetworkError', 'UnrecoverableEarlyEof', 'media-decode-error'],
|
||||
['MediaError', 'FormatError', 'media-decode-error'],
|
||||
['MediaError', 'FormatUnsupported', 'unsupported-container'],
|
||||
['MediaError', 'CodecUnsupported', 'unsupported-codec'],
|
||||
['MediaError', 'MediaMSEError', 'media-decode-error'],
|
||||
['OtherError', 'Exception', 'unknown-playback-error'],
|
||||
])('classifies %s + %s as %s', (type, details, code) => {
|
||||
const issue = classifyMpegTsPlaybackIssue(
|
||||
createMpegTsPlaybackEvidence(type, details, undefined),
|
||||
METADATA
|
||||
);
|
||||
|
||||
expect(issue.code).toBe(code);
|
||||
expect(issue.details).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves an exact HTTP failure without retaining provider details', () => {
|
||||
const secret = 'mpegts-http-secret';
|
||||
const issue = classifyMpegTsPlaybackIssue(
|
||||
createMpegTsPlaybackEvidence(
|
||||
'NetworkError',
|
||||
'HttpStatusCodeInvalid',
|
||||
{
|
||||
code: 404,
|
||||
msg: `Not Found ${secret}`,
|
||||
url: `https://provider.example/error?token=${secret}`,
|
||||
}
|
||||
),
|
||||
METADATA
|
||||
);
|
||||
|
||||
expect(issue).toEqual(
|
||||
expect.objectContaining({
|
||||
code: PlaybackDiagnosticCode.NetworkError,
|
||||
httpStatus: 404,
|
||||
mpegTs: expect.objectContaining({
|
||||
engineType: 'NetworkError',
|
||||
engineDetails: 'HttpStatusCodeInvalid',
|
||||
disposition: 'terminal',
|
||||
stage: 'loader',
|
||||
failure: 'http',
|
||||
httpStatus: 404,
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(issue.details).toBeUndefined();
|
||||
expect(JSON.stringify(issue)).not.toContain(secret);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
type MpegTsPlaybackEngineDetails as MpegTsPlaybackEngineDetailsValue,
|
||||
type MpegTsPlaybackEngineType as MpegTsPlaybackEngineTypeValue,
|
||||
type MpegTsPlaybackEvidence,
|
||||
type MpegTsPlaybackFailure as MpegTsPlaybackFailureValue,
|
||||
type MpegTsPlaybackStage as MpegTsPlaybackStageValue,
|
||||
MpegTsPlaybackDisposition,
|
||||
MpegTsPlaybackEngineDetails,
|
||||
MpegTsPlaybackEngineType,
|
||||
MpegTsPlaybackFailure,
|
||||
MpegTsPlaybackStage,
|
||||
} from './mpegts-playback-evidence.model';
|
||||
|
||||
export const MPEGTS_DIAGNOSTIC_VERSION = '1.8.0';
|
||||
|
||||
interface MpegTsPlaybackCause {
|
||||
readonly stage: MpegTsPlaybackStageValue;
|
||||
readonly failure: MpegTsPlaybackFailureValue;
|
||||
}
|
||||
|
||||
export function createMpegTsPlaybackEvidence(
|
||||
type: unknown,
|
||||
details: unknown,
|
||||
info: unknown
|
||||
): MpegTsPlaybackEvidence {
|
||||
const engineType = normalizeEngineType(type);
|
||||
const engineDetails = normalizeEngineDetails(details);
|
||||
const cause = deriveMpegTsPlaybackCause(engineType, engineDetails);
|
||||
const httpStatus =
|
||||
engineType === MpegTsPlaybackEngineType.Network &&
|
||||
engineDetails ===
|
||||
MpegTsPlaybackEngineDetails.HttpStatusCodeInvalid
|
||||
? readHttpStatus(info)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
engineType,
|
||||
engineDetails,
|
||||
disposition: MpegTsPlaybackDisposition.Terminal,
|
||||
...cause,
|
||||
...(httpStatus === undefined ? {} : { httpStatus }),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEngineType(value: unknown): MpegTsPlaybackEngineTypeValue {
|
||||
switch (value) {
|
||||
case MpegTsPlaybackEngineType.Network:
|
||||
case MpegTsPlaybackEngineType.Media:
|
||||
case MpegTsPlaybackEngineType.Other:
|
||||
return value;
|
||||
default:
|
||||
return MpegTsPlaybackEngineType.Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeEngineDetails(
|
||||
value: unknown
|
||||
): MpegTsPlaybackEngineDetailsValue {
|
||||
switch (value) {
|
||||
case MpegTsPlaybackEngineDetails.NetworkException:
|
||||
case MpegTsPlaybackEngineDetails.HttpStatusCodeInvalid:
|
||||
case MpegTsPlaybackEngineDetails.ConnectingTimeout:
|
||||
case MpegTsPlaybackEngineDetails.UnrecoverableEarlyEof:
|
||||
case MpegTsPlaybackEngineDetails.MediaMseError:
|
||||
case MpegTsPlaybackEngineDetails.FormatError:
|
||||
case MpegTsPlaybackEngineDetails.FormatUnsupported:
|
||||
case MpegTsPlaybackEngineDetails.CodecUnsupported:
|
||||
return value;
|
||||
default:
|
||||
return MpegTsPlaybackEngineDetails.Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
function deriveMpegTsPlaybackCause(
|
||||
type: MpegTsPlaybackEngineTypeValue,
|
||||
details: MpegTsPlaybackEngineDetailsValue
|
||||
): MpegTsPlaybackCause {
|
||||
if (type === MpegTsPlaybackEngineType.Network) {
|
||||
return deriveNetworkCause(details);
|
||||
}
|
||||
if (type === MpegTsPlaybackEngineType.Media) {
|
||||
return deriveMediaCause(details);
|
||||
}
|
||||
return unknownCause();
|
||||
}
|
||||
|
||||
function deriveNetworkCause(
|
||||
details: MpegTsPlaybackEngineDetailsValue
|
||||
): MpegTsPlaybackCause {
|
||||
switch (details) {
|
||||
case MpegTsPlaybackEngineDetails.HttpStatusCodeInvalid:
|
||||
return cause(
|
||||
MpegTsPlaybackStage.Loader,
|
||||
MpegTsPlaybackFailure.Http
|
||||
);
|
||||
case MpegTsPlaybackEngineDetails.ConnectingTimeout:
|
||||
return cause(
|
||||
MpegTsPlaybackStage.Loader,
|
||||
MpegTsPlaybackFailure.Timeout
|
||||
);
|
||||
case MpegTsPlaybackEngineDetails.NetworkException:
|
||||
return cause(
|
||||
MpegTsPlaybackStage.Loader,
|
||||
MpegTsPlaybackFailure.Network
|
||||
);
|
||||
case MpegTsPlaybackEngineDetails.UnrecoverableEarlyEof:
|
||||
return cause(
|
||||
MpegTsPlaybackStage.Loader,
|
||||
MpegTsPlaybackFailure.TruncatedStream
|
||||
);
|
||||
default:
|
||||
return unknownCause();
|
||||
}
|
||||
}
|
||||
|
||||
function deriveMediaCause(
|
||||
details: MpegTsPlaybackEngineDetailsValue
|
||||
): MpegTsPlaybackCause {
|
||||
switch (details) {
|
||||
case MpegTsPlaybackEngineDetails.FormatError:
|
||||
case MpegTsPlaybackEngineDetails.FormatUnsupported:
|
||||
return cause(
|
||||
MpegTsPlaybackStage.Demux,
|
||||
MpegTsPlaybackFailure.Format
|
||||
);
|
||||
case MpegTsPlaybackEngineDetails.CodecUnsupported:
|
||||
return cause(
|
||||
MpegTsPlaybackStage.Demux,
|
||||
MpegTsPlaybackFailure.Codec
|
||||
);
|
||||
case MpegTsPlaybackEngineDetails.MediaMseError:
|
||||
return cause(
|
||||
MpegTsPlaybackStage.MediaSource,
|
||||
MpegTsPlaybackFailure.MediaSource
|
||||
);
|
||||
default:
|
||||
return unknownCause();
|
||||
}
|
||||
}
|
||||
|
||||
function cause(
|
||||
stage: MpegTsPlaybackStageValue,
|
||||
failure: MpegTsPlaybackFailureValue
|
||||
): MpegTsPlaybackCause {
|
||||
return { stage, failure };
|
||||
}
|
||||
|
||||
function unknownCause(): MpegTsPlaybackCause {
|
||||
return cause(
|
||||
MpegTsPlaybackStage.Unknown,
|
||||
MpegTsPlaybackFailure.Unknown
|
||||
);
|
||||
}
|
||||
|
||||
function readHttpStatus(info: unknown): number | undefined {
|
||||
if (typeof info !== 'object' || info === null || Array.isArray(info)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const code = (info as Readonly<Record<string, unknown>>)['code'];
|
||||
return typeof code === 'number' &&
|
||||
Number.isInteger(code) &&
|
||||
code >= 400 &&
|
||||
code <= 599
|
||||
? code
|
||||
: undefined;
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import type {
|
||||
ExternalPlayerName,
|
||||
ResolvedPortalPlayback,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { ErrorDetails, ErrorTypes } from 'hls.js';
|
||||
import type { MpegTsPlaybackEvidence } from './mpegts-playback-evidence.model';
|
||||
|
||||
export * from './mpegts-playback-evidence.model';
|
||||
|
||||
export const PlaybackDiagnosticCode = {
|
||||
UnsupportedContainer: 'unsupported-container',
|
||||
UnsupportedCodec: 'unsupported-codec',
|
||||
MediaDecodeError: 'media-decode-error',
|
||||
NetworkError: 'network-error',
|
||||
BrowserAccessError: 'browser-access-error',
|
||||
DrmOrEncryption: 'drm-or-encryption',
|
||||
UnknownPlaybackError: 'unknown-playback-error',
|
||||
} as const;
|
||||
|
||||
export type PlaybackDiagnosticCode =
|
||||
(typeof PlaybackDiagnosticCode)[keyof typeof PlaybackDiagnosticCode];
|
||||
|
||||
export const PlaybackDiagnosticSource = {
|
||||
Source: 'source',
|
||||
Native: 'native',
|
||||
Vhs: 'vhs',
|
||||
Hls: 'hls',
|
||||
MpegTs: 'mpegts',
|
||||
Shaka: 'shaka',
|
||||
} as const;
|
||||
|
||||
export type PlaybackDiagnosticSource =
|
||||
(typeof PlaybackDiagnosticSource)[keyof typeof PlaybackDiagnosticSource];
|
||||
|
||||
export const PlaybackRuntimeSupport = {
|
||||
ShakaBrowserUnsupported: 'shaka-browser-unsupported',
|
||||
} as const;
|
||||
|
||||
export type PlaybackRuntimeSupport =
|
||||
(typeof PlaybackRuntimeSupport)[keyof typeof PlaybackRuntimeSupport];
|
||||
|
||||
export const InlinePlaybackPlayer = {
|
||||
VideoJs: 'videojs',
|
||||
Html5: 'html5',
|
||||
ArtPlayer: 'artplayer',
|
||||
} as const;
|
||||
|
||||
export type InlinePlaybackPlayer =
|
||||
(typeof InlinePlaybackPlayer)[keyof typeof InlinePlaybackPlayer];
|
||||
|
||||
export interface PlaybackSourceMetadataInput {
|
||||
readonly url: string;
|
||||
readonly mimeType?: string;
|
||||
readonly player?: InlinePlaybackPlayer;
|
||||
readonly audioCodecs?: readonly string[];
|
||||
readonly videoCodecs?: readonly string[];
|
||||
}
|
||||
|
||||
export interface PlaybackSourceMetadata {
|
||||
readonly url: string;
|
||||
readonly extension: string;
|
||||
readonly container: string;
|
||||
readonly mimeType?: string;
|
||||
readonly player?: InlinePlaybackPlayer;
|
||||
readonly audioCodecs: readonly string[];
|
||||
readonly videoCodecs: readonly string[];
|
||||
}
|
||||
|
||||
export interface NativePlaybackErrorMetadataInput {
|
||||
readonly errorType?: unknown;
|
||||
}
|
||||
|
||||
export interface NativePlaybackErrorInput {
|
||||
readonly code?: number;
|
||||
readonly message?: string;
|
||||
readonly status?: number;
|
||||
readonly metadata?: NativePlaybackErrorMetadataInput;
|
||||
}
|
||||
|
||||
export const VhsPlaybackEngineType = {
|
||||
NetworkBadStatus: 'networkbadstatus',
|
||||
NetworkRequestFailed: 'networkrequestfailed',
|
||||
NetworkRequestAborted: 'networkrequestaborted',
|
||||
NetworkRequestTimeout: 'networkrequesttimeout',
|
||||
NetworkBodyParserFailed: 'networkbodyparserfailed',
|
||||
StreamingHlsPlaylistParserError: 'streaminghlsplaylistparsererror',
|
||||
StreamingDashManifestParserError: 'streamingdashmanifestparsererror',
|
||||
StreamingContentSteeringParserError: 'streamingcontentsteeringparsererror',
|
||||
StreamingVttParserError: 'streamingvttparsererror',
|
||||
StreamingFailedToSelectNextSegment: 'streamingfailedtoselectnextsegment',
|
||||
StreamingFailedToDecryptSegment: 'streamingfailedtodecryptsegment',
|
||||
StreamingFailedToTransmuxSegment: 'streamingfailedtotransmuxsegment',
|
||||
StreamingFailedToAppendSegment: 'streamingfailedtoappendsegment',
|
||||
StreamingCodecsChangeError: 'streamingcodecschangeerror',
|
||||
} as const;
|
||||
|
||||
export const VhsPlaybackUnknownEngineType = 'unknown' as const;
|
||||
|
||||
export type VhsPlaybackEngineType =
|
||||
| (typeof VhsPlaybackEngineType)[keyof typeof VhsPlaybackEngineType]
|
||||
| typeof VhsPlaybackUnknownEngineType;
|
||||
|
||||
export const VhsPlaybackMediaErrorCode = {
|
||||
Custom: 0,
|
||||
Aborted: 1,
|
||||
Network: 2,
|
||||
Decode: 3,
|
||||
SourceNotSupported: 4,
|
||||
Encrypted: 5,
|
||||
Unknown: 'unknown',
|
||||
} as const;
|
||||
|
||||
export type VhsPlaybackMediaErrorCode =
|
||||
(typeof VhsPlaybackMediaErrorCode)[keyof typeof VhsPlaybackMediaErrorCode];
|
||||
|
||||
export const VhsPlaybackDisposition = {
|
||||
Terminal: 'terminal',
|
||||
} as const;
|
||||
|
||||
export type VhsPlaybackDisposition =
|
||||
(typeof VhsPlaybackDisposition)[keyof typeof VhsPlaybackDisposition];
|
||||
|
||||
export const VhsPlaybackStage = {
|
||||
Manifest: 'manifest',
|
||||
Playlist: 'playlist',
|
||||
Segment: 'segment',
|
||||
Unknown: 'unknown',
|
||||
} as const;
|
||||
|
||||
export type VhsPlaybackStage =
|
||||
(typeof VhsPlaybackStage)[keyof typeof VhsPlaybackStage];
|
||||
|
||||
export interface VhsPlaybackEvidence {
|
||||
readonly engineType: VhsPlaybackEngineType;
|
||||
readonly mediaErrorCode: VhsPlaybackMediaErrorCode;
|
||||
readonly disposition: VhsPlaybackDisposition;
|
||||
readonly stage: VhsPlaybackStage;
|
||||
readonly httpStatus?: number;
|
||||
}
|
||||
|
||||
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 const ShakaPlaybackSeverity = {
|
||||
Recoverable: 'recoverable',
|
||||
Critical: 'critical',
|
||||
Unknown: 'unknown',
|
||||
} as const;
|
||||
|
||||
export type ShakaPlaybackSeverity =
|
||||
(typeof ShakaPlaybackSeverity)[keyof typeof ShakaPlaybackSeverity];
|
||||
|
||||
export const ShakaPlaybackCategory = {
|
||||
Network: 'network',
|
||||
Text: 'text',
|
||||
Media: 'media',
|
||||
Manifest: 'manifest',
|
||||
Streaming: 'streaming',
|
||||
Drm: 'drm',
|
||||
Player: 'player',
|
||||
Cast: 'cast',
|
||||
Storage: 'storage',
|
||||
Ads: 'ads',
|
||||
Unknown: 'unknown',
|
||||
} as const;
|
||||
|
||||
export type ShakaPlaybackCategory =
|
||||
(typeof ShakaPlaybackCategory)[keyof typeof ShakaPlaybackCategory];
|
||||
|
||||
export const ShakaPlaybackDisposition = {
|
||||
Terminal: 'terminal',
|
||||
Recoverable: 'recoverable',
|
||||
} as const;
|
||||
|
||||
export type ShakaPlaybackDisposition =
|
||||
(typeof ShakaPlaybackDisposition)[keyof typeof ShakaPlaybackDisposition];
|
||||
|
||||
export const ShakaPlaybackStage = {
|
||||
Manifest: 'manifest',
|
||||
Segment: 'segment',
|
||||
Media: 'media',
|
||||
License: 'license',
|
||||
Unknown: 'unknown',
|
||||
} as const;
|
||||
|
||||
export type ShakaPlaybackStage =
|
||||
(typeof ShakaPlaybackStage)[keyof typeof ShakaPlaybackStage];
|
||||
|
||||
export const ShakaPlaybackFailure = {
|
||||
Network: 'network',
|
||||
Drm: 'drm',
|
||||
Manifest: 'manifest',
|
||||
Media: 'media',
|
||||
Unknown: 'unknown',
|
||||
} as const;
|
||||
|
||||
export type ShakaPlaybackFailure =
|
||||
(typeof ShakaPlaybackFailure)[keyof typeof ShakaPlaybackFailure];
|
||||
|
||||
export const ShakaPlaybackUnknownCode = 'unknown' as const;
|
||||
|
||||
export type ShakaPlaybackEngineCode = number | typeof ShakaPlaybackUnknownCode;
|
||||
|
||||
export interface ShakaPlaybackEvidence {
|
||||
readonly severity: ShakaPlaybackSeverity;
|
||||
readonly category: ShakaPlaybackCategory;
|
||||
readonly engineCode: ShakaPlaybackEngineCode;
|
||||
readonly disposition: ShakaPlaybackDisposition;
|
||||
readonly stage: ShakaPlaybackStage;
|
||||
readonly failure: ShakaPlaybackFailure;
|
||||
readonly httpStatus?: number;
|
||||
}
|
||||
|
||||
export interface PlaybackDiagnostic {
|
||||
readonly code: PlaybackDiagnosticCode;
|
||||
readonly source: PlaybackDiagnosticSource;
|
||||
readonly sourceUrl: string;
|
||||
readonly container: string;
|
||||
readonly mimeType?: string;
|
||||
readonly player?: InlinePlaybackPlayer;
|
||||
readonly audioCodecs: readonly string[];
|
||||
readonly videoCodecs: readonly string[];
|
||||
readonly runtimeSupport?: PlaybackRuntimeSupport;
|
||||
readonly details?: string;
|
||||
readonly nativeErrorCode?: number;
|
||||
readonly nativeErrorMessage?: string;
|
||||
readonly httpStatus?: number;
|
||||
readonly nativeErrorType?: string;
|
||||
readonly vhs?: VhsPlaybackEvidence;
|
||||
readonly hls?: HlsPlaybackEvidence;
|
||||
readonly mpegTs?: MpegTsPlaybackEvidence;
|
||||
readonly shaka?: ShakaPlaybackEvidence;
|
||||
}
|
||||
|
||||
export interface PlaybackFallbackRequest {
|
||||
readonly player: ExternalPlayerName;
|
||||
readonly playback: ResolvedPortalPlayback;
|
||||
readonly diagnostic: PlaybackDiagnostic;
|
||||
}
|
||||
@@ -0,0 +1,772 @@
|
||||
import { ErrorDetails, ErrorTypes } from 'hls.js';
|
||||
import type {
|
||||
NativePlaybackErrorInput,
|
||||
PlaybackDiagnostic,
|
||||
PlaybackSourceMetadata,
|
||||
} from './playback-diagnostics.model';
|
||||
import * as diagnostics from './playback-diagnostics.util';
|
||||
import {
|
||||
PlaybackDiagnosticCode,
|
||||
classifyHlsPlaybackIssue,
|
||||
classifyMpegTsPlaybackIssue,
|
||||
classifyNativePlaybackIssue,
|
||||
classifyUnsupportedHlsManifestCodecs,
|
||||
createMpegTsPlaybackEvidence,
|
||||
createPlaybackSourceMetadata,
|
||||
getLikelyBrowserUnsupportedCodecLabels,
|
||||
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);
|
||||
}
|
||||
|
||||
type VhsClassifier = (
|
||||
error: NativePlaybackErrorInput,
|
||||
metadata: PlaybackSourceMetadata
|
||||
) => PlaybackDiagnostic;
|
||||
|
||||
describe('playback diagnostics', () => {
|
||||
it('classifies manifest codecs only when the explicit support probe rejects them', () => {
|
||||
const metadata = createPlaybackSourceMetadata({
|
||||
url: 'https://example.com/live/index.m3u8',
|
||||
player: 'html5',
|
||||
audioCodecs: ['ac-3'],
|
||||
videoCodecs: ['avc1.64001f'],
|
||||
});
|
||||
|
||||
const issue = classifyUnsupportedHlsManifestCodecs(
|
||||
metadata,
|
||||
() => false
|
||||
);
|
||||
|
||||
expect(issue?.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec);
|
||||
});
|
||||
|
||||
it.each([true, undefined])(
|
||||
'does not classify manifest codecs when the support probe returns %s',
|
||||
(supportResult) => {
|
||||
const issue = classifyUnsupportedHlsManifestCodecs(
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'https://example.com/live/index.m3u8',
|
||||
player: 'html5',
|
||||
videoCodecs: ['avc1.64001f'],
|
||||
}),
|
||||
() => supportResult
|
||||
);
|
||||
|
||||
expect(issue).toBeNull();
|
||||
}
|
||||
);
|
||||
|
||||
it('classifies HLS incompatible codec errors as unsupported codec fallbacks', () => {
|
||||
const issue = classifyStructuredHlsPlaybackIssue(
|
||||
{
|
||||
engineType: ErrorTypes.MEDIA_ERROR,
|
||||
engineDetails: ErrorDetails.MANIFEST_INCOMPATIBLE_CODECS_ERROR,
|
||||
disposition: 'fatal',
|
||||
stage: 'manifest',
|
||||
failure: 'unknown',
|
||||
},
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'https://example.com/live/index.m3u8',
|
||||
mimeType: 'application/x-mpegURL',
|
||||
player: 'videojs',
|
||||
audioCodecs: ['ac-3'],
|
||||
videoCodecs: ['avc1.64001f'],
|
||||
})
|
||||
);
|
||||
|
||||
expect(issue?.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec);
|
||||
expect(issue?.audioCodecs).toEqual(['ac-3']);
|
||||
expect(issue?.videoCodecs).toEqual(['avc1.64001f']);
|
||||
});
|
||||
|
||||
it('classifies HLS buffer codec errors as unsupported codec fallbacks', () => {
|
||||
const issue = classifyStructuredHlsPlaybackIssue(
|
||||
{
|
||||
engineType: ErrorTypes.MEDIA_ERROR,
|
||||
engineDetails: ErrorDetails.BUFFER_INCOMPATIBLE_CODECS_ERROR,
|
||||
disposition: 'fatal',
|
||||
stage: 'media',
|
||||
failure: 'unknown',
|
||||
},
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'https://example.com/live/index.m3u8',
|
||||
player: 'html5',
|
||||
})
|
||||
);
|
||||
|
||||
expect(issue?.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec);
|
||||
});
|
||||
|
||||
it('classifies native decode and unsupported source errors without using network wording', () => {
|
||||
const decodeIssue = classifyNativePlaybackIssue(
|
||||
{ code: 3, message: 'decode failed' },
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'https://example.com/movie.mp4',
|
||||
mimeType: 'video/mp4',
|
||||
player: 'artplayer',
|
||||
})
|
||||
);
|
||||
const unsupportedSourceIssue = classifyNativePlaybackIssue(
|
||||
{ code: 4, message: 'source not supported' },
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'https://example.com/movie.mkv',
|
||||
mimeType: 'video/matroska',
|
||||
player: 'artplayer',
|
||||
})
|
||||
);
|
||||
|
||||
expect(decodeIssue.code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
|
||||
expect(unsupportedSourceIssue.code).toBe(
|
||||
PlaybackDiagnosticCode.UnsupportedContainer
|
||||
);
|
||||
});
|
||||
|
||||
it('classifies MIME-only unsupported containers as container diagnostics', () => {
|
||||
const issue = classifyNativePlaybackIssue(
|
||||
{ code: 4, message: 'source not supported' },
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'https://example.com/archive/stream',
|
||||
mimeType: 'video/x-msvideo',
|
||||
player: 'html5',
|
||||
})
|
||||
);
|
||||
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.UnsupportedContainer);
|
||||
expect(issue.container).toBe('x-msvideo');
|
||||
});
|
||||
|
||||
it('keeps MPEG-TS MIME-only source failures unknown without codec evidence', () => {
|
||||
const issue = classifyNativePlaybackIssue(
|
||||
{ code: 4, message: 'source not supported' },
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'https://example.com/live/stream',
|
||||
mimeType: 'video/mp2t',
|
||||
player: 'videojs',
|
||||
})
|
||||
);
|
||||
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
|
||||
expect(issue.container).toBe('mp2t');
|
||||
});
|
||||
|
||||
it('classifies native HTTP source failures from a safe status and error type', () => {
|
||||
const issue = classifyNativePlaybackIssue(
|
||||
{
|
||||
code: 4,
|
||||
message: 'source not supported',
|
||||
status: 404,
|
||||
metadata: { errorType: 'networkrequestfailed' },
|
||||
},
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'https://example.com/live/missing.m3u8',
|
||||
player: 'videojs',
|
||||
})
|
||||
);
|
||||
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError);
|
||||
expect(issue.httpStatus).toBe(404);
|
||||
expect(issue.nativeErrorType).toBe('networkrequestfailed');
|
||||
});
|
||||
|
||||
it('classifies exact VHS network evidence without using provider text', () => {
|
||||
const issue = classifyVhsPlaybackIssue(
|
||||
{
|
||||
code: 4,
|
||||
message:
|
||||
'provider says codec DRM CORS at ' +
|
||||
'https://provider.example/live.m3u8?token=secret',
|
||||
metadata: { errorType: 'networkrequestfailed' },
|
||||
},
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'https://example.com/live/missing.m3u8',
|
||||
player: 'videojs',
|
||||
})
|
||||
);
|
||||
|
||||
expect(issue).toEqual(
|
||||
expect.objectContaining({
|
||||
code: PlaybackDiagnosticCode.NetworkError,
|
||||
source: 'vhs',
|
||||
httpStatus: undefined,
|
||||
nativeErrorMessage: undefined,
|
||||
vhs: {
|
||||
engineType: 'networkrequestfailed',
|
||||
mediaErrorCode: 4,
|
||||
disposition: 'terminal',
|
||||
stage: 'unknown',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('classifies exact VHS decrypt evidence without inspecting messages', () => {
|
||||
const issue = classifyVhsPlaybackIssue(
|
||||
{
|
||||
code: 3,
|
||||
message: 'provider network timeout',
|
||||
metadata: {
|
||||
errorType: 'streamingfailedtodecryptsegment',
|
||||
},
|
||||
},
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'https://example.com/live/encrypted.m3u8',
|
||||
player: 'videojs',
|
||||
})
|
||||
);
|
||||
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
|
||||
expect(issue.source).toBe('vhs');
|
||||
expect(issue.nativeErrorMessage).toBeUndefined();
|
||||
expect(issue.vhs).toEqual({
|
||||
engineType: 'streamingfailedtodecryptsegment',
|
||||
mediaErrorCode: 3,
|
||||
disposition: 'terminal',
|
||||
stage: 'segment',
|
||||
});
|
||||
});
|
||||
|
||||
it('classifies the public encrypted MediaError code without DRM text', () => {
|
||||
const issue = classifyVhsPlaybackIssue(
|
||||
{ code: 5, message: 'provider supplied message' },
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'https://example.com/live/encrypted.m3u8',
|
||||
player: 'videojs',
|
||||
})
|
||||
);
|
||||
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
|
||||
expect(issue.nativeErrorMessage).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps generic VHS code three unknown while native code three remains decode evidence', () => {
|
||||
const metadata = createPlaybackSourceMetadata({
|
||||
url: 'https://example.com/live/index.m3u8',
|
||||
player: 'videojs',
|
||||
});
|
||||
const vhsIssue = classifyVhsPlaybackIssue(
|
||||
{
|
||||
code: 3,
|
||||
message:
|
||||
'Playback cannot continue. No available working or supported playlists.',
|
||||
},
|
||||
metadata
|
||||
);
|
||||
const nativeIssue = classifyNativePlaybackIssue(
|
||||
{ code: 3, message: 'native decode failed' },
|
||||
metadata
|
||||
);
|
||||
|
||||
expect(vhsIssue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
|
||||
expect(vhsIssue.nativeErrorMessage).toBeUndefined();
|
||||
expect(nativeIssue.code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
|
||||
});
|
||||
|
||||
it('retains only safe VHS HTTP and allowlisted engine evidence', () => {
|
||||
const secret = 'vhs-classifier-secret';
|
||||
const issue = classifyVhsPlaybackIssue(
|
||||
{
|
||||
code: 4,
|
||||
status: 503,
|
||||
message: `https://provider.example/?token=${secret}`,
|
||||
metadata: {
|
||||
errorType: 'networkbadstatus',
|
||||
requestType: 'hls-key',
|
||||
headers: { Authorization: secret },
|
||||
responseBody: secret,
|
||||
},
|
||||
} as NativePlaybackErrorInput,
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'https://example.com/live/index.m3u8',
|
||||
player: 'videojs',
|
||||
})
|
||||
);
|
||||
const serializedEvidence = JSON.stringify(issue.vhs);
|
||||
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError);
|
||||
expect(issue.httpStatus).toBe(503);
|
||||
expect(issue.nativeErrorMessage).toBeUndefined();
|
||||
expect(serializedEvidence).not.toContain(secret);
|
||||
expect(serializedEvidence).not.toContain('provider.example');
|
||||
expect(serializedEvidence).not.toContain('hls-key');
|
||||
expect(serializedEvidence).not.toContain('Authorization');
|
||||
});
|
||||
|
||||
it('keeps unknown VHS metadata unknown despite cause-shaped provider text', () => {
|
||||
const issue = classifyVhsPlaybackIssue(
|
||||
{
|
||||
code: 3,
|
||||
message: 'CORS codec DRM network timeout',
|
||||
metadata: { errorType: 'providerCodecCorsDrmError' },
|
||||
},
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'https://example.com/live/index.m3u8',
|
||||
player: 'videojs',
|
||||
})
|
||||
);
|
||||
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
|
||||
expect(issue.vhs?.engineType).toBe('unknown');
|
||||
expect(issue.nativeErrorMessage).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps native code four HLS source failures unknown without status or container evidence', () => {
|
||||
const issue = classifyNativePlaybackIssue(
|
||||
{ code: 4, message: 'source not supported' },
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'https://example.com/live/missing.m3u8',
|
||||
player: 'videojs',
|
||||
})
|
||||
);
|
||||
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
|
||||
expect(issue.httpStatus).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not retain unsafe native status or metadata error types', () => {
|
||||
const issue = classifyNativePlaybackIssue(
|
||||
{
|
||||
code: 4,
|
||||
message: 'source not supported',
|
||||
status: 0,
|
||||
metadata: { errorType: 'request failed: token=secret value' },
|
||||
},
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'https://example.com/live/missing.m3u8',
|
||||
player: 'videojs',
|
||||
})
|
||||
);
|
||||
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
|
||||
expect(issue.httpStatus).toBeUndefined();
|
||||
expect(issue.nativeErrorType).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ status: 399, accepted: false },
|
||||
{ status: 400, accepted: true },
|
||||
{ status: 599, accepted: true },
|
||||
{ status: 600, accepted: false },
|
||||
{ status: 404.5, accepted: false },
|
||||
])(
|
||||
'accepts native HTTP status $status only when it is a 4xx or 5xx integer',
|
||||
({ status, accepted }) => {
|
||||
const issue = classifyNativePlaybackIssue(
|
||||
{ code: 4, status },
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'https://example.com/live/missing.m3u8',
|
||||
player: 'videojs',
|
||||
})
|
||||
);
|
||||
|
||||
expect(issue.code).toBe(
|
||||
accepted
|
||||
? PlaybackDiagnosticCode.NetworkError
|
||||
: PlaybackDiagnosticCode.UnknownPlaybackError
|
||||
);
|
||||
expect(issue.httpStatus).toBe(accepted ? status : undefined);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ length: 128, accepted: true },
|
||||
{ length: 129, accepted: false },
|
||||
])(
|
||||
'retains native error type identifiers up to $length characters',
|
||||
({ length, accepted }) => {
|
||||
const errorType = 'a'.repeat(length);
|
||||
const issue = classifyNativePlaybackIssue(
|
||||
{ code: 4, metadata: { errorType } },
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'https://example.com/live/missing.m3u8',
|
||||
player: 'videojs',
|
||||
})
|
||||
);
|
||||
|
||||
expect(issue.code).toBe(
|
||||
PlaybackDiagnosticCode.UnknownPlaybackError
|
||||
);
|
||||
expect(issue.nativeErrorType).toBe(
|
||||
accepted ? errorType : undefined
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('classifies HLS network errors without claiming codec incompatibility', () => {
|
||||
const issue = classifyStructuredHlsPlaybackIssue(
|
||||
{
|
||||
engineType: ErrorTypes.NETWORK_ERROR,
|
||||
engineDetails: ErrorDetails.MANIFEST_LOAD_ERROR,
|
||||
disposition: 'fatal',
|
||||
stage: 'manifest',
|
||||
failure: 'network',
|
||||
},
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'https://example.com/live/index.m3u8',
|
||||
player: 'videojs',
|
||||
})
|
||||
);
|
||||
|
||||
expect(issue?.code).toBe(PlaybackDiagnosticCode.NetworkError);
|
||||
});
|
||||
|
||||
it('retains structured HLS HTTP and stage evidence', () => {
|
||||
const issue = classifyStructuredHlsPlaybackIssue(
|
||||
{
|
||||
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,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
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',
|
||||
player: 'videojs',
|
||||
})
|
||||
);
|
||||
|
||||
expect(issue).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps status-zero HLS failures as network evidence rather than guessing access', () => {
|
||||
const issue = classifyStructuredHlsPlaybackIssue(
|
||||
{
|
||||
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();
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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.m3u8',
|
||||
player: 'html5',
|
||||
})
|
||||
);
|
||||
|
||||
expect(issue?.code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
|
||||
}
|
||||
);
|
||||
|
||||
it('keeps unknown structured HLS evidence unknown', () => {
|
||||
const issue = classifyStructuredHlsPlaybackIssue(
|
||||
{
|
||||
engineType: 'unknown',
|
||||
engineDetails: ErrorDetails.UNKNOWN,
|
||||
disposition: 'fatal',
|
||||
stage: 'unknown',
|
||||
failure: 'unknown',
|
||||
},
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'https://provider.example/live.m3u8?codec=network',
|
||||
player: 'html5',
|
||||
})
|
||||
);
|
||||
|
||||
expect(issue?.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
|
||||
});
|
||||
|
||||
it('classifies native CORS failures as browser access errors', () => {
|
||||
const issue = classifyNativePlaybackIssue(
|
||||
{
|
||||
code: 2,
|
||||
message:
|
||||
'Access to media at https://provider.example/live.m3u8 from origin app://iptvnator has been blocked by CORS policy: No Access-Control-Allow-Origin header is present.',
|
||||
},
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'https://provider.example/live.m3u8',
|
||||
player: 'html5',
|
||||
})
|
||||
);
|
||||
|
||||
expect(issue.code).toBe('browser-access-error');
|
||||
});
|
||||
|
||||
it('keeps opaque native network failures generic when the browser hides access details', () => {
|
||||
const issue = classifyNativePlaybackIssue(
|
||||
{
|
||||
code: 2,
|
||||
message: '',
|
||||
},
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'https://provider.example/live.m3u8',
|
||||
player: 'html5',
|
||||
})
|
||||
);
|
||||
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError);
|
||||
});
|
||||
|
||||
it('does not guess mpegts browser access from exception messages', () => {
|
||||
const secret = 'blocked by CORS policy';
|
||||
const issue = classifyMpegTsPlaybackIssue(
|
||||
createMpegTsPlaybackEvidence('NetworkError', 'Exception', {
|
||||
msg: secret,
|
||||
}),
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'https://provider.example/live/channel.ts',
|
||||
mimeType: 'video/mp2t',
|
||||
player: 'videojs',
|
||||
})
|
||||
);
|
||||
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError);
|
||||
expect(JSON.stringify(issue)).not.toContain(secret);
|
||||
});
|
||||
|
||||
it('classifies mpegts early EOF failures as fallback-actionable media errors', () => {
|
||||
const issue = classifyMpegTsPlaybackIssue(
|
||||
createMpegTsPlaybackEvidence(
|
||||
'NetworkError',
|
||||
'UnrecoverableEarlyEof',
|
||||
{ msg: 'Fetch stream meet Early-EOF' }
|
||||
),
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'https://provider.example/movie/123.ts',
|
||||
mimeType: 'video/mp2t',
|
||||
player: 'videojs',
|
||||
})
|
||||
);
|
||||
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
|
||||
expect(issue.mpegTs?.failure).toBe('truncated-stream');
|
||||
expect(issue.details).toBeUndefined();
|
||||
});
|
||||
|
||||
it('classifies mpegts codec errors as unsupported codec fallbacks', () => {
|
||||
const issue = classifyMpegTsPlaybackIssue(
|
||||
createMpegTsPlaybackEvidence(
|
||||
'MediaError',
|
||||
'CodecUnsupported',
|
||||
undefined
|
||||
),
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'https://example.com/live/channel.ts',
|
||||
mimeType: 'video/mp2t',
|
||||
player: 'videojs',
|
||||
})
|
||||
);
|
||||
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec);
|
||||
});
|
||||
|
||||
it('prefers stream extension query metadata over web script path extensions', () => {
|
||||
const metadata = createPlaybackSourceMetadata({
|
||||
url: 'http://portal.example/play/live.php?stream=123&extension=ts',
|
||||
player: 'html5',
|
||||
});
|
||||
|
||||
expect(metadata.extension).toBe('ts');
|
||||
expect(metadata.container).toBe('ts');
|
||||
});
|
||||
|
||||
it('does not expose web script extensions as media containers', () => {
|
||||
const metadata = createPlaybackSourceMetadata({
|
||||
url: 'http://portal.example/play/live.php?stream=123',
|
||||
player: 'html5',
|
||||
});
|
||||
|
||||
expect(metadata.extension).toBe('');
|
||||
expect(metadata.container).toBe('');
|
||||
});
|
||||
|
||||
it('uses declared media query metadata without exposing web script extensions', () => {
|
||||
expect(
|
||||
getPlaybackMediaExtensionFromUrl(
|
||||
'http://portal.example/play/live.php?format=m3u8&stream=123'
|
||||
)
|
||||
).toBe('m3u8');
|
||||
expect(
|
||||
getPlaybackMediaExtensionFromUrl(
|
||||
'http://portal.example/play/live.php?container=m3u8&stream=123'
|
||||
)
|
||||
).toBe('m3u8');
|
||||
expect(
|
||||
getPlaybackMediaExtensionFromUrl(
|
||||
'http://portal.example/play/live.php?ext=ts&stream=123'
|
||||
)
|
||||
).toBe('ts');
|
||||
expect(
|
||||
getPlaybackMediaExtensionFromUrl(
|
||||
'http://portal.example/play/live.php?stream=123'
|
||||
)
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
it('keeps trusted media path extensions ahead of generic query metadata', () => {
|
||||
expect(
|
||||
getPlaybackMediaExtensionFromUrl(
|
||||
'http://cdn.example/archive/movie.mp4?container=m3u8'
|
||||
)
|
||||
).toBe('mp4');
|
||||
expect(
|
||||
getPlaybackMediaExtensionFromUrl(
|
||||
'http://cdn.example/live/channel.m3u8?format=mp4'
|
||||
)
|
||||
).toBe('m3u8');
|
||||
});
|
||||
|
||||
it('keeps explicit extension query metadata ahead of trusted media path extensions', () => {
|
||||
expect(
|
||||
getPlaybackMediaExtensionFromUrl(
|
||||
'http://cdn.example/live/channel.ts?extension=m3u8'
|
||||
)
|
||||
).toBe('m3u8');
|
||||
expect(
|
||||
getPlaybackMediaExtensionFromUrl(
|
||||
'http://cdn.example/live/channel.m3u8?ext=ts'
|
||||
)
|
||||
).toBe('ts');
|
||||
});
|
||||
|
||||
it('does not infer media extensions from generic type/output query metadata', () => {
|
||||
expect(
|
||||
getPlaybackMediaExtensionFromUrl(
|
||||
'http://portal.example/play/live.php?type=m3u8&stream=123'
|
||||
)
|
||||
).toBe('');
|
||||
expect(
|
||||
getPlaybackMediaExtensionFromUrl(
|
||||
'http://portal.example/play/live.php?output=ts&stream=123'
|
||||
)
|
||||
).toBe('');
|
||||
expect(
|
||||
getPlaybackMediaExtensionFromUrl(
|
||||
'http://portal.example/play/live.php?format=mpv&stream=123'
|
||||
)
|
||||
).toBe('');
|
||||
expect(
|
||||
getPlaybackMediaExtensionFromUrl(
|
||||
'http://portal.example/play/live.mpv?stream=123'
|
||||
)
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
it('detects codecs with limited Chromium browser-player support', () => {
|
||||
expect(
|
||||
getLikelyBrowserUnsupportedCodecLabels({
|
||||
audioCodecs: ['mp4a.40.2', 'ac-3', 'ec-3'],
|
||||
videoCodecs: ['avc1.64001f', 'hvc1.1.6.L93.B0'],
|
||||
})
|
||||
).toEqual(['HEVC', 'AC-3', 'E-AC-3']);
|
||||
});
|
||||
});
|
||||
|
||||
function classifyVhsPlaybackIssue(
|
||||
error: NativePlaybackErrorInput,
|
||||
metadata: PlaybackSourceMetadata
|
||||
): PlaybackDiagnostic {
|
||||
const classifier = (
|
||||
diagnostics as unknown as {
|
||||
readonly classifyVhsPlaybackIssue?: VhsClassifier;
|
||||
}
|
||||
).classifyVhsPlaybackIssue;
|
||||
|
||||
expect(classifier).toBeDefined();
|
||||
if (!classifier) {
|
||||
throw new Error('classifyVhsPlaybackIssue is not exported');
|
||||
}
|
||||
return classifier(error, metadata);
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import type {
|
||||
HlsPlaybackEvidence,
|
||||
MpegTsPlaybackEvidence,
|
||||
NativePlaybackErrorInput,
|
||||
PlaybackDiagnostic,
|
||||
PlaybackDiagnosticCode,
|
||||
PlaybackDiagnosticSource,
|
||||
PlaybackRuntimeSupport,
|
||||
PlaybackSourceMetadata,
|
||||
ShakaPlaybackEvidence,
|
||||
VhsPlaybackEngineType as VhsPlaybackEngineTypeValue,
|
||||
VhsPlaybackEvidence,
|
||||
} from './playback-diagnostics.model';
|
||||
import { PlaybackDiagnosticCode as DiagnosticCode } from './playback-diagnostics.model';
|
||||
import { HlsPlaybackDisposition } from './playback-diagnostics.model';
|
||||
import {
|
||||
MpegTsPlaybackEngineDetails,
|
||||
MpegTsPlaybackFailure,
|
||||
} from './mpegts-playback-evidence.model';
|
||||
import { PlaybackDiagnosticSource as DiagnosticSource } from './playback-diagnostics.model';
|
||||
import {
|
||||
VhsPlaybackEngineType,
|
||||
VhsPlaybackMediaErrorCode,
|
||||
} from './playback-diagnostics.model';
|
||||
import { getHlsPlaybackDiagnosticCode } from './hls-playback-evidence.util';
|
||||
import { isBrowserAccessFailure } from './playback-error-patterns.util';
|
||||
import { isLikelyContainerIssue } from './playback-media-source.util';
|
||||
import { createVhsPlaybackEvidence } from './vhs-playback-evidence.util';
|
||||
|
||||
export * from './playback-diagnostics.model';
|
||||
export { createHlsPlaybackEvidence } from './hls-playback-evidence.util';
|
||||
export { createMpegTsPlaybackEvidence } from './mpegts-playback-evidence.util';
|
||||
export { createVhsPlaybackEvidence } from './vhs-playback-evidence.util';
|
||||
export {
|
||||
createPlaybackSourceMetadata,
|
||||
getLikelyBrowserUnsupportedCodecLabels,
|
||||
getPlaybackMediaExtensionFromUrl,
|
||||
} from './playback-media-source.util';
|
||||
|
||||
const SOURCE_NOT_SUPPORTED_CODE = 4;
|
||||
const DECODE_ERROR_CODE = 3;
|
||||
const NETWORK_ERROR_CODE = 2;
|
||||
const NATIVE_ERROR_TYPE_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
|
||||
const VHS_NETWORK_ERROR_TYPES: ReadonlySet<VhsPlaybackEngineTypeValue> =
|
||||
new Set([
|
||||
VhsPlaybackEngineType.NetworkBadStatus,
|
||||
VhsPlaybackEngineType.NetworkRequestFailed,
|
||||
VhsPlaybackEngineType.NetworkRequestAborted,
|
||||
VhsPlaybackEngineType.NetworkRequestTimeout,
|
||||
VhsPlaybackEngineType.NetworkBodyParserFailed,
|
||||
]);
|
||||
|
||||
export function classifyNativePlaybackIssue(
|
||||
error: NativePlaybackErrorInput | null | undefined,
|
||||
metadata: PlaybackSourceMetadata
|
||||
): PlaybackDiagnostic {
|
||||
const nativeErrorCode = error?.code;
|
||||
const nativeErrorMessage = error?.message || undefined;
|
||||
const httpStatus = getNativeHttpStatus(error?.status);
|
||||
const nativeErrorType = getNativeErrorType(error?.metadata?.errorType);
|
||||
const lowerNativeErrorMessage = nativeErrorMessage?.toLowerCase() ?? '';
|
||||
|
||||
if (httpStatus !== undefined) {
|
||||
return createPlaybackDiagnostic({
|
||||
code: DiagnosticCode.NetworkError,
|
||||
source: DiagnosticSource.Native,
|
||||
metadata,
|
||||
nativeErrorCode,
|
||||
nativeErrorMessage,
|
||||
httpStatus,
|
||||
nativeErrorType,
|
||||
});
|
||||
}
|
||||
|
||||
if (nativeErrorCode === NETWORK_ERROR_CODE) {
|
||||
// Native MediaError details are often opaque for browser security
|
||||
// failures. Only classify browser access when the runtime exposes a
|
||||
// concrete CORS/mixed-content/CSP-style message.
|
||||
return createPlaybackDiagnostic({
|
||||
code: isBrowserAccessFailure(lowerNativeErrorMessage)
|
||||
? DiagnosticCode.BrowserAccessError
|
||||
: DiagnosticCode.NetworkError,
|
||||
source: DiagnosticSource.Native,
|
||||
metadata,
|
||||
nativeErrorCode,
|
||||
nativeErrorMessage,
|
||||
nativeErrorType,
|
||||
});
|
||||
}
|
||||
|
||||
if (nativeErrorCode === DECODE_ERROR_CODE) {
|
||||
return createPlaybackDiagnostic({
|
||||
code: DiagnosticCode.MediaDecodeError,
|
||||
source: DiagnosticSource.Native,
|
||||
metadata,
|
||||
nativeErrorCode,
|
||||
nativeErrorMessage,
|
||||
nativeErrorType,
|
||||
});
|
||||
}
|
||||
|
||||
if (nativeErrorCode === SOURCE_NOT_SUPPORTED_CODE) {
|
||||
return createPlaybackDiagnostic({
|
||||
code: isLikelyContainerIssue(metadata)
|
||||
? DiagnosticCode.UnsupportedContainer
|
||||
: DiagnosticCode.UnknownPlaybackError,
|
||||
source: DiagnosticSource.Native,
|
||||
metadata,
|
||||
nativeErrorCode,
|
||||
nativeErrorMessage,
|
||||
nativeErrorType,
|
||||
});
|
||||
}
|
||||
|
||||
return createPlaybackDiagnostic({
|
||||
code: DiagnosticCode.UnknownPlaybackError,
|
||||
source: DiagnosticSource.Native,
|
||||
metadata,
|
||||
nativeErrorCode,
|
||||
nativeErrorMessage,
|
||||
nativeErrorType,
|
||||
});
|
||||
}
|
||||
|
||||
export function classifyVhsPlaybackIssue(
|
||||
error: NativePlaybackErrorInput,
|
||||
metadata: PlaybackSourceMetadata
|
||||
): PlaybackDiagnostic {
|
||||
const evidence = createVhsPlaybackEvidence(error);
|
||||
const code = getVhsPlaybackDiagnosticCode(evidence, metadata);
|
||||
|
||||
return createPlaybackDiagnostic({
|
||||
code,
|
||||
source: DiagnosticSource.Vhs,
|
||||
metadata,
|
||||
nativeErrorCode:
|
||||
typeof evidence.mediaErrorCode === 'number'
|
||||
? evidence.mediaErrorCode
|
||||
: undefined,
|
||||
httpStatus: evidence.httpStatus,
|
||||
vhs: evidence,
|
||||
});
|
||||
}
|
||||
|
||||
export function classifyHlsPlaybackIssue(
|
||||
evidence: HlsPlaybackEvidence,
|
||||
metadata: PlaybackSourceMetadata
|
||||
): PlaybackDiagnostic | null {
|
||||
if (evidence.disposition === HlsPlaybackDisposition.Recoverable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPlaybackDiagnostic({
|
||||
code: getHlsPlaybackDiagnosticCode(evidence),
|
||||
source: DiagnosticSource.Hls,
|
||||
metadata,
|
||||
httpStatus: evidence.httpStatus,
|
||||
hls: evidence,
|
||||
});
|
||||
}
|
||||
|
||||
export function classifyMpegTsPlaybackIssue(
|
||||
evidence: MpegTsPlaybackEvidence,
|
||||
metadata: PlaybackSourceMetadata
|
||||
): PlaybackDiagnostic {
|
||||
return createPlaybackDiagnostic({
|
||||
code: getMpegTsPlaybackDiagnosticCode(evidence),
|
||||
source: DiagnosticSource.MpegTs,
|
||||
metadata,
|
||||
httpStatus: evidence.httpStatus,
|
||||
mpegTs: evidence,
|
||||
});
|
||||
}
|
||||
|
||||
export type MediaTypeSupportProbe = (mimeType: string) => boolean | undefined;
|
||||
|
||||
export function classifyUnsupportedHlsManifestCodecs(
|
||||
metadata: PlaybackSourceMetadata,
|
||||
isTypeSupported: MediaTypeSupportProbe
|
||||
): PlaybackDiagnostic | null {
|
||||
if (!hasCodecs(metadata)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const codecList = [...metadata.videoCodecs, ...metadata.audioCodecs];
|
||||
const mimeType = `video/mp4; codecs="${codecList.join(',')}"`;
|
||||
|
||||
if (isTypeSupported(mimeType) !== false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPlaybackDiagnostic({
|
||||
code: DiagnosticCode.UnsupportedCodec,
|
||||
source: DiagnosticSource.Source,
|
||||
metadata,
|
||||
details: mimeType,
|
||||
});
|
||||
}
|
||||
|
||||
export function createPlaybackDiagnostic(options: {
|
||||
readonly code: PlaybackDiagnosticCode;
|
||||
readonly source: PlaybackDiagnosticSource;
|
||||
readonly metadata: PlaybackSourceMetadata;
|
||||
readonly runtimeSupport?: PlaybackRuntimeSupport;
|
||||
readonly details?: string;
|
||||
readonly nativeErrorCode?: number;
|
||||
readonly nativeErrorMessage?: string;
|
||||
readonly httpStatus?: number;
|
||||
readonly nativeErrorType?: string;
|
||||
readonly vhs?: VhsPlaybackEvidence;
|
||||
readonly hls?: HlsPlaybackEvidence;
|
||||
readonly mpegTs?: MpegTsPlaybackEvidence;
|
||||
readonly shaka?: ShakaPlaybackEvidence;
|
||||
}): PlaybackDiagnostic {
|
||||
const {
|
||||
code,
|
||||
source,
|
||||
metadata,
|
||||
runtimeSupport,
|
||||
details,
|
||||
nativeErrorCode,
|
||||
nativeErrorMessage,
|
||||
httpStatus,
|
||||
nativeErrorType,
|
||||
vhs,
|
||||
hls,
|
||||
mpegTs,
|
||||
shaka,
|
||||
} = options;
|
||||
|
||||
return {
|
||||
code,
|
||||
source,
|
||||
sourceUrl: metadata.url,
|
||||
container: metadata.container,
|
||||
mimeType: metadata.mimeType,
|
||||
player: metadata.player,
|
||||
audioCodecs: metadata.audioCodecs,
|
||||
videoCodecs: metadata.videoCodecs,
|
||||
runtimeSupport,
|
||||
details: details || undefined,
|
||||
nativeErrorCode,
|
||||
nativeErrorMessage,
|
||||
httpStatus,
|
||||
nativeErrorType,
|
||||
vhs,
|
||||
hls,
|
||||
mpegTs,
|
||||
shaka,
|
||||
};
|
||||
}
|
||||
|
||||
function getMpegTsPlaybackDiagnosticCode(
|
||||
evidence: MpegTsPlaybackEvidence
|
||||
): PlaybackDiagnosticCode {
|
||||
switch (evidence.failure) {
|
||||
case MpegTsPlaybackFailure.Http:
|
||||
case MpegTsPlaybackFailure.Timeout:
|
||||
case MpegTsPlaybackFailure.Network:
|
||||
return DiagnosticCode.NetworkError;
|
||||
case MpegTsPlaybackFailure.TruncatedStream:
|
||||
case MpegTsPlaybackFailure.MediaSource:
|
||||
return DiagnosticCode.MediaDecodeError;
|
||||
case MpegTsPlaybackFailure.Codec:
|
||||
return DiagnosticCode.UnsupportedCodec;
|
||||
case MpegTsPlaybackFailure.Format:
|
||||
return evidence.engineDetails ===
|
||||
MpegTsPlaybackEngineDetails.FormatUnsupported
|
||||
? DiagnosticCode.UnsupportedContainer
|
||||
: DiagnosticCode.MediaDecodeError;
|
||||
default:
|
||||
return DiagnosticCode.UnknownPlaybackError;
|
||||
}
|
||||
}
|
||||
|
||||
function getVhsPlaybackDiagnosticCode(
|
||||
evidence: VhsPlaybackEvidence,
|
||||
metadata: PlaybackSourceMetadata
|
||||
): PlaybackDiagnosticCode {
|
||||
if (
|
||||
evidence.httpStatus !== undefined ||
|
||||
VHS_NETWORK_ERROR_TYPES.has(evidence.engineType) ||
|
||||
evidence.mediaErrorCode === VhsPlaybackMediaErrorCode.Network
|
||||
) {
|
||||
return DiagnosticCode.NetworkError;
|
||||
}
|
||||
|
||||
if (
|
||||
evidence.engineType ===
|
||||
VhsPlaybackEngineType.StreamingFailedToDecryptSegment ||
|
||||
evidence.mediaErrorCode === VhsPlaybackMediaErrorCode.Encrypted
|
||||
) {
|
||||
return DiagnosticCode.DrmOrEncryption;
|
||||
}
|
||||
|
||||
if (
|
||||
evidence.mediaErrorCode ===
|
||||
VhsPlaybackMediaErrorCode.SourceNotSupported &&
|
||||
isLikelyContainerIssue(metadata)
|
||||
) {
|
||||
return DiagnosticCode.UnsupportedContainer;
|
||||
}
|
||||
|
||||
return DiagnosticCode.UnknownPlaybackError;
|
||||
}
|
||||
|
||||
function getNativeHttpStatus(status: unknown): number | undefined {
|
||||
return typeof status === 'number' &&
|
||||
Number.isInteger(status) &&
|
||||
status >= 400 &&
|
||||
status <= 599
|
||||
? status
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function getNativeErrorType(errorType: unknown): string | undefined {
|
||||
return typeof errorType === 'string' &&
|
||||
NATIVE_ERROR_TYPE_PATTERN.test(errorType)
|
||||
? errorType
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function hasCodecs(metadata: PlaybackSourceMetadata): boolean {
|
||||
return metadata.audioCodecs.length > 0 || metadata.videoCodecs.length > 0;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export function isBrowserAccessFailure(details: string): boolean {
|
||||
return (
|
||||
details.includes('cors') ||
|
||||
details.includes('cross-origin') ||
|
||||
details.includes('cross origin') ||
|
||||
details.includes('access-control') ||
|
||||
details.includes('access control') ||
|
||||
details.includes('content security policy') ||
|
||||
details.includes('mixed content') ||
|
||||
details.includes('private network access') ||
|
||||
details.includes('blocked by content security') ||
|
||||
details.includes('blocked by cors') ||
|
||||
details.includes('blocked by mixed content') ||
|
||||
details.includes('not allowed to load local resource') ||
|
||||
details.includes('err_blocked') ||
|
||||
details.includes('err_cleartext')
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
UNSUPPORTED_CONTAINER_EXTENSIONS,
|
||||
getPlaybackMediaExtensionFromUrl,
|
||||
} from '@iptvnator/shared/m3u-utils';
|
||||
|
||||
import type {
|
||||
PlaybackSourceMetadata,
|
||||
PlaybackSourceMetadataInput,
|
||||
} from './playback-diagnostics.model';
|
||||
|
||||
// Media-extension normalization lives in @iptvnator/shared/m3u-utils so M3U
|
||||
// routing (isDashChannel) and engine selection always agree; re-exported here
|
||||
// to keep the playback-lib import surface stable.
|
||||
export { getPlaybackMediaExtensionFromUrl };
|
||||
|
||||
const UNSUPPORTED_CONTAINER_NAMES = new Set([
|
||||
...UNSUPPORTED_CONTAINER_EXTENSIONS,
|
||||
'matroska',
|
||||
'quicktime',
|
||||
'x-matroska',
|
||||
'x-msvideo',
|
||||
]);
|
||||
|
||||
const BROWSER_LIMITED_CODEC_PATTERNS: ReadonlyArray<{
|
||||
readonly label: string;
|
||||
readonly pattern: RegExp;
|
||||
}> = [
|
||||
{ label: 'HEVC', pattern: /^(hev1|hvc1|hevc|h265)/ },
|
||||
{ label: 'AC-3', pattern: /^(ac-?3|dac3)$/ },
|
||||
{ label: 'E-AC-3', pattern: /^(ec-?3|eac-?3|dec3)$/ },
|
||||
{ label: 'DTS', pattern: /^(dts|dtsc|dtse|dtsh|dtsl)/ },
|
||||
{ label: 'MPEG-2 Video', pattern: /^(mp2v|mpeg2video)/ },
|
||||
];
|
||||
|
||||
export function createPlaybackSourceMetadata(
|
||||
input: PlaybackSourceMetadataInput
|
||||
): PlaybackSourceMetadata {
|
||||
const extension = getPlaybackMediaExtensionFromUrl(input.url);
|
||||
const mimeType = input.mimeType?.trim() || undefined;
|
||||
|
||||
return {
|
||||
url: input.url,
|
||||
extension,
|
||||
container: extension || inferContainerFromMimeType(mimeType),
|
||||
mimeType,
|
||||
player: input.player,
|
||||
audioCodecs: normalizeCodecs(input.audioCodecs),
|
||||
videoCodecs: normalizeCodecs(input.videoCodecs),
|
||||
};
|
||||
}
|
||||
|
||||
export function getLikelyBrowserUnsupportedCodecLabels(
|
||||
metadata: Pick<PlaybackSourceMetadata, 'audioCodecs' | 'videoCodecs'>
|
||||
): string[] {
|
||||
const codecs = [...metadata.videoCodecs, ...metadata.audioCodecs].map(
|
||||
normalizeToken
|
||||
);
|
||||
|
||||
return BROWSER_LIMITED_CODEC_PATTERNS.filter(({ pattern }) =>
|
||||
codecs.some((codec) => pattern.test(codec))
|
||||
).map(({ label }) => label);
|
||||
}
|
||||
|
||||
export function isLikelyContainerIssue(
|
||||
metadata: PlaybackSourceMetadata
|
||||
): boolean {
|
||||
return (
|
||||
UNSUPPORTED_CONTAINER_NAMES.has(metadata.extension) ||
|
||||
UNSUPPORTED_CONTAINER_NAMES.has(metadata.container) ||
|
||||
metadata.mimeType === 'video/matroska'
|
||||
);
|
||||
}
|
||||
|
||||
export function mergeCodecMetadata(
|
||||
metadata: PlaybackSourceMetadata,
|
||||
codecs: Pick<PlaybackSourceMetadataInput, 'audioCodecs' | 'videoCodecs'>
|
||||
): PlaybackSourceMetadata {
|
||||
const audioCodecs = normalizeCodecs([
|
||||
...metadata.audioCodecs,
|
||||
...(codecs.audioCodecs ?? []),
|
||||
]);
|
||||
const videoCodecs = normalizeCodecs([
|
||||
...metadata.videoCodecs,
|
||||
...(codecs.videoCodecs ?? []),
|
||||
]);
|
||||
|
||||
return {
|
||||
...metadata,
|
||||
audioCodecs,
|
||||
videoCodecs,
|
||||
};
|
||||
}
|
||||
|
||||
function inferContainerFromMimeType(mimeType: string | undefined): string {
|
||||
if (!mimeType) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const subtype = mimeType.split(';')[0]?.split('/')[1] ?? '';
|
||||
return subtype.toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeCodecs(codecs: readonly string[] | undefined): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
(codecs ?? [])
|
||||
.map((codec) => codec.trim())
|
||||
.filter((codec) => codec.length > 0)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeToken(value: string | undefined): string {
|
||||
return value?.trim().toLowerCase() ?? '';
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import {
|
||||
InlinePlaybackPlayer,
|
||||
PlaybackDiagnosticCode,
|
||||
PlaybackDiagnosticSource,
|
||||
} from './playback-diagnostics.model';
|
||||
import { createPlaybackSourceMetadata } from './playback-diagnostics.util';
|
||||
import {
|
||||
classifyShakaPlaybackIssue,
|
||||
createUnsupportedDrmDiagnostic,
|
||||
} from './shaka-error-classifier';
|
||||
|
||||
interface StructuredShakaDiagnostic {
|
||||
readonly code: string;
|
||||
readonly details?: string;
|
||||
readonly httpStatus?: number;
|
||||
readonly shaka?: {
|
||||
readonly severity: string;
|
||||
readonly category: string;
|
||||
readonly engineCode: number | string;
|
||||
readonly disposition: string;
|
||||
readonly stage: string;
|
||||
readonly failure: string;
|
||||
readonly httpStatus?: number;
|
||||
};
|
||||
}
|
||||
|
||||
type StructuredClassifier = (
|
||||
error: Record<string, unknown> | null | undefined,
|
||||
sourceMetadata: typeof metadata,
|
||||
disposition: 'terminal' | 'recoverable'
|
||||
) => StructuredShakaDiagnostic | null;
|
||||
|
||||
const metadata = createPlaybackSourceMetadata({
|
||||
url: 'http://example.com/stream.mpd',
|
||||
mimeType: 'application/dash+xml',
|
||||
player: InlinePlaybackPlayer.Html5,
|
||||
});
|
||||
|
||||
const classify = classifyShakaPlaybackIssue as StructuredClassifier;
|
||||
|
||||
describe('classifyShakaPlaybackIssue', () => {
|
||||
it.each([
|
||||
['DRM category/code pair', { severity: 2, category: 6, code: 6001 }],
|
||||
['manifest key-system code', { severity: 2, category: 4, code: 4008 }],
|
||||
])('maps exact %s to DrmOrEncryption', (_label, error) => {
|
||||
const issue = classify(error, metadata, 'terminal');
|
||||
expect(issue).not.toBeNull();
|
||||
expect(issue?.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
|
||||
expect(issue?.shaka?.failure).toBe('drm');
|
||||
});
|
||||
|
||||
it('maps an exact terminal network error and retains only safe status evidence', () => {
|
||||
const issue = classify(
|
||||
{
|
||||
severity: 1,
|
||||
category: 1,
|
||||
code: 1001,
|
||||
message:
|
||||
'Blocked by CORS at https://provider.example/?token=secret',
|
||||
data: [
|
||||
'https://provider.example/manifest.mpd?token=secret',
|
||||
503,
|
||||
'provider response secret',
|
||||
{ Authorization: 'Bearer secret' },
|
||||
],
|
||||
},
|
||||
metadata,
|
||||
'terminal'
|
||||
);
|
||||
|
||||
expect(issue).toEqual(
|
||||
expect.objectContaining({
|
||||
code: PlaybackDiagnosticCode.NetworkError,
|
||||
source: PlaybackDiagnosticSource.Shaka,
|
||||
httpStatus: 503,
|
||||
details: undefined,
|
||||
shaka: {
|
||||
severity: 'recoverable',
|
||||
category: 'network',
|
||||
engineCode: 1001,
|
||||
disposition: 'terminal',
|
||||
stage: 'unknown',
|
||||
failure: 'network',
|
||||
httpStatus: 503,
|
||||
},
|
||||
})
|
||||
);
|
||||
const serialized = JSON.stringify(issue);
|
||||
expect(serialized).not.toContain('secret');
|
||||
expect(serialized).not.toContain('provider.example');
|
||||
expect(serialized).not.toContain('Authorization');
|
||||
expect(serialized).not.toContain('response');
|
||||
});
|
||||
|
||||
it.each([3014, 3015, 3016])(
|
||||
'maps exact media pipeline code %s to MediaDecodeError',
|
||||
(code) => {
|
||||
const issue = classify(
|
||||
{ severity: 2, category: 3, code },
|
||||
metadata,
|
||||
'terminal'
|
||||
);
|
||||
expect(issue?.code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
|
||||
}
|
||||
);
|
||||
|
||||
it('maps only the exact DASH unsupported-container code to UnsupportedContainer', () => {
|
||||
const issue = classify(
|
||||
{ severity: 2, category: 4, code: 4006 },
|
||||
metadata,
|
||||
'terminal'
|
||||
);
|
||||
expect(issue?.code).toBe(PlaybackDiagnosticCode.UnsupportedContainer);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'manifest parsing',
|
||||
{ severity: 2, category: 4, code: 4001 },
|
||||
'manifest',
|
||||
],
|
||||
[
|
||||
'ambiguous browser content support',
|
||||
{ severity: 2, category: 4, code: 4032 },
|
||||
'media',
|
||||
],
|
||||
[
|
||||
'ambiguous restrictions',
|
||||
{ severity: 2, category: 4, code: 4012 },
|
||||
'unknown',
|
||||
],
|
||||
[
|
||||
'generic media parsing',
|
||||
{ severity: 2, category: 3, code: 3005 },
|
||||
'media',
|
||||
],
|
||||
[
|
||||
'mismatched category/code pair',
|
||||
{ severity: 2, category: 1, code: 6001 },
|
||||
'unknown',
|
||||
],
|
||||
[
|
||||
'mismatched media code pair',
|
||||
{ severity: 2, category: 1, code: 3014 },
|
||||
'unknown',
|
||||
],
|
||||
[
|
||||
'mismatched container code pair',
|
||||
{ severity: 2, category: 3, code: 4006 },
|
||||
'unknown',
|
||||
],
|
||||
[
|
||||
'provider extension values',
|
||||
{ severity: 3, category: 11, code: 123456 },
|
||||
'unknown',
|
||||
],
|
||||
])(
|
||||
'keeps %s as UnknownPlaybackError with structured failure=%s',
|
||||
(_label, error, failure) => {
|
||||
const issue = classify(
|
||||
{
|
||||
...error,
|
||||
message:
|
||||
'CORS codec DRM unsupported container license request',
|
||||
data: [{ provider: 'secret-payload' }],
|
||||
},
|
||||
metadata,
|
||||
'terminal'
|
||||
);
|
||||
|
||||
expect(issue?.code).toBe(
|
||||
PlaybackDiagnosticCode.UnknownPlaybackError
|
||||
);
|
||||
expect(issue?.shaka?.failure).toBe(failure);
|
||||
expect(issue?.details).toBeUndefined();
|
||||
}
|
||||
);
|
||||
|
||||
it('does not turn a recoverable Shaka event into a terminal diagnostic', () => {
|
||||
const issue = classify(
|
||||
{ severity: 1, category: 1, code: 1002 },
|
||||
metadata,
|
||||
'recoverable'
|
||||
);
|
||||
expect(issue).toBeNull();
|
||||
});
|
||||
|
||||
it('creates unknown structured evidence when no public Shaka error is available', () => {
|
||||
const issue = classify(null, metadata, 'terminal');
|
||||
expect(issue).toEqual(
|
||||
expect.objectContaining({
|
||||
code: PlaybackDiagnosticCode.UnknownPlaybackError,
|
||||
details: undefined,
|
||||
shaka: {
|
||||
severity: 'unknown',
|
||||
category: 'unknown',
|
||||
engineCode: 'unknown',
|
||||
disposition: 'terminal',
|
||||
stage: 'unknown',
|
||||
failure: 'unknown',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createUnsupportedDrmDiagnostic', () => {
|
||||
it('creates a safe DRM diagnostic without echoing provider license data', () => {
|
||||
const secret = 'unsupported-drm-secret';
|
||||
const issue = createUnsupportedDrmDiagnostic(
|
||||
`https://provider.example/license?token=${secret}`,
|
||||
metadata
|
||||
);
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
|
||||
expect(issue.source).toBe(PlaybackDiagnosticSource.Shaka);
|
||||
expect(issue.details).toBe('Unsupported DRM license configuration');
|
||||
expect(JSON.stringify(issue)).not.toContain(secret);
|
||||
expect(JSON.stringify(issue)).not.toContain('provider.example');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import type {
|
||||
PlaybackDiagnostic,
|
||||
PlaybackDiagnosticCode,
|
||||
PlaybackSourceMetadata,
|
||||
ShakaPlaybackDisposition,
|
||||
ShakaPlaybackEvidence,
|
||||
} from './playback-diagnostics.model';
|
||||
import {
|
||||
PlaybackDiagnosticCode as DiagnosticCode,
|
||||
PlaybackDiagnosticSource as DiagnosticSource,
|
||||
ShakaPlaybackCategory,
|
||||
ShakaPlaybackDisposition as ShakaDisposition,
|
||||
ShakaPlaybackFailure,
|
||||
PlaybackRuntimeSupport,
|
||||
} from './playback-diagnostics.model';
|
||||
import { createPlaybackDiagnostic } from './playback-diagnostics.util';
|
||||
import { SHAKA_ERROR_CODE } from './shaka-error-contract';
|
||||
import { createShakaPlaybackEvidence } from './shaka-playback-evidence.util';
|
||||
import type { ShakaErrorLike } from './shaka-error.types';
|
||||
|
||||
export {
|
||||
SHAKA_DIAGNOSTIC_VERSION,
|
||||
SHAKA_ERROR_CATEGORY,
|
||||
SHAKA_ERROR_CODE,
|
||||
SHAKA_ERROR_SEVERITY,
|
||||
} from './shaka-error-contract';
|
||||
export { createShakaPlaybackEvidence } from './shaka-playback-evidence.util';
|
||||
|
||||
const MEDIA_DECODE_CODES = new Set<number>([
|
||||
SHAKA_ERROR_CODE.MEDIA_SOURCE_OPERATION_FAILED,
|
||||
SHAKA_ERROR_CODE.MEDIA_SOURCE_OPERATION_THREW,
|
||||
SHAKA_ERROR_CODE.VIDEO_ERROR,
|
||||
]);
|
||||
|
||||
export function classifyShakaPlaybackIssue(
|
||||
error: Partial<ShakaErrorLike> | null | undefined,
|
||||
metadata: PlaybackSourceMetadata,
|
||||
disposition: ShakaPlaybackDisposition
|
||||
): PlaybackDiagnostic | null {
|
||||
const evidence = createShakaPlaybackEvidence(error, disposition);
|
||||
if (evidence.disposition === ShakaDisposition.Recoverable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPlaybackDiagnostic({
|
||||
code: getDiagnosticCode(evidence),
|
||||
source: DiagnosticSource.Shaka,
|
||||
metadata,
|
||||
httpStatus: evidence.httpStatus,
|
||||
shaka: evidence,
|
||||
});
|
||||
}
|
||||
|
||||
function getDiagnosticCode(
|
||||
evidence: ShakaPlaybackEvidence
|
||||
): PlaybackDiagnosticCode {
|
||||
if (evidence.failure === ShakaPlaybackFailure.Network) {
|
||||
return DiagnosticCode.NetworkError;
|
||||
}
|
||||
if (evidence.failure === ShakaPlaybackFailure.Drm) {
|
||||
return DiagnosticCode.DrmOrEncryption;
|
||||
}
|
||||
if (
|
||||
evidence.category === ShakaPlaybackCategory.Manifest &&
|
||||
evidence.engineCode === SHAKA_ERROR_CODE.DASH_UNSUPPORTED_CONTAINER
|
||||
) {
|
||||
return DiagnosticCode.UnsupportedContainer;
|
||||
}
|
||||
if (
|
||||
evidence.category === ShakaPlaybackCategory.Media &&
|
||||
typeof evidence.engineCode === 'number' &&
|
||||
MEDIA_DECODE_CODES.has(evidence.engineCode)
|
||||
) {
|
||||
return DiagnosticCode.MediaDecodeError;
|
||||
}
|
||||
return DiagnosticCode.UnknownPlaybackError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostic for `.mpd` channels that declare a DRM system the app cannot
|
||||
* handle. Provider-supplied license strings are intentionally not retained.
|
||||
*/
|
||||
export function createUnsupportedDrmDiagnostic(
|
||||
_licenseType: string,
|
||||
metadata: PlaybackSourceMetadata
|
||||
): PlaybackDiagnostic {
|
||||
return createPlaybackDiagnostic({
|
||||
code: DiagnosticCode.DrmOrEncryption,
|
||||
source: DiagnosticSource.Shaka,
|
||||
metadata,
|
||||
details: 'Unsupported DRM license configuration',
|
||||
});
|
||||
}
|
||||
|
||||
/** Exact app-owned evidence for Shaka's browser-support preflight. */
|
||||
export function createShakaBrowserUnsupportedDiagnostic(
|
||||
metadata: PlaybackSourceMetadata
|
||||
): PlaybackDiagnostic {
|
||||
return createPlaybackDiagnostic({
|
||||
code: DiagnosticCode.UnknownPlaybackError,
|
||||
source: DiagnosticSource.Shaka,
|
||||
metadata,
|
||||
runtimeSupport: PlaybackRuntimeSupport.ShakaBrowserUnsupported,
|
||||
shaka: createShakaPlaybackEvidence(null, ShakaDisposition.Terminal),
|
||||
});
|
||||
}
|
||||
|
||||
/** Narrows an unknown rejection to a Shaka-error-like shape, if it is one. */
|
||||
export function asShakaError(error: unknown): Partial<ShakaErrorLike> | null {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = error as Partial<ShakaErrorLike>;
|
||||
return typeof candidate.code === 'number' ||
|
||||
typeof candidate.category === 'number' ||
|
||||
typeof candidate.severity === 'number'
|
||||
? candidate
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Public Shaka error values audited against the locked 5.2.2 runtime.
|
||||
*
|
||||
* Keep the version assertion in the contract spec: a Shaka upgrade must stop
|
||||
* here for a new audit instead of silently accepting new error layouts.
|
||||
*/
|
||||
export const SHAKA_DIAGNOSTIC_VERSION = 'v5.2.2';
|
||||
|
||||
export const SHAKA_ERROR_SEVERITY = {
|
||||
RECOVERABLE: 1,
|
||||
CRITICAL: 2,
|
||||
} as const;
|
||||
|
||||
export const SHAKA_ERROR_CATEGORY = {
|
||||
NETWORK: 1,
|
||||
TEXT: 2,
|
||||
MEDIA: 3,
|
||||
MANIFEST: 4,
|
||||
STREAMING: 5,
|
||||
DRM: 6,
|
||||
PLAYER: 7,
|
||||
CAST: 8,
|
||||
STORAGE: 9,
|
||||
ADS: 10,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Public online-playback codes whose numeric value may cross the evidence
|
||||
* boundary. Retired gaps and offline/cast/ad-only codes are intentionally
|
||||
* absent.
|
||||
*/
|
||||
export const SHAKA_ERROR_CODE = {
|
||||
UNSUPPORTED_SCHEME: 1000,
|
||||
BAD_HTTP_STATUS: 1001,
|
||||
HTTP_ERROR: 1002,
|
||||
TIMEOUT: 1003,
|
||||
MALFORMED_DATA_URI: 1004,
|
||||
REQUEST_FILTER_ERROR: 1006,
|
||||
RESPONSE_FILTER_ERROR: 1007,
|
||||
SEGMENT_MISSING: 1011,
|
||||
// Text parsing reachable from DASH playback. HLS-only and app-uninvoked
|
||||
// external-track/src= codes stay outside this boundary.
|
||||
INVALID_TEXT_HEADER: 2000,
|
||||
INVALID_TEXT_CUE: 2001,
|
||||
UNABLE_TO_DETECT_ENCODING: 2003,
|
||||
BAD_ENCODING: 2004,
|
||||
INVALID_XML: 2005,
|
||||
INVALID_MP4_TTML: 2007,
|
||||
INVALID_MP4_VTT: 2008,
|
||||
INVALID_MP4_CEA: 2010,
|
||||
BUFFER_READ_OUT_OF_BOUNDS: 3000,
|
||||
JS_INTEGER_OVERFLOW: 3001,
|
||||
EBML_OVERFLOW: 3002,
|
||||
EBML_BAD_FLOATING_POINT_SIZE: 3003,
|
||||
MP4_SIDX_WRONG_BOX_TYPE: 3004,
|
||||
MP4_SIDX_INVALID_TIMESCALE: 3005,
|
||||
MP4_SIDX_TYPE_NOT_SUPPORTED: 3006,
|
||||
WEBM_CUES_ELEMENT_MISSING: 3007,
|
||||
WEBM_EBML_HEADER_ELEMENT_MISSING: 3008,
|
||||
WEBM_SEGMENT_ELEMENT_MISSING: 3009,
|
||||
WEBM_INFO_ELEMENT_MISSING: 3010,
|
||||
WEBM_DURATION_ELEMENT_MISSING: 3011,
|
||||
WEBM_CUE_TRACK_POSITIONS_ELEMENT_MISSING: 3012,
|
||||
WEBM_CUE_TIME_ELEMENT_MISSING: 3013,
|
||||
MEDIA_SOURCE_OPERATION_FAILED: 3014,
|
||||
MEDIA_SOURCE_OPERATION_THREW: 3015,
|
||||
VIDEO_ERROR: 3016,
|
||||
QUOTA_EXCEEDED_ERROR: 3017,
|
||||
TRANSMUXING_FAILED: 3018,
|
||||
CONTENT_TRANSFORMATION_FAILED: 3019,
|
||||
TRANSMUXING_NO_VIDEO_DATA: 3023,
|
||||
STREAMING_NOT_ALLOWED: 3024,
|
||||
BUFFER_WRITE_OUT_OF_BOUNDS: 3025,
|
||||
UNABLE_TO_GUESS_MANIFEST_TYPE: 4000,
|
||||
DASH_INVALID_XML: 4001,
|
||||
DASH_NO_SEGMENT_INFO: 4002,
|
||||
DASH_EMPTY_ADAPTATION_SET: 4003,
|
||||
DASH_EMPTY_PERIOD: 4004,
|
||||
DASH_WEBM_MISSING_INIT: 4005,
|
||||
DASH_UNSUPPORTED_CONTAINER: 4006,
|
||||
DASH_PSSH_BAD_ENCODING: 4007,
|
||||
DASH_NO_COMMON_KEY_SYSTEM: 4008,
|
||||
DASH_MULTIPLE_KEY_IDS_NOT_SUPPORTED: 4009,
|
||||
DASH_CONFLICTING_KEY_IDS: 4010,
|
||||
RESTRICTIONS_CANNOT_BE_MET: 4012,
|
||||
DASH_DUPLICATE_REPRESENTATION_ID: 4018,
|
||||
DASH_UNSUPPORTED_XLINK_ACTUATE: 4027,
|
||||
DASH_XLINK_DEPTH_LIMIT: 4028,
|
||||
CONTENT_UNSUPPORTED_BY_BROWSER: 4032,
|
||||
NO_VARIANTS: 4036,
|
||||
PERIOD_FLATTENING_FAILED: 4037,
|
||||
INCONSISTENT_DRM_ACROSS_PERIODS: 4038,
|
||||
NO_WEB_CRYPTO_API: 4042,
|
||||
AES_128_INVALID_IV_LENGTH: 4048,
|
||||
AES_128_INVALID_KEY_LENGTH: 4049,
|
||||
DASH_CONFLICTING_AES_128: 4050,
|
||||
DASH_UNSUPPORTED_AES_128: 4051,
|
||||
DASH_INVALID_PATCH: 4052,
|
||||
DASH_MSE_ENCRYPTED_LEGACY_APPLE_MEDIA_KEYS_NOT_SUPPORTED: 4054,
|
||||
WEBTRANSPORT_NOT_AVAILABLE: 4056,
|
||||
WEBTRANSPORT_INITIALIZATION_FAILED: 4057,
|
||||
DASH_INVALID_JSON: 4061,
|
||||
DASH_UNSUPPORTED_ESSENTIAL_PROPERTY: 4063,
|
||||
STREAMING_ENGINE_STARTUP_INVALID_STATE: 5006,
|
||||
NO_RECOGNIZED_KEY_SYSTEMS: 6000,
|
||||
REQUESTED_KEY_SYSTEM_CONFIG_UNAVAILABLE: 6001,
|
||||
FAILED_TO_CREATE_CDM: 6002,
|
||||
FAILED_TO_ATTACH_TO_VIDEO: 6003,
|
||||
INVALID_SERVER_CERTIFICATE: 6004,
|
||||
FAILED_TO_CREATE_SESSION: 6005,
|
||||
FAILED_TO_GENERATE_LICENSE_REQUEST: 6006,
|
||||
LICENSE_REQUEST_FAILED: 6007,
|
||||
LICENSE_RESPONSE_REJECTED: 6008,
|
||||
ENCRYPTED_CONTENT_WITHOUT_DRM_INFO: 6010,
|
||||
NO_LICENSE_SERVER_GIVEN: 6012,
|
||||
OFFLINE_SESSION_REMOVED: 6013,
|
||||
EXPIRED: 6014,
|
||||
SERVER_CERTIFICATE_REQUIRED: 6015,
|
||||
INIT_DATA_TRANSFORM_ERROR: 6016,
|
||||
SERVER_CERTIFICATE_REQUEST_FAILED: 6017,
|
||||
MIN_HDCP_VERSION_NOT_MATCH: 6018,
|
||||
ERROR_CHECKING_HDCP_VERSION: 6019,
|
||||
MISSING_EME_SUPPORT: 6020,
|
||||
LOAD_INTERRUPTED: 7000,
|
||||
} as const;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ShakaPlaybackDisposition as ShakaDisposition } from './playback-diagnostics.model';
|
||||
import {
|
||||
SHAKA_ERROR_CATEGORY,
|
||||
SHAKA_ERROR_CODE,
|
||||
SHAKA_ERROR_SEVERITY,
|
||||
} from './shaka-error-contract';
|
||||
import type { ShakaErrorLike } from './shaka-error.types';
|
||||
|
||||
export function getShakaErrorEventDisposition(
|
||||
error: Partial<ShakaErrorLike> | null
|
||||
): (typeof ShakaDisposition)[keyof typeof ShakaDisposition] | null {
|
||||
if (error?.severity === SHAKA_ERROR_SEVERITY.RECOVERABLE) {
|
||||
return ShakaDisposition.Recoverable;
|
||||
}
|
||||
if (error?.severity === SHAKA_ERROR_SEVERITY.CRITICAL) {
|
||||
return ShakaDisposition.Terminal;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isShakaLoadInterrupted(
|
||||
error: Partial<ShakaErrorLike> | null
|
||||
): boolean {
|
||||
return (
|
||||
error?.severity === SHAKA_ERROR_SEVERITY.CRITICAL &&
|
||||
error.category === SHAKA_ERROR_CATEGORY.PLAYER &&
|
||||
error.code === SHAKA_ERROR_CODE.LOAD_INTERRUPTED
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { SHAKA_ERROR_CODE } from './shaka-error-contract';
|
||||
|
||||
export const SHAKA_PUBLIC_CODES = new Set<number>(
|
||||
Object.values(SHAKA_ERROR_CODE)
|
||||
);
|
||||
|
||||
export const SHAKA_NETWORK_CODES = new Set<number>([
|
||||
SHAKA_ERROR_CODE.UNSUPPORTED_SCHEME,
|
||||
SHAKA_ERROR_CODE.BAD_HTTP_STATUS,
|
||||
SHAKA_ERROR_CODE.HTTP_ERROR,
|
||||
SHAKA_ERROR_CODE.TIMEOUT,
|
||||
SHAKA_ERROR_CODE.MALFORMED_DATA_URI,
|
||||
SHAKA_ERROR_CODE.REQUEST_FILTER_ERROR,
|
||||
SHAKA_ERROR_CODE.RESPONSE_FILTER_ERROR,
|
||||
SHAKA_ERROR_CODE.SEGMENT_MISSING,
|
||||
]);
|
||||
|
||||
export const SHAKA_MEDIA_CODES = new Set<number>([
|
||||
SHAKA_ERROR_CODE.BUFFER_READ_OUT_OF_BOUNDS,
|
||||
SHAKA_ERROR_CODE.JS_INTEGER_OVERFLOW,
|
||||
SHAKA_ERROR_CODE.EBML_OVERFLOW,
|
||||
SHAKA_ERROR_CODE.EBML_BAD_FLOATING_POINT_SIZE,
|
||||
SHAKA_ERROR_CODE.MP4_SIDX_WRONG_BOX_TYPE,
|
||||
SHAKA_ERROR_CODE.MP4_SIDX_INVALID_TIMESCALE,
|
||||
SHAKA_ERROR_CODE.MP4_SIDX_TYPE_NOT_SUPPORTED,
|
||||
SHAKA_ERROR_CODE.WEBM_CUES_ELEMENT_MISSING,
|
||||
SHAKA_ERROR_CODE.WEBM_EBML_HEADER_ELEMENT_MISSING,
|
||||
SHAKA_ERROR_CODE.WEBM_SEGMENT_ELEMENT_MISSING,
|
||||
SHAKA_ERROR_CODE.WEBM_INFO_ELEMENT_MISSING,
|
||||
SHAKA_ERROR_CODE.WEBM_DURATION_ELEMENT_MISSING,
|
||||
SHAKA_ERROR_CODE.WEBM_CUE_TRACK_POSITIONS_ELEMENT_MISSING,
|
||||
SHAKA_ERROR_CODE.WEBM_CUE_TIME_ELEMENT_MISSING,
|
||||
SHAKA_ERROR_CODE.MEDIA_SOURCE_OPERATION_FAILED,
|
||||
SHAKA_ERROR_CODE.MEDIA_SOURCE_OPERATION_THREW,
|
||||
SHAKA_ERROR_CODE.VIDEO_ERROR,
|
||||
SHAKA_ERROR_CODE.QUOTA_EXCEEDED_ERROR,
|
||||
SHAKA_ERROR_CODE.TRANSMUXING_FAILED,
|
||||
SHAKA_ERROR_CODE.CONTENT_TRANSFORMATION_FAILED,
|
||||
SHAKA_ERROR_CODE.TRANSMUXING_NO_VIDEO_DATA,
|
||||
SHAKA_ERROR_CODE.STREAMING_NOT_ALLOWED,
|
||||
SHAKA_ERROR_CODE.BUFFER_WRITE_OUT_OF_BOUNDS,
|
||||
]);
|
||||
|
||||
export const SHAKA_MANIFEST_DRM_CODES = new Set<number>([
|
||||
SHAKA_ERROR_CODE.DASH_PSSH_BAD_ENCODING,
|
||||
SHAKA_ERROR_CODE.DASH_NO_COMMON_KEY_SYSTEM,
|
||||
SHAKA_ERROR_CODE.DASH_MULTIPLE_KEY_IDS_NOT_SUPPORTED,
|
||||
SHAKA_ERROR_CODE.DASH_CONFLICTING_KEY_IDS,
|
||||
SHAKA_ERROR_CODE.INCONSISTENT_DRM_ACROSS_PERIODS,
|
||||
SHAKA_ERROR_CODE.NO_WEB_CRYPTO_API,
|
||||
SHAKA_ERROR_CODE.AES_128_INVALID_IV_LENGTH,
|
||||
SHAKA_ERROR_CODE.AES_128_INVALID_KEY_LENGTH,
|
||||
SHAKA_ERROR_CODE.DASH_CONFLICTING_AES_128,
|
||||
SHAKA_ERROR_CODE.DASH_UNSUPPORTED_AES_128,
|
||||
SHAKA_ERROR_CODE.DASH_MSE_ENCRYPTED_LEGACY_APPLE_MEDIA_KEYS_NOT_SUPPORTED,
|
||||
]);
|
||||
|
||||
export const SHAKA_MANIFEST_MEDIA_CODES = new Set<number>([
|
||||
SHAKA_ERROR_CODE.DASH_UNSUPPORTED_CONTAINER,
|
||||
SHAKA_ERROR_CODE.CONTENT_UNSUPPORTED_BY_BROWSER,
|
||||
]);
|
||||
|
||||
export const SHAKA_AMBIGUOUS_MANIFEST_CODES = new Set<number>([
|
||||
SHAKA_ERROR_CODE.RESTRICTIONS_CANNOT_BE_MET,
|
||||
]);
|
||||
|
||||
export const SHAKA_MANIFEST_CODES = new Set<number>(
|
||||
Object.values(SHAKA_ERROR_CODE).filter(
|
||||
(code) => code >= 4000 && code < 5000
|
||||
)
|
||||
);
|
||||
|
||||
export const SHAKA_DRM_CODES = new Set<number>(
|
||||
Object.values(SHAKA_ERROR_CODE).filter(
|
||||
(code) => code >= 6000 && code < 7000
|
||||
)
|
||||
);
|
||||
|
||||
export const SHAKA_SEGMENT_STAGE_CODES = new Set<number>([
|
||||
SHAKA_ERROR_CODE.SEGMENT_MISSING,
|
||||
SHAKA_ERROR_CODE.BUFFER_READ_OUT_OF_BOUNDS,
|
||||
SHAKA_ERROR_CODE.JS_INTEGER_OVERFLOW,
|
||||
SHAKA_ERROR_CODE.EBML_OVERFLOW,
|
||||
SHAKA_ERROR_CODE.EBML_BAD_FLOATING_POINT_SIZE,
|
||||
SHAKA_ERROR_CODE.MP4_SIDX_WRONG_BOX_TYPE,
|
||||
SHAKA_ERROR_CODE.MP4_SIDX_INVALID_TIMESCALE,
|
||||
SHAKA_ERROR_CODE.MP4_SIDX_TYPE_NOT_SUPPORTED,
|
||||
SHAKA_ERROR_CODE.WEBM_CUES_ELEMENT_MISSING,
|
||||
SHAKA_ERROR_CODE.WEBM_EBML_HEADER_ELEMENT_MISSING,
|
||||
SHAKA_ERROR_CODE.WEBM_SEGMENT_ELEMENT_MISSING,
|
||||
SHAKA_ERROR_CODE.WEBM_INFO_ELEMENT_MISSING,
|
||||
SHAKA_ERROR_CODE.WEBM_DURATION_ELEMENT_MISSING,
|
||||
SHAKA_ERROR_CODE.WEBM_CUE_TRACK_POSITIONS_ELEMENT_MISSING,
|
||||
SHAKA_ERROR_CODE.WEBM_CUE_TIME_ELEMENT_MISSING,
|
||||
]);
|
||||
|
||||
export const SHAKA_MEDIA_STAGE_CODES = new Set<number>([
|
||||
SHAKA_ERROR_CODE.MEDIA_SOURCE_OPERATION_FAILED,
|
||||
SHAKA_ERROR_CODE.MEDIA_SOURCE_OPERATION_THREW,
|
||||
SHAKA_ERROR_CODE.VIDEO_ERROR,
|
||||
SHAKA_ERROR_CODE.QUOTA_EXCEEDED_ERROR,
|
||||
SHAKA_ERROR_CODE.TRANSMUXING_FAILED,
|
||||
SHAKA_ERROR_CODE.CONTENT_TRANSFORMATION_FAILED,
|
||||
SHAKA_ERROR_CODE.TRANSMUXING_NO_VIDEO_DATA,
|
||||
SHAKA_ERROR_CODE.STREAMING_NOT_ALLOWED,
|
||||
SHAKA_ERROR_CODE.BUFFER_WRITE_OUT_OF_BOUNDS,
|
||||
]);
|
||||
|
||||
export const SHAKA_LICENSE_STAGE_CODES = new Set<number>([
|
||||
SHAKA_ERROR_CODE.FAILED_TO_GENERATE_LICENSE_REQUEST,
|
||||
SHAKA_ERROR_CODE.LICENSE_REQUEST_FAILED,
|
||||
SHAKA_ERROR_CODE.LICENSE_RESPONSE_REJECTED,
|
||||
SHAKA_ERROR_CODE.NO_LICENSE_SERVER_GIVEN,
|
||||
SHAKA_ERROR_CODE.EXPIRED,
|
||||
SHAKA_ERROR_CODE.SERVER_CERTIFICATE_REQUEST_FAILED,
|
||||
]);
|
||||
|
||||
export const SHAKA_NESTED_HTTP_CODES = new Set<number>([
|
||||
SHAKA_ERROR_CODE.LICENSE_REQUEST_FAILED,
|
||||
SHAKA_ERROR_CODE.SERVER_CERTIFICATE_REQUEST_FAILED,
|
||||
]);
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface ShakaErrorLike {
|
||||
readonly severity: number;
|
||||
readonly category: number;
|
||||
readonly code: number;
|
||||
readonly data?: readonly unknown[];
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import * as shakaDiagnostics from './shaka-error-classifier';
|
||||
|
||||
interface ShakaErrorInput {
|
||||
readonly severity?: unknown;
|
||||
readonly category?: unknown;
|
||||
readonly code?: unknown;
|
||||
readonly message?: unknown;
|
||||
readonly data?: readonly unknown[];
|
||||
readonly [key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ExpectedShakaPlaybackEvidence {
|
||||
readonly severity: string;
|
||||
readonly category: string;
|
||||
readonly engineCode: number | string;
|
||||
readonly disposition: string;
|
||||
readonly stage: string;
|
||||
readonly failure: string;
|
||||
readonly httpStatus?: number;
|
||||
}
|
||||
|
||||
interface InstalledShakaContract {
|
||||
readonly version: string;
|
||||
readonly severity: Readonly<Record<string, number>>;
|
||||
readonly category: Readonly<Record<string, number>>;
|
||||
readonly code: Readonly<Record<string, number>>;
|
||||
}
|
||||
|
||||
interface ShakaEvidenceExports {
|
||||
readonly SHAKA_DIAGNOSTIC_VERSION?: string;
|
||||
readonly SHAKA_ERROR_SEVERITY?: Readonly<Record<string, number>>;
|
||||
readonly SHAKA_ERROR_CATEGORY?: Readonly<Record<string, number>>;
|
||||
readonly SHAKA_ERROR_CODE?: Readonly<Record<string, number>>;
|
||||
readonly createShakaPlaybackEvidence?: (
|
||||
error: ShakaErrorInput | null | undefined,
|
||||
disposition: 'terminal' | 'recoverable'
|
||||
) => ExpectedShakaPlaybackEvidence;
|
||||
}
|
||||
|
||||
const exportsUnderTest = shakaDiagnostics as ShakaEvidenceExports;
|
||||
|
||||
describe('Shaka playback evidence', () => {
|
||||
it('matches the installed public Shaka 5.2.2 error contract', () => {
|
||||
const installed = getInstalledShakaContract();
|
||||
|
||||
expect(exportsUnderTest.SHAKA_DIAGNOSTIC_VERSION).toBe(
|
||||
installed.version
|
||||
);
|
||||
expect(exportsUnderTest.SHAKA_ERROR_SEVERITY).toEqual(
|
||||
installed.severity
|
||||
);
|
||||
expect(exportsUnderTest.SHAKA_ERROR_CATEGORY).toEqual(
|
||||
installed.category
|
||||
);
|
||||
expect(exportsUnderTest.SHAKA_ERROR_CODE).toBeDefined();
|
||||
|
||||
for (const [name, value] of Object.entries(
|
||||
exportsUnderTest.SHAKA_ERROR_CODE ?? {}
|
||||
)) {
|
||||
expect(installed.code[name]).toBe(value);
|
||||
}
|
||||
});
|
||||
|
||||
it('extracts only the documented status from a real bad-status shape', () => {
|
||||
const secret = 'shaka-direct-secret';
|
||||
const evidence = createEvidence(
|
||||
{
|
||||
severity: 1,
|
||||
category: 1,
|
||||
code: 1001,
|
||||
message: `request failed at https://user:${secret}@provider.example`,
|
||||
data: [
|
||||
`https://provider.example/manifest.mpd?token=${secret}`,
|
||||
503,
|
||||
`provider response body ${secret}`,
|
||||
{ Authorization: `Bearer ${secret}` },
|
||||
0,
|
||||
`https://provider.example/final?token=${secret}`,
|
||||
],
|
||||
providerPayload: { key: secret },
|
||||
},
|
||||
'terminal'
|
||||
);
|
||||
const serialized = JSON.stringify(evidence);
|
||||
|
||||
expect(evidence).toEqual({
|
||||
severity: 'recoverable',
|
||||
category: 'network',
|
||||
engineCode: 1001,
|
||||
disposition: 'terminal',
|
||||
stage: 'unknown',
|
||||
failure: 'network',
|
||||
httpStatus: 503,
|
||||
});
|
||||
expect(serialized).not.toContain(secret);
|
||||
expect(serialized).not.toContain('provider.example');
|
||||
expect(serialized).not.toContain('Authorization');
|
||||
expect(serialized).not.toContain('response body');
|
||||
expect(Object.keys(evidence).sort()).toEqual(
|
||||
[
|
||||
'category',
|
||||
'disposition',
|
||||
'engineCode',
|
||||
'failure',
|
||||
'httpStatus',
|
||||
'severity',
|
||||
'stage',
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ code: 6007, label: 'license request' },
|
||||
{ code: 6017, label: 'server certificate request' },
|
||||
])(
|
||||
'extracts a nested bad status from the documented $label layout',
|
||||
({ code }) => {
|
||||
const secret = 'shaka-nested-secret';
|
||||
const evidence = createEvidence(
|
||||
{
|
||||
severity: 2,
|
||||
category: 6,
|
||||
code,
|
||||
data: [
|
||||
{
|
||||
severity: 1,
|
||||
category: 1,
|
||||
code: 1001,
|
||||
data: [
|
||||
`https://provider.example/license?token=${secret}`,
|
||||
403,
|
||||
`license response ${secret}`,
|
||||
{ Authorization: `Bearer ${secret}` },
|
||||
],
|
||||
},
|
||||
{
|
||||
licenseRequest: secret,
|
||||
sessionMetadata: secret,
|
||||
},
|
||||
],
|
||||
},
|
||||
'terminal'
|
||||
);
|
||||
const serialized = JSON.stringify(evidence);
|
||||
|
||||
expect(evidence).toEqual({
|
||||
severity: 'critical',
|
||||
category: 'drm',
|
||||
engineCode: code,
|
||||
disposition: 'terminal',
|
||||
stage: 'license',
|
||||
failure: 'drm',
|
||||
httpStatus: 403,
|
||||
});
|
||||
expect(serialized).not.toContain(secret);
|
||||
expect(serialized).not.toContain('provider.example');
|
||||
expect(serialized).not.toContain('Authorization');
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ status: 99, expected: undefined },
|
||||
{ status: 100, expected: 100 },
|
||||
{ status: 599, expected: 599 },
|
||||
{ status: 600, expected: undefined },
|
||||
{ status: 404.5, expected: undefined },
|
||||
{ status: '503', expected: undefined },
|
||||
])(
|
||||
'retains documented HTTP status $status only inside the protocol range',
|
||||
({ status, expected }) => {
|
||||
const evidence = createEvidence(
|
||||
{
|
||||
severity: 1,
|
||||
category: 1,
|
||||
code: 1001,
|
||||
data: ['https://provider.example/manifest.mpd', status],
|
||||
},
|
||||
'terminal'
|
||||
);
|
||||
|
||||
expect(evidence.httpStatus).toBe(expected);
|
||||
}
|
||||
);
|
||||
|
||||
it('ignores status-like data on every undocumented layout', () => {
|
||||
const evidence = createEvidence(
|
||||
{
|
||||
severity: 2,
|
||||
category: 4,
|
||||
code: 4001,
|
||||
data: [503, 504, { status: 505 }],
|
||||
},
|
||||
'terminal'
|
||||
);
|
||||
|
||||
expect(evidence.httpStatus).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves the exact public streaming startup code as unknown failure evidence', () => {
|
||||
expect(
|
||||
createEvidence({ severity: 2, category: 5, code: 5006 }, 'terminal')
|
||||
).toEqual({
|
||||
severity: 'critical',
|
||||
category: 'streaming',
|
||||
engineCode: 5006,
|
||||
disposition: 'terminal',
|
||||
stage: 'unknown',
|
||||
failure: 'unknown',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['INVALID_TEXT_HEADER', 2000],
|
||||
['INVALID_TEXT_CUE', 2001],
|
||||
['UNABLE_TO_DETECT_ENCODING', 2003],
|
||||
['BAD_ENCODING', 2004],
|
||||
['INVALID_XML', 2005],
|
||||
['INVALID_MP4_TTML', 2007],
|
||||
['INVALID_MP4_VTT', 2008],
|
||||
['INVALID_MP4_CEA', 2010],
|
||||
])('preserves the exact public DASH text code %s', (_name, code) => {
|
||||
expect(
|
||||
createEvidence({ severity: 2, category: 2, code }, 'terminal')
|
||||
).toEqual({
|
||||
severity: 'critical',
|
||||
category: 'text',
|
||||
engineCode: code,
|
||||
disposition: 'terminal',
|
||||
stage: 'unknown',
|
||||
failure: 'unknown',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects public text codes from app-uninvoked explicit-track APIs', () => {
|
||||
expect(
|
||||
createEvidence({ severity: 2, category: 2, code: 2014 }, 'terminal')
|
||||
).toEqual({
|
||||
severity: 'critical',
|
||||
category: 'text',
|
||||
engineCode: 'unknown',
|
||||
disposition: 'terminal',
|
||||
stage: 'unknown',
|
||||
failure: 'unknown',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'manifest parsing',
|
||||
error: { severity: 2, category: 4, code: 4001 },
|
||||
stage: 'manifest',
|
||||
failure: 'manifest',
|
||||
},
|
||||
{
|
||||
label: 'segment index parsing',
|
||||
error: { severity: 2, category: 3, code: 3004 },
|
||||
stage: 'segment',
|
||||
failure: 'media',
|
||||
},
|
||||
{
|
||||
label: 'media source operation',
|
||||
error: { severity: 2, category: 3, code: 3014 },
|
||||
stage: 'media',
|
||||
failure: 'media',
|
||||
},
|
||||
{
|
||||
label: 'license response',
|
||||
error: { severity: 2, category: 6, code: 6008 },
|
||||
stage: 'license',
|
||||
failure: 'drm',
|
||||
},
|
||||
{
|
||||
label: 'network without proven request stage',
|
||||
error: { severity: 2, category: 1, code: 1002 },
|
||||
stage: 'unknown',
|
||||
failure: 'network',
|
||||
},
|
||||
])(
|
||||
'maps exact $label evidence without reading messages',
|
||||
({ error, stage, failure }) => {
|
||||
const evidence = createEvidence(
|
||||
{
|
||||
...error,
|
||||
message:
|
||||
'CORS codec manifest license segment provider guess',
|
||||
},
|
||||
'terminal'
|
||||
);
|
||||
|
||||
expect(evidence.stage).toBe(stage);
|
||||
expect(evidence.failure).toBe(failure);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'ambiguous restrictions',
|
||||
error: { severity: 2, category: 4, code: 4012 },
|
||||
stage: 'manifest',
|
||||
},
|
||||
{
|
||||
label: 'mismatched public pair',
|
||||
error: { severity: 2, category: 1, code: 6001 },
|
||||
stage: 'unknown',
|
||||
},
|
||||
{
|
||||
label: 'mismatched media stage pair',
|
||||
error: { severity: 2, category: 1, code: 3004 },
|
||||
stage: 'unknown',
|
||||
},
|
||||
{
|
||||
label: 'unknown provider code',
|
||||
error: { severity: 3, category: 11, code: 123456 },
|
||||
stage: 'unknown',
|
||||
},
|
||||
])('keeps $label failure evidence unknown', ({ error, stage }) => {
|
||||
const evidence = createEvidence(error, 'terminal');
|
||||
|
||||
expect(evidence.failure).toBe('unknown');
|
||||
expect(evidence.stage).toBe(stage);
|
||||
});
|
||||
|
||||
it('validates severity, category, code, and explicit disposition independently', () => {
|
||||
expect(
|
||||
createEvidence(
|
||||
{ severity: 99, category: 99, code: 999999 },
|
||||
'recoverable'
|
||||
)
|
||||
).toEqual({
|
||||
severity: 'unknown',
|
||||
category: 'unknown',
|
||||
engineCode: 'unknown',
|
||||
disposition: 'recoverable',
|
||||
stage: 'unknown',
|
||||
failure: 'unknown',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createEvidence(
|
||||
error: ShakaErrorInput | null | undefined,
|
||||
disposition: 'terminal' | 'recoverable'
|
||||
): ExpectedShakaPlaybackEvidence {
|
||||
const factory = exportsUnderTest.createShakaPlaybackEvidence;
|
||||
|
||||
expect(factory).toBeDefined();
|
||||
if (!factory) {
|
||||
throw new Error('createShakaPlaybackEvidence is not exported');
|
||||
}
|
||||
return factory(error, disposition);
|
||||
}
|
||||
|
||||
function getInstalledShakaContract(): InstalledShakaContract {
|
||||
const script = `
|
||||
global.self = globalThis;
|
||||
import('shaka-player').then((module) => {
|
||||
const shaka = module.default ?? module;
|
||||
process.stdout.write(JSON.stringify({
|
||||
version: shaka.Player.version,
|
||||
severity: shaka.util.Error.Severity,
|
||||
category: shaka.util.Error.Category,
|
||||
code: shaka.util.Error.Code,
|
||||
}));
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
`;
|
||||
return JSON.parse(
|
||||
execFileSync(process.execPath, ['-e', script], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
})
|
||||
) as InstalledShakaContract;
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import {
|
||||
type ShakaPlaybackCategory as ShakaPlaybackCategoryValue,
|
||||
type ShakaPlaybackDisposition,
|
||||
type ShakaPlaybackEvidence,
|
||||
type ShakaPlaybackFailure as ShakaPlaybackFailureValue,
|
||||
type ShakaPlaybackStage as ShakaPlaybackStageValue,
|
||||
ShakaPlaybackCategory,
|
||||
ShakaPlaybackFailure,
|
||||
ShakaPlaybackSeverity,
|
||||
ShakaPlaybackStage,
|
||||
ShakaPlaybackUnknownCode,
|
||||
} from './playback-diagnostics.model';
|
||||
import {
|
||||
SHAKA_ERROR_CATEGORY,
|
||||
SHAKA_ERROR_CODE,
|
||||
SHAKA_ERROR_SEVERITY,
|
||||
} from './shaka-error-contract';
|
||||
import {
|
||||
SHAKA_AMBIGUOUS_MANIFEST_CODES,
|
||||
SHAKA_DRM_CODES,
|
||||
SHAKA_LICENSE_STAGE_CODES,
|
||||
SHAKA_MANIFEST_CODES,
|
||||
SHAKA_MANIFEST_DRM_CODES,
|
||||
SHAKA_MANIFEST_MEDIA_CODES,
|
||||
SHAKA_MEDIA_CODES,
|
||||
SHAKA_MEDIA_STAGE_CODES,
|
||||
SHAKA_NESTED_HTTP_CODES,
|
||||
SHAKA_NETWORK_CODES,
|
||||
SHAKA_PUBLIC_CODES,
|
||||
SHAKA_SEGMENT_STAGE_CODES,
|
||||
} from './shaka-error-mapping';
|
||||
import type { ShakaErrorLike } from './shaka-error.types';
|
||||
|
||||
export function createShakaPlaybackEvidence(
|
||||
error: Partial<ShakaErrorLike> | null | undefined,
|
||||
disposition: ShakaPlaybackDisposition
|
||||
): ShakaPlaybackEvidence {
|
||||
const category = getCategory(error?.category);
|
||||
const engineCode = getEngineCode(error?.code);
|
||||
const httpStatus = getHttpStatus(error);
|
||||
const evidence: ShakaPlaybackEvidence = {
|
||||
severity: getSeverity(error?.severity),
|
||||
category,
|
||||
engineCode,
|
||||
disposition,
|
||||
stage: getStage(category, engineCode),
|
||||
failure: getFailure(category, engineCode),
|
||||
};
|
||||
|
||||
return httpStatus === undefined ? evidence : { ...evidence, httpStatus };
|
||||
}
|
||||
|
||||
function getSeverity(value: unknown): ShakaPlaybackEvidence['severity'] {
|
||||
if (value === SHAKA_ERROR_SEVERITY.RECOVERABLE) {
|
||||
return ShakaPlaybackSeverity.Recoverable;
|
||||
}
|
||||
if (value === SHAKA_ERROR_SEVERITY.CRITICAL) {
|
||||
return ShakaPlaybackSeverity.Critical;
|
||||
}
|
||||
return ShakaPlaybackSeverity.Unknown;
|
||||
}
|
||||
|
||||
function getCategory(value: unknown): ShakaPlaybackCategoryValue {
|
||||
switch (value) {
|
||||
case SHAKA_ERROR_CATEGORY.NETWORK:
|
||||
return ShakaPlaybackCategory.Network;
|
||||
case SHAKA_ERROR_CATEGORY.TEXT:
|
||||
return ShakaPlaybackCategory.Text;
|
||||
case SHAKA_ERROR_CATEGORY.MEDIA:
|
||||
return ShakaPlaybackCategory.Media;
|
||||
case SHAKA_ERROR_CATEGORY.MANIFEST:
|
||||
return ShakaPlaybackCategory.Manifest;
|
||||
case SHAKA_ERROR_CATEGORY.STREAMING:
|
||||
return ShakaPlaybackCategory.Streaming;
|
||||
case SHAKA_ERROR_CATEGORY.DRM:
|
||||
return ShakaPlaybackCategory.Drm;
|
||||
case SHAKA_ERROR_CATEGORY.PLAYER:
|
||||
return ShakaPlaybackCategory.Player;
|
||||
case SHAKA_ERROR_CATEGORY.CAST:
|
||||
return ShakaPlaybackCategory.Cast;
|
||||
case SHAKA_ERROR_CATEGORY.STORAGE:
|
||||
return ShakaPlaybackCategory.Storage;
|
||||
case SHAKA_ERROR_CATEGORY.ADS:
|
||||
return ShakaPlaybackCategory.Ads;
|
||||
default:
|
||||
return ShakaPlaybackCategory.Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
function getEngineCode(value: unknown): ShakaPlaybackEvidence['engineCode'] {
|
||||
return typeof value === 'number' &&
|
||||
Number.isInteger(value) &&
|
||||
SHAKA_PUBLIC_CODES.has(value)
|
||||
? value
|
||||
: ShakaPlaybackUnknownCode;
|
||||
}
|
||||
|
||||
function getFailure(
|
||||
category: ShakaPlaybackCategoryValue,
|
||||
engineCode: ShakaPlaybackEvidence['engineCode']
|
||||
): ShakaPlaybackFailureValue {
|
||||
if (
|
||||
category === ShakaPlaybackCategory.Network &&
|
||||
typeof engineCode === 'number' &&
|
||||
SHAKA_NETWORK_CODES.has(engineCode)
|
||||
) {
|
||||
return ShakaPlaybackFailure.Network;
|
||||
}
|
||||
if (
|
||||
category === ShakaPlaybackCategory.Drm &&
|
||||
typeof engineCode === 'number' &&
|
||||
SHAKA_DRM_CODES.has(engineCode)
|
||||
) {
|
||||
return ShakaPlaybackFailure.Drm;
|
||||
}
|
||||
if (
|
||||
category === ShakaPlaybackCategory.Media &&
|
||||
typeof engineCode === 'number' &&
|
||||
SHAKA_MEDIA_CODES.has(engineCode)
|
||||
) {
|
||||
return ShakaPlaybackFailure.Media;
|
||||
}
|
||||
if (
|
||||
category === ShakaPlaybackCategory.Manifest &&
|
||||
typeof engineCode === 'number'
|
||||
) {
|
||||
if (SHAKA_MANIFEST_DRM_CODES.has(engineCode)) {
|
||||
return ShakaPlaybackFailure.Drm;
|
||||
}
|
||||
if (SHAKA_MANIFEST_MEDIA_CODES.has(engineCode)) {
|
||||
return ShakaPlaybackFailure.Media;
|
||||
}
|
||||
if (
|
||||
SHAKA_MANIFEST_CODES.has(engineCode) &&
|
||||
!SHAKA_AMBIGUOUS_MANIFEST_CODES.has(engineCode)
|
||||
) {
|
||||
return ShakaPlaybackFailure.Manifest;
|
||||
}
|
||||
}
|
||||
return ShakaPlaybackFailure.Unknown;
|
||||
}
|
||||
|
||||
function getStage(
|
||||
category: ShakaPlaybackCategoryValue,
|
||||
engineCode: ShakaPlaybackEvidence['engineCode']
|
||||
): ShakaPlaybackStageValue {
|
||||
if (typeof engineCode !== 'number') {
|
||||
return ShakaPlaybackStage.Unknown;
|
||||
}
|
||||
if (
|
||||
category === ShakaPlaybackCategory.Manifest &&
|
||||
SHAKA_MANIFEST_CODES.has(engineCode)
|
||||
) {
|
||||
return ShakaPlaybackStage.Manifest;
|
||||
}
|
||||
if (
|
||||
(category === ShakaPlaybackCategory.Network &&
|
||||
engineCode === SHAKA_ERROR_CODE.SEGMENT_MISSING) ||
|
||||
(category === ShakaPlaybackCategory.Media &&
|
||||
SHAKA_SEGMENT_STAGE_CODES.has(engineCode))
|
||||
) {
|
||||
return ShakaPlaybackStage.Segment;
|
||||
}
|
||||
if (
|
||||
category === ShakaPlaybackCategory.Media &&
|
||||
SHAKA_MEDIA_STAGE_CODES.has(engineCode)
|
||||
) {
|
||||
return ShakaPlaybackStage.Media;
|
||||
}
|
||||
if (
|
||||
category === ShakaPlaybackCategory.Drm &&
|
||||
SHAKA_LICENSE_STAGE_CODES.has(engineCode)
|
||||
) {
|
||||
return ShakaPlaybackStage.License;
|
||||
}
|
||||
return ShakaPlaybackStage.Unknown;
|
||||
}
|
||||
|
||||
function getHttpStatus(
|
||||
error: Partial<ShakaErrorLike> | null | undefined
|
||||
): number | undefined {
|
||||
if (
|
||||
error?.category === SHAKA_ERROR_CATEGORY.NETWORK &&
|
||||
error.code === SHAKA_ERROR_CODE.BAD_HTTP_STATUS
|
||||
) {
|
||||
return asHttpStatus(error.data?.[1]);
|
||||
}
|
||||
|
||||
if (
|
||||
error?.category !== SHAKA_ERROR_CATEGORY.DRM ||
|
||||
typeof error.code !== 'number' ||
|
||||
!SHAKA_NESTED_HTTP_CODES.has(error.code)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const nested = asShakaErrorData(error.data?.[0]);
|
||||
return nested?.category === SHAKA_ERROR_CATEGORY.NETWORK &&
|
||||
nested.code === SHAKA_ERROR_CODE.BAD_HTTP_STATUS
|
||||
? asHttpStatus(nested.data?.[1])
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function asShakaErrorData(value: unknown): Partial<ShakaErrorLike> | undefined {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
const candidate = value as {
|
||||
readonly severity?: unknown;
|
||||
readonly category?: unknown;
|
||||
readonly code?: unknown;
|
||||
readonly data?: unknown;
|
||||
};
|
||||
return {
|
||||
severity:
|
||||
typeof candidate.severity === 'number'
|
||||
? candidate.severity
|
||||
: undefined,
|
||||
category:
|
||||
typeof candidate.category === 'number'
|
||||
? candidate.category
|
||||
: undefined,
|
||||
code: typeof candidate.code === 'number' ? candidate.code : undefined,
|
||||
data: Array.isArray(candidate.data) ? candidate.data : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function asHttpStatus(value: unknown): number | undefined {
|
||||
return typeof value === 'number' &&
|
||||
Number.isInteger(value) &&
|
||||
value >= 100 &&
|
||||
value <= 599
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import type { NativePlaybackErrorInput } from './playback-diagnostics.model';
|
||||
import * as diagnostics from './playback-diagnostics.util';
|
||||
|
||||
interface ExpectedVhsPlaybackEvidence {
|
||||
readonly engineType: string;
|
||||
readonly mediaErrorCode: number | string;
|
||||
readonly disposition: string;
|
||||
readonly stage: string;
|
||||
readonly httpStatus?: number;
|
||||
}
|
||||
|
||||
type EvidenceFactory = (
|
||||
error: NativePlaybackErrorInput
|
||||
) => ExpectedVhsPlaybackEvidence;
|
||||
|
||||
interface VideoJsErrorConstants {
|
||||
readonly [name: string]: string;
|
||||
}
|
||||
|
||||
describe('VHS playback evidence', () => {
|
||||
it('matches the installed public videojs.Error identifiers', () => {
|
||||
expect(Object.values(getEngineTypes()).sort()).toEqual(
|
||||
Object.values(getInstalledVideoJsErrors()).sort()
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts only allowlisted fields from a real VHS bad-status shape', () => {
|
||||
const secret = 'vhs-evidence-secret';
|
||||
const evidence = createEvidence({
|
||||
code: 4,
|
||||
status: 503,
|
||||
message:
|
||||
'HLS playlist request error at URL: ' +
|
||||
`https://provider.example/live.m3u8?token=${secret}`,
|
||||
metadata: {
|
||||
errorType: getInstalledVideoJsErrors()['NetworkBadStatus'],
|
||||
requestType: 'hls-playlist',
|
||||
uri: `https://provider.example/live.m3u8?token=${secret}`,
|
||||
headers: { Authorization: `Bearer ${secret}` },
|
||||
responseText: `provider body ${secret}`,
|
||||
},
|
||||
} as NativePlaybackErrorInput);
|
||||
const serialized = JSON.stringify(evidence);
|
||||
|
||||
expect(evidence).toEqual({
|
||||
engineType: 'networkbadstatus',
|
||||
mediaErrorCode: 4,
|
||||
disposition: 'terminal',
|
||||
stage: 'unknown',
|
||||
httpStatus: 503,
|
||||
});
|
||||
expect(serialized).not.toContain(secret);
|
||||
expect(serialized).not.toContain('provider.example');
|
||||
expect(serialized).not.toContain('Authorization');
|
||||
expect(serialized).not.toContain('hls-playlist');
|
||||
expect(Object.keys(evidence).sort()).toEqual(
|
||||
[
|
||||
'disposition',
|
||||
'engineType',
|
||||
'httpStatus',
|
||||
'mediaErrorCode',
|
||||
'stage',
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
engineType: 'streaminghlsplaylistparsererror',
|
||||
stage: 'playlist',
|
||||
},
|
||||
{
|
||||
engineType: 'streamingdashmanifestparsererror',
|
||||
stage: 'manifest',
|
||||
},
|
||||
{
|
||||
engineType: 'streamingfailedtoselectnextsegment',
|
||||
stage: 'segment',
|
||||
},
|
||||
{
|
||||
engineType: 'streamingfailedtodecryptsegment',
|
||||
stage: 'segment',
|
||||
},
|
||||
{
|
||||
engineType: 'streamingfailedtotransmuxsegment',
|
||||
stage: 'segment',
|
||||
},
|
||||
{
|
||||
engineType: 'streamingfailedtoappendsegment',
|
||||
stage: 'segment',
|
||||
},
|
||||
{
|
||||
engineType: 'networkrequestfailed',
|
||||
stage: 'unknown',
|
||||
},
|
||||
{
|
||||
engineType: 'streamingcontentsteeringparsererror',
|
||||
stage: 'unknown',
|
||||
},
|
||||
{
|
||||
engineType: 'streamingvttparsererror',
|
||||
stage: 'unknown',
|
||||
},
|
||||
{
|
||||
engineType: 'streamingcodecschangeerror',
|
||||
stage: 'unknown',
|
||||
},
|
||||
])(
|
||||
'maps public $engineType evidence only to the proven $stage stage',
|
||||
({ engineType, stage }) => {
|
||||
expect(
|
||||
createEvidence({
|
||||
code: 3,
|
||||
metadata: { errorType: engineType },
|
||||
}).stage
|
||||
).toBe(stage);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ code: 0, expected: 0 },
|
||||
{ code: 5, expected: 5 },
|
||||
{ code: -1, expected: 'unknown' },
|
||||
{ code: 6, expected: 'unknown' },
|
||||
{ code: 3.5, expected: 'unknown' },
|
||||
])(
|
||||
'retains MediaError code $code only inside the standard range',
|
||||
({ code, expected }) => {
|
||||
expect(createEvidence({ code }).mediaErrorCode).toBe(expected);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ status: 399, expected: undefined },
|
||||
{ status: 400, expected: 400 },
|
||||
{ status: 599, expected: 599 },
|
||||
{ status: 600, expected: undefined },
|
||||
{ status: 404.5, expected: undefined },
|
||||
])(
|
||||
'retains HTTP status $status only for integer 4xx and 5xx failures',
|
||||
({ status, expected }) => {
|
||||
expect(createEvidence({ code: 2, status }).httpStatus).toBe(
|
||||
expected
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('keeps unknown identifiers unknown without inspecting messages or request types', () => {
|
||||
const evidence = createEvidence({
|
||||
code: 3,
|
||||
status: 0,
|
||||
message:
|
||||
'CORS codec DRM failure in hls-key request at provider URL',
|
||||
metadata: {
|
||||
errorType: 'providerCorsCodecDrmFailure',
|
||||
requestType: 'hls-key',
|
||||
},
|
||||
} as NativePlaybackErrorInput);
|
||||
|
||||
expect(evidence).toEqual({
|
||||
engineType: 'unknown',
|
||||
mediaErrorCode: 3,
|
||||
disposition: 'terminal',
|
||||
stage: 'unknown',
|
||||
});
|
||||
});
|
||||
|
||||
it('populates player.error before the installed Video.js error listener runs', () => {
|
||||
expect(readInstalledVideoJsErrorFromListener()).toEqual({
|
||||
code: 4,
|
||||
status: 503,
|
||||
errorType: 'networkbadstatus',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createEvidence(
|
||||
error: NativePlaybackErrorInput
|
||||
): ExpectedVhsPlaybackEvidence {
|
||||
const factory = (
|
||||
diagnostics as unknown as {
|
||||
readonly createVhsPlaybackEvidence?: EvidenceFactory;
|
||||
}
|
||||
).createVhsPlaybackEvidence;
|
||||
|
||||
expect(factory).toBeDefined();
|
||||
if (!factory) {
|
||||
throw new Error('createVhsPlaybackEvidence is not exported');
|
||||
}
|
||||
return factory(error);
|
||||
}
|
||||
|
||||
function getEngineTypes(): VideoJsErrorConstants {
|
||||
const engineTypes = (
|
||||
diagnostics as unknown as {
|
||||
readonly VhsPlaybackEngineType?: VideoJsErrorConstants;
|
||||
}
|
||||
).VhsPlaybackEngineType;
|
||||
|
||||
expect(engineTypes).toBeDefined();
|
||||
if (!engineTypes) {
|
||||
throw new Error('VhsPlaybackEngineType is not exported');
|
||||
}
|
||||
return engineTypes;
|
||||
}
|
||||
|
||||
function getInstalledVideoJsErrors(): VideoJsErrorConstants {
|
||||
const script =
|
||||
"import videoJs from 'video.js';" +
|
||||
'process.stdout.write(JSON.stringify(videoJs.Error));';
|
||||
const output = execFileSync(
|
||||
process.execPath,
|
||||
['--input-type=module', '-e', script],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
}
|
||||
);
|
||||
|
||||
return JSON.parse(output) as VideoJsErrorConstants;
|
||||
}
|
||||
|
||||
function readInstalledVideoJsErrorFromListener(): {
|
||||
readonly code: number;
|
||||
readonly status: number;
|
||||
readonly errorType: string;
|
||||
} {
|
||||
const script = `
|
||||
import { createRequire } from 'node:module';
|
||||
const rootRequire = createRequire(import.meta.url);
|
||||
const environmentPath = rootRequire.resolve(
|
||||
'jest-environment-jsdom/package.json'
|
||||
);
|
||||
const environmentRequire = createRequire(environmentPath);
|
||||
const { JSDOM } = environmentRequire('jsdom');
|
||||
const dom = new JSDOM(
|
||||
'<!doctype html><html><body></body></html>',
|
||||
{ url: 'http://localhost/', pretendToBeVisual: true }
|
||||
);
|
||||
dom.window.HTMLMediaElement.prototype.load = () => {};
|
||||
for (const name of [
|
||||
'window',
|
||||
'document',
|
||||
'navigator',
|
||||
'Element',
|
||||
'HTMLElement',
|
||||
'HTMLVideoElement',
|
||||
'HTMLMediaElement',
|
||||
'Event',
|
||||
'CustomEvent',
|
||||
'Node',
|
||||
]) {
|
||||
Object.defineProperty(globalThis, name, {
|
||||
value: dom.window[name],
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
const { default: videoJs } = await import('video.js');
|
||||
videoJs.log.level('off');
|
||||
const video = document.createElement('video');
|
||||
document.body.append(video);
|
||||
const player = videoJs(video);
|
||||
let observed = null;
|
||||
player.on('error', () => {
|
||||
observed = player.error();
|
||||
});
|
||||
player.error({
|
||||
code: 4,
|
||||
status: 503,
|
||||
metadata: {
|
||||
errorType: videoJs.Error.NetworkBadStatus,
|
||||
},
|
||||
});
|
||||
process.stdout.write(JSON.stringify({
|
||||
code: observed.code,
|
||||
status: observed.status,
|
||||
errorType: observed.metadata.errorType,
|
||||
}));
|
||||
player.dispose();
|
||||
dom.window.close();
|
||||
`;
|
||||
const output = execFileSync(
|
||||
process.execPath,
|
||||
['--input-type=module', '-e', script],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
}
|
||||
);
|
||||
|
||||
return JSON.parse(output) as {
|
||||
readonly code: number;
|
||||
readonly status: number;
|
||||
readonly errorType: string;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type {
|
||||
NativePlaybackErrorInput,
|
||||
VhsPlaybackEngineType as VhsPlaybackEngineTypeValue,
|
||||
VhsPlaybackEvidence,
|
||||
VhsPlaybackMediaErrorCode as VhsPlaybackMediaErrorCodeValue,
|
||||
VhsPlaybackStage as VhsPlaybackStageValue,
|
||||
} from './playback-diagnostics.model';
|
||||
import {
|
||||
VhsPlaybackDisposition,
|
||||
VhsPlaybackEngineType,
|
||||
VhsPlaybackMediaErrorCode,
|
||||
VhsPlaybackStage,
|
||||
VhsPlaybackUnknownEngineType,
|
||||
} from './playback-diagnostics.model';
|
||||
|
||||
const ENGINE_TYPES = new Set<string>(Object.values(VhsPlaybackEngineType));
|
||||
const SEGMENT_ENGINE_TYPES = new Set<VhsPlaybackEngineTypeValue>([
|
||||
VhsPlaybackEngineType.StreamingFailedToSelectNextSegment,
|
||||
VhsPlaybackEngineType.StreamingFailedToDecryptSegment,
|
||||
VhsPlaybackEngineType.StreamingFailedToTransmuxSegment,
|
||||
VhsPlaybackEngineType.StreamingFailedToAppendSegment,
|
||||
]);
|
||||
|
||||
export function createVhsPlaybackEvidence(
|
||||
error: NativePlaybackErrorInput
|
||||
): VhsPlaybackEvidence {
|
||||
const engineType = getEngineType(error.metadata?.errorType);
|
||||
const httpStatus = getHttpStatus(error.status);
|
||||
const evidence: VhsPlaybackEvidence = {
|
||||
engineType,
|
||||
mediaErrorCode: getMediaErrorCode(error.code),
|
||||
disposition: VhsPlaybackDisposition.Terminal,
|
||||
stage: getStage(engineType),
|
||||
};
|
||||
|
||||
return httpStatus === undefined ? evidence : { ...evidence, httpStatus };
|
||||
}
|
||||
|
||||
function getEngineType(value: unknown): VhsPlaybackEngineTypeValue {
|
||||
return typeof value === 'string' && ENGINE_TYPES.has(value)
|
||||
? (value as VhsPlaybackEngineTypeValue)
|
||||
: VhsPlaybackUnknownEngineType;
|
||||
}
|
||||
|
||||
function getMediaErrorCode(value: unknown): VhsPlaybackMediaErrorCodeValue {
|
||||
return typeof value === 'number' &&
|
||||
Number.isInteger(value) &&
|
||||
value >= VhsPlaybackMediaErrorCode.Custom &&
|
||||
value <= VhsPlaybackMediaErrorCode.Encrypted
|
||||
? (value as VhsPlaybackMediaErrorCodeValue)
|
||||
: VhsPlaybackMediaErrorCode.Unknown;
|
||||
}
|
||||
|
||||
function getHttpStatus(value: unknown): number | undefined {
|
||||
return typeof value === 'number' &&
|
||||
Number.isInteger(value) &&
|
||||
value >= 400 &&
|
||||
value <= 599
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function getStage(
|
||||
engineType: VhsPlaybackEngineTypeValue
|
||||
): VhsPlaybackStageValue {
|
||||
if (engineType === VhsPlaybackEngineType.StreamingHlsPlaylistParserError) {
|
||||
return VhsPlaybackStage.Playlist;
|
||||
}
|
||||
if (engineType === VhsPlaybackEngineType.StreamingDashManifestParserError) {
|
||||
return VhsPlaybackStage.Manifest;
|
||||
}
|
||||
if (SEGMENT_ENGINE_TYPES.has(engineType)) {
|
||||
return VhsPlaybackStage.Segment;
|
||||
}
|
||||
return VhsPlaybackStage.Unknown;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { InlinePlaybackPlayer } from './diagnostics/playback-diagnostics.model';
|
||||
import {
|
||||
PlaybackEngineFamily,
|
||||
type PlaybackRecommendationContext,
|
||||
type PlaybackRecommendationTarget,
|
||||
type PlaybackTargetCapability,
|
||||
} from './playback-recommendation.model';
|
||||
import { getInlinePlaybackEngineFamily } from './playback-target-capabilities';
|
||||
|
||||
interface RuntimeCapabilityRecord {
|
||||
readonly target: unknown;
|
||||
readonly kind: unknown;
|
||||
readonly available: unknown;
|
||||
readonly engineFamily?: unknown;
|
||||
}
|
||||
|
||||
export type PlaybackCapabilityIndex = ReadonlyMap<
|
||||
PlaybackRecommendationTarget,
|
||||
PlaybackTargetCapability
|
||||
>;
|
||||
|
||||
const CANONICAL_TARGETS: readonly PlaybackRecommendationTarget[] = [
|
||||
InlinePlaybackPlayer.VideoJs,
|
||||
InlinePlaybackPlayer.Html5,
|
||||
InlinePlaybackPlayer.ArtPlayer,
|
||||
'mpv',
|
||||
'vlc',
|
||||
];
|
||||
|
||||
export function createPlaybackCapabilityIndex(
|
||||
context: PlaybackRecommendationContext
|
||||
): PlaybackCapabilityIndex | null {
|
||||
const index = new Map<
|
||||
PlaybackRecommendationTarget,
|
||||
PlaybackTargetCapability
|
||||
>();
|
||||
for (const capability of context.targetCapabilities) {
|
||||
if (!isValidCapability(context, capability, index)) {
|
||||
return null;
|
||||
}
|
||||
index.set(capability.target, capability);
|
||||
}
|
||||
if (index.size !== CANONICAL_TARGETS.length) {
|
||||
return null;
|
||||
}
|
||||
const active = index.get(context.activeTarget);
|
||||
return active?.kind === 'inline' &&
|
||||
active.available &&
|
||||
active.engineFamily !== null
|
||||
? index
|
||||
: null;
|
||||
}
|
||||
|
||||
export function hasAvailableCapability(
|
||||
capabilityIndex: PlaybackCapabilityIndex | null,
|
||||
target: PlaybackRecommendationTarget
|
||||
): boolean {
|
||||
return capabilityIndex?.get(target)?.available === true;
|
||||
}
|
||||
|
||||
export function getAttemptedInlineFamilies(
|
||||
context: PlaybackRecommendationContext,
|
||||
capabilityIndex: PlaybackCapabilityIndex | null
|
||||
): ReadonlySet<PlaybackEngineFamily> {
|
||||
const families = new Set<PlaybackEngineFamily>();
|
||||
if (capabilityIndex === null) {
|
||||
return families;
|
||||
}
|
||||
for (const target of context.attemptedTargets) {
|
||||
const capability = capabilityIndex.get(target);
|
||||
if (
|
||||
capability?.kind === 'inline' &&
|
||||
capability.available &&
|
||||
capability.engineFamily !== null
|
||||
) {
|
||||
families.add(capability.engineFamily);
|
||||
}
|
||||
}
|
||||
return families;
|
||||
}
|
||||
|
||||
export function isInlineFamilyAttempted(
|
||||
target: PlaybackRecommendationTarget,
|
||||
capabilityIndex: PlaybackCapabilityIndex | null,
|
||||
attemptedFamilies: ReadonlySet<PlaybackEngineFamily>
|
||||
): boolean {
|
||||
const capability = capabilityIndex?.get(target);
|
||||
return (
|
||||
capability?.kind === 'inline' &&
|
||||
capability.engineFamily !== null &&
|
||||
attemptedFamilies.has(capability.engineFamily)
|
||||
);
|
||||
}
|
||||
|
||||
function isValidCapability(
|
||||
context: PlaybackRecommendationContext,
|
||||
capability: unknown,
|
||||
index: PlaybackCapabilityIndex
|
||||
): capability is PlaybackTargetCapability {
|
||||
if (
|
||||
!hasRequiredCapabilityFields(capability) ||
|
||||
!isKnownTarget(capability.target) ||
|
||||
index.has(capability.target) ||
|
||||
typeof capability.available !== 'boolean'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!isInlineTarget(capability.target)) {
|
||||
return capability.kind === 'external';
|
||||
}
|
||||
if (capability.kind !== 'inline' || !('engineFamily' in capability)) {
|
||||
return false;
|
||||
}
|
||||
const expectedFamily = getInlinePlaybackEngineFamily(
|
||||
context.source.kind,
|
||||
capability.target
|
||||
);
|
||||
return (
|
||||
capability.engineFamily === expectedFamily &&
|
||||
!(expectedFamily === null && capability.available)
|
||||
);
|
||||
}
|
||||
|
||||
function hasRequiredCapabilityFields(
|
||||
value: unknown
|
||||
): value is RuntimeCapabilityRecord {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'target' in value &&
|
||||
'kind' in value &&
|
||||
'available' in value
|
||||
);
|
||||
}
|
||||
|
||||
function isKnownTarget(value: unknown): value is PlaybackRecommendationTarget {
|
||||
return CANONICAL_TARGETS.includes(value as PlaybackRecommendationTarget);
|
||||
}
|
||||
|
||||
function isInlineTarget(
|
||||
value: PlaybackRecommendationTarget
|
||||
): value is InlinePlaybackPlayer {
|
||||
return value !== 'mpv' && value !== 'vlc';
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,302 @@
|
||||
import type { ExternalPlayerName } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
InlinePlaybackPlayer,
|
||||
PlaybackDiagnosticCode,
|
||||
PlaybackDiagnosticSource,
|
||||
PlaybackRuntimeSupport,
|
||||
} from './diagnostics/playback-diagnostics.model';
|
||||
import {
|
||||
PlaybackEngineFamily,
|
||||
PlaybackRecommendationReason,
|
||||
PlaybackSourceKind,
|
||||
type PlaybackRecommendation,
|
||||
type PlaybackRecommendationContext,
|
||||
type PlaybackRecommendationTarget,
|
||||
type PlaybackTargetCapability,
|
||||
} from './playback-recommendation.model';
|
||||
import {
|
||||
createPlaybackCapabilityIndex,
|
||||
getAttemptedInlineFamilies,
|
||||
hasAvailableCapability,
|
||||
isInlineFamilyAttempted,
|
||||
type PlaybackCapabilityIndex,
|
||||
} from './playback-recommendation-capabilities';
|
||||
|
||||
type WithoutPriority<T> = T extends unknown ? Omit<T, 'priority'> : never;
|
||||
|
||||
type PlaybackRecommendationCandidate = WithoutPriority<PlaybackRecommendation>;
|
||||
|
||||
type InlineCapability = Extract<
|
||||
PlaybackTargetCapability,
|
||||
{ readonly kind: 'inline' }
|
||||
>;
|
||||
|
||||
type AvailableInlineCapability = InlineCapability & {
|
||||
readonly available: true;
|
||||
readonly engineFamily: PlaybackEngineFamily;
|
||||
};
|
||||
|
||||
export function recommendPlaybackRecovery(
|
||||
context: PlaybackRecommendationContext
|
||||
): readonly PlaybackRecommendation[] {
|
||||
const capabilityIndex = hasPlayerRecoveryEvidence(context)
|
||||
? createPlaybackCapabilityIndex(context)
|
||||
: null;
|
||||
const attemptedInlineFamilies = getAttemptedInlineFamilies(
|
||||
context,
|
||||
capabilityIndex
|
||||
);
|
||||
const seenTargets = new Set<PlaybackRecommendationTarget>();
|
||||
const candidates = buildCandidates(context, capabilityIndex).filter(
|
||||
isCandidate
|
||||
);
|
||||
const filtered = candidates.filter((candidate) => {
|
||||
if (candidate.action !== 'player') {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
candidate.target === context.activeTarget ||
|
||||
context.attemptedTargets.has(candidate.target) ||
|
||||
seenTargets.has(candidate.target) ||
|
||||
!hasAvailableCapability(capabilityIndex, candidate.target)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
isInlineFamilyAttempted(
|
||||
candidate.target,
|
||||
capabilityIndex,
|
||||
attemptedInlineFamilies
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
isExternalTarget(candidate.target) &&
|
||||
!canTransferExternally(context)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
seenTargets.add(candidate.target);
|
||||
return true;
|
||||
});
|
||||
|
||||
return filtered.slice(0, 3).map((candidate, index) => ({
|
||||
...candidate,
|
||||
priority: index === 0 ? 'primary' : 'secondary',
|
||||
}));
|
||||
}
|
||||
|
||||
function buildCandidates(
|
||||
context: PlaybackRecommendationContext,
|
||||
capabilityIndex: PlaybackCapabilityIndex | null
|
||||
): readonly (PlaybackRecommendationCandidate | null)[] {
|
||||
if (hasPlayerRecoveryEvidence(context) && capabilityIndex === null) {
|
||||
return [retryUnknown(), alternative(context)];
|
||||
}
|
||||
|
||||
if (isShakaBrowserUnsupported(context)) {
|
||||
return [
|
||||
externalCodecSupport('mpv'),
|
||||
externalCodecSupport('vlc'),
|
||||
alternative(context),
|
||||
];
|
||||
}
|
||||
|
||||
switch (context.diagnostic.code) {
|
||||
case PlaybackDiagnosticCode.NetworkError:
|
||||
return [retryTransient(), alternative(context)];
|
||||
case PlaybackDiagnosticCode.BrowserAccessError:
|
||||
return [
|
||||
external(
|
||||
'mpv',
|
||||
PlaybackRecommendationReason.ExternalBrowserAccess
|
||||
),
|
||||
external(
|
||||
'vlc',
|
||||
PlaybackRecommendationReason.ExternalBrowserAccess
|
||||
),
|
||||
alternative(context),
|
||||
];
|
||||
case PlaybackDiagnosticCode.UnsupportedCodec:
|
||||
case PlaybackDiagnosticCode.UnsupportedContainer:
|
||||
return [
|
||||
externalCodecSupport('mpv'),
|
||||
externalCodecSupport('vlc'),
|
||||
alternative(context),
|
||||
];
|
||||
case PlaybackDiagnosticCode.MediaDecodeError:
|
||||
return [
|
||||
distinctInline(
|
||||
context,
|
||||
capabilityIndex,
|
||||
PlaybackRecommendationReason.DifferentEngineFamily
|
||||
),
|
||||
externalCodecSupport('mpv'),
|
||||
externalCodecSupport('vlc'),
|
||||
alternative(context),
|
||||
];
|
||||
case PlaybackDiagnosticCode.DrmOrEncryption:
|
||||
return [
|
||||
distinctInline(
|
||||
context,
|
||||
capabilityIndex,
|
||||
PlaybackRecommendationReason.CompatibleDrmPath
|
||||
),
|
||||
alternative(context),
|
||||
external('mpv', PlaybackRecommendationReason.CompatibleDrmPath),
|
||||
external('vlc', PlaybackRecommendationReason.CompatibleDrmPath),
|
||||
];
|
||||
case PlaybackDiagnosticCode.UnknownPlaybackError:
|
||||
default:
|
||||
return [retryUnknown(), alternative(context)];
|
||||
}
|
||||
}
|
||||
|
||||
function retryTransient(): PlaybackRecommendationCandidate {
|
||||
return {
|
||||
action: 'retry',
|
||||
reason: PlaybackRecommendationReason.RetryTransientFailure,
|
||||
};
|
||||
}
|
||||
|
||||
function retryUnknown(): PlaybackRecommendationCandidate {
|
||||
return {
|
||||
action: 'retry',
|
||||
reason: PlaybackRecommendationReason.RetryUnknownFailure,
|
||||
};
|
||||
}
|
||||
|
||||
function alternative(
|
||||
context: PlaybackRecommendationContext
|
||||
): PlaybackRecommendationCandidate | null {
|
||||
return !Number.isSafeInteger(context.alternativeSourceCount) ||
|
||||
context.alternativeSourceCount <= 0
|
||||
? null
|
||||
: {
|
||||
action: 'alternative-source',
|
||||
reason: PlaybackRecommendationReason.AlternativeSourceAvailable,
|
||||
};
|
||||
}
|
||||
|
||||
function distinctInline(
|
||||
context: PlaybackRecommendationContext,
|
||||
capabilityIndex: PlaybackCapabilityIndex | null,
|
||||
reason: PlaybackRecommendationCandidate['reason']
|
||||
): PlaybackRecommendationCandidate | null {
|
||||
const activeCapability = getActiveInlineCapability(
|
||||
context,
|
||||
capabilityIndex
|
||||
);
|
||||
if (activeCapability === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const canonicalTarget = getCanonicalDistinctInlineTarget(
|
||||
activeCapability.engineFamily
|
||||
);
|
||||
if (canonicalTarget === null) {
|
||||
return null;
|
||||
}
|
||||
const capability = capabilityIndex?.get(canonicalTarget);
|
||||
return capability?.kind !== 'inline' ||
|
||||
!capability.available ||
|
||||
capability.engineFamily === null ||
|
||||
capability.engineFamily === activeCapability.engineFamily
|
||||
? null
|
||||
: { action: 'player', target: capability.target, reason };
|
||||
}
|
||||
|
||||
function getCanonicalDistinctInlineTarget(
|
||||
activeFamily: PlaybackEngineFamily
|
||||
): InlinePlaybackPlayer | null {
|
||||
if (activeFamily === PlaybackEngineFamily.Vhs) {
|
||||
return InlinePlaybackPlayer.Html5;
|
||||
}
|
||||
return activeFamily === PlaybackEngineFamily.HlsJs
|
||||
? InlinePlaybackPlayer.VideoJs
|
||||
: null;
|
||||
}
|
||||
|
||||
function getActiveInlineCapability(
|
||||
context: PlaybackRecommendationContext,
|
||||
capabilityIndex: PlaybackCapabilityIndex | null
|
||||
): AvailableInlineCapability | null {
|
||||
const capability = capabilityIndex?.get(context.activeTarget);
|
||||
return capability?.kind === 'inline' &&
|
||||
capability.available &&
|
||||
capability.engineFamily !== null
|
||||
? (capability as AvailableInlineCapability)
|
||||
: null;
|
||||
}
|
||||
|
||||
function externalCodecSupport(
|
||||
target: ExternalPlayerName
|
||||
): PlaybackRecommendationCandidate {
|
||||
return external(
|
||||
target,
|
||||
PlaybackRecommendationReason.ExternalCodecOrContainerSupport
|
||||
);
|
||||
}
|
||||
|
||||
function external(
|
||||
target: ExternalPlayerName,
|
||||
reason: PlaybackRecommendationCandidate['reason']
|
||||
): PlaybackRecommendationCandidate {
|
||||
return { action: 'player', target, reason };
|
||||
}
|
||||
|
||||
function isPlayerOrientedDiagnostic(
|
||||
code: PlaybackRecommendationContext['diagnostic']['code']
|
||||
): boolean {
|
||||
return (
|
||||
code === PlaybackDiagnosticCode.BrowserAccessError ||
|
||||
code === PlaybackDiagnosticCode.UnsupportedCodec ||
|
||||
code === PlaybackDiagnosticCode.UnsupportedContainer ||
|
||||
code === PlaybackDiagnosticCode.MediaDecodeError ||
|
||||
code === PlaybackDiagnosticCode.DrmOrEncryption
|
||||
);
|
||||
}
|
||||
|
||||
function hasPlayerRecoveryEvidence(
|
||||
context: PlaybackRecommendationContext
|
||||
): boolean {
|
||||
return (
|
||||
isPlayerOrientedDiagnostic(context.diagnostic.code) ||
|
||||
isShakaBrowserUnsupported(context)
|
||||
);
|
||||
}
|
||||
|
||||
function isShakaBrowserUnsupported(
|
||||
context: PlaybackRecommendationContext
|
||||
): boolean {
|
||||
return (
|
||||
context.diagnostic.code ===
|
||||
PlaybackDiagnosticCode.UnknownPlaybackError &&
|
||||
context.diagnostic.source === PlaybackDiagnosticSource.Shaka &&
|
||||
context.diagnostic.runtimeSupport ===
|
||||
PlaybackRuntimeSupport.ShakaBrowserUnsupported &&
|
||||
context.source.kind === PlaybackSourceKind.Dash
|
||||
);
|
||||
}
|
||||
|
||||
function isExternalTarget(
|
||||
target: PlaybackRecommendationTarget
|
||||
): target is ExternalPlayerName {
|
||||
return target === 'mpv' || target === 'vlc';
|
||||
}
|
||||
|
||||
function canTransferExternally(
|
||||
context: PlaybackRecommendationContext
|
||||
): boolean {
|
||||
return (
|
||||
context.source.drm !== 'untransferable' &&
|
||||
context.source.externalTransferable
|
||||
);
|
||||
}
|
||||
|
||||
function isCandidate(
|
||||
candidate: PlaybackRecommendationCandidate | null
|
||||
): candidate is PlaybackRecommendationCandidate {
|
||||
return candidate !== null;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type {
|
||||
ExternalPlayerName,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type {
|
||||
InlinePlaybackPlayer,
|
||||
PlaybackDiagnostic,
|
||||
} from './diagnostics/playback-diagnostics.model';
|
||||
|
||||
export const PlaybackRecommendationReason = {
|
||||
RetryTransientFailure: 'retry-transient-failure',
|
||||
RetryUnknownFailure: 'retry-unknown-failure',
|
||||
AlternativeSourceAvailable: 'alternative-source-available',
|
||||
DifferentEngineFamily: 'different-engine-family',
|
||||
ExternalCodecOrContainerSupport: 'external-codec-or-container-support',
|
||||
ExternalBrowserAccess: 'external-browser-access',
|
||||
CompatibleDrmPath: 'compatible-drm-path',
|
||||
} as const;
|
||||
|
||||
export type PlaybackRecommendationReason =
|
||||
(typeof PlaybackRecommendationReason)[keyof typeof PlaybackRecommendationReason];
|
||||
|
||||
export type PlaybackRecommendationPriority = 'primary' | 'secondary';
|
||||
|
||||
export type PlaybackRecommendationTarget =
|
||||
| InlinePlaybackPlayer
|
||||
| ExternalPlayerName;
|
||||
|
||||
export const PlaybackSourceKind = {
|
||||
Hls: 'hls',
|
||||
MpegTs: 'mpegts',
|
||||
Dash: 'dash',
|
||||
Native: 'native',
|
||||
Unknown: 'unknown',
|
||||
} as const;
|
||||
|
||||
export type PlaybackSourceKind =
|
||||
(typeof PlaybackSourceKind)[keyof typeof PlaybackSourceKind];
|
||||
|
||||
export const PlaybackEngineFamily = {
|
||||
Vhs: 'vhs',
|
||||
HlsJs: 'hls.js',
|
||||
MpegTsJs: 'mpegts.js',
|
||||
Shaka: 'shaka',
|
||||
NativeMedia: 'native-media',
|
||||
} as const;
|
||||
|
||||
export type PlaybackEngineFamily =
|
||||
(typeof PlaybackEngineFamily)[keyof typeof PlaybackEngineFamily];
|
||||
|
||||
export type PlaybackTargetCapability =
|
||||
| {
|
||||
readonly kind: 'inline';
|
||||
readonly target: InlinePlaybackPlayer;
|
||||
readonly available: boolean;
|
||||
readonly engineFamily: PlaybackEngineFamily | null;
|
||||
}
|
||||
| {
|
||||
readonly kind: 'external';
|
||||
readonly target: ExternalPlayerName;
|
||||
readonly available: boolean;
|
||||
};
|
||||
|
||||
export interface PlaybackRecommendationSourceContext {
|
||||
readonly kind: PlaybackSourceKind;
|
||||
readonly isLive: boolean;
|
||||
readonly drm: 'none' | 'untransferable';
|
||||
readonly externalTransferable: boolean;
|
||||
}
|
||||
|
||||
export interface PlaybackRecommendationContext {
|
||||
readonly diagnostic: PlaybackDiagnostic;
|
||||
readonly activeTarget: PlaybackRecommendationTarget;
|
||||
readonly attemptedTargets: ReadonlySet<PlaybackRecommendationTarget>;
|
||||
readonly targetCapabilities: readonly PlaybackTargetCapability[];
|
||||
readonly source: PlaybackRecommendationSourceContext;
|
||||
readonly alternativeSourceCount: number;
|
||||
}
|
||||
|
||||
export type PlaybackRecommendation =
|
||||
| {
|
||||
readonly action: 'retry';
|
||||
readonly reason: PlaybackRecommendationReason;
|
||||
readonly priority: PlaybackRecommendationPriority;
|
||||
}
|
||||
| {
|
||||
readonly action: 'alternative-source';
|
||||
readonly reason: PlaybackRecommendationReason;
|
||||
readonly priority: PlaybackRecommendationPriority;
|
||||
}
|
||||
| {
|
||||
readonly action: 'player';
|
||||
readonly target: PlaybackRecommendationTarget;
|
||||
readonly reason: PlaybackRecommendationReason;
|
||||
readonly priority: PlaybackRecommendationPriority;
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
import * as playbackSessionKey from './playback-session-key';
|
||||
import type { PlaybackSessionIdentity } from './playback-session-key';
|
||||
|
||||
const { createPlaybackSessionKey } = playbackSessionKey;
|
||||
|
||||
describe('playback session key', () => {
|
||||
it('length-prefixes every identity part', () => {
|
||||
expect(
|
||||
createPlaybackSessionKey({
|
||||
kind: 'episode',
|
||||
sourceId: 'list:west|hd',
|
||||
contentId: 'ep:4|finale',
|
||||
seriesId: 'series|2:remaster',
|
||||
seasonNumber: 3,
|
||||
episodeNumber: 4,
|
||||
})
|
||||
).toBe(
|
||||
'7:episode|12:list:west|hd|11:ep:4|finale|17:series|2:remaster|1:3|1:4'
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
{ kind: 'live', sourceId: 'a', contentId: 'b|c' },
|
||||
{ kind: 'live', sourceId: 'a|b', contentId: 'c' },
|
||||
],
|
||||
[
|
||||
{ kind: 'vod', sourceId: 'a:1', contentId: '2' },
|
||||
{ kind: 'vod', sourceId: 'a', contentId: '1:2' },
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: 'episode',
|
||||
sourceId: 'source',
|
||||
contentId: 'content',
|
||||
seriesId: 'series:1|2',
|
||||
seasonNumber: 3,
|
||||
episodeNumber: 4,
|
||||
},
|
||||
{
|
||||
kind: 'episode',
|
||||
sourceId: 'source',
|
||||
contentId: 'content',
|
||||
seriesId: 'series',
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 2,
|
||||
},
|
||||
],
|
||||
] as const)(
|
||||
'does not collide delimiter-bearing identities %#',
|
||||
(left, right) => {
|
||||
expect(createPlaybackSessionKey(left)).not.toBe(
|
||||
createPlaybackSessionKey(right)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('reuses canonical movie identity across provider copies and source URLs', () => {
|
||||
const logicalIdentity: PlaybackSessionIdentity = {
|
||||
kind: 'vod',
|
||||
sourceId: 'catalog:featured',
|
||||
contentId: 'movie:42',
|
||||
};
|
||||
const originalCopy = {
|
||||
providerId: 'provider:one',
|
||||
sourceUrl: 'https://one.example/movie/42',
|
||||
logicalIdentity,
|
||||
};
|
||||
const alternativeCopy = {
|
||||
providerId: 'provider|two',
|
||||
sourceUrl: 'https://two.example/alternate/9001',
|
||||
logicalIdentity,
|
||||
};
|
||||
|
||||
expect(createPlaybackSessionKey(originalCopy.logicalIdentity)).toBe(
|
||||
createPlaybackSessionKey(alternativeCopy.logicalIdentity)
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ kind: 'live', sourceId: 'playlist:2', contentId: 42 }],
|
||||
[{ kind: 'live', sourceId: 'playlist:1', contentId: 43 }],
|
||||
[{ kind: 'vod', sourceId: 'playlist:1', contentId: 42 }],
|
||||
] as const)(
|
||||
'changes when a live channel identity changes: %o',
|
||||
(changedIdentity) => {
|
||||
const original = createPlaybackSessionKey({
|
||||
kind: 'live',
|
||||
sourceId: 'playlist:1',
|
||||
contentId: 42,
|
||||
});
|
||||
|
||||
expect(createPlaybackSessionKey(changedIdentity)).not.toBe(
|
||||
original
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
[{ kind: 'vod', sourceId: 'playlist:2', contentId: 42 }],
|
||||
[{ kind: 'vod', sourceId: 'playlist:1', contentId: 43 }],
|
||||
] as const)(
|
||||
'changes when a logical movie identity changes: %o',
|
||||
(changedIdentity) => {
|
||||
const original = createPlaybackSessionKey({
|
||||
kind: 'vod',
|
||||
sourceId: 'playlist:1',
|
||||
contentId: 42,
|
||||
});
|
||||
|
||||
expect(createPlaybackSessionKey(changedIdentity)).not.toBe(
|
||||
original
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
[
|
||||
'source',
|
||||
{
|
||||
kind: 'episode',
|
||||
sourceId: 'playlist:2',
|
||||
contentId: 42,
|
||||
seriesId: 7,
|
||||
seasonNumber: 3,
|
||||
episodeNumber: 4,
|
||||
},
|
||||
],
|
||||
[
|
||||
'content',
|
||||
{
|
||||
kind: 'episode',
|
||||
sourceId: 'playlist:1',
|
||||
contentId: 43,
|
||||
seriesId: 7,
|
||||
seasonNumber: 3,
|
||||
episodeNumber: 4,
|
||||
},
|
||||
],
|
||||
[
|
||||
'series',
|
||||
{
|
||||
kind: 'episode',
|
||||
sourceId: 'playlist:1',
|
||||
contentId: 42,
|
||||
seriesId: 8,
|
||||
seasonNumber: 3,
|
||||
episodeNumber: 4,
|
||||
},
|
||||
],
|
||||
[
|
||||
'season',
|
||||
{
|
||||
kind: 'episode',
|
||||
sourceId: 'playlist:1',
|
||||
contentId: 42,
|
||||
seriesId: 7,
|
||||
seasonNumber: 4,
|
||||
episodeNumber: 4,
|
||||
},
|
||||
],
|
||||
[
|
||||
'episode',
|
||||
{
|
||||
kind: 'episode',
|
||||
sourceId: 'playlist:1',
|
||||
contentId: 42,
|
||||
seriesId: 7,
|
||||
seasonNumber: 3,
|
||||
episodeNumber: 5,
|
||||
},
|
||||
],
|
||||
] as const)(
|
||||
'changes when the logical episode %s identity changes',
|
||||
(_field, changedIdentity) => {
|
||||
const original = createPlaybackSessionKey({
|
||||
kind: 'episode',
|
||||
sourceId: 'playlist:1',
|
||||
contentId: 42,
|
||||
seriesId: 7,
|
||||
seasonNumber: 3,
|
||||
episodeNumber: 4,
|
||||
});
|
||||
|
||||
expect(createPlaybackSessionKey(changedIdentity)).not.toBe(
|
||||
original
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('does not expose a provider-scoped content-info adapter', () => {
|
||||
expect(playbackSessionKey).not.toHaveProperty(
|
||||
'createPlaybackSessionKeyFromContentInfo'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Host-owned canonical logical content identity. Source and content IDs must
|
||||
* not come from the currently selected provider copy or playback URL.
|
||||
*/
|
||||
export type PlaybackSessionIdentity =
|
||||
| {
|
||||
readonly kind: 'live';
|
||||
readonly sourceId: string;
|
||||
readonly contentId: string | number;
|
||||
}
|
||||
| {
|
||||
readonly kind: 'vod';
|
||||
readonly sourceId: string;
|
||||
readonly contentId: string | number;
|
||||
}
|
||||
| {
|
||||
readonly kind: 'episode';
|
||||
readonly sourceId: string;
|
||||
readonly contentId: string | number;
|
||||
readonly seriesId?: string | number;
|
||||
readonly seasonNumber?: number;
|
||||
readonly episodeNumber?: number;
|
||||
};
|
||||
|
||||
export function createPlaybackSessionKey(
|
||||
identity: PlaybackSessionIdentity
|
||||
): string {
|
||||
const parts = [
|
||||
identity.kind,
|
||||
identity.sourceId,
|
||||
String(identity.contentId),
|
||||
identity.kind === 'episode' ? String(identity.seriesId ?? '') : '',
|
||||
identity.kind === 'episode' ? String(identity.seasonNumber ?? '') : '',
|
||||
identity.kind === 'episode' ? String(identity.episodeNumber ?? '') : '',
|
||||
];
|
||||
|
||||
return parts.map((part) => `${part.length}:${part}`).join('|');
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import {
|
||||
InlinePlaybackPlayer,
|
||||
PlaybackDiagnosticCode,
|
||||
PlaybackDiagnosticSource,
|
||||
type PlaybackDiagnostic,
|
||||
} from './diagnostics/playback-diagnostics.model';
|
||||
import {
|
||||
PlaybackEngineFamily,
|
||||
PlaybackSourceKind,
|
||||
} from './playback-recommendation.model';
|
||||
import {
|
||||
createPlaybackTargetCapabilities,
|
||||
getInlinePlaybackEngineFamily,
|
||||
resolvePlaybackSourceKind,
|
||||
} from './playback-target-capabilities';
|
||||
|
||||
function diagnostic(
|
||||
overrides: Partial<PlaybackDiagnostic>
|
||||
): PlaybackDiagnostic {
|
||||
return {
|
||||
code: PlaybackDiagnosticCode.UnknownPlaybackError,
|
||||
source: PlaybackDiagnosticSource.Source,
|
||||
sourceUrl: 'https://example.com/stream',
|
||||
container: '',
|
||||
audioCodecs: [],
|
||||
videoCodecs: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('playback target capabilities', () => {
|
||||
it.each([
|
||||
[
|
||||
PlaybackSourceKind.Hls,
|
||||
InlinePlaybackPlayer.VideoJs,
|
||||
PlaybackEngineFamily.Vhs,
|
||||
],
|
||||
[
|
||||
PlaybackSourceKind.Hls,
|
||||
InlinePlaybackPlayer.Html5,
|
||||
PlaybackEngineFamily.HlsJs,
|
||||
],
|
||||
[
|
||||
PlaybackSourceKind.Hls,
|
||||
InlinePlaybackPlayer.ArtPlayer,
|
||||
PlaybackEngineFamily.HlsJs,
|
||||
],
|
||||
[
|
||||
PlaybackSourceKind.MpegTs,
|
||||
InlinePlaybackPlayer.VideoJs,
|
||||
PlaybackEngineFamily.MpegTsJs,
|
||||
],
|
||||
[
|
||||
PlaybackSourceKind.MpegTs,
|
||||
InlinePlaybackPlayer.Html5,
|
||||
PlaybackEngineFamily.MpegTsJs,
|
||||
],
|
||||
[
|
||||
PlaybackSourceKind.MpegTs,
|
||||
InlinePlaybackPlayer.ArtPlayer,
|
||||
PlaybackEngineFamily.MpegTsJs,
|
||||
],
|
||||
[PlaybackSourceKind.Dash, InlinePlaybackPlayer.VideoJs, null],
|
||||
[
|
||||
PlaybackSourceKind.Dash,
|
||||
InlinePlaybackPlayer.Html5,
|
||||
PlaybackEngineFamily.Shaka,
|
||||
],
|
||||
[
|
||||
PlaybackSourceKind.Dash,
|
||||
InlinePlaybackPlayer.ArtPlayer,
|
||||
PlaybackEngineFamily.Shaka,
|
||||
],
|
||||
[
|
||||
PlaybackSourceKind.Native,
|
||||
InlinePlaybackPlayer.VideoJs,
|
||||
PlaybackEngineFamily.NativeMedia,
|
||||
],
|
||||
[
|
||||
PlaybackSourceKind.Native,
|
||||
InlinePlaybackPlayer.Html5,
|
||||
PlaybackEngineFamily.NativeMedia,
|
||||
],
|
||||
[
|
||||
PlaybackSourceKind.Native,
|
||||
InlinePlaybackPlayer.ArtPlayer,
|
||||
PlaybackEngineFamily.NativeMedia,
|
||||
],
|
||||
] as const)('maps %s on %s to %s', (sourceKind, target, expectedFamily) => {
|
||||
expect(getInlinePlaybackEngineFamily(sourceKind, target)).toBe(
|
||||
expectedFamily
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[PlaybackDiagnosticSource.Hls, {}, PlaybackSourceKind.Hls],
|
||||
[
|
||||
PlaybackDiagnosticSource.Vhs,
|
||||
{ container: 'm3u8' },
|
||||
PlaybackSourceKind.Hls,
|
||||
],
|
||||
[
|
||||
PlaybackDiagnosticSource.Source,
|
||||
{ container: 'm3u' },
|
||||
PlaybackSourceKind.Hls,
|
||||
],
|
||||
[
|
||||
PlaybackDiagnosticSource.Vhs,
|
||||
{
|
||||
mimeType: ' Application/Vnd.Apple.MPEGURL; charset=UTF-8 ',
|
||||
},
|
||||
PlaybackSourceKind.Hls,
|
||||
],
|
||||
[
|
||||
PlaybackDiagnosticSource.Source,
|
||||
{ mimeType: 'application/x-mpegurl' },
|
||||
PlaybackSourceKind.Hls,
|
||||
],
|
||||
[
|
||||
PlaybackDiagnosticSource.Vhs,
|
||||
{ mimeType: 'audio/x-mpegurl' },
|
||||
PlaybackSourceKind.Hls,
|
||||
],
|
||||
[PlaybackDiagnosticSource.Shaka, {}, PlaybackSourceKind.Dash],
|
||||
[
|
||||
PlaybackDiagnosticSource.Source,
|
||||
{ container: 'mpd' },
|
||||
PlaybackSourceKind.Dash,
|
||||
],
|
||||
[
|
||||
PlaybackDiagnosticSource.Vhs,
|
||||
{ container: 'mpd' },
|
||||
PlaybackSourceKind.Dash,
|
||||
],
|
||||
[
|
||||
PlaybackDiagnosticSource.Source,
|
||||
{ mimeType: ' Application/Dash+XML; profile=live ' },
|
||||
PlaybackSourceKind.Dash,
|
||||
],
|
||||
[PlaybackDiagnosticSource.MpegTs, {}, PlaybackSourceKind.MpegTs],
|
||||
[PlaybackDiagnosticSource.Native, {}, PlaybackSourceKind.Native],
|
||||
] as const)(
|
||||
'resolves %s diagnostics with %o as %s',
|
||||
(source, metadata, expectedKind) => {
|
||||
expect(
|
||||
resolvePlaybackSourceKind(diagnostic({ source, ...metadata }))
|
||||
).toBe(expectedKind);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
diagnostic({ source: PlaybackDiagnosticSource.Source }),
|
||||
diagnostic({
|
||||
source: PlaybackDiagnosticSource.Source,
|
||||
container: 'mp4',
|
||||
mimeType: 'video/mp4',
|
||||
}),
|
||||
diagnostic({
|
||||
source: PlaybackDiagnosticSource.Vhs,
|
||||
container: 'mpd',
|
||||
mimeType: 'application/vnd.apple.mpegurl',
|
||||
}),
|
||||
diagnostic({
|
||||
source: PlaybackDiagnosticSource.Source,
|
||||
container: 'm3u8',
|
||||
mimeType: 'application/dash+xml',
|
||||
}),
|
||||
diagnostic({
|
||||
source: PlaybackDiagnosticSource.Vhs,
|
||||
mimeType: 'application/not-mpegurl',
|
||||
}),
|
||||
])('keeps insufficient or contradictory evidence unknown', (issue) => {
|
||||
expect(resolvePlaybackSourceKind(issue)).toBe(
|
||||
PlaybackSourceKind.Unknown
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
PlaybackDiagnosticSource.Hls,
|
||||
{ container: 'mpd', mimeType: 'application/dash+xml' },
|
||||
PlaybackSourceKind.Hls,
|
||||
],
|
||||
[
|
||||
PlaybackDiagnosticSource.MpegTs,
|
||||
{ container: 'mpd', mimeType: 'application/dash+xml' },
|
||||
PlaybackSourceKind.MpegTs,
|
||||
],
|
||||
[
|
||||
PlaybackDiagnosticSource.Shaka,
|
||||
{
|
||||
container: 'm3u8',
|
||||
mimeType: 'application/vnd.apple.mpegurl',
|
||||
},
|
||||
PlaybackSourceKind.Dash,
|
||||
],
|
||||
[
|
||||
PlaybackDiagnosticSource.Native,
|
||||
{
|
||||
container: 'm3u8',
|
||||
mimeType: 'application/vnd.apple.mpegurl',
|
||||
},
|
||||
PlaybackSourceKind.Native,
|
||||
],
|
||||
] as const)(
|
||||
'treats engine-specific %s source evidence as authoritative',
|
||||
(source, metadata, expectedKind) => {
|
||||
expect(
|
||||
resolvePlaybackSourceKind(diagnostic({ source, ...metadata }))
|
||||
).toBe(expectedKind);
|
||||
}
|
||||
);
|
||||
|
||||
it('creates capabilities in canonical order', () => {
|
||||
expect(
|
||||
createPlaybackTargetCapabilities({
|
||||
sourceKind: PlaybackSourceKind.Hls,
|
||||
managedExternalPlayersAvailable: true,
|
||||
})
|
||||
).toEqual([
|
||||
{
|
||||
kind: 'inline',
|
||||
target: InlinePlaybackPlayer.VideoJs,
|
||||
available: true,
|
||||
engineFamily: PlaybackEngineFamily.Vhs,
|
||||
},
|
||||
{
|
||||
kind: 'inline',
|
||||
target: InlinePlaybackPlayer.Html5,
|
||||
available: true,
|
||||
engineFamily: PlaybackEngineFamily.HlsJs,
|
||||
},
|
||||
{
|
||||
kind: 'inline',
|
||||
target: InlinePlaybackPlayer.ArtPlayer,
|
||||
available: true,
|
||||
engineFamily: PlaybackEngineFamily.HlsJs,
|
||||
},
|
||||
{ kind: 'external', target: 'mpv', available: true },
|
||||
{ kind: 'external', target: 'vlc', available: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks Video.js unavailable for DASH and mirrors external availability', () => {
|
||||
expect(
|
||||
createPlaybackTargetCapabilities({
|
||||
sourceKind: PlaybackSourceKind.Dash,
|
||||
managedExternalPlayersAvailable: false,
|
||||
})
|
||||
).toEqual([
|
||||
{
|
||||
kind: 'inline',
|
||||
target: InlinePlaybackPlayer.VideoJs,
|
||||
available: false,
|
||||
engineFamily: null,
|
||||
},
|
||||
{
|
||||
kind: 'inline',
|
||||
target: InlinePlaybackPlayer.Html5,
|
||||
available: true,
|
||||
engineFamily: PlaybackEngineFamily.Shaka,
|
||||
},
|
||||
{
|
||||
kind: 'inline',
|
||||
target: InlinePlaybackPlayer.ArtPlayer,
|
||||
available: true,
|
||||
engineFamily: PlaybackEngineFamily.Shaka,
|
||||
},
|
||||
{ kind: 'external', target: 'mpv', available: false },
|
||||
{ kind: 'external', target: 'vlc', available: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('fails closed for unknown inline source capabilities', () => {
|
||||
expect(
|
||||
createPlaybackTargetCapabilities({
|
||||
sourceKind: PlaybackSourceKind.Unknown,
|
||||
managedExternalPlayersAvailable: true,
|
||||
})
|
||||
).toEqual([
|
||||
{
|
||||
kind: 'inline',
|
||||
target: InlinePlaybackPlayer.VideoJs,
|
||||
available: false,
|
||||
engineFamily: null,
|
||||
},
|
||||
{
|
||||
kind: 'inline',
|
||||
target: InlinePlaybackPlayer.Html5,
|
||||
available: false,
|
||||
engineFamily: null,
|
||||
},
|
||||
{
|
||||
kind: 'inline',
|
||||
target: InlinePlaybackPlayer.ArtPlayer,
|
||||
available: false,
|
||||
engineFamily: null,
|
||||
},
|
||||
{ kind: 'external', target: 'mpv', available: true },
|
||||
{ kind: 'external', target: 'vlc', available: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
InlinePlaybackPlayer,
|
||||
PlaybackDiagnosticSource,
|
||||
type PlaybackDiagnostic,
|
||||
} from './diagnostics/playback-diagnostics.model';
|
||||
import {
|
||||
PlaybackEngineFamily,
|
||||
PlaybackSourceKind,
|
||||
type PlaybackTargetCapability,
|
||||
} from './playback-recommendation.model';
|
||||
|
||||
const HLS_MIME_TYPES = new Set([
|
||||
'application/vnd.apple.mpegurl',
|
||||
'application/x-mpegurl',
|
||||
'audio/x-mpegurl',
|
||||
]);
|
||||
const DASH_MIME_TYPE = 'application/dash+xml';
|
||||
|
||||
export function resolvePlaybackSourceKind(
|
||||
diagnostic: PlaybackDiagnostic
|
||||
): PlaybackSourceKind {
|
||||
switch (diagnostic.source) {
|
||||
// Engine-specific sources identify the boundary that emitted the
|
||||
// failure, so generic container/MIME metadata cannot override them.
|
||||
case PlaybackDiagnosticSource.Hls:
|
||||
return PlaybackSourceKind.Hls;
|
||||
case PlaybackDiagnosticSource.MpegTs:
|
||||
return PlaybackSourceKind.MpegTs;
|
||||
case PlaybackDiagnosticSource.Shaka:
|
||||
return PlaybackSourceKind.Dash;
|
||||
case PlaybackDiagnosticSource.Source:
|
||||
case PlaybackDiagnosticSource.Vhs:
|
||||
return resolveSourceOrVhsKind(diagnostic);
|
||||
case PlaybackDiagnosticSource.Native:
|
||||
return PlaybackSourceKind.Native;
|
||||
default:
|
||||
return PlaybackSourceKind.Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
export function getInlinePlaybackEngineFamily(
|
||||
sourceKind: PlaybackSourceKind,
|
||||
target: InlinePlaybackPlayer
|
||||
): PlaybackEngineFamily | null {
|
||||
switch (sourceKind) {
|
||||
case PlaybackSourceKind.Hls:
|
||||
return target === InlinePlaybackPlayer.VideoJs
|
||||
? PlaybackEngineFamily.Vhs
|
||||
: PlaybackEngineFamily.HlsJs;
|
||||
case PlaybackSourceKind.MpegTs:
|
||||
return PlaybackEngineFamily.MpegTsJs;
|
||||
case PlaybackSourceKind.Dash:
|
||||
return target === InlinePlaybackPlayer.VideoJs
|
||||
? null
|
||||
: PlaybackEngineFamily.Shaka;
|
||||
case PlaybackSourceKind.Native:
|
||||
return PlaybackEngineFamily.NativeMedia;
|
||||
case PlaybackSourceKind.Unknown:
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createPlaybackTargetCapabilities(options: {
|
||||
readonly sourceKind: PlaybackSourceKind;
|
||||
readonly managedExternalPlayersAvailable: boolean;
|
||||
}): readonly PlaybackTargetCapability[] {
|
||||
const inlineTargets = [
|
||||
InlinePlaybackPlayer.VideoJs,
|
||||
InlinePlaybackPlayer.Html5,
|
||||
InlinePlaybackPlayer.ArtPlayer,
|
||||
] as const;
|
||||
const inlineCapabilities = inlineTargets.map((target) => {
|
||||
const engineFamily = getInlinePlaybackEngineFamily(
|
||||
options.sourceKind,
|
||||
target
|
||||
);
|
||||
return {
|
||||
kind: 'inline' as const,
|
||||
target,
|
||||
available: engineFamily !== null,
|
||||
engineFamily,
|
||||
};
|
||||
});
|
||||
|
||||
return [
|
||||
...inlineCapabilities,
|
||||
{
|
||||
kind: 'external',
|
||||
target: 'mpv',
|
||||
available: options.managedExternalPlayersAvailable,
|
||||
},
|
||||
{
|
||||
kind: 'external',
|
||||
target: 'vlc',
|
||||
available: options.managedExternalPlayersAvailable,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function resolveSourceOrVhsKind(
|
||||
diagnostic: PlaybackDiagnostic
|
||||
): PlaybackSourceKind {
|
||||
const containerKind = resolveContainerKind(diagnostic.container);
|
||||
const mimeTypeKind = resolveMimeTypeKind(diagnostic.mimeType);
|
||||
|
||||
if (
|
||||
containerKind !== null &&
|
||||
mimeTypeKind !== null &&
|
||||
containerKind !== mimeTypeKind
|
||||
) {
|
||||
return PlaybackSourceKind.Unknown;
|
||||
}
|
||||
|
||||
return containerKind ?? mimeTypeKind ?? PlaybackSourceKind.Unknown;
|
||||
}
|
||||
|
||||
function resolveContainerKind(container: string): PlaybackSourceKind | null {
|
||||
if (container === 'm3u' || container === 'm3u8') {
|
||||
return PlaybackSourceKind.Hls;
|
||||
}
|
||||
return container === 'mpd' ? PlaybackSourceKind.Dash : null;
|
||||
}
|
||||
|
||||
function resolveMimeTypeKind(
|
||||
mimeType: string | undefined
|
||||
): PlaybackSourceKind | null {
|
||||
const baseMimeType = mimeType?.split(';', 1)[0]?.trim().toLowerCase() ?? '';
|
||||
if (HLS_MIME_TYPES.has(baseMimeType)) {
|
||||
return PlaybackSourceKind.Hls;
|
||||
}
|
||||
return baseMimeType === DASH_MIME_TYPE ? PlaybackSourceKind.Dash : null;
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, relative } from 'node:path';
|
||||
import ts from 'typescript';
|
||||
|
||||
const PROJECT_SOURCE_ROOT = join(process.cwd(), 'libs/playback/util/src');
|
||||
const FORBIDDEN_IMPORT_PREFIXES = [
|
||||
'@angular/',
|
||||
'@iptvnator/ui/',
|
||||
'electron',
|
||||
'@ngx-pwa/',
|
||||
] as const;
|
||||
const FORBIDDEN_GLOBALS = new Set([
|
||||
'window',
|
||||
'document',
|
||||
'localStorage',
|
||||
'sessionStorage',
|
||||
'MediaSource',
|
||||
'MediaError',
|
||||
'HTMLMediaElement',
|
||||
'HTMLElement',
|
||||
'navigator',
|
||||
'indexedDB',
|
||||
]);
|
||||
|
||||
describe('playback util boundary', () => {
|
||||
it('keeps production sources independent of UI and browser globals', () => {
|
||||
const violations = collectProductionSources(
|
||||
PROJECT_SOURCE_ROOT
|
||||
).flatMap(findBoundaryViolations);
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['import declaration', "import '@angular/core';", '@angular/core'],
|
||||
[
|
||||
're-export declaration',
|
||||
"export * from '@iptvnator/ui/components';",
|
||||
'@iptvnator/ui/components',
|
||||
],
|
||||
[
|
||||
'import type node',
|
||||
"type AngularType = import('@angular/core').Type;",
|
||||
'@angular/core',
|
||||
],
|
||||
[
|
||||
'dynamic import',
|
||||
"void import('@ngx-pwa/service-worker');",
|
||||
'@ngx-pwa/service-worker',
|
||||
],
|
||||
[
|
||||
'import-equals declaration',
|
||||
"import Electron = require('electron'); void Electron;",
|
||||
'electron',
|
||||
],
|
||||
])('rejects forbidden sources in a %s', (_label, source, moduleName) => {
|
||||
const violations = findFixtureViolations(source);
|
||||
|
||||
expect(violations).toEqual([expect.stringContaining(moduleName)]);
|
||||
});
|
||||
|
||||
it('allows non-forbidden sources in every supported import form', () => {
|
||||
const violations = findFixtureViolations(`
|
||||
import './imported';
|
||||
export * from './exported';
|
||||
type LocalType = import('./typed').Type;
|
||||
void import('./dynamic');
|
||||
import Local = require('./required');
|
||||
void Local;
|
||||
`);
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['direct identifier', 'void navigator;', 'navigator'],
|
||||
['direct storage identifier', 'void indexedDB;', 'indexedDB'],
|
||||
[
|
||||
'globalThis property access',
|
||||
'void globalThis.MediaSource;',
|
||||
'MediaSource',
|
||||
],
|
||||
['self property access', 'void self.indexedDB;', 'indexedDB'],
|
||||
[
|
||||
'globalThis element access',
|
||||
"void globalThis['localStorage'];",
|
||||
'localStorage',
|
||||
],
|
||||
[
|
||||
'self element access',
|
||||
"void self['sessionStorage'];",
|
||||
'sessionStorage',
|
||||
],
|
||||
[
|
||||
'globalThis destructuring',
|
||||
'const { navigator } = globalThis;',
|
||||
'navigator',
|
||||
],
|
||||
[
|
||||
'self destructuring',
|
||||
'const { indexedDB: database } = self; void database;',
|
||||
'indexedDB',
|
||||
],
|
||||
])(
|
||||
'rejects forbidden browser globals through %s',
|
||||
(_label, source, name) => {
|
||||
const violations = findFixtureViolations(source);
|
||||
|
||||
expect(violations).toEqual([expect.stringContaining(name)]);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
['property access', 'void source.navigator;'],
|
||||
['element access', "void source['indexedDB'];"],
|
||||
['destructuring', 'const { navigator } = source;'],
|
||||
[
|
||||
'property declaration',
|
||||
'const config = { MediaSource: source }; void config;',
|
||||
],
|
||||
['shadowed self', 'const self = source; void self.MediaSource;'],
|
||||
[
|
||||
'local forbidden-name binding',
|
||||
'const navigator = source.navigator; void navigator;',
|
||||
],
|
||||
])('allows ordinary local %s names', (_label, source) => {
|
||||
const violations = findFixtureViolations(source);
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
function findFixtureViolations(source: string): string[] {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'playback-util-boundary-'));
|
||||
const filePath = join(directory, 'fixture.ts');
|
||||
try {
|
||||
writeFileSync(filePath, `${source}\nexport {};`);
|
||||
return findBoundaryViolations(filePath);
|
||||
} finally {
|
||||
rmSync(directory, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function collectProductionSources(directory: string): string[] {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return collectProductionSources(path);
|
||||
}
|
||||
return entry.isFile() &&
|
||||
entry.name.endsWith('.ts') &&
|
||||
!entry.name.endsWith('.spec.ts') &&
|
||||
!entry.name.endsWith('.test.ts')
|
||||
? [path]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
function findBoundaryViolations(filePath: string): string[] {
|
||||
const program = ts.createProgram({
|
||||
rootNames: [filePath],
|
||||
options: {
|
||||
lib: ['lib.es2022.d.ts', 'lib.dom.d.ts'],
|
||||
module: ts.ModuleKind.ESNext,
|
||||
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
||||
noEmit: true,
|
||||
noResolve: true,
|
||||
skipLibCheck: true,
|
||||
target: ts.ScriptTarget.ESNext,
|
||||
},
|
||||
});
|
||||
const sourceFile = program.getSourceFile(filePath);
|
||||
if (!sourceFile) {
|
||||
throw new Error(`Unable to parse ${filePath}`);
|
||||
}
|
||||
const checker = program.getTypeChecker();
|
||||
const violations: string[] = [];
|
||||
|
||||
sourceFile.forEachChild(function visit(node): void {
|
||||
const moduleSpecifier = getExternalModuleSpecifier(node);
|
||||
if (moduleSpecifier && isForbiddenModuleSpecifier(moduleSpecifier)) {
|
||||
violations.push(formatViolation(sourceFile, node, moduleSpecifier));
|
||||
}
|
||||
for (const globalName of getForbiddenGlobalPropertyAccesses(
|
||||
node,
|
||||
checker,
|
||||
sourceFile
|
||||
)) {
|
||||
violations.push(formatViolation(sourceFile, node, globalName));
|
||||
}
|
||||
if (
|
||||
ts.isIdentifier(node) &&
|
||||
FORBIDDEN_GLOBALS.has(node.text) &&
|
||||
isAmbientIdentifierReference(node, checker, sourceFile)
|
||||
) {
|
||||
violations.push(formatViolation(sourceFile, node, node.text));
|
||||
}
|
||||
node.forEachChild(visit);
|
||||
});
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
function getExternalModuleSpecifier(node: ts.Node): string | undefined {
|
||||
if (
|
||||
(ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) &&
|
||||
node.moduleSpecifier
|
||||
) {
|
||||
return getStaticString(node.moduleSpecifier);
|
||||
}
|
||||
if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
|
||||
return getStaticString(node.argument.literal);
|
||||
}
|
||||
if (
|
||||
ts.isCallExpression(node) &&
|
||||
node.expression.kind === ts.SyntaxKind.ImportKeyword
|
||||
) {
|
||||
return getStaticString(node.arguments[0]);
|
||||
}
|
||||
if (
|
||||
ts.isImportEqualsDeclaration(node) &&
|
||||
ts.isExternalModuleReference(node.moduleReference)
|
||||
) {
|
||||
return getStaticString(node.moduleReference.expression);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getStaticString(node?: ts.Node): string | undefined {
|
||||
return node &&
|
||||
(ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
|
||||
? node.text
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function isForbiddenModuleSpecifier(moduleSpecifier: string): boolean {
|
||||
return FORBIDDEN_IMPORT_PREFIXES.some((prefix) =>
|
||||
moduleSpecifier.startsWith(prefix)
|
||||
);
|
||||
}
|
||||
|
||||
function getForbiddenGlobalPropertyAccesses(
|
||||
node: ts.Node,
|
||||
checker: ts.TypeChecker,
|
||||
sourceFile: ts.SourceFile
|
||||
): string[] {
|
||||
if (
|
||||
ts.isPropertyAccessExpression(node) &&
|
||||
isAmbientGlobalRoot(node.expression, checker, sourceFile) &&
|
||||
FORBIDDEN_GLOBALS.has(node.name.text)
|
||||
) {
|
||||
return [node.name.text];
|
||||
}
|
||||
if (
|
||||
ts.isElementAccessExpression(node) &&
|
||||
isAmbientGlobalRoot(node.expression, checker, sourceFile)
|
||||
) {
|
||||
const propertyName = getStaticString(node.argumentExpression);
|
||||
return propertyName && FORBIDDEN_GLOBALS.has(propertyName)
|
||||
? [propertyName]
|
||||
: [];
|
||||
}
|
||||
if (
|
||||
ts.isVariableDeclaration(node) &&
|
||||
ts.isObjectBindingPattern(node.name) &&
|
||||
node.initializer &&
|
||||
isAmbientGlobalRoot(node.initializer, checker, sourceFile)
|
||||
) {
|
||||
return node.name.elements.flatMap((element) => {
|
||||
const propertyName = getBindingPropertyName(element);
|
||||
return propertyName && FORBIDDEN_GLOBALS.has(propertyName)
|
||||
? [propertyName]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function getBindingPropertyName(
|
||||
element: ts.BindingElement
|
||||
): string | undefined {
|
||||
const property = element.propertyName ?? element.name;
|
||||
return ts.isIdentifier(property) || ts.isStringLiteral(property)
|
||||
? property.text
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function isAmbientGlobalRoot(
|
||||
expression: ts.Expression,
|
||||
checker: ts.TypeChecker,
|
||||
sourceFile: ts.SourceFile
|
||||
): boolean {
|
||||
return (
|
||||
ts.isIdentifier(expression) &&
|
||||
(expression.text === 'globalThis' || expression.text === 'self') &&
|
||||
isAmbientIdentifier(expression, checker, sourceFile)
|
||||
);
|
||||
}
|
||||
|
||||
function isAmbientIdentifierReference(
|
||||
identifier: ts.Identifier,
|
||||
checker: ts.TypeChecker,
|
||||
sourceFile: ts.SourceFile
|
||||
): boolean {
|
||||
return (
|
||||
!isPropertyName(identifier) &&
|
||||
isAmbientIdentifier(identifier, checker, sourceFile)
|
||||
);
|
||||
}
|
||||
|
||||
function isAmbientIdentifier(
|
||||
identifier: ts.Identifier,
|
||||
checker: ts.TypeChecker,
|
||||
sourceFile: ts.SourceFile
|
||||
): boolean {
|
||||
const symbol = checker.getSymbolAtLocation(identifier);
|
||||
return !symbol?.declarations?.some(
|
||||
(declaration) => declaration.getSourceFile() === sourceFile
|
||||
);
|
||||
}
|
||||
|
||||
function isPropertyName(identifier: ts.Identifier): boolean {
|
||||
const { parent } = identifier;
|
||||
return (
|
||||
(ts.isPropertyAccessExpression(parent) && parent.name === identifier) ||
|
||||
(ts.isBindingElement(parent) &&
|
||||
(parent.name === identifier ||
|
||||
parent.propertyName === identifier)) ||
|
||||
((ts.isPropertyAssignment(parent) ||
|
||||
ts.isPropertyDeclaration(parent) ||
|
||||
ts.isPropertySignature(parent) ||
|
||||
ts.isMethodDeclaration(parent) ||
|
||||
ts.isMethodSignature(parent) ||
|
||||
ts.isEnumMember(parent)) &&
|
||||
parent.name === identifier)
|
||||
);
|
||||
}
|
||||
|
||||
function formatViolation(
|
||||
sourceFile: ts.SourceFile,
|
||||
node: ts.Node,
|
||||
detail: string
|
||||
): string {
|
||||
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
|
||||
return `${relative(process.cwd(), sourceFile.fileName)}:${line + 1}: ${detail}`;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"isolatedModules": true,
|
||||
"target": "es2022",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"emitDecoratorMetadata": false,
|
||||
"module": "preserve"
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"module": "ESNext",
|
||||
"target": "ESNext",
|
||||
"esModuleInterop": true,
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"include": ["src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"]
|
||||
}
|
||||
Reference in new issue
Block a user