diff --git a/lib/streaming_providers/providers/magenta2/channel_manager.py b/lib/streaming_providers/providers/magenta2/channel_manager.py index c78d4b2..0d67819 100644 --- a/lib/streaming_providers/providers/magenta2/channel_manager.py +++ b/lib/streaming_providers/providers/magenta2/channel_manager.py @@ -6,8 +6,10 @@ population for the Magenta2 provider. """ import json import re +import threading import time -from typing import Callable, Dict, List, Optional +import uuid +from typing import Callable, Dict, List, Optional, Tuple from ...base.errors import AuthPendingError, ProviderError from ...base.models import StreamingChannel @@ -20,6 +22,7 @@ from .constants import ( DEFAULT_REQUEST_TIMEOUT, DISTRIBUTION_PACKAGE_NAMES, DRM_SYSTEM_WIDEVINE, + ENTITLED_FEED_FALLBACK_MIN_INTERVAL, ERROR_CODES, MODE_LIVE, ) @@ -97,6 +100,16 @@ class ChannelManager: # release_pid -> release_pid (fast membership test for PlaybackManager) self._live_pid_cache: Dict[str, str] = {} + # release_pid -> hls_url (feed-delivered HLS variant, if any). + # Written by the same writers as _live_manifest_cache. + self._live_hls_cache: Dict[str, str] = {} + + # Playback-time feed fallback (resolve_release_pid_via_feed): + # serialises concurrent fallback fetches and rate-limits how often + # an unvouched pid may trigger an entitled-channels feed refetch. + self._feed_fallback_lock = threading.Lock() + self._feed_fallback_last_run: float = 0.0 + # 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 @@ -109,6 +122,13 @@ class ChannelManager: # are handled by the rate-limited re-fetch on pid miss # (PlaybackManager._prepare_live_pid). # + # resolve_release_pid_via_feed() is a SECOND feed-loading writer: + # at playback time it re-fetches the entitled-channels feed and + # overwrites the map with release pids under the same provenance + # argument (see that method for why: the SMIL/selector path can be + # unusable for a live pid — e.g. UserAgentBlocked — while the feed + # still carries both the release pid and the direct streamingUrl). + # # 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 @@ -136,34 +156,11 @@ class ChannelManager: return list(self._cached_channels) try: - import uuid - cid = f"{self._session_id}::{str(uuid.uuid4())}" - user_agent = self._ua_subscriber - - # ── Step 1: distribution rights ────────────────────────────────── - rights_url = ( - self._provider_config.manifest.mpx.license_service_url - if self._provider_config and self._provider_config.manifest - else None - ) - if not rights_url: - raise RuntimeError("No license_service_url available") - - persona_token = self._ensure_authenticated() - auth_headers = { - "Authorization": f"Basic {persona_token}", - "Origin": "https://www.magenta.tv", - "Referer": "https://www.magenta.tv/", - } - - distribution_rights = fetch_distribution_rights( - http_manager=self._http, - rights_url=rights_url, - cid=cid, - user_agent=user_agent, - timeout=DEFAULT_REQUEST_TIMEOUT, - extra_headers=auth_headers, - ) + # ── Steps 1+2: distribution rights + entitled-channels feed ── + # Shared with the playback-time fallback + # (resolve_release_pid_via_feed) so both paths issue + # byte-identical requests. + distribution_rights, tp_channels = self._fetch_entitled_feed_data() def _dist_uri_to_int(uri) -> Optional[int]: try: @@ -180,22 +177,6 @@ class ChannelManager: for pkg in package_names: logger.info(f" · {pkg}") - # ── Step 2: 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, - feed_url=feed_url, - distribution_rights=distribution_rights, - cid=cid, - user_agent=user_agent, - timeout=DEFAULT_REQUEST_TIMEOUT, - ) - # ── Step 3: merge with station metadata and build channels ────── channels: List[StreamingChannel] = [] for tp_ch in tp_channels: @@ -239,8 +220,15 @@ class ChannelManager: manifest_script_parts.append(f"pid={playback_id}") manifest_script = " ".join(manifest_script_parts) if manifest_script_parts else "" - # Store mapping for DRM lookup - self._station_to_playback_id[station_id] = playback_id + # Store mapping for DRM lookup + feed the live caches + # (shared writer with resolve_release_pid_via_feed so + # both feed-loading paths record identical semantics) + self._cache_live_pid( + station_id=station_id, + playback_id=playback_id, + mpd_url=tp_ch.mpd_url, + hls_url=tp_ch.hls_url, + ) magenta2_channel = Magenta2Channel( name=name, @@ -266,9 +254,6 @@ class ChannelManager: if tp_ch.hls_url: streaming_channel.hls_url = tp_ch.hls_url - self._live_manifest_cache[playback_id] = tp_ch.mpd_url - self._live_pid_cache[playback_id] = playback_id # For PlaybackManager fast-path - channels.append(streaming_channel) except Exception as exc: logger.warning(f"get_channels: skipping channel {tp_ch.station_id}: {exc}") @@ -328,13 +313,22 @@ class ChannelManager: 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. + feed (get_channels / resolve_release_pid_via_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 get_live_hls_manifest_url(self, playback_id: str) -> Optional[str]: + """ + HLS (M3U/CMAF) URL for a live channel, as delivered by the + entitled-channels feed alongside the DASH MPD — None when the feed + carried no HLS variant for this pid. + """ + return self._live_hls_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 @@ -352,8 +346,9 @@ class ChannelManager: 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 + entitled source — the entitled-channels feed (get_channels or the + playback-time fallback resolve_release_pid_via_feed) or the + channel-playlist endpoint (populate_streaming_data). All are legitimately "entitled" sources: the playlist endpoint requires an entitlement token and restricted channels are skipped. @@ -366,9 +361,11 @@ class ChannelManager: 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. + True once a full entitled-feed load has completed and overwritten + the station-to-pid map with feed-sourced RELEASE pids — via + get_channels() or the playback-time fallback + resolve_release_pid_via_feed() — 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 @@ -388,6 +385,125 @@ class ChannelManager: """ return self._station_pids_are_release + def resolve_release_pid_via_feed( + self, station_id: str, force: bool = False + ) -> Optional[str]: + """ + Playback-time fallback: (re)resolve *station_id*'s RELEASE pid from + the entitled-channels feed, bypassing the SMIL/selector round trip. + + Why this exists: a live channel can reach SMIL with a pid the + platform refuses to resolve — a station-metadata MEDIA pid left in + the map when the feed never (re)ran, a stale pid after a feed + change, or content whose selector is user-agent-gated (observed: + ``UserAgentBlocked`` / "User Agent Restriction" error-SMIL, HTTP + 200 + 539-byte error body, for a Sky Sport UHD station). The + entitled-channels feed carries BOTH the release pid and the direct + CDN streamingUrl (DASH MPD + HLS), so manifest AND DRM can be + served without SMIL at all: + + pid = channel_manager.resolve_release_pid_via_feed(sid) + manifest = channel_manager.get_live_manifest_url(pid) + drm = live fast-path construction with release_pid=pid + + Behaviour: + * Vouched fast path: if the station already maps to a pid that is + in the entitled pid cache, it is returned WITHOUT a fetch. + * Rate limit: at most one feed refetch per + ENTITLED_FEED_FALLBACK_MIN_INTERVAL seconds per instance, so a + genuinely unentitled station cannot hammer the feed on every + playback attempt. force=True bypasses (user-initiated retry, + admin refresh). Concurrent callers serialise on a lock; the + loser profits from the winner's refreshed maps. + * On the rate-limited path the current map entry is returned ONLY + if it is vouched; a media/stale pid yields None (returning it + would recreate the exact failure this method exists to fix). + + Side effects on a completed refetch: station->pid map and the live + manifest/pid/hls caches are rewritten for EVERY feed entry (not + just *station_id*), `_station_pids_are_release` is set, and a + cached channel list is dropped so the next get_channels() rebuilds + with the fresh pids. + + Returns the release pid, or None if the station is not in the feed + (not entitled on this account) or the feed could not be fetched. + Never raises. + """ + vouched = self._vouched_pid_for_station(station_id) + if vouched: + return vouched + + if not force and (time.time() - self._feed_fallback_last_run) < ( + ENTITLED_FEED_FALLBACK_MIN_INTERVAL + ): + logger.debug( + f"resolve_release_pid_via_feed: rate-limited — no feed refetch " + f"for station {station_id}" + ) + return None + + with self._feed_fallback_lock: + # Re-check under the lock: a concurrent caller may have just + # refreshed the maps (and the rate-limit timestamp). + vouched = self._vouched_pid_for_station(station_id) + if vouched: + return vouched + if not force and (time.time() - self._feed_fallback_last_run) < ( + ENTITLED_FEED_FALLBACK_MIN_INTERVAL + ): + return None + + logger.info( + f"resolve_release_pid_via_feed: re-resolving station {station_id} " + f"from the entitled-channels feed (pid not vouched or SMIL " + f"path unusable)" + ) + try: + _, tp_channels = self._fetch_entitled_feed_data() + applied = 0 + for tp_ch in tp_channels: + sid = self._extract_station_id(tp_ch.station_id) + if not sid or not tp_ch.release_pid: + continue + self._cache_live_pid( + station_id=sid, + playback_id=tp_ch.release_pid, + mpd_url=tp_ch.mpd_url, + hls_url=tp_ch.hls_url, + ) + applied += 1 + except Exception as exc: + logger.warning( + f"resolve_release_pid_via_feed: entitled-feed refetch failed " + f"for station {station_id}: {exc}" + ) + return None + finally: + self._feed_fallback_last_run = time.time() + + if applied: + # Same provenance argument as a completed get_channels() + # feed load: every entry written above is a feed-sourced + # release pid. + self._station_pids_are_release = True + # Cached StreamingChannels embed the old pids (cdm / + # manifest_script); force a rebuild on next get_channels(). + self._cached_channels = None + + pid = self._vouched_pid_for_station(station_id) + if pid: + logger.info( + f"resolve_release_pid_via_feed: station {station_id} -> " + f"release pid {pid}" + ) + return pid + + logger.warning( + f"resolve_release_pid_via_feed: station {station_id} is not in " + f"the entitled-channels feed (not entitled on this account?)" + ) + return None + def get_entitlement_token( self, content_id: str, content_type: str = CONTENT_TYPE_LIVE ) -> str: @@ -589,6 +705,7 @@ class ChannelManager: self._cached_channels = None self._live_manifest_cache.clear() self._live_pid_cache.clear() + self._live_hls_cache.clear() logger.debug("ChannelManager: caches cleared") # ------------------------------------------------------------------ # @@ -622,6 +739,102 @@ class ChannelManager: logger.warning(f"Error extracting playback ID from entry: {e}") return None + def _vouched_pid_for_station(self, station_id: str) -> Optional[str]: + """ + The station's map pid ONLY if it is a vouched release pid (member + of the entitled pid cache — see is_entitled_live_pid). Media and + stale pids yield None: playback must not be built on them. + """ + pid = self._station_to_playback_id.get(station_id) + if pid and pid in self._live_pid_cache: + return pid + return None + + def _cache_live_pid( + self, + station_id: str, + playback_id: str, + mpd_url: Optional[str], + hls_url: Optional[str], + ) -> None: + """ + Single writer for the live mappings: station -> playback pid plus + the pid-keyed live caches (DASH MPD, HLS, entitled-pid set). + + Shared by get_channels() and resolve_release_pid_via_feed() so the + two feed-loading paths can never diverge on what a recorded entry + means. mpd_url/hls_url are stored only when truthy: the entitled + feed always carries streamingUrl, but skipping falsy values keeps + cache-miss semantics clean (a stored None would be indistinguishable + from "known to have no URL"). + """ + self._station_to_playback_id[station_id] = playback_id + if mpd_url: + self._live_manifest_cache[playback_id] = mpd_url + if hls_url: + self._live_hls_cache[playback_id] = hls_url + self._live_pid_cache[playback_id] = playback_id + + def _fetch_entitled_feed_data( + self, + ) -> Tuple[List[str], List[TheplatformChannel]]: + """ + Steps 1-2 shared by get_channels() and the playback-time feed + fallback (resolve_release_pid_via_feed): + + 1. getApplicableDistributionRights — license_service_url with + Basic persona auth (cid correlation header). + 2. mdeprod-entitled-channels feed — filtered by the account's + distribution rights, paginated by fetch_entitled_channels_feed. + + Returns (distribution_rights, tp_channels); raises on failure so + callers choose their own degradation (get_channels wraps it into a + ProviderError-preserving exception chain, the fallback logs and + returns None). + """ + rights_url = ( + self._provider_config.manifest.mpx.license_service_url + if self._provider_config and self._provider_config.manifest + else None + ) + if not rights_url: + raise RuntimeError("No license_service_url available") + + cid = f"{self._session_id}::{uuid.uuid4()}" + user_agent = self._ua_subscriber + + persona_token = self._ensure_authenticated() + auth_headers = { + "Authorization": f"Basic {persona_token}", + "Origin": "https://www.magenta.tv", + "Referer": "https://www.magenta.tv/", + } + + distribution_rights = fetch_distribution_rights( + http_manager=self._http, + rights_url=rights_url, + cid=cid, + user_agent=user_agent, + timeout=DEFAULT_REQUEST_TIMEOUT, + extra_headers=auth_headers, + ) + + 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, + feed_url=feed_url, + distribution_rights=distribution_rights, + cid=cid, + user_agent=user_agent, + timeout=DEFAULT_REQUEST_TIMEOUT, + ) + return distribution_rights, tp_channels + def _fetch_station_metadata(self) -> Dict[str, Dict]: metadata: Dict[str, Dict] = {} self._station_to_playback_id.clear() diff --git a/lib/streaming_providers/providers/magenta2/constants.py b/lib/streaming_providers/providers/magenta2/constants.py index d8b89b3..ed67c66 100644 --- a/lib/streaming_providers/providers/magenta2/constants.py +++ b/lib/streaming_providers/providers/magenta2/constants.py @@ -269,6 +269,14 @@ OPENID_CONFIG_CACHE_DURATION = 86400 # 24 hours MANIFEST_CACHE_DURATION = 7200 # 2 hours SMIL_CACHE_DURATION = 3600 # 1 hour +# Minimum seconds between entitled-channels feed refetches triggered by a +# playback-time pid miss (ChannelManager.resolve_release_pid_via_feed — the +# fallback that bypasses the SMIL/selector path when it is unusable, e.g. +# UserAgentBlocked). Keeps a genuinely unentitled station from re-hitting the +# feed on every playback attempt; force=True on that method bypasses the gate +# (user-initiated retry / admin refresh). +ENTITLED_FEED_FALLBACK_MIN_INTERVAL = 300 + # ============================================================================ # Error Codes # ============================================================================ diff --git a/lib/streaming_providers/providers/magenta2/playback_manager.py b/lib/streaming_providers/providers/magenta2/playback_manager.py index 3d3448f..ea68e4a 100644 --- a/lib/streaming_providers/providers/magenta2/playback_manager.py +++ b/lib/streaming_providers/providers/magenta2/playback_manager.py @@ -37,6 +37,16 @@ load completes) — NOT by the live manifest/pid caches, which 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. +When neither the map nor a get_channels() refresh yields a vouched pid +(rate-limited refresh, stale map entry after a feed change), numeric +station ids get one last chance through the entitled-channels feed +fallback — ``ChannelManager.resolve_release_pid_via_feed`` (wrapped in +``_feed_fallback_pid``). That feed carries both the release pid and the +direct streamingUrl, so live manifest, DRM and catchup can be served +without any SMIL round-trip; it is the escape hatch for content whose +selector is user-agent-gated (observed: ``UserAgentBlocked`` error-SMIL, +"User Agent Restriction", for a Sky Sport UHD station). + Catchup routing --------------- Magenta2 catchup reuses the live DASH manifest URL with ``begin``/``end`` @@ -55,7 +65,9 @@ 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, 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. +so an unentitled or stale id cannot storm the feed. If the refresh +does not yield a vouched pid either, the entitled-channels feed fallback +may rewrite the caches (see ``_prepare_live_pid`` / ``_feed_fallback_pid``). SmilManager is NOT used for catchup on this provider. """ import base64 @@ -178,6 +190,11 @@ class PlaybackManager: # be small early after boot). self._last_populate_attempt: float = float("-inf") self._populate_lock = threading.Lock() + # One-shot warning flag for the (unsupported) case of a + # ChannelManager without resolve_release_pid_via_feed — i.e. this + # patched PlaybackManager running against an un-patched + # channel_manager.py. See _feed_fallback_pid. + self._warned_no_feed_fallback: bool = False # ------------------------------------------------------------------ # # Public API # @@ -204,7 +221,10 @@ class PlaybackManager: 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). + while the SMIL fetch was in flight) and, for numeric station + ids, the station is re-resolved once too (a concurrent + feed-fallback refresh may have REWRITTEN the station->pid + mapping, changing the pid itself). 3. **VOD / recordings**: Fall through to ``SmilManager.get_manifest``. """ if self._is_catchup_request(kwargs): @@ -237,11 +257,23 @@ class PlaybackManager: # 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) + fallback_pid = lookup_id + mpd = self._channel_manager.get_live_manifest_url(fallback_pid) + if not mpd and content_id.isdigit(): + # A concurrent feed-fallback refresh + # (ChannelManager.resolve_release_pid_via_feed) can also + # REWRITE the station->pid map — the pid itself may have + # changed while the SMIL fetch was in flight. One map read + # plus one cache read; still never a re-fetch. + re_resolved = self._resolve_station_id(content_id) + if re_resolved != lookup_id: + fallback_pid = re_resolved + mpd = self._channel_manager.get_live_manifest_url(fallback_pid) 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)" + f"entitled-feed MPD for {fallback_pid} " + "(may lack the SMIL path's session binding)" ) return mpd return result @@ -267,7 +299,11 @@ class PlaybackManager: 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. + a cached, persistent wrong-licence config. (The resolved pid + reaches this check only after the get_channels() refresh and — + for numeric station ids — the entitled-channels feed fallback + in ``_prepare_live_pid``; a pid still unvouched after both has + genuinely resisted two feed-level rescue attempts.) 3. **VOD / recordings**: Fall through to ``SmilManager.get_drm``. """ lookup_id = content_id @@ -395,6 +431,20 @@ class PlaybackManager: filled by populate_streaming_data (channel-playlist endpoint), which never updates the station map. + If the pid is STILL unvouched after that and *content_id* is a + numeric station id, the entitled-channels feed fallback + (``_feed_fallback_pid`` → ChannelManager.resolve_release_pid_via_feed) + gets the last word: it re-resolves the station from the feed, + which carries both the release pid and the direct streamingUrl, + so the live fast paths can serve manifest and DRM with no SMIL + at all — the escape hatch for user-agent-gated selectors + (``UserAgentBlocked`` error SMILs) and stale-map entries alike. + Its refetch is rate-limited independently of the get_channels() + refresh above (one attempt per ENTITLED_FEED_FALLBACK_MIN_INTERVAL, + shared across stations) and it never raises; on failure the + possibly-stale pid passes through and the caller degrades to + SmilManager. + Never raises: a failed feed fetch degrades to resolving against whatever map state exists, and callers fall through to SmilManager. """ @@ -419,6 +469,27 @@ class PlaybackManager: "_prepare_live_pid: feed fetch failed — degrading with the " f"current pid ({pid})" ) + if ( + not self._channel_manager.is_entitled_live_pid(pid) + and content_id.isdigit() + ): + # Entitled-channels-feed fallback: the pid is still not + # vouched — a stale map entry, a media pid from a map that + # pre-dates any feed load, or a station the (rate-limited) + # refresh could not fix. The feed carries release pid AND + # direct streamingUrl, so the live fast paths below can + # serve manifest/DRM/catchup without SMIL. Vouched fast + # path inside; refetch rate-limited independently of the + # refresh above; never raises (see _feed_fallback_pid). + feed_pid = self._feed_fallback_pid(content_id) + if feed_pid: + pid = feed_pid + else: + logger.debug( + "_prepare_live_pid: entitled-feed fallback yielded no " + f"vouched pid for station {content_id} — degrading to " + "SMIL with the current id" + ) 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) @@ -504,6 +575,49 @@ class PlaybackManager: ) return _LiveCachePopulateResult.FAILED + def _feed_fallback_pid(self, station_id: str) -> Optional[str]: + """ + Entitled-channels-feed fallback: wrap + ``ChannelManager.resolve_release_pid_via_feed`` for + ``_prepare_live_pid``. + + The wrapped method re-resolves *station_id* from the + entitled-channels feed — served from the + ``mpx_feed_entitledChannelsFeed`` endpoint (never a hardcoded + URL) — rewrites the station->pid map and the live + manifest/pid/HLS caches for every feed entry, and returns the + vouched release pid. None means the station is not in the feed + (not entitled on this account), the feed could not be fetched, or + the refetch was rate-limited; the caller then degrades to + SmilManager with the current id. Never raises. + + The getattr guard keeps this PlaybackManager usable against an + un-patched ChannelManager (the fallback patch spans several + files — channel_manager.py, constants.py, smil_manager.py and + this one); playback then degrades exactly as before, with one + warning instead of an AttributeError on every live request. + """ + resolver = getattr( + self._channel_manager, "resolve_release_pid_via_feed", None + ) + if resolver is None: + if not self._warned_no_feed_fallback: + self._warned_no_feed_fallback = True + logger.warning( + "ChannelManager has no resolve_release_pid_via_feed — " + "apply the magenta2 feed-fallback patch to " + "channel_manager.py to enable the playback-time fallback" + ) + return None + try: + return resolver(station_id) + except Exception as exc: # defensive: never break live playback + logger.warning( + f"_feed_fallback_pid: entitled-feed fallback failed for " + f"station {station_id}: {exc}" + ) + return None + def _build_live_drm(self, content_id: str) -> List[DRMConfig]: """ Build a Widevine DRMConfig directly for a live channel, bypassing SMIL. diff --git a/lib/streaming_providers/providers/magenta2/smil_manager.py b/lib/streaming_providers/providers/magenta2/smil_manager.py index 4418053..3e88ba7 100644 --- a/lib/streaming_providers/providers/magenta2/smil_manager.py +++ b/lib/streaming_providers/providers/magenta2/smil_manager.py @@ -185,8 +185,10 @@ class SmilManager: release_pid = smil_data.get("release_pid") if not release_pid: + error_exception = smil_data.get("error_exception") logger.error( f"{self._provider}: No releasePid in SMIL for {content_id}" + + (f" [{error_exception}]" if error_exception else "") ) if smil_data.get("content"): logger.debug( @@ -350,6 +352,12 @@ class SmilManager: "mpd_url": mpd_url, "release_pid": release_pid, "channel_id": cache_key, + # Failure reason carried by an error-SMIL body (observed: + # UserAgentBlocked — the selector refuses this content for + # the requesting user agent). None for successful resolves; + # lets callers and logs distinguish "not entitled" from + # "selector blocked" without re-parsing the raw content. + "error_exception": SmilManager._parse_error_exception(smil_content), } if mpd_url or release_pid: @@ -582,8 +590,10 @@ class SmilManager: title = error_match.group(1) abstract = error_match.group(2) if "errorFiles/Unavailable.flv" in smil_content: + exception = SmilManager._parse_error_exception(smil_content) logger.error( f"SMIL unavailable content for {content_id}: {title}" + + (f" (exception={exception})" if exception else "") ) return None if "Invalid Token" in title or "InvalidAuthToken" in smil_content: @@ -640,6 +650,25 @@ class SmilManager: logger.error(f"Error parsing SMIL for {content_id}: {e}") return None + @staticmethod + def _parse_error_exception(smil_content: str) -> Optional[str]: + """ + Platform error code from an error-SMIL body, if any. + + Error SMILs carry the failure reason as a + (observed values: UserAgentBlocked — the selector refuses this + content for the requesting user agent; InvalidAuthToken). Success + SMILs have no such param. + """ + try: + m = re.search( + r' Optional[str]: """Extract releasePid from the SMIL trackingData parameter."""