From ee0848705d375467761decf7c57cf37600a19a32 Mon Sep 17 00:00:00 2001 From: Nirvana Date: Wed, 29 Oct 2025 20:23:50 +0100 Subject: [PATCH] Initial commit --- .idea/.gitignore | 3 + .../inspectionProfiles/profiles_settings.xml | 6 + .idea/misc.xml | 7 + .idea/modules.xml | 8 + .idea/script.service.ultimate.iml | 12 + .idea/vcs.xml | 6 + addon.xml | 26 + lib/streaming_providers/__init__.py | 126 ++ lib/streaming_providers/base/__init__.py | 19 + lib/streaming_providers/base/auth/__init__.py | 16 + .../base/auth/base_auth.py | 694 ++++++++++ .../base/auth/base_oauth2_auth.py | 917 +++++++++++++ .../base/auth/credential_manager.py | 583 +++++++++ .../base/auth/credentials.py | 83 ++ .../base/auth/session_manager.py | 515 ++++++++ lib/streaming_providers/base/drm/__init__.py | 18 + .../base/drm/drm_plugin.py | 46 + .../base/drm/plugin_manager.py | 318 +++++ lib/streaming_providers/base/manager.py | 457 +++++++ .../base/models/__init__.py | 5 + .../base/models/drm_models.py | 175 +++ .../base/models/proxy_models.py | 280 ++++ .../base/models/streaming_channel.py | 98 ++ .../base/network/__init__.py | 10 + .../base/network/http_manager.py | 353 ++++++ .../base/network/proxy_manager.py | 558 ++++++++ lib/streaming_providers/base/provider.py | 172 +++ .../base/settings/__init__.py | 11 + .../base/settings/kodi_settings_bridge.py | 460 +++++++ .../base/settings/models/__init__.py | 42 + .../base/settings/models/provider_settings.py | 603 +++++++++ .../base/settings/models/settings_models.py | 566 +++++++++ .../base/settings/settings_manager.py | 1129 +++++++++++++++++ .../base/utils/__init__.py | 16 + lib/streaming_providers/base/utils/logger.py | 63 + .../base/utils/manifest_parser.py | 116 ++ .../base/utils/mpd_cache.py | 238 ++++ .../base/utils/mpd_rewriter.py | 362 ++++++ lib/streaming_providers/base/utils/vfs.py | 476 +++++++ .../providers/joyn/__init__.py | 25 + .../providers/joyn/auth.py | 758 +++++++++++ .../providers/joyn/constants.py | 272 ++++ .../providers/joyn/models.py | 203 +++ .../providers/joyn/provider.py | 725 +++++++++++ .../providers/rtlplus/__init__.py | 8 + .../providers/rtlplus/auth.py | 347 +++++ .../providers/rtlplus/constants.py | 155 +++ .../providers/rtlplus/models.py | 213 ++++ .../providers/rtlplus/provider.py | 417 ++++++ .../Resource.language.en_gb/strings.po | 113 ++ resources/settings.xml | 79 ++ service.py | 916 +++++++++++++ 52 files changed, 13824 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/script.service.ultimate.iml create mode 100644 .idea/vcs.xml create mode 100644 addon.xml create mode 100644 lib/streaming_providers/__init__.py create mode 100644 lib/streaming_providers/base/__init__.py create mode 100644 lib/streaming_providers/base/auth/__init__.py create mode 100644 lib/streaming_providers/base/auth/base_auth.py create mode 100644 lib/streaming_providers/base/auth/base_oauth2_auth.py create mode 100644 lib/streaming_providers/base/auth/credential_manager.py create mode 100644 lib/streaming_providers/base/auth/credentials.py create mode 100644 lib/streaming_providers/base/auth/session_manager.py create mode 100644 lib/streaming_providers/base/drm/__init__.py create mode 100644 lib/streaming_providers/base/drm/drm_plugin.py create mode 100644 lib/streaming_providers/base/drm/plugin_manager.py create mode 100644 lib/streaming_providers/base/manager.py create mode 100644 lib/streaming_providers/base/models/__init__.py create mode 100644 lib/streaming_providers/base/models/drm_models.py create mode 100644 lib/streaming_providers/base/models/proxy_models.py create mode 100644 lib/streaming_providers/base/models/streaming_channel.py create mode 100644 lib/streaming_providers/base/network/__init__.py create mode 100644 lib/streaming_providers/base/network/http_manager.py create mode 100644 lib/streaming_providers/base/network/proxy_manager.py create mode 100644 lib/streaming_providers/base/provider.py create mode 100644 lib/streaming_providers/base/settings/__init__.py create mode 100644 lib/streaming_providers/base/settings/kodi_settings_bridge.py create mode 100644 lib/streaming_providers/base/settings/models/__init__.py create mode 100644 lib/streaming_providers/base/settings/models/provider_settings.py create mode 100644 lib/streaming_providers/base/settings/models/settings_models.py create mode 100644 lib/streaming_providers/base/settings/settings_manager.py create mode 100644 lib/streaming_providers/base/utils/__init__.py create mode 100644 lib/streaming_providers/base/utils/logger.py create mode 100644 lib/streaming_providers/base/utils/manifest_parser.py create mode 100644 lib/streaming_providers/base/utils/mpd_cache.py create mode 100644 lib/streaming_providers/base/utils/mpd_rewriter.py create mode 100644 lib/streaming_providers/base/utils/vfs.py create mode 100644 lib/streaming_providers/providers/joyn/__init__.py create mode 100644 lib/streaming_providers/providers/joyn/auth.py create mode 100644 lib/streaming_providers/providers/joyn/constants.py create mode 100644 lib/streaming_providers/providers/joyn/models.py create mode 100644 lib/streaming_providers/providers/joyn/provider.py create mode 100644 lib/streaming_providers/providers/rtlplus/__init__.py create mode 100644 lib/streaming_providers/providers/rtlplus/auth.py create mode 100644 lib/streaming_providers/providers/rtlplus/constants.py create mode 100644 lib/streaming_providers/providers/rtlplus/models.py create mode 100644 lib/streaming_providers/providers/rtlplus/provider.py create mode 100644 resources/language/Resource.language.en_gb/strings.po create mode 100644 resources/settings.xml create mode 100644 service.py diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..ab32451 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..36fdda1 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/script.service.ultimate.iml b/.idea/script.service.ultimate.iml new file mode 100644 index 0000000..1dfd243 --- /dev/null +++ b/.idea/script.service.ultimate.iml @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/addon.xml b/addon.xml new file mode 100644 index 0000000..275885b --- /dev/null +++ b/addon.xml @@ -0,0 +1,26 @@ + + + + + + + + + + executable + + + + Backend service for Ultimate streaming providers + + Provides channel lists, EPG data and manifest information for various streaming providers. + Acts as a backend for Ultimate PVR and video addons. + + all + GPL-3.0 + https://example.com/forum + https://example.com + support@example.com + https://github.com/yourrepo/ultimate-backend + + diff --git a/lib/streaming_providers/__init__.py b/lib/streaming_providers/__init__.py new file mode 100644 index 0000000..7d65d48 --- /dev/null +++ b/lib/streaming_providers/__init__.py @@ -0,0 +1,126 @@ +# lib/streaming_providers/__init__.py +import importlib +from typing import Dict, Type +import os +import sys + +# Import the centralized logger +from .base.utils.logger import logger + +AVAILABLE_PROVIDERS: Dict[str, Type] = {} + + +def _discover_providers(): + """Kodi-compatible provider discovery""" + try: + # Import the base provider class first + from .base.provider import StreamingProvider + + # Get the current package path + current_dir = os.path.dirname(__file__) + providers_dir = os.path.join(current_dir, 'providers') + + logger.info(f"Looking for providers in: {providers_dir}") + + # Check if providers directory exists + if not os.path.exists(providers_dir): + logger.error(f"Providers directory does not exist: {providers_dir}") + return + + # Add the lib directory to Python path if not already there + lib_dir = os.path.dirname(current_dir) + if lib_dir not in sys.path: + sys.path.insert(0, lib_dir) + logger.debug(f"Added lib directory to Python path: {lib_dir}") + + # Iterate through subdirectories in providers folder + for item in os.listdir(providers_dir): + 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('__'): + continue + + # Check if __init__.py exists in the provider directory + 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 + + try: + logger.debug(f"Attempting to import provider: {item}") + + # Import the provider module using absolute import + 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): + AVAILABLE_PROVIDERS[item] = attr + logger.info(f"Discovered provider: {item} -> {attr_name}") + provider_found = True + break + + if not provider_found: + logger.warning(f"No valid provider class found in {item}") + + except ImportError as e: + logger.error(f"Could not import provider {item}: {e}") + logger.debug(f"Python path: {sys.path}") + except Exception as e: + logger.error(f"Error processing provider {item}: {e}") + + except Exception as e: + logger.error(f"Error during provider discovery: {e}") + logger.debug(f"Current working directory: {os.getcwd()}") + logger.debug(f"Python path: {sys.path}") + + +def get_configured_manager(country: str = 'de') -> 'ProviderManager': + """ + Get manager with settings-aware providers + + Args: + country: Default country code for providers without country detection (default: 'de') + + Returns: + Configured ProviderManager instance with all detected providers + """ + from .base.manager import ProviderManager + from .base.settings.settings_manager import SettingsManager + + # Create manager + manager = ProviderManager() + + # Initialize settings manager to detect providers + settings_manager = SettingsManager(enable_kodi_integration=True) + + # Detect providers from Kodi (if available) or use all available providers + detected_providers = None + + 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: + logger.info(f"Not in Kodi environment, will register all available providers with default country '{country}'") + + # Use the new discover_providers method with detected providers + registered = manager.discover_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 diff --git a/lib/streaming_providers/base/__init__.py b/lib/streaming_providers/base/__init__.py new file mode 100644 index 0000000..108b609 --- /dev/null +++ b/lib/streaming_providers/base/__init__.py @@ -0,0 +1,19 @@ +# streaming_providers/base/__init__.py +""" +Base module for streaming providers + +This module contains the core abstractions and models used by all providers. +""" + +from .models import StreamingChannel +from .provider import StreamingProvider +from .manager import ProviderManager +from .drm import DRMPlugin, DRMPluginManager + +__all__ = [ + "StreamingChannel", + "StreamingProvider", + "ProviderManager", + "DRMPlugin", + "DRMPluginManager", +] diff --git a/lib/streaming_providers/base/auth/__init__.py b/lib/streaming_providers/base/auth/__init__.py new file mode 100644 index 0000000..23916d7 --- /dev/null +++ b/lib/streaming_providers/base/auth/__init__.py @@ -0,0 +1,16 @@ +# 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 + +# Only export what consumers should use +__all__ = [ + 'BaseAuthenticator', + 'BaseAuthToken', + 'BaseCredentials', + 'UserPasswordCredentials', + 'ClientCredentials', + 'SessionManager', + 'CredentialManager' +] \ No newline at end of file diff --git a/lib/streaming_providers/base/auth/base_auth.py b/lib/streaming_providers/base/auth/base_auth.py new file mode 100644 index 0000000..814bc2a --- /dev/null +++ b/lib/streaming_providers/base/auth/base_auth.py @@ -0,0 +1,694 @@ +# streaming_providers/base/auth/base_auth.py (Country-Aware) +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Dict, Optional, Any +from enum import Enum +import time + +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 + UNKNOWN = "unknown" # Cannot determine + + +@dataclass +class BaseAuthToken(ABC): + """Base class for authentication tokens with enhanced metadata""" + access_token: str + token_type: str + expires_in: int + issued_at: float + refresh_token: Optional[str] = None + refresh_expires_in: int = 0 + + # Token metadata for upgrade logic + auth_level: TokenAuthLevel = TokenAuthLevel.UNKNOWN + credential_type: Optional[str] = None # Type of credentials used + + @property + def is_expired(self) -> bool: + """Check if token is expired (with 5 minute buffer)""" + return time.time() >= (self.issued_at + self.expires_in - 300) + + def needs_refresh(self) -> bool: + """Check if token should be refreshed""" + if not self.refresh_token: + return False + + current_time = time.time() + access_token_expiry = self.issued_at + self.expires_in + needs_access_refresh = current_time > (access_token_expiry - 300) + + refresh_token_valid = True + if self.refresh_expires_in > 0: + refresh_token_expiry = self.issued_at + self.refresh_expires_in + refresh_token_valid = current_time < (refresh_token_expiry - 300) + + return needs_access_refresh and refresh_token_valid + + def is_anonymous(self) -> bool: + """Check if this is an anonymous token""" + return self.auth_level == TokenAuthLevel.ANONYMOUS + + def is_client_credentials(self) -> bool: + """Check if this is a client credentials token""" + return self.auth_level == TokenAuthLevel.CLIENT_CREDENTIALS + + def is_user_authenticated(self) -> bool: + """Check if this is a user-authenticated token""" + return self.auth_level == TokenAuthLevel.USER_AUTHENTICATED + + def can_be_upgraded(self) -> bool: + """Check if this token can be upgraded to a higher auth level""" + return self.auth_level in [ + TokenAuthLevel.ANONYMOUS, + TokenAuthLevel.CLIENT_CREDENTIALS, + TokenAuthLevel.UNKNOWN + ] + + @property + def bearer_token(self) -> str: + """Get the bearer token string""" + return self.access_token + + @abstractmethod + def to_dict(self) -> Dict[str, Any]: + """Convert token to dictionary representation""" + pass + + +class BaseAuthenticator(ABC): + """ + Abstract base class for provider authenticators + 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): + """ + Initialize authenticator + + Args: + provider_name: Name of the streaming provider + settings_manager: Injected settings manager (SettingsManager or compatible) + credentials: Optional credentials to use (overrides all other sources) + country: Optional country code (e.g., 'de', 'at', 'ch') for country-specific sessions + config_dir: Optional config directory override (for backward compatibility) + enable_kodi_integration: Whether to enable Kodi settings integration (for backward compatibility) + """ + self.provider_name = provider_name + self.country = country + self._current_token: Optional[BaseAuthToken] = None + + # Log country configuration + if self.country: + logger.info(f"Initializing {provider_name} authenticator for country: {country}") + else: + 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: + self.settings_manager = settings_manager + 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}") + + # Register provider with settings manager + if hasattr(self.settings_manager, 'register_provider'): + self.settings_manager.register_provider(provider_name) + + # Load credentials with priority: + # 1. Provided credentials (highest priority) + # 2. Settings manager + if credentials: + self.credentials = credentials + logger.info(f"Using provided credentials for {provider_name}") + else: + self.credentials = self._load_credentials_from_manager() + + # Load existing session/token + self._load_session() + + 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 + ) + logger.debug(f"Created SettingsManager for {self.provider_name}") + return settings_manager + + except ImportError as e: + logger.warning(f"Could not import SettingsManager: {e}") + # 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): + """Create fallback using adapter pattern""" + try: + 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 + ) + logger.debug(f"Created adapter fallback for {self.provider_name}") + return adapter + + except ImportError as e: + logger.warning(f"Could not create adapter fallback: {e}") + # Create minimal fallback + return self._create_minimal_fallback(config_dir) + + def _create_minimal_fallback(self, config_dir: Optional[str] = None): + """Create minimal fallback manager""" + try: + # Try to create basic managers directly + from .session_manager import SessionManager + from .credential_manager import CredentialManager + + 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 + + def get_provider_credentials(self, provider_name, country=None): + if self.credential_manager: + return self.credential_manager.load_credentials(provider_name, country) + return 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 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) + + def get_device_id(self, provider_name, country=None): + return self.session_manager.get_device_id(provider_name, country) + + def clear_token(self, provider_name, country=None): + return self.session_manager.clear_token(provider_name, country) + + def get_credential_info(self, provider_name, country=None): + return { + 'provider_name': provider_name, + 'country': country, + 'source': 'minimal_fallback', + 'config_dir': config_dir + } + + def register_provider(self, provider_name): + # No-op for minimal fallback + return True + + return MinimalSettingsManager(config_dir) + + except ImportError: + # Absolute minimal fallback + return self._create_absolute_minimal_fallback() + + def _create_absolute_minimal_fallback(self): + """Create absolute minimal fallback when nothing else works""" + + class AbsoluteMinimalManager: + def get_provider_credentials(self, provider_name, country=None): + return None + + def save_provider_credentials(self, provider_name, credentials, country=None): + return False + + def load_token_data(self, provider_name, country=None): + return None + + def save_token_data(self, provider_name, token_data, country=None): + return False + + 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'} + + def register_provider(self, provider_name): + return True + + logger.warning(f"Using absolute minimal fallback for {self.provider_name}") + return AbsoluteMinimalManager() + + def _load_credentials_from_manager(self): + """Load credentials from the settings manager""" + try: + # Check if it's a SettingsManager instance (or compatible) + 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) + 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) + else: + 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}") + return None + + # Abstract methods remain unchanged + @property + @abstractmethod + def auth_endpoint(self) -> str: + """Authentication endpoint URL""" + pass + + @abstractmethod + def _get_auth_headers(self) -> Dict[str, str]: + """Get headers for authentication request""" + pass + + @abstractmethod + def _build_auth_payload(self) -> Dict[str, Any]: + """Build authentication payload""" + pass + + @abstractmethod + def _create_token_from_response(self, response_data: Dict[str, Any]) -> BaseAuthToken: + """Create token object from API response""" + pass + + @abstractmethod + def get_fallback_credentials(self): + """Get fallback credentials when no user credentials are available""" + pass + + @abstractmethod + def _perform_authentication(self) -> BaseAuthToken: + """Perform the actual authentication request""" + pass + + # Session management methods + def _load_session(self) -> None: + """Load token from persistent storage""" + try: + country_str = f" (country: {self.country})" if self.country else "" + logger.debug(f"Loading session for {self.provider_name}{country_str}") + + # Try country-aware call first + try: + 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") + 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}") + else: + logger.info(f"No existing session found for {self.provider_name}{country_str}") + self._current_token = None + + except Exception as e: + logger.error(f"Error loading session for {self.provider_name}: {e}") + self._current_token = None + + def _save_session(self) -> None: + """Save current token to persistent storage""" + if self._current_token: + try: + country_str = f" (country: {self.country})" if self.country else "" + + # Try country-aware call first + try: + success = self.settings_manager.save_token_data( + 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") + success = self.settings_manager.save_token_data( + 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}") + except Exception as e: + logger.error(f"Error saving session for {self.provider_name}: {e}") + + def _ensure_credentials(self) -> bool: + """Ensure we have valid credentials""" + # Try to refresh credentials from settings manager + if not self.credentials or not self.credentials.validate(): + fresh_credentials = self._load_credentials_from_manager() + if fresh_credentials and fresh_credentials.validate(): + self.credentials = fresh_credentials + logger.debug(f"Refreshed credentials for {self.provider_name}") + + # 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") + self.credentials = self.get_fallback_credentials() + + return self.credentials is not None and self.credentials.validate() + + # Authentication methods remain mostly unchanged + 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}") + + # Check current token state for debugging + if self._current_token: + logger.debug( + f"[{self.provider_name}{country_str}] Current token - is_expired: {self._current_token.is_expired}, " + f"has_refresh: {bool(self._current_token.refresh_token)}") + 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") + return self._current_token + + # 2. Try refresh if available + if (not force_refresh and + self._current_token and + self._current_token.refresh_token and + self._current_token.needs_refresh()): + + logger.info(f"[{self.provider_name}{country_str}] Attempting token refresh") + try: + refreshed_token = self._refresh_token() + 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") + return self._current_token + else: + logger.debug( + f"[{self.provider_name}{country_str}] Refresh returned None, falling back to full auth") + except Exception as e: + logger.warning( + f"[{self.provider_name}{country_str}] Token refresh failed: {e}, attempting new authentication") + + # 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") + token = self._perform_authentication() + self._current_token = token + self._save_session() + logger.info(f"[{self.provider_name}{country_str}] Authentication successful") + return token + + # Credential management methods + def save_credentials(self, credentials, sync_to_kodi: bool = False) -> bool: + """Save credentials to persistent storage""" + try: + # Try country-aware call first + try: + success = self.settings_manager.save_provider_credentials( + 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") + success = self.settings_manager.save_provider_credentials( + self.provider_name, + credentials + ) + + if success: + self.credentials = credentials + country_str = f" (country: {self.country})" if self.country else "" + logger.info(f"Saved credentials for {self.provider_name}{country_str}") + return success + except Exception as e: + logger.error(f"Error saving credentials for {self.provider_name}: {e}") + return False + + def sync_credentials_from_kodi(self) -> bool: + """ + Manually sync credentials from Kodi settings (for backward compatibility) + """ + try: + 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}") + + return success + else: + 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}") + 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 + } + + # 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) + except TypeError: + 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}") + + return base_info + + # Backward compatibility aliases + def get_credential_source_info(self) -> Dict[str, Any]: + """Alias for get_credential_info for backward compatibility""" + return self.get_credential_info() + + def clear_stored_credentials(self) -> bool: + """Clear stored credentials and revert to fallback credentials""" + try: + self.credentials = self.get_fallback_credentials() + + # Try to clear through settings manager + success = True + 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 + ) + except TypeError: + 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}") + + if success: + 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") + return success + except Exception as e: + logger.error(f"Error clearing stored credentials for {self.provider_name}: {e}") + return False + + def has_stored_credentials(self) -> bool: + """Check if stored credentials exist""" + try: + # Try country-aware call first + try: + credentials = self.settings_manager.get_provider_credentials(self.provider_name, self.country) + except TypeError: + 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}") + return False + + def test_current_credentials(self) -> bool: + """Test current credentials by attempting authentication""" + try: + country_str = f" (country: {self.country})" if self.country else "" + logger.debug(f"Testing credentials for {self.provider_name}{country_str}") + self.authenticate(force_refresh=True) + logger.info(f"Credential test passed for {self.provider_name}{country_str}") + return True + except Exception as e: + logger.warning(f"Credential test failed for {self.provider_name}: {e}") + return False + + # Token management methods + def _refresh_token(self) -> Optional[BaseAuthToken]: + """Refresh the current token using refresh token""" + return None + + def get_bearer_token(self, force_refresh: bool = False) -> str: + """Get current bearer token, authenticating if necessary""" + token = self.authenticate(force_refresh) + return token.bearer_token + + def is_authenticated(self) -> bool: + """Check if currently authenticated with valid token""" + return self._current_token is not None and not self._current_token.is_expired + + def invalidate_token(self) -> None: + """Invalidate current token""" + country_str = f" (country: {self.country})" if self.country else "" + logger.debug(f"Invalidating token for {self.provider_name}{country_str}") + self._current_token = None + + try: + # Try country-aware call first + try: + self.settings_manager.clear_token(self.provider_name, self.country) + 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}") + + 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) + 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]]: + """Get information about the current token""" + if not self._current_token: + 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 + } + + base_info.update(self._current_token.to_dict()) + return base_info + + 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() + } + + @abstractmethod + def _classify_token(self, token: BaseAuthToken) -> TokenAuthLevel: + """ + Classify the authentication level of a token + + Must be implemented by subclasses to provide provider-specific logic + for determining if a token is anonymous, client credentials, or user-authenticated. + + Args: + token: Token to classify + + Returns: + TokenAuthLevel indicating the authentication level + """ + pass + + def should_upgrade_token(self, token: BaseAuthToken) -> bool: + """ + Determine if a token should be upgraded based on: + 1. Token's current authentication level + 2. Availability of user credentials + 3. Provider-specific upgrade policy + + Args: + token: Current token to evaluate + + Returns: + True if token should be upgraded, False otherwise + """ + if not token: + return False + + # Classify token if not already classified + if token.auth_level == TokenAuthLevel.UNKNOWN: + token.auth_level = self._classify_token(token) + + # Check if token can be upgraded + if not token.can_be_upgraded(): + return False + + # Check if we have user credentials available + from ...base.auth.credentials import UserPasswordCredentials + + # Check stored credentials first + try: + 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) + + 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() + + # Only upgrade if we have user credentials + return has_user_creds \ No newline at end of file diff --git a/lib/streaming_providers/base/auth/base_oauth2_auth.py b/lib/streaming_providers/base/auth/base_oauth2_auth.py new file mode 100644 index 0000000..90124e0 --- /dev/null +++ b/lib/streaming_providers/base/auth/base_oauth2_auth.py @@ -0,0 +1,917 @@ +# 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 html +from urllib.parse import urlencode, parse_qs, urlparse + +from .base_auth import BaseAuthenticator, BaseAuthToken, TokenAuthLevel +from ..utils.logger import logger +from ..models.proxy_models import ProxyConfig + + +class OAuth2Error(Exception): + """OAuth2-specific error with structured error information""" + + 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 + message = error + if error_description: + message = f"{error}: {error_description}" + super().__init__(message) + + +class SessionAwareHTTPManager: + """Wraps http_manager to provide session-like cookie handling while maintaining proxy support""" + + def __init__(self, http_manager): + self.http_manager = http_manager + self.cookies = {} + self.headers = {} + + def get(self, url: str, **kwargs): + """GET request with cookie handling""" + 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 + + kwargs['headers'] = headers + response = self.http_manager.get(url, operation='oauth', **kwargs) + + # Update cookies from response + self._update_cookies_from_response(response) + return response + + def post(self, url: str, **kwargs): + """POST request with cookie handling""" + 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 + + kwargs['headers'] = headers + response = self.http_manager.post(url, operation='oauth', **kwargs) + + # Update cookies from response + self._update_cookies_from_response(response) + return response + + def _update_cookies_from_response(self, response): + """Extract and update cookies from response""" + 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): + # Pass country to parent BaseAuthenticator + super().__init__( + provider_name, + settings_manager, + credentials, + country=country, # ADD THIS LINE + config_dir=config_dir, + 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'): + self._config = None + + self._proxy_config = proxy_config + self._auth_endpoint = None + self._http_manager = http_manager + self._token_expiry_buffer = 300 + + @property + def http_manager(self): + """Safe access to http_manager - use provided one or create fallback""" + if self._http_manager is not None: + return self._http_manager + + 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) + ) + except Exception as e: + 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 + + @http_manager.setter + def http_manager(self, value): + """Allow setting http_manager""" + self._http_manager = value + + @property + def config(self): + """Safe access to config with fallback""" + import traceback + + if self._config is not None: + return self._config + + # Log who's calling this before config is set + 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.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', + } + + 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") + return self._config + + @config.setter + def config(self, value): + """Allow subclasses to set config""" + self._config = value + + @staticmethod + def _create_minimal_http_manager(): + """Create absolute minimal HTTP manager fallback""" + + class MinimalHTTPManager: + @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() + + @property + def auth_endpoint(self) -> str: + """Get authentication endpoint - subclasses can override""" + if hasattr(self, '_auth_endpoint') and self._auth_endpoint: + return self._auth_endpoint + + if hasattr(self.config, 'auth_endpoint'): + return self.config.auth_endpoint + + raise NotImplementedError("Subclass must implement auth_endpoint or set _auth_endpoint") + + @auth_endpoint.setter + def auth_endpoint(self, value): + """Allow setting auth_endpoint directly""" + self._auth_endpoint = value + + # Abstract properties + @property + @abstractmethod + def oauth_client_id(self) -> str: + pass + + @property + @abstractmethod + def oauth_scope(self) -> str: + pass + + @property + @abstractmethod + def oauth_redirect_uri(self) -> str: + pass + + @property + def oauth_authorize_endpoint(self) -> str: + """Get OAuth2 authorization 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") + 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') + else: + 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') + 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') + logger.debug(f"Generated PKCE challenge: {challenge_b64}") + return challenge_b64 + + # OAuth2 State Management + def generate_oauth_state(self) -> str: + """Generate secure state parameter for OAuth2 flow""" + state = str(uuid.uuid4()) + self._oauth_state = state + return state + + @staticmethod + def generate_oauth_nonce() -> str: + """Generate secure nonce parameter for OAuth2 flow""" + return str(uuid.uuid4()) + + @staticmethod + def validate_oauth_state(received_state: str, original_state: str) -> bool: + """Validate OAuth2 state parameter to prevent CSRF""" + if not received_state or not original_state: + logger.warning("OAuth2 state validation failed: missing state parameters") + return False + + is_valid = received_state == original_state + if not is_valid: + logger.warning("OAuth2 state validation failed: state mismatch") + + return is_valid + + # Session Management + 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', '') + }) + return session + + # Complete Client Credentials Flow + def _perform_oauth_client_credentials_flow(self) -> Dict[str, Any]: + """ + Complete manual implementation of OAuth2 client credentials flow + Uses provider-specific headers and payload formatting + """ + try: + 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._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}") + return token_data + + except OAuth2Error: + raise + except Exception as 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]: + """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' + } + + if extra_params: + params.update(extra_params) + + authorization_url = f"{self.oauth_authorize_endpoint}?{urlencode(params)}" + + 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]: + """ + 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}") + + # Allow subclasses to override the default payload + data = self._build_token_exchange_payload( + authorization_code=authorization_code, + code_verifier=code_verifier, + state=state, + **kwargs + ) + + # Allow subclasses to override headers + headers = self._get_token_exchange_headers(**kwargs) + + # Allow subclasses to override data format and endpoint + endpoint = self._get_token_exchange_endpoint(**kwargs) + use_json = self._should_use_json_for_token_exchange(**kwargs) + + request_kwargs = { + 'operation': 'auth', + 'headers': headers, + 'timeout': getattr(self.config, 'timeout', 30) + } + + if use_json: + request_kwargs['json_data'] = data + else: + request_kwargs['data'] = urlencode(data).encode() + + 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}") + return token_data + + except OAuth2Error: + raise + except Exception as 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]: + """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 + } + + client_secret = getattr(self.credentials, 'client_secret', None) + if client_secret: + data['client_secret'] = client_secret + + return data + + def _get_token_exchange_headers(self, **kwargs) -> Dict[str, str]: + """Get token exchange headers - subclasses can override for custom headers""" + 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' + else: + headers['Content-Type'] = 'application/x-www-form-urlencoded' + + return headers + + def _get_token_exchange_endpoint(self, **kwargs) -> str: + """Get token exchange endpoint - subclasses can override for custom endpoints""" + return self.auth_endpoint + + @staticmethod + def _should_use_json_for_token_exchange(**kwargs) -> bool: + """Determine if token exchange should use JSON - subclasses can override""" + return False # Default to form-encoded for OAuth2 compliance + + # 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 + ) -> Dict[str, Any]: + """ + Generic OAuth2 form-based login flow + + Args: + username: User's username + password: User's password + form_selector_pattern: Regex to find login form action URL + login_fields: Field names mapping (e.g., {'username': 'email', 'password': 'pass'}) + extra_params: Additional authorization URL parameters + additional_form_data: Additional form fields to submit + + Returns: + Token data dictionary + """ + try: + auth_url, state, code_verifier = self._build_authorization_url(extra_params) + + session = self._create_oauth_session() + + # Step 1: Get login form + auth_response = session.get(auth_url, timeout=self.config.timeout) + auth_response.raise_for_status() + + # 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}") + + login_url = html.unescape(form_matches[0]) + + # Step 3: Build login data + login_data = {} + 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 + + # Step 4: Submit login credentials + login_response = session.post( + login_url, + data=login_data, + timeout=self.config.timeout, + 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') + 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_url = redirect_response.url + + # Step 6: Validate and extract authorization code + 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}") + + # Step 7: Exchange code for token + token_data = self._exchange_authorization_code_for_token( + authorization_code=authorization_code, + code_verifier=code_verifier, + state=state + ) + + return token_data + + except Exception as e: + raise Exception(f"OAuth2 form-based login failed: {e}") + + # Token Refresh + def _refresh_oauth_token(self) -> Optional[BaseAuthToken]: + """Complete manual token refresh implementation""" + if not self._current_token or not self._current_token.refresh_token: + logger.debug(f"No refresh token available for {self.provider_name}") + return None + + try: + 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, + } + + client_secret = getattr(self.credentials, 'client_secret', None) + if 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._check_oauth_error_response(response) + response.raise_for_status() + + new_token_data = response.json() + refreshed_token = self._create_token_from_response(new_token_data) + logger.info(f"OAuth2 token refresh successful for {self.provider_name}") + return refreshed_token + + except OAuth2Error as e: + logger.warning(f"OAuth2 token refresh failed for {self.provider_name}: {e}") + return None + except Exception as e: + logger.warning(f"OAuth2 token refresh failed for {self.provider_name}: {e}") + return None + + # Error Response Handling + @staticmethod + def _check_oauth_error_response(response): + """Check response for OAuth2 error and raise OAuth2Error if found""" + try: + if response.status_code >= 400: + try: + error_data = response.json() + 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') + ) + except (ValueError, KeyError): + pass + except OAuth2Error: + raise + except Exception: + pass + + # Dynamic Client ID Extraction + def _extract_client_id_from_js( + self, + main_page_url: str, + js_file_pattern: str, + client_id_pattern: str + ) -> Optional[str]: + """ + Extract client ID from provider's JavaScript + + Args: + main_page_url: URL of the main page containing script references + js_file_pattern: Regex pattern to find the JS file URL + client_id_pattern: Regex pattern to extract client ID from JS content + + Returns: + Extracted client ID or None + """ + try: + headers = self.config.get_base_headers() + + response = self.http_manager.get( + main_page_url, + operation='api', + headers=headers + ) + response.raise_for_status() + + js_matches = re.findall(js_file_pattern, response.text) + if not js_matches: + logger.warning(f"Could not find JS file using pattern: {js_file_pattern}") + return None + + js_url = main_page_url.rstrip('/') + '/' + js_matches[-1].lstrip('/') + + js_response = self.http_manager.get(js_url, operation='api', headers=headers) + js_response.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}") + return None + + return client_id_match.group(1) + + except Exception as e: + logger.error(f"Error extracting client ID from JS: {e}") + return None + + # 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]] + ) -> Optional[Dict[str, Any]]: + """ + Generic JS config extraction + + Args: + main_page_url: URL of the main page + js_file_pattern: Regex to find JS file + config_pattern: Regex to extract config section + parse_function: Function to parse the config string into a dict + + Returns: + Parsed configuration dictionary or None + """ + try: + headers = self.config.get_base_headers() + + response = self.http_manager.get( + main_page_url, + operation='api', + headers=headers + ) + response.raise_for_status() + + js_matches = re.findall(js_file_pattern, response.text) + if not js_matches: + return None + + js_url = main_page_url.rstrip('/') + '/' + js_matches[-1].lstrip('/') + + js_response = self.http_manager.get(js_url, operation='api', headers=headers) + js_response.raise_for_status() + + config_match = re.search(config_pattern, js_response.text) + if not config_match: + return None + + config_str = config_match.group(1) + return parse_function(config_str) + + except Exception as e: + logger.error(f"Error extracting config from JS: {e}") + return None + + # Token Upgrade Support + def _should_upgrade_to_user_token(self, token: BaseAuthToken) -> bool: + """ + Check if token should be upgraded - now uses the base class logic + + Override in subclass only if provider has specific upgrade rules + """ + return self.should_upgrade_token(token) + + def _get_effective_credentials(self): + """ + Get effective credentials with priority: + 1. Stored user credentials (if available) + 2. Current credentials if valid + 3. Fallback credentials + """ + 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) + if stored_creds and isinstance(stored_creds, UserPasswordCredentials): + if stored_creds.validate(): + return stored_creds + + # Then use current credentials + if self.credentials and self.credentials.validate(): + return self.credentials + + # Finally fallback + fallback = self.get_fallback_credentials() + self.credentials = fallback + return fallback + + def get_bearer_token(self, force_refresh: bool = False, force_upgrade: bool = False) -> str: + """ + Get bearer token with automatic upgrade support + + Args: + force_refresh: Force token refresh even if not expired + force_upgrade: Force token upgrade attempt regardless of current level + + Returns: + Bearer token string + """ + 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) + + # Classify token if needed + if current_token.auth_level == TokenAuthLevel.UNKNOWN: + current_token.auth_level = self._classify_token(current_token) + 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) + + 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)})") + + original_credentials = self.credentials + + try: + # Get effective credentials (prioritizes stored user credentials) + self.credentials = self._get_effective_credentials() + + if not self.credentials or not self.credentials.validate(): + logger.debug("No valid credentials for upgrade") + return current_token.bearer_token + + # Perform authentication with new credentials + user_token = self._perform_authentication() + + if user_token and not user_token.is_expired: + # Classify the new token + user_token.auth_level = self._classify_token(user_token) + + # Verify it's actually an upgrade + if user_token.is_user_authenticated(): + logger.info("Successfully upgraded to user token") + self._current_token = user_token + self._save_session() + return user_token.bearer_token + else: + logger.warning( + f"Authentication succeeded but token is not user level: {user_token.auth_level.value}") + self.credentials = original_credentials + return current_token.bearer_token + else: + logger.warning("User authentication failed, keeping current token") + self.credentials = original_credentials + return current_token.bearer_token + + except Exception as e: + logger.error(f"Token upgrade failed: {e}") + self.credentials = original_credentials + return current_token.bearer_token + + return current_token.bearer_token if current_token else "" + + # Main Authentication Flow + def _perform_authentication(self) -> BaseAuthToken: + """Complete OAuth2 authentication based on credential type""" + from ...base.auth.credentials import UserPasswordCredentials, ClientCredentials + + logger.debug( + 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}") + token_data = self._perform_oauth_authorization_code_flow( + self.credentials.username, + self.credentials.password + ) + elif isinstance(self.credentials, ClientCredentials): + 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)}") + + 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}") + + if isinstance(original_credentials, UserPasswordCredentials): + 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}") + 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}") + raise e + else: + raise e + + # Token Management + @abstractmethod + def _create_token_from_response(self, response_data: Dict[str, Any]) -> BaseAuthToken: + """Create provider-specific token from OAuth2 response""" + pass + + def _refresh_token(self) -> Optional[BaseAuthToken]: + """Override base refresh to use manual OAuth2 refresh flow""" + return self._refresh_oauth_token() + + # Status and Diagnostics + def get_authentication_status(self) -> Dict[str, Any]: + """Get comprehensive OAuth2 authentication status information""" + 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), + } + + 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(), + }) + + status.update(oauth_status) + return status + + # Utility Methods + @staticmethod + def extract_authorization_code_from_url(url: str) -> Optional[str]: + """Extract authorization code from callback URL""" + try: + parsed = urlparse(url) + query_params = parse_qs(parsed.query) + 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]]: + """ + Validate OAuth2 authentication response + Returns: (is_valid, error_message, authorization_code) + """ + try: + 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] + return False, f"{error}: {error_description}", None + + 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] + if not authorization_code: + return False, "No authorization code in response", None + + return True, None, authorization_code + + except Exception as e: + return False, f"Error processing authentication response: {e}", None + + # Abstract method for provider-specific authorization code flow + @abstractmethod + 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 diff --git a/lib/streaming_providers/base/auth/credential_manager.py b/lib/streaming_providers/base/auth/credential_manager.py new file mode 100644 index 0000000..df01505 --- /dev/null +++ b/lib/streaming_providers/base/auth/credential_manager.py @@ -0,0 +1,583 @@ +# 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 + +# Import centralized logger and VFS +from ..utils.logger import logger + + +class CredentialManager: + """ + Manages loading and saving credentials from/to persistent storage + Now supports country-specific credentials + """ + + 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' + + # Ensure base directory exists + 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: + """ + Determine the path to credential data based on country + + Args: + provider: Provider name + country: Optional country code (e.g., 'de', 'at', 'ch') + + Returns: + Tuple of (keys_path, is_nested) where keys_path is list of keys to navigate + """ + if country: + return [provider, country], True + else: + return [provider], False + + def load_credentials(self, provider: str, country: Optional[str] = None) -> Optional[BaseCredentials]: + """ + Load credentials for a specific provider and optional country + + Args: + provider: Provider name (e.g., 'rtlplus', 'joyn') + country: Optional country code (e.g., 'de', 'at', 'ch') + + Returns: + BaseCredentials instance or None if not found + """ + country_str = f" (country: {country})" if country else "" + try: + 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}") + return None + + 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") + return None + + logger.debug(f"CredentialManager: Available providers in file: {list(data.keys())}") + + # Navigate to the correct credential data + keys_path, is_nested = self._get_credential_path(provider, country) + 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 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}{country_str}'") + return None + + 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}") + + 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}") + + creds = UserPasswordCredentials( + username=username, + password=self._decode_password(provider_data.get('password', '')), + client_id=client_id, + grant_type=provider_data.get('grant_type', 'password') + ) + + logger.debug( + 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 + + logger.debug(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') + ) + + 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}") + return None + + except Exception as e: + country_str = f" (country: {country})" if country else "" + logger.error(f"CredentialManager: Error loading credentials for '{provider}{country_str}': {e}") + return None + + def save_credentials(self, provider: str, credentials: BaseCredentials, + country: Optional[str] = None) -> bool: + """ + Save credentials for a specific provider and optional country + + Args: + provider: Provider name + credentials: Credentials to save + country: Optional country code + + Returns: + True if successful, False otherwise + """ + country_str = f" (country: {country})" if country else "" + try: + logger.debug(f"Saving {credentials.credential_type} credentials for {provider}{country_str}") + + # Load existing data + data = {} + if self.vfs.exists(self.credentials_file): + data = self.vfs.read_json(self.credentials_file) or {} + + # 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 + } + if 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 + } + else: + logger.error(f"Unsupported credential type: {type(credentials)}") + return False + + # Navigate and create nested structure if needed + keys_path, is_nested = self._get_credential_path(provider, country) + + # Build nested structure + current = data + for i, key in enumerate(keys_path[:-1]): + if key not in current: + current[key] = {} + elif not isinstance(current[key], dict): + logger.warning(f"Overwriting non-dict value at {key}") + current[key] = {} + current = current[key] + + # Set the final value + current[keys_path[-1]] = provider_data + + # Save to file using VFS + success = self.vfs.write_json(self.credentials_file, data) + + if success: + logger.info(f"Successfully saved credentials for {provider}{country_str}") + + # Verify by reading back + verify_data = self.vfs.read_json(self.credentials_file) + if verify_data: + verify_current = verify_data + found = True + for key in keys_path: + 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") + else: + logger.error(f"Verification failed: {provider}{country_str} data NOT found in saved file") + return False + + return True + else: + logger.error(f"Failed to save credentials for {provider}{country_str}") + return False + + except Exception as e: + country_str = f" (country: {country})" if country else "" + logger.error(f"Error saving credentials for {provider}{country_str}: {e}") + return False + + def delete_credentials(self, provider: str, country: Optional[str] = None) -> bool: + """ + Delete credentials for a specific provider and optional country + + Args: + provider: Provider name + country: Optional country code (if None, deletes entire provider or all countries) + + Returns: + True if successful, False otherwise + """ + country_str = f" (country: {country})" if country else "" + try: + + if not self.vfs.exists(self.credentials_file): + 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}") + return True + + if country: + # Delete specific country data + 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}") + + # 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") + + return self.vfs.write_json(self.credentials_file, data) + else: + logger.debug(f"No stored credentials found to delete for {provider}{country_str}") + else: + # Delete entire provider data (all countries) + if provider in data: + del data[provider] + 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}") + + return True + + except Exception as e: + country_str = f" (country: {country})" if country else "" + logger.error(f"Error deleting credentials for {provider}{country_str}: {e}") + return False + + def list_providers(self) -> List[str]: + """ + List all providers with stored credentials + + Returns: + List of provider names + """ + try: + if not self.vfs.exists(self.credentials_file): + logger.debug("No credentials file exists, returning empty provider list") + return [] + + data = self.vfs.read_json(self.credentials_file) + if not data: + logger.debug("Credentials file is empty, returning empty provider list") + return [] + + providers = list(data.keys()) + logger.debug(f"Found stored credentials for providers: {providers}") + return providers + + except Exception as e: + logger.error(f"Error listing providers: {e}") + return [] + + def get_all_countries(self, provider: str) -> List[str]: + """ + Get all countries that have credentials for a provider + + Args: + provider: Provider name + + Returns: + List of country codes + """ + try: + if not self.vfs.exists(self.credentials_file): + return [] + + data = self.vfs.read_json(self.credentials_file) + if not data or provider not in data: + return [] + + provider_data = data[provider] + + # Check if this is a nested (country-aware) structure + if isinstance(provider_data, dict): + # Check if any keys look like country codes (2-3 char strings) + # 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: + countries.append(key) + return countries + + return [] + + except Exception as e: + logger.error(f"Error getting countries for {provider}: {e}") + return [] + + def has_credentials(self, provider: str, country: Optional[str] = None) -> bool: + """ + Check if credentials exist for a provider and optional country + + Args: + provider: Provider name + country: Optional country code + + Returns: + True if credentials exist, False otherwise + """ + credentials = self.load_credentials(provider, country) + return credentials is not None and credentials.validate() + + @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") + return password + + @staticmethod + def _decode_password(encoded_password: str) -> str: + """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]: + """ + Export credentials to a portable format + + Args: + provider: Optional provider name to export (if None, exports all) + country: Optional country code (only used if provider is specified) + + Returns: + Dictionary with credentials data + """ + export_data = { + 'version': '1.1', # Bumped for country support + 'exported_at': time.time(), + 'credentials': {} + } + + if provider: + # Export specific provider + if country: + # Export specific country + credentials = self.load_credentials(provider, country) + if credentials: + export_data['credentials'][provider] = { + country: self._credential_to_dict(credentials) + } + else: + # Export all countries for provider or non-country data + countries = self.get_all_countries(provider) + if countries: + # Provider has country-specific credentials + provider_data = {} + for ctry in countries: + credentials = self.load_credentials(provider, ctry) + if credentials: + provider_data[ctry] = self._credential_to_dict(credentials) + if 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) + else: + # Export all providers + for prov in self.list_providers(): + countries = self.get_all_countries(prov) + if countries: + # Provider has country-specific credentials + provider_data = {} + for ctry in countries: + credentials = self.load_credentials(prov, ctry) + if credentials: + provider_data[ctry] = self._credential_to_dict(credentials) + if 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) + + return export_data + + @staticmethod + def _credential_to_dict(credentials: BaseCredentials) -> Dict[str, Any]: + """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 + } + elif isinstance(credentials, ClientCredentials): + return { + 'type': 'client_credentials', + 'client_id': credentials.client_id, + 'client_secret': credentials.client_secret + } + else: + return {} + + def import_config(self, config_data: Dict[str, Any]) -> Dict[str, bool]: + """ + Import credentials from exported configuration + + Args: + config_data: Configuration data from export_config() + + Returns: + Dictionary mapping provider names (or provider_country) to import success status + """ + results = {} + + # Validate 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', {}) + + 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'): + # Nested structure with countries + for country, cred_data in provider_data.items(): + if not isinstance(cred_data, dict): + continue + + credentials = self._dict_to_credential(cred_data) + if credentials: + success = self.save_credentials(provider, credentials, country) + results[f"{provider}_{country}"] = success + + if success: + logger.info(f"Successfully imported credentials for {provider} ({country})") + else: + logger.error(f"Failed to import credentials for {provider} ({country})") + else: + # Flat structure (no country) + credentials = self._dict_to_credential(provider_data) + if credentials: + success = self.save_credentials(provider, credentials) + results[provider] = success + + if success: + logger.info(f"Successfully imported credentials for {provider}") + else: + logger.error(f"Failed to import credentials for {provider}") + + except Exception as e: + logger.error(f"Error importing credentials for {provider}: {e}") + results[provider] = False + + return results + + @staticmethod + def _dict_to_credential(cred_data: Dict[str, Any]) -> Optional[BaseCredentials]: + """Convert dictionary to credentials object""" + try: + 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') + ) + elif cred_data.get('type') == 'client_credentials': + return ClientCredentials( + 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')}") + return None + except Exception as e: + logger.error(f"Error creating credential from dict: {e}") + return None + + def debug_credentials_file(self) -> None: + """Debug method to log the current state of the credentials file""" + try: + logger.info(f"=== CREDENTIALS FILE DEBUG INFO ===") + + 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)}") + + if self.vfs.exists(self.credentials_file): + file_size = self.vfs.get_size(self.credentials_file) + logger.info(f"Credentials file size: {file_size} bytes") + + 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())}") + + 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 + 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}") + 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}") + else: + logger.error("Credentials file contains invalid JSON or is empty") + else: + logger.info("Credentials file is empty") + else: + logger.info("Credentials file does not exist yet") + + 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 diff --git a/lib/streaming_providers/base/auth/credentials.py b/lib/streaming_providers/base/auth/credentials.py new file mode 100644 index 0000000..c84b638 --- /dev/null +++ b/lib/streaming_providers/base/auth/credentials.py @@ -0,0 +1,83 @@ +# streaming_providers/base/auth/credentials.py +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Dict, Any, Optional + + +@dataclass +class BaseCredentials(ABC): + """ + Base class for authentication credentials + """ + + @abstractmethod + def validate(self) -> bool: + """Validate credentials""" + pass + + @abstractmethod + def to_auth_payload(self) -> Dict[str, Any]: + """Convert credentials to authentication payload""" + pass + + @property + @abstractmethod + def credential_type(self) -> str: + """Return the type of credentials for storage identification""" + pass + + +@dataclass +class UserPasswordCredentials(BaseCredentials): + """ + Username/password based credentials + """ + username: str + password: str + client_id: Optional[str] = None + grant_type: str = 'password' + + def validate(self) -> bool: + """Validate username/password credentials""" + return bool(self.username and self.password) + + def to_auth_payload(self) -> Dict[str, Any]: + """Convert to authentication payload""" + payload = { + 'grant_type': self.grant_type, + 'username': self.username, + 'password': self.password + } + if self.client_id: + payload['client_id'] = self.client_id + return payload + + @property + def credential_type(self) -> str: + return "user_password" + + +@dataclass +class ClientCredentials(BaseCredentials): + """ + Client credentials (client_id/client_secret) based authentication + """ + client_id: str + client_secret: str + grant_type: str = 'client_credentials' + + def validate(self) -> bool: + """Validate client credentials""" + return bool(self.client_id and self.client_secret) + + 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 + } + + @property + def credential_type(self) -> str: + return "client_credentials" diff --git a/lib/streaming_providers/base/auth/session_manager.py b/lib/streaming_providers/base/auth/session_manager.py new file mode 100644 index 0000000..ed5feca --- /dev/null +++ b/lib/streaming_providers/base/auth/session_manager.py @@ -0,0 +1,515 @@ +# streaming_providers/base/auth/session_manager.py +import uuid +import time +from typing import Optional, Dict, Any +from .base_auth import BaseAuthToken + +# Import centralized logger and VFS +from ..utils.logger import logger +from ..utils.vfs import VFS + + +class SessionManager: + """ + Manages persistent session data including tokens and device IDs + Compatible with both Kodi and standalone environments via VFS abstraction + Now supports country-specific sessions + """ + + def __init__(self, config_dir: Optional[str] = None): + """ + Initialize SessionManager + + Args: + config_dir: Optional config directory override (mainly for testing) + """ + # Initialize VFS with optional config directory override + if config_dir: + # For custom config directories, we'll use a VFS instance that treats + # the config_dir as the base path directly + self.vfs = VFS() + self.vfs._base_path = config_dir + else: + # Use default VFS (handles Kodi vs standard filesystem automatically) + self.vfs = VFS() + + # Session file is always in the root of the VFS base path + self.session_file = 'session.json' + + # Ensure base directory exists + 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)}") + + @staticmethod + def _get_session_path(provider: str, country: Optional[str] = None) -> tuple: + """ + Determine the path to session data based on country + + Args: + provider: Provider name + country: Optional country code (e.g., 'de', 'at', 'ch') + + Returns: + Tuple of (keys_path, is_nested) where keys_path is list of keys to navigate + """ + if country: + return [provider, country], True + else: + return [provider], False + + def load_session(self, provider: str, country: Optional[str] = None) -> Optional[Dict[str, Any]]: + """ + Load session data for a specific provider and optional country + + Args: + provider: Provider name + country: Optional country code (e.g., 'de', 'at', 'ch') + + Returns: + Session data dictionary or None + """ + country_str = f" (country: {country})" if country else "" + + try: + logger.debug(f"Attempting to load session data for {provider}{country_str}") + + data = self.vfs.read_json(self.session_file) + if not data: + logger.info(f"Session file does not exist or is empty") + return None + + # Navigate to the correct session data + keys_path, is_nested = self._get_session_path(provider, country) + session_data = data + + 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)}") + return None + session_data = session_data[key] + + if session_data: + # Log what we found (without sensitive data) + safe_data = {} + for key, value in session_data.items(): + if key in ['access_token', 'refresh_token', 'client_secret', 'password']: + safe_data[key] = f"<{key}_present>" if value else f"<{key}_missing>" + else: + safe_data[key] = value + 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") + + return session_data + + except Exception as e: + 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: + """ + Save session data for a specific provider and optional country + + Args: + provider: Provider name + session_data: Session data to save + country: Optional country code (e.g., 'de', 'at', 'ch') + + Returns: + True if successful, False otherwise + """ + country_str = f" (country: {country})" if country else "" + + try: + logger.debug(f"Attempting to save session data for {provider}{country_str}") + + # Load existing data + data = self.vfs.read_json(self.session_file) or {} + 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 session_data is a token-like object, convert to dict first + if hasattr(session_data, 'to_dict'): + session_data = session_data.to_dict() + else: + # Extract classification fields from object + token_dict = {} + 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 + + # Log what we're about to save (without sensitive data) + safe_data = {} + for key, value in session_data.items(): + if key in ['access_token', 'refresh_token', 'client_secret', 'password']: + safe_data[key] = f"<{key}_present>" if value else f"<{key}_missing>" + else: + safe_data[key] = value + logger.info(f"Saving session data for {provider}{country_str}: {safe_data}") + + # Navigate and create nested structure if needed + keys_path, is_nested = self._get_session_path(provider, country) + + # Build nested structure + current = data + for i, key in enumerate(keys_path[:-1]): + if key not in current: + current[key] = {} + elif not isinstance(current[key], dict): + logger.warning(f"Overwriting non-dict value at {key}") + current[key] = {} + current = current[key] + + # Set the final value + current[keys_path[-1]] = session_data + + # Save to file using VFS + success = self.vfs.write_json(self.session_file, data) + + if success: + 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) + if verify_data: + # Navigate to verify + verify_current = verify_data + found = True + for key in keys_path: + 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") + else: + 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") + return False + else: + logger.error(f"Failed to write session file") + return False + + return True + + 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_token(self, provider: str, token: BaseAuthToken, + country: Optional[str] = None) -> bool: + """ + Save authentication token for a provider + + Args: + provider: Provider name + token: Authentication token to save + country: Optional country code + + Returns: + True if successful, False otherwise + """ + country_str = f" (country: {country})" if country else "" + + try: + logger.debug(f"Saving authentication token for {provider}{country_str}") + + # Load existing session data + session_data = self.load_session(provider, country) or {} + logger.debug(f"Current session data keys before token save: {list(session_data.keys())}") + + # Update token data - include classification fields + token_data = token.to_dict() + + # Log token info (without sensitive data) + safe_token_data = {} + for key, value in token_data.items(): + if key in ['access_token', 'refresh_token']: + safe_token_data[key] = f"<{key}_present>" if value else f"<{key}_missing>" + elif key in ['auth_level', 'credential_type']: + safe_token_data[key] = value # Include classification info + else: + safe_token_data[key] = value + logger.info(f"Token data to save for {provider}{country_str}: {safe_token_data}") + + session_data.update(token_data) + logger.debug(f"Updated session data keys after token merge: {list(session_data.keys())}") + + success = self.save_session(provider, session_data, country) + if success: + logger.info(f"Successfully saved authentication token for {provider}{country_str}") + else: + 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]]: + """ + Load token data for a provider + + Args: + provider: Provider name + country: Optional country code + + Returns: + Dictionary with token data or None if not found/expired + """ + country_str = f" (country: {country})" if country else "" + + logger.debug(f"Loading token data for {provider}{country_str}") + + 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}") + return None + + # Check if we have token data + if 'access_token' not in session_data: + logger.info(f"No access token found in session data for {provider}{country_str}") + logger.debug(f"Available session keys: {list(session_data.keys())}") + return None + + # Check if token is expired (with 5 minute buffer) + expires_in = session_data.get('expires_in', 0) + issued_at = session_data.get('issued_at', 0) + current_time = time.time() + expires_at = issued_at + expires_in + time_until_expiry = expires_at - current_time + + logger.debug(f"Token expiry check for {provider}{country_str}: issued_at={issued_at}, " + f"expires_in={expires_in}, current_time={current_time}, " + f"time_until_expiry={time_until_expiry:.0f}") + + if current_time >= (expires_at - 300): # 5 minute buffer + logger.info(f"Token expired for {provider}{country_str} " + f"(expired {abs(time_until_expiry):.0f}s ago)") + return None + + logger.info(f"Loaded valid token data for {provider}{country_str} " + f"(expires in {time_until_expiry:.0f}s, " + f"auth_level={session_data.get('auth_level')})") + return session_data + + def get_device_id(self, provider: str, country: Optional[str] = None) -> str: + """ + Get or generate device ID for a provider + + Args: + provider: Provider name + country: Optional country code + + Returns: + Device ID (UUID string) + """ + country_str = f" (country: {country})" if country else "" + + session_data = self.load_session(provider, country) or {} + + device_id = session_data.get('device_id') + if not device_id: + # Generate new device ID + device_id = str(uuid.uuid4()) + session_data['device_id'] = device_id + self.save_session(provider, session_data, country) + logger.info(f"Generated new device ID for {provider}{country_str}: {device_id}") + else: + logger.debug(f"Using existing device ID for {provider}{country_str}: {device_id}") + + return device_id + + def clear_session(self, provider: str, country: Optional[str] = None) -> bool: + """ + Clear session data for a provider and optional country + + Args: + provider: Provider name + country: Optional country code (if None, clears entire provider) + + Returns: + True if successful, False otherwise + """ + country_str = f" (country: {country})" if country else "" + + 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}") + return True + + if country: + # Clear specific country data + 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 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") + + return self.vfs.write_json(self.session_file, data) + else: + logger.debug(f"No session data found to clear for {provider}{country_str}") + else: + # Clear entire provider data (all countries) + if provider in data: + del data[provider] + logger.info(f"Cleared all session data for {provider}") + return self.vfs.write_json(self.session_file, data) + else: + logger.debug(f"No session data found to clear for {provider}") + + return True + + except Exception as e: + logger.error(f"Error clearing session for {provider}{country_str}: {e}") + return False + + def clear_token(self, provider: str, country: Optional[str] = None) -> bool: + """ + Clear only token data but keep other session data (like device_id) + + Args: + provider: Provider name + country: Optional country code + + Returns: + True if successful, False otherwise + """ + country_str = f" (country: {country})" if country else "" + + 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}") + return True + + # Remove token-related fields + 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}") + + return self.save_session(provider, session_data, country) + + except Exception as e: + logger.error(f"Error clearing token for {provider}{country_str}: {e}") + return False + + def get_all_countries(self, provider: str) -> list: + """ + Get all countries that have session data for a provider + + Args: + provider: Provider name + + Returns: + List of country codes + """ + try: + data = self.vfs.read_json(self.session_file) + if not data or provider not in data: + return [] + + provider_data = data[provider] + + # Check if this is a nested (country-aware) structure + if isinstance(provider_data, dict): + # Check if any keys look like country codes (2-3 char strings) + # and their values are dicts (session data) + countries = [] + for key, value in provider_data.items(): + if isinstance(value, dict) and len(key) <= 3: + countries.append(key) + return countries + + return [] + + except Exception as e: + logger.error(f"Error getting countries for {provider}: {e}") + return [] + + def debug_session_file(self) -> None: + """ + Debug method to log the current state of the session file + """ + try: + logger.info(f"=== SESSION FILE DEBUG INFO ===") + + # Get VFS debug info + vfs_info = self.vfs.debug_info() + for key, value in vfs_info.items(): + logger.info(f"VFS {key}: {value}") + + session_file_path = self.vfs.join_path(self.session_file) + logger.info(f"Session file path: {session_file_path}") + logger.info(f"Session file exists: {self.vfs.exists(self.session_file)}") + + if self.vfs.exists(self.session_file): + file_size = self.vfs.get_size(self.session_file) + logger.info(f"Session file size: {file_size} bytes") + + 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())}") + + 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 + for k, v in provider_data.items() + ) + + if has_countries: + logger.info(f" {provider} (country-aware):") + for country, session_data in provider_data.items(): + if isinstance(session_data, dict): + safe_keys = self._get_safe_keys(session_data) + logger.info(f" {country}: {safe_keys}") + else: + # Flat structure (no country) + safe_keys = self._get_safe_keys(provider_data) + logger.info(f" {provider} (no country): {safe_keys}") + else: + logger.error("Session file contains invalid JSON or is empty") + else: + logger.info("Session file is empty") + else: + logger.info("Session file does not exist yet") + + logger.info(f"=== END SESSION FILE DEBUG ===") + + except Exception as e: + logger.error(f"Error during session file debug: {e}") + + @staticmethod + def _get_safe_keys(session_data: Dict[str, Any]) -> list: + """Helper to get safe representation of session keys""" + safe_keys = [] + for key in session_data.keys(): + if key in ['access_token', 'refresh_token']: + safe_keys.append(f"{key}:present" if session_data[key] else f"{key}:missing") + else: + safe_keys.append(f"{key}:{session_data[key]}") + return safe_keys \ No newline at end of file diff --git a/lib/streaming_providers/base/drm/__init__.py b/lib/streaming_providers/base/drm/__init__.py new file mode 100644 index 0000000..3c66c2c --- /dev/null +++ b/lib/streaming_providers/base/drm/__init__.py @@ -0,0 +1,18 @@ +# streaming_providers/base/drm/__init__.py +""" +Plugin system for DRM configuration processing + +This module contains the core abstractions for DRM plugins. +All plugin implementations are automatically discovered by the DRMPluginManager. +""" + +from .drm_plugin import DRMPlugin +from .plugin_manager import DRMPluginManager + +# The plugin manager handles automatic discovery and registration +# No need to manually import or register plugins here + +__all__ = [ + "DRMPlugin", + "DRMPluginManager", +] diff --git a/lib/streaming_providers/base/drm/drm_plugin.py b/lib/streaming_providers/base/drm/drm_plugin.py new file mode 100644 index 0000000..4b4d4f6 --- /dev/null +++ b/lib/streaming_providers/base/drm/drm_plugin.py @@ -0,0 +1,46 @@ +# 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. + """ + + @property + @abstractmethod + def plugin_name(self) -> str: + """Return the unique plugin name""" + pass + + @property + @abstractmethod + def supported_drm_system(self) -> DRMSystem: + """Return the DRM system this plugin processes""" + pass + + @abstractmethod + def process_drm_config(self, + drm_config: DRMConfig, + pssh_data: Optional[PSSHData], + **kwargs) -> Optional[DRMConfig]: + """ + Process and transform a DRM configuration. + + Args: + drm_config: The DRM config to process (guaranteed to match supported_drm_system) + pssh_data: The PSSH data for this DRM system from the manifest, or None if not available + **kwargs: Additional context from the original method call + + 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 + """ + pass diff --git a/lib/streaming_providers/base/drm/plugin_manager.py b/lib/streaming_providers/base/drm/plugin_manager.py new file mode 100644 index 0000000..609b20f --- /dev/null +++ b/lib/streaming_providers/base/drm/plugin_manager.py @@ -0,0 +1,318 @@ +# 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 + + +class DRMPluginManager: + """ + Manager for DRM configuration plugins. + + Handles plugin registration, discovery, and processing of DRM configs with PSSH data. + """ + + def __init__(self, auto_discover: bool = True): + """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...") + discovered = self.discover_plugins() + if discovered: + logger.debug(f"DRMPluginManager: Auto-discovery completed, {len(discovered)} plugins ready") + else: + logger.debug("DRMPluginManager: Auto-discovery completed, no plugins found") + + def register_plugin(self, plugin: DRMPlugin) -> None: + """ + Register a single plugin instance. + + 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)}") + 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") + + self.plugins[drm_system] = plugin + 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 + + 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}") + 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)): + + 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) + if spec is None or spec.loader is None: + 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__): + plugin_classes.append((name, obj)) + + if not plugin_classes: + 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]}") + + # 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}") + + # 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}") + + # Register the plugin + self.register_plugin(plugin) + registered.append(plugin_name) + 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)) + 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}" + 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") + 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}") + else: + 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:") + 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]: + """ + Process a list of DRM configs through registered plugins using PSSH data. + Generic plugins are processed first, and if any ClearKey config is found, + only that config is returned. + + Args: + drm_configs: List of DRM configs to process + pssh_data_list: List of PSSH data extracted from manifest + **kwargs: Additional context from the original method call + + Returns: + List of processed DRM configs (may be modified, filtered, or unchanged) + """ + if not drm_configs: + 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") + + # 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}") + + # 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}'") + 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}'") + try: + # Generic plugins process all configs at once + temp_configs = [] + for config in processed_configs: + pssh_data = pssh_by_system.get(config.system) + 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") + return [config] + + processed_configs = temp_configs + + except Exception as 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}") + + # 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}") + + try: + pssh_data = pssh_by_system.get(config.system) + if pssh_data: + 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) + 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") + return [processed_config] + + final_configs.append(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)") + + except Exception as 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") + final_configs.append(config) + + logger.debug(f"DRMPluginManager: Completed processing - {len(final_configs)} configs returned") + return final_configs + + def get_plugin(self, drm_system: DRMSystem) -> Optional[DRMPlugin]: + """ + Get registered plugin for a DRM system. + + Args: + drm_system: DRM system to get plugin for + + Returns: + The plugin instance or None if not found + """ + plugin = self.plugins.get(drm_system) + if plugin: + 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}") + return plugin + + def list_plugins(self) -> Dict[DRMSystem, str]: + """ + List all registered plugins. + + Returns: + Dictionary mapping DRM systems to plugin names + """ + 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 + + def clear_plugins(self) -> None: + """Clear all registered plugins""" + plugin_count = len(self.plugins) + self.plugins.clear() + logger.debug(f"DRMPluginManager: Cleared {plugin_count} registered plugins") diff --git a/lib/streaming_providers/base/manager.py b/lib/streaming_providers/base/manager.py new file mode 100644 index 0000000..35e581d --- /dev/null +++ b/lib/streaming_providers/base/manager.py @@ -0,0 +1,457 @@ +# streaming_providers/base/manager.py +from typing import Dict, List, Optional +from .provider import StreamingProvider +from .models import StreamingChannel +from .drm import DRMPluginManager +from .utils.logger import logger + +class ProviderManager: + """ + Central manager for handling multiple streaming providers. + Handles provider registration, discovery, and channel fetching operations. + """ + + def __init__(self): + """Initialize with empty provider registry and DRM plugin manager""" + self.providers: Dict[str, StreamingProvider] = {} + self.drm_plugin_manager = DRMPluginManager() + logger.info("ProviderManager: Initialized with DRM plugin manager") + + def register_provider(self, provider: StreamingProvider) -> None: + """ + Register a single provider instance. + + Args: + provider: Configured provider instance to register + """ + if not isinstance(provider, StreamingProvider): + logger.error(f"ProviderManager: Failed to register provider - invalid type: {type(provider)}") + raise ValueError("Only StreamingProvider instances can be registered") + + self.providers[provider.provider_name] = provider + logger.info(f"ProviderManager: Registered provider '{provider.provider_name}'") + + def register_providers(self, providers: List[StreamingProvider]) -> None: + """ + Register multiple provider instances at once. + + Args: + providers: List of configured provider instances + """ + logger.info(f"ProviderManager: Registering {len(providers)} providers") + for provider in providers: + self.register_provider(provider) + + def discover_providers(self, country: str = 'DE', detected_providers: Dict[str, List[str]] = None) -> List[str]: + """ + Discover and register all available providers for a country. + + Args: + country: Country code for provider configuration (used as fallback) + detected_providers: Optional dict mapping provider names to country lists. + If None, falls back to discovering all AVAILABLE_PROVIDERS. + + Returns: + List of discovered provider names (without country suffixes for compatibility) + """ + from streaming_providers import AVAILABLE_PROVIDERS + + # Backward compatibility: if no detected_providers, use original discovery logic + if detected_providers is None: + logger.info(f"ProviderManager: Discovering providers for country '{country}'") + registered = [] + failed = [] + + for provider_name, provider_class in AVAILABLE_PROVIDERS.items(): + if provider_name not in self.providers: + try: + provider = provider_class(country=country) + self.register_provider(provider) + registered.append(provider_name) + except Exception as e: + failed.append((provider_name, str(e))) + logger.warning(f"ProviderManager: Could not initialize provider '{provider_name}': {e}") + + logger.info( + f"ProviderManager: Discovery completed - {len(registered)} providers registered, {len(failed)} failed") + return registered + + # New multi-country logic + logger.info(f"ProviderManager: Discovering providers with multi-country support") + registered = [] + failed = [] + + for provider_name, countries in detected_providers.items(): + if provider_name not in AVAILABLE_PROVIDERS: + logger.warning(f"ProviderManager: Provider '{provider_name}' not in AVAILABLE_PROVIDERS, skipping") + continue + + provider_class = AVAILABLE_PROVIDERS[provider_name] + + try: + if countries: # Multi-country provider + for country_code in countries: + provider_key = f"{provider_name}_{country_code}" + if provider_key not in self.providers: + provider = provider_class(country=country_code.lower()) + self.providers[provider_key] = provider + logger.debug(f"ProviderManager: Registered {provider_key}") + + # Return base provider name once for backward compatibility + if provider_name not in registered: + registered.append(provider_name) + else: # Single country provider (fallback to default country) + if provider_name not in self.providers: + provider = provider_class(country=country.lower()) + self.providers[provider_name] = provider + registered.append(provider_name) + except Exception as e: + failed.append((provider_name, str(e))) + logger.warning(f"ProviderManager: Could not initialize provider '{provider_name}': {e}") + + logger.info( + f"ProviderManager: Discovery completed - {len(registered)} providers registered, {len(failed)} failed") + return registered + + def discover_drm_plugins(self) -> List[str]: + """ + Discover and register all available DRM plugins. + + Returns: + List of discovered plugin names + """ + logger.info("ProviderManager: Discovering DRM plugins") + discovered = self.drm_plugin_manager.discover_plugins() + logger.info(f"ProviderManager: DRM plugin discovery completed - {len(discovered)} plugins available") + return discovered + + def get_provider(self, provider_name: str) -> Optional[StreamingProvider]: + """ + Get registered provider by name. + + Args: + provider_name: Name of the provider to retrieve + + Returns: + The provider instance or None if not found + """ + provider = self.providers.get(provider_name) + if not provider: + logger.debug(f"ProviderManager: Provider '{provider_name}' not found") + return provider + + def get_provider_http_manager(self, provider_name: str): + """ + Get HTTP manager for a specific provider. + + Args: + provider_name: Name of the provider + + Returns: + HTTPManager instance if provider exists and has one, None otherwise + """ + provider = self.get_provider(provider_name) + if not provider: + logger.warning(f"ProviderManager: Provider '{provider_name}' not found") + return None + + http_manager = provider.http_manager + if not http_manager: + logger.warning(f"ProviderManager: Provider '{provider_name}' has no HTTP manager configured") + return None + + logger.debug(f"ProviderManager: Retrieved HTTP manager for provider '{provider_name}'") + return http_manager + + def needs_proxy(self, provider_name: str) -> bool: + """ + Check if a provider needs proxy support. + + Args: + provider_name: Name of the provider + + Returns: + True if provider has proxy configured, False otherwise + """ + http_manager = self.get_provider_http_manager(provider_name) + if not http_manager: + return False + + has_proxy = http_manager.config.proxy_config is not None + if has_proxy: + logger.debug(f"ProviderManager: Provider '{provider_name}' requires proxy") + else: + logger.debug(f"ProviderManager: Provider '{provider_name}' does not require proxy") + + return has_proxy + + def get_provider_choices(self) -> Dict[int, str]: + """ + Get numbered provider choices for user selection. + Includes an 'all' option as the last choice. + + Returns: + Mapping of choice numbers to provider names + """ + choices = {i+1: name for i, name in enumerate(self.providers.keys())} + choices[len(choices)+1] = 'all' + return choices + + def get_selected_providers(self, choices_input: str) -> List[str]: + """ + Convert user input string to list of provider names. + + Args: + choices_input: Comma-separated string of choice numbers + + Returns: + List of selected provider names + """ + available = list(self.providers.keys()) + if not available: + logger.warning("ProviderManager: No providers available for selection") + return [] + + selected = [] + for choice in choices_input.split(','): + choice = choice.strip() + if choice.isdigit(): + idx = int(choice) - 1 + if 0 <= idx < len(available): + selected.append(available[idx]) + elif idx == len(available): # 'all' option + selected = available.copy() + + result = selected or available + logger.debug(f"ProviderManager: Selected {len(result)} providers from input '{choices_input}'") + return result + + def get_channels(self, provider_name: str, fetch_manifests: bool = False, **kwargs) -> List[StreamingChannel]: + """ + Get channels from a specific provider. + + Args: + provider_name: Name of the provider + fetch_manifests: Whether to enrich channels with manifest data + **kwargs: Additional arguments for channel fetching + + Returns: + List of channels from the provider + + Raises: + ValueError: If provider not found + """ + provider = self.get_provider(provider_name) + if not provider: + logger.error(f"ProviderManager: Cannot get channels - provider '{provider_name}' not found") + raise ValueError(f"Provider '{provider_name}' not found") + + logger.debug(f"ProviderManager: Fetching channels from provider '{provider_name}' (fetch_manifests={fetch_manifests})") + channels = provider.fetch_channels(**kwargs) + logger.info(f"ProviderManager: Retrieved {len(channels)} channels from provider '{provider_name}'") + + if fetch_manifests and not provider.uses_dynamic_manifests: + logger.debug(f"ProviderManager: Enriching channels with manifest data for provider '{provider_name}'") + enriched_channels = [] + for channel in channels: + enriched = provider.enrich_channel_data(channel, **kwargs) + if enriched is not None: + enriched_channels.append(enriched) + logger.info(f"ProviderManager: Enriched {len(enriched_channels)} out of {len(channels)} channels") + return enriched_channels + return channels + + def get_channel_manifest(self, provider_name: str, channel_id: str, **kwargs) -> Optional[str]: + """ + Get manifest URL for a specific channel from a provider. + + Args: + provider_name: Name of the provider + channel_id: ID of the channel + **kwargs: Additional arguments (e.g., country) + + Returns: + Manifest URL string, or None if not available + + Raises: + ValueError: If provider not found + """ + provider = self.get_provider(provider_name) + if not provider: + logger.error(f"ProviderManager: Cannot get manifest - provider '{provider_name}' not found") + raise ValueError(f"Provider '{provider_name}' not found") + + manifest_url = provider.get_manifest(channel_id, **kwargs) + if manifest_url: + logger.debug(f"ProviderManager: Retrieved manifest for channel '{channel_id}' from provider '{provider_name}'") + else: + logger.warning(f"ProviderManager: No manifest available for channel '{channel_id}' from provider '{provider_name}'") + return manifest_url + + def get_channel_epg(self, provider_name: str, channel_id: str, **kwargs) -> List[Dict]: + """ + Get EPG data for a specific channel from a provider. + + Args: + provider_name: Name of the provider + channel_id: ID of the channel + **kwargs: Additional arguments (e.g., start_time, end_time, country) + + Returns: + List of EPG entries + + Raises: + ValueError: If provider not found + """ + provider = self.get_provider(provider_name) + if not provider: + logger.error(f"ProviderManager: Cannot get EPG - provider '{provider_name}' not found") + raise ValueError(f"Provider '{provider_name}' not found") + + epg_data = provider.get_epg(channel_id, **kwargs) + logger.debug(f"ProviderManager: Retrieved {len(epg_data)} EPG entries for channel '{channel_id}' from provider '{provider_name}'") + return epg_data + + def get_provider_epg_xmltv(self, provider_name: str, **kwargs) -> Optional[str]: + """ + Get complete EPG data for a provider in XMLTV format. + + Args: + provider_name: Name of the provider + **kwargs: Additional arguments (e.g., country) + + Returns: + XMLTV formatted string, or None if not available + + Raises: + ValueError: If provider not found + """ + provider = self.get_provider(provider_name) + if not provider: + logger.error(f"ProviderManager: Cannot get XMLTV EPG - provider '{provider_name}' not found") + raise ValueError(f"Provider '{provider_name}' not found") + + xmltv_data = provider.get_epg_xmltv(**kwargs) + if xmltv_data: + logger.info(f"ProviderManager: Retrieved XMLTV EPG data for provider '{provider_name}'") + else: + logger.warning(f"ProviderManager: No XMLTV EPG data available for provider '{provider_name}'") + return xmltv_data + + def get_channel_drm_configs(self, provider_name: str, channel_id: str, **kwargs) -> List: + """ + Get DRM configurations for a specific channel from a provider. + DRM configs are processed through registered plugins before being returned. + + Args: + provider_name: Name of the provider + channel_id: ID of the channel + **kwargs: Additional arguments (e.g., country) + + Returns: + List of DRM configuration objects (processed by plugins if available) + + Raises: + ValueError: If provider not found + """ + provider = self.get_provider(provider_name) + if not provider: + logger.error(f"ProviderManager: Cannot get DRM configs - provider '{provider_name}' not found") + raise ValueError(f"Provider '{provider_name}' not found") + + logger.debug(f"ProviderManager: Getting DRM configs for channel '{channel_id}' from provider '{provider_name}'") + + # Get raw DRM configs from provider + drm_configs = provider.get_drm_configs_by_id(channel_id, **kwargs) + logger.debug(f"ProviderManager: Retrieved {len(drm_configs)} raw DRM configs") + + # Get manifest URL for the channel + manifest_url = provider.get_manifest(channel_id, **kwargs) + + # Extract PSSH data from manifest if available + pssh_data_list = [] + if manifest_url: + logger.debug(f"ProviderManager: Extracting PSSH data from manifest") + try: + pssh_data_list = self._extract_pssh_from_manifest(manifest_url) + logger.debug(f"ProviderManager: Extracted {len(pssh_data_list)} PSSH data entries") + except Exception as e: + logger.warning(f"ProviderManager: Could not extract PSSH data from manifest: {e}") + + # Process through DRM plugins with PSSH data + processed_configs = self.drm_plugin_manager.process_drm_configs(drm_configs, pssh_data_list, **kwargs) + logger.info(f"ProviderManager: Processed DRM configs for channel '{channel_id}' - {len(processed_configs)} configs returned") + + return processed_configs + + def _extract_pssh_from_manifest(self, manifest_url: str) -> List: + """ + Extract PSSH data from a manifest URL. + + Args: + manifest_url: URL of the manifest to parse + + Returns: + List of PSSHData objects extracted from the manifest + """ + import requests + from .utils.manifest_parser import ManifestParser + + try: + # Fetch the manifest content + response = requests.get(manifest_url, timeout=10) + response.raise_for_status() + + # Parse and extract PSSH data + return ManifestParser.extract_pssh_from_manifest(response.text, manifest_url) + + except Exception as e: + logger.warning(f"ProviderManager: Failed to fetch or parse manifest from {manifest_url}: {e}") + return [] + + def get_all_channels(self, fetch_manifests: bool = True, **kwargs) -> Dict[str, List[StreamingChannel]]: + """ + Get channels from all registered providers. + + Args: + fetch_manifests: Whether to enrich channels with manifest data + **kwargs: Additional arguments for channel fetching + + Returns: + Dictionary mapping provider names to their channels + """ + logger.info(f"ProviderManager: Fetching channels from all {len(self.providers)} providers (fetch_manifests={fetch_manifests})") + + result = {} + total_channels = 0 + + for name in self.providers: + try: + channels = self.get_channels(name, fetch_manifests, **kwargs) + result[name] = channels + total_channels += len(channels) + except Exception as e: + logger.error(f"ProviderManager: Failed to get channels from provider '{name}': {e}") + result[name] = [] + + logger.info(f"ProviderManager: Retrieved {total_channels} total channels from all providers") + return result + + def list_providers(self) -> List[str]: + """List names of all registered providers""" + return list(self.providers.keys()) + + def clear_providers(self) -> None: + """Clear all registered providers""" + provider_count = len(self.providers) + self.providers.clear() + logger.info(f"ProviderManager: Cleared {provider_count} registered providers") + + def list_drm_plugins(self) -> Dict: + """List all registered DRM plugins""" + return self.drm_plugin_manager.list_plugins() + + def clear_drm_plugins(self) -> None: + """Clear all registered DRM plugins""" + logger.info("ProviderManager: Clearing all DRM plugins") + self.drm_plugin_manager.clear_plugins() diff --git a/lib/streaming_providers/base/models/__init__.py b/lib/streaming_providers/base/models/__init__.py new file mode 100644 index 0000000..ad74d61 --- /dev/null +++ b/lib/streaming_providers/base/models/__init__.py @@ -0,0 +1,5 @@ +# streaming_providers/base/models/__init__.py +from .streaming_channel import StreamingChannel +from .drm_models import DRMConfig, LicenseConfig, DRMSystem + +__all__ = ['StreamingChannel', 'DRMConfig', 'LicenseConfig', 'DRMSystem'] diff --git a/lib/streaming_providers/base/models/drm_models.py b/lib/streaming_providers/base/models/drm_models.py new file mode 100644 index 0000000..e2d5bcd --- /dev/null +++ b/lib/streaming_providers/base/models/drm_models.py @@ -0,0 +1,175 @@ +# streaming_providers/base/models/drm_models.py +from dataclasses import dataclass, field +from typing import Dict, Optional, List +import base64 +from enum import Enum + +class DRMSystem(str, Enum): + WIDEVINE = "com.widevine.alpha" + PLAYREADY = "com.microsoft.playready" + WISEPLAY = "com.huawei.wiseplay" + CLEARKEY = "org.w3.clearkey" + FAIRPLAY = "com.apple.fps" + GENERIC = "generic" + + @property + def system_uuid(self) -> str: + """Get the standard UUID for this DRM system""" + uuid_mapping = { + self.WIDEVINE: "edef8ba9-79d6-4ace-a3c8-27dcd51d21ed", + self.PLAYREADY: "9a04f079-9840-4286-ab92-e65be0885f95", + 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 + } + return uuid_mapping.get(self, "") + + @classmethod + 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 + } + return uuid_mapping.get(uuid_lower) + +class WrapperType(str, Enum): + BASE64 = "base64" + URLENC = "urlenc" + NONE = "none" + +class UnwrapperType(str, Enum): + AUTO = "auto" + BASE64 = "base64" + JSON = "json" + XML = "xml" + NONE = "none" + +@dataclass +class PSSHData: + """ + Protection System Specific Header data for DRM systems + """ + system_id: str # UUID of the DRM system + pssh_box: str # Base64 encoded PSSH box data + key_ids: List[str] = field(default_factory=list) # Optional key IDs + + @property + def 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 + path_data_traverse: bool = False + path_hdcp_res: Optional[str] = None + path_hdcp_res_traverse: bool = False + path_hdcp_ver: Optional[str] = None + path_hdcp_ver_traverse: bool = False + +@dataclass +class LicenseConfig: + server_url: Optional[str] = None + server_certificate: Optional[str] = None + use_http_get_request: bool = False + req_headers: Optional[str] = None + req_params: Optional[str] = None + req_data: Optional[str] = None + wrapper: Optional[str] = None + unwrapper: Optional[str] = None + unwrapper_params: Optional[LicenseUnwrapperParams] = None + keyids: Dict[str, str] = field(default_factory=dict) # For ClearKey + + def validate(self): + """Validate the license configuration""" + if self.server_certificate: + try: + base64.b64decode(self.server_certificate) + except Exception: + raise ValueError("server_certificate must be valid base64") + + if self.req_data: + try: + base64.b64decode(self.req_data) + except Exception: + raise ValueError("req_data must be valid base64") + + if self.keyids: + for kid, key in self.keyids.items(): + if not all(c in "0123456789abcdefABCDEF" for c in kid): + raise ValueError(f"Invalid KID format: {kid}") + if not all(c in "0123456789abcdefABCDEF" for c in key): + raise ValueError(f"Invalid KEY format: {key}") + +@dataclass +class DRMConfig: + system: DRMSystem + priority: int = 0 + license: Optional[LicenseConfig] = None + + def validate(self): + """Validate the DRM configuration""" + if self.license: + self.license.validate() + + def to_dict(self) -> Dict: + """Convert to dictionary format expected by players""" + result = { + str(self.system.value): { # Use .value to get the actual string + "priority": self.priority + } + } + + if self.license: + license_dict = {} + if self.license.server_url: + license_dict["server_url"] = self.license.server_url + if self.license.server_certificate: + license_dict["server_certificate"] = self.license.server_certificate + if self.license.use_http_get_request: + license_dict["use_http_get_request"] = self.license.use_http_get_request + if self.license.req_headers: + license_dict["req_headers"] = self.license.req_headers + if self.license.req_params: + license_dict["req_params"] = self.license.req_params + if self.license.req_data: + license_dict["req_data"] = self.license.req_data + if self.license.wrapper: + license_dict["wrapper"] = self.license.wrapper + if self.license.unwrapper: + license_dict["unwrapper"] = self.license.unwrapper + if self.license.unwrapper_params: + license_dict["unwrapper_params"] = { + k: v for k, v in vars(self.license.unwrapper_params).items() + if v is not None + } + if self.license.keyids: + license_dict["keyids"] = self.license.keyids + + if license_dict: + result[str(self.system.value)]["license"] = license_dict + + return result diff --git a/lib/streaming_providers/base/models/proxy_models.py b/lib/streaming_providers/base/models/proxy_models.py new file mode 100644 index 0000000..1a25d21 --- /dev/null +++ b/lib/streaming_providers/base/models/proxy_models.py @@ -0,0 +1,280 @@ +# streaming_providers/base/models/proxy_models.py +from dataclasses import dataclass, field +from typing import Dict, Optional, Any +from enum import Enum + + +class ProxyType(Enum): + """Supported proxy types""" + HTTP = "http" + HTTPS = "https" + SOCKS4 = "socks4" + SOCKS5 = "socks5" + + +@dataclass +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 + license: bool = True # DRM license requests + all: bool = True # Master switch - overrides all others + + def should_use_proxy_for(self, operation: str) -> bool: + """Check if proxy should be used for specific operation""" + if not self.all: + return False + + operation_map = { + 'api': self.api_calls, + 'auth': self.authentication, + 'manifest': self.manifests, + 'license': self.license + } + + return operation_map.get(operation, True) + + +@dataclass +class ProxyAuth: + """Proxy authentication details""" + username: str + password: str + + def to_auth_string(self) -> str: + """Convert to authentication string for proxy URL""" + return f"{self.username}:{self.password}" + + +@dataclass +class ProxyConfig: + """ + Comprehensive proxy configuration + Supports different proxy types and authentication + """ + # Basic proxy settings + host: str + port: int + proxy_type: ProxyType = ProxyType.HTTP + + # Authentication (optional) + auth: Optional[ProxyAuth] = None + + # Scope control + scope: ProxyScope = field(default_factory=ProxyScope) + + # Advanced settings + timeout: int = 30 + verify_ssl: bool = True + + # Provider-specific overrides + provider_specific: Dict[str, Any] = field(default_factory=dict) + + def to_proxy_dict(self) -> Dict[str, str]: + """ + Convert to requests-compatible proxy dictionary + + Returns: + Dict in format {'http': 'proxy_url', 'https': 'proxy_url'} + """ + # Build proxy URL + auth_part = "" + if self.auth: + auth_part = f"{self.auth.to_auth_string()}@" + + 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 + } + + def to_proxy_url(self) -> str: + """Get single proxy URL string""" + auth_part = "" + if self.auth: + auth_part = f"{self.auth.to_auth_string()}@" + + return f"{self.proxy_type.value}://{auth_part}{self.host}:{self.port}" + + def validate(self) -> bool: + """Validate proxy configuration""" + if not self.host or not self.port: + return False + if self.port < 1 or self.port > 65535: + return False + if self.timeout < 1: + return False + return True + + 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 + }, + 'provider_specific': self.provider_specific + } + + if self.auth: + result['auth'] = { + 'username': self.auth.username, + 'password': self.auth.password + } + + return result + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'ProxyConfig': + """Create ProxyConfig from dictionary""" + auth = None + if 'auth' in data and data['auth']: + auth = ProxyAuth( + username=data['auth']['username'], + password=data['auth']['password'] + ) + + 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) + ) + + return cls( + 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', {}) + ) + + @classmethod + def from_url(cls, proxy_url: str, scope: Optional[ProxyScope] = None) -> 'ProxyConfig': + """ + Create ProxyConfig from proxy URL string + + Args: + proxy_url: Proxy URL like "http://user:pass@proxy.example.com:8080" + scope: Optional scope configuration + + Returns: + ProxyConfig instance + """ + import urllib.parse + + parsed = urllib.parse.urlparse(proxy_url) + + if not parsed.hostname or not parsed.port: + raise ValueError(f"Invalid proxy URL: {proxy_url}") + + auth = None + if parsed.username and parsed.password: + auth = ProxyAuth( + username=parsed.username, + password=parsed.password + ) + + proxy_type = ProxyType.HTTP + if parsed.scheme: + try: + proxy_type = ProxyType(parsed.scheme.lower()) + except ValueError: + # Default to HTTP if scheme not recognized + pass + + return cls( + host=parsed.hostname, + port=parsed.port, + proxy_type=proxy_type, + auth=auth, + scope=scope or ProxyScope() + ) + + +@dataclass +class RequestConfig: + """ + Configuration for HTTP requests including proxy settings + Used by HTTPManager for consistent request handling + """ + # Proxy settings + proxy_config: Optional[ProxyConfig] = None + + # Request settings + timeout: int = 30 + verify_ssl: bool = True + max_retries: int = 3 + retry_delay: float = 1.0 + + # 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" + + # Provider-specific settings + provider: str = "" + + def get_request_kwargs(self, operation: str = "api") -> Dict[str, Any]: + """ + Get kwargs for requests library call + + Args: + operation: Type of operation (api, auth, manifest, license) + + Returns: + Dictionary of kwargs for requests + """ + kwargs = { + '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() + + 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 + 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 + } + + if self.proxy_config: + 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 new file mode 100644 index 0000000..0493223 --- /dev/null +++ b/lib/streaming_providers/base/models/streaming_channel.py @@ -0,0 +1,98 @@ +# streaming_providers/base/models.py +from dataclasses import dataclass +from typing import Dict, Optional +from .drm_models import DRMConfig + +@dataclass +class StreamingChannel: + """ + Universal channel representation for all providers + """ + # Core identification + name: str + channel_id: str + provider: str # 'joyn', 'zdf', 'ard', etc. + + # Visual elements + logo_url: Optional[str] = None + + # Streaming configuration + mode: str = "live" # "live" or "vod" + session_manifest: bool = False + manifest: Optional[str] = None + manifest_script: Optional[str] = None + + # DRM/CDM settings + cdm_type: Optional[str] = None + use_cdm: bool = True + cdm: Optional[str] = None + cdm_mode: str = 'external' + + # DRM Configuration + drm_config: Optional[DRMConfig] = None + + # Video settings + video: str = 'best' + on_demand: bool = True + speed_up: bool = True + + # Additional metadata + content_type: str = 'LIVE' + description: Optional[str] = None + genre: Optional[str] = None + language: str = 'de' + country: str = 'DE' + + # Streaming URLs + license_url: Optional[str] = None + certificate_url: Optional[str] = None + streaming_format: Optional[str] = None + + def to_dict(self) -> Dict: + """Convert to dictionary format""" + result = { + 'Name': self.name, + 'Id': self.channel_id, + 'Provider': self.provider, + '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, + 'ContentType': self.content_type, + 'Country': self.country, + 'Language': self.language, + 'StreamingFormat': self.streaming_format + } + + # Add DRM config if present + if self.drm_config: + result['DrmConfig'] = self.drm_config.to_dict() + + return result + + def set_static_manifest(self, manifest_url: str) -> None: + """ + Set a static manifest URL (scenario 1: provider gives manifest directly) + """ + self.manifest = manifest_url + self.session_manifest = False + self.manifest_script = None + + def set_dynamic_manifest(self, manifest_script_params: str) -> None: + """ + Set dynamic manifest parameters (scenario 3: manifest needs to be fetched at request time) + + Args: + manifest_script_params: Parameters needed to fetch manifest (e.g., channel_id, api_endpoint) + """ + self.manifest = None + self.session_manifest = True + self.manifest_script = manifest_script_params diff --git a/lib/streaming_providers/base/network/__init__.py b/lib/streaming_providers/base/network/__init__.py new file mode 100644 index 0000000..da90652 --- /dev/null +++ b/lib/streaming_providers/base/network/__init__.py @@ -0,0 +1,10 @@ +# streaming_providers/base/network/__init__.py +from .http_manager import HTTPManager, HTTPManagerFactory +from .proxy_manager import ProxyConfigManager + +# Only export what consumers should use +__all__ = [ + 'HTTPManager', + 'HTTPManagerFactory', + 'ProxyConfigManager' +] diff --git a/lib/streaming_providers/base/network/http_manager.py b/lib/streaming_providers/base/network/http_manager.py new file mode 100644 index 0000000..ef88639 --- /dev/null +++ b/lib/streaming_providers/base/network/http_manager.py @@ -0,0 +1,353 @@ +# streaming_providers/base/network/http_manager.py +import requests +import time +import json +from typing import Dict, Any, Optional +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +from ..models.proxy_models import RequestConfig, ProxyConfig +from ..utils.logger import logger + + +class HTTPManager: + """ + Centralized HTTP request manager with proxy support + + Handles all HTTP requests for streaming providers with: + - Proxy configuration per operation type + - Retry logic + - Error handling + - Request/response logging + - Provider-specific configurations + """ + + def __init__(self, config: Optional[RequestConfig] = None): + """ + Initialize HTTP manager + + Args: + config: Request configuration including proxy settings + """ + self.config = config or RequestConfig() + self._session = None + self._setup_session() + + def _setup_session(self) -> None: + """Setup requests session with retry strategy""" + self._session = requests.Session() + + # Setup retry strategy + retry_strategy = Retry( + 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"] + ) + + adapter = HTTPAdapter(max_retries=retry_strategy) + self._session.mount("http://", adapter) + self._session.mount("https://", adapter) + + def update_config(self, config: RequestConfig) -> None: + """ + Update request configuration + + Args: + config: New request configuration + """ + self.config = config + self._setup_session() + + def update_proxy(self, proxy_config: Optional[ProxyConfig]) -> None: + """ + Update just the proxy configuration + + Args: + proxy_config: New proxy configuration (None to disable proxy) + """ + self.config.proxy_config = proxy_config + + def get(self, url: str, operation: str = "api", **kwargs) -> requests.Response: + """ + Perform GET request with proxy support + + Args: + url: Request URL + operation: Operation type (api, auth, manifest, license) for proxy scoping + **kwargs: Additional arguments for requests + + Returns: + requests.Response object + + Raises: + requests.exceptions.RequestException: On request failure + """ + 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: + """ + Perform POST request with proxy support + + Args: + url: Request URL + operation: Operation type for proxy scoping + data: Request data (form data or raw) + json_data: JSON data to send + **kwargs: Additional arguments for requests + + Returns: + requests.Response object + """ + if json_data is not None: + kwargs['json'] = json_data + elif data is not None: + kwargs['data'] = data + + return self._make_request("POST", url, operation, **kwargs) + + def put(self, url: str, operation: str = "api", **kwargs) -> requests.Response: + """Perform PUT request with proxy support""" + return self._make_request("PUT", url, operation, **kwargs) + + def delete(self, url: str, operation: str = "api", **kwargs) -> requests.Response: + """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: + """ + Make HTTP request with full configuration support + """ + # Get base request configuration + request_kwargs = self.config.get_request_kwargs(operation) + + # Merge with any additional kwargs (allows overrides) + request_kwargs.update(kwargs) + + # Log request details (excluding sensitive data) + self._log_request(method, url, operation, request_kwargs) + + try: + # Make the request + response = self._session.request(method, url, **request_kwargs) + + # Log response + self._log_response(response) + + # Check for HTTP errors (will raise for 4xx/5xx) + response.raise_for_status() + + return response + + except requests.exceptions.ProxyError as e: + logger.error( + f"{self.config.provider}: Proxy error for {operation} request to {url}: {e}" + ) + raise + + except requests.exceptions.Timeout as e: + logger.error( + f"{self.config.provider}: Timeout ({request_kwargs.get('timeout', 'unknown')}s) " + f"for {operation} request to {url}: {e}" + ) + raise + + except requests.exceptions.ConnectionError as e: + logger.error( + f"{self.config.provider}: Connection error for {operation} request to {url}: {e}" + ) + raise + + except requests.exceptions.HTTPError as e: + # Additional context for HTTP errors + 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}" + ) + raise + + except requests.exceptions.RequestException as e: + logger.error( + f"{self.config.provider}: Request error for {operation} request to {url}: {e}" + ) + raise + + 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 + proxy_info = "" + 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_type = self.config.proxy_config.proxy_type.value + 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 + proxy_info = f" [proxy: disabled for operation '{operation}']" + else: + # No proxy configured + proxy_info = " [proxy: none]" + + # Get timeout info + 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:]}" + + logger.debug( + f"{self.config.provider}: {method} {operation} -> {display_url}" + f"{proxy_info} [timeout: {timeout}s]" + ) + + def _log_response(self, response: requests.Response) -> None: + """Log response details with timing information""" + + # Get response time if available + 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') + + # Size info + size = len(response.content) + size_display = f"{size} bytes" + if size > 1024 * 1024: # > 1MB + size_display = f"{size / (1024 * 1024):.2f} MB" + elif size > 1024: # > 1KB + size_display = f"{size / 1024:.2f} KB" + + logger.debug( + f"{self.config.provider}: Response {response.status_code} " + f"({size_display}, {content_type}){elapsed}" + ) + + def test_connection(self, test_url: str = "https://httpbin.org/ip", + operation: str = "api") -> Dict[str, Any]: + """ + Test network connection and proxy configuration + + Args: + test_url: URL to test connection with + operation: Operation type for proxy scoping + + Returns: + Dictionary with test results + """ + result = { + 'success': False, + 'proxy_used': False, + 'response_time': 0.0, + 'error': None, + 'ip_info': None + } + + try: + start_time = time.time() + 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) + ) + + # Try to parse IP info if using httpbin + try: + result['ip_info'] = response.json() + except (json.JSONDecodeError, AttributeError): + result['ip_info'] = {'response': response.text[:100]} + + except Exception as e: + result['error'] = str(e) + logger.error(f"Connection test failed: {e}") + + return result + + def close(self) -> None: + """Close the session""" + if self._session: + self._session.close() + + def __enter__(self): + """Context manager entry""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit""" + self.close() + + +class HTTPManagerFactory: + """ + Factory for creating HTTP managers with provider-specific configurations + """ + + @staticmethod + def create_for_provider(provider_name: str, + proxy_config: Optional[ProxyConfig] = None, + **config_kwargs) -> HTTPManager: + """ + Create HTTP manager configured for specific provider + + Args: + provider_name: Name of the provider + proxy_config: Proxy configuration + **config_kwargs: Additional RequestConfig parameters + + Returns: + Configured HTTPManager instance + """ + # 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 + }, + '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 + } + + # Get provider-specific defaults + defaults = provider_defaults.get(provider_name, {}) + defaults.update(config_kwargs) + defaults['provider'] = provider_name + + # Create request config + config = RequestConfig( + proxy_config=proxy_config, + **defaults + ) + + return HTTPManager(config) + + @staticmethod + def create_with_proxy_url(provider_name: str, proxy_url: str, **kwargs) -> HTTPManager: + """ + Create HTTP manager with proxy from URL string + + Args: + provider_name: Name of the provider + proxy_url: Proxy URL (e.g., "http://proxy.example.com:8080") + **kwargs: Additional configuration + + Returns: + Configured HTTPManager instance + """ + proxy_config = ProxyConfig.from_url(proxy_url) + return HTTPManagerFactory.create_for_provider( + provider_name, proxy_config, **kwargs + ) diff --git a/lib/streaming_providers/base/network/proxy_manager.py b/lib/streaming_providers/base/network/proxy_manager.py new file mode 100644 index 0000000..37b7d2b --- /dev/null +++ b/lib/streaming_providers/base/network/proxy_manager.py @@ -0,0 +1,558 @@ +# streaming_providers/base/network/proxy_manager.py +import json +import time +from typing import Dict, Optional, List, Any +from pathlib import Path + +from ..models.proxy_models import ProxyConfig, ProxyScope +from ..utils.logger import logger + + +class ProxyConfigManager: + """ + Manages proxy configurations with file-based persistence + Supports per-provider proxy settings with global fallbacks + Now supports country-specific proxy configurations + """ + + def __init__(self, config_dir: Optional[str] = None): + """ + Initialize proxy configuration manager with VFS support + """ + # 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.proxy_config_file = 'proxy_config.json' # Use relative path for VFS + + # Cache for loaded configurations + self._config_cache: Dict[str, ProxyConfig] = {} + self._global_config: Optional[ProxyConfig] = None + + # Load existing configurations + self._load_configurations() + + @staticmethod + def _get_proxy_path(provider_name: str, country: Optional[str] = None) -> tuple: + """ + Determine the path to proxy data based on country + + Args: + provider_name: Provider name + country: Optional country code (e.g., 'de', 'at', 'ch') + + Returns: + Tuple of (cache_key, is_nested) + """ + if country: + return f"{provider_name}_{country}", True + else: + return provider_name, False + + def _load_configurations(self) -> None: + """Load proxy configurations from file using VFS""" + try: + 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") + return + + # Load global configuration + 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', {}) + 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() + ): + # 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})") + 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}") + except Exception as e: + logger.error(f"Error loading proxy config for {provider_name}: {e}") + + except Exception as e: + logger.error(f"Error loading proxy configurations: {e}") + + def _save_configurations(self) -> bool: + """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' + } + } + + # Save global configuration + if self._global_config: + 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: + # Country-aware key (e.g., "joyn_de") + parts = cache_key.rsplit('_', 1) + provider_name = parts[0] + country = parts[1] + + if provider_name not in provider_configs: + provider_configs[provider_name] = {} + provider_configs[provider_name][country] = config.to_dict() + else: + # Non-country key + provider_configs[cache_key] = config.to_dict() + + data['providers'] = provider_configs + + # Save using VFS + success = self.vfs.write_json(self.proxy_config_file, data) + + if success: + logger.debug("Saved proxy configurations") + else: + logger.error("Failed to save proxy configurations") + + return success + + except Exception as e: + logger.error(f"Error saving proxy configurations: {e}") + return False + + def get_proxy_config(self, provider_name: str, country: Optional[str] = None) -> Optional[ProxyConfig]: + """ + Get proxy configuration for a provider and optional country + + Args: + provider_name: Provider name + country: Optional country code + + Returns: + ProxyConfig or None + """ + cache_key, _ = self._get_proxy_path(provider_name, country) + + # Check provider-specific config first + if cache_key in self._config_cache: + config = self._config_cache[cache_key] + country_str = f" ({country})" if country else "" + logger.debug( + f"Using provider-specific proxy for {provider_name}{country_str}: " + f"{config.proxy_type.value}://{config.host}:{config.port}" + ) + return config + + # If country specified but not found, try without country + if country and provider_name in self._config_cache: + config = self._config_cache[provider_name] + logger.debug( + f"Using non-country proxy for {provider_name} ({country}): " + f"{config.proxy_type.value}://{config.host}:{config.port}" + ) + return config + + # Fall back to global config + if self._global_config: + country_str = f" ({country})" if country else "" + logger.debug( + f"Using global proxy for {provider_name}{country_str}: " + f"{self._global_config.proxy_type.value}://{self._global_config.host}:{self._global_config.port}" + ) + return self._global_config + + country_str = f" ({country})" if country else "" + 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: + """ + Set proxy configuration for a provider + + Args: + provider_name: Name of the provider (use 'global' for global config) + proxy_config: Proxy configuration to set + country: Optional country code + + Returns: + True if successful, False otherwise + """ + if not proxy_config.validate(): + logger.error(f"Invalid proxy configuration for {provider_name}") + return False + + try: + 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}") + + 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}") + return False + + def remove_proxy_config(self, provider_name: str, country: Optional[str] = None) -> bool: + """ + Remove proxy configuration for a provider + + Args: + provider_name: Name of the provider (use 'global' for global config) + country: Optional country code (if None, removes all countries for provider) + + Returns: + True if successful, False otherwise + """ + try: + if provider_name == 'global': + self._global_config = None + logger.info("Removed global proxy configuration") + else: + if country: + # Remove specific country + 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})") + else: + 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() + 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}") + else: + 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}") + return False + + def get_all_countries(self, provider_name: str) -> List[str]: + """ + Get all countries that have proxy configs for a provider + + Args: + provider_name: Provider name + + Returns: + List of country codes + """ + countries = [] + prefix = f"{provider_name}_" + + for cache_key in self._config_cache.keys(): + if cache_key.startswith(prefix): + country = cache_key[len(prefix):] + if len(country) <= 3: # Validate it looks like a country code + countries.append(country) + + return countries + + def list_proxy_configs(self) -> List[str]: + """ + Get list of providers with proxy configurations + + Returns: + List of provider names (including country-specific ones) + """ + providers = list(self._config_cache.keys()) + if self._global_config: + providers.append('global') + return providers + + def test_proxy_config(self, provider_name: str, country: Optional[str] = None) -> Dict[str, Any]: + """ + Test proxy configuration for a provider + + Args: + provider_name: Name of the provider + country: Optional country code + + Returns: + Dictionary with test results + """ + 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 + } + + # Test basic connectivity through proxy + from .http_manager import HTTPManager, RequestConfig + + 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() + + manager.close() + return result + + def get_proxy_info(self, provider_name: str, country: Optional[str] = None) -> Dict[str, Any]: + """ + Get detailed information about proxy configuration + + Args: + provider_name: Name of the provider + country: Optional country code + + Returns: + Dictionary with proxy information + """ + proxy_config = self.get_proxy_config(provider_name, country) + 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 + } + + if proxy_config: + # Determine config source + if cache_key in self._config_cache: + 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' + elif self._global_config: + 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 + } + + return info + + def export_config(self, export_path: Optional[str] = None) -> str: + """ + Export proxy configurations to a file + + Args: + export_path: Optional path to export to, defaults to backup file + + Returns: + Path to exported file + """ + if not export_path: + 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: + # Country-aware key + parts = cache_key.rsplit('_', 1) + provider_name = parts[0] + country = parts[1] + + if provider_name not in providers_export: + providers_export[provider_name] = {} + providers_export[provider_name][country] = config.to_dict() + else: + # Non-country key + providers_export[cache_key] = config.to_dict() + + export_data = { + '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 + } + + 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}") + return export_path + + except Exception as e: + logger.error(f"Error exporting proxy configurations: {e}") + raise + + def import_config(self, import_path: str, merge: bool = True) -> bool: + """ + Import proxy configurations from a file + + Args: + import_path: Path to import file + merge: If True, merge with existing configs; if False, replace all + + Returns: + True if successful, False otherwise + """ + try: + with open(import_path, 'r', encoding='utf-8') as f: + import_data = json.load(f) + + if not merge: + # Clear existing configurations + self._config_cache.clear() + 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']) + + # Import provider configurations + 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() + ): + # 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) + else: + # Flat structure + 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}") + 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: + """ + Create and set proxy configuration from URL + + Args: + provider_name: Name of the provider + proxy_url: Proxy URL (e.g., "http://user:pass@proxy.example.com:8080") + scope: Optional scope configuration + country: Optional country code + + Returns: + True if successful, False otherwise + """ + try: + proxy_config = ProxyConfig.from_url(proxy_url, scope) + 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}") + return False + + 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 + + Args: + proxy_config: Proxy configuration to set + provider_names: List of provider names + country: Optional country code to apply to all providers + + Returns: + Dictionary mapping provider names to success status + """ + results = {} + for provider_name in provider_names: + 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]]: + """ + Get proxy information for all configured providers + + Returns: + Dictionary mapping provider names to their proxy info + """ + info = {} + + # Add global config info + if self._global_config: + 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: + # Country-aware key + parts = cache_key.rsplit('_', 1) + provider_name = parts[0] + country = parts[1] + info[cache_key] = self.get_proxy_info(provider_name, country) + else: + # Non-country key + info[cache_key] = self.get_proxy_info(cache_key) + + return info \ No newline at end of file diff --git a/lib/streaming_providers/base/provider.py b/lib/streaming_providers/base/provider.py new file mode 100644 index 0000000..cc0108c --- /dev/null +++ b/lib/streaming_providers/base/provider.py @@ -0,0 +1,172 @@ +# streaming_providers/base/provider.py +from abc import ABC, abstractmethod +from typing import Dict, List, Optional +import json +from datetime import datetime + +from .models.streaming_channel import StreamingChannel +from .models.drm_models import DRMConfig + +class StreamingProvider(ABC): + """ + Abstract base class for streaming providers + """ + + def __init__(self, country: str = 'DE'): + self.country = country + self.channels: List[StreamingChannel] = [] + self._http_manager = None + + @property + def http_manager(self): + """ + Return the provider's HTTP manager instance + + Returns: + HTTPManager instance if available, None otherwise + """ + return getattr(self, '_http_manager', None) + + @http_manager.setter + def http_manager(self, value): + """ + Set the provider's HTTP manager instance + + Args: + value: HTTPManager instance to set + """ + self._http_manager = value + + @property + @abstractmethod + def provider_name(self) -> str: + """Return the provider name (e.g., 'joyn', 'zdf', 'ard')""" + pass + + @property + @abstractmethod + def uses_dynamic_manifests(self) -> bool: + """ + Return True if provider uses truly dynamic manifests (timestamps, session-dependent) + Return False if provider uses static manifests or manifests that can be fetched once + """ + pass + + @abstractmethod + def fetch_channels(self, **kwargs) -> List[StreamingChannel]: + """ + Fetch channels from the provider + + Returns: + List of StreamingChannel objects (may have empty manifests) + """ + pass + + def get_drm_configs(self, channel: StreamingChannel, **kwargs) -> List[DRMConfig]: + """ + Get all DRM configurations for a channel + + Args: + channel: Channel to get DRM configs for + **kwargs: Additional parameters + + Returns: + List of DRMConfig objects (can be empty if no DRM is used) + """ + return [] + + def get_drm_configs_by_id(self, channel_id: str, **kwargs) -> List[DRMConfig]: + """ + Get all DRM configurations for a channel by ID + + Args: + channel_id: ID of the channel to get DRM configs for + **kwargs: Additional parameters (e.g., country) + + Returns: + List of DRMConfig objects (can be empty if no DRM is used) + """ + return [] + + 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 + + Args: + channel_id: Channel ID to get EPG for + start_time: Optional start time for EPG window + end_time: Optional end time for EPG window + **kwargs: Additional parameters + + Returns: + List of EPG entries (each containing start/end times, title, description, etc.) + """ + return [] + + @staticmethod + def get_epg_xmltv(self, **kwargs) -> Optional[str]: + """ + Get complete EPG data for this provider in XMLTV format. + + This method should be implemented by concrete providers to return + the entire EPG as a properly formatted XMLTV string. + + Args: + **kwargs: Additional parameters (e.g., country, date range) + + Returns: + XMLTV formatted string, or None if EPG is not available + """ + return None # Default implementation - providers can override + + @abstractmethod + def enrich_channel_data(self, channel: StreamingChannel, **kwargs) -> Optional[StreamingChannel]: + """ + Enrich channel with additional data including manifest URL and other info + + Args: + channel: Channel to enrich + **kwargs: Additional parameters + + Returns: + The enriched StreamingChannel with manifest and additional info, or None if failed + """ + return None + + @abstractmethod + def get_manifest(self, channel_id: str, **kwargs) -> Optional[str]: + """ + Get manifest URL for a specific channel by ID + + Args: + channel_id: ID of the channel to get manifest for + **kwargs: Additional parameters (e.g., country) + + Returns: + Manifest URL string, or None if not available + """ + return None + + def get_dynamic_manifest_params(self, channel: StreamingChannel, **kwargs) -> Optional[str]: + """ + Optional: Get dynamic manifest parameters for a channel + """ + return None + + def to_output_format(self, channels: List[StreamingChannel] = None) -> Dict: + """Convert channels to output format""" + if channels is None: + channels = self.channels + + return { + '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) diff --git a/lib/streaming_providers/base/settings/__init__.py b/lib/streaming_providers/base/settings/__init__.py new file mode 100644 index 0000000..196bfb1 --- /dev/null +++ b/lib/streaming_providers/base/settings/__init__.py @@ -0,0 +1,11 @@ +# streaming_providers/base/settings/__init__.py +from .kodi_settings_bridge import KodiSettingsBridge +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 diff --git a/lib/streaming_providers/base/settings/kodi_settings_bridge.py b/lib/streaming_providers/base/settings/kodi_settings_bridge.py new file mode 100644 index 0000000..b3eb10e --- /dev/null +++ b/lib/streaming_providers/base/settings/kodi_settings_bridge.py @@ -0,0 +1,460 @@ +# streaming_providers/base/settings/kodi_settings_bridge.py +from typing import Dict, List, Optional, Set, Tuple +import xml.etree.ElementTree as ElementTree + +from ..auth.credentials import BaseCredentials, UserPasswordCredentials, ClientCredentials +from ..models.proxy_models import ProxyConfig +from ..utils.logger import logger + +try: + import xbmcaddon + + KODI_AVAILABLE = True +except ImportError: + KODI_AVAILABLE = False + + +class KodiSettingsBridge: + """Bridge between Kodi addon settings and internal configuration system""" + + # Markers that identify credential settings + CREDENTIAL_MARKERS = {'_username', '_password', '_client_id', '_client_secret'} + + def __init__(self, addon_id: Optional[str] = None, config_dir: Optional[str] = None): + """Initialize Kodi settings bridge""" + self.addon = None + self.addon_id = addon_id + + # Initialize VFS with config directory support + from ..utils.vfs import VFS + self.vfs = VFS(config_dir=config_dir) + + if KODI_AVAILABLE: + try: + 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}") + except Exception as e: + logger.error(f"Failed to initialize Kodi addon: {e}") + self.addon = None + + def is_kodi_environment(self) -> bool: + """Check if currently running in Kodi environment""" + return KODI_AVAILABLE and self.addon is not None + + def get_addon_info(self) -> Dict[str, str]: + """Get information about current Kodi addon (ID, version, etc.)""" + if not self.is_kodi_environment(): + return {"error": "Not in Kodi environment"} + + return { + '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 ============= + + def _get_all_setting_ids(self) -> List[str]: + """ + Get all setting IDs from settings.xml by parsing the file. + + Returns: + List of all setting IDs found in settings.xml + """ + if not self.is_kodi_environment(): + return [] + + try: + # Read settings.xml from VFS base path (addon profile directory) + xml_content = self.vfs.read_text('settings.xml') + if not xml_content: + logger.warning("settings.xml not found or empty") + return [] + if not xml_content: + logger.warning("settings.xml is empty") + return [] + + root = ElementTree.fromstring(xml_content) + + # Extract all setting IDs + setting_ids = [] + for setting in root.findall('.//setting'): + setting_id = setting.get('id') + if setting_id: + setting_ids.append(setting_id) + + logger.info(f"Found {len(setting_ids)} settings in settings.xml") + logger.debug(f"Setting IDs: {setting_ids}") + + return setting_ids + + except Exception as e: + logger.error(f"Error reading settings.xml: {e}") + return [] + + def _parse_provider_country(self, setting_id: str) -> Optional[Tuple[str, Optional[str]]]: + """ + Parse a setting ID to extract provider and optional country. + + Pattern: + - provider_marker → (provider, None) + - provider_country_marker → (provider, country) + - anything else → None + + Args: + setting_id: Setting ID like "joyn_username" or "joyn_de_username" + + Returns: + Tuple of (provider, country) or None if pattern doesn't match + """ + # Check if ends with a credential marker + marker = None + for m in self.CREDENTIAL_MARKERS: + if setting_id.endswith(m): + marker = m + break + + if not marker: + return None + + # Remove the marker + prefix = setting_id[:-len(marker)] + + # Split by underscore + parts = prefix.split('_') + + if len(parts) == 1: + # provider only + return parts[0], None + elif len(parts) == 2: + # provider_country + return parts[0], parts[1] + else: + # Too many parts, doesn't match our pattern + return None + + def discover_all_providers(self) -> Dict[str, List[str]]: + """ + Scan all Kodi settings and discover providers with their countries dynamically. + + Returns: + Dict mapping provider names to list of countries. + Empty list means provider without country. + Example: {'joyn': ['de', 'at'], 'rtlplus': ['de'], 'zattoo': []} + """ + if not self.is_kodi_environment(): + return {} + + discovered: Dict[str, Set[Optional[str]]] = {} + + # Get all setting IDs + setting_ids = self._get_all_setting_ids() + + for setting_id in setting_ids: + result = self._parse_provider_country(setting_id) + if result: + provider, country = result + if provider not in discovered: + discovered[provider] = set() + discovered[provider].add(country) + + # Convert sets to lists, with None values converted to empty list + result = {} + for provider, countries in discovered.items(): + country_list = [] + for country in countries: + if country is None: + # Provider without country - represent as empty list for that provider + if not country_list: # Only add if we haven't already + result[provider] = [] + else: + country_list.append(country) + + if country_list: + result[provider] = sorted(country_list) + elif provider not in result: + result[provider] = [] + + logger.info(f"Discovered providers: {result}") + return result + + def detect_all_providers_from_kodi(self) -> Dict[str, List[str]]: + """ + Alias for discover_all_providers() for backward compatibility. + """ + return self.discover_all_providers() + + def detect_countries_for_provider(self, provider: str) -> List[str]: + """ + Discover which countries are configured for a specific provider. + + Args: + provider: Provider name (e.g., 'joyn') + + Returns: + List of detected country codes (e.g., ['de', 'at']) + """ + all_providers = self.discover_all_providers() + countries = all_providers.get(provider, []) + + # Filter out empty list (which means provider without country) + if not countries: + return [] + + return countries + + 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. + + Args: + provider: Provider name + available_countries: List of country codes to check + + Returns: + List of country codes that have credentials in Kodi + """ + discovered_countries = self.detect_countries_for_provider(provider) + return [c for c in available_countries if c in discovered_countries] + + # ============= Credential Operations ============= + + def read_credentials_from_kodi(self, provider: str, country: Optional[str] = None) -> Optional[BaseCredentials]: + """ + Read authentication credentials from Kodi settings for a provider. + Uses convention: {provider}_{country}_username, {provider}_{country}_password, etc. + If country is None, tries without country suffix for backward compatibility. + """ + if not self.is_kodi_environment(): + return None + + country_suffix = f"_{country}" if country else "" + + try: + # Try convention-based setting names + username = self.addon.getSetting(f'{provider}{country_suffix}_username') + password = self.addon.getSetting(f'{provider}{country_suffix}_password') + client_id = self.addon.getSetting(f'{provider}{country_suffix}_client_id') + client_secret = self.addon.getSetting(f'{provider}{country_suffix}_client_secret') + + logger.debug(f"Kodi settings for {provider}{country_suffix}:") + logger.debug(f" username: '{username}' (empty={not username})") + logger.debug(f" password: {'***' if password else '(empty)'}") + logger.debug(f" client_id: '{client_id}' (empty={not client_id})") + logger.debug(f" client_secret: {'***' if client_secret else '(empty)'}") + + # Determine credential type based on available values + if username and password: + logger.info(f"Found username/password credentials for {provider}{country_suffix} in Kodi") + return UserPasswordCredentials( + username=username.strip(), + password=password.strip(), + 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} in Kodi") + return ClientCredentials( + client_id=client_id.strip(), + client_secret=client_secret.strip() + ) + + logger.debug(f"No valid credentials found in Kodi for {provider}{country_suffix}") + return None + + except Exception as e: + logger.error(f"Error reading credentials from Kodi for {provider}: {e}") + return None + + def write_credentials_to_kodi(self, provider: str, credentials: BaseCredentials, + country: Optional[str] = None) -> bool: + """Write authentication credentials to Kodi settings""" + if not self.is_kodi_environment(): + return False + + country_suffix = f"_{country}" if country else "" + + try: + if isinstance(credentials, UserPasswordCredentials): + self.addon.setSetting(f'{provider}{country_suffix}_username', credentials.username) + self.addon.setSetting(f'{provider}{country_suffix}_password', credentials.password) + if credentials.client_id: + self.addon.setSetting(f'{provider}{country_suffix}_client_id', credentials.client_id) + logger.info(f"Wrote username/password credentials to Kodi for {provider}{country_suffix}") + return True + + elif isinstance(credentials, ClientCredentials): + self.addon.setSetting(f'{provider}{country_suffix}_client_id', credentials.client_id) + self.addon.setSetting(f'{provider}{country_suffix}_client_secret', credentials.client_secret) + logger.info(f"Wrote client credentials to Kodi for {provider}{country_suffix}") + return True + + return False + + except Exception as e: + logger.error(f"Error writing credentials to Kodi for {provider}: {e}") + return False + + def sync_credentials_to_file(self, provider: str, credential_manager, + country: Optional[str] = None) -> bool: + """Sync provider credentials from Kodi settings to credential file""" + credentials = self.read_credentials_from_kodi(provider, country) + if not credentials: + logger.debug(f"No credentials to sync for {provider}") + return False + + # Check if credentials are different from file + file_credentials = credential_manager.load_credentials(provider, country) + if self._credentials_equal(credentials, file_credentials): + logger.debug(f"Credentials already in sync for {provider}") + return True # Already in sync + + success = credential_manager.save_credentials(provider, credentials, country) + if success: + logger.info(f"Synced credentials from Kodi to file for {provider}") + return success + + # ============= Proxy Operations ============= + + def read_proxy_config_from_kodi(self, provider: str, country: Optional[str] = None) -> Optional[ProxyConfig]: + """ + Read proxy configuration from Kodi settings for a provider. + Uses convention: {provider}_{country}_proxy_enabled, {provider}_{country}_proxy_host, etc. + """ + if not self.is_kodi_environment(): + return None + + country_suffix = f"_{country}" if country else "" + + try: + # Check if proxy is enabled + proxy_enabled = self.addon.getSetting(f'{provider}{country_suffix}_proxy_enabled') + logger.debug(f"Proxy enabled setting for {provider}{country_suffix}: '{proxy_enabled}'") + + if not proxy_enabled or proxy_enabled.lower() not in ['true', '1', 'yes']: + logger.debug(f"Proxy not enabled for {provider}{country_suffix}") + return None + + proxy_host = self.addon.getSetting(f'{provider}{country_suffix}_proxy_host') + proxy_port_str = self.addon.getSetting(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}") + return None + + try: + proxy_port = int(proxy_port_str) + except ValueError: + 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} in Kodi: {proxy_host}:{proxy_port}") + return proxy_config + + except Exception as e: + logger.error(f"Error reading proxy config from Kodi for {provider}: {e}") + return None + + def write_proxy_config_to_kodi(self, provider: str, proxy_config: ProxyConfig, + country: Optional[str] = None) -> bool: + """Write proxy configuration to Kodi settings""" + if not self.is_kodi_environment(): + return False + + country_suffix = f"_{country}" if country else "" + + try: + self.addon.setSetting(f'{provider}{country_suffix}_proxy_enabled', 'true') + self.addon.setSetting(f'{provider}{country_suffix}_proxy_host', proxy_config.host) + self.addon.setSetting(f'{provider}{country_suffix}_proxy_port', str(proxy_config.port)) + + logger.info(f"Wrote proxy config to Kodi for {provider}{country_suffix}") + return True + + except Exception as e: + logger.error(f"Error writing proxy config to Kodi for {provider}: {e}") + return False + + def sync_proxy_config_to_file(self, provider: str, proxy_manager, + country: Optional[str] = None) -> bool: + """Sync provider proxy config from Kodi settings to proxy config file""" + proxy_config = self.read_proxy_config_from_kodi(provider, country) + if not proxy_config: + logger.debug(f"No proxy config to sync for {provider}") + return False + + # Check if proxy config is different from file + file_proxy = proxy_manager.get_proxy_config(provider, country) + if self._proxy_configs_equal(proxy_config, file_proxy): + logger.debug(f"Proxy config already in sync for {provider}") + return True # Already in sync + + success = proxy_manager.set_proxy_config(provider, proxy_config, country) + if success: + logger.info(f"Synced proxy config from Kodi to file for {provider}") + return success + + # ============= Comparison Helpers ============= + + @staticmethod + 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 + if cred1 is None or cred2 is None: + return False + 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) + + return False + + @staticmethod + 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 + if proxy1 is None or proxy2 is None: + return False + + # Compare basic properties + 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): + 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): + return False + + return True \ No newline at end of file diff --git a/lib/streaming_providers/base/settings/models/__init__.py b/lib/streaming_providers/base/settings/models/__init__.py new file mode 100644 index 0000000..c9bd176 --- /dev/null +++ b/lib/streaming_providers/base/settings/models/__init__.py @@ -0,0 +1,42 @@ +# 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 +) + +__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', + + # From provider_settings.py + 'ProviderSettingsSchema', + 'StandardProviderSettings' +] diff --git a/lib/streaming_providers/base/settings/models/provider_settings.py b/lib/streaming_providers/base/settings/models/provider_settings.py new file mode 100644 index 0000000..4bcfc1d --- /dev/null +++ b/lib/streaming_providers/base/settings/models/provider_settings.py @@ -0,0 +1,603 @@ +# 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 ...utils.logger import logger + + +@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) + _kodi_mapping: Dict[str, str] = field(default_factory=dict) + + def __post_init__(self): + """Initialize schema with default settings""" + self._build_default_schema() + + def _build_default_schema(self) -> None: + """Build the complete settings schema for this provider""" + # Build all setting categories + credential_settings = self.define_credential_settings() + proxy_settings = self.define_proxy_settings() + video_settings = self.define_video_settings() + drm_settings = self.define_drm_settings() + network_settings = self.define_network_settings() + + # Merge all settings + self._settings.update(credential_settings) + self._settings.update(proxy_settings) + self._settings.update(video_settings) + self._settings.update(drm_settings) + self._settings.update(network_settings) + + # 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()) + } + + # Build Kodi mapping + self._build_kodi_mapping() + + def _build_kodi_mapping(self) -> None: + """Build mapping from internal setting names to Kodi setting IDs""" + for setting_name, setting in self._settings.items(): + if setting.kodi_setting_id: + self._kodi_mapping[setting_name] = setting.kodi_setting_id + + def define_credential_settings(self) -> Dict[str, SettingValue]: + """Define credential-related settings for this provider""" + settings = {} + + # Common credential settings - can be overridden in subclasses + settings['username'] = ( + string_setting() + .required() + .min_length(1) + .display_name("Username") + .description("Account username or email address") + .kodi_setting(f"{self.provider_name}_username") + .build() + ) + + settings['password'] = ( + password_setting() + .display_name("Password") + .description("Account password") + .kodi_setting(f"{self.provider_name}_password") + .build() + ) + + # Optional client ID for OAuth providers + settings['client_id'] = ( + string_setting() + .display_name("Client ID") + .description("OAuth client identifier (if required)") + .kodi_setting(f"{self.provider_name}_client_id") + .build() + ) + + # Optional client secret for OAuth providers + settings['client_secret'] = ( + password_setting(required=False) + .display_name("Client Secret") + .description("OAuth client secret (if required)") + .kodi_setting(f"{self.provider_name}_client_secret") + .build() + ) + + return settings + + def define_proxy_settings(self) -> Dict[str, SettingValue]: + """Define proxy-related settings for this provider""" + settings = {} + + # Enable proxy for this provider + settings['proxy_enabled'] = ( + boolean_setting(False) + .display_name("Enable Proxy") + .description("Use proxy for this provider") + .kodi_setting(f"{self.provider_name}_proxy_enabled") + .build() + ) + + # Proxy host + settings['proxy_host'] = ( + string_setting() + .display_name("Proxy Host") + .description("Proxy server hostname or IP address") + .kodi_setting(f"{self.provider_name}_proxy_host") + .build() + ) + + # Proxy port + settings['proxy_port'] = ( + port_setting(8080) + .display_name("Proxy Port") + .description("Proxy server port") + .kodi_setting(f"{self.provider_name}_proxy_port") + .build() + ) + + # Proxy type + 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") + .build() + ) + + # Proxy authentication + settings['proxy_auth_enabled'] = ( + boolean_setting(False) + .display_name("Proxy Authentication") + .description("Proxy server requires authentication") + .kodi_setting(f"{self.provider_name}_proxy_auth_enabled") + .build() + ) + + # Proxy username + settings['proxy_username'] = ( + string_setting() + .display_name("Proxy Username") + .description("Username for proxy authentication") + .kodi_setting(f"{self.provider_name}_proxy_username") + .build() + ) + + # Proxy password + settings['proxy_password'] = ( + password_setting(required=False) + .display_name("Proxy Password") + .description("Password for proxy authentication") + .kodi_setting(f"{self.provider_name}_proxy_password") + .build() + ) + + # Proxy scope settings + settings['proxy_scope_api'] = ( + boolean_setting(True) + .display_name("Proxy API Calls") + .description("Use proxy for API requests") + .kodi_setting(f"{self.provider_name}_proxy_scope_api") + .build() + ) + + settings['proxy_scope_auth'] = ( + boolean_setting(True) + .display_name("Proxy Authentication") + .description("Use proxy for authentication requests") + .kodi_setting(f"{self.provider_name}_proxy_scope_auth") + .build() + ) + + settings['proxy_scope_manifest'] = ( + boolean_setting(True) + .display_name("Proxy Manifests") + .description("Use proxy for manifest downloads") + .kodi_setting(f"{self.provider_name}_proxy_scope_manifest") + .build() + ) + + settings['proxy_scope_license'] = ( + boolean_setting(True) + .display_name("Proxy DRM Licenses") + .description("Use proxy for DRM license requests") + .kodi_setting(f"{self.provider_name}_proxy_scope_license") + .build() + ) + + return settings + + def define_video_settings(self) -> Dict[str, SettingValue]: + """Define video quality/format settings for this provider""" + settings = {} + + # Video quality preference + 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") + .build() + ) + + # Audio language preference + 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") + .build() + ) + + # Subtitle language preference + 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") + .build() + ) + + # Speed up streams + settings['speed_up'] = ( + boolean_setting(True) + .display_name("Speed Up Playback") + .description("Enable faster stream startup") + .kodi_setting(f"{self.provider_name}_speed_up") + .build() + ) + + return settings + + def define_drm_settings(self) -> Dict[str, SettingValue]: + """Define DRM/CDM related settings for this provider""" + settings = {} + + # CDM usage + settings['use_cdm'] = ( + boolean_setting(True) + .display_name("Use CDM") + .description("Enable Content Decryption Module for DRM") + .kodi_setting(f"{self.provider_name}_use_cdm") + .build() + ) + + # CDM type + 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") + .build() + ) + + # CDM mode + 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") + .build() + ) + + return settings + + def define_network_settings(self) -> Dict[str, SettingValue]: + """Define network timeout/retry settings for this provider""" + settings = {} + + # Request timeout + settings['request_timeout'] = ( + integer_setting(30, 5, 120) + .display_name("Request Timeout") + .description("Timeout for network requests in seconds") + .kodi_setting(f"{self.provider_name}_request_timeout") + .build() + ) + + # Max retries + settings['max_retries'] = ( + integer_setting(3, 0, 10) + .display_name("Max Retries") + .description("Maximum number of retry attempts") + .kodi_setting(f"{self.provider_name}_max_retries") + .build() + ) + + # Retry delay + settings['retry_delay'] = ( + integer_setting(1, 0, 10) + .display_name("Retry Delay") + .description("Delay between retry attempts in seconds") + .kodi_setting(f"{self.provider_name}_retry_delay") + .build() + ) + + # Verify SSL + settings['verify_ssl'] = ( + boolean_setting(True) + .display_name("Verify SSL") + .description("Verify SSL certificates for HTTPS requests") + .kodi_setting(f"{self.provider_name}_verify_ssl") + .build() + ) + + return settings + + def get_all_settings(self) -> Dict[str, SettingValue]: + """Get complete settings schema for this provider""" + return self._settings.copy() + + def get_setting(self, setting_name: str) -> Optional[SettingValue]: + """Get a specific setting by name""" + return self._settings.get(setting_name) + + def get_settings_by_category(self, category: str) -> Dict[str, SettingValue]: + """Get all settings in a specific category""" + if category not in self._categories: + return {} + + category_settings = {} + for setting_name in self._categories[category]: + if setting_name in self._settings: + category_settings[setting_name] = self._settings[setting_name] + + return category_settings + + def get_categories(self) -> List[str]: + """Get list of all setting categories""" + return list(self._categories.keys()) + + def get_kodi_setting_mapping(self) -> Dict[str, str]: + """Get mapping from internal setting names to Kodi setting IDs""" + return self._kodi_mapping.copy() + + def get_reverse_kodi_mapping(self) -> Dict[str, str]: + """Get mapping from Kodi setting IDs to internal setting names""" + return {v: k for k, v in self._kodi_mapping.items()} + + def validate_all_settings(self) -> Dict[str, tuple[bool, List[str]]]: + """Validate all settings in the schema""" + results = {} + for setting_name, setting in self._settings.items(): + results[setting_name] = setting.validate() + return results + + def get_required_settings(self) -> List[str]: + """Get list of setting names that are required""" + 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) + if has_required_rule: + required.append(setting_name) + return required + + def get_incomplete_settings(self) -> List[str]: + """Get list of required settings that don't have values""" + incomplete = [] + required_settings = self.get_required_settings() + + for setting_name in required_settings: + setting = self._settings[setting_name] + if not setting.has_value(): + incomplete.append(setting_name) + + return incomplete + + def is_configuration_complete(self) -> bool: + """Check if all required settings have values""" + return len(self.get_incomplete_settings()) == 0 + + def get_configuration_completeness(self) -> Dict[str, Any]: + """Get detailed information about configuration completeness""" + required_settings = self.get_required_settings() + incomplete_settings = self.get_incomplete_settings() + + 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] + + 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) + ) + } + + 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) + ), + 'categories': completeness_by_category, + 'incomplete_setting_names': incomplete_settings + } + + 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 + + if category not in self._categories: + self._categories[category] = set() + self._categories[category].add(setting_name) + + if setting.kodi_setting_id: + self._kodi_mapping[setting_name] = setting.kodi_setting_id + + def remove_setting(self, setting_name: str) -> bool: + """Remove a setting from the schema""" + if setting_name not in self._settings: + return False + + # Remove from settings + del self._settings[setting_name] + + # Remove from categories + for category_settings in self._categories.values(): + category_settings.discard(setting_name) + + # Remove from Kodi mapping + if setting_name in self._kodi_mapping: + del self._kodi_mapping[setting_name] + + return True + + 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() + }, + 'categories': { + category: list(settings) + for category, settings in self._categories.items() + }, + 'kodi_mapping': self._kodi_mapping.copy(), + 'configuration_status': self.get_configuration_completeness() + } + + +class StandardProviderSettings: + """Standard settings schemas for common providers""" + + _registered_schemas: Dict[str, ProviderSettingsSchema] = {} + + @classmethod + def get_rtlplus_schema(cls) -> ProviderSettingsSchema: + """Get RTL Plus settings schema""" + 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" + + # Add RTL Plus specific settings + schema.add_custom_setting( + 'device_id', + string_setting() + .display_name("Device ID") + .description("Unique device identifier for RTL Plus") + .kodi_setting("rtlplus_device_id") + .build(), + 'credentials' + ) + + cls._registered_schemas['rtlplus'] = schema + + 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') + + # Joyn doesn't require authentication for basic content + 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 + if rule.name != "not_empty" + ] + + cls._registered_schemas['joyn'] = schema + + 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') + + # ZDF is free, no authentication required + 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') + .display_name("Geographic Location") + .description("Your geographic location for content filtering") + .kodi_setting("zdf_geo_location") + .build(), + 'network' + ) + + cls._registered_schemas['zdf'] = schema + + 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') + + # ARD is free, no authentication required + schema.remove_setting('username') + schema.remove_setting('password') + schema.remove_setting('client_id') + schema.remove_setting('client_secret') + + cls._registered_schemas['ard'] = schema + + return cls._registered_schemas['ard'] + + @classmethod + 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]: + """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' + if hasattr(cls, method_name): + return getattr(cls, method_name)() + + # Check registered schemas + if provider_name in cls._registered_schemas: + return cls._registered_schemas[provider_name] + + # Create default schema + logger.info(f"Creating default settings schema for provider: {provider_name}") + schema = ProviderSettingsSchema(provider_name) + cls._registered_schemas[provider_name] = schema + return schema + + @classmethod + def list_registered_providers(cls) -> List[str]: + """Get list of all registered provider schemas""" + # 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': + 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()))) + return sorted(all_providers) + + @classmethod + def unregister_provider_schema(cls, provider_name: str) -> bool: + """Unregister a provider schema""" + if provider_name in cls._registered_schemas: + del cls._registered_schemas[provider_name] + logger.info(f"Unregistered settings schema for provider: {provider_name}") + return True + return False diff --git a/lib/streaming_providers/base/settings/models/settings_models.py b/lib/streaming_providers/base/settings/models/settings_models.py new file mode 100644 index 0000000..d4d7ea3 --- /dev/null +++ b/lib/streaming_providers/base/settings/models/settings_models.py @@ -0,0 +1,566 @@ +# 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 +from urllib.parse import urlparse + + +class SettingType(Enum): + """Supported setting value types""" + STRING = "string" + INTEGER = "integer" + BOOLEAN = "boolean" + FLOAT = "float" + SELECT = "select" # dropdown with predefined options + PASSWORD = "password" # masked string input + URL = "url" # URL with validation + IP_ADDRESS = "ip_address" # IP address validation + PORT = "port" # Port number (1-65535) + EMAIL = "email" # Email address validation + + +@dataclass +class ValidationRule: + """Validation rule for a setting value""" + name: str + validator: Callable[[Any], bool] + error_message: str + + def validate(self, value: Any) -> bool: + """Validate a setting value against this rule""" + try: + return self.validator(value) + except Exception: + return False + + def get_error_message(self) -> str: + """Get human-readable error message for validation failure""" + return self.error_message + + +class StandardValidationRules: + """Standard validation rules for common use cases""" + + @staticmethod + def not_empty(error_msg: str = "Value cannot be empty") -> ValidationRule: + """Rule to ensure value is not empty""" + return ValidationRule( + name="not_empty", + validator=lambda x: x is not None and str(x).strip() != "", + error_message=error_msg + ) + + @staticmethod + def min_length(min_len: int, error_msg: Optional[str] = None) -> ValidationRule: + """Rule to ensure string has minimum length""" + if error_msg is None: + error_msg = f"Value must be at least {min_len} characters long" + return ValidationRule( + name="min_length", + validator=lambda x: isinstance(x, str) and len(x) >= min_len, + error_message=error_msg + ) + + @staticmethod + def max_length(max_len: int, error_msg: Optional[str] = None) -> ValidationRule: + """Rule to ensure string has maximum length""" + if error_msg is None: + error_msg = f"Value must be at most {max_len} characters long" + return ValidationRule( + name="max_length", + validator=lambda x: isinstance(x, str) and len(x) <= max_len, + error_message=error_msg + ) + + @staticmethod + 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 + ) + + @staticmethod + def regex_pattern(pattern: str, error_msg: str) -> ValidationRule: + """Rule to validate against regex pattern""" + compiled_pattern = re.compile(pattern) + return ValidationRule( + name="regex_pattern", + validator=lambda x: isinstance(x, str) and bool(compiled_pattern.match(x)), + 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,}$' + return StandardValidationRules.regex_pattern(email_pattern, error_msg) + + @staticmethod + def valid_url(error_msg: str = "Invalid URL format") -> ValidationRule: + """Rule to validate URL format""" + + def validate_url(value: str) -> bool: + if not isinstance(value, str): + return False + try: + result = urlparse(value) + return all([result.scheme, result.netloc]) + except Exception: + return False + + return ValidationRule( + name="valid_url", + validator=validate_url, + error_message=error_msg + ) + + @staticmethod + 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: + if not isinstance(value, str): + return False + try: + ipaddress.ip_address(value) + return True + except ValueError: + return False + + return ValidationRule( + 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: + """Rule to validate port number""" + return ValidationRule( + name="valid_port", + validator=lambda x: isinstance(x, int) and 1 <= x <= 65535, + error_message=error_msg + ) + + @staticmethod + 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 + ) + + +@dataclass +class SettingValue: + """Container for a setting value with metadata and validation""" + setting_type: SettingType + default_value: Any = None + current_value: Any = None + choices: Optional[List[Any]] = None # For SELECT type + validation_rules: List[ValidationRule] = field(default_factory=list) + description: Optional[str] = None + display_name: Optional[str] = None + is_sensitive: bool = False # For passwords, secrets, etc. + kodi_setting_id: Optional[str] = None # Mapping to Kodi setting ID + + def __post_init__(self): + """Post-initialization setup""" + # Set current value to default if not provided + if self.current_value is None: + self.current_value = self.default_value + + # Add type-specific validation rules + self._add_type_validation() + + # Add choices validation for SELECT type + if self.setting_type == SettingType.SELECT and 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)""" + return any(rule.name == "not_empty" for rule in self.validation_rules) + + 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" + )) + + 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" + )) + + 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" + )) + + elif self.setting_type == SettingType.URL: + self.add_validation_rule(StandardValidationRules.valid_url()) + + elif self.setting_type == SettingType.IP_ADDRESS: + self.add_validation_rule(StandardValidationRules.valid_ip_address()) + + elif self.setting_type == SettingType.PORT: + self.add_validation_rule(StandardValidationRules.valid_port()) + + elif self.setting_type == SettingType.EMAIL: + self.add_validation_rule(StandardValidationRules.valid_email()) + + def _is_valid_float_string(self, value: Any) -> bool: + """Check if string can be converted to float""" + if not isinstance(value, str): + return False + try: + float(value) + return True + except ValueError: + return False + + def set_value(self, value: Any) -> bool: + """ + Set the setting value with validation and type conversion + + Args: + value: New value to set + + Returns: + True if value was set successfully, False if validation failed + """ + # Convert value to appropriate type + converted_value = self._convert_value(value) + if converted_value is None: + return False + + # Validate the converted value + if not self._validate_value(converted_value): + return False + + self.current_value = converted_value + return True + + def _convert_value(self, value: Any) -> Any: + """Convert value to the appropriate type""" + if value is None: + return None + + try: + 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: + if isinstance(value, int): + return value + elif isinstance(value, str) and value.isdigit(): + return int(value) + else: + return None + + elif self.setting_type == SettingType.FLOAT: + if isinstance(value, (int, float)): + return float(value) + elif isinstance(value, str): + return float(value) + else: + return None + + elif self.setting_type == SettingType.BOOLEAN: + if isinstance(value, bool): + return value + elif isinstance(value, str): + 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]: + return str(value) + + else: + return value + + except (ValueError, TypeError): + return None + + def _validate_value(self, value: Any) -> bool: + """Validate value against all validation rules""" + for rule in self.validation_rules: + if not rule.validate(value): + return False + return True + + def get_value(self) -> Any: + """Get the current setting value""" + return self.current_value + + def get_display_value(self) -> str: + """Get value formatted for display (masks sensitive values)""" + if self.is_sensitive and self.current_value: + return "*" * min(len(str(self.current_value)), 8) + return str(self.current_value) if self.current_value is not None else "" + + def add_validation_rule(self, rule: ValidationRule) -> None: + """Add a validation rule to this setting""" + # Check if rule with same name already exists + existing_names = {r.name for r in self.validation_rules} + if rule.name not in existing_names: + self.validation_rules.append(rule) + + 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] + return len(self.validation_rules) < original_length + + def validate(self) -> tuple[bool, List[str]]: + """ + Validate current value against all rules + + Returns: + Tuple of (is_valid, list_of_error_messages) + """ + 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) + if has_required_rule: + return False, ["Value is required"] + else: + return True, [] + + errors = [] + for rule in self.validation_rules: + if not rule.validate(self.current_value): + errors.append(rule.get_error_message()) + + return len(errors) == 0, errors + + def reset_to_default(self) -> None: + """Reset value to default""" + self.current_value = self.default_value + + def has_value(self) -> bool: + """Check if setting has a non-None value""" + return self.current_value is not None + + def is_modified(self) -> bool: + """Check if current value differs from default""" + return self.current_value != self.default_value + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary representation""" + 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() + } + + @classmethod + 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') + ) + return setting + + def clone(self) -> 'SettingValue': + """Create a copy of this setting""" + return SettingValue( + setting_type=self.setting_type, + default_value=self.default_value, + current_value=self.current_value, + choices=self.choices.copy() if self.choices else None, + validation_rules=self.validation_rules.copy(), + description=self.description, + display_name=self.display_name, + is_sensitive=self.is_sensitive, + kodi_setting_id=self.kodi_setting_id + ) + + +class SettingValueBuilder: + """Builder pattern for creating SettingValue instances""" + + def __init__(self, setting_type: SettingType): + self._setting_type = setting_type + self._default_value = None + self._choices = None + self._validation_rules = [] + self._description = None + self._display_name = None + self._is_sensitive = False + self._kodi_setting_id = None + + def default(self, value: Any) -> 'SettingValueBuilder': + """Set default value""" + self._default_value = value + return self + + def choices(self, choices: List[Any]) -> 'SettingValueBuilder': + """Set choices for SELECT type""" + self._choices = choices + return self + + def description(self, desc: str) -> 'SettingValueBuilder': + """Set description""" + self._description = desc + return self + + def display_name(self, name: str) -> 'SettingValueBuilder': + """Set display name""" + self._display_name = name + return self + + 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': + """Set Kodi setting ID mapping""" + self._kodi_setting_id = setting_id + return self + + 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': + """Add minimum length validation""" + self._validation_rules.append(StandardValidationRules.min_length(length)) + return self + + 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': + """Add numeric range validation""" + self._validation_rules.append(StandardValidationRules.numeric_range(min_val, max_val)) + return self + + def custom_validation(self, rule: ValidationRule) -> 'SettingValueBuilder': + """Add custom validation rule""" + self._validation_rules.append(rule) + return self + + def build(self) -> SettingValue: + """Build the SettingValue instance""" + setting = SettingValue( + setting_type=self._setting_type, + 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 + ) + + # Add custom validation rules + for rule in self._validation_rules: + setting.add_validation_rule(rule) + + return setting + + +# Convenience factory functions +def string_setting(default: str = "", required: bool = False) -> SettingValueBuilder: + """Create a string setting builder""" + builder = SettingValueBuilder(SettingType.STRING).default(default) + if required: + builder.required() + return builder + + +def password_setting(required: bool = True) -> SettingValueBuilder: + """Create a password setting builder""" + builder = SettingValueBuilder(SettingType.PASSWORD).sensitive(True) + if required: + builder.required() + return builder + + +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: + builder.numeric_range(min_val, max_val) + return builder + + +def boolean_setting(default: bool = False) -> SettingValueBuilder: + """Create a boolean setting builder""" + return SettingValueBuilder(SettingType.BOOLEAN).default(default) + + +def select_setting(choices: List[Any], default: Any = None) -> SettingValueBuilder: + """Create a select setting builder""" + builder = SettingValueBuilder(SettingType.SELECT).choices(choices) + if default is not None: + builder.default(default) + return builder + + +def url_setting(required: bool = False) -> SettingValueBuilder: + """Create a URL setting builder""" + builder = SettingValueBuilder(SettingType.URL) + if required: + builder.required() + return builder + + +def port_setting(default: int = 8080) -> SettingValueBuilder: + """Create a port setting builder""" + return SettingValueBuilder(SettingType.PORT).default(default) + + +def ip_setting(required: bool = False) -> SettingValueBuilder: + """Create an IP address setting builder""" + builder = SettingValueBuilder(SettingType.IP_ADDRESS) + if required: + builder.required() + return builder diff --git a/lib/streaming_providers/base/settings/settings_manager.py b/lib/streaming_providers/base/settings/settings_manager.py new file mode 100644 index 0000000..b5d3b86 --- /dev/null +++ b/lib/streaming_providers/base/settings/settings_manager.py @@ -0,0 +1,1129 @@ +# streaming_providers/base/settings/settings_manager.py +""" +Unified Settings Manager - Country-Aware Implementation +Supports country-specific credentials, sessions, and configurations +""" + +import json +import time +import os +from typing import Dict, Any, Optional, List +from pathlib import Path +from dataclasses import dataclass, field + +from ..auth.session_manager import SessionManager +from ..auth.credential_manager import CredentialManager +from ..auth.credentials import BaseCredentials +from ..network.proxy_manager import ProxyConfigManager +from ..models.proxy_models import ProxyConfig +from ..utils.logger import logger +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .kodi_settings_bridge import KodiSettingsBridge + + +@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 + + +# Maintain backward compatibility by keeping the original class name +UnifiedSettingsManager = None # Will be set below for backward compatibility + + +class SettingsManager: + """ + Unified interface for all streaming provider settings management. + Now supports country-specific settings for multi-region providers. + """ + + def __init__(self, config_dir: Optional[str] = None, enable_kodi_integration: bool = True): + """ + Initialize unified settings manager + + Args: + config_dir: Optional config directory override + enable_kodi_integration: Enable Kodi settings bridge integration + """ + # Store config directory for component managers + self.config_dir_path = config_dir + + # Initialize VFS for all file operations + from ..utils.vfs import VFS + self.vfs = VFS(config_dir=config_dir) + + # Maintain config_path for backward compatibility + if config_dir: + self.config_path = Path(config_dir) + else: + # 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' + + # Initialize component managers - pass the original config_dir + self.session_manager = SessionManager(self.config_dir_path) + self.credential_manager = CredentialManager(self.config_dir_path) + self.proxy_manager = ProxyConfigManager(self.config_dir_path) + + # Initialize Kodi integration if enabled + self.enable_kodi_integration = enable_kodi_integration + self.kodi_bridge: Optional['KodiSettingsBridge'] = None + + if enable_kodi_integration: + success = self._setup_kodi_integration() + if not success: + self.kodi_bridge = None + self.enable_kodi_integration = False + + # Provider registry + self._registered_providers: Dict[str, ProviderRegistration] = {} + + # Load existing configuration + 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(): + logger.info("Performing initial sync from Kodi...") + + # First, detect all providers from Kodi (including unregistered ones) + detected_providers = self.kodi_bridge.detect_all_providers_from_kodi() + logger.info(f"Detected providers from Kodi: {detected_providers}") + + # 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") + self.register_provider( + provider_name, + supports_countries=bool(countries), + 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}") + reg = self._registered_providers[provider_name] + reg.supports_countries = True + reg.available_countries = countries + self._save_configuration() + + # Now sync all registered providers + for provider_name in self.list_registered_providers(): + # Check current registration status + 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}") + + for country in available_countries: + 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})...") + self._sync_proxy_from_kodi(provider_name, country) + else: + # Single-country provider (backward compatibility) + logger.info(f"Syncing credentials for {provider_name}...") + self._sync_credentials_from_kodi(provider_name) + + 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}") + + def _setup_kodi_integration(self) -> bool: + """Setup Kodi integration, return True if successful""" + try: + from .kodi_settings_bridge import KodiSettingsBridge + except ImportError: + logger.debug("KodiSettingsBridge not available") + return False + + try: + self.kodi_bridge = KodiSettingsBridge() + if self.kodi_bridge and self.kodi_bridge.is_kodi_environment(): + logger.info("Kodi integration enabled") + return True + else: + logger.debug("KodiSettingsBridge created but not in Kodi environment") + self.kodi_bridge = None + return False + except Exception as e: + logger.error(f"Failed to initialize Kodi bridge: {e}") + self.kodi_bridge = None + return False + + # ============= Internal Methods ============= + + def _load_configuration(self) -> None: + """Load settings manager configuration from file using VFS""" + if not self.vfs.exists(self.settings_file): + logger.debug("No unified settings file found, starting fresh") + return + + try: + data = self.vfs.read_json(self.settings_file) + + # Load provider registrations + 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', []) + ) + self._registered_providers[provider_name] = registration + 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") + + except Exception as e: + logger.error(f"Error loading unified settings configuration: {e}") + + def _save_configuration(self) -> bool: + """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': {} + } + + # 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 + } + + success = self.vfs.write_json(self.settings_file, data) + if success: + logger.debug("Successfully saved unified settings configuration") + else: + logger.error("Failed to save unified settings configuration") + return success + + except Exception as e: + logger.error(f"Error saving unified settings configuration: {e}") + return False + + # ============= 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]: + """ + Get credentials for a provider with optional country and automatic Kodi sync + + Args: + provider_name: Name of the provider + country: Optional country code (e.g., 'de', 'at', 'ch') + + Returns: + BaseCredentials instance or None + """ + country_str = f" (country: {country})" if country else "" + 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}") + else: + logger.debug(f"SettingsManager: File credentials class: {credentials.__class__.__name__}") + else: + 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 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)}") + if credentials: + 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}") + return credentials + + 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 + + Args: + provider_name: Name of the provider + credentials: Credentials to save + country: Optional country code + + Returns: + True if successful, False otherwise + """ + # Save to file first + 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}") + 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) + if not kodi_success: + 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]]: + """ + Load token data for a provider + + Args: + provider_name: Name of the provider + country: Optional country code + + Returns: + Dictionary with token data or None + """ + 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: + """ + Save token data for a provider + + Args: + provider_name: Name of the provider + token_data: Token data to save + country: Optional country code + + Returns: + True if successful, False otherwise + """ + return self.session_manager.save_session(provider_name, token_data, country) + + def get_device_id(self, provider_name: str, country: Optional[str] = None) -> str: + """ + Get or generate device ID for a provider + + Args: + provider_name: Name of the provider + country: Optional country code + + Returns: + Device ID string + """ + return self.session_manager.get_device_id(provider_name, country) + + def clear_token(self, provider_name: str, country: Optional[str] = None) -> bool: + """ + Clear token data for a provider + + Args: + provider_name: Name of the provider + country: Optional country code + + Returns: + True if successful, False otherwise + """ + return self.session_manager.clear_token(provider_name, country) + + # ============= 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: + """ + Register a provider with the settings manager + + Args: + provider_name: Name of the provider to register + settings_schema: DEPRECATED - no longer used (kept for backward compatibility) + supports_countries: Whether this provider supports country-specific settings + available_countries: List of supported country codes (e.g., ['de', 'at', 'ch']) + + Returns: + True if successful, False otherwise + """ + try: + registration = ProviderRegistration( + name=provider_name, + is_active=True, + settings_schema=settings_schema, # Keep for backward compat, but not used + supports_countries=supports_countries, + available_countries=available_countries or [] + ) + + self._registered_providers[provider_name] = registration + + # Schema registration is no longer needed - Kodi bridge uses convention + # if self.kodi_bridge: + # self.kodi_bridge.register_provider_schema(provider_name, settings_schema) # ← REMOVED + + # 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}") + 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: + """ + Unregister a provider and optionally clean up its data + + Args: + provider_name: Name of the provider to unregister + cleanup_data: Whether to delete all data for this provider + + Returns: + True if successful, False otherwise + """ + if provider_name not in self._registered_providers: + logger.warning(f"Provider {provider_name} is not registered") + return True + + try: + # Cleanup data if requested + if cleanup_data: + # Clear all country data if provider supports countries + 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.session_manager.clear_session(provider_name, country) + self.proxy_manager.remove_proxy_config(provider_name, country) + else: + # Clear non-country-specific data + self.credential_manager.delete_credentials(provider_name) + self.session_manager.clear_session(provider_name) + self.proxy_manager.remove_proxy_config(provider_name) + + # Remove from registry + del self._registered_providers[provider_name] + + # Save configuration + success = self._save_configuration() + if success: + logger.info(f"Successfully unregistered provider: {provider_name}") + return success + + except Exception as e: + logger.error(f"Error unregistering provider {provider_name}: {e}") + return False + + 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] + + 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 + + def get_provider_countries(self, provider_name: str) -> List[str]: + """ + Get list of available countries for a provider + + Args: + provider_name: Name of the provider + + Returns: + List of country codes, empty if provider doesn't support countries + """ + if provider_name not in self._registered_providers: + return [] + + reg = self._registered_providers[provider_name] + return reg.available_countries if reg.supports_countries else [] + + def provider_supports_countries(self, provider_name: str) -> bool: + """ + Check if a provider supports country-specific settings + + Args: + provider_name: Name of the provider + + Returns: + True if provider supports countries, False otherwise + """ + if provider_name not in self._registered_providers: + return False + + return self._registered_providers[provider_name].supports_countries + + # ============= Configuration Status ============= + + def get_provider_status(self, provider_name: str, country: Optional[str] = None) -> Dict[str, Any]: + """ + Get comprehensive status for a provider + + Args: + provider_name: Name of the provider + country: Optional country code + + Returns: + Dictionary with provider status information + """ + if not self.is_provider_registered(provider_name): + return { + 'provider_name': provider_name, + 'country': country, + 'is_registered': False, + 'error': 'Provider not registered' + } + + registration = self._registered_providers[provider_name] + + # Check credential status + credentials = self.get_provider_credentials(provider_name, country) + has_credentials = credentials is not None + credentials_valid = credentials.validate() if credentials else False + + # Check session status + token_data = self.load_token_data(provider_name, country) + has_active_session = token_data is not None + + # Check proxy status + proxy_config = self.proxy_manager.get_proxy_config(provider_name, country) + has_proxy = proxy_config is not None + + status = { + 'provider_name': provider_name, + 'country': country, + 'is_registered': True, + '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) + }, + '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 + } + } + + # Add country-specific info if applicable + if registration.supports_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 + + return status + + def is_provider_ready(self, provider_name: str, country: Optional[str] = None) -> bool: + """ + Check if provider is fully configured and ready to use + + Args: + provider_name: Name of the provider + country: Optional country code + + Returns: + True if ready, False otherwise + """ + status = self.get_provider_status(provider_name, country) + + 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) + + def get_ready_providers(self, country: Optional[str] = None) -> List[str]: + """ + Get list of providers that are fully configured and ready + + Args: + country: Optional country code to check + + Returns: + List of provider names + """ + ready = [] + for provider_name in self.list_registered_providers(): + if self.is_provider_ready(provider_name, country): + ready.append(provider_name) + return ready + + # ============= Kodi Integration ============= + + def _sync_credentials_from_kodi(self, provider_name: str, country: Optional[str] = None) -> bool: + """ + Sync credentials from Kodi for a single provider + + Args: + provider_name: Name of the provider + country: Optional country code + + Returns: + 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}") + + 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}") + 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)}") + + if not kodi_credentials: + 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}") + return True # Not an error + + # Check if different from current file credentials + 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}") + 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.debug(f"SettingsManager: Kodi sync save result: {success}") + + if success: + 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}") + return False + + def sync_all_from_kodi(self) -> Dict[str, bool]: + """ + Sync all provider settings from Kodi (both single and multi-country) + + Returns: + Dictionary mapping provider names (or provider_country) to sync success status + """ + if not self.kodi_bridge or not self.kodi_bridge.is_kodi_environment(): + logger.debug("Not in Kodi environment, skipping sync") + return {} + + results = {} + + # Detect all providers from Kodi + detected_providers = self.kodi_bridge.detect_all_providers_from_kodi() + + for provider_name, countries in detected_providers.items(): + # Auto-register if not registered + if not self.is_provider_registered(provider_name): + self.register_provider( + provider_name, + supports_countries=bool(countries), + 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) + proxy_success = self._sync_proxy_from_kodi(provider_name, country) + results[f"{provider_name}_{country}"] = cred_success or proxy_success + else: + # Single provider + cred_success = self._sync_credentials_from_kodi(provider_name) + proxy_success = self._sync_proxy_from_kodi(provider_name) + results[provider_name] = cred_success or proxy_success + + return results + + @staticmethod + 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 + if cred1 is None or cred2 is None: + return False + if type(cred1) != type(cred2): + return False + + # Use the credential objects' own comparison logic if available + if hasattr(cred1, '__eq__'): + return cred1 == cred2 + + # Fallback to basic attribute comparison + return getattr(cred1, '__dict__', {}) == getattr(cred2, '__dict__', {}) + + # ============= Proxy Configuration ============= + + 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 self._sync_proxy_from_kodi(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: + """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) + if kodi_proxy_config: + 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: + """Set proxy configuration for a provider with Kodi sync""" + 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) + + return success + + 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: + """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]: + """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 + } + + 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') + } + + # ============= 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: + """ + Reset various data for a provider + + Args: + provider_name: Name of the provider + reset_credentials: Reset stored credentials + reset_session: Reset session data (tokens, device ID) + reset_proxy: Reset proxy configuration + country: Optional country code (if None, resets all countries for multi-country providers) + + Returns: + True if successful, False otherwise + """ + if not self.is_provider_registered(provider_name): + logger.error(f"Provider {provider_name} is not registered") + return False + + success = True + + try: + # If provider supports countries and no specific country given, reset all + if country is None and self.provider_supports_countries(provider_name): + countries = self.get_provider_countries(provider_name) + for ctry in countries: + if reset_credentials: + self.credential_manager.delete_credentials(provider_name, ctry) + if reset_session: + self.session_manager.clear_session(provider_name, ctry) + if reset_proxy: + self.proxy_manager.remove_proxy_config(provider_name, ctry) + logger.info(f"Reset all countries for {provider_name}") + else: + # 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 "")) + + if reset_session: + self.session_manager.clear_session(provider_name, country) + 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 "")) + + except Exception as e: + logger.error(f"Error resetting provider {provider_name}: {e}") + success = False + + return success + + def export_provider_settings(self, provider_name: str, country: Optional[str] = None) -> Dict[str, Any]: + """ + Export all settings for a single provider + + Args: + provider_name: Name of the provider + country: Optional country code + + Returns: + Dictionary with all provider settings + """ + if not self.is_provider_registered(provider_name): + 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) + } + + # Export credentials (without sensitive data) + credentials = self.get_provider_credentials(provider_name, country) + if credentials: + cred_export = credentials.__dict__.copy() + # Mask sensitive fields + for cred_field in ['password', 'client_secret']: + if cred_field in 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 session info (without tokens) + session_info = self.get_provider_session_info(provider_name, country) + 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 + + return export_data + + def export_all_settings(self, export_path: Optional[str] = None) -> str: + """ + Export all settings to a file + + Args: + export_path: Optional path for export file + + Returns: + Path to exported file + """ + if not export_path: + 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') + else: + 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' + }, + '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': {} + } + + # Export each provider + for provider_name in self.list_registered_providers(): + 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: + json.dump(export_data, f, indent=2, ensure_ascii=False, default=str) + + logger.info(f"Exported all settings to: {export_path}") + return export_path + + # ============= System Information ============= + + def get_system_status(self) -> Dict[str, Any]: + """Get comprehensive system status""" + + # 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 + } + else: + config_status = { + '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) + }, + '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()) + } + } + + def debug_provider(self, provider_name: str, country: Optional[str] = None) -> Dict[str, Any]: + """ + Get detailed debug information for a provider + + Args: + provider_name: Name of the provider + country: Optional country code + + Returns: + Dictionary with debug information + """ + debug_info: Dict[str, Any] = { + '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 + } + else: + 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 + } + else: + 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() + }, + '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() + } + } + + # 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' + } + else: + 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]: + """ + Get information about credentials for a provider + + Args: + provider_name: Name of the provider + country: Optional country code + + Returns: + Dictionary with credential information + """ + 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 + } + + def list_provider_countries_with_data(self, provider_name: str) -> List[str]: + """ + Get list of countries that actually have session/credential data for a provider + + Args: + provider_name: Name of the provider + + Returns: + List of country codes with data + """ + if not self.provider_supports_countries(provider_name): + return [] + + # 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: + """ + Migrate a non-country provider to country-aware structure + + Args: + provider_name: Name of the provider + default_country: Country code to migrate existing data to (e.g., 'de') + + Returns: + True if successful, False otherwise + """ + if not self.is_provider_registered(provider_name): + logger.error(f"Provider {provider_name} is not registered") + return False + + if self.provider_supports_countries(provider_name): + logger.warning(f"Provider {provider_name} already supports countries") + return True + + try: + 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) + old_session = self.session_manager.load_session(provider_name) + old_proxy = self.proxy_manager.get_proxy_config(provider_name) + + # Save to country-specific locations + if old_credentials: + 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) + logger.debug(f"Migrated session to {default_country}") + + if old_proxy: + 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 + self.credential_manager.delete_credentials(provider_name, None) + self.session_manager.clear_session(provider_name, None) + self.proxy_manager.remove_proxy_config(provider_name, None) + + # Update registration + reg = self._registered_providers[provider_name] + reg.supports_countries = True + reg.available_countries = [default_country] + self._save_configuration() + + logger.info(f"Successfully migrated {provider_name} to country structure") + return True + + except Exception as e: + logger.error(f"Error migrating {provider_name} to country structure: {e}") + return False + + +# For imports that expect the old interface +UnifiedSettingsManager = SettingsManager # Backward compatibility alias + +__all__ = ['SettingsManager', 'UnifiedSettingsManager', 'ProviderRegistration'] \ No newline at end of file diff --git a/lib/streaming_providers/base/utils/__init__.py b/lib/streaming_providers/base/utils/__init__.py new file mode 100644 index 0000000..0dd0527 --- /dev/null +++ b/lib/streaming_providers/base/utils/__init__.py @@ -0,0 +1,16 @@ +# streaming_providers/base/utils/__init__.py + +from .logger import logger, XBMCLogger +from .manifest_parser import ManifestParser +from .vfs import VFS +from .mpd_rewriter import MPDRewriter +from .mpd_cache import MPDCacheManager + +__all__ = [ + 'logger', + 'XBMCLogger', + 'ManifestParser', + 'VFS', + 'MPDRewriter', + 'MPDCacheManager' +] diff --git a/lib/streaming_providers/base/utils/logger.py b/lib/streaming_providers/base/utils/logger.py new file mode 100644 index 0000000..0bd1b56 --- /dev/null +++ b/lib/streaming_providers/base/utils/logger.py @@ -0,0 +1,63 @@ +# streaming_providers/base/utils/logger.py +import xbmc + + +class XBMCLogger: + """Centralized logging using XBMC's logging system.""" + + def __init__(self, addon_name: str, addon_version: str): + self.addon_name = addon_name + self.addon_version = addon_version + self.prefix = f"[{addon_name} v{addon_version}]" + + def log(self, message: str, level: int = xbmc.LOGINFO) -> None: + """Main logging method. + + Args: + message: The message to log + level: One of xbmc.LOGDEBUG, LOGINFO, LOGWARNING, LOGERROR, LOGFATAL + """ + xbmc.log(f"{self.prefix} {message}", level) + + def debug(self, message: str) -> None: + self.log(message, xbmc.LOGDEBUG) + + def info(self, message: str) -> None: + self.log(message, xbmc.LOGINFO) + + def warning(self, message: str) -> None: + self.log(message, xbmc.LOGWARNING) + + def error(self, message: str) -> None: + self.log(message, xbmc.LOGERROR) + + def critical(self, message: str) -> None: + self.log(message, xbmc.LOGFATAL) + + def log_auth_event(self, provider: str, event: str, details: str = "") -> None: + """Specialized logging method for authentication events.""" + message = f"AUTH [{provider}] {event}" + if details: + message += f" - {details}" + self.info(message) + + def log_credential_event(self, provider: str, event: str, details: str = "") -> None: + """Specialized logging method for credential management events.""" + message = f"CRED [{provider}] {event}" + if details: + message += f" - {details}" + self.info(message) + + def log_session_event(self, provider: str, event: str, details: str = "") -> None: + """Specialized logging method for session management events.""" + message = f"SESSION [{provider}] {event}" + if details: + message += f" - {details}" + self.debug(message) + + +# Initialize with your addon info +logger = XBMCLogger( + addon_name="Ultimate Backend", + addon_version="1.0.0" # You could get this from your addon.xml +) \ No newline at end of file diff --git a/lib/streaming_providers/base/utils/manifest_parser.py b/lib/streaming_providers/base/utils/manifest_parser.py new file mode 100644 index 0000000..4442a9f --- /dev/null +++ b/lib/streaming_providers/base/utils/manifest_parser.py @@ -0,0 +1,116 @@ +import re +import base64 +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): + logger.debug("Starting PSSH extraction from manifest") + + # Check if this looks like a DASH manifest + is_dash = (']*>([^<]+)' + 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) + 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) + if kid_match: + clean_kid = kid_match.group(1).replace("-", "").lower() + if clean_kid not in global_key_ids: + global_key_ids.append(clean_kid) + logger.debug(f"Found global default KID: {clean_kid}") + + # Second pass: extract PSSH data + for i, block in enumerate(cp_blocks, 1): + 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]}...") + 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}") + + # 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 + ) + 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") + return pssh_list diff --git a/lib/streaming_providers/base/utils/mpd_cache.py b/lib/streaming_providers/base/utils/mpd_cache.py new file mode 100644 index 0000000..373cc9e --- /dev/null +++ b/lib/streaming_providers/base/utils/mpd_cache.py @@ -0,0 +1,238 @@ +# streaming_providers/base/utils/mpd_cache.py +import time +from typing import Optional +from .vfs import VFS +from .logger import logger + + +class MPDCacheManager: + """ + Manages caching of rewritten MPD manifests with TTL support + """ + + def __init__(self): + """Initialize MPD cache manager with VFS""" + self.vfs = VFS(addon_subdir="mpd_cache") + logger.debug(f"MPD cache initialized at: {self.vfs.base_path}") + + def _get_cache_key(self, provider: str, channel_id: str) -> str: + """Generate cache key for provider/channel""" + return f"{provider}_{channel_id}" + + def _get_manifest_filename(self, cache_key: str) -> str: + """Get filename for cached manifest""" + return f"{cache_key}.xml" + + def _get_meta_filename(self, cache_key: str) -> str: + """Get filename for cache metadata""" + return f"{cache_key}.meta" + + def get(self, provider: str, channel_id: str) -> Optional[str]: + """ + Get cached MPD manifest if valid + + Args: + provider: Provider name + channel_id: Channel ID + + Returns: + Cached MPD content if valid and not expired, None otherwise + """ + cache_key = self._get_cache_key(provider, channel_id) + meta_file = self._get_meta_filename(cache_key) + manifest_file = self._get_manifest_filename(cache_key) + + try: + # Read metadata first (small file) + meta = self.vfs.read_json(meta_file) + if not meta: + logger.debug(f"No cache metadata found for {cache_key}") + return None + + # Check expiry + 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)") + # Clean up expired cache + self.vfs.delete(manifest_file) + self.vfs.delete(meta_file) + return None + + # Cache is valid, read manifest + manifest_content = self.vfs.read_text(manifest_file) + if manifest_content: + 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}") + self.vfs.delete(meta_file) + return None + + except Exception as e: + 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: + """ + Store MPD manifest in cache with TTL + + Args: + provider: Provider name + channel_id: Channel ID + mpd_content: Rewritten MPD content + ttl: Time to live in seconds + original_url: Original manifest URL (for debugging) + + Returns: + True if successfully cached, False otherwise + """ + cache_key = self._get_cache_key(provider, channel_id) + meta_file = self._get_meta_filename(cache_key) + manifest_file = self._get_manifest_filename(cache_key) + + try: + # Calculate expiry timestamp + expiry = int(time.time()) + ttl + + # Create metadata + meta = { + 'expiry': expiry, + 'ttl': ttl, + 'cached_at': int(time.time()), + 'provider': provider, + 'channel_id': channel_id + } + + if original_url: + meta['original_url'] = original_url + + # Write manifest + if not self.vfs.write_text(manifest_file, mpd_content): + logger.error(f"Failed to write manifest cache for {cache_key}") + return False + + # Write metadata + if not self.vfs.write_json(meta_file, meta): + logger.error(f"Failed to write metadata cache for {cache_key}") + # Clean up manifest if metadata write failed + self.vfs.delete(manifest_file) + return False + + logger.info(f"Cached MPD for {cache_key} with TTL={ttl}s (expires at {expiry})") + return True + + except Exception as e: + logger.error(f"Error caching MPD for {cache_key}: {e}") + return False + + def delete(self, provider: str, channel_id: str) -> bool: + """ + Delete cached MPD for a channel + + Args: + provider: Provider name + channel_id: Channel ID + + Returns: + True if deleted or didn't exist, False on error + """ + cache_key = self._get_cache_key(provider, channel_id) + meta_file = self._get_meta_filename(cache_key) + manifest_file = self._get_manifest_filename(cache_key) + + try: + self.vfs.delete(manifest_file) + self.vfs.delete(meta_file) + logger.debug(f"Deleted cache for {cache_key}") + return True + except Exception as e: + logger.error(f"Error deleting cache for {cache_key}: {e}") + return False + + def clear_all(self) -> bool: + """ + Clear all cached MPD files + + Returns: + True if successful, False otherwise + """ + try: + files = self.vfs.listdir() + deleted = 0 + + for file in files: + if file.endswith('.xml') or file.endswith('.meta'): + if self.vfs.delete(file): + deleted += 1 + + logger.info(f"Cleared {deleted} cached MPD files") + return True + + except Exception as e: + logger.error(f"Error clearing MPD cache: {e}") + return False + + def clear_expired(self) -> int: + """ + Clear all expired cached MPD files + + Returns: + Number of expired entries cleared + """ + try: + files = self.vfs.listdir() + now = int(time.time()) + cleared = 0 + + # Find all meta files + 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: + # Expired, delete both meta and manifest + cache_key = meta_file.replace('.meta', '') + self.vfs.delete(f"{cache_key}.xml") + self.vfs.delete(meta_file) + cleared += 1 + logger.debug(f"Cleared expired cache: {cache_key}") + except Exception as e: + logger.warning(f"Error checking {meta_file}: {e}") + + if cleared > 0: + logger.info(f"Cleared {cleared} expired MPD cache entries") + + return cleared + + except Exception as e: + logger.error(f"Error clearing expired caches: {e}") + return 0 + + def get_cache_info(self, provider: str, channel_id: str) -> Optional[dict]: + """ + Get cache information without reading the full manifest + + Args: + provider: Provider name + channel_id: Channel ID + + Returns: + Cache metadata dict or None if not cached + """ + cache_key = self._get_cache_key(provider, channel_id) + meta_file = self._get_meta_filename(cache_key) + + try: + 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) + return meta + except Exception as e: + logger.debug(f"Error getting cache info for {cache_key}: {e}") + return None diff --git a/lib/streaming_providers/base/utils/mpd_rewriter.py b/lib/streaming_providers/base/utils/mpd_rewriter.py new file mode 100644 index 0000000..2099e73 --- /dev/null +++ b/lib/streaming_providers/base/utils/mpd_rewriter.py @@ -0,0 +1,362 @@ +# streaming_providers/base/utils/mpd_rewriter.py +import xml.etree.ElementTree as ET +import base64 +from typing import Optional, Tuple +from urllib.parse import urljoin, urlparse +from .logger import logger + + +class MPDRewriter: + """ + Utility for rewriting MPD (MPEG-DASH) manifest URLs to point to proxy endpoints + + Strategy: + - Remove all BaseURL elements + - Convert all relative URLs to absolute URLs + - Rewrite all absolute URLs to proxy endpoint + - Keep template variables visible for client-side substitution + """ + + # MPD namespace + MPD_NAMESPACE = {'mpd': 'urn:mpeg:dash:schema:mpd:2011'} + + def __init__(self, proxy_base_url: str, provider_name: str): + """ + Initialize MPD rewriter + + Args: + 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.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') + + @staticmethod + def decode_url(encoded: str) -> str: + """Decode base64 URL from proxy endpoint""" + return base64.urlsafe_b64decode(encoded.encode('utf-8')).decode('utf-8') + + def build_proxy_url(self, original_url: str, template_pattern: Optional[str] = None) -> str: + """ + Build proxy URL for an original media URL + + Args: + original_url: Original URL to be proxied (base path for templates) + template_pattern: Optional template pattern to append (e.g., "segment-$Number$.m4s") + + Returns: + Proxy URL + """ + encoded = self.encode_url(original_url) + proxy_url = f"{self.proxy_base_url}/api/proxy/{self.provider_name}/{encoded}" + + # Append template pattern if provided (keeps variables visible for client) + if template_pattern: + proxy_url += f"/{template_pattern}" + + return proxy_url + + @staticmethod + def split_template_url(url: str) -> Tuple[str, Optional[str]]: + """ + Split a URL with template variables into base path and template pattern + + Args: + url: URL potentially containing template variables (e.g., $Number$) + + Returns: + Tuple of (base_path, template_pattern) + - 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: + return url, None + + # Find the position of the first template variable + 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) + + if last_slash_before_template == -1: + # No slash found before template, entire URL is template (unusual but handle it) + return '', url + + base_path = url[:last_slash_before_template] + template_pattern = url[last_slash_before_template + 1:] + + return base_path, template_pattern + + def rewrite_mpd(self, mpd_content: str, manifest_url: str) -> str: + """ + Rewrite MPD content to use proxy URLs + + Strategy: + 1. Parse MPD XML + 2. Extract and remove all BaseURL elements + 3. Resolve all relative URLs to absolute using BaseURLs and manifest URL + 4. Rewrite all absolute URLs to proxy endpoints + 5. Keep template variables visible for client substitution + + Args: + mpd_content: Original MPD XML content + manifest_url: URL where the manifest was fetched from (for relative URL resolution) + + Returns: + Rewritten MPD XML content + """ + try: + # Parse XML + root = ET.fromstring(mpd_content) + + # Register namespace to preserve it in output + ET.register_namespace('', self.MPD_NAMESPACE['mpd']) + + # Get base URL for relative resolution + base_url = self._extract_base_url(root, manifest_url) + + # Remove all BaseURL elements (Option 3 strategy) + self._remove_base_urls(root) + + # Rewrite all URLs in the MPD + self._rewrite_urls_recursive(root, base_url) + + # Convert back to string + 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}'") + return rewritten + + except ET.ParseError as e: + logger.error(f"Failed to parse MPD XML: {e}") + raise ValueError(f"Invalid MPD XML: {e}") + except Exception as e: + logger.error(f"Failed to rewrite MPD: {e}") + raise + + def _extract_base_url(self, root: ET.Element, manifest_url: str) -> str: + """ + Extract base URL from MPD or use manifest URL + + Priority: + 1. First BaseURL element in MPD + 2. Manifest URL's directory + + Args: + root: MPD root element + manifest_url: URL where manifest was fetched + + Returns: + Base URL for resolving relative URLs + """ + # Try to find BaseURL element + base_url_elem = root.find('.//mpd:BaseURL', self.MPD_NAMESPACE) + if base_url_elem is not None and base_url_elem.text: + base_url = base_url_elem.text.strip() + logger.debug(f"Using BaseURL from MPD: {base_url}") + return base_url + + # Fall back to manifest URL directory + parsed = urlparse(manifest_url) + base_url = f"{parsed.scheme}://{parsed.netloc}{parsed.path.rsplit('/', 1)[0]}/" + logger.debug(f"Using manifest URL directory as base: {base_url}") + return base_url + + def _remove_base_urls(self, root: ET.Element) -> None: + """ + Remove all BaseURL elements from MPD (Option 3 strategy) + + Args: + 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)): + parent.remove(base_url_elem) + logger.debug("Removed BaseURL element") + + def _rewrite_urls_recursive(self, element: ET.Element, base_url: str) -> None: + """ + Recursively rewrite all URLs in MPD element tree + + Args: + element: Current XML element + base_url: Base URL for resolving relative URLs + """ + # Attributes that contain URLs + url_attributes = [ + 'media', # SegmentTemplate + 'initialization', # SegmentTemplate + 'sourceURL', # Initialization, RepresentationIndex + 'indexRange', # SegmentBase (not a URL but can be affected) + ] + + # Rewrite URL attributes in current element + for attr in url_attributes: + if attr in element.attrib: + original_url = element.attrib[attr] + + if not original_url: + continue + + # Resolve to absolute URL first + resolved = urljoin(base_url, original_url) + + # Check if URL contains template variables + 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}") + 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'] + resolved = urljoin(base_url, original_url) + + # SegmentURL typically doesn't have templates, but handle it just in case + if '$' in resolved: + base_path, template_pattern = self.split_template_url(resolved) + element.attrib['media'] = self.build_proxy_url(base_path, template_pattern) + else: + element.attrib['media'] = self.build_proxy_url(resolved) + + # Recurse to child elements + for child in element: + self._rewrite_urls_recursive(child, base_url) + + @staticmethod + def extract_cache_ttl(headers: dict) -> int: + """ + Extract cache TTL from HTTP response headers + + Priority: + 1. Cache-Control: max-age=X + 2. Expires header + 3. Default to 300 seconds (5 minutes) + + Args: + headers: HTTP response headers dict + + Returns: + Cache TTL in seconds + """ + # Check Cache-Control header + 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(','): + directive = directive.strip() + 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')) + if expires: + try: + from email.utils import parsedate_to_datetime + from datetime import datetime, timezone + + expires_dt = parsedate_to_datetime(expires) + now = datetime.now(timezone.utc) + ttl = int((expires_dt - now).total_seconds()) + + if ttl > 0: + logger.debug(f"Cache TTL from Expires: {ttl}s") + return ttl + except Exception as e: + logger.warning(f"Failed to parse Expires header: {e}") + + # Default TTL + default_ttl = 300 + logger.debug(f"Using default cache TTL: {default_ttl}s") + return default_ttl + + @staticmethod + def extract_mpd_update_period(mpd_content: str) -> Optional[int]: + """ + Extract minimumUpdatePeriod from MPD as fallback TTL + + Args: + mpd_content: MPD XML content + + Returns: + Update period in seconds, or None if not found/applicable + """ + try: + root = ET.fromstring(mpd_content) + + # Check if dynamic manifest + mpd_type = root.attrib.get('type', 'static') + if mpd_type != 'dynamic': + return None + + # 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) + + except Exception as e: + logger.debug(f"Could not extract MPD update period: {e}") + + return None + + @staticmethod + def _parse_iso_duration(duration: str) -> int: + """ + Parse ISO 8601 duration to seconds + + Supports formats like: PT5S, PT1M30S, PT1H + + Args: + duration: ISO 8601 duration string + + Returns: + Duration in seconds + """ + import re + + # Remove PT prefix + duration = duration.replace('PT', '') + + # Parse hours, minutes, seconds + hours = minutes = seconds = 0 + + 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) + if m_match: + minutes = int(m_match.group(1)) + + 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 diff --git a/lib/streaming_providers/base/utils/vfs.py b/lib/streaming_providers/base/utils/vfs.py new file mode 100644 index 0000000..160b1ba --- /dev/null +++ b/lib/streaming_providers/base/utils/vfs.py @@ -0,0 +1,476 @@ +# streaming_providers/base/utils/vfs.py +""" +Virtual File System abstraction layer +Provides transparent file operations for both Kodi and regular Python environments +""" + +import json +import os +from typing import Optional, Any, List +from pathlib import Path + +# Import centralized logger +from .logger import logger + +# Kodi imports - with fallback for non-Kodi environments +try: + import xbmc + import xbmcvfs + import xbmcaddon + + KODI_AVAILABLE = True + logger.info("Kodi VFS environment detected") +except ImportError: + KODI_AVAILABLE = False + logger.info("Standard filesystem environment detected") + + +class VFS: + """ + Virtual File System abstraction layer + + Provides a unified interface for file operations that works in both + Kodi addon environments and standard Python environments. + """ + + def __init__(self, config_dir: Optional[str] = None, addon_subdir: str = ""): + """ + Initialize VFS handler with explicit config directory support + + Args: + config_dir: Optional explicit config directory (overrides automatic detection) + addon_subdir: Optional subdirectory within addon data (for organization) + """ + self.addon_subdir = addon_subdir + self._base_path = None + self._explicit_config_dir = config_dir + logger.debug(f"VFS initialized with config_dir={config_dir}, addon_subdir={addon_subdir}") + + @property + def base_path(self) -> str: + """Get the base path for file operations""" + if self._base_path is None: + if self._explicit_config_dir: + # Use explicitly provided config directory + self._base_path = self._explicit_config_dir + logger.info(f"Using explicit config directory: {self._base_path}") + elif KODI_AVAILABLE: + # Use Kodi's addon data directory + try: + addon = xbmcaddon.Addon() + # Use xbmcvfs.translatePath instead of xbmc.translatePath for Kodi 19+ + addon_profile = xbmcvfs.translatePath(addon.getAddonInfo('profile')) + if self.addon_subdir: + self._base_path = os.path.join(addon_profile, self.addon_subdir).replace('\\', '/') + else: + self._base_path = addon_profile.replace('\\', '/') + logger.info(f"Kodi base path: {self._base_path}") + except Exception as e: + logger.error(f"Error getting Kodi addon path: {e}") + # Fallback to temp directory + try: + self._base_path = xbmcvfs.translatePath("special://temp/streaming_providers") + except: + self._base_path = "/tmp/streaming_providers" + else: + # Use standard filesystem + if self.addon_subdir: + self._base_path = str(Path.home() / '.streaming_providers' / self.addon_subdir) + else: + self._base_path = str(Path.home() / '.streaming_providers') + logger.info(f"Standard filesystem base path: {self._base_path}") + + # Ensure base directory exists + self.mkdirs('') + + return self._base_path + + def join_path(self, *parts) -> str: + """ + Join path components using appropriate separator for environment + + Args: + *parts: Path components to join + + Returns: + Joined path string + """ + if KODI_AVAILABLE: + # Kodi VFS uses forward slashes + path = self.base_path + for part in parts: + if part: + path = path.rstrip('/') + '/' + str(part).lstrip('/') + return path + else: + # Use pathlib for standard filesystem + path = Path(self.base_path) + for part in parts: + if part: + path = path / str(part) + return str(path) + + def exists(self, filepath: str) -> bool: + """ + Check if file or directory exists + + Args: + filepath: Path to check (relative to base_path or absolute) + + Returns: + True if exists, False otherwise + """ + try: + if not os.path.isabs(filepath): + filepath = self.join_path(filepath) + + if KODI_AVAILABLE: + return xbmcvfs.exists(filepath) + else: + return Path(filepath).exists() + except Exception as e: + logger.error(f"Error checking if {filepath} exists: {e}") + return False + + def mkdirs(self, dirpath: str) -> bool: + """ + Create directory and all parent directories + + Args: + dirpath: Directory path to create + + Returns: + True if successful, False otherwise + """ + try: + if not os.path.isabs(dirpath): + dirpath = self.join_path(dirpath) + + if KODI_AVAILABLE: + if not xbmcvfs.exists(dirpath): + result = xbmcvfs.mkdirs(dirpath) + logger.debug(f"Kodi mkdirs {dirpath}: {result}") + return result + return True + else: + 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]: + """ + Read text content from file + + Args: + filepath: File path to read + encoding: Text encoding (ignored in Kodi VFS) + + Returns: + File content as string or None if error/not found + """ + try: + if not os.path.isabs(filepath): + filepath = self.join_path(filepath) + + if KODI_AVAILABLE: + if not xbmcvfs.exists(filepath): + return None + with xbmcvfs.File(filepath, 'r') as f: + content = f.read() + return content if content else None + else: + path = Path(filepath) + if not path.exists(): + return None + 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: + """ + Write text content to file + + Args: + filepath: File path to write + content: Text content to write + encoding: Text encoding (ignored in Kodi VFS) + + Returns: + True if successful, False otherwise + """ + try: + if not os.path.isabs(filepath): + filepath = self.join_path(filepath) + + if KODI_AVAILABLE: + # Ensure directory exists + 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: + bytes_written = f.write(content) + logger.debug(f"Kodi file write: {bytes_written} bytes to {filepath}") + return bytes_written > 0 + else: + path = Path(filepath) + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, 'w', encoding=encoding) as f: + f.write(content) + return True + except Exception as e: + logger.error(f"Error writing file {filepath}: {e}") + return False + + def delete(self, filepath: str) -> bool: + """ + Delete file + + Args: + filepath: File path to delete + + Returns: + True if successful, False otherwise + """ + try: + if not os.path.isabs(filepath): + filepath = self.join_path(filepath) + + if KODI_AVAILABLE: + if xbmcvfs.exists(filepath): + return xbmcvfs.delete(filepath) + return True + else: + path = Path(filepath) + if path.exists(): + path.unlink() + return True + except Exception as e: + logger.error(f"Error deleting file {filepath}: {e}") + return False + + def read_json(self, filepath: str) -> Optional[dict]: + """ + Read and parse JSON file + + Args: + filepath: JSON file path to read + + Returns: + Parsed JSON data or None if error/not found + """ + try: + content = self.read_text(filepath) + if content: + return json.loads(content) + return None + except json.JSONDecodeError as e: + logger.error(f"Invalid JSON in {filepath}: {e}") + return None + except Exception as e: + logger.error(f"Error reading JSON file {filepath}: {e}") + return None + + def write_json(self, filepath: str, data: Any, indent: int = 2) -> bool: + """ + Write data to JSON file + + Args: + filepath: JSON file path to write + data: Data to serialize as JSON + indent: JSON indentation level + + Returns: + True if successful, False otherwise + """ + try: + content = json.dumps(data, indent=indent, ensure_ascii=False, default=str) + return self.write_text(filepath, content) + except Exception as e: + logger.error(f"Error writing JSON file {filepath}: {e}") + return False + + def list_files(self, dirpath: str = "", pattern: str = "*") -> List[str]: + """ + List files in directory + + Args: + dirpath: Directory path to list (relative to base_path) + pattern: File pattern filter (basic glob patterns) + + Returns: + List of filenames + """ + try: + if not dirpath: + dirpath = self.base_path + elif not os.path.isabs(dirpath): + dirpath = self.join_path(dirpath) + + if KODI_AVAILABLE: + if not xbmcvfs.exists(dirpath): + return [] + + dirs, files = xbmcvfs.listdir(dirpath) + # Basic pattern matching (only supports * wildcard) + if pattern == "*": + return files + else: + # Simple pattern matching + import fnmatch + return [f for f in files if fnmatch.fnmatch(f, pattern)] + else: + path = Path(dirpath) + if not path.exists() or not path.is_dir(): + return [] + + if pattern == "*": + return [f.name for f in path.iterdir() if f.is_file()] + else: + return [f.name for f in path.glob(pattern) if f.is_file()] + except Exception as e: + logger.error(f"Error listing files in {dirpath}: {e}") + return [] + + def get_size(self, filepath: str) -> Optional[int]: + """ + Get file size in bytes + + Args: + filepath: File path + + Returns: + File size in bytes or None if error/not found + """ + try: + if not os.path.isabs(filepath): + filepath = self.join_path(filepath) + + if KODI_AVAILABLE: + if not xbmcvfs.exists(filepath): + return None + stat = xbmcvfs.Stat(filepath) + return stat.st_size() + else: + path = Path(filepath) + if not path.exists(): + return None + return path.stat().st_size + except Exception as e: + logger.error(f"Error getting size of {filepath}: {e}") + return None + + def ensure_directory(self, filepath: str) -> bool: + """ + Ensure directory for filepath exists + + Args: + filepath: File path to ensure directory for + + Returns: + True if successful, False otherwise + """ + try: + if not os.path.isabs(filepath): + filepath = self.join_path(filepath) + + dir_path = os.path.dirname(filepath) + return self.mkdirs(dir_path) + except Exception as e: + logger.error(f"Error ensuring directory for {filepath}: {e}") + return False + + def debug_info(self) -> dict: + """ + Get debug information about the VFS environment + + Returns: + Dictionary with debug information + """ + info = { + 'kodi_available': KODI_AVAILABLE, + 'base_path': self.base_path, + 'base_path_exists': self.exists(''), + 'explicit_config_dir': self._explicit_config_dir, + 'addon_subdir': self.addon_subdir + } + + if KODI_AVAILABLE: + try: + addon = xbmcaddon.Addon() + 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) + + return info + + +# Convenience functions for global VFS instance +_global_vfs = None + + +def get_vfs(config_dir: Optional[str] = None, addon_subdir: str = "") -> VFS: + """ + Get global VFS instance + + Args: + config_dir: Optional explicit config directory + addon_subdir: Optional subdirectory within addon data + + Returns: + VFS instance + """ + global _global_vfs + if _global_vfs is None or _global_vfs._explicit_config_dir != config_dir or _global_vfs.addon_subdir != addon_subdir: + _global_vfs = VFS(config_dir, addon_subdir) + return _global_vfs + + +# Convenience functions that use global VFS +def exists(filepath: str, config_dir: Optional[str] = None) -> bool: + """Check if file exists""" + return get_vfs(config_dir).exists(filepath) + + +def mkdirs(dirpath: str, config_dir: Optional[str] = None) -> bool: + """Create directories""" + return get_vfs(config_dir).mkdirs(dirpath) + + +def read_text(filepath: str, encoding: str = 'utf-8', config_dir: Optional[str] = None) -> Optional[str]: + """Read text file""" + return get_vfs(config_dir).read_text(filepath, encoding) + + +def write_text(filepath: str, content: str, encoding: str = 'utf-8', config_dir: Optional[str] = None) -> bool: + """Write text file""" + return get_vfs(config_dir).write_text(filepath, content, encoding) + + +def read_json(filepath: str, config_dir: Optional[str] = None) -> Optional[dict]: + """Read JSON file""" + return get_vfs(config_dir).read_json(filepath) + + +def write_json(filepath: str, data: Any, indent: int = 2, config_dir: Optional[str] = None) -> bool: + """Write JSON file""" + return get_vfs(config_dir).write_json(filepath, data, indent) + + +def delete(filepath: str, config_dir: Optional[str] = None) -> bool: + """Delete file""" + return get_vfs(config_dir).delete(filepath) + + +def join_path(*parts, config_dir: Optional[str] = None) -> str: + """Join path components""" + vfs = get_vfs(config_dir) + return vfs.join_path(*parts) \ No newline at end of file diff --git a/lib/streaming_providers/providers/joyn/__init__.py b/lib/streaming_providers/providers/joyn/__init__.py new file mode 100644 index 0000000..1cf3fbc --- /dev/null +++ b/lib/streaming_providers/providers/joyn/__init__.py @@ -0,0 +1,25 @@ +# 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 +) + +__all__ = [ + '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 diff --git a/lib/streaming_providers/providers/joyn/auth.py b/lib/streaming_providers/providers/joyn/auth.py new file mode 100644 index 0000000..40f6ca3 --- /dev/null +++ b/lib/streaming_providers/providers/joyn/auth.py @@ -0,0 +1,758 @@ +# streaming_providers/providers/joyn/auth.py +# -*- coding: utf-8 -*- +import uuid +from typing import Dict, Optional, Any +from dataclasses import dataclass, field +import time + +from ...base.auth.base_oauth2_auth import BaseOAuth2Authenticator +from ...base.auth.base_auth import BaseAuthToken, TokenAuthLevel +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, + JOYN_CIDAAS_ENDPOINTS, + DEFAULT_COUNTRY, + DEFAULT_REQUEST_TIMEOUT, JOYN_AUTH_ENDPOINTS +) + + +class JoynSSODiscovery: + """Service to discover SSO endpoints dynamically""" + + def __init__(self, http_manager, country: str = DEFAULT_COUNTRY, platform: str = DEFAULT_PLATFORM): + self.http_manager = http_manager + self.country = country + self.platform = platform + self._endpoints_cache = None + self._cache_timestamp = None + self._cache_ttl = 3600 # 1 hour cache + + @staticmethod + 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' + } + + 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): + return self._endpoints_cache + + try: + params = { + 'client_id': DEVICE_IDS[self.platform], + 'client_name': self.platform + } + + response = self.http_manager.get( + 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())}") + return self._endpoints_cache + + except Exception as e: + # Fallback to hardcoded endpoints if discovery fails + logger.warning(f"SSO discovery failed, using fallback: {e}") + return self.get_fallback_endpoints() + + def get_auth_endpoint(self, auth_type: str = None) -> str: + """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' + + endpoints = self.get_endpoints() + endpoint = endpoints.get(auth_type) + if not endpoint: + 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', + '') + return endpoint + + +@dataclass +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) + + 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]) + + if not self.distribution_tenant and self.country in COUNTRY_TENANT_MAPPING: + self.distribution_tenant = COUNTRY_TENANT_MAPPING[self.country] + + def validate(self) -> bool: + """Validate Joyn credentials""" + if not self.client_id or not self.client_name: + return False + if self.country not in SUPPORTED_COUNTRIES: + return False + return True + + 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()) + } + + @property + def credential_type(self) -> str: + return "joyn_client_credentials" + + +@dataclass +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 + } + + def get_jwt_claims(self) -> Optional[Dict[str, Any]]: + """Extract JWT claims from access token for debugging and classification""" + try: + if not self.access_token: + return None + + parts = self.access_token.split('.') + if len(parts) != 3: + return None + + import base64 + import json + + payload_b64 = parts[1] + padding = len(payload_b64) % 4 + if padding: + payload_b64 += '=' * (4 - padding) + + 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): + self.country = country + self.distribution_tenant = distribution_tenant + self.platform = platform + self.user_agent = JOYN_USER_AGENT + self.timeout = DEFAULT_REQUEST_TIMEOUT + self.http_manager = http_manager + + # Only create SSO discovery if we have http_manager + if http_manager is not None: + self.sso_discovery = JoynSSODiscovery(http_manager, country, platform) + else: + self.sso_discovery = None + + 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') + # Fallback if SSO discovery not available + 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') + # 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', '') + + 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']) + } + + 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()) + }) + return headers + + +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): + """ + Initialize authenticator for specific country + """ + if country not in 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: + raise ValueError( + "http_manager is required for JoynAuthenticator. " + "It should be created in JoynProvider and passed to the authenticator." + ) + + # Set country-specific attributes FIRST + self.country = country + self.platform = platform + self.distribution_tenant = COUNTRY_TENANT_MAPPING[country] + + # Store http_manager reference (provided by JoynProvider) + 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) + + # 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', + 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 + ) + + 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()) + }) + return headers + + def _extract_client_id_from_endpoints(self) -> str: + """Extract client_id from SSO endpoints during initialization""" + try: + # Get endpoints from SSO discovery + endpoints = self._config.sso_discovery.get_endpoints() + + # Get the platform-specific login endpoint + platform_key = f"{self.platform}-login" + 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') + + if not login_url: + raise Exception( + f"No login endpoint found for platform '{self.platform}' or generic 'web-login' in SSO discovery") + + # Extract client_id from the URL parameters + from urllib.parse import urlparse, parse_qs + parsed_url = urlparse(login_url) + query_params = parse_qs(parsed_url.query) + + 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}") + 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])}") + return DEVICE_IDS.get(self.platform, DEVICE_IDS[DEFAULT_PLATFORM]) + + @property + def oauth_client_id(self) -> str: + """Get OAuth2 client ID - uses cached value from initialization""" + return self._client_id + + @property + def oauth_scope(self) -> str: + """OAuth2 scopes for authorization code flow""" + return JOYN_OAUTH_SCOPE + + @property + 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'] + + def _get_auth_headers(self) -> Dict[str, str]: + """Get headers for authentication request""" + return self._config.get_auth_headers() + + def _build_auth_payload(self) -> Dict[str, Any]: + """Build authentication payload - only used for client credentials flow""" + if not self.credentials: + raise Exception("No credentials available") + return self.credentials.to_auth_payload() + + 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()) + ) + + # ALWAYS classify immediately when creating from response + token.auth_level = self._classify_token(token) + logger.debug(f"Token created and classified as: {token.auth_level.value}") + + return token + + def get_fallback_credentials(self) -> JoynCredentials: + """Get fallback credentials when no user credentials are available""" + return JoynCredentials( + client_id=self._client_id, + client_secret="", # Joyn doesn't use client_secret + country=self.country + ) + + def get_token_redeem_url(self) -> str: + """Get token redemption URL for OAuth flows""" + return self._config.get_token_redeem_endpoint() + + # MAIN AUTHENTICATION METHOD - UPDATED to handle both flows + def _perform_authentication(self) -> BaseAuthToken: + """ + Perform authentication using appropriate flow based on credential type + """ + from ...base.auth.credentials import UserPasswordCredentials, ClientCredentials + + if isinstance(self.credentials, UserPasswordCredentials): + # Use OAuth2 authorization code flow with PKCE + logger.info(f"Using OAuth2 authorization code flow for {self.provider_name}") + token_data = self._perform_oauth_authorization_code_flow( + self.credentials.username, + self.credentials.password + ) + elif isinstance(self.credentials, ClientCredentials): + # Use client credentials flow (anonymous auth) + logger.info(f"Using OAuth2 client credentials flow for {self.provider_name}") + token_data = self._perform_oauth_client_credentials_flow() + else: + raise Exception(f"Unsupported credential type for {self.provider_name}: {type(self.credentials)}") + + return self._create_token_from_response(token_data) + + # Client credentials flow implementation - KEEP EXISTING + def _perform_oauth_client_credentials_flow(self) -> Dict[str, Any]: + """ + Client credentials flow because Joyn uses JSON instead of form data + """ + try: + logger.debug(f"Starting Joyn-specific OAuth2 client credentials flow") + + headers = self._get_auth_headers() + payload = self._build_auth_payload() + + logger.debug(headers) + logger.debug(payload) + # Joyn expects JSON, not form-encoded data + response = self.http_manager.post( + self.auth_endpoint, + operation='auth', + headers=headers, + 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}") + 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}") + raise Exception(f"OAuth2 client credentials flow failed: {e}") + + # Authorization code flow - Joyn-specific implementation + def _perform_oauth_authorization_code_flow(self, username: str, password: str) -> Dict[str, Any]: + """ + Simplified Joyn OAuth2 authorization code flow using existing methods + """ + try: + logger.debug("Starting Joyn OAuth2 authorization code flow using existing methods") + + # Step 1: Use existing SSO endpoints from config (already initialized) + web_login_url = self._config.get_authorize_endpoint() + logger.debug(f"Using existing authorize endpoint: {web_login_url}") + + # Step 2: Use our existing working method to get request_id + from urllib.parse import urlencode, parse_qs, urlparse + import re + + # Build authorization URL with PKCE (our existing working method) + 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_mode': 'query', + 'view_type': 'login', + 'prompt': 'consent', + 'cd1': str(uuid.uuid4()), + } + + authorization_url = f"{web_login_url}?{urlencode(params)}" + + session = self._create_oauth_session() + + # This is our existing working method to get request_id + logger.debug("Fetching authorization page for request_id") + auth_response = session.get(authorization_url, timeout=self._config.timeout) + auth_response.raise_for_status() + + # Extract request_id from the redirect URL - THIS WORKS! + parsed_url = urlparse(auth_response.url) + query_params = parse_qs(parsed_url.query) + 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) + if request_id_match: + request_id = request_id_match.group(1) + else: + logger.error(f"Could not extract request_id from URL: {auth_response.url}") + raise Exception("Could not extract request_id from authorization page") + + logger.debug(f"Extracted request_id: {request_id}") + + # Step 3: DIRECT LOGIN - Skip redundant verification steps + logger.debug("Performing direct login with credentials") + login_url = JOYN_CIDAAS_ENDPOINTS['LOGIN'] + login_data = { + "username": username, + "password": password, + "requestId": request_id + } + + login_response = session.post(login_url, data=login_data, timeout=self._config.timeout, + allow_redirects=False) + login_response.raise_for_status() + + # Step 4: Extract authorization code from redirect + redirect_url = login_response.headers.get('Location', '') + if not redirect_url: + # Check if login failed - look for error messages + if 'error' in auth_response.text.lower() or 'invalid' in auth_response.text.lower(): + logger.error("Login likely failed - check credentials") + raise Exception("Authentication failed - check username and password") + raise Exception("No redirect URL after login") + + # Follow redirects to get the final URL with authorization code + final_response = session.get(redirect_url, timeout=self._config.timeout, allow_redirects=True) + final_params = parse_qs(urlparse(final_response.url).query) + + auth_code = final_params.get('code', [None])[0] + if not auth_code: + raise Exception("Could not extract authorization code from login flow") + + logger.debug(f"Extracted authorization code: {auth_code}") + + # Step 5: Exchange authorization code for tokens using existing method + 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") + 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]: + """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 + # No grant_type for Joyn + } + + def _get_token_exchange_endpoint(self, **kwargs) -> str: + """Joyn-specific token exchange endpoint""" + token_endpoint = self._config.get_token_redeem_endpoint() + logger.debug(f"Using dynamic token endpoint: {token_endpoint}") + return token_endpoint + + def _get_token_exchange_headers(self, **kwargs) -> Dict[str, str]: + return self._get_joyn_auth_headers() + + def _should_use_json_for_token_exchange(self, **kwargs) -> bool: + """Joyn uses JSON instead of form-encoded""" + return True + + # Refresh token - KEEP EXISTING + def _refresh_oauth_token(self) -> Optional[BaseAuthToken]: + """Joyn-specific token refresh implementation""" + if not self._current_token or not self._current_token.refresh_token: + logger.debug(f"No refresh token available for {self.provider_name}") + return None + + try: + logger.debug(f"Refreshing OAuth2 token for {self.provider_name}") + + # Joyn-specific refresh payload + 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 + } + + headers = self._get_joyn_auth_headers() + + # Use the refresh-specific endpoint + refresh_endpoint = JOYN_AUTH_ENDPOINTS['REFRESH'] + + response = self.http_manager.post( + refresh_endpoint, + operation='auth', + headers=headers, + json_data=payload, # JSON format + timeout=self._config.timeout + ) + + response.raise_for_status() + + new_token_data = response.json() + refreshed_token = self._create_token_from_response(new_token_data) + logger.info(f"OAuth2 token refresh successful for {self.provider_name}") + return refreshed_token + + except Exception as e: + logger.warning(f"OAuth2 token refresh failed for {self.provider_name}: {e}") + return None + + # Backward compatibility methods + def is_authenticated(self) -> bool: + """Check if currently authenticated with valid token""" + return self._current_token is not None and not self._current_token.is_expired + + def invalidate_token(self) -> None: + """Invalidate current token (forces re-authentication on next request)""" + self._current_token = None + try: + self.settings_manager.clear_token(self.provider_name) + except (AttributeError, KeyError, IOError, OSError): + # AttributeError: settings_manager is None + # KeyError: provider_name not found in settings + # IOError/OSError: filesystem errors when clearing token + pass + + def _classify_token(self, token: BaseAuthToken) -> TokenAuthLevel: + """ + Classify Joyn token based on JWT claims and token structure + + Args: + token: JoynAuthToken to classify + + Returns: + TokenAuthLevel indicating authentication level + """ + try: + if not token or not token.access_token: + return TokenAuthLevel.UNKNOWN + + # Parse JWT token to extract claims + try: + # JWT tokens are in format: header.payload.signature + parts = token.access_token.split('.') + if len(parts) != 3: + logger.warning(f"Invalid JWT format for token classification") + return TokenAuthLevel.UNKNOWN + + import base64 + import json + + # Decode payload (second part) + payload_b64 = parts[1] + # Add padding if needed + padding = len(payload_b64) % 4 + if padding: + payload_b64 += '=' * (4 - padding) + + 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']} }") + + except Exception as e: + logger.warning(f"Failed to parse JWT for classification: {e}") + return TokenAuthLevel.UNKNOWN + + # Classification logic based on your analysis: + + # 1. Check jIdC prefix - most reliable indicator + 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-'): + 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)") + return TokenAuthLevel.USER_AUTHENTICATED + + # 3. Check client ID (cId) against known client IDs + client_id = claims.get('cId', '') + known_client_ids = { + DEVICE_IDS['web'], # Web client + DEVICE_IDS['android'], # Android client + DEVICE_IDS['ios'] # iOS client + } + + if client_id in known_client_ids: + logger.debug("Token classified as CLIENT_CREDENTIALS (known client ID)") + return TokenAuthLevel.CLIENT_CREDENTIALS + + # 4. Check for anonymous device patterns in subject (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)") + return TokenAuthLevel.CLIENT_CREDENTIALS + + # 5. Fallback: Check token scope or other claims + 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)") + return TokenAuthLevel.USER_AUTHENTICATED + 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") + return TokenAuthLevel.UNKNOWN + + except Exception as e: + logger.error(f"Error classifying token: {e}") + return TokenAuthLevel.UNKNOWN + + 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'} + + 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 diff --git a/lib/streaming_providers/providers/joyn/constants.py b/lib/streaming_providers/providers/joyn/constants.py new file mode 100644 index 0000000..9d416dc --- /dev/null +++ b/lib/streaming_providers/providers/joyn/constants.py @@ -0,0 +1,272 @@ +# streaming_providers/providers/joyn/constants.py +# ============================================================================ +# SSO Discovery Configuration +# ============================================================================ + +# SSO endpoints discovery URL +JOYN_SSO_DISCOVERY_URL = 'https://auth.joyn.de/sso/endpoints' + +# 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' +} + +# OAuth2 Configuration +JOYN_OAUTH_SCOPE = "openid email profile offline_access" + +# ============================================================================ +# Authentication Configuration +# ============================================================================ + +# Base authentication URL +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 +} + +# ============================================================================ +# Cidaas/7pass Configuration +# ============================================================================ + +# Cidaas base URL (7pass authentication service) +JOYN_CIDAAS_BASE_URL = 'https://auth.7pass.de' + +# Cidaas API endpoints +JOYN_CIDAAS_ENDPOINTS = { + 'LOGIN': f'{JOYN_CIDAAS_BASE_URL}/login-srv/login', + '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', + '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' +} + +# ============================================================================ +# API Configuration +# ============================================================================ + +# Client version used in API requests +JOYN_CLIENT_VERSION = '5.1261.0' + +# Platform identifier +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' + +# Base64 encoded secret key for signature generation +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, + # 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 +} + +# ============================================================================ +# GraphQL Configuration +# ============================================================================ + +# GraphQL base URL +JOYN_GRAPHQL_BASE_URL = 'https://api.joyn.de/graphql' + +# GraphQL persisted query hashes +GRAPHQL_QUERY_HASHES = { + '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' +} + +# 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 +} + +# GraphQL persisted query version +GRAPHQL_PERSISTED_QUERY_VERSION = 1 + +# GraphQL query defaults +GRAPHQL_LIVE_CHANNELS_FILTER = "DEFAULT" +GRAPHQL_MAX_RESULTS = 5000 +GRAPHQL_OFFSET = 0 + +# ============================================================================ +# Streaming Configuration +# ============================================================================ + +# 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' +} + +# Default video data payload configuration +""" +DEFAULT_VIDEO_CONFIG = { + 'manufacturer': 'unknown', + 'platform': 'browser', + 'maxSecurityLevel': 1, + 'model': 'unknown', + 'protectionSystem': 'widevine', + 'streamingFormat': 'dash', + 'enableSubtitles': True, + 'maxResolution': 1080, + 'version': 'v1', +} +""" +DEFAULT_VIDEO_CONFIG = { + "enableDolbyAtmos": True, + "enableSubtitles": True, + "manufacturer": "", + "maxResolution": 2160, + "model": "", + "platform": "android-tv", + "protectionSystem": "widevine", + "streamingFormat": "dash", + "variantName": "", + "version": "v1", + "maxSecurityLevel": 5, +} + +# ============================================================================ +# Content Configuration +# ============================================================================ + +# Content types +CONTENT_TYPE_LIVE = 'LIVE' +CONTENT_TYPE_VOD = 'VOD' + +# Stream types +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'] + +# Stream modes +MODE_LIVE = 'live' +MODE_VOD = 'vod' + +# ============================================================================ +# Error Codes +# ============================================================================ + +# 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 +} + +# ============================================================================ +# Country/Region Configuration +# ============================================================================ + +# Country to distribution tenant mapping +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' +} + +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' + +# ============================================================================ +# DRM Configuration +# ============================================================================ + +# DRM system +DRM_SYSTEM_WIDEVINE = 'widevine' + +# DRM request headers +DRM_REQUEST_HEADERS = { + '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 +} + +# ============================================================================ +# Request Configuration +# ============================================================================ + +# Default timeout for HTTP requests (seconds) +DEFAULT_REQUEST_TIMEOUT = 30 + +# Default maximum retries for failed requests +DEFAULT_MAX_RETRIES = 3 + +# Default time window for EPG queries (hours) +DEFAULT_EPG_WINDOW_HOURS = 3 + +# ============================================================================ +# Channel Configuration +# ============================================================================ + +# Default channel settings +DEFAULT_CHANNEL_CONFIG = { + '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 diff --git a/lib/streaming_providers/providers/joyn/models.py b/lib/streaming_providers/providers/joyn/models.py new file mode 100644 index 0000000..cc8c0e4 --- /dev/null +++ b/lib/streaming_providers/providers/joyn/models.py @@ -0,0 +1,203 @@ +# streaming_providers/providers/joyn/models.py +from dataclasses import dataclass, field +from typing import Dict, Optional +import json + +from ...base.models import StreamingChannel + + +class PlaybackRestrictedException(Exception): + """ + Exception raised when content playback is restricted + """ + pass + + +@dataclass +class JoynChannel: + """ + Represents a Joyn channel with all necessary streaming data + """ + # Core identification + name: str + channel_id: str + + # Visual elements + logo_url: Optional[str] = None + + # Streaming configuration + mode: str = "live" # "live" or "vod" + session_manifest: bool = False + manifest: Optional[str] = None + manifest_script: Optional[str] = None + + # CDM (Content Decryption Module) settings + cdm_type: Optional[str] = None + use_cdm: bool = True + cdm: Optional[str] = None # Usually "pid={pid}" + cdm_mode: str = 'external' + + # Video settings + video: str = 'best' + on_demand: bool = True + speed_up: bool = True + + # Additional metadata + content_type: str = 'LIVE' # 'LIVE' or 'VOD' + description: Optional[str] = None + genre: Optional[str] = None + language: str = 'de' + country: str = 'DE' + + # Streaming data + license_url: Optional[str] = None + certificate_url: Optional[str] = None + streaming_format: Optional[str] = None + + # Internal tracking + raw_data: Dict = field(default_factory=dict) + + @classmethod + def from_api_data(cls, api_data: Dict, **kwargs) -> 'JoynChannel': + """ + Create JoynChannel from API response data + + Args: + api_data: Raw API response data + **kwargs: Additional parameters to override defaults + + Returns: + 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() + ) + + # Apply any additional parameters + for key, value in kwargs.items(): + if hasattr(channel, key): + setattr(channel, key, value) + + 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: + """ + Configure streaming-specific data + + Args: + manifest: Manifest URL for streaming + cdm_type: Content decryption module type + pid: Program ID for CDM + license_url: License URL for DRM + certificate_url: Certificate URL for DRM + streaming_format: Streaming format (e.g., 'dash') + """ + self.manifest = manifest + + if cdm_type: + self.cdm_type = cdm_type + + if pid: + self.cdm = f"pid={pid}" + + if license_url: + self.license_url = license_url + + if certificate_url: + self.certificate_url = certificate_url + + if streaming_format: + self.streaming_format = streaming_format + + def set_logo(self, logo_url: str) -> None: + """Set channel logo URL""" + self.logo_url = logo_url + + def set_metadata(self, description: str = None, genre: str = None) -> None: + """Set additional metadata""" + if description: + self.description = description + if genre: + self.genre = genre + + def is_live(self) -> bool: + """Check if channel is live TV""" + 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' + + def to_streaming_channel(self, provider_name: str = 'joyn') -> StreamingChannel: + """ + Convert to generic StreamingChannel object + + Args: + provider_name: Provider name to set + + Returns: + StreamingChannel instance + """ + return StreamingChannel( + name=self.name, + channel_id=self.channel_id, + provider=provider_name, + logo_url=self.logo_url, + mode=self.mode, + session_manifest=self.session_manifest, + manifest=self.manifest, + manifest_script=self.manifest_script, + cdm_type=self.cdm_type, + use_cdm=self.use_cdm, + cdm=self.cdm, + cdm_mode=self.cdm_mode, + video=self.video, + on_demand=self.on_demand, + speed_up=self.speed_up, + content_type=self.content_type, + description=self.description, + genre=self.genre, + language=self.language, + country=self.country, + license_url=self.license_url, + certificate_url=self.certificate_url, + streaming_format=self.streaming_format + ) + + def to_dict(self) -> Dict: + """ + Convert to dictionary format matching your output specification + + Returns: + 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 + } + + def to_json(self, indent: int = 2) -> str: + """Convert to JSON string""" + return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False) + + def __str__(self) -> str: + return f"JoynChannel(name='{self.name}', id='{self.channel_id}', type='{self.content_type}')" + + def __repr__(self) -> str: + return self.__str__() \ No newline at end of file diff --git a/lib/streaming_providers/providers/joyn/provider.py b/lib/streaming_providers/providers/joyn/provider.py new file mode 100644 index 0000000..288f826 --- /dev/null +++ b/lib/streaming_providers/providers/joyn/provider.py @@ -0,0 +1,725 @@ +# streaming_providers/providers/joyn/provider.py +# -*- coding: utf-8 -*- +from typing import Dict, Optional, List +import json +import time +import hashlib +import urllib.parse +from datetime import datetime, timedelta +from base64 import b64decode +from json import dumps +from urllib.parse import urlencode + +from ...base.provider import StreamingProvider +from ...base.models import DRMConfig, LicenseConfig, DRMSystem +from ...base.models.streaming_channel import StreamingChannel +from ...base.network import HTTPManagerFactory, ProxyConfigManager +from ...base.models.proxy_models import ProxyConfig +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 +) + + +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 + + +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') + + signature_input = f"{video_payload},{entitlement_token}{secret_key}" + return hashlib.sha1(signature_input.encode('utf-8')).hexdigest() + + +class JoynProvider(StreamingProvider): + """ + Joyn streaming provider implementation with centralized HTTP management + """ + + 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 + + Args: + country: Country code ('de', 'at', 'ch') + config_dir: Optional config directory override + proxy_config: Optional proxy configuration (overrides ProxyConfigManager) + proxy_url: Optional proxy URL string (converted to ProxyConfig) + """ + super().__init__(country=country) + + if country not in SUPPORTED_COUNTRIES: + raise ValueError(f"Unsupported country: {country}. Must be one of: {SUPPORTED_COUNTRIES}") + + self.distribution_tenant = COUNTRY_TENANT_MAPPING[country] + + # Setup proxy configuration with priority: proxy_config > proxy_url > ProxyConfigManager + self.proxy_config = ( + 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: + logger.info("Using proxy configuration for Joyn") + else: + logger.debug("No proxy configuration found for Joyn") + + # Create HTTP manager FIRST - single instance for all operations + self.http_manager = HTTPManagerFactory.create_for_provider( + provider_name='joyn', + proxy_config=self.proxy_config, + user_agent=JOYN_USER_AGENT, + timeout=DEFAULT_REQUEST_TIMEOUT, + max_retries=DEFAULT_MAX_RETRIES + ) + + self.platform = platform + + # Create authenticator with shared HTTP manager + self.authenticator = JoynAuthenticator( + country=country, + platform=platform, + config_dir=config_dir, + http_manager=self.http_manager, + proxy_config=self.proxy_config + ) + + # Always authenticate through authenticator (no direct bearer_token parameter) + try: + self.bearer_token = self.authenticator.get_bearer_token() + except Exception as e: + logger.warning(f"Could not authenticate during initialization: {e}") + self.bearer_token = None + + def _load_proxy_from_manager(self, config_dir: Optional[str]) -> Optional[ProxyConfig]: + """ + Load proxy configuration from ProxyConfigManager + + Args: + config_dir: Optional config directory path + + Returns: + ProxyConfig if found, None otherwise + """ + try: + proxy_manager = ProxyConfigManager(config_dir) + return proxy_manager.get_proxy_config('joyn', self.country) + except Exception as e: + logger.warning(f"Could not load proxy from ProxyConfigManager: {e}") + return None + + @property + def provider_name(self) -> str: + return 'joyn' + + @property + def uses_dynamic_manifests(self) -> bool: + return False + + 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)) + return self.bearer_token + + def get_dynamic_manifest_params(self, channel: StreamingChannel, **kwargs) -> Optional[str]: + return None + + def _get_graphql_headers(self) -> Dict[str, str]: + """Get headers for GraphQL requests""" + headers = JOYN_GRAPHQL_BASE_HEADERS.copy() + headers.update({ + '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' + }) + return headers + + def refresh_authentication(self) -> str: + """Force refresh authentication""" + self.bearer_token = self.authenticator.get_bearer_token(force_refresh=True) + return self.bearer_token + + def fetch_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 + + Args: + time_window_hours: EPG time window in hours + fetch_manifests: Whether to immediately populate streaming data + populate_streaming_data: Whether to populate streaming data when fetch_manifests is True + **kwargs: Additional parameters + + Returns: + List of StreamingChannel objects + """ + try: + headers = self._get_graphql_headers() + + current_time = int(time.time()) + end_time = current_time + (time_window_hours * 3600) + + variables = { + "liveStreamGroupFilter": GRAPHQL_LIVE_CHANNELS_FILTER, + "first": GRAPHQL_MAX_RESULTS, + "offset": GRAPHQL_OFFSET, + "livestreamTypes": DEFAULT_LIVESTREAM_TYPES, + "from": current_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'] + } + } + extensions_encoded = urllib.parse.quote(json.dumps(extensions)) + + url = f"{JOYN_GRAPHQL_ENDPOINTS['LIVE_CHANNELS']}&variables={variables_encoded}&extensions={extensions_encoded}" + + # Use http_manager instead of requests + response = self.http_manager.get( + url, + operation='api', + headers=headers, + timeout=DEFAULT_REQUEST_TIMEOUT + ) + response.raise_for_status() + channel_data = response.json() + + channels = self._process_graphql_response(channel_data) + + # Populate streaming data immediately after fetching channels + 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}") + return channels + + except Exception as e: + raise Exception(f"Error fetching channels from GraphQL: {e}") + + 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']: + raise Exception("Invalid GraphQL response structure") + + 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', '') + + logo_url = None + 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 + + joyn_channel = JoynChannel( + name=title, + channel_id=channel_id, + logo_url=logo_url, + mode=mode, + content_type=content_type, + country=self.country, + 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 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 + ) + channels.append(streaming_channel) + + except Exception as e: + logger.warning(f"Error processing channel data: {e}") + + return channels + + def get_entitlement_token(self, content_id: str, content_type: str = CONTENT_TYPE_LIVE) -> str: + """ + Get entitlement token for content + + Args: + content_id: Content ID + content_type: Content type ('LIVE' or 'VOD') + + Returns: + Entitlement token string + """ + headers = JOYN_API_BASE_HEADERS.copy() + headers.update({ + 'joyn-client-version': JOYN_CLIENT_VERSION, + 'joyn-country': self.country.upper(), + 'joyn-distribution-tenant': self.distribution_tenant, + 'joyn-platform': self.platform, + 'origin': JOYN_DOMAINS.get(self.country, JOYN_DOMAINS['de']) + }) + headers['Authorization'] = f'Bearer {self.bearer_token}' + + payload = { + "content_id": content_id, + "content_type": content_type + } + + try: + # Use http_manager instead of requests (proxy already configured) + response = self.http_manager.post( + JOYN_STREAMING_ENDPOINTS['ENTITLEMENT'], + operation='auth', + headers=headers, + json_data=payload, + timeout=DEFAULT_REQUEST_TIMEOUT + ) + + if response.status_code == 400: + try: + error_data = response.json() + if isinstance(error_data, list) and len(error_data) > 0: + 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}") + else: + 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), and failed to parse error: {e}") + + response.raise_for_status() + data = response.json() + return data['entitlement_token'] + + except PlaybackRestrictedException: + raise + except KeyError: + raise Exception(f"No entitlement_token in response for {content_id}") + 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: + """ + Get channel playlist data + + Args: + channel_id: Channel ID + entitlement_token: Entitlement token + video_config: Optional video configuration override + + Returns: + Playlist data dictionary + """ + 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 += f"?signature={signature}" + + headers = JOYN_API_BASE_HEADERS.copy() + headers['Authorization'] = f'Bearer {entitlement_token}' + + try: + # Use http_manager instead of requests (proxy already configured) + response = self.http_manager.post( + url, + operation='manifest', + headers=headers, + data=video_payload, + timeout=DEFAULT_REQUEST_TIMEOUT + ) + response.raise_for_status() + return response.json() + + 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]: + """ + Populate streaming data (manifest, DRM) for all channels + + Args: + channels: List of channels to populate + video_config: Optional video configuration + max_retries: Maximum retry attempts per channel + + Returns: + List of successfully populated channels + """ + successful_channels = [] + + for channel in channels: + retries = 0 + success = False + is_restricted = False + + while retries < max_retries and not success and not is_restricted: + try: + 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 + ) + + logger.debug(f"Getting playlist data for: {channel.name}") + + playlist_data = self.get_channel_playlist( + 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') + + if manifest_url: + channel.manifest = manifest_url + channel.cdm_type = DRM_SYSTEM_WIDEVINE + channel.cdm = f"pid={channel.channel_id}" + channel.license_url = license_url + channel.certificate_url = certificate_url + channel.streaming_format = streaming_format + + logger.info(f"Streaming data populated for: {channel.name}") + successful_channels.append(channel) + success = True + else: + raise Exception("No manifestUrl in response") + + except PlaybackRestrictedException as e: + logger.warning(f"Playback restricted for {channel.name}: {e}") + is_restricted = True + + except Exception as e: + retries += 1 + if retries < max_retries: + 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.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" Total: {len(channels)}") + + return successful_channels + + 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 + + Args: + channel: StreamingChannel to enrich + video_config: Optional video configuration dictionary + **kwargs: Additional parameters + + Returns: + The enriched StreamingChannel with manifest and proper DRM config, or None if failed + """ + try: + # First get entitlement token + entitlement_token = self.get_entitlement_token( + content_id=channel.channel_id, + content_type=channel.content_type + ) + + # Then get playlist data + playlist_data = self.get_channel_playlist( + channel.channel_id, + entitlement_token, + video_config + ) + + manifest_url = playlist_data.get('manifestUrl') + if not manifest_url: + return None + + # Update the channel with manifest + channel.manifest = manifest_url + channel.streaming_format = playlist_data.get('streamingFormat', 'dash') + + # Only set up DRM if we have license information + license_url = playlist_data.get('licenseUrl') + if license_url: + # Create proper DRM configuration with Joyn-specific requirements + 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'] + }), + req_data="{CHA-RAW}", + use_http_get_request=False + ) + ) + channel.drm_config = drm_config + channel.cdm_type = DRM_SYSTEM_WIDEVINE + channel.cdm = f"pid={channel.channel_id}" + + return channel + + except Exception as e: + logger.error(f"Error getting manifest for {channel.name}: {e}") + return None + + def get_drm_configs(self, + channel: StreamingChannel, + needs_base64_wrap: bool = False, + **kwargs) -> List[DRMConfig]: + """ + Get DRM configuration for Joyn channels (Widevine) + + Args: + channel: StreamingChannel object + needs_base64_wrap: Whether to wrap the license request in base64 + **kwargs: Additional parameters + + Returns: + List of DRMConfig objects (typically contains one Widevine config) + """ + if not channel.license_url: + return [] # No DRM if no license URL + + try: + # Get fresh auth token if needed + if not self.authenticator.is_authenticated(): + self.bearer_token = self.authenticator.authenticate() + + # Prepare license headers + license_headers = DRM_REQUEST_HEADERS.copy() + license_headers['Authorization'] = f"Bearer {self.bearer_token}" + + return [ + DRMConfig( + system=DRMSystem.WIDEVINE, + priority=1, # Highest priority for Widevine + license=LicenseConfig( + server_url=channel.license_url, + server_certificate=channel.certificate_url, + req_headers=urlencode(license_headers), + use_http_get_request=False, + wrapper="base64" if needs_base64_wrap else None + ) + ) + ] + + except Exception as e: + logger.error(f"Error generating DRM config for {channel.name}: {e}") + return [] + + 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 + + Args: + channel_id: ID of the channel to get manifest for + content_type: Content type ('LIVE' or 'VOD') + video_config: Optional video configuration dictionary + **kwargs: Additional parameters (ignored for compatibility) + + Returns: + Manifest URL string, or None if not available + """ + try: + # Get entitlement token + entitlement_token = self.get_entitlement_token( + content_id=channel_id, + content_type=content_type + ) + + # Get playlist data + playlist_data = self.get_channel_playlist( + channel_id, + entitlement_token, + video_config + ) + + 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_configs_by_id(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 + + Args: + channel_id: ID of the channel to get DRM configs + content_type: Content type ('LIVE' or 'VOD') + video_config: Optional video configuration dictionary + **kwargs: Additional parameters (ignored for compatibility) + + Returns: + List of DRMConfig objects (can be empty if no DRM is used) + """ + try: + # Get entitlement token + entitlement_token = self.get_entitlement_token( + content_id=channel_id, + content_type=content_type + ) + + # Get playlist data + playlist_data = self.get_channel_playlist( + channel_id, + entitlement_token, + video_config + ) + + license_url = playlist_data.get('licenseUrl') + if not license_url: + return [] # No DRM if no license URL + + # Create DRM configuration + drm_config = DRMConfig( + system=DRMSystem.WIDEVINE, + priority=1, + license=LicenseConfig( + 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 + }), + req_data="{CHA-RAW}", + use_http_get_request=False + ) + ) + + return [drm_config] + + except Exception as e: + 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]: + """ + Get EPG data for a channel + + Args: + channel_id: Channel ID to get EPG for + start_time: Optional start time for EPG window + end_time: Optional end time for EPG window + **kwargs: Additional parameters + + Returns: + List of EPG entries (each containing start/end times, title, description, etc.) + """ + # Joyn EPG implementation would require additional GraphQL queries + # This is a stub implementation - would need specific Joyn EPG endpoints + try: + # Default time window if not provided + if start_time is None: + start_time = datetime.now() + if end_time is None: + end_time = datetime.now() + timedelta(hours=DEFAULT_EPG_WINDOW_HOURS) + + headers = self._get_graphql_headers() + + # This would need the actual Joyn EPG GraphQL query + # For now, return empty list as EPG functionality would require + # additional endpoint investigation + 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 diff --git a/lib/streaming_providers/providers/rtlplus/__init__.py b/lib/streaming_providers/providers/rtlplus/__init__.py new file mode 100644 index 0000000..8bbc6e2 --- /dev/null +++ b/lib/streaming_providers/providers/rtlplus/__init__.py @@ -0,0 +1,8 @@ +# lib/streaming_providers/providers/rtlplus/__init__.py +""" +RTL+ streaming provider module +""" + +from .provider import RTLPlusProvider +__all__ = ["RTLPlusProvider"] + diff --git a/lib/streaming_providers/providers/rtlplus/auth.py b/lib/streaming_providers/providers/rtlplus/auth.py new file mode 100644 index 0000000..4a88b88 --- /dev/null +++ b/lib/streaming_providers/providers/rtlplus/auth.py @@ -0,0 +1,347 @@ +# streaming_providers/providers/rtlplus/auth.py +import json +import base64 +from typing import Dict, Any, Optional +from ...base.auth.base_auth import BaseAuthToken, TokenAuthLevel +from ...base.auth.base_oauth2_auth import BaseOAuth2Authenticator +from ...base.utils.logger import logger +from .models import RTLPlusClientCredentials, RTLPlusUserCredentials, RTLPlusAuthToken +from .constants import RTLPlusDefaults, RTLPlusConfig +from ...base.models.proxy_models import ProxyConfig + + +class RTLPlusAuthenticator(BaseOAuth2Authenticator): + def __init__(self, credentials=None, config_dir=None, client_version=None, device_id=None, + proxy_config: Optional[ProxyConfig] = None, http_manager=None): + + # Initialize configuration FIRST + config_dict = {} + if client_version: + config_dict['client_version'] = client_version + if device_id: + config_dict['device_id'] = device_id + + self._config = RTLPlusConfig(config_dict) + self._client_id = None + + # 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('rtlplus') + + # Call parent init FIRST + super().__init__( + provider_name='rtlplus', + credentials=credentials, # Pass None if not provided + config_dir=config_dir, + proxy_config=proxy_config, + http_manager=http_manager + ) + + # NOW set default credentials if needed (after super init) + if self.credentials is None: + self.credentials = self._get_default_credentials() + + @property + def auth_endpoint(self) -> str: + """Override auth_endpoint to use our config""" + return self.config.auth_endpoint + + # Required OAuth2 properties + @property + def oauth_client_id(self) -> str: + return self._get_client_id() + + @property + def oauth_scope(self) -> str: + return "openid email" + + @property + def oauth_redirect_uri(self) -> str: + return self.config.base_website + + def _get_auth_headers(self) -> Dict[str, str]: + """RTL+-specific authentication headers""" + return self.config.get_auth_headers() + + def _build_auth_payload(self) -> Dict[str, Any]: + """Build authentication payload from credentials""" + return self.credentials.to_auth_payload() + + def _get_default_credentials(self): + """Get default client credentials for anonymous access""" + try: + # Try to get dynamic credentials first + config_creds = self._get_anonymous_credentials_from_config() + if config_creds: + return RTLPlusClientCredentials( + client_id=config_creds.get('client_id', RTLPlusDefaults.ANONYMOUS_CLIENT_ID), + client_secret=config_creds.get('client_secret', RTLPlusDefaults.ANONYMOUS_CLIENT_SECRET) + ) + except Exception as e: + logger.warning(f"Could not get dynamic credentials: {e}") + + # Fallback to default credentials + return RTLPlusClientCredentials() + + def _create_token_from_response(self, response_data: Dict[str, Any]) -> RTLPlusAuthToken: + """Create RTL+-specific token from OAuth2 response""" + import time + return RTLPlusAuthToken( + access_token=response_data['access_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()), + refresh_token=response_data.get('refresh_token'), + refresh_expires_in=response_data.get('refresh_expires_in', 0), + not_before_policy=response_data.get('not-before-policy'), + scope=response_data.get('scope', '') + ) + + def get_fallback_credentials(self): + """Get fallback credentials (anonymous client credentials)""" + return self._get_default_credentials() + + def _classify_token(self, token: BaseAuthToken) -> TokenAuthLevel: + """ + Classify RTL+ token authentication level by decoding JWT payload + + Logic: + - CLIENT_CREDENTIALS: isGuest=True AND clientId='anonymous-user' + - USER_AUTHENTICATED: Has preferred_username OR email claims + - UNKNOWN: Cannot determine or invalid token + + Args: + token: Token to classify + + Returns: + TokenAuthLevel indicating the authentication level + """ + if not token or not token.access_token: + logger.debug("RTL+ Cannot classify: No token or access token") + return TokenAuthLevel.UNKNOWN + + try: + # Decode JWT without verification to check the payload + parts = token.access_token.split('.') + if len(parts) < 2: + logger.debug("RTL+ Cannot classify: Invalid token format") + return TokenAuthLevel.UNKNOWN + + # Add padding if needed and decode + payload_segment = parts[1] + padding = 4 - len(payload_segment) % 4 + if padding != 4: + payload_segment += '=' * padding + + payload_json = base64.b64decode(payload_segment) + payload = json.loads(payload_json) + + # Extract relevant claims + client_id = payload.get('clientId') + is_guest = payload.get('isGuest', False) + preferred_username = payload.get('preferred_username') + email = payload.get('email') + + logger.debug(f"RTL+ Token JWT payload: clientId={client_id}, isGuest={is_guest}, " + f"has_preferred_username={bool(preferred_username)}, has_email={bool(email)}") + + # Check for user-authenticated token + if preferred_username or email: + logger.debug("RTL+ Token classified as USER_AUTHENTICATED (has user claims)") + return TokenAuthLevel.USER_AUTHENTICATED + + # Check for client credentials (anonymous) token + if is_guest and client_id == 'anonymous-user': + logger.debug("RTL+ Token classified as CLIENT_CREDENTIALS (anonymous)") + return TokenAuthLevel.CLIENT_CREDENTIALS + + # Cannot determine + logger.debug("RTL+ Token classified as UNKNOWN (no matching criteria)") + return TokenAuthLevel.UNKNOWN + + except Exception as e: + logger.warning(f"RTL+ Error classifying token: {e}") + return TokenAuthLevel.UNKNOWN + + def _perform_oauth_authorization_code_flow(self, username: str, password: str) -> Dict[str, Any]: + """ + RTL+ specific OAuth2 authorization code flow with PKCE + Uses base class generic form login + """ + return self._perform_generic_form_login( + username=username, + password=password, + form_selector_pattern=r'
str: + """Get client ID from RTL+ website configuration using base class method""" + if self._client_id: + return self._client_id + + # Use base class method for extraction + self._client_id = self._extract_client_id_from_js( + main_page_url=self.config.base_website, + js_file_pattern=r'