diff --git a/.changes/m3u-clearkey-base64.md b/.changes/m3u-clearkey-base64.md new file mode 100644 index 000000000..245c4f153 --- /dev/null +++ b/.changes/m3u-clearkey-base64.md @@ -0,0 +1,6 @@ +--- +type: fix +area: m3u +--- + +ClearKey channels with ordinary Base64 keys now play when imported or refreshed, including JSON playlist exports that use + and / characters. Refresh an existing playlist once to apply the fix. diff --git a/AGENTS.md b/AGENTS.md index fd2fb995c..c429d321e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -562,7 +562,9 @@ Key files: - 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 + `Channel.drm` (hex, Base64URL or ordinary Base64, strictly 128-bit key/KID; + refresh replaces cached unsupported parser results), and the shared bridge + exposes Shaka audio/text tracks via source kind `shaka`. The DOM-free Shaka `5.2.4` diagnostic boundary lives in `libs/playback/util`; it version-locks public severity/category/code evidence, ignores recoverable error events, diff --git a/CLAUDE.md b/CLAUDE.md index 006c9ad52..6cef6d394 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1070,7 +1070,9 @@ app as a real argument, so it is not an option. 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 + import paths; hex, Base64URL or ordinary Base64, strictly 128-bit key/KID; + refresh replaces cached unsupported parser results). 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, diff --git a/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts b/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts index 7bbf9c4b6..37184339a 100644 --- a/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts +++ b/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts @@ -153,11 +153,22 @@ async function startDashFixtureServer(): Promise { } function buildDashPlaylist(origin: string): string { + // Regression for #1466: ordinary Base64 includes + and / in this key. + const license = JSON.stringify({ + keys: [ + { + kty: 'oct', + kid: Buffer.from(CLEARKEY_KID, 'hex').toString('base64'), + k: Buffer.from(CLEARKEY_KEY, 'hex').toString('base64'), + }, + ], + type: 'temporary', + }); 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}`, + `#KODIPROP:inputstream.adaptive.license_key=${license}`, `${origin}/clearkey.mpd`, '#EXTINF:-1 tvg-id="wv-dash" group-title="DASH",Widevine DASH', '#KODIPROP:inputstream.adaptive.license_type=com.widevine.alpha', diff --git a/apps/web-e2e/src/dash-clearkey.e2e.ts b/apps/web-e2e/src/dash-clearkey.e2e.ts index e2ad28d2f..49f60313c 100644 --- a/apps/web-e2e/src/dash-clearkey.e2e.ts +++ b/apps/web-e2e/src/dash-clearkey.e2e.ts @@ -16,12 +16,27 @@ const FIXTURE_HOST = 'https://dash-fixture.local'; const CLEARKEY_KID = '00112233445566778899aabbccddeeff'; const CLEARKEY_KEY = 'ffeeddccbbaa99887766554433221100'; +// Regression for #1466: accept standard Base64 JSON without padding in PWA. +const CLEARKEY_LICENSE = JSON.stringify({ + keys: [ + { + kty: 'oct', + kid: Buffer.from(CLEARKEY_KID, 'hex') + .toString('base64') + .replace(/=+$/, ''), + k: Buffer.from(CLEARKEY_KEY, 'hex') + .toString('base64') + .replace(/=+$/, ''), + }, + ], + type: 'temporary', +}); 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}`, + `#KODIPROP:inputstream.adaptive.license_key=${CLEARKEY_LICENSE}`, `${FIXTURE_HOST}/clearkey.mpd`, '#EXTINF:-1 tvg-id="clear-dash" group-title="DASH",Clear DASH', `${FIXTURE_HOST}/clear.mpd`, diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 8f6da45fa..172ed7944 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -1313,7 +1313,12 @@ player in settings. `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 + JSON map. Key components also accept Base64URL and ordinary Base64 (`+`/`/`), + with or without padding; decoded keys and KIDs must remain exactly 128 bits. + This includes JSON exports that use ordinary Base64 instead of the W3C + Base64URL alphabet. Refresh an already imported source to replace a cached + `drm.supported: false` result after a parser compatibility update. + 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 diff --git a/libs/shared/m3u-utils/src/lib/kodiprop.utils.spec.ts b/libs/shared/m3u-utils/src/lib/kodiprop.utils.spec.ts index 2864ca1e0..61fa9cfb5 100644 --- a/libs/shared/m3u-utils/src/lib/kodiprop.utils.spec.ts +++ b/libs/shared/m3u-utils/src/lib/kodiprop.utils.spec.ts @@ -90,6 +90,92 @@ describe('kodiprop.utils', () => { expect(drm?.clearKeys).toEqual({ [KID_HEX]: KEY_HEX }); }); + describe.each(['atob', 'Buffer'])('%s decoder', (decoder) => { + const atobDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'atob' + ); + + beforeEach(() => { + if (decoder === 'Buffer') { + Object.defineProperty(globalThis, 'atob', { + configurable: true, + value: undefined, + }); + } + }); + + afterEach(() => { + if (atobDescriptor) { + Object.defineProperty(globalThis, 'atob', atobDescriptor); + } else { + Reflect.deleteProperty(globalThis, 'atob'); + } + }); + + it.each([true, false])( + 'accepts standard Base64 JSON keys (padding=%s)', + (padding) => { + // Synthetic bytes exercise both + and /, as in #1466. + const kidHex = 'fb'.repeat(16); + const keyHex = 'ff'.repeat(16); + const encode = (hex: string): string => { + const value = Buffer.from(hex, 'hex').toString( + 'base64' + ); + return padding ? value : value.replace(/=+$/, ''); + }; + const license = JSON.stringify({ + keys: [ + { + kty: 'oct', + kid: encode(kidHex), + k: encode(keyHex), + }, + ], + type: 'temporary', + }); + + expect( + extractDrmFromRaw( + rawWith( + '#KODIPROP:inputstream.adaptive.license_type=clearkey', + `#KODIPROP:inputstream.adaptive.license_key=${license}` + ) + ) + ).toEqual({ + licenseType: 'clearkey', + supported: true, + clearKeys: { [kidHex]: keyHex }, + }); + } + ); + + it.each([ + Buffer.alloc(15, 255).toString('base64'), + Buffer.alloc(17, 255).toString('base64'), + `${KEY_B64}!`, + `${KEY_B64.slice(0, 10)} ${KEY_B64.slice(10)}`, + `${KEY_B64}===`, + `${KEY_B64.slice(0, 10)}=${KEY_B64.slice(10)}`, + `${KID_HEX.slice(0, 16)} corrupted ${KID_HEX.slice(16)}`, + ])('rejects invalid key components: %s', (invalid) => { + for (const entry of [ + { kid: invalid, k: KEY_B64 }, + { kid: KID_B64, k: invalid }, + ]) { + expect( + extractDrmFromRaw( + rawWith( + '#KODIPROP:inputstream.adaptive.license_type=clearkey', + `#KODIPROP:inputstream.adaptive.license_key=${JSON.stringify({ keys: [entry] })}` + ) + ) + ).toEqual({ licenseType: 'clearkey', supported: false }); + } + }); + }); + it('parses the drm_legacy combined property', () => { const drm = extractDrmFromRaw( rawWith( @@ -163,9 +249,7 @@ describe('kodiprop.utils', () => { it('ignores unrelated KODIPROP properties', () => { expect( extractDrmFromRaw( - rawWith( - '#KODIPROP:inputstream.adaptive.manifest_type=mpd' - ) + rawWith('#KODIPROP:inputstream.adaptive.manifest_type=mpd') ) ).toBeUndefined(); }); diff --git a/libs/shared/m3u-utils/src/lib/kodiprop.utils.ts b/libs/shared/m3u-utils/src/lib/kodiprop.utils.ts index d2bdae1ae..e1e16a15b 100644 --- a/libs/shared/m3u-utils/src/lib/kodiprop.utils.ts +++ b/libs/shared/m3u-utils/src/lib/kodiprop.utils.ts @@ -3,10 +3,9 @@ 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. + * The playlist parser preserves `#KODIPROP:` lines before `#EXTINF` or + * between it and the stream URL in `item.raw`. This module post-processes + * that raw block into a typed {@link ChannelDrm} value. * * Supported properties: * - `inputstream.adaptive.license_type` + `inputstream.adaptive.license_key` @@ -81,9 +80,7 @@ export function extractDrmFromRaw( }; } -function collectKodipropValues( - raw: string | undefined -): Map { +function collectKodipropValues(raw: string | undefined): Map { const props = new Map(); if (!raw) { return props; @@ -220,7 +217,8 @@ function parseClearKeyMap( /** * Normalizes a 128-bit key component to 32 lowercase hex chars. Accepts plain - * or dashed (UUID-style) hex and base64url (the W3C license encoding). + * or dashed (UUID-style) hex, base64url (the W3C license encoding), and + * standard Base64 used by some playlist exporters. */ function normalizeKeyComponent(value: string | undefined): string | undefined { const compact = value?.trim().replace(/-/g, '').toLowerCase(); @@ -232,14 +230,14 @@ function normalizeKeyComponent(value: string | undefined): string | undefined { return compact; } - const fromBase64 = base64UrlToHex(value?.trim() ?? ''); + const fromBase64 = base64ToHex(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)) { +function base64ToHex(value: string): string | undefined { + if (!/^[A-Za-z0-9_+/-]+={0,2}$/.test(value)) { return undefined; } diff --git a/tools/release/capture-app-driver.ts b/tools/release/capture-app-driver.ts index cfbe7dd0e..bc58924ce 100644 --- a/tools/release/capture-app-driver.ts +++ b/tools/release/capture-app-driver.ts @@ -6,7 +6,7 @@ */ import { spawn, type ChildProcess } from 'node:child_process'; -import { mkdirSync, writeFileSync } from 'node:fs'; +import { mkdirSync } from 'node:fs'; import path from 'node:path'; import { _electron as electron, @@ -15,7 +15,6 @@ import { } from '@playwright/test'; import { - M3U_FIXTURE_TITLE, STALKER_FIXTURE_MAC, STALKER_FIXTURE_PORTAL_URL, STALKER_FIXTURE_TITLE, @@ -38,6 +37,7 @@ export { M3U_FIXTURE_TITLE, XTREAM_FIXTURE_TITLE, XTREAM_MOCK_ORIGIN, + writeM3uFixture, } from './capture-fixtures'; /** @@ -66,34 +66,6 @@ const STALKER_MOCK_FIXTURE_CATEGORIES = ['Newsroom', 'Culture & Docs']; /* Fixtures */ /* ------------------------------------------------------------------ */ -/** Entirely synthetic channels; streams and logos point at the mock. */ -export function writeM3uFixture(dataDir: string): string { - const channels = [ - ['Newsroom', 'Aurora Local', 'aurora-local'], - ['Newsroom', 'Civic Pulse', 'civic-pulse'], - ['Sports', 'Fieldside One', 'fieldside-one'], - ['Sports', 'Motion Arena', 'motion-arena'], - ['Kids', 'Horizon Kids', 'horizon-kids'], - ['Kids', 'Story Lantern', 'story-lantern'], - ['Culture', 'Atlas Culture', 'atlas-culture'], - ['Culture', 'Night Music', 'night-music'], - ]; - const stream = `${XTREAM_MOCK_ORIGIN}/live/marketing/marketing/52000.m3u8`; - const lines = ['#EXTM3U']; - - channels.forEach(([group, title, slug], index) => { - lines.push( - `#EXTINF:-1 tvg-id="demo-${index + 1}" tvg-name="${title}" tvg-logo="${XTREAM_MOCK_ORIGIN}/assets/marketing/logo/${slug}.svg?size=256x256" group-title="${group}",${title}`, - stream - ); - }); - - const filePath = path.join(dataDir, `${M3U_FIXTURE_TITLE}.m3u`); - writeFileSync(filePath, `${lines.join('\n')}\n`, 'utf8'); - - return filePath; -} - export async function ensureXtreamMockServer( workspaceRoot: string ): Promise { diff --git a/tools/release/capture-fixtures.ts b/tools/release/capture-fixtures.ts index cfda0f827..f6c52760c 100644 --- a/tools/release/capture-fixtures.ts +++ b/tools/release/capture-fixtures.ts @@ -1,6 +1,6 @@ /** - * Fixture identities shared by the capture driver (seeding) and the named - * setup actions (guide shots that re-enter the add-playlist dialog). Kept in + * Fixture identities and M3U generation shared by the capture driver and + * the named setup actions (guide shots that re-enter the add-playlist dialog). Kept in * a leaf module so capture-navigation.ts can import them without pulling in * the driver, which itself imports the navigation module. * @@ -10,6 +10,9 @@ * instead of a `get.php?username=…` link. */ +import { writeFileSync } from 'node:fs'; +import path from 'node:path'; + import { FICTIONAL_STALKER_MAC } from './screenshot-guards.mjs'; export const XTREAM_MOCK_ORIGIN = 'http://localhost:3211'; @@ -73,3 +76,31 @@ export const AUTO_DETECT_FIXTURE_MESSAGE = [ '', 'Use these details in any Xtream Codes compatible player.', ].join('\n'); + +/** Entirely synthetic channels; streams and logos point at the mock. */ +export function writeM3uFixture(dataDir: string): string { + const channels = [ + ['Newsroom', 'Aurora Local', 'aurora-local'], + ['Newsroom', 'Civic Pulse', 'civic-pulse'], + ['Sports', 'Fieldside One', 'fieldside-one'], + ['Sports', 'Motion Arena', 'motion-arena'], + ['Kids', 'Horizon Kids', 'horizon-kids'], + ['Kids', 'Story Lantern', 'story-lantern'], + ['Culture', 'Atlas Culture', 'atlas-culture'], + ['Culture', 'Night Music', 'night-music'], + ]; + const stream = `${XTREAM_MOCK_ORIGIN}/live/marketing/marketing/52000.m3u8`; + const lines = ['#EXTM3U']; + + channels.forEach(([group, title, slug], index) => { + lines.push( + `#EXTINF:-1 tvg-id="demo-${index + 1}" tvg-name="${title}" tvg-logo="${XTREAM_MOCK_ORIGIN}/assets/marketing/logo/${slug}.svg?size=256x256" group-title="${group}",${title}`, + stream + ); + }); + + const filePath = path.join(dataDir, `${M3U_FIXTURE_TITLE}.m3u`); + writeFileSync(filePath, `${lines.join('\n')}\n`, 'utf8'); + + return filePath; +} diff --git a/tools/release/project.json b/tools/release/project.json index 7d2f7e24f..6b436d17c 100644 --- a/tools/release/project.json +++ b/tools/release/project.json @@ -41,7 +41,7 @@ "{workspaceRoot}/tools/eslint-rules/**/*", "{workspaceRoot}/tools/eslint/**/*" ], - "command": "eslint \"tools/release/*.mjs\" \"tools/release/capture-release-screenshots.ts\" \"tools/release/capture-app-driver.ts\" \"tools/release/capture-navigation.ts\" \"tools/release/capture-navigation-helpers.ts\" \"tools/release/capture-navigation-setup-actions.ts\" \"tools/release/capture-navigation-portal-actions.ts\" \"tools/release/capture-navigation-download-actions.ts\" \"tools/release/capture-network-gate.ts\" \"tools/release/capture-tmdb-check.ts\"" + "command": "eslint \"tools/release/*.mjs\" \"tools/release/capture-release-screenshots.ts\" \"tools/release/capture-app-driver.ts\" \"tools/release/capture-fixtures.ts\" \"tools/release/capture-navigation.ts\" \"tools/release/capture-navigation-helpers.ts\" \"tools/release/capture-navigation-setup-actions.ts\" \"tools/release/capture-navigation-portal-actions.ts\" \"tools/release/capture-navigation-download-actions.ts\" \"tools/release/capture-network-gate.ts\" \"tools/release/capture-tmdb-check.ts\"" } }, "tags": [