mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-09-30 13:02:19 +02:00
905 lines
31 KiB
Python
905 lines
31 KiB
Python
# 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] = {}
|
|
|
|
# Playback info cache: {edit_id: (expiry_timestamp, playback_data)}
|
|
# Entries are valid until expiry_timestamp (derived from drm_expiration,
|
|
# or 23 hours from fetch time as a safe fallback for the 24h token window).
|
|
self._playback_cache: Dict[str, tuple] = {}
|
|
|
|
# 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.
|
|
# Priority:
|
|
# 1. Explicit username/password passed at construction time
|
|
# 2. Stored user credentials from CredentialManager (same /config path
|
|
# the authenticator uses — does not depend on injected settings_manager)
|
|
# 3. Anonymous fallback
|
|
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:
|
|
stored = None
|
|
try:
|
|
from ...base.auth.credential_manager import CredentialManager
|
|
_cm = CredentialManager(config_dir=config_dir)
|
|
stored = _cm.load_credentials("discovery", country)
|
|
logger.debug(
|
|
f"CredentialManager lookup for discovery ({country}): "
|
|
f"{type(stored).__name__ if stored else 'None'}"
|
|
)
|
|
except Exception as e:
|
|
logger.debug(f"Could not load stored credentials via CredentialManager: {e}")
|
|
|
|
if stored and stored.validate() and hasattr(stored, "username"):
|
|
logger.info(
|
|
f"Found stored user credentials for discovery ({country}), "
|
|
"upgrading from anonymous to user authentication"
|
|
)
|
|
self.credentials = DiscoveryUserCredentials(
|
|
username=stored.username,
|
|
password=stored.password,
|
|
)
|
|
self.auth_type = "user_credentials"
|
|
else:
|
|
logger.debug(
|
|
f"No stored user credentials found for discovery ({country}), "
|
|
"using anonymous authentication"
|
|
)
|
|
self.credentials = DiscoveryAnonymousCredentials()
|
|
|
|
# Create authenticator with dynamic endpoint discovery.
|
|
# settings_manager is intentionally not passed so the authenticator
|
|
# self-constructs its own SettingsManager via the backward-compat path,
|
|
# identical to how JoynAuthenticator works. This ensures it always
|
|
# resolves the correct /config path via the environment variable.
|
|
self.authenticator = DiscoveryAuthenticator(
|
|
country=country,
|
|
settings_manager=None,
|
|
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: {self.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,
|
|
"server": {
|
|
"deviceId": "",
|
|
"iabTCFString": "",
|
|
"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 _get_cached_playback_info(self, edit_id: str) -> Dict:
|
|
"""
|
|
Return playback info for edit_id, using a cache keyed on expiry time.
|
|
|
|
Tokens issued by Discovery+ are valid for 24 hours. The cache entry
|
|
is kept until the drm_expiration timestamp returned in the response
|
|
(minus a 60-second safety margin). If no expiration is present in the
|
|
response, a fallback TTL of 23 hours is used.
|
|
|
|
Args:
|
|
edit_id: Content edit ID
|
|
|
|
Returns:
|
|
Playback info dictionary (from cache or freshly fetched)
|
|
"""
|
|
now = time.time()
|
|
cached = self._playback_cache.get(edit_id)
|
|
|
|
if cached:
|
|
expiry, data = cached
|
|
if now < expiry:
|
|
logger.debug(
|
|
f"Playback cache hit for edit_id {edit_id} "
|
|
f"(expires in {int(expiry - now)}s)"
|
|
)
|
|
return data
|
|
else:
|
|
logger.debug(f"Playback cache expired for edit_id {edit_id}, re-fetching")
|
|
|
|
data = self.get_playback_info(edit_id=edit_id)
|
|
|
|
# Determine expiry from drm_expiration field if available
|
|
expiry = None
|
|
try:
|
|
drm_expiration_str = (
|
|
data.get("drm", {}).get("expirationDate")
|
|
)
|
|
if drm_expiration_str:
|
|
from datetime import timezone
|
|
dt = datetime.fromisoformat(
|
|
drm_expiration_str.replace("Z", "+00:00")
|
|
)
|
|
# Apply a 60-second safety margin
|
|
expiry = dt.timestamp() - 60
|
|
except Exception as e:
|
|
logger.debug(f"Could not parse drm_expiration: {e}")
|
|
|
|
if expiry is None or expiry <= now:
|
|
# Fallback: 23 hours from now
|
|
expiry = now + 23 * 3600
|
|
logger.debug(
|
|
f"Using fallback 23h TTL for playback cache (edit_id: {edit_id})"
|
|
)
|
|
|
|
self._playback_cache[edit_id] = (expiry, data)
|
|
logger.debug(
|
|
f"Playback cache stored for edit_id {edit_id} "
|
|
f"(expires in {int(expiry - now)}s)"
|
|
)
|
|
return data
|
|
|
|
@staticmethod
|
|
def extract_streaming_data(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_cached_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_cached_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()
|
|
|
|
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_cached_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_cached_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()
|
|
|
|
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 [] |