mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-09-18 15:12:12 +02:00
1214 lines
41 KiB
Python
1214 lines
41 KiB
Python
# streaming_providers/base/provider.py - Enhanced with Static Metadata
|
|
"""
|
|
Streaming Provider Base Class with Static Metadata Support
|
|
|
|
New Features:
|
|
- Class attributes for static metadata (PROVIDER_LABEL, etc.)
|
|
- Static methods to get metadata without instantiation
|
|
- Backward compatible with existing @property methods
|
|
"""
|
|
|
|
import json
|
|
from abc import ABC, abstractmethod
|
|
from datetime import datetime
|
|
from enum import Enum
|
|
from typing import Any, Callable, ClassVar, Dict, List, Optional
|
|
|
|
from ..providers.auth import AuthContext, AuthStatus
|
|
from .models.drm import DRMConfig
|
|
from .models.proxy_models import ProxyConfig
|
|
from .models.streaming_channel import StreamingChannel
|
|
from .models.subscription import SubscriptionPackage, UserSubscription
|
|
from .network import HTTPManager, HTTPManagerFactory
|
|
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
|
|
"""
|
|
|
|
# ============================================================================
|
|
# 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
|
|
self.channels: List[StreamingChannel] = []
|
|
self._http_manager = None
|
|
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, country: str = None) -> str:
|
|
"""
|
|
Get provider logo URL without instantiation.
|
|
|
|
Args:
|
|
country: Optional country code for country-specific logos
|
|
|
|
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)
|
|
# ============================================================================
|
|
|
|
@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
|
|
# ============================================================================
|
|
|
|
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
|
|
|
|
# ============================================================================
|
|
# CATCHUP ABSTRACT METHODS
|
|
# ============================================================================
|
|
|
|
def get_catchup_manifest(
|
|
self,
|
|
channel_id: str,
|
|
start_time: int,
|
|
end_time: int,
|
|
epg_id: Optional[str] = None,
|
|
**kwargs,
|
|
) -> Optional[str]:
|
|
"""
|
|
Get manifest URL for catchup/timeshift content.
|
|
|
|
Args:
|
|
channel_id: Channel identifier
|
|
start_time: Start time as Unix timestamp
|
|
end_time: End time as Unix timestamp
|
|
epg_id: Optional EPG event ID (might be needed by some providers)
|
|
**kwargs: Additional provider-specific parameters
|
|
|
|
Returns:
|
|
Manifest URL for catchup content, or None if not supported
|
|
|
|
Default implementation falls back to live manifest.
|
|
Override in subclass to implement provider-specific catchup logic.
|
|
"""
|
|
if not self.supports_catchup:
|
|
logger.debug(
|
|
f"{self.provider_name}: Catchup not supported, falling back to live manifest"
|
|
)
|
|
return self.get_manifest(channel_id, **kwargs)
|
|
|
|
logger.warning(
|
|
f"{self.provider_name}: get_catchup_manifest not implemented, "
|
|
f"falling back to live manifest"
|
|
)
|
|
return self.get_manifest(channel_id, **kwargs)
|
|
|
|
def get_catchup_drm(
|
|
self,
|
|
channel_id: str,
|
|
start_time: int,
|
|
end_time: int,
|
|
epg_id: Optional[str] = None,
|
|
**kwargs,
|
|
) -> List[DRMConfig]:
|
|
"""
|
|
Get DRM configurations for catchup content.
|
|
|
|
Args:
|
|
channel_id: Channel identifier
|
|
start_time: Start time as Unix timestamp
|
|
end_time: End time as Unix timestamp
|
|
epg_id: Optional EPG event ID (might be needed for DRM licensing)
|
|
**kwargs: Additional provider-specific parameters
|
|
|
|
Returns:
|
|
List of DRM configurations for catchup content
|
|
|
|
Default implementation falls back to live DRM.
|
|
Override in subclass if catchup requires different DRM configuration.
|
|
"""
|
|
if not self.supports_catchup:
|
|
logger.debug(f"{self.provider_name}: Catchup not supported, falling back to live DRM")
|
|
return self.get_drm(channel_id, **kwargs)
|
|
|
|
logger.debug(
|
|
f"{self.provider_name}: get_catchup_drm not implemented, "
|
|
f"using live DRM configuration"
|
|
)
|
|
return self.get_drm(channel_id, **kwargs)
|
|
|
|
# ============================================================================
|
|
# CATCHUP HELPER METHODS
|
|
# ============================================================================
|
|
|
|
def get_catchup_window_for_channel(self, channel_id: str) -> int:
|
|
"""
|
|
Get catchup window for a specific channel in HOURS.
|
|
|
|
Args:
|
|
channel_id: Channel identifier
|
|
|
|
Returns:
|
|
int: Catchup window in hours for this channel
|
|
"""
|
|
return self.catchup_window
|
|
|
|
def validate_catchup_request(
|
|
self, start_time: int, end_time: int
|
|
) -> tuple[bool, Optional[str]]:
|
|
"""
|
|
Validate a catchup request against provider's capabilities.
|
|
|
|
Args:
|
|
start_time: Start time as Unix timestamp
|
|
end_time: End time as Unix timestamp
|
|
|
|
Returns:
|
|
Tuple of (is_valid, error_message)
|
|
"""
|
|
import time
|
|
|
|
if not self.supports_catchup:
|
|
return False, f"Provider '{self.provider_name}' does not support catchup"
|
|
|
|
if start_time >= end_time:
|
|
return False, "Invalid time range: start_time must be before end_time"
|
|
|
|
now = int(time.time())
|
|
if start_time > now:
|
|
return False, "Cannot request future content"
|
|
|
|
# CHANGE FROM DAYS TO HOURS HERE
|
|
max_age_seconds = self.catchup_window * 3600 # hours to seconds
|
|
content_age = now - start_time
|
|
|
|
if content_age > max_age_seconds:
|
|
hours_ago = content_age // 3600
|
|
return False, (
|
|
f"Content is outside catchup window "
|
|
f"(requested: {hours_ago} hours ago, "
|
|
f"max: {self.catchup_window} hours)"
|
|
)
|
|
|
|
return True, None
|
|
|
|
def format_catchup_time_params(
|
|
self, start_time: int, end_time: int, format_type: str = "iso"
|
|
) -> Dict[str, str]:
|
|
"""
|
|
Format time parameters for provider-specific API calls.
|
|
|
|
Different providers expect different time formats in their APIs.
|
|
This helper converts Unix timestamps to various formats.
|
|
|
|
Args:
|
|
start_time: Start time as Unix timestamp
|
|
end_time: End time as Unix timestamp
|
|
format_type: Format type ('iso', 'unix', 'millis', 'custom')
|
|
|
|
Returns:
|
|
Dictionary with formatted time parameters
|
|
|
|
Override in subclass for provider-specific formatting.
|
|
"""
|
|
from datetime import datetime
|
|
|
|
if format_type == "iso":
|
|
# ISO 8601 format
|
|
start_dt = datetime.fromtimestamp(start_time)
|
|
end_dt = datetime.fromtimestamp(end_time)
|
|
return {"start": start_dt.isoformat(), "end": end_dt.isoformat()}
|
|
elif format_type == "unix":
|
|
# Unix timestamps (seconds)
|
|
return {"start": str(start_time), "end": str(end_time)}
|
|
elif format_type == "millis":
|
|
# Milliseconds since epoch
|
|
return {"start": str(start_time * 1000), "end": str(end_time * 1000)}
|
|
else:
|
|
# Default to unix
|
|
return {"start": str(start_time), "end": str(end_time)}
|
|
|
|
def build_catchup_manifest_url(
|
|
self, base_url: str, start_time: int, end_time: int, url_format: str = "query"
|
|
) -> str:
|
|
"""
|
|
Build catchup manifest URL with time parameters.
|
|
|
|
Helper method to construct manifest URLs with time parameters
|
|
in various formats that different providers use.
|
|
|
|
Args:
|
|
base_url: Base manifest URL
|
|
start_time: Start time as Unix timestamp
|
|
end_time: End time as Unix timestamp
|
|
url_format: Format ('query', 'path', 'fragment')
|
|
|
|
Returns:
|
|
Complete manifest URL with time parameters
|
|
|
|
Override in subclass for provider-specific URL construction.
|
|
"""
|
|
if url_format == "query":
|
|
# Add as query parameters
|
|
separator = "&" if "?" in base_url else "?"
|
|
return f"{base_url}{separator}start={start_time}&end={end_time}"
|
|
elif url_format == "path":
|
|
# Add to path (e.g., /manifest/start/end.mpd)
|
|
return f"{base_url}/{start_time}/{end_time}"
|
|
elif url_format == "fragment":
|
|
# Add as URL fragment (e.g., manifest.mpd#t=start,end)
|
|
return f"{base_url}#t={start_time},{end_time}"
|
|
else:
|
|
# Default to query parameters
|
|
separator = "&" if "?" in base_url else "?"
|
|
return f"{base_url}{separator}start={start_time}&end={end_time}"
|
|
|
|
# ============================================================================
|
|
# SUBSCRIPTION METHODS
|
|
# ============================================================================
|
|
|
|
def get_subscription_status(self, **kwargs) -> Optional[UserSubscription]:
|
|
"""
|
|
Get user's subscription status for this provider.
|
|
|
|
Returns:
|
|
UserSubscription object with subscription details, or
|
|
None if provider doesn't support subscription queries or
|
|
subscription info isn't available.
|
|
|
|
Default implementation returns None (subscription not supported).
|
|
Override in provider plugins that support subscription checking.
|
|
|
|
Example usage in providers:
|
|
# Query provider API for user entitlements
|
|
# Parse response into SubscriptionPackage objects
|
|
# Return UserSubscription with packages and accessible channels
|
|
"""
|
|
return None
|
|
|
|
def get_subscribed_channels(self, **kwargs) -> List[StreamingChannel]:
|
|
"""
|
|
Get channels the current user is subscribed to.
|
|
|
|
This method:
|
|
1. Gets subscription status (if supported)
|
|
2. Filters all channels based on accessible channel IDs
|
|
3. Returns filtered list or all channels as fallback
|
|
|
|
Returns:
|
|
List of StreamingChannel objects that the user can access
|
|
|
|
Note:
|
|
Override get_subscription_status() in provider plugins
|
|
to enable subscription filtering.
|
|
"""
|
|
# Get all channels first
|
|
all_channels = self.get_channels(**kwargs)
|
|
|
|
# Try to get subscription status
|
|
subscription = self.get_subscription_status(**kwargs)
|
|
|
|
# If no subscription info or not active, return all channels
|
|
if not subscription or not subscription.active:
|
|
return all_channels
|
|
|
|
# Filter channels based on accessible channel IDs
|
|
if subscription.accessible_channel_ids:
|
|
return [
|
|
channel
|
|
for channel in all_channels
|
|
if channel.channel_id in subscription.accessible_channel_ids
|
|
]
|
|
|
|
# No filtering possible, return all channels
|
|
return all_channels
|
|
|
|
def get_available_packages(self, **kwargs) -> List[SubscriptionPackage]:
|
|
"""
|
|
Get all subscription packages available from this provider.
|
|
|
|
Useful for:
|
|
- Displaying upgrade options in UI
|
|
- Showing package comparison
|
|
- Subscription management interface
|
|
|
|
Returns:
|
|
List of available SubscriptionPackage objects
|
|
Empty list if not implemented or no packages available
|
|
|
|
Default implementation returns empty list.
|
|
Override in provider plugins that have package information.
|
|
"""
|
|
return []
|
|
|
|
def is_channel_accessible(self, channel_id: str, **kwargs) -> bool:
|
|
"""
|
|
Check if a specific channel is accessible with current subscription.
|
|
|
|
Args:
|
|
channel_id: ID of the channel to check
|
|
**kwargs: Additional arguments passed to get_subscription_status()
|
|
|
|
Returns:
|
|
True if channel is accessible, False otherwise.
|
|
Returns True if subscription checking is not supported.
|
|
|
|
Note:
|
|
This is a convenience method for quick checks.
|
|
"""
|
|
subscription = self.get_subscription_status(**kwargs)
|
|
|
|
# If no subscription info, assume accessible (backward compatibility)
|
|
if not subscription or not subscription.active:
|
|
return True
|
|
|
|
# Check if channel is in accessible set
|
|
return subscription.can_access_channel(channel_id)
|
|
|
|
@classmethod
|
|
def get_supported_countries(cls) -> List[str]:
|
|
"""
|
|
Get list of countries supported by this provider.
|
|
|
|
Returns:
|
|
List of ISO country codes (e.g., ['de', 'at', 'ch'])
|
|
Empty list means single-country provider using default country
|
|
"""
|
|
return cls.SUPPORTED_COUNTRIES.copy()
|
|
|
|
@classmethod
|
|
def supports_multiple_countries(cls) -> bool:
|
|
"""
|
|
Check if this provider supports multiple countries.
|
|
|
|
Returns:
|
|
True if provider supports country-specific instances
|
|
"""
|
|
return len(cls.SUPPORTED_COUNTRIES) > 1
|
|
|
|
@classmethod
|
|
def validate_country(cls, country: str) -> bool:
|
|
"""
|
|
Validate if a country is supported by this provider.
|
|
|
|
Args:
|
|
country: ISO country code to validate
|
|
|
|
Returns:
|
|
True if country is supported or provider is single-country
|
|
"""
|
|
if not cls.supports_multiple_countries():
|
|
# Single-country providers accept any country (or ignore it)
|
|
return True
|
|
|
|
return country.lower() in [c.lower() for c in cls.SUPPORTED_COUNTRIES]
|
|
|
|
def validate_auth_type(self, auth_type: str) -> bool:
|
|
"""
|
|
Check if an auth type is supported by this provider.
|
|
|
|
Useful for:
|
|
- Validating user input in configuration UI
|
|
- Safely switching auth modes
|
|
- Error messages when unsupported auth is requested
|
|
|
|
Args:
|
|
auth_type: Auth type to check (e.g., 'user_credentials')
|
|
|
|
Returns:
|
|
True if supported, False otherwise
|
|
|
|
Example:
|
|
if provider.validate_auth_type('user_credentials'):
|
|
# Safe to request user credentials
|
|
"""
|
|
return auth_type in self.supported_auth_types
|
|
|
|
def get_auth_type_description(self, auth_type: str) -> str:
|
|
"""
|
|
Get human-readable description of an auth type.
|
|
|
|
Args:
|
|
auth_type: Auth type to describe
|
|
|
|
Returns:
|
|
Description string or empty string if not supported
|
|
"""
|
|
descriptions = {
|
|
"user_credentials": "Username and password authentication",
|
|
"client_credentials": "Client ID and secret authentication",
|
|
"network_based": "Network/fixed-line authentication",
|
|
"anonymous": "No authentication required",
|
|
"device_registration": "Device registration authentication",
|
|
"embedded_client": "Built-in credentials authentication",
|
|
}
|
|
|
|
if auth_type in descriptions:
|
|
return descriptions[auth_type]
|
|
|
|
# For custom auth types
|
|
return f"Custom authentication: {auth_type}"
|
|
|
|
def get_auth_requirements(self, auth_type: str) -> Dict[str, Any]:
|
|
"""
|
|
Get requirements for a specific auth type.
|
|
|
|
Args:
|
|
auth_type: Auth type to get requirements for
|
|
|
|
Returns:
|
|
Dictionary with requirement information
|
|
|
|
Raises:
|
|
ValueError: If auth_type is not supported
|
|
"""
|
|
if not self.validate_auth_type(auth_type):
|
|
raise ValueError(f"Auth type '{auth_type}' not supported by {self.provider_name}")
|
|
|
|
requirements = {
|
|
"auth_type": auth_type,
|
|
"needs_storage": auth_type in ["user_credentials", "client_credentials"],
|
|
"provides_token": auth_type != "anonymous",
|
|
"user_interaction_required": auth_type in ["user_credentials", "device_registration"],
|
|
}
|
|
|
|
# Type-specific details
|
|
if auth_type == "user_credentials":
|
|
requirements.update(
|
|
{
|
|
"fields": ["username", "password"],
|
|
"optional_fields": ["client_id"],
|
|
"storage_key": "user_password",
|
|
}
|
|
)
|
|
elif auth_type == "client_credentials":
|
|
requirements.update(
|
|
{
|
|
"fields": ["client_id", "client_secret"],
|
|
"storage_key": "client_credentials",
|
|
}
|
|
)
|
|
elif auth_type == "network_based":
|
|
requirements.update(
|
|
{
|
|
"description": "Authenticates via your network provider",
|
|
"automatic": True,
|
|
}
|
|
)
|
|
|
|
return requirements
|
|
|
|
# ===== AUTHENTICATION PROPERTIES AND METHODS =====
|
|
|
|
@property
|
|
@abstractmethod
|
|
def supported_auth_types(self) -> List[str]:
|
|
"""List of authentication types this provider supports."""
|
|
pass
|
|
|
|
@property
|
|
def preferred_auth_type(self) -> str:
|
|
"""Preferred authentication type (first in supported list)."""
|
|
types = self.supported_auth_types
|
|
return types[0] if types else "unknown"
|
|
|
|
@property
|
|
def requires_stored_credentials(self) -> bool:
|
|
"""True if provider needs credentials stored in settings."""
|
|
credential_types = ["user_credentials", "client_credentials"]
|
|
return any(auth_type in credential_types for auth_type in self.supported_auth_types)
|
|
|
|
# ===== AUTHENTICATION PROPERTIES =====
|
|
|
|
def get_current_auth_type(self, context: AuthContext) -> str:
|
|
"""
|
|
Determine which auth type is currently active.
|
|
|
|
Default implementation checks tokens/credentials.
|
|
Override for providers with complex auth logic.
|
|
|
|
Args:
|
|
context: AuthContext for accessing tokens/credentials
|
|
|
|
Returns:
|
|
Current active auth type
|
|
"""
|
|
return self._determine_current_auth_type_default(context)
|
|
|
|
def _determine_current_auth_type_default(self, context: AuthContext) -> str:
|
|
"""
|
|
Default logic for determining current auth type.
|
|
Providers can override get_current_auth_type() directly instead.
|
|
"""
|
|
# 1. Check if provider requires stored credentials
|
|
if self.requires_stored_credentials:
|
|
credentials = context.get_credentials(self.provider_name, self.country)
|
|
if credentials:
|
|
# Map credential type to auth type
|
|
if hasattr(credentials, "credential_type"):
|
|
if credentials.credential_type == "user_password":
|
|
return "user_credentials"
|
|
elif credentials.credential_type == "client_credentials":
|
|
return "client_credentials"
|
|
|
|
# 2. Check token auth level
|
|
primary_token = context.get_token(
|
|
self.provider_name, self.primary_token_scope, self.country
|
|
)
|
|
if primary_token:
|
|
auth_level = primary_token.get("auth_level")
|
|
if auth_level == "user_authenticated":
|
|
return "user_credentials"
|
|
elif auth_level == "client_credentials":
|
|
return "client_credentials"
|
|
elif auth_level == "anonymous":
|
|
return "anonymous"
|
|
elif auth_level == "network_based":
|
|
return "network_based"
|
|
|
|
# 3. Return first supported type as default
|
|
return self.preferred_auth_type
|
|
|
|
# Token management properties (keep these)
|
|
@property
|
|
def primary_token_scope(self) -> Optional[str]:
|
|
"""
|
|
Primary token scope for this provider.
|
|
None = uses root-level token or no token needed.
|
|
|
|
Returns:
|
|
Token scope string or None
|
|
"""
|
|
return None
|
|
|
|
@property
|
|
def token_scopes(self) -> List[str]:
|
|
"""
|
|
All token scopes this provider uses.
|
|
|
|
Returns:
|
|
List of token scope strings
|
|
"""
|
|
scope = self.primary_token_scope
|
|
return [scope] if scope else []
|
|
|
|
def get_auth_status(self, context: AuthContext) -> "AuthStatus":
|
|
"""
|
|
Get authentication status for this provider.
|
|
Uses AuthStatusBuilder by default.
|
|
|
|
Override only for providers with special requirements.
|
|
|
|
Args:
|
|
context: AuthContext with access to settings
|
|
|
|
Returns:
|
|
AuthStatus object
|
|
"""
|
|
from ..providers.auth_builder import (
|
|
AuthStatusBuilder,
|
|
) # Import here to avoid circular imports
|
|
|
|
return AuthStatusBuilder.for_provider(self, context)
|
|
|
|
# Optional override methods for providers with special logic
|
|
def _calculate_auth_state(self, context: AuthContext):
|
|
"""
|
|
Override to provide custom auth state calculation.
|
|
Return None to use standard calculation.
|
|
|
|
Returns:
|
|
AuthState or None
|
|
"""
|
|
return None
|
|
|
|
def _calculate_readiness(self, context: AuthContext):
|
|
"""
|
|
Override to provide custom readiness calculation.
|
|
Return None to use standard calculation.
|
|
|
|
Returns:
|
|
Tuple of (is_ready: bool, reason: str) or None
|
|
"""
|
|
return None
|
|
|
|
def get_auth_details(self, context: AuthContext) -> Dict[str, Any]:
|
|
"""
|
|
Override to provide provider-specific auth details.
|
|
|
|
Returns:
|
|
Dictionary with provider-specific information
|
|
"""
|
|
return {}
|