magenta2: add fallback releasepid

This commit is contained in:
Nirvana
2026-10-21 18:54:09 +02:00
parent 49a99cb32c
commit 4241c721c9
5 changed files with 452 additions and 190 deletions
@@ -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()
@@ -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
@@ -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(
@@ -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
)
@@ -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
return MAGENTA2_FALLBACK_ACCOUNT_URI