From 9289b400f7ebca2f2ea6922202dda14769cd076f Mon Sep 17 00:00:00 2001 From: Nirvana Date: Fri, 21 Aug 2026 14:53:47 +0200 Subject: [PATCH] harmonize proxied and normal --- routes/streams/__init__.py | 124 +++++++++++++++++++++++++++++++++++-- service.py | 18 +++++- 2 files changed, 134 insertions(+), 8 deletions(-) diff --git a/routes/streams/__init__.py b/routes/streams/__init__.py index 8eb2076..1eb5def 100644 --- a/routes/streams/__init__.py +++ b/routes/streams/__init__.py @@ -30,6 +30,7 @@ helpers: import base64 import json import re +import time from urllib.parse import urljoin from bottle import HTTPResponse, redirect, request, response @@ -410,6 +411,39 @@ def make_helpers(manager, service): count=1, ) + def _validate_catchup_window(provider: str, content_id: str, start_time: int): + """ + Checks start_time against the channel's catchup_hours window. + Returns an error dict (suitable for a 400 response) if the channel + doesn't support catchup or the window has been exceeded; None if valid. + + Extracted from channels.py's _handle_channel_stream so + _resolve_decrypted_stream can apply the same check — previously it + skipped this validation entirely, letting catchup requests outside the + provider's DVR window through to the decrypted-stream path when the + standard path would reject them with a 400. + """ + channels = manager.get_channels(provider_name=provider, fetch_manifests=False) + channel_obj = next((c for c in channels if c.channel_id == content_id), None) + + catchup_hours = ( + getattr(channel_obj, "catchup_hours", None) + or getattr(channel_obj, "catchup_window", 0) + ) if channel_obj else 0 + + if not catchup_hours: + logger.warning( + f"CATCHUP: rejecting {provider}/{content_id} — " + f"catchup_hours=0 or attribute not found on channel model" + ) + return {"error": f'Catchup not supported for channel "{content_id}"'} + + age_seconds = int(time.time()) - start_time + if age_seconds > catchup_hours * 3600: + return {"error": f"Content outside catchup window (max {catchup_hours} hours)"} + + return None + def _resolve_stream( content_type: str, provider: str, @@ -464,6 +498,35 @@ def make_helpers(manager, service): # --- Catchup path (channel-specific) --- if is_catchup: if manager.needs_proxy(provider) and not no_proxy: + # Reuse the catchup DRM configs _build_drm_header already fetched + # above (it calls manager.get_catchup_drm_configs when is_catchup + # is True) rather than fetching a second time — same pattern the + # live branch below uses for its own DRM configs. + # + # Previously this branch always called get_proxied_catchup_manifest + # regardless of DRM scheme, unlike the live branch which checks for + # ClearKey first. That meant ClearKey-protected catchup content was + # served through the plain proxy path with stripped ContentProtection + # instead of the receiver-side rewrite the player needs. + drm_dict = _serialize_drm_configs(drm_configs_for_header) + keyids = ( + drm_dict.get("org.w3.clearkey", {}) + .get("license", {}) + .get("keyids", {}) + ) + + if keyids: + logger.debug( + f"ClearKey DRM detected for catchup {provider}/{content_id} — " + "using receiver-side ClearKey rewrite" + ) + return service.get_decrypted_catchup_manifest( + provider, content_id, + start_time=start_time, end_time=end_time, + keyids=keyids, epg_id=epg_id, + receiver_side=True, + ) + return service.get_proxied_catchup_manifest( provider, content_id, start_time, end_time, epg_id, country ) @@ -629,6 +692,17 @@ def make_helpers(manager, service): response.status = 400 return {"error": "Invalid start_time or end_time format"} + # Same catchup-window check the standard /stream/index.mpd path + # applies (channels.py's _handle_channel_stream) — this endpoint + # was bypassing it entirely, letting requests outside the + # provider's DVR window through where the standard path would + # reject them with a 400. + if content_type == CONTENT_TYPE_CHANNEL: + window_error = _validate_catchup_window(provider, content_id, start_time_int) + if window_error: + response.status = 400 + return window_error + if not service.media_proxy_url: response.status = 503 return {"error": "Media proxy not configured (MEDIA_PROXY_URL not set)"} @@ -719,11 +793,50 @@ def make_helpers(manager, service): 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) + # No proxy/headers needed — mirrors _resolve_stream's equivalent + # branch, which checks requires_manifest_context before deciding + # between a fetch-and-inject-BaseURL response and a plain redirect. + # This endpoint previously redirected unconditionally here, which + # breaks for any provider needing manifest context (relative + # segment URLs resolve against the wrong base after a bare 302). + provider_instance = manager.get_provider(provider) + + if provider_instance is None: + logger.warning( + f"_resolve_decrypted_stream: manager.get_provider('{provider}') " + "returned None — cannot check requires_manifest_context; " + "falling back to redirect" + ) + + if provider_instance and getattr(provider_instance, 'requires_manifest_context', False): + logger.debug( + f"Provider {provider} requires manifest context — fetching manifest " + f"directly for {content_type}/{content_id}" + ) + 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}"'} + try: + manifest_text, _, _, _, effective_url = service.fetch_manifest_for_rewriter( + provider, content_id, manifest_url + ) + manifest_text = _inject_base_url(manifest_text, effective_url) + response.content_type = "application/dash+xml; charset=utf-8" + return manifest_text + except Exception as e: + logger.error( + f"Failed to fetch manifest for {content_type}/{content_id} " + f"from {provider}: {e}" + ) + response.status = 502 + return {"error": f"Failed to fetch manifest: {str(e)}"} + 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: response.status = 400 @@ -757,6 +870,7 @@ def make_helpers(manager, service): "_build_stream_headers": _build_stream_headers, "_stream_needs_headers": _stream_needs_headers, "_inject_base_url": _inject_base_url, + "_validate_catchup_window": _validate_catchup_window, "_resolve_stream": _resolve_stream, "_resolve_decrypted_stream": _resolve_decrypted_stream, "_serialize_drm_configs": _serialize_drm_configs, diff --git a/service.py b/service.py index 11bed06..31ada3b 100644 --- a/service.py +++ b/service.py @@ -553,6 +553,11 @@ class UltimateService: response.content_type = "application/dash+xml; charset=utf-8" return cached_mpd + if not self.media_proxy_url: + response.status = 503 + response.content_type = "application/json" + return json.dumps({"error": "Media proxy not configured (MEDIA_PROXY_URL not set)"}) + logger.info( f"Generating {'receiver-side clearkey' if receiver_side else 'decrypted'} manifest " f"for {provider}/{channel_id} (highest_quality_only={highest_quality_only}, drm_variant={drm_variant})" @@ -590,7 +595,8 @@ class UltimateService: def get_decrypted_catchup_manifest(self, provider: str, channel_id: str, start_time: int, end_time: int, keyids: dict, epg_id: str = None, - highest_quality_only: bool = False) -> str: + highest_quality_only: bool = False, + receiver_side: bool = False) -> str: country = request.query.get("country") cache_key = f"{provider}_{channel_id}_catchup_{start_time}_{end_time}_drm" @@ -600,8 +606,14 @@ class UltimateService: response.content_type = "application/dash+xml; charset=utf-8" return cached_mpd + if not self.media_proxy_url: + response.status = 503 + response.content_type = "application/json" + return json.dumps({"error": "Media proxy not configured (MEDIA_PROXY_URL not set)"}) + logger.info( - f"Generating decrypted catchup manifest for {provider}/{channel_id} " + f"Generating {'receiver-side clearkey' if receiver_side else 'decrypted'} catchup manifest " + f"for {provider}/{channel_id} " f"(start={start_time} end={end_time} highest_quality_only={highest_quality_only})" ) @@ -621,7 +633,7 @@ class UltimateService: ) rewriter = MPDRewriter( self.media_proxy_url, provider_proxy_url, keyids, highest_quality_only, - provider=provider, channel=channel_id, clearkey_receiver_side=False, + provider=provider, channel=channel_id, clearkey_receiver_side=receiver_side, segment_headers=segment_headers, ) rewritten_mpd = rewriter.rewrite_mpd(manifest_text, effective_url)