change routes

This commit is contained in:
Nirvana
2026-09-11 23:51:17 +02:00
parent a316ded25b
commit ff1aa617a6
2 changed files with 204 additions and 135 deletions
+84 -68
View File
@@ -30,9 +30,9 @@ def setup_m3u_routes(app, manager, service):
proxy-gated "fast" endpoints, for their own 503 handling.
cache_key: pass None for routes that must always regenerate live
(the uncached "fast" proxied/ffmpeg endpoints, and the force
/generate endpoints, which never checked cache in the original
code either).
(the uncached clientdrm endpoints and the ffmpeg endpoint, plus the
force /generate endpoints, which never checked cache in the
original code either).
"""
try:
if cache_key:
@@ -61,102 +61,119 @@ def setup_m3u_routes(app, manager, service):
pass # cache file may not exist yet - fine
return _handle_m3u_route(generate_fn, log_ctx)
# ── Normal / no-proxy playlists (cached) ──────────────────────────────
# ── Plain playlists (cached) ──────────────────────────────────────────
# Server-side decrypt, bare stream URLs (no "?client_drm=false" — that's
# the route's own default). Nothing per-channel here is request-time-
# volatile (no DRM/key lookups happen at generation time), so caching is
# safe and this is now the default "/api/m3u" behavior.
@app.route("/api/m3u")
def get_m3u_all():
"""Generates M3U playlist for all configured providers."""
return _handle_m3u_route(
lambda: service.generate_m3u_all(save_to_cache=True),
lambda: service.generate_m3u_plain_all(save_to_cache=True),
log_ctx="/api/m3u",
cache_key="playlist.m3u",
filename="playlist.m3u8",
)
@app.route("/api/m3u/noproxy")
def get_m3u_all_noproxy():
"""Generates M3U playlist using direct (non-proxied) stream URLs."""
return _handle_m3u_route(
lambda: service.generate_m3u_all(save_to_cache=True, no_proxy=True),
log_ctx="/api/m3u/noproxy",
cache_key="playlist_noproxy.m3u",
filename="playlist_noproxy.m3u8",
)
@app.route("/api/m3u/generate")
def generate_m3u_all():
"""Force regeneration of M3U playlist for all providers."""
return _handle_m3u_route(
lambda: service.generate_m3u_all(save_to_cache=True),
lambda: service.generate_m3u_plain_all(save_to_cache=True),
log_ctx="/api/m3u/generate",
)
@app.route("/api/m3u/noproxy/generate")
def generate_m3u_all_noproxy():
"""Force regeneration of the no-proxy M3U playlist."""
return _handle_m3u_route(
lambda: service.generate_m3u_all(save_to_cache=True, no_proxy=True),
log_ctx="/api/m3u/noproxy/generate",
)
@app.route("/api/providers/<provider>/m3u")
def get_m3u_provider(provider):
"""Generates M3U playlist for a specific provider."""
return _handle_m3u_route(
lambda: service.generate_m3u_provider(provider, save_to_cache=True),
lambda: service.generate_m3u_plain_provider(provider, save_to_cache=True),
log_ctx=f"/api/providers/{provider}/m3u",
cache_key=f"{provider}.m3u",
filename=f"{provider}_playlist.m3u8",
)
@app.route("/api/providers/<provider>/m3u/noproxy")
def get_m3u_provider_noproxy(provider):
"""Generates no-proxy M3U playlist for a specific provider."""
return _handle_m3u_route(
lambda: service.generate_m3u_provider(provider, save_to_cache=True, no_proxy=True),
log_ctx=f"/api/providers/{provider}/m3u/noproxy",
cache_key=f"{provider}_noproxy.m3u",
filename=f"{provider}_playlist_noproxy.m3u8",
)
@app.route("/api/providers/<provider>/m3u/generate")
def generate_m3u_provider(provider):
"""Force regeneration of M3U playlist for a specific provider."""
return _handle_m3u_route(
lambda: service.generate_m3u_provider(provider, save_to_cache=True),
lambda: service.generate_m3u_plain_provider(provider, save_to_cache=True),
log_ctx=f"/api/providers/{provider}/m3u/generate",
)
# ── Client-side-decrypt playlists (deliberately UNCACHED) ─────────────
# Dynamic per-channel ClearKey lookup, key/kid pairs baked into KODIPROP
# directives. Uncached on purpose: upstream keys can rotate, and a
# cached playlist would silently serve a stale key until someone
# force-regenerated it. Fresh generation per request avoids that
# failure mode; revisit with a short TTL if per-request DRM lookups
# turn out to be too frequent/expensive.
@app.route("/api/m3u/clientdrm")
def get_m3u_clientdrm():
"""Generates client-side-decrypt M3U playlist for all providers. No caching."""
return _handle_m3u_route(
lambda: service.generate_m3u_clientdrm_all(),
log_ctx="/api/m3u/clientdrm",
)
@app.route("/api/providers/<provider>/m3u/clientdrm")
def get_m3u_clientdrm_provider(provider):
"""Generates client-side-decrypt M3U playlist for a specific provider. No caching."""
return _handle_m3u_route(
lambda: service.generate_m3u_clientdrm_provider(provider),
log_ctx=f"/api/providers/{provider}/m3u/clientdrm",
)
# ── No-proxy playlists (cached) ────────────────────────────────────────
# Unrelated to the plain/clientdrm split above — "no_proxy" here means
# bypassing the media proxy at the stream-route level, independent of
# who does the decrypting. Still client-side-decrypt underneath
# (client_drm=true&no_proxy=true), same as before this change; only the
# service method name changed (generate_m3u_all -> generate_m3u_noproxy_all),
# since generate_m3u_all now refers to the plain playlist above.
@app.route("/api/m3u/noproxy")
def get_m3u_all_noproxy():
"""Generates M3U playlist using direct (non-proxied) stream URLs."""
return _handle_m3u_route(
lambda: service.generate_m3u_noproxy_all(save_to_cache=True),
log_ctx="/api/m3u/noproxy",
cache_key="playlist_noproxy.m3u",
filename="playlist_noproxy.m3u8",
)
@app.route("/api/m3u/noproxy/generate")
def generate_m3u_all_noproxy():
"""Force regeneration of the no-proxy M3U playlist."""
return _handle_m3u_route(
lambda: service.generate_m3u_noproxy_all(save_to_cache=True),
log_ctx="/api/m3u/noproxy/generate",
)
@app.route("/api/providers/<provider>/m3u/noproxy")
def get_m3u_provider_noproxy(provider):
"""Generates no-proxy M3U playlist for a specific provider."""
return _handle_m3u_route(
lambda: service.generate_m3u_noproxy_provider(provider, save_to_cache=True),
log_ctx=f"/api/providers/{provider}/m3u/noproxy",
cache_key=f"{provider}_noproxy.m3u",
filename=f"{provider}_playlist_noproxy.m3u8",
)
@app.route("/api/providers/<provider>/m3u/noproxy/generate")
def generate_m3u_provider_noproxy(provider):
"""Force regeneration of no-proxy M3U playlist for a specific provider."""
return _handle_m3u_route(
lambda: service.generate_m3u_provider(provider, save_to_cache=True, no_proxy=True),
lambda: service.generate_m3u_noproxy_provider(provider, save_to_cache=True),
log_ctx=f"/api/providers/{provider}/m3u/noproxy/generate",
)
# ── Proxied / decrypted "fast" playlists (deliberately UNCACHED) ─────
# These intentionally skip the cache layer - proxy/DRM session state
# can shift between requests, and service.generate_m3u_proxied_fast /
# generate_m3u_decrypted_ffmpeg_fast already own the 503 "media proxy
# not configured" guard and their own response headers. Do not give
# these a cache_key.
@app.route("/api/m3u/proxied")
def get_m3u_proxied():
"""Generates proxied M3U playlist for all providers. No caching."""
return _handle_m3u_route(
lambda: service.generate_m3u_proxied_fast(providers=None),
log_ctx="/api/m3u/proxied",
)
@app.route("/api/providers/<provider>/m3u/proxied")
def get_m3u_proxied_provider(provider):
"""Generates proxied M3U playlist for a specific provider. No caching."""
return _handle_m3u_route(
lambda: service.generate_m3u_proxied_fast(providers=provider),
log_ctx=f"/api/providers/{provider}/m3u/proxied",
)
# ── ffmpeg-piped playlist (deliberately UNCACHED) ─────────────────────
# Unchanged by this turn's split — left as-is per your call to leave
# ffmpeg/filtered/subscribed alone for now.
@app.route("/api/providers/<provider>/m3u/proxied/ffmpeg")
def get_m3u_proxied_ffmpeg_provider(provider):
@@ -167,8 +184,9 @@ def setup_m3u_routes(app, manager, service):
)
# ── Filtered proxied playlists (cached; ClearKey or unencrypted only) ─
# NOTE: cache filenames below match what
# _generate_m3u_proxied_filtered_content() actually writes
# Unchanged by this turn's split — left as-is per your call to leave
# ffmpeg/filtered/subscribed alone for now. NOTE: cache filenames below
# match what _generate_m3u_proxied_filtered_content() actually writes
# ("*_proxied_filtered.m3u"), fixing a pre-existing mismatch where
# this route checked "*_proxied_filtered.m3u" - a file the service
# never wrote - so the cache never hit.
@@ -212,13 +230,11 @@ def setup_m3u_routes(app, manager, service):
)
# ── Subscribed-channel playlists ──────────────────────────────────────
# get_m3u_subscribed / get_m3u_subscribed_proxied still build their own
# M3U content directly (they were never moved into service.py) - only the
# boilerplate around them is shared via the same helpers used everywhere
# else. The two bodies used to be ~90% duplicated hand-written copies of
# each other, differing only in stream URL path, whether DRM directives
# are looked up per-channel vs a fixed KODIPROP line, and whether the
# result gets cached — now unified into one function with a `proxied` flag.
# Unchanged by this turn's split — left as-is per your call to leave
# ffmpeg/filtered/subscribed alone for now. get_m3u_subscribed /
# get_m3u_subscribed_proxied still build their own M3U content directly
# (they were never moved into service.py) - only the boilerplate around
# them is shared via the same helpers used everywhere else.
def _generate_m3u_subscribed(proxied: bool = False):
if proxied and not service.media_proxy_url:
+120 -67
View File
@@ -872,21 +872,35 @@ class UltimateService:
continue
yield provider_name, provider_label, channels
def _generate_m3u_proxied_fast(self, providers=None):
def _generate_m3u_plain_content(self, providers=None, save_to_cache=True, cache_filename=None):
"""
Fast generation of decrypted M3U content for specified providers.
Includes ALL channels with decrypted stream URLs.
No DRM filtering, no caching - maximum speed.
Generate M3U content for the default, server-side-decrypt stream
route — bare "stream/index.mpd", no query string at all (client_drm
defaults to false at the route level in channels.py, so this is
equivalent to the old explicit "?client_drm=false", just without
writing out a param that only restates the default).
This is what "/api/m3u" and "/api/providers/<provider>/m3u" serve.
It replaces the old uncached "/m3u/proxied" fast-path — same DRM
handling (drm_directives="", nothing embedded, server does the
decrypting) — but adds caching back, because nothing on this path
is request-time-volatile: there's no per-channel DRM/key lookup
happening at generation time, unlike the dynamic-DRM "clientdrm"
variant below.
Args:
providers: List of provider names, or None for all providers
save_to_cache: Whether to save to cache
cache_filename: Cache filename to use
Returns:
M3U content as string
"""
# Check if media proxy is configured
# Check if media proxy is configured — the bare stream route still
# depends on it at playback time, so fail the playlist build fast
# rather than handing out URLs that won't resolve.
if not self.media_proxy_url:
logger.error("Cannot generate proxied M3U: MEDIA_PROXY_URL not set")
logger.error("Cannot generate M3U: MEDIA_PROXY_URL not set")
response.status = 503
response.content_type = "application/json"
return json.dumps(
@@ -902,25 +916,25 @@ class UltimateService:
# Determine which providers to process
if providers is None:
providers_to_process = self.manager.list_providers()
cache_filename = cache_filename or "playlist.m3u"
else:
providers_to_process = (
[providers] if isinstance(providers, str) else providers
)
cache_filename = cache_filename or f"{providers_to_process[0]}.m3u"
channels_included = 0
for provider_name, provider_label, channels in self._iter_m3u_provider_channels(providers_to_process):
try:
# Process each channel - no DRM checks (all channels are
# already routed through the media proxy at playback time)
for channel in channels:
m3u_content += self._generate_m3u_entry(
base_url, provider_name, channel,
# 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",
# Bare path — no ?client_drm=false, since that's the
# route's own default. No KODIPROP line needed either
# (drm_directives="") — the client never uses
# inputstream.adaptive when the server decrypts.
stream_path="stream/index.mpd",
provider_label=provider_label,
drm_directives="",
)
@@ -932,21 +946,15 @@ class UltimateService:
)
continue
logger.info(f"Fast decrypted M3U: included {channels_included} channels")
logger.info(f"Plain M3U: included {channels_included} channels")
# Set appropriate headers
response.content_type = "audio/x-mpegurl; charset=utf-8"
if providers and isinstance(providers, str):
# Single provider
response.headers["Content-Disposition"] = (
f'attachment; filename="{providers}_proxied_playlist.m3u8"'
)
else:
# All providers
response.headers["Content-Disposition"] = (
'attachment; filename="playlist_proxied.m3u8"'
)
# Save to cache if requested — safe here (see docstring: nothing on
# this path is request-time-volatile).
if save_to_cache and cache_filename:
if self.vfs.write_text(cache_filename, m3u_content):
logger.info(f"M3U playlist cached to {cache_filename}")
else:
logger.warning(f"Failed to cache M3U playlist to {cache_filename}")
return m3u_content
@@ -1175,7 +1183,14 @@ class UltimateService:
self, providers=None, save_to_cache=True, cache_filename=None, no_proxy=False
):
"""
Internal method to generate M3U content for specified providers.
Internal method to generate M3U content for specified providers, on
the client-side-decrypt route (client_drm=true — dynamic per-channel
DRM/ClearKey lookup, key material embedded as KODIPROP directives).
This backs the "clientdrm" playlists (generate_m3u_clientdrm_all/
_provider, always save_to_cache=False — see those wrappers for why)
and the pre-existing "/m3u/noproxy" playlists, which combine
client-side decrypt with a forced non-proxied stream route.
Args:
providers: List of provider names, or None for all providers
@@ -1435,17 +1450,85 @@ class UltimateService:
"""Public wrapper for EPG ID lookup."""
return self._get_epg_id(channel_id)
def generate_m3u_all(self, save_to_cache: bool = False, no_proxy: bool = False) -> str:
"""Public wrapper for full M3U generation."""
return self._generate_m3u_all(save_to_cache=save_to_cache, no_proxy=no_proxy)
def generate_m3u_plain_all(self, save_to_cache: bool = False) -> str:
"""
Public wrapper: server-side-decrypt M3U for all providers (bare
stream URLs, no query string). This is "/api/m3u" — cached by
default when called from the route (save_to_cache=True there).
"""
logger.info("Generating M3U playlist for all providers")
m3u_content = self._generate_m3u_plain_content(providers=None, save_to_cache=save_to_cache)
def generate_m3u_provider(self, provider: str, save_to_cache: bool = False, no_proxy: bool = False) -> str:
"""Public wrapper for per-provider M3U generation."""
return self._generate_m3u_provider(provider, save_to_cache=save_to_cache, no_proxy=no_proxy)
response.content_type = "audio/x-mpegurl; charset=utf-8"
response.headers["Content-Disposition"] = 'attachment; filename="playlist.m3u8"'
def generate_m3u_proxied_fast(self, providers=None) -> str:
"""Public wrapper for fast decrypted M3U generation."""
return self._generate_m3u_proxied_fast(providers)
return m3u_content
def generate_m3u_plain_provider(self, provider: str, save_to_cache: bool = False) -> str:
"""Public wrapper: server-side-decrypt M3U for a specific provider."""
logger.info(f"Generating M3U playlist for provider '{provider}'")
m3u_content = self._generate_m3u_plain_content(providers=provider, save_to_cache=save_to_cache)
response.content_type = "audio/x-mpegurl; charset=utf-8"
response.headers["Content-Disposition"] = f'attachment; filename="{provider}_playlist.m3u8"'
return m3u_content
def generate_m3u_clientdrm_all(self) -> str:
"""
Public wrapper: client-side-decrypt M3U for all providers
(dynamic per-channel ClearKey lookup, key/kid pairs embedded as
KODIPROP directives). Deliberately UNCACHED, unlike the plain
playlist above — upstream keys can rotate, and a cached playlist
would silently serve a stale key until someone force-regenerates
it. Generating fresh per request avoids that failure mode
entirely; add a short TTL later if per-request DRM-config lookups
turn out to be too frequent/expensive in practice.
"""
logger.info("Generating clientdrm M3U playlist for all providers")
m3u_content = self._generate_m3u_content(providers=None, save_to_cache=False)
response.content_type = "audio/x-mpegurl; charset=utf-8"
response.headers["Content-Disposition"] = 'attachment; filename="playlist_clientdrm.m3u8"'
return m3u_content
def generate_m3u_clientdrm_provider(self, provider: str) -> str:
"""Public wrapper: client-side-decrypt M3U for a specific provider. Uncached — see generate_m3u_clientdrm_all."""
logger.info(f"Generating clientdrm M3U playlist for provider '{provider}'")
m3u_content = self._generate_m3u_content(providers=provider, save_to_cache=False)
response.content_type = "audio/x-mpegurl; charset=utf-8"
response.headers["Content-Disposition"] = f'attachment; filename="{provider}_playlist_clientdrm.m3u8"'
return m3u_content
def generate_m3u_noproxy_all(self, save_to_cache: bool = False) -> str:
"""
Public wrapper: client-side-decrypt M3U for all providers, forced
onto the non-proxied stream route (client_drm=true&no_proxy=true).
Backs "/api/m3u/noproxy" — unrelated to this turn's plain/clientdrm
split, kept exactly as it behaved before (cached, via
_generate_m3u_content). Renamed from the old generate_m3u_all only
because that name now belongs to the plain-playlist wrapper above.
"""
logger.info("Generating no-proxy M3U playlist for all providers")
m3u_content = self._generate_m3u_content(providers=None, save_to_cache=save_to_cache, no_proxy=True)
response.content_type = "audio/x-mpegurl; charset=utf-8"
response.headers["Content-Disposition"] = 'attachment; filename="playlist_noproxy.m3u8"'
return m3u_content
def generate_m3u_noproxy_provider(self, provider: str, save_to_cache: bool = False) -> str:
"""Public wrapper: no-proxy M3U for a specific provider. See generate_m3u_noproxy_all."""
logger.info(f"Generating no-proxy M3U playlist for provider '{provider}'")
m3u_content = self._generate_m3u_content(providers=provider, save_to_cache=save_to_cache, no_proxy=True)
response.content_type = "audio/x-mpegurl; charset=utf-8"
response.headers["Content-Disposition"] = f'attachment; filename="{provider}_playlist_noproxy.m3u8"'
return m3u_content
def generate_m3u_proxied_ffmpeg_fast(self, providers=None) -> str:
"""Public wrapper for fast ffmpeg-decrypted M3U generation."""
@@ -1511,36 +1594,6 @@ class UltimateService:
logger.warning(f"Unsupported headers type: {type(req_headers)}")
return str(req_headers)
def _generate_m3u_all(self, save_to_cache: bool = False, no_proxy: bool = False) -> str:
"""Internal method to generate M3U for all providers."""
logger.info(f"Generating {'no-proxy ' if no_proxy else ''}M3U playlist for all providers")
m3u_content = self._generate_m3u_content(
providers=None, save_to_cache=save_to_cache, no_proxy=no_proxy
)
# Set appropriate headers for M3U
response.content_type = "audio/x-mpegurl; charset=utf-8"
filename = "playlist_noproxy.m3u8" if no_proxy else "playlist.m3u8"
response.headers["Content-Disposition"] = f'attachment; filename="{filename}"'
return m3u_content
def _generate_m3u_provider(self, provider: str, save_to_cache: bool = False, no_proxy: bool = False) -> str:
"""Internal method to generate M3U for a specific provider."""
logger.info(f"Generating {'no-proxy ' if no_proxy else ''}M3U playlist for provider '{provider}'")
m3u_content = self._generate_m3u_content(
providers=provider, save_to_cache=save_to_cache, no_proxy=no_proxy
)
# Set appropriate headers for M3U
response.content_type = "audio/x-mpegurl; charset=utf-8"
filename = f"{provider}_playlist_noproxy.m3u8" if no_proxy else f"{provider}_playlist.m3u8"
response.headers["Content-Disposition"] = (
f'attachment; filename="{filename}"'
)
return m3u_content
@staticmethod
def get_settings_manager():
"""Simple helper to get SettingsManager"""