From bd07e17857cca90fcdf572a497c0ffb254337c00 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:31:18 +0200 Subject: [PATCH] feat(m3u): DASH + ClearKey playback via Shaka Player (#1225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(m3u): extract ClearKey DRM from #KODIPROP playlist lines Adds the typed ChannelDrm model (shared interfaces) and a KODIPROP post-processing step in createPlaylistObject() — the single funnel for all four playlist import paths. Parses inputstream.adaptive.license_type, license_key and drm_legacy; ClearKey keys accepted as kid:key hex pairs, W3C ClearKey license JSON, or a plain kid→key JSON map. Unsupported license types (Widevine/PlayReady/license URLs) are preserved with supported=false so playback can surface a DRM diagnostic instead of failing silently. Also adds isDashStreamUrl/isDashChannel helpers for DASH routing. Co-Authored-By: Claude Fable 5 * feat(playback): add Shaka DASH source engine with ClearKey support Introduces ShakaVideoSession (libs/ui/playback/src/lib/shaka-engine/): a lazily imported shaka-player engine (separate lazy chunk, ~217 KB transfer) owning attach/configure/load with an operation queue and generation guard against channel-switch races. Channel ClearKey config maps to drm.clearKeys; channels with an unsupported license type emit a DrmOrEncryption diagnostic without starting an engine. Shaka errors are classified into the existing playback diagnostics (PlaybackDiagnosticSource.Shaka). Wires the engine into both built-in players like hls.js/mpegts.js: - HTML5: extension === 'mpd' branch in playChannel(); hls/mpegts/native glue extracted to helpers to keep the component within the size budget - ArtPlayer: customType 'mpd' in ArtPlayerSourceSession (+ getDrm seam) - Shared controls: WebVideoControlsSource kind 'shaka' + WebVideoShakaControls using the Shaka 5 text model (selectTextTrack(null) hides subtitles; Player.setTextTrackVisibility no longer exists) Adds a CJS shaka-player jest stub (video.js precedent) for web specs. Co-Authored-By: Claude Fable 5 * feat(m3u): route DASH channels to the inline Shaka-capable player DASH (.mpd) channels always play in a built-in web engine (radio precedent): external MPV/VLC cannot receive KODIPROP ClearKey configuration (VLC upstream #29465) and Video.js has no DASH bridge yet. - shouldShowInlinePlayer() bypasses the external-player setting for DASH - new shouldAutoLaunchExternalPlayer() guard consolidates the MPV/VLC auto-launch conditions in the m3u-state effects (incl. catch-up path) - the M3U page overrides the player for DASH channels: ArtPlayer stays ArtPlayer, everything else falls back to the HTML5 player - ChannelDrm is passed through ResolvedPortalPlayback into the synthetic player-view channel Co-Authored-By: Claude Fable 5 * test(e2e): add offline DASH ClearKey fixtures and e2e coverage Fixtures (apps/web-e2e/src/fixtures/dash/): ~4s VP9+Opus DASH, clear and CENC-encrypted variants with fixed synthetic ClearKey credentials. Content synthesized by ffmpeg; encryption done by Shaka Packager because ffmpeg's mp4 muxer writes senc-only metadata (Chromium needs saiz/saio) and cannot produce the subsample encryption the VP9 CENC binding requires. Generation script + README document regeneration. web-e2e (Chromium): import an M3U with KODIPROP ClearKey via raw text, verify encrypted and clear DASH actually play (currentTime advances, no diagnostic banner) and that an unsupported license type (Widevine) surfaces the DRM diagnostic. Fixtures are served through Playwright route interception with HTTP Range support; the Angular service worker is blocked since SW-routed requests bypass interception. electron-backend-e2e: the same happy path + negative against a local Range-aware fixture server — the automated proof that ClearKey EME works in the real Electron runtime (file:// secure context). Co-Authored-By: Claude Fable 5 * docs: document DASH + ClearKey playback architecture Co-Authored-By: Claude Fable 5 * fix(pwa): extract KODIPROP DRM on the web-backend /parse import path The web-backend keeps its own playlist builder for the PWA URL-import path, so the shared createPlaylistObject() DRM hook never ran there and encrypted DASH channels imported by URL reached Shaka without keys. Apply extractDrmFromRaw() in that builder too and cover the path with a regression test. Addresses Codex review on PR #1225. Co-Authored-By: Claude Fable 5 * fix(playback): interrupt stalled Shaka loads and destroy failed engines Two review findings on the ShakaVideoSession lifecycle: - stop()/start() now tear the current player down immediately instead of queueing the destroy behind the in-flight operation. Shaka's destroy() interrupts a pending load() (LOAD_INTERRUPTED), so a stalled manifest fetch can no longer wedge the operation chain and block the next channel start (Codex P1). - A rejected attach()/load() now destroys the failed player after emitting the diagnostic, so a non-functional engine never stays attached to the media element or exposed to the shared-controls bridge (Greptile P1). Regression tests cover both paths. The Shaka fakes are consolidated into a shared jest-free test double that mirrors the destroy-interrupts-load semantic, and the ArtPlayer source-session spec is split (fixtures + DASH cases) to stay within the max-lines lint budget. Co-Authored-By: Claude Fable 5 * fix(m3u): unify DASH URL detection with playback extension normalization isDashStreamUrl() used the simpler getStreamExtensionFromUrl(), so URLs the player engines classify as DASH (stream.MPD, ?ext=mpd, ?format=mpd) were not routed to the Shaka-capable inline player and lost their ClearKey metadata with Video.js or external players configured (Codex P2). The normalized getPlaybackMediaExtensionFromUrl() now lives in @iptvnator/shared/m3u-utils (re-exported unchanged from the playback lib) and both routing and engine selection share it. Co-Authored-By: Claude Fable 5 * chore(lint): satisfy CI lint and CodeQL in DASH support files - replace shell-built tar/npm commands with execFileSync arg arrays in the fixture generator (CodeQL: uncontrolled shell command) - give jest stub methods explicit bodies (no-empty-function) - compact the diagnostic label switches in WebPlayerViewComponent to stay under the max-lines budget Co-Authored-By: Claude Fable 5 * fix(playback): tear down the Shaka engine on critical error events too A non-recoverable Shaka error emitted after a successful load left the dead engine attached to the media element and exposed to the shared-controls bridge (Greptile P1, round 2). Critical error events now destroy the player right after the diagnostic is emitted, matching the load-failure path. Co-Authored-By: Claude Fable 5 * fix(m3u): honor DASH catch-up URLs and drop unusable DRM fallbacks Two Codex round-2 findings: - The inline-playback DASH gate only examined the channel URL, while the external-player guard checks the resolved catch-up URL — a replay that resolves to an .mpd manifest with MPV/VLC configured ended up with no player at all. The gate now uses the effective playback URL (activePlaybackUrl ?? channel.url). - The unsupported-DRM diagnostic advertised MPV/VLC fallback actions, but external players cannot receive the KODIPROP license config either — the diagnostic no longer recommends them. Co-Authored-By: Claude Fable 5 * fix(playback): suppress unusable external fallback for ClearKey DRM failures Runtime DRM errors on channels that carry KODIPROP ClearKey config (wrong or rotated keys) advertised MPV/VLC fallback actions, but external players never receive the license config — the fallback could only fail differently. DRM-classified diagnostics from such channels no longer recommend external players; clear channels keep the hint. Co-Authored-By: Claude Fable 5 * fix(m3u): symmetric DASH inline gate and lazy DRM for pre-upgrade playlists - The inline DASH gate is now true when either the channel or the resolved catch-up URL is DASH, mirroring the external-player guard — a .mpd channel whose catch-up resolves to .m3u8 no longer ends up with no player at all. - Playlists imported before the DRM feature carry no drm field, but the raw KODIPROP block survived in the stored items; the M3U page now falls back to extractDrmFromRaw(channel.raw) at playback time, so encrypted channels work without a re-import (Channel gains raw?). Co-Authored-By: Claude Fable 5 * docs: sync the DASH/Shaka contract across agent docs Mirrors the DASH/Shaka source-engine contract into AGENTS.md and adds Shaka to the shared web-video bridge descriptions in CLAUDE.md and the player-controls contract; documents the lazy raw-KODIPROP DRM fallback for pre-upgrade playlists in the M3U architecture doc. Co-Authored-By: Claude Fable 5 * fix(playback): reset the media element for rejected DRM and widen ClearKey fallback suppression - Switching from a playing stream to an unsupported-DRM DASH channel loads no new source, but play() still ran and the un-loaded element could resume the previous stream underneath the diagnostic banner. The HTML5 player now resets the element instead of playing. - Any inline failure on a KODIPROP ClearKey channel (manifest, codec, media, network — not just DRM-category errors) is unsolvable in MPV/VLC, which never receive the license config; the external fallback hint is now suppressed for all diagnostics of such channels. Co-Authored-By: Claude Fable 5 * fix(playback): restore suppressed DASH captions when the preference re-enables The Shaka bridge dropped the auto-selected text track with selectTextTrack(null) when showCaptions was off, but did not remember it — re-enabling the preference mid-session left captions permanently off (HLS/native bridges already restore). The session now remembers the suppressed track id and reselects it via the bridge's caption-state pass; suppression is also skipped when no track is active. Covered by session and new WebVideoShakaControls specs. Co-Authored-By: Claude Fable 5 * chore: retrigger CI GitHub Actions created no check suites for the last three pushes to this branch (third-party apps received the webhooks); an empty commit re-fires the push and pull_request events. Co-Authored-By: Claude Fable 5 * refactor(playback): split oversized Shaka session and HTML5 spec files CI lint enforces max-lines 400: extract ShakaTextTrackSuppression and the shaka-error helpers out of ShakaVideoSession, and move the DASH-specific HTML5 player test into its own spec. No behavior change. Co-Authored-By: Claude Fable 5 * ci: allow manual dispatch of the cross-platform E2E workflow GitHub stopped delivering push/pull_request events for this branch; workflow_dispatch provides a manual escape hatch (CI and build-and-make already have one). Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .github/workflows/e2e-tests.yaml | 1 + AGENTS.md | 12 +- CLAUDE.md | 15 +- .../src/dash-clearkey.e2e.ts | 172 ++++++++ .../src/app/web-backend-app.spec.ts | 44 ++ apps/web-backend/src/app/web-backend-app.ts | 19 +- apps/web-e2e/src/dash-clearkey.e2e.ts | 153 +++++++ apps/web-e2e/src/fixtures/dash/README.md | 49 +++ .../web-e2e/src/fixtures/dash/clear-audio.mp4 | Bin 0 -> 31528 bytes .../web-e2e/src/fixtures/dash/clear-video.mp4 | Bin 0 -> 73947 bytes apps/web-e2e/src/fixtures/dash/clear.mpd | 23 ++ .../src/fixtures/dash/clearkey-audio.mp4 | Bin 0 -> 33321 bytes .../src/fixtures/dash/clearkey-video.mp4 | Bin 0 -> 75668 bytes apps/web-e2e/src/fixtures/dash/clearkey.mpd | 31 ++ .../src/fixtures/dash/generate-fixture.mjs | 139 +++++++ apps/web/jest.config.ts | 1 + apps/web/src/test-stubs/shaka-player.js | 95 +++++ docs/architecture/m3u-playlist-module.md | 69 ++++ docs/architecture/player-controls-contract.md | 6 +- jest.web-esm.workspace.ts | 1 + libs/m3u-state/src/lib/effects.spec.ts | 100 ++++- libs/m3u-state/src/lib/effects.ts | 44 +- .../src/lib/external-player-payload.util.ts | 32 +- .../video-player/video-player.component.html | 6 +- .../video-player.component.spec.ts | 130 ++++++ .../video-player/video-player.component.ts | 36 ++ libs/shared/interfaces/src/index.ts | 1 + .../src/lib/channel-drm.interface.ts | 18 + .../interfaces/src/lib/channel.interface.ts | 5 + .../src/lib/parsed-playlist.interface.ts | 3 + .../src/lib/portal-playback.interface.ts | 2 + libs/shared/m3u-utils/src/index.ts | 3 + .../m3u-utils/src/lib/dash.utils.spec.ts | 36 ++ libs/shared/m3u-utils/src/lib/dash.utils.ts | 18 + .../m3u-utils/src/lib/kodiprop.utils.spec.ts | 194 +++++++++ .../m3u-utils/src/lib/kodiprop.utils.ts | 279 +++++++++++++ .../src/lib/playback-media-extension.util.ts | 127 ++++++ .../m3u-utils/src/lib/playlist.utils.spec.ts | 41 ++ .../m3u-utils/src/lib/playlist.utils.ts | 13 +- .../lib/art-player/art-player-setup.spec.ts | 11 + .../src/lib/art-player/art-player-setup.ts | 2 + .../art-player-source-session.dash.spec.ts | 85 ++++ ...art-player-source-session.spec-fixtures.ts | 191 +++++++++ .../art-player-source-session.spec.ts | 181 +------- .../art-player/art-player-source-session.ts | 34 ++ .../lib/art-player/art-player.component.ts | 1 + .../html-video-player-diagnostics.ts | 87 ++++ .../html-video-player.component.dash.spec.ts | 81 ++++ ....component.shared-controls.sources.spec.ts | 31 ++ ...component.shared-controls.spec-fixtures.ts | 2 +- .../html-video-player.component.ts | 173 ++++---- .../playback-diagnostics.model.ts | 1 + .../playback-diagnostics.util.ts | 41 +- .../playback-media-source.util.ts | 128 +----- .../shaka-error-classifier.spec.ts | 101 +++++ .../shaka-engine/shaka-error-classifier.ts | 173 ++++++++ .../lib/shaka-engine/shaka-module.types.ts | 75 ++++ .../shaka-engine/shaka-player-test-double.ts | 164 ++++++++ .../shaka-text-track-suppression.ts | 42 ++ .../shaka-engine/shaka-video-session.spec.ts | 294 +++++++++++++ .../lib/shaka-engine/shaka-video-session.ts | 388 ++++++++++++++++++ .../web-player-view-diagnostics.utils.ts | 2 + .../web-player-view.component.ts | 1 + .../exit-owned-fullscreen.util.ts | 23 ++ .../web-video-native-source.util.ts | 22 + .../web-video-shaka-controls.spec.ts | 81 ++++ .../web-video-shaka-controls.ts | 135 ++++++ .../web-video-source-controls.bridge.ts | 33 +- package.json | 1 + pnpm-lock.yaml | 145 ++++++- 70 files changed, 4185 insertions(+), 462 deletions(-) create mode 100644 apps/electron-backend-e2e/src/dash-clearkey.e2e.ts create mode 100644 apps/web-e2e/src/dash-clearkey.e2e.ts create mode 100644 apps/web-e2e/src/fixtures/dash/README.md create mode 100644 apps/web-e2e/src/fixtures/dash/clear-audio.mp4 create mode 100644 apps/web-e2e/src/fixtures/dash/clear-video.mp4 create mode 100644 apps/web-e2e/src/fixtures/dash/clear.mpd create mode 100644 apps/web-e2e/src/fixtures/dash/clearkey-audio.mp4 create mode 100644 apps/web-e2e/src/fixtures/dash/clearkey-video.mp4 create mode 100644 apps/web-e2e/src/fixtures/dash/clearkey.mpd create mode 100644 apps/web-e2e/src/fixtures/dash/generate-fixture.mjs create mode 100644 apps/web/src/test-stubs/shaka-player.js create mode 100644 libs/shared/interfaces/src/lib/channel-drm.interface.ts create mode 100644 libs/shared/m3u-utils/src/lib/dash.utils.spec.ts create mode 100644 libs/shared/m3u-utils/src/lib/dash.utils.ts create mode 100644 libs/shared/m3u-utils/src/lib/kodiprop.utils.spec.ts create mode 100644 libs/shared/m3u-utils/src/lib/kodiprop.utils.ts create mode 100644 libs/shared/m3u-utils/src/lib/playback-media-extension.util.ts create mode 100644 libs/ui/playback/src/lib/art-player/art-player-source-session.dash.spec.ts create mode 100644 libs/ui/playback/src/lib/art-player/art-player-source-session.spec-fixtures.ts create mode 100644 libs/ui/playback/src/lib/html-video-player/html-video-player-diagnostics.ts create mode 100644 libs/ui/playback/src/lib/html-video-player/html-video-player.component.dash.spec.ts create mode 100644 libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.spec.ts create mode 100644 libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.ts create mode 100644 libs/ui/playback/src/lib/shaka-engine/shaka-module.types.ts create mode 100644 libs/ui/playback/src/lib/shaka-engine/shaka-player-test-double.ts create mode 100644 libs/ui/playback/src/lib/shaka-engine/shaka-text-track-suppression.ts create mode 100644 libs/ui/playback/src/lib/shaka-engine/shaka-video-session.spec.ts create mode 100644 libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts create mode 100644 libs/ui/playback/src/lib/web-video-support/exit-owned-fullscreen.util.ts create mode 100644 libs/ui/playback/src/lib/web-video-support/web-video-native-source.util.ts create mode 100644 libs/ui/playback/src/lib/web-video-support/web-video-shaka-controls.spec.ts create mode 100644 libs/ui/playback/src/lib/web-video-support/web-video-shaka-controls.ts diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml index fe56b10ce..bacc48746 100644 --- a/.github/workflows/e2e-tests.yaml +++ b/.github/workflows/e2e-tests.yaml @@ -7,6 +7,7 @@ on: pull_request: branches: - master + workflow_dispatch: jobs: electron-e2e-tests: diff --git a/AGENTS.md b/AGENTS.md index 10c032ebf..180f023c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -160,10 +160,16 @@ Key files: commands are cancelled. Same-session IPC replies also yield to a broadcast snapshot received while the command was pending, preventing a successful recording acknowledgement from being rolled back by a stale reply. +- DASH (`.mpd`) sources play through a lazily imported Shaka Player source + 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`. See the CLAUDE.md "Video Players" feature entry and + `docs/architecture/m3u-playlist-module.md` ("DASH + ClearKey Playback"). - The built-in HTML5/hls.js player is the second guarded consumer. `HtmlVideoPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its neutral `web-video-support` bridge is shared - with ArtPlayer and owns HLS/native tracks, MPEG-TS VOD duration correction, + with ArtPlayer and owns HLS/Shaka(DASH)/native tracks, MPEG-TS VOD duration correction, caption preference, and source cleanup. `HtmlVideoElementSession` owns native video-event lifecycle, persisted volume, start-time/time/ended propagation, and legacy post-play caption @@ -186,10 +192,10 @@ Key files: path keeps the existing Video.js skin and legacy series navigation unchanged. - ArtPlayer is the fourth guarded consumer. `ArtPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns - HLS/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and + HLS/DASH(Shaka)/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and a destroyed-session guard for delayed `customType` callbacks, while `ArtPlayerVideoSession` owns native media/ArtPlayer events. Shared mode uses - authoritative live/VOD metadata, HLS/native tracks and caption preference, + authoritative live/VOD metadata, HLS/Shaka/native tracks and caption preference, MPEG-TS VOD duration correction, and reapplies app volume directly after ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled, and a transparent capture layer gives shared controls exclusive click and diff --git a/CLAUDE.md b/CLAUDE.md index 621f520b8..78d0595ae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -615,6 +615,19 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use **Video Players**: - Built-in web players: HTML5+hls.js, Video.js, and ArtPlayer +- DASH + ClearKey (M3U module): `.mpd` channels play through a lazily loaded + Shaka Player source engine inside the HTML5 and ArtPlayer components (no new + player in settings). ClearKey keys come from `#KODIPROP:inputstream.adaptive.*` + lines, post-processed into `Channel.drm` by `extractDrmFromRaw()` in + `libs/shared/m3u-utils` (hooked in `createPlaylistObject()`, covering all + import paths). DASH channels always play inline: `isDashChannel()` bypasses + the external-player setting (radio precedent) and routes Video.js/MPV/VLC/ + embedded-MPV users to the HTML5 player via `playerOverride` (ArtPlayer keeps + 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/`; details in + `docs/architecture/m3u-playlist-module.md` ("DASH + ClearKey Playback"). - External players: MPV, VLC (via IPC to Electron backend) - Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. macOS uses the libmpv render API in an `NSOpenGLView`; Windows uses in-process libmpv with `--wid` against an app-owned child `HWND`; Linux spawns an out-of-process `mpv --wid=` controlled over a JSON IPC socket (X11/XWayland only, requires system `mpv` on PATH; subtitles/speed/aspect/recording are not exported there). mpv's own screensaver inhibition does not apply to any of these paths, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Renderer bounds are CSS pixels; the service converts them to native units in the main process (`embedded-mpv-bounds.util.ts`: × page zoom everywhere, × display scale on Windows/Linux whose child windows are positioned in physical pixels; frame-copy bounds stay unscaled), and the session controller re-syncs bounds when `devicePixelRatio` changes. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`. - Embedded MPV frame-copy engine (experimental, macOS Apple Silicon + Linux @@ -746,7 +759,7 @@ engine` (restart required) or helper: `apps/electron-backend/native/helper/`; canonical packaging/runtime contracts: `docs/architecture/embedded-mpv-native.md` and `tools/embedded-mpv/README.md`. -- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. In fullscreen, `app-player-controls` shows a pointer-transparent media-title overlay at the top while controls are revealed (`mediaTitle` input: movie/channel/series name, plus an `S01E03` second line for episodes; series names flow from the detail views through `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`). Persisted `Settings.webPlayerSharedControls` is default-off, and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the next host without an application restart; an existing session never changes controls mode in place. Embedded MPV ignores the web-player preference: frame-copy always uses shared DOM controls through `EmbeddedMpvControlsAdapter`, native-view retains its compositor-safe legacy dock, and external MPV/VLC retain their own UI. The Embedded MPV host selects exactly one controls UI for its reported engine. `showControls=false` detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync, and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. The built-in HTML5/hls.js player is the second guarded consumer: `HtmlVideoPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`, while its neutral `web-video-support` bridge is shared with ArtPlayer and owns HLS/native tracks, MPEG-TS VOD duration correction, caption preference, and source cleanup. `HtmlVideoElementSession` owns native video-event lifecycle, persisted volume, start-time/time/ended propagation, and legacy post-play caption suppression. Video.js is the third guarded consumer: `VjsPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its bridge rebinds the current Tech video after `playerreset`, exposes source-stable audio/subtitle IDs, preserves caption preference and explicit subtitle-off state, and reads Video.js duration. Reset-driven raw MPEG-TS changes pause first, coalesce to the latest desired source, preserve actual volume across Video.js's reset, and restart when authoritative live/VOD metadata changes. In shared-controls mode, Video.js native controls, click/double-click/hotkey actions, and spatial navigation are disabled. ArtPlayer is the fourth guarded consumer: `ArtPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns HLS/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and a destroyed-session guard for delayed `customType` callbacks, while `ArtPlayerVideoSession` owns native media/ArtPlayer events. Shared ArtPlayer mode uses authoritative live/VOD metadata, HLS/native tracks and caption preference, MPEG-TS VOD duration correction, and reapplies app volume directly after ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled, and a transparent capture layer gives shared controls exclusive click and double-click ownership. `WebPlayerViewComponent.resolvedIsLive` supplies authoritative metadata; visible playback diagnostics disable shared pointer/keyboard ownership and exit only the active HTML5, Video.js, or ArtPlayer shell's own fullscreen so retry/fallback actions remain visible. On the preference-off path, all three web players retain their existing controls, source behavior, and legacy series navigation. Contract: `docs/architecture/player-controls-contract.md`. +- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. In fullscreen, `app-player-controls` shows a pointer-transparent media-title overlay at the top while controls are revealed (`mediaTitle` input: movie/channel/series name, plus an `S01E03` second line for episodes; series names flow from the detail views through `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`). Persisted `Settings.webPlayerSharedControls` is default-off, and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the next host without an application restart; an existing session never changes controls mode in place. Embedded MPV ignores the web-player preference: frame-copy always uses shared DOM controls through `EmbeddedMpvControlsAdapter`, native-view retains its compositor-safe legacy dock, and external MPV/VLC retain their own UI. The Embedded MPV host selects exactly one controls UI for its reported engine. `showControls=false` detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync, and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. The built-in HTML5/hls.js player is the second guarded consumer: `HtmlVideoPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`, while its neutral `web-video-support` bridge is shared with ArtPlayer and owns HLS/Shaka(DASH)/native tracks, MPEG-TS VOD duration correction, caption preference, and source cleanup. `HtmlVideoElementSession` owns native video-event lifecycle, persisted volume, start-time/time/ended propagation, and legacy post-play caption suppression. Video.js is the third guarded consumer: `VjsPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its bridge rebinds the current Tech video after `playerreset`, exposes source-stable audio/subtitle IDs, preserves caption preference and explicit subtitle-off state, and reads Video.js duration. Reset-driven raw MPEG-TS changes pause first, coalesce to the latest desired source, preserve actual volume across Video.js's reset, and restart when authoritative live/VOD metadata changes. In shared-controls mode, Video.js native controls, click/double-click/hotkey actions, and spatial navigation are disabled. ArtPlayer is the fourth guarded consumer: `ArtPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns HLS/DASH(Shaka)/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and a destroyed-session guard for delayed `customType` callbacks, while `ArtPlayerVideoSession` owns native media/ArtPlayer events. Shared ArtPlayer mode uses authoritative live/VOD metadata, HLS/Shaka/native tracks and caption preference, MPEG-TS VOD duration correction, and reapplies app volume directly after ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled, and a transparent capture layer gives shared controls exclusive click and double-click ownership. `WebPlayerViewComponent.resolvedIsLive` supplies authoritative metadata; visible playback diagnostics disable shared pointer/keyboard ownership and exit only the active HTML5, Video.js, or ArtPlayer shell's own fullscreen so retry/fallback actions remain visible. On the preference-off path, all three web players retain their existing controls, source behavior, and legacy series navigation. Contract: `docs/architecture/player-controls-contract.md`. - Shared web picture-in-picture stays inside that default-off rollout. `PlayerController` exposes capability `pictureInPicture`, state `pictureInPictureActive`/`canPictureInPicture`, and command diff --git a/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts b/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts new file mode 100644 index 000000000..553708db6 --- /dev/null +++ b/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts @@ -0,0 +1,172 @@ +import { createServer, Server } from 'http'; +import { readFileSync } from 'fs'; +import { basename, join } from 'path'; +import { + channelItemByTitle, + closeElectronApp, + expect, + launchElectronApp, + LaunchedElectronApp, + openAddPlaylistDialog, + test, + waitForM3uCatalog, + workspaceRoot, +} from './electron-test-fixtures'; + +/** + * DASH + ClearKey playback in the real Electron runtime — the only automated + * proof that ClearKey EME works in the packaged `file://` renderer (secure + * context). Uses the shared offline fixtures from apps/web-e2e/src/fixtures. + */ + +const FIXTURE_DIR = join(workspaceRoot, 'apps/web-e2e/src/fixtures/dash'); + +const CLEARKEY_KID = '00112233445566778899aabbccddeeff'; +const CLEARKEY_KEY = 'ffeeddccbbaa99887766554433221100'; + +type DashFixtureServer = { + close: () => Promise; + origin: string; +}; + +/** Serves the DASH fixture directory with HTTP Range support (Shaka fetches + * init segments and the sidx via byte ranges). */ +async function startDashFixtureServer(): Promise { + const server: Server = createServer((request, response) => { + const pathname = (request.url ?? '').split('?')[0]; + const fileName = basename(pathname); + let body: Buffer; + try { + body = readFileSync(join(FIXTURE_DIR, fileName)); + } catch { + response.writeHead(404); + response.end('not found'); + return; + } + + const contentType = fileName.endsWith('.mpd') + ? 'application/dash+xml' + : 'video/mp4'; + const range = /bytes=(\d+)-(\d+)?/.exec( + request.headers.range ?? '' + ); + if (!range) { + response.writeHead(200, { + 'Content-Type': contentType, + 'Accept-Ranges': 'bytes', + 'Content-Length': body.length, + }); + response.end(body); + return; + } + + const start = Number(range[1]); + const end = range[2] ? Number(range[2]) : body.length - 1; + const chunk = body.subarray(start, end + 1); + response.writeHead(206, { + 'Content-Type': contentType, + 'Accept-Ranges': 'bytes', + 'Content-Range': `bytes ${start}-${end}/${body.length}`, + 'Content-Length': chunk.length, + }); + response.end(chunk); + }); + + await new Promise((resolvePromise, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.off('error', reject); + resolvePromise(); + }); + }); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Failed to resolve dash fixture server address.'); + } + + return { + origin: `http://127.0.0.1:${address.port}`, + close: () => + new Promise((resolvePromise, reject) => { + server.close((error) => + error ? reject(error) : resolvePromise() + ); + }), + }; +} + +function buildDashPlaylist(origin: string): string { + return [ + '#EXTM3U', + '#EXTINF:-1 tvg-id="ck-dash" group-title="DASH",ClearKey DASH', + '#KODIPROP:inputstream.adaptive.license_type=clearkey', + `#KODIPROP:inputstream.adaptive.license_key=${CLEARKEY_KID}:${CLEARKEY_KEY}`, + `${origin}/clearkey.mpd`, + '#EXTINF:-1 tvg-id="wv-dash" group-title="DASH",Widevine DASH', + '#KODIPROP:inputstream.adaptive.license_type=com.widevine.alpha', + '#KODIPROP:inputstream.adaptive.license_key=https://license.example.com/wv', + `${origin}/clearkey.mpd`, + ].join('\n'); +} + +async function importDashPlaylistFromText( + app: LaunchedElectronApp, + playlist: string +): Promise { + await openAddPlaylistDialog(app.mainWindow); + const dialog = app.mainWindow.locator('mat-dialog-container').last(); + await dialog.getByRole('radio', { name: /Raw m3u text/i }).click(); + await dialog.locator('textarea').fill(playlist); + await dialog.getByRole('button', { name: 'Import', exact: true }).click(); + await dialog.waitFor({ state: 'detached' }); + await waitForM3uCatalog(app.mainWindow); +} + +test('@electron @dash ClearKey DASH plays inline and unsupported DRM surfaces a diagnostic', async ({ + dataDir, +}) => { + const fixtureServer = await startDashFixtureServer(); + const app = await launchElectronApp(dataDir); + + try { + await importDashPlaylistFromText( + app, + buildDashPlaylist(fixtureServer.origin) + ); + + // Happy path: ClearKey EME decrypts and playback advances. + await channelItemByTitle(app.mainWindow, 'ClearKey DASH') + .first() + .click(); + const video = app.mainWindow + .locator('app-web-player-view video') + .first(); + await expect(video).toBeVisible({ timeout: 15_000 }); + await expect + .poll( + () => + video.evaluate( + (element: HTMLVideoElement) => element.currentTime + ), + { timeout: 20_000 } + ) + .toBeGreaterThan(0.5); + await expect( + app.mainWindow.getByTestId('playback-diagnostic-banner') + ).toBeHidden(); + + // Negative: an unsupported license type must not crash — it shows the + // DRM diagnostic instead. + await channelItemByTitle(app.mainWindow, 'Widevine DASH') + .first() + .click(); + const banner = app.mainWindow.getByTestId( + 'playback-diagnostic-banner' + ); + await expect(banner).toBeVisible({ timeout: 15_000 }); + await expect(banner).toContainText(/encrypted or DRM-protected/i); + } finally { + await closeElectronApp(app); + await fixtureServer.close(); + } +}); diff --git a/apps/web-backend/src/app/web-backend-app.spec.ts b/apps/web-backend/src/app/web-backend-app.spec.ts index e9ab7f2a8..83f66ed29 100644 --- a/apps/web-backend/src/app/web-backend-app.spec.ts +++ b/apps/web-backend/src/app/web-backend-app.spec.ts @@ -196,6 +196,50 @@ https://stream.example/news.m3u8`); ); }); + it('extracts KODIPROP ClearKey DRM on the /parse URL-import path', async () => { + const httpClient = new StubHttpClient(); + const kid = '00112233445566778899aabbccddeeff'; + const key = 'ffeeddccbbaa99887766554433221100'; + httpClient.queueResponse(`#EXTM3U +#EXTINF:-1 tvg-id="ck" group-title="DASH",Encrypted DASH +#KODIPROP:inputstream.adaptive.license_type=clearkey +#KODIPROP:inputstream.adaptive.license_key=${kid}:${key} +https://stream.example/enc.mpd +#EXTINF:-1 tvg-id="plain",Plain Channel +https://stream.example/live.m3u8`); + + await withServer( + createWebBackendApp({ + clientOrigins: ['http://localhost:4200'], + guid: () => 'fixed-id', + httpClient, + resolveHostname: resolvePublicHost, + }), + async (baseUrl) => { + const targetId = await registerProviderTarget( + baseUrl, + 'https://provider.example/drm.m3u' + ); + const response = await fetch( + `${baseUrl}/parse?targetId=${targetId}`, + { headers: { Origin: 'http://localhost:4200' } } + ); + const body = (await response.json()) as { + playlist: { items: Array> }; + }; + + expect(response.status).toBe(200); + expect(body.playlist.items).toHaveLength(2); + expect(body.playlist.items[0]['drm']).toEqual({ + licenseType: 'clearkey', + supported: true, + clearKeys: { [kid]: key }, + }); + expect(body.playlist.items[1]['drm']).toBeUndefined(); + } + ); + }); + it('allows browser preflight checks for provider target registration', async () => { await withServer( createWebBackendApp({ diff --git a/apps/web-backend/src/app/web-backend-app.ts b/apps/web-backend/src/app/web-backend-app.ts index 32bd75abe..1ccbba9c9 100644 --- a/apps/web-backend/src/app/web-backend-app.ts +++ b/apps/web-backend/src/app/web-backend-app.ts @@ -8,6 +8,7 @@ import axios from 'axios'; import epgParser from 'epg-parser'; import parser from 'iptv-playlist-parser'; import { normalizeXtreamServerUrl } from '@iptvnator/shared/interfaces'; +import { extractDrmFromRaw } from '@iptvnator/shared/m3u-utils'; export interface WebBackendHttpGetOptions { readonly headers?: Record; @@ -514,10 +515,20 @@ function createPlaylistObject(options: { count: options.playlist.items.length, playlist: { ...options.playlist, - items: options.playlist.items.map((item) => ({ - id: options.guid(), - ...item, - })), + items: options.playlist.items.map((item) => { + // Keep this builder aligned with the shared + // createPlaylistObject() in @iptvnator/shared/m3u-utils: + // KODIPROP ClearKey DRM must survive the /parse URL-import + // path too. + const drm = extractDrmFromRaw( + typeof item['raw'] === 'string' ? item['raw'] : undefined + ); + return { + id: options.guid(), + ...item, + ...(drm ? { drm } : {}), + }; + }), }, importDate: timestamp, lastUsage: timestamp, diff --git a/apps/web-e2e/src/dash-clearkey.e2e.ts b/apps/web-e2e/src/dash-clearkey.e2e.ts new file mode 100644 index 000000000..280b1631a --- /dev/null +++ b/apps/web-e2e/src/dash-clearkey.e2e.ts @@ -0,0 +1,153 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { Page } from '@playwright/test'; +import { expect, test } from './fixtures'; + +/** + * DASH + ClearKey playback (offline fixture, no network). + * + * The fixture host is virtual: every request to it is fulfilled from + * `fixtures/dash/` via route interception, including HTTP Range requests + * (Shaka fetches the init segment and sidx via byte ranges). + */ + +const FIXTURE_DIR = join(__dirname, 'fixtures/dash'); +const FIXTURE_HOST = 'https://dash-fixture.local'; + +const CLEARKEY_KID = '00112233445566778899aabbccddeeff'; +const CLEARKEY_KEY = 'ffeeddccbbaa99887766554433221100'; + +const DASH_PLAYLIST = [ + '#EXTM3U', + '#EXTINF:-1 tvg-id="ck-dash" group-title="DASH",ClearKey DASH', + '#KODIPROP:inputstream.adaptive.license_type=clearkey', + `#KODIPROP:inputstream.adaptive.license_key=${CLEARKEY_KID}:${CLEARKEY_KEY}`, + `${FIXTURE_HOST}/clearkey.mpd`, + '#EXTINF:-1 tvg-id="clear-dash" group-title="DASH",Clear DASH', + `${FIXTURE_HOST}/clear.mpd`, + '#EXTINF:-1 tvg-id="wv-dash" group-title="DASH",Widevine DASH', + '#KODIPROP:inputstream.adaptive.license_type=com.widevine.alpha', + '#KODIPROP:inputstream.adaptive.license_key=https://license.example.com/wv', + `${FIXTURE_HOST}/clearkey.mpd`, +].join('\n'); + +// The inline player starts playback programmatically; without this flag the +// bundled Chromium blocks play() before a user gesture reaches the video. +// The Angular service worker must be blocked: requests going through it +// bypass Playwright route interception, so the virtual fixture host would +// never resolve. +test.use({ + launchOptions: { + args: ['--autoplay-policy=no-user-gesture-required'], + }, + serviceWorkers: 'block', +}); + +// ClearKey EME + VP9 support is only deterministic in Chromium among the +// bundled Playwright browsers (WebKit lacks ClearKey); the Electron e2e suite +// covers the real desktop runtime. +test.skip( + ({ browserName }) => browserName !== 'chromium', + 'DASH ClearKey coverage targets Chromium' +); + +async function serveDashFixtures(page: Page): Promise { + await page.route(`${FIXTURE_HOST}/**`, async (route) => { + const url = new URL(route.request().url()); + let body: Buffer; + try { + body = readFileSync(join(FIXTURE_DIR, url.pathname.slice(1))); + } catch { + await route.fulfill({ status: 404, body: 'not found' }); + return; + } + + const contentType = url.pathname.endsWith('.mpd') + ? 'application/dash+xml' + : 'video/mp4'; + const rangeHeader = route.request().headers()['range']; + const range = rangeHeader + ? /bytes=(\d+)-(\d+)?/.exec(rangeHeader) + : null; + if (!range) { + await route.fulfill({ + status: 200, + headers: { + 'Content-Type': contentType, + 'Accept-Ranges': 'bytes', + }, + body, + }); + return; + } + + const start = Number(range[1]); + const end = range[2] ? Number(range[2]) : body.length - 1; + await route.fulfill({ + status: 206, + headers: { + 'Content-Type': contentType, + 'Accept-Ranges': 'bytes', + 'Content-Range': `bytes ${start}-${end}/${body.length}`, + }, + body: body.subarray(start, end + 1), + }); + }); +} + +async function importDashPlaylist(page: Page): Promise { + await page.goto('/'); + await page.getByRole('button', { name: 'Add playlist' }).click(); + const dialog = page.locator('mat-dialog-container'); + await expect(dialog).toBeVisible(); + await dialog.getByRole('radio', { name: /Raw m3u text/i }).click(); + await dialog.locator('textarea').fill(DASH_PLAYLIST); + await Promise.all([ + page.waitForURL(/\/workspace\/playlists\/.+\/all$/), + dialog.getByRole('button', { name: 'Import', exact: true }).click(), + ]); + await expect(page.getByText('3 channels')).toBeVisible(); +} + +async function expectVideoPlaying(page: Page): Promise { + const video = page.locator('app-web-player-view video').first(); + await expect(video).toBeVisible({ timeout: 15_000 }); + await expect + .poll( + () => + video.evaluate( + (element: HTMLVideoElement) => element.currentTime + ), + { timeout: 20_000 } + ) + .toBeGreaterThan(0.5); + await expect( + page.locator('[data-test-id="playback-diagnostic-banner"]') + ).toBeHidden(); +} + +test('@web @m3u @dash ClearKey and clear DASH channels play inline', async ({ + page, +}) => { + await serveDashFixtures(page); + await importDashPlaylist(page); + + await page.getByText('1. ClearKey DASH').click(); + await expectVideoPlaying(page); + + await page.getByText('2. Clear DASH').click(); + await expectVideoPlaying(page); +}); + +test('@web @m3u @dash unsupported DRM shows the encryption diagnostic', async ({ + page, +}) => { + await serveDashFixtures(page); + await importDashPlaylist(page); + + await page.getByText('3. Widevine DASH').click(); + + const banner = page.locator('[data-test-id="playback-diagnostic-banner"]'); + await expect(banner).toBeVisible({ timeout: 15_000 }); + await expect(banner).toContainText(/encrypted or DRM-protected/i); +}); diff --git a/apps/web-e2e/src/fixtures/dash/README.md b/apps/web-e2e/src/fixtures/dash/README.md new file mode 100644 index 000000000..c7d11c27e --- /dev/null +++ b/apps/web-e2e/src/fixtures/dash/README.md @@ -0,0 +1,49 @@ +# Offline DASH ClearKey fixtures + +Deterministic ~4 s DASH assets used by the DASH/ClearKey e2e suites +(`apps/web-e2e/src/dash-clearkey.e2e.ts` and the Electron equivalent): + +| File | Content | +| -------------------- | ---------------------------------------------------- | +| `clearkey-video.mp4` | VP9 video, CENC (`cenc` AES-CTR, subsample) encrypted | +| `clearkey-audio.mp4` | Opus audio, CENC encrypted | +| `clearkey.mpd` | Static on-demand MPD for the encrypted pair | +| `clear-video.mp4` | VP9 video, unencrypted (clear-DASH regression case) | +| `clear-audio.mp4` | Opus audio, unencrypted | +| `clear.mpd` | Static on-demand MPD for the clear pair | + +Fixed ClearKey test credentials (obviously synthetic, safe to commit): + +- KID: `00112233445566778899aabbccddeeff` +- KEY: `ffeeddccbbaa99887766554433221100` + +They correspond to the `#KODIPROP:inputstream.adaptive.license_key=KID:KEY` +value used by the e2e playlists. + +**Why VP9+Opus:** Playwright's bundled Chromium ships without proprietary +codecs (no H.264/AAC), while royalty-free VP9/Opus decode both there and in +Electron — one fixture serves both suites. + +**Why Shaka Packager for encryption:** ffmpeg's mp4 muxer only writes `senc` +sample-encryption metadata — Chromium's demuxer requires `saiz`/`saio` and +fails with `CHUNK_DEMUXER_ERROR_APPEND_FAILED: Sample encryption info is not +available`. ffmpeg also cannot produce the subsample encryption that the VP9 +CENC binding mandates (a fully-encrypted VP9 track ends in +`MEDIA_ERR_DECODE`). Shaka Packager produces spec-compliant output for both. + +## Regeneration + +```bash +node apps/web-e2e/src/fixtures/dash/generate-fixture.mjs +``` + +Requires `ffmpeg` (tested with 7.x) with `libvpx-vp9` and `libopus`. Shaka +Packager is resolved in this order: the `SHAKA_PACKAGER` env var (path to a +`packager` binary), an installed `shaka-packager` npm package, otherwise the +script fetches the official npm package (prebuilt per-platform binaries) once +into a temp directory via `npm pack`. + +The script synthesizes the content with ffmpeg (`testsrc2` + `sine`, clear +master), then packages both variants with Shaka Packager, which also writes +the MPDs. Byte-exact output across tool versions is not guaranteed; the +committed files are the source of truth. diff --git a/apps/web-e2e/src/fixtures/dash/clear-audio.mp4 b/apps/web-e2e/src/fixtures/dash/clear-audio.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..9531d3301038d9ac39fd732c34e559922e03a689 GIT binary patch literal 31528 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 Ka&s)#$^ReU;N0c_ literal 0 HcmV?d00001 diff --git a/apps/web-e2e/src/fixtures/dash/clear-video.mp4 b/apps/web-e2e/src/fixtures/dash/clear-video.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..5ace4324e7bc98fead1ab5a59639cf5ac6c837f1 GIT binary patch literal 73947 zcmZ_01zg@t)VF!#?oiy_-5rX%7I$}dcXufiFYXQnio3hJTXBcN(w1kxci-KeAIWw8 zXJ(SgnaCt}0ssJ@FmZCXv$kVl1iUA6M_Z2f$@-5r(swj-v18ygv^FsW0D#}DZEaoN zi&$B^m>K;kyYH_`^k)J982(5A0O;@EKl?-fAMr2$e`F%jI7=_=Y65z*5)=Q?*@s{KjIiU82{0K(x2wQIy+bqeAxYt zP6k%*wuhsWQpj>3?m)yF=i;`Qq<6f2KbP2wGKMm_XR}eTAm~JpX%+ z@%HxaLUpk-6#lz7m;hklKOGMMAUZlZIsUc&&EB=+`)>GuC)9WC@qep-#(Nn<+yCK1 zb+P^%zW>=$4r>=NO*ZJeeaQfp120*{t|8rf4P9{c9 ze;4t027>D3;A{f`fd46@_g??KCr~#2-n!;v>*A=9~AmWwDy1P2ZsOf%X~2LgIpgR z{-E&(e|>Q8gSP(&9{B&%K#L#R?}J}H=<-2|e+2ul9_oib>Vu9SboyY|2S3`0{--bA z6L9c{Kjwo!Kj{6z&JX5)@ce_PA2j$!aLj)M+<*A1KB)Ua)(_}o_(wj?hd%t! zpFYU*;qUoJu;dTj@R6_jq5D4=`{Bp`(CvQ-ghgX*r0*0U5hw&exqx8<)B<0Ip$^Wd(r8b^Rity`bl

+@~e?M>zKjeY2*H`&K$ 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 PiH1@ZCag8q^8x~Zkh-J` literal 0 HcmV?d00001 diff --git a/apps/web-e2e/src/fixtures/dash/clear.mpd b/apps/web-e2e/src/fixtures/dash/clear.mpd new file mode 100644 index 000000000..9e97a8489 --- /dev/null +++ b/apps/web-e2e/src/fixtures/dash/clear.mpd @@ -0,0 +1,23 @@ + + + + + + + clear-video.mp4 + + + + + + + + + clear-audio.mp4 + + + + + + + diff --git a/apps/web-e2e/src/fixtures/dash/clearkey-audio.mp4 b/apps/web-e2e/src/fixtures/dash/clearkey-audio.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..c3fee3c152f7bfaba5d079fdc3f09b59b105424a GIT binary patch literal 33321 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 z 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

_9q?k#K2aUwV#zKIuonvtEFK}$MmnCQr(`h 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/AAAAAA== + + + clearkey-video.mp4 + + + + + + + + + AAAANHBzc2gBAAAAEHfv7MCyTQKs4zweUuL7SwAAAAEAESIzRFVmd4iZqrvM3e7/AAAAAA== + + + + clearkey-audio.mp4 + + + + + + + diff --git a/apps/web-e2e/src/fixtures/dash/generate-fixture.mjs b/apps/web-e2e/src/fixtures/dash/generate-fixture.mjs new file mode 100644 index 000000000..f6522f0dd --- /dev/null +++ b/apps/web-e2e/src/fixtures/dash/generate-fixture.mjs @@ -0,0 +1,139 @@ +#!/usr/bin/env node +/** + * Regenerates the offline DASH ClearKey fixtures used by the DASH e2e suites. + * + * Produces, next to this script: + * - clearkey-video.mp4 / clearkey-audio.mp4 / clearkey.mpd — + * CENC (`cenc` AES-CTR) encrypted VP9 video + Opus audio. Encryption is + * done by Shaka Packager because ffmpeg's mp4 muxer only writes `senc` + * sample-encryption metadata (Chromium requires `saiz`/`saio`) and cannot + * produce the subsample encryption that the VP9 CENC binding mandates. + * - clear-video.mp4 / clear-audio.mp4 / clear.mpd — same content, clear. + * + * VP9+Opus is deliberate: Playwright's bundled Chromium ships no proprietary + * codecs (H.264/AAC), while royalty-free codecs work there and in Electron. + * + * Requirements: + * - ffmpeg (tested with 7.x) built with libvpx-vp9 and libopus + * - Shaka Packager: either set SHAKA_PACKAGER=/path/to/packager, install + * the `shaka-packager` npm package, or let this script fetch it once via + * `npm pack shaka-packager` into a temp directory (official Google + * package with prebuilt per-platform binaries). + * + * Byte-exact output across tool versions is not guaranteed — the committed + * files are the source of truth for CI. + * + * Usage: node apps/web-e2e/src/fixtures/dash/generate-fixture.mjs + */ + +import { execFileSync } from 'node:child_process'; +import { chmodSync, mkdtempSync, readdirSync, rmSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const OUT_DIR = dirname(fileURLToPath(import.meta.url)); + +/** Fixed, obviously synthetic 128-bit ClearKey test credentials. */ +export const CLEARKEY_KID = '00112233445566778899aabbccddeeff'; +export const CLEARKEY_KEY = 'ffeeddccbbaa99887766554433221100'; + +const DURATION_SECONDS = 4; +const FRAME_RATE = 24; + +const masterPath = join(OUT_DIR, 'content-master.tmp.mp4'); +const packager = resolvePackager(); + +try { + // 1. Synthesize the clear master (muxed VP9+Opus). + execFileSync( + 'ffmpeg', + [ + '-y', + '-f', 'lavfi', '-i', + `testsrc2=duration=${DURATION_SECONDS}:size=320x180:rate=${FRAME_RATE}`, + '-f', 'lavfi', '-i', + `sine=frequency=440:duration=${DURATION_SECONDS}`, + '-c:v', 'libvpx-vp9', '-b:v', '150k', '-g', String(FRAME_RATE), + '-c:a', 'libopus', '-b:a', '48k', + masterPath, + ], + { stdio: ['ignore', 'ignore', 'inherit'] } + ); + + // 2. Encrypted variant (subsample CENC for VP9, on-demand profile MPD). + runPackager([ + `in=${masterPath},stream=video,output=${join(OUT_DIR, 'clearkey-video.mp4')},drm_label=CK`, + `in=${masterPath},stream=audio,output=${join(OUT_DIR, 'clearkey-audio.mp4')},drm_label=CK`, + '--enable_raw_key_encryption', + '--keys', `label=CK:key_id=${CLEARKEY_KID}:key=${CLEARKEY_KEY}`, + '--clear_lead', '0', + '--protection_scheme', 'cenc', + '--mpd_output', join(OUT_DIR, 'clearkey.mpd'), + ]); + console.log(`clearkey.mpd: CENC VP9+Opus (kid=${CLEARKEY_KID})`); + + // 3. Clear variant. + runPackager([ + `in=${masterPath},stream=video,output=${join(OUT_DIR, 'clear-video.mp4')}`, + `in=${masterPath},stream=audio,output=${join(OUT_DIR, 'clear-audio.mp4')}`, + '--mpd_output', join(OUT_DIR, 'clear.mpd'), + ]); + console.log('clear.mpd: clear VP9+Opus'); +} finally { + rmSync(masterPath, { force: true }); +} + +function runPackager(args) { + execFileSync(packager.command, [...packager.prefixArgs, ...args], { + stdio: ['ignore', 'ignore', 'inherit'], + }); +} + +function resolvePackager() { + if (process.env.SHAKA_PACKAGER) { + return { command: process.env.SHAKA_PACKAGER, prefixArgs: [] }; + } + + try { + const launcher = createRequire(import.meta.url).resolve( + 'shaka-packager' + ); + return { command: process.execPath, prefixArgs: [launcher] }; + } catch { + return fetchPackagerViaNpmPack(); + } +} + +/** One-off fetch of the official npm package with prebuilt binaries. */ +function fetchPackagerViaNpmPack() { + const workDir = mkdtempSync(join(tmpdir(), 'shaka-packager-')); + console.log(`Fetching shaka-packager via npm pack into ${workDir} ...`); + execFileSync('npm', ['pack', 'shaka-packager', '--silent'], { + cwd: workDir, + stdio: ['ignore', 'ignore', 'inherit'], + }); + const tarball = readdirSync(workDir).find((name) => + name.endsWith('.tgz') + ); + if (!tarball) { + throw new Error('npm pack did not produce a shaka-packager tarball.'); + } + execFileSync('tar', ['-xzf', tarball], { cwd: workDir }); + + const binaryName = { + darwin: { arm64: 'packager-osx-arm64', x64: 'packager-osx-x64' }, + linux: { arm64: 'packager-linux-arm64', x64: 'packager-linux-x64' }, + win32: { x64: 'packager-win-x64.exe' }, + }[process.platform]?.[process.arch]; + if (!binaryName) { + throw new Error( + `No shaka-packager binary for ${process.platform}/${process.arch}; set SHAKA_PACKAGER manually.` + ); + } + + const binaryPath = join(workDir, 'package', 'bin', binaryName); + chmodSync(binaryPath, 0o755); + return { command: binaryPath, prefixArgs: [] }; +} diff --git a/apps/web/jest.config.ts b/apps/web/jest.config.ts index b6ba9eba2..fe4335511 100644 --- a/apps/web/jest.config.ts +++ b/apps/web/jest.config.ts @@ -39,6 +39,7 @@ export default { tslib: 'tslib/tslib.es6.js', '^iptv-playlist-parser$': '/src/test-stubs/iptv-playlist-parser.mjs', + '^shaka-player$': '/src/test-stubs/shaka-player.js', '^video.js$': '/src/test-stubs/video-js.js', '^rxjs': '/../../node_modules/rxjs/dist/bundles/rxjs.umd.js', '^uuid$': '/../../node_modules/uuid/wrapper.mjs', diff --git a/apps/web/src/test-stubs/shaka-player.js b/apps/web/src/test-stubs/shaka-player.js new file mode 100644 index 000000000..05d80083b --- /dev/null +++ b/apps/web/src/test-stubs/shaka-player.js @@ -0,0 +1,95 @@ +/** + * CJS test stub for the lazily imported `shaka-player` module (the real + * compiled bundle is huge and ESM-hostile under jest). Mirrors the surface in + * libs/ui/playback/src/lib/shaka-engine/shaka-module.types.ts. + * + * Specs can inspect `shaka.__instances` / reset with `shaka.__reset()`. + */ +class MockShakaPlayer { + constructor() { + this.configureCalls = []; + this.loadedUrls = []; + this.attachedTo = null; + this.destroyCount = 0; + this.selectTextTrackCalls = []; + this.listeners = new Map(); + shaka.__instances.push(this); + } + + attach(mediaElement) { + this.attachedTo = mediaElement; + return Promise.resolve(); + } + + configure(config) { + this.configureCalls.push(config); + return true; + } + + load(assetUri) { + this.loadedUrls.push(assetUri); + return Promise.resolve(); + } + + destroy() { + this.destroyCount += 1; + return Promise.resolve(); + } + + addEventListener(type, listener) { + const set = this.listeners.get(type) || new Set(); + set.add(listener); + this.listeners.set(type, set); + } + + removeEventListener(type, listener) { + const set = this.listeners.get(type); + if (set) { + set.delete(listener); + } + } + + dispatch(type, detail) { + for (const listener of this.listeners.get(type) || []) { + listener({ type, detail }); + } + } + + getAudioTracks() { + return []; + } + + selectAudioTrack() { + return undefined; + } + + getTextTracks() { + return []; + } + + selectTextTrack(track) { + this.selectTextTrackCalls.push(track); + } + + isLive() { + return false; + } +} + +MockShakaPlayer.isBrowserSupported = () => true; + +const shaka = { + Player: MockShakaPlayer, + polyfill: { + installAll() { + return undefined; + }, + }, + __instances: [], + __reset() { + shaka.__instances.length = 0; + }, +}; + +module.exports = shaka; +module.exports.default = shaka; diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index d4c8ba9ea..9d41014be 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -677,6 +677,73 @@ class EpgService { for a programme the user clicked, both hosts surface a `EPG.TIMELINE.CATCHUP_FAILED` snackbar instead of doing nothing. +### DASH + ClearKey Playback + +MPEG-DASH (`.mpd`) channels play through a Shaka Player *source engine* inside +the existing built-in players — exactly like hls.js/mpegts.js. There is no new +player in settings. + +**DRM data flow** (M3U module only; Xtream/Stalker have no DRM concept): + +1. The playlist parser fork does not understand `#KODIPROP:` lines, but keeps + every unknown line between `#EXTINF` and the stream URL in `item.raw` (the + dominant Kodi/TiviMate layout; `#KODIPROP` lines *before* `#EXTINF` are + dropped by the parser — fixing that requires a parser-fork patch and is + deferred). +2. `extractDrmFromRaw()` (`libs/shared/m3u-utils/src/lib/kodiprop.utils.ts`) + post-processes `raw` inside `createPlaylistObject()` — the single funnel + for all four import paths (Electron URL/file import, refresh worker, + web-backend `/parse`, client-side upload). It reads + `inputstream.adaptive.license_type`, `license_key`, and the combined + `drm_legacy` property. ClearKey key formats: `kid:key` hex (single or + comma-separated), the W3C ClearKey license JSON, and a plain `{kid: key}` + JSON map. Unsupported license types (Widevine, PlayReady, license-server + URLs, malformed values) are preserved as `supported: false` — never a + throw. +3. The typed result lands on `Channel.drm` (`ChannelDrm` in + `@iptvnator/shared/interfaces`), travels through + `ResolvedPortalPlayback.drm` into `WebPlayerViewComponent`'s synthetic + channel, and reaches the engine. Persistence is free for newly imported or + refreshed playlists (playlist JSON blob / IndexedDB object). Playlists + imported **before** the DRM feature carry no `drm` field yet, but the raw + `#KODIPROP` block survived in the stored items — the M3U player page falls + back to `extractDrmFromRaw(channel.raw)` at playback time, so encrypted + channels of pre-upgrade playlists work without a re-import. + +**Engine selection and routing:** + +- `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 Shaka-error → + `PlaybackDiagnostic` classification (`PlaybackDiagnosticSource.Shaka`). + Channels with `drm.supported === false` emit a `DrmOrEncryption` diagnostic + without starting an engine. +- HTML5 player: `extension === 'mpd'` branch in `playChannel()`. ArtPlayer: + `customType.mpd` in `ArtPlayerSourceSession`. Shared-controls bridge: + `WebVideoControlsSource` kind `'shaka'` + `WebVideoShakaControls` + (audio/text tracks via the Shaka 5 API — selecting a text track shows it, + `selectTextTrack(null)` hides). +- DASH channels always play inline (radio precedent): `isDashChannel()` gates + `shouldShowInlinePlayer()`, the MPV/VLC auto-launch in `m3u-state` effects + (`shouldAutoLaunchExternalPlayer()`), and the `playerOverride` passed to + `app-web-player-view` — ArtPlayer stays ArtPlayer, every other configured + player (Video.js without a DASH bridge, embedded/external MPV, VLC) falls + back to the HTML5 player. External players cannot receive KODIPROP ClearKey + configuration (VLC upstream feature request #29465). +- ClearKey EME works in stock Electron (`org.w3.clearkey`; no Widevine CDM + required) — EME needs a secure context, which `file://` (packaged) and + `http://localhost` (dev/PWA) both satisfy. Widevine/FairPlay are out of + scope (castLabs fork + VMP signing). + +**Testing:** offline VP9+Opus CENC fixtures in +`apps/web-e2e/src/fixtures/dash/` (generated with ffmpeg + Shaka Packager — +see the README there for why), e2e suites `web-e2e:src/dash-clearkey.e2e.ts` +(Chromium; the Angular service worker is blocked because SW-routed requests +bypass Playwright interception) and +`electron-backend-e2e:src/dash-clearkey.e2e.ts` (real ClearKey EME in +Electron). + ## Interfaces ### Channel Interface @@ -703,6 +770,8 @@ interface Channel { 'user-agent': string; origin: string; }; + /** ClearKey DRM extracted from #KODIPROP lines (DASH channels). */ + drm?: ChannelDrm; } ``` diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index c8aba51a9..19468c508 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -37,7 +37,7 @@ those control systems is active at a time. When `WEB_PLAYER_SHARED_CONTROLS` is enabled, the built-in HTML5 player mounts the same presentation component over its real player shell and disables the -native video controls. Its neutral source bridge supplies HLS/native tracks, +native video controls. Its neutral source bridge supplies HLS/Shaka/native tracks, corrected MPEG-TS VOD duration, and authoritative live/VOD metadata to the generic web adapter. When the host token resolves to false, the native controls and legacy series navigation remain unchanged and the adapter is not attached. @@ -50,7 +50,7 @@ keeps its existing skin and legacy series navigation. ArtPlayer is the fourth consumer. Its source session owns HLS, MPEG-TS, native source selection, and delayed `customType` callbacks, while the neutral -web-video source bridge exposes HLS/native tracks, caption preference, and +web-video source bridge exposes HLS/Shaka/native tracks, caption preference, and MPEG-TS VOD duration to the adapter. Its video session owns native media and ArtPlayer event listeners. Shared mode uses authoritative live/VOD metadata, reapplies the app volume directly to the media element after ArtPlayer restores @@ -665,7 +665,7 @@ libs/ui/playback/src/lib/art-player/ ``` `ArtPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`. -`ArtPlayerSourceSession` owns HLS/MPEG-TS/native engines, the neutral source +`ArtPlayerSourceSession` owns HLS/DASH(Shaka)/MPEG-TS/native engines, the neutral source bridge, exact engine/listener cleanup, and a destroyed-session guard for ArtPlayer's delayed `customType` dispatch. `ArtPlayerVideoSession` owns native media errors, readiness, volume persistence, ended/time updates, and exact diff --git a/jest.web-esm.workspace.ts b/jest.web-esm.workspace.ts index d10d1b943..d22c06e22 100644 --- a/jest.web-esm.workspace.ts +++ b/jest.web-esm.workspace.ts @@ -31,6 +31,7 @@ export default { tslib: 'tslib/tslib.es6.js', '^iptv-playlist-parser$': '/apps/web/src/test-stubs/iptv-playlist-parser.mjs', + '^shaka-player$': '/apps/web/src/test-stubs/shaka-player.js', '^rxjs': '/node_modules/rxjs/dist/bundles/rxjs.umd.js', '^uuid$': '/node_modules/uuid/wrapper.mjs', }, diff --git a/libs/m3u-state/src/lib/effects.spec.ts b/libs/m3u-state/src/lib/effects.spec.ts index 525953cf8..553cd1f20 100644 --- a/libs/m3u-state/src/lib/effects.spec.ts +++ b/libs/m3u-state/src/lib/effects.spec.ts @@ -1,6 +1,9 @@ -import { Channel, EpgProgram } from '@iptvnator/shared/interfaces'; +import { Channel, EpgProgram, VideoPlayer } from '@iptvnator/shared/interfaces'; import { EpgActions } from './actions'; -import { buildExternalPlayerPayload } from './external-player-payload.util'; +import { + buildExternalPlayerPayload, + shouldAutoLaunchExternalPlayer, +} from './external-player-payload.util'; import { resolveActiveEpgProgramAction } from './resolve-active-epg-program.util'; describe('buildExternalPlayerPayload', () => { @@ -44,6 +47,99 @@ describe('buildExternalPlayerPayload', () => { origin: 'https://origin.example.com', }); }); + + describe('shouldAutoLaunchExternalPlayer', () => { + const mpvSettings = { player: VideoPlayer.MPV }; + + it('launches the configured external player for regular channels', () => { + expect( + shouldAutoLaunchExternalPlayer( + mpvSettings, + true, + activeChannel, + VideoPlayer.MPV + ) + ).toBe(true); + expect( + shouldAutoLaunchExternalPlayer( + mpvSettings, + true, + activeChannel, + VideoPlayer.VLC + ) + ).toBe(false); + }); + + it('never launches external players for DASH (.mpd) channels', () => { + const dashChannel: Channel = { + ...activeChannel, + url: 'https://streams.example.com/live.mpd', + }; + + expect( + shouldAutoLaunchExternalPlayer( + mpvSettings, + true, + dashChannel, + VideoPlayer.MPV + ) + ).toBe(false); + expect( + shouldAutoLaunchExternalPlayer( + { player: VideoPlayer.VLC }, + true, + dashChannel, + VideoPlayer.VLC + ) + ).toBe(false); + }); + + it('never launches external players for radio channels', () => { + expect( + shouldAutoLaunchExternalPlayer( + mpvSettings, + true, + { ...activeChannel, radio: 'true' }, + VideoPlayer.MPV + ) + ).toBe(false); + }); + + it('respects the double-click setting and missing settings', () => { + expect( + shouldAutoLaunchExternalPlayer( + { ...mpvSettings, openStreamOnDoubleClick: true }, + undefined, + activeChannel, + VideoPlayer.MPV + ) + ).toBe(false); + expect( + shouldAutoLaunchExternalPlayer( + { ...mpvSettings, openStreamOnDoubleClick: true }, + true, + activeChannel, + VideoPlayer.MPV + ) + ).toBe(true); + expect( + shouldAutoLaunchExternalPlayer( + null, + true, + activeChannel, + VideoPlayer.MPV + ) + ).toBe(false); + expect( + shouldAutoLaunchExternalPlayer( + {}, + true, + activeChannel, + VideoPlayer.MPV + ) + ).toBe(false); + }); + }); }); describe('resolveActiveEpgProgramAction', () => { diff --git a/libs/m3u-state/src/lib/effects.ts b/libs/m3u-state/src/lib/effects.ts index 3931aafca..ae0e5f0f5 100644 --- a/libs/m3u-state/src/lib/effects.ts +++ b/libs/m3u-state/src/lib/effects.ts @@ -2,7 +2,11 @@ import { inject, Injectable } from '@angular/core'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Router } from '@angular/router'; import { EpgService } from '@iptvnator/epg/data-access'; -import { normalizeEpgUrls } from '@iptvnator/shared/m3u-utils'; +import { + isDashChannel, + isDashStreamUrl, + normalizeEpgUrls, +} from '@iptvnator/shared/m3u-utils'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import { Store } from '@ngrx/store'; import { StorageMap } from '@ngx-pwa/local-storage'; @@ -45,7 +49,10 @@ import { selectFavorites, } from './selectors'; import { resolveChannelEpgLookupKey } from './channel-epg-lookup.util'; -import { buildExternalPlayerPayload } from './external-player-payload.util'; +import { + buildExternalPlayerPayload, + shouldAutoLaunchExternalPlayer, +} from './external-player-payload.util'; import { resolvePlaylistScopedEpgFetchPlan } from './playlist-scoped-epg-fetch.util'; import { resolveActiveEpgProgramAction } from './resolve-active-epg-program.util'; @@ -189,16 +196,13 @@ export class PlaylistEffects { firstValueFrom(this.storage.get(STORE_KEY.Settings)).then( (settings: any) => { - const shouldOpenExternalPlayer = - !settings?.openStreamOnDoubleClick || - action.startPlayback === true; - if ( - settings && - Object.keys(settings).length > 0 && - shouldOpenExternalPlayer && - settings.player === VideoPlayer.MPV && - channel.radio !== 'true' + shouldAutoLaunchExternalPlayer( + settings, + action.startPlayback, + channel, + VideoPlayer.MPV + ) ) { this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, { url: channel.url, @@ -208,12 +212,13 @@ export class PlaylistEffects { origin: channel.http.origin, }); } else if ( - settings && - Object.keys(settings).length > 0 && - shouldOpenExternalPlayer && - settings.player === VideoPlayer.VLC && - channel.radio !== 'true' - ) + shouldAutoLaunchExternalPlayer( + settings, + action.startPlayback, + channel, + VideoPlayer.VLC + ) + ) { this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, { url: channel.url, title: channel.name ?? '', @@ -221,6 +226,7 @@ export class PlaylistEffects { referer: channel.http.referrer, origin: channel.http.origin, }); + } } ); @@ -253,6 +259,10 @@ export class PlaylistEffects { playbackUrl: string, activeChannel: Channel | undefined | null ): Promise { + if (isDashStreamUrl(playbackUrl) || isDashChannel(activeChannel)) { + return; + } + const payload = buildExternalPlayerPayload(activeChannel, playbackUrl); if (!payload) { return; diff --git a/libs/m3u-state/src/lib/external-player-payload.util.ts b/libs/m3u-state/src/lib/external-player-payload.util.ts index af82f2ca6..082770e2a 100644 --- a/libs/m3u-state/src/lib/external-player-payload.util.ts +++ b/libs/m3u-state/src/lib/external-player-payload.util.ts @@ -1,4 +1,34 @@ -import { Channel } from '@iptvnator/shared/interfaces'; +import { Channel, VideoPlayer } from '@iptvnator/shared/interfaces'; +import { isDashChannel } from '@iptvnator/shared/m3u-utils'; + +/** + * Decides whether activating a channel should auto-launch the configured + * external player. Radio channels use the inline audio player; DASH (`.mpd`) + * channels use the inline Shaka engine because MPV/VLC cannot receive the + * KODIPROP ClearKey configuration. + */ +export function shouldAutoLaunchExternalPlayer( + settings: + | { player?: VideoPlayer; openStreamOnDoubleClick?: boolean } + | null + | undefined, + startPlayback: boolean | undefined, + channel: Channel, + player: VideoPlayer.MPV | VideoPlayer.VLC +): boolean { + if (!settings || Object.keys(settings).length === 0) { + return false; + } + + const startRequested = + !settings.openStreamOnDoubleClick || startPlayback === true; + return ( + startRequested && + settings.player === player && + channel.radio !== 'true' && + !isDashChannel(channel) + ); +} export function buildExternalPlayerPayload( activeChannel: Channel | undefined | null, diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index aa4b12ff0..e529618be 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -76,7 +76,11 @@ [streamUrl]="embeddedPlayback()?.streamUrl ?? ''" [title]="inlinePlayerTitle()" [playback]="embeddedPlayback()" - [playerOverride]="playerSettings.player ?? null" + [playerOverride]=" + activeChannelIsDash() + ? dashPlayerOverride() + : (playerSettings.player ?? null) + " [volume]="volume()" [showCaptions]="!!playerSettings.showCaptions" (externalFallbackRequested)=" diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts index 82403755d..b4879d433 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts @@ -627,6 +627,136 @@ describe('VideoPlayerComponent', () => { ).toBe(false); }); + it('keeps DASH channels inline on the HTML5 player even when MPV is configured', () => { + syncStoreState({ + ...sampleChannel, + url: 'http://localhost/live.mpd', + } as Channel); + player.set(VideoPlayer.MPV); + + fixture.detectChanges(); + + const playerView = fixture.debugElement.query( + By.directive(StubWebPlayerViewComponent) + ); + expect(playerView).not.toBeNull(); + const stub = + playerView.componentInstance as StubWebPlayerViewComponent; + expect(stub.playerOverride()).toBe(VideoPlayer.Html5Player); + expect(dataServiceMock.sendIpcEvent).not.toHaveBeenCalled(); + }); + + it('routes catch-up playback that resolves to a DASH URL inline as well', () => { + syncStoreState(sampleChannel); + activePlaybackUrl.set('http://localhost/archive/replay.mpd'); + player.set(VideoPlayer.MPV); + + fixture.detectChanges(); + + const playerView = fixture.debugElement.query( + By.directive(StubWebPlayerViewComponent) + ); + expect(playerView).not.toBeNull(); + const stub = + playerView.componentInstance as StubWebPlayerViewComponent; + expect(stub.playerOverride()).toBe(VideoPlayer.Html5Player); + }); + + it('keeps a DASH channel inline when its catch-up resolves to a non-DASH URL', () => { + syncStoreState({ + ...sampleChannel, + url: 'http://localhost/live.mpd', + } as Channel); + activePlaybackUrl.set('http://localhost/archive/replay.m3u8'); + player.set(VideoPlayer.MPV); + + fixture.detectChanges(); + + // The external-player guard declines DASH channels, so the inline + // player must stay — otherwise the session has no player at all. + expect( + fixture.debugElement.query( + By.directive(StubWebPlayerViewComponent) + ) + ).not.toBeNull(); + }); + + it('extracts DRM lazily from raw KODIPROP for pre-upgrade playlists', () => { + const kid = '00112233445566778899aabbccddeeff'; + const key = 'ffeeddccbbaa99887766554433221100'; + syncStoreState({ + ...sampleChannel, + url: 'http://localhost/enc.mpd', + raw: [ + '#EXTINF:-1,Encrypted', + '#KODIPROP:inputstream.adaptive.license_type=clearkey', + `#KODIPROP:inputstream.adaptive.license_key=${kid}:${key}`, + 'http://localhost/enc.mpd', + ].join('\r\n'), + } as Channel); + player.set(VideoPlayer.Html5Player); + + fixture.detectChanges(); + + const stub = fixture.debugElement.query( + By.directive(StubWebPlayerViewComponent) + ).componentInstance as StubWebPlayerViewComponent; + expect(stub.playback()).toEqual( + expect.objectContaining({ + drm: { + licenseType: 'clearkey', + supported: true, + clearKeys: { [kid]: key }, + }, + }) + ); + }); + + it('keeps ArtPlayer for DASH channels and forwards the ClearKey DRM config', () => { + const drm = { + licenseType: 'clearkey', + supported: true, + clearKeys: { '11223344556677889900aabbccddeeff': 'f'.repeat(32) }, + }; + syncStoreState({ + ...sampleChannel, + url: 'http://localhost/live.mpd', + drm, + } as Channel); + player.set(VideoPlayer.ArtPlayer); + + fixture.detectChanges(); + + const stub = fixture.debugElement.query( + By.directive(StubWebPlayerViewComponent) + ).componentInstance as StubWebPlayerViewComponent; + expect(stub.playerOverride()).toBe(VideoPlayer.ArtPlayer); + expect(stub.playback()).toEqual(expect.objectContaining({ drm })); + }); + + it('routes Video.js users to the HTML5 player only for DASH channels', () => { + syncStoreState({ + ...sampleChannel, + url: 'http://localhost/live.mpd', + } as Channel); + player.set(VideoPlayer.VideoJs); + + fixture.detectChanges(); + + let stub = fixture.debugElement.query( + By.directive(StubWebPlayerViewComponent) + ).componentInstance as StubWebPlayerViewComponent; + expect(stub.playerOverride()).toBe(VideoPlayer.Html5Player); + + syncStoreState(sampleChannel); + fixture.detectChanges(); + + stub = fixture.debugElement.query( + By.directive(StubWebPlayerViewComponent) + ).componentInstance as StubWebPlayerViewComponent; + expect(stub.playerOverride()).toBe(VideoPlayer.VideoJs); + }); + it('passes remote volume changes to the radio audio player', () => { const radioChannel = { ...sampleChannel, diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index e7513d6b2..a887f4a39 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -24,6 +24,9 @@ import { ResizableDirective } from '@iptvnator/ui/components'; import { applyChannelNameStrip, getM3uArchiveDays, + extractDrmFromRaw, + isDashChannel, + isDashStreamUrl, isM3uCatchupPlaybackSupported, } from '@iptvnator/shared/m3u-utils'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; @@ -173,6 +176,28 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { readonly archivePlaybackAvailable = computed(() => isM3uCatchupPlaybackSupported(this.activeChannel()) ); + /** + * DASH (.mpd) playback always runs inline via the Shaka engine. True when + * either the channel itself or the resolved catch-up URL is DASH — + * mirroring the external-player guard in the m3u-state effects, so a + * DASH-flavored session can never end up with no player at all. + */ + readonly activeChannelIsDash = computed( + () => + isDashStreamUrl(this.activePlaybackUrl() ?? undefined) || + isDashChannel(this.activeChannel()) + ); + /** + * Player forced for DASH channels: ArtPlayer keeps ArtPlayer (it has a + * Shaka source engine); every other choice — Video.js (no DASH bridge), + * embedded/external MPV and VLC (no KODIPROP ClearKey support) — falls + * back to the HTML5 player. + */ + readonly dashPlayerOverride = computed(() => + this.settingsStore.player() === VideoPlayer.ArtPlayer + ? VideoPlayer.ArtPlayer + : VideoPlayer.Html5Player + ); /** Full multi-day programme window for the active channel (timeline). */ readonly epgPrograms = toSignal(this.epgService.currentEpgPrograms$, { initialValue: [] as EpgProgram[], @@ -280,6 +305,10 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { userAgent: http['user-agent'] || undefined, referer: http.referrer || undefined, origin: http.origin || undefined, + // Playlists imported before the DRM feature carry no drm field + // yet, but their raw KODIPROP block survived in the stored items + // — extract lazily so they work without a re-import. + drm: playbackTarget.drm ?? extractDrmFromRaw(playbackTarget.raw), }; }); readonly sidebarStorageKey = computed(() => @@ -992,6 +1021,13 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { return false; } + // DASH playback bypasses the external-player setting (radio + // precedent): MPV/VLC cannot receive the KODIPROP ClearKey + // configuration. Checked on the effective (possibly catch-up) URL. + if (this.activeChannelIsDash()) { + return true; + } + return !this.isExternalPlayer(this.playerSettings.player); } diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 2f99efe03..9d8452872 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -1,3 +1,4 @@ +export * from './lib/channel-drm.interface'; export * from './lib/channel.interface'; export * from './lib/channel.model'; export * from './lib/dev-logger.util'; diff --git a/libs/shared/interfaces/src/lib/channel-drm.interface.ts b/libs/shared/interfaces/src/lib/channel-drm.interface.ts new file mode 100644 index 000000000..3c9eac43c --- /dev/null +++ b/libs/shared/interfaces/src/lib/channel-drm.interface.ts @@ -0,0 +1,18 @@ +/** + * DRM configuration extracted from `#KODIPROP:inputstream.adaptive.*` lines of + * an M3U playlist entry. Only ClearKey is playable in-app; other license types + * are preserved so playback can surface a meaningful DRM diagnostic instead of + * failing silently. + */ +export interface ChannelDrmClearKeys { + /** Key id (32 lowercase hex chars) mapped to its key (32 lowercase hex chars). */ + [kidHex: string]: string; +} + +export interface ChannelDrm { + /** Normalized license type, e.g. `clearkey` or `com.widevine.alpha`. */ + licenseType: string; + /** True only for ClearKey entries with at least one successfully parsed key. */ + supported: boolean; + clearKeys?: ChannelDrmClearKeys; +} diff --git a/libs/shared/interfaces/src/lib/channel.interface.ts b/libs/shared/interfaces/src/lib/channel.interface.ts index 03bef9ee7..2eb403ad7 100644 --- a/libs/shared/interfaces/src/lib/channel.interface.ts +++ b/libs/shared/interfaces/src/lib/channel.interface.ts @@ -1,3 +1,5 @@ +import { ChannelDrm } from './channel-drm.interface'; + /** * Represents channel object * TODO: define channel interface in iptv-parser library @@ -29,4 +31,7 @@ export interface Channel { origin: string; }; radio: string; + drm?: ChannelDrm; + /** Raw M3U block of the entry as preserved by the playlist parser. */ + raw?: string; } diff --git a/libs/shared/interfaces/src/lib/parsed-playlist.interface.ts b/libs/shared/interfaces/src/lib/parsed-playlist.interface.ts index 350be337f..cd9ffff12 100644 --- a/libs/shared/interfaces/src/lib/parsed-playlist.interface.ts +++ b/libs/shared/interfaces/src/lib/parsed-playlist.interface.ts @@ -1,3 +1,5 @@ +import { ChannelDrm } from './channel-drm.interface'; + export interface ParsedPlaylist { header: { attrs: Record; @@ -32,4 +34,5 @@ export interface ParsedPlaylistItem { }; timeshift?: string; radio?: string; + drm?: ChannelDrm; } diff --git a/libs/shared/interfaces/src/lib/portal-playback.interface.ts b/libs/shared/interfaces/src/lib/portal-playback.interface.ts index 1ab48fa88..c06ebb8bc 100644 --- a/libs/shared/interfaces/src/lib/portal-playback.interface.ts +++ b/libs/shared/interfaces/src/lib/portal-playback.interface.ts @@ -1,3 +1,4 @@ +import { ChannelDrm } from './channel-drm.interface'; import { PlaybackPositionData } from './playback-position.interface'; export interface PlayerContentInfo extends Omit< @@ -18,4 +19,5 @@ export interface ResolvedPortalPlayback { userAgent?: string; referer?: string; origin?: string; + drm?: ChannelDrm; } diff --git a/libs/shared/m3u-utils/src/index.ts b/libs/shared/m3u-utils/src/index.ts index d50f5a867..e9c180317 100644 --- a/libs/shared/m3u-utils/src/index.ts +++ b/libs/shared/m3u-utils/src/index.ts @@ -1,3 +1,6 @@ +export * from './lib/dash.utils'; +export * from './lib/kodiprop.utils'; +export * from './lib/playback-media-extension.util'; export * from './lib/playlist.utils'; export * from './lib/catchup.utils'; export * from './lib/strip-country-prefix.util'; diff --git a/libs/shared/m3u-utils/src/lib/dash.utils.spec.ts b/libs/shared/m3u-utils/src/lib/dash.utils.spec.ts new file mode 100644 index 000000000..32cc17523 --- /dev/null +++ b/libs/shared/m3u-utils/src/lib/dash.utils.spec.ts @@ -0,0 +1,36 @@ +import { isDashChannel, isDashStreamUrl } from './dash.utils'; + +describe('dash.utils', () => { + it.each([ + 'https://example.com/live/stream.mpd', + 'https://example.com/live/stream.MPD', + 'https://example.com/live/stream.mpd?token=abc#frag', + 'https://example.com/play?extension=mpd', + 'https://example.com/play?ext=mpd', + 'https://example.com/play?format=mpd', + ])('detects %s as DASH', (url) => { + expect(isDashStreamUrl(url)).toBe(true); + }); + + it.each([ + 'https://example.com/live/playlist.m3u8', + 'https://example.com/live/stream.ts', + 'https://example.com/live.php?id=7', + 'https://example.com/movie.mp4', + '', + undefined, + ])('does not flag %s as DASH', (url) => { + expect(isDashStreamUrl(url as string | undefined)).toBe(false); + }); + + it('detects DASH channels by their stream URL', () => { + expect( + isDashChannel({ url: 'https://example.com/enc.mpd' }) + ).toBe(true); + expect( + isDashChannel({ url: 'https://example.com/live.m3u8' }) + ).toBe(false); + expect(isDashChannel(null)).toBe(false); + expect(isDashChannel(undefined)).toBe(false); + }); +}); diff --git a/libs/shared/m3u-utils/src/lib/dash.utils.ts b/libs/shared/m3u-utils/src/lib/dash.utils.ts new file mode 100644 index 000000000..b8860c164 --- /dev/null +++ b/libs/shared/m3u-utils/src/lib/dash.utils.ts @@ -0,0 +1,18 @@ +import { Channel } from '@iptvnator/shared/interfaces'; +import { getPlaybackMediaExtensionFromUrl } from './playback-media-extension.util'; + +/** + * DASH (`.mpd`) detection for M3U channels. DASH streams must always play in + * a Shaka-capable built-in web engine: external MPV/VLC cannot receive the + * KODIPROP ClearKey configuration, and Video.js has no DASH bridge yet. + * + * Uses the same normalized extension detection as the player engines, so + * routing and engine selection always agree (`stream.MPD`, `?format=mpd`, + * `?ext=mpd` and friends included). + */ +export const isDashStreamUrl = (url: string | undefined): boolean => + !!url && getPlaybackMediaExtensionFromUrl(url) === 'mpd'; + +export const isDashChannel = ( + channel: Pick | null | undefined +): boolean => isDashStreamUrl(channel?.url); diff --git a/libs/shared/m3u-utils/src/lib/kodiprop.utils.spec.ts b/libs/shared/m3u-utils/src/lib/kodiprop.utils.spec.ts new file mode 100644 index 000000000..2864ca1e0 --- /dev/null +++ b/libs/shared/m3u-utils/src/lib/kodiprop.utils.spec.ts @@ -0,0 +1,194 @@ +import { extractDrmFromRaw, isClearKeyLicenseType } from './kodiprop.utils'; + +const KID_HEX = '9eb4050de44b4802932e27d75083e266'; +const KEY_HEX = '166634c675823c235a4a9446fad52e4d'; +// base64url encodings of the same 16-byte values as above. +const KID_B64 = 'nrQFDeRLSAKTLifXUIPiZg'; +const KEY_B64 = 'FmY0xnWCPCNaSpRG-tUuTQ'; + +const rawWith = (...kodipropLines: string[]): string => + [ + '#EXTINF:-1 tvg-id="enc" group-title="DASH",Encrypted channel', + ...kodipropLines, + 'http://example.com/stream.mpd', + ].join('\r\n'); + +describe('kodiprop.utils', () => { + describe('extractDrmFromRaw', () => { + it('parses a single hex kid:key ClearKey pair', () => { + const drm = extractDrmFromRaw( + rawWith( + '#KODIPROP:inputstream.adaptive.license_type=clearkey', + `#KODIPROP:inputstream.adaptive.license_key=${KID_HEX}:${KEY_HEX}` + ) + ); + + expect(drm).toEqual({ + licenseType: 'clearkey', + supported: true, + clearKeys: { [KID_HEX]: KEY_HEX }, + }); + }); + + it('accepts the org.w3.clearkey license type and dashed UUID kids', () => { + const dashedKid = + '9eb4050d-e44b-4802-932e-27d75083e266'.toUpperCase(); + const drm = extractDrmFromRaw( + rawWith( + '#KODIPROP:inputstream.adaptive.license_type=org.w3.clearkey', + `#KODIPROP:inputstream.adaptive.license_key=${dashedKid}:${KEY_HEX}` + ) + ); + + expect(drm?.supported).toBe(true); + expect(drm?.clearKeys).toEqual({ [KID_HEX]: KEY_HEX }); + }); + + it('parses comma-separated multi-key pairs', () => { + const secondKid = 'a'.repeat(32); + const secondKey = 'b'.repeat(32); + const drm = extractDrmFromRaw( + rawWith( + '#KODIPROP:inputstream.adaptive.license_type=clearkey', + `#KODIPROP:inputstream.adaptive.license_key=${KID_HEX}:${KEY_HEX},${secondKid}:${secondKey}` + ) + ); + + expect(drm?.clearKeys).toEqual({ + [KID_HEX]: KEY_HEX, + [secondKid]: secondKey, + }); + }); + + it('parses the W3C ClearKey license JSON with base64url values', () => { + const license = JSON.stringify({ + keys: [{ kty: 'oct', k: KEY_B64, kid: KID_B64 }], + type: 'temporary', + }); + const drm = extractDrmFromRaw( + rawWith( + '#KODIPROP:inputstream.adaptive.license_type=clearkey', + `#KODIPROP:inputstream.adaptive.license_key=${license}` + ) + ); + + expect(drm).toEqual({ + licenseType: 'clearkey', + supported: true, + clearKeys: { [KID_HEX]: KEY_HEX }, + }); + }); + + it('parses the plain JSON kid→key map form', () => { + const drm = extractDrmFromRaw( + rawWith( + '#KODIPROP:inputstream.adaptive.license_type=clearkey', + `#KODIPROP:inputstream.adaptive.license_key={"${KID_HEX}":"${KEY_HEX}"}` + ) + ); + + expect(drm?.clearKeys).toEqual({ [KID_HEX]: KEY_HEX }); + }); + + it('parses the drm_legacy combined property', () => { + const drm = extractDrmFromRaw( + rawWith( + `#KODIPROP:inputstream.adaptive.drm_legacy=org.w3.clearkey|${KID_HEX}:${KEY_HEX}` + ) + ); + + expect(drm).toEqual({ + licenseType: 'org.w3.clearkey', + supported: true, + clearKeys: { [KID_HEX]: KEY_HEX }, + }); + }); + + it('treats a parseable key without license type as ClearKey', () => { + const drm = extractDrmFromRaw( + rawWith( + `#KODIPROP:inputstream.adaptive.license_key=${KID_HEX}:${KEY_HEX}` + ) + ); + + expect(drm?.supported).toBe(true); + expect(drm?.licenseType).toBe('clearkey'); + }); + + it('marks widevine as unsupported without throwing', () => { + const drm = extractDrmFromRaw( + rawWith( + '#KODIPROP:inputstream.adaptive.license_type=com.widevine.alpha', + '#KODIPROP:inputstream.adaptive.license_key=https://license.example.com/wv' + ) + ); + + expect(drm).toEqual({ + licenseType: 'com.widevine.alpha', + supported: false, + }); + }); + + it.each([ + ['truncated hex pair', `${KID_HEX.slice(0, 10)}:${KEY_HEX}`], + ['missing key part', KID_HEX], + ['license server URL', 'https://license.example.com/ck'], + ['broken JSON', '{"keys":[{'], + ['JSON with non-string values', `{"${KID_HEX}": 42}`], + ])( + 'marks ClearKey with a malformed key value as unsupported (%s)', + (_label, licenseKey) => { + const drm = extractDrmFromRaw( + rawWith( + '#KODIPROP:inputstream.adaptive.license_type=clearkey', + `#KODIPROP:inputstream.adaptive.license_key=${licenseKey}` + ) + ); + + expect(drm).toEqual({ + licenseType: 'clearkey', + supported: false, + }); + } + ); + + it('returns undefined for channels without KODIPROP lines', () => { + expect( + extractDrmFromRaw( + '#EXTINF:-1 tvg-id="plain",Plain channel\r\nhttp://example.com/live.m3u8' + ) + ).toBeUndefined(); + }); + + it('ignores unrelated KODIPROP properties', () => { + expect( + extractDrmFromRaw( + rawWith( + '#KODIPROP:inputstream.adaptive.manifest_type=mpd' + ) + ) + ).toBeUndefined(); + }); + + it('returns undefined for empty or missing raw', () => { + expect(extractDrmFromRaw(undefined)).toBeUndefined(); + expect(extractDrmFromRaw('')).toBeUndefined(); + }); + }); + + describe('isClearKeyLicenseType', () => { + it.each(['clearkey', 'org.w3.clearkey', ' ClearKey '])( + 'accepts %s', + (value) => { + expect(isClearKeyLicenseType(value)).toBe(true); + } + ); + + it.each(['com.widevine.alpha', 'com.microsoft.playready', ''])( + 'rejects %s', + (value) => { + expect(isClearKeyLicenseType(value)).toBe(false); + } + ); + }); +}); diff --git a/libs/shared/m3u-utils/src/lib/kodiprop.utils.ts b/libs/shared/m3u-utils/src/lib/kodiprop.utils.ts new file mode 100644 index 000000000..d2bdae1ae --- /dev/null +++ b/libs/shared/m3u-utils/src/lib/kodiprop.utils.ts @@ -0,0 +1,279 @@ +import { ChannelDrm, ChannelDrmClearKeys } from '@iptvnator/shared/interfaces'; + +/** + * `#KODIPROP:` DRM extraction. + * + * The playlist parser does not understand `#KODIPROP:` lines, but it appends + * every unknown line between `#EXTINF` and the stream URL to `item.raw` + * (the dominant Kodi/TiviMate layout). This module post-processes that raw + * block into a typed {@link ChannelDrm} value. + * + * Supported properties: + * - `inputstream.adaptive.license_type` + `inputstream.adaptive.license_key` + * - `inputstream.adaptive.drm_legacy` (`|`) + * + * ClearKey key formats: single `kid:key` hex pair, comma-separated pairs, the + * W3C ClearKey license JSON (`{"keys":[{"kty":"oct","k":...,"kid":...}]}`) + * and the plain `{kid: key}` JSON map. Anything else (license-server URLs, + * Widevine/PlayReady types, malformed values) yields `supported: false` so the + * player can show a clear DRM diagnostic instead of crashing. + */ + +const KODIPROP_PREFIX = '#kodiprop:'; +const LICENSE_TYPE_PROP = 'inputstream.adaptive.license_type'; +const LICENSE_KEY_PROP = 'inputstream.adaptive.license_key'; +const DRM_LEGACY_PROP = 'inputstream.adaptive.drm_legacy'; + +const CLEARKEY_LICENSE_TYPES = new Set(['clearkey', 'org.w3.clearkey']); + +const HEX_128_BIT_PATTERN = /^[0-9a-f]{32}$/; + +export function isClearKeyLicenseType(licenseType: string): boolean { + return CLEARKEY_LICENSE_TYPES.has(licenseType.trim().toLowerCase()); +} + +/** + * Extracts DRM configuration from a playlist item's raw M3U block. + * Returns `undefined` when the block carries no DRM-related `#KODIPROP` lines, + * so channels without KODIPROP metadata stay untouched. + */ +export function extractDrmFromRaw( + raw: string | undefined +): ChannelDrm | undefined { + const props = collectKodipropValues(raw); + if (props.size === 0) { + return undefined; + } + + let licenseType = props.get(LICENSE_TYPE_PROP)?.trim() ?? ''; + let licenseKey = props.get(LICENSE_KEY_PROP)?.trim() ?? ''; + + const drmLegacy = props.get(DRM_LEGACY_PROP)?.trim(); + if (drmLegacy && (!licenseType || !licenseKey)) { + const [legacyType, ...legacyKeyParts] = drmLegacy.split('|'); + licenseType ||= legacyType?.trim() ?? ''; + licenseKey ||= legacyKeyParts.join('|').trim(); + } + + if (!licenseType && !licenseKey) { + return undefined; + } + + const normalizedType = licenseType.toLowerCase(); + const clearKeys = isClearKeyLicenseType(normalizedType) + ? parseClearKeys(licenseKey) + : !normalizedType + ? // No explicit type: a parseable kid:key value is ClearKey in practice. + parseClearKeys(licenseKey) + : undefined; + + if (clearKeys) { + return { + licenseType: normalizedType || 'clearkey', + supported: true, + clearKeys, + }; + } + + return { + licenseType: normalizedType, + supported: false, + }; +} + +function collectKodipropValues( + raw: string | undefined +): Map { + const props = new Map(); + if (!raw) { + return props; + } + + for (const line of raw.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed.toLowerCase().startsWith(KODIPROP_PREFIX)) { + continue; + } + + const assignment = trimmed.slice(KODIPROP_PREFIX.length); + const separatorIndex = assignment.indexOf('='); + if (separatorIndex < 1) { + continue; + } + + const name = assignment.slice(0, separatorIndex).trim().toLowerCase(); + const value = assignment.slice(separatorIndex + 1).trim(); + if ( + name === LICENSE_TYPE_PROP || + name === LICENSE_KEY_PROP || + name === DRM_LEGACY_PROP + ) { + props.set(name, value); + } + } + + return props; +} + +function parseClearKeys(value: string): ChannelDrmClearKeys | undefined { + if (!value) { + return undefined; + } + + return value.startsWith('{') + ? parseClearKeysFromJson(value) + : parseClearKeysFromPairs(value); +} + +function parseClearKeysFromPairs( + value: string +): ChannelDrmClearKeys | undefined { + const keys: ChannelDrmClearKeys = {}; + + for (const pair of value.split(',')) { + const [kid, key, ...rest] = pair.split(':'); + if (rest.length > 0) { + return undefined; + } + + const kidHex = normalizeKeyComponent(kid); + const keyHex = normalizeKeyComponent(key); + if (!kidHex || !keyHex) { + return undefined; + } + + keys[kidHex] = keyHex; + } + + return Object.keys(keys).length > 0 ? keys : undefined; +} + +function parseClearKeysFromJson( + value: string +): ChannelDrmClearKeys | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + return undefined; + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return undefined; + } + + const record = parsed as Record; + return Array.isArray(record['keys']) + ? parseW3cClearKeyLicense(record['keys']) + : parseClearKeyMap(record); +} + +/** W3C ClearKey license form: `{"keys":[{"kty":"oct","k":...,"kid":...}]}`. */ +function parseW3cClearKeyLicense( + entries: unknown[] +): ChannelDrmClearKeys | undefined { + const keys: ChannelDrmClearKeys = {}; + + for (const entry of entries) { + if (!entry || typeof entry !== 'object') { + return undefined; + } + + const { kid, k } = entry as { kid?: unknown; k?: unknown }; + const kidHex = normalizeKeyComponent( + typeof kid === 'string' ? kid : undefined + ); + const keyHex = normalizeKeyComponent( + typeof k === 'string' ? k : undefined + ); + if (!kidHex || !keyHex) { + return undefined; + } + + keys[kidHex] = keyHex; + } + + return Object.keys(keys).length > 0 ? keys : undefined; +} + +function parseClearKeyMap( + record: Record +): ChannelDrmClearKeys | undefined { + const keys: ChannelDrmClearKeys = {}; + + for (const [kid, key] of Object.entries(record)) { + if (typeof key !== 'string') { + return undefined; + } + + const kidHex = normalizeKeyComponent(kid); + const keyHex = normalizeKeyComponent(key); + if (!kidHex || !keyHex) { + return undefined; + } + + keys[kidHex] = keyHex; + } + + return Object.keys(keys).length > 0 ? keys : undefined; +} + +/** + * Normalizes a 128-bit key component to 32 lowercase hex chars. Accepts plain + * or dashed (UUID-style) hex and base64url (the W3C license encoding). + */ +function normalizeKeyComponent(value: string | undefined): string | undefined { + const compact = value?.trim().replace(/-/g, '').toLowerCase(); + if (!compact) { + return undefined; + } + + if (HEX_128_BIT_PATTERN.test(compact)) { + return compact; + } + + const fromBase64 = base64UrlToHex(value?.trim() ?? ''); + return fromBase64 && HEX_128_BIT_PATTERN.test(fromBase64) + ? fromBase64 + : undefined; +} + +function base64UrlToHex(value: string): string | undefined { + if (!/^[A-Za-z0-9_-]+={0,2}$/.test(value)) { + return undefined; + } + + const base64 = value.replace(/-/g, '+').replace(/_/g, '/'); + try { + const binary = decodeBase64(base64); + let hex = ''; + for (let index = 0; index < binary.length; index += 1) { + hex += binary.charCodeAt(index).toString(16).padStart(2, '0'); + } + return hex; + } catch { + return undefined; + } +} + +/** Works in both the browser (atob) and Node workers (Buffer). */ +function decodeBase64(base64: string): string { + if (typeof atob === 'function') { + return atob(base64); + } + + const nodeBuffer = ( + globalThis as { + Buffer?: { + from( + data: string, + encoding: string + ): { toString(encoding: string): string }; + }; + } + ).Buffer; + if (!nodeBuffer) { + throw new Error('No base64 decoder available'); + } + return nodeBuffer.from(base64, 'base64').toString('binary'); +} diff --git a/libs/shared/m3u-utils/src/lib/playback-media-extension.util.ts b/libs/shared/m3u-utils/src/lib/playback-media-extension.util.ts new file mode 100644 index 000000000..fe919896c --- /dev/null +++ b/libs/shared/m3u-utils/src/lib/playback-media-extension.util.ts @@ -0,0 +1,127 @@ +import { getExtensionFromUrl } from './playlist.utils'; + +/** + * Normalized media-extension detection for playback URLs. Shared between the + * player engines (engine selection in `@iptvnator/ui/playback`) and M3U + * routing (`isDashChannel`), so both always agree on what a URL is. + */ + +export const UNSUPPORTED_CONTAINER_EXTENSIONS = new Set([ + 'avi', + 'asf', + 'divx', + 'flv', + 'm2ts', + 'm4v', + 'mkv', + 'mov', + 'mpeg', + 'mpg', + 'rm', + 'rmvb', + 'ts', + 'vob', + 'wmv', +]); + +const EXPLICIT_MEDIA_EXTENSION_QUERY_KEYS = ['extension', 'ext']; + +const DECLARED_MEDIA_FORMAT_QUERY_KEYS = ['format', 'container']; + +const DECLARED_MEDIA_EXTENSION_ALIASES = new Map([ + ['hls', 'm3u8'], + ['mpegts', 'ts'], + ['mpeg-ts', 'ts'], +]); + +const DECLARED_MEDIA_EXTENSIONS = new Set([ + ...UNSUPPORTED_CONTAINER_EXTENSIONS, + 'aac', + 'flac', + 'm3u', + 'm3u8', + 'm4s', + 'mp3', + 'mp4', + 'mpd', + 'oga', + 'ogg', + 'ogv', + 'webm', +]); + +const NON_MEDIA_URL_EXTENSIONS = new Set([ + 'asp', + 'aspx', + 'cgi', + 'jsp', + 'mpv', + 'php', + 'pl', +]); + +export function getPlaybackMediaExtensionFromUrl(url: string): string { + const explicitQueryExtension = getMediaExtensionFromQuery( + url, + EXPLICIT_MEDIA_EXTENSION_QUERY_KEYS + ); + if (explicitQueryExtension) { + return explicitQueryExtension; + } + + const pathExtension = normalizeExtensionToken(getExtensionFromUrl(url)); + if (DECLARED_MEDIA_EXTENSIONS.has(pathExtension)) { + return pathExtension; + } + + const formatQueryExtension = getMediaExtensionFromQuery( + url, + DECLARED_MEDIA_FORMAT_QUERY_KEYS + ); + if (formatQueryExtension) { + return formatQueryExtension; + } + + if (NON_MEDIA_URL_EXTENSIONS.has(pathExtension)) { + return ''; + } + + return pathExtension; +} + +function normalizeExtensionToken(value: string | undefined): string { + return (value?.trim().toLowerCase() ?? '').replace(/^\.+/, ''); +} + +function getMediaExtensionFromQuery( + url: string, + queryKeys: readonly string[] +): string { + try { + const parsedUrl = new URL(url, 'http://iptvnator.local'); + for (const key of queryKeys) { + const declaredExtension = normalizeDeclaredMediaExtension( + parsedUrl.searchParams.get(key) ?? undefined + ); + if (declaredExtension) { + return declaredExtension; + } + } + + return ''; + } catch { + return ''; + } +} + +function normalizeDeclaredMediaExtension(value: string | undefined): string { + const extension = normalizeExtensionToken(value); + const normalized = + DECLARED_MEDIA_EXTENSION_ALIASES.get(extension) ?? extension; + + if (!normalized || NON_MEDIA_URL_EXTENSIONS.has(normalized)) { + return ''; + } + + return DECLARED_MEDIA_EXTENSIONS.has(normalized) ? normalized : ''; +} diff --git a/libs/shared/m3u-utils/src/lib/playlist.utils.spec.ts b/libs/shared/m3u-utils/src/lib/playlist.utils.spec.ts index 37e296596..55eebfbc5 100644 --- a/libs/shared/m3u-utils/src/lib/playlist.utils.spec.ts +++ b/libs/shared/m3u-utils/src/lib/playlist.utils.spec.ts @@ -240,6 +240,47 @@ describe('playlist utils', () => { expect(playlist.epgUrls).toEqual(epgUrls.slice(0, 5)); }); + it('attaches ClearKey DRM extracted from raw KODIPROP lines and leaves other channels untouched', () => { + const kid = '9eb4050de44b4802932e27d75083e266'; + const key = '166634c675823c235a4a9446fad52e4d'; + const baseItem = { + tvg: { id: '', name: '', url: '', logo: '', rec: '' }, + group: { title: 'DASH' }, + http: { referrer: '', 'user-agent': '' }, + }; + const playlist = createPlaylistObject('DRM playlist', { + header: { attrs: {}, raw: '#EXTM3U' }, + items: [ + { + ...baseItem, + name: 'Encrypted DASH', + url: 'https://example.com/enc.mpd', + raw: [ + '#EXTINF:-1,Encrypted DASH', + '#KODIPROP:inputstream.adaptive.license_type=clearkey', + `#KODIPROP:inputstream.adaptive.license_key=${kid}:${key}`, + 'https://example.com/enc.mpd', + ].join('\r\n'), + }, + { + ...baseItem, + name: 'Plain HLS', + url: 'https://example.com/live.m3u8', + raw: '#EXTINF:-1,Plain HLS\r\nhttps://example.com/live.m3u8', + }, + ], + }); + + const [encrypted, plain] = playlist.playlist.items; + expect(encrypted.drm).toEqual({ + licenseType: 'clearkey', + supported: true, + clearKeys: { [kid]: key }, + }); + expect(plain.drm).toBeUndefined(); + expect('drm' in plain).toBe(false); + }); + it('keeps disabled playlist EPG sources out while preserving manually enabled URLs', () => { expect( resolvePlaylistEpgSourceState({ diff --git a/libs/shared/m3u-utils/src/lib/playlist.utils.ts b/libs/shared/m3u-utils/src/lib/playlist.utils.ts index 2aa9e23d8..1361d13a7 100644 --- a/libs/shared/m3u-utils/src/lib/playlist.utils.ts +++ b/libs/shared/m3u-utils/src/lib/playlist.utils.ts @@ -5,6 +5,7 @@ import { Playlist, } from '@iptvnator/shared/interfaces'; import { v4 as uuidv4 } from 'uuid'; +import { extractDrmFromRaw } from './kodiprop.utils'; /** * Aggregates favorite channels as objects from all available playlists @@ -392,10 +393,14 @@ export const createPlaylistObject = ( count: playlist.items.length, playlist: { ...playlist, - items: playlist.items.map((item: ParsedPlaylistItem) => ({ - ...item, - id: uuidv4(), - })), + items: playlist.items.map((item: ParsedPlaylistItem) => { + const drm = extractDrmFromRaw(item.raw); + return { + ...item, + id: uuidv4(), + ...(drm ? { drm } : {}), + }; + }), }, importDate: new Date().toISOString(), lastUsage: new Date().toISOString(), diff --git a/libs/ui/playback/src/lib/art-player/art-player-setup.spec.ts b/libs/ui/playback/src/lib/art-player/art-player-setup.spec.ts index cf42526bc..f02a4b006 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-setup.spec.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-setup.spec.ts @@ -83,6 +83,17 @@ describe('ArtPlayer setup', () => { ); }); + it('routes DASH manifests to the mpd custom type', () => { + expect(getArtPlayerVideoType('https://example.test/live.mpd')).toBe( + 'mpd' + ); + expect( + getArtPlayerVideoType( + 'https://example.test/live.mpd?token=signed#frag' + ) + ).toBe('mpd'); + }); + it('exits fullscreen only when the shared ArtPlayer shell owns it', () => { const fullscreenElementDescriptor = Object.getOwnPropertyDescriptor( document, 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 2e3b0cebe..9e78fe990 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 @@ -72,6 +72,8 @@ export function getArtPlayerVideoType(url: string): string { return 'm3u8'; case 'mp4': return 'mp4'; + case 'mpd': + return 'mpd'; case 'ts': return 'ts'; default: 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 new file mode 100644 index 000000000..dd48c9565 --- /dev/null +++ b/libs/ui/playback/src/lib/art-player/art-player-source-session.dash.spec.ts @@ -0,0 +1,85 @@ +import type { ChannelDrm } from '@iptvnator/shared/interfaces'; +import { + createFakeShakaEnvironment, + flushShakaMicrotasks, +} from '../shaka-engine/shaka-player-test-double'; +import { + createSession, + hlsInstances, + initArtPlayerSourceSessionModule, + mpegTsInstances, + resetArtPlayerSourceFixtures, +} from './art-player-source-session.spec-fixtures'; + +describe('ArtPlayerSourceSession DASH (mpd custom type)', () => { + beforeAll(async () => { + await initArtPlayerSourceSessionModule(); + }); + + beforeEach(() => { + resetArtPlayerSourceFixtures(); + }); + + it('starts the Shaka engine with channel DRM for the mpd custom type', async () => { + const fakeShaka = createFakeShakaEnvironment(); + const drm: ChannelDrm = { + licenseType: 'clearkey', + supported: true, + clearKeys: { abc: 'def' }, + }; + const { session, player, video } = createSession({ + sharedControls: true, + getDrm: () => drm, + loadShaka: fakeShaka.loader, + }); + session.attach(player); + + session.customType['mpd']?.( + video, + 'https://example.test/live.mpd', + player + ); + await flushShakaMicrotasks(); + + expect(fakeShaka.loaderCalls).toBe(1); + expect(fakeShaka.instances).toHaveLength(1); + expect(fakeShaka.instances[0].attachedTo).toBe(video); + expect(fakeShaka.instances[0].loadedUrls).toEqual([ + 'https://example.test/live.mpd', + ]); + expect(fakeShaka.instances[0].configureCalls).toEqual([ + { drm: { clearKeys: { abc: 'def' } } }, + ]); + expect(hlsInstances).toHaveLength(0); + expect(mpegTsInstances).toHaveLength(0); + }); + + it('destroys the Shaka engine when the source changes or the session dies', async () => { + const fakeShaka = createFakeShakaEnvironment(); + const { session, player, video } = createSession({ + sharedControls: false, + loadShaka: fakeShaka.loader, + }); + session.attach(player); + + session.customType['mpd']?.( + video, + 'https://example.test/live.mpd', + player + ); + await flushShakaMicrotasks(); + session.customType['m3u8']?.( + video, + 'https://example.test/live.m3u8', + player + ); + await flushShakaMicrotasks(); + + expect(fakeShaka.instances[0].destroyCount).toBe(1); + expect(hlsInstances).toHaveLength(1); + + session.destroy(); + await flushShakaMicrotasks(); + expect(fakeShaka.instances).toHaveLength(1); + }); +}); 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 new file mode 100644 index 000000000..26a046c2c --- /dev/null +++ b/libs/ui/playback/src/lib/art-player/art-player-source-session.spec-fixtures.ts @@ -0,0 +1,191 @@ +import type Artplayer from 'artplayer'; +import type { ChannelDrm } from '@iptvnator/shared/interfaces'; +import type { PlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.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'; + +/** + * Shared engine mocks and session bootstrap for the ArtPlayerSourceSession + * spec files. Importing this module registers the hls.js/mpegts.js module + * mocks, so it must be imported before `initArtPlayerSourceSessionModule()` + * dynamically loads the session under test. + */ + +export const hlsInstances: MockHls[] = []; +export const mpegTsInstances: MockMpegTsPlayer[] = []; + +export class MockHls { + static Events = { + MANIFEST_PARSED: 'manifestParsed', + ERROR: 'error', + AUDIO_TRACKS_UPDATED: 'audioTracksUpdated', + AUDIO_TRACK_SWITCHING: 'audioTrackSwitching', + AUDIO_TRACK_SWITCHED: 'audioTrackSwitched', + SUBTITLE_TRACKS_UPDATED: 'subtitleTracksUpdated', + SUBTITLE_TRACKS_CLEARED: 'subtitleTracksCleared', + SUBTITLE_TRACK_SWITCH: 'subtitleTrackSwitch', + MANIFEST_LOADING: 'manifestLoading', + }; + + static isSupported = jest.fn(() => true); + + readonly handlers = new Map void>>(); + readonly on = jest.fn( + (event: string, handler: (...args: unknown[]) => void) => { + const handlers = this.handlers.get(event) ?? []; + handlers.push(handler); + this.handlers.set(event, handlers); + } + ); + readonly off = jest.fn( + (event: string, handler: (...args: unknown[]) => void) => { + const handlers = this.handlers.get(event) ?? []; + this.handlers.set( + event, + handlers.filter((candidate) => candidate !== handler) + ); + } + ); + readonly attachMedia = jest.fn(); + readonly loadSource = jest.fn(); + readonly destroy = jest.fn(); + audioTracks: Array<{ name?: string; lang?: string }> = []; + audioTrack = 0; + subtitleTracks: Array<{ name?: string; lang?: string }> = []; + subtitleTrack = -1; + subtitleDisplay = false; + + constructor() { + hlsInstances.push(this); + } + + emit(event: string, ...args: unknown[]): void { + for (const handler of this.handlers.get(event) ?? []) { + handler(...args); + } + } +} + +export class MockMpegTsPlayer { + readonly handlers = new Map void>(); + readonly attachMediaElement = jest.fn(); + readonly on = jest.fn( + (event: string, handler: (...args: unknown[]) => void) => { + this.handlers.set(event, handler); + } + ); + readonly off = jest.fn( + (event: string, handler: (...args: unknown[]) => void) => { + if (this.handlers.get(event) === handler) { + this.handlers.delete(event); + } + } + ); + readonly load = jest.fn(); + readonly play = jest.fn(() => undefined as Promise | void); + readonly pause = jest.fn(); + readonly unload = jest.fn(); + readonly detachMediaElement = jest.fn(); + readonly destroy = jest.fn(); + + constructor() { + mpegTsInstances.push(this); + } +} + +export const createMpegTsPlayer = jest.fn(() => new MockMpegTsPlayer()); + +jest.unstable_mockModule('hls.js', () => ({ + default: MockHls, +})); + +jest.unstable_mockModule('mpegts.js', () => ({ + default: { + Events: { ERROR: 'error' }, + createPlayer: createMpegTsPlayer, + isSupported: jest.fn(() => true), + }, +})); + +let sessionConstructor: + | typeof import('./art-player-source-session').ArtPlayerSourceSession + | undefined; + +/** Loads the session module after the engine mocks above are registered. */ +export async function initArtPlayerSourceSessionModule(): Promise { + ({ ArtPlayerSourceSession: sessionConstructor } = await import( + './art-player-source-session' + )); +} + +export function resetArtPlayerSourceFixtures(): void { + hlsInstances.length = 0; + mpegTsInstances.length = 0; + createMpegTsPlayer + .mockClear() + .mockImplementation(() => new MockMpegTsPlayer()); + MockHls.isSupported.mockReturnValue(true); +} + +export function createSession({ + sharedControls, + isLive = true, + emitPlaybackIssue = () => undefined, + getDrm, + loadShaka, +}: { + sharedControls: boolean; + isLive?: boolean; + emitPlaybackIssue?: (issue: PlaybackDiagnostic) => void; + getDrm?: () => ChannelDrm | undefined; + loadShaka?: ShakaModuleLoader; +}): { + session: ArtPlayerSourceSessionInstance; + player: Artplayer; + video: HTMLVideoElement; + adapter: WebVideoControlsAdapter; + settingAdd: jest.Mock; +} { + const video = document.createElement('video'); + const settingAdd = jest.fn(); + const player = { + video, + setting: { add: settingAdd }, + } as unknown as Artplayer; + const adapter = new WebVideoControlsAdapter(); + const Session = requireSessionConstructor(); + + return { + session: new Session({ + sharedControls, + controlsAdapter: adapter, + isLive: () => isLive, + showCaptions: () => false, + emitPlaybackIssue, + getDrm, + loadShaka, + }), + player, + video, + adapter, + settingAdd, + }; +} + +function requireSessionConstructor(): typeof import('./art-player-source-session').ArtPlayerSourceSession { + if (!sessionConstructor) { + throw new Error( + 'ArtPlayerSourceSession test module is not initialized' + ); + } + return sessionConstructor; +} + +export function createTimeRanges(ends: number[]): TimeRanges { + return { + length: ends.length, + start: () => 0, + end: (index: number) => ends[index], + }; +} 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 bd1f1a8ee..05b7f031f 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,118 +1,23 @@ -import type Artplayer from 'artplayer'; import type { PlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util'; -import { WebVideoControlsAdapter } from '../player-controls'; -import type { ArtPlayerSourceSession as ArtPlayerSourceSessionInstance } from './art-player-source-session'; - -const hlsInstances: MockHls[] = []; -const mpegTsInstances: MockMpegTsPlayer[] = []; - -class MockHls { - static Events = { - MANIFEST_PARSED: 'manifestParsed', - ERROR: 'error', - AUDIO_TRACKS_UPDATED: 'audioTracksUpdated', - AUDIO_TRACK_SWITCHING: 'audioTrackSwitching', - AUDIO_TRACK_SWITCHED: 'audioTrackSwitched', - SUBTITLE_TRACKS_UPDATED: 'subtitleTracksUpdated', - SUBTITLE_TRACKS_CLEARED: 'subtitleTracksCleared', - SUBTITLE_TRACK_SWITCH: 'subtitleTrackSwitch', - MANIFEST_LOADING: 'manifestLoading', - }; - - static isSupported = jest.fn(() => true); - - readonly handlers = new Map void>>(); - readonly on = jest.fn( - (event: string, handler: (...args: unknown[]) => void) => { - const handlers = this.handlers.get(event) ?? []; - handlers.push(handler); - this.handlers.set(event, handlers); - } - ); - readonly off = jest.fn( - (event: string, handler: (...args: unknown[]) => void) => { - const handlers = this.handlers.get(event) ?? []; - this.handlers.set( - event, - handlers.filter((candidate) => candidate !== handler) - ); - } - ); - readonly attachMedia = jest.fn(); - readonly loadSource = jest.fn(); - readonly destroy = jest.fn(); - audioTracks: Array<{ name?: string; lang?: string }> = []; - audioTrack = 0; - subtitleTracks: Array<{ name?: string; lang?: string }> = []; - subtitleTrack = -1; - subtitleDisplay = false; - - constructor() { - hlsInstances.push(this); - } - - emit(event: string, ...args: unknown[]): void { - for (const handler of this.handlers.get(event) ?? []) { - handler(...args); - } - } -} - -class MockMpegTsPlayer { - readonly handlers = new Map void>(); - readonly attachMediaElement = jest.fn(); - readonly on = jest.fn( - (event: string, handler: (...args: unknown[]) => void) => { - this.handlers.set(event, handler); - } - ); - readonly off = jest.fn( - (event: string, handler: (...args: unknown[]) => void) => { - if (this.handlers.get(event) === handler) { - this.handlers.delete(event); - } - } - ); - readonly load = jest.fn(); - readonly play = jest.fn(() => undefined as Promise | void); - readonly pause = jest.fn(); - readonly unload = jest.fn(); - readonly detachMediaElement = jest.fn(); - readonly destroy = jest.fn(); - - constructor() { - mpegTsInstances.push(this); - } -} - -const createMpegTsPlayer = jest.fn(() => new MockMpegTsPlayer()); - -jest.unstable_mockModule('hls.js', () => ({ - default: MockHls, -})); - -jest.unstable_mockModule('mpegts.js', () => ({ - default: { - Events: { ERROR: 'error' }, - createPlayer: createMpegTsPlayer, - isSupported: jest.fn(() => true), - }, -})); +import { + MockHls, + MockMpegTsPlayer, + createMpegTsPlayer, + createSession, + createTimeRanges, + hlsInstances, + initArtPlayerSourceSessionModule, + mpegTsInstances, + resetArtPlayerSourceFixtures, +} from './art-player-source-session.spec-fixtures'; describe('ArtPlayerSourceSession', () => { - let ArtPlayerSourceSession: typeof import('./art-player-source-session').ArtPlayerSourceSession; - beforeAll(async () => { - ({ ArtPlayerSourceSession } = - await import('./art-player-source-session')); - sessionConstructor = ArtPlayerSourceSession; + await initArtPlayerSourceSessionModule(); }); beforeEach(() => { - hlsInstances.length = 0; - mpegTsInstances.length = 0; - createMpegTsPlayer.mockClear(); - MockHls.isSupported.mockReturnValue(true); + resetArtPlayerSourceFixtures(); }); it('binds HLS tracks to shared controls and tears every listener down exactly', () => { @@ -336,63 +241,3 @@ describe('ArtPlayerSourceSession', () => { expect(refresh).toHaveBeenCalled(); }); }); - -function createSession({ - sharedControls, - isLive = true, - emitPlaybackIssue = () => undefined, -}: { - sharedControls: boolean; - isLive?: boolean; - emitPlaybackIssue?: (issue: PlaybackDiagnostic) => void; -}): { - session: ArtPlayerSourceSessionInstance; - player: Artplayer; - video: HTMLVideoElement; - adapter: WebVideoControlsAdapter; - settingAdd: jest.Mock; -} { - const video = document.createElement('video'); - const settingAdd = jest.fn(); - const player = { - video, - setting: { add: settingAdd }, - } as unknown as Artplayer; - const adapter = new WebVideoControlsAdapter(); - const Session = requireSessionConstructor(); - - return { - session: new Session({ - sharedControls, - controlsAdapter: adapter, - isLive: () => isLive, - showCaptions: () => false, - emitPlaybackIssue, - }), - player, - video, - adapter, - settingAdd, - }; -} - -let sessionConstructor: - | typeof import('./art-player-source-session').ArtPlayerSourceSession - | undefined; - -function requireSessionConstructor(): typeof import('./art-player-source-session').ArtPlayerSourceSession { - if (!sessionConstructor) { - throw new Error( - 'ArtPlayerSourceSession test module is not initialized' - ); - } - return sessionConstructor; -} - -function createTimeRanges(ends: number[]): TimeRanges { - return { - length: ends.length, - start: () => 0, - end: (index: number) => ends[index], - }; -} 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 7040409e1..def6b1be7 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 @@ -2,6 +2,7 @@ import type Artplayer from 'artplayer'; import type { Option } from 'artplayer'; import Hls, { type ErrorData, type ManifestParsedData } from 'hls.js'; import mpegts from 'mpegts.js'; +import type { ChannelDrm } from '@iptvnator/shared/interfaces'; import { InlinePlaybackPlayer, type PlaybackDiagnostic, @@ -11,6 +12,8 @@ import { createPlaybackSourceMetadata, } from '../playback-diagnostics/playback-diagnostics.util'; import type { WebVideoControlsAdapter } from '../player-controls'; +import type { ShakaModuleLoader } from '../shaka-engine/shaka-module.types'; +import { ShakaVideoSession } from '../shaka-engine/shaka-video-session'; import { type WebVideoControlsSource, WebVideoSourceControlsBridge, @@ -23,6 +26,10 @@ export interface ArtPlayerSourceSessionConfig { isLive: () => boolean; showCaptions: () => boolean; emitPlaybackIssue: (issue: PlaybackDiagnostic) => void; + /** DRM config of the active channel, used by the DASH (`mpd`) engine. */ + getDrm?: () => ChannelDrm | undefined; + /** Test seam for the lazily imported shaka-player module. */ + loadShaka?: ShakaModuleLoader; } /** @@ -35,6 +42,7 @@ export interface ArtPlayerSourceSessionConfig { export class ArtPlayerSourceSession { readonly customType: NonNullable = { m3u8: (video, url, art) => this.startHls(video, url, art), + mpd: (video, url) => this.startDash(video, url), ts: (video, url) => this.startMpegTs(video, url), 'video/matroska': (video, url) => this.startNative(video, url), }; @@ -53,6 +61,7 @@ export class ArtPlayerSourceSession { private mpegTsErrorListener: | ((type: string, details: string, info: unknown) => void) | null = null; + private shakaSession: ShakaVideoSession | null = null; private destroyed = false; constructor(private readonly config: ArtPlayerSourceSessionConfig) {} @@ -99,6 +108,8 @@ export class ArtPlayerSourceSession { this.controlsBridge = null; this.destroyHls(); this.destroyMpegTs(); + this.shakaSession?.destroy(); + this.shakaSession = null; this.player = null; } @@ -181,6 +192,17 @@ export class ArtPlayerSourceSession { } } + private startDash(video: HTMLVideoElement, url: string): void { + if (this.destroyed) { + return; + } + + this.prepareForSourceChange(); + const session = this.getShakaSession(); + this.bindControlsSource({ kind: 'shaka', session }); + session.start(video, url, this.config.getDrm?.()); + } + private startNative(video: HTMLVideoElement, url: string): void { if (this.destroyed) { return; @@ -195,6 +217,18 @@ export class ArtPlayerSourceSession { this.controlsBridge?.clearSource(); this.destroyHls(); this.destroyMpegTs(); + this.shakaSession?.stop(); + } + + private getShakaSession(): ShakaVideoSession { + this.shakaSession ??= new ShakaVideoSession({ + player: InlinePlaybackPlayer.ArtPlayer, + emitPlaybackIssue: (issue) => + this.config.emitPlaybackIssue(issue), + showCaptions: this.config.showCaptions, + loadShaka: this.config.loadShaka, + }); + return this.shakaSession; } private bindControlsSource(source: WebVideoControlsSource): void { 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 7da5b597e..61cb5235f 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 @@ -136,6 +136,7 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges { isLive: () => this.isLive(), showCaptions: () => this.showCaptions(), emitPlaybackIssue: (issue) => this.playbackIssue.emit(issue), + getDrm: () => this.channel().drm, }); this.sourceSession = sourceSession; 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 new file mode 100644 index 000000000..4c5284892 --- /dev/null +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player-diagnostics.ts @@ -0,0 +1,87 @@ +import type { ErrorData, ManifestParsedData } from 'hls.js'; +import { + InlinePlaybackPlayer, + type PlaybackDiagnostic, + type PlaybackSourceMetadata, + classifyHlsPlaybackIssue, + classifyMpegTsPlaybackIssue, + classifyUnsupportedHlsManifestCodecs, + createPlaybackSourceMetadata, +} from '../playback-diagnostics/playback-diagnostics.util'; + +/** + * HTML5-player diagnostics glue extracted from the component: builds source + * metadata and turns engine error payloads into emitted playback issues. + */ + +export function createHtml5SourceMetadata( + url: string, + mimeType?: string, + audioCodecs: readonly string[] = [], + videoCodecs: readonly string[] = [] +): PlaybackSourceMetadata { + return createPlaybackSourceMetadata({ + url, + mimeType, + player: InlinePlaybackPlayer.Html5, + audioCodecs, + videoCodecs, + }); +} + +export function emitUnsupportedHlsManifestCodecs( + url: string, + data: ManifestParsedData, + emitPlaybackIssue: (issue: PlaybackDiagnostic) => void +): void { + const metadata = createHtml5SourceMetadata( + url, + 'application/x-mpegURL', + data.levels + .map((level) => level.audioCodec) + .filter((codec): codec is string => Boolean(codec)), + data.levels + .map((level) => level.videoCodec) + .filter((codec): codec is string => Boolean(codec)) + ); + const issue = classifyUnsupportedHlsManifestCodecs(metadata); + if (issue) { + emitPlaybackIssue(issue); + } +} + +export function emitFatalHlsPlaybackError( + url: string, + data: ErrorData, + emitPlaybackIssue: (issue: PlaybackDiagnostic) => void +): void { + if (!data.fatal) { + return; + } + + emitPlaybackIssue( + classifyHlsPlaybackIssue( + { + type: data.type, + details: data.details, + fatal: data.fatal, + message: data.error?.message, + error: data.error, + }, + createHtml5SourceMetadata(url, 'application/x-mpegURL') + ) + ); +} + +export function emitMpegTsPlaybackError( + url: string, + error: { type: string; details: string; info: unknown }, + emitPlaybackIssue: (issue: PlaybackDiagnostic) => void +): void { + emitPlaybackIssue( + classifyMpegTsPlaybackIssue( + error, + createHtml5SourceMetadata(url, 'video/mp2t') + ) + ); +} diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.dash.spec.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.dash.spec.ts new file mode 100644 index 000000000..02223c14e --- /dev/null +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.dash.spec.ts @@ -0,0 +1,81 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { DataService } from '@iptvnator/services'; +import { Channel } from '@iptvnator/shared/interfaces'; +import { HtmlVideoPlayerComponent } from './html-video-player.component'; + +/** DASH-specific behavior of the HTML5 player (legacy controls mode). */ +describe('HtmlVideoPlayerComponent DASH', () => { + let component: HtmlVideoPlayerComponent; + let fixture: ComponentFixture; + const electronApi = { + setUserAgent: jest.fn().mockResolvedValue(true), + }; + + const TEST_CHANNEL: Channel = { + id: 'dash-1', + url: 'https://stream.example/enc.mpd', + name: 'DASH channel', + group: { title: 'DASH' }, + http: { origin: '', referrer: '', 'user-agent': '' }, + radio: 'false', + tvg: { id: '', logo: '', name: '', rec: '', url: '' }, + }; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [HtmlVideoPlayerComponent, TranslateModule.forRoot()], + providers: [ + { + provide: DataService, + useValue: { + sendIpcEvent: jest.fn().mockResolvedValue(undefined), + }, + }, + ], + }).compileComponents(); + })); + + beforeEach(() => { + Object.defineProperty(window, 'electron', { + configurable: true, + value: electronApi, + }); + fixture = TestBed.createComponent(HtmlVideoPlayerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + delete (window as unknown as { electron?: unknown }).electron; + jest.restoreAllMocks(); + }); + + it('does not resume the previous source when a channel declares unsupported DRM', () => { + const video = component.videoPlayer.nativeElement; + const loadSpy = jest + .spyOn(video, 'load') + .mockImplementation(() => undefined); + const playSpy = jest.spyOn(video, 'play').mockResolvedValue(undefined); + + component.playChannel({ + ...TEST_CHANNEL, + url: 'https://stream.example/movie.mp4', + }); + playSpy.mockClear(); + loadSpy.mockClear(); + + component.playChannel({ + ...TEST_CHANNEL, + drm: { + licenseType: 'com.widevine.alpha', + supported: false, + }, + }); + + // No source is loaded for unsupported DRM: play() must not run, and + // the element is reset so the old stream cannot keep playing. + expect(playSpy).not.toHaveBeenCalled(); + expect(loadSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.sources.spec.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.sources.spec.ts index 9b336e083..1c82ec2b4 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.sources.spec.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.sources.spec.ts @@ -126,6 +126,37 @@ describe('HtmlVideoPlayerComponent shared controls sources', () => { ); }); + it('owns one Shaka source for DASH (.mpd) streams', () => { + const { component } = renderSharedControls( + HtmlVideoPlayerComponent, + fixtures + ); + const setSource = observeBridgeSourceBinding(component); + + component.playChannel({ + ...TEST_CHANNEL, + url: 'https://example.test/live.mpd', + drm: { + licenseType: 'clearkey', + supported: true, + clearKeys: { abc: 'def' }, + }, + }); + + const internals = readHtmlPlayerInternals(component); + expect(setSource).toHaveBeenCalledTimes(1); + expect(setSource).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'shaka' }) + ); + expect(internals.controlsSource).toEqual( + expect.objectContaining({ kind: 'shaka' }) + ); + expect(internals.hls).toBeNull(); + expect(internals.mpegtsPlayer).toBeNull(); + expect(hlsInstances).toHaveLength(0); + expect(mpegTsInstances).toHaveLength(0); + }); + it('owns one native source before loading native media', () => { const { component, fixture } = renderSharedControls( HtmlVideoPlayerComponent, diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.spec-fixtures.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.spec-fixtures.ts index 926b5f850..ade52f86e 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.spec-fixtures.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.spec-fixtures.ts @@ -19,7 +19,7 @@ export type SharedControlsFixture = ComponentFixture; export interface ControlsSource { - kind: 'native' | 'mpegts' | 'hls'; + kind: 'native' | 'mpegts' | 'hls' | 'shaka'; } export interface HtmlPlayerInternals { 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 58e4a8200..4ebb45ae0 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 @@ -20,10 +20,6 @@ import { Channel, createDevLogger } from '@iptvnator/shared/interfaces'; import { InlinePlaybackPlayer, PlaybackDiagnostic, - classifyHlsPlaybackIssue, - classifyMpegTsPlaybackIssue, - classifyUnsupportedHlsManifestCodecs, - createPlaybackSourceMetadata, getPlaybackMediaExtensionFromUrl, } from '../playback-diagnostics/playback-diagnostics.util'; import { @@ -34,7 +30,18 @@ import { } from '../player-controls'; import { SeriesPlaybackNavigationControlsComponent } from '../portal-inline-player/series-playback-navigation-controls.component'; import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation'; +import { ShakaVideoSession } from '../shaka-engine/shaka-video-session'; +import { exitOwnedFullscreen } from '../web-video-support/exit-owned-fullscreen.util'; +import { + clearNativeVideoSources, + setNativeVideoSource, +} from '../web-video-support/web-video-native-source.util'; import { HtmlVideoElementSession } from './html-video-element-session'; +import { + emitFatalHlsPlaybackError, + emitMpegTsPlaybackError, + emitUnsupportedHlsManifestCodecs, +} from './html-video-player-diagnostics'; import { HtmlVideoPlayerControlsBridge, type HtmlVideoControlsSource, @@ -90,6 +97,8 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { hls: Hls | null = null; /** mpegts.js player for raw MPEG-TS streams */ private mpegtsPlayer: mpegts.Player | null = null; + /** Shaka session for DASH (.mpd) streams, created on first use */ + private shakaSession: ShakaVideoSession | null = null; private controlsSource: HtmlVideoControlsSource | null = null; private controlsBridge: HtmlVideoPlayerControlsBridge | null = null; private videoSession: HtmlVideoElementSession | null = null; @@ -129,7 +138,15 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { this.controlsBridge?.refreshInputs(); } if (changes['interactionEnabled']?.currentValue === false) { - this.exitOwnedFullscreen(); + exitOwnedFullscreen( + this.sharedControls, + this.playerRoot()?.nativeElement, + (error) => + debugHtmlPlayer( + 'Failed to exit HTML5 player fullscreen:', + error + ) + ); } if (changes['volume']?.currentValue !== undefined) { debugHtmlPlayer( @@ -141,29 +158,6 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { } } - private exitOwnedFullscreen(): void { - if ( - !this.sharedControls || - document.fullscreenElement !== this.playerRoot()?.nativeElement || - typeof document.exitFullscreen !== 'function' - ) { - return; - } - - try { - void Promise.resolve(document.exitFullscreen()).catch( - (error: unknown) => { - debugHtmlPlayer( - 'Failed to exit HTML5 player fullscreen:', - error - ); - } - ); - } catch (error: unknown) { - debugHtmlPlayer('Failed to exit HTML5 player fullscreen:', error); - } - } - /** * Starts to play the given channel * @param channel given channel object @@ -172,7 +166,8 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { this.clearControlsSource(); this.destroyMpegtsPlayer(); this.destroyHls(); - this.clearNativeVideoSources(this.videoPlayer.nativeElement); + this.shakaSession?.stop(); + clearNativeVideoSources(this.videoPlayer.nativeElement); if (channel.url) { this.playbackIssue.emit(null); const url = channel.url + (channel.epgParams ?? ''); @@ -191,7 +186,31 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { ); }); - if ((extension === 'ts' || !extension) && mpegts.isSupported()) { + if (extension === 'mpd') { + debugHtmlPlayer( + 'Using Shaka Player for DASH stream:', + channel.name, + url + ); + const session = this.getShakaSession(); + this.bindControlsSource({ kind: 'shaka', session }); + session.start( + this.videoPlayer.nativeElement, + url, + channel.drm + ); + if (channel.drm && !channel.drm.supported) { + // No source is loaded for unsupported DRM; reset the + // element so the previous stream cannot resume playing + // underneath the diagnostic banner. + this.videoPlayer.nativeElement.load(); + } else { + this.handlePlayOperation(); + } + } else if ( + (extension === 'ts' || !extension) && + mpegts.isSupported() + ) { debugHtmlPlayer( 'Using mpegts.js for TS stream:', channel.name, @@ -209,15 +228,10 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { this.mpegtsPlayer.on( mpegts.Events.ERROR, (type: string, details: string, info: unknown): void => { - this.playbackIssue.emit( - classifyMpegTsPlaybackIssue( - { - type, - details, - info, - }, - this.createSourceMetadata(url, 'video/mp2t') - ) + emitMpegTsPlaybackError( + url, + { type, details, info }, + (issue) => this.playbackIssue.emit(issue) ); } ); @@ -244,35 +258,18 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { this.handlePlayOperation(); } else { debugHtmlPlayer('Using native video player'); - this.replaceNativeVideoSource( + setNativeVideoSource( this.videoPlayer.nativeElement, url, 'video/mp4' ); + this.bindControlsSource({ kind: 'native' }); + this.videoPlayer.nativeElement.load(); this.handlePlayOperation(); } } } - private clearNativeVideoSources(element: HTMLVideoElement): void { - element.removeAttribute('src'); - element.replaceChildren(); - } - - private replaceNativeVideoSource( - element: HTMLVideoElement, - url: string, - type: string - ): void { - this.clearNativeVideoSources(element); - const source = document.createElement('source'); - source.src = url; - source.type = type; - element.appendChild(source); - this.bindControlsSource({ kind: 'native' }); - element.load(); - } - private bindControlsSource(source: HtmlVideoControlsSource): void { this.controlsSource = source; this.controlsBridge?.setSource(source); @@ -301,6 +298,15 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { hls?.destroy(); } + private getShakaSession(): ShakaVideoSession { + this.shakaSession ??= new ShakaVideoSession({ + player: InlinePlaybackPlayer.Html5, + emitPlaybackIssue: (issue) => this.playbackIssue.emit(issue), + showCaptions: () => this.showCaptions(), + }); + return this.shakaSession; + } + /** * Disables text based captions based on the global settings */ @@ -319,56 +325,17 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { url: string, data: ManifestParsedData ): void { - const metadata = this.createSourceMetadata( - url, - 'application/x-mpegURL', - data.levels - .map((level) => level.audioCodec) - .filter((codec): codec is string => Boolean(codec)), - data.levels - .map((level) => level.videoCodec) - .filter((codec): codec is string => Boolean(codec)) + emitUnsupportedHlsManifestCodecs(url, data, (issue) => + this.playbackIssue.emit(issue) ); - const issue = classifyUnsupportedHlsManifestCodecs(metadata); - if (issue) { - this.playbackIssue.emit(issue); - } } private handleHlsError(url: string, data: ErrorData): void { - if (!data.fatal) { - return; - } - - this.playbackIssue.emit( - classifyHlsPlaybackIssue( - { - type: data.type, - details: data.details, - fatal: data.fatal, - message: data.error?.message, - error: data.error, - }, - this.createSourceMetadata(url, 'application/x-mpegURL') - ) + emitFatalHlsPlaybackError(url, data, (issue) => + this.playbackIssue.emit(issue) ); } - private createSourceMetadata( - url: string, - mimeType?: string, - audioCodecs: readonly string[] = [], - videoCodecs: readonly string[] = [] - ) { - return createPlaybackSourceMetadata({ - url, - mimeType, - player: InlinePlaybackPlayer.Html5, - audioCodecs, - videoCodecs, - }); - } - /** * Destroy hls instance on component destroy and clean up event listener */ @@ -380,6 +347,8 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { this.videoSession = null; this.destroyMpegtsPlayer(); this.destroyHls(); + this.shakaSession?.destroy(); + this.shakaSession = null; } private getVideoSession(): HtmlVideoElementSession { diff --git a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.model.ts b/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.model.ts index 4bc4f8195..1079ab2f1 100644 --- a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.model.ts +++ b/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.model.ts @@ -21,6 +21,7 @@ export const PlaybackDiagnosticSource = { Native: 'native', Hls: 'hls', MpegTs: 'mpegts', + Shaka: 'shaka', } as const; export type PlaybackDiagnosticSource = diff --git a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts b/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts index 8cf24499c..298f054bb 100644 --- a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts +++ b/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts @@ -45,7 +45,7 @@ export function classifyNativePlaybackIssue( // Native MediaError details are often opaque for browser security // failures. Only classify browser access when the runtime exposes a // concrete CORS/mixed-content/CSP-style message. - return createDiagnostic({ + return createPlaybackDiagnostic({ code: isBrowserAccessFailure(lowerNativeErrorMessage) ? DiagnosticCode.BrowserAccessError : DiagnosticCode.NetworkError, @@ -57,7 +57,7 @@ export function classifyNativePlaybackIssue( } if (nativeErrorCode === DECODE_ERROR_CODE) { - return createDiagnostic({ + return createPlaybackDiagnostic({ code: DiagnosticCode.MediaDecodeError, source: DiagnosticSource.Native, metadata, @@ -67,7 +67,7 @@ export function classifyNativePlaybackIssue( } if (nativeErrorCode === SOURCE_NOT_SUPPORTED_CODE) { - return createDiagnostic({ + return createPlaybackDiagnostic({ code: isLikelyContainerIssue(metadata) ? DiagnosticCode.UnsupportedContainer : DiagnosticCode.UnsupportedCodec, @@ -78,7 +78,7 @@ export function classifyNativePlaybackIssue( }); } - return createDiagnostic({ + return createPlaybackDiagnostic({ code: DiagnosticCode.UnknownPlaybackError, source: DiagnosticSource.Native, metadata, @@ -100,7 +100,7 @@ export function classifyHlsPlaybackIssue( }); if (isNetworkFailure(lowerType, lowerDetails)) { - return createDiagnostic({ + return createPlaybackDiagnostic({ code: isBrowserAccessFailure(lowerDetails) ? DiagnosticCode.BrowserAccessError : DiagnosticCode.NetworkError, @@ -111,7 +111,7 @@ export function classifyHlsPlaybackIssue( } if (isDrmOrEncryptionFailure(lowerDetails)) { - return createDiagnostic({ + return createPlaybackDiagnostic({ code: DiagnosticCode.DrmOrEncryption, source: DiagnosticSource.Hls, metadata: mergedMetadata, @@ -120,7 +120,7 @@ export function classifyHlsPlaybackIssue( } if (isCodecFailure(lowerDetails)) { - return createDiagnostic({ + return createPlaybackDiagnostic({ code: DiagnosticCode.UnsupportedCodec, source: DiagnosticSource.Hls, metadata: mergedMetadata, @@ -129,7 +129,7 @@ export function classifyHlsPlaybackIssue( } if (lowerType.includes('media') || lowerType.includes('mux')) { - return createDiagnostic({ + return createPlaybackDiagnostic({ code: DiagnosticCode.MediaDecodeError, source: DiagnosticSource.Hls, metadata: mergedMetadata, @@ -137,7 +137,7 @@ export function classifyHlsPlaybackIssue( }); } - return createDiagnostic({ + return createPlaybackDiagnostic({ code: DiagnosticCode.UnknownPlaybackError, source: DiagnosticSource.Hls, metadata: mergedMetadata, @@ -154,7 +154,7 @@ export function classifyMpegTsPlaybackIssue( const lowerType = (error.type ?? '').toLowerCase(); if (isEarlyEofFailure(lowerDetails)) { - return createDiagnostic({ + return createPlaybackDiagnostic({ code: DiagnosticCode.MediaDecodeError, source: DiagnosticSource.MpegTs, metadata, @@ -163,7 +163,7 @@ export function classifyMpegTsPlaybackIssue( } if (isNetworkFailure(lowerType, lowerDetails)) { - return createDiagnostic({ + return createPlaybackDiagnostic({ code: isBrowserAccessFailure(lowerDetails) ? DiagnosticCode.BrowserAccessError : DiagnosticCode.NetworkError, @@ -174,7 +174,7 @@ export function classifyMpegTsPlaybackIssue( } if (lowerDetails.includes('codec')) { - return createDiagnostic({ + return createPlaybackDiagnostic({ code: DiagnosticCode.UnsupportedCodec, source: DiagnosticSource.MpegTs, metadata, @@ -183,7 +183,7 @@ export function classifyMpegTsPlaybackIssue( } if (lowerDetails.includes('format') || lowerDetails.includes('mse')) { - return createDiagnostic({ + return createPlaybackDiagnostic({ code: DiagnosticCode.UnsupportedContainer, source: DiagnosticSource.MpegTs, metadata, @@ -192,7 +192,7 @@ export function classifyMpegTsPlaybackIssue( } if (lowerType.includes('media')) { - return createDiagnostic({ + return createPlaybackDiagnostic({ code: DiagnosticCode.MediaDecodeError, source: DiagnosticSource.MpegTs, metadata, @@ -200,7 +200,7 @@ export function classifyMpegTsPlaybackIssue( }); } - return createDiagnostic({ + return createPlaybackDiagnostic({ code: DiagnosticCode.UnknownPlaybackError, source: DiagnosticSource.MpegTs, metadata, @@ -222,7 +222,7 @@ export function classifyUnsupportedHlsManifestCodecs( return null; } - return createDiagnostic({ + return createPlaybackDiagnostic({ code: DiagnosticCode.UnsupportedCodec, source: DiagnosticSource.Source, metadata, @@ -230,13 +230,16 @@ export function classifyUnsupportedHlsManifestCodecs( }); } -function createDiagnostic(options: { +export function createPlaybackDiagnostic(options: { readonly code: PlaybackDiagnosticCode; readonly source: PlaybackDiagnosticSource; readonly metadata: PlaybackSourceMetadata; readonly details?: string; readonly nativeErrorCode?: number; readonly nativeErrorMessage?: string; + /** Overrides the code-derived recommendation, e.g. when external players + * are known to be unable to handle the stream either. */ + readonly externalFallbackRecommended?: boolean; }): PlaybackDiagnostic { const { code, @@ -259,7 +262,9 @@ function createDiagnostic(options: { details: details || undefined, nativeErrorCode, nativeErrorMessage, - externalFallbackRecommended: isExternalFallbackRecommended(code), + externalFallbackRecommended: + options.externalFallbackRecommended ?? + isExternalFallbackRecommended(code), }; } diff --git a/libs/ui/playback/src/lib/playback-diagnostics/playback-media-source.util.ts b/libs/ui/playback/src/lib/playback-diagnostics/playback-media-source.util.ts index ba3ab2000..8ac8ff5d9 100644 --- a/libs/ui/playback/src/lib/playback-diagnostics/playback-media-source.util.ts +++ b/libs/ui/playback/src/lib/playback-diagnostics/playback-media-source.util.ts @@ -1,27 +1,17 @@ -import { getExtensionFromUrl } from '@iptvnator/shared/m3u-utils'; +import { + UNSUPPORTED_CONTAINER_EXTENSIONS, + getPlaybackMediaExtensionFromUrl, +} from '@iptvnator/shared/m3u-utils'; import type { PlaybackSourceMetadata, PlaybackSourceMetadataInput, } from './playback-diagnostics.model'; -const UNSUPPORTED_CONTAINER_EXTENSIONS = new Set([ - 'avi', - 'asf', - 'divx', - 'flv', - 'm2ts', - 'm4v', - 'mkv', - 'mov', - 'mpeg', - 'mpg', - 'rm', - 'rmvb', - 'ts', - 'vob', - 'wmv', -]); +// Media-extension normalization lives in @iptvnator/shared/m3u-utils so M3U +// routing (isDashChannel) and engine selection always agree; re-exported here +// to keep the playback-lib import surface stable. +export { getPlaybackMediaExtensionFromUrl }; const UNSUPPORTED_CONTAINER_NAMES = new Set([ ...UNSUPPORTED_CONTAINER_EXTENSIONS, @@ -31,42 +21,6 @@ const UNSUPPORTED_CONTAINER_NAMES = new Set([ 'x-msvideo', ]); -const EXPLICIT_MEDIA_EXTENSION_QUERY_KEYS = ['extension', 'ext']; - -const DECLARED_MEDIA_FORMAT_QUERY_KEYS = ['format', 'container']; - -const DECLARED_MEDIA_EXTENSION_ALIASES = new Map([ - ['hls', 'm3u8'], - ['mpegts', 'ts'], - ['mpeg-ts', 'ts'], -]); - -const DECLARED_MEDIA_EXTENSIONS = new Set([ - ...UNSUPPORTED_CONTAINER_EXTENSIONS, - 'aac', - 'flac', - 'm3u', - 'm3u8', - 'm4s', - 'mp3', - 'mp4', - 'mpd', - 'oga', - 'ogg', - 'ogv', - 'webm', -]); - -const NON_MEDIA_URL_EXTENSIONS = new Set([ - 'asp', - 'aspx', - 'cgi', - 'jsp', - 'mpv', - 'php', - 'pl', -]); - const BROWSER_LIMITED_CODEC_PATTERNS: ReadonlyArray<{ readonly label: string; readonly pattern: RegExp; @@ -95,35 +49,6 @@ export function createPlaybackSourceMetadata( }; } -export function getPlaybackMediaExtensionFromUrl(url: string): string { - const explicitQueryExtension = getMediaExtensionFromQuery( - url, - EXPLICIT_MEDIA_EXTENSION_QUERY_KEYS - ); - if (explicitQueryExtension) { - return explicitQueryExtension; - } - - const pathExtension = normalizeExtensionToken(getExtensionFromUrl(url)); - if (DECLARED_MEDIA_EXTENSIONS.has(pathExtension)) { - return pathExtension; - } - - const formatQueryExtension = getMediaExtensionFromQuery( - url, - DECLARED_MEDIA_FORMAT_QUERY_KEYS - ); - if (formatQueryExtension) { - return formatQueryExtension; - } - - if (NON_MEDIA_URL_EXTENSIONS.has(pathExtension)) { - return ''; - } - - return pathExtension; -} - export function getLikelyBrowserUnsupportedCodecLabels( metadata: Pick ): string[] { @@ -188,40 +113,3 @@ function normalizeCodecs(codecs: readonly string[] | undefined): string[] { function normalizeToken(value: string | undefined): string { return value?.trim().toLowerCase() ?? ''; } - -function normalizeExtensionToken(value: string | undefined): string { - return normalizeToken(value).replace(/^\.+/, ''); -} - -function getMediaExtensionFromQuery( - url: string, - queryKeys: readonly string[] -): string { - try { - const parsedUrl = new URL(url, 'http://iptvnator.local'); - for (const key of queryKeys) { - const declaredExtension = normalizeDeclaredMediaExtension( - parsedUrl.searchParams.get(key) ?? undefined - ); - if (declaredExtension) { - return declaredExtension; - } - } - - return ''; - } catch { - return ''; - } -} - -function normalizeDeclaredMediaExtension(value: string | undefined): string { - const extension = normalizeExtensionToken(value); - const normalized = - DECLARED_MEDIA_EXTENSION_ALIASES.get(extension) ?? extension; - - if (!normalized || NON_MEDIA_URL_EXTENSIONS.has(normalized)) { - return ''; - } - - return DECLARED_MEDIA_EXTENSIONS.has(normalized) ? normalized : ''; -} diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.spec.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.spec.ts new file mode 100644 index 000000000..9dbe76aca --- /dev/null +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.spec.ts @@ -0,0 +1,101 @@ +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'; + +const metadata = createPlaybackSourceMetadata({ + url: 'http://example.com/stream.mpd', + mimeType: 'application/dash+xml', + player: InlinePlaybackPlayer.Html5, +}); + +describe('classifyShakaPlaybackIssue', () => { + it.each([ + ['DRM category', { category: 6, code: 6001 }], + ['restrictions-cannot-be-met', { category: 4, code: 4012 }], + [ + 'license keyword in message', + { category: 5, code: 5001, message: 'license request failed' }, + ], + ])('maps %s to DrmOrEncryption', (_label, error) => { + const issue = classifyShakaPlaybackIssue(error, metadata); + expect(issue.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption); + expect(issue.source).toBe(PlaybackDiagnosticSource.Shaka); + }); + + it('maps network category to NetworkError', () => { + const issue = classifyShakaPlaybackIssue( + { category: 1, code: 1001 }, + metadata + ); + expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError); + }); + + it('maps CORS-flavored network failures to BrowserAccessError', () => { + const issue = classifyShakaPlaybackIssue( + { category: 1, code: 1002, message: 'Blocked by CORS policy' }, + metadata + ); + expect(issue.code).toBe(PlaybackDiagnosticCode.BrowserAccessError); + }); + + it('maps media category to MediaDecodeError', () => { + const issue = classifyShakaPlaybackIssue( + { category: 3, code: 3016 }, + metadata + ); + expect(issue.code).toBe(PlaybackDiagnosticCode.MediaDecodeError); + }); + + it('maps codec-flavored media failures to UnsupportedCodec', () => { + const issue = classifyShakaPlaybackIssue( + { category: 3, code: 3005, message: 'addCodec failed for hvc1' }, + metadata + ); + expect(issue.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec); + }); + + it('maps manifest category to UnsupportedContainer', () => { + const issue = classifyShakaPlaybackIssue( + { category: 4, code: 4001 }, + metadata + ); + expect(issue.code).toBe(PlaybackDiagnosticCode.UnsupportedContainer); + }); + + it('falls back to UnknownPlaybackError and keeps details', () => { + const issue = classifyShakaPlaybackIssue( + { category: 7, code: 7002, message: 'boom' }, + metadata + ); + expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError); + expect(issue.details).toContain('boom'); + expect(issue.details).toContain('7002'); + }); + + it('tolerates missing error objects', () => { + const issue = classifyShakaPlaybackIssue(null, metadata); + expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError); + }); +}); + +describe('createUnsupportedDrmDiagnostic', () => { + it('creates a DRM diagnostic without recommending unusable external fallbacks', () => { + const issue = createUnsupportedDrmDiagnostic( + 'com.widevine.alpha', + metadata + ); + expect(issue.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption); + expect(issue.source).toBe(PlaybackDiagnosticSource.Shaka); + expect(issue.details).toContain('com.widevine.alpha'); + // MPV/VLC cannot receive KODIPROP license config, so the diagnostic + // must not offer them as a fallback. + expect(issue.externalFallbackRecommended).toBe(false); + }); +}); diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.ts new file mode 100644 index 000000000..221384c31 --- /dev/null +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.ts @@ -0,0 +1,173 @@ +import type { + PlaybackDiagnostic, + PlaybackSourceMetadata, +} from '../playback-diagnostics/playback-diagnostics.model'; +import { + PlaybackDiagnosticCode as DiagnosticCode, + PlaybackDiagnosticSource as DiagnosticSource, +} from '../playback-diagnostics/playback-diagnostics.model'; +import { createPlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util'; +import { + isBrowserAccessFailure, + isCodecFailure, + isDrmOrEncryptionFailure, +} from '../playback-diagnostics/playback-error-patterns.util'; +import type { ShakaErrorLike } from './shaka-module.types'; + +/** + * Numeric `shaka.util.Error.Category` values (stable public Shaka API). Kept + * local so classification stays a pure function that does not need the lazily + * loaded module. + */ +const SHAKA_CATEGORY = { + Network: 1, + Text: 2, + Media: 3, + Manifest: 4, + Streaming: 5, + Drm: 6, +} as const; + +/** `shaka.util.Error.Code.RESTRICTIONS_CANNOT_BE_MET` — in practice this fires + * when every variant is restricted by unusable decryption keys. */ +const RESTRICTIONS_CANNOT_BE_MET = 4012; + +export function classifyShakaPlaybackIssue( + error: Partial | null | undefined, + metadata: PlaybackSourceMetadata +): PlaybackDiagnostic { + const details = formatShakaErrorDetails(error); + const lowerDetails = details.toLowerCase(); + const category = error?.category; + + if ( + category === SHAKA_CATEGORY.Drm || + error?.code === RESTRICTIONS_CANNOT_BE_MET || + isDrmOrEncryptionFailure(lowerDetails) + ) { + return createPlaybackDiagnostic({ + code: DiagnosticCode.DrmOrEncryption, + source: DiagnosticSource.Shaka, + metadata, + details, + }); + } + + if (category === SHAKA_CATEGORY.Network) { + return createPlaybackDiagnostic({ + code: isBrowserAccessFailure(lowerDetails) + ? DiagnosticCode.BrowserAccessError + : DiagnosticCode.NetworkError, + source: DiagnosticSource.Shaka, + metadata, + details, + }); + } + + if ( + category === SHAKA_CATEGORY.Media || + category === SHAKA_CATEGORY.Streaming + ) { + return createPlaybackDiagnostic({ + code: isCodecFailure(lowerDetails) + ? DiagnosticCode.UnsupportedCodec + : DiagnosticCode.MediaDecodeError, + source: DiagnosticSource.Shaka, + metadata, + details, + }); + } + + if (category === SHAKA_CATEGORY.Manifest) { + return createPlaybackDiagnostic({ + code: DiagnosticCode.UnsupportedContainer, + source: DiagnosticSource.Shaka, + metadata, + details, + }); + } + + return createPlaybackDiagnostic({ + code: DiagnosticCode.UnknownPlaybackError, + source: DiagnosticSource.Shaka, + metadata, + details, + }); +} + +/** + * Diagnostic for `.mpd` channels that declare a DRM system the app cannot + * handle (Widevine, PlayReady, malformed ClearKey config, …). Emitted before + * any Shaka engine is started. + */ +export function createUnsupportedDrmDiagnostic( + licenseType: string, + metadata: PlaybackSourceMetadata +): PlaybackDiagnostic { + return createPlaybackDiagnostic({ + code: DiagnosticCode.DrmOrEncryption, + source: DiagnosticSource.Shaka, + metadata, + details: licenseType + ? `Unsupported DRM license configuration: ${licenseType}` + : 'Unsupported DRM license configuration', + // 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 | null { + if (!error || typeof error !== 'object') { + return null; + } + + const candidate = error as Partial; + return typeof candidate.code === 'number' || + typeof candidate.category === 'number' + ? candidate + : null; +} + +export function toErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + return typeof error === 'string' ? error : String(error); +} + +function formatShakaErrorDetails( + error: Partial | null | undefined +): string { + if (!error) { + return ''; + } + + const parts = [ + typeof error.category === 'number' + ? `Shaka category ${error.category}` + : '', + typeof error.code === 'number' ? `code ${error.code}` : '', + error.message ?? '', + stringifyErrorData(error.data), + ]; + + return parts.filter((part) => part.length > 0).join(' '); +} + +function stringifyErrorData(data: unknown[] | undefined): string { + if (!data || data.length === 0) { + return ''; + } + + try { + const serialized = JSON.stringify(data); + return serialized === '[]' ? '' : serialized; + } catch { + return ''; + } +} 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 new file mode 100644 index 000000000..50421098a --- /dev/null +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-module.types.ts @@ -0,0 +1,75 @@ +/** + * Minimal structural typings for the lazily imported `shaka-player` module. + * + * The vendor package ships clutz-generated global namespace typings that are + * awkward to consume from an ESM library. These interfaces cover exactly the + * surface the app uses and double as the seam for unit-test fakes: the session + * accepts any {@link ShakaModuleLoader}, so specs never touch the real module. + */ + +export interface ShakaAudioTrackLike { + active: boolean; + language: string; + label: string | null; + roles: string[]; +} + +export interface ShakaTextTrackLike { + id: number; + active: boolean; + language: string; + label: string | null; + kind: string | null; +} + +export interface ShakaErrorLike { + severity: number; + category: number; + code: number; + message?: string; + data?: unknown[]; +} + +export interface ShakaPlayerLike { + attach(mediaElement: HTMLMediaElement): Promise; + configure(config: Record): boolean; + load(assetUri: string): Promise; + destroy(): Promise; + addEventListener(type: string, listener: (event: Event) => void): void; + removeEventListener(type: string, listener: (event: Event) => void): void; + getAudioTracks(): ShakaAudioTrackLike[]; + selectAudioTrack(track: ShakaAudioTrackLike): void; + getTextTracks(): ShakaTextTrackLike[]; + /** + * Shaka 5 visibility model: selecting a track shows it, `null` unloads + * the active track (subtitles off). There is no separate visibility API. + */ + selectTextTrack(track: ShakaTextTrackLike | null): void; + isLive(): boolean; +} + +export interface ShakaModuleLike { + Player: { + new (): ShakaPlayerLike; + isBrowserSupported(): boolean; + }; + polyfill: { + installAll(): void; + }; +} + +export type ShakaModuleLoader = () => Promise; + +/** + * Default production loader. The dynamic import keeps shaka-player (~267 KB + * gzip) out of the main bundle until the first `.mpd` stream starts. + */ +export const loadShakaModule: ShakaModuleLoader = async () => { + const module = (await import('shaka-player')) as unknown as + | { default?: ShakaModuleLike } + | ShakaModuleLike; + return ( + ((module as { default?: ShakaModuleLike }).default ?? + module) as ShakaModuleLike + ); +}; diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-player-test-double.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-player-test-double.ts new file mode 100644 index 000000000..eb6aed50e --- /dev/null +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-player-test-double.ts @@ -0,0 +1,164 @@ +import type { + ShakaAudioTrackLike, + ShakaModuleLike, + ShakaModuleLoader, + ShakaPlayerLike, + ShakaTextTrackLike, +} from './shaka-module.types'; + +/** + * Jest-free Shaka test double shared by the shaka-engine and ArtPlayer specs. + * Mirrors the {@link ShakaPlayerLike} surface, including the real Shaka + * semantic that `destroy()` interrupts an in-flight `load()` with + * `LOAD_INTERRUPTED` (7000). + */ + +type Listener = (event: Event) => void; + +const LOAD_INTERRUPTED_ERROR = { + severity: 2, + category: 7, + code: 7000, +} as const; + +export class FakeShakaPlayer implements ShakaPlayerLike { + readonly configureCalls: Record[] = []; + readonly selectTextTrackCalls: unknown[] = []; + readonly selectedAudioTracks: ShakaAudioTrackLike[] = []; + readonly listeners = new Map>(); + attachedTo: HTMLMediaElement | null = null; + loadedUrls: string[] = []; + destroyCount = 0; + audioTracks: ShakaAudioTrackLike[] = []; + textTracks: ShakaTextTrackLike[] = []; + /** When true, the next `load()` stays pending until `destroy()`. */ + stallNextLoad = false; + loadResult: Promise = Promise.resolve(); + private pendingLoadReject: ((error: unknown) => void) | null = null; + + attach(mediaElement: HTMLMediaElement): Promise { + this.attachedTo = mediaElement; + return Promise.resolve(); + } + + configure(config: Record): boolean { + this.configureCalls.push(config); + return true; + } + + load(assetUri: string): Promise { + this.loadedUrls.push(assetUri); + if (this.stallNextLoad) { + this.stallNextLoad = false; + return new Promise((_resolve, reject) => { + this.pendingLoadReject = reject; + }); + } + return this.loadResult; + } + + destroy(): Promise { + this.destroyCount += 1; + this.pendingLoadReject?.(LOAD_INTERRUPTED_ERROR); + this.pendingLoadReject = null; + return Promise.resolve(); + } + + addEventListener(type: string, listener: Listener): void { + const set = this.listeners.get(type) ?? new Set(); + set.add(listener); + this.listeners.set(type, set); + } + + removeEventListener(type: string, listener: Listener): void { + this.listeners.get(type)?.delete(listener); + } + + dispatch(type: string, detail?: unknown): void { + for (const listener of [...(this.listeners.get(type) ?? [])]) { + listener({ type, detail } as unknown as Event); + } + } + + getAudioTracks(): ShakaAudioTrackLike[] { + return this.audioTracks; + } + + selectAudioTrack(track: ShakaAudioTrackLike): void { + this.selectedAudioTracks.push(track); + } + + getTextTracks(): ShakaTextTrackLike[] { + return this.textTracks; + } + + selectTextTrack(track: ShakaTextTrackLike | null): void { + this.selectTextTrackCalls.push(track); + for (const candidate of this.textTracks) { + candidate.active = candidate === track; + } + } + + isLive(): boolean { + return false; + } +} + +export interface FakeShakaEnvironment { + /** Every player the fake module has constructed, in creation order. */ + readonly instances: FakeShakaPlayer[]; + readonly loader: ShakaModuleLoader; + readonly module: ShakaModuleLike; + loaderCalls: number; + installAllCalls: number; + browserSupported: boolean; +} + +export function createFakeShakaEnvironment(options?: { + onCreate?: (player: FakeShakaPlayer, index: number) => void; +}): FakeShakaEnvironment { + const instances: FakeShakaPlayer[] = []; + + const environment: FakeShakaEnvironment = { + instances, + loaderCalls: 0, + installAllCalls: 0, + browserSupported: true, + module: undefined as unknown as ShakaModuleLike, + loader: undefined as unknown as ShakaModuleLoader, + }; + + const playerConstructor = function (this: unknown) { + const player = new FakeShakaPlayer(); + options?.onCreate?.(player, instances.length); + instances.push(player); + return player; + } as unknown as ShakaModuleLike['Player']; + ( + playerConstructor as unknown as { isBrowserSupported: () => boolean } + ).isBrowserSupported = () => environment.browserSupported; + + const module: ShakaModuleLike = { + Player: playerConstructor, + polyfill: { + installAll: () => { + environment.installAllCalls += 1; + }, + }, + }; + + (environment as { module: ShakaModuleLike }).module = module; + (environment as { loader: ShakaModuleLoader }).loader = () => { + environment.loaderCalls += 1; + return Promise.resolve(module); + }; + + return environment; +} + +/** Drains chained microtasks so queued session operations settle. */ +export async function flushShakaMicrotasks(): Promise { + for (let index = 0; index < 10; index += 1) { + await Promise.resolve(); + } +} diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-text-track-suppression.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-text-track-suppression.ts new file mode 100644 index 000000000..5d401819f --- /dev/null +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-text-track-suppression.ts @@ -0,0 +1,42 @@ +import type { ShakaPlayerLike } from './shaka-module.types'; + +/** + * Remembers the text track hidden while the captions preference is off so it + * can be reselected when the preference turns back on — Shaka 5 has no + * separate visibility API (selecting a track shows it, `null` hides text). + */ +export class ShakaTextTrackSuppression { + private suppressedTrackId: number | null = null; + + /** Hides the active text track, remembering it for {@link restore}. */ + suppress(player: ShakaPlayerLike): void { + const active = player + .getTextTracks() + .find((candidate) => candidate.active); + if (!active) { + return; + } + + this.suppressedTrackId = active.id; + player.selectTextTrack(null); + } + + /** One-shot reselection of the previously suppressed track. */ + restore(player: ShakaPlayerLike): void { + if (this.suppressedTrackId === null) { + return; + } + + const track = player + .getTextTracks() + .find((candidate) => candidate.id === this.suppressedTrackId); + this.suppressedTrackId = null; + if (track && !track.active) { + player.selectTextTrack(track); + } + } + + reset(): void { + this.suppressedTrackId = null; + } +} 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 new file mode 100644 index 000000000..a568e6f66 --- /dev/null +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.spec.ts @@ -0,0 +1,294 @@ +import type { ChannelDrm } from '@iptvnator/shared/interfaces'; +import { + InlinePlaybackPlayer, + PlaybackDiagnostic, + PlaybackDiagnosticCode, + PlaybackDiagnosticSource, +} from '../playback-diagnostics/playback-diagnostics.model'; +import { + FakeShakaPlayer, + createFakeShakaEnvironment, + flushShakaMicrotasks as flush, +} from './shaka-player-test-double'; +import { ShakaVideoSession } from './shaka-video-session'; + +describe('ShakaVideoSession', () => { + const video = {} as HTMLVideoElement; + let issues: PlaybackDiagnostic[]; + + const createSession = ( + environment: ReturnType + ) => + new ShakaVideoSession({ + player: InlinePlaybackPlayer.Html5, + emitPlaybackIssue: (issue) => issues.push(issue), + showCaptions: () => false, + loadShaka: environment.loader, + }); + + beforeEach(() => { + issues = []; + }); + + it('lazily loads the module once, installs polyfills and starts playback', async () => { + const environment = createFakeShakaEnvironment(); + const session = createSession(environment); + session.start(video, 'http://example.com/a.mpd'); + await flush(); + session.start(video, 'http://example.com/b.mpd'); + await flush(); + + expect(environment.loaderCalls).toBe(1); + expect(environment.installAllCalls).toBe(1); + const [first, second] = environment.instances; + expect(first.attachedTo).toBe(video); + expect(first.loadedUrls).toEqual(['http://example.com/a.mpd']); + expect(first.destroyCount).toBe(1); + expect(second.loadedUrls).toEqual(['http://example.com/b.mpd']); + expect(issues).toEqual([]); + }); + + it('configures ClearKey keys before load for supported DRM', async () => { + const environment = createFakeShakaEnvironment(); + const drm: ChannelDrm = { + licenseType: 'clearkey', + supported: true, + clearKeys: { abc: 'def' }, + }; + const session = createSession(environment); + session.start(video, 'http://example.com/enc.mpd', drm); + await flush(); + + const player = environment.instances[0]; + expect(player.configureCalls).toEqual([ + { drm: { clearKeys: { abc: 'def' } } }, + ]); + expect(player.loadedUrls).toEqual(['http://example.com/enc.mpd']); + }); + + it('starts without DRM config for clear DASH channels', async () => { + const environment = createFakeShakaEnvironment(); + const session = createSession(environment); + session.start(video, 'http://example.com/clear.mpd'); + await flush(); + + expect(environment.instances[0].configureCalls).toEqual([]); + expect(issues).toEqual([]); + }); + + it('emits a DRM diagnostic and starts no engine for unsupported license types', async () => { + const environment = createFakeShakaEnvironment(); + const session = createSession(environment); + session.start(video, 'http://example.com/wv.mpd', { + licenseType: 'com.widevine.alpha', + supported: false, + }); + await flush(); + + expect(environment.loaderCalls).toBe(0); + expect(environment.instances).toHaveLength(0); + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe(PlaybackDiagnosticCode.DrmOrEncryption); + expect(issues[0].source).toBe(PlaybackDiagnosticSource.Shaka); + expect(issues[0].details).toContain('com.widevine.alpha'); + }); + + it('classifies critical shaka error events and ignores recoverable ones', async () => { + const environment = createFakeShakaEnvironment(); + const session = createSession(environment); + session.start(video, 'http://example.com/a.mpd'); + await flush(); + + const player = environment.instances[0]; + player.dispatch('error', { severity: 1, category: 1, code: 1002 }); + expect(issues).toEqual([]); + expect(session.getPlayer()).toBe(player); + + player.dispatch('error', { severity: 2, category: 6, code: 6001 }); + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe(PlaybackDiagnosticCode.DrmOrEncryption); + // 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(); + }); + + it('drops the external-fallback hint for every failure on ClearKey channels', async () => { + const environment = createFakeShakaEnvironment(); + const session = createSession(environment); + session.start(video, 'http://example.com/enc.mpd', { + licenseType: 'clearkey', + supported: true, + clearKeys: { abc: 'def' }, + }); + await flush(); + + environment.instances[0].dispatch('error', { + severity: 2, + category: 6, + code: 6001, + }); + + 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, + clearKeys: { abc: 'def' }, + }); + await flush(); + environment.instances[1].dispatch('error', { + severity: 2, + category: 3, + code: 3016, + }); + + 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 () => { + const loadError = { severity: 2, category: 4, code: 4001 }; + const environment = createFakeShakaEnvironment({ + onCreate: (player) => { + player.loadResult = Promise.reject(loadError); + }, + }); + const session = createSession(environment); + session.start(video, 'http://example.com/bad.mpd'); + await flush(); + + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe( + PlaybackDiagnosticCode.UnsupportedContainer + ); + expect(issues[0].sourceUrl).toBe('http://example.com/bad.mpd'); + // The failed engine must not stay attached or exposed to controls. + expect(environment.instances[0].destroyCount).toBe(1); + expect(session.getPlayer()).toBeNull(); + }); + + it('recovers from a stalled load: stop() interrupts it and the next start proceeds', async () => { + const environment = createFakeShakaEnvironment({ + onCreate: (player, index) => { + if (index === 0) { + player.stallNextLoad = true; + } + }, + }); + const session = createSession(environment); + session.start(video, 'http://example.com/stalled.mpd'); + await flush(); + expect(environment.instances[0].loadedUrls).toEqual([ + 'http://example.com/stalled.mpd', + ]); + + session.stop(); + session.start(video, 'http://example.com/next.mpd'); + await flush(); + + const [stalled, next] = environment.instances; + expect(stalled.destroyCount).toBe(1); + expect(next.loadedUrls).toEqual(['http://example.com/next.mpd']); + expect(session.getPlayer()).toBe(next); + expect(issues).toEqual([]); + }); + + it('suppresses interrupted loads and stale results after a channel switch', async () => { + let releaseFirstLoad: () => void = () => undefined; + const environment = createFakeShakaEnvironment({ + onCreate: (player, index) => { + if (index === 0) { + player.loadResult = new Promise((resolve) => { + releaseFirstLoad = () => resolve(undefined); + }); + } + }, + }); + const session = createSession(environment); + session.start(video, 'http://example.com/slow.mpd'); + await flush(); + session.start(video, 'http://example.com/fast.mpd'); + releaseFirstLoad(); + await flush(); + + const [slow, fast] = environment.instances as [ + FakeShakaPlayer, + FakeShakaPlayer, + ]; + expect(slow.destroyCount).toBe(1); + expect(fast.loadedUrls).toEqual(['http://example.com/fast.mpd']); + // The slow player resolved after being superseded; nothing may have + // touched its text tracks afterwards. + expect(slow.selectTextTrackCalls).toEqual([]); + expect(issues).toEqual([]); + }); + + it('suppresses auto-selected captions on load and restores them on demand', async () => { + const environment = createFakeShakaEnvironment({ + onCreate: (player) => { + player.textTracks = [ + { + id: 7, + active: true, + language: 'en', + label: 'English', + kind: 'subtitles', + }, + ]; + }, + }); + const session = createSession(environment); + session.start(video, 'http://example.com/subs.mpd'); + await flush(); + + const player = environment.instances[0]; + // showCaptions() is false: the auto-selected track is hidden… + expect(player.selectTextTrackCalls).toEqual([null]); + expect(player.textTracks[0].active).toBe(false); + + // …and remembered, so re-enabling the preference restores it. + session.restoreSuppressedTextTrack(); + expect(player.textTracks[0].active).toBe(true); + + // The restore is one-shot. + session.restoreSuppressedTextTrack(); + expect(player.selectTextTrackCalls).toHaveLength(2); + }); + + it('emits an unsupported-container diagnostic when the browser lacks MSE/EME', async () => { + const environment = createFakeShakaEnvironment(); + environment.browserSupported = false; + const session = createSession(environment); + session.start(video, 'http://example.com/a.mpd'); + await flush(); + + expect(environment.instances).toHaveLength(0); + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe( + PlaybackDiagnosticCode.UnsupportedContainer + ); + }); + + it('destroy tears down the engine and blocks later starts', async () => { + const environment = createFakeShakaEnvironment(); + const session = createSession(environment); + session.start(video, 'http://example.com/a.mpd'); + await flush(); + session.destroy(); + await flush(); + session.start(video, 'http://example.com/b.mpd'); + await flush(); + + expect(environment.instances).toHaveLength(1); + expect(environment.instances[0].destroyCount).toBe(1); + }); +}); 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 new file mode 100644 index 000000000..6a25c597d --- /dev/null +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts @@ -0,0 +1,388 @@ +import type { ChannelDrm } from '@iptvnator/shared/interfaces'; +import type { + InlinePlaybackPlayer, + PlaybackDiagnostic, + PlaybackSourceMetadata, +} from '../playback-diagnostics/playback-diagnostics.model'; +import { PlaybackDiagnosticCode as DiagnosticCode } from '../playback-diagnostics/playback-diagnostics.model'; +import { PlaybackDiagnosticSource as DiagnosticSource } from '../playback-diagnostics/playback-diagnostics.model'; +import { + createPlaybackDiagnostic, + createPlaybackSourceMetadata, +} from '../playback-diagnostics/playback-diagnostics.util'; +import { + asShakaError, + classifyShakaPlaybackIssue, + createUnsupportedDrmDiagnostic, + toErrorMessage, +} from './shaka-error-classifier'; +import { ShakaTextTrackSuppression } from './shaka-text-track-suppression'; +import { + loadShakaModule, + type ShakaErrorLike, + type ShakaModuleLike, + type ShakaModuleLoader, + type ShakaPlayerLike, +} from './shaka-module.types'; + +export interface ShakaVideoSessionConfig { + player: InlinePlaybackPlayer; + emitPlaybackIssue: (issue: PlaybackDiagnostic) => void; + showCaptions?: () => boolean; + loadShaka?: ShakaModuleLoader; +} + +const DASH_MIME_TYPE = 'application/dash+xml'; +/** `shaka.util.Error.Severity.RECOVERABLE` — Shaka retries these itself. */ +const SHAKA_SEVERITY_RECOVERABLE = 1; +/** `shaka.util.Error.Code.LOAD_INTERRUPTED` — expected when a newer + * `load()`/`destroy()` supersedes an in-flight one. */ +const SHAKA_LOAD_INTERRUPTED = 7000; + +const PLAYER_REFRESH_EVENTS = [ + 'loaded', + 'trackschanged', + 'adaptation', + 'variantchanged', + 'textchanged', + 'texttrackvisibility', +] as const; + +/** + * Owns one Shaka Player engine for DASH (`.mpd`) playback, mirroring how + * hls.js/mpegts.js engines are owned by their player components. + * + * - `shaka-player` is loaded lazily on the first start; the module is cached. + * - `start()`/`stop()` are synchronous entry points; the async work is + * serialized on an internal chain and guarded by a generation counter, so a + * channel switch during an in-flight load can never resurrect a stale + * engine. + * - ClearKey DRM comes from the channel's {@link ChannelDrm}; unsupported + * license types emit a DRM diagnostic instead of starting an engine. + */ +export class ShakaVideoSession { + private module: ShakaModuleLike | null = null; + private player: ShakaPlayerLike | null = null; + private playerErrorListener: ((event: Event) => void) | null = null; + private playerRefreshListener: (() => void) | null = null; + private readonly refreshListeners = new Set<() => void>(); + private operationChain: Promise = Promise.resolve(); + private pendingTeardown: Promise = Promise.resolve(); + private readonly textSuppression = new ShakaTextTrackSuppression(); + private generation = 0; + private destroyed = false; + + constructor(private readonly config: ShakaVideoSessionConfig) {} + + /** Starts DASH playback of `url` on `video`. Synchronous entry point. */ + start(video: HTMLVideoElement, url: string, drm?: ChannelDrm): void { + if (this.destroyed) { + return; + } + + if (drm && !drm.supported) { + this.stop(); + this.config.emitPlaybackIssue( + createUnsupportedDrmDiagnostic( + drm.licenseType, + this.createMetadata(url) + ) + ); + return; + } + + const generation = ++this.generation; + this.beginPlayerTeardown(); + this.enqueue(async () => { + await this.pendingTeardown; + if (this.isStale(generation)) { + return; + } + await this.startPlayback(generation, video, url, drm); + }); + } + + /** Tears down the current engine (if any) without ending the session. */ + stop(): void { + this.generation += 1; + this.beginPlayerTeardown(); + } + + destroy(): void { + if (this.destroyed) { + return; + } + + this.destroyed = true; + this.generation += 1; + this.refreshListeners.clear(); + this.beginPlayerTeardown(); + } + + /** Current engine, for the shared-controls track bridge. */ + getPlayer(): ShakaPlayerLike | null { + return this.player; + } + + /** Registers a callback fired whenever tracks/text state may have changed. */ + subscribe(listener: () => void): () => void { + this.refreshListeners.add(listener); + return () => this.refreshListeners.delete(listener); + } + + private async startPlayback( + generation: number, + video: HTMLVideoElement, + url: string, + drm: ChannelDrm | undefined + ): Promise { + let module: ShakaModuleLike; + try { + module = await this.loadModule(); + } catch (error: unknown) { + this.emitIfCurrent( + generation, + classifyShakaPlaybackIssue( + { message: toErrorMessage(error) }, + this.createMetadata(url) + ) + ); + return; + } + + if (this.isStale(generation)) { + return; + } + + if (!module.Player.isBrowserSupported()) { + this.emitIfCurrent( + generation, + createPlaybackDiagnostic({ + code: DiagnosticCode.UnsupportedContainer, + source: DiagnosticSource.Shaka, + metadata: this.createMetadata(url), + details: 'Shaka Player is not supported in this browser', + }) + ); + return; + } + + const player = new module.Player(); + this.player = player; + this.bindPlayerListeners(player, generation, url, drm !== undefined); + + if (drm?.clearKeys) { + player.configure({ drm: { clearKeys: drm.clearKeys } }); + } + + try { + await player.attach(video); + await player.load(url); + } catch (error: unknown) { + this.handleLoadFailure( + generation, + player, + url, + error, + drm !== undefined + ); + return; + } + + if (this.isStale(generation)) { + return; + } + + // Shaka 5 shows a text track by selecting it; keep captions off when + // the preference is disabled in case the manifest auto-selected one. + if (!(this.config.showCaptions?.() ?? false)) { + this.textSuppression.suppress(player); + } + this.notifyRefresh(); + } + + /** Hides the active text track, remembering it for a later restore. */ + suppressTextTracks(): void { + const player = this.player; + if (player) { + this.textSuppression.suppress(player); + } + } + + /** Reselects the text track hidden by {@link suppressTextTracks}. */ + restoreSuppressedTextTrack(): void { + const player = this.player; + if (player) { + this.textSuppression.restore(player); + } + } + + private handleLoadFailure( + generation: number, + player: ShakaPlayerLike, + url: string, + error: unknown, + drmProvided: boolean + ): void { + const shakaError = asShakaError(error); + if ( + this.isStale(generation) || + this.player !== player || + shakaError?.code === SHAKA_LOAD_INTERRUPTED + ) { + return; + } + + this.config.emitPlaybackIssue( + this.withoutUnusableDrmFallback( + classifyShakaPlaybackIssue( + shakaError ?? { message: toErrorMessage(error) }, + this.createMetadata(url) + ), + drmProvided + ) + ); + // 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 + ): void { + const errorListener = (event: Event): void => { + if (this.isStale(generation) || this.player !== player) { + return; + } + + const detail = (event as { detail?: Partial }) + .detail; + if (detail?.severity === SHAKA_SEVERITY_RECOVERABLE) { + return; + } + + this.config.emitPlaybackIssue( + this.withoutUnusableDrmFallback( + classifyShakaPlaybackIssue( + detail, + this.createMetadata(url) + ), + drmProvided + ) + ); + // Critical errors end playback; never leave the dead engine + // attached or exposed to the shared-controls bridge. + this.beginPlayerTeardown(); + }; + const refreshListener = (): void => { + if (!this.isStale(generation) && this.player === player) { + this.notifyRefresh(); + } + }; + + this.playerErrorListener = errorListener; + this.playerRefreshListener = refreshListener; + player.addEventListener('error', errorListener); + for (const event of PLAYER_REFRESH_EVENTS) { + player.addEventListener(event, refreshListener); + } + } + + /** + * Detaches and destroys the current engine immediately — never queued + * behind pending operations. `player.destroy()` interrupts an in-flight + * `load()` (it rejects with `LOAD_INTERRUPTED`), so a stalled manifest + * fetch cannot wedge the operation chain. New starts await + * {@link pendingTeardown} before attaching to the media element. + */ + private beginPlayerTeardown(): void { + const player = this.player; + this.player = null; + this.textSuppression.reset(); + if (!player) { + this.playerErrorListener = null; + this.playerRefreshListener = null; + return; + } + + if (this.playerErrorListener) { + player.removeEventListener('error', this.playerErrorListener); + } + if (this.playerRefreshListener) { + for (const event of PLAYER_REFRESH_EVENTS) { + player.removeEventListener(event, this.playerRefreshListener); + } + } + this.playerErrorListener = null; + this.playerRefreshListener = null; + this.notifyRefresh(); + + const teardown = player.destroy().then( + () => undefined, + () => undefined + ); + this.pendingTeardown = this.pendingTeardown.then(() => teardown); + } + + private async loadModule(): Promise { + if (this.module) { + return this.module; + } + + const loader = this.config.loadShaka ?? loadShakaModule; + const module = await loader(); + module.polyfill.installAll(); + this.module = module; + return module; + } + + private createMetadata(url: string): PlaybackSourceMetadata { + return createPlaybackSourceMetadata({ + url, + mimeType: DASH_MIME_TYPE, + player: this.config.player, + }); + } + + private enqueue(operation: () => Promise): void { + this.operationChain = this.operationChain.then(operation, operation); + } + + private isStale(generation: number): boolean { + return this.destroyed || generation !== this.generation; + } + + private emitIfCurrent( + generation: number, + issue: PlaybackDiagnostic + ): void { + if (!this.isStale(generation)) { + this.config.emitPlaybackIssue(issue); + } + } + + private notifyRefresh(): void { + for (const listener of [...this.refreshListeners]) { + listener(); + } + } +} 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 c5f17c630..d852115a0 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 @@ -128,6 +128,8 @@ function formatDiagnosticSource(source: PlaybackDiagnostic['source']): string { return 'mpegts.js'; case 'native': return 'Native media element'; + case 'shaka': + return 'Shaka Player'; case 'source': return 'Stream metadata'; default: 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 8e2244764..c09a866f9 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 @@ -243,6 +243,7 @@ export class WebPlayerViewComponent { '', }, radio: 'false', + drm: playback.drm, }; } diff --git a/libs/ui/playback/src/lib/web-video-support/exit-owned-fullscreen.util.ts b/libs/ui/playback/src/lib/web-video-support/exit-owned-fullscreen.util.ts new file mode 100644 index 000000000..52d4ba2cd --- /dev/null +++ b/libs/ui/playback/src/lib/web-video-support/exit-owned-fullscreen.util.ts @@ -0,0 +1,23 @@ +/** + * Exits document fullscreen only when the given surface owns it. Shared by + * player components whose shared-controls mode uses DOM-based fullscreen. + */ +export function exitOwnedFullscreen( + sharedControls: boolean, + surface: HTMLElement | undefined, + reportError: (error: unknown) => void +): void { + if ( + !sharedControls || + document.fullscreenElement !== surface || + typeof document.exitFullscreen !== 'function' + ) { + return; + } + + try { + void Promise.resolve(document.exitFullscreen()).catch(reportError); + } catch (error: unknown) { + reportError(error); + } +} diff --git a/libs/ui/playback/src/lib/web-video-support/web-video-native-source.util.ts b/libs/ui/playback/src/lib/web-video-support/web-video-native-source.util.ts new file mode 100644 index 000000000..df2c31d16 --- /dev/null +++ b/libs/ui/playback/src/lib/web-video-support/web-video-native-source.util.ts @@ -0,0 +1,22 @@ +/** Helpers for driving the native `

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 Ug-;7ncLRDdmv0Fg$wu#poh)-+Y5)KL literal 0 HcmV?d00001 diff --git a/apps/web-e2e/src/fixtures/dash/clearkey-video.mp4 b/apps/web-e2e/src/fixtures/dash/clearkey-video.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..6acbd1b1c536eba7959eec5bec1c322c0438f9bf GIT binary patch literal 75668 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