From 3044bf2e02d0fa0d03a7a726b46e08e4c1d470bf Mon Sep 17 00:00:00 2001 From: Nirvana Date: Thu, 26 Mar 2026 16:24:12 +0100 Subject: [PATCH] Add movetv --- .../base/drm_operations.py | 105 +++++++++++------- lib/streaming_providers/base/provider.py | 24 ++++ .../providers/movetv/provider.py | 9 +- 3 files changed, 98 insertions(+), 40 deletions(-) diff --git a/lib/streaming_providers/base/drm_operations.py b/lib/streaming_providers/base/drm_operations.py index 71d8731..2f50d46 100644 --- a/lib/streaming_providers/base/drm_operations.py +++ b/lib/streaming_providers/base/drm_operations.py @@ -291,7 +291,10 @@ class DRMOperations: """ Get DRM configurations for a channel with two-phase plugin processing. - Phase 0: Fetch manifest, short-circuit if stream is unencrypted. + 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. Phase 1: GENERIC plugins (pre-provider) — can generate configs from PSSH. If Phase 1 produces configs: - Full ClearKey coverage → cache ClearKey-only, return immediately. @@ -306,6 +309,8 @@ class DRMOperations: """ cache_key = f"{provider_name}:{channel_id}" manifest_content = None # stored to avoid redundant fetches + manifest_url = None # resolved once in Step 0a and reused throughout + manifest_headers = None # resolved once in Step 0a and reused throughout # ------------------------------------------------------------------ # Step 0: Resolve provider and inject proxy_config into kwargs @@ -327,14 +332,16 @@ class DRMOperations: ) # ------------------------------------------------------------------ - # Step 0a: Fetch manifest and short-circuit for unencrypted streams + # 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. # ------------------------------------------------------------------ - manifest_url = provider.get_manifest(content_id=channel_id, **kwargs) + manifest_url, manifest_headers = provider.get_manifest_with_headers(channel_id, **kwargs) 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() - response = http.get(manifest_url, timeout=10, operation="api") + response = http.get(manifest_url, headers=manifest_headers, timeout=10, operation="api") response.raise_for_status() manifest_content = response.text @@ -358,6 +365,8 @@ class DRMOperations: # ------------------------------------------------------------------ # Step 2: PHASE 1 — Try GENERIC plugins first (if registered) + # Pass the already-resolved manifest_url/headers so _try_generic_plugins + # never needs to call get_manifest_with_headers() itself. # ------------------------------------------------------------------ pssh_data_list = None # reused across phases to avoid re-fetching @@ -365,7 +374,11 @@ class DRMOperations: 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 + provider_name, channel_id, cache_key, + manifest_content=manifest_content, + manifest_url=manifest_url, + manifest_headers=manifest_headers, + **kwargs ) # Only proceed with generic configs if they contain real DRM systems @@ -417,8 +430,10 @@ class DRMOperations: return [DRMConfig(system=DRMSystem.NONE, priority=0)] # ------------------------------------------------------------------ - # Step 4: PHASE 2 — Extract PSSH if needed for system-specific plugins + # Step 4: PHASE 2 — Extract PSSH if needed for system-specific plugins. # Reuse pssh_data_list from Phase 1 if already extracted. + # If a fresh fetch is needed, reuse the manifest_url/headers resolved + # in Step 0a — never call get_manifest_with_headers() again. # ------------------------------------------------------------------ if pssh_data_list is None and self.drm_plugin_manager.has_system_specific_plugins(): if self._needs_pssh_extraction(provider_drm_configs): @@ -433,12 +448,16 @@ class DRMOperations: self.pssh_cache.set(cache_key, pssh_data_list) if not pssh_data_list: - logger.debug(f"Phase 2: PSSH cache miss for {cache_key}, fetching manifest") - manifest_url = provider.get_manifest(content_id=channel_id, **kwargs) + # manifest_url/headers already resolved in Step 0a — reuse them if manifest_url: - pssh_data_list = self._extract_pssh_from_manifest(manifest_url, provider_name) + 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 + ) if pssh_data_list: self.pssh_cache.set(cache_key, pssh_data_list) + else: + logger.debug(f"Phase 2: No manifest URL available for PSSH extraction") else: logger.debug(f"Phase 2: Using cached PSSH for {cache_key}") @@ -520,6 +539,8 @@ class DRMOperations: channel_id: str, cache_key: str, manifest_content: Optional[str] = None, + manifest_url: Optional[str] = None, + manifest_headers: Optional[dict] = None, **kwargs ) -> Tuple[Optional[List[DRMConfig]], Optional[List]]: """ @@ -527,6 +548,11 @@ class DRMOperations: Returns (configs, pssh_data_list) tuple. Both can be None. IMPORTANT: Ensures PSSH data is complete (with KIDs) before calling plugins. + + 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). """ # Get PSSH data (from cache or manifest) pssh_data_list = self.pssh_cache.get(cache_key) @@ -541,17 +567,22 @@ class DRMOperations: if pssh_data_list: self.pssh_cache.set(cache_key, pssh_data_list) - # If still no PSSH, fetch manifest + # 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). 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 + 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}'") - manifest_url = provider.get_manifest(content_id=channel_id, **kwargs) if manifest_url: - pssh_data_list = self._extract_pssh_from_manifest(manifest_url, provider_name) + pssh_data_list = self._extract_pssh_from_manifest(manifest_url, manifest_headers, provider_name) if pssh_data_list: self.pssh_cache.set(cache_key, pssh_data_list) @@ -565,30 +596,26 @@ class DRMOperations: 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") + # 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") return None, pssh_data_list - manifest_url = provider.get_manifest(content_id=channel_id, **kwargs) - if manifest_url: - real_pssh = self._extract_pssh_from_manifest(manifest_url, provider_name) + real_pssh = self._extract_pssh_from_manifest(manifest_url, manifest_headers, 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 + 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: Cannot get manifest URL for init segment extraction") + logger.error( + f"GENERIC plugin: Failed to extract complete PSSH - " + f"Kid-Key plugin will not be called" + ) return None, pssh_data_list # Verify we now have complete PSSH @@ -639,7 +666,7 @@ class DRMOperations: } return bool(config_systems & plugin_systems) - def _extract_pssh_from_manifest(self, manifest_url: str, provider_name: Optional[str] = None) -> List: + 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.""" from .utils.drm_extractor import DRMExtractor from .network import HTTPManager @@ -663,7 +690,7 @@ class DRMOperations: http = HTTPManager() # 3. Perform the request - response = http.get(manifest_url, timeout=10, operation="api") + response = http.get(manifest_url, headers=manifest_headers, timeout=10, operation="api") response.raise_for_status() manifest_content = response.text diff --git a/lib/streaming_providers/base/provider.py b/lib/streaming_providers/base/provider.py index 932c6da..375bbd2 100644 --- a/lib/streaming_providers/base/provider.py +++ b/lib/streaming_providers/base/provider.py @@ -267,6 +267,30 @@ class StreamingProvider(ABC): """Get manifest URL for a specific channel by ID""" return None + def get_manifest_headers(self, content_id: str, **kwargs) -> Dict[str, str]: + """ + Return headers for manifest requests. + Override if provider requires specific headers. + """ + return {} + + def get_segment_headers(self, content_id: str, **kwargs) -> Dict[str, str]: + """ + Return headers for segment requests (used by proxy). + Default implementation uses manifest headers. + """ + return self.get_manifest_headers(content_id, **kwargs) + + def get_manifest_with_headers(self, content_id: str, **kwargs) -> Tuple[Optional[str], Dict[str, str]]: + """ + Convenience method that returns (manifest_url, headers). + Default implementation uses get_manifest() and get_manifest_headers(). + Providers can override if they need more complex logic. + """ + url = self.get_manifest(content_id, **kwargs) + headers = self.get_manifest_headers(content_id, **kwargs) + return url, headers + def get_dynamic_manifest_params(self, channel: StreamingChannel, **kwargs) -> Optional[str]: """Optional: Get dynamic manifest parameters for a channel""" return None diff --git a/lib/streaming_providers/providers/movetv/provider.py b/lib/streaming_providers/providers/movetv/provider.py index 67e54f0..905d21a 100644 --- a/lib/streaming_providers/providers/movetv/provider.py +++ b/lib/streaming_providers/providers/movetv/provider.py @@ -328,7 +328,7 @@ class MoveTVProvider(StreamingProvider): logger.error(f"move.tv: Unexpected error fetching manifest for {content_id}: {exc}") return None - def get_play_auth_header(self, content_id: str) -> Optional[str]: + def _get_play_auth_header(self, content_id: str) -> Optional[str]: """ Return the X-Play-Auth header value for a channel. @@ -344,6 +344,13 @@ class MoveTVProvider(StreamingProvider): channel = self._channel_by_id(content_id) return channel.play_auth_header if channel else None + def get_manifest_headers(self, content_id: str, **kwargs) -> Dict[str, str]: + headers: Dict[str, str] = { + "User-Agent": MoveTVConfig.USER_AGENT, + "X-Play-Auth": self._get_play_auth_header(content_id), + } + return headers + # ------------------------------------------------------------------ # get_drm (stub – AES-128/token auth, no active Widevine/PlayReady) # ------------------------------------------------------------------