From 786250391f472ebe6f203a7bd7e3d801c41f2e3e Mon Sep 17 00:00:00 2001 From: Nirvana Date: Sat, 22 Aug 2026 23:38:01 +0200 Subject: [PATCH] service and m3u consolodation --- routes/m3u.py | 113 ++++------ service.py | 581 +++++++++++++++----------------------------------- 2 files changed, 212 insertions(+), 482 deletions(-) diff --git a/routes/m3u.py b/routes/m3u.py index 62ed578..3ec96e8 100644 --- a/routes/m3u.py +++ b/routes/m3u.py @@ -212,12 +212,19 @@ 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), - # so they keep their bespoke bodies below - only the boilerplate - # around them is shared via the same helpers used everywhere else. + # 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. + + def _generate_m3u_subscribed(proxied: bool = False): + if proxied and not service.media_proxy_url: + response.status = 503 + return {"error": "Media proxy not configured (MEDIA_PROXY_URL not set)"} - def _generate_m3u_subscribed(): base_url = f"{request.urlparts.scheme}://{request.urlparts.netloc}" m3u_content = "#EXTM3U\n" @@ -246,19 +253,25 @@ 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_url = f"{base_url}/api/providers/{provider_name}/channels/{channel_id}/stream/index.mpd" + stream_path = "stream/proxied/index.mpd" if proxied else "stream/index.mpd" + stream_url = ( + f"{base_url}/api/providers/{provider_name}/channels/{channel_id}/{stream_path}" + ) m3u_content += ( f'#EXTINF:-1 tvg-id="{channel_id}"{epg_id_attr}{chno} ' f'tvg-logo="{channel_logo}" group-title="{provider_label}",{channel_name}\n' ) - try: - drm_configs = manager.get_channel_drm_configs(provider_name, channel_id) - if drm_configs: - m3u_content += service.generate_drm_directives(drm_configs) - except Exception as drm_err: - logger.debug(f"Could not get DRM for {provider_name}/{channel_id}: {drm_err}") + if proxied: + m3u_content += "#KODIPROP:inputstream=inputstream.adaptive\n" + else: + try: + drm_configs = manager.get_channel_drm_configs(provider_name, channel_id) + if drm_configs: + m3u_content += service.generate_drm_directives(drm_configs) + except Exception as drm_err: + logger.debug(f"Could not get DRM for {provider_name}/{channel_id}: {drm_err}") m3u_content += f"{stream_url}\n" @@ -268,18 +281,24 @@ def setup_m3u_routes(app, manager, service): ) continue - if service.vfs.write_text("playlist_subscribed.m3u", m3u_content): - logger.info("Subscribed M3U playlist cached to playlist_subscribed.m3u") + filename = "playlist_subscribed_proxied.m3u8" if proxied else "playlist_subscribed.m3u8" + + # Proxied variant is deliberately uncached — proxy/DRM session state + # can shift between requests, same rationale as the "fast" proxied + # playlists above. + if not proxied: + if service.vfs.write_text("playlist_subscribed.m3u", m3u_content): + logger.info("Subscribed M3U playlist cached to playlist_subscribed.m3u") response.content_type = "audio/x-mpegurl; charset=utf-8" - response.headers["Content-Disposition"] = 'attachment; filename="playlist_subscribed.m3u8"' + response.headers["Content-Disposition"] = f'attachment; filename="{filename}"' return m3u_content @app.route("/api/m3u/subscribed") def get_m3u_subscribed(): """Generate M3U playlist with only subscribed channels.""" return _handle_m3u_route( - _generate_m3u_subscribed, + lambda: _generate_m3u_subscribed(proxied=False), log_ctx="/api/m3u/subscribed", cache_key="playlist_subscribed.m3u", filename="playlist_subscribed.m3u8", @@ -290,66 +309,14 @@ def setup_m3u_routes(app, manager, service): """Force regenerate subscribed M3U playlist.""" return _force_regenerate( "playlist_subscribed.m3u", - _generate_m3u_subscribed, + lambda: _generate_m3u_subscribed(proxied=False), log_ctx="/api/m3u/subscribed/generate", ) @app.route("/api/m3u/subscribed/proxied") def get_m3u_subscribed_proxied(): """Generate proxied M3U playlist with only subscribed channels. No caching.""" - - def _generate(): - if not service.media_proxy_url: - response.status = 503 - return {"error": "Media proxy not configured (MEDIA_PROXY_URL not set)"} - - base_url = f"{request.urlparts.scheme}://{request.urlparts.netloc}" - m3u_content = "#EXTM3U\n" - - for provider_name in manager.list_providers(): - try: - channels = sorted( - manager.get_subscribed_channels(provider_name), - key=lambda ch: (ch.channel_number is None, ch.channel_number or 0), - ) - - try: - provider_label = getattr( - manager.get_provider(provider_name), "provider_label", provider_name - ) - except Exception: - provider_label = provider_name - - for channel in channels: - channel_id = channel.channel_id - channel_name = channel.name - channel_logo = channel.logo_url or "" - chno = ( - f' tvg-chno="{channel.channel_number}" ch-number="{channel.channel_number}"' - if getattr(channel, "channel_number", None) is not None - else "" - ) - epg_id = service.get_epg_id(channel_id) - epg_id_attr = f' tvg-epgid="{epg_id}"' if epg_id else "" - stream_url = ( - f"{base_url}/api/providers/{provider_name}/channels/{channel_id}/stream/proxied/index.mpd" - ) - - m3u_content += ( - f'#EXTINF:-1 tvg-id="{channel_id}"{epg_id_attr}{chno} ' - f'tvg-logo="{channel_logo}" group-title="{provider_label}",{channel_name}\n' - ) - m3u_content += "#KODIPROP:inputstream=inputstream.adaptive\n" - m3u_content += f"{stream_url}\n" - - except Exception as provider_err: - logger.warning( - f"Failed to process subscribed channels for '{provider_name}': {provider_err}" - ) - continue - - response.content_type = "audio/x-mpegurl; charset=utf-8" - response.headers["Content-Disposition"] = 'attachment; filename="playlist_subscribed_proxied.m3u8"' - return m3u_content - - return _handle_m3u_route(_generate, log_ctx="/api/m3u/subscribed/proxied") \ No newline at end of file + return _handle_m3u_route( + lambda: _generate_m3u_subscribed(proxied=True), + log_ctx="/api/m3u/subscribed/proxied", + ) \ No newline at end of file diff --git a/service.py b/service.py index 31ada3b..ea44866 100644 --- a/service.py +++ b/service.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 import json +import math import os import sys import threading @@ -709,144 +710,127 @@ class UltimateService: return None return self.epg_alias_map.get(channel_id) - def _generate_m3u_channel_entry(self, base_url, provider_name, channel, no_proxy=False): + def _build_m3u_entry_header( + self, + provider_name, + channel, + *, + provider_label=None, + drm_directives=None, + include_catchup=True, + ) -> str: """ - Generate M3U entry for a single channel. + Build the #EXTINF (+ optional KODIPROP) block for one channel, without + the trailing stream URL / playback line — callers append that + themselves via _generate_m3u_entry or their own final line (e.g. the + ffmpeg-piped variant, which wraps the stream URL in a pipe command + instead of using it directly). + + Single source of truth for the EXTINF/catchup-tag/chno construction + previously duplicated across _generate_m3u_channel_entry, + _generate_m3u_proxied_channel_entry, and inline reimplementations in + _generate_m3u_proxied_fast, _generate_m3u_proxied_ffmpeg_fast, and + _generate_m3u_proxied_filtered_content's unencrypted branch. Args: - base_url: Base URL for stream endpoints - provider_name: Name of the provider - channel: StreamingChannel object - no_proxy: If True, point the stream URL at the /stream/noproxy/index.mpd - variant instead of /stream/index.mpd. Everything else about the - entry (EPG attrs, catchup attrs, DRM KODIPROP directives) is - unchanged — no_proxy only affects transport, not DRM. - - Returns: - M3U entry as string + provider_label: Pass explicitly when the caller already resolved + it once per provider (avoids a redundant + get_provider() lookup per channel). Falls back to + a fresh lookup, then provider_name, if omitted. + drm_directives: None = look up this channel's DRM configs + dynamically and generate directives if any exist + (the historical /stream/index.mpd behavior). + "" = skip DRM directives entirely (known + unencrypted content). Any other string = insert + as-is (e.g. a fixed + "#KODIPROP:inputstream=inputstream.adaptive\\n" + for content already known to be ClearKey/proxied, + avoiding a redundant DRM lookup the caller + already did). + include_catchup: False suppresses catchup attributes on the + EXTINF line even if the channel has a catchup + window — used by the ffmpeg-piped variant, which + is intentionally live-only (existing behavior, + preserved rather than changed here). """ - entry_content = "" - - # Access StreamingChannel attributes directly channel_id = channel.channel_id channel_name = channel.name channel_logo = channel.logo_url or "" chno = self._chno_attr(channel) - # Get EPG ID if available epg_id = self._get_epg_id(channel_id) epg_id_attr = f' tvg-epgid="{epg_id}"' if epg_id else "" - # Build stream URL. no_proxy points at the sibling route that forces a - # redirect to the upstream manifest instead of going through the media proxy. - stream_path = "stream/noproxy/index.mpd" if no_proxy else "stream/index.mpd" - stream_url = ( - f"{base_url}/api/providers/{provider_name}/channels/{channel_id}/{stream_path}" - ) + if provider_label is None: + try: + provider_label = self.manager.get_provider(provider_name).provider_label + except (AttributeError, KeyError, ValueError): + provider_label = provider_name - # Get provider instance to access provider_label - try: - provider_instance = self.manager.get_provider(provider_name) - provider_label = provider_instance.provider_label - except (AttributeError, KeyError, ValueError): - # Fallback to provider_name if provider_label is not available - provider_label = provider_name - - # Get catchup information from channel - catchup_window = getattr(channel, 'catchup_hours', 0) # in hours - catchup_type = getattr(channel, 'catchup_type', 'append') - catchup_source = getattr(channel, 'catchup_source', '?start_time={utc}&end_time={utcend}') - - # Build EXTINF line with catchup tags if available - import math + entry_content = "" + catchup_window = getattr(channel, 'catchup_hours', 0) if include_catchup else 0 if catchup_window > 0: - # Convert hours to days (ceil to ensure full coverage) + catchup_type = getattr(channel, 'catchup_type', 'append') + catchup_source = getattr(channel, 'catchup_source', '?start_time={utc}&end_time={utcend}') catchup_days = math.ceil(catchup_window / 24) - entry_content += f'#EXTINF:-1 tvg-id="{channel_id}"{epg_id_attr}{chno} tvg-logo="{channel_logo}" group-title="{provider_label}" catchup="{catchup_type}" catchup-days="{catchup_days}" catchup-source="{catchup_source}",{channel_name}\n' + entry_content += ( + f'#EXTINF:-1 tvg-id="{channel_id}"{epg_id_attr}{chno} tvg-logo="{channel_logo}" ' + f'group-title="{provider_label}" catchup="{catchup_type}" catchup-days="{catchup_days}" ' + f'catchup-source="{catchup_source}",{channel_name}\n' + ) else: - entry_content += f'#EXTINF:-1 tvg-id="{channel_id}"{epg_id_attr}{chno} tvg-logo="{channel_logo}" group-title="{provider_label}",{channel_name}\n' - - # Get DRM configs and add KODIPROP directives - try: - drm_configs = self.manager.get_channel_drm_configs( - provider_name=provider_name, channel_id=channel_id + entry_content += ( + f'#EXTINF:-1 tvg-id="{channel_id}"{epg_id_attr}{chno} tvg-logo="{channel_logo}" ' + f'group-title="{provider_label}",{channel_name}\n' ) - if drm_configs: - drm_directives = self._generate_drm_directives(drm_configs) - entry_content += drm_directives - - except Exception as drm_err: - logger.debug( - f"Could not get DRM for {provider_name}/{channel_id}: {str(drm_err)}" - ) - - # Add stream URL - entry_content += f"{stream_url}\n" + if drm_directives is None: + try: + drm_configs = self.manager.get_channel_drm_configs( + provider_name=provider_name, channel_id=channel_id + ) + if drm_configs: + entry_content += self._generate_drm_directives(drm_configs) + except Exception as drm_err: + logger.debug( + f"Could not get DRM for {provider_name}/{channel_id}: {str(drm_err)}" + ) + elif drm_directives: + entry_content += drm_directives return entry_content - def _generate_m3u_proxied_channel_entry( - self, # Changed from @staticmethod to instance method + def _generate_m3u_entry( + self, base_url, provider_name, - provider_label, channel, - clearkey_data, - provider_proxy_url, - ): + *, + stream_path, + provider_label=None, + drm_directives=None, + include_catchup=True, + ) -> str: """ - Generate M3U entry for a ClearKey encrypted channel with decryption URL. + Full M3U entry (header + trailing stream URL line) for one channel. + See _build_m3u_entry_header for the shared header-construction logic; + this just appends the stream URL built from stream_path. Args: - base_url: Base URL for stream endpoints - provider_name: Name of the provider - provider_label: Display label for the provider - channel: StreamingChannel object - clearkey_data: ClearKey DRM data - provider_proxy_url: Optional provider proxy URL - - Returns: - M3U entry as string + 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. """ - entry_content = "" - channel_id = channel.channel_id - channel_name = channel.name - channel_logo = channel.logo_url or "" - chno = f' tvg-chno="{channel.channel_number}" ch-number="{channel.channel_number}"' if getattr(channel, - "channel_number", - None) is not None else "" - - # Get EPG ID if available - epg_id = self._get_epg_id(channel_id) - epg_id_attr = f' tvg-epgid="{epg_id}"' if epg_id else "" - - # Build proxied stream URL via media proxy with /index.mpd - # Format: /api/providers/{provider}/channels/{channel_id}/stream/proxied/index.mpd - stream_url = f"{base_url}/api/providers/{provider_name}/channels/{channel_id}/stream/proxied/index.mpd" - - # Get catchup information from channel - catchup_window = getattr(channel, 'catchup_hours', 0) # in hours - catchup_type = getattr(channel, 'catchup_type', 'append') - catchup_source = getattr(channel, 'catchup_source', '?start_time={utc}&end_time={utcend}') - - import math - # Build EXTINF line with catchup tags if available - if catchup_window > 0: - # Convert hours to days (ceil to ensure full coverage) - catchup_days = math.ceil(catchup_window / 24) - entry_content += f'#EXTINF:-1 tvg-id="{channel_id}"{epg_id_attr}{chno} tvg-logo="{channel_logo}" group-title="{provider_label}" catchup="{catchup_type}" catchup-days="{catchup_days}" catchup-source="{catchup_source}",{channel_name}\n' - else: - entry_content += f'#EXTINF:-1 tvg-id="{channel_id}"{epg_id_attr}{chno} tvg-logo="{channel_logo}" group-title="{provider_label}",{channel_name}\n' - - # Add KODIPROP for inputstream.adaptive (still needed for DASH playback) - entry_content += "#KODIPROP:inputstream=inputstream.adaptive\n" - - # Add stream URL - entry_content += f"{stream_url}\n" - - return entry_content + header = self._build_m3u_entry_header( + provider_name, channel, + provider_label=provider_label, + drm_directives=drm_directives, + include_catchup=include_catchup, + ) + stream_url = f"{base_url}/api/providers/{provider_name}/channels/{channel_id}/{stream_path}" + return header + f"{stream_url}\n" def _generate_m3u_proxied_fast(self, providers=None): """ @@ -899,36 +883,15 @@ class UltimateService: except (AttributeError, KeyError, ValueError): provider_label = provider_name - # Process each channel - no DRM checks + # Process each channel - no DRM checks (all channels are + # already routed through the media proxy at playback time) for channel in channels: - channel_id = channel.channel_id - channel_name = channel.name - channel_logo = channel.logo_url or "" - chno = self._chno_attr(channel) - - # Get EPG ID if available - epg_id = self._get_epg_id(channel_id) - epg_id_attr = f' tvg-epgid="{epg_id}"' if epg_id else "" - - # Build decrypted stream URL - stream_url = f"{base_url}/api/providers/{provider_name}/channels/{channel_id}/stream/proxied/index.mpd" - - # Get catchup information - catchup_window = getattr(channel, 'catchup_hours', 0) - catchup_type = getattr(channel, 'catchup_type', 'append') - catchup_source = getattr(channel, 'catchup_source', '?start_time={utc}&end_time={utcend}') - - import math - # Add M3U entry with catchup tags if available - if catchup_window > 0: - catchup_days = math.ceil(catchup_window / 24) - m3u_content += f'#EXTINF:-1 tvg-id="{channel_id}"{epg_id_attr}{chno} tvg-logo="{channel_logo}" group-title="{provider_label}" catchup="{catchup_type}" catchup-days="{catchup_days}" catchup-source="{catchup_source}",{channel_name}\n' - else: - m3u_content += f'#EXTINF:-1 tvg-id="{channel_id}"{epg_id_attr}{chno} tvg-logo="{channel_logo}" group-title="{provider_label}",{channel_name}\n' - - m3u_content += "#KODIPROP:inputstream=inputstream.adaptive\n" - m3u_content += f"{stream_url}\n" - + m3u_content += self._generate_m3u_entry( + base_url, provider_name, channel, + stream_path="stream/proxied/index.mpd", + provider_label=provider_label, + drm_directives="#KODIPROP:inputstream=inputstream.adaptive\n", + ) channels_included += 1 except Exception as provider_err: @@ -1010,12 +973,6 @@ class UltimateService: for channel in channels: channel_id = channel.channel_id channel_name = channel.name - channel_logo = channel.logo_url or "" - chno = self._chno_attr(channel) - - # Get EPG ID if available - epg_id = self._get_epg_id(channel_id) - epg_id_attr = f' tvg-epgid="{epg_id}"' if epg_id else "" # 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" @@ -1041,9 +998,16 @@ class UltimateService: f'pipe:1' ) - # Add M3U entry with metadata - m3u_content += f'#EXTINF:-1 tvg-id="{channel_id}"{epg_id_attr}{chno} tvg-logo="{channel_logo}" group-title="{provider_label}",{channel_name}\n' - m3u_content += "#KODIPROP:inputstream=inputstream.adaptive\n" + # Header only (EXTINF + KODIPROP) — this variant is + # intentionally live-only, so include_catchup=False + # preserves its existing behavior of never emitting + # catchup attributes, even for channels that support it. + m3u_content += self._build_m3u_entry_header( + provider_name, channel, + provider_label=provider_label, + drm_directives="#KODIPROP:inputstream=inputstream.adaptive\n", + include_catchup=False, + ) m3u_content += f"{ffmpeg_cmd}\n" channels_included += 1 @@ -1122,77 +1086,56 @@ class UltimateService: self.manager.get_channels(provider_name=provider_name, fetch_manifests=False) ) - # Get provider instance for label and proxy config + # Get provider instance for label try: provider_instance = self.manager.get_provider(provider_name) provider_label = provider_instance.provider_label except (AttributeError, KeyError, ValueError): provider_label = provider_name - # Get provider proxy URL if configured - http_manager = self.manager.get_provider_http_manager(provider_name) - provider_proxy_url = None - if http_manager and http_manager.config.proxy_config: - proxy_cfg = http_manager.config.proxy_config - provider_proxy_url = ( - f"{proxy_cfg.proxy_type.value.lower()}://{proxy_cfg.host}:{proxy_cfg.port}" - ) - # Process each channel for channel in channels: channel_id = channel.channel_id - channel_name = channel.name - channel_logo = channel.logo_url or "" - chno = self._chno_attr(channel) # Try to get DRM configs try: drm_configs = self.manager.get_channel_drm_configs( provider_name=provider_name, channel_id=channel_id ) - - # Convert list of DRM configs to dict - drm_dict = {} - if isinstance(drm_configs, list): - for config in drm_configs: - if hasattr(config, "to_dict"): - config_dict = config.to_dict() - drm_dict.update(config_dict) - elif isinstance(config, dict): - drm_dict.update(config) - elif isinstance(drm_configs, dict): - drm_dict = drm_configs + drm_dict = self._drm_configs_to_dict(drm_configs) # Check if channel has ClearKey DRM or is unencrypted - has_clearkey = False - is_unencrypted = False - clearkey_data = None + has_clearkey = "org.w3.clearkey" in drm_dict and drm_dict["org.w3.clearkey"] + is_unencrypted = "none" in drm_dict - if "org.w3.clearkey" in drm_dict: - clearkey_data = drm_dict["org.w3.clearkey"] - has_clearkey = True - elif "none" in drm_dict: - is_unencrypted = True - - if has_clearkey and clearkey_data: - # Channel has ClearKey - generate decrypted entry - entry_content = self._generate_m3u_proxied_channel_entry( - base_url=base_url, - provider_name=provider_name, + if has_clearkey: + # Channel has ClearKey - generate proxied entry. + # include_catchup=True matches this branch's + # pre-consolidation behavior (the old + # _generate_m3u_proxied_channel_entry always + # included catchup tags when available). + m3u_content += self._generate_m3u_entry( + base_url, provider_name, channel, + stream_path="stream/proxied/index.mpd", provider_label=provider_label, - channel=channel, - clearkey_data=clearkey_data, - provider_proxy_url=provider_proxy_url, + drm_directives="#KODIPROP:inputstream=inputstream.adaptive\n", + include_catchup=True, ) - m3u_content += entry_content channels_included += 1 elif is_unencrypted: - # Explicitly unencrypted channel - include with direct stream URL - epg_id = self._get_epg_id(channel_id) - epg_id_attr = f' tvg-epgid="{epg_id}"' if epg_id else "" - stream_url = f"{base_url}/api/providers/{provider_name}/channels/{channel_id}/stream/index.mpd" - m3u_content += f'#EXTINF:-1 tvg-id="{channel_id}"{epg_id_attr}{chno} tvg-logo="{channel_logo}" group-title="{provider_label}",{channel_name}\n' - m3u_content += f"{stream_url}\n" + # Explicitly unencrypted channel - include with direct + # stream URL. include_catchup=True — previously this + # branch never included catchup tags, unlike the + # ClearKey branch above; fixed so both branches behave + # consistently (deliberate change, not preserved + # pre-consolidation behavior). + m3u_content += self._generate_m3u_entry( + base_url, provider_name, channel, + stream_path="stream/index.mpd", + provider_label=provider_label, + drm_directives="", + include_catchup=True, + ) channels_included += 1 else: # Channel has other DRM, is inaccessible, or unknown - skip @@ -1277,6 +1220,8 @@ class UltimateService: else: cache_filename = f"{cache_filename}_noproxy" + stream_path = "stream/noproxy/index.mpd" if no_proxy else "stream/index.mpd" + for provider_name in providers_to_process: try: # Get channels for this provider @@ -1284,10 +1229,20 @@ class UltimateService: self.manager.get_channels(provider_name=provider_name, fetch_manifests=False) ) + # Resolve provider_label once per provider instead of once per + # channel (previously done inside _generate_m3u_channel_entry + # on every call — same result, redundant lookups). + try: + provider_label = self.manager.get_provider(provider_name).provider_label + except (AttributeError, KeyError, ValueError): + provider_label = provider_name + # Add each channel to M3U for channel in channels: - m3u_content += self._generate_m3u_channel_entry( - base_url, provider_name, channel, no_proxy=no_proxy + m3u_content += self._generate_m3u_entry( + base_url, provider_name, channel, + stream_path=stream_path, + provider_label=provider_label, ) except Exception as provider_err: @@ -1357,207 +1312,26 @@ class UltimateService: chno = getattr(channel, "channel_number", None) return f' tvg-chno="{chno}" ch-number="{chno}"' if chno is not None else "" - def _generate_m3u_decrypted_content( - self, providers=None, save_to_cache=True, cache_filename=None - ): + @staticmethod + def _drm_configs_to_dict(drm_configs) -> dict: """ - Internal method to generate decrypted M3U content for specified providers. - Only includes channels with ClearKey DRM or unencrypted channels. + Normalize DRM configs (list of DRMConfig/dict objects, or an + already-merged dict) into a single dict keyed by DRM scheme. - 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 + Shared by _generate_drm_directives and _generate_m3u_proxied_filtered_content, + which each used to do this same list-to-dict merge independently. """ - # Check if media proxy is configured - if not self.media_proxy_url: - logger.error("Cannot generate decrypted M3U: MEDIA_PROXY_URL not set") - return None + if isinstance(drm_configs, dict): + return drm_configs - # Get base URL for absolute stream URLs - base_url = f"{request.urlparts.scheme}://{request.urlparts.netloc}" - - # Start M3U content - m3u_content = "#EXTM3U\n" - - # Determine which providers to process - if providers is None: - # All providers - providers_to_process = self.manager.list_providers() - cache_filename = cache_filename or "playlist_decrypted.m3u" - else: - # Specific provider(s) - providers_to_process = ( - [providers] if isinstance(providers, str) else providers - ) - cache_filename = ( - cache_filename or f"{providers_to_process[0]}_decrypted.m3u" - ) - - channels_included = 0 - channels_skipped = 0 - - for provider_name in providers_to_process: - try: - # Get channels for this provider - channels = self._sort_channels( - self.manager.get_channels(provider_name=provider_name, fetch_manifests=False) - ) - - # Get provider instance for label and proxy config - try: - provider_instance = self.manager.get_provider(provider_name) - provider_label = provider_instance.provider_label - except (AttributeError, KeyError, ValueError): - provider_label = provider_name - - # Get provider proxy URL if configured - http_manager = self.manager.get_provider_http_manager(provider_name) - provider_proxy_url = None - if http_manager and http_manager.config.proxy_config: - proxy_cfg = http_manager.config.proxy_config - provider_proxy_url = ( - f"{proxy_cfg.proxy_type.value.lower()}://{proxy_cfg.host}:{proxy_cfg.port}" - ) - - # Process each channel - for channel in channels: - channel_id = channel.channel_id - channel_name = channel.name - channel_logo = channel.logo_url or "" - - # Try to get DRM configs - try: - drm_configs = self.manager.get_channel_drm_configs( - provider_name=provider_name, channel_id=channel_id - ) - - # Convert list of DRM configs to dict - drm_dict = {} - if isinstance(drm_configs, list): - for config in drm_configs: - if hasattr(config, "to_dict"): - config_dict = config.to_dict() - drm_dict.update(config_dict) - elif isinstance(config, dict): - drm_dict.update(config) - elif isinstance(drm_configs, dict): - drm_dict = drm_configs - - # Check if channel has ClearKey DRM or is unencrypted - has_clearkey = False - is_unencrypted = False - clearkey_data = None - - if "org.w3.clearkey" in drm_dict: - clearkey_data = drm_dict["org.w3.clearkey"] - has_clearkey = True - elif "none" in drm_dict: - is_unencrypted = True - - if has_clearkey and clearkey_data: - # Channel has ClearKey - generate decrypted entry - entry_content = self._generate_m3u_proxied_channel_entry( - base_url=base_url, - provider_name=provider_name, - provider_label=provider_label, - channel=channel, - clearkey_data=clearkey_data, - provider_proxy_url=provider_proxy_url, - ) - m3u_content += entry_content - channels_included += 1 - elif is_unencrypted: - # Explicitly unencrypted channel - include with direct stream URL - stream_url = f"{base_url}/api/providers/{provider_name}/channels/{channel_id}/stream/index.mpd" - chno = f' tvg-chno="{channel.channel_number}" ch-number="{channel.channel_number}"' if getattr(channel, "channel_number", None) is not None else "" - m3u_content += f'#EXTINF:-1 tvg-id="{channel_id}"{chno} tvg-logo="{channel_logo}" group-title="{provider_label}",{channel_name}\n' - m3u_content += f"{stream_url}\n" - channels_included += 1 - else: - # Channel has other DRM, is inaccessible, or unknown - skip - channels_skipped += 1 - logger.debug( - f"Skipping {provider_name}/{channel_id} - unsupported DRM or no access" - ) - - except Exception as drm_err: - # Could not get DRM info - skip channel - logger.warning( - f"Could not get DRM for {provider_name}/{channel_id}: {drm_err}" - ) - channels_skipped += 1 - continue - - except Exception as provider_err: - logger.warning( - f"Failed to process provider '{provider_name}': {str(provider_err)}" - ) - continue - - logger.info( - f"Decrypted M3U: included {channels_included} channels, skipped {channels_skipped}" - ) - - # Save to cache if requested - if save_to_cache and cache_filename: - if self.vfs.write_text(cache_filename, m3u_content): - logger.info(f"Decrypted M3U playlist cached to {cache_filename}") - else: - logger.warning( - f"Failed to cache decrypted M3U playlist to {cache_filename}" - ) - - return m3u_content - - def _generate_m3u_decrypted_all(self, save_to_cache: bool = False) -> str: - """Internal method to generate decrypted M3U for all providers.""" - logger.info("Generating decrypted M3U playlist for all providers") - m3u_content = self._generate_m3u_decrypted_content( - providers=None, save_to_cache=save_to_cache - ) - - if m3u_content is None: - response.status = 503 - response.content_type = "application/json" - return json.dumps( - {"error": "Media proxy not configured (MEDIA_PROXY_URL not set)"} - ) - - # Set appropriate headers for M3U - response.content_type = "audio/x-mpegurl; charset=utf-8" - response.headers["Content-Disposition"] = ( - 'attachment; filename="playlist_proxied.m3u8"' - ) - - return m3u_content - - def _generate_m3u_decrypted_provider( - self, provider: str, save_to_cache: bool = False - ) -> str: - """Internal method to generate decrypted M3U for a specific provider.""" - logger.info(f"Generating decrypted M3U playlist for provider '{provider}'") - m3u_content = self._generate_m3u_decrypted_content( - providers=provider, save_to_cache=save_to_cache - ) - - if m3u_content is None: - response.status = 503 - response.content_type = "application/json" - return json.dumps( - {"error": "Media proxy not configured (MEDIA_PROXY_URL not set)"} - ) - - # Set appropriate headers for M3U - response.content_type = "audio/x-mpegurl; charset=utf-8" - response.headers["Content-Disposition"] = ( - f'attachment; filename="{provider}_proxied_playlist.m3u8"' - ) - - return m3u_content + merged = {} + if isinstance(drm_configs, list): + for config in drm_configs: + if hasattr(config, "to_dict"): + merged.update(config.to_dict()) + elif isinstance(config, dict): + merged.update(config) + return merged def _generate_drm_directives(self, drm_configs): """ @@ -1571,20 +1345,9 @@ class UltimateService: """ directives = "" - # drm_configs is now a dictionary, not a list - if not isinstance(drm_configs, dict): - # For backward compatibility, handle list format - if isinstance(drm_configs, list): - # Convert list to dict - temp_dict = {} - for config in drm_configs: - if hasattr(config, "to_dict"): - temp_dict.update(config.to_dict()) - elif isinstance(config, dict): - temp_dict.update(config) - drm_configs = temp_dict - else: - return directives + drm_configs = self._drm_configs_to_dict(drm_configs) + if not drm_configs: + return directives # Rest of the method remains the same... # Prioritize: clearkey > widevine > playready