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