From 3c9b39acf9a398175bcff0a45e080fb73c4a9d41 Mon Sep 17 00:00:00 2001 From: Nirvana Date: Tue, 23 Dec 2025 16:51:49 +0100 Subject: [PATCH] Restructure for enabling/disabling --- .../base/catchup_operations.py | 116 ++ .../base/channel_operations.py | 71 ++ .../base/drm_operations.py | 77 ++ .../base/epg_operations.py | 72 ++ lib/streaming_providers/base/manager.py | 1112 +++-------------- lib/streaming_providers/base/provider.py | 422 ++++--- .../base/provider_registry.py | 222 ++++ .../base/subscription_operations.py | 79 ++ .../providers/hrti/provider.py | 86 +- .../providers/joyn/provider.py | 78 +- .../providers/magentaeu/provider.py | 37 +- .../providers/rtlplus/provider.py | 11 +- service.py | 141 +-- 13 files changed, 1259 insertions(+), 1265 deletions(-) create mode 100644 lib/streaming_providers/base/catchup_operations.py create mode 100644 lib/streaming_providers/base/channel_operations.py create mode 100644 lib/streaming_providers/base/drm_operations.py create mode 100644 lib/streaming_providers/base/epg_operations.py create mode 100644 lib/streaming_providers/base/provider_registry.py create mode 100644 lib/streaming_providers/base/subscription_operations.py diff --git a/lib/streaming_providers/base/catchup_operations.py b/lib/streaming_providers/base/catchup_operations.py new file mode 100644 index 0000000..99019e2 --- /dev/null +++ b/lib/streaming_providers/base/catchup_operations.py @@ -0,0 +1,116 @@ +# ============================================================================ +# streaming_providers/base/catchup_operations.py +""" +Catchup/timeshift operations. +""" +from typing import Optional, List, Dict +from .utils.logger import logger + + +class CatchupOperations: + """Handles all catchup-related operations.""" + + def __init__(self, registry, drm_operations): + self.registry = registry + self.drm_operations = drm_operations + logger.debug("CatchupOperations: Initialized") + + def get_catchup_manifest(self, provider_name: str, channel_id: str, + start_time: int, end_time: int, + epg_id: Optional[str] = None, + country: Optional[str] = None) -> Optional[str]: + """Get catchup manifest URL.""" + provider = self.registry.get_provider(provider_name) + if not provider: + raise ValueError(f"Provider '{provider_name}' not found or disabled") + + if not provider.supports_catchup: + logger.warning(f"Provider '{provider_name}' doesn't support catchup") + return None + + is_valid, error = provider.validate_catchup_request(start_time, end_time) + if not is_valid: + logger.error(f"Invalid catchup request: {error}") + return None + + try: + return provider.get_catchup_manifest( + channel_id=channel_id, + start_time=start_time, + end_time=end_time, + epg_id=epg_id, + country=country + ) + except NotImplementedError: + logger.error(f"Provider '{provider_name}' hasn't implemented catchup") + return None + + def get_catchup_drm_configs(self, provider_name: str, channel_id: str, + start_time: int, end_time: int, + epg_id: Optional[str] = None, + country: Optional[str] = None) -> List: + """Get DRM configs for catchup content.""" + provider = self.registry.get_provider(provider_name) + if not provider: + raise ValueError(f"Provider '{provider_name}' not found or disabled") + + if not provider.supports_catchup: + return self.drm_operations.get_channel_drm_configs( + provider_name, channel_id, country=country + ) + + try: + return provider.get_catchup_drm( + channel_id=channel_id, + start_time=start_time, + end_time=end_time, + epg_id=epg_id, + country=country + ) + except NotImplementedError: + return self.drm_operations.get_channel_drm_configs( + provider_name, channel_id, country=country + ) + + def get_catchup_window(self, provider_name: str, + channel_id: Optional[str] = None) -> int: + """Get catchup window in hours.""" + provider = self.registry.get_provider(provider_name) + if not provider: + raise ValueError(f"Provider '{provider_name}' not found or disabled") + + if channel_id: + try: + return provider.get_catchup_window_for_channel(channel_id) + except Exception as e: + logger.warning(f"Error getting channel catchup window: {e}") + + return provider.catchup_window + + def supports_catchup(self, provider_name: str) -> bool: + """Check if provider supports catchup.""" + provider = self.registry.get_provider(provider_name) + if not provider: + raise ValueError(f"Provider '{provider_name}' not found or disabled") + return provider.supports_catchup + + def get_all_catchup_capabilities(self) -> Dict[str, Dict]: + """Get catchup capabilities for all providers.""" + capabilities = {} + for name in self.registry.list_providers(): + try: + provider = self.registry.get_provider(name) + capabilities[name] = { + 'supports_catchup': provider.supports_catchup, + 'catchup_window': provider.catchup_window, + 'catchup_enabled': provider.supports_catchup and + provider.catchup_window > 0 + } + except Exception as e: + logger.warning(f"Error getting catchup for '{name}': {e}") + capabilities[name] = { + 'supports_catchup': False, + 'catchup_window': 0, + 'catchup_enabled': False + } + return capabilities diff --git a/lib/streaming_providers/base/channel_operations.py b/lib/streaming_providers/base/channel_operations.py new file mode 100644 index 0000000..7a597cf --- /dev/null +++ b/lib/streaming_providers/base/channel_operations.py @@ -0,0 +1,71 @@ +# ============================================================================ +# streaming_providers/base/channel_operations.py +""" +Channel-related operations separated from core registry. +""" +from typing import List, Optional, Dict +from .models import StreamingChannel +from .utils.logger import logger + + +class ChannelOperations: + """Handles all channel-related operations.""" + + def __init__(self, registry): + self.registry = registry + logger.debug("ChannelOperations: Initialized") + + def get_channels(self, provider_name: str, fetch_manifests: bool = False, + **kwargs) -> List[StreamingChannel]: + """Get channels from a specific provider.""" + provider = self.registry.get_provider(provider_name) + if not provider: + raise ValueError(f"Provider '{provider_name}' not found or disabled") + + channels = provider.get_channels(**kwargs) + logger.info(f"Retrieved {len(channels)} channels from '{provider_name}'") + + if fetch_manifests and not provider.uses_dynamic_manifests: + enriched = [] + for channel in channels: + enriched_channel = provider.enrich_channel_data(channel, **kwargs) + if enriched_channel: + enriched.append(enriched_channel) + logger.info(f"Enriched {len(enriched)}/{len(channels)} channels") + return enriched + + return channels + + def get_channel_manifest(self, provider_name: str, channel_id: str, + **kwargs) -> Optional[str]: + """Get manifest URL for a specific channel.""" + provider = self.registry.get_provider(provider_name) + if not provider: + raise ValueError(f"Provider '{provider_name}' not found or disabled") + + manifest_url = provider.get_manifest(channel_id, **kwargs) + if manifest_url: + logger.debug(f"Retrieved manifest for '{channel_id}' from '{provider_name}'") + return manifest_url + + def get_all_channels(self, fetch_manifests: bool = True, + **kwargs) -> Dict[str, List[StreamingChannel]]: + """Get channels from all enabled providers.""" + enabled = self.registry.get_enabled_providers() + logger.info(f"Fetching channels from {len(enabled)} providers") + + result = {} + total = 0 + + for name in enabled: + try: + channels = self.get_channels(name, fetch_manifests, **kwargs) + result[name] = channels + total += len(channels) + except Exception as e: + logger.error(f"Failed to get channels from '{name}': {e}") + result[name] = [] + + logger.info(f"Retrieved {total} total channels") + return result + diff --git a/lib/streaming_providers/base/drm_operations.py b/lib/streaming_providers/base/drm_operations.py new file mode 100644 index 0000000..022fbe3 --- /dev/null +++ b/lib/streaming_providers/base/drm_operations.py @@ -0,0 +1,77 @@ +# ============================================================================ +# streaming_providers/base/drm_operations.py +""" +DRM-related operations. +""" +from typing import List, Dict +from .drm import DRMPluginManager +from .models import DRMSystem +from .utils.logger import logger + + +class DRMOperations: + """Handles all DRM-related operations.""" + + def __init__(self, registry): + self.registry = registry + self.drm_plugin_manager = DRMPluginManager() + logger.debug("DRMOperations: Initialized") + + def get_channel_drm_configs(self, provider_name: str, channel_id: str, + **kwargs) -> List: + """Get DRM configurations for a channel.""" + provider = self.registry.get_provider(provider_name) + if not provider: + raise ValueError(f"Provider '{provider_name}' not found or disabled") + + drm_configs = provider.get_drm(channel_id, **kwargs) + + # Extract PSSH if needed + pssh_data_list = [] + if drm_configs and self.drm_plugin_manager.plugins: + if self._needs_pssh_extraction(drm_configs): + manifest_url = provider.get_manifest(channel_id, **kwargs) + if manifest_url: + pssh_data_list = self._extract_pssh_from_manifest(manifest_url) + + # Process through plugins + processed = self.drm_plugin_manager.process_drm_configs( + drm_configs, pssh_data_list, **kwargs + ) + + logger.info(f"Processed DRM for '{channel_id}': {len(processed)} configs") + return processed + + def _needs_pssh_extraction(self, drm_configs) -> bool: + """Check if PSSH extraction is needed.""" + config_systems = {config.system for config in drm_configs} + plugin_systems = set(self.drm_plugin_manager.plugins.keys()) + + return bool( + config_systems & plugin_systems or + DRMSystem.GENERIC in plugin_systems + ) + + def _extract_pssh_from_manifest(self, manifest_url: str) -> List: + """Extract PSSH data from manifest.""" + import requests + from .utils.manifest_parser import ManifestParser + + try: + response = requests.get(manifest_url, timeout=10) + response.raise_for_status() + return ManifestParser.extract_pssh_from_manifest( + response.text, manifest_url + ) + except Exception as e: + logger.warning(f"Failed to extract PSSH: {e}") + return [] + + def list_drm_plugins(self) -> Dict: + """List registered DRM plugins.""" + return self.drm_plugin_manager.list_plugins() + + def clear_drm_plugins(self): + """Clear all DRM plugins.""" + self.drm_plugin_manager.clear_plugins() + diff --git a/lib/streaming_providers/base/epg_operations.py b/lib/streaming_providers/base/epg_operations.py new file mode 100644 index 0000000..b2805d8 --- /dev/null +++ b/lib/streaming_providers/base/epg_operations.py @@ -0,0 +1,72 @@ +# ============================================================================ +# streaming_providers/base/epg_operations.py +""" +EPG-related operations. +""" +from typing import List, Dict, Optional +from .epg import EPGManager +from .utils.logger import logger + + +class EPGOperations: + """Handles all EPG-related operations.""" + + def __init__(self, registry): + self.registry = registry + self.epg_manager = EPGManager() + logger.debug("EPGOperations: Initialized") + + def get_channel_epg(self, provider_name: str, channel_id: str, + **kwargs) -> List[Dict]: + """Get EPG data for a specific channel.""" + provider = self.registry.get_provider(provider_name) + if not provider: + raise ValueError(f"Provider '{provider_name}' not found or disabled") + + if provider.implements_epg: + logger.debug(f"Using native EPG for '{provider_name}'") + epg_data = provider.get_epg(channel_id, **kwargs) + else: + logger.debug(f"Using generic EPG for '{provider_name}'") + epg_data = self.epg_manager.get_epg( + provider_name=provider_name, + channel_id=channel_id, + start_time=kwargs.get('start_time'), + end_time=kwargs.get('end_time') + ) + + logger.debug(f"Retrieved {len(epg_data)} EPG entries for '{channel_id}'") + return epg_data + + def get_provider_epg_xmltv(self, provider_name: str, **kwargs) -> Optional[str]: + """Get complete EPG data in XMLTV format.""" + provider = self.registry.get_provider(provider_name) + if not provider: + raise ValueError(f"Provider '{provider_name}' not found or disabled") + + if provider.implements_epg: + return provider.get_epg_xmltv(**kwargs) + + logger.warning(f"Provider '{provider_name}' has no XMLTV EPG") + return None + + def clear_epg_cache(self) -> bool: + """Clear the generic EPG cache.""" + return self.epg_manager.clear_cache() + + def reload_epg_mapping(self) -> bool: + """Reload EPG channel mapping.""" + return self.epg_manager.reload_mapping() + + def get_epg_cache_info(self) -> Optional[Dict]: + """Get EPG cache information.""" + return self.epg_manager.get_cache_info() + + def get_epg_mapping_stats(self) -> Dict: + """Get EPG mapping statistics.""" + return self.epg_manager.get_mapping_stats() + + def has_epg_mapping(self, provider_name: str, channel_id: str) -> bool: + """Check if EPG mapping exists.""" + return self.epg_manager.has_mapping_for_channel(provider_name, channel_id) + diff --git a/lib/streaming_providers/base/manager.py b/lib/streaming_providers/base/manager.py index 78b8b20..ac84e30 100644 --- a/lib/streaming_providers/base/manager.py +++ b/lib/streaming_providers/base/manager.py @@ -1,191 +1,213 @@ # streaming_providers/base/manager.py -from typing import Dict, List, Optional -from .provider import StreamingProvider -from .models import StreamingChannel, DRMSystem -from .models import UserSubscription, SubscriptionPackage -from .drm import DRMPluginManager -from .epg import EPGManager +""" +Main ProviderManager as a facade coordinating all operations. +Maintains backward compatibility while delegating to specialized classes. +""" +from typing import Dict, List, Optional, Any +from .provider_registry import ProviderRegistry +from .channel_operations import ChannelOperations +from .epg_operations import EPGOperations +from .drm_operations import DRMOperations +from .catchup_operations import CatchupOperations +from .subscription_operations import SubscriptionOperations +from .models import StreamingChannel from .utils.logger import logger + class ProviderManager: """ - Central manager for handling multiple streaming providers. - Handles provider registration, discovery, and channel fetching operations. + Facade coordinating all provider-related operations. + Maintains backward compatibility while delegating to specialized classes. """ def __init__(self): - """Initialize with empty provider registry and DRM plugin manager""" - self.providers: Dict[str, StreamingProvider] = {} - self.drm_plugin_manager = DRMPluginManager() - logger.info("ProviderManager: Initialized with DRM plugin manager") + # Core components + self.registry = ProviderRegistry() - self.epg_manager = EPGManager() + # Specialized operations + self.channel_ops = ChannelOperations(self.registry) + self.epg_ops = EPGOperations(self.registry) + self.drm_ops = DRMOperations(self.registry) + self.catchup_ops = CatchupOperations(self.registry, self.drm_ops) + self.subscription_ops = SubscriptionOperations(self.registry) - logger.info("ProviderManager: Initialized with DRM plugin manager and EPG manager") + # Backward compatibility - expose managers directly + self.drm_plugin_manager = self.drm_ops.drm_plugin_manager + self.epg_manager = self.epg_ops.epg_manager + + # Legacy compatibility - expose providers dict + self.providers = self.registry.providers + self.provider_metadata = self.registry.provider_metadata + + logger.info("ProviderManager: Initialized with modular architecture") + + # ========================================================================== + # REGISTRY OPERATIONS (delegate to ProviderRegistry) + # ========================================================================== + + def discover_all_providers(self, default_country: str = 'DE') -> List[str]: + return self.registry.discover_all_providers(default_country) + + def discover_providers(self, country: str = 'DE', + detected_providers: Dict = None) -> List[str]: + """Legacy method for backward compatibility.""" + if not self.registry.provider_metadata: + self.registry.discover_all_providers(country) + return self.registry.get_enabled_providers() + + def get_provider(self, provider_name: str): + return self.registry.get_provider(provider_name) + + def set_provider_enabled(self, provider_name: str, enabled: bool) -> bool: + return self.registry.set_provider_enabled(provider_name, enabled) + + def get_all_providers_metadata(self) -> List[Dict[str, Any]]: + return self.registry.get_all_providers_metadata() + + def reinitialize_provider(self, provider_name: str) -> bool: + return self.registry.reinitialize_provider(provider_name) + + def reinitialize_providers(self, provider_names: List[str]) -> Dict[str, bool]: + return {name: self.registry.reinitialize_provider(name) + for name in provider_names} + + def reinitialize_all_providers(self) -> Dict[str, bool]: + enabled = self.registry.get_enabled_providers() + return self.reinitialize_providers(enabled) + + def list_providers(self) -> List[str]: + return self.registry.list_providers() + + def list_all_providers(self) -> List[str]: + return self.registry.list_all_providers() + + def clear_providers(self): + self.registry.clear_providers() @staticmethod - def _is_provider_enabled(provider_name: str, country: Optional[str] = None) -> bool: - """ - Check if a provider is enabled via settings manager. - - Args: - provider_name: Name of the provider - country: Optional country code - - Returns: - True if provider is enabled, False otherwise - """ - try: - from .settings.settings_manager import SettingsManager - # Create or get existing settings manager - settings_manager = SettingsManager() - return settings_manager.is_provider_enabled(provider_name, country) - except Exception as e: - logger.warning(f"Could not check enable status for '{provider_name}': {e}, defaulting to enabled") - return True - - def register_provider(self, provider: StreamingProvider) -> None: - """ - Register a single provider instance. - - Args: - provider: Configured provider instance to register - """ - if not isinstance(provider, StreamingProvider): - logger.error(f"ProviderManager: Failed to register provider - invalid type: {type(provider)}") - raise ValueError("Only StreamingProvider instances can be registered") - - self.providers[provider.provider_name] = provider - logger.info(f"ProviderManager: Registered provider '{provider.provider_name}'") - - def register_providers(self, providers: List[StreamingProvider]) -> None: - """ - Register multiple provider instances at once. - - Args: - providers: List of configured provider instances - """ - logger.info(f"ProviderManager: Registering {len(providers)} providers") - for provider in providers: - self.register_provider(provider) - - def discover_providers(self, country: str = 'DE', detected_providers: Dict[str, List[str]] = None) -> List[str]: + def get_provider_class(provider_name: str): + """Get provider class from AVAILABLE_PROVIDERS registry.""" from streaming_providers import AVAILABLE_PROVIDERS - # Backward compatibility: if no detected_providers, use original discovery logic - if detected_providers is None: - logger.info(f"ProviderManager: Discovering providers for country '{country}'") - registered = [] - failed = [] + # Remove country suffix if present + base_name = provider_name + if '_' in provider_name: + name_parts = provider_name.rsplit('_', 1) + if len(name_parts[1]) in (2, 3) and name_parts[1].isalpha(): + base_name = name_parts[0] - for provider_name, provider_class in AVAILABLE_PROVIDERS.items(): - if provider_name not in self.providers: - # CHECK ENABLED STATUS - if not self._is_provider_enabled(provider_name): - logger.debug(f"ProviderManager: Provider '{provider_name}' is disabled, skipping") - continue + return AVAILABLE_PROVIDERS.get(base_name) - try: - provider = provider_class(country=country) - self.register_provider(provider) - registered.append(provider_name) - except Exception as e: - failed.append((provider_name, str(e))) - logger.warning(f"ProviderManager: Could not initialize provider '{provider_name}': {e}") + # ========================================================================== + # CHANNEL OPERATIONS (delegate to ChannelOperations) + # ========================================================================== - logger.info( - f"ProviderManager: Discovery completed - {len(registered)} providers registered, {len(failed)} failed") - return registered + def get_channels(self, provider_name: str, fetch_manifests: bool = False, + **kwargs) -> List[StreamingChannel]: + return self.channel_ops.get_channels(provider_name, fetch_manifests, **kwargs) - # New multi-country logic - logger.info(f"ProviderManager: Discovering providers with multi-country support") - registered = [] - failed = [] + def get_channel_manifest(self, provider_name: str, channel_id: str, + **kwargs) -> Optional[str]: + return self.channel_ops.get_channel_manifest(provider_name, channel_id, **kwargs) - for provider_name, countries in detected_providers.items(): - if provider_name not in AVAILABLE_PROVIDERS: - logger.warning(f"ProviderManager: Provider '{provider_name}' not in AVAILABLE_PROVIDERS, skipping") - continue + def get_all_channels(self, fetch_manifests: bool = True, + **kwargs) -> Dict[str, List[StreamingChannel]]: + return self.channel_ops.get_all_channels(fetch_manifests, **kwargs) - provider_class = AVAILABLE_PROVIDERS[provider_name] + # ========================================================================== + # EPG OPERATIONS (delegate to EPGOperations) + # ========================================================================== - try: - if countries: # Multi-country provider - enabled_countries = [] - for country_code in countries: - # CHECK COUNTRY-SPECIFIC ENABLED STATUS - if not self._is_provider_enabled(provider_name, country_code): - logger.debug( - f"ProviderManager: Provider '{provider_name}_{country_code}' is disabled, skipping") - continue + def get_channel_epg(self, provider_name: str, channel_id: str, + **kwargs) -> List[Dict]: + return self.epg_ops.get_channel_epg(provider_name, channel_id, **kwargs) - provider_key = f"{provider_name}_{country_code}" - if provider_key not in self.providers: - provider = provider_class(country=country_code.lower()) - self.providers[provider_key] = provider - enabled_countries.append(country_code) - logger.debug(f"ProviderManager: Registered {provider_key}") + def get_provider_epg_xmltv(self, provider_name: str, **kwargs) -> Optional[str]: + return self.epg_ops.get_provider_epg_xmltv(provider_name, **kwargs) - # Only add to registered list if at least one country is enabled - if enabled_countries and provider_name not in registered: - registered.append(provider_name) + def clear_epg_cache(self) -> bool: + return self.epg_ops.clear_epg_cache() - else: # Single country provider (fallback to default country) - # CHECK ENABLED STATUS - if not self._is_provider_enabled(provider_name): - logger.debug(f"ProviderManager: Provider '{provider_name}' is disabled, skipping") - continue + def reload_epg_mapping(self) -> bool: + return self.epg_ops.reload_epg_mapping() - if provider_name not in self.providers: - provider = provider_class(country=country.lower()) - self.providers[provider_name] = provider - registered.append(provider_name) + def get_epg_cache_info(self) -> Optional[Dict]: + return self.epg_ops.get_epg_cache_info() - except Exception as e: - failed.append((provider_name, str(e))) - logger.warning(f"ProviderManager: Could not initialize provider '{provider_name}': {e}") + def get_epg_mapping_stats(self) -> Dict: + return self.epg_ops.get_epg_mapping_stats() - logger.info( - f"ProviderManager: Discovery completed - {len(registered)} providers registered, {len(failed)} failed") - return registered + def has_epg_mapping(self, provider_name: str, channel_id: str) -> bool: + return self.epg_ops.has_epg_mapping(provider_name, channel_id) - def discover_drm_plugins(self) -> List[str]: - """ - Discover and register all available DRM plugins. + # ========================================================================== + # DRM OPERATIONS (delegate to DRMOperations) + # ========================================================================== - Returns: - List of discovered plugin names - """ - logger.info("ProviderManager: Discovering DRM plugins") - discovered = self.drm_plugin_manager.discover_plugins() - logger.info(f"ProviderManager: DRM plugin discovery completed - {len(discovered)} plugins available") - return discovered + def get_channel_drm_configs(self, provider_name: str, channel_id: str, + **kwargs) -> List: + return self.drm_ops.get_channel_drm_configs(provider_name, channel_id, **kwargs) - def get_provider(self, provider_name: str) -> Optional[StreamingProvider]: - """ - Get registered provider by name. + def list_drm_plugins(self) -> Dict: + return self.drm_ops.list_drm_plugins() - Args: - provider_name: Name of the provider to retrieve + def clear_drm_plugins(self): + self.drm_ops.clear_drm_plugins() - Returns: - The provider instance or None if not found - """ - provider = self.providers.get(provider_name) - if not provider: - logger.debug(f"ProviderManager: Provider '{provider_name}' not found") - return provider + # ========================================================================== + # CATCHUP OPERATIONS (delegate to CatchupOperations) + # ========================================================================== + + def get_catchup_manifest(self, provider_name: str, channel_id: str, + start_time: int, end_time: int, + epg_id: Optional[str] = None, + country: Optional[str] = None) -> Optional[str]: + return self.catchup_ops.get_catchup_manifest( + provider_name, channel_id, start_time, end_time, epg_id, country + ) + + def get_catchup_drm_configs(self, provider_name: str, channel_id: str, + start_time: int, end_time: int, + epg_id: Optional[str] = None, + country: Optional[str] = None) -> List: + return self.catchup_ops.get_catchup_drm_configs( + provider_name, channel_id, start_time, end_time, epg_id, country + ) + + def get_catchup_window(self, provider_name: str, + channel_id: Optional[str] = None) -> int: + return self.catchup_ops.get_catchup_window(provider_name, channel_id) + + def supports_catchup(self, provider_name: str) -> bool: + return self.catchup_ops.supports_catchup(provider_name) + + def get_all_catchup_capabilities(self) -> Dict[str, Dict]: + return self.catchup_ops.get_all_catchup_capabilities() + + # ========================================================================== + # SUBSCRIPTION OPERATIONS (delegate to SubscriptionOperations) + # ========================================================================== + + def get_subscription_status(self, provider_name: str, **kwargs): + return self.subscription_ops.get_subscription_status(provider_name, **kwargs) + + def get_subscribed_channels(self, provider_name: str, **kwargs): + return self.subscription_ops.get_subscribed_channels(provider_name, **kwargs) + + def get_available_packages(self, provider_name: str, **kwargs): + return self.subscription_ops.get_available_packages(provider_name, **kwargs) + + def is_channel_accessible(self, provider_name: str, channel_id: str, **kwargs): + return self.subscription_ops.is_channel_accessible( + provider_name, channel_id, **kwargs + ) + + # ========================================================================== + # UTILITY METHODS (remain in ProviderManager as helpers) + # ========================================================================== def get_provider_http_manager(self, provider_name: str): - """ - Get HTTP manager for a specific provider. - - Args: - provider_name: Name of the provider - - Returns: - HTTPManager instance if provider exists and has one, None otherwise - """ + """Get HTTP manager for a specific provider.""" provider = self.get_provider(provider_name) if not provider: logger.warning(f"ProviderManager: Provider '{provider_name}' not found") @@ -193,44 +215,31 @@ class ProviderManager: http_manager = provider.http_manager if not http_manager: - logger.warning(f"ProviderManager: Provider '{provider_name}' has no HTTP manager configured") + logger.warning(f"ProviderManager: Provider '{provider_name}' has no HTTP manager") return None - logger.debug(f"ProviderManager: Retrieved HTTP manager for provider '{provider_name}'") + logger.debug(f"ProviderManager: Retrieved HTTP manager for '{provider_name}'") return http_manager def needs_proxy(self, provider_name: str) -> bool: - """ - Check if a provider needs proxy support. - - Args: - provider_name: Name of the provider - - Returns: - True if provider has proxy configured, False otherwise - """ + """Check if a provider needs proxy support.""" http_manager = self.get_provider_http_manager(provider_name) if not http_manager: return False has_proxy = http_manager.config.proxy_config is not None - if has_proxy: - logger.debug(f"ProviderManager: Provider '{provider_name}' requires proxy") - else: - logger.debug(f"ProviderManager: Provider '{provider_name}' does not require proxy") - + logger.debug( + f"ProviderManager: Provider '{provider_name}' {'requires' if has_proxy else 'does not require'} proxy") return has_proxy def get_provider_choices(self) -> Dict[int, str]: """ Get numbered provider choices for user selection. Includes an 'all' option as the last choice. - - Returns: - Mapping of choice numbers to provider names """ - choices = {i+1: name for i, name in enumerate(self.providers.keys())} - choices[len(choices)+1] = 'all' + enabled_providers = self.registry.get_enabled_providers() + choices = {i + 1: name for i, name in enumerate(enabled_providers)} + choices[len(choices) + 1] = 'all' return choices def get_selected_providers(self, choices_input: str) -> List[str]: @@ -243,9 +252,10 @@ class ProviderManager: Returns: List of selected provider names """ - available = list(self.providers.keys()) + available = self.registry.get_enabled_providers() + if not available: - logger.warning("ProviderManager: No providers available for selection") + logger.warning("ProviderManager: No enabled providers available for selection") return [] selected = [] @@ -257,751 +267,19 @@ class ProviderManager: selected.append(available[idx]) elif idx == len(available): # 'all' option selected = available.copy() - - result = selected or available + break + + result = selected if selected else available logger.debug(f"ProviderManager: Selected {len(result)} providers from input '{choices_input}'") return result - def get_channels(self, provider_name: str, fetch_manifests: bool = False, **kwargs) -> List[StreamingChannel]: - """ - Get channels from a specific provider. - - Args: - provider_name: Name of the provider - fetch_manifests: Whether to enrich channels with manifest data - **kwargs: Additional arguments for channel fetching - - Returns: - List of channels from the provider - - Raises: - ValueError: If provider not found - """ - provider = self.get_provider(provider_name) - if not provider: - logger.error(f"ProviderManager: Cannot get channels - provider '{provider_name}' not found") - raise ValueError(f"Provider '{provider_name}' not found") - - logger.debug(f"ProviderManager: Fetching channels from provider '{provider_name}' (fetch_manifests={fetch_manifests})") - channels = provider.get_channels(**kwargs) - logger.info(f"ProviderManager: Retrieved {len(channels)} channels from provider '{provider_name}'") - - if fetch_manifests and not provider.uses_dynamic_manifests: - logger.debug(f"ProviderManager: Enriching channels with manifest data for provider '{provider_name}'") - enriched_channels = [] - for channel in channels: - enriched = provider.enrich_channel_data(channel, **kwargs) - if enriched is not None: - enriched_channels.append(enriched) - logger.info(f"ProviderManager: Enriched {len(enriched_channels)} out of {len(channels)} channels") - return enriched_channels - return channels - - def get_channel_manifest(self, provider_name: str, channel_id: str, **kwargs) -> Optional[str]: - """ - Get manifest URL for a specific channel from a provider. - - Args: - provider_name: Name of the provider - channel_id: ID of the channel - **kwargs: Additional arguments (e.g., country) - - Returns: - Manifest URL string, or None if not available - - Raises: - ValueError: If provider not found - """ - provider = self.get_provider(provider_name) - if not provider: - logger.error(f"ProviderManager: Cannot get manifest - provider '{provider_name}' not found") - raise ValueError(f"Provider '{provider_name}' not found") - - manifest_url = provider.get_manifest(channel_id, **kwargs) - if manifest_url: - logger.debug(f"ProviderManager: Retrieved manifest for channel '{channel_id}' from provider '{provider_name}'") - else: - logger.warning(f"ProviderManager: No manifest available for channel '{channel_id}' from provider '{provider_name}'") - return manifest_url - - def get_channel_epg(self, provider_name: str, channel_id: str, **kwargs) -> List[Dict]: - """ - Get EPG data for a specific channel from a provider. - - Args: - provider_name: Name of the provider - channel_id: ID of the channel - **kwargs: Additional arguments (e.g., start_time, end_time, country) - - Returns: - List of EPG entries - - Raises: - ValueError: If provider not found - """ - provider = self.get_provider(provider_name) - if not provider: - logger.error(f"ProviderManager: Cannot get EPG - provider '{provider_name}' not found") - raise ValueError(f"Provider '{provider_name}' not found") - - # CHECK IF PROVIDER IMPLEMENTS ITS OWN EPG - if provider.implements_epg: - # Use provider's native EPG implementation - logger.debug(f"ProviderManager: Using native EPG for provider '{provider_name}'") - epg_data = provider.get_epg(channel_id, **kwargs) - else: - # Use generic EPG manager - logger.debug(f"ProviderManager: Using generic EPG for provider '{provider_name}'") - epg_data = self.epg_manager.get_epg( - provider_name=provider_name, - channel_id=channel_id, - start_time=kwargs.get('start_time'), - end_time=kwargs.get('end_time') - ) - - logger.debug( - f"ProviderManager: Retrieved {len(epg_data)} EPG entries for channel '{channel_id}' from provider '{provider_name}'") - return epg_data - - def get_provider_epg_xmltv(self, provider_name: str, **kwargs) -> Optional[str]: - """ - Get complete EPG data for a provider in XMLTV format. - - Args: - provider_name: Name of the provider - **kwargs: Additional arguments (e.g., country) - - Returns: - XMLTV formatted string, or None if not available - - Raises: - ValueError: If provider not found - """ - provider = self.get_provider(provider_name) - if not provider: - logger.error(f"ProviderManager: Cannot get XMLTV EPG - provider '{provider_name}' not found") - raise ValueError(f"Provider '{provider_name}' not found") - - # Only use provider's XMLTV if it implements EPG - if provider.implements_epg: - logger.debug(f"ProviderManager: Using native XMLTV EPG for provider '{provider_name}'") - xmltv_data = provider.get_epg_xmltv(**kwargs) - else: - # For generic EPG, we don't generate XMLTV format - # (The EPG XML is already in XMLTV format, but it's global, not provider-specific) - logger.warning( - f"ProviderManager: Provider '{provider_name}' does not implement EPG, no provider-specific XMLTV available") - xmltv_data = None - - if xmltv_data: - logger.info(f"ProviderManager: Retrieved XMLTV EPG data for provider '{provider_name}'") - else: - logger.warning(f"ProviderManager: No XMLTV EPG data available for provider '{provider_name}'") - - return xmltv_data - - def clear_epg_cache(self) -> bool: - """ - Clear the generic EPG cache. - - Returns: - True if cleared successfully - """ - logger.info("ProviderManager: Clearing EPG cache") - return self.epg_manager.clear_cache() - - def reload_epg_mapping(self) -> bool: - """ - Reload EPG channel mapping from file. - - Returns: - True if reloaded successfully - """ - logger.info("ProviderManager: Reloading EPG mapping") - return self.epg_manager.reload_mapping() - - def get_epg_cache_info(self) -> Optional[Dict]: - """ - Get information about EPG cache. - - Returns: - Dictionary with cache info, or None if no cache - """ - return self.epg_manager.get_cache_info() - - def get_epg_mapping_stats(self) -> Dict: - """ - Get statistics about EPG channel mapping. - - Returns: - Dictionary with mapping statistics - """ - return self.epg_manager.get_mapping_stats() - - def has_epg_mapping(self, provider_name: str, channel_id: str) -> bool: - """ - Check if EPG mapping exists for a specific channel. - - Args: - provider_name: Name of provider - channel_id: Channel ID - - Returns: - True if mapping exists - """ - return self.epg_manager.has_mapping_for_channel(provider_name, channel_id) - - def get_channel_drm_configs(self, provider_name: str, channel_id: str, **kwargs) -> List: - provider = self.get_provider(provider_name) - if not provider: - logger.error(f"ProviderManager: Cannot get DRM configs - provider '{provider_name}' not found") - raise ValueError(f"Provider '{provider_name}' not found") - - logger.debug(f"ProviderManager: Getting DRM configs for channel '{channel_id}' from provider '{provider_name}'") - - # Get raw DRM configs from provider - drm_configs = provider.get_drm(channel_id, **kwargs) - logger.debug(f"ProviderManager: Retrieved {len(drm_configs)} raw DRM configs") - - # Check if we need PSSH data at all - pssh_data_list = [] - if drm_configs and self.drm_plugin_manager.plugins: - # Get DRM systems from configs - config_drm_systems = {config.system for config in drm_configs} - # Get DRM systems that have plugins (excluding GENERIC which processes all) - plugin_drm_systems = set(self.drm_plugin_manager.plugins.keys()) - - # Check if there's overlap OR if there's a GENERIC plugin (which processes everything) - needs_pssh = bool( - config_drm_systems & plugin_drm_systems or - DRMSystem.GENERIC in plugin_drm_systems - ) - - if needs_pssh: - manifest_url = provider.get_manifest(channel_id, **kwargs) - if manifest_url: - logger.debug( - f"ProviderManager: PSSH needed - extracting from manifest (matching systems: {config_drm_systems & plugin_drm_systems})") - try: - pssh_data_list = self._extract_pssh_from_manifest(manifest_url) - logger.debug(f"ProviderManager: Extracted {len(pssh_data_list)} PSSH data entries") - except Exception as e: - logger.warning(f"ProviderManager: Could not extract PSSH data from manifest: {e}") - else: - logger.debug( - f"ProviderManager: No matching plugins for DRM systems {config_drm_systems}, skipping PSSH extraction") - else: - logger.debug(f"ProviderManager: No configs or no plugins registered, skipping PSSH extraction") - - # Process through DRM plugins with PSSH data - processed_configs = self.drm_plugin_manager.process_drm_configs(drm_configs, pssh_data_list, **kwargs) - logger.info( - f"ProviderManager: Processed DRM configs for channel '{channel_id}' - {len(processed_configs)} configs returned") - - return processed_configs - - # ============================================================================== - # CATCHUP METHODS - Add these after get_channel_drm_configs() method - # ============================================================================== - - def get_catchup_manifest(self, - provider_name: str, - channel_id: str, - start_time: int, - end_time: int, - epg_id: Optional[str] = None, - country: Optional[str] = None) -> Optional[str]: - """ - Get catchup manifest URL for a channel. - - Args: - provider_name: Name of the provider - channel_id: Channel identifier - start_time: Start time as Unix timestamp - end_time: End time as Unix timestamp - epg_id: Optional EPG event ID - country: Optional country code - - Returns: - Manifest URL for catchup content, or None if not available - - Raises: - ValueError: If provider not found - """ - provider = self.get_provider(provider_name) - - if not provider: - raise ValueError(f"Provider '{provider_name}' not found") - - # Check if provider supports catchup - if not provider.supports_catchup: - logger.warning(f"Provider '{provider_name}' does not support catchup") - return None - - # Validate catchup request - is_valid, error_msg = provider.validate_catchup_request(start_time, end_time) - if not is_valid: - logger.error(f"Invalid catchup request for {provider_name}/{channel_id}: {error_msg}") - return None - - try: - # Call provider's catchup manifest method - manifest_url = provider.get_catchup_manifest( - channel_id=channel_id, - start_time=start_time, - end_time=end_time, - epg_id=epg_id, - country=country - ) - - if manifest_url: - logger.info(f"Got catchup manifest for {provider_name}/{channel_id}: {manifest_url[:100]}...") - else: - logger.warning(f"No catchup manifest returned for {provider_name}/{channel_id}") - - return manifest_url - - except NotImplementedError: - logger.error(f"Provider '{provider_name}' has not implemented get_catchup_manifest()") - return None - except Exception as e: - logger.error(f"Error getting catchup manifest for {provider_name}/{channel_id}: {e}", - exc_info=True) - return None - - def get_catchup_drm_configs(self, - provider_name: str, - channel_id: str, - start_time: int, - end_time: int, - epg_id: Optional[str] = None, - country: Optional[str] = None) -> List: - """ - Get DRM configurations for catchup content. - - Args: - provider_name: Name of the provider - channel_id: Channel identifier - start_time: Start time as Unix timestamp - end_time: End time as Unix timestamp - epg_id: Optional EPG event ID - country: Optional country code - - Returns: - List of DRM configurations - - Raises: - ValueError: If provider not found - """ - provider = self.get_provider(provider_name) - - if not provider: - raise ValueError(f"Provider '{provider_name}' not found") - - # Check if provider supports catchup - if not provider.supports_catchup: - logger.debug(f"Provider '{provider_name}' does not support catchup, using live DRM") - return self.get_channel_drm_configs(provider_name, channel_id, country=country) - - try: - # Call provider's catchup DRM method - drm_configs = provider.get_catchup_drm( - channel_id=channel_id, - start_time=start_time, - end_time=end_time, - epg_id=epg_id, - country=country - ) - - if drm_configs: - logger.debug(f"Got {len(drm_configs)} DRM config(s) for catchup {provider_name}/{channel_id}") - else: - logger.debug(f"No catchup DRM configs for {provider_name}/{channel_id}") - - return drm_configs - - except NotImplementedError: - logger.debug(f"Provider '{provider_name}' has not implemented get_catchup_drm(), " - f"using live DRM") - return self.get_channel_drm_configs(provider_name, channel_id, country=country) - except Exception as e: - logger.error(f"Error getting catchup DRM for {provider_name}/{channel_id}: {e}", - exc_info=True) - # Fallback to live DRM on error - return self.get_channel_drm_configs(provider_name, channel_id, country=country) - - def get_catchup_window(self, - provider_name: str, - channel_id: Optional[str] = None) -> int: - """ - Get catchup window in HOURS for a provider or specific channel. - - Args: - provider_name: Name of the provider - channel_id: Optional channel identifier for channel-specific window - - Returns: - Catchup window in hours (0 = no catchup support) - """ - provider = self.get_provider(provider_name) - - if not provider: - raise ValueError(f"Provider '{provider_name}' not found") - - # Get channel-specific window if channel_id provided - if channel_id: - try: - return provider.get_catchup_window_for_channel(channel_id) - except Exception as e: - logger.warning(f"Error getting channel-specific catchup window: {e}") - # Fallback to provider-wide window - - # Return provider-wide catchup window - return provider.catchup_window - - def supports_catchup(self, provider_name: str) -> bool: - """ - Check if a provider supports catchup. - - Args: - provider_name: Name of the provider - - Returns: - True if provider supports catchup - - Raises: - ValueError: If provider not found - """ - provider = self.get_provider(provider_name) - - if not provider: - raise ValueError(f"Provider '{provider_name}' not found") - - return provider.supports_catchup - - def get_all_catchup_capabilities(self) -> Dict[str, Dict[str, any]]: - """ - Get catchup capabilities for all providers. - - Returns: - Dictionary mapping provider names to their catchup capabilities: - { - 'provider_name': { - 'supports_catchup': bool, - 'catchup_window': int, # IN HOURS - 'catchup_enabled': bool - } - } - """ - capabilities = {} - - for provider_name in self.list_providers(): - try: - provider = self.get_provider(provider_name) - capabilities[provider_name] = { - 'supports_catchup': provider.supports_catchup, - 'catchup_window': provider.catchup_window, - 'catchup_enabled': provider.supports_catchup and provider.catchup_window > 0 - } - except Exception as e: - logger.warning(f"Error getting catchup capabilities for {provider_name}: {e}") - capabilities[provider_name] = { - 'supports_catchup': False, - 'catchup_window': 0, - 'catchup_enabled': False - } - - return capabilities - - # ============================================================================ - # SUBSCRIPTION METHODS (NEW) - # ============================================================================ - - def get_subscription_status(self, provider_name: str, **kwargs) -> Optional[UserSubscription]: - """ - Get subscription status for a specific provider. - - Args: - provider_name: Name of the provider - **kwargs: Additional arguments passed to provider - - Returns: - UserSubscription object or None if not supported/available - - Raises: - ValueError: If provider not found - """ - provider = self.get_provider(provider_name) - if not provider: - raise ValueError(f"Provider '{provider_name}' not found") - - try: - subscription = provider.get_subscription_status(**kwargs) - if subscription: - logger.debug(f"Got subscription status for {provider_name}: " - f"{subscription.package_count} packages, " - f"{subscription.accessible_channel_count} channels") - else: - logger.debug(f"No subscription info available for {provider_name}") - - return subscription - except Exception as e: - logger.warning(f"Error getting subscription status for {provider_name}: {e}") - return None - - def get_subscribed_channels(self, provider_name: str, **kwargs) -> List[StreamingChannel]: - """ - Get subscribed channels for a specific provider. - - Args: - provider_name: Name of the provider - **kwargs: Additional arguments passed to provider - - Returns: - List of StreamingChannel objects the user can access - - Raises: - ValueError: If provider not found - """ - provider = self.get_provider(provider_name) - if not provider: - raise ValueError(f"Provider '{provider_name}' not found") - - try: - channels = provider.get_subscribed_channels(**kwargs) - logger.info(f"Got {len(channels)} subscribed channels from {provider_name}") - return channels - except Exception as e: - logger.error(f"Error getting subscribed channels for {provider_name}: {e}") - # Fallback to all channels on error - return provider.get_channels(**kwargs) - - def get_available_packages(self, provider_name: str, **kwargs) -> List[SubscriptionPackage]: - """ - Get available subscription packages for a provider. - - Args: - provider_name: Name of the provider - **kwargs: Additional arguments passed to provider - - Returns: - List of available SubscriptionPackage objects - Empty list if not supported/available - - Raises: - ValueError: If provider not found - """ - provider = self.get_provider(provider_name) - if not provider: - raise ValueError(f"Provider '{provider_name}' not found") - - try: - packages = provider.get_available_packages(**kwargs) - logger.debug(f"Got {len(packages)} available packages from {provider_name}") - return packages - except Exception as e: - logger.warning(f"Error getting available packages for {provider_name}: {e}") - return [] - - def is_channel_accessible(self, provider_name: str, channel_id: str, **kwargs) -> bool: - """ - Check if a channel is accessible with current subscription. - - Args: - provider_name: Name of the provider - channel_id: ID of the channel to check - **kwargs: Additional arguments passed to provider - - Returns: - True if accessible, False otherwise - True if subscription checking not supported - - Raises: - ValueError: If provider not found - """ - provider = self.get_provider(provider_name) - if not provider: - raise ValueError(f"Provider '{provider_name}' not found") - - try: - accessible = provider.is_channel_accessible(channel_id, **kwargs) - logger.debug(f"Channel {channel_id} accessible for {provider_name}: {accessible}") - return accessible - except Exception as e: - logger.warning(f"Error checking channel accessibility for {provider_name}/{channel_id}: {e}") - return True # Assume accessible on error (backward compatibility) + # ========================================================================== + # LEGACY COMPATIBILITY METHODS + # ========================================================================== def _extract_pssh_from_manifest(self, manifest_url: str) -> List: """ Extract PSSH data from a manifest URL. - - Args: - manifest_url: URL of the manifest to parse - - Returns: - List of PSSHData objects extracted from the manifest + Kept for backward compatibility but delegated to DRMOperations. """ - import requests - from .utils.manifest_parser import ManifestParser - - try: - # Fetch the manifest content - response = requests.get(manifest_url, timeout=10) - response.raise_for_status() - - # Parse and extract PSSH data - return ManifestParser.extract_pssh_from_manifest(response.text, manifest_url) - - except Exception as e: - logger.warning(f"ProviderManager: Failed to fetch or parse manifest from {manifest_url}: {e}") - return [] - - def get_all_channels(self, fetch_manifests: bool = True, **kwargs) -> Dict[str, List[StreamingChannel]]: - """ - Get channels from all registered providers. - - Args: - fetch_manifests: Whether to enrich channels with manifest data - **kwargs: Additional arguments for channel fetching - - Returns: - Dictionary mapping provider names to their channels - """ - logger.info(f"ProviderManager: Fetching channels from all {len(self.providers)} providers (fetch_manifests={fetch_manifests})") - - result = {} - total_channels = 0 - - for name in self.providers: - try: - channels = self.get_channels(name, fetch_manifests, **kwargs) - result[name] = channels - total_channels += len(channels) - except Exception as e: - logger.error(f"ProviderManager: Failed to get channels from provider '{name}': {e}") - result[name] = [] - - logger.info(f"ProviderManager: Retrieved {total_channels} total channels from all providers") - return result - - def list_providers(self) -> List[str]: - """List names of all registered providers""" - return list(self.providers.keys()) - - def clear_providers(self) -> None: - """Clear all registered providers""" - provider_count = len(self.providers) - self.providers.clear() - logger.info(f"ProviderManager: Cleared {provider_count} registered providers") - - def list_drm_plugins(self) -> Dict: - """List all registered DRM plugins""" - return self.drm_plugin_manager.list_plugins() - - def clear_drm_plugins(self) -> None: - """Clear all registered DRM plugins""" - logger.info("ProviderManager: Clearing all DRM plugins") - self.drm_plugin_manager.clear_plugins() - - @staticmethod - def get_provider_class(provider_name: str): - """ - Get provider class from AVAILABLE_PROVIDERS registry. - - Args: - provider_name: Name of the provider (without country suffix) - - Returns: - Provider class or None if not found - """ - from streaming_providers import AVAILABLE_PROVIDERS - - # Remove country suffix if present (e.g., "joyn_de" -> "joyn") - base_name = provider_name - if '_' in provider_name: - # Check if suffix looks like a country code (2-3 letters) - name_parts = provider_name.rsplit('_', 1) - if len(name_parts[1]) in (2, 3) and name_parts[1].isalpha(): - base_name = name_parts[0] - - return AVAILABLE_PROVIDERS.get(base_name) - - - def reinitialize_provider(self, provider_name: str) -> bool: - """ - Reinitialize a provider instance (e.g., after credentials change). - - Args: - provider_name: Name of the provider to reinitialize - - Returns: - True if successful, False otherwise - - Note: - This creates a new provider instance with the same country, - preserving the original configuration but with fresh HTTP session. - """ - logger.info(f"ProviderManager: Reinitializing provider '{provider_name}'") - - # Get existing provider to extract configuration - existing_provider = self.get_provider(provider_name) - if not existing_provider: - logger.error(f"ProviderManager: Cannot reinitialize - provider '{provider_name}' not found") - return False - - # Extract country from existing provider - country = getattr(existing_provider, 'country', 'DE') - - # Get provider class - provider_class = self.get_provider_class(provider_name) - if not provider_class: - logger.error(f"ProviderManager: Provider class not found for '{provider_name}'") - return False - - try: - # Create new provider instance with same country - new_provider = provider_class(country=country) - - # Replace in registry - self.providers[provider_name] = new_provider - - logger.info(f"ProviderManager: Successfully reinitialized provider '{provider_name}'") - return True - - except Exception as e: - logger.error(f"ProviderManager: Failed to reinitialize provider '{provider_name}': {e}") - return False - - - def reinitialize_providers(self, provider_names: List[str]) -> Dict[str, bool]: - """ - Reinitialize multiple providers. - - Args: - provider_names: List of provider names to reinitialize - - Returns: - Dictionary mapping provider names to success status - """ - logger.info(f"ProviderManager: Reinitializing {len(provider_names)} providers") - - results = {} - for provider_name in provider_names: - success = self.reinitialize_provider(provider_name) - results[provider_name] = success - - successful = sum(1 for result in results.values() if result) - logger.info(f"ProviderManager: Reinitialization completed - {successful}/{len(provider_names)} successful") - - return results - - - def reinitialize_all_providers(self) -> Dict[str, bool]: - """ - Reinitialize all registered providers. - - Returns: - Dictionary mapping provider names to success status - """ - provider_names = list(self.providers.keys()) - return self.reinitialize_providers(provider_names) \ No newline at end of file + return self.drm_ops._extract_pssh_from_manifest(manifest_url) \ No newline at end of file diff --git a/lib/streaming_providers/base/provider.py b/lib/streaming_providers/base/provider.py index caf1ef3..124d0c9 100644 --- a/lib/streaming_providers/base/provider.py +++ b/lib/streaming_providers/base/provider.py @@ -1,75 +1,15 @@ -# streaming_providers/base/provider.py - Enhanced with Header Abstractions +# streaming_providers/base/provider.py - Enhanced with Static Metadata """ -Streaming Provider Base Class +Streaming Provider Base Class with Static Metadata Support -Authentication System: ---------------------- -Providers implement authentication through three core components: - -1. CAPABILITIES (Declarative): - - supported_auth_types: List[str] - What auth methods the provider CAN use - - preferred_auth_type: str - Which method SHOULD be used by default - -2. STATE (Dynamic): - - get_current_auth_type(context) -> str - Which method IS currently active - - get_auth_status(context) -> AuthStatus - Complete authentication status - -3. LOGIC (Optional Overrides): - - _calculate_auth_state(context) -> Optional[AuthState] - Custom auth state logic - - _calculate_readiness(context) -> Optional[Tuple[bool, str]] - Custom readiness - - get_auth_details(context) -> Dict[str, Any] - Provider-specific details - -Auth Type Definitions: - - 'user_credentials': Username/password (Joyn, RTL+) - - 'client_credentials': Client ID/secret (Joyn fallback, some APIs) - - 'network_based': Fixed-line/network auth (Magenta2, cable providers) - - 'anonymous': No auth needed (ZDF, ARD) - - 'device_registration': Device-based auth (Smart TV apps) - - 'embedded_client': Built-in credentials - -Implementation Examples: ----------------------- -# Simple provider (ZDF) -class ZDFProvider(StreamingProvider): - @property - def supported_auth_types(self) -> List[str]: - return ['anonymous'] # Just one type - -# Multi-auth provider (Joyn) -class JoynProvider(StreamingProvider): - @property - def supported_auth_types(self) -> List[str]: - return ['client_credentials', 'user_credentials'] - - @property - def preferred_auth_type(self) -> str: - return 'user_credentials' # Prefer full access - - def get_current_auth_type(self, context: AuthContext) -> str: - # Custom logic to detect current auth mode - token = context.get_token(self.provider_name, None, self.country) - return 'user_credentials' if token and token.get('auth_level') == 'user_authenticated' else 'client_credentials' - -# Network-based provider (Magenta2) -class Magenta2Provider(StreamingProvider): - @property - def supported_auth_types(self) -> List[str]: - return ['network_based'] - - @property - def primary_token_scope(self) -> Optional[str]: - return 'yo_digital' # Uses scoped tokens - - def _calculate_readiness(self, context: AuthContext): - # Check multiple token scopes - yo_token = context.get_token(self.provider_name, 'yo_digital', self.country) - if yo_token and not context.session._is_token_expired(yo_token): - return True, "Has valid streaming token" - return False, "No valid tokens found" +New Features: +- Class attributes for static metadata (PROVIDER_LABEL, etc.) +- Static methods to get metadata without instantiation +- Backward compatible with existing @property methods """ from abc import ABC, abstractmethod -from typing import Dict, List, Optional, Callable, Any +from typing import Dict, List, Optional, Callable, Any, ClassVar from enum import Enum import json from datetime import datetime @@ -97,7 +37,22 @@ class StreamingProvider(ABC): Abstract base class for streaming providers with centralized HTTP and auth management """ - SUPPORTED_COUNTRIES: List[str] = [] + # ============================================================================ + # STATIC METADATA (NEW) + # ============================================================================ + + # Class attributes for static metadata (accessible without instantiation) + PROVIDER_LABEL: ClassVar[str] = "" + """Base provider label without country suffix (e.g., 'Joyn', 'RTL+')""" + + SUPPORTED_AUTH_TYPES: ClassVar[List[str]] = [] + """Authentication types supported by this provider""" + + PROVIDER_LOGO: ClassVar[str] = "" + """URL to provider logo""" + + SUPPORTED_COUNTRIES: ClassVar[List[str]] = [] + """List of ISO country codes this provider supports (empty = single country)""" def __init__(self, country: str = 'DE'): self.country = country @@ -106,6 +61,215 @@ class StreamingProvider(ABC): self._default_user_agent = 'StreamingProvider/1.0' self.authenticator = None # Optional: set by concrete providers + # ============================================================================ + # STATIC METHODS FOR METADATA EXTRACTION (NEW) + # ============================================================================ + + @classmethod + def get_static_label(cls, country: str = None) -> str: + """ + Get provider label without instantiation. + + Args: + country: Optional country code for country-specific labels + + Returns: + Provider label string + """ + base_label = cls.PROVIDER_LABEL or cls.__name__.replace('Provider', '') + + if country: + # Format country code + country_upper = country.upper() + + # Special handling for common cases + if country_upper == 'DE': + return f"{base_label} Germany" + elif country_upper == 'AT': + return f"{base_label} Austria" + elif country_upper == 'CH': + return f"{base_label} Switzerland" + else: + return f"{base_label} ({country_upper})" + + return base_label + + @classmethod + def get_static_auth_types(cls) -> List[str]: + """ + Get supported authentication types without instantiation. + + Returns: + List of supported auth type strings + """ + return cls.SUPPORTED_AUTH_TYPES.copy() + + @classmethod + def get_static_logo(cls) -> str: + """ + Get provider logo URL without instantiation. + + Returns: + Logo URL string + """ + return cls.PROVIDER_LOGO + + @classmethod + def get_static_supported_countries(cls) -> List[str]: + """ + Get supported countries without instantiation. + + Returns: + List of ISO country codes + """ + return cls.SUPPORTED_COUNTRIES.copy() + + @classmethod + def get_all_possible_instances(cls) -> List[Dict[str, Any]]: + """ + Get metadata for all possible instances of this provider. + + Returns: + List of instance metadata dictionaries + """ + instances = [] + + if cls.supports_multiple_countries(): + for country in cls.SUPPORTED_COUNTRIES: + instances.append({ + 'plugin': cls.__name__.lower().replace('provider', ''), + 'country': country.upper(), + 'label': cls.get_static_label(country), + 'requires_country_suffix': True + }) + else: + # Single-country provider + instances.append({ + 'plugin': cls.__name__.lower().replace('provider', ''), + 'country': 'DE', # Default country for single-country providers + 'label': cls.get_static_label(), + 'requires_country_suffix': False + }) + + return instances + + # ============================================================================ + # INSTANCE PROPERTIES (Backward Compatible) + # ============================================================================ + + @property + @abstractmethod + def provider_name(self) -> str: + """Return the provider name (e.g., 'joyn', 'zdf', 'ard')""" + pass + + @property + def provider_label(self) -> str: + """Return the provider label (e.g., 'JOYN', 'ZDF', 'RTL+')""" + # Use static method with instance's country + return self.get_static_label(self.country) + + @property + def provider_logo(self) -> str: + """Return the provider logo URL""" + return self.get_static_logo() + + @property + def supported_auth_types(self) -> List[str]: + """List of authentication types this provider supports.""" + return self.get_static_auth_types() + + @property + @abstractmethod + def uses_dynamic_manifests(self) -> bool: + """Return True if provider uses truly dynamic manifests""" + pass + + @property + @abstractmethod + def implements_epg(self) -> bool: + """ + Indicates whether this provider has its own EPG implementation. + If False, the generic EPG manager will be used. + + Override in subclass and return True if provider has native EPG. + + Returns: + True if provider implements its own EPG, False to use generic EPG + """ + pass + + @abstractmethod + def get_channels(self, **kwargs) -> List[StreamingChannel]: + """Fetch channels from the provider""" + pass + + @abstractmethod + def get_drm(self, channel_id: str, **kwargs) -> List[DRMConfig]: + """Get all DRM configurations for a channel by ID""" + return [] + + @property + def catchup_window(self) -> int: + """ + Return the catchup window in HOURS for this provider. + + Returns: + int: Number of hours of catchup available (0 = no catchup support) + """ + return 0 + + @property + def supports_catchup(self) -> bool: + """ + Check if provider supports catchup/timeshift functionality. + + Returns: + bool: True if catchup is supported + """ + return self.catchup_window > 0 + + def get_epg(self, channel_id: str, + start_time: Optional[datetime] = None, + end_time: Optional[datetime] = None, + **kwargs) -> List[Dict]: + """Get EPG data for a channel""" + return [] + + @staticmethod + def get_epg_xmltv(**kwargs) -> Optional[str]: + """Get complete EPG data for this provider in XMLTV format""" + return None + + @abstractmethod + def enrich_channel_data(self, channel: StreamingChannel, **kwargs) -> Optional[StreamingChannel]: + """Enrich channel with additional data including manifest URL""" + return None + + @abstractmethod + def get_manifest(self, channel_id: str, **kwargs) -> Optional[str]: + """Get manifest URL for a specific channel by ID""" + return None + + def get_dynamic_manifest_params(self, channel: StreamingChannel, **kwargs) -> Optional[str]: + """Optional: Get dynamic manifest parameters for a channel""" + return None + + def to_output_format(self, channels: List[StreamingChannel] = None) -> Dict: + """Convert channels to output format""" + if channels is None: + channels = self.channels + + return { + 'Provider': self.provider_name, + 'Country': self.country, + 'Channels': [channel.to_dict() for channel in channels] + } + + def to_json(self, channels: List[StreamingChannel] = None, indent: int = 2) -> str: + """Convert to JSON string""" + return json.dumps(self.to_output_format(channels), indent=indent, ensure_ascii=False) + # ============================================================================ # HTTP MANAGER SETUP (Already Implemented) # ============================================================================ @@ -212,7 +376,7 @@ class StreamingProvider(ABC): if 'user_agent' in manager_kwargs: ua_preview = manager_kwargs['user_agent'][:50] + '...' if len(manager_kwargs['user_agent']) > 50 else \ - manager_kwargs['user_agent'] + manager_kwargs['user_agent'] info_parts.append(f"user-agent: {ua_preview}") if 'timeout' in manager_kwargs: @@ -241,7 +405,7 @@ class StreamingProvider(ABC): return http_manager # ============================================================================ - # AUTHENTICATION HEADER ABSTRACTIONS (NEW) + # AUTHENTICATION HEADER ABSTRACTIONS # ============================================================================ def _get_base_headers(self, @@ -446,120 +610,6 @@ class StreamingProvider(ABC): logger.error(f"{self.provider_name}: Error getting {token_type} token: {e}") return None - # ============================================================================ - # ABSTRACT METHODS (Required by all providers) - # ============================================================================ - - @property - @abstractmethod - def provider_name(self) -> str: - """Return the provider name (e.g., 'joyn', 'zdf', 'ard')""" - pass - - @property - @abstractmethod - def provider_label(self) -> str: - """Return the provider label (e.g., 'JOYN', 'ZDF', 'RTL+')""" - pass - - @property - @abstractmethod - def provider_logo(self) -> str: - """Return the provider logo URL""" - pass - - @property - @abstractmethod - def uses_dynamic_manifests(self) -> bool: - """Return True if provider uses truly dynamic manifests""" - pass - - @property - @abstractmethod - def implements_epg(self) -> bool: - """ - Indicates whether this provider has its own EPG implementation. - If False, the generic EPG manager will be used. - - Override in subclass and return True if provider has native EPG. - - Returns: - True if provider implements its own EPG, False to use generic EPG - """ - pass - - @abstractmethod - def get_channels(self, **kwargs) -> List[StreamingChannel]: - """Fetch channels from the provider""" - pass - - @abstractmethod - def get_drm(self, channel_id: str, **kwargs) -> List[DRMConfig]: - """Get all DRM configurations for a channel by ID""" - return [] - - @property - def catchup_window(self) -> int: - """ - Return the catchup window in HOURS for this provider. - - Returns: - int: Number of hours of catchup available (0 = no catchup support) - """ - return 0 - - @property - def supports_catchup(self) -> bool: - """ - Check if provider supports catchup/timeshift functionality. - - Returns: - bool: True if catchup is supported - """ - return self.catchup_window > 0 - - def get_epg(self, channel_id: str, - start_time: Optional[datetime] = None, - end_time: Optional[datetime] = None, - **kwargs) -> List[Dict]: - """Get EPG data for a channel""" - return [] - - @staticmethod - def get_epg_xmltv(**kwargs) -> Optional[str]: - """Get complete EPG data for this provider in XMLTV format""" - return None - - @abstractmethod - def enrich_channel_data(self, channel: StreamingChannel, **kwargs) -> Optional[StreamingChannel]: - """Enrich channel with additional data including manifest URL""" - return None - - @abstractmethod - def get_manifest(self, channel_id: str, **kwargs) -> Optional[str]: - """Get manifest URL for a specific channel by ID""" - return None - - def get_dynamic_manifest_params(self, channel: StreamingChannel, **kwargs) -> Optional[str]: - """Optional: Get dynamic manifest parameters for a channel""" - return None - - def to_output_format(self, channels: List[StreamingChannel] = None) -> Dict: - """Convert channels to output format""" - if channels is None: - channels = self.channels - - return { - 'Provider': self.provider_name, - 'Country': self.country, - 'Channels': [channel.to_dict() for channel in channels] - } - - def to_json(self, channels: List[StreamingChannel] = None, indent: int = 2) -> str: - """Convert to JSON string""" - return json.dumps(self.to_output_format(channels), indent=indent, ensure_ascii=False) - - # ============================================================================ # CATCHUP ABSTRACT METHODS # ============================================================================ @@ -762,7 +812,7 @@ class StreamingProvider(ABC): return f"{base_url}{separator}start={start_time}&end={end_time}" # ============================================================================ - # SUBSCRIPTION METHODS (NEW) + # SUBSCRIPTION METHODS # ============================================================================ def get_subscription_status(self, **kwargs) -> Optional[UserSubscription]: @@ -1130,4 +1180,4 @@ class StreamingProvider(ABC): Returns: Dictionary with provider-specific information """ - return {} + return {} \ No newline at end of file diff --git a/lib/streaming_providers/base/provider_registry.py b/lib/streaming_providers/base/provider_registry.py new file mode 100644 index 0000000..c0f38c3 --- /dev/null +++ b/lib/streaming_providers/base/provider_registry.py @@ -0,0 +1,222 @@ +# streaming_providers/base/provider_registry.py +""" +Core provider registry handling discovery, metadata, and lifecycle management. +""" +from typing import Dict, List, Optional, Any +from .provider import StreamingProvider +from .utils.logger import logger + + +class ProviderMetadata: + """Metadata for a provider instance with lazy initialization.""" + + def __init__(self, plugin_class, country: str, enabled: bool = False): + self.plugin_class = plugin_class + self.country = country.lower() + self.enabled = enabled + self.instance: Optional[StreamingProvider] = None + self._extract_metadata() + + def _extract_metadata(self): + """Extract static metadata from provider class without instantiation""" + self.plugin_name = self.plugin_class.__name__.lower().replace('provider', '') + + if self.plugin_class.supports_multiple_countries(): + self.name = f"{self.plugin_name}_{self.country}" + else: + self.name = self.plugin_name + + self.label = self.plugin_class.get_static_label(self.country) + self.supported_auth_types = self.plugin_class.get_static_auth_types() + self.logo = self.plugin_class.get_static_logo() + self.supported_countries = self.plugin_class.get_static_supported_countries() + + self.requires_credentials = any( + auth_type in ['user_credentials', 'client_credentials'] + for auth_type in self.supported_auth_types + ) + self.is_multi_country = len(self.supported_countries) > 0 + + def create_instance(self) -> Optional[StreamingProvider]: + """Lazily create provider instance if enabled""" + if not self.enabled: + return None + + if self.instance is None: + try: + logger.info(f"Creating instance for provider: {self.name}") + self.instance = self.plugin_class(country=self.country) + logger.debug(f"Successfully created instance for {self.name}") + except Exception as e: + logger.error(f"Failed to create instance for {self.name}: {e}") + self.instance = None + + return self.instance + + def destroy_instance(self): + """Clean up provider instance""" + if self.instance: + logger.debug(f"Destroying instance for provider: {self.name}") + self.instance = None + + def set_enabled(self, enabled: bool): + """Update enabled status and manage instance accordingly""" + self.enabled = enabled + if enabled and self.instance is None: + self.create_instance() + elif not enabled and self.instance is not None: + self.destroy_instance() + + def to_dict(self) -> Dict[str, Any]: + """Convert metadata to dictionary for API response""" + return { + 'name': self.name, + 'label': self.label, + 'plugin': self.plugin_name, + 'country': self.country.upper(), + 'enabled': self.enabled, + 'instance_ready': self.instance is not None, + 'requires_credentials': self.requires_credentials, + 'supported_auth_types': self.supported_auth_types, + 'logo': self.logo, + 'is_multi_country': self.is_multi_country, + 'supported_countries': self.supported_countries + } + + +class ProviderRegistry: + """ + Core registry for provider discovery, metadata, and lifecycle management. + Separated concern: Provider registration and access. + """ + + def __init__(self): + self.providers: Dict[str, StreamingProvider] = {} # Active instances + self.provider_metadata: Dict[str, ProviderMetadata] = {} # All providers + logger.info("ProviderRegistry: Initialized") + + @staticmethod + def _is_provider_enabled(provider_name: str, country: Optional[str] = None) -> bool: + """Check if a provider is enabled via settings manager.""" + try: + from .settings.provider_enable_manager import ProviderEnableManager + enable_manager = ProviderEnableManager() + + instance_name = f"{provider_name}_{country}" if country else provider_name + return enable_manager.is_provider_enabled(instance_name) + except Exception as e: + logger.warning(f"Could not check enable status for '{provider_name}': {e}") + return True + + def discover_all_providers(self, default_country: str = 'DE') -> List[str]: + """Discover ALL provider instances and extract metadata.""" + from streaming_providers import AVAILABLE_PROVIDERS + + logger.info("ProviderRegistry: Discovering provider instances") + discovered = [] + + for plugin_name, plugin_class in AVAILABLE_PROVIDERS.items(): + if plugin_class.supports_multiple_countries(): + for country in plugin_class.get_static_supported_countries(): + instance_name = f"{plugin_name}_{country}" + enabled = self._is_provider_enabled(plugin_name, country) + + metadata = ProviderMetadata(plugin_class, country, enabled) + self.provider_metadata[instance_name] = metadata + discovered.append(instance_name) + + if enabled: + instance = metadata.create_instance() + if instance: + self.providers[instance_name] = instance + else: + instance_name = plugin_name + enabled = self._is_provider_enabled(plugin_name) + + metadata = ProviderMetadata(plugin_class, default_country, enabled) + self.provider_metadata[instance_name] = metadata + discovered.append(instance_name) + + if enabled: + instance = metadata.create_instance() + if instance: + self.providers[instance_name] = instance + + logger.info(f"ProviderRegistry: Discovered {len(discovered)} provider instances") + return discovered + + def get_provider(self, provider_name: str) -> Optional[StreamingProvider]: + """Get provider instance, creating it lazily if needed.""" + provider = self.providers.get(provider_name) + if provider: + return provider + + metadata = self.provider_metadata.get(provider_name) + if not metadata or not metadata.enabled: + return None + + provider = metadata.create_instance() + if provider: + self.providers[provider_name] = provider + return provider + + def set_provider_enabled(self, provider_name: str, enabled: bool) -> bool: + """Enable or disable a provider dynamically.""" + metadata = self.provider_metadata.get(provider_name) + if not metadata: + logger.error(f"Cannot enable/disable unknown provider '{provider_name}'") + return False + + metadata.set_enabled(enabled) + + if enabled and metadata.instance: + self.providers[provider_name] = metadata.instance + elif not enabled and provider_name in self.providers: + del self.providers[provider_name] + + try: + from .settings.provider_enable_manager import ProviderEnableManager + enable_manager = ProviderEnableManager() + success, message = enable_manager.set_provider_enabled(provider_name, enabled) + return success + except Exception as e: + logger.error(f"Error updating enable status: {e}") + return False + + def reinitialize_provider(self, provider_name: str) -> bool: + """Reinitialize a provider instance.""" + metadata = self.provider_metadata.get(provider_name) + if not metadata or not metadata.enabled: + return False + + try: + metadata.destroy_instance() + new_instance = metadata.create_instance() + if new_instance: + self.providers[provider_name] = new_instance + return True + return False + except Exception as e: + logger.error(f"Failed to reinitialize '{provider_name}': {e}") + return False + + def get_all_providers_metadata(self) -> List[Dict[str, Any]]: + """Get metadata for ALL provider instances.""" + return [m.to_dict() for m in self.provider_metadata.values()] + + def list_providers(self) -> List[str]: + """List enabled provider names.""" + return list(self.providers.keys()) + + def list_all_providers(self) -> List[str]: + """List ALL provider names (enabled + disabled).""" + return list(self.provider_metadata.keys()) + + def get_enabled_providers(self) -> List[str]: + """Get list of enabled provider names.""" + return [name for name, m in self.provider_metadata.items() if m.enabled] + + def clear_providers(self): + """Clear all providers.""" + self.providers.clear() + logger.info("ProviderRegistry: Cleared all providers") diff --git a/lib/streaming_providers/base/subscription_operations.py b/lib/streaming_providers/base/subscription_operations.py new file mode 100644 index 0000000..47cdb25 --- /dev/null +++ b/lib/streaming_providers/base/subscription_operations.py @@ -0,0 +1,79 @@ +# ============================================================================ +# streaming_providers/base/subscription_operations.py +""" +Subscription and package management operations. +""" +from typing import Optional, List +from .models import UserSubscription, SubscriptionPackage, StreamingChannel +from .utils.logger import logger + + +class SubscriptionOperations: + """Handles all subscription-related operations.""" + + def __init__(self, registry): + self.registry = registry + logger.debug("SubscriptionOperations: Initialized") + + def get_subscription_status(self, provider_name: str, + **kwargs) -> Optional[UserSubscription]: + """Get subscription status for a provider.""" + provider = self.registry.get_provider(provider_name) + if not provider: + raise ValueError(f"Provider '{provider_name}' not found or disabled") + + try: + subscription = provider.get_subscription_status(**kwargs) + if subscription: + logger.debug(f"Got subscription for '{provider_name}': " + f"{subscription.package_count} packages") + return subscription + except Exception as e: + logger.warning(f"Error getting subscription for '{provider_name}': {e}") + return None + + def get_subscribed_channels(self, provider_name: str, + **kwargs) -> List[StreamingChannel]: + """Get subscribed channels.""" + provider = self.registry.get_provider(provider_name) + if not provider: + raise ValueError(f"Provider '{provider_name}' not found or disabled") + + try: + channels = provider.get_subscribed_channels(**kwargs) + logger.info(f"Got {len(channels)} subscribed channels from '{provider_name}'") + return channels + except Exception as e: + logger.error(f"Error getting subscribed channels: {e}") + return provider.get_channels(**kwargs) + + def get_available_packages(self, provider_name: str, + **kwargs) -> List[SubscriptionPackage]: + """Get available subscription packages.""" + provider = self.registry.get_provider(provider_name) + if not provider: + raise ValueError(f"Provider '{provider_name}' not found or disabled") + + try: + packages = provider.get_available_packages(**kwargs) + logger.debug(f"Got {len(packages)} packages from '{provider_name}'") + return packages + except Exception as e: + logger.warning(f"Error getting packages for '{provider_name}': {e}") + return [] + + def is_channel_accessible(self, provider_name: str, channel_id: str, + **kwargs) -> bool: + """Check if channel is accessible with current subscription.""" + provider = self.registry.get_provider(provider_name) + if not provider: + raise ValueError(f"Provider '{provider_name}' not found or disabled") + + try: + accessible = provider.is_channel_accessible(channel_id, **kwargs) + logger.debug(f"Channel '{channel_id}' accessible: {accessible}") + return accessible + except Exception as e: + logger.warning(f"Error checking accessibility: {e}") + return True # Assume accessible on error + diff --git a/lib/streaming_providers/providers/hrti/provider.py b/lib/streaming_providers/providers/hrti/provider.py index fcbdef2..f22ab5f 100644 --- a/lib/streaming_providers/providers/hrti/provider.py +++ b/lib/streaming_providers/providers/hrti/provider.py @@ -1,18 +1,31 @@ # lib/streaming_providers/providers/hrti/provider.py import json import requests -from typing import Dict, List, Optional +from typing import Dict, List, Optional, ClassVar -from ...base.provider import StreamingProvider, AuthType # ← ADD AuthType import +from ...base.provider import StreamingProvider, AuthType from ...base.models.streaming_channel import StreamingChannel from ...base.models import DRMConfig, LicenseConfig, DRMSystem, LicenseUnwrapperParams from .auth import HRTiAuthenticator from .constants import HRTiConfig from ...base.utils import logger from ...base.models.proxy_models import ProxyConfig +from .constants import HRTiDefaults class HRTiProvider(StreamingProvider): + """ + HRTi (Croatian Radio Television Internet) provider implementation. + """ + + # ============================================================================ + # STATIC METADATA (NEW) + # ============================================================================ + PROVIDER_LABEL: ClassVar[str] = "HRTi" + SUPPORTED_AUTH_TYPES: ClassVar[List[str]] = ['user_credentials'] + PROVIDER_LOGO: ClassVar[str] = HRTiDefaults.PROVIDER_LOGO + SUPPORTED_COUNTRIES: ClassVar[List[str]] = ['HR'] # Croatia only + def __init__(self, country: str = 'HR', config: Optional[Dict] = None, proxy_config: Optional[ProxyConfig] = None): super().__init__(country) @@ -20,7 +33,7 @@ class HRTiProvider(StreamingProvider): self.hrti_config = HRTiConfig(config) self.channels_cache = None - # ✅ Use abstraction for HTTP manager setup + # Setup HTTP manager using abstraction self.http_manager = self._setup_http_manager( provider_name='hrti', proxy_config=proxy_config, @@ -50,11 +63,13 @@ class HRTiProvider(StreamingProvider): @property def provider_label(self) -> str: - return 'HRTi' + # Override to use static metadata with country context + return self.get_static_label(self.country) @property def provider_logo(self) -> str: - return self.hrti_config.logo + # Use instance config for backward compatibility + return self.hrti_config.logo or self.PROVIDER_LOGO @property def uses_dynamic_manifests(self) -> bool: @@ -67,7 +82,7 @@ class HRTiProvider(StreamingProvider): @property def supported_auth_types(self) -> List[str]: - return ['user_credentials'] + return self.SUPPORTED_AUTH_TYPES # Use class attribute def _get_hrti_authenticated_headers(self) -> Dict[str, str]: """ @@ -76,7 +91,7 @@ class HRTiProvider(StreamingProvider): This is a provider-specific wrapper that uses the base class abstraction. """ return self._build_provider_headers( - auth_type=AuthType.CLIENT, # ← Now properly imported + auth_type=AuthType.CLIENT, token_key='authorization', # HRTi uses lowercase provider_headers={ 'deviceid': self.authenticator.get_device_id(), @@ -93,7 +108,7 @@ class HRTiProvider(StreamingProvider): Fetch channels from HRTi API """ try: - # ✅ Use provider-specific method + # Use provider-specific method headers = self._get_hrti_authenticated_headers() # Log the request for debugging @@ -452,7 +467,7 @@ class HRTiProvider(StreamingProvider): logger.error(f"Traceback: {traceback.format_exc()}") return [] - def get_epg_data(self, channel_id: str, **kwargs) -> Optional[Dict]: + def get_epg(self, channel_id: str, **kwargs) -> List[Dict]: """ Get EPG data for a channel """ @@ -479,12 +494,22 @@ class HRTiProvider(StreamingProvider): epg_data = response.json() if 'Result' in epg_data: - return epg_data['Result'] - return None + # Convert to standard EPG format + epg_entries = [] + for entry in epg_data['Result']: + epg_entries.append({ + 'title': entry.get('Title', ''), + 'description': entry.get('Description', ''), + 'start': entry.get('StartTime', ''), + 'end': entry.get('EndTime', ''), + 'genre': entry.get('Genre', '') + }) + return epg_entries + return [] except Exception as e: logger.error(f"Error getting EPG data for channel {channel_id}: {e}") - return None + return [] def get_license_url(self, channel: StreamingChannel, **kwargs) -> Optional[str]: """ @@ -493,4 +518,41 @@ class HRTiProvider(StreamingProvider): drm_configs = self.get_drm(channel.channel_id, **kwargs) if drm_configs: return drm_configs[0].license.server_url + return None + + # ============================================================================ + # CATCHUP METHODS (Implementing abstract methods) + # ============================================================================ + + @property + def catchup_window(self) -> int: + """ + Return the catchup window in HOURS for HRTi. + + Note: HRTi doesn't officially support catchup for live channels, + but may have some VOD content available. + """ + return 0 # No catchup support for live streams + + def get_epg_xmltv(self, **kwargs) -> Optional[str]: + """ + Get complete EPG data for HRTi in XMLTV format. + + Returns: + XMLTV formatted string, or None if not available + """ + # HRTi doesn't provide XMLTV format natively + return None + + def get_dynamic_manifest_params(self, channel: StreamingChannel, **kwargs) -> Optional[str]: + """ + Get dynamic manifest parameters for HRTi channels. + + Args: + channel: StreamingChannel to get parameters for + + Returns: + Parameters string or None + """ + # HRTi requires session authorization which is handled in enrich_channel_data return None \ No newline at end of file diff --git a/lib/streaming_providers/providers/joyn/provider.py b/lib/streaming_providers/providers/joyn/provider.py index 8c7b1fb..04a223b 100644 --- a/lib/streaming_providers/providers/joyn/provider.py +++ b/lib/streaming_providers/providers/joyn/provider.py @@ -1,6 +1,6 @@ # streaming_providers/providers/joyn/provider.py # -*- coding: utf-8 -*- -from typing import Dict, Optional, List +from typing import Dict, Optional, List, ClassVar import json import time import hashlib @@ -73,7 +73,14 @@ class JoynProvider(StreamingProvider): """ Joyn streaming provider implementation with centralized HTTP management """ - SUPPORTED_COUNTRIES = SUPPORTED_COUNTRIES + + # ============================================================================ + # STATIC METADATA (NEW) + # ============================================================================ + PROVIDER_LABEL: ClassVar[str] = "Joyn" + SUPPORTED_AUTH_TYPES: ClassVar[List[str]] = ['client_credentials', 'user_credentials'] + PROVIDER_LOGO: ClassVar[str] = JOYN_LOGO + SUPPORTED_COUNTRIES: ClassVar[List[str]] = SUPPORTED_COUNTRIES def __init__(self, country: str = 'de', platform: str = DEFAULT_PLATFORM, @@ -105,27 +112,7 @@ class JoynProvider(StreamingProvider): self.distribution_tenant = COUNTRY_TENANT_MAPPING[country] self.platform = platform - # ✅ BEFORE: Manual proxy resolution and HTTP manager setup (15 lines) - # self.proxy_config = ( - # proxy_config or - # (ProxyConfig.from_url(proxy_url) if proxy_url else None) or - # self._load_proxy_from_manager(config_dir) - # ) - # - # if self.proxy_config: - # logger.info("Using proxy configuration for Joyn") - # else: - # logger.debug("No proxy configuration found for Joyn") - # - # self.http_manager = HTTPManagerFactory.create_for_provider( - # provider_name='joyn', - # proxy_config=self.proxy_config, - # user_agent=JOYN_USER_AGENT, - # timeout=DEFAULT_REQUEST_TIMEOUT, - # max_retries=DEFAULT_MAX_RETRIES - # ) - - # ✅ AFTER: Using abstraction (6 lines) + # Setup HTTP manager using abstraction self.http_manager = self._setup_http_manager( provider_name='joyn', proxy_config=proxy_config, @@ -145,9 +132,6 @@ class JoynProvider(StreamingProvider): proxy_config=self.http_manager.config.proxy_config # Use resolved proxy ) - # ✅ Optional: Share HTTP manager (if authenticator might have its own) - # self.http_manager = self._share_http_manager_with_authenticator(self.authenticator) - # Authenticate try: self.bearer_token = self.authenticator.get_bearer_token() @@ -155,29 +139,28 @@ class JoynProvider(StreamingProvider): logger.warning(f"Could not authenticate during initialization: {e}") self.bearer_token = None - # ✅ BEFORE: Had to implement this helper method (12 lines) - # def _load_proxy_from_manager(self, config_dir: Optional[str]) -> Optional[ProxyConfig]: - # """Load proxy configuration from ProxyConfigManager""" - # try: - # proxy_manager = ProxyConfigManager(config_dir) - # return proxy_manager.get_proxy_config('joyn', self.country) - # except Exception as e: - # logger.warning(f"Could not load proxy from ProxyConfigManager: {e}") - # return None - - # ✅ AFTER: No longer needed - handled by abstraction! - @property def provider_name(self) -> str: return 'joyn' + # Override provider_label to provide custom country formatting @property def provider_label(self) -> str: - return f'Joyn ({self.country})' + """Return country-specific label""" + country_map = { + 'de': 'Joyn Germany', + 'at': 'Joyn Austria', + 'ch': 'Joyn Switzerland' + } + return country_map.get(self.country, f"Joyn ({self.country.upper()})") @property def provider_logo(self) -> str: - return JOYN_LOGO + return self.PROVIDER_LOGO # Use class attribute + + @property + def supported_auth_types(self) -> List[str]: + return self.SUPPORTED_AUTH_TYPES # Use class attribute @property def uses_dynamic_manifests(self) -> bool: @@ -187,10 +170,6 @@ class JoynProvider(StreamingProvider): def implements_epg(self) -> bool: return False - @property - def supported_auth_types(self) -> List[str]: - return ['client_credentials','user_credentials'] - def authenticate(self, **kwargs) -> str: """Authenticate and return bearer token""" self.bearer_token = self.authenticator.get_bearer_token( @@ -221,10 +200,10 @@ class JoynProvider(StreamingProvider): return self.bearer_token def get_channels(self, - time_window_hours: int = DEFAULT_EPG_WINDOW_HOURS, - fetch_manifests: bool = False, - populate_streaming_data: bool = True, - **kwargs) -> List[StreamingChannel]: + time_window_hours: int = DEFAULT_EPG_WINDOW_HOURS, + fetch_manifests: bool = False, + populate_streaming_data: bool = True, + **kwargs) -> List[StreamingChannel]: """ Fetch available channels from Joyn GraphQL API @@ -263,7 +242,6 @@ class JoynProvider(StreamingProvider): url = f"{JOYN_GRAPHQL_ENDPOINTS['LIVE_CHANNELS']}&variables={variables_encoded}&extensions={extensions_encoded}" - # ✅ Already using http_manager correctly response = self.http_manager.get( url, operation='api', @@ -369,7 +347,6 @@ class JoynProvider(StreamingProvider): } try: - # ✅ Already using http_manager correctly response = self.http_manager.post( JOYN_STREAMING_ENDPOINTS['ENTITLEMENT'], operation='auth', @@ -426,7 +403,6 @@ class JoynProvider(StreamingProvider): headers['Authorization'] = f'Bearer {entitlement_token}' try: - # ✅ Already using http_manager correctly response = self.http_manager.post( url, operation='manifest', diff --git a/lib/streaming_providers/providers/magentaeu/provider.py b/lib/streaming_providers/providers/magentaeu/provider.py index eddd6f3..669b660 100644 --- a/lib/streaming_providers/providers/magentaeu/provider.py +++ b/lib/streaming_providers/providers/magentaeu/provider.py @@ -1,7 +1,7 @@ # streaming_providers/providers/magentaeu/provider.py # -*- coding: utf-8 -*- import time -from typing import Dict, Optional, List +from typing import Dict, Optional, List, ClassVar from ...base.auth import UserPasswordCredentials from ...base.provider import StreamingProvider @@ -21,6 +21,7 @@ from .constants import ( DRM_SYSTEM_WIDEVINE, MAGENTA_TV_AT_LOGO, MAGENTA_TV_PL_LOGO, + MAX_TV_LOGO, WV_URL, CONTENT_TYPE_LIVE, STREAMING_FORMAT_DASH, @@ -28,11 +29,15 @@ from .constants import ( get_natco_key, get_guest_headers, get_base_url, - get_language, MAX_TV_LOGO + get_language ) class MagentaProvider(StreamingProvider): + # Provider constants with country-specific logos + PROVIDER_LOGO_AT: ClassVar[str] = MAGENTA_TV_AT_LOGO + PROVIDER_LOGO_PL: ClassVar[str] = MAGENTA_TV_PL_LOGO + PROVIDER_LOGO_HR: ClassVar[str] = MAX_TV_LOGO SUPPORTED_COUNTRIES = SUPPORTED_COUNTRIES @@ -53,21 +58,6 @@ class MagentaProvider(StreamingProvider): if country not in SUPPORTED_COUNTRIES: raise ValueError(f"Unsupported country: {country}") - # ✅ BEFORE: Complex proxy resolution logic (10+ lines) - # self.proxy_config = ( - # proxy_config or - # (ProxyConfig.from_url(proxy_url) if proxy_url else None) or - # self._load_proxy_from_manager(config_dir) - # ) - # - # self.http_manager = HTTPManagerFactory.create_for_provider( - # provider_name='magentaeu', - # proxy_config=self.proxy_config, - # user_agent=USER_AGENT, - # timeout=DEFAULT_REQUEST_TIMEOUT, - # max_retries=DEFAULT_MAX_RETRIES - # ) - # ✅ AFTER: Using abstraction with automatic proxy resolution (6 lines) self.http_manager = self._setup_http_manager( provider_name='magentaeu', @@ -119,12 +109,13 @@ class MagentaProvider(StreamingProvider): @property def provider_logo(self) -> str: - if self.country.lower() == 'at': - return MAGENTA_TV_AT_LOGO - if self.country.lower() == 'hr': - return MAX_TV_LOGO - elif self.country.lower() == 'pl': - return MAGENTA_TV_PL_LOGO + country_lower = self.country.lower() + if country_lower == 'at': + return self.PROVIDER_LOGO_AT + elif country_lower == 'hr': + return self.PROVIDER_LOGO_HR + elif country_lower == 'pl': + return self.PROVIDER_LOGO_PL else: return '' diff --git a/lib/streaming_providers/providers/rtlplus/provider.py b/lib/streaming_providers/providers/rtlplus/provider.py index e75c8cc..a7c4b08 100644 --- a/lib/streaming_providers/providers/rtlplus/provider.py +++ b/lib/streaming_providers/providers/rtlplus/provider.py @@ -1,8 +1,8 @@ # lib/streaming_providers/providers/rtlplus/provider.py import json -import requests -from typing import Dict, List, Optional +from typing import Dict, List, Optional, ClassVar +import requests from ...base.provider import StreamingProvider from ...base.models.streaming_channel import StreamingChannel from ...base.models import DRMConfig, LicenseConfig, DRMSystem @@ -13,6 +13,9 @@ from ...base.models.proxy_models import ProxyConfig class RTLPlusProvider(StreamingProvider): + # Provider constants + PROVIDER_LOGO: ClassVar[str] = RTLPlusDefaults.RTLPLUS_LOGO + def __init__(self, country: str = 'DE', config: Optional[Dict] = None, proxy_config: Optional[ProxyConfig] = None): super().__init__(country) @@ -58,7 +61,7 @@ class RTLPlusProvider(StreamingProvider): @property def provider_logo(self) -> str: - return self.rtl_config.logo + return self.PROVIDER_LOGO @property def uses_dynamic_manifests(self) -> bool: @@ -73,7 +76,7 @@ class RTLPlusProvider(StreamingProvider): def supported_auth_types(self) -> List[str]: return ['user_credentials'] - # ============================================================================ + # ============================================================================ # OPTION 1: Provider-specific method (RECOMMENDED - No signature conflict) # ============================================================================ def _get_rtlplus_authenticated_headers(self) -> Dict[str, str]: diff --git a/service.py b/service.py index d79e43f..e47ee95 100644 --- a/service.py +++ b/service.py @@ -712,66 +712,71 @@ class UltimateService: @self.app.route('/api/providers') def list_providers(): try: - provider_names = self.manager.list_providers() - default_country = self._get_setting('default_country', 'DE') + # Get metadata for ALL providers (enabled + disabled) + all_metadata = self.manager.get_all_providers_metadata() - providers_details = [] - for provider_name in provider_names: - provider_instance = self.manager.get_provider(provider_name) - if provider_instance: - provider_label = getattr(provider_instance, 'provider_label', provider_name) - country = getattr(provider_instance, 'country', default_country) - provider_logo = getattr(provider_instance, 'provider_logo', '') + # For backward compatibility, also get details for enabled providers + enabled_providers = [] + for metadata in all_metadata: + if metadata['enabled'] and metadata['instance_ready']: + provider_instance = self.manager.get_provider(metadata['name']) + if provider_instance: + # Get detailed auth info from instance + supported_auth_types = getattr(provider_instance, 'supported_auth_types', []) + preferred_auth_type = getattr(provider_instance, 'preferred_auth_type', 'unknown') + requires_stored_credentials = getattr( + provider_instance, 'requires_stored_credentials', False + ) - # Get authentication properties - supported_auth_types = getattr(provider_instance, 'supported_auth_types', []) - preferred_auth_type = getattr(provider_instance, 'preferred_auth_type', 'unknown') - requires_stored_credentials = getattr( - provider_instance, 'requires_stored_credentials', False - ) + # Check specific auth type needs + needs_user_creds = 'user_credentials' in supported_auth_types + needs_client_creds = 'client_credentials' in supported_auth_types + is_network_based = 'network_based' in supported_auth_types + is_anonymous = 'anonymous' in supported_auth_types + uses_device_reg = 'device_registration' in supported_auth_types + uses_embedded = 'embedded_client' in supported_auth_types - # Check specific auth type needs - needs_user_creds = 'user_credentials' in supported_auth_types - needs_client_creds = 'client_credentials' in supported_auth_types - is_network_based = 'network_based' in supported_auth_types - is_anonymous = 'anonymous' in supported_auth_types - uses_device_reg = 'device_registration' in supported_auth_types - uses_embedded = 'embedded_client' in supported_auth_types + provider_details = { + 'name': metadata['name'], + 'label': metadata['label'], + 'logo': metadata['logo'], + 'country': metadata['country'], - providers_details.append({ - 'name': provider_name, - 'label': provider_label, - 'logo': provider_logo, - 'country': country, + # Core authentication properties + 'auth': { + 'supported_auth_types': supported_auth_types, + 'preferred_auth_type': preferred_auth_type, + 'requires_stored_credentials': requires_stored_credentials, - # Core authentication properties - 'auth': { - 'supported_auth_types': supported_auth_types, - 'preferred_auth_type': preferred_auth_type, - 'requires_stored_credentials': requires_stored_credentials, + # Specific auth type flags for easy UI decisions + 'needs_user_credentials': needs_user_creds, + 'needs_client_credentials': needs_client_creds, + 'is_network_based': is_network_based, + 'is_anonymous': is_anonymous, + 'uses_device_registration': uses_device_reg, + 'uses_embedded_client': uses_embedded, - # Specific auth type flags for easy UI decisions - 'needs_user_credentials': needs_user_creds, - 'needs_client_credentials': needs_client_creds, - 'is_network_based': is_network_based, - 'is_anonymous': is_anonymous, - 'uses_device_registration': uses_device_reg, - 'uses_embedded_client': uses_embedded, + # Derived summary for UI + 'needs_user_input': needs_user_creds or uses_device_reg, + 'needs_configuration': needs_user_creds or needs_client_creds, + 'is_automatic': is_network_based or is_anonymous or uses_embedded, + }, - # Derived summary for UI - 'needs_user_input': needs_user_creds or uses_device_reg, - 'needs_configuration': needs_user_creds or needs_client_creds, - 'is_automatic': is_network_based or is_anonymous or uses_embedded, - }, + # Token properties + 'primary_token_scope': getattr(provider_instance, 'primary_token_scope', None), + 'token_scopes': getattr(provider_instance, 'token_scopes', []), - # Token properties - 'primary_token_scope': getattr(provider_instance, 'primary_token_scope', None), - 'token_scopes': getattr(provider_instance, 'token_scopes', []), - }) + # Metadata fields + 'enabled': metadata['enabled'], + 'instance_ready': metadata['instance_ready'], + 'requires_credentials': metadata['requires_credentials'] + } + enabled_providers.append(provider_details) return { - 'providers': providers_details, - 'default_country': default_country + 'providers': enabled_providers, + 'all_providers': all_metadata, # NEW: Include all providers metadata + 'default_country': self.default_country } except Exception as api_err: logger.error(f"API Error in /api/providers: {str(api_err)}") @@ -2099,13 +2104,8 @@ class UltimateService: @self.app.route('/api/providers//enabled', method='POST') def set_provider_enabled(provider): - """Set enabled status for provider (writes to file only)""" + """Set enabled status for provider""" try: - # Validate provider exists - if not self.manager.get_provider(provider): - response.status = 404 - return {'error': f'Provider {provider} not found'} - # Parse request try: data = request.json @@ -2118,31 +2118,28 @@ class UltimateService: response.status = 400 return {'error': 'Invalid JSON'} - # Check if controlled by Kodi - enable_manager = ProviderEnableManager() - source = enable_manager.get_enabled_source(provider) - - if source == 'kodi': - response.status = 403 - return { - 'error': f'Provider {provider} is controlled by Kodi settings', - 'hint': 'Change the setting in Kodi addon settings' - } - - # Write to file - success = enable_manager.set_provider_enabled(provider, enabled) + # Use the new manager method + success = self.manager.set_provider_enabled(provider, enabled) if success: + # Get updated metadata + metadata = None + all_metadata = self.manager.get_all_providers_metadata() + for md in all_metadata: + if md['name'] == provider: + metadata = md + break + return { 'success': True, 'provider': provider, 'enabled': enabled, - 'source': 'file', - 'message': f'Provider {provider} {"enabled" if enabled else "disabled"} in file' + 'metadata': metadata, + 'message': f'Provider {provider} {"enabled" if enabled else "disabled"}' } else: response.status = 500 - return {'error': 'Failed to save setting'} + return {'error': f'Failed to {"enable" if enabled else "disable"} provider {provider}'} except Exception as e: logger.error(f"Error setting enabled status for {provider}: {e}")