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

@@ -0,0 +1,9 @@
---
type: fix
area: playback
---
The default web player now reports safer, more accurate streaming errors:
confirmed network and encrypted-segment failures keep structured details,
while ambiguous Video.js errors remain unknown instead of suggesting the
wrong cause.
+41 -1
View File
@@ -335,6 +335,38 @@ Video.js HTTP error is `network-error` and shows its status. Because an HTTP
status is server/network evidence rather than decoding evidence, external
decoding is not presented as a likely fix.
Video.js `8.23.9`, the default web player, runs HTTP streaming through bundled
VHS `3.17.5`. Its terminal `Player#error` crosses a separate allowlisted
boundary built only from the public Video.js `MediaError` code/status,
`metadata.errorType`, and the documented `player.tech().vhs` runtime property.
The engine type must exactly match an installed `videojs.Error` value;
unrecognized values remain unknown. Exact network identifiers, HTTP 4xx/5xx
status, and standard network code 2 produce `network-error`; exact
`streamingfailedtodecryptsegment` or standard encrypted code 5 produce
`drm-or-encryption`. A generic VHS code 3 remains
`unknown-playback-error` because VHS also assigns code 3 to terminal internal
objects and strings that do not establish a decode cause. Non-VHS native code
3 keeps its standard media-decode meaning.
VHS stage evidence is derived only where the public engine identifier names
the operation: HLS playlist parsing is `playlist`, DASH manifest parsing is
`manifest`, and select/decrypt/transmux/append segment errors are `segment`;
everything else is `unknown`. In particular, IPTVnator does not read internal
`requestType` values to guess manifest, playlist, segment, or key stages. VHS
handles retries, rendition exclusions/re-inclusions, segment timeout recovery,
and request aborts before a final player error; IPTVnator observes only the
public terminal event and does not subscribe to undocumented recovery events
or private loaders.
Video.js/VHS error messages, request or response URLs, headers, xhr objects,
response text/bodies, credentials, request types, and arbitrary metadata are
neither retained nor rendered. Technical details contain only the sanitized
stage, exact/unknown engine type, standard/unknown media error code, terminal
disposition, and validated HTTP status. The active playback URL remains
available only through the pre-existing playback metadata used by Retry, Copy
URL, and explicit external-player actions; it is never copied from VHS error
metadata.
HLS.js errors cross one shared sanitizer boundary before HTML5 or ArtPlayer can
emit a diagnostic. The boundary retains only allowlisted engine `type` and
`details` identifiers, the final fatal/recoverable disposition, a stage derived
@@ -360,7 +392,15 @@ they do not include provider-supplied channel names or source URLs.
mpegts.js `Early-EOF` failures on MPEG-TS streams are classified as `media-decode-error` instead of generic `network-error`. These failures usually mean the fetch stream ended before mpegts.js expected a complete transport stream, and external players may still handle the same URL more tolerant of short reads or malformed TS boundaries.
The diagnostic surface covers the inline player viewport when playback fails, with a compact warning badge, a native-player fallback headline, and player-card actions for configured external players. It exposes technical details on demand: diagnostic code, reporting player/source, detected container/MIME, video/audio codecs, native browser error fields, sanitized structured HLS evidence, and existing mpegts details. HLS manifest codec metadata also drives a concise browser-support hint for codecs that Chromium/Electron commonly cannot decode inline, such as HEVC, AC-3, E-AC-3, DTS, and MPEG-2 video.
The diagnostic surface covers the inline player viewport when playback fails,
with a compact warning badge, a native-player fallback headline, and
player-card actions for configured external players. It exposes technical
details on demand: diagnostic code, reporting player/source, detected
container/MIME, video/audio codecs, native browser error fields, sanitized
structured Video.js/VHS and HLS evidence, and existing mpegts details. HLS
manifest codec metadata also drives a concise browser-support hint for codecs
that Chromium/Electron commonly cannot decode inline, such as HEVC, AC-3,
E-AC-3, DTS, and MPEG-2 video.
URL extension metadata is filtered before diagnostics and player selection use it. Web script extensions such as `.php` are not shown as stream containers; explicit media query metadata such as `extension=ts` or `format=m3u8` is preferred when present.
@@ -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,
};
}