From 3c1f24bb7c2e0f0c587680b29717f577c3e22789 Mon Sep 17 00:00:00 2001 From: Nirvana Date: Mon, 23 Mar 2026 17:46:10 +0100 Subject: [PATCH] Prepare recordings --- .../providers/magenta2/provider.py | 169 +++++++++++++++--- .../providers/magentaeu/provider.py | 104 ++++------- 2 files changed, 173 insertions(+), 100 deletions(-) diff --git a/lib/streaming_providers/providers/magenta2/provider.py b/lib/streaming_providers/providers/magenta2/provider.py index 0180987..84692a1 100644 --- a/lib/streaming_providers/providers/magenta2/provider.py +++ b/lib/streaming_providers/providers/magenta2/provider.py @@ -30,6 +30,7 @@ from .constants import ( DRM_SYSTEM_WIDEVINE, ERROR_CODES, MAGENTA2_CLIENT_IDS, + MAGENTA2_FALLBACK_ACCOUNT_URI, MAGENTA2_LOGO, MAGENTA2_PLATFORMS, MODE_LIVE, @@ -37,6 +38,14 @@ from .constants import ( SUPPORTED_COUNTRIES, ) from .discovery import DiscoveryService +from ..lib_theplatform import ( + TheplatformChannel, + fetch_distribution_rights, + fetch_entitled_channels_feed, + extract_persona_jwt, + build_licence_url, + build_widevine_drm_config, +) from .endpoint_manager import EndpointManager from .models import Magenta2Channel, Magenta2PlaybackRestrictedException from .token_flow_manager import PersonaResult @@ -314,6 +323,10 @@ class Magenta2Provider(StreamingProvider): # get_manifest() / get_drm() can inject smil_base_url automatically # without requiring callers (e.g. DRMOperations) to know about it. self._recording_url_cache: Dict[str, str] = {} + # station_id → mpd_url / release_pid; populated by get_channels() so + # that get_manifest() / get_drm() can bypass SMIL for live channels. + self._live_manifest_cache: Dict[str, str] = {} + self._live_pid_cache: Dict[str, str] = {} logger.info("Magenta2 provider initialization completed successfully") @staticmethod @@ -794,40 +807,84 @@ class Magenta2Provider(StreamingProvider): **kwargs, ) -> List[StreamingChannel]: """ - Fetch available channels from Magenta2 API + Fetch available channels from Magenta2 via the entitled-channels flow. + + Uses lib_theplatform to: + 1. Call getApplicableDistributionRights (license_service_url from manifest). + 2. Fetch the entitled-channels feed filtered by those rights. + 3. Convert each TheplatformChannel to a StreamingChannel. """ try: - headers = self._get_api_headers(require_auth=False) + cid = f"{self.session_id}::{self._generate_call_id()}" + user_agent = self.platform_config["user_agent"] - # Use the discovered channel stations endpoint - url = None - if self.endpoint_manager: - url = self.endpoint_manager.get_endpoint("channel_stations") - if not url: - url = self.endpoint_manager.get_endpoint("channel_list") - if not url and self.endpoint_manager.has_endpoint("mpx_feed_entitledChannelsFeed"): - url = self.endpoint_manager.get_endpoint("mpx_feed_entitledChannelsFeed") - - # Final fallback - if not url: - url = "https://feed.entertainment.tv.theplatform.eu/f/mdeprod/mdeprod-channel-stations-main" - - url += "?lang=short-de&sort=dt%24displayChannelNumber&range=1-1000" - - logger.debug(f"Fetching channels from: {url}") - response = self.http_manager.get( - url, operation="api", headers=headers, timeout=DEFAULT_REQUEST_TIMEOUT + # ── Step 1: resolve distribution rights ────────────────────────── + rights_url = ( + self.provider_config.manifest.mpx.license_service_url + if self.provider_config and self.provider_config.manifest + else None ) - response.raise_for_status() - channels_data = response.json() + if not rights_url: + raise RuntimeError( + "No license_service_url available – configuration discovery may have failed" + ) - channels = self._process_channel_stations_response_optimized( - channels_data, prefer_highest_quality + distribution_rights = fetch_distribution_rights( + http_manager=self.http_manager, + rights_url=rights_url, + cid=cid, + user_agent=user_agent, + timeout=DEFAULT_REQUEST_TIMEOUT, ) + # ── Step 2: fetch entitled-channels feed ───────────────────────── + feed_url = ( + self.endpoint_manager.get_endpoint("mpx_feed_entitledChannelsFeed") + if self.endpoint_manager + else None + ) or "https://feed.entertainment.tv.theplatform.eu/f/mdeprod/mdeprod-entitled-channels" + + tp_channels: List[TheplatformChannel] = fetch_entitled_channels_feed( + http_manager=self.http_manager, + feed_url=feed_url, + distribution_rights=distribution_rights, + cid=cid, + user_agent=user_agent, + timeout=DEFAULT_REQUEST_TIMEOUT, + ) + + # ── Step 3: convert to StreamingChannel ────────────────────────── + channels: List[StreamingChannel] = [] + for tp_ch in tp_channels: + try: + magenta2_channel = Magenta2Channel( + name=tp_ch.station_id, # placeholder; no display name in this feed + channel_id=tp_ch.station_id, + logo_url=None, + mode=MODE_LIVE, + content_type=CONTENT_TYPE_LIVE, + country=self.country, + raw_data=tp_ch.extra, + ) + streaming_channel = magenta2_channel.to_streaming_channel( + provider_name=self.provider_name + ) + streaming_channel.channel_number = tp_ch.channel_number + streaming_channel.manifest = tp_ch.mpd_url + if tp_ch.hls_url: + streaming_channel.hls_url = tp_ch.hls_url + + # Cache manifest URL and releasePid for SMIL-free playback + self._live_manifest_cache[tp_ch.station_id] = tp_ch.mpd_url + self._live_pid_cache[tp_ch.station_id] = tp_ch.release_pid + + channels.append(streaming_channel) + except Exception as exc: + logger.warning(f"get_channels: skipping channel {tp_ch.station_id}: {exc}") + logger.info( - f"Successfully fetched {len(channels)} channels for country {self.country} " - f"(quality preference: {'highest' if prefer_highest_quality else 'lowest'})" + f"Successfully fetched {len(channels)} entitled channels " + f"for country {self.country}" ) return channels @@ -1275,7 +1332,16 @@ class Magenta2Provider(StreamingProvider): def get_manifest( self, content_id: str, content_type: str = CONTENT_TYPE_LIVE, **kwargs ) -> Optional[str]: - """Get MPD manifest URL via SmilManager.""" + """Get MPD manifest URL. + + For live channels whose manifest was already fetched by get_channels(), + return the cached MPD URL directly without a SMIL round-trip. + VOD and recordings fall through to SmilManager as before. + """ + if content_type == CONTENT_TYPE_LIVE and content_id in self._live_manifest_cache: + logger.debug(f"get_manifest: cache hit for live channel {content_id}") + return self._live_manifest_cache[content_id] + if not self._smil_manager: raise RuntimeError("SmilManager not available") # Inject smil_base_url from recording cache when not already supplied. @@ -1330,7 +1396,54 @@ class Magenta2Provider(StreamingProvider): def get_drm( self, content_id: str, content_type: str = CONTENT_TYPE_LIVE, **kwargs ) -> List[DRMConfig]: - """Get DRM configuration via SmilManager.""" + """Get DRM configuration. + + For live channels whose releasePid was cached by get_channels(), build + the Widevine licence URL directly using lib_theplatform — no SMIL fetch. + VOD and recordings fall through to SmilManager as before. + """ + if content_type == CONTENT_TYPE_LIVE and content_id in self._live_pid_cache: + release_pid = self._live_pid_cache[content_id] + logger.debug( + f"get_drm: building licence directly for live channel {content_id} " + f"(releasePid: {release_pid})" + ) + try: + persona_token = self._ensure_authenticated() + raw_jwt = extract_persona_jwt(persona_token) + if not raw_jwt: + logger.error("get_drm: failed to extract persona JWT") + return [] + + widevine_endpoint = ( + self.endpoint_manager.get_endpoint("widevine_license") + if self.endpoint_manager + else None + ) + if not widevine_endpoint: + logger.error("get_drm: no widevine_license endpoint available") + return [] + + account_uri = ( + self.provider_config.manifest.mpx.get_account_uri() + if self.provider_config and self.provider_config.manifest + else None + ) or MAGENTA2_FALLBACK_ACCOUNT_URI + + licence_url = build_licence_url( + widevine_endpoint=widevine_endpoint, + release_pid=release_pid, + persona_jwt=raw_jwt, + account_uri=account_uri, + ) + return [build_widevine_drm_config( + licence_url=licence_url, + user_agent=self.platform_config["user_agent"], + )] + except Exception as exc: + logger.error(f"get_drm: direct licence build failed for {content_id}: {exc}") + return [] + if not self._smil_manager: raise RuntimeError("SmilManager not available") # Same smil_base_url injection as get_manifest — keeps both consistent. diff --git a/lib/streaming_providers/providers/magentaeu/provider.py b/lib/streaming_providers/providers/magentaeu/provider.py index 7e2e5ca..1634b62 100644 --- a/lib/streaming_providers/providers/magentaeu/provider.py +++ b/lib/streaming_providers/providers/magentaeu/provider.py @@ -5,11 +5,17 @@ import datetime from typing import ClassVar, Dict, List, Optional from ...base.auth import UserPasswordCredentials -from ...base.models import DRMConfig, DRMSystem, LicenseConfig, StreamingChannel, Event +from ...base.models import DRMConfig, StreamingChannel, Event from ...base.models.proxy_models import ProxyConfig from ...base.network import ProxyConfigManager from ...base.provider import StreamingProvider from ...base.utils.logger import logger +from ..lib_theplatform import ( + build_catchup_url, + build_licence_url, + build_widevine_drm_config, + parse_bifrost_epg_channel, +) from .auth import MagentaAuthenticator from .constants import ( API_ENDPOINTS, @@ -213,40 +219,38 @@ class MagentaEUProvider(StreamingProvider): channels = [] for channel_data in response_data["channels"]: try: - # Extract channel information + tp_channel = parse_bifrost_epg_channel(channel_data) + if not tp_channel: + continue + + # Bifrost-specific fields not covered by TheplatformChannel title = channel_data.get("title", "Unknown Channel") logo = channel_data.get("channel_logo", "") - manifest = channel_data.get("video_src_dash", "") - pid = channel_data.get("pid_dash", "") - station_id = channel_data.get("station_id", "") - channel_number = channel_data.get("channel_number", "") media_pid = channel_data.get("media_pid", "") is_audio = channel_data.get("is_audio", False) - # Build manifest script + # Build manifest script from bifrost metadata fields manifest_script_parts = [] - if channel_number: - manifest_script_parts.append(f"chno={channel_number}") - if station_id: - manifest_script_parts.append(f"epgid={station_id}") + if tp_channel.channel_number: + manifest_script_parts.append(f"chno={tp_channel.channel_number}") + if tp_channel.station_id: + manifest_script_parts.append(f"epgid={tp_channel.station_id}") if media_pid: manifest_script_parts.append(f"media={media_pid}") - manifest_script = " ".join(manifest_script_parts) if manifest_script_parts else "" - # Create streaming channel streaming_channel = StreamingChannel( name=title, - content_id=station_id or pid or title, + content_id=tp_channel.station_id or tp_channel.release_pid or title, provider=self.provider_name, logo_url=logo, mode="live", session_manifest=False, - manifest=manifest, + manifest=tp_channel.mpd_url, manifest_script=manifest_script, cdm_type=DRM_SYSTEM_WIDEVINE, use_cdm=True, - cdm=f"pid={pid}" if pid else "", + cdm=f"pid={tp_channel.release_pid}" if tp_channel.release_pid else "", cdm_mode="external", video="best", on_demand=True, @@ -257,7 +261,6 @@ class MagentaEUProvider(StreamingProvider): language=get_language(self.country), streaming_format=STREAMING_FORMAT_DASH, ) - channels.append(streaming_channel) except Exception as e: @@ -348,38 +351,22 @@ class MagentaEUProvider(StreamingProvider): Returns: Manifest URL with catchup time parameters, or None if channel not found """ - from ...base.utils.timestamp_converter import TimestampConverter - # Ensure cache is populated first if not self._ensure_channels_cache(): logger.warning(f"Cannot get catchup manifest for {channel_id}, channels cache unavailable") return None - # First get the base manifest URL base_manifest = self.get_manifest(channel_id, **kwargs) - if not base_manifest: logger.warning(f"Channel {channel_id} not found or has no manifest") return None try: - # Convert epoch seconds to ISO basic format (YYYYMMDDTHHMMSS) - start_iso = TimestampConverter.epoch_to_iso( - start_time, format_type="basic", as_utc=True - ) - end_iso = TimestampConverter.epoch_to_iso(end_time, format_type="basic", as_utc=True) - - # Build the catchup manifest URL - # Check if the manifest already has query parameters - separator = "&" if "?" in base_manifest else "?" - catchup_manifest = f"{base_manifest}{separator}begin={start_iso}&end={end_iso}" - + catchup_manifest = build_catchup_url(base_manifest, start_time, end_time) logger.debug(f"Catchup manifest for channel {channel_id}: {catchup_manifest}") return catchup_manifest - except Exception as e: logger.error(f"Error building catchup manifest for channel {channel_id}: {e}") - # Fall back to live manifest if catchup formatting fails logger.warning(f"Falling back to live manifest for channel {channel_id}") return base_manifest @@ -416,10 +403,6 @@ class MagentaEUProvider(StreamingProvider): def get_drm_config(self, channel: StreamingChannel, **kwargs) -> Optional[DRMConfig]: """Get DRM configuration for channel with correct authentication""" try: - import base64 - import json - from urllib.parse import quote # For URL encoding - from .auth import MagentaAuthToken, decode_jwt from .constants import ACC_URL @@ -443,16 +426,12 @@ class MagentaEUProvider(StreamingProvider): logger.warning("No bearer token available for DRM config") return None - # Remove 'Bearer ' prefix if present if access_token.startswith("Bearer "): access_token = access_token[7:] - # Decode JWT token to get account details + # Decode JWT to extract account ID and persona token try: - # Get current token from authenticator current_token = self.authenticator.current_token - - # Use the helper method if token is MagentaAuthToken if isinstance(current_token, MagentaAuthToken) and hasattr( current_token, "get_jwt_claims" ): @@ -461,14 +440,11 @@ class MagentaEUProvider(StreamingProvider): logger.warning("Failed to get JWT claims from token") return None else: - # Fallback: use decode_jwt helper decoded_payload = decode_jwt(access_token, verify=False) - except Exception as e: logger.warning(f"Error decoding JWT token for DRM: {e}") return None - # Extract account information from JWT payload account_id = decoded_payload.get("dc_cts_accountId", "") persona_token = decoded_payload.get("dc_cts_personaToken", "") @@ -476,36 +452,20 @@ class MagentaEUProvider(StreamingProvider): logger.warning("Missing account ID or persona token in JWT payload") return None - # CORRECT: Build account URI and URL-encode it account_uri = f"{ACC_URL}/{account_id}" - encoded_account_uri = quote(account_uri, safe="") # URL encode, NOT base64 - - # Build license URL with parameters - license_url = ( - f"{WV_URL}{pid}&" f"token={persona_token}&" f"account={encoded_account_uri}" + license_url = build_licence_url( + widevine_endpoint=WV_URL, + release_pid=pid, + persona_jwt=persona_token, + account_uri=account_uri, ) - logger.debug(f"License URL created: {license_url[:100]}...") - # Create DRM config with minimal headers - drm_config = DRMConfig( - system=DRMSystem.WIDEVINE, - priority=1, - license=LicenseConfig.create_with_req_data( - req_data_template="{CHA-RAW}", - server_url=license_url, - req_headers=json.dumps( - { - "User-Agent": USER_AGENT, - "Content-Type": "application/octet-stream", - "Origin": get_base_url(self.country), - "Referer": f"{get_base_url(self.country)}/", - } - ), - use_http_get_request=False, - ), + drm_config = build_widevine_drm_config( + licence_url=license_url, + user_agent=USER_AGENT, + origin=get_base_url(self.country), ) - logger.debug(f"DRM config created successfully for channel {channel.name}") return drm_config @@ -579,4 +539,4 @@ class MagentaEUProvider(StreamingProvider): elif country: return f"Magenta TV ({country.upper()})" else: - return "Magenta TV" + return "Magenta TV" \ No newline at end of file