From ea44c7eab8e5c02e240cd195df2411a8bcf43f70 Mon Sep 17 00:00:00 2001 From: Nirvana Date: Sat, 12 Sep 2026 09:59:09 +0200 Subject: [PATCH] adapt routes --- routes/m3u.py | 109 +++++++++++++++++++++++++++++++++++++++++++------- service.py | 25 +++++++----- 2 files changed, 111 insertions(+), 23 deletions(-) diff --git a/routes/m3u.py b/routes/m3u.py index 223aee2..1acd25b 100644 --- a/routes/m3u.py +++ b/routes/m3u.py @@ -3,15 +3,64 @@ M3U playlist route handlers """ +import time + from bottle import request, response from streaming_providers.base.utils import logger +# TTL for the plain M3U cache ("/api/m3u", "/api/providers//m3u") +# only. Nothing embedded there goes stale from a security standpoint +# (drm_directives="" — no keys/license URLs baked in), but the channel +# LINEUP can change upstream (providers add/remove channels), and without +# an expiry the cache would otherwise only ever refresh via a manual +# /generate call. 24h bounds how long a new channel can be missing from +# the served playlist. clientdrm is unaffected — it's already uncached +# (see generate_m3u_clientdrm_all), so it never goes stale in the first +# place, for lineup or DRM credentials. +PLAIN_M3U_TTL_SECONDS = 24 * 60 * 60 + def setup_m3u_routes(app, manager, service): """Setup M3U playlist-related routes""" - def _serve_cached(cache_key: str, filename: str): - """Return cached M3U content with headers set, or None if not cached.""" + def _cache_meta_key(cache_key: str) -> str: + return f"{cache_key}.meta.json" + + def _touch_cache_meta(cache_key: str) -> None: + """ + Record the write time for a cached M3U file, used by the TTL check + in _serve_cached. Stored as a small JSON sidecar via + vfs.write_json/read_json rather than filesystem mtime — VFS + abstracts over an xbmcvfs (Kodi special:// paths) backend as well + as a plain filesystem one, and os.path.getmtime doesn't work + against the former. + """ + service.vfs.write_json(_cache_meta_key(cache_key), {"cached_at": time.time()}) + + def _serve_cached(cache_key: str, filename: str, ttl_seconds: int = None): + """ + Return cached M3U content with headers set, or None if not cached + (or, when ttl_seconds is given, if the sidecar meta file shows the + cache is older than that — triggering a regeneration the same as + a cold cache would). + """ + if ttl_seconds is not None: + meta = service.vfs.read_json(_cache_meta_key(cache_key)) + cached_at = meta.get("cached_at") if meta else None + if cached_at is None: + # No meta yet — e.g. a cache file written before this TTL + # logic existed, or the meta write previously failed. + # Treat as expired so it regenerates (and gets a meta file + # going forward) rather than serving an unbounded-age file. + logger.info(f"No cache-age metadata for {cache_key} — treating as expired") + return None + age = time.time() - cached_at + if age > ttl_seconds: + logger.info( + f"Cache expired for {cache_key} (age {age:.0f}s > TTL {ttl_seconds}s)" + ) + return None + cached = service.vfs.read_text(cache_key) if not cached: return None @@ -20,7 +69,8 @@ def setup_m3u_routes(app, manager, service): response.headers["Content-Disposition"] = f'attachment; filename="{filename}"' return cached - def _handle_m3u_route(generate_fn, log_ctx, cache_key=None, filename=None): + def _handle_m3u_route(generate_fn, log_ctx, cache_key=None, filename=None, + ttl_seconds=None, force=False): """ Shared cache-check / error-handling wrapper for M3U routes. @@ -30,18 +80,36 @@ 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 clientdrm endpoints and the ffmpeg endpoint, plus the - force /generate endpoints, which never checked cache in the - original code either). + (the uncached clientdrm endpoints and the ffmpeg endpoint) and + never touch the TTL meta either. Pass it (with force=False) for a + normal cache-first read, or (with force=True) to skip the read but + still refresh the TTL meta after a forced regeneration — see the + /generate routes below. + + ttl_seconds: pass None (default) to keep the existing "cache + forever until /generate is called" behavior, with no meta file + written. Currently only the plain M3U routes pass + PLAIN_M3U_TTL_SECONDS — noproxy/filtered/subscribed keep the old + no-expiry behavior unless you want that extended too. """ try: - if cache_key: - cached = _serve_cached(cache_key, filename) + if cache_key and not force: + cached = _serve_cached(cache_key, filename, ttl_seconds=ttl_seconds) if cached is not None: return cached logger.info(f"No valid cache found, generating M3U: {log_ctx}") - return generate_fn() + result = generate_fn() + + # Refresh the TTL clock whenever this route actually writes to + # the cache (cache miss above, or a forced /generate) — a + # forced regen with no meta refresh would leave the TTL check + # comparing against a stale timestamp forever, defeating the + # point of the TTL. + if cache_key and ttl_seconds is not None and isinstance(result, str): + _touch_cache_meta(cache_key) + + return result except ValueError as val_err: logger.error(f"API Error in {log_ctx}: {val_err}") @@ -61,20 +129,24 @@ def setup_m3u_routes(app, manager, service): pass # cache file may not exist yet - fine return _handle_m3u_route(generate_fn, log_ctx) - # ── Plain playlists (cached) ────────────────────────────────────────── + # ── Plain playlists (cached, 24h TTL) ────────────────────────────────── # 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. + # volatile from a DRM standpoint (no key/license lookups happen at + # generation time), so caching is safe. The 24h TTL exists for a + # different reason: the channel LINEUP itself can change upstream, and + # without an expiry this would otherwise only refresh via a manual + # /generate call. @app.route("/api/m3u") def get_m3u_all(): - """Generates M3U playlist for all configured providers.""" + """Generates M3U playlist for all configured providers. Cache expires after 24h.""" return _handle_m3u_route( lambda: service.generate_m3u_plain_all(save_to_cache=True), log_ctx="/api/m3u", cache_key="playlist.m3u", filename="playlist.m3u8", + ttl_seconds=PLAIN_M3U_TTL_SECONDS, ) @app.route("/api/m3u/generate") @@ -83,16 +155,21 @@ def setup_m3u_routes(app, manager, service): return _handle_m3u_route( lambda: service.generate_m3u_plain_all(save_to_cache=True), log_ctx="/api/m3u/generate", + cache_key="playlist.m3u", + filename="playlist.m3u8", + ttl_seconds=PLAIN_M3U_TTL_SECONDS, + force=True, ) @app.route("/api/providers//m3u") def get_m3u_provider(provider): - """Generates M3U playlist for a specific provider.""" + """Generates M3U playlist for a specific provider. Cache expires after 24h.""" return _handle_m3u_route( 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", + ttl_seconds=PLAIN_M3U_TTL_SECONDS, ) @app.route("/api/providers//m3u/generate") @@ -101,6 +178,10 @@ def setup_m3u_routes(app, manager, service): return _handle_m3u_route( lambda: service.generate_m3u_plain_provider(provider, save_to_cache=True), log_ctx=f"/api/providers/{provider}/m3u/generate", + cache_key=f"{provider}.m3u", + filename=f"{provider}_playlist.m3u8", + ttl_seconds=PLAIN_M3U_TTL_SECONDS, + force=True, ) # ── Client-side-decrypt playlists (deliberately UNCACHED) ───────────── diff --git a/service.py b/service.py index a1e692c..f7751a6 100644 --- a/service.py +++ b/service.py @@ -1185,7 +1185,11 @@ class UltimateService: """ 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). + DRM lookup via _generate_drm_directives, which embeds whatever the + channel's DRM system needs: ClearKey key/kid pairs inlined directly, + or a Widevine/PlayReady license server URL (+ headers) for the + client's inputstream.adaptive to negotiate against at playback + time. Not limited to ClearKey channels. This backs the "clientdrm" playlists (generate_m3u_clientdrm_all/ _provider, always save_to_cache=False — see those wrappers for why) @@ -1476,14 +1480,17 @@ class UltimateService: 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. + Public wrapper: client-side-decrypt M3U for all providers (dynamic + per-channel DRM lookup — ClearKey key/kid pairs inlined directly, + or a Widevine/PlayReady license server URL + headers, whichever + the channel actually uses; not limited to ClearKey providers). + Deliberately UNCACHED, unlike the plain playlist above — the + embedded DRM config (keys, or license URLs/tokens) can rotate + upstream, and a cached playlist would silently serve stale + credentials 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)