diff --git a/routes/streams.py b/routes/streams.py index 20c28ae..d697ac5 100644 --- a/routes/streams.py +++ b/routes/streams.py @@ -49,12 +49,24 @@ def setup_stream_routes(app, manager, service): # INTERNAL HELPERS # ========================================================================= - def _get_drm_configs(content_type: str, provider: str, content_id: str, **kwargs): + def _get_drm_configs( + content_type: str, + provider: str, + content_id: str, + drm_variant: str = "auto", + **kwargs, + ): """ Dispatch DRM config retrieval to the correct manager method based on content type. Returns a list of DRMConfig objects (may be empty). Raises ValueError for unknown provider; re-raises other exceptions. + + Args: + drm_variant: 'auto' (provider default) or 'software' (prefer ClearKey). + Passed through to the provider via **kwargs so individual + providers can select an appropriate DRM scheme. """ + kwargs.setdefault("drm_variant", drm_variant) if content_type == CONTENT_TYPE_CHANNEL: return manager.get_channel_drm_configs( provider_name=provider, channel_id=content_id, **kwargs @@ -74,11 +86,23 @@ def setup_stream_routes(app, manager, service): else: raise ValueError(f"Unknown content_type '{content_type}'") - def _get_manifest_url(content_type: str, provider: str, content_id: str, **kwargs) -> str: + def _get_manifest_url( + content_type: str, + provider: str, + content_id: str, + drm_variant: str = "auto", + **kwargs, + ) -> str: """ Dispatch manifest URL retrieval to the correct manager method. Returns the raw upstream manifest URL (before any proxy rewriting). + + Args: + drm_variant: 'auto' or 'software'. Passed through so providers that + expose separate ClearKey manifest URLs can return the + correct one. """ + kwargs.setdefault("drm_variant", drm_variant) if content_type == CONTENT_TYPE_CHANNEL: return manager.get_channel_manifest( provider_name=provider, channel_id=content_id, **kwargs @@ -108,11 +132,17 @@ def setup_stream_routes(app, manager, service): start_time: int = None, end_time: int = None, epg_id: str = None, + drm_variant: str = "auto", ): """ Fetch DRM configs, serialise to JSON and attach as a base64-encoded response header (x-kodi-drm-configs). Non-fatal: logs a warning and returns None on any error. + + Args: + drm_variant: 'auto' or 'software'. Forwarded to the DRM config + fetch so the provider can return ClearKey configs when + the software-DRM variant is requested. """ try: if is_catchup and content_type == CONTENT_TYPE_CHANNEL: @@ -123,10 +153,12 @@ def setup_stream_routes(app, manager, service): end_time=end_time, epg_id=epg_id, country=country, + drm_variant=drm_variant, ) else: drm_configs = _get_drm_configs( - content_type, provider, content_id, country=country + content_type, provider, content_id, + country=country, drm_variant=drm_variant, ) merged = {} @@ -144,7 +176,7 @@ def setup_stream_routes(app, manager, service): except Exception as e: logger.warning( f"Could not build x-kodi-drm-configs header for " - f"{content_type} {provider}/{content_id}: {e}" + f"{content_type} {provider}/{content_id} (variant={drm_variant}): {e}" ) return None @@ -258,10 +290,16 @@ def setup_stream_routes(app, manager, service): start_time_int: int = None, end_time_int: int = None, epg_id: str = None, + drm_variant: str = "auto", ): """ Core stream resolution: attach DRM header, then either redirect to the upstream manifest or return a proxy-rewritten manifest body. + + 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. """ # --- DRM header (best-effort, never fatal) --- _build_drm_header( @@ -271,6 +309,7 @@ def setup_stream_routes(app, manager, service): start_time=start_time_int, end_time=end_time_int, epg_id=epg_id, + drm_variant=drm_variant, ) # --- Stream headers (best-effort, never fatal) --- _build_stream_headers( @@ -296,6 +335,7 @@ def setup_stream_routes(app, manager, service): end_time=end_time_int, epg_id=epg_id, country=country, + drm_variant=drm_variant, ) if not manifest_url: response.status = 404 @@ -312,7 +352,8 @@ def setup_stream_routes(app, manager, service): # with stripped ContentProtection that the player cannot handle. try: drm_configs = _get_drm_configs( - content_type, provider, content_id, country=country + content_type, provider, content_id, + country=country, drm_variant=drm_variant, ) drm_dict = {} for config in drm_configs: @@ -332,10 +373,21 @@ def setup_stream_routes(app, manager, service): .get("keyids", {}) ) + # 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"— using receiver-side ClearKey rewrite" + f"(variant={drm_variant}) — using receiver-side ClearKey rewrite" ) return service.get_decrypted_manifest( provider, content_id, keyids, @@ -360,7 +412,8 @@ def setup_stream_routes(app, manager, service): ) try: manifest_url = _get_manifest_url( - content_type, provider, content_id, country=country + content_type, provider, content_id, + country=country, drm_variant=drm_variant, ) if not manifest_url: response.status = 404 @@ -392,7 +445,8 @@ def setup_stream_routes(app, manager, service): return {"error": f"Failed to fetch manifest: {str(e)}"} else: manifest_url = _get_manifest_url( - content_type, provider, content_id, country=country + content_type, provider, content_id, + country=country, drm_variant=drm_variant, ) if not manifest_url: response.status = 404 @@ -520,15 +574,23 @@ def setup_stream_routes(app, manager, service): """ Returns JSON with a manifest_url pointing to the stream endpoint. Attaches x-kodi-drm-configs header. + + 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. """ try: country = request.query.get("country") base_url = f"{request.urlparts.scheme}://{request.urlparts.netloc}" + qs = f"?country={country}" if country else "" stream_url = ( - f"{base_url}/api/providers/{provider}/channels/{channel_id}/stream/index.mpd" + f"{base_url}/api/providers/{provider}/channels/{channel_id}" + f"/stream/index.mpd{qs}" + ) + sw_drm_stream_url = ( + f"{base_url}/api/providers/{provider}/channels/{channel_id}" + f"/stream/sw-drm/index.mpd{qs}" ) - if country: - stream_url += f"?country={country}" _build_drm_header(CONTENT_TYPE_CHANNEL, provider, channel_id, country=country) _build_stream_headers(CONTENT_TYPE_CHANNEL, provider, channel_id, country=country) @@ -537,6 +599,7 @@ def setup_stream_routes(app, manager, service): "provider": provider, "channel_id": channel_id, "manifest_url": stream_url, + "sw_drm_manifest_url": sw_drm_stream_url, } except ValueError as e: @@ -678,6 +741,75 @@ def setup_stream_routes(app, manager, service): response.status = 500 return {"error": f"Internal server error: {str(e)}"} + @app.route("/api/providers//channels//stream/sw-drm/index.mpd") + def get_channel_stream_sw_drm(provider, channel_id): + """ + Software-DRM (ClearKey) channel stream endpoint. + + Identical to the standard stream endpoint except that drm_variant='software' + is passed through to the resolution helpers. Providers that expose a separate + ClearKey manifest URL will return it here; for proxy-routed streams, the + server will 400 if no ClearKey keys are available (rather than silently + serving a Widevine stream the client cannot decrypt). + + Supports catchup via the same start_time / end_time / epg_id query params + as the standard endpoint. + """ + try: + start_time = request.query.get("start_time") + end_time = request.query.get("end_time") + epg_id = request.query.get("epg_id") + country = request.query.get("country") + is_catchup = bool(start_time and end_time) + + if is_catchup: + try: + start_time_int = int(start_time) + end_time_int = int(end_time) + except (ValueError, TypeError): + response.status = 400 + return {"error": "Invalid start_time or end_time format"} + + provider_instance = manager.get_provider(provider) + catchup_hours = getattr(provider_instance, "catchup_window", 0) + if catchup_hours == 0: + response.status = 400 + return {"error": f'Catchup not supported for provider "{provider}"'} + + import time + if (int(time.time()) - start_time_int) > 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, + is_catchup=True, + start_time_int=start_time_int, + end_time_int=end_time_int, + epg_id=epg_id, + drm_variant="software", + ) + else: + return _resolve_stream( + CONTENT_TYPE_CHANNEL, provider, channel_id, + country=country, + drm_variant="software", + ) + + except HTTPResponse: + raise + except ValueError as e: + logger.error(f"sw-drm stream error for channel {provider}/{channel_id}: {e}") + response.status = 404 + return {"error": str(e)} + except Exception as e: + logger.error(f"sw-drm stream error for channel {provider}/{channel_id}: {e}") + response.status = 500 + return {"error": f"Internal server error: {str(e)}"} + @app.route("/api/providers//channels//stream/decrypted/index.mpd") def get_channel_stream_decrypted(provider, channel_id): """Decrypted stream — all quality representations.""" @@ -759,15 +891,22 @@ def setup_stream_routes(app, manager, service): """ Returns JSON with a manifest_url pointing to the event stream endpoint. Attaches x-kodi-drm-configs header. + + Response includes both stream_url (auto DRM) and sw_drm_stream_url + (software / ClearKey DRM). """ try: country = request.query.get("country") base_url = f"{request.urlparts.scheme}://{request.urlparts.netloc}" + qs = f"?country={country}" if country else "" stream_url = ( - f"{base_url}/api/providers/{provider}/events/{event_id}/stream/index.mpd" + f"{base_url}/api/providers/{provider}/events/{event_id}" + f"/stream/index.mpd{qs}" + ) + sw_drm_stream_url = ( + f"{base_url}/api/providers/{provider}/events/{event_id}" + f"/stream/sw-drm/index.mpd{qs}" ) - if country: - stream_url += f"?country={country}" _build_drm_header(CONTENT_TYPE_EVENT, provider, event_id, country=country) _build_stream_headers(CONTENT_TYPE_EVENT, provider, event_id, country=country) @@ -776,6 +915,7 @@ def setup_stream_routes(app, manager, service): "provider": provider, "event_id": event_id, "manifest_url": stream_url, + "sw_drm_manifest_url": sw_drm_stream_url, } except ValueError as e: @@ -809,6 +949,31 @@ def setup_stream_routes(app, manager, service): response.status = 500 return {"error": f"Internal server error: {str(e)}"} + @app.route("/api/providers//events//stream/sw-drm/index.mpd") + def get_event_stream_sw_drm(provider, event_id): + """ + Software-DRM (ClearKey) event stream endpoint. + + Identical to the standard event stream endpoint except that + drm_variant='software' is passed through to the resolution helpers. + """ + try: + country = request.query.get("country") + return _resolve_stream( + CONTENT_TYPE_EVENT, provider, event_id, + country=country, drm_variant="software", + ) + except HTTPResponse: + raise + except ValueError as e: + logger.error(f"sw-drm stream error for event {provider}/{event_id}: {e}") + response.status = 404 + return {"error": str(e)} + except Exception as e: + logger.error(f"sw-drm stream error for event {provider}/{event_id}: {e}") + response.status = 500 + return {"error": f"Internal server error: {str(e)}"} + @app.route( "/api/providers//events//stream/decrypted/index.mpd" )