magenta2: add fallback

This commit is contained in:
Nirvana
2026-10-26 15:27:58 +01:00
parent c947bf17f8
commit 52ee0484bc
4 changed files with 426 additions and 62 deletions
@@ -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()
@@ -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
# ============================================================================
@@ -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.
@@ -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 <param name="exception">
(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'<param\s+name="exception"\s+value="([^"]+)"', smil_content
)
return m.group(1) if m else None
except Exception as e:
logger.debug(f"Error extracting SMIL exception code: {e}")
return None
@staticmethod
def _parse_release_pid(smil_content: str) -> Optional[str]:
"""Extract releasePid from the SMIL trackingData parameter."""