From 6246c13004eedb379a69652a77530368bdc31a85 Mon Sep 17 00:00:00 2001 From: Nirvana Date: Mon, 16 Feb 2026 16:00:22 +0100 Subject: [PATCH] Add discovery --- .gitignore | 1 + .../providers/discovery/__init__.py | 87 ++ .../providers/discovery/auth.py | 589 +++++++++++++ .../providers/discovery/constants.py | 271 ++++++ .../providers/discovery/exceptions.py | 90 ++ .../providers/discovery/models.py | 364 ++++++++ .../providers/discovery/provider.py | 806 ++++++++++++++++++ 7 files changed, 2208 insertions(+) create mode 100644 .gitignore create mode 100644 lib/streaming_providers/providers/discovery/__init__.py create mode 100644 lib/streaming_providers/providers/discovery/auth.py create mode 100644 lib/streaming_providers/providers/discovery/constants.py create mode 100644 lib/streaming_providers/providers/discovery/exceptions.py create mode 100644 lib/streaming_providers/providers/discovery/models.py create mode 100644 lib/streaming_providers/providers/discovery/provider.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b0ac3ed --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.aider* diff --git a/lib/streaming_providers/providers/discovery/__init__.py b/lib/streaming_providers/providers/discovery/__init__.py new file mode 100644 index 0000000..d0e2ebe --- /dev/null +++ b/lib/streaming_providers/providers/discovery/__init__.py @@ -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" \ No newline at end of file diff --git a/lib/streaming_providers/providers/discovery/auth.py b/lib/streaming_providers/providers/discovery/auth.py new file mode 100644 index 0000000..71ead64 --- /dev/null +++ b/lib/streaming_providers/providers/discovery/auth.py @@ -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 \ No newline at end of file diff --git a/lib/streaming_providers/providers/discovery/constants.py b/lib/streaming_providers/providers/discovery/constants.py new file mode 100644 index 0000000..0dbaa2a --- /dev/null +++ b/lib/streaming_providers/providers/discovery/constants.py @@ -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" \ No newline at end of file diff --git a/lib/streaming_providers/providers/discovery/exceptions.py b/lib/streaming_providers/providers/discovery/exceptions.py new file mode 100644 index 0000000..231ccad --- /dev/null +++ b/lib/streaming_providers/providers/discovery/exceptions.py @@ -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) \ No newline at end of file diff --git a/lib/streaming_providers/providers/discovery/models.py b/lib/streaming_providers/providers/discovery/models.py new file mode 100644 index 0000000..b9e4259 --- /dev/null +++ b/lib/streaming_providers/providers/discovery/models.py @@ -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__() \ No newline at end of file diff --git a/lib/streaming_providers/providers/discovery/provider.py b/lib/streaming_providers/providers/discovery/provider.py new file mode 100644 index 0000000..6bb8858 --- /dev/null +++ b/lib/streaming_providers/providers/discovery/provider.py @@ -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 [] \ No newline at end of file