mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user