mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-10-28 17:52:58 +01:00
magenta2: add fallback
This commit is contained in:
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user