mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 20:56:19 -08:00
fix(playback): structure Shaka diagnostics
This commit is contained in:
1 parent
d0694a33c4
commit
1a621ffb6d
15 files changed
+1523
-249
No files matched your search
@@ -1,4 +1,10 @@
|
||||
import type { ChannelDrm } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
InlinePlaybackPlayer,
|
||||
type PlaybackDiagnostic,
|
||||
PlaybackDiagnosticCode,
|
||||
PlaybackDiagnosticSource,
|
||||
} from '../playback-diagnostics/playback-diagnostics.model';
|
||||
import {
|
||||
createFakeShakaEnvironment,
|
||||
flushShakaMicrotasks,
|
||||
@@ -82,4 +88,62 @@ describe('ArtPlayerSourceSession DASH (mpd custom type)', () => {
|
||||
await flushShakaMicrotasks();
|
||||
expect(fakeShaka.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('routes only sanitized terminal Shaka evidence through the ArtPlayer adapter', async () => {
|
||||
const secret = 'artplayer-shaka-secret';
|
||||
const issues: PlaybackDiagnostic[] = [];
|
||||
const fakeShaka = createFakeShakaEnvironment({
|
||||
onCreate: (shakaPlayer) => {
|
||||
shakaPlayer.loadResult = Promise.reject({
|
||||
severity: 1,
|
||||
category: 1,
|
||||
code: 1001,
|
||||
message: `https://provider.example/?token=${secret}`,
|
||||
data: [
|
||||
`https://provider.example/manifest.mpd?token=${secret}`,
|
||||
503,
|
||||
`provider body ${secret}`,
|
||||
{ Authorization: `Bearer ${secret}` },
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
const { session, player, video } = createSession({
|
||||
sharedControls: true,
|
||||
emitPlaybackIssue: (issue) => issues.push(issue),
|
||||
loadShaka: fakeShaka.loader,
|
||||
});
|
||||
session.attach(player);
|
||||
|
||||
session.customType['mpd']?.(
|
||||
video,
|
||||
'https://example.test/failing.mpd',
|
||||
player
|
||||
);
|
||||
await flushShakaMicrotasks();
|
||||
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
source: PlaybackDiagnosticSource.Shaka,
|
||||
player: InlinePlaybackPlayer.ArtPlayer,
|
||||
code: PlaybackDiagnosticCode.NetworkError,
|
||||
httpStatus: 503,
|
||||
shaka: {
|
||||
severity: 'recoverable',
|
||||
category: 'network',
|
||||
engineCode: 1001,
|
||||
disposition: 'terminal',
|
||||
stage: 'unknown',
|
||||
failure: 'network',
|
||||
httpStatus: 503,
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(JSON.stringify(issues[0].shaka)).not.toContain(secret);
|
||||
expect(JSON.stringify(issues[0].shaka)).not.toContain(
|
||||
'provider.example'
|
||||
);
|
||||
expect(JSON.stringify(issues[0].shaka)).not.toContain('Authorization');
|
||||
});
|
||||
});
|
||||
@@ -173,6 +173,76 @@ export interface HlsPlaybackEvidence {
|
||||
readonly httpStatus?: number;
|
||||
}
|
||||
|
||||
export const ShakaPlaybackSeverity = {
|
||||
Recoverable: 'recoverable',
|
||||
Critical: 'critical',
|
||||
Unknown: 'unknown',
|
||||
} as const;
|
||||
|
||||
export type ShakaPlaybackSeverity =
|
||||
(typeof ShakaPlaybackSeverity)[keyof typeof ShakaPlaybackSeverity];
|
||||
|
||||
export const ShakaPlaybackCategory = {
|
||||
Network: 'network',
|
||||
Text: 'text',
|
||||
Media: 'media',
|
||||
Manifest: 'manifest',
|
||||
Streaming: 'streaming',
|
||||
Drm: 'drm',
|
||||
Player: 'player',
|
||||
Cast: 'cast',
|
||||
Storage: 'storage',
|
||||
Ads: 'ads',
|
||||
Unknown: 'unknown',
|
||||
} as const;
|
||||
|
||||
export type ShakaPlaybackCategory =
|
||||
(typeof ShakaPlaybackCategory)[keyof typeof ShakaPlaybackCategory];
|
||||
|
||||
export const ShakaPlaybackDisposition = {
|
||||
Terminal: 'terminal',
|
||||
Recoverable: 'recoverable',
|
||||
} as const;
|
||||
|
||||
export type ShakaPlaybackDisposition =
|
||||
(typeof ShakaPlaybackDisposition)[keyof typeof ShakaPlaybackDisposition];
|
||||
|
||||
export const ShakaPlaybackStage = {
|
||||
Manifest: 'manifest',
|
||||
Segment: 'segment',
|
||||
Media: 'media',
|
||||
License: 'license',
|
||||
Unknown: 'unknown',
|
||||
} as const;
|
||||
|
||||
export type ShakaPlaybackStage =
|
||||
(typeof ShakaPlaybackStage)[keyof typeof ShakaPlaybackStage];
|
||||
|
||||
export const ShakaPlaybackFailure = {
|
||||
Network: 'network',
|
||||
Drm: 'drm',
|
||||
Manifest: 'manifest',
|
||||
Media: 'media',
|
||||
Unknown: 'unknown',
|
||||
} as const;
|
||||
|
||||
export type ShakaPlaybackFailure =
|
||||
(typeof ShakaPlaybackFailure)[keyof typeof ShakaPlaybackFailure];
|
||||
|
||||
export const ShakaPlaybackUnknownCode = 'unknown' as const;
|
||||
|
||||
export type ShakaPlaybackEngineCode = number | typeof ShakaPlaybackUnknownCode;
|
||||
|
||||
export interface ShakaPlaybackEvidence {
|
||||
readonly severity: ShakaPlaybackSeverity;
|
||||
readonly category: ShakaPlaybackCategory;
|
||||
readonly engineCode: ShakaPlaybackEngineCode;
|
||||
readonly disposition: ShakaPlaybackDisposition;
|
||||
readonly stage: ShakaPlaybackStage;
|
||||
readonly failure: ShakaPlaybackFailure;
|
||||
readonly httpStatus?: number;
|
||||
}
|
||||
|
||||
export interface MpegTsPlaybackErrorInput {
|
||||
readonly type?: string;
|
||||
readonly details?: string;
|
||||
@@ -196,6 +266,7 @@ export interface PlaybackDiagnostic {
|
||||
readonly nativeErrorType?: string;
|
||||
readonly vhs?: VhsPlaybackEvidence;
|
||||
readonly hls?: HlsPlaybackEvidence;
|
||||
readonly shaka?: ShakaPlaybackEvidence;
|
||||
readonly externalFallbackRecommended: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
PlaybackDiagnosticCode,
|
||||
PlaybackDiagnosticSource,
|
||||
PlaybackSourceMetadata,
|
||||
ShakaPlaybackEvidence,
|
||||
VhsPlaybackEngineType as VhsPlaybackEngineTypeValue,
|
||||
VhsPlaybackEvidence,
|
||||
} from './playback-diagnostics.model';
|
||||
@@ -257,6 +258,7 @@ export function createPlaybackDiagnostic(options: {
|
||||
readonly nativeErrorType?: string;
|
||||
readonly vhs?: VhsPlaybackEvidence;
|
||||
readonly hls?: HlsPlaybackEvidence;
|
||||
readonly shaka?: ShakaPlaybackEvidence;
|
||||
/** Overrides the code-derived recommendation, e.g. when external players
|
||||
* are known to be unable to handle the stream either. */
|
||||
readonly externalFallbackRecommended?: boolean;
|
||||
@@ -272,6 +274,7 @@ export function createPlaybackDiagnostic(options: {
|
||||
nativeErrorType,
|
||||
vhs,
|
||||
hls,
|
||||
shaka,
|
||||
} = options;
|
||||
|
||||
return {
|
||||
@@ -290,6 +293,7 @@ export function createPlaybackDiagnostic(options: {
|
||||
nativeErrorType,
|
||||
vhs,
|
||||
hls,
|
||||
shaka,
|
||||
externalFallbackRecommended:
|
||||
options.externalFallbackRecommended ??
|
||||
isExternalFallbackRecommended(code),
|
||||
|
||||
@@ -9,91 +9,213 @@ import {
|
||||
createUnsupportedDrmDiagnostic,
|
||||
} from './shaka-error-classifier';
|
||||
|
||||
interface StructuredShakaDiagnostic {
|
||||
readonly code: string;
|
||||
readonly details?: string;
|
||||
readonly httpStatus?: number;
|
||||
readonly shaka?: {
|
||||
readonly severity: string;
|
||||
readonly category: string;
|
||||
readonly engineCode: number | string;
|
||||
readonly disposition: string;
|
||||
readonly stage: string;
|
||||
readonly failure: string;
|
||||
readonly httpStatus?: number;
|
||||
};
|
||||
}
|
||||
|
||||
type StructuredClassifier = (
|
||||
error: Record<string, unknown> | null | undefined,
|
||||
sourceMetadata: typeof metadata,
|
||||
disposition: 'terminal' | 'recoverable'
|
||||
) => StructuredShakaDiagnostic | null;
|
||||
|
||||
const metadata = createPlaybackSourceMetadata({
|
||||
url: 'http://example.com/stream.mpd',
|
||||
mimeType: 'application/dash+xml',
|
||||
player: InlinePlaybackPlayer.Html5,
|
||||
});
|
||||
|
||||
const classify = classifyShakaPlaybackIssue as StructuredClassifier;
|
||||
|
||||
describe('classifyShakaPlaybackIssue', () => {
|
||||
it.each([
|
||||
['DRM category', { category: 6, code: 6001 }],
|
||||
['restrictions-cannot-be-met', { category: 4, code: 4012 }],
|
||||
['DRM category/code pair', { severity: 2, category: 6, code: 6001 }],
|
||||
['manifest key-system code', { severity: 2, category: 4, code: 4008 }],
|
||||
])('maps exact %s to DrmOrEncryption', (_label, error) => {
|
||||
const issue = classify(error, metadata, 'terminal');
|
||||
expect(issue).not.toBeNull();
|
||||
expect(issue?.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
|
||||
expect(issue?.shaka?.failure).toBe('drm');
|
||||
});
|
||||
|
||||
it('maps an exact terminal network error and retains only safe status evidence', () => {
|
||||
const issue = classify(
|
||||
{
|
||||
severity: 1,
|
||||
category: 1,
|
||||
code: 1001,
|
||||
message:
|
||||
'Blocked by CORS at https://provider.example/?token=secret',
|
||||
data: [
|
||||
'https://provider.example/manifest.mpd?token=secret',
|
||||
503,
|
||||
'provider response secret',
|
||||
{ Authorization: 'Bearer secret' },
|
||||
],
|
||||
},
|
||||
metadata,
|
||||
'terminal'
|
||||
);
|
||||
|
||||
expect(issue).toEqual(
|
||||
expect.objectContaining({
|
||||
code: PlaybackDiagnosticCode.NetworkError,
|
||||
source: PlaybackDiagnosticSource.Shaka,
|
||||
httpStatus: 503,
|
||||
details: undefined,
|
||||
shaka: {
|
||||
severity: 'recoverable',
|
||||
category: 'network',
|
||||
engineCode: 1001,
|
||||
disposition: 'terminal',
|
||||
stage: 'unknown',
|
||||
failure: 'network',
|
||||
httpStatus: 503,
|
||||
},
|
||||
})
|
||||
);
|
||||
const serialized = JSON.stringify(issue);
|
||||
expect(serialized).not.toContain('secret');
|
||||
expect(serialized).not.toContain('provider.example');
|
||||
expect(serialized).not.toContain('Authorization');
|
||||
expect(serialized).not.toContain('response');
|
||||
});
|
||||
|
||||
it.each([3014, 3015, 3016])(
|
||||
'maps exact media pipeline code %s to MediaDecodeError',
|
||||
(code) => {
|
||||
const issue = classify(
|
||||
{ severity: 2, category: 3, code },
|
||||
metadata,
|
||||
'terminal'
|
||||
);
|
||||
expect(issue?.code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
|
||||
}
|
||||
);
|
||||
|
||||
it('maps only the exact DASH unsupported-container code to UnsupportedContainer', () => {
|
||||
const issue = classify(
|
||||
{ severity: 2, category: 4, code: 4006 },
|
||||
metadata,
|
||||
'terminal'
|
||||
);
|
||||
expect(issue?.code).toBe(PlaybackDiagnosticCode.UnsupportedContainer);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'license keyword in message',
|
||||
{ category: 5, code: 5001, message: 'license request failed' },
|
||||
'manifest parsing',
|
||||
{ severity: 2, category: 4, code: 4001 },
|
||||
'manifest',
|
||||
],
|
||||
])('maps %s to DrmOrEncryption', (_label, error) => {
|
||||
const issue = classifyShakaPlaybackIssue(error, metadata);
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
|
||||
expect(issue.source).toBe(PlaybackDiagnosticSource.Shaka);
|
||||
});
|
||||
[
|
||||
'ambiguous browser content support',
|
||||
{ severity: 2, category: 4, code: 4032 },
|
||||
'media',
|
||||
],
|
||||
[
|
||||
'ambiguous restrictions',
|
||||
{ severity: 2, category: 4, code: 4012 },
|
||||
'unknown',
|
||||
],
|
||||
[
|
||||
'generic media parsing',
|
||||
{ severity: 2, category: 3, code: 3005 },
|
||||
'media',
|
||||
],
|
||||
[
|
||||
'mismatched category/code pair',
|
||||
{ severity: 2, category: 1, code: 6001 },
|
||||
'unknown',
|
||||
],
|
||||
[
|
||||
'mismatched media code pair',
|
||||
{ severity: 2, category: 1, code: 3014 },
|
||||
'unknown',
|
||||
],
|
||||
[
|
||||
'mismatched container code pair',
|
||||
{ severity: 2, category: 3, code: 4006 },
|
||||
'unknown',
|
||||
],
|
||||
[
|
||||
'provider extension values',
|
||||
{ severity: 3, category: 11, code: 123456 },
|
||||
'unknown',
|
||||
],
|
||||
])(
|
||||
'keeps %s as UnknownPlaybackError with structured failure=%s',
|
||||
(_label, error, failure) => {
|
||||
const issue = classify(
|
||||
{
|
||||
...error,
|
||||
message:
|
||||
'CORS codec DRM unsupported container license request',
|
||||
data: [{ provider: 'secret-payload' }],
|
||||
},
|
||||
metadata,
|
||||
'terminal'
|
||||
);
|
||||
|
||||
it('maps network category to NetworkError', () => {
|
||||
const issue = classifyShakaPlaybackIssue(
|
||||
{ category: 1, code: 1001 },
|
||||
metadata
|
||||
expect(issue?.code).toBe(
|
||||
PlaybackDiagnosticCode.UnknownPlaybackError
|
||||
);
|
||||
expect(issue?.shaka?.failure).toBe(failure);
|
||||
expect(issue?.details).toBeUndefined();
|
||||
}
|
||||
);
|
||||
|
||||
it('does not turn a recoverable Shaka event into a terminal diagnostic', () => {
|
||||
const issue = classify(
|
||||
{ severity: 1, category: 1, code: 1002 },
|
||||
metadata,
|
||||
'recoverable'
|
||||
);
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError);
|
||||
expect(issue).toBeNull();
|
||||
});
|
||||
|
||||
it('maps CORS-flavored network failures to BrowserAccessError', () => {
|
||||
const issue = classifyShakaPlaybackIssue(
|
||||
{ category: 1, code: 1002, message: 'Blocked by CORS policy' },
|
||||
metadata
|
||||
it('creates unknown structured evidence when no public Shaka error is available', () => {
|
||||
const issue = classify(null, metadata, 'terminal');
|
||||
expect(issue).toEqual(
|
||||
expect.objectContaining({
|
||||
code: PlaybackDiagnosticCode.UnknownPlaybackError,
|
||||
details: undefined,
|
||||
shaka: {
|
||||
severity: 'unknown',
|
||||
category: 'unknown',
|
||||
engineCode: 'unknown',
|
||||
disposition: 'terminal',
|
||||
stage: 'unknown',
|
||||
failure: 'unknown',
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.BrowserAccessError);
|
||||
});
|
||||
|
||||
it('maps media category to MediaDecodeError', () => {
|
||||
const issue = classifyShakaPlaybackIssue(
|
||||
{ category: 3, code: 3016 },
|
||||
metadata
|
||||
);
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
|
||||
});
|
||||
|
||||
it('maps codec-flavored media failures to UnsupportedCodec', () => {
|
||||
const issue = classifyShakaPlaybackIssue(
|
||||
{ category: 3, code: 3005, message: 'addCodec failed for hvc1' },
|
||||
metadata
|
||||
);
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec);
|
||||
});
|
||||
|
||||
it('maps manifest category to UnsupportedContainer', () => {
|
||||
const issue = classifyShakaPlaybackIssue(
|
||||
{ category: 4, code: 4001 },
|
||||
metadata
|
||||
);
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.UnsupportedContainer);
|
||||
});
|
||||
|
||||
it('falls back to UnknownPlaybackError and keeps details', () => {
|
||||
const issue = classifyShakaPlaybackIssue(
|
||||
{ category: 7, code: 7002, message: 'boom' },
|
||||
metadata
|
||||
);
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
|
||||
expect(issue.details).toContain('boom');
|
||||
expect(issue.details).toContain('7002');
|
||||
});
|
||||
|
||||
it('tolerates missing error objects', () => {
|
||||
const issue = classifyShakaPlaybackIssue(null, metadata);
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createUnsupportedDrmDiagnostic', () => {
|
||||
it('creates a DRM diagnostic without recommending unusable external fallbacks', () => {
|
||||
it('creates a safe DRM diagnostic without echoing provider license data', () => {
|
||||
const secret = 'unsupported-drm-secret';
|
||||
const issue = createUnsupportedDrmDiagnostic(
|
||||
'com.widevine.alpha',
|
||||
`https://provider.example/license?token=${secret}`,
|
||||
metadata
|
||||
);
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
|
||||
expect(issue.source).toBe(PlaybackDiagnosticSource.Shaka);
|
||||
expect(issue.details).toContain('com.widevine.alpha');
|
||||
expect(issue.details).toBe('Unsupported DRM license configuration');
|
||||
expect(JSON.stringify(issue)).not.toContain(secret);
|
||||
expect(JSON.stringify(issue)).not.toContain('provider.example');
|
||||
// MPV/VLC cannot receive KODIPROP license config, so the diagnostic
|
||||
// must not offer them as a fallback.
|
||||
expect(issue.externalFallbackRecommended).toBe(false);
|
||||
|
||||
@@ -1,116 +1,93 @@
|
||||
import type {
|
||||
PlaybackDiagnostic,
|
||||
PlaybackDiagnosticCode,
|
||||
PlaybackSourceMetadata,
|
||||
ShakaPlaybackDisposition,
|
||||
ShakaPlaybackEvidence,
|
||||
} from '../playback-diagnostics/playback-diagnostics.model';
|
||||
import {
|
||||
PlaybackDiagnosticCode as DiagnosticCode,
|
||||
PlaybackDiagnosticSource as DiagnosticSource,
|
||||
ShakaPlaybackCategory,
|
||||
ShakaPlaybackDisposition as ShakaDisposition,
|
||||
ShakaPlaybackFailure,
|
||||
} from '../playback-diagnostics/playback-diagnostics.model';
|
||||
import { createPlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util';
|
||||
import {
|
||||
isBrowserAccessFailure,
|
||||
isCodecFailure,
|
||||
isDrmOrEncryptionFailure,
|
||||
} from '../playback-diagnostics/playback-error-patterns.util';
|
||||
import { SHAKA_ERROR_CODE } from './shaka-error-contract';
|
||||
import { createShakaPlaybackEvidence } from './shaka-playback-evidence.util';
|
||||
import type { ShakaErrorLike } from './shaka-module.types';
|
||||
|
||||
/**
|
||||
* Numeric `shaka.util.Error.Category` values (stable public Shaka API). Kept
|
||||
* local so classification stays a pure function that does not need the lazily
|
||||
* loaded module.
|
||||
*/
|
||||
const SHAKA_CATEGORY = {
|
||||
Network: 1,
|
||||
Text: 2,
|
||||
Media: 3,
|
||||
Manifest: 4,
|
||||
Streaming: 5,
|
||||
Drm: 6,
|
||||
} as const;
|
||||
export {
|
||||
SHAKA_DIAGNOSTIC_VERSION,
|
||||
SHAKA_ERROR_CATEGORY,
|
||||
SHAKA_ERROR_CODE,
|
||||
SHAKA_ERROR_SEVERITY,
|
||||
} from './shaka-error-contract';
|
||||
export { createShakaPlaybackEvidence } from './shaka-playback-evidence.util';
|
||||
|
||||
/** `shaka.util.Error.Code.RESTRICTIONS_CANNOT_BE_MET` — in practice this fires
|
||||
* when every variant is restricted by unusable decryption keys. */
|
||||
const RESTRICTIONS_CANNOT_BE_MET = 4012;
|
||||
const MEDIA_DECODE_CODES = new Set<number>([
|
||||
SHAKA_ERROR_CODE.MEDIA_SOURCE_OPERATION_FAILED,
|
||||
SHAKA_ERROR_CODE.MEDIA_SOURCE_OPERATION_THREW,
|
||||
SHAKA_ERROR_CODE.VIDEO_ERROR,
|
||||
]);
|
||||
|
||||
export function classifyShakaPlaybackIssue(
|
||||
error: Partial<ShakaErrorLike> | null | undefined,
|
||||
metadata: PlaybackSourceMetadata
|
||||
): PlaybackDiagnostic {
|
||||
const details = formatShakaErrorDetails(error);
|
||||
const lowerDetails = details.toLowerCase();
|
||||
const category = error?.category;
|
||||
|
||||
if (
|
||||
category === SHAKA_CATEGORY.Drm ||
|
||||
error?.code === RESTRICTIONS_CANNOT_BE_MET ||
|
||||
isDrmOrEncryptionFailure(lowerDetails)
|
||||
) {
|
||||
return createPlaybackDiagnostic({
|
||||
code: DiagnosticCode.DrmOrEncryption,
|
||||
source: DiagnosticSource.Shaka,
|
||||
metadata,
|
||||
details,
|
||||
});
|
||||
}
|
||||
|
||||
if (category === SHAKA_CATEGORY.Network) {
|
||||
return createPlaybackDiagnostic({
|
||||
code: isBrowserAccessFailure(lowerDetails)
|
||||
? DiagnosticCode.BrowserAccessError
|
||||
: DiagnosticCode.NetworkError,
|
||||
source: DiagnosticSource.Shaka,
|
||||
metadata,
|
||||
details,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
category === SHAKA_CATEGORY.Media ||
|
||||
category === SHAKA_CATEGORY.Streaming
|
||||
) {
|
||||
return createPlaybackDiagnostic({
|
||||
code: isCodecFailure(lowerDetails)
|
||||
? DiagnosticCode.UnsupportedCodec
|
||||
: DiagnosticCode.MediaDecodeError,
|
||||
source: DiagnosticSource.Shaka,
|
||||
metadata,
|
||||
details,
|
||||
});
|
||||
}
|
||||
|
||||
if (category === SHAKA_CATEGORY.Manifest) {
|
||||
return createPlaybackDiagnostic({
|
||||
code: DiagnosticCode.UnsupportedContainer,
|
||||
source: DiagnosticSource.Shaka,
|
||||
metadata,
|
||||
details,
|
||||
});
|
||||
metadata: PlaybackSourceMetadata,
|
||||
disposition: ShakaPlaybackDisposition
|
||||
): PlaybackDiagnostic | null {
|
||||
const evidence = createShakaPlaybackEvidence(error, disposition);
|
||||
if (evidence.disposition === ShakaDisposition.Recoverable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPlaybackDiagnostic({
|
||||
code: DiagnosticCode.UnknownPlaybackError,
|
||||
code: getDiagnosticCode(evidence),
|
||||
source: DiagnosticSource.Shaka,
|
||||
metadata,
|
||||
details,
|
||||
httpStatus: evidence.httpStatus,
|
||||
shaka: evidence,
|
||||
});
|
||||
}
|
||||
|
||||
function getDiagnosticCode(
|
||||
evidence: ShakaPlaybackEvidence
|
||||
): PlaybackDiagnosticCode {
|
||||
if (evidence.failure === ShakaPlaybackFailure.Network) {
|
||||
return DiagnosticCode.NetworkError;
|
||||
}
|
||||
if (evidence.failure === ShakaPlaybackFailure.Drm) {
|
||||
return DiagnosticCode.DrmOrEncryption;
|
||||
}
|
||||
if (
|
||||
evidence.category === ShakaPlaybackCategory.Manifest &&
|
||||
evidence.engineCode === SHAKA_ERROR_CODE.DASH_UNSUPPORTED_CONTAINER
|
||||
) {
|
||||
return DiagnosticCode.UnsupportedContainer;
|
||||
}
|
||||
if (
|
||||
evidence.category === ShakaPlaybackCategory.Media &&
|
||||
typeof evidence.engineCode === 'number' &&
|
||||
MEDIA_DECODE_CODES.has(evidence.engineCode)
|
||||
) {
|
||||
return DiagnosticCode.MediaDecodeError;
|
||||
}
|
||||
return DiagnosticCode.UnknownPlaybackError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostic for `.mpd` channels that declare a DRM system the app cannot
|
||||
* handle (Widevine, PlayReady, malformed ClearKey config, …). Emitted before
|
||||
* any Shaka engine is started.
|
||||
* handle. Provider-supplied license strings are intentionally not retained.
|
||||
*/
|
||||
export function createUnsupportedDrmDiagnostic(
|
||||
licenseType: string,
|
||||
_licenseType: string,
|
||||
metadata: PlaybackSourceMetadata
|
||||
): PlaybackDiagnostic {
|
||||
return createPlaybackDiagnostic({
|
||||
code: DiagnosticCode.DrmOrEncryption,
|
||||
source: DiagnosticSource.Shaka,
|
||||
metadata,
|
||||
details: licenseType
|
||||
? `Unsupported DRM license configuration: ${licenseType}`
|
||||
: 'Unsupported DRM license configuration',
|
||||
details: 'Unsupported DRM license configuration',
|
||||
// External MPV/VLC cannot receive the KODIPROP license config either,
|
||||
// so offering them as a fallback would just fail differently.
|
||||
externalFallbackRecommended: false,
|
||||
@@ -118,56 +95,15 @@ export function createUnsupportedDrmDiagnostic(
|
||||
}
|
||||
|
||||
/** Narrows an unknown rejection to a Shaka-error-like shape, if it is one. */
|
||||
export function asShakaError(
|
||||
error: unknown
|
||||
): Partial<ShakaErrorLike> | null {
|
||||
export function asShakaError(error: unknown): Partial<ShakaErrorLike> | null {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = error as Partial<ShakaErrorLike>;
|
||||
return typeof candidate.code === 'number' ||
|
||||
typeof candidate.category === 'number'
|
||||
typeof candidate.category === 'number' ||
|
||||
typeof candidate.severity === 'number'
|
||||
? candidate
|
||||
: null;
|
||||
}
|
||||
|
||||
export function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return typeof error === 'string' ? error : String(error);
|
||||
}
|
||||
|
||||
function formatShakaErrorDetails(
|
||||
error: Partial<ShakaErrorLike> | null | undefined
|
||||
): string {
|
||||
if (!error) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const parts = [
|
||||
typeof error.category === 'number'
|
||||
? `Shaka category ${error.category}`
|
||||
: '',
|
||||
typeof error.code === 'number' ? `code ${error.code}` : '',
|
||||
error.message ?? '',
|
||||
stringifyErrorData(error.data),
|
||||
];
|
||||
|
||||
return parts.filter((part) => part.length > 0).join(' ');
|
||||
}
|
||||
|
||||
function stringifyErrorData(data: unknown[] | undefined): string {
|
||||
if (!data || data.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
const serialized = JSON.stringify(data);
|
||||
return serialized === '[]' ? '' : serialized;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Public Shaka error values audited against the locked 5.2.2 runtime.
|
||||
*
|
||||
* Keep the version assertion in the contract spec: a Shaka upgrade must stop
|
||||
* here for a new audit instead of silently accepting new error layouts.
|
||||
*/
|
||||
export const SHAKA_DIAGNOSTIC_VERSION = 'v5.2.2';
|
||||
|
||||
export const SHAKA_ERROR_SEVERITY = {
|
||||
RECOVERABLE: 1,
|
||||
CRITICAL: 2,
|
||||
} as const;
|
||||
|
||||
export const SHAKA_ERROR_CATEGORY = {
|
||||
NETWORK: 1,
|
||||
TEXT: 2,
|
||||
MEDIA: 3,
|
||||
MANIFEST: 4,
|
||||
STREAMING: 5,
|
||||
DRM: 6,
|
||||
PLAYER: 7,
|
||||
CAST: 8,
|
||||
STORAGE: 9,
|
||||
ADS: 10,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Public online-playback codes whose numeric value may cross the evidence
|
||||
* boundary. Retired gaps and offline/cast/ad-only codes are intentionally
|
||||
* absent.
|
||||
*/
|
||||
export const SHAKA_ERROR_CODE = {
|
||||
UNSUPPORTED_SCHEME: 1000,
|
||||
BAD_HTTP_STATUS: 1001,
|
||||
HTTP_ERROR: 1002,
|
||||
TIMEOUT: 1003,
|
||||
MALFORMED_DATA_URI: 1004,
|
||||
REQUEST_FILTER_ERROR: 1006,
|
||||
RESPONSE_FILTER_ERROR: 1007,
|
||||
SEGMENT_MISSING: 1011,
|
||||
BUFFER_READ_OUT_OF_BOUNDS: 3000,
|
||||
JS_INTEGER_OVERFLOW: 3001,
|
||||
EBML_OVERFLOW: 3002,
|
||||
EBML_BAD_FLOATING_POINT_SIZE: 3003,
|
||||
MP4_SIDX_WRONG_BOX_TYPE: 3004,
|
||||
MP4_SIDX_INVALID_TIMESCALE: 3005,
|
||||
MP4_SIDX_TYPE_NOT_SUPPORTED: 3006,
|
||||
WEBM_CUES_ELEMENT_MISSING: 3007,
|
||||
WEBM_EBML_HEADER_ELEMENT_MISSING: 3008,
|
||||
WEBM_SEGMENT_ELEMENT_MISSING: 3009,
|
||||
WEBM_INFO_ELEMENT_MISSING: 3010,
|
||||
WEBM_DURATION_ELEMENT_MISSING: 3011,
|
||||
WEBM_CUE_TRACK_POSITIONS_ELEMENT_MISSING: 3012,
|
||||
WEBM_CUE_TIME_ELEMENT_MISSING: 3013,
|
||||
MEDIA_SOURCE_OPERATION_FAILED: 3014,
|
||||
MEDIA_SOURCE_OPERATION_THREW: 3015,
|
||||
VIDEO_ERROR: 3016,
|
||||
QUOTA_EXCEEDED_ERROR: 3017,
|
||||
TRANSMUXING_FAILED: 3018,
|
||||
CONTENT_TRANSFORMATION_FAILED: 3019,
|
||||
TRANSMUXING_NO_VIDEO_DATA: 3023,
|
||||
STREAMING_NOT_ALLOWED: 3024,
|
||||
BUFFER_WRITE_OUT_OF_BOUNDS: 3025,
|
||||
UNABLE_TO_GUESS_MANIFEST_TYPE: 4000,
|
||||
DASH_INVALID_XML: 4001,
|
||||
DASH_NO_SEGMENT_INFO: 4002,
|
||||
DASH_EMPTY_ADAPTATION_SET: 4003,
|
||||
DASH_EMPTY_PERIOD: 4004,
|
||||
DASH_WEBM_MISSING_INIT: 4005,
|
||||
DASH_UNSUPPORTED_CONTAINER: 4006,
|
||||
DASH_PSSH_BAD_ENCODING: 4007,
|
||||
DASH_NO_COMMON_KEY_SYSTEM: 4008,
|
||||
DASH_MULTIPLE_KEY_IDS_NOT_SUPPORTED: 4009,
|
||||
DASH_CONFLICTING_KEY_IDS: 4010,
|
||||
RESTRICTIONS_CANNOT_BE_MET: 4012,
|
||||
DASH_DUPLICATE_REPRESENTATION_ID: 4018,
|
||||
DASH_UNSUPPORTED_XLINK_ACTUATE: 4027,
|
||||
DASH_XLINK_DEPTH_LIMIT: 4028,
|
||||
CONTENT_UNSUPPORTED_BY_BROWSER: 4032,
|
||||
NO_VARIANTS: 4036,
|
||||
PERIOD_FLATTENING_FAILED: 4037,
|
||||
INCONSISTENT_DRM_ACROSS_PERIODS: 4038,
|
||||
NO_WEB_CRYPTO_API: 4042,
|
||||
AES_128_INVALID_IV_LENGTH: 4048,
|
||||
AES_128_INVALID_KEY_LENGTH: 4049,
|
||||
DASH_CONFLICTING_AES_128: 4050,
|
||||
DASH_UNSUPPORTED_AES_128: 4051,
|
||||
DASH_INVALID_PATCH: 4052,
|
||||
DASH_MSE_ENCRYPTED_LEGACY_APPLE_MEDIA_KEYS_NOT_SUPPORTED: 4054,
|
||||
WEBTRANSPORT_NOT_AVAILABLE: 4056,
|
||||
WEBTRANSPORT_INITIALIZATION_FAILED: 4057,
|
||||
DASH_INVALID_JSON: 4061,
|
||||
DASH_UNSUPPORTED_ESSENTIAL_PROPERTY: 4063,
|
||||
NO_RECOGNIZED_KEY_SYSTEMS: 6000,
|
||||
REQUESTED_KEY_SYSTEM_CONFIG_UNAVAILABLE: 6001,
|
||||
FAILED_TO_CREATE_CDM: 6002,
|
||||
FAILED_TO_ATTACH_TO_VIDEO: 6003,
|
||||
INVALID_SERVER_CERTIFICATE: 6004,
|
||||
FAILED_TO_CREATE_SESSION: 6005,
|
||||
FAILED_TO_GENERATE_LICENSE_REQUEST: 6006,
|
||||
LICENSE_REQUEST_FAILED: 6007,
|
||||
LICENSE_RESPONSE_REJECTED: 6008,
|
||||
ENCRYPTED_CONTENT_WITHOUT_DRM_INFO: 6010,
|
||||
NO_LICENSE_SERVER_GIVEN: 6012,
|
||||
OFFLINE_SESSION_REMOVED: 6013,
|
||||
EXPIRED: 6014,
|
||||
SERVER_CERTIFICATE_REQUIRED: 6015,
|
||||
INIT_DATA_TRANSFORM_ERROR: 6016,
|
||||
SERVER_CERTIFICATE_REQUEST_FAILED: 6017,
|
||||
MIN_HDCP_VERSION_NOT_MATCH: 6018,
|
||||
ERROR_CHECKING_HDCP_VERSION: 6019,
|
||||
MISSING_EME_SUPPORT: 6020,
|
||||
LOAD_INTERRUPTED: 7000,
|
||||
} as const;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ShakaPlaybackDisposition as ShakaDisposition } from '../playback-diagnostics/playback-diagnostics.model';
|
||||
import {
|
||||
SHAKA_ERROR_CATEGORY,
|
||||
SHAKA_ERROR_CODE,
|
||||
SHAKA_ERROR_SEVERITY,
|
||||
} from './shaka-error-contract';
|
||||
import type { ShakaErrorLike } from './shaka-module.types';
|
||||
|
||||
export function getShakaErrorEventDisposition(
|
||||
error: Partial<ShakaErrorLike> | null
|
||||
): (typeof ShakaDisposition)[keyof typeof ShakaDisposition] | null {
|
||||
if (error?.severity === SHAKA_ERROR_SEVERITY.RECOVERABLE) {
|
||||
return ShakaDisposition.Recoverable;
|
||||
}
|
||||
if (error?.severity === SHAKA_ERROR_SEVERITY.CRITICAL) {
|
||||
return ShakaDisposition.Terminal;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isShakaLoadInterrupted(
|
||||
error: Partial<ShakaErrorLike> | null
|
||||
): boolean {
|
||||
return (
|
||||
error?.severity === SHAKA_ERROR_SEVERITY.CRITICAL &&
|
||||
error.category === SHAKA_ERROR_CATEGORY.PLAYER &&
|
||||
error.code === SHAKA_ERROR_CODE.LOAD_INTERRUPTED
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { SHAKA_ERROR_CODE } from './shaka-error-contract';
|
||||
|
||||
export const SHAKA_PUBLIC_CODES = new Set<number>(
|
||||
Object.values(SHAKA_ERROR_CODE)
|
||||
);
|
||||
|
||||
export const SHAKA_NETWORK_CODES = new Set<number>([
|
||||
SHAKA_ERROR_CODE.UNSUPPORTED_SCHEME,
|
||||
SHAKA_ERROR_CODE.BAD_HTTP_STATUS,
|
||||
SHAKA_ERROR_CODE.HTTP_ERROR,
|
||||
SHAKA_ERROR_CODE.TIMEOUT,
|
||||
SHAKA_ERROR_CODE.MALFORMED_DATA_URI,
|
||||
SHAKA_ERROR_CODE.REQUEST_FILTER_ERROR,
|
||||
SHAKA_ERROR_CODE.RESPONSE_FILTER_ERROR,
|
||||
SHAKA_ERROR_CODE.SEGMENT_MISSING,
|
||||
]);
|
||||
|
||||
export const SHAKA_MEDIA_CODES = new Set<number>([
|
||||
SHAKA_ERROR_CODE.BUFFER_READ_OUT_OF_BOUNDS,
|
||||
SHAKA_ERROR_CODE.JS_INTEGER_OVERFLOW,
|
||||
SHAKA_ERROR_CODE.EBML_OVERFLOW,
|
||||
SHAKA_ERROR_CODE.EBML_BAD_FLOATING_POINT_SIZE,
|
||||
SHAKA_ERROR_CODE.MP4_SIDX_WRONG_BOX_TYPE,
|
||||
SHAKA_ERROR_CODE.MP4_SIDX_INVALID_TIMESCALE,
|
||||
SHAKA_ERROR_CODE.MP4_SIDX_TYPE_NOT_SUPPORTED,
|
||||
SHAKA_ERROR_CODE.WEBM_CUES_ELEMENT_MISSING,
|
||||
SHAKA_ERROR_CODE.WEBM_EBML_HEADER_ELEMENT_MISSING,
|
||||
SHAKA_ERROR_CODE.WEBM_SEGMENT_ELEMENT_MISSING,
|
||||
SHAKA_ERROR_CODE.WEBM_INFO_ELEMENT_MISSING,
|
||||
SHAKA_ERROR_CODE.WEBM_DURATION_ELEMENT_MISSING,
|
||||
SHAKA_ERROR_CODE.WEBM_CUE_TRACK_POSITIONS_ELEMENT_MISSING,
|
||||
SHAKA_ERROR_CODE.WEBM_CUE_TIME_ELEMENT_MISSING,
|
||||
SHAKA_ERROR_CODE.MEDIA_SOURCE_OPERATION_FAILED,
|
||||
SHAKA_ERROR_CODE.MEDIA_SOURCE_OPERATION_THREW,
|
||||
SHAKA_ERROR_CODE.VIDEO_ERROR,
|
||||
SHAKA_ERROR_CODE.QUOTA_EXCEEDED_ERROR,
|
||||
SHAKA_ERROR_CODE.TRANSMUXING_FAILED,
|
||||
SHAKA_ERROR_CODE.CONTENT_TRANSFORMATION_FAILED,
|
||||
SHAKA_ERROR_CODE.TRANSMUXING_NO_VIDEO_DATA,
|
||||
SHAKA_ERROR_CODE.STREAMING_NOT_ALLOWED,
|
||||
SHAKA_ERROR_CODE.BUFFER_WRITE_OUT_OF_BOUNDS,
|
||||
]);
|
||||
|
||||
export const SHAKA_MANIFEST_DRM_CODES = new Set<number>([
|
||||
SHAKA_ERROR_CODE.DASH_PSSH_BAD_ENCODING,
|
||||
SHAKA_ERROR_CODE.DASH_NO_COMMON_KEY_SYSTEM,
|
||||
SHAKA_ERROR_CODE.DASH_MULTIPLE_KEY_IDS_NOT_SUPPORTED,
|
||||
SHAKA_ERROR_CODE.DASH_CONFLICTING_KEY_IDS,
|
||||
SHAKA_ERROR_CODE.INCONSISTENT_DRM_ACROSS_PERIODS,
|
||||
SHAKA_ERROR_CODE.NO_WEB_CRYPTO_API,
|
||||
SHAKA_ERROR_CODE.AES_128_INVALID_IV_LENGTH,
|
||||
SHAKA_ERROR_CODE.AES_128_INVALID_KEY_LENGTH,
|
||||
SHAKA_ERROR_CODE.DASH_CONFLICTING_AES_128,
|
||||
SHAKA_ERROR_CODE.DASH_UNSUPPORTED_AES_128,
|
||||
SHAKA_ERROR_CODE.DASH_MSE_ENCRYPTED_LEGACY_APPLE_MEDIA_KEYS_NOT_SUPPORTED,
|
||||
]);
|
||||
|
||||
export const SHAKA_MANIFEST_MEDIA_CODES = new Set<number>([
|
||||
SHAKA_ERROR_CODE.DASH_UNSUPPORTED_CONTAINER,
|
||||
SHAKA_ERROR_CODE.CONTENT_UNSUPPORTED_BY_BROWSER,
|
||||
]);
|
||||
|
||||
export const SHAKA_AMBIGUOUS_MANIFEST_CODES = new Set<number>([
|
||||
SHAKA_ERROR_CODE.RESTRICTIONS_CANNOT_BE_MET,
|
||||
]);
|
||||
|
||||
export const SHAKA_MANIFEST_CODES = new Set<number>(
|
||||
Object.values(SHAKA_ERROR_CODE).filter(
|
||||
(code) => code >= 4000 && code < 5000
|
||||
)
|
||||
);
|
||||
|
||||
export const SHAKA_DRM_CODES = new Set<number>(
|
||||
Object.values(SHAKA_ERROR_CODE).filter(
|
||||
(code) => code >= 6000 && code < 7000
|
||||
)
|
||||
);
|
||||
|
||||
export const SHAKA_SEGMENT_STAGE_CODES = new Set<number>([
|
||||
SHAKA_ERROR_CODE.SEGMENT_MISSING,
|
||||
SHAKA_ERROR_CODE.BUFFER_READ_OUT_OF_BOUNDS,
|
||||
SHAKA_ERROR_CODE.JS_INTEGER_OVERFLOW,
|
||||
SHAKA_ERROR_CODE.EBML_OVERFLOW,
|
||||
SHAKA_ERROR_CODE.EBML_BAD_FLOATING_POINT_SIZE,
|
||||
SHAKA_ERROR_CODE.MP4_SIDX_WRONG_BOX_TYPE,
|
||||
SHAKA_ERROR_CODE.MP4_SIDX_INVALID_TIMESCALE,
|
||||
SHAKA_ERROR_CODE.MP4_SIDX_TYPE_NOT_SUPPORTED,
|
||||
SHAKA_ERROR_CODE.WEBM_CUES_ELEMENT_MISSING,
|
||||
SHAKA_ERROR_CODE.WEBM_EBML_HEADER_ELEMENT_MISSING,
|
||||
SHAKA_ERROR_CODE.WEBM_SEGMENT_ELEMENT_MISSING,
|
||||
SHAKA_ERROR_CODE.WEBM_INFO_ELEMENT_MISSING,
|
||||
SHAKA_ERROR_CODE.WEBM_DURATION_ELEMENT_MISSING,
|
||||
SHAKA_ERROR_CODE.WEBM_CUE_TRACK_POSITIONS_ELEMENT_MISSING,
|
||||
SHAKA_ERROR_CODE.WEBM_CUE_TIME_ELEMENT_MISSING,
|
||||
]);
|
||||
|
||||
export const SHAKA_MEDIA_STAGE_CODES = new Set<number>([
|
||||
SHAKA_ERROR_CODE.MEDIA_SOURCE_OPERATION_FAILED,
|
||||
SHAKA_ERROR_CODE.MEDIA_SOURCE_OPERATION_THREW,
|
||||
SHAKA_ERROR_CODE.VIDEO_ERROR,
|
||||
SHAKA_ERROR_CODE.QUOTA_EXCEEDED_ERROR,
|
||||
SHAKA_ERROR_CODE.TRANSMUXING_FAILED,
|
||||
SHAKA_ERROR_CODE.CONTENT_TRANSFORMATION_FAILED,
|
||||
SHAKA_ERROR_CODE.TRANSMUXING_NO_VIDEO_DATA,
|
||||
SHAKA_ERROR_CODE.STREAMING_NOT_ALLOWED,
|
||||
SHAKA_ERROR_CODE.BUFFER_WRITE_OUT_OF_BOUNDS,
|
||||
]);
|
||||
|
||||
export const SHAKA_LICENSE_STAGE_CODES = new Set<number>([
|
||||
SHAKA_ERROR_CODE.FAILED_TO_GENERATE_LICENSE_REQUEST,
|
||||
SHAKA_ERROR_CODE.LICENSE_REQUEST_FAILED,
|
||||
SHAKA_ERROR_CODE.LICENSE_RESPONSE_REJECTED,
|
||||
SHAKA_ERROR_CODE.NO_LICENSE_SERVER_GIVEN,
|
||||
SHAKA_ERROR_CODE.EXPIRED,
|
||||
SHAKA_ERROR_CODE.SERVER_CERTIFICATE_REQUEST_FAILED,
|
||||
]);
|
||||
|
||||
export const SHAKA_NESTED_HTTP_CODES = new Set<number>([
|
||||
SHAKA_ERROR_CODE.LICENSE_REQUEST_FAILED,
|
||||
SHAKA_ERROR_CODE.SERVER_CERTIFICATE_REQUEST_FAILED,
|
||||
]);
|
||||
@@ -26,8 +26,7 @@ export interface ShakaErrorLike {
|
||||
severity: number;
|
||||
category: number;
|
||||
code: number;
|
||||
message?: string;
|
||||
data?: unknown[];
|
||||
data?: readonly unknown[];
|
||||
}
|
||||
|
||||
export interface ShakaPlayerLike {
|
||||
@@ -66,10 +65,7 @@ export type ShakaModuleLoader = () => Promise<ShakaModuleLike>;
|
||||
*/
|
||||
export const loadShakaModule: ShakaModuleLoader = async () => {
|
||||
const module = (await import('shaka-player')) as unknown as
|
||||
| { default?: ShakaModuleLike }
|
||||
| ShakaModuleLike;
|
||||
return (
|
||||
((module as { default?: ShakaModuleLike }).default ??
|
||||
module) as ShakaModuleLike
|
||||
);
|
||||
{ default?: ShakaModuleLike } | ShakaModuleLike;
|
||||
return ((module as { default?: ShakaModuleLike }).default ??
|
||||
module) as ShakaModuleLike;
|
||||
};
|
||||
@@ -0,0 +1,328 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import * as shakaDiagnostics from './shaka-error-classifier';
|
||||
|
||||
interface ShakaErrorInput {
|
||||
readonly severity?: unknown;
|
||||
readonly category?: unknown;
|
||||
readonly code?: unknown;
|
||||
readonly message?: unknown;
|
||||
readonly data?: readonly unknown[];
|
||||
readonly [key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ExpectedShakaPlaybackEvidence {
|
||||
readonly severity: string;
|
||||
readonly category: string;
|
||||
readonly engineCode: number | string;
|
||||
readonly disposition: string;
|
||||
readonly stage: string;
|
||||
readonly failure: string;
|
||||
readonly httpStatus?: number;
|
||||
}
|
||||
|
||||
interface InstalledShakaContract {
|
||||
readonly version: string;
|
||||
readonly severity: Readonly<Record<string, number>>;
|
||||
readonly category: Readonly<Record<string, number>>;
|
||||
readonly code: Readonly<Record<string, number>>;
|
||||
}
|
||||
|
||||
interface ShakaEvidenceExports {
|
||||
readonly SHAKA_DIAGNOSTIC_VERSION?: string;
|
||||
readonly SHAKA_ERROR_SEVERITY?: Readonly<Record<string, number>>;
|
||||
readonly SHAKA_ERROR_CATEGORY?: Readonly<Record<string, number>>;
|
||||
readonly SHAKA_ERROR_CODE?: Readonly<Record<string, number>>;
|
||||
readonly createShakaPlaybackEvidence?: (
|
||||
error: ShakaErrorInput | null | undefined,
|
||||
disposition: 'terminal' | 'recoverable'
|
||||
) => ExpectedShakaPlaybackEvidence;
|
||||
}
|
||||
|
||||
const exportsUnderTest = shakaDiagnostics as ShakaEvidenceExports;
|
||||
|
||||
describe('Shaka playback evidence', () => {
|
||||
it('matches the installed public Shaka 5.2.2 error contract', () => {
|
||||
const installed = getInstalledShakaContract();
|
||||
|
||||
expect(exportsUnderTest.SHAKA_DIAGNOSTIC_VERSION).toBe(
|
||||
installed.version
|
||||
);
|
||||
expect(exportsUnderTest.SHAKA_ERROR_SEVERITY).toEqual(
|
||||
installed.severity
|
||||
);
|
||||
expect(exportsUnderTest.SHAKA_ERROR_CATEGORY).toEqual(
|
||||
installed.category
|
||||
);
|
||||
expect(exportsUnderTest.SHAKA_ERROR_CODE).toBeDefined();
|
||||
|
||||
for (const [name, value] of Object.entries(
|
||||
exportsUnderTest.SHAKA_ERROR_CODE ?? {}
|
||||
)) {
|
||||
expect(installed.code[name]).toBe(value);
|
||||
}
|
||||
});
|
||||
|
||||
it('extracts only the documented status from a real bad-status shape', () => {
|
||||
const secret = 'shaka-direct-secret';
|
||||
const evidence = createEvidence(
|
||||
{
|
||||
severity: 1,
|
||||
category: 1,
|
||||
code: 1001,
|
||||
message: `request failed at https://user:${secret}@provider.example`,
|
||||
data: [
|
||||
`https://provider.example/manifest.mpd?token=${secret}`,
|
||||
503,
|
||||
`provider response body ${secret}`,
|
||||
{ Authorization: `Bearer ${secret}` },
|
||||
0,
|
||||
`https://provider.example/final?token=${secret}`,
|
||||
],
|
||||
providerPayload: { key: secret },
|
||||
},
|
||||
'terminal'
|
||||
);
|
||||
const serialized = JSON.stringify(evidence);
|
||||
|
||||
expect(evidence).toEqual({
|
||||
severity: 'recoverable',
|
||||
category: 'network',
|
||||
engineCode: 1001,
|
||||
disposition: 'terminal',
|
||||
stage: 'unknown',
|
||||
failure: 'network',
|
||||
httpStatus: 503,
|
||||
});
|
||||
expect(serialized).not.toContain(secret);
|
||||
expect(serialized).not.toContain('provider.example');
|
||||
expect(serialized).not.toContain('Authorization');
|
||||
expect(serialized).not.toContain('response body');
|
||||
expect(Object.keys(evidence).sort()).toEqual(
|
||||
[
|
||||
'category',
|
||||
'disposition',
|
||||
'engineCode',
|
||||
'failure',
|
||||
'httpStatus',
|
||||
'severity',
|
||||
'stage',
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ code: 6007, label: 'license request' },
|
||||
{ code: 6017, label: 'server certificate request' },
|
||||
])(
|
||||
'extracts a nested bad status from the documented $label layout',
|
||||
({ code }) => {
|
||||
const secret = 'shaka-nested-secret';
|
||||
const evidence = createEvidence(
|
||||
{
|
||||
severity: 2,
|
||||
category: 6,
|
||||
code,
|
||||
data: [
|
||||
{
|
||||
severity: 1,
|
||||
category: 1,
|
||||
code: 1001,
|
||||
data: [
|
||||
`https://provider.example/license?token=${secret}`,
|
||||
403,
|
||||
`license response ${secret}`,
|
||||
{ Authorization: `Bearer ${secret}` },
|
||||
],
|
||||
},
|
||||
{
|
||||
licenseRequest: secret,
|
||||
sessionMetadata: secret,
|
||||
},
|
||||
],
|
||||
},
|
||||
'terminal'
|
||||
);
|
||||
const serialized = JSON.stringify(evidence);
|
||||
|
||||
expect(evidence).toEqual({
|
||||
severity: 'critical',
|
||||
category: 'drm',
|
||||
engineCode: code,
|
||||
disposition: 'terminal',
|
||||
stage: 'license',
|
||||
failure: 'drm',
|
||||
httpStatus: 403,
|
||||
});
|
||||
expect(serialized).not.toContain(secret);
|
||||
expect(serialized).not.toContain('provider.example');
|
||||
expect(serialized).not.toContain('Authorization');
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ status: 99, expected: undefined },
|
||||
{ status: 100, expected: 100 },
|
||||
{ status: 599, expected: 599 },
|
||||
{ status: 600, expected: undefined },
|
||||
{ status: 404.5, expected: undefined },
|
||||
{ status: '503', expected: undefined },
|
||||
])(
|
||||
'retains documented HTTP status $status only inside the protocol range',
|
||||
({ status, expected }) => {
|
||||
const evidence = createEvidence(
|
||||
{
|
||||
severity: 1,
|
||||
category: 1,
|
||||
code: 1001,
|
||||
data: ['https://provider.example/manifest.mpd', status],
|
||||
},
|
||||
'terminal'
|
||||
);
|
||||
|
||||
expect(evidence.httpStatus).toBe(expected);
|
||||
}
|
||||
);
|
||||
|
||||
it('ignores status-like data on every undocumented layout', () => {
|
||||
const evidence = createEvidence(
|
||||
{
|
||||
severity: 2,
|
||||
category: 4,
|
||||
code: 4001,
|
||||
data: [503, 504, { status: 505 }],
|
||||
},
|
||||
'terminal'
|
||||
);
|
||||
|
||||
expect(evidence.httpStatus).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'manifest parsing',
|
||||
error: { severity: 2, category: 4, code: 4001 },
|
||||
stage: 'manifest',
|
||||
failure: 'manifest',
|
||||
},
|
||||
{
|
||||
label: 'segment index parsing',
|
||||
error: { severity: 2, category: 3, code: 3004 },
|
||||
stage: 'segment',
|
||||
failure: 'media',
|
||||
},
|
||||
{
|
||||
label: 'media source operation',
|
||||
error: { severity: 2, category: 3, code: 3014 },
|
||||
stage: 'media',
|
||||
failure: 'media',
|
||||
},
|
||||
{
|
||||
label: 'license response',
|
||||
error: { severity: 2, category: 6, code: 6008 },
|
||||
stage: 'license',
|
||||
failure: 'drm',
|
||||
},
|
||||
{
|
||||
label: 'network without proven request stage',
|
||||
error: { severity: 2, category: 1, code: 1002 },
|
||||
stage: 'unknown',
|
||||
failure: 'network',
|
||||
},
|
||||
])(
|
||||
'maps exact $label evidence without reading messages',
|
||||
({ error, stage, failure }) => {
|
||||
const evidence = createEvidence(
|
||||
{
|
||||
...error,
|
||||
message:
|
||||
'CORS codec manifest license segment provider guess',
|
||||
},
|
||||
'terminal'
|
||||
);
|
||||
|
||||
expect(evidence.stage).toBe(stage);
|
||||
expect(evidence.failure).toBe(failure);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'ambiguous restrictions',
|
||||
error: { severity: 2, category: 4, code: 4012 },
|
||||
stage: 'manifest',
|
||||
},
|
||||
{
|
||||
label: 'mismatched public pair',
|
||||
error: { severity: 2, category: 1, code: 6001 },
|
||||
stage: 'unknown',
|
||||
},
|
||||
{
|
||||
label: 'mismatched media stage pair',
|
||||
error: { severity: 2, category: 1, code: 3004 },
|
||||
stage: 'unknown',
|
||||
},
|
||||
{
|
||||
label: 'unknown provider code',
|
||||
error: { severity: 3, category: 11, code: 123456 },
|
||||
stage: 'unknown',
|
||||
},
|
||||
])('keeps $label failure evidence unknown', ({ error, stage }) => {
|
||||
const evidence = createEvidence(error, 'terminal');
|
||||
|
||||
expect(evidence.failure).toBe('unknown');
|
||||
expect(evidence.stage).toBe(stage);
|
||||
});
|
||||
|
||||
it('validates severity, category, code, and explicit disposition independently', () => {
|
||||
expect(
|
||||
createEvidence(
|
||||
{ severity: 99, category: 99, code: 999999 },
|
||||
'recoverable'
|
||||
)
|
||||
).toEqual({
|
||||
severity: 'unknown',
|
||||
category: 'unknown',
|
||||
engineCode: 'unknown',
|
||||
disposition: 'recoverable',
|
||||
stage: 'unknown',
|
||||
failure: 'unknown',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createEvidence(
|
||||
error: ShakaErrorInput | null | undefined,
|
||||
disposition: 'terminal' | 'recoverable'
|
||||
): ExpectedShakaPlaybackEvidence {
|
||||
const factory = exportsUnderTest.createShakaPlaybackEvidence;
|
||||
|
||||
expect(factory).toBeDefined();
|
||||
if (!factory) {
|
||||
throw new Error('createShakaPlaybackEvidence is not exported');
|
||||
}
|
||||
return factory(error, disposition);
|
||||
}
|
||||
|
||||
function getInstalledShakaContract(): InstalledShakaContract {
|
||||
const script = `
|
||||
global.self = globalThis;
|
||||
import('shaka-player').then((module) => {
|
||||
const shaka = module.default ?? module;
|
||||
process.stdout.write(JSON.stringify({
|
||||
version: shaka.Player.version,
|
||||
severity: shaka.util.Error.Severity,
|
||||
category: shaka.util.Error.Category,
|
||||
code: shaka.util.Error.Code,
|
||||
}));
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
`;
|
||||
return JSON.parse(
|
||||
execFileSync(process.execPath, ['-e', script], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
})
|
||||
) as InstalledShakaContract;
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import {
|
||||
type ShakaPlaybackCategory as ShakaPlaybackCategoryValue,
|
||||
type ShakaPlaybackDisposition,
|
||||
type ShakaPlaybackEvidence,
|
||||
type ShakaPlaybackFailure as ShakaPlaybackFailureValue,
|
||||
type ShakaPlaybackStage as ShakaPlaybackStageValue,
|
||||
ShakaPlaybackCategory,
|
||||
ShakaPlaybackFailure,
|
||||
ShakaPlaybackSeverity,
|
||||
ShakaPlaybackStage,
|
||||
ShakaPlaybackUnknownCode,
|
||||
} from '../playback-diagnostics/playback-diagnostics.model';
|
||||
import {
|
||||
SHAKA_ERROR_CATEGORY,
|
||||
SHAKA_ERROR_CODE,
|
||||
SHAKA_ERROR_SEVERITY,
|
||||
} from './shaka-error-contract';
|
||||
import {
|
||||
SHAKA_AMBIGUOUS_MANIFEST_CODES,
|
||||
SHAKA_DRM_CODES,
|
||||
SHAKA_LICENSE_STAGE_CODES,
|
||||
SHAKA_MANIFEST_CODES,
|
||||
SHAKA_MANIFEST_DRM_CODES,
|
||||
SHAKA_MANIFEST_MEDIA_CODES,
|
||||
SHAKA_MEDIA_CODES,
|
||||
SHAKA_MEDIA_STAGE_CODES,
|
||||
SHAKA_NESTED_HTTP_CODES,
|
||||
SHAKA_NETWORK_CODES,
|
||||
SHAKA_PUBLIC_CODES,
|
||||
SHAKA_SEGMENT_STAGE_CODES,
|
||||
} from './shaka-error-mapping';
|
||||
import type { ShakaErrorLike } from './shaka-module.types';
|
||||
|
||||
export function createShakaPlaybackEvidence(
|
||||
error: Partial<ShakaErrorLike> | null | undefined,
|
||||
disposition: ShakaPlaybackDisposition
|
||||
): ShakaPlaybackEvidence {
|
||||
const category = getCategory(error?.category);
|
||||
const engineCode = getEngineCode(error?.code);
|
||||
const httpStatus = getHttpStatus(error);
|
||||
const evidence: ShakaPlaybackEvidence = {
|
||||
severity: getSeverity(error?.severity),
|
||||
category,
|
||||
engineCode,
|
||||
disposition,
|
||||
stage: getStage(category, engineCode),
|
||||
failure: getFailure(category, engineCode),
|
||||
};
|
||||
|
||||
return httpStatus === undefined ? evidence : { ...evidence, httpStatus };
|
||||
}
|
||||
|
||||
function getSeverity(value: unknown): ShakaPlaybackEvidence['severity'] {
|
||||
if (value === SHAKA_ERROR_SEVERITY.RECOVERABLE) {
|
||||
return ShakaPlaybackSeverity.Recoverable;
|
||||
}
|
||||
if (value === SHAKA_ERROR_SEVERITY.CRITICAL) {
|
||||
return ShakaPlaybackSeverity.Critical;
|
||||
}
|
||||
return ShakaPlaybackSeverity.Unknown;
|
||||
}
|
||||
|
||||
function getCategory(value: unknown): ShakaPlaybackCategoryValue {
|
||||
switch (value) {
|
||||
case SHAKA_ERROR_CATEGORY.NETWORK:
|
||||
return ShakaPlaybackCategory.Network;
|
||||
case SHAKA_ERROR_CATEGORY.TEXT:
|
||||
return ShakaPlaybackCategory.Text;
|
||||
case SHAKA_ERROR_CATEGORY.MEDIA:
|
||||
return ShakaPlaybackCategory.Media;
|
||||
case SHAKA_ERROR_CATEGORY.MANIFEST:
|
||||
return ShakaPlaybackCategory.Manifest;
|
||||
case SHAKA_ERROR_CATEGORY.STREAMING:
|
||||
return ShakaPlaybackCategory.Streaming;
|
||||
case SHAKA_ERROR_CATEGORY.DRM:
|
||||
return ShakaPlaybackCategory.Drm;
|
||||
case SHAKA_ERROR_CATEGORY.PLAYER:
|
||||
return ShakaPlaybackCategory.Player;
|
||||
case SHAKA_ERROR_CATEGORY.CAST:
|
||||
return ShakaPlaybackCategory.Cast;
|
||||
case SHAKA_ERROR_CATEGORY.STORAGE:
|
||||
return ShakaPlaybackCategory.Storage;
|
||||
case SHAKA_ERROR_CATEGORY.ADS:
|
||||
return ShakaPlaybackCategory.Ads;
|
||||
default:
|
||||
return ShakaPlaybackCategory.Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
function getEngineCode(value: unknown): ShakaPlaybackEvidence['engineCode'] {
|
||||
return typeof value === 'number' &&
|
||||
Number.isInteger(value) &&
|
||||
SHAKA_PUBLIC_CODES.has(value)
|
||||
? value
|
||||
: ShakaPlaybackUnknownCode;
|
||||
}
|
||||
|
||||
function getFailure(
|
||||
category: ShakaPlaybackCategoryValue,
|
||||
engineCode: ShakaPlaybackEvidence['engineCode']
|
||||
): ShakaPlaybackFailureValue {
|
||||
if (
|
||||
category === ShakaPlaybackCategory.Network &&
|
||||
typeof engineCode === 'number' &&
|
||||
SHAKA_NETWORK_CODES.has(engineCode)
|
||||
) {
|
||||
return ShakaPlaybackFailure.Network;
|
||||
}
|
||||
if (
|
||||
category === ShakaPlaybackCategory.Drm &&
|
||||
typeof engineCode === 'number' &&
|
||||
SHAKA_DRM_CODES.has(engineCode)
|
||||
) {
|
||||
return ShakaPlaybackFailure.Drm;
|
||||
}
|
||||
if (
|
||||
category === ShakaPlaybackCategory.Media &&
|
||||
typeof engineCode === 'number' &&
|
||||
SHAKA_MEDIA_CODES.has(engineCode)
|
||||
) {
|
||||
return ShakaPlaybackFailure.Media;
|
||||
}
|
||||
if (
|
||||
category === ShakaPlaybackCategory.Manifest &&
|
||||
typeof engineCode === 'number'
|
||||
) {
|
||||
if (SHAKA_MANIFEST_DRM_CODES.has(engineCode)) {
|
||||
return ShakaPlaybackFailure.Drm;
|
||||
}
|
||||
if (SHAKA_MANIFEST_MEDIA_CODES.has(engineCode)) {
|
||||
return ShakaPlaybackFailure.Media;
|
||||
}
|
||||
if (
|
||||
SHAKA_MANIFEST_CODES.has(engineCode) &&
|
||||
!SHAKA_AMBIGUOUS_MANIFEST_CODES.has(engineCode)
|
||||
) {
|
||||
return ShakaPlaybackFailure.Manifest;
|
||||
}
|
||||
}
|
||||
return ShakaPlaybackFailure.Unknown;
|
||||
}
|
||||
|
||||
function getStage(
|
||||
category: ShakaPlaybackCategoryValue,
|
||||
engineCode: ShakaPlaybackEvidence['engineCode']
|
||||
): ShakaPlaybackStageValue {
|
||||
if (typeof engineCode !== 'number') {
|
||||
return ShakaPlaybackStage.Unknown;
|
||||
}
|
||||
if (
|
||||
category === ShakaPlaybackCategory.Manifest &&
|
||||
SHAKA_MANIFEST_CODES.has(engineCode)
|
||||
) {
|
||||
return ShakaPlaybackStage.Manifest;
|
||||
}
|
||||
if (
|
||||
(category === ShakaPlaybackCategory.Network &&
|
||||
engineCode === SHAKA_ERROR_CODE.SEGMENT_MISSING) ||
|
||||
(category === ShakaPlaybackCategory.Media &&
|
||||
SHAKA_SEGMENT_STAGE_CODES.has(engineCode))
|
||||
) {
|
||||
return ShakaPlaybackStage.Segment;
|
||||
}
|
||||
if (
|
||||
category === ShakaPlaybackCategory.Media &&
|
||||
SHAKA_MEDIA_STAGE_CODES.has(engineCode)
|
||||
) {
|
||||
return ShakaPlaybackStage.Media;
|
||||
}
|
||||
if (
|
||||
category === ShakaPlaybackCategory.Drm &&
|
||||
SHAKA_LICENSE_STAGE_CODES.has(engineCode)
|
||||
) {
|
||||
return ShakaPlaybackStage.License;
|
||||
}
|
||||
return ShakaPlaybackStage.Unknown;
|
||||
}
|
||||
|
||||
function getHttpStatus(
|
||||
error: Partial<ShakaErrorLike> | null | undefined
|
||||
): number | undefined {
|
||||
if (
|
||||
error?.category === SHAKA_ERROR_CATEGORY.NETWORK &&
|
||||
error.code === SHAKA_ERROR_CODE.BAD_HTTP_STATUS
|
||||
) {
|
||||
return asHttpStatus(error.data?.[1]);
|
||||
}
|
||||
|
||||
if (
|
||||
error?.category !== SHAKA_ERROR_CATEGORY.DRM ||
|
||||
typeof error.code !== 'number' ||
|
||||
!SHAKA_NESTED_HTTP_CODES.has(error.code)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const nested = asShakaErrorData(error.data?.[0]);
|
||||
return nested?.category === SHAKA_ERROR_CATEGORY.NETWORK &&
|
||||
nested.code === SHAKA_ERROR_CODE.BAD_HTTP_STATUS
|
||||
? asHttpStatus(nested.data?.[1])
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function asShakaErrorData(value: unknown): Partial<ShakaErrorLike> | undefined {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
const candidate = value as {
|
||||
readonly severity?: unknown;
|
||||
readonly category?: unknown;
|
||||
readonly code?: unknown;
|
||||
readonly data?: unknown;
|
||||
};
|
||||
return {
|
||||
severity:
|
||||
typeof candidate.severity === 'number'
|
||||
? candidate.severity
|
||||
: undefined,
|
||||
category:
|
||||
typeof candidate.category === 'number'
|
||||
? candidate.category
|
||||
: undefined,
|
||||
code: typeof candidate.code === 'number' ? candidate.code : undefined,
|
||||
data: Array.isArray(candidate.data) ? candidate.data : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function asHttpStatus(value: unknown): number | undefined {
|
||||
return typeof value === 'number' &&
|
||||
Number.isInteger(value) &&
|
||||
value >= 100 &&
|
||||
value <= 599
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
@@ -90,7 +90,8 @@ describe('ShakaVideoSession', () => {
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0].code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
|
||||
expect(issues[0].source).toBe(PlaybackDiagnosticSource.Shaka);
|
||||
expect(issues[0].details).toContain('com.widevine.alpha');
|
||||
expect(issues[0].details).toBe('Unsupported DRM license configuration');
|
||||
expect(JSON.stringify(issues[0])).not.toContain('com.widevine.alpha');
|
||||
});
|
||||
|
||||
it('classifies critical shaka error events and ignores recoverable ones', async () => {
|
||||
@@ -107,6 +108,14 @@ describe('ShakaVideoSession', () => {
|
||||
player.dispatch('error', { severity: 2, category: 6, code: 6001 });
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0].code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
|
||||
expect(issues[0].shaka).toEqual({
|
||||
severity: 'critical',
|
||||
category: 'drm',
|
||||
engineCode: 6001,
|
||||
disposition: 'terminal',
|
||||
stage: 'unknown',
|
||||
failure: 'drm',
|
||||
});
|
||||
// Without KODIPROP config the DRM hint may still help externally.
|
||||
expect(issues[0].externalFallbackRecommended).toBe(true);
|
||||
// Critical errors end playback: the dead engine must be torn down.
|
||||
@@ -114,6 +123,25 @@ describe('ShakaVideoSession', () => {
|
||||
expect(session.getPlayer()).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores error events whose public severity does not prove terminal state', async () => {
|
||||
const environment = createFakeShakaEnvironment();
|
||||
const session = createSession(environment);
|
||||
session.start(video, 'http://example.com/a.mpd');
|
||||
await flush();
|
||||
|
||||
const player = environment.instances[0];
|
||||
player.dispatch('error', {
|
||||
severity: 99,
|
||||
category: 1,
|
||||
code: 1002,
|
||||
message: 'CORS codec license provider guess',
|
||||
});
|
||||
|
||||
expect(issues).toEqual([]);
|
||||
expect(player.destroyCount).toBe(0);
|
||||
expect(session.getPlayer()).toBe(player);
|
||||
});
|
||||
|
||||
it('drops the external-fallback hint for every failure on ClearKey channels', async () => {
|
||||
const environment = createFakeShakaEnvironment();
|
||||
const session = createSession(environment);
|
||||
@@ -168,14 +196,158 @@ describe('ShakaVideoSession', () => {
|
||||
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0].code).toBe(
|
||||
PlaybackDiagnosticCode.UnsupportedContainer
|
||||
PlaybackDiagnosticCode.UnknownPlaybackError
|
||||
);
|
||||
expect(issues[0].sourceUrl).toBe('http://example.com/bad.mpd');
|
||||
expect(issues[0].shaka).toEqual({
|
||||
severity: 'critical',
|
||||
category: 'manifest',
|
||||
engineCode: 4001,
|
||||
disposition: 'terminal',
|
||||
stage: 'manifest',
|
||||
failure: 'manifest',
|
||||
});
|
||||
// The failed engine must not stay attached or exposed to controls.
|
||||
expect(environment.instances[0].destroyCount).toBe(1);
|
||||
expect(session.getPlayer()).toBeNull();
|
||||
});
|
||||
|
||||
it('treats a recoverable-severity load rejection as terminal lifecycle evidence', async () => {
|
||||
const secret = 'load-http-secret';
|
||||
const environment = createFakeShakaEnvironment({
|
||||
onCreate: (player) => {
|
||||
player.loadResult = Promise.reject({
|
||||
severity: 1,
|
||||
category: 1,
|
||||
code: 1001,
|
||||
message: `https://provider.example/?token=${secret}`,
|
||||
data: [
|
||||
`https://provider.example/manifest.mpd?token=${secret}`,
|
||||
503,
|
||||
`provider body ${secret}`,
|
||||
{ Authorization: `Bearer ${secret}` },
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
const session = createSession(environment);
|
||||
session.start(video, 'http://example.com/retry-exhausted.mpd');
|
||||
await flush();
|
||||
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
code: PlaybackDiagnosticCode.NetworkError,
|
||||
httpStatus: 503,
|
||||
shaka: {
|
||||
severity: 'recoverable',
|
||||
category: 'network',
|
||||
engineCode: 1001,
|
||||
disposition: 'terminal',
|
||||
stage: 'unknown',
|
||||
failure: 'network',
|
||||
httpStatus: 503,
|
||||
},
|
||||
})
|
||||
);
|
||||
const serialized = JSON.stringify(issues[0].shaka);
|
||||
expect(serialized).not.toContain(secret);
|
||||
expect(serialized).not.toContain('provider.example');
|
||||
expect(serialized).not.toContain('Authorization');
|
||||
expect(environment.instances[0].destroyCount).toBe(1);
|
||||
expect(session.getPlayer()).toBeNull();
|
||||
});
|
||||
|
||||
it('emits a critical in-flight error once before teardown interrupts load', async () => {
|
||||
const environment = createFakeShakaEnvironment({
|
||||
onCreate: (player) => {
|
||||
player.stallNextLoad = true;
|
||||
},
|
||||
});
|
||||
const session = createSession(environment);
|
||||
session.start(video, 'http://example.com/stalled.mpd');
|
||||
await flush();
|
||||
|
||||
const player = environment.instances[0];
|
||||
player.dispatch('error', {
|
||||
severity: 2,
|
||||
category: 6,
|
||||
code: 6008,
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0].shaka).toEqual(
|
||||
expect.objectContaining({
|
||||
engineCode: 6008,
|
||||
disposition: 'terminal',
|
||||
})
|
||||
);
|
||||
expect(player.destroyCount).toBe(1);
|
||||
expect(session.getPlayer()).toBeNull();
|
||||
});
|
||||
|
||||
it('does not suppress an arbitrary load rejection that only reuses code 7000', async () => {
|
||||
const environment = createFakeShakaEnvironment({
|
||||
onCreate: (player) => {
|
||||
player.loadResult = Promise.reject({
|
||||
severity: 2,
|
||||
category: 1,
|
||||
code: 7000,
|
||||
});
|
||||
},
|
||||
});
|
||||
const session = createSession(environment);
|
||||
session.start(video, 'http://example.com/not-interrupted.mpd');
|
||||
await flush();
|
||||
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0].code).toBe(
|
||||
PlaybackDiagnosticCode.UnknownPlaybackError
|
||||
);
|
||||
expect(issues[0].shaka).toEqual({
|
||||
severity: 'critical',
|
||||
category: 'network',
|
||||
engineCode: 7000,
|
||||
disposition: 'terminal',
|
||||
stage: 'unknown',
|
||||
failure: 'unknown',
|
||||
});
|
||||
});
|
||||
|
||||
it('drops arbitrary module-loader rejection messages', async () => {
|
||||
const secret = 'module-loader-secret';
|
||||
const session = new ShakaVideoSession({
|
||||
player: InlinePlaybackPlayer.Html5,
|
||||
emitPlaybackIssue: (issue) => issues.push(issue),
|
||||
loadShaka: () =>
|
||||
Promise.reject(
|
||||
new Error(
|
||||
`https://user:${secret}@provider.example/shaka.js`
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
session.start(video, 'http://example.com/a.mpd');
|
||||
await flush();
|
||||
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0].code).toBe(
|
||||
PlaybackDiagnosticCode.UnknownPlaybackError
|
||||
);
|
||||
expect(issues[0].details).toBeUndefined();
|
||||
expect(issues[0].shaka).toEqual({
|
||||
severity: 'unknown',
|
||||
category: 'unknown',
|
||||
engineCode: 'unknown',
|
||||
disposition: 'terminal',
|
||||
stage: 'unknown',
|
||||
failure: 'unknown',
|
||||
});
|
||||
expect(JSON.stringify(issues[0])).not.toContain(secret);
|
||||
expect(JSON.stringify(issues[0])).not.toContain('provider.example');
|
||||
});
|
||||
|
||||
it('recovers from a stalled load: stop() interrupts it and the next start proceeds', async () => {
|
||||
const environment = createFakeShakaEnvironment({
|
||||
onCreate: (player, index) => {
|
||||
@@ -274,8 +446,16 @@ describe('ShakaVideoSession', () => {
|
||||
expect(environment.instances).toHaveLength(0);
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0].code).toBe(
|
||||
PlaybackDiagnosticCode.UnsupportedContainer
|
||||
PlaybackDiagnosticCode.UnknownPlaybackError
|
||||
);
|
||||
expect(issues[0].shaka).toEqual({
|
||||
severity: 'unknown',
|
||||
category: 'unknown',
|
||||
engineCode: 'unknown',
|
||||
disposition: 'terminal',
|
||||
stage: 'unknown',
|
||||
failure: 'unknown',
|
||||
});
|
||||
});
|
||||
|
||||
it('destroy tears down the engine and blocks later starts', async () => {
|
||||
|
||||
@@ -4,18 +4,17 @@ import type {
|
||||
PlaybackDiagnostic,
|
||||
PlaybackSourceMetadata,
|
||||
} from '../playback-diagnostics/playback-diagnostics.model';
|
||||
import { PlaybackDiagnosticCode as DiagnosticCode } from '../playback-diagnostics/playback-diagnostics.model';
|
||||
import { PlaybackDiagnosticSource as DiagnosticSource } from '../playback-diagnostics/playback-diagnostics.model';
|
||||
import {
|
||||
createPlaybackDiagnostic,
|
||||
createPlaybackSourceMetadata,
|
||||
} from '../playback-diagnostics/playback-diagnostics.util';
|
||||
import { ShakaPlaybackDisposition as ShakaDisposition } from '../playback-diagnostics/playback-diagnostics.model';
|
||||
import { createPlaybackSourceMetadata } from '../playback-diagnostics/playback-diagnostics.util';
|
||||
import {
|
||||
asShakaError,
|
||||
classifyShakaPlaybackIssue,
|
||||
createUnsupportedDrmDiagnostic,
|
||||
toErrorMessage,
|
||||
} from './shaka-error-classifier';
|
||||
import {
|
||||
getShakaErrorEventDisposition,
|
||||
isShakaLoadInterrupted,
|
||||
} from './shaka-error-lifecycle';
|
||||
import { ShakaTextTrackSuppression } from './shaka-text-track-suppression';
|
||||
import {
|
||||
loadShakaModule,
|
||||
@@ -33,11 +32,6 @@ export interface ShakaVideoSessionConfig {
|
||||
}
|
||||
|
||||
const DASH_MIME_TYPE = 'application/dash+xml';
|
||||
/** `shaka.util.Error.Severity.RECOVERABLE` — Shaka retries these itself. */
|
||||
const SHAKA_SEVERITY_RECOVERABLE = 1;
|
||||
/** `shaka.util.Error.Code.LOAD_INTERRUPTED` — expected when a newer
|
||||
* `load()`/`destroy()` supersedes an in-flight one. */
|
||||
const SHAKA_LOAD_INTERRUPTED = 7000;
|
||||
|
||||
const PLAYER_REFRESH_EVENTS = [
|
||||
'loaded',
|
||||
@@ -139,14 +133,8 @@ export class ShakaVideoSession {
|
||||
let module: ShakaModuleLike;
|
||||
try {
|
||||
module = await this.loadModule();
|
||||
} catch (error: unknown) {
|
||||
this.emitIfCurrent(
|
||||
generation,
|
||||
classifyShakaPlaybackIssue(
|
||||
{ message: toErrorMessage(error) },
|
||||
this.createMetadata(url)
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
this.emitTerminalIfCurrent(generation, null, url);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -155,15 +143,7 @@ export class ShakaVideoSession {
|
||||
}
|
||||
|
||||
if (!module.Player.isBrowserSupported()) {
|
||||
this.emitIfCurrent(
|
||||
generation,
|
||||
createPlaybackDiagnostic({
|
||||
code: DiagnosticCode.UnsupportedContainer,
|
||||
source: DiagnosticSource.Shaka,
|
||||
metadata: this.createMetadata(url),
|
||||
details: 'Shaka Player is not supported in this browser',
|
||||
})
|
||||
);
|
||||
this.emitTerminalIfCurrent(generation, null, url);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -228,20 +208,21 @@ export class ShakaVideoSession {
|
||||
if (
|
||||
this.isStale(generation) ||
|
||||
this.player !== player ||
|
||||
shakaError?.code === SHAKA_LOAD_INTERRUPTED
|
||||
isShakaLoadInterrupted(shakaError)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.config.emitPlaybackIssue(
|
||||
this.withoutUnusableDrmFallback(
|
||||
classifyShakaPlaybackIssue(
|
||||
shakaError ?? { message: toErrorMessage(error) },
|
||||
this.createMetadata(url)
|
||||
),
|
||||
drmProvided
|
||||
)
|
||||
const issue = classifyShakaPlaybackIssue(
|
||||
shakaError,
|
||||
this.createMetadata(url),
|
||||
ShakaDisposition.Terminal
|
||||
);
|
||||
if (issue) {
|
||||
this.config.emitPlaybackIssue(
|
||||
this.withoutUnusableDrmFallback(issue, drmProvided)
|
||||
);
|
||||
}
|
||||
// Never leave a non-functional engine attached to the media element
|
||||
// or exposed to the shared-controls bridge.
|
||||
this.beginPlayerTeardown();
|
||||
@@ -274,20 +255,24 @@ export class ShakaVideoSession {
|
||||
return;
|
||||
}
|
||||
|
||||
const detail = (event as { detail?: Partial<ShakaErrorLike> })
|
||||
.detail;
|
||||
if (detail?.severity === SHAKA_SEVERITY_RECOVERABLE) {
|
||||
const detail = (event as { detail?: unknown }).detail;
|
||||
const shakaError = asShakaError(detail);
|
||||
const disposition = getShakaErrorEventDisposition(shakaError);
|
||||
if (!disposition) {
|
||||
return;
|
||||
}
|
||||
|
||||
const issue = classifyShakaPlaybackIssue(
|
||||
shakaError,
|
||||
this.createMetadata(url),
|
||||
disposition
|
||||
);
|
||||
if (!issue) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.config.emitPlaybackIssue(
|
||||
this.withoutUnusableDrmFallback(
|
||||
classifyShakaPlaybackIssue(
|
||||
detail,
|
||||
this.createMetadata(url)
|
||||
),
|
||||
drmProvided
|
||||
)
|
||||
this.withoutUnusableDrmFallback(issue, drmProvided)
|
||||
);
|
||||
// Critical errors end playback; never leave the dead engine
|
||||
// attached or exposed to the shared-controls bridge.
|
||||
@@ -371,15 +356,27 @@ export class ShakaVideoSession {
|
||||
return this.destroyed || generation !== this.generation;
|
||||
}
|
||||
|
||||
private emitIfCurrent(
|
||||
generation: number,
|
||||
issue: PlaybackDiagnostic
|
||||
): void {
|
||||
private emitIfCurrent(generation: number, issue: PlaybackDiagnostic): void {
|
||||
if (!this.isStale(generation)) {
|
||||
this.config.emitPlaybackIssue(issue);
|
||||
}
|
||||
}
|
||||
|
||||
private emitTerminalIfCurrent(
|
||||
generation: number,
|
||||
error: Partial<ShakaErrorLike> | null,
|
||||
url: string
|
||||
): void {
|
||||
const issue = classifyShakaPlaybackIssue(
|
||||
error,
|
||||
this.createMetadata(url),
|
||||
ShakaDisposition.Terminal
|
||||
);
|
||||
if (issue) {
|
||||
this.emitIfCurrent(generation, issue);
|
||||
}
|
||||
}
|
||||
|
||||
private notifyRefresh(): void {
|
||||
for (const listener of [...this.refreshListeners]) {
|
||||
listener();
|
||||
|
||||
@@ -82,7 +82,10 @@ export function getDiagnosticDetails(
|
||||
},
|
||||
{
|
||||
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_NATIVE_ERROR_MESSAGE',
|
||||
value: issue.vhs ? '' : (issue.nativeErrorMessage ?? ''),
|
||||
value:
|
||||
issue.vhs || issue.shaka
|
||||
? ''
|
||||
: (issue.nativeErrorMessage ?? ''),
|
||||
},
|
||||
{
|
||||
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_ERROR_DETAILS',
|
||||
@@ -92,6 +95,22 @@ export function getDiagnosticDetails(
|
||||
}
|
||||
|
||||
function formatDiagnosticErrorDetails(issue: PlaybackDiagnostic): string {
|
||||
if (issue.shaka) {
|
||||
return [
|
||||
`stage=${issue.shaka.stage}`,
|
||||
`failure=${issue.shaka.failure}`,
|
||||
`severity=${issue.shaka.severity}`,
|
||||
`category=${issue.shaka.category}`,
|
||||
`code=${issue.shaka.engineCode}`,
|
||||
`disposition=${issue.shaka.disposition}`,
|
||||
issue.shaka.httpStatus === undefined
|
||||
? ''
|
||||
: `HTTP ${issue.shaka.httpStatus}`,
|
||||
]
|
||||
.filter((value) => value.length > 0)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
if (issue.vhs) {
|
||||
return [
|
||||
`stage=${issue.vhs.stage}`,
|
||||
|
||||
@@ -371,6 +371,36 @@ describe('WebPlayerViewComponent', () => {
|
||||
expect(renderedDetails).not.toContain('response body');
|
||||
});
|
||||
|
||||
it('renders only sanitized structured Shaka evidence in technical details', () => {
|
||||
const issue = createStructuredShakaDiagnostic();
|
||||
|
||||
component.handlePlaybackIssue(issue);
|
||||
fixture.detectChanges();
|
||||
|
||||
const details = component.getDiagnosticDetails(issue);
|
||||
const renderedDetails = details.map(({ value }) => value).join(' ');
|
||||
|
||||
expect(details).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_SOURCE',
|
||||
value: 'Shaka Player',
|
||||
},
|
||||
{
|
||||
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_ERROR_DETAILS',
|
||||
value:
|
||||
'stage=unknown · failure=network · ' +
|
||||
'severity=recoverable · category=network · ' +
|
||||
'code=1001 · disposition=terminal · HTTP 503',
|
||||
},
|
||||
])
|
||||
);
|
||||
expect(renderedDetails).not.toContain('shaka-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';
|
||||
@@ -980,3 +1010,31 @@ function createStructuredVhsDiagnostic(): PlaybackDiagnostic {
|
||||
externalFallbackRecommended: false,
|
||||
};
|
||||
}
|
||||
|
||||
function createStructuredShakaDiagnostic(): PlaybackDiagnostic {
|
||||
return {
|
||||
code: PlaybackDiagnosticCode.NetworkError,
|
||||
source: PlaybackDiagnosticSource.Shaka,
|
||||
sourceUrl:
|
||||
'https://provider.example/live.mpd?token=shaka-render-secret',
|
||||
container: 'mpd',
|
||||
mimeType: 'application/dash+xml',
|
||||
player: 'artplayer',
|
||||
audioCodecs: [],
|
||||
videoCodecs: [],
|
||||
details: 'Authorization response body shaka-render-secret',
|
||||
nativeErrorMessage:
|
||||
'https://provider.example/error?token=shaka-render-secret',
|
||||
httpStatus: 503,
|
||||
shaka: {
|
||||
severity: 'recoverable',
|
||||
category: 'network',
|
||||
engineCode: 1001,
|
||||
disposition: 'terminal',
|
||||
stage: 'unknown',
|
||||
failure: 'network',
|
||||
httpStatus: 503,
|
||||
},
|
||||
externalFallbackRecommended: false,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user