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:
4gray authored and GitHub committed 2026-08-08 01:04:39 +02:00
1 parent 5e4f2ca3dd
commit fd96b85c19
176 files changed
+15228 -1908

No files matched your search

+28
View File
@@ -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"
}
}
}
+9
View File
@@ -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}`;
}
+25
View File
@@ -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"
}
]
}
+12
View File
@@ -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"]
}
+12
View File
@@ -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"]
}