From 2b709622571d24ecabe38e1e95f39ad623bcfb19 Mon Sep 17 00:00:00 2001 From: Nirvana Date: Tue, 9 Jun 2026 16:33:13 +0200 Subject: [PATCH] magenta2 catchup --- .../providers/magenta2/playback_manager.py | 188 ++++++++++++++++-- 1 file changed, 175 insertions(+), 13 deletions(-) diff --git a/lib/streaming_providers/providers/magenta2/playback_manager.py b/lib/streaming_providers/providers/magenta2/playback_manager.py index 6d3d76d..3dde9d5 100644 --- a/lib/streaming_providers/providers/magenta2/playback_manager.py +++ b/lib/streaming_providers/providers/magenta2/playback_manager.py @@ -9,15 +9,37 @@ Responsibilities (avoiding a SMIL round-trip for channels already fetched by get_channels). - Build Widevine licence URLs directly for live channels using lib_theplatform. - Delegate VOD and recording manifest/DRM requests to SmilManager. +- Serve catchup manifests by appending ``dvr_window_length`` to the cached + live 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``. -- Provide ``get_catchup_manifest`` via SmilManager. The class holds NO state of its own beyond references to the managers and callbacks passed at construction time. All caches belong to ChannelManager. + +Catchup routing +--------------- +Magenta2 catchup is DVR-based: the live DASH manifest URL is reused with a +``dvr_window_length`` query parameter (in seconds) that tells the CDN how far +back the sliding window should reach. Example: + + Live: https://svc45.…/zdf_hd/DASH/index.mpd?AppVersion=… + Catchup: https://svc45.…/zdf_hd/DASH/index.mpd?AppVersion=…&dvr_window_length=14400 + +The window length is derived from the provider's ``catchup_window`` property +(hours) and is passed in as ``dvr_window_seconds`` from the caller, or falls +back to ``DVR_WINDOW_SECONDS_DEFAULT`` (4 h = 14 400 s). + +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. +SmilManager is NOT used for catchup on this provider. """ import base64 from typing import Callable, Dict, List, Optional +from urllib.parse import urlencode, urlparse, urlunparse, parse_qs from ...base.models import DRMConfig from ...base.utils.logger import logger @@ -32,6 +54,10 @@ from ..lib_theplatform import ( build_widevine_drm_config, ) +# Default DVR window: 4 hours in seconds. Matches provider.catchup_window = 4. +DVR_WINDOW_SECONDS_DEFAULT: int = 4 * 3600 # 14 400 +DVR_WINDOW_PARAM: str = "dvr_window_length" + class PlaybackManager: """ @@ -84,10 +110,18 @@ class PlaybackManager: """ Return the MPD manifest URL for *content_id*. - Live channels whose manifest was already fetched by get_channels() are - served directly from the ChannelManager cache — no SMIL round-trip. - VOD and recordings fall through to SmilManager. + Routing logic + ~~~~~~~~~~~~~ + 1. **Catchup** (``start_time`` + ``end_time`` present in kwargs): + Appends ``dvr_window_length`` to the cached live manifest URL. + No SMIL round-trip. The live cache is populated on demand if empty. + 2. **Live** (``content_type == CONTENT_TYPE_LIVE``, no time window): + Served directly from the ChannelManager cache when available. + 3. **VOD / recordings**: Fall through to ``SmilManager.get_manifest``. """ + if self._is_catchup_request(kwargs): + return self._get_catchup_manifest(content_id, kwargs) + if content_type == CONTENT_TYPE_LIVE: self._ensure_live_cache() if content_id in self._channel_manager._live_manifest_cache: @@ -105,10 +139,35 @@ class PlaybackManager: """ Return DRM configuration for *content_id*. - For live channels whose releasePid is cached, the Widevine licence URL - is built directly using lib_theplatform — no SMIL fetch needed. - VOD and recordings fall through to SmilManager. + Routing logic + ~~~~~~~~~~~~~ + 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. + 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. + 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" + ) + 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: @@ -122,17 +181,120 @@ class PlaybackManager: def get_catchup_manifest( self, channel_id: str, start_time: int, end_time: int, **kwargs ) -> Optional[str]: - """Return a catchup manifest URL via SmilManager.""" - if not self._smil_manager: - raise RuntimeError("SmilManager not available") - return self._smil_manager.get_catchup_manifest( - channel_id, start_time, end_time, **kwargs - ) + """ + Return a catchup manifest URL for *channel_id*. + + Builds the DVR URL from the cached live manifest by appending + ``dvr_window_length``. ``start_time`` and ``end_time`` are accepted + for interface compatibility but are not used — Magenta2 catchup is a + sliding DVR window, not a fixed time-range VOD asset. + + Parameters + ---------- + channel_id: + The live channel content_id (same key used in the live cache). + start_time: + Unix timestamp (seconds) of the catchup window start. Accepted + for API compatibility; not forwarded to the CDN. + end_time: + Unix timestamp (seconds) of the catchup window end. Accepted + for API compatibility; not forwarded to the CDN. + dvr_window_seconds: + Optional override (via kwargs) for the DVR window length in + seconds. Defaults to ``DVR_WINDOW_SECONDS_DEFAULT`` (14 400). + """ + dvr_seconds: int = kwargs.get("dvr_window_seconds", DVR_WINDOW_SECONDS_DEFAULT) + return self._build_dvr_manifest_url(channel_id, dvr_seconds) # ------------------------------------------------------------------ # # Internal helpers # # ------------------------------------------------------------------ # + @staticmethod + def _is_catchup_request(kwargs: Dict) -> bool: + """ + Return True when *kwargs* carries both ``start_time`` and ``end_time``, + indicating a time-shifted / catchup playback request. + """ + return ( + kwargs.get("start_time") is not None + and kwargs.get("end_time") is not None + ) + + def _get_catchup_manifest(self, content_id: str, kwargs: Dict) -> Optional[str]: + """ + Build a DVR manifest URL for a catchup request arriving via + ``get_manifest``. + + Extracts an optional ``dvr_window_seconds`` override from kwargs; + otherwise derives the window from ``end_time - start_time`` when both + are present, capped at ``DVR_WINDOW_SECONDS_DEFAULT``. + """ + # Prefer an explicit override; otherwise derive from the requested + # time window so the DVR slider covers at least the requested range. + if "dvr_window_seconds" in kwargs: + dvr_seconds: int = kwargs["dvr_window_seconds"] + else: + start_time: int = kwargs["start_time"] + end_time: int = kwargs["end_time"] + requested_window = end_time - start_time + # Never request a window smaller than the requested range, but + # cap at the provider default to avoid oversized requests. + dvr_seconds = max( + min(requested_window, DVR_WINDOW_SECONDS_DEFAULT), + DVR_WINDOW_SECONDS_DEFAULT, + ) + + logger.debug( + f"get_manifest: catchup request for {content_id} " + f"(start={kwargs['start_time']}, end={kwargs['end_time']}, " + f"dvr_window_seconds={dvr_seconds})" + ) + return self._build_dvr_manifest_url(content_id, dvr_seconds) + + def _build_dvr_manifest_url(self, content_id: str, dvr_seconds: int) -> Optional[str]: + """ + Retrieve the cached live manifest URL for *content_id* and append + (or replace) the ``dvr_window_length`` query parameter. + + Returns ``None`` if the channel is not found in the live cache even + after attempting to populate it. + """ + self._ensure_live_cache() + + live_url = self._channel_manager._live_manifest_cache.get(content_id) + if not live_url: + logger.error( + f"_build_dvr_manifest_url: no live manifest cached for {content_id}; " + "cannot build catchup URL" + ) + return None + + dvr_url = self._append_dvr_param(live_url, dvr_seconds) + logger.debug( + f"_build_dvr_manifest_url: {content_id} → {dvr_url}" + ) + return dvr_url + + @staticmethod + def _append_dvr_param(url: str, dvr_seconds: int) -> str: + """ + Return *url* with ``dvr_window_length=`` set in the query + string. Any pre-existing ``dvr_window_length`` value is replaced so + that repeated calls are idempotent. + """ + parsed = urlparse(url) + # Parse existing query parameters, preserving all existing keys. + # parse_qs returns lists; rebuild as a flat dict for urlencode. + existing: Dict[str, List[str]] = parse_qs(parsed.query, keep_blank_values=True) + # Replace (or add) the DVR param — overwrite any existing value. + existing[DVR_WINDOW_PARAM] = [str(dvr_seconds)] + new_query = urlencode( + {k: v[0] for k, v in existing.items()}, + safe="", + ) + return urlunparse(parsed._replace(query=new_query)) + def _ensure_live_cache(self) -> None: """ Bootstrap the live-manifest / live-pid caches on demand by calling