mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
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:
1 parent
643dee1be3
commit
e9d336ee7a
12 files changed
+599
-4
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user