From 2668fe0dd546ecf7387c08b07406fa4bf2ed8ba0 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 3 Aug 2026 19:00:10 +0200 Subject: [PATCH] refactor(playback): extract diagnostic utilities --- AGENTS.md | 12 +- CLAUDE.md | 10 +- docs/architecture/m3u-playlist-module.md | 5 +- docs/architecture/nx-workspace-boundaries.md | 5 + libs/playback/util/project.json | 28 ++ libs/playback/util/src/index.ts | 5 + .../hls-playback-evidence.util.spec.ts | 2 +- .../hls-playback-evidence.util.ts | 0 .../mpegts-playback-evidence.model.ts | 0 .../mpegts-playback-evidence.util.spec.ts | 0 .../mpegts-playback-evidence.util.ts | 0 .../playback-diagnostics.model.ts | 0 .../playback-diagnostics.util.spec.ts | 33 ++ .../diagnostics}/playback-diagnostics.util.ts | 19 +- .../playback-error-patterns.util.ts | 0 .../playback-media-source.util.ts | 0 .../shaka-error-classifier.spec.ts | 4 +- .../diagnostics}/shaka-error-classifier.ts | 8 +- .../lib/diagnostics}/shaka-error-contract.ts | 0 .../lib/diagnostics}/shaka-error-lifecycle.ts | 4 +- .../lib/diagnostics}/shaka-error-mapping.ts | 0 .../src/lib/diagnostics/shaka-error.types.ts | 6 + .../shaka-playback-evidence.util.spec.ts | 0 .../shaka-playback-evidence.util.ts | 4 +- .../vhs-playback-evidence.util.spec.ts | 0 .../vhs-playback-evidence.util.ts | 0 .../src/lib/playback-util-boundary.spec.ts | 347 ++++++++++++++++++ libs/playback/util/tsconfig.json | 25 ++ libs/playback/util/tsconfig.lib.json | 12 + libs/playback/util/tsconfig.spec.json | 12 + libs/ui/playback/src/index.ts | 2 +- .../src/lib/art-player/art-player-setup.ts | 2 +- .../art-player-source-session.dash.spec.ts | 2 +- ...art-player-source-session.spec-fixtures.ts | 2 +- .../art-player-source-session.spec.ts | 88 ++++- .../art-player/art-player-source-session.ts | 8 +- .../art-player/art-player-video-session.ts | 2 +- .../lib/art-player/art-player.component.ts | 2 +- .../html-video-element-session.ts | 2 +- .../html-video-player-diagnostics.ts | 8 +- .../html-video-player.component.spec.ts | 95 ++++- .../html-video-player.component.ts | 2 +- .../portal-inline-player.component.ts | 4 +- .../lib/shaka-engine/shaka-module.types.ts | 7 +- .../shaka-engine/shaka-video-session.spec.ts | 2 +- .../lib/shaka-engine/shaka-video-session.ts | 16 +- .../src/lib/vjs-player/vjs-mpegts-session.ts | 2 +- .../vjs-player/vjs-player.component.spec.ts | 4 +- .../lib/vjs-player/vjs-player.component.ts | 2 +- .../src/lib/vjs-player/vjs-player.types.ts | 2 +- .../lib/vod-details/vod-details.component.ts | 2 +- .../web-player-view-diagnostics.utils.ts | 2 +- ...yer-view.component.shared-controls.spec.ts | 2 +- .../web-player-view.component.spec.ts | 2 +- .../web-player-view.component.ts | 2 +- .../web-player-view.spec-stubs.ts | 2 +- .../browser-media-type-support.spec.ts | 71 ++++ .../browser-media-type-support.ts | 16 + .../web-video-shaka-controls.spec.ts | 2 +- tsconfig.base.json | 1 + 60 files changed, 820 insertions(+), 77 deletions(-) create mode 100644 libs/playback/util/project.json create mode 100644 libs/playback/util/src/index.ts rename libs/{ui/playback/src/lib/playback-diagnostics => playback/util/src/lib/diagnostics}/hls-playback-evidence.util.spec.ts (99%) rename libs/{ui/playback/src/lib/playback-diagnostics => playback/util/src/lib/diagnostics}/hls-playback-evidence.util.ts (100%) rename libs/{ui/playback/src/lib/playback-diagnostics => playback/util/src/lib/diagnostics}/mpegts-playback-evidence.model.ts (100%) rename libs/{ui/playback/src/lib/playback-diagnostics => playback/util/src/lib/diagnostics}/mpegts-playback-evidence.util.spec.ts (100%) rename libs/{ui/playback/src/lib/playback-diagnostics => playback/util/src/lib/diagnostics}/mpegts-playback-evidence.util.ts (100%) rename libs/{ui/playback/src/lib/playback-diagnostics => playback/util/src/lib/diagnostics}/playback-diagnostics.model.ts (100%) rename libs/{ui/playback/src/lib/playback-diagnostics => playback/util/src/lib/diagnostics}/playback-diagnostics.util.spec.ts (96%) rename libs/{ui/playback/src/lib/playback-diagnostics => playback/util/src/lib/diagnostics}/playback-diagnostics.util.ts (95%) rename libs/{ui/playback/src/lib/playback-diagnostics => playback/util/src/lib/diagnostics}/playback-error-patterns.util.ts (100%) rename libs/{ui/playback/src/lib/playback-diagnostics => playback/util/src/lib/diagnostics}/playback-media-source.util.ts (100%) rename libs/{ui/playback/src/lib/shaka-engine => playback/util/src/lib/diagnostics}/shaka-error-classifier.spec.ts (97%) rename libs/{ui/playback/src/lib/shaka-engine => playback/util/src/lib/diagnostics}/shaka-error-classifier.ts (92%) rename libs/{ui/playback/src/lib/shaka-engine => playback/util/src/lib/diagnostics}/shaka-error-contract.ts (100%) rename libs/{ui/playback/src/lib/shaka-engine => playback/util/src/lib/diagnostics}/shaka-error-lifecycle.ts (82%) rename libs/{ui/playback/src/lib/shaka-engine => playback/util/src/lib/diagnostics}/shaka-error-mapping.ts (100%) create mode 100644 libs/playback/util/src/lib/diagnostics/shaka-error.types.ts rename libs/{ui/playback/src/lib/shaka-engine => playback/util/src/lib/diagnostics}/shaka-playback-evidence.util.spec.ts (100%) rename libs/{ui/playback/src/lib/shaka-engine => playback/util/src/lib/diagnostics}/shaka-playback-evidence.util.ts (98%) rename libs/{ui/playback/src/lib/playback-diagnostics => playback/util/src/lib/diagnostics}/vhs-playback-evidence.util.spec.ts (100%) rename libs/{ui/playback/src/lib/playback-diagnostics => playback/util/src/lib/diagnostics}/vhs-playback-evidence.util.ts (100%) create mode 100644 libs/playback/util/src/lib/playback-util-boundary.spec.ts create mode 100644 libs/playback/util/tsconfig.json create mode 100644 libs/playback/util/tsconfig.lib.json create mode 100644 libs/playback/util/tsconfig.spec.json create mode 100644 libs/ui/playback/src/lib/web-video-support/browser-media-type-support.spec.ts create mode 100644 libs/ui/playback/src/lib/web-video-support/browser-media-type-support.ts diff --git a/AGENTS.md b/AGENTS.md index 0b8dc3676..019266cb3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -223,8 +223,9 @@ Key files: engine (`libs/ui/playback/src/lib/shaka-engine/`) inside the HTML5 and ArtPlayer components; ClearKey keys come from KODIPROP-derived `Channel.drm`, and the shared bridge exposes Shaka audio/text tracks via - source kind `shaka`. The Shaka `5.2.2` diagnostic boundary version-locks - public severity/category/code evidence, ignores recoverable error events, + source kind `shaka`. The DOM-free Shaka `5.2.2` diagnostic boundary lives in + `libs/playback/util`; it version-locks public severity/category/code evidence, + ignores recoverable error events, treats rejected loads as terminal lifecycle outcomes, preserves exact public DASH text-parser category/code evidence with unknown stage/failure, and never retains or renders raw messages or `error.data`. A failed browser-support @@ -232,9 +233,10 @@ Key files: DRM still suppresses it. See the CLAUDE.md "Video Players" feature entry and `docs/architecture/m3u-playlist-module.md` ("DASH + ClearKey Playback"). - mpegts.js `1.8.0` errors from HTML5, Video.js, and ArtPlayer cross one - version-locked structured evidence boundary. Only exact public type/detail - pairs, pair-derived stage/failure, terminal disposition, and the validated - HTTP 4xx/5xx status slot are retained; raw messages and arbitrary `info` + version-locked structured evidence boundary in `libs/playback/util`. Only + exact public type/detail pairs, pair-derived stage/failure, terminal + disposition, and the validated HTTP 4xx/5xx status slot are retained; raw + messages and arbitrary `info` never reach diagnostics. This is a sibling of `PlayerController`, not part of the controls contract. - The built-in HTML5/hls.js player is the second guarded consumer. diff --git a/CLAUDE.md b/CLAUDE.md index d7e0dfacb..93638914e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -758,8 +758,9 @@ app as a real argument, so it is not an option. - Built-in web players: HTML5+hls.js, Video.js, and ArtPlayer - mpegts.js `1.8.0` errors from all three built-in players cross one - version-locked structured evidence boundary. It retains only exact public - type/detail pairs, pair-derived stage/failure, terminal disposition, and a + version-locked structured evidence boundary in `libs/playback/util`. It + retains only exact public type/detail pairs, pair-derived stage/failure, + terminal disposition, and a validated HTTP 4xx/5xx status; raw messages and arbitrary `info` never reach stored or rendered diagnostics. HTTP/network failures avoid false decoder recommendations, while exact format, codec, truncated-stream, and @@ -776,8 +777,9 @@ app as a real argument, so it is not an option. ArtPlayer). Unsupported license types (Widevine/PlayReady — out of scope, need the castLabs Electron fork) surface a DRM playback diagnostic instead of crashing. ClearKey EME works in stock Electron. Engine: - `libs/ui/playback/src/lib/shaka-engine/`. Its Shaka `5.2.2` diagnostic - boundary version-locks public severity/category/code evidence, ignores + `libs/ui/playback/src/lib/shaka-engine/`. Its DOM-free Shaka `5.2.2` + diagnostic boundary lives in `libs/playback/util`; it version-locks public + severity/category/code evidence, ignores recoverable error events, treats rejected loads as terminal lifecycle outcomes, preserves exact public DASH text-parser category/code evidence with unknown stage/failure, and never retains or renders raw messages or diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index cec501b91..ffaf74942 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -994,8 +994,9 @@ player in settings. - `ShakaVideoSession` (`libs/ui/playback/src/lib/shaka-engine/`) owns the engine: lazy `import('shaka-player')` on first use (the module is a separate lazy chunk, ~217 KB transfer), `drm.clearKeys` configuration, an operation - queue + generation guard against channel-switch races, and a Shaka `5.2.2` - public-error boundary. The boundary version-locks its allowlisted + queue + generation guard against channel-switch races. The DOM-free Shaka + `5.2.2` public-error boundary lives in `libs/playback/util`; it version-locks + its allowlisted severity/category/code values, emits only structured sanitized `PlaybackDiagnosticSource.Shaka` evidence, ignores recoverable error events, and treats a rejected load as terminal even if its final retry error retains diff --git a/docs/architecture/nx-workspace-boundaries.md b/docs/architecture/nx-workspace-boundaries.md index 63faab2bc..9d58fc9c6 100644 --- a/docs/architecture/nx-workspace-boundaries.md +++ b/docs/architecture/nx-workspace-boundaries.md @@ -73,6 +73,11 @@ in `libs/portal/shared/util`, while reusable collection views stay in `libs/portal/shared/ui`. Existing injectable or stateful services in a `util` path are legacy debt, not precedent for new placement. +Playback follows the same split: browser and Angular player integration stays +in `libs/ui/playback`, while DOM-free diagnostic contracts and classifiers live +in `libs/playback/util` and receive browser capability checks as explicit +probes. + ## Project Tags Every Nx project keeps one tag from each family in `project.json`: diff --git a/libs/playback/util/project.json b/libs/playback/util/project.json new file mode 100644 index 000000000..7cea6e54e --- /dev/null +++ b/libs/playback/util/project.json @@ -0,0 +1,28 @@ +{ + "name": "playback-util", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/playback/util/src", + "prefix": "lib", + "projectType": "library", + "tags": ["scope:shared", "domain:playback", "type:util"], + "targets": { + "test": { + "executor": "nx:run-commands", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "command": [ + "node", + "./tools/testing/run-web-esm-lib-tests.mjs", + "libs/playback/util/src" + ], + "env": { + "NODE_OPTIONS": "--experimental-vm-modules" + }, + "forwardAllArgs": true + } + }, + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/libs/playback/util/src/index.ts b/libs/playback/util/src/index.ts new file mode 100644 index 000000000..3a64249f0 --- /dev/null +++ b/libs/playback/util/src/index.ts @@ -0,0 +1,5 @@ +export * from './lib/diagnostics/playback-diagnostics.util'; +export * from './lib/diagnostics/shaka-error-classifier'; +export * from './lib/diagnostics/shaka-error-contract'; +export * from './lib/diagnostics/shaka-error-lifecycle'; +export type * from './lib/diagnostics/shaka-error.types'; diff --git a/libs/ui/playback/src/lib/playback-diagnostics/hls-playback-evidence.util.spec.ts b/libs/playback/util/src/lib/diagnostics/hls-playback-evidence.util.spec.ts similarity index 99% rename from libs/ui/playback/src/lib/playback-diagnostics/hls-playback-evidence.util.spec.ts rename to libs/playback/util/src/lib/diagnostics/hls-playback-evidence.util.spec.ts index b53899c1f..4ac813d5b 100644 --- a/libs/ui/playback/src/lib/playback-diagnostics/hls-playback-evidence.util.spec.ts +++ b/libs/playback/util/src/lib/diagnostics/hls-playback-evidence.util.spec.ts @@ -154,7 +154,7 @@ describe('HLS playback evidence', () => { url: `https://provider.example/context?token=${secret}`, responseType: 'text', headers: { Authorization: `Bearer ${secret}` }, - } as ErrorData['context'], + } as unknown as ErrorData['context'], networkDetails: { responseURL: `https://provider.example/xhr?token=${secret}`, responseText: secret, diff --git a/libs/ui/playback/src/lib/playback-diagnostics/hls-playback-evidence.util.ts b/libs/playback/util/src/lib/diagnostics/hls-playback-evidence.util.ts similarity index 100% rename from libs/ui/playback/src/lib/playback-diagnostics/hls-playback-evidence.util.ts rename to libs/playback/util/src/lib/diagnostics/hls-playback-evidence.util.ts diff --git a/libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.model.ts b/libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.model.ts similarity index 100% rename from libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.model.ts rename to libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.model.ts diff --git a/libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.util.spec.ts b/libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.util.spec.ts similarity index 100% rename from libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.util.spec.ts rename to libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.util.spec.ts diff --git a/libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.util.ts b/libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.util.ts similarity index 100% rename from libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.util.ts rename to libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.util.ts diff --git a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.model.ts b/libs/playback/util/src/lib/diagnostics/playback-diagnostics.model.ts similarity index 100% rename from libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.model.ts rename to libs/playback/util/src/lib/diagnostics/playback-diagnostics.model.ts diff --git a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.spec.ts b/libs/playback/util/src/lib/diagnostics/playback-diagnostics.util.spec.ts similarity index 96% rename from libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.spec.ts rename to libs/playback/util/src/lib/diagnostics/playback-diagnostics.util.spec.ts index bda83e268..9f94ed6d8 100644 --- a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.spec.ts +++ b/libs/playback/util/src/lib/diagnostics/playback-diagnostics.util.spec.ts @@ -10,6 +10,7 @@ import { classifyHlsPlaybackIssue, classifyMpegTsPlaybackIssue, classifyNativePlaybackIssue, + classifyUnsupportedHlsManifestCodecs, createMpegTsPlaybackEvidence, createPlaybackSourceMetadata, getLikelyBrowserUnsupportedCodecLabels, @@ -38,6 +39,38 @@ type VhsClassifier = ( ) => PlaybackDiagnostic; describe('playback diagnostics', () => { + it('classifies manifest codecs only when the explicit support probe rejects them', () => { + const metadata = createPlaybackSourceMetadata({ + url: 'https://example.com/live/index.m3u8', + player: 'html5', + audioCodecs: ['ac-3'], + videoCodecs: ['avc1.64001f'], + }); + + const issue = classifyUnsupportedHlsManifestCodecs( + metadata, + () => false + ); + + expect(issue?.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec); + }); + + it.each([true, undefined])( + 'does not classify manifest codecs when the support probe returns %s', + (supportResult) => { + const issue = classifyUnsupportedHlsManifestCodecs( + createPlaybackSourceMetadata({ + url: 'https://example.com/live/index.m3u8', + player: 'html5', + videoCodecs: ['avc1.64001f'], + }), + () => supportResult + ); + + expect(issue).toBeNull(); + } + ); + it('classifies HLS incompatible codec errors as unsupported codec fallbacks', () => { const issue = classifyStructuredHlsPlaybackIssue( { diff --git a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts b/libs/playback/util/src/lib/diagnostics/playback-diagnostics.util.ts similarity index 95% rename from libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts rename to libs/playback/util/src/lib/diagnostics/playback-diagnostics.util.ts index c6c61aa53..d2645d930 100644 --- a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts +++ b/libs/playback/util/src/lib/diagnostics/playback-diagnostics.util.ts @@ -50,17 +50,13 @@ const VHS_NETWORK_ERROR_TYPES: ReadonlySet = ]); export function classifyNativePlaybackIssue( - error: NativePlaybackErrorInput | MediaError | null | undefined, + error: NativePlaybackErrorInput | null | undefined, metadata: PlaybackSourceMetadata ): PlaybackDiagnostic { const nativeErrorCode = error?.code; const nativeErrorMessage = error?.message || undefined; - const nativeErrorInput = error as - NativePlaybackErrorInput | null | undefined; - const httpStatus = getNativeHttpStatus(nativeErrorInput?.status); - const nativeErrorType = getNativeErrorType( - nativeErrorInput?.metadata?.errorType - ); + const httpStatus = getNativeHttpStatus(error?.status); + const nativeErrorType = getNativeErrorType(error?.metadata?.errorType); const lowerNativeErrorMessage = nativeErrorMessage?.toLowerCase() ?? ''; if (httpStatus !== undefined) { @@ -175,17 +171,20 @@ export function classifyMpegTsPlaybackIssue( }); } +export type MediaTypeSupportProbe = (mimeType: string) => boolean | undefined; + export function classifyUnsupportedHlsManifestCodecs( - metadata: PlaybackSourceMetadata + metadata: PlaybackSourceMetadata, + isTypeSupported: MediaTypeSupportProbe ): PlaybackDiagnostic | null { - if (!hasCodecs(metadata) || typeof MediaSource === 'undefined') { + if (!hasCodecs(metadata)) { return null; } const codecList = [...metadata.videoCodecs, ...metadata.audioCodecs]; const mimeType = `video/mp4; codecs="${codecList.join(',')}"`; - if (MediaSource.isTypeSupported(mimeType)) { + if (isTypeSupported(mimeType) !== false) { return null; } diff --git a/libs/ui/playback/src/lib/playback-diagnostics/playback-error-patterns.util.ts b/libs/playback/util/src/lib/diagnostics/playback-error-patterns.util.ts similarity index 100% rename from libs/ui/playback/src/lib/playback-diagnostics/playback-error-patterns.util.ts rename to libs/playback/util/src/lib/diagnostics/playback-error-patterns.util.ts diff --git a/libs/ui/playback/src/lib/playback-diagnostics/playback-media-source.util.ts b/libs/playback/util/src/lib/diagnostics/playback-media-source.util.ts similarity index 100% rename from libs/ui/playback/src/lib/playback-diagnostics/playback-media-source.util.ts rename to libs/playback/util/src/lib/diagnostics/playback-media-source.util.ts diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.spec.ts b/libs/playback/util/src/lib/diagnostics/shaka-error-classifier.spec.ts similarity index 97% rename from libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.spec.ts rename to libs/playback/util/src/lib/diagnostics/shaka-error-classifier.spec.ts index 529dbc9d4..28effb252 100644 --- a/libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.spec.ts +++ b/libs/playback/util/src/lib/diagnostics/shaka-error-classifier.spec.ts @@ -2,8 +2,8 @@ import { InlinePlaybackPlayer, PlaybackDiagnosticCode, PlaybackDiagnosticSource, -} from '../playback-diagnostics/playback-diagnostics.model'; -import { createPlaybackSourceMetadata } from '../playback-diagnostics/playback-diagnostics.util'; +} from './playback-diagnostics.model'; +import { createPlaybackSourceMetadata } from './playback-diagnostics.util'; import { classifyShakaPlaybackIssue, createUnsupportedDrmDiagnostic, diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.ts b/libs/playback/util/src/lib/diagnostics/shaka-error-classifier.ts similarity index 92% rename from libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.ts rename to libs/playback/util/src/lib/diagnostics/shaka-error-classifier.ts index 9bc96d8ef..4bef97f68 100644 --- a/libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.ts +++ b/libs/playback/util/src/lib/diagnostics/shaka-error-classifier.ts @@ -4,18 +4,18 @@ import type { PlaybackSourceMetadata, ShakaPlaybackDisposition, ShakaPlaybackEvidence, -} from '../playback-diagnostics/playback-diagnostics.model'; +} from './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'; +} from './playback-diagnostics.model'; +import { createPlaybackDiagnostic } from './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'; +import type { ShakaErrorLike } from './shaka-error.types'; export { SHAKA_DIAGNOSTIC_VERSION, diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-error-contract.ts b/libs/playback/util/src/lib/diagnostics/shaka-error-contract.ts similarity index 100% rename from libs/ui/playback/src/lib/shaka-engine/shaka-error-contract.ts rename to libs/playback/util/src/lib/diagnostics/shaka-error-contract.ts diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-error-lifecycle.ts b/libs/playback/util/src/lib/diagnostics/shaka-error-lifecycle.ts similarity index 82% rename from libs/ui/playback/src/lib/shaka-engine/shaka-error-lifecycle.ts rename to libs/playback/util/src/lib/diagnostics/shaka-error-lifecycle.ts index b58c0a87c..c29dc807a 100644 --- a/libs/ui/playback/src/lib/shaka-engine/shaka-error-lifecycle.ts +++ b/libs/playback/util/src/lib/diagnostics/shaka-error-lifecycle.ts @@ -1,10 +1,10 @@ -import { ShakaPlaybackDisposition as ShakaDisposition } from '../playback-diagnostics/playback-diagnostics.model'; +import { ShakaPlaybackDisposition as ShakaDisposition } from './playback-diagnostics.model'; import { SHAKA_ERROR_CATEGORY, SHAKA_ERROR_CODE, SHAKA_ERROR_SEVERITY, } from './shaka-error-contract'; -import type { ShakaErrorLike } from './shaka-module.types'; +import type { ShakaErrorLike } from './shaka-error.types'; export function getShakaErrorEventDisposition( error: Partial | null diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-error-mapping.ts b/libs/playback/util/src/lib/diagnostics/shaka-error-mapping.ts similarity index 100% rename from libs/ui/playback/src/lib/shaka-engine/shaka-error-mapping.ts rename to libs/playback/util/src/lib/diagnostics/shaka-error-mapping.ts diff --git a/libs/playback/util/src/lib/diagnostics/shaka-error.types.ts b/libs/playback/util/src/lib/diagnostics/shaka-error.types.ts new file mode 100644 index 000000000..41ce89de0 --- /dev/null +++ b/libs/playback/util/src/lib/diagnostics/shaka-error.types.ts @@ -0,0 +1,6 @@ +export interface ShakaErrorLike { + readonly severity: number; + readonly category: number; + readonly code: number; + readonly data?: readonly unknown[]; +} diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-playback-evidence.util.spec.ts b/libs/playback/util/src/lib/diagnostics/shaka-playback-evidence.util.spec.ts similarity index 100% rename from libs/ui/playback/src/lib/shaka-engine/shaka-playback-evidence.util.spec.ts rename to libs/playback/util/src/lib/diagnostics/shaka-playback-evidence.util.spec.ts diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-playback-evidence.util.ts b/libs/playback/util/src/lib/diagnostics/shaka-playback-evidence.util.ts similarity index 98% rename from libs/ui/playback/src/lib/shaka-engine/shaka-playback-evidence.util.ts rename to libs/playback/util/src/lib/diagnostics/shaka-playback-evidence.util.ts index 479788259..00ae4c116 100644 --- a/libs/ui/playback/src/lib/shaka-engine/shaka-playback-evidence.util.ts +++ b/libs/playback/util/src/lib/diagnostics/shaka-playback-evidence.util.ts @@ -9,7 +9,7 @@ import { ShakaPlaybackSeverity, ShakaPlaybackStage, ShakaPlaybackUnknownCode, -} from '../playback-diagnostics/playback-diagnostics.model'; +} from './playback-diagnostics.model'; import { SHAKA_ERROR_CATEGORY, SHAKA_ERROR_CODE, @@ -29,7 +29,7 @@ import { SHAKA_PUBLIC_CODES, SHAKA_SEGMENT_STAGE_CODES, } from './shaka-error-mapping'; -import type { ShakaErrorLike } from './shaka-module.types'; +import type { ShakaErrorLike } from './shaka-error.types'; export function createShakaPlaybackEvidence( error: Partial | null | undefined, diff --git a/libs/ui/playback/src/lib/playback-diagnostics/vhs-playback-evidence.util.spec.ts b/libs/playback/util/src/lib/diagnostics/vhs-playback-evidence.util.spec.ts similarity index 100% rename from libs/ui/playback/src/lib/playback-diagnostics/vhs-playback-evidence.util.spec.ts rename to libs/playback/util/src/lib/diagnostics/vhs-playback-evidence.util.spec.ts diff --git a/libs/ui/playback/src/lib/playback-diagnostics/vhs-playback-evidence.util.ts b/libs/playback/util/src/lib/diagnostics/vhs-playback-evidence.util.ts similarity index 100% rename from libs/ui/playback/src/lib/playback-diagnostics/vhs-playback-evidence.util.ts rename to libs/playback/util/src/lib/diagnostics/vhs-playback-evidence.util.ts diff --git a/libs/playback/util/src/lib/playback-util-boundary.spec.ts b/libs/playback/util/src/lib/playback-util-boundary.spec.ts new file mode 100644 index 000000000..ba5e757b4 --- /dev/null +++ b/libs/playback/util/src/lib/playback-util-boundary.spec.ts @@ -0,0 +1,347 @@ +import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, relative } from 'node:path'; +import ts from 'typescript'; + +const PROJECT_SOURCE_ROOT = join(process.cwd(), 'libs/playback/util/src'); +const FORBIDDEN_IMPORT_PREFIXES = [ + '@angular/', + '@iptvnator/ui/', + 'electron', + '@ngx-pwa/', +] as const; +const FORBIDDEN_GLOBALS = new Set([ + 'window', + 'document', + 'localStorage', + 'sessionStorage', + 'MediaSource', + 'MediaError', + 'HTMLMediaElement', + 'HTMLElement', + 'navigator', + 'indexedDB', +]); + +describe('playback util boundary', () => { + it('keeps production sources independent of UI and browser globals', () => { + const violations = collectProductionSources( + PROJECT_SOURCE_ROOT + ).flatMap(findBoundaryViolations); + + expect(violations).toEqual([]); + }); + + it.each([ + ['import declaration', "import '@angular/core';", '@angular/core'], + [ + 're-export declaration', + "export * from '@iptvnator/ui/components';", + '@iptvnator/ui/components', + ], + [ + 'import type node', + "type AngularType = import('@angular/core').Type;", + '@angular/core', + ], + [ + 'dynamic import', + "void import('@ngx-pwa/service-worker');", + '@ngx-pwa/service-worker', + ], + [ + 'import-equals declaration', + "import Electron = require('electron'); void Electron;", + 'electron', + ], + ])('rejects forbidden sources in a %s', (_label, source, moduleName) => { + const violations = findFixtureViolations(source); + + expect(violations).toEqual([expect.stringContaining(moduleName)]); + }); + + it('allows non-forbidden sources in every supported import form', () => { + const violations = findFixtureViolations(` + import './imported'; + export * from './exported'; + type LocalType = import('./typed').Type; + void import('./dynamic'); + import Local = require('./required'); + void Local; + `); + + expect(violations).toEqual([]); + }); + + it.each([ + ['direct identifier', 'void navigator;', 'navigator'], + ['direct storage identifier', 'void indexedDB;', 'indexedDB'], + [ + 'globalThis property access', + 'void globalThis.MediaSource;', + 'MediaSource', + ], + ['self property access', 'void self.indexedDB;', 'indexedDB'], + [ + 'globalThis element access', + "void globalThis['localStorage'];", + 'localStorage', + ], + [ + 'self element access', + "void self['sessionStorage'];", + 'sessionStorage', + ], + [ + 'globalThis destructuring', + 'const { navigator } = globalThis;', + 'navigator', + ], + [ + 'self destructuring', + 'const { indexedDB: database } = self; void database;', + 'indexedDB', + ], + ])( + 'rejects forbidden browser globals through %s', + (_label, source, name) => { + const violations = findFixtureViolations(source); + + expect(violations).toEqual([expect.stringContaining(name)]); + } + ); + + it.each([ + ['property access', 'void source.navigator;'], + ['element access', "void source['indexedDB'];"], + ['destructuring', 'const { navigator } = source;'], + [ + 'property declaration', + 'const config = { MediaSource: source }; void config;', + ], + ['shadowed self', 'const self = source; void self.MediaSource;'], + [ + 'local forbidden-name binding', + 'const navigator = source.navigator; void navigator;', + ], + ])('allows ordinary local %s names', (_label, source) => { + const violations = findFixtureViolations(source); + + expect(violations).toEqual([]); + }); +}); + +function findFixtureViolations(source: string): string[] { + const directory = mkdtempSync(join(tmpdir(), 'playback-util-boundary-')); + const filePath = join(directory, 'fixture.ts'); + try { + writeFileSync(filePath, `${source}\nexport {};`); + return findBoundaryViolations(filePath); + } finally { + rmSync(directory, { force: true, recursive: true }); + } +} + +function collectProductionSources(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + return collectProductionSources(path); + } + return entry.isFile() && + entry.name.endsWith('.ts') && + !entry.name.endsWith('.spec.ts') && + !entry.name.endsWith('.test.ts') + ? [path] + : []; + }); +} + +function findBoundaryViolations(filePath: string): string[] { + const program = ts.createProgram({ + rootNames: [filePath], + options: { + lib: ['lib.es2022.d.ts', 'lib.dom.d.ts'], + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + noEmit: true, + noResolve: true, + skipLibCheck: true, + target: ts.ScriptTarget.ESNext, + }, + }); + const sourceFile = program.getSourceFile(filePath); + if (!sourceFile) { + throw new Error(`Unable to parse ${filePath}`); + } + const checker = program.getTypeChecker(); + const violations: string[] = []; + + sourceFile.forEachChild(function visit(node): void { + const moduleSpecifier = getExternalModuleSpecifier(node); + if (moduleSpecifier && isForbiddenModuleSpecifier(moduleSpecifier)) { + violations.push(formatViolation(sourceFile, node, moduleSpecifier)); + } + for (const globalName of getForbiddenGlobalPropertyAccesses( + node, + checker, + sourceFile + )) { + violations.push(formatViolation(sourceFile, node, globalName)); + } + if ( + ts.isIdentifier(node) && + FORBIDDEN_GLOBALS.has(node.text) && + isAmbientIdentifierReference(node, checker, sourceFile) + ) { + violations.push(formatViolation(sourceFile, node, node.text)); + } + node.forEachChild(visit); + }); + + return violations; +} + +function getExternalModuleSpecifier(node: ts.Node): string | undefined { + if ( + (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && + node.moduleSpecifier + ) { + return getStaticString(node.moduleSpecifier); + } + if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) { + return getStaticString(node.argument.literal); + } + if ( + ts.isCallExpression(node) && + node.expression.kind === ts.SyntaxKind.ImportKeyword + ) { + return getStaticString(node.arguments[0]); + } + if ( + ts.isImportEqualsDeclaration(node) && + ts.isExternalModuleReference(node.moduleReference) + ) { + return getStaticString(node.moduleReference.expression); + } + return undefined; +} + +function getStaticString(node?: ts.Node): string | undefined { + return node && + (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) + ? node.text + : undefined; +} + +function isForbiddenModuleSpecifier(moduleSpecifier: string): boolean { + return FORBIDDEN_IMPORT_PREFIXES.some((prefix) => + moduleSpecifier.startsWith(prefix) + ); +} + +function getForbiddenGlobalPropertyAccesses( + node: ts.Node, + checker: ts.TypeChecker, + sourceFile: ts.SourceFile +): string[] { + if ( + ts.isPropertyAccessExpression(node) && + isAmbientGlobalRoot(node.expression, checker, sourceFile) && + FORBIDDEN_GLOBALS.has(node.name.text) + ) { + return [node.name.text]; + } + if ( + ts.isElementAccessExpression(node) && + isAmbientGlobalRoot(node.expression, checker, sourceFile) + ) { + const propertyName = getStaticString(node.argumentExpression); + return propertyName && FORBIDDEN_GLOBALS.has(propertyName) + ? [propertyName] + : []; + } + if ( + ts.isVariableDeclaration(node) && + ts.isObjectBindingPattern(node.name) && + node.initializer && + isAmbientGlobalRoot(node.initializer, checker, sourceFile) + ) { + return node.name.elements.flatMap((element) => { + const propertyName = getBindingPropertyName(element); + return propertyName && FORBIDDEN_GLOBALS.has(propertyName) + ? [propertyName] + : []; + }); + } + return []; +} + +function getBindingPropertyName( + element: ts.BindingElement +): string | undefined { + const property = element.propertyName ?? element.name; + return ts.isIdentifier(property) || ts.isStringLiteral(property) + ? property.text + : undefined; +} + +function isAmbientGlobalRoot( + expression: ts.Expression, + checker: ts.TypeChecker, + sourceFile: ts.SourceFile +): boolean { + return ( + ts.isIdentifier(expression) && + (expression.text === 'globalThis' || expression.text === 'self') && + isAmbientIdentifier(expression, checker, sourceFile) + ); +} + +function isAmbientIdentifierReference( + identifier: ts.Identifier, + checker: ts.TypeChecker, + sourceFile: ts.SourceFile +): boolean { + return ( + !isPropertyName(identifier) && + isAmbientIdentifier(identifier, checker, sourceFile) + ); +} + +function isAmbientIdentifier( + identifier: ts.Identifier, + checker: ts.TypeChecker, + sourceFile: ts.SourceFile +): boolean { + const symbol = checker.getSymbolAtLocation(identifier); + return !symbol?.declarations?.some( + (declaration) => declaration.getSourceFile() === sourceFile + ); +} + +function isPropertyName(identifier: ts.Identifier): boolean { + const { parent } = identifier; + return ( + (ts.isPropertyAccessExpression(parent) && parent.name === identifier) || + (ts.isBindingElement(parent) && + (parent.name === identifier || + parent.propertyName === identifier)) || + ((ts.isPropertyAssignment(parent) || + ts.isPropertyDeclaration(parent) || + ts.isPropertySignature(parent) || + ts.isMethodDeclaration(parent) || + ts.isMethodSignature(parent) || + ts.isEnumMember(parent)) && + parent.name === identifier) + ); +} + +function formatViolation( + sourceFile: ts.SourceFile, + node: ts.Node, + detail: string +): string { + const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart()); + return `${relative(process.cwd(), sourceFile.fileName)}:${line + 1}: ${detail}`; +} diff --git a/libs/playback/util/tsconfig.json b/libs/playback/util/tsconfig.json new file mode 100644 index 000000000..41d58cc4b --- /dev/null +++ b/libs/playback/util/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "isolatedModules": true, + "target": "es2022", + "moduleResolution": "bundler", + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "emitDecoratorMetadata": false, + "module": "preserve" + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/playback/util/tsconfig.lib.json b/libs/playback/util/tsconfig.lib.json new file mode 100644 index 000000000..03dbd1884 --- /dev/null +++ b/libs/playback/util/tsconfig.lib.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libs/playback/util/tsconfig.spec.json b/libs/playback/util/tsconfig.spec.json new file mode 100644 index 000000000..01174f48f --- /dev/null +++ b/libs/playback/util/tsconfig.spec.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "ESNext", + "target": "ESNext", + "esModuleInterop": true, + "types": ["jest", "node"], + "moduleResolution": "bundler" + }, + "include": ["src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/libs/ui/playback/src/index.ts b/libs/ui/playback/src/index.ts index f06705daa..73366aa88 100644 --- a/libs/ui/playback/src/index.ts +++ b/libs/ui/playback/src/index.ts @@ -5,7 +5,7 @@ export * from './lib/embedded-mpv-player/embedded-mpv-player.component'; export * from './lib/embedded-mpv-player/embedded-mpv-session-controller'; export * from './lib/external-player-info-dialog/external-player-info-dialog.component'; export * from './lib/html-video-player/html-video-player.component'; -export * from './lib/playback-diagnostics/playback-diagnostics.util'; +export * from '@iptvnator/playback/util'; export * from './lib/player-controls'; export * from './lib/portal-inline-player/portal-inline-player.component'; export * from './lib/portal-inline-player/series-playback-navigation'; diff --git a/libs/ui/playback/src/lib/art-player/art-player-setup.ts b/libs/ui/playback/src/lib/art-player/art-player-setup.ts index 9e78fe990..3cd91304e 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-setup.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-setup.ts @@ -1,4 +1,4 @@ -import { getPlaybackMediaExtensionFromUrl } from '../playback-diagnostics/playback-diagnostics.util'; +import { getPlaybackMediaExtensionFromUrl } from '@iptvnator/playback/util'; /** * ArtPlayer option overrides for the legacy and shared-control surfaces. diff --git a/libs/ui/playback/src/lib/art-player/art-player-source-session.dash.spec.ts b/libs/ui/playback/src/lib/art-player/art-player-source-session.dash.spec.ts index e0c839e19..102282f24 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-source-session.dash.spec.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-source-session.dash.spec.ts @@ -4,7 +4,7 @@ import { type PlaybackDiagnostic, PlaybackDiagnosticCode, PlaybackDiagnosticSource, -} from '../playback-diagnostics/playback-diagnostics.model'; +} from '@iptvnator/playback/util'; import { createFakeShakaEnvironment, flushShakaMicrotasks, diff --git a/libs/ui/playback/src/lib/art-player/art-player-source-session.spec-fixtures.ts b/libs/ui/playback/src/lib/art-player/art-player-source-session.spec-fixtures.ts index 4d97dec50..239351d13 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-source-session.spec-fixtures.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-source-session.spec-fixtures.ts @@ -1,6 +1,6 @@ import type Artplayer from 'artplayer'; import type { ChannelDrm } from '@iptvnator/shared/interfaces'; -import type { PlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util'; +import type { PlaybackDiagnostic } from '@iptvnator/playback/util'; import { WebVideoControlsAdapter } from '../player-controls'; import type { ShakaModuleLoader } from '../shaka-engine/shaka-module.types'; import type { ArtPlayerSourceSession as ArtPlayerSourceSessionInstance } from './art-player-source-session'; diff --git a/libs/ui/playback/src/lib/art-player/art-player-source-session.spec.ts b/libs/ui/playback/src/lib/art-player/art-player-source-session.spec.ts index fcf24cf3b..4f3515791 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-source-session.spec.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-source-session.spec.ts @@ -1,4 +1,4 @@ -import type { PlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util'; +import type { PlaybackDiagnostic } from '@iptvnator/playback/util'; import { MockHls, MockMpegTsPlayer, @@ -90,6 +90,84 @@ describe('ArtPlayerSourceSession', () => { expect(attach).not.toHaveBeenCalled(); }); + it('uses browser media-type support for HLS manifest codec diagnostics', () => { + const mediaSourceDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'MediaSource' + ); + Object.defineProperty(globalThis, 'MediaSource', { + configurable: true, + value: { isTypeSupported: jest.fn(() => false) }, + }); + const emitted: PlaybackDiagnostic[] = []; + const { session, player, video } = createSession({ + sharedControls: true, + emitPlaybackIssue: (issue) => emitted.push(issue), + }); + session.attach(player); + session.customType['m3u8']?.( + video, + 'https://example.test/live.m3u8', + player + ); + + try { + hlsInstances[0].emit(MockHls.Events.MANIFEST_PARSED, null, { + levels: [{ audioCodec: 'ac-3', videoCodec: 'avc1.64001f' }], + }); + } finally { + restoreMediaSource(mediaSourceDescriptor); + } + expect(emitted).toEqual([ + expect.objectContaining({ + code: 'unsupported-codec', + source: 'source', + }), + ]); + }); + + it('keeps HLS manifest handling alive when the browser support probe throws', () => { + const mediaSourceDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'MediaSource' + ); + Object.defineProperty(globalThis, 'MediaSource', { + configurable: true, + value: { + isTypeSupported: () => { + throw new Error('provider-controlled-codec'); + }, + }, + }); + const emitted: PlaybackDiagnostic[] = []; + const { session, player, video } = createSession({ + sharedControls: true, + emitPlaybackIssue: (issue) => emitted.push(issue), + }); + session.attach(player); + session.customType['m3u8']?.( + video, + 'https://example.test/live.m3u8', + player + ); + + try { + expect(() => + hlsInstances[0].emit(MockHls.Events.MANIFEST_PARSED, null, { + levels: [ + { + audioCodec: 'provider-controlled-codec', + videoCodec: 'avc1.64001f', + }, + ], + }) + ).not.toThrow(); + } finally { + restoreMediaSource(mediaSourceDescriptor); + } + expect(emitted).toEqual([]); + }); + it('uses authoritative VOD metadata for shared MPEG-TS and catches autoplay rejection', async () => { const { session, player, video, adapter } = createSession({ sharedControls: true, @@ -299,3 +377,11 @@ describe('ArtPlayerSourceSession', () => { expect(refresh).toHaveBeenCalled(); }); }); + +function restoreMediaSource(descriptor?: PropertyDescriptor): void { + if (descriptor) { + Object.defineProperty(globalThis, 'MediaSource', descriptor); + return; + } + Reflect.deleteProperty(globalThis, 'MediaSource'); +} diff --git a/libs/ui/playback/src/lib/art-player/art-player-source-session.ts b/libs/ui/playback/src/lib/art-player/art-player-source-session.ts index 5c71da94b..226c05daa 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-source-session.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-source-session.ts @@ -12,7 +12,8 @@ import { createHlsPlaybackEvidence, createMpegTsPlaybackEvidence, createPlaybackSourceMetadata, -} from '../playback-diagnostics/playback-diagnostics.util'; +} from '@iptvnator/playback/util'; +import { isBrowserMediaTypeSupported } from '../web-video-support/browser-media-type-support'; import type { WebVideoControlsAdapter } from '../player-controls'; import type { ShakaModuleLoader } from '../shaka-engine/shaka-module.types'; import { ShakaVideoSession } from '../shaka-engine/shaka-video-session'; @@ -307,7 +308,10 @@ export class ArtPlayerSourceSession { .map((level) => level.videoCodec) .filter((codec): codec is string => Boolean(codec)) ); - const issue = classifyUnsupportedHlsManifestCodecs(metadata); + const issue = classifyUnsupportedHlsManifestCodecs( + metadata, + isBrowserMediaTypeSupported + ); if (issue) { this.config.emitPlaybackIssue(issue); } diff --git a/libs/ui/playback/src/lib/art-player/art-player-video-session.ts b/libs/ui/playback/src/lib/art-player/art-player-video-session.ts index 20bf5ef20..c01485bf4 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-video-session.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-video-session.ts @@ -4,7 +4,7 @@ import { type PlaybackDiagnostic, classifyNativePlaybackIssue, createPlaybackSourceMetadata, -} from '../playback-diagnostics/playback-diagnostics.util'; +} from '@iptvnator/playback/util'; export interface ArtPlayerVideoSessionConfig { player: Artplayer; diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.ts b/libs/ui/playback/src/lib/art-player/art-player.component.ts index 61cb5235f..8b659eba0 100644 --- a/libs/ui/playback/src/lib/art-player/art-player.component.ts +++ b/libs/ui/playback/src/lib/art-player/art-player.component.ts @@ -13,7 +13,7 @@ import { } from '@angular/core'; import Artplayer from 'artplayer'; import { Channel, createDevLogger } from '@iptvnator/shared/interfaces'; -import type { PlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util'; +import type { PlaybackDiagnostic } from '@iptvnator/playback/util'; import { PlayerControlsComponent, type PlayerMediaTitle, diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-element-session.ts b/libs/ui/playback/src/lib/html-video-player/html-video-element-session.ts index 3e320c19b..ef9593c54 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-element-session.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-element-session.ts @@ -4,7 +4,7 @@ import { type PlaybackDiagnostic, classifyNativePlaybackIssue, createPlaybackSourceMetadata, -} from '../playback-diagnostics/playback-diagnostics.util'; +} from '@iptvnator/playback/util'; const debugHtmlPlayer = createDevLogger('HtmlVideoPlayer'); diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player-diagnostics.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player-diagnostics.ts index 048176b95..ac3da7102 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player-diagnostics.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player-diagnostics.ts @@ -9,7 +9,8 @@ import { createHlsPlaybackEvidence, createMpegTsPlaybackEvidence, createPlaybackSourceMetadata, -} from '../playback-diagnostics/playback-diagnostics.util'; +} from '@iptvnator/playback/util'; +import { isBrowserMediaTypeSupported } from '../web-video-support/browser-media-type-support'; /** * HTML5-player diagnostics glue extracted from the component: builds source @@ -46,7 +47,10 @@ export function emitUnsupportedHlsManifestCodecs( .map((level) => level.videoCodec) .filter((codec): codec is string => Boolean(codec)) ); - const issue = classifyUnsupportedHlsManifestCodecs(metadata); + const issue = classifyUnsupportedHlsManifestCodecs( + metadata, + isBrowserMediaTypeSupported + ); if (issue) { emitPlaybackIssue(issue); } diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts index cd1f8f540..2eb43b0c6 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts @@ -4,7 +4,12 @@ import { By } from '@angular/platform-browser'; import { TranslateModule } from '@ngx-translate/core'; import { DataService } from '@iptvnator/services'; import { Channel } from '@iptvnator/shared/interfaces'; -import { ErrorDetails, ErrorTypes, type ErrorData } from 'hls.js'; +import { + ErrorDetails, + ErrorTypes, + type ErrorData, + type ManifestParsedData, +} from 'hls.js'; import { PlayerControlsComponent, WebVideoControlsAdapter, @@ -203,6 +208,86 @@ describe('HtmlVideoPlayerComponent', () => { ]); }); + it('uses browser media-type support for HLS manifest codec diagnostics', () => { + const mediaSourceDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'MediaSource' + ); + Object.defineProperty(globalThis, 'MediaSource', { + configurable: true, + value: { isTypeSupported: jest.fn(() => false) }, + }); + const issues: unknown[] = []; + component.playbackIssue.subscribe((issue) => issues.push(issue)); + + try { + ( + component as unknown as { + handleHlsManifestParsed: ( + url: string, + data: ManifestParsedData + ) => void; + } + ).handleHlsManifestParsed( + 'https://example.com/live/playlist.m3u8', + { + levels: [{ audioCodec: 'ac-3', videoCodec: 'avc1.64001f' }], + } as ManifestParsedData + ); + } finally { + restoreMediaSource(mediaSourceDescriptor); + } + expect(issues).toEqual([ + expect.objectContaining({ + code: 'unsupported-codec', + source: 'source', + }), + ]); + }); + + it('keeps HLS manifest handling alive when the browser support probe throws', () => { + const mediaSourceDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'MediaSource' + ); + Object.defineProperty(globalThis, 'MediaSource', { + configurable: true, + value: { + isTypeSupported: () => { + throw new Error('provider-controlled-codec'); + }, + }, + }); + const issues: unknown[] = []; + component.playbackIssue.subscribe((issue) => issues.push(issue)); + + try { + expect(() => + ( + component as unknown as { + handleHlsManifestParsed: ( + url: string, + data: ManifestParsedData + ) => void; + } + ).handleHlsManifestParsed( + 'https://example.com/live/playlist.m3u8', + { + levels: [ + { + audioCodec: 'provider-controlled-codec', + videoCodec: 'avc1.64001f', + }, + ], + } as ManifestParsedData + ) + ).not.toThrow(); + } finally { + restoreMediaSource(mediaSourceDescriptor); + } + expect(issues).toEqual([]); + }); + it('does not emit a playback issue when HLS.js reports a recoverable error', () => { const issues: unknown[] = []; component.playbackIssue.subscribe((issue) => { @@ -355,3 +440,11 @@ describe('HtmlVideoPlayerComponent', () => { expect(events).toEqual(['previous']); }); }); + +function restoreMediaSource(descriptor?: PropertyDescriptor): void { + if (descriptor) { + Object.defineProperty(globalThis, 'MediaSource', descriptor); + return; + } + Reflect.deleteProperty(globalThis, 'MediaSource'); +} diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts index 4562aecfa..41f9c4a25 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts @@ -21,7 +21,7 @@ import { InlinePlaybackPlayer, PlaybackDiagnostic, getPlaybackMediaExtensionFromUrl, -} from '../playback-diagnostics/playback-diagnostics.util'; +} from '@iptvnator/playback/util'; import { PlayerControlsComponent, type PlayerMediaTitle, diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts index 7555504d3..734efb70b 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts @@ -22,8 +22,8 @@ import { type VodSourceDescriptor, type VodSourceMatchKind, } from '@iptvnator/shared/interfaces'; -import type { PlaybackFallbackRequest } from '../playback-diagnostics/playback-diagnostics.util'; -import type { PlaybackDiagnosticCode } from '../playback-diagnostics/playback-diagnostics.model'; +import type { PlaybackFallbackRequest } from '@iptvnator/playback/util'; +import type { PlaybackDiagnosticCode } from '@iptvnator/playback/util'; import { SettingsStore } from '@iptvnator/services'; import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils'; import type { PlayerMediaTitle } from '../player-controls'; diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-module.types.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-module.types.ts index 353d5d8c6..f0a089d7f 100644 --- a/libs/ui/playback/src/lib/shaka-engine/shaka-module.types.ts +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-module.types.ts @@ -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; diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.spec.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.spec.ts index bcdc7f5cc..b4d6aa59e 100644 --- a/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.spec.ts +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.spec.ts @@ -4,7 +4,7 @@ import { PlaybackDiagnostic, PlaybackDiagnosticCode, PlaybackDiagnosticSource, -} from '../playback-diagnostics/playback-diagnostics.model'; +} from '@iptvnator/playback/util'; import { FakeShakaPlayer, createFakeShakaEnvironment, diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts index 6898e23d7..0fd1f39a8 100644 --- a/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts @@ -1,20 +1,16 @@ 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, 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, diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.ts b/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.ts index 3e272eb0e..08b0347a6 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.ts @@ -6,7 +6,7 @@ import { createMpegTsPlaybackEvidence, createPlaybackSourceMetadata, getPlaybackMediaExtensionFromUrl, -} from '../playback-diagnostics/playback-diagnostics.util'; +} from '@iptvnator/playback/util'; interface VjsDurationPlayer { duration(value?: number): unknown; diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts index c5c37e6f5..f24552bc5 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts @@ -1,7 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import type { NativePlaybackErrorInput } from '../playback-diagnostics/playback-diagnostics.model'; -import type { PlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util'; +import type { NativePlaybackErrorInput } from '@iptvnator/playback/util'; +import type { PlaybackDiagnostic } from '@iptvnator/playback/util'; import type { VjsPlayerComponent as VjsPlayerComponentInstance } from './vjs-player.component'; import type { VideoJsPlayer } from './vjs-player.types'; diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts index b7cf69e15..2b2ea6974 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts @@ -23,7 +23,7 @@ import { classifyNativePlaybackIssue, classifyVhsPlaybackIssue, createPlaybackSourceMetadata, -} from '../playback-diagnostics/playback-diagnostics.util'; +} from '@iptvnator/playback/util'; import { PlayerControlsComponent, type PlayerMediaTitle, diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.types.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.types.ts index 32361a32f..31cdff7bf 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.types.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.types.ts @@ -1,5 +1,5 @@ import type videoJs from 'video.js'; -import type { NativePlaybackErrorInput } from '../playback-diagnostics/playback-diagnostics.model'; +import type { NativePlaybackErrorInput } from '@iptvnator/playback/util'; export type VideoPlayerSource = { src: string; diff --git a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts index 63235b3e6..9f613b414 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts +++ b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts @@ -36,7 +36,7 @@ import { CrossPortalSimilarService, DownloadsService, } from '@iptvnator/services'; -import type { PlaybackFallbackRequest } from '../playback-diagnostics/playback-diagnostics.util'; +import type { PlaybackFallbackRequest } from '@iptvnator/playback/util'; import { PortalInlinePlayerComponent } from '../portal-inline-player/portal-inline-player.component'; import { createVodDownloadState } from './vod-download-state.util'; diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view-diagnostics.utils.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view-diagnostics.utils.ts index 284ab8575..966c437b8 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view-diagnostics.utils.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view-diagnostics.utils.ts @@ -2,7 +2,7 @@ import { type PlaybackDiagnostic, PlaybackDiagnosticCode, getLikelyBrowserUnsupportedCodecLabels, -} from '../playback-diagnostics/playback-diagnostics.util'; +} from '@iptvnator/playback/util'; export type PlaybackDiagnosticDetail = { readonly labelKey: string; diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.shared-controls.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.shared-controls.spec.ts index 71b8b09b3..d06a75c85 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.shared-controls.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.shared-controls.spec.ts @@ -29,7 +29,7 @@ import { type PlaybackDiagnostic, PlaybackDiagnosticCode, PlaybackDiagnosticSource, -} from '../playback-diagnostics/playback-diagnostics.util'; +} from '@iptvnator/playback/util'; jest.unstable_mockModule('video.js', () => ({ default: jest.fn(), diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts index ee0e90dd5..5d677add9 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts @@ -21,7 +21,7 @@ import { PlaybackDiagnostic, PlaybackDiagnosticCode, PlaybackDiagnosticSource, -} from '../playback-diagnostics/playback-diagnostics.util'; +} from '@iptvnator/playback/util'; jest.unstable_mockModule('video.js', () => ({ default: jest.fn(), diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts index b4c94525b..c5c7509da 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts @@ -45,7 +45,7 @@ import { type PlaybackDiagnosticCode, type PlaybackFallbackRequest, getPlaybackMediaExtensionFromUrl, -} from '../playback-diagnostics/playback-diagnostics.util'; +} from '@iptvnator/playback/util'; import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation'; import { VjsPlayerComponent } from '../vjs-player/vjs-player.component'; import { ElectronStreamHeadersService } from './electron-stream-headers.service'; diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts index 553ad2646..7e1980e55 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts @@ -1,5 +1,5 @@ import { Component, input, output } from '@angular/core'; -import type { PlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util'; +import type { PlaybackDiagnostic } from '@iptvnator/playback/util'; /** * Player stand-ins for WebPlayerViewComponent specs. They mirror the real diff --git a/libs/ui/playback/src/lib/web-video-support/browser-media-type-support.spec.ts b/libs/ui/playback/src/lib/web-video-support/browser-media-type-support.spec.ts new file mode 100644 index 000000000..2d518a3a5 --- /dev/null +++ b/libs/ui/playback/src/lib/web-video-support/browser-media-type-support.spec.ts @@ -0,0 +1,71 @@ +import { isBrowserMediaTypeSupported } from './browser-media-type-support'; + +describe('isBrowserMediaTypeSupported', () => { + let mediaSourceDescriptor: PropertyDescriptor | undefined; + + beforeEach(() => { + mediaSourceDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'MediaSource' + ); + }); + + afterEach(() => { + restoreMediaSource(mediaSourceDescriptor); + }); + + it('returns undefined when MediaSource is unavailable', () => { + Reflect.deleteProperty(globalThis, 'MediaSource'); + + expect(isBrowserMediaTypeSupported('video/mp4')).toBeUndefined(); + }); + + it.each([undefined, null, true, {}])( + 'returns undefined when isTypeSupported is not callable: %p', + (isTypeSupported) => { + setMediaSource({ isTypeSupported }); + + expect(isBrowserMediaTypeSupported('video/mp4')).toBeUndefined(); + } + ); + + it('returns undefined when the browser capability probe throws', () => { + const providerMimeType = + 'video/mp4; codecs="provider-controlled-codec"'; + setMediaSource({ + isTypeSupported: () => { + throw new Error(providerMimeType); + }, + }); + + expect(isBrowserMediaTypeSupported(providerMimeType)).toBeUndefined(); + }); + + it.each([true, false])( + 'returns the browser capability result %s', + (supportResult) => { + const isTypeSupported = jest.fn(() => supportResult); + setMediaSource({ isTypeSupported }); + + expect(isBrowserMediaTypeSupported('video/mp4')).toBe( + supportResult + ); + expect(isTypeSupported).toHaveBeenCalledWith('video/mp4'); + } + ); +}); + +function setMediaSource(value: unknown): void { + Object.defineProperty(globalThis, 'MediaSource', { + configurable: true, + value, + }); +} + +function restoreMediaSource(descriptor?: PropertyDescriptor): void { + if (descriptor) { + Object.defineProperty(globalThis, 'MediaSource', descriptor); + return; + } + Reflect.deleteProperty(globalThis, 'MediaSource'); +} diff --git a/libs/ui/playback/src/lib/web-video-support/browser-media-type-support.ts b/libs/ui/playback/src/lib/web-video-support/browser-media-type-support.ts new file mode 100644 index 000000000..23f611d84 --- /dev/null +++ b/libs/ui/playback/src/lib/web-video-support/browser-media-type-support.ts @@ -0,0 +1,16 @@ +export function isBrowserMediaTypeSupported( + mimeType: string +): boolean | undefined { + if (typeof MediaSource === 'undefined') { + return undefined; + } + + try { + const probe: unknown = MediaSource.isTypeSupported; + return typeof probe === 'function' + ? probe.call(MediaSource, mimeType) + : undefined; + } catch { + return undefined; + } +} diff --git a/libs/ui/playback/src/lib/web-video-support/web-video-shaka-controls.spec.ts b/libs/ui/playback/src/lib/web-video-support/web-video-shaka-controls.spec.ts index 6dcd6c642..251755462 100644 --- a/libs/ui/playback/src/lib/web-video-support/web-video-shaka-controls.spec.ts +++ b/libs/ui/playback/src/lib/web-video-support/web-video-shaka-controls.spec.ts @@ -1,4 +1,4 @@ -import { InlinePlaybackPlayer } from '../playback-diagnostics/playback-diagnostics.model'; +import { InlinePlaybackPlayer } from '@iptvnator/playback/util'; import { createFakeShakaEnvironment, flushShakaMicrotasks as flush, diff --git a/tsconfig.base.json b/tsconfig.base.json index 78aa9fdc2..c934e2bb9 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -32,6 +32,7 @@ "libs/portal/shared/util/src/index.ts" ], "@iptvnator/epg/data-access": ["libs/epg/data-access/src/index.ts"], + "@iptvnator/playback/util": ["libs/playback/util/src/index.ts"], "@iptvnator/playlist/shared/ui": [ "libs/playlist/shared/ui/src/index.ts" ],