mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 20:56:19 -08:00
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:
1 parent
c952b55da1
commit
93e759e1da
11 files changed
+178
-52
No files matched your search
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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`,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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": [
|
||||
|
||||
Reference in new issue
Block a user