mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
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:
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')
|
||||
)
|
||||
);
|
||||
|
||||
+46
@@ -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(
|
||||
|
||||
+19
-1
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user