From 6c32e93a02bac5482c304ef362ad5e05174574c2 Mon Sep 17 00:00:00 2001 From: Nirvana Date: Mon, 5 Oct 2026 11:18:01 +0200 Subject: [PATCH] add raw route --- routes/m3u.py | 30 ++++++++ service.py | 189 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 219 insertions(+) diff --git a/routes/m3u.py b/routes/m3u.py index 5bdeb48..fb2352d 100644 --- a/routes/m3u.py +++ b/routes/m3u.py @@ -27,6 +27,7 @@ FALLBACK_WARN_INTERVAL_SECONDS = 60 * 60 # visible to clients and when debugging (the URL is the same either way). M3U_MODE_PLAIN = "plain" M3U_MODE_CLIENTDRM_FALLBACK = "clientdrm-fallback" +M3U_MODE_NOPROXY_RAW = "noproxy-raw" def setup_m3u_routes(app, manager, service): @@ -357,6 +358,35 @@ def setup_m3u_routes(app, manager, service): log_ctx=f"/api/providers/{provider}/m3u/noproxy/generate", ) + # ── No-proxy / raw-URL playlists (deliberately UNCACHED) ────────────── + # clientdrm M3U with the raw upstream manifest URL written directly into + # each entry — no /stream/index.mpd redirect hop. Strict: channels that + # need headers or manifest context are skipped, not downgraded (counts + # are exposed via X-M3U-Channels-Included / -Skipped). Uncached, unlike + # /api/m3u/noproxy: raw URLs may carry short-lived tokens. Hence no + # /generate variants either — there is nothing to regenerate. + # Unlike /api/m3u/noproxy, entries carry no catchup attributes: the raw + # upstream live URL can't take the start/end times the redirect route + # translates. + + @app.route("/api/m3u/noproxy/raw") + def get_m3u_all_noproxy_raw(): + """Generates clientdrm M3U with raw upstream manifest URLs. No caching.""" + response.headers["X-M3U-Mode"] = M3U_MODE_NOPROXY_RAW + return _handle_m3u_route( + lambda: service.generate_m3u_noproxy_raw_all(), + log_ctx="/api/m3u/noproxy/raw", + ) + + @app.route("/api/providers//m3u/noproxy/raw") + def get_m3u_provider_noproxy_raw(provider): + """Generates clientdrm M3U with raw upstream manifest URLs for a provider. No caching.""" + response.headers["X-M3U-Mode"] = M3U_MODE_NOPROXY_RAW + return _handle_m3u_route( + lambda: service.generate_m3u_noproxy_raw_provider(provider), + log_ctx=f"/api/providers/{provider}/m3u/noproxy/raw", + ) + # ── ffmpeg-piped playlist (deliberately UNCACHED) ───────────────────── # Intentionally left as-is (not part of the plain/clientdrm split). diff --git a/service.py b/service.py index 0452c04..b10e003 100644 --- a/service.py +++ b/service.py @@ -1292,6 +1292,166 @@ class UltimateService: return m3u_content + @staticmethod + def _provider_needs_headers(provider_instance, channel_id: str) -> bool: + """ + True if playing this channel requires manifest or segment headers + (auth tokens etc.) — the same two provider calls + fetch_manifest_for_rewriter makes at playback time. A raw upstream + URL in an M3U carries no headers, so such channels would 403. + + Matches fetch_manifest_for_rewriter exactly: no catchup kwargs (only + live is emitted) and no country (not known at playlist time). Do not + add either here without also changing what the raw path emits. + + Deliberately lets exceptions propagate: "could not determine" must + not be read as "no headers needed". Callers skip the channel. + """ + return bool( + provider_instance.get_manifest_headers(channel_id) + or provider_instance.get_segment_headers(channel_id) + ) + + def _generate_m3u_noproxy_raw_content(self, providers=None): + """ + Like _generate_m3u_content(no_proxy=True), but resolves each channel's + upstream manifest URL at generation time and writes it straight into + the playlist, removing the /stream/index.mpd redirect hop. + + STRICT: a channel that cannot safely be served by a bare upstream URL + is SKIPPED, never downgraded to the redirect URL. Skipped when: + - the provider has requires_manifest_context (manifest not usable + standalone), or + - manifest/segment headers are required (the M3U carries no auth), or + that could not be determined, or + - the manifest URL could not be resolved / is empty. + + Catchup attributes are intentionally NOT emitted: catchup_type + "append" would append ?start_time=...&end_time=... to the raw + upstream live URL, which the upstream does not understand (the + redirect route is what translates it). + + Deliberately UNCACHED — raw URLs can carry short-lived tokens. + + Returns: + (m3u_content, channels_included, channels_skipped) + """ + m3u_content = "#EXTM3U\n" + + if providers is None: + providers_to_process = self.manager.list_providers() + else: + providers_to_process = ( + [providers] if isinstance(providers, str) else providers + ) + + total_included = 0 + total_skipped = 0 + + for provider_name, provider_label, channels in self._iter_m3u_provider_channels(providers_to_process): + included = 0 + provider_skip_reason = None + try: + provider_instance = self.manager.get_provider(provider_name) + if provider_instance is None: + provider_skip_reason = "provider resolved to None" + elif getattr(provider_instance, "requires_manifest_context", False): + provider_skip_reason = "provider requires manifest context" + + if provider_skip_reason is None: + for channel in channels: + channel_id = channel.channel_id + + # Cheap check first, expensive manifest resolution last. + try: + if self._provider_needs_headers(provider_instance, channel_id): + logger.debug( + f"noproxy/raw: skipping {provider_name}/{channel_id} — headers required" + ) + continue + except Exception as hdr_err: + logger.warning( + f"noproxy/raw: header check failed for " + f"{provider_name}/{channel_id}: {hdr_err} — skipping" + ) + continue + + try: + manifest_url = self.manager.get_channel_manifest( + provider_name=provider_name, channel_id=channel_id + ) + except Exception as url_err: + logger.warning( + f"noproxy/raw: could not resolve manifest URL for " + f"{provider_name}/{channel_id}: {url_err} — skipping" + ) + continue + + if not manifest_url: + logger.warning( + f"noproxy/raw: empty manifest URL for " + f"{provider_name}/{channel_id} — skipping" + ) + continue + + # DRM lookup done here, not via drm_directives=None: + # _build_m3u_entry_header swallows lookup errors and + # would emit a keyless (undecryptable) entry. Strict + # like the checks above — same as + # _generate_m3u_proxied_filtered_content, which also + # skips a channel whose DRM lookup raises. + try: + drm_configs = self.manager.get_channel_drm_configs( + provider_name=provider_name, channel_id=channel_id + ) + except Exception as drm_err: + logger.warning( + f"noproxy/raw: DRM lookup failed for " + f"{provider_name}/{channel_id}: {drm_err} — skipping" + ) + continue + + m3u_content += self._build_m3u_entry_header( + provider_name, channel, + provider_label=provider_label, + drm_directives=( + self._generate_drm_directives(drm_configs) + if drm_configs else "" + ), + include_catchup=False, + ) + m3u_content += f"{manifest_url}\n" + included += 1 + + except Exception as provider_err: + logger.warning( + f"noproxy/raw: failed to process provider '{provider_name}': {provider_err}" + ) + + skipped = len(channels) - included + total_included += included + total_skipped += skipped + + if provider_skip_reason: + logger.warning( + f"noproxy/raw: provider '{provider_name}' skipped entirely " + f"({provider_skip_reason}) — {skipped} channels omitted" + ) + elif included == 0 and skipped > 0: + logger.warning( + f"noproxy/raw: provider '{provider_name}' contributed 0 of " + f"{skipped} channels (all need headers or failed to resolve)" + ) + elif skipped: + logger.info( + f"noproxy/raw: provider '{provider_name}': {included} included, {skipped} skipped" + ) + + logger.info( + f"noproxy/raw M3U: included {total_included} channels, skipped {total_skipped}" + ) + return m3u_content, total_included, total_skipped + def _generate_m3u_proxied_filtered_all(self, save_to_cache: bool = False) -> str: """Internal method to generate filtered decrypted M3U for all providers.""" logger.info("Generating filtered decrypted M3U playlist for all providers") @@ -1557,6 +1717,35 @@ class UltimateService: return m3u_content + def _serve_m3u_noproxy_raw(self, providers, filename: str) -> str: + """Shared body of the noproxy/raw public wrappers: generate, set headers.""" + m3u_content, included, skipped = self._generate_m3u_noproxy_raw_content(providers=providers) + + response.content_type = "audio/x-mpegurl; charset=utf-8" + response.headers["Content-Disposition"] = f'attachment; filename="{filename}"' + # Raw upstream URLs may embed short-lived tokens: keep clients and + # intermediaries from caching the playlist either. + response.headers["Cache-Control"] = "no-store" + # Skips are otherwise invisible to the client (a shorter playlist). + response.headers["X-M3U-Channels-Included"] = str(included) + response.headers["X-M3U-Channels-Skipped"] = str(skipped) + + return m3u_content + + def generate_m3u_noproxy_raw_all(self) -> str: + """ + Public wrapper: clientdrm M3U with raw upstream manifest URLs (no + redirect hop) for all providers. Backs "/api/m3u/noproxy/raw". + Deliberately UNCACHED — see _generate_m3u_noproxy_raw_content. + """ + logger.info("Generating noproxy/raw M3U playlist for all providers") + return self._serve_m3u_noproxy_raw(None, "playlist_noproxy_raw.m3u8") + + def generate_m3u_noproxy_raw_provider(self, provider: str) -> str: + """Public wrapper: noproxy/raw M3U for a single provider. Uncached.""" + logger.info(f"Generating noproxy/raw M3U playlist for provider '{provider}'") + return self._serve_m3u_noproxy_raw(provider, f"{provider}_playlist_noproxy_raw.m3u8") + def generate_m3u_proxied_ffmpeg_fast(self, providers=None) -> str: """Public wrapper for fast ffmpeg-decrypted M3U generation.""" return self._generate_m3u_proxied_ffmpeg_fast(providers)