fix(playback): avoid false codec diagnostics (#1314)

* docs(playback): design accurate native diagnostics

* docs(playback): plan accurate native diagnostics

* fix(playback): classify native source errors from evidence

* fix(playback): preserve Video.js HTTP error context

* fix(playback): show explicit HTTP playback errors

* docs(playback): document native error evidence
This commit is contained in:
4gray authored and GitHub committed 2026-07-30 19:55:35 +02:00
1 parent 32ba209b63
commit bf13849d69
11 files changed
+1156 -6

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 ||
@@ -1,5 +1,6 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import type { NativePlaybackErrorInput } from '../playback-diagnostics/playback-diagnostics.model';
import type { PlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util';
import type { VjsPlayerComponent as VjsPlayerComponentInstance } from './vjs-player.component';
import type { VideoJsPlayer } from './vjs-player.types';
@@ -200,6 +201,38 @@ 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));
render({
sources: [
{
src: 'https://example.test/missing/playlist.m3u8',
type: 'application/x-mpegURL',
},
],
});
harness.currentError = {
code: 4,
message: 'The media could not be loaded',
status: 404,
metadata: { errorType: 'networkrequestfailed' },
};
harness.emit('error');
expect(issues.at(-1)).toEqual(
expect.objectContaining({
code: 'network-error',
source: 'native',
sourceUrl: 'https://example.test/missing/playlist.m3u8',
httpStatus: 404,
nativeErrorType: 'networkrequestfailed',
externalFallbackRecommended: false,
})
);
});
it('rebinds native ended handling after playerreset', () => {
const events: string[] = [];
component.playbackEnded.subscribe(() => events.push('ended'));
@@ -278,7 +311,7 @@ function createPlayerHarness() {
let volumeValue = 0.5;
const harness = {
currentVideo: document.createElement('video'),
currentError: null as { code?: number; message?: string } | null,
currentError: null as NativePlaybackErrorInput | null,
paused: true,
pauseCompletesImmediately: true,
ready: () => undefined,
@@ -1,4 +1,5 @@
import type videoJs from 'video.js';
import type { NativePlaybackErrorInput } from '../playback-diagnostics/playback-diagnostics.model';
export type VideoPlayerSource = {
src: string;
@@ -83,7 +84,7 @@ export type VideoJsPlayer = Omit<
textTracks: () => VideoJsTextTrackList | null;
tech: (options?: unknown) => VideoJsTech | null;
getChild: (name: string) => VideoJsControlChild | null;
error: () => { code?: number; message?: string } | null;
error: () => NativePlaybackErrorInput | null;
};
export function getVideoJsTechVideo(
@@ -28,6 +28,10 @@ export function getDiagnosticDescriptionKey(
}
export function getDiagnosticMeta(issue: PlaybackDiagnostic): string {
if (issue.httpStatus !== undefined) {
return `HTTP ${issue.httpStatus}`;
}
const codecs = [...issue.videoCodecs, ...issue.audioCodecs].join(', ');
if (codecs) {
return codecs;
@@ -82,11 +86,21 @@ export function getDiagnosticDetails(
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_ERROR_DETAILS',
value: issue.details ?? '',
value: formatDiagnosticErrorDetails(issue),
},
].filter(({ value }) => value.trim().length > 0);
}
function formatDiagnosticErrorDetails(issue: PlaybackDiagnostic): string {
return [
issue.httpStatus !== undefined ? `HTTP ${issue.httpStatus}` : '',
issue.nativeErrorType ?? '',
issue.details ?? '',
]
.filter((value) => value.trim().length > 0)
.join(' · ');
}
function getDiagnosticTranslationBase(issue: PlaybackDiagnostic): string {
switch (issue.code) {
case PlaybackDiagnosticCode.UnsupportedContainer:
@@ -289,6 +289,34 @@ describe('WebPlayerViewComponent', () => {
]);
});
it('renders explicit HTTP evidence without recommending an external fallback', () => {
runtimeCapabilities.supportsManagedExternalPlayers = true;
const issue = createHttpDiagnostic();
fixture.detectChanges();
component.handlePlaybackIssue(issue);
fixture.detectChanges();
const banner = fixture.debugElement.query(
By.css('[data-test-id="playback-diagnostic-banner"]')
);
const mpvButton = fixture.debugElement.query(
By.css('[data-test-id="playback-fallback-mpv"]')
);
expect(banner.nativeElement.textContent).toContain('HTTP 404');
expect(mpvButton).toBeNull();
expect(component.getDiagnosticMeta(issue)).toBe('HTTP 404');
expect(component.getDiagnosticDetails(issue)).toEqual(
expect.arrayContaining([
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_ERROR_DETAILS',
value: 'HTTP 404 · networkrequestfailed',
},
])
);
});
it('keeps query-declared HLS streams on the HLS mime type', () => {
const streamUrl =
'https://example.com/play?extension=m3u8&token=signed';
@@ -831,3 +859,21 @@ function createNetworkDiagnostic(): PlaybackDiagnostic {
externalFallbackRecommended: false,
};
}
function createHttpDiagnostic(): PlaybackDiagnostic {
return {
code: PlaybackDiagnosticCode.NetworkError,
source: PlaybackDiagnosticSource.Native,
sourceUrl: 'https://example.com/live/missing.m3u8',
container: 'm3u8',
mimeType: 'application/x-mpegURL',
player: 'videojs',
audioCodecs: [],
videoCodecs: [],
nativeErrorCode: 4,
nativeErrorMessage: 'source not supported',
httpStatus: 404,
nativeErrorType: 'networkrequestfailed',
externalFallbackRecommended: false,
};
}