fix(playback): classify native source errors from evidence

This commit is contained in:
4gray committed 2026-07-30 09:31:56 +02:00
1 parent a53a52efc4
commit 742915f389
3 files changed
+156 -3

No files matched your search

@@ -54,9 +54,15 @@ export interface PlaybackSourceMetadata {
readonly videoCodecs: readonly string[];
}
export interface NativePlaybackErrorMetadataInput {
readonly errorType?: unknown;
}
export interface NativePlaybackErrorInput {
readonly code?: number;
readonly message?: string;
readonly status?: number;
readonly metadata?: NativePlaybackErrorMetadataInput;
}
export interface HlsPlaybackErrorInput {
@@ -88,6 +94,8 @@ export interface PlaybackDiagnostic {
readonly details?: string;
readonly nativeErrorCode?: number;
readonly nativeErrorMessage?: string;
readonly httpStatus?: number;
readonly nativeErrorType?: string;
readonly externalFallbackRecommended: boolean;
}
@@ -88,7 +88,7 @@ describe('playback diagnostics', () => {
expect(issue.container).toBe('x-msvideo');
});
it('does not classify MPEG-TS MIME-only failures as unsupported containers', () => {
it('keeps MPEG-TS MIME-only source failures unknown without codec evidence', () => {
const issue = classifyNativePlaybackIssue(
{ code: 4, message: 'source not supported' },
createPlaybackSourceMetadata({
@@ -98,8 +98,109 @@ describe('playback diagnostics', () => {
})
);
expect(issue.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec);
expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
expect(issue.container).toBe('mp2t');
expect(issue.externalFallbackRecommended).toBe(false);
});
it('classifies native HTTP source failures from a safe status and error type', () => {
const issue = classifyNativePlaybackIssue(
{
code: 4,
message: 'source not supported',
status: 404,
metadata: { errorType: 'networkrequestfailed' },
},
createPlaybackSourceMetadata({
url: 'https://example.com/live/missing.m3u8',
player: 'videojs',
})
);
expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError);
expect(issue.httpStatus).toBe(404);
expect(issue.nativeErrorType).toBe('networkrequestfailed');
expect(issue.externalFallbackRecommended).toBe(false);
});
it('keeps native code four HLS source failures unknown without status or container evidence', () => {
const issue = classifyNativePlaybackIssue(
{ code: 4, message: 'source not supported' },
createPlaybackSourceMetadata({
url: 'https://example.com/live/missing.m3u8',
player: 'videojs',
})
);
expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
expect(issue.externalFallbackRecommended).toBe(false);
expect(issue.httpStatus).toBeUndefined();
});
it('does not retain unsafe native status or metadata error types', () => {
const issue = classifyNativePlaybackIssue(
{
code: 4,
message: 'source not supported',
status: 0,
metadata: { errorType: 'request failed: token=secret value' },
},
createPlaybackSourceMetadata({
url: 'https://example.com/live/missing.m3u8',
player: 'videojs',
})
);
expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
expect(issue.httpStatus).toBeUndefined();
expect(issue.nativeErrorType).toBeUndefined();
expect(issue.externalFallbackRecommended).toBe(false);
});
it.each([
{ status: 399, accepted: false },
{ status: 400, accepted: true },
{ status: 599, accepted: true },
{ status: 600, accepted: false },
{ status: 404.5, accepted: false },
])('accepts native HTTP status $status only when it is a 4xx or 5xx integer', ({
status,
accepted,
}) => {
const issue = classifyNativePlaybackIssue(
{ code: 4, status },
createPlaybackSourceMetadata({
url: 'https://example.com/live/missing.m3u8',
player: 'videojs',
})
);
expect(issue.code).toBe(
accepted
? PlaybackDiagnosticCode.NetworkError
: PlaybackDiagnosticCode.UnknownPlaybackError
);
expect(issue.httpStatus).toBe(accepted ? status : undefined);
});
it.each([
{ length: 128, accepted: true },
{ length: 129, accepted: false },
])('retains native error type identifiers up to $length characters', ({
length,
accepted,
}) => {
const errorType = 'a'.repeat(length);
const issue = classifyNativePlaybackIssue(
{ code: 4, metadata: { errorType } },
createPlaybackSourceMetadata({
url: 'https://example.com/live/missing.m3u8',
player: 'videojs',
})
);
expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
expect(issue.nativeErrorType).toBe(accepted ? errorType : undefined);
});
it('classifies HLS network errors without claiming codec incompatibility', () => {
@@ -32,6 +32,7 @@ export {
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}$/;
export function classifyNativePlaybackIssue(
error: NativePlaybackErrorInput | MediaError | null | undefined,
@@ -39,8 +40,25 @@ export function classifyNativePlaybackIssue(
): PlaybackDiagnostic {
const nativeErrorCode = error?.code;
const nativeErrorMessage = error?.message || undefined;
const nativeErrorInput = error as NativePlaybackErrorInput | null | undefined;
const httpStatus = getNativeHttpStatus(nativeErrorInput?.status);
const nativeErrorType = getNativeErrorType(
nativeErrorInput?.metadata?.errorType
);
const lowerNativeErrorMessage = nativeErrorMessage?.toLowerCase() ?? '';
if (httpStatus !== undefined) {
return createPlaybackDiagnostic({
code: DiagnosticCode.NetworkError,
source: DiagnosticSource.Native,
metadata,
nativeErrorCode,
nativeErrorMessage,
httpStatus,
nativeErrorType,
});
}
if (nativeErrorCode === NETWORK_ERROR_CODE) {
// Native MediaError details are often opaque for browser security
// failures. Only classify browser access when the runtime exposes a
@@ -53,6 +71,7 @@ export function classifyNativePlaybackIssue(
metadata,
nativeErrorCode,
nativeErrorMessage,
nativeErrorType,
});
}
@@ -63,6 +82,7 @@ export function classifyNativePlaybackIssue(
metadata,
nativeErrorCode,
nativeErrorMessage,
nativeErrorType,
});
}
@@ -70,11 +90,12 @@ export function classifyNativePlaybackIssue(
return createPlaybackDiagnostic({
code: isLikelyContainerIssue(metadata)
? DiagnosticCode.UnsupportedContainer
: DiagnosticCode.UnsupportedCodec,
: DiagnosticCode.UnknownPlaybackError,
source: DiagnosticSource.Native,
metadata,
nativeErrorCode,
nativeErrorMessage,
nativeErrorType,
});
}
@@ -84,6 +105,7 @@ export function classifyNativePlaybackIssue(
metadata,
nativeErrorCode,
nativeErrorMessage,
nativeErrorType,
});
}
@@ -237,6 +259,8 @@ export function createPlaybackDiagnostic(options: {
readonly details?: string;
readonly nativeErrorCode?: number;
readonly nativeErrorMessage?: string;
readonly httpStatus?: number;
readonly nativeErrorType?: string;
/** Overrides the code-derived recommendation, e.g. when external players
* are known to be unable to handle the stream either. */
readonly externalFallbackRecommended?: boolean;
@@ -248,6 +272,8 @@ export function createPlaybackDiagnostic(options: {
details,
nativeErrorCode,
nativeErrorMessage,
httpStatus,
nativeErrorType,
} = options;
return {
@@ -262,12 +288,30 @@ export function createPlaybackDiagnostic(options: {
details: details || undefined,
nativeErrorCode,
nativeErrorMessage,
httpStatus,
nativeErrorType,
externalFallbackRecommended:
options.externalFallbackRecommended ??
isExternalFallbackRecommended(code),
};
}
function getNativeHttpStatus(status: unknown): number | undefined {
return typeof status === 'number' &&
Number.isInteger(status) &&
status >= 400 &&
status <= 599
? status
: undefined;
}
function getNativeErrorType(errorType: unknown): string | undefined {
return typeof errorType === 'string' &&
NATIVE_ERROR_TYPE_PATTERN.test(errorType)
? errorType
: undefined;
}
function isExternalFallbackRecommended(code: PlaybackDiagnosticCode): boolean {
return (
code === DiagnosticCode.UnsupportedContainer ||