From 4241c721c9b1a2b162a1bc6fa02956dd5db29e5b Mon Sep 17 00:00:00 2001 From: Nirvana Date: Wed, 21 Oct 2026 18:54:09 +0200 Subject: [PATCH] magenta2: add fallback releasepid --- .../providers/magenta2/channel_manager.py | 96 +++++ .../providers/magenta2/constants.py | 2 +- .../providers/magenta2/playback_manager.py | 345 ++++++++++++++---- .../providers/magenta2/provider.py | 42 +-- .../providers/magenta2/smil_manager.py | 157 +++----- 5 files changed, 452 insertions(+), 190 deletions(-) diff --git a/lib/streaming_providers/providers/magenta2/channel_manager.py b/lib/streaming_providers/providers/magenta2/channel_manager.py index a1b9b98..658aa8a 100644 --- a/lib/streaming_providers/providers/magenta2/channel_manager.py +++ b/lib/streaming_providers/providers/magenta2/channel_manager.py @@ -96,6 +96,25 @@ class ChannelManager: # release_pid -> release_pid (fast membership test for PlaybackManager) self._live_pid_cache: Dict[str, str] = {} + # True once get_channels() has COMPLETED and overwritten the + # station->pid map with feed-sourced release pids. Until then the + # map holds only station-metadata MEDIA pids (from + # _fetch_station_metadata, which runs once in __init__). + # + # Deliberately NOT reset by invalidate_cache(): the entries a + # completed feed load wrote remain release pids afterwards, and + # refusing to resolve through them would degrade playback to raw + # station ids for no benefit. Pids gone stale after a feed change + # are handled by the rate-limited re-fetch on pid miss + # (PlaybackManager._prepare_live_pid). + # + # Note the live manifest/pid caches above are a DIFFERENT signal: + # populate_streaming_data() also fills them (channel-playlist + # endpoint) without ever touching the station map, so their + # non-emptiness must not be used to vouch for the map — see + # has_release_pid_map() vs has_entitled_live_channels(). + self._station_pids_are_release: bool = False + # Pre-fetched station metadata (unauthenticated) self.station_metadata: Dict[str, Dict] = self._fetch_station_metadata() @@ -259,6 +278,15 @@ class ChannelManager: f"({len(self.station_metadata)} stations with metadata)" ) channels.sort(key=lambda ch: (ch.channel_number is None, ch.channel_number or 0)) + # A feed load completed: every map entry written above is a + # release pid, so station-id resolution may trust the map from + # here on (see has_release_pid_map). Set even when some (or + # all) channels were skipped — the flag vouches for the + # PROVENANCE of the entries written, not for completeness; + # stations the feed skipped keep their constructor-era media + # pids and fail entitlement downstream, which is the honest + # outcome for a non-entitled station. + self._station_pids_are_release = True self._cached_channels = channels return channels @@ -291,6 +319,69 @@ class ChannelManager: return part.split("=", 1)[1] return None + def get_live_manifest_url(self, playback_id: str) -> Optional[str]: + """ + MPD URL for a live channel, as delivered by the entitled-channels + feed (or the channel-playlist endpoint) — known before any SMIL + call is ever made. + + Returns None for non-live / unknown ids. + """ + return self._live_manifest_cache.get(playback_id) + + def is_entitled_live_pid(self, playback_id: str) -> bool: + """ + True if *playback_id* is a release pid delivered by the + entitled-channels feed. + + Membership is the entitlement verification for PlaybackManager's + live fast paths (manifest cache hit / direct licence build): a pid + in this cache came from the entitled feed; a pid outside it may be + a station-metadata media pid, a stale post-refresh pid, or an + unentitled one, and must not be promoted to a releasePid without + the SMIL round-trip. + """ + return playback_id in self._live_pid_cache + + def has_entitled_live_channels(self) -> bool: + """ + True once the live manifest cache has been populated by an + entitled source — the entitled-channels feed (get_channels) or the + channel-playlist endpoint (populate_streaming_data). Both are + legitimately "entitled" sources: the playlist endpoint requires + an entitlement token and restricted channels are skipped. + + NOTE: this does NOT imply the station-to-pid map holds release + pids — populate_streaming_data() fills this cache without ever + touching that map. Whether resolving a numeric station id through + the map is safe is signalled by has_release_pid_map() instead. + """ + return bool(self._live_manifest_cache) + + def has_release_pid_map(self) -> bool: + """ + True once get_channels() has completed and overwritten the + station-to-pid map with feed-sourced RELEASE pids — the signal + that resolving a numeric station id through the map is safe. + + This, not has_entitled_live_channels(), is the guard + PlaybackManager._resolve_station_id keys on: the live manifest/pid + caches are also populated by populate_streaming_data() + (channel-playlist endpoint), which never updates the station map, + so keying on those caches would let a playlist-only population + vouch for a map that still holds station-metadata MEDIA pids — + the "error SMIL for a live channel" bug re-entered through the + playlist door. + + Once True it stays True for the instance's lifetime: the only + writer of media pids is _fetch_station_metadata(), which runs once + in __init__. invalidate_cache() deliberately does not reset it — + map entries written by a completed feed load remain release pids, + and ones gone stale after a feed change are refreshed by the + rate-limited re-fetch on pid miss. + """ + return self._station_pids_are_release + def get_entitlement_token( self, content_id: str, content_type: str = CONTENT_TYPE_LIVE ) -> str: @@ -477,6 +568,11 @@ class ChannelManager: return successful_channels def invalidate_cache(self) -> None: + # _station_pids_are_release is deliberately NOT reset here: it + # tracks the PROVENANCE of the station->pid map (feed-written + # release pids), which invalidation does not undo — only a fresh + # instance (whose map holds constructor-era media pids) starts + # False again. See has_release_pid_map(). self._cached_channels = None self._live_manifest_cache.clear() self._live_pid_cache.clear() diff --git a/lib/streaming_providers/providers/magenta2/constants.py b/lib/streaming_providers/providers/magenta2/constants.py index 6561f90..d8b89b3 100644 --- a/lib/streaming_providers/providers/magenta2/constants.py +++ b/lib/streaming_providers/providers/magenta2/constants.py @@ -21,7 +21,7 @@ MAGENTA2_LOGO = "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e4/Magen # manifest response — see config_models.BootstrapConfig / ManifestConfig. # # Versions below are confirmed from real-device captures/logs: -# - android-tv / atv-launcher: 3.180.7748 (Sebastian's current AndroidTV, +# - android-tv / atv-launcher: 3.180.7748 (Current AndroidTV, # shape confirmed against real AndroidTV request logs). SMIL, DRM and # concurrency request *shapes* were only ever captured on atv-launcher # (v3.136.4682) — carried over to android-tv on the assumption the diff --git a/lib/streaming_providers/providers/magenta2/playback_manager.py b/lib/streaming_providers/providers/magenta2/playback_manager.py index 1960157..3d3448f 100644 --- a/lib/streaming_providers/providers/magenta2/playback_manager.py +++ b/lib/streaming_providers/providers/magenta2/playback_manager.py @@ -13,9 +13,29 @@ Responsibilities manifest URL — no separate SMIL fetch required. - Inject ``smil_base_url`` from the recording-URL cache so callers (e.g. DRMOperations) only need to pass a ``content_id``. +- Resolve numeric station ids to entitled release pids for live/catchup + requests, AFTER the live caches are populated (see "Live id resolution"). -The class holds NO state of its own beyond references to the managers and -callbacks passed at construction time. All caches belong to ChannelManager. +The class holds one piece of state beyond the manager/callback references: +the rate-limit timestamp guarding get_channels() re-population attempts +(see ``_LIVE_CACHE_RETRY_INTERVAL``). All caches belong to ChannelManager. + +Live id resolution +------------------ +Numeric station ids must be resolved to release pids through +ChannelManager's station-to-pid map, but that map only holds entitled +RELEASE pids after get_channels() has run — before that it holds +station-metadata MEDIA pids, which the SMIL path rejects (observed as +error SMILs for live channels). Resolution therefore happens inside +PlaybackManager, strictly after cache population (``_prepare_live_pid``), +never in the provider before the playback call. + +Whether the map is safe to resolve through is signalled by +``ChannelManager.has_release_pid_map()`` (set when a get_channels() feed +load completes) — NOT by the live manifest/pid caches, which +``populate_streaming_data()`` (channel-playlist endpoint) also fills +without ever updating the station map. Keying on the caches would let +a playlist-only population vouch for a map that still holds media pids. Catchup routing --------------- @@ -33,10 +53,14 @@ DRM for catchup is identical to live — the same Widevine licence URL applies so ``get_drm`` routes catchup requests through the same fast-path as live. If the live manifest URL is not yet cached when a catchup request arrives, -``_ensure_live_cache()`` is called to populate it before building the URL. +the live caches are (re)populated before building the URL — on a pid miss +this refresh is rate-limited to one attempt per ``_LIVE_CACHE_RETRY_INTERVAL`` +so an unentitled or stale id cannot storm the feed. SmilManager is NOT used for catchup on this provider. """ import base64 +import enum +import threading import time from typing import Callable, Dict, List, Optional from urllib.parse import urlencode, urlparse, urlunparse, parse_qs @@ -56,6 +80,29 @@ from ..lib_theplatform import ( ) +class _LiveCachePopulateResult(enum.Enum): + """ + Outcome of a ``PlaybackManager._populate_live_caches()`` call. + + Tri-state so callers can distinguish "did not try" from "tried and + failed" — both are falsy outcomes for the purposes of re-resolving + ids, but they mean different things operationally (back off vs. + degrade) and are logged differently by the caller. + """ + + # A fetch was attempted AND succeeded — the map/caches were refreshed, + # so a caller may re-resolve ids against them. + POPULATED = "populated" + + # Skipped: within _LIVE_CACHE_RETRY_INTERVAL of the last attempt + # (shared negative cache across cold-starts, failures and pid misses). + RATE_LIMITED = "rate-limited" + + # A fetch was attempted but raised — logged here; playback degrades + # to the SMIL path with whatever id state exists. + FAILED = "failed" + + class PlaybackManager: """ Routes manifest and DRM requests for Magenta2 content. @@ -93,6 +140,13 @@ class PlaybackManager: for recordings fetched via RecordingsManager. """ + # Minimum seconds between ChannelManager.get_channels() (re)population + # attempts triggered from the playback path (cold cache, feed failure, + # or an entitled-cache pid miss). Bounds feed traffic when callers retry + # unentitled/stale ids — get_channels() has no negative caching of its + # own. + _LIVE_CACHE_RETRY_INTERVAL: float = 300.0 + def __init__( self, channel_manager: ChannelManager, @@ -117,6 +171,14 @@ class PlaybackManager: self._session_id = session_id self._call_id = call_id_callback + # Rate-limit state for get_channels() (re)population attempts. A + # single shared negative cache: cold-starts, failed fetches and pid + # misses all consume the same interval. -inf so the first attempt + # always fires regardless of process uptime (time.monotonic() can + # be small early after boot). + self._last_populate_attempt: float = float("-inf") + self._populate_lock = threading.Lock() + # ------------------------------------------------------------------ # # Public API # # ------------------------------------------------------------------ # @@ -127,28 +189,62 @@ class PlaybackManager: """ Return the MPD manifest URL for *content_id*. + *content_id* may be a numeric station_id or a playback pid for + live/catchup requests (station ids are resolved here, after the + live caches are populated — see ``_prepare_live_pid``); VOD and + recording ids pass through untouched. + Routing logic ~~~~~~~~~~~~~ 1. **Catchup** (``start_time`` + ``end_time`` present in kwargs): Appends ``begin``/``end`` to the cached live manifest URL. - No SMIL round-trip. The live cache is populated on demand if empty. + No SMIL round-trip. The live cache is (re)populated on demand. 2. **Live** (``content_type == CONTENT_TYPE_LIVE``, no time window): - Served directly from the ChannelManager cache when available. + Served directly from the ChannelManager cache when the resolved + pid is entitled. On a miss the request degrades to SmilManager + with the resolved id; if SMIL also fails, the cache is re-read + once (narrow race: a concurrent refresh may have populated it + while the SMIL fetch was in flight). 3. **VOD / recordings**: Fall through to ``SmilManager.get_manifest``. """ if self._is_catchup_request(kwargs): - return self._get_catchup_manifest(content_id, kwargs) + pid = self._prepare_live_pid(content_id) + logger.debug( + f"get_manifest: catchup request for {pid} " + f"(start={kwargs['start_time']}, end={kwargs['end_time']})" + ) + return self._build_begin_end_manifest_url( + pid, kwargs["start_time"], kwargs["end_time"] + ) + lookup_id = content_id if content_type == CONTENT_TYPE_LIVE: - self._ensure_live_cache() - if content_id in self._channel_manager._live_manifest_cache: - logger.debug(f"get_manifest: cache hit for live channel {content_id}") - return self._channel_manager._live_manifest_cache[content_id] + lookup_id = self._prepare_live_pid(content_id) + mpd = self._channel_manager.get_live_manifest_url(lookup_id) + if mpd: + logger.debug(f"get_manifest: cache hit for live channel {lookup_id}") + return mpd + logger.debug( + f"get_manifest: no entitled manifest for {lookup_id} — degrading to SMIL" + ) if not self._smil_manager: raise RuntimeError("SmilManager not available") - self._inject_smil_base_url(content_id, kwargs) - return self._smil_manager.get_manifest(content_id, content_type, **kwargs) + self._inject_smil_base_url(lookup_id, kwargs) + result = self._smil_manager.get_manifest(lookup_id, content_type, **kwargs) + + if result is None and content_type == CONTENT_TYPE_LIVE: + # Post-SMIL fallback (narrow race): another thread may have + # populated the live cache while the SMIL fetch was in flight. + # One dict read — never a re-fetch. + mpd = self._channel_manager.get_live_manifest_url(lookup_id) + if mpd: + logger.warning( + f"get_manifest: SMIL returned no MPD for {lookup_id}; using " + "entitled-feed MPD (may lack the SMIL path's session binding)" + ) + return mpd + return result def get_drm( self, content_id: str, content_type: str = CONTENT_TYPE_LIVE, **kwargs @@ -160,40 +256,50 @@ class PlaybackManager: ~~~~~~~~~~~~~ 1. **Catchup** (``start_time`` + ``end_time`` present in kwargs): DRM is identical to live — same Widevine licence URL applies to the - DVR window. Routes through the live fast-path using the pid cache. - Falls back to SmilManager if the pid is not cached. + DVR window. Routes through the live fast-path using the + entitled-pid cache. Falls back to SmilManager if the pid is not + entitled. 2. **Live** (``content_type == CONTENT_TYPE_LIVE``, no time window): - For live channels whose releasePid is cached, the Widevine licence - URL is built directly using lib_theplatform — no SMIL fetch needed. + For ids whose resolved pid is in the entitled-pid cache, the + Widevine licence URL is built directly using lib_theplatform — no + SMIL fetch needed. If the pid is NOT in the entitled cache the + request degrades to SmilManager. It must NOT direct-build in that + case: pid-cache membership is the entitlement verification for the + direct licence path, and callers cache non-empty DRM results — an + unverified releasePid would turn a self-healing empty result into + a cached, persistent wrong-licence config. 3. **VOD / recordings**: Fall through to ``SmilManager.get_drm``. """ - if self._is_catchup_request(kwargs): - # Catchup uses the same Widevine licence as live — reuse the fast-path. - self._ensure_live_cache() - if content_id in self._channel_manager._live_pid_cache: - logger.debug( - f"get_drm: catchup request for {content_id} — reusing live DRM fast-path" + lookup_id = content_id + if self._is_catchup_request(kwargs) or content_type == CONTENT_TYPE_LIVE: + lookup_id = self._prepare_live_pid(content_id) + if self._channel_manager.is_entitled_live_pid(lookup_id): + if self._is_catchup_request(kwargs): + logger.debug( + f"get_drm: catchup for {lookup_id} — reusing live DRM fast-path" + ) + return self._build_live_drm(lookup_id) + if self._is_catchup_request(kwargs): + # pid not entitled — fall through to SmilManager as a best-effort. + logger.warning( + f"get_drm: catchup for {lookup_id} but pid not in live cache; " + "falling back to SmilManager" + ) + else: + # Symmetric with get_manifest's degradation log, so the + # reason a SMIL round-trip happened is visible on the DRM + # path too. This is the deliberate veto case: pid-cache + # membership is the entitlement verification for a direct + # licence build (see _build_live_drm). + logger.debug( + f"get_drm: {lookup_id} not in entitled pid cache — " + "degrading to SMIL" ) - return self._build_live_drm(content_id) - # pid not cached yet — fall through to SmilManager as a best-effort. - logger.warning( - f"get_drm: catchup for {content_id} but pid not in live cache; " - "falling back to SmilManager" - ) - if not self._smil_manager: - raise RuntimeError("SmilManager not available") - self._inject_smil_base_url(content_id, kwargs) - return self._smil_manager.get_drm(content_id, content_type, **kwargs) - - if content_type == CONTENT_TYPE_LIVE: - self._ensure_live_cache() - if content_id in self._channel_manager._live_pid_cache: - return self._build_live_drm(content_id) if not self._smil_manager: raise RuntimeError("SmilManager not available") - self._inject_smil_base_url(content_id, kwargs) - return self._smil_manager.get_drm(content_id, content_type, **kwargs) + self._inject_smil_base_url(lookup_id, kwargs) + return self._smil_manager.get_drm(lookup_id, content_type, **kwargs) def get_catchup_manifest( self, content_id: str, start_time: int, end_time: int, drm_variant: Optional[str] = "auto", **kwargs @@ -210,13 +316,16 @@ class PlaybackManager: Parameters ---------- content_id: - The live channel content_id (same key used in the live cache). + The live channel content_id — a numeric station_id or a playback + pid (the same key used in the live cache; station ids are + resolved here, after cache population). start_time: Unix timestamp (seconds) of the catchup window start. end_time: Unix timestamp (seconds) of the catchup window end. """ - return self._build_begin_end_manifest_url(content_id, start_time, end_time) + pid = self._prepare_live_pid(content_id) + return self._build_begin_end_manifest_url(pid, start_time, end_time) # ------------------------------------------------------------------ # # Internal helpers # @@ -233,19 +342,6 @@ class PlaybackManager: and kwargs.get("end_time") is not None ) - def _get_catchup_manifest(self, content_id: str, kwargs: Dict) -> Optional[str]: - """ - Build a programme-bounded catchup manifest URL for a catchup request - arriving via ``get_manifest`` (``start_time``/``end_time`` in kwargs). - """ - start_time: int = kwargs["start_time"] - end_time: int = kwargs["end_time"] - logger.debug( - f"get_manifest: catchup request for {content_id} " - f"(start={start_time}, end={end_time})" - ) - return self._build_begin_end_manifest_url(content_id, start_time, end_time) - def _build_begin_end_manifest_url( self, content_id: str, start_time: int, end_time: int ) -> Optional[str]: @@ -254,12 +350,10 @@ class PlaybackManager: ``begin``/``end`` programme window (basic-ISO UTC). ``end`` is clamped to now so a running programme never requests beyond the live edge. - Returns ``None`` if the channel is not found in the live cache even after - attempting to populate it. + Returns ``None`` if the channel is not found in the live cache (the + caller is responsible for cache population via ``_prepare_live_pid``). """ - self._ensure_live_cache() - - live_url = self._channel_manager._live_manifest_cache.get(content_id) + live_url = self._channel_manager.get_live_manifest_url(content_id) if not live_url: logger.error( f"_build_begin_end_manifest_url: no live manifest cached for {content_id}; " @@ -282,18 +376,141 @@ class PlaybackManager: logger.debug(f"_build_begin_end_manifest_url: {content_id} → {catchup_url}") return catchup_url - def _ensure_live_cache(self) -> None: + def _prepare_live_pid(self, content_id: str) -> str: """ - Bootstrap the live-manifest / live-pid caches on demand by calling - ChannelManager.get_channels() if they are empty. + Resolve *content_id* to the best available live pid, with the + ordering the live fast paths depend on. + + Ordering (the observed "error SMIL for a live channel" bug): + ChannelManager's station-to-pid map holds station-metadata MEDIA + pids until get_channels() runs and overwrites them with entitled + RELEASE pids. Resolving a numeric station_id through a pre-feed + map therefore yields a media pid, which the SMIL path rejects. So: + populate first, resolve second; and on a pid miss, one rate-limited + refresh followed by a re-resolve (a refreshed map can fix a stale + pid, not just a cache miss). + + The map's safety signal is ``has_release_pid_map()`` (a feed load + completed), NOT the live manifest/pid caches — those are also + filled by populate_streaming_data (channel-playlist endpoint), + which never updates the station map. + + Never raises: a failed feed fetch degrades to resolving against + whatever map state exists, and callers fall through to SmilManager. """ - if not self._channel_manager._live_manifest_cache: - logger.debug("Live channel cache is empty — auto-populating via get_channels()") + if not self._channel_manager.has_release_pid_map(): + result = self._populate_live_caches() + if result is not _LiveCachePopulateResult.POPULATED: + logger.debug( + f"_prepare_live_pid: initial population {result.value}; " + "the station map still pre-dates the feed" + ) + pid = self._resolve_station_id(content_id) + if not self._channel_manager.is_entitled_live_pid(pid): + result = self._populate_live_caches() + if result is _LiveCachePopulateResult.POPULATED: + pid = self._resolve_station_id(content_id) + elif result is _LiveCachePopulateResult.RATE_LIMITED: + logger.debug( + "_prepare_live_pid: refresh rate-limited — skipping re-resolve" + ) + else: # FAILED + logger.debug( + "_prepare_live_pid: feed fetch failed — degrading with the " + f"current pid ({pid})" + ) + if pid == content_id and content_id.isdigit(): + # Explicit degraded-path signal: the live fast paths will miss + # on a raw numeric station id (their caches are keyed by pids) + # and SMIL will fail predictably — a numeric station id is not + # an MPX guid. Logged here rather than relying on the SMIL + # error to make itself understood. + logger.debug( + f"_prepare_live_pid: cannot resolve station id {content_id} to " + "a release pid — skipping the live fast path for it" + ) + return pid + + def _resolve_station_id(self, content_id: str) -> str: + """ + Numeric station_id → release pid via the station-to-pid map; + anything else (already a pid, or a VOD id) passes through unchanged. + """ + if not (content_id and content_id.isdigit()): + return content_id + if not self._channel_manager.has_release_pid_map(): + # The map provably pre-dates any feed load (get_channels() has + # never completed), so any mapping it holds for this station is + # a media pid — do not resolve through it. Keyed on the + # release-map flag, NOT on the live caches: those are also + # filled by populate_streaming_data (channel-playlist + # endpoint), which never touches the station map — a + # playlist-only population must not vouch for it. + # Debug, not warning: the failed/attempted fetch that led here + # is already logged by _populate_live_caches, and this fires + # once per playback request while the negative cache holds. + logger.debug( + f"Station map not feed-sourced yet; cannot resolve station " + f"{content_id} to a release pid — passing the raw station id " + "through" + ) + return content_id + playback_id = self._channel_manager.get_playback_id_for_station(content_id) + if playback_id: + logger.debug(f"Resolved station {content_id} -> pid {playback_id}") + return playback_id + logger.warning( + f"Station {content_id} has no playback pid in the station map; " + "passing the raw id through" + ) + return content_id + + def _populate_live_caches(self) -> _LiveCachePopulateResult: + """ + (Re)populate the live caches via ChannelManager.get_channels(), + guarded by: + + - a shared rate limit / negative cache — at most one attempt per + ``_LIVE_CACHE_RETRY_INTERVAL`` across cold-starts, failed fetches + and pid misses alike, so a failing feed or a caller retrying an + unentitled/stale id cannot storm the feed (get_channels() has no + negative caching of its own); + - a no-raise policy — a feed outage logs and degrades playback to + the SMIL path instead of raising out of every live request. + + Returns ``_LiveCachePopulateResult.POPULATED`` only when a fetch + was attempted AND succeeded (so a caller may re-resolve ids against + the refreshed map); ``RATE_LIMITED`` when the attempt was skipped + by the shared interval, and ``FAILED`` when the fetch raised. The + two non-POPULATED outcomes are indistinguishable to the caller's + control flow (neither can produce a fresher map) but are logged + differently — rate-limiting is routine back-off, failure is a + degradation worth its own line in _populate_live_caches. + """ + with self._populate_lock: + now = time.monotonic() + if now - self._last_populate_attempt < self._LIVE_CACHE_RETRY_INTERVAL: + return _LiveCachePopulateResult.RATE_LIMITED + self._last_populate_attempt = now + try: + logger.debug( + "Live cache cold or pid miss — (re)populating via get_channels()" + ) self._channel_manager.get_channels() + return _LiveCachePopulateResult.POPULATED + except Exception as exc: + logger.warning( + f"Live-cache population failed ({exc}); degrading to SMIL path" + ) + return _LiveCachePopulateResult.FAILED def _build_live_drm(self, content_id: str) -> List[DRMConfig]: """ Build a Widevine DRMConfig directly for a live channel, bypassing SMIL. + + *content_id* must be an ENTITLED release pid — callers gate this on + ``ChannelManager.is_entitled_live_pid`` (the entitled-feed + verification), because a non-empty DRM result is cached upstream. """ release_pid = content_id logger.debug( diff --git a/lib/streaming_providers/providers/magenta2/provider.py b/lib/streaming_providers/providers/magenta2/provider.py index fcd5c1f..1e29b17 100644 --- a/lib/streaming_providers/providers/magenta2/provider.py +++ b/lib/streaming_providers/providers/magenta2/provider.py @@ -1096,34 +1096,19 @@ class Magenta2Provider(StreamingProvider): ) -> List[Event]: return [] - @staticmethod - def _get_playback_id(content_id: str, channel_manager: ChannelManager) -> str: - """ - Convert station_id to playback_id if needed, using the given - ChannelManager so the conversion and the subsequent playback call - operate on the same manager generation. - - If content_id is numeric (station_id), convert to playback_id. - Otherwise return as-is (already a playback_id or VOD ID). - """ - if content_id.isdigit(): - playback_id = channel_manager.get_playback_id_for_station(content_id) - if playback_id: - logger.debug(f"Converted station_id {content_id} -> playback_id {playback_id}") - return playback_id - logger.warning(f"No playback_id found for station_id {content_id}") - return content_id - return content_id - def get_manifest( self, content_id: str, content_type: str = CONTENT_TYPE_LIVE, **kwargs: Any ) -> Optional[str]: - # Snapshot one manager generation: the station→pid conversion and - # the playback call must not straddle a refresh swap. + # Snapshot one manager generation: the id resolution PlaybackManager + # performs inside this call and the playback call itself must not + # straddle a refresh swap. managers = self._managers - # Convert station_id -> playback_id for live channels - if content_type == CONTENT_TYPE_LIVE: - content_id = self._get_playback_id(content_id, managers.channel) + # Deliberately NO station_id -> pid pre-resolution here. + # PlaybackManager resolves the id AFTER populating the live caches + # (PlaybackManager._prepare_live_pid). Pre-resolving here reads the + # station->pid map before the entitled-channels feed has run on a + # cold start — handing SMIL a media pid instead of the release pid + # (the "error SMIL" bug this patch set fixes). return managers.playback.get_manifest(content_id, content_type, **kwargs) def get_drm( @@ -1156,9 +1141,8 @@ class Magenta2Provider(StreamingProvider): f"get_drm(content_id, content_type={drm_variant!r})" ) managers = self._managers - # Convert station_id -> playback_id for live channels - if content_type == CONTENT_TYPE_LIVE: - content_id = self._get_playback_id(content_id, managers.channel) + # No station_id pre-resolution here either — PlaybackManager owns + # the resolution (same cold-start rationale as get_manifest above). return managers.playback.get_drm(content_id, content_type, **kwargs) def get_catchup_manifest( @@ -1192,7 +1176,9 @@ class Magenta2Provider(StreamingProvider): f"which looks like a drm_variant — pass drm_variant by keyword" ) managers = self._managers - content_id = self._get_playback_id(content_id, managers.channel) + # No station_id pre-resolution: PlaybackManager.get_catchup_manifest + # resolves through _prepare_live_pid (populate-then-resolve), the + # same as the live paths above. return managers.playback.get_catchup_manifest( content_id, start_time, end_time, drm_variant, **kwargs ) diff --git a/lib/streaming_providers/providers/magenta2/smil_manager.py b/lib/streaming_providers/providers/magenta2/smil_manager.py index f76cbb0..4418053 100644 --- a/lib/streaming_providers/providers/magenta2/smil_manager.py +++ b/lib/streaming_providers/providers/magenta2/smil_manager.py @@ -1,10 +1,10 @@ -# streaming_providers/providers/magenta2/smil_manager.py """ Magenta2 SMIL / Playback Manager Handles all SMIL fetching, parsing, caching, and DRM resolution for the -Magenta2 provider. This is the shared playback layer used by live channels, -VOD items, and recordings — content type does not affect the code path. +Magenta2 provider. This is the shared playback layer used by VOD items, +recordings, and live channels that miss PlaybackManager's entitled-feed +fast path — content type does not affect the code path. SMIL URL strategy ----------------- @@ -31,20 +31,29 @@ Public interface ---------------- smil_manager.get_smil_data(content_id) -> Optional[Dict] smil_manager.get_manifest(content_id) -> Optional[str] - smil_manager.get_catchup_manifest(channel_id, start_time, end_time) -> Optional[str] smil_manager.get_drm(content_id) -> List[DRMConfig] + +(Catchup manifests are NOT served here — PlaybackManager builds them from +the cached live manifest URL. A former get_catchup_manifest() was removed +because it was unreachable in the provider's routing and behaved differently +from PlaybackManager's version: no clamping of ``end`` to the live edge and a +naive ``?``/``&`` append instead of parse_qs/urlencode.) """ import base64 -import json import re import time from typing import Any, Dict, List, Optional, Tuple -from urllib.parse import quote -from ...base.models import DRMConfig, DRMSystem, LicenseConfig +from ...base.models import DRMConfig from ...base.utils.logger import logger +from ..lib_theplatform import ( + build_licence_url, + build_widevine_drm_config, + extract_persona_jwt, +) + from .constants import ( CONTENT_TYPE_LIVE, DEFAULT_REQUEST_TIMEOUT, @@ -145,46 +154,6 @@ class SmilManager: smil_data = self.get_smil_data(content_id, smil_base_url=smil_base_url) return smil_data.get("mpd_url") if smil_data else None - def get_catchup_manifest( - self, channel_id: str, start_time: int, end_time: int, **kwargs - ) -> Optional[str]: - """ - Return a live manifest extended with catchup time-range parameters. - - Args: - channel_id: MPX GUID for the live channel. - start_time: Catchup start as Unix epoch seconds. - end_time: Catchup end as Unix epoch seconds. - """ - from ...base.utils.timestamp_converter import TimestampConverter - - base_manifest = self.get_manifest(channel_id, **kwargs) - if not base_manifest: - logger.warning( - f"{self._provider}: Channel {channel_id} has no manifest for catchup" - ) - return None - - try: - 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) - separator = "&" if "?" in base_manifest else "?" - catchup_url = f"{base_manifest}{separator}begin={start_iso}&end={end_iso}" - logger.debug( - f"{self._provider}: Catchup manifest for {channel_id}: {catchup_url}" - ) - return catchup_url - except Exception as e: - logger.error( - f"{self._provider}: Error building catchup manifest for {channel_id}: {e}" - ) - logger.warning( - f"{self._provider}: Falling back to live manifest for {channel_id}" - ) - return base_manifest - def get_drm( self, content_id: str, @@ -195,6 +164,15 @@ class SmilManager: """ Return a Widevine DRMConfig for *content_id*. + The licence URL and DRMConfig are built with the shared + lib_theplatform helpers (build_licence_url / + build_widevine_drm_config / extract_persona_jwt) — the same + construction PlaybackManager._build_live_drm uses for its live + fast path, so the SMIL/VOD path and the live fast path share one + licence-URL/DRMConfig construction. The paths still differ + deliberately in how the release pid is obtained (parsed from SMIL + tracking data here vs. taken from the entitled feed there). + Args: smil_base_url: Full theplatform selector URL when already known. Passed through to get_smil_data to skip resolution. @@ -217,7 +195,7 @@ class SmilManager: return [] persona_token = self._auth() - raw_jwt = self._extract_persona_jwt(persona_token) + raw_jwt = extract_persona_jwt(persona_token) if not raw_jwt: logger.error(f"{self._provider}: Failed to extract persona JWT") return [] @@ -229,34 +207,18 @@ class SmilManager: ) return [] - account_uri = self._get_account_uri() - license_url = ( - f"{widevine_endpoint}?" - f"schema=1.0&" - f"releasePid={release_pid}&" - f"token={raw_jwt}&" - f"account={quote(account_uri, safe='')}" + licence_url = build_licence_url( + widevine_endpoint=widevine_endpoint, + release_pid=release_pid, + persona_jwt=raw_jwt, + account_uri=self._get_account_uri(), ) - call_id = self._call_id() - cid = f"{self._session_id}::{call_id}" - - drm_config = DRMConfig( - system=DRMSystem.WIDEVINE, - priority=1, - license=LicenseConfig.create_with_req_data( - req_data_template="{CHA-RAW}", - server_url=license_url, - server_certificate=None, - req_headers=json.dumps( - { - "User-Agent": self._ua_subscriber, - "Content-Type": "application/octet-stream", - "CID": cid, - } - ), - use_http_get_request=False, - ), + drm_config = build_widevine_drm_config( + licence_url=licence_url, + user_agent=self._ua_subscriber, + session_id=self._session_id, + call_id=self._call_id(), ) logger.info( f"{self._provider}: DRM config for {content_id} " @@ -294,6 +256,7 @@ class SmilManager: # ── Direct URL supplied (recordings, already-resolved VOD) ─────── # smil_base_url known upfront — strip any existing query string and # derive cache key from the GUID tail. Skip all prefix dispatch. + is_live_request = False if smil_base_url: smil_base_url = smil_base_url.split("?")[0] tail = smil_base_url.rsplit("/media/", 1) @@ -331,6 +294,7 @@ class SmilManager: # ── Live channel — content_id IS the bare MPX GUID ─────────────── else: cache_key = content_id + is_live_request = True # ── Cache check ────────────────────────────────────────────────── now = time.time() @@ -360,6 +324,27 @@ class SmilManager: mpd_url = self._parse_mpd_url(smil_content, cache_key) release_pid = self._parse_release_pid(smil_content) + if is_live_request and release_pid: + if release_pid != cache_key: + # Monitored invariant: for live requests the pid we send + # is expected to BE the releasePid the SMIL echoes back + # in trackingData. A mismatch means the id we sent was + # (probably) a station-metadata media pid or a stale pid, + # and the manifest/DRM built from this SMIL may describe + # a different asset than the one the caller asked for. + # Warning, not error: behaviour is unchanged, but the + # assumption is now observable in the wild. + logger.warning( + f"{self._provider}: live pid mismatch: SMIL releasePid=" + f"{release_pid} != requested pid={cache_key} — requested " + f"id may be a media/stale pid; manifest and DRM may " + f"target a different asset" + ) + else: + logger.debug( + f"{self._provider}: releasePid invariant holds for {cache_key}" + ) + smil_data: Dict[str, Any] = { "content": smil_content, "mpd_url": mpd_url, @@ -682,32 +667,10 @@ class SmilManager: # Private – token / account helpers # ========================================================================= - @staticmethod - def _extract_persona_jwt(persona_token: str) -> Optional[str]: - """ - Extract the raw JWT from a Base64-encoded persona token. - - Persona token format: Base64(account_uri + ":" + jwt) - """ - try: - decoded = base64.b64decode(persona_token).decode("utf-8") - idx = decoded.rfind(":") - if idx == -1: - logger.error("No colon in decoded persona token") - return None - jwt = decoded[idx + 1:] - if not jwt.startswith("eyJ"): - logger.error("Extracted token is not a JWT") - return None - return jwt - except Exception as e: - logger.error(f"Error extracting persona JWT: {e}") - return None - def _get_account_uri(self) -> str: """Return the MPX account URI, falling back to the hardcoded constant.""" if self._provider_config and self._provider_config.manifest: uri = self._provider_config.manifest.mpx.get_account_uri() if uri: return uri - return MAGENTA2_FALLBACK_ACCOUNT_URI \ No newline at end of file + return MAGENTA2_FALLBACK_ACCOUNT_URI