# streaming_providers/base/settings/settings_manager.py """ Unified Settings Manager - Country-Aware Implementation Supports country-specific credentials, sessions, and configurations """ import json import os import time from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple from ..auth.credential_manager import CredentialManager from ..auth.credentials import BaseCredentials, ClientCredentials, UserPasswordCredentials from ..auth.session_manager import SessionManager from ..models.proxy_models import ProxyConfig from ..network.proxy_manager import ProxyConfigManager from ..utils.logger import logger 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(): try: # ← wrap kodi_bridge call if self._sync_credentials_from_kodi(provider_name, country): credentials = self.credential_manager.load_credentials(provider_name, country) except Exception as e: logger.warning(f"SettingsManager: Kodi sync failed for {provider_name}{country_str}: {e}") 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 provider is enabled (using ProviderEnableManager)""" # Import here to avoid circular imports from .provider_enable_manager import ProviderEnableManager enable_manager = ProviderEnableManager(self.config_dir_path) # Handle country parameter if provider_name doesn't have suffix if country and "_" not in provider_name: provider_to_check = f"{provider_name}_{country}" else: provider_to_check = provider_name return enable_manager.is_provider_enabled(provider_to_check) # ============= 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 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 ProxyAuth, ProxyConfig, ProxyScope, ProxyType # 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"]