mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-09-16 06:02:35 +02:00
breaking change: consolidate routes with parameters
This commit is contained in:
+16
-2
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user