Add discovery

This commit is contained in:
Nirvana
2026-03-10 19:38:14 +01:00
parent 749233bee2
commit aef7ba2760
2 changed files with 141 additions and 67 deletions
@@ -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"<DRMConfig({self.system.name}, priority={self.priority}, {has_license})>"
@@ -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,<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 = {}