fix(playback): structure Video.js diagnostics

This commit is contained in:
4gray committed 2026-07-31 08:40:33 +02:00
1 parent 04faa50548
commit 877d632255
13 files changed
+1016 -61

No files matched your search

@@ -20,6 +20,7 @@ export type PlaybackDiagnosticCode =
export const PlaybackDiagnosticSource = {
Source: 'source',
Native: 'native',
Vhs: 'vhs',
Hls: 'hls',
MpegTs: 'mpegts',
Shaka: 'shaka',
@@ -66,6 +67,67 @@ export interface NativePlaybackErrorInput {
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',
@@ -100,8 +162,7 @@ export type HlsPlaybackFailure =
export const HlsPlaybackUnknownEngineType = 'unknown' as const;
export type HlsPlaybackEngineType =
| ErrorTypes
| typeof HlsPlaybackUnknownEngineType;
ErrorTypes | typeof HlsPlaybackUnknownEngineType;
export interface HlsPlaybackEvidence {
readonly engineType: HlsPlaybackEngineType;
@@ -133,6 +194,7 @@ export interface PlaybackDiagnostic {
readonly nativeErrorMessage?: string;
readonly httpStatus?: number;
readonly nativeErrorType?: string;
readonly vhs?: VhsPlaybackEvidence;
readonly hls?: HlsPlaybackEvidence;
readonly externalFallbackRecommended: boolean;
}
@@ -1,4 +1,10 @@
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,
@@ -25,13 +31,17 @@ function classifyStructuredHlsPlaybackIssue(
return classifyHlsPlaybackIssue(evidence as never, metadata);
}
type VhsClassifier = (
error: NativePlaybackErrorInput,
metadata: PlaybackSourceMetadata
) => PlaybackDiagnostic;
describe('playback diagnostics', () => {
it('classifies HLS incompatible codec errors as unsupported codec fallbacks', () => {
const issue = classifyStructuredHlsPlaybackIssue(
{
engineType: ErrorTypes.MEDIA_ERROR,
engineDetails:
ErrorDetails.MANIFEST_INCOMPATIBLE_CODECS_ERROR,
engineDetails: ErrorDetails.MANIFEST_INCOMPATIBLE_CODECS_ERROR,
disposition: 'fatal',
stage: 'manifest',
failure: 'unknown',
@@ -55,8 +65,7 @@ describe('playback diagnostics', () => {
const issue = classifyStructuredHlsPlaybackIssue(
{
engineType: ErrorTypes.MEDIA_ERROR,
engineDetails:
ErrorDetails.BUFFER_INCOMPATIBLE_CODECS_ERROR,
engineDetails: ErrorDetails.BUFFER_INCOMPATIBLE_CODECS_ERROR,
disposition: 'fatal',
stage: 'media',
failure: 'unknown',
@@ -146,6 +155,150 @@ describe('playback diagnostics', () => {
expect(issue.externalFallbackRecommended).toBe(false);
});
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,
externalFallbackRecommended: false,
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.externalFallbackRecommended).toBe(true);
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.externalFallbackRecommended).toBe(false);
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' },
@@ -186,45 +339,49 @@ describe('playback diagnostics', () => {
{ 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',
})
);
])(
'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);
});
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',
})
);
])(
'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);
});
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(
@@ -371,9 +528,7 @@ describe('playback diagnostics', () => {
})
);
expect(issue?.code).toBe(
PlaybackDiagnosticCode.MediaDecodeError
);
expect(issue?.code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
}
);
@@ -392,9 +547,7 @@ describe('playback diagnostics', () => {
})
);
expect(issue?.code).toBe(
PlaybackDiagnosticCode.UnknownPlaybackError
);
expect(issue?.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
expect(issue?.externalFallbackRecommended).toBe(false);
});
@@ -586,3 +739,20 @@ describe('playback diagnostics', () => {
).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);
}
@@ -6,10 +6,16 @@ import type {
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
PlaybackSourceMetadata,
VhsPlaybackEngineType as VhsPlaybackEngineTypeValue,
VhsPlaybackEvidence,
} from './playback-diagnostics.model';
import { PlaybackDiagnosticCode as DiagnosticCode } from './playback-diagnostics.model';
import { HlsPlaybackDisposition } from './playback-diagnostics.model';
import { PlaybackDiagnosticSource as DiagnosticSource } from './playback-diagnostics.model';
import {
VhsPlaybackEngineType,
VhsPlaybackMediaErrorCode,
} from './playback-diagnostics.model';
import { getHlsPlaybackDiagnosticCode } from './hls-playback-evidence.util';
import {
isBrowserAccessFailure,
@@ -18,9 +24,11 @@ import {
normalizeErrorDetails,
} 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 { createVhsPlaybackEvidence } from './vhs-playback-evidence.util';
export {
createPlaybackSourceMetadata,
getLikelyBrowserUnsupportedCodecLabels,
@@ -31,6 +39,14 @@ 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 | MediaError | null | undefined,
@@ -38,7 +54,8 @@ export function classifyNativePlaybackIssue(
): PlaybackDiagnostic {
const nativeErrorCode = error?.code;
const nativeErrorMessage = error?.message || undefined;
const nativeErrorInput = error as NativePlaybackErrorInput | null | undefined;
const nativeErrorInput = error as
NativePlaybackErrorInput | null | undefined;
const httpStatus = getNativeHttpStatus(nativeErrorInput?.status);
const nativeErrorType = getNativeErrorType(
nativeErrorInput?.metadata?.errorType
@@ -107,6 +124,26 @@ export function classifyNativePlaybackIssue(
});
}
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
@@ -218,6 +255,7 @@ export function createPlaybackDiagnostic(options: {
readonly nativeErrorMessage?: string;
readonly httpStatus?: number;
readonly nativeErrorType?: string;
readonly vhs?: VhsPlaybackEvidence;
readonly hls?: HlsPlaybackEvidence;
/** Overrides the code-derived recommendation, e.g. when external players
* are known to be unable to handle the stream either. */
@@ -232,6 +270,7 @@ export function createPlaybackDiagnostic(options: {
nativeErrorMessage,
httpStatus,
nativeErrorType,
vhs,
hls,
} = options;
@@ -249,6 +288,7 @@ export function createPlaybackDiagnostic(options: {
nativeErrorMessage,
httpStatus,
nativeErrorType,
vhs,
hls,
externalFallbackRecommended:
options.externalFallbackRecommended ??
@@ -256,6 +296,37 @@ export function createPlaybackDiagnostic(options: {
};
}
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) &&
@@ -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;
}
@@ -204,6 +204,7 @@ describe('VjsPlayerComponent', () => {
it('preserves Video.js HTTP error context in a playback diagnostic', () => {
const issues: Array<PlaybackDiagnostic | null> = [];
component.playbackIssue.subscribe((issue) => issues.push(issue));
harness.vhsActive = true;
render({
sources: [
{
@@ -224,15 +225,112 @@ describe('VjsPlayerComponent', () => {
expect(issues.at(-1)).toEqual(
expect.objectContaining({
code: 'network-error',
source: 'native',
source: 'vhs',
sourceUrl: 'https://example.test/missing/playlist.m3u8',
httpStatus: 404,
nativeErrorType: 'networkrequestfailed',
nativeErrorMessage: undefined,
vhs: {
engineType: 'networkrequestfailed',
mediaErrorCode: 4,
disposition: 'terminal',
stage: 'unknown',
httpStatus: 404,
},
externalFallbackRecommended: false,
})
);
});
it('keeps a generic terminal VHS code three unknown', () => {
const issues: Array<PlaybackDiagnostic | null> = [];
component.playbackIssue.subscribe((issue) => issues.push(issue));
harness.vhsActive = true;
render({
sources: [
{
src: 'https://example.test/live/playlist.m3u8',
type: 'application/x-mpegURL',
},
],
});
harness.currentError = {
code: 3,
message:
'Playback cannot continue. No available working or supported playlists.',
};
harness.emit('error');
expect(issues.at(-1)).toEqual(
expect.objectContaining({
code: 'unknown-playback-error',
source: 'vhs',
nativeErrorMessage: undefined,
vhs: {
engineType: 'unknown',
mediaErrorCode: 3,
disposition: 'terminal',
stage: 'unknown',
},
externalFallbackRecommended: false,
})
);
});
it('keeps non-VHS native code three as media decode evidence', () => {
const issues: Array<PlaybackDiagnostic | null> = [];
component.playbackIssue.subscribe((issue) => issues.push(issue));
render({
sources: [
{
src: 'https://example.test/archive/movie.mp4',
type: 'video/mp4',
},
],
});
harness.currentError = {
code: 3,
message: 'Native media decode failed',
};
harness.emit('error');
expect(issues.at(-1)).toEqual(
expect.objectContaining({
code: 'media-decode-error',
source: 'native',
nativeErrorMessage: 'Native media decode failed',
externalFallbackRecommended: true,
})
);
});
it('does not publish a diagnostic before the terminal VHS player error', () => {
const issues: Array<PlaybackDiagnostic | null> = [];
component.playbackIssue.subscribe((issue) => issues.push(issue));
harness.vhsActive = true;
render({
sources: [
{
src: 'https://example.test/live/playlist.m3u8',
type: 'application/x-mpegURL',
},
],
});
harness.currentError = {
code: 2,
metadata: { errorType: 'networkrequestfailed' },
};
harness.emit('retryplaylist');
expect(issues).toEqual([]);
harness.emit('error');
expect(issues.at(-1)?.vhs?.disposition).toBe('terminal');
});
it('rebinds native ended handling after playerreset', () => {
const events: string[] = [];
component.playbackEnded.subscribe(() => events.push('ended'));
@@ -312,6 +410,7 @@ function createPlayerHarness() {
const harness = {
currentVideo: document.createElement('video'),
currentError: null as NativePlaybackErrorInput | null,
vhsActive: false,
paused: true,
pauseCompletesImmediately: true,
ready: () => undefined,
@@ -355,7 +454,10 @@ function createPlayerHarness() {
paused: jest.fn(() => harness.paused),
reset: harness.reset,
src: harness.src,
tech: jest.fn(() => ({ el: () => harness.currentVideo })),
tech: jest.fn(() => ({
el: () => harness.currentVideo,
...(harness.vhsActive ? { vhs: {} } : {}),
})),
volume: harness.volume,
dispose: jest.fn(),
qualitySelectorHls: jest.fn(),
@@ -21,6 +21,7 @@ import {
InlinePlaybackPlayer,
type PlaybackDiagnostic,
classifyNativePlaybackIssue,
classifyVhsPlaybackIssue,
createPlaybackSourceMetadata,
} from '../playback-diagnostics/playback-diagnostics.util';
import {
@@ -48,6 +49,7 @@ import {
type VideoPlayerOptions,
type VideoPlayerSource,
getVideoJsTechVideo,
hasActiveVhsSourceHandler,
} from './vjs-player.types';
import { VjsVideoElementSession } from './vjs-video-element-session';
@@ -239,16 +241,19 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
typeof this.player.error === 'function'
? this.player.error()
: null;
const metadata = createPlaybackSourceMetadata({
url: source?.src ?? video.currentSrc ?? '',
mimeType: source?.type,
player: InlinePlaybackPlayer.VideoJs,
});
this.mpegTsSession.syncDuration();
this.playbackIssue.emit(
classifyNativePlaybackIssue(
playerError ?? video.error,
createPlaybackSourceMetadata({
url: source?.src ?? video.currentSrc ?? '',
mimeType: source?.type,
player: InlinePlaybackPlayer.VideoJs,
})
)
playerError && hasActiveVhsSourceHandler(this.player)
? classifyVhsPlaybackIssue(playerError, metadata)
: classifyNativePlaybackIssue(
playerError ?? video.error,
metadata
)
);
};
@@ -1,4 +1,5 @@
import { type VideoJsPlayer, getVideoJsTechVideo } from './vjs-player.types';
import * as playerTypes from './vjs-player.types';
describe('getVideoJsTechVideo', () => {
it('returns the current Video.js Tech video element', () => {
@@ -28,3 +29,44 @@ describe('getVideoJsTechVideo', () => {
).toBeNull();
});
});
describe('hasActiveVhsSourceHandler', () => {
it('detects the documented Video.js Tech VHS runtime property', () => {
expect(
hasActiveVhsSourceHandler({
tech: () => ({ vhs: {} }),
} as unknown as VideoJsPlayer)
).toBe(true);
expect(
hasActiveVhsSourceHandler({
tech: () => ({ el: () => document.createElement('video') }),
} as unknown as VideoJsPlayer)
).toBe(false);
});
it('fails closed when Tech access is transiently unavailable', () => {
expect(
hasActiveVhsSourceHandler({
tech: () => {
throw new Error('Tech unavailable');
},
} as unknown as VideoJsPlayer)
).toBe(false);
});
});
function hasActiveVhsSourceHandler(player: VideoJsPlayer): boolean {
const helper = (
playerTypes as unknown as {
readonly hasActiveVhsSourceHandler?: (
candidate: VideoJsPlayer
) => boolean;
}
).hasActiveVhsSourceHandler;
expect(helper).toBeDefined();
if (!helper) {
throw new Error('hasActiveVhsSourceHandler is not exported');
}
return helper(player);
}
@@ -97,3 +97,14 @@ export function getVideoJsTechVideo(
return null;
}
}
export function hasActiveVhsSourceHandler(
player: Pick<VideoJsPlayer, 'tech'>
): boolean {
try {
const vhs = player.tech()?.vhs;
return typeof vhs === 'object' && vhs !== null;
} catch {
return false;
}
}
@@ -82,7 +82,7 @@ export function getDiagnosticDetails(
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_NATIVE_ERROR_MESSAGE',
value: issue.nativeErrorMessage ?? '',
value: issue.vhs ? '' : (issue.nativeErrorMessage ?? ''),
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_ERROR_DETAILS',
@@ -92,6 +92,20 @@ export function getDiagnosticDetails(
}
function formatDiagnosticErrorDetails(issue: PlaybackDiagnostic): string {
if (issue.vhs) {
return [
`stage=${issue.vhs.stage}`,
`type=${issue.vhs.engineType}`,
`code=${issue.vhs.mediaErrorCode}`,
`disposition=${issue.vhs.disposition}`,
issue.vhs.httpStatus === undefined
? ''
: `HTTP ${issue.vhs.httpStatus}`,
]
.filter((value) => value.length > 0)
.join(' · ');
}
if (issue.hls) {
return [
`stage=${issue.hls.stage}`,
@@ -151,6 +165,8 @@ function formatPlayer(player: PlaybackDiagnostic['player']): string {
function formatDiagnosticSource(source: PlaybackDiagnostic['source']): string {
switch (source) {
case 'vhs':
return 'Video.js / VHS';
case 'hls':
return 'HLS.js';
case 'mpegts':
@@ -342,6 +342,35 @@ describe('WebPlayerViewComponent', () => {
expect(renderedDetails).not.toContain('provider.example');
});
it('renders only sanitized structured VHS evidence in technical details', () => {
const issue = createStructuredVhsDiagnostic();
component.handlePlaybackIssue(issue);
fixture.detectChanges();
const details = component.getDiagnosticDetails(issue);
const renderedDetails = details.map(({ value }) => value).join(' ');
expect(details).toEqual(
expect.arrayContaining([
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_SOURCE',
value: 'Video.js / VHS',
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_ERROR_DETAILS',
value:
'stage=unknown · type=networkbadstatus · code=4 · ' +
'disposition=terminal · HTTP 503',
},
])
);
expect(renderedDetails).not.toContain('vhs-render-secret');
expect(renderedDetails).not.toContain('provider.example');
expect(renderedDetails).not.toContain('Authorization');
expect(renderedDetails).not.toContain('response body');
});
it('keeps query-declared HLS streams on the HLS mime type', () => {
const streamUrl =
'https://example.com/play?extension=m3u8&token=signed';
@@ -370,8 +399,7 @@ describe('WebPlayerViewComponent', () => {
});
it('uses the Matroska mime type for query-declared MKV streams', () => {
const streamUrl =
'https://example.com/play?container=mkv&token=signed';
const streamUrl = 'https://example.com/play?container=mkv&token=signed';
component.setVjsOptions(streamUrl);
@@ -926,3 +954,29 @@ function createStructuredHlsDiagnostic(): PlaybackDiagnostic {
externalFallbackRecommended: false,
};
}
function createStructuredVhsDiagnostic(): PlaybackDiagnostic {
return {
code: PlaybackDiagnosticCode.NetworkError,
source: PlaybackDiagnosticSource.Vhs,
sourceUrl: 'https://provider.example/live.m3u8?token=vhs-render-secret',
container: 'm3u8',
mimeType: 'application/x-mpegURL',
player: 'videojs',
audioCodecs: [],
videoCodecs: [],
details: 'Authorization response body vhs-render-secret',
nativeErrorCode: 4,
nativeErrorMessage:
'https://provider.example/error?token=vhs-render-secret',
httpStatus: 503,
vhs: {
engineType: 'networkbadstatus',
mediaErrorCode: 4,
disposition: 'terminal',
stage: 'unknown',
httpStatus: 503,
},
externalFallbackRecommended: false,
};
}