feat(playback): recommend recovery actions (#1374)

* docs(playback): design recovery recommendations

* docs(playback): plan recovery recommendations

* refactor(playback): extract diagnostic utilities

* feat(playback): define recovery recommendation contracts

* feat(playback): rank recovery recommendations

* feat(playback): track session recovery attempts

* feat(playback): identify content recovery sessions

* feat(ui): add ranked playback diagnostic panel

* feat(playback): switch temporarily to recommended players

* test(playback): cover temporary player recommendation

* test(playback): verify recommendation capability guards

* docs(playback): document recovery recommendations

* fix(playback): keep recovery keys credential-free

* fix(playback): remove derived tracking ownership

* fix(playback): preserve distinct recovery fallbacks

* fix(playback): reset resume for new sources

* fix(playback): preserve desktop recovery guidance

* docs(playback): clarify recovery policy exceptions

* fix(playback): reject stale progress updates

* fix(playback): keep protected recovery guidance neutral

* test(playback): cover stale progress output

* fix(playback): neutralize protected diagnostic copy

* fix(playback): harden runtime guidance ownership

* fix(playback): stabilize recovery application ownership

* fix(ci): classify playback util coverage

* fix(e2e): preserve playback fixture bytes
This commit is contained in:
4gray authored and GitHub committed 2026-08-08 01:04:39 +02:00
1 parent 5e4f2ca3dd
commit fd96b85c19
176 files changed
+15228 -1908

No files matched your search

@@ -1,223 +0,0 @@
import {
InlinePlaybackPlayer,
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
} from '../playback-diagnostics/playback-diagnostics.model';
import { createPlaybackSourceMetadata } from '../playback-diagnostics/playback-diagnostics.util';
import {
classifyShakaPlaybackIssue,
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/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([
[
'manifest parsing',
{ severity: 2, category: 4, code: 4001 },
'manifest',
],
[
'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'
);
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).toBeNull();
});
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',
},
})
);
});
});
describe('createUnsupportedDrmDiagnostic', () => {
it('creates a safe DRM diagnostic without echoing provider license data', () => {
const secret = 'unsupported-drm-secret';
const issue = createUnsupportedDrmDiagnostic(
`https://provider.example/license?token=${secret}`,
metadata
);
expect(issue.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
expect(issue.source).toBe(PlaybackDiagnosticSource.Shaka);
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,109 +0,0 @@
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 { SHAKA_ERROR_CODE } from './shaka-error-contract';
import { createShakaPlaybackEvidence } from './shaka-playback-evidence.util';
import type { ShakaErrorLike } from './shaka-module.types';
export {
SHAKA_DIAGNOSTIC_VERSION,
SHAKA_ERROR_CATEGORY,
SHAKA_ERROR_CODE,
SHAKA_ERROR_SEVERITY,
} from './shaka-error-contract';
export { createShakaPlaybackEvidence } from './shaka-playback-evidence.util';
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,
disposition: ShakaPlaybackDisposition
): PlaybackDiagnostic | null {
const evidence = createShakaPlaybackEvidence(error, disposition);
if (evidence.disposition === ShakaDisposition.Recoverable) {
return null;
}
return createPlaybackDiagnostic({
code: getDiagnosticCode(evidence),
source: DiagnosticSource.Shaka,
metadata,
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. Provider-supplied license strings are intentionally not retained.
*/
export function createUnsupportedDrmDiagnostic(
_licenseType: string,
metadata: PlaybackSourceMetadata
): PlaybackDiagnostic {
return createPlaybackDiagnostic({
code: DiagnosticCode.DrmOrEncryption,
source: DiagnosticSource.Shaka,
metadata,
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,
});
}
/** Narrows an unknown rejection to a Shaka-error-like shape, if it is one. */
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.severity === 'number'
? candidate
: null;
}
@@ -1,125 +0,0 @@
/**
* 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,
// Text parsing reachable from DASH playback. HLS-only and app-uninvoked
// external-track/src= codes stay outside this boundary.
INVALID_TEXT_HEADER: 2000,
INVALID_TEXT_CUE: 2001,
UNABLE_TO_DETECT_ENCODING: 2003,
BAD_ENCODING: 2004,
INVALID_XML: 2005,
INVALID_MP4_TTML: 2007,
INVALID_MP4_VTT: 2008,
INVALID_MP4_CEA: 2010,
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,
STREAMING_ENGINE_STARTUP_INVALID_STATE: 5006,
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;
@@ -1,29 +0,0 @@
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
);
}
@@ -1,121 +0,0 @@
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,
]);
@@ -22,12 +22,7 @@ export interface ShakaTextTrackLike {
kind: string | null;
}
export interface ShakaErrorLike {
severity: number;
category: number;
code: number;
data?: readonly unknown[];
}
export type { ShakaErrorLike } from '@iptvnator/playback/util';
export interface ShakaPlayerLike {
attach(mediaElement: HTMLMediaElement): Promise<unknown>;
@@ -1,376 +0,0 @@
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('preserves the exact public streaming startup code as unknown failure evidence', () => {
expect(
createEvidence({ severity: 2, category: 5, code: 5006 }, 'terminal')
).toEqual({
severity: 'critical',
category: 'streaming',
engineCode: 5006,
disposition: 'terminal',
stage: 'unknown',
failure: 'unknown',
});
});
it.each([
['INVALID_TEXT_HEADER', 2000],
['INVALID_TEXT_CUE', 2001],
['UNABLE_TO_DETECT_ENCODING', 2003],
['BAD_ENCODING', 2004],
['INVALID_XML', 2005],
['INVALID_MP4_TTML', 2007],
['INVALID_MP4_VTT', 2008],
['INVALID_MP4_CEA', 2010],
])('preserves the exact public DASH text code %s', (_name, code) => {
expect(
createEvidence({ severity: 2, category: 2, code }, 'terminal')
).toEqual({
severity: 'critical',
category: 'text',
engineCode: code,
disposition: 'terminal',
stage: 'unknown',
failure: 'unknown',
});
});
it('rejects public text codes from app-uninvoked explicit-track APIs', () => {
expect(
createEvidence({ severity: 2, category: 2, code: 2014 }, 'terminal')
).toEqual({
severity: 'critical',
category: 'text',
engineCode: 'unknown',
disposition: 'terminal',
stage: 'unknown',
failure: 'unknown',
});
});
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;
}
@@ -1,235 +0,0 @@
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;
}
@@ -4,7 +4,7 @@ import {
PlaybackDiagnostic,
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
} from '../playback-diagnostics/playback-diagnostics.model';
} from '@iptvnator/playback/util';
import {
FakeShakaPlayer,
createFakeShakaEnvironment,
@@ -116,8 +116,6 @@ describe('ShakaVideoSession', () => {
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.
expect(player.destroyCount).toBe(1);
expect(session.getPlayer()).toBeNull();
@@ -142,7 +140,7 @@ describe('ShakaVideoSession', () => {
expect(session.getPlayer()).toBe(player);
});
it('drops the external-fallback hint for every failure on ClearKey channels', async () => {
it('emits structured failures without owning ClearKey recovery policy', async () => {
const environment = createFakeShakaEnvironment();
const session = createSession(environment);
session.start(video, 'http://example.com/enc.mpd', {
@@ -160,12 +158,6 @@ describe('ShakaVideoSession', () => {
expect(issues).toHaveLength(1);
expect(issues[0].code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
// MPV/VLC never receive the KODIPROP license config, so they are not
// offered as a fallback for key failures on ClearKey channels.
expect(issues[0].externalFallbackRecommended).toBe(false);
// Non-DRM failures (media/codec/manifest/…) are equally unsolvable
// externally while the stream itself stays encrypted.
session.start(video, 'http://example.com/enc.mpd', {
licenseType: 'clearkey',
supported: true,
@@ -180,7 +172,6 @@ describe('ShakaVideoSession', () => {
expect(issues).toHaveLength(2);
expect(issues[1].code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
expect(issues[1].externalFallbackRecommended).toBe(false);
});
it('emits a diagnostic and tears the engine down when load rejects', async () => {
@@ -407,6 +398,7 @@ describe('ShakaVideoSession', () => {
});
expect(JSON.stringify(issues[0])).not.toContain(secret);
expect(JSON.stringify(issues[0])).not.toContain('provider.example');
expect(issues[0].runtimeSupport).toBeUndefined();
});
it('recovers from a stalled load: stop() interrupts it and the next start proceeds', async () => {
@@ -497,7 +489,7 @@ describe('ShakaVideoSession', () => {
expect(player.selectTextTrackCalls).toHaveLength(2);
});
it('keeps external fallback available when the browser cannot run Shaka for clear DASH', async () => {
it('emits an unknown diagnostic when the browser cannot run Shaka for clear DASH', async () => {
const environment = createFakeShakaEnvironment();
environment.browserSupported = false;
const session = createSession(environment);
@@ -517,10 +509,13 @@ describe('ShakaVideoSession', () => {
stage: 'unknown',
failure: 'unknown',
});
expect(issues[0].externalFallbackRecommended).toBe(true);
expect(issues[0]).toHaveProperty(
'runtimeSupport',
'shaka-browser-unsupported'
);
});
it('keeps external fallback unavailable when browser support fails for KODIPROP DRM', async () => {
it('emits the same unknown diagnostic when browser support fails with DRM', async () => {
const environment = createFakeShakaEnvironment();
environment.browserSupported = false;
const session = createSession(environment);
@@ -536,7 +531,10 @@ describe('ShakaVideoSession', () => {
expect(issues[0].code).toBe(
PlaybackDiagnosticCode.UnknownPlaybackError
);
expect(issues[0].externalFallbackRecommended).toBe(false);
expect(issues[0]).toHaveProperty(
'runtimeSupport',
'shaka-browser-unsupported'
);
});
it('destroy tears down the engine and blocks later starts', async () => {
@@ -1,20 +1,17 @@
import type { ChannelDrm } from '@iptvnator/shared/interfaces';
import type {
InlinePlaybackPlayer,
PlaybackDiagnostic,
PlaybackSourceMetadata,
} from '../playback-diagnostics/playback-diagnostics.model';
import { ShakaPlaybackDisposition as ShakaDisposition } from '../playback-diagnostics/playback-diagnostics.model';
import { createPlaybackSourceMetadata } from '../playback-diagnostics/playback-diagnostics.util';
import {
ShakaPlaybackDisposition as ShakaDisposition,
asShakaError,
classifyShakaPlaybackIssue,
createPlaybackSourceMetadata,
createShakaBrowserUnsupportedDiagnostic,
createUnsupportedDrmDiagnostic,
} from './shaka-error-classifier';
import {
getShakaErrorEventDisposition,
isShakaLoadInterrupted,
} from './shaka-error-lifecycle';
type InlinePlaybackPlayer,
type PlaybackDiagnostic,
type PlaybackSourceMetadata,
} from '@iptvnator/playback/util';
import { ShakaTextTrackSuppression } from './shaka-text-track-suppression';
import {
loadShakaModule,
@@ -143,18 +140,18 @@ export class ShakaVideoSession {
}
if (!module.Player.isBrowserSupported()) {
this.emitTerminalIfCurrent(
this.emitIfCurrent(
generation,
null,
url,
drm === undefined
createShakaBrowserUnsupportedDiagnostic(
this.createMetadata(url)
)
);
return;
}
const player = new module.Player();
this.player = player;
this.bindPlayerListeners(player, generation, url, drm !== undefined);
this.bindPlayerListeners(player, generation, url);
if (drm?.clearKeys) {
player.configure({ drm: { clearKeys: drm.clearKeys } });
@@ -164,13 +161,7 @@ export class ShakaVideoSession {
await player.attach(video);
await player.load(url);
} catch (error: unknown) {
this.handleLoadFailure(
generation,
player,
url,
error,
drm !== undefined
);
this.handleLoadFailure(generation, player, url, error);
return;
}
@@ -206,8 +197,7 @@ export class ShakaVideoSession {
generation: number,
player: ShakaPlayerLike,
url: string,
error: unknown,
drmProvided: boolean
error: unknown
): void {
const shakaError = asShakaError(error);
if (
@@ -224,36 +214,17 @@ export class ShakaVideoSession {
ShakaDisposition.Terminal
);
if (issue) {
this.config.emitPlaybackIssue(
this.withoutUnusableDrmFallback(issue, drmProvided)
);
this.config.emitPlaybackIssue(issue);
}
// Never leave a non-functional engine attached to the media element
// or exposed to the shared-controls bridge.
this.beginPlayerTeardown();
}
/**
* Channels carrying KODIPROP ClearKey config cannot be handed to MPV/VLC
* at all — external players never receive the license config, so the
* encrypted stream fails there regardless of what broke inline (DRM,
* manifest, codec, network, …). Suppress the fallback hint entirely.
*/
private withoutUnusableDrmFallback(
issue: PlaybackDiagnostic,
drmProvided: boolean
): PlaybackDiagnostic {
if (!drmProvided || !issue.externalFallbackRecommended) {
return issue;
}
return { ...issue, externalFallbackRecommended: false };
}
private bindPlayerListeners(
player: ShakaPlayerLike,
generation: number,
url: string,
drmProvided: boolean
url: string
): void {
const errorListener = (event: Event): void => {
if (this.isStale(generation) || this.player !== player) {
@@ -276,9 +247,7 @@ export class ShakaVideoSession {
return;
}
this.config.emitPlaybackIssue(
this.withoutUnusableDrmFallback(issue, drmProvided)
);
this.config.emitPlaybackIssue(issue);
// Critical errors end playback; never leave the dead engine
// attached or exposed to the shared-controls bridge.
this.beginPlayerTeardown();
@@ -370,8 +339,7 @@ export class ShakaVideoSession {
private emitTerminalIfCurrent(
generation: number,
error: Partial<ShakaErrorLike> | null,
url: string,
externalFallbackRecommended?: boolean
url: string
): void {
const issue = classifyShakaPlaybackIssue(
error,
@@ -379,12 +347,7 @@ export class ShakaVideoSession {
ShakaDisposition.Terminal
);
if (issue) {
this.emitIfCurrent(
generation,
externalFallbackRecommended === undefined
? issue
: { ...issue, externalFallbackRecommended }
);
this.emitIfCurrent(generation, issue);
}
}