From ff1aa617a608898926116fd2f7b242a8b48fff18 Mon Sep 17 00:00:00 2001 From: Nirvana Date: Fri, 11 Sep 2026 23:51:17 +0200 Subject: [PATCH] change routes --- routes/m3u.py | 152 ++++++++++++++++++++++------------------ service.py | 187 ++++++++++++++++++++++++++++++++------------------ 2 files changed, 204 insertions(+), 135 deletions(-) diff --git a/routes/m3u.py b/routes/m3u.py index 906abe1..223aee2 100644 --- a/routes/m3u.py +++ b/routes/m3u.py @@ -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//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//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//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//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//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//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//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//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: diff --git a/service.py b/service.py index 78acb88..a1e692c 100644 --- a/service.py +++ b/service.py @@ -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//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"""