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):
"""