diff --git a/lib/streaming_providers/base/drm_operations.py b/lib/streaming_providers/base/drm_operations.py index 2f50d46..ffe8a6c 100644 --- a/lib/streaming_providers/base/drm_operations.py +++ b/lib/streaming_providers/base/drm_operations.py @@ -452,7 +452,8 @@ class DRMOperations: if manifest_url: logger.debug(f"Phase 2: PSSH cache miss for {cache_key}, extracting from manifest") pssh_data_list = self._extract_pssh_from_manifest( - manifest_url, manifest_headers, provider_name + manifest_url, manifest_headers, provider_name, + channel_id=channel_id, ) if pssh_data_list: self.pssh_cache.set(cache_key, pssh_data_list) @@ -582,7 +583,10 @@ class DRMOperations: logger.debug(f"GENERIC plugin: Using pre-fetched manifest URL for '{channel_id}'") if manifest_url: - pssh_data_list = self._extract_pssh_from_manifest(manifest_url, manifest_headers, provider_name) + pssh_data_list = self._extract_pssh_from_manifest( + manifest_url, manifest_headers, provider_name, + channel_id=channel_id, + ) if pssh_data_list: self.pssh_cache.set(cache_key, pssh_data_list) @@ -602,7 +606,10 @@ class DRMOperations: logger.error(f"GENERIC plugin: Cannot get manifest URL for init segment extraction") return None, pssh_data_list - real_pssh = self._extract_pssh_from_manifest(manifest_url, manifest_headers, provider_name) + real_pssh = self._extract_pssh_from_manifest( + manifest_url, manifest_headers, provider_name, + channel_id=channel_id, + ) if real_pssh and not self._has_stub_pssh(real_pssh): logger.info( @@ -666,8 +673,26 @@ class DRMOperations: } return bool(config_systems & plugin_systems) - def _extract_pssh_from_manifest(self, manifest_url: str, manifest_headers: dict[str, str], provider_name: Optional[str] = None) -> List: - """Extract PSSH data from manifest using the provider's HTTPManager.""" + 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 {}. + """ from .utils.drm_extractor import DRMExtractor from .network import HTTPManager @@ -677,19 +702,28 @@ class DRMOperations: return [] try: - # 2. Resolve the correct HTTP manager + # 2. Resolve the correct HTTP manager and segment headers http = None + segment_headers = {} + if provider_name: provider = self.registry.get_provider(provider_name) if provider: http = provider.http_manager logger.debug(f"Using configured HTTPManager for provider: {provider_name}") + 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())}" + ) + if not http: logger.debug("No provider manager found; using default HTTPManager") http = HTTPManager() - # 3. Perform the request + # 3. Perform the manifest request response = http.get(manifest_url, headers=manifest_headers, timeout=10, operation="api") response.raise_for_status() manifest_content = response.text @@ -712,7 +746,8 @@ class DRMOperations: if init_segment_url: segment_pssh = DRMExtractor._extract_from_single_segment( init_segment_url, - [p.system_id for p in pssh_list] if pssh_list else [] + [p.system_id for p in pssh_list] if pssh_list else [], + headers=segment_headers, ) if segment_pssh: diff --git a/lib/streaming_providers/base/utils/drm_extractor.py b/lib/streaming_providers/base/utils/drm_extractor.py index 1ece0ba..c17810a 100644 --- a/lib/streaming_providers/base/utils/drm_extractor.py +++ b/lib/streaming_providers/base/utils/drm_extractor.py @@ -6,7 +6,7 @@ Separated from general manifest parsing to maintain clear separation of concerns import base64 import re -from typing import List +from typing import Dict, List, Optional from ..models.drm import PSSHData from .logger import logger @@ -43,6 +43,8 @@ class DRMExtractor: if fallback_to_segments and segment_urls: incomplete_pssh = [p for p in pssh_list if not p.pssh_box or not p.key_ids] if incomplete_pssh: + # No headers available in this deprecated path — callers that need + # auth on segment requests should use _extract_from_single_segment directly. segment_pssh = DRMExtractor._extract_from_single_segment( segment_urls[0], [p.system_id for p in incomplete_pssh] ) @@ -92,13 +94,27 @@ class DRMExtractor: @staticmethod def _extract_from_single_segment( segment_url: str, - expected_system_ids: List[str] = None + expected_system_ids: List[str] = None, + headers: Optional[Dict[str, str]] = None, ) -> List[PSSHData]: - """Extract PSSH from a single segment URL.""" + """ + Extract PSSH from a single segment URL. + + Args: + segment_url: URL of the init segment to fetch + expected_system_ids: If provided, filter results to these DRM system IDs. + Falls back to returning all PSSH if no matches found. + headers: HTTP headers to use when fetching the segment (e.g. Authorization). + Providers that require auth on segment requests should supply these + via StreamingProvider.get_segment_headers(). + """ from .mp4_pssh_extractor import MP4PSSHExtractor try: - pssh_from_segment = MP4PSSHExtractor.extract_from_url(segment_url) + pssh_from_segment = MP4PSSHExtractor.extract_from_url( + segment_url, + headers=headers or {}, + ) if expected_system_ids: # Normalize expected IDs using the model diff --git a/lib/streaming_providers/base/utils/mpd_rewriter.py b/lib/streaming_providers/base/utils/mpd_rewriter.py index a5048a6..e1d91cf 100644 --- a/lib/streaming_providers/base/utils/mpd_rewriter.py +++ b/lib/streaming_providers/base/utils/mpd_rewriter.py @@ -41,6 +41,7 @@ class MPDRewriter: channel: Optional[str] = None, blocklist_path: str = "representation_blocklist.json", clearkey_receiver_side: bool = False, + segment_headers: Optional[Dict[str, str]] = None, ): self.media_proxy_url = media_proxy_url.rstrip("/") self.provider_proxy_url = provider_proxy_url @@ -61,6 +62,12 @@ class MPDRewriter: self._static_params = {} if self.provider_proxy_url: self._static_params["proxy"] = self.provider_proxy_url + if segment_headers: + import json + encoded = base64.urlsafe_b64encode( + json.dumps(segment_headers).encode() + ).decode().rstrip("=") + self._static_params["headers"] = encoded @staticmethod def encode_url(url: str) -> str: diff --git a/service.py b/service.py index 7a5bbb5..dfb96a6 100644 --- a/service.py +++ b/service.py @@ -332,6 +332,58 @@ class UltimateService: # Fallback to environment manager config return self.env_manager.get_config(setting_id, default) + def _fetch_manifest_for_rewriter( + self, + provider: str, + channel_id: str, + manifest_url: str, + ) -> tuple: + """ + Fetch a manifest and collect everything MPDRewriter needs. + + Returns: + (manifest_text, ttl, provider_proxy_url, segment_headers) + + Raises: + ValueError: if the provider HTTP manager is not available + requests.HTTPError: if the manifest fetch fails + """ + http_manager = self.manager.get_provider_http_manager(provider) + if not http_manager: + raise ValueError(f'Provider "{provider}" not configured properly (no HTTP manager)') + + # Resolve manifest headers from the provider so auth tokens etc. are sent + provider_instance = self.manager.get_provider(provider) + manifest_headers = ( + provider_instance.get_manifest_headers(channel_id) + if provider_instance else {} + ) + segment_headers = ( + provider_instance.get_segment_headers(channel_id) + if provider_instance else {} + ) + + manifest_response = http_manager.get( + manifest_url, headers=manifest_headers, operation="manifest" + ) + manifest_response.raise_for_status() + + # Cache TTL: prefer HTTP Cache-Control/Expires, fall back to MPD minimumUpdatePeriod + ttl = MPDRewriter.extract_cache_ttl(manifest_response.headers) + mpd_ttl = MPDRewriter.extract_mpd_update_period(manifest_response.text) + if mpd_ttl and mpd_ttl < ttl: + ttl = mpd_ttl + + # Derive provider-side proxy URL for the media proxy to use when forwarding + provider_proxy_url = None + if http_manager.config.proxy_config: + proxy_cfg = http_manager.config.proxy_config + provider_proxy_url = ( + f"{proxy_cfg.proxy_type.value.lower()}://{proxy_cfg.host}:{proxy_cfg.port}" + ) + + return manifest_response.text, ttl, provider_proxy_url, segment_headers + def get_decrypted_manifest( self, provider: str, channel_id: str, keyids: dict, highest_quality_only: bool = False, receiver_side: bool = False @@ -345,6 +397,7 @@ class UltimateService: channel_id: Channel ID keyids: Dictionary of kid:key pairs highest_quality_only: If True, keep only highest quality video representation + receiver_side: If True, inject ClearKey signaling for receiver-side decryption Returns: Rewritten MPD content as string @@ -354,48 +407,24 @@ class UltimateService: # Note: We don't cache decrypted manifests as they contain keys logger.info( f"Generating {'receiver-side clearkey' if receiver_side else 'decrypted'} manifest " - f"for {provider}/{channel_id} (highest_quality_only={highest_quality_only})") + f"for {provider}/{channel_id} (highest_quality_only={highest_quality_only})" + ) - # Get original manifest URL manifest_url = self.manager.get_channel_manifest( provider_name=provider, channel_id=channel_id, country=country ) - if not manifest_url: response.status = 404 response.content_type = "application/json" return json.dumps( - { - "error": f'Manifest not available for channel "{channel_id}" from provider "{provider}"' - } + {"error": f'Manifest not available for channel "{channel_id}" from provider "{provider}"'} ) - # Get provider's HTTP manager - http_manager = self.manager.get_provider_http_manager(provider) - if not http_manager: - logger.error(f"No HTTP manager found for provider '{provider}'") - response.status = 502 - response.content_type = "application/json" - return json.dumps( - {"error": f'Provider "{provider}" not configured properly'} - ) - - # Fetch manifest try: - logger.debug(f"Fetching manifest for decryption: {manifest_url}") - manifest_response = http_manager.get(manifest_url, operation="manifest") + manifest_text, _ttl, provider_proxy_url, segment_headers = self._fetch_manifest_for_rewriter( + provider, channel_id, manifest_url + ) - # Get provider proxy URL if configured - provider_proxy_url = None - if http_manager.config.proxy_config: - proxy_cfg = http_manager.config.proxy_config - provider_proxy_url = ( - f"{proxy_cfg.proxy_type.value.lower()}://{proxy_cfg.host}:{proxy_cfg.port}" - ) - logger.debug(f"Provider has proxy configured: {provider_proxy_url}") - - # Rewrite MPD URLs to point to media proxy decrypt endpoint with keys - # CHANGED: Added provider and channel_id parameters for blocklist filtering rewriter = MPDRewriter( self.media_proxy_url, provider_proxy_url, @@ -404,127 +433,153 @@ class UltimateService: provider=provider, channel=channel_id, clearkey_receiver_side=receiver_side, + segment_headers=segment_headers, ) - rewritten_mpd = rewriter.rewrite_mpd(manifest_response.text, manifest_url) + rewritten_mpd = rewriter.rewrite_mpd(manifest_text, manifest_url) - # Return rewritten MPD response.content_type = "application/dash+xml; charset=utf-8" return rewritten_mpd + except ValueError as e: + logger.error(str(e)) + response.status = 502 + response.content_type = "application/json" + return json.dumps({"error": str(e)}) except Exception as fetch_err: logger.error(f"Failed to fetch manifest for decryption: {fetch_err}") response.status = 502 response.content_type = "application/json" return json.dumps({"error": f"Failed to fetch manifest: {str(fetch_err)}"}) + def get_proxied_catchup_manifest( + self, + provider: str, + channel_id: str, + start_time: int, + end_time: int, + epg_id: str = None, + country: str = None, + ) -> str: + """ + Get proxied and rewritten MPD manifest for catchup content using media proxy. + Similar to get_proxied_manifest but for catchup streams. + """ + cache_key = f"{channel_id}_catchup_{start_time}_{end_time}" + + cached_mpd = self.mpd_cache.get(provider, cache_key) + if cached_mpd: + response.content_type = "application/dash+xml; charset=utf-8" + return cached_mpd + + logger.info(f"Cache miss for catchup {provider}/{channel_id}, fetching manifest") + + if not self.media_proxy_url: + response.status = 503 + response.content_type = "application/json" + return json.dumps({"error": "Media proxy not configured (MEDIA_PROXY_URL not set)"}) + + manifest_url = self.manager.get_catchup_manifest( + provider_name=provider, + channel_id=channel_id, + start_time=start_time, + end_time=end_time, + epg_id=epg_id, + country=country, + ) + if not manifest_url: + response.status = 404 + response.content_type = "application/json" + return json.dumps({"error": "Catchup manifest not available"}) + + try: + manifest_text, ttl, provider_proxy_url, segment_headers = self._fetch_manifest_for_rewriter( + provider, channel_id, manifest_url + ) + + rewriter = MPDRewriter( + self.media_proxy_url, + provider_proxy_url, + None, # No keyids for catchup streams + False, # highest_quality_only — not needed for catchup + provider=provider, + channel=channel_id, + segment_headers=segment_headers, + ) + rewritten_mpd = rewriter.rewrite_mpd(manifest_text, manifest_url) + + self.mpd_cache.set( + provider=provider, + channel_id=cache_key, + mpd_content=rewritten_mpd, + ttl=ttl, + original_url=manifest_url, + ) + + response.content_type = "application/dash+xml; charset=utf-8" + return rewritten_mpd + + except ValueError as e: + logger.error(str(e)) + response.status = 502 + response.content_type = "application/json" + return json.dumps({"error": str(e)}) + except Exception as fetch_err: + logger.error(f"Failed to fetch catchup manifest: {fetch_err}") + response.status = 502 + response.content_type = "application/json" + return json.dumps({"error": f"Failed to fetch manifest: {str(fetch_err)}"}) + def get_proxied_manifest(self, provider: str, channel_id: str, highest_quality_only: bool = False) -> str: - """ - Get proxied and rewritten MPD manifest for a channel using media proxy. - Uses cache when available and valid. - - Args: - provider: Provider name - channel_id: Channel ID - highest_quality_only: If True, keep only highest quality video representation - - Returns: - Rewritten MPD content as string - """ country = request.query.get("country") - # Try cache first (only if not using highest_quality_only, as that changes output) if not highest_quality_only: cached_mpd = self.mpd_cache.get(provider, channel_id) if cached_mpd: response.content_type = "application/dash+xml; charset=utf-8" return cached_mpd - # Cache miss or highest_quality_only enabled - fetch and rewrite - logger.info( - f"Cache miss for {provider}/{channel_id}, fetching manifest (highest_quality_only={highest_quality_only})") - - # Check if media proxy is configured if not self.media_proxy_url: response.status = 503 response.content_type = "application/json" - return json.dumps( - {"error": "Media proxy not configured (MEDIA_PROXY_URL not set)"} - ) + return json.dumps({"error": "Media proxy not configured (MEDIA_PROXY_URL not set)"}) - # Get original manifest URL manifest_url = self.manager.get_channel_manifest( provider_name=provider, channel_id=channel_id, country=country ) - if not manifest_url: response.status = 404 response.content_type = "application/json" return json.dumps( - { - "error": f'Manifest not available for channel "{channel_id}" from provider "{provider}"' - } - ) + {"error": f'Manifest not available for channel "{channel_id}" from provider "{provider}"'}) - # Get provider's HTTP manager to fetch manifest and check proxy config - http_manager = self.manager.get_provider_http_manager(provider) - if not http_manager: - logger.error(f"No HTTP manager found for provider '{provider}'") - response.status = 502 - response.content_type = "application/json" - return json.dumps( - {"error": f'Provider "{provider}" not configured properly'} - ) - - # Fetch manifest via provider's HTTP manager try: - logger.debug(f"Fetching manifest: {manifest_url}") - manifest_response = http_manager.get(manifest_url, operation="manifest") + manifest_text, ttl, provider_proxy_url, segment_headers = self._fetch_manifest_for_rewriter( + provider, channel_id, manifest_url + ) - # Extract cache TTL from response headers - ttl = MPDRewriter.extract_cache_ttl(manifest_response.headers) - - # Also check MPD's own update period as fallback - mpd_ttl = MPDRewriter.extract_mpd_update_period(manifest_response.text) - if mpd_ttl and mpd_ttl < ttl: - ttl = mpd_ttl - logger.debug(f"Using MPD minimumUpdatePeriod as TTL: {ttl}s") - - # Get provider proxy URL if configured - provider_proxy_url = None - if http_manager.config.proxy_config: - # Build proxy URL from config - proxy_cfg = http_manager.config.proxy_config - provider_proxy_url = ( - f"{proxy_cfg.proxy_type.value.lower()}://{proxy_cfg.host}:{proxy_cfg.port}" - ) - logger.debug(f"Provider has proxy configured: {provider_proxy_url}") - - # Rewrite MPD URLs to point to media proxy - # CHANGED: Added provider and channel_id parameters for blocklist filtering rewriter = MPDRewriter( self.media_proxy_url, provider_proxy_url, - None, # No keyids for proxied (unencrypted) streams + None, highest_quality_only, - provider=provider, # NEW: Enable blocklist filtering - channel=channel_id # NEW: Enable blocklist filtering + provider=provider, + channel=channel_id, + segment_headers=segment_headers, ) - rewritten_mpd = rewriter.rewrite_mpd(manifest_response.text, manifest_url) + rewritten_mpd = rewriter.rewrite_mpd(manifest_text, manifest_url) - # Cache the rewritten MPD (only if not using highest_quality_only) if not highest_quality_only: - self.mpd_cache.set( - provider=provider, - channel_id=channel_id, - mpd_content=rewritten_mpd, - ttl=ttl, - original_url=manifest_url, - ) + self.mpd_cache.set(provider=provider, channel_id=channel_id, + mpd_content=rewritten_mpd, ttl=ttl, original_url=manifest_url) - # Return rewritten MPD response.content_type = "application/dash+xml; charset=utf-8" return rewritten_mpd + except ValueError as e: + logger.error(str(e)) + response.status = 502 + response.content_type = "application/json" + return json.dumps({"error": str(e)}) except Exception as fetch_err: logger.error(f"Failed to fetch manifest: {fetch_err}") response.status = 502 @@ -1497,115 +1552,6 @@ class UltimateService: return m3u_content - def get_proxied_catchup_manifest( - self, - provider: str, - channel_id: str, - start_time: int, - end_time: int, - epg_id: str = None, - country: str = None, - ) -> str: - """ - Get proxied and rewritten MPD manifest for catchup content using media proxy. - Similar to get_proxied_manifest but for catchup streams. - """ - # Generate cache key that includes time parameters - cache_key = f"{channel_id}_catchup_{start_time}_{end_time}" - - # Try cache first (with catchup-specific key) - cached_mpd = self.mpd_cache.get(provider, cache_key) - if cached_mpd: - response.content_type = "application/dash+xml; charset=utf-8" - return cached_mpd - - logger.info( - f"Cache miss for catchup {provider}/{channel_id}, fetching manifest" - ) - - # Check if media proxy is configured - if not self.media_proxy_url: - response.status = 503 - response.content_type = "application/json" - return json.dumps( - {"error": "Media proxy not configured (MEDIA_PROXY_URL not set)"} - ) - - # Get catchup manifest URL - manifest_url = self.manager.get_catchup_manifest( - provider_name=provider, - channel_id=channel_id, - start_time=start_time, - end_time=end_time, - epg_id=epg_id, - country=country, - ) - - if not manifest_url: - response.status = 404 - response.content_type = "application/json" - return json.dumps({"error": f"Catchup manifest not available"}) - - # Get provider's HTTP manager - http_manager = self.manager.get_provider_http_manager(provider) - if not http_manager: - logger.error(f"No HTTP manager found for provider '{provider}'") - response.status = 502 - response.content_type = "application/json" - return json.dumps( - {"error": f'Provider "{provider}" not configured properly'} - ) - - # Fetch manifest - try: - logger.debug(f"Fetching catchup manifest: {manifest_url}") - manifest_response = http_manager.get(manifest_url, operation="manifest") - - # Extract cache TTL - ttl = MPDRewriter.extract_cache_ttl(manifest_response.headers) - mpd_ttl = MPDRewriter.extract_mpd_update_period(manifest_response.text) - if mpd_ttl and mpd_ttl < ttl: - ttl = mpd_ttl - - # Get provider proxy URL if configured - provider_proxy_url = None - if http_manager.config.proxy_config: - proxy_cfg = http_manager.config.proxy_config - provider_proxy_url = ( - f"{proxy_cfg.proxy_type.value.lower()}://{proxy_cfg.host}:{proxy_cfg.port}" - ) - logger.debug(f"Provider has proxy configured: {provider_proxy_url}") - - # Rewrite MPD URLs to point to media proxy - # CHANGED: Added provider and channel_id parameters for blocklist filtering - rewriter = MPDRewriter( - self.media_proxy_url, - provider_proxy_url, - None, # No keyids for catchup streams - False, # highest_quality_only - usually not needed for catchup - provider=provider, # NEW: Enable blocklist filtering - channel=channel_id # NEW: Enable blocklist filtering - ) - rewritten_mpd = rewriter.rewrite_mpd(manifest_response.text, manifest_url) - - # Cache the rewritten MPD with catchup-specific key - self.mpd_cache.set( - provider=provider, - channel_id=cache_key, # Use catchup-specific cache key - mpd_content=rewritten_mpd, - ttl=ttl, - original_url=manifest_url, - ) - - response.content_type = "application/dash+xml; charset=utf-8" - return rewritten_mpd - - except Exception as fetch_err: - logger.error(f"Failed to fetch catchup manifest: {fetch_err}") - response.status = 502 - response.content_type = "application/json" - return json.dumps({"error": f"Failed to fetch manifest: {str(fetch_err)}"}) - @staticmethod def get_settings_manager(): """Simple helper to get SettingsManager"""