mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-10-12 02:42:39 +02:00
Add discovery
This commit is contained in:
@@ -0,0 +1 @@
|
||||
.aider*
|
||||
@@ -0,0 +1,87 @@
|
||||
# streaming_providers/providers/discovery/__init__.py
|
||||
"""
|
||||
Discovery+ Provider Package
|
||||
|
||||
Provides access to Discovery+ streaming content with support for:
|
||||
- Anonymous and user authentication
|
||||
- Live channels and VOD content
|
||||
- DRM-protected streams
|
||||
- Multi-country support (EMEA)
|
||||
"""
|
||||
|
||||
from .auth import (
|
||||
DiscoveryAuthenticator,
|
||||
DiscoveryAuthToken,
|
||||
DiscoveryAnonymousCredentials,
|
||||
DiscoveryUserCredentials,
|
||||
)
|
||||
from .constants import (
|
||||
DEFAULT_COUNTRY,
|
||||
SUPPORTED_COUNTRIES,
|
||||
CHANNEL_COLLECTIONS,
|
||||
CHANNEL_ITEM_TYPES,
|
||||
StreamingMode,
|
||||
CDMMode,
|
||||
VideoQuality,
|
||||
DRMSystem,
|
||||
AuthProvider,
|
||||
ErrorCode,
|
||||
)
|
||||
from .exceptions import (
|
||||
DiscoveryError,
|
||||
PlaybackRestrictedException,
|
||||
AuthenticationError,
|
||||
InvalidCredentialsError,
|
||||
UnsupportedCredentialTypeError,
|
||||
TokenExpiredError,
|
||||
EndpointDiscoveryError,
|
||||
ChannelNotFoundError,
|
||||
ManifestFetchError,
|
||||
DRMConfigurationError,
|
||||
)
|
||||
from .models import DiscoveryChannel
|
||||
|
||||
# Note: DiscoveryProvider would be imported here when available
|
||||
# from .provider import DiscoveryProvider
|
||||
|
||||
__all__ = [
|
||||
# Provider class (when available)
|
||||
# "DiscoveryProvider",
|
||||
|
||||
# Models
|
||||
"DiscoveryChannel",
|
||||
|
||||
# Exceptions
|
||||
"DiscoveryError",
|
||||
"PlaybackRestrictedException",
|
||||
"AuthenticationError",
|
||||
"InvalidCredentialsError",
|
||||
"UnsupportedCredentialTypeError",
|
||||
"TokenExpiredError",
|
||||
"EndpointDiscoveryError",
|
||||
"ChannelNotFoundError",
|
||||
"ManifestFetchError",
|
||||
"DRMConfigurationError",
|
||||
|
||||
# Authentication
|
||||
"DiscoveryAuthenticator",
|
||||
"DiscoveryAuthToken",
|
||||
"DiscoveryAnonymousCredentials",
|
||||
"DiscoveryUserCredentials",
|
||||
|
||||
# Constants - Values
|
||||
"DEFAULT_COUNTRY",
|
||||
"SUPPORTED_COUNTRIES",
|
||||
"CHANNEL_COLLECTIONS",
|
||||
"CHANNEL_ITEM_TYPES",
|
||||
|
||||
# Constants - Enums
|
||||
"StreamingMode",
|
||||
"CDMMode",
|
||||
"VideoQuality",
|
||||
"DRMSystem",
|
||||
"AuthProvider",
|
||||
"ErrorCode",
|
||||
]
|
||||
|
||||
__version__ = "2.0.0"
|
||||
@@ -0,0 +1,589 @@
|
||||
# streaming_providers/providers/discovery/auth.py
|
||||
"""
|
||||
Discovery+ Authentication
|
||||
|
||||
Handles authentication for Discovery+ including anonymous and user credentials.
|
||||
"""
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from ...base.auth.base_auth import BaseAuthenticator, BaseAuthToken, TokenAuthLevel
|
||||
from ...base.auth.credentials import ClientCredentials, UserPasswordCredentials
|
||||
from ...base.models.proxy_models import ProxyConfig
|
||||
from ...base.utils.logger import logger
|
||||
|
||||
from .constants import (
|
||||
DEFAULT_DEVICE_ID,
|
||||
DEFAULT_ENV,
|
||||
DEFAULT_REALM,
|
||||
DEFAULT_TENANT,
|
||||
DISCOVERY_BOOTSTRAP_URL,
|
||||
DISCOVERY_USER_AGENT,
|
||||
HOME_MARKET_MAPPING,
|
||||
AuthProvider,
|
||||
)
|
||||
from .exceptions import (
|
||||
InvalidCredentialsError,
|
||||
UnsupportedCredentialTypeError,
|
||||
EndpointDiscoveryError,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiscoveryAnonymousCredentials(ClientCredentials):
|
||||
"""Discovery+ anonymous credentials"""
|
||||
|
||||
realm: str = DEFAULT_REALM
|
||||
|
||||
def validate(self) -> bool:
|
||||
"""Validate anonymous credentials"""
|
||||
return bool(self.realm)
|
||||
|
||||
@property
|
||||
def credential_type(self) -> str:
|
||||
return "discovery_anonymous"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiscoveryUserCredentials(UserPasswordCredentials):
|
||||
"""Discovery+ user credentials"""
|
||||
|
||||
provider: str = AuthProvider.USERNAME_PASSWORD.value
|
||||
|
||||
def to_login_payload(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Convert to login payload for API request.
|
||||
|
||||
Returns:
|
||||
Dictionary formatted for Discovery+ login API
|
||||
"""
|
||||
return {
|
||||
"credentials": {
|
||||
"password": self.password,
|
||||
"username": self.username,
|
||||
"provider": self.provider,
|
||||
}
|
||||
}
|
||||
|
||||
@property
|
||||
def credential_type(self) -> str:
|
||||
return "discovery_user"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiscoveryAuthToken(BaseAuthToken):
|
||||
"""Discovery+ authentication token"""
|
||||
|
||||
refresh_token: Optional[str] = field(default=None)
|
||||
realm: Optional[str] = field(default=None)
|
||||
anonymous: bool = field(default=True)
|
||||
token_id: Optional[str] = field(default=None)
|
||||
|
||||
@classmethod
|
||||
def from_token_response(cls, response_data: Dict[str, Any]) -> "DiscoveryAuthToken":
|
||||
"""
|
||||
Create token from API response.
|
||||
|
||||
Args:
|
||||
response_data: Token response from Discovery+ API
|
||||
|
||||
Returns:
|
||||
Initialized DiscoveryAuthToken
|
||||
"""
|
||||
data = response_data.get("data", {})
|
||||
attributes = data.get("attributes", {})
|
||||
|
||||
token = cls(
|
||||
access_token=attributes.get("token", ""),
|
||||
token_type="Bearer",
|
||||
expires_in=31536000, # Default 1 year
|
||||
issued_at=time.time(),
|
||||
realm=attributes.get("realm"),
|
||||
anonymous=attributes.get("anonymous", True),
|
||||
token_id=data.get("id"),
|
||||
)
|
||||
|
||||
# Parse JWT for more accurate expiration
|
||||
token._parse_jwt_expiration()
|
||||
return token
|
||||
|
||||
def _parse_jwt_expiration(self) -> None:
|
||||
"""
|
||||
Extract expiration from JWT token.
|
||||
|
||||
Updates expires_in based on JWT exp claim if present.
|
||||
"""
|
||||
try:
|
||||
parts = self.access_token.split(".")
|
||||
if len(parts) == 3:
|
||||
import base64
|
||||
import json
|
||||
|
||||
payload_b64 = parts[1]
|
||||
# Add padding if needed
|
||||
padding = len(payload_b64) % 4
|
||||
if padding:
|
||||
payload_b64 += "=" * (4 - padding)
|
||||
|
||||
payload = json.loads(base64.b64decode(payload_b64))
|
||||
if "exp" in payload:
|
||||
self.expires_in = payload["exp"] - self.issued_at
|
||||
except Exception:
|
||||
# If JWT parsing fails, keep default expiration
|
||||
pass
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Convert token to dictionary.
|
||||
|
||||
Returns:
|
||||
Dictionary representation of the token
|
||||
"""
|
||||
return {
|
||||
"access_token": self.access_token,
|
||||
"refresh_token": self.refresh_token,
|
||||
"token_type": self.token_type,
|
||||
"expires_in": self.expires_in,
|
||||
"issued_at": self.issued_at,
|
||||
"realm": self.realm,
|
||||
"anonymous": self.anonymous,
|
||||
"token_id": self.token_id,
|
||||
}
|
||||
|
||||
|
||||
class DiscoveryAuthenticator(BaseAuthenticator):
|
||||
"""
|
||||
Discovery+ authenticator with dynamic endpoint discovery.
|
||||
|
||||
Supports:
|
||||
- Anonymous authentication
|
||||
- User credentials (username/password)
|
||||
- Dynamic endpoint discovery from bootstrap
|
||||
|
||||
Note: Discovery+ uses simple token-based auth, not OAuth2.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
country: str = "de",
|
||||
settings_manager=None,
|
||||
credentials=None,
|
||||
config_dir: Optional[str] = None,
|
||||
http_manager=None,
|
||||
proxy_config: Optional[ProxyConfig] = None,
|
||||
):
|
||||
"""
|
||||
Initialize authenticator for specific country.
|
||||
|
||||
Args:
|
||||
country: ISO country code (e.g., 'de', 'uk')
|
||||
settings_manager: Settings manager instance
|
||||
credentials: Authentication credentials
|
||||
config_dir: Configuration directory path
|
||||
http_manager: HTTP manager instance
|
||||
proxy_config: Proxy configuration
|
||||
"""
|
||||
self.country = country
|
||||
self.home_market = HOME_MARKET_MAPPING.get(country, "emea")
|
||||
self.tenant = DEFAULT_TENANT
|
||||
self.env = DEFAULT_ENV
|
||||
self.device_id = DEFAULT_DEVICE_ID
|
||||
|
||||
# Store http_manager and proxy_config BEFORE calling super().__init__
|
||||
# BaseAuthenticator doesn't accept these parameters
|
||||
self._http_manager = http_manager
|
||||
self._proxy_config = proxy_config
|
||||
self._endpoints: Dict[str, str] = {}
|
||||
self._api_groups: Dict[str, Any] = {}
|
||||
self._routing: Dict[str, Any] = {}
|
||||
|
||||
# Call parent __init__ (BaseAuthenticator signature)
|
||||
super().__init__(
|
||||
provider_name="discovery",
|
||||
settings_manager=settings_manager,
|
||||
credentials=credentials,
|
||||
country=country,
|
||||
config_dir=config_dir,
|
||||
enable_kodi_integration=True,
|
||||
)
|
||||
|
||||
# Discover endpoints after initialization
|
||||
self._discover_endpoints()
|
||||
|
||||
@property
|
||||
def http_manager(self):
|
||||
"""
|
||||
Get or create HTTP manager.
|
||||
|
||||
Returns:
|
||||
HTTP manager instance
|
||||
|
||||
Raises:
|
||||
RuntimeError: If HTTP manager cannot be created
|
||||
"""
|
||||
if self._http_manager is not None:
|
||||
return self._http_manager
|
||||
|
||||
logger.warning(
|
||||
f"No HTTP manager available for {self.provider_name}, creating one"
|
||||
)
|
||||
|
||||
try:
|
||||
from ...base.network import HTTPManagerFactory
|
||||
|
||||
self._http_manager = HTTPManagerFactory.create_for_provider(
|
||||
self.provider_name,
|
||||
proxy_config=self._proxy_config,
|
||||
user_agent=DISCOVERY_USER_AGENT,
|
||||
timeout=30,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating HTTP manager: {e}")
|
||||
raise RuntimeError(
|
||||
f"Cannot create HTTP manager for {self.provider_name}: {e}"
|
||||
)
|
||||
|
||||
return self._http_manager
|
||||
|
||||
def _discover_endpoints(self) -> None:
|
||||
"""
|
||||
Discover API endpoints from bootstrap.
|
||||
|
||||
Raises:
|
||||
EndpointDiscoveryError: If endpoint discovery fails
|
||||
"""
|
||||
try:
|
||||
logger.debug(f"Discovering endpoints for Discovery+ ({self.country})")
|
||||
|
||||
response = self.http_manager.get(
|
||||
DISCOVERY_BOOTSTRAP_URL,
|
||||
operation="bootstrap",
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# Store routing info
|
||||
self._routing = data.get("routing", {})
|
||||
self._api_groups = data.get("apiGroups", {})
|
||||
|
||||
# Build endpoint map
|
||||
self._endpoints = {}
|
||||
for endpoint in data.get("endpoints", []):
|
||||
path = endpoint.get("path")
|
||||
api_group = endpoint.get("apiGroup")
|
||||
if path and api_group and api_group in self._api_groups:
|
||||
base_url = self._build_api_url(api_group)
|
||||
if base_url:
|
||||
self._endpoints[path] = f"{base_url}{path}"
|
||||
|
||||
logger.debug(f"Discovered {len(self._endpoints)} endpoints")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to discover endpoints: {e}")
|
||||
# Initialize empty dicts for fallback
|
||||
self._endpoints = {}
|
||||
self._api_groups = {}
|
||||
self._routing = {}
|
||||
|
||||
def _build_api_url(self, api_group: str) -> Optional[str]:
|
||||
"""
|
||||
Build API URL from apiGroup template.
|
||||
|
||||
Args:
|
||||
api_group: API group identifier
|
||||
|
||||
Returns:
|
||||
Constructed API URL or None if template not found
|
||||
"""
|
||||
if not self._api_groups or api_group not in self._api_groups:
|
||||
return None
|
||||
|
||||
template = self._api_groups[api_group].get("baseUrl", "")
|
||||
if not template:
|
||||
return None
|
||||
|
||||
# Replace placeholders
|
||||
replacements = {
|
||||
"{tenant}": self.tenant,
|
||||
"{homeMarket}": self.home_market,
|
||||
"{env}": self.env,
|
||||
"{domain}": "api.discoveryplus.com",
|
||||
}
|
||||
|
||||
url = template
|
||||
for key, value in replacements.items():
|
||||
url = url.replace(key, value)
|
||||
|
||||
return url
|
||||
|
||||
def get_endpoint(self, path: str) -> Optional[str]:
|
||||
"""
|
||||
Get full URL for an endpoint path.
|
||||
|
||||
Args:
|
||||
path: Endpoint path (e.g., '/token')
|
||||
|
||||
Returns:
|
||||
Full endpoint URL or None if not found
|
||||
"""
|
||||
if not self._endpoints:
|
||||
self._discover_endpoints()
|
||||
return self._endpoints.get(path)
|
||||
|
||||
@property
|
||||
def auth_endpoint(self) -> str:
|
||||
"""
|
||||
Get authentication endpoint URL (required by BaseAuthenticator).
|
||||
|
||||
For Discovery+, this returns the login endpoint for user auth
|
||||
or token endpoint for anonymous auth.
|
||||
|
||||
Returns:
|
||||
Authentication endpoint URL
|
||||
"""
|
||||
# Return login endpoint as the primary auth endpoint
|
||||
return self.login_endpoint
|
||||
|
||||
@property
|
||||
def token_endpoint(self) -> str:
|
||||
"""Get token endpoint URL"""
|
||||
endpoint = self.get_endpoint("/token")
|
||||
if endpoint:
|
||||
return endpoint
|
||||
return f"https://default.any-any.{self.env}.api.discoveryplus.com/token"
|
||||
|
||||
@property
|
||||
def login_endpoint(self) -> str:
|
||||
"""Get login endpoint URL"""
|
||||
endpoint = self.get_endpoint("/login")
|
||||
if endpoint:
|
||||
return endpoint
|
||||
bolt_any = self._build_api_url("bolt-any-homemarket")
|
||||
return (
|
||||
f"{bolt_any}/login" if bolt_any
|
||||
else "https://default.any-emea.prd.api.discoveryplus.com/login"
|
||||
)
|
||||
|
||||
@property
|
||||
def playback_endpoint(self) -> str:
|
||||
"""Get playback endpoint URL"""
|
||||
endpoint = self.get_endpoint("/any/playback/v1/playbackInfo")
|
||||
if endpoint:
|
||||
return endpoint
|
||||
bolt_any = self._build_api_url("bolt-any-homemarket")
|
||||
return (
|
||||
f"{bolt_any}/any/playback/v1/playbackInfo" if bolt_any
|
||||
else "https://default.any-any.prd.api.discoveryplus.com/any/playback/v1/playbackInfo"
|
||||
)
|
||||
|
||||
@property
|
||||
def cms_home_endpoint(self) -> str:
|
||||
"""Get CMS home endpoint URL"""
|
||||
endpoint = self.get_endpoint("/cms/routes/home")
|
||||
if endpoint:
|
||||
return endpoint
|
||||
bolt_any = self._build_api_url("bolt-any-homemarket")
|
||||
return (
|
||||
f"{bolt_any}/cms/routes/home" if bolt_any
|
||||
else "https://default.any-any.prd.api.discoveryplus.com/cms/routes/home"
|
||||
)
|
||||
|
||||
@property
|
||||
def cms_collections_endpoint(self) -> str:
|
||||
"""Get CMS collections endpoint URL"""
|
||||
endpoint = self.get_endpoint("/cms/collections")
|
||||
if endpoint:
|
||||
return endpoint
|
||||
bolt_any = self._build_api_url("bolt-any-homemarket")
|
||||
return (
|
||||
f"{bolt_any}/cms/collections" if bolt_any
|
||||
else "https://default.any-any.prd.api.discoveryplus.com/cms/collections"
|
||||
)
|
||||
|
||||
def _get_auth_headers(self) -> Dict[str, str]:
|
||||
"""
|
||||
Get headers for authentication request.
|
||||
|
||||
Returns:
|
||||
Dictionary of HTTP headers
|
||||
"""
|
||||
return {
|
||||
"User-Agent": DISCOVERY_USER_AGENT,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
def _build_auth_payload(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Build authentication payload based on credential type.
|
||||
|
||||
Returns:
|
||||
Authentication payload dictionary
|
||||
|
||||
Raises:
|
||||
InvalidCredentialsError: If no credentials available
|
||||
UnsupportedCredentialTypeError: If credential type not supported
|
||||
"""
|
||||
if not self.credentials:
|
||||
raise InvalidCredentialsError("No credentials available")
|
||||
|
||||
if isinstance(self.credentials, DiscoveryAnonymousCredentials):
|
||||
return {}
|
||||
elif isinstance(self.credentials, DiscoveryUserCredentials):
|
||||
return self.credentials.to_login_payload()
|
||||
else:
|
||||
raise UnsupportedCredentialTypeError(type(self.credentials).__name__)
|
||||
|
||||
def _create_token_from_response(
|
||||
self,
|
||||
response_data: Dict[str, Any]
|
||||
) -> BaseAuthToken:
|
||||
"""
|
||||
Create token object from API response.
|
||||
|
||||
Args:
|
||||
response_data: Response data from authentication API
|
||||
|
||||
Returns:
|
||||
Initialized token with auth level classified
|
||||
"""
|
||||
token = DiscoveryAuthToken.from_token_response(response_data)
|
||||
token.auth_level = self._classify_token(token)
|
||||
return token
|
||||
|
||||
def get_fallback_credentials(self) -> DiscoveryAnonymousCredentials:
|
||||
"""
|
||||
Get fallback credentials (anonymous).
|
||||
|
||||
Returns:
|
||||
Anonymous credentials instance
|
||||
"""
|
||||
return DiscoveryAnonymousCredentials(realm=DEFAULT_REALM)
|
||||
|
||||
def _perform_authentication(self) -> BaseAuthToken:
|
||||
"""
|
||||
Perform authentication using appropriate flow based on credential type.
|
||||
|
||||
Returns:
|
||||
Authenticated token
|
||||
|
||||
Raises:
|
||||
InvalidCredentialsError: If no credentials available
|
||||
UnsupportedCredentialTypeError: If credential type not supported
|
||||
"""
|
||||
if isinstance(self.credentials, DiscoveryAnonymousCredentials):
|
||||
return self._perform_anonymous_auth()
|
||||
elif isinstance(self.credentials, DiscoveryUserCredentials):
|
||||
return self._perform_user_auth()
|
||||
else:
|
||||
raise UnsupportedCredentialTypeError(type(self.credentials).__name__)
|
||||
|
||||
def _perform_anonymous_auth(self) -> BaseAuthToken:
|
||||
"""
|
||||
Perform anonymous authentication (GET /token?realm=bolt).
|
||||
|
||||
Returns:
|
||||
Anonymous authentication token
|
||||
"""
|
||||
logger.info(f"Performing anonymous authentication for {self.provider_name}")
|
||||
|
||||
params = {"realm": self.credentials.realm}
|
||||
headers = self._get_auth_headers()
|
||||
|
||||
response = self.http_manager.get(
|
||||
self.token_endpoint,
|
||||
operation="auth",
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
token_data = response.json()
|
||||
return self._create_token_from_response(token_data)
|
||||
|
||||
def _perform_user_auth(self) -> BaseAuthToken:
|
||||
"""
|
||||
Perform user authentication (POST /login with credentials).
|
||||
|
||||
Returns:
|
||||
User authentication token
|
||||
"""
|
||||
logger.info(f"Performing user authentication for {self.provider_name}")
|
||||
|
||||
headers = self._get_auth_headers()
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
payload = self._build_auth_payload()
|
||||
|
||||
response = self.http_manager.post(
|
||||
self.login_endpoint,
|
||||
operation="auth",
|
||||
headers=headers,
|
||||
json_data=payload,
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
token_data = response.json()
|
||||
return self._create_token_from_response(token_data)
|
||||
|
||||
def _refresh_token(self) -> Optional[BaseAuthToken]:
|
||||
"""
|
||||
Token refresh - Discovery tokens don't support refresh, re-authenticate.
|
||||
|
||||
Returns:
|
||||
None (triggers re-authentication)
|
||||
"""
|
||||
logger.debug(
|
||||
f"No refresh token support for {self.provider_name}, "
|
||||
"re-authenticating"
|
||||
)
|
||||
return None
|
||||
|
||||
def _classify_token(self, token: BaseAuthToken) -> TokenAuthLevel:
|
||||
"""
|
||||
Classify Discovery+ token based on authentication level.
|
||||
|
||||
Args:
|
||||
token: Token to classify
|
||||
|
||||
Returns:
|
||||
Token authentication level
|
||||
"""
|
||||
if hasattr(token, "anonymous"):
|
||||
if not token.anonymous:
|
||||
return TokenAuthLevel.USER_AUTHENTICATED
|
||||
return TokenAuthLevel.ANONYMOUS
|
||||
|
||||
def get_bearer_token(self, force_refresh: bool = False) -> str:
|
||||
"""
|
||||
Get bearer token for API requests.
|
||||
|
||||
Args:
|
||||
force_refresh: Force token refresh
|
||||
|
||||
Returns:
|
||||
Bearer token string
|
||||
"""
|
||||
token = self.authenticate(force_refresh=force_refresh)
|
||||
return token.bearer_token
|
||||
|
||||
def is_authenticated(self) -> bool:
|
||||
"""
|
||||
Check if currently authenticated with valid token.
|
||||
|
||||
Returns:
|
||||
True if authenticated with valid token
|
||||
"""
|
||||
return (
|
||||
self._current_token is not None and
|
||||
not self._current_token.is_expired
|
||||
)
|
||||
|
||||
def invalidate_token(self) -> None:
|
||||
"""Invalidate current token and clear from storage"""
|
||||
self._current_token = None
|
||||
try:
|
||||
self.settings_manager.clear_token(self.provider_name, self.country)
|
||||
except (AttributeError, KeyError, IOError, OSError):
|
||||
pass
|
||||
@@ -0,0 +1,271 @@
|
||||
# streaming_providers/providers/discovery/constants.py
|
||||
"""
|
||||
Discovery+ Provider Constants
|
||||
|
||||
All configuration values for the Discovery+ streaming provider.
|
||||
"""
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Final
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Enums for Type Safety
|
||||
# ============================================================================
|
||||
|
||||
class StreamingMode(str, Enum):
|
||||
"""Streaming modes"""
|
||||
LIVE = "live"
|
||||
VOD = "vod"
|
||||
|
||||
|
||||
class CDMMode(str, Enum):
|
||||
"""CDM (Content Decryption Module) modes"""
|
||||
EXTERNAL = "external"
|
||||
INTERNAL = "internal"
|
||||
|
||||
|
||||
class VideoQuality(str, Enum):
|
||||
"""Video quality settings"""
|
||||
BEST = "best"
|
||||
HD = "hd"
|
||||
SD = "sd"
|
||||
|
||||
|
||||
class DRMSystem(str, Enum):
|
||||
"""DRM systems"""
|
||||
WIDEVINE = "widevine"
|
||||
CLEARKEY = "clearkey"
|
||||
|
||||
|
||||
class AuthProvider(str, Enum):
|
||||
"""Authentication providers"""
|
||||
USERNAME_PASSWORD = "USERNAME_PASSWORD"
|
||||
ANONYMOUS = "anonymous"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Basic Configuration
|
||||
# ============================================================================
|
||||
|
||||
# Provider information
|
||||
DISCOVERY_LOGO: Final[
|
||||
str] = "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f1/Discovery%2B_logo.svg/2560px-Discovery%2B_logo.svg.png"
|
||||
|
||||
# Bootstrap endpoint for dynamic configuration
|
||||
DISCOVERY_BOOTSTRAP_URL: Final[
|
||||
str] = "https://default.any-any.prd.api.discoveryplus.com/session-context/headwaiter/v1/bootstrap"
|
||||
|
||||
# Default realm
|
||||
DEFAULT_REALM: Final[str] = "bolt"
|
||||
|
||||
# ============================================================================
|
||||
# Authentication Configuration
|
||||
# ============================================================================
|
||||
|
||||
# Supported auth types
|
||||
SUPPORTED_AUTH_TYPES: Final[List[str]] = [
|
||||
"anonymous", # Anonymous token
|
||||
"user_credentials", # Username/password login
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Country/Region Configuration
|
||||
# ============================================================================
|
||||
|
||||
# Supported countries (EMEA region)
|
||||
SUPPORTED_COUNTRIES: Final[List[str]] = [
|
||||
"de", "at", "ch", "dk", "fi", "no", "se", "it", "nl", "es", "uk", "ie",
|
||||
]
|
||||
|
||||
# Home market mapping (immutable)
|
||||
HOME_MARKET_MAPPING: Final[Dict[str, str]] = {
|
||||
"de": "emea", "at": "emea", "ch": "emea",
|
||||
"dk": "emea", "fi": "emea", "no": "emea", "se": "emea",
|
||||
"it": "emea", "nl": "emea", "es": "emea",
|
||||
"uk": "uk", "ie": "uk",
|
||||
}
|
||||
|
||||
# Tenant
|
||||
DEFAULT_TENANT: Final[str] = "dplus"
|
||||
|
||||
# Environment
|
||||
DEFAULT_ENV: Final[str] = "prd"
|
||||
|
||||
# Domain
|
||||
DEFAULT_DOMAIN: Final[str] = "api.discoveryplus.com"
|
||||
|
||||
# Default country
|
||||
DEFAULT_COUNTRY: Final[str] = "de"
|
||||
|
||||
# ============================================================================
|
||||
# CMS Configuration
|
||||
# ============================================================================
|
||||
|
||||
CMS_INCLUDE_PARAMS: Final[str] = "default,viewingHistory,isFavorite,contentAction,badges"
|
||||
CMS_PAGE_SIZE: Final[int] = 50
|
||||
|
||||
# Known collection IDs for TV channels (will be discovered dynamically, but these are fallbacks)
|
||||
CHANNEL_COLLECTIONS: Final[Dict[str, List[str]]] = {
|
||||
"de": [
|
||||
"158509151486067887066538918357725817419", # "Unsere TV-Kanäle" (Germany)
|
||||
"191019300316436018732917730141471355952", # DMAX Austria
|
||||
],
|
||||
"at": [
|
||||
"191019300316436018732917730141471355952", # DMAX Austria
|
||||
],
|
||||
"ch": [],
|
||||
"uk": [],
|
||||
"ie": [],
|
||||
}
|
||||
|
||||
# Collection item types that represent channels
|
||||
CHANNEL_ITEM_TYPES: Final[List[str]] = ["distributionChannel", "channel", "linearChannel", "liveChannel"]
|
||||
|
||||
# ============================================================================
|
||||
# User Agent Configuration
|
||||
# ============================================================================
|
||||
|
||||
DISCOVERY_USER_AGENT: Final[
|
||||
str] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36"
|
||||
|
||||
# ============================================================================
|
||||
# Device Configuration
|
||||
# ============================================================================
|
||||
|
||||
# Device ID (consistent across sessions for anonymous tracking)
|
||||
DEFAULT_DEVICE_ID: Final[str] = "a2f463fa-2052-4af1-ae16-26d8289c6b94"
|
||||
|
||||
|
||||
def get_default_device_info() -> Dict[str, any]:
|
||||
"""
|
||||
Return a fresh copy of default device info.
|
||||
|
||||
Returns:
|
||||
Dictionary containing device information
|
||||
"""
|
||||
return {
|
||||
"deviceId": DEFAULT_DEVICE_ID,
|
||||
"browser": {"name": "Chrome", "version": "144.0.0.0"},
|
||||
"make": "desktop",
|
||||
"model": "desktop",
|
||||
"os": {"name": "Linux", "version": "0.0.0"},
|
||||
"platform": "WEB",
|
||||
"deviceType": "web",
|
||||
"player": {
|
||||
"sdk": {"name": "Beam Player Desktop", "version": "6.14.0"},
|
||||
"mediaEngine": {
|
||||
"name": "GLUON_BROWSER",
|
||||
"version": "7.0.0",
|
||||
"labsConfigVersion": "playback_engine_gluon@web-1.2.0,playback_engine_gluon_disable_network_request_event@default-1.0.0,playback_engine_gluon_segment_download_during_drminit@1.0.0,playback_engine_gluon_abr_protocol_hybrid@1.0.0,playback_engine_gluon_initial_track_selection@1.0.0",
|
||||
},
|
||||
"playerView": {"height": 1080, "width": 1920},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_default_capabilities() -> Dict[str, any]:
|
||||
"""
|
||||
Return a fresh copy of default capabilities.
|
||||
|
||||
Returns:
|
||||
Dictionary containing device capabilities
|
||||
"""
|
||||
return {
|
||||
"manifests": {"formats": {"dash": {}}},
|
||||
"codecs": {
|
||||
"audio": {"decoders": [{"codec": "aac", "profiles": ["lc", "hev", "hev2"]}]},
|
||||
"video": {
|
||||
"decoders": [
|
||||
{
|
||||
"codec": "h264",
|
||||
"profiles": ["high", "main", "baseline"],
|
||||
"maxLevel": "5.2",
|
||||
"levelConstraints": {
|
||||
"width": {"min": 0, "max": 973},
|
||||
"height": {"min": 0, "max": 919},
|
||||
"framerate": {"min": 0, "max": 60},
|
||||
},
|
||||
},
|
||||
{
|
||||
"codec": "h265",
|
||||
"profiles": ["main10", "main"],
|
||||
"maxLevel": "5.2",
|
||||
"levelConstraints": {
|
||||
"width": {"min": 0, "max": 973},
|
||||
"height": {"min": 0, "max": 919},
|
||||
"framerate": {"min": 0, "max": 60},
|
||||
},
|
||||
},
|
||||
],
|
||||
"hdrFormats": [],
|
||||
},
|
||||
},
|
||||
"contentProtection": {
|
||||
"contentDecryptionModules": [
|
||||
{"drmKeySystem": "clearkey"},
|
||||
{"drmKeySystem": "widevine", "maxSecurityLevel": "l3"},
|
||||
]
|
||||
},
|
||||
"devicePlatform": {
|
||||
"memory": {"allocatedMemory": 0, "freeAvailableMemory": 1.7976931348623157e308},
|
||||
"network": {
|
||||
"capabilities": {"protocols": {"http": {"byteRangeRequests": True}}},
|
||||
"lastKnownStatus": {"networkTransportType": "unknown"},
|
||||
},
|
||||
"videoSink": {
|
||||
"capabilities": {"colorGamuts": ["standard"], "hdrFormats": []},
|
||||
"lastKnownStatus": {"height": 1080, "width": 1920},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Playback Configuration
|
||||
# ============================================================================
|
||||
|
||||
DEFAULT_PLAYBACK_SESSION_ID: Final[str] = "fb20e22e-abcd-490a-a83d-8c71f79e7435"
|
||||
DEFAULT_APPLICATION_SESSION_ID: Final[str] = "74985592-a061-4597-ab5b-51492d0fc2c6"
|
||||
|
||||
# ============================================================================
|
||||
# DRM Configuration
|
||||
# ============================================================================
|
||||
|
||||
DRM_SYSTEM_WIDEVINE: Final[str] = DRMSystem.WIDEVINE.value
|
||||
|
||||
|
||||
def get_drm_request_headers() -> Dict[str, str]:
|
||||
"""
|
||||
Return a fresh copy of DRM request headers.
|
||||
|
||||
Returns:
|
||||
Dictionary containing DRM request headers
|
||||
"""
|
||||
return {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"User-Agent": DISCOVERY_USER_AGENT,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Request Configuration
|
||||
# ============================================================================
|
||||
|
||||
DEFAULT_REQUEST_TIMEOUT: Final[int] = 30
|
||||
DEFAULT_MAX_RETRIES: Final[int] = 3
|
||||
DEFAULT_RETRY_DELAY: Final[int] = 1
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Error Codes
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class ErrorCode(str, Enum):
|
||||
"""Discovery+ error codes"""
|
||||
PLAYBACK_RESTRICTED = "PLAYBACK_RESTRICTED"
|
||||
UNAUTHORIZED = "UNAUTHORIZED"
|
||||
NOT_FOUND = "NOT_FOUND"
|
||||
GEOBLOCKED = "GEOBLOCKED"
|
||||
INVALID_CREDENTIALS = "INVALID_CREDENTIALS"
|
||||
AUTHENTICATION_FAILED = "AUTHENTICATION_FAILED"
|
||||
@@ -0,0 +1,90 @@
|
||||
# streaming_providers/providers/discovery/exceptions.py
|
||||
"""
|
||||
Discovery+ Provider Exceptions
|
||||
|
||||
Specific exception types for Discovery+ provider operations.
|
||||
"""
|
||||
|
||||
|
||||
class DiscoveryError(Exception):
|
||||
"""Base exception for Discovery provider errors"""
|
||||
pass
|
||||
|
||||
|
||||
class PlaybackRestrictedException(DiscoveryError):
|
||||
"""Exception raised when content playback is restricted"""
|
||||
|
||||
def __init__(self, reason: str = "", error_code: str = ""):
|
||||
"""
|
||||
Initialize playback restriction exception.
|
||||
|
||||
Args:
|
||||
reason: Human-readable reason for restriction
|
||||
error_code: Machine-readable error code
|
||||
"""
|
||||
self.reason = reason
|
||||
self.error_code = error_code
|
||||
|
||||
message = "Playback restricted"
|
||||
if reason:
|
||||
message = f"{message}: {reason}"
|
||||
if error_code:
|
||||
message = f"{message} ({error_code})"
|
||||
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class AuthenticationError(DiscoveryError):
|
||||
"""Base exception for authentication failures"""
|
||||
pass
|
||||
|
||||
|
||||
class InvalidCredentialsError(AuthenticationError):
|
||||
"""Invalid or missing credentials provided"""
|
||||
|
||||
def __init__(self, message: str = "Invalid or missing credentials"):
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class UnsupportedCredentialTypeError(AuthenticationError):
|
||||
"""Unsupported credential type provided"""
|
||||
|
||||
def __init__(self, credential_type: str):
|
||||
self.credential_type = credential_type
|
||||
super().__init__(f"Unsupported credential type: {credential_type}")
|
||||
|
||||
|
||||
class TokenExpiredError(AuthenticationError):
|
||||
"""Authentication token has expired"""
|
||||
|
||||
def __init__(self, message: str = "Authentication token has expired"):
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class EndpointDiscoveryError(DiscoveryError):
|
||||
"""Failed to discover API endpoints"""
|
||||
|
||||
def __init__(self, message: str = "Failed to discover API endpoints"):
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class ChannelNotFoundError(DiscoveryError):
|
||||
"""Requested channel not found"""
|
||||
|
||||
def __init__(self, channel_id: str):
|
||||
self.channel_id = channel_id
|
||||
super().__init__(f"Channel not found: {channel_id}")
|
||||
|
||||
|
||||
class ManifestFetchError(DiscoveryError):
|
||||
"""Failed to fetch streaming manifest"""
|
||||
|
||||
def __init__(self, message: str = "Failed to fetch streaming manifest"):
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class DRMConfigurationError(DiscoveryError):
|
||||
"""DRM configuration error"""
|
||||
|
||||
def __init__(self, message: str = "DRM configuration error"):
|
||||
super().__init__(message)
|
||||
@@ -0,0 +1,364 @@
|
||||
# streaming_providers/providers/discovery/models.py
|
||||
"""
|
||||
Discovery+ Provider Models
|
||||
|
||||
Data models for Discovery+ channels and content.
|
||||
"""
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any, Dict, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...base.models import StreamingChannel, DRMConfig, LicenseConfig
|
||||
|
||||
from .constants import StreamingMode, CDMMode, VideoQuality, DRMSystem
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiscoveryChannel:
|
||||
"""
|
||||
Represents a Discovery+ channel/stream with all necessary streaming data.
|
||||
|
||||
This is a Discovery-specific model that can be converted to the generic
|
||||
StreamingChannel model via to_streaming_channel().
|
||||
"""
|
||||
|
||||
# Core identification
|
||||
name: str
|
||||
channel_id: str # distributionChannel ID (first UUID)
|
||||
edit_id: Optional[str] = None # edit ID (second UUID) - used for playback
|
||||
|
||||
# Visual elements
|
||||
logo_url: Optional[str] = None
|
||||
|
||||
# Channel metadata
|
||||
channel_number: Optional[int] = None
|
||||
quality: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
# Streaming configuration
|
||||
mode: str = StreamingMode.LIVE.value
|
||||
session_manifest: bool = True
|
||||
manifest: Optional[str] = None
|
||||
manifest_script: Optional[str] = None
|
||||
|
||||
# CDM settings
|
||||
cdm_type: Optional[str] = None
|
||||
use_cdm: bool = True
|
||||
cdm: Optional[str] = None
|
||||
cdm_mode: str = CDMMode.EXTERNAL.value
|
||||
|
||||
# Video settings
|
||||
video: str = VideoQuality.BEST.value
|
||||
on_demand: bool = True
|
||||
speed_up: bool = False
|
||||
|
||||
# Additional metadata
|
||||
content_type: str = "LIVE"
|
||||
genre: Optional[str] = None
|
||||
language: str = "de"
|
||||
country: str = "DE"
|
||||
|
||||
# Streaming data
|
||||
license_url: Optional[str] = None
|
||||
certificate_url: Optional[str] = None
|
||||
streaming_format: Optional[str] = None
|
||||
|
||||
# Event data (for events/VOD)
|
||||
start_time: Optional[int] = None
|
||||
end_time: Optional[int] = None
|
||||
|
||||
# Internal tracking - provider-specific data
|
||||
raw_data: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def from_distribution_data(
|
||||
cls,
|
||||
distribution_data: Dict[str, Any],
|
||||
included_by_id: Dict[str, Any]
|
||||
) -> "DiscoveryChannel":
|
||||
"""
|
||||
Create DiscoveryChannel from distributionChannel API response.
|
||||
|
||||
Args:
|
||||
distribution_data: The channel data from API response.data
|
||||
included_by_id: Map of included resources keyed by "type:id"
|
||||
for resolving relationships (images, edits, etc.)
|
||||
|
||||
Returns:
|
||||
Initialized DiscoveryChannel with metadata and relationships resolved
|
||||
|
||||
Example:
|
||||
The included_by_id dictionary should be built from the 'included'
|
||||
array in the API response using:
|
||||
|
||||
included_by_id = {}
|
||||
for item in response.get('included', []):
|
||||
key = f"{item['type']}:{item['id']}"
|
||||
included_by_id[key] = item
|
||||
|
||||
Then create a channel from the first distribution data item:
|
||||
|
||||
channel = DiscoveryChannel.from_distribution_data(
|
||||
response['data'][0],
|
||||
included_by_id
|
||||
)
|
||||
print(channel.name) # Output: Eurosport 1
|
||||
"""
|
||||
attributes = distribution_data.get("attributes", {})
|
||||
relationships = distribution_data.get("relationships", {})
|
||||
|
||||
# Get edit ID
|
||||
edit_data = relationships.get("edit", {}).get("data", {})
|
||||
edit_id = edit_data.get("id")
|
||||
|
||||
# Extract logo
|
||||
logo_url = cls._extract_logo_url(relationships, included_by_id)
|
||||
|
||||
return cls(
|
||||
name=attributes.get("name", "Unknown Channel"),
|
||||
channel_id=distribution_data.get("id", ""),
|
||||
edit_id=edit_id,
|
||||
logo_url=logo_url,
|
||||
description=attributes.get("description", ""),
|
||||
mode=StreamingMode.LIVE.value,
|
||||
session_manifest=True,
|
||||
manifest_script=f"editid={edit_id}" if edit_id else None,
|
||||
cdm=f"editid={edit_id}" if edit_id else None,
|
||||
raw_data=distribution_data.copy(),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_event_api_data(
|
||||
cls,
|
||||
api_data: Dict[str, Any],
|
||||
included_by_id: Dict[str, Any],
|
||||
**kwargs: Any
|
||||
) -> "DiscoveryChannel":
|
||||
"""
|
||||
Create DiscoveryChannel from event/VOD API response data.
|
||||
|
||||
Args:
|
||||
api_data: Event/VOD data from API response
|
||||
included_by_id: Map of included resources
|
||||
**kwargs: Additional parameters to override defaults
|
||||
|
||||
Returns:
|
||||
Initialized DiscoveryChannel for VOD content
|
||||
"""
|
||||
attributes = api_data.get("attributes", {})
|
||||
relationships = api_data.get("relationships", {})
|
||||
|
||||
# Get edit ID (for VOD, the ID itself is often the edit_id)
|
||||
edit_id = api_data.get("id")
|
||||
|
||||
# Extract logo
|
||||
logo_url = cls._extract_logo_url(relationships, included_by_id)
|
||||
|
||||
channel = cls(
|
||||
name=attributes.get("name", "Unknown Event"),
|
||||
channel_id=api_data.get("id", ""),
|
||||
edit_id=edit_id,
|
||||
logo_url=logo_url,
|
||||
content_type="VOD",
|
||||
mode=StreamingMode.VOD.value,
|
||||
session_manifest=True,
|
||||
manifest_script=f"editid={edit_id}" if edit_id else None,
|
||||
cdm=f"editid={edit_id}" if edit_id else None,
|
||||
raw_data=api_data.copy(),
|
||||
**kwargs
|
||||
)
|
||||
|
||||
# Set event timestamps if available
|
||||
if "scheduleStart" in attributes:
|
||||
channel.start_time = cls._parse_timestamp(attributes["scheduleStart"])
|
||||
if "scheduleEnd" in attributes:
|
||||
channel.end_time = cls._parse_timestamp(attributes["scheduleEnd"])
|
||||
|
||||
return channel
|
||||
|
||||
@staticmethod
|
||||
def _extract_logo_url(
|
||||
relationships: Dict[str, Any],
|
||||
included_by_id: Dict[str, Any]
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Extract logo URL from relationship data.
|
||||
|
||||
Args:
|
||||
relationships: Relationship data from API response
|
||||
included_by_id: Map of included resources
|
||||
|
||||
Returns:
|
||||
Logo URL or None if not found
|
||||
"""
|
||||
images_data = relationships.get("images", {}).get("data", [])
|
||||
for image_ref in images_data:
|
||||
image_id = image_ref.get("id")
|
||||
image_type = image_ref.get("type")
|
||||
if image_id and image_type:
|
||||
key = f"{image_type}:{image_id}"
|
||||
image_item = included_by_id.get(key)
|
||||
if image_item:
|
||||
img_attrs = image_item.get("attributes", {})
|
||||
logo_url = (
|
||||
img_attrs.get("src") or
|
||||
img_attrs.get("url") or
|
||||
img_attrs.get("secureUrl")
|
||||
)
|
||||
if logo_url:
|
||||
return logo_url
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _parse_timestamp(timestamp_str: str) -> Optional[int]:
|
||||
"""
|
||||
Parse ISO 8601 timestamp to Unix timestamp.
|
||||
|
||||
Args:
|
||||
timestamp_str: ISO 8601 timestamp string (e.g., "2024-01-15T20:00:00Z")
|
||||
|
||||
Returns:
|
||||
Unix timestamp (seconds since epoch) or None if parsing fails
|
||||
"""
|
||||
try:
|
||||
# Handle both Z and +00:00 formats
|
||||
dt = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00'))
|
||||
return int(dt.timestamp())
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
return None
|
||||
|
||||
def set_streaming_data(
|
||||
self,
|
||||
manifest_url: str,
|
||||
license_url: Optional[str] = None,
|
||||
drm_token: Optional[str] = None,
|
||||
streaming_format: str = "dash",
|
||||
) -> None:
|
||||
"""
|
||||
Configure streaming-specific data.
|
||||
|
||||
Args:
|
||||
manifest_url: Manifest URL for streaming
|
||||
license_url: License URL for DRM
|
||||
drm_token: DRM token for authorization
|
||||
streaming_format: Streaming format (e.g., 'dash')
|
||||
"""
|
||||
self.manifest = manifest_url
|
||||
self.streaming_format = streaming_format
|
||||
|
||||
if license_url:
|
||||
self.license_url = license_url
|
||||
self.cdm_type = DRMSystem.WIDEVINE.value
|
||||
if drm_token:
|
||||
self.raw_data["drm_token"] = drm_token
|
||||
|
||||
def to_streaming_channel(self, provider_name: str = "discovery") -> "StreamingChannel":
|
||||
"""
|
||||
Convert to generic StreamingChannel object.
|
||||
|
||||
Args:
|
||||
provider_name: Provider identifier
|
||||
|
||||
Returns:
|
||||
Generic StreamingChannel instance with all data transferred
|
||||
"""
|
||||
from ...base.models import StreamingChannel, DRMConfig
|
||||
|
||||
# Create DRM config if we have license URL
|
||||
drm_config = None
|
||||
if self.license_url:
|
||||
headers = {}
|
||||
if "drm_token" in self.raw_data:
|
||||
headers["Authorization"] = f"Bearer {self.raw_data['drm_token']}"
|
||||
|
||||
drm_config = DRMConfig(
|
||||
system=DRMSystem.WIDEVINE, # ✅ Use system field
|
||||
priority=1,
|
||||
license=LicenseConfig(
|
||||
server_url=self.license_url,
|
||||
req_headers=json.dumps(headers),
|
||||
req_data="{CHA-RAW}",
|
||||
use_http_get_request=False,
|
||||
),
|
||||
)
|
||||
|
||||
channel = StreamingChannel(
|
||||
name=self.name,
|
||||
channel_id=self.channel_id,
|
||||
provider=provider_name,
|
||||
logo_url=self.logo_url,
|
||||
mode=self.mode,
|
||||
session_manifest=self.session_manifest,
|
||||
manifest=self.manifest,
|
||||
manifest_script=self.manifest_script,
|
||||
cdm_type=self.cdm_type,
|
||||
use_cdm=self.use_cdm,
|
||||
cdm=self.cdm,
|
||||
cdm_mode=self.cdm_mode,
|
||||
video=self.video,
|
||||
on_demand=self.on_demand,
|
||||
speed_up=self.speed_up,
|
||||
content_type=self.content_type,
|
||||
description=self.description,
|
||||
genre=self.genre,
|
||||
language=self.language,
|
||||
country=self.country,
|
||||
license_url=self.license_url,
|
||||
certificate_url=self.certificate_url,
|
||||
streaming_format=self.streaming_format,
|
||||
drm_config=drm_config,
|
||||
)
|
||||
|
||||
# Transfer raw_data
|
||||
channel.raw_data = self.raw_data.copy()
|
||||
|
||||
# Store edit_id and timestamps in raw_data for later use
|
||||
if self.edit_id:
|
||||
channel.raw_data["edit_id"] = self.edit_id
|
||||
if self.start_time:
|
||||
channel.raw_data["start_time"] = self.start_time
|
||||
if self.end_time:
|
||||
channel.raw_data["end_time"] = self.end_time
|
||||
|
||||
return channel
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Convert to dictionary format.
|
||||
|
||||
Returns:
|
||||
Dictionary representation of the channel
|
||||
"""
|
||||
return {
|
||||
"Name": self.name,
|
||||
"Id": self.channel_id,
|
||||
"EditId": self.edit_id,
|
||||
"LogoUrl": self.logo_url,
|
||||
"Mode": self.mode,
|
||||
"SessionManifest": self.session_manifest,
|
||||
"Manifest": self.manifest,
|
||||
"ManifestScript": self.manifest_script,
|
||||
"CdmType": self.cdm_type,
|
||||
"UseCdm": self.use_cdm,
|
||||
"Cdm": self.cdm,
|
||||
"CdmMode": self.cdm_mode,
|
||||
"Video": self.video,
|
||||
"OnDemand": self.on_demand,
|
||||
"SpeedUp": self.speed_up,
|
||||
"ContentType": self.content_type,
|
||||
"Description": self.description,
|
||||
"Language": self.language,
|
||||
"Country": self.country,
|
||||
"StreamingFormat": self.streaming_format,
|
||||
}
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f"DiscoveryChannel(name='{self.name}', id='{self.channel_id}', "
|
||||
f"edit_id='{self.edit_id}', type='{self.content_type}')"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.__str__()
|
||||
@@ -0,0 +1,806 @@
|
||||
# streaming_providers/providers/discovery/provider.py
|
||||
"""
|
||||
Discovery+ Streaming Provider
|
||||
|
||||
Provides access to Discovery+ live channels and VOD content with proper
|
||||
authentication, DRM support, and channel management.
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
import secrets
|
||||
import base64
|
||||
from datetime import datetime
|
||||
from typing import ClassVar, Dict, List, Optional, Any
|
||||
|
||||
from ...base.models import DRMConfig, DRMSystem, LicenseConfig
|
||||
from ...base.models.proxy_models import ProxyConfig
|
||||
from ...base.models.streaming_channel import StreamingChannel
|
||||
from ...base.provider import AuthType, StreamingProvider
|
||||
from ...base.utils.logger import logger
|
||||
|
||||
from .auth import (
|
||||
DiscoveryAuthenticator,
|
||||
DiscoveryAnonymousCredentials,
|
||||
DiscoveryUserCredentials,
|
||||
)
|
||||
from .constants import (
|
||||
DEFAULT_COUNTRY,
|
||||
DEFAULT_TENANT,
|
||||
DISCOVERY_LOGO,
|
||||
DISCOVERY_USER_AGENT,
|
||||
DRMSystem as DiscoveryDRMSystem,
|
||||
SUPPORTED_AUTH_TYPES,
|
||||
SUPPORTED_COUNTRIES,
|
||||
CMS_INCLUDE_PARAMS,
|
||||
CMS_PAGE_SIZE,
|
||||
CHANNEL_COLLECTIONS,
|
||||
CHANNEL_ITEM_TYPES,
|
||||
get_default_capabilities,
|
||||
get_default_device_info,
|
||||
get_drm_request_headers,
|
||||
)
|
||||
from .exceptions import (
|
||||
PlaybackRestrictedException,
|
||||
ChannelNotFoundError,
|
||||
ManifestFetchError,
|
||||
)
|
||||
from .models import DiscoveryChannel
|
||||
|
||||
|
||||
class DiscoveryProvider(StreamingProvider):
|
||||
"""
|
||||
Discovery+ streaming provider implementation with dynamic endpoint discovery.
|
||||
|
||||
Features:
|
||||
- Multi-country support (EMEA region)
|
||||
- Anonymous and user authentication
|
||||
- Live channels and VOD content
|
||||
- DRM-protected streams (Widevine)
|
||||
- Provider cache pattern for efficient channel management
|
||||
"""
|
||||
|
||||
# ============================================================================
|
||||
# STATIC METADATA
|
||||
# ============================================================================
|
||||
PROVIDER_LABEL: ClassVar[str] = "Discovery+"
|
||||
SUPPORTED_AUTH_TYPES: ClassVar[List[str]] = SUPPORTED_AUTH_TYPES
|
||||
PROVIDER_LOGO: ClassVar[str] = DISCOVERY_LOGO
|
||||
SUPPORTED_COUNTRIES: ClassVar[List[str]] = SUPPORTED_COUNTRIES
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
country: str = DEFAULT_COUNTRY,
|
||||
auth_type: str = "anonymous",
|
||||
username: Optional[str] = None,
|
||||
password: Optional[str] = None,
|
||||
config_dir: Optional[str] = None,
|
||||
settings_manager=None,
|
||||
proxy_config: Optional[ProxyConfig] = None,
|
||||
proxy_url: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Initialize Discovery+ provider.
|
||||
|
||||
Args:
|
||||
country: Country code (e.g., 'de', 'uk', 'at')
|
||||
auth_type: Authentication type ("anonymous" or "user_credentials")
|
||||
username: Username (required for user_credentials)
|
||||
password: Password (required for user_credentials)
|
||||
config_dir: Optional config directory override
|
||||
settings_manager: Settings manager instance
|
||||
proxy_config: Optional proxy configuration
|
||||
proxy_url: Optional proxy URL string
|
||||
|
||||
Raises:
|
||||
ValueError: If country not supported or credentials missing
|
||||
"""
|
||||
if not self.validate_country(country):
|
||||
supported = ", ".join(self.SUPPORTED_COUNTRIES)
|
||||
raise ValueError(
|
||||
f"Unsupported country: {country}. Discovery+ supports: {supported}"
|
||||
)
|
||||
|
||||
super().__init__(country=country)
|
||||
|
||||
self.auth_type = auth_type
|
||||
self._settings_manager = settings_manager
|
||||
|
||||
# Provider cache for DiscoveryChannel objects
|
||||
self._channels_cache: Dict[str, DiscoveryChannel] = {}
|
||||
|
||||
# Setup HTTP manager using abstraction
|
||||
self.http_manager = self._setup_http_manager(
|
||||
provider_name="discovery",
|
||||
proxy_config=proxy_config,
|
||||
proxy_url=proxy_url,
|
||||
config_dir=config_dir,
|
||||
user_agent=DISCOVERY_USER_AGENT,
|
||||
timeout=30,
|
||||
max_retries=3,
|
||||
)
|
||||
|
||||
# Create appropriate credentials
|
||||
if auth_type == "user_credentials":
|
||||
if not username or not password:
|
||||
raise ValueError(
|
||||
"Username and password required for user_credentials auth"
|
||||
)
|
||||
self.credentials = DiscoveryUserCredentials(
|
||||
username=username,
|
||||
password=password,
|
||||
)
|
||||
else:
|
||||
self.credentials = DiscoveryAnonymousCredentials()
|
||||
|
||||
# Create authenticator with dynamic endpoint discovery
|
||||
self.authenticator = DiscoveryAuthenticator(
|
||||
country=country,
|
||||
settings_manager=settings_manager,
|
||||
config_dir=config_dir,
|
||||
http_manager=self.http_manager,
|
||||
proxy_config=self.http_manager.config.proxy_config,
|
||||
credentials=self.credentials,
|
||||
)
|
||||
|
||||
# Authenticate
|
||||
try:
|
||||
self.bearer_token = self.authenticator.get_bearer_token()
|
||||
self.token_info = self.authenticator.get_token_info()
|
||||
logger.info(f"Authentication successful (type: {auth_type})")
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Could not authenticate during initialization: {e}"
|
||||
)
|
||||
self.bearer_token = None
|
||||
self.token_info = None
|
||||
|
||||
@property
|
||||
def provider_name(self) -> str:
|
||||
"""Provider identifier"""
|
||||
return "discovery"
|
||||
|
||||
@property
|
||||
def provider_label(self) -> str:
|
||||
"""Return country-specific label"""
|
||||
country_map = {
|
||||
"de": "Discovery+ Germany",
|
||||
"at": "Discovery+ Austria",
|
||||
"ch": "Discovery+ Switzerland",
|
||||
"dk": "Discovery+ Denmark",
|
||||
"fi": "Discovery+ Finland",
|
||||
"no": "Discovery+ Norway",
|
||||
"se": "Discovery+ Sweden",
|
||||
"it": "Discovery+ Italy",
|
||||
"nl": "Discovery+ Netherlands",
|
||||
"es": "Discovery+ Spain",
|
||||
"uk": "Discovery+ UK",
|
||||
"ie": "Discovery+ Ireland",
|
||||
}
|
||||
return country_map.get(
|
||||
self.country,
|
||||
f"Discovery+ ({self.country.upper()})"
|
||||
)
|
||||
|
||||
@property
|
||||
def provider_logo(self) -> str:
|
||||
"""Provider logo URL"""
|
||||
return self.PROVIDER_LOGO
|
||||
|
||||
@property
|
||||
def supported_auth_types(self) -> List[str]:
|
||||
"""Supported authentication types"""
|
||||
return self.SUPPORTED_AUTH_TYPES
|
||||
|
||||
@property
|
||||
def uses_dynamic_manifests(self) -> bool:
|
||||
"""Discovery+ uses session-based manifests"""
|
||||
return True
|
||||
|
||||
@property
|
||||
def implements_epg(self) -> bool:
|
||||
"""Discovery+ does not implement native EPG"""
|
||||
return False
|
||||
|
||||
def _get_auth_headers(self) -> Dict[str, str]:
|
||||
"""Get headers with authentication"""
|
||||
return self._build_provider_headers(
|
||||
base_headers={
|
||||
"User-Agent": DISCOVERY_USER_AGENT,
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
auth_type=AuthType.BEARER,
|
||||
)
|
||||
|
||||
def authenticate(self, **kwargs) -> str:
|
||||
"""Authenticate and return bearer token"""
|
||||
self.bearer_token = self.authenticator.get_bearer_token(
|
||||
force_refresh=kwargs.get("force_refresh", False)
|
||||
)
|
||||
self.token_info = self.authenticator.get_token_info()
|
||||
return self.bearer_token
|
||||
|
||||
def refresh_authentication(self) -> str:
|
||||
"""Force refresh authentication"""
|
||||
return self.authenticate(force_refresh=True)
|
||||
|
||||
def is_anonymous(self) -> bool:
|
||||
"""Check if using anonymous authentication"""
|
||||
if self.token_info:
|
||||
return self.token_info.get("anonymous", True)
|
||||
return True
|
||||
|
||||
def get_channels(
|
||||
self,
|
||||
fetch_manifests: bool = False,
|
||||
populate_streaming_data: bool = True,
|
||||
**kwargs,
|
||||
) -> List[StreamingChannel]:
|
||||
"""
|
||||
Fetch available channels from Discovery+ CMS.
|
||||
|
||||
Args:
|
||||
fetch_manifests: Whether to immediately populate streaming data
|
||||
populate_streaming_data: Whether to populate streaming data
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
List of StreamingChannel objects
|
||||
"""
|
||||
try:
|
||||
headers = self._get_auth_headers()
|
||||
|
||||
# Fetch home route with all needed includes
|
||||
url = self.authenticator.cms_home_endpoint
|
||||
params = {
|
||||
"include": CMS_INCLUDE_PARAMS,
|
||||
"decorators": "viewingHistory,isFavorite,contentAction,badges",
|
||||
"page[items.size]": CMS_PAGE_SIZE,
|
||||
}
|
||||
|
||||
response = self.http_manager.get(
|
||||
url,
|
||||
operation="cms",
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# Extract channels and cache DiscoveryChannel objects
|
||||
discovery_channels = self._extract_distribution_channels(data)
|
||||
|
||||
# Cache the original DiscoveryChannel objects
|
||||
self._channels_cache = {
|
||||
ch.channel_id: ch for ch in discovery_channels
|
||||
}
|
||||
|
||||
# Convert to StreamingChannel objects
|
||||
streaming_channels = [
|
||||
ch.to_streaming_channel(self.provider_name)
|
||||
for ch in discovery_channels
|
||||
]
|
||||
|
||||
logger.info(
|
||||
f"Found {len(streaming_channels)} distribution channels"
|
||||
)
|
||||
|
||||
# Populate streaming data if requested
|
||||
if fetch_manifests and populate_streaming_data and streaming_channels:
|
||||
streaming_channels = self.populate_streaming_data(
|
||||
streaming_channels
|
||||
)
|
||||
|
||||
return streaming_channels
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching channels: {e}")
|
||||
return []
|
||||
|
||||
def _extract_distribution_channels(
|
||||
self, data: Dict
|
||||
) -> List[DiscoveryChannel]:
|
||||
"""
|
||||
Extract distributionChannel objects from CMS response.
|
||||
|
||||
Args:
|
||||
data: Parsed JSON response from CMS home route
|
||||
|
||||
Returns:
|
||||
List of DiscoveryChannel objects (not StreamingChannel!)
|
||||
"""
|
||||
channels = []
|
||||
|
||||
# Build lookup of included items by ID and type
|
||||
included_by_id = {}
|
||||
for item in data.get("included", []):
|
||||
item_id = item.get("id")
|
||||
item_type = item.get("type")
|
||||
if item_id and item_type:
|
||||
key = f"{item_type}:{item_id}"
|
||||
included_by_id[key] = item
|
||||
|
||||
# Find all distributionChannel objects in included
|
||||
for item in data.get("included", []):
|
||||
if item.get("type") == "distributionChannel":
|
||||
channel = self._create_discovery_channel_from_distribution(
|
||||
item, included_by_id
|
||||
)
|
||||
if channel:
|
||||
channels.append(channel)
|
||||
|
||||
return channels
|
||||
|
||||
@staticmethod
|
||||
def _create_discovery_channel_from_distribution(
|
||||
distribution_data: Dict,
|
||||
included_by_id: Dict
|
||||
) -> Optional[DiscoveryChannel]:
|
||||
"""
|
||||
Create a DiscoveryChannel from distributionChannel object.
|
||||
|
||||
Args:
|
||||
distribution_data: The distributionChannel object from CMS
|
||||
included_by_id: Lookup dictionary of all included items
|
||||
|
||||
Returns:
|
||||
DiscoveryChannel object or None if invalid
|
||||
"""
|
||||
try:
|
||||
# Use the factory method from DiscoveryChannel
|
||||
discovery_channel = DiscoveryChannel.from_distribution_data(
|
||||
distribution_data, included_by_id
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Created channel: {discovery_channel.name} "
|
||||
f"(edit_id: {discovery_channel.edit_id})"
|
||||
)
|
||||
|
||||
return discovery_channel
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error creating channel from distribution data: {e}"
|
||||
)
|
||||
return None
|
||||
|
||||
def _build_playback_payload(self, edit_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Build playback payload for the API.
|
||||
|
||||
Args:
|
||||
edit_id: Content edit ID
|
||||
|
||||
Returns:
|
||||
Playback request payload
|
||||
"""
|
||||
# Generate Google PAL nonce
|
||||
nonce = secrets.token_bytes(256)
|
||||
google_pal_nonce = base64.b64encode(nonce).decode('ascii')
|
||||
|
||||
return {
|
||||
"appBundle": "dplus",
|
||||
"advertisingInfo": {
|
||||
"adBlockerDetection": False,
|
||||
"debug": {},
|
||||
"device": {},
|
||||
"googlePALNonce": google_pal_nonce,
|
||||
"isLimitedAdTracking": 0,
|
||||
"nielsenAppId": "",
|
||||
"ssaiProvider": {"version": "2.2.0"},
|
||||
},
|
||||
"consumptionType": "streaming",
|
||||
"deviceInfo": get_default_device_info(),
|
||||
"editId": edit_id,
|
||||
"capabilities": get_default_capabilities(),
|
||||
"gdpr": False,
|
||||
"firstPlay": False,
|
||||
"playbackSessionId": str(uuid.uuid4()),
|
||||
"applicationSessionId": str(uuid.uuid4()),
|
||||
"userPreferences": {
|
||||
"videoQuality": "best",
|
||||
"uiLanguage": f"{self.country}-DE".upper()
|
||||
},
|
||||
"features": ["mlp"],
|
||||
}
|
||||
|
||||
def get_playback_info(self, edit_id: str, **kwargs) -> Dict:
|
||||
"""
|
||||
Get playback information using edit_id.
|
||||
|
||||
Args:
|
||||
edit_id: Edit ID for content
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
Playback info dictionary
|
||||
|
||||
Raises:
|
||||
ValueError: If edit_id not provided
|
||||
PlaybackRestrictedException: If playback restricted
|
||||
"""
|
||||
if not edit_id:
|
||||
raise ValueError("edit_id is required for playback")
|
||||
|
||||
headers = self._get_auth_headers()
|
||||
payload = self._build_playback_payload(edit_id)
|
||||
|
||||
try:
|
||||
response = self.http_manager.post(
|
||||
self.authenticator.playback_endpoint,
|
||||
operation="playback",
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if response.status_code >= 400:
|
||||
error_text = response.text
|
||||
logger.error(f"Playback error response: {error_text}")
|
||||
if "PLAYBACK_RESTRICTED" in error_text:
|
||||
raise PlaybackRestrictedException(
|
||||
reason=f"Playback restricted for {edit_id}",
|
||||
error_code="PLAYBACK_RESTRICTED"
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except PlaybackRestrictedException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise ManifestFetchError(
|
||||
f"Error getting playback info for edit_id {edit_id}: {e}"
|
||||
)
|
||||
|
||||
def extract_streaming_data(self, playback_data: Dict) -> Dict[str, Any]:
|
||||
"""
|
||||
Extract streaming URLs and DRM info from playback response.
|
||||
|
||||
Args:
|
||||
playback_data: Playback API response
|
||||
|
||||
Returns:
|
||||
Dictionary with manifest_url, license_url, drm_auth, etc.
|
||||
"""
|
||||
result = {
|
||||
"manifest_url": None,
|
||||
"license_url": None,
|
||||
"drm_auth": None,
|
||||
"streaming_format": "dash",
|
||||
"drm_expiration": None,
|
||||
"fallback_manifest": None,
|
||||
}
|
||||
|
||||
try:
|
||||
# Extract main manifest
|
||||
manifest = playback_data.get("manifest", {})
|
||||
if manifest:
|
||||
result["manifest_url"] = manifest.get("url")
|
||||
result["streaming_format"] = manifest.get("format", "dash")
|
||||
|
||||
# Extract fallback manifest if available
|
||||
fallback = playback_data.get("fallback", {})
|
||||
if fallback:
|
||||
fallback_manifest = fallback.get("manifest", {})
|
||||
if fallback_manifest:
|
||||
result["fallback_manifest"] = fallback_manifest.get("url")
|
||||
|
||||
# Extract DRM info
|
||||
drm = playback_data.get("drm", {})
|
||||
if drm:
|
||||
schemes = drm.get("schemes", {})
|
||||
widevine = schemes.get("widevine", {})
|
||||
if widevine:
|
||||
result["license_url"] = widevine.get("licenseUrl")
|
||||
result["drm_expiration"] = drm.get("expirationDate")
|
||||
|
||||
# Extract auth token from license URL if present
|
||||
if result["license_url"] and "auth=" in result["license_url"]:
|
||||
import urllib.parse
|
||||
parsed = urllib.parse.urlparse(result["license_url"])
|
||||
query = urllib.parse.parse_qs(parsed.query)
|
||||
if "auth" in query:
|
||||
result["drm_auth"] = query["auth"][0]
|
||||
|
||||
# Extract CDN info
|
||||
cdn = playback_data.get("cdn", {})
|
||||
if cdn:
|
||||
result["cdn_provider"] = cdn.get("provider")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error extracting streaming data: {e}")
|
||||
|
||||
return result
|
||||
|
||||
def populate_streaming_data(
|
||||
self,
|
||||
channels: List[StreamingChannel],
|
||||
max_retries: int = 3,
|
||||
) -> List[StreamingChannel]:
|
||||
"""
|
||||
Populate streaming data for channels using cached DiscoveryChannel data.
|
||||
|
||||
Args:
|
||||
channels: List of StreamingChannel objects to populate
|
||||
max_retries: Maximum retry attempts per channel
|
||||
|
||||
Returns:
|
||||
List of successfully populated channels
|
||||
"""
|
||||
successful_channels = []
|
||||
|
||||
for channel in channels:
|
||||
retries = 0
|
||||
success = False
|
||||
is_restricted = False
|
||||
|
||||
while retries < max_retries and not success and not is_restricted:
|
||||
try:
|
||||
# Get DiscoveryChannel from cache
|
||||
disco_channel = self._channels_cache.get(channel.channel_id)
|
||||
|
||||
if not disco_channel:
|
||||
logger.warning(
|
||||
f"Channel {channel.name} not in cache, skipping"
|
||||
)
|
||||
break
|
||||
|
||||
# Get edit_id from cached DiscoveryChannel
|
||||
edit_id = disco_channel.edit_id
|
||||
|
||||
if not edit_id:
|
||||
logger.warning(
|
||||
f"No edit_id for channel {channel.name}, skipping"
|
||||
)
|
||||
break
|
||||
|
||||
logger.debug(
|
||||
f"Getting playback info for: {channel.name} "
|
||||
f"(edit_id: {edit_id}, attempt {retries + 1})"
|
||||
)
|
||||
|
||||
playback_data = self.get_playback_info(edit_id=edit_id)
|
||||
streaming_data = self.extract_streaming_data(playback_data)
|
||||
|
||||
if streaming_data["manifest_url"]:
|
||||
channel.manifest = streaming_data["manifest_url"]
|
||||
channel.streaming_format = streaming_data["streaming_format"]
|
||||
|
||||
if streaming_data["license_url"]:
|
||||
channel.license_url = streaming_data["license_url"]
|
||||
channel.cdm_type = DiscoveryDRMSystem.WIDEVINE.value
|
||||
|
||||
# Store DRM token in DiscoveryChannel cache
|
||||
if streaming_data["drm_auth"]:
|
||||
disco_channel.raw_data["drm_auth"] = (
|
||||
streaming_data["drm_auth"]
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Streaming data populated for: {channel.name}"
|
||||
)
|
||||
successful_channels.append(channel)
|
||||
success = True
|
||||
else:
|
||||
raise ManifestFetchError("No manifest URL in response")
|
||||
|
||||
except PlaybackRestrictedException as e:
|
||||
logger.warning(
|
||||
f"Playback restricted for {channel.name}: {e}"
|
||||
)
|
||||
is_restricted = True
|
||||
|
||||
except Exception as e:
|
||||
retries += 1
|
||||
if retries < max_retries:
|
||||
logger.debug(
|
||||
f"Retry {retries}/{max_retries} for "
|
||||
f"{channel.name}: {e}"
|
||||
)
|
||||
time.sleep(1)
|
||||
else:
|
||||
logger.error(
|
||||
f"Failed to get streaming data for "
|
||||
f"{channel.name}: {e}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Streaming data population complete: "
|
||||
f"{len(successful_channels)}/{len(channels)}"
|
||||
)
|
||||
return successful_channels
|
||||
|
||||
def enrich_channel_data(
|
||||
self,
|
||||
channel: StreamingChannel,
|
||||
**kwargs
|
||||
) -> Optional[StreamingChannel]:
|
||||
"""
|
||||
Get manifest URL for a specific channel and properly configure DRM.
|
||||
|
||||
Args:
|
||||
channel: StreamingChannel to enrich
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
Enriched StreamingChannel or None if failed
|
||||
"""
|
||||
try:
|
||||
# Get DiscoveryChannel from cache
|
||||
disco_channel = self._channels_cache.get(channel.channel_id)
|
||||
|
||||
if not disco_channel:
|
||||
logger.warning(
|
||||
f"Channel {channel.name} not in cache, cannot enrich"
|
||||
)
|
||||
return None
|
||||
|
||||
edit_id = disco_channel.edit_id
|
||||
|
||||
if not edit_id:
|
||||
logger.warning(f"No edit_id found for channel {channel.name}")
|
||||
return None
|
||||
|
||||
playback_data = self.get_playback_info(edit_id=edit_id)
|
||||
streaming_data = self.extract_streaming_data(playback_data)
|
||||
|
||||
if not streaming_data["manifest_url"]:
|
||||
return None
|
||||
|
||||
channel.manifest = streaming_data["manifest_url"]
|
||||
channel.streaming_format = streaming_data["streaming_format"]
|
||||
|
||||
# Configure DRM if license URL is present
|
||||
if streaming_data["license_url"]:
|
||||
license_headers = get_drm_request_headers()
|
||||
|
||||
if streaming_data["drm_auth"]:
|
||||
license_headers["preauthorization"] = (
|
||||
streaming_data["drm_auth"]
|
||||
)
|
||||
|
||||
drm_config = DRMConfig(
|
||||
system=DRMSystem.WIDEVINE,
|
||||
priority=1,
|
||||
license=LicenseConfig(
|
||||
server_url=streaming_data["license_url"],
|
||||
req_headers=json.dumps(license_headers),
|
||||
req_data="{CHA-RAW}",
|
||||
use_http_get_request=False,
|
||||
),
|
||||
)
|
||||
channel.drm_config = drm_config
|
||||
channel.cdm_type = DiscoveryDRMSystem.WIDEVINE.value
|
||||
|
||||
return channel
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error enriching channel data for {channel.name}: {e}"
|
||||
)
|
||||
return None
|
||||
|
||||
def get_manifest(self, channel_id: str, **kwargs) -> Optional[str]:
|
||||
"""
|
||||
Get manifest URL for a specific channel by ID.
|
||||
|
||||
Args:
|
||||
channel_id: Channel identifier
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
Manifest URL or None
|
||||
|
||||
Raises:
|
||||
ChannelNotFoundError: If channel not in cache
|
||||
"""
|
||||
try:
|
||||
# Get DiscoveryChannel from cache
|
||||
disco_channel = self._channels_cache.get(channel_id)
|
||||
|
||||
if not disco_channel:
|
||||
raise ChannelNotFoundError(channel_id)
|
||||
|
||||
edit_id = disco_channel.edit_id
|
||||
|
||||
if not edit_id:
|
||||
raise ManifestFetchError(
|
||||
f"No edit_id for channel {channel_id}"
|
||||
)
|
||||
|
||||
playback_data = self.get_playback_info(edit_id=edit_id)
|
||||
streaming_data = self.extract_streaming_data(playback_data)
|
||||
return streaming_data.get("manifest_url")
|
||||
|
||||
except (ChannelNotFoundError, ManifestFetchError):
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error getting manifest for channel {channel_id}: {e}"
|
||||
)
|
||||
return None
|
||||
|
||||
def get_drm(self, channel_id: str, **kwargs) -> List[DRMConfig]:
|
||||
"""
|
||||
Get all DRM configurations for a channel by ID.
|
||||
|
||||
Args:
|
||||
channel_id: Channel identifier
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
List of DRM configurations
|
||||
"""
|
||||
try:
|
||||
# Get DiscoveryChannel from cache
|
||||
disco_channel = self._channels_cache.get(channel_id)
|
||||
|
||||
if not disco_channel:
|
||||
logger.warning(
|
||||
f"Channel {channel_id} not in cache for DRM"
|
||||
)
|
||||
return []
|
||||
|
||||
edit_id = disco_channel.edit_id
|
||||
|
||||
if not edit_id:
|
||||
return []
|
||||
|
||||
playback_data = self.get_playback_info(edit_id=edit_id)
|
||||
streaming_data = self.extract_streaming_data(playback_data)
|
||||
|
||||
if not streaming_data["license_url"]:
|
||||
return []
|
||||
|
||||
license_headers = get_drm_request_headers()
|
||||
|
||||
if streaming_data["drm_auth"]:
|
||||
license_headers["preauthorization"] = streaming_data["drm_auth"]
|
||||
|
||||
drm_config = DRMConfig(
|
||||
system=DRMSystem.WIDEVINE,
|
||||
priority=1,
|
||||
license=LicenseConfig(
|
||||
server_url=streaming_data["license_url"],
|
||||
req_headers=json.dumps(license_headers),
|
||||
req_data="{CHA-RAW}",
|
||||
use_http_get_request=False,
|
||||
),
|
||||
)
|
||||
|
||||
return [drm_config]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error getting DRM configs for channel {channel_id}: {e}"
|
||||
)
|
||||
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 (not yet implemented).
|
||||
|
||||
Args:
|
||||
channel_id: Channel identifier
|
||||
start_time: Start time for EPG data
|
||||
end_time: End time for EPG data
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
Empty list (not implemented)
|
||||
"""
|
||||
logger.info(
|
||||
f"EPG data requested for channel {channel_id} - "
|
||||
"not yet implemented"
|
||||
)
|
||||
return []
|
||||
Reference in New Issue
Block a user