Files
script.service.ultimate/lib/streaming_providers/base/drm_operations.py
T

801 lines
35 KiB
Python
Raw Normal View History

2025-12-23 16:51:49 +01:00
# ============================================================================
# streaming_providers/base/drm_operations.py
"""
2026-01-16 12:24:41 +01:00
DRM-related operations with caching and optimized PSSH extraction.
2026-01-26 16:32:30 +01:00
Two-phase plugin processing: GENERIC plugins first, then system-specific.
2026-02-26 11:22:09 +01:00
Caching strategy:
- All DRM configs are cached uniformly regardless of DRM system type.
- Exception: if ClearKey configs have FULL key coverage, only the ClearKey
configs are cached and returned (no need to involve upstream license servers).
- If ClearKey coverage is PARTIAL, all configs (ClearKey + others) are cached
and returned so upstream can decide which system to use.
- Unencrypted streams (DRMSystem.NONE) are never cached.
2025-12-23 16:51:49 +01:00
"""
2026-01-06 16:41:03 +01:00
2026-01-16 12:24:41 +01:00
import time
from threading import Lock
from typing import Dict, List, Optional, Tuple
2026-01-06 16:41:03 +01:00
2025-12-23 16:51:49 +01:00
from .drm import DRMPluginManager
2026-01-26 16:32:30 +01:00
from .models import DRMSystem, DRMConfig
2025-12-23 16:51:49 +01:00
from .utils.logger import logger
2026-01-16 12:24:41 +01:00
class PSSHCache:
"""Thread-safe cache for PSSH data"""
def __init__(self, ttl_seconds: int = 3600):
self.cache: Dict[str, Tuple[List, float]] = {}
self.ttl = ttl_seconds
self.lock = Lock()
def get(self, key: str) -> Optional[List]:
"""Get cached PSSH data if not expired"""
with self.lock:
if key in self.cache:
pssh_list, timestamp = self.cache[key]
if time.time() - timestamp < self.ttl:
logger.debug(f"Cache HIT for {key}")
return pssh_list
else:
logger.debug(f"Cache EXPIRED for {key}")
del self.cache[key]
return None
def set(self, key: str, pssh_list: List):
"""Cache PSSH data"""
with self.lock:
self.cache[key] = (pssh_list, time.time())
logger.debug(f"Cache SET for {key}")
def clear(self):
"""Clear all cache entries"""
with self.lock:
self.cache.clear()
logger.debug("Cache CLEARED")
2026-01-23 14:48:38 +01:00
class DRMConfigCache:
2026-02-26 11:22:09 +01:00
"""
Thread-safe cache for DRM configurations.
Caches all DRM config types uniformly. The caller is responsible for
deciding what to store (full ClearKey-only set vs. mixed set).
Simple TTL expiry — no stale-while-revalidate complexity.
"""
2026-01-23 14:48:38 +01:00
2026-02-14 18:40:17 +01:00
def __init__(self, ttl_seconds: int = 3600):
self.cache: Dict[str, Tuple[List, float]] = {}
self.ttl = ttl_seconds
2026-01-23 14:48:38 +01:00
self.lock = Lock()
2026-02-26 11:22:09 +01:00
def get(self, key: str) -> Optional[List]:
2026-02-15 20:18:40 +01:00
"""
2026-02-26 11:22:09 +01:00
Get cached DRM configs if not expired.
2026-02-15 20:18:40 +01:00
Returns:
2026-02-26 11:22:09 +01:00
List of DRMConfig if found and within TTL, None otherwise.
2026-02-15 20:18:40 +01:00
"""
2026-01-23 14:48:38 +01:00
with self.lock:
if key in self.cache:
2026-02-14 18:40:17 +01:00
drm_configs, timestamp = self.cache[key]
2026-02-26 11:22:09 +01:00
if time.time() - timestamp < self.ttl:
2026-02-14 18:40:17 +01:00
logger.debug(f"DRM Config Cache HIT for {key}")
2026-02-26 11:22:09 +01:00
return drm_configs
2026-02-14 18:40:17 +01:00
else:
logger.debug(f"DRM Config Cache EXPIRED for {key}")
del self.cache[key]
2026-01-23 14:48:38 +01:00
return None
def set(self, key: str, drm_configs: List):
2026-02-26 11:22:09 +01:00
"""Cache DRM configs"""
2026-01-23 14:48:38 +01:00
with self.lock:
2026-02-14 18:40:17 +01:00
self.cache[key] = (drm_configs, time.time())
2026-01-23 14:48:38 +01:00
logger.debug(f"DRM Config Cache SET for {key}")
def clear(self):
"""Clear all cache entries"""
with self.lock:
self.cache.clear()
logger.debug("DRM Config Cache CLEARED")
2025-12-23 16:51:49 +01:00
class DRMOperations:
"""Handles all DRM-related operations."""
2026-01-16 12:24:41 +01:00
def __init__(self, registry, cache_ttl: int = 3600):
2025-12-23 16:51:49 +01:00
self.registry = registry
self.drm_plugin_manager = DRMPluginManager()
2026-01-16 12:24:41 +01:00
self.pssh_cache = PSSHCache(ttl_seconds=cache_ttl)
2026-02-14 18:40:17 +01:00
self.drm_config_cache = DRMConfigCache(ttl_seconds=cache_ttl)
2026-01-26 16:32:30 +01:00
logger.debug("DRMOperations: Initialized with two-phase plugin processing")
2025-12-23 16:51:49 +01:00
2026-01-28 12:26:42 +01:00
@staticmethod
def _is_manifest_encrypted(manifest_content: str) -> bool:
"""
Check if manifest contains any DRM/encryption markers.
Returns True if encrypted, False if unencrypted.
2026-01-30 10:23:14 +01:00
2026-02-13 17:25:46 +01:00
Uses the new DRM models for consistent detection.
2026-01-28 12:26:42 +01:00
"""
2026-01-30 10:23:14 +01:00
import re
2026-02-13 17:25:46 +01:00
from ..base.models.drm import DRMSystem
2026-01-30 10:23:14 +01:00
2026-02-13 17:25:46 +01:00
# First, try to extract PSSH data using ManifestParser
2026-02-14 09:11:05 +01:00
from .utils.drm_extractor import DRMExtractor
pssh_list = DRMExtractor._extract_from_manifest_content(manifest_content)
2026-01-30 10:23:14 +01:00
2026-02-13 17:25:46 +01:00
if pssh_list:
# If we got PSSHData objects with valid DRM systems, it's encrypted!
for pssh in pssh_list:
if pssh.drm_system and pssh.drm_system != DRMSystem.NONE:
logger.debug(f"Detected DRM system: {pssh.drm_system.value} from PSSH data")
return True
# Fallback: Check for ContentProtection elements with known DRM UUIDs
2026-01-30 10:23:14 +01:00
cp_pattern = re.compile(
r'<ContentProtection[^>]*schemeIdUri="urn:uuid:([^"]+)"[^>]*>',
re.IGNORECASE
)
for match in cp_pattern.finditer(manifest_content):
uuid = match.group(1).lower()
2026-02-13 17:25:46 +01:00
drm_system = DRMSystem.from_uuid(uuid)
if drm_system:
logger.debug(f"Detected DRM system: {drm_system.value} (UUID: {uuid})")
2026-01-30 10:23:14 +01:00
return True
# Also check for cenc:default_KID (indicates encryption even without DRM system)
if re.search(r'(?:cenc:)?default_KID\s*=', manifest_content, re.IGNORECASE):
logger.debug("Detected cenc:default_KID attribute (encrypted)")
return True
2026-01-28 12:26:42 +01:00
2026-02-13 17:25:46 +01:00
# Check for PSSH boxes directly
2026-01-30 10:23:14 +01:00
if re.search(r'<(?:cenc:)?pssh[^>]*>', manifest_content, re.IGNORECASE):
logger.debug("Detected PSSH box (encrypted)")
return True
2026-01-28 12:26:42 +01:00
2026-01-30 10:23:14 +01:00
logger.debug("No DRM/encryption markers found (unencrypted)")
return False
2026-01-28 12:26:42 +01:00
2026-02-11 16:59:41 +01:00
@staticmethod
2026-02-26 11:22:09 +01:00
def _check_clearkey_coverage(
2026-02-11 16:59:41 +01:00
drm_configs: List[DRMConfig],
pssh_data_list: List
2026-02-26 11:22:09 +01:00
) -> Tuple[List[DRMConfig], bool]:
2026-02-11 16:59:41 +01:00
"""
2026-02-26 11:22:09 +01:00
Validate ClearKey configs against required KIDs from PSSH data and
determine coverage level.
Args:
drm_configs: List of DRM configs to check
pssh_data_list: PSSH data containing required KIDs
2026-02-11 16:59:41 +01:00
2026-02-26 11:22:09 +01:00
Returns:
Tuple of (validated_configs, has_full_coverage) where:
- validated_configs: configs with invalid ClearKey entries removed.
If ALL ClearKey configs are invalid, returns empty list.
- has_full_coverage: True if at least one ClearKey config covers
ALL required KIDs. False if coverage is partial or zero.
Notes:
- Non-ClearKey configs are always passed through unchanged.
- A ClearKey config is considered valid if it covers at least one
required KID (partial coverage is acceptable, but will not set
has_full_coverage=True).
- has_full_coverage=True means the caller may safely discard all
non-ClearKey configs and rely solely on ClearKey decryption.
2026-02-11 16:59:41 +01:00
"""
if not pssh_data_list:
logger.warning("No PSSH data available for ClearKey validation")
2026-02-26 11:22:09 +01:00
return drm_configs, False
2026-02-11 16:59:41 +01:00
2026-02-26 11:22:09 +01:00
# Extract all required key_ids from PSSH (normalized)
2026-02-11 16:59:41 +01:00
required_key_ids = set()
for pssh_data in pssh_data_list:
if pssh_data.key_ids:
2026-02-26 11:22:09 +01:00
required_key_ids.update(
kid.lower().replace("-", "") for kid in pssh_data.key_ids
)
2026-02-11 16:59:41 +01:00
if not required_key_ids:
logger.warning("No key_ids found in PSSH data")
2026-02-26 11:22:09 +01:00
return drm_configs, False
2026-02-11 16:59:41 +01:00
validated_configs = []
has_valid_clearkey = False
2026-02-26 11:22:09 +01:00
has_full_coverage = False
2026-02-11 16:59:41 +01:00
for config in drm_configs:
if config.system != DRMSystem.CLEARKEY:
validated_configs.append(config)
continue
# Validate ClearKey config
if not config.license or not config.license.keyids:
2026-02-26 11:22:09 +01:00
logger.warning("ClearKey config missing license.keyids — skipping")
2026-02-11 16:59:41 +01:00
continue
2026-02-26 11:22:09 +01:00
# Normalize provided KIDs
provided_kids = {
kid.lower().replace("-", "")
for kid in config.license.keyids.keys()
}
2026-02-11 16:59:41 +01:00
2026-02-26 11:22:09 +01:00
# Find intersection
2026-02-11 16:59:41 +01:00
valid_keys = required_key_ids & provided_kids
if valid_keys:
has_valid_clearkey = True
2026-02-26 11:22:09 +01:00
validated_configs.append(config)
if valid_keys == required_key_ids:
has_full_coverage = True
2026-02-11 16:59:41 +01:00
logger.info(
2026-02-26 11:22:09 +01:00
f"ClearKey config: full coverage — "
f"all {len(required_key_ids)} required KIDs present"
2026-02-11 16:59:41 +01:00
)
else:
logger.info(
2026-02-26 11:22:09 +01:00
f"ClearKey config: partial coverage — "
f"{len(valid_keys)}/{len(required_key_ids)} KIDs present "
2026-02-11 16:59:41 +01:00
f"(may limit available resolutions/tracks)"
)
else:
logger.error(
2026-02-26 11:22:09 +01:00
f"ClearKey config INVALID: none of the provided KIDs match "
f"the {len(required_key_ids)} required KIDs — discarding config"
2026-02-11 16:59:41 +01:00
)
2026-02-26 11:22:09 +01:00
# If all ClearKey configs were invalid, return empty list so the caller
# knows the stream is encrypted but we have no usable keys.
2026-02-11 16:59:41 +01:00
if any(c.system == DRMSystem.CLEARKEY for c in drm_configs) and not has_valid_clearkey:
logger.error(
2026-02-26 11:22:09 +01:00
"All ClearKey configs failed validation "
"(stream is encrypted but no valid keys available) — returning empty list"
2026-02-11 16:59:41 +01:00
)
2026-02-26 11:22:09 +01:00
return [], False
return validated_configs, has_full_coverage
@staticmethod
def _select_configs_for_cache_and_return(
configs: List[DRMConfig],
has_full_clearkey_coverage: bool
) -> List[DRMConfig]:
"""
Apply the caching/return selection rule:
2026-02-11 16:59:41 +01:00
2026-02-26 11:22:09 +01:00
- Full ClearKey coverage → return only ClearKey configs (discard others)
- Partial coverage or none → return all configs as-is
Args:
configs: Validated DRM configs
has_full_clearkey_coverage: Result of _check_clearkey_coverage
Returns:
The list that should be both cached and returned upstream.
"""
if has_full_clearkey_coverage:
clearkey_only = [c for c in configs if c.system == DRMSystem.CLEARKEY]
logger.info(
f"Full ClearKey coverage: returning {len(clearkey_only)} ClearKey config(s) only "
f"(discarding {len(configs) - len(clearkey_only)} other config(s))"
)
return clearkey_only
return configs
2026-02-11 16:59:41 +01:00
2026-03-03 13:45:47 +01:00
def get_content_drm_configs(self, provider_name: str, channel_id: str, **kwargs) -> List:
2026-01-26 16:32:30 +01:00
"""
Get DRM configurations for a channel with two-phase plugin processing.
2026-03-26 16:24:12 +01:00
Phase 0: Fetch manifest once, short-circuit if stream is unencrypted.
manifest_url, manifest_headers, and manifest_content are resolved
here and threaded through to all subsequent phases — no phase ever
calls get_manifest_with_headers() again unless Step 0a failed.
2026-02-26 11:22:09 +01:00
Phase 1: GENERIC plugins (pre-provider) — can generate configs from PSSH.
If Phase 1 produces configs:
- Full ClearKey coverage → cache ClearKey-only, return immediately.
- Partial ClearKey → fall through to Phase 2 for a better result.
- No ClearKey / invalid → fall through to Phase 2.
Phase 2: Provider DRM configs + system-specific plugin processing.
Apply coverage check, cache, and return.
Caching:
All resulting configs are cached uniformly with a simple TTL.
On cache hit the result is returned directly with no re-validation.
2026-01-26 16:32:30 +01:00
"""
2026-01-23 14:48:38 +01:00
cache_key = f"{provider_name}:{channel_id}"
2026-02-26 11:22:09 +01:00
manifest_content = None # stored to avoid redundant fetches
2026-03-26 16:24:12 +01:00
manifest_url = None # resolved once in Step 0a and reused throughout
manifest_headers = None # resolved once in Step 0a and reused throughout
2026-01-28 12:26:42 +01:00
2026-02-26 11:22:09 +01:00
# ------------------------------------------------------------------
# Step 0: Resolve provider and inject proxy_config into kwargs
# ------------------------------------------------------------------
2026-01-28 12:26:42 +01:00
provider = self.registry.get_provider(provider_name)
if not provider:
raise ValueError(f"Provider '{provider_name}' not found or disabled")
2026-02-25 10:02:40 +01:00
if "proxy_config" not in kwargs:
http_mgr = getattr(provider, "http_manager", None)
if http_mgr is not None:
provider_proxy = getattr(getattr(http_mgr, "config", None), "proxy_config", None)
if provider_proxy is not None:
kwargs["proxy_config"] = provider_proxy
logger.debug(
f"DRMOperations: Injected proxy_config from provider "
f"'{provider_name}' into plugin kwargs "
f"({provider_proxy.host}:{provider_proxy.port})"
)
2026-02-26 11:22:09 +01:00
# ------------------------------------------------------------------
2026-03-26 16:24:12 +01:00
# Step 0a: Fetch manifest once and short-circuit for unencrypted streams.
# manifest_url, manifest_headers, and manifest_content are stored here
# so that no later phase needs to call get_manifest_with_headers() again.
2026-02-26 11:22:09 +01:00
# ------------------------------------------------------------------
2026-03-26 16:24:12 +01:00
manifest_url, manifest_headers = provider.get_manifest_with_headers(channel_id, **kwargs)
2026-01-28 12:26:42 +01:00
if manifest_url and manifest_url.startswith(('http://', 'https://')):
try:
from .network import HTTPManager
http = provider.http_manager if hasattr(provider, 'http_manager') else HTTPManager()
2026-03-26 16:24:12 +01:00
response = http.get(manifest_url, headers=manifest_headers, timeout=10, operation="api")
2026-01-28 12:26:42 +01:00
response.raise_for_status()
manifest_content = response.text
if not self._is_manifest_encrypted(manifest_content):
logger.info(f"Stream '{channel_id}' is unencrypted (no DRM in manifest)")
return [DRMConfig(system=DRMSystem.NONE, priority=0)]
logger.debug(f"Stream '{channel_id}' is encrypted, proceeding with DRM processing")
except Exception as e:
logger.warning(f"Failed to check manifest encryption for '{channel_id}': {e}")
manifest_content = None
2026-01-23 14:48:38 +01:00
2026-02-26 11:22:09 +01:00
# ------------------------------------------------------------------
# Step 1: DRM config cache — simple TTL, no stale revalidation
# ------------------------------------------------------------------
cached_configs = self.drm_config_cache.get(cache_key)
if cached_configs is not None:
logger.info(f"Using cached DRM configs for '{cache_key}'")
return cached_configs
2026-01-23 14:48:38 +01:00
2026-02-26 11:22:09 +01:00
# ------------------------------------------------------------------
# Step 2: PHASE 1 — Try GENERIC plugins first (if registered)
2026-03-26 16:24:12 +01:00
# Pass the already-resolved manifest_url/headers so _try_generic_plugins
# never needs to call get_manifest_with_headers() itself.
2026-02-26 11:22:09 +01:00
# ------------------------------------------------------------------
pssh_data_list = None # reused across phases to avoid re-fetching
2026-02-11 16:59:41 +01:00
2026-01-26 16:32:30 +01:00
if DRMSystem.GENERIC in self.drm_plugin_manager.plugins:
logger.debug(f"Phase 1: Attempting GENERIC plugin processing for '{channel_id}'")
2026-02-11 16:59:41 +01:00
generic_configs, pssh_data_list = self._try_generic_plugins(
2026-03-26 16:24:12 +01:00
provider_name, channel_id, cache_key,
manifest_content=manifest_content,
manifest_url=manifest_url,
manifest_headers=manifest_headers,
**kwargs
2026-01-28 12:26:42 +01:00
)
2026-01-26 16:32:30 +01:00
2026-02-26 11:22:09 +01:00
# Only proceed with generic configs if they contain real DRM systems
if generic_configs and any(c.system != DRMSystem.NONE for c in generic_configs):
2026-02-10 20:37:59 +01:00
logger.info(f"Phase 1: Generated {len(generic_configs)} configs via GENERIC plugin")
2026-01-26 16:32:30 +01:00
2026-02-26 11:22:09 +01:00
if pssh_data_list:
validated, has_full_coverage = self._check_clearkey_coverage(
generic_configs, pssh_data_list
)
else:
validated = generic_configs
has_full_coverage = False
if not validated:
# All ClearKey configs were invalid — fall through to Phase 2
logger.warning(
f"Phase 1: GENERIC plugins produced no valid ClearKey configs for "
f"'{channel_id}', falling back to provider DRM"
)
elif has_full_coverage:
# Best case: we have all the keys we need — no need for Phase 2
result = self._select_configs_for_cache_and_return(validated, has_full_coverage)
self.drm_config_cache.set(cache_key, result)
return result
2026-02-11 16:59:41 +01:00
else:
2026-02-26 11:22:09 +01:00
# Partial ClearKey coverage — fall through to Phase 2 to see if
# the provider can give us a better result. Keep validated and
# pssh_data_list in scope so Phase 2 can merge if needed.
logger.info(
f"Phase 1: Partial ClearKey coverage for '{channel_id}', "
f"continuing to Phase 2 for potentially better coverage"
)
2026-01-26 16:32:30 +01:00
else:
2026-02-10 20:37:59 +01:00
logger.debug(f"Phase 1: No actual DRM configs from GENERIC plugin, proceeding to provider")
2026-02-26 11:22:09 +01:00
generic_configs = None # ensure clean state for Phase 2
else:
generic_configs = None
2026-01-26 16:32:30 +01:00
2026-02-26 11:22:09 +01:00
# ------------------------------------------------------------------
# Step 3: PHASE 2 — Get provider's DRM configs
# ------------------------------------------------------------------
2026-03-01 19:58:59 +01:00
provider_drm_configs = provider.get_drm(content_id=channel_id, **kwargs)
2025-12-23 16:51:49 +01:00
2026-02-26 11:22:09 +01:00
# Secondary unencrypted check (in case manifest fetch failed earlier)
if not provider_drm_configs:
2026-01-28 12:26:42 +01:00
logger.info(f"Stream '{channel_id}' is unencrypted (no DRM configs from provider)")
return [DRMConfig(system=DRMSystem.NONE, priority=0)]
2026-02-26 11:22:09 +01:00
# ------------------------------------------------------------------
2026-03-26 16:24:12 +01:00
# Step 4: PHASE 2 — Extract PSSH if needed for system-specific plugins.
2026-02-26 11:22:09 +01:00
# Reuse pssh_data_list from Phase 1 if already extracted.
2026-03-26 16:24:12 +01:00
# If a fresh fetch is needed, reuse the manifest_url/headers resolved
# in Step 0a — never call get_manifest_with_headers() again.
2026-02-26 11:22:09 +01:00
# ------------------------------------------------------------------
if pssh_data_list is None and self.drm_plugin_manager.has_system_specific_plugins():
if self._needs_pssh_extraction(provider_drm_configs):
2026-01-16 18:27:08 +01:00
pssh_data_list = self.pssh_cache.get(cache_key)
if pssh_data_list is None:
2026-01-28 12:26:42 +01:00
if manifest_content:
logger.debug(f"Phase 2: Using cached manifest_content for PSSH extraction")
2026-02-14 09:11:05 +01:00
from .utils.drm_extractor import DRMExtractor
pssh_data_list = DRMExtractor._extract_from_manifest_content(manifest_content)
2026-01-16 12:24:41 +01:00
if pssh_data_list:
self.pssh_cache.set(cache_key, pssh_data_list)
2026-01-28 12:26:42 +01:00
if not pssh_data_list:
2026-03-26 16:24:12 +01:00
# manifest_url/headers already resolved in Step 0a — reuse them
2026-01-28 12:26:42 +01:00
if manifest_url:
2026-03-26 16:24:12 +01:00
logger.debug(f"Phase 2: PSSH cache miss for {cache_key}, extracting from manifest")
pssh_data_list = self._extract_pssh_from_manifest(
2026-03-29 21:46:57 +02:00
manifest_url, manifest_headers, provider_name,
channel_id=channel_id,
2026-03-26 16:24:12 +01:00
)
2026-01-28 12:26:42 +01:00
if pssh_data_list:
self.pssh_cache.set(cache_key, pssh_data_list)
2026-03-26 16:24:12 +01:00
else:
logger.debug(f"Phase 2: No manifest URL available for PSSH extraction")
2026-01-16 18:27:08 +01:00
else:
2026-01-26 16:32:30 +01:00
logger.debug(f"Phase 2: Using cached PSSH for {cache_key}")
2025-12-23 16:51:49 +01:00
2026-02-26 11:22:09 +01:00
# ------------------------------------------------------------------
2026-02-26 14:35:00 +01:00
# Step 5: PHASE 2 — Process through system-specific plugins.
# Snapshot provider_drm_configs first so we can reinstate any configs
# that a plugin replaced (e.g. PlayReady → ClearKey) if coverage turns
# out to be only partial.
2026-02-26 11:22:09 +01:00
# ------------------------------------------------------------------
2026-02-26 14:35:00 +01:00
provider_configs_snapshot = list(provider_drm_configs)
2026-04-27 17:26:35 +02:00
sorted_systems = sorted(
{c.system for c in provider_drm_configs},
key=lambda s: min(c.priority for c in provider_drm_configs if c.system == s)
2025-12-23 16:51:49 +01:00
)
2026-04-27 17:26:35 +02:00
remaining = list(provider_drm_configs)
for drm_system in sorted_systems:
system_configs = [c for c in remaining if c.system == drm_system]
if not system_configs:
continue
batch_result = self.drm_plugin_manager.process_system_specific_plugins(
system_configs, pssh_data_list or [], **kwargs
)
# Replace this system's configs with plugin output
remaining = [c for c in remaining if c.system != drm_system] + batch_result
# Check coverage after each system
if pssh_data_list and any(c.system == DRMSystem.CLEARKEY for c in remaining):
remaining, has_full_coverage = self._check_clearkey_coverage(remaining, pssh_data_list)
if has_full_coverage:
logger.info(f"Full ClearKey coverage after {drm_system.value} — stopping early")
break
processed = remaining
2026-02-26 11:22:09 +01:00
# ------------------------------------------------------------------
# Step 6: Merge Phase 1 partial ClearKey result (if any) with Phase 2
2026-02-26 14:35:00 +01:00
# output. Phase 1 configs whose DRM system is not already represented
# in Phase 2 are appended so upstream has the full picture.
# De-duplication is by DRM system to avoid two ClearKey entries.
2026-02-26 11:22:09 +01:00
# ------------------------------------------------------------------
if generic_configs:
phase2_systems = {c.system for c in processed}
extra = [c for c in generic_configs if c.system not in phase2_systems]
if extra:
logger.info(
f"Merging {len(extra)} Phase 1 config(s) into Phase 2 results"
)
processed = processed + extra
# ------------------------------------------------------------------
2026-02-26 14:35:00 +01:00
# Step 7: Validate ClearKey configs and determine coverage.
# If coverage is only partial, reinstate any original provider configs
# whose DRM system was replaced by a plugin but is no longer present
# in the processed list. This ensures upstream always has a complete
# fallback (e.g. PlayReady) alongside a partial ClearKey config.
2026-02-26 11:22:09 +01:00
# ------------------------------------------------------------------
if pssh_data_list and any(c.system == DRMSystem.CLEARKEY for c in processed):
processed, has_full_coverage = self._check_clearkey_coverage(processed, pssh_data_list)
else:
has_full_coverage = False
2026-02-11 16:59:41 +01:00
2026-02-26 14:35:00 +01:00
if not has_full_coverage:
# Find original provider systems that are no longer in processed
# (a plugin replaced them) and reinstate them.
processed_systems = {c.system for c in processed}
reinstated = [
c for c in provider_configs_snapshot
if c.system not in processed_systems
]
if reinstated:
logger.info(
f"Partial/no ClearKey coverage: reinstating "
f"{len(reinstated)} replaced provider config(s): "
f"{[c.system.name for c in reinstated]}"
)
processed = processed + reinstated
2026-02-11 16:59:41 +01:00
if not processed:
logger.error(
f"DRM processing failed for '{channel_id}': "
f"stream is encrypted but no valid DRM configs available"
)
return []
2026-02-26 11:22:09 +01:00
# ------------------------------------------------------------------
# Step 8: Apply selection rule, cache, and return
# ------------------------------------------------------------------
result = self._select_configs_for_cache_and_return(processed, has_full_coverage)
self.drm_config_cache.set(cache_key, result)
logger.info(
f"Processed DRM for '{channel_id}': returning {len(result)} config(s) "
f"({'full ClearKey' if has_full_coverage else 'mixed/partial'})"
)
return result
2025-12-23 16:51:49 +01:00
2026-01-26 16:32:30 +01:00
def _try_generic_plugins(
self,
provider_name: str,
channel_id: str,
cache_key: str,
2026-01-28 12:26:42 +01:00
manifest_content: Optional[str] = None,
2026-03-26 16:24:12 +01:00
manifest_url: Optional[str] = None,
manifest_headers: Optional[dict] = None,
2026-01-26 16:32:30 +01:00
**kwargs
2026-02-11 16:59:41 +01:00
) -> Tuple[Optional[List[DRMConfig]], Optional[List]]:
2026-01-26 16:32:30 +01:00
"""
Try to generate configs using GENERIC plugins.
2026-02-11 16:59:41 +01:00
Returns (configs, pssh_data_list) tuple. Both can be None.
2026-02-13 22:14:28 +01:00
IMPORTANT: Ensures PSSH data is complete (with KIDs) before calling plugins.
2026-03-26 16:24:12 +01:00
manifest_url and manifest_headers should be passed in from the caller
(already resolved in Step 0a of get_content_drm_configs) to avoid any
additional calls to get_manifest_with_headers(). A lazy fallback fetch
is performed only when both are None (i.e. Step 0a failed entirely).
2026-01-26 16:32:30 +01:00
"""
# Get PSSH data (from cache or manifest)
pssh_data_list = self.pssh_cache.get(cache_key)
if pssh_data_list is None:
2026-01-28 12:26:42 +01:00
# Try to extract from provided manifest_content first
if manifest_content:
logger.debug(f"GENERIC plugin: Using cached manifest_content for '{channel_id}'")
2026-02-14 09:11:05 +01:00
from .utils.drm_extractor import DRMExtractor
pssh_data_list = DRMExtractor._extract_from_manifest_content(manifest_content)
2026-01-28 12:26:42 +01:00
2026-01-26 16:32:30 +01:00
if pssh_data_list:
self.pssh_cache.set(cache_key, pssh_data_list)
2026-03-26 16:24:12 +01:00
# If still no PSSH, use the manifest_url/headers passed in from the caller.
# Only perform a lazy get_manifest_with_headers() call if they were not provided
# (i.e. Step 0a failed entirely).
2026-01-28 12:26:42 +01:00
if not pssh_data_list:
2026-03-26 16:24:12 +01:00
if not manifest_url:
logger.debug(f"GENERIC plugin: No manifest URL provided, fetching for '{provider_name}' / '{channel_id}'")
provider = self.registry.get_provider(provider_name)
if not provider:
logger.warning(f"Provider '{provider_name}' not found for GENERIC plugin")
return None, None
manifest_url, manifest_headers = provider.get_manifest_with_headers(channel_id, **kwargs)
else:
logger.debug(f"GENERIC plugin: Using pre-fetched manifest URL for '{channel_id}'")
2026-01-28 12:26:42 +01:00
if manifest_url:
2026-03-29 21:46:57 +02:00
pssh_data_list = self._extract_pssh_from_manifest(
manifest_url, manifest_headers, provider_name,
channel_id=channel_id,
)
2026-01-28 12:26:42 +01:00
if pssh_data_list:
self.pssh_cache.set(cache_key, pssh_data_list)
2026-01-26 16:32:30 +01:00
if not pssh_data_list:
logger.debug(f"GENERIC plugin: No PSSH data available for '{channel_id}'")
2026-02-11 16:59:41 +01:00
return None, None
2026-01-26 16:32:30 +01:00
2026-02-13 22:14:28 +01:00
# CRITICAL: Check if PSSH is stub (no real data)
if self._has_stub_pssh(pssh_data_list):
logger.warning(
f"GENERIC plugin: PSSH data is incomplete (no KIDs) - extracting from init segment"
)
2026-03-26 16:24:12 +01:00
# Reuse manifest_url/headers already in scope; they are guaranteed to be
# populated at this point (either passed in or fetched in the block above).
if not manifest_url:
logger.error(f"GENERIC plugin: Cannot get manifest URL for init segment extraction")
2026-02-13 22:14:28 +01:00
return None, pssh_data_list
2026-03-29 21:46:57 +02:00
real_pssh = self._extract_pssh_from_manifest(
manifest_url, manifest_headers, provider_name,
channel_id=channel_id,
)
2026-02-13 22:14:28 +01:00
2026-03-26 16:24:12 +01:00
if real_pssh and not self._has_stub_pssh(real_pssh):
logger.info(
f"GENERIC plugin: Successfully extracted complete PSSH with "
f"{sum(len(p.key_ids) for p in real_pssh)} KIDs from init segment"
)
pssh_data_list = real_pssh
self.pssh_cache.set(cache_key, pssh_data_list)
2026-02-13 22:14:28 +01:00
else:
2026-03-26 16:24:12 +01:00
logger.error(
f"GENERIC plugin: Failed to extract complete PSSH - "
f"Kid-Key plugin will not be called"
)
2026-02-13 22:14:28 +01:00
return None, pssh_data_list
# Verify we now have complete PSSH
total_kids = sum(len(p.key_ids) for p in pssh_data_list)
if total_kids == 0:
logger.warning(
f"GENERIC plugin: PSSH has no KIDs even after extraction - "
f"Kid-Key plugin will not be called"
)
return None, pssh_data_list
logger.debug(f"GENERIC plugin: Processing with complete PSSH ({total_kids} KIDs)")
2026-01-26 16:32:30 +01:00
# Create a dummy config to pass to GENERIC plugin
dummy_configs = [DRMConfig(system=DRMSystem.NONE, priority=0)]
# Let GENERIC plugins try to generate configs
generic_configs = self.drm_plugin_manager.process_generic_plugins(
dummy_configs, pssh_data_list, **kwargs
)
2026-02-11 16:59:41 +01:00
return generic_configs if generic_configs else None, pssh_data_list
2026-01-26 16:32:30 +01:00
2026-02-13 22:14:28 +01:00
@staticmethod
def _has_stub_pssh(pssh_data_list: List) -> bool:
"""
Check if PSSH data is incomplete (stub).
Stub PSSH occurs when manifest has ContentProtection tags but no actual PSSH boxes.
Returns True if any PSSH is missing pssh_box or has no key_ids.
"""
if not pssh_data_list:
return True
for pssh in pssh_data_list:
if not pssh.pssh_box or not pssh.key_ids:
return True
return False
2025-12-23 16:51:49 +01:00
def _needs_pssh_extraction(self, drm_configs) -> bool:
2026-01-26 16:32:30 +01:00
"""Check if PSSH extraction is needed for system-specific plugins."""
2025-12-23 16:51:49 +01:00
config_systems = {config.system for config in drm_configs}
2026-01-26 16:32:30 +01:00
# Exclude GENERIC from this check (it's handled separately)
plugin_systems = {
sys for sys in self.drm_plugin_manager.plugins.keys()
if sys != DRMSystem.GENERIC
}
return bool(config_systems & plugin_systems)
2025-12-23 16:51:49 +01:00
2026-03-29 21:46:57 +02:00
def _extract_pssh_from_manifest(
self,
manifest_url: str,
manifest_headers: Dict[str, str],
provider_name: Optional[str] = None,
channel_id: Optional[str] = None,
) -> List:
"""
Extract PSSH data from manifest, falling back to the init segment if
the manifest itself carries only stub ContentProtection entries.
Args:
manifest_url: URL of the manifest to fetch.
manifest_headers: HTTP headers to use when fetching the manifest.
provider_name: Used to resolve the provider's HTTPManager and
segment headers. If None, a plain HTTPManager is used.
channel_id: Passed to provider.get_segment_headers() so providers
that require per-channel auth on segments supply the
correct headers. If None, segment headers default to {}.
"""
2026-02-14 09:11:05 +01:00
from .utils.drm_extractor import DRMExtractor
2026-01-26 19:56:03 +01:00
from .network import HTTPManager
# 1. Validate the URL before attempting the request
if not manifest_url or not manifest_url.startswith(('http://', 'https://')):
logger.error(f"Invalid manifest URL provided: '{manifest_url}'. Cannot extract PSSH.")
return []
2025-12-23 16:51:49 +01:00
try:
2026-03-29 21:46:57 +02:00
# 2. Resolve the correct HTTP manager and segment headers
2026-01-26 19:56:03 +01:00
http = None
2026-03-29 21:46:57 +02:00
segment_headers = {}
2026-01-26 19:19:28 +01:00
if provider_name:
provider = self.registry.get_provider(provider_name)
if provider:
http = provider.http_manager
2026-01-26 19:56:03 +01:00
logger.debug(f"Using configured HTTPManager for provider: {provider_name}")
2026-03-29 21:46:57 +02:00
if channel_id:
segment_headers = provider.get_segment_headers(channel_id)
logger.debug(
f"Resolved segment headers for '{provider_name}/{channel_id}': "
f"{list(segment_headers.keys())}"
)
2026-01-26 19:56:03 +01:00
if not http:
logger.debug("No provider manager found; using default HTTPManager")
2026-01-26 19:19:28 +01:00
http = HTTPManager()
2026-03-29 21:46:57 +02:00
# 3. Perform the manifest request
2026-03-26 16:24:12 +01:00
response = http.get(manifest_url, headers=manifest_headers, timeout=10, operation="api")
2026-01-26 19:56:03 +01:00
response.raise_for_status()
2026-01-16 12:24:41 +01:00
manifest_content = response.text
2026-01-26 19:56:03 +01:00
# 4. Standard PSSH extraction logic
2026-02-14 09:11:05 +01:00
pssh_list = DRMExtractor._extract_from_manifest_content(manifest_content)
2026-01-16 12:24:41 +01:00
# Check if we need segment extraction
needs_segment_extraction = not pssh_list or any(
not p.pssh_box or not p.key_ids for p in pssh_list
2025-12-23 16:51:49 +01:00
)
2026-01-16 12:24:41 +01:00
if needs_segment_extraction:
logger.debug("PSSH incomplete in manifest, extracting from init segment")
2026-02-14 09:11:05 +01:00
from .utils import ManifestParser
2026-01-16 12:24:41 +01:00
init_segment_url = ManifestParser.extract_single_init_segment_url(
manifest_content, manifest_url
)
if init_segment_url:
2026-02-14 09:11:05 +01:00
segment_pssh = DRMExtractor._extract_from_single_segment(
2026-01-26 19:19:28 +01:00
init_segment_url,
2026-03-29 21:46:57 +02:00
[p.system_id for p in pssh_list] if pssh_list else [],
headers=segment_headers,
2026-04-23 11:55:03 +02:00
http_manager=http,
2026-01-16 12:24:41 +01:00
)
if segment_pssh:
2026-02-14 09:11:05 +01:00
return DRMExtractor._merge_pssh_data(pssh_list, segment_pssh)
2026-01-16 12:24:41 +01:00
return pssh_list
2025-12-23 16:51:49 +01:00
except Exception as e:
logger.warning(f"Failed to extract PSSH: {e}")
return []
def list_drm_plugins(self) -> Dict:
"""List registered DRM plugins."""
return self.drm_plugin_manager.list_plugins()
def clear_drm_plugins(self):
"""Clear all DRM plugins."""
self.drm_plugin_manager.clear_plugins()
2026-01-16 12:24:41 +01:00
def clear_pssh_cache(self):
"""Clear PSSH cache."""
self.pssh_cache.clear()
2026-01-23 14:48:38 +01:00
def clear_drm_config_cache(self):
"""Clear DRM config cache."""
self.drm_config_cache.clear()