Files
script.service.ultimate/lib/streaming_providers/base/provider.py
T
2025-12-03 12:03:28 +01:00

468 lines
18 KiB
Python

# streaming_providers/base/provider.py - Enhanced with Header Abstractions
from abc import ABC, abstractmethod
from typing import Dict, List, Optional, Callable
from enum import Enum
import json
from datetime import datetime
from .models.streaming_channel import StreamingChannel
from .models.drm_models import DRMConfig
from .models.proxy_models import ProxyConfig
from .network import HTTPManagerFactory, HTTPManager
from .utils.logger import logger
class AuthType(Enum):
"""Authentication token types"""
BEARER = "bearer"
BASIC = "basic"
CLIENT = "client"
CUSTOM = "custom"
NONE = "none"
class StreamingProvider(ABC):
"""
Abstract base class for streaming providers with centralized HTTP and auth management
"""
def __init__(self, country: str = 'DE'):
self.country = country
self.channels: List[StreamingChannel] = []
self._http_manager = None
self._default_user_agent = 'StreamingProvider/1.0'
self.authenticator = None # Optional: set by concrete providers
# ============================================================================
# HTTP MANAGER SETUP (Already Implemented)
# ============================================================================
@property
def http_manager(self) -> Optional[HTTPManager]:
"""Return the provider's HTTP manager instance"""
return self._http_manager
@http_manager.setter
def http_manager(self, value: HTTPManager):
"""Set the provider's HTTP manager instance"""
self._http_manager = value
def _setup_http_manager(self,
provider_name: str,
proxy_config: Optional[ProxyConfig] = None,
proxy_url: Optional[str] = None,
config_dir: Optional[str] = None,
country: Optional[str] = None,
user_agent: Optional[str] = None,
timeout: Optional[int] = None,
max_retries: Optional[int] = None,
**kwargs) -> HTTPManager:
"""Standard HTTP manager setup for providers with intelligent proxy resolution"""
if country is None:
country = self.country
resolved_proxy = self._resolve_proxy_config(
proxy_config=proxy_config,
proxy_url=proxy_url,
config_dir=config_dir,
provider_name=provider_name,
country=country
)
manager_kwargs = {}
if user_agent:
manager_kwargs['user_agent'] = user_agent
if timeout:
manager_kwargs['timeout'] = timeout
if max_retries:
manager_kwargs['max_retries'] = max_retries
manager_kwargs.update(kwargs)
http_manager = HTTPManagerFactory.create_for_provider(
provider_name=provider_name,
proxy_config=resolved_proxy,
**manager_kwargs
)
self._log_http_manager_setup(provider_name, resolved_proxy, manager_kwargs)
return http_manager
@staticmethod
def _resolve_proxy_config(proxy_config: Optional[ProxyConfig],
proxy_url: Optional[str],
config_dir: Optional[str],
provider_name: str,
country: str) -> Optional[ProxyConfig]:
"""Resolve proxy configuration from multiple sources with priority"""
if proxy_config is not None:
logger.debug(f"{provider_name}: Using directly provided proxy configuration")
return proxy_config
if proxy_url:
try:
logger.debug(f"{provider_name}: Creating proxy config from URL")
return ProxyConfig.from_url(proxy_url)
except Exception as e:
logger.warning(f"{provider_name}: Failed to parse proxy URL '{proxy_url}': {e}")
try:
from .network import ProxyConfigManager
proxy_mgr = ProxyConfigManager(config_dir)
managed_proxy = proxy_mgr.get_proxy_config(provider_name, country)
if managed_proxy:
logger.debug(f"{provider_name}: Using proxy from ProxyConfigManager")
return managed_proxy
else:
logger.debug(f"{provider_name}: No proxy configuration found in ProxyConfigManager")
except Exception as e:
logger.warning(f"{provider_name}: Could not load proxy from ProxyConfigManager: {e}")
logger.debug(f"{provider_name}: No proxy configuration available")
return None
@staticmethod
def _log_http_manager_setup(provider_name: str,
proxy_config: Optional[ProxyConfig],
manager_kwargs: Dict) -> None:
"""Log HTTP manager setup information"""
info_parts = [f"HTTP manager initialized for '{provider_name}'"]
if proxy_config:
proxy_type = proxy_config.proxy_type.value if proxy_config.proxy_type else 'http'
proxy_host = f"{proxy_config.host}:{proxy_config.port}"
has_auth = "authenticated" if proxy_config.auth else "no-auth"
info_parts.append(f"proxy: {proxy_type}://{proxy_host} ({has_auth})")
else:
info_parts.append("proxy: none")
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']
info_parts.append(f"user-agent: {ua_preview}")
if 'timeout' in manager_kwargs:
info_parts.append(f"timeout: {manager_kwargs['timeout']}s")
if 'max_retries' in manager_kwargs:
info_parts.append(f"retries: {manager_kwargs['max_retries']}")
logger.info(f"{provider_name}: {', '.join(info_parts)}")
def _share_http_manager_with_authenticator(self,
authenticator,
http_manager: Optional[HTTPManager] = None) -> HTTPManager:
"""Share HTTP manager with authenticator for consistency"""
if http_manager is None:
http_manager = self.http_manager
if http_manager and hasattr(authenticator, 'http_manager'):
if authenticator.http_manager is None:
logger.debug(f"{self.provider_name}: Sharing HTTP manager with authenticator")
authenticator.http_manager = http_manager
else:
logger.debug(f"{self.provider_name}: Using authenticator's existing HTTP manager")
http_manager = authenticator.http_manager
return http_manager
# ============================================================================
# AUTHENTICATION HEADER ABSTRACTIONS (NEW)
# ============================================================================
def _get_base_headers(self,
user_agent: Optional[str] = None,
accept: str = 'application/json',
content_type: str = 'application/json',
additional_headers: Optional[Dict[str, str]] = None) -> Dict[str, str]:
"""
Get base headers for API requests
Args:
user_agent: Custom user agent (uses default if None)
accept: Accept header value
content_type: Content-Type header value
additional_headers: Additional headers to merge
Returns:
Dictionary of HTTP headers
"""
headers = {
'User-Agent': user_agent or self._default_user_agent,
'Accept': accept,
'Content-Type': content_type
}
if additional_headers:
headers.update(additional_headers)
return headers
def _get_authenticated_headers(self,
auth_type: AuthType = AuthType.BEARER,
token_getter: Optional[Callable[[], str]] = None,
token_key: str = 'Authorization',
base_headers: Optional[Dict[str, str]] = None,
additional_headers: Optional[Dict[str, str]] = None,
**kwargs) -> Dict[str, str]:
"""
Get headers with authentication token
This is a flexible method that handles different authentication schemes
commonly used by streaming providers.
Args:
auth_type: Type of auth (BEARER, BASIC, CLIENT, CUSTOM, NONE)
token_getter: Function to get token (uses self.authenticator.get_bearer_token if None)
token_key: Header key for token (default: 'Authorization')
base_headers: Base headers to start with (creates new if None)
additional_headers: Additional headers to add after auth
**kwargs: Arguments passed to token_getter
Returns:
Dictionary of authenticated HTTP headers
"""
# Start with base headers or create new
headers = base_headers.copy() if base_headers else self._get_base_headers()
# Add authentication if needed
if auth_type != AuthType.NONE:
# Get token using provided getter or default to authenticator
if token_getter:
token = token_getter()
elif self.authenticator is not None:
token = self.authenticator.get_bearer_token(**kwargs)
else:
logger.warning(f"{self.provider_name}: No token getter or authenticator available")
token = None
# Add auth header based on type
if token:
if auth_type == AuthType.BEARER:
headers[token_key] = f'Bearer {token}'
elif auth_type == AuthType.BASIC:
headers[token_key] = f'Basic {token}'
elif auth_type == AuthType.CLIENT:
headers[token_key] = f'Client {token}'
elif auth_type == AuthType.CUSTOM:
# Custom type - just use token as-is
headers[token_key] = token
# Add any additional headers
if additional_headers:
headers.update(additional_headers)
return headers
def _build_provider_headers(self,
base_headers: Optional[Dict[str, str]] = None,
auth_type: AuthType = AuthType.NONE,
provider_headers: Optional[Dict[str, str]] = None,
**auth_kwargs) -> Dict[str, str]:
"""
Build complete headers with provider-specific fields
This is a convenience method that combines base headers, authentication,
and provider-specific headers in one call.
Args:
base_headers: Base headers (created if None)
auth_type: Authentication type (NONE = no auth)
provider_headers: Provider-specific headers to add
**auth_kwargs: Arguments for authentication
Returns:
Complete headers dictionary
"""
# Start with base or provided headers
headers = base_headers.copy() if base_headers else self._get_base_headers()
# Add authentication if needed
if auth_type != AuthType.NONE:
headers = self._get_authenticated_headers(
auth_type=auth_type,
base_headers=headers,
**auth_kwargs
)
# Add provider-specific headers
if provider_headers:
headers.update(provider_headers)
return headers
def _add_auth_to_headers(self,
headers: Dict[str, str],
auth_type: AuthType = AuthType.BEARER,
token_getter: Optional[Callable[[], str]] = None,
token_key: str = 'Authorization',
**kwargs) -> Dict[str, str]:
"""
Add authentication to existing headers (in-place modification)
Useful when you've already built headers and just need to add auth.
Args:
headers: Headers dictionary to modify
auth_type: Type of authentication
token_getter: Function to get token
token_key: Header key for token
**kwargs: Arguments for token_getter
Returns:
The modified headers dictionary (same object)
"""
if auth_type == AuthType.NONE:
return headers
# Get token
if token_getter:
token = token_getter()
elif self.authenticator is not None:
token = self.authenticator.get_bearer_token(**kwargs)
else:
logger.warning(f"{self.provider_name}: No token available for auth")
return headers
# Add auth header
if token:
if auth_type == AuthType.BEARER:
headers[token_key] = f'Bearer {token}'
elif auth_type == AuthType.BASIC:
headers[token_key] = f'Basic {token}'
elif auth_type == AuthType.CLIENT:
headers[token_key] = f'Client {token}'
elif auth_type == AuthType.CUSTOM:
headers[token_key] = token
return headers
def _get_auth_token(self,
token_type: str = 'bearer',
force_refresh: bool = False,
**kwargs) -> Optional[str]:
"""
Get authentication token from authenticator
Convenience method for getting tokens with common options.
Args:
token_type: Type of token to get ('bearer', 'device', 'persona', etc.)
force_refresh: Force token refresh
**kwargs: Additional arguments for authenticator
Returns:
Token string or None
"""
if self.authenticator is None:
logger.warning(f"{self.provider_name}: No authenticator available")
return None
try:
# Try to get token based on type
if token_type == 'bearer':
return self.authenticator.get_bearer_token(force_refresh=force_refresh, **kwargs)
elif hasattr(self.authenticator, f'get_{token_type}_token'):
getter = getattr(self.authenticator, f'get_{token_type}_token')
return getter(force_refresh=force_refresh, **kwargs)
else:
# Default to bearer token
return self.authenticator.get_bearer_token(force_refresh=force_refresh, **kwargs)
except Exception as e:
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 []
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)