diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index b65a7381a..76c222da6 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -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'; diff --git a/libs/shared/interfaces/src/lib/channel-drm.interface.ts b/libs/shared/interfaces/src/lib/channel-drm.interface.ts new file mode 100644 index 000000000..3c9eac43c --- /dev/null +++ b/libs/shared/interfaces/src/lib/channel-drm.interface.ts @@ -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; +} diff --git a/libs/shared/interfaces/src/lib/channel.interface.ts b/libs/shared/interfaces/src/lib/channel.interface.ts index 03bef9ee7..e45e39069 100644 --- a/libs/shared/interfaces/src/lib/channel.interface.ts +++ b/libs/shared/interfaces/src/lib/channel.interface.ts @@ -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; } diff --git a/libs/shared/interfaces/src/lib/parsed-playlist.interface.ts b/libs/shared/interfaces/src/lib/parsed-playlist.interface.ts index 32f7a3473..48d1a0f0f 100644 --- a/libs/shared/interfaces/src/lib/parsed-playlist.interface.ts +++ b/libs/shared/interfaces/src/lib/parsed-playlist.interface.ts @@ -1,3 +1,5 @@ +import { ChannelDrm } from './channel-drm.interface'; + export interface ParsedPlaylist { header: { attrs: Record; @@ -31,4 +33,5 @@ export interface ParsedPlaylistItem { }; timeshift?: string; radio?: string; + drm?: ChannelDrm; } diff --git a/libs/shared/interfaces/src/lib/portal-playback.interface.ts b/libs/shared/interfaces/src/lib/portal-playback.interface.ts index 1ab48fa88..c06ebb8bc 100644 --- a/libs/shared/interfaces/src/lib/portal-playback.interface.ts +++ b/libs/shared/interfaces/src/lib/portal-playback.interface.ts @@ -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; } diff --git a/libs/shared/m3u-utils/src/index.ts b/libs/shared/m3u-utils/src/index.ts index d50f5a867..493feb47e 100644 --- a/libs/shared/m3u-utils/src/index.ts +++ b/libs/shared/m3u-utils/src/index.ts @@ -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'; diff --git a/libs/shared/m3u-utils/src/lib/dash.utils.spec.ts b/libs/shared/m3u-utils/src/lib/dash.utils.spec.ts new file mode 100644 index 000000000..b51ad7988 --- /dev/null +++ b/libs/shared/m3u-utils/src/lib/dash.utils.spec.ts @@ -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); + }); +}); diff --git a/libs/shared/m3u-utils/src/lib/dash.utils.ts b/libs/shared/m3u-utils/src/lib/dash.utils.ts new file mode 100644 index 000000000..3fcf29072 --- /dev/null +++ b/libs/shared/m3u-utils/src/lib/dash.utils.ts @@ -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 | null | undefined +): boolean => isDashStreamUrl(channel?.url); diff --git a/libs/shared/m3u-utils/src/lib/kodiprop.utils.spec.ts b/libs/shared/m3u-utils/src/lib/kodiprop.utils.spec.ts new file mode 100644 index 000000000..2864ca1e0 --- /dev/null +++ b/libs/shared/m3u-utils/src/lib/kodiprop.utils.spec.ts @@ -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); + } + ); + }); +}); diff --git a/libs/shared/m3u-utils/src/lib/kodiprop.utils.ts b/libs/shared/m3u-utils/src/lib/kodiprop.utils.ts new file mode 100644 index 000000000..d2bdae1ae --- /dev/null +++ b/libs/shared/m3u-utils/src/lib/kodiprop.utils.ts @@ -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` (`|`) + * + * 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 { + const props = new Map(); + 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; + 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 +): 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'); +} diff --git a/libs/shared/m3u-utils/src/lib/playlist.utils.spec.ts b/libs/shared/m3u-utils/src/lib/playlist.utils.spec.ts index 37e296596..55eebfbc5 100644 --- a/libs/shared/m3u-utils/src/lib/playlist.utils.spec.ts +++ b/libs/shared/m3u-utils/src/lib/playlist.utils.spec.ts @@ -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({ diff --git a/libs/shared/m3u-utils/src/lib/playlist.utils.ts b/libs/shared/m3u-utils/src/lib/playlist.utils.ts index 2aa9e23d8..1361d13a7 100644 --- a/libs/shared/m3u-utils/src/lib/playlist.utils.ts +++ b/libs/shared/m3u-utils/src/lib/playlist.utils.ts @@ -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(),