# 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, Tuple 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, ClientCredentials, UserPasswordCredentials 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, 'is_enabled': self.is_provider_enabled(provider_name, country), '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, 'is_enabled': self.is_provider_enabled(provider_name, country), 'registered_at': registration.registered_at, 'supports_countries': registration.supports_countries, 'credentials': { 'has_credentials': has_credentials, 'credentials_valid': credentials_valid, 'credential_type': credentials.credential_type if credentials else None }, 'session': { 'has_active_session': has_active_session, 'device_id': self.get_device_id(provider_name, country) }, '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 scoped_tokens = {} available_scopes = self.list_scoped_tokens(provider_name, country) for scope in available_scopes: scope_status = self.get_scoped_token_status(provider_name, scope, country) scoped_tokens[scope] = scope_status if scoped_tokens: status['scoped_tokens'] = scoped_tokens 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 # ============= Scoped Token Management (Pass-through to SessionManager) ============= def save_scoped_token(self, provider_name: str, scope: str, token_data: Dict[str, Any], country: Optional[str] = None) -> bool: """ Save authentication token for a specific scope Pass-through method to SessionManager.save_scoped_token() Args: provider_name: Name of the provider scope: Token scope (e.g., 'tvhubs', 'taa', 'yo_digital') token_data: Token data to save (should include access_token, expires_in, etc.) country: Optional country code Returns: True if successful, False otherwise Examples: # Save tvhubs token (access token only) settings_manager.save_scoped_token('magenta2', 'tvhubs', { 'access_token': '...', 'token_type': 'Bearer', 'expires_in': 7200, 'issued_at': 1234567890 }, 'de') # Save yo_digital token (access + refresh, each with own expiry) settings_manager.save_scoped_token('magenta2', 'yo_digital', { 'access_token': '...', 'access_token_expires_in': 3600, 'access_token_issued_at': 1234567890, 'refresh_token': '...', 'refresh_token_expires_in': 86400, 'refresh_token_issued_at': 1234567890, 'token_type': 'Bearer' }, 'de') """ return self.session_manager.save_scoped_token(provider_name, scope, token_data, country) def load_scoped_token(self, provider_name: str, scope: str, country: Optional[str] = None) -> Optional[Dict[str, Any]]: """ Load token data for a specific scope Pass-through method to SessionManager.load_scoped_token() Args: provider_name: Name of the provider scope: Token scope (e.g., 'tvhubs', 'taa', 'yo_digital') country: Optional country code Returns: Token data dictionary or None if not found or expired Note: This method checks token expiration automatically. For yo_digital tokens, it checks both access_token and refresh_token expiry. """ return self.session_manager.load_scoped_token(provider_name, scope, country) def clear_scoped_token(self, provider_name: str, scope: str, country: Optional[str] = None) -> bool: """ Clear token for a specific scope Pass-through method to SessionManager.clear_scoped_token() Args: provider_name: Name of the provider scope: Token scope to clear country: Optional country code Returns: True if successful, False otherwise Example: # Clear only the tvhubs token, keep taa and yo_digital settings_manager.clear_scoped_token('magenta2', 'tvhubs', 'de') """ return self.session_manager.clear_scoped_token(provider_name, scope, country) def list_scoped_tokens(self, provider_name: str, country: Optional[str] = None) -> List[str]: """ List all available token scopes for a provider Args: provider_name: Name of the provider country: Optional country code Returns: List of scope names (e.g., ['tvhubs', 'taa', 'yo_digital']) Example: scopes = settings_manager.list_scoped_tokens('magenta2', 'de') # Returns: ['tvhubs', 'taa', 'yo_digital'] """ session_data = self.session_manager.load_session(provider_name, country) if not session_data: return [] # Filter out non-scope keys (device_id, refresh_token at provider level) non_scope_keys = {'device_id', 'refresh_token'} scopes = [ key for key in session_data.keys() if isinstance(session_data[key], dict) and key not in non_scope_keys ] return scopes def get_scoped_token_status(self, provider_name: str, scope: str, country: Optional[str] = None) -> Dict[str, Any]: """ Get detailed status information for a scoped token Args: provider_name: Name of the provider scope: Token scope country: Optional country code Returns: Dictionary with token status information Example: status = settings_manager.get_scoped_token_status('magenta2', 'yo_digital', 'de') # Returns: # { # 'exists': True, # 'access_token_valid': True, # 'access_token_expires_at': 1234567890, # 'refresh_token_valid': True, # 'refresh_token_expires_at': 1234654290, # 'scope': 'yo_digital' # } """ token_data = self.load_scoped_token(provider_name, scope, country) if not token_data: return { 'exists': False, 'scope': scope, 'provider_name': provider_name, 'country': country } status = { 'exists': True, 'scope': scope, 'provider_name': provider_name, 'country': country, 'token_type': token_data.get('token_type', 'Bearer') } # Check access token expiration if 'access_token' in token_data: status['has_access_token'] = True # Standard expiration (single expires_in and issued_at) if 'expires_in' in token_data and 'issued_at' in token_data: expires_at = token_data['issued_at'] + token_data['expires_in'] status['access_token_expires_at'] = expires_at status['access_token_valid'] = time.time() < expires_at # yo_digital style (separate access_token_expires_in) elif 'access_token_expires_in' in token_data and 'access_token_issued_at' in token_data: expires_at = token_data['access_token_issued_at'] + token_data['access_token_expires_in'] status['access_token_expires_at'] = expires_at status['access_token_valid'] = time.time() < expires_at else: status['access_token_valid'] = None # Cannot determine # Check refresh token expiration (for yo_digital) if 'refresh_token' in token_data: status['has_refresh_token'] = True if 'refresh_token_expires_in' in token_data and 'refresh_token_issued_at' in token_data: expires_at = token_data['refresh_token_issued_at'] + token_data['refresh_token_expires_in'] status['refresh_token_expires_at'] = expires_at status['refresh_token_valid'] = time.time() < expires_at else: status['refresh_token_valid'] = None # Cannot determine return status def is_provider_enabled(self, provider_name: str, country: Optional[str] = None) -> bool: """ Check if a provider is enabled via Kodi settings. Args: provider_name: Name of the provider (e.g., 'joyn', 'magenta2') country: Optional country code (e.g., 'de', 'at') Returns: True if provider is enabled, False if disabled, True by default if no setting found """ if not self.kodi_bridge or not self.kodi_bridge.is_kodi_environment(): return True # Default to enabled if not in Kodi environment try: # Priority 1: Check provider_country specific enable setting (e.g., enable_joyn_de) if country: enable_setting_country = f"enable_{provider_name}_{country}" if self.kodi_bridge.addon.getSetting(enable_setting_country): enabled = self.kodi_bridge.addon.getSetting(enable_setting_country) logger.debug(f"Found country-specific enable setting '{enable_setting_country}': {enabled}") return enabled.lower() in ['true', '1', 'yes'] # Priority 2: Check general provider enable setting (e.g., enable_joyn) enable_setting_general = f"enable_{provider_name}" if self.kodi_bridge.addon.getSetting(enable_setting_general): enabled = self.kodi_bridge.addon.getSetting(enable_setting_general) logger.debug(f"Found general enable setting '{enable_setting_general}': {enabled}") return enabled.lower() in ['true', '1', 'yes'] # No enable setting found - default to enabled logger.debug(f"No enable setting found for '{provider_name}', defaulting to enabled") return True except Exception as e: logger.warning(f"Error checking enable status for '{provider_name}': {e}, defaulting to enabled") return True # ============= API Import Methods ============= @staticmethod def parse_provider_country(provider_name: str) -> Tuple[str, Optional[str]]: """ Parse provider_country format into (provider, country) Examples: "joyn_de" → ("joyn", "de") "magenta2_de" → ("magenta2", "de") "some_provider" → ("some_provider", None) Args: provider_name: Combined provider name like "joyn_de" or plain "provider" Returns: Tuple of (provider, country) where country may be None """ # Split by last underscore parts = provider_name.rsplit('_', 1) # Check if last part looks like a country code (2-3 alphabetic chars) if len(parts) == 2 and len(parts[1]) in (2, 3) and parts[1].isalpha(): return parts[0], parts[1].lower() # No country suffix detected return provider_name, None def get_auth_status(self, provider_name: str) -> Dict[str, Any]: """ Get comprehensive authentication status for a provider Determines the "real" authentication state accounting for: - Providers with credentials but no token (lazy auth) - Expired vs valid tokens - Different credential types Args: provider_name: Provider name, optionally with country (e.g., "joyn_de") Returns: Dictionary with authentication status information """ # Parse provider and country provider, country = self.parse_provider_country(provider_name) # Check if provider is registered if not self.is_provider_registered(provider): return { 'provider': provider_name, 'auth_state': 'not_authenticated', 'error': f'Provider "{provider}" is not registered', 'is_ready': False } # Load credentials credentials = self.get_provider_credentials(provider, country) has_credentials = credentials is not None and credentials.validate() # Load token data token_data = self.load_token_data(provider, country) has_token = token_data is not None # Check token expiration (using same buffer as SessionManager) token_valid = False token_expires_at = None if has_token: # Use SessionManager's expiration check token_valid = not self.session_manager._is_token_expired(token_data, buffer_seconds=300) # Calculate expiration timestamp if 'issued_at' in token_data and 'expires_in' in token_data: token_expires_at = token_data['issued_at'] + token_data['expires_in'] # Determine authentication state auth_state = self._determine_auth_state( has_credentials=has_credentials, has_token=has_token, token_valid=token_valid, credential_type=credentials.credential_type if credentials else None ) # Build response status = { 'provider': provider_name, 'auth_state': auth_state, 'credential_type': credentials.credential_type if credentials else None, 'has_credentials': has_credentials, 'has_active_token': token_valid, 'token_expires_at': token_expires_at, 'is_ready': auth_state in ['user_authenticated', 'client_authenticated'] } return status @staticmethod def _determine_auth_state(has_credentials: bool, has_token: bool, token_valid: bool, credential_type: Optional[str]) -> str: """ Determine authentication state from credential and token status Args: has_credentials: Whether valid credentials exist has_token: Whether token data exists token_valid: Whether token is valid (not expired) credential_type: Type of credential ("user_password" or "client_credentials") Returns: One of: "not_authenticated", "credentials_only", "user_authenticated", "client_authenticated" """ # No credentials and no token if not has_credentials and not has_token: return "not_authenticated" # No credentials but has token (shouldn't happen, but handle it) if not has_credentials and has_token: return "not_authenticated" # Has credentials but no token (lazy auth - hasn't authenticated yet) if has_credentials and not has_token: return "credentials_only" # Has credentials and token but token expired (needs re-auth) if has_credentials and has_token and not token_valid: return "credentials_only" # Has credentials and valid token - determine type if has_credentials and has_token and token_valid: if credential_type == "user_password": return "user_authenticated" elif credential_type == "client_credentials": return "client_authenticated" else: # Unknown credential type but authenticated return "user_authenticated" # Fallback return "not_authenticated" def save_provider_credentials_from_api(self, provider_name: str, credentials_data: Dict[str, Any]) -> Tuple[bool, str]: """ Save credentials from API request (bypasses Kodi sync) Args: provider_name: Provider name, optionally with country (e.g., "joyn_de") credentials_data: Dictionary with credential data For user_password: {"username": "...", "password": "...", "client_id": "..." (optional)} For client_credentials: {"client_id": "...", "client_secret": "..."} Returns: Tuple of (success: bool, message: str) """ try: # Parse provider and country provider, country = self.parse_provider_country(provider_name) # Check if provider is registered if not self.is_provider_registered(provider): return False, f'Provider "{provider}" is not registered' # Validate credentials_data format if not isinstance(credentials_data, dict): return False, "Credentials data must be a dictionary" # Determine credential type and create appropriate object credentials = self._parse_credentials_from_dict(credentials_data) if not credentials: return False, "Invalid credentials format. Must provide either (username + password) or (client_id + client_secret)" # Validate credentials if not credentials.validate(): return False, f"Credential validation failed: missing required fields" # Save directly to file (bypass Kodi sync) success = self.credential_manager.save_credentials(provider, credentials, country) if success: country_str = f" ({country})" if country else "" logger.info(f"Successfully saved credentials from API for {provider}{country_str}") return True, "Credentials saved successfully" else: return False, "Failed to save credentials to file" except Exception as e: logger.error(f"Error saving credentials from API for {provider_name}: {e}") return False, f"Internal error: {str(e)}" @staticmethod def _parse_credentials_from_dict(credentials_data: Dict[str, Any]) -> Optional[BaseCredentials]: """ Parse credentials dictionary and create appropriate credential object Args: credentials_data: Dictionary with credential data Returns: BaseCredentials instance or None if invalid """ # Check for user_password credentials has_username = 'username' in credentials_data and credentials_data['username'] has_password = 'password' in credentials_data and credentials_data['password'] if has_username and has_password: return UserPasswordCredentials( username=credentials_data['username'].strip(), password=credentials_data['password'].strip(), client_id=credentials_data.get('client_id', '').strip() or None ) # Check for client credentials has_client_id = 'client_id' in credentials_data and credentials_data['client_id'] has_client_secret = 'client_secret' in credentials_data and credentials_data['client_secret'] if has_client_id and has_client_secret: return ClientCredentials( client_id=credentials_data['client_id'].strip(), client_secret=credentials_data['client_secret'].strip() ) # Invalid format return None def save_provider_proxy_from_api(self, provider_name: str, proxy_data: Dict[str, Any]) -> Tuple[bool, str]: """ Save proxy configuration from API request (bypasses Kodi sync) Args: provider_name: Provider name, optionally with country (e.g., "joyn_de") proxy_data: Dictionary with proxy data Required: {"host": "...", "port": 8080} Optional: {"proxy_type": "http", "username": "...", "password": "...", "timeout": 30, "verify_ssl": true} Returns: Tuple of (success: bool, message: str) """ try: # Parse provider and country provider, country = self.parse_provider_country(provider_name) # Check if provider is registered if not self.is_provider_registered(provider): return False, f'Provider "{provider}" is not registered' # Validate proxy_data format if not isinstance(proxy_data, dict): return False, "Proxy data must be a dictionary" # Create ProxyConfig object proxy_config = self._parse_proxy_from_dict(proxy_data) if not proxy_config: return False, "Invalid proxy format. Must provide 'host' and 'port'" # Validate proxy config if not proxy_config.validate(): return False, "Proxy validation failed: invalid host, port, or timeout" # Save directly to file (bypass Kodi sync) success = self.proxy_manager.set_proxy_config(provider, proxy_config, country) if success: country_str = f" ({country})" if country else "" logger.info(f"Successfully saved proxy config from API for {provider}{country_str}") return True, "Proxy configuration saved successfully" else: return False, "Failed to save proxy configuration to file" except Exception as e: logger.error(f"Error saving proxy config from API for {provider_name}: {e}") return False, f"Internal error: {str(e)}" @staticmethod def _parse_proxy_from_dict(proxy_data: Dict[str, Any]) -> Optional[ProxyConfig]: """ Parse proxy dictionary and create ProxyConfig object Args: proxy_data: Dictionary with proxy data Returns: ProxyConfig instance or None if invalid """ from ..models.proxy_models import ProxyConfig, ProxyAuth, ProxyType, ProxyScope # Required fields if 'host' not in proxy_data or not proxy_data['host']: return None if 'port' not in proxy_data: return None try: port = int(proxy_data['port']) except (ValueError, TypeError): return None # Optional proxy type proxy_type = ProxyType.HTTP if 'proxy_type' in proxy_data: try: proxy_type = ProxyType(proxy_data['proxy_type'].lower()) except (ValueError, AttributeError): pass # Use default # Optional authentication auth = None if 'username' in proxy_data and 'password' in proxy_data: if proxy_data['username'] and proxy_data['password']: auth = ProxyAuth( username=proxy_data['username'].strip(), password=proxy_data['password'].strip() ) # Optional scope (default to all enabled) scope = ProxyScope() if 'scope' in proxy_data and isinstance(proxy_data['scope'], dict): scope_data = proxy_data['scope'] 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) ) # Optional settings timeout = proxy_data.get('timeout', 30) verify_ssl = proxy_data.get('verify_ssl', True) return ProxyConfig( host=proxy_data['host'].strip(), port=port, proxy_type=proxy_type, auth=auth, scope=scope, timeout=timeout, verify_ssl=verify_ssl ) def delete_provider_credentials_from_api(self, provider_name: str) -> Tuple[bool, str]: """ Delete credentials via API request Args: provider_name: Provider name, optionally with country (e.g., "joyn_de") Returns: Tuple of (success: bool, message: str) """ try: # Parse provider and country provider, country = self.parse_provider_country(provider_name) # Check if provider is registered if not self.is_provider_registered(provider): return False, f'Provider "{provider}" is not registered' # Delete credentials success = self.credential_manager.delete_credentials(provider, country) if success: country_str = f" ({country})" if country else "" logger.info(f"Successfully deleted credentials from API for {provider}{country_str}") return True, "Credentials deleted successfully" else: return False, "Failed to delete credentials" except Exception as e: logger.error(f"Error deleting credentials from API for {provider_name}: {e}") return False, f"Internal error: {str(e)}" def delete_provider_proxy_from_api(self, provider_name: str) -> Tuple[bool, str]: """ Delete proxy configuration via API request Args: provider_name: Provider name, optionally with country (e.g., "joyn_de") Returns: Tuple of (success: bool, message: str) """ try: # Parse provider and country provider, country = self.parse_provider_country(provider_name) # Check if provider is registered if not self.is_provider_registered(provider): return False, f'Provider "{provider}" is not registered' # Delete proxy config success = self.proxy_manager.remove_proxy_config(provider, country) if success: country_str = f" ({country})" if country else "" logger.info(f"Successfully deleted proxy config from API for {provider}{country_str}") return True, "Proxy configuration deleted successfully" else: return False, "Failed to delete proxy configuration" except Exception as e: logger.error(f"Error deleting proxy config from API for {provider_name}: {e}") return False, f"Internal error: {str(e)}" # For imports that expect the old interface UnifiedSettingsManager = SettingsManager # Backward compatibility alias __all__ = ['SettingsManager', 'UnifiedSettingsManager', 'ProviderRegistration']