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 000000000..9531d3301 Binary files /dev/null and b/apps/web-e2e/src/fixtures/dash/clear-audio.mp4 differ 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 000000000..5ace4324e Binary files /dev/null and b/apps/web-e2e/src/fixtures/dash/clear-video.mp4 differ 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 000000000..c3fee3c15 Binary files /dev/null and b/apps/web-e2e/src/fixtures/dash/clearkey-audio.mp4 differ 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 000000000..6acbd1b1c Binary files /dev/null and b/apps/web-e2e/src/fixtures/dash/clearkey-video.mp4 differ diff --git a/apps/web-e2e/src/fixtures/dash/clearkey.mpd b/apps/web-e2e/src/fixtures/dash/clearkey.mpd new file mode 100644 index 000000000..a3a10a8e2 --- /dev/null +++ b/apps/web-e2e/src/fixtures/dash/clearkey.mpd @@ -0,0 +1,31 @@ + + + + + + + + AAAANHBzc2gBAAAAEHfv7MCyTQKs4zweUuL7SwAAAAEAESIzRFVmd4iZqrvM3e7/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 `