fix(playback): structure Shaka diagnostics

This commit is contained in:
4gray committed 2026-07-31 09:57:17 +02:00
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,
};
}