mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-09-28 20:12:19 +02:00
540 lines
20 KiB
Python
540 lines
20 KiB
Python
# 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,
|
||
),
|
||
) |