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."""