diff --git a/routes/streams/__init__.py b/routes/streams/__init__.py index 1eb5def..e573815 100644 --- a/routes/streams/__init__.py +++ b/routes/streams/__init__.py @@ -444,7 +444,79 @@ def make_helpers(manager, service): return None - def _resolve_stream( + def _redirect_or_fetch(content_type: str, provider: str, content_id: str, + country, drm_variant: str): + """ + Shared tail behavior for every "no proxy involved" outcome, across all + three modes: check requires_manifest_context and either fetch + inject + BaseURL, or do a plain redirect. Extracted so mode="noproxy" (live/vod), + mode="auto"'s non-proxied branch, and mode="decrypt"'s unencrypted/ + no-proxy-needed branch all get identical, correct behavior instead of + three independently-maintained copies (previously the decrypt path had + none of this at all and always redirected unconditionally). + """ + provider_instance = manager.get_provider(provider) + + if provider_instance is None: + logger.warning( + f"_redirect_or_fetch: 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 directly " + f"for {content_type}/{content_id}" + ) + try: + manifest_url = _get_manifest_url( + content_type, provider, content_id, + country=country, drm_variant=drm_variant, + ) + if not manifest_url: + response.status = 404 + return { + "error": ( + f'Manifest not available for {content_type} ' + f'"{content_id}" from provider "{provider}"' + ) + } + + manifest_text, _, _, _, effective_url = service.fetch_manifest_for_rewriter( + provider, content_id, manifest_url + ) + # Inject the upstream manifest URL as a BaseURL so the player can + # resolve relative segment URLs correctly. Without this, segments + # resolve against the local server URL and all requests fail. + 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, drm_variant=drm_variant, + ) + if not manifest_url: + response.status = 404 + return { + "error": ( + f'Manifest not available for {content_type} ' + f'"{content_id}" from provider "{provider}"' + ) + } + logger.debug(f"Redirecting to manifest: {manifest_url}") + return redirect(manifest_url) + + def _resolve_stream_unified( content_type: str, provider: str, content_id: str, @@ -455,27 +527,60 @@ def make_helpers(manager, service): end_time: int = None, epg_id: str = None, drm_variant: str = "auto", - no_proxy: bool = False, + mode: str = "auto", + receiver_side: bool = True, + highest_quality_only: bool = False, ): """ - Core stream resolution: attach DRM header, then either redirect to the - upstream manifest or return a proxy-rewritten manifest body. + Single resolver for every stream endpoint — replaces the former + _resolve_stream / _resolve_decrypted_stream split. Both names still + exist below as thin wrappers for existing call sites. Args: - drm_variant: 'auto' (provider decides) or 'software' (prefer ClearKey / - software-decodable DRM). Threaded through to all helpers so - providers can return the correct manifest URL and DRM configs. - no_proxy: If True, force the redirect-to-upstream branch even for - providers that would normally be proxied. This overrides the - manager.needs_proxy(provider) checks below rather than adding - a separate code path, so catchup, DRM header building, and - requires_manifest_context handling all continue to work - exactly as they do for the proxied case — only the - proxy-vs-redirect decision changes. + drm_variant: 'auto' (provider decides) or 'software'. This selects + which upstream DRM/quality variant to request (e.g. a + provider's L1 vs L3 Widevine stream) — orthogonal to + `mode`, which governs transport (proxy/decrypt/redirect). + mode: "auto" - proxy if the provider needs it, else redirect/fetch. + ClearKey content is always rewritten receiver-side + (client decrypts) — this is the historical /stream/ + contract and is not caller-configurable. + "decrypt" - server-side ClearKey decryption via the media + proxy (receiver_side controls client- vs + server-side decrypt). Requires MEDIA_PROXY_URL. + Unencrypted content falls through to the same + proxy/redirect behavior as mode="auto" — there's + nothing to decrypt but nothing blocking playback + either. Content that's neither ClearKey nor + unencrypted (e.g. Widevine-only) is rejected with + a 400, since decrypt mode can't do anything useful + with it — applies equally to live/VOD and catchup. + "noproxy" - force redirect/fetch even if the provider would + normally be proxied. + receiver_side: Only consulted when mode="decrypt". True = client + decrypts (ClearKey signaled in the manifest), False = + server decrypts and serves plaintext segments. + highest_quality_only: Honored wherever the underlying service call + supports it (get_proxied_manifest, get_decrypted_manifest, + get_decrypted_catchup_manifest). get_proxied_catchup_manifest + has no such parameter today, so it's a no-op for + mode="auto"/"decrypt" catchup content that turns out + unencrypted. Meaningless for mode="noproxy" (plain + redirect, no rewriter involved). """ + # --- Catchup window validation (channels only), before anything else — + # previously duplicated between channels.py (for auto/noproxy) and + # _resolve_decrypted_stream (for decrypt); now the single source of truth + # for every mode, so channels.py no longer needs its own copy. --- + if is_catchup and content_type == CONTENT_TYPE_CHANNEL: + window_error = _validate_catchup_window(provider, content_id, start_time) + if window_error: + response.status = 400 + return window_error + # --- DRM header (best-effort, never fatal). Also returns the DRM - # configs it fetched so the proxy branch below can reuse them instead - # of fetching from the provider a second time. --- + # configs it fetched so the branches below can reuse them instead of + # fetching from the provider a second time. --- drm_configs_for_header = _build_drm_header( content_type, provider, content_id, country=country, @@ -495,26 +600,69 @@ def make_helpers(manager, service): epg_id=epg_id, ) - # --- 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", {}) - ) + drm_dict = _serialize_drm_configs(drm_configs_for_header) + keyids = ( + drm_dict.get("org.w3.clearkey", {}) + .get("license", {}) + .get("keyids", {}) + ) + is_unencrypted = "none" in drm_dict + # ================================================================== + # Catchup path (channel-specific) + # ================================================================== + if is_catchup: + if mode == "noproxy": + manifest_url = manager.get_catchup_manifest( + provider_name=provider, + channel_id=content_id, + start_time=start_time, + end_time=end_time, + epg_id=epg_id, + country=country, + drm_variant=drm_variant, + ) + if not manifest_url: + response.status = 404 + return { + "error": f'Catchup manifest not available for channel "{content_id}"' + } + logger.debug(f"Redirecting to catchup manifest: {manifest_url}") + return redirect(manifest_url) + + if mode == "decrypt": + if keyids: + if not service.media_proxy_url: + response.status = 503 + return {"error": "Media proxy not configured (MEDIA_PROXY_URL not set)"} + 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=receiver_side, + highest_quality_only=highest_quality_only, + ) + elif is_unencrypted: + # Nothing to decrypt, but nothing blocking playback either — + # same fallback mode="auto" would use for this content. + return service.get_proxied_catchup_manifest( + provider, content_id, start_time, end_time, epg_id, country + ) + else: + # Encrypted but not ClearKey (e.g. Widevine-only catchup) — + # decrypt mode genuinely can't act on this, matching the + # live/VOD path's rejection below rather than silently + # proxying content the client likely can't play anyway. + response.status = 400 + return { + "error": ( + f'Catchup content for channel "{content_id}" does not support ' + f"decrypted playback (requires ClearKey or unencrypted)" + ) + } + + # mode == "auto" + if manager.needs_proxy(provider): if keyids: logger.debug( f"ClearKey DRM detected for catchup {provider}/{content_id} — " @@ -525,8 +673,8 @@ def make_helpers(manager, service): start_time=start_time, end_time=end_time, keyids=keyids, epg_id=epg_id, receiver_side=True, + highest_quality_only=highest_quality_only, ) - return service.get_proxied_catchup_manifest( provider, content_id, start_time, end_time, epg_id, country ) @@ -546,26 +694,56 @@ def make_helpers(manager, service): "error": f'Catchup manifest not available for channel "{content_id}"' } logger.debug(f"Redirecting to catchup manifest: {manifest_url}") - redirect(manifest_url) + return redirect(manifest_url) - # --- Live / event / vod path --- - if manager.needs_proxy(provider) and not no_proxy: - # Check for ClearKey DRM — if present, rewrite manifest with ClearKey signaling - # so the receiver can decrypt itself, rather than serving a plain proxy stream - # with stripped ContentProtection that the player cannot handle. - # - # Reuse the configs _build_drm_header already fetched above rather than - # calling the provider again. This branch is only reached when - # is_catchup is False, so drm_configs_for_header always corresponds to - # the non-catchup fetch performed above. - drm_dict = _serialize_drm_configs(drm_configs_for_header) + # ================================================================== + # Live / event / vod / recording path + # ================================================================== + if mode == "noproxy": + return _redirect_or_fetch(content_type, provider, content_id, country, drm_variant) - keyids = ( - drm_dict.get("org.w3.clearkey", {}) - .get("license", {}) - .get("keyids", {}) - ) + if mode == "decrypt": + if keyids: + if not service.media_proxy_url: + response.status = 503 + return {"error": "Media proxy not configured (MEDIA_PROXY_URL not set)"} + return service.get_decrypted_manifest( + provider, content_id, keyids, + receiver_side=receiver_side, + drm_variant=drm_variant, + highest_quality_only=highest_quality_only, + ) + elif is_unencrypted: + needs_headers = _stream_needs_headers(content_type, provider, content_id, country) + needs_proxy = manager.needs_proxy(provider) + 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: + logger.warning( + f"Provider {provider}/{content_id} needs proxy/headers but MEDIA_PROXY_URL is not set; " + "falling back to redirect (playback may fail)" + ) + return _redirect_or_fetch(content_type, provider, content_id, country, drm_variant) + else: + return _redirect_or_fetch(content_type, provider, content_id, country, drm_variant) + else: + # Neither ClearKey nor unencrypted (e.g. Widevine-only) — decrypt + # mode has nothing useful to do with this, unlike mode="auto" + # which would happily proxy it for the client's own DRM stack. + response.status = 400 + return { + "error": ( + f'{content_type.capitalize()} "{content_id}" does not support ' + f"decrypted playback (requires ClearKey or unencrypted)" + ) + } + + # mode == "auto" + if manager.needs_proxy(provider): # When the caller explicitly requested software DRM and we found no # ClearKey keys, surface a clear error rather than silently serving a # Widevine stream the client cannot decrypt. @@ -586,72 +764,45 @@ def make_helpers(manager, service): provider, content_id, keyids, receiver_side=True, drm_variant=drm_variant, + highest_quality_only=highest_quality_only, ) else: - return service.get_proxied_manifest(provider, content_id) + return service.get_proxied_manifest( + provider, content_id, + highest_quality_only=highest_quality_only, + ) else: - # Check if provider requires manifest context (needs HTTP manager to fetch) - provider_instance = manager.get_provider(provider) + return _redirect_or_fetch(content_type, provider, content_id, country, drm_variant) - if provider_instance is None: - logger.warning( - f"_resolve_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 directly " - f"for {content_type}/{content_id}" - ) - try: - manifest_url = _get_manifest_url( - content_type, provider, content_id, - country=country, drm_variant=drm_variant, - ) - if not manifest_url: - response.status = 404 - return { - "error": ( - f'Manifest not available for {content_type} ' - f'"{content_id}" from provider "{provider}"' - ) - } - - manifest_text, _, _, _, effective_url = service.fetch_manifest_for_rewriter( - provider, content_id, manifest_url - ) - - # Inject the upstream manifest URL as a BaseURL so the player can - # resolve relative segment URLs correctly. Without this, segments - # resolve against the local server URL and all requests fail. - 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, drm_variant=drm_variant, - ) - if not manifest_url: - response.status = 404 - return { - "error": ( - f'Manifest not available for {content_type} ' - f'"{content_id}" from provider "{provider}"' - ) - } - logger.debug(f"Redirecting to manifest: {manifest_url}") - return redirect(manifest_url) + def _resolve_stream( + content_type: str, + provider: str, + content_id: str, + country=None, + is_catchup: bool = False, + start_time: int = None, + end_time: int = None, + epg_id: str = None, + drm_variant: str = "auto", + no_proxy: bool = False, + ): + """ + Deprecated: thin wrapper around _resolve_stream_unified, kept so + existing call sites (channels.py, events.py, vod.py, recordings.py) + don't need to change. New code should call _resolve_stream_unified + directly with an explicit mode. + """ + return _resolve_stream_unified( + content_type, provider, content_id, + country=country, + is_catchup=is_catchup, + start_time=start_time, + end_time=end_time, + epg_id=epg_id, + drm_variant=drm_variant, + mode="noproxy" if no_proxy else "auto", + receiver_side=True, + ) def _resolve_decrypted_stream( content_type: str, @@ -660,25 +811,15 @@ def make_helpers(manager, service): highest_quality_only: bool = False, ): """ - Shared handler for decrypted stream endpoints. - Resolves DRM, then returns an appropriately rewritten manifest. - - For channel catchup (start_time + end_time query params present) the - handler fetches the catchup DRM configs and catchup manifest URL so that - server-side decryption operates on the correct DVR/time-shifted stream - rather than the live channel manifest. receiver_side is always False - here — the /decrypted/ endpoint contract is that the server decrypts. + Deprecated: thin wrapper around _resolve_stream_unified, kept so + existing call sites (channels.py's /stream/proxied/ routes) don't need + to change. Reads start_time/end_time/epg_id/country from the request + query string itself, matching the original function's contract — those + routes never passed catchup args explicitly. New code should call + _resolve_stream_unified directly with mode="decrypt". """ try: country = request.query.get("country") - - # ------------------------------------------------------------------ - # Catchup branch — channels only. - # When start_time + end_time are present we must use the catchup - # DRM configs and catchup manifest URL. The live path below would - # silently fetch the live manifest and encrypt/decrypt against the - # wrong stream. - # ------------------------------------------------------------------ start_time = request.query.get("start_time") end_time = request.query.get("end_time") epg_id = request.query.get("epg_id") @@ -686,167 +827,24 @@ def make_helpers(manager, service): if is_catchup: try: - start_time_int = int(start_time) - end_time_int = int(end_time) + start_time = int(start_time) + end_time = int(end_time) except (ValueError, TypeError): 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)"} - - catchup_drm_configs = manager.get_catchup_drm_configs( - provider_name=provider, - channel_id=content_id, - start_time=start_time_int, - end_time=end_time_int, - epg_id=epg_id, - country=country, - ) - catchup_drm_dict = _serialize_drm_configs(catchup_drm_configs) - - is_unencrypted = "none" in catchup_drm_dict - keyids = ( - catchup_drm_dict.get("org.w3.clearkey", {}) - .get("license", {}) - .get("keyids", {}) - ) - - if is_unencrypted: - return service.get_proxied_catchup_manifest( - provider, content_id, start_time_int, end_time_int, epg_id, country - ) - - if not keyids: - response.status = 400 - return {"error": "ClearKey DRM not available for this catchup content"} - - return service.get_decrypted_catchup_manifest( - provider, content_id, - start_time=start_time_int, - end_time=end_time_int, - keyids=keyids, - epg_id=epg_id, - highest_quality_only=highest_quality_only, - ) - - # ------------------------------------------------------------------ - # Live / VOD / event / recording path - # ------------------------------------------------------------------ - drm_configs = _get_drm_configs( - content_type, provider, content_id, country=country + return _resolve_stream_unified( + content_type, provider, content_id, + country=country, + is_catchup=is_catchup, + start_time=start_time, + end_time=end_time, + epg_id=epg_id, + mode="decrypt", + receiver_side=False, + highest_quality_only=highest_quality_only, ) - drm_dict = _serialize_drm_configs(drm_configs) - - has_clearkey = "org.w3.clearkey" in drm_dict - is_unencrypted = "none" in drm_dict - - if has_clearkey: - if not service.media_proxy_url: - response.status = 503 - return {"error": "Media proxy not configured (MEDIA_PROXY_URL not set)"} - - keyids = ( - drm_dict["org.w3.clearkey"] - .get("license", {}) - .get("keyids", {}) - ) - if not keyids: - response.status = 400 - return {"error": "ClearKey DRM found but no key IDs available"} - - return service.get_decrypted_manifest( - provider, content_id, keyids, - highest_quality_only=highest_quality_only, - ) - - 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) - - 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: - 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: - # 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 - return { - "error": ( - f'{content_type.capitalize()} "{content_id}" does not support ' - f"decrypted playback (requires ClearKey or unencrypted)" - ) - } - except HTTPResponse: raise except ValueError as e: @@ -873,5 +871,6 @@ def make_helpers(manager, service): "_validate_catchup_window": _validate_catchup_window, "_resolve_stream": _resolve_stream, "_resolve_decrypted_stream": _resolve_decrypted_stream, + "_resolve_stream_unified": _resolve_stream_unified, "_serialize_drm_configs": _serialize_drm_configs, } \ No newline at end of file diff --git a/routes/streams/channels.py b/routes/streams/channels.py index 18ee62f..3dbfa10 100644 --- a/routes/streams/channels.py +++ b/routes/streams/channels.py @@ -7,7 +7,6 @@ stream resolution. This module only defines route decorators and the minimal channel-specific catchup handling logic. """ -import time from bottle import HTTPResponse, request, response from streaming_providers.base.utils import logger @@ -59,38 +58,11 @@ def setup_channel_routes(app, manager, service, helpers): 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) - 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 channel "{channel_id}"'} - - 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)"} + # Window validation (catchup_hours lookup, age check) now lives + # inside _resolve_stream_unified via _validate_catchup_window, + # shared across every mode (auto/noproxy/decrypt) instead of + # being duplicated here and in the decrypted-stream path + # separately. No channel lookup needed here anymore. return _resolve_stream( CONTENT_TYPE_CHANNEL, provider, channel_id,