diff --git a/lib/streaming_providers/__init__.py b/lib/streaming_providers/__init__.py index 1b0b76a..95ea4b8 100644 --- a/lib/streaming_providers/__init__.py +++ b/lib/streaming_providers/__init__.py @@ -1,8 +1,8 @@ # lib/streaming_providers/__init__.py import importlib -from typing import Dict, Type import os import sys +from typing import Dict, Type # Import the centralized logger from .base.utils.logger import logger @@ -18,7 +18,7 @@ def _discover_providers(): # Get the current package path current_dir = os.path.dirname(__file__) - providers_dir = os.path.join(current_dir, 'providers') + providers_dir = os.path.join(current_dir, "providers") logger.info(f"Looking for providers in: {providers_dir}") @@ -38,11 +38,11 @@ def _discover_providers(): provider_path = os.path.join(providers_dir, item) # Skip if not a directory or if it starts with __ - if not os.path.isdir(provider_path) or item.startswith('__'): + if not os.path.isdir(provider_path) or item.startswith("__"): continue # Check if __init__.py exists in the provider directory - init_file = os.path.join(provider_path, '__init__.py') + init_file = os.path.join(provider_path, "__init__.py") if not os.path.exists(init_file): logger.debug(f"No __init__.py found in {item}, skipping") continue @@ -51,16 +51,18 @@ def _discover_providers(): logger.debug(f"Attempting to import provider: {item}") # Import the provider module using absolute import - module_name = f'streaming_providers.providers.{item}' + module_name = f"streaming_providers.providers.{item}" module = importlib.import_module(module_name) # Find provider classes in the module provider_found = False for attr_name in dir(module): attr = getattr(module, attr_name) - if (isinstance(attr, type) and - issubclass(attr, StreamingProvider) and - attr != StreamingProvider): + if ( + isinstance(attr, type) + and issubclass(attr, StreamingProvider) + and attr != StreamingProvider + ): AVAILABLE_PROVIDERS[item] = attr logger.info(f"Discovered provider: {item} -> {attr_name}") provider_found = True @@ -81,7 +83,7 @@ def _discover_providers(): logger.debug(f"Python path: {sys.path}") -def get_configured_manager(country: str = 'de') -> 'ProviderManager': +def get_configured_manager(country: str = "de") -> "ProviderManager": """ Get manager with settings-aware providers @@ -101,20 +103,29 @@ 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(): # Check if provider supports multiple countries - if hasattr(provider_class, 'SUPPORTED_COUNTRIES'): - supported_countries = getattr(provider_class, 'SUPPORTED_COUNTRIES', []) + if hasattr(provider_class, "SUPPORTED_COUNTRIES"): + supported_countries = getattr(provider_class, "SUPPORTED_COUNTRIES", []) detected_providers[provider_name] = supported_countries - logger.debug(f"Provider '{provider_name}' supports countries: {supported_countries}") + logger.debug( + f"Provider '{provider_name}' supports countries: {supported_countries}" + ) else: # Single-country provider (empty list means no multi-country support) detected_providers[provider_name] = [] @@ -122,15 +133,15 @@ def get_configured_manager(country: str = 'de') -> 'ProviderManager': # Use the new discover_providers method with detected providers registered = manager.discover_providers( - country=country, - detected_providers=detected_providers + country=country, detected_providers=detected_providers ) logger.info(f"Registered {len(registered)} providers: {registered}") return manager + # Initial discovery _discover_providers() -__all__ = ['AVAILABLE_PROVIDERS', 'get_configured_manager'] \ No newline at end of file +__all__ = ["AVAILABLE_PROVIDERS", "get_configured_manager"] diff --git a/lib/streaming_providers/base/__init__.py b/lib/streaming_providers/base/__init__.py index 108b609..52d7d0b 100644 --- a/lib/streaming_providers/base/__init__.py +++ b/lib/streaming_providers/base/__init__.py @@ -5,14 +5,14 @@ Base module for streaming providers This module contains the core abstractions and models used by all providers. """ +from .drm import DRMPlugin, DRMPluginManager +from .manager import ProviderManager from .models import StreamingChannel from .provider import StreamingProvider -from .manager import ProviderManager -from .drm import DRMPlugin, DRMPluginManager __all__ = [ "StreamingChannel", - "StreamingProvider", + "StreamingProvider", "ProviderManager", "DRMPlugin", "DRMPluginManager", diff --git a/lib/streaming_providers/base/auth/__init__.py b/lib/streaming_providers/base/auth/__init__.py index 23916d7..ecf3a45 100644 --- a/lib/streaming_providers/base/auth/__init__.py +++ b/lib/streaming_providers/base/auth/__init__.py @@ -1,16 +1,17 @@ # streaming_providers/base/auth/__init__.py from .base_auth import BaseAuthenticator, BaseAuthToken -from .credentials import BaseCredentials, UserPasswordCredentials, ClientCredentials -from .session_manager import SessionManager from .credential_manager import CredentialManager +from .credentials import (BaseCredentials, ClientCredentials, + UserPasswordCredentials) +from .session_manager import SessionManager # Only export what consumers should use __all__ = [ - 'BaseAuthenticator', - 'BaseAuthToken', - 'BaseCredentials', - 'UserPasswordCredentials', - 'ClientCredentials', - 'SessionManager', - 'CredentialManager' -] \ No newline at end of file + "BaseAuthenticator", + "BaseAuthToken", + "BaseCredentials", + "UserPasswordCredentials", + "ClientCredentials", + "SessionManager", + "CredentialManager", +] diff --git a/lib/streaming_providers/base/auth/base_auth.py b/lib/streaming_providers/base/auth/base_auth.py index 58fd888..044f65b 100644 --- a/lib/streaming_providers/base/auth/base_auth.py +++ b/lib/streaming_providers/base/auth/base_auth.py @@ -1,15 +1,16 @@ # streaming_providers/base/auth/base_auth.py (Country-Aware) +import time from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import Dict, Optional, Any from enum import Enum -import time +from typing import Any, Dict, Optional from ..utils.logger import logger class TokenAuthLevel(Enum): """Classification of token authentication levels""" + ANONYMOUS = "anonymous" # No user authentication CLIENT_CREDENTIALS = "client_credentials" # Client credentials flow USER_AUTHENTICATED = "user_authenticated" # User login flow @@ -19,6 +20,7 @@ class TokenAuthLevel(Enum): @dataclass class BaseAuthToken(ABC): """Base class for authentication tokens with enhanced metadata""" + access_token: str token_type: str expires_in: int @@ -68,7 +70,7 @@ class BaseAuthToken(ABC): return self.auth_level in [ TokenAuthLevel.ANONYMOUS, TokenAuthLevel.CLIENT_CREDENTIALS, - TokenAuthLevel.UNKNOWN + TokenAuthLevel.UNKNOWN, ] @property @@ -88,9 +90,15 @@ class BaseAuthenticator(ABC): Now supports country-specific authentication """ - def __init__(self, provider_name: str, settings_manager=None, credentials=None, - country: Optional[str] = None, config_dir: Optional[str] = None, - enable_kodi_integration: bool = True): + def __init__( + self, + provider_name: str, + settings_manager=None, + credentials=None, + country: Optional[str] = None, + config_dir: Optional[str] = None, + enable_kodi_integration: bool = True, + ): """ Initialize authenticator @@ -108,9 +116,13 @@ 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: @@ -118,11 +130,15 @@ class BaseAuthenticator(ABC): logger.info(f"Using injected settings manager for {provider_name}") else: # Create settings manager for backward compatibility - self.settings_manager = self._create_settings_manager(config_dir, enable_kodi_integration) - logger.info(f"Created settings manager for backward compatibility for {provider_name}") + self.settings_manager = self._create_settings_manager( + config_dir, enable_kodi_integration + ) + logger.info( + f"Created settings manager for backward compatibility for {provider_name}" + ) # Register provider with settings manager - if hasattr(self.settings_manager, 'register_provider'): + if hasattr(self.settings_manager, "register_provider"): self.settings_manager.register_provider(provider_name) # Load credentials with priority: @@ -137,15 +153,16 @@ class BaseAuthenticator(ABC): # Load existing session/token self._load_session() - def _create_settings_manager(self, config_dir: Optional[str] = None, enable_kodi_integration: bool = True): + def _create_settings_manager( + self, config_dir: Optional[str] = None, enable_kodi_integration: bool = True + ): """Create settings manager for backward compatibility""" try: # Try to use the new SettingsManager from ..settings.settings_manager import SettingsManager settings_manager = SettingsManager( - config_dir=config_dir, - enable_kodi_integration=enable_kodi_integration + config_dir=config_dir, enable_kodi_integration=enable_kodi_integration ) logger.debug(f"Created SettingsManager for {self.provider_name}") return settings_manager @@ -155,15 +172,18 @@ class BaseAuthenticator(ABC): # Fall back to adapter approach return self._create_adapter_fallback(config_dir, enable_kodi_integration) - def _create_adapter_fallback(self, config_dir: Optional[str] = None, enable_kodi_integration: bool = True): + def _create_adapter_fallback( + self, config_dir: Optional[str] = None, enable_kodi_integration: bool = True + ): """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, config_dir=config_dir, - enable_kodi_integration=enable_kodi_integration + enable_kodi_integration=enable_kodi_integration, ) logger.debug(f"Created adapter fallback for {self.provider_name}") return adapter @@ -177,30 +197,41 @@ class BaseAuthenticator(ABC): """Create minimal fallback manager""" try: # Try to create basic managers directly - from .session_manager import SessionManager from .credential_manager import CredentialManager + from .session_manager import SessionManager class MinimalSettingsManager: def __init__(self, config_dir): self.session_manager = SessionManager(config_dir) - self.credential_manager = CredentialManager(config_dir) if hasattr(self, - 'CredentialManager') else None + self.credential_manager = ( + CredentialManager(config_dir) + if hasattr(self, "CredentialManager") + else None + ) 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) + return self.credential_manager.save_credentials( + provider_name, credentials, country + ) return False def load_token_data(self, provider_name, country=None): 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) @@ -210,10 +241,10 @@ class BaseAuthenticator(ABC): def get_credential_info(self, provider_name, country=None): return { - 'provider_name': provider_name, - 'country': country, - 'source': 'minimal_fallback', - 'config_dir': config_dir + "provider_name": provider_name, + "country": country, + "source": "minimal_fallback", + "config_dir": config_dir, } def register_provider(self, provider_name): @@ -233,7 +264,9 @@ 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): @@ -244,13 +277,18 @@ class BaseAuthenticator(ABC): def get_device_id(self, provider_name, country=None): import uuid + return str(uuid.uuid4()) def clear_token(self, provider_name, country=None): return False def get_credential_info(self, provider_name, country=None): - return {'provider_name': provider_name, 'country': country, 'source': 'absolute_minimal'} + return { + "provider_name": provider_name, + "country": country, + "source": "absolute_minimal", + } def register_provider(self, provider_name): return True @@ -262,20 +300,30 @@ class BaseAuthenticator(ABC): """Load credentials from the settings manager""" try: # Check if it's a SettingsManager instance (or compatible) - if hasattr(self.settings_manager, 'get_provider_credentials'): + if hasattr(self.settings_manager, "get_provider_credentials"): # Try country-aware call first try: - return self.settings_manager.get_provider_credentials(self.provider_name, self.country) + return self.settings_manager.get_provider_credentials( + 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") - return self.settings_manager.get_provider_credentials(self.provider_name) + logger.debug( + f"Settings manager doesn't support country parameter, using without" + ) + 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}") + logger.warning( + f"Settings manager has no credential loading method for {self.provider_name}" + ) 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 @@ -296,7 +344,9 @@ 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 @@ -319,17 +369,25 @@ 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: self._current_token = self._create_token_from_response(token_data) - logger.info(f"Successfully loaded existing session for {self.provider_name}{country_str}") + logger.info( + 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: @@ -345,22 +403,23 @@ class BaseAuthenticator(ABC): # Try country-aware call first try: success = self.settings_manager.save_token_data( - self.provider_name, - self._current_token.to_dict(), - self.country + self.provider_name, self._current_token.to_dict(), 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" + ) success = self.settings_manager.save_token_data( - self.provider_name, - self._current_token.to_dict() + self.provider_name, self._current_token.to_dict() ) 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}") @@ -375,7 +434,9 @@ 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() @@ -383,61 +444,85 @@ class BaseAuthenticator(ABC): def authenticate(self, force_refresh: bool = False) -> BaseAuthToken: """Authenticate and get access token with persistent storage""" country_str = f" (country: {self.country})" if self.country else "" - logger.debug(f"[{self.provider_name}{country_str}] Starting authentication, force_refresh={force_refresh}") + logger.debug( + f"[{self.provider_name}{country_str}] Starting authentication, force_refresh={force_refresh}" + ) # DEBUG: Log current token state if self._current_token: - logger.debug(f"[{self.provider_name}{country_str}] Current token state - " - f"is_expired: {self._current_token.is_expired}, " - f"has_refresh: {bool(self._current_token.refresh_token)}, " - f"needs_refresh: {self._current_token.needs_refresh()}") + logger.debug( + f"[{self.provider_name}{country_str}] Current token state - " + f"is_expired: {self._current_token.is_expired}, " + f"has_refresh: {bool(self._current_token.refresh_token)}, " + f"needs_refresh: {self._current_token.needs_refresh()}" + ) else: 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 # This covers both: tokens that need refresh AND expired tokens that can be refreshed should_attempt_refresh = ( - not force_refresh and - self._current_token and - self._current_token.refresh_token and - (self._current_token.needs_refresh() or self._current_token.is_expired) + not force_refresh + and self._current_token + and self._current_token.refresh_token + and (self._current_token.needs_refresh() or self._current_token.is_expired) ) - logger.debug(f"[{self.provider_name}{country_str}] Refresh decision - " - f"should_attempt_refresh: {should_attempt_refresh}, " - f"force_refresh: {force_refresh}, " - f"has_token: {bool(self._current_token)}, " - f"has_refresh_token: {bool(self._current_token.refresh_token if self._current_token else False)}, " - f"needs_refresh: {self._current_token.needs_refresh() if self._current_token else False}, " - f"is_expired: {self._current_token.is_expired if self._current_token else False}") + logger.debug( + f"[{self.provider_name}{country_str}] Refresh decision - " + f"should_attempt_refresh: {should_attempt_refresh}, " + f"force_refresh: {force_refresh}, " + f"has_token: {bool(self._current_token)}, " + f"has_refresh_token: {bool(self._current_token.refresh_token if self._current_token else False)}, " + f"needs_refresh: {self._current_token.needs_refresh() if self._current_token else False}, " + f"is_expired: {self._current_token.is_expired if self._current_token else False}" + ) if should_attempt_refresh: logger.info(f"[{self.provider_name}{country_str}] Attempting token refresh") try: - refreshed_token = self._refresh_token() # Provider-specific implementation - logger.debug(f"[{self.provider_name}{country_str}] Refresh result: {refreshed_token is not None}") + refreshed_token = ( + self._refresh_token() + ) # Provider-specific implementation + logger.debug( + f"[{self.provider_name}{country_str}] Refresh result: {refreshed_token is not None}" + ) 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") + 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() @@ -451,16 +536,15 @@ class BaseAuthenticator(ABC): # Try country-aware call first try: success = self.settings_manager.save_provider_credentials( - self.provider_name, - credentials, - self.country + self.provider_name, credentials, 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" + ) success = self.settings_manager.save_provider_credentials( - self.provider_name, - credentials + self.provider_name, credentials ) if success: @@ -477,43 +561,59 @@ class BaseAuthenticator(ABC): Manually sync credentials from Kodi settings (for backward compatibility) """ try: - if hasattr(self.settings_manager, 'sync_all_from_kodi'): + if hasattr(self.settings_manager, "sync_all_from_kodi"): results = self.settings_manager.sync_all_from_kodi() success = results.get(self.provider_name, True) if success: # Reload credentials after successful sync self.credentials = self._load_credentials_from_manager() - logger.info(f"Successfully synced and reloaded credentials from Kodi for {self.provider_name}") + logger.info( + f"Successfully synced and reloaded credentials from Kodi for {self.provider_name}" + ) 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]: """Get information about credential sources""" base_info = { - 'provider': self.provider_name, - 'country': self.country, - 'has_current_credentials': self.credentials is not None, - 'current_credentials_valid': self.credentials.validate() if self.credentials else False, - 'current_credential_type': self.credentials.credential_type if self.credentials else None + "provider": self.provider_name, + "country": self.country, + "has_current_credentials": self.credentials is not None, + "current_credentials_valid": ( + self.credentials.validate() if self.credentials else False + ), + "current_credential_type": ( + self.credentials.credential_type if self.credentials else None + ), } # Add settings manager info if available try: # Try country-aware call first try: - manager_info = self.settings_manager.get_credential_info(self.provider_name, self.country) + manager_info = self.settings_manager.get_credential_info( + 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 @@ -529,26 +629,39 @@ class BaseAuthenticator(ABC): # Try to clear through settings manager success = True - if hasattr(self.settings_manager, 'credential_manager'): + 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(f"No credential deletion capability in settings manager for {self.provider_name}") + logger.debug( + f"No credential deletion capability in settings manager for {self.provider_name}" + ) 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: @@ -556,12 +669,18 @@ class BaseAuthenticator(ABC): try: # Try country-aware call first try: - credentials = self.settings_manager.get_provider_credentials(self.provider_name, self.country) + credentials = self.settings_manager.get_provider_credentials( + 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: @@ -603,19 +722,24 @@ class BaseAuthenticator(ABC): except TypeError: self.settings_manager.clear_token(self.provider_name) except Exception as e: - logger.debug(f"Could not clear token from settings manager for {self.provider_name}: {e}") + logger.debug( + f"Could not clear token from settings manager for {self.provider_name}: {e}" + ) def get_device_id(self) -> str: """Get persistent device ID for this provider""" 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: logger.error(f"Error getting device ID for {self.provider_name}: {e}") import uuid + return str(uuid.uuid4()) def get_token_info(self) -> Optional[Dict[str, Any]]: @@ -624,12 +748,12 @@ class BaseAuthenticator(ABC): return None base_info = { - 'expires_in': self._current_token.expires_in, - 'issued_at': self._current_token.issued_at, - 'is_expired': self._current_token.is_expired, - 'token_type': self._current_token.token_type, - 'has_refresh_token': self._current_token.refresh_token is not None, - 'country': self.country + "expires_in": self._current_token.expires_in, + "issued_at": self._current_token.issued_at, + "is_expired": self._current_token.is_expired, + "token_type": self._current_token.token_type, + "has_refresh_token": self._current_token.refresh_token is not None, + "country": self.country, } base_info.update(self._current_token.to_dict()) @@ -638,11 +762,11 @@ class BaseAuthenticator(ABC): def get_authentication_status(self) -> Dict[str, Any]: """Get detailed authentication status information""" return { - 'provider': self.provider_name, - 'country': self.country, - 'is_authenticated': self.is_authenticated(), - 'token_info': self.get_token_info(), - 'credential_info': self.get_credential_info() + "provider": self.provider_name, + "country": self.country, + "is_authenticated": self.is_authenticated(), + "token_info": self.get_token_info(), + "credential_info": self.get_credential_info(), } @abstractmethod @@ -690,15 +814,25 @@ class BaseAuthenticator(ABC): # Check stored credentials first try: - stored_creds = self.settings_manager.get_provider_credentials(self.provider_name, self.country) + stored_creds = self.settings_manager.get_provider_credentials( + 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() + has_user_creds = ( + isinstance(stored_creds, UserPasswordCredentials) + and stored_creds.validate() + ) # Check current credentials if not has_user_creds: - has_user_creds = isinstance(self.credentials, UserPasswordCredentials) and self.credentials.validate() + has_user_creds = ( + isinstance(self.credentials, UserPasswordCredentials) + and self.credentials.validate() + ) # Only upgrade if we have user credentials - return has_user_creds \ No newline at end of file + return has_user_creds diff --git a/lib/streaming_providers/base/auth/base_oauth2_auth.py b/lib/streaming_providers/base/auth/base_oauth2_auth.py index 90124e0..2f192e6 100644 --- a/lib/streaming_providers/base/auth/base_oauth2_auth.py +++ b/lib/streaming_providers/base/auth/base_oauth2_auth.py @@ -1,23 +1,25 @@ # streaming_providers/base/auth/base_oauth2_auth.py -from abc import abstractmethod -from typing import Dict, Optional, Any, Callable -import uuid -import hashlib import base64 -import secrets -import re +import hashlib import html -from urllib.parse import urlencode, parse_qs, urlparse +import re +import secrets +import uuid +from abc import abstractmethod +from typing import Any, Callable, Dict, Optional +from urllib.parse import parse_qs, urlencode, urlparse -from .base_auth import BaseAuthenticator, BaseAuthToken, TokenAuthLevel -from ..utils.logger import logger from ..models.proxy_models import ProxyConfig +from ..utils.logger import logger +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 @@ -37,16 +39,16 @@ class SessionAwareHTTPManager: def get(self, url: str, **kwargs): """GET request with cookie handling""" - headers = kwargs.get('headers', {}).copy() + headers = kwargs.get("headers", {}).copy() headers.update(self.headers) # Add cookies if self.cookies: - cookie_str = '; '.join([f"{k}={v}" for k, v in self.cookies.items()]) - headers['Cookie'] = cookie_str + cookie_str = "; ".join([f"{k}={v}" for k, v in self.cookies.items()]) + headers["Cookie"] = cookie_str - kwargs['headers'] = headers - response = self.http_manager.get(url, operation='oauth', **kwargs) + kwargs["headers"] = headers + response = self.http_manager.get(url, operation="oauth", **kwargs) # Update cookies from response self._update_cookies_from_response(response) @@ -54,16 +56,16 @@ class SessionAwareHTTPManager: def post(self, url: str, **kwargs): """POST request with cookie handling""" - headers = kwargs.get('headers', {}).copy() + headers = kwargs.get("headers", {}).copy() headers.update(self.headers) # Add cookies if self.cookies: - cookie_str = '; '.join([f"{k}={v}" for k, v in self.cookies.items()]) - headers['Cookie'] = cookie_str + cookie_str = "; ".join([f"{k}={v}" for k, v in self.cookies.items()]) + headers["Cookie"] = cookie_str - kwargs['headers'] = headers - response = self.http_manager.post(url, operation='oauth', **kwargs) + kwargs["headers"] = headers + response = self.http_manager.post(url, operation="oauth", **kwargs) # Update cookies from response self._update_cookies_from_response(response) @@ -71,17 +73,23 @@ class SessionAwareHTTPManager: def _update_cookies_from_response(self, response): """Extract and update cookies from response""" - if hasattr(response, 'cookies'): + if hasattr(response, "cookies"): for cookie in response.cookies: self.cookies[cookie.name] = cookie.value class BaseOAuth2Authenticator(BaseAuthenticator): - def __init__(self, provider_name: str, settings_manager=None, credentials=None, - country: Optional[str] = None, # ADD THIS PARAMETER - config_dir: Optional[str] = None, enable_kodi_integration: bool = True, - proxy_config: Optional[ProxyConfig] = None, - http_manager=None): + def __init__( + self, + provider_name: str, + settings_manager=None, + credentials=None, + country: Optional[str] = None, # ADD THIS PARAMETER + config_dir: Optional[str] = None, + enable_kodi_integration: bool = True, + proxy_config: Optional[ProxyConfig] = None, + http_manager=None, + ): # Pass country to parent BaseAuthenticator super().__init__( provider_name, @@ -89,13 +97,13 @@ class BaseOAuth2Authenticator(BaseAuthenticator): credentials, country=country, # ADD THIS LINE config_dir=config_dir, - enable_kodi_integration=enable_kodi_integration + enable_kodi_integration=enable_kodi_integration, ) self._oauth_state = None self._pkce_verifier = None # Preserve _config if subclass already set it, otherwise initialize to None - if not hasattr(self, '_config'): + if not hasattr(self, "_config"): self._config = None self._proxy_config = proxy_config @@ -109,18 +117,23 @@ 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 + self._http_manager = HTTPManagerFactory.create_for_provider( self.provider_name, proxy_config=self._proxy_config, - user_agent=getattr(self.config, 'user_agent', 'Mozilla/5.0'), - timeout=getattr(self.config, 'timeout', 30) + user_agent=getattr(self.config, "user_agent", "Mozilla/5.0"), + 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 @@ -139,28 +152,34 @@ 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" def get_base_headers(self): return { - 'User-Agent': self.user_agent, - 'Accept': 'application/json', + "User-Agent": self.user_agent, + "Accept": "application/json", } def get_auth_headers(self): return self.get_base_headers() self._config = MinimalConfig() - logger.warning(f"Using minimal config for {self.provider_name} - subclass should set config") + logger.warning( + f"Using minimal config for {self.provider_name} - subclass should set config" + ) return self._config @config.setter @@ -176,11 +195,13 @@ class BaseOAuth2Authenticator(BaseAuthenticator): @staticmethod def get(url, operation=None, headers=None, **kwargs): import requests + return requests.get(url, headers=headers, **kwargs) @staticmethod def post(url, operation=None, headers=None, data=None, **kwargs): import requests + return requests.post(url, headers=headers, data=data, **kwargs) return MinimalHTTPManager() @@ -188,13 +209,15 @@ class BaseOAuth2Authenticator(BaseAuthenticator): @property def auth_endpoint(self) -> str: """Get authentication endpoint - subclasses can override""" - if hasattr(self, '_auth_endpoint') and self._auth_endpoint: + if hasattr(self, "_auth_endpoint") and self._auth_endpoint: return self._auth_endpoint - if hasattr(self.config, 'auth_endpoint'): + 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): @@ -220,33 +243,35 @@ class BaseOAuth2Authenticator(BaseAuthenticator): @property def oauth_authorize_endpoint(self) -> str: """Get OAuth2 authorization endpoint""" - if hasattr(self, 'auth_endpoint'): + 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'): - return auth_endpoint.replace('/token', '/auth') - elif '/protocol/openid-connect/token' in auth_endpoint: - return auth_endpoint.replace('/token', '/auth') + if auth_endpoint.endswith("/token"): + return auth_endpoint.replace("/token", "/auth") + elif "/protocol/openid-connect/token" in auth_endpoint: + return auth_endpoint.replace("/token", "/auth") else: - return '/'.join(auth_endpoint.split('/')[:-1]) + '/auth' + return "/".join(auth_endpoint.split("/")[:-1]) + "/auth" # PKCE Implementation @staticmethod def generate_pkce_verifier() -> str: """Generate PKCE code verifier (RFC 7636)""" token = secrets.token_bytes(32) - verifier = base64.urlsafe_b64encode(token).rstrip(b'=').decode('ascii') + verifier = base64.urlsafe_b64encode(token).rstrip(b"=").decode("ascii") logger.debug(f"Generated PKCE verifier: {verifier}") return verifier @staticmethod def generate_pkce_challenge(verifier: str) -> str: """Generate PKCE code challenge from verifier""" - challenge = hashlib.sha256(verifier.encode('ascii')).digest() - challenge_b64 = base64.urlsafe_b64encode(challenge).rstrip(b'=').decode('ascii') + challenge = hashlib.sha256(verifier.encode("ascii")).digest() + challenge_b64 = base64.urlsafe_b64encode(challenge).rstrip(b"=").decode("ascii") logger.debug(f"Generated PKCE challenge: {challenge_b64}") return challenge_b64 @@ -279,11 +304,13 @@ class BaseOAuth2Authenticator(BaseAuthenticator): def _create_oauth_session(self) -> SessionAwareHTTPManager: """Create a session-aware HTTP manager for OAuth flows""" session = SessionAwareHTTPManager(self.http_manager) - session.headers.update({ - 'User-Agent': self.config.user_agent, - 'Referer': getattr(self.config, 'base_website', ''), - 'Origin': getattr(self.config, 'base_website', '') - }) + session.headers.update( + { + "User-Agent": self.config.user_agent, + "Referer": getattr(self.config, "base_website", ""), + "Origin": getattr(self.config, "base_website", ""), + } + ) return session # Complete Client Credentials Flow @@ -293,46 +320,51 @@ 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() response = self.http_manager.post( - self.auth_endpoint, - operation='auth', - headers=headers, - data=data + self.auth_endpoint, operation="auth", headers=headers, data=data ) self._check_oauth_error_response(response) 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) state = self.generate_oauth_state() params = { - 'response_type': 'code', - 'client_id': self.oauth_client_id, - 'redirect_uri': self.oauth_redirect_uri, - 'scope': self.oauth_scope, - 'state': state, - 'code_challenge': code_challenge, - 'code_challenge_method': 'S256' + "response_type": "code", + "client_id": self.oauth_client_id, + "redirect_uri": self.oauth_redirect_uri, + "scope": self.oauth_scope, + "state": state, + "code_challenge": code_challenge, + "code_challenge_method": "S256", } if extra_params: @@ -343,21 +375,24 @@ class BaseOAuth2Authenticator(BaseAuthenticator): return authorization_url, state, code_verifier # Authorization Code Exchange - def _exchange_authorization_code_for_token(self, authorization_code: str, code_verifier: str, - state: str = None, **kwargs) -> Dict[str, Any]: + def _exchange_authorization_code_for_token( + self, authorization_code: str, code_verifier: str, state: str = None, **kwargs + ) -> Dict[str, Any]: """ Exchange authorization code for access token (PKCE flow) 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( authorization_code=authorization_code, code_verifier=code_verifier, state=state, - **kwargs + **kwargs, ) # Allow subclasses to override headers @@ -368,49 +403,51 @@ class BaseOAuth2Authenticator(BaseAuthenticator): use_json = self._should_use_json_for_token_exchange(**kwargs) request_kwargs = { - 'operation': 'auth', - 'headers': headers, - 'timeout': getattr(self.config, 'timeout', 30) + "operation": "auth", + "headers": headers, + "timeout": getattr(self.config, "timeout", 30), } if use_json: - request_kwargs['json_data'] = data + request_kwargs["json_data"] = data else: - request_kwargs['data'] = urlencode(data).encode() + request_kwargs["data"] = urlencode(data).encode() - response = self.http_manager.post( - endpoint, - **request_kwargs - ) + response = self.http_manager.post(endpoint, **request_kwargs) self._check_oauth_error_response(response) 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 - def _build_token_exchange_payload(self, authorization_code: str, code_verifier: str, - state: str = None, **kwargs) -> Dict[str, Any]: + def _build_token_exchange_payload( + self, authorization_code: str, code_verifier: str, state: str = None, **kwargs + ) -> Dict[str, Any]: """Build token exchange payload - subclasses can override for custom parameters""" data = { - 'grant_type': 'authorization_code', - 'client_id': self.oauth_client_id, - 'code': authorization_code, - 'redirect_uri': self.oauth_redirect_uri, - 'code_verifier': code_verifier + "grant_type": "authorization_code", + "client_id": self.oauth_client_id, + "code": authorization_code, + "redirect_uri": self.oauth_redirect_uri, + "code_verifier": code_verifier, } - client_secret = getattr(self.credentials, 'client_secret', None) + client_secret = getattr(self.credentials, "client_secret", None) if client_secret: - data['client_secret'] = client_secret + data["client_secret"] = client_secret return data @@ -419,10 +456,12 @@ 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): - headers['Content-Type'] = 'application/json' + 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' + headers["Content-Type"] = "application/x-www-form-urlencoded" return headers @@ -437,13 +476,13 @@ class BaseOAuth2Authenticator(BaseAuthenticator): # Generic Form-Based Login Flow def _perform_generic_form_login( - self, - username: str, - password: str, - form_selector_pattern: str, - login_fields: Dict[str, str], - extra_params: Dict[str, Any] = None, - additional_form_data: Dict[str, str] = None + self, + username: str, + password: str, + form_selector_pattern: str, + login_fields: Dict[str, str], + extra_params: Dict[str, Any] = None, + additional_form_data: Dict[str, str] = None, ) -> Dict[str, Any]: """ Generic OAuth2 form-based login flow @@ -471,7 +510,9 @@ 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]) @@ -480,36 +521,42 @@ class BaseOAuth2Authenticator(BaseAuthenticator): if additional_form_data: login_data.update(additional_form_data) - login_data[login_fields.get('username', 'username')] = username - login_data[login_fields.get('password', 'password')] = password + login_data[login_fields.get("username", "username")] = username + login_data[login_fields.get("password", "password")] = password # Step 4: Submit login credentials login_response = session.post( login_url, data=login_data, timeout=self.config.timeout, - allow_redirects=False + allow_redirects=False, ) # Step 5: Handle redirect and extract authorization code if login_response.status_code in [302, 303]: - redirect_url = login_response.headers.get('Location') + redirect_url = login_response.headers.get("Location") 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( authorization_code=authorization_code, code_verifier=code_verifier, - state=state + state=state, ) return token_data @@ -528,23 +575,20 @@ class BaseOAuth2Authenticator(BaseAuthenticator): logger.debug(f"Refreshing OAuth2 token for {self.provider_name}") data = { - 'grant_type': 'refresh_token', - 'refresh_token': self._current_token.refresh_token, - 'client_id': self.oauth_client_id, + "grant_type": "refresh_token", + "refresh_token": self._current_token.refresh_token, + "client_id": self.oauth_client_id, } - client_secret = getattr(self.credentials, 'client_secret', None) + client_secret = getattr(self.credentials, "client_secret", None) if client_secret: - data['client_secret'] = client_secret + data["client_secret"] = client_secret headers = self._get_auth_headers() encoded_data = urlencode(data).encode() response = self.http_manager.post( - self.auth_endpoint, - operation='auth', - headers=headers, - data=encoded_data + self.auth_endpoint, operation="auth", headers=headers, data=encoded_data ) self._check_oauth_error_response(response) @@ -570,11 +614,11 @@ class BaseOAuth2Authenticator(BaseAuthenticator): if response.status_code >= 400: try: error_data = response.json() - if 'error' in error_data: + if "error" in error_data: raise OAuth2Error( - error=error_data.get('error'), - error_description=error_data.get('error_description'), - error_uri=error_data.get('error_uri') + error=error_data.get("error"), + error_description=error_data.get("error_description"), + error_uri=error_data.get("error_uri"), ) except (ValueError, KeyError): pass @@ -585,10 +629,7 @@ class BaseOAuth2Authenticator(BaseAuthenticator): # Dynamic Client ID Extraction def _extract_client_id_from_js( - self, - main_page_url: str, - js_file_pattern: str, - client_id_pattern: str + self, main_page_url: str, js_file_pattern: str, client_id_pattern: str ) -> Optional[str]: """ Extract client ID from provider's JavaScript @@ -605,25 +646,29 @@ class BaseOAuth2Authenticator(BaseAuthenticator): headers = self.config.get_base_headers() response = self.http_manager.get( - main_page_url, - operation='api', - headers=headers + 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_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) @@ -634,11 +679,11 @@ class BaseOAuth2Authenticator(BaseAuthenticator): # Generic Config Extraction from JS def _extract_config_from_js( - self, - main_page_url: str, - js_file_pattern: str, - config_pattern: str, - parse_function: Callable[[str], Dict[str, Any]] + self, + main_page_url: str, + js_file_pattern: str, + config_pattern: str, + parse_function: Callable[[str], Dict[str, Any]], ) -> Optional[Dict[str, Any]]: """ Generic JS config extraction @@ -656,9 +701,7 @@ class BaseOAuth2Authenticator(BaseAuthenticator): headers = self.config.get_base_headers() response = self.http_manager.get( - main_page_url, - operation='api', - headers=headers + main_page_url, operation="api", headers=headers ) response.raise_for_status() @@ -666,9 +709,11 @@ class BaseOAuth2Authenticator(BaseAuthenticator): if not js_matches: return None - js_url = main_page_url.rstrip('/') + '/' + js_matches[-1].lstrip('/') + 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) @@ -701,7 +746,9 @@ 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 @@ -715,7 +762,9 @@ 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 @@ -726,7 +775,9 @@ class BaseOAuth2Authenticator(BaseAuthenticator): Returns: Bearer token string """ - logger.debug(f"get_bearer_token called: force_refresh={force_refresh}, force_upgrade={force_upgrade}") + logger.debug( + f"get_bearer_token called: force_refresh={force_refresh}, force_upgrade={force_upgrade}" + ) # Get current token (authenticate if needed) current_token = self.authenticate(force_refresh=force_refresh) @@ -737,11 +788,14 @@ 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( - f"Token upgrade triggered (force={force_upgrade}, auto={self._should_upgrade_to_user_token(current_token)})") + f"Token upgrade triggered (force={force_upgrade}, auto={self._should_upgrade_to_user_token(current_token)})" + ) original_credentials = self.credentials @@ -768,7 +822,8 @@ class BaseOAuth2Authenticator(BaseAuthenticator): return user_token.bearer_token else: logger.warning( - f"Authentication succeeded but token is not user level: {user_token.auth_level.value}") + f"Authentication succeeded but token is not user level: {user_token.auth_level.value}" + ) self.credentials = original_credentials return current_token.bearer_token else: @@ -786,52 +841,68 @@ class BaseOAuth2Authenticator(BaseAuthenticator): # Main Authentication Flow def _perform_authentication(self) -> BaseAuthToken: """Complete OAuth2 authentication based on credential type""" - from ...base.auth.credentials import UserPasswordCredentials, ClientCredentials + from ...base.auth.credentials import (ClientCredentials, + UserPasswordCredentials) logger.debug( - f"Starting OAuth2 authentication for {self.provider_name} with credential type: {type(self.credentials)}") + f"Starting OAuth2 authentication for {self.provider_name} with credential type: {type(self.credentials)}" + ) original_credentials = self.credentials 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 + self.credentials.username, self.credentials.password ) elif isinstance(self.credentials, ClientCredentials): - logger.info(f"Attempting OAuth2 client credentials authentication for {self.provider_name}") + logger.info( + f"Attempting OAuth2 client credentials authentication for {self.provider_name}" + ) 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(f"User authentication failed, falling back to client credentials for {self.provider_name}") + logger.info( + f"User authentication failed, falling back to client credentials for {self.provider_name}" + ) try: self.credentials = self.get_fallback_credentials() token_data = self._perform_oauth_client_credentials_flow() result = self._create_token_from_response(token_data) - logger.info(f"Successfully fell back to client credentials for {self.provider_name}") + logger.info( + f"Successfully fell back to client credentials for {self.provider_name}" + ) return result except Exception as fallback_error: self.credentials = original_credentials logger.error( - f"Fallback to client credentials also failed for {self.provider_name}: {fallback_error}") + f"Fallback to client credentials also failed for {self.provider_name}: {fallback_error}" + ) raise e else: raise e # 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 @@ -845,24 +916,28 @@ class BaseOAuth2Authenticator(BaseAuthenticator): status = super().get_authentication_status() oauth_status = { - 'oauth_client_id': self.oauth_client_id, - 'oauth_scope': self.oauth_scope, - 'oauth_redirect_uri': self.oauth_redirect_uri, - 'oauth_authorize_endpoint': self.oauth_authorize_endpoint, - 'authentication_flow': 'oauth2', - '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), + "oauth_client_id": self.oauth_client_id, + "oauth_scope": self.oauth_scope, + "oauth_redirect_uri": self.oauth_redirect_uri, + "oauth_authorize_endpoint": self.oauth_authorize_endpoint, + "authentication_flow": "oauth2", + "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 + ), } if self._current_token: - oauth_status.update({ - 'token_expires_in': self._current_token.expires_in, - 'token_issued_at': self._current_token.issued_at, - 'token_is_expired': self._current_token.is_expired, - 'token_needs_refresh': self._current_token.needs_refresh(), - }) + oauth_status.update( + { + "token_expires_in": self._current_token.expires_in, + "token_issued_at": self._current_token.issued_at, + "token_is_expired": self._current_token.is_expired, + "token_needs_refresh": self._current_token.needs_refresh(), + } + ) status.update(oauth_status) return status @@ -874,13 +949,14 @@ class BaseOAuth2Authenticator(BaseAuthenticator): try: parsed = urlparse(url) query_params = parse_qs(parsed.query) - return query_params.get('code', [None])[0] + return query_params.get("code", [None])[0] except Exception as e: logger.error(f"Error extracting authorization code from URL: {e}") return None - def validate_authentication_response(self, url: str, original_state: str) -> tuple[ - bool, Optional[str], Optional[str]]: + def validate_authentication_response( + self, url: str, original_state: str + ) -> tuple[bool, Optional[str], Optional[str]]: """ Validate OAuth2 authentication response Returns: (is_valid, error_message, authorization_code) @@ -889,16 +965,16 @@ class BaseOAuth2Authenticator(BaseAuthenticator): parsed = urlparse(url) query_params = parse_qs(parsed.query) - if 'error' in query_params: - error = query_params['error'][0] - error_description = query_params.get('error_description', [''])[0] + if "error" in query_params: + error = query_params["error"][0] + error_description = query_params.get("error_description", [""])[0] return False, f"{error}: {error_description}", None - received_state = query_params.get('state', [None])[0] + received_state = query_params.get("state", [None])[0] if not self.validate_oauth_state(received_state, original_state): return False, "State validation failed", None - authorization_code = query_params.get('code', [None])[0] + authorization_code = query_params.get("code", [None])[0] if not authorization_code: return False, "No authorization code in response", None @@ -909,9 +985,11 @@ class BaseOAuth2Authenticator(BaseAuthenticator): # Abstract method for provider-specific authorization code flow @abstractmethod - def _perform_oauth_authorization_code_flow(self, username: str, password: str) -> Dict[str, Any]: + def _perform_oauth_authorization_code_flow( + self, username: str, password: str + ) -> Dict[str, Any]: """ Perform OAuth2 authorization code flow with PKCE for user login Must be implemented by subclasses for provider-specific login forms """ - pass \ No newline at end of file + pass diff --git a/lib/streaming_providers/base/auth/credential_manager.py b/lib/streaming_providers/base/auth/credential_manager.py index fd30ed6..a78874e 100644 --- a/lib/streaming_providers/base/auth/credential_manager.py +++ b/lib/streaming_providers/base/auth/credential_manager.py @@ -1,12 +1,11 @@ # streaming_providers/base/auth/credential_manager.py -from typing import Optional -from .credentials import BaseCredentials, UserPasswordCredentials, ClientCredentials - import time -from typing import Dict, Any, List +from typing import Any, Dict, List, Optional # Import centralized logger and VFS from ..utils.logger import logger +from .credentials import (BaseCredentials, ClientCredentials, + UserPasswordCredentials) class CredentialManager: @@ -18,14 +17,17 @@ class CredentialManager: def __init__(self, config_dir: Optional[str] = None): # Initialize VFS with config directory support from ..utils.vfs import VFS + self.vfs = VFS(config_dir=config_dir) # Credentials file is always in the root of the VFS base path - self.credentials_file = 'credentials.json' + self.credentials_file = "credentials.json" # Ensure base directory exists - self.vfs.mkdirs('') - logger.debug(f"CredentialManager initialized with VFS base: {self.vfs.base_path}") + self.vfs.mkdirs("") + logger.debug( + f"CredentialManager initialized with VFS base: {self.vfs.base_path}" + ) @staticmethod def _get_credential_path(provider: str, country: Optional[str] = None) -> tuple: @@ -44,7 +46,9 @@ class CredentialManager: else: return [provider], False - def load_credentials(self, provider: str, country: Optional[str] = None) -> Optional[BaseCredentials]: + def load_credentials( + self, provider: str, country: Optional[str] = None + ) -> Optional[BaseCredentials]: """ Load credentials for a specific provider and optional country. @@ -61,21 +65,31 @@ 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): - logger.debug(f"CredentialManager: Credentials file does not exist: {self.credentials_file}") + logger.debug( + f"CredentialManager: Credentials file does not exist: {self.credentials_file}" + ) 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: @@ -91,23 +105,34 @@ class CredentialManager: if found and provider_data: logger.debug( - f"CredentialManager: Found credentials with country nesting for '{provider}{country_str}'") - return self._create_credential_from_data(provider, country, provider_data) + f"CredentialManager: Found credentials with country nesting for '{provider}{country_str}'" + ) + return self._create_credential_from_data( + provider, country, provider_data + ) # Fallback: try without country (legacy format: {"provider": {...}}) - logger.debug(f"CredentialManager: No country-nested credentials found, trying legacy format") + logger.debug( + f"CredentialManager: No country-nested credentials found, trying legacy format" + ) keys_path, _ = self._get_credential_path(provider, None) provider_data = data for key in keys_path: if not isinstance(provider_data, dict) or key not in provider_data: - logger.debug(f"CredentialManager: No credentials found at path: {' -> '.join(keys_path)}") + logger.debug( + f"CredentialManager: No credentials found at path: {' -> '.join(keys_path)}" + ) return None provider_data = provider_data[key] if provider_data: - logger.debug(f"CredentialManager: Found credentials in legacy format for '{provider}{country_str}'") - return self._create_credential_from_data(provider, country, provider_data) + logger.debug( + f"CredentialManager: Found credentials in legacy format for '{provider}{country_str}'" + ) + return self._create_credential_from_data( + provider, country, provider_data + ) return None @@ -117,22 +142,29 @@ class CredentialManager: for key in keys_path: if not isinstance(provider_data, dict) or key not in provider_data: - logger.debug(f"CredentialManager: No data found at path: {' -> '.join(keys_path)}") + logger.debug( + f"CredentialManager: No data found at path: {' -> '.join(keys_path)}" + ) return None 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) except Exception as e: - logger.error(f"CredentialManager: Error loading credentials for '{provider}{country_str}': {e}") + logger.error( + f"CredentialManager: Error loading credentials for '{provider}{country_str}': {e}" + ) return None - def _create_credential_from_data(self, provider: str, country: Optional[str], - provider_data: dict) -> Optional[BaseCredentials]: + def _create_credential_from_data( + self, provider: str, country: Optional[str], provider_data: dict + ) -> Optional[BaseCredentials]: """ Helper method to create credential object from loaded data. @@ -146,55 +178,72 @@ class CredentialManager: """ country_str = f" (country: {country})" if country else "" - logger.debug(f"CredentialManager: Found data for '{provider}{country_str}': {list(provider_data.keys())}") + logger.debug( + f"CredentialManager: Found data for '{provider}{country_str}': {list(provider_data.keys())}" + ) - credential_type = provider_data.get('type') - logger.debug(f"CredentialManager: Credential type for '{provider}{country_str}': {credential_type}") + credential_type = provider_data.get("type") + logger.debug( + f"CredentialManager: Credential type for '{provider}{country_str}': {credential_type}" + ) - if credential_type == 'user_password': - username = provider_data.get('username', '') - password_present = 'password' in provider_data - client_id = provider_data.get('client_id') + if credential_type == "user_password": + username = provider_data.get("username", "") + password_present = "password" in provider_data + client_id = provider_data.get("client_id") - logger.debug(f"CredentialManager: Creating UserPasswordCredentials for '{provider}{country_str}'") logger.debug( - f"CredentialManager: Username: '{username}', Password present: {password_present}, Client ID: {client_id}") + f"CredentialManager: Creating UserPasswordCredentials for '{provider}{country_str}'" + ) + logger.debug( + f"CredentialManager: Username: '{username}', Password present: {password_present}, Client ID: {client_id}" + ) creds = UserPasswordCredentials( username=username, - password=self._decode_password(provider_data.get('password', '')), + password=self._decode_password(provider_data.get("password", "")), client_id=client_id, - grant_type=provider_data.get('grant_type', 'password') + grant_type=provider_data.get("grant_type", "password"), ) logger.debug( - f"CredentialManager: Successfully created UserPasswordCredentials for '{provider}{country_str}'") + f"CredentialManager: Successfully created UserPasswordCredentials for '{provider}{country_str}'" + ) return creds - elif credential_type == 'client_credentials': - client_id = provider_data.get('client_id', '') - client_secret_present = 'client_secret' in provider_data + elif credential_type == "client_credentials": + client_id = provider_data.get("client_id", "") + client_secret_present = "client_secret" in provider_data - logger.debug(f"CredentialManager: Creating ClientCredentials for '{provider}{country_str}'") logger.debug( - f"CredentialManager: Client ID: '{client_id}', Client secret present: {client_secret_present}") + f"CredentialManager: Creating ClientCredentials for '{provider}{country_str}'" + ) + logger.debug( + f"CredentialManager: Client ID: '{client_id}', Client secret present: {client_secret_present}" + ) creds = ClientCredentials( client_id=client_id, - client_secret=self._decode_password(provider_data.get('client_secret', '')), - grant_type=provider_data.get('grant_type', 'client_credentials') + client_secret=self._decode_password( + provider_data.get("client_secret", "") + ), + grant_type=provider_data.get("grant_type", "client_credentials"), ) - logger.debug(f"CredentialManager: Successfully created ClientCredentials for '{provider}{country_str}'") + logger.debug( + f"CredentialManager: Successfully created ClientCredentials for '{provider}{country_str}'" + ) return creds else: logger.error( - f"CredentialManager: Unknown credential type for '{provider}{country_str}': {credential_type}") + f"CredentialManager: Unknown credential type for '{provider}{country_str}': {credential_type}" + ) return None - def save_credentials(self, provider: str, credentials: BaseCredentials, - country: Optional[str] = None) -> bool: + def save_credentials( + self, provider: str, credentials: BaseCredentials, country: Optional[str] = None + ) -> bool: """ Save credentials for a specific provider and optional country @@ -208,7 +257,9 @@ class CredentialManager: """ country_str = f" (country: {country})" if country else "" try: - logger.debug(f"Saving {credentials.credential_type} credentials for {provider}{country_str}") + logger.debug( + f"Saving {credentials.credential_type} credentials for {provider}{country_str}" + ) # Load existing data data = {} @@ -218,20 +269,20 @@ class CredentialManager: # Prepare provider data based on credential type if isinstance(credentials, UserPasswordCredentials): provider_data = { - 'type': 'user_password', - 'username': credentials.username, - 'password': self._encode_password(credentials.password), - 'grant_type': credentials.grant_type + "type": "user_password", + "username": credentials.username, + "password": self._encode_password(credentials.password), + "grant_type": credentials.grant_type, } if credentials.client_id: - provider_data['client_id'] = credentials.client_id + provider_data["client_id"] = credentials.client_id elif isinstance(credentials, ClientCredentials): provider_data = { - 'type': 'client_credentials', - 'client_id': credentials.client_id, - 'client_secret': self._encode_password(credentials.client_secret), - 'grant_type': credentials.grant_type + "type": "client_credentials", + "client_id": credentials.client_id, + "client_secret": self._encode_password(credentials.client_secret), + "grant_type": credentials.grant_type, } else: logger.error(f"Unsupported credential type: {type(credentials)}") @@ -257,7 +308,9 @@ 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) @@ -265,15 +318,22 @@ 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] if found: - logger.debug(f"Verification successful: {provider}{country_str} data found in saved file") + logger.debug( + f"Verification successful: {provider}{country_str} data found in saved file" + ) else: - logger.error(f"Verification failed: {provider}{country_str} data NOT found in saved file") + logger.error( + f"Verification failed: {provider}{country_str} data NOT found in saved file" + ) return False return True @@ -301,28 +361,42 @@ class CredentialManager: try: if not self.vfs.exists(self.credentials_file): - logger.debug(f"No credentials file exists, nothing to delete for {provider}{country_str}") + logger.debug( + f"No credentials file exists, nothing to delete for {provider}{country_str}" + ) return True data = self.vfs.read_json(self.credentials_file) if not data: - logger.debug(f"Credentials file is empty, nothing to delete for {provider}{country_str}") + logger.debug( + f"Credentials file is empty, nothing to delete for {provider}{country_str}" + ) return True if country: # Delete specific country data - if provider in data and isinstance(data[provider], dict) and country in data[provider]: + if ( + provider in data + and isinstance(data[provider], dict) + 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: - logger.debug(f"No stored credentials found to delete for {provider}{country_str}") + logger.debug( + f"No stored credentials found to delete for {provider}{country_str}" + ) else: # Delete entire provider data (all countries) if provider in data: @@ -330,7 +404,9 @@ 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 @@ -348,7 +424,9 @@ 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) @@ -390,7 +468,7 @@ class CredentialManager: # and their values are dicts (credential data) countries = [] for key, value in provider_data.items(): - if isinstance(value, dict) and len(key) <= 3 and 'type' in value: + if isinstance(value, dict) and len(key) <= 3 and "type" in value: countries.append(key) return countries @@ -417,7 +495,9 @@ class CredentialManager: @staticmethod def _encode_password(password: str) -> str: """No encoding - store as plaintext (TEMPORARY INSECURE SOLUTION).""" - logger.warning("Storing password in plaintext - this is insecure and should be replaced with proper encryption") + logger.warning( + "Storing password in plaintext - this is insecure and should be replaced with proper encryption" + ) return password @staticmethod @@ -425,8 +505,9 @@ class CredentialManager: """No decoding needed - passwords are stored in plaintext.""" return encoded_password - def export_config(self, provider: Optional[str] = None, - country: Optional[str] = None) -> Dict[str, Any]: + def export_config( + self, provider: Optional[str] = None, country: Optional[str] = None + ) -> Dict[str, Any]: """ Export credentials to a portable format @@ -438,9 +519,9 @@ class CredentialManager: Dictionary with credentials data """ export_data = { - 'version': '1.1', # Bumped for country support - 'exported_at': time.time(), - 'credentials': {} + "version": "1.1", # Bumped for country support + "exported_at": time.time(), + "credentials": {}, } if provider: @@ -449,7 +530,7 @@ class CredentialManager: # Export specific country credentials = self.load_credentials(provider, country) if credentials: - export_data['credentials'][provider] = { + export_data["credentials"][provider] = { country: self._credential_to_dict(credentials) } else: @@ -463,12 +544,14 @@ class CredentialManager: if credentials: provider_data[ctry] = self._credential_to_dict(credentials) if provider_data: - export_data['credentials'][provider] = provider_data + export_data["credentials"][provider] = provider_data else: # 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(): @@ -481,12 +564,14 @@ class CredentialManager: if credentials: provider_data[ctry] = self._credential_to_dict(credentials) if provider_data: - export_data['credentials'][prov] = provider_data + export_data["credentials"][prov] = provider_data else: # 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 @@ -495,16 +580,16 @@ class CredentialManager: """Convert credentials object to dictionary for export""" if isinstance(credentials, UserPasswordCredentials): return { - 'type': 'user_password', - 'username': credentials.username, - 'password': credentials.password, - 'client_id': credentials.client_id + "type": "user_password", + "username": credentials.username, + "password": credentials.password, + "client_id": credentials.client_id, } elif isinstance(credentials, ClientCredentials): return { - 'type': 'client_credentials', - 'client_id': credentials.client_id, - 'client_secret': credentials.client_secret + "type": "client_credentials", + "client_id": credentials.client_id, + "client_secret": credentials.client_secret, } else: return {} @@ -522,16 +607,16 @@ class CredentialManager: results = {} # Validate config data - if not isinstance(config_data, dict) or 'credentials' not in config_data: + if not isinstance(config_data, dict) or "credentials" not in config_data: logger.error("Invalid credential config data format") return results - credentials_data = config_data.get('credentials', {}) + credentials_data = config_data.get("credentials", {}) for provider, provider_data in credentials_data.items(): try: # Check if this is country-aware structure - if isinstance(provider_data, dict) and not provider_data.get('type'): + if isinstance(provider_data, dict) and not provider_data.get("type"): # Nested structure with countries for country, cred_data in provider_data.items(): if not isinstance(cred_data, dict): @@ -539,13 +624,19 @@ 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: - logger.info(f"Successfully imported credentials for {provider} ({country})") + logger.info( + f"Successfully imported credentials for {provider} ({country})" + ) else: - logger.error(f"Failed to import credentials for {provider} ({country})") + logger.error( + f"Failed to import credentials for {provider} ({country})" + ) else: # Flat structure (no country) credentials = self._dict_to_credential(provider_data) @@ -554,7 +645,9 @@ 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}") @@ -568,16 +661,16 @@ class CredentialManager: def _dict_to_credential(cred_data: Dict[str, Any]) -> Optional[BaseCredentials]: """Convert dictionary to credentials object""" try: - if cred_data.get('type') == 'user_password': + if cred_data.get("type") == "user_password": return UserPasswordCredentials( - username=cred_data.get('username', ''), - password=cred_data.get('password', ''), - client_id=cred_data.get('client_id') + username=cred_data.get("username", ""), + password=cred_data.get("password", ""), + client_id=cred_data.get("client_id"), ) - elif cred_data.get('type') == 'client_credentials': + elif cred_data.get("type") == "client_credentials": return ClientCredentials( - client_id=cred_data.get('client_id', ''), - client_secret=cred_data.get('client_secret', '') + client_id=cred_data.get("client_id", ""), + client_secret=cred_data.get("client_secret", ""), ) else: logger.error(f"Unknown credential type: {cred_data.get('type')}") @@ -593,7 +686,9 @@ 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) @@ -602,30 +697,41 @@ class CredentialManager: if file_size and file_size > 0: data = self.vfs.read_json(self.credentials_file) if data: - logger.info(f"Credentials file contains {len(data)} providers: {list(data.keys())}") + logger.info( + f"Credentials file contains {len(data)} providers: {list(data.keys())}" + ) for provider, provider_data in data.items(): if isinstance(provider_data, dict): # Check if nested (has countries) has_countries = any( - isinstance(v, dict) and len(k) <= 3 and 'type' in v + isinstance(v, dict) and len(k) <= 3 and "type" in v for k, v in provider_data.items() ) 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: - cred_type = cred_data.get('type') - username = cred_data.get('username', 'N/A') - logger.info(f" {country}: type={cred_type}, username={username}") + 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( + f" {country}: type={cred_type}, username={username}" + ) else: # Flat structure (no country) - cred_type = provider_data.get('type', 'unknown') - username = provider_data.get('username', 'N/A') - logger.info(f" {provider} (no country): type={cred_type}, username={username}") + cred_type = provider_data.get("type", "unknown") + username = provider_data.get("username", "N/A") + logger.info( + 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: @@ -634,4 +740,4 @@ class CredentialManager: logger.info(f"=== END CREDENTIALS FILE DEBUG ===") except Exception as e: - logger.error(f"Error during credentials file debug: {e}") \ No newline at end of file + logger.error(f"Error during credentials file debug: {e}") diff --git a/lib/streaming_providers/base/auth/credentials.py b/lib/streaming_providers/base/auth/credentials.py index 6396e17..7a9e64b 100644 --- a/lib/streaming_providers/base/auth/credentials.py +++ b/lib/streaming_providers/base/auth/credentials.py @@ -1,7 +1,7 @@ # streaming_providers/base/auth/credentials.py from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import Dict, Any, Optional +from typing import Any, Dict, Optional @dataclass @@ -32,10 +32,11 @@ class UserPasswordCredentials(BaseCredentials): """ Username/password based credentials """ + username: str password: str client_id: Optional[str] = None - grant_type: str = 'password' + grant_type: str = "password" def validate(self) -> bool: """Validate username/password credentials""" @@ -44,12 +45,12 @@ class UserPasswordCredentials(BaseCredentials): def to_auth_payload(self) -> Dict[str, Any]: """Convert to authentication payload""" payload = { - 'grant_type': self.grant_type, - 'username': self.username, - 'password': self.password + "grant_type": self.grant_type, + "username": self.username, + "password": self.password, } if self.client_id: - payload['client_id'] = self.client_id + payload["client_id"] = self.client_id return payload @property @@ -62,9 +63,10 @@ class ClientCredentials(BaseCredentials): """ Client credentials (client_id/client_secret) based authentication """ + client_id: str client_secret: Optional[str] = "" - grant_type: str = 'client_credentials' + grant_type: str = "client_credentials" def validate(self) -> bool: """Validate client credentials""" @@ -73,9 +75,9 @@ class ClientCredentials(BaseCredentials): def to_auth_payload(self) -> Dict[str, Any]: """Convert to authentication payload""" return { - 'grant_type': self.grant_type, - 'client_id': self.client_id, - 'client_secret': self.client_secret + "grant_type": self.grant_type, + "client_id": self.client_id, + "client_secret": self.client_secret, } @property diff --git a/lib/streaming_providers/base/auth/session_manager.py b/lib/streaming_providers/base/auth/session_manager.py index edd818e..d92ab15 100644 --- a/lib/streaming_providers/base/auth/session_manager.py +++ b/lib/streaming_providers/base/auth/session_manager.py @@ -1,12 +1,12 @@ # streaming_providers/base/auth/session_manager.py -import uuid import time -from typing import Optional, Dict, Any -from .base_auth import BaseAuthToken +import uuid +from typing import Any, Dict, Optional # Import centralized logger and VFS from ..utils.logger import logger from ..utils.vfs import VFS +from .base_auth import BaseAuthToken class SessionManager: @@ -31,10 +31,10 @@ class SessionManager: self.vfs = VFS() # Session file is always in the root of the VFS base path - self.session_file = 'session.json' + self.session_file = "session.json" # Ensure base directory exists - self.vfs.mkdirs('') + self.vfs.mkdirs("") logger.debug(f"SessionManager initialized with VFS base: {self.vfs.base_path}") logger.debug(f"Session file: {self.vfs.join_path(self.session_file)}") @@ -56,7 +56,9 @@ class SessionManager: else: return [provider], False - def load_session(self, provider: str, country: Optional[str] = None) -> Optional[Dict[str, Any]]: + def load_session( + self, provider: str, country: Optional[str] = None + ) -> Optional[Dict[str, Any]]: """ Load session data for a specific provider and optional country @@ -83,16 +85,22 @@ 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 @@ -100,8 +108,9 @@ class SessionManager: logger.error(f"Error loading session for {provider}{country_str}: {e}") return None - def save_session(self, provider: str, session_data: Dict[str, Any], - country: Optional[str] = None) -> bool: + def save_session( + self, provider: str, session_data: Dict[str, Any], country: Optional[str] = None + ) -> bool: """ Save session data for a specific provider and optional country @@ -123,13 +132,19 @@ class SessionManager: logger.debug(f"Loaded existing data for providers: {list(data.keys())}") # Extract and preserve token classification data if it's a token object - if hasattr(session_data, 'auth_level'): - if hasattr(session_data, 'to_dict'): + if hasattr(session_data, "auth_level"): + if hasattr(session_data, "to_dict"): session_data = session_data.to_dict() else: token_dict = {} - for key in ['access_token', 'refresh_token', 'token_type', 'expires_in', 'issued_at', - 'refresh_expires_in']: + for key in [ + "access_token", + "refresh_token", + "token_type", + "expires_in", + "issued_at", + "refresh_expires_in", + ]: if hasattr(session_data, key): token_dict[key] = getattr(session_data, key) session_data = token_dict @@ -158,7 +173,9 @@ 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) @@ -166,18 +183,27 @@ 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] if found: - logger.debug(f"Verification successful: {provider}{country_str} data found in saved file") + logger.debug( + f"Verification successful: {provider}{country_str} data found in saved file" + ) else: - logger.error(f"Verification failed: {provider}{country_str} data NOT found in saved file") + logger.error( + f"Verification failed: {provider}{country_str} data NOT found in saved file" + ) 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") @@ -187,12 +213,18 @@ class SessionManager: except Exception as e: import traceback + logger.error(f"Error saving session for {provider}{country_str}: {e}") logger.error(f"Full traceback: {traceback.format_exc()}") return False - def save_scoped_token(self, provider: str, scope: str, token_data: Dict[str, Any], - country: Optional[str] = None) -> bool: + def save_scoped_token( + self, + provider: str, + scope: str, + token_data: Dict[str, Any], + country: Optional[str] = None, + ) -> bool: """ Save authentication token for a specific scope @@ -208,7 +240,9 @@ 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 {} @@ -218,27 +252,38 @@ 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(self, provider: str, scope: str, - country: Optional[str] = None) -> Optional[Dict[str, Any]]: + def load_scoped_token( + self, provider: str, scope: str, country: Optional[str] = None + ) -> Optional[Dict[str, Any]]: """ Load token data for a specific scope (ENHANCED VERSION) """ 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: @@ -247,16 +292,20 @@ 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 token_data = session_data[scope] # 🚨 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") + 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" + ) return None # Persona tokens don't expire in the same way - they use expires_at field @@ -264,22 +313,28 @@ class SessionManager: return token_data # 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") + 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" + ) return None # Check access token expiration access_token_expired = self._is_token_expired(token_data) # For yo_digital, also check refresh token - if scope == 'yo_digital' and 'refresh_token' in token_data: + if scope == "yo_digital" and "refresh_token" in token_data: refresh_token_expired = self._is_refresh_token_expired(token_data) if access_token_expired and not refresh_token_expired: - logger.info(f"Access token expired for scope '{scope}' but refresh token still valid") + logger.info( + f"Access token expired for scope '{scope}' but refresh token still valid" + ) 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}") @@ -295,7 +350,9 @@ 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 @@ -318,44 +375,53 @@ class SessionManager: # Format 1: Standard single token expiration # Used by: tvhubs tokens, taa tokens, SAM3 tokens - if 'expires_in' in token_data and 'issued_at' in token_data: - expires_in = token_data.get('expires_in', 0) - issued_at = token_data.get('issued_at', 0) + if "expires_in" in token_data and "issued_at" in token_data: + expires_in = token_data.get("expires_in", 0) + issued_at = token_data.get("issued_at", 0) expires_at = issued_at + expires_in is_expired = current_time >= (expires_at - buffer_seconds) if is_expired: - logger.debug(f"Token expired (standard format): " - f"issued_at={issued_at}, expires_in={expires_in}, " - f"expires_at={expires_at}, current={current_time}") + logger.debug( + f"Token expired (standard format): " + f"issued_at={issued_at}, expires_in={expires_in}, " + f"expires_at={expires_at}, current={current_time}" + ) return is_expired # 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: - expires_in = token_data.get('access_token_expires_in', 0) - issued_at = token_data.get('access_token_issued_at', 0) + 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 is_expired = current_time >= (expires_at - buffer_seconds) if is_expired: - logger.debug(f"Access token expired (yo_digital format): " - f"issued_at={issued_at}, expires_in={expires_in}, " - f"expires_at={expires_at}, current={current_time}") + logger.debug( + f"Access token expired (yo_digital format): " + f"issued_at={issued_at}, expires_in={expires_in}, " + f"expires_at={expires_at}, current={current_time}" + ) return is_expired # Format 3: Direct expiration timestamp (if some API returns 'exp' or 'expires_at') - if 'expires_at' in token_data: - expires_at = token_data.get('expires_at', 0) + if "expires_at" in token_data: + expires_at = token_data.get("expires_at", 0) is_expired = current_time >= (expires_at - buffer_seconds) if is_expired: - logger.debug(f"Token expired (direct timestamp): " - f"expires_at={expires_at}, current={current_time}") + logger.debug( + f"Token expired (direct timestamp): " + f"expires_at={expires_at}, current={current_time}" + ) return is_expired @@ -364,7 +430,9 @@ 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) @@ -379,26 +447,32 @@ 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) + expires_in = token_data.get("refresh_token_expires_in", 0) + issued_at = token_data.get("refresh_token_issued_at", 0) expires_at = issued_at + expires_in is_expired = current_time >= (expires_at - buffer_seconds) if is_expired: - logger.debug(f"Refresh token expired (yo_digital format): " - f"issued_at={issued_at}, expires_in={expires_in}, " - f"expires_at={expires_at}, current={current_time}") + logger.debug( + f"Refresh token expired (yo_digital format): " + f"issued_at={issued_at}, expires_in={expires_in}, " + f"expires_at={expires_at}, current={current_time}" + ) return is_expired # No refresh token or no expiration info return False - def save_token(self, provider: str, token: BaseAuthToken, - country: Optional[str] = None) -> bool: + def save_token( + self, provider: str, token: BaseAuthToken, country: Optional[str] = None + ) -> bool: """ Save authentication token for a provider (legacy compatibility) @@ -423,22 +497,30 @@ 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: logger.error(f"Error saving token for {provider}{country_str}: {e}") return False - def load_token_data(self, provider: str, country: Optional[str] = None) -> Optional[Dict[str, Any]]: + def load_token_data( + self, provider: str, country: Optional[str] = None + ) -> Optional[Dict[str, Any]]: """Load token data for a provider (legacy compatibility)""" country_str = f" (country: {country})" if country else "" @@ -446,23 +528,31 @@ 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}") + if "access_token" not in session_data: + 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 # Check if token is expired if self._is_token_expired(session_data): - has_refresh_token = bool(session_data.get('refresh_token')) + has_refresh_token = bool(session_data.get("refresh_token")) if has_refresh_token: - logger.info(f"Token expired for {provider}{country_str} but refresh token available") + logger.info( + f"Token expired for {provider}{country_str} but refresh token available" + ) return session_data else: - logger.info(f"Token expired for {provider}{country_str} and no refresh token available") + logger.info( + f"Token expired for {provider}{country_str} and no refresh token available" + ) return None logger.info(f"Loaded valid token data for {provider}{country_str}") @@ -474,14 +564,18 @@ class SessionManager: session_data = self.load_session(provider, country) or {} - device_id = session_data.get('device_id') + device_id = session_data.get("device_id") if not device_id: device_id = str(uuid.uuid4()) - session_data['device_id'] = device_id + 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 @@ -492,21 +586,31 @@ class SessionManager: try: data = self.vfs.read_json(self.session_file) if not data: - logger.debug(f"No session file exists, nothing to clear for {provider}{country_str}") + logger.debug( + f"No session file exists, nothing to clear for {provider}{country_str}" + ) return True if country: - if provider in data and isinstance(data[provider], dict) and country in data[provider]: + if ( + provider in data + and isinstance(data[provider], dict) + and country in data[provider] + ): del data[provider][country] logger.info(f"Cleared session data for {provider}{country_str}") 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] @@ -521,8 +625,9 @@ 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 @@ -547,11 +652,15 @@ 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: @@ -561,19 +670,30 @@ 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 - token_fields = ['access_token', 'refresh_token', 'token_type', 'expires_in', - 'issued_at', 'auth_level', 'credential_type'] + token_fields = [ + "access_token", + "refresh_token", + "token_type", + "expires_in", + "issued_at", + "auth_level", + "credential_type", + ] fields_removed = [] for field in token_fields: if session_data.pop(field, None) is not None: 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) @@ -611,8 +731,14 @@ class SessionManager: safe_data = {} for key, value in data.items(): - if key in ['access_token', 'refresh_token', 'client_secret', 'password', - 'persona_token', 'persona_jwt']: # ← ADD THESE TWO FIELDS + if key in [ + "access_token", + "refresh_token", + "client_secret", + "password", + "persona_token", + "persona_jwt", + ]: # ← ADD THESE TWO FIELDS safe_data[key] = f"" if value else f"" elif isinstance(value, dict): # Recursively handle nested dicts (for scoped tokens) @@ -642,7 +768,9 @@ class SessionManager: if file_size and file_size > 0: data = self.vfs.read_json(self.session_file) if data: - logger.info(f"Session file contains {len(data)} providers: {list(data.keys())}") + logger.info( + f"Session file contains {len(data)} providers: {list(data.keys())}" + ) for provider, provider_data in data.items(): if isinstance(provider_data, dict): @@ -655,11 +783,17 @@ 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: @@ -670,4 +804,4 @@ class SessionManager: logger.info(f"=== END SESSION FILE DEBUG ===") except Exception as e: - logger.error(f"Error during session file debug: {e}") \ No newline at end of file + logger.error(f"Error during session file debug: {e}") diff --git a/lib/streaming_providers/base/catchup_operations.py b/lib/streaming_providers/base/catchup_operations.py index 99019e2..c843848 100644 --- a/lib/streaming_providers/base/catchup_operations.py +++ b/lib/streaming_providers/base/catchup_operations.py @@ -3,7 +3,9 @@ """ Catchup/timeshift operations. """ -from typing import Optional, List, Dict + +from typing import Dict, List, Optional + from .utils.logger import logger @@ -15,10 +17,15 @@ class CatchupOperations: self.drm_operations = drm_operations logger.debug("CatchupOperations: Initialized") - def get_catchup_manifest(self, provider_name: str, channel_id: str, - start_time: int, end_time: int, - epg_id: Optional[str] = None, - country: Optional[str] = None) -> Optional[str]: + def get_catchup_manifest( + self, + provider_name: str, + channel_id: str, + start_time: int, + end_time: int, + epg_id: Optional[str] = None, + country: Optional[str] = None, + ) -> Optional[str]: """Get catchup manifest URL.""" provider = self.registry.get_provider(provider_name) if not provider: @@ -39,16 +46,21 @@ class CatchupOperations: start_time=start_time, end_time=end_time, epg_id=epg_id, - country=country + country=country, ) except NotImplementedError: logger.error(f"Provider '{provider_name}' hasn't implemented catchup") return None - def get_catchup_drm_configs(self, provider_name: str, channel_id: str, - start_time: int, end_time: int, - epg_id: Optional[str] = None, - country: Optional[str] = None) -> List: + def get_catchup_drm_configs( + self, + provider_name: str, + channel_id: str, + start_time: int, + end_time: int, + epg_id: Optional[str] = None, + country: Optional[str] = None, + ) -> List: """Get DRM configs for catchup content.""" provider = self.registry.get_provider(provider_name) if not provider: @@ -65,15 +77,16 @@ class CatchupOperations: start_time=start_time, end_time=end_time, epg_id=epg_id, - country=country + country=country, ) except NotImplementedError: return self.drm_operations.get_channel_drm_configs( 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: @@ -101,16 +114,16 @@ class CatchupOperations: try: provider = self.registry.get_provider(name) capabilities[name] = { - 'supports_catchup': provider.supports_catchup, - 'catchup_window': provider.catchup_window, - 'catchup_enabled': provider.supports_catchup and - provider.catchup_window > 0 + "supports_catchup": provider.supports_catchup, + "catchup_window": provider.catchup_window, + "catchup_enabled": provider.supports_catchup + and provider.catchup_window > 0, } except Exception as e: logger.warning(f"Error getting catchup for '{name}': {e}") capabilities[name] = { - 'supports_catchup': False, - 'catchup_window': 0, - 'catchup_enabled': False + "supports_catchup": False, + "catchup_window": 0, + "catchup_enabled": False, } return capabilities diff --git a/lib/streaming_providers/base/channel_operations.py b/lib/streaming_providers/base/channel_operations.py index 7a597cf..9d6d57c 100644 --- a/lib/streaming_providers/base/channel_operations.py +++ b/lib/streaming_providers/base/channel_operations.py @@ -3,7 +3,9 @@ """ Channel-related operations separated from core registry. """ -from typing import List, Optional, Dict + +from typing import Dict, List, Optional + from .models import StreamingChannel from .utils.logger import logger @@ -15,8 +17,9 @@ class ChannelOperations: self.registry = registry logger.debug("ChannelOperations: Initialized") - def get_channels(self, provider_name: str, fetch_manifests: bool = False, - **kwargs) -> List[StreamingChannel]: + def get_channels( + self, provider_name: str, fetch_manifests: bool = False, **kwargs + ) -> List[StreamingChannel]: """Get channels from a specific provider.""" provider = self.registry.get_provider(provider_name) if not provider: @@ -36,8 +39,9 @@ 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: @@ -45,11 +49,14 @@ 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(self, fetch_manifests: bool = True, - **kwargs) -> Dict[str, List[StreamingChannel]]: + def get_all_channels( + self, fetch_manifests: bool = True, **kwargs + ) -> Dict[str, List[StreamingChannel]]: """Get channels from all enabled providers.""" enabled = self.registry.get_enabled_providers() logger.info(f"Fetching channels from {len(enabled)} providers") @@ -68,4 +75,3 @@ class ChannelOperations: logger.info(f"Retrieved {total} total channels") return result - diff --git a/lib/streaming_providers/base/drm/drm_plugin.py b/lib/streaming_providers/base/drm/drm_plugin.py index 4b4d4f6..d5d34b4 100644 --- a/lib/streaming_providers/base/drm/drm_plugin.py +++ b/lib/streaming_providers/base/drm/drm_plugin.py @@ -1,13 +1,14 @@ # streaming_providers/base/drm/drm_plugin.py from abc import ABC, abstractmethod from typing import Optional + from ..models.drm_models import DRMConfig, DRMSystem, PSSHData class DRMPlugin(ABC): """ Abstract base class for DRM configuration plugins. - + Plugins can register to process DRM configs for specific DRM systems and transform them before they are returned to the caller. """ @@ -25,10 +26,9 @@ class DRMPlugin(ABC): pass @abstractmethod - def process_drm_config(self, - drm_config: DRMConfig, - pssh_data: Optional[PSSHData], - **kwargs) -> Optional[DRMConfig]: + def process_drm_config( + self, drm_config: DRMConfig, pssh_data: Optional[PSSHData], **kwargs + ) -> Optional[DRMConfig]: """ Process and transform a DRM configuration. @@ -39,7 +39,7 @@ class DRMPlugin(ABC): Returns: Transformed DRMConfig, or None if the config should be filtered out - + Raises: Exception: Any exception will be caught and logged, plugin will be skipped """ diff --git a/lib/streaming_providers/base/drm/plugin_manager.py b/lib/streaming_providers/base/drm/plugin_manager.py index 609b20f..7710d9a 100644 --- a/lib/streaming_providers/base/drm/plugin_manager.py +++ b/lib/streaming_providers/base/drm/plugin_manager.py @@ -1,15 +1,16 @@ # streaming_providers/base/drm/plugin_manager.py -from typing import Dict, List, Optional -from ..models.drm_models import DRMConfig, DRMSystem, PSSHData -from .drm_plugin import DRMPlugin -from ..utils.logger import logger import traceback +from typing import Dict, List, Optional + +from ..models.drm_models import DRMConfig, DRMSystem, PSSHData +from ..utils.logger import logger +from .drm_plugin import DRMPlugin class DRMPluginManager: """ Manager for DRM configuration plugins. - + Handles plugin registration, discovery, and processing of DRM configs with PSSH data. """ @@ -17,14 +18,20 @@ class DRMPluginManager: """Initialize with empty plugin registry and optionally auto-discover plugins""" self.plugins: Dict[DRMSystem, DRMPlugin] = {} 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") + 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: """ @@ -32,153 +39,192 @@ class DRMPluginManager: Args: plugin: Configured plugin instance to register - + Raises: ValueError: If plugin is invalid or DRM system already has a plugin """ if not isinstance(plugin, DRMPlugin): - logger.warning(f"DRMPluginManager: Registration failed - invalid plugin type: {type(plugin)}") + logger.warning( + f"DRMPluginManager: Registration failed - invalid plugin type: {type(plugin)}" + ) raise ValueError("Only DRMPlugin instances can be registered") - + drm_system = plugin.supported_drm_system plugin_name = plugin.plugin_name - + if drm_system in self.plugins: existing_plugin = self.plugins[drm_system].plugin_name - logger.warning(f"DRMPluginManager: Registration failed - DRM system {drm_system} already has plugin '{existing_plugin}' registered") - raise ValueError(f"DRM system {drm_system} already has plugin '{existing_plugin}' registered") - + logger.warning( + f"DRMPluginManager: Registration failed - DRM system {drm_system} already has plugin '{existing_plugin}' registered" + ) + raise ValueError( + f"DRM system {drm_system} already has plugin '{existing_plugin}' registered" + ) + self.plugins[drm_system] = plugin - logger.debug(f"DRMPluginManager: Successfully registered plugin '{plugin_name}' for DRM system {drm_system}") + logger.debug( + f"DRMPluginManager: Successfully registered plugin '{plugin_name}' for DRM system {drm_system}" + ) def discover_plugins(self) -> List[str]: """ Discover and register all available DRM plugins by scanning filesystem. - + Scans only the plugins directory (not subdirectories) for Python files containing classes that inherit from DRMPlugin. Returns: List of discovered plugin names """ - import os import importlib.util import inspect - + import os + logger.debug("DRMPluginManager: Starting filesystem-based plugin autodiscovery") - + # Get the directory where this plugin manager is located current_dir = os.path.dirname(os.path.abspath(__file__)) plugins_dir = os.path.join(current_dir, "plugins") # Scan the plugins subfolder - + # 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}") - + registered = [] failed_plugins = [] scanned_files = [] - + # Scan only the plugins directory (no subdirectories) try: files = os.listdir(plugins_dir) except OSError as e: logger.warning(f"DRMPluginManager: Error reading plugins directory: {e}") return [] - + for filename in files: file_path = os.path.join(plugins_dir, filename) - + # Only process Python files (not directories or other files) - if (filename.endswith('.py') and - not filename.startswith('__') and - os.path.isfile(file_path)): - + if ( + filename.endswith(".py") + and not filename.startswith("__") + and os.path.isfile(file_path) + ): + scanned_files.append(filename) - + logger.debug(f"DRMPluginManager: Scanning file: {filename}") - + try: # Create module name from filename 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}") + logger.debug( + f"DRMPluginManager: Could not create module spec for {filename}" + ) continue - + module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) - + # Find all classes in the module that inherit from DRMPlugin plugin_classes = [] for name, obj in inspect.getmembers(module, inspect.isclass): # Check if it's a DRMPlugin subclass (but not DRMPlugin itself) - if (issubclass(obj, DRMPlugin) and - obj is not DRMPlugin and - obj.__module__ == module.__name__): + if ( + issubclass(obj, DRMPlugin) + and obj is not DRMPlugin + and obj.__module__ == module.__name__ + ): 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(f"DRMPluginManager: Found {len(plugin_classes)} plugin class(es) in {filename}: {[name for name, _ in plugin_classes]}") - + + logger.debug( + f"DRMPluginManager: Found {len(plugin_classes)} plugin class(es) in {filename}: {[name for name, _ in plugin_classes]}" + ) + # Instantiate and register each plugin class found for class_name, plugin_class in plugin_classes: try: - logger.debug(f"DRMPluginManager: Attempting to instantiate {class_name} from {filename}") - + logger.debug( + f"DRMPluginManager: Attempting to instantiate {class_name} from {filename}" + ) + # Create plugin instance plugin = plugin_class() plugin_name = plugin.plugin_name drm_system = plugin.supported_drm_system - - logger.debug(f"DRMPluginManager: Successfully created plugin '{plugin_name}' (class: {class_name}) supporting {drm_system}") - + + logger.debug( + f"DRMPluginManager: Successfully created plugin '{plugin_name}' (class: {class_name}) supporting {drm_system}" + ) + # Register the plugin self.register_plugin(plugin) registered.append(plugin_name) - logger.debug(f"DRMPluginManager: Plugin '{plugin_name}' from {filename} successfully registered") - + logger.debug( + f"DRMPluginManager: Plugin '{plugin_name}' from {filename} successfully registered" + ) + 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)) + 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}") - + # Log scanning summary - logger.debug(f"DRMPluginManager: Filesystem scan completed - scanned {len(scanned_files)} Python files") + logger.debug( + f"DRMPluginManager: Filesystem scan completed - scanned {len(scanned_files)} Python files" + ) if scanned_files: logger.debug(f"DRMPluginManager: Scanned files: {scanned_files}") - + # Log final discovery results if registered: - logger.debug(f"DRMPluginManager: Filesystem autodiscovery completed successfully - {len(registered)} plugins registered: {registered}") + logger.debug( + f"DRMPluginManager: Filesystem autodiscovery completed successfully - {len(registered)} plugins registered: {registered}" + ) else: - logger.debug("DRMPluginManager: Filesystem autodiscovery completed - no plugins were registered") - + logger.debug( + "DRMPluginManager: Filesystem autodiscovery completed - no plugins were registered" + ) + 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}") return registered - def process_drm_configs(self, - drm_configs: List[DRMConfig], - pssh_data_list: List[PSSHData], - **kwargs) -> List[DRMConfig]: + def process_drm_configs( + self, drm_configs: List[DRMConfig], pssh_data_list: List[PSSHData], **kwargs + ) -> List[DRMConfig]: """ Process a list of DRM configs through registered plugins using PSSH data. Generic plugins are processed first, and if any ClearKey config is found, @@ -196,31 +242,39 @@ class DRMPluginManager: logger.debug("DRMPluginManager: No DRM configs to process") return drm_configs - logger.debug(f"DRMPluginManager: Processing {len(drm_configs)} DRM configs with {len(pssh_data_list)} PSSH data entries") + logger.debug( + f"DRMPluginManager: Processing {len(drm_configs)} DRM configs with {len(pssh_data_list)} PSSH data entries" + ) # Create a mapping of DRM system to PSSH data for quick lookup pssh_by_system = {} for pssh_data in pssh_data_list: if pssh_data.drm_system: pssh_by_system[pssh_data.drm_system] = pssh_data - logger.debug(f"DRMPluginManager: Mapped PSSH data for DRM system: {pssh_data.drm_system}") + logger.debug( + f"DRMPluginManager: Mapped PSSH data for DRM system: {pssh_data.drm_system}" + ) # Separate generic and specific plugins generic_plugins = [] specific_plugins = [] - + 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)) # Process generic plugins first processed_configs = list(drm_configs) - + for plugin in generic_plugins: - logger.debug(f"DRMPluginManager: Processing through generic plugin '{plugin.plugin_name}'") + logger.debug( + f"DRMPluginManager: Processing through generic plugin '{plugin.plugin_name}'" + ) try: # Generic plugins process all configs at once temp_configs = [] @@ -229,58 +283,84 @@ class DRMPluginManager: result = plugin.process_drm_config(config, pssh_data, **kwargs) if result is not None: temp_configs.append(result) - + # Check for ClearKey and return immediately if found for config in temp_configs: if config.system == DRMSystem.CLEARKEY: - logger.debug(f"DRMPluginManager: ClearKey config found, returning immediately") + logger.debug( + f"DRMPluginManager: ClearKey config found, returning immediately" + ) return [config] - + processed_configs = temp_configs - + except Exception as e: - logger.warning(f"DRMPluginManager: Generic plugin '{plugin.plugin_name}' failed: {str(e)}") + logger.warning( + f"DRMPluginManager: Generic plugin '{plugin.plugin_name}' failed: {str(e)}" + ) continue # 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) - + if plugin: - logger.debug(f"DRMPluginManager: Found plugin '{plugin.plugin_name}' for DRM system {config.system}") - + logger.debug( + f"DRMPluginManager: Found plugin '{plugin.plugin_name}' for DRM system {config.system}" + ) + try: pssh_data = pssh_by_system.get(config.system) if pssh_data: - logger.debug(f"DRMPluginManager: Using PSSH data for DRM system {config.system}") + logger.debug( + f"DRMPluginManager: Using PSSH data for DRM system {config.system}" + ) else: - logger.debug(f"DRMPluginManager: No PSSH data available for DRM system {config.system}") - - processed_config = plugin.process_drm_config(config, pssh_data, **kwargs) + logger.debug( + f"DRMPluginManager: No PSSH data available for DRM system {config.system}" + ) + + 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: - logger.debug(f"DRMPluginManager: ClearKey config found, returning immediately") + logger.debug( + f"DRMPluginManager: ClearKey config found, returning immediately" + ) return [processed_config] - + final_configs.append(processed_config) - logger.debug(f"DRMPluginManager: Plugin '{plugin.plugin_name}' successfully processed config") + logger.debug( + f"DRMPluginManager: Plugin '{plugin.plugin_name}' successfully processed config" + ) else: - logger.debug(f"DRMPluginManager: Plugin '{plugin.plugin_name}' filtered out config (returned None)") - + logger.debug( + f"DRMPluginManager: Plugin '{plugin.plugin_name}' filtered out config (returned None)" + ) + except Exception as e: - logger.warning(f"DRMPluginManager: Plugin '{plugin.plugin_name}' failed to process config: {str(e)}") + logger.warning( + f"DRMPluginManager: Plugin '{plugin.plugin_name}' failed to process config: {str(e)}" + ) final_configs.append(config) logger.debug(f"DRMPluginManager: Using original config as fallback") else: - logger.debug(f"DRMPluginManager: No plugin registered for DRM system {config.system}, passing through unchanged") + logger.debug( + f"DRMPluginManager: No plugin registered for DRM system {config.system}, passing through unchanged" + ) final_configs.append(config) - - logger.debug(f"DRMPluginManager: Completed processing - {len(final_configs)} configs returned") + + logger.debug( + f"DRMPluginManager: Completed processing - {len(final_configs)} configs returned" + ) return final_configs def get_plugin(self, drm_system: DRMSystem) -> Optional[DRMPlugin]: @@ -295,9 +375,13 @@ class DRMPluginManager: """ plugin = self.plugins.get(drm_system) if plugin: - logger.debug(f"DRMPluginManager: Retrieved plugin '{plugin.plugin_name}' for DRM system {drm_system}") + logger.debug( + 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]: @@ -307,7 +391,10 @@ class DRMPluginManager: Returns: Dictionary mapping DRM systems to plugin names """ - plugin_list = {drm_system: plugin.plugin_name for drm_system, plugin in self.plugins.items()} + plugin_list = { + 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 022fbe3..e76a0f0 100644 --- a/lib/streaming_providers/base/drm_operations.py +++ b/lib/streaming_providers/base/drm_operations.py @@ -3,7 +3,9 @@ """ DRM-related operations. """ -from typing import List, Dict + +from typing import Dict, List + from .drm import DRMPluginManager from .models import DRMSystem from .utils.logger import logger @@ -17,8 +19,9 @@ class DRMOperations: self.drm_plugin_manager = DRMPluginManager() logger.debug("DRMOperations: Initialized") - 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: @@ -48,13 +51,13 @@ class DRMOperations: plugin_systems = set(self.drm_plugin_manager.plugins.keys()) return bool( - config_systems & plugin_systems or - DRMSystem.GENERIC in plugin_systems + 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.""" import requests + from .utils.manifest_parser import ManifestParser try: @@ -74,4 +77,3 @@ class DRMOperations: def clear_drm_plugins(self): """Clear all DRM plugins.""" self.drm_plugin_manager.clear_plugins() - diff --git a/lib/streaming_providers/base/epg/__init__.py b/lib/streaming_providers/base/epg/__init__.py index b2ff0a1..3ea3bd2 100644 --- a/lib/streaming_providers/base/epg/__init__.py +++ b/lib/streaming_providers/base/epg/__init__.py @@ -4,14 +4,9 @@ EPG Module for XMLTV EPG handling """ -from .epg_manager import EPGManager from .epg_cache import EPGCache +from .epg_manager import EPGManager from .epg_mapping import EPGMapping from .epg_parser import EPGParser -__all__ = [ - 'EPGManager', - 'EPGCache', - 'EPGMapping', - 'EPGParser' -] \ No newline at end of file +__all__ = ["EPGManager", "EPGCache", "EPGMapping", "EPGParser"] diff --git a/lib/streaming_providers/base/epg/epg_cache.py b/lib/streaming_providers/base/epg/epg_cache.py index b095afe..d942d66 100644 --- a/lib/streaming_providers/base/epg/epg_cache.py +++ b/lib/streaming_providers/base/epg/epg_cache.py @@ -6,7 +6,8 @@ Handles downloading, caching, and TTL management for EPG XML files """ import time -from typing import Optional, Dict +from typing import Dict, Optional + from ..utils.logger import logger from ..utils.vfs import VFS @@ -60,10 +61,10 @@ class EPGCache: True if saved successfully """ metadata = { - 'downloaded_at': int(time.time()), - 'url': url, - 'file_size': file_size, - 'is_gzipped': is_gzipped + "downloaded_at": int(time.time()), + "url": url, + "file_size": file_size, + "is_gzipped": is_gzipped, } success = self.vfs.write_json(self.METADATA_FILE, metadata) @@ -86,17 +87,19 @@ class EPGCache: return False # Check if EPG file exists - filename = self.EPG_GZ_FILE if metadata.get('is_gzipped') else self.EPG_FILE + filename = self.EPG_GZ_FILE if metadata.get("is_gzipped") else self.EPG_FILE if not self.vfs.exists(filename): logger.debug(f"EPGCache: EPG file '{filename}' not found, cache invalid") return False # Check TTL - downloaded_at = metadata.get('downloaded_at', 0) + downloaded_at = metadata.get("downloaded_at", 0) 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)") @@ -116,7 +119,7 @@ class EPGCache: if not metadata: return None - filename = self.EPG_GZ_FILE if metadata.get('is_gzipped') else self.EPG_FILE + filename = self.EPG_GZ_FILE if metadata.get("is_gzipped") else self.EPG_FILE file_path = self.vfs.join_path(filename) logger.debug(f"EPGCache: Returning cached file path: {file_path}") @@ -143,12 +146,12 @@ class EPGCache: response.raise_for_status() # Determine if content is gzipped - content_type = response.headers.get('Content-Type', '').lower() - content_encoding = response.headers.get('Content-Encoding', '').lower() + 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 + "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 @@ -163,7 +166,7 @@ class EPGCache: chunk_size = 8192 total_size = 0 - with open(file_path, 'wb') as f: + with open(file_path, "wb") as f: for chunk in response.iter_content(chunk_size=chunk_size): if chunk: f.write(chunk) @@ -234,19 +237,19 @@ class EPGCache: if not metadata: return None - filename = self.EPG_GZ_FILE if metadata.get('is_gzipped') else self.EPG_FILE + filename = self.EPG_GZ_FILE if metadata.get("is_gzipped") else self.EPG_FILE file_exists = self.vfs.exists(filename) - age = int(time.time()) - metadata.get('downloaded_at', 0) + age = int(time.time()) - metadata.get("downloaded_at", 0) is_valid = self.is_cache_valid() return { - 'url': metadata.get('url'), - 'downloaded_at': metadata.get('downloaded_at'), - 'age_seconds': age, - 'file_size': metadata.get('file_size'), - 'is_gzipped': metadata.get('is_gzipped'), - 'file_exists': file_exists, - 'is_valid': is_valid, - 'ttl_seconds': self.CACHE_TTL_SECONDS - } \ No newline at end of file + "url": metadata.get("url"), + "downloaded_at": metadata.get("downloaded_at"), + "age_seconds": age, + "file_size": metadata.get("file_size"), + "is_gzipped": metadata.get("is_gzipped"), + "file_exists": file_exists, + "is_valid": is_valid, + "ttl_seconds": self.CACHE_TTL_SECONDS, + } diff --git a/lib/streaming_providers/base/epg/epg_manager.py b/lib/streaming_providers/base/epg/epg_manager.py index f5b631e..b528d2c 100644 --- a/lib/streaming_providers/base/epg/epg_manager.py +++ b/lib/streaming_providers/base/epg/epg_manager.py @@ -36,13 +36,14 @@ when only the broadcast_id is passed from Kodi. """ from datetime import datetime, timedelta -from typing import List, Dict, Optional +from typing import Dict, List, Optional + +from ..models.epg_models import EPGEntry +from ..utils.environment import get_environment_manager, is_kodi_environment +from ..utils.logger import logger from .epg_cache import EPGCache from .epg_mapping import EPGMapping from .epg_parser import EPGParser -from ..models.epg_models import EPGEntry -from ..utils.logger import logger -from ..utils.environment import get_environment_manager, is_kodi_environment class EPGManager: @@ -90,7 +91,7 @@ class EPGManager: bridge = KodiSettingsBridge() if bridge.is_kodi_environment(): - url = bridge.addon.getSetting('epg_xml_url') + url = bridge.addon.getSetting("epg_xml_url") if url and url.strip(): logger.info(f"EPGManager: Using EPG URL from settings: {url}") @@ -117,11 +118,11 @@ class EPGManager: return start, end def get_epg( - self, - provider_name: str, - channel_id: str, - start_time: Optional[datetime] = None, - end_time: Optional[datetime] = None + self, + provider_name: str, + channel_id: str, + start_time: Optional[datetime] = None, + end_time: Optional[datetime] = None, ) -> List[Dict]: """ Get EPG data for a specific channel within a time range. @@ -176,7 +177,7 @@ class EPGManager: epg_channel_id, start_ts, end_ts, - provider_name # Enable provider encoding + provider_name, # Enable provider encoding ) logger.info( @@ -192,11 +193,11 @@ class EPGManager: return [] def get_epg_entries( - self, - provider_name: str, - channel_id: str, - start_time: Optional[datetime] = None, - end_time: Optional[datetime] = None + self, + provider_name: str, + channel_id: str, + start_time: Optional[datetime] = None, + end_time: Optional[datetime] = None, ) -> List[EPGEntry]: """ Get EPG data as EPGEntry objects (for internal use). @@ -240,7 +241,7 @@ class EPGManager: epg_channel_id, start_ts, end_ts, - provider_name # Enable provider encoding + provider_name, # Enable provider encoding ) except Exception as e: @@ -248,10 +249,10 @@ class EPGManager: return [] def get_epg_for_provider( - self, - provider_name: str, - start_time: Optional[datetime] = None, - end_time: Optional[datetime] = None + self, + provider_name: str, + start_time: Optional[datetime] = None, + end_time: Optional[datetime] = None, ) -> Dict[str, List[Dict]]: """ Get EPG data for all channels of a provider. @@ -266,7 +267,9 @@ 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 = {} @@ -274,7 +277,9 @@ 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 @@ -387,10 +392,17 @@ class EPGManager: try: if is_kodi_environment(): import xbmcaddon + 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}") + 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}" + ) return kodi_url.strip() except Exception as e: logger.debug(f"EPGManager: Could not get EPG URL from Kodi settings: {e}") @@ -398,35 +410,53 @@ class EPGManager: # 3. config.json via environment manager 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": + config_url = env_manager.get_config("epg_url") + 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') + 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 try: # Check if we have cache metadata with a valid URL - metadata = self.cache._get_metadata() if hasattr(self, 'cache') else None - if metadata and 'url' in metadata: - cached_url = metadata.get('url') - if cached_url and cached_url.strip() and cached_url != "https://example.com/epg.xml.gz": - logger.info(f"EPGManager: Using last known URL from cache metadata: {cached_url}") + metadata = self.cache._get_metadata() if hasattr(self, "cache") else None + if metadata and "url" in metadata: + cached_url = metadata.get("url") + if ( + cached_url + and cached_url.strip() + and cached_url != "https://example.com/epg.xml.gz" + ): + logger.info( + f"EPGManager: Using last known URL from cache metadata: {cached_url}" + ) return cached_url.strip() except Exception as e: logger.debug(f"EPGManager: Could not get URL from cache metadata: {e}") # 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 @@ -442,4 +472,3 @@ class EPGManager: True if broadcast_id was generated for this provider """ return EPGEntry.verify_provider(broadcast_id, provider_name) - diff --git a/lib/streaming_providers/base/epg/epg_mapping.py b/lib/streaming_providers/base/epg/epg_mapping.py index c2ef701..c631b06 100644 --- a/lib/streaming_providers/base/epg/epg_mapping.py +++ b/lib/streaming_providers/base/epg/epg_mapping.py @@ -9,9 +9,10 @@ New provider-per-file format: - File contains direct channel mappings without provider wrapper """ -import os import glob -from typing import List, Optional, Dict, Any +import os +from typing import Any, Dict, List, Optional + from ..utils.logger import logger from ..utils.vfs import VFS @@ -49,7 +50,9 @@ 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() @@ -69,15 +72,21 @@ class EPGMapping: bridge = KodiSettingsBridge() if bridge.is_kodi_environment(): addon_info = bridge.get_addon_info() - addon_path = addon_info.get('path') + 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 @@ -85,7 +94,9 @@ 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: @@ -103,7 +114,9 @@ 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") @@ -111,10 +124,14 @@ 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 @@ -129,7 +146,7 @@ class EPGMapping: # Read default file using standard file operations try: - with open(default_file, 'r', encoding='utf-8') as f: + with open(default_file, "r", encoding="utf-8") as f: content = f.read() # Write to user VFS @@ -137,7 +154,9 @@ 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}") @@ -161,7 +180,7 @@ class EPGMapping: Provider name, e.g., "magentaeu_at" """ if filename.endswith(self.MAPPING_FILE_SUFFIX): - return filename[:-len(self.MAPPING_FILE_SUFFIX)] + return filename[: -len(self.MAPPING_FILE_SUFFIX)] return filename def _get_mapping_filename(self, provider_name: str) -> str: @@ -196,14 +215,18 @@ 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 @@ -212,20 +235,22 @@ class EPGMapping: for channel_id, channel_data in raw_mapping.items(): # Skip metadata entries (those starting with underscore) - if channel_id.startswith('_'): + if channel_id.startswith("_"): continue # Handle both dict format and simple string format if isinstance(channel_data, dict): # New format: { "epg_id": "...", "name": "..." } - epg_id = channel_data.get('epg_id') - name = channel_data.get('name', channel_id) + epg_id = channel_data.get("epg_id") + name = channel_data.get("name", channel_id) elif isinstance(channel_data, str): # Legacy simple format: just the epg_id string 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: @@ -236,7 +261,7 @@ class EPGMapping: cached_data = { "mapping": mapping, "names": names, - "provider_name": raw_mapping.get('_provider_name', provider_name) + "provider_name": raw_mapping.get("_provider_name", provider_name), } self._cache[provider_name] = cached_data @@ -248,7 +273,9 @@ class EPGMapping: 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]: @@ -278,7 +305,9 @@ 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 @@ -380,18 +409,15 @@ class EPGMapping: # Get cached providers cached_providers = list(self._cache.keys()) - total_channels = sum( - len(data["mapping"]) - for data in self._cache.values() - ) + total_channels = sum(len(data["mapping"]) for data in self._cache.values()) return { - 'total_providers': len(providers), - 'cached_providers': len(cached_providers), - 'total_channels': total_channels, - 'providers': providers, - 'cached_providers_list': cached_providers, - 'user_mapping_dir': self.user_vfs.base_path + "total_providers": len(providers), + "cached_providers": len(cached_providers), + "total_channels": total_channels, + "providers": providers, + "cached_providers_list": cached_providers, + "user_mapping_dir": self.user_vfs.base_path, } def list_providers_with_mappings(self) -> List[str]: @@ -402,4 +428,4 @@ class EPGMapping: List of provider names """ user_files = self.user_vfs.list_files(pattern=self.MAPPING_FILE_PATTERN) - return [self._extract_provider_from_filename(f) for f in user_files] \ No newline at end of file + return [self._extract_provider_from_filename(f) for f in user_files] diff --git a/lib/streaming_providers/base/epg/epg_parser.py b/lib/streaming_providers/base/epg/epg_parser.py index af11475..39931cd 100644 --- a/lib/streaming_providers/base/epg/epg_parser.py +++ b/lib/streaming_providers/base/epg/epg_parser.py @@ -5,12 +5,13 @@ XMLTV EPG Parser for Kodi PVR Backend Parses XMLTV format and converts to Kodi PVR EPG format """ -import xml.etree.ElementTree as ET import gzip +import xml.etree.ElementTree as ET from datetime import datetime -from typing import List, Optional, Tuple, Any, Dict -from ..models.epg_models import EPGEntry +from typing import Any, Dict, List, Optional, Tuple + from ..models import epg_models +from ..models.epg_models import EPGEntry from ..utils.logger import logger # Import constants with proper naming @@ -44,11 +45,14 @@ class EPGParser: Provider hash (16-bit) """ import hashlib - provider_hash_obj = hashlib.sha256(provider_name.encode('utf-8')) + + provider_hash_obj = hashlib.sha256(provider_name.encode("utf-8")) 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 @@ -99,17 +103,18 @@ class EPGParser: dt_str = parts[0] # Parse basic timestamp - dt = datetime.strptime(dt_str, '%Y%m%d%H%M%S') + dt = datetime.strptime(dt_str, "%Y%m%d%H%M%S") # Handle timezone offset if present if len(parts) > 1: tz_str = parts[1] - if tz_str.startswith(('+', '-')): - sign = 1 if tz_str[0] == '+' else -1 + if tz_str.startswith(("+", "-")): + sign = 1 if tz_str[0] == "+" else -1 hours = int(tz_str[1:3]) minutes = int(tz_str[3:5]) if len(tz_str) > 3 else 0 # Adjust for timezone (subtract offset to get UTC) from datetime import timedelta + dt = dt - timedelta(hours=sign * hours, minutes=sign * minutes) return int(dt.timestamp()) @@ -120,9 +125,7 @@ class EPGParser: @staticmethod def generate_broadcast_id( - channel_id: str, - start_time: int, - provider_name: Optional[str] = None + channel_id: str, start_time: int, provider_name: Optional[str] = None ) -> int: """ Generate deterministic broadcast ID from channel and start time. @@ -144,13 +147,16 @@ class EPGParser: else: # Legacy method: simple hash (for backward compatibility) import hashlib - hash_input = f"{channel_id}_{start_time}".encode('utf-8') + + hash_input = f"{channel_id}_{start_time}".encode("utf-8") hash_digest = hashlib.sha256(hash_input).hexdigest() # Convert first 8 hex chars to int (32-bit) return int(hash_digest[:8], 16) @staticmethod - def parse_episode_num(episode_elem: ET.Element) -> Tuple[Optional[int], Optional[int], Optional[int]]: + def parse_episode_num( + episode_elem: ET.Element, + ) -> Tuple[Optional[int], Optional[int], Optional[int]]: """ Parse episode numbering from XMLTV episode-num element. @@ -164,52 +170,68 @@ class EPGParser: Returns: Tuple of (series_number, episode_number, episode_part_number) """ - system = episode_elem.get('system', '') - text = episode_elem.text or '' + system = episode_elem.get("system", "") + text = episode_elem.text or "" - if system == 'xmltv_ns': + if system == "xmltv_ns": # Format: "season.episode.part/total" but may have spaces like "11 . 6 . " # Clean up spaces around dots text = text.strip() # Replace spaces around dots and remove multiple spaces import re - text = re.sub(r'\s*\.\s*', '.', text) + + text = re.sub(r"\s*\.\s*", ".", text) try: # Remove trailing slash if present without numerator (e.g., "0.5.") - if text.endswith('.'): + if text.endswith("."): text = text[:-1] - parts = text.split('.') + parts = text.split(".") 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 + 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 + ) # Handle episode number - 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 + 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 + ) # 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_str = parts[2].split("/")[0] + 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': + elif system == "onscreen": # Format: "S05E12" or "5x12" try: import re + # Try S##E## format - match = re.match(r'S(\d+)E(\d+)', text, re.IGNORECASE) + match = re.match(r"S(\d+)E(\d+)", text, re.IGNORECASE) if match: return int(match.group(1)), int(match.group(2)), None # Try #x## format - match = re.match(r'(\d+)x(\d+)', text) + match = re.match(r"(\d+)x(\d+)", text) if match: return int(match.group(1)), int(match.group(2)), None except (ValueError, AttributeError) as e: @@ -228,13 +250,13 @@ class EPGParser: Returns: Rating as integer 0-10, or None """ - value_elem = rating_elem.find('value') + value_elem = rating_elem.find("value") if value_elem is not None and value_elem.text: try: # Format can be "8/10" or "4/5" or just "8" text = value_elem.text.strip() - if '/' in text: - parts = text.split('/') + if "/" in text: + parts = text.split("/") numerator = float(parts[0]) denominator = float(parts[1]) # Normalize to 0-10 scale @@ -259,41 +281,55 @@ class EPGParser: Returns: Numeric genre type from EPGGenre (DVB-SI ETSI EN 300 468) """ - if 'movie' in genre_str or 'film' in genre_str or 'drama' in genre_str: + if "movie" in genre_str or "film" in genre_str or "drama" in genre_str: return EPGGenre.MOVIEDRAMA - elif 'news' in genre_str: + elif "news" in genre_str: return EPGGenre.NEWSCURRENTAFFAIRS - elif 'show' in genre_str or 'series' in genre_str or 'serie' in genre_str: + elif "show" in genre_str or "series" in genre_str or "serie" in genre_str: return EPGGenre.SHOW - elif 'sports' in genre_str or 'sport' in genre_str: + elif "sports" in genre_str or "sport" in genre_str: return EPGGenre.SPORTS - elif 'children' in genre_str or 'kids' in genre_str or 'cartoon' in genre_str or 'youth' in genre_str: + elif ( + "children" in genre_str + or "kids" in genre_str + or "cartoon" in genre_str + or "youth" in genre_str + ): return EPGGenre.CHILDRENYOUTH - elif 'documentary' in genre_str or 'dokumentation' in genre_str: + elif "documentary" in genre_str or "dokumentation" in genre_str: # Documentary can be either News/Current Affairs or Educational/Science # We'll use News/Current Affairs as it's more common return EPGGenre.NEWSCURRENTAFFAIRS - elif 'music' in genre_str or 'ballet' in genre_str or 'dance' in genre_str: + elif "music" in genre_str or "ballet" in genre_str or "dance" in genre_str: return EPGGenre.MUSICBALLETDANCE - elif 'arts' in genre_str or 'culture' in genre_str or 'art' in genre_str: + 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 or 'political' in genre_str or 'politics' in genre_str or 'economic' in genre_str: + elif ( + "social" in genre_str + or "political" in genre_str + or "politics" in genre_str + or "economic" in genre_str + ): return EPGGenre.SOCIALPOLITICALECONOMICS - elif 'leisure' in genre_str or 'hobbies' in genre_str or 'hobby' in genre_str: + elif "leisure" in genre_str or "hobbies" in genre_str or "hobby" in genre_str: return EPGGenre.LEISUREHOBBIES - elif 'comedy' in genre_str: + elif "comedy" in genre_str: # Comedy is a subtype of Movie/Drama return EPGGenre.MOVIEDRAMA else: return EPGGenre.UNDEFINED def parse_programme( - self, - programme_elem: ET.Element, - epg_channel_id: str, - provider_name: Optional[str] = None + self, + programme_elem: ET.Element, + epg_channel_id: str, + provider_name: Optional[str] = None, ) -> Optional[EPGEntry]: """ Parse a single XMLTV programme element to EPGEntry object. @@ -307,153 +343,158 @@ class EPGParser: EPGEntry object, or None if required fields missing or validation fails """ # Verify channel matches - channel = programme_elem.get('channel', '') + channel = programme_elem.get("channel", "") if channel != epg_channel_id: return None # Parse required fields - start_str = programme_elem.get('start', '') - stop_str = programme_elem.get('stop', '') + start_str = programme_elem.get("start", "") + stop_str = programme_elem.get("stop", "") start_time = EPGParser.parse_xmltv_time(start_str) end_time = EPGParser.parse_xmltv_time(stop_str) if not start_time or not end_time: - logger.debug(f"Programme missing valid start/end time: start={start_str}, stop={stop_str}") + logger.debug( + f"Programme missing valid start/end time: start={start_str}, stop={stop_str}" + ) return None # 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_elem = programme_elem.find("title") + 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 + epg_channel_id, start_time, provider_name ) # Build EPGEntry with required fields entry_kwargs: Dict[str, Any] = { - 'broadcast_id': broadcast_id, - 'title': title.strip(), - 'start': start_time, - 'end': end_time, + "broadcast_id": broadcast_id, + "title": title.strip(), + "start": start_time, + "end": end_time, } # Parse optional fields # Sub-title (episode name) - subtitle_elem = programme_elem.find('sub-title') + subtitle_elem = programme_elem.find("sub-title") if subtitle_elem is not None and subtitle_elem.text: - entry_kwargs['episode_name'] = subtitle_elem.text.strip() + entry_kwargs["episode_name"] = subtitle_elem.text.strip() # Description (plot) - desc_elem = programme_elem.find('desc') + desc_elem = programme_elem.find("desc") if desc_elem is not None and desc_elem.text: plot = desc_elem.text.strip() - entry_kwargs['description'] = plot + entry_kwargs["description"] = plot # Use first sentence or first 100 chars as plot_outline - outline = plot.split('.')[0] if '.' in plot else plot[:100] - entry_kwargs['plot_outline'] = outline.strip() + outline = plot.split(".")[0] if "." in plot else plot[:100] + entry_kwargs["plot_outline"] = outline.strip() # Credits - credits_elem = programme_elem.find('credits') + credits_elem = programme_elem.find("credits") if credits_elem is not None: # Director - director_elem = credits_elem.find('director') + director_elem = credits_elem.find("director") if director_elem is not None and director_elem.text: - entry_kwargs['directors'] = [director_elem.text.strip()] + entry_kwargs["directors"] = [director_elem.text.strip()] # Actors - actors = credits_elem.findall('actor') + actors = credits_elem.findall("actor") if actors: cast_list = [actor.text.strip() for actor in actors if actor.text] if cast_list: - entry_kwargs['cast'] = cast_list + entry_kwargs["cast"] = cast_list # Writer - writer_elem = credits_elem.find('writer') + writer_elem = credits_elem.find("writer") if writer_elem is not None and writer_elem.text: - entry_kwargs['writers'] = [writer_elem.text.strip()] + entry_kwargs["writers"] = [writer_elem.text.strip()] # Year/Date - date_elem = programme_elem.find('date') + date_elem = programme_elem.find("date") if date_elem is not None and date_elem.text: try: year = int(date_elem.text.strip()[:4]) - entry_kwargs['year'] = year + entry_kwargs["year"] = year except ValueError: pass # Category (genre) - category_elem = programme_elem.find('category') + category_elem = programme_elem.find("category") if category_elem is not None and category_elem.text: genre_str = category_elem.text.strip().lower() - entry_kwargs['genre'] = EPGParser._map_genre_to_type(genre_str) + entry_kwargs["genre"] = EPGParser._map_genre_to_type(genre_str) # Icon - icon_elem = programme_elem.find('icon') + icon_elem = programme_elem.find("icon") if icon_elem is not None: - icon_src = icon_elem.get('src', '') + icon_src = icon_elem.get("src", "") if icon_src: - entry_kwargs['icon'] = icon_src + entry_kwargs["icon"] = icon_src # Episode numbering - episode_nums = programme_elem.findall('episode-num') + episode_nums = programme_elem.findall("episode-num") series_num = episode_num = part_num = None # Try xmltv_ns first for ep_elem in episode_nums: - if ep_elem.get('system') == 'xmltv_ns': + if ep_elem.get("system") == "xmltv_ns": series_num, episode_num, part_num = EPGParser.parse_episode_num(ep_elem) break # Fall back to onscreen if xmltv_ns didn't work 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) + if ep_elem.get("system") == "onscreen": + series_num, episode_num, part_num = EPGParser.parse_episode_num( + ep_elem + ) break if series_num is not None: - entry_kwargs['season_number'] = series_num + entry_kwargs["season_number"] = series_num if episode_num is not None: - entry_kwargs['episode_number'] = episode_num + entry_kwargs["episode_number"] = episode_num if part_num is not None: - entry_kwargs['episode_part_number'] = part_num + entry_kwargs["episode_part_number"] = part_num # Star rating - star_rating_elem = programme_elem.find('star-rating') + star_rating_elem = programme_elem.find("star-rating") if star_rating_elem is not None: rating = EPGParser.parse_star_rating(star_rating_elem) if rating is not None: - entry_kwargs['star_rating'] = rating + entry_kwargs["star_rating"] = rating # Parental rating - rating_elems = programme_elem.findall('rating') + rating_elems = programme_elem.findall("rating") for rating_elem in rating_elems: - value_elem = rating_elem.find('value') + value_elem = rating_elem.find("value") if value_elem is not None and value_elem.text: try: # Try to extract numeric rating import re - match = re.search(r'\d+', value_elem.text) + + match = re.search(r"\d+", value_elem.text) if match: - entry_kwargs['parental_rating'] = int(match.group()) + entry_kwargs["parental_rating"] = int(match.group()) break except (ValueError, AttributeError): pass # Previously shown (first aired) - prev_shown_elem = programme_elem.find('previously-shown') + prev_shown_elem = programme_elem.find("previously-shown") if prev_shown_elem is not None: - first_aired_str = prev_shown_elem.get('start', '') + first_aired_str = prev_shown_elem.get("start", "") if first_aired_str: first_aired = EPGParser.parse_xmltv_time(first_aired_str) if first_aired: - entry_kwargs['first_aired'] = first_aired + entry_kwargs["first_aired"] = first_aired # Create EPGEntry object with validation try: @@ -473,20 +514,20 @@ class EPGParser: Returns: File object suitable for ET.iterparse """ - if file_path.endswith('.gz'): + if file_path.endswith(".gz"): logger.debug(f"Opening gzipped EPG file: {file_path}") - return gzip.open(file_path, 'rt', encoding='utf-8') + return gzip.open(file_path, "rt", encoding="utf-8") else: logger.debug(f"Opening plain EPG file: {file_path}") - return open(file_path, 'r', encoding='utf-8') + return open(file_path, "r", encoding="utf-8") def parse_epg_for_channel( - self, - xml_path: str, - epg_channel_id: str, - start_time: Optional[int] = None, - end_time: Optional[int] = None, - provider_name: Optional[str] = None + self, + xml_path: str, + epg_channel_id: str, + start_time: Optional[int] = None, + end_time: Optional[int] = None, + provider_name: Optional[str] = None, ) -> List[EPGEntry]: """ Parse EPG data for a specific channel within a time range. @@ -505,37 +546,41 @@ class EPGParser: logger.info(f"Parsing EPG for channel '{epg_channel_id}' from {xml_path}") # Register provider once at the beginning (if provided) - if provider_name and hasattr(self, '_provider_registry'): + if provider_name and hasattr(self, "_provider_registry"): # Check if already registered import hashlib - hash_obj = hashlib.sha256(provider_name.encode('utf-8')) + + hash_obj = hashlib.sha256(provider_name.encode("utf-8")) provider_hash = int(hash_obj.hexdigest()[:4], 16) 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] = [] try: with EPGParser.open_xml_file(xml_path) as xml_file: # Use iterparse for memory-efficient streaming - context = ET.iterparse(xml_file, events=('end',)) + context = ET.iterparse(xml_file, events=("end",)) for event, elem in context: - if elem.tag == 'programme': + if elem.tag == "programme": # Check if this programme matches our channel - if elem.get('channel') == epg_channel_id: + 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 + 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 @@ -551,7 +596,9 @@ 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: @@ -559,4 +606,4 @@ class EPGParser: return [] except Exception as e: logger.error(f"Error parsing EPG file: {e}") - return [] \ No newline at end of file + return [] diff --git a/lib/streaming_providers/base/epg_operations.py b/lib/streaming_providers/base/epg_operations.py index b2805d8..f413574 100644 --- a/lib/streaming_providers/base/epg_operations.py +++ b/lib/streaming_providers/base/epg_operations.py @@ -3,7 +3,9 @@ """ EPG-related operations. """ -from typing import List, Dict, Optional + +from typing import Dict, List, Optional + from .epg import EPGManager from .utils.logger import logger @@ -16,8 +18,9 @@ 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: @@ -31,8 +34,8 @@ class EPGOperations: epg_data = self.epg_manager.get_epg( provider_name=provider_name, channel_id=channel_id, - start_time=kwargs.get('start_time'), - end_time=kwargs.get('end_time') + start_time=kwargs.get("start_time"), + end_time=kwargs.get("end_time"), ) logger.debug(f"Retrieved {len(epg_data)} EPG entries for '{channel_id}'") @@ -69,4 +72,3 @@ class EPGOperations: def has_epg_mapping(self, provider_name: str, channel_id: str) -> bool: """Check if EPG mapping exists.""" return self.epg_manager.has_mapping_for_channel(provider_name, channel_id) - diff --git a/lib/streaming_providers/base/manager.py b/lib/streaming_providers/base/manager.py index ac84e30..786400d 100644 --- a/lib/streaming_providers/base/manager.py +++ b/lib/streaming_providers/base/manager.py @@ -3,14 +3,16 @@ Main ProviderManager as a facade coordinating all operations. Maintains backward compatibility while delegating to specialized classes. """ -from typing import Dict, List, Optional, Any -from .provider_registry import ProviderRegistry -from .channel_operations import ChannelOperations -from .epg_operations import EPGOperations -from .drm_operations import DRMOperations + +from typing import Any, Dict, List, Optional + from .catchup_operations import CatchupOperations -from .subscription_operations import SubscriptionOperations +from .channel_operations import ChannelOperations +from .drm_operations import DRMOperations +from .epg_operations import EPGOperations from .models import StreamingChannel +from .provider_registry import ProviderRegistry +from .subscription_operations import SubscriptionOperations from .utils.logger import logger @@ -45,11 +47,12 @@ class ProviderManager: # REGISTRY OPERATIONS (delegate to ProviderRegistry) # ========================================================================== - def discover_all_providers(self, default_country: str = 'DE') -> List[str]: + 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) @@ -68,8 +71,9 @@ 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() @@ -91,8 +95,8 @@ class ProviderManager: # Remove country suffix if present base_name = provider_name - if '_' in provider_name: - name_parts = provider_name.rsplit('_', 1) + if "_" in provider_name: + name_parts = provider_name.rsplit("_", 1) if len(name_parts[1]) in (2, 3) and name_parts[1].isalpha(): base_name = name_parts[0] @@ -102,24 +106,30 @@ class ProviderManager: # CHANNEL OPERATIONS (delegate to ChannelOperations) # ========================================================================== - def get_channels(self, provider_name: str, fetch_manifests: bool = False, - **kwargs) -> List[StreamingChannel]: + def get_channels( + self, provider_name: str, fetch_manifests: bool = False, **kwargs + ) -> 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) -> Dict[str, List[StreamingChannel]]: + def get_all_channels( + self, fetch_manifests: bool = True, **kwargs + ) -> Dict[str, List[StreamingChannel]]: return self.channel_ops.get_all_channels(fetch_manifests, **kwargs) # ========================================================================== # 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]: @@ -144,8 +154,9 @@ 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: @@ -158,24 +169,35 @@ class ProviderManager: # CATCHUP OPERATIONS (delegate to CatchupOperations) # ========================================================================== - def get_catchup_manifest(self, provider_name: str, channel_id: str, - start_time: int, end_time: int, - epg_id: Optional[str] = None, - country: Optional[str] = None) -> Optional[str]: + def get_catchup_manifest( + self, + provider_name: str, + channel_id: str, + start_time: int, + end_time: int, + epg_id: Optional[str] = None, + country: Optional[str] = None, + ) -> Optional[str]: return self.catchup_ops.get_catchup_manifest( provider_name, channel_id, start_time, end_time, epg_id, country ) - def get_catchup_drm_configs(self, provider_name: str, channel_id: str, - start_time: int, end_time: int, - epg_id: Optional[str] = None, - country: Optional[str] = None) -> List: + def get_catchup_drm_configs( + self, + provider_name: str, + channel_id: str, + start_time: int, + end_time: int, + epg_id: Optional[str] = None, + country: Optional[str] = None, + ) -> List: return self.catchup_ops.get_catchup_drm_configs( 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: @@ -215,7 +237,9 @@ 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}'") @@ -229,7 +253,8 @@ class ProviderManager: has_proxy = http_manager.config.proxy_config is not None logger.debug( - f"ProviderManager: Provider '{provider_name}' {'requires' if has_proxy else 'does not require'} proxy") + f"ProviderManager: Provider '{provider_name}' {'requires' if has_proxy else 'does not require'} proxy" + ) return has_proxy def get_provider_choices(self) -> Dict[int, str]: @@ -239,7 +264,7 @@ class ProviderManager: """ enabled_providers = self.registry.get_enabled_providers() choices = {i + 1: name for i, name in enumerate(enabled_providers)} - choices[len(choices) + 1] = 'all' + choices[len(choices) + 1] = "all" return choices def get_selected_providers(self, choices_input: str) -> List[str]: @@ -255,11 +280,13 @@ 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 = [] - for choice in choices_input.split(','): + for choice in choices_input.split(","): choice = choice.strip() if choice.isdigit(): idx = int(choice) - 1 @@ -270,7 +297,9 @@ class ProviderManager: break result = selected if selected else available - logger.debug(f"ProviderManager: Selected {len(result)} providers from input '{choices_input}'") + logger.debug( + f"ProviderManager: Selected {len(result)} providers from input '{choices_input}'" + ) return result # ========================================================================== @@ -282,4 +311,4 @@ class ProviderManager: Extract PSSH data from a manifest URL. Kept for backward compatibility but delegated to DRMOperations. """ - return self.drm_ops._extract_pssh_from_manifest(manifest_url) \ No newline at end of file + return self.drm_ops._extract_pssh_from_manifest(manifest_url) diff --git a/lib/streaming_providers/base/models/__init__.py b/lib/streaming_providers/base/models/__init__.py index 21c5c12..9f8faa2 100644 --- a/lib/streaming_providers/base/models/__init__.py +++ b/lib/streaming_providers/base/models/__init__.py @@ -1,7 +1,15 @@ # streaming_providers/base/models/__init__.py +from .drm_models import (DRMConfig, DRMSystem, LicenseConfig, + LicenseUnwrapperParams) from .streaming_channel import StreamingChannel -from .drm_models import DRMConfig, LicenseConfig, DRMSystem, LicenseUnwrapperParams from .subscription import SubscriptionPackage, UserSubscription - -__all__ = ['StreamingChannel', 'DRMConfig', 'LicenseConfig', 'LicenseUnwrapperParams', 'DRMSystem', 'SubscriptionPackage', 'UserSubscription'] +__all__ = [ + "StreamingChannel", + "DRMConfig", + "LicenseConfig", + "LicenseUnwrapperParams", + "DRMSystem", + "SubscriptionPackage", + "UserSubscription", +] diff --git a/lib/streaming_providers/base/models/auth.py b/lib/streaming_providers/base/models/auth.py index 91f1688..d94bcda 100644 --- a/lib/streaming_providers/base/models/auth.py +++ b/lib/streaming_providers/base/models/auth.py @@ -1,11 +1,12 @@ # streaming_providers/base/models/auth.py -from typing import Optional, Dict, Any -from enum import Enum from dataclasses import dataclass +from enum import Enum +from typing import Any, Dict, Optional class AuthState(Enum): """Authentication state""" + NOT_APPLICABLE = "not_applicable" NOT_AUTHENTICATED = "not_authenticated" AUTHENTICATED = "authenticated" @@ -15,6 +16,7 @@ class AuthState(Enum): @dataclass class TokenInfo: """Information about a specific token scope""" + scope: str has_token: bool is_valid: bool @@ -26,6 +28,7 @@ class TokenInfo: @dataclass class AuthStatus: """Complete authentication status for a provider""" + provider_name: str provider_label: str country: Optional[str] @@ -53,7 +56,11 @@ class AuthStatus: import time result = { - "provider": f"{self.provider_name}_{self.country}" if self.country else self.provider_name, + "provider": ( + f"{self.provider_name}_{self.country}" + if self.country + else self.provider_name + ), "provider_name": self.provider_name, "provider_label": self.provider_label, "country": self.country, @@ -70,7 +77,7 @@ class AuthStatus: "is_valid": info.is_valid, "expires_at": info.expires_at, "has_refresh_token": info.has_refresh_token, - "auth_level": info.auth_level + "auth_level": info.auth_level, } for scope, info in self.token_scopes.items() }, @@ -80,7 +87,7 @@ class AuthStatus: "timestamp": time.time(), "last_authentication": self.last_authentication, "provider_specific": self.provider_specific, - "auth_state_description": self._get_auth_state_description() + "auth_state_description": self._get_auth_state_description(), } # Add token expiration info if available @@ -94,7 +101,9 @@ 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 @@ -107,4 +116,4 @@ class AuthStatus: elif self.auth_state == AuthState.EXPIRED: return "Authentication expired, refresh available" else: - return "Not authenticated" \ No newline at end of file + return "Not authenticated" diff --git a/lib/streaming_providers/base/models/drm_models.py b/lib/streaming_providers/base/models/drm_models.py index 34af8dd..4ebe535 100644 --- a/lib/streaming_providers/base/models/drm_models.py +++ b/lib/streaming_providers/base/models/drm_models.py @@ -1,8 +1,9 @@ # streaming_providers/base/models/drm_models.py -from dataclasses import dataclass, field -from typing import Dict, Optional, List import base64 +from dataclasses import dataclass, field from enum import Enum +from typing import Dict, List, Optional + class DRMSystem(str, Enum): WIDEVINE = "com.widevine.alpha" @@ -21,27 +22,29 @@ class DRMSystem(str, Enum): self.CLEARKEY: "e2719d58-a985-b3c9-781a-b030af78d30e", self.WISEPLAY: "3d5e6d35-9b9a-41e8-b843-dd3c6e72c42c", self.FAIRPLAY: "94ce86fb-07ff-4f43-adb8-93d2fa968ca2", - self.GENERIC: "" # No UUID for generic plugins + self.GENERIC: "", # No UUID for generic plugins } return uuid_mapping.get(self, "") @classmethod - def from_uuid(cls, uuid: str) -> Optional['DRMSystem']: + def from_uuid(cls, uuid: str) -> Optional["DRMSystem"]: """Get DRM system from UUID""" uuid_lower = uuid.lower().replace("-", "") uuid_mapping = { "edef8ba979d64acea3c827dcd51d21ed": cls.WIDEVINE, "9a04f07998404286ab92e65be0885f95": cls.PLAYREADY, "e2719d58a985b3c9781ab030af78d30e": cls.CLEARKEY, - "3d5e6d359b9a41e8b843dd3c6e72c42c": cls.WISEPLAY + "3d5e6d359b9a41e8b843dd3c6e72c42c": cls.WISEPLAY, } return uuid_mapping.get(uuid_lower) + class WrapperType(str, Enum): BASE64 = "base64" URLENC = "urlenc" NONE = "none" + class UnwrapperType(str, Enum): AUTO = "auto" BASE64 = "base64" @@ -49,38 +52,41 @@ class UnwrapperType(str, Enum): XML = "xml" NONE = "none" + @dataclass class PSSHData: """ Protection System Specific Header data for DRM systems """ + system_id: str # UUID of the DRM system - pssh_box: str # Base64 encoded PSSH box data + pssh_box: str # Base64 encoded PSSH box data key_ids: List[str] = field(default_factory=list) # Optional key IDs - + @property def drm_system(self) -> Optional[DRMSystem]: """Get the corresponding DRM system for this PSSH""" return DRMSystem.from_uuid(self.system_id) - + def validate(self): """Validate the PSSH data""" 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") - + # Validate key IDs if present for kid in self.key_ids: if not all(c in "0123456789abcdefABCDEF-" for c in kid): raise ValueError(f"Invalid key ID format: {kid}") + @dataclass class LicenseUnwrapperParams: path_data: Optional[str] = None @@ -90,6 +96,7 @@ class LicenseUnwrapperParams: path_hdcp_ver: Optional[str] = None path_hdcp_ver_traverse: bool = False + @dataclass class LicenseConfig: server_url: Optional[str] = None @@ -128,9 +135,13 @@ class LicenseConfig: def create_with_base64_req_data(cls, req_data_template: str, **kwargs): """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) + @dataclass class DRMConfig: system: DRMSystem @@ -170,7 +181,8 @@ 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() + k: v + for k, v in vars(self.license.unwrapper_params).items() if v is not None } if self.license.keyids: diff --git a/lib/streaming_providers/base/models/epg_models.py b/lib/streaming_providers/base/models/epg_models.py index bf4cfc8..f7c5096 100644 --- a/lib/streaming_providers/base/models/epg_models.py +++ b/lib/streaming_providers/base/models/epg_models.py @@ -6,9 +6,9 @@ Based on Kodi PVR EPG Tag specification (ETSI EN 300 468 DVB-SI standard) """ from dataclasses import dataclass, field -from typing import Optional, List from datetime import datetime from enum import IntEnum +from typing import List, Optional # EPG Constants from Kodi PVR specification EPG_TAG_INVALID_UID = 0 @@ -29,6 +29,7 @@ class EPGEventState(IntEnum): EPG event states for event lifecycle callbacks. Used with EpgEventStateChange() callback in C++ PVR client. """ + CREATED = 0 # Event created UPDATED = 1 # Event updated DELETED = 2 # Event deleted @@ -189,20 +190,35 @@ class EPGEntry: Dictionary with EPG data """ result = { - 'broadcast_id': self.broadcast_id, - 'title': self.title, - 'start': self.start, - 'end': self.end, + "broadcast_id": self.broadcast_id, + "title": self.title, + "start": self.start, + "end": self.end, } # Add optional fields only if they have values optional_fields = [ - 'description', 'plot_outline', 'episode_name', 'original_title', - 'year', 'icon', 'cast', 'directors', 'writers', 'genre', - 'genre_description', 'season_number', 'episode_number', - 'episode_part_number', 'star_rating', 'parental_rating', - 'parental_rating_code', 'first_aired', 'imdb_number', - 'series_link', 'flags' + "description", + "plot_outline", + "episode_name", + "original_title", + "year", + "icon", + "cast", + "directors", + "writers", + "genre", + "genre_description", + "season_number", + "episode_number", + "episode_part_number", + "star_rating", + "parental_rating", + "parental_rating_code", + "first_aired", + "imdb_number", + "series_link", + "flags", ] for field_name in optional_fields: @@ -213,7 +229,7 @@ class EPGEntry: return result @classmethod - def from_dict(cls, data: dict) -> 'EPGEntry': + def from_dict(cls, data: dict) -> "EPGEntry": """ Create EPGEntry from dictionary (e.g., from epg_parser output). @@ -228,10 +244,7 @@ class EPGEntry: # Extract only fields that exist in EPGEntry valid_fields = {f.name for f in fields(cls)} - filtered_data = { - k: v for k, v in data.items() - if k in valid_fields - } + filtered_data = {k: v for k, v in data.items() if k in valid_fields} return cls(**filtered_data) @@ -387,13 +400,15 @@ 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 + provider_name: str, channel_id: str, start_time: int ) -> int: """ Generate deterministic broadcast ID with encoded provider information. @@ -422,11 +437,11 @@ class EPGEntry: import hashlib # Generate provider hash (16 bits) - provider_hash_obj = hashlib.sha256(provider_name.encode('utf-8')) + provider_hash_obj = hashlib.sha256(provider_name.encode("utf-8")) provider_hash = int(provider_hash_obj.hexdigest()[:4], 16) # 16 bits # Generate event hash from channel + start time (16 bits) - event_input = f"{channel_id}_{start_time}".encode('utf-8') + event_input = f"{channel_id}_{start_time}".encode("utf-8") event_hash_obj = hashlib.sha256(event_input) event_hash = int(event_hash_obj.hexdigest()[:4], 16) # 16 bits @@ -489,7 +504,7 @@ class EPGEntry: stored_hash = EPGEntry.get_provider_hash(broadcast_id) # Calculate hash for given provider name - provider_hash_obj = hashlib.sha256(provider_name.encode('utf-8')) + provider_hash_obj = hashlib.sha256(provider_name.encode("utf-8")) calculated_hash = int(provider_hash_obj.hexdigest()[:4], 16) return stored_hash == calculated_hash @@ -511,17 +526,26 @@ 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})" ) - if self.episode_part_number is not None and self.episode_part_number < EPG_TAG_INVALID_SERIES_EPISODE: + if ( + self.episode_part_number is not None + and self.episode_part_number < EPG_TAG_INVALID_SERIES_EPISODE + ): raise ValueError( f"episode_part_number must be >= EPG_TAG_INVALID_SERIES_EPISODE ({EPG_TAG_INVALID_SERIES_EPISODE})" ) @@ -542,6 +566,7 @@ class EPGFlags: flags=EPGFlags.IS_SERIES | EPGFlags.IS_PREMIERE ) """ + UNDEFINED = 0x00 # 0000 0000 : Nothing special to say about this entry IS_SERIES = 0x01 # 0000 0001 : This EPG entry is part of a series IS_NEW = 0x02 # 0000 0010 : This EPG entry will be flagged as new @@ -629,6 +654,7 @@ class EPGGenre: EPG genre type codes based on DVB-SI standard (ETSI EN 300 468). These are the main content masks - use with genre_type field. """ + # Main genre types (content masks) UNDEFINED = 0x00 MOVIEDRAMA = 0x10 @@ -786,18 +812,16 @@ PVREPGTag = EPGEntry # Export all public symbols __all__ = [ # Main classes - 'EPGEntry', - 'PVREPGTag', # Legacy alias - + "EPGEntry", + "PVREPGTag", # Legacy alias # Constants - 'EPG_TAG_INVALID_UID', - 'EPG_TAG_INVALID_SERIES_EPISODE', - 'EPG_TIMEFRAME_UNLIMITED', - 'EPG_STRING_TOKEN_SEPARATOR', - + "EPG_TAG_INVALID_UID", + "EPG_TAG_INVALID_SERIES_EPISODE", + "EPG_TIMEFRAME_UNLIMITED", + "EPG_STRING_TOKEN_SEPARATOR", # Enums and flags - 'EPGEventState', - 'EPGFlags', - 'EPGGenre', - 'EPGGenreSubtype', -] \ No newline at end of file + "EPGEventState", + "EPGFlags", + "EPGGenre", + "EPGGenreSubtype", +] diff --git a/lib/streaming_providers/base/models/proxy_models.py b/lib/streaming_providers/base/models/proxy_models.py index 1a25d21..e67bec6 100644 --- a/lib/streaming_providers/base/models/proxy_models.py +++ b/lib/streaming_providers/base/models/proxy_models.py @@ -1,11 +1,12 @@ # streaming_providers/base/models/proxy_models.py from dataclasses import dataclass, field -from typing import Dict, Optional, Any from enum import Enum +from typing import Any, Dict, Optional class ProxyType(Enum): """Supported proxy types""" + HTTP = "http" HTTPS = "https" SOCKS4 = "socks4" @@ -18,6 +19,7 @@ class ProxyScope: Define which network operations should use proxy Allows granular control per provider """ + api_calls: bool = True # GraphQL, REST API calls authentication: bool = True # Auth endpoints manifests: bool = True # Manifest/playlist downloads @@ -30,10 +32,10 @@ class ProxyScope: return False operation_map = { - 'api': self.api_calls, - 'auth': self.authentication, - 'manifest': self.manifests, - 'license': self.license + "api": self.api_calls, + "auth": self.authentication, + "manifest": self.manifests, + "license": self.license, } return operation_map.get(operation, True) @@ -42,6 +44,7 @@ class ProxyScope: @dataclass class ProxyAuth: """Proxy authentication details""" + username: str password: str @@ -56,6 +59,7 @@ class ProxyConfig: Comprehensive proxy configuration Supports different proxy types and authentication """ + # Basic proxy settings host: str port: int @@ -89,10 +93,7 @@ class ProxyConfig: proxy_url = f"{self.proxy_type.value}://{auth_part}{self.host}:{self.port}" # Return both HTTP and HTTPS proxy settings - return { - 'http': proxy_url, - 'https': proxy_url - } + return {"http": proxy_url, "https": proxy_url} def to_proxy_url(self) -> str: """Get single proxy URL string""" @@ -115,61 +116,62 @@ class ProxyConfig: def to_dict(self) -> Dict[str, Any]: """Convert to dictionary for serialization""" result = { - 'host': self.host, - 'port': self.port, - 'proxy_type': self.proxy_type.value, - 'timeout': self.timeout, - 'verify_ssl': self.verify_ssl, - 'scope': { - 'api_calls': self.scope.api_calls, - 'authentication': self.scope.authentication, - 'manifests': self.scope.manifests, - 'license': self.scope.license, - 'all': self.scope.all + "host": self.host, + "port": self.port, + "proxy_type": self.proxy_type.value, + "timeout": self.timeout, + "verify_ssl": self.verify_ssl, + "scope": { + "api_calls": self.scope.api_calls, + "authentication": self.scope.authentication, + "manifests": self.scope.manifests, + "license": self.scope.license, + "all": self.scope.all, }, - 'provider_specific': self.provider_specific + "provider_specific": self.provider_specific, } if self.auth: - result['auth'] = { - 'username': self.auth.username, - 'password': self.auth.password + result["auth"] = { + "username": self.auth.username, + "password": self.auth.password, } return result @classmethod - def from_dict(cls, data: Dict[str, Any]) -> 'ProxyConfig': + def from_dict(cls, data: Dict[str, Any]) -> "ProxyConfig": """Create ProxyConfig from dictionary""" auth = None - if 'auth' in data and data['auth']: + if "auth" in data and data["auth"]: auth = ProxyAuth( - username=data['auth']['username'], - password=data['auth']['password'] + username=data["auth"]["username"], password=data["auth"]["password"] ) - scope_data = data.get('scope', {}) + scope_data = data.get("scope", {}) scope = ProxyScope( - api_calls=scope_data.get('api_calls', True), - authentication=scope_data.get('authentication', True), - manifests=scope_data.get('manifests', True), - license=scope_data.get('license', True), - all=scope_data.get('all', True) + api_calls=scope_data.get("api_calls", True), + authentication=scope_data.get("authentication", True), + manifests=scope_data.get("manifests", True), + license=scope_data.get("license", True), + all=scope_data.get("all", True), ) return cls( - host=data['host'], - port=data['port'], - proxy_type=ProxyType(data.get('proxy_type', 'http')), + host=data["host"], + port=data["port"], + proxy_type=ProxyType(data.get("proxy_type", "http")), auth=auth, scope=scope, - timeout=data.get('timeout', 30), - verify_ssl=data.get('verify_ssl', True), - provider_specific=data.get('provider_specific', {}) + timeout=data.get("timeout", 30), + verify_ssl=data.get("verify_ssl", True), + provider_specific=data.get("provider_specific", {}), ) @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 @@ -189,10 +191,7 @@ class ProxyConfig: auth = None if parsed.username and parsed.password: - auth = ProxyAuth( - username=parsed.username, - password=parsed.password - ) + auth = ProxyAuth(username=parsed.username, password=parsed.password) proxy_type = ProxyType.HTTP if parsed.scheme: @@ -207,7 +206,7 @@ class ProxyConfig: port=parsed.port, proxy_type=proxy_type, auth=auth, - scope=scope or ProxyScope() + scope=scope or ProxyScope(), ) @@ -217,6 +216,7 @@ class RequestConfig: Configuration for HTTP requests including proxy settings Used by HTTPManager for consistent request handling """ + # Proxy settings proxy_config: Optional[ProxyConfig] = None @@ -228,7 +228,9 @@ class RequestConfig: # Headers default_headers: Dict[str, str] = field(default_factory=dict) - user_agent: str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36" + user_agent: str = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36" + ) # Provider-specific settings provider: str = "" @@ -244,37 +246,39 @@ class RequestConfig: Dictionary of kwargs for requests """ kwargs = { - 'timeout': self.timeout, - 'verify': self.verify_ssl, - 'headers': self._get_headers() + "timeout": self.timeout, + "verify": self.verify_ssl, + "headers": self._get_headers(), } # Add proxy if configured and enabled for this operation - if self.proxy_config and self.proxy_config.scope.should_use_proxy_for(operation): - kwargs['proxies'] = self.proxy_config.to_proxy_dict() + if self.proxy_config and self.proxy_config.scope.should_use_proxy_for( + operation + ): + kwargs["proxies"] = self.proxy_config.to_proxy_dict() return kwargs def _get_headers(self) -> Dict[str, str]: """Build headers with user agent""" headers = self.default_headers.copy() - if 'User-Agent' not in headers: - headers['User-Agent'] = self.user_agent + if "User-Agent" not in headers: + headers["User-Agent"] = self.user_agent return headers def to_dict(self) -> Dict[str, Any]: """Convert to dictionary""" result = { - 'timeout': self.timeout, - 'verify_ssl': self.verify_ssl, - 'max_retries': self.max_retries, - 'retry_delay': self.retry_delay, - 'default_headers': self.default_headers, - 'user_agent': self.user_agent, - 'provider': self.provider + "timeout": self.timeout, + "verify_ssl": self.verify_ssl, + "max_retries": self.max_retries, + "retry_delay": self.retry_delay, + "default_headers": self.default_headers, + "user_agent": self.user_agent, + "provider": self.provider, } if self.proxy_config: - result['proxy_config'] = self.proxy_config.to_dict() + result["proxy_config"] = self.proxy_config.to_dict() return result diff --git a/lib/streaming_providers/base/models/streaming_channel.py b/lib/streaming_providers/base/models/streaming_channel.py index 89202ab..2edb743 100644 --- a/lib/streaming_providers/base/models/streaming_channel.py +++ b/lib/streaming_providers/base/models/streaming_channel.py @@ -1,8 +1,9 @@ # streaming_providers/base/models.py +import logging from dataclasses import dataclass from enum import Enum -from typing import Dict, Optional, List -import logging +from typing import Dict, List, Optional + from .drm_models import DRMConfig # Setup logging for validation warnings @@ -11,12 +12,14 @@ logger = logging.getLogger(__name__) class StreamingMode(Enum): """Enum for streaming modes""" + LIVE = "live" VOD = "vod" class ContentType(Enum): """Enum for content types""" + LIVE = "LIVE" VOD = "VOD" SERIES = "SERIES" @@ -26,6 +29,7 @@ class ContentType(Enum): class Quality(Enum): """Enum for stream quality""" + SD = "SD" HD = "HD" UHD = "UHD" @@ -43,6 +47,7 @@ class StreamingChannel: - New fields have default values - All existing methods remain functional """ + # Core identification name: str channel_id: str @@ -65,22 +70,22 @@ class StreamingChannel: cdm_type: Optional[str] = None use_cdm: bool = True cdm: Optional[str] = None - cdm_mode: str = 'external' + cdm_mode: str = "external" # DRM Configuration drm_config: Optional[DRMConfig] = None # Video settings - video: str = 'best' + video: str = "best" on_demand: bool = True speed_up: bool = True # Additional metadata - content_type: str = 'LIVE' + content_type: str = "LIVE" description: Optional[str] = None genre: Optional[str] = None - language: str = 'de' - country: str = 'DE' + language: str = "de" + country: str = "DE" # Streaming URLs license_url: Optional[str] = None @@ -99,9 +104,9 @@ class StreamingChannel: self._validate_fields() # Auto-update content_type for radio - if self.is_radio and self.content_type == 'LIVE': - self.content_type = 'RADIO' - self.quality = 'AUDIO' + if self.is_radio and self.content_type == "LIVE": + self.content_type = "RADIO" + self.quality = "AUDIO" def _validate_fields(self): """ @@ -109,7 +114,7 @@ class StreamingChannel: for backward compatibility """ # Validate content_type consistency - if self.mode == 'vod' and self.content_type == 'LIVE': + if self.mode == "vod" and self.content_type == "LIVE": logger.warning( f"Channel {self.name} ({self.channel_id}): " f"VOD mode with LIVE content_type - consider changing to VOD" @@ -125,34 +130,34 @@ class StreamingChannel: def to_dict(self) -> Dict: """Convert to dictionary format - backward compatible with new fields added""" result = { - 'Name': self.name, - 'Id': self.channel_id, - 'Provider': self.provider, - 'LogoUrl': self.logo_url, - 'ChannelNumber': self.channel_number, - 'Quality': self.quality, - 'Mode': self.mode, - 'SessionManifest': self.session_manifest, - 'Manifest': self.manifest, - 'ManifestScript': self.manifest_script, - 'CdmType': self.cdm_type, - 'UseCdm': self.use_cdm, - 'Cdm': self.cdm, - 'CdmMode': self.cdm_mode, - 'Video': self.video, - 'OnDemand': self.on_demand, - 'SpeedUp': self.speed_up, - 'ContentType': self.content_type, - 'Country': self.country, - 'Language': self.language, - 'StreamingFormat': self.streaming_format, + "Name": self.name, + "Id": self.channel_id, + "Provider": self.provider, + "LogoUrl": self.logo_url, + "ChannelNumber": self.channel_number, + "Quality": self.quality, + "Mode": self.mode, + "SessionManifest": self.session_manifest, + "Manifest": self.manifest, + "ManifestScript": self.manifest_script, + "CdmType": self.cdm_type, + "UseCdm": self.use_cdm, + "Cdm": self.cdm, + "CdmMode": self.cdm_mode, + "Video": self.video, + "OnDemand": self.on_demand, + "SpeedUp": self.speed_up, + "ContentType": self.content_type, + "Country": self.country, + "Language": self.language, + "StreamingFormat": self.streaming_format, # NEW: Radio fields (backward compatible addition) - 'IsRadio': self.is_radio, + "IsRadio": self.is_radio, } # Add DRM config if present if self.drm_config: - result['DrmConfig'] = self.drm_config.to_dict() + result["DrmConfig"] = self.drm_config.to_dict() return result @@ -178,7 +183,9 @@ class StreamingChannel: # NEW: Backward compatible enhancements @classmethod - def create_live_channel(cls, name: str, channel_id: str, provider: str, **kwargs) -> 'StreamingChannel': + def create_live_channel( + cls, name: str, channel_id: str, provider: str, **kwargs + ) -> "StreamingChannel": """ Factory method for live channels with proper defaults Backward compatible: Existing code can continue using direct instantiation @@ -189,11 +196,13 @@ class StreamingChannel: provider=provider, mode="live", content_type="LIVE", - **kwargs + **kwargs, ) @classmethod - def create_vod_channel(cls, name: str, content_id: str, provider: str, **kwargs) -> 'StreamingChannel': + def create_vod_channel( + cls, name: str, content_id: str, provider: str, **kwargs + ) -> "StreamingChannel": """ Factory method for VOD content Backward compatible: Existing code can continue using direct instantiation @@ -204,11 +213,13 @@ class StreamingChannel: provider=provider, mode="vod", content_type="VOD", - **kwargs + **kwargs, ) @classmethod - def create_radio_channel(cls, name: str, channel_id: str, provider: str, **kwargs) -> 'StreamingChannel': + def create_radio_channel( + cls, name: str, channel_id: str, provider: str, **kwargs + ) -> "StreamingChannel": """ Factory method for radio channels Backward compatible: Existing code can continue using direct instantiation @@ -220,7 +231,7 @@ class StreamingChannel: is_radio=True, content_type="RADIO", quality="AUDIO", - **kwargs + **kwargs, ) def get_streaming_urls(self) -> List[str]: @@ -260,23 +271,25 @@ class StreamingChannel: return radio_indicators = [ - self.name.lower().startswith('radio'), - 'radio' in self.name.lower(), - self.quality in ['audio', 'aac', 'mp3', 'AUDIO'], - self.description and 'radio' in self.description.lower(), - self.genre and 'radio' in self.genre.lower(), + self.name.lower().startswith("radio"), + "radio" in self.name.lower(), + self.quality in ["audio", "aac", "mp3", "AUDIO"], + self.description and "radio" in self.description.lower(), + self.genre and "radio" in self.genre.lower(), ] if any(radio_indicators): 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]: - self.quality = 'AUDIO' + 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 - if self.content_type == 'LIVE': - self.content_type = 'RADIO' + if self.content_type == "LIVE": + self.content_type = "RADIO" # Compatibility properties (optional - for clearer naming) @property @@ -310,11 +323,11 @@ class StreamingChannel: warnings.append("License URL provided but no DRM configuration") # Check content type consistency - if self.mode == 'vod' and self.content_type == 'LIVE': + if self.mode == "vod" and self.content_type == "LIVE": warnings.append("VOD mode should not have LIVE content_type") # Check radio consistency - if self.is_radio and self.quality not in ['AUDIO', 'audio', None]: + if self.is_radio and self.quality not in ["AUDIO", "audio", None]: warnings.append(f"Radio channel has video quality setting: {self.quality}") - return warnings \ No newline at end of file + return warnings diff --git a/lib/streaming_providers/base/models/subscription.py b/lib/streaming_providers/base/models/subscription.py index 82e06f4..4cad8ec 100644 --- a/lib/streaming_providers/base/models/subscription.py +++ b/lib/streaming_providers/base/models/subscription.py @@ -1,9 +1,10 @@ """ Subscription models for provider packages and user entitlements. """ -from typing import Dict, List, Optional, Set + from dataclasses import dataclass, field from datetime import datetime +from typing import Dict, List, Optional, Set @dataclass @@ -98,28 +99,28 @@ class UserSubscription: def to_dict(self) -> Dict: """Convert to dictionary for JSON serialization""" return { - 'provider': self.provider, - 'country': self.country, - 'active': self.active, - 'has_packages': self.has_packages, - 'package_count': self.package_count, - 'package_names': self.package_names, - 'accessible_channel_count': self.accessible_channel_count, - 'valid_from': self.valid_from.isoformat() if self.valid_from else None, - 'valid_until': self.valid_until.isoformat() if self.valid_until else None, - 'status_message': self.status_message, - 'billing_status': self.billing_status, - 'packages': [ + "provider": self.provider, + "country": self.country, + "active": self.active, + "has_packages": self.has_packages, + "package_count": self.package_count, + "package_names": self.package_names, + "accessible_channel_count": self.accessible_channel_count, + "valid_from": self.valid_from.isoformat() if self.valid_from else None, + "valid_until": self.valid_until.isoformat() if self.valid_until else None, + "status_message": self.status_message, + "billing_status": self.billing_status, + "packages": [ { - 'package_id': pkg.package_id, - 'name': pkg.name, - 'description': pkg.description, - 'price_info': pkg.price_info, - 'channel_count': pkg.channel_count, - 'metadata': pkg.metadata + "package_id": pkg.package_id, + "name": pkg.name, + "description": pkg.description, + "price_info": pkg.price_info, + "channel_count": pkg.channel_count, + "metadata": pkg.metadata, } for pkg in self.packages - ] + ], } def add_package(self, package: SubscriptionPackage): @@ -129,4 +130,4 @@ class UserSubscription: def can_access_channel(self, channel_id: str) -> bool: """Check if user can access a specific channel""" - return channel_id in self.accessible_channel_ids \ No newline at end of file + return channel_id in self.accessible_channel_ids diff --git a/lib/streaming_providers/base/network/__init__.py b/lib/streaming_providers/base/network/__init__.py index da90652..4a0620c 100644 --- a/lib/streaming_providers/base/network/__init__.py +++ b/lib/streaming_providers/base/network/__init__.py @@ -3,8 +3,4 @@ from .http_manager import HTTPManager, HTTPManagerFactory from .proxy_manager import ProxyConfigManager # Only export what consumers should use -__all__ = [ - 'HTTPManager', - 'HTTPManagerFactory', - 'ProxyConfigManager' -] +__all__ = ["HTTPManager", "HTTPManagerFactory", "ProxyConfigManager"] diff --git a/lib/streaming_providers/base/network/http_manager.py b/lib/streaming_providers/base/network/http_manager.py index ef88639..451f600 100644 --- a/lib/streaming_providers/base/network/http_manager.py +++ b/lib/streaming_providers/base/network/http_manager.py @@ -1,12 +1,13 @@ # streaming_providers/base/network/http_manager.py -import requests -import time import json -from typing import Dict, Any, Optional +import time +from typing import Any, Dict, Optional + +import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry -from ..models.proxy_models import RequestConfig, ProxyConfig +from ..models.proxy_models import ProxyConfig, RequestConfig from ..utils.logger import logger @@ -42,7 +43,7 @@ class HTTPManager: total=self.config.max_retries, backoff_factor=self.config.retry_delay, status_forcelist=[429, 500, 502, 503, 504], - allowed_methods=["HEAD", "GET", "OPTIONS", "POST", "PUT", "DELETE"] + allowed_methods=["HEAD", "GET", "OPTIONS", "POST", "PUT", "DELETE"], ) adapter = HTTPAdapter(max_retries=retry_strategy) @@ -85,8 +86,14 @@ class HTTPManager: """ return self._make_request("GET", url, operation, **kwargs) - def post(self, url: str, operation: str = "api", data: Any = None, - json_data: Any = None, **kwargs) -> requests.Response: + def post( + self, + url: str, + operation: str = "api", + data: Any = None, + json_data: Any = None, + **kwargs, + ) -> requests.Response: """ Perform POST request with proxy support @@ -101,9 +108,9 @@ class HTTPManager: requests.Response object """ if json_data is not None: - kwargs['json'] = json_data + kwargs["json"] = json_data elif data is not None: - kwargs['data'] = data + kwargs["data"] = data return self._make_request("POST", url, operation, **kwargs) @@ -115,7 +122,9 @@ 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 """ @@ -161,7 +170,7 @@ class HTTPManager: except requests.exceptions.HTTPError as e: # Additional context for HTTP errors - status = e.response.status_code if e.response else 'unknown' + status = e.response.status_code if e.response else "unknown" logger.error( f"{self.config.provider}: HTTP {status} error for {operation} request to {url}: {e}" ) @@ -173,7 +182,9 @@ 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 @@ -181,9 +192,13 @@ 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 @@ -193,7 +208,7 @@ class HTTPManager: proxy_info = " [proxy: none]" # Get timeout info - timeout = kwargs.get('timeout', self.config.timeout) + timeout = kwargs.get("timeout", self.config.timeout) # Truncate URL for readability if very long display_url = url if len(url) <= 100 else f"{url[:80]}...{url[-17:]}" @@ -208,12 +223,12 @@ class HTTPManager: # Get response time if available elapsed = "" - if hasattr(response, 'elapsed'): + if hasattr(response, "elapsed"): elapsed_ms = int(response.elapsed.total_seconds() * 1000) elapsed = f" [{elapsed_ms}ms]" # Content type for context - content_type = response.headers.get('Content-Type', 'unknown') + content_type = response.headers.get("Content-Type", "unknown") # Size info size = len(response.content) @@ -228,8 +243,9 @@ class HTTPManager: f"({size_display}, {content_type}){elapsed}" ) - def test_connection(self, test_url: str = "https://httpbin.org/ip", - operation: str = "api") -> Dict[str, Any]: + def test_connection( + self, test_url: str = "https://httpbin.org/ip", operation: str = "api" + ) -> Dict[str, Any]: """ Test network connection and proxy configuration @@ -241,11 +257,11 @@ class HTTPManager: Dictionary with test results """ result = { - 'success': False, - 'proxy_used': False, - 'response_time': 0.0, - 'error': None, - 'ip_info': None + "success": False, + "proxy_used": False, + "response_time": 0.0, + "error": None, + "ip_info": None, } try: @@ -253,21 +269,21 @@ class HTTPManager: response = self.get(test_url, operation=operation) end_time = time.time() - result['success'] = True - result['response_time'] = end_time - start_time - result['proxy_used'] = bool( - self.config.proxy_config and - self.config.proxy_config.scope.should_use_proxy_for(operation) + result["success"] = True + result["response_time"] = end_time - start_time + result["proxy_used"] = bool( + self.config.proxy_config + and self.config.proxy_config.scope.should_use_proxy_for(operation) ) # Try to parse IP info if using httpbin try: - result['ip_info'] = response.json() + result["ip_info"] = response.json() except (json.JSONDecodeError, AttributeError): - result['ip_info'] = {'response': response.text[:100]} + result["ip_info"] = {"response": response.text[:100]} except Exception as e: - result['error'] = str(e) + result["error"] = str(e) logger.error(f"Connection test failed: {e}") return result @@ -292,9 +308,9 @@ class HTTPManagerFactory: """ @staticmethod - def create_for_provider(provider_name: str, - proxy_config: Optional[ProxyConfig] = None, - **config_kwargs) -> HTTPManager: + def create_for_provider( + provider_name: str, proxy_config: Optional[ProxyConfig] = None, **config_kwargs + ) -> HTTPManager: """ Create HTTP manager configured for specific provider @@ -308,15 +324,15 @@ class HTTPManagerFactory: """ # Provider-specific defaults provider_defaults = { - 'joyn': { - 'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36', - 'timeout': 30, - 'max_retries': 3 + "joyn": { + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", + "timeout": 30, + "max_retries": 3, }, - 'zdf': { - 'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - 'timeout': 25, - 'max_retries': 2 + "zdf": { + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "timeout": 25, + "max_retries": 2, }, # Add more providers as needed } @@ -324,18 +340,17 @@ class HTTPManagerFactory: # Get provider-specific defaults defaults = provider_defaults.get(provider_name, {}) defaults.update(config_kwargs) - defaults['provider'] = provider_name + defaults["provider"] = provider_name # Create request config - config = RequestConfig( - proxy_config=proxy_config, - **defaults - ) + config = RequestConfig(proxy_config=proxy_config, **defaults) 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 diff --git a/lib/streaming_providers/base/network/proxy_manager.py b/lib/streaming_providers/base/network/proxy_manager.py index 37b7d2b..6dbf2ef 100644 --- a/lib/streaming_providers/base/network/proxy_manager.py +++ b/lib/streaming_providers/base/network/proxy_manager.py @@ -1,8 +1,8 @@ # streaming_providers/base/network/proxy_manager.py import json import time -from typing import Dict, Optional, List, Any from pathlib import Path +from typing import Any, Dict, List, Optional from ..models.proxy_models import ProxyConfig, ProxyScope from ..utils.logger import logger @@ -21,15 +21,16 @@ class ProxyConfigManager: """ # Initialize VFS with config directory support from ..utils.vfs import VFS + self.vfs = VFS(config_dir=config_dir) # Keep legacy attributes for backward compatibility but use VFS primarily if config_dir: self.config_dir = Path(config_dir) else: - self.config_dir = Path.home() / '.streaming_providers' / 'config' + self.config_dir = Path.home() / ".streaming_providers" / "config" - self.proxy_config_file = 'proxy_config.json' # Use relative path for VFS + self.proxy_config_file = "proxy_config.json" # Use relative path for VFS # Cache for loaded configurations self._config_cache: Dict[str, ProxyConfig] = {} @@ -61,33 +62,43 @@ 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 - if 'global' in vfs_data: - self._global_config = ProxyConfig.from_dict(vfs_data['global']) + if "global" in vfs_data: + self._global_config = ProxyConfig.from_dict(vfs_data["global"]) logger.debug("Loaded global proxy configuration") # Load provider-specific configurations - providers = vfs_data.get('providers', {}) + providers = vfs_data.get("providers", {}) for provider_name, provider_data in providers.items(): 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}") @@ -98,25 +109,25 @@ class ProxyConfigManager: """Save proxy configurations to file using VFS""" try: data: Dict[str, Any] = { - 'providers': {}, - 'metadata': { - 'version': '1.1', # Bumped for country support - 'description': 'Streaming provider proxy configurations' - } + "providers": {}, + "metadata": { + "version": "1.1", # Bumped for country support + "description": "Streaming provider proxy configurations", + }, } # Save global configuration if self._global_config: - data['global'] = self._global_config.to_dict() + data["global"] = self._global_config.to_dict() # Save provider-specific configurations # Group by provider to maintain nested structure provider_configs: Dict[str, Dict] = {} for cache_key, config in self._config_cache.items(): - if '_' in cache_key and len(cache_key.split('_')[-1]) <= 3: + if "_" in cache_key and len(cache_key.split("_")[-1]) <= 3: # Country-aware key (e.g., "joyn_de") - parts = cache_key.rsplit('_', 1) + parts = cache_key.rsplit("_", 1) provider_name = parts[0] country = parts[1] @@ -127,7 +138,7 @@ class ProxyConfigManager: # Non-country key provider_configs[cache_key] = config.to_dict() - data['providers'] = provider_configs + data["providers"] = provider_configs # Save using VFS success = self.vfs.write_json(self.proxy_config_file, data) @@ -143,7 +154,9 @@ class ProxyConfigManager: logger.error(f"Error saving proxy configurations: {e}") return False - def get_proxy_config(self, provider_name: str, country: Optional[str] = None) -> Optional[ProxyConfig]: + def get_proxy_config( + self, provider_name: str, country: Optional[str] = None + ) -> Optional[ProxyConfig]: """ Get proxy configuration for a provider and optional country @@ -188,8 +201,12 @@ class ProxyConfigManager: logger.debug(f"No proxy configuration found for {provider_name}{country_str}") return None - def set_proxy_config(self, provider_name: str, proxy_config: ProxyConfig, - country: Optional[str] = None) -> bool: + def set_proxy_config( + self, + provider_name: str, + proxy_config: ProxyConfig, + country: Optional[str] = None, + ) -> bool: """ Set proxy configuration for a provider @@ -206,23 +223,29 @@ class ProxyConfigManager: return False try: - if provider_name == 'global': + if provider_name == "global": self._global_config = proxy_config logger.info("Set global proxy configuration") else: 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 @@ -234,7 +257,7 @@ class ProxyConfigManager: True if successful, False otherwise """ try: - if provider_name == 'global': + if provider_name == "global": self._global_config = None logger.info("Removed global proxy configuration") else: @@ -243,30 +266,41 @@ 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})") + logger.warning( + f"No proxy configuration found for {provider_name} ({country})" + ) return True else: # Remove all countries for provider keys_to_remove = [ - key for key in self._config_cache.keys() + key + for key in self._config_cache.keys() if key == provider_name or key.startswith(f"{provider_name}_") ] 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() except Exception as e: country_str = f" ({country})" if country else "" - logger.error(f"Error removing proxy configuration for {provider_name}{country_str}: {e}") + logger.error( + f"Error removing proxy configuration for {provider_name}{country_str}: {e}" + ) return False def get_all_countries(self, provider_name: str) -> List[str]: @@ -284,7 +318,7 @@ class ProxyConfigManager: for cache_key in self._config_cache.keys(): if cache_key.startswith(prefix): - country = cache_key[len(prefix):] + country = cache_key[len(prefix) :] if len(country) <= 3: # Validate it looks like a country code countries.append(country) @@ -299,10 +333,12 @@ class ProxyConfigManager: """ providers = list(self._config_cache.keys()) if self._global_config: - providers.append('global') + providers.append("global") return providers - def test_proxy_config(self, provider_name: str, country: Optional[str] = None) -> Dict[str, Any]: + def test_proxy_config( + self, provider_name: str, country: Optional[str] = None + ) -> Dict[str, Any]: """ Test proxy configuration for a provider @@ -316,30 +352,29 @@ class ProxyConfigManager: proxy_config = self.get_proxy_config(provider_name, country) if not proxy_config: return { - 'success': False, - 'error': 'No proxy configuration found', - 'provider': provider_name, - 'country': country + "success": False, + "error": "No proxy configuration found", + "provider": provider_name, + "country": country, } # Test basic connectivity through proxy from .http_manager import HTTPManager, RequestConfig - config = RequestConfig( - proxy_config=proxy_config, - provider=provider_name - ) + config = RequestConfig(proxy_config=proxy_config, provider=provider_name) manager = HTTPManager(config) result = manager.test_connection() - result['provider'] = provider_name - result['country'] = country - result['proxy_config'] = proxy_config.to_dict() + result["provider"] = provider_name + result["country"] = country + result["proxy_config"] = proxy_config.to_dict() 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 @@ -354,31 +389,33 @@ class ProxyConfigManager: cache_key, _ = self._get_proxy_path(provider_name, country) info = { - 'provider': provider_name, - 'country': country, - 'has_proxy': proxy_config is not None, - 'config_source': None, - 'proxy_details': None + "provider": provider_name, + "country": country, + "has_proxy": proxy_config is not None, + "config_source": None, + "proxy_details": None, } if proxy_config: # Determine config source if cache_key in self._config_cache: - info['config_source'] = 'provider_country_specific' if country else 'provider_specific' + info["config_source"] = ( + "provider_country_specific" if country else "provider_specific" + ) elif country and provider_name in self._config_cache: - info['config_source'] = 'provider_fallback' + info["config_source"] = "provider_fallback" elif self._global_config: - info['config_source'] = 'global' + info["config_source"] = "global" # Add proxy details (without sensitive auth info) - info['proxy_details'] = { - 'host': proxy_config.host, - 'port': proxy_config.port, - 'proxy_type': proxy_config.proxy_type.value, - 'has_auth': proxy_config.auth is not None, - 'scope': proxy_config.scope.__dict__, - 'timeout': proxy_config.timeout, - 'verify_ssl': proxy_config.verify_ssl + info["proxy_details"] = { + "host": proxy_config.host, + "port": proxy_config.port, + "proxy_type": proxy_config.proxy_type.value, + "has_auth": proxy_config.auth is not None, + "scope": proxy_config.scope.__dict__, + "timeout": proxy_config.timeout, + "verify_ssl": proxy_config.verify_ssl, } return info @@ -394,16 +431,18 @@ 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 providers_export = {} for cache_key, config in self._config_cache.items(): - if '_' in cache_key and len(cache_key.split('_')[-1]) <= 3: + if "_" in cache_key and len(cache_key.split("_")[-1]) <= 3: # Country-aware key - parts = cache_key.rsplit('_', 1) + parts = cache_key.rsplit("_", 1) provider_name = parts[0] country = parts[1] @@ -415,16 +454,18 @@ class ProxyConfigManager: providers_export[cache_key] = config.to_dict() export_data = { - 'metadata': { - 'exported_at': time.time(), - 'version': '1.1', - 'source': 'streaming_providers_proxy_manager' + "metadata": { + "exported_at": time.time(), + "version": "1.1", + "source": "streaming_providers_proxy_manager", }, - 'global': self._global_config.to_dict() if self._global_config else None, - 'providers': providers_export + "global": ( + self._global_config.to_dict() if self._global_config else None + ), + "providers": providers_export, } - with open(export_path, 'w', encoding='utf-8') as f: + with open(export_path, "w", encoding="utf-8") as f: json.dump(export_data, f, indent=2, ensure_ascii=False) logger.info(f"Exported proxy configurations to {export_path}") @@ -446,7 +487,7 @@ class ProxyConfigManager: True if successful, False otherwise """ try: - with open(import_path, 'r', encoding='utf-8') as f: + with open(import_path, "r", encoding="utf-8") as f: import_data = json.load(f) if not merge: @@ -455,42 +496,52 @@ class ProxyConfigManager: self._global_config = None # Import global configuration - if 'global' in import_data and import_data['global']: - self._global_config = ProxyConfig.from_dict(import_data['global']) + if "global" in import_data and import_data["global"]: + self._global_config = ProxyConfig.from_dict(import_data["global"]) # Import provider configurations - providers = import_data.get('providers', {}) + providers = import_data.get("providers", {}) for provider_name, provider_data in providers.items(): 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: logger.error(f"Error importing proxy configurations: {e}") return False - def create_proxy_from_url(self, provider_name: str, proxy_url: str, - scope: Optional[ProxyScope] = None, - country: Optional[str] = None) -> bool: + def create_proxy_from_url( + self, + provider_name: str, + proxy_url: str, + scope: Optional[ProxyScope] = None, + country: Optional[str] = None, + ) -> bool: """ Create and set proxy configuration from URL @@ -508,12 +559,17 @@ class ProxyConfigManager: return self.set_proxy_config(provider_name, proxy_config, country) except Exception as e: country_str = f" ({country})" if country else "" - logger.error(f"Error creating proxy config from URL for {provider_name}{country_str}: {e}") + logger.error( + f"Error creating proxy config from URL for {provider_name}{country_str}: {e}" + ) return False - def bulk_set_proxy(self, proxy_config: ProxyConfig, - provider_names: List[str], - country: Optional[str] = None) -> Dict[str, bool]: + def bulk_set_proxy( + self, + proxy_config: ProxyConfig, + provider_names: List[str], + country: Optional[str] = None, + ) -> Dict[str, bool]: """ Set the same proxy configuration for multiple providers @@ -527,7 +583,9 @@ 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]]: @@ -541,13 +599,13 @@ class ProxyConfigManager: # Add global config info if self._global_config: - info['global'] = self.get_proxy_info('global') + info["global"] = self.get_proxy_info("global") # Add provider-specific configs for cache_key in self._config_cache.keys(): - if '_' in cache_key and len(cache_key.split('_')[-1]) <= 3: + if "_" in cache_key and len(cache_key.split("_")[-1]) <= 3: # Country-aware key - parts = cache_key.rsplit('_', 1) + parts = cache_key.rsplit("_", 1) provider_name = parts[0] country = parts[1] info[cache_key] = self.get_proxy_info(provider_name, country) @@ -555,4 +613,4 @@ class ProxyConfigManager: # Non-country key info[cache_key] = self.get_proxy_info(cache_key) - return info \ No newline at end of file + return info diff --git a/lib/streaming_providers/base/provider.py b/lib/streaming_providers/base/provider.py index 8922a3c..c8b97a8 100644 --- a/lib/streaming_providers/base/provider.py +++ b/lib/streaming_providers/base/provider.py @@ -8,23 +8,24 @@ New Features: - Backward compatible with existing @property methods """ -from abc import ABC, abstractmethod -from typing import Dict, List, Optional, Callable, Any, ClassVar -from enum import Enum import json +from abc import ABC, abstractmethod from datetime import datetime +from enum import Enum +from typing import Any, Callable, ClassVar, Dict, List, Optional -from .models.streaming_channel import StreamingChannel +from ..providers.auth import AuthContext, AuthStatus from .models.drm_models import DRMConfig from .models.proxy_models import ProxyConfig -from .models.subscription import UserSubscription, SubscriptionPackage -from .network import HTTPManagerFactory, HTTPManager +from .models.streaming_channel import StreamingChannel +from .models.subscription import SubscriptionPackage, UserSubscription +from .network import HTTPManager, HTTPManagerFactory from .utils.logger import logger -from ..providers.auth import AuthContext, AuthStatus class AuthType(Enum): """Authentication token types""" + BEARER = "bearer" BASIC = "basic" CLIENT = "client" @@ -54,11 +55,11 @@ class StreamingProvider(ABC): SUPPORTED_COUNTRIES: ClassVar[List[str]] = [] """List of ISO country codes this provider supports (empty = single country)""" - def __init__(self, country: str = 'DE'): + def __init__(self, country: str = "DE"): self.country = country self.channels: List[StreamingChannel] = [] self._http_manager = None - self._default_user_agent = 'StreamingProvider/1.0' + self._default_user_agent = "StreamingProvider/1.0" self.authenticator = None # Optional: set by concrete providers # ============================================================================ @@ -76,18 +77,18 @@ class StreamingProvider(ABC): Returns: Provider label string """ - base_label = cls.PROVIDER_LABEL or cls.__name__.replace('Provider', '') + base_label = cls.PROVIDER_LABEL or cls.__name__.replace("Provider", "") if country: # Format country code country_upper = country.upper() # Special handling for common cases - if country_upper == 'DE': + if country_upper == "DE": return f"{base_label} Germany" - elif country_upper == 'AT': + elif country_upper == "AT": return f"{base_label} Austria" - elif country_upper == 'CH': + elif country_upper == "CH": return f"{base_label} Switzerland" else: return f"{base_label} ({country_upper})" @@ -139,20 +140,24 @@ class StreamingProvider(ABC): if cls.supports_multiple_countries(): for country in cls.SUPPORTED_COUNTRIES: - instances.append({ - 'plugin': cls.__name__.lower().replace('provider', ''), - 'country': country.upper(), - 'label': cls.get_static_label(country), - 'requires_country_suffix': True - }) + instances.append( + { + "plugin": cls.__name__.lower().replace("provider", ""), + "country": country.upper(), + "label": cls.get_static_label(country), + "requires_country_suffix": True, + } + ) else: # Single-country provider - instances.append({ - 'plugin': cls.__name__.lower().replace('provider', ''), - 'country': 'DE', # Default country for single-country providers - 'label': cls.get_static_label(), - 'requires_country_suffix': False - }) + instances.append( + { + "plugin": cls.__name__.lower().replace("provider", ""), + "country": "DE", # Default country for single-country providers + "label": cls.get_static_label(), + "requires_country_suffix": False, + } + ) return instances @@ -232,10 +237,13 @@ class StreamingProvider(ABC): """ return self.catchup_window > 0 - def get_epg(self, channel_id: str, - start_time: Optional[datetime] = None, - end_time: Optional[datetime] = None, - **kwargs) -> List[Dict]: + def get_epg( + self, + channel_id: str, + start_time: Optional[datetime] = None, + end_time: Optional[datetime] = None, + **kwargs, + ) -> List[Dict]: """Get EPG data for a channel""" return [] @@ -245,7 +253,9 @@ class StreamingProvider(ABC): return None @abstractmethod - def enrich_channel_data(self, channel: StreamingChannel, **kwargs) -> Optional[StreamingChannel]: + def enrich_channel_data( + self, channel: StreamingChannel, **kwargs + ) -> Optional[StreamingChannel]: """Enrich channel with additional data including manifest URL""" return None @@ -254,7 +264,9 @@ 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 @@ -264,14 +276,16 @@ class StreamingProvider(ABC): channels = self.channels return { - 'Provider': self.provider_name, - 'Country': self.country, - 'Channels': [channel.to_dict() for channel in channels] + "Provider": self.provider_name, + "Country": self.country, + "Channels": [channel.to_dict() for channel in channels], } 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) @@ -287,16 +301,18 @@ class StreamingProvider(ABC): """Set the provider's HTTP manager instance""" self._http_manager = value - def _setup_http_manager(self, - provider_name: str, - proxy_config: Optional[ProxyConfig] = None, - proxy_url: Optional[str] = None, - config_dir: Optional[str] = None, - country: Optional[str] = None, - user_agent: Optional[str] = None, - timeout: Optional[int] = None, - max_retries: Optional[int] = None, - **kwargs) -> HTTPManager: + def _setup_http_manager( + self, + provider_name: str, + proxy_config: Optional[ProxyConfig] = None, + proxy_url: Optional[str] = None, + config_dir: Optional[str] = None, + country: Optional[str] = None, + user_agent: Optional[str] = None, + timeout: Optional[int] = None, + max_retries: Optional[int] = None, + **kwargs, + ) -> HTTPManager: """Standard HTTP manager setup for providers with intelligent proxy resolution""" if country is None: country = self.country @@ -306,36 +322,38 @@ class StreamingProvider(ABC): proxy_url=proxy_url, config_dir=config_dir, provider_name=provider_name, - country=country + country=country, ) manager_kwargs = {} if user_agent: - manager_kwargs['user_agent'] = user_agent + manager_kwargs["user_agent"] = user_agent if timeout: - manager_kwargs['timeout'] = timeout + manager_kwargs["timeout"] = timeout if max_retries: - manager_kwargs['max_retries'] = max_retries + manager_kwargs["max_retries"] = max_retries manager_kwargs.update(kwargs) http_manager = HTTPManagerFactory.create_for_provider( - provider_name=provider_name, - proxy_config=resolved_proxy, - **manager_kwargs + provider_name=provider_name, proxy_config=resolved_proxy, **manager_kwargs ) self._log_http_manager_setup(provider_name, resolved_proxy, manager_kwargs) return http_manager @staticmethod - def _resolve_proxy_config(proxy_config: Optional[ProxyConfig], - proxy_url: Optional[str], - config_dir: Optional[str], - provider_name: str, - country: str) -> Optional[ProxyConfig]: + def _resolve_proxy_config( + proxy_config: Optional[ProxyConfig], + proxy_url: Optional[str], + config_dir: Optional[str], + provider_name: str, + country: str, + ) -> 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: @@ -343,10 +361,13 @@ 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 + proxy_mgr = ProxyConfigManager(config_dir) managed_proxy = proxy_mgr.get_proxy_config(provider_name, country) @@ -354,55 +375,68 @@ 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 @staticmethod - def _log_http_manager_setup(provider_name: str, - proxy_config: Optional[ProxyConfig], - manager_kwargs: Dict) -> None: + def _log_http_manager_setup( + provider_name: str, proxy_config: Optional[ProxyConfig], manager_kwargs: Dict + ) -> None: """Log HTTP manager setup information""" 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})") else: info_parts.append("proxy: none") - if 'user_agent' in manager_kwargs: - ua_preview = manager_kwargs['user_agent'][:50] + '...' if len(manager_kwargs['user_agent']) > 50 else \ - manager_kwargs['user_agent'] + if "user_agent" in manager_kwargs: + ua_preview = ( + manager_kwargs["user_agent"][:50] + "..." + if len(manager_kwargs["user_agent"]) > 50 + else manager_kwargs["user_agent"] + ) info_parts.append(f"user-agent: {ua_preview}") - if 'timeout' in manager_kwargs: + if "timeout" in manager_kwargs: info_parts.append(f"timeout: {manager_kwargs['timeout']}s") - if 'max_retries' in manager_kwargs: + if "max_retries" in manager_kwargs: info_parts.append(f"retries: {manager_kwargs['max_retries']}") logger.info(f"{provider_name}: {', '.join(info_parts)}") - def _share_http_manager_with_authenticator(self, - authenticator, - http_manager: Optional[HTTPManager] = None) -> HTTPManager: + def _share_http_manager_with_authenticator( + self, authenticator, http_manager: Optional[HTTPManager] = None + ) -> HTTPManager: """Share HTTP manager with authenticator for consistency""" if http_manager is None: http_manager = self.http_manager - if http_manager and hasattr(authenticator, 'http_manager'): + 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 @@ -411,11 +445,13 @@ class StreamingProvider(ABC): # AUTHENTICATION HEADER ABSTRACTIONS # ============================================================================ - def _get_base_headers(self, - user_agent: Optional[str] = None, - accept: str = 'application/json', - content_type: str = 'application/json', - additional_headers: Optional[Dict[str, str]] = None) -> Dict[str, str]: + def _get_base_headers( + self, + user_agent: Optional[str] = None, + accept: str = "application/json", + content_type: str = "application/json", + additional_headers: Optional[Dict[str, str]] = None, + ) -> Dict[str, str]: """ Get base headers for API requests @@ -429,9 +465,9 @@ class StreamingProvider(ABC): Dictionary of HTTP headers """ headers = { - 'User-Agent': user_agent or self._default_user_agent, - 'Accept': accept, - 'Content-Type': content_type + "User-Agent": user_agent or self._default_user_agent, + "Accept": accept, + "Content-Type": content_type, } if additional_headers: @@ -439,13 +475,15 @@ class StreamingProvider(ABC): return headers - def _get_authenticated_headers(self, - auth_type: AuthType = AuthType.BEARER, - token_getter: Optional[Callable[[], str]] = None, - token_key: str = 'Authorization', - base_headers: Optional[Dict[str, str]] = None, - additional_headers: Optional[Dict[str, str]] = None, - **kwargs) -> Dict[str, str]: + def _get_authenticated_headers( + self, + auth_type: AuthType = AuthType.BEARER, + token_getter: Optional[Callable[[], str]] = None, + token_key: str = "Authorization", + base_headers: Optional[Dict[str, str]] = None, + additional_headers: Optional[Dict[str, str]] = None, + **kwargs, + ) -> Dict[str, str]: """ Get headers with authentication token @@ -474,17 +512,19 @@ 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 if token: if auth_type == AuthType.BEARER: - headers[token_key] = f'Bearer {token}' + headers[token_key] = f"Bearer {token}" elif auth_type == AuthType.BASIC: - headers[token_key] = f'Basic {token}' + headers[token_key] = f"Basic {token}" elif auth_type == AuthType.CLIENT: - headers[token_key] = f'Client {token}' + headers[token_key] = f"Client {token}" elif auth_type == AuthType.CUSTOM: # Custom type - just use token as-is headers[token_key] = token @@ -495,11 +535,13 @@ class StreamingProvider(ABC): return headers - def _build_provider_headers(self, - base_headers: Optional[Dict[str, str]] = None, - auth_type: AuthType = AuthType.NONE, - provider_headers: Optional[Dict[str, str]] = None, - **auth_kwargs) -> Dict[str, str]: + def _build_provider_headers( + self, + base_headers: Optional[Dict[str, str]] = None, + auth_type: AuthType = AuthType.NONE, + provider_headers: Optional[Dict[str, str]] = None, + **auth_kwargs, + ) -> Dict[str, str]: """ Build complete headers with provider-specific fields @@ -521,9 +563,7 @@ class StreamingProvider(ABC): # Add authentication if needed if auth_type != AuthType.NONE: headers = self._get_authenticated_headers( - auth_type=auth_type, - base_headers=headers, - **auth_kwargs + auth_type=auth_type, base_headers=headers, **auth_kwargs ) # Add provider-specific headers @@ -532,12 +572,14 @@ class StreamingProvider(ABC): return headers - def _add_auth_to_headers(self, - headers: Dict[str, str], - auth_type: AuthType = AuthType.BEARER, - token_getter: Optional[Callable[[], str]] = None, - token_key: str = 'Authorization', - **kwargs) -> Dict[str, str]: + def _add_auth_to_headers( + self, + headers: Dict[str, str], + auth_type: AuthType = AuthType.BEARER, + token_getter: Optional[Callable[[], str]] = None, + token_key: str = "Authorization", + **kwargs, + ) -> Dict[str, str]: """ Add authentication to existing headers (in-place modification) @@ -568,20 +610,19 @@ class StreamingProvider(ABC): # Add auth header if token: if auth_type == AuthType.BEARER: - headers[token_key] = f'Bearer {token}' + headers[token_key] = f"Bearer {token}" elif auth_type == AuthType.BASIC: - headers[token_key] = f'Basic {token}' + headers[token_key] = f"Basic {token}" elif auth_type == AuthType.CLIENT: - headers[token_key] = f'Client {token}' + headers[token_key] = f"Client {token}" elif auth_type == AuthType.CUSTOM: headers[token_key] = token return headers - def _get_auth_token(self, - token_type: str = 'bearer', - force_refresh: bool = False, - **kwargs) -> Optional[str]: + def _get_auth_token( + self, token_type: str = "bearer", force_refresh: bool = False, **kwargs + ) -> Optional[str]: """ Get authentication token from authenticator @@ -601,14 +642,18 @@ 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) - elif hasattr(self.authenticator, f'get_{token_type}_token'): - getter = getattr(self.authenticator, f'get_{token_type}_token') + if token_type == "bearer": + 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 @@ -617,12 +662,14 @@ class StreamingProvider(ABC): # CATCHUP ABSTRACT METHODS # ============================================================================ - def get_catchup_manifest(self, - channel_id: str, - start_time: int, - end_time: int, - epg_id: Optional[str] = None, - **kwargs) -> Optional[str]: + def get_catchup_manifest( + self, + channel_id: str, + start_time: int, + end_time: int, + epg_id: Optional[str] = None, + **kwargs, + ) -> Optional[str]: """ Get manifest URL for catchup/timeshift content. @@ -640,19 +687,25 @@ class StreamingProvider(ABC): Override in subclass to implement provider-specific catchup logic. """ if not self.supports_catchup: - logger.debug(f"{self.provider_name}: Catchup not supported, falling back to live manifest") + logger.debug( + f"{self.provider_name}: Catchup not supported, falling back to live manifest" + ) return self.get_manifest(channel_id, **kwargs) - logger.warning(f"{self.provider_name}: get_catchup_manifest not implemented, " - f"falling back to live manifest") + logger.warning( + f"{self.provider_name}: get_catchup_manifest not implemented, " + f"falling back to live manifest" + ) return self.get_manifest(channel_id, **kwargs) - def get_catchup_drm(self, - channel_id: str, - start_time: int, - end_time: int, - epg_id: Optional[str] = None, - **kwargs) -> List[DRMConfig]: + def get_catchup_drm( + self, + channel_id: str, + start_time: int, + end_time: int, + epg_id: Optional[str] = None, + **kwargs, + ) -> List[DRMConfig]: """ Get DRM configurations for catchup content. @@ -670,11 +723,15 @@ 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(f"{self.provider_name}: get_catchup_drm not implemented, " - f"using live DRM configuration") + logger.debug( + f"{self.provider_name}: get_catchup_drm not implemented, " + f"using live DRM configuration" + ) return self.get_drm(channel_id, **kwargs) # ============================================================================ @@ -693,7 +750,9 @@ class StreamingProvider(ABC): """ return self.catchup_window - def validate_catchup_request(self, start_time: int, end_time: int) -> tuple[bool, Optional[str]]: + def validate_catchup_request( + self, start_time: int, end_time: int + ) -> tuple[bool, Optional[str]]: """ Validate a catchup request against provider's capabilities. @@ -722,16 +781,17 @@ class StreamingProvider(ABC): if content_age > max_age_seconds: hours_ago = content_age // 3600 - return False, (f"Content is outside catchup window " - f"(requested: {hours_ago} hours ago, " - f"max: {self.catchup_window} hours)") + return False, ( + f"Content is outside catchup window " + f"(requested: {hours_ago} hours ago, " + f"max: {self.catchup_window} hours)" + ) return True, None - def format_catchup_time_params(self, - start_time: int, - end_time: int, - format_type: str = 'iso') -> Dict[str, str]: + def format_catchup_time_params( + self, start_time: int, end_time: int, format_type: str = "iso" + ) -> Dict[str, str]: """ Format time parameters for provider-specific API calls. @@ -750,38 +810,24 @@ class StreamingProvider(ABC): """ from datetime import datetime - if format_type == 'iso': + if format_type == "iso": # ISO 8601 format start_dt = datetime.fromtimestamp(start_time) end_dt = datetime.fromtimestamp(end_time) - return { - 'start': start_dt.isoformat(), - 'end': end_dt.isoformat() - } - elif format_type == 'unix': + return {"start": start_dt.isoformat(), "end": end_dt.isoformat()} + elif format_type == "unix": # Unix timestamps (seconds) - return { - 'start': str(start_time), - 'end': str(end_time) - } - elif format_type == 'millis': + return {"start": str(start_time), "end": str(end_time)} + elif format_type == "millis": # Milliseconds since epoch - return { - 'start': str(start_time * 1000), - 'end': str(end_time * 1000) - } + return {"start": str(start_time * 1000), "end": str(end_time * 1000)} else: # Default to unix - return { - 'start': str(start_time), - 'end': str(end_time) - } + return {"start": str(start_time), "end": str(end_time)} - def build_catchup_manifest_url(self, - base_url: str, - start_time: int, - end_time: int, - url_format: str = 'query') -> str: + def build_catchup_manifest_url( + self, base_url: str, start_time: int, end_time: int, url_format: str = "query" + ) -> str: """ Build catchup manifest URL with time parameters. @@ -799,19 +845,19 @@ class StreamingProvider(ABC): Override in subclass for provider-specific URL construction. """ - if url_format == 'query': + if url_format == "query": # Add as query parameters - separator = '&' if '?' in base_url else '?' + separator = "&" if "?" in base_url else "?" return f"{base_url}{separator}start={start_time}&end={end_time}" - elif url_format == 'path': + elif url_format == "path": # Add to path (e.g., /manifest/start/end.mpd) return f"{base_url}/{start_time}/{end_time}" - elif url_format == 'fragment': + elif url_format == "fragment": # Add as URL fragment (e.g., manifest.mpd#t=start,end) return f"{base_url}#t={start_time},{end_time}" else: # Default to query parameters - separator = '&' if '?' in base_url else '?' + separator = "&" if "?" in base_url else "?" return f"{base_url}{separator}start={start_time}&end={end_time}" # ============================================================================ @@ -866,7 +912,8 @@ class StreamingProvider(ABC): # Filter channels based on accessible channel IDs if subscription.accessible_channel_ids: return [ - channel for channel in all_channels + channel + for channel in all_channels if channel.channel_id in subscription.accessible_channel_ids ] @@ -985,12 +1032,12 @@ class StreamingProvider(ABC): Description string or empty string if not supported """ descriptions = { - 'user_credentials': 'Username and password authentication', - 'client_credentials': 'Client ID and secret authentication', - 'network_based': 'Network/fixed-line authentication', - 'anonymous': 'No authentication required', - 'device_registration': 'Device registration authentication', - 'embedded_client': 'Built-in credentials authentication' + "user_credentials": "Username and password authentication", + "client_credentials": "Client ID and secret authentication", + "network_based": "Network/fixed-line authentication", + "anonymous": "No authentication required", + "device_registration": "Device registration authentication", + "embedded_client": "Built-in credentials authentication", } if auth_type in descriptions: @@ -1013,32 +1060,41 @@ 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'] + "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"], } # Type-specific details - if auth_type == 'user_credentials': - requirements.update({ - 'fields': ['username', 'password'], - 'optional_fields': ['client_id'], - 'storage_key': 'user_password' - }) - elif auth_type == 'client_credentials': - requirements.update({ - 'fields': ['client_id', 'client_secret'], - 'storage_key': 'client_credentials' - }) - elif auth_type == 'network_based': - requirements.update({ - 'description': 'Authenticates via your network provider', - 'automatic': True - }) + if auth_type == "user_credentials": + requirements.update( + { + "fields": ["username", "password"], + "optional_fields": ["client_id"], + "storage_key": "user_password", + } + ) + elif auth_type == "client_credentials": + requirements.update( + { + "fields": ["client_id", "client_secret"], + "storage_key": "client_credentials", + } + ) + elif auth_type == "network_based": + requirements.update( + { + "description": "Authenticates via your network provider", + "automatic": True, + } + ) return requirements @@ -1054,14 +1110,15 @@ class StreamingProvider(ABC): def preferred_auth_type(self) -> str: """Preferred authentication type (first in supported list).""" types = self.supported_auth_types - return types[0] if types else 'unknown' + return types[0] if types else "unknown" @property 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) + credential_types = ["user_credentials", "client_credentials"] + return any( + auth_type in credential_types for auth_type in self.supported_auth_types + ) # ===== AUTHENTICATION PROPERTIES ===== @@ -1090,28 +1147,26 @@ class StreamingProvider(ABC): credentials = context.get_credentials(self.provider_name, self.country) if credentials: # Map credential type to auth type - if hasattr(credentials, 'credential_type'): - if credentials.credential_type == 'user_password': - return 'user_credentials' - elif credentials.credential_type == 'client_credentials': - return 'client_credentials' + if hasattr(credentials, "credential_type"): + if credentials.credential_type == "user_password": + return "user_credentials" + elif credentials.credential_type == "client_credentials": + return "client_credentials" # 2. Check token auth level primary_token = context.get_token( - self.provider_name, - self.primary_token_scope, - self.country + self.provider_name, self.primary_token_scope, self.country ) if primary_token: - auth_level = primary_token.get('auth_level') - if auth_level == 'user_authenticated': - return 'user_credentials' - elif auth_level == 'client_credentials': - return 'client_credentials' - elif auth_level == 'anonymous': - return 'anonymous' - elif auth_level == 'network_based': - return 'network_based' + auth_level = primary_token.get("auth_level") + if auth_level == "user_authenticated": + return "user_credentials" + elif auth_level == "client_credentials": + return "client_credentials" + elif auth_level == "anonymous": + return "anonymous" + elif auth_level == "network_based": + return "network_based" # 3. Return first supported type as default return self.preferred_auth_type @@ -1139,7 +1194,7 @@ class StreamingProvider(ABC): scope = self.primary_token_scope return [scope] if scope else [] - def get_auth_status(self, context: AuthContext) -> 'AuthStatus': + def get_auth_status(self, context: AuthContext) -> "AuthStatus": """ Get authentication status for this provider. Uses AuthStatusBuilder by default. @@ -1152,7 +1207,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) # Optional override methods for providers with special logic @@ -1183,4 +1240,4 @@ class StreamingProvider(ABC): Returns: Dictionary with provider-specific information """ - return {} \ No newline at end of file + return {} diff --git a/lib/streaming_providers/base/provider_registry.py b/lib/streaming_providers/base/provider_registry.py index 2aa284c..a3625c2 100644 --- a/lib/streaming_providers/base/provider_registry.py +++ b/lib/streaming_providers/base/provider_registry.py @@ -2,7 +2,9 @@ """ Core provider registry handling discovery, metadata, and lifecycle management. """ -from typing import Dict, List, Optional, Any + +from typing import Any, Dict, List, Optional + from .provider import StreamingProvider from .utils.logger import logger @@ -19,7 +21,7 @@ class ProviderMetadata: def _extract_metadata(self): """Extract static metadata from provider class without instantiation""" - self.plugin_name = self.plugin_class.__name__.lower().replace('provider', '') + self.plugin_name = self.plugin_class.__name__.lower().replace("provider", "") # Check if provider supports multiple countries supports_multiple = self.plugin_class.supports_multiple_countries() @@ -49,7 +51,7 @@ class ProviderMetadata: self.logo = self.plugin_class.get_static_logo(self.country) self.supported_countries = self.plugin_class.get_static_supported_countries() self.requires_credentials = any( - auth_type in ['user_credentials', 'client_credentials'] + auth_type in ["user_credentials", "client_credentials"] for auth_type in self.supported_auth_types ) @@ -86,17 +88,17 @@ class ProviderMetadata: def to_dict(self) -> Dict[str, Any]: """Convert metadata to dictionary for API response""" return { - 'name': self.name, - 'label': self.label, - 'plugin': self.plugin_name, - 'country': self.country.upper(), - 'enabled': self.enabled, - 'instance_ready': self.instance is not None, - 'requires_credentials': self.requires_credentials, - 'supported_auth_types': self.supported_auth_types, - 'logo': self.logo, - 'is_multi_country': self.is_multi_country, - 'supported_countries': self.supported_countries + "name": self.name, + "label": self.label, + "plugin": self.plugin_name, + "country": self.country.upper(), + "enabled": self.enabled, + "instance_ready": self.instance is not None, + "requires_credentials": self.requires_credentials, + "supported_auth_types": self.supported_auth_types, + "logo": self.logo, + "is_multi_country": self.is_multi_country, + "supported_countries": self.supported_countries, } @@ -116,6 +118,7 @@ class ProviderRegistry: """Check if a provider is enabled via settings manager.""" try: from .settings.provider_enable_manager import ProviderEnableManager + enable_manager = ProviderEnableManager() instance_name = f"{provider_name}_{country}" if country else provider_name @@ -124,7 +127,7 @@ class ProviderRegistry: logger.warning(f"Could not check enable status for '{provider_name}': {e}") return True - def discover_all_providers(self, default_country: str = 'DE') -> List[str]: + def discover_all_providers(self, default_country: str = "DE") -> List[str]: """Discover ALL provider instances and extract metadata.""" from streaming_providers import AVAILABLE_PROVIDERS @@ -158,7 +161,9 @@ 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]: @@ -192,8 +197,11 @@ class ProviderRegistry: try: 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 196bfb1..f2fd29c 100644 --- a/lib/streaming_providers/base/settings/__init__.py +++ b/lib/streaming_providers/base/settings/__init__.py @@ -1,11 +1,16 @@ # streaming_providers/base/settings/__init__.py from .kodi_settings_bridge import KodiSettingsBridge +from .models.provider_settings import (ProviderSettingsSchema, + StandardProviderSettings) +from .models.settings_models import SettingType, SettingValue, ValidationRule from .settings_manager import UnifiedSettingsManager -from .models.settings_models import SettingValue, SettingType, ValidationRule -from .models.provider_settings import ProviderSettingsSchema, StandardProviderSettings __all__ = [ - 'KodiSettingsBridge', 'UnifiedSettingsManager', - 'SettingValue', 'SettingType', 'ValidationRule', - 'ProviderSettingsSchema', 'StandardProviderSettings' -] \ No newline at end of file + "KodiSettingsBridge", + "UnifiedSettingsManager", + "SettingValue", + "SettingType", + "ValidationRule", + "ProviderSettingsSchema", + "StandardProviderSettings", +] diff --git a/lib/streaming_providers/base/settings/kodi_settings_bridge.py b/lib/streaming_providers/base/settings/kodi_settings_bridge.py index 22972a1..631f611 100644 --- a/lib/streaming_providers/base/settings/kodi_settings_bridge.py +++ b/lib/streaming_providers/base/settings/kodi_settings_bridge.py @@ -1,21 +1,25 @@ # streaming_providers/base/settings/kodi_settings_bridge.py -from typing import Dict, List, Optional, Set, Tuple, Any import json import xml.etree.ElementTree as ElementTree +from typing import Any, Dict, List, Optional, Set, Tuple -from ..auth.credentials import BaseCredentials, UserPasswordCredentials, ClientCredentials +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.logger import logger -from ..utils.environment import get_environment_manager, is_kodi_environment, get_vfs_instance class KodiSettingsBridge: """Bridge between Kodi addon settings and internal configuration system""" # Markers that identify credential settings - CREDENTIAL_MARKERS = {'_username', '_password', '_client_id', '_client_secret'} + 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 @@ -32,12 +36,15 @@ class KodiSettingsBridge: if is_kodi_environment(): try: import xbmcaddon + if addon_id: self.addon = xbmcaddon.Addon(addon_id) else: self.addon = xbmcaddon.Addon() - self.addon_id = self.addon.getAddonInfo('id') - logger.info(f"Kodi settings bridge initialized for addon: {self.addon_id}") + self.addon_id = self.addon.getAddonInfo("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 @@ -53,7 +60,9 @@ 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}") @@ -62,7 +71,9 @@ 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}") @@ -100,21 +111,21 @@ class KodiSettingsBridge: if not self.is_kodi_environment(): # Return environment info in standalone mode return { - 'environment': 'standalone', - 'id': self._env_manager.get_config('addon_id', 'standalone'), - 'name': self._env_manager.get_config('addon_name', 'Ultimate Backend'), - 'version': self._env_manager.get_config('addon_version', '1.0.0'), - 'config_dir': self.vfs.base_path, - 'profile_path': self._env_manager.get_config('profile_path', ''), + "environment": "standalone", + "id": self._env_manager.get_config("addon_id", "standalone"), + "name": self._env_manager.get_config("addon_name", "Ultimate Backend"), + "version": self._env_manager.get_config("addon_version", "1.0.0"), + "config_dir": self.vfs.base_path, + "profile_path": self._env_manager.get_config("profile_path", ""), } return { - 'environment': 'kodi', - 'id': self.addon.getAddonInfo('id'), - 'name': self.addon.getAddonInfo('name'), - 'version': self.addon.getAddonInfo('version'), - 'author': self.addon.getAddonInfo('author'), - 'path': self.addon.getAddonInfo('path') + "environment": "kodi", + "id": self.addon.getAddonInfo("id"), + "name": self.addon.getAddonInfo("name"), + "version": self.addon.getAddonInfo("version"), + "author": self.addon.getAddonInfo("author"), + "path": self.addon.getAddonInfo("path"), } # ============= Dynamic Discovery ============= @@ -132,7 +143,7 @@ class KodiSettingsBridge: try: # Read settings.xml from VFS base path (addon profile directory) - xml_content = self.vfs.read_text('settings.xml') + xml_content = self.vfs.read_text("settings.xml") if not xml_content: logger.warning("settings.xml not found or empty") return [] @@ -141,8 +152,8 @@ class KodiSettingsBridge: # Extract all setting IDs setting_ids = [] - for setting in root.findall('.//setting'): - setting_id = setting.get('id') + for setting in root.findall(".//setting"): + setting_id = setting.get("id") if setting_id: setting_ids.append(setting_id) @@ -155,7 +166,9 @@ 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. @@ -181,10 +194,10 @@ class KodiSettingsBridge: return None # Remove the marker - prefix = setting_id[:-len(marker)] + prefix = setting_id[: -len(marker)] # Split by underscore - parts = prefix.split('_') + parts = prefix.split("_") if len(parts) == 1: # provider only @@ -265,7 +278,9 @@ class KodiSettingsBridge: return countries - def get_all_countries_for_provider(self, provider: str, available_countries: List[str]) -> List[str]: + def get_all_countries_for_provider( + self, provider: str, available_countries: List[str] + ) -> List[str]: """ Check which countries from a given list have credentials configured for a provider. @@ -281,7 +296,9 @@ class KodiSettingsBridge: # ============= Credential Operations ============= - def read_credentials_from_kodi(self, provider: str, country: Optional[str] = None) -> Optional[BaseCredentials]: + def read_credentials_from_kodi( + self, provider: str, country: Optional[str] = None + ) -> Optional[BaseCredentials]: """ Read authentication credentials from settings for a provider. Uses convention: {provider}_{country}_username, {provider}_{country}_password, etc. @@ -292,10 +309,12 @@ class KodiSettingsBridge: try: # Use unified get_setting method - 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') + 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" + ) logger.debug(f"Settings for {provider}{country_suffix}:") logger.debug(f" username: '{username}' (empty={not username})") @@ -305,17 +324,18 @@ 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(), - client_id=client_id.strip() if client_id else None + client_id=client_id.strip() if client_id else None, ) elif client_id and client_secret: logger.info(f"Found client credentials for {provider}{country_suffix}") return ClientCredentials( - client_id=client_id.strip(), - client_secret=client_secret.strip() + client_id=client_id.strip(), client_secret=client_secret.strip() ) logger.debug(f"No valid credentials found for {provider}{country_suffix}") @@ -325,23 +345,37 @@ class KodiSettingsBridge: logger.error(f"Error reading credentials for {provider}: {e}") return None - def write_credentials_to_kodi(self, provider: str, credentials: BaseCredentials, - country: Optional[str] = None) -> bool: + def write_credentials_to_kodi( + self, provider: str, credentials: BaseCredentials, country: Optional[str] = None + ) -> bool: """Write authentication credentials to settings""" country_suffix = f"_{country}" if country else "" 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_secret', credentials.client_secret) + self.set_setting( + f"{provider}{country_suffix}_client_id", credentials.client_id + ) + self.set_setting( + f"{provider}{country_suffix}_client_secret", + credentials.client_secret, + ) logger.info(f"Wrote client credentials for {provider}{country_suffix}") return True @@ -351,8 +385,9 @@ class KodiSettingsBridge: logger.error(f"Error writing credentials for {provider}: {e}") return False - def sync_credentials_to_file(self, provider: str, credential_manager, - country: Optional[str] = None) -> bool: + def sync_credentials_to_file( + self, provider: str, credential_manager, country: Optional[str] = None + ) -> bool: """Sync provider credentials from settings to credential file""" credentials = self.read_credentials_from_kodi(provider, country) if not credentials: @@ -372,7 +407,9 @@ class KodiSettingsBridge: # ============= Proxy Operations ============= - def read_proxy_config_from_kodi(self, provider: str, country: Optional[str] = None) -> Optional[ProxyConfig]: + def read_proxy_config_from_kodi( + self, provider: str, country: Optional[str] = None + ) -> Optional[ProxyConfig]: """ Read proxy configuration from settings for a provider. Uses convention: {provider}_{country}_proxy_enabled, {provider}_{country}_proxy_host, etc. @@ -381,49 +418,64 @@ 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']: + if not proxy_enabled or proxy_enabled.lower() not in ["true", "1", "yes"]: logger.debug(f"Proxy not enabled for {provider}{country_suffix}") return None - proxy_host = self.get_setting(f'{provider}{country_suffix}_proxy_host') - proxy_port_str = self.get_setting(f'{provider}{country_suffix}_proxy_port') + proxy_host = self.get_setting(f"{provider}{country_suffix}_proxy_host") + proxy_port_str = self.get_setting(f"{provider}{country_suffix}_proxy_port") logger.debug(f"Proxy settings for {provider}{country_suffix}:") logger.debug(f" host: '{proxy_host}'") 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: proxy_port = int(proxy_port_str) except ValueError: - logger.error(f"Invalid proxy port '{proxy_port_str}' for {provider}{country_suffix}") + logger.error( + f"Invalid proxy port '{proxy_port_str}' for {provider}{country_suffix}" + ) return None # Create proxy config proxy_config = ProxyConfig(host=proxy_host.strip(), port=proxy_port) - logger.info(f"Found proxy config for {provider}{country_suffix}: {proxy_host}:{proxy_port}") + logger.info( + f"Found proxy config for {provider}{country_suffix}: {proxy_host}:{proxy_port}" + ) return proxy_config except Exception as e: logger.error(f"Error reading proxy config for {provider}: {e}") return None - def write_proxy_config_to_kodi(self, provider: str, proxy_config: ProxyConfig, - country: Optional[str] = None) -> bool: + def write_proxy_config_to_kodi( + self, provider: str, proxy_config: ProxyConfig, country: Optional[str] = None + ) -> bool: """Write proxy configuration to settings""" country_suffix = f"_{country}" if country else "" 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_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) + ) logger.info(f"Wrote proxy config for {provider}{country_suffix}") return True @@ -432,8 +484,9 @@ class KodiSettingsBridge: logger.error(f"Error writing proxy config for {provider}: {e}") return False - def sync_proxy_config_to_file(self, provider: str, proxy_manager, - country: Optional[str] = None) -> bool: + def sync_proxy_config_to_file( + self, provider: str, proxy_manager, country: Optional[str] = None + ) -> bool: """Sync provider proxy config from settings to proxy config file""" proxy_config = self.read_proxy_config_from_kodi(provider, country) if not proxy_config: @@ -451,7 +504,9 @@ class KodiSettingsBridge: logger.info(f"Synced proxy config from settings to file for {provider}") return success - def read_ip_address_from_kodi(self, provider: str, country: Optional[str] = None) -> Optional[str]: + def read_ip_address_from_kodi( + self, provider: str, country: Optional[str] = None + ) -> Optional[str]: """ Read IP address from settings for a provider. Uses convention: {provider}_{country}_ipaddress or {provider}_ipaddress @@ -466,12 +521,16 @@ class KodiSettingsBridge: country_suffix = f"_{country}" if country else "" try: - ip_address = self.get_setting(f'{provider}{country_suffix}_ipaddress') + 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(f"Found configured IP address for {provider}{country_suffix}: {ip_address}") + logger.info( + f"Found configured IP address for {provider}{country_suffix}: {ip_address}" + ) return ip_address.strip() logger.debug(f"No IP address configured for {provider}{country_suffix}") @@ -484,7 +543,9 @@ class KodiSettingsBridge: # ============= Comparison Helpers ============= @staticmethod - def _credentials_equal(cred1: Optional[BaseCredentials], cred2: Optional[BaseCredentials]) -> bool: + def _credentials_equal( + cred1: Optional[BaseCredentials], cred2: Optional[BaseCredentials] + ) -> bool: """Compare two credentials for equality""" if cred1 is None and cred2 is None: return True @@ -493,18 +554,28 @@ class KodiSettingsBridge: if type(cred1) != type(cred2): return False - if isinstance(cred1, UserPasswordCredentials) and isinstance(cred2, UserPasswordCredentials): - return (cred1.username == cred2.username 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) + if isinstance(cred1, UserPasswordCredentials) and isinstance( + cred2, UserPasswordCredentials + ): + return ( + cred1.username == cred2.username + 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 + ) 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 @@ -512,24 +583,30 @@ class KodiSettingsBridge: return False # Compare basic properties - if (proxy1.host != proxy2.host or - proxy1.port != proxy2.port or - proxy1.proxy_type != proxy2.proxy_type): + if ( + proxy1.host != proxy2.host + or proxy1.port != proxy2.port + or proxy1.proxy_type != proxy2.proxy_type + ): return False # Compare authentication if (proxy1.auth is None) != (proxy2.auth is None): return False if proxy1.auth and proxy2.auth: - if (proxy1.auth.username != proxy2.auth.username or - proxy1.auth.password != proxy2.auth.password): + if ( + proxy1.auth.username != proxy2.auth.username + or proxy1.auth.password != proxy2.auth.password + ): return False # Compare scope - if (proxy1.scope.api_calls != proxy2.scope.api_calls or - proxy1.scope.authentication != proxy2.scope.authentication or - proxy1.scope.manifests != proxy2.scope.manifests or - proxy1.scope.license != proxy2.scope.license): + if ( + proxy1.scope.api_calls != proxy2.scope.api_calls + or proxy1.scope.authentication != proxy2.scope.authentication + or proxy1.scope.manifests != proxy2.scope.manifests + or proxy1.scope.license != proxy2.scope.license + ): return False return True @@ -537,17 +614,17 @@ class KodiSettingsBridge: def debug_info(self) -> Dict[str, Any]: """Get debug information about the settings bridge""" info = { - 'environment': 'kodi' if self.is_kodi_environment() else 'standalone', - 'addon_info': self.get_addon_info(), - 'has_addon': self.addon is not None, - 'standalone_settings_count': len(self._standalone_settings), - 'vfs_base_path': self.vfs.base_path, + "environment": "kodi" if self.is_kodi_environment() else "standalone", + "addon_info": self.get_addon_info(), + "has_addon": self.addon is not None, + "standalone_settings_count": len(self._standalone_settings), + "vfs_base_path": self.vfs.base_path, } if self.is_kodi_environment(): - info['addon_id'] = self.addon_id - info['kodi_available'] = True + info["addon_id"] = self.addon_id + info["kodi_available"] = True else: - info['kodi_available'] = False + info["kodi_available"] = False - return info \ No newline at end of file + return info diff --git a/lib/streaming_providers/base/settings/models/__init__.py b/lib/streaming_providers/base/settings/models/__init__.py index c9bd176..b13c54b 100644 --- a/lib/streaming_providers/base/settings/models/__init__.py +++ b/lib/streaming_providers/base/settings/models/__init__.py @@ -1,42 +1,27 @@ # streaming_providers/base/settings/models/__init__.py -from .settings_models import ( - SettingType, - ValidationRule, - StandardValidationRules, - SettingValue, - SettingValueBuilder, - string_setting, - password_setting, - integer_setting, - boolean_setting, - select_setting, - url_setting, - port_setting, - ip_setting -) - -from .provider_settings import ( - ProviderSettingsSchema, - StandardProviderSettings -) +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) __all__ = [ # From settings_models.py - 'SettingType', - 'ValidationRule', - 'StandardValidationRules', - 'SettingValue', - 'SettingValueBuilder', - 'string_setting', - 'password_setting', - 'integer_setting', - 'boolean_setting', - 'select_setting', - 'url_setting', - 'port_setting', - 'ip_setting', - + "SettingType", + "ValidationRule", + "StandardValidationRules", + "SettingValue", + "SettingValueBuilder", + "string_setting", + "password_setting", + "integer_setting", + "boolean_setting", + "select_setting", + "url_setting", + "port_setting", + "ip_setting", # From provider_settings.py - 'ProviderSettingsSchema', - 'StandardProviderSettings' + "ProviderSettingsSchema", + "StandardProviderSettings", ] diff --git a/lib/streaming_providers/base/settings/models/provider_settings.py b/lib/streaming_providers/base/settings/models/provider_settings.py index 4bcfc1d..bc810f2 100644 --- a/lib/streaming_providers/base/settings/models/provider_settings.py +++ b/lib/streaming_providers/base/settings/models/provider_settings.py @@ -1,17 +1,17 @@ # streaming_providers/base/settings/models/provider_settings.py -from typing import Dict, List, Optional, Any, Set from dataclasses import dataclass, field -from .settings_models import ( - SettingValue, - string_setting, password_setting, integer_setting, boolean_setting, - select_setting, port_setting -) +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) @dataclass class ProviderSettingsSchema: """Schema definition for a provider's settings""" + provider_name: str _settings: Dict[str, SettingValue] = field(default_factory=dict) _categories: Dict[str, Set[str]] = field(default_factory=dict) @@ -39,11 +39,11 @@ class ProviderSettingsSchema: # Build category mappings self._categories = { - 'credentials': set(credential_settings.keys()), - 'proxy': set(proxy_settings.keys()), - 'video': set(video_settings.keys()), - 'drm': set(drm_settings.keys()), - 'network': set(network_settings.keys()) + "credentials": set(credential_settings.keys()), + "proxy": set(proxy_settings.keys()), + "video": set(video_settings.keys()), + "drm": set(drm_settings.keys()), + "network": set(network_settings.keys()), } # Build Kodi mapping @@ -60,7 +60,7 @@ class ProviderSettingsSchema: settings = {} # Common credential settings - can be overridden in subclasses - settings['username'] = ( + settings["username"] = ( string_setting() .required() .min_length(1) @@ -70,7 +70,7 @@ class ProviderSettingsSchema: .build() ) - settings['password'] = ( + settings["password"] = ( password_setting() .display_name("Password") .description("Account password") @@ -79,7 +79,7 @@ class ProviderSettingsSchema: ) # Optional client ID for OAuth providers - settings['client_id'] = ( + settings["client_id"] = ( string_setting() .display_name("Client ID") .description("OAuth client identifier (if required)") @@ -88,7 +88,7 @@ class ProviderSettingsSchema: ) # Optional client secret for OAuth providers - settings['client_secret'] = ( + settings["client_secret"] = ( password_setting(required=False) .display_name("Client Secret") .description("OAuth client secret (if required)") @@ -103,7 +103,7 @@ class ProviderSettingsSchema: settings = {} # Enable proxy for this provider - settings['proxy_enabled'] = ( + settings["proxy_enabled"] = ( boolean_setting(False) .display_name("Enable Proxy") .description("Use proxy for this provider") @@ -112,7 +112,7 @@ class ProviderSettingsSchema: ) # Proxy host - settings['proxy_host'] = ( + settings["proxy_host"] = ( string_setting() .display_name("Proxy Host") .description("Proxy server hostname or IP address") @@ -121,7 +121,7 @@ class ProviderSettingsSchema: ) # Proxy port - settings['proxy_port'] = ( + settings["proxy_port"] = ( port_setting(8080) .display_name("Proxy Port") .description("Proxy server port") @@ -130,8 +130,8 @@ class ProviderSettingsSchema: ) # Proxy type - settings['proxy_type'] = ( - select_setting(['http', 'https', 'socks4', 'socks5'], 'http') + settings["proxy_type"] = ( + select_setting(["http", "https", "socks4", "socks5"], "http") .display_name("Proxy Type") .description("Type of proxy server") .kodi_setting(f"{self.provider_name}_proxy_type") @@ -139,7 +139,7 @@ class ProviderSettingsSchema: ) # Proxy authentication - settings['proxy_auth_enabled'] = ( + settings["proxy_auth_enabled"] = ( boolean_setting(False) .display_name("Proxy Authentication") .description("Proxy server requires authentication") @@ -148,7 +148,7 @@ class ProviderSettingsSchema: ) # Proxy username - settings['proxy_username'] = ( + settings["proxy_username"] = ( string_setting() .display_name("Proxy Username") .description("Username for proxy authentication") @@ -157,7 +157,7 @@ class ProviderSettingsSchema: ) # Proxy password - settings['proxy_password'] = ( + settings["proxy_password"] = ( password_setting(required=False) .display_name("Proxy Password") .description("Password for proxy authentication") @@ -166,7 +166,7 @@ class ProviderSettingsSchema: ) # Proxy scope settings - settings['proxy_scope_api'] = ( + settings["proxy_scope_api"] = ( boolean_setting(True) .display_name("Proxy API Calls") .description("Use proxy for API requests") @@ -174,7 +174,7 @@ class ProviderSettingsSchema: .build() ) - settings['proxy_scope_auth'] = ( + settings["proxy_scope_auth"] = ( boolean_setting(True) .display_name("Proxy Authentication") .description("Use proxy for authentication requests") @@ -182,7 +182,7 @@ class ProviderSettingsSchema: .build() ) - settings['proxy_scope_manifest'] = ( + settings["proxy_scope_manifest"] = ( boolean_setting(True) .display_name("Proxy Manifests") .description("Use proxy for manifest downloads") @@ -190,7 +190,7 @@ class ProviderSettingsSchema: .build() ) - settings['proxy_scope_license'] = ( + settings["proxy_scope_license"] = ( boolean_setting(True) .display_name("Proxy DRM Licenses") .description("Use proxy for DRM license requests") @@ -205,8 +205,8 @@ class ProviderSettingsSchema: settings = {} # Video quality preference - settings['video_quality'] = ( - select_setting(['best', 'worst', '720p', '1080p', '4k'], 'best') + settings["video_quality"] = ( + select_setting(["best", "worst", "720p", "1080p", "4k"], "best") .display_name("Video Quality") .description("Preferred video quality") .kodi_setting(f"{self.provider_name}_video_quality") @@ -214,8 +214,8 @@ class ProviderSettingsSchema: ) # Audio language preference - settings['audio_language'] = ( - select_setting(['de', 'en', 'original'], 'de') + settings["audio_language"] = ( + select_setting(["de", "en", "original"], "de") .display_name("Audio Language") .description("Preferred audio language") .kodi_setting(f"{self.provider_name}_audio_language") @@ -223,8 +223,8 @@ class ProviderSettingsSchema: ) # Subtitle language preference - settings['subtitle_language'] = ( - select_setting(['none', 'de', 'en', 'auto'], 'none') + settings["subtitle_language"] = ( + select_setting(["none", "de", "en", "auto"], "none") .display_name("Subtitle Language") .description("Preferred subtitle language") .kodi_setting(f"{self.provider_name}_subtitle_language") @@ -232,7 +232,7 @@ class ProviderSettingsSchema: ) # Speed up streams - settings['speed_up'] = ( + settings["speed_up"] = ( boolean_setting(True) .display_name("Speed Up Playback") .description("Enable faster stream startup") @@ -247,7 +247,7 @@ class ProviderSettingsSchema: settings = {} # CDM usage - settings['use_cdm'] = ( + settings["use_cdm"] = ( boolean_setting(True) .display_name("Use CDM") .description("Enable Content Decryption Module for DRM") @@ -256,8 +256,8 @@ class ProviderSettingsSchema: ) # CDM type - settings['cdm_type'] = ( - select_setting(['widevine', 'playready', 'clearkey'], 'widevine') + settings["cdm_type"] = ( + select_setting(["widevine", "playready", "clearkey"], "widevine") .display_name("CDM Type") .description("Type of Content Decryption Module") .kodi_setting(f"{self.provider_name}_cdm_type") @@ -265,8 +265,8 @@ class ProviderSettingsSchema: ) # CDM mode - settings['cdm_mode'] = ( - select_setting(['external', 'internal', 'auto'], 'external') + settings["cdm_mode"] = ( + select_setting(["external", "internal", "auto"], "external") .display_name("CDM Mode") .description("How to handle CDM operations") .kodi_setting(f"{self.provider_name}_cdm_mode") @@ -280,7 +280,7 @@ class ProviderSettingsSchema: settings = {} # Request timeout - settings['request_timeout'] = ( + settings["request_timeout"] = ( integer_setting(30, 5, 120) .display_name("Request Timeout") .description("Timeout for network requests in seconds") @@ -289,7 +289,7 @@ class ProviderSettingsSchema: ) # Max retries - settings['max_retries'] = ( + settings["max_retries"] = ( integer_setting(3, 0, 10) .display_name("Max Retries") .description("Maximum number of retry attempts") @@ -298,7 +298,7 @@ class ProviderSettingsSchema: ) # Retry delay - settings['retry_delay'] = ( + settings["retry_delay"] = ( integer_setting(1, 0, 10) .display_name("Retry Delay") .description("Delay between retry attempts in seconds") @@ -307,7 +307,7 @@ class ProviderSettingsSchema: ) # Verify SSL - settings['verify_ssl'] = ( + settings["verify_ssl"] = ( boolean_setting(True) .display_name("Verify SSL") .description("Verify SSL certificates for HTTPS requests") @@ -361,7 +361,9 @@ 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 @@ -390,34 +392,50 @@ class ProviderSettingsSchema: completeness_by_category = {} for category in self.get_categories(): category_settings = self.get_settings_by_category(category) - category_required = [name for name in category_settings.keys() if name in required_settings] - category_incomplete = [name for name in category_settings.keys() if name in incomplete_settings] + category_required = [ + name for name in category_settings.keys() if name in required_settings + ] + category_incomplete = [ + name for name in category_settings.keys() if name in incomplete_settings + ] completeness_by_category[category] = { - 'total_settings': len(category_settings), - 'required_settings': len(category_required), - 'incomplete_settings': len(category_incomplete), - 'is_complete': len(category_incomplete) == 0, - 'completion_percentage': ( - 100.0 if len(category_required) == 0 - else ((len(category_required) - len(category_incomplete)) / len(category_required) * 100) - ) + "total_settings": len(category_settings), + "required_settings": len(category_required), + "incomplete_settings": len(category_incomplete), + "is_complete": len(category_incomplete) == 0, + "completion_percentage": ( + 100.0 + if len(category_required) == 0 + else ( + (len(category_required) - len(category_incomplete)) + / len(category_required) + * 100 + ) + ), } return { - 'is_complete': self.is_configuration_complete(), - 'total_settings': len(self._settings), - 'required_settings': len(required_settings), - 'incomplete_settings': len(incomplete_settings), - 'completion_percentage': ( - 100.0 if len(required_settings) == 0 - else ((len(required_settings) - len(incomplete_settings)) / len(required_settings) * 100) + "is_complete": self.is_configuration_complete(), + "total_settings": len(self._settings), + "required_settings": len(required_settings), + "incomplete_settings": len(incomplete_settings), + "completion_percentage": ( + 100.0 + if len(required_settings) == 0 + else ( + (len(required_settings) - len(incomplete_settings)) + / len(required_settings) + * 100 + ) ), - 'categories': completeness_by_category, - 'incomplete_setting_names': incomplete_settings + "categories": completeness_by_category, + "incomplete_setting_names": incomplete_settings, } - def add_custom_setting(self, setting_name: str, setting: SettingValue, category: str = 'custom') -> None: + def add_custom_setting( + self, setting_name: str, setting: SettingValue, category: str = "custom" + ) -> None: """Add a custom setting to the schema""" self._settings[setting_name] = setting @@ -449,17 +467,16 @@ class ProviderSettingsSchema: def to_dict(self) -> Dict[str, Any]: """Convert schema to dictionary representation""" return { - 'provider_name': self.provider_name, - 'settings': { - name: setting.to_dict() - for name, setting in self._settings.items() + "provider_name": self.provider_name, + "settings": { + name: setting.to_dict() for name, setting in self._settings.items() }, - 'categories': { + "categories": { category: list(settings) for category, settings in self._categories.items() }, - 'kodi_mapping': self._kodi_mapping.copy(), - 'configuration_status': self.get_configuration_completeness() + "kodi_mapping": self._kodi_mapping.copy(), + "configuration_status": self.get_configuration_completeness(), } @@ -471,102 +488,108 @@ class StandardProviderSettings: @classmethod def get_rtlplus_schema(cls) -> ProviderSettingsSchema: """Get RTL Plus settings schema""" - if 'rtlplus' not in cls._registered_schemas: - schema = ProviderSettingsSchema('rtlplus') + if "rtlplus" not in cls._registered_schemas: + schema = ProviderSettingsSchema("rtlplus") # RTL Plus specific customizations # Override default credential settings for RTL Plus specifics - schema._settings['username'].description = "RTL Plus account email address" + schema._settings["username"].description = "RTL Plus account email address" # Add RTL Plus specific settings schema.add_custom_setting( - 'device_id', + "device_id", string_setting() .display_name("Device ID") .description("Unique device identifier for RTL Plus") .kodi_setting("rtlplus_device_id") .build(), - 'credentials' + "credentials", ) - cls._registered_schemas['rtlplus'] = schema + cls._registered_schemas["rtlplus"] = schema - return cls._registered_schemas['rtlplus'] + return cls._registered_schemas["rtlplus"] @classmethod def get_joyn_schema(cls) -> ProviderSettingsSchema: """Get Joyn settings schema""" - if 'joyn' not in cls._registered_schemas: - schema = ProviderSettingsSchema('joyn') + if "joyn" not in cls._registered_schemas: + schema = ProviderSettingsSchema("joyn") # Joyn doesn't require authentication for basic content - schema._settings['username'].validation_rules = [ - rule for rule in schema._settings['username'].validation_rules + schema._settings["username"].validation_rules = [ + rule + for rule in schema._settings["username"].validation_rules if rule.name != "not_empty" ] - schema._settings['password'].validation_rules = [ - rule for rule in schema._settings['password'].validation_rules + schema._settings["password"].validation_rules = [ + rule + for rule in schema._settings["password"].validation_rules if rule.name != "not_empty" ] - cls._registered_schemas['joyn'] = schema + cls._registered_schemas["joyn"] = schema - return cls._registered_schemas['joyn'] + return cls._registered_schemas["joyn"] @classmethod def get_zdf_schema(cls) -> ProviderSettingsSchema: """Get ZDF settings schema""" - if 'zdf' not in cls._registered_schemas: - schema = ProviderSettingsSchema('zdf') + if "zdf" not in cls._registered_schemas: + schema = ProviderSettingsSchema("zdf") # ZDF is free, no authentication required - schema.remove_setting('username') - schema.remove_setting('password') - schema.remove_setting('client_id') - schema.remove_setting('client_secret') + schema.remove_setting("username") + schema.remove_setting("password") + schema.remove_setting("client_id") + schema.remove_setting("client_secret") # Add ZDF specific settings schema.add_custom_setting( - 'geo_location', - select_setting(['DE', 'AT', 'CH'], 'DE') + "geo_location", + select_setting(["DE", "AT", "CH"], "DE") .display_name("Geographic Location") .description("Your geographic location for content filtering") .kodi_setting("zdf_geo_location") .build(), - 'network' + "network", ) - cls._registered_schemas['zdf'] = schema + cls._registered_schemas["zdf"] = schema - return cls._registered_schemas['zdf'] + return cls._registered_schemas["zdf"] @classmethod def get_ard_schema(cls) -> ProviderSettingsSchema: """Get ARD settings schema""" - if 'ard' not in cls._registered_schemas: - schema = ProviderSettingsSchema('ard') + if "ard" not in cls._registered_schemas: + schema = ProviderSettingsSchema("ard") # ARD is free, no authentication required - schema.remove_setting('username') - schema.remove_setting('password') - schema.remove_setting('client_id') - schema.remove_setting('client_secret') + schema.remove_setting("username") + schema.remove_setting("password") + schema.remove_setting("client_id") + schema.remove_setting("client_secret") - cls._registered_schemas['ard'] = schema + cls._registered_schemas["ard"] = schema - return cls._registered_schemas['ard'] + 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' + method_name = f"get_{provider_name}_schema" if hasattr(cls, method_name): return getattr(cls, method_name)() @@ -586,11 +609,17 @@ class StandardProviderSettings: # Include both registered schemas and built-in methods builtin_providers = [] for attr_name in dir(cls): - if attr_name.startswith('get_') and attr_name.endswith('_schema') and attr_name != 'get_provider_schema': + if ( + attr_name.startswith("get_") + and attr_name.endswith("_schema") + and attr_name != "get_provider_schema" + ): 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 d4d7ea3..de237fe 100644 --- a/lib/streaming_providers/base/settings/models/settings_models.py +++ b/lib/streaming_providers/base/settings/models/settings_models.py @@ -1,14 +1,15 @@ # streaming_providers/base/settings/models/settings_models.py -from dataclasses import dataclass, field -from typing import Any, Optional, Callable, Union, List, Dict -from enum import Enum -import re import ipaddress +import re +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Callable, Dict, List, Optional, Union from urllib.parse import urlparse class SettingType(Enum): """Supported setting value types""" + STRING = "string" INTEGER = "integer" BOOLEAN = "boolean" @@ -24,6 +25,7 @@ class SettingType(Enum): @dataclass class ValidationRule: """Validation rule for a setting value""" + name: str validator: Callable[[Any], bool] error_message: str @@ -49,7 +51,7 @@ class StandardValidationRules: return ValidationRule( name="not_empty", validator=lambda x: x is not None and str(x).strip() != "", - error_message=error_msg + error_message=error_msg, ) @staticmethod @@ -60,7 +62,7 @@ class StandardValidationRules: return ValidationRule( name="min_length", validator=lambda x: isinstance(x, str) and len(x) >= min_len, - error_message=error_msg + error_message=error_msg, ) @staticmethod @@ -71,19 +73,22 @@ class StandardValidationRules: return ValidationRule( name="max_length", validator=lambda x: isinstance(x, str) and len(x) <= max_len, - error_message=error_msg + error_message=error_msg, ) @staticmethod - def numeric_range(min_val: Union[int, float], max_val: Union[int, float], - error_msg: Optional[str] = None) -> ValidationRule: + def numeric_range( + min_val: Union[int, float], + max_val: Union[int, float], + error_msg: Optional[str] = None, + ) -> ValidationRule: """Rule to ensure numeric value is within range""" if error_msg is None: error_msg = f"Value must be between {min_val} and {max_val}" return ValidationRule( name="numeric_range", validator=lambda x: isinstance(x, (int, float)) and min_val <= x <= max_val, - error_message=error_msg + error_message=error_msg, ) @staticmethod @@ -93,13 +98,13 @@ class StandardValidationRules: return ValidationRule( name="regex_pattern", validator=lambda x: isinstance(x, str) and bool(compiled_pattern.match(x)), - error_message=error_msg + error_message=error_msg, ) @staticmethod def valid_email(error_msg: str = "Invalid email address format") -> ValidationRule: """Rule to validate email address""" - email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" return StandardValidationRules.regex_pattern(email_pattern, error_msg) @staticmethod @@ -116,13 +121,13 @@ class StandardValidationRules: return False return ValidationRule( - name="valid_url", - validator=validate_url, - error_message=error_msg + name="valid_url", validator=validate_url, error_message=error_msg ) @staticmethod - def valid_ip_address(error_msg: str = "Invalid IP address format") -> ValidationRule: + def valid_ip_address( + error_msg: str = "Invalid IP address format", + ) -> ValidationRule: """Rule to validate IP address (IPv4 or IPv6)""" def validate_ip(value: str) -> bool: @@ -135,35 +140,36 @@ class StandardValidationRules: return False return ValidationRule( - name="valid_ip_address", - validator=validate_ip, - error_message=error_msg + name="valid_ip_address", validator=validate_ip, error_message=error_msg ) @staticmethod - def valid_port(error_msg: str = "Port must be between 1 and 65535") -> ValidationRule: + def valid_port( + error_msg: str = "Port must be between 1 and 65535", + ) -> ValidationRule: """Rule to validate port number""" return ValidationRule( name="valid_port", validator=lambda x: isinstance(x, int) and 1 <= x <= 65535, - error_message=error_msg + error_message=error_msg, ) @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))}" return ValidationRule( - name="in_choices", - validator=lambda x: x in choices, - error_message=error_msg + name="in_choices", validator=lambda x: x in choices, error_message=error_msg ) @dataclass class SettingValue: """Container for a setting value with metadata and validation""" + setting_type: SettingType default_value: Any = None current_value: Any = None @@ -185,9 +191,7 @@ class SettingValue: # Add choices validation for SELECT type if self.setting_type == SettingType.SELECT and self.choices: - self.add_validation_rule( - StandardValidationRules.in_choices(self.choices) - ) + self.add_validation_rule(StandardValidationRules.in_choices(self.choices)) def is_required(self) -> bool: """Check if this setting is required (has not_empty validation rule)""" @@ -196,25 +200,34 @@ class SettingValue: def _add_type_validation(self): """Add validation rules based on setting type""" if self.setting_type == SettingType.INTEGER: - self.add_validation_rule(ValidationRule( - name="is_integer", - validator=lambda x: isinstance(x, int) or (isinstance(x, str) and x.isdigit()), - error_message="Value must be an integer" - )) + self.add_validation_rule( + ValidationRule( + name="is_integer", + validator=lambda x: isinstance(x, int) + or (isinstance(x, str) and x.isdigit()), + error_message="Value must be an integer", + ) + ) elif self.setting_type == SettingType.FLOAT: - self.add_validation_rule(ValidationRule( - name="is_float", - validator=lambda x: isinstance(x, (int, float)) or self._is_valid_float_string(x), - error_message="Value must be a number" - )) + self.add_validation_rule( + ValidationRule( + name="is_float", + validator=lambda x: isinstance(x, (int, float)) + or self._is_valid_float_string(x), + error_message="Value must be a number", + ) + ) elif self.setting_type == SettingType.BOOLEAN: - self.add_validation_rule(ValidationRule( - name="is_boolean", - validator=lambda x: isinstance(x, bool) or str(x).lower() in ['true', 'false', '1', '0'], - error_message="Value must be true or false" - )) + self.add_validation_rule( + ValidationRule( + name="is_boolean", + validator=lambda x: isinstance(x, bool) + or str(x).lower() in ["true", "false", "1", "0"], + error_message="Value must be true or false", + ) + ) elif self.setting_type == SettingType.URL: self.add_validation_rule(StandardValidationRules.valid_url()) @@ -266,10 +279,16 @@ 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(): @@ -289,14 +308,18 @@ class SettingValue: if isinstance(value, bool): return value elif isinstance(value, str): - return value.lower() in ['true', '1', 'yes', 'on'] + return value.lower() in ["true", "1", "yes", "on"] elif isinstance(value, int): return bool(value) else: return None - elif self.setting_type in [SettingType.SELECT, SettingType.URL, - SettingType.IP_ADDRESS, SettingType.EMAIL]: + elif self.setting_type in [ + SettingType.SELECT, + SettingType.URL, + SettingType.IP_ADDRESS, + SettingType.EMAIL, + ]: return str(value) else: @@ -332,7 +355,9 @@ 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]]: @@ -344,7 +369,9 @@ 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: @@ -374,37 +401,37 @@ class SettingValue: is_valid, errors = self.validate() return { - 'setting_type': self.setting_type.value, - 'current_value': self.current_value, - 'default_value': self.default_value, - 'choices': self.choices, - 'description': self.description, - 'display_name': self.display_name, - 'is_sensitive': self.is_sensitive, - 'kodi_setting_id': self.kodi_setting_id, - 'is_valid': is_valid, - 'validation_errors': errors, - 'has_value': self.has_value(), - 'is_modified': self.is_modified(), - 'display_value': self.get_display_value() + "setting_type": self.setting_type.value, + "current_value": self.current_value, + "default_value": self.default_value, + "choices": self.choices, + "description": self.description, + "display_name": self.display_name, + "is_sensitive": self.is_sensitive, + "kodi_setting_id": self.kodi_setting_id, + "is_valid": is_valid, + "validation_errors": errors, + "has_value": self.has_value(), + "is_modified": self.is_modified(), + "display_value": self.get_display_value(), } @classmethod - def from_dict(cls, data: Dict[str, Any]) -> 'SettingValue': + def from_dict(cls, data: Dict[str, Any]) -> "SettingValue": """Create SettingValue from dictionary representation""" setting = cls( - setting_type=SettingType(data['setting_type']), - default_value=data.get('default_value'), - current_value=data.get('current_value'), - choices=data.get('choices'), - description=data.get('description'), - display_name=data.get('display_name'), - is_sensitive=data.get('is_sensitive', False), - kodi_setting_id=data.get('kodi_setting_id') + setting_type=SettingType(data["setting_type"]), + default_value=data.get("default_value"), + current_value=data.get("current_value"), + choices=data.get("choices"), + description=data.get("description"), + display_name=data.get("display_name"), + is_sensitive=data.get("is_sensitive", False), + kodi_setting_id=data.get("kodi_setting_id"), ) return setting - def clone(self) -> 'SettingValue': + def clone(self) -> "SettingValue": """Create a copy of this setting""" return SettingValue( setting_type=self.setting_type, @@ -415,7 +442,7 @@ class SettingValue: description=self.description, display_name=self.display_name, is_sensitive=self.is_sensitive, - kodi_setting_id=self.kodi_setting_id + kodi_setting_id=self.kodi_setting_id, ) @@ -432,57 +459,61 @@ class SettingValueBuilder: self._is_sensitive = False self._kodi_setting_id = None - def default(self, value: Any) -> 'SettingValueBuilder': + def default(self, value: Any) -> "SettingValueBuilder": """Set default value""" self._default_value = value return self - def choices(self, choices: List[Any]) -> 'SettingValueBuilder': + def choices(self, choices: List[Any]) -> "SettingValueBuilder": """Set choices for SELECT type""" self._choices = choices return self - def description(self, desc: str) -> 'SettingValueBuilder': + def description(self, desc: str) -> "SettingValueBuilder": """Set description""" self._description = desc return self - def display_name(self, name: str) -> 'SettingValueBuilder': + def display_name(self, name: str) -> "SettingValueBuilder": """Set display name""" self._display_name = name return self - def sensitive(self, is_sensitive: bool = True) -> 'SettingValueBuilder': + def sensitive(self, is_sensitive: bool = True) -> "SettingValueBuilder": """Mark as sensitive (for passwords, etc.)""" self._is_sensitive = is_sensitive return self - def kodi_setting(self, setting_id: str) -> 'SettingValueBuilder': + def kodi_setting(self, setting_id: str) -> "SettingValueBuilder": """Set Kodi setting ID mapping""" self._kodi_setting_id = setting_id return self - def required(self) -> 'SettingValueBuilder': + def required(self) -> "SettingValueBuilder": """Mark as required (not empty)""" self._validation_rules.append(StandardValidationRules.not_empty()) return self - def min_length(self, length: int) -> 'SettingValueBuilder': + def min_length(self, length: int) -> "SettingValueBuilder": """Add minimum length validation""" self._validation_rules.append(StandardValidationRules.min_length(length)) return self - def max_length(self, length: int) -> 'SettingValueBuilder': + def max_length(self, length: int) -> "SettingValueBuilder": """Add maximum length validation""" self._validation_rules.append(StandardValidationRules.max_length(length)) return self - def numeric_range(self, min_val: Union[int, float], max_val: Union[int, float]) -> 'SettingValueBuilder': + def numeric_range( + 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': + def custom_validation(self, rule: ValidationRule) -> "SettingValueBuilder": """Add custom validation rule""" self._validation_rules.append(rule) return self @@ -496,7 +527,7 @@ class SettingValueBuilder: description=self._description, display_name=self._display_name, is_sensitive=self._is_sensitive, - kodi_setting_id=self._kodi_setting_id + kodi_setting_id=self._kodi_setting_id, ) # Add custom validation rules @@ -523,8 +554,9 @@ def password_setting(required: bool = True) -> SettingValueBuilder: return builder -def integer_setting(default: int = 0, min_val: Optional[int] = None, - max_val: Optional[int] = None) -> SettingValueBuilder: +def integer_setting( + default: int = 0, min_val: Optional[int] = None, max_val: Optional[int] = None +) -> SettingValueBuilder: """Create an integer setting builder""" builder = SettingValueBuilder(SettingType.INTEGER).default(default) if min_val is not None and max_val is not None: diff --git a/lib/streaming_providers/base/settings/provider_enable_manager.py b/lib/streaming_providers/base/settings/provider_enable_manager.py index d3497f9..00bf9f8 100644 --- a/lib/streaming_providers/base/settings/provider_enable_manager.py +++ b/lib/streaming_providers/base/settings/provider_enable_manager.py @@ -9,15 +9,16 @@ Works in both Kodi and standalone environments. """ import time -from typing import Dict, Optional, Tuple, Any from enum import Enum +from typing import Any, Dict, Optional, Tuple +from ..utils.environment import get_vfs_instance, is_kodi_environment from ..utils.logger import logger -from ..utils.environment import is_kodi_environment, get_vfs_instance class EnableSource(Enum): """Source of the enable setting""" + KODI = "kodi" FILE = "file" DEFAULT = "default" @@ -50,13 +51,16 @@ class ProviderEnableManager: try: # Fixed import path - kodi_settings_bridge is in the same directory from .kodi_settings_bridge import KodiSettingsBridge + self._kodi_bridge = KodiSettingsBridge(config_dir=config_dir) logger.debug("ProviderEnableManager: Kodi bridge initialized") except ImportError as e: 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]: @@ -72,20 +76,23 @@ 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 - default_data = { - "version": self.DEFAULT_VERSION, - "providers": {} - } + default_data = {"version": self.DEFAULT_VERSION, "providers": {}} # Check if file exists if not self.vfs.exists(self.DEFAULT_FILENAME): self._cache = default_data self._cache_time = current_time - logger.debug(f"ProviderEnableManager: File {self.DEFAULT_FILENAME} not found, using defaults") + logger.debug( + f"ProviderEnableManager: File {self.DEFAULT_FILENAME} not found, using defaults" + ) return default_data try: @@ -93,7 +100,9 @@ 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 @@ -111,7 +120,9 @@ class ProviderEnableManager: try: data["providers"][provider] = bool(value) except (ValueError, TypeError): - logger.warning(f"ProviderEnableManager: Invalid value for {provider}, removing") + logger.warning( + f"ProviderEnableManager: Invalid value for {provider}, removing" + ) del data["providers"][provider] # Add last_updated if missing @@ -122,11 +133,15 @@ class ProviderEnableManager: self._cache = data self._cache_time = current_time - logger.debug(f"ProviderEnableManager: Loaded {len(data['providers'])} providers from file") + logger.debug( + f"ProviderEnableManager: Loaded {len(data['providers'])} providers from file" + ) 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 @@ -154,14 +169,20 @@ class ProviderEnableManager: # Update cache self._cache = data self._cache_time = time.time() - logger.debug(f"ProviderEnableManager: Saved {len(data.get('providers', {}))} providers to file") + logger.debug( + 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]: @@ -179,7 +200,7 @@ class ProviderEnableManager: try: # Parse provider_name for potential country suffix - parts = provider_name.rsplit('_', 1) + parts = provider_name.rsplit("_", 1) # Check country-specific setting first (e.g., "enable_joyn_de") if len(parts) == 2 and len(parts[1]) in (2, 3): # Country code length @@ -189,8 +210,10 @@ class ProviderEnableManager: # Try country-specific setting country_value = self._kodi_bridge.get_setting(country_setting) if country_value: - enabled = country_value.lower() in ['true', '1', 'yes'] - logger.debug(f"ProviderEnableManager: Found Kodi setting {country_setting}={enabled}") + enabled = country_value.lower() in ["true", "1", "yes"] + logger.debug( + f"ProviderEnableManager: Found Kodi setting {country_setting}={enabled}" + ) return enabled # Try general provider setting (e.g., "enable_joyn") @@ -198,16 +221,22 @@ class ProviderEnableManager: general_value = self._kodi_bridge.get_setting(general_setting) if general_value: - enabled = general_value.lower() in ['true', '1', 'yes'] - logger.debug(f"ProviderEnableManager: Found Kodi setting {general_setting}={enabled}") + enabled = general_value.lower() in ["true", "1", "yes"] + logger.debug( + f"ProviderEnableManager: Found Kodi setting {general_setting}={enabled}" + ) 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: - logger.warning(f"ProviderEnableManager: Error reading Kodi setting for {provider_name}: {e}") + logger.warning( + f"ProviderEnableManager: Error reading Kodi setting for {provider_name}: {e}" + ) return None def is_provider_enabled(self, provider_name: str) -> bool: @@ -264,7 +293,9 @@ 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). @@ -282,8 +313,10 @@ class ProviderEnableManager: source = self.get_enabled_source(provider_name) if source == EnableSource.KODI: - message = f"Provider '{provider_name}' is controlled by Kodi settings. " \ - f"Change the setting in Kodi addon settings." + message = ( + f"Provider '{provider_name}' is controlled by Kodi settings. " + f"Change the setting in Kodi addon settings." + ) logger.warning(f"ProviderEnableManager: {message}") return False, message @@ -322,7 +355,7 @@ class ProviderEnableManager: result[provider_name] = { "enabled": enabled, "source": source.value, # Convert enum to string - "can_modify": source != EnableSource.KODI + "can_modify": source != EnableSource.KODI, } return result @@ -350,7 +383,7 @@ class ProviderEnableManager: "enabled": enabled, "source": source.value, # Convert enum to string "can_modify": source != EnableSource.KODI, - "in_file": True + "in_file": True, } def delete_provider_setting(self, provider_name: str) -> Tuple[bool, str]: @@ -367,7 +400,9 @@ 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 @@ -407,7 +442,7 @@ class ProviderEnableManager: default_data = { "version": self.DEFAULT_VERSION, "last_updated": time.time(), - "providers": {} + "providers": {}, } # Save empty file @@ -442,7 +477,11 @@ class ProviderEnableManager: "last_updated": data.get("last_updated"), "provider_count": len(data.get("providers", {})), "file_exists": self.vfs.exists(self.DEFAULT_FILENAME), - "file_path": self.vfs.get_file_path(self.DEFAULT_FILENAME) if hasattr(self.vfs, 'get_file_path') else "vfs" + "file_path": ( + self.vfs.get_file_path(self.DEFAULT_FILENAME) + if hasattr(self.vfs, "get_file_path") + else "vfs" + ), } def migrate_from_kodi(self) -> Tuple[bool, str, int]: @@ -476,13 +515,17 @@ 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: @@ -497,4 +540,4 @@ class ProviderEnableManager: except Exception as e: message = f"Error migrating from Kodi: {e}" logger.error(f"ProviderEnableManager: {message}") - return False, message, 0 \ No newline at end of file + return False, message, 0 diff --git a/lib/streaming_providers/base/settings/settings_manager.py b/lib/streaming_providers/base/settings/settings_manager.py index 3e2ffa7..c34b0f2 100644 --- a/lib/streaming_providers/base/settings/settings_manager.py +++ b/lib/streaming_providers/base/settings/settings_manager.py @@ -5,19 +5,19 @@ Supports country-specific credentials, sessions, and configurations """ import json -import time import os -from typing import Dict, Any, Optional, List, Tuple -from pathlib import Path +import time from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple -from ..auth.session_manager import SessionManager from ..auth.credential_manager import CredentialManager -from ..auth.credentials import BaseCredentials, ClientCredentials, UserPasswordCredentials -from ..network.proxy_manager import ProxyConfigManager +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 from ..utils.logger import logger -from typing import TYPE_CHECKING if TYPE_CHECKING: from .kodi_settings_bridge import KodiSettingsBridge @@ -26,12 +26,17 @@ if TYPE_CHECKING: @dataclass class ProviderRegistration: """Information about a registered provider""" + name: str 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 @@ -44,7 +49,9 @@ 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 @@ -57,6 +64,7 @@ class SettingsManager: # Initialize VFS for all file operations from ..utils.vfs import VFS + self.vfs = VFS(config_dir=config_dir) # Maintain config_path for backward compatibility @@ -66,7 +74,7 @@ class SettingsManager: # Let VFS handle default path resolution, but provide Path object if needed self.config_path = Path(self.vfs.base_path) if self.vfs.base_path else None - self.settings_file = 'unified_settings.json' + self.settings_file = "unified_settings.json" # Initialize component managers - pass the original config_dir self.session_manager = SessionManager(self.config_dir_path) @@ -75,7 +83,7 @@ class SettingsManager: # Initialize Kodi integration if enabled self.enable_kodi_integration = enable_kodi_integration - self.kodi_bridge: Optional['KodiSettingsBridge'] = None + self.kodi_bridge: Optional["KodiSettingsBridge"] = None if enable_kodi_integration: success = self._setup_kodi_integration() @@ -90,7 +98,11 @@ class SettingsManager: self._load_configuration() # Perform Kodi sync if bridge is available and in Kodi environment - if self.enable_kodi_integration and self.kodi_bridge and self.kodi_bridge.is_kodi_environment(): + if ( + self.enable_kodi_integration + and self.kodi_bridge + and self.kodi_bridge.is_kodi_environment() + ): logger.info("Performing initial sync from Kodi...") # First, detect all providers from Kodi (including unregistered ones) @@ -100,16 +112,22 @@ 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), - available_countries=countries if countries else [] + available_countries=countries if countries else [], ) else: # Provider already registered - check if we need to upgrade to multi-country - if countries and not self.provider_supports_countries(provider_name): - logger.info(f"Auto-upgrading existing provider '{provider_name}' to multi-country: {countries}") + if countries and not self.provider_supports_countries( + provider_name + ): + logger.info( + f"Auto-upgrading existing provider '{provider_name}' to multi-country: {countries}" + ) reg = self._registered_providers[provider_name] reg.supports_countries = True reg.available_countries = countries @@ -121,10 +139,14 @@ class SettingsManager: if self.provider_supports_countries(provider_name): # Multi-country provider - sync each country available_countries = self.get_provider_countries(provider_name) - logger.info(f"Syncing multi-country provider {provider_name} for countries: {available_countries}") + logger.info( + f"Syncing multi-country provider {provider_name} for countries: {available_countries}" + ) 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})...") @@ -137,7 +159,9 @@ 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""" @@ -173,24 +197,32 @@ class SettingsManager: data = self.vfs.read_json(self.settings_file) # Load provider registrations - providers_data = data.get('providers', {}) + providers_data = data.get("providers", {}) for provider_name, provider_info in providers_data.items(): try: registration = ProviderRegistration( name=provider_name, - 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', []) + 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", [] + ), ) self._registered_providers[provider_name] = registration - logger.debug(f"Loaded provider registration: {provider_name} " - f"(countries: {registration.supports_countries})") + logger.debug( + f"Loaded provider registration: {provider_name} " + f"(countries: {registration.supports_countries})" + ) 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}") @@ -199,20 +231,20 @@ class SettingsManager: """Save settings manager configuration to file using VFS""" try: data = { - 'version': '2.1', # Bumped version for country support - 'last_updated': time.time(), - 'kodi_integration_enabled': self.enable_kodi_integration, - 'providers': {} + "version": "2.1", # Bumped version for country support + "last_updated": time.time(), + "kodi_integration_enabled": self.enable_kodi_integration, + "providers": {}, } # Save provider registrations for name, reg in self._registered_providers.items(): - data['providers'][name] = { - 'registered_at': reg.registered_at, - 'is_active': reg.is_active, - 'settings_schema': reg.settings_schema, - 'supports_countries': reg.supports_countries, - 'available_countries': reg.available_countries + data["providers"][name] = { + "registered_at": reg.registered_at, + "is_active": reg.is_active, + "settings_schema": reg.settings_schema, + "supports_countries": reg.supports_countries, + "available_countries": reg.available_countries, } success = self.vfs.write_json(self.settings_file, data) @@ -229,7 +261,9 @@ class SettingsManager: # ============= Authentication Manager Interface ============= # These methods provide the clean interface that BaseAuthenticator expects - def get_provider_credentials(self, provider_name: str, country: Optional[str] = None) -> Optional[BaseCredentials]: + def get_provider_credentials( + self, provider_name: str, country: Optional[str] = None + ) -> Optional[BaseCredentials]: """ Get credentials for a provider with optional country and automatic Kodi sync @@ -241,35 +275,63 @@ 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}") - if hasattr(credentials, 'username'): - logger.debug(f"SettingsManager: File credentials username: {credentials.username}") + logger.debug( + f"SettingsManager: File credentials type: {credentials.credential_type}" + ) + if hasattr(credentials, "username"): + logger.debug( + f"SettingsManager: File credentials username: {credentials.username}" + ) else: - logger.debug(f"SettingsManager: File credentials class: {credentials.__class__.__name__}") + logger.debug( + f"SettingsManager: File credentials class: {credentials.__class__.__name__}" + ) else: - logger.debug(f"SettingsManager: No file credentials found for {provider_name}{country_str}") + logger.debug( + f"SettingsManager: No file credentials found for {provider_name}{country_str}" + ) # 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(): - logger.debug(f"SettingsManager: No file credentials for {provider_name}{country_str}, trying Kodi sync") + 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}") + logger.debug( + f"SettingsManager: Synced credentials type: {credentials.credential_type}" + ) - logger.debug(f"SettingsManager: Final credentials for {provider_name}{country_str}: {credentials is not None}") + logger.debug( + f"SettingsManager: Final credentials for {provider_name}{country_str}: {credentials is not None}" + ) return credentials - def save_provider_credentials(self, provider_name: str, credentials: BaseCredentials, - country: Optional[str] = None) -> bool: + def save_provider_credentials( + self, + provider_name: str, + credentials: BaseCredentials, + country: Optional[str] = None, + ) -> bool: """ Save credentials for a provider to both file and optionally Kodi @@ -282,23 +344,33 @@ 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 if self.kodi_bridge and self.kodi_bridge.is_kodi_environment(): - kodi_success = self.kodi_bridge.write_credentials_to_kodi(provider_name, credentials, country) + kodi_success = self.kodi_bridge.write_credentials_to_kodi( + 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 - def load_token_data(self, provider_name: str, country: Optional[str] = None) -> Optional[Dict[str, Any]]: + def load_token_data( + self, provider_name: str, country: Optional[str] = None + ) -> Optional[Dict[str, Any]]: """ Load token data for a provider @@ -311,8 +383,12 @@ class SettingsManager: """ return self.session_manager.load_token_data(provider_name, country) - def save_token_data(self, provider_name: str, token_data: Dict[str, Any], - country: Optional[str] = None) -> bool: + def save_token_data( + self, + provider_name: str, + token_data: Dict[str, Any], + country: Optional[str] = None, + ) -> bool: """ Save token data for a provider @@ -354,9 +430,13 @@ class SettingsManager: # ============= Provider Management ============= - def register_provider(self, provider_name: str, settings_schema: Optional[Dict[str, Any]] = None, - supports_countries: bool = False, - available_countries: Optional[List[str]] = None) -> bool: + def register_provider( + self, + provider_name: str, + settings_schema: Optional[Dict[str, Any]] = None, + supports_countries: bool = False, + available_countries: Optional[List[str]] = None, + ) -> bool: """ Register a provider with the settings manager @@ -375,7 +455,7 @@ class SettingsManager: is_active=True, settings_schema=settings_schema, # Keep for backward compat, but not used supports_countries=supports_countries, - available_countries=available_countries or [] + available_countries=available_countries or [], ) self._registered_providers[provider_name] = registration @@ -387,15 +467,23 @@ class SettingsManager: # Save configuration 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}") + 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 @@ -417,7 +505,9 @@ 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: @@ -441,11 +531,16 @@ 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""" - return provider_name in self._registered_providers and self._registered_providers[provider_name].is_active + return ( + provider_name in self._registered_providers + and self._registered_providers[provider_name].is_active + ) def get_provider_countries(self, provider_name: str) -> List[str]: """ @@ -480,7 +575,9 @@ class SettingsManager: # ============= Configuration Status ============= - def get_provider_status(self, provider_name: str, country: Optional[str] = None) -> Dict[str, Any]: + def get_provider_status( + self, provider_name: str, country: Optional[str] = None + ) -> Dict[str, Any]: """ Get comprehensive status for a provider @@ -493,11 +590,11 @@ class SettingsManager: """ if not self.is_provider_registered(provider_name): return { - 'provider_name': provider_name, - 'country': country, - 'is_registered': False, - 'is_enabled': self.is_provider_enabled(provider_name, country), - 'error': 'Provider not registered' + "provider_name": provider_name, + "country": country, + "is_registered": False, + "is_enabled": self.is_provider_enabled(provider_name, country), + "error": "Provider not registered", } registration = self._registered_providers[provider_name] @@ -516,38 +613,39 @@ class SettingsManager: has_proxy = proxy_config is not None status = { - 'provider_name': provider_name, - 'country': country, - 'is_registered': True, - 'is_enabled': self.is_provider_enabled(provider_name, country), - 'registered_at': registration.registered_at, - 'supports_countries': registration.supports_countries, - 'credentials': { - 'has_credentials': has_credentials, - 'credentials_valid': credentials_valid, - 'credential_type': credentials.credential_type if credentials else None + "provider_name": provider_name, + "country": country, + "is_registered": True, + "is_enabled": self.is_provider_enabled(provider_name, country), + "registered_at": registration.registered_at, + "supports_countries": registration.supports_countries, + "credentials": { + "has_credentials": has_credentials, + "credentials_valid": credentials_valid, + "credential_type": credentials.credential_type if credentials else None, }, - 'session': { - 'has_active_session': has_active_session, - 'device_id': self.get_device_id(provider_name, country) + "session": { + "has_active_session": has_active_session, + "device_id": self.get_device_id(provider_name, country), }, - 'proxy': { - 'has_proxy': has_proxy, - 'proxy_enabled': has_proxy + "proxy": {"has_proxy": has_proxy, "proxy_enabled": has_proxy}, + "kodi_integration": { + "enabled": self.enable_kodi_integration, + "environment": ( + self.kodi_bridge.is_kodi_environment() + if self.kodi_bridge + else False + ), }, - 'kodi_integration': { - 'enabled': self.enable_kodi_integration, - 'environment': self.kodi_bridge.is_kodi_environment() if self.kodi_bridge else False - } } # Add country-specific info if applicable if registration.supports_countries: - status['available_countries'] = registration.available_countries + status["available_countries"] = registration.available_countries # Get session data for all countries all_countries = self.session_manager.get_all_countries(provider_name) - status['configured_countries'] = all_countries + status["configured_countries"] = all_countries scoped_tokens = {} available_scopes = self.list_scoped_tokens(provider_name, country) @@ -557,11 +655,13 @@ class SettingsManager: scoped_tokens[scope] = scope_status if scoped_tokens: - status['scoped_tokens'] = scoped_tokens + status["scoped_tokens"] = scoped_tokens 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 @@ -574,12 +674,14 @@ class SettingsManager: """ status = self.get_provider_status(provider_name, country) - if not status.get('is_registered'): + if not status.get("is_registered"): return False # 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) + credentials_status = status.get("credentials", {}) + 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]: """ @@ -599,7 +701,9 @@ class SettingsManager: # ============= Kodi Integration ============= - def _sync_credentials_from_kodi(self, provider_name: str, country: Optional[str] = None) -> bool: + def _sync_credentials_from_kodi( + self, provider_name: str, country: Optional[str] = None + ) -> bool: """ Sync credentials from Kodi for a single provider @@ -611,43 +715,69 @@ 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(f"SettingsManager: Kodi bridge not available for {provider_name}{country_str}") + logger.debug( + f"SettingsManager: Kodi bridge not available for {provider_name}{country_str}" + ) 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(f"SettingsManager: No Kodi credentials found for {provider_name}{country_str}") + logger.debug( + f"SettingsManager: No Kodi credentials found for {provider_name}{country_str}" + ) return True # Not an error if not kodi_credentials.validate(): - logger.debug(f"SettingsManager: Invalid Kodi credentials for {provider_name}{country_str}") + logger.debug( + f"SettingsManager: Invalid Kodi credentials for {provider_name}{country_str}" + ) 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): - logger.debug(f"SettingsManager: Credentials already in sync for {provider_name}{country_str}") + if file_credentials and self._credentials_equal( + kodi_credentials, file_credentials + ): + logger.debug( + f"SettingsManager: Credentials already in sync for {provider_name}{country_str}" + ) return True # Save Kodi credentials to file - logger.info(f"SettingsManager: Saving credentials from Kodi for {provider_name}{country_str}") - success = self.credential_manager.save_credentials(provider_name, kodi_credentials, country) + logger.info( + f"SettingsManager: Saving credentials from Kodi for {provider_name}{country_str}" + ) + success = self.credential_manager.save_credentials( + provider_name, kodi_credentials, country + ) 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: - logger.error(f"Error syncing credentials from Kodi for {provider_name}{country_str}: {e}") + logger.error( + f"Error syncing credentials from Kodi for {provider_name}{country_str}: {e}" + ) return False def sync_all_from_kodi(self) -> Dict[str, bool]: @@ -672,16 +802,20 @@ class SettingsManager: self.register_provider( provider_name, supports_countries=bool(countries), - available_countries=countries if countries else [] + available_countries=countries if countries else [], ) # Sync based on whether it's multi-country or not 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) @@ -691,7 +825,9 @@ class SettingsManager: return results @staticmethod - def _credentials_equal(cred1: Optional[BaseCredentials], cred2: Optional[BaseCredentials]) -> bool: + def _credentials_equal( + cred1: Optional[BaseCredentials], cred2: Optional[BaseCredentials] + ) -> bool: """Compare two credentials for equality""" if cred1 is None and cred2 is None: return True @@ -701,85 +837,116 @@ class SettingsManager: return False # Use the credential objects' own comparison logic if available - if hasattr(cred1, '__eq__'): + if hasattr(cred1, "__eq__"): return cred1 == cred2 # Fallback to basic attribute comparison - return getattr(cred1, '__dict__', {}) == getattr(cred2, '__dict__', {}) + return getattr(cred1, "__dict__", {}) == getattr(cred2, "__dict__", {}) # ============= Proxy Configuration ============= - def get_provider_proxy(self, provider_name: str, country: Optional[str] = None) -> Optional[ProxyConfig]: + def get_provider_proxy( + self, provider_name: str, country: Optional[str] = None + ) -> Optional[ProxyConfig]: """Get proxy configuration for a provider with Kodi sync""" # Try proxy manager first 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(self, provider_name: str, proxy_config: ProxyConfig, - country: Optional[str] = None) -> bool: + def set_provider_proxy( + self, + provider_name: str, + proxy_config: ProxyConfig, + 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) - def get_provider_session_info(self, provider_name: str, country: Optional[str] = None) -> Dict[str, Any]: + def get_provider_session_info( + self, provider_name: str, country: Optional[str] = None + ) -> Dict[str, Any]: """Get session information for a provider""" session_data = self.session_manager.load_session(provider_name, country) if not session_data: return { - 'provider_name': provider_name, - 'country': country, - 'has_session': False + "provider_name": provider_name, + "country": country, + "has_session": False, } return { - 'provider_name': provider_name, - 'country': country, - 'has_session': True, - 'device_id': session_data.get('device_id'), - 'has_token': 'access_token' in session_data, - 'token_expires_in': session_data.get('expires_in'), - 'token_issued_at': session_data.get('issued_at') + "provider_name": provider_name, + "country": country, + "has_session": True, + "device_id": session_data.get("device_id"), + "has_token": "access_token" in session_data, + "token_expires_in": session_data.get("expires_in"), + "token_issued_at": session_data.get("issued_at"), } # ============= Bulk Operations ============= - def reset_provider(self, provider_name: str, - reset_credentials: bool = True, - reset_session: bool = True, - reset_proxy: bool = True, - country: Optional[str] = None) -> bool: + def reset_provider( + self, + provider_name: str, + reset_credentials: bool = True, + reset_session: bool = True, + reset_proxy: bool = True, + country: Optional[str] = None, + ) -> bool: """ Reset various data for a provider @@ -815,18 +982,24 @@ class SettingsManager: # Reset specific country or non-country provider if reset_credentials: self.credential_manager.delete_credentials(provider_name, country) - logger.info(f"Reset credentials for {provider_name}" + - (f" ({country})" if country else "")) + logger.info( + f"Reset credentials for {provider_name}" + + (f" ({country})" if country else "") + ) if reset_session: self.session_manager.clear_session(provider_name, country) - logger.info(f"Reset session for {provider_name}" + - (f" ({country})" if country else "")) + logger.info( + f"Reset session for {provider_name}" + + (f" ({country})" if country else "") + ) if reset_proxy: self.proxy_manager.remove_proxy_config(provider_name, country) - logger.info(f"Reset proxy config for {provider_name}" + - (f" ({country})" if country else "")) + logger.info( + f"Reset proxy config for {provider_name}" + + (f" ({country})" if country else "") + ) except Exception as e: logger.error(f"Error resetting provider {provider_name}: {e}") @@ -834,7 +1007,9 @@ class SettingsManager: return success - def export_provider_settings(self, provider_name: str, country: Optional[str] = None) -> Dict[str, Any]: + def export_provider_settings( + self, provider_name: str, country: Optional[str] = None + ) -> Dict[str, Any]: """ Export all settings for a single provider @@ -846,14 +1021,14 @@ class SettingsManager: Dictionary with all provider settings """ if not self.is_provider_registered(provider_name): - return {'error': 'Provider not registered'} + return {"error": "Provider not registered"} export_data: Dict[str, Any] = { - 'provider_name': provider_name, - 'country': country, - 'exported_at': time.time(), - 'registration': self._registered_providers[provider_name].__dict__, - 'status': self.get_provider_status(provider_name, country) + "provider_name": provider_name, + "country": country, + "exported_at": time.time(), + "registration": self._registered_providers[provider_name].__dict__, + "status": self.get_provider_status(provider_name, country), } # Export credentials (without sensitive data) @@ -861,26 +1036,28 @@ class SettingsManager: if credentials: cred_export = credentials.__dict__.copy() # Mask sensitive fields - for cred_field in ['password', 'client_secret']: + for cred_field in ["password", "client_secret"]: if cred_field in cred_export: - cred_export[cred_field] = '***MASKED***' - export_data['credentials'] = cred_export + cred_export[cred_field] = "***MASKED***" + export_data["credentials"] = cred_export # Export proxy config proxy_config = self.get_provider_proxy(provider_name, country) if proxy_config: - export_data['proxy'] = proxy_config.to_dict() + export_data["proxy"] = proxy_config.to_dict() # Export session info (without tokens) session_info = self.get_provider_session_info(provider_name, country) - export_data['session'] = session_info + export_data["session"] = session_info # If provider supports countries, export all countries 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) - export_data['countries'] = countries_data + countries_data[ctry] = self.export_provider_settings( + provider_name, ctry + ) + export_data["countries"] = countries_data return export_data @@ -898,30 +1075,36 @@ 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' + export_path = f"settings_export_{timestamp}.json" export_data: Dict[str, Any] = { - 'metadata': { - 'exported_at': time.time(), - 'version': '2.1', - 'source': 'unified_settings_manager_v2.1_country_aware' + "metadata": { + "exported_at": time.time(), + "version": "2.1", + "source": "unified_settings_manager_v2.1_country_aware", }, - 'system_info': { - '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) + "system_info": { + "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), }, - 'providers': {} + "providers": {}, } # 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: + with open(export_path, "w", encoding="utf-8") as f: json.dump(export_data, f, indent=2, ensure_ascii=False, default=str) logger.info(f"Exported all settings to: {export_path}") @@ -935,37 +1118,51 @@ class SettingsManager: # Handle config directory status safely - use config_path for backward compatibility if self.config_path: config_status = { - 'path': str(self.config_path), - 'exists': self.config_path.exists(), - 'writable': os.access(str(self.config_path), os.W_OK) if self.config_path.exists() else False + "path": str(self.config_path), + "exists": self.config_path.exists(), + "writable": ( + os.access(str(self.config_path), os.W_OK) + if self.config_path.exists() + else False + ), } else: config_status = { - 'path': 'default_vfs_path', - 'exists': 'unknown', - 'writable': 'unknown' + "path": "default_vfs_path", + "exists": "unknown", + "writable": "unknown", } return { - 'config_directory': config_status, - 'providers': { - 'registered': len(self._registered_providers), - '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) + "config_directory": config_status, + "providers": { + "registered": len(self._registered_providers), + "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 + ), }, - '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 + "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 + ), + }, + "storage": { + "credential_providers": len(self.credential_manager.list_providers()), + "proxy_configs": len(self.proxy_manager.list_proxy_configs()), }, - 'storage': { - 'credential_providers': len(self.credential_manager.list_providers()), - 'proxy_configs': len(self.proxy_manager.list_proxy_configs()) - } } - 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 @@ -977,68 +1174,79 @@ class SettingsManager: Dictionary with debug information """ debug_info: Dict[str, Any] = { - 'provider_name': provider_name, - 'country': country, - 'timestamp': time.time() + "provider_name": provider_name, + "country": country, + "timestamp": time.time(), } # Registration info if provider_name in self._registered_providers: reg = self._registered_providers[provider_name] - debug_info['registration'] = { - 'is_registered': True, - 'registered_at': reg.registered_at, - 'is_active': reg.is_active, - 'has_schema': reg.settings_schema is not None, - 'supports_countries': reg.supports_countries, - 'available_countries': reg.available_countries + debug_info["registration"] = { + "is_registered": True, + "registered_at": reg.registered_at, + "is_active": reg.is_active, + "has_schema": reg.settings_schema is not None, + "supports_countries": reg.supports_countries, + "available_countries": reg.available_countries, } else: - debug_info['registration'] = {'is_registered': False} + debug_info["registration"] = {"is_registered": False} # File system checks - use config_path for backward compatibility if self.config_path: - debug_info['filesystem'] = { - 'config_dir_exists': self.config_path.exists(), - 'config_dir_writable': os.access(str(self.config_path), os.W_OK) if self.config_path.exists() else False + debug_info["filesystem"] = { + "config_dir_exists": self.config_path.exists(), + "config_dir_writable": ( + os.access(str(self.config_path), os.W_OK) + if self.config_path.exists() + else False + ), } else: - debug_info['filesystem'] = { - 'config_dir_exists': 'using_vfs_default', - 'config_dir_writable': 'using_vfs_default' + debug_info["filesystem"] = { + "config_dir_exists": "using_vfs_default", + "config_dir_writable": "using_vfs_default", } # Component manager status - debug_info['components'] = { - 'credential_manager': { - 'has_credentials': provider_name in self.credential_manager.list_providers() + debug_info["components"] = { + "credential_manager": { + "has_credentials": provider_name + in self.credential_manager.list_providers() }, - 'session_manager': { - 'has_session': self.session_manager.load_session(provider_name, country) is not None, - 'device_id': self.session_manager.get_device_id(provider_name, country), - 'all_countries': self.session_manager.get_all_countries(provider_name) + "session_manager": { + "has_session": self.session_manager.load_session(provider_name, country) + is not None, + "device_id": self.session_manager.get_device_id(provider_name, country), + "all_countries": self.session_manager.get_all_countries(provider_name), + }, + "proxy_manager": { + "has_proxy": provider_name in self.proxy_manager.list_proxy_configs() }, - 'proxy_manager': { - 'has_proxy': provider_name in self.proxy_manager.list_proxy_configs() - } } # Kodi integration status if self.kodi_bridge: - debug_info['kodi'] = { - 'bridge_available': True, - 'in_environment': self.kodi_bridge.is_kodi_environment(), - 'provider_test': self.kodi_bridge.test_provider_connectivity(provider_name) if hasattr( - self.kodi_bridge, 'test_provider_connectivity') else 'test_unavailable' + debug_info["kodi"] = { + "bridge_available": True, + "in_environment": self.kodi_bridge.is_kodi_environment(), + "provider_test": ( + self.kodi_bridge.test_provider_connectivity(provider_name) + if hasattr(self.kodi_bridge, "test_provider_connectivity") + else "test_unavailable" + ), } else: - debug_info['kodi'] = {'bridge_available': False} + debug_info["kodi"] = {"bridge_available": False} return debug_info # ============= Country-Specific Helper Methods ============= - def get_credential_info(self, provider_name: str, country: Optional[str] = None) -> Dict[str, Any]: + def get_credential_info( + self, provider_name: str, country: Optional[str] = None + ) -> Dict[str, Any]: """ Get information about credentials for a provider @@ -1052,12 +1260,12 @@ class SettingsManager: credentials = self.get_provider_credentials(provider_name, country) return { - 'provider_name': provider_name, - 'country': country, - 'has_credentials': credentials is not None, - 'credentials_valid': credentials.validate() if credentials else False, - 'credential_type': credentials.credential_type if credentials else None, - 'source': 'file' # Could be extended to track source + "provider_name": provider_name, + "country": country, + "has_credentials": credentials is not None, + "credentials_valid": credentials.validate() if credentials else False, + "credential_type": credentials.credential_type if credentials else None, + "source": "file", # Could be extended to track source } def list_provider_countries_with_data(self, provider_name: str) -> List[str]: @@ -1076,7 +1284,9 @@ 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 @@ -1096,7 +1306,9 @@ class SettingsManager: return True try: - logger.info(f"Migrating {provider_name} to country structure with default: {default_country}") + logger.info( + f"Migrating {provider_name} to country structure with default: {default_country}" + ) # Load existing non-country data old_credentials = self.credential_manager.load_credentials(provider_name) @@ -1105,15 +1317,21 @@ class SettingsManager: # Save to country-specific locations if old_credentials: - self.credential_manager.save_credentials(provider_name, old_credentials, default_country) + self.credential_manager.save_credentials( + provider_name, old_credentials, default_country + ) 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 @@ -1136,8 +1354,13 @@ class SettingsManager: # ============= Scoped Token Management (Pass-through to SessionManager) ============= - def save_scoped_token(self, provider_name: str, scope: str, token_data: Dict[str, Any], - country: Optional[str] = None) -> bool: + def save_scoped_token( + self, + provider_name: str, + scope: str, + token_data: Dict[str, Any], + country: Optional[str] = None, + ) -> bool: """ Save authentication token for a specific scope @@ -1172,10 +1395,13 @@ 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) -> Optional[Dict[str, Any]]: + def load_scoped_token( + self, provider_name: str, scope: str, country: Optional[str] = None + ) -> Optional[Dict[str, Any]]: """ Load token data for a specific scope @@ -1195,8 +1421,9 @@ class SettingsManager: """ return self.session_manager.load_scoped_token(provider_name, scope, country) - def clear_scoped_token(self, provider_name: str, scope: str, - country: Optional[str] = None) -> bool: + def clear_scoped_token( + self, provider_name: str, scope: str, country: Optional[str] = None + ) -> bool: """ Clear token for a specific scope @@ -1216,7 +1443,9 @@ 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 @@ -1236,16 +1465,18 @@ class SettingsManager: return [] # Filter out non-scope keys (device_id, refresh_token at provider level) - non_scope_keys = {'device_id', 'refresh_token'} + non_scope_keys = {"device_id", "refresh_token"} scopes = [ - key for key in session_data.keys() + key + for key in session_data.keys() if isinstance(session_data[key], dict) and key not in non_scope_keys ] return scopes - def get_scoped_token_status(self, provider_name: str, scope: str, - country: Optional[str] = None) -> Dict[str, Any]: + def get_scoped_token_status( + self, provider_name: str, scope: str, country: Optional[str] = None + ) -> Dict[str, Any]: """ Get detailed status information for a scoped token @@ -1273,52 +1504,66 @@ class SettingsManager: if not token_data: return { - 'exists': False, - 'scope': scope, - 'provider_name': provider_name, - 'country': country + "exists": False, + "scope": scope, + "provider_name": provider_name, + "country": country, } status = { - 'exists': True, - 'scope': scope, - 'provider_name': provider_name, - 'country': country, - 'token_type': token_data.get('token_type', 'Bearer') + "exists": True, + "scope": scope, + "provider_name": provider_name, + "country": country, + "token_type": token_data.get("token_type", "Bearer"), } # Check access token expiration - if 'access_token' in token_data: - status['has_access_token'] = True + if "access_token" in token_data: + status["has_access_token"] = True # Standard expiration (single expires_in and issued_at) - if 'expires_in' in token_data and 'issued_at' in token_data: - expires_at = token_data['issued_at'] + token_data['expires_in'] - status['access_token_expires_at'] = expires_at - status['access_token_valid'] = time.time() < expires_at + if "expires_in" in token_data and "issued_at" in token_data: + expires_at = token_data["issued_at"] + token_data["expires_in"] + status["access_token_expires_at"] = expires_at + 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: - expires_at = 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 + 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"] + ) + status["access_token_expires_at"] = expires_at + status["access_token_valid"] = time.time() < expires_at else: - status['access_token_valid'] = None # Cannot determine + status["access_token_valid"] = None # Cannot determine # Check refresh token expiration (for yo_digital) - if 'refresh_token' in token_data: - status['has_refresh_token'] = True + 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: - expires_at = 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 + 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"] + ) + status["refresh_token_expires_at"] = expires_at + status["refresh_token_valid"] = time.time() < expires_at else: - status['refresh_token_valid'] = None # Cannot determine + status["refresh_token_valid"] = None # Cannot determine 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 @@ -1326,7 +1571,7 @@ class SettingsManager: enable_manager = ProviderEnableManager(self.config_dir_path) # Handle country parameter if provider_name doesn't have suffix - if country and '_' not in provider_name: + if country and "_" not in provider_name: provider_to_check = f"{provider_name}_{country}" else: provider_to_check = provider_name @@ -1352,7 +1597,7 @@ class SettingsManager: Tuple of (provider, country) where country may be None """ # Split by last underscore - parts = provider_name.rsplit('_', 1) + parts = provider_name.rsplit("_", 1) # Check if last part looks like a country code (2-3 alphabetic chars) if len(parts) == 2 and len(parts[1]) in (2, 3) and parts[1].isalpha(): @@ -1361,8 +1606,9 @@ class SettingsManager: # No country suffix detected return provider_name, None - def save_provider_credentials_from_api(self, provider_name: str, - credentials_data: Dict[str, Any]) -> Tuple[bool, str]: + def save_provider_credentials_from_api( + self, provider_name: str, credentials_data: Dict[str, Any] + ) -> Tuple[bool, str]: """ Save credentials from API request (bypasses Kodi sync) @@ -1391,18 +1637,25 @@ class SettingsManager: credentials = self._parse_credentials_from_dict(credentials_data) if not credentials: - return False, "Invalid credentials format. Must provide either (username + password) or (client_id + client_secret)" + return ( + False, + "Invalid credentials format. Must provide either (username + password) or (client_id + client_secret)", + ) # Validate credentials if not credentials.validate(): 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" @@ -1411,9 +1664,10 @@ class SettingsManager: logger.error(f"Error saving credentials from API for {provider_name}: {e}") return False, f"Internal error: {str(e)}" - @staticmethod - def _parse_credentials_from_dict(credentials_data: Dict[str, Any]) -> Optional[BaseCredentials]: + def _parse_credentials_from_dict( + credentials_data: Dict[str, Any], + ) -> Optional[BaseCredentials]: """ Parse credentials dictionary and create appropriate credential object @@ -1424,32 +1678,36 @@ class SettingsManager: BaseCredentials instance or None if invalid """ # Check for user_password credentials - has_username = 'username' in credentials_data and credentials_data['username'] - has_password = 'password' in credentials_data and credentials_data['password'] + has_username = "username" in credentials_data and credentials_data["username"] + has_password = "password" in credentials_data and credentials_data["password"] if has_username and has_password: return UserPasswordCredentials( - username=credentials_data['username'].strip(), - password=credentials_data['password'].strip(), - client_id=credentials_data.get('client_id', '').strip() or None + username=credentials_data["username"].strip(), + password=credentials_data["password"].strip(), + client_id=credentials_data.get("client_id", "").strip() or None, ) # Check for client credentials - 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'] + 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"] + ) if has_client_id and has_client_secret: return ClientCredentials( - client_id=credentials_data['client_id'].strip(), - client_secret=credentials_data['client_secret'].strip() + client_id=credentials_data["client_id"].strip(), + client_secret=credentials_data["client_secret"].strip(), ) # Invalid format return None - - def save_provider_proxy_from_api(self, provider_name: str, - proxy_data: Dict[str, Any]) -> Tuple[bool, str]: + def save_provider_proxy_from_api( + self, provider_name: str, proxy_data: Dict[str, Any] + ) -> Tuple[bool, str]: """ Save proxy configuration from API request (bypasses Kodi sync) @@ -1486,11 +1744,15 @@ 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" @@ -1499,7 +1761,6 @@ class SettingsManager: logger.error(f"Error saving proxy config from API for {provider_name}: {e}") return False, f"Internal error: {str(e)}" - @staticmethod def _parse_proxy_from_dict(proxy_data: Dict[str, Any]) -> Optional[ProxyConfig]: """ @@ -1511,64 +1772,66 @@ class SettingsManager: Returns: ProxyConfig instance or None if invalid """ - from ..models.proxy_models import ProxyConfig, ProxyAuth, ProxyType, ProxyScope + from ..models.proxy_models import (ProxyAuth, ProxyConfig, ProxyScope, + ProxyType) # Required fields - if 'host' not in proxy_data or not proxy_data['host']: + if "host" not in proxy_data or not proxy_data["host"]: return None - if 'port' not in proxy_data: + if "port" not in proxy_data: return None try: - port = int(proxy_data['port']) + port = int(proxy_data["port"]) except (ValueError, TypeError): return None # Optional proxy type proxy_type = ProxyType.HTTP - if 'proxy_type' in proxy_data: + if "proxy_type" in proxy_data: try: - proxy_type = ProxyType(proxy_data['proxy_type'].lower()) + proxy_type = ProxyType(proxy_data["proxy_type"].lower()) except (ValueError, AttributeError): pass # Use default # Optional authentication auth = None - if 'username' in proxy_data and 'password' in proxy_data: - if proxy_data['username'] and proxy_data['password']: + if "username" in proxy_data and "password" in proxy_data: + if proxy_data["username"] and proxy_data["password"]: auth = ProxyAuth( - username=proxy_data['username'].strip(), - password=proxy_data['password'].strip() + username=proxy_data["username"].strip(), + password=proxy_data["password"].strip(), ) # Optional scope (default to all enabled) scope = ProxyScope() - if 'scope' in proxy_data and isinstance(proxy_data['scope'], dict): - scope_data = proxy_data['scope'] + if "scope" in proxy_data and isinstance(proxy_data["scope"], dict): + scope_data = proxy_data["scope"] scope = ProxyScope( - api_calls=scope_data.get('api_calls', True), - authentication=scope_data.get('authentication', True), - manifests=scope_data.get('manifests', True), - license=scope_data.get('license', True), - all=scope_data.get('all', True) + api_calls=scope_data.get("api_calls", True), + authentication=scope_data.get("authentication", True), + manifests=scope_data.get("manifests", True), + license=scope_data.get("license", True), + all=scope_data.get("all", True), ) # Optional settings - timeout = proxy_data.get('timeout', 30) - verify_ssl = proxy_data.get('verify_ssl', True) + timeout = proxy_data.get("timeout", 30) + verify_ssl = proxy_data.get("verify_ssl", True) return ProxyConfig( - host=proxy_data['host'].strip(), + host=proxy_data["host"].strip(), port=port, proxy_type=proxy_type, auth=auth, scope=scope, timeout=timeout, - verify_ssl=verify_ssl + 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 @@ -1591,16 +1854,19 @@ class SettingsManager: if success: country_str = f" ({country})" if country else "" - logger.info(f"Successfully deleted credentials from API for {provider}{country_str}") + logger.info( + f"Successfully deleted credentials from API for {provider}{country_str}" + ) return True, "Credentials deleted successfully" else: 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]: """ Delete proxy configuration via API request @@ -1624,17 +1890,21 @@ class SettingsManager: if success: country_str = f" ({country})" if country else "" - logger.info(f"Successfully deleted proxy config from API for {provider}{country_str}") + logger.info( + f"Successfully deleted proxy config from API for {provider}{country_str}" + ) return True, "Proxy configuration deleted successfully" else: 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)}" # For imports that expect the old interface UnifiedSettingsManager = SettingsManager # Backward compatibility alias -__all__ = ['SettingsManager', 'UnifiedSettingsManager', 'ProviderRegistration'] \ No newline at end of file +__all__ = ["SettingsManager", "UnifiedSettingsManager", "ProviderRegistration"] diff --git a/lib/streaming_providers/base/subscription_operations.py b/lib/streaming_providers/base/subscription_operations.py index 47cdb25..1d22ea9 100644 --- a/lib/streaming_providers/base/subscription_operations.py +++ b/lib/streaming_providers/base/subscription_operations.py @@ -3,8 +3,10 @@ """ Subscription and package management operations. """ -from typing import Optional, List -from .models import UserSubscription, SubscriptionPackage, StreamingChannel + +from typing import List, Optional + +from .models import StreamingChannel, SubscriptionPackage, UserSubscription from .utils.logger import logger @@ -15,8 +17,9 @@ 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: @@ -25,15 +28,18 @@ class SubscriptionOperations: try: subscription = provider.get_subscription_status(**kwargs) if subscription: - logger.debug(f"Got subscription for '{provider_name}': " - f"{subscription.package_count} packages") + logger.debug( + f"Got subscription for '{provider_name}': " + f"{subscription.package_count} packages" + ) return subscription except Exception as e: 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: @@ -41,14 +47,17 @@ 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: @@ -62,8 +71,9 @@ 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: @@ -76,4 +86,3 @@ class SubscriptionOperations: except Exception as e: logger.warning(f"Error checking accessibility: {e}") return True # Assume accessible on error - diff --git a/lib/streaming_providers/base/ui/__init__.py b/lib/streaming_providers/base/ui/__init__.py index 7c24f89..5005380 100644 --- a/lib/streaming_providers/base/ui/__init__.py +++ b/lib/streaming_providers/base/ui/__init__.py @@ -6,26 +6,27 @@ UI notification system for streaming providers Provides adapters for different UI environments (Kodi, console, web, etc.) """ -from .notification_interface import NotificationInterface, NotificationResult from .notification_factory import NotificationFactory +from .notification_interface import NotificationInterface, NotificationResult # Conditional imports - only import if environment supports them try: from .kodi_notification_adapter import KodiNotificationAdapter + __all__ = [ - 'NotificationInterface', - 'NotificationResult', - 'NotificationFactory', - 'KodiNotificationAdapter', - 'ConsoleNotificationAdapter' + "NotificationInterface", + "NotificationResult", + "NotificationFactory", + "KodiNotificationAdapter", + "ConsoleNotificationAdapter", ] except ImportError: # Kodi not available __all__ = [ - 'NotificationInterface', - 'NotificationResult', - 'NotificationFactory', - 'ConsoleNotificationAdapter' + "NotificationInterface", + "NotificationResult", + "NotificationFactory", + "ConsoleNotificationAdapter", ] -from .console_notification_adapter import ConsoleNotificationAdapter \ No newline at end of file +from .console_notification_adapter import ConsoleNotificationAdapter diff --git a/lib/streaming_providers/base/ui/console_notification_adapter.py b/lib/streaming_providers/base/ui/console_notification_adapter.py index 97bd47f..946b4bc 100644 --- a/lib/streaming_providers/base/ui/console_notification_adapter.py +++ b/lib/streaming_providers/base/ui/console_notification_adapter.py @@ -5,10 +5,12 @@ Console notification adapter for non-Kodi environments Displays remote login information as simple text output """ + import time from typing import Optional -from .notification_interface import NotificationInterface, NotificationResult + from ..utils.logger import logger +from .notification_interface import NotificationInterface, NotificationResult class ConsoleNotificationAdapter(NotificationInterface): @@ -46,11 +48,7 @@ class ConsoleNotificationAdapter(NotificationInterface): return False def show_remote_login( - self, - login_code: str, - qr_target_url: str, - expires_in: int, - interval: int = 10 + self, login_code: str, qr_target_url: str, expires_in: int, interval: int = 10 ) -> NotificationResult: """ Show remote login information in console @@ -90,7 +88,9 @@ 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 @@ -164,4 +164,4 @@ class ConsoleNotificationAdapter(NotificationInterface): Returns: bool: Always False (console can't be cancelled interactively) """ - return False \ No newline at end of file + return False diff --git a/lib/streaming_providers/base/ui/kodi_notification_adapter.py b/lib/streaming_providers/base/ui/kodi_notification_adapter.py index 85ad209..6a892df 100644 --- a/lib/streaming_providers/base/ui/kodi_notification_adapter.py +++ b/lib/streaming_providers/base/ui/kodi_notification_adapter.py @@ -6,17 +6,20 @@ Architecture: 3. Dialog monitors thread status and auto-closes on success 4. User can cancel by closing dialog """ -import time + import os import tempfile import threading -from typing import Optional, Callable -from .notification_interface import NotificationInterface, NotificationResult +import time +from typing import Callable, Optional + from ..utils.logger import logger +from .notification_interface import NotificationInterface, NotificationResult # Import QR generator try: from .qr_generator import generate_qr_code_png + QR_GENERATOR_AVAILABLE = True except ImportError: logger.warning("qr_generator not available") @@ -84,8 +87,15 @@ class QRCodeDialog: WindowDialog for displaying QR code with status monitoring """ - def __init__(self, xbmcgui, xbmc, qr_image_path: str, login_code: str, - expires_in: int, polling_thread: Optional[PollingThread] = None): + def __init__( + self, + xbmcgui, + xbmc, + qr_image_path: str, + login_code: str, + expires_in: int, + polling_thread: Optional[PollingThread] = None, + ): """ Initialize QR code dialog @@ -130,12 +140,11 @@ class QRCodeDialog: # Background - Use a solid color label instead of ControlImage bg = self.xbmcgui.ControlLabel( - dialog_x, dialog_y, dialog_width, dialog_height, - label='' + dialog_x, dialog_y, dialog_width, dialog_height, label="" ) # Set semi-transparent black background try: - bg.setColorDiffuse('0xE0000000') + bg.setColorDiffuse("0xE0000000") except: pass # Ignore if method not available self.dialog.addControl(bg) @@ -143,12 +152,14 @@ class QRCodeDialog: # Title title_y = dialog_y + 30 title = self.xbmcgui.ControlLabel( - x=dialog_x + 50, y=title_y, - width=dialog_width - 100, height=50, - label='[B]MagentaTV Remote Login[/B]', - font='font30', - textColor='0xFFFFFFFF', - alignment=0x00000002 # Center + x=dialog_x + 50, + y=title_y, + width=dialog_width - 100, + height=50, + label="[B]MagentaTV Remote Login[/B]", + font="font30", + textColor="0xFFFFFFFF", + alignment=0x00000002, # Center ) self.dialog.addControl(title) @@ -159,8 +170,7 @@ class QRCodeDialog: if os.path.exists(self.qr_image_path): qr_image = self.xbmcgui.ControlImage( - qr_x, qr_y, qr_size, qr_size, - self.qr_image_path + qr_x, qr_y, qr_size, qr_size, self.qr_image_path ) self.dialog.addControl(qr_image) else: @@ -170,59 +180,69 @@ class QRCodeDialog: instructions_y = qr_y + qr_size + 40 inst1 = self.xbmcgui.ControlLabel( - x=dialog_x + 50, y=instructions_y, - width=dialog_width - 100, height=30, - label='[B]Scan QR code with your MagentaTV app[/B]', - font='font13', - textColor='0xFFFFFFFF', - alignment=0x00000002 + x=dialog_x + 50, + y=instructions_y, + width=dialog_width - 100, + height=30, + label="[B]Scan QR code with your MagentaTV app[/B]", + font="font13", + textColor="0xFFFFFFFF", + alignment=0x00000002, ) self.dialog.addControl(inst1) inst2_y = instructions_y + 35 inst2 = self.xbmcgui.ControlLabel( - x=dialog_x + 50, y=inst2_y, - width=dialog_width - 100, height=30, - label=f'Or enter code: [COLOR yellow]{self.login_code}[/COLOR]', - font='font13', - textColor='0xFFCCCCCC', - alignment=0x00000002 + x=dialog_x + 50, + y=inst2_y, + width=dialog_width - 100, + height=30, + label=f"Or enter code: [COLOR yellow]{self.login_code}[/COLOR]", + font="font13", + textColor="0xFFCCCCCC", + alignment=0x00000002, ) self.dialog.addControl(inst2) # Time remaining label time_y = inst2_y + 45 self.time_label = self.xbmcgui.ControlLabel( - x=dialog_x + 50, y=time_y, - width=dialog_width - 100, height=30, - label=f'Time remaining: {self._format_time(self.expires_in)}', - font='font12', - textColor='0xFFFF8800', - alignment=0x00000002 + x=dialog_x + 50, + y=time_y, + width=dialog_width - 100, + height=30, + label=f"Time remaining: {self._format_time(self.expires_in)}", + font="font12", + textColor="0xFFFF8800", + alignment=0x00000002, ) self.dialog.addControl(self.time_label) # Status label status_y = time_y + 35 self.status_label = self.xbmcgui.ControlLabel( - x=dialog_x + 50, y=status_y, - width=dialog_width - 100, height=30, - label='Waiting for authentication...', - font='font12', - textColor='0xFFAAAAAA', - alignment=0x00000002 + x=dialog_x + 50, + y=status_y, + width=dialog_width - 100, + height=30, + label="Waiting for authentication...", + font="font12", + textColor="0xFFAAAAAA", + alignment=0x00000002, ) self.dialog.addControl(self.status_label) # Cancel hint cancel_y = status_y + 35 cancel = self.xbmcgui.ControlLabel( - x=dialog_x + 50, y=cancel_y, - width=dialog_width - 100, height=25, - label='(Press any key to cancel)', - font='font10', - textColor='0xFF888888', - alignment=0x00000002 + x=dialog_x + 50, + y=cancel_y, + width=dialog_width - 100, + height=25, + label="(Press any key to cancel)", + font="font10", + textColor="0xFF888888", + alignment=0x00000002, ) self.dialog.addControl(cancel) @@ -266,22 +286,30 @@ 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: logger.info("Monitor: Authentication completed, closing dialog") if self.status_label: - self.status_label.setLabel('[COLOR green]Authentication successful![/COLOR]') + self.status_label.setLabel( + "[COLOR green]Authentication successful![/COLOR]" + ) time.sleep(1) # Show success message briefly self.close_dialog() break # 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 @@ -341,9 +369,10 @@ class KodiNotificationAdapter(NotificationInterface): # Import Kodi modules try: - import xbmcgui import xbmc + import xbmcgui import xbmcvfs + self.xbmcgui = xbmcgui self.xbmc = xbmc self.xbmcvfs = xbmcvfs @@ -374,12 +403,12 @@ class KodiNotificationAdapter(NotificationInterface): return True # From caller's perspective, it blocks def show_remote_login_with_polling( - self, - login_code: str, - qr_target_url: str, - expires_in: int, - interval: int, - poll_callback: Callable + self, + login_code: str, + qr_target_url: str, + expires_in: int, + interval: int, + poll_callback: Callable, ) -> NotificationResult: """ Show remote login with integrated polling @@ -428,7 +457,7 @@ class KodiNotificationAdapter(NotificationInterface): qr_image_path, login_code, expires_in, - self._polling_thread + self._polling_thread, ) self._qr_dialog.show() @@ -453,11 +482,7 @@ class KodiNotificationAdapter(NotificationInterface): self._cleanup_qr_image() def show_remote_login( - self, - login_code: str, - qr_target_url: str, - expires_in: int, - interval: int = 10 + self, login_code: str, qr_target_url: str, expires_in: int, interval: int = 10 ) -> NotificationResult: """ Simplified version without polling (for backward compatibility) @@ -489,7 +514,7 @@ class KodiNotificationAdapter(NotificationInterface): qr_image_path, login_code, expires_in, - None # No polling thread + None, # No polling thread ) self._qr_dialog.show() @@ -532,14 +557,14 @@ class KodiNotificationAdapter(NotificationInterface): "MagentaTV", "Remote login successful!", self.xbmcgui.NOTIFICATION_INFO, - 3000 + 3000, ) elif message and not self._is_cancelled: self.xbmcgui.Dialog().notification( "MagentaTV", f"Remote login failed: {message}", self.xbmcgui.NOTIFICATION_ERROR, - 5000 + 5000, ) self._cleanup_qr_image() @@ -594,7 +619,7 @@ class KodiNotificationAdapter(NotificationInterface): png_filename = f"magentatv_qr_{int(time.time())}.png" png_path = os.path.join(temp_dir, png_filename) - with open(png_path, 'wb') as f: + with open(png_path, "wb") as f: f.write(png_data) logger.info(f"QR code saved to: {png_path}") @@ -613,4 +638,4 @@ class KodiNotificationAdapter(NotificationInterface): except Exception as e: logger.warning(f"Failed to cleanup QR image: {e}") finally: - self._qr_image_path = None \ No newline at end of file + self._qr_image_path = None diff --git a/lib/streaming_providers/base/ui/notification_factory.py b/lib/streaming_providers/base/ui/notification_factory.py index c76f24c..fb23e8f 100644 --- a/lib/streaming_providers/base/ui/notification_factory.py +++ b/lib/streaming_providers/base/ui/notification_factory.py @@ -5,9 +5,11 @@ Factory for creating appropriate notification adapters Auto-detects environment (Kodi vs standalone) and creates correct adapter """ + from typing import Optional -from .notification_interface import NotificationInterface + from ..utils.logger import logger +from .notification_interface import NotificationInterface class NotificationFactory: @@ -24,8 +26,9 @@ class NotificationFactory: _environment_detected: Optional[str] = None @classmethod - def create(cls, force_environment: Optional[str] = None, - http_manager=None) -> NotificationInterface: + def create( + cls, force_environment: Optional[str] = None, http_manager=None + ) -> NotificationInterface: """ Create appropriate notification adapter @@ -38,8 +41,14 @@ 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 @@ -51,7 +60,7 @@ class NotificationFactory: logger.info(f"Detected notification environment: {environment}") # Create appropriate adapter - if environment == 'kodi': + if environment == "kodi": adapter = cls._create_kodi_adapter(http_manager=http_manager) else: adapter = cls._create_console_adapter() @@ -76,12 +85,14 @@ class NotificationFactory: # If import succeeds, we're in Kodi logger.debug("Kodi modules available - using Kodi notification adapter") - return 'kodi' + return "kodi" except ImportError: # If import fails, we're standalone - logger.debug("Kodi modules not available - using console notification adapter") - return 'console' + logger.debug( + "Kodi modules not available - using console notification adapter" + ) + return "console" @classmethod def _create_kodi_adapter(cls, http_manager=None) -> NotificationInterface: @@ -96,6 +107,7 @@ class NotificationFactory: """ try: from .kodi_notification_adapter import KodiNotificationAdapter + adapter = KodiNotificationAdapter(http_manager=http_manager) logger.info("✓ Kodi notification adapter created") return adapter @@ -114,6 +126,7 @@ class NotificationFactory: ConsoleNotificationAdapter """ from .console_notification_adapter import ConsoleNotificationAdapter + adapter = ConsoleNotificationAdapter() logger.info("✓ Console notification adapter created") return adapter @@ -145,6 +158,7 @@ class NotificationFactory: """ try: import xbmcgui + return True except ImportError: - return False \ No newline at end of file + return False diff --git a/lib/streaming_providers/base/ui/notification_interface.py b/lib/streaming_providers/base/ui/notification_interface.py index 56f3bcf..765f8d8 100644 --- a/lib/streaming_providers/base/ui/notification_interface.py +++ b/lib/streaming_providers/base/ui/notification_interface.py @@ -5,13 +5,15 @@ Abstract notification interface for displaying authentication prompts Supports multiple UI backends (Kodi, console, web, etc.) """ + from abc import ABC, abstractmethod -from typing import Optional from enum import Enum +from typing import Optional class NotificationResult(Enum): """Result of notification display""" + CONTINUE = "continue" # User wants to continue CANCELLED = "cancelled" # User cancelled TIMEOUT = "timeout" # Notification timed out @@ -36,11 +38,7 @@ class NotificationInterface(ABC): @abstractmethod def show_remote_login( - self, - login_code: str, - qr_target_url: str, - expires_in: int, - interval: int = 10 + self, login_code: str, qr_target_url: str, expires_in: int, interval: int = 10 ) -> NotificationResult: """ Show remote login notification to user @@ -144,9 +142,9 @@ class NotificationInterface(ABC): dict: Capability information """ return { - 'type': self.__class__.__name__, - 'supports_qr_display': self.supports_qr_display, - 'supports_countdown': self.supports_countdown, - 'is_blocking': self.is_blocking, - 'is_active': self.is_active - } \ No newline at end of file + "type": self.__class__.__name__, + "supports_qr_display": self.supports_qr_display, + "supports_countdown": self.supports_countdown, + "is_blocking": self.is_blocking, + "is_active": self.is_active, + } diff --git a/lib/streaming_providers/base/ui/qr_generator.py b/lib/streaming_providers/base/ui/qr_generator.py index c3b8b39..fe2fc8f 100644 --- a/lib/streaming_providers/base/ui/qr_generator.py +++ b/lib/streaming_providers/base/ui/qr_generator.py @@ -6,6 +6,7 @@ Generates PNG QR codes directly from URLs This is a GENERIC module - no provider-specific logic! Size: qrcode library is ~50KB, pure Python """ + import io from typing import Optional @@ -40,11 +41,13 @@ 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() - img.save(buffer, format='PNG') + img.save(buffer, format="PNG") png_data = buffer.getvalue() return png_data @@ -67,7 +70,7 @@ def generate_qr_code_png(data: str, size: int = 512) -> Optional[bytes]: img = qr.make_image(fill_color="black", back_color="white") buffer = io.BytesIO() - img.save(buffer, format='PNG') + img.save(buffer, format="PNG") png_data = buffer.getvalue() return png_data @@ -82,7 +85,7 @@ def generate_qr_code_png(data: str, size: int = 512) -> Optional[bytes]: # Test function -if __name__ == '__main__': +if __name__ == "__main__": # Test QR generation test_url = "https://example.com/login?code=ABC123" @@ -91,8 +94,8 @@ if __name__ == '__main__': print(f"Generated QR code PNG: {len(png_data)} bytes") # Save to file for testing - with open('test_qr.png', 'wb') as f: + with open("test_qr.png", "wb") as f: f.write(png_data) print("Saved to test_qr.png") else: - print("Failed to generate QR code") \ No newline at end of file + print("Failed to generate QR code") diff --git a/lib/streaming_providers/base/utils/__init__.py b/lib/streaming_providers/base/utils/__init__.py index b9cd002..d893438 100644 --- a/lib/streaming_providers/base/utils/__init__.py +++ b/lib/streaming_providers/base/utils/__init__.py @@ -1,18 +1,18 @@ # streaming_providers/base/utils/__init__.py -from .logger import logger, BaseLogger +from .logger import BaseLogger, logger from .manifest_parser import ManifestParser -from .vfs import VFS -from .mpd_rewriter import MPDRewriter from .mpd_cache import MPDCacheManager +from .mpd_rewriter import MPDRewriter from .timestamp_converter import TimestampConverter +from .vfs import VFS __all__ = [ - 'logger', - 'BaseLogger', - 'ManifestParser', - 'VFS', - 'MPDRewriter', - 'MPDCacheManager', - 'TimestampConverter' -] \ No newline at end of file + "logger", + "BaseLogger", + "ManifestParser", + "VFS", + "MPDRewriter", + "MPDCacheManager", + "TimestampConverter", +] diff --git a/lib/streaming_providers/base/utils/environment.py b/lib/streaming_providers/base/utils/environment.py index 2d95a72..c507c5a 100644 --- a/lib/streaming_providers/base/utils/environment.py +++ b/lib/streaming_providers/base/utils/environment.py @@ -4,17 +4,17 @@ Central environment detection and service management. Provides unified access to VFS, logger, settings bridge, and other services. """ +import json import os import sys -import json -from typing import Dict, Any, Optional, TYPE_CHECKING, Union from pathlib import Path +from typing import TYPE_CHECKING, Any, Dict, Optional, Union # Type hints to avoid circular imports if TYPE_CHECKING: - from .vfs import VFS - from .logger import BaseLogger # Changed from 'logger' to 'BaseLogger' from ..settings.kodi_settings_bridge import KodiSettingsBridge + from .logger import BaseLogger # Changed from 'logger' to 'BaseLogger' + from .vfs import VFS class EnvironmentManager: @@ -22,9 +22,9 @@ class EnvironmentManager: Central manager for environment detection and service coordination. """ - _instance: Optional['EnvironmentManager'] = None + _instance: Optional["EnvironmentManager"] = None - def __new__(cls) -> 'EnvironmentManager': + def __new__(cls) -> "EnvironmentManager": if cls._instance is None: cls._instance = super(EnvironmentManager, cls).__new__(cls) cls._instance._initialized = False @@ -40,6 +40,7 @@ class EnvironmentManager: try: import xbmcaddon import xbmcvfs + self._is_kodi = True self._kodi_import_error: Optional[Exception] = None except ImportError as import_err: @@ -68,30 +69,36 @@ class EnvironmentManager: # Create addon instance self._addon = kodi_xbmcaddon.Addon() - self._config['environment'] = 'kodi' - self._config['addon_id'] = self._addon.getAddonInfo('id') - self._config['addon_name'] = self._addon.getAddonInfo('name') - self._config['addon_version'] = self._addon.getAddonInfo('version') - self._config['addon_path'] = self._addon.getAddonInfo('path') + self._config["environment"] = "kodi" + self._config["addon_id"] = self._addon.getAddonInfo("id") + self._config["addon_name"] = self._addon.getAddonInfo("name") + self._config["addon_version"] = self._addon.getAddonInfo("version") + self._config["addon_path"] = self._addon.getAddonInfo("path") # Get profile path - profile_info = self._addon.getAddonInfo('profile') + profile_info = self._addon.getAddonInfo("profile") profile_path = kodi_xbmcvfs.translatePath(profile_info) - self._config['profile_path'] = str(profile_path) + self._config["profile_path"] = str(profile_path) # Get settings - default_country = self._addon.getSetting('default_country') - self._config['default_country'] = str(default_country) if default_country else 'DE' + default_country = self._addon.getSetting("default_country") + self._config["default_country"] = ( + str(default_country) if default_country else "DE" + ) - server_port = self._addon.getSetting('server_port') + 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 + 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) @@ -106,42 +113,45 @@ class EnvironmentManager: def _init_standalone(self) -> None: """Initialize standalone mode components""" - self._config['environment'] = 'standalone' - self._config['addon_id'] = 'ultimate-backend-standalone' - self._config['addon_name'] = 'Ultimate Backend' - self._config['addon_version'] = '1.0.0' - self._config['addon_path'] = os.path.dirname(os.path.abspath(__file__)) + self._config["environment"] = "standalone" + self._config["addon_id"] = "ultimate-backend-standalone" + self._config["addon_name"] = "Ultimate Backend" + self._config["addon_version"] = "1.0.0" + 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') - self._config['config_dir'] = os.path.join(config_home, 'ultimate-backend') - self._config['profile_path'] = self._config['config_dir'] + 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"] # Load environment variables with defaults - self._config['default_country'] = os.environ.get('DEFAULT_COUNTRY', 'DE') + self._config["default_country"] = os.environ.get("DEFAULT_COUNTRY", "DE") try: - self._config['server_port'] = int(os.environ.get('SERVER_PORT', '7777')) + self._config["server_port"] = int(os.environ.get("SERVER_PORT", "7777")) except ValueError as port_error: print(f"Invalid server port, using default: {port_error}", file=sys.stderr) - self._config['server_port'] = 7777 + self._config["server_port"] = 7777 # Ensure config directory exists try: - os.makedirs(self._config['config_dir'], exist_ok=True) + os.makedirs(self._config["config_dir"], exist_ok=True) except OSError as dir_error: print(f"Failed to create config directory: {dir_error}", file=sys.stderr) # Use temp directory as fallback import tempfile - self._config['config_dir'] = tempfile.mkdtemp(prefix='ultimate-backend-') - self._config['profile_path'] = self._config['config_dir'] + + self._config["config_dir"] = tempfile.mkdtemp(prefix="ultimate-backend-") + self._config["profile_path"] = self._config["config_dir"] def _load_config(self) -> None: """Load additional configuration from files""" - config_file = os.path.join(self._config['profile_path'], 'config.json') + config_file = os.path.join(self._config["profile_path"], "config.json") if os.path.exists(config_file): try: - with open(config_file, 'r', encoding='utf-8') as f: + with open(config_file, "r", encoding="utf-8") as f: file_config = json.load(f) # Update config with file contents for key, value in file_config.items(): @@ -158,7 +168,7 @@ class EnvironmentManager: def get_environment(self) -> str: """Get current environment name""" - return str(self._config.get('environment', 'unknown')) + return str(self._config.get("environment", "unknown")) def get_config(self, key: str, default: Any = None) -> Any: """Get configuration value""" @@ -170,51 +180,56 @@ class EnvironmentManager: # Auto-save to config file in standalone mode if not self._is_kodi: - config_file = os.path.join(self._config['profile_path'], 'config.json') + config_file = os.path.join(self._config["profile_path"], "config.json") try: # Read existing config existing_config = {} if os.path.exists(config_file): - with open(config_file, 'r', encoding='utf-8') as f: + with open(config_file, "r", encoding="utf-8") as f: existing_config = json.load(f) # Update with new value existing_config[key] = value # Write back - with open(config_file, 'w', encoding='utf-8') as f: + with open(config_file, "w", encoding="utf-8") as f: json.dump(existing_config, f, indent=2, ensure_ascii=False) except OSError as save_error: print(f"Failed to save config: {save_error}", file=sys.stderr) except (TypeError, ValueError) as type_error: - print(f"Config contains non-serializable data: {type_error}", file=sys.stderr) + print( + f"Config contains non-serializable data: {type_error}", + file=sys.stderr, + ) - def get_vfs(self, subdir: str = "", config_dir: Optional[str] = None) -> 'VFS': + def get_vfs(self, subdir: str = "", config_dir: Optional[str] = None) -> "VFS": """Get VFS instance with appropriate configuration""" # Import here to avoid circular imports from .vfs import VFS # Use provided config_dir, or environment default if config_dir is None: - config_dir = self._config.get('profile_path', '') + config_dir = self._config.get("profile_path", "") return VFS(config_dir=config_dir, addon_subdir=subdir) @staticmethod - def get_logger() -> 'BaseLogger': + def get_logger() -> "BaseLogger": """Get logger instance for current environment (static method)""" # Import here to avoid circular imports from .logger import logger as logger_instance + return logger_instance - def get_settings_bridge(self, addon_id: Optional[str] = None, - config_dir: Optional[str] = None) -> 'KodiSettingsBridge': + def get_settings_bridge( + self, addon_id: Optional[str] = None, config_dir: Optional[str] = None + ) -> "KodiSettingsBridge": """Get settings bridge instance""" # Import here to avoid circular imports from ..settings.kodi_settings_bridge import KodiSettingsBridge if config_dir is None: - config_dir = self._config.get('profile_path', '') + config_dir = self._config.get("profile_path", "") return KodiSettingsBridge(addon_id=addon_id, config_dir=config_dir) @@ -223,16 +238,19 @@ class EnvironmentManager: try: # Lazy import to avoid circular dependencies import importlib + # Adjust this import path based on your actual module structure - module = importlib.import_module('streaming_providers') - if hasattr(module, 'get_configured_manager'): + module = importlib.import_module("streaming_providers") + if hasattr(module, "get_configured_manager"): manager_func = module.get_configured_manager if callable(manager_func): return manager_func() 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() @@ -242,41 +260,45 @@ class EnvironmentManager: def get_service_config(self) -> Dict[str, Any]: """Get configuration for running the service""" return { - 'is_kodi': self._is_kodi, - 'port': self._config.get('server_port', 7777), - 'default_country': self._config.get('default_country', 'DE'), - 'profile_path': self._config.get('profile_path', ''), - 'addon_path': self._config.get('addon_path', '') + "is_kodi": self._is_kodi, + "port": self._config.get("server_port", 7777), + "default_country": self._config.get("default_country", "DE"), + "profile_path": self._config.get("profile_path", ""), + "addon_path": self._config.get("addon_path", ""), } def debug_info(self) -> Dict[str, Any]: """Get debug information about the environment""" info: Dict[str, Any] = { - 'environment': self.get_environment(), - 'is_kodi': self._is_kodi, - 'python_version': sys.version, - 'platform': sys.platform, + "environment": self.get_environment(), + "is_kodi": self._is_kodi, + "python_version": sys.version, + "platform": sys.platform, } # Add Kodi-specific info if available if self._is_kodi and self._addon: - info['kodi_addon_id'] = self._addon.getAddonInfo('id') - info['kodi_addon_version'] = self._addon.getAddonInfo('version') + info["kodi_addon_id"] = self._addon.getAddonInfo("id") + info["kodi_addon_version"] = self._addon.getAddonInfo("version") # Add import error info if present - if hasattr(self, '_kodi_import_error') and self._kodi_import_error: - info['kodi_import_error'] = str(self._kodi_import_error) + if hasattr(self, "_kodi_import_error") and self._kodi_import_error: + info["kodi_import_error"] = str(self._kodi_import_error) # Create a safe config summary without sensitive paths safe_config: Dict[str, Any] = {} for key, value in self._config.items(): - if not key.endswith('_path') and key != 'config_dir' and key not in ['profile_path', 'addon_path']: + if ( + not key.endswith("_path") + and key != "config_dir" + and key not in ["profile_path", "addon_path"] + ): if isinstance(value, (str, int, float, bool, type(None))): safe_config[key] = value else: safe_config[key] = str(type(value)) - info['config_summary'] = safe_config + info["config_summary"] = safe_config return info @@ -299,11 +321,11 @@ def is_kodi_environment() -> bool: return get_environment_manager().is_kodi() -def get_logger_instance() -> 'BaseLogger': +def get_logger_instance() -> "BaseLogger": """Get logger instance (convenience function)""" return EnvironmentManager.get_logger() -def get_vfs_instance(subdir: str = "", config_dir: Optional[str] = None) -> 'VFS': +def get_vfs_instance(subdir: str = "", config_dir: Optional[str] = None) -> "VFS": """Get VFS instance (convenience function)""" - return get_environment_manager().get_vfs(subdir, config_dir) \ No newline at end of file + return get_environment_manager().get_vfs(subdir, config_dir) diff --git a/lib/streaming_providers/base/utils/logger.py b/lib/streaming_providers/base/utils/logger.py index 47cd60e..67e619c 100644 --- a/lib/streaming_providers/base/utils/logger.py +++ b/lib/streaming_providers/base/utils/logger.py @@ -4,8 +4,8 @@ Centralized logging module for Ultimate Backend. Provides environment-aware logging for both Kodi and standalone modes. """ -import sys import logging +import sys # Import environment manager from .environment import get_environment_manager @@ -50,7 +50,9 @@ 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: @@ -69,8 +71,8 @@ def create_logger() -> BaseLogger: """Create appropriate logger instance based on environment""" # Get configuration - app_name = _env_manager_instance.get_config('addon_name', 'Ultimate Backend') - app_version = _env_manager_instance.get_config('addon_version', '1.0.0') + app_name = _env_manager_instance.get_config("addon_name", "Ultimate Backend") + app_version = _env_manager_instance.get_config("addon_version", "1.0.0") if _env_manager_instance.is_kodi(): # Try to create Kodi logger @@ -116,24 +118,28 @@ def create_logger() -> BaseLogger: # Console handler console_handler = logging.StreamHandler(sys.stdout) formatter = logging.Formatter( - f'%(asctime)s {self.prefix} %(levelname)s: %(message)s', - datefmt='%Y-%m-%d %H:%M:%S' + f"%(asctime)s {self.prefix} %(levelname)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", ) console_handler.setFormatter(formatter) self._logger.addHandler(console_handler) # File handler (optional) - log_dir = _env_manager_instance.get_config('profile_path') + log_dir = _env_manager_instance.get_config("profile_path") if log_dir: import os - log_file = os.path.join(str(log_dir), 'ultimate-backend.log') + + log_file = os.path.join(str(log_dir), "ultimate-backend.log") try: - file_handler = logging.FileHandler(log_file, encoding='utf-8') + file_handler = logging.FileHandler(log_file, encoding="utf-8") file_handler.setFormatter(formatter) self._logger.addHandler(file_handler) except (OSError, PermissionError) as file_handler_error: # Log to console only if file logging fails - print(f"Failed to create file handler: {file_handler_error}", file=sys.stderr) + print( + f"Failed to create file handler: {file_handler_error}", + file=sys.stderr, + ) self._logger.setLevel(logging.DEBUG) @@ -162,4 +168,4 @@ def create_logger() -> BaseLogger: logger: BaseLogger = create_logger() # Export the BaseLogger type for type hints -__all__ = ['BaseLogger', 'logger'] \ No newline at end of file +__all__ = ["BaseLogger", "logger"] diff --git a/lib/streaming_providers/base/utils/manifest_parser.py b/lib/streaming_providers/base/utils/manifest_parser.py index 4442a9f..9a72b0c 100644 --- a/lib/streaming_providers/base/utils/manifest_parser.py +++ b/lib/streaming_providers/base/utils/manifest_parser.py @@ -1,25 +1,29 @@ -import re import base64 +import re + from ..models.drm_models import PSSHData from .logger import logger + class ManifestParser: @staticmethod - def extract_pssh_from_manifest(manifest_content: str, manifest_url: str = "", return_collection: bool = True): + def extract_pssh_from_manifest( + manifest_content: str, manifest_url: str = "", return_collection: bool = True + ): logger.debug("Starting PSSH extraction from manifest") - + # Check if this looks like a DASH manifest - is_dash = (']*>([^<]+)' + pssh_pattern = r"<(?:cenc:)?pssh[^>]*>([^<]+)" default_kid_pattern = r'(?:cenc:)?default_KID="([^"]+)"' system_id_pattern = r'schemeIdUri="urn:uuid:([^"]+)"' - + logger.debug("Searching for ContentProtection blocks") - cp_blocks = re.findall(r']*>.*?', mpd_content, re.DOTALL) + 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 for block in cp_blocks: kid_match = re.search(default_kid_pattern, block) @@ -59,58 +65,66 @@ class ManifestParser: 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): 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) 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: try: - logger.debug(f"Processing PSSH (first 30 chars): {pssh_b64[:30]}...") + 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 if not system_id: system_id_bytes = pssh_data[12:28] - system_id = '-'.join([ - system_id_bytes[0:4].hex(), - system_id_bytes[4:6].hex(), - system_id_bytes[6:8].hex(), - system_id_bytes[8:10].hex(), - system_id_bytes[10:16].hex() - ]) - logger.debug(f"Extracted system ID from PSSH: {system_id}") - + system_id = "-".join( + [ + system_id_bytes[0:4].hex(), + system_id_bytes[4:6].hex(), + system_id_bytes[6:8].hex(), + system_id_bytes[8:10].hex(), + system_id_bytes[10:16].hex(), + ] + ) + logger.debug( + f"Extracted system ID from PSSH: {system_id}" + ) + # Use PSSH box as key for deduplication 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(), # Add all global KIDs to each 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)}") - + except Exception as e: logger.error(f"Error processing ContentProtection block: {str(e)}") - + pssh_list = list(pssh_dict.values()) - logger.debug(f"Completed PSSH extraction, found {len(pssh_list)} unique entries") + logger.debug( + f"Completed PSSH extraction, found {len(pssh_list)} unique entries" + ) return pssh_list diff --git a/lib/streaming_providers/base/utils/mpd_cache.py b/lib/streaming_providers/base/utils/mpd_cache.py index c2a2fdd..f2b1d92 100644 --- a/lib/streaming_providers/base/utils/mpd_cache.py +++ b/lib/streaming_providers/base/utils/mpd_cache.py @@ -1,8 +1,9 @@ # streaming_providers/base/utils/mpd_cache.py import time from typing import Optional -from .vfs import VFS + from .logger import logger +from .vfs import VFS class MPDCacheManager: @@ -53,11 +54,13 @@ class MPDCacheManager: return None # Check expiry - expiry = meta.get('expiry', 0) + expiry = meta.get("expiry", 0) 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) @@ -69,7 +72,9 @@ 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 @@ -77,8 +82,14 @@ class MPDCacheManager: logger.error(f"Error reading cache for {cache_key}: {e}") return None - def set(self, provider: str, channel_id: str, mpd_content: str, - ttl: int, original_url: Optional[str] = None) -> bool: + def set( + self, + provider: str, + channel_id: str, + mpd_content: str, + ttl: int, + original_url: Optional[str] = None, + ) -> bool: """ Store MPD manifest in cache with TTL @@ -102,15 +113,15 @@ class MPDCacheManager: # Create metadata meta = { - 'expiry': expiry, - 'ttl': ttl, - 'cached_at': int(time.time()), - 'provider': provider, - 'channel_id': channel_id + "expiry": expiry, + "ttl": ttl, + "cached_at": int(time.time()), + "provider": provider, + "channel_id": channel_id, } if original_url: - meta['original_url'] = original_url + meta["original_url"] = original_url # Write manifest if not self.vfs.write_text(manifest_file, mpd_content): @@ -124,7 +135,9 @@ 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: @@ -167,7 +180,7 @@ class MPDCacheManager: deleted = 0 for file in files: - if file.endswith('.xml') or file.endswith('.meta'): + if file.endswith(".xml") or file.endswith(".meta"): if self.vfs.delete(file): deleted += 1 @@ -191,14 +204,14 @@ class MPDCacheManager: cleared = 0 # Find all meta files - meta_files = [f for f in files if f.endswith('.meta')] + meta_files = [f for f in files if f.endswith(".meta")] for meta_file in meta_files: try: meta = self.vfs.read_json(meta_file) - if meta and meta.get('expiry', 0) < now: + if meta and meta.get("expiry", 0) < now: # Expired, delete both meta and manifest - cache_key = meta_file.replace('.meta', '') + cache_key = meta_file.replace(".meta", "") self.vfs.delete(f"{cache_key}.xml") self.vfs.delete(meta_file) cleared += 1 @@ -233,8 +246,8 @@ class MPDCacheManager: meta = self.vfs.read_json(meta_file) if meta: now = int(time.time()) - meta['expired'] = now >= meta.get('expiry', 0) - meta['remaining_ttl'] = max(0, meta.get('expiry', 0) - now) + meta["expired"] = now >= meta.get("expiry", 0) + meta["remaining_ttl"] = max(0, meta.get("expiry", 0) - now) return meta except Exception as e: logger.debug(f"Error getting cache info for {cache_key}: {e}") diff --git a/lib/streaming_providers/base/utils/mpd_rewriter.py b/lib/streaming_providers/base/utils/mpd_rewriter.py index 64bcb67..2e6ecec 100644 --- a/lib/streaming_providers/base/utils/mpd_rewriter.py +++ b/lib/streaming_providers/base/utils/mpd_rewriter.py @@ -1,8 +1,9 @@ # streaming_providers/base/utils/mpd_rewriter.py -import xml.etree.ElementTree as ET import base64 +import xml.etree.ElementTree as ET from typing import Optional, Tuple from urllib.parse import urljoin, urlparse + from .logger import logger @@ -18,7 +19,7 @@ class MPDRewriter: """ # MPD namespace - MPD_NAMESPACE = {'mpd': 'urn:mpeg:dash:schema:mpd:2011'} + MPD_NAMESPACE = {"mpd": "urn:mpeg:dash:schema:mpd:2011"} def __init__(self, proxy_base_url: str, provider_name: str): """ @@ -28,20 +29,22 @@ class MPDRewriter: proxy_base_url: Base URL of the proxy service (e.g., http://localhost:7777) provider_name: Name of the provider for proxy routing """ - self.proxy_base_url = proxy_base_url.rstrip('/') + self.proxy_base_url = proxy_base_url.rstrip("/") self.provider_name = provider_name @staticmethod def encode_url(url: str) -> str: """Encode URL to base64 for use in proxy endpoint""" - return base64.urlsafe_b64encode(url.encode('utf-8')).decode('utf-8') + return base64.urlsafe_b64encode(url.encode("utf-8")).decode("utf-8") @staticmethod def decode_url(encoded: str) -> str: """Decode base64 URL from proxy endpoint""" - return base64.urlsafe_b64decode(encoded.encode('utf-8')).decode('utf-8') + 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 @@ -74,24 +77,24 @@ class MPDRewriter: - base_path: URL up to the last slash before any template variable - template_pattern: Path with template variables, or None if no templates """ - if '$' not in url: + if "$" not in url: return url, None # Find the position of the first template variable - first_template_pos = url.find('$') + first_template_pos = url.find("$") # Find the last slash BEFORE the first template variable # This handles cases like: # - https://cdn.com/path/segment-$Number$.m4s # - https://cdn.com/path/$RepresentationID$/init.mp4 - last_slash_before_template = url.rfind('/', 0, first_template_pos) + last_slash_before_template = url.rfind("/", 0, first_template_pos) if last_slash_before_template == -1: # No slash found before template, entire URL is template (unusual but handle it) - return '', url + return "", url base_path = url[:last_slash_before_template] - template_pattern = url[last_slash_before_template + 1:] + template_pattern = url[last_slash_before_template + 1 :] return base_path, template_pattern @@ -118,7 +121,7 @@ class MPDRewriter: root = ET.fromstring(mpd_content) # Register namespace to preserve it in output - ET.register_namespace('', self.MPD_NAMESPACE['mpd']) + ET.register_namespace("", self.MPD_NAMESPACE["mpd"]) # Get base URL for relative resolution base_url = self._extract_base_url(root, manifest_url) @@ -130,13 +133,15 @@ class MPDRewriter: self._rewrite_urls_recursive(root, base_url) # Convert back to string - rewritten = ET.tostring(root, encoding='unicode', method='xml') + rewritten = ET.tostring(root, encoding="unicode", method="xml") # Add XML declaration if not present - 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: @@ -162,17 +167,19 @@ class MPDRewriter: Base URL for resolving relative URLs """ # Try to find BaseURL element - base_url_elem = root.find('.//mpd:BaseURL', self.MPD_NAMESPACE) + base_url_elem = root.find(".//mpd:BaseURL", self.MPD_NAMESPACE) if base_url_elem is not None and base_url_elem.text: base_url_text = base_url_elem.text.strip() # Check if the BaseURL is relative (doesn't start with http:// or https://) - if not base_url_text.startswith(('http://', 'https://')): + if not base_url_text.startswith(("http://", "https://")): # It's a relative BaseURL, resolve it against the manifest URL's directory 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 @@ -193,8 +200,10 @@ class MPDRewriter: root: MPD root element """ # 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 parent in root.findall(".//*"): + for base_url_elem in list( + parent.findall("mpd:BaseURL", self.MPD_NAMESPACE) + ): parent.remove(base_url_elem) logger.debug("Removed BaseURL element") @@ -208,10 +217,10 @@ class MPDRewriter: """ # Attributes that contain URLs url_attributes = [ - 'media', # SegmentTemplate - 'initialization', # SegmentTemplate - 'sourceURL', # Initialization, RepresentationIndex - 'indexRange', # SegmentBase (not a URL but can be affected) + "media", # SegmentTemplate + "initialization", # SegmentTemplate + "sourceURL", # Initialization, RepresentationIndex + "indexRange", # SegmentBase (not a URL but can be affected) ] # Rewrite URL attributes in current element @@ -226,28 +235,34 @@ class MPDRewriter: resolved = urljoin(base_url, original_url) # Check if URL contains template variables - if '$' in resolved: + 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) - logger.debug(f"Rewrote template URL: {original_url} -> proxy with template {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}" + ) else: # Regular URL without templates element.attrib[attr] = self.build_proxy_url(resolved) logger.debug(f"Rewrote URL: {original_url} -> proxy") # Handle SegmentURL elements (used in SegmentList) - if element.tag.endswith('SegmentURL'): - if 'media' in element.attrib: - original_url = element.attrib['media'] + if element.tag.endswith("SegmentURL"): + if "media" in element.attrib: + original_url = element.attrib["media"] resolved = urljoin(base_url, original_url) # SegmentURL typically doesn't have templates, but handle it just in case - if '$' in resolved: + 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) + element.attrib["media"] = self.build_proxy_url(resolved) # Recurse to child elements for child in element: @@ -270,25 +285,25 @@ class MPDRewriter: Cache TTL in seconds """ # Check Cache-Control header - cache_control = headers.get('Cache-Control', headers.get('cache-control', '')) - if 'max-age=' in cache_control: + cache_control = headers.get("Cache-Control", headers.get("cache-control", "")) + if "max-age=" in cache_control: try: # Extract max-age value - for directive in cache_control.split(','): + for directive in cache_control.split(","): directive = directive.strip() - if directive.startswith('max-age='): - max_age = int(directive.split('=')[1]) + if directive.startswith("max-age="): + max_age = int(directive.split("=")[1]) logger.debug(f"Cache TTL from Cache-Control: {max_age}s") return max_age except (ValueError, IndexError) as e: logger.warning(f"Failed to parse max-age from Cache-Control: {e}") # Check Expires header - expires = headers.get('Expires', headers.get('expires')) + expires = headers.get("Expires", headers.get("expires")) if expires: try: - from email.utils import parsedate_to_datetime from datetime import datetime, timezone + from email.utils import parsedate_to_datetime expires_dt = parsedate_to_datetime(expires) now = datetime.now(timezone.utc) @@ -320,12 +335,12 @@ class MPDRewriter: root = ET.fromstring(mpd_content) # Check if dynamic manifest - mpd_type = root.attrib.get('type', 'static') - if mpd_type != 'dynamic': + mpd_type = root.attrib.get("type", "static") + if mpd_type != "dynamic": return None # Get minimumUpdatePeriod - update_period = root.attrib.get('minimumUpdatePeriod') + update_period = root.attrib.get("minimumUpdatePeriod") if update_period: # Parse ISO 8601 duration (e.g., "PT5S" = 5 seconds) return MPDRewriter._parse_iso_duration(update_period) @@ -351,23 +366,23 @@ class MPDRewriter: import re # Remove PT prefix - duration = duration.replace('PT', '') + duration = duration.replace("PT", "") # Parse hours, minutes, seconds hours = minutes = seconds = 0 - h_match = re.search(r'(\d+)H', duration) + h_match = re.search(r"(\d+)H", duration) if h_match: hours = int(h_match.group(1)) - m_match = re.search(r'(\d+)M', duration) + m_match = re.search(r"(\d+)M", duration) if m_match: minutes = int(m_match.group(1)) - s_match = re.search(r'(\d+(?:\.\d+)?)S', duration) + s_match = re.search(r"(\d+(?:\.\d+)?)S", duration) if s_match: seconds = float(s_match.group(1)) total_seconds = int(hours * 3600 + minutes * 60 + seconds) logger.debug(f"Parsed ISO duration '{duration}' to {total_seconds}s") - return total_seconds \ No newline at end of file + return total_seconds diff --git a/lib/streaming_providers/base/utils/timestamp_converter.py b/lib/streaming_providers/base/utils/timestamp_converter.py index e6fa446..0af6fcb 100644 --- a/lib/streaming_providers/base/utils/timestamp_converter.py +++ b/lib/streaming_providers/base/utils/timestamp_converter.py @@ -24,7 +24,7 @@ class TimestampConverter: ISO_WITH_TIMEZONE = "%Y-%m-%dT%H:%M:%S%z" # UTC timezone constant (Python 3.11+ compatibility) - if hasattr(datetime, 'UTC'): + if hasattr(datetime, "UTC"): UTC = datetime.UTC else: UTC = datetime.timezone.utc @@ -34,7 +34,7 @@ class TimestampConverter: epoch_seconds: Union[int, float], format_type: str = "extended", timezone: Optional[datetime.tzinfo] = None, - as_utc: bool = True + as_utc: bool = True, ) -> str: """ Convert Unix epoch seconds to ISO 8601 timestamp. @@ -56,7 +56,9 @@ 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) @@ -85,7 +87,7 @@ class TimestampConverter: def iso_to_epoch( iso_string: str, format_type: Optional[str] = None, - timezone: Optional[datetime.tzinfo] = None + timezone: Optional[datetime.tzinfo] = None, ) -> float: """ Convert ISO 8601 timestamp to Unix epoch seconds. @@ -105,7 +107,7 @@ class TimestampConverter: # First try datetime.fromisoformat() which handles many ISO formats try: # Clean up the string for fromisoformat - cleaned = iso_string.replace('Z', '+00:00') + cleaned = iso_string.replace("Z", "+00:00") dt = datetime.datetime.fromisoformat(cleaned) # If we got here, fromisoformat worked except ValueError: @@ -127,8 +129,7 @@ class TimestampConverter: @staticmethod def _parse_custom_iso( - iso_string: str, - format_type: Optional[str] = None + iso_string: str, format_type: Optional[str] = None ) -> datetime.datetime: """ Parse custom ISO formats not handled by fromisoformat. @@ -158,7 +159,7 @@ class TimestampConverter: "extended": TimestampConverter.ISO_EXTENDED, "basic": TimestampConverter.ISO_BASIC, "microseconds": TimestampConverter.ISO_WITH_MICROSECONDS, - "with_timezone": TimestampConverter.ISO_WITH_TIMEZONE + "with_timezone": TimestampConverter.ISO_WITH_TIMEZONE, } if format_type not in format_map: @@ -171,7 +172,9 @@ 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 @@ -179,8 +182,7 @@ class TimestampConverter: @staticmethod def now_iso( - format_type: str = "extended", - timezone: Optional[datetime.tzinfo] = None + format_type: str = "extended", timezone: Optional[datetime.tzinfo] = None ) -> str: """ Get current time as ISO 8601 string. @@ -243,7 +245,7 @@ class TimestampConverter: def duration_between( start: Union[int, float, str], end: Union[int, float, str], - unit: str = "seconds" + unit: str = "seconds", ) -> float: """ Calculate duration between two timestamps. @@ -271,9 +273,9 @@ class TimestampConverter: # Convert to requested unit unit_conversion = { "seconds": 1, - "minutes": 1/60, - "hours": 1/3600, - "days": 1/86400 + "minutes": 1 / 60, + "hours": 1 / 3600, + "days": 1 / 86400, } if unit not in unit_conversion: @@ -319,6 +321,7 @@ class TimestampConverter: # Try zoneinfo (Python 3.9+) try: from zoneinfo import ZoneInfo + return ZoneInfo(name) except ImportError: pass @@ -326,9 +329,10 @@ class TimestampConverter: # Try pytz try: import pytz + return pytz.timezone(name) except ImportError: raise ImportError( f"Need pytz or zoneinfo to use timezone '{name}'. " f"Install with: pip install pytz" - ) \ No newline at end of file + ) diff --git a/lib/streaming_providers/base/utils/vfs.py b/lib/streaming_providers/base/utils/vfs.py index 03d5b36..3f0da49 100644 --- a/lib/streaming_providers/base/utils/vfs.py +++ b/lib/streaming_providers/base/utils/vfs.py @@ -6,11 +6,10 @@ Provides transparent file operations for both Kodi and regular Python environmen import json import os -from typing import Optional, Any, List, Dict, Tuple +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 @@ -36,7 +35,9 @@ 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: @@ -48,11 +49,13 @@ class VFS: logger.info(f"Using explicit config directory: {self._base_path}") else: # Use profile path from environment manager - profile_path = self._env_manager.get_config('profile_path', '') + profile_path = self._env_manager.get_config("profile_path", "") 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) @@ -62,7 +65,7 @@ class VFS: logger.info(f"Base path from environment: {self._base_path}") # Ensure base directory exists - self.mkdirs('') + self.mkdirs("") return self._base_path @@ -81,7 +84,7 @@ class VFS: path = self.base_path for part in parts: if part: - path = path.rstrip('/') + '/' + str(part).lstrip('/') + path = path.rstrip("/") + "/" + str(part).lstrip("/") return path else: # Use os.path.join for standard filesystem @@ -103,9 +106,11 @@ class VFS: if is_kodi_environment(): import xbmcvfs + return xbmcvfs.exists(filepath) else: import pathlib + return pathlib.Path(filepath).exists() except Exception as e: logger.error(f"Error checking if {filepath} exists: {e}") @@ -127,6 +132,7 @@ class VFS: if is_kodi_environment(): import xbmcvfs + if not xbmcvfs.exists(dirpath): result = xbmcvfs.mkdirs(dirpath) logger.debug(f"Kodi mkdirs {dirpath}: {result}") @@ -134,13 +140,14 @@ class VFS: return True else: import pathlib + pathlib.Path(dirpath).mkdir(parents=True, exist_ok=True) return True except Exception as e: logger.error(f"Error creating directory {dirpath}: {e}") return False - def read_text(self, filepath: str, encoding: str = 'utf-8') -> Optional[str]: + def read_text(self, filepath: str, encoding: str = "utf-8") -> Optional[str]: """ Read text content from file @@ -157,23 +164,25 @@ class VFS: if is_kodi_environment(): import xbmcvfs + if not xbmcvfs.exists(filepath): return None - with xbmcvfs.File(filepath, 'r') as f: + with xbmcvfs.File(filepath, "r") as f: content = f.read() return content if content else None else: import pathlib + path = pathlib.Path(filepath) if not path.exists(): return None - with open(path, 'r', encoding=encoding) as f: + with open(path, "r", encoding=encoding) as f: return f.read() except Exception as e: logger.error(f"Error reading file {filepath}: {e}") return None - def write_text(self, filepath: str, content: str, encoding: str = 'utf-8') -> bool: + def write_text(self, filepath: str, content: str, encoding: str = "utf-8") -> bool: """ Write text content to file @@ -191,20 +200,24 @@ class VFS: if is_kodi_environment(): import xbmcvfs + # Ensure directory exists - dir_path = '/'.join(filepath.split('/')[:-1]) + dir_path = "/".join(filepath.split("/")[:-1]) if dir_path and not xbmcvfs.exists(dir_path): xbmcvfs.mkdirs(dir_path) - with xbmcvfs.File(filepath, 'w') as f: + 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 + path = pathlib.Path(filepath) path.parent.mkdir(parents=True, exist_ok=True) - with open(path, 'w', encoding=encoding) as f: + with open(path, "w", encoding=encoding) as f: f.write(content) return True except Exception as e: @@ -227,11 +240,13 @@ class VFS: if is_kodi_environment(): import xbmcvfs + if xbmcvfs.exists(filepath): return xbmcvfs.delete(filepath) return True else: import pathlib + path = pathlib.Path(filepath) if path.exists(): path.unlink() @@ -299,8 +314,10 @@ class VFS: dirpath = self.join_path(dirpath) if is_kodi_environment(): - import xbmcvfs import fnmatch + + import xbmcvfs + if not xbmcvfs.exists(dirpath): return [] @@ -313,6 +330,7 @@ class VFS: return [f for f in files if fnmatch.fnmatch(f, pattern)] else: import pathlib + path = pathlib.Path(dirpath) if not path.exists() or not path.is_dir(): return [] @@ -341,12 +359,14 @@ class VFS: if is_kodi_environment(): import xbmcvfs + if not xbmcvfs.exists(filepath): return None stat = xbmcvfs.Stat(filepath) return stat.st_size() else: import pathlib + path = pathlib.Path(filepath) if not path.exists(): return None @@ -383,35 +403,38 @@ class VFS: Dictionary with debug information """ info = { - 'kodi_available': is_kodi_environment(), - 'base_path': self.base_path, - 'base_path_exists': self.exists(''), - 'explicit_config_dir': self._explicit_config_dir, - 'addon_subdir': self.addon_subdir, - 'environment': self._env_manager.get_environment() + "kodi_available": is_kodi_environment(), + "base_path": self.base_path, + "base_path_exists": self.exists(""), + "explicit_config_dir": self._explicit_config_dir, + "addon_subdir": self.addon_subdir, + "environment": self._env_manager.get_environment(), } if is_kodi_environment(): try: - import xbmcaddon import xbmc + import xbmcaddon + addon = xbmcaddon.Addon() - info.update({ - 'addon_id': addon.getAddonInfo('id'), - 'addon_version': addon.getAddonInfo('version'), - 'kodi_version': xbmc.getInfoLabel('System.BuildVersion'), - }) + info.update( + { + "addon_id": addon.getAddonInfo("id"), + "addon_version": addon.getAddonInfo("version"), + "kodi_version": xbmc.getInfoLabel("System.BuildVersion"), + } + ) except Exception as e: - info['kodi_error'] = str(e) + info["kodi_error"] = str(e) return info # Cache for VFS instances with different configurations -_vfs_cache: Dict[Tuple[Optional[str], str], 'VFS'] = {} +_vfs_cache: Dict[Tuple[Optional[str], str], "VFS"] = {} -def get_vfs(config_dir: Optional[str] = None, addon_subdir: str = "") -> 'VFS': +def get_vfs(config_dir: Optional[str] = None, addon_subdir: str = "") -> "VFS": """ Get VFS instance for specific configuration @@ -438,7 +461,7 @@ def get_vfs(config_dir: Optional[str] = None, addon_subdir: str = "") -> 'VFS': _global_vfs = None -def get_global_vfs(config_dir: Optional[str] = None, addon_subdir: str = "") -> 'VFS': +def get_global_vfs(config_dir: Optional[str] = None, addon_subdir: str = "") -> "VFS": """ Get global VFS instance (for backward compatibility) @@ -451,40 +474,62 @@ 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) -def read_text(filepath: str, encoding: str = 'utf-8', config_dir: Optional[str] = None, addon_subdir: str = "") -> \ -Optional[str]: +def read_text( + filepath: str, + encoding: str = "utf-8", + config_dir: Optional[str] = None, + addon_subdir: str = "", +) -> Optional[str]: """Read text file""" return get_vfs(config_dir, addon_subdir).read_text(filepath, encoding) -def write_text(filepath: str, content: str, encoding: str = 'utf-8', config_dir: Optional[str] = None, - addon_subdir: str = "") -> bool: +def write_text( + filepath: str, + content: str, + encoding: str = "utf-8", + config_dir: Optional[str] = None, + addon_subdir: str = "", +) -> bool: """Write text file""" return get_vfs(config_dir, addon_subdir).write_text(filepath, content, encoding) -def read_json(filepath: str, config_dir: Optional[str] = None, addon_subdir: str = "") -> Optional[dict]: +def read_json( + filepath: str, config_dir: Optional[str] = None, addon_subdir: str = "" +) -> Optional[dict]: """Read JSON file""" return get_vfs(config_dir, addon_subdir).read_json(filepath) -def write_json(filepath: str, data: Any, indent: int = 2, config_dir: Optional[str] = None, - addon_subdir: str = "") -> bool: +def write_json( + filepath: str, + data: Any, + indent: int = 2, + config_dir: Optional[str] = None, + addon_subdir: str = "", +) -> bool: """Write JSON file""" 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) @@ -492,4 +537,4 @@ def delete(filepath: str, config_dir: Optional[str] = None, addon_subdir: str = def join_path(*parts, config_dir: Optional[str] = None, addon_subdir: str = "") -> str: """Join path components""" vfs = get_vfs(config_dir, addon_subdir) - return vfs.join_path(*parts) \ No newline at end of file + return vfs.join_path(*parts) diff --git a/lib/streaming_providers/providers/auth.py b/lib/streaming_providers/providers/auth.py index 944b6b3..113d272 100644 --- a/lib/streaming_providers/providers/auth.py +++ b/lib/streaming_providers/providers/auth.py @@ -1,5 +1,6 @@ from abc import ABC, abstractmethod -from typing import Dict, Any, List +from typing import Any, Dict, List + from ..base.models.auth import AuthStatus @@ -70,4 +71,4 @@ class AuthContext: return self.session.load_token_data(provider_name, country) def get_all_scopes(self, provider_name: str, country: str = None) -> List[str]: - return self.session.list_scoped_tokens(provider_name, country) \ No newline at end of file + return self.session.list_scoped_tokens(provider_name, country) diff --git a/lib/streaming_providers/providers/auth_builder.py b/lib/streaming_providers/providers/auth_builder.py index 1dc34ee..191222e 100644 --- a/lib/streaming_providers/providers/auth_builder.py +++ b/lib/streaming_providers/providers/auth_builder.py @@ -1,8 +1,9 @@ # streaming_providers/providers/auth_builder.py -from typing import Dict, Any, Optional, Tuple +from typing import Any, Dict, Optional, Tuple + +from ..base.models.auth import AuthState, AuthStatus, TokenInfo from .auth_context import AuthContext -from ..base.models.auth import AuthStatus, TokenInfo, AuthState class AuthStatusBuilder: @@ -42,9 +43,12 @@ class AuthStatusBuilder: ) # Get token expiration info - (token_expires_at, token_expires_in_seconds, - refresh_token_expires_at, refresh_token_expires_in_seconds) = \ - AuthStatusBuilder._get_token_expiration_info(provider, context) + ( + token_expires_at, + token_expires_in_seconds, + refresh_token_expires_at, + refresh_token_expires_in_seconds, + ) = AuthStatusBuilder._get_token_expiration_info(provider, context) # Build and return return AuthStatus( @@ -61,21 +65,25 @@ 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, refresh_token_expires_at=refresh_token_expires_at, - refresh_token_expires_in_seconds=refresh_token_expires_in_seconds + refresh_token_expires_in_seconds=refresh_token_expires_in_seconds, ) # ===== 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'): + if hasattr(provider, "_calculate_auth_state"): custom_state = provider._calculate_auth_state(context) if custom_state: return custom_state @@ -84,10 +92,12 @@ 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'): + if hasattr(provider, "_calculate_readiness"): custom_result = provider._calculate_readiness(context) if custom_result: return custom_result @@ -102,7 +112,7 @@ class AuthStatusBuilder: """Standard auth state calculation""" # Anonymous providers don't need auth - if 'anonymous' in provider.supported_auth_types: + if "anonymous" in provider.supported_auth_types: return AuthState.NOT_APPLICABLE # Check if we have any valid token @@ -110,7 +120,9 @@ 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 @@ -131,7 +143,7 @@ class AuthStatusBuilder: """ # 1. Anonymous providers are always ready - if 'anonymous' in provider.supported_auth_types: + if "anonymous" in provider.supported_auth_types: return True, "Anonymous provider always ready" # 2. Check if we have a valid token (MOST IMPORTANT) @@ -139,13 +151,17 @@ 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" @@ -153,7 +169,7 @@ class AuthStatusBuilder: return False, "Has credentials but needs authentication" # 5. Network-based providers might be authenticating - if 'network_based' in provider.supported_auth_types: + if "network_based" in provider.supported_auth_types: return False, "Network authentication in progress" # 6. Not ready @@ -166,7 +182,9 @@ class AuthStatusBuilder: """Check if provider has any valid token""" # Check primary scope first if provider.primary_token_scope: - token = context.get_token(provider.provider_name, provider.primary_token_scope, provider.country) + token = context.get_token( + provider.provider_name, provider.primary_token_scope, provider.country + ) if token and not context.is_token_expired(token): return True @@ -184,23 +202,27 @@ 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: - token = context.get_token(provider.provider_name, provider.primary_token_scope, provider.country) - if token and context.is_token_expired(token) and token.get('refresh_token'): + token = context.get_token( + provider.provider_name, provider.primary_token_scope, provider.country + ) + if token and context.is_token_expired(token) and token.get("refresh_token"): return token # Check all scopes for scope in provider.token_scopes: token = context.get_token(provider.provider_name, scope, provider.country) - if token and context.is_token_expired(token) and token.get('refresh_token'): + if token and context.is_token_expired(token) and token.get("refresh_token"): return token # Check root-level token token = context.get_token(provider.provider_name, None, provider.country) - if token and context.is_token_expired(token) and token.get('refresh_token'): + if token and context.is_token_expired(token) and token.get("refresh_token"): return token return None @@ -211,32 +233,32 @@ 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: - expires_at = token_data['issued_at'] + token_data['expires_in'] + if "issued_at" in token_data and "expires_in" in token_data: + expires_at = token_data["issued_at"] + token_data["expires_in"] token_info = TokenInfo( scope=scope, has_token=True, is_valid=not context.is_token_expired(token_data), expires_at=expires_at, - has_refresh_token=bool(token_data.get('refresh_token')), - auth_level=token_data.get('auth_level') + has_refresh_token=bool(token_data.get("refresh_token")), + auth_level=token_data.get("auth_level"), ) else: - token_info = TokenInfo( - scope=scope, - has_token=False, - is_valid=False - ) + token_info = TokenInfo(scope=scope, has_token=False, is_valid=False) token_scopes[scope] = token_info 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 @@ -251,38 +273,41 @@ class AuthStatusBuilder: def _get_last_auth_time(provider, context: AuthContext) -> Optional[float]: """Get last authentication time from token""" if provider.primary_token_scope: - token = context.get_token(provider.provider_name, provider.primary_token_scope, provider.country) - if token and 'issued_at' in token: - return token['issued_at'] + token = context.get_token( + provider.provider_name, provider.primary_token_scope, provider.country + ) + if token and "issued_at" in token: + return token["issued_at"] # Check root-level token token = context.get_token(provider.provider_name, None, provider.country) - if token and 'issued_at' in token: - return token['issued_at'] + if token and "issued_at" in token: + return token["issued_at"] return None @staticmethod - def _get_provider_specific_details(provider, context: AuthContext, - current_auth_type: str) -> Dict[str, Any]: + def _get_provider_specific_details( + provider, context: AuthContext, current_auth_type: str + ) -> Dict[str, Any]: """Get provider-specific auth details""" details = { - 'supported_auth_types': provider.supported_auth_types, - 'preferred_auth_type': provider.preferred_auth_type, - 'current_auth_type': current_auth_type + "supported_auth_types": provider.supported_auth_types, + "preferred_auth_type": provider.preferred_auth_type, + "current_auth_type": current_auth_type, } # Add provider's own details if available - if hasattr(provider, 'get_auth_details'): + if hasattr(provider, "get_auth_details"): custom_details = provider.get_auth_details(context) details.update(custom_details) return details @staticmethod - def _get_token_expiration_info(provider, context: AuthContext) -> Tuple[ - Optional[float], Optional[int], Optional[float], Optional[int] - ]: + def _get_token_expiration_info( + provider, context: AuthContext + ) -> Tuple[Optional[float], Optional[int], Optional[float], Optional[int]]: """ Get token expiration information. @@ -291,6 +316,7 @@ class AuthStatusBuilder: refresh_token_expires_at, refresh_token_expires_in_seconds) """ import time + current_time = time.time() token_expires_at: Optional[float] = None @@ -304,26 +330,20 @@ class AuthStatusBuilder: # Check primary scope first if provider.primary_token_scope: token_data = context.get_token( - provider.provider_name, - provider.primary_token_scope, - provider.country + provider.provider_name, provider.primary_token_scope, provider.country ) # 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 + 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 + provider.provider_name, scope, provider.country ) if token_data: break @@ -333,36 +353,55 @@ class AuthStatusBuilder: # Calculate access token expiration # Standard format: expires_in + issued_at - 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 "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) + ): 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: - 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)): + 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) + ): token_expires_at = float(issued_at) + float(expires_in) token_expires_in_seconds = int(token_expires_at - current_time) # Direct expiration timestamp - elif 'expires_at' in token_data: - expires_at = token_data['expires_at'] + elif "expires_at" in token_data: + expires_at = token_data["expires_at"] if isinstance(expires_at, (int, float)): token_expires_at = float(expires_at) 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): - 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) + 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 + ) - return (token_expires_at, token_expires_in_seconds, - refresh_token_expires_at, refresh_token_expires_in_seconds) \ No newline at end of file + return ( + token_expires_at, + token_expires_in_seconds, + refresh_token_expires_at, + refresh_token_expires_in_seconds, + ) diff --git a/lib/streaming_providers/providers/auth_context.py b/lib/streaming_providers/providers/auth_context.py index 5f38c00..8e23c03 100644 --- a/lib/streaming_providers/providers/auth_context.py +++ b/lib/streaming_providers/providers/auth_context.py @@ -1,5 +1,5 @@ # streaming_providers/providers/auth_context.py -from typing import Optional, List, Dict, Any +from typing import Any, Dict, List, Optional class AuthContext: @@ -11,7 +11,9 @@ 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""" @@ -19,8 +21,9 @@ class AuthContext: return self.settings.get_provider_credentials(provider_name, country) return None - def get_token(self, provider_name: str, scope: str = None, - country: str = None) -> Optional[Dict[str, Any]]: + def get_token( + self, provider_name: str, scope: str = None, country: str = None + ) -> Optional[Dict[str, Any]]: """ Get token for provider (scoped or root-level). @@ -68,13 +71,13 @@ class AuthContext: if not self.session: return [] - if hasattr(self.session, 'list_scoped_tokens'): + if hasattr(self.session, "list_scoped_tokens"): return self.session.list_scoped_tokens(provider_name, country) return [] def is_token_expired(self, token_data): """Check if token is expired""" - if self.session and hasattr(self.session, '_is_token_expired'): + if self.session and hasattr(self.session, "_is_token_expired"): return self.session._is_token_expired(token_data) - return True # Conservative default \ No newline at end of file + return True # Conservative default diff --git a/lib/streaming_providers/providers/hrti/__init__.py b/lib/streaming_providers/providers/hrti/__init__.py index 7162e0b..c294164 100644 --- a/lib/streaming_providers/providers/hrti/__init__.py +++ b/lib/streaming_providers/providers/hrti/__init__.py @@ -6,5 +6,6 @@ HRTi streaming provider module """ from .provider import HRTiProvider + __all__ = ["HRTiProvider"] -# [file content end] \ No newline at end of file +# [file content end] diff --git a/lib/streaming_providers/providers/hrti/auth.py b/lib/streaming_providers/providers/hrti/auth.py index 9e39b5b..3e8efc9 100644 --- a/lib/streaming_providers/providers/hrti/auth.py +++ b/lib/streaming_providers/providers/hrti/auth.py @@ -1,23 +1,29 @@ # [file name]: auth.py # [file content begin] # streaming_providers/providers/hrti/auth.py -import json import base64 +import json import time import uuid -from typing import Dict, Any, Optional from datetime import datetime, timedelta +from typing import Any, Dict, Optional -from ...base.auth.base_auth import BaseAuthenticator, BaseAuthToken, TokenAuthLevel -from ...base.utils.logger import logger -from .models import HRTiCredentials, HRTiAuthToken -from .constants import HRTiConfig +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 +from .models import HRTiAuthToken, HRTiCredentials class HRTiAuthenticator(BaseAuthenticator): - def __init__(self, credentials=None, config_dir=None, - proxy_config: Optional[ProxyConfig] = None, http_manager=None): + def __init__( + self, + credentials=None, + config_dir=None, + proxy_config: Optional[ProxyConfig] = None, + http_manager=None, + ): # Initialize configuration FIRST self._config = HRTiConfig() @@ -25,8 +31,9 @@ class HRTiAuthenticator(BaseAuthenticator): # Get proxy_config if not provided if proxy_config is None: from ...base.network import ProxyConfigManager + proxy_mgr = ProxyConfigManager(config_dir) - proxy_config = proxy_mgr.get_proxy_config('hrti') + proxy_config = proxy_mgr.get_proxy_config("hrti") # Store HTTP manager and proxy config locally (like MagentaEU) self._http_manager = http_manager @@ -43,10 +50,10 @@ class HRTiAuthenticator(BaseAuthenticator): # Call parent init WITHOUT proxy_config and http_manager (like MagentaEU) super().__init__( - provider_name='hrti', + provider_name="hrti", credentials=credentials, country=None, # Changed from 'HR' to None for flat credential structure - config_dir=config_dir + config_dir=config_dir, # NO proxy_config or http_manager passed to parent ) @@ -63,7 +70,9 @@ class HRTiAuthenticator(BaseAuthenticator): """Safe access to http_manager - required by provider""" if self._http_manager is None: # This should never happen since we validate in __init__ - raise ValueError("HTTP manager not available - this should have been set during initialization") + raise ValueError( + "HTTP manager not available - this should have been set during initialization" + ) return self._http_manager @http_manager.setter @@ -74,7 +83,7 @@ class HRTiAuthenticator(BaseAuthenticator): @property def auth_endpoint(self) -> str: """HRTi authentication endpoint""" - return self.config.api_endpoints['grant_access'] + return self.config.api_endpoints["grant_access"] def _get_auth_headers(self) -> Dict[str, str]: """Get headers specifically for authentication endpoint""" @@ -85,21 +94,23 @@ class HRTiAuthenticator(BaseAuthenticator): # Special headers for authentication endpoint with /signin referer headers = { - 'User-Agent': self.config.user_agent, - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'deviceid': device_id, - 'devicetypeid': self.config.device_reference_id, + "User-Agent": self.config.user_agent, + "Accept": "application/json", + "Content-Type": "application/json", + "deviceid": device_id, + "devicetypeid": self.config.device_reference_id, # REMOVED: 'host': 'hrti.hrt.hr', # Let HTTP library set this automatically - 'ipaddress': self._ip_address, - 'operatorreferenceid': self.config.operator_reference_id, - 'origin': self.config.base_website, - 'referer': f'{self.config.base_website}/signin' + "ipaddress": self._ip_address, + "operatorreferenceid": self.config.operator_reference_id, + "origin": self.config.base_website, + "referer": f"{self.config.base_website}/signin", } 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() @@ -107,91 +118,100 @@ class HRTiAuthenticator(BaseAuthenticator): device_id = self.get_device_id() if referer is None: - referer = f'{self.config.base_website}/login' + referer = f"{self.config.base_website}/login" headers = { - 'connection': 'keep-alive', - 'User-Agent': self.config.user_agent, - 'Accept': 'application/json, text/plain, */*', - 'Content-Type': 'application/json', - 'deviceid': device_id, - 'devicetypeid': self.config.device_reference_id, + "connection": "keep-alive", + "User-Agent": self.config.user_agent, + "Accept": "application/json, text/plain, */*", + "Content-Type": "application/json", + "deviceid": device_id, + "devicetypeid": self.config.device_reference_id, # REMOVED: 'host': 'hrti.hrt.hr', # Let HTTP library set this automatically - 'ipaddress': self._ip_address, - 'operatorreferenceid': self.config.operator_reference_id, - 'origin': self.config.base_website, - 'referer': referer, - 'accept-encoding': 'gzip, deflate, br', - 'accept-language': 'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7', + "ipaddress": self._ip_address, + "operatorreferenceid": self.config.operator_reference_id, + "origin": self.config.base_website, + "referer": referer, + "accept-encoding": "gzip, deflate, br", + "accept-language": "de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7", } # 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]}...") + headers["authorization"] = f"Client {bearer_token}" + 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, - "OperatorReferenceId": self.config.operator_reference_id + "OperatorReferenceId": self.config.operator_reference_id, } else: # Fallback for any other credential type return { - "Username": getattr(self.credentials, 'username', ''), - "Password": getattr(self.credentials, 'password', ''), - "OperatorReferenceId": self.config.operator_reference_id + "Username": getattr(self.credentials, "username", ""), + "Password": getattr(self.credentials, "password", ""), + "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', {}) + result = response_data.get("Result", {}) # Debug the response structure logger.debug(f"GrantAccess response Result keys: {list(result.keys())}") # Extract token - access_token = result.get('Token', '') - logger.debug(f"Extracted access_token: {'present' if access_token else 'MISSING'}") + access_token = result.get("Token", "") + 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: - self._user_id = result['Customer'].get('CustomerId', '') + if "Customer" in result: + self._user_id = result["Customer"].get("CustomerId", "") logger.debug(f"Extracted user_id: {self._user_id}") else: logger.warning("No Customer data in response") token = HRTiAuthToken( access_token=access_token, - token_type='Client', + token_type="Client", expires_in=86400, # 24 hours default issued_at=time.time(), - user_id=self._user_id or '', # Ensure user_id is never None - valid_from=result.get('ValidFrom', ''), - valid_to=result.get('ValidTo', '') + user_id=self._user_id or "", # Ensure user_id is never None + valid_from=result.get("ValidFrom", ""), + valid_to=result.get("ValidTo", ""), ) # Classify the token immediately after creation token.auth_level = self._classify_token(token) - logger.debug(f"Created HRTiAuthToken - access_token: {bool(token.access_token)}, user_id: {token.user_id}") + logger.debug( + f"Created HRTiAuthToken - access_token: {bool(token.access_token)}, user_id: {token.user_id}" + ) return token def get_fallback_credentials(self): """Get fallback credentials (anonymous access)""" - return HRTiCredentials( - username='anonymoushrt', - password='an0nPasshrt' - ) + return HRTiCredentials(username="anonymoushrt", password="an0nPasshrt") def _perform_authentication(self) -> HRTiAuthToken: """Perform HRTi custom authentication flow""" @@ -211,7 +231,9 @@ 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: @@ -223,7 +245,9 @@ class HRTiAuthenticator(BaseAuthenticator): # Step 6: Get content rating and profiles self._get_initial_data() - logger.info(f"HRTi authentication successful - user_id: {token.user_id}, auth_level: {token.auth_level.value}") + logger.info( + f"HRTi authentication successful - user_id: {token.user_id}, auth_level: {token.auth_level.value}" + ) # Save the session after successful authentication self._save_session() @@ -240,7 +264,9 @@ 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}") @@ -249,11 +275,12 @@ class HRTiAuthenticator(BaseAuthenticator): try: logger.debug("No configured IP found, fetching from API...") response = self.http_manager.get( - self.config.api_endpoints['get_ip'], - operation='api' + 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: @@ -269,7 +296,7 @@ class HRTiAuthenticator(BaseAuthenticator): Configured IP address or None if not available """ # Access settings_manager through parent class - if not hasattr(self, 'settings_manager') or not self.settings_manager: + if not hasattr(self, "settings_manager") or not self.settings_manager: logger.debug("Settings manager not available for IP configuration") return None @@ -280,7 +307,7 @@ class HRTiAuthenticator(BaseAuthenticator): # Try to read IP from Kodi settings # HRTi doesn't use country in this implementation, pass None - configured_ip = kodi_bridge.read_ip_address_from_kodi('hrti', None) + configured_ip = kodi_bridge.read_ip_address_from_kodi("hrti", None) if configured_ip: logger.debug(f"Found configured IP in Kodi settings: {configured_ip}") @@ -293,16 +320,14 @@ class HRTiAuthenticator(BaseAuthenticator): try: # Get env config env_response = self.http_manager.get( - self.config.env_endpoint, - operation='api' + 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' + self.config.config_endpoint, operation="api" ) config_response.raise_for_status() config_data = config_response.json() @@ -323,7 +348,9 @@ 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}") @@ -333,37 +360,46 @@ class HRTiAuthenticator(BaseAuthenticator): # Log the request details (safely) safe_headers = headers.copy() - if 'deviceid' in safe_headers: - safe_headers['deviceid'] = f"{safe_headers['deviceid'][:8]}..." + if "deviceid" in safe_headers: + safe_headers["deviceid"] = f"{safe_headers['deviceid'][:8]}..." logger.debug(f"HRTi Auth Headers: {safe_headers}") safe_payload = payload.copy() - if 'Password' in safe_payload: - safe_payload['Password'] = '***' if safe_payload['Password'] else '' + if "Password" in safe_payload: + safe_payload["Password"] = ( + "***" if safe_payload["Password"] else "" + ) logger.debug(f"HRTi Auth Payload: {safe_payload}") response = self.http_manager.post( self.auth_endpoint, - operation='auth', + operation="auth", headers=headers, - data=json.dumps(payload) + data=json.dumps(payload), ) logger.debug(f"HRTi Auth Response - Status: {response.status_code}") response.raise_for_status() result = response.json() - if 'Result' not in result: + 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 except Exception as e: logger.error(f"HRTi grant access failed: {e}") # Only fallback to anonymous if we're not already using it - if not isinstance(self.credentials, HRTiCredentials) or self.credentials.username != 'anonymoushrt': + if ( + not isinstance(self.credentials, HRTiCredentials) + or self.credentials.username != "anonymoushrt" + ): logger.info("Falling back to anonymous credentials") self.credentials = self.get_fallback_credentials() return self._perform_grant_access() @@ -373,7 +409,9 @@ 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 @@ -382,11 +420,11 @@ class HRTiAuthenticator(BaseAuthenticator): headers = self._get_api_headers(bearer_token) # Update the referer to root path for device registration - headers['referer'] = f'{self.config.base_website}/' + headers["referer"] = f"{self.config.base_website}/" # Add cache-control and pragma headers to match working example - headers['cache-control'] = 'no-cache' - headers['pragma'] = 'no-cache' + headers["cache-control"] = "no-cache" + headers["pragma"] = "no-cache" payload = { "DeviceSerial": self._device_id, @@ -396,16 +434,16 @@ class HRTiAuthenticator(BaseAuthenticator): "ApplicationVersion": self.config.application_version, "DrmId": self._device_id, "OsVersion": self.config.os_version, - "ClientType": self.config.client_type + "ClientType": self.config.client_type, } logger.debug(f"HRTi Device Registration Payload: {payload}") response = self.http_manager.post( - self.config.api_endpoints['register_device'], - operation='api', + self.config.api_endpoints["register_device"], + operation="api", headers=headers, - data=json.dumps(payload) + data=json.dumps(payload), ) logger.debug(f"Device registration response status: {response.status_code}") @@ -420,27 +458,29 @@ 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 - headers['referer'] = f'{self.config.base_website}/' + headers["referer"] = f"{self.config.base_website}/" # Get content ratings content_response = self.http_manager.post( - self.config.api_endpoints['content_ratings'], - operation='api', + self.config.api_endpoints["content_ratings"], + operation="api", headers=headers, - data=json.dumps({}) + data=json.dumps({}), ) content_response.raise_for_status() # Get profiles profiles_response = self.http_manager.post( - self.config.api_endpoints['profiles'], - operation='api', + self.config.api_endpoints["profiles"], + operation="api", headers=headers, - data=json.dumps({}) + data=json.dumps({}), ) profiles_response.raise_for_status() @@ -452,14 +492,18 @@ 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: @@ -499,29 +543,37 @@ class HRTiAuthenticator(BaseAuthenticator): except Exception as e: logger.error(f"Error in HRTi session loading: {e}") # Ensure _user_id is at least an empty string - self._user_id = self._user_id or '' + self._user_id = self._user_id or "" - def authorize_session(self, content_type: str, content_ref_id: str, - content_drm_id: str = None, - video_store_ids: list = None, - channel_id: str = None, - start_time: str = None, - end_time: str = None) -> Optional[Dict[str, Any]]: + def authorize_session( + self, + content_type: str, + content_ref_id: str, + content_drm_id: str = None, + video_store_ids: list = None, + channel_id: str = None, + start_time: str = None, + end_time: str = None, + ) -> 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 if channel_id is None: - headers['referer'] = f'{self.config.base_website}/videostore' + 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' + headers["referer"] = f"{self.config.base_website}/live/radio" else: - headers['referer'] = f'{self.config.base_website}/live/' + headers["referer"] = f"{self.config.base_website}/live/" payload = { "ContentType": content_type, @@ -530,25 +582,31 @@ class HRTiAuthenticator(BaseAuthenticator): "VideostoreReferenceIds": video_store_ids, "ChannelReferenceId": channel_id, "StartTime": start_time, - "EndTime": end_time + "EndTime": end_time, } - logger.debug(f"Authorizing session - type: {content_type}, ref: {content_ref_id}, drm: {content_drm_id}") + logger.debug( + f"Authorizing session - type: {content_type}, ref: {content_ref_id}, drm: {content_drm_id}" + ) response = self.http_manager.post( - self.config.api_endpoints['authorize_session'], - operation='api', + self.config.api_endpoints["authorize_session"], + operation="api", headers=headers, - data=json.dumps(payload) + data=json.dumps(payload), ) response.raise_for_status() result = response.json() - if 'Result' in result: - authorized = result['Result'].get('Authorized', False) - session_id = result['Result'].get('SessionId') or result['Result'].get('DrmId') - logger.debug(f"Session authorization result - authorized: {authorized}, session_id: {session_id}") - return result['Result'] + if "Result" in result: + authorized = result["Result"].get("Authorized", False) + session_id = result["Result"].get("SessionId") or result["Result"].get( + "DrmId" + ) + logger.debug( + f"Session authorization result - authorized: {authorized}, session_id: {session_id}" + ) + return result["Result"] else: logger.warning("No result in session authorization response") return None @@ -560,25 +618,26 @@ 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' + 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, # 1 = play start - "SessionId": session_id - } + payload = {"SessionEventId": 1, "SessionId": session_id} # 1 = play start response = self.http_manager.post( - self.config.api_endpoints['report_session'], - operation='api', + self.config.api_endpoints["report_session"], + operation="api", headers=headers, - data=json.dumps(payload) + data=json.dumps(payload), ) response.raise_for_status() @@ -593,19 +652,22 @@ class HRTiAuthenticator(BaseAuthenticator): """Generate license data for DRM - returns base64 encoded string""" try: drm_license = { - 'userId': self._user_id or '', - 'sessionId': session_id, - 'merchant': self.config.merchant + "userId": self._user_id or "", + "sessionId": session_id, + "merchant": self.config.merchant, } logger.debug( - f"Creating license data - userId: {self._user_id}, sessionId: {session_id}, merchant: {self.config.merchant}") + f"Creating license data - userId: {self._user_id}, sessionId: {session_id}, merchant: {self.config.merchant}" + ) # Encode to JSON then to base64 - license_bytes = json.dumps(drm_license).encode('utf-8') - license_b64 = base64.b64encode(license_bytes).decode('utf-8') + 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: @@ -626,7 +688,7 @@ class HRTiAuthenticator(BaseAuthenticator): # Check if it's an HRTiAuthToken and has user_id attribute if isinstance(token, HRTiAuthToken): # Check if it's an anonymous token by user_id - if token.user_id == 'anonymoushrt': + if token.user_id == "anonymoushrt": return TokenAuthLevel.ANONYMOUS # Check if we have user credentials available @@ -634,29 +696,43 @@ class HRTiAuthenticator(BaseAuthenticator): # Check stored credentials first try: - stored_creds = self.settings_manager.get_provider_credentials(self.provider_name, self.country) + stored_creds = self.settings_manager.get_provider_credentials( + 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() + has_user_creds = ( + isinstance(stored_creds, UserPasswordCredentials) + and stored_creds.validate() + ) # Check current credentials if not has_user_creds: - has_user_creds = isinstance(self.credentials, UserPasswordCredentials) and self.credentials.validate() + has_user_creds = ( + isinstance(self.credentials, UserPasswordCredentials) + and self.credentials.validate() + ) # If we have user credentials and token has a real user_id, it's user authenticated - if has_user_creds and token.user_id and token.user_id != 'anonymoushrt': + if has_user_creds and token.user_id and token.user_id != "anonymoushrt": 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 - if (hasattr(self.credentials, 'username') and - self.credentials.username and - self.credentials.username != 'anonymoushrt'): + if ( + hasattr(self.credentials, "username") + and self.credentials.username + and self.credentials.username != "anonymoushrt" + ): return TokenAuthLevel.USER_AUTHENTICATED return TokenAuthLevel.CLIENT_CREDENTIALS @@ -678,26 +754,39 @@ class HRTiAuthenticator(BaseAuthenticator): # Check if we have stored user credentials try: - stored_creds = self.settings_manager.get_provider_credentials(self.provider_name, self.country) + stored_creds = self.settings_manager.get_provider_credentials( + 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() + has_stored_user_creds = ( + isinstance(stored_creds, UserPasswordCredentials) + and stored_creds.validate() + ) # Check current credentials - has_current_user_creds = isinstance(self.credentials, - UserPasswordCredentials) and self.credentials.validate() + has_current_user_creds = ( + isinstance(self.credentials, UserPasswordCredentials) + and self.credentials.validate() + ) # Upgrade if we have any user credentials return has_stored_user_creds or has_current_user_creds 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 """ - logger.debug(f"HRTi get_bearer_token: force_refresh={force_refresh}, force_upgrade={force_upgrade}") + logger.debug( + f"HRTi get_bearer_token: force_refresh={force_refresh}, force_upgrade={force_upgrade}" + ) # Get current token (authenticate if needed) current_token = self.authenticate(force_refresh=force_refresh) @@ -707,7 +796,8 @@ class HRTiAuthenticator(BaseAuthenticator): if should_upgrade: logger.info( - f"HRTi token upgrade triggered (force={force_upgrade}, should_upgrade={self.should_upgrade_token(current_token)})") + f"HRTi token upgrade triggered (force={force_upgrade}, should_upgrade={self.should_upgrade_token(current_token)})" + ) # Store original credentials at the beginning original_credentials = self.credentials @@ -715,9 +805,13 @@ class HRTiAuthenticator(BaseAuthenticator): try: # Get user credentials from settings manager try: - user_creds = self.settings_manager.get_provider_credentials(self.provider_name, self.country) + user_creds = self.settings_manager.get_provider_credentials( + 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") @@ -727,7 +821,9 @@ 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: @@ -740,7 +836,9 @@ class HRTiAuthenticator(BaseAuthenticator): self._save_session() return user_token.bearer_token else: - logger.warning("Authentication succeeded but token is still anonymous level") + logger.warning( + "Authentication succeeded but token is still anonymous level" + ) self.credentials = original_credentials return current_token.bearer_token else: @@ -764,4 +862,6 @@ class HRTiAuthenticator(BaseAuthenticator): except Exception as e: logger.error(f"HRTi token refresh failed: {e}") return None -# [file content end] \ No newline at end of file + + +# [file content end] diff --git a/lib/streaming_providers/providers/hrti/constants.py b/lib/streaming_providers/providers/hrti/constants.py index cd967ff..604d19e 100644 --- a/lib/streaming_providers/providers/hrti/constants.py +++ b/lib/streaming_providers/providers/hrti/constants.py @@ -10,45 +10,45 @@ class HRTiDefaults: """Default values for HRTi provider""" # Provider information - PROVIDER_LOGO = 'https://upload.wikimedia.org/wikipedia/en/thumb/9/9e/Logo_of_the_HRT.svg/2560px-Logo_of_the_HRT.svg.png' - PROVIDER_NAME = 'HRTi' + PROVIDER_LOGO = "https://upload.wikimedia.org/wikipedia/en/thumb/9/9e/Logo_of_the_HRT.svg/2560px-Logo_of_the_HRT.svg.png" + PROVIDER_NAME = "HRTi" # Website and base URLs - BASE_WEBSITE = 'https://hrti.hrt.hr' - BASE_URL = 'https://hrti.hrt.hr' - HSAPI_BASE_URL = 'https://hsapi.aviion.tv/client.svc/json' + BASE_WEBSITE = "https://hrti.hrt.hr" + BASE_URL = "https://hrti.hrt.hr" + HSAPI_BASE_URL = "https://hsapi.aviion.tv/client.svc/json" # Configuration endpoints - ENV_ENDPOINT = f'{BASE_URL}/assets/config/env.json' - CONFIG_ENDPOINT = f'{BASE_URL}/assets/config/config.production.json' + ENV_ENDPOINT = f"{BASE_URL}/assets/config/env.json" + CONFIG_ENDPOINT = f"{BASE_URL}/assets/config/config.production.json" # API endpoints API_ENDPOINTS = { - 'get_ip': f'{BASE_URL}/api/api/ott/getIPAddress', - 'grant_access': f'{BASE_URL}/api/api/ott/GrantAccess', - 'channels': f'{BASE_URL}/api/api/ott/GetChannels', - 'programme': f'{BASE_URL}/api/api/ott/GetProgramme', - 'authorize_session': f'{BASE_URL}/api/api/ott/AuthorizeSession', - 'report_session': f'{BASE_URL}/api/api/ott/ReportSessionEvent', - 'register_device': f'{HSAPI_BASE_URL}/RegisterDevice', - 'content_ratings': f'{HSAPI_BASE_URL}/ContentRatingsGet', - 'profiles': f'{HSAPI_BASE_URL}/ProfilesGet' + "get_ip": f"{BASE_URL}/api/api/ott/getIPAddress", + "grant_access": f"{BASE_URL}/api/api/ott/GrantAccess", + "channels": f"{BASE_URL}/api/api/ott/GetChannels", + "programme": f"{BASE_URL}/api/api/ott/GetProgramme", + "authorize_session": f"{BASE_URL}/api/api/ott/AuthorizeSession", + "report_session": f"{BASE_URL}/api/api/ott/ReportSessionEvent", + "register_device": f"{HSAPI_BASE_URL}/RegisterDevice", + "content_ratings": f"{HSAPI_BASE_URL}/ContentRatingsGet", + "profiles": f"{HSAPI_BASE_URL}/ProfilesGet", } # DRM and License endpoints - LICENSE_URL = 'https://lic.drmtoday.com/license-proxy-widevine/cenc/' + LICENSE_URL = "https://lic.drmtoday.com/license-proxy-widevine/cenc/" # Device information - DEVICE_REFERENCE_ID = '6' # String '6' as required by headers - OPERATOR_REFERENCE_ID = 'hrt' - MERCHANT = 'aviion2' - CONNECTION_TYPE = 'LAN/WiFi' - APPLICATION_VERSION = '5.97.6' - OS_VERSION = 'Linux' - CLIENT_TYPE = 'Chrome 142' + DEVICE_REFERENCE_ID = "6" # String '6' as required by headers + OPERATOR_REFERENCE_ID = "hrt" + MERCHANT = "aviion2" + CONNECTION_TYPE = "LAN/WiFi" + APPLICATION_VERSION = "5.97.6" + OS_VERSION = "Linux" + CLIENT_TYPE = "Chrome 142" # User Agent - USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36' + USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36" # HTTP settings DEFAULT_TIMEOUT = 30 @@ -61,98 +61,119 @@ class HRTiConfig: """Initialize with optional configuration overrides""" config = config_dict or {} - self.logo = config.get('logo', HRTiDefaults.PROVIDER_LOGO) + self.logo = config.get("logo", HRTiDefaults.PROVIDER_LOGO) # Website and base URLs - self.base_website = config.get('base_website', HRTiDefaults.BASE_WEBSITE) - 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.base_website = config.get("base_website", HRTiDefaults.BASE_WEBSITE) + 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 + ) # 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) + self.license_url = config.get("license_url", HRTiDefaults.LICENSE_URL) # Device configuration - self.device_reference_id = config.get('device_reference_id', HRTiDefaults.DEVICE_REFERENCE_ID) - self.operator_reference_id = config.get('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.application_version = config.get('application_version', HRTiDefaults.APPLICATION_VERSION) - self.os_version = config.get('os_version', HRTiDefaults.OS_VERSION) - self.client_type = config.get('client_type', HRTiDefaults.CLIENT_TYPE) + self.device_reference_id = config.get( + "device_reference_id", HRTiDefaults.DEVICE_REFERENCE_ID + ) + self.operator_reference_id = config.get( + "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.application_version = config.get( + "application_version", HRTiDefaults.APPLICATION_VERSION + ) + self.os_version = config.get("os_version", HRTiDefaults.OS_VERSION) + self.client_type = config.get("client_type", HRTiDefaults.CLIENT_TYPE) # HTTP settings - self.user_agent = config.get('user_agent', HRTiDefaults.USER_AGENT) - self.timeout = config.get('timeout', HRTiDefaults.DEFAULT_TIMEOUT) + self.user_agent = config.get("user_agent", HRTiDefaults.USER_AGENT) + self.timeout = config.get("timeout", HRTiDefaults.DEFAULT_TIMEOUT) # Web API URL (can be updated from config) - self.web_api_url = config.get('web_api_url', 'api/api/ott') + self.web_api_url = config.get("web_api_url", "api/api/ott") def update_from_api(self, env_data: dict, config_data: dict): """Update configuration from API responses""" try: # Update from env data - if 'applicationVersion' in env_data: - self.application_version = env_data['applicationVersion'] + if "applicationVersion" in env_data: + self.application_version = env_data["applicationVersion"] # Update from config data - if 'apiUrl' in config_data: - self.hsapi_base_url = config_data['apiUrl'] + if "apiUrl" in config_data: + self.hsapi_base_url = config_data["apiUrl"] - if 'webApiUrl' in config_data: - self.web_api_url = config_data['webApiUrl'] + if "webApiUrl" in config_data: + self.web_api_url = config_data["webApiUrl"] # Update API endpoints with new web API URL base_api_url = f"{self.base_url}/{self.web_api_url}" - self.api_endpoints.update({ - 'get_ip': f"{base_api_url}/getIPAddress", - 'grant_access': f"{base_api_url}/GrantAccess", - 'channels': f"{base_api_url}/GetChannels", - 'programme': f"{base_api_url}/GetProgramme", - 'authorize_session': f"{base_api_url}/AuthorizeSession" - }) + self.api_endpoints.update( + { + "get_ip": f"{base_api_url}/getIPAddress", + "grant_access": f"{base_api_url}/GrantAccess", + "channels": f"{base_api_url}/GetChannels", + "programme": f"{base_api_url}/GetProgramme", + "authorize_session": f"{base_api_url}/AuthorizeSession", + } + ) - if 'operators' in config_data and config_data['operators']: - operator = config_data['operators'][0] - if 'playerMerchant' in operator: - self.merchant = operator['playerMerchant'] - if 'selfcareUrl' in operator: + if "operators" in config_data and config_data["operators"]: + operator = config_data["operators"][0] + if "playerMerchant" in operator: + self.merchant = operator["playerMerchant"] + if "selfcareUrl" in operator: # Store selfcare URL if needed pass except Exception as e: # Log error but don't raise - use defaults if update fails import logging + logging.debug(f"Error updating HRTi config from API: {e}") def get_base_headers(self) -> dict: """Get base HTTP headers""" return { - 'User-Agent': self.user_agent, - 'Accept': 'application/json', - 'Content-Type': 'application/json' + "User-Agent": self.user_agent, + "Accept": "application/json", + "Content-Type": "application/json", } - def get_auth_headers(self, device_id: str = None, ip_address: str = None, token: str = None) -> dict: + def get_auth_headers( + self, device_id: str = None, ip_address: str = None, token: str = None + ) -> dict: """Get authenticated headers for API requests""" headers = self.get_base_headers() if device_id: - headers['deviceid'] = device_id + headers["deviceid"] = device_id if ip_address: - headers['ipaddress'] = ip_address + headers["ipaddress"] = ip_address if token: - headers['authorization'] = f'Client {token}' + headers["authorization"] = f"Client {token}" - headers.update({ - 'devicetypeid': self.device_reference_id, # Added devicetypeid - 'operatorreferenceid': self.operator_reference_id, - 'origin': self.base_website, - 'referer': self.base_website, - }) + headers.update( + { + "devicetypeid": self.device_reference_id, # Added devicetypeid + "operatorreferenceid": self.operator_reference_id, + "origin": self.base_website, + "referer": self.base_website, + } + ) return headers -# [file content end] \ No newline at end of file + + +# [file content end] diff --git a/lib/streaming_providers/providers/hrti/models.py b/lib/streaming_providers/providers/hrti/models.py index 0426a5a..43707b3 100644 --- a/lib/streaming_providers/providers/hrti/models.py +++ b/lib/streaming_providers/providers/hrti/models.py @@ -2,7 +2,7 @@ # [file content begin] # streaming_providers/providers/hrti/models.py from dataclasses import dataclass -from typing import Dict, Any, Optional +from typing import Any, Dict, Optional from ...base.auth.base_auth import BaseAuthToken from ...base.auth.credentials import UserPasswordCredentials @@ -24,7 +24,7 @@ class HRTiCredentials(UserPasswordCredentials): return { "Username": self.username, "Password": self.password, - "OperatorReferenceId": HRTiDefaults.OPERATOR_REFERENCE_ID + "OperatorReferenceId": HRTiDefaults.OPERATOR_REFERENCE_ID, } def validate(self) -> bool: @@ -37,16 +37,24 @@ class HRTiAuthToken(BaseAuthToken): HRTi specific authentication token """ - def __init__(self, access_token: str, token_type: str, expires_in: int, - issued_at: float, user_id: str = '', valid_from: str = '', - valid_to: str = '', refresh_token: Optional[str] = None): + def __init__( + self, + access_token: str, + token_type: str, + expires_in: int, + issued_at: float, + user_id: str = "", + valid_from: str = "", + valid_to: str = "", + refresh_token: Optional[str] = None, + ): super().__init__( access_token=access_token, token_type=token_type, expires_in=expires_in, issued_at=issued_at, - refresh_token=refresh_token + refresh_token=refresh_token, ) # HRTi specific fields @@ -58,43 +66,46 @@ class HRTiAuthToken(BaseAuthToken): def to_dict(self) -> Dict[str, Any]: """Convert token to dictionary""" base_dict = { - 'access_token': self.access_token, - 'token_type': self.token_type, - 'expires_in': self.expires_in, - 'issued_at': self.issued_at, - 'refresh_token': self.refresh_token or "" + "access_token": self.access_token, + "token_type": self.token_type, + "expires_in": self.expires_in, + "issued_at": self.issued_at, + "refresh_token": self.refresh_token or "", } # Add HRTi specific fields - base_dict.update({ - 'user_id': self.user_id, - 'valid_from': self.valid_from, - 'valid_to': self.valid_to - }) + base_dict.update( + { + "user_id": self.user_id, + "valid_from": self.valid_from, + "valid_to": self.valid_to, + } + ) # Add auth_level if available - if hasattr(self, 'auth_level') and self.auth_level: - base_dict['auth_level'] = self.auth_level.value + if hasattr(self, "auth_level") and self.auth_level: + base_dict["auth_level"] = self.auth_level.value return base_dict @classmethod - def from_dict(cls, data: Dict[str, Any]) -> 'HRTiAuthToken': + def from_dict(cls, data: Dict[str, Any]) -> "HRTiAuthToken": """Create token from dictionary""" return cls( - access_token=data['access_token'], - token_type=data.get('token_type', 'Client'), - expires_in=data.get('expires_in', 86400), - issued_at=data.get('issued_at', 0), - user_id=data.get('user_id', ''), - valid_from=data.get('valid_from', ''), - valid_to=data.get('valid_to', ''), - refresh_token=data.get('refresh_token') + access_token=data["access_token"], + token_type=data.get("token_type", "Client"), + expires_in=data.get("expires_in", 86400), + issued_at=data.get("issued_at", 0), + user_id=data.get("user_id", ""), + valid_from=data.get("valid_from", ""), + valid_to=data.get("valid_to", ""), + refresh_token=data.get("refresh_token"), ) def is_valid(self) -> bool: """Check if token is still valid""" import time + if not self.access_token: return False @@ -110,6 +121,7 @@ class HRTiChannel: """ HRTi channel information """ + id: str name: str reference_id: str @@ -125,30 +137,30 @@ class HRTiChannel: raise ValueError("Channel must have id, name, and reference_id") @classmethod - def from_api_response(cls, data: Dict[str, Any]) -> 'HRTiChannel': + def from_api_response(cls, data: Dict[str, Any]) -> "HRTiChannel": """Create channel from API response data""" return cls( - id=data.get('ReferenceId', ''), - name=data.get('Name', ''), - reference_id=data.get('ReferenceId', ''), - streaming_url=data.get('StreamingURL', ''), - icon_url=data.get('Icon', ''), - is_radio=data.get('Radio', False), - description=data.get('Description'), - sort_order=data.get('SortOrder', 0) + id=data.get("ReferenceId", ""), + name=data.get("Name", ""), + reference_id=data.get("ReferenceId", ""), + streaming_url=data.get("StreamingURL", ""), + icon_url=data.get("Icon", ""), + is_radio=data.get("Radio", False), + description=data.get("Description"), + sort_order=data.get("SortOrder", 0), ) def to_dict(self) -> Dict[str, Any]: """Convert channel to dictionary""" return { - 'id': self.id, - 'name': self.name, - 'reference_id': self.reference_id, - 'streaming_url': self.streaming_url, - 'icon_url': self.icon_url, - 'is_radio': self.is_radio, - 'description': self.description, - 'sort_order': self.sort_order + "id": self.id, + "name": self.name, + "reference_id": self.reference_id, + "streaming_url": self.streaming_url, + "icon_url": self.icon_url, + "is_radio": self.is_radio, + "description": self.description, + "sort_order": self.sort_order, } @@ -157,6 +169,7 @@ class HRTiEpgEntry: """ HRTi EPG (Electronic Program Guide) entry """ + reference_id: str title: str description_short: str @@ -170,36 +183,36 @@ class HRTiEpgEntry: season_number: Optional[str] = None @classmethod - def from_api_response(cls, data: Dict[str, Any], channel_id: str) -> 'HRTiEpgEntry': + def from_api_response(cls, data: Dict[str, Any], channel_id: str) -> "HRTiEpgEntry": """Create EPG entry from API response""" return cls( - reference_id=data.get('ReferenceId', ''), - title=data.get('Title', ''), - description_short=data.get('DescriptionShort', ''), - description_long=data.get('DescriptionLong', ''), - start_time=data.get('TimeStart', ''), - end_time=data.get('TimeEnd', ''), - image_url=data.get('ImagePath', ''), + reference_id=data.get("ReferenceId", ""), + title=data.get("Title", ""), + description_short=data.get("DescriptionShort", ""), + description_long=data.get("DescriptionLong", ""), + start_time=data.get("TimeStart", ""), + end_time=data.get("TimeEnd", ""), + image_url=data.get("ImagePath", ""), channel_reference_id=channel_id, - content_rating=data.get('ContentRating'), - episode_number=data.get('EpisodeNr'), - season_number=data.get('SeasonNr') + content_rating=data.get("ContentRating"), + episode_number=data.get("EpisodeNr"), + season_number=data.get("SeasonNr"), ) def to_dict(self) -> Dict[str, Any]: """Convert EPG entry to dictionary""" return { - 'reference_id': self.reference_id, - 'title': self.title, - 'description_short': self.description_short, - 'description_long': self.description_long, - 'start_time': self.start_time, - 'end_time': self.end_time, - 'image_url': self.image_url, - 'channel_reference_id': self.channel_reference_id, - 'content_rating': self.content_rating, - 'episode_number': self.episode_number, - 'season_number': self.season_number + "reference_id": self.reference_id, + "title": self.title, + "description_short": self.description_short, + "description_long": self.description_long, + "start_time": self.start_time, + "end_time": self.end_time, + "image_url": self.image_url, + "channel_reference_id": self.channel_reference_id, + "content_rating": self.content_rating, + "episode_number": self.episode_number, + "season_number": self.season_number, } @@ -208,6 +221,7 @@ class HRTiSession: """ HRTi playback session information """ + session_id: str authorized: bool drm_id: str @@ -215,23 +229,25 @@ class HRTiSession: content_reference_id: str @classmethod - def from_api_response(cls, data: Dict[str, Any]) -> 'HRTiSession': + def from_api_response(cls, data: Dict[str, Any]) -> "HRTiSession": """Create session from API response""" return cls( - session_id=data.get('SessionId', ''), - authorized=data.get('Authorized', False), - drm_id=data.get('DrmId', ''), - channel_reference_id=data.get('ChannelReferenceId', ''), - content_reference_id=data.get('ContentReferenceId', '') + session_id=data.get("SessionId", ""), + authorized=data.get("Authorized", False), + drm_id=data.get("DrmId", ""), + channel_reference_id=data.get("ChannelReferenceId", ""), + content_reference_id=data.get("ContentReferenceId", ""), ) def to_dict(self) -> Dict[str, Any]: """Convert session to dictionary""" return { - 'session_id': self.session_id, - 'authorized': self.authorized, - 'drm_id': self.drm_id, - 'channel_reference_id': self.channel_reference_id, - 'content_reference_id': self.content_reference_id + "session_id": self.session_id, + "authorized": self.authorized, + "drm_id": self.drm_id, + "channel_reference_id": self.channel_reference_id, + "content_reference_id": self.content_reference_id, } -# [file content end] \ No newline at end of file + + +# [file content end] diff --git a/lib/streaming_providers/providers/hrti/provider.py b/lib/streaming_providers/providers/hrti/provider.py index cfc5d73..90bb8a9 100644 --- a/lib/streaming_providers/providers/hrti/provider.py +++ b/lib/streaming_providers/providers/hrti/provider.py @@ -1,16 +1,17 @@ # lib/streaming_providers/providers/hrti/provider.py import json -import requests -from typing import Dict, List, Optional, ClassVar +from typing import ClassVar, Dict, List, Optional -from ...base.provider import StreamingProvider, AuthType -from ...base.models.streaming_channel import StreamingChannel -from ...base.models import DRMConfig, LicenseConfig, DRMSystem, LicenseUnwrapperParams -from .auth import HRTiAuthenticator -from .constants import HRTiConfig -from ...base.utils import logger +import requests + +from ...base.models import (DRMConfig, DRMSystem, LicenseConfig, + LicenseUnwrapperParams) from ...base.models.proxy_models import ProxyConfig -from .constants import HRTiDefaults +from ...base.models.streaming_channel import StreamingChannel +from ...base.provider import AuthType, StreamingProvider +from ...base.utils import logger +from .auth import HRTiAuthenticator +from .constants import HRTiConfig, HRTiDefaults class HRTiProvider(StreamingProvider): @@ -22,11 +23,19 @@ class HRTiProvider(StreamingProvider): # STATIC METADATA (NEW) # ============================================================================ PROVIDER_LABEL: ClassVar[str] = "HRTi" - SUPPORTED_AUTH_TYPES: ClassVar[List[str]] = ['client_credentials', 'user_credentials'] + SUPPORTED_AUTH_TYPES: ClassVar[List[str]] = [ + "client_credentials", + "user_credentials", + ] PROVIDER_LOGO: ClassVar[str] = HRTiDefaults.PROVIDER_LOGO - SUPPORTED_COUNTRIES: ClassVar[List[str]] = ['HR'] + SUPPORTED_COUNTRIES: ClassVar[List[str]] = ["HR"] - def __init__(self, country: str = 'HR', config: Optional[Dict] = None, proxy_config: Optional[ProxyConfig] = None): + def __init__( + self, + country: str = "HR", + config: Optional[Dict] = None, + proxy_config: Optional[ProxyConfig] = None, + ): super().__init__(country) # Initialize configuration with overrides @@ -35,20 +44,21 @@ class HRTiProvider(StreamingProvider): # Setup HTTP manager using abstraction self.http_manager = self._setup_http_manager( - provider_name='hrti', + provider_name="hrti", proxy_config=proxy_config, user_agent=self.hrti_config.user_agent, - timeout=self.hrti_config.timeout + timeout=self.hrti_config.timeout, ) # Initialize authenticator and share HTTP manager self.authenticator = HRTiAuthenticator( - proxy_config=proxy_config, - http_manager=self.http_manager + proxy_config=proxy_config, http_manager=self.http_manager ) # 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 @@ -92,15 +102,15 @@ class HRTiProvider(StreamingProvider): """ return self._build_provider_headers( auth_type=AuthType.CLIENT, - token_key='authorization', # HRTi uses lowercase + token_key="authorization", # HRTi uses lowercase provider_headers={ - 'deviceid': self.authenticator.get_device_id(), - 'devicetypeid': self.hrti_config.device_reference_id, - 'ipaddress': self.authenticator.get_ip_address(), - 'operatorreferenceid': self.hrti_config.operator_reference_id, - 'origin': self.hrti_config.base_website, - 'referer': f'{self.hrti_config.base_website}/login' - } + "deviceid": self.authenticator.get_device_id(), + "devicetypeid": self.hrti_config.device_reference_id, + "ipaddress": self.authenticator.get_ip_address(), + "operatorreferenceid": self.hrti_config.operator_reference_id, + "origin": self.hrti_config.base_website, + "referer": f"{self.hrti_config.base_website}/login", + }, ) def get_channels(self, **kwargs) -> List[StreamingChannel]: @@ -113,20 +123,21 @@ class HRTiProvider(StreamingProvider): # Log the request for debugging logger.debug( - f"Fetching HRTi channels with authorization: {'Client ...' if 'authorization' in headers else 'NO AUTHORIZATION'}") + f"Fetching HRTi channels with authorization: {'Client ...' if 'authorization' in headers else 'NO AUTHORIZATION'}" + ) response = self.http_manager.post( - self.hrti_config.api_endpoints['channels'], - operation='api', + self.hrti_config.api_endpoints["channels"], + operation="api", headers=headers, - data=json.dumps({}) + data=json.dumps({}), ) response.raise_for_status() channels_data = response.json() - if 'Result' in channels_data and channels_data['Result']: + if "Result" in channels_data and channels_data["Result"]: channels = [] - for channel in channels_data['Result']: + for channel in channels_data["Result"]: streaming_channel = self._parse_channel_data(channel) if streaming_channel: channels.append(streaming_channel) @@ -136,7 +147,7 @@ class HRTiProvider(StreamingProvider): return channels else: logger.warning("No channels found in HRTi response") - if 'Result' in channels_data: + if "Result" in channels_data: logger.debug(f"HRTi channels response: {channels_data}") return [] @@ -149,23 +160,25 @@ class HRTiProvider(StreamingProvider): headers = self._get_hrti_authenticated_headers() response = self.http_manager.post( - self.hrti_config.api_endpoints['channels'], - operation='api', + self.hrti_config.api_endpoints["channels"], + operation="api", headers=headers, - data=json.dumps({}) + data=json.dumps({}), ) response.raise_for_status() channels_data = response.json() - if 'Result' in channels_data and channels_data['Result']: + if "Result" in channels_data and channels_data["Result"]: channels = [] - for channel in channels_data['Result']: + for channel in channels_data["Result"]: streaming_channel = self._parse_channel_data(channel) if streaming_channel: 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") @@ -184,12 +197,12 @@ class HRTiProvider(StreamingProvider): Parse HRTi channel data to StreamingChannel """ try: - name = channel_data.get('Name', '') + name = channel_data.get("Name", "") # FIX: API returns 'ReferenceID' (capital ID), not 'ReferenceId' - channel_id = channel_data.get('ReferenceID', '') - streaming_url = channel_data.get('StreamingURL', '') - is_radio = channel_data.get('Radio', False) - icon_url = channel_data.get('Icon', '') + channel_id = channel_data.get("ReferenceID", "") + streaming_url = channel_data.get("StreamingURL", "") + is_radio = channel_data.get("Radio", False) + icon_url = channel_data.get("Icon", "") if not name or not channel_id: logger.debug(f"Skipping channel - missing name or ID: {channel_data}") @@ -208,21 +221,25 @@ class HRTiProvider(StreamingProvider): content_type="AUDIO" if is_radio else "LIVE", country=self.country, is_radio=is_radio, - language="hr" # Croatian + language="hr", # Croatian ) # HRTi uses DRM for most content 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: logger.error(f"Error parsing channel {channel_data}: {e}") return None - def enrich_channel_data(self, channel: StreamingChannel, **kwargs) -> Optional[StreamingChannel]: + def enrich_channel_data( + self, channel: StreamingChannel, **kwargs + ) -> Optional[StreamingChannel]: """ Enrich channel with manifest URL and DRM configuration. This method pre-authorizes a session and passes session_data to get_drm(). @@ -235,8 +252,9 @@ class HRTiProvider(StreamingProvider): # Parse the streaming URL to get content DRM ID from urllib.parse import urlparse + parts = urlparse(channel.manifest_script) - path_parts = parts.path.strip('/').split('/') + path_parts = parts.path.strip("/").split("/") # Content DRM ID format: directory1_directory2 # Example: /cdn1oiv/hrtliveorigin/... -> cdn1oiv_hrtliveorigin @@ -254,22 +272,24 @@ class HRTiProvider(StreamingProvider): video_store_ids=None, channel_id=channel.channel_id, start_time=None, - end_time=None + end_time=None, ) 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 - if not session_data.get('Authorized', False): + if not session_data.get("Authorized", False): logger.warning(f"Session not authorized for channel {channel.name}") return channel logger.debug(f"Session authorized for {channel.name}") # Report session event (play start) - use full SessionId - session_id = session_data.get('SessionId') + session_id = session_data.get("SessionId") if session_id: self.authenticator.report_session_event(session_id, channel.channel_id) @@ -281,7 +301,9 @@ 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" @@ -296,10 +318,10 @@ class HRTiProvider(StreamingProvider): license_key_parts = [ config.license.server_url, config.license.req_headers, - 'R{SSM}', # Placeholder - inputstream will replace with actual challenge - 'JBlicense' # Response is JSON, extract 'license' field + "R{SSM}", # Placeholder - inputstream will replace with actual challenge + "JBlicense", # Response is JSON, extract 'license' field ] - channel.license_key = '|'.join(license_key_parts) + channel.license_key = "|".join(license_key_parts) logger.debug(f"Set DRM config for {channel.name}") logger.debug(f"License URL: {config.license.server_url}") @@ -314,6 +336,7 @@ class HRTiProvider(StreamingProvider): except Exception as e: logger.error(f"Error enriching channel data for {channel.name}: {e}") import traceback + logger.error(f"Traceback: {traceback.format_exc()}") return channel @@ -326,10 +349,10 @@ class HRTiProvider(StreamingProvider): session_data = self.authenticator.authorize_session( content_type="tlive", # TV live content_ref_id=channel_id, - channel_id=channel_id + channel_id=channel_id, ) - if session_data and session_data.get('Authorized', False): + if session_data and session_data.get("Authorized", False): # For live channels, use the streaming URL from channel data # The actual manifest will be resolved during playback with session authorization channels = self.get_channels() @@ -344,7 +367,9 @@ 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. @@ -352,10 +377,16 @@ class HRTiProvider(StreamingProvider): try: # If no session data provided, authorize a new session if not session_data: - logger.debug(f"No session data provided for DRM - authorizing new session for channel {channel_id}") + logger.debug( + f"No session data provided for DRM - authorizing new session for channel {channel_id}" + ) # Find the channel to get content type and streaming URL - channels = self.get_channels() if not hasattr(self, 'channels') or not self.channels else self.channels + channels = ( + self.get_channels() + if not hasattr(self, "channels") or not self.channels + else self.channels + ) target_channel = None for ch in channels: if ch.channel_id == channel_id: @@ -363,16 +394,21 @@ 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 + parts = urlparse(target_channel.manifest_script) - path_parts = parts.path.strip('/').split('/') + path_parts = parts.path.strip("/").split("/") # Content DRM ID format: directory1_directory2 content_drm_id = None @@ -380,7 +416,8 @@ class HRTiProvider(StreamingProvider): content_drm_id = f"{path_parts[0]}_{path_parts[1]}" logger.debug( - f"Authorizing session for DRM - channel: {channel_id}, content_type: {content_type}, drm_id: {content_drm_id}") + f"Authorizing session for DRM - channel: {channel_id}, content_type: {content_type}, drm_id: {content_drm_id}" + ) # Authorize session session_data = self.authenticator.authorize_session( @@ -390,37 +427,43 @@ class HRTiProvider(StreamingProvider): video_store_ids=None, channel_id=channel_id, start_time=None, - end_time=None + end_time=None, ) 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}") + if not session_data.get("Authorized", False): + logger.warning( + f"Session not authorized for DRM - channel {channel_id}" + ) return [] logger.debug(f"Session authorized for DRM - channel {channel_id}") # Report session event (use full SessionId, not DrmId) - session_id = session_data.get('SessionId') + session_id = session_data.get("SessionId") if session_id: self.authenticator.report_session_event(session_id, channel_id) # IMPORTANT: For license data, use DrmId (not SessionId) # DrmId is the short random string for DRM # SessionId is the full identifier like "6:hrt:userid:uuid" - drm_id = session_data.get('DrmId') - session_id = session_data.get('SessionId') # Keep for session reporting + drm_id = session_data.get("DrmId") + session_id = session_data.get("SessionId") # Keep for session reporting if not drm_id: logger.error("No DRM ID in session data") logger.debug(f"Session data keys: {list(session_data.keys())}") return [] - logger.debug(f"Using DrmId for license: {drm_id[:20]}... (full SessionId: {session_id})") + logger.debug( + f"Using DrmId for license: {drm_id[:20]}... (full SessionId: {session_id})" + ) # Generate license data (base64 encoded) - use DrmId not SessionId license_data = self.authenticator.get_license_data(drm_id) @@ -432,38 +475,41 @@ class HRTiProvider(StreamingProvider): # Build the license request headers in the format expected by inputstream.adaptive # Format: Key1=Value1&Key2=Value2 - license_headers = '&'.join([ - f'User-Agent={self.hrti_config.user_agent}', - f'origin={self.hrti_config.base_website}', - f'referer={self.hrti_config.base_website}/', - f'dt-custom-data={license_data}' - ]) + license_headers = "&".join( + [ + f"User-Agent={self.hrti_config.user_agent}", + f"origin={self.hrti_config.base_website}", + f"referer={self.hrti_config.base_website}/", + f"dt-custom-data={license_data}", + ] + ) # Create the license configuration # Use the class method to create LicenseConfig with base64 encoded req_data license_config = LicenseConfig.create_with_base64_req_data( - req_data_template='{CHA-RAW}', # The placeholder string + req_data_template="{CHA-RAW}", # The placeholder string server_url=self.hrti_config.license_url, use_http_get_request=False, req_headers=license_headers, wrapper=None, - unwrapper='json,base64', - unwrapper_params=LicenseUnwrapperParams(path_data="license") + unwrapper="json,base64", + unwrapper_params=LicenseUnwrapperParams(path_data="license"), ) # Create the DRM configuration drm_config = DRMConfig( - system=DRMSystem.WIDEVINE, - priority=1, - license=license_config + 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: logger.error(f"Error getting DRM config for channel {channel_id}: {e}") import traceback + logger.error(f"Traceback: {traceback.format_exc()}") return [] @@ -481,29 +527,31 @@ class HRTiProvider(StreamingProvider): payload = { "ChannelReferenceIds": [channel_id], "StartTime": f"/Date({start_time})/", - "EndTime": f"/Date({end_time})/" + "EndTime": f"/Date({end_time})/", } response = self.http_manager.post( - self.hrti_config.api_endpoints['programme'], - operation='api', + self.hrti_config.api_endpoints["programme"], + operation="api", headers=headers, - data=json.dumps(payload) + data=json.dumps(payload), ) response.raise_for_status() epg_data = response.json() - if 'Result' in epg_data: + if "Result" in epg_data: # Convert to standard EPG format epg_entries = [] - for entry in epg_data['Result']: - epg_entries.append({ - 'title': entry.get('Title', ''), - 'description': entry.get('Description', ''), - 'start': entry.get('StartTime', ''), - 'end': entry.get('EndTime', ''), - 'genre': entry.get('Genre', '') - }) + for entry in epg_data["Result"]: + epg_entries.append( + { + "title": entry.get("Title", ""), + "description": entry.get("Description", ""), + "start": entry.get("StartTime", ""), + "end": entry.get("EndTime", ""), + "genre": entry.get("Genre", ""), + } + ) return epg_entries return [] @@ -544,7 +592,9 @@ 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. @@ -555,4 +605,4 @@ class HRTiProvider(StreamingProvider): Parameters string or None """ # HRTi requires session authorization which is handled in enrich_channel_data - return None \ No newline at end of file + return None diff --git a/lib/streaming_providers/providers/joyn/__init__.py b/lib/streaming_providers/providers/joyn/__init__.py index 1cf3fbc..554a1ef 100644 --- a/lib/streaming_providers/providers/joyn/__init__.py +++ b/lib/streaming_providers/providers/joyn/__init__.py @@ -1,25 +1,21 @@ # streaming_providers/providers/joyn/__init__.py -from .provider import JoynProvider -from .models import JoynChannel, PlaybackRestrictedException 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 __all__ = [ - 'JoynProvider', - 'JoynChannel', - 'PlaybackRestrictedException', - 'JoynAuthenticator', - 'JoynAuthToken', - 'JoynCredentials', - 'COUNTRY_TENANT_MAPPING', - 'DEFAULT_VIDEO_CONFIG', - 'JOYN_GRAPHQL_ENDPOINTS', - 'JOYN_STREAMING_ENDPOINTS' + "JoynProvider", + "JoynChannel", + "PlaybackRestrictedException", + "JoynAuthenticator", + "JoynAuthToken", + "JoynCredentials", + "COUNTRY_TENANT_MAPPING", + "DEFAULT_VIDEO_CONFIG", + "JOYN_GRAPHQL_ENDPOINTS", + "JOYN_STREAMING_ENDPOINTS", ] -__version__ = '1.1.0' # Updated version for refactored code \ No newline at end of file +__version__ = "1.1.0" # Updated version for refactored code diff --git a/lib/streaming_providers/providers/joyn/auth.py b/lib/streaming_providers/providers/joyn/auth.py index 14fbddd..5732ece 100644 --- a/lib/streaming_providers/providers/joyn/auth.py +++ b/lib/streaming_providers/providers/joyn/auth.py @@ -1,36 +1,33 @@ # streaming_providers/providers/joyn/auth.py # -*- coding: utf-8 -*- -import uuid -from typing import Dict, Optional, Any -from dataclasses import dataclass, field -import time import re -from urllib.parse import urlencode, parse_qs, urlparse +import time +import uuid +from dataclasses import dataclass, field +from typing import Any, Dict, Optional +from urllib.parse import parse_qs, urlencode, urlparse -from ...base.auth.base_oauth2_auth import BaseOAuth2Authenticator from ...base.auth.base_auth import BaseAuthToken, TokenAuthLevel +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, - SUPPORTED_COUNTRIES, - DEVICE_IDS, - JOYN_DOMAINS, - JOYN_OAUTH_SCOPE, - JOYN_SSO_DISCOVERY_URL, - JOYN_CLIENT_VERSION, - DEFAULT_PLATFORM, - JOYN_USER_AGENT, - DEFAULT_COUNTRY, - DEFAULT_REQUEST_TIMEOUT, JOYN_AUTH_ENDPOINTS -) +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: """Service to discover SSO endpoints dynamically""" - def __init__(self, http_manager, country: str = DEFAULT_COUNTRY, platform: str = DEFAULT_PLATFORM): + def __init__( + self, + http_manager, + country: str = DEFAULT_COUNTRY, + platform: str = DEFAULT_PLATFORM, + ): self.http_manager = http_manager self.country = country self.platform = platform @@ -42,34 +39,37 @@ class JoynSSODiscovery: def get_fallback_endpoints() -> Dict[str, str]: """Fallback endpoints if discovery fails""" return { - 'device-login': 'https://sso.joyn.de/ci', - 'device-register': 'https://sso.joyn.de/cr', - 'web-login': 'https://auth.7pass.de/authz-srv/authz', - 'redeem-token': 'https://auth.joyn.de/auth/7pass/token' + "device-login": "https://sso.joyn.de/ci", + "device-register": "https://sso.joyn.de/cr", + "web-login": "https://auth.7pass.de/authz-srv/authz", + "redeem-token": "https://auth.joyn.de/auth/7pass/token", } def get_endpoints(self, force_refresh: bool = False) -> Dict[str, str]: """Get SSO endpoints, with caching""" - if (self._endpoints_cache and not force_refresh and - time.time() - self._cache_timestamp < self._cache_ttl): + if ( + self._endpoints_cache + and not force_refresh + and time.time() - self._cache_timestamp < self._cache_ttl + ): return self._endpoints_cache try: params = { - 'client_id': DEVICE_IDS[self.platform], - 'client_name': self.platform + "client_id": DEVICE_IDS[self.platform], + "client_name": self.platform, } response = self.http_manager.get( - JOYN_SSO_DISCOVERY_URL, - operation='sso_discovery', - params=params + JOYN_SSO_DISCOVERY_URL, operation="sso_discovery", params=params ) response.raise_for_status() self._endpoints_cache = response.json() self._cache_timestamp = time.time() - logger.debug(f"SSO discovery successful, endpoints: {list(self._endpoints_cache.keys())}") + logger.debug( + f"SSO discovery successful, endpoints: {list(self._endpoints_cache.keys())}" + ) return self._endpoints_cache except Exception as e: @@ -81,7 +81,7 @@ class JoynSSODiscovery: """Get specific auth endpoint by type""" # If no auth_type specified, use platform-specific login endpoint if auth_type is None: - auth_type = f'{self.platform}-login' + auth_type = f"{self.platform}-login" endpoints = self.get_endpoints() endpoint = endpoints.get(auth_type) @@ -89,8 +89,11 @@ class JoynSSODiscovery: logger.warning(f"Auth endpoint '{auth_type}' not found, using fallback") fallback = self.get_fallback_endpoints() # Try platform-specific first, then generic web-login - endpoint = fallback.get(auth_type) or fallback.get(f'{self.platform}-login') or fallback.get('web-login', - '') + endpoint = ( + fallback.get(auth_type) + or fallback.get(f"{self.platform}-login") + or fallback.get("web-login", "") + ) return endpoint @@ -99,6 +102,7 @@ class JoynCredentials(ClientCredentials): """ Joyn-specific credentials for client credentials flow (anonymous auth) """ + client_name: str = DEFAULT_PLATFORM country: str = DEFAULT_COUNTRY distribution_tenant: Optional[str] = field(default=None) @@ -106,7 +110,9 @@ 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] @@ -122,9 +128,9 @@ class JoynCredentials(ClientCredentials): def to_auth_payload(self) -> Dict[str, Any]: """Convert to authentication payload for Joyn's anonymous auth endpoint""" return { - 'client_id': self.client_id, - 'client_name': self.client_name, - 'anon_device_id': str(uuid.uuid4()) + "client_id": self.client_id, + "client_name": self.client_name, + "anon_device_id": str(uuid.uuid4()), } @property @@ -137,16 +143,17 @@ class JoynAuthToken(BaseAuthToken): """ Joyn-specific authentication token """ + refresh_token: Optional[str] = field(default="") def to_dict(self) -> Dict[str, Any]: """Convert token to dictionary""" return { - 'access_token': self.access_token, - 'refresh_token': self.refresh_token or "", - 'token_type': self.token_type, - 'expires_in': self.expires_in, - 'issued_at': self.issued_at + "access_token": self.access_token, + "refresh_token": self.refresh_token or "", + "token_type": self.token_type, + "expires_in": self.expires_in, + "issued_at": self.issued_at, } def get_jwt_claims(self) -> Optional[Dict[str, Any]]: @@ -155,7 +162,7 @@ class JoynAuthToken(BaseAuthToken): if not self.access_token: return None - parts = self.access_token.split('.') + parts = self.access_token.split(".") if len(parts) != 3: return None @@ -165,19 +172,26 @@ class JoynAuthToken(BaseAuthToken): payload_b64 = parts[1] padding = len(payload_b64) % 4 if padding: - payload_b64 += '=' * (4 - padding) + payload_b64 += "=" * (4 - padding) - payload_json = base64.b64decode(payload_b64).decode('utf-8') + payload_json = base64.b64decode(payload_b64).decode("utf-8") return json.loads(payload_json) except Exception as e: logger.debug(f"Failed to extract JWT claims: {e}") return None + class JoynAuthConfig: """Configuration object for Joyn authentication with dynamic endpoints""" - def __init__(self, country: str, distribution_tenant: str, http_manager, platform: str = DEFAULT_PLATFORM): + def __init__( + self, + country: str, + distribution_tenant: str, + http_manager, + platform: str = DEFAULT_PLATFORM, + ): self.country = country self.distribution_tenant = distribution_tenant self.platform = platform @@ -194,38 +208,40 @@ class JoynAuthConfig: def get_token_redeem_endpoint(self) -> str: """Get token redemption endpoint for user login flows""" if self.sso_discovery: - return self.sso_discovery.get_auth_endpoint('redeem-token') + return self.sso_discovery.get_auth_endpoint("redeem-token") # Fallback if SSO discovery not available - return JoynSSODiscovery.get_fallback_endpoints()['redeem-token'] + return JoynSSODiscovery.get_fallback_endpoints()["redeem-token"] def get_authorize_endpoint(self) -> str: """Get authorization endpoint for OAuth2 flow""" if self.sso_discovery: # Try platform-specific login endpoint first - return self.sso_discovery.get_auth_endpoint(f'{self.platform}-login') + return self.sso_discovery.get_auth_endpoint(f"{self.platform}-login") # Fallback if SSO discovery not available - try platform-specific, then web-login fallback = JoynSSODiscovery.get_fallback_endpoints() - return fallback.get(f'{self.platform}-login') or fallback.get('web-login', '') + return fallback.get(f"{self.platform}-login") or fallback.get("web-login", "") def get_base_headers(self) -> Dict[str, str]: """Get base headers for all requests""" return { - 'User-Agent': self.user_agent, - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Origin': JOYN_DOMAINS.get(self.country, JOYN_DOMAINS['de']) + "User-Agent": self.user_agent, + "Accept": "application/json", + "Content-Type": "application/json", + "Origin": JOYN_DOMAINS.get(self.country, JOYN_DOMAINS["de"]), } def get_auth_headers(self) -> Dict[str, str]: """Get headers for authentication requests""" headers = self.get_base_headers() - headers.update({ - 'joyn-client-version': JOYN_CLIENT_VERSION, - 'joyn-country': self.country.upper(), - 'joyn-distribution-tenant': self.distribution_tenant, - 'joyn-platform': self.platform, - 'joyn-request-id': str(uuid.uuid4()) - }) + headers.update( + { + "joyn-client-version": JOYN_CLIENT_VERSION, + "joyn-country": self.country.upper(), + "joyn-distribution-tenant": self.distribution_tenant, + "joyn-platform": self.platform, + "joyn-request-id": str(uuid.uuid4()), + } + ) return headers @@ -234,18 +250,23 @@ class JoynAuthenticator(BaseOAuth2Authenticator): Joyn authenticator using OAuth2 client credentials flow with dynamic endpoints """ - def __init__(self, country: str = DEFAULT_COUNTRY, - platform: str = DEFAULT_PLATFORM, - settings_manager=None, - credentials=None, - config_dir: Optional[str] = None, - http_manager=None, - proxy_config: Optional[ProxyConfig] = None): + def __init__( + self, + country: str = DEFAULT_COUNTRY, + platform: str = DEFAULT_PLATFORM, + settings_manager=None, + credentials=None, + config_dir: Optional[str] = None, + http_manager=None, + proxy_config: Optional[ProxyConfig] = None, + ): """ Initialize authenticator for specific country """ if country not in SUPPORTED_COUNTRIES: - raise ValueError(f"Unsupported country: {country}. Must be one of: {SUPPORTED_COUNTRIES}") + raise ValueError( + f"Unsupported country: {country}. Must be one of: {SUPPORTED_COUNTRIES}" + ) # Validate that http_manager is provided if http_manager is None: @@ -263,34 +284,39 @@ class JoynAuthenticator(BaseOAuth2Authenticator): self._http_manager = http_manager # Setup Joyn-specific config BEFORE super().__init__ - self._config = JoynAuthConfig(self.country, self.distribution_tenant, self._http_manager, self.platform) + self._config = JoynAuthConfig( + self.country, self.distribution_tenant, self._http_manager, self.platform + ) # Extract and cache client_id during initialization self._client_id = self._extract_client_id_from_endpoints() # NOW call parent __init__ - config, http_manager AND country are ready super().__init__( - provider_name='joyn', + provider_name="joyn", settings_manager=settings_manager, credentials=credentials, country=country, config_dir=config_dir, enable_kodi_integration=True, http_manager=self._http_manager, - proxy_config=proxy_config + proxy_config=proxy_config, ) def _get_joyn_auth_headers(self) -> Dict[str, str]: """Get standardized Joyn authentication headers""" from .constants import JOYN_AUTH_HEADERS_BASE + headers = JOYN_AUTH_HEADERS_BASE.copy() - headers['Origin'] = f'https://www.joyn.{self.country.lower()}' - headers.update({ - 'joyn-country': self.country.upper(), - 'joyn-distribution-tenant': self.distribution_tenant, - 'joyn-platform': self.platform, # Using self.platform - 'joyn-request-id': str(uuid.uuid4()) - }) + headers["Origin"] = f"https://www.joyn.{self.country.lower()}" + headers.update( + { + "joyn-country": self.country.upper(), + "joyn-distribution-tenant": self.distribution_tenant, + "joyn-platform": self.platform, # Using self.platform + "joyn-request-id": str(uuid.uuid4()), + } + ) return headers def _extract_client_id_from_endpoints(self) -> str: @@ -304,28 +330,34 @@ class JoynAuthenticator(BaseOAuth2Authenticator): login_url = endpoints.get(platform_key) if not login_url: - logger.warning(f"No {platform_key} endpoint found, trying generic web-login as fallback") - login_url = endpoints.get('web-login') + logger.warning( + f"No {platform_key} endpoint found, trying generic web-login as fallback" + ) + login_url = endpoints.get("web-login") if not login_url: raise Exception( - f"No login endpoint found for platform '{self.platform}' or generic 'web-login' in SSO discovery") + f"No login endpoint found for platform '{self.platform}' or generic 'web-login' in SSO discovery" + ) # Extract client_id from the URL parameters parsed_url = urlparse(login_url) query_params = parse_qs(parsed_url.query) - client_id = query_params.get('client_id', [None])[0] + client_id = query_params.get("client_id", [None])[0] 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: logger.error( - f"Error extracting client_id from endpoints: {e}, using fallback: {DEVICE_IDS.get(self.platform, DEVICE_IDS[DEFAULT_PLATFORM])}") + f"Error extracting client_id from endpoints: {e}, using fallback: {DEVICE_IDS.get(self.platform, DEVICE_IDS[DEFAULT_PLATFORM])}" + ) return DEVICE_IDS.get(self.platform, DEVICE_IDS[DEFAULT_PLATFORM]) @property @@ -342,18 +374,20 @@ class JoynAuthenticator(BaseOAuth2Authenticator): def oauth_redirect_uri(self) -> str: """OAuth2 redirect URI - country-specific""" from .constants import get_oauth_redirect_uri + return get_oauth_redirect_uri(self.country) @property def auth_endpoint(self) -> str: """Authentication endpoint URL - dynamic based on flow""" from ...base.auth.credentials import UserPasswordCredentials + if isinstance(self.credentials, UserPasswordCredentials): # For authorization code flow - use token endpoint from SSO discovery return self._config.get_token_redeem_endpoint() else: # For client credentials flow - use anonymous endpoint - return JOYN_AUTH_ENDPOINTS['ANONYMOUS'] + return JOYN_AUTH_ENDPOINTS["ANONYMOUS"] def _get_auth_headers(self) -> Dict[str, str]: """Get headers for authentication request""" @@ -365,14 +399,16 @@ 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'], - refresh_token=response_data.get('refresh_token', ''), - token_type=response_data.get('token_type', 'Bearer'), - expires_in=response_data.get('expires_in', 86400), - issued_at=response_data.get('issued_at', time.time()) + access_token=response_data["access_token"], + refresh_token=response_data.get("refresh_token", ""), + token_type=response_data.get("token_type", "Bearer"), + expires_in=response_data.get("expires_in", 86400), + issued_at=response_data.get("issued_at", time.time()), ) # ALWAYS classify immediately when creating from response @@ -386,7 +422,7 @@ class JoynAuthenticator(BaseOAuth2Authenticator): return JoynCredentials( client_id=self._client_id, client_secret="", # Joyn doesn't use client_secret - country=self.country + country=self.country, ) def get_token_redeem_url(self) -> str: @@ -398,21 +434,27 @@ class JoynAuthenticator(BaseOAuth2Authenticator): """ Perform authentication using appropriate flow based on credential type """ - from ...base.auth.credentials import UserPasswordCredentials, ClientCredentials + 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 + 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(f"Unsupported credential type for {self.provider_name}: {type(self.credentials)}") + raise Exception( + f"Unsupported credential type for {self.provider_name}: {type(self.credentials)}" + ) return self._create_token_from_response(token_data) @@ -432,24 +474,29 @@ class JoynAuthenticator(BaseOAuth2Authenticator): # Joyn expects JSON, not form-encoded data response = self.http_manager.post( self.auth_endpoint, - operation='auth', + operation="auth", headers=headers, - json_data=payload # JSON instead of form data + json_data=payload, # JSON instead of form data ) self._check_oauth_error_response(response) 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: - logger.error(f"OAuth2 client credentials flow on endpoint {self.auth_endpoint} failed for {self.provider_name}: {e}") + logger.error( + f"OAuth2 client credentials flow on endpoint {self.auth_endpoint} failed for {self.provider_name}: {e}" + ) raise Exception(f"OAuth2 client credentials flow failed: {e}") - def _perform_two_step_verification(self, session, username: str, password: str, - request_id: str, referer_url: str) -> tuple[str, str]: + def _perform_two_step_verification( + self, session, username: str, password: str, request_id: str, referer_url: str + ) -> tuple[str, str]: """ Perform the two-step verification process using constants Returns: (final_sub, final_status_id) @@ -458,11 +505,11 @@ class JoynAuthenticator(BaseOAuth2Authenticator): # Common headers for verification requests verification_headers = { - 'User-Agent': JOYN_USER_AGENT, - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Origin': 'https://signin.7pass.de', - 'Referer': referer_url + "User-Agent": JOYN_USER_AGENT, + "Accept": "application/json", + "Content-Type": "application/json", + "Origin": "https://signin.7pass.de", + "Referer": referer_url, } # Step 1: Initiate password verification @@ -471,30 +518,34 @@ class JoynAuthenticator(BaseOAuth2Authenticator): "email": username, "medium_id": "PASSWORD", "usage_type": "PASSWORDLESS_AUTHENTICATION", - "type": "PASSWORD" + "type": "PASSWORD", } logger.debug("Step 1: Initiating password verification") initiate_response = session.post( - JOYN_CIDAAS_ENDPOINTS['VERIFICATION_INITIATE'], + JOYN_CIDAAS_ENDPOINTS["VERIFICATION_INITIATE"], json_data=initiate_data, headers=verification_headers, - timeout=self._config.timeout + timeout=self._config.timeout, ) logger.debug(f"Initiate response status: {initiate_response.status_code}") initiate_response.raise_for_status() initiate_result = initiate_response.json() - if not initiate_result.get('success'): - raise Exception(f"Password verification initiation failed: {initiate_result}") + if not initiate_result.get("success"): + 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'] + 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 = { @@ -502,33 +553,36 @@ class JoynAuthenticator(BaseOAuth2Authenticator): "pass_code": password, "sub": sub, # Use sub from first response "type": "PASSWORD", - "password": password + "password": password, } logger.debug("Step 2: Authenticating with password") authenticate_response = session.post( - JOYN_CIDAAS_ENDPOINTS['VERIFICATION_AUTHENTICATE'], + JOYN_CIDAAS_ENDPOINTS["VERIFICATION_AUTHENTICATE"], json_data=authenticate_data, headers=verification_headers, - timeout=self._config.timeout + 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() - if not authenticate_result.get('success'): + if not authenticate_result.get("success"): raise Exception(f"Password authentication failed: {authenticate_result}") - auth_exchange_data = authenticate_result['data'] - final_sub = auth_exchange_data['sub'] # Use sub from second response - final_status_id = auth_exchange_data['status_id'] + auth_exchange_data = authenticate_result["data"] + final_sub = auth_exchange_data["sub"] # Use sub from second response + final_status_id = auth_exchange_data["status_id"] logger.debug(f"Got final sub: {final_sub}, final_status_id: {final_status_id}") return final_sub, final_status_id - def _perform_final_login(self, session, final_sub: str, final_status_id: str, - request_id: str) -> bool: + def _perform_final_login( + self, session, final_sub: str, final_status_id: str, request_id: str + ) -> bool: """ Perform the final login with verification results using constants Returns: True if successful @@ -536,27 +590,27 @@ class JoynAuthenticator(BaseOAuth2Authenticator): from .constants import JOYN_CIDAAS_ENDPOINTS, JOYN_USER_AGENT login_data = { - 'sub': final_sub, - 'status_id': final_status_id, - 'verificationType': 'PASSWORD', - 'requestId': request_id, - 'remember_me': 'true' + "sub": final_sub, + "status_id": final_status_id, + "verificationType": "PASSWORD", + "requestId": request_id, + "remember_me": "true", } login_headers = { - 'User-Agent': JOYN_USER_AGENT, - 'Accept': 'application/json', - 'Content-Type': 'application/x-www-form-urlencoded', - 'Origin': 'https://signin.7pass.de' + "User-Agent": JOYN_USER_AGENT, + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "Origin": "https://signin.7pass.de", } logger.debug("Step 3: Final login with verification results") login_response = session.post( - JOYN_CIDAAS_ENDPOINTS['LOGIN_VERIFICATION'], + JOYN_CIDAAS_ENDPOINTS["LOGIN_VERIFICATION"], data=urlencode(login_data).encode(), headers=login_headers, timeout=self._config.timeout, - allow_redirects=False + allow_redirects=False, ) logger.debug(f"Login response status: {login_response.status_code}") @@ -585,36 +639,43 @@ class JoynAuthenticator(BaseOAuth2Authenticator): # This is a simplified version - you'll need to adapt based on actual response # Check current URL for authorization code - current_url = getattr(session, 'last_url', None) # You'd need to track this + current_url = getattr(session, "last_url", None) # You'd need to track this if current_url: parsed_url = urlparse(current_url) query_params = parse_qs(parsed_url.query) - auth_code = query_params.get('code', [None])[0] + auth_code = query_params.get("code", [None])[0] if auth_code: logger.debug(f"Found authorization code in URL: {auth_code}") return auth_code # Also check fragment for SPA redirects - if '#' in current_url: - fragment = current_url.split('#')[1] + if "#" in current_url: + fragment = current_url.split("#")[1] fragment_params = parse_qs(fragment) - auth_code = fragment_params.get('code', [None])[0] + 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 # You'll need to implement this based on how your session tracks redirects - logger.warning(f"Reached max redirects ({max_redirects}) without finding authorization code") + logger.warning( + f"Reached max redirects ({max_redirects}) without finding authorization code" + ) return None # Authorization code flow - Joyn-specific implementation - def _perform_oauth_authorization_code_flow(self, username: str, password: str) -> Dict[str, Any]: + def _perform_oauth_authorization_code_flow( + self, username: str, password: str + ) -> Dict[str, Any]: """ Optimized Joyn OAuth2 authorization code flow with two-step verification + Handles both cases: existing session (direct redirect) and new login (form-based) """ try: logger.debug("Starting optimized Joyn OAuth2 authorization code flow") @@ -629,17 +690,17 @@ class JoynAuthenticator(BaseOAuth2Authenticator): state = self.generate_oauth_state() params = { - 'response_type': 'code', - 'client_id': self.oauth_client_id, - 'redirect_uri': self.oauth_redirect_uri, - 'scope': self.oauth_scope, - 'state': state, - 'code_challenge': code_challenge, - 'code_challenge_method': 'S256', - 'response_mode': 'query', - 'view_type': 'login', - 'prompt': 'consent', - 'cd1': str(uuid.uuid4()), + "response_type": "code", + "client_id": self.oauth_client_id, + "redirect_uri": self.oauth_redirect_uri, + "scope": self.oauth_scope, + "state": state, + "code_challenge": code_challenge, + "code_challenge_method": "S256", + "response_mode": "query", + "view_type": "login", + "prompt": "consent", + "cd1": str(uuid.uuid4()), } authorization_url = f"{web_login_url}?{urlencode(params)}" @@ -647,31 +708,78 @@ class JoynAuthenticator(BaseOAuth2Authenticator): session = self._create_oauth_session() - # Step 2: Get authorization page and extract request_id - logger.debug("Fetching authorization page for request_id") + # Step 2: Get authorization page + 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() - # Extract request_id from the redirect URL + # 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" + ) + + # Parse the redirect URL for authorization code + parsed_url = urlparse(auth_response.url) + query_params = parse_qs(parsed_url.query) + + auth_code = query_params.get("code", [None])[0] + received_state = query_params.get("state", [None])[0] + + if not auth_code: + 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}" + ) + + # Exchange code for token + logger.debug("Exchanging authorization code for tokens") + token_data = self._exchange_authorization_code_for_token( + authorization_code=auth_code, + code_verifier=code_verifier, + state=state, + ) + + logger.debug( + "Joyn OAuth2 authorization code flow successful (existing session)" + ) + return token_data + + # Step 2b: No existing session - need to perform login + logger.info("No existing session - performing form-based login") + + # Extract request_id from the response parsed_url = urlparse(auth_response.url) query_params = parse_qs(parsed_url.query) - request_id = query_params.get('requestId', [None])[0] + request_id = query_params.get("requestId", [None])[0] if not request_id: # Fallback: try to extract from response body - request_id_match = re.search(r'requestId["\']?\s*:\s*["\']([^"\']+)', auth_response.text) + request_id_match = re.search( + r'requestId["\']?\s*:\s*["\']([^"\']+)', auth_response.text + ) 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}") - # Step 3: Two-step verification process using helper method + # Step 3: Two-step verification process final_sub, final_status_id = self._perform_two_step_verification( session, username, password, request_id, auth_response.url ) @@ -695,28 +803,27 @@ class JoynAuthenticator(BaseOAuth2Authenticator): # Step 6: Exchange authorization code for tokens logger.debug("Exchanging authorization code for tokens") token_data = self._exchange_authorization_code_for_token( - authorization_code=auth_code, - code_verifier=code_verifier, - state=state + authorization_code=auth_code, code_verifier=code_verifier, state=state ) - logger.debug("Joyn OAuth2 authorization code flow successful") + logger.debug("Joyn OAuth2 authorization code flow successful (form login)") return token_data except Exception as e: logger.error(f"Joyn OAuth2 authorization code flow failed: {e}") raise Exception(f"OAuth2 authorization code flow failed: {e}") - def _build_token_exchange_payload(self, authorization_code: str, code_verifier: str, - state: str = None, **kwargs) -> Dict[str, Any]: + def _build_token_exchange_payload( + self, authorization_code: str, code_verifier: str, state: str = None, **kwargs + ) -> Dict[str, Any]: """Joyn-specific token exchange payload""" return { - 'code': authorization_code, - 'client_id': self.oauth_client_id, - 'redirect_uri': self.oauth_redirect_uri, - 'tracking_id': str(uuid.uuid4()), - 'tracking_name': self.platform, - 'code_verifier': code_verifier + "code": authorization_code, + "client_id": self.oauth_client_id, + "redirect_uri": self.oauth_redirect_uri, + "tracking_id": str(uuid.uuid4()), + "tracking_name": self.platform, + "code_verifier": code_verifier, # No grant_type for Joyn } @@ -743,27 +850,38 @@ class JoynAuthenticator(BaseOAuth2Authenticator): try: logger.debug(f"Refreshing OAuth2 token for {self.provider_name}") - # Joyn-specific refresh payload + # 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': self.oauth_client_id, - 'client_name': self.platform, # Joyn requires this - 'grant_type': 'Bearer', # Joyn uses 'Bearer' instead of 'refresh_token' - 'refresh_token': self._current_token.refresh_token + "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, } headers = self._get_joyn_auth_headers() # Use the refresh-specific endpoint - refresh_endpoint = JOYN_AUTH_ENDPOINTS['REFRESH'] + refresh_endpoint = JOYN_AUTH_ENDPOINTS["REFRESH"] + + logger.debug(f"Refresh payload: {payload}") + logger.debug(f"Refresh headers: {headers}") response = self.http_manager.post( refresh_endpoint, - operation='auth', + operation="auth", headers=headers, json_data=payload, # JSON format - timeout=self._config.timeout + timeout=self._config.timeout, ) + # Log response for debugging + logger.debug(f"Refresh response status: {response.status_code}") + if response.status_code >= 400: + logger.error(f"Refresh error response: {response.text}") + response.raise_for_status() new_token_data = response.json() @@ -808,7 +926,7 @@ class JoynAuthenticator(BaseOAuth2Authenticator): # Parse JWT token to extract claims try: # JWT tokens are in format: header.payload.signature - parts = token.access_token.split('.') + parts = token.access_token.split(".") if len(parts) != 3: logger.warning(f"Invalid JWT format for token classification") return TokenAuthLevel.UNKNOWN @@ -821,13 +939,14 @@ class JoynAuthenticator(BaseOAuth2Authenticator): # Add padding if needed padding = len(payload_b64) % 4 if padding: - payload_b64 += '=' * (4 - padding) + payload_b64 += "=" * (4 - padding) - payload_json = base64.b64decode(payload_b64).decode('utf-8') + payload_json = base64.b64decode(payload_b64).decode("utf-8") claims = json.loads(payload_json) logger.debug( - f"JWT claims for classification: { {k: v for k, v in claims.items() if k not in ['access_token', 'refresh_token']} }") + f"JWT claims for classification: { {k: v for k, v in claims.items() if k not in ['access_token', 'refresh_token']} }" + ) except Exception as e: logger.warning(f"Failed to parse JWT for classification: {e}") @@ -836,25 +955,27 @@ class JoynAuthenticator(BaseOAuth2Authenticator): # Classification logic based on your analysis: # 1. Check jIdC prefix - most reliable indicator - jidc = claims.get('jIdC', '') - if jidc.startswith('JNAA-'): + jidc = claims.get("jIdC", "") + if jidc.startswith("JNAA-"): logger.debug("Token classified as CLIENT_CREDENTIALS (JNAA prefix)") return TokenAuthLevel.CLIENT_CREDENTIALS - elif jidc.startswith('JNDE-'): + elif jidc.startswith("JNDE-"): logger.debug("Token classified as USER_AUTHENTICATED (JNDE prefix)") return TokenAuthLevel.USER_AUTHENTICATED # 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)") + if "social_id" in claims: + logger.debug( + "Token classified as USER_AUTHENTICATED (social_id present)" + ) return TokenAuthLevel.USER_AUTHENTICATED # 3. Check client ID (cId) against known client IDs - client_id = claims.get('cId', '') + client_id = claims.get("cId", "") known_client_ids = { - DEVICE_IDS['web'], # Web client - DEVICE_IDS['android'], # Android client - DEVICE_IDS['ios'] # iOS client + DEVICE_IDS["web"], # Web client + DEVICE_IDS["android"], # Android client + DEVICE_IDS["ios"], # iOS client } if client_id in known_client_ids: @@ -862,22 +983,28 @@ class JoynAuthenticator(BaseOAuth2Authenticator): return TokenAuthLevel.CLIENT_CREDENTIALS # 4. Check for anonymous device patterns in subject (sub) - subject = claims.get('sub', '') + subject = claims.get("sub", "") 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 - scope = claims.get('scope', '') + scope = claims.get("scope", "") if scope: scopes = scope.split() - if 'offline_access' in scopes and 'profile' in scopes: - logger.debug("Token classified as USER_AUTHENTICATED (user scopes present)") + if "offline_access" in scopes and "profile" in scopes: + 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)") + elif "openid" in scopes and len(scopes) <= 2: + logger.debug( + "Token classified as CLIENT_CREDENTIALS (minimal scopes)" + ) return TokenAuthLevel.CLIENT_CREDENTIALS logger.warning(f"Could not definitively classify token, using UNKNOWN") @@ -890,21 +1017,33 @@ class JoynAuthenticator(BaseOAuth2Authenticator): def debug_token_classification(self) -> Dict[str, Any]: """Debug method to analyze current token classification""" if not self._current_token: - return {'error': 'No current token'} + return {"error": "No current token"} - claims = self._current_token.get_jwt_claims() if hasattr(self._current_token, 'get_jwt_claims') else {} + claims = ( + self._current_token.get_jwt_claims() + if hasattr(self._current_token, "get_jwt_claims") + else {} + ) return { - 'token_type': type(self._current_token).__name__, - 'auth_level': self._current_token.auth_level.value, - 'is_expired': self._current_token.is_expired, - 'has_refresh': bool(self._current_token.refresh_token), - 'jwt_claims_available': bool(claims), - 'key_claims': { - 'jIdC': claims.get('jIdC', 'MISSING'), - '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', - 'scope': claims.get('scope', 'MISSING') - } if claims else {} - } \ No newline at end of file + "token_type": type(self._current_token).__name__, + "auth_level": self._current_token.auth_level.value, + "is_expired": self._current_token.is_expired, + "has_refresh": bool(self._current_token.refresh_token), + "jwt_claims_available": bool(claims), + "key_claims": ( + { + "jIdC": claims.get("jIdC", "MISSING"), + "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" + ), + "scope": claims.get("scope", "MISSING"), + } + if claims + else {} + ), + } diff --git a/lib/streaming_providers/providers/joyn/constants.py b/lib/streaming_providers/providers/joyn/constants.py index 966db91..b1891ef 100644 --- a/lib/streaming_providers/providers/joyn/constants.py +++ b/lib/streaming_providers/providers/joyn/constants.py @@ -4,15 +4,15 @@ # ============================================================================ # SSO endpoints discovery URL -JOYN_SSO_DISCOVERY_URL = 'https://auth.joyn.de/sso/endpoints' +JOYN_SSO_DISCOVERY_URL = "https://auth.joyn.de/sso/endpoints" -JOYN_LOGO = 'https://upload.wikimedia.org/wikipedia/de/thumb/7/74/Joyn_%28Streaminganbieter%29_logo.svg/2560px-Joyn_%28Streaminganbieter%29_logo.svg.png' +JOYN_LOGO = "https://upload.wikimedia.org/wikipedia/de/thumb/7/74/Joyn_%28Streaminganbieter%29_logo.svg/2560px-Joyn_%28Streaminganbieter%29_logo.svg.png" # Default client IDs for different platforms (fallback) DEVICE_IDS = { - 'web': '709115c2-f87e-4bad-9b94-28ac08d72cd9', - 'android': '05f5f3df-1130-4707-a761-c04d0c50b7f2', - 'ios': '21218403-52ec-4a65-abf4-f36a0eadd631' + "web": "709115c2-f87e-4bad-9b94-28ac08d72cd9", + "android": "05f5f3df-1130-4707-a761-c04d0c50b7f2", + "ios": "21218403-52ec-4a65-abf4-f36a0eadd631", } # OAuth2 Configuration @@ -23,13 +23,13 @@ JOYN_OAUTH_SCOPE = "openid email profile offline_access" # ============================================================================ # Base authentication URL -JOYN_AUTH_BASE_URL = 'https://auth.joyn.de/auth' +JOYN_AUTH_BASE_URL = "https://auth.joyn.de/auth" # Authentication endpoints JOYN_AUTH_ENDPOINTS = { - 'ANONYMOUS': f'{JOYN_AUTH_BASE_URL}/anonymous', # Client credentials flow - 'REFRESH': f'{JOYN_AUTH_BASE_URL}/refresh', # Token refresh - 'LOGOUT': f'{JOYN_AUTH_BASE_URL}/logout', # Logout + "ANONYMOUS": f"{JOYN_AUTH_BASE_URL}/anonymous", # Client credentials flow + "REFRESH": f"{JOYN_AUTH_BASE_URL}/refresh", # Token refresh + "LOGOUT": f"{JOYN_AUTH_BASE_URL}/logout", # Logout } # ============================================================================ @@ -37,25 +37,25 @@ JOYN_AUTH_ENDPOINTS = { # ============================================================================ # Cidaas base URL (7pass authentication service) -JOYN_CIDAAS_BASE_URL = 'https://auth.7pass.de' +JOYN_CIDAAS_BASE_URL = "https://auth.7pass.de" # Cidaas API endpoints JOYN_CIDAAS_ENDPOINTS = { - 'VERIFICATION_INITIATE': f'{JOYN_CIDAAS_BASE_URL}/verification-srv/v2/authenticate/initiate/PASSWORD', - 'VERIFICATION_AUTHENTICATE': f'{JOYN_CIDAAS_BASE_URL}/verification-srv/v2/authenticate/authenticate/PASSWORD', - 'LOGIN_VERIFICATION': f'{JOYN_CIDAAS_BASE_URL}/login-srv/verification/login', - 'REGISTRATION_SETUP': f'{JOYN_CIDAAS_BASE_URL}/registration-setup-srv/public/list', - 'USER_CHECK_EXISTS': f'{JOYN_CIDAAS_BASE_URL}/users-srv/user/checkexists', - 'VERIFICATION_LIST': f'{JOYN_CIDAAS_BASE_URL}/verification-srv/v2/setup/public/configured/list', - 'CONSENT_ACCEPT': f'{JOYN_CIDAAS_BASE_URL}/consent-management-srv/consent/scope/accept', - 'LOGIN_CONTINUE': f'{JOYN_CIDAAS_BASE_URL}/login-srv/precheck/continue' + "VERIFICATION_INITIATE": f"{JOYN_CIDAAS_BASE_URL}/verification-srv/v2/authenticate/initiate/PASSWORD", + "VERIFICATION_AUTHENTICATE": f"{JOYN_CIDAAS_BASE_URL}/verification-srv/v2/authenticate/authenticate/PASSWORD", + "LOGIN_VERIFICATION": f"{JOYN_CIDAAS_BASE_URL}/login-srv/verification/login", + "REGISTRATION_SETUP": f"{JOYN_CIDAAS_BASE_URL}/registration-setup-srv/public/list", + "USER_CHECK_EXISTS": f"{JOYN_CIDAAS_BASE_URL}/users-srv/user/checkexists", + "VERIFICATION_LIST": f"{JOYN_CIDAAS_BASE_URL}/verification-srv/v2/setup/public/configured/list", + "CONSENT_ACCEPT": f"{JOYN_CIDAAS_BASE_URL}/consent-management-srv/consent/scope/accept", + "LOGIN_CONTINUE": f"{JOYN_CIDAAS_BASE_URL}/login-srv/precheck/continue", } # Base URLs JOYN_BASE_URLS = { - 'ORIGIN': 'https://www.joyn.de', - 'REFERER': 'https://www.joyn.de/', - 'SIGNIN_BASE': 'https://signin.7pass.de' + "ORIGIN": "https://www.joyn.de", + "REFERER": "https://www.joyn.de/", + "SIGNIN_BASE": "https://signin.7pass.de", } # ============================================================================ @@ -63,31 +63,31 @@ JOYN_BASE_URLS = { # ============================================================================ # Client version used in API requests -JOYN_CLIENT_VERSION = '5.1344.1' +JOYN_CLIENT_VERSION = "5.1344.1" # Platform identifier -DEFAULT_PLATFORM = 'web' +DEFAULT_PLATFORM = "web" # Default user agent for all requests -JOYN_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36' +JOYN_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36" # Base64 encoded secret key for signature generation -SIGNATURE_SECRET_KEY = 'MzU0MzM3MzgzMzM4MzMzNjM1NDMzNzM4MzYzNDM2MzYzNTQzMzczODM2MzYzMzM4MzIzNjM1NDMzNzM4MzMzMDM2MzQzNTM5MzU0MzM3MzgzMzM5MzMzNTMyMzQzNTQzMzczODM2MzUzMzM5MzU0MzM3MzgzMzM4MzMzMjMzNDYzNTQzMzczODM2MzYzMzMzMzM0NDMzNDIzNTQzMzczODMzMzgzNjM2MzMzNQ==' +SIGNATURE_SECRET_KEY = "MzU0MzM3MzgzMzM4MzMzNjM1NDMzNzM4MzYzNDM2MzYzNTQzMzczODM2MzYzMzM4MzIzNjM1NDMzNzM4MzMzMDM2MzQzNTM5MzU0MzM3MzgzMzM5MzMzNTMyMzQzNTQzMzczODM2MzUzMzM5MzU0MzM3MzgzMzM4MzMzMjMzNDYzNTQzMzczODM2MzYzMzMzMzM0NDMzNDIzNTQzMzczODMzMzgzNjM2MzMzNQ==" JOYN_AUTH_HEADERS_BASE = { - 'User-Agent': JOYN_USER_AGENT, - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Origin': JOYN_BASE_URLS['ORIGIN'], - 'joyn-client-version': JOYN_CLIENT_VERSION, + "User-Agent": JOYN_USER_AGENT, + "Accept": "application/json", + "Content-Type": "application/json", + "Origin": JOYN_BASE_URLS["ORIGIN"], + "joyn-client-version": JOYN_CLIENT_VERSION, # Note: 'joyn-platform' is added dynamically in auth.py and provider.py } # Base API headers (without dynamic auth tokens) JOYN_API_BASE_HEADERS = { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'User-Agent': JOYN_USER_AGENT + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": JOYN_USER_AGENT, } # ============================================================================ @@ -95,26 +95,26 @@ JOYN_API_BASE_HEADERS = { # ============================================================================ # GraphQL base URL -JOYN_GRAPHQL_BASE_URL = 'https://api.joyn.de/graphql' +JOYN_GRAPHQL_BASE_URL = "https://api.joyn.de/graphql" # GraphQL persisted query hashes GRAPHQL_QUERY_HASHES = { - 'LIVE_PLAYER': '52b37a3cf5bc75e56026aed7b0d234874eeabd2eccd369d0cd3d3a6ea15ef566', - 'LIVE_CHANNELS': 'b7703103ddd0516be6b49ed66186092a6c6f6d815ccc502a9f50800a8cc18dd2' + "LIVE_PLAYER": "52b37a3cf5bc75e56026aed7b0d234874eeabd2eccd369d0cd3d3a6ea15ef566", + "LIVE_CHANNELS": "b7703103ddd0516be6b49ed66186092a6c6f6d815ccc502a9f50800a8cc18dd2", } # GraphQL endpoints with full URLs JOYN_GRAPHQL_ENDPOINTS = { - 'LIVE_PLAYER': f'{JOYN_GRAPHQL_BASE_URL}?operationName=PageLivePlayerClientSide&enable_user_location=true&watch_assistant_variant=true&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%22{GRAPHQL_QUERY_HASHES["LIVE_PLAYER"]}%22%7D%7D', - 'LIVE_CHANNELS': f'{JOYN_GRAPHQL_BASE_URL}?operationName=LiveChannelsAndEpg&enable_user_location=true&watch_assistant_variant=true' + "LIVE_PLAYER": f'{JOYN_GRAPHQL_BASE_URL}?operationName=PageLivePlayerClientSide&enable_user_location=true&watch_assistant_variant=true&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%22{GRAPHQL_QUERY_HASHES["LIVE_PLAYER"]}%22%7D%7D', + "LIVE_CHANNELS": f"{JOYN_GRAPHQL_BASE_URL}?operationName=LiveChannelsAndEpg&enable_user_location=true&watch_assistant_variant=true", } # Base GraphQL headers (without country-specific ones) JOYN_GRAPHQL_BASE_HEADERS = { - 'X-Api-Key': '4f0fd9f18abbe3cf0e87fdb556bc39c8', - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'User-Agent': JOYN_USER_AGENT + "X-Api-Key": "4f0fd9f18abbe3cf0e87fdb556bc39c8", + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": JOYN_USER_AGENT, } # GraphQL persisted query version @@ -131,8 +131,8 @@ GRAPHQL_OFFSET = 0 # Streaming API endpoints JOYN_STREAMING_ENDPOINTS = { - 'ENTITLEMENT': 'https://entitlement.p7s1.io/api/user/entitlement-token', - 'PLAYLIST': 'https://api.vod-prd.s.joyn.de/v1/channel/{channel_id}/playlist' + "ENTITLEMENT": "https://entitlement.p7s1.io/api/user/entitlement-token", + "PLAYLIST": "https://api.vod-prd.s.joyn.de/v1/channel/{channel_id}/playlist", } # Default video data payload configuration @@ -168,20 +168,20 @@ DEFAULT_VIDEO_CONFIG = { # ============================================================================ # Content types -CONTENT_TYPE_LIVE = 'LIVE' -CONTENT_TYPE_VOD = 'VOD' +CONTENT_TYPE_LIVE = "LIVE" +CONTENT_TYPE_VOD = "VOD" # Stream types -STREAM_TYPE_LINEAR = 'LINEAR' -STREAM_TYPE_EVENT = 'EVENT' -STREAM_TYPE_ON_DEMAND = 'ON_DEMAND' +STREAM_TYPE_LINEAR = "LINEAR" +STREAM_TYPE_EVENT = "EVENT" +STREAM_TYPE_ON_DEMAND = "ON_DEMAND" # Livestream types for GraphQL queries -DEFAULT_LIVESTREAM_TYPES = ['EVENT', 'LINEAR', 'ON_DEMAND'] +DEFAULT_LIVESTREAM_TYPES = ["EVENT", "LINEAR", "ON_DEMAND"] # Stream modes -MODE_LIVE = 'live' -MODE_VOD = 'vod' +MODE_LIVE = "live" +MODE_VOD = "vod" # ============================================================================ # Error Codes @@ -189,12 +189,12 @@ MODE_VOD = 'vod' # Known error codes from Joyn API ERROR_CODES = { - 'PLAYBACK_RESTRICTED': 'ENT_RVOD_Playback_Restricted', - 'UNAUTHORIZED': 'ENT_Unauthorized', - 'NOT_FOUND': 'ENT_Not_Found', - 'GEOBLOCKED': 'ENT_Geoblocked', - 'VALIDATION_ERROR': 'VALIDATION_ERROR', # Added for token refresh - 'INVALID_JWT': 'INVALID_JWT' # Added for expired tokens + "PLAYBACK_RESTRICTED": "ENT_RVOD_Playback_Restricted", + "UNAUTHORIZED": "ENT_Unauthorized", + "NOT_FOUND": "ENT_Not_Found", + "GEOBLOCKED": "ENT_Geoblocked", + "VALIDATION_ERROR": "VALIDATION_ERROR", # Added for token refresh + "INVALID_JWT": "INVALID_JWT", # Added for expired tokens } # ============================================================================ @@ -202,45 +202,43 @@ ERROR_CODES = { # ============================================================================ # Country to distribution tenant mapping -COUNTRY_TENANT_MAPPING = { - 'de': 'JOYN', - 'at': 'JOYN_AT', - 'ch': 'JOYN_CH' -} +COUNTRY_TENANT_MAPPING = {"de": "JOYN", "at": "JOYN_AT", "ch": "JOYN_CH"} JOYN_DOMAINS = { - 'de': 'https://www.joyn.de', - 'at': 'https://www.joyn.at', - 'ch': 'https://www.joyn.ch' + "de": "https://www.joyn.de", + "at": "https://www.joyn.at", + "ch": "https://www.joyn.ch", } + def get_oauth_redirect_uri(country: str) -> str: """Get country-specific OAuth redirect URI""" return "https://www.joyn.de/oauth" + # Supported countries SUPPORTED_COUNTRIES = list(COUNTRY_TENANT_MAPPING.keys()) # Default country -DEFAULT_COUNTRY = 'de' +DEFAULT_COUNTRY = "de" # ============================================================================ # DRM Configuration # ============================================================================ # DRM system -DRM_SYSTEM_WIDEVINE = 'widevine' +DRM_SYSTEM_WIDEVINE = "widevine" # DRM request headers DRM_REQUEST_HEADERS = { - 'Content-Type': 'application/octet-stream', - 'User-Agent': JOYN_USER_AGENT + "Content-Type": "application/octet-stream", + "User-Agent": JOYN_USER_AGENT, } # DRM license request template (without bearer token) DRM_LICENSE_HEADERS_BASE = { - 'Content-Type': 'application/octet-stream', - 'User-Agent': JOYN_USER_AGENT + "Content-Type": "application/octet-stream", + "User-Agent": JOYN_USER_AGENT, } # ============================================================================ @@ -262,13 +260,13 @@ DEFAULT_EPG_WINDOW_HOURS = 3 # Default channel settings DEFAULT_CHANNEL_CONFIG = { - 'video': 'best', - 'on_demand': True, - 'speed_up': True, - 'use_cdm': True, - 'cdm_mode': 'external', - 'session_manifest': False + "video": "best", + "on_demand": True, + "speed_up": True, + "use_cdm": True, + "cdm_mode": "external", + "session_manifest": False, } # Default language -DEFAULT_LANGUAGE = 'de' \ No newline at end of file +DEFAULT_LANGUAGE = "de" diff --git a/lib/streaming_providers/providers/joyn/models.py b/lib/streaming_providers/providers/joyn/models.py index cc8c0e4..88d4a2c 100644 --- a/lib/streaming_providers/providers/joyn/models.py +++ b/lib/streaming_providers/providers/joyn/models.py @@ -1,7 +1,7 @@ # streaming_providers/providers/joyn/models.py +import json from dataclasses import dataclass, field from typing import Dict, Optional -import json from ...base.models import StreamingChannel @@ -10,6 +10,7 @@ class PlaybackRestrictedException(Exception): """ Exception raised when content playback is restricted """ + pass @@ -18,6 +19,7 @@ class JoynChannel: """ Represents a Joyn channel with all necessary streaming data """ + # Core identification name: str channel_id: str @@ -35,19 +37,19 @@ class JoynChannel: cdm_type: Optional[str] = None use_cdm: bool = True cdm: Optional[str] = None # Usually "pid={pid}" - cdm_mode: str = 'external' + cdm_mode: str = "external" # Video settings - video: str = 'best' + video: str = "best" on_demand: bool = True speed_up: bool = True # Additional metadata - content_type: str = 'LIVE' # 'LIVE' or 'VOD' + content_type: str = "LIVE" # 'LIVE' or 'VOD' description: Optional[str] = None genre: Optional[str] = None - language: str = 'de' - country: str = 'DE' + language: str = "de" + country: str = "DE" # Streaming data license_url: Optional[str] = None @@ -58,7 +60,7 @@ class JoynChannel: raw_data: Dict = field(default_factory=dict) @classmethod - def from_api_data(cls, api_data: Dict, **kwargs) -> 'JoynChannel': + def from_api_data(cls, api_data: Dict, **kwargs) -> "JoynChannel": """ Create JoynChannel from API response data @@ -70,10 +72,10 @@ class JoynChannel: JoynChannel instance """ channel = cls( - name=api_data.get('title', 'Unknown Channel'), - channel_id=api_data.get('id', ''), - content_type=api_data.get('type', 'LIVE'), - raw_data=api_data.copy() + name=api_data.get("title", "Unknown Channel"), + channel_id=api_data.get("id", ""), + content_type=api_data.get("type", "LIVE"), + raw_data=api_data.copy(), ) # Apply any additional parameters @@ -83,9 +85,15 @@ class JoynChannel: return channel - def set_streaming_data(self, manifest: str, cdm_type: str = None, - pid: str = None, license_url: str = None, - certificate_url: str = None, streaming_format: str = None) -> None: + def set_streaming_data( + self, + manifest: str, + cdm_type: str = None, + pid: str = None, + license_url: str = None, + certificate_url: str = None, + streaming_format: str = None, + ) -> None: """ Configure streaming-specific data @@ -127,13 +135,13 @@ class JoynChannel: def is_live(self) -> bool: """Check if channel is live TV""" - return self.content_type == 'LIVE' and self.mode == 'live' + return self.content_type == "LIVE" and self.mode == "live" def is_vod(self) -> bool: """Check if channel is video on demand""" - return self.content_type == 'VOD' or self.mode == 'vod' + return self.content_type == "VOD" or self.mode == "vod" - def to_streaming_channel(self, provider_name: str = 'joyn') -> StreamingChannel: + def to_streaming_channel(self, provider_name: str = "joyn") -> StreamingChannel: """ Convert to generic StreamingChannel object @@ -166,7 +174,7 @@ class JoynChannel: country=self.country, license_url=self.license_url, certificate_url=self.certificate_url, - streaming_format=self.streaming_format + streaming_format=self.streaming_format, ) def to_dict(self) -> Dict: @@ -177,19 +185,19 @@ class JoynChannel: Dictionary in the format expected by your application """ return { - 'Name': self.name, - 'LogoUrl': self.logo_url, - 'Mode': self.mode, - 'SessionManifest': self.session_manifest, - 'Manifest': self.manifest, - 'ManifestScript': self.manifest_script, - 'CdmType': self.cdm_type, - 'UseCdm': self.use_cdm, - 'Cdm': self.cdm, - 'CdmMode': self.cdm_mode, - 'Video': self.video, - 'OnDemand': self.on_demand, - 'SpeedUp': self.speed_up + "Name": self.name, + "LogoUrl": self.logo_url, + "Mode": self.mode, + "SessionManifest": self.session_manifest, + "Manifest": self.manifest, + "ManifestScript": self.manifest_script, + "CdmType": self.cdm_type, + "UseCdm": self.use_cdm, + "Cdm": self.cdm, + "CdmMode": self.cdm_mode, + "Video": self.video, + "OnDemand": self.on_demand, + "SpeedUp": self.speed_up, } def to_json(self, indent: int = 2) -> str: @@ -200,4 +208,4 @@ class JoynChannel: return f"JoynChannel(name='{self.name}', id='{self.channel_id}', type='{self.content_type}')" def __repr__(self) -> str: - return self.__str__() \ No newline at end of file + return self.__str__() diff --git a/lib/streaming_providers/providers/joyn/provider.py b/lib/streaming_providers/providers/joyn/provider.py index 04a223b..b8bc4e7 100644 --- a/lib/streaming_providers/providers/joyn/provider.py +++ b/lib/streaming_providers/providers/joyn/provider.py @@ -1,72 +1,58 @@ # streaming_providers/providers/joyn/provider.py # -*- coding: utf-8 -*- -from typing import Dict, Optional, List, ClassVar +import hashlib import json import time -import hashlib import urllib.parse -from datetime import datetime, timedelta from base64 import b64decode +from datetime import datetime, timedelta from json import dumps +from typing import ClassVar, Dict, List, Optional -from ...base.provider import StreamingProvider, AuthType -from ...base.models import DRMConfig, LicenseConfig, DRMSystem -from ...base.models.streaming_channel import StreamingChannel +from ...base.models import DRMConfig, DRMSystem, LicenseConfig from ...base.models.proxy_models import ProxyConfig +from ...base.models.streaming_channel import StreamingChannel +from ...base.provider import AuthType, StreamingProvider from ...base.utils.logger import logger -from .models import JoynChannel, PlaybackRestrictedException from .auth import JoynAuthenticator -from .constants import ( - SIGNATURE_SECRET_KEY, - DEFAULT_VIDEO_CONFIG, - COUNTRY_TENANT_MAPPING, - SUPPORTED_COUNTRIES, - JOYN_GRAPHQL_ENDPOINTS, - JOYN_GRAPHQL_BASE_HEADERS, - JOYN_STREAMING_ENDPOINTS, - JOYN_CLIENT_VERSION, - DEFAULT_PLATFORM, - JOYN_USER_AGENT, - JOYN_API_BASE_HEADERS, - JOYN_DOMAINS, - ERROR_CODES, - CONTENT_TYPE_LIVE, - CONTENT_TYPE_VOD, - DEFAULT_LIVESTREAM_TYPES, - MODE_LIVE, - MODE_VOD, - DRM_SYSTEM_WIDEVINE, - DRM_REQUEST_HEADERS, - DEFAULT_REQUEST_TIMEOUT, - DEFAULT_MAX_RETRIES, - DEFAULT_EPG_WINDOW_HOURS, - GRAPHQL_PERSISTED_QUERY_VERSION, - GRAPHQL_LIVE_CHANNELS_FILTER, - GRAPHQL_MAX_RESULTS, - GRAPHQL_OFFSET, - GRAPHQL_QUERY_HASHES, - JOYN_LOGO -) +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 def create_video_payload(config: Optional[Dict] = None, compact: bool = True) -> str: """Create video data payload for requests""" video_config = config or DEFAULT_VIDEO_CONFIG payload = dumps(video_config) - return payload.replace(' ', '') if compact else payload + return payload.replace(" ", "") if compact else payload -def build_signature(entitlement_token: str, video_payload: Optional[str] = None, - secret_key: Optional[str] = None) -> str: +def build_signature( + entitlement_token: str, + video_payload: Optional[str] = None, + secret_key: Optional[str] = None, +) -> str: """Build signature for video data requests""" if video_payload is None: video_payload = create_video_payload() if secret_key is None: - secret_key = b64decode(SIGNATURE_SECRET_KEY).decode('utf-8') + secret_key = b64decode(SIGNATURE_SECRET_KEY).decode("utf-8") signature_input = f"{video_payload},{entitlement_token}{secret_key}" - return hashlib.sha1(signature_input.encode('utf-8')).hexdigest() + return hashlib.sha1(signature_input.encode("utf-8")).hexdigest() class JoynProvider(StreamingProvider): @@ -78,15 +64,21 @@ class JoynProvider(StreamingProvider): # STATIC METADATA (NEW) # ============================================================================ PROVIDER_LABEL: ClassVar[str] = "Joyn" - SUPPORTED_AUTH_TYPES: ClassVar[List[str]] = ['client_credentials', 'user_credentials'] + SUPPORTED_AUTH_TYPES: ClassVar[List[str]] = [ + "client_credentials", + "user_credentials", + ] PROVIDER_LOGO: ClassVar[str] = JOYN_LOGO SUPPORTED_COUNTRIES: ClassVar[List[str]] = SUPPORTED_COUNTRIES - def __init__(self, country: str = 'de', - platform: str = DEFAULT_PLATFORM, - config_dir: Optional[str] = None, - proxy_config: Optional[ProxyConfig] = None, - proxy_url: Optional[str] = None): + def __init__( + self, + country: str = "de", + platform: str = DEFAULT_PLATFORM, + config_dir: Optional[str] = None, + proxy_config: Optional[ProxyConfig] = None, + proxy_url: Optional[str] = None, + ): """ Initialize Joyn provider @@ -98,29 +90,30 @@ class JoynProvider(StreamingProvider): proxy_url: Optional proxy URL string (medium priority) """ if not self.validate_country(country): - supported = ', '.join(self.SUPPORTED_COUNTRIES) + supported = ", ".join(self.SUPPORTED_COUNTRIES) raise ValueError( - f"Unsupported country: {country}. " - f"Joyn supports: {supported}" + f"Unsupported country: {country}. " f"Joyn supports: {supported}" ) super().__init__(country=country) if country not in SUPPORTED_COUNTRIES: - raise ValueError(f"Unsupported country: {country}. Must be one of: {SUPPORTED_COUNTRIES}") + raise ValueError( + f"Unsupported country: {country}. Must be one of: {SUPPORTED_COUNTRIES}" + ) self.distribution_tenant = COUNTRY_TENANT_MAPPING[country] self.platform = platform # Setup HTTP manager using abstraction self.http_manager = self._setup_http_manager( - provider_name='joyn', + provider_name="joyn", proxy_config=proxy_config, proxy_url=proxy_url, config_dir=config_dir, user_agent=JOYN_USER_AGENT, timeout=DEFAULT_REQUEST_TIMEOUT, - max_retries=DEFAULT_MAX_RETRIES + max_retries=DEFAULT_MAX_RETRIES, ) # Create authenticator with shared HTTP manager @@ -129,7 +122,7 @@ class JoynProvider(StreamingProvider): platform=platform, config_dir=config_dir, http_manager=self.http_manager, - proxy_config=self.http_manager.config.proxy_config # Use resolved proxy + proxy_config=self.http_manager.config.proxy_config, # Use resolved proxy ) # Authenticate @@ -141,16 +134,16 @@ class JoynProvider(StreamingProvider): @property def provider_name(self) -> str: - return 'joyn' + return "joyn" # Override provider_label to provide custom country formatting @property def provider_label(self) -> str: """Return country-specific label""" country_map = { - 'de': 'Joyn Germany', - 'at': 'Joyn Austria', - 'ch': 'Joyn Switzerland' + "de": "Joyn Germany", + "at": "Joyn Austria", + "ch": "Joyn Switzerland", } return country_map.get(self.country, f"Joyn ({self.country.upper()})") @@ -173,11 +166,13 @@ class JoynProvider(StreamingProvider): def authenticate(self, **kwargs) -> str: """Authenticate and return bearer token""" self.bearer_token = self.authenticator.get_bearer_token( - force_refresh=kwargs.get('force_refresh', False) + force_refresh=kwargs.get("force_refresh", False) ) 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]: @@ -186,12 +181,12 @@ class JoynProvider(StreamingProvider): base_headers=JOYN_GRAPHQL_BASE_HEADERS, auth_type=AuthType.NONE, # GraphQL queries don't need auth provider_headers={ - 'joyn-client-version': JOYN_CLIENT_VERSION, - 'joyn-country': self.country.upper(), - 'joyn-distribution-tenant': self.distribution_tenant, - 'joyn-platform': self.platform, - 'joyn-user-state': 'code=R_A' - } + "joyn-client-version": JOYN_CLIENT_VERSION, + "joyn-country": self.country.upper(), + "joyn-distribution-tenant": self.distribution_tenant, + "joyn-platform": self.platform, + "joyn-user-state": "code=R_A", + }, ) def refresh_authentication(self) -> str: @@ -199,11 +194,13 @@ class JoynProvider(StreamingProvider): self.bearer_token = self.authenticator.get_bearer_token(force_refresh=True) return self.bearer_token - def get_channels(self, - time_window_hours: int = DEFAULT_EPG_WINDOW_HOURS, - fetch_manifests: bool = False, - populate_streaming_data: bool = True, - **kwargs) -> List[StreamingChannel]: + def get_channels( + self, + time_window_hours: int = DEFAULT_EPG_WINDOW_HOURS, + fetch_manifests: bool = False, + populate_streaming_data: bool = True, + **kwargs, + ) -> List[StreamingChannel]: """ Fetch available channels from Joyn GraphQL API @@ -228,14 +225,14 @@ class JoynProvider(StreamingProvider): "offset": GRAPHQL_OFFSET, "livestreamTypes": DEFAULT_LIVESTREAM_TYPES, "from": current_time, - "to": end_time + "to": end_time, } variables_encoded = urllib.parse.quote(json.dumps(variables)) extensions = { "persistedQuery": { "version": GRAPHQL_PERSISTED_QUERY_VERSION, - "sha256Hash": GRAPHQL_QUERY_HASHES['LIVE_CHANNELS'] + "sha256Hash": GRAPHQL_QUERY_HASHES["LIVE_CHANNELS"], } } extensions_encoded = urllib.parse.quote(json.dumps(extensions)) @@ -243,10 +240,7 @@ class JoynProvider(StreamingProvider): url = f"{JOYN_GRAPHQL_ENDPOINTS['LIVE_CHANNELS']}&variables={variables_encoded}&extensions={extensions_encoded}" response = self.http_manager.get( - url, - operation='api', - headers=headers, - timeout=DEFAULT_REQUEST_TIMEOUT + url, operation="api", headers=headers, timeout=DEFAULT_REQUEST_TIMEOUT ) response.raise_for_status() channel_data = response.json() @@ -257,7 +251,9 @@ 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: @@ -265,25 +261,27 @@ class JoynProvider(StreamingProvider): def _process_graphql_response(self, response_data: Dict) -> List[StreamingChannel]: """Process GraphQL response and convert to StreamingChannel objects""" - if 'data' not in response_data or 'liveStreams' not in response_data['data']: + if "data" not in response_data or "liveStreams" not in response_data["data"]: raise Exception("Invalid GraphQL response structure") - live_streams = response_data['data']['liveStreams'] + live_streams = response_data["data"]["liveStreams"] channels = [] for stream_data in live_streams: try: - channel_id = stream_data.get('id', '') - title = stream_data.get('title', 'Unknown Channel') - stream_type = stream_data.get('type', 'LINEAR') - quality = stream_data.get('quality', '') + channel_id = stream_data.get("id", "") + title = stream_data.get("title", "Unknown Channel") + stream_type = stream_data.get("type", "LINEAR") + quality = stream_data.get("quality", "") logo_url = None - if 'logo' in stream_data and 'url' in stream_data['logo']: - logo_url = stream_data['logo']['url'] + 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 - mode = MODE_LIVE if stream_type == 'LINEAR' else MODE_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( name=title, @@ -292,19 +290,19 @@ class JoynProvider(StreamingProvider): mode=mode, content_type=content_type, country=self.country, - raw_data=stream_data + raw_data=stream_data, ) if quality: joyn_channel.name = f"{title} ({quality})" - if 'brand' in stream_data: - brand_data = stream_data['brand'] - if 'brand_id' in brand_data: - joyn_channel.raw_data['brand_id'] = brand_data['brand_id'] + if "brand" in stream_data: + brand_data = stream_data["brand"] + if "brand_id" in brand_data: + joyn_channel.raw_data["brand_id"] = brand_data["brand_id"] - if stream_data.get('eventStream', False): - joyn_channel.raw_data['is_event_stream'] = True + if stream_data.get("eventStream", False): + joyn_channel.raw_data["is_event_stream"] = True streaming_channel = joyn_channel.to_streaming_channel( provider_name=self.provider_name @@ -316,7 +314,9 @@ 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 @@ -331,28 +331,25 @@ class JoynProvider(StreamingProvider): base_headers=JOYN_API_BASE_HEADERS, auth_type=AuthType.BEARER, provider_headers={ - 'joyn-client-version': JOYN_CLIENT_VERSION, - 'joyn-country': self.country.upper(), - 'joyn-distribution-tenant': self.distribution_tenant, - 'joyn-platform': self.platform, - 'joyn-b2b-context': 'UNKNOWN', - 'joyn-client-os': 'UNKNOWN', - 'origin': JOYN_DOMAINS.get(self.country, JOYN_DOMAINS['de']) - } + "joyn-client-version": JOYN_CLIENT_VERSION, + "joyn-country": self.country.upper(), + "joyn-distribution-tenant": self.distribution_tenant, + "joyn-platform": self.platform, + "joyn-b2b-context": "UNKNOWN", + "joyn-client-os": "UNKNOWN", + "origin": JOYN_DOMAINS.get(self.country, JOYN_DOMAINS["de"]), + }, ) - payload = { - "content_id": content_id, - "content_type": content_type - } + payload = {"content_id": content_id, "content_type": content_type} try: response = self.http_manager.post( - JOYN_STREAMING_ENDPOINTS['ENTITLEMENT'], - operation='auth', + JOYN_STREAMING_ENDPOINTS["ENTITLEMENT"], + operation="auth", headers=headers, json_data=payload, - timeout=DEFAULT_REQUEST_TIMEOUT + timeout=DEFAULT_REQUEST_TIMEOUT, ) if response.status_code == 400: @@ -362,16 +359,22 @@ class JoynProvider(StreamingProvider): error = error_data[0] code = error.get("code", "UNKNOWN") msg = error.get("msg", "No error message provided") - if code == ERROR_CODES['PLAYBACK_RESTRICTED']: - raise PlaybackRestrictedException(f"Playback restricted for {content_id}: {msg}") + if code == ERROR_CODES["PLAYBACK_RESTRICTED"]: + raise PlaybackRestrictedException( + 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}") + raise Exception( + f"Bad response for {content_id} (400), failed to parse error: {e}" + ) response.raise_for_status() data = response.json() - return data['entitlement_token'] + return data["entitlement_token"] except PlaybackRestrictedException: raise @@ -380,8 +383,12 @@ class JoynProvider(StreamingProvider): except Exception as e: raise Exception(f"Error getting entitlement token for {content_id}: {e}") - def get_channel_playlist(self, channel_id: str, entitlement_token: str, - video_config: Optional[Dict] = None) -> Dict: + def get_channel_playlist( + self, + channel_id: str, + entitlement_token: str, + video_config: Optional[Dict] = None, + ) -> Dict: """ Get channel playlist data @@ -396,19 +403,19 @@ class JoynProvider(StreamingProvider): video_payload = create_video_payload(video_config) signature = build_signature(entitlement_token, video_payload) - url = JOYN_STREAMING_ENDPOINTS['PLAYLIST'].format(channel_id=channel_id) + url = JOYN_STREAMING_ENDPOINTS["PLAYLIST"].format(channel_id=channel_id) url += f"?signature={signature}" headers = JOYN_API_BASE_HEADERS.copy() - headers['Authorization'] = f'Bearer {entitlement_token}' + headers["Authorization"] = f"Bearer {entitlement_token}" try: response = self.http_manager.post( url, - operation='manifest', + operation="manifest", headers=headers, data=video_payload, - timeout=DEFAULT_REQUEST_TIMEOUT + timeout=DEFAULT_REQUEST_TIMEOUT, ) response.raise_for_status() return response.json() @@ -416,9 +423,12 @@ class JoynProvider(StreamingProvider): except Exception as e: raise Exception(f"Error getting playlist for {channel_id}: {e}") - def populate_streaming_data(self, channels: List[StreamingChannel], - video_config: Optional[Dict] = None, - max_retries: int = DEFAULT_MAX_RETRIES) -> List[StreamingChannel]: + def populate_streaming_data( + self, + channels: List[StreamingChannel], + video_config: Optional[Dict] = None, + max_retries: int = DEFAULT_MAX_RETRIES, + ) -> List[StreamingChannel]: """ Populate streaming data (manifest, DRM) for all channels @@ -439,25 +449,24 @@ class JoynProvider(StreamingProvider): while retries < max_retries and not success and not is_restricted: try: - logger.debug(f"Getting entitlement token for: {channel.name} (attempt {retries + 1})") + logger.debug( + f"Getting entitlement token for: {channel.name} (attempt {retries + 1})" + ) entitlement_token = self.get_entitlement_token( - content_id=channel.channel_id, - content_type=channel.content_type + content_id=channel.channel_id, content_type=channel.content_type ) logger.debug(f"Getting playlist data for: {channel.name}") playlist_data = self.get_channel_playlist( - channel.channel_id, - entitlement_token, - video_config + channel.channel_id, entitlement_token, video_config ) - manifest_url = playlist_data.get('manifestUrl') - license_url = playlist_data.get('licenseUrl') - certificate_url = playlist_data.get('certificateUrl') - streaming_format = playlist_data.get('streamingFormat', 'dash') + manifest_url = playlist_data.get("manifestUrl") + license_url = playlist_data.get("licenseUrl") + certificate_url = playlist_data.get("certificateUrl") + streaming_format = playlist_data.get("streamingFormat", "dash") if manifest_url: channel.manifest = manifest_url @@ -480,22 +489,27 @@ 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 - def enrich_channel_data(self, - channel: StreamingChannel, - video_config: Optional[Dict] = None, - **kwargs) -> Optional[StreamingChannel]: + def enrich_channel_data( + self, channel: StreamingChannel, video_config: Optional[Dict] = None, **kwargs + ) -> Optional[StreamingChannel]: """ Get manifest URL for a specific channel and properly configure DRM @@ -509,38 +523,37 @@ class JoynProvider(StreamingProvider): """ try: entitlement_token = self.get_entitlement_token( - content_id=channel.channel_id, - content_type=channel.content_type + content_id=channel.channel_id, content_type=channel.content_type ) playlist_data = self.get_channel_playlist( - channel.channel_id, - entitlement_token, - video_config + channel.channel_id, entitlement_token, video_config ) - manifest_url = playlist_data.get('manifestUrl') + manifest_url = playlist_data.get("manifestUrl") if not manifest_url: return None channel.manifest = manifest_url - channel.streaming_format = playlist_data.get('streamingFormat', 'dash') + channel.streaming_format = playlist_data.get("streamingFormat", "dash") - license_url = playlist_data.get('licenseUrl') + license_url = playlist_data.get("licenseUrl") if license_url: drm_config = DRMConfig( system=DRMSystem.WIDEVINE, priority=1, license=LicenseConfig( server_url=license_url, - server_certificate=playlist_data.get('certificateUrl'), - req_headers=json.dumps({ - 'User-Agent': JOYN_USER_AGENT, - 'Content-Type': DRM_REQUEST_HEADERS['Content-Type'] - }), + server_certificate=playlist_data.get("certificateUrl"), + req_headers=json.dumps( + { + "User-Agent": JOYN_USER_AGENT, + "Content-Type": DRM_REQUEST_HEADERS["Content-Type"], + } + ), req_data="{CHA-RAW}", - use_http_get_request=False - ) + use_http_get_request=False, + ), ) channel.drm_config = drm_config channel.cdm_type = DRM_SYSTEM_WIDEVINE @@ -552,11 +565,13 @@ class JoynProvider(StreamingProvider): logger.error(f"Error getting manifest for {channel.name}: {e}") return None - def get_manifest(self, - channel_id: str, - content_type: str = CONTENT_TYPE_LIVE, - video_config: Optional[Dict] = None, - **kwargs) -> Optional[str]: + def get_manifest( + self, + channel_id: str, + content_type: str = CONTENT_TYPE_LIVE, + video_config: Optional[Dict] = None, + **kwargs, + ) -> Optional[str]: """ Get manifest URL for a specific channel by ID @@ -571,27 +586,26 @@ class JoynProvider(StreamingProvider): """ try: entitlement_token = self.get_entitlement_token( - content_id=channel_id, - content_type=content_type + content_id=channel_id, content_type=content_type ) playlist_data = self.get_channel_playlist( - channel_id, - entitlement_token, - video_config + channel_id, entitlement_token, video_config ) - return playlist_data.get('manifestUrl') + return playlist_data.get("manifestUrl") except Exception as e: logger.error(f"Error getting manifest for channel {channel_id}: {e}") return None - def get_drm(self, - channel_id: str, - content_type: str = CONTENT_TYPE_LIVE, - video_config: Optional[Dict] = None, - **kwargs) -> List[DRMConfig]: + def get_drm( + self, + channel_id: str, + content_type: str = CONTENT_TYPE_LIVE, + video_config: Optional[Dict] = None, + **kwargs, + ) -> List[DRMConfig]: """ Get all DRM configurations for a channel by ID @@ -606,17 +620,14 @@ class JoynProvider(StreamingProvider): """ try: entitlement_token = self.get_entitlement_token( - content_id=channel_id, - content_type=content_type + content_id=channel_id, content_type=content_type ) playlist_data = self.get_channel_playlist( - channel_id, - entitlement_token, - video_config + channel_id, entitlement_token, video_config ) - license_url = playlist_data.get('licenseUrl') + license_url = playlist_data.get("licenseUrl") if not license_url: return [] @@ -626,14 +637,16 @@ class JoynProvider(StreamingProvider): license=LicenseConfig.create_with_base64_req_data( req_data_template="{CHA-RAW}", server_url=license_url, - server_certificate=playlist_data.get('certificateUrl'), - req_headers=json.dumps({ - 'Authorization': f'Bearer {self.bearer_token}', - 'Content-Type': DRM_REQUEST_HEADERS['Content-Type'], - 'User-Agent': JOYN_USER_AGENT - }), - use_http_get_request=False - ) + server_certificate=playlist_data.get("certificateUrl"), + req_headers=json.dumps( + { + "Authorization": f"Bearer {self.bearer_token}", + "Content-Type": DRM_REQUEST_HEADERS["Content-Type"], + "User-Agent": JOYN_USER_AGENT, + } + ), + use_http_get_request=False, + ), ) return [drm_config] @@ -642,11 +655,13 @@ class JoynProvider(StreamingProvider): logger.error(f"Error getting DRM configs for channel {channel_id}: {e}") return [] - def get_epg(self, - channel_id: str, - start_time: Optional[datetime] = None, - end_time: Optional[datetime] = None, - **kwargs) -> List[Dict]: + def get_epg( + self, + channel_id: str, + start_time: Optional[datetime] = None, + end_time: Optional[datetime] = None, + **kwargs, + ) -> List[Dict]: """ Get EPG data for a channel @@ -668,9 +683,11 @@ 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: logger.error(f"Error getting EPG for channel {channel_id}: {e}") - return [] \ No newline at end of file + return [] diff --git a/lib/streaming_providers/providers/magenta2/__init__.py b/lib/streaming_providers/providers/magenta2/__init__.py index 719d4e6..45e57bb 100644 --- a/lib/streaming_providers/providers/magenta2/__init__.py +++ b/lib/streaming_providers/providers/magenta2/__init__.py @@ -1,45 +1,40 @@ # streaming_providers/providers/magenta2/__init__.py -from .provider import Magenta2Provider -from .models import Magenta2Channel, Magenta2PlaybackRestrictedException, DeviceLimitExceededException from .auth import Magenta2Authenticator, Magenta2AuthToken, Magenta2Credentials +from .config_models import (BootstrapConfig, DrmConfig, ManifestConfig, + MpxConfig, OpenIDConfig, ProviderConfig, + TvHubConfig) +from .constants import SUPPORTED_COUNTRIES from .discovery import DiscoveryService -from .endpoint_manager import EndpointManager, EndpointCategory -from .config_models import BootstrapConfig, ManifestConfig, OpenIDConfig, ProviderConfig, MpxConfig, DrmConfig, \ - TvHubConfig -from .constants import ( - SUPPORTED_COUNTRIES, -) +from .endpoint_manager import EndpointCategory, EndpointManager +from .models import (DeviceLimitExceededException, Magenta2Channel, + Magenta2PlaybackRestrictedException) +from .provider import Magenta2Provider __all__ = [ # Core provider - 'Magenta2Provider', - + "Magenta2Provider", # Models - 'Magenta2Channel', - 'Magenta2PlaybackRestrictedException', - 'DeviceLimitExceededException', - + "Magenta2Channel", + "Magenta2PlaybackRestrictedException", + "DeviceLimitExceededException", # Authentication - 'Magenta2Authenticator', - 'Magenta2AuthToken', - 'Magenta2Credentials', - + "Magenta2Authenticator", + "Magenta2AuthToken", + "Magenta2Credentials", # New discovery system - 'DiscoveryService', - 'EndpointManager', - 'EndpointCategory', - + "DiscoveryService", + "EndpointManager", + "EndpointCategory", # Configuration models - 'BootstrapConfig', - 'ManifestConfig', - 'OpenIDConfig', - 'ProviderConfig', - 'MpxConfig', - 'DrmConfig', - 'TvHubConfig', - + "BootstrapConfig", + "ManifestConfig", + "OpenIDConfig", + "ProviderConfig", + "MpxConfig", + "DrmConfig", + "TvHubConfig", # Constants - 'SUPPORTED_COUNTRIES', + "SUPPORTED_COUNTRIES", ] -__version__ = '2.0.0' # Major version bump for architectural changes \ No newline at end of file +__version__ = "2.0.0" # Major version bump for architectural changes diff --git a/lib/streaming_providers/providers/magenta2/auth.py b/lib/streaming_providers/providers/magenta2/auth.py index 93efa9f..a960d66 100644 --- a/lib/streaming_providers/providers/magenta2/auth.py +++ b/lib/streaming_providers/providers/magenta2/auth.py @@ -12,15 +12,20 @@ Key components: - TaaClient: TAA authentication and yo_digital token exchange - SsoClient: SSO operations (user authentication) """ -import uuid -import time -from typing import Dict, Optional, Any -from dataclasses import dataclass, field -from ...base.auth.base_auth import BaseAuthenticator, BaseAuthToken, TokenAuthLevel +import time +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.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) # Import Magenta2-specific components from .sam3_client import Sam3Client from .sso_client import SsoClient @@ -28,18 +33,6 @@ from .taa_client import TaaClient from .token_flow_manager import TokenFlowManager from .token_utils import JWTParser, PersonaTokenComposer -from .constants import ( - SUPPORTED_COUNTRIES, - DEFAULT_COUNTRY, - DEFAULT_PLATFORM, - MAGENTA2_CLIENT_IDS, - MAGENTA2_PLATFORMS, - IDM, - APPVERSION2, - TAA_REQUEST_TEMPLATE, - SSO_USER_AGENT -) - @dataclass class Magenta2Credentials(ClientCredentials): @@ -47,18 +40,21 @@ class Magenta2Credentials(ClientCredentials): Magenta2-specific credentials for client credentials flow (TAA auth) Note: Magenta2 uses public client OAuth flow - no client_secret required """ + platform: str = DEFAULT_PLATFORM country: str = DEFAULT_COUNTRY device_id: Optional[str] = field(default=None) def __post_init__(self): # Magenta2 doesn't use client_secret (public client flow) - if not hasattr(self, 'client_secret') or self.client_secret is None: + if not hasattr(self, "client_secret") or self.client_secret is None: self.client_secret = "" # Empty string for public client # Set client_id from constant if not provided if not self.client_id: - self.client_id = MAGENTA2_CLIENT_IDS.get(self.platform, MAGENTA2_CLIENT_IDS[DEFAULT_PLATFORM]) + self.client_id = MAGENTA2_CLIENT_IDS.get( + self.platform, MAGENTA2_CLIENT_IDS[DEFAULT_PLATFORM] + ) # Generate device ID if not provided if not self.device_id: @@ -72,45 +68,52 @@ class Magenta2Credentials(ClientCredentials): return False return True - def to_taa_payload(self, access_token: str, client_model: Optional[str] = None, - device_model: Optional[str] = None) -> Dict[str, Any]: + def to_taa_payload( + self, + access_token: str, + client_model: Optional[str] = None, + device_model: Optional[str] = None, + ) -> Dict[str, Any]: """Convert to TAA authentication payload""" - platform_config = MAGENTA2_PLATFORMS.get(self.platform, MAGENTA2_PLATFORMS[DEFAULT_PLATFORM]) + platform_config = MAGENTA2_PLATFORMS.get( + self.platform, MAGENTA2_PLATFORMS[DEFAULT_PLATFORM] + ) # Use provided models or fallback to platform defaults - resolved_device_model = device_model or platform_config['device_name'] + resolved_device_model = device_model or platform_config["device_name"] resolved_client_model = client_model or f"ftv-{self.platform}" # Build keyValue string with client model if available - key_value_parts = [ - IDM, - APPVERSION2 - ] + key_value_parts = [IDM, APPVERSION2] # Add client model if available if resolved_client_model: key_value_parts.append(f"ClientModelParams(id={resolved_client_model})") - key_value_parts.extend([ - f"TokenChannelParams(id=Tv)", - f"TokenDeviceParams(id={self.device_id}, model={resolved_device_model}, os={platform_config['firmware']})", - "DE", - "telekom" - ]) + key_value_parts.extend( + [ + f"TokenChannelParams(id=Tv)", + f"TokenDeviceParams(id={self.device_id}, model={resolved_device_model}, os={platform_config['firmware']})", + "DE", + "telekom", + ] + ) key_value = "/".join(key_value_parts) # Start with template and populate fields payload = TAA_REQUEST_TEMPLATE.copy() - payload.update({ - "keyValue": key_value, - "accessToken": access_token, - "device": { - "id": self.device_id, - "model": resolved_device_model, - "os": platform_config['firmware'] + payload.update( + { + "keyValue": key_value, + "accessToken": access_token, + "device": { + "id": self.device_id, + "model": resolved_device_model, + "os": platform_config["firmware"], + }, } - }) + ) # Add client model if available if resolved_client_model: @@ -129,6 +132,7 @@ class Magenta2UserCredentials(Magenta2Credentials): Magenta2 user credentials for complete authentication flow Adds username/password support for SAM3 login """ + username: str = "" password: str = "" @@ -138,7 +142,11 @@ 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: @@ -150,6 +158,7 @@ class Magenta2AuthToken(BaseAuthToken): """ Magenta2-specific authentication token with TAA data and persona token composition """ + refresh_token: Optional[str] = field(default="") dc_cts_persona_token: Optional[str] = field(default=None) persona_id: Optional[str] = field(default=None) @@ -166,44 +175,43 @@ class Magenta2AuthToken(BaseAuthToken): def to_dict(self) -> Dict[str, Any]: """Convert token to dictionary""" base_dict = { - 'access_token': self.access_token, - 'refresh_token': self.refresh_token or "", - 'token_type': self.token_type, - 'expires_in': self.expires_in, - 'issued_at': self.issued_at + "access_token": self.access_token, + "refresh_token": self.refresh_token or "", + "token_type": self.token_type, + "expires_in": self.expires_in, + "issued_at": self.issued_at, } # Add Magenta2-specific fields if self.dc_cts_persona_token: - base_dict['dc_cts_persona_token'] = self.dc_cts_persona_token + base_dict["dc_cts_persona_token"] = self.dc_cts_persona_token if self.persona_id: - base_dict['persona_id'] = self.persona_id + base_dict["persona_id"] = self.persona_id if self.account_id: - base_dict['account_id'] = self.account_id + base_dict["account_id"] = self.account_id if self.consumer_id: - base_dict['consumer_id'] = self.consumer_id + base_dict["consumer_id"] = self.consumer_id if self.tv_account_id: - base_dict['tv_account_id'] = self.tv_account_id + base_dict["tv_account_id"] = self.tv_account_id if self.account_token: - base_dict['account_token'] = self.account_token + base_dict["account_token"] = self.account_token if self.account_uri: - base_dict['account_uri'] = self.account_uri + base_dict["account_uri"] = self.account_uri if self.composed_persona_token: - base_dict['composed_persona_token'] = self.composed_persona_token + base_dict["composed_persona_token"] = self.composed_persona_token if self.token_exp: - base_dict['token_exp'] = self.token_exp + base_dict["token_exp"] = self.token_exp if self.sso_user_id: - base_dict['sso_user_id'] = self.sso_user_id + base_dict["sso_user_id"] = self.sso_user_id if self.sso_display_name: - base_dict['sso_display_name'] = self.sso_display_name + base_dict["sso_display_name"] = self.sso_display_name return base_dict def compose_persona_token(self) -> Optional[str]: """Compose persona token from components""" self.composed_persona_token = PersonaTokenComposer.compose_from_components( - account_uri=self.account_uri, - dc_cts_persona_token=self.dc_cts_persona_token + account_uri=self.account_uri, dc_cts_persona_token=self.dc_cts_persona_token ) return self.composed_persona_token @@ -216,14 +224,20 @@ class Magenta2AuthToken(BaseAuthToken): class Magenta2AuthConfig: """Configuration object for Magenta2 authentication""" - def __init__(self, country: str, platform: str = DEFAULT_PLATFORM, - endpoints: Optional[Dict[str, str]] = None, - client_model: Optional[str] = None, - device_model: Optional[str] = None): + def __init__( + self, + country: str, + platform: str = DEFAULT_PLATFORM, + endpoints: Optional[Dict[str, str]] = None, + client_model: Optional[str] = None, + device_model: Optional[str] = None, + ): self.country = country self.platform = platform - self.platform_config = MAGENTA2_PLATFORMS.get(platform, MAGENTA2_PLATFORMS[DEFAULT_PLATFORM]) - self.user_agent = self.platform_config['user_agent'] + self.platform_config = MAGENTA2_PLATFORMS.get( + platform, MAGENTA2_PLATFORMS[DEFAULT_PLATFORM] + ) + self.user_agent = self.platform_config["user_agent"] self.timeout = 30 self.endpoints = endpoints or {} self.client_model = client_model @@ -232,38 +246,40 @@ class Magenta2AuthConfig: def get_base_headers(self) -> Dict[str, str]: """Get base headers for all requests""" return { - 'User-Agent': self.user_agent, - 'Accept': 'application/json', - 'Content-Type': 'application/json' + "User-Agent": self.user_agent, + "Accept": "application/json", + "Content-Type": "application/json", } def get_oauth_headers(self) -> Dict[str, str]: """Get headers for OAuth2 requests (use form encoding)""" headers = self.get_base_headers() - headers['Content-Type'] = 'application/x-www-form-urlencoded' + headers["Content-Type"] = "application/x-www-form-urlencoded" 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, - 'Content-Type': 'application/json', - 'origin': 'https://web2.magentatv.de', - 'referer': 'https://web2.magentatv.de/' + "User-Agent": SSO_USER_AGENT, + "Content-Type": "application/json", + "origin": "https://web2.magentatv.de", + "referer": "https://web2.magentatv.de/", } if session_id: - headers['session-id'] = session_id + headers["session-id"] = session_id if device_id: - headers['device-id'] = device_id + headers["device-id"] = device_id return headers def get_taa_headers(self, sam3_token: str) -> Dict[str, str]: """Get headers for TAA requests""" headers = self.get_base_headers() - headers['Authorization'] = f'Bearer {sam3_token}' + headers["Authorization"] = f"Bearer {sam3_token}" return headers @@ -283,19 +299,22 @@ class Magenta2Authenticator(BaseAuthenticator): - Token Exchange: refresh_token → taa → yo_digital """ - def __init__(self, country: str = DEFAULT_COUNTRY, - platform: str = DEFAULT_PLATFORM, - settings_manager=None, - credentials=None, - config_dir: Optional[str] = None, - http_manager=None, - endpoints: Optional[Dict[str, str]] = None, - client_model: Optional[str] = None, - device_model: Optional[str] = None, - sam3_client_id: Optional[str] = None, - session_id: Optional[str] = None, - device_id: Optional[str] = None, - provider_config: Optional[Any] = None): + def __init__( + self, + country: str = DEFAULT_COUNTRY, + platform: str = DEFAULT_PLATFORM, + settings_manager=None, + credentials=None, + config_dir: Optional[str] = None, + http_manager=None, + endpoints: Optional[Dict[str, str]] = None, + client_model: Optional[str] = None, + device_model: Optional[str] = None, + sam3_client_id: Optional[str] = None, + session_id: Optional[str] = None, + device_id: Optional[str] = None, + provider_config: Optional[Any] = None, + ): """ Initialize Magenta2 authenticator @@ -315,7 +334,9 @@ class Magenta2Authenticator(BaseAuthenticator): provider_config: Provider configuration object """ if country not in SUPPORTED_COUNTRIES: - raise ValueError(f"Unsupported country: {country}. Must be one of: {SUPPORTED_COUNTRIES}") + raise ValueError( + f"Unsupported country: {country}. Must be one of: {SUPPORTED_COUNTRIES}" + ) if http_manager is None: raise ValueError("http_manager is required for Magenta2Authenticator") @@ -356,13 +377,12 @@ class Magenta2Authenticator(BaseAuthenticator): self.platform, self._dynamic_endpoints, self._client_model, - self._device_model + self._device_model, ) # Extract and cache client_id self._client_id = self._sam3_client_id or MAGENTA2_CLIENT_IDS.get( - self.platform, - MAGENTA2_CLIENT_IDS[DEFAULT_PLATFORM] + self.platform, MAGENTA2_CLIENT_IDS[DEFAULT_PLATFORM] ) # Initialize credentials if not provided @@ -378,12 +398,12 @@ class Magenta2Authenticator(BaseAuthenticator): # Initialize parent BaseAuthenticator super().__init__( - provider_name='magenta2', + provider_name="magenta2", settings_manager=settings_manager, credentials=credentials, country=country, config_dir=config_dir, - enable_kodi_integration=True + enable_kodi_integration=True, ) # Initialize TokenFlowManager @@ -399,7 +419,7 @@ class Magenta2Authenticator(BaseAuthenticator): @property def auth_endpoint(self) -> str: """Primary authentication endpoint - TAA flow""" - return self._get_endpoint('taa_auth', 'TAA_AUTH') + return self._get_endpoint("taa_auth", "TAA_AUTH") def _get_auth_headers(self) -> Dict[str, str]: """Get headers for authentication request""" @@ -411,36 +431,46 @@ 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'), - account_id=response_data.get('account_id'), - consumer_id=response_data.get('consumer_id'), - tv_account_id=response_data.get('tv_account_id'), - account_token=response_data.get('account_token'), - account_uri=response_data.get('account_uri'), - token_exp=response_data.get('token_exp'), - + dc_cts_persona_token=response_data.get("dc_cts_persona_token"), + persona_id=response_data.get("persona_id"), + account_id=response_data.get("account_id"), + consumer_id=response_data.get("consumer_id"), + tv_account_id=response_data.get("tv_account_id"), + account_token=response_data.get("account_token"), + account_uri=response_data.get("account_uri"), + token_exp=response_data.get("token_exp"), # SSO fields if available - sso_user_id=response_data.get('sso_user_id'), - sso_display_name=response_data.get('sso_display_name') + sso_user_id=response_data.get("sso_user_id"), + sso_display_name=response_data.get("sso_display_name"), ) # Compose final persona token @@ -459,14 +489,18 @@ class Magenta2Authenticator(BaseAuthenticator): # Try to construct account_uri from MPX account PID if available if token.dc_cts_persona_token and self._mpx_account_pid: - logger.info(f"Attempting to construct account_uri from MPX account PID: {self._mpx_account_pid}") + logger.info( + f"Attempting to construct account_uri from MPX account PID: {self._mpx_account_pid}" + ) 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': + if not response_data.get("auth_source") == "line_auth": token.auth_level = self._classify_token(token) logger.debug(f"Token created and classified as: {token.auth_level.value}") else: @@ -484,7 +518,7 @@ class Magenta2Authenticator(BaseAuthenticator): client_id=self._client_id, platform=self.platform, country=self.country, - device_id=self._device_id + device_id=self._device_id, ) def _perform_authentication(self) -> BaseAuthToken: @@ -507,10 +541,10 @@ class Magenta2Authenticator(BaseAuthenticator): # The actual tokens are managed by TokenFlowManager in scoped storage token = Magenta2AuthToken( access_token=persona_result.persona_token, - token_type='Basic', + token_type="Basic", expires_in=3600, # Default expiry issued_at=time.time(), - auth_level=TokenAuthLevel.USER_AUTHENTICATED + auth_level=TokenAuthLevel.USER_AUTHENTICATED, ) logger.info("✓ Magenta2 authentication successful") @@ -524,33 +558,54 @@ 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())}") # Check for persona token presence - indicates user authentication - if hasattr(token, 'dc_cts_persona_token') and token.dc_cts_persona_token: - logger.debug("Token classified as USER_AUTHENTICATED (dc_cts_persona_token present)") + if hasattr(token, "dc_cts_persona_token") and token.dc_cts_persona_token: + logger.debug( + "Token classified as USER_AUTHENTICATED (dc_cts_persona_token present)" + ) return TokenAuthLevel.USER_AUTHENTICATED # Check JWT claims for user identifiers - user_claim_keys = ['dc_cts_personaId', 'personaId', 'dc_cts_accountId', 'accountId', - 'dc_cts_consumerId', 'consumerId', 'dc_tvAccountId', 'tvAccountId'] + user_claim_keys = [ + "dc_cts_personaId", + "personaId", + "dc_cts_accountId", + "accountId", + "dc_cts_consumerId", + "consumerId", + "dc_tvAccountId", + "tvAccountId", + ] 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 - client_id = claims.get('client_id', claims.get('clientId', '')) + client_id = claims.get("client_id", claims.get("clientId", "")) if client_id in MAGENTA2_CLIENT_IDS.values(): logger.debug("Token classified as CLIENT_CREDENTIALS (known client ID)") return TokenAuthLevel.CLIENT_CREDENTIALS @@ -576,15 +631,17 @@ 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( access_token=persona_result.persona_token, - token_type='Basic', + token_type="Basic", expires_in=3600, issued_at=time.time(), - auth_level=TokenAuthLevel.USER_AUTHENTICATED + auth_level=TokenAuthLevel.USER_AUTHENTICATED, ) logger.info("✓ Token refresh successful") return token @@ -607,7 +664,7 @@ class Magenta2Authenticator(BaseAuthenticator): self._sso_client = SsoClient( http_manager=self._http_manager, session_id=self._session_id, - device_id=self._device_id + device_id=self._device_id, ) # Initialize SAM3 client if we have client ID @@ -619,14 +676,18 @@ class Magenta2Authenticator(BaseAuthenticator): qr_code_url_template = None # 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}") + 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}" + ) 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') + 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" + ) self._sam3_client = Sam3Client( http_manager=self._http_manager, @@ -637,7 +698,7 @@ class Magenta2Authenticator(BaseAuthenticator): oauth_token_endpoint=oauth_endpoint, line_auth_endpoint=line_auth_endpoint, backchannel_start_url=backchannel_start_url, - qr_code_url_template=qr_code_url_template + qr_code_url_template=qr_code_url_template, ) logger.info( @@ -655,17 +716,18 @@ class Magenta2Authenticator(BaseAuthenticator): def _initialize_taa_client(self) -> None: """Initialize TAA client""" self._taa_client = TaaClient( - http_manager=self._http_manager, - platform=self.platform + http_manager=self._http_manager, platform=self.platform ) logger.debug("TAA client initialized") def _initialize_token_flow_manager(self) -> None: """Initialize token flow manager after SAM3 and TAA clients are ready""" if self._sam3_client and self._taa_client: - session_manager = getattr(self.settings_manager, 'session_manager', None) + 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( @@ -676,7 +738,7 @@ class Magenta2Authenticator(BaseAuthenticator): country=self.country, provider_config=self.provider_config, line_auth_callback=self._perform_line_auth_with_response, - remote_login_callback=self._perform_remote_login_flow + remote_login_callback=self._perform_remote_login_flow, ) logger.debug("TokenFlowManager initialized with auth callbacks") @@ -746,9 +808,13 @@ 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""" @@ -780,7 +846,9 @@ 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 @@ -795,7 +863,9 @@ 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") @@ -811,7 +881,9 @@ class Magenta2Authenticator(BaseAuthenticator): self._sam3_client.update_endpoints(openid_config) logger.debug("OpenID configuration updated") - def set_remote_login_urls(self, qr_code_url_template: str, backchannel_start_url: str = None) -> None: + def set_remote_login_urls( + self, qr_code_url_template: str, backchannel_start_url: str = None + ) -> None: """ Set remote login URLs for backchannel authentication @@ -852,16 +924,15 @@ class Magenta2Authenticator(BaseAuthenticator): def can_use_line_auth(self) -> bool: """Check if line auth components are available""" return ( - self._device_token is not None and - self._authorize_tokens_url is not None and - self._sam3_client is not None + self._device_token is not None + and self._authorize_tokens_url is not None + and self._sam3_client is not None ) 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() + self._sam3_client is not None and self._sam3_client.can_use_remote_login() ) def get_authentication_capabilities(self) -> Dict[str, Any]: @@ -870,36 +941,59 @@ class Magenta2Authenticator(BaseAuthenticator): remote_login_available = self.can_use_remote_login() return { - 'line_auth_available': line_auth_available, - 'remote_login_available': remote_login_available, - 'user_credentials_available': isinstance(self.credentials, - Magenta2UserCredentials) and self.credentials.has_user_credentials(), - 'client_credentials_available': True, - 'preferred_flow': 'LINE_AUTH' if line_auth_available else - 'REMOTE_LOGIN' if remote_login_available else - 'USER' if (isinstance(self.credentials, - Magenta2UserCredentials) and self.credentials.has_user_credentials()) else - 'CLIENT' + "line_auth_available": line_auth_available, + "remote_login_available": remote_login_available, + "user_credentials_available": isinstance( + self.credentials, Magenta2UserCredentials + ) + and self.credentials.has_user_credentials(), + "client_credentials_available": True, + "preferred_flow": ( + "LINE_AUTH" + if line_auth_available + else ( + "REMOTE_LOGIN" + if remote_login_available + else ( + "USER" + if ( + isinstance(self.credentials, Magenta2UserCredentials) + and self.credentials.has_user_credentials() + ) + else "CLIENT" + ) + ) + ), } def get_authentication_flow_info(self) -> Dict[str, Any]: """Get authentication flow information""" base_info = { - '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, - 'sso_client_available': self._sso_client is not None, - 'taa_client_available': self._taa_client is not None, - 'device_token_available': bool(self._device_token), - 'mpx_account_pid_available': bool(self._mpx_account_pid), - 'preferred_flow': 'USER' if (isinstance(self.credentials, - Magenta2UserCredentials) and self.credentials.has_user_credentials()) else 'CLIENT' + "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, + "sso_client_available": self._sso_client is not None, + "taa_client_available": self._taa_client is not None, + "device_token_available": bool(self._device_token), + "mpx_account_pid_available": bool(self._mpx_account_pid), + "preferred_flow": ( + "USER" + if ( + isinstance(self.credentials, Magenta2UserCredentials) + and self.credentials.has_user_credentials() + ) + else "CLIENT" + ), } # 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 @@ -927,7 +1021,9 @@ 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: @@ -971,10 +1067,12 @@ class Magenta2Authenticator(BaseAuthenticator): response_data = self._sam3_client.get_last_line_auth_response() # Store refresh token for SAM3 token requests - if response_data and 'refresh_token' in response_data: - self._line_auth_refresh_token = response_data['refresh_token'] + 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 @@ -1028,13 +1126,13 @@ class Magenta2Authenticator(BaseAuthenticator): Debug information dictionary """ if not self._taa_client: - return {'error': 'TAA client not initialized'} + return {"error": "TAA client not initialized"} return self._taa_client.debug_taa_token(taa_token) def get_sam3_client_status(self) -> Dict[str, Any]: """Get SAM3 client status for debugging""" if not self._sam3_client: - return {'initialized': False} + return {"initialized": False} return self._sam3_client.get_client_status() @@ -1046,59 +1144,81 @@ class Magenta2Authenticator(BaseAuthenticator): Comprehensive authentication state information """ if not self._current_token: - return {'error': 'No current token'} + return {"error": "No current token"} token = self._current_token return { - 'has_access_token': bool(token.access_token), - '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)), - 'persona_token_preview': getattr(token, 'composed_persona_token', '')[:50] + '...' if getattr(token, - 'composed_persona_token', - None) else None, - 'account_uri': getattr(token, 'account_uri', None), - 'persona_id': getattr(token, 'persona_id', None), - 'account_id': getattr(token, 'account_id', None), - 'sso_user_id': getattr(token, 'sso_user_id', None), - 'sso_display_name': getattr(token, 'sso_display_name', None), - 'token_expires_at': getattr(token, 'token_exp', None), - 'is_expired': token.is_expired, - 'auth_level': token.auth_level.value, - 'flow_used': 'USER' if getattr(token, 'sso_user_id', None) else 'CLIENT' + "has_access_token": bool(token.access_token), + "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) + ), + "persona_token_preview": ( + getattr(token, "composed_persona_token", "")[:50] + "..." + if getattr(token, "composed_persona_token", None) + else None + ), + "account_uri": getattr(token, "account_uri", None), + "persona_id": getattr(token, "persona_id", None), + "account_id": getattr(token, "account_id", None), + "sso_user_id": getattr(token, "sso_user_id", None), + "sso_display_name": getattr(token, "sso_display_name", None), + "token_expires_at": getattr(token, "token_exp", None), + "is_expired": token.is_expired, + "auth_level": token.auth_level.value, + "flow_used": "USER" if getattr(token, "sso_user_id", None) else "CLIENT", } def debug_token_classification(self) -> Dict[str, Any]: """Debug method to analyze current token classification""" if not self._current_token: - return {'error': 'No current token'} + return {"error": "No current token"} - claims = self._current_token.get_jwt_claims() if hasattr(self._current_token, 'get_jwt_claims') else {} + claims = ( + self._current_token.get_jwt_claims() + if hasattr(self._current_token, "get_jwt_claims") + else {} + ) return { - 'token_type': type(self._current_token).__name__, - '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)), - 'jwt_claims_available': bool(claims), - 'key_claims': { - 'client_id': claims.get('client_id', claims.get('clientId', 'MISSING')), - 'persona_id': claims.get('dc_cts_personaId', claims.get('personaId', 'MISSING')), - 'account_id': claims.get('dc_cts_accountId', claims.get('accountId', 'MISSING')), - } if claims else {}, - 'discovered_endpoints': list(self._dynamic_endpoints.keys()), - 'bootstrap_parameters': { - 'client_model': self._client_model, - 'device_model': self._device_model, - 'sam3_client_id': self._sam3_client_id + "token_type": type(self._current_token).__name__, + "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) + ), + "jwt_claims_available": bool(claims), + "key_claims": ( + { + "client_id": claims.get( + "client_id", claims.get("clientId", "MISSING") + ), + "persona_id": claims.get( + "dc_cts_personaId", claims.get("personaId", "MISSING") + ), + "account_id": claims.get( + "dc_cts_accountId", claims.get("accountId", "MISSING") + ), + } + if claims + else {} + ), + "discovered_endpoints": list(self._dynamic_endpoints.keys()), + "bootstrap_parameters": { + "client_model": self._client_model, + "device_model": self._device_model, + "sam3_client_id": self._sam3_client_id, + }, + "clients_initialized": { + "sam3": self._sam3_client is not None, + "sso": self._sso_client is not None, + "taa": self._taa_client is not None, }, - 'clients_initialized': { - 'sam3': self._sam3_client is not None, - 'sso': self._sso_client is not None, - 'taa': self._taa_client is not None - } } # ======================================================================== @@ -1128,6 +1248,7 @@ class Magenta2Authenticator(BaseAuthenticator): # Fall back to hardcoded if available if fallback_key: from .constants import MAGENTA2_FALLBACK_ENDPOINTS + if fallback_key in MAGENTA2_FALLBACK_ENDPOINTS: url = MAGENTA2_FALLBACK_ENDPOINTS[fallback_key] logger.debug(f"Using fallback endpoint for {endpoint_key}: {url}") @@ -1145,41 +1266,38 @@ class Magenta2Authenticator(BaseAuthenticator): """ # Save ONLY the access token data under 'tvhubs' scope scoped_token_data = { - 'access_token': token.access_token, - 'token_type': token.token_type, - 'expires_in': token.expires_in, - 'issued_at': token.issued_at + "access_token": token.access_token, + "token_type": token.token_type, + "expires_in": token.expires_in, + "issued_at": token.issued_at, } # Save scoped token (access_token under 'tvhubs' scope) self.settings_manager.save_scoped_token( - self.provider_name, - 'tvhubs', - scoped_token_data, - self.country + self.provider_name, "tvhubs", scoped_token_data, self.country ) # Save provider session data without access_token and without persona fields # Only keep refresh_token and device_id provider_session_data = { - 'refresh_token': token.refresh_token, - 'device_id': self._device_id + "refresh_token": token.refresh_token, + "device_id": self._device_id, } # Save provider session data self.settings_manager.session_manager.save_session( - self.provider_name, - provider_session_data, - self.country + self.provider_name, provider_session_data, self.country ) logger.info( - "✓ Access token saved under 'tvhubs' scope, only refresh_token and device_id saved at provider level") + "✓ Access token saved under 'tvhubs' scope, only refresh_token and device_id saved at provider level" + ) @staticmethod def _url_encode(value: str) -> str: """URL encode a string""" from urllib.parse import quote + return quote(value) # ======================================================================== @@ -1200,4 +1318,4 @@ class Magenta2Authenticator(BaseAuthenticator): try: self.settings_manager.clear_token(self.provider_name, self.country) except Exception: - pass \ No newline at end of file + pass diff --git a/lib/streaming_providers/providers/magenta2/concurrency.py b/lib/streaming_providers/providers/magenta2/concurrency.py index a50b9a6..46ca54f 100644 --- a/lib/streaming_providers/providers/magenta2/concurrency.py +++ b/lib/streaming_providers/providers/magenta2/concurrency.py @@ -1,11 +1,13 @@ # streaming_providers/providers/magenta2/concurrency.py import urllib.parse + from ...base.network import HTTPManager from ...base.utils.logger import logger -def extract_and_release_lock(smil_content: str, http_manager: HTTPManager, - client_id: str, user_agent: str) -> bool: +def extract_and_release_lock( + smil_content: str, http_manager: HTTPManager, client_id: str, user_agent: str +) -> bool: """ Extract concurrency lock from SMIL and immediately release it Returns True if lock was found and released, False otherwise @@ -23,76 +25,93 @@ def extract_and_release_lock(smil_content: str, http_manager: HTTPManager, root = ET.fromstring(smil_content) # Extract head metadata - head = root.find('{http://www.w3.org/2005/SMIL21/Language}head') + head = root.find("{http://www.w3.org/2005/SMIL21/Language}head") if head is None: return False # Extract lock parameters lock_params = {} - for meta in head.findall('{http://www.w3.org/2005/SMIL21/Language}meta'): - name = meta.get('name') - content = meta.get('content') + for meta in head.findall("{http://www.w3.org/2005/SMIL21/Language}meta"): + name = meta.get("name") + content = meta.get("content") if name and content: lock_params[name] = content # Check if we have all required lock parameters - required_params = ['concurrencyInstance', 'concurrencyServiceUrl', 'lockId', 'lockSequenceToken', 'lock'] + required_params = [ + "concurrencyInstance", + "concurrencyServiceUrl", + "lockId", + "lockSequenceToken", + "lock", + ] if not all(param in lock_params for param in required_params): logger.debug("SMIL doesn't contain complete concurrency 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 = { - 'schema': '1.0', - 'form': 'json', - '_clientId': formatted_client_id, # Use player_{smil_client_id} - '_id': lock_params['lockId'], - '_sequenceToken': urllib.parse.quote(lock_params['lockSequenceToken']), - '_encryptedLock': urllib.parse.quote(lock_params['lock']) + "schema": "1.0", + "form": "json", + "_clientId": formatted_client_id, # Use player_{smil_client_id} + "_id": lock_params["lockId"], + "_sequenceToken": urllib.parse.quote(lock_params["lockSequenceToken"]), + "_encryptedLock": urllib.parse.quote(lock_params["lock"]), } - param_string = '&'.join([f"{k}={v}" for k, v in params.items()]) + param_string = "&".join([f"{k}={v}" for k, v in params.items()]) release_url = f"{base_url}?{param_string}" - logger.debug(f"Releasing concurrency lock: {lock_params['lockId']} with client: {formatted_client_id}") + logger.debug( + f"Releasing concurrency lock: {lock_params['lockId']} with client: {formatted_client_id}" + ) headers = { - 'User-Agent': user_agent, # Use platform-specific user agent - 'Accept': 'application/json' + "User-Agent": user_agent, # Use platform-specific user agent + "Accept": "application/json", } # Release the lock immediately # Release the lock immediately response = http_manager.get( - release_url, - operation='concurrency_unlock', - headers=headers, - timeout=10 + release_url, operation="concurrency_unlock", headers=headers, timeout=10 ) # Log the response for debugging - logger.debug(f"Concurrency unlock response: Status={response.status_code}, Content={response.text}") + logger.debug( + f"Concurrency unlock response: Status={response.status_code}, Content={response.text}" + ) if response.status_code == 200: try: # Parse JSON response to verify it contains unlockResponse response_data = response.json() - if 'unlockResponse' in response_data: - logger.info(f"✓ Concurrency lock released successfully with client: {formatted_client_id}") + if "unlockResponse" in response_data: + logger.info( + f"✓ Concurrency lock released successfully with client: {formatted_client_id}" + ) return True else: - logger.warning(f"Concurrency lock release failed - missing unlockResponse: {response_data}") + logger.warning( + f"Concurrency lock release failed - missing unlockResponse: {response_data}" + ) return False except Exception as e: - logger.warning(f"Concurrency lock release - invalid JSON response: {e}, Content: {response.text}") + logger.warning( + f"Concurrency lock release - invalid JSON response: {e}, Content: {response.text}" + ) 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: logger.warning(f"Error releasing concurrency lock: {e}") - return False \ No newline at end of file + return False diff --git a/lib/streaming_providers/providers/magenta2/config_models.py b/lib/streaming_providers/providers/magenta2/config_models.py index a87b026..000a97d 100644 --- a/lib/streaming_providers/providers/magenta2/config_models.py +++ b/lib/streaming_providers/providers/magenta2/config_models.py @@ -1,12 +1,13 @@ # streaming_providers/providers/magenta2/config_models.py from dataclasses import dataclass, field -from typing import Dict, Optional, Any from datetime import datetime +from typing import Any, Dict, Optional @dataclass class BootstrapConfig: """Configuration extracted from bootstrap response""" + client_model: str device_model: str sam3_client_id: Optional[str] = None @@ -21,60 +22,64 @@ 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', {}) + base_settings = bootstrap_data.get("baseSettings", {}) return cls( - client_model=base_settings.get('clientModel', f"ftv-{platform}"), - device_model=base_settings.get('deviceModel', f"{platform.upper()}_FTV"), - sam3_client_id=base_settings.get('sam3ClientId'), - taa_url=base_settings.get('taaUrl'), - device_tokens_url=base_settings.get('deviceTokensUrl'), - line_auth_url=base_settings.get('lineAuthUrl'), - remote_login_url=base_settings.get('remoteLoginUrl'), - openid_config_url=base_settings.get('sam3Url'), - account_base_url=base_settings.get('accountBaseUrl'), - consumer_accounts_url=base_settings.get('consumerAccountsBaseUrl'), - login_qr_code_url=base_settings.get('loginQrCodeUrl'), - raw_data=bootstrap_data + client_model=base_settings.get("clientModel", f"ftv-{platform}"), + device_model=base_settings.get("deviceModel", f"{platform.upper()}_FTV"), + sam3_client_id=base_settings.get("sam3ClientId"), + taa_url=base_settings.get("taaUrl"), + device_tokens_url=base_settings.get("deviceTokensUrl"), + line_auth_url=base_settings.get("lineAuthUrl"), + remote_login_url=base_settings.get("remoteLoginUrl"), + openid_config_url=base_settings.get("sam3Url"), + account_base_url=base_settings.get("accountBaseUrl"), + consumer_accounts_url=base_settings.get("consumerAccountsBaseUrl"), + login_qr_code_url=base_settings.get("loginQrCodeUrl"), + raw_data=bootstrap_data, ) - def update_from_manifest(self, manifest_config: 'ManifestConfig') -> None: + def update_from_manifest(self, manifest_config: "ManifestConfig") -> None: """Update bootstrap config with data from manifest""" from ...base.utils.logger import logger # Get SAM3 client ID from manifest if not in bootstrap if not self.sam3_client_id: - sam3_client_id = manifest_config.get_parameter_value('SAM3ClientId') + sam3_client_id = manifest_config.get_parameter_value("SAM3ClientId") if sam3_client_id: self.sam3_client_id = sam3_client_id logger.info(f"✓ SAM3 Client ID from manifest: {sam3_client_id}") # Get TAA URL from manifest if not in bootstrap if not self.taa_url: - taa_url = manifest_config.get_parameter_value('TAA-URL') + taa_url = manifest_config.get_parameter_value("TAA-URL") if taa_url: self.taa_url = taa_url logger.debug(f"TAA URL from manifest: {taa_url}") # Get Line Auth URL from manifest if not in bootstrap if not self.line_auth_url: - line_auth_url = manifest_config.get_parameter_value('LineAuthURL') + line_auth_url = manifest_config.get_parameter_value("LineAuthURL") if line_auth_url: self.line_auth_url = line_auth_url logger.debug(f"Line Auth URL from manifest: {line_auth_url}") # Get Remote Login URL from manifest if not in bootstrap if not self.remote_login_url: - remote_login_url = manifest_config.get_parameter_value('RemoteLoginURL') + remote_login_url = manifest_config.get_parameter_value("RemoteLoginURL") if remote_login_url: self.remote_login_url = remote_login_url logger.debug(f"Remote Login URL from manifest: {remote_login_url}") + @dataclass class MpxConfig: """MPX (ThePlatform) configuration from manifest""" + account_pid: str license_service_url: str selector_service_url: str @@ -88,53 +93,64 @@ class MpxConfig: mpx_basic_url_selector_service: Optional[str] = None # MPD manifest endpoint @classmethod - def from_manifest_data(cls, manifest_data: Dict[str, Any]) -> 'MpxConfig': + def from_manifest_data(cls, manifest_data: Dict[str, Any]) -> "MpxConfig": """Create MpxConfig from manifest data""" + def get_param(key: str) -> Optional[str]: """Helper to get value from parameters array""" - if 'settings' not in manifest_data: + if "settings" not in manifest_data: return None - settings = manifest_data['settings'] - if 'parameters' not in settings: + settings = manifest_data["settings"] + if "parameters" not in settings: return None - for param in settings['parameters']: - if param.get('key') == key: - value = param.get('value') - return value if value and value != 'unused' else None + for param in settings["parameters"]: + if param.get("key") == key: + value = param.get("value") + return value if value and value != "unused" else None return None - mpx_data = manifest_data.get('mpx', {}) + mpx_data = manifest_data.get("mpx", {}) # Build feeds dict from parameters feeds = {} feed_keys = [ - 'mpxBasicUrlAllChannelSchedulesFeed', - 'mpxBasicUrlEntitledChannelsFeed', - 'mpxAllListingsFeedUrl', - 'mpxAllProgramsFeedUrl' + "mpxBasicUrlAllChannelSchedulesFeed", + "mpxBasicUrlEntitledChannelsFeed", + "mpxAllListingsFeedUrl", + "mpxAllProgramsFeedUrl", ] 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') + channel_stations_feed = get_param("mpxDefaultUrlAllChannelStationsFeed") return cls( - 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'), - pvr_base_url=get_param('mpxPvrBaseUrl') or mpx_data.get('pvrBaseUrl'), + 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"), + pvr_base_url=get_param("mpxPvrBaseUrl") or mpx_data.get("pvrBaseUrl"), feeds=feeds, channel_stations_feed=channel_stations_feed, - mpx_account_uri = get_param('mpxAccountUri'), # Extract actual account URI - mpx_basic_url_selector_service = get_param('mpxBasicUrlSelectorService') # Extract MPD endpoint + mpx_account_uri=get_param("mpxAccountUri"), # Extract actual account URI + mpx_basic_url_selector_service=get_param( + "mpxBasicUrlSelectorService" + ), # Extract MPD endpoint ) def get_account_uri(self) -> str: @@ -147,130 +163,138 @@ class MpxConfig: # Fallback to constructed format return "http://access.auth.theplatform.com/data/Account/2709353023" + @dataclass class ImageConfig: """Image scaling configuration from manifest""" + scaling_base_url: Optional[str] = None scaling_call_parameter: Optional[str] = None @classmethod - def from_manifest_data(cls, manifest_data: Dict[str, Any]) -> 'ImageConfig': + def from_manifest_data(cls, manifest_data: Dict[str, Any]) -> "ImageConfig": """Create ImageConfig from manifest data""" def get_param(key: str) -> Optional[str]: """Helper to get value from parameters array""" - if 'settings' not in manifest_data: + if "settings" not in manifest_data: return None - settings = manifest_data['settings'] - if 'parameters' not in settings: + settings = manifest_data["settings"] + if "parameters" not in settings: return None - for param in settings['parameters']: - if param.get('key') == key: - value = param.get('value') - return value if value and value != 'unused' else None + for param in settings["parameters"]: + if param.get("key") == key: + value = param.get("value") + return value if value and value != "unused" else None return None return cls( - scaling_base_url=get_param('imageScalingBasicUrl'), - scaling_call_parameter=get_param('imageScalingCallParameter') + scaling_base_url=get_param("imageScalingBasicUrl"), + scaling_call_parameter=get_param("imageScalingCallParameter"), ) + @dataclass class DrmConfig: """DRM configuration from manifest""" + widevine_license_url: str vod_widevine_license_url: Optional[str] = None fairplay_license_url: Optional[str] = None @classmethod - def from_manifest_data(cls, manifest_data: Dict[str, Any]) -> 'DrmConfig': + def from_manifest_data(cls, manifest_data: Dict[str, Any]) -> "DrmConfig": """Create DrmConfig from manifest data""" from ...base.utils.logger import logger # Create a temporary helper function for this method def get_param(key: str) -> Optional[str]: """Helper to get value from parameters array""" - if 'settings' not in manifest_data: + if "settings" not in manifest_data: return None - settings = manifest_data['settings'] - if 'parameters' not in settings: + settings = manifest_data["settings"] + if "parameters" not in settings: return None - for param in settings['parameters']: - if param.get('key') == key: - value = param.get('value') - return value if value and value != 'unused' else None + for param in settings["parameters"]: + if param.get("key") == key: + value = param.get("value") + return value if value and value != "unused" else None return None # Try parameters array first (current structure) - widevine_url = get_param('widevineLicenseAcquisitionURL') - fairplay_url = get_param('fairplayLicenseAcquisitionURL') + widevine_url = get_param("widevineLicenseAcquisitionURL") + fairplay_url = get_param("fairplayLicenseAcquisitionURL") # Fallback to legacy structure if not widevine_url or not fairplay_url: - livetv_drm = manifest_data.get('livetv', {}).get('drm', {}) - vod_drm = manifest_data.get('vod', {}).get('drm', {}) + livetv_drm = manifest_data.get("livetv", {}).get("drm", {}) + vod_drm = manifest_data.get("vod", {}).get("drm", {}) if not widevine_url: - widevine_url = livetv_drm.get('widevineLicenseAcquisitionUrl', '') + widevine_url = livetv_drm.get("widevineLicenseAcquisitionUrl", "") if not fairplay_url: - fairplay_url = livetv_drm.get('fairplayLicenseAcquisitionUrl', '') + fairplay_url = livetv_drm.get("fairplayLicenseAcquisitionUrl", "") - vod_widevine = vod_drm.get('widevineLicenseAcquisitionUrl') + vod_widevine = vod_drm.get("widevineLicenseAcquisitionUrl") 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 '', + widevine_license_url=widevine_url or "", vod_widevine_license_url=vod_widevine, - fairplay_license_url=fairplay_url or '' + fairplay_license_url=fairplay_url or "", ) + @dataclass class TvHubConfig: """TV Hub URLs configuration from manifest""" + base_urls: Dict[str, str] = field(default_factory=dict) @classmethod - def from_manifest_data(cls, manifest_data: Dict[str, Any]) -> 'TvHubConfig': + def from_manifest_data(cls, manifest_data: Dict[str, Any]) -> "TvHubConfig": """Create TvHubConfig from manifest data""" # Create a temporary helper function for this method def get_param(param_key: str) -> Optional[str]: """Helper to get value from parameters array""" - if 'settings' not in manifest_data: + if "settings" not in manifest_data: return None - settings_data = manifest_data['settings'] - if 'parameters' not in settings_data: + settings_data = manifest_data["settings"] + if "parameters" not in settings_data: return None - for param in settings_data['parameters']: - if param.get('key') == param_key: - value = param.get('value') - return value if value and value != 'unused' else None + for param in settings_data["parameters"]: + if param.get("key") == param_key: + value = param.get("value") + return value if value and value != "unused" else None return None base_urls = {} # TV Hub URLs from parameters tvhub_keys = [ - 'homeUrl', - 'settingsMenu', - 'broadcastDetailsURL', - 'vodDetailsURL', - 'searchUrl', - 'kidsSearchURL', - 'myWatchlistUrl', - 'myMoviesURL' + "homeUrl", + "settingsMenu", + "broadcastDetailsURL", + "vodDetailsURL", + "searchUrl", + "kidsSearchURL", + "myWatchlistUrl", + "myMoviesURL", ] # Check settings object first - if 'settings' in manifest_data: - settings_obj = manifest_data['settings'] + if "settings" in manifest_data: + settings_obj = manifest_data["settings"] for tvhub_key in tvhub_keys: if tvhub_key in settings_obj: url = settings_obj.get(tvhub_key) - if url and url != 'unused': + if url and url != "unused": base_urls[tvhub_key] = url # Also check parameters array @@ -281,16 +305,22 @@ class TvHubConfig: base_urls[tvhub_key] = url # Legacy structure fallback - tv_hubs = manifest_data.get('tvHubUrls', {}) + tv_hubs = manifest_data.get("tvHubUrls", {}) for hub_name, hub_url in tv_hubs.items(): - if isinstance(hub_url, str) and hub_url.startswith('http') and hub_name not in base_urls: + if ( + isinstance(hub_url, str) + and hub_url.startswith("http") + and hub_name not in base_urls + ): base_urls[hub_name] = hub_url return cls(base_urls=base_urls) + @dataclass class ManifestConfig: """Complete configuration from manifest discovery""" + mpx: MpxConfig drm: DrmConfig tv_hubs: TvHubConfig @@ -300,69 +330,71 @@ class ManifestConfig: raw_data: Dict[str, Any] = field(default_factory=dict) @classmethod - def from_api_response(cls, manifest_data: Dict[str, Any]) -> 'ManifestConfig': + def from_api_response(cls, manifest_data: Dict[str, Any]) -> "ManifestConfig": """Create ManifestConfig from API response""" return cls( mpx=MpxConfig.from_manifest_data(manifest_data), drm=DrmConfig.from_manifest_data(manifest_data), tv_hubs=TvHubConfig.from_manifest_data(manifest_data), image_config=ImageConfig.from_manifest_data(manifest_data), - youbora_config=manifest_data.get('youbora', {}), - npvr_config=manifest_data.get('npvr', {}), - raw_data=manifest_data + youbora_config=manifest_data.get("youbora", {}), + npvr_config=manifest_data.get("npvr", {}), + raw_data=manifest_data, ) def get_parameter_value(self, key: str) -> Optional[str]: """Get value from settings.parameters array by key""" - if 'settings' not in self.raw_data: + if "settings" not in self.raw_data: return None - settings = self.raw_data['settings'] - if 'parameters' not in settings or not isinstance(settings['parameters'], list): + settings = self.raw_data["settings"] + if "parameters" not in settings or not isinstance(settings["parameters"], list): return None - for param in settings['parameters']: - if param.get('key') == key: - value = param.get('value') + for param in settings["parameters"]: + if param.get("key") == key: + value = param.get("value") # Empty string or "unused" means not available - return value if value and value != 'unused' else None + return value if value and value != "unused" else None return None def get_device_token(self) -> Optional[str]: """Extract device token from raw data""" # Try direct path first (legacy) - if 'deviceToken' in self.raw_data: - return self.raw_data['deviceToken'] + if "deviceToken" in self.raw_data: + return self.raw_data["deviceToken"] # Try nested in sts object (current structure) - if 'sts' in self.raw_data and isinstance(self.raw_data['sts'], dict): - if 'deviceToken' in self.raw_data['sts']: - return self.raw_data['sts']['deviceToken'] + if "sts" in self.raw_data and isinstance(self.raw_data["sts"], dict): + if "deviceToken" in self.raw_data["sts"]: + return self.raw_data["sts"]["deviceToken"] return None def get_authorize_tokens_url(self) -> Optional[str]: """Extract authorize tokens URL from raw data""" # Check in sts object first (current structure) - if 'sts' in self.raw_data and isinstance(self.raw_data['sts'], dict): - if 'authorizeTokensUrl' in self.raw_data['sts']: - return self.raw_data['sts']['authorizeTokensUrl'] + if "sts" in self.raw_data and isinstance(self.raw_data["sts"], dict): + if "authorizeTokensUrl" in self.raw_data["sts"]: + return self.raw_data["sts"]["authorizeTokensUrl"] # Check direct path (legacy) - if 'authorizeTokensUrl' in self.raw_data: - return self.raw_data['authorizeTokensUrl'] + if "authorizeTokensUrl" in self.raw_data: + return self.raw_data["authorizeTokensUrl"] # Check in settings.parameters array as fallback - line_auth_url = self.get_parameter_value('LineAuthURL') + line_auth_url = self.get_parameter_value("LineAuthURL") if line_auth_url: return line_auth_url return None + @dataclass class OpenIDConfig: """OpenID Connect configuration""" + token_endpoint: str authorization_endpoint: str userinfo_endpoint: Optional[str] = None @@ -370,20 +402,21 @@ class OpenIDConfig: raw_data: Dict[str, Any] = field(default_factory=dict) @classmethod - def from_api_response(cls, openid_data: Dict[str, Any]) -> 'OpenIDConfig': + def from_api_response(cls, openid_data: Dict[str, Any]) -> "OpenIDConfig": """Create OpenIDConfig from API response""" return cls( - token_endpoint=openid_data.get('token_endpoint', ''), - authorization_endpoint=openid_data.get('authorization_endpoint', ''), - userinfo_endpoint=openid_data.get('userinfo_endpoint'), - revocation_endpoint=openid_data.get('revocation_endpoint'), - raw_data=openid_data + token_endpoint=openid_data.get("token_endpoint", ""), + authorization_endpoint=openid_data.get("authorization_endpoint", ""), + userinfo_endpoint=openid_data.get("userinfo_endpoint"), + revocation_endpoint=openid_data.get("revocation_endpoint"), + raw_data=openid_data, ) @dataclass class ProviderConfig: """Complete provider configuration assembled from all discovery sources""" + bootstrap: BootstrapConfig manifest: Optional[ManifestConfig] = None openid: Optional[OpenIDConfig] = None @@ -403,9 +436,11 @@ class ProviderConfig: if not feed_template: return None - return feed_template.replace('{MpxAccountPid}', self.manifest.mpx.account_pid) + return feed_template.replace("{MpxAccountPid}", self.manifest.mpx.account_pid) - def get_resolved_tvhub_url(self, hub_name: str, client_model: Optional[str] = None) -> Optional[str]: + def get_resolved_tvhub_url( + self, hub_name: str, client_model: Optional[str] = None + ) -> Optional[str]: """Get resolved TV Hub URL with client model substitution""" if not self.manifest: return None @@ -415,7 +450,7 @@ class ProviderConfig: return None resolved_client_model = client_model or self.bootstrap.client_model - return hub_template.replace('{clientModel}', resolved_client_model) + return hub_template.replace("{clientModel}", resolved_client_model) def get_mpx_account_uri(self) -> Optional[str]: """NEW: Get MPX account URI for persona token composition""" @@ -433,4 +468,4 @@ class ProviderConfig: """NEW: Get authorize tokens URL from manifest""" if not self.manifest: return None - return self.manifest.get_authorize_tokens_url() \ No newline at end of file + return self.manifest.get_authorize_tokens_url() diff --git a/lib/streaming_providers/providers/magenta2/constants.py b/lib/streaming_providers/providers/magenta2/constants.py index f033e60..e11710e 100644 --- a/lib/streaming_providers/providers/magenta2/constants.py +++ b/lib/streaming_providers/providers/magenta2/constants.py @@ -4,61 +4,61 @@ # ============================================================================ # Supported countries (Magenta2 is Germany-specific) -SUPPORTED_COUNTRIES = ['de'] +SUPPORTED_COUNTRIES = ["de"] # Default country -DEFAULT_COUNTRY = 'de' +DEFAULT_COUNTRY = "de" -MAGENTA2_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/e/e4/Magenta_TV_Logo_2024.svg/2339px-Magenta_TV_Logo_2024.svg.png' +MAGENTA2_LOGO = "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e4/Magenta_TV_Logo_2024.svg/2339px-Magenta_TV_Logo_2024.svg.png" # Platform configuration -DEFAULT_PLATFORM = 'android-tv' +DEFAULT_PLATFORM = "android-tv" MAGENTA2_PLATFORMS = { - 'web': { - 'device_name': 'Web Browser', - 'firmware': 'Chrome 120', - 'user_agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - 'terminal_type': 'WEB', + "web": { + "device_name": "Web Browser", + "firmware": "Chrome 120", + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "terminal_type": "WEB", # TAA-specific device identification - 'taa_device_model': 'Web Browser', - 'taa_os': 'Chrome 120' + "taa_device_model": "Web Browser", + "taa_os": "Chrome 120", }, - 'android-tv': { - 'device_name': 'Android TV', - 'firmware': 'Android 11', - 'user_agent': 'Dalvik/2.1.0 (Linux; U; Android 11; SHIELD Android TV Build/RQ1A.210105.003) ((2.00T_ATV::3.134.4462::mdarcy::))', - 'terminal_type': 'ATV_ANDROIDTV', + "android-tv": { + "device_name": "Android TV", + "firmware": "Android 11", + "user_agent": "Dalvik/2.1.0 (Linux; U; Android 11; SHIELD Android TV Build/RQ1A.210105.003) ((2.00T_ATV::3.134.4462::mdarcy::))", + "terminal_type": "ATV_ANDROIDTV", # TAA-specific device identification (API level format required) - 'taa_device_model': 'SHIELD Android TV', - 'taa_os': 'API level 30' + "taa_device_model": "SHIELD Android TV", + "taa_os": "API level 30", }, - 'atv-launcher': { - 'device_name': 'MagentaTV Stick', - 'firmware': 'Android 11', - 'user_agent': 'Mozilla/5.0 (Linux; Android 11; AFTS Build/PPR1.180610.011) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36', - 'terminal_type': 'ATV_LAUNCHER', + "atv-launcher": { + "device_name": "MagentaTV Stick", + "firmware": "Android 11", + "user_agent": "Mozilla/5.0 (Linux; Android 11; AFTS Build/PPR1.180610.011) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", + "terminal_type": "ATV_LAUNCHER", # TAA-specific device identification - 'taa_device_model': 'MagentaTV Stick', - 'taa_os': 'API level 30' + "taa_device_model": "MagentaTV Stick", + "taa_os": "API level 30", }, - 'android-mobile': { - 'device_name': 'Android Mobile', - 'firmware': 'Android 13', - 'user_agent': 'Mozilla/5.0 (Linux; Android 13; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36', - 'terminal_type': 'ANDROID_MOBILE', + "android-mobile": { + "device_name": "Android Mobile", + "firmware": "Android 13", + "user_agent": "Mozilla/5.0 (Linux; Android 13; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36", + "terminal_type": "ANDROID_MOBILE", # TAA-specific device identification - 'taa_device_model': 'Android Mobile', - 'taa_os': 'API level 33' + "taa_device_model": "Android Mobile", + "taa_os": "API level 33", }, - 'ios': { - 'device_name': 'iPhone', - 'firmware': 'iOS 15', - 'user_agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1', - 'terminal_type': 'IOS', + "ios": { + "device_name": "iPhone", + "firmware": "iOS 15", + "user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1", + "terminal_type": "IOS", # TAA-specific device identification - 'taa_device_model': 'iPhone', - 'taa_os': 'iOS 15.0' - } + "taa_device_model": "iPhone", + "taa_os": "iOS 15.0", + }, } # ============================================================================ @@ -66,52 +66,52 @@ MAGENTA2_PLATFORMS = { # ============================================================================ # App identification for manifest requests -MAGENTA2_APP_NAME = 'MagentaTV' -MAGENTA2_APP_VERSION = '104180' -MAGENTA2_RUNTIME_VERSION = '1' +MAGENTA2_APP_NAME = "MagentaTV" +MAGENTA2_APP_VERSION = "104180" +MAGENTA2_RUNTIME_VERSION = "1" # Model names for manifest requests (different from device models!) MANIFEST_MODEL_MAPPINGS = { - 'web': 'DT:WEB', - 'android-tv': 'DT:ATV-AndroidTV', - 'atv-launcher': 'DT:ATV-Launcher', - 'android-mobile': 'DT:Android-Mobile', - 'ios': 'DT:IOS' + "web": "DT:WEB", + "android-tv": "DT:ATV-AndroidTV", + "atv-launcher": "DT:ATV-Launcher", + "android-mobile": "DT:Android-Mobile", + "ios": "DT:IOS", } # Firmware strings for manifest requests MANIFEST_FIRMWARE_MAPPINGS = { - 'web': 'Chrome 120', - 'android-tv': 'API level 30', - 'atv-launcher': 'API level 30', - 'android-mobile': 'API level 33', - 'ios': 'iOS 15.0' + "web": "Chrome 120", + "android-tv": "API level 30", + "atv-launcher": "API level 30", + "android-mobile": "API level 33", + "ios": "iOS 15.0", } # Also update the fallback mappings: CLIENT_MODEL_MAPPINGS = { - 'web': 'ftv-web', - 'android-tv': 'ftv-androidtv', - 'atv-launcher': 'ftv-androidtv', - 'android-mobile': 'ftv-android', - 'ios': 'ftv-ios' + "web": "ftv-web", + "android-tv": "ftv-androidtv", + "atv-launcher": "ftv-androidtv", + "android-mobile": "ftv-android", + "ios": "ftv-ios", } DEVICE_MODEL_MAPPINGS = { - 'web': 'WebBrowser_FTV', - 'android-tv': 'AndroidTV_FTV', - 'atv-launcher': 'AndroidTV_FTV', - 'android-mobile': 'AndroidMobile_FTV', - 'ios': 'iOS_FTV' + "web": "WebBrowser_FTV", + "android-tv": "AndroidTV_FTV", + "atv-launcher": "AndroidTV_FTV", + "android-mobile": "AndroidMobile_FTV", + "ios": "iOS_FTV", } # And update subscriber types if needed: SUBSCRIBER_TYPES = { - 'web': 'WEB_OTT_DT', - 'android-tv': 'FTV_OTT_DT', - 'atv-launcher': 'FTV_OTT_DT', # Same as android-tv - 'android-mobile': 'MOB_OTT_DT', # Different for mobile - 'ios': 'IOS_OTT_DT' + "web": "WEB_OTT_DT", + "android-tv": "FTV_OTT_DT", + "atv-launcher": "FTV_OTT_DT", # Same as android-tv + "android-mobile": "MOB_OTT_DT", # Different for mobile + "ios": "IOS_OTT_DT", } # ============================================================================ @@ -119,18 +119,20 @@ SUBSCRIBER_TYPES = { # ============================================================================ # Only bootstrap endpoint is hardcoded - everything else discovered dynamically -MAGENTA2_BASE_URL = 'https://prod.dcm.telekom-dienste.de/v1' -MAGENTA2_BOOTSTRAP_URL = MAGENTA2_BASE_URL + '/settings/{terminal_type}/bootstrap' -MAGENTA2_MANIFEST_URL = MAGENTA2_BASE_URL + '/settings/{terminal_type}/manifest' +MAGENTA2_BASE_URL = "https://prod.dcm.telekom-dienste.de/v1" +MAGENTA2_BOOTSTRAP_URL = MAGENTA2_BASE_URL + "/settings/{terminal_type}/bootstrap" +MAGENTA2_MANIFEST_URL = MAGENTA2_BASE_URL + "/settings/{terminal_type}/manifest" # Fallback endpoints if discovery fails MAGENTA2_FALLBACK_ENDPOINTS = { - 'OPENID_CONFIG': 'https://accounts.login.idm.telekom.com/.well-known/openid-configuration', - 'TAA_AUTH': 'https://taa.p7s1.io/api/v1/taa', - 'ENTITLEMENT': 'https://entitlement.p7s1.io/api/user/entitlement-token', + "OPENID_CONFIG": "https://accounts.login.idm.telekom.com/.well-known/openid-configuration", + "TAA_AUTH": "https://taa.p7s1.io/api/v1/taa", + "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 @@ -150,9 +152,9 @@ SSO_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, lik # Client IDs for different platforms MAGENTA2_CLIENT_IDS = { - 'web': '709115c2-f87e-4bad-9b94-28ac08d72cd9', - 'android-tv': '05f5f3df-1130-4707-a761-c04d0c50b7f2', - 'ios': '21218403-52ec-4a65-abf4-f36a0eadd631' + "web": "709115c2-f87e-4bad-9b94-28ac08d72cd9", + "android-tv": "05f5f3df-1130-4707-a761-c04d0c50b7f2", + "ios": "21218403-52ec-4a65-abf4-f36a0eadd631", } # OAuth2 scopes @@ -167,23 +169,15 @@ MAGENTA2_REDIRECT_URI = "https://web2.magentatv.de/authn/idm" # Headers for different API endpoints MAGENTA2_HEADERS = { - 'DEFAULT': { - 'Content-Type': 'application/json', - 'Accept': 'application/json' + "DEFAULT": {"Content-Type": "application/json", "Accept": "application/json"}, + "SSO": { + "User-Agent": SSO_USER_AGENT, + "Content-Type": "application/json", + "origin": "https://web2.magentatv.de", + "referer": "https://web2.magentatv.de/", }, - 'SSO': { - 'User-Agent': SSO_USER_AGENT, - 'Content-Type': 'application/json', - 'origin': 'https://web2.magentatv.de', - 'referer': 'https://web2.magentatv.de/' - }, - 'DCM': { - 'Content-Type': 'application/json', - 'Accept': 'application/json' - }, - 'OAUTH2': { - 'Content-Type': 'application/x-www-form-urlencoded' - } + "DCM": {"Content-Type": "application/json", "Accept": "application/json"}, + "OAUTH2": {"Content-Type": "application/x-www-form-urlencoded"}, } # ============================================================================ @@ -192,12 +186,12 @@ MAGENTA2_HEADERS = { # Grant types GRANT_TYPES = { - 'LINE_AUTH': 'urn:com:telekom:ott-app-services:access-auth', - 'AUTH_CODE': 'authorization_code', - 'PASSWORD': 'password', - 'REFRESH_TOKEN': 'refresh_token', - 'REMOTE_LOGIN': 'urn:telekom:com:grant-type:remote-login', - 'CLIENT_CREDENTIALS': 'client_credentials' + "LINE_AUTH": "urn:com:telekom:ott-app-services:access-auth", + "AUTH_CODE": "authorization_code", + "PASSWORD": "password", + "REFRESH_TOKEN": "refresh_token", + "REMOTE_LOGIN": "urn:telekom:com:grant-type:remote-login", + "CLIENT_CREDENTIALS": "client_credentials", } # ============================================================================ @@ -205,24 +199,22 @@ GRANT_TYPES = { # ============================================================================ # Content types -CONTENT_TYPE_LIVE = 'LIVE' -CONTENT_TYPE_VOD = 'VOD' +CONTENT_TYPE_LIVE = "LIVE" +CONTENT_TYPE_VOD = "VOD" # Stream modes -MODE_LIVE = 'live' -MODE_VOD = 'vod' +MODE_LIVE = "live" +MODE_VOD = "vod" # ============================================================================ # DRM Configuration # ============================================================================ # DRM system -DRM_SYSTEM_WIDEVINE = 'widevine' +DRM_SYSTEM_WIDEVINE = "widevine" # DRM request headers -DRM_REQUEST_HEADERS = { - 'Content-Type': 'application/octet-stream' -} +DRM_REQUEST_HEADERS = {"Content-Type": "application/octet-stream"} # ============================================================================ # Request Configuration @@ -248,11 +240,11 @@ MANIFEST_CACHE_DURATION = 7200 # 2 hours # Known error codes from Magenta2 API ERROR_CODES = { - 'DEVICE_LIMIT_EXCEEDED': 'deviceLimitExceeded', - 'PLAYBACK_RESTRICTED': 'ENT_RVOD_Playback_Restricted', - 'UNAUTHORIZED': 'ENT_Unauthorized', - 'INVALID_TOKEN': 'INVALID_TOKEN', - 'SESSION_EXPIRED': 'SESSION_EXPIRED' + "DEVICE_LIMIT_EXCEEDED": "deviceLimitExceeded", + "PLAYBACK_RESTRICTED": "ENT_RVOD_Playback_Restricted", + "UNAUTHORIZED": "ENT_Unauthorized", + "INVALID_TOKEN": "INVALID_TOKEN", + "SESSION_EXPIRED": "SESSION_EXPIRED", } # ============================================================================ @@ -263,11 +255,9 @@ ERROR_CODES = { TAA_REQUEST_TEMPLATE = { "accessTokenSource": IDM, "appVersion": APPVERSION2, - "channel": { - "id": "Tv" - }, + "channel": {"id": "Tv"}, "natco": "DE", - "type": "telekom" + "type": "telekom", } # ============================================================================ @@ -275,8 +265,6 @@ TAA_REQUEST_TEMPLATE = { # ============================================================================ # Bootstrap parameters -BOOTSTRAP_PARAMS = { - '$redirect': 'false' -} +BOOTSTRAP_PARAMS = {"$redirect": "false"} -# REMOVED: Old BOOTSTRAP_KEYS - now handled in config_models.py \ No newline at end of file +# REMOVED: Old BOOTSTRAP_KEYS - now handled in config_models.py diff --git a/lib/streaming_providers/providers/magenta2/discovery.py b/lib/streaming_providers/providers/magenta2/discovery.py index cfbefe3..11a82b1 100644 --- a/lib/streaming_providers/providers/magenta2/discovery.py +++ b/lib/streaming_providers/providers/magenta2/discovery.py @@ -1,19 +1,15 @@ # streaming_providers/providers/magenta2/discovery.py import time -from typing import Dict, Optional, Any +from typing import Any, Dict, Optional -from ...base.network import HTTPManager 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 ( - MAGENTA2_BOOTSTRAP_URL, - MAGENTA2_MANIFEST_URL, - SUBSCRIBER_TYPES, - DEFAULT_REQUEST_TIMEOUT, - BOOTSTRAP_CACHE_DURATION, - OPENID_CONFIG_CACHE_DURATION -) +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: @@ -21,15 +17,22 @@ class DiscoveryService: Service for dynamic discovery of Magenta2 endpoints and configuration """ - def __init__(self, platform: str, terminal_type: str, device_id: str, session_id: str, - http_manager: HTTPManager, proxy_config: Optional[ProxyConfig] = None): + def __init__( + self, + platform: str, + terminal_type: str, + device_id: str, + session_id: str, + http_manager: HTTPManager, + proxy_config: Optional[ProxyConfig] = None, + ): self.platform = platform self.terminal_type = terminal_type self.device_id = device_id self.session_id = session_id self.http_manager = http_manager self.proxy_config = proxy_config - self.subscriber_type = SUBSCRIBER_TYPES.get(platform, 'FTV_OTT_DT') + self.subscriber_type = SUBSCRIBER_TYPES.get(platform, "FTV_OTT_DT") # Cache storage self._bootstrap_config: Optional[BootstrapConfig] = None @@ -79,7 +82,7 @@ class DiscoveryService: provider_config = ProviderConfig( bootstrap=bootstrap_config, manifest=manifest_config, - openid=openid_config + openid=openid_config, ) # Log MPX account info for persona token composition @@ -94,7 +97,9 @@ 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 @@ -111,28 +116,30 @@ class DiscoveryService: try: logger.info("Discovering bootstrap configuration") - terminal_type = self.terminal_type.lower().replace('_', '-') + terminal_type = self.terminal_type.lower().replace("_", "-") url = MAGENTA2_BOOTSTRAP_URL.format(terminal_type=terminal_type) params = { - 'deviceid': self.device_id, - 'sid': self.session_id, - '$redirect': 'false' + "deviceid": self.device_id, + "sid": self.session_id, + "$redirect": "false", } headers = self._get_dcm_headers() response = self.http_manager.get( url, - operation='bootstrap', + operation="bootstrap", headers=headers, params=params, - timeout=DEFAULT_REQUEST_TIMEOUT + timeout=DEFAULT_REQUEST_TIMEOUT, ) response.raise_for_status() bootstrap_data = response.json() - self._bootstrap_config = BootstrapConfig.from_api_response(bootstrap_data, self.platform) + self._bootstrap_config = BootstrapConfig.from_api_response( + bootstrap_data, self.platform + ) self._last_bootstrap = time.time() logger.info( @@ -145,7 +152,9 @@ 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 @@ -156,7 +165,9 @@ 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 @@ -177,29 +188,30 @@ 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('_', '-') + 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_MODEL_MAPPINGS, - MANIFEST_FIRMWARE_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'), - 'runtimeVersion': MAGENTA2_RUNTIME_VERSION, - 'duid': self.device_id # Same as deviceId + "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" + ), + "runtimeVersion": MAGENTA2_RUNTIME_VERSION, + "duid": self.device_id, # Same as deviceId } logger.debug(f"Manifest request params: {params}") @@ -208,10 +220,10 @@ class DiscoveryService: response = self.http_manager.get( manifest_url, - operation='manifest_discovery', + operation="manifest_discovery", headers=headers, params=params, - timeout=DEFAULT_REQUEST_TIMEOUT + timeout=DEFAULT_REQUEST_TIMEOUT, ) response.raise_for_status() @@ -225,7 +237,9 @@ 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") @@ -254,7 +268,9 @@ 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 @@ -269,7 +285,10 @@ 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 @@ -279,9 +298,9 @@ class DiscoveryService: response = self.http_manager.get( openid_url, - operation='openid_discovery', - headers={'Accept': 'application/json'}, - timeout=DEFAULT_REQUEST_TIMEOUT + operation="openid_discovery", + headers={"Accept": "application/json"}, + timeout=DEFAULT_REQUEST_TIMEOUT, ) response.raise_for_status() @@ -289,7 +308,9 @@ class DiscoveryService: self._openid_config = OpenIDConfig.from_api_response(openid_data) self._last_openid = time.time() - logger.info(f"OpenID discovery successful: token_endpoint={self._openid_config.token_endpoint}") + logger.info( + f"OpenID discovery successful: token_endpoint={self._openid_config.token_endpoint}" + ) return self._openid_config except Exception as e: @@ -301,69 +322,80 @@ class DiscoveryService: def _get_dcm_headers(self) -> Dict[str, str]: """Get headers for DCM requests""" - from .constants import MAGENTA2_PLATFORMS, DEFAULT_PLATFORM + from .constants import DEFAULT_PLATFORM, MAGENTA2_PLATFORMS - platform_config = MAGENTA2_PLATFORMS.get(self.platform, MAGENTA2_PLATFORMS[DEFAULT_PLATFORM]) + platform_config = MAGENTA2_PLATFORMS.get( + self.platform, MAGENTA2_PLATFORMS[DEFAULT_PLATFORM] + ) return { - 'User-Agent': platform_config['user_agent'], - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'x-dt-session-id': self.session_id, - 'x-dt-call-id': self._generate_call_id() + "User-Agent": platform_config["user_agent"], + "Content-Type": "application/json", + "Accept": "application/json", + "x-dt-session-id": self.session_id, + "x-dt-call-id": self._generate_call_id(), } @staticmethod def _generate_call_id() -> str: """Generate call ID for requests""" import uuid + return str(uuid.uuid4()) def get_discovery_status(self) -> Dict[str, Any]: """Get discovery service status""" status = { - 'bootstrap_available': self._bootstrap_config is not None, - 'manifest_available': self._manifest_config is not None, - 'openid_available': self._openid_config is not None, + "bootstrap_available": self._bootstrap_config is not None, + "manifest_available": self._manifest_config is not None, + "openid_available": self._openid_config is not None, } if self._bootstrap_config: - status['bootstrap'] = { - 'client_model': self._bootstrap_config.client_model, - 'device_model': self._bootstrap_config.device_model, - 'has_device_tokens_url': bool(self._bootstrap_config.device_tokens_url), - 'has_openid_config_url': bool(self._bootstrap_config.openid_config_url), - 'has_taa_url': bool(self._bootstrap_config.taa_url), + status["bootstrap"] = { + "client_model": self._bootstrap_config.client_model, + "device_model": self._bootstrap_config.device_model, + "has_device_tokens_url": bool(self._bootstrap_config.device_tokens_url), + "has_openid_config_url": bool(self._bootstrap_config.openid_config_url), + "has_taa_url": bool(self._bootstrap_config.taa_url), } if self._manifest_config: device_token = self._manifest_config.get_device_token() - status['manifest'] = { - 'mpx_account_pid': self._manifest_config.mpx.account_pid, - '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, - 'drm_endpoints': { - 'widevine': bool(self._manifest_config.drm.widevine_license_url), - 'vod_widevine': bool(self._manifest_config.drm.vod_widevine_license_url), - 'fairplay': bool(self._manifest_config.drm.fairplay_license_url), + status["manifest"] = { + "mpx_account_pid": self._manifest_config.mpx.account_pid, + "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 + ), + "drm_endpoints": { + "widevine": bool(self._manifest_config.drm.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), + "tvhub_count": len(self._manifest_config.tv_hubs.base_urls), } if self._openid_config: - status['openid'] = { - 'has_token_endpoint': bool(self._openid_config.token_endpoint), - 'has_authorization_endpoint': bool(self._openid_config.authorization_endpoint), + status["openid"] = { + "has_token_endpoint": bool(self._openid_config.token_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, - 'manifest_age': now - self._last_manifest if self._last_manifest else None, - 'openid_age': now - self._last_openid if self._last_openid else None, + status["cache"] = { + "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, } return status @@ -376,4 +408,4 @@ class DiscoveryService: self._last_bootstrap = None self._last_manifest = None self._last_openid = None - logger.info("Discovery cache cleared") \ No newline at end of file + logger.info("Discovery cache cleared") diff --git a/lib/streaming_providers/providers/magenta2/endpoint_manager.py b/lib/streaming_providers/providers/magenta2/endpoint_manager.py index 9822c74..610b3f2 100644 --- a/lib/streaming_providers/providers/magenta2/endpoint_manager.py +++ b/lib/streaming_providers/providers/magenta2/endpoint_manager.py @@ -1,8 +1,8 @@ # streaming_providers/providers/magenta2/endpoint_manager.py -from typing import Dict, Optional, Any, List +import logging from dataclasses import dataclass from enum import Enum -import logging +from typing import Any, Dict, List, Optional from .config_models import ProviderConfig from .constants import MAGENTA2_FALLBACK_ENDPOINTS @@ -12,6 +12,7 @@ logger = logging.getLogger(__name__) class EndpointCategory(Enum): """Categories for different types of endpoints""" + AUTHENTICATION = "auth" CONTENT = "content" DRM = "drm" @@ -24,6 +25,7 @@ class EndpointCategory(Enum): @dataclass class EndpointInfo: """Information about a specific endpoint""" + category: EndpointCategory url: str requires_auth: bool = False @@ -55,29 +57,53 @@ 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('openid_config', EndpointCategory.AUTHENTICATION, bootstrap.openid_config_url) + self._add_endpoint( + "openid_config", + EndpointCategory.AUTHENTICATION, + bootstrap.openid_config_url, + ) if bootstrap.line_auth_url: - self._add_endpoint('line_auth', EndpointCategory.AUTHENTICATION, bootstrap.line_auth_url) + self._add_endpoint( + "line_auth", EndpointCategory.AUTHENTICATION, bootstrap.line_auth_url + ) if bootstrap.remote_login_url: - self._add_endpoint('remote_login', EndpointCategory.AUTHENTICATION, bootstrap.remote_login_url) + self._add_endpoint( + "remote_login", + EndpointCategory.AUTHENTICATION, + bootstrap.remote_login_url, + ) if bootstrap.login_qr_code_url: - self._add_endpoint('login_qr_code', EndpointCategory.AUTHENTICATION, bootstrap.login_qr_code_url) + self._add_endpoint( + "login_qr_code", + EndpointCategory.AUTHENTICATION, + bootstrap.login_qr_code_url, + ) # Content endpoints if bootstrap.device_tokens_url: - self._add_endpoint('device_tokens', EndpointCategory.CONTENT, bootstrap.device_tokens_url) + self._add_endpoint( + "device_tokens", EndpointCategory.CONTENT, bootstrap.device_tokens_url + ) 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('consumer_accounts', EndpointCategory.USER, bootstrap.consumer_accounts_url) + self._add_endpoint( + "consumer_accounts", + EndpointCategory.USER, + bootstrap.consumer_accounts_url, + ) def _add_manifest_endpoints(self) -> None: """Add endpoints from manifest configuration""" @@ -88,36 +114,62 @@ class EndpointManager: # MPX endpoints if manifest.mpx.license_service_url: - self._add_endpoint('mpx_license', EndpointCategory.MPX, manifest.mpx.license_service_url) + self._add_endpoint( + "mpx_license", EndpointCategory.MPX, manifest.mpx.license_service_url + ) if manifest.mpx.selector_service_url: - self._add_endpoint('mpx_selector', EndpointCategory.MPX, manifest.mpx.selector_service_url) + self._add_endpoint( + "mpx_selector", EndpointCategory.MPX, manifest.mpx.selector_service_url + ) if manifest.mpx.channel_stations_feed: - self._add_endpoint('channel_stations', EndpointCategory.CONTENT, manifest.mpx.channel_stations_feed) - logger.info(f"Channel stations feed found: {manifest.mpx.channel_stations_feed}") + self._add_endpoint( + "channel_stations", + EndpointCategory.CONTENT, + 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: - self._add_endpoint('widevine_license', EndpointCategory.DRM, manifest.drm.widevine_license_url) + self._add_endpoint( + "widevine_license", + EndpointCategory.DRM, + manifest.drm.widevine_license_url, + ) if manifest.drm.vod_widevine_license_url: - self._add_endpoint('vod_widevine_license', EndpointCategory.DRM, manifest.drm.vod_widevine_license_url) + self._add_endpoint( + "vod_widevine_license", + EndpointCategory.DRM, + manifest.drm.vod_widevine_license_url, + ) if manifest.drm.fairplay_license_url: - self._add_endpoint('fairplay_license', EndpointCategory.DRM, manifest.drm.fairplay_license_url) + self._add_endpoint( + "fairplay_license", + EndpointCategory.DRM, + manifest.drm.fairplay_license_url, + ) # MPX feeds (resolved with account PID) 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""" @@ -127,44 +179,74 @@ class EndpointManager: openid = self.config.openid if openid.token_endpoint: - self._add_endpoint('oauth_token', EndpointCategory.AUTHENTICATION, openid.token_endpoint, - requires_auth=False) + self._add_endpoint( + "oauth_token", + EndpointCategory.AUTHENTICATION, + openid.token_endpoint, + requires_auth=False, + ) if openid.authorization_endpoint: - self._add_endpoint('oauth_authorize', EndpointCategory.AUTHENTICATION, openid.authorization_endpoint) + self._add_endpoint( + "oauth_authorize", + EndpointCategory.AUTHENTICATION, + openid.authorization_endpoint, + ) if openid.userinfo_endpoint: - self._add_endpoint('userinfo', EndpointCategory.USER, openid.userinfo_endpoint, requires_auth=True) + self._add_endpoint( + "userinfo", + EndpointCategory.USER, + openid.userinfo_endpoint, + requires_auth=True, + ) if openid.revocation_endpoint: - self._add_endpoint('oauth_revoke', EndpointCategory.AUTHENTICATION, openid.revocation_endpoint) + self._add_endpoint( + "oauth_revoke", + EndpointCategory.AUTHENTICATION, + openid.revocation_endpoint, + ) def _add_fallback_endpoints(self) -> None: """Add fallback endpoints for critical functionality""" fallbacks = { - 'openid_config': MAGENTA2_FALLBACK_ENDPOINTS['OPENID_CONFIG'], - 'taa_auth': MAGENTA2_FALLBACK_ENDPOINTS['TAA_AUTH'], - 'entitlement': MAGENTA2_FALLBACK_ENDPOINTS['ENTITLEMENT'], - 'mpx_license': 'https://license.entitlement.theplatform.eu/license/web/ContentAccessRules/getApplicableDistributionRights', - 'mpx_selector': 'https://link.api.eu.theplatform.com/s/', - 'widevine_license': 'https://widevine.entitlement.theplatform.eu/wv/web/ModularDrm/getRawWidevineLicense', + "openid_config": MAGENTA2_FALLBACK_ENDPOINTS["OPENID_CONFIG"], + "taa_auth": MAGENTA2_FALLBACK_ENDPOINTS["TAA_AUTH"], + "entitlement": MAGENTA2_FALLBACK_ENDPOINTS["ENTITLEMENT"], + "mpx_license": "https://license.entitlement.theplatform.eu/license/web/ContentAccessRules/getApplicableDistributionRights", + "mpx_selector": "https://link.api.eu.theplatform.com/s/", + "widevine_license": "https://widevine.entitlement.theplatform.eu/wv/web/ModularDrm/getRawWidevineLicense", } for endpoint_name, url in fallbacks.items(): # Only add fallback if we don't already have this endpoint if endpoint_name not in self._endpoints: - self._add_endpoint(endpoint_name, EndpointCategory.AUTHENTICATION, url, is_fallback=True) + self._add_endpoint( + endpoint_name, + EndpointCategory.AUTHENTICATION, + url, + is_fallback=True, + ) - def _add_endpoint(self, name: str, category: EndpointCategory, url: str, - requires_auth: bool = False, is_fallback: bool = False) -> None: + def _add_endpoint( + self, + name: str, + category: EndpointCategory, + url: str, + requires_auth: bool = False, + is_fallback: bool = False, + ) -> None: """Add an endpoint to the manager""" self._endpoints[name] = EndpointInfo( category=category, url=url, requires_auth=requires_auth, - is_fallback=is_fallback + is_fallback=is_fallback, + ) + logger.debug( + f"Added endpoint: {name} -> {url} (category: {category.value}, fallback: {is_fallback})" ) - logger.debug(f"Added endpoint: {name} -> {url} (category: {category.value}, fallback: {is_fallback})") def get_endpoint(self, name: str) -> Optional[str]: """Get endpoint URL by name""" @@ -209,20 +291,20 @@ class EndpointManager: fallback_count += 1 return { - 'total_endpoints': total, - 'endpoints_by_category': by_category, - 'fallback_endpoints': fallback_count, - 'dynamic_endpoints': total - fallback_count, - 'is_complete': self.config.is_complete + "total_endpoints": total, + "endpoints_by_category": by_category, + "fallback_endpoints": fallback_count, + "dynamic_endpoints": total - fallback_count, + "is_complete": self.config.is_complete, } def validate_critical_endpoints(self) -> List[str]: """Validate that all critical endpoints are available""" critical_endpoints = [ - 'taa_auth', - 'openid_config', - 'mpx_license', - 'widevine_license' + "taa_auth", + "openid_config", + "mpx_license", + "widevine_license", ] missing = [] @@ -230,4 +312,4 @@ class EndpointManager: if not self.has_endpoint(endpoint): missing.append(endpoint) - return missing \ No newline at end of file + return missing diff --git a/lib/streaming_providers/providers/magenta2/models.py b/lib/streaming_providers/providers/magenta2/models.py index 7f14705..cfdd14e 100644 --- a/lib/streaming_providers/providers/magenta2/models.py +++ b/lib/streaming_providers/providers/magenta2/models.py @@ -1,21 +1,23 @@ # streaming_providers/providers/magenta2/models.py +import json from dataclasses import dataclass, field from typing import Dict, Optional -import json from ...base.models import StreamingChannel - # streaming_providers/providers/magenta2/models.py # Add this exception if not already present + class DeviceLimitExceededException(Exception): """Exception raised when device limit is exceeded""" + pass class Magenta2PlaybackRestrictedException(Exception): """Exception raised when playback is restricted for content""" + pass @@ -24,6 +26,7 @@ class Magenta2Channel: """ Represents a Magenta2 channel with all necessary streaming data """ + # Core identification name: str channel_id: str @@ -41,19 +44,19 @@ class Magenta2Channel: cdm_type: Optional[str] = None use_cdm: bool = True cdm: Optional[str] = None # Usually "pid={pid}" - cdm_mode: str = 'external' + cdm_mode: str = "external" # Video settings - video: str = 'best' + video: str = "best" on_demand: bool = True speed_up: bool = True # Additional metadata - content_type: str = 'LIVE' # 'LIVE' or 'VOD' + content_type: str = "LIVE" # 'LIVE' or 'VOD' description: Optional[str] = None genre: Optional[str] = None - language: str = 'de' - country: str = 'DE' + language: str = "de" + country: str = "DE" # Streaming data license_url: Optional[str] = None @@ -64,7 +67,7 @@ class Magenta2Channel: raw_data: Dict = field(default_factory=dict) @classmethod - def from_api_data(cls, api_data: Dict, **kwargs) -> 'Magenta2Channel': + def from_api_data(cls, api_data: Dict, **kwargs) -> "Magenta2Channel": """ Create Magenta2Channel from API response data @@ -76,10 +79,10 @@ class Magenta2Channel: Magenta2Channel instance """ channel = cls( - name=api_data.get('title', api_data.get('name', 'Unknown Channel')), - channel_id=api_data.get('id', ''), - content_type=api_data.get('type', 'LIVE'), - raw_data=api_data.copy() + name=api_data.get("title", api_data.get("name", "Unknown Channel")), + channel_id=api_data.get("id", ""), + content_type=api_data.get("type", "LIVE"), + raw_data=api_data.copy(), ) # Apply any additional parameters @@ -89,9 +92,15 @@ class Magenta2Channel: return channel - def set_streaming_data(self, manifest: str, cdm_type: str = None, - pid: str = None, license_url: str = None, - certificate_url: str = None, streaming_format: str = None) -> None: + def set_streaming_data( + self, + manifest: str, + cdm_type: str = None, + pid: str = None, + license_url: str = None, + certificate_url: str = None, + streaming_format: str = None, + ) -> None: """ Configure streaming-specific data @@ -133,13 +142,13 @@ class Magenta2Channel: def is_live(self) -> bool: """Check if channel is live TV""" - return self.content_type == 'LIVE' and self.mode == 'live' + return self.content_type == "LIVE" and self.mode == "live" def is_vod(self) -> bool: """Check if channel is video on demand""" - return self.content_type == 'VOD' or self.mode == 'vod' + return self.content_type == "VOD" or self.mode == "vod" - def to_streaming_channel(self, provider_name: str = 'magenta2') -> StreamingChannel: + def to_streaming_channel(self, provider_name: str = "magenta2") -> StreamingChannel: """ Convert to generic StreamingChannel object @@ -172,7 +181,7 @@ class Magenta2Channel: country=self.country, license_url=self.license_url, certificate_url=self.certificate_url, - streaming_format=self.streaming_format + streaming_format=self.streaming_format, ) def to_dict(self) -> Dict: @@ -183,19 +192,19 @@ class Magenta2Channel: Dictionary in the format expected by your application """ return { - 'Name': self.name, - 'LogoUrl': self.logo_url, - 'Mode': self.mode, - 'SessionManifest': self.session_manifest, - 'Manifest': self.manifest, - 'ManifestScript': self.manifest_script, - 'CdmType': self.cdm_type, - 'UseCdm': self.use_cdm, - 'Cdm': self.cdm, - 'CdmMode': self.cdm_mode, - 'Video': self.video, - 'OnDemand': self.on_demand, - 'SpeedUp': self.speed_up + "Name": self.name, + "LogoUrl": self.logo_url, + "Mode": self.mode, + "SessionManifest": self.session_manifest, + "Manifest": self.manifest, + "ManifestScript": self.manifest_script, + "CdmType": self.cdm_type, + "UseCdm": self.use_cdm, + "Cdm": self.cdm, + "CdmMode": self.cdm_mode, + "Video": self.video, + "OnDemand": self.on_demand, + "SpeedUp": self.speed_up, } def to_json(self, indent: int = 2) -> str: @@ -206,4 +215,4 @@ class Magenta2Channel: return f"Magenta2Channel(name='{self.name}', id='{self.channel_id}', type='{self.content_type}')" def __repr__(self) -> str: - return self.__str__() \ No newline at end of file + return self.__str__() diff --git a/lib/streaming_providers/providers/magenta2/provider.py b/lib/streaming_providers/providers/magenta2/provider.py index 0582f9d..322539e 100644 --- a/lib/streaming_providers/providers/magenta2/provider.py +++ b/lib/streaming_providers/providers/magenta2/provider.py @@ -1,43 +1,33 @@ # streaming_providers/providers/magenta2/provider.py # -*- coding: utf-8 -*- -from typing import Dict, Optional, List, Any, Tuple, ClassVar -import json import base64 +import json +import re import time import uuid -import re from datetime import datetime, timedelta +from typing import Any, ClassVar, Dict, List, Optional, Tuple -from ...base.provider import StreamingProvider -from ...base.models import DRMConfig, LicenseConfig, DRMSystem +from ...base.models import DRMConfig, DRMSystem, LicenseConfig +from ...base.models.auth import AuthState +from ...base.models.proxy_models import ProxyConfig from ...base.models.streaming_channel import StreamingChannel from ...base.network import HTTPManagerFactory, ProxyConfigManager -from ...base.models.proxy_models import ProxyConfig +from ...base.provider import StreamingProvider from ...base.utils.logger import logger -from ...base.models.auth import AuthState -from .models import Magenta2Channel, Magenta2PlaybackRestrictedException -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 .discovery import DiscoveryService from .endpoint_manager import EndpointManager -from .config_models import ProviderConfig +from .models import Magenta2Channel, Magenta2PlaybackRestrictedException from .token_flow_manager import PersonaResult -from .constants import ( - SUPPORTED_COUNTRIES, - DEFAULT_COUNTRY, - DEFAULT_PLATFORM, - MAGENTA2_PLATFORMS, - MAGENTA2_CLIENT_IDS, - CONTENT_TYPE_LIVE, - CONTENT_TYPE_VOD, - MODE_LIVE, - MODE_VOD, - DRM_SYSTEM_WIDEVINE, - DRM_REQUEST_HEADERS, - DEFAULT_REQUEST_TIMEOUT, - DEFAULT_MAX_RETRIES, - DEFAULT_EPG_WINDOW_HOURS, - ERROR_CODES, MAGENTA2_LOGO -) class Magenta2Provider(StreamingProvider): @@ -47,24 +37,31 @@ class Magenta2Provider(StreamingProvider): Magenta2 streaming provider implementation with enhanced dynamic discovery """ - def __init__(self, country: str = DEFAULT_COUNTRY, - platform: str = DEFAULT_PLATFORM, - config_dir: Optional[str] = None, - proxy_config: Optional[ProxyConfig] = None, - proxy_url: Optional[str] = None, - username: Optional[str] = None, - password: Optional[str] = None): + def __init__( + self, + country: str = DEFAULT_COUNTRY, + platform: str = DEFAULT_PLATFORM, + config_dir: Optional[str] = None, + proxy_config: Optional[ProxyConfig] = None, + proxy_url: Optional[str] = None, + username: Optional[str] = None, + password: Optional[str] = None, + ): """ Initialize Magenta2 provider with enhanced discovery """ super().__init__(country=country) if country not in SUPPORTED_COUNTRIES: - raise ValueError(f"Unsupported country: {country}. Must be one of: {SUPPORTED_COUNTRIES}") + raise ValueError( + f"Unsupported country: {country}. Must be one of: {SUPPORTED_COUNTRIES}" + ) self.platform = platform - self.platform_config = MAGENTA2_PLATFORMS.get(platform, MAGENTA2_PLATFORMS[DEFAULT_PLATFORM]) - self.terminal_type = self.platform_config['terminal_type'] + self.platform_config = MAGENTA2_PLATFORMS.get( + platform, MAGENTA2_PLATFORMS[DEFAULT_PLATFORM] + ) + self.terminal_type = self.platform_config["terminal_type"] # Generate session ID and device ID self.session_id = self._generate_uuid() @@ -72,9 +69,9 @@ class Magenta2Provider(StreamingProvider): # Setup proxy configuration self.proxy_config = ( - proxy_config or - (ProxyConfig.from_url(proxy_url) if proxy_url else None) or - self._load_proxy_from_manager(config_dir) + proxy_config + or (ProxyConfig.from_url(proxy_url) if proxy_url else None) + or self._load_proxy_from_manager(config_dir) ) if self.proxy_config: @@ -84,11 +81,11 @@ class Magenta2Provider(StreamingProvider): # Create HTTP manager self.http_manager = HTTPManagerFactory.create_for_provider( - provider_name='magenta2', + provider_name="magenta2", proxy_config=self.proxy_config, - user_agent=self.platform_config['user_agent'], + user_agent=self.platform_config["user_agent"], timeout=DEFAULT_REQUEST_TIMEOUT, - max_retries=DEFAULT_MAX_RETRIES + max_retries=DEFAULT_MAX_RETRIES, ) # Initialize discovery service @@ -98,7 +95,7 @@ class Magenta2Provider(StreamingProvider): device_id=self.device_id, session_id=self.session_id, http_manager=self.http_manager, - proxy_config=self.proxy_config + proxy_config=self.proxy_config, ) # Initialize endpoint manager (will be populated after discovery) @@ -107,7 +104,9 @@ class Magenta2Provider(StreamingProvider): # 🚨 INITIALIZE AUTHENTICATOR FIRST (with minimal config) # Use fallback client IDs initially - fallback_client_id = MAGENTA2_CLIENT_IDS.get(platform, MAGENTA2_CLIENT_IDS[DEFAULT_PLATFORM]) + fallback_client_id = MAGENTA2_CLIENT_IDS.get( + platform, MAGENTA2_CLIENT_IDS[DEFAULT_PLATFORM] + ) if username and password: # Use user credentials for complete authentication flow @@ -117,7 +116,7 @@ class Magenta2Provider(StreamingProvider): country=country, device_id=self.device_id, username=username, - password=password + password=password, ) logger.info("Using user credentials for authentication") else: @@ -126,7 +125,7 @@ class Magenta2Provider(StreamingProvider): client_id=fallback_client_id, # Use fallback initially platform=platform, country=country, - device_id=self.device_id + device_id=self.device_id, ) logger.info("Using client credentials for authentication") @@ -143,7 +142,7 @@ class Magenta2Provider(StreamingProvider): sam3_client_id=fallback_client_id, # Use fallback initially session_id=self.session_id, device_id=self.device_id, - provider_config=None + provider_config=None, ) # 🚨 NOW PERFORM CONFIGURATION DISCOVERY (authenticator exists) @@ -156,39 +155,65 @@ 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 - if hasattr(self.authenticator, 'token_flow_manager') and self.authenticator.token_flow_manager: - self.authenticator.token_flow_manager.provider_config = self.provider_config + if ( + hasattr(self.authenticator, "token_flow_manager") + and self.authenticator.token_flow_manager + ): + 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: # Use public method if available, otherwise update directly - if hasattr(self.authenticator, 'update_sam3_client_id'): - self.authenticator.update_sam3_client_id(self.provider_config.bootstrap.sam3_client_id) + if hasattr(self.authenticator, "update_sam3_client_id"): + self.authenticator.update_sam3_client_id( + self.provider_config.bootstrap.sam3_client_id + ) else: - self.authenticator._sam3_client_id = self.provider_config.bootstrap.sam3_client_id + self.authenticator._sam3_client_id = ( + self.provider_config.bootstrap.sam3_client_id + ) if self.authenticator.credentials: - self.authenticator.credentials.client_id = self.provider_config.bootstrap.sam3_client_id + self.authenticator.credentials.client_id = ( + self.provider_config.bootstrap.sam3_client_id + ) logger.debug( - f"Updated authenticator with SAM3 client ID: {self.provider_config.bootstrap.sam3_client_id}") + f"Updated authenticator with SAM3 client ID: {self.provider_config.bootstrap.sam3_client_id}" + ) if self.provider_config.bootstrap.client_model: - if hasattr(self.authenticator, 'update_client_model'): - self.authenticator.update_client_model(self.provider_config.bootstrap.client_model) + if hasattr(self.authenticator, "update_client_model"): + self.authenticator.update_client_model( + self.provider_config.bootstrap.client_model + ) else: - self.authenticator._client_model = self.provider_config.bootstrap.client_model - logger.debug(f"Updated authenticator with 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}" + ) if self.provider_config.bootstrap.device_model: - if hasattr(self.authenticator, 'update_device_model'): - self.authenticator.update_device_model(self.provider_config.bootstrap.device_model) + if hasattr(self.authenticator, "update_device_model"): + self.authenticator.update_device_model( + self.provider_config.bootstrap.device_model + ) else: - self.authenticator._device_model = self.provider_config.bootstrap.device_model - logger.debug(f"Updated authenticator with 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}" + ) # Update authenticator with device token and MPX account if available if self.provider_config.manifest: @@ -196,17 +221,25 @@ 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 if self.provider_config.manifest.mpx.account_pid: - self.authenticator.set_mpx_account_pid(self.provider_config.manifest.mpx.account_pid) - logger.debug(f"MPX account PID configured: {self.provider_config.manifest.mpx.account_pid}") + self.authenticator.set_mpx_account_pid( + self.provider_config.manifest.mpx.account_pid + ) + logger.debug( + f"MPX account PID configured: {self.provider_config.manifest.mpx.account_pid}" + ) # 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: @@ -217,28 +250,38 @@ class Magenta2Provider(StreamingProvider): } # Update authenticator with discovered endpoints using public method - if hasattr(self.authenticator, 'update_dynamic_endpoints'): + if hasattr(self.authenticator, "update_dynamic_endpoints"): self.authenticator.update_dynamic_endpoints(all_endpoints) - logger.info(f"✓ Updated authenticator with {len(all_endpoints)} endpoints") - elif hasattr(self.authenticator, 'update_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") # Specifically update SAM3 client with QR code URL - qr_url = self.endpoint_manager.get_endpoint('login_qr_code') - if qr_url and hasattr(self.authenticator, 'update_sam3_qr_code_url'): + qr_url = self.endpoint_manager.get_endpoint("login_qr_code") + 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") @@ -260,11 +303,13 @@ 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) - return proxy_manager.get_proxy_config('magenta2', self.country) + return proxy_manager.get_proxy_config("magenta2", self.country) except Exception as e: logger.warning(f"Could not load proxy from ProxyConfigManager: {e}") return None @@ -280,27 +325,35 @@ 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) # DEBUG: Check if QR code endpoint is discovered if self.endpoint_manager: - qr_url = self.endpoint_manager.get_endpoint('login_qr_code') + 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'): + 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'): + if hasattr(self.authenticator, "get_sam3_client_status"): status = self.authenticator.get_sam3_client_status() logger.debug(f"SAM3 client status after update: {status}") else: @@ -311,17 +364,23 @@ 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") if self.provider_config.manifest.mpx.account_pid: - logger.info(f"✓ MPX account PID discovered: {self.provider_config.manifest.mpx.account_pid}") + logger.info( + f"✓ MPX account PID discovered: {self.provider_config.manifest.mpx.account_pid}" + ) # Validate critical endpoints missing_endpoints = self.endpoint_manager.validate_critical_endpoints() @@ -351,7 +410,7 @@ class Magenta2Provider(StreamingProvider): bootstrap_config = BootstrapConfig( client_model=f"ftv-{self.platform}", - device_model=f"{self.platform.upper()}_FTV" + device_model=f"{self.platform.upper()}_FTV", ) self.provider_config = ProviderConfig(bootstrap=bootstrap_config) @@ -362,34 +421,34 @@ class Magenta2Provider(StreamingProvider): def _get_dcm_headers(self) -> Dict[str, str]: """Get headers for DCM requests""" return { - 'User-Agent': self.platform_config['user_agent'], - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'x-dt-session-id': self.session_id, - 'x-dt-call-id': self._generate_call_id() + "User-Agent": self.platform_config["user_agent"], + "Content-Type": "application/json", + "Accept": "application/json", + "x-dt-session-id": self.session_id, + "x-dt-call-id": self._generate_call_id(), } def _get_api_headers(self, require_auth: bool = False) -> Dict[str, str]: """Get headers for API requests""" headers = { - 'User-Agent': self.platform_config['user_agent'], - 'Accept': 'application/json', - 'Content-Type': 'application/json' + "User-Agent": self.platform_config["user_agent"], + "Accept": "application/json", + "Content-Type": "application/json", } if require_auth: persona_token = self._ensure_authenticated() - headers['Authorization'] = f'Basic {persona_token}' + headers["Authorization"] = f"Basic {persona_token}" return headers @property def provider_name(self) -> str: - return 'magenta2' + return "magenta2" @property def provider_label(self) -> str: - return 'Magenta TV 2.0' + return "Magenta TV 2.0" @property def provider_logo(self) -> str: @@ -410,25 +469,25 @@ class Magenta2Provider(StreamingProvider): @property def supported_auth_types(self) -> List[str]: - return ['network_based'] # ← Change 1 + return ["network_based"] # ← Change 1 @property def primary_token_scope(self) -> Optional[str]: - return 'persona' # ← Keep this + return "persona" # ← Keep this @property def token_scopes(self) -> List[str]: - return ['yo_digital', 'tvhubs', 'taa', 'persona'] + return ["yo_digital", "tvhubs", "taa", "persona"] def get_discovery_status(self) -> Dict[str, Any]: """Get discovery and configuration status""" if not self.discovery_service: - return {'error': 'Discovery service not initialized'} + return {"error": "Discovery service not initialized"} status = self.discovery_service.get_discovery_status() if self.endpoint_manager: - status['endpoints'] = self.endpoint_manager.get_stats() + status["endpoints"] = self.endpoint_manager.get_stats() return status @@ -445,7 +504,9 @@ 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 @@ -453,13 +514,19 @@ 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') + device_token = new_config.manifest.raw_data.get("deviceToken") + 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 @@ -484,7 +551,7 @@ class Magenta2Provider(StreamingProvider): return False # PROPER: Use public method instead of checking protected attribute - if hasattr(self.authenticator, 'perform_device_authentication'): + if hasattr(self.authenticator, "perform_device_authentication"): success = self.authenticator.perform_device_authentication() if success: logger.info("✓ Device registration successful") @@ -493,7 +560,9 @@ 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: @@ -507,7 +576,9 @@ class Magenta2Provider(StreamingProvider): current_time = time.time() # Check if cached token is still valid (with 1-minute buffer) if current_time < (self._persona_cache.expires_at - 60): - logger.debug(f"Using in-memory cached persona token (expires at {time.ctime(self._persona_cache.expires_at)})") + logger.debug( + f"Using in-memory cached persona token (expires at {time.ctime(self._persona_cache.expires_at)})" + ) return self._persona_cache.persona_token else: # Cache expired @@ -524,7 +595,9 @@ class Magenta2Provider(StreamingProvider): # Cache the entire PersonaResult with expiry self._persona_cache = persona_result - logger.debug(f"Cached persona token in memory (expires at {time.ctime(persona_result.expires_at)})") + logger.debug( + f"Cached persona token in memory (expires at {time.ctime(persona_result.expires_at)})" + ) return persona_result.persona_token @@ -537,14 +610,16 @@ 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 def _extract_channel_id_from_entry(entry: Dict) -> Optional[str]: """Extract the correct channel ID from era$mediaPids""" try: - stations = entry.get('stations', {}) + stations = entry.get("stations", {}) if not stations: return None @@ -553,15 +628,15 @@ class Magenta2Provider(StreamingProvider): station_info = stations[station_id] # Extract from era$mediaPids (this is the correct ID for API calls) - era_media_pids = station_info.get('era$mediaPids', {}) - channel_id = era_media_pids.get('urn:theplatform:tv:location:any') + era_media_pids = station_info.get("era$mediaPids", {}) + channel_id = era_media_pids.get("urn:theplatform:tv:location:any") if channel_id: logger.debug(f"Extracted channel ID from era$mediaPids: {channel_id}") return channel_id # Fallback to guid if era$mediaPids not available - fallback_id = entry.get('guid') + fallback_id = entry.get("guid") if fallback_id: logger.warning(f"Using fallback channel ID from guid: {fallback_id}") return fallback_id @@ -589,60 +664,62 @@ class Magenta2Provider(StreamingProvider): # Parse the call parameter (e.g., "client=ftp22") call_params = {} - for param in image_config.scaling_call_parameter.split('&'): - if '=' in param: - key, value = param.split('=', 1) + for param in image_config.scaling_call_parameter.split("&"): + if "=" in param: + key, value = param.split("=", 1) call_params[key] = value # Build the scaling URL - base_url = image_config.scaling_base_url.rstrip('/') + base_url = image_config.scaling_base_url.rstrip("/") # Add required parameters params = { **call_params, # client=ftp22 - 'x': '120', - 'y': '42', - 'ar': 'keep', # aspect ratio - 'src': original_url # original image URL + "x": "120", + "y": "42", + "ar": "keep", # aspect ratio + "src": original_url, # original image URL } # 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}" def _process_channel_stations_response_optimized( - self, - response_data: Dict, - prefer_highest_quality: bool = True + self, response_data: Dict, prefer_highest_quality: bool = True ) -> List[StreamingChannel]: """Ultra-optimized single-pass processing""" - if 'entries' not in response_data: + if "entries" not in response_data: return [] - quality_rank = {'SD': 1, 'HD': 2, 'UHD': 3, '4K': 3} + quality_rank = {"SD": 1, "HD": 2, "UHD": 3, "4K": 3} best_entries = {} channels = [] - for entry in response_data['entries']: + for entry in response_data["entries"]: try: - stations = entry.get('stations', {}) + stations = entry.get("stations", {}) if not stations: continue station_info = next(iter(stations.values())) - display_number = entry.get('dt$displayChannelNumber') + display_number = entry.get("dt$displayChannelNumber") 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 # Extract quality for comparison - quality = station_info.get('dt$quality', 'SD') + quality = station_info.get("dt$quality", "SD") current_rank = quality_rank.get(quality, 1) # Check if we need to replace existing entry @@ -651,16 +728,23 @@ class Magenta2Provider(StreamingProvider): best_entries[display_number] = (entry, station_info, current_rank) else: _, _, existing_rank = existing - if (prefer_highest_quality and current_rank > existing_rank) or \ - (not prefer_highest_quality and current_rank < existing_rank): - best_entries[display_number] = (entry, station_info, current_rank) + if (prefer_highest_quality and current_rank > existing_rank) or ( + not prefer_highest_quality and current_rank < existing_rank + ): + best_entries[display_number] = ( + entry, + station_info, + current_rank, + ) except Exception: continue # 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) @@ -669,7 +753,7 @@ class Magenta2Provider(StreamingProvider): def _create_channel_from_entry(self, entry, station_info, display_number): """Helper to create StreamingChannel from entry data""" try: - title = station_info.get('title') or entry.get('title', 'Unknown Channel') + title = station_info.get("title") or entry.get("title", "Unknown Channel") title = title.replace(" - Main", "") channel_id = self._extract_channel_id_from_entry(entry) @@ -678,10 +762,10 @@ class Magenta2Provider(StreamingProvider): # Logo processing logo_url = None - thumbnails = station_info.get('thumbnails', {}) - for logo_type in ['stationLogo', 'stationLogoColored']: + thumbnails = station_info.get("thumbnails", {}) + for logo_type in ["stationLogo", "stationLogoColored"]: if logo_type in thumbnails: - original_url = thumbnails[logo_type].get('url') + original_url = thumbnails[logo_type].get("url") if original_url: logo_url = self._build_scaled_image_url(original_url) break @@ -693,14 +777,14 @@ class Magenta2Provider(StreamingProvider): mode=MODE_LIVE, content_type=CONTENT_TYPE_LIVE, country=self.country, - raw_data=entry + raw_data=entry, ) streaming_channel = magenta2_channel.to_streaming_channel( provider_name=self.provider_name ) streaming_channel.channel_number = display_number - streaming_channel.quality = station_info.get('dt$quality', 'SD') + streaming_channel.quality = station_info.get("dt$quality", "SD") return streaming_channel @@ -708,12 +792,14 @@ class Magenta2Provider(StreamingProvider): logger.warning(f"Error creating channel from entry: {e}") return None - def get_channels(self, - time_window_hours: int = DEFAULT_EPG_WINDOW_HOURS, - fetch_manifests: bool = False, - populate_streaming_data: bool = True, - prefer_highest_quality: bool = True, - **kwargs) -> List[StreamingChannel]: + def get_channels( + self, + time_window_hours: int = DEFAULT_EPG_WINDOW_HOURS, + fetch_manifests: bool = False, + populate_streaming_data: bool = True, + prefer_highest_quality: bool = True, + **kwargs, + ) -> List[StreamingChannel]: """ Fetch available channels from Magenta2 API """ @@ -723,11 +809,15 @@ class Magenta2Provider(StreamingProvider): # Use the discovered channel stations endpoint url = None if self.endpoint_manager: - url = self.endpoint_manager.get_endpoint('channel_stations') + 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') + 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" + ) # Final fallback if not url: @@ -737,16 +827,15 @@ class Magenta2Provider(StreamingProvider): logger.debug(f"Fetching channels from: {url}") response = self.http_manager.get( - url, - operation='api', - headers=headers, - timeout=DEFAULT_REQUEST_TIMEOUT + url, operation="api", headers=headers, timeout=DEFAULT_REQUEST_TIMEOUT ) response.raise_for_status() channels_data = response.json() # ✅ USE OPTIMIZED SINGLE-PASS PROCESSING - channels = self._process_channel_stations_response_optimized(channels_data, prefer_highest_quality) + channels = self._process_channel_stations_response_optimized( + channels_data, prefer_highest_quality + ) logger.info( f"Successfully fetched {len(channels)} channels for country {self.country} " @@ -763,7 +852,9 @@ class Magenta2Provider(StreamingProvider): if not self.endpoint_manager: return [] - entitled_channels_url = self.endpoint_manager.get_endpoint('mpx_feed_entitledChannelsFeed') + entitled_channels_url = self.endpoint_manager.get_endpoint( + "mpx_feed_entitledChannelsFeed" + ) if not entitled_channels_url: return [] @@ -772,9 +863,9 @@ class Magenta2Provider(StreamingProvider): headers = self._get_api_headers(require_auth=True) response = self.http_manager.get( entitled_channels_url, - operation='mpx_feed', + operation="mpx_feed", headers=headers, - timeout=DEFAULT_REQUEST_TIMEOUT + timeout=DEFAULT_REQUEST_TIMEOUT, ) response.raise_for_status() @@ -788,12 +879,14 @@ class Magenta2Provider(StreamingProvider): def _parse_mpx_feed_response(self, feed_data: Dict) -> List[StreamingChannel]: """Parse MPX feed response into channels""" channels = [] - entries = feed_data.get('entries', []) + entries = feed_data.get("entries", []) for entry in entries: try: - title = entry.get('title', 'Unknown') - channel_id = entry.get('guid', '').split('/')[-1] if entry.get('guid') else '' + title = entry.get("title", "Unknown") + channel_id = ( + entry.get("guid", "").split("/")[-1] if entry.get("guid") else "" + ) if not channel_id: continue @@ -803,7 +896,7 @@ class Magenta2Provider(StreamingProvider): channel_id=channel_id, content_type=CONTENT_TYPE_LIVE, country=self.country, - raw_data=entry + raw_data=entry, ) streaming_channel = channel.to_streaming_channel( @@ -823,10 +916,10 @@ class Magenta2Provider(StreamingProvider): if isinstance(response_data, list): channel_list = response_data elif isinstance(response_data, dict): - if 'channels' in response_data: - channel_list = response_data['channels'] - elif 'data' in response_data: - channel_list = response_data['data'] + if "channels" in response_data: + channel_list = response_data["channels"] + elif "data" in response_data: + channel_list = response_data["data"] else: channel_list = [response_data] else: @@ -835,26 +928,35 @@ class Magenta2Provider(StreamingProvider): for channel_data in channel_list: try: - channel_id = channel_data.get('id', channel_data.get('channelId', '')) + channel_id = channel_data.get("id", channel_data.get("channelId", "")) if not channel_id: logger.warning("Channel missing ID, skipping") continue - title = channel_data.get('title', channel_data.get('name', 'Unknown Channel')) - stream_type = channel_data.get('type', 'LIVE') - quality = channel_data.get('quality', '') + 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']: - logo_url = channel_data['logo']['url'] - elif isinstance(channel_data['logo'], str): - logo_url = channel_data['logo'] - elif 'image' in channel_data: - logo_url = channel_data['image'] + if "logo" in channel_data: + 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"] + elif "image" in channel_data: + logo_url = channel_data["image"] - content_type = CONTENT_TYPE_LIVE if stream_type.upper() == 'LIVE' else CONTENT_TYPE_VOD - mode = MODE_LIVE if stream_type.upper() == 'LIVE' else MODE_VOD + content_type = ( + CONTENT_TYPE_LIVE + if stream_type.upper() == "LIVE" + else CONTENT_TYPE_VOD + ) + mode = MODE_LIVE if stream_type.upper() == "LIVE" else MODE_VOD magenta2_channel = Magenta2Channel( name=title, @@ -863,7 +965,7 @@ class Magenta2Provider(StreamingProvider): mode=mode, content_type=content_type, country=self.country, - raw_data=channel_data + raw_data=channel_data, ) if quality: @@ -879,7 +981,9 @@ 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 """ @@ -889,50 +993,62 @@ class Magenta2Provider(StreamingProvider): # Use persona token (Basic auth) for entitlement headers = self._get_api_headers(require_auth=True) - payload = { - "content_id": content_id, - "content_type": content_type - } + payload = {"content_id": content_id, "content_type": content_type} - url = self.endpoint_manager.get_endpoint( - 'entitlement') if self.endpoint_manager else 'https://entitlement.p7s1.io/api/user/entitlement-token' + url = ( + self.endpoint_manager.get_endpoint("entitlement") + if self.endpoint_manager + else "https://entitlement.p7s1.io/api/user/entitlement-token" + ) 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', + operation="auth", headers=headers, json_data=payload, - timeout=DEFAULT_REQUEST_TIMEOUT + timeout=DEFAULT_REQUEST_TIMEOUT, ) 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}") + 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}") + raise Exception( + f"Bad response for {content_id} (400), failed to parse error: {e}" + ) response.raise_for_status() data = response.json() - if 'entitlement_token' in data: - return data['entitlement_token'] - elif 'entitlementToken' in data: - return data['entitlementToken'] - elif 'token' in data: - return data['token'] + if "entitlement_token" in data: + return data["entitlement_token"] + elif "entitlementToken" in data: + return data["entitlementToken"] + elif "token" in data: + return data["token"] else: raise KeyError("No entitlement token found in response") @@ -940,32 +1056,40 @@ 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'): - url = self.endpoint_manager.get_endpoint('channel_playlist').format(channel_id=channel_id) + 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 + ) else: url = f"https://api.magentatv.de/v1/channel/{channel_id}/playlist" headers = { - 'Authorization': f'Bearer {entitlement_token}', - 'User-Agent': self.platform_config['user_agent'], - 'Accept': 'application/json' + "Authorization": f"Bearer {entitlement_token}", + "User-Agent": self.platform_config["user_agent"], + "Accept": "application/json", } try: response = self.http_manager.get( url, - operation='manifest', + operation="manifest", headers=headers, - timeout=DEFAULT_REQUEST_TIMEOUT + timeout=DEFAULT_REQUEST_TIMEOUT, ) response.raise_for_status() return response.json() @@ -973,8 +1097,9 @@ class Magenta2Provider(StreamingProvider): except Exception as e: raise Exception(f"Error getting playlist for {channel_id}: {e}") - def populate_streaming_data(self, channels: List[StreamingChannel], - max_retries: int = DEFAULT_MAX_RETRIES) -> List[StreamingChannel]: + def populate_streaming_data( + self, channels: List[StreamingChannel], max_retries: int = DEFAULT_MAX_RETRIES + ) -> List[StreamingChannel]: """Populate streaming data (manifest, DRM) for all channels""" self._ensure_authenticated() @@ -987,24 +1112,32 @@ class Magenta2Provider(StreamingProvider): while retries < max_retries and not success and not is_restricted: try: - logger.debug(f"Getting entitlement token for: {channel.name} (attempt {retries + 1})") + logger.debug( + f"Getting entitlement token for: {channel.name} (attempt {retries + 1})" + ) entitlement_token = self.get_entitlement_token( - content_id=channel.channel_id, - content_type=channel.content_type + content_id=channel.channel_id, content_type=channel.content_type ) logger.debug(f"Getting playlist data for: {channel.name}") playlist_data = self.get_channel_playlist( - channel.channel_id, - entitlement_token + 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')) - certificate_url = playlist_data.get('certificateUrl', playlist_data.get('certificate')) - streaming_format = playlist_data.get('streamingFormat', playlist_data.get('format', 'dash')) + 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") + ) + streaming_format = playlist_data.get( + "streamingFormat", playlist_data.get("format", "dash") + ) if manifest_url: channel.manifest = manifest_url @@ -1027,10 +1160,14 @@ 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)}") @@ -1039,46 +1176,57 @@ class Magenta2Provider(StreamingProvider): return successful_channels - def enrich_channel_data(self, channel: StreamingChannel, **kwargs) -> Optional[StreamingChannel]: + def enrich_channel_data( + self, channel: StreamingChannel, **kwargs + ) -> Optional[StreamingChannel]: """Get manifest URL for a specific channel and configure DRM""" self._ensure_authenticated() try: entitlement_token = self.get_entitlement_token( - content_id=channel.channel_id, - content_type=channel.content_type + content_id=channel.channel_id, content_type=channel.content_type ) playlist_data = self.get_channel_playlist( - channel.channel_id, - entitlement_token + 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 channel.manifest = manifest_url - channel.streaming_format = playlist_data.get('streamingFormat', playlist_data.get('format', 'dash')) + channel.streaming_format = playlist_data.get( + "streamingFormat", playlist_data.get("format", "dash") + ) - license_url = playlist_data.get('licenseUrl', playlist_data.get('license')) + license_url = playlist_data.get("licenseUrl", playlist_data.get("license")) if license_url: - widevine_url = self.endpoint_manager.get_endpoint( - 'widevine_license') if self.endpoint_manager else license_url + widevine_url = ( + self.endpoint_manager.get_endpoint("widevine_license") + if self.endpoint_manager + else license_url + ) drm_config = DRMConfig( system=DRMSystem.WIDEVINE, priority=1, license=LicenseConfig( server_url=widevine_url, - server_certificate=playlist_data.get('certificateUrl', playlist_data.get('certificate')), - req_headers=json.dumps({ - 'User-Agent': self.platform_config['user_agent'], - 'Content-Type': DRM_REQUEST_HEADERS['Content-Type'] - }), + server_certificate=playlist_data.get( + "certificateUrl", playlist_data.get("certificate") + ), + req_headers=json.dumps( + { + "User-Agent": self.platform_config["user_agent"], + "Content-Type": DRM_REQUEST_HEADERS["Content-Type"], + } + ), req_data="{CHA-RAW}", - use_http_get_request=False - ) + use_http_get_request=False, + ), ) channel.drm_config = drm_config channel.cdm_type = DRM_SYSTEM_WIDEVINE @@ -1103,23 +1251,33 @@ class Magenta2Provider(StreamingProvider): MPD URL string or None if not found """ try: - logger.debug(f"Parsing SMIL response for channel {channel_id} (length: {len(smil_content)} chars)") + logger.debug( + f"Parsing SMIL response for channel {channel_id} (length: {len(smil_content)} chars)" + ) # 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: title = error_match.group(1) abstract = error_match.group(2) - logger.warning(f"SMIL error response for channel {channel_id}: {title} - {abstract}") + logger.warning( + f"SMIL error response for channel {channel_id}: {title} - {abstract}" + ) # 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}") @@ -1134,7 +1292,9 @@ class Magenta2Provider(StreamingProvider): logger.debug(f"Found MPD URL in