Files
script.service.ultimate/lib/streaming_providers/providers/discovery/playback_manager.py
T
2026-05-04 18:23:25 +02:00

540 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# streaming_providers/providers/discovery/playback_manager.py
"""
Discovery+ Playback Manager
Handles all streaming/playback concerns:
- Fetching and caching playback info (tokens valid for 24 hours)
- Extracting manifest URLs and DRM details from playback responses
- Building DRMConfig objects
- Populating StreamingChannel objects with streaming data
"""
import base64
import secrets
import time
import uuid
from datetime import datetime
from typing import Any, Dict, List, Optional
from ...base.models import DRMConfig, DRMSystem, LicenseConfig, StreamingChannel
from ...base.utils.logger import logger
from .constants import (
BLACKLISTED_CDN_PREFIXES,
CDN_MAX_RETRIES,
PlatformOS,
get_default_capabilities,
get_default_device_info,
get_drm_request_headers,
)
from .exceptions import ManifestFetchError, PlaybackRestrictedException
class DiscoveryPlaybackManager:
"""
Manages playback info fetching and streaming data population for Discovery+.
Responsibilities:
- Building the playback request payload (device capabilities, DRM, ads)
- Fetching playback info from the Discovery+ playback endpoint
- Caching playback tokens keyed on ``edit_id`` until DRM expiration
- Extracting manifest URLs and DRM scheme details from playback responses
- Building DRMConfig objects for Widevine / PlayReady
- Populating a list of StreamingChannel objects (``populate_streaming_data``)
The shared ``_channels_cache`` dict is owned by the provider and passed in
so the manager can resolve ``edit_id`` values without extra API calls.
"""
def __init__(
self,
provider, # DiscoveryProvider — avoid circular import
playback_cache: Dict[str, tuple],
):
self._provider = provider
# {edit_id: (expiry_timestamp, playback_data)}
self._playback_cache = playback_cache
# =========================================================================
# Public interface
# =========================================================================
def populate_streaming_data(
self,
channels: List[StreamingChannel],
max_retries: int = 3,
) -> List[StreamingChannel]:
"""
Populate streaming data for a list of StreamingChannel objects.
``channel.channel_id`` is the ``edit_id`` (playback identifier), so
it is passed directly to ``get_cached_playback_info`` — no cache
indirection required.
Args:
channels: 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:
edit_id = channel.channel_id
if not edit_id:
logger.warning(
f"No edit_id (channel_id) for {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_with_cdn_check(
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"]:
drm_config = self.build_drm_config(streaming_data)
if drm_config:
channel.drm_config = drm_config
channel.license_url = streaming_data["license_url"]
channel.cdm_type = streaming_data["drm_system"]
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 get_playback_info(self, edit_id: str, **kwargs) -> Dict:
"""
Fetch playback information for the given edit_id.
Args:
edit_id: Content edit ID.
**kwargs: Additional parameters (unused, reserved for future use).
Returns:
Playback info dictionary from the Discovery+ playback endpoint.
Raises:
ValueError: If edit_id is not provided.
PlaybackRestrictedException: If the server returns PLAYBACK_RESTRICTED.
ManifestFetchError: On any other HTTP / parse error.
"""
if not edit_id:
raise ValueError("edit_id is required for playback")
headers = self._provider.get_auth_headers()
payload = self._build_playback_payload(edit_id)
try:
response = self._provider.http_manager.post(
self._provider.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}"
)
# =========================================================================
# Playback cache
# =========================================================================
def get_cached_playback_info(self, edit_id: str) -> Dict:
"""
Return playback info for edit_id, using a time-based cache.
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 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"
)
# CDN check + cache write are both handled inside get_playback_info_with_cdn_check.
return self.get_playback_info_with_cdn_check(edit_id=edit_id)
def _store_playback_cache(self, edit_id: str, data: Dict) -> None:
"""
Compute the expiry timestamp from playback data and write it to the cache.
Args:
edit_id: Content edit ID used as the cache key.
data: Playback API response dict.
"""
now = time.time()
expiry = None
try:
drm_expiration_str = data.get("drm", {}).get("expirationDate")
if drm_expiration_str:
dt = datetime.fromisoformat(
drm_expiration_str.replace("Z", "+00:00")
)
expiry = dt.timestamp() - 60
except Exception as e:
logger.debug(f"Could not parse drm_expiration: {e}")
if expiry is None or expiry <= now:
expiry = now + 23 * 3600
logger.debug(
f"Using fallback 23h TTL for playback cache "
f"(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)"
)
# =========================================================================
# Payload / streaming data helpers
# =========================================================================
def _build_playback_payload(self, edit_id: str) -> Dict[str, Any]:
"""
Build the playback request payload for the Discovery+ playback API.
Args:
edit_id: Content edit ID.
Returns:
JSON-serialisable payload dict.
"""
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(
self._provider.platform_os,
device_id=self._provider.authenticator.device_id,
),
"editId": edit_id,
"capabilities": get_default_capabilities(
self._provider.platform_os
),
"gdpr": False,
"firstPlay": False,
"playbackSessionId": str(uuid.uuid4()),
"applicationSessionId": str(uuid.uuid4()),
"userPreferences": {
"videoQuality": "best",
"uiLanguage": f"{self._provider.country.lower()}-{self._provider.country.upper()}",
},
"features": ["mlp"],
}
@staticmethod
def extract_streaming_data(playback_data: Dict) -> Dict[str, Any]:
"""
Extract streaming URLs and DRM info from a playback response.
Detects whichever DRM scheme is present — either ``widevine``
(Linux/Chrome) or ``playready`` (Windows/Edge) — so callers can build
the correct DRMConfig without knowing which platform produced the
response.
Args:
playback_data: Playback API response dict.
Returns:
Dictionary with keys:
manifest_url – DASH manifest URL
license_url – DRM license server URL (or None)
drm_system – 'widevine' | 'playready' | None
drm_auth – JWT auth token from license URL (or None)
streaming_format – 'dash' (always for Discovery+)
drm_expiration – ISO-8601 expiration string (or None)
fallback_manifest – Fallback manifest URL (or None)
"""
result: Dict[str, Any] = {
"manifest_url": None,
"license_url": None,
"drm_system": None,
"drm_auth": None,
"streaming_format": "dash",
"drm_expiration": None,
"fallback_manifest": None,
}
try:
manifest = playback_data.get("manifest", {})
if manifest:
result["manifest_url"] = manifest.get("url")
result["streaming_format"] = manifest.get("format", "dash")
fallback = playback_data.get("fallback", {})
if fallback:
fallback_manifest = fallback.get("manifest", {})
if fallback_manifest:
result["fallback_manifest"] = fallback_manifest.get("url")
drm = playback_data.get("drm", {})
if drm:
result["drm_expiration"] = drm.get("expirationDate")
schemes = drm.get("schemes", {})
for scheme_name in ("widevine", "playready"):
scheme = schemes.get(scheme_name, {})
if scheme and scheme.get("licenseUrl"):
result["license_url"] = scheme["licenseUrl"]
result["drm_system"] = scheme_name
if "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]
break
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
# =========================================================================
# CDN helpers
# =========================================================================
@staticmethod
def _extract_cdn_prefix(url: str) -> Optional[str]:
"""
Extract the CDN prefix from a manifest URL.
The prefix is the first label of the hostname.
Example: ``https://cf.dplus.eu.prd.media.max.com/...`` → ``"cf"``
Args:
url: Manifest URL string.
Returns:
Lowercase CDN prefix, or None if the URL cannot be parsed.
"""
try:
import urllib.parse
hostname = urllib.parse.urlparse(url).hostname or ""
prefix = hostname.split(".")[0]
return prefix.lower() if prefix else None
except Exception:
return None
@staticmethod
def _is_blacklisted_cdn(url: str) -> bool:
"""
Return True if the manifest URL's CDN prefix is blacklisted.
Args:
url: Manifest URL string.
Returns:
True when the CDN prefix appears in BLACKLISTED_CDN_PREFIXES.
"""
prefix = DiscoveryPlaybackManager._extract_cdn_prefix(url)
return prefix in BLACKLISTED_CDN_PREFIXES
def get_playback_info_with_cdn_check(self, edit_id: str) -> Dict:
"""
Fetch playback info, retrying up to CDN_MAX_RETRIES times if the
resolved manifest URL lands on a blacklisted CDN.
Each retry issues a completely fresh playback request (bypassing the
cache) so Discovery+ may route us to a different CDN node. After all
retries are exhausted the last response is returned regardless of CDN.
Args:
edit_id: Content edit ID.
Returns:
Playback info dictionary — CDN-filtered when possible.
"""
playback_data: Dict = {}
cdn_prefix: Optional[str] = None
for attempt in range(1, CDN_MAX_RETRIES + 1):
# Always fetch fresh — we need a new request to get a new CDN assignment.
playback_data = self.get_playback_info(edit_id=edit_id)
manifest_url = playback_data.get("manifest", {}).get("url", "")
cdn_prefix = self._extract_cdn_prefix(manifest_url)
if not self._is_blacklisted_cdn(manifest_url):
if attempt > 1:
logger.info(
f"CDN check passed on attempt {attempt}/{CDN_MAX_RETRIES} "
f"(cdn={cdn_prefix}, edit_id={edit_id})"
)
self._store_playback_cache(edit_id, playback_data)
return playback_data
logger.warning(
f"Blacklisted CDN '{cdn_prefix}' on attempt {attempt}/{CDN_MAX_RETRIES} "
f"for edit_id={edit_id} — retrying"
)
# All retries exhausted — accept whatever CDN we got last.
logger.warning(
f"All {CDN_MAX_RETRIES} CDN retries exhausted for edit_id={edit_id}; "
f"accepting blacklisted CDN '{cdn_prefix}'"
)
self._store_playback_cache(edit_id, playback_data)
return playback_data
def build_drm_config(
self, streaming_data: Dict[str, Any]
) -> Optional[DRMConfig]:
"""
Build a DRMConfig from extracted streaming data.
Selects the correct DRMSystem based on the scheme returned by the
server (``streaming_data["drm_system"]``). Falls back to the active
``platform_os`` when the scheme is absent.
Supported schemes:
'widevine' → DRMSystem.WIDEVINE (Linux/Chrome path)
'playready' → DRMSystem.PLAYREADY (Windows/Edge path)
Args:
streaming_data: Dict returned by ``extract_streaming_data()``.
Returns:
DRMConfig instance, or None if no license URL is available.
"""
license_url = streaming_data.get("license_url")
if not license_url:
return None
drm_system_str = streaming_data.get("drm_system")
if drm_system_str == "playready":
drm_system = DRMSystem.PLAYREADY
elif drm_system_str == "widevine":
drm_system = DRMSystem.WIDEVINE
else:
drm_system = (
DRMSystem.PLAYREADY
if self._provider.platform_os == PlatformOS.WINDOWS
else DRMSystem.WIDEVINE
)
logger.debug(
f"drm_system not in streaming_data, inferred from platform_os "
f"({self._provider.platform_os.value}): {drm_system.name}"
)
license_headers = get_drm_request_headers(self._provider.platform_os)
return DRMConfig(
system=drm_system,
priority=1,
license=LicenseConfig(
server_url=license_url,
req_headers=license_headers,
req_data="{CHA-RAW}",
use_http_get_request=False,
),
)