diff --git a/lib/streaming_providers/base/models/drm/drm_config.py b/lib/streaming_providers/base/models/drm/drm_config.py index bcd9236..a9f060d 100644 --- a/lib/streaming_providers/base/models/drm/drm_config.py +++ b/lib/streaming_providers/base/models/drm/drm_config.py @@ -15,20 +15,49 @@ from .exceptions import LicenseConfigError @dataclass class DRMConfig: """ - Complete DRM Configuration. + Complete DRM Configuration for a single DRM system. - Combines DRM system identification with license configuration - for a single DRM system. + Produces output compatible with the inputstream.adaptive.drm JSON property + (Kodi 22+). The to_dict() output can be merged with other DRMConfig dicts + and passed directly to json.dumps() — no further transformation needed. + + Top-level ISA parameters (outside "license"): Attributes: - system: DRM system type - priority: Priority for multi-DRM scenarios (higher = preferred) - license: License server configuration (optional for unencrypted) + system: DRM system type. + priority: DRM priority in multi-DRM scenarios. Lower number = higher + priority. Must be >= 1; 0 is invalid per ISA spec. Two DRMs must + not share the same priority value. + license: License server configuration. + init_data: Custom initialization data (PSSH box) encoded as base64. + Replaces any PSSH provided by the manifest. For Widevine, also + accepts raw Widevine PSSH data with optional placeholders: + {KID} (KID as bytes), {UUID} (KID as UUID string) — both must + be encoded as base64 together with the surrounding data. + pre_init_data: Widevine only. Pre-initialize a DRM session for + licensed manifests. Format: "PSSH_base64|KID_base64". + Requires priority=1 and a proxy server in the add-on. + persistent_storage: Enable CDM persistent state (store session data + locally). Only enable if the streaming service requires it. + secure_decoder: Force-enable (True) or force-disable (False) the + secure decoder, overriding the ISA add-on user setting. + Omit (None) to leave the user setting untouched. + force_single_session: Force a single DRM session for all tracks. + Saves license round-trips but may cause playback issues if the + backend does not return all keys in one response. + optional_key_req_params: CDM-specific key request parameters. + PlayReady: {"custom_data": "..."} sets PRCustomData. """ system: DRMSystem - priority: int = 0 + priority: int = 1 license: Optional[LicenseConfig] = None + init_data: Optional[str] = None + pre_init_data: Optional[str] = None + persistent_storage: Optional[bool] = None + secure_decoder: Optional[bool] = None + force_single_session: Optional[bool] = None + optional_key_req_params: Optional[dict] = None def validate(self) -> None: """ @@ -42,34 +71,64 @@ class DRMConfig: f"system must be a DRMSystem enum, got {type(self.system)}" ) + # priority=0 is explicitly invalid per ISA spec + if self.priority == 0: + raise LicenseConfigError( + "priority=0 is invalid per ISA spec. Use priority >= 1 " + "(lower number = higher priority)." + ) + + if self.pre_init_data and self.priority != 1: + raise LicenseConfigError( + "pre_init_data requires priority=1 per ISA spec." + ) + if self.license: self.license.validate() def to_dict(self) -> dict[str, dict]: """ - Convert to dictionary format expected by players. + Convert to the dictionary format expected by inputstream.adaptive.drm. Returns: - Dictionary with DRM configuration in player-compatible format: { "com.widevine.alpha": { "priority": 1, - "license": {...} + "license": { ... }, # if set + "init_data": "...", # if set + "pre_init_data": "...", # if set + "persistent_storage": True, # if set + "secure_decoder": False, # if set + "force_single_session": True, # if set + "optional_key_req_params": {...} # if set } } """ - result: dict[str, dict] = { - self.system.value: { - "priority": self.priority - } - } + cfg: dict = {"priority": self.priority} if self.license: license_dict = self.license.to_dict() if license_dict: - result[self.system.value]["license"] = license_dict + cfg["license"] = license_dict - return result + if self.init_data is not None: + cfg["init_data"] = self.init_data + if self.pre_init_data is not None: + cfg["pre_init_data"] = self.pre_init_data + if self.persistent_storage is not None: + cfg["persistent_storage"] = self.persistent_storage + if self.secure_decoder is not None: + cfg["secure_decoder"] = self.secure_decoder + if self.force_single_session is not None: + cfg["force_single_session"] = self.force_single_session + if self.optional_key_req_params: + cfg["optional_key_req_params"] = self.optional_key_req_params + + return {self.system.value: cfg} + + # ------------------------------------------------------------------ + # Factory helpers + # ------------------------------------------------------------------ @classmethod def create_widevine( @@ -79,15 +138,13 @@ class DRMConfig: **license_kwargs ) -> "DRMConfig": """ - Helper to create Widevine DRM configuration. + Create a Widevine DRM configuration. Args: - server_url: Widevine license server URL - priority: Priority (default: 1) - **license_kwargs: Additional LicenseConfig parameters - - Returns: - DRMConfig instance for Widevine + server_url: Widevine license server URL. Supports {CHA-B64U}, + {CHA-MD5} placeholders to inject the challenge in the URL. + priority: Priority (default: 1). + **license_kwargs: Additional LicenseConfig parameters. """ license_config = LicenseConfig(server_url=server_url, **license_kwargs) return cls(system=DRMSystem.WIDEVINE, priority=priority, license=license_config) @@ -100,15 +157,12 @@ class DRMConfig: **license_kwargs ) -> "DRMConfig": """ - Helper to create PlayReady DRM configuration. + Create a PlayReady DRM configuration. Args: - server_url: PlayReady license server URL - priority: Priority (default: 1) - **license_kwargs: Additional LicenseConfig parameters - - Returns: - DRMConfig instance for PlayReady + server_url: PlayReady license server URL. + priority: Priority (default: 1). + **license_kwargs: Additional LicenseConfig parameters. """ license_config = LicenseConfig(server_url=server_url, **license_kwargs) return cls(system=DRMSystem.PLAYREADY, priority=priority, license=license_config) @@ -117,19 +171,16 @@ class DRMConfig: def create_clearkey( cls, keyids: dict[str, str], - priority: int = 0, + priority: int = 1, **license_kwargs ) -> "DRMConfig": """ - Helper to create ClearKey DRM configuration. + Create a ClearKey DRM configuration. Args: - keyids: Mapping of Key IDs to Keys (hex strings) - priority: Priority (default: 0) - **license_kwargs: Additional LicenseConfig parameters - - Returns: - DRMConfig instance for ClearKey + keyids: Mapping of Key IDs to Keys (hex strings, 32 chars each). + priority: Priority (default: 1). + **license_kwargs: Additional LicenseConfig parameters. """ license_config = LicenseConfig(keyids=keyids, **license_kwargs) return cls(system=DRMSystem.CLEARKEY, priority=priority, license=license_config) @@ -143,16 +194,13 @@ class DRMConfig: **license_kwargs ) -> "DRMConfig": """ - Helper to create FairPlay DRM configuration. + Create a FairPlay DRM configuration. Args: - server_url: FairPlay license server URL (skd://) - server_certificate: Base64-encoded FairPlay certificate - priority: Priority (default: 1) - **license_kwargs: Additional LicenseConfig parameters - - Returns: - DRMConfig instance for FairPlay + server_url: FairPlay license server URL (skd://). + server_certificate: Base64-encoded FairPlay certificate. + priority: Priority (default: 1). + **license_kwargs: Additional LicenseConfig parameters. """ license_config = LicenseConfig( server_url=server_url, @@ -162,7 +210,6 @@ class DRMConfig: return cls(system=DRMSystem.FAIRPLAY, priority=priority, license=license_config) def __repr__(self) -> str: - """Return detailed representation.""" has_license = "with license" if self.license else "no license" return ( f"" diff --git a/lib/streaming_providers/base/models/drm/license_config.py b/lib/streaming_providers/base/models/drm/license_config.py index e19ce1b..53a3cae 100644 --- a/lib/streaming_providers/base/models/drm/license_config.py +++ b/lib/streaming_providers/base/models/drm/license_config.py @@ -38,7 +38,7 @@ class LicenseUnwrapperParams: non-standard license server responses. Attributes: - path_data: JSON/XML path to license data (e.g., "license.data") + path_data: JSON/XML path to license data (e.g., "licenseresponse/data") path_data_traverse: Whether to traverse nested structures for data path_hdcp_res: JSON/XML path to HDCP resolution restriction path_hdcp_res_traverse: Whether to traverse for HDCP resolution @@ -77,17 +77,37 @@ class LicenseConfig: including server URLs, certificates, request customization, and response processing. + All fields map directly to ISA (inputstream.adaptive) license parameters. + The to_dict() output can be used as-is inside the inputstream.adaptive.drm + JSON property — no further transformation is required. + Attributes: - server_url: License server URL - server_certificate: Base64-encoded server certificate (for FairPlay, etc.) - use_http_get_request: Use GET instead of POST for license requests - req_headers: Custom HTTP headers as JSON string - req_params: URL query parameters as JSON string - req_data: Base64-encoded custom request body data - wrapper: Request body wrapper type - unwrapper: Response unwrapper type - unwrapper_params: Parameters for response unwrapping - keyids: ClearKey key mappings (KID -> Key in hex) + server_url: License server URL. For Widevine, supports placeholders + to inject the DRM challenge: {CHA-B64U}, {CHA-MD5}. + For ClearKey, also accepts a URI "data" scheme: + "data:application/json;base64," + server_certificate: Base64-encoded server certificate + (Widevine, FairPlay). + use_http_get_request: Force HTTP GET for the license request instead + of the default POST (Widevine, PlayReady, Wiseplay only). + req_headers: Custom HTTP headers as a URL-encoded string. + Format: "Header1=Value1&Header2=Value2" where values are + URL-encoded (use urllib.parse.urlencode() or quote_plus()). + Example: "Content-Type=application%2Foctet-stream&User-Agent=Mozilla%2F5.0" + req_params: Path extension or parameters appended to the license URL. + Example: "/one/two/three-path" + req_data: Base64-encoded custom request body template. + Supports ISA placeholders: {CHA-RAW}, {CHA-B64}, {CHA-B64U}, + {CHA-DEC}, {SID-RAW}, {SID-B64}, {SID-B64U}, {KID-UUID}, + {KID-HEX}, {PSSH-B64}, {PSSH-B64U}. + wrapper: Request body wrapper flags, comma-separated: + "base64" | "urlenc" | "none" + unwrapper: Response unwrapper flags, comma-separated: + "auto" | "base64" | "json" | "xml" | "none" + unwrapper_params: Parameters for JSON/XML response unwrapping + (required when unwrapper includes "json" or "xml"). + keyids: ClearKey only. Map of KID -> Key pairs in hex format + (32 hex chars each). """ server_url: Optional[str] = None @@ -104,7 +124,6 @@ class LicenseConfig: def __post_init__(self): """Normalize key IDs in keyids mapping.""" if self.keyids: - # Normalize all KIDs and keys to lowercase hex normalized_keyids = {} for kid, key in self.keyids.items(): try: @@ -112,7 +131,6 @@ class LicenseConfig: norm_key = key.lower().replace("-", "") normalized_keyids[norm_kid] = norm_key except Exception: - # Skip invalid entries pass self.keyids = normalized_keyids @@ -123,7 +141,6 @@ class LicenseConfig: Raises: LicenseConfigError: If configuration is invalid """ - # Validate server_certificate if present if self.server_certificate: try: safe_base64_decode(self.server_certificate) @@ -132,7 +149,6 @@ class LicenseConfig: f"server_certificate must be valid base64: {e}" ) from e - # Validate req_data if present if self.req_data: try: safe_base64_decode(self.req_data) @@ -141,7 +157,14 @@ class LicenseConfig: f"req_data must be valid base64: {e}" ) from e - # Validate keyids format (for ClearKey) + # req_headers must be a URL-encoded string, NOT a JSON dict + if self.req_headers and self.req_headers.strip().startswith("{"): + raise LicenseConfigError( + "req_headers must be a URL-encoded string " + "(e.g. 'Content-Type=application%2Foctet-stream'), not a JSON dict. " + "Use urllib.parse.urlencode() to encode headers." + ) + if self.keyids: for kid, key in self.keyids.items(): if len(kid) != 32: @@ -165,7 +188,8 @@ class LicenseConfig: Helper to create LicenseConfig with base64-encoded req_data. Args: - req_data_template: Plain text request data template + req_data_template: Plain text request data template (may contain + ISA placeholders like {CHA-B64}, {SID-RAW}, {KID-HEX}, etc.) **kwargs: Other LicenseConfig parameters Returns: @@ -187,10 +211,13 @@ class LicenseConfig: def to_dict(self) -> dict: """ - Convert to dictionary, excluding None/empty values. + Convert to dictionary ready for the inputstream.adaptive.drm JSON payload. + + Output maps directly to ISA license parameters — no further + transformation is needed before json.dumps(). Returns: - Dictionary representation + Dictionary with only non-empty/non-False values included. """ result = {}