feat(m3u): extract ClearKey DRM from #KODIPROP playlist lines

Adds the typed ChannelDrm model (shared interfaces) and a KODIPROP
post-processing step in createPlaylistObject() — the single funnel for all
four playlist import paths. Parses inputstream.adaptive.license_type,
license_key and drm_legacy; ClearKey keys accepted as kid:key hex pairs,
W3C ClearKey license JSON, or a plain kid→key JSON map. Unsupported license
types (Widevine/PlayReady/license URLs) are preserved with supported=false
so playback can surface a DRM diagnostic instead of failing silently.
Also adds isDashStreamUrl/isDashChannel helpers for DASH routing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-07-18 19:16:25 +02:00
1 parent 643dee1be3
commit e9d336ee7a
12 files changed
+599 -4

No files matched your search

+1
View File
@@ -1,3 +1,4 @@
export * from './lib/channel-drm.interface';
export * from './lib/channel.interface';
export * from './lib/channel.model';
export * from './lib/dev-logger.util';
@@ -0,0 +1,18 @@
/**
* DRM configuration extracted from `#KODIPROP:inputstream.adaptive.*` lines of
* an M3U playlist entry. Only ClearKey is playable in-app; other license types
* are preserved so playback can surface a meaningful DRM diagnostic instead of
* failing silently.
*/
export interface ChannelDrmClearKeys {
/** Key id (32 lowercase hex chars) mapped to its key (32 lowercase hex chars). */
[kidHex: string]: string;
}
export interface ChannelDrm {
/** Normalized license type, e.g. `clearkey` or `com.widevine.alpha`. */
licenseType: string;
/** True only for ClearKey entries with at least one successfully parsed key. */
supported: boolean;
clearKeys?: ChannelDrmClearKeys;
}
@@ -1,3 +1,5 @@
import { ChannelDrm } from './channel-drm.interface';
/**
* Represents channel object
* TODO: define channel interface in iptv-parser library
@@ -29,4 +31,5 @@ export interface Channel {
origin: string;
};
radio: string;
drm?: ChannelDrm;
}
@@ -1,3 +1,5 @@
import { ChannelDrm } from './channel-drm.interface';
export interface ParsedPlaylist {
header: {
attrs: Record<string, string | undefined>;
@@ -31,4 +33,5 @@ export interface ParsedPlaylistItem {
};
timeshift?: string;
radio?: string;
drm?: ChannelDrm;
}
@@ -1,3 +1,4 @@
import { ChannelDrm } from './channel-drm.interface';
import { PlaybackPositionData } from './playback-position.interface';
export interface PlayerContentInfo extends Omit<
@@ -18,4 +19,5 @@ export interface ResolvedPortalPlayback {
userAgent?: string;
referer?: string;
origin?: string;
drm?: ChannelDrm;
}
+2
View File
@@ -1,3 +1,5 @@
export * from './lib/dash.utils';
export * from './lib/kodiprop.utils';
export * from './lib/playlist.utils';
export * from './lib/catchup.utils';
export * from './lib/strip-country-prefix.util';
@@ -0,0 +1,33 @@
import { isDashChannel, isDashStreamUrl } from './dash.utils';
describe('dash.utils', () => {
it.each([
'https://example.com/live/stream.mpd',
'https://example.com/live/stream.mpd?token=abc#frag',
'https://example.com/play?extension=mpd',
])('detects %s as DASH', (url) => {
expect(isDashStreamUrl(url)).toBe(true);
});
it.each([
'https://example.com/live/playlist.m3u8',
'https://example.com/live/stream.ts',
'https://example.com/live.php?id=7',
'https://example.com/movie.mp4',
'',
undefined,
])('does not flag %s as DASH', (url) => {
expect(isDashStreamUrl(url as string | undefined)).toBe(false);
});
it('detects DASH channels by their stream URL', () => {
expect(
isDashChannel({ url: 'https://example.com/enc.mpd' })
).toBe(true);
expect(
isDashChannel({ url: 'https://example.com/live.m3u8' })
).toBe(false);
expect(isDashChannel(null)).toBe(false);
expect(isDashChannel(undefined)).toBe(false);
});
});
@@ -0,0 +1,14 @@
import { Channel } from '@iptvnator/shared/interfaces';
import { getStreamExtensionFromUrl } from './playlist.utils';
/**
* DASH (`.mpd`) detection for M3U channels. DASH streams must always play in
* a Shaka-capable built-in web engine: external MPV/VLC cannot receive the
* KODIPROP ClearKey configuration, and Video.js has no DASH bridge yet.
*/
export const isDashStreamUrl = (url: string | undefined): boolean =>
!!url && getStreamExtensionFromUrl(url) === 'mpd';
export const isDashChannel = (
channel: Pick<Channel, 'url'> | null | undefined
): boolean => isDashStreamUrl(channel?.url);
@@ -0,0 +1,194 @@
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 });
});
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);
}
);
});
});
@@ -0,0 +1,279 @@
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.
*
* Supported properties:
* - `inputstream.adaptive.license_type` + `inputstream.adaptive.license_key`
* - `inputstream.adaptive.drm_legacy` (`<license type>|<license key>`)
*
* ClearKey key formats: single `kid:key` hex pair, comma-separated pairs, the
* W3C ClearKey license JSON (`{"keys":[{"kty":"oct","k":...,"kid":...}]}`)
* and the plain `{kid: key}` JSON map. Anything else (license-server URLs,
* Widevine/PlayReady types, malformed values) yields `supported: false` so the
* player can show a clear DRM diagnostic instead of crashing.
*/
const KODIPROP_PREFIX = '#kodiprop:';
const LICENSE_TYPE_PROP = 'inputstream.adaptive.license_type';
const LICENSE_KEY_PROP = 'inputstream.adaptive.license_key';
const DRM_LEGACY_PROP = 'inputstream.adaptive.drm_legacy';
const CLEARKEY_LICENSE_TYPES = new Set(['clearkey', 'org.w3.clearkey']);
const HEX_128_BIT_PATTERN = /^[0-9a-f]{32}$/;
export function isClearKeyLicenseType(licenseType: string): boolean {
return CLEARKEY_LICENSE_TYPES.has(licenseType.trim().toLowerCase());
}
/**
* Extracts DRM configuration from a playlist item's raw M3U block.
* Returns `undefined` when the block carries no DRM-related `#KODIPROP` lines,
* so channels without KODIPROP metadata stay untouched.
*/
export function extractDrmFromRaw(
raw: string | undefined
): ChannelDrm | undefined {
const props = collectKodipropValues(raw);
if (props.size === 0) {
return undefined;
}
let licenseType = props.get(LICENSE_TYPE_PROP)?.trim() ?? '';
let licenseKey = props.get(LICENSE_KEY_PROP)?.trim() ?? '';
const drmLegacy = props.get(DRM_LEGACY_PROP)?.trim();
if (drmLegacy && (!licenseType || !licenseKey)) {
const [legacyType, ...legacyKeyParts] = drmLegacy.split('|');
licenseType ||= legacyType?.trim() ?? '';
licenseKey ||= legacyKeyParts.join('|').trim();
}
if (!licenseType && !licenseKey) {
return undefined;
}
const normalizedType = licenseType.toLowerCase();
const clearKeys = isClearKeyLicenseType(normalizedType)
? parseClearKeys(licenseKey)
: !normalizedType
? // No explicit type: a parseable kid:key value is ClearKey in practice.
parseClearKeys(licenseKey)
: undefined;
if (clearKeys) {
return {
licenseType: normalizedType || 'clearkey',
supported: true,
clearKeys,
};
}
return {
licenseType: normalizedType,
supported: false,
};
}
function collectKodipropValues(
raw: string | undefined
): Map<string, string> {
const props = new Map<string, string>();
if (!raw) {
return props;
}
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed.toLowerCase().startsWith(KODIPROP_PREFIX)) {
continue;
}
const assignment = trimmed.slice(KODIPROP_PREFIX.length);
const separatorIndex = assignment.indexOf('=');
if (separatorIndex < 1) {
continue;
}
const name = assignment.slice(0, separatorIndex).trim().toLowerCase();
const value = assignment.slice(separatorIndex + 1).trim();
if (
name === LICENSE_TYPE_PROP ||
name === LICENSE_KEY_PROP ||
name === DRM_LEGACY_PROP
) {
props.set(name, value);
}
}
return props;
}
function parseClearKeys(value: string): ChannelDrmClearKeys | undefined {
if (!value) {
return undefined;
}
return value.startsWith('{')
? parseClearKeysFromJson(value)
: parseClearKeysFromPairs(value);
}
function parseClearKeysFromPairs(
value: string
): ChannelDrmClearKeys | undefined {
const keys: ChannelDrmClearKeys = {};
for (const pair of value.split(',')) {
const [kid, key, ...rest] = pair.split(':');
if (rest.length > 0) {
return undefined;
}
const kidHex = normalizeKeyComponent(kid);
const keyHex = normalizeKeyComponent(key);
if (!kidHex || !keyHex) {
return undefined;
}
keys[kidHex] = keyHex;
}
return Object.keys(keys).length > 0 ? keys : undefined;
}
function parseClearKeysFromJson(
value: string
): ChannelDrmClearKeys | undefined {
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
return undefined;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return undefined;
}
const record = parsed as Record<string, unknown>;
return Array.isArray(record['keys'])
? parseW3cClearKeyLicense(record['keys'])
: parseClearKeyMap(record);
}
/** W3C ClearKey license form: `{"keys":[{"kty":"oct","k":...,"kid":...}]}`. */
function parseW3cClearKeyLicense(
entries: unknown[]
): ChannelDrmClearKeys | undefined {
const keys: ChannelDrmClearKeys = {};
for (const entry of entries) {
if (!entry || typeof entry !== 'object') {
return undefined;
}
const { kid, k } = entry as { kid?: unknown; k?: unknown };
const kidHex = normalizeKeyComponent(
typeof kid === 'string' ? kid : undefined
);
const keyHex = normalizeKeyComponent(
typeof k === 'string' ? k : undefined
);
if (!kidHex || !keyHex) {
return undefined;
}
keys[kidHex] = keyHex;
}
return Object.keys(keys).length > 0 ? keys : undefined;
}
function parseClearKeyMap(
record: Record<string, unknown>
): ChannelDrmClearKeys | undefined {
const keys: ChannelDrmClearKeys = {};
for (const [kid, key] of Object.entries(record)) {
if (typeof key !== 'string') {
return undefined;
}
const kidHex = normalizeKeyComponent(kid);
const keyHex = normalizeKeyComponent(key);
if (!kidHex || !keyHex) {
return undefined;
}
keys[kidHex] = keyHex;
}
return Object.keys(keys).length > 0 ? keys : undefined;
}
/**
* Normalizes a 128-bit key component to 32 lowercase hex chars. Accepts plain
* or dashed (UUID-style) hex and base64url (the W3C license encoding).
*/
function normalizeKeyComponent(value: string | undefined): string | undefined {
const compact = value?.trim().replace(/-/g, '').toLowerCase();
if (!compact) {
return undefined;
}
if (HEX_128_BIT_PATTERN.test(compact)) {
return compact;
}
const fromBase64 = base64UrlToHex(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)) {
return undefined;
}
const base64 = value.replace(/-/g, '+').replace(/_/g, '/');
try {
const binary = decodeBase64(base64);
let hex = '';
for (let index = 0; index < binary.length; index += 1) {
hex += binary.charCodeAt(index).toString(16).padStart(2, '0');
}
return hex;
} catch {
return undefined;
}
}
/** Works in both the browser (atob) and Node workers (Buffer). */
function decodeBase64(base64: string): string {
if (typeof atob === 'function') {
return atob(base64);
}
const nodeBuffer = (
globalThis as {
Buffer?: {
from(
data: string,
encoding: string
): { toString(encoding: string): string };
};
}
).Buffer;
if (!nodeBuffer) {
throw new Error('No base64 decoder available');
}
return nodeBuffer.from(base64, 'base64').toString('binary');
}
@@ -240,6 +240,47 @@ describe('playlist utils', () => {
expect(playlist.epgUrls).toEqual(epgUrls.slice(0, 5));
});
it('attaches ClearKey DRM extracted from raw KODIPROP lines and leaves other channels untouched', () => {
const kid = '9eb4050de44b4802932e27d75083e266';
const key = '166634c675823c235a4a9446fad52e4d';
const baseItem = {
tvg: { id: '', name: '', url: '', logo: '', rec: '' },
group: { title: 'DASH' },
http: { referrer: '', 'user-agent': '' },
};
const playlist = createPlaylistObject('DRM playlist', {
header: { attrs: {}, raw: '#EXTM3U' },
items: [
{
...baseItem,
name: 'Encrypted DASH',
url: 'https://example.com/enc.mpd',
raw: [
'#EXTINF:-1,Encrypted DASH',
'#KODIPROP:inputstream.adaptive.license_type=clearkey',
`#KODIPROP:inputstream.adaptive.license_key=${kid}:${key}`,
'https://example.com/enc.mpd',
].join('\r\n'),
},
{
...baseItem,
name: 'Plain HLS',
url: 'https://example.com/live.m3u8',
raw: '#EXTINF:-1,Plain HLS\r\nhttps://example.com/live.m3u8',
},
],
});
const [encrypted, plain] = playlist.playlist.items;
expect(encrypted.drm).toEqual({
licenseType: 'clearkey',
supported: true,
clearKeys: { [kid]: key },
});
expect(plain.drm).toBeUndefined();
expect('drm' in plain).toBe(false);
});
it('keeps disabled playlist EPG sources out while preserving manually enabled URLs', () => {
expect(
resolvePlaylistEpgSourceState({
@@ -5,6 +5,7 @@ import {
Playlist,
} from '@iptvnator/shared/interfaces';
import { v4 as uuidv4 } from 'uuid';
import { extractDrmFromRaw } from './kodiprop.utils';
/**
* Aggregates favorite channels as objects from all available playlists
@@ -392,10 +393,14 @@ export const createPlaylistObject = (
count: playlist.items.length,
playlist: {
...playlist,
items: playlist.items.map((item: ParsedPlaylistItem) => ({
...item,
id: uuidv4(),
})),
items: playlist.items.map((item: ParsedPlaylistItem) => {
const drm = extractDrmFromRaw(item.raw);
return {
...item,
id: uuidv4(),
...(drm ? { drm } : {}),
};
}),
},
importDate: new Date().toISOString(),
lastUsage: new Date().toISOString(),