mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
fix(playback): structure mpegts error evidence
This commit is contained in:
1 parent
0c377e3ef5
commit
fc8fc4bdae
7 files changed
+533
-158
No files matched your search
@@ -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,24 @@ 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 { createMpegTsPlaybackEvidence } from './mpegts-playback-evidence.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 +164,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 +209,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 +226,7 @@ export function createPlaybackDiagnostic(options: {
|
||||
nativeErrorType,
|
||||
vhs,
|
||||
hls,
|
||||
mpegTs,
|
||||
shaka,
|
||||
} = options;
|
||||
|
||||
@@ -293,6 +246,7 @@ export function createPlaybackDiagnostic(options: {
|
||||
nativeErrorType,
|
||||
vhs,
|
||||
hls,
|
||||
mpegTs,
|
||||
shaka,
|
||||
externalFallbackRecommended:
|
||||
options.externalFallbackRecommended ??
|
||||
@@ -300,6 +254,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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user