fix(playback): structure MPEG-TS diagnostics (#1327)

* docs(playback): design structured mpegts diagnostics

* docs(playback): plan structured mpegts diagnostics

* fix(playback): structure mpegts error evidence

* fix(playback): structure HTML5 mpegts errors

* fix(playback): share mpegts evidence across players

* fix(playback): render structured mpegts evidence

* docs(playback): document structured mpegts diagnostics

* chore(playback): keep diagnostics lint clean

* docs(playback): complete mpegts diagnostics plan
This commit is contained in:
4gray authored and GitHub committed 2026-08-01 17:13:16 +02:00
1 parent 9bb1811984
commit 0c59aace71
23 files changed
+1886 -189

No files matched your search

@@ -186,10 +186,11 @@ describe('ArtPlayerSourceSession', () => {
details: 'stale callback',
fatal: true,
});
const secret = 'art-mpegts-secret';
mpegTsInstances.at(-1)?.handlers.get('error')?.(
'mediaError',
'unsupported codec',
{}
'MediaError',
'CodecUnsupported',
{ message: secret, headers: { Authorization: secret } }
);
expect(emitted).toEqual([
@@ -199,11 +200,18 @@ describe('ArtPlayerSourceSession', () => {
player: 'artplayer',
}),
expect.objectContaining({
code: 'unsupported-codec',
source: 'mpegts',
sourceUrl: 'https://example.test/live.ts',
player: 'artplayer',
mpegTs: expect.objectContaining({
engineType: 'MediaError',
engineDetails: 'CodecUnsupported',
failure: 'codec',
}),
}),
]);
expect(JSON.stringify(emitted)).not.toContain(secret);
});
it('reports only structured evidence for a fatal HLS manifest HTTP failure', () => {
@@ -10,6 +10,7 @@ import {
classifyMpegTsPlaybackIssue,
classifyUnsupportedHlsManifestCodecs,
createHlsPlaybackEvidence,
createMpegTsPlaybackEvidence,
createPlaybackSourceMetadata,
} from '../playback-diagnostics/playback-diagnostics.util';
import type { WebVideoControlsAdapter } from '../player-controls';
@@ -66,7 +67,7 @@ export class ArtPlayerSourceSession {
| null = null;
private mpegTsPlayer: mpegts.Player | null = null;
private mpegTsErrorListener:
| ((type: string, details: string, info: unknown) => void)
| ((type: unknown, details: unknown, info: unknown) => void)
| null = null;
private shakaSession: ShakaVideoSession | null = null;
private destroyed = false;
@@ -192,7 +193,7 @@ export class ArtPlayerSourceSession {
}
this.config.emitPlaybackIssue(
classifyMpegTsPlaybackIssue(
{ type, details, info },
createMpegTsPlaybackEvidence(type, details, info),
this.createSourceMetadata(url, 'video/mp2t')
)
);
@@ -239,10 +239,11 @@ describe('ArtPlayerComponent', () => {
artPlayerInstances[0].video,
'https://example.com/live/channel.ts'
);
const secret = 'art-component-mpegts-secret';
mpegTsInstances[0].handlers.get('error')?.(
'mediaError',
'unsupported codec',
{}
'MediaError',
'CodecUnsupported',
{ message: secret }
);
expect(issues).toEqual([
@@ -250,9 +251,15 @@ describe('ArtPlayerComponent', () => {
code: 'unsupported-codec',
source: 'mpegts',
sourceUrl: 'https://example.com/live/channel.ts',
mpegTs: expect.objectContaining({
engineType: 'MediaError',
engineDetails: 'CodecUnsupported',
failure: 'codec',
}),
externalFallbackRecommended: true,
}),
]);
expect(JSON.stringify(issues)).not.toContain(secret);
});
it('emits playbackEnded exactly once for a native ended event and not during reload or destroy', () => {
@@ -7,6 +7,7 @@ import {
classifyMpegTsPlaybackIssue,
classifyUnsupportedHlsManifestCodecs,
createHlsPlaybackEvidence,
createMpegTsPlaybackEvidence,
createPlaybackSourceMetadata,
} from '../playback-diagnostics/playback-diagnostics.util';
@@ -69,12 +70,16 @@ export function emitFatalHlsPlaybackError(
export function emitMpegTsPlaybackError(
url: string,
error: { type: string; details: string; info: unknown },
error: { type: unknown; details: unknown; info: unknown },
emitPlaybackIssue: (issue: PlaybackDiagnostic) => void
): void {
emitPlaybackIssue(
classifyMpegTsPlaybackIssue(
error,
createMpegTsPlaybackEvidence(
error.type,
error.details,
error.info
),
createHtml5SourceMetadata(url, 'video/mp2t')
)
);
@@ -114,6 +114,52 @@ describe('HtmlVideoPlayerComponent shared controls sources', () => {
}
);
it('emits structured HTTP evidence for mpegts.js failures', () => {
mpegTsIsSupported.mockReturnValue(true);
const { component } = renderSharedControls(
HtmlVideoPlayerComponent,
fixtures,
{
channel: {
...TEST_CHANNEL,
url: 'https://example.test/missing.ts',
},
}
);
const issues: unknown[] = [];
component.playbackIssue.subscribe((issue) => issues.push(issue));
const secret = 'html-mpegts-secret';
mpegTsInstances[0].emit(
'error',
'NetworkError',
'HttpStatusCodeInvalid',
{
code: 404,
msg: `Not Found ${secret}`,
url: `https://provider.example/error?token=${secret}`,
}
);
expect(issues).toEqual([
expect.objectContaining({
code: 'network-error',
source: 'mpegts',
sourceUrl: 'https://example.test/missing.ts',
player: 'html5',
httpStatus: 404,
mpegTs: expect.objectContaining({
engineType: 'NetworkError',
engineDetails: 'HttpStatusCodeInvalid',
failure: 'http',
httpStatus: 404,
}),
externalFallbackRecommended: false,
}),
]);
expect(JSON.stringify(issues)).not.toContain(secret);
});
it('owns one MPEG-TS source between media attachment and loading', () => {
mpegTsIsSupported.mockReturnValue(true);
const { component } = renderSharedControls(
@@ -93,10 +93,22 @@ export class MockHls {
}
export class MockMpegTsPlayer {
private readonly listeners = new Map<
string,
Set<(...args: unknown[]) => void>
>();
readonly attachMediaElement = jest.fn(() =>
lifecycle.push('mpegts:attachMedia')
);
readonly on = jest.fn();
readonly on = jest.fn(
(event: string, listener: (...args: unknown[]) => void) => {
const listeners =
this.listeners.get(event) ??
new Set<(...args: unknown[]) => void>();
listeners.add(listener);
this.listeners.set(event, listeners);
}
);
readonly load = jest.fn(() => lifecycle.push('mpegts:load'));
readonly pause = jest.fn();
readonly unload = jest.fn();
@@ -106,6 +118,12 @@ export class MockMpegTsPlayer {
constructor() {
mpegTsInstances.push(this);
}
emit(event: string, ...args: unknown[]): void {
for (const listener of this.listeners.get(event) ?? []) {
listener(...args);
}
}
}
export const mpegTsCreatePlayer = jest.fn(() => new MockMpegTsPlayer());
@@ -0,0 +1,64 @@
export const MpegTsPlaybackEngineType = {
Network: 'NetworkError',
Media: 'MediaError',
Other: 'OtherError',
Unknown: 'unknown',
} as const;
export type MpegTsPlaybackEngineType =
(typeof MpegTsPlaybackEngineType)[keyof typeof MpegTsPlaybackEngineType];
export const MpegTsPlaybackEngineDetails = {
NetworkException: 'Exception',
HttpStatusCodeInvalid: 'HttpStatusCodeInvalid',
ConnectingTimeout: 'ConnectingTimeout',
UnrecoverableEarlyEof: 'UnrecoverableEarlyEof',
MediaMseError: 'MediaMSEError',
FormatError: 'FormatError',
FormatUnsupported: 'FormatUnsupported',
CodecUnsupported: 'CodecUnsupported',
Unknown: 'unknown',
} as const;
export type MpegTsPlaybackEngineDetails =
(typeof MpegTsPlaybackEngineDetails)[keyof typeof MpegTsPlaybackEngineDetails];
export const MpegTsPlaybackDisposition = {
Terminal: 'terminal',
} as const;
export type MpegTsPlaybackDisposition =
(typeof MpegTsPlaybackDisposition)[keyof typeof MpegTsPlaybackDisposition];
export const MpegTsPlaybackStage = {
Loader: 'loader',
Demux: 'demux',
MediaSource: 'media-source',
Unknown: 'unknown',
} as const;
export type MpegTsPlaybackStage =
(typeof MpegTsPlaybackStage)[keyof typeof MpegTsPlaybackStage];
export const MpegTsPlaybackFailure = {
Http: 'http',
Timeout: 'timeout',
Network: 'network',
TruncatedStream: 'truncated-stream',
Format: 'format',
Codec: 'codec',
MediaSource: 'media-source',
Unknown: 'unknown',
} as const;
export type MpegTsPlaybackFailure =
(typeof MpegTsPlaybackFailure)[keyof typeof MpegTsPlaybackFailure];
export interface MpegTsPlaybackEvidence {
readonly engineType: MpegTsPlaybackEngineType;
readonly engineDetails: MpegTsPlaybackEngineDetails;
readonly disposition: MpegTsPlaybackDisposition;
readonly stage: MpegTsPlaybackStage;
readonly failure: MpegTsPlaybackFailure;
readonly httpStatus?: number;
}
@@ -0,0 +1,239 @@
import mpegts from 'mpegts.js';
import {
InlinePlaybackPlayer,
PlaybackDiagnosticCode,
classifyMpegTsPlaybackIssue,
createPlaybackSourceMetadata,
} from './playback-diagnostics.util';
import {
MpegTsPlaybackEngineDetails,
MpegTsPlaybackEngineType,
} from './mpegts-playback-evidence.model';
import {
MPEGTS_DIAGNOSTIC_VERSION,
createMpegTsPlaybackEvidence,
} from './mpegts-playback-evidence.util';
const METADATA = createPlaybackSourceMetadata({
url: 'https://example.test/live.ts',
mimeType: 'video/mp2t',
player: InlinePlaybackPlayer.VideoJs,
});
describe('mpegts.js playback evidence', () => {
it('locks the accepted public contract to mpegts.js 1.8.0', () => {
expect(mpegts.version).toBe(MPEGTS_DIAGNOSTIC_VERSION);
expect(mpegts.ErrorTypes).toEqual({
NETWORK_ERROR: MpegTsPlaybackEngineType.Network,
MEDIA_ERROR: MpegTsPlaybackEngineType.Media,
OTHER_ERROR: MpegTsPlaybackEngineType.Other,
});
expect(mpegts.ErrorDetails).toEqual({
NETWORK_EXCEPTION:
MpegTsPlaybackEngineDetails.NetworkException,
NETWORK_STATUS_CODE_INVALID:
MpegTsPlaybackEngineDetails.HttpStatusCodeInvalid,
NETWORK_TIMEOUT: MpegTsPlaybackEngineDetails.ConnectingTimeout,
NETWORK_UNRECOVERABLE_EARLY_EOF:
MpegTsPlaybackEngineDetails.UnrecoverableEarlyEof,
MEDIA_MSE_ERROR: MpegTsPlaybackEngineDetails.MediaMseError,
MEDIA_FORMAT_ERROR: MpegTsPlaybackEngineDetails.FormatError,
MEDIA_FORMAT_UNSUPPORTED:
MpegTsPlaybackEngineDetails.FormatUnsupported,
MEDIA_CODEC_UNSUPPORTED:
MpegTsPlaybackEngineDetails.CodecUnsupported,
});
});
it.each([
[
'NetworkError',
'HttpStatusCodeInvalid',
'loader',
'http',
],
['NetworkError', 'ConnectingTimeout', 'loader', 'timeout'],
['NetworkError', 'Exception', 'loader', 'network'],
[
'NetworkError',
'UnrecoverableEarlyEof',
'loader',
'truncated-stream',
],
['MediaError', 'FormatError', 'demux', 'format'],
['MediaError', 'FormatUnsupported', 'demux', 'format'],
['MediaError', 'CodecUnsupported', 'demux', 'codec'],
[
'MediaError',
'MediaMSEError',
'media-source',
'media-source',
],
])(
'maps %s + %s to %s/%s',
(engineType, engineDetails, stage, failure) => {
expect(
createMpegTsPlaybackEvidence(
engineType,
engineDetails,
undefined
)
).toEqual({
engineType,
engineDetails,
disposition: 'terminal',
stage,
failure,
});
}
);
it.each([
['OtherError', 'CodecUnsupported'],
['NetworkError', 'CodecUnsupported'],
['MediaError', 'ConnectingTimeout'],
['networkerror', 'HttpStatusCodeInvalid'],
['NetworkError', 'httpstatuscodeinvalid'],
[{ type: 'NetworkError' }, 'Exception'],
['MediaError', { details: 'FormatError' }],
])('keeps inconsistent or malformed %p + %p evidence unknown', (type, details) => {
const evidence = createMpegTsPlaybackEvidence(type, details, {
code: 503,
});
expect(evidence.stage).toBe('unknown');
expect(evidence.failure).toBe('unknown');
expect(evidence.httpStatus).toBeUndefined();
});
it.each([399, 600, 404.5, '404', null, undefined])(
'rejects invalid HTTP status %p',
(code) => {
const evidence = createMpegTsPlaybackEvidence(
'NetworkError',
'HttpStatusCodeInvalid',
{ code }
);
expect(evidence.httpStatus).toBeUndefined();
}
);
it('reads HTTP status only from the exact top-level public slot', () => {
expect(
createMpegTsPlaybackEvidence(
'NetworkError',
'HttpStatusCodeInvalid',
{ code: 404 }
).httpStatus
).toBe(404);
expect(
createMpegTsPlaybackEvidence('NetworkError', 'Exception', {
code: 503,
}).httpStatus
).toBeUndefined();
expect(
createMpegTsPlaybackEvidence(
'NetworkError',
'HttpStatusCodeInvalid',
{ response: { code: 503 } }
).httpStatus
).toBeUndefined();
});
it('does not inspect or retain arbitrary engine information', () => {
const secret = 'mpegts-info-secret';
const circularInfo: Record<string, unknown> = {
code: 404,
msg: `Not Found ${secret}`,
url: `https://provider.example/error?token=${secret}`,
headers: { Authorization: secret },
};
circularInfo['self'] = circularInfo;
const evidence = createMpegTsPlaybackEvidence(
'NetworkError',
'HttpStatusCodeInvalid',
circularInfo
);
expect(evidence.httpStatus).toBe(404);
expect(JSON.stringify(evidence)).not.toContain(secret);
expect(JSON.stringify(evidence)).not.toContain('provider.example');
});
it.each([
['NetworkError', 'HttpStatusCodeInvalid', 'network-error', false],
['NetworkError', 'ConnectingTimeout', 'network-error', false],
['NetworkError', 'Exception', 'network-error', false],
[
'NetworkError',
'UnrecoverableEarlyEof',
'media-decode-error',
true,
],
['MediaError', 'FormatError', 'media-decode-error', true],
[
'MediaError',
'FormatUnsupported',
'unsupported-container',
true,
],
[
'MediaError',
'CodecUnsupported',
'unsupported-codec',
true,
],
['MediaError', 'MediaMSEError', 'media-decode-error', true],
['OtherError', 'Exception', 'unknown-playback-error', false],
])(
'classifies %s + %s as %s with fallback=%s',
(type, details, code, externalFallbackRecommended) => {
const issue = classifyMpegTsPlaybackIssue(
createMpegTsPlaybackEvidence(type, details, undefined),
METADATA
);
expect(issue.code).toBe(code);
expect(issue.externalFallbackRecommended).toBe(
externalFallbackRecommended
);
expect(issue.details).toBeUndefined();
}
);
it('preserves an exact HTTP failure without retaining provider details', () => {
const secret = 'mpegts-http-secret';
const issue = classifyMpegTsPlaybackIssue(
createMpegTsPlaybackEvidence(
'NetworkError',
'HttpStatusCodeInvalid',
{
code: 404,
msg: `Not Found ${secret}`,
url: `https://provider.example/error?token=${secret}`,
}
),
METADATA
);
expect(issue).toEqual(
expect.objectContaining({
code: PlaybackDiagnosticCode.NetworkError,
httpStatus: 404,
mpegTs: expect.objectContaining({
engineType: 'NetworkError',
engineDetails: 'HttpStatusCodeInvalid',
disposition: 'terminal',
stage: 'loader',
failure: 'http',
httpStatus: 404,
}),
externalFallbackRecommended: false,
})
);
expect(issue.details).toBeUndefined();
expect(JSON.stringify(issue)).not.toContain(secret);
});
});
@@ -0,0 +1,167 @@
import {
type MpegTsPlaybackEngineDetails as MpegTsPlaybackEngineDetailsValue,
type MpegTsPlaybackEngineType as MpegTsPlaybackEngineTypeValue,
type MpegTsPlaybackEvidence,
type MpegTsPlaybackFailure as MpegTsPlaybackFailureValue,
type MpegTsPlaybackStage as MpegTsPlaybackStageValue,
MpegTsPlaybackDisposition,
MpegTsPlaybackEngineDetails,
MpegTsPlaybackEngineType,
MpegTsPlaybackFailure,
MpegTsPlaybackStage,
} from './mpegts-playback-evidence.model';
export const MPEGTS_DIAGNOSTIC_VERSION = '1.8.0';
interface MpegTsPlaybackCause {
readonly stage: MpegTsPlaybackStageValue;
readonly failure: MpegTsPlaybackFailureValue;
}
export function createMpegTsPlaybackEvidence(
type: unknown,
details: unknown,
info: unknown
): MpegTsPlaybackEvidence {
const engineType = normalizeEngineType(type);
const engineDetails = normalizeEngineDetails(details);
const cause = deriveMpegTsPlaybackCause(engineType, engineDetails);
const httpStatus =
engineType === MpegTsPlaybackEngineType.Network &&
engineDetails ===
MpegTsPlaybackEngineDetails.HttpStatusCodeInvalid
? readHttpStatus(info)
: undefined;
return {
engineType,
engineDetails,
disposition: MpegTsPlaybackDisposition.Terminal,
...cause,
...(httpStatus === undefined ? {} : { httpStatus }),
};
}
function normalizeEngineType(value: unknown): MpegTsPlaybackEngineTypeValue {
switch (value) {
case MpegTsPlaybackEngineType.Network:
case MpegTsPlaybackEngineType.Media:
case MpegTsPlaybackEngineType.Other:
return value;
default:
return MpegTsPlaybackEngineType.Unknown;
}
}
function normalizeEngineDetails(
value: unknown
): MpegTsPlaybackEngineDetailsValue {
switch (value) {
case MpegTsPlaybackEngineDetails.NetworkException:
case MpegTsPlaybackEngineDetails.HttpStatusCodeInvalid:
case MpegTsPlaybackEngineDetails.ConnectingTimeout:
case MpegTsPlaybackEngineDetails.UnrecoverableEarlyEof:
case MpegTsPlaybackEngineDetails.MediaMseError:
case MpegTsPlaybackEngineDetails.FormatError:
case MpegTsPlaybackEngineDetails.FormatUnsupported:
case MpegTsPlaybackEngineDetails.CodecUnsupported:
return value;
default:
return MpegTsPlaybackEngineDetails.Unknown;
}
}
function deriveMpegTsPlaybackCause(
type: MpegTsPlaybackEngineTypeValue,
details: MpegTsPlaybackEngineDetailsValue
): MpegTsPlaybackCause {
if (type === MpegTsPlaybackEngineType.Network) {
return deriveNetworkCause(details);
}
if (type === MpegTsPlaybackEngineType.Media) {
return deriveMediaCause(details);
}
return unknownCause();
}
function deriveNetworkCause(
details: MpegTsPlaybackEngineDetailsValue
): MpegTsPlaybackCause {
switch (details) {
case MpegTsPlaybackEngineDetails.HttpStatusCodeInvalid:
return cause(
MpegTsPlaybackStage.Loader,
MpegTsPlaybackFailure.Http
);
case MpegTsPlaybackEngineDetails.ConnectingTimeout:
return cause(
MpegTsPlaybackStage.Loader,
MpegTsPlaybackFailure.Timeout
);
case MpegTsPlaybackEngineDetails.NetworkException:
return cause(
MpegTsPlaybackStage.Loader,
MpegTsPlaybackFailure.Network
);
case MpegTsPlaybackEngineDetails.UnrecoverableEarlyEof:
return cause(
MpegTsPlaybackStage.Loader,
MpegTsPlaybackFailure.TruncatedStream
);
default:
return unknownCause();
}
}
function deriveMediaCause(
details: MpegTsPlaybackEngineDetailsValue
): MpegTsPlaybackCause {
switch (details) {
case MpegTsPlaybackEngineDetails.FormatError:
case MpegTsPlaybackEngineDetails.FormatUnsupported:
return cause(
MpegTsPlaybackStage.Demux,
MpegTsPlaybackFailure.Format
);
case MpegTsPlaybackEngineDetails.CodecUnsupported:
return cause(
MpegTsPlaybackStage.Demux,
MpegTsPlaybackFailure.Codec
);
case MpegTsPlaybackEngineDetails.MediaMseError:
return cause(
MpegTsPlaybackStage.MediaSource,
MpegTsPlaybackFailure.MediaSource
);
default:
return unknownCause();
}
}
function cause(
stage: MpegTsPlaybackStageValue,
failure: MpegTsPlaybackFailureValue
): MpegTsPlaybackCause {
return { stage, failure };
}
function unknownCause(): MpegTsPlaybackCause {
return cause(
MpegTsPlaybackStage.Unknown,
MpegTsPlaybackFailure.Unknown
);
}
function readHttpStatus(info: unknown): number | undefined {
if (typeof info !== 'object' || info === null || Array.isArray(info)) {
return undefined;
}
const code = (info as Readonly<Record<string, unknown>>)['code'];
return typeof code === 'number' &&
Number.isInteger(code) &&
code >= 400 &&
code <= 599
? code
: undefined;
}
@@ -3,6 +3,9 @@ import type {
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import type { ErrorDetails, ErrorTypes } from 'hls.js';
import type { MpegTsPlaybackEvidence } from './mpegts-playback-evidence.model';
export * from './mpegts-playback-evidence.model';
export const PlaybackDiagnosticCode = {
UnsupportedContainer: 'unsupported-container',
@@ -243,13 +246,6 @@ export interface ShakaPlaybackEvidence {
readonly httpStatus?: number;
}
export interface MpegTsPlaybackErrorInput {
readonly type?: string;
readonly details?: string;
readonly message?: string;
readonly info?: unknown;
}
export interface PlaybackDiagnostic {
readonly code: PlaybackDiagnosticCode;
readonly source: PlaybackDiagnosticSource;
@@ -266,6 +262,7 @@ export interface PlaybackDiagnostic {
readonly nativeErrorType?: string;
readonly vhs?: VhsPlaybackEvidence;
readonly hls?: HlsPlaybackEvidence;
readonly mpegTs?: MpegTsPlaybackEvidence;
readonly shaka?: ShakaPlaybackEvidence;
readonly externalFallbackRecommended: boolean;
}
@@ -10,6 +10,7 @@ import {
classifyHlsPlaybackIssue,
classifyMpegTsPlaybackIssue,
classifyNativePlaybackIssue,
createMpegTsPlaybackEvidence,
createPlaybackSourceMetadata,
getLikelyBrowserUnsupportedCodecLabels,
getPlaybackMediaExtensionFromUrl,
@@ -584,13 +585,12 @@ describe('playback diagnostics', () => {
expect(issue.externalFallbackRecommended).toBe(false);
});
it('classifies mpegts browser fetch restrictions separately from generic network errors', () => {
it('does not guess mpegts browser access from exception messages', () => {
const secret = 'blocked by CORS policy';
const issue = classifyMpegTsPlaybackIssue(
{
type: 'NetworkError',
details:
'Fetch blocked by access-control policy while loading segment',
},
createMpegTsPlaybackEvidence('NetworkError', 'Exception', {
msg: secret,
}),
createPlaybackSourceMetadata({
url: 'https://provider.example/live/channel.ts',
mimeType: 'video/mp2t',
@@ -598,17 +598,18 @@ describe('playback diagnostics', () => {
})
);
expect(issue.code).toBe('browser-access-error');
expect(issue.externalFallbackRecommended).toBe(true);
expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError);
expect(issue.externalFallbackRecommended).toBe(false);
expect(JSON.stringify(issue)).not.toContain(secret);
});
it('classifies mpegts early EOF failures as fallback-actionable media errors', () => {
const issue = classifyMpegTsPlaybackIssue(
{
type: 'NetworkError',
details: 'UnrecoverableEarlyEof',
info: { msg: 'Fetch stream meet Early-EOF' },
},
createMpegTsPlaybackEvidence(
'NetworkError',
'UnrecoverableEarlyEof',
{ msg: 'Fetch stream meet Early-EOF' }
),
createPlaybackSourceMetadata({
url: 'https://provider.example/movie/123.ts',
mimeType: 'video/mp2t',
@@ -618,15 +619,17 @@ describe('playback diagnostics', () => {
expect(issue.code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
expect(issue.externalFallbackRecommended).toBe(true);
expect(issue.details).toContain('Early-EOF');
expect(issue.mpegTs?.failure).toBe('truncated-stream');
expect(issue.details).toBeUndefined();
});
it('classifies mpegts codec errors as unsupported codec fallbacks', () => {
const issue = classifyMpegTsPlaybackIssue(
{
type: 'MediaError',
details: 'MediaCodecUnsupported',
},
createMpegTsPlaybackEvidence(
'MediaError',
'CodecUnsupported',
undefined
),
createPlaybackSourceMetadata({
url: 'https://example.com/live/channel.ts',
mimeType: 'video/mp2t',
@@ -1,6 +1,6 @@
import type {
HlsPlaybackEvidence,
MpegTsPlaybackErrorInput,
MpegTsPlaybackEvidence,
NativePlaybackErrorInput,
PlaybackDiagnostic,
PlaybackDiagnosticCode,
@@ -12,23 +12,23 @@ import type {
} from './playback-diagnostics.model';
import { PlaybackDiagnosticCode as DiagnosticCode } from './playback-diagnostics.model';
import { HlsPlaybackDisposition } from './playback-diagnostics.model';
import {
MpegTsPlaybackEngineDetails,
MpegTsPlaybackFailure,
} from './mpegts-playback-evidence.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,
isEarlyEofFailure,
isNetworkFailure,
normalizeErrorDetails,
} from './playback-error-patterns.util';
import { isBrowserAccessFailure } 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 { createMpegTsPlaybackEvidence } from './mpegts-playback-evidence.util';
export { createVhsPlaybackEvidence } from './vhs-playback-evidence.util';
export {
createPlaybackSourceMetadata,
@@ -163,65 +163,15 @@ export function classifyHlsPlaybackIssue(
}
export function classifyMpegTsPlaybackIssue(
error: MpegTsPlaybackErrorInput,
evidence: MpegTsPlaybackEvidence,
metadata: PlaybackSourceMetadata
): PlaybackDiagnostic {
const details = normalizeErrorDetails(error);
const lowerDetails = details.toLowerCase();
const lowerType = (error.type ?? '').toLowerCase();
if (isEarlyEofFailure(lowerDetails)) {
return createPlaybackDiagnostic({
code: DiagnosticCode.MediaDecodeError,
source: DiagnosticSource.MpegTs,
metadata,
details,
});
}
if (isNetworkFailure(lowerType, lowerDetails)) {
return createPlaybackDiagnostic({
code: isBrowserAccessFailure(lowerDetails)
? DiagnosticCode.BrowserAccessError
: DiagnosticCode.NetworkError,
source: DiagnosticSource.MpegTs,
metadata,
details,
});
}
if (lowerDetails.includes('codec')) {
return createPlaybackDiagnostic({
code: DiagnosticCode.UnsupportedCodec,
source: DiagnosticSource.MpegTs,
metadata,
details,
});
}
if (lowerDetails.includes('format') || lowerDetails.includes('mse')) {
return createPlaybackDiagnostic({
code: DiagnosticCode.UnsupportedContainer,
source: DiagnosticSource.MpegTs,
metadata,
details,
});
}
if (lowerType.includes('media')) {
return createPlaybackDiagnostic({
code: DiagnosticCode.MediaDecodeError,
source: DiagnosticSource.MpegTs,
metadata,
details,
});
}
return createPlaybackDiagnostic({
code: DiagnosticCode.UnknownPlaybackError,
code: getMpegTsPlaybackDiagnosticCode(evidence),
source: DiagnosticSource.MpegTs,
metadata,
details,
httpStatus: evidence.httpStatus,
mpegTs: evidence,
});
}
@@ -258,6 +208,7 @@ export function createPlaybackDiagnostic(options: {
readonly nativeErrorType?: string;
readonly vhs?: VhsPlaybackEvidence;
readonly hls?: HlsPlaybackEvidence;
readonly mpegTs?: MpegTsPlaybackEvidence;
readonly shaka?: ShakaPlaybackEvidence;
/** Overrides the code-derived recommendation, e.g. when external players
* are known to be unable to handle the stream either. */
@@ -274,6 +225,7 @@ export function createPlaybackDiagnostic(options: {
nativeErrorType,
vhs,
hls,
mpegTs,
shaka,
} = options;
@@ -293,6 +245,7 @@ export function createPlaybackDiagnostic(options: {
nativeErrorType,
vhs,
hls,
mpegTs,
shaka,
externalFallbackRecommended:
options.externalFallbackRecommended ??
@@ -300,6 +253,29 @@ export function createPlaybackDiagnostic(options: {
};
}
function getMpegTsPlaybackDiagnosticCode(
evidence: MpegTsPlaybackEvidence
): PlaybackDiagnosticCode {
switch (evidence.failure) {
case MpegTsPlaybackFailure.Http:
case MpegTsPlaybackFailure.Timeout:
case MpegTsPlaybackFailure.Network:
return DiagnosticCode.NetworkError;
case MpegTsPlaybackFailure.TruncatedStream:
case MpegTsPlaybackFailure.MediaSource:
return DiagnosticCode.MediaDecodeError;
case MpegTsPlaybackFailure.Codec:
return DiagnosticCode.UnsupportedCodec;
case MpegTsPlaybackFailure.Format:
return evidence.engineDetails ===
MpegTsPlaybackEngineDetails.FormatUnsupported
? DiagnosticCode.UnsupportedContainer
: DiagnosticCode.MediaDecodeError;
default:
return DiagnosticCode.UnknownPlaybackError;
}
}
function getVhsPlaybackDiagnosticCode(
evidence: VhsPlaybackEvidence,
metadata: PlaybackSourceMetadata
@@ -1,25 +1,3 @@
import type { MpegTsPlaybackErrorInput } from './playback-diagnostics.model';
export function normalizeErrorDetails(
error: MpegTsPlaybackErrorInput
): string {
const info = normalizeErrorPayload(error.info);
return [error.details, error.message, info]
.filter((part): part is string => Boolean(part))
.join(' ');
}
export function isNetworkFailure(type: string, details: string): boolean {
return (
type.includes('network') ||
details.includes('network') ||
details.includes('loaderror') ||
details.includes('timeout') ||
details.includes('status')
);
}
export function isBrowserAccessFailure(details: string): boolean {
return (
details.includes('cors') ||
@@ -38,53 +16,3 @@ export function isBrowserAccessFailure(details: string): boolean {
details.includes('err_cleartext')
);
}
export function isEarlyEofFailure(details: string): boolean {
const compactDetails = details.replace(/[^a-z0-9]/g, '');
return compactDetails.includes('earlyeof');
}
export function isCodecFailure(details: string): boolean {
return (
details.includes('codec') ||
details.includes('incompatiblecodecs') ||
details.includes('addcodec')
);
}
export function isDrmOrEncryptionFailure(details: string): boolean {
return (
details.includes('decrypt') ||
details.includes('keysystem') ||
details.includes('keyload') ||
details.includes('license') ||
details.includes('drm')
);
}
function normalizeErrorPayload(payload: unknown): string {
if (!payload) {
return '';
}
if (typeof payload === 'string') {
return payload;
}
if (payload instanceof Error) {
const extraDetails = stringifyUnknown(payload);
return [payload.message, extraDetails === '{}' ? '' : extraDetails]
.filter(Boolean)
.join(' ');
}
return stringifyUnknown(payload);
}
function stringifyUnknown(value: unknown): string {
try {
return JSON.stringify(value) || '';
} catch {
return String(value);
}
}
@@ -111,28 +111,68 @@ describe('VjsMpegTsSession', () => {
expect(duration).toHaveBeenCalledWith(200);
});
it('classifies engine errors with Video.js source metadata', () => {
it('emits structured HTTP evidence with Video.js source metadata', () => {
const mpegTsPlayer = createMpegTsPlayer();
createPlayerMock.mockReturnValue(mpegTsPlayer);
const video = document.createElement('video');
const { session, emitPlaybackIssue } = createSession();
const secret = 'vjs-mpegts-secret';
session.start('https://example.test/live/stream.ts', video);
mpegTsPlayer.emit(
'error',
'NetworkError',
'HttpStatusCodeInvalid',
{
code: 503,
msg: `Service Unavailable ${secret}`,
headers: { Authorization: secret },
}
);
expect(emitPlaybackIssue).toHaveBeenCalledWith(
expect.objectContaining({
code: 'network-error',
source: 'mpegts',
sourceUrl: 'https://example.test/live/stream.ts',
player: 'videojs',
httpStatus: 503,
mpegTs: expect.objectContaining({
engineType: 'NetworkError',
engineDetails: 'HttpStatusCodeInvalid',
failure: 'http',
httpStatus: 503,
}),
externalFallbackRecommended: false,
})
);
expect(JSON.stringify(emitPlaybackIssue.mock.calls)).not.toContain(
secret
);
});
it('does not guess browser access from a generic mpegts exception', () => {
const mpegTsPlayer = createMpegTsPlayer();
createPlayerMock.mockReturnValue(mpegTsPlayer);
const video = document.createElement('video');
const { session, emitPlaybackIssue } = createSession();
session.start('https://example.test/live/stream.ts', video);
mpegTsPlayer.emit(
'error',
'NetworkError',
'FetchError',
new Error('CORS blocked')
);
mpegTsPlayer.emit('error', 'NetworkError', 'Exception', {
code: -1,
msg: 'blocked by CORS policy',
});
expect(emitPlaybackIssue).toHaveBeenCalledWith(
expect.objectContaining({
code: 'browser-access-error',
source: 'mpegts',
sourceUrl: 'https://example.test/live/stream.ts',
player: 'videojs',
code: 'network-error',
mpegTs: expect.objectContaining({ failure: 'network' }),
externalFallbackRecommended: false,
})
);
expect(JSON.stringify(emitPlaybackIssue.mock.calls)).not.toContain(
'CORS'
);
});
it('replaces an active session and removes listeners from the old video', () => {
@@ -3,6 +3,7 @@ import {
InlinePlaybackPlayer,
type PlaybackDiagnostic,
classifyMpegTsPlaybackIssue,
createMpegTsPlaybackEvidence,
createPlaybackSourceMetadata,
getPlaybackMediaExtensionFromUrl,
} from '../playback-diagnostics/playback-diagnostics.util';
@@ -59,12 +60,7 @@ export class VjsMpegTsSession {
this.syncDuration();
this.config.emitPlaybackIssue(
classifyMpegTsPlaybackIssue(
{
type: typeof type === 'string' ? type : undefined,
details:
typeof details === 'string' ? details : undefined,
info,
},
createMpegTsPlaybackEvidence(type, details, info),
createPlaybackSourceMetadata({
url,
mimeType: 'video/mp2t',
@@ -83,7 +83,7 @@ export function getDiagnosticDetails(
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_NATIVE_ERROR_MESSAGE',
value:
issue.vhs || issue.shaka
issue.vhs || issue.mpegTs || issue.shaka
? ''
: (issue.nativeErrorMessage ?? ''),
},
@@ -95,6 +95,21 @@ export function getDiagnosticDetails(
}
function formatDiagnosticErrorDetails(issue: PlaybackDiagnostic): string {
if (issue.mpegTs) {
return [
`stage=${issue.mpegTs.stage}`,
`failure=${issue.mpegTs.failure}`,
`type=${issue.mpegTs.engineType}`,
`details=${issue.mpegTs.engineDetails}`,
`disposition=${issue.mpegTs.disposition}`,
issue.mpegTs.httpStatus === undefined
? ''
: `HTTP ${issue.mpegTs.httpStatus}`,
]
.filter((value) => value.length > 0)
.join(' · ');
}
if (issue.shaka) {
return [
`stage=${issue.shaka.stage}`,
@@ -401,6 +401,37 @@ describe('WebPlayerViewComponent', () => {
expect(renderedDetails).not.toContain('response body');
});
it('renders only sanitized structured mpegts evidence in technical details', () => {
const issue = createStructuredMpegTsDiagnostic();
component.handlePlaybackIssue(issue);
fixture.detectChanges();
const details = component.getDiagnosticDetails(issue);
const renderedDetails = details.map(({ value }) => value).join(' ');
expect(component.getDiagnosticMeta(issue)).toBe('HTTP 404');
expect(details).toEqual(
expect.arrayContaining([
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_SOURCE',
value: 'mpegts.js',
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_ERROR_DETAILS',
value:
'stage=loader · failure=http · type=NetworkError · ' +
'details=HttpStatusCodeInvalid · ' +
'disposition=terminal · HTTP 404',
},
])
);
expect(renderedDetails).not.toContain('mpegts-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';
@@ -1038,3 +1069,30 @@ function createStructuredShakaDiagnostic(): PlaybackDiagnostic {
externalFallbackRecommended: false,
};
}
function createStructuredMpegTsDiagnostic(): PlaybackDiagnostic {
return {
code: PlaybackDiagnosticCode.NetworkError,
source: PlaybackDiagnosticSource.MpegTs,
sourceUrl:
'https://provider.example/live.ts?token=mpegts-render-secret',
container: 'ts',
mimeType: 'video/mp2t',
player: 'html5',
audioCodecs: [],
videoCodecs: [],
details: 'Authorization response body mpegts-render-secret',
nativeErrorMessage:
'https://provider.example/error?token=mpegts-render-secret',
httpStatus: 404,
mpegTs: {
engineType: 'NetworkError',
engineDetails: 'HttpStatusCodeInvalid',
disposition: 'terminal',
stage: 'loader',
failure: 'http',
httpStatus: 404,
},
externalFallbackRecommended: false,
};
}