diff --git a/routes/streams/__init__.py b/routes/streams/__init__.py index c4fe177..b508085 100644 --- a/routes/streams/__init__.py +++ b/routes/streams/__init__.py @@ -19,12 +19,11 @@ helpers: Returns the fetched DRMConfig list (or [] on failure) so callers that also need the configs don't have to fetch them a second time. - _resolve_stream(content_type, provider, content_id, ...) + _resolve_stream_unified(content_type, provider, content_id, ...) The single place that understands how to turn (type, provider, id) into a manifest response — redirect, proxied rewrite, or decrypted rewrite. - - _resolve_decrypted_stream(content_type, provider, content_id, ...) - Returns a manifest with server-side decryption (ClearKey keys injected). + receiver_side selects who decrypts (client vs. server); every content + type's route handler calls this directly with an explicit value. """ import base64 @@ -33,7 +32,7 @@ import re import time from urllib.parse import urljoin -from bottle import HTTPResponse, redirect, request, response +from bottle import redirect, request, response from streaming_providers.base.utils import logger from streaming_providers.base.utils.manifest_utils import ManifestUtils @@ -252,8 +251,8 @@ def make_helpers(manager, service): Returns: The list of DRMConfig objects that were fetched (or [] on failure). - Callers that also need the raw configs (e.g. _resolve_stream) can - reuse this instead of fetching them again. + Callers that also need the raw configs (e.g. _resolve_stream_unified) + can reuse this instead of fetching them again. """ try: if is_catchup and content_type == CONTENT_TYPE_CHANNEL: @@ -418,10 +417,11 @@ def make_helpers(manager, service): doesn't support catchup or the window has been exceeded; None if valid. Extracted from channels.py's _handle_channel_stream so - _resolve_decrypted_stream can apply the same check — previously it - skipped this validation entirely, letting catchup requests outside the - provider's DVR window through to the decrypted-stream path when the - standard path would reject them with a 400. + _resolve_stream_unified can apply the same check for every + receiver_side value — previously the decrypted-stream path skipped + this validation entirely, letting catchup requests outside the + provider's DVR window through when the standard path would reject + them with a 400. """ channels = manager.get_channels(provider_name=provider, fetch_manifests=False) channel_obj = next((c for c in channels if c.channel_id == content_id), None) @@ -534,8 +534,10 @@ def make_helpers(manager, service): """ Single resolver for every stream endpoint — replaces the former _resolve_stream / _resolve_decrypted_stream split (and the later - mode="auto"/"playable" split). Both wrapper names still exist below - for existing call sites. + mode="auto"/"playable" split before that). Every content type's + route handler (channels.py, events.py, vod.py, recordings.py) calls + this directly with an explicit receiver_side; the two wrapper + functions this replaced have been removed. Routing is entirely data-driven: whenever content has ClearKey DRM and a media proxy is configured, the proxy is used to inject correct @@ -857,87 +859,6 @@ def make_helpers(manager, service): ) } - def _resolve_stream( - content_type: str, - provider: str, - content_id: str, - country=None, - is_catchup: bool = False, - start_time: int = None, - end_time: int = None, - epg_id: str = None, - drm_variant: str = "auto", - no_proxy: bool = False, - ): - """ - Deprecated: thin wrapper around _resolve_stream_unified, kept so - existing call sites (channels.py, events.py, vod.py, recordings.py) - don't need to change. New code should call _resolve_stream_unified - directly with an explicit receiver_side. - """ - return _resolve_stream_unified( - content_type, provider, content_id, - country=country, - is_catchup=is_catchup, - start_time=start_time, - end_time=end_time, - epg_id=epg_id, - drm_variant=drm_variant, - receiver_side=True, - no_proxy=no_proxy, - ) - - def _resolve_decrypted_stream( - content_type: str, - provider: str, - content_id: str, - highest_quality_only: bool = False, - ): - """ - Deprecated: thin wrapper around _resolve_stream_unified, kept so - existing call sites (channels.py's /stream/proxied/ routes) don't need - to change. Reads start_time/end_time/epg_id/country from the request - query string itself, matching the original function's contract — those - routes never passed catchup args explicitly. New code should call - _resolve_stream_unified directly with receiver_side=False. - """ - try: - country = request.query.get("country") - start_time = request.query.get("start_time") - end_time = request.query.get("end_time") - epg_id = request.query.get("epg_id") - is_catchup = bool(start_time and end_time and content_type == CONTENT_TYPE_CHANNEL) - - if is_catchup: - try: - start_time = int(start_time) - end_time = int(end_time) - except (ValueError, TypeError): - response.status = 400 - return {"error": "Invalid start_time or end_time format"} - - return _resolve_stream_unified( - content_type, provider, content_id, - country=country, - is_catchup=is_catchup, - start_time=start_time, - end_time=end_time, - epg_id=epg_id, - receiver_side=False, - highest_quality_only=highest_quality_only, - ) - - except HTTPResponse: - raise - except ValueError as e: - logger.error(f"API Error in decrypted {content_type} stream: {e}") - response.status = 404 - return {"error": str(e)} - except Exception as e: - logger.error(f"API Error in decrypted {content_type} stream: {e}") - response.status = 500 - return {"error": f"Internal server error: {str(e)}"} - # Return all helpers as a dict for submodules to use return { "CONTENT_TYPE_CHANNEL": CONTENT_TYPE_CHANNEL, @@ -951,8 +872,6 @@ def make_helpers(manager, service): "_stream_needs_headers": _stream_needs_headers, "_inject_base_url": _inject_base_url, "_validate_catchup_window": _validate_catchup_window, - "_resolve_stream": _resolve_stream, - "_resolve_decrypted_stream": _resolve_decrypted_stream, "_resolve_stream_unified": _resolve_stream_unified, "_serialize_drm_configs": _serialize_drm_configs, } \ No newline at end of file diff --git a/service.py b/service.py index ef8493c..78dd3e8 100644 --- a/service.py +++ b/service.py @@ -836,6 +836,42 @@ class UltimateService: stream_url = f"{base_url}/api/providers/{provider_name}/channels/{channel_id}/{stream_path}" return header + f"{stream_url}\n" + def _iter_m3u_provider_channels(self, providers_to_process): + """ + Yield (provider_name, provider_label, channels) for each provider in + providers_to_process — sorted channels, with provider_label resolved + (falling back to provider_name on lookup failure). + + Extracted from the four _generate_m3u_* generators, which duplicated + this exact prefix. Structurally identical to what each of them did + inline: channels-fetch and provider_label lookup share one + try/except Exception (log-and-skip-this-provider), with the label + lookup nested inside on its own narrower + (AttributeError, KeyError, ValueError) fallback — same nesting as + before, just here once instead of four times. Each caller still + wraps its own per-channel body in its own try/except Exception, + log-and-continue-to-next-provider, exactly as it did before this + was split out — this generator doesn't change where or how + failures are caught, only removes the duplicated lookup code ahead + of that point. + """ + for provider_name in providers_to_process: + try: + channels = self._sort_channels( + self.manager.get_channels(provider_name=provider_name, fetch_manifests=False) + ) + try: + provider_instance = self.manager.get_provider(provider_name) + provider_label = provider_instance.provider_label + except (AttributeError, KeyError, ValueError): + provider_label = provider_name + except Exception as provider_err: + logger.warning( + f"Failed to process provider '{provider_name}': {str(provider_err)}" + ) + continue + yield provider_name, provider_label, channels + def _generate_m3u_proxied_fast(self, providers=None): """ Fast generation of decrypted M3U content for specified providers. @@ -873,20 +909,8 @@ class UltimateService: channels_included = 0 - for provider_name in providers_to_process: + for provider_name, provider_label, channels in self._iter_m3u_provider_channels(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 label - try: - provider_instance = self.manager.get_provider(provider_name) - provider_label = provider_instance.provider_label - except (AttributeError, KeyError, ValueError): - provider_label = provider_name - # Process each channel - no DRM checks (all channels are # already routed through the media proxy at playback time) for channel in channels: @@ -963,20 +987,8 @@ class UltimateService: channels_included = 0 - for provider_name in providers_to_process: + for provider_name, provider_label, channels in self._iter_m3u_provider_channels(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 label - try: - provider_instance = self.manager.get_provider(provider_name) - provider_label = provider_instance.provider_label - except (AttributeError, KeyError, ValueError): - provider_label = provider_name - # Process each channel - no DRM checks for channel in channels: channel_id = channel.channel_id @@ -1075,20 +1087,8 @@ class UltimateService: channels_included = 0 channels_skipped = 0 - for provider_name in providers_to_process: + for provider_name, provider_label, channels in self._iter_m3u_provider_channels(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 - try: - provider_instance = self.manager.get_provider(provider_name) - provider_label = provider_instance.provider_label - except (AttributeError, KeyError, ValueError): - provider_label = provider_name - # Process each channel for channel in channels: channel_id = channel.channel_id @@ -1228,21 +1228,8 @@ class UltimateService: # every entry here would silently mismatch its own KODIPROP directives. stream_path = "stream/index.mpd?client_drm=true&no_proxy=true" if no_proxy else "stream/index.mpd?client_drm=true" - for provider_name in providers_to_process: + for provider_name, provider_label, channels in self._iter_m3u_provider_channels(providers_to_process): try: - # Get channels for this provider - channels = self._sort_channels( - 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_entry( @@ -1253,7 +1240,7 @@ class UltimateService: except Exception as provider_err: logger.warning( - f"Failed to get channels for provider '{provider_name}': {str(provider_err)}" + f"Failed to process provider '{provider_name}': {str(provider_err)}" ) continue