From 7988e4f1aae3d5ae4c5c86d8120c25a697c21978 Mon Sep 17 00:00:00 2001 From: Nirvana Date: Fri, 28 Aug 2026 14:15:29 +0200 Subject: [PATCH] adapt events, recordings, vod to same structure as channels --- routes/streams/events.py | 159 ++++++++++++++++++++--------------- routes/streams/recordings.py | 94 +++++++++++++-------- routes/streams/vod.py | 85 +++++++++++++------ service.py | 58 ++++++++----- 4 files changed, 252 insertions(+), 144 deletions(-) diff --git a/routes/streams/events.py b/routes/streams/events.py index c70e6a1..1886ac6 100644 --- a/routes/streams/events.py +++ b/routes/streams/events.py @@ -4,6 +4,13 @@ Event-specific stream routes. Events are temporary/live streams (sports, special broadcasts, etc.). They share the same transport pattern as channels but without catchup support. + +As of the query-param consolidation (mirrors channels.py's +_handle_channel_stream), there's a single /stream/index.mpd route rather +than separate /stream/sw-drm/, /stream/proxied/, and /stream/proxied/ffmpeg/ +routes. Those combinations are now reached via drm_variant/client_drm/ +no_proxy/highest_quality_only query params on the one route, resolved +directly through _resolve_stream_unified. """ from bottle import HTTPResponse, request, response @@ -16,18 +23,70 @@ def setup_event_routes(app, manager, service, helpers): CONTENT_TYPE_EVENT = helpers["CONTENT_TYPE_EVENT"] _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_event_stream(provider, event_id): + """Single implementation backing /stream/index.mpd — the only event + stream route. Replaces the former /stream/sw-drm/, /stream/proxied/, + and /stream/proxied/ffmpeg/ routes, each of which used to hardcode + one fixed combination of drm_variant/receiver_side/ + highest_quality_only. Mirrors channels.py's _handle_channel_stream, + minus catchup handling — events have no catchup.""" + try: + country = request.query.get("country") + 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; receiver_side is + # what _resolve_stream_unified calls the same axis internally — + # translated here at the route boundary, same as channels.py. + # Old /stream/index.mpd (via the deprecated _resolve_stream + # wrapper) hardcoded receiver_side=True, so client_drm defaults + # to "true" here to preserve that for callers that don't pass + # it explicitly. This intentionally differs from channels.py's + # "false" default — that reflects channels' own prior behavior, + # not a shared convention. + client_drm = request.query.get("client_drm", "true").lower() == "true" + highest_quality_only = request.query.get("highest_quality_only", "false").lower() == "true" + + logger.debug( + f"_handle_event_stream: provider={provider} event={event_id} " + f"country={country!r} drm_variant={drm_variant} " + f"client_drm={client_drm} no_proxy={no_proxy} " + f"highest_quality_only={highest_quality_only}" + ) + + return _resolve_stream_unified( + CONTENT_TYPE_EVENT, provider, event_id, + country=country, + 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: + logger.error(f"stream error for event {provider}/{event_id}: {e}") + response.status = 404 + return {"error": str(e)} + except Exception as e: + logger.error(f"stream error for event {provider}/{event_id}: {e}") + response.status = 500 + return {"error": f"Internal server error: {str(e)}"} + @app.route("/api/providers//events//manifest") def get_event_manifest(provider, event_id): """ - Returns JSON with a manifest_url pointing to the event 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). + sw_drm_manifest_url is kept in the response for backward + compatibility with existing clients (pvr.ultimate / + plugin.video.ultimate may read this field by name) — it's now just + manifest_url with drm_variant=software&client_drm=true appended, + rather than a separate route. """ try: country = request.query.get("country") @@ -37,9 +96,9 @@ def setup_event_routes(app, manager, service, helpers): f"{base_url}/api/providers/{provider}/events/{event_id}" f"/stream/index.mpd{qs}" ) + sw_drm_qs_sep = "&" if qs else "?" sw_drm_stream_url = ( - f"{base_url}/api/providers/{provider}/events/{event_id}" - f"/stream/sw-drm/index.mpd{qs}" + f"{stream_url}{sw_drm_qs_sep}drm_variant=software&client_drm=true" ) _build_drm_header(CONTENT_TYPE_EVENT, provider, event_id, country=country) @@ -64,67 +123,31 @@ def setup_event_routes(app, manager, service, helpers): @app.route("/api/providers//events//stream/index.mpd") def get_event_stream(provider, event_id): """ - Returns HTTP 302 redirect to the event manifest, or a rewritten - manifest body when media proxy is active. + Single stream endpoint for event playback. Replaces the former + /stream/sw-drm/, /stream/proxied/, and /stream/proxied/ffmpeg/ + routes, which are removed. Every combination those routes used to + hardcode is now expressed via independent, freely-combinable query + params (see get_channel_stream in channels.py for the full list): + + client_drm=true|false client decrypts ClearKey itself + if true; server decrypts to + plaintext segments if false. + Default false. + drm_variant=auto|software which upstream DRM/quality + variant to request. 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. + country as before. + + Returns an HTTP 302 redirect to the upstream manifest, or a + rewritten manifest body when the media proxy is involved. """ - try: - country = request.query.get("country") - return _resolve_stream( - CONTENT_TYPE_EVENT, provider, event_id, country=country - ) - except HTTPResponse: - raise - except ValueError as e: - logger.error(f"stream error for event {provider}/{event_id}: {e}") - response.status = 404 - return {"error": str(e)} - except Exception as e: - logger.error(f"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/sw-drm/index.mpd") - def get_event_stream_sw_drm(provider, event_id): - """ - Software-DRM 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/proxied/index.mpd" - ) - def get_event_stream_decrypted(provider, event_id): - """Proxied event stream — all quality representations.""" - return _resolve_decrypted_stream( - CONTENT_TYPE_EVENT, provider, event_id, highest_quality_only=False - ) - - @app.route( - "/api/providers//events//stream/proxied/ffmpeg/index.mpd" - ) - def get_event_stream_decrypted_ffmpeg(provider, event_id): - """Proxied event stream — highest quality only, optimised for ffmpeg.""" - return _resolve_decrypted_stream( - CONTENT_TYPE_EVENT, provider, event_id, highest_quality_only=True - ) + return _handle_event_stream(provider, event_id) @app.route("/api/providers//events//drm") def get_event_drm(provider, event_id): diff --git a/routes/streams/recordings.py b/routes/streams/recordings.py index d926ae1..e8df8fe 100644 --- a/routes/streams/recordings.py +++ b/routes/streams/recordings.py @@ -4,8 +4,13 @@ Recording stream routes. Recordings are always on-demand (pre-captured), so: - No catchup path (unlike channels) - - No ffmpeg variant (not a live/adaptive stream that needs quality pinning) - recording_id is a flat identifier, no path hierarchy needed + +As of the query-param consolidation (mirrors channels.py's +_handle_channel_stream), there's a single /stream/index.mpd route rather +than a separate /stream/proxied/ route. That combination is now reached via +client_drm on the one route, resolved directly through +_resolve_stream_unified. """ from bottle import HTTPResponse, request, response @@ -18,10 +23,57 @@ def setup_recording_routes(app, manager, service, helpers): CONTENT_TYPE_RECORDING = helpers["CONTENT_TYPE_RECORDING"] _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_recording_stream(provider, recording_id): + """Single implementation backing /stream/index.mpd — the only + recording stream route. Replaces the former /stream/proxied/ route, + which hardcoded receiver_side=False. Mirrors channels.py's + _handle_channel_stream, minus catchup handling — recordings have no + catchup. highest_quality_only is accepted for symmetry with the + other content types even though no /stream/proxied/ffmpeg/ route + ever existed here (recordings are on-demand, not live/adaptive, so + there was never a quality-pinning need) — it's a no-op unless a + caller opts in.""" + try: + country = request.query.get("country") + drm_variant = request.query.get("drm_variant", "auto") + no_proxy = request.query.get("no_proxy", "false").lower() == "true" + # Old /stream/index.mpd (via the deprecated _resolve_stream + # wrapper) hardcoded receiver_side=True, so client_drm defaults + # to "true" here to preserve that for callers that don't pass + # it explicitly — same reasoning as events.py/vod.py. + client_drm = request.query.get("client_drm", "true").lower() == "true" + highest_quality_only = request.query.get("highest_quality_only", "false").lower() == "true" + + logger.debug( + f"_handle_recording_stream: provider={provider} recording={recording_id} " + f"country={country!r} drm_variant={drm_variant} " + f"client_drm={client_drm} no_proxy={no_proxy} " + f"highest_quality_only={highest_quality_only}" + ) + + return _resolve_stream_unified( + CONTENT_TYPE_RECORDING, provider, recording_id, + country=country, + 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: + logger.error(f"stream error for recording {provider}/{recording_id}: {e}") + response.status = 404 + return {"error": str(e)} + except Exception as e: + logger.error(f"stream error for recording {provider}/{recording_id}: {e}") + response.status = 500 + return {"error": f"Internal server error: {str(e)}"} + @app.route("/api/providers//recordings//manifest") def get_recording_manifest(provider, recording_id): """ @@ -67,37 +119,13 @@ def setup_recording_routes(app, manager, service, helpers): @app.route("/api/providers//recordings//stream/index.mpd") def get_recording_stream(provider, recording_id): """ - Returns HTTP 302 redirect to the recording manifest, or a rewritten - manifest body when media proxy is active. + Single stream endpoint for recording playback. Replaces the former + /stream/proxied/ route, which is removed. client_drm=false now + reaches what that route used to do; see get_channel_stream in + channels.py for the full query-param list (client_drm, drm_variant, + no_proxy, highest_quality_only, country). """ - try: - country = request.query.get("country") - return _resolve_stream( - CONTENT_TYPE_RECORDING, provider, recording_id, country=country - ) - except HTTPResponse: - raise - except ValueError as e: - logger.error( - f"stream error for recording {provider}/{recording_id}: {e}" - ) - response.status = 404 - return {"error": str(e)} - except Exception as e: - logger.error( - f"stream error for recording {provider}/{recording_id}: {e}" - ) - response.status = 500 - return {"error": f"Internal server error: {str(e)}"} - - @app.route( - "/api/providers//recordings//stream/proxied/index.mpd" - ) - def get_recording_stream_decrypted(provider, recording_id): - """Proxied recording stream — all quality representations.""" - return _resolve_decrypted_stream( - CONTENT_TYPE_RECORDING, provider, recording_id, highest_quality_only=False - ) + return _handle_recording_stream(provider, recording_id) @app.route("/api/providers//recordings//drm") def get_recording_drm(provider, recording_id): diff --git a/routes/streams/vod.py b/routes/streams/vod.py index 897a208..f0e9e83 100644 --- a/routes/streams/vod.py +++ b/routes/streams/vod.py @@ -4,6 +4,13 @@ VOD (Video on Demand) stream routes. VOD content uses the same transport pattern as events but with hierarchical IDs (paths like "clip_1417600/stream") and without catchup support. + +As of the query-param consolidation (mirrors channels.py's +_handle_channel_stream), there's a single /stream/index.mpd route rather +than separate /stream/proxied/ and /stream/proxied/ffmpeg/ routes. Those +combinations are now reached via client_drm/no_proxy/highest_quality_only +query params on the one route, resolved directly through +_resolve_stream_unified. """ from bottle import HTTPResponse, request, response @@ -16,21 +23,50 @@ def setup_vod_routes(app, manager, service, helpers): CONTENT_TYPE_VOD = helpers["CONTENT_TYPE_VOD"] _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"] - @app.route("/api/providers//vod//stream/index.mpd") - def get_vod_stream(provider, path): - # Extract vod_id as the first segment before any slashes - # Example: "clip_1417600/stream" -> "clip_1417600" - vod_id = path.split("/")[0] - + def _handle_vod_stream(provider, vod_id): + """Single implementation backing /stream/index.mpd — the only VOD + stream route. Replaces the former /stream/proxied/ and + /stream/proxied/ffmpeg/ routes, each of which used to hardcode one + fixed combination of receiver_side/highest_quality_only. Mirrors + channels.py's _handle_channel_stream, minus catchup handling — VOD + has no catchup. drm_variant is accepted for symmetry with events/ + channels even though no VOD provider integration currently uses a + software-DRM variant.""" try: country = request.query.get("country") - return _resolve_stream( - CONTENT_TYPE_VOD, provider, vod_id, country=country + 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; receiver_side is + # what _resolve_stream_unified calls the same axis internally — + # translated here at the route boundary, same as channels.py. + # Old /stream/index.mpd (via the deprecated _resolve_stream + # wrapper) hardcoded receiver_side=True, so client_drm defaults + # to "true" here to preserve that for callers that don't pass + # it explicitly. This intentionally differs from channels.py's + # "false" default — that reflects channels' own prior behavior, + # not a shared convention. + client_drm = request.query.get("client_drm", "true").lower() == "true" + highest_quality_only = request.query.get("highest_quality_only", "false").lower() == "true" + + logger.debug( + f"_handle_vod_stream: provider={provider} vod_id={vod_id} " + f"country={country!r} drm_variant={drm_variant} " + f"client_drm={client_drm} no_proxy={no_proxy} " + f"highest_quality_only={highest_quality_only}" ) + + return _resolve_stream_unified( + CONTENT_TYPE_VOD, provider, vod_id, + country=country, + 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: @@ -42,6 +78,21 @@ def setup_vod_routes(app, manager, service, helpers): response.status = 500 return {"error": f"Internal server error: {str(e)}"} + @app.route("/api/providers//vod//stream/index.mpd") + def get_vod_stream(provider, path): + """ + Single stream endpoint for VOD playback. Replaces the former + /stream/proxied/ and /stream/proxied/ffmpeg/ routes, which are + removed. Every combination those routes used to hardcode is now + expressed via independent, freely-combinable query params (see + get_channel_stream in channels.py for the full list): client_drm, + drm_variant, no_proxy, highest_quality_only, country. + """ + # Extract vod_id as the first segment before any slashes + # Example: "clip_1417600/stream" -> "clip_1417600" + vod_id = path.split("/")[0] + return _handle_vod_stream(provider, vod_id) + @app.route("/api/providers//vod//manifest") def get_vod_manifest(provider, path): vod_id = path.split("/")[0] @@ -72,20 +123,6 @@ def setup_vod_routes(app, manager, service, helpers): response.status = 500 return {"error": f"Internal server error: {str(e)}"} - @app.route("/api/providers//vod//stream/proxied/index.mpd") - def get_vod_stream_decrypted(provider, path): - vod_id = path.split("/")[0] - return _resolve_decrypted_stream( - CONTENT_TYPE_VOD, provider, vod_id, highest_quality_only=False - ) - - @app.route("/api/providers//vod//stream/proxied/ffmpeg/index.mpd") - def get_vod_stream_decrypted_ffmpeg(provider, path): - vod_id = path.split("/")[0] - return _resolve_decrypted_stream( - CONTENT_TYPE_VOD, provider, vod_id, highest_quality_only=True - ) - @app.route("/api/providers//vod//drm") def get_vod_drm(provider, path): vod_id = path.split("/")[0] diff --git a/service.py b/service.py index 013195f..ef8493c 100644 --- a/service.py +++ b/service.py @@ -989,25 +989,7 @@ class UltimateService: 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 - ffmpeg_cmd = ( - f'pipe://ffmpeg -loglevel fatal ' - f'-fflags +genpts+igndts+discardcorrupt ' - f'-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5 ' - f'-thread_queue_size 2048 ' # Kept large to handle demuxing all 4 audio tracks smoothly - f'-re ' - f'-i "{stream_url}" ' - f'-map 0:v:0 ' # Maps the 1080p video stream - f'-map 0:a? ' # Maps ALL available audio tracks (German/English AAC + AC-3) - f'-c copy ' - f'-max_muxing_queue_size 8192 ' # Kept high to safely interleave the multi-audio timescales - f'-f mpegts ' - f'-muxdelay 0 -muxpreload 0 ' - f'-mpegts_flags resend_headers ' - f'-metadata service_name="{channel_name}" ' - f'-flush_packets 1 ' - f'pipe:1' - ) + ffmpeg_cmd = self._build_ffmpeg_pipe_command(stream_url, channel_name) # Header only (EXTINF + KODIPROP) — this variant is # intentionally live-only, so include_catchup=False @@ -1330,6 +1312,44 @@ class UltimateService: return m3u_content + @staticmethod + def _build_ffmpeg_pipe_command(stream_url: str, channel_name: str) -> str: + """ + Build the ffmpeg pipe:// command used by the ffmpeg-piped M3U variant. + + Extracted from _generate_m3u_proxied_ffmpeg_fast, which was the only + caller and still is — pulled out unchanged so it's reusable/testable + on its own, not because behavior needed to change. + + - -thread_queue_size 2048: kept large to handle demuxing multiple + audio tracks smoothly. + - -map 0:v:0: maps the single video stream (only one exists, since + the upstream URL is requested with highest_quality_only=true). + - -map 0:a?: maps all available audio tracks (channels commonly + carry multiple, e.g. German/English AAC + AC-3); "?" makes the + map optional so this doesn't fail if a channel has none. + - -max_muxing_queue_size 8192: kept high to safely interleave + multi-audio timescales. + """ + return ( + f'pipe://ffmpeg -loglevel fatal ' + f'-fflags +genpts+igndts+discardcorrupt ' + f'-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5 ' + f'-thread_queue_size 2048 ' + f'-re ' + f'-i "{stream_url}" ' + f'-map 0:v:0 ' + f'-map 0:a? ' + f'-c copy ' + f'-max_muxing_queue_size 8192 ' + f'-f mpegts ' + f'-muxdelay 0 -muxpreload 0 ' + f'-mpegts_flags resend_headers ' + f'-metadata service_name="{channel_name}" ' + f'-flush_packets 1 ' + f'pipe:1' + ) + @staticmethod def _chno_attr(channel) -> str: """Return ' tvg-chno="N" ch-number="N"' when channel_number is set, else empty string."""