Files
script.service.ultimate/lib/streaming_providers/base/provider_registry.py
T
2026-10-07 12:12:10 +02:00

500 lines
20 KiB
Python

# streaming_providers/base/provider_registry.py
"""
Core provider registry handling discovery, metadata, and lifecycle management.
"""
import threading
from typing import Any, Dict, List, Optional
from .provider import StreamingProvider
from .utils.logger import logger
_CAPABILITY_NAMES = (
"channels", "vod", "epg", "recordings", "favorites", "bookmarks",
"catchup", "drm",
)
def _collect_capabilities(instance: Optional[StreamingProvider]) -> Optional[Dict[str, bool]]:
"""
Read the derived ``implements_*`` flags of a live provider instance.
Capabilities are instance-level (they come from which managers the
provider wired), so they are only known once the provider exists, i.e.
for ENABLED providers. Returns None when there is no instance or the
provider exposes no boolean flags (e.g. an old-style provider).
"""
if instance is None:
return None
caps: Dict[str, bool] = {}
for name in _CAPABILITY_NAMES:
try:
value = getattr(instance, f"implements_{name}")
except Exception: # a property may raise on a half-built provider
continue
if isinstance(value, bool):
caps[name] = value
return caps or None
class ProviderMetadata:
"""Metadata for a provider instance with lazy initialization."""
def __init__(
self,
plugin_class,
country: str,
enabled: bool = False,
instance_name: Optional[str] = None,
):
self.plugin_class = plugin_class
self.country = country.lower()
self.enabled = enabled
self.instance: Optional[StreamingProvider] = None
# The registry key this metadata is stored under. When given, it
# becomes ``name`` so to_dict()["name"] always resolves in
# get_provider(); otherwise the name is derived from the class name
# and the two can drift apart.
self._instance_name = instance_name
# Guards lazy create/destroy: two threads asking for the same
# provider must not build (and authenticate) two instances.
self._lock = threading.RLock()
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", "")
# Check if provider supports multiple countries
supports_multiple = self.plugin_class.supports_multiple_countries()
if supports_multiple:
# Multi-country provider: include country in name
self.name = f"{self.plugin_name}_{self.country}"
self.is_multi_country = True
else:
# Single-country provider
self.name = self.plugin_name
# Check if provider has an explicit single country
supported_countries = self.plugin_class.get_static_supported_countries()
if len(supported_countries) == 1:
# Provider has exactly one supported country (e.g., HRTi with ["HR"])
# Use that country instead of the passed-in country
self.country = supported_countries[0]
self.is_multi_country = False
else:
# True single-country or country-agnostic (empty list)
self.is_multi_country = False
# Rest of the method remains the same...
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.country)
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
)
if self._instance_name:
self.name = self._instance_name
def create_instance(self) -> Optional[StreamingProvider]:
"""Lazily create provider instance if enabled"""
if not self.enabled:
return None
with self._lock:
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:
# logger.exception keeps the traceback: a missing
# provider_name, a bad manager wiring (ConfigurationError)
# or an import error otherwise shows up as one cryptic
# line and the provider silently vanishes from the UI.
logger.exception(f"Failed to create instance for {self.name}")
self.instance = None
return self.instance
def destroy_instance(self):
"""Clean up provider instance"""
with self._lock:
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,
"capabilities": _collect_capabilities(self.instance),
}
class M3UGroupMetadata:
"""
Specialized metadata for M3U group-based providers.
NOW SUPPORTS LAZY INITIALIZATION like regular ProviderMetadata.
This ensures M3U providers get proper proxy configuration.
"""
def __init__(self, plugin_class, group: str, country: str, enabled: bool = False):
"""
Initialize M3U group metadata.
Args:
plugin_class: M3UProvider class (not instance!)
group: Group title from M3U file
country: Country code
enabled: Whether this provider is enabled
"""
self.plugin_class = plugin_class
self.group = group
self.country = country.lower()
self.enabled = enabled
self.instance: Optional[StreamingProvider] = None
self._lock = threading.RLock()
# Extract metadata from class
self._extract_metadata()
def _extract_metadata(self):
"""Extract metadata from provider class"""
self.plugin_name = "m3u"
# Create name from sanitized group title
# IMPORTANT: This MUST match what M3UProvider.provider_name property returns!
# See M3UProvider lines 189-194:
# clean_name = re.sub(r'[^a-z0-9\s]+', '', self.group_filter.lower())
# clean_name = re.sub(r'\s+', '_', clean_name).strip('_')
import re
clean_name = re.sub(r'[^a-z0-9\s]+', '', self.group.lower())
clean_name = re.sub(r'\s+', '_', clean_name).strip('_')
self.name = clean_name if clean_name else "m3u"
# Static metadata
self.label = self.group # Use original group as display label
self.is_multi_country = False
self.supported_auth_types = self.plugin_class.get_static_auth_types()
self.logo = self.plugin_class.get_static_logo()
self.supported_countries = ["*"] # M3U supports all countries
self.requires_credentials = False
def create_instance(self) -> Optional[StreamingProvider]:
"""
Lazily create M3U provider instance.
This now follows the same pattern as regular providers, ensuring
proper initialization including proxy configuration.
"""
if not self.enabled:
return None
with self._lock:
if self.instance is None:
try:
logger.info(f"Creating M3U group instance: {self.name} (group: {self.group})")
# Create instance with group filter - __init__ will handle proxy setup
self.instance = self.plugin_class(
country=self.country,
group_filter=self.group
)
logger.debug(f"Successfully created instance for M3U group: {self.name}")
except Exception:
logger.exception(f"Failed to create M3U group instance {self.name}")
self.instance = None
return self.instance
def destroy_instance(self):
"""Clean up provider instance"""
with self._lock:
if self.instance:
logger.debug(f"Destroying M3U group instance: {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,
"capabilities": _collect_capabilities(self.instance),
# M3U-specific fields
"group": self.group,
"type": "m3u_group",
}
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():
# Special handling for M3U provider - discover groups
if plugin_name == "m3u":
discovered_groups = self._discover_m3u_groups(plugin_class, default_country)
discovered.extend(discovered_groups)
continue
if plugin_class.supports_multiple_countries():
country_list = plugin_class.get_static_supported_countries()
if country_list == ["*"]:
# Wildcard provider: register one instance for the default country
country_list = [default_country]
for country in country_list:
instance_name = f"{plugin_name}_{country}"
enabled = self._is_provider_enabled(plugin_name, country)
metadata = ProviderMetadata(
plugin_class, country, enabled, instance_name=instance_name
)
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, instance_name=instance_name
)
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 _discover_m3u_groups(self, plugin_class, default_country: str) -> List[str]:
"""
Discover M3U groups and register each as a separate provider instance.
Each group-title in M3U files becomes an independent provider.
IMPORTANT: This now creates lazy metadata instead of eager instances,
allowing proper proxy configuration during initialization.
Args:
plugin_class: M3UProvider class
default_country: Default country code
Returns:
List of discovered provider instance names
"""
discovered = []
try:
# Import M3UProvider to call static discovery method
from streaming_providers.providers.m3u import M3UProvider
# Discover all unique groups from M3U files
# Now includes filename-based fallback for files without group-title
groups = M3UProvider.discover_groups(config_dir=None)
if not groups:
logger.warning("M3U: No M3U files found or all files are unreadable")
return discovered
logger.info(f"M3U: Discovered {len(groups)} groups: {', '.join(groups)}")
# Check if M3U provider is enabled
# Use base "m3u" for enable check (all groups share same enable status)
enabled = self._is_provider_enabled("m3u", None)
# Register each group as a separate provider with LAZY METADATA
for group in groups:
try:
# Create lazy metadata (no instance yet!)
metadata = M3UGroupMetadata(
plugin_class=plugin_class, # Pass class, not instance
group=group,
country=default_country,
enabled=enabled
)
# Register metadata
self.provider_metadata[metadata.name] = metadata
discovered.append(metadata.name)
# Only create instance if enabled
if enabled:
instance = metadata.create_instance()
if instance:
self.providers[metadata.name] = instance
logger.debug(
f"M3U: Registered group provider '{metadata.name}' "
f"(label: {group}, enabled: {enabled})"
)
except Exception as e:
logger.error(f"M3U: Failed to register group '{group}': {e}")
continue
logger.info(f"M3U: Successfully registered {len(discovered)} group providers")
except ImportError:
logger.warning("M3U: M3UProvider not available for group discovery")
except Exception as e:
logger.error(f"M3U: Error during group discovery: {e}")
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:
# Drop the registry's reference first: if re-creation fails we
# must not keep serving the instance the metadata already forgot.
self.providers.pop(provider_name, None)
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")