mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 05:36:17 -08:00
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:
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user