Files
iptvnator/libs/shared/m3u-utils/src/lib/kodiprop.utils.spec.ts
T
4gray 93e759e1da 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
2026-09-08 08:59:00 +02:00

279 lines
10 KiB
TypeScript

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 });
});
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(
`#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);
}
);
});
});