From eef01919d7b4655b5333445b89c1a221b469c740 Mon Sep 17 00:00:00 2001 From: Nirvana Date: Fri, 16 Jan 2026 12:24:41 +0100 Subject: [PATCH] Reformat --- lib/streaming_providers/__init__.py | 17 +- lib/streaming_providers/base/auth/__init__.py | 3 +- .../base/auth/base_auth.py | 152 +++------ .../base/auth/base_oauth2_auth.py | 135 ++------ .../base/auth/credential_manager.py | 93 ++---- .../base/auth/session_manager.py | 151 +++------ .../base/catchup_operations.py | 7 +- .../base/channel_operations.py | 8 +- .../base/drm/plugin_manager.py | 53 +-- .../base/drm_operations.py | 107 +++++- lib/streaming_providers/base/epg/epg_cache.py | 10 +- .../base/epg/epg_manager.py | 48 +-- .../base/epg/epg_mapping.py | 59 +--- .../base/epg/epg_parser.py | 61 +--- .../base/epg_operations.py | 4 +- lib/streaming_providers/base/manager.py | 40 +-- .../base/models/__init__.py | 3 +- lib/streaming_providers/base/models/auth.py | 8 +- .../base/models/drm_models.py | 51 ++- .../base/models/epg_models.py | 20 +- .../base/models/proxy_models.py | 12 +- .../base/models/streaming_channel.py | 4 +- .../base/network/http_manager.py | 24 +- .../base/network/proxy_manager.py | 78 ++--- lib/streaming_providers/base/provider.py | 68 ++-- .../base/provider_registry.py | 8 +- .../base/settings/__init__.py | 3 +- .../base/settings/kodi_settings_bridge.py | 91 ++--- .../base/settings/models/__init__.py | 20 +- .../base/settings/models/provider_settings.py | 35 +- .../base/settings/models/settings_models.py | 33 +- .../base/settings/provider_enable_manager.py | 46 +-- .../base/settings/settings_manager.py | 294 +++++----------- .../base/subscription_operations.py | 20 +- .../base/ui/console_notification_adapter.py | 4 +- .../base/ui/kodi_notification_adapter.py | 12 +- .../base/ui/notification_factory.py | 14 +- .../base/ui/qr_generator.py | 4 +- .../base/utils/environment.py | 20 +- lib/streaming_providers/base/utils/logger.py | 4 +- .../base/utils/manifest_parser.py | 310 +++++++++++++---- .../base/utils/mp4_parser.py | 304 +++++++++++++++++ .../base/utils/mpd_cache.py | 12 +- .../base/utils/mpd_rewriter.py | 24 +- .../base/utils/timestamp_converter.py | 16 +- lib/streaming_providers/base/utils/vfs.py | 27 +- .../providers/auth_builder.py | 78 ++--- .../providers/auth_context.py | 4 +- .../providers/hrti/auth.py | 136 ++------ .../providers/hrti/constants.py | 12 +- .../providers/hrti/provider.py | 55 +-- .../providers/joyn/__init__.py | 8 +- .../providers/joyn/auth.py | 110 +++--- .../providers/joyn/models.py | 4 +- .../providers/joyn/provider.py | 93 +++--- .../providers/magenta2/__init__.py | 19 +- .../providers/magenta2/auth.py | 158 +++------ .../providers/magenta2/concurrency.py | 8 +- .../providers/magenta2/config_models.py | 23 +- .../providers/magenta2/constants.py | 4 +- .../providers/magenta2/discovery.py | 74 ++--- .../providers/magenta2/endpoint_manager.py | 24 +- .../providers/magenta2/provider.py | 314 +++++------------- .../magenta2/remote_login_handler.py | 18 +- .../providers/magenta2/sam3_client.py | 28 +- .../providers/magenta2/taa_client.py | 28 +- .../providers/magenta2/token_flow_manager.py | 109 ++---- .../providers/magenta2/token_utils.py | 20 +- .../providers/magentaeu/__init__.py | 3 +- .../providers/magentaeu/auth.py | 92 +++-- .../providers/magentaeu/constants.py | 4 +- .../providers/magentaeu/provider.py | 82 ++--- .../providers/rtlplus/auth.py | 27 +- .../providers/rtlplus/constants.py | 31 +- .../providers/rtlplus/models.py | 8 +- .../providers/rtlplus/provider.py | 28 +- service.py | 312 +++++++++++++++-- 77 files changed, 1910 insertions(+), 2521 deletions(-) create mode 100644 lib/streaming_providers/base/utils/mp4_parser.py diff --git a/lib/streaming_providers/__init__.py b/lib/streaming_providers/__init__.py index 95ea4b8..4d3d219 100644 --- a/lib/streaming_providers/__init__.py +++ b/lib/streaming_providers/__init__.py @@ -103,19 +103,12 @@ def get_configured_manager(country: str = "de") -> "ProviderManager": settings_manager = SettingsManager(enable_kodi_integration=True) # Detect providers from Kodi (if available) or auto-detect from AVAILABLE_PROVIDERS - if ( - settings_manager.kodi_bridge - and settings_manager.kodi_bridge.is_kodi_environment() - ): - detected_providers = ( - settings_manager.kodi_bridge.detect_all_providers_from_kodi() - ) + if settings_manager.kodi_bridge and settings_manager.kodi_bridge.is_kodi_environment(): + detected_providers = settings_manager.kodi_bridge.detect_all_providers_from_kodi() logger.info(f"Detected providers from Kodi: {detected_providers}") else: # Auto-generate detected_providers from AVAILABLE_PROVIDERS - logger.info( - f"Not in Kodi environment, auto-detecting providers from AVAILABLE_PROVIDERS" - ) + logger.info(f"Not in Kodi environment, auto-detecting providers from AVAILABLE_PROVIDERS") detected_providers = {} for provider_name, provider_class in AVAILABLE_PROVIDERS.items(): @@ -132,9 +125,7 @@ def get_configured_manager(country: str = "de") -> "ProviderManager": logger.debug(f"Provider '{provider_name}' is single-country") # Use the new discover_providers method with detected providers - registered = manager.discover_providers( - country=country, detected_providers=detected_providers - ) + registered = manager.discover_providers(country=country, detected_providers=detected_providers) logger.info(f"Registered {len(registered)} providers: {registered}") diff --git a/lib/streaming_providers/base/auth/__init__.py b/lib/streaming_providers/base/auth/__init__.py index ecf3a45..7e93ee9 100644 --- a/lib/streaming_providers/base/auth/__init__.py +++ b/lib/streaming_providers/base/auth/__init__.py @@ -1,8 +1,7 @@ # streaming_providers/base/auth/__init__.py from .base_auth import BaseAuthenticator, BaseAuthToken from .credential_manager import CredentialManager -from .credentials import (BaseCredentials, ClientCredentials, - UserPasswordCredentials) +from .credentials import BaseCredentials, ClientCredentials, UserPasswordCredentials from .session_manager import SessionManager # Only export what consumers should use diff --git a/lib/streaming_providers/base/auth/base_auth.py b/lib/streaming_providers/base/auth/base_auth.py index 044f65b..1e3ef48 100644 --- a/lib/streaming_providers/base/auth/base_auth.py +++ b/lib/streaming_providers/base/auth/base_auth.py @@ -116,13 +116,9 @@ class BaseAuthenticator(ABC): # Log country configuration if self.country: - logger.info( - f"Initializing {provider_name} authenticator for country: {country}" - ) + logger.info(f"Initializing {provider_name} authenticator for country: {country}") else: - logger.info( - f"Initializing {provider_name} authenticator (no country specified)" - ) + logger.info(f"Initializing {provider_name} authenticator (no country specified)") # Use injected settings manager or create one for backward compatibility if settings_manager is not None: @@ -133,9 +129,7 @@ class BaseAuthenticator(ABC): self.settings_manager = self._create_settings_manager( config_dir, enable_kodi_integration ) - logger.info( - f"Created settings manager for backward compatibility for {provider_name}" - ) + logger.info(f"Created settings manager for backward compatibility for {provider_name}") # Register provider with settings manager if hasattr(self.settings_manager, "register_provider"): @@ -177,8 +171,7 @@ class BaseAuthenticator(ABC): ): """Create fallback using adapter pattern""" try: - from ..settings.settings_manager_adapter import \ - SettingsManagerFactory + from ..settings.settings_manager_adapter import SettingsManagerFactory adapter = SettingsManagerFactory.create_default_adapter( prefer_unified=True, @@ -211,14 +204,10 @@ class BaseAuthenticator(ABC): def get_provider_credentials(self, provider_name, country=None): if self.credential_manager: - return self.credential_manager.load_credentials( - provider_name, country - ) + return self.credential_manager.load_credentials(provider_name, country) return None - def save_provider_credentials( - self, provider_name, credentials, country=None - ): + def save_provider_credentials(self, provider_name, credentials, country=None): if self.credential_manager: return self.credential_manager.save_credentials( provider_name, credentials, country @@ -229,9 +218,7 @@ class BaseAuthenticator(ABC): return self.session_manager.load_token_data(provider_name, country) def save_token_data(self, provider_name, token_data, country=None): - return self.session_manager.save_session( - provider_name, token_data, country - ) + return self.session_manager.save_session(provider_name, token_data, country) def get_device_id(self, provider_name, country=None): return self.session_manager.get_device_id(provider_name, country) @@ -264,9 +251,7 @@ class BaseAuthenticator(ABC): def get_provider_credentials(self, provider_name, country=None): return None - def save_provider_credentials( - self, provider_name, credentials, country=None - ): + def save_provider_credentials(self, provider_name, credentials, country=None): return False def load_token_data(self, provider_name, country=None): @@ -311,9 +296,7 @@ class BaseAuthenticator(ABC): logger.debug( f"Settings manager doesn't support country parameter, using without" ) - return self.settings_manager.get_provider_credentials( - self.provider_name - ) + return self.settings_manager.get_provider_credentials(self.provider_name) else: logger.warning( f"Settings manager has no credential loading method for {self.provider_name}" @@ -321,9 +304,7 @@ class BaseAuthenticator(ABC): return None except Exception as e: - logger.error( - f"Error loading credentials from manager for {self.provider_name}: {e}" - ) + logger.error(f"Error loading credentials from manager for {self.provider_name}: {e}") return None # Abstract methods remain unchanged @@ -344,9 +325,7 @@ class BaseAuthenticator(ABC): pass @abstractmethod - def _create_token_from_response( - self, response_data: Dict[str, Any] - ) -> BaseAuthToken: + def _create_token_from_response(self, response_data: Dict[str, Any]) -> BaseAuthToken: """Create token object from API response""" pass @@ -369,14 +348,10 @@ class BaseAuthenticator(ABC): # Try country-aware call first try: - token_data = self.settings_manager.load_token_data( - self.provider_name, self.country - ) + token_data = self.settings_manager.load_token_data(self.provider_name, self.country) except TypeError: # Fallback for managers that don't support country parameter - logger.debug( - f"Settings manager doesn't support country parameter, using without" - ) + logger.debug(f"Settings manager doesn't support country parameter, using without") token_data = self.settings_manager.load_token_data(self.provider_name) if token_data: @@ -385,9 +360,7 @@ class BaseAuthenticator(ABC): f"Successfully loaded existing session for {self.provider_name}{country_str}" ) else: - logger.info( - f"No existing session found for {self.provider_name}{country_str}" - ) + logger.info(f"No existing session found for {self.provider_name}{country_str}") self._current_token = None except Exception as e: @@ -417,9 +390,7 @@ class BaseAuthenticator(ABC): if success: logger.debug(f"Saved session for {self.provider_name}{country_str}") else: - logger.warning( - f"Failed to save session for {self.provider_name}{country_str}" - ) + logger.warning(f"Failed to save session for {self.provider_name}{country_str}") except Exception as e: logger.error(f"Error saving session for {self.provider_name}: {e}") @@ -434,9 +405,7 @@ class BaseAuthenticator(ABC): # If we still don't have valid credentials, try fallback if not self.credentials or not self.credentials.validate(): - logger.info( - f"No valid user credentials found for {self.provider_name}, using fallback" - ) + logger.info(f"No valid user credentials found for {self.provider_name}, using fallback") self.credentials = self.get_fallback_credentials() return self.credentials is not None and self.credentials.validate() @@ -460,14 +429,8 @@ class BaseAuthenticator(ABC): logger.debug(f"[{self.provider_name}{country_str}] No current token") # 1. Return existing token if valid - if ( - not force_refresh - and self._current_token - and not self._current_token.is_expired - ): - logger.info( - f"[{self.provider_name}{country_str}] Using existing valid token" - ) + if not force_refresh and self._current_token and not self._current_token.is_expired: + logger.info(f"[{self.provider_name}{country_str}] Using existing valid token") return self._current_token # 2. ENHANCED REFRESH LOGIC: Attempt refresh if we have a token with refresh capability @@ -492,9 +455,7 @@ class BaseAuthenticator(ABC): if should_attempt_refresh: logger.info(f"[{self.provider_name}{country_str}] Attempting token refresh") try: - refreshed_token = ( - self._refresh_token() - ) # Provider-specific implementation + refreshed_token = self._refresh_token() # Provider-specific implementation logger.debug( f"[{self.provider_name}{country_str}] Refresh result: {refreshed_token is not None}" ) @@ -502,27 +463,21 @@ class BaseAuthenticator(ABC): if refreshed_token: self._current_token = refreshed_token self._save_session() - logger.info( - f"[{self.provider_name}{country_str}] Token refresh successful" - ) + logger.info(f"[{self.provider_name}{country_str}] Token refresh successful") return self._current_token else: logger.debug( f"[{self.provider_name}{country_str}] Refresh failed, falling back to full auth" ) except Exception as e: - logger.warning( - f"[{self.provider_name}{country_str}] Token refresh failed: {e}" - ) + logger.warning(f"[{self.provider_name}{country_str}] Token refresh failed: {e}") # 3. Ensure we have credentials before attempting full authentication if not self._ensure_credentials(): raise Exception(f"No valid credentials available for {self.provider_name}") # 4. Perform full authentication - logger.info( - f"[{self.provider_name}{country_str}] Performing new authentication" - ) + logger.info(f"[{self.provider_name}{country_str}] Performing new authentication") token = self._perform_authentication() self._current_token = token self._save_session() @@ -540,9 +495,7 @@ class BaseAuthenticator(ABC): ) except TypeError: # Fallback for managers that don't support country parameter - logger.debug( - f"Settings manager doesn't support country parameter, using without" - ) + logger.debug(f"Settings manager doesn't support country parameter, using without") success = self.settings_manager.save_provider_credentials( self.provider_name, credentials ) @@ -574,14 +527,10 @@ class BaseAuthenticator(ABC): return success else: - logger.debug( - f"No Kodi sync capability available for {self.provider_name}" - ) + logger.debug(f"No Kodi sync capability available for {self.provider_name}") return True except Exception as e: - logger.error( - f"Error syncing credentials from Kodi for {self.provider_name}: {e}" - ) + logger.error(f"Error syncing credentials from Kodi for {self.provider_name}: {e}") return False def get_credential_info(self) -> Dict[str, Any]: @@ -606,14 +555,10 @@ class BaseAuthenticator(ABC): self.provider_name, self.country ) except TypeError: - manager_info = self.settings_manager.get_credential_info( - self.provider_name - ) + manager_info = self.settings_manager.get_credential_info(self.provider_name) base_info.update(manager_info) except Exception as e: - logger.debug( - f"Could not get extended credential info for {self.provider_name}: {e}" - ) + logger.debug(f"Could not get extended credential info for {self.provider_name}: {e}") return base_info @@ -632,16 +577,12 @@ class BaseAuthenticator(ABC): if hasattr(self.settings_manager, "credential_manager"): # Try country-aware call first try: - success = ( - self.settings_manager.credential_manager.delete_credentials( - self.provider_name, self.country - ) + success = self.settings_manager.credential_manager.delete_credentials( + self.provider_name, self.country ) except TypeError: - success = ( - self.settings_manager.credential_manager.delete_credentials( - self.provider_name - ) + success = self.settings_manager.credential_manager.delete_credentials( + self.provider_name ) else: logger.debug( @@ -649,19 +590,13 @@ class BaseAuthenticator(ABC): ) if success: - logger.info( - f"{self.provider_name}: Stored credentials cleared successfully" - ) + logger.info(f"{self.provider_name}: Stored credentials cleared successfully") self.invalidate_token() else: - logger.warning( - f"{self.provider_name}: Failed to clear stored credentials" - ) + logger.warning(f"{self.provider_name}: Failed to clear stored credentials") return success except Exception as e: - logger.error( - f"Error clearing stored credentials for {self.provider_name}: {e}" - ) + logger.error(f"Error clearing stored credentials for {self.provider_name}: {e}") return False def has_stored_credentials(self) -> bool: @@ -673,14 +608,10 @@ class BaseAuthenticator(ABC): self.provider_name, self.country ) except TypeError: - credentials = self.settings_manager.get_provider_credentials( - self.provider_name - ) + credentials = self.settings_manager.get_provider_credentials(self.provider_name) return credentials is not None and credentials.validate() except Exception as e: - logger.debug( - f"Error checking stored credentials for {self.provider_name}: {e}" - ) + logger.debug(f"Error checking stored credentials for {self.provider_name}: {e}") return False def test_current_credentials(self) -> bool: @@ -731,9 +662,7 @@ class BaseAuthenticator(ABC): try: # Try country-aware call first try: - return self.settings_manager.get_device_id( - self.provider_name, self.country - ) + return self.settings_manager.get_device_id(self.provider_name, self.country) except TypeError: return self.settings_manager.get_device_id(self.provider_name) except Exception as e: @@ -818,13 +747,10 @@ class BaseAuthenticator(ABC): self.provider_name, self.country ) except TypeError: - stored_creds = self.settings_manager.get_provider_credentials( - self.provider_name - ) + stored_creds = self.settings_manager.get_provider_credentials(self.provider_name) has_user_creds = ( - isinstance(stored_creds, UserPasswordCredentials) - and stored_creds.validate() + isinstance(stored_creds, UserPasswordCredentials) and stored_creds.validate() ) # Check current credentials diff --git a/lib/streaming_providers/base/auth/base_oauth2_auth.py b/lib/streaming_providers/base/auth/base_oauth2_auth.py index 2f192e6..02cce7d 100644 --- a/lib/streaming_providers/base/auth/base_oauth2_auth.py +++ b/lib/streaming_providers/base/auth/base_oauth2_auth.py @@ -17,9 +17,7 @@ from .base_auth import BaseAuthenticator, BaseAuthToken, TokenAuthLevel class OAuth2Error(Exception): """OAuth2-specific error with structured error information""" - def __init__( - self, error: str, error_description: str = None, error_uri: str = None - ): + def __init__(self, error: str, error_description: str = None, error_uri: str = None): self.error = error self.error_description = error_description self.error_uri = error_uri @@ -117,9 +115,7 @@ class BaseOAuth2Authenticator(BaseAuthenticator): if self._http_manager is not None: return self._http_manager - logger.warning( - f"No HTTP manager available for {self.provider_name}, creating one" - ) + logger.warning(f"No HTTP manager available for {self.provider_name}, creating one") try: from ...base.network import HTTPManagerFactory @@ -131,9 +127,7 @@ class BaseOAuth2Authenticator(BaseAuthenticator): timeout=getattr(self.config, "timeout", 30), ) except Exception as e: - logger.warning( - f"Error creating HTTP manager via factory: {e}, using minimal fallback" - ) + logger.warning(f"Error creating HTTP manager via factory: {e}, using minimal fallback") self._http_manager = self._create_minimal_http_manager() return self._http_manager @@ -152,18 +146,14 @@ class BaseOAuth2Authenticator(BaseAuthenticator): return self._config # Log who's calling this before config is set - logger.warning( - f"Config accessed before initialization for {self.provider_name}" - ) + logger.warning(f"Config accessed before initialization for {self.provider_name}") logger.debug(f"Call stack:\n{''.join(traceback.format_stack()[-5:])}") # Only create minimal config if absolutely necessary class MinimalConfig: def __init__(self): self.timeout = 30 - self.user_agent = ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" - ) + self.user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" self.base_website = "https://example.com" self.auth_endpoint = "https://auth.example.com" @@ -215,9 +205,7 @@ class BaseOAuth2Authenticator(BaseAuthenticator): if hasattr(self.config, "auth_endpoint"): return self.config.auth_endpoint - raise NotImplementedError( - "Subclass must implement auth_endpoint or set _auth_endpoint" - ) + raise NotImplementedError("Subclass must implement auth_endpoint or set _auth_endpoint") @auth_endpoint.setter def auth_endpoint(self, value): @@ -246,9 +234,7 @@ class BaseOAuth2Authenticator(BaseAuthenticator): if hasattr(self, "auth_endpoint"): auth_endpoint = self.auth_endpoint else: - logger.warning( - f"auth_endpoint not defined for {self.provider_name}, using default" - ) + logger.warning(f"auth_endpoint not defined for {self.provider_name}, using default") return "https://auth.example.com/oauth2/auth" if auth_endpoint.endswith("/token"): @@ -320,9 +306,7 @@ class BaseOAuth2Authenticator(BaseAuthenticator): Uses provider-specific headers and payload formatting """ try: - logger.debug( - f"Starting OAuth2 client credentials flow for {self.provider_name}" - ) + logger.debug(f"Starting OAuth2 client credentials flow for {self.provider_name}") headers = self._get_auth_headers() data = self._build_auth_payload() @@ -335,23 +319,17 @@ class BaseOAuth2Authenticator(BaseAuthenticator): response.raise_for_status() token_data = response.json() - logger.debug( - f"OAuth2 client credentials flow successful for {self.provider_name}" - ) + logger.debug(f"OAuth2 client credentials flow successful for {self.provider_name}") return token_data except OAuth2Error: raise except Exception as e: - logger.error( - f"OAuth2 client credentials flow failed for {self.provider_name}: {e}" - ) + logger.error(f"OAuth2 client credentials flow failed for {self.provider_name}: {e}") raise Exception(f"OAuth2 client credentials flow failed: {e}") # Authorization URL Building - def _build_authorization_url( - self, extra_params: Dict[str, Any] = None - ) -> tuple[str, str, str]: + def _build_authorization_url(self, extra_params: Dict[str, Any] = None) -> tuple[str, str, str]: """Build authorization URL with PKCE for authorization code flow""" code_verifier = self.generate_pkce_verifier() code_challenge = self.generate_pkce_challenge(code_verifier) @@ -383,9 +361,7 @@ class BaseOAuth2Authenticator(BaseAuthenticator): Enhanced to support provider-specific customizations """ try: - logger.debug( - f"Exchanging authorization code for token for {self.provider_name}" - ) + logger.debug(f"Exchanging authorization code for token for {self.provider_name}") # Allow subclasses to override the default payload data = self._build_token_exchange_payload( @@ -419,17 +395,13 @@ class BaseOAuth2Authenticator(BaseAuthenticator): response.raise_for_status() token_data = response.json() - logger.debug( - f"Authorization code exchange successful for {self.provider_name}" - ) + logger.debug(f"Authorization code exchange successful for {self.provider_name}") return token_data except OAuth2Error: raise except Exception as e: - logger.error( - f"Authorization code exchange failed for {self.provider_name}: {e}" - ) + logger.error(f"Authorization code exchange failed for {self.provider_name}: {e}") raise Exception(f"Authorization code exchange failed: {e}") # New flexible methods that subclasses can override @@ -456,9 +428,7 @@ class BaseOAuth2Authenticator(BaseAuthenticator): headers = self._get_auth_headers() # Ensure Content-Type is appropriate - if kwargs.get("use_json", False) or self._should_use_json_for_token_exchange( - **kwargs - ): + if kwargs.get("use_json", False) or self._should_use_json_for_token_exchange(**kwargs): headers["Content-Type"] = "application/json" else: headers["Content-Type"] = "application/x-www-form-urlencoded" @@ -510,9 +480,7 @@ class BaseOAuth2Authenticator(BaseAuthenticator): # Step 2: Extract login form action URL form_matches = re.findall(form_selector_pattern, auth_response.text) if not form_matches: - raise Exception( - f"Could not find login form using pattern: {form_selector_pattern}" - ) + raise Exception(f"Could not find login form using pattern: {form_selector_pattern}") login_url = html.unescape(form_matches[0]) @@ -538,19 +506,15 @@ class BaseOAuth2Authenticator(BaseAuthenticator): if not redirect_url: raise Exception("No redirect URL found after login") else: - redirect_response = session.get( - login_response.url, timeout=self.config.timeout - ) + redirect_response = session.get(login_response.url, timeout=self.config.timeout) redirect_url = redirect_response.url # Step 6: Validate and extract authorization code - is_valid, error_msg, authorization_code = ( - self.validate_authentication_response(redirect_url, state) + is_valid, error_msg, authorization_code = self.validate_authentication_response( + redirect_url, state ) if not is_valid: - raise Exception( - f"Authentication response validation failed: {error_msg}" - ) + raise Exception(f"Authentication response validation failed: {error_msg}") # Step 7: Exchange code for token token_data = self._exchange_authorization_code_for_token( @@ -645,30 +609,22 @@ class BaseOAuth2Authenticator(BaseAuthenticator): try: headers = self.config.get_base_headers() - response = self.http_manager.get( - main_page_url, operation="api", headers=headers - ) + response = self.http_manager.get(main_page_url, operation="api", headers=headers) response.raise_for_status() js_matches = re.findall(js_file_pattern, response.text) if not js_matches: - logger.warning( - f"Could not find JS file using pattern: {js_file_pattern}" - ) + logger.warning(f"Could not find JS file using pattern: {js_file_pattern}") return None js_url = main_page_url.rstrip("/") + "/" + js_matches[-1].lstrip("/") - js_response = self.http_manager.get( - js_url, operation="api", headers=headers - ) + js_response = self.http_manager.get(js_url, operation="api", headers=headers) js_response.raise_for_status() client_id_match = re.search(client_id_pattern, js_response.text) if not client_id_match: - logger.warning( - f"Could not find client ID using pattern: {client_id_pattern}" - ) + logger.warning(f"Could not find client ID using pattern: {client_id_pattern}") return None return client_id_match.group(1) @@ -700,9 +656,7 @@ class BaseOAuth2Authenticator(BaseAuthenticator): try: headers = self.config.get_base_headers() - response = self.http_manager.get( - main_page_url, operation="api", headers=headers - ) + response = self.http_manager.get(main_page_url, operation="api", headers=headers) response.raise_for_status() js_matches = re.findall(js_file_pattern, response.text) @@ -711,9 +665,7 @@ class BaseOAuth2Authenticator(BaseAuthenticator): js_url = main_page_url.rstrip("/") + "/" + js_matches[-1].lstrip("/") - js_response = self.http_manager.get( - js_url, operation="api", headers=headers - ) + js_response = self.http_manager.get(js_url, operation="api", headers=headers) js_response.raise_for_status() config_match = re.search(config_pattern, js_response.text) @@ -746,9 +698,7 @@ class BaseOAuth2Authenticator(BaseAuthenticator): from ...base.auth.credentials import UserPasswordCredentials # ALWAYS check stored credentials first for user credentials - stored_creds = self.settings_manager.get_provider_credentials( - self.provider_name - ) + stored_creds = self.settings_manager.get_provider_credentials(self.provider_name) if stored_creds and isinstance(stored_creds, UserPasswordCredentials): if stored_creds.validate(): return stored_creds @@ -762,9 +712,7 @@ class BaseOAuth2Authenticator(BaseAuthenticator): self.credentials = fallback return fallback - def get_bearer_token( - self, force_refresh: bool = False, force_upgrade: bool = False - ) -> str: + def get_bearer_token(self, force_refresh: bool = False, force_upgrade: bool = False) -> str: """ Get bearer token with automatic upgrade support @@ -788,9 +736,7 @@ class BaseOAuth2Authenticator(BaseAuthenticator): logger.debug(f"Token classified as: {current_token.auth_level.value}") # Check if upgrade is needed/requested - should_upgrade = force_upgrade or self._should_upgrade_to_user_token( - current_token - ) + should_upgrade = force_upgrade or self._should_upgrade_to_user_token(current_token) if should_upgrade and not force_refresh: logger.info( @@ -841,8 +787,7 @@ class BaseOAuth2Authenticator(BaseAuthenticator): # Main Authentication Flow def _perform_authentication(self) -> BaseAuthToken: """Complete OAuth2 authentication based on credential type""" - from ...base.auth.credentials import (ClientCredentials, - UserPasswordCredentials) + from ...base.auth.credentials import ClientCredentials, UserPasswordCredentials logger.debug( f"Starting OAuth2 authentication for {self.provider_name} with credential type: {type(self.credentials)}" @@ -852,9 +797,7 @@ class BaseOAuth2Authenticator(BaseAuthenticator): try: if isinstance(self.credentials, UserPasswordCredentials): - logger.info( - f"Attempting OAuth2 user authentication for {self.provider_name}" - ) + logger.info(f"Attempting OAuth2 user authentication for {self.provider_name}") token_data = self._perform_oauth_authorization_code_flow( self.credentials.username, self.credentials.password ) @@ -864,18 +807,14 @@ class BaseOAuth2Authenticator(BaseAuthenticator): ) token_data = self._perform_oauth_client_credentials_flow() else: - raise Exception( - f"Unsupported credential type for OAuth2: {type(self.credentials)}" - ) + raise Exception(f"Unsupported credential type for OAuth2: {type(self.credentials)}") token = self._create_token_from_response(token_data) logger.info(f"OAuth2 authentication successful for {self.provider_name}") return token except Exception as e: - logger.error( - f"Primary OAuth2 authentication failed for {self.provider_name}: {e}" - ) + logger.error(f"Primary OAuth2 authentication failed for {self.provider_name}: {e}") if isinstance(original_credentials, UserPasswordCredentials): logger.info( @@ -900,9 +839,7 @@ class BaseOAuth2Authenticator(BaseAuthenticator): # Token Management @abstractmethod - def _create_token_from_response( - self, response_data: Dict[str, Any] - ) -> BaseAuthToken: + def _create_token_from_response(self, response_data: Dict[str, Any]) -> BaseAuthToken: """Create provider-specific token from OAuth2 response""" pass @@ -924,9 +861,7 @@ class BaseOAuth2Authenticator(BaseAuthenticator): "pkce_support": True, "proxy_support": hasattr(self, "http_manager"), "credential_type": type(self.credentials).__name__, - "has_refresh_token": bool( - self._current_token and self._current_token.refresh_token - ), + "has_refresh_token": bool(self._current_token and self._current_token.refresh_token), } if self._current_token: diff --git a/lib/streaming_providers/base/auth/credential_manager.py b/lib/streaming_providers/base/auth/credential_manager.py index a78874e..3d88929 100644 --- a/lib/streaming_providers/base/auth/credential_manager.py +++ b/lib/streaming_providers/base/auth/credential_manager.py @@ -4,8 +4,7 @@ from typing import Any, Dict, List, Optional # Import centralized logger and VFS from ..utils.logger import logger -from .credentials import (BaseCredentials, ClientCredentials, - UserPasswordCredentials) +from .credentials import BaseCredentials, ClientCredentials, UserPasswordCredentials class CredentialManager: @@ -25,9 +24,7 @@ class CredentialManager: # Ensure base directory exists self.vfs.mkdirs("") - logger.debug( - f"CredentialManager initialized with VFS base: {self.vfs.base_path}" - ) + logger.debug(f"CredentialManager initialized with VFS base: {self.vfs.base_path}") @staticmethod def _get_credential_path(provider: str, country: Optional[str] = None) -> tuple: @@ -65,9 +62,7 @@ class CredentialManager: country_str = f" (country: {country})" if country else "" try: - logger.debug( - f"CredentialManager: Loading credentials for '{provider}{country_str}'" - ) + logger.debug(f"CredentialManager: Loading credentials for '{provider}{country_str}'") # Check if credentials file exists if not self.vfs.exists(self.credentials_file): @@ -76,20 +71,14 @@ class CredentialManager: ) return None - logger.debug( - f"CredentialManager: Reading credentials file: {self.credentials_file}" - ) + logger.debug(f"CredentialManager: Reading credentials file: {self.credentials_file}") data = self.vfs.read_json(self.credentials_file) if not data: - logger.debug( - f"CredentialManager: Credentials file is empty or invalid JSON" - ) + logger.debug(f"CredentialManager: Credentials file is empty or invalid JSON") return None - logger.debug( - f"CredentialManager: Available providers in file: {list(data.keys())}" - ) + logger.debug(f"CredentialManager: Available providers in file: {list(data.keys())}") # Try with country first (new format: {"provider": {"country": {...}}}) if country: @@ -107,9 +96,7 @@ class CredentialManager: logger.debug( f"CredentialManager: Found credentials with country nesting for '{provider}{country_str}'" ) - return self._create_credential_from_data( - provider, country, provider_data - ) + return self._create_credential_from_data(provider, country, provider_data) # Fallback: try without country (legacy format: {"provider": {...}}) logger.debug( @@ -130,9 +117,7 @@ class CredentialManager: logger.debug( f"CredentialManager: Found credentials in legacy format for '{provider}{country_str}'" ) - return self._create_credential_from_data( - provider, country, provider_data - ) + return self._create_credential_from_data(provider, country, provider_data) return None @@ -149,9 +134,7 @@ class CredentialManager: provider_data = provider_data[key] if not provider_data: - logger.debug( - f"CredentialManager: No data found for provider '{provider}'" - ) + logger.debug(f"CredentialManager: No data found for provider '{provider}'") return None return self._create_credential_from_data(provider, None, provider_data) @@ -224,9 +207,7 @@ class CredentialManager: creds = ClientCredentials( client_id=client_id, - client_secret=self._decode_password( - provider_data.get("client_secret", "") - ), + client_secret=self._decode_password(provider_data.get("client_secret", "")), grant_type=provider_data.get("grant_type", "client_credentials"), ) @@ -308,9 +289,7 @@ class CredentialManager: success = self.vfs.write_json(self.credentials_file, data) if success: - logger.info( - f"Successfully saved credentials for {provider}{country_str}" - ) + logger.info(f"Successfully saved credentials for {provider}{country_str}") # Verify by reading back verify_data = self.vfs.read_json(self.credentials_file) @@ -318,10 +297,7 @@ class CredentialManager: verify_current = verify_data found = True for key in keys_path: - if ( - not isinstance(verify_current, dict) - or key not in verify_current - ): + if not isinstance(verify_current, dict) or key not in verify_current: found = False break verify_current = verify_current[key] @@ -381,16 +357,12 @@ class CredentialManager: and country in data[provider] ): del data[provider][country] - logger.info( - f"Deleted stored credentials for {provider}{country_str}" - ) + logger.info(f"Deleted stored credentials for {provider}{country_str}") # If provider dict is now empty, remove it entirely if not data[provider]: del data[provider] - logger.debug( - f"Provider {provider} had no more countries, removed entirely" - ) + logger.debug(f"Provider {provider} had no more countries, removed entirely") return self.vfs.write_json(self.credentials_file, data) else: @@ -404,9 +376,7 @@ class CredentialManager: logger.info(f"Deleted all stored credentials for {provider}") return self.vfs.write_json(self.credentials_file, data) else: - logger.debug( - f"No stored credentials found to delete for {provider}" - ) + logger.debug(f"No stored credentials found to delete for {provider}") return True @@ -424,9 +394,7 @@ class CredentialManager: """ try: if not self.vfs.exists(self.credentials_file): - logger.debug( - "No credentials file exists, returning empty provider list" - ) + logger.debug("No credentials file exists, returning empty provider list") return [] data = self.vfs.read_json(self.credentials_file) @@ -549,9 +517,7 @@ class CredentialManager: # Non-country provider credentials = self.load_credentials(provider) if credentials: - export_data["credentials"][provider] = self._credential_to_dict( - credentials - ) + export_data["credentials"][provider] = self._credential_to_dict(credentials) else: # Export all providers for prov in self.list_providers(): @@ -569,9 +535,7 @@ class CredentialManager: # Non-country provider credentials = self.load_credentials(prov) if credentials: - export_data["credentials"][prov] = self._credential_to_dict( - credentials - ) + export_data["credentials"][prov] = self._credential_to_dict(credentials) return export_data @@ -624,9 +588,7 @@ class CredentialManager: credentials = self._dict_to_credential(cred_data) if credentials: - success = self.save_credentials( - provider, credentials, country - ) + success = self.save_credentials(provider, credentials, country) results[f"{provider}_{country}"] = success if success: @@ -645,9 +607,7 @@ class CredentialManager: results[provider] = success if success: - logger.info( - f"Successfully imported credentials for {provider}" - ) + logger.info(f"Successfully imported credentials for {provider}") else: logger.error(f"Failed to import credentials for {provider}") @@ -686,9 +646,7 @@ class CredentialManager: credentials_file_path = self.vfs.join_path(self.credentials_file) logger.info(f"Credentials file path: {credentials_file_path}") - logger.info( - f"Credentials file exists: {self.vfs.exists(self.credentials_file)}" - ) + logger.info(f"Credentials file exists: {self.vfs.exists(self.credentials_file)}") if self.vfs.exists(self.credentials_file): file_size = self.vfs.get_size(self.credentials_file) @@ -712,10 +670,7 @@ class CredentialManager: if has_countries: logger.info(f" {provider} (country-aware):") for country, cred_data in provider_data.items(): - if ( - isinstance(cred_data, dict) - and "type" in cred_data - ): + if isinstance(cred_data, dict) and "type" in cred_data: cred_type = cred_data.get("type") username = cred_data.get("username", "N/A") logger.info( @@ -729,9 +684,7 @@ class CredentialManager: f" {provider} (no country): type={cred_type}, username={username}" ) else: - logger.error( - "Credentials file contains invalid JSON or is empty" - ) + logger.error("Credentials file contains invalid JSON or is empty") else: logger.info("Credentials file is empty") else: diff --git a/lib/streaming_providers/base/auth/session_manager.py b/lib/streaming_providers/base/auth/session_manager.py index d92ab15..5964145 100644 --- a/lib/streaming_providers/base/auth/session_manager.py +++ b/lib/streaming_providers/base/auth/session_manager.py @@ -85,22 +85,16 @@ class SessionManager: for key in keys_path: if not isinstance(session_data, dict) or key not in session_data: - logger.info( - f"No session data found at path: {' -> '.join(keys_path)}" - ) + logger.info(f"No session data found at path: {' -> '.join(keys_path)}") return None session_data = session_data[key] if session_data: # Log what we found (without sensitive data) safe_data = self._get_safe_representation(session_data) - logger.info( - f"Loaded session data for {provider}{country_str}: {safe_data}" - ) + logger.info(f"Loaded session data for {provider}{country_str}: {safe_data}") else: - logger.info( - f"No session data found for {provider}{country_str} in session file" - ) + logger.info(f"No session data found for {provider}{country_str} in session file") return session_data @@ -173,9 +167,7 @@ class SessionManager: success = self.vfs.write_json(self.session_file, data) if success: - logger.info( - f"Successfully saved session data for {provider}{country_str}" - ) + logger.info(f"Successfully saved session data for {provider}{country_str}") # Verify by reading it back verify_data = self.vfs.read_json(self.session_file) @@ -183,10 +175,7 @@ class SessionManager: verify_current = verify_data found = True for key in keys_path: - if ( - not isinstance(verify_current, dict) - or key not in verify_current - ): + if not isinstance(verify_current, dict) or key not in verify_current: found = False break verify_current = verify_current[key] @@ -201,9 +190,7 @@ class SessionManager: ) return False else: - logger.error( - f"Verification failed: Could not read back session file" - ) + logger.error(f"Verification failed: Could not read back session file") return False else: logger.error(f"Failed to write session file") @@ -240,9 +227,7 @@ class SessionManager: country_str = f" (country: {country})" if country else "" try: - logger.debug( - f"Saving scoped token for {provider}{country_str}, scope: {scope}" - ) + logger.debug(f"Saving scoped token for {provider}{country_str}, scope: {scope}") # Load existing session data session_data = self.load_session(provider, country) or {} @@ -252,25 +237,17 @@ class SessionManager: # Log what we're saving safe_token_data = self._get_safe_representation(token_data) - logger.info( - f"Scoped token data for {provider}{country_str}/{scope}: {safe_token_data}" - ) + logger.info(f"Scoped token data for {provider}{country_str}/{scope}: {safe_token_data}") success = self.save_session(provider, session_data, country) if success: - logger.info( - f"Successfully saved scoped token for {provider}{country_str}/{scope}" - ) + logger.info(f"Successfully saved scoped token for {provider}{country_str}/{scope}") else: - logger.error( - f"Failed to save scoped token for {provider}{country_str}/{scope}" - ) + logger.error(f"Failed to save scoped token for {provider}{country_str}/{scope}") return success except Exception as e: - logger.error( - f"Error saving scoped token for {provider}{country_str}/{scope}: {e}" - ) + logger.error(f"Error saving scoped token for {provider}{country_str}/{scope}: {e}") return False def load_scoped_token( @@ -281,9 +258,7 @@ class SessionManager: """ country_str = f" (country: {country})" if country else "" - logger.debug( - f"Loading scoped token for {provider}{country_str}, scope: {scope}" - ) + logger.debug(f"Loading scoped token for {provider}{country_str}, scope: {scope}") session_data = self.load_session(provider, country) if not session_data: @@ -292,9 +267,7 @@ class SessionManager: # Check if scope exists if scope not in session_data: - logger.info( - f"No token found for scope '{scope}' in {provider}{country_str}" - ) + logger.info(f"No token found for scope '{scope}' in {provider}{country_str}") logger.debug(f"Available scopes: {list(session_data.keys())}") return None @@ -303,9 +276,7 @@ class SessionManager: # 🚨 FIX: Special handling for persona scope if scope == "persona": if not isinstance(token_data, dict) or "persona_token" not in token_data: - logger.warning( - f"Persona scope exists but doesn't contain valid persona token data" - ) + logger.warning(f"Persona scope exists but doesn't contain valid persona token data") return None # Persona tokens don't expire in the same way - they use expires_at field @@ -314,9 +285,7 @@ class SessionManager: # Validate it's actually token data (for other scopes) if not isinstance(token_data, dict) or "access_token" not in token_data: - logger.warning( - f"Scope '{scope}' exists but doesn't contain valid token data" - ) + logger.warning(f"Scope '{scope}' exists but doesn't contain valid token data") return None # Check access token expiration @@ -332,9 +301,7 @@ class SessionManager: ) return token_data # Return so caller can attempt refresh elif access_token_expired and refresh_token_expired: - logger.warning( - f"Both access and refresh tokens expired for scope '{scope}'" - ) + logger.warning(f"Both access and refresh tokens expired for scope '{scope}'") return None # Both expired, no point returning else: logger.info(f"Loaded valid token for {provider}{country_str}/{scope}") @@ -350,9 +317,7 @@ class SessionManager: return token_data @staticmethod - def _is_token_expired( - token_data: Dict[str, Any], buffer_seconds: int = 300 - ) -> bool: + def _is_token_expired(token_data: Dict[str, Any], buffer_seconds: int = 300) -> bool: """ Check if token is expired with buffer @@ -393,10 +358,7 @@ class SessionManager: # Format 2: yo_digital separate expiration for access_token # yo_digital tokens have separate expiry for access and refresh tokens - if ( - "access_token_expires_in" in token_data - and "access_token_issued_at" in token_data - ): + if "access_token_expires_in" in token_data and "access_token_issued_at" in token_data: expires_in = token_data.get("access_token_expires_in", 0) issued_at = token_data.get("access_token_issued_at", 0) expires_at = issued_at + expires_in @@ -430,9 +392,7 @@ class SessionManager: return False @staticmethod - def _is_refresh_token_expired( - token_data: Dict[str, Any], buffer_seconds: int = 300 - ) -> bool: + def _is_refresh_token_expired(token_data: Dict[str, Any], buffer_seconds: int = 300) -> bool: """ Check if refresh token is expired (for yo_digital tokens) @@ -447,10 +407,7 @@ class SessionManager: True if refresh token expired, False otherwise or if no refresh token """ # yo_digital format with separate refresh token expiry - if ( - "refresh_token_expires_in" in token_data - and "refresh_token_issued_at" in token_data - ): + if "refresh_token_expires_in" in token_data and "refresh_token_issued_at" in token_data: current_time = time.time() expires_in = token_data.get("refresh_token_expires_in", 0) issued_at = token_data.get("refresh_token_issued_at", 0) @@ -497,21 +454,15 @@ class SessionManager: # Log token info (without sensitive data) safe_token_data = self._get_safe_representation(token_data) - logger.info( - f"Token data to save for {provider}{country_str}: {safe_token_data}" - ) + logger.info(f"Token data to save for {provider}{country_str}: {safe_token_data}") session_data.update(token_data) success = self.save_session(provider, session_data, country) if success: - logger.info( - f"Successfully saved authentication token for {provider}{country_str}" - ) + logger.info(f"Successfully saved authentication token for {provider}{country_str}") else: - logger.error( - f"Failed to save authentication token for {provider}{country_str}" - ) + logger.error(f"Failed to save authentication token for {provider}{country_str}") return success except Exception as e: @@ -528,16 +479,12 @@ class SessionManager: session_data = self.load_session(provider, country) if not session_data: - logger.info( - f"No session data available for token loading for {provider}{country_str}" - ) + logger.info(f"No session data available for token loading for {provider}{country_str}") return None # Check if we have token data if "access_token" not in session_data: - logger.info( - f"No access token found in session data for {provider}{country_str}" - ) + logger.info(f"No access token found in session data for {provider}{country_str}") logger.debug(f"Available session keys: {list(session_data.keys())}") return None @@ -569,13 +516,9 @@ class SessionManager: device_id = str(uuid.uuid4()) session_data["device_id"] = device_id self.save_session(provider, session_data, country) - logger.info( - f"Generated new device ID for {provider}{country_str}: {device_id}" - ) + logger.info(f"Generated new device ID for {provider}{country_str}: {device_id}") else: - logger.debug( - f"Using existing device ID for {provider}{country_str}: {device_id}" - ) + logger.debug(f"Using existing device ID for {provider}{country_str}: {device_id}") return device_id @@ -602,15 +545,11 @@ class SessionManager: if not data[provider]: del data[provider] - logger.debug( - f"Provider {provider} had no more countries, removed entirely" - ) + logger.debug(f"Provider {provider} had no more countries, removed entirely") return self.vfs.write_json(self.session_file, data) else: - logger.debug( - f"No session data found to clear for {provider}{country_str}" - ) + logger.debug(f"No session data found to clear for {provider}{country_str}") else: if provider in data: del data[provider] @@ -625,9 +564,7 @@ class SessionManager: logger.error(f"Error clearing session for {provider}{country_str}: {e}") return False - def clear_scoped_token( - self, provider: str, scope: str, country: Optional[str] = None - ) -> bool: + def clear_scoped_token(self, provider: str, scope: str, country: Optional[str] = None) -> bool: """ Clear token for a specific scope @@ -652,15 +589,11 @@ class SessionManager: logger.info(f"Cleared scoped token for {provider}{country_str}/{scope}") return self.save_session(provider, session_data, country) else: - logger.debug( - f"No token found for scope '{scope}' in {provider}{country_str}" - ) + logger.debug(f"No token found for scope '{scope}' in {provider}{country_str}") return True except Exception as e: - logger.error( - f"Error clearing scoped token for {provider}{country_str}/{scope}: {e}" - ) + logger.error(f"Error clearing scoped token for {provider}{country_str}/{scope}: {e}") return False def clear_token(self, provider: str, country: Optional[str] = None) -> bool: @@ -670,9 +603,7 @@ class SessionManager: try: session_data = self.load_session(provider, country) if not session_data: - logger.debug( - f"No session data found, nothing to clear for {provider}{country_str}" - ) + logger.debug(f"No session data found, nothing to clear for {provider}{country_str}") return True # Remove token-related fields @@ -691,9 +622,7 @@ class SessionManager: fields_removed.append(field) if fields_removed: - logger.debug( - f"Cleared token fields {fields_removed} for {provider}{country_str}" - ) + logger.debug(f"Cleared token fields {fields_removed} for {provider}{country_str}") return self.save_session(provider, session_data, country) @@ -783,17 +712,11 @@ class SessionManager: logger.info(f" {provider} (country-aware):") for country, session_data in provider_data.items(): if isinstance(session_data, dict): - safe_repr = self._get_safe_representation( - session_data - ) + safe_repr = self._get_safe_representation(session_data) logger.info(f" {country}: {safe_repr}") else: - safe_repr = self._get_safe_representation( - provider_data - ) - logger.info( - f" {provider} (no country): {safe_repr}" - ) + safe_repr = self._get_safe_representation(provider_data) + logger.info(f" {provider} (no country): {safe_repr}") else: logger.error("Session file contains invalid JSON or is empty") else: diff --git a/lib/streaming_providers/base/catchup_operations.py b/lib/streaming_providers/base/catchup_operations.py index c843848..2efb22f 100644 --- a/lib/streaming_providers/base/catchup_operations.py +++ b/lib/streaming_providers/base/catchup_operations.py @@ -84,9 +84,7 @@ class CatchupOperations: provider_name, channel_id, country=country ) - def get_catchup_window( - self, provider_name: str, channel_id: Optional[str] = None - ) -> int: + def get_catchup_window(self, provider_name: str, channel_id: Optional[str] = None) -> int: """Get catchup window in hours.""" provider = self.registry.get_provider(provider_name) if not provider: @@ -116,8 +114,7 @@ class CatchupOperations: capabilities[name] = { "supports_catchup": provider.supports_catchup, "catchup_window": provider.catchup_window, - "catchup_enabled": provider.supports_catchup - and provider.catchup_window > 0, + "catchup_enabled": provider.supports_catchup and provider.catchup_window > 0, } except Exception as e: logger.warning(f"Error getting catchup for '{name}': {e}") diff --git a/lib/streaming_providers/base/channel_operations.py b/lib/streaming_providers/base/channel_operations.py index 9d6d57c..95df6ec 100644 --- a/lib/streaming_providers/base/channel_operations.py +++ b/lib/streaming_providers/base/channel_operations.py @@ -39,9 +39,7 @@ class ChannelOperations: return channels - def get_channel_manifest( - self, provider_name: str, channel_id: str, **kwargs - ) -> Optional[str]: + def get_channel_manifest(self, provider_name: str, channel_id: str, **kwargs) -> Optional[str]: """Get manifest URL for a specific channel.""" provider = self.registry.get_provider(provider_name) if not provider: @@ -49,9 +47,7 @@ class ChannelOperations: manifest_url = provider.get_manifest(channel_id, **kwargs) if manifest_url: - logger.debug( - f"Retrieved manifest for '{channel_id}' from '{provider_name}'" - ) + logger.debug(f"Retrieved manifest for '{channel_id}' from '{provider_name}'") return manifest_url def get_all_channels( diff --git a/lib/streaming_providers/base/drm/plugin_manager.py b/lib/streaming_providers/base/drm/plugin_manager.py index 7710d9a..be5c795 100644 --- a/lib/streaming_providers/base/drm/plugin_manager.py +++ b/lib/streaming_providers/base/drm/plugin_manager.py @@ -20,18 +20,14 @@ class DRMPluginManager: logger.debug("DRMPluginManager: Initialized with empty plugin registry") if auto_discover: - logger.debug( - "DRMPluginManager: Auto-discovery enabled, discovering plugins..." - ) + logger.debug("DRMPluginManager: Auto-discovery enabled, discovering plugins...") discovered = self.discover_plugins() if discovered: logger.debug( f"DRMPluginManager: Auto-discovery completed, {len(discovered)} plugins ready" ) else: - logger.debug( - "DRMPluginManager: Auto-discovery completed, no plugins found" - ) + logger.debug("DRMPluginManager: Auto-discovery completed, no plugins found") def register_plugin(self, plugin: DRMPlugin) -> None: """ @@ -88,9 +84,7 @@ class DRMPluginManager: # Check if plugins directory exists if not os.path.exists(plugins_dir): - logger.debug( - f"DRMPluginManager: Plugins directory does not exist: {plugins_dir}" - ) + logger.debug(f"DRMPluginManager: Plugins directory does not exist: {plugins_dir}") return [] logger.debug(f"DRMPluginManager: Scanning plugins directory: {plugins_dir}") @@ -125,9 +119,7 @@ class DRMPluginManager: module_name = os.path.splitext(filename)[0] # Import the module dynamically - spec = importlib.util.spec_from_file_location( - module_name, file_path - ) + spec = importlib.util.spec_from_file_location(module_name, file_path) if spec is None or spec.loader is None: logger.debug( f"DRMPluginManager: Could not create module spec for {filename}" @@ -149,9 +141,7 @@ class DRMPluginManager: plugin_classes.append((name, obj)) if not plugin_classes: - logger.debug( - f"DRMPluginManager: No DRMPlugin classes found in {filename}" - ) + logger.debug(f"DRMPluginManager: No DRMPlugin classes found in {filename}") continue logger.debug( @@ -182,17 +172,15 @@ class DRMPluginManager: ) except Exception as e: - error_msg = f"Failed to instantiate {class_name} from {filename}: {str(e)}" - failed_plugins.append( - (f"{filename}::{class_name}", error_msg) + error_msg = ( + f"Failed to instantiate {class_name} from {filename}: {str(e)}" ) + failed_plugins.append((f"{filename}::{class_name}", error_msg)) logger.warning(f"DRMPluginManager: {error_msg}") except Exception as e: traceback_str = traceback.format_exc() - error_msg = ( - f"Failed to process file {filename}: {str(e)}\n{traceback_str}" - ) + error_msg = f"Failed to process file {filename}: {str(e)}\n{traceback_str}" failed_plugins.append((filename, error_msg)) logger.warning(f"DRMPluginManager: {error_msg}") @@ -214,9 +202,7 @@ class DRMPluginManager: ) if failed_plugins: - logger.debug( - f"DRMPluginManager: {len(failed_plugins)} plugins/files failed to load:" - ) + logger.debug(f"DRMPluginManager: {len(failed_plugins)} plugins/files failed to load:") for plugin_name, error in failed_plugins: logger.debug(f" - {plugin_name}: {error}") @@ -262,9 +248,7 @@ class DRMPluginManager: for drm_system, plugin in self.plugins.items(): if drm_system == DRMSystem.GENERIC: generic_plugins.append(plugin) - logger.debug( - f"DRMPluginManager: Found generic plugin '{plugin.plugin_name}'" - ) + logger.debug(f"DRMPluginManager: Found generic plugin '{plugin.plugin_name}'") else: specific_plugins.append((drm_system, plugin)) @@ -303,9 +287,7 @@ class DRMPluginManager: # Process specific plugins final_configs = [] for config in processed_configs: - logger.debug( - f"DRMPluginManager: Processing DRM config for system: {config.system}" - ) + logger.debug(f"DRMPluginManager: Processing DRM config for system: {config.system}") # Find specific plugin for this DRM system plugin = self.plugins.get(config.system) @@ -326,9 +308,7 @@ class DRMPluginManager: f"DRMPluginManager: No PSSH data available for DRM system {config.system}" ) - processed_config = plugin.process_drm_config( - config, pssh_data, **kwargs - ) + processed_config = plugin.process_drm_config(config, pssh_data, **kwargs) if processed_config is not None: # Check for ClearKey and return immediately if found if processed_config.system == DRMSystem.CLEARKEY: @@ -379,9 +359,7 @@ class DRMPluginManager: f"DRMPluginManager: Retrieved plugin '{plugin.plugin_name}' for DRM system {drm_system}" ) else: - logger.debug( - f"DRMPluginManager: No plugin found for DRM system {drm_system}" - ) + logger.debug(f"DRMPluginManager: No plugin found for DRM system {drm_system}") return plugin def list_plugins(self) -> Dict[DRMSystem, str]: @@ -392,8 +370,7 @@ class DRMPluginManager: Dictionary mapping DRM systems to plugin names """ plugin_list = { - drm_system: plugin.plugin_name - for drm_system, plugin in self.plugins.items() + drm_system: plugin.plugin_name for drm_system, plugin in self.plugins.items() } logger.debug(f"DRMPluginManager: Currently registered plugins: {plugin_list}") return plugin_list diff --git a/lib/streaming_providers/base/drm_operations.py b/lib/streaming_providers/base/drm_operations.py index e76a0f0..5afb7b2 100644 --- a/lib/streaming_providers/base/drm_operations.py +++ b/lib/streaming_providers/base/drm_operations.py @@ -1,27 +1,62 @@ # ============================================================================ # streaming_providers/base/drm_operations.py """ -DRM-related operations. +DRM-related operations with caching and optimized PSSH extraction. """ -from typing import Dict, List +import time +from threading import Lock +from typing import Dict, List, Optional, Tuple from .drm import DRMPluginManager from .models import DRMSystem 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 DRMOperations: """Handles all DRM-related operations.""" - def __init__(self, registry): + def __init__(self, registry, cache_ttl: int = 3600): self.registry = registry self.drm_plugin_manager = DRMPluginManager() - logger.debug("DRMOperations: Initialized") + self.pssh_cache = PSSHCache(ttl_seconds=cache_ttl) + logger.debug("DRMOperations: Initialized with caching") - def get_channel_drm_configs( - self, provider_name: str, channel_id: str, **kwargs - ) -> List: + def get_channel_drm_configs(self, provider_name: str, channel_id: str, **kwargs) -> List: """Get DRM configurations for a channel.""" provider = self.registry.get_provider(provider_name) if not provider: @@ -35,7 +70,15 @@ class DRMOperations: if self._needs_pssh_extraction(drm_configs): manifest_url = provider.get_manifest(channel_id, **kwargs) if manifest_url: - pssh_data_list = self._extract_pssh_from_manifest(manifest_url) + # Try cache first + cache_key = f"{provider_name}:{channel_id}" + pssh_data_list = self.pssh_cache.get(cache_key) + + if pssh_data_list is None: + # Cache miss - extract and cache + pssh_data_list = self._extract_pssh_from_manifest(manifest_url) + if pssh_data_list: + self.pssh_cache.set(cache_key, pssh_data_list) # Process through plugins processed = self.drm_plugin_manager.process_drm_configs( @@ -50,12 +93,11 @@ class DRMOperations: config_systems = {config.system for config in drm_configs} plugin_systems = set(self.drm_plugin_manager.plugins.keys()) - return bool( - config_systems & plugin_systems or DRMSystem.GENERIC in plugin_systems - ) + return bool(config_systems & plugin_systems or DRMSystem.GENERIC in plugin_systems) - def _extract_pssh_from_manifest(self, manifest_url: str) -> List: - """Extract PSSH data from manifest.""" + @staticmethod + def _extract_pssh_from_manifest(manifest_url: str) -> List: + """Extract PSSH data from manifest with single segment fallback.""" import requests from .utils.manifest_parser import ManifestParser @@ -63,9 +105,40 @@ class DRMOperations: try: response = requests.get(manifest_url, timeout=10) response.raise_for_status() - return ManifestParser.extract_pssh_from_manifest( - response.text, manifest_url + manifest_content = response.text + + # Extract from manifest content + pssh_list = ManifestParser._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") + + # Extract ONE init segment URL + init_segment_url = ManifestParser.extract_single_init_segment_url( + manifest_content, manifest_url + ) + + if init_segment_url: + # Get expected system IDs + expected_system_ids = [p.system_id for p in pssh_list] if pssh_list else [] + + segment_pssh = ManifestParser._extract_from_single_segment( + init_segment_url, expected_system_ids + ) + + if segment_pssh: + # Merge or replace + return ManifestParser._merge_pssh_data(pssh_list, segment_pssh) + else: + logger.warning("Could not extract init segment URL from manifest") + + return pssh_list + except Exception as e: logger.warning(f"Failed to extract PSSH: {e}") return [] @@ -77,3 +150,7 @@ class DRMOperations: 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() diff --git a/lib/streaming_providers/base/epg/epg_cache.py b/lib/streaming_providers/base/epg/epg_cache.py index d942d66..5557bc9 100644 --- a/lib/streaming_providers/base/epg/epg_cache.py +++ b/lib/streaming_providers/base/epg/epg_cache.py @@ -97,9 +97,7 @@ class EPGCache: age = int(time.time()) - downloaded_at if age > self.CACHE_TTL_SECONDS: - logger.info( - f"EPGCache: Cache expired (age: {age}s, TTL: {self.CACHE_TTL_SECONDS}s)" - ) + logger.info(f"EPGCache: Cache expired (age: {age}s, TTL: {self.CACHE_TTL_SECONDS}s)") return False logger.debug(f"EPGCache: Cache valid (age: {age}s)") @@ -148,11 +146,7 @@ class EPGCache: # Determine if content is gzipped content_type = response.headers.get("Content-Type", "").lower() content_encoding = response.headers.get("Content-Encoding", "").lower() - is_gzipped = ( - "gzip" in content_encoding - or url.endswith(".gz") - or "gzip" in content_type - ) + is_gzipped = "gzip" in content_encoding or url.endswith(".gz") or "gzip" in content_type filename = self.EPG_GZ_FILE if is_gzipped else self.EPG_FILE diff --git a/lib/streaming_providers/base/epg/epg_manager.py b/lib/streaming_providers/base/epg/epg_manager.py index b528d2c..d6f9980 100644 --- a/lib/streaming_providers/base/epg/epg_manager.py +++ b/lib/streaming_providers/base/epg/epg_manager.py @@ -146,9 +146,7 @@ class EPGManager: # Step 1: Map to EPG channel ID epg_channel_id = self.mapping.get_epg_channel_id(provider_name, channel_id) if not epg_channel_id: - logger.warning( - f"EPGManager: No EPG mapping found for {provider_name}/{channel_id}" - ) + logger.warning(f"EPGManager: No EPG mapping found for {provider_name}/{channel_id}") return [] logger.debug(f"EPGManager: Mapped to EPG channel ID: {epg_channel_id}") @@ -218,9 +216,7 @@ class EPGManager: try: epg_channel_id = self.mapping.get_epg_channel_id(provider_name, channel_id) if not epg_channel_id: - logger.warning( - f"EPGManager: No EPG mapping found for {provider_name}/{channel_id}" - ) + logger.warning(f"EPGManager: No EPG mapping found for {provider_name}/{channel_id}") return [] xml_path = self.cache.get_or_download(self.epg_url) @@ -267,9 +263,7 @@ class EPGManager: Returns: Dictionary mapping channel IDs to their EPG entries (as dicts) """ - logger.info( - f"EPGManager: Getting EPG for all channels of provider '{provider_name}'" - ) + logger.info(f"EPGManager: Getting EPG for all channels of provider '{provider_name}'") result = {} @@ -277,9 +271,7 @@ class EPGManager: provider_mapping = self.mapping.get_provider_mapping(provider_name) if not provider_mapping: - logger.warning( - f"EPGManager: No channels mapped for provider '{provider_name}'" - ) + logger.warning(f"EPGManager: No channels mapped for provider '{provider_name}'") return result # Get EPG for each channel @@ -395,14 +387,8 @@ class EPGManager: addon = xbmcaddon.Addon() kodi_url = addon.getSetting("epg_xml_url") - if ( - kodi_url - and kodi_url.strip() - and kodi_url != "https://example.com/epg.xml.gz" - ): - logger.info( - f"EPGManager: Using EPG URL from Kodi settings: {kodi_url}" - ) + if kodi_url and kodi_url.strip() and kodi_url != "https://example.com/epg.xml.gz": + logger.info(f"EPGManager: Using EPG URL from Kodi settings: {kodi_url}") return kodi_url.strip() except Exception as e: logger.debug(f"EPGManager: Could not get EPG URL from Kodi settings: {e}") @@ -411,24 +397,16 @@ class EPGManager: try: env_manager = get_environment_manager() config_url = env_manager.get_config("epg_url") - if ( - config_url - and config_url.strip() - and config_url != "https://example.com/epg.xml.gz" - ): + if config_url and config_url.strip() and config_url != "https://example.com/epg.xml.gz": logger.info(f"EPGManager: Using EPG URL from config.json: {config_url}") return config_url.strip() except Exception as e: - logger.debug( - f"EPGManager: Could not get EPG URL from environment manager: {e}" - ) + logger.debug(f"EPGManager: Could not get EPG URL from environment manager: {e}") # 4. Environment variable env_url = os.environ.get("ULTIMATE_EPG_URL") if env_url and env_url.strip() and env_url != "https://example.com/epg.xml.gz": - logger.info( - f"EPGManager: Using EPG URL from environment variable: {env_url}" - ) + logger.info(f"EPGManager: Using EPG URL from environment variable: {env_url}") return env_url.strip() # 5. Try to get the last known URL from cache metadata @@ -451,12 +429,8 @@ class EPGManager: # 6. Default fallback (LAST RESORT - should rarely be used) default_url = "https://example.com/epg.xml.gz" - logger.warning( - f"EPGManager: No valid EPG URL found, using default: {default_url}" - ) - logger.warning( - "Please set ULTIMATE_EPG_URL environment variable or configure in settings!" - ) + logger.warning(f"EPGManager: No valid EPG URL found, using default: {default_url}") + logger.warning("Please set ULTIMATE_EPG_URL environment variable or configure in settings!") return default_url @staticmethod diff --git a/lib/streaming_providers/base/epg/epg_mapping.py b/lib/streaming_providers/base/epg/epg_mapping.py index c631b06..3ed1cc6 100644 --- a/lib/streaming_providers/base/epg/epg_mapping.py +++ b/lib/streaming_providers/base/epg/epg_mapping.py @@ -50,9 +50,7 @@ class EPGMapping: # Structure: {provider_name: {"mapping": {...}, "names": {...}}} self._cache: Dict[str, Dict[str, Any]] = {} - logger.info( - f"EPGMapping: Initialized with user path: {self.user_vfs.base_path}" - ) + logger.info(f"EPGMapping: Initialized with user path: {self.user_vfs.base_path}") # Copy default mapping files from addon resources self._copy_default_mapping_files() @@ -74,19 +72,13 @@ class EPGMapping: addon_info = bridge.get_addon_info() addon_path = addon_info.get("path") if addon_path: - default_dir = os.path.join( - addon_path, "resources", "config", "epg_mappings" - ) - logger.debug( - f"EPGMapping: Default mapping dir (Kodi): {default_dir}" - ) + default_dir = os.path.join(addon_path, "resources", "config", "epg_mappings") + logger.debug(f"EPGMapping: Default mapping dir (Kodi): {default_dir}") return default_dir # Fallback to standard filesystem addon_path = os.getcwd() - default_dir = os.path.join( - addon_path, "resources", "config", "epg_mappings" - ) + default_dir = os.path.join(addon_path, "resources", "config", "epg_mappings") logger.debug(f"EPGMapping: Default mapping dir (standard): {default_dir}") return default_dir @@ -94,9 +86,7 @@ class EPGMapping: logger.warning(f"EPGMapping: Could not determine default mapping dir: {e}") # Last resort fallback addon_path = os.getcwd() - default_dir = os.path.join( - addon_path, "resources", "config", "epg_mappings" - ) + default_dir = os.path.join(addon_path, "resources", "config", "epg_mappings") return default_dir def _copy_default_mapping_files(self) -> bool: @@ -114,9 +104,7 @@ class EPGMapping: # Check if any user mapping files already exist user_files = self.user_vfs.list_files(pattern=self.MAPPING_FILE_PATTERN) if user_files: - logger.info( - f"EPGMapping: Found {len(user_files)} existing user mapping files" - ) + logger.info(f"EPGMapping: Found {len(user_files)} existing user mapping files") return True else: logger.warning("EPGMapping: No default files and no user files found") @@ -124,14 +112,10 @@ class EPGMapping: try: # Get list of default mapping files - default_files = glob.glob( - os.path.join(default_dir, self.MAPPING_FILE_PATTERN) - ) + default_files = glob.glob(os.path.join(default_dir, self.MAPPING_FILE_PATTERN)) if not default_files: - logger.warning( - f"EPGMapping: No default mapping files found in {default_dir}" - ) + logger.warning(f"EPGMapping: No default mapping files found in {default_dir}") return False copied_count = 0 @@ -154,9 +138,7 @@ class EPGMapping: logger.info(f"EPGMapping: Copied default mapping: {filename}") copied_count += 1 else: - logger.error( - f"EPGMapping: Failed to write user mapping: {filename}" - ) + logger.error(f"EPGMapping: Failed to write user mapping: {filename}") except Exception as e: logger.error(f"EPGMapping: Failed to read/copy {filename}: {e}") @@ -215,18 +197,14 @@ class EPGMapping: # Check if file exists if not self.user_vfs.exists(filename): - logger.debug( - f"EPGMapping: No mapping file found for provider '{provider_name}'" - ) + logger.debug(f"EPGMapping: No mapping file found for provider '{provider_name}'") return None try: # Load mapping from file raw_mapping = self.user_vfs.read_json(filename) if raw_mapping is None: - logger.warning( - f"EPGMapping: Failed to parse mapping file for '{provider_name}'" - ) + logger.warning(f"EPGMapping: Failed to parse mapping file for '{provider_name}'") return None # Flatten the mapping @@ -248,9 +226,7 @@ class EPGMapping: epg_id = channel_data name = channel_id else: - logger.warning( - f"EPGMapping: Invalid format for {provider_name}/{channel_id}" - ) + logger.warning(f"EPGMapping: Invalid format for {provider_name}/{channel_id}") continue if epg_id: @@ -266,16 +242,13 @@ class EPGMapping: self._cache[provider_name] = cached_data logger.info( - f"EPGMapping: Loaded mapping for '{provider_name}' " - f"with {len(mapping)} channels" + f"EPGMapping: Loaded mapping for '{provider_name}' " f"with {len(mapping)} channels" ) return cached_data except Exception as e: - logger.error( - f"EPGMapping: Failed to load mapping for '{provider_name}': {e}" - ) + logger.error(f"EPGMapping: Failed to load mapping for '{provider_name}': {e}") return None def get_epg_channel_id(self, provider_name: str, channel_id: str) -> Optional[str]: @@ -305,9 +278,7 @@ class EPGMapping: epg_id = provider_data["mapping"].get(channel_id) if epg_id: - logger.debug( - f"EPGMapping: Mapped '{provider_name}/{channel_id}' -> '{epg_id}'" - ) + logger.debug(f"EPGMapping: Mapped '{provider_name}/{channel_id}' -> '{epg_id}'") return epg_id else: # Channel not in mapping - fall back to channel_id diff --git a/lib/streaming_providers/base/epg/epg_parser.py b/lib/streaming_providers/base/epg/epg_parser.py index 39931cd..1d28889 100644 --- a/lib/streaming_providers/base/epg/epg_parser.py +++ b/lib/streaming_providers/base/epg/epg_parser.py @@ -50,9 +50,7 @@ class EPGParser: provider_hash = int(provider_hash_obj.hexdigest()[:4], 16) self._provider_registry[provider_hash] = provider_name - logger.debug( - f"Registered provider '{provider_name}' with hash {provider_hash:04x}" - ) + logger.debug(f"Registered provider '{provider_name}' with hash {provider_hash:04x}") return provider_hash @@ -191,35 +189,23 @@ class EPGParser: if len(parts) >= 2: # Handle season number season_str = parts[0].split("/")[0] if parts[0] else None - series_num = ( - int(season_str) + 1 - if season_str and season_str.strip() - else None - ) + series_num = int(season_str) + 1 if season_str and season_str.strip() else None # Handle episode number - episode_str = ( - parts[1].split("/")[0] if len(parts) > 1 and parts[1] else None - ) + episode_str = parts[1].split("/")[0] if len(parts) > 1 and parts[1] else None episode_num = ( - int(episode_str) + 1 - if episode_str and episode_str.strip() - else None + int(episode_str) + 1 if episode_str and episode_str.strip() else None ) # Handle part number (optional) part_num = None if len(parts) >= 3 and parts[2]: part_str = parts[2].split("/")[0] - part_num = ( - int(part_str) + 1 if part_str and part_str.strip() else None - ) + part_num = int(part_str) + 1 if part_str and part_str.strip() else None return series_num, episode_num, part_num except (ValueError, IndexError) as e: - logger.debug( - f"Failed to parse xmltv_ns episode number '{episode_elem.text}': {e}" - ) + logger.debug(f"Failed to parse xmltv_ns episode number '{episode_elem.text}': {e}") elif system == "onscreen": # Format: "S05E12" or "5x12" @@ -304,11 +290,7 @@ class EPGParser: return EPGGenre.MUSICBALLETDANCE elif "arts" in genre_str or "culture" in genre_str or "art" in genre_str: return EPGGenre.ARTSCULTURE - elif ( - "educational" in genre_str - or "education" in genre_str - or "science" in genre_str - ): + elif "educational" in genre_str or "education" in genre_str or "science" in genre_str: return EPGGenre.EDUCATIONALSCIENCE elif ( "social" in genre_str @@ -362,14 +344,10 @@ class EPGParser: # Get title title_elem = programme_elem.find("title") - title = ( - title_elem.text if title_elem is not None and title_elem.text else "Unknown" - ) + title = title_elem.text if title_elem is not None and title_elem.text else "Unknown" # Generate broadcast ID (with provider encoding if available) - broadcast_id = EPGParser.generate_broadcast_id( - epg_channel_id, start_time, provider_name - ) + broadcast_id = EPGParser.generate_broadcast_id(epg_channel_id, start_time, provider_name) # Build EPGEntry with required fields entry_kwargs: Dict[str, Any] = { @@ -452,9 +430,7 @@ class EPGParser: if series_num is None: for ep_elem in episode_nums: if ep_elem.get("system") == "onscreen": - series_num, episode_num, part_num = EPGParser.parse_episode_num( - ep_elem - ) + series_num, episode_num, part_num = EPGParser.parse_episode_num(ep_elem) break if series_num is not None: @@ -555,9 +531,7 @@ class EPGParser: if provider_hash not in self._provider_registry: self._provider_registry[provider_hash] = provider_name - logger.debug( - f"Registered provider '{provider_name}' with hash {provider_hash:04x}" - ) + logger.debug(f"Registered provider '{provider_name}' with hash {provider_hash:04x}") programmes: List[EPGEntry] = [] @@ -571,16 +545,11 @@ class EPGParser: # Check if this programme matches our channel if elem.get("channel") == epg_channel_id: # Parse the programme WITHOUT calling register_provider again - epg_entry = self.parse_programme( - elem, epg_channel_id, provider_name - ) + epg_entry = self.parse_programme(elem, epg_channel_id, provider_name) if epg_entry: # Filter by time range using EPGEntry methods - if ( - start_time is not None - and epg_entry.end < start_time - ): + if start_time is not None and epg_entry.end < start_time: # Programme ends before requested range elem.clear() continue @@ -596,9 +565,7 @@ class EPGParser: # Clear element to free memory elem.clear() - logger.info( - f"Parsed {len(programmes)} programmes for channel '{epg_channel_id}'" - ) + logger.info(f"Parsed {len(programmes)} programmes for channel '{epg_channel_id}'") return programmes except ET.ParseError as e: diff --git a/lib/streaming_providers/base/epg_operations.py b/lib/streaming_providers/base/epg_operations.py index f413574..058f35e 100644 --- a/lib/streaming_providers/base/epg_operations.py +++ b/lib/streaming_providers/base/epg_operations.py @@ -18,9 +18,7 @@ class EPGOperations: self.epg_manager = EPGManager() logger.debug("EPGOperations: Initialized") - def get_channel_epg( - self, provider_name: str, channel_id: str, **kwargs - ) -> List[Dict]: + def get_channel_epg(self, provider_name: str, channel_id: str, **kwargs) -> List[Dict]: """Get EPG data for a specific channel.""" provider = self.registry.get_provider(provider_name) if not provider: diff --git a/lib/streaming_providers/base/manager.py b/lib/streaming_providers/base/manager.py index 786400d..8da3cab 100644 --- a/lib/streaming_providers/base/manager.py +++ b/lib/streaming_providers/base/manager.py @@ -50,9 +50,7 @@ class ProviderManager: def discover_all_providers(self, default_country: str = "DE") -> List[str]: return self.registry.discover_all_providers(default_country) - def discover_providers( - self, country: str = "DE", detected_providers: Dict = None - ) -> List[str]: + def discover_providers(self, country: str = "DE", detected_providers: Dict = None) -> List[str]: """Legacy method for backward compatibility.""" if not self.registry.provider_metadata: self.registry.discover_all_providers(country) @@ -71,9 +69,7 @@ class ProviderManager: return self.registry.reinitialize_provider(provider_name) def reinitialize_providers(self, provider_names: List[str]) -> Dict[str, bool]: - return { - name: self.registry.reinitialize_provider(name) for name in provider_names - } + return {name: self.registry.reinitialize_provider(name) for name in provider_names} def reinitialize_all_providers(self) -> Dict[str, bool]: enabled = self.registry.get_enabled_providers() @@ -111,12 +107,8 @@ class ProviderManager: ) -> List[StreamingChannel]: return self.channel_ops.get_channels(provider_name, fetch_manifests, **kwargs) - def get_channel_manifest( - self, provider_name: str, channel_id: str, **kwargs - ) -> Optional[str]: - return self.channel_ops.get_channel_manifest( - provider_name, channel_id, **kwargs - ) + def get_channel_manifest(self, provider_name: str, channel_id: str, **kwargs) -> Optional[str]: + return self.channel_ops.get_channel_manifest(provider_name, channel_id, **kwargs) def get_all_channels( self, fetch_manifests: bool = True, **kwargs @@ -127,9 +119,7 @@ class ProviderManager: # EPG OPERATIONS (delegate to EPGOperations) # ========================================================================== - def get_channel_epg( - self, provider_name: str, channel_id: str, **kwargs - ) -> List[Dict]: + def get_channel_epg(self, provider_name: str, channel_id: str, **kwargs) -> List[Dict]: return self.epg_ops.get_channel_epg(provider_name, channel_id, **kwargs) def get_provider_epg_xmltv(self, provider_name: str, **kwargs) -> Optional[str]: @@ -154,9 +144,7 @@ class ProviderManager: # DRM OPERATIONS (delegate to DRMOperations) # ========================================================================== - def get_channel_drm_configs( - self, provider_name: str, channel_id: str, **kwargs - ) -> List: + def get_channel_drm_configs(self, provider_name: str, channel_id: str, **kwargs) -> List: return self.drm_ops.get_channel_drm_configs(provider_name, channel_id, **kwargs) def list_drm_plugins(self) -> Dict: @@ -195,9 +183,7 @@ class ProviderManager: provider_name, channel_id, start_time, end_time, epg_id, country ) - def get_catchup_window( - self, provider_name: str, channel_id: Optional[str] = None - ) -> int: + def get_catchup_window(self, provider_name: str, channel_id: Optional[str] = None) -> int: return self.catchup_ops.get_catchup_window(provider_name, channel_id) def supports_catchup(self, provider_name: str) -> bool: @@ -220,9 +206,7 @@ class ProviderManager: return self.subscription_ops.get_available_packages(provider_name, **kwargs) def is_channel_accessible(self, provider_name: str, channel_id: str, **kwargs): - return self.subscription_ops.is_channel_accessible( - provider_name, channel_id, **kwargs - ) + return self.subscription_ops.is_channel_accessible(provider_name, channel_id, **kwargs) # ========================================================================== # UTILITY METHODS (remain in ProviderManager as helpers) @@ -237,9 +221,7 @@ class ProviderManager: http_manager = provider.http_manager if not http_manager: - logger.warning( - f"ProviderManager: Provider '{provider_name}' has no HTTP manager" - ) + logger.warning(f"ProviderManager: Provider '{provider_name}' has no HTTP manager") return None logger.debug(f"ProviderManager: Retrieved HTTP manager for '{provider_name}'") @@ -280,9 +262,7 @@ class ProviderManager: available = self.registry.get_enabled_providers() if not available: - logger.warning( - "ProviderManager: No enabled providers available for selection" - ) + logger.warning("ProviderManager: No enabled providers available for selection") return [] selected = [] diff --git a/lib/streaming_providers/base/models/__init__.py b/lib/streaming_providers/base/models/__init__.py index 9f8faa2..087bc73 100644 --- a/lib/streaming_providers/base/models/__init__.py +++ b/lib/streaming_providers/base/models/__init__.py @@ -1,6 +1,5 @@ # streaming_providers/base/models/__init__.py -from .drm_models import (DRMConfig, DRMSystem, LicenseConfig, - LicenseUnwrapperParams) +from .drm_models import DRMConfig, DRMSystem, LicenseConfig, LicenseUnwrapperParams from .streaming_channel import StreamingChannel from .subscription import SubscriptionPackage, UserSubscription diff --git a/lib/streaming_providers/base/models/auth.py b/lib/streaming_providers/base/models/auth.py index d94bcda..c0637d9 100644 --- a/lib/streaming_providers/base/models/auth.py +++ b/lib/streaming_providers/base/models/auth.py @@ -57,9 +57,7 @@ class AuthStatus: result = { "provider": ( - f"{self.provider_name}_{self.country}" - if self.country - else self.provider_name + f"{self.provider_name}_{self.country}" if self.country else self.provider_name ), "provider_name": self.provider_name, "provider_label": self.provider_label, @@ -101,9 +99,7 @@ class AuthStatus: result["refresh_token_expires_at"] = self.refresh_token_expires_at if self.refresh_token_expires_in_seconds is not None: - result["refresh_token_expires_in_seconds"] = ( - self.refresh_token_expires_in_seconds - ) + result["refresh_token_expires_in_seconds"] = self.refresh_token_expires_in_seconds return result diff --git a/lib/streaming_providers/base/models/drm_models.py b/lib/streaming_providers/base/models/drm_models.py index 4ebe535..692ce49 100644 --- a/lib/streaming_providers/base/models/drm_models.py +++ b/lib/streaming_providers/base/models/drm_models.py @@ -45,23 +45,17 @@ class WrapperType(str, Enum): NONE = "none" -class UnwrapperType(str, Enum): - AUTO = "auto" - BASE64 = "base64" - JSON = "json" - XML = "xml" - NONE = "none" - - @dataclass class PSSHData: - """ - Protection System Specific Header data for DRM systems - """ + system_id: str + pssh_box: str = "" # Base64 encoded PSSH box + key_ids: List[str] = field(default_factory=list) + source: str = "manifest" # "manifest", "mp4_segment", "unknown" - system_id: str # UUID of the DRM system - pssh_box: str # Base64 encoded PSSH box data - key_ids: List[str] = field(default_factory=list) # Optional key IDs + @property + def needs_extraction(self) -> bool: + """Check if PSSH/key_ids need to be extracted from segments""" + return not self.pssh_box or not self.key_ids @property def drm_system(self) -> Optional[DRMSystem]: @@ -73,13 +67,12 @@ class PSSHData: if not self.system_id: raise ValueError("system_id is required") - if not self.pssh_box: - raise ValueError("pssh_box is required") - - try: - base64.b64decode(self.pssh_box) - except Exception: - raise ValueError("pssh_box must be valid base64") + # FIX: pssh_box can be empty (PSSH in segments) + if self.pssh_box: # Only validate if not empty + try: + base64.b64decode(self.pssh_box) + except Exception: + raise ValueError("pssh_box must be valid base64") # Validate key IDs if present for kid in self.key_ids: @@ -87,6 +80,14 @@ class PSSHData: raise ValueError(f"Invalid key ID format: {kid}") +class UnwrapperType(str, Enum): + AUTO = "auto" + BASE64 = "base64" + JSON = "json" + XML = "xml" + NONE = "none" + + @dataclass class LicenseUnwrapperParams: path_data: Optional[str] = None @@ -136,9 +137,7 @@ class LicenseConfig: """Helper to ensure req_data is base64 encoded""" import base64 - req_data_encoded = base64.b64encode(req_data_template.encode("utf-8")).decode( - "utf-8" - ) + req_data_encoded = base64.b64encode(req_data_template.encode("utf-8")).decode("utf-8") return cls(req_data=req_data_encoded, **kwargs) @@ -181,9 +180,7 @@ class DRMConfig: license_dict["unwrapper"] = self.license.unwrapper if self.license.unwrapper_params: license_dict["unwrapper_params"] = { - k: v - for k, v in vars(self.license.unwrapper_params).items() - if v is not None + k: v for k, v in vars(self.license.unwrapper_params).items() if v is not None } if self.license.keyids: license_dict["keyids"] = self.license.keyids diff --git a/lib/streaming_providers/base/models/epg_models.py b/lib/streaming_providers/base/models/epg_models.py index f7c5096..046348d 100644 --- a/lib/streaming_providers/base/models/epg_models.py +++ b/lib/streaming_providers/base/models/epg_models.py @@ -400,16 +400,10 @@ class EPGEntry: """ if not text: return [] - return [ - item.strip() - for item in text.split(EPG_STRING_TOKEN_SEPARATOR) - if item.strip() - ] + return [item.strip() for item in text.split(EPG_STRING_TOKEN_SEPARATOR) if item.strip()] @staticmethod - def encode_broadcast_id( - provider_name: str, channel_id: str, start_time: int - ) -> int: + def encode_broadcast_id(provider_name: str, channel_id: str, start_time: int) -> int: """ Generate deterministic broadcast ID with encoded provider information. @@ -526,18 +520,12 @@ class EPGEntry: raise ValueError("end time must be after start time") # Validate episode numbers if set - if ( - self.season_number is not None - and self.season_number < EPG_TAG_INVALID_SERIES_EPISODE - ): + if self.season_number is not None and self.season_number < EPG_TAG_INVALID_SERIES_EPISODE: raise ValueError( f"season_number must be >= EPG_TAG_INVALID_SERIES_EPISODE ({EPG_TAG_INVALID_SERIES_EPISODE})" ) - if ( - self.episode_number is not None - and self.episode_number < EPG_TAG_INVALID_SERIES_EPISODE - ): + if self.episode_number is not None and self.episode_number < EPG_TAG_INVALID_SERIES_EPISODE: raise ValueError( f"episode_number must be >= EPG_TAG_INVALID_SERIES_EPISODE ({EPG_TAG_INVALID_SERIES_EPISODE})" ) diff --git a/lib/streaming_providers/base/models/proxy_models.py b/lib/streaming_providers/base/models/proxy_models.py index e67bec6..d15a812 100644 --- a/lib/streaming_providers/base/models/proxy_models.py +++ b/lib/streaming_providers/base/models/proxy_models.py @@ -144,9 +144,7 @@ class ProxyConfig: """Create ProxyConfig from dictionary""" auth = None if "auth" in data and data["auth"]: - auth = ProxyAuth( - username=data["auth"]["username"], password=data["auth"]["password"] - ) + auth = ProxyAuth(username=data["auth"]["username"], password=data["auth"]["password"]) scope_data = data.get("scope", {}) scope = ProxyScope( @@ -169,9 +167,7 @@ class ProxyConfig: ) @classmethod - def from_url( - cls, proxy_url: str, scope: Optional[ProxyScope] = None - ) -> "ProxyConfig": + def from_url(cls, proxy_url: str, scope: Optional[ProxyScope] = None) -> "ProxyConfig": """ Create ProxyConfig from proxy URL string @@ -252,9 +248,7 @@ class RequestConfig: } # Add proxy if configured and enabled for this operation - if self.proxy_config and self.proxy_config.scope.should_use_proxy_for( - operation - ): + if self.proxy_config and self.proxy_config.scope.should_use_proxy_for(operation): kwargs["proxies"] = self.proxy_config.to_proxy_dict() return kwargs diff --git a/lib/streaming_providers/base/models/streaming_channel.py b/lib/streaming_providers/base/models/streaming_channel.py index 2edb743..1bcb01a 100644 --- a/lib/streaming_providers/base/models/streaming_channel.py +++ b/lib/streaming_providers/base/models/streaming_channel.py @@ -282,9 +282,7 @@ class StreamingChannel: self.is_radio = True # Update quality if not set - if not self.quality or self.quality.upper() not in [ - q.value for q in Quality - ]: + if not self.quality or self.quality.upper() not in [q.value for q in Quality]: self.quality = "AUDIO" # Update content_type if it's still LIVE diff --git a/lib/streaming_providers/base/network/http_manager.py b/lib/streaming_providers/base/network/http_manager.py index 451f600..594b3ff 100644 --- a/lib/streaming_providers/base/network/http_manager.py +++ b/lib/streaming_providers/base/network/http_manager.py @@ -122,9 +122,7 @@ class HTTPManager: """Perform DELETE request with proxy support""" return self._make_request("DELETE", url, operation, **kwargs) - def _make_request( - self, method: str, url: str, operation: str, **kwargs - ) -> requests.Response: + def _make_request(self, method: str, url: str, operation: str, **kwargs) -> requests.Response: """ Make HTTP request with full configuration support """ @@ -182,9 +180,7 @@ class HTTPManager: ) raise - def _log_request( - self, method: str, url: str, operation: str, kwargs: Dict[str, Any] - ) -> None: + def _log_request(self, method: str, url: str, operation: str, kwargs: Dict[str, Any]) -> None: """Log request details with comprehensive proxy information""" # Build proxy information string @@ -192,13 +188,9 @@ class HTTPManager: if self.config.proxy_config: if self.config.proxy_config.scope.should_use_proxy_for(operation): # Proxy is configured and will be used - proxy_host = ( - f"{self.config.proxy_config.host}:{self.config.proxy_config.port}" - ) + proxy_host = f"{self.config.proxy_config.host}:{self.config.proxy_config.port}" proxy_type = self.config.proxy_config.proxy_type.value - has_auth = ( - "authenticated" if self.config.proxy_config.auth else "no-auth" - ) + has_auth = "authenticated" if self.config.proxy_config.auth else "no-auth" proxy_info = f" [proxy: {proxy_type}://{proxy_host} ({has_auth})]" else: # Proxy is configured but not used for this operation @@ -348,9 +340,7 @@ class HTTPManagerFactory: return HTTPManager(config) @staticmethod - def create_with_proxy_url( - provider_name: str, proxy_url: str, **kwargs - ) -> HTTPManager: + def create_with_proxy_url(provider_name: str, proxy_url: str, **kwargs) -> HTTPManager: """ Create HTTP manager with proxy from URL string @@ -363,6 +353,4 @@ class HTTPManagerFactory: Configured HTTPManager instance """ proxy_config = ProxyConfig.from_url(proxy_url) - return HTTPManagerFactory.create_for_provider( - provider_name, proxy_config, **kwargs - ) + return HTTPManagerFactory.create_for_provider(provider_name, proxy_config, **kwargs) diff --git a/lib/streaming_providers/base/network/proxy_manager.py b/lib/streaming_providers/base/network/proxy_manager.py index 6dbf2ef..64c39cb 100644 --- a/lib/streaming_providers/base/network/proxy_manager.py +++ b/lib/streaming_providers/base/network/proxy_manager.py @@ -62,9 +62,7 @@ class ProxyConfigManager: vfs_data = self.vfs.read_json(self.proxy_config_file) if vfs_data is None: - logger.debug( - "No proxy configuration file found, starting with empty config" - ) + logger.debug("No proxy configuration file found, starting with empty config") return # Load global configuration @@ -78,27 +76,18 @@ class ProxyConfigManager: try: # Check if this is a nested (country-aware) structure if isinstance(provider_data, dict) and any( - isinstance(v, dict) and len(k) <= 3 - for k, v in provider_data.items() + isinstance(v, dict) and len(k) <= 3 for k, v in provider_data.items() ): # Country-aware structure for country, config_data in provider_data.items(): if isinstance(config_data, dict) and len(country) <= 3: cache_key = f"{provider_name}_{country}" - self._config_cache[cache_key] = ProxyConfig.from_dict( - config_data - ) - logger.debug( - f"Loaded proxy config for {provider_name} ({country})" - ) + self._config_cache[cache_key] = ProxyConfig.from_dict(config_data) + logger.debug(f"Loaded proxy config for {provider_name} ({country})") else: # Flat structure (no country) - self._config_cache[provider_name] = ProxyConfig.from_dict( - provider_data - ) - logger.debug( - f"Loaded proxy configuration for provider: {provider_name}" - ) + self._config_cache[provider_name] = ProxyConfig.from_dict(provider_data) + logger.debug(f"Loaded proxy configuration for provider: {provider_name}") except Exception as e: logger.error(f"Error loading proxy config for {provider_name}: {e}") @@ -230,22 +219,16 @@ class ProxyConfigManager: cache_key, _ = self._get_proxy_path(provider_name, country) self._config_cache[cache_key] = proxy_config country_str = f" ({country})" if country else "" - logger.info( - f"Set proxy configuration for provider: {provider_name}{country_str}" - ) + logger.info(f"Set proxy configuration for provider: {provider_name}{country_str}") return self._save_configurations() except Exception as e: country_str = f" ({country})" if country else "" - logger.error( - f"Error setting proxy configuration for {provider_name}{country_str}: {e}" - ) + logger.error(f"Error setting proxy configuration for {provider_name}{country_str}: {e}") return False - def remove_proxy_config( - self, provider_name: str, country: Optional[str] = None - ) -> bool: + def remove_proxy_config(self, provider_name: str, country: Optional[str] = None) -> bool: """ Remove proxy configuration for a provider @@ -266,9 +249,7 @@ class ProxyConfigManager: cache_key, _ = self._get_proxy_path(provider_name, country) if cache_key in self._config_cache: del self._config_cache[cache_key] - logger.info( - f"Removed proxy configuration for {provider_name} ({country})" - ) + logger.info(f"Removed proxy configuration for {provider_name} ({country})") else: logger.warning( f"No proxy configuration found for {provider_name} ({country})" @@ -285,13 +266,9 @@ class ProxyConfigManager: if keys_to_remove: for key in keys_to_remove: del self._config_cache[key] - logger.info( - f"Removed all proxy configurations for {provider_name}" - ) + logger.info(f"Removed all proxy configurations for {provider_name}") else: - logger.warning( - f"No proxy configuration found for {provider_name}" - ) + logger.warning(f"No proxy configuration found for {provider_name}") return True return self._save_configurations() @@ -372,9 +349,7 @@ class ProxyConfigManager: manager.close() return result - def get_proxy_info( - self, provider_name: str, country: Optional[str] = None - ) -> Dict[str, Any]: + def get_proxy_info(self, provider_name: str, country: Optional[str] = None) -> Dict[str, Any]: """ Get detailed information about proxy configuration @@ -431,9 +406,7 @@ class ProxyConfigManager: Path to exported file """ if not export_path: - export_path = str( - self.config_dir / f"proxy_config_backup_{int(time.time())}.json" - ) + export_path = str(self.config_dir / f"proxy_config_backup_{int(time.time())}.json") try: # Create export data with country-aware structure @@ -459,9 +432,7 @@ class ProxyConfigManager: "version": "1.1", "source": "streaming_providers_proxy_manager", }, - "global": ( - self._global_config.to_dict() if self._global_config else None - ), + "global": (self._global_config.to_dict() if self._global_config else None), "providers": providers_export, } @@ -505,30 +476,23 @@ class ProxyConfigManager: try: # Check if nested (country-aware) if isinstance(provider_data, dict) and any( - isinstance(v, dict) and len(k) <= 3 - for k, v in provider_data.items() + isinstance(v, dict) and len(k) <= 3 for k, v in provider_data.items() ): # Country-aware structure for country, config_data in provider_data.items(): if isinstance(config_data, dict) and len(country) <= 3: cache_key = f"{provider_name}_{country}" - self._config_cache[cache_key] = ProxyConfig.from_dict( - config_data - ) + self._config_cache[cache_key] = ProxyConfig.from_dict(config_data) else: # Flat structure - self._config_cache[provider_name] = ProxyConfig.from_dict( - provider_data - ) + self._config_cache[provider_name] = ProxyConfig.from_dict(provider_data) except Exception as e: logger.error(f"Error importing config for {provider_name}: {e}") # Save the imported configurations success = self._save_configurations() if success: - logger.info( - f"Successfully imported proxy configurations from {import_path}" - ) + logger.info(f"Successfully imported proxy configurations from {import_path}") return success except Exception as e: @@ -583,9 +547,7 @@ class ProxyConfigManager: """ results = {} for provider_name in provider_names: - results[provider_name] = self.set_proxy_config( - provider_name, proxy_config, country - ) + results[provider_name] = self.set_proxy_config(provider_name, proxy_config, country) return results def get_all_proxy_info(self) -> Dict[str, Dict[str, Any]]: diff --git a/lib/streaming_providers/base/provider.py b/lib/streaming_providers/base/provider.py index c8b97a8..12d7325 100644 --- a/lib/streaming_providers/base/provider.py +++ b/lib/streaming_providers/base/provider.py @@ -264,9 +264,7 @@ class StreamingProvider(ABC): """Get manifest URL for a specific channel by ID""" return None - def get_dynamic_manifest_params( - self, channel: StreamingChannel, **kwargs - ) -> Optional[str]: + def get_dynamic_manifest_params(self, channel: StreamingChannel, **kwargs) -> Optional[str]: """Optional: Get dynamic manifest parameters for a channel""" return None @@ -283,9 +281,7 @@ class StreamingProvider(ABC): def to_json(self, channels: List[StreamingChannel] = None, indent: int = 2) -> str: """Convert to JSON string""" - return json.dumps( - self.to_output_format(channels), indent=indent, ensure_ascii=False - ) + return json.dumps(self.to_output_format(channels), indent=indent, ensure_ascii=False) # ============================================================================ # HTTP MANAGER SETUP (Already Implemented) @@ -351,9 +347,7 @@ class StreamingProvider(ABC): ) -> Optional[ProxyConfig]: """Resolve proxy configuration from multiple sources with priority""" if proxy_config is not None: - logger.debug( - f"{provider_name}: Using directly provided proxy configuration" - ) + logger.debug(f"{provider_name}: Using directly provided proxy configuration") return proxy_config if proxy_url: @@ -361,9 +355,7 @@ class StreamingProvider(ABC): logger.debug(f"{provider_name}: Creating proxy config from URL") return ProxyConfig.from_url(proxy_url) except Exception as e: - logger.warning( - f"{provider_name}: Failed to parse proxy URL '{proxy_url}': {e}" - ) + logger.warning(f"{provider_name}: Failed to parse proxy URL '{proxy_url}': {e}") try: from .network import ProxyConfigManager @@ -375,14 +367,10 @@ class StreamingProvider(ABC): logger.debug(f"{provider_name}: Using proxy from ProxyConfigManager") return managed_proxy else: - logger.debug( - f"{provider_name}: No proxy configuration found in ProxyConfigManager" - ) + logger.debug(f"{provider_name}: No proxy configuration found in ProxyConfigManager") except Exception as e: - logger.warning( - f"{provider_name}: Could not load proxy from ProxyConfigManager: {e}" - ) + logger.warning(f"{provider_name}: Could not load proxy from ProxyConfigManager: {e}") logger.debug(f"{provider_name}: No proxy configuration available") return None @@ -395,9 +383,7 @@ class StreamingProvider(ABC): info_parts = [f"HTTP manager initialized for '{provider_name}'"] if proxy_config: - proxy_type = ( - proxy_config.proxy_type.value if proxy_config.proxy_type else "http" - ) + proxy_type = proxy_config.proxy_type.value if proxy_config.proxy_type else "http" proxy_host = f"{proxy_config.host}:{proxy_config.port}" has_auth = "authenticated" if proxy_config.auth else "no-auth" info_parts.append(f"proxy: {proxy_type}://{proxy_host} ({has_auth})") @@ -429,14 +415,10 @@ class StreamingProvider(ABC): if http_manager and hasattr(authenticator, "http_manager"): if authenticator.http_manager is None: - logger.debug( - f"{self.provider_name}: Sharing HTTP manager with authenticator" - ) + logger.debug(f"{self.provider_name}: Sharing HTTP manager with authenticator") authenticator.http_manager = http_manager else: - logger.debug( - f"{self.provider_name}: Using authenticator's existing HTTP manager" - ) + logger.debug(f"{self.provider_name}: Using authenticator's existing HTTP manager") http_manager = authenticator.http_manager return http_manager @@ -512,9 +494,7 @@ class StreamingProvider(ABC): elif self.authenticator is not None: token = self.authenticator.get_bearer_token(**kwargs) else: - logger.warning( - f"{self.provider_name}: No token getter or authenticator available" - ) + logger.warning(f"{self.provider_name}: No token getter or authenticator available") token = None # Add auth header based on type @@ -643,17 +623,13 @@ class StreamingProvider(ABC): try: # Try to get token based on type if token_type == "bearer": - return self.authenticator.get_bearer_token( - force_refresh=force_refresh, **kwargs - ) + return self.authenticator.get_bearer_token(force_refresh=force_refresh, **kwargs) elif hasattr(self.authenticator, f"get_{token_type}_token"): getter = getattr(self.authenticator, f"get_{token_type}_token") return getter(force_refresh=force_refresh, **kwargs) else: # Default to bearer token - return self.authenticator.get_bearer_token( - force_refresh=force_refresh, **kwargs - ) + return self.authenticator.get_bearer_token(force_refresh=force_refresh, **kwargs) except Exception as e: logger.error(f"{self.provider_name}: Error getting {token_type} token: {e}") return None @@ -723,9 +699,7 @@ class StreamingProvider(ABC): Override in subclass if catchup requires different DRM configuration. """ if not self.supports_catchup: - logger.debug( - f"{self.provider_name}: Catchup not supported, falling back to live DRM" - ) + logger.debug(f"{self.provider_name}: Catchup not supported, falling back to live DRM") return self.get_drm(channel_id, **kwargs) logger.debug( @@ -1060,16 +1034,13 @@ class StreamingProvider(ABC): ValueError: If auth_type is not supported """ if not self.validate_auth_type(auth_type): - raise ValueError( - f"Auth type '{auth_type}' not supported by {self.provider_name}" - ) + raise ValueError(f"Auth type '{auth_type}' not supported by {self.provider_name}") requirements = { "auth_type": auth_type, "needs_storage": auth_type in ["user_credentials", "client_credentials"], "provides_token": auth_type != "anonymous", - "user_interaction_required": auth_type - in ["user_credentials", "device_registration"], + "user_interaction_required": auth_type in ["user_credentials", "device_registration"], } # Type-specific details @@ -1116,9 +1087,7 @@ class StreamingProvider(ABC): def requires_stored_credentials(self) -> bool: """True if provider needs credentials stored in settings.""" credential_types = ["user_credentials", "client_credentials"] - return any( - auth_type in credential_types for auth_type in self.supported_auth_types - ) + return any(auth_type in credential_types for auth_type in self.supported_auth_types) # ===== AUTHENTICATION PROPERTIES ===== @@ -1207,8 +1176,9 @@ class StreamingProvider(ABC): Returns: AuthStatus object """ - from ..providers.auth_builder import \ - AuthStatusBuilder # Import here to avoid circular imports + from ..providers.auth_builder import ( + AuthStatusBuilder, + ) # Import here to avoid circular imports return AuthStatusBuilder.for_provider(self, context) diff --git a/lib/streaming_providers/base/provider_registry.py b/lib/streaming_providers/base/provider_registry.py index a3625c2..7f1dc54 100644 --- a/lib/streaming_providers/base/provider_registry.py +++ b/lib/streaming_providers/base/provider_registry.py @@ -161,9 +161,7 @@ class ProviderRegistry: if instance: self.providers[instance_name] = instance - logger.info( - f"ProviderRegistry: Discovered {len(discovered)} provider instances" - ) + logger.info(f"ProviderRegistry: Discovered {len(discovered)} provider instances") return discovered def get_provider(self, provider_name: str) -> Optional[StreamingProvider]: @@ -199,9 +197,7 @@ class ProviderRegistry: from .settings.provider_enable_manager import ProviderEnableManager enable_manager = ProviderEnableManager() - success, message = enable_manager.set_provider_enabled( - provider_name, enabled - ) + success, message = enable_manager.set_provider_enabled(provider_name, enabled) return success except Exception as e: logger.error(f"Error updating enable status: {e}") diff --git a/lib/streaming_providers/base/settings/__init__.py b/lib/streaming_providers/base/settings/__init__.py index f2fd29c..d73b378 100644 --- a/lib/streaming_providers/base/settings/__init__.py +++ b/lib/streaming_providers/base/settings/__init__.py @@ -1,7 +1,6 @@ # streaming_providers/base/settings/__init__.py from .kodi_settings_bridge import KodiSettingsBridge -from .models.provider_settings import (ProviderSettingsSchema, - StandardProviderSettings) +from .models.provider_settings import ProviderSettingsSchema, StandardProviderSettings from .models.settings_models import SettingType, SettingValue, ValidationRule from .settings_manager import UnifiedSettingsManager diff --git a/lib/streaming_providers/base/settings/kodi_settings_bridge.py b/lib/streaming_providers/base/settings/kodi_settings_bridge.py index 631f611..407d5c6 100644 --- a/lib/streaming_providers/base/settings/kodi_settings_bridge.py +++ b/lib/streaming_providers/base/settings/kodi_settings_bridge.py @@ -3,11 +3,9 @@ import json import xml.etree.ElementTree as ElementTree from typing import Any, Dict, List, Optional, Set, Tuple -from ..auth.credentials import (BaseCredentials, ClientCredentials, - UserPasswordCredentials) +from ..auth.credentials import BaseCredentials, ClientCredentials, UserPasswordCredentials from ..models.proxy_models import ProxyConfig -from ..utils.environment import (get_environment_manager, get_vfs_instance, - is_kodi_environment) +from ..utils.environment import get_environment_manager, get_vfs_instance, is_kodi_environment from ..utils.logger import logger @@ -17,9 +15,7 @@ class KodiSettingsBridge: # Markers that identify credential settings CREDENTIAL_MARKERS = {"_username", "_password", "_client_id", "_client_secret"} - def __init__( - self, addon_id: Optional[str] = None, config_dir: Optional[str] = None - ): + def __init__(self, addon_id: Optional[str] = None, config_dir: Optional[str] = None): """Initialize Kodi settings bridge""" self.addon = None self.addon_id = addon_id @@ -42,9 +38,7 @@ class KodiSettingsBridge: else: self.addon = xbmcaddon.Addon() self.addon_id = self.addon.getAddonInfo("id") - logger.info( - f"Kodi settings bridge initialized for addon: {self.addon_id}" - ) + logger.info(f"Kodi settings bridge initialized for addon: {self.addon_id}") except Exception as e: logger.error(f"Failed to initialize Kodi addon: {e}") self.addon = None @@ -60,9 +54,7 @@ class KodiSettingsBridge: content = self.vfs.read_text(self._settings_file) if content: self._standalone_settings = json.loads(content) - logger.debug( - f"Loaded {len(self._standalone_settings)} standalone settings" - ) + logger.debug(f"Loaded {len(self._standalone_settings)} standalone settings") except Exception as e: logger.error(f"Error loading standalone settings: {e}") @@ -71,9 +63,7 @@ class KodiSettingsBridge: if not is_kodi_environment(): try: self.vfs.write_json(self._settings_file, self._standalone_settings) - logger.debug( - f"Saved {len(self._standalone_settings)} standalone settings" - ) + logger.debug(f"Saved {len(self._standalone_settings)} standalone settings") except Exception as e: logger.error(f"Error saving standalone settings: {e}") @@ -166,9 +156,7 @@ class KodiSettingsBridge: logger.error(f"Error reading settings.xml: {e}") return [] - def _parse_provider_country( - self, setting_id: str - ) -> Optional[Tuple[str, Optional[str]]]: + def _parse_provider_country(self, setting_id: str) -> Optional[Tuple[str, Optional[str]]]: """ Parse a setting ID to extract provider and optional country. @@ -312,9 +300,7 @@ class KodiSettingsBridge: username = self.get_setting(f"{provider}{country_suffix}_username") password = self.get_setting(f"{provider}{country_suffix}_password") client_id = self.get_setting(f"{provider}{country_suffix}_client_id") - client_secret = self.get_setting( - f"{provider}{country_suffix}_client_secret" - ) + client_secret = self.get_setting(f"{provider}{country_suffix}_client_secret") logger.debug(f"Settings for {provider}{country_suffix}:") logger.debug(f" username: '{username}' (empty={not username})") @@ -324,9 +310,7 @@ class KodiSettingsBridge: # Determine credential type based on available values if username and password: - logger.info( - f"Found username/password credentials for {provider}{country_suffix}" - ) + logger.info(f"Found username/password credentials for {provider}{country_suffix}") return UserPasswordCredentials( username=username.strip(), password=password.strip(), @@ -353,25 +337,15 @@ class KodiSettingsBridge: try: if isinstance(credentials, UserPasswordCredentials): - self.set_setting( - f"{provider}{country_suffix}_username", credentials.username - ) - self.set_setting( - f"{provider}{country_suffix}_password", credentials.password - ) + self.set_setting(f"{provider}{country_suffix}_username", credentials.username) + self.set_setting(f"{provider}{country_suffix}_password", credentials.password) if credentials.client_id: - self.set_setting( - f"{provider}{country_suffix}_client_id", credentials.client_id - ) - logger.info( - f"Wrote username/password credentials for {provider}{country_suffix}" - ) + self.set_setting(f"{provider}{country_suffix}_client_id", credentials.client_id) + logger.info(f"Wrote username/password credentials for {provider}{country_suffix}") return True elif isinstance(credentials, ClientCredentials): - self.set_setting( - f"{provider}{country_suffix}_client_id", credentials.client_id - ) + self.set_setting(f"{provider}{country_suffix}_client_id", credentials.client_id) self.set_setting( f"{provider}{country_suffix}_client_secret", credentials.client_secret, @@ -418,12 +392,8 @@ class KodiSettingsBridge: try: # Check if proxy is enabled - proxy_enabled = self.get_setting( - f"{provider}{country_suffix}_proxy_enabled" - ) - logger.debug( - f"Proxy enabled setting for {provider}{country_suffix}: '{proxy_enabled}'" - ) + proxy_enabled = self.get_setting(f"{provider}{country_suffix}_proxy_enabled") + logger.debug(f"Proxy enabled setting for {provider}{country_suffix}: '{proxy_enabled}'") if not proxy_enabled or proxy_enabled.lower() not in ["true", "1", "yes"]: logger.debug(f"Proxy not enabled for {provider}{country_suffix}") @@ -437,9 +407,7 @@ class KodiSettingsBridge: logger.debug(f" port: '{proxy_port_str}'") if not proxy_host or not proxy_port_str: - logger.debug( - f"Proxy host or port missing for {provider}{country_suffix}" - ) + logger.debug(f"Proxy host or port missing for {provider}{country_suffix}") return None try: @@ -470,12 +438,8 @@ class KodiSettingsBridge: try: self.set_setting(f"{provider}{country_suffix}_proxy_enabled", "true") - self.set_setting( - f"{provider}{country_suffix}_proxy_host", proxy_config.host - ) - self.set_setting( - f"{provider}{country_suffix}_proxy_port", str(proxy_config.port) - ) + self.set_setting(f"{provider}{country_suffix}_proxy_host", proxy_config.host) + self.set_setting(f"{provider}{country_suffix}_proxy_port", str(proxy_config.port)) logger.info(f"Wrote proxy config for {provider}{country_suffix}") return True @@ -523,9 +487,7 @@ class KodiSettingsBridge: try: ip_address = self.get_setting(f"{provider}{country_suffix}_ipaddress") - logger.debug( - f"IP address setting for {provider}{country_suffix}: '{ip_address}'" - ) + logger.debug(f"IP address setting for {provider}{country_suffix}: '{ip_address}'") if ip_address and ip_address.strip(): logger.info( @@ -562,20 +524,13 @@ class KodiSettingsBridge: and cred1.password == cred2.password and cred1.client_id == cred2.client_id ) - elif isinstance(cred1, ClientCredentials) and isinstance( - cred2, ClientCredentials - ): - return ( - cred1.client_id == cred2.client_id - and cred1.client_secret == cred2.client_secret - ) + elif isinstance(cred1, ClientCredentials) and isinstance(cred2, ClientCredentials): + return cred1.client_id == cred2.client_id and cred1.client_secret == cred2.client_secret return False @staticmethod - def _proxy_configs_equal( - proxy1: Optional[ProxyConfig], proxy2: Optional[ProxyConfig] - ) -> bool: + def _proxy_configs_equal(proxy1: Optional[ProxyConfig], proxy2: Optional[ProxyConfig]) -> bool: """Compare two proxy configurations for equality""" if proxy1 is None and proxy2 is None: return True diff --git a/lib/streaming_providers/base/settings/models/__init__.py b/lib/streaming_providers/base/settings/models/__init__.py index b13c54b..26e8055 100644 --- a/lib/streaming_providers/base/settings/models/__init__.py +++ b/lib/streaming_providers/base/settings/models/__init__.py @@ -1,10 +1,20 @@ # streaming_providers/base/settings/models/__init__.py from .provider_settings import ProviderSettingsSchema, StandardProviderSettings -from .settings_models import (SettingType, SettingValue, SettingValueBuilder, - StandardValidationRules, ValidationRule, - boolean_setting, integer_setting, ip_setting, - password_setting, port_setting, select_setting, - string_setting, url_setting) +from .settings_models import ( + SettingType, + SettingValue, + SettingValueBuilder, + StandardValidationRules, + ValidationRule, + boolean_setting, + integer_setting, + ip_setting, + password_setting, + port_setting, + select_setting, + string_setting, + url_setting, +) __all__ = [ # From settings_models.py diff --git a/lib/streaming_providers/base/settings/models/provider_settings.py b/lib/streaming_providers/base/settings/models/provider_settings.py index bc810f2..bf51a12 100644 --- a/lib/streaming_providers/base/settings/models/provider_settings.py +++ b/lib/streaming_providers/base/settings/models/provider_settings.py @@ -3,9 +3,15 @@ from dataclasses import dataclass, field from typing import Any, Dict, List, Optional, Set from ...utils.logger import logger -from .settings_models import (SettingValue, boolean_setting, integer_setting, - password_setting, port_setting, select_setting, - string_setting) +from .settings_models import ( + SettingValue, + boolean_setting, + integer_setting, + password_setting, + port_setting, + select_setting, + string_setting, +) @dataclass @@ -361,9 +367,7 @@ class ProviderSettingsSchema: required = [] for setting_name, setting in self._settings.items(): # Check if setting has a "not_empty" validation rule - has_required_rule = any( - rule.name == "not_empty" for rule in setting.validation_rules - ) + has_required_rule = any(rule.name == "not_empty" for rule in setting.validation_rules) if has_required_rule: required.append(setting_name) return required @@ -468,12 +472,9 @@ class ProviderSettingsSchema: """Convert schema to dictionary representation""" return { "provider_name": self.provider_name, - "settings": { - name: setting.to_dict() for name, setting in self._settings.items() - }, + "settings": {name: setting.to_dict() for name, setting in self._settings.items()}, "categories": { - category: list(settings) - for category, settings in self._categories.items() + category: list(settings) for category, settings in self._categories.items() }, "kodi_mapping": self._kodi_mapping.copy(), "configuration_status": self.get_configuration_completeness(), @@ -576,17 +577,13 @@ class StandardProviderSettings: return cls._registered_schemas["ard"] @classmethod - def register_provider_schema( - cls, provider_name: str, schema: ProviderSettingsSchema - ) -> None: + def register_provider_schema(cls, provider_name: str, schema: ProviderSettingsSchema) -> None: """Register a custom provider schema""" cls._registered_schemas[provider_name] = schema logger.info(f"Registered custom settings schema for provider: {provider_name}") @classmethod - def get_provider_schema( - cls, provider_name: str - ) -> Optional[ProviderSettingsSchema]: + def get_provider_schema(cls, provider_name: str) -> Optional[ProviderSettingsSchema]: """Get schema for a provider, creating default if not found""" # Check if we have a specific schema method method_name = f"get_{provider_name}_schema" @@ -617,9 +614,7 @@ class StandardProviderSettings: provider_name = attr_name[4:-7] # Remove 'get_' and '_schema' builtin_providers.append(provider_name) - all_providers = list( - set(builtin_providers + list(cls._registered_schemas.keys())) - ) + all_providers = list(set(builtin_providers + list(cls._registered_schemas.keys()))) return sorted(all_providers) @classmethod diff --git a/lib/streaming_providers/base/settings/models/settings_models.py b/lib/streaming_providers/base/settings/models/settings_models.py index de237fe..2cd00ce 100644 --- a/lib/streaming_providers/base/settings/models/settings_models.py +++ b/lib/streaming_providers/base/settings/models/settings_models.py @@ -120,9 +120,7 @@ class StandardValidationRules: except Exception: return False - return ValidationRule( - name="valid_url", validator=validate_url, error_message=error_msg - ) + return ValidationRule(name="valid_url", validator=validate_url, error_message=error_msg) @staticmethod def valid_ip_address( @@ -155,9 +153,7 @@ class StandardValidationRules: ) @staticmethod - def in_choices( - choices: List[Any], error_msg: Optional[str] = None - ) -> ValidationRule: + def in_choices(choices: List[Any], error_msg: Optional[str] = None) -> ValidationRule: """Rule to ensure value is in predefined choices""" if error_msg is None: error_msg = f"Value must be one of: {', '.join(map(str, choices))}" @@ -203,8 +199,7 @@ class SettingValue: self.add_validation_rule( ValidationRule( name="is_integer", - validator=lambda x: isinstance(x, int) - or (isinstance(x, str) and x.isdigit()), + validator=lambda x: isinstance(x, int) or (isinstance(x, str) and x.isdigit()), error_message="Value must be an integer", ) ) @@ -279,16 +274,10 @@ class SettingValue: return None try: - if ( - self.setting_type == SettingType.STRING - or self.setting_type == SettingType.PASSWORD - ): + if self.setting_type == SettingType.STRING or self.setting_type == SettingType.PASSWORD: return str(value) - elif ( - self.setting_type == SettingType.INTEGER - or self.setting_type == SettingType.PORT - ): + elif self.setting_type == SettingType.INTEGER or self.setting_type == SettingType.PORT: if isinstance(value, int): return value elif isinstance(value, str) and value.isdigit(): @@ -355,9 +344,7 @@ class SettingValue: def remove_validation_rule(self, rule_name: str) -> bool: """Remove a validation rule by name""" original_length = len(self.validation_rules) - self.validation_rules = [ - r for r in self.validation_rules if r.name != rule_name - ] + self.validation_rules = [r for r in self.validation_rules if r.name != rule_name] return len(self.validation_rules) < original_length def validate(self) -> tuple[bool, List[str]]: @@ -369,9 +356,7 @@ class SettingValue: """ if self.current_value is None: # Check if this setting is required (has not_empty rule) - has_required_rule = any( - rule.name == "not_empty" for rule in self.validation_rules - ) + has_required_rule = any(rule.name == "not_empty" for rule in self.validation_rules) if has_required_rule: return False, ["Value is required"] else: @@ -508,9 +493,7 @@ class SettingValueBuilder: self, min_val: Union[int, float], max_val: Union[int, float] ) -> "SettingValueBuilder": """Add numeric range validation""" - self._validation_rules.append( - StandardValidationRules.numeric_range(min_val, max_val) - ) + self._validation_rules.append(StandardValidationRules.numeric_range(min_val, max_val)) return self def custom_validation(self, rule: ValidationRule) -> "SettingValueBuilder": diff --git a/lib/streaming_providers/base/settings/provider_enable_manager.py b/lib/streaming_providers/base/settings/provider_enable_manager.py index 00bf9f8..92b15a3 100644 --- a/lib/streaming_providers/base/settings/provider_enable_manager.py +++ b/lib/streaming_providers/base/settings/provider_enable_manager.py @@ -58,9 +58,7 @@ class ProviderEnableManager: logger.debug(f"ProviderEnableManager: Kodi bridge not available: {e}") self._kodi_bridge = None except Exception as e: - logger.error( - f"ProviderEnableManager: Error initializing Kodi bridge: {e}" - ) + logger.error(f"ProviderEnableManager: Error initializing Kodi bridge: {e}") self._kodi_bridge = None def _load_file(self, force_reload: bool = False) -> Dict[str, Any]: @@ -76,11 +74,7 @@ class ProviderEnableManager: current_time = time.time() # Check cache first - if ( - not force_reload - and self._cache - and (current_time - self._cache_time) < self.CACHE_TTL - ): + if not force_reload and self._cache and (current_time - self._cache_time) < self.CACHE_TTL: return self._cache # Initialize default structure @@ -100,9 +94,7 @@ class ProviderEnableManager: data = self.vfs.read_json(self.DEFAULT_FILENAME) if not isinstance(data, dict): - logger.warning( - f"ProviderEnableManager: Invalid file format, using defaults" - ) + logger.warning(f"ProviderEnableManager: Invalid file format, using defaults") self._cache = default_data self._cache_time = current_time return default_data @@ -139,9 +131,7 @@ class ProviderEnableManager: return data except Exception as e: - logger.error( - f"ProviderEnableManager: Error loading {self.DEFAULT_FILENAME}: {e}" - ) + logger.error(f"ProviderEnableManager: Error loading {self.DEFAULT_FILENAME}: {e}") self._cache = default_data self._cache_time = current_time return default_data @@ -173,16 +163,12 @@ class ProviderEnableManager: f"ProviderEnableManager: Saved {len(data.get('providers', {}))} providers to file" ) else: - logger.error( - f"ProviderEnableManager: Failed to write to {self.DEFAULT_FILENAME}" - ) + logger.error(f"ProviderEnableManager: Failed to write to {self.DEFAULT_FILENAME}") return success except Exception as e: - logger.error( - f"ProviderEnableManager: Error saving {self.DEFAULT_FILENAME}: {e}" - ) + logger.error(f"ProviderEnableManager: Error saving {self.DEFAULT_FILENAME}: {e}") return False def _get_kodi_enabled_status(self, provider_name: str) -> Optional[bool]: @@ -228,9 +214,7 @@ class ProviderEnableManager: return enabled # No Kodi setting found - logger.debug( - f"ProviderEnableManager: No Kodi setting found for {provider_name}" - ) + logger.debug(f"ProviderEnableManager: No Kodi setting found for {provider_name}") return None except Exception as e: @@ -293,9 +277,7 @@ class ProviderEnableManager: return EnableSource.DEFAULT - def set_provider_enabled( - self, provider_name: str, enabled: bool - ) -> Tuple[bool, str]: + def set_provider_enabled(self, provider_name: str, enabled: bool) -> Tuple[bool, str]: """ Set enabled status for a provider (writes to file only). @@ -400,9 +382,7 @@ class ProviderEnableManager: source = self.get_enabled_source(provider_name) if source == EnableSource.KODI: - message = ( - f"Cannot delete setting for '{provider_name}' - controlled by Kodi" - ) + message = f"Cannot delete setting for '{provider_name}' - controlled by Kodi" logger.warning(f"ProviderEnableManager: {message}") return False, message @@ -515,17 +495,13 @@ class ProviderEnableManager: # Kodi has explicit setting, migrate to file data["providers"][provider_name] = kodi_enabled migrated_count += 1 - logger.debug( - f"ProviderEnableManager: Migrated {provider_name}={kodi_enabled}" - ) + logger.debug(f"ProviderEnableManager: Migrated {provider_name}={kodi_enabled}") if migrated_count > 0: # Save migrated data success = self._save_file(data) if success: - message = ( - f"Migrated {migrated_count} provider settings from Kodi to file" - ) + message = f"Migrated {migrated_count} provider settings from Kodi to file" logger.info(f"ProviderEnableManager: {message}") return True, message, migrated_count else: diff --git a/lib/streaming_providers/base/settings/settings_manager.py b/lib/streaming_providers/base/settings/settings_manager.py index c34b0f2..8a6b919 100644 --- a/lib/streaming_providers/base/settings/settings_manager.py +++ b/lib/streaming_providers/base/settings/settings_manager.py @@ -12,8 +12,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple from ..auth.credential_manager import CredentialManager -from ..auth.credentials import (BaseCredentials, ClientCredentials, - UserPasswordCredentials) +from ..auth.credentials import BaseCredentials, ClientCredentials, UserPasswordCredentials from ..auth.session_manager import SessionManager from ..models.proxy_models import ProxyConfig from ..network.proxy_manager import ProxyConfigManager @@ -31,12 +30,8 @@ class ProviderRegistration: registered_at: float = field(default_factory=time.time) is_active: bool = True settings_schema: Optional[Dict[str, Any]] = None - supports_countries: bool = ( - False # NEW: Flag if provider supports country-specific settings - ) - available_countries: List[str] = field( - default_factory=list - ) # NEW: List of supported countries + supports_countries: bool = False # NEW: Flag if provider supports country-specific settings + available_countries: List[str] = field(default_factory=list) # NEW: List of supported countries # Maintain backward compatibility by keeping the original class name @@ -49,9 +44,7 @@ class SettingsManager: Now supports country-specific settings for multi-region providers. """ - def __init__( - self, config_dir: Optional[str] = None, enable_kodi_integration: bool = True - ): + def __init__(self, config_dir: Optional[str] = None, enable_kodi_integration: bool = True): """ Initialize unified settings manager @@ -112,9 +105,7 @@ class SettingsManager: # Auto-register any unregistered providers for provider_name, countries in detected_providers.items(): if not self.is_provider_registered(provider_name): - logger.info( - f"Auto-registering provider '{provider_name}' from Kodi" - ) + logger.info(f"Auto-registering provider '{provider_name}' from Kodi") self.register_provider( provider_name, supports_countries=bool(countries), @@ -122,9 +113,7 @@ class SettingsManager: ) else: # Provider already registered - check if we need to upgrade to multi-country - if countries and not self.provider_supports_countries( - provider_name - ): + if countries and not self.provider_supports_countries(provider_name): logger.info( f"Auto-upgrading existing provider '{provider_name}' to multi-country: {countries}" ) @@ -144,9 +133,7 @@ class SettingsManager: ) for country in available_countries: - logger.info( - f"Syncing credentials for {provider_name} ({country})..." - ) + logger.info(f"Syncing credentials for {provider_name} ({country})...") self._sync_credentials_from_kodi(provider_name, country) logger.info(f"Syncing proxy for {provider_name} ({country})...") @@ -159,9 +146,7 @@ class SettingsManager: logger.info(f"Syncing proxy for {provider_name}...") self._sync_proxy_from_kodi(provider_name) - logger.info( - f"Initialized SettingsManager with config dir: {self.config_dir_path}" - ) + logger.info(f"Initialized SettingsManager with config dir: {self.config_dir_path}") def _setup_kodi_integration(self) -> bool: """Setup Kodi integration, return True if successful""" @@ -205,12 +190,8 @@ class SettingsManager: registered_at=provider_info.get("registered_at", time.time()), is_active=provider_info.get("is_active", True), settings_schema=provider_info.get("settings_schema"), - supports_countries=provider_info.get( - "supports_countries", False - ), - available_countries=provider_info.get( - "available_countries", [] - ), + supports_countries=provider_info.get("supports_countries", False), + available_countries=provider_info.get("available_countries", []), ) self._registered_providers[provider_name] = registration logger.debug( @@ -220,9 +201,7 @@ class SettingsManager: except Exception as e: logger.error(f"Error loading provider {provider_name}: {e}") - logger.info( - f"Loaded {len(self._registered_providers)} provider registrations" - ) + logger.info(f"Loaded {len(self._registered_providers)} provider registrations") except Exception as e: logger.error(f"Error loading unified settings configuration: {e}") @@ -275,22 +254,16 @@ class SettingsManager: BaseCredentials instance or None """ country_str = f" (country: {country})" if country else "" - logger.debug( - f"SettingsManager: Loading credentials for '{provider_name}{country_str}'" - ) + logger.debug(f"SettingsManager: Loading credentials for '{provider_name}{country_str}'") # Try file-based credentials first credentials = self.credential_manager.load_credentials(provider_name, country) logger.debug(f"SettingsManager: File credentials result: {type(credentials)}") if credentials: - logger.debug( - f"SettingsManager: File credentials type: {credentials.credential_type}" - ) + logger.debug(f"SettingsManager: File credentials type: {credentials.credential_type}") if hasattr(credentials, "username"): - logger.debug( - f"SettingsManager: File credentials username: {credentials.username}" - ) + logger.debug(f"SettingsManager: File credentials username: {credentials.username}") else: logger.debug( f"SettingsManager: File credentials class: {credentials.__class__.__name__}" @@ -301,21 +274,13 @@ class SettingsManager: ) # If no file credentials and Kodi is available, try syncing from Kodi - if ( - not credentials - and self.kodi_bridge - and self.kodi_bridge.is_kodi_environment() - ): + if not credentials and self.kodi_bridge and self.kodi_bridge.is_kodi_environment(): logger.debug( f"SettingsManager: No file credentials for {provider_name}{country_str}, trying Kodi sync" ) if self._sync_credentials_from_kodi(provider_name, country): - credentials = self.credential_manager.load_credentials( - provider_name, country - ) - logger.debug( - f"SettingsManager: After Kodi sync, credentials: {type(credentials)}" - ) + credentials = self.credential_manager.load_credentials(provider_name, country) + logger.debug(f"SettingsManager: After Kodi sync, credentials: {type(credentials)}") if credentials: logger.debug( f"SettingsManager: Synced credentials type: {credentials.credential_type}" @@ -344,15 +309,11 @@ class SettingsManager: True if successful, False otherwise """ # Save to file first - file_success = self.credential_manager.save_credentials( - provider_name, credentials, country - ) + file_success = self.credential_manager.save_credentials(provider_name, credentials, country) if not file_success: country_str = f" (country: {country})" if country else "" - logger.error( - f"Failed to save credentials to file for {provider_name}{country_str}" - ) + logger.error(f"Failed to save credentials to file for {provider_name}{country_str}") return False # Sync to Kodi if available and in Kodi environment @@ -361,9 +322,7 @@ class SettingsManager: provider_name, credentials, country ) if not kodi_success: - logger.warning( - f"Failed to sync credentials to Kodi for {provider_name}" - ) + logger.warning(f"Failed to sync credentials to Kodi for {provider_name}") # Don't fail the entire operation if Kodi sync fails return True @@ -468,22 +427,16 @@ class SettingsManager: success = self._save_configuration() if success: country_info = ( - f" (supports countries: {available_countries})" - if supports_countries - else "" - ) - logger.info( - f"Successfully registered provider: {provider_name}{country_info}" + f" (supports countries: {available_countries})" if supports_countries else "" ) + logger.info(f"Successfully registered provider: {provider_name}{country_info}") return success except Exception as e: logger.error(f"Error registering provider {provider_name}: {e}") return False - def unregister_provider( - self, provider_name: str, cleanup_data: bool = False - ) -> bool: + def unregister_provider(self, provider_name: str, cleanup_data: bool = False) -> bool: """ Unregister a provider and optionally clean up its data @@ -505,9 +458,7 @@ class SettingsManager: reg = self._registered_providers[provider_name] if reg.supports_countries: for country in reg.available_countries: - self.credential_manager.delete_credentials( - provider_name, country - ) + self.credential_manager.delete_credentials(provider_name, country) self.session_manager.clear_session(provider_name, country) self.proxy_manager.remove_proxy_config(provider_name, country) else: @@ -531,9 +482,7 @@ class SettingsManager: def list_registered_providers(self) -> List[str]: """Get list of all registered providers""" - return [ - name for name, reg in self._registered_providers.items() if reg.is_active - ] + return [name for name, reg in self._registered_providers.items() if reg.is_active] def is_provider_registered(self, provider_name: str) -> bool: """Check if a provider is registered""" @@ -632,9 +581,7 @@ class SettingsManager: "kodi_integration": { "enabled": self.enable_kodi_integration, "environment": ( - self.kodi_bridge.is_kodi_environment() - if self.kodi_bridge - else False + self.kodi_bridge.is_kodi_environment() if self.kodi_bridge else False ), }, } @@ -659,9 +606,7 @@ class SettingsManager: return status - def is_provider_ready( - self, provider_name: str, country: Optional[str] = None - ) -> bool: + def is_provider_ready(self, provider_name: str, country: Optional[str] = None) -> bool: """ Check if provider is fully configured and ready to use @@ -679,9 +624,9 @@ class SettingsManager: # Provider is ready if it has valid credentials credentials_status = status.get("credentials", {}) - return credentials_status.get( - "has_credentials", False - ) and credentials_status.get("credentials_valid", False) + return credentials_status.get("has_credentials", False) and credentials_status.get( + "credentials_valid", False + ) def get_ready_providers(self, country: Optional[str] = None) -> List[str]: """ @@ -715,9 +660,7 @@ class SettingsManager: True if successful or no sync needed, False on error """ country_str = f" (country: {country})" if country else "" - logger.debug( - f"SettingsManager: Attempting Kodi sync for {provider_name}{country_str}" - ) + logger.debug(f"SettingsManager: Attempting Kodi sync for {provider_name}{country_str}") if not self.kodi_bridge or not self.kodi_bridge.is_kodi_environment(): logger.debug( @@ -726,12 +669,8 @@ class SettingsManager: return False try: - kodi_credentials = self.kodi_bridge.read_credentials_from_kodi( - provider_name, country - ) - logger.debug( - f"SettingsManager: Kodi credentials result: {type(kodi_credentials)}" - ) + kodi_credentials = self.kodi_bridge.read_credentials_from_kodi(provider_name, country) + logger.debug(f"SettingsManager: Kodi credentials result: {type(kodi_credentials)}") if not kodi_credentials: logger.debug( @@ -746,14 +685,10 @@ class SettingsManager: return True # Not an error # Check if different from current file credentials - file_credentials = self.credential_manager.load_credentials( - provider_name, country - ) + file_credentials = self.credential_manager.load_credentials(provider_name, country) # ← FIX: Only skip if BOTH exist AND are equal - if file_credentials and self._credentials_equal( - kodi_credentials, file_credentials - ): + if file_credentials and self._credentials_equal(kodi_credentials, file_credentials): logger.debug( f"SettingsManager: Credentials already in sync for {provider_name}{country_str}" ) @@ -769,9 +704,7 @@ class SettingsManager: logger.debug(f"SettingsManager: Kodi sync save result: {success}") if success: - logger.info( - f"Synced credentials from Kodi for {provider_name}{country_str}" - ) + logger.info(f"Synced credentials from Kodi for {provider_name}{country_str}") return success except Exception as e: @@ -809,13 +742,9 @@ class SettingsManager: if countries: # Multi-country provider for country in countries: - cred_success = self._sync_credentials_from_kodi( - provider_name, country - ) + cred_success = self._sync_credentials_from_kodi(provider_name, country) proxy_success = self._sync_proxy_from_kodi(provider_name, country) - results[f"{provider_name}_{country}"] = ( - cred_success or proxy_success - ) + results[f"{provider_name}_{country}"] = cred_success or proxy_success else: # Single provider cred_success = self._sync_credentials_from_kodi(provider_name) @@ -853,32 +782,20 @@ class SettingsManager: proxy_config = self.proxy_manager.get_proxy_config(provider_name, country) # If no proxy config and Kodi available, sync from Kodi - if ( - not proxy_config - and self.kodi_bridge - and self.kodi_bridge.is_kodi_environment() - ): + if not proxy_config and self.kodi_bridge and self.kodi_bridge.is_kodi_environment(): if self._sync_proxy_from_kodi(provider_name, country): - proxy_config = self.proxy_manager.get_proxy_config( - provider_name, country - ) + proxy_config = self.proxy_manager.get_proxy_config(provider_name, country) return proxy_config - def _sync_proxy_from_kodi( - self, provider_name: str, country: Optional[str] = None - ) -> bool: + def _sync_proxy_from_kodi(self, provider_name: str, country: Optional[str] = None) -> bool: """Sync proxy config from Kodi to proxy manager""" if not self.kodi_bridge: return False - kodi_proxy_config = self.kodi_bridge.read_proxy_config_from_kodi( - provider_name, country - ) + kodi_proxy_config = self.kodi_bridge.read_proxy_config_from_kodi(provider_name, country) if kodi_proxy_config: - return self.proxy_manager.set_proxy_config( - provider_name, kodi_proxy_config, country - ) + return self.proxy_manager.set_proxy_config(provider_name, kodi_proxy_config, country) return False def set_provider_proxy( @@ -888,29 +805,21 @@ class SettingsManager: country: Optional[str] = None, ) -> bool: """Set proxy configuration for a provider with Kodi sync""" - success = self.proxy_manager.set_proxy_config( - provider_name, proxy_config, country - ) + success = self.proxy_manager.set_proxy_config(provider_name, proxy_config, country) # Also sync to Kodi if available if success and self.kodi_bridge and self.kodi_bridge.is_kodi_environment(): - self.kodi_bridge.write_proxy_config_to_kodi( - provider_name, proxy_config, country - ) + self.kodi_bridge.write_proxy_config_to_kodi(provider_name, proxy_config, country) return success - def remove_provider_proxy( - self, provider_name: str, country: Optional[str] = None - ) -> bool: + def remove_provider_proxy(self, provider_name: str, country: Optional[str] = None) -> bool: """Remove proxy configuration for a provider""" return self.proxy_manager.remove_proxy_config(provider_name, country) # ============= Session Management ============= - def clear_provider_session( - self, provider_name: str, country: Optional[str] = None - ) -> bool: + def clear_provider_session(self, provider_name: str, country: Optional[str] = None) -> bool: """Clear all session data for a provider""" return self.session_manager.clear_session(provider_name, country) @@ -990,8 +899,7 @@ class SettingsManager: if reset_session: self.session_manager.clear_session(provider_name, country) logger.info( - f"Reset session for {provider_name}" - + (f" ({country})" if country else "") + f"Reset session for {provider_name}" + (f" ({country})" if country else "") ) if reset_proxy: @@ -1054,9 +962,7 @@ class SettingsManager: if country is None and self.provider_supports_countries(provider_name): countries_data = {} for ctry in self.get_provider_countries(provider_name): - countries_data[ctry] = self.export_provider_settings( - provider_name, ctry - ) + countries_data[ctry] = self.export_provider_settings(provider_name, ctry) export_data["countries"] = countries_data return export_data @@ -1075,9 +981,7 @@ class SettingsManager: timestamp = int(time.time()) # Use config_path for backward compatibility if self.config_path: - export_path = str( - self.config_path / f"settings_export_{timestamp}.json" - ) + export_path = str(self.config_path / f"settings_export_{timestamp}.json") else: export_path = f"settings_export_{timestamp}.json" @@ -1088,9 +992,7 @@ class SettingsManager: "source": "unified_settings_manager_v2.1_country_aware", }, "system_info": { - "config_dir": ( - str(self.config_path) if self.config_path else "default_vfs_path" - ), + "config_dir": (str(self.config_path) if self.config_path else "default_vfs_path"), "kodi_integration_enabled": self.enable_kodi_integration, "registered_provider_count": len(self._registered_providers), }, @@ -1099,9 +1001,7 @@ class SettingsManager: # Export each provider for provider_name in self.list_registered_providers(): - export_data["providers"][provider_name] = self.export_provider_settings( - provider_name - ) + export_data["providers"][provider_name] = self.export_provider_settings(provider_name) # Write to file - use standard filesystem for exports (not VFS) with open(export_path, "w", encoding="utf-8") as f: @@ -1140,18 +1040,14 @@ class SettingsManager: "active": len(self.list_registered_providers()), "ready": len(self.get_ready_providers()), "country_aware": sum( - 1 - for reg in self._registered_providers.values() - if reg.supports_countries + 1 for reg in self._registered_providers.values() if reg.supports_countries ), }, "kodi_integration": { "enabled": self.enable_kodi_integration, "bridge_available": self.kodi_bridge is not None, "in_kodi_environment": ( - self.kodi_bridge.is_kodi_environment() - if self.kodi_bridge - else False + self.kodi_bridge.is_kodi_environment() if self.kodi_bridge else False ), }, "storage": { @@ -1160,9 +1056,7 @@ class SettingsManager: }, } - def debug_provider( - self, provider_name: str, country: Optional[str] = None - ) -> Dict[str, Any]: + def debug_provider(self, provider_name: str, country: Optional[str] = None) -> Dict[str, Any]: """ Get detailed debug information for a provider @@ -1212,8 +1106,7 @@ class SettingsManager: # Component manager status debug_info["components"] = { "credential_manager": { - "has_credentials": provider_name - in self.credential_manager.list_providers() + "has_credentials": provider_name in self.credential_manager.list_providers() }, "session_manager": { "has_session": self.session_manager.load_session(provider_name, country) @@ -1284,9 +1177,7 @@ class SettingsManager: # Get countries with session data return self.session_manager.get_all_countries(provider_name) - def migrate_to_country_structure( - self, provider_name: str, default_country: str - ) -> bool: + def migrate_to_country_structure(self, provider_name: str, default_country: str) -> bool: """ Migrate a non-country provider to country-aware structure @@ -1323,15 +1214,11 @@ class SettingsManager: logger.debug(f"Migrated credentials to {default_country}") if old_session: - self.session_manager.save_session( - provider_name, old_session, default_country - ) + self.session_manager.save_session(provider_name, old_session, default_country) logger.debug(f"Migrated session to {default_country}") if old_proxy: - self.proxy_manager.set_proxy_config( - provider_name, old_proxy, default_country - ) + self.proxy_manager.set_proxy_config(provider_name, old_proxy, default_country) logger.debug(f"Migrated proxy to {default_country}") # Clear old non-country data @@ -1395,9 +1282,7 @@ class SettingsManager: 'token_type': 'Bearer' }, 'de') """ - return self.session_manager.save_scoped_token( - provider_name, scope, token_data, country - ) + return self.session_manager.save_scoped_token(provider_name, scope, token_data, country) def load_scoped_token( self, provider_name: str, scope: str, country: Optional[str] = None @@ -1443,9 +1328,7 @@ class SettingsManager: """ return self.session_manager.clear_scoped_token(provider_name, scope, country) - def list_scoped_tokens( - self, provider_name: str, country: Optional[str] = None - ) -> List[str]: + def list_scoped_tokens(self, provider_name: str, country: Optional[str] = None) -> List[str]: """ List all available token scopes for a provider @@ -1529,13 +1412,9 @@ class SettingsManager: status["access_token_valid"] = time.time() < expires_at # yo_digital style (separate access_token_expires_in) - elif ( - "access_token_expires_in" in token_data - and "access_token_issued_at" in token_data - ): + elif "access_token_expires_in" in token_data and "access_token_issued_at" in token_data: expires_at = ( - token_data["access_token_issued_at"] - + token_data["access_token_expires_in"] + token_data["access_token_issued_at"] + token_data["access_token_expires_in"] ) status["access_token_expires_at"] = expires_at status["access_token_valid"] = time.time() < expires_at @@ -1546,13 +1425,9 @@ class SettingsManager: if "refresh_token" in token_data: status["has_refresh_token"] = True - if ( - "refresh_token_expires_in" in token_data - and "refresh_token_issued_at" in token_data - ): + if "refresh_token_expires_in" in token_data and "refresh_token_issued_at" in token_data: expires_at = ( - token_data["refresh_token_issued_at"] - + token_data["refresh_token_expires_in"] + token_data["refresh_token_issued_at"] + token_data["refresh_token_expires_in"] ) status["refresh_token_expires_at"] = expires_at status["refresh_token_valid"] = time.time() < expires_at @@ -1561,9 +1436,7 @@ class SettingsManager: return status - def is_provider_enabled( - self, provider_name: str, country: Optional[str] = None - ) -> bool: + def is_provider_enabled(self, provider_name: str, country: Optional[str] = None) -> bool: """Check if provider is enabled (using ProviderEnableManager)""" # Import here to avoid circular imports from .provider_enable_manager import ProviderEnableManager @@ -1647,15 +1520,11 @@ class SettingsManager: return False, f"Credential validation failed: missing required fields" # Save directly to file (bypass Kodi sync) - success = self.credential_manager.save_credentials( - provider, credentials, country - ) + success = self.credential_manager.save_credentials(provider, credentials, country) if success: country_str = f" ({country})" if country else "" - logger.info( - f"Successfully saved credentials from API for {provider}{country_str}" - ) + logger.info(f"Successfully saved credentials from API for {provider}{country_str}") return True, "Credentials saved successfully" else: return False, "Failed to save credentials to file" @@ -1689,9 +1558,7 @@ class SettingsManager: ) # Check for client credentials - has_client_id = ( - "client_id" in credentials_data and credentials_data["client_id"] - ) + has_client_id = "client_id" in credentials_data and credentials_data["client_id"] has_client_secret = ( "client_secret" in credentials_data and credentials_data["client_secret"] ) @@ -1744,15 +1611,11 @@ class SettingsManager: return False, "Proxy validation failed: invalid host, port, or timeout" # Save directly to file (bypass Kodi sync) - success = self.proxy_manager.set_proxy_config( - provider, proxy_config, country - ) + success = self.proxy_manager.set_proxy_config(provider, proxy_config, country) if success: country_str = f" ({country})" if country else "" - logger.info( - f"Successfully saved proxy config from API for {provider}{country_str}" - ) + logger.info(f"Successfully saved proxy config from API for {provider}{country_str}") return True, "Proxy configuration saved successfully" else: return False, "Failed to save proxy configuration to file" @@ -1772,8 +1635,7 @@ class SettingsManager: Returns: ProxyConfig instance or None if invalid """ - from ..models.proxy_models import (ProxyAuth, ProxyConfig, ProxyScope, - ProxyType) + from ..models.proxy_models import ProxyAuth, ProxyConfig, ProxyScope, ProxyType # Required fields if "host" not in proxy_data or not proxy_data["host"]: @@ -1829,9 +1691,7 @@ class SettingsManager: verify_ssl=verify_ssl, ) - def delete_provider_credentials_from_api( - self, provider_name: str - ) -> Tuple[bool, str]: + def delete_provider_credentials_from_api(self, provider_name: str) -> Tuple[bool, str]: """ Delete credentials via API request @@ -1862,9 +1722,7 @@ class SettingsManager: return False, "Failed to delete credentials" except Exception as e: - logger.error( - f"Error deleting credentials from API for {provider_name}: {e}" - ) + logger.error(f"Error deleting credentials from API for {provider_name}: {e}") return False, f"Internal error: {str(e)}" def delete_provider_proxy_from_api(self, provider_name: str) -> Tuple[bool, str]: @@ -1898,9 +1756,7 @@ class SettingsManager: return False, "Failed to delete proxy configuration" except Exception as e: - logger.error( - f"Error deleting proxy config from API for {provider_name}: {e}" - ) + logger.error(f"Error deleting proxy config from API for {provider_name}: {e}") return False, f"Internal error: {str(e)}" diff --git a/lib/streaming_providers/base/subscription_operations.py b/lib/streaming_providers/base/subscription_operations.py index 1d22ea9..771ad94 100644 --- a/lib/streaming_providers/base/subscription_operations.py +++ b/lib/streaming_providers/base/subscription_operations.py @@ -17,9 +17,7 @@ class SubscriptionOperations: self.registry = registry logger.debug("SubscriptionOperations: Initialized") - def get_subscription_status( - self, provider_name: str, **kwargs - ) -> Optional[UserSubscription]: + def get_subscription_status(self, provider_name: str, **kwargs) -> Optional[UserSubscription]: """Get subscription status for a provider.""" provider = self.registry.get_provider(provider_name) if not provider: @@ -37,9 +35,7 @@ class SubscriptionOperations: logger.warning(f"Error getting subscription for '{provider_name}': {e}") return None - def get_subscribed_channels( - self, provider_name: str, **kwargs - ) -> List[StreamingChannel]: + def get_subscribed_channels(self, provider_name: str, **kwargs) -> List[StreamingChannel]: """Get subscribed channels.""" provider = self.registry.get_provider(provider_name) if not provider: @@ -47,17 +43,13 @@ class SubscriptionOperations: try: channels = provider.get_subscribed_channels(**kwargs) - logger.info( - f"Got {len(channels)} subscribed channels from '{provider_name}'" - ) + logger.info(f"Got {len(channels)} subscribed channels from '{provider_name}'") return channels except Exception as e: logger.error(f"Error getting subscribed channels: {e}") return provider.get_channels(**kwargs) - def get_available_packages( - self, provider_name: str, **kwargs - ) -> List[SubscriptionPackage]: + def get_available_packages(self, provider_name: str, **kwargs) -> List[SubscriptionPackage]: """Get available subscription packages.""" provider = self.registry.get_provider(provider_name) if not provider: @@ -71,9 +63,7 @@ class SubscriptionOperations: logger.warning(f"Error getting packages for '{provider_name}': {e}") return [] - def is_channel_accessible( - self, provider_name: str, channel_id: str, **kwargs - ) -> bool: + def is_channel_accessible(self, provider_name: str, channel_id: str, **kwargs) -> bool: """Check if channel is accessible with current subscription.""" provider = self.registry.get_provider(provider_name) if not provider: diff --git a/lib/streaming_providers/base/ui/console_notification_adapter.py b/lib/streaming_providers/base/ui/console_notification_adapter.py index 946b4bc..7680ecf 100644 --- a/lib/streaming_providers/base/ui/console_notification_adapter.py +++ b/lib/streaming_providers/base/ui/console_notification_adapter.py @@ -88,9 +88,7 @@ class ConsoleNotificationAdapter(NotificationInterface): print("=" * 70) print() - logger.info( - f"Remote login started: code={login_code}, expires_in={expires_in}s" - ) + logger.info(f"Remote login started: code={login_code}, expires_in={expires_in}s") logger.info(f"QR target URL: {qr_target_url}") return NotificationResult.CONTINUE diff --git a/lib/streaming_providers/base/ui/kodi_notification_adapter.py b/lib/streaming_providers/base/ui/kodi_notification_adapter.py index 6a892df..7ebbed1 100644 --- a/lib/streaming_providers/base/ui/kodi_notification_adapter.py +++ b/lib/streaming_providers/base/ui/kodi_notification_adapter.py @@ -286,9 +286,7 @@ class QRCodeDialog: if self.polling_thread: remaining = self.polling_thread.get_remaining_time() if self.time_label: - self.time_label.setLabel( - f"Time remaining: {self._format_time(remaining)}" - ) + self.time_label.setLabel(f"Time remaining: {self._format_time(remaining)}") # Check if auth completed if self.polling_thread.auth_completed: @@ -303,13 +301,9 @@ class QRCodeDialog: # Check for errors if self.polling_thread.error: - logger.error( - f"Monitor: Polling error: {self.polling_thread.error}" - ) + logger.error(f"Monitor: Polling error: {self.polling_thread.error}") if self.status_label: - self.status_label.setLabel( - "[COLOR red]Authentication failed[/COLOR]" - ) + self.status_label.setLabel("[COLOR red]Authentication failed[/COLOR]") time.sleep(2) self.close_dialog() break diff --git a/lib/streaming_providers/base/ui/notification_factory.py b/lib/streaming_providers/base/ui/notification_factory.py index fb23e8f..44302c2 100644 --- a/lib/streaming_providers/base/ui/notification_factory.py +++ b/lib/streaming_providers/base/ui/notification_factory.py @@ -41,14 +41,8 @@ class NotificationFactory: NotificationInterface: Appropriate adapter for current environment """ # Return cached adapter if available and no http_manager change - if ( - cls._cached_adapter is not None - and force_environment is None - and http_manager is None - ): - logger.debug( - f"Using cached notification adapter: {cls._environment_detected}" - ) + if cls._cached_adapter is not None and force_environment is None and http_manager is None: + logger.debug(f"Using cached notification adapter: {cls._environment_detected}") return cls._cached_adapter # Detect environment @@ -89,9 +83,7 @@ class NotificationFactory: except ImportError: # If import fails, we're standalone - logger.debug( - "Kodi modules not available - using console notification adapter" - ) + logger.debug("Kodi modules not available - using console notification adapter") return "console" @classmethod diff --git a/lib/streaming_providers/base/ui/qr_generator.py b/lib/streaming_providers/base/ui/qr_generator.py index fe2fc8f..e1d9506 100644 --- a/lib/streaming_providers/base/ui/qr_generator.py +++ b/lib/streaming_providers/base/ui/qr_generator.py @@ -41,9 +41,7 @@ def generate_qr_code_png(data: str, size: int = 512) -> Optional[bytes]: qr.make(fit=True) # Generate image using pure Python PNG backend - img = qr.make_image( - image_factory=PyPNGImage, fill_color="black", back_color="white" - ) + img = qr.make_image(image_factory=PyPNGImage, fill_color="black", back_color="white") # Convert to PNG bytes buffer = io.BytesIO() diff --git a/lib/streaming_providers/base/utils/environment.py b/lib/streaming_providers/base/utils/environment.py index c507c5a..610e603 100644 --- a/lib/streaming_providers/base/utils/environment.py +++ b/lib/streaming_providers/base/utils/environment.py @@ -83,22 +83,16 @@ class EnvironmentManager: # Get settings default_country = self._addon.getSetting("default_country") - self._config["default_country"] = ( - str(default_country) if default_country else "DE" - ) + self._config["default_country"] = str(default_country) if default_country else "DE" server_port = self._addon.getSetting("server_port") try: - self._config["server_port"] = ( - int(str(server_port)) if server_port else 7777 - ) + self._config["server_port"] = int(str(server_port)) if server_port else 7777 except ValueError: self._config["server_port"] = 7777 except Exception as init_error: # noqa: B902 - print( - f"DEBUG: Exception type: {type(init_error).__name__}", file=sys.stderr - ) + print(f"DEBUG: Exception type: {type(init_error).__name__}", file=sys.stderr) print(f"DEBUG: Exception message: {str(init_error)}", file=sys.stderr) # Log the error and fallback to standalone self._log_init_error("Kodi initialization failed", init_error) @@ -120,9 +114,7 @@ class EnvironmentManager: self._config["addon_path"] = os.path.dirname(os.path.abspath(__file__)) # Default configuration paths - config_home = os.environ.get("XDG_CONFIG_HOME") or os.path.join( - str(Path.home()), ".config" - ) + config_home = os.environ.get("XDG_CONFIG_HOME") or os.path.join(str(Path.home()), ".config") self._config["config_dir"] = os.path.join(config_home, "ultimate-backend") self._config["profile_path"] = self._config["config_dir"] @@ -248,9 +240,7 @@ class EnvironmentManager: else: raise ImportError("get_configured_manager is not callable") else: - raise ImportError( - "get_configured_manager not found in streaming_providers module" - ) + raise ImportError("get_configured_manager not found in streaming_providers module") except ImportError as manager_error: # Log error using the logger once we have it logger_instance = self.get_logger() diff --git a/lib/streaming_providers/base/utils/logger.py b/lib/streaming_providers/base/utils/logger.py index 67e619c..04f3a8a 100644 --- a/lib/streaming_providers/base/utils/logger.py +++ b/lib/streaming_providers/base/utils/logger.py @@ -50,9 +50,7 @@ class BaseLogger: log_message += f" - {details}" self.info(log_message) - def log_credential_event( - self, provider: str, event: str, details: str = "" - ) -> None: + def log_credential_event(self, provider: str, event: str, details: str = "") -> None: """Log credential event""" log_message = f"CRED [{provider}] {event}" if details: diff --git a/lib/streaming_providers/base/utils/manifest_parser.py b/lib/streaming_providers/base/utils/manifest_parser.py index 9a72b0c..d9c4fb3 100644 --- a/lib/streaming_providers/base/utils/manifest_parser.py +++ b/lib/streaming_providers/base/utils/manifest_parser.py @@ -1,96 +1,166 @@ import base64 import re +from typing import List, Optional +from urllib.parse import quote, urljoin, urlparse -from ..models.drm_models import PSSHData +from ..models.drm_models import DRMSystem, PSSHData from .logger import logger class ManifestParser: @staticmethod def extract_pssh_from_manifest( - manifest_content: str, manifest_url: str = "", return_collection: bool = True - ): - logger.debug("Starting PSSH extraction from manifest") + manifest_content: str, + manifest_url: str = "", + fallback_to_segments: bool = True, + segment_urls: List[str] = None, + ) -> List[PSSHData]: + """ + DEPRECATED: Use extract_single_init_segment_url instead. + Kept for backwards compatibility. + """ + pssh_list = ManifestParser._extract_from_manifest_content(manifest_content) - # Check if this looks like a DASH manifest - is_dash = (" List[PSSHData]: + """Extract PSSH and DRM systems from manifest content""" + # Try regex extraction first (handles PSSH boxes with KIDs) + pssh_list = ManifestParser._extract_with_regex(manifest_content) + if pssh_list: + return pssh_list - pssh_dict = {} # Use dict to automatically handle deduplication - global_key_ids = [] # Collect all KIDs from the manifest + # Fallback: extract DRM systems from schemeIdUri only + drm_systems_found = set() + result = [] - # Regex patterns - pssh_pattern = r"<(?:cenc:)?pssh[^>]*>([^<]+)" - default_kid_pattern = r'(?:cenc:)?default_KID="([^"]+)"' - system_id_pattern = r'schemeIdUri="urn:uuid:([^"]+)"' + # More efficient: compile regex once + cp_pattern = re.compile( + r']*schemeIdUri="urn:uuid:([^"]+)"[^>]*>', + re.IGNORECASE, + ) - logger.debug("Searching for ContentProtection blocks") + for match in cp_pattern.finditer(manifest_content): + system_id = match.group(1).lower() + + # Skip mp4protection scheme + if "mp4protection" in manifest_content[max(0, match.start() - 100) : match.start()]: + continue + + drm_system = DRMSystem.from_uuid(system_id) + if drm_system and system_id not in drm_systems_found: + drm_systems_found.add(system_id) + result.append( + PSSHData( + system_id=system_id, + pssh_box="", # Empty - PSSH in segments + key_ids=[], + source="manifest_scheme_only", + ) + ) + logger.debug(f"Found DRM system from schemeIdUri: {drm_system.value}") + + return result + + @staticmethod + def _extract_from_single_segment( + segment_url: str, expected_system_ids: List[str] = None + ) -> List[PSSHData]: + """Extract PSSH from a single MP4 segment""" + from .mp4_parser import MP4PSSHExtractor + + try: + pssh_from_segment = MP4PSSHExtractor.extract_from_url(segment_url) + + # Filter for expected DRM systems if provided + if expected_system_ids: + filtered_pssh = [p for p in pssh_from_segment if p.system_id in expected_system_ids] + if filtered_pssh: + logger.debug(f"Found {len(filtered_pssh)} PSSH boxes in segment") + return filtered_pssh + elif pssh_from_segment: + logger.debug(f"Found {len(pssh_from_segment)} PSSH boxes in segment") + return pssh_from_segment + + except Exception as e: + logger.warning(f"Failed to extract PSSH from segment: {e}") + + return [] + + @staticmethod + def _merge_pssh_data( + manifest_pssh: List[PSSHData], segment_pssh: List[PSSHData] + ) -> List[PSSHData]: + """Merge manifest and segment PSSH data""" + if not manifest_pssh: + return segment_pssh + if not segment_pssh: + return manifest_pssh + + merged = [] + segment_by_system = {p.system_id: p for p in segment_pssh} + + for manifest_p in manifest_pssh: + if manifest_p.system_id in segment_by_system: + # Use segment data (complete) + merged.append(segment_by_system[manifest_p.system_id]) + else: + # Keep manifest data (incomplete) + merged.append(manifest_p) + + return merged + + @staticmethod + def _extract_with_regex(mpd_content: str) -> List[PSSHData]: + """Extract PSSH boxes using regex""" + pssh_dict = {} + global_key_ids = [] + + # Compile patterns once + pssh_pattern = re.compile(r"<(?:cenc:)?pssh[^>]*>([^<]+)") + default_kid_pattern = re.compile(r'(?:cenc:)?default_KID="([^"]+)"', re.IGNORECASE) + system_id_pattern = re.compile(r'schemeIdUri="urn:uuid:([^"]+)"', re.IGNORECASE) + + # Find ContentProtection blocks efficiently cp_blocks = re.findall( r"]*>.*?", mpd_content, re.DOTALL ) - logger.debug(f"Found {len(cp_blocks)} ContentProtection blocks") - # First pass: collect all default KIDs from the entire manifest + # First pass: collect all default KIDs for block in cp_blocks: - kid_match = re.search(default_kid_pattern, block) + kid_match = default_kid_pattern.search(block) if kid_match: clean_kid = kid_match.group(1).replace("-", "").lower() if clean_kid not in global_key_ids: global_key_ids.append(clean_kid) - logger.debug(f"Found global default KID: {clean_kid}") # Second pass: extract PSSH data - for i, block in enumerate(cp_blocks, 1): + for block in cp_blocks: try: - logger.debug(f"Processing block {i}/{len(cp_blocks)}") system_id = None # Extract system ID from schemeIdUri - scheme_match = re.search(system_id_pattern, block) + scheme_match = system_id_pattern.search(block) if scheme_match: system_id = scheme_match.group(1).lower() - logger.debug(f"Found system ID in schemeIdUri: {system_id}") # Extract PSSH data - pssh_matches = re.findall(pssh_pattern, block) - logger.debug(f"Found {len(pssh_matches)} PSSH elements in block") - - for pssh_b64 in pssh_matches: + for pssh_match in pssh_pattern.finditer(block): + pssh_b64 = pssh_match.group(1) try: - logger.debug( - f"Processing PSSH (first 30 chars): {pssh_b64[:30]}..." - ) pssh_data = base64.b64decode(pssh_b64) if len(pssh_data) >= 28: - # Extract system ID from PSSH if not found in schemeIdUri + # Extract system ID from PSSH if not found if not system_id: system_id_bytes = pssh_data[12:28] system_id = "-".join( @@ -102,29 +172,133 @@ class ManifestParser: system_id_bytes[10:16].hex(), ] ) - logger.debug( - f"Extracted system ID from PSSH: {system_id}" - ) - # Use PSSH box as key for deduplication + # Deduplicate by PSSH box content if pssh_b64 not in pssh_dict: pssh_dict[pssh_b64] = PSSHData( system_id=system_id, pssh_box=pssh_b64, - key_ids=global_key_ids.copy(), # Add all global KIDs to each PSSH + key_ids=global_key_ids.copy(), + source="manifest_pssh", ) - logger.debug("Successfully added new PSSH entry") - else: - logger.debug("PSSH already exists, skipping duplicate") except Exception as e: - logger.error(f"Error decoding PSSH: {str(e)}") + logger.debug(f"Error decoding PSSH: {e}") except Exception as e: - logger.error(f"Error processing ContentProtection block: {str(e)}") + logger.debug(f"Error processing ContentProtection block: {e}") - pssh_list = list(pssh_dict.values()) - logger.debug( - f"Completed PSSH extraction, found {len(pssh_list)} unique entries" + return list(pssh_dict.values()) + + @staticmethod + def extract_single_init_segment_url(manifest_content: str, manifest_url: str) -> Optional[str]: + """ + Extract ONE init segment URL from DASH manifest. + Prioritizes video representations as they typically have the same DRM as audio. + """ + # Parse manifest base URL + parsed = urlparse(manifest_url) + manifest_base = f"{parsed.scheme}://{parsed.netloc}{'/'.join(parsed.path.split('/')[:-1])}" + if not manifest_base.endswith("/"): + manifest_base += "/" + + # Extract BaseURL elements (can appear at multiple levels) + base_urls = re.findall(r"]*>([^<]+)", manifest_content) + + # Build effective base URL + effective_base = manifest_base + for base_url in base_urls: + if base_url.startswith("http"): + effective_base = base_url + else: + effective_base = urljoin(effective_base, base_url) + + if not effective_base.endswith("/"): + effective_base += "/" + + logger.debug(f"Effective base URL: {effective_base}") + + # Find SegmentTemplate with initialization attribute + # Prioritize video AdaptationSets + adaptation_sets = re.findall( + r"]*>.*?", manifest_content, re.DOTALL ) - return pssh_list + + video_sets = [] + audio_sets = [] + + for ad_set in adaptation_sets: + if 'contentType="video"' in ad_set or 'mimeType="video/' in ad_set: + video_sets.append(ad_set) + elif 'contentType="audio"' in ad_set or 'mimeType="audio/' in ad_set: + audio_sets.append(ad_set) + + # Try video first, then audio + target_sets = video_sets + audio_sets + + for ad_set in target_sets: + # Find SegmentTemplate initialization + seg_template_match = re.search( + r']*initialization="([^"]+)"', ad_set, re.IGNORECASE + ) + + if not seg_template_match: + continue + + init_template = seg_template_match.group(1) + logger.debug(f"Found init template: {init_template}") + + # Find first Representation in this AdaptationSet + rep_match = re.search(r']*id="([^"]+)"', ad_set) + if not rep_match: + continue + + rep_id = rep_match.group(1) + logger.debug(f"Using Representation ID: {rep_id}") + + # Substitute template variables + init_url = init_template.replace("$RepresentationID$", rep_id) + + # Handle other common template variables + init_url = init_url.replace("$Bandwidth$", "0") + init_url = init_url.replace("$Time$", "0") + init_url = init_url.replace("$Number$", "1") + + # Construct full URL + if init_url.startswith("http"): + full_url = init_url + else: + # URL encode special characters in representation ID + # Split path and encode only the filename part + path_parts = init_url.split("/") + path_parts[-1] = quote(path_parts[-1], safe=".-_") + init_url = "/".join(path_parts) + + full_url = urljoin(effective_base, init_url) + + logger.info(f"Constructed init segment URL: {full_url}") + return full_url + + logger.warning("Could not find init segment URL in manifest") + return None + + @staticmethod + def extract_segment_urls(manifest_content: str, manifest_url: str) -> List[str]: + """ + DEPRECATED: Use extract_single_init_segment_url instead. + This extracts ALL segments which is inefficient. + """ + logger.warning("extract_segment_urls is deprecated, use extract_single_init_segment_url") + init_url = ManifestParser.extract_single_init_segment_url(manifest_content, manifest_url) + return [init_url] if init_url else [] + + @staticmethod + def extract_init_segment_urls(manifest_content: str, manifest_url: str) -> List[str]: + """ + DEPRECATED: Use extract_single_init_segment_url instead. + """ + logger.warning( + "extract_init_segment_urls is deprecated, use extract_single_init_segment_url" + ) + init_url = ManifestParser.extract_single_init_segment_url(manifest_content, manifest_url) + return [init_url] if init_url else [] diff --git a/lib/streaming_providers/base/utils/mp4_parser.py b/lib/streaming_providers/base/utils/mp4_parser.py new file mode 100644 index 0000000..f9671cb --- /dev/null +++ b/lib/streaming_providers/base/utils/mp4_parser.py @@ -0,0 +1,304 @@ +import base64 +import struct +import uuid +from typing import List, Optional + +from ..models.drm_models import PSSHData +from .logger import logger + + +class MP4PSSHExtractor: + """Extract PSSH boxes and key IDs from MP4 segments""" + + @staticmethod + def extract_from_url(segment_url: str, timeout: int = 10) -> List[PSSHData]: + """Download MP4 segment and extract PSSH data""" + import requests + + try: + response = requests.get(segment_url, timeout=timeout) + response.raise_for_status() + + # Only download first ~100KB for efficiency + chunk_size = 1024 * 100 + data = response.content[:chunk_size] + + return MP4PSSHExtractor.extract_from_bytes(data) + + except Exception as e: + logger.error(f"Failed to extract PSSH from {segment_url}: {e}") + return [] + + @staticmethod + def extract_from_bytes(data: bytes) -> List[PSSHData]: + """Extract PSSH boxes from MP4 binary data""" + pssh_data_list = [] + offset = 0 + + while offset < len(data): + try: + # Read box size (4 bytes, big-endian) + if offset + 4 > len(data): + break + + box_size = struct.unpack(">I", data[offset : offset + 4])[0] + if box_size == 0: + box_size = len(data) - offset # Box extends to end of file + elif box_size == 1: + # Extended size (skip for now - rare in practice) + break + + if offset + box_size > len(data): + break + + # Read box type (4 bytes) + box_type = data[offset + 4 : offset + 8].decode("ascii", errors="ignore") + + if box_type == "moov": + # Look for PSSH in moov box + moov_data = data[offset : offset + box_size] + pssh_in_moov = MP4PSSHExtractor._extract_from_moov(moov_data) + pssh_data_list.extend(pssh_in_moov) + + elif box_type == "pssh": + # Found standalone PSSH box + pssh_box = MP4PSSHExtractor._parse_pssh_box(data[offset : offset + box_size]) + if pssh_box: + pssh_data_list.append(pssh_box) + + offset += box_size + + except Exception as e: + logger.debug(f"Error parsing MP4 box at offset {offset}: {e}") + offset += 1 # Try to recover + + return pssh_data_list + + @staticmethod + def _extract_from_moov(moov_data: bytes) -> List[PSSHData]: + """Extract PSSH boxes from moov container""" + pssh_list = [] + offset = 8 # Skip moov header + + while offset < len(moov_data): + try: + box_size = struct.unpack(">I", moov_data[offset : offset + 4])[0] + box_type = moov_data[offset + 4 : offset + 8].decode("ascii", errors="ignore") + + if box_type == "trak": + # Parse track for PSSH + trak_data = moov_data[offset : offset + box_size] + pssh_in_trak = MP4PSSHExtractor._extract_from_trak(trak_data) + pssh_list.extend(pssh_in_trak) + + elif box_type == "pssh": + # PSSH directly in moov + pssh_box = MP4PSSHExtractor._parse_pssh_box( + moov_data[offset : offset + box_size] + ) + if pssh_box: + pssh_list.append(pssh_box) + + offset += box_size + + except: + break + + return pssh_list + + @staticmethod + def _extract_from_trak(trak_data: bytes) -> List[PSSHData]: + """Extract PSSH from trak box""" + pssh_list = [] + offset = 8 + + while offset < len(trak_data): + try: + box_size = struct.unpack(">I", trak_data[offset : offset + 4])[0] + box_type = trak_data[offset + 4 : offset + 8].decode("ascii", errors="ignore") + + if box_type == "mdia": + mdia_data = trak_data[offset : offset + box_size] + pssh_in_mdia = MP4PSSHExtractor._extract_from_mdia(mdia_data) + pssh_list.extend(pssh_in_mdia) + + offset += box_size + + except: + break + + return pssh_list + + @staticmethod + def _extract_from_mdia(mdia_data: bytes) -> List[PSSHData]: + """Extract PSSH from mdia box""" + pssh_list = [] + offset = 8 + + while offset < len(mdia_data): + try: + box_size = struct.unpack(">I", mdia_data[offset : offset + 4])[0] + box_type = mdia_data[offset + 4 : offset + 8].decode("ascii", errors="ignore") + + if box_type == "minf": + minf_data = mdia_data[offset : offset + box_size] + pssh_in_minf = MP4PSSHExtractor._extract_from_minf(minf_data) + pssh_list.extend(pssh_in_minf) + + offset += box_size + + except: + break + + return pssh_list + + @staticmethod + def _extract_from_minf(minf_data: bytes) -> List[PSSHData]: + """Extract PSSH from minf box""" + pssh_list = [] + offset = 8 + + while offset < len(minf_data): + try: + box_size = struct.unpack(">I", minf_data[offset : offset + 4])[0] + box_type = minf_data[offset + 4 : offset + 8].decode("ascii", errors="ignore") + + if box_type == "stbl": + stbl_data = minf_data[offset : offset + box_size] + pssh_in_stbl = MP4PSSHExtractor._extract_from_stbl(stbl_data) + pssh_list.extend(pssh_in_stbl) + + offset += box_size + + except: + break + + return pssh_list + + @staticmethod + def _extract_from_stbl(stbl_data: bytes) -> List[PSSHData]: + """Extract PSSH from stbl box (where protection scheme info usually is)""" + pssh_list = [] + offset = 8 + + while offset < len(stbl_data): + try: + box_size = struct.unpack(">I", stbl_data[offset : offset + 4])[0] + box_type = stbl_data[offset + 4 : offset + 8].decode("ascii", errors="ignore") + + if box_type == "sinf": + sinf_data = stbl_data[offset : offset + box_size] + pssh_in_sinf = MP4PSSHExtractor._extract_from_sinf(sinf_data) + pssh_list.extend(pssh_in_sinf) + + offset += box_size + + except: + break + + return pssh_list + + @staticmethod + def _extract_from_sinf(sinf_data: bytes) -> List[PSSHData]: + """Extract PSSH from sinf (protection scheme information) box""" + pssh_list = [] + offset = 8 + + while offset < len(sinf_data): + try: + box_size = struct.unpack(">I", sinf_data[offset : offset + 4])[0] + box_type = sinf_data[offset + 4 : offset + 8].decode("ascii", errors="ignore") + + if box_type == "schi": + schi_data = sinf_data[offset : offset + box_size] + pssh_in_schi = MP4PSSHExtractor._extract_from_schi(schi_data) + pssh_list.extend(pssh_in_schi) + + offset += box_size + + except: + break + + return pssh_list + + @staticmethod + def _extract_from_schi(schi_data: bytes) -> List[PSSHData]: + """Extract PSSH from schi box (where PSSH boxes are typically stored)""" + pssh_list = [] + offset = 8 + + while offset < len(schi_data): + try: + box_size = struct.unpack(">I", schi_data[offset : offset + 4])[0] + box_type = schi_data[offset + 4 : offset + 8].decode("ascii", errors="ignore") + + if box_type == "pssh": + pssh_box = MP4PSSHExtractor._parse_pssh_box( + schi_data[offset : offset + box_size] + ) + if pssh_box: + pssh_list.append(pssh_box) + + offset += box_size + + except: + break + + return pssh_list + + @staticmethod + def _parse_pssh_box(pssh_bytes: bytes) -> Optional[PSSHData]: + """Parse a PSSH box and extract system_id, pssh_box, and key_ids""" + try: + if len(pssh_bytes) < 32: # Minimum size for PSSH box + return None + + # Parse box header + box_size = struct.unpack(">I", pssh_bytes[0:4])[0] + box_type = pssh_bytes[4:8].decode("ascii") + + if box_type != "pssh": + return None + + version = pssh_bytes[8] + flags = struct.unpack(">I", b"\x00" + pssh_bytes[9:12])[0] + + # Extract system ID (bytes 12-28) + system_id_bytes = pssh_bytes[12:28] + system_id = str(uuid.UUID(bytes=system_id_bytes)) + + # Extract key IDs (if version > 0) + key_ids = [] + current_offset = 28 + + if version > 0: + # Read KID count + if current_offset + 4 > len(pssh_bytes): + return None + + kid_count = struct.unpack(">I", pssh_bytes[current_offset : current_offset + 4])[0] + current_offset += 4 + + # Read each KID + for _ in range(kid_count): + if current_offset + 16 > len(pssh_bytes): + break + + kid_bytes = pssh_bytes[current_offset : current_offset + 16] + kid_uuid = str(uuid.UUID(bytes=kid_bytes)) + key_ids.append(kid_uuid.replace("-", "").lower()) + current_offset += 16 + + # Encode entire PSSH box as base64 + pssh_b64 = base64.b64encode(pssh_bytes[:box_size]).decode("ascii") + + return PSSHData( + system_id=system_id, + pssh_box=pssh_b64, + key_ids=key_ids, + source="mp4_segment", + ) + + except Exception as e: + logger.debug(f"Failed to parse PSSH box: {e}") + return None diff --git a/lib/streaming_providers/base/utils/mpd_cache.py b/lib/streaming_providers/base/utils/mpd_cache.py index f2b1d92..1e67ff6 100644 --- a/lib/streaming_providers/base/utils/mpd_cache.py +++ b/lib/streaming_providers/base/utils/mpd_cache.py @@ -58,9 +58,7 @@ class MPDCacheManager: now = int(time.time()) if now >= expiry: - logger.debug( - f"Cache expired for {cache_key} (expired {now - expiry}s ago)" - ) + logger.debug(f"Cache expired for {cache_key} (expired {now - expiry}s ago)") # Clean up expired cache self.vfs.delete(manifest_file) self.vfs.delete(meta_file) @@ -72,9 +70,7 @@ class MPDCacheManager: logger.info(f"Cache hit for {cache_key} (expires in {expiry - now}s)") return manifest_content else: - logger.warning( - f"Cache metadata exists but manifest file missing for {cache_key}" - ) + logger.warning(f"Cache metadata exists but manifest file missing for {cache_key}") self.vfs.delete(meta_file) return None @@ -135,9 +131,7 @@ class MPDCacheManager: self.vfs.delete(manifest_file) return False - logger.info( - f"Cached MPD for {cache_key} with TTL={ttl}s (expires at {expiry})" - ) + logger.info(f"Cached MPD for {cache_key} with TTL={ttl}s (expires at {expiry})") return True except Exception as e: diff --git a/lib/streaming_providers/base/utils/mpd_rewriter.py b/lib/streaming_providers/base/utils/mpd_rewriter.py index 2e6ecec..7334db8 100644 --- a/lib/streaming_providers/base/utils/mpd_rewriter.py +++ b/lib/streaming_providers/base/utils/mpd_rewriter.py @@ -42,9 +42,7 @@ class MPDRewriter: """Decode base64 URL from proxy endpoint""" return base64.urlsafe_b64decode(encoded.encode("utf-8")).decode("utf-8") - def build_proxy_url( - self, original_url: str, template_pattern: Optional[str] = None - ) -> str: + def build_proxy_url(self, original_url: str, template_pattern: Optional[str] = None) -> str: """ Build proxy URL for an original media URL @@ -139,9 +137,7 @@ class MPDRewriter: if not rewritten.startswith("\n' + rewritten - logger.debug( - f"Successfully rewrote MPD for provider '{self.provider_name}'" - ) + logger.debug(f"Successfully rewrote MPD for provider '{self.provider_name}'") return rewritten except ET.ParseError as e: @@ -177,9 +173,7 @@ class MPDRewriter: parsed_manifest = urlparse(manifest_url) manifest_dir = f"{parsed_manifest.scheme}://{parsed_manifest.netloc}{parsed_manifest.path.rsplit('/', 1)[0]}/" resolved_base = urljoin(manifest_dir, base_url_text) - logger.debug( - f"Resolved relative BaseURL '{base_url_text}' to: {resolved_base}" - ) + logger.debug(f"Resolved relative BaseURL '{base_url_text}' to: {resolved_base}") return resolved_base else: # It's already an absolute URL @@ -201,9 +195,7 @@ class MPDRewriter: """ # Find all BaseURL elements at any level for parent in root.findall(".//*"): - for base_url_elem in list( - parent.findall("mpd:BaseURL", self.MPD_NAMESPACE) - ): + for base_url_elem in list(parent.findall("mpd:BaseURL", self.MPD_NAMESPACE)): parent.remove(base_url_elem) logger.debug("Removed BaseURL element") @@ -238,9 +230,7 @@ class MPDRewriter: if "$" in resolved: # Split into base path and template pattern base_path, template_pattern = self.split_template_url(resolved) - element.attrib[attr] = self.build_proxy_url( - base_path, template_pattern - ) + element.attrib[attr] = self.build_proxy_url(base_path, template_pattern) logger.debug( f"Rewrote template URL: {original_url} -> proxy with template {template_pattern}" ) @@ -258,9 +248,7 @@ class MPDRewriter: # SegmentURL typically doesn't have templates, but handle it just in case if "$" in resolved: base_path, template_pattern = self.split_template_url(resolved) - element.attrib["media"] = self.build_proxy_url( - base_path, template_pattern - ) + element.attrib["media"] = self.build_proxy_url(base_path, template_pattern) else: element.attrib["media"] = self.build_proxy_url(resolved) diff --git a/lib/streaming_providers/base/utils/timestamp_converter.py b/lib/streaming_providers/base/utils/timestamp_converter.py index 0af6fcb..468500f 100644 --- a/lib/streaming_providers/base/utils/timestamp_converter.py +++ b/lib/streaming_providers/base/utils/timestamp_converter.py @@ -56,9 +56,7 @@ class TimestampConverter: # Create timezone-aware datetime from epoch if as_utc and timezone is None: # Use UTC timezone - dt = datetime.datetime.fromtimestamp( - epoch_seconds, tz=TimestampConverter.UTC - ) + dt = datetime.datetime.fromtimestamp(epoch_seconds, tz=TimestampConverter.UTC) elif timezone is not None: # Use specified timezone dt = datetime.datetime.fromtimestamp(epoch_seconds, tz=timezone) @@ -128,9 +126,7 @@ class TimestampConverter: return dt.replace(tzinfo=TimestampConverter.UTC).timestamp() @staticmethod - def _parse_custom_iso( - iso_string: str, format_type: Optional[str] = None - ) -> datetime.datetime: + def _parse_custom_iso(iso_string: str, format_type: Optional[str] = None) -> datetime.datetime: """ Parse custom ISO formats not handled by fromisoformat. @@ -172,18 +168,14 @@ class TimestampConverter: except ValueError: # Try without microseconds if microseconds format fails if format_type == "microseconds": - dt = datetime.datetime.strptime( - iso_string, TimestampConverter.ISO_EXTENDED - ) + dt = datetime.datetime.strptime(iso_string, TimestampConverter.ISO_EXTENDED) else: raise return dt @staticmethod - def now_iso( - format_type: str = "extended", timezone: Optional[datetime.tzinfo] = None - ) -> str: + def now_iso(format_type: str = "extended", timezone: Optional[datetime.tzinfo] = None) -> str: """ Get current time as ISO 8601 string. diff --git a/lib/streaming_providers/base/utils/vfs.py b/lib/streaming_providers/base/utils/vfs.py index 3f0da49..fd4e830 100644 --- a/lib/streaming_providers/base/utils/vfs.py +++ b/lib/streaming_providers/base/utils/vfs.py @@ -10,6 +10,7 @@ from typing import Any, Dict, List, Optional, Tuple # Import centralized environment manager from .environment import get_environment_manager, is_kodi_environment + # Import centralized logger from .logger import logger @@ -35,9 +36,7 @@ class VFS: self._explicit_config_dir = config_dir self._env_manager = get_environment_manager() - logger.debug( - f"VFS initialized with config_dir={config_dir}, addon_subdir={addon_subdir}" - ) + logger.debug(f"VFS initialized with config_dir={config_dir}, addon_subdir={addon_subdir}") @property def base_path(self) -> str: @@ -53,9 +52,9 @@ class VFS: if self.addon_subdir: if is_kodi_environment(): # Kodi uses forward slashes - self._base_path = os.path.join( - profile_path, self.addon_subdir - ).replace("\\", "/") + self._base_path = os.path.join(profile_path, self.addon_subdir).replace( + "\\", "/" + ) else: # Standard filesystem self._base_path = os.path.join(profile_path, self.addon_subdir) @@ -208,9 +207,7 @@ class VFS: with xbmcvfs.File(filepath, "w") as f: bytes_written = f.write(content) - logger.debug( - f"Kodi file write: {bytes_written} bytes to {filepath}" - ) + logger.debug(f"Kodi file write: {bytes_written} bytes to {filepath}") return bytes_written > 0 else: import pathlib @@ -474,16 +471,12 @@ def get_global_vfs(config_dir: Optional[str] = None, addon_subdir: str = "") -> # Convenience functions that use VFS cache -def exists( - filepath: str, config_dir: Optional[str] = None, addon_subdir: str = "" -) -> bool: +def exists(filepath: str, config_dir: Optional[str] = None, addon_subdir: str = "") -> bool: """Check if file exists""" return get_vfs(config_dir, addon_subdir).exists(filepath) -def mkdirs( - dirpath: str, config_dir: Optional[str] = None, addon_subdir: str = "" -) -> bool: +def mkdirs(dirpath: str, config_dir: Optional[str] = None, addon_subdir: str = "") -> bool: """Create directories""" return get_vfs(config_dir, addon_subdir).mkdirs(dirpath) @@ -527,9 +520,7 @@ def write_json( return get_vfs(config_dir, addon_subdir).write_json(filepath, data, indent) -def delete( - filepath: str, config_dir: Optional[str] = None, addon_subdir: str = "" -) -> bool: +def delete(filepath: str, config_dir: Optional[str] = None, addon_subdir: str = "") -> bool: """Delete file""" return get_vfs(config_dir, addon_subdir).delete(filepath) diff --git a/lib/streaming_providers/providers/auth_builder.py b/lib/streaming_providers/providers/auth_builder.py index 191222e..7403825 100644 --- a/lib/streaming_providers/providers/auth_builder.py +++ b/lib/streaming_providers/providers/auth_builder.py @@ -20,14 +20,10 @@ class AuthStatusBuilder: current_auth_type = provider.get_current_auth_type(context) # Calculate auth state (with provider override support) - auth_state = AuthStatusBuilder._calculate_auth_state_with_override( - provider, context - ) + auth_state = AuthStatusBuilder._calculate_auth_state_with_override(provider, context) # Calculate readiness (with provider override support) - is_ready, reason = AuthStatusBuilder._calculate_readiness_with_override( - provider, context - ) + is_ready, reason = AuthStatusBuilder._calculate_readiness_with_override(provider, context) # Build token info token_scopes = AuthStatusBuilder._build_token_scopes(provider, context) @@ -65,9 +61,7 @@ class AuthStatusBuilder: has_valid_token=AuthStatusBuilder._has_valid_token(provider, context), primary_token_scope=provider.primary_token_scope, token_scopes=token_scopes, - last_authentication=AuthStatusBuilder._get_last_auth_time( - provider, context - ), + last_authentication=AuthStatusBuilder._get_last_auth_time(provider, context), provider_specific=provider_specific, token_expires_at=token_expires_at, token_expires_in_seconds=token_expires_in_seconds, @@ -78,9 +72,7 @@ class AuthStatusBuilder: # ===== Calculation Methods (with provider override support) ===== @staticmethod - def _calculate_auth_state_with_override( - provider, context: AuthContext - ) -> AuthState: + def _calculate_auth_state_with_override(provider, context: AuthContext) -> AuthState: """Calculate auth state, allowing provider override""" # Check if provider has custom logic if hasattr(provider, "_calculate_auth_state"): @@ -92,9 +84,7 @@ class AuthStatusBuilder: return AuthStatusBuilder._calculate_auth_state(provider, context) @staticmethod - def _calculate_readiness_with_override( - provider, context: AuthContext - ) -> Tuple[bool, str]: + def _calculate_readiness_with_override(provider, context: AuthContext) -> Tuple[bool, str]: """Calculate readiness, allowing provider override""" # Check if provider has custom logic if hasattr(provider, "_calculate_readiness"): @@ -120,9 +110,7 @@ class AuthStatusBuilder: return AuthState.AUTHENTICATED # Check if we have an expired token that can be refreshed - expired_token = AuthStatusBuilder._get_expired_token_with_refresh( - provider, context - ) + expired_token = AuthStatusBuilder._get_expired_token_with_refresh(provider, context) if expired_token: return AuthState.EXPIRED @@ -151,17 +139,13 @@ class AuthStatusBuilder: return True, "Has valid authentication token" # 3. Check if we have an expired token that can be refreshed - expired_token = AuthStatusBuilder._get_expired_token_with_refresh( - provider, context - ) + expired_token = AuthStatusBuilder._get_expired_token_with_refresh(provider, context) if expired_token: return True, "Has expired token with refresh capability" # 4. Check credentials if required (only matters if no valid token) if provider.requires_stored_credentials: - credentials = context.get_credentials( - provider.provider_name, provider.country - ) + credentials = context.get_credentials(provider.provider_name, provider.country) if not credentials: return False, "Missing required credentials" @@ -202,9 +186,7 @@ class AuthStatusBuilder: return False @staticmethod - def _get_expired_token_with_refresh( - provider, context: AuthContext - ) -> Optional[Dict[str, Any]]: + def _get_expired_token_with_refresh(provider, context: AuthContext) -> Optional[Dict[str, Any]]: """Get an expired token that has refresh capability""" # Check primary scope if provider.primary_token_scope: @@ -233,9 +215,7 @@ class AuthStatusBuilder: token_scopes = {} for scope in provider.token_scopes: - token_data = context.get_token( - provider.provider_name, scope, provider.country - ) + token_data = context.get_token(provider.provider_name, scope, provider.country) if token_data: expires_at = None if "issued_at" in token_data and "expires_in" in token_data: @@ -256,9 +236,7 @@ class AuthStatusBuilder: return token_scopes @staticmethod - def _get_credentials_info( - provider, context: AuthContext - ) -> Tuple[bool, Optional[str]]: + def _get_credentials_info(provider, context: AuthContext) -> Tuple[bool, Optional[str]]: """Get credentials information""" if not provider.requires_stored_credentials: return False, None @@ -335,16 +313,12 @@ class AuthStatusBuilder: # If no primary scope or no token, check root-level token if not token_data: - token_data = context.get_token( - provider.provider_name, None, provider.country - ) + token_data = context.get_token(provider.provider_name, None, provider.country) # If still no token, try first available scope if not token_data and provider.token_scopes: for scope in provider.token_scopes: - token_data = context.get_token( - provider.provider_name, scope, provider.country - ) + token_data = context.get_token(provider.provider_name, scope, provider.country) if token_data: break @@ -356,22 +330,15 @@ class AuthStatusBuilder: if "expires_in" in token_data and "issued_at" in token_data: expires_in = token_data["expires_in"] issued_at = token_data["issued_at"] - if isinstance(expires_in, (int, float)) and isinstance( - issued_at, (int, float) - ): + if isinstance(expires_in, (int, float)) and isinstance(issued_at, (int, float)): token_expires_at = float(issued_at) + float(expires_in) token_expires_in_seconds = int(token_expires_at - current_time) # yo_digital format: separate access token expiration - elif ( - "access_token_expires_in" in token_data - and "access_token_issued_at" in token_data - ): + elif "access_token_expires_in" in token_data and "access_token_issued_at" in token_data: expires_in = token_data["access_token_expires_in"] issued_at = token_data["access_token_issued_at"] - if isinstance(expires_in, (int, float)) and isinstance( - issued_at, (int, float) - ): + if isinstance(expires_in, (int, float)) and isinstance(issued_at, (int, float)): token_expires_at = float(issued_at) + float(expires_in) token_expires_in_seconds = int(token_expires_at - current_time) @@ -383,21 +350,14 @@ class AuthStatusBuilder: token_expires_in_seconds = int(token_expires_at - current_time) # Calculate refresh token expiration (yo_digital format) - if ( - "refresh_token_expires_in" in token_data - and "refresh_token_issued_at" in token_data - ): + if "refresh_token_expires_in" in token_data and "refresh_token_issued_at" in token_data: refresh_expires_in = token_data["refresh_token_expires_in"] refresh_issued_at = token_data["refresh_token_issued_at"] if isinstance(refresh_expires_in, (int, float)) and isinstance( refresh_issued_at, (int, float) ): - refresh_token_expires_at = float(refresh_issued_at) + float( - refresh_expires_in - ) - refresh_token_expires_in_seconds = int( - refresh_token_expires_at - current_time - ) + refresh_token_expires_at = float(refresh_issued_at) + float(refresh_expires_in) + refresh_token_expires_in_seconds = int(refresh_token_expires_at - current_time) return ( token_expires_at, diff --git a/lib/streaming_providers/providers/auth_context.py b/lib/streaming_providers/providers/auth_context.py index 8e23c03..d037b84 100644 --- a/lib/streaming_providers/providers/auth_context.py +++ b/lib/streaming_providers/providers/auth_context.py @@ -11,9 +11,7 @@ class AuthContext: def __init__(self, settings_manager): self.settings = settings_manager self.session = settings_manager.session_manager if settings_manager else None - self.credentials = ( - settings_manager.credential_manager if settings_manager else None - ) + self.credentials = settings_manager.credential_manager if settings_manager else None def get_credentials(self, provider_name: str, country: str = None) -> Optional[Any]: """Get credentials for provider""" diff --git a/lib/streaming_providers/providers/hrti/auth.py b/lib/streaming_providers/providers/hrti/auth.py index 3e8efc9..1051ef2 100644 --- a/lib/streaming_providers/providers/hrti/auth.py +++ b/lib/streaming_providers/providers/hrti/auth.py @@ -8,8 +8,7 @@ import uuid from datetime import datetime, timedelta from typing import Any, Dict, Optional -from ...base.auth.base_auth import (BaseAuthenticator, BaseAuthToken, - TokenAuthLevel) +from ...base.auth.base_auth import BaseAuthenticator, BaseAuthToken, TokenAuthLevel from ...base.models.proxy_models import ProxyConfig from ...base.utils.logger import logger from .constants import HRTiConfig @@ -108,9 +107,7 @@ class HRTiAuthenticator(BaseAuthenticator): return headers - def _get_api_headers( - self, bearer_token: str = None, referer: str = None - ) -> Dict[str, str]: + def _get_api_headers(self, bearer_token: str = None, referer: str = None) -> Dict[str, str]: """Get headers for regular API endpoints with proper session support""" if not self._ip_address: self._get_ip_address() @@ -139,18 +136,14 @@ class HRTiAuthenticator(BaseAuthenticator): # Add authorization header with Client prefix if bearer_token: headers["authorization"] = f"Client {bearer_token}" - logger.debug( - f"Added authorization header with token: {bearer_token[:20]}..." - ) + logger.debug(f"Added authorization header with token: {bearer_token[:20]}...") return headers def _build_auth_payload(self) -> Dict[str, Any]: """Build HRTi-specific authentication payload""" # HRTi uses specific format: {"Username":"...","Password":"...","OperatorReferenceId":"hrt"} - if hasattr(self.credentials, "username") and hasattr( - self.credentials, "password" - ): + if hasattr(self.credentials, "username") and hasattr(self.credentials, "password"): return { "Username": self.credentials.username, "Password": self.credentials.password, @@ -164,9 +157,7 @@ class HRTiAuthenticator(BaseAuthenticator): "OperatorReferenceId": self.config.operator_reference_id, } - def _create_token_from_response( - self, response_data: Dict[str, Any] - ) -> HRTiAuthToken: + def _create_token_from_response(self, response_data: Dict[str, Any]) -> HRTiAuthToken: """Create HRTi-specific token from API response""" result = response_data.get("Result", {}) @@ -175,13 +166,9 @@ class HRTiAuthenticator(BaseAuthenticator): # Extract token access_token = result.get("Token", "") - logger.debug( - f"Extracted access_token: {'present' if access_token else 'MISSING'}" - ) + logger.debug(f"Extracted access_token: {'present' if access_token else 'MISSING'}") if access_token: - logger.debug( - f"Token length: {len(access_token)}, starts with: {access_token[:20]}..." - ) + logger.debug(f"Token length: {len(access_token)}, starts with: {access_token[:20]}...") # Store user ID if available if "Customer" in result: @@ -231,9 +218,7 @@ class HRTiAuthenticator(BaseAuthenticator): self._current_token = token # THIS IS THE CRITICAL MISSING LINE # Debug: Verify token is available - logger.debug( - f"Token created - access_token present: {bool(token.access_token)}" - ) + logger.debug(f"Token created - access_token present: {bool(token.access_token)}") if token.access_token: logger.debug(f"Token value: {token.access_token[:20]}...") else: @@ -264,9 +249,7 @@ class HRTiAuthenticator(BaseAuthenticator): configured_ip = self._get_configured_ip_from_settings() if configured_ip: self._ip_address = configured_ip - logger.info( - f"Using configured IP address from Kodi settings: {self._ip_address}" - ) + logger.info(f"Using configured IP address from Kodi settings: {self._ip_address}") return self._ip_address except Exception as e: logger.debug(f"Could not read configured IP from settings: {e}") @@ -274,13 +257,9 @@ class HRTiAuthenticator(BaseAuthenticator): # Priority 2: Fall back to API fetch if no configured IP try: logger.debug("No configured IP found, fetching from API...") - response = self.http_manager.get( - self.config.api_endpoints["get_ip"], operation="api" - ) + response = self.http_manager.get(self.config.api_endpoints["get_ip"], operation="api") response.raise_for_status() - self._ip_address = response.text.strip().strip( - '"' - ) # Remove quotes if present + self._ip_address = response.text.strip().strip('"') # Remove quotes if present logger.info(f"Retrieved IP address from API: {self._ip_address}") return self._ip_address except Exception as e: @@ -319,16 +298,12 @@ class HRTiAuthenticator(BaseAuthenticator): """Get HRTi environment configuration""" try: # Get env config - env_response = self.http_manager.get( - self.config.env_endpoint, operation="api" - ) + env_response = self.http_manager.get(self.config.env_endpoint, operation="api") env_response.raise_for_status() env_data = env_response.json() # Get main config - config_response = self.http_manager.get( - self.config.config_endpoint, operation="api" - ) + config_response = self.http_manager.get(self.config.config_endpoint, operation="api") config_response.raise_for_status() config_data = config_response.json() @@ -348,9 +323,7 @@ class HRTiAuthenticator(BaseAuthenticator): device_id = self.get_device_id() - logger.debug( - f"Performing grant access with username: {self.credentials.username}" - ) + logger.debug(f"Performing grant access with username: {self.credentials.username}") logger.debug(f"Using device ID: {device_id}") logger.debug(f"Using IP address: {self._ip_address}") @@ -366,9 +339,7 @@ class HRTiAuthenticator(BaseAuthenticator): safe_payload = payload.copy() if "Password" in safe_payload: - safe_payload["Password"] = ( - "***" if safe_payload["Password"] else "" - ) + safe_payload["Password"] = "***" if safe_payload["Password"] else "" logger.debug(f"HRTi Auth Payload: {safe_payload}") response = self.http_manager.post( @@ -385,11 +356,7 @@ class HRTiAuthenticator(BaseAuthenticator): if "Result" not in result: raise Exception("No result in grant access response") - customer_id = ( - result.get("Result", {}) - .get("Customer", {}) - .get("CustomerId", "unknown") - ) + customer_id = result.get("Result", {}).get("Customer", {}).get("CustomerId", "unknown") logger.debug(f"Grant access successful - user: {customer_id}") return result @@ -409,9 +376,7 @@ class HRTiAuthenticator(BaseAuthenticator): def _register_device(self): """Register device with HRTi using API headers with proper authorization""" try: - bearer_token = ( - self._current_token.access_token if self._current_token else "" - ) + bearer_token = self._current_token.access_token if self._current_token else "" if not bearer_token: logger.warning("No bearer token available for device registration") return @@ -458,9 +423,7 @@ class HRTiAuthenticator(BaseAuthenticator): def _get_initial_data(self): """Get initial content rating and profiles using API headers with proper authorization""" try: - bearer_token = ( - self._current_token.access_token if self._current_token else "" - ) + bearer_token = self._current_token.access_token if self._current_token else "" headers = self._get_api_headers(bearer_token) # Update the referer to root path for these API calls @@ -492,18 +455,14 @@ class HRTiAuthenticator(BaseAuthenticator): def _initialize_device(self): """Initialize or load device ID - ensure it's a proper UUID""" try: - self._device_id = self.settings_manager.get_device_id( - self.provider_name, self.country - ) + self._device_id = self.settings_manager.get_device_id(self.provider_name, self.country) if not self._device_id: self._device_id = str(uuid.uuid4()) logger.debug(f"Generated new device ID: {self._device_id}") # Ensure it's a valid UUID format if not self._validate_uuid(self._device_id): - logger.warning( - f"Invalid device ID format, generating new one: {self._device_id}" - ) + logger.warning(f"Invalid device ID format, generating new one: {self._device_id}") self._device_id = str(uuid.uuid4()) except Exception as e: @@ -557,9 +516,7 @@ class HRTiAuthenticator(BaseAuthenticator): ) -> Optional[Dict[str, Any]]: """Authorize a playback session""" try: - bearer_token = ( - self._current_token.access_token if self._current_token else "" - ) + bearer_token = self._current_token.access_token if self._current_token else "" headers = self._get_api_headers(bearer_token) # Set referer based on content type @@ -567,9 +524,7 @@ class HRTiAuthenticator(BaseAuthenticator): headers["referer"] = f"{self.config.base_website}/videostore" else: if content_type == "tlive": - headers["referer"] = ( - f"{self.config.base_website}/live/tv?channel={channel_id}" - ) + headers["referer"] = f"{self.config.base_website}/live/tv?channel={channel_id}" elif content_type == "rlive": headers["referer"] = f"{self.config.base_website}/live/radio" else: @@ -600,9 +555,7 @@ class HRTiAuthenticator(BaseAuthenticator): result = response.json() if "Result" in result: authorized = result["Result"].get("Authorized", False) - session_id = result["Result"].get("SessionId") or result["Result"].get( - "DrmId" - ) + session_id = result["Result"].get("SessionId") or result["Result"].get("DrmId") logger.debug( f"Session authorization result - authorized: {authorized}, session_id: {session_id}" ) @@ -618,18 +571,14 @@ class HRTiAuthenticator(BaseAuthenticator): def report_session_event(self, session_id: str, channel_id: str = None) -> bool: """Report session event (like play start)""" try: - bearer_token = ( - self._current_token.access_token if self._current_token else "" - ) + bearer_token = self._current_token.access_token if self._current_token else "" headers = self._get_api_headers(bearer_token) # Set referer based on channel if channel_id is None: headers["referer"] = f"{self.config.base_website}/videostore" else: - headers["referer"] = ( - f"{self.config.base_website}/live/tv?channel={channel_id}" - ) + headers["referer"] = f"{self.config.base_website}/live/tv?channel={channel_id}" payload = {"SessionEventId": 1, "SessionId": session_id} # 1 = play start @@ -665,9 +614,7 @@ class HRTiAuthenticator(BaseAuthenticator): license_bytes = json.dumps(drm_license).encode("utf-8") license_b64 = base64.b64encode(license_bytes).decode("utf-8") - logger.debug( - f"License data (base64, first 30 chars): {license_b64[:30]}..." - ) + logger.debug(f"License data (base64, first 30 chars): {license_b64[:30]}...") return license_b64 except Exception as e: @@ -700,13 +647,10 @@ class HRTiAuthenticator(BaseAuthenticator): self.provider_name, self.country ) except TypeError: - stored_creds = self.settings_manager.get_provider_credentials( - self.provider_name - ) + stored_creds = self.settings_manager.get_provider_credentials(self.provider_name) has_user_creds = ( - isinstance(stored_creds, UserPasswordCredentials) - and stored_creds.validate() + isinstance(stored_creds, UserPasswordCredentials) and stored_creds.validate() ) # Check current credentials @@ -721,10 +665,7 @@ class HRTiAuthenticator(BaseAuthenticator): return TokenAuthLevel.USER_AUTHENTICATED # Check if using anonymous credentials - if ( - hasattr(self.credentials, "username") - and self.credentials.username == "anonymoushrt" - ): + if hasattr(self.credentials, "username") and self.credentials.username == "anonymoushrt": return TokenAuthLevel.ANONYMOUS # If we have user credentials, consider it user authenticated @@ -758,13 +699,10 @@ class HRTiAuthenticator(BaseAuthenticator): self.provider_name, self.country ) except TypeError: - stored_creds = self.settings_manager.get_provider_credentials( - self.provider_name - ) + stored_creds = self.settings_manager.get_provider_credentials(self.provider_name) has_stored_user_creds = ( - isinstance(stored_creds, UserPasswordCredentials) - and stored_creds.validate() + isinstance(stored_creds, UserPasswordCredentials) and stored_creds.validate() ) # Check current credentials @@ -778,9 +716,7 @@ class HRTiAuthenticator(BaseAuthenticator): return False - def get_bearer_token( - self, force_refresh: bool = False, force_upgrade: bool = False - ) -> str: + def get_bearer_token(self, force_refresh: bool = False, force_upgrade: bool = False) -> str: """ Get bearer token with automatic upgrade from anonymous to user credentials """ @@ -809,9 +745,7 @@ class HRTiAuthenticator(BaseAuthenticator): self.provider_name, self.country ) except TypeError: - user_creds = self.settings_manager.get_provider_credentials( - self.provider_name - ) + user_creds = self.settings_manager.get_provider_credentials(self.provider_name) if not user_creds or not user_creds.validate(): logger.debug("No valid user credentials available for upgrade") @@ -821,9 +755,7 @@ class HRTiAuthenticator(BaseAuthenticator): self.credentials = user_creds # Perform authentication with user credentials - logger.info( - "Performing authentication with user credentials for upgrade" - ) + logger.info("Performing authentication with user credentials for upgrade") user_token = self._perform_authentication() if user_token and not user_token.is_expired: diff --git a/lib/streaming_providers/providers/hrti/constants.py b/lib/streaming_providers/providers/hrti/constants.py index 604d19e..69c7cb4 100644 --- a/lib/streaming_providers/providers/hrti/constants.py +++ b/lib/streaming_providers/providers/hrti/constants.py @@ -68,14 +68,10 @@ class HRTiConfig: self.base_url = config.get("base_url", HRTiDefaults.BASE_URL) self.hsapi_base_url = config.get("hsapi_base_url", HRTiDefaults.HSAPI_BASE_URL) self.env_endpoint = config.get("env_endpoint", HRTiDefaults.ENV_ENDPOINT) - self.config_endpoint = config.get( - "config_endpoint", HRTiDefaults.CONFIG_ENDPOINT - ) + self.config_endpoint = config.get("config_endpoint", HRTiDefaults.CONFIG_ENDPOINT) # API endpoints configuration - self.api_endpoints = config.get( - "api_endpoints", HRTiDefaults.API_ENDPOINTS.copy() - ) + self.api_endpoints = config.get("api_endpoints", HRTiDefaults.API_ENDPOINTS.copy()) # DRM and License self.license_url = config.get("license_url", HRTiDefaults.LICENSE_URL) @@ -88,9 +84,7 @@ class HRTiConfig: "operator_reference_id", HRTiDefaults.OPERATOR_REFERENCE_ID ) self.merchant = config.get("merchant", HRTiDefaults.MERCHANT) - self.connection_type = config.get( - "connection_type", HRTiDefaults.CONNECTION_TYPE - ) + self.connection_type = config.get("connection_type", HRTiDefaults.CONNECTION_TYPE) self.application_version = config.get( "application_version", HRTiDefaults.APPLICATION_VERSION ) diff --git a/lib/streaming_providers/providers/hrti/provider.py b/lib/streaming_providers/providers/hrti/provider.py index 90bb8a9..0d502d9 100644 --- a/lib/streaming_providers/providers/hrti/provider.py +++ b/lib/streaming_providers/providers/hrti/provider.py @@ -4,8 +4,7 @@ from typing import ClassVar, Dict, List, Optional import requests -from ...base.models import (DRMConfig, DRMSystem, LicenseConfig, - LicenseUnwrapperParams) +from ...base.models import DRMConfig, DRMSystem, LicenseConfig, LicenseUnwrapperParams from ...base.models.proxy_models import ProxyConfig from ...base.models.streaming_channel import StreamingChannel from ...base.provider import AuthType, StreamingProvider @@ -56,9 +55,7 @@ class HRTiProvider(StreamingProvider): ) # Share HTTP manager for consistency - self.http_manager = self._share_http_manager_with_authenticator( - self.authenticator - ) + self.http_manager = self._share_http_manager_with_authenticator(self.authenticator) try: # Initialize authentication @@ -176,9 +173,7 @@ class HRTiProvider(StreamingProvider): channels.append(streaming_channel) self.channels = channels - logger.info( - f"Successfully fetched {len(channels)} channels from HRTi on retry" - ) + logger.info(f"Successfully fetched {len(channels)} channels from HRTi on retry") return channels else: logger.warning("No channels found in HRTi retry response") @@ -228,9 +223,7 @@ class HRTiProvider(StreamingProvider): channel.use_cdm = True channel.cdm_type = "widevine" - logger.debug( - f"Parsed HRTi channel: {name} ({channel_id}) - radio: {is_radio}" - ) + logger.debug(f"Parsed HRTi channel: {name} ({channel_id}) - radio: {is_radio}") return channel except Exception as e: @@ -276,9 +269,7 @@ class HRTiProvider(StreamingProvider): ) if not session_data: - logger.warning( - f"Failed to authorize session for channel {channel.name}" - ) + logger.warning(f"Failed to authorize session for channel {channel.name}") return channel # Check if authorized @@ -301,9 +292,7 @@ class HRTiProvider(StreamingProvider): # Set DRM configuration with session data # Pass session_data to avoid re-authorizing - drm_configs = self.get_drm( - channel.channel_id, session_data=session_data, **kwargs - ) + drm_configs = self.get_drm(channel.channel_id, session_data=session_data, **kwargs) if drm_configs: channel.use_cdm = True channel.cdm_type = "widevine" @@ -367,9 +356,7 @@ class HRTiProvider(StreamingProvider): logger.error(f"Error getting manifest for channel {channel_id}: {e}") return None - def get_drm( - self, channel_id: str, session_data: Dict = None, **kwargs - ) -> List[DRMConfig]: + def get_drm(self, channel_id: str, session_data: Dict = None, **kwargs) -> List[DRMConfig]: """ Get DRM configurations for a channel with proper license data. If session_data is not provided, will authorize a new session. @@ -394,15 +381,11 @@ class HRTiProvider(StreamingProvider): break if not target_channel: - logger.error( - f"Channel {channel_id} not found for DRM authorization" - ) + logger.error(f"Channel {channel_id} not found for DRM authorization") return [] # Determine content type - content_type = ( - "rlive" if target_channel.content_type == "AUDIO" else "tlive" - ) + content_type = "rlive" if target_channel.content_type == "AUDIO" else "tlive" # Parse the streaming URL to get content DRM ID from urllib.parse import urlparse @@ -431,16 +414,12 @@ class HRTiProvider(StreamingProvider): ) if not session_data: - logger.error( - f"Failed to authorize session for DRM - channel {channel_id}" - ) + logger.error(f"Failed to authorize session for DRM - channel {channel_id}") return [] # Check if authorized if not session_data.get("Authorized", False): - logger.warning( - f"Session not authorized for DRM - channel {channel_id}" - ) + logger.warning(f"Session not authorized for DRM - channel {channel_id}") return [] logger.debug(f"Session authorized for DRM - channel {channel_id}") @@ -497,13 +476,9 @@ class HRTiProvider(StreamingProvider): ) # Create the DRM configuration - drm_config = DRMConfig( - system=DRMSystem.WIDEVINE, priority=1, license=license_config - ) + drm_config = DRMConfig(system=DRMSystem.WIDEVINE, priority=1, license=license_config) - logger.debug( - f"Created DRM config for channel {channel_id} with DrmId {drm_id}" - ) + logger.debug(f"Created DRM config for channel {channel_id} with DrmId {drm_id}") return [drm_config] except Exception as e: @@ -592,9 +567,7 @@ class HRTiProvider(StreamingProvider): # HRTi doesn't provide XMLTV format natively return None - def get_dynamic_manifest_params( - self, channel: StreamingChannel, **kwargs - ) -> Optional[str]: + def get_dynamic_manifest_params(self, channel: StreamingChannel, **kwargs) -> Optional[str]: """ Get dynamic manifest parameters for HRTi channels. diff --git a/lib/streaming_providers/providers/joyn/__init__.py b/lib/streaming_providers/providers/joyn/__init__.py index 554a1ef..52006e6 100644 --- a/lib/streaming_providers/providers/joyn/__init__.py +++ b/lib/streaming_providers/providers/joyn/__init__.py @@ -1,7 +1,11 @@ # streaming_providers/providers/joyn/__init__.py from .auth import JoynAuthenticator, JoynAuthToken, JoynCredentials -from .constants import (COUNTRY_TENANT_MAPPING, DEFAULT_VIDEO_CONFIG, - JOYN_GRAPHQL_ENDPOINTS, JOYN_STREAMING_ENDPOINTS) +from .constants import ( + COUNTRY_TENANT_MAPPING, + DEFAULT_VIDEO_CONFIG, + JOYN_GRAPHQL_ENDPOINTS, + JOYN_STREAMING_ENDPOINTS, +) from .models import JoynChannel, PlaybackRestrictedException from .provider import JoynProvider diff --git a/lib/streaming_providers/providers/joyn/auth.py b/lib/streaming_providers/providers/joyn/auth.py index 5732ece..3262994 100644 --- a/lib/streaming_providers/providers/joyn/auth.py +++ b/lib/streaming_providers/providers/joyn/auth.py @@ -12,11 +12,20 @@ from ...base.auth.base_oauth2_auth import BaseOAuth2Authenticator from ...base.auth.credentials import ClientCredentials from ...base.models.proxy_models import ProxyConfig from ...base.utils.logger import logger -from .constants import (COUNTRY_TENANT_MAPPING, DEFAULT_COUNTRY, - DEFAULT_PLATFORM, DEFAULT_REQUEST_TIMEOUT, DEVICE_IDS, - JOYN_AUTH_ENDPOINTS, JOYN_CLIENT_VERSION, JOYN_DOMAINS, - JOYN_OAUTH_SCOPE, JOYN_SSO_DISCOVERY_URL, - JOYN_USER_AGENT, SUPPORTED_COUNTRIES) +from .constants import ( + COUNTRY_TENANT_MAPPING, + DEFAULT_COUNTRY, + DEFAULT_PLATFORM, + DEFAULT_REQUEST_TIMEOUT, + DEVICE_IDS, + JOYN_AUTH_ENDPOINTS, + JOYN_CLIENT_VERSION, + JOYN_DOMAINS, + JOYN_OAUTH_SCOPE, + JOYN_SSO_DISCOVERY_URL, + JOYN_USER_AGENT, + SUPPORTED_COUNTRIES, +) class JoynSSODiscovery: @@ -110,9 +119,7 @@ class JoynCredentials(ClientCredentials): def __post_init__(self): # Set client_id from constant if not provided if not self.client_id: - self.client_id = DEVICE_IDS.get( - self.client_name, DEVICE_IDS[DEFAULT_PLATFORM] - ) + self.client_id = DEVICE_IDS.get(self.client_name, DEVICE_IDS[DEFAULT_PLATFORM]) if not self.distribution_tenant and self.country in COUNTRY_TENANT_MAPPING: self.distribution_tenant = COUNTRY_TENANT_MAPPING[self.country] @@ -349,9 +356,7 @@ class JoynAuthenticator(BaseOAuth2Authenticator): if not client_id: raise Exception("No client_id found in login endpoint") - logger.debug( - f"Extracted and cached client_id for {self.platform}: {client_id}" - ) + logger.debug(f"Extracted and cached client_id for {self.platform}: {client_id}") return client_id except Exception as e: @@ -399,9 +404,7 @@ class JoynAuthenticator(BaseOAuth2Authenticator): raise Exception("No credentials available") return self.credentials.to_auth_payload() - def _create_token_from_response( - self, response_data: Dict[str, Any] - ) -> BaseAuthToken: + def _create_token_from_response(self, response_data: Dict[str, Any]) -> BaseAuthToken: """Create token object from API response""" token = JoynAuthToken( access_token=response_data["access_token"], @@ -434,22 +437,17 @@ class JoynAuthenticator(BaseOAuth2Authenticator): """ Perform authentication using appropriate flow based on credential type """ - from ...base.auth.credentials import (ClientCredentials, - UserPasswordCredentials) + from ...base.auth.credentials import ClientCredentials, UserPasswordCredentials if isinstance(self.credentials, UserPasswordCredentials): # Use OAuth2 authorization code flow with PKCE - logger.info( - f"Using OAuth2 authorization code flow for {self.provider_name}" - ) + logger.info(f"Using OAuth2 authorization code flow for {self.provider_name}") token_data = self._perform_oauth_authorization_code_flow( self.credentials.username, self.credentials.password ) elif isinstance(self.credentials, ClientCredentials): # Use client credentials flow (anonymous auth) - logger.info( - f"Using OAuth2 client credentials flow for {self.provider_name}" - ) + logger.info(f"Using OAuth2 client credentials flow for {self.provider_name}") token_data = self._perform_oauth_client_credentials_flow() else: raise Exception( @@ -483,9 +481,7 @@ class JoynAuthenticator(BaseOAuth2Authenticator): response.raise_for_status() token_data = response.json() - logger.debug( - f"OAuth2 client credentials flow successful for {self.provider_name}" - ) + logger.debug(f"OAuth2 client credentials flow successful for {self.provider_name}") return token_data except Exception as e: @@ -534,18 +530,14 @@ class JoynAuthenticator(BaseOAuth2Authenticator): initiate_result = initiate_response.json() if not initiate_result.get("success"): - raise Exception( - f"Password verification initiation failed: {initiate_result}" - ) + raise Exception(f"Password verification initiation failed: {initiate_result}") exchange_data = initiate_result["data"] exchange_id = exchange_data["exchange_id"]["exchange_id"] sub = exchange_data["sub"] status_id = exchange_data["status_id"] - logger.debug( - f"Got exchange_id: {exchange_id}, sub: {sub}, status_id: {status_id}" - ) + logger.debug(f"Got exchange_id: {exchange_id}, sub: {sub}, status_id: {status_id}") # Step 2: Authenticate with password authenticate_data = { @@ -564,9 +556,7 @@ class JoynAuthenticator(BaseOAuth2Authenticator): timeout=self._config.timeout, ) - logger.debug( - f"Authenticate response status: {authenticate_response.status_code}" - ) + logger.debug(f"Authenticate response status: {authenticate_response.status_code}") authenticate_response.raise_for_status() authenticate_result = authenticate_response.json() @@ -656,9 +646,7 @@ class JoynAuthenticator(BaseOAuth2Authenticator): fragment_params = parse_qs(fragment) auth_code = fragment_params.get("code", [None])[0] if auth_code: - logger.debug( - f"Found authorization code in fragment: {auth_code}" - ) + logger.debug(f"Found authorization code in fragment: {auth_code}") return auth_code # If no code found, try to follow redirects @@ -712,18 +700,14 @@ class JoynAuthenticator(BaseOAuth2Authenticator): logger.debug("Fetching authorization page") auth_response = session.get(authorization_url, timeout=self._config.timeout) - logger.debug( - f"Authorization page response status: {auth_response.status_code}" - ) + logger.debug(f"Authorization page response status: {auth_response.status_code}") logger.debug(f"Authorization page response URL: {auth_response.url}") auth_response.raise_for_status() # Step 2a: Check if we got redirected directly to callback (existing session) if self.oauth_redirect_uri in auth_response.url: - logger.info( - "User already authenticated - extracting code from redirect" - ) + logger.info("User already authenticated - extracting code from redirect") # Parse the redirect URL for authorization code parsed_url = urlparse(auth_response.url) @@ -733,16 +717,12 @@ class JoynAuthenticator(BaseOAuth2Authenticator): received_state = query_params.get("state", [None])[0] if not auth_code: - raise Exception( - "Redirect to callback but no authorization code found" - ) + raise Exception("Redirect to callback but no authorization code found") if not self.validate_oauth_state(received_state, state): raise Exception("State validation failed on direct redirect") - logger.debug( - f"Extracted authorization code from direct redirect: {auth_code}" - ) + logger.debug(f"Extracted authorization code from direct redirect: {auth_code}") # Exchange code for token logger.debug("Exchanging authorization code for tokens") @@ -752,9 +732,7 @@ class JoynAuthenticator(BaseOAuth2Authenticator): state=state, ) - logger.debug( - "Joyn OAuth2 authorization code flow successful (existing session)" - ) + logger.debug("Joyn OAuth2 authorization code flow successful (existing session)") return token_data # Step 2b: No existing session - need to perform login @@ -773,9 +751,7 @@ class JoynAuthenticator(BaseOAuth2Authenticator): if request_id_match: request_id = request_id_match.group(1) else: - raise Exception( - "Could not extract request_id from authorization page" - ) + raise Exception("Could not extract request_id from authorization page") logger.debug(f"Extracted request_id: {request_id}") @@ -853,9 +829,7 @@ class JoynAuthenticator(BaseOAuth2Authenticator): # Use DEVICE_IDS directly instead of oauth_client_id # Joyn refresh might need the platform-specific device ID, not the OAuth client ID payload = { - "client_id": DEVICE_IDS.get( - self.platform, DEVICE_IDS[DEFAULT_PLATFORM] - ), + "client_id": DEVICE_IDS.get(self.platform, DEVICE_IDS[DEFAULT_PLATFORM]), "client_name": self.platform, "grant_type": "Bearer", # Joyn uses 'Bearer' instead of 'refresh_token' "refresh_token": self._current_token.refresh_token, @@ -965,9 +939,7 @@ class JoynAuthenticator(BaseOAuth2Authenticator): # 2. Check for social_id presence - clear indicator of user authentication if "social_id" in claims: - logger.debug( - "Token classified as USER_AUTHENTICATED (social_id present)" - ) + logger.debug("Token classified as USER_AUTHENTICATED (social_id present)") return TokenAuthLevel.USER_AUTHENTICATED # 3. Check client ID (cId) against known client IDs @@ -987,9 +959,7 @@ class JoynAuthenticator(BaseOAuth2Authenticator): if subject and len(subject) == 36: # UUID format # Client credentials tokens often have UUID subjects representing the client # User tokens might have different patterns or include user identifiers - logger.debug( - "Token classified as CLIENT_CREDENTIALS (UUID subject pattern)" - ) + logger.debug("Token classified as CLIENT_CREDENTIALS (UUID subject pattern)") return TokenAuthLevel.CLIENT_CREDENTIALS # 5. Fallback: Check token scope or other claims @@ -997,14 +967,10 @@ class JoynAuthenticator(BaseOAuth2Authenticator): if scope: scopes = scope.split() if "offline_access" in scopes and "profile" in scopes: - logger.debug( - "Token classified as USER_AUTHENTICATED (user scopes present)" - ) + logger.debug("Token classified as USER_AUTHENTICATED (user scopes present)") return TokenAuthLevel.USER_AUTHENTICATED elif "openid" in scopes and len(scopes) <= 2: - logger.debug( - "Token classified as CLIENT_CREDENTIALS (minimal scopes)" - ) + logger.debug("Token classified as CLIENT_CREDENTIALS (minimal scopes)") return TokenAuthLevel.CLIENT_CREDENTIALS logger.warning(f"Could not definitively classify token, using UNKNOWN") @@ -1037,9 +1003,7 @@ class JoynAuthenticator(BaseOAuth2Authenticator): "cId": claims.get("cId", "MISSING"), "social_id": "PRESENT" if "social_id" in claims else "MISSING", "sub": ( - claims.get("sub", "MISSING")[:8] + "..." - if claims.get("sub") - else "MISSING" + claims.get("sub", "MISSING")[:8] + "..." if claims.get("sub") else "MISSING" ), "scope": claims.get("scope", "MISSING"), } diff --git a/lib/streaming_providers/providers/joyn/models.py b/lib/streaming_providers/providers/joyn/models.py index 88d4a2c..2dd573f 100644 --- a/lib/streaming_providers/providers/joyn/models.py +++ b/lib/streaming_providers/providers/joyn/models.py @@ -205,7 +205,9 @@ class JoynChannel: return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False) def __str__(self) -> str: - return f"JoynChannel(name='{self.name}', id='{self.channel_id}', type='{self.content_type}')" + return ( + f"JoynChannel(name='{self.name}', id='{self.channel_id}', type='{self.content_type}')" + ) def __repr__(self) -> str: return self.__str__() diff --git a/lib/streaming_providers/providers/joyn/provider.py b/lib/streaming_providers/providers/joyn/provider.py index b8bc4e7..2bd3bc3 100644 --- a/lib/streaming_providers/providers/joyn/provider.py +++ b/lib/streaming_providers/providers/joyn/provider.py @@ -15,20 +15,37 @@ from ...base.models.streaming_channel import StreamingChannel from ...base.provider import AuthType, StreamingProvider from ...base.utils.logger import logger from .auth import JoynAuthenticator -from .constants import (CONTENT_TYPE_LIVE, CONTENT_TYPE_VOD, - COUNTRY_TENANT_MAPPING, DEFAULT_EPG_WINDOW_HOURS, - DEFAULT_LIVESTREAM_TYPES, DEFAULT_MAX_RETRIES, - DEFAULT_PLATFORM, DEFAULT_REQUEST_TIMEOUT, - DEFAULT_VIDEO_CONFIG, DRM_REQUEST_HEADERS, - DRM_SYSTEM_WIDEVINE, ERROR_CODES, - GRAPHQL_LIVE_CHANNELS_FILTER, GRAPHQL_MAX_RESULTS, - GRAPHQL_OFFSET, GRAPHQL_PERSISTED_QUERY_VERSION, - GRAPHQL_QUERY_HASHES, JOYN_API_BASE_HEADERS, - JOYN_CLIENT_VERSION, JOYN_DOMAINS, - JOYN_GRAPHQL_BASE_HEADERS, JOYN_GRAPHQL_ENDPOINTS, - JOYN_LOGO, JOYN_STREAMING_ENDPOINTS, JOYN_USER_AGENT, - MODE_LIVE, MODE_VOD, SIGNATURE_SECRET_KEY, - SUPPORTED_COUNTRIES) +from .constants import ( + CONTENT_TYPE_LIVE, + CONTENT_TYPE_VOD, + COUNTRY_TENANT_MAPPING, + DEFAULT_EPG_WINDOW_HOURS, + DEFAULT_LIVESTREAM_TYPES, + DEFAULT_MAX_RETRIES, + DEFAULT_PLATFORM, + DEFAULT_REQUEST_TIMEOUT, + DEFAULT_VIDEO_CONFIG, + DRM_REQUEST_HEADERS, + DRM_SYSTEM_WIDEVINE, + ERROR_CODES, + GRAPHQL_LIVE_CHANNELS_FILTER, + GRAPHQL_MAX_RESULTS, + GRAPHQL_OFFSET, + GRAPHQL_PERSISTED_QUERY_VERSION, + GRAPHQL_QUERY_HASHES, + JOYN_API_BASE_HEADERS, + JOYN_CLIENT_VERSION, + JOYN_DOMAINS, + JOYN_GRAPHQL_BASE_HEADERS, + JOYN_GRAPHQL_ENDPOINTS, + JOYN_LOGO, + JOYN_STREAMING_ENDPOINTS, + JOYN_USER_AGENT, + MODE_LIVE, + MODE_VOD, + SIGNATURE_SECRET_KEY, + SUPPORTED_COUNTRIES, +) from .models import JoynChannel, PlaybackRestrictedException @@ -91,9 +108,7 @@ class JoynProvider(StreamingProvider): """ if not self.validate_country(country): supported = ", ".join(self.SUPPORTED_COUNTRIES) - raise ValueError( - f"Unsupported country: {country}. " f"Joyn supports: {supported}" - ) + raise ValueError(f"Unsupported country: {country}. " f"Joyn supports: {supported}") super().__init__(country=country) @@ -170,9 +185,7 @@ class JoynProvider(StreamingProvider): ) return self.bearer_token - def get_dynamic_manifest_params( - self, channel: StreamingChannel, **kwargs - ) -> Optional[str]: + def get_dynamic_manifest_params(self, channel: StreamingChannel, **kwargs) -> Optional[str]: return None def _get_graphql_headers(self) -> Dict[str, str]: @@ -251,9 +264,7 @@ class JoynProvider(StreamingProvider): if fetch_manifests and populate_streaming_data: channels = self.populate_streaming_data(channels) - logger.info( - f"Successfully fetched {len(channels)} channels for country {self.country}" - ) + logger.info(f"Successfully fetched {len(channels)} channels for country {self.country}") return channels except Exception as e: @@ -278,9 +289,7 @@ class JoynProvider(StreamingProvider): if "logo" in stream_data and "url" in stream_data["logo"]: logo_url = stream_data["logo"]["url"] - content_type = ( - CONTENT_TYPE_LIVE if stream_type == "LINEAR" else CONTENT_TYPE_VOD - ) + content_type = CONTENT_TYPE_LIVE if stream_type == "LINEAR" else CONTENT_TYPE_VOD mode = MODE_LIVE if stream_type == "LINEAR" else MODE_VOD joyn_channel = JoynChannel( @@ -314,9 +323,7 @@ class JoynProvider(StreamingProvider): return channels - def get_entitlement_token( - self, content_id: str, content_type: str = CONTENT_TYPE_LIVE - ) -> str: + def get_entitlement_token(self, content_id: str, content_type: str = CONTENT_TYPE_LIVE) -> str: """ Get entitlement token for content @@ -364,9 +371,7 @@ class JoynProvider(StreamingProvider): f"Playback restricted for {content_id}: {msg}" ) else: - raise Exception( - f"Entitlement error for {content_id} ({code}): {msg}" - ) + raise Exception(f"Entitlement error for {content_id} ({code}): {msg}") except (json.JSONDecodeError, KeyError, IndexError) as e: raise Exception( f"Bad response for {content_id} (400), failed to parse error: {e}" @@ -489,20 +494,14 @@ class JoynProvider(StreamingProvider): except Exception as e: retries += 1 if retries < max_retries: - logger.debug( - f"Retry {retries}/{max_retries} for {channel.name}: {e}" - ) + logger.debug(f"Retry {retries}/{max_retries} for {channel.name}: {e}") time.sleep(1) else: - logger.error( - f"Failed to get streaming data for {channel.name}: {e}" - ) + logger.error(f"Failed to get streaming data for {channel.name}: {e}") logger.info(f"Streaming data population complete:") logger.info(f" Successful: {len(successful_channels)}") - logger.info( - f" Restricted: {len([c for c in channels if c not in successful_channels])}" - ) + logger.info(f" Restricted: {len([c for c in channels if c not in successful_channels])}") logger.info(f" Total: {len(channels)}") return successful_channels @@ -589,9 +588,7 @@ class JoynProvider(StreamingProvider): content_id=channel_id, content_type=content_type ) - playlist_data = self.get_channel_playlist( - channel_id, entitlement_token, video_config - ) + playlist_data = self.get_channel_playlist(channel_id, entitlement_token, video_config) return playlist_data.get("manifestUrl") @@ -623,9 +620,7 @@ class JoynProvider(StreamingProvider): content_id=channel_id, content_type=content_type ) - playlist_data = self.get_channel_playlist( - channel_id, entitlement_token, video_config - ) + playlist_data = self.get_channel_playlist(channel_id, entitlement_token, video_config) license_url = playlist_data.get("licenseUrl") if not license_url: @@ -683,9 +678,7 @@ class JoynProvider(StreamingProvider): headers = self._get_graphql_headers() # Joyn EPG would require additional GraphQL queries - logger.info( - f"EPG data requested for channel {channel_id} - not yet implemented" - ) + logger.info(f"EPG data requested for channel {channel_id} - not yet implemented") return [] except Exception as e: diff --git a/lib/streaming_providers/providers/magenta2/__init__.py b/lib/streaming_providers/providers/magenta2/__init__.py index 45e57bb..ce2e669 100644 --- a/lib/streaming_providers/providers/magenta2/__init__.py +++ b/lib/streaming_providers/providers/magenta2/__init__.py @@ -1,13 +1,22 @@ # streaming_providers/providers/magenta2/__init__.py from .auth import Magenta2Authenticator, Magenta2AuthToken, Magenta2Credentials -from .config_models import (BootstrapConfig, DrmConfig, ManifestConfig, - MpxConfig, OpenIDConfig, ProviderConfig, - TvHubConfig) +from .config_models import ( + BootstrapConfig, + DrmConfig, + ManifestConfig, + MpxConfig, + OpenIDConfig, + ProviderConfig, + TvHubConfig, +) from .constants import SUPPORTED_COUNTRIES from .discovery import DiscoveryService from .endpoint_manager import EndpointCategory, EndpointManager -from .models import (DeviceLimitExceededException, Magenta2Channel, - Magenta2PlaybackRestrictedException) +from .models import ( + DeviceLimitExceededException, + Magenta2Channel, + Magenta2PlaybackRestrictedException, +) from .provider import Magenta2Provider __all__ = [ diff --git a/lib/streaming_providers/providers/magenta2/auth.py b/lib/streaming_providers/providers/magenta2/auth.py index a960d66..1dc6c1b 100644 --- a/lib/streaming_providers/providers/magenta2/auth.py +++ b/lib/streaming_providers/providers/magenta2/auth.py @@ -18,14 +18,21 @@ import uuid from dataclasses import dataclass, field from typing import Any, Dict, Optional -from ...base.auth.base_auth import (BaseAuthenticator, BaseAuthToken, - TokenAuthLevel) +from ...base.auth.base_auth import BaseAuthenticator, BaseAuthToken, TokenAuthLevel from ...base.auth.credentials import ClientCredentials from ...base.utils.logger import logger -from .constants import (APPVERSION2, DEFAULT_COUNTRY, DEFAULT_PLATFORM, IDM, - MAGENTA2_CLIENT_IDS, MAGENTA2_PLATFORMS, - SSO_USER_AGENT, SUPPORTED_COUNTRIES, - TAA_REQUEST_TEMPLATE) +from .constants import ( + APPVERSION2, + DEFAULT_COUNTRY, + DEFAULT_PLATFORM, + IDM, + MAGENTA2_CLIENT_IDS, + MAGENTA2_PLATFORMS, + SSO_USER_AGENT, + SUPPORTED_COUNTRIES, + TAA_REQUEST_TEMPLATE, +) + # Import Magenta2-specific components from .sam3_client import Sam3Client from .sso_client import SsoClient @@ -142,11 +149,7 @@ class Magenta2UserCredentials(Magenta2Credentials): def validate_user_credentials(self) -> bool: """Validate user credentials""" - return ( - self.has_user_credentials() - and len(self.username) > 0 - and len(self.password) > 0 - ) + return self.has_user_credentials() and len(self.username) > 0 and len(self.password) > 0 @property def credential_type(self) -> str: @@ -258,9 +261,7 @@ class Magenta2AuthConfig: return headers @staticmethod - def get_sso_headers( - session_id: str = None, device_id: str = None - ) -> Dict[str, str]: + def get_sso_headers(session_id: str = None, device_id: str = None) -> Dict[str, str]: """Get headers for SSO requests""" headers = { "User-Agent": SSO_USER_AGENT, @@ -431,34 +432,22 @@ class Magenta2Authenticator(BaseAuthenticator): # TokenFlowManager handles all token acquisition return {} - def _create_token_from_response( - self, response_data: Dict[str, Any] - ) -> BaseAuthToken: + def _create_token_from_response(self, response_data: Dict[str, Any]) -> BaseAuthToken: """ Create token object from API response and compose persona token """ # Handle different response key formats - access_token = response_data.get( - "access_token", response_data.get("accessToken") - ) + access_token = response_data.get("access_token", response_data.get("accessToken")) if not access_token: raise ValueError("No access token in response") # Create token with ALL fields token = Magenta2AuthToken( access_token=access_token, - refresh_token=response_data.get( - "refresh_token", response_data.get("refreshToken", "") - ), - token_type=response_data.get( - "token_type", response_data.get("tokenType", "Bearer") - ), - expires_in=response_data.get( - "expires_in", response_data.get("expiresIn", 3600) - ), - issued_at=response_data.get( - "issued_at", response_data.get("issuedAt", time.time()) - ), + refresh_token=response_data.get("refresh_token", response_data.get("refreshToken", "")), + token_type=response_data.get("token_type", response_data.get("tokenType", "Bearer")), + expires_in=response_data.get("expires_in", response_data.get("expiresIn", 3600)), + issued_at=response_data.get("issued_at", response_data.get("issuedAt", time.time())), # Magenta2-specific fields from JWT dc_cts_persona_token=response_data.get("dc_cts_persona_token"), persona_id=response_data.get("persona_id"), @@ -495,9 +484,7 @@ class Magenta2Authenticator(BaseAuthenticator): token.account_uri = f"urn:theplatform:auth:root:{self._mpx_account_pid}" composed = token.compose_persona_token() if composed: - logger.info( - "✓ Persona token composed using constructed account_uri" - ) + logger.info("✓ Persona token composed using constructed account_uri") # Classify token if it's NOT from line_auth if not response_data.get("auth_source") == "line_auth": @@ -558,22 +545,13 @@ class Magenta2Authenticator(BaseAuthenticator): if not token or not token.access_token: return TokenAuthLevel.UNKNOWN - claims = ( - token.get_jwt_claims() if hasattr(token, "get_jwt_claims") else None - ) + claims = token.get_jwt_claims() if hasattr(token, "get_jwt_claims") else None if not claims: # If we can't parse claims, check token attributes - if ( - hasattr(token, "dc_cts_persona_token") - and token.dc_cts_persona_token - ): - logger.debug( - "Token classified as USER_AUTHENTICATED (persona token present)" - ) + if hasattr(token, "dc_cts_persona_token") and token.dc_cts_persona_token: + logger.debug("Token classified as USER_AUTHENTICATED (persona token present)") return TokenAuthLevel.USER_AUTHENTICATED - logger.debug( - "Token classified as CLIENT_CREDENTIALS (no claims, no persona token)" - ) + logger.debug("Token classified as CLIENT_CREDENTIALS (no claims, no persona token)") return TokenAuthLevel.CLIENT_CREDENTIALS logger.debug(f"JWT claims for classification: {list(claims.keys())}") @@ -599,9 +577,7 @@ class Magenta2Authenticator(BaseAuthenticator): for key in user_claim_keys: if key in claims: - logger.debug( - f"Token classified as USER_AUTHENTICATED (found {key} in JWT)" - ) + logger.debug(f"Token classified as USER_AUTHENTICATED (found {key} in JWT)") return TokenAuthLevel.USER_AUTHENTICATED # Check for client credentials patterns @@ -631,9 +607,7 @@ class Magenta2Authenticator(BaseAuthenticator): logger.info("Refreshing Magenta2 token via TokenFlowManager") # Force refresh through TokenFlowManager - persona_result = self.token_flow_manager.get_persona_token( - force_refresh=True - ) + persona_result = self.token_flow_manager.get_persona_token(force_refresh=True) if persona_result.success: token = Magenta2AuthToken( @@ -678,16 +652,12 @@ class Magenta2Authenticator(BaseAuthenticator): # Get QR code URL from dynamic endpoints if "login_qr_code" in self._dynamic_endpoints: qr_code_url_template = self._dynamic_endpoints["login_qr_code"] - logger.debug( - f"QR code URL from dynamic endpoints: {qr_code_url_template}" - ) + logger.debug(f"QR code URL from dynamic endpoints: {qr_code_url_template}") if self._openid_config: issuer_url = self._openid_config.get("issuer") oauth_endpoint = self._openid_config.get("token_endpoint") - backchannel_start_url = self._openid_config.get( - "backchannel_auth_start" - ) + backchannel_start_url = self._openid_config.get("backchannel_auth_start") self._sam3_client = Sam3Client( http_manager=self._http_manager, @@ -715,9 +685,7 @@ class Magenta2Authenticator(BaseAuthenticator): def _initialize_taa_client(self) -> None: """Initialize TAA client""" - self._taa_client = TaaClient( - http_manager=self._http_manager, platform=self.platform - ) + self._taa_client = TaaClient(http_manager=self._http_manager, platform=self.platform) logger.debug("TAA client initialized") def _initialize_token_flow_manager(self) -> None: @@ -725,9 +693,7 @@ class Magenta2Authenticator(BaseAuthenticator): if self._sam3_client and self._taa_client: session_manager = getattr(self.settings_manager, "session_manager", None) if not session_manager: - logger.error( - "Cannot initialize TokenFlowManager: No session_manager available" - ) + logger.error("Cannot initialize TokenFlowManager: No session_manager available") return self.token_flow_manager = TokenFlowManager( @@ -765,9 +731,7 @@ class Magenta2Authenticator(BaseAuthenticator): if not self.token_flow_manager: raise Exception("TokenFlowManager not initialized") - persona_result = self.token_flow_manager.get_persona_token( - force_refresh=force_refresh - ) + persona_result = self.token_flow_manager.get_persona_token(force_refresh=force_refresh) if not persona_result.success: raise Exception(f"Failed to get persona token: {persona_result.error}") @@ -808,13 +772,9 @@ class Magenta2Authenticator(BaseAuthenticator): if self._sam3_client: self._sam3_client.update_sam3_client_id(client_id) - logger.info( - f"✓ Updated SAM3 client ID: {old_client_id[:8]}... -> {client_id[:8]}..." - ) + logger.info(f"✓ Updated SAM3 client ID: {old_client_id[:8]}... -> {client_id[:8]}...") else: - logger.debug( - f"Updated SAM3 client ID (no client to update yet): {client_id}" - ) + logger.debug(f"Updated SAM3 client ID (no client to update yet): {client_id}") def update_client_model(self, client_model: str) -> None: """Update client model""" @@ -846,9 +806,7 @@ class Magenta2Authenticator(BaseAuthenticator): self._mpx_account_pid = account_pid logger.debug(f"MPX account PID set: {account_pid}") - def set_device_token( - self, device_token: str, authorize_tokens_url: str = None - ) -> None: + def set_device_token(self, device_token: str, authorize_tokens_url: str = None) -> None: """ Enhanced device token setup with both endpoints @@ -863,9 +821,7 @@ class Magenta2Authenticator(BaseAuthenticator): if self._sam3_client and authorize_tokens_url: self._sam3_client.line_auth_endpoint = authorize_tokens_url self._sam3_client.token_endpoint = authorize_tokens_url # Backwards compat - logger.info( - f"✓ Updated SAM3 client with line auth endpoint: {authorize_tokens_url}" - ) + logger.info(f"✓ Updated SAM3 client with line auth endpoint: {authorize_tokens_url}") logger.debug("Device token configured with line authentication support") @@ -931,9 +887,7 @@ class Magenta2Authenticator(BaseAuthenticator): def can_use_remote_login(self) -> bool: """Check if remote login components are available""" - return ( - self._sam3_client is not None and self._sam3_client.can_use_remote_login() - ) + return self._sam3_client is not None and self._sam3_client.can_use_remote_login() def get_authentication_capabilities(self) -> Dict[str, Any]: """Get authentication capabilities information""" @@ -943,9 +897,7 @@ class Magenta2Authenticator(BaseAuthenticator): return { "line_auth_available": line_auth_available, "remote_login_available": remote_login_available, - "user_credentials_available": isinstance( - self.credentials, Magenta2UserCredentials - ) + "user_credentials_available": isinstance(self.credentials, Magenta2UserCredentials) and self.credentials.has_user_credentials(), "client_credentials_available": True, "preferred_flow": ( @@ -969,9 +921,7 @@ class Magenta2Authenticator(BaseAuthenticator): def get_authentication_flow_info(self) -> Dict[str, Any]: """Get authentication flow information""" base_info = { - "user_credentials_available": isinstance( - self.credentials, Magenta2UserCredentials - ) + "user_credentials_available": isinstance(self.credentials, Magenta2UserCredentials) and self.credentials.has_user_credentials(), "client_credentials_available": True, "sam3_client_available": self._sam3_client is not None, @@ -991,9 +941,7 @@ class Magenta2Authenticator(BaseAuthenticator): # Add TAA-specific info if available if self._taa_client and self._current_token: - base_info["taa_token_valid"] = self.validate_taa_token( - self._current_token.access_token - ) + base_info["taa_token_valid"] = self.validate_taa_token(self._current_token.access_token) return base_info @@ -1021,9 +969,7 @@ class Magenta2Authenticator(BaseAuthenticator): """ try: if not self._device_token or not self._authorize_tokens_url: - logger.warning( - "Line auth skipped - missing device token or authorize URL" - ) + logger.warning("Line auth skipped - missing device token or authorize URL") return False if not self._sam3_client: @@ -1070,9 +1016,7 @@ class Magenta2Authenticator(BaseAuthenticator): if response_data and "refresh_token" in response_data: self._line_auth_refresh_token = response_data["refresh_token"] logger.info(f"✓ Stored refresh token from line auth for SAM3 requests") - logger.debug( - f"Refresh token preview: {self._line_auth_refresh_token[:20]}..." - ) + logger.debug(f"Refresh token preview: {self._line_auth_refresh_token[:20]}...") return response_data @@ -1150,13 +1094,9 @@ class Magenta2Authenticator(BaseAuthenticator): return { "has_access_token": bool(token.access_token), - "has_dc_cts_persona_token": bool( - getattr(token, "dc_cts_persona_token", None) - ), + "has_dc_cts_persona_token": bool(getattr(token, "dc_cts_persona_token", None)), "has_account_uri": bool(getattr(token, "account_uri", None)), - "has_composed_persona_token": bool( - getattr(token, "composed_persona_token", None) - ), + "has_composed_persona_token": bool(getattr(token, "composed_persona_token", None)), "persona_token_preview": ( getattr(token, "composed_persona_token", "")[:50] + "..." if getattr(token, "composed_persona_token", None) @@ -1189,15 +1129,11 @@ class Magenta2Authenticator(BaseAuthenticator): "auth_level": self._current_token.auth_level.value, "is_expired": self._current_token.is_expired, "has_refresh": bool(self._current_token.refresh_token), - "has_persona_token": bool( - getattr(self._current_token, "dc_cts_persona_token", None) - ), + "has_persona_token": bool(getattr(self._current_token, "dc_cts_persona_token", None)), "jwt_claims_available": bool(claims), "key_claims": ( { - "client_id": claims.get( - "client_id", claims.get("clientId", "MISSING") - ), + "client_id": claims.get("client_id", claims.get("clientId", "MISSING")), "persona_id": claims.get( "dc_cts_personaId", claims.get("personaId", "MISSING") ), diff --git a/lib/streaming_providers/providers/magenta2/concurrency.py b/lib/streaming_providers/providers/magenta2/concurrency.py index 46ca54f..7f87dc4 100644 --- a/lib/streaming_providers/providers/magenta2/concurrency.py +++ b/lib/streaming_providers/providers/magenta2/concurrency.py @@ -50,9 +50,7 @@ def extract_and_release_lock( return False # Build release URL with the same client_id formatted as player_{client_id} - base_url = ( - lock_params["concurrencyServiceUrl"].rstrip("/") + "/web/Concurrency/unlock" - ) + base_url = lock_params["concurrencyServiceUrl"].rstrip("/") + "/web/Concurrency/unlock" formatted_client_id = f"player_{client_id}" params = { @@ -107,9 +105,7 @@ def extract_and_release_lock( ) return False else: - logger.warning( - f"Concurrency lock release failed with status: {response.status_code}" - ) + logger.warning(f"Concurrency lock release failed with status: {response.status_code}") return False except Exception as e: diff --git a/lib/streaming_providers/providers/magenta2/config_models.py b/lib/streaming_providers/providers/magenta2/config_models.py index 000a97d..0d18b14 100644 --- a/lib/streaming_providers/providers/magenta2/config_models.py +++ b/lib/streaming_providers/providers/magenta2/config_models.py @@ -22,9 +22,7 @@ class BootstrapConfig: raw_data: Dict[str, Any] = field(default_factory=dict) @classmethod - def from_api_response( - cls, bootstrap_data: Dict[str, Any], platform: str - ) -> "BootstrapConfig": + def from_api_response(cls, bootstrap_data: Dict[str, Any], platform: str) -> "BootstrapConfig": """Create BootstrapConfig from API response""" base_settings = bootstrap_data.get("baseSettings", {}) @@ -123,27 +121,20 @@ class MpxConfig: for feed_key in feed_keys: feed_url = get_param(feed_key) if feed_url: - simple_key = ( - feed_key.replace("mpx", "") - .replace("Url", "") - .replace("BasicUrl", "") - ) + simple_key = feed_key.replace("mpx", "").replace("Url", "").replace("BasicUrl", "") feeds[simple_key] = feed_url # ADD THIS: Extract channel stations feed channel_stations_feed = get_param("mpxDefaultUrlAllChannelStationsFeed") return cls( - account_pid=get_param("mpxAccountPid") - or mpx_data.get("accountPid", "mdeprod"), + account_pid=get_param("mpxAccountPid") or mpx_data.get("accountPid", "mdeprod"), license_service_url=get_param("mpxBasicUrlGetApplicableDistributionRights") or mpx_data.get("licenseServiceUrl", ""), selector_service_url=get_param("mpxBasicUrlSelectorService") or mpx_data.get("selectorServiceUrl", ""), - user_profile_url=get_param("mpxUserProfileUrl") - or mpx_data.get("userProfileUrl"), - bookmark_base_url=get_param("mpxBookmarkBaseUrl") - or mpx_data.get("bookmarkBaseUrl"), + user_profile_url=get_param("mpxUserProfileUrl") or mpx_data.get("userProfileUrl"), + bookmark_base_url=get_param("mpxBookmarkBaseUrl") or mpx_data.get("bookmarkBaseUrl"), pvr_base_url=get_param("mpxPvrBaseUrl") or mpx_data.get("pvrBaseUrl"), feeds=feeds, channel_stations_feed=channel_stations_feed, @@ -239,9 +230,7 @@ class DrmConfig: else: vod_widevine = None # Not in parameters array - logger.debug( - f"DRM config: widevine={bool(widevine_url)}, fairplay={bool(fairplay_url)}" - ) + logger.debug(f"DRM config: widevine={bool(widevine_url)}, fairplay={bool(fairplay_url)}") return cls( widevine_license_url=widevine_url or "", diff --git a/lib/streaming_providers/providers/magenta2/constants.py b/lib/streaming_providers/providers/magenta2/constants.py index e11710e..6702178 100644 --- a/lib/streaming_providers/providers/magenta2/constants.py +++ b/lib/streaming_providers/providers/magenta2/constants.py @@ -130,9 +130,7 @@ MAGENTA2_FALLBACK_ENDPOINTS = { "ENTITLEMENT": "https://entitlement.p7s1.io/api/user/entitlement-token", } -MAGENTA2_FALLBACK_ACCOUNT_URI = ( - "http://access.auth.theplatform.com/data/Account/2709353023" -) +MAGENTA2_FALLBACK_ACCOUNT_URI = "http://access.auth.theplatform.com/data/Account/2709353023" # ============================================================================ # Application Configuration diff --git a/lib/streaming_providers/providers/magenta2/discovery.py b/lib/streaming_providers/providers/magenta2/discovery.py index 11a82b1..15f1c8a 100644 --- a/lib/streaming_providers/providers/magenta2/discovery.py +++ b/lib/streaming_providers/providers/magenta2/discovery.py @@ -5,11 +5,15 @@ from typing import Any, Dict, Optional from ...base.models.proxy_models import ProxyConfig from ...base.network import HTTPManager from ...base.utils.logger import logger -from .config_models import (BootstrapConfig, ManifestConfig, OpenIDConfig, - ProviderConfig) -from .constants import (BOOTSTRAP_CACHE_DURATION, DEFAULT_REQUEST_TIMEOUT, - MAGENTA2_BOOTSTRAP_URL, MAGENTA2_MANIFEST_URL, - OPENID_CONFIG_CACHE_DURATION, SUBSCRIBER_TYPES) +from .config_models import BootstrapConfig, ManifestConfig, OpenIDConfig, ProviderConfig +from .constants import ( + BOOTSTRAP_CACHE_DURATION, + DEFAULT_REQUEST_TIMEOUT, + MAGENTA2_BOOTSTRAP_URL, + MAGENTA2_MANIFEST_URL, + OPENID_CONFIG_CACHE_DURATION, + SUBSCRIBER_TYPES, +) class DiscoveryService: @@ -97,9 +101,7 @@ class DiscoveryService: self._create_fallback_configuration() raise - def discover_bootstrap( - self, force_refresh: bool = False - ) -> Optional[BootstrapConfig]: + def discover_bootstrap(self, force_refresh: bool = False) -> Optional[BootstrapConfig]: """ Discover bootstrap configuration @@ -152,9 +154,7 @@ class DiscoveryService: if self._bootstrap_config.taa_url: logger.debug(f"TAA URL: {self._bootstrap_config.taa_url}") if self._bootstrap_config.device_tokens_url: - logger.debug( - f"Device tokens URL: {self._bootstrap_config.device_tokens_url}" - ) + logger.debug(f"Device tokens URL: {self._bootstrap_config.device_tokens_url}") return self._bootstrap_config @@ -165,9 +165,7 @@ class DiscoveryService: self._last_bootstrap = None return None - def discover_manifest( - self, force_refresh: bool = False - ) -> Optional[ManifestConfig]: + def discover_manifest(self, force_refresh: bool = False) -> Optional[ManifestConfig]: """ ENHANCED: Discover manifest configuration including device token Uses correct manifest endpoint parameters @@ -188,28 +186,27 @@ class DiscoveryService: # Determine manifest URL - prefer device_tokens_url from bootstrap if self._bootstrap_config and self._bootstrap_config.device_tokens_url: manifest_url = self._bootstrap_config.device_tokens_url - logger.debug( - f"Using bootstrap device_tokens_url for manifest: {manifest_url}" - ) + logger.debug(f"Using bootstrap device_tokens_url for manifest: {manifest_url}") else: terminal_type = self.terminal_type.lower().replace("_", "-") manifest_url = MAGENTA2_MANIFEST_URL.format(terminal_type=terminal_type) logger.debug(f"Using fallback manifest URL: {manifest_url}") # Build correct manifest parameters - from .constants import (MAGENTA2_APP_NAME, MAGENTA2_APP_VERSION, - MAGENTA2_RUNTIME_VERSION, - MANIFEST_FIRMWARE_MAPPINGS, - MANIFEST_MODEL_MAPPINGS) + from .constants import ( + MAGENTA2_APP_NAME, + MAGENTA2_APP_VERSION, + MAGENTA2_RUNTIME_VERSION, + MANIFEST_FIRMWARE_MAPPINGS, + MANIFEST_MODEL_MAPPINGS, + ) params = { "model": MANIFEST_MODEL_MAPPINGS.get(self.platform, "DT:ATV-AndroidTV"), "deviceId": self.device_id, "appname": MAGENTA2_APP_NAME, "appVersion": MAGENTA2_APP_VERSION, - "firmware": MANIFEST_FIRMWARE_MAPPINGS.get( - self.platform, "API level 30" - ), + "firmware": MANIFEST_FIRMWARE_MAPPINGS.get(self.platform, "API level 30"), "runtimeVersion": MAGENTA2_RUNTIME_VERSION, "duid": self.device_id, # Same as deviceId } @@ -237,9 +234,7 @@ class DiscoveryService: if device_token: logger.info("✓ Device token found in manifest") - logger.debug( - f"Device token preview: {device_token[:20]}...{device_token[-10:]}" - ) + logger.debug(f"Device token preview: {device_token[:20]}...{device_token[-10:]}") else: logger.warning("⚠️ Device token NOT found in manifest response") @@ -268,9 +263,7 @@ class DiscoveryService: self._last_manifest = None return None - def discover_openid_config( - self, force_refresh: bool = False - ) -> Optional[OpenIDConfig]: + def discover_openid_config(self, force_refresh: bool = False) -> Optional[OpenIDConfig]: """ Discover OpenID Connect configuration @@ -285,10 +278,7 @@ class DiscoveryService: return self._openid_config try: - if ( - not self._bootstrap_config - or not self._bootstrap_config.openid_config_url - ): + if not self._bootstrap_config or not self._bootstrap_config.openid_config_url: logger.warning("No OpenID config URL available from bootstrap") return None @@ -367,14 +357,10 @@ class DiscoveryService: "mpx_account_uri": self._manifest_config.mpx.get_account_uri(), "feed_count": len(self._manifest_config.mpx.feeds), "has_device_token": bool(device_token), - "device_token_preview": ( - device_token[:20] + "..." if device_token else None - ), + "device_token_preview": (device_token[:20] + "..." if device_token else None), "drm_endpoints": { "widevine": bool(self._manifest_config.drm.widevine_license_url), - "vod_widevine": bool( - self._manifest_config.drm.vod_widevine_license_url - ), + "vod_widevine": bool(self._manifest_config.drm.vod_widevine_license_url), "fairplay": bool(self._manifest_config.drm.fairplay_license_url), }, "tvhub_count": len(self._manifest_config.tv_hubs.base_urls), @@ -383,17 +369,13 @@ class DiscoveryService: if self._openid_config: status["openid"] = { "has_token_endpoint": bool(self._openid_config.token_endpoint), - "has_authorization_endpoint": bool( - self._openid_config.authorization_endpoint - ), + "has_authorization_endpoint": bool(self._openid_config.authorization_endpoint), } # Cache status now = time.time() status["cache"] = { - "bootstrap_age": ( - now - self._last_bootstrap if self._last_bootstrap else None - ), + "bootstrap_age": (now - self._last_bootstrap if self._last_bootstrap else None), "manifest_age": now - self._last_manifest if self._last_manifest else None, "openid_age": now - self._last_openid if self._last_openid else None, } diff --git a/lib/streaming_providers/providers/magenta2/endpoint_manager.py b/lib/streaming_providers/providers/magenta2/endpoint_manager.py index 610b3f2..8171c80 100644 --- a/lib/streaming_providers/providers/magenta2/endpoint_manager.py +++ b/lib/streaming_providers/providers/magenta2/endpoint_manager.py @@ -57,9 +57,7 @@ class EndpointManager: # Authentication endpoints if bootstrap.taa_url: - self._add_endpoint( - "taa_auth", EndpointCategory.AUTHENTICATION, bootstrap.taa_url - ) + self._add_endpoint("taa_auth", EndpointCategory.AUTHENTICATION, bootstrap.taa_url) if bootstrap.openid_config_url: self._add_endpoint( @@ -94,9 +92,7 @@ class EndpointManager: ) if bootstrap.account_base_url: - self._add_endpoint( - "account_base", EndpointCategory.USER, bootstrap.account_base_url - ) + self._add_endpoint("account_base", EndpointCategory.USER, bootstrap.account_base_url) if bootstrap.consumer_accounts_url: self._add_endpoint( @@ -129,9 +125,7 @@ class EndpointManager: EndpointCategory.CONTENT, manifest.mpx.channel_stations_feed, ) - logger.info( - f"Channel stations feed found: {manifest.mpx.channel_stations_feed}" - ) + logger.info(f"Channel stations feed found: {manifest.mpx.channel_stations_feed}") # DRM endpoints if manifest.drm.widevine_license_url: @@ -159,17 +153,13 @@ class EndpointManager: for feed_name, feed_template in manifest.mpx.feeds.items(): resolved_url = self.config.get_resolved_feed_url(feed_name) if resolved_url: - self._add_endpoint( - f"mpx_feed_{feed_name}", EndpointCategory.MPX, resolved_url - ) + self._add_endpoint(f"mpx_feed_{feed_name}", EndpointCategory.MPX, resolved_url) # TV Hub URLs (resolved with client model) for hub_name in manifest.tv_hubs.base_urls.keys(): resolved_url = self.config.get_resolved_tvhub_url(hub_name) if resolved_url: - self._add_endpoint( - f"tvhub_{hub_name}", EndpointCategory.TVHUBS, resolved_url - ) + self._add_endpoint(f"tvhub_{hub_name}", EndpointCategory.TVHUBS, resolved_url) def _add_openid_endpoints(self) -> None: """Add endpoints from OpenID configuration""" @@ -260,9 +250,7 @@ class EndpointManager: def get_endpoints_by_category(self, category: EndpointCategory) -> Dict[str, str]: """Get all endpoints for a specific category""" return { - name: info.url - for name, info in self._endpoints.items() - if info.category == category + name: info.url for name, info in self._endpoints.items() if info.category == category } def has_endpoint(self, name: str) -> bool: diff --git a/lib/streaming_providers/providers/magenta2/provider.py b/lib/streaming_providers/providers/magenta2/provider.py index 322539e..99f4fbe 100644 --- a/lib/streaming_providers/providers/magenta2/provider.py +++ b/lib/streaming_providers/providers/magenta2/provider.py @@ -15,15 +15,26 @@ from ...base.models.streaming_channel import StreamingChannel from ...base.network import HTTPManagerFactory, ProxyConfigManager from ...base.provider import StreamingProvider from ...base.utils.logger import logger -from .auth import (Magenta2Authenticator, Magenta2Credentials, - Magenta2UserCredentials) +from .auth import Magenta2Authenticator, Magenta2Credentials, Magenta2UserCredentials from .config_models import ProviderConfig -from .constants import (CONTENT_TYPE_LIVE, CONTENT_TYPE_VOD, DEFAULT_COUNTRY, - DEFAULT_EPG_WINDOW_HOURS, DEFAULT_MAX_RETRIES, - DEFAULT_PLATFORM, DEFAULT_REQUEST_TIMEOUT, - DRM_REQUEST_HEADERS, DRM_SYSTEM_WIDEVINE, ERROR_CODES, - MAGENTA2_CLIENT_IDS, MAGENTA2_LOGO, MAGENTA2_PLATFORMS, - MODE_LIVE, MODE_VOD, SUPPORTED_COUNTRIES) +from .constants import ( + CONTENT_TYPE_LIVE, + CONTENT_TYPE_VOD, + DEFAULT_COUNTRY, + DEFAULT_EPG_WINDOW_HOURS, + DEFAULT_MAX_RETRIES, + DEFAULT_PLATFORM, + DEFAULT_REQUEST_TIMEOUT, + DRM_REQUEST_HEADERS, + DRM_SYSTEM_WIDEVINE, + ERROR_CODES, + MAGENTA2_CLIENT_IDS, + MAGENTA2_LOGO, + MAGENTA2_PLATFORMS, + MODE_LIVE, + MODE_VOD, + SUPPORTED_COUNTRIES, +) from .discovery import DiscoveryService from .endpoint_manager import EndpointManager from .models import Magenta2Channel, Magenta2PlaybackRestrictedException @@ -155,9 +166,7 @@ class Magenta2Provider(StreamingProvider): # 🚨 UPDATE AUTHENTICATOR WITH DISCOVERED CONFIG if self.provider_config: # Update authenticator with discovered client_id and models - self.authenticator.provider_config = ( - self.provider_config - ) # ✅ Store the config + self.authenticator.provider_config = self.provider_config # ✅ Store the config logger.info("✓ ProviderConfig stored in authenticator") # Also update TokenFlowManager if it exists @@ -165,9 +174,7 @@ class Magenta2Provider(StreamingProvider): hasattr(self.authenticator, "token_flow_manager") and self.authenticator.token_flow_manager ): - self.authenticator.token_flow_manager.provider_config = ( - self.provider_config - ) + self.authenticator.token_flow_manager.provider_config = self.provider_config logger.info("✓ ProviderConfig also stored in TokenFlowManager") if self.provider_config.bootstrap.sam3_client_id: @@ -195,9 +202,7 @@ class Magenta2Provider(StreamingProvider): self.provider_config.bootstrap.client_model ) else: - self.authenticator._client_model = ( - self.provider_config.bootstrap.client_model - ) + self.authenticator._client_model = self.provider_config.bootstrap.client_model logger.debug( f"Updated authenticator with client model: {self.provider_config.bootstrap.client_model}" ) @@ -208,9 +213,7 @@ class Magenta2Provider(StreamingProvider): self.provider_config.bootstrap.device_model ) else: - self.authenticator._device_model = ( - self.provider_config.bootstrap.device_model - ) + self.authenticator._device_model = self.provider_config.bootstrap.device_model logger.debug( f"Updated authenticator with device model: {self.provider_config.bootstrap.device_model}" ) @@ -221,9 +224,7 @@ class Magenta2Provider(StreamingProvider): authorize_tokens_url = self.provider_config.get_authorize_tokens_url() if device_token: - self.authenticator.set_device_token( - device_token, authorize_tokens_url - ) + self.authenticator.set_device_token(device_token, authorize_tokens_url) logger.debug("Device token configured in authenticator") # CRITICAL: Pass MPX account PID for account URI construction @@ -237,9 +238,7 @@ class Magenta2Provider(StreamingProvider): # Pass OpenID configuration if available if self.provider_config.openid: - self.authenticator.set_openid_config( - self.provider_config.openid.raw_data - ) + self.authenticator.set_openid_config(self.provider_config.openid.raw_data) # Update authenticator with discovered endpoints using public methods if self.endpoint_manager: @@ -252,14 +251,10 @@ class Magenta2Provider(StreamingProvider): # Update authenticator with discovered endpoints using public method if hasattr(self.authenticator, "update_dynamic_endpoints"): self.authenticator.update_dynamic_endpoints(all_endpoints) - logger.info( - f"✓ Updated authenticator with {len(all_endpoints)} endpoints" - ) + logger.info(f"✓ Updated authenticator with {len(all_endpoints)} endpoints") elif hasattr(self.authenticator, "update_endpoints"): self.authenticator.update_endpoints(all_endpoints) - logger.info( - f"✓ Updated authenticator with {len(all_endpoints)} endpoints" - ) + logger.info(f"✓ Updated authenticator with {len(all_endpoints)} endpoints") else: logger.warning("No public method available to update endpoints") @@ -268,20 +263,14 @@ class Magenta2Provider(StreamingProvider): if qr_url and hasattr(self.authenticator, "update_sam3_qr_code_url"): success = self.authenticator.update_sam3_qr_code_url(qr_url) if success: - logger.info( - "✓ Successfully updated SAM3 client with QR code URL" - ) + logger.info("✓ Successfully updated SAM3 client with QR code URL") else: - logger.warning( - "✗ Failed to update SAM3 client with QR code URL" - ) + logger.warning("✗ Failed to update SAM3 client with QR code URL") # Initialize auth tokens (lazy - populated on first use) self.device_token = None self._persona_cache: Optional[PersonaResult] = None - self._smil_cache: Dict[str, Tuple[float, Dict]] = ( - {} - ) # channel_id -> (timestamp, smil_data) + self._smil_cache: Dict[str, Tuple[float, Dict]] = {} # channel_id -> (timestamp, smil_data) self._smil_cache_ttl = 3600 logger.info("Magenta2 provider initialization completed successfully") @@ -303,9 +292,7 @@ class Magenta2Provider(StreamingProvider): """Generate call ID for requests""" return str(uuid.uuid4()) - def _load_proxy_from_manager( - self, config_dir: Optional[str] - ) -> Optional[ProxyConfig]: + def _load_proxy_from_manager(self, config_dir: Optional[str]) -> Optional[ProxyConfig]: """Load proxy configuration from ProxyConfigManager""" try: proxy_manager = ProxyConfigManager(config_dir) @@ -325,9 +312,7 @@ class Magenta2Provider(StreamingProvider): self.provider_config = self.discovery_service.discover_provider_config() if not self.provider_config or not self.provider_config.is_complete: - logger.warning( - "Configuration discovery incomplete, some features may not work" - ) + logger.warning("Configuration discovery incomplete, some features may not work") # Initialize endpoint manager with discovered configuration self.endpoint_manager = EndpointManager(self.provider_config) @@ -336,21 +321,15 @@ class Magenta2Provider(StreamingProvider): if self.endpoint_manager: qr_url = self.endpoint_manager.get_endpoint("login_qr_code") if qr_url: - logger.info( - f"✓ QR code endpoint discovered in endpoint manager: {qr_url}" - ) + logger.info(f"✓ QR code endpoint discovered in endpoint manager: {qr_url}") # PROPER FIX: Use public method to update SAM3 client if hasattr(self.authenticator, "update_sam3_qr_code_url"): success = self.authenticator.update_sam3_qr_code_url(qr_url) if success: - logger.info( - "✓ Successfully updated SAM3 client with QR code URL" - ) + logger.info("✓ Successfully updated SAM3 client with QR code URL") else: - logger.warning( - "✗ Failed to update SAM3 client with QR code URL" - ) + logger.warning("✗ Failed to update SAM3 client with QR code URL") # Also debug the current status if hasattr(self.authenticator, "get_sam3_client_status"): @@ -364,16 +343,12 @@ class Magenta2Provider(StreamingProvider): authorize_tokens_url = self.provider_config.get_authorize_tokens_url() if device_token: - logger.info( - f"✓ Device token discovered (length: {len(device_token)})" - ) + logger.info(f"✓ Device token discovered (length: {len(device_token)})") else: logger.warning("⚠️ No device token found in manifest") if authorize_tokens_url: - logger.info( - f"✓ Line auth endpoint discovered: {authorize_tokens_url}" - ) + logger.info(f"✓ Line auth endpoint discovered: {authorize_tokens_url}") else: logger.warning("⚠️ No authorize tokens URL found in manifest") @@ -504,9 +479,7 @@ class Magenta2Provider(StreamingProvider): try: logger.info("Refreshing provider configuration") - new_config = self.discovery_service.discover_provider_config( - force_refresh=force - ) + new_config = self.discovery_service.discover_provider_config(force_refresh=force) if new_config and new_config.is_complete: self.provider_config = new_config @@ -515,18 +488,12 @@ class Magenta2Provider(StreamingProvider): # Update authenticator with new config if new_config.manifest: device_token = new_config.manifest.raw_data.get("deviceToken") - authorize_tokens_url = new_config.manifest.raw_data.get( - "authorizeTokensUrl" - ) + authorize_tokens_url = new_config.manifest.raw_data.get("authorizeTokensUrl") if device_token: - self.authenticator.set_device_token( - device_token, authorize_tokens_url - ) + self.authenticator.set_device_token(device_token, authorize_tokens_url) if new_config.manifest.mpx.account_pid: - self.authenticator.set_mpx_account_pid( - new_config.manifest.mpx.account_pid - ) + self.authenticator.set_mpx_account_pid(new_config.manifest.mpx.account_pid) logger.info("Configuration refresh successful") return True @@ -560,9 +527,7 @@ class Magenta2Provider(StreamingProvider): logger.warning("Device registration failed") return False else: - logger.warning( - "Device authentication not supported in current authenticator" - ) + logger.warning("Device authentication not supported in current authenticator") return False except Exception as e: @@ -610,9 +575,7 @@ class Magenta2Provider(StreamingProvider): self._persona_cache = None logger.debug("Cleared in-memory persona cache") - def get_dynamic_manifest_params( - self, channel: StreamingChannel, **kwargs - ) -> Optional[str]: + def get_dynamic_manifest_params(self, channel: StreamingChannel, **kwargs) -> Optional[str]: return None @staticmethod @@ -682,9 +645,7 @@ class Magenta2Provider(StreamingProvider): } # Build query string - query_string = "&".join( - [f"{k}={self._url_encode(v)}" for k, v in params.items()] - ) + query_string = "&".join([f"{k}={self._url_encode(v)}" for k, v in params.items()]) return f"{base_url}/iss?{query_string}" @@ -711,9 +672,7 @@ class Magenta2Provider(StreamingProvider): if display_number is None: # Process immediately if no number (no filtering needed) - channel = self._create_channel_from_entry( - entry, station_info, display_number - ) + channel = self._create_channel_from_entry(entry, station_info, display_number) if channel: channels.append(channel) continue @@ -742,9 +701,7 @@ class Magenta2Provider(StreamingProvider): # Convert best entries to channels for display_number, (entry, station_info, _) in best_entries.items(): - channel = self._create_channel_from_entry( - entry, station_info, display_number - ) + channel = self._create_channel_from_entry(entry, station_info, display_number) if channel: channels.append(channel) @@ -812,12 +769,8 @@ class Magenta2Provider(StreamingProvider): url = self.endpoint_manager.get_endpoint("channel_stations") if not url: url = self.endpoint_manager.get_endpoint("channel_list") - if not url and self.endpoint_manager.has_endpoint( - "mpx_feed_entitledChannelsFeed" - ): - url = self.endpoint_manager.get_endpoint( - "mpx_feed_entitledChannelsFeed" - ) + if not url and self.endpoint_manager.has_endpoint("mpx_feed_entitledChannelsFeed"): + url = self.endpoint_manager.get_endpoint("mpx_feed_entitledChannelsFeed") # Final fallback if not url: @@ -884,9 +837,7 @@ class Magenta2Provider(StreamingProvider): for entry in entries: try: title = entry.get("title", "Unknown") - channel_id = ( - entry.get("guid", "").split("/")[-1] if entry.get("guid") else "" - ) + channel_id = entry.get("guid", "").split("/")[-1] if entry.get("guid") else "" if not channel_id: continue @@ -899,9 +850,7 @@ class Magenta2Provider(StreamingProvider): raw_data=entry, ) - streaming_channel = channel.to_streaming_channel( - provider_name=self.provider_name - ) + streaming_channel = channel.to_streaming_channel(provider_name=self.provider_name) channels.append(streaming_channel) except Exception as e: @@ -933,18 +882,13 @@ class Magenta2Provider(StreamingProvider): logger.warning("Channel missing ID, skipping") continue - title = channel_data.get( - "title", channel_data.get("name", "Unknown Channel") - ) + title = channel_data.get("title", channel_data.get("name", "Unknown Channel")) stream_type = channel_data.get("type", "LIVE") quality = channel_data.get("quality", "") logo_url = None if "logo" in channel_data: - if ( - isinstance(channel_data["logo"], dict) - and "url" in channel_data["logo"] - ): + if isinstance(channel_data["logo"], dict) and "url" in channel_data["logo"]: logo_url = channel_data["logo"]["url"] elif isinstance(channel_data["logo"], str): logo_url = channel_data["logo"] @@ -952,9 +896,7 @@ class Magenta2Provider(StreamingProvider): logo_url = channel_data["image"] content_type = ( - CONTENT_TYPE_LIVE - if stream_type.upper() == "LIVE" - else CONTENT_TYPE_VOD + CONTENT_TYPE_LIVE if stream_type.upper() == "LIVE" else CONTENT_TYPE_VOD ) mode = MODE_LIVE if stream_type.upper() == "LIVE" else MODE_VOD @@ -981,9 +923,7 @@ class Magenta2Provider(StreamingProvider): return channels - def get_entitlement_token( - self, content_id: str, content_type: str = CONTENT_TYPE_LIVE - ) -> str: + def get_entitlement_token(self, content_id: str, content_type: str = CONTENT_TYPE_LIVE) -> str: """ Get entitlement token using persona_token Basic auth """ @@ -1002,9 +942,7 @@ class Magenta2Provider(StreamingProvider): ) try: - logger.debug( - f"Requesting entitlement token with persona token for: {content_id}" - ) + logger.debug(f"Requesting entitlement token with persona token for: {content_id}") response = self.http_manager.post( url, operation="auth", @@ -1016,25 +954,19 @@ class Magenta2Provider(StreamingProvider): if response.status_code == 400: try: error_data = response.json() - error_list = ( - error_data if isinstance(error_data, list) else [error_data] - ) + error_list = error_data if isinstance(error_data, list) else [error_data] if len(error_list) > 0: error = error_list[0] code = error.get("code", error.get("errorCode", "UNKNOWN")) - msg = error.get( - "msg", error.get("message", "No error message provided") - ) + msg = error.get("msg", error.get("message", "No error message provided")) if code == ERROR_CODES["PLAYBACK_RESTRICTED"]: raise Magenta2PlaybackRestrictedException( f"Playback restricted for {content_id}: {msg}" ) else: - raise Exception( - f"Entitlement error for {content_id} ({code}): {msg}" - ) + raise Exception(f"Entitlement error for {content_id} ({code}): {msg}") except (json.JSONDecodeError, KeyError, IndexError) as e: raise Exception( f"Bad response for {content_id} (400), failed to parse error: {e}" @@ -1056,22 +988,16 @@ class Magenta2Provider(StreamingProvider): raise except KeyError as e: logger.error(f"No entitlement token in response for {content_id}: {e}") - logger.debug( - f"Auth state: {self.authenticator.debug_authentication_state()}" - ) + logger.debug(f"Auth state: {self.authenticator.debug_authentication_state()}") raise Exception(f"No entitlement token in response for {content_id}: {e}") except Exception as e: logger.error(f"Error getting entitlement token for {content_id}: {e}") - logger.debug( - f"Auth state: {self.authenticator.debug_authentication_state()}" - ) + logger.debug(f"Auth state: {self.authenticator.debug_authentication_state()}") raise Exception(f"Error getting entitlement token for {content_id}: {e}") def get_channel_playlist(self, channel_id: str, entitlement_token: str) -> Dict: """Get channel playlist data""" - if self.endpoint_manager and self.endpoint_manager.has_endpoint( - "channel_playlist" - ): + if self.endpoint_manager and self.endpoint_manager.has_endpoint("channel_playlist"): url = self.endpoint_manager.get_endpoint("channel_playlist").format( channel_id=channel_id ) @@ -1122,16 +1048,10 @@ class Magenta2Provider(StreamingProvider): logger.debug(f"Getting playlist data for: {channel.name}") - playlist_data = self.get_channel_playlist( - channel.channel_id, entitlement_token - ) + playlist_data = self.get_channel_playlist(channel.channel_id, entitlement_token) - manifest_url = playlist_data.get( - "manifestUrl", playlist_data.get("manifest") - ) - license_url = playlist_data.get( - "licenseUrl", playlist_data.get("license") - ) + manifest_url = playlist_data.get("manifestUrl", playlist_data.get("manifest")) + license_url = playlist_data.get("licenseUrl", playlist_data.get("license")) certificate_url = playlist_data.get( "certificateUrl", playlist_data.get("certificate") ) @@ -1160,14 +1080,10 @@ class Magenta2Provider(StreamingProvider): except Exception as e: retries += 1 if retries < max_retries: - logger.debug( - f"Retry {retries}/{max_retries} for {channel.name}: {e}" - ) + logger.debug(f"Retry {retries}/{max_retries} for {channel.name}: {e}") time.sleep(1) else: - logger.error( - f"Failed to get streaming data for {channel.name}: {e}" - ) + logger.error(f"Failed to get streaming data for {channel.name}: {e}") logger.info(f"Streaming data population complete:") logger.info(f" Successful: {len(successful_channels)}") @@ -1187,13 +1103,9 @@ class Magenta2Provider(StreamingProvider): content_id=channel.channel_id, content_type=channel.content_type ) - playlist_data = self.get_channel_playlist( - channel.channel_id, entitlement_token - ) + playlist_data = self.get_channel_playlist(channel.channel_id, entitlement_token) - manifest_url = playlist_data.get( - "manifestUrl", playlist_data.get("manifest") - ) + manifest_url = playlist_data.get("manifestUrl", playlist_data.get("manifest")) if not manifest_url: return None @@ -1256,9 +1168,7 @@ class Magenta2Provider(StreamingProvider): ) # First check for error cases - error_title_pattern = ( - r']*title="([^"]*)"[^>]*abstract="([^"]*)"[^>]*>' - ) + error_title_pattern = r']*title="([^"]*)"[^>]*abstract="([^"]*)"[^>]*>' error_match = re.search(error_title_pattern, smil_content) if error_match: @@ -1270,14 +1180,10 @@ class Magenta2Provider(StreamingProvider): # Check for specific error patterns if "errorFiles/Unavailable.flv" in smil_content: - logger.error( - f"SMIL returned unavailable content for channel {channel_id}" - ) + logger.error(f"SMIL returned unavailable content for channel {channel_id}") return None if "Invalid Token" in title or "InvalidAuthToken" in smil_content: - logger.error( - f"Invalid authentication token for channel {channel_id}" - ) + logger.error(f"Invalid authentication token for channel {channel_id}") return None if "403" in smil_content: logger.error(f"Access forbidden (403) for channel {channel_id}") @@ -1325,9 +1231,7 @@ class Magenta2Provider(StreamingProvider): return mpd_url # If we get here, no MPD URL was found - logger.warning( - f"No MPD URL found in SMIL response for channel {channel_id}" - ) + logger.warning(f"No MPD URL found in SMIL response for channel {channel_id}") # Log the full SMIL content for debugging in case of unexpected format if len(smil_content) < 1000: # Only log if it's reasonably short @@ -1380,26 +1284,18 @@ class Magenta2Provider(StreamingProvider): start_iso = TimestampConverter.epoch_to_iso( start_time, format_type="basic", as_utc=True ) - end_iso = TimestampConverter.epoch_to_iso( - end_time, format_type="basic", as_utc=True - ) + end_iso = TimestampConverter.epoch_to_iso(end_time, format_type="basic", as_utc=True) # Build the catchup manifest URL # Check if the manifest already has query parameters separator = "&" if "?" in base_manifest else "?" - catchup_manifest = ( - f"{base_manifest}{separator}begin={start_iso}&end={end_iso}" - ) + catchup_manifest = f"{base_manifest}{separator}begin={start_iso}&end={end_iso}" - logger.debug( - f"Catchup manifest for channel {channel_id}: {catchup_manifest}" - ) + logger.debug(f"Catchup manifest for channel {channel_id}: {catchup_manifest}") return catchup_manifest except Exception as e: - logger.error( - f"Error building catchup manifest for channel {channel_id}: {e}" - ) + logger.error(f"Error building catchup manifest for channel {channel_id}: {e}") # Fall back to live manifest if catchup formatting fails logger.warning(f"Falling back to live manifest for channel {channel_id}") return base_manifest @@ -1449,9 +1345,7 @@ class Magenta2Provider(StreamingProvider): self._smil_cache[channel_id] = (now, smil_data) logger.debug(f"Cached SMIL data for {channel_id}") else: - logger.warning( - f"No MPD URL or releasePid found for {channel_id}, not caching" - ) + logger.warning(f"No MPD URL or releasePid found for {channel_id}, not caching") return smil_data @@ -1466,9 +1360,7 @@ class Magenta2Provider(StreamingProvider): try: logger.debug("🔵 Step 1: Calling _ensure_authenticated()") persona_token = self._ensure_authenticated() - logger.debug( - f"🔵 _ensure_authenticated() SUCCESS, token length: {len(persona_token)}" - ) + logger.debug(f"🔵 _ensure_authenticated() SUCCESS, token length: {len(persona_token)}") if not persona_token: logger.error(f"No persona token!:") @@ -1499,9 +1391,7 @@ class Magenta2Provider(StreamingProvider): for key, value in headers.items(): if key == "Authorization": # Don't log full auth token for security, but show it exists - logger.debug( - f" {key}: Basic [REDACTED] (length: {len(persona_token)})" - ) + logger.debug(f" {key}: Basic [REDACTED] (length: {len(persona_token)})") else: logger.debug(f" {key}: {value}") @@ -1534,16 +1424,12 @@ class Magenta2Provider(StreamingProvider): smil_content, self.http_manager, client_id=client_id, # Use the same client_id as SMIL request - user_agent=self.platform_config[ - "user_agent" - ], # Platform user agent + user_agent=self.platform_config["user_agent"], # Platform user agent ) return smil_content else: - logger.error( - f"Failed to get SMIL content for DRM: {response.status_code}" - ) + logger.error(f"Failed to get SMIL content for DRM: {response.status_code}") return None except Exception as e: @@ -1630,9 +1516,7 @@ class Magenta2Provider(StreamingProvider): # Debug what we do have logger.debug(f"SMIL data keys: {list(smil_data.keys())}") if "content" in smil_data and smil_data["content"]: - logger.debug( - f"SMIL content preview: {smil_data['content'][:500]}..." - ) + logger.debug(f"SMIL content preview: {smil_data['content'][:500]}...") return [] release_pid = smil_data["release_pid"] @@ -1807,9 +1691,7 @@ class Magenta2Provider(StreamingProvider): if current_time >= (expires_at - 300): # Token expired but might be refreshable # Check if we have yo_digital token with refresh capability - yo_token = context.get_token( - self.provider_name, "yo_digital", self.country - ) + yo_token = context.get_token(self.provider_name, "yo_digital", self.country) if yo_token and "refresh_token" in yo_token: # Check if yo_digital refresh token is still valid if ( @@ -1860,40 +1742,26 @@ class Magenta2Provider(StreamingProvider): expires_at = token["expires_at"] scope_info["expires_at"] = expires_at scope_info["is_expired"] = current_time >= expires_at - scope_info["time_remaining"] = int( - max(0, expires_at - current_time) - ) + scope_info["time_remaining"] = int(max(0, expires_at - current_time)) if "composed_at" in token: scope_info["composed_at"] = token["composed_at"] # Handle yo_digital token elif scope == "yo_digital": - if ( - "access_token_expires_in" in token - and "access_token_issued_at" in token - ): + if "access_token_expires_in" in token and "access_token_issued_at" in token: current_time = time.time() - expires_at = ( - token["access_token_issued_at"] - + token["access_token_expires_in"] - ) + expires_at = token["access_token_issued_at"] + token["access_token_expires_in"] scope_info["access_token_expires_at"] = expires_at scope_info["access_token_is_expired"] = current_time >= expires_at - if ( - "refresh_token_expires_in" in token - and "refresh_token_issued_at" in token - ): + if "refresh_token_expires_in" in token and "refresh_token_issued_at" in token: current_time = time.time() refresh_expires_at = ( - token["refresh_token_issued_at"] - + token["refresh_token_expires_in"] + token["refresh_token_issued_at"] + token["refresh_token_expires_in"] ) scope_info["refresh_token_expires_at"] = refresh_expires_at - scope_info["refresh_token_is_expired"] = ( - current_time >= refresh_expires_at - ) + scope_info["refresh_token_is_expired"] = current_time >= refresh_expires_at scope_info["has_refresh_token"] = True # Standard token handling (tvhubs, taa) @@ -1973,9 +1841,7 @@ class Magenta2Provider(StreamingProvider): result["endpoints"] = { "has_taa_auth": self.endpoint_manager.has_endpoint("taa_auth"), "has_entitlement": self.endpoint_manager.has_endpoint("entitlement"), - "has_widevine_license": self.endpoint_manager.has_endpoint( - "widevine_license" - ), + "has_widevine_license": self.endpoint_manager.has_endpoint("widevine_license"), "has_mpx_selector": self.endpoint_manager.has_endpoint("mpx_selector"), "total_endpoints": len(self.endpoint_manager.get_all_endpoints()), } diff --git a/lib/streaming_providers/providers/magenta2/remote_login_handler.py b/lib/streaming_providers/providers/magenta2/remote_login_handler.py index 23b3155..31d0097 100644 --- a/lib/streaming_providers/providers/magenta2/remote_login_handler.py +++ b/lib/streaming_providers/providers/magenta2/remote_login_handler.py @@ -12,11 +12,9 @@ from typing import Any, Dict, Optional from urllib.parse import parse_qs, unquote, urlparse from ...base.network import HTTPManager -from ...base.ui import (NotificationFactory, NotificationInterface, - NotificationResult) +from ...base.ui import NotificationFactory, NotificationInterface, NotificationResult from ...base.utils.logger import logger -from .constants import (DEFAULT_PLATFORM, DEFAULT_REQUEST_TIMEOUT, GRANT_TYPES, - MAGENTA2_PLATFORMS) +from .constants import DEFAULT_PLATFORM, DEFAULT_REQUEST_TIMEOUT, GRANT_TYPES, MAGENTA2_PLATFORMS @dataclass @@ -82,18 +80,14 @@ class RemoteLoginHandler: self._current_session: Optional[RemoteLoginSession] = None - logger.debug( - f"RemoteLoginHandler initialized with {self._notifier.__class__.__name__}" - ) + logger.debug(f"RemoteLoginHandler initialized with {self._notifier.__class__.__name__}") def set_notifier(self, notifier: NotificationInterface) -> None: """Set custom notification interface""" self._notifier = notifier logger.debug(f"Notifier set to: {notifier.__class__.__name__}") - def start_remote_login( - self, scope: str = "tvhubs offline_access" - ) -> RemoteLoginSession: + def start_remote_login(self, scope: str = "tvhubs offline_access") -> RemoteLoginSession: """ Start backchannel authentication flow @@ -306,9 +300,7 @@ class RemoteLoginHandler: # Perform poll poll_count += 1 - logger.debug( - f"Poll {poll_count}/{max_polls} (remaining: {remaining:.0f}s)" - ) + logger.debug(f"Poll {poll_count}/{max_polls} (remaining: {remaining:.0f}s)") try: response = self.http_manager.post( diff --git a/lib/streaming_providers/providers/magenta2/sam3_client.py b/lib/streaming_providers/providers/magenta2/sam3_client.py index 210ec98..dd0ecf5 100644 --- a/lib/streaming_providers/providers/magenta2/sam3_client.py +++ b/lib/streaming_providers/providers/magenta2/sam3_client.py @@ -99,9 +99,7 @@ class Sam3Client: def _get_token_endpoint(self) -> str: """Get the appropriate token endpoint with fallback logic""" - return ( - self.oauth_token_endpoint or self.token_endpoint or self.line_auth_endpoint - ) + return self.oauth_token_endpoint or self.token_endpoint or self.line_auth_endpoint def _get_line_auth_endpoint(self) -> str: """Get line auth endpoint with fallback""" @@ -277,9 +275,7 @@ class Sam3Client: code, state = self._extract_code_and_state(redirect_url) if not code or not state: - raise Exception( - "Could not extract authorization code and state from redirect" - ) + raise Exception("Could not extract authorization code and state from redirect") logger.info("SAM3 login completed successfully") return {"code": code, "state": state, "redirect_url": redirect_url} @@ -324,9 +320,7 @@ class Sam3Client: logger.info("Line authentication successful, refresh token obtained") return True - logger.warning( - "Line authentication succeeded but no refresh token received" - ) + logger.warning("Line authentication succeeded but no refresh token received") return False except Exception as e: @@ -403,9 +397,7 @@ class Sam3Client: """Check if remote login is available""" return self._get_remote_login_handler() is not None - def remote_login( - self, scope: str = "tvhubs offline_access" - ) -> Optional[Dict[str, Any]]: + def remote_login(self, scope: str = "tvhubs offline_access") -> Optional[Dict[str, Any]]: """ Perform complete remote login (backchannel auth) flow @@ -505,9 +497,7 @@ class Sam3Client: logger.warning(f"Token refresh failed for scope {scope}: {e}") # If no refresh token available, we can't get an access token - logger.warning( - f"No access token available for scope {scope} - line auth may be needed" - ) + logger.warning(f"No access token available for scope {scope} - line auth may be needed") return "" def get_token_endpoint(self) -> str: @@ -575,9 +565,7 @@ class Sam3Client: form_html = html_content[form_start:form_end] # Find all hidden input fields - pattern = ( - r']*type="hidden"[^>]*name="([^"]*)"[^>]*value="([^"]*)"[^>]*>' - ) + pattern = r']*type="hidden"[^>]*name="([^"]*)"[^>]*value="([^"]*)"[^>]*>' matches = re.findall(pattern, form_html, re.IGNORECASE) for name, value in matches: @@ -688,9 +676,7 @@ class Sam3Client: # Get backchannel auth start endpoint if "backchannel_auth_start" in openid_config: self.backchannel_start_url = openid_config["backchannel_auth_start"] - logger.debug( - f"Backchannel auth endpoint from OpenID: {self.backchannel_start_url}" - ) + logger.debug(f"Backchannel auth endpoint from OpenID: {self.backchannel_start_url}") # OAuth token endpoint might be different from line auth endpoint if "token_endpoint" in openid_config: diff --git a/lib/streaming_providers/providers/magenta2/taa_client.py b/lib/streaming_providers/providers/magenta2/taa_client.py index 63fe3dd..5138c0f 100644 --- a/lib/streaming_providers/providers/magenta2/taa_client.py +++ b/lib/streaming_providers/providers/magenta2/taa_client.py @@ -95,9 +95,7 @@ class TaaClient: ) logger.debug(f"yo_digital request to: {endpoint}") - logger.debug( - f"TAA payload keyValue: {taa_payload.get('keyValue', 'MISSING')}" - ) + logger.debug(f"TAA payload keyValue: {taa_payload.get('keyValue', 'MISSING')}") # Perform yo_digital request response = self.http_manager.post( @@ -119,12 +117,8 @@ class TaaClient: ) # Check for deviceLimitExceed (note: might be "Exceed" not "Exceeded") - if error_data.get("deviceLimitExceed") or error_data.get( - "deviceLimitExceeded" - ): - logger.error( - "Device limit exceeded in yo_digital authentication" - ) + if error_data.get("deviceLimitExceed") or error_data.get("deviceLimitExceeded"): + logger.error("Device limit exceeded in yo_digital authentication") return YoDigitalTokens( access_token="", access_token_expires_in=0, @@ -135,17 +129,13 @@ class TaaClient: # Log any other error details if "error" in error_data: - logger.error( - f"yo_digital error type: {error_data.get('error')}" - ) + logger.error(f"yo_digital error type: {error_data.get('error')}") if "error_description" in error_data: logger.error( f"yo_digital error description: {error_data.get('error_description')}" ) if "message" in error_data: - logger.error( - f"yo_digital error message: {error_data.get('message')}" - ) + logger.error(f"yo_digital error message: {error_data.get('message')}") except (ValueError, KeyError) as e: logger.error(f"Could not parse 400 error response: {e}") @@ -314,9 +304,7 @@ class TaaClient: error_data = response.json() if error_data.get("deviceLimitExceeded"): logger.error("Device limit exceeded in TAA authentication") - return TaaAuthResult( - access_token="", device_limit_exceeded=True - ) + return TaaAuthResult(access_token="", device_limit_exceeded=True) except: pass @@ -399,9 +387,7 @@ class TaaClient: ) # Get TAA-specific OS format - resolved_os = ( - self.platform_config.get("taa_os") or self.platform_config["firmware"] - ) + resolved_os = self.platform_config.get("taa_os") or self.platform_config["firmware"] resolved_client_model = client_model or f"ftv-{self.platform}" diff --git a/lib/streaming_providers/providers/magenta2/token_flow_manager.py b/lib/streaming_providers/providers/magenta2/token_flow_manager.py index b4f30d0..036e16c 100644 --- a/lib/streaming_providers/providers/magenta2/token_flow_manager.py +++ b/lib/streaming_providers/providers/magenta2/token_flow_manager.py @@ -110,9 +110,7 @@ class TokenFlowManager: if not token_result.success or not token_result.access_token: logger.debug("=== GET_PERSONA_TOKEN FAILED (token_result failed) ===") - return PersonaResult( - success=False, error=token_result.error or "No access token" - ) + return PersonaResult(success=False, error=token_result.error or "No access token") # Compose persona token with expiry information using existing method from .token_utils import PersonaTokenComposer @@ -161,9 +159,7 @@ class TokenFlowManager: current_time = time.time() expires_at = persona_data["expires_at"] - logger.debug( - f"🟡 Current time: {current_time}, Expires at: {expires_at}" - ) + logger.debug(f"🟡 Current time: {current_time}, Expires at: {expires_at}") # Check if cached token is still valid using the actual persona JWT expiry if current_time < (expires_at - 300): # 5-minute buffer @@ -176,9 +172,7 @@ class TokenFlowManager: expires_at=expires_at, # 🆕 Return expiry ) else: - logger.debug( - f"🟡 Cached persona token expired at {time.ctime(expires_at)}" - ) + logger.debug(f"🟡 Cached persona token expired at {time.ctime(expires_at)}") else: logger.debug("🟡 No valid persona data in cache") @@ -214,9 +208,7 @@ class TokenFlowManager: """Backward compatibility method - delegates to new composition""" from .token_utils import PersonaTokenComposer - result = PersonaTokenComposer.compose_from_jwt( - access_token, MAGENTA2_FALLBACK_ACCOUNT_URI - ) + result = PersonaTokenComposer.compose_from_jwt(access_token, MAGENTA2_FALLBACK_ACCOUNT_URI) return result.persona_token if result else None def get_yo_digital_token(self, force_refresh: bool = False) -> TokenFlowResult: @@ -312,9 +304,7 @@ class TokenFlowManager: except Exception as e: logger.debug(f"Error checking yo_digital access_token: {e}") - return TokenFlowResult( - success=False, error=str(e), flow_path="check_yo_digital_access" - ) + return TokenFlowResult(success=False, error=str(e), flow_path="check_yo_digital_access") # ======================================================================== # Step 2: Refresh yo_digital tokens @@ -344,9 +334,7 @@ class TokenFlowManager: # Refresh via TaaClient (stub for now) logger.debug("Attempting to refresh yo_digital tokens") - new_tokens_dict = self.taa_client.refresh_yo_digital_tokens( - token_data["refresh_token"] - ) + new_tokens_dict = self.taa_client.refresh_yo_digital_tokens(token_data["refresh_token"]) if not new_tokens_dict: return TokenFlowResult( @@ -369,9 +357,7 @@ class TokenFlowManager: except Exception as e: logger.debug(f"Error refreshing yo_digital tokens: {e}") - return TokenFlowResult( - success=False, error=str(e), flow_path="refresh_yo_digital" - ) + return TokenFlowResult(success=False, error=str(e), flow_path="refresh_yo_digital") # ======================================================================== # Step 3: Get yo_digital from taa access_token @@ -404,9 +390,7 @@ class TokenFlowManager: logger.debug("Getting yo_digital tokens from taa access_token") yo_digital_result = self.taa_client.get_yo_digital_tokens( taa_access_token=taa_token_data["access_token"], - device_id=self.session_manager.get_device_id( - self.provider_name, self.country - ), + device_id=self.session_manager.get_device_id(self.provider_name, self.country), ) if not yo_digital_result: @@ -419,12 +403,8 @@ class TokenFlowManager: logger.info("Clearing cached tokens and falling back to remote_login") # Clear the invalid cached tokens to prevent repeated failures - self.session_manager.clear_scoped_token( - self.provider_name, "taa", self.country - ) - self.session_manager.clear_scoped_token( - self.provider_name, "tvhubs", self.country - ) + self.session_manager.clear_scoped_token(self.provider_name, "taa", self.country) + self.session_manager.clear_scoped_token(self.provider_name, "tvhubs", self.country) # Fallback to remote_login return self._get_yo_digital_via_remote_login() @@ -449,9 +429,7 @@ class TokenFlowManager: except Exception as e: logger.debug(f"Error getting yo_digital from taa: {e}") - return TokenFlowResult( - success=False, error=str(e), flow_path="yo_digital_from_taa" - ) + return TokenFlowResult(success=False, error=str(e), flow_path="yo_digital_from_taa") # ======================================================================== # Step 4: Exchange shared refresh_token for taa, then yo_digital @@ -461,9 +439,7 @@ class TokenFlowManager: """Exchange shared refresh_token for taa, then get yo_digital""" try: # Check if we have shared refresh_token at provider level - session_data = self.session_manager.load_session( - self.provider_name, self.country - ) + session_data = self.session_manager.load_session(self.provider_name, self.country) if not session_data or "refresh_token" not in session_data: return TokenFlowResult( @@ -494,9 +470,7 @@ class TokenFlowManager: logger.debug("Getting yo_digital tokens from exchanged taa token") yo_digital_result = self.taa_client.get_yo_digital_tokens( taa_access_token=taa_token, - device_id=self.session_manager.get_device_id( - self.provider_name, self.country - ), + device_id=self.session_manager.get_device_id(self.provider_name, self.country), ) if not yo_digital_result: @@ -509,17 +483,11 @@ class TokenFlowManager: logger.info("Clearing cached tokens and falling back to remote_login") # Clear the invalid cached tokens and refresh_token - self.session_manager.clear_scoped_token( - self.provider_name, "taa", self.country - ) - self.session_manager.clear_scoped_token( - self.provider_name, "tvhubs", self.country - ) + self.session_manager.clear_scoped_token(self.provider_name, "taa", self.country) + self.session_manager.clear_scoped_token(self.provider_name, "tvhubs", self.country) # Clear the shared refresh_token from session data - session_data = self.session_manager.load_session( - self.provider_name, self.country - ) + session_data = self.session_manager.load_session(self.provider_name, self.country) if session_data and "refresh_token" in session_data: del session_data["refresh_token"] self.session_manager.save_session( @@ -609,9 +577,7 @@ class TokenFlowManager: logger.debug("Getting yo_digital tokens from line_auth taa token") yo_digital_result = self.taa_client.get_yo_digital_tokens( taa_access_token=taa_token, - device_id=self.session_manager.get_device_id( - self.provider_name, self.country - ), + device_id=self.session_manager.get_device_id(self.provider_name, self.country), ) if not yo_digital_result: @@ -621,9 +587,7 @@ class TokenFlowManager: "yo_digital acquisition failed after successful line_auth - " "likely ISP account differs from TV subscription account" ) - logger.info( - "Falling back to remote_login for TV account authentication" - ) + logger.info("Falling back to remote_login for TV account authentication") # Fallback to remote_login (tokens will be overwritten) return self._get_yo_digital_via_remote_login() @@ -677,9 +641,7 @@ class TokenFlowManager: logger.info("Attempting remote_login flow") # Perform remote login - remote_token_data = self.sam3_client.remote_login( - scope="tvhubs offline_access" - ) + remote_token_data = self.sam3_client.remote_login(scope="tvhubs offline_access") if not remote_token_data: return TokenFlowResult( @@ -717,9 +679,7 @@ class TokenFlowManager: logger.debug("Getting yo_digital tokens from remote_login taa token") yo_digital_result = self.taa_client.get_yo_digital_tokens( taa_access_token=taa_token, - device_id=self.session_manager.get_device_id( - self.provider_name, self.country - ), + device_id=self.session_manager.get_device_id(self.provider_name, self.country), ) if not yo_digital_result: @@ -766,9 +726,7 @@ class TokenFlowManager: ): return False - expires_at = ( - token_data["access_token_issued_at"] + token_data["access_token_expires_in"] - ) + expires_at = token_data["access_token_issued_at"] + token_data["access_token_expires_in"] # Use 5 minute buffer return time.time() < (expires_at - 300) @@ -781,10 +739,7 @@ class TokenFlowManager: ): return False - expires_at = ( - token_data["refresh_token_issued_at"] - + token_data["refresh_token_expires_in"] - ) + expires_at = token_data["refresh_token_issued_at"] + token_data["refresh_token_expires_in"] # Use 5 minute buffer return time.time() < (expires_at - 300) @@ -836,9 +791,7 @@ class TokenFlowManager: "issued_at": time.time(), } - self.session_manager.save_scoped_token( - self.provider_name, "taa", token_data, self.country - ) + self.session_manager.save_scoped_token(self.provider_name, "taa", token_data, self.country) logger.debug("taa token saved") def _save_tvhubs_token(self, token_data: Dict[str, Any]) -> None: @@ -857,18 +810,14 @@ class TokenFlowManager: def _save_refresh_token(self, refresh_token: str) -> None: """Save shared refresh_token at provider level""" - session_data = ( - self.session_manager.load_session(self.provider_name, self.country) or {} - ) + session_data = self.session_manager.load_session(self.provider_name, self.country) or {} session_data["refresh_token"] = refresh_token session_data["device_id"] = self.session_manager.get_device_id( self.provider_name, self.country ) - self.session_manager.save_session( - self.provider_name, session_data, self.country - ) + self.session_manager.save_session(self.provider_name, session_data, self.country) logger.debug("Shared refresh_token saved") # ======================================================================== @@ -905,9 +854,7 @@ class TokenFlowManager: def _get_taa_status(self) -> Dict[str, Any]: """Get taa token status""" - token_data = self.session_manager.load_scoped_token( - self.provider_name, "taa", self.country - ) + token_data = self.session_manager.load_scoped_token(self.provider_name, "taa", self.country) if not token_data: return {"exists": False} @@ -935,9 +882,7 @@ class TokenFlowManager: def _get_refresh_token_status(self) -> Dict[str, Any]: """Get shared refresh_token status""" - session_data = self.session_manager.load_session( - self.provider_name, self.country - ) + session_data = self.session_manager.load_session(self.provider_name, self.country) if not session_data or "refresh_token" not in session_data: return {"exists": False} diff --git a/lib/streaming_providers/providers/magenta2/token_utils.py b/lib/streaming_providers/providers/magenta2/token_utils.py index d1ac7e6..4c9a396 100644 --- a/lib/streaming_providers/providers/magenta2/token_utils.py +++ b/lib/streaming_providers/providers/magenta2/token_utils.py @@ -46,9 +46,7 @@ class JWTClaims: def is_user_token(self) -> bool: """Check if this is a user-authenticated token""" - return bool( - self.persona_id or self.account_id or self.consumer_id or self.tv_account_id - ) + return bool(self.persona_id or self.account_id or self.consumer_id or self.tv_account_id) class JWTParser: @@ -203,9 +201,7 @@ class PersonaTokenComposer: """Compose persona token and return with expiry information""" logger.debug(f"🟢 PersonaTokenComposer.compose_from_jwt START") try: - logger.debug( - f"🟢 Input JWT token length: {len(jwt_token) if jwt_token else 0}" - ) + logger.debug(f"🟢 Input JWT token length: {len(jwt_token) if jwt_token else 0}") claims = JWTParser.parse(jwt_token) if not claims: @@ -225,9 +221,7 @@ class PersonaTokenComposer: return None # Compose the persona token - composed_token = PersonaTokenComposer._compose_token( - account_uri, persona_jwt - ) + composed_token = PersonaTokenComposer._compose_token(account_uri, persona_jwt) if not composed_token: logger.error("🔴 _compose_token returned None") return None @@ -279,9 +273,7 @@ class PersonaTokenComposer: return None @staticmethod - def compose_from_components( - account_uri: str, dc_cts_persona_token: str - ) -> Optional[str]: + def compose_from_components(account_uri: str, dc_cts_persona_token: str) -> Optional[str]: """Original method - for backward compatibility""" return PersonaTokenComposer._compose_token(account_uri, dc_cts_persona_token) @@ -315,9 +307,7 @@ class PersonaTokenComposer: # Verify persona_jwt looks like a JWT if not persona_jwt.startswith("eyJ"): - logger.warning( - f"Extracted token doesn't look like a JWT: {persona_jwt[:20]}..." - ) + logger.warning(f"Extracted token doesn't look like a JWT: {persona_jwt[:20]}...") return {"account_uri": account_uri, "persona_jwt": persona_jwt} diff --git a/lib/streaming_providers/providers/magentaeu/__init__.py b/lib/streaming_providers/providers/magentaeu/__init__.py index 30f2d9b..3061819 100644 --- a/lib/streaming_providers/providers/magentaeu/__init__.py +++ b/lib/streaming_providers/providers/magentaeu/__init__.py @@ -1,7 +1,6 @@ # streaming_providers/providers/magenta_eu/__init__.py from .auth import MagentaAuthenticator, MagentaAuthToken -from .constants import (API_ENDPOINTS, COUNTRY_CONFIG, DEFAULT_COUNTRY, - SUPPORTED_COUNTRIES) +from .constants import API_ENDPOINTS, COUNTRY_CONFIG, DEFAULT_COUNTRY, SUPPORTED_COUNTRIES from .provider import MagentaEUProvider __all__ = [ diff --git a/lib/streaming_providers/providers/magentaeu/auth.py b/lib/streaming_providers/providers/magentaeu/auth.py index 2466290..0b9dd8b 100644 --- a/lib/streaming_providers/providers/magentaeu/auth.py +++ b/lib/streaming_providers/providers/magentaeu/auth.py @@ -17,19 +17,37 @@ except ImportError: from Crypto.Cipher import PKCS1_OAEP from Crypto.PublicKey import RSA -from ...base.auth.base_auth import (BaseAuthenticator, BaseAuthToken, - TokenAuthLevel) +from ...base.auth.base_auth import BaseAuthenticator, BaseAuthToken, TokenAuthLevel from ...base.models.proxy_models import ProxyConfig from ...base.utils.logger import logger -from .constants import (API_ENDPOINTS, APP_VERSION, AUTH_FLOWS, AUTH_STEPS, - BROADCASTING_STREAM_LIMITATION_APPLIES, CALL_TYPES, - CHANNEL_ID, COUNTRY_CONFIG, DEFAULT_COUNTRY, - DEFAULT_REQUEST_TIMEOUT, DEVICE_CONCURRENCY_PARAM, - DEVICE_MANUFACTURER, DEVICE_MODEL, DEVICE_NAME, - DEVICE_OS, DEVICE_TYPE, LOGIN_CONTEXT, LOGIN_TYPE, - MANAGE_DEVICE, SUPPORTED_COUNTRIES, USER_AGENT, - X_USER_AGENT, get_base_headers, get_base_url, - get_bifrost_url, get_language) +from .constants import ( + API_ENDPOINTS, + APP_VERSION, + AUTH_FLOWS, + AUTH_STEPS, + BROADCASTING_STREAM_LIMITATION_APPLIES, + CALL_TYPES, + CHANNEL_ID, + COUNTRY_CONFIG, + DEFAULT_COUNTRY, + DEFAULT_REQUEST_TIMEOUT, + DEVICE_CONCURRENCY_PARAM, + DEVICE_MANUFACTURER, + DEVICE_MODEL, + DEVICE_NAME, + DEVICE_OS, + DEVICE_TYPE, + LOGIN_CONTEXT, + LOGIN_TYPE, + MANAGE_DEVICE, + SUPPORTED_COUNTRIES, + USER_AGENT, + X_USER_AGENT, + get_base_headers, + get_base_url, + get_bifrost_url, + get_language, +) class InvalidTokenError(Exception): @@ -89,9 +107,7 @@ class MagentaAuthToken(BaseAuthToken): "expires_in": self.expires_in, "issued_at": self.issued_at, "auth_level": ( - self.auth_level.value - if self.auth_level - else TokenAuthLevel.UNKNOWN.value + self.auth_level.value if self.auth_level else TokenAuthLevel.UNKNOWN.value ), "credential_type": self.credential_type or "", } @@ -164,9 +180,7 @@ class MagentaAuthConfig: try: rsa_key = self.country_config["rsa_key"] if not rsa_key: - logger.error( - f"No RSA public key configured for country: {self.country}" - ) + logger.error(f"No RSA public key configured for country: {self.country}") return password key = RSA.import_key(rsa_key) @@ -265,9 +279,7 @@ class MagentaAuthenticator(BaseAuthenticator): logger.warning(f"CRITICAL: No session IDs available, using random fallback") # Ensure current token has the correct IDs - if not self._current_token or not isinstance( - self._current_token, MagentaAuthToken - ): + if not self._current_token or not isinstance(self._current_token, MagentaAuthToken): self._current_token = MagentaAuthToken( access_token="", refresh_token="", @@ -370,9 +382,7 @@ class MagentaAuthenticator(BaseAuthenticator): """Build authentication payload - required by BaseAuthenticator""" from ...base.auth.credentials import UserPasswordCredentials - if not self.credentials or not isinstance( - self.credentials, UserPasswordCredentials - ): + if not self.credentials or not isinstance(self.credentials, UserPasswordCredentials): raise Exception("No valid credentials available") # Enhanced validation @@ -416,9 +426,7 @@ class MagentaAuthenticator(BaseAuthenticator): }, } - def _create_token_from_response( - self, response_data: Dict[str, Any] - ) -> BaseAuthToken: + def _create_token_from_response(self, response_data: Dict[str, Any]) -> BaseAuthToken: """Create token from API response - required by BaseAuthenticator""" # PRESERVE the existing session IDs (which follow the correct priority) device_id = "" @@ -455,28 +463,20 @@ class MagentaAuthenticator(BaseAuthenticator): # DUAL KEY SUPPORT: Handle both camelCase (API responses) and snake_case (stored sessions) # Access token - access_token = response_data.get("accessToken") or response_data.get( - "access_token" - ) + access_token = response_data.get("accessToken") or response_data.get("access_token") if not access_token: logger.error(f"CRITICAL: No access token found in response data") logger.error(f"Available keys: {list(response_data.keys())}") raise Exception("No access token found in response data") # Refresh token - refresh_token = response_data.get("refreshToken") or response_data.get( - "refresh_token", "" - ) + refresh_token = response_data.get("refreshToken") or response_data.get("refresh_token", "") # Expires in - expires_in = response_data.get("expiresIn") or response_data.get( - "expires_in", 3600 - ) + expires_in = response_data.get("expiresIn") or response_data.get("expires_in", 3600) # Token type - token_type = response_data.get("tokenType") or response_data.get( - "token_type", "Bearer" - ) + token_type = response_data.get("tokenType") or response_data.get("token_type", "Bearer") # For stored sessions, issued_at might be in the data, otherwise use current time issued_at = response_data.get("issued_at", time.time()) @@ -494,9 +494,7 @@ class MagentaAuthenticator(BaseAuthenticator): # Classify token token.auth_level = self._classify_token(token) - logger.debug( - f"Token created successfully from {len(response_data)} data fields" - ) + logger.debug(f"Token created successfully from {len(response_data)} data fields") return token @@ -531,9 +529,7 @@ class MagentaAuthenticator(BaseAuthenticator): headers = self._get_auth_headers() payload = self._build_auth_payload() - logger.debug( - f"Authentication payload prepared for user: {self.credentials.username}" - ) + logger.debug(f"Authentication payload prepared for user: {self.credentials.username}") response = self._http_manager.post( self.auth_endpoint, @@ -554,9 +550,7 @@ class MagentaAuthenticator(BaseAuthenticator): return self._create_token_from_response(token_data) except Exception as e: - logger.error( - f"Authentication failed for user {self.credentials.username}: {e}" - ) + logger.error(f"Authentication failed for user {self.credentials.username}: {e}") raise def _upgrade_token(self, refresh_token: str) -> Dict[str, Any]: @@ -648,9 +642,7 @@ class MagentaAuthenticator(BaseAuthenticator): # Create new token with updated data but preserve session IDs new_token = MagentaAuthToken( access_token=token_data["accessToken"], - refresh_token=token_data.get( - "refreshToken", self._current_token.refresh_token - ), + refresh_token=token_data.get("refreshToken", self._current_token.refresh_token), token_type="Bearer", expires_in=token_data.get("expiresIn", 3600), issued_at=time.time(), diff --git a/lib/streaming_providers/providers/magentaeu/constants.py b/lib/streaming_providers/providers/magentaeu/constants.py index 1cef612..93045a2 100644 --- a/lib/streaming_providers/providers/magentaeu/constants.py +++ b/lib/streaming_providers/providers/magentaeu/constants.py @@ -91,7 +91,9 @@ DEVICE_CONCURRENCY_PARAM = "TVSOA-restriction-unmanagedDeviceStreamLimit" # User agent configuration USER_AGENT = f"Mozilla/5.0 (X11; {OS} x86_64) AppleWebKit/537.36 (KHTML, like Gecko) {BROWSER}/{BROWSER_VERSION}.0.0.0 Safari/537.36" -X_USER_AGENT = f"{DEVICE_MODEL.lower()}|{DEVICE_TYPE.lower()}|{BROWSER}-{BROWSER_VERSION}|{APP_VERSION}|1" +X_USER_AGENT = ( + f"{DEVICE_MODEL.lower()}|{DEVICE_TYPE.lower()}|{BROWSER}-{BROWSER_VERSION}|{APP_VERSION}|1" +) # ============================================================================ # API Endpoints diff --git a/lib/streaming_providers/providers/magentaeu/provider.py b/lib/streaming_providers/providers/magentaeu/provider.py index e11acd4..9f22b0b 100644 --- a/lib/streaming_providers/providers/magentaeu/provider.py +++ b/lib/streaming_providers/providers/magentaeu/provider.py @@ -11,13 +11,26 @@ from ...base.network import ProxyConfigManager from ...base.provider import StreamingProvider from ...base.utils.logger import logger from .auth import MagentaAuthenticator -from .constants import (API_ENDPOINTS, CONTENT_TYPE_LIVE, DEFAULT_COUNTRY, - DEFAULT_MAX_RETRIES, DEFAULT_REQUEST_TIMEOUT, - DRM_SYSTEM_WIDEVINE, MAGENTA_TV_AT_LOGO, - MAGENTA_TV_PL_LOGO, MAX_TV_LOGO, STREAMING_FORMAT_DASH, - SUPPORTED_COUNTRIES, USER_AGENT, WV_URL, get_base_url, - get_bifrost_url, get_guest_headers, get_language, - get_natco_key) +from .constants import ( + API_ENDPOINTS, + CONTENT_TYPE_LIVE, + DEFAULT_COUNTRY, + DEFAULT_MAX_RETRIES, + DEFAULT_REQUEST_TIMEOUT, + DRM_SYSTEM_WIDEVINE, + MAGENTA_TV_AT_LOGO, + MAGENTA_TV_PL_LOGO, + MAX_TV_LOGO, + STREAMING_FORMAT_DASH, + SUPPORTED_COUNTRIES, + USER_AGENT, + WV_URL, + get_base_url, + get_bifrost_url, + get_guest_headers, + get_language, + get_natco_key, +) class MagentaEUProvider(StreamingProvider): @@ -42,8 +55,7 @@ class MagentaEUProvider(StreamingProvider): if not self.validate_country(country): supported = ", ".join(self.SUPPORTED_COUNTRIES) raise ValueError( - f"Unsupported country: {country}. " - f"MagentaTV EU supports: {supported}" + f"Unsupported country: {country}. " f"MagentaTV EU supports: {supported}" ) super().__init__(country=country) @@ -79,9 +91,7 @@ class MagentaEUProvider(StreamingProvider): logger.info(f"=== MagentaProvider.__init__ COMPLETE ===") - def _load_proxy_from_manager( - self, config_dir: Optional[str] - ) -> Optional[ProxyConfig]: + def _load_proxy_from_manager(self, config_dir: Optional[str]) -> Optional[ProxyConfig]: """Load proxy configuration from ProxyConfigManager""" try: proxy_manager = ProxyConfigManager(config_dir) @@ -125,18 +135,14 @@ class MagentaEUProvider(StreamingProvider): return ["user_credentials"] def authenticate(self, **kwargs) -> str: - logger.info( - f"=== MagentaProvider.authenticate() CALLED with kwargs: {kwargs} ===" - ) + logger.info(f"=== MagentaProvider.authenticate() CALLED with kwargs: {kwargs} ===") self.bearer_token = self.authenticator.get_bearer_token( force_refresh=kwargs.get("force_refresh", False) ) logger.info(f"=== MagentaProvider.authenticate() COMPLETE ===") return self.bearer_token - def get_dynamic_manifest_params( - self, channel: StreamingChannel, **kwargs - ) -> Optional[str]: + def get_dynamic_manifest_params(self, channel: StreamingChannel, **kwargs) -> Optional[str]: return None def refresh_authentication(self) -> str: @@ -173,9 +179,7 @@ class MagentaEUProvider(StreamingProvider): "natco_code": self.country, } - logger.debug( - f"Fetching channels with device_id: {device_id}, session_id: {session_id}" - ) + logger.debug(f"Fetching channels with device_id: {device_id}, session_id: {session_id}") response = self.http_manager.get( channels_url, @@ -192,9 +196,7 @@ class MagentaEUProvider(StreamingProvider): self._channels_cache = channels self._channels_cache_timestamp = time.time() - logger.info( - f"Successfully fetched {len(channels)} channels for country {self.country}" - ) + logger.info(f"Successfully fetched {len(channels)} channels for country {self.country}") return channels except Exception as e: @@ -230,9 +232,7 @@ class MagentaEUProvider(StreamingProvider): if media_pid: manifest_script_parts.append(f"media={media_pid}") - manifest_script = ( - " ".join(manifest_script_parts) if manifest_script_parts else "" - ) + manifest_script = " ".join(manifest_script_parts) if manifest_script_parts else "" # Create streaming channel streaming_channel = StreamingChannel( @@ -327,35 +327,25 @@ class MagentaEUProvider(StreamingProvider): start_iso = TimestampConverter.epoch_to_iso( start_time, format_type="basic", as_utc=True ) - end_iso = TimestampConverter.epoch_to_iso( - end_time, format_type="basic", as_utc=True - ) + end_iso = TimestampConverter.epoch_to_iso(end_time, format_type="basic", as_utc=True) # Build the catchup manifest URL # Check if the manifest already has query parameters separator = "&" if "?" in base_manifest else "?" - catchup_manifest = ( - f"{base_manifest}{separator}begin={start_iso}&end={end_iso}" - ) + catchup_manifest = f"{base_manifest}{separator}begin={start_iso}&end={end_iso}" - logger.debug( - f"Catchup manifest for channel {channel_id}: {catchup_manifest}" - ) + logger.debug(f"Catchup manifest for channel {channel_id}: {catchup_manifest}") return catchup_manifest except Exception as e: - logger.error( - f"Error building catchup manifest for channel {channel_id}: {e}" - ) + logger.error(f"Error building catchup manifest for channel {channel_id}: {e}") # Fall back to live manifest if catchup formatting fails logger.warning(f"Falling back to live manifest for channel {channel_id}") return base_manifest def get_drm(self, channel_id: str, **kwargs) -> List[DRMConfig]: """Get DRM configurations for channel by ID""" - logger.info( - f"=== get_drm_configs_by_id CALLED for channel_id: {channel_id} ===" - ) + logger.info(f"=== get_drm_configs_by_id CALLED for channel_id: {channel_id} ===") # Find channel in cache channel = None @@ -379,9 +369,7 @@ class MagentaEUProvider(StreamingProvider): drm_config = self.get_drm_config(channel) return [drm_config] if drm_config else [] - def get_drm_config( - self, channel: StreamingChannel, **kwargs - ) -> Optional[DRMConfig]: + def get_drm_config(self, channel: StreamingChannel, **kwargs) -> Optional[DRMConfig]: """Get DRM configuration for channel with correct authentication""" try: import base64 @@ -450,9 +438,7 @@ class MagentaEUProvider(StreamingProvider): # Build license URL with parameters license_url = ( - f"{WV_URL}{pid}&" - f"token={persona_token}&" - f"account={encoded_account_uri}" + f"{WV_URL}{pid}&" f"token={persona_token}&" f"account={encoded_account_uri}" ) logger.debug(f"License URL created: {license_url[:100]}...") diff --git a/lib/streaming_providers/providers/rtlplus/auth.py b/lib/streaming_providers/providers/rtlplus/auth.py index 03ed3af..5694834 100644 --- a/lib/streaming_providers/providers/rtlplus/auth.py +++ b/lib/streaming_providers/providers/rtlplus/auth.py @@ -8,8 +8,7 @@ from ...base.auth.base_oauth2_auth import BaseOAuth2Authenticator from ...base.models.proxy_models import ProxyConfig from ...base.utils.logger import logger from .constants import RTLPlusConfig, RTLPlusDefaults -from .models import (RTLPlusAuthToken, RTLPlusClientCredentials, - RTLPlusUserCredentials) +from .models import RTLPlusAuthToken, RTLPlusClientCredentials, RTLPlusUserCredentials class RTLPlusAuthenticator(BaseOAuth2Authenticator): @@ -86,9 +85,7 @@ class RTLPlusAuthenticator(BaseOAuth2Authenticator): config_creds = self._get_anonymous_credentials_from_config() if config_creds: return RTLPlusClientCredentials( - client_id=config_creds.get( - "client_id", RTLPlusDefaults.ANONYMOUS_CLIENT_ID - ), + client_id=config_creds.get("client_id", RTLPlusDefaults.ANONYMOUS_CLIENT_ID), client_secret=config_creds.get( "client_secret", RTLPlusDefaults.ANONYMOUS_CLIENT_SECRET ), @@ -99,9 +96,7 @@ class RTLPlusAuthenticator(BaseOAuth2Authenticator): # Fallback to default credentials return RTLPlusClientCredentials() - def _create_token_from_response( - self, response_data: Dict[str, Any] - ) -> RTLPlusAuthToken: + def _create_token_from_response(self, response_data: Dict[str, Any]) -> RTLPlusAuthToken: """Create RTL+-specific token from OAuth2 response""" import time @@ -168,9 +163,7 @@ class RTLPlusAuthenticator(BaseOAuth2Authenticator): # Check for user-authenticated token if preferred_username or email: - logger.debug( - "RTL+ Token classified as USER_AUTHENTICATED (has user claims)" - ) + logger.debug("RTL+ Token classified as USER_AUTHENTICATED (has user claims)") return TokenAuthLevel.USER_AUTHENTICATED # Check for client credentials (anonymous) token @@ -321,9 +314,7 @@ class RTLPlusAuthenticator(BaseOAuth2Authenticator): """ from ...base.auth.credentials import UserPasswordCredentials - return isinstance( - self.credentials, (RTLPlusUserCredentials, UserPasswordCredentials) - ) + return isinstance(self.credentials, (RTLPlusUserCredentials, UserPasswordCredentials)) def has_stored_credentials(self) -> bool: """ @@ -331,9 +322,7 @@ class RTLPlusAuthenticator(BaseOAuth2Authenticator): """ try: logger.debug("RTL+ Checking for stored credentials using settings manager") - stored_creds = self.settings_manager.get_provider_credentials( - self.provider_name - ) + stored_creds = self.settings_manager.get_provider_credentials(self.provider_name) if not stored_creds: logger.debug("RTL+ No stored credentials found") @@ -366,9 +355,7 @@ class RTLPlusAuthenticator(BaseOAuth2Authenticator): status.update( { "has_user_credentials": self.has_user_credentials(), - "authentication_mode": ( - "user" if self.has_user_credentials() else "anonymous" - ), + "authentication_mode": ("user" if self.has_user_credentials() else "anonymous"), "client_version": self.config.client_version, } ) diff --git a/lib/streaming_providers/providers/rtlplus/constants.py b/lib/streaming_providers/providers/rtlplus/constants.py index ffb00c9..955c87d 100644 --- a/lib/streaming_providers/providers/rtlplus/constants.py +++ b/lib/streaming_providers/providers/rtlplus/constants.py @@ -26,7 +26,9 @@ class RTLPlusDefaults: AUTH_ENDPOINT = f"{AUTH_BASE_URL}/token" AUTH_AUTHORIZE_ENDPOINT = f"{AUTH_BASE_URL}/auth" GRAPHQL_ENDPOINT = "https://cdn.gateway.now-plus-prod.aws-cbc.cloud/graphql" - MANIFEST_ENDPOINT = "https://stus.player.streamingtech.de/livestream/linear/{channel_id}?platform=web" + MANIFEST_ENDPOINT = ( + "https://stus.player.streamingtech.de/livestream/linear/{channel_id}?platform=web" + ) BASE_WEBSITE = "https://plus.rtl.de/" CONFIG_ENDPOINT = "https://plus.rtl.de/assets/config/config.json" @@ -88,8 +90,7 @@ class RTLPlusHeaders: "Content-Type": "application/json", "Rtlplus-Client-Id": RTLPlusDefaults.CLIENT_ID, "Rtlplus-Referrer": "", - "Rtlplus-Client-Version": client_version - or RTLPlusDefaults.CLIENT_VERSION, + "Rtlplus-Client-Version": client_version or RTLPlusDefaults.CLIENT_VERSION, } ) @@ -102,9 +103,7 @@ class RTLPlusHeaders: return headers @staticmethod - def get_drm_headers( - access_token: str, device_id: str = None, user_agent: str = None - ) -> dict: + def get_drm_headers(access_token: str, device_id: str = None, user_agent: str = None) -> dict: """Get headers for DRM license requests""" return { "X-Auth-Token": access_token, @@ -126,27 +125,17 @@ class RTLPlusConfig: self.logo = config.get("logo", RTLPlusDefaults.RTLPLUS_LOGO) # Core settings (can be overridden) - self.client_version = config.get( - "client_version", RTLPlusDefaults.CLIENT_VERSION - ) - self.chrome_version = config.get( - "chrome_version", RTLPlusDefaults.CHROME_VERSION - ) + self.client_version = config.get("client_version", RTLPlusDefaults.CLIENT_VERSION) + self.chrome_version = config.get("chrome_version", RTLPlusDefaults.CHROME_VERSION) self.device_id = config.get("device_id", RTLPlusDefaults.DEVICE_ID) self.user_agent = config.get("user_agent", RTLPlusDefaults.USER_AGENT) # API endpoints (can be overridden for testing) self.auth_endpoint = config.get("auth_endpoint", RTLPlusDefaults.AUTH_ENDPOINT) - self.graphql_endpoint = config.get( - "graphql_endpoint", RTLPlusDefaults.GRAPHQL_ENDPOINT - ) - self.manifest_endpoint = config.get( - "manifest_endpoint", RTLPlusDefaults.MANIFEST_ENDPOINT - ) + self.graphql_endpoint = config.get("graphql_endpoint", RTLPlusDefaults.GRAPHQL_ENDPOINT) + self.manifest_endpoint = config.get("manifest_endpoint", RTLPlusDefaults.MANIFEST_ENDPOINT) self.base_website = config.get("base_website", RTLPlusDefaults.BASE_WEBSITE) - self.config_endpoint = config.get( - "config_endpoint", RTLPlusDefaults.CONFIG_ENDPOINT - ) + self.config_endpoint = config.get("config_endpoint", RTLPlusDefaults.CONFIG_ENDPOINT) # HTTP settings self.timeout = config.get("timeout", RTLPlusDefaults.DEFAULT_TIMEOUT) diff --git a/lib/streaming_providers/providers/rtlplus/models.py b/lib/streaming_providers/providers/rtlplus/models.py index 3c2cb24..9cd8c4e 100644 --- a/lib/streaming_providers/providers/rtlplus/models.py +++ b/lib/streaming_providers/providers/rtlplus/models.py @@ -40,9 +40,7 @@ class RTLPlusClientCredentials(ClientCredentials): RTL+ specific client credentials (anonymous access) """ - def __init__( - self, client_id: Optional[str] = None, client_secret: Optional[str] = None - ): + def __init__(self, client_id: Optional[str] = None, client_secret: Optional[str] = None): super().__init__( client_id=client_id or RTLPlusDefaults.ANONYMOUS_CLIENT_ID, client_secret=client_secret or RTLPlusDefaults.ANONYMOUS_CLIENT_SECRET, @@ -200,9 +198,7 @@ class RTLPlusStreamInfo: raise ValueError("Stream must have both manifest_url and channel_id") @classmethod - def from_manifest_response( - cls, data: Dict[str, Any], channel_id: str - ) -> "RTLPlusStreamInfo": + def from_manifest_response(cls, data: Dict[str, Any], channel_id: str) -> "RTLPlusStreamInfo": """Create stream info from manifest API response""" return cls( manifest_url=data["url"], diff --git a/lib/streaming_providers/providers/rtlplus/provider.py b/lib/streaming_providers/providers/rtlplus/provider.py index 059d2d4..52c7b61 100644 --- a/lib/streaming_providers/providers/rtlplus/provider.py +++ b/lib/streaming_providers/providers/rtlplus/provider.py @@ -51,9 +51,7 @@ class RTLPlusProvider(StreamingProvider): ) # ✅ Share HTTP manager with authenticator - self.http_manager = self._share_http_manager_with_authenticator( - self.authenticator - ) + self.http_manager = self._share_http_manager_with_authenticator(self.authenticator) # Try authentication try: @@ -259,9 +257,7 @@ class RTLPlusProvider(StreamingProvider): logger.debug(f"RTL+ Manifest Request: GET {manifest_url}") headers = self.rtl_config.get_base_headers() - response = self.http_manager.get( - manifest_url, operation="manifest", headers=headers - ) + response = self.http_manager.get(manifest_url, operation="manifest", headers=headers) logger.debug(f"RTL+ Manifest Response: Status={response.status_code}") logger.debug(f"RTL+ Response Headers: {dict(response.headers)}") @@ -269,9 +265,7 @@ class RTLPlusProvider(StreamingProvider): response.raise_for_status() manifest_data = response.json() - logger.debug( - f"RTL+ Manifest Data: {self._sanitize_manifest_log(manifest_data)}" - ) + logger.debug(f"RTL+ Manifest Data: {self._sanitize_manifest_log(manifest_data)}") # Process manifest data quality_preference = ["dashhd", "dashsd"] @@ -280,9 +274,7 @@ class RTLPlusProvider(StreamingProvider): for stream in manifest_data: if stream.get("name") == quality: sources = stream.get("sources", []) - non_yospace_sources = [ - s for s in sources if not s.get("isYospace", False) - ] + non_yospace_sources = [s for s in sources if not s.get("isYospace", False)] if non_yospace_sources: selected_url = non_yospace_sources[0].get("url") @@ -361,9 +353,7 @@ class RTLPlusProvider(StreamingProvider): if not license_url: continue - def create_drm_config( - drm_system, priority, server_url, headers - ): + def create_drm_config(drm_system, priority, server_url, headers): return DRMConfig( system=drm_system, priority=priority, @@ -407,14 +397,10 @@ class RTLPlusProvider(StreamingProvider): return drm_configs except requests.RequestException as e: - logger.error( - f"Error fetching DRM configs for RTL+ channel {channel_id}: {e}" - ) + logger.error(f"Error fetching DRM configs for RTL+ channel {channel_id}: {e}") return [] except Exception as e: - logger.error( - f"Error parsing DRM configs for RTL+ channel {channel_id}: {e}" - ) + logger.error(f"Error parsing DRM configs for RTL+ channel {channel_id}: {e}") return [] @staticmethod diff --git a/service.py b/service.py index db5423f..940dcb7 100644 --- a/service.py +++ b/service.py @@ -2,6 +2,7 @@ import os import sys import threading +import traceback from datetime import datetime import time import json @@ -1031,60 +1032,305 @@ class UltimateService: @self.app.route('/api/providers//channels//pssh') def get_channel_pssh(provider, channel_id): + """ + Extract PSSH data for a channel. + + Query parameters: + - country: Optional country code for geo-specific manifests + - force_refresh: If 'true', bypass cache and re-extract PSSH + + Returns: + { + "provider": "provider_name", + "channel_id": "channel_id", + "manifest_url": "https://...", + "pssh_data": [ + { + "system_id": "edef8ba9-79d6-4ace-a3c8-27dcd51d21ed", + "drm_system": "com.widevine.alpha", + "pssh_box": "AAAANHBzc2g...", + "key_ids": ["64656d6f..."], + "source": "mp4_segment" + } + ], + "count": 1, + "cached": true + } + """ try: - # Get the manifest URL first - manifest_url = self.manager.get_channel_manifest( - provider_name=provider, - channel_id=channel_id, - country=request.query.get('country') - ) + # Parse query parameters + country = request.params.get('country') + force_refresh = request.params.get('force_refresh', '').lower() == 'true' + + # Clear cache if force refresh requested + if force_refresh: + cache_key = f"{provider}:{channel_id}" + self.manager.drm_operations.pssh_cache.clear() + logger.info(f"Cache cleared for force_refresh request: {cache_key}") + + # Get manifest URL + try: + manifest_url = self.manager.get_channel_manifest( + provider_name=provider, + channel_id=channel_id, + country=country + ) + except ValueError as e: + response.status = 404 + return { + 'error': 'Provider not found', + 'message': str(e), + 'provider': provider + } + except Exception as e: + response.status = 500 + return { + 'error': 'Failed to get manifest', + 'message': str(e), + 'provider': provider, + 'channel_id': channel_id + } if not manifest_url: response.status = 404 - return {'error': f'Manifest not available for channel "{channel_id}" from provider "{provider}"'} + return { + 'error': 'Manifest not available', + 'message': f'No manifest found for channel "{channel_id}" from provider "{provider}"', + 'provider': provider, + 'channel_id': channel_id + } - # Extract PSSH data from the manifest - pssh_data_list = self.manager.extract_pssh_from_manifest(manifest_url) + # Check if we're using cache + cache_key = f"{provider}:{channel_id}" + cached_pssh = self.manager.drm_operations.pssh_cache.get(cache_key) + was_cached = cached_pssh is not None + + # Extract PSSH data (uses cache internally unless force_refresh) + try: + pssh_data_list = self.manager.drm_operations._extract_pssh_from_manifest( + manifest_url + ) + except Exception as e: + response.status = 500 + return { + 'error': 'Failed to extract PSSH', + 'message': str(e), + 'provider': provider, + 'channel_id': channel_id, + 'manifest_url': manifest_url, + 'traceback': traceback.format_exc() if self.app.config.get('debug') else None + } if not pssh_data_list: response.status = 404 return { - 'error': f'No PSSH data found in manifest for channel "{channel_id}" from provider "{provider}"'} + 'error': 'No PSSH data found', + 'message': f'No PSSH data found in manifest or segments for channel "{channel_id}"', + 'provider': provider, + 'channel_id': channel_id, + 'manifest_url': manifest_url, + 'hint': 'This channel may not use DRM, or PSSH extraction failed' + } - # Convert PSSH data to dictionary format for JSON response + # Convert PSSH data to dictionary format pssh_list = [] for pssh_data in pssh_data_list: - if hasattr(pssh_data, 'to_dict'): - pssh_list.append(pssh_data.to_dict()) - else: - # Fallback for basic PSSH data structure - pssh_dict = { - 'pssh': getattr(pssh_data, 'pssh', str(pssh_data)) if hasattr(pssh_data, 'pssh') else str( - pssh_data), - 'system_id': getattr(pssh_data, 'system_id', None), - 'key_id': getattr(pssh_data, 'key_id', None) if hasattr(pssh_data, 'key_id') else None - } - # Remove None values - pssh_dict = {k: v for k, v in pssh_dict.items() if v is not None} - pssh_list.append(pssh_dict) + pssh_dict = { + 'system_id': pssh_data.system_id, + 'drm_system': pssh_data.drm_system.value if pssh_data.drm_system else None, + 'pssh_box': pssh_data.pssh_box if pssh_data.pssh_box else None, + 'key_ids': pssh_data.key_ids if pssh_data.key_ids else [], + 'source': pssh_data.source + } + # Add human-readable system name + if pssh_data.drm_system: + pssh_dict['drm_system_name'] = { + 'com.widevine.alpha': 'Widevine', + 'com.microsoft.playready': 'PlayReady', + 'com.apple.fps': 'FairPlay', + 'org.w3.clearkey': 'ClearKey', + 'com.huawei.wiseplay': 'Wiseplay' + }.get(pssh_data.drm_system.value, pssh_data.drm_system.value) + + # Remove None values for cleaner response + pssh_dict = {k: v for k, v in pssh_dict.items() if v is not None} + pssh_list.append(pssh_dict) + + response.status = 200 return { 'provider': provider, 'channel_id': channel_id, 'manifest_url': manifest_url, 'pssh_data': pssh_list, - 'count': len(pssh_list) + 'count': len(pssh_list), + 'cached': was_cached, + 'cache_ttl_seconds': self.manager.drm_operations.pssh_cache.ttl } - except ValueError as val_err: - # This handles the case where manager raises ValueError for unknown provider - logger.error(f"API Error in /api/providers/{provider}/channels/{channel_id}/pssh: {str(val_err)}") - response.status = 404 - return {'error': str(val_err)} - except Exception as api_err: - logger.error(f"API Error in /api/providers/{provider}/channels/{channel_id}/pssh: {str(api_err)}") + except Exception as e: + # Catch-all for unexpected errors + logger.error(f"Unexpected error in get_channel_pssh: {e}") + logger.error(traceback.format_exc()) response.status = 500 - return {'error': f'Internal server error: {str(api_err)}'} + return { + 'error': 'Internal server error', + 'message': str(e), + 'provider': provider, + 'channel_id': channel_id, + 'traceback': traceback.format_exc() if self.app.config.get('debug') else None + } + + @self.app.route('/api/providers//channels//pssh/refresh', method='POST') + def refresh_channel_pssh(provider, channel_id): + """ + Force refresh PSSH data for a channel (clears cache and re-extracts). + + This is useful when: + - Keys have been rotated + - Manifest structure has changed + - Previous extraction failed + """ + try: + # Clear cache for this specific channel + cache_key = f"{provider}:{channel_id}" + + # Check if entry exists in cache + cached = self.manager.drm_operations.pssh_cache.get(cache_key) + + # Clear it + if cached: + # Remove specific key (you may need to add this method to PSSHCache) + with self.manager.drm_operations.pssh_cache.lock: + if cache_key in self.manager.drm_operations.pssh_cache.cache: + del self.manager.drm_operations.pssh_cache.cache[cache_key] + logger.info(f"Cleared cache for {cache_key}") + + # Now extract fresh data + country = request.params.get('country') + + manifest_url = self.manager.get_channel_manifest( + provider_name=provider, + channel_id=channel_id, + country=country + ) + + if not manifest_url: + response.status = 404 + return { + 'error': 'Manifest not available', + 'provider': provider, + 'channel_id': channel_id + } + + # Extract and cache + pssh_data_list = self.manager.drm_operations._extract_pssh_from_manifest( + manifest_url + ) + + if pssh_data_list: + # Cache it + self.manager.drm_operations.pssh_cache.set(cache_key, pssh_data_list) + + response.status = 200 + return { + 'message': 'PSSH data refreshed', + 'provider': provider, + 'channel_id': channel_id, + 'count': len(pssh_data_list), + 'was_cached': cached is not None, + 'now_cached': len(pssh_data_list) > 0 + } + + except Exception as e: + logger.error(f"Error refreshing PSSH: {e}") + response.status = 500 + return { + 'error': 'Failed to refresh', + 'message': str(e) + } + + @self.app.route('/api/cache/pssh', method='DELETE') + def clear_pssh_cache(): + """ + Clear all PSSH cache entries. + + This is useful for: + - Debugging + - Freeing memory + - Forcing re-extraction of all channels + """ + try: + cache_size = len(self.manager.drm_operations.pssh_cache.cache) + self.manager.drm_operations.pssh_cache.clear() + + response.status = 200 + return { + 'message': 'PSSH cache cleared', + 'entries_cleared': cache_size + } + + except Exception as e: + logger.error(f"Error clearing cache: {e}") + response.status = 500 + return { + 'error': 'Failed to clear cache', + 'message': str(e) + } + + @self.app.route('/api/cache/pssh', method='GET') + def get_pssh_cache_stats(): + """ + Get PSSH cache statistics. + + Returns information about: + - Number of cached entries + - TTL configuration + - Memory usage estimate + """ + try: + cache = self.manager.drm_operations.pssh_cache + + with cache.lock: + entries = [] + total_size = 0 + + for key, (pssh_list, timestamp) in cache.cache.items(): + import time + age = time.time() - timestamp + expires_in = cache.ttl - age + + # Estimate size + size = sum( + len(p.pssh_box) + len(str(p.key_ids)) + len(p.system_id) + for p in pssh_list + ) + total_size += size + + entries.append({ + 'key': key, + 'pssh_count': len(pssh_list), + 'age_seconds': int(age), + 'expires_in_seconds': int(expires_in), + 'size_bytes': size + }) + + response.status = 200 + return { + 'total_entries': len(entries), + 'ttl_seconds': cache.ttl, + 'total_size_bytes': total_size, + 'total_size_mb': round(total_size / 1024 / 1024, 2), + 'entries': sorted(entries, key=lambda x: x['age_seconds'], reverse=True) + } + + except Exception as e: + logger.error(f"Error getting cache stats: {e}") + response.status = 500 + return { + 'error': 'Failed to get cache stats', + 'message': str(e) + } @self.app.route('/api/providers//channels//epg') def get_channel_epg(provider, channel_id):