Files
script.service.ultimate/lib/streaming_providers/base/settings/kodi_settings_bridge.py
T
2026-01-16 12:24:41 +01:00

586 lines
23 KiB
Python

# streaming_providers/base/settings/kodi_settings_bridge.py
import json
import xml.etree.ElementTree as ElementTree
from typing import Any, Dict, List, Optional, Set, Tuple
from ..auth.credentials import BaseCredentials, ClientCredentials, UserPasswordCredentials
from ..models.proxy_models import ProxyConfig
from ..utils.environment import get_environment_manager, get_vfs_instance, is_kodi_environment
from ..utils.logger import logger
class KodiSettingsBridge:
"""Bridge between Kodi addon settings and internal configuration system"""
# Markers that identify credential settings
CREDENTIAL_MARKERS = {"_username", "_password", "_client_id", "_client_secret"}
def __init__(self, addon_id: Optional[str] = None, config_dir: Optional[str] = None):
"""Initialize Kodi settings bridge"""
self.addon = None
self.addon_id = addon_id
self._env_manager = get_environment_manager()
# Initialize VFS with config directory support
self.vfs = get_vfs_instance(config_dir=config_dir)
# Settings storage for standalone mode
self._standalone_settings: Dict[str, str] = {}
self._settings_file = "standalone_settings.json"
self._load_standalone_settings()
if is_kodi_environment():
try:
import xbmcaddon
if addon_id:
self.addon = xbmcaddon.Addon(addon_id)
else:
self.addon = xbmcaddon.Addon()
self.addon_id = self.addon.getAddonInfo("id")
logger.info(f"Kodi settings bridge initialized for addon: {self.addon_id}")
except Exception as e:
logger.error(f"Failed to initialize Kodi addon: {e}")
self.addon = None
def is_kodi_environment(self) -> bool:
"""Check if currently running in Kodi environment"""
return is_kodi_environment() and self.addon is not None
def _load_standalone_settings(self) -> None:
"""Load settings from file in standalone mode"""
if not is_kodi_environment() and self.vfs.exists(self._settings_file):
try:
content = self.vfs.read_text(self._settings_file)
if content:
self._standalone_settings = json.loads(content)
logger.debug(f"Loaded {len(self._standalone_settings)} standalone settings")
except Exception as e:
logger.error(f"Error loading standalone settings: {e}")
def _save_standalone_settings(self) -> None:
"""Save settings to file in standalone mode"""
if not is_kodi_environment():
try:
self.vfs.write_json(self._settings_file, self._standalone_settings)
logger.debug(f"Saved {len(self._standalone_settings)} standalone settings")
except Exception as e:
logger.error(f"Error saving standalone settings: {e}")
def get_setting(self, setting_id: str, default: str = "") -> str:
"""Get setting value, works in both Kodi and standalone mode"""
if self.is_kodi_environment():
try:
return self.addon.getSetting(setting_id) or default
except Exception as e:
logger.error(f"Error getting setting {setting_id}: {e}")
return default
else:
# Standalone mode: use local storage
return self._standalone_settings.get(setting_id, default)
def set_setting(self, setting_id: str, value: str) -> bool:
"""Set setting value, works in both Kodi and standalone mode"""
if self.is_kodi_environment():
try:
self.addon.setSetting(setting_id, value)
logger.debug(f"Set Kodi setting {setting_id}")
return True
except Exception as e:
logger.error(f"Error setting {setting_id}: {e}")
return False
else:
# Standalone mode: update local storage
self._standalone_settings[setting_id] = value
self._save_standalone_settings()
logger.debug(f"Set standalone setting {setting_id}")
return True
def get_addon_info(self) -> Dict[str, str]:
"""Get information about current Kodi addon or environment"""
if not self.is_kodi_environment():
# Return environment info in standalone mode
return {
"environment": "standalone",
"id": self._env_manager.get_config("addon_id", "standalone"),
"name": self._env_manager.get_config("addon_name", "Ultimate Backend"),
"version": self._env_manager.get_config("addon_version", "1.0.0"),
"config_dir": self.vfs.base_path,
"profile_path": self._env_manager.get_config("profile_path", ""),
}
return {
"environment": "kodi",
"id": self.addon.getAddonInfo("id"),
"name": self.addon.getAddonInfo("name"),
"version": self.addon.getAddonInfo("version"),
"author": self.addon.getAddonInfo("author"),
"path": self.addon.getAddonInfo("path"),
}
# ============= Dynamic Discovery =============
def _get_all_setting_ids(self) -> List[str]:
"""
Get all setting IDs from settings.xml by parsing the file.
Returns:
List of all setting IDs found in settings.xml
"""
if not self.is_kodi_environment():
# In standalone mode, return keys from standalone settings
return list(self._standalone_settings.keys())
try:
# Read settings.xml from VFS base path (addon profile directory)
xml_content = self.vfs.read_text("settings.xml")
if not xml_content:
logger.warning("settings.xml not found or empty")
return []
root = ElementTree.fromstring(xml_content)
# Extract all setting IDs
setting_ids = []
for setting in root.findall(".//setting"):
setting_id = setting.get("id")
if setting_id:
setting_ids.append(setting_id)
logger.info(f"Found {len(setting_ids)} settings in settings.xml")
logger.debug(f"Setting IDs: {setting_ids}")
return setting_ids
except Exception as e:
logger.error(f"Error reading settings.xml: {e}")
return []
def _parse_provider_country(self, setting_id: str) -> Optional[Tuple[str, Optional[str]]]:
"""
Parse a setting ID to extract provider and optional country.
Pattern:
- provider_marker → (provider, None)
- provider_country_marker → (provider, country)
- anything else → None
Args:
setting_id: Setting ID like "joyn_username" or "joyn_de_username"
Returns:
Tuple of (provider, country) or None if pattern doesn't match
"""
# Check if ends with a credential marker
marker = None
for m in self.CREDENTIAL_MARKERS:
if setting_id.endswith(m):
marker = m
break
if not marker:
return None
# Remove the marker
prefix = setting_id[: -len(marker)]
# Split by underscore
parts = prefix.split("_")
if len(parts) == 1:
# provider only
return parts[0], None
elif len(parts) == 2:
# provider_country
return parts[0], parts[1]
else:
# Too many parts, doesn't match our pattern
return None
def discover_all_providers(self) -> Dict[str, List[str]]:
"""
Scan all settings and discover providers with their countries dynamically.
Works in both Kodi and standalone mode.
Returns:
Dict mapping provider names to list of countries.
Empty list means provider without country.
Example: {'joyn': ['de', 'at'], 'rtlplus': ['de'], 'zattoo': []}
"""
discovered: Dict[str, Set[Optional[str]]] = {}
# Get all setting IDs
setting_ids = self._get_all_setting_ids()
for setting_id in setting_ids:
result = self._parse_provider_country(setting_id)
if result:
provider, country = result
if provider not in discovered:
discovered[provider] = set()
discovered[provider].add(country)
# Convert sets to lists, with None values converted to empty list
result = {}
for provider, countries in discovered.items():
country_list = []
for country in countries:
if country is None:
# Provider without country - represent as empty list for that provider
if not country_list: # Only add if we haven't already
result[provider] = []
else:
country_list.append(country)
if country_list:
result[provider] = sorted(country_list)
elif provider not in result:
result[provider] = []
logger.info(f"Discovered providers: {result}")
return result
def detect_all_providers_from_kodi(self) -> Dict[str, List[str]]:
"""
Alias for discover_all_providers() for backward compatibility.
"""
return self.discover_all_providers()
def detect_countries_for_provider(self, provider: str) -> List[str]:
"""
Discover which countries are configured for a specific provider.
Args:
provider: Provider name (e.g., 'joyn')
Returns:
List of detected country codes (e.g., ['de', 'at'])
"""
all_providers = self.discover_all_providers()
countries = all_providers.get(provider, [])
# Filter out empty list (which means provider without country)
if not countries:
return []
return countries
def get_all_countries_for_provider(
self, provider: str, available_countries: List[str]
) -> List[str]:
"""
Check which countries from a given list have credentials configured for a provider.
Args:
provider: Provider name
available_countries: List of country codes to check
Returns:
List of country codes that have credentials
"""
discovered_countries = self.detect_countries_for_provider(provider)
return [c for c in available_countries if c in discovered_countries]
# ============= Credential Operations =============
def read_credentials_from_kodi(
self, provider: str, country: Optional[str] = None
) -> Optional[BaseCredentials]:
"""
Read authentication credentials from settings for a provider.
Uses convention: {provider}_{country}_username, {provider}_{country}_password, etc.
If country is None, tries without country suffix for backward compatibility.
Works in both Kodi and standalone mode.
"""
country_suffix = f"_{country}" if country else ""
try:
# Use unified get_setting method
username = self.get_setting(f"{provider}{country_suffix}_username")
password = self.get_setting(f"{provider}{country_suffix}_password")
client_id = self.get_setting(f"{provider}{country_suffix}_client_id")
client_secret = self.get_setting(f"{provider}{country_suffix}_client_secret")
logger.debug(f"Settings for {provider}{country_suffix}:")
logger.debug(f" username: '{username}' (empty={not username})")
logger.debug(f" password: {'***' if password else '(empty)'}")
logger.debug(f" client_id: '{client_id}' (empty={not client_id})")
logger.debug(f" client_secret: {'***' if client_secret else '(empty)'}")
# Determine credential type based on available values
if username and password:
logger.info(f"Found username/password credentials for {provider}{country_suffix}")
return UserPasswordCredentials(
username=username.strip(),
password=password.strip(),
client_id=client_id.strip() if client_id else None,
)
elif client_id and client_secret:
logger.info(f"Found client credentials for {provider}{country_suffix}")
return ClientCredentials(
client_id=client_id.strip(), client_secret=client_secret.strip()
)
logger.debug(f"No valid credentials found for {provider}{country_suffix}")
return None
except Exception as e:
logger.error(f"Error reading credentials for {provider}: {e}")
return None
def write_credentials_to_kodi(
self, provider: str, credentials: BaseCredentials, country: Optional[str] = None
) -> bool:
"""Write authentication credentials to settings"""
country_suffix = f"_{country}" if country else ""
try:
if isinstance(credentials, UserPasswordCredentials):
self.set_setting(f"{provider}{country_suffix}_username", credentials.username)
self.set_setting(f"{provider}{country_suffix}_password", credentials.password)
if credentials.client_id:
self.set_setting(f"{provider}{country_suffix}_client_id", credentials.client_id)
logger.info(f"Wrote username/password credentials for {provider}{country_suffix}")
return True
elif isinstance(credentials, ClientCredentials):
self.set_setting(f"{provider}{country_suffix}_client_id", credentials.client_id)
self.set_setting(
f"{provider}{country_suffix}_client_secret",
credentials.client_secret,
)
logger.info(f"Wrote client credentials for {provider}{country_suffix}")
return True
return False
except Exception as e:
logger.error(f"Error writing credentials for {provider}: {e}")
return False
def sync_credentials_to_file(
self, provider: str, credential_manager, country: Optional[str] = None
) -> bool:
"""Sync provider credentials from settings to credential file"""
credentials = self.read_credentials_from_kodi(provider, country)
if not credentials:
logger.debug(f"No credentials to sync for {provider}")
return False
# Check if credentials are different from file
file_credentials = credential_manager.load_credentials(provider, country)
if self._credentials_equal(credentials, file_credentials):
logger.debug(f"Credentials already in sync for {provider}")
return True # Already in sync
success = credential_manager.save_credentials(provider, credentials, country)
if success:
logger.info(f"Synced credentials from settings to file for {provider}")
return success
# ============= Proxy Operations =============
def read_proxy_config_from_kodi(
self, provider: str, country: Optional[str] = None
) -> Optional[ProxyConfig]:
"""
Read proxy configuration from settings for a provider.
Uses convention: {provider}_{country}_proxy_enabled, {provider}_{country}_proxy_host, etc.
"""
country_suffix = f"_{country}" if country else ""
try:
# Check if proxy is enabled
proxy_enabled = self.get_setting(f"{provider}{country_suffix}_proxy_enabled")
logger.debug(f"Proxy enabled setting for {provider}{country_suffix}: '{proxy_enabled}'")
if not proxy_enabled or proxy_enabled.lower() not in ["true", "1", "yes"]:
logger.debug(f"Proxy not enabled for {provider}{country_suffix}")
return None
proxy_host = self.get_setting(f"{provider}{country_suffix}_proxy_host")
proxy_port_str = self.get_setting(f"{provider}{country_suffix}_proxy_port")
logger.debug(f"Proxy settings for {provider}{country_suffix}:")
logger.debug(f" host: '{proxy_host}'")
logger.debug(f" port: '{proxy_port_str}'")
if not proxy_host or not proxy_port_str:
logger.debug(f"Proxy host or port missing for {provider}{country_suffix}")
return None
try:
proxy_port = int(proxy_port_str)
except ValueError:
logger.error(
f"Invalid proxy port '{proxy_port_str}' for {provider}{country_suffix}"
)
return None
# Create proxy config
proxy_config = ProxyConfig(host=proxy_host.strip(), port=proxy_port)
logger.info(
f"Found proxy config for {provider}{country_suffix}: {proxy_host}:{proxy_port}"
)
return proxy_config
except Exception as e:
logger.error(f"Error reading proxy config for {provider}: {e}")
return None
def write_proxy_config_to_kodi(
self, provider: str, proxy_config: ProxyConfig, country: Optional[str] = None
) -> bool:
"""Write proxy configuration to settings"""
country_suffix = f"_{country}" if country else ""
try:
self.set_setting(f"{provider}{country_suffix}_proxy_enabled", "true")
self.set_setting(f"{provider}{country_suffix}_proxy_host", proxy_config.host)
self.set_setting(f"{provider}{country_suffix}_proxy_port", str(proxy_config.port))
logger.info(f"Wrote proxy config for {provider}{country_suffix}")
return True
except Exception as e:
logger.error(f"Error writing proxy config for {provider}: {e}")
return False
def sync_proxy_config_to_file(
self, provider: str, proxy_manager, country: Optional[str] = None
) -> bool:
"""Sync provider proxy config from settings to proxy config file"""
proxy_config = self.read_proxy_config_from_kodi(provider, country)
if not proxy_config:
logger.debug(f"No proxy config to sync for {provider}")
return False
# Check if proxy config is different from file
file_proxy = proxy_manager.get_proxy_config(provider, country)
if self._proxy_configs_equal(proxy_config, file_proxy):
logger.debug(f"Proxy config already in sync for {provider}")
return True # Already in sync
success = proxy_manager.set_proxy_config(provider, proxy_config, country)
if success:
logger.info(f"Synced proxy config from settings to file for {provider}")
return success
def read_ip_address_from_kodi(
self, provider: str, country: Optional[str] = None
) -> Optional[str]:
"""
Read IP address from settings for a provider.
Uses convention: {provider}_{country}_ipaddress or {provider}_ipaddress
Args:
provider: Provider name (e.g., 'hrti')
country: Optional country code
Returns:
Configured IP address or None if not set
"""
country_suffix = f"_{country}" if country else ""
try:
ip_address = self.get_setting(f"{provider}{country_suffix}_ipaddress")
logger.debug(f"IP address setting for {provider}{country_suffix}: '{ip_address}'")
if ip_address and ip_address.strip():
logger.info(
f"Found configured IP address for {provider}{country_suffix}: {ip_address}"
)
return ip_address.strip()
logger.debug(f"No IP address configured for {provider}{country_suffix}")
return None
except Exception as e:
logger.error(f"Error reading IP address for {provider}: {e}")
return None
# ============= Comparison Helpers =============
@staticmethod
def _credentials_equal(
cred1: Optional[BaseCredentials], cred2: Optional[BaseCredentials]
) -> bool:
"""Compare two credentials for equality"""
if cred1 is None and cred2 is None:
return True
if cred1 is None or cred2 is None:
return False
if type(cred1) != type(cred2):
return False
if isinstance(cred1, UserPasswordCredentials) and isinstance(
cred2, UserPasswordCredentials
):
return (
cred1.username == cred2.username
and cred1.password == cred2.password
and cred1.client_id == cred2.client_id
)
elif isinstance(cred1, ClientCredentials) and isinstance(cred2, ClientCredentials):
return cred1.client_id == cred2.client_id and cred1.client_secret == cred2.client_secret
return False
@staticmethod
def _proxy_configs_equal(proxy1: Optional[ProxyConfig], proxy2: Optional[ProxyConfig]) -> bool:
"""Compare two proxy configurations for equality"""
if proxy1 is None and proxy2 is None:
return True
if proxy1 is None or proxy2 is None:
return False
# Compare basic properties
if (
proxy1.host != proxy2.host
or proxy1.port != proxy2.port
or proxy1.proxy_type != proxy2.proxy_type
):
return False
# Compare authentication
if (proxy1.auth is None) != (proxy2.auth is None):
return False
if proxy1.auth and proxy2.auth:
if (
proxy1.auth.username != proxy2.auth.username
or proxy1.auth.password != proxy2.auth.password
):
return False
# Compare scope
if (
proxy1.scope.api_calls != proxy2.scope.api_calls
or proxy1.scope.authentication != proxy2.scope.authentication
or proxy1.scope.manifests != proxy2.scope.manifests
or proxy1.scope.license != proxy2.scope.license
):
return False
return True
def debug_info(self) -> Dict[str, Any]:
"""Get debug information about the settings bridge"""
info = {
"environment": "kodi" if self.is_kodi_environment() else "standalone",
"addon_info": self.get_addon_info(),
"has_addon": self.addon is not None,
"standalone_settings_count": len(self._standalone_settings),
"vfs_base_path": self.vfs.base_path,
}
if self.is_kodi_environment():
info["addon_id"] = self.addon_id
info["kodi_available"] = True
else:
info["kodi_available"] = False
return info