diff --git a/routes/m3u.py b/routes/m3u.py index 3ec96e8..906abe1 100644 --- a/routes/m3u.py +++ b/routes/m3u.py @@ -253,7 +253,19 @@ def setup_m3u_routes(app, manager, service): ) epg_id = service.get_epg_id(channel_id) epg_id_attr = f' tvg-epgid="{epg_id}"' if epg_id else "" - stream_path = "stream/proxied/index.mpd" if proxied else "stream/index.mpd" + # /stream/proxied/ no longer exists as a separate route — + # folded into client_drm on the single /stream/index.mpd + # endpoint. client_drm=false for proxied (matches the + # static KODIPROP line below, server decrypts); + # client_drm=true otherwise (matches the dynamic + # per-channel DRM lookup below, client decrypts) — it + # now defaults to false, so this must be explicit or the + # non-proxied branch's entries would mismatch their own + # KODIPROP directives. + stream_path = ( + "stream/index.mpd?client_drm=false" if proxied + else "stream/index.mpd?client_drm=true" + ) stream_url = ( f"{base_url}/api/providers/{provider_name}/channels/{channel_id}/{stream_path}" ) @@ -264,7 +276,9 @@ def setup_m3u_routes(app, manager, service): ) if proxied: - m3u_content += "#KODIPROP:inputstream=inputstream.adaptive\n" + # No KODIPROP line — client_drm=false, client doesn't + # use inputstream.adaptive when the server decrypts. + pass else: try: drm_configs = manager.get_channel_drm_configs(provider_name, channel_id) diff --git a/routes/streams/channels.py b/routes/streams/channels.py index 3dbfa10..36b0c98 100644 --- a/routes/streams/channels.py +++ b/routes/streams/channels.py @@ -17,15 +17,18 @@ def setup_channel_routes(app, manager, service, helpers): CONTENT_TYPE_CHANNEL = helpers["CONTENT_TYPE_CHANNEL"] _build_drm_header = helpers["_build_drm_header"] _build_stream_headers = helpers["_build_stream_headers"] - _resolve_stream = helpers["_resolve_stream"] - _resolve_decrypted_stream = helpers["_resolve_decrypted_stream"] + _resolve_stream_unified = helpers["_resolve_stream_unified"] _get_drm_configs = helpers["_get_drm_configs"] - def _handle_channel_stream(provider, channel_id, *, drm_variant="auto", no_proxy=False): - """Shared implementation for /stream/index.mpd, /stream/sw-drm/index.mpd, - and /stream/noproxy/index.mpd. All three variants get the same catchup - window validation and int parsing — no_proxy only changes whether - _resolve_stream is told to force a redirect past the media proxy.""" + def _handle_channel_stream(provider, channel_id): + """Single implementation backing /stream/index.mpd — the only channel + stream route. Replaces the former /stream/sw-drm/, /stream/noproxy/, + /stream/proxied/, and /stream/proxied/ffmpeg/ routes, each of which + used to hardcode one fixed combination of drm_variant/receiver_side/ + no_proxy/highest_quality_only. Those axes are independent, so folding + them into query params makes every combination reachable — including + ones no route could express before (e.g. client-side decrypt + + highest_quality_only).""" try: start_time = request.query.get("start_time") end_time = request.query.get("end_time") @@ -33,16 +36,25 @@ def setup_channel_routes(app, manager, service, helpers): country = request.query.get("country") is_catchup = bool(start_time and end_time) + drm_variant = request.query.get("drm_variant", "auto") + no_proxy = request.query.get("no_proxy", "false").lower() == "true" + # client_drm is the public query-param name (self-explanatory in a + # URL); receiver_side is what _resolve_stream_unified calls the + # same axis internally — translated here at the route boundary. + client_drm = request.query.get("client_drm", "false").lower() == "true" + highest_quality_only = request.query.get("highest_quality_only", "false").lower() == "true" + 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}" + f"drm_variant={drm_variant} client_drm={client_drm} " + f"no_proxy={no_proxy} highest_quality_only={highest_quality_only}" ) - # 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. + # Always defined so the _resolve_stream_unified 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 @@ -58,13 +70,10 @@ def setup_channel_routes(app, manager, service, helpers): response.status = 400 return {"error": "Invalid start_time or end_time format"} - # 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. + # Window validation (catchup_hours lookup, age check) lives + # inside _resolve_stream_unified via _validate_catchup_window. - return _resolve_stream( + return _resolve_stream_unified( CONTENT_TYPE_CHANNEL, provider, channel_id, country=country, is_catchup=is_catchup, @@ -72,19 +81,19 @@ def setup_channel_routes(app, manager, service, helpers): end_time=end_time_int if is_catchup else None, epg_id=epg_id if is_catchup else None, drm_variant=drm_variant, + receiver_side=client_drm, no_proxy=no_proxy, + highest_quality_only=highest_quality_only, ) except HTTPResponse: raise except ValueError as e: - label = "no-proxy " if no_proxy else ("sw-drm " if drm_variant == "software" else "") - logger.error(f"{label}stream error for channel {provider}/{channel_id}: {e}") + logger.error(f"stream error for channel {provider}/{channel_id}: {e}") response.status = 404 return {"error": str(e)} except Exception as e: - label = "no-proxy " if no_proxy else ("sw-drm " if drm_variant == "software" else "") - logger.error(f"{label}stream error for channel {provider}/{channel_id}: {e}") + logger.error(f"stream error for channel {provider}/{channel_id}: {e}") response.status = 500 return {"error": f"Internal server error: {str(e)}"} @@ -95,16 +104,15 @@ def setup_channel_routes(app, manager, service, helpers): @app.route("/api/providers//channels//manifest") def get_channel_manifest(provider, channel_id): """ - Returns JSON with a manifest_url pointing to the stream endpoint. - Attaches x-kodi-drm-configs header. + Returns JSON with a manifest_url pointing to the single 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. - - 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. + As of the query-param consolidation, there's one stream_url rather + than a separate URL per DRM/proxy/quality combination — the caller + appends whatever combination of client_drm/drm_variant/no_proxy/ + highest_quality_only query params it needs (see get_channel_stream's + docstring for the full list). catchup_stream_url_template still + carries {start_time}/{end_time} placeholders for DVR/catchup playback. """ try: country = request.query.get("country") @@ -114,14 +122,6 @@ def setup_channel_routes(app, manager, service, helpers): 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}" - ) - noproxy_stream_url = ( - f"{base_url}/api/providers/{provider}/channels/{channel_id}" - f"/stream/noproxy/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 "?" @@ -138,8 +138,6 @@ def setup_channel_routes(app, manager, service, helpers): "provider": provider, "channel_id": channel_id, "manifest_url": stream_url, - "sw_drm_manifest_url": sw_drm_stream_url, - "noproxy_manifest_url": noproxy_stream_url, "catchup_stream_url_template": catchup_stream_url_template, } @@ -154,48 +152,36 @@ def setup_channel_routes(app, manager, service, helpers): @app.route("/api/providers//channels//stream/index.mpd") def get_channel_stream(provider, channel_id): - """Returns HTTP 302 redirect to the actual manifest, or a rewritten - manifest body when media proxy is active. Supports live and catchup.""" + """ + Single stream endpoint for all channel playback — live and catchup. + Replaces the former /stream/sw-drm/, /stream/noproxy/, /stream/proxied/, + and /stream/proxied/ffmpeg/ routes, which are removed. Every + combination those routes used to hardcode (and some that were + previously unreachable through any route) is now expressed via + independent, freely-combinable query params: + + client_drm=true|false client decrypts ClearKey itself + (receiver-side rewrite) if true; + server decrypts to plaintext + segments if false. Default false. + drm_variant=auto|software which upstream DRM/quality variant + to request (e.g. Widevine L1 vs + L3). Default auto. + no_proxy=true|false force redirect/fetch even if the + content would normally be proxied. + Default false. + highest_quality_only=true|false collapse to a single + highest-quality representation + (e.g. for ffmpeg piping). Default + false. + start_time / end_time / epg_id / country - catchup + context, as + before. + + Returns an HTTP 302 redirect to the upstream manifest, or a rewritten + manifest body when the media proxy is involved. + """ return _handle_channel_stream(provider, channel_id) - @app.route("/api/providers//channels//stream/sw-drm/index.mpd") - def get_channel_stream_sw_drm(provider, channel_id): - """Software-DRM variant. Identical transport to the standard - endpoint; passes drm_variant='software' through to _resolve_stream.""" - return _handle_channel_stream(provider, channel_id, drm_variant="software") - - @app.route("/api/providers//channels//stream/noproxy/index.mpd") - def get_channel_stream_noproxy(provider, channel_id): - """ - Returns HTTP 302 redirect to the actual manifest, forcing a bypass of any - configured media proxy even if the provider would normally be proxied. - Supports live and catchup — reuses _handle_channel_stream so it gets the - same catchup window validation, timestamp parsing, and error handling as - the standard and sw-drm routes. - - Useful when: - - Media proxy is misbehaving - - You want to test upstream performance directly - - Proxy is not needed for a specific provider - """ - return _handle_channel_stream(provider, channel_id, no_proxy=True) - - @app.route("/api/providers//channels//stream/proxied/index.mpd") - def get_channel_stream_decrypted(provider, channel_id): - """Proxied stream — all quality representations.""" - return _resolve_decrypted_stream( - CONTENT_TYPE_CHANNEL, provider, channel_id, highest_quality_only=False - ) - - @app.route( - "/api/providers//channels//stream/proxied/ffmpeg/index.mpd" - ) - def get_channel_stream_decrypted_ffmpeg(provider, channel_id): - """Proxied stream — highest quality only, optimised for ffmpeg.""" - return _resolve_decrypted_stream( - CONTENT_TYPE_CHANNEL, provider, channel_id, highest_quality_only=True - ) - @app.route("/api/providers//channels//drm") def get_channel_drm(provider, channel_id): """ diff --git a/service.py b/service.py index ea44866..013195f 100644 --- a/service.py +++ b/service.py @@ -817,10 +817,14 @@ class UltimateService: this just appends the stream URL built from stream_path. Args: - stream_path: Relative path after .../channels/{channel_id}/, e.g. - "stream/index.mpd", "stream/noproxy/index.mpd", - "stream/proxied/index.mpd". The caller decides - transport; this function only describes the channel. + stream_path: Relative path (optionally with a query string) after + .../channels/{channel_id}/. There's only one real + channel stream route now — "stream/index.mpd" — so + callers select behavior via query params on it, e.g. + "stream/index.mpd?client_drm=true" or + "stream/index.mpd?client_drm=false&highest_quality_only=true". + This function doesn't parse or validate stream_path; + it's appended as-is. """ channel_id = channel.channel_id header = self._build_m3u_entry_header( @@ -888,9 +892,13 @@ class UltimateService: for channel in channels: m3u_content += self._generate_m3u_entry( base_url, provider_name, channel, - stream_path="stream/proxied/index.mpd", + # client_drm=false (explicit, though it's the default). + # No KODIPROP line needed — the client doesn't use + # inputstream.adaptive at all when the server does the + # decrypting. + stream_path="stream/index.mpd?client_drm=false", provider_label=provider_label, - drm_directives="#KODIPROP:inputstream=inputstream.adaptive\n", + drm_directives="", ) channels_included += 1 @@ -975,7 +983,10 @@ class UltimateService: channel_name = channel.name # Build decrypted stream URL (ffmpeg variant with highest quality) - stream_url = f"{base_url}/api/providers/{provider_name}/channels/{channel_id}/stream/proxied/ffmpeg/index.mpd" + # client_drm=false matches this playlist's static KODIPROP + # line below (server decrypts); highest_quality_only=true + # replaces the old dedicated /stream/proxied/ffmpeg/ path. + stream_url = f"{base_url}/api/providers/{provider_name}/channels/{channel_id}/stream/index.mpd?client_drm=false&highest_quality_only=true" # Build ffmpeg pipe command # Map all video (will be just one due to highest_quality_only), all audio, and optional subtitles @@ -1002,10 +1013,13 @@ class UltimateService: # intentionally live-only, so include_catchup=False # preserves its existing behavior of never emitting # catchup attributes, even for channels that support it. + # No KODIPROP line — client_drm=false here too (server + # decrypts), and the stream target is a pipe://ffmpeg + # command anyway, which inputstream.adaptive never touches. m3u_content += self._build_m3u_entry_header( provider_name, channel, provider_label=provider_label, - drm_directives="#KODIPROP:inputstream=inputstream.adaptive\n", + drm_directives="", include_catchup=False, ) m3u_content += f"{ffmpeg_cmd}\n" @@ -1114,11 +1128,13 @@ class UltimateService: # pre-consolidation behavior (the old # _generate_m3u_proxied_channel_entry always # included catchup tags when available). + # No KODIPROP line — client_drm=false, client + # doesn't use inputstream.adaptive. m3u_content += self._generate_m3u_entry( base_url, provider_name, channel, - stream_path="stream/proxied/index.mpd", + stream_path="stream/index.mpd?client_drm=false", provider_label=provider_label, - drm_directives="#KODIPROP:inputstream=inputstream.adaptive\n", + drm_directives="", include_catchup=True, ) channels_included += 1 @@ -1220,7 +1236,15 @@ class UltimateService: else: cache_filename = f"{cache_filename}_noproxy" - stream_path = "stream/noproxy/index.mpd" if no_proxy else "stream/index.mpd" + # /stream/noproxy/ and /stream/sw-drm/ no longer exist as separate + # routes — folded into query params on the single /stream/index.mpd + # endpoint. client_drm=true because this playlist's KODIPROP + # directives come from a dynamic per-channel DRM lookup (see the + # drm_directives=None call below / _build_m3u_entry_header), which + # only makes sense if the client is the one doing the decrypting — + # client_drm now defaults to false, so this has to be explicit or + # every entry here would silently mismatch its own KODIPROP directives. + stream_path = "stream/index.mpd?client_drm=true&no_proxy=true" if no_proxy else "stream/index.mpd?client_drm=true" for provider_name in providers_to_process: try: