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-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..c16cfa487 --- /dev/null +++ b/apps/web-e2e/src/fixtures/dash/generate-fixture.mjs @@ -0,0 +1,136 @@ +#!/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, execSync } 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} ...`); + execSync('npm pack shaka-packager --silent', { + cwd: workDir, + stdio: ['ignore', 'ignore', 'inherit'], + }); + const tarball = readdirSync(workDir).find((name) => + name.endsWith('.tgz') + ); + execSync(`tar -xzf ${JSON.stringify(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: [] }; +}