Add discovery

This commit is contained in:
Nirvana
2026-02-16 16:00:22 +01:00
parent dbafdacb7b
commit 6246c13004
7 changed files with 2208 additions and 0 deletions
+1
View File
@@ -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 []