diff --git a/routes/streams/__init__.py b/routes/streams/__init__.py index 46829d9..c4fe177 100644 --- a/routes/streams/__init__.py +++ b/routes/streams/__init__.py @@ -447,13 +447,13 @@ def make_helpers(manager, service): 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="playable"'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). + Shared tail behavior for every "no proxy involved" outcome: check + requires_manifest_context and either fetch + inject BaseURL, or do a + plain redirect. Extracted so no_proxy=True, receiver_side=True's + non-proxied branch, and receiver_side=False's unencrypted/no-proxy-needed branch all + get identical, correct behavior instead of three independently + maintained copies (previously the playable path had none of this at + all and always redirected unconditionally). """ provider_instance = manager.get_provider(provider) @@ -527,54 +527,62 @@ def make_helpers(manager, service): end_time: int = None, epg_id: str = None, drm_variant: str = "auto", - mode: str = "auto", receiver_side: bool = True, + no_proxy: bool = False, highest_quality_only: bool = False, ): """ 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. + _resolve_stream / _resolve_decrypted_stream split (and the later + mode="auto"/"playable" split). Both wrapper names still exist below + for existing call sites. + + Routing is entirely data-driven: whenever content has ClearKey DRM and + a media proxy is configured, the proxy is used to inject correct + ClearKey signaling — this is mandatory regardless of who decrypts, + because plain proxy passthrough leaves the original manifest's + ContentProtection untouched (not stripped, but also not necessarily + correct — some providers' raw manifests don't carry adequate ClearKey + signaling on their own). receiver_side is the only caller-facing + choice, and it only controls who does the decrypting, not whether the + proxy gets involved: Args: - drm_variant: 'auto' (provider decides) or 'software'. This selects + drm_variant: 'auto' (provider decides) or 'software'. 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/playable/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. - "playable" - guarantees the client gets a manifest it can - actually play without doing its own key - exchange, or an honest error — never a manifest - it can't handle. ClearKey content is rewritten - 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" — 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 - this mode can't hand such a client anything - playable — applies equally to live/VOD and - catchup. (Named for the contract it guarantees, - not the ClearKey mechanism it mostly relies on - — the old /stream/proxied/ endpoints this mode - backs are exactly this "simple client" case.) - "noproxy" - force redirect/fetch even if the provider would - normally be proxied. - receiver_side: Only consulted when mode="playable". True = client - decrypts (ClearKey signaled in the manifest), False = - server decrypts and serves plaintext segments. + receiver_side. + receiver_side: True - client decrypts (ClearKey signaled in the + manifest, receiver-side rewrite). This is + the /stream/index.mpd contract. Content + that's encrypted with something other than + ClearKey (e.g. Widevine-only) is passed + through via proxy/redirect for the client's + own DRM stack to handle — this mode never + rejects content outright. + False - server decrypts, plaintext segments served + (the /stream/proxied/ contract). Requires + MEDIA_PROXY_URL whenever ClearKey is + present. Content that's neither ClearKey + nor unencrypted is rejected with a 400, + since the server has no way to decrypt it + — applies equally to live/VOD and catchup. + Unencrypted content is unaffected by this flag + either way — there's nothing to decrypt, so it + always takes the same proxy/redirect path. + no_proxy: Orthogonal override, independent of receiver_side — + forces a redirect/fetch even for providers or content + that would normally be proxied, skipping ClearKey + handling and the software-DRM-availability check + entirely. Always wins over receiver_side, the same way + it did as a bare `no_proxy` flag on the pre-merge + _resolve_stream. 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"/"playable" catchup content that turns out - unencrypted. Meaningless for mode="noproxy" (plain - redirect, no rewriter involved). + catchup content that turns out unencrypted. Meaningless + when no_proxy=True (plain redirect, no rewriter involved). """ # --- Catchup window validation (channels only), before anything else — # previously duplicated between channels.py (for auto/noproxy) and @@ -620,7 +628,7 @@ def make_helpers(manager, service): # Catchup path (channel-specific) # ================================================================== if is_catchup: - if mode == "noproxy": + if no_proxy: manifest_url = manager.get_catchup_manifest( provider_name=provider, channel_id=content_id, @@ -638,11 +646,27 @@ def make_helpers(manager, service): logger.debug(f"Redirecting to catchup manifest: {manifest_url}") return redirect(manifest_url) - if mode == "playable": - if keyids: - if not service.media_proxy_url: - response.status = 503 - return {"error": "Media proxy not configured (MEDIA_PROXY_URL not set)"} + if keyids: + # Deliberately NOT gated on manager.needs_proxy(provider) — that + # flag answers a different question (does this provider's + # manifest/segment fetching need proxying for header/CORS/token + # reasons) than "does this content's ClearKey need signaling + # injected" (a per-content fact, not a per-provider one). A + # provider can have needs_proxy=False while still returning + # ClearKey content whose manifest doesn't carry adequate + # signaling on its own — plain passthrough leaves the original + # ContentProtection untouched, which isn't reliably sufficient + # for the client to decrypt. So: ClearKey + a configured proxy + # → always inject, independent of needs_proxy. + # + # receiver_side is a separate axis entirely — it only decides + # who decrypts (client vs server) once we're already here; it + # never decides whether to use the proxy. MPDRewriter requires + # media_proxy_url for the injection regardless of receiver_side + # (see get_decrypted_catchup_manifest / MPDRewriter — segment + # URLs route through the proxy either way, only the "proxy" vs + # "decrypt" endpoint differs). + if service.media_proxy_url: return service.get_decrypted_catchup_manifest( provider, content_id, start_time=start_time, end_time=end_time, @@ -650,17 +674,81 @@ def make_helpers(manager, service): 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. + elif receiver_side: + # No proxy configured, but the client can decrypt on its + # own — best effort: redirect and hope the raw manifest + # carries adequate signaling. + 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}"' + } + return redirect(manifest_url) + else: + # Server was supposed to decrypt but has nothing to + # decrypt with. + response.status = 503 + return {"error": "Media proxy not configured (MEDIA_PROXY_URL not set)"} + + elif is_unencrypted: + if manager.needs_proxy(provider): return service.get_proxied_catchup_manifest( provider, content_id, start_time, end_time, epg_id, country ) + 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) + + else: + # Encrypted but not ClearKey (e.g. Widevine-only catchup). + if receiver_side: + # Client handles its own DRM — same proxy-if-needed/redirect + # passthrough as the unencrypted case above. + if manager.needs_proxy(provider): + return service.get_proxied_catchup_manifest( + provider, content_id, start_time, end_time, epg_id, country + ) + 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}"' + } + return redirect(manifest_url) else: - # Encrypted but not ClearKey (e.g. Widevine-only catchup) — - # playable 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. + # Server can't decrypt non-ClearKey schemes — honest error + # rather than silently proxying content the client likely + # can't play anyway. response.status = 400 return { "error": ( @@ -669,79 +757,98 @@ def make_helpers(manager, service): ) } - # mode == "auto" - if manager.needs_proxy(provider): - 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, - highest_quality_only=highest_quality_only, - ) - return service.get_proxied_catchup_manifest( - provider, content_id, start_time, end_time, epg_id, country - ) - else: - 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) - # ================================================================== # Live / event / vod / recording path # ================================================================== - if mode == "noproxy": + if no_proxy: return _redirect_or_fetch(content_type, provider, content_id, country, drm_variant) - if mode == "playable": - if keyids: - if not service.media_proxy_url: - response.status = 503 - return {"error": "Media proxy not configured (MEDIA_PROXY_URL not set)"} + if keyids: + # Deliberately NOT gated on manager.needs_proxy(provider) — that + # flag answers a different question (does this provider's + # manifest/segment fetching need proxying for header/CORS/token + # reasons) than "does this content's ClearKey need signaling + # injected" (a per-content fact, not a per-provider one). A + # provider can have needs_proxy=False while still returning + # ClearKey content whose manifest doesn't carry adequate signaling + # on its own — plain passthrough leaves the original + # ContentProtection untouched, which isn't reliably sufficient for + # the client to decrypt. So: ClearKey + a configured proxy → + # always inject, independent of needs_proxy. + # + # receiver_side is a separate axis entirely — it only decides who + # decrypts (client vs server) once we're already here; it never + # decides whether to use the proxy. MPDRewriter requires + # media_proxy_url for the injection regardless of receiver_side + # (see get_decrypted_manifest / MPDRewriter — segment URLs route + # through the proxy either way, only the "proxy" vs "decrypt" + # endpoint differs). + if service.media_proxy_url: + logger.debug( + f"ClearKey DRM detected for {provider}/{content_id} " + f"(variant={drm_variant}) — using " + f"{'receiver-side' if receiver_side else 'server-side'} rewrite" + ) 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) + elif receiver_side: + # No proxy configured, but the client can decrypt on its own — + # best effort: redirect/fetch and hope the raw manifest + # carries adequate signaling. + return _redirect_or_fetch(content_type, provider, content_id, country, drm_variant) + else: + # Server was supposed to decrypt but has nothing to decrypt with. + response.status = 503 + return {"error": "Media proxy not configured (MEDIA_PROXY_URL not set)"} - if (needs_headers or needs_proxy) and service.media_proxy_url: + 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: + # Encrypted but not ClearKey (e.g. Widevine-only). + if receiver_side: + # 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. + if drm_variant == "software": + logger.warning( + f"Software DRM requested but no ClearKey keys found for " + f"{provider}/{content_id}" + ) + response.status = 400 + return {"error": "Software DRM not available for this content"} + + # Client handles its own DRM — same proxy-if-needed/redirect + # passthrough as the unencrypted case above. + if manager.needs_proxy(provider): 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) + 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. + # Server can't decrypt non-ClearKey schemes — honest error + # rather than silently proxying content the client likely + # can't play anyway. response.status = 400 return { "error": ( @@ -750,38 +857,6 @@ def make_helpers(manager, service): ) } - # 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. - if drm_variant == "software" and not keyids: - logger.warning( - f"Software DRM requested but no ClearKey keys found for " - f"{provider}/{content_id}" - ) - response.status = 400 - return {"error": "Software DRM not available for this content"} - - if keyids: - logger.debug( - f"ClearKey DRM detected for {provider}/{content_id} " - f"(variant={drm_variant}) — using receiver-side ClearKey rewrite" - ) - return service.get_decrypted_manifest( - 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, - highest_quality_only=highest_quality_only, - ) - else: - return _redirect_or_fetch(content_type, provider, content_id, country, drm_variant) - def _resolve_stream( content_type: str, provider: str, @@ -798,7 +873,7 @@ def make_helpers(manager, service): 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. + directly with an explicit receiver_side. """ return _resolve_stream_unified( content_type, provider, content_id, @@ -808,8 +883,8 @@ def make_helpers(manager, service): end_time=end_time, epg_id=epg_id, drm_variant=drm_variant, - mode="noproxy" if no_proxy else "auto", receiver_side=True, + no_proxy=no_proxy, ) def _resolve_decrypted_stream( @@ -824,7 +899,7 @@ def make_helpers(manager, service): 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="playable". + _resolve_stream_unified directly with receiver_side=False. """ try: country = request.query.get("country") @@ -848,7 +923,6 @@ def make_helpers(manager, service): start_time=start_time, end_time=end_time, epg_id=epg_id, - mode="playable", receiver_side=False, highest_quality_only=highest_quality_only, )