# ============================================================================ # streaming_providers/base/drm_operations.py """ DRM-related operations with caching and optimized PSSH extraction. Two-phase plugin processing: GENERIC plugins first, then system-specific. """ import time from threading import Lock from typing import Dict, List, Optional, Tuple from .drm import DRMPluginManager from .models import DRMSystem, DRMConfig from .utils.logger import logger 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") class DRMConfigCache: """Thread-safe cache for DRM configurations (ClearKey only)""" 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, allow_expired: bool = False) -> Optional[Tuple[List, bool]]: """ Get cached DRM configs. Args: key: Cache key allow_expired: If True, return expired entries for validation Returns: Tuple of (drm_configs, is_expired) if found, None otherwise. If allow_expired=False and entry is expired, returns None and deletes entry. """ with self.lock: if key in self.cache: drm_configs, timestamp = self.cache[key] is_expired = time.time() - timestamp >= self.ttl if not is_expired: logger.debug(f"DRM Config Cache HIT for {key}") return drm_configs, False elif allow_expired: logger.debug(f"DRM Config Cache EXPIRED for {key} (returned for validation)") return drm_configs, True else: logger.debug(f"DRM Config Cache EXPIRED for {key}") del self.cache[key] return None def set(self, key: str, drm_configs: List): """Cache DRM configs (only if contains ClearKey)""" with self.lock: self.cache[key] = (drm_configs, time.time()) 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") class DRMOperations: """Handles all DRM-related operations.""" def __init__(self, registry, cache_ttl: int = 3600): self.registry = registry self.drm_plugin_manager = DRMPluginManager() self.pssh_cache = PSSHCache(ttl_seconds=cache_ttl) self.drm_config_cache = DRMConfigCache(ttl_seconds=cache_ttl) logger.debug("DRMOperations: Initialized with two-phase plugin processing") @staticmethod def _is_manifest_encrypted(manifest_content: str) -> bool: """ Check if manifest contains any DRM/encryption markers. Returns True if encrypted, False if unencrypted. Uses the new DRM models for consistent detection. """ import re from ..base.models.drm import DRMSystem # First, try to extract PSSH data using ManifestParser from .utils.drm_extractor import DRMExtractor pssh_list = DRMExtractor._extract_from_manifest_content(manifest_content) 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 # But now use DRMSystem.from_uuid() for detection! cp_pattern = re.compile( r']*schemeIdUri="urn:uuid:([^"]+)"[^>]*>', re.IGNORECASE ) for match in cp_pattern.finditer(manifest_content): uuid = match.group(1).lower() # Use DRMSystem.from_uuid() - it handles normalization! drm_system = DRMSystem.from_uuid(uuid) if drm_system: logger.debug(f"Detected DRM system: {drm_system.value} (UUID: {uuid})") 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 # Check for PSSH boxes directly if re.search(r'<(?:cenc:)?pssh[^>]*>', manifest_content, re.IGNORECASE): logger.debug("Detected PSSH box (encrypted)") return True logger.debug("No DRM/encryption markers found (unencrypted)") return False @staticmethod def _validate_clearkey_configs( drm_configs: List[DRMConfig], pssh_data_list: List ) -> List[DRMConfig]: """ Validate that ClearKey configs contain at least one key for required key_ids. Returns configs if at least one valid key exists, empty list if no valid keys. Having a subset of valid keys is acceptable (e.g., only lower resolutions), but having NO valid keys means decryption will fail entirely. """ if not pssh_data_list: logger.warning("No PSSH data available for ClearKey validation") return drm_configs # Extract all required key_ids from PSSH required_key_ids = set() for pssh_data in pssh_data_list: if pssh_data.key_ids: # Normalize key_ids to lowercase without hyphens for comparison required_key_ids.update(kid.lower().replace("-", "") for kid in pssh_data.key_ids) if not required_key_ids: logger.warning("No key_ids found in PSSH data") return drm_configs validated_configs = [] has_valid_clearkey = False 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: logger.warning(f"ClearKey config missing license.keyids") continue # Check if config provides keys for any required key_ids # Normalize provided KIDs to lowercase without hyphens for comparison provided_kids = {kid.lower().replace("-", "") for kid in config.license.keyids.keys()} # Find intersection - keys that are both required and provided valid_keys = required_key_ids & provided_kids if valid_keys: has_valid_clearkey = True if len(valid_keys) == len(required_key_ids): logger.info( f"ClearKey config validated: all {len(required_key_ids)} required keys present" ) else: logger.info( f"ClearKey config validated: {len(valid_keys)}/{len(required_key_ids)} keys present " f"(may limit available resolutions/tracks)" ) validated_configs.append(config) else: logger.error( f"ClearKey config INVALID: no valid keys found. " f"Required {len(required_key_ids)} key_ids but none match provided keys." ) # If we have ClearKey configs but NONE are valid, return empty list # This prevents returning DRMSystem.NONE which would indicate unencrypted stream if any(c.system == DRMSystem.CLEARKEY for c in drm_configs) and not has_valid_clearkey: logger.error( "All ClearKey configs failed validation. Returning empty list " "(stream is encrypted but no valid keys available)." ) return [] return validated_configs def get_channel_drm_configs(self, provider_name: str, channel_id: str, **kwargs) -> List: """ Get DRM configurations for a channel with two-phase plugin processing. Phase 0: Check if stream is encrypted (by examining manifest) Phase 1: GENERIC plugins (pre-provider) - can generate configs from PSSH Phase 2: System-specific plugins (post-provider) - transform provider configs """ cache_key = f"{provider_name}:{channel_id}" manifest_content = None # Store to avoid redundant fetches # Step 0: Get provider instance provider = self.registry.get_provider(provider_name) if not provider: raise ValueError(f"Provider '{provider_name}' not found or disabled") # Step 0a: Fetch manifest and check if it's encrypted manifest_url = provider.get_manifest(channel_id, **kwargs) if manifest_url and manifest_url.startswith(('http://', 'https://')): try: from .network import HTTPManager # Use provider's HTTP manager if available http = provider.http_manager if hasattr(provider, 'http_manager') else HTTPManager() response = http.get(manifest_url, timeout=10, operation="api") response.raise_for_status() manifest_content = response.text # Check if manifest is encrypted 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}") # Continue to provider DRM config on error manifest_content = None # Step 1: Check DRM config cache (ClearKey configs) - allow expired for validation cache_result = self.drm_config_cache.get(cache_key, allow_expired=True) if cache_result is not None: cached_configs, is_expired = cache_result if self._has_clearkey_config(cached_configs): # Only validate if TTL has expired if is_expired: logger.info(f"Found expired DRM configs for '{cache_key}', validating against current KIDs") # Get current PSSH to verify KIDs haven't changed current_pssh = self.pssh_cache.get(cache_key) if current_pssh is None: # Need to fetch PSSH to validate if manifest_content: from .utils.manifest_parser import ManifestParser current_pssh = ManifestParser._extract_from_manifest_content(manifest_content) if current_pssh: self.pssh_cache.set(cache_key, current_pssh) # If still no PSSH or stub, fetch from manifest if not current_pssh or self._has_stub_pssh(current_pssh): logger.debug(f"Expired config validation: PSSH incomplete, extracting from init segment") manifest_url = provider.get_manifest(channel_id, **kwargs) if manifest_url: current_pssh = self._extract_pssh_from_manifest(manifest_url, provider_name) if current_pssh: self.pssh_cache.set(cache_key, current_pssh) # Validate expired configs against current PSSH if current_pssh and not self._has_stub_pssh(current_pssh): if self._validate_cached_clearkey(cached_configs, current_pssh): logger.info( f"Expired configs still valid for all current KIDs - refreshing TTL for '{cache_key}'" ) # Refresh the cache with new TTL self.drm_config_cache.set(cache_key, cached_configs) return cached_configs else: logger.warning( f"Expired configs INVALID: KIDs changed or keys missing - regenerating for '{cache_key}'" ) # Fall through to regenerate else: logger.warning(f"Cannot validate expired configs: PSSH data incomplete - regenerating") # Fall through to regenerate else: # Still within TTL, use cached configs directly without validation logger.info(f"Using cached DRM configs for '{cache_key}' (within TTL)") return cached_configs # Step 2: PHASE 1 - Try GENERIC plugins first (if registered) pssh_data_list = None # Will be populated if PSSH extraction occurs if DRMSystem.GENERIC in self.drm_plugin_manager.plugins: logger.debug(f"Phase 1: Attempting GENERIC plugin processing for '{channel_id}'") generic_configs, pssh_data_list = self._try_generic_plugins( provider_name, channel_id, cache_key, manifest_content, **kwargs ) # Only use generic configs if they contain actual DRM systems (not just NONE) if generic_configs and any(config.system != DRMSystem.NONE for config in generic_configs): logger.info(f"Phase 1: Generated {len(generic_configs)} configs via GENERIC plugin") # Validate ClearKey configs if PSSH data is available if pssh_data_list and self._has_clearkey_config(generic_configs): validated = self._validate_clearkey_configs(generic_configs, pssh_data_list) # If validation returned empty list, it means encrypted stream with no valid keys # Don't cache and don't return - proceed to provider DRM (Phase 2) if not validated: logger.warning( f"Phase 1: GENERIC plugins produced invalid ClearKey configs for '{channel_id}', " f"falling back to provider DRM" ) else: logger.info(f"Phase 1: Validated {len(validated)} ClearKey configs") if self._has_clearkey_config(validated): self.drm_config_cache.set(cache_key, validated) return validated else: # No ClearKey configs to validate, return as-is if self._has_clearkey_config(generic_configs): self.drm_config_cache.set(cache_key, generic_configs) return generic_configs else: logger.debug(f"Phase 1: No actual DRM configs from GENERIC plugin, proceeding to provider") # Step 3: Get provider's DRM configs (PHASE 2 entry point) drm_configs = provider.get_drm(channel_id, **kwargs) # Step 3a: Check for unencrypted streams (provider returned empty configs) # This is a secondary check if manifest check failed or was skipped if not drm_configs: logger.info(f"Stream '{channel_id}' is unencrypted (no DRM configs from provider)") return [DRMConfig(system=DRMSystem.NONE, priority=0)] # Step 4: PHASE 2 - Extract PSSH if needed for system-specific plugins # Reuse pssh_data_list from Phase 1 if already extracted if pssh_data_list is None and drm_configs and self.drm_plugin_manager.has_system_specific_plugins(): if self._needs_pssh_extraction(drm_configs): # Try PSSH cache first pssh_data_list = self.pssh_cache.get(cache_key) if pssh_data_list is None: # Try to extract from manifest_content if we already have it if manifest_content: logger.debug(f"Phase 2: Using cached manifest_content for PSSH extraction") from .utils.drm_extractor import DRMExtractor pssh_data_list = DRMExtractor._extract_from_manifest_content(manifest_content) if pssh_data_list: self.pssh_cache.set(cache_key, pssh_data_list) # If still no PSSH, fetch manifest and extract if not pssh_data_list: logger.debug(f"Phase 2: PSSH cache miss for {cache_key}, fetching manifest") manifest_url = provider.get_manifest(channel_id, **kwargs) if manifest_url: pssh_data_list = self._extract_pssh_from_manifest(manifest_url, provider_name) if pssh_data_list: self.pssh_cache.set(cache_key, pssh_data_list) else: logger.debug(f"Phase 2: Using cached PSSH for {cache_key}") # Step 5: PHASE 2 - Process through system-specific plugins (EXCLUDE GENERIC) processed = self.drm_plugin_manager.process_system_specific_plugins( drm_configs, pssh_data_list if pssh_data_list else [], **kwargs ) # Step 6: Validate ClearKey configs if PSSH data is available if pssh_data_list and self._has_clearkey_config(processed): processed = self._validate_clearkey_configs(processed, pssh_data_list) # If validation returned empty list, return it (encrypted stream with no valid keys) if not processed: logger.error( f"DRM processing failed for '{channel_id}': " f"stream is encrypted but no valid DRM configs available" ) return [] # Step 7: Cache if contains ClearKey config if self._has_clearkey_config(processed): logger.info(f"Caching validated ClearKey DRM configs for '{channel_id}'") self.drm_config_cache.set(cache_key, processed) logger.info(f"Processed DRM for '{channel_id}': {len(processed)} validated configs") return processed def _try_generic_plugins( self, provider_name: str, channel_id: str, cache_key: str, manifest_content: Optional[str] = None, **kwargs ) -> Tuple[Optional[List[DRMConfig]], Optional[List]]: """ Try to generate configs using GENERIC plugins. Returns (configs, pssh_data_list) tuple. Both can be None. IMPORTANT: Ensures PSSH data is complete (with KIDs) before calling plugins. """ # Get PSSH data (from cache or manifest) pssh_data_list = self.pssh_cache.get(cache_key) if pssh_data_list is None: # Try to extract from provided manifest_content first if manifest_content: logger.debug(f"GENERIC plugin: Using cached manifest_content for '{channel_id}'") from .utils.drm_extractor import DRMExtractor pssh_data_list = DRMExtractor._extract_from_manifest_content(manifest_content) if pssh_data_list: self.pssh_cache.set(cache_key, pssh_data_list) # If still no PSSH, fetch manifest if not pssh_data_list: logger.debug(f"GENERIC plugin: Fetching PSSH 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 = provider.get_manifest(channel_id, **kwargs) if manifest_url: pssh_data_list = self._extract_pssh_from_manifest(manifest_url, provider_name) if pssh_data_list: self.pssh_cache.set(cache_key, pssh_data_list) if not pssh_data_list: logger.debug(f"GENERIC plugin: No PSSH data available for '{channel_id}'") return None, None # 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" ) provider = self.registry.get_provider(provider_name) if not provider: logger.error(f"Cannot extract real PSSH: provider '{provider_name}' not found") return None, pssh_data_list manifest_url = provider.get_manifest(channel_id, **kwargs) if manifest_url: real_pssh = self._extract_pssh_from_manifest(manifest_url, provider_name) 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) else: logger.error( f"GENERIC plugin: Failed to extract complete PSSH - " f"Kid-Key plugin will not be called" ) return None, pssh_data_list else: logger.error(f"GENERIC plugin: Cannot get manifest URL for init segment extraction") 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)") # 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 ) return generic_configs if generic_configs else None, pssh_data_list @staticmethod def _has_clearkey_config(drm_configs: List) -> bool: """Check if any config is a ClearKey config""" return any(config.system == DRMSystem.CLEARKEY for config in drm_configs) @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: # Check if PSSH box is missing or empty if not pssh.pssh_box or not pssh.key_ids: return True return False @staticmethod def _validate_cached_clearkey(cached_configs: List, current_pssh: List) -> bool: """ Validate that cached ClearKey configs have keys for ALL current KIDs. Returns True if all current KIDs are covered by cached configs. Returns False if any KID is missing or configs are invalid. """ # Extract all ClearKey configs clearkey_configs = [c for c in cached_configs if c.system == DRMSystem.CLEARKEY] if not clearkey_configs: logger.debug("No ClearKey configs in cache") return False # Extract all current KIDs from PSSH current_kids = set() for pssh in current_pssh: if pssh.key_ids: current_kids.update(kid.lower().replace("-", "") for kid in pssh.key_ids) if not current_kids: logger.debug("No KIDs in current PSSH") return False # Check if cached configs have keys for ALL current KIDs for config in clearkey_configs: if not config.license or not config.license.keyids: continue # Get provided KIDs from config provided_kids = {kid.lower().replace("-", "") for kid in config.license.keyids.keys()} # Check if this config covers all current KIDs if current_kids.issubset(provided_kids): logger.debug( f"Cached config has keys for all {len(current_kids)} current KIDs" ) return True # No config covers all KIDs missing_kids = current_kids - provided_kids if clearkey_configs else current_kids logger.debug( f"Cached configs missing keys for {len(missing_kids)} KIDs: " f"{list(missing_kids)[:3]}{'...' if len(missing_kids) > 3 else ''}" ) return False def _needs_pssh_extraction(self, drm_configs) -> bool: """Check if PSSH extraction is needed for system-specific plugins.""" config_systems = {config.system for config in drm_configs} # 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) def _extract_pssh_from_manifest(self, manifest_url: str, provider_name: Optional[str] = None) -> List: """Extract PSSH data from manifest using the provider's HTTPManager.""" from .utils.drm_extractor import DRMExtractor 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 [] try: # 2. Resolve the correct HTTP manager http = None if provider_name: # Use the registry passed during __init__ to get the provider instance provider = self.registry.get_provider(provider_name) if provider: # Access the pre-configured http_manager property of the provider http = provider.http_manager logger.debug(f"Using configured HTTPManager for provider: {provider_name}") # Fallback to default if no provider-specific manager is found if not http: logger.debug("No provider manager found; using default HTTPManager") http = HTTPManager() # 3. Perform the request (HTTPManager follows redirects by default) # The 'operation' parameter helps the manager choose the right proxy settings response = http.get(manifest_url, timeout=10, operation="api") response.raise_for_status() manifest_content = response.text # 4. Standard PSSH extraction logic pssh_list = DRMExtractor._extract_from_manifest_content(manifest_content) # 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 ) if needs_segment_extraction: logger.debug("PSSH incomplete in manifest, extracting from init segment") from .utils import ManifestParser init_segment_url = ManifestParser.extract_single_init_segment_url( manifest_content, manifest_url ) if init_segment_url: # Reuse the same configured http manager for the segment download segment_pssh = DRMExtractor._extract_from_single_segment( init_segment_url, [p.system_id for p in pssh_list] if pssh_list else [] ) if segment_pssh: return DRMExtractor._merge_pssh_data(pssh_list, segment_pssh) return pssh_list 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() def clear_pssh_cache(self): """Clear PSSH cache.""" self.pssh_cache.clear() def clear_drm_config_cache(self): """Clear DRM config cache.""" self.drm_config_cache.clear()