From b280efbc77e92cd53ca006f58c399eb9f6f0854e Mon Sep 17 00:00:00 2001 From: Nirvana Date: Thu, 11 Jun 2026 10:20:16 +0200 Subject: [PATCH] catchup window from channels --- routes/streams.py | 116 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 87 insertions(+), 29 deletions(-) diff --git a/routes/streams.py b/routes/streams.py index b06acd0..779bca9 100644 --- a/routes/streams.py +++ b/routes/streams.py @@ -241,18 +241,40 @@ def setup_stream_routes(app, manager, service): return None def _stream_needs_headers( - content_type: str, provider: str, content_id: str, country=None + content_type: str, + provider: str, + content_id: str, + country=None, + # catchup-specific — forwarded so providers can return catchup-appropriate headers + is_catchup: bool = False, + start_time: int = None, + end_time: int = None, + epg_id: str = None, ) -> bool: """ Returns True if the provider requires manifest or segment headers for this content — meaning a plain redirect would lose those headers and playback would likely fail. + + Catchup context (is_catchup, start_time, end_time, epg_id) is forwarded + to the provider so it can return headers appropriate for the DVR/catchup + endpoint rather than the live endpoint. Without this, providers that use + different auth tokens for catchup would return live headers here and the + proxy decision could be wrong. """ try: provider_instance = manager.get_provider(provider) if not provider_instance: return False - kwargs = {"country": country} if country else {} + kwargs = {} + if country: + kwargs["country"] = country + if is_catchup and content_type == CONTENT_TYPE_CHANNEL: + kwargs.update( + start_time=start_time, + end_time=end_time, + epg_id=epg_id, + ) manifest_headers = provider_instance.get_manifest_headers(content_id, **kwargs) or {} segment_headers = provider_instance.get_segment_headers(content_id, **kwargs) or {} return bool(manifest_headers or segment_headers) @@ -506,44 +528,29 @@ def setup_stream_routes(app, manager, service): elif is_unencrypted: - + # Decrypted-stream endpoints do not support catchup — catchup requires a + # live DVR manifest URL which must be resolved via _resolve_stream / the + # catchup path. Unencrypted content here is always VOD or live-redirect. needs_headers = _stream_needs_headers(content_type, provider, content_id, country) + needs_proxy = manager.needs_proxy(provider) - needs_proxy = manager.needs_proxy(provider) # ← ADD THIS - - if (needs_headers or needs_proxy) and service.media_proxy_url: # ← include needs_proxy - + if (needs_headers or needs_proxy) and service.media_proxy_url: return service.get_proxied_manifest( - provider, content_id, - highest_quality_only=highest_quality_only, - ) - - elif (needs_headers or needs_proxy) and not service.media_proxy_url: # ← include needs_proxy - + elif (needs_headers or needs_proxy) and not service.media_proxy_url: logger.warning( - f"Provider {provider}/{content_id} needs proxy/headers but MEDIA_PROXY_URL is not set; " - "falling back to redirect (playback may fail)" - ) - manifest_url = _get_manifest_url(content_type, provider, content_id, country=country) - return redirect(manifest_url) - else: - manifest_url = _get_manifest_url(content_type, provider, content_id, country=country) - if not manifest_url: response.status = 404 - return {"error": f'Manifest not available for {content_type} "{content_id}"'} - return redirect(manifest_url) else: @@ -579,6 +586,10 @@ def setup_stream_routes(app, manager, service): Response includes both stream_url (auto DRM) and sw_drm_stream_url (software / ClearKey DRM) so callers can pick the appropriate variant without a second round-trip. + + Also includes catchup_stream_url_template — a URL with {start_time} and + {end_time} placeholders (Unix timestamps) that callers can expand for + DVR/catchup playback, avoiding the need to construct the URL manually. """ try: country = request.query.get("country") @@ -592,6 +603,14 @@ def setup_stream_routes(app, manager, service): f"{base_url}/api/providers/{provider}/channels/{channel_id}" f"/stream/sw-drm/index.mpd{qs}" ) + # Catchup template — callers substitute {start_time}/{end_time} with + # Unix timestamps. Matches the query params consumed by _handle_channel_stream. + catchup_qs_sep = "&" if qs else "?" + catchup_stream_url_template = ( + f"{base_url}/api/providers/{provider}/channels/{channel_id}" + f"/stream/index.mpd{qs}{catchup_qs_sep}" + f"start_time={{start_time}}&end_time={{end_time}}" + ) _build_drm_header(CONTENT_TYPE_CHANNEL, provider, channel_id, country=country) _build_stream_headers(CONTENT_TYPE_CHANNEL, provider, channel_id, country=country) @@ -601,6 +620,7 @@ def setup_stream_routes(app, manager, service): "channel_id": channel_id, "manifest_url": stream_url, "sw_drm_manifest_url": sw_drm_stream_url, + "catchup_stream_url_template": catchup_stream_url_template, } except ValueError as e: @@ -693,28 +713,66 @@ def setup_stream_routes(app, manager, service): country = request.query.get("country") is_catchup = bool(start_time and end_time) + logger.debug( + f"_handle_channel_stream: provider={provider} channel={channel_id} " + f"start_time={start_time!r} end_time={end_time!r} " + f"epg_id={epg_id!r} country={country!r} is_catchup={is_catchup} " + f"drm_variant={drm_variant}" + ) + + # Always defined so the _resolve_stream call below is unconditionally safe, + # even though the ternary guards already prevent None from being passed when + # is_catchup is False. + start_time_int: int | None = None + end_time_int: int | None = None + if is_catchup: - logger.debug(f"CATCHUP REQUEST DETECTED: start={start_time}, end={end_time}") try: start_time_int = int(start_time) end_time_int = int(end_time) - logger.debug(f"CATCHUP TIMES CONVERTED: {start_time_int} to {end_time_int}") + logger.debug(f"CATCHUP: times parsed OK: {start_time_int} to {end_time_int}") except (ValueError, TypeError): + logger.warning( + f"CATCHUP: could not parse start_time={start_time!r} / end_time={end_time!r} as int" + ) response.status = 400 return {"error": "Invalid start_time or end_time format"} channels = manager.get_channels(provider_name=provider, fetch_manifests=False) channel_obj = next((c for c in channels if c.channel_id == channel_id), None) - catchup_hours = getattr(channel_obj, "catchup_hours", 0) if channel_obj else 0 - if catchup_hours == 0: + logger.debug( + f"CATCHUP: channel lookup id={channel_id!r} -> " + + (f"found (catchup_hours attr={getattr(channel_obj, 'catchup_hours', 'MISSING')!r}, " + f"catchup_window attr={getattr(channel_obj, 'catchup_window', 'MISSING')!r})" + if channel_obj else "NOT FOUND in channel list") + ) + + # The model field is catchup_hours (serialises as CatchupHours). + # Fall back to catchup_window for providers using the older name. + catchup_hours = ( + getattr(channel_obj, "catchup_hours", None) + or getattr(channel_obj, "catchup_window", 0) + ) if channel_obj else 0 + logger.debug(f"CATCHUP: resolved catchup_hours={catchup_hours!r}") + + if not catchup_hours: + logger.warning( + f"CATCHUP: rejecting {provider}/{channel_id} — " + f"catchup_hours=0 or attribute not found on channel model" + ) response.status = 400 - return {"error": f'Catchup not supported for provider "{provider}"'} + return {"error": f'Catchup not supported for channel "{channel_id}"'} import time - if (int(time.time()) - start_time_int) > catchup_hours * 3600: + age_seconds = int(time.time()) - start_time_int + logger.debug( + f"CATCHUP: window check age={age_seconds}s limit={catchup_hours * 3600}s ({catchup_hours}h)" + ) + if age_seconds > catchup_hours * 3600: response.status = 400 return {"error": f"Content outside catchup window (max {catchup_hours} hours)"} + return _resolve_stream( CONTENT_TYPE_CHANNEL, provider, channel_id, country=country,