fix(m3u): accept standard Base64 ClearKey values (#1575)

* fix(m3u): accept standard Base64 ClearKey values

* refactor(release): move M3U fixture generation out of capture driver
This commit is contained in:
4gray authored and GitHub committed 2026-09-08 08:59:00 +02:00
1 parent c952b55da1
commit 93e759e1da
11 files changed
+178 -52

No files matched your search

+6
View File
@@ -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.
+3 -1
View File
@@ -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,
+3 -1
View File
@@ -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,
@@ -153,11 +153,22 @@ async function startDashFixtureServer(): Promise<DashFixtureServer> {
}
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',
+16 -1
View File
@@ -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`,
+6 -1
View File
@@ -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
@@ -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();
});
@@ -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<string, string> {
function collectKodipropValues(raw: string | undefined): Map<string, string> {
const props = new Map<string, string>();
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;
}
+2 -30
View File
@@ -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<ChildProcess | undefined> {
+33 -2
View File
@@ -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;
}
+1 -1
View File
@@ -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": [