breaking change: consolidate routes with parameters

This commit is contained in:
Nirvana
2026-08-25 08:54:43 +02:00
parent 786250391f
commit efe403df2a
3 changed files with 117 additions and 93 deletions
+16 -2
View File
@@ -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)
+66 -80
View File
@@ -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/<provider>/channels/<channel_id>/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/<provider>/channels/<channel_id>/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/<provider>/channels/<channel_id>/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/<provider>/channels/<channel_id>/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/<provider>/channels/<channel_id>/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/<provider>/channels/<channel_id>/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/<provider>/channels/<channel_id>/drm")
def get_channel_drm(provider, channel_id):
"""
+35 -11
View File
@@ -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: