# streaming_providers/base/manager.py """ Main ProviderManager as a facade coordinating all operations. Maintains backward compatibility while delegating to specialized classes. """ from typing import Any, Dict, List, Optional from .catchup_operations import CatchupOperations from .channel_operations import ChannelOperations from .drm_operations import DRMOperations from .epg_operations import EPGOperations from .event_operations import EventOperations from .vod_operations import VodOperations from .recording_operations import RecordingOperations from .timer_operations import TimerOperations from .bookmark_operations import BookmarkOperations from .favorite_operations import FavoriteOperations from .models import StreamingChannel from .models.bookmark import Bookmark, ContentType from .models.favorite import FavoriteType, Favorite from .provider_registry import ProviderRegistry from .subscription_operations import SubscriptionOperations from .utils.logger import logger class ProviderManager: """ Facade coordinating all provider-related operations. Maintains backward compatibility while delegating to specialized classes. """ def __init__(self): # Core components self.registry = ProviderRegistry() # 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) self.event_ops = EventOperations(self.registry) self.vod_ops = VodOperations(self.registry) self.recording_ops = RecordingOperations(self.registry) self.timer_ops = TimerOperations(self.registry) self.bookmark_ops = BookmarkOperations(self.registry) self.favorite_ops = FavoriteOperations(self.registry) # 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 rediscover_providers(self, country: str = "DE") -> List[str]: """ Re-scan for new providers, particularly useful for dynamically added M3U playlists. Args: country: Country code for provider discovery Returns: List of all discovered provider names """ logger.info(f"ProviderManager: Re-discovering providers for country '{country}'...") discovered = self.registry.discover_all_providers(country) logger.info(f"ProviderManager: Re-discovery complete. Found {len(discovered)} providers") return discovered 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 get_provider_class(provider_name: str): """Get provider class from AVAILABLE_PROVIDERS registry.""" from streaming_providers import AVAILABLE_PROVIDERS # 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] return AVAILABLE_PROVIDERS.get(base_name) # ========================================================================== # CHANNEL OPERATIONS (delegate to ChannelOperations) # ========================================================================== 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) 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) def get_all_channels( self, fetch_manifests: bool = True, **kwargs ) -> Dict[str, List[StreamingChannel]]: return self.channel_ops.get_all_channels(fetch_manifests, **kwargs) # ========================================================================== # EPG OPERATIONS (delegate to EPGOperations) # ========================================================================== 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) def get_provider_epg_xmltv(self, provider_name: str, **kwargs) -> Optional[str]: return self.epg_ops.get_provider_epg_xmltv(provider_name, **kwargs) def clear_epg_cache(self) -> bool: return self.epg_ops.clear_epg_cache() def reload_epg_mapping(self) -> bool: return self.epg_ops.reload_epg_mapping() def get_epg_cache_info(self) -> Optional[Dict]: return self.epg_ops.get_epg_cache_info() def get_epg_mapping_stats(self) -> Dict: return self.epg_ops.get_epg_mapping_stats() def has_epg_mapping(self, provider_name: str, channel_id: str) -> bool: return self.epg_ops.has_epg_mapping(provider_name, channel_id) # ========================================================================== # DRM OPERATIONS (delegate to DRMOperations) # ========================================================================== def get_channel_drm_configs(self, provider_name: str, channel_id: str, **kwargs) -> List: return self.drm_ops.get_content_drm_configs(provider_name, channel_id, **kwargs) def list_drm_plugins(self) -> Dict: return self.drm_ops.list_drm_plugins() def clear_drm_plugins(self): self.drm_ops.clear_drm_plugins() # ========================================================================== # 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, drm_variant = "auto", ) -> Optional[str]: return self.catchup_ops.get_catchup_manifest( provider_name, channel_id, start_time, end_time, epg_id, country, drm_variant ) 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, drm_variant: Optional[str] = None, ) -> List: return self.catchup_ops.get_catchup_drm_configs( provider_name, channel_id, start_time, end_time, epg_id, country, drm_variant=drm_variant ) 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() # ========================================================================== # EVENT OPERATIONS (delegate to EventOperations) # ========================================================================== def get_events( self, provider_name: str, start_time=None, end_time=None, ): return self.event_ops.get_events(provider_name, start_time, end_time) def get_all_events(self, start_time=None, end_time=None): return self.event_ops.get_all_events(start_time, end_time) def get_event_manifest(self, provider_name: str, event_id: str, **kwargs) -> Optional[str]: return self.event_ops.get_event_manifest(provider_name, event_id, **kwargs) def get_event_drm_configs(self, provider_name: str, event_id: str, **kwargs) -> List: return self.drm_ops.get_content_drm_configs(provider_name, event_id, **kwargs) # ========================================================================== # VOD OPERATIONS (delegate to VodOperations) # ========================================================================== def get_vod_node( self, provider_name: str, content_id: str = "", cursor: Optional[str] = None, page_size: int = 24, **kwargs, ) -> dict: return self.vod_ops.get_vod_node( provider_name, content_id=content_id, cursor=cursor, page_size=page_size, **kwargs, ) def get_vod_manifest(self, provider_name: str, vod_id: str, **kwargs): return self.vod_ops.get_vod_manifest(provider_name, vod_id, **kwargs) def get_vod_drm_configs(self, provider_name: str, vod_id: str, **kwargs) -> list: return self.drm_ops.get_content_drm_configs(provider_name, vod_id, **kwargs) def get_all_vod_roots(self) -> dict: return self.vod_ops.get_all_vod_roots() def search_vod( self, provider_name: str, query: str, cursor: Optional[str] = None, page_size: int = 24, **kwargs, ) -> dict: """Search VOD catalogue of a single provider.""" return self.vod_ops.search_vod( provider_name, query=query, cursor=cursor, page_size=page_size, **kwargs, ) # Keep this for searching across all providers def search_all_vod(self, query: str, **kwargs) -> dict: """Search VOD across all providers.""" return self.vod_ops.search_all_vod(query, **kwargs) # ========================================================================== # RECORDING OPERATIONS (delegate to RecordingOperations) # ========================================================================== def get_recordings( self, provider_name: str, include_deleted: bool = False, ): return self.recording_ops.get_recordings( provider_name, include_deleted=include_deleted ) def get_all_recordings(self, include_deleted: bool = False): return self.recording_ops.get_all_recordings( include_deleted=include_deleted ) def get_recording_manifest( self, provider_name: str, recording_id: str, **kwargs ) -> Optional[str]: return self.recording_ops.get_recording_manifest( provider_name, recording_id, **kwargs ) def get_recording_drm_configs( self, provider_name: str, recording_id: str, **kwargs ) -> List: # Reuses the same DRM resolution path as channels, events, and VOD. return self.drm_ops.get_content_drm_configs( provider_name, recording_id, **kwargs ) def delete_recording( self, provider_name: str, recording_id: str, **kwargs ) -> None: return self.recording_ops.delete_recording( provider_name, recording_id, **kwargs ) # ========================================================================== # TIMER OPERATIONS (delegate to TimerOperations) # ========================================================================== def get_timer_types(self, provider_name: str) -> List: return self.timer_ops.get_timer_types(provider_name) def get_all_timer_types(self) -> Dict[str, List]: return self.timer_ops.get_all_timer_types() def get_timers( self, provider_name: str, include_inactive: bool = False, ) -> List: return self.timer_ops.get_timers( provider_name, include_inactive=include_inactive ) def get_all_timers(self, include_inactive: bool = False) -> Dict[str, List]: return self.timer_ops.get_all_timers(include_inactive=include_inactive) def get_timer(self, provider_name: str, client_index: int): return self.timer_ops.get_timer(provider_name, client_index) def add_timer(self, provider_name: str, timer, **kwargs): return self.timer_ops.add_timer(provider_name, timer, **kwargs) def update_timer(self, provider_name: str, timer, **kwargs): return self.timer_ops.update_timer(provider_name, timer, **kwargs) def delete_timer( self, provider_name: str, client_index: int, force_delete: bool = False, **kwargs, ) -> None: return self.timer_ops.delete_timer( provider_name, client_index, force_delete=force_delete, **kwargs ) # ========================================================================== # BOOKMARK OPERATIONS (delegate to BookmarkOperations) # ========================================================================== def get_bookmarks( self, provider_name: str, content_type: Optional[ContentType] = None, include_completed: bool = False, include_stale: bool = False, max_age_hours: int = 720, ) -> List[Bookmark]: """ Get bookmarks from a specific provider. Args: provider_name: Name of the provider to query. content_type: Optional filter by content type. include_completed: If True, include completed bookmarks. include_stale: If True, include stale bookmarks. max_age_hours: Maximum age before a bookmark is considered stale. Returns: List of Bookmark objects. Raises: ValueError: If the provider is not found or disabled. """ return self.bookmark_ops.get_bookmarks( provider_name, content_type=content_type, include_completed=include_completed, include_stale=include_stale, max_age_hours=max_age_hours, ) def get_bookmark( self, provider_name: str, content_id: str ) -> Optional[Bookmark]: """ Get a specific bookmark by content ID from a provider. Args: provider_name: Name of the provider. content_id: Content identifier. Returns: Bookmark object if found, None otherwise. Raises: ValueError: If the provider is not found or disabled. """ return self.bookmark_ops.get_bookmark(provider_name, content_id) def update_bookmark( self, provider_name: str, content_id: str, content_type: ContentType, position_seconds: int, duration_seconds: Optional[int] = None, title: Optional[str] = None, **kwargs, ) -> Optional[Bookmark]: """ Update or create a bookmark for a specific content. Args: provider_name: Name of the provider. content_id: Content identifier. content_type: Type of content being bookmarked. position_seconds: Playback position in seconds (0 = start, -1 = completed). duration_seconds: Total duration of the content (optional). title: Content title for caching (optional). **kwargs: Additional metadata (thumbnail_url, series_title, etc.). Returns: Updated Bookmark object, or None if provider doesn't support bookmarks. Raises: ValueError: If the provider is not found or disabled. RuntimeError: If the provider rejects the update. """ return self.bookmark_ops.update_bookmark( provider_name, content_id, content_type, position_seconds, duration_seconds=duration_seconds, title=title, **kwargs, ) def delete_bookmark( self, provider_name: str, content_id: str ) -> bool: """ Delete a bookmark from a specific provider. Args: provider_name: Name of the provider. content_id: Content identifier. Returns: True if deleted, False if bookmark didn't exist or provider doesn't support. Raises: ValueError: If the provider is not found or disabled. RuntimeError: If the provider refuses deletion. """ return self.bookmark_ops.delete_bookmark(provider_name, content_id) def mark_bookmark_completed( self, provider_name: str, content_id: str, content_type: ContentType, duration_seconds: Optional[int] = None, title: Optional[str] = None, **kwargs, ) -> Optional[Bookmark]: """ Mark a content as completed (watched to end). Args: provider_name: Name of the provider. content_id: Content identifier. content_type: Type of content. duration_seconds: Total duration (optional). title: Content title (optional). **kwargs: Additional metadata. Returns: Updated Bookmark with position set to -1, or None if not supported. Raises: ValueError: If the provider is not found or disabled. """ return self.bookmark_ops.mark_completed( provider_name, content_id, content_type, duration_seconds=duration_seconds, title=title, **kwargs, ) def get_all_bookmarks( self, content_type: Optional[ContentType] = None, include_completed: bool = False, include_stale: bool = False, max_age_hours: int = 720, ) -> Dict[str, List[Bookmark]]: """ Get bookmarks from all enabled providers. Args: content_type: Optional filter by content type. include_completed: If True, include completed bookmarks. include_stale: If True, include stale bookmarks. max_age_hours: Maximum age for stale detection. Returns: Dict mapping provider name → list of Bookmark objects. Failed providers are represented by an '_errors' key. """ return self.bookmark_ops.get_all_bookmarks( content_type=content_type, include_completed=include_completed, include_stale=include_stale, max_age_hours=max_age_hours, ) def get_all_bookmarks_flat( self, content_type: Optional[ContentType] = None, include_completed: bool = False, include_stale: bool = False, max_age_hours: int = 720, sort_by: str = "last_updated", ) -> List[Bookmark]: """ Get all bookmarks as a flat list sorted by the specified field. Args: content_type: Optional filter by content type. include_completed: If True, include completed bookmarks. include_stale: If True, include stale bookmarks. max_age_hours: Maximum age for stale detection. sort_by: Sort field ('last_updated', 'created_at', 'title', 'provider'). Returns: Flat list of Bookmark objects sorted by sort_by. Raises: ValueError: If sort_by is not a recognised field. """ return self.bookmark_ops.get_all_bookmarks_flat( content_type=content_type, include_completed=include_completed, include_stale=include_stale, max_age_hours=max_age_hours, sort_by=sort_by, ) def delete_all_bookmarks_for_content( self, content_id: str, provider_filter: Optional[List[str]] = None, ) -> Dict[str, bool]: """ Delete bookmarks for a specific content ID across all providers. Useful when content is removed from a provider. Args: content_id: Content identifier to delete. provider_filter: Optional list of provider names to restrict deletion. Returns: Dict mapping provider name → True if deleted, False if not found. """ return self.bookmark_ops.delete_all_bookmarks_for_content( content_id, provider_filter=provider_filter ) def cleanup_stale_bookmarks( self, max_age_hours: int = 720, dry_run: bool = True, provider_filter: Optional[List[str]] = None, ) -> Dict[str, int]: """ Remove bookmarks older than max_age_hours. Args: max_age_hours: Age threshold in hours (default 720 = 30 days). dry_run: If True, only report what would be deleted without actually deleting. provider_filter: Optional list of provider names to restrict cleanup. Returns: Dict mapping provider name → number of bookmarks deleted (or would-be deleted). """ return self.bookmark_ops.cleanup_stale_bookmarks( max_age_hours=max_age_hours, dry_run=dry_run, provider_filter=provider_filter, ) def get_bookmark_stats(self, max_age_hours: int = 720) -> Dict[str, Any]: """ Get statistics about bookmarks across all providers. Args: max_age_hours: Age threshold used for staleness classification. Returns: Dictionary with counts by status, content type, and provider. """ return self.bookmark_ops.get_bookmark_stats(max_age_hours=max_age_hours) def validate_bookmarks( self, provider_name: str, auto_fix: bool = False ) -> Dict[str, Any]: """ Validate all bookmarks from a provider and optionally fix common issues. Args: provider_name: Name of the provider. auto_fix: If True, attempt to fix fixable issues. Returns: Dictionary with validation results (total, errors, warnings, fixed). """ return self.bookmark_ops.validate_bookmarks(provider_name, auto_fix=auto_fix) # ========================================================================== # Favorite OPERATIONS (delegate to FavoriteOperations) # ========================================================================== def get_favorites( self, provider_name: str, favorite_type: Optional[FavoriteType] = None, ) -> List[Favorite]: """Get favorites from a specific provider.""" return self.favorite_ops.get_favorites(provider_name, favorite_type) def add_favorite( self, provider_name: str, content_id: str, favorite_type: FavoriteType, title: Optional[str] = None, **kwargs, ) -> Optional[Favorite]: """Add a favorite.""" return self.favorite_ops.add_favorite( provider_name, content_id, favorite_type, title, **kwargs ) def remove_favorite( self, provider_name: str, content_id: str ) -> bool: """Remove a favorite.""" return self.favorite_ops.remove_favorite(provider_name, content_id) def get_all_favorites( self, favorite_type: Optional[FavoriteType] = None, ) -> Dict[str, List[Favorite]]: """Get favorites from all enabled providers.""" return self.favorite_ops.get_all_favorites(favorite_type) def is_favorited( self, provider_name: str, content_id: str ) -> bool: """Check if content is favorited.""" return self.favorite_ops.is_favorited(provider_name, content_id) # ========================================================================== # 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.""" provider = self.get_provider(provider_name) if not provider: logger.warning(f"ProviderManager: Provider '{provider_name}' not found") return None http_manager = provider.http_manager if not http_manager: logger.warning(f"ProviderManager: Provider '{provider_name}' has no HTTP manager") return None 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.""" 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 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. """ 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]: """ Convert user input string to list of provider names. Args: choices_input: Comma-separated string of choice numbers Returns: List of selected provider names """ available = self.registry.get_enabled_providers() if not available: logger.warning("ProviderManager: No enabled providers available for selection") return [] selected = [] for choice in choices_input.split(","): choice = choice.strip() if choice.isdigit(): idx = int(choice) - 1 if 0 <= idx < len(available): selected.append(available[idx]) elif idx == len(available): # 'all' option selected = available.copy() break result = selected if selected else available logger.debug( f"ProviderManager: Selected {len(result)} providers from input '{choices_input}'" ) return result # ========================================================================== # LEGACY COMPATIBILITY METHODS # ========================================================================== def _extract_pssh_from_manifest(self, manifest_url: str) -> List: """ Extract PSSH data from a manifest URL. Kept for backward compatibility but delegated to DRMOperations. """ return self.drm_ops._extract_pssh_from_manifest(manifest_url)