From b892ec8e71840e8886acf191c649624bc362bf9b Mon Sep 17 00:00:00 2001 From: Nirvana Date: Mon, 15 Jun 2026 11:45:30 +0200 Subject: [PATCH] magenta2 search --- .../providers/magenta2/vod_manager.py | 43 +- routes/streams.py | 1401 ----------------- routes/streams/__init__.py | 744 +++++++++ routes/streams/channels.py | 261 +++ routes/streams/epg.py | 95 ++ routes/streams/events.py | 163 ++ routes/streams/recordings.py | 140 ++ routes/streams/vod.py | 123 ++ 8 files changed, 1536 insertions(+), 1434 deletions(-) delete mode 100644 routes/streams.py create mode 100644 routes/streams/__init__.py create mode 100644 routes/streams/channels.py create mode 100644 routes/streams/epg.py create mode 100644 routes/streams/events.py create mode 100644 routes/streams/recordings.py create mode 100644 routes/streams/vod.py diff --git a/lib/streaming_providers/providers/magenta2/vod_manager.py b/lib/streaming_providers/providers/magenta2/vod_manager.py index 4b14e27..290e05a 100644 --- a/lib/streaming_providers/providers/magenta2/vod_manager.py +++ b/lib/streaming_providers/providers/magenta2/vod_manager.py @@ -1865,47 +1865,24 @@ class VodManager: return {"entries": entries, "next_cursor": None, "total": len(entries)} def _get_search_url_template(self) -> Optional[str]: - """ - Resolve the search URL template from available sources. - - Priority order: - 1. endpoint_manager.get_endpoint("search_template") - if available - 2. provider_config.get_search_url_template() - direct manifest access - 3. None (fallback logging only - no hardcoded fallback) - - Returns: - Raw template string with {clientModel} and {query} placeholders, - or None if not found. - """ # Priority 1: Try endpoint manager (discovered via manifest) - if hasattr(self._provider, 'endpoint_manager'): - template = self._provider.endpoint_manager.get_endpoint("search_template") - if template: - logger.debug(f"{self._provider}: Found search template via endpoint_manager") - return template + # This won't work because VodManager doesn't have endpoint_manager - # Priority 2: Try provider config directly - if hasattr(self._provider, 'provider_config') and self._provider.provider_config: - template = self._provider.provider_config.get_search_url_template() + # Priority 2: Try provider config directly (THIS IS THE CORRECT PATH) + if self._provider_config: + template = self._provider_config.get_search_url_template() if template: logger.debug(f"{self._provider}: Found search template via provider_config") return template # Priority 3: Try manifest.tv_hubs.base_urls directly (fallback) - if hasattr(self._provider, 'provider_config'): - manifest = getattr(self._provider.provider_config, "manifest", None) - if manifest: - tv_hubs = getattr(manifest, "tv_hubs", None) - if tv_hubs: - template = tv_hubs.base_urls.get("searchUrl") - if template: - logger.debug(f"{self._provider}: Found search template via manifest.tv_hubs") - return template + if self._provider_config and hasattr(self._provider_config, 'manifest') and self._provider_config.manifest: + template = self._provider_config.manifest.tv_hubs.base_urls.get("searchUrl") + if template: + logger.debug(f"{self._provider}: Found search template via manifest.tv_hubs") + return template - logger.warning( - f"{self._provider}: Search URL template not found in any source. " - "Search functionality will not work." - ) + logger.warning(f"{self._provider}: Search URL template not found in any source.") return None def _parse_search_groups(self, data: Dict) -> List[Union[VodCategory, VodItem]]: diff --git a/routes/streams.py b/routes/streams.py deleted file mode 100644 index e270e3d..0000000 --- a/routes/streams.py +++ /dev/null @@ -1,1401 +0,0 @@ -#!/usr/bin/env python3 -""" -Stream and manifest route handlers. - -Architecture -============ -All content types (channels, events, future VOD) share identical transport-level -operations: resolve a manifest URL, fetch DRM configs, optionally rewrite the -manifest through a media proxy. The typed route handlers (channel vs event vs -vod) are therefore thin wrappers around two shared helpers: - - _build_drm_header(content_type, provider, content_id, ...) - Fetches DRM configs via the correct manager method and attaches them as a - base64-encoded response header. Non-fatal — logs a warning on failure. - - _resolve_stream(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. - -Adding VOD in the future means: - 1. Implement manager.get_vod_manifest() / get_vod_drm_configs() (same pattern). - 2. Register the three route URLs for /vod//{stream,manifest,drm}. - 3. No changes to the shared helpers. -""" - -import base64 -import json -import re -from urllib.parse import urljoin -from datetime import datetime - -from bottle import HTTPResponse, redirect, request, response -from streaming_providers.base.utils import logger -from streaming_providers.base.utils.manifest_utils import ManifestUtils - -# --------------------------------------------------------------------------- -# Content-type constants — single source of truth used by helpers and routes -# --------------------------------------------------------------------------- -CONTENT_TYPE_CHANNEL = "channel" -CONTENT_TYPE_EVENT = "event" -CONTENT_TYPE_VOD = "vod" -CONTENT_TYPE_RECORDING = "recording" - - -def setup_stream_routes(app, manager, service): - """Setup stream and manifest-related routes.""" - - # ========================================================================= - # INTERNAL HELPERS - # ========================================================================= - - def _get_drm_configs( - content_type: str, - provider: str, - content_id: str, - drm_variant: str = "auto", - **kwargs, - ): - """ - Dispatch DRM config retrieval to the correct manager method based on - content type. Returns a list of DRMConfig objects (may be empty). - Raises ValueError for unknown provider; re-raises other exceptions. - - Args: - drm_variant: 'auto' (provider default) or 'software' (prefer ClearKey). - Passed through to the provider via **kwargs so individual - providers can select an appropriate DRM scheme. - """ - kwargs.setdefault("drm_variant", drm_variant) - if content_type == CONTENT_TYPE_CHANNEL: - return manager.get_channel_drm_configs( - provider_name=provider, channel_id=content_id, **kwargs - ) - elif content_type == CONTENT_TYPE_EVENT: - return manager.get_event_drm_configs( - provider_name=provider, event_id=content_id, **kwargs - ) - elif content_type == CONTENT_TYPE_VOD: - return manager.get_vod_drm_configs( - provider_name=provider, vod_id=content_id, **kwargs - ) - elif content_type == CONTENT_TYPE_RECORDING: - return manager.get_recording_drm_configs( - provider_name=provider, recording_id=content_id, **kwargs - ) - else: - raise ValueError(f"Unknown content_type '{content_type}'") - - def _get_manifest_url( - content_type: str, - provider: str, - content_id: str, - drm_variant: str = "auto", - **kwargs, - ) -> str: - """ - Dispatch manifest URL retrieval to the correct manager method. - Returns the raw upstream manifest URL (before any proxy rewriting). - - Args: - drm_variant: 'auto' or 'software'. Passed through so providers that - expose separate ClearKey manifest URLs can return the - correct one. - """ - kwargs.setdefault("drm_variant", drm_variant) - if content_type == CONTENT_TYPE_CHANNEL: - return manager.get_channel_manifest( - provider_name=provider, channel_id=content_id, **kwargs - ) - elif content_type == CONTENT_TYPE_EVENT: - return manager.get_event_manifest( - provider_name=provider, event_id=content_id, **kwargs - ) - elif content_type == CONTENT_TYPE_VOD: - return manager.get_vod_manifest( - provider_name=provider, vod_id=content_id, **kwargs - ) - elif content_type == CONTENT_TYPE_RECORDING: - return manager.get_recording_manifest( - provider_name=provider, recording_id=content_id, **kwargs - ) - else: - raise ValueError(f"Unknown content_type '{content_type}'") - - def _build_drm_header( - content_type: str, - provider: str, - content_id: str, - country=None, - # catchup-specific — only used for channels - is_catchup: bool = False, - start_time: int = None, - end_time: int = None, - epg_id: str = None, - drm_variant: str = "auto", - ): - """ - Fetch DRM configs, serialise to JSON and attach as a base64-encoded - response header (x-kodi-drm-configs). Non-fatal: logs a warning and - returns None on any error. - - Args: - drm_variant: 'auto' or 'software'. Forwarded to the DRM config - fetch so the provider can return ClearKey configs when - the software-DRM variant is requested. - """ - try: - if is_catchup and content_type == CONTENT_TYPE_CHANNEL: - drm_configs = manager.get_catchup_drm_configs( - provider_name=provider, - channel_id=content_id, - start_time=start_time, - end_time=end_time, - epg_id=epg_id, - country=country, - drm_variant=drm_variant, - ) - else: - drm_configs = _get_drm_configs( - content_type, provider, content_id, - country=country, drm_variant=drm_variant, - ) - - merged = {} - for config in drm_configs: - config_dict = config.to_dict() if hasattr(config, "to_dict") else config - merged.update(config_dict) - - encoded = base64.b64encode( - json.dumps(merged).encode("utf-8") - ).decode("ascii") - - response.headers["x-kodi-drm-configs"] = encoded - return encoded - - except Exception as e: - logger.warning( - f"Could not build x-kodi-drm-configs header for " - f"{content_type} {provider}/{content_id} (variant={drm_variant}): {e}" - ) - return None - - def _build_stream_headers( - content_type: str, - provider: str, - content_id: str, - country=None, - # catchup-specific — only used for channels - is_catchup: bool = False, - start_time: int = None, - end_time: int = None, - epg_id: str = None, - ): - """ - Fetch manifest and segment headers from the provider, serialise to JSON - and attach as a base64-encoded response header (x-kodi-stream-headers). - Non-fatal: logs a warning and returns None on any error. - - Payload structure: - { - "manifest": { : , ... }, - "segment": { : , ... } - } - Both keys are always present (empty dict if the provider returns nothing). - """ - try: - provider_instance = manager.get_provider(provider) - if not provider_instance: - return None - - kwargs = {} - if country: - kwargs["country"] = country - if is_catchup and content_type == CONTENT_TYPE_CHANNEL: - kwargs.update( - start_time=start_time, - end_time=end_time, - epg_id=epg_id, - ) - - manifest_headers = provider_instance.get_manifest_headers(content_id, **kwargs) - segment_headers = provider_instance.get_segment_headers(content_id, **kwargs) - - payload = { - "manifest": manifest_headers or {}, - "segment": segment_headers or {}, - } - - encoded = base64.b64encode( - json.dumps(payload).encode("utf-8") - ).decode("ascii") - - response.headers["x-kodi-stream-headers"] = encoded - return encoded - - except Exception as e: - logger.warning( - f"Could not build x-kodi-stream-headers header for " - f"{content_type} {provider}/{content_id}: {e}" - ) - return None - - def _stream_needs_headers( - content_type: str, - provider: str, - content_id: str, - country=None, - # catchup-specific — forwarded so providers can return catchup-appropriate headers - is_catchup: bool = False, - start_time: int = None, - end_time: int = None, - epg_id: str = None, - ) -> bool: - """ - Returns True if the provider requires manifest or segment headers for - this content — meaning a plain redirect would lose those headers and - playback would likely fail. - - Catchup context (is_catchup, start_time, end_time, epg_id) is forwarded - to the provider so it can return headers appropriate for the DVR/catchup - endpoint rather than the live endpoint. Without this, providers that use - different auth tokens for catchup would return live headers here and the - proxy decision could be wrong. - """ - try: - provider_instance = manager.get_provider(provider) - if not provider_instance: - return False - kwargs = {} - if country: - kwargs["country"] = country - if is_catchup and content_type == CONTENT_TYPE_CHANNEL: - kwargs.update( - start_time=start_time, - end_time=end_time, - epg_id=epg_id, - ) - manifest_headers = provider_instance.get_manifest_headers(content_id, **kwargs) or {} - segment_headers = provider_instance.get_segment_headers(content_id, **kwargs) or {} - return bool(manifest_headers or segment_headers) - except Exception as e: - logger.warning( - f"Could not check stream headers for {content_type} {provider}/{content_id}: {e}" - ) - return False # assume no headers needed; let the redirect attempt proceed - - def _inject_base_url(manifest_text: str, manifest_url: str) -> str: - existing = ManifestUtils.extract_base_urls(manifest_text) - if existing: - return manifest_text - - # Strip the filename, keep the directory — e.g. - # https://cdn.example.com/live/stream/index.mpd - # → https://cdn.example.com/live/stream/ - base_url = urljoin(manifest_url, ".") - - base_url_element = f"{base_url}" - return re.sub( - r"(]*>)", - rf"\1\n {base_url_element}", - manifest_text, - count=1, - ) - - def _resolve_stream( - content_type: str, - provider: str, - content_id: str, - country=None, - # catchup-specific — only used for channels - is_catchup: bool = False, - start_time: int = None, - end_time: int = None, - epg_id: str = None, - drm_variant: str = "auto", - ): - """ - Core stream resolution: attach DRM header, then either redirect to the - upstream manifest or return a proxy-rewritten manifest body. - - Args: - drm_variant: 'auto' (provider decides) or 'software' (prefer ClearKey / - software-decodable DRM). Threaded through to all helpers so - providers can return the correct manifest URL and DRM configs. - """ - # --- DRM header (best-effort, never fatal) --- - _build_drm_header( - 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, - ) - # --- Stream headers (best-effort, never fatal) --- - _build_stream_headers( - content_type, provider, content_id, - country=country, - is_catchup=is_catchup, - start_time=start_time, - end_time=end_time, - epg_id=epg_id, - ) - - # --- Catchup path (channel-specific) --- - if is_catchup: - if manager.needs_proxy(provider): - return service.get_proxied_catchup_manifest( - provider, content_id, start_time, end_time, epg_id, country - ) - else: - manifest_url = manager.get_catchup_manifest( - provider_name=provider, - channel_id=content_id, - start_time=start_time, - end_time=end_time, - epg_id=epg_id, - country=country, - drm_variant=drm_variant, - ) - if not manifest_url: - response.status = 404 - return { - "error": f'Catchup manifest not available for channel "{content_id}"' - } - logger.debug(f"Redirecting to catchup manifest: {manifest_url}") - redirect(manifest_url) - - # --- Live / event / vod path --- - if manager.needs_proxy(provider): - # Check for ClearKey DRM — if present, rewrite manifest with ClearKey signaling - # so the receiver can decrypt itself, rather than serving a plain proxy stream - # with stripped ContentProtection that the player cannot handle. - try: - drm_configs = _get_drm_configs( - content_type, provider, content_id, - country=country, drm_variant=drm_variant, - ) - drm_dict = {} - for config in drm_configs: - drm_dict.update( - config.to_dict() if hasattr(config, "to_dict") else config - ) - except Exception as e: - logger.warning( - f"Could not fetch DRM configs for {content_type} " - f"{provider}/{content_id} during proxy resolution: {e}" - ) - drm_dict = {} - - keyids = ( - drm_dict.get("org.w3.clearkey", {}) - .get("license", {}) - .get("keyids", {}) - ) - - # When the caller explicitly requested software DRM and we found no - # ClearKey keys, surface a clear error rather than silently serving a - # Widevine stream the client cannot decrypt. - if drm_variant == "software" and not keyids: - logger.warning( - f"Software DRM requested but no ClearKey keys found for " - f"{provider}/{content_id}" - ) - response.status = 400 - return {"error": "Software DRM not available for this content"} - - if keyids: - logger.debug( - f"ClearKey DRM detected for {provider}/{content_id} " - f"(variant={drm_variant}) — using receiver-side ClearKey rewrite" - ) - return service.get_decrypted_manifest( - provider, content_id, keyids, - receiver_side=True, - drm_variant=drm_variant, - ) - else: - return service.get_proxied_manifest(provider, content_id) - else: - # Check if provider requires manifest context (needs HTTP manager to fetch) - provider_instance = manager.get_provider(provider) - - if provider_instance is None: - logger.warning( - f"_resolve_stream: manager.get_provider('{provider}') returned None — " - "cannot check requires_manifest_context; falling back to redirect" - ) - - if provider_instance and getattr(provider_instance, 'requires_manifest_context', False): - logger.debug( - f"Provider {provider} requires manifest context — fetching manifest directly " - f"for {content_type}/{content_id}" - ) - try: - manifest_url = _get_manifest_url( - content_type, provider, content_id, - country=country, drm_variant=drm_variant, - ) - if not manifest_url: - response.status = 404 - return { - "error": ( - f'Manifest not available for {content_type} ' - f'"{content_id}" from provider "{provider}"' - ) - } - - manifest_text, _, _, _, effective_url = service.fetch_manifest_for_rewriter( - provider, content_id, manifest_url - ) - - # Inject the upstream manifest URL as a BaseURL so the player can - # resolve relative segment URLs correctly. Without this, segments - # resolve against the local server URL and all requests fail. - manifest_text = _inject_base_url(manifest_text, effective_url) - - response.content_type = "application/dash+xml; charset=utf-8" - return manifest_text - - except Exception as e: - logger.error( - f"Failed to fetch manifest for {content_type}/{content_id} " - f"from {provider}: {e}" - ) - response.status = 502 - return {"error": f"Failed to fetch manifest: {str(e)}"} - else: - manifest_url = _get_manifest_url( - content_type, provider, content_id, - country=country, drm_variant=drm_variant, - ) - if not manifest_url: - response.status = 404 - return { - "error": ( - f'Manifest not available for {content_type} ' - f'"{content_id}" from provider "{provider}"' - ) - } - logger.debug(f"Redirecting to manifest: {manifest_url}") - return redirect(manifest_url) - - def _resolve_decrypted_stream( - content_type: str, - provider: str, - content_id: str, - highest_quality_only: bool = False, - ): - """ - Shared handler for decrypted stream endpoints. - Resolves DRM, then returns an appropriately rewritten manifest. - - For channel catchup (start_time + end_time query params present) the - handler fetches the catchup DRM configs and catchup manifest URL so that - server-side decryption operates on the correct DVR/time-shifted stream - rather than the live channel manifest. receiver_side is always False - here — the /decrypted/ endpoint contract is that the server decrypts. - """ - try: - country = request.query.get("country") - - # ------------------------------------------------------------------ - # Catchup branch — channels only. - # When start_time + end_time are present we must use the catchup - # DRM configs and catchup manifest URL. The live path below would - # silently fetch the live manifest and encrypt/decrypt against the - # wrong stream. - # ------------------------------------------------------------------ - 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 = int(start_time) - end_time_int = int(end_time) - except (ValueError, TypeError): - response.status = 400 - return {"error": "Invalid start_time or end_time format"} - - if not service.media_proxy_url: - response.status = 503 - return {"error": "Media proxy not configured (MEDIA_PROXY_URL not set)"} - - catchup_drm_configs = manager.get_catchup_drm_configs( - provider_name=provider, - channel_id=content_id, - start_time=start_time_int, - end_time=end_time_int, - epg_id=epg_id, - country=country, - ) - catchup_drm_dict = {} - for config in catchup_drm_configs: - catchup_drm_dict.update( - config.to_dict() if hasattr(config, "to_dict") else config - ) - - keyids = ( - catchup_drm_dict.get("org.w3.clearkey", {}) - .get("license", {}) - .get("keyids", {}) - ) - if not keyids: - response.status = 400 - return {"error": "ClearKey DRM not available for this catchup content"} - - return service.get_decrypted_catchup_manifest( - provider, content_id, - start_time=start_time_int, - end_time=end_time_int, - keyids=keyids, - epg_id=epg_id, - highest_quality_only=highest_quality_only, - ) - - # ------------------------------------------------------------------ - # Live / VOD / event / recording path - # ------------------------------------------------------------------ - drm_configs = _get_drm_configs( - content_type, provider, content_id, country=country - ) - - drm_dict = {} - for config in drm_configs: - drm_dict.update( - config.to_dict() if hasattr(config, "to_dict") else config - ) - - has_clearkey = "org.w3.clearkey" in drm_dict - is_unencrypted = "none" in drm_dict - - if has_clearkey: - if not service.media_proxy_url: - response.status = 503 - return {"error": "Media proxy not configured (MEDIA_PROXY_URL not set)"} - - keyids = ( - drm_dict["org.w3.clearkey"] - .get("license", {}) - .get("keyids", {}) - ) - if not keyids: - response.status = 400 - return {"error": "ClearKey DRM found but no key IDs available"} - - return service.get_decrypted_manifest( - provider, content_id, keyids, - highest_quality_only=highest_quality_only, - ) - - - elif is_unencrypted: - # Decrypted-stream endpoints do not support catchup — catchup requires a - # live DVR manifest URL which must be resolved via _resolve_stream / the - # catchup path. Unencrypted content here is always VOD or live-redirect. - needs_headers = _stream_needs_headers(content_type, provider, content_id, country) - needs_proxy = manager.needs_proxy(provider) - - if (needs_headers or needs_proxy) and service.media_proxy_url: - return service.get_proxied_manifest( - provider, content_id, - highest_quality_only=highest_quality_only, - ) - elif (needs_headers or needs_proxy) and not service.media_proxy_url: - logger.warning( - f"Provider {provider}/{content_id} needs proxy/headers but MEDIA_PROXY_URL is not set; " - "falling back to redirect (playback may fail)" - ) - manifest_url = _get_manifest_url(content_type, provider, content_id, country=country) - return redirect(manifest_url) - else: - manifest_url = _get_manifest_url(content_type, provider, content_id, country=country) - if not manifest_url: - response.status = 404 - return {"error": f'Manifest not available for {content_type} "{content_id}"'} - return redirect(manifest_url) - - else: - response.status = 400 - return { - "error": ( - f'{content_type.capitalize()} "{content_id}" does not support ' - f"decrypted playback (requires ClearKey or unencrypted)" - ) - } - - 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)}"} - - # ========================================================================= - # CHANNEL ROUTES (preserved exactly — backward-compatible) - # ========================================================================= - - @app.route("/api/providers//channels//manifest") - def get_channel_manifest(provider, channel_id): - """ - Returns JSON with a manifest_url pointing to the stream endpoint. - Attaches x-kodi-drm-configs header. - - Response includes both stream_url (auto DRM) and sw_drm_stream_url - (software / ClearKey DRM) so callers can pick the appropriate variant - without a second round-trip. - - Also includes catchup_stream_url_template — a URL with {start_time} and - {end_time} placeholders (Unix timestamps) that callers can expand for - DVR/catchup playback, avoiding the need to construct the URL manually. - """ - try: - country = request.query.get("country") - base_url = f"{request.urlparts.scheme}://{request.urlparts.netloc}" - qs = f"?country={country}" if country else "" - stream_url = ( - f"{base_url}/api/providers/{provider}/channels/{channel_id}" - f"/stream/index.mpd{qs}" - ) - sw_drm_stream_url = ( - f"{base_url}/api/providers/{provider}/channels/{channel_id}" - f"/stream/sw-drm/index.mpd{qs}" - ) - # Catchup template — callers substitute {start_time}/{end_time} with - # Unix timestamps. Matches the query params consumed by _handle_channel_stream. - catchup_qs_sep = "&" if qs else "?" - catchup_stream_url_template = ( - f"{base_url}/api/providers/{provider}/channels/{channel_id}" - f"/stream/index.mpd{qs}{catchup_qs_sep}" - f"start_time={{start_time}}&end_time={{end_time}}" - ) - - _build_drm_header(CONTENT_TYPE_CHANNEL, provider, channel_id, country=country) - _build_stream_headers(CONTENT_TYPE_CHANNEL, provider, channel_id, country=country) - - return { - "provider": provider, - "channel_id": channel_id, - "manifest_url": stream_url, - "sw_drm_manifest_url": sw_drm_stream_url, - "catchup_stream_url_template": catchup_stream_url_template, - } - - except ValueError as e: - logger.error(f"manifest endpoint error for {provider}/{channel_id}: {e}") - response.status = 404 - return {"error": str(e)} - except Exception as e: - logger.error(f"manifest endpoint error for {provider}/{channel_id}: {e}") - response.status = 500 - return {"error": f"Internal server error: {str(e)}"} - - @app.route("/api/providers////manifest/original") - def get_original_manifest(provider, content_type, content_id): - """ - Fetch and return the original manifest content without modifications. - The server fetches the manifest and returns it as-is. - - Supported content_type values: - - channels - - events - - vod - - recordings - - Example: - GET /api/providers/rtlplus/channels/123/manifest/original - GET /api/providers/discovery_de/vod/movie123/manifest/original - """ - try: - country = request.query.get("country") - - # Get the upstream manifest URL based on content_type - if content_type == "channels": - manifest_url = manager.get_channel_manifest( - provider_name=provider, - channel_id=content_id, - country=country - ) - elif content_type == "events": - manifest_url = manager.get_event_manifest( - provider_name=provider, - event_id=content_id, - country=country - ) - elif content_type == "vod": - manifest_url = manager.get_vod_manifest( - provider_name=provider, - vod_id=content_id, - country=country - ) - elif content_type == "recordings": - manifest_url = manager.get_recording_manifest( - provider_name=provider, - recording_id=content_id, - country=country - ) - else: - response.status = 400 - return {"error": f"Invalid content_type: {content_type}"} - - if not manifest_url: - response.status = 404 - return {"error": f'Manifest not available for {content_type} "{content_id}" from provider "{provider}"'} - - # Use existing helper to fetch manifest - # _fetch_manifest_for_rewriter expects (provider, channel_id, manifest_url) - # but we can pass content_id as channel_id since it's just an identifier - manifest_text, _, _, _, _ = service.fetch_manifest_for_rewriter( - provider, content_id, manifest_url - ) - - # Return unmodified manifest - response.content_type = "application/dash+xml; charset=utf-8" - return manifest_text - - except ValueError as e: - logger.error(f"Original manifest error for {provider}/{content_type}/{content_id}: {e}") - response.status = 404 - return {"error": str(e)} - except Exception as e: - logger.error(f"Original manifest error for {provider}/{content_type}/{content_id}: {e}") - response.status = 500 - return {"error": f"Failed to fetch manifest: {str(e)}"} - - def _handle_channel_stream(provider, channel_id, *, drm_variant="auto"): - """Shared implementation for /stream/index.mpd and /stream/sw-drm/index.mpd.""" - try: - start_time = request.query.get("start_time") - end_time = request.query.get("end_time") - epg_id = request.query.get("epg_id") - country = request.query.get("country") - is_catchup = bool(start_time and end_time) - - logger.debug( - f"_handle_channel_stream: provider={provider} channel={channel_id} " - f"start_time={start_time!r} end_time={end_time!r} " - f"epg_id={epg_id!r} country={country!r} is_catchup={is_catchup} " - f"drm_variant={drm_variant}" - ) - - # Always defined so the _resolve_stream call below is unconditionally safe, - # even though the ternary guards already prevent None from being passed when - # is_catchup is False. - start_time_int: int | None = None - end_time_int: int | None = None - - if is_catchup: - try: - start_time_int = int(start_time) - end_time_int = int(end_time) - logger.debug(f"CATCHUP: times parsed OK: {start_time_int} to {end_time_int}") - except (ValueError, TypeError): - logger.warning( - f"CATCHUP: could not parse start_time={start_time!r} / end_time={end_time!r} as int" - ) - response.status = 400 - return {"error": "Invalid start_time or end_time format"} - - channels = manager.get_channels(provider_name=provider, fetch_manifests=False) - channel_obj = next((c for c in channels if c.channel_id == channel_id), None) - logger.debug( - f"CATCHUP: channel lookup id={channel_id!r} -> " - + (f"found (catchup_hours attr={getattr(channel_obj, 'catchup_hours', 'MISSING')!r}, " - f"catchup_window attr={getattr(channel_obj, 'catchup_window', 'MISSING')!r})" - if channel_obj else "NOT FOUND in channel list") - ) - - # The model field is catchup_hours (serialises as CatchupHours). - # Fall back to catchup_window for providers using the older name. - catchup_hours = ( - getattr(channel_obj, "catchup_hours", None) - or getattr(channel_obj, "catchup_window", 0) - ) if channel_obj else 0 - logger.debug(f"CATCHUP: resolved catchup_hours={catchup_hours!r}") - - if not catchup_hours: - logger.warning( - f"CATCHUP: rejecting {provider}/{channel_id} — " - f"catchup_hours=0 or attribute not found on channel model" - ) - response.status = 400 - return {"error": f'Catchup not supported for channel "{channel_id}"'} - - import time - age_seconds = int(time.time()) - start_time_int - logger.debug( - f"CATCHUP: window check age={age_seconds}s limit={catchup_hours * 3600}s ({catchup_hours}h)" - ) - if age_seconds > catchup_hours * 3600: - response.status = 400 - return {"error": f"Content outside catchup window (max {catchup_hours} hours)"} - - - return _resolve_stream( - CONTENT_TYPE_CHANNEL, provider, channel_id, - country=country, - is_catchup=is_catchup, - start_time=start_time_int if is_catchup else None, - end_time=end_time_int if is_catchup else None, - epg_id=epg_id if is_catchup else None, - drm_variant=drm_variant, - ) - - except HTTPResponse: - raise - except ValueError as e: - label = "sw-drm " if drm_variant == "software" else "" - logger.error(f"{label}stream error for channel {provider}/{channel_id}: {e}") - response.status = 404 - return {"error": str(e)} - except Exception as e: - label = "sw-drm " if drm_variant == "software" else "" - logger.error(f"{label}stream error for channel {provider}/{channel_id}: {e}") - response.status = 500 - return {"error": f"Internal server error: {str(e)}"} - - @app.route("/api/providers//channels//stream/index.mpd") - def get_channel_stream(provider, channel_id): - """Returns HTTP 302 redirect to the actual manifest, or a rewritten - manifest body when media proxy is active. Supports live and catchup.""" - return _handle_channel_stream(provider, channel_id) - - @app.route("/api/providers//channels//stream/sw-drm/index.mpd") - def get_channel_stream_sw_drm(provider, channel_id): - """Software-DRM (ClearKey) variant. Identical transport to the standard - endpoint; passes drm_variant='software' through to _resolve_stream.""" - return _handle_channel_stream(provider, channel_id, drm_variant="software") - - @app.route("/api/providers//channels//stream/decrypted/index.mpd") - def get_channel_stream_decrypted(provider, channel_id): - """Decrypted stream — all quality representations.""" - return _resolve_decrypted_stream( - CONTENT_TYPE_CHANNEL, provider, channel_id, highest_quality_only=False - ) - - @app.route( - "/api/providers//channels//stream/decrypted/ffmpeg/index.mpd" - ) - def get_channel_stream_decrypted_ffmpeg(provider, channel_id): - """Decrypted stream — highest quality only, optimised for ffmpeg.""" - return _resolve_decrypted_stream( - CONTENT_TYPE_CHANNEL, provider, channel_id, highest_quality_only=True - ) - - @app.route("/api/providers//channels//drm") - def get_channel_drm(provider, channel_id): - """ - Return DRM configs for a channel. Supports catchup via query params. - """ - try: - start_time = request.query.get("start_time") - end_time = request.query.get("end_time") - epg_id = request.query.get("epg_id") - country = request.query.get("country") - is_catchup = bool(start_time and end_time) - - if is_catchup: - try: - start_time_int = int(start_time) - end_time_int = int(end_time) - except (ValueError, TypeError): - response.status = 400 - return {"error": "Invalid start_time or end_time format"} - - drm_configs = manager.get_catchup_drm_configs( - provider_name=provider, - channel_id=channel_id, - start_time=start_time_int, - end_time=end_time_int, - epg_id=epg_id, - country=country, - ) - else: - drm_configs = _get_drm_configs( - CONTENT_TYPE_CHANNEL, provider, channel_id, country=country - ) - - merged = {} - for config in drm_configs: - merged.update( - config.to_dict() if hasattr(config, "to_dict") else config - ) - - return { - "provider": provider, - "channel_id": channel_id, - "content_type": CONTENT_TYPE_CHANNEL, - "is_catchup": is_catchup, - "drm_configs": merged, - } - - except ValueError as e: - logger.error(f"DRM endpoint error for channel {provider}/{channel_id}: {e}") - response.status = 404 - return {"error": str(e)} - except Exception as e: - logger.error(f"DRM endpoint error for channel {provider}/{channel_id}: {e}") - response.status = 500 - return {"error": f"Internal server error: {str(e)}"} - - # ========================================================================= - # EVENT ROUTES - # ========================================================================= - - @app.route("/api/providers//events//manifest") - def get_event_manifest(provider, event_id): - """ - Returns JSON with a manifest_url pointing to the event stream endpoint. - Attaches x-kodi-drm-configs header. - - Response includes both stream_url (auto DRM) and sw_drm_stream_url - (software / ClearKey DRM). - """ - try: - country = request.query.get("country") - base_url = f"{request.urlparts.scheme}://{request.urlparts.netloc}" - qs = f"?country={country}" if country else "" - stream_url = ( - f"{base_url}/api/providers/{provider}/events/{event_id}" - f"/stream/index.mpd{qs}" - ) - sw_drm_stream_url = ( - f"{base_url}/api/providers/{provider}/events/{event_id}" - f"/stream/sw-drm/index.mpd{qs}" - ) - - _build_drm_header(CONTENT_TYPE_EVENT, provider, event_id, country=country) - _build_stream_headers(CONTENT_TYPE_EVENT, provider, event_id, country=country) - - return { - "provider": provider, - "event_id": event_id, - "manifest_url": stream_url, - "sw_drm_manifest_url": sw_drm_stream_url, - } - - except ValueError as e: - logger.error(f"manifest endpoint error for event {provider}/{event_id}: {e}") - response.status = 404 - return {"error": str(e)} - except Exception as e: - logger.error(f"manifest endpoint error for event {provider}/{event_id}: {e}") - response.status = 500 - return {"error": f"Internal server error: {str(e)}"} - - @app.route("/api/providers//events//stream/index.mpd") - def get_event_stream(provider, event_id): - """ - Returns HTTP 302 redirect to the event manifest, or a rewritten - manifest body when media proxy is active. - """ - try: - country = request.query.get("country") - return _resolve_stream( - CONTENT_TYPE_EVENT, provider, event_id, country=country - ) - except HTTPResponse: - raise - except ValueError as e: - logger.error(f"stream error for event {provider}/{event_id}: {e}") - response.status = 404 - return {"error": str(e)} - except Exception as e: - logger.error(f"stream error for event {provider}/{event_id}: {e}") - response.status = 500 - return {"error": f"Internal server error: {str(e)}"} - - @app.route("/api/providers//events//stream/sw-drm/index.mpd") - def get_event_stream_sw_drm(provider, event_id): - """ - Software-DRM (ClearKey) event stream endpoint. - - Identical to the standard event stream endpoint except that - drm_variant='software' is passed through to the resolution helpers. - """ - try: - country = request.query.get("country") - return _resolve_stream( - CONTENT_TYPE_EVENT, provider, event_id, - country=country, drm_variant="software", - ) - except HTTPResponse: - raise - except ValueError as e: - logger.error(f"sw-drm stream error for event {provider}/{event_id}: {e}") - response.status = 404 - return {"error": str(e)} - except Exception as e: - logger.error(f"sw-drm stream error for event {provider}/{event_id}: {e}") - response.status = 500 - return {"error": f"Internal server error: {str(e)}"} - - @app.route( - "/api/providers//events//stream/decrypted/index.mpd" - ) - def get_event_stream_decrypted(provider, event_id): - """Decrypted event stream — all quality representations.""" - return _resolve_decrypted_stream( - CONTENT_TYPE_EVENT, provider, event_id, highest_quality_only=False - ) - - @app.route( - "/api/providers//events//stream/decrypted/ffmpeg/index.mpd" - ) - def get_event_stream_decrypted_ffmpeg(provider, event_id): - """Decrypted event stream — highest quality only, optimised for ffmpeg.""" - return _resolve_decrypted_stream( - CONTENT_TYPE_EVENT, provider, event_id, highest_quality_only=True - ) - - @app.route("/api/providers//events//drm") - def get_event_drm(provider, event_id): - """Return DRM configs for a specific event.""" - try: - country = request.query.get("country") - drm_configs = _get_drm_configs( - CONTENT_TYPE_EVENT, provider, event_id, country=country - ) - - merged = {} - for config in drm_configs: - merged.update( - config.to_dict() if hasattr(config, "to_dict") else config - ) - - return { - "provider": provider, - "event_id": event_id, - "content_type": CONTENT_TYPE_EVENT, - "drm_configs": merged, - } - - except ValueError as e: - logger.error(f"DRM endpoint error for event {provider}/{event_id}: {e}") - response.status = 404 - return {"error": str(e)} - except Exception as e: - logger.error(f"DRM endpoint error for event {provider}/{event_id}: {e}") - response.status = 500 - return {"error": f"Internal server error: {str(e)}"} - - # ========================================================================= - # VOD ROUTES — identical transport pattern to event routes - # /manifest → returns local stream URL + attaches DRM header - # /stream → proxy-rewrites or redirects to upstream manifest - # /drm → returns raw DRM configs - # ========================================================================= - - @app.route("/api/providers//vod//stream/index.mpd") - def get_vod_stream(provider, path): - # Extract vod_id as the first segment before any slashes - # Example: "clip_1417600/stream" -> "clip_1417600" - vod_id = path.split("/")[0] - - try: - country = request.query.get("country") - return _resolve_stream( - CONTENT_TYPE_VOD, provider, vod_id, country=country - ) - except HTTPResponse: - raise - except ValueError as e: - logger.error(f"stream error for VOD {provider}/{vod_id}: {e}") - response.status = 404 - return {"error": str(e)} - except Exception as e: - logger.error(f"stream error for VOD {provider}/{vod_id}: {e}") - response.status = 500 - return {"error": f"Internal server error: {str(e)}"} - - @app.route("/api/providers//vod//manifest") - def get_vod_stream_manifest(provider, path): - vod_id = path.split("/")[0] # Fix here too - try: - country = request.query.get("country") - base_url = f"{request.urlparts.scheme}://{request.urlparts.netloc}" - stream_url = ( - f"{base_url}/api/providers/{provider}/vod/{vod_id}/stream/index.mpd" - ) - if country: - stream_url += f"?country={country}" - - _build_drm_header(CONTENT_TYPE_VOD, provider, vod_id, country=country) - _build_stream_headers(CONTENT_TYPE_VOD, provider, vod_id, country=country) - - return { - "provider": provider, - "vod_id": vod_id, - "manifest_url": stream_url, - } - - except ValueError as e: - logger.error(f"manifest endpoint error for VOD {provider}/{vod_id}: {e}") - response.status = 404 - return {"error": str(e)} - except Exception as e: - logger.error(f"manifest endpoint error for VOD {provider}/{vod_id}: {e}") - response.status = 500 - return {"error": f"Internal server error: {str(e)}"} - - @app.route("/api/providers//vod//stream/decrypted/index.mpd") - def get_vod_stream_decrypted(provider, path): - vod_id = path.split("/")[0] # Fix here too - return _resolve_decrypted_stream( - CONTENT_TYPE_VOD, provider, vod_id, highest_quality_only=False - ) - - @app.route("/api/providers//vod//stream/decrypted/ffmpeg/index.mpd") - def get_vod_stream_decrypted_ffmpeg(provider, path): - vod_id = path.split("/")[0] # Fix here too - return _resolve_decrypted_stream( - CONTENT_TYPE_VOD, provider, vod_id, highest_quality_only=True - ) - - @app.route("/api/providers//vod//drm") - def get_vod_drm(provider, path): - vod_id = path.split("/")[0] # Fix here too - try: - country = request.query.get("country") - drm_configs = _get_drm_configs( - CONTENT_TYPE_VOD, provider, vod_id, country=country - ) - - merged = {} - for config in drm_configs: - merged.update( - config.to_dict() if hasattr(config, "to_dict") else config - ) - - return { - "provider": provider, - "vod_id": vod_id, - "content_type": CONTENT_TYPE_VOD, - "drm_configs": merged, - } - - except ValueError as e: - logger.error(f"DRM endpoint error for VOD {provider}/{vod_id}: {e}") - response.status = 404 - return {"error": str(e)} - except Exception as e: - logger.error(f"DRM endpoint error for VOD {provider}/{vod_id}: {e}") - response.status = 500 - return {"error": f"Internal server error: {str(e)}"} - - # ========================================================================= - # RECORDING ROUTES — identical transport pattern to VOD routes - # - # Recordings are always on-demand (pre-captured), so: - # - No catchup path (unlike channels) - # - No ffmpeg variant (not a live/adaptive stream that needs quality pinning) - # - recording_id is a flat identifier, no path hierarchy needed - # - # /manifest → returns local stream URL + attaches DRM header - # /stream → proxy-rewrites or redirects to upstream manifest - # /drm → returns raw DRM configs - # ========================================================================= - - @app.route("/api/providers//recordings//manifest") - def get_recording_manifest(provider, recording_id): - """ - Returns JSON with a manifest_url pointing to the recording stream endpoint. - Attaches x-kodi-drm-configs header. - """ - try: - country = request.query.get("country") - base_url = f"{request.urlparts.scheme}://{request.urlparts.netloc}" - stream_url = ( - f"{base_url}/api/providers/{provider}" - f"/recordings/{recording_id}/stream/index.mpd" - ) - if country: - stream_url += f"?country={country}" - - _build_drm_header( - CONTENT_TYPE_RECORDING, provider, recording_id, country=country - ) - _build_stream_headers( - CONTENT_TYPE_RECORDING, provider, recording_id, country=country - ) - - return { - "provider": provider, - "recording_id": recording_id, - "manifest_url": stream_url, - } - - except ValueError as e: - logger.error( - f"manifest endpoint error for recording {provider}/{recording_id}: {e}" - ) - response.status = 404 - return {"error": str(e)} - except Exception as e: - logger.error( - f"manifest endpoint error for recording {provider}/{recording_id}: {e}" - ) - response.status = 500 - return {"error": f"Internal server error: {str(e)}"} - - @app.route("/api/providers//recordings//stream/index.mpd") - def get_recording_stream(provider, recording_id): - """ - Returns HTTP 302 redirect to the recording manifest, or a rewritten - manifest body when media proxy is active. - """ - try: - country = request.query.get("country") - return _resolve_stream( - CONTENT_TYPE_RECORDING, provider, recording_id, country=country - ) - except HTTPResponse: - raise - except ValueError as e: - logger.error( - f"stream error for recording {provider}/{recording_id}: {e}" - ) - response.status = 404 - return {"error": str(e)} - except Exception as e: - logger.error( - f"stream error for recording {provider}/{recording_id}: {e}" - ) - response.status = 500 - return {"error": f"Internal server error: {str(e)}"} - - @app.route( - "/api/providers//recordings//stream/decrypted/index.mpd" - ) - def get_recording_stream_decrypted(provider, recording_id): - """Decrypted recording stream — all quality representations.""" - return _resolve_decrypted_stream( - CONTENT_TYPE_RECORDING, provider, recording_id, highest_quality_only=False - ) - - @app.route("/api/providers//recordings//drm") - def get_recording_drm(provider, recording_id): - """Return DRM configs for a specific recording.""" - try: - country = request.query.get("country") - drm_configs = _get_drm_configs( - CONTENT_TYPE_RECORDING, provider, recording_id, country=country - ) - - merged = {} - for config in drm_configs: - merged.update( - config.to_dict() if hasattr(config, "to_dict") else config - ) - - return { - "provider": provider, - "recording_id": recording_id, - "content_type": CONTENT_TYPE_RECORDING, - "drm_configs": merged, - } - - except ValueError as e: - logger.error( - f"DRM endpoint error for recording {provider}/{recording_id}: {e}" - ) - response.status = 404 - return {"error": str(e)} - except Exception as e: - logger.error( - f"DRM endpoint error for recording {provider}/{recording_id}: {e}" - ) - response.status = 500 - return {"error": f"Internal server error: {str(e)}"} - - # ========================================================================= - # EPG ROUTES (unchanged from original) - # ========================================================================= - - @app.route("/api/providers//channels//epg") - def get_channel_epg(provider, channel_id): - try: - kwargs = {"country": request.query.get("country")} - - from datetime import timezone - - if request.query.get("start_time"): - start_time_str = request.query.get("start_time") - try: - kwargs["start_time"] = datetime.fromtimestamp( - int(start_time_str), tz=timezone.utc - ) - except (ValueError, TypeError): - try: - kwargs["start_time"] = datetime.fromisoformat( - start_time_str.replace("Z", "+00:00") - ) - except ValueError: - logger.warning(f"Invalid start_time format: {start_time_str}") - - if request.query.get("end_time"): - end_time_str = request.query.get("end_time") - try: - kwargs["end_time"] = datetime.fromtimestamp( - int(end_time_str), tz=timezone.utc - ) - except (ValueError, TypeError): - try: - kwargs["end_time"] = datetime.fromisoformat( - end_time_str.replace("Z", "+00:00") - ) - except ValueError: - logger.warning(f"Invalid end_time format: {end_time_str}") - - epg_data = manager.get_channel_epg( - provider_name=provider, channel_id=channel_id, **kwargs - ) - - response.content_type = "application/json; charset=utf-8" - return {"provider": provider, "channel_id": channel_id, "epg": epg_data} - - except ValueError as e: - logger.error(f"EPG error for {provider}/{channel_id}: {e}") - response.status = 404 - response.content_type = "application/json; charset=utf-8" - return {"error": str(e)} - except Exception as e: - logger.error(f"EPG error for {provider}/{channel_id}: {e}") - response.status = 500 - response.content_type = "application/json; charset=utf-8" - return {"error": f"Internal server error: {str(e)}"} - - @app.route("/api/providers//epg") - def get_provider_epg_xmltv(provider): - try: - response.content_type = "application/xml; charset=utf-8" - response.headers["Content-Disposition"] = ( - f'attachment; filename="{provider}_epg.xml"' - ) - - xmltv_data = manager.get_provider_epg_xmltv( - provider_name=provider, country=request.query.get("country") - ) - - if not xmltv_data: - response.status = 404 - return {"error": f'EPG data not available for provider "{provider}"'} - - return xmltv_data - - except ValueError as e: - logger.error(f"XMLTV EPG error for {provider}: {e}") - response.status = 404 - return {"error": str(e)} - except Exception as e: - logger.error(f"XMLTV EPG error for {provider}: {e}") - response.status = 500 - return {"error": f"Internal server error: {str(e)}"} \ No newline at end of file diff --git a/routes/streams/__init__.py b/routes/streams/__init__.py new file mode 100644 index 0000000..0f1c879 --- /dev/null +++ b/routes/streams/__init__.py @@ -0,0 +1,744 @@ +#!/usr/bin/env python3 +""" +Stream route shared helpers and orchestration. + +This module contains the core logic that all content types (channels, events, +VOD, recordings) share. Individual content type modules define only their +route decorators and call the helpers defined here. + +Architecture +============ +All content types share identical transport-level operations: resolve a +manifest URL, fetch DRM configs, optionally rewrite the manifest through a +media proxy. The typed route handlers are thin wrappers around two shared +helpers: + + _build_drm_header(content_type, provider, content_id, ...) + Fetches DRM configs via the correct manager method and attaches them as a + base64-encoded response header. Non-fatal — logs a warning on failure. + + _resolve_stream(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). +""" + +import base64 +import json +import re +from urllib.parse import urljoin + +from bottle import HTTPResponse, redirect, request, response +from streaming_providers.base.utils import logger +from streaming_providers.base.utils.manifest_utils import ManifestUtils + +# --------------------------------------------------------------------------- +# Content-type constants — single source of truth used by helpers and routes +# --------------------------------------------------------------------------- +CONTENT_TYPE_CHANNEL = "channel" +CONTENT_TYPE_EVENT = "event" +CONTENT_TYPE_VOD = "vod" +CONTENT_TYPE_RECORDING = "recording" + + +def setup_stream_routes(app, manager, service): + """Register all stream routes from submodules.""" + from .channels import setup_channel_routes + from .events import setup_event_routes + from .vod import setup_vod_routes + from .recordings import setup_recording_routes + from .epg import setup_epg_routes + + # Pass manager and service to all submodules + setup_channel_routes(app, manager, service) + setup_event_routes(app, manager, service) + setup_vod_routes(app, manager, service) + setup_recording_routes(app, manager, service) + setup_epg_routes(app, manager, service) + + # ========================================================================= + # ORIGINAL MANIFEST ENDPOINT (shared across content types) + # ========================================================================= + + @app.route("/api/providers////manifest/original") + def get_original_manifest(provider, content_type, content_id): + """ + Fetch and return the original manifest content without modifications. + The server fetches the manifest and returns it as-is. + + Supported content_type values: + - channels + - events + - vod + - recordings + + Example: + GET /api/providers/rtlplus/channels/123/manifest/original + GET /api/providers/discovery_de/vod/movie123/manifest/original + """ + try: + country = request.query.get("country") + + # Create helpers to use the manager methods + helpers = make_helpers(manager, service) + _get_manifest_url = helpers["_get_manifest_url"] + + # Map content_type string to constant + content_type_map = { + "channels": CONTENT_TYPE_CHANNEL, + "events": CONTENT_TYPE_EVENT, + "vod": CONTENT_TYPE_VOD, + "recordings": CONTENT_TYPE_RECORDING, + } + + if content_type not in content_type_map: + response.status = 400 + return {"error": f"Invalid content_type: {content_type}"} + + ct_const = content_type_map[content_type] + + # Get the upstream manifest URL + manifest_url = _get_manifest_url( + ct_const, provider, content_id, country=country + ) + + if not manifest_url: + response.status = 404 + return {"error": f'Manifest not available for {content_type} "{content_id}" from provider "{provider}"'} + + # Use service.fetch_manifest_for_rewriter to fetch manifest + manifest_text, _, _, _, _ = service.fetch_manifest_for_rewriter( + provider, content_id, manifest_url + ) + + # Return unmodified manifest + response.content_type = "application/dash+xml; charset=utf-8" + return manifest_text + + except ValueError as e: + logger.error(f"Original manifest error for {provider}/{content_type}/{content_id}: {e}") + response.status = 404 + return {"error": str(e)} + except Exception as e: + logger.error(f"Original manifest error for {provider}/{content_type}/{content_id}: {e}") + response.status = 500 + return {"error": f"Failed to fetch manifest: {str(e)}"} + + +def make_helpers(manager, service): + """ + Factory function that creates helper functions with manager and service + closures. Returns a dict of helpers for submodules to use. + """ + + # ========================================================================= + # INTERNAL HELPERS (with manager and service closed over) + # ========================================================================= + + def _get_drm_configs( + content_type: str, + provider: str, + content_id: str, + drm_variant: str = "auto", + **kwargs, + ): + """ + Dispatch DRM config retrieval to the correct manager method based on + content type. Returns a list of DRMConfig objects (may be empty). + Raises ValueError for unknown provider; re-raises other exceptions. + + Args: + drm_variant: 'auto' (provider default) or 'software' (prefer ClearKey). + Passed through to the provider via **kwargs so individual + providers can select an appropriate DRM scheme. + """ + kwargs.setdefault("drm_variant", drm_variant) + if content_type == CONTENT_TYPE_CHANNEL: + return manager.get_channel_drm_configs( + provider_name=provider, channel_id=content_id, **kwargs + ) + elif content_type == CONTENT_TYPE_EVENT: + return manager.get_event_drm_configs( + provider_name=provider, event_id=content_id, **kwargs + ) + elif content_type == CONTENT_TYPE_VOD: + return manager.get_vod_drm_configs( + provider_name=provider, vod_id=content_id, **kwargs + ) + elif content_type == CONTENT_TYPE_RECORDING: + return manager.get_recording_drm_configs( + provider_name=provider, recording_id=content_id, **kwargs + ) + else: + raise ValueError(f"Unknown content_type '{content_type}'") + + def _get_manifest_url( + content_type: str, + provider: str, + content_id: str, + drm_variant: str = "auto", + **kwargs, + ) -> str: + """ + Dispatch manifest URL retrieval to the correct manager method. + Returns the raw upstream manifest URL (before any proxy rewriting). + + Args: + drm_variant: 'auto' or 'software'. Passed through so providers that + expose separate ClearKey manifest URLs can return the + correct one. + """ + kwargs.setdefault("drm_variant", drm_variant) + if content_type == CONTENT_TYPE_CHANNEL: + return manager.get_channel_manifest( + provider_name=provider, channel_id=content_id, **kwargs + ) + elif content_type == CONTENT_TYPE_EVENT: + return manager.get_event_manifest( + provider_name=provider, event_id=content_id, **kwargs + ) + elif content_type == CONTENT_TYPE_VOD: + return manager.get_vod_manifest( + provider_name=provider, vod_id=content_id, **kwargs + ) + elif content_type == CONTENT_TYPE_RECORDING: + return manager.get_recording_manifest( + provider_name=provider, recording_id=content_id, **kwargs + ) + else: + raise ValueError(f"Unknown content_type '{content_type}'") + + def _build_drm_header( + content_type: str, + provider: str, + content_id: str, + country=None, + # catchup-specific — only used for channels + is_catchup: bool = False, + start_time: int = None, + end_time: int = None, + epg_id: str = None, + drm_variant: str = "auto", + ): + """ + Fetch DRM configs, serialise to JSON and attach as a base64-encoded + response header (x-kodi-drm-configs). Non-fatal: logs a warning and + returns None on any error. + + Args: + drm_variant: 'auto' or 'software'. Forwarded to the DRM config + fetch so the provider can return ClearKey configs when + the software-DRM variant is requested. + """ + try: + if is_catchup and content_type == CONTENT_TYPE_CHANNEL: + drm_configs = manager.get_catchup_drm_configs( + provider_name=provider, + channel_id=content_id, + start_time=start_time, + end_time=end_time, + epg_id=epg_id, + country=country, + drm_variant=drm_variant, + ) + else: + drm_configs = _get_drm_configs( + content_type, provider, content_id, + country=country, drm_variant=drm_variant, + ) + + merged = {} + for config in drm_configs: + config_dict = config.to_dict() if hasattr(config, "to_dict") else config + merged.update(config_dict) + + encoded = base64.b64encode( + json.dumps(merged).encode("utf-8") + ).decode("ascii") + + response.headers["x-kodi-drm-configs"] = encoded + return encoded + + except Exception as e: + logger.warning( + f"Could not build x-kodi-drm-configs header for " + f"{content_type} {provider}/{content_id} (variant={drm_variant}): {e}" + ) + return None + + def _build_stream_headers( + content_type: str, + provider: str, + content_id: str, + country=None, + # catchup-specific — only used for channels + is_catchup: bool = False, + start_time: int = None, + end_time: int = None, + epg_id: str = None, + ): + """ + Fetch manifest and segment headers from the provider, serialise to JSON + and attach as a base64-encoded response header (x-kodi-stream-headers). + Non-fatal: logs a warning and returns None on any error. + + Payload structure: + { + "manifest": { : , ... }, + "segment": { : , ... } + } + Both keys are always present (empty dict if the provider returns nothing). + """ + try: + provider_instance = manager.get_provider(provider) + if not provider_instance: + return None + + kwargs = {} + if country: + kwargs["country"] = country + if is_catchup and content_type == CONTENT_TYPE_CHANNEL: + kwargs.update( + start_time=start_time, + end_time=end_time, + epg_id=epg_id, + ) + + manifest_headers = provider_instance.get_manifest_headers(content_id, **kwargs) + segment_headers = provider_instance.get_segment_headers(content_id, **kwargs) + + payload = { + "manifest": manifest_headers or {}, + "segment": segment_headers or {}, + } + + encoded = base64.b64encode( + json.dumps(payload).encode("utf-8") + ).decode("ascii") + + response.headers["x-kodi-stream-headers"] = encoded + return encoded + + except Exception as e: + logger.warning( + f"Could not build x-kodi-stream-headers header for " + f"{content_type} {provider}/{content_id}: {e}" + ) + return None + + def _stream_needs_headers( + content_type: str, + provider: str, + content_id: str, + country=None, + # catchup-specific — forwarded so providers can return catchup-appropriate headers + is_catchup: bool = False, + start_time: int = None, + end_time: int = None, + epg_id: str = None, + ) -> bool: + """ + Returns True if the provider requires manifest or segment headers for + this content — meaning a plain redirect would lose those headers and + playback would likely fail. + + Catchup context (is_catchup, start_time, end_time, epg_id) is forwarded + to the provider so it can return headers appropriate for the DVR/catchup + endpoint rather than the live endpoint. Without this, providers that use + different auth tokens for catchup would return live headers here and the + proxy decision could be wrong. + """ + try: + provider_instance = manager.get_provider(provider) + if not provider_instance: + return False + kwargs = {} + if country: + kwargs["country"] = country + if is_catchup and content_type == CONTENT_TYPE_CHANNEL: + kwargs.update( + start_time=start_time, + end_time=end_time, + epg_id=epg_id, + ) + manifest_headers = provider_instance.get_manifest_headers(content_id, **kwargs) or {} + segment_headers = provider_instance.get_segment_headers(content_id, **kwargs) or {} + return bool(manifest_headers or segment_headers) + except Exception as e: + logger.warning( + f"Could not check stream headers for {content_type} {provider}/{content_id}: {e}" + ) + return False # assume no headers needed; let the redirect attempt proceed + + def _inject_base_url(manifest_text: str, manifest_url: str) -> str: + """Inject BaseURL element into MPD if none exists.""" + existing = ManifestUtils.extract_base_urls(manifest_text) + if existing: + return manifest_text + + # Strip the filename, keep the directory — e.g. + # https://cdn.example.com/live/stream/index.mpd + # → https://cdn.example.com/live/stream/ + base_url = urljoin(manifest_url, ".") + + base_url_element = f"{base_url}" + return re.sub( + r"(]*>)", + rf"\1\n {base_url_element}", + manifest_text, + count=1, + ) + + def _resolve_stream( + content_type: str, + provider: str, + content_id: str, + country=None, + # catchup-specific — only used for channels + is_catchup: bool = False, + start_time: int = None, + end_time: int = None, + epg_id: str = None, + drm_variant: str = "auto", + ): + """ + Core stream resolution: attach DRM header, then either redirect to the + upstream manifest or return a proxy-rewritten manifest body. + + Args: + drm_variant: 'auto' (provider decides) or 'software' (prefer ClearKey / + software-decodable DRM). Threaded through to all helpers so + providers can return the correct manifest URL and DRM configs. + """ + # --- DRM header (best-effort, never fatal) --- + _build_drm_header( + 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, + ) + # --- Stream headers (best-effort, never fatal) --- + _build_stream_headers( + content_type, provider, content_id, + country=country, + is_catchup=is_catchup, + start_time=start_time, + end_time=end_time, + epg_id=epg_id, + ) + + # --- Catchup path (channel-specific) --- + if is_catchup: + if manager.needs_proxy(provider): + return service.get_proxied_catchup_manifest( + provider, content_id, start_time, end_time, epg_id, country + ) + else: + manifest_url = manager.get_catchup_manifest( + provider_name=provider, + channel_id=content_id, + start_time=start_time, + end_time=end_time, + epg_id=epg_id, + country=country, + drm_variant=drm_variant, + ) + if not manifest_url: + response.status = 404 + return { + "error": f'Catchup manifest not available for channel "{content_id}"' + } + logger.debug(f"Redirecting to catchup manifest: {manifest_url}") + redirect(manifest_url) + + # --- Live / event / vod path --- + if manager.needs_proxy(provider): + # Check for ClearKey DRM — if present, rewrite manifest with ClearKey signaling + # so the receiver can decrypt itself, rather than serving a plain proxy stream + # with stripped ContentProtection that the player cannot handle. + try: + drm_configs = _get_drm_configs( + content_type, provider, content_id, + country=country, drm_variant=drm_variant, + ) + drm_dict = {} + for config in drm_configs: + drm_dict.update( + config.to_dict() if hasattr(config, "to_dict") else config + ) + except Exception as e: + logger.warning( + f"Could not fetch DRM configs for {content_type} " + f"{provider}/{content_id} during proxy resolution: {e}" + ) + drm_dict = {} + + keyids = ( + drm_dict.get("org.w3.clearkey", {}) + .get("license", {}) + .get("keyids", {}) + ) + + # When the caller explicitly requested software DRM and we found no + # ClearKey keys, surface a clear error rather than silently serving a + # Widevine stream the client cannot decrypt. + if drm_variant == "software" and not keyids: + logger.warning( + f"Software DRM requested but no ClearKey keys found for " + f"{provider}/{content_id}" + ) + response.status = 400 + return {"error": "Software DRM not available for this content"} + + if keyids: + logger.debug( + f"ClearKey DRM detected for {provider}/{content_id} " + f"(variant={drm_variant}) — using receiver-side ClearKey rewrite" + ) + return service.get_decrypted_manifest( + provider, content_id, keyids, + receiver_side=True, + drm_variant=drm_variant, + ) + else: + return service.get_proxied_manifest(provider, content_id) + else: + # Check if provider requires manifest context (needs HTTP manager to fetch) + provider_instance = manager.get_provider(provider) + + if provider_instance is None: + logger.warning( + f"_resolve_stream: manager.get_provider('{provider}') returned None — " + "cannot check requires_manifest_context; falling back to redirect" + ) + + if provider_instance and getattr(provider_instance, 'requires_manifest_context', False): + logger.debug( + f"Provider {provider} requires manifest context — fetching manifest directly " + f"for {content_type}/{content_id}" + ) + try: + manifest_url = _get_manifest_url( + content_type, provider, content_id, + country=country, drm_variant=drm_variant, + ) + if not manifest_url: + response.status = 404 + return { + "error": ( + f'Manifest not available for {content_type} ' + f'"{content_id}" from provider "{provider}"' + ) + } + + manifest_text, _, _, _, effective_url = service.fetch_manifest_for_rewriter( + provider, content_id, manifest_url + ) + + # Inject the upstream manifest URL as a BaseURL so the player can + # resolve relative segment URLs correctly. Without this, segments + # resolve against the local server URL and all requests fail. + manifest_text = _inject_base_url(manifest_text, effective_url) + + response.content_type = "application/dash+xml; charset=utf-8" + return manifest_text + + except Exception as e: + logger.error( + f"Failed to fetch manifest for {content_type}/{content_id} " + f"from {provider}: {e}" + ) + response.status = 502 + return {"error": f"Failed to fetch manifest: {str(e)}"} + else: + manifest_url = _get_manifest_url( + content_type, provider, content_id, + country=country, drm_variant=drm_variant, + ) + if not manifest_url: + response.status = 404 + return { + "error": ( + f'Manifest not available for {content_type} ' + f'"{content_id}" from provider "{provider}"' + ) + } + logger.debug(f"Redirecting to manifest: {manifest_url}") + return redirect(manifest_url) + + def _resolve_decrypted_stream( + content_type: str, + provider: str, + content_id: str, + highest_quality_only: bool = False, + ): + """ + Shared handler for decrypted stream endpoints. + Resolves DRM, then returns an appropriately rewritten manifest. + + For channel catchup (start_time + end_time query params present) the + handler fetches the catchup DRM configs and catchup manifest URL so that + server-side decryption operates on the correct DVR/time-shifted stream + rather than the live channel manifest. receiver_side is always False + here — the /decrypted/ endpoint contract is that the server decrypts. + """ + try: + country = request.query.get("country") + + # ------------------------------------------------------------------ + # Catchup branch — channels only. + # When start_time + end_time are present we must use the catchup + # DRM configs and catchup manifest URL. The live path below would + # silently fetch the live manifest and encrypt/decrypt against the + # wrong stream. + # ------------------------------------------------------------------ + 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 = int(start_time) + end_time_int = int(end_time) + except (ValueError, TypeError): + response.status = 400 + return {"error": "Invalid start_time or end_time format"} + + if not service.media_proxy_url: + response.status = 503 + return {"error": "Media proxy not configured (MEDIA_PROXY_URL not set)"} + + catchup_drm_configs = manager.get_catchup_drm_configs( + provider_name=provider, + channel_id=content_id, + start_time=start_time_int, + end_time=end_time_int, + epg_id=epg_id, + country=country, + ) + catchup_drm_dict = {} + for config in catchup_drm_configs: + catchup_drm_dict.update( + config.to_dict() if hasattr(config, "to_dict") else config + ) + + keyids = ( + catchup_drm_dict.get("org.w3.clearkey", {}) + .get("license", {}) + .get("keyids", {}) + ) + if not keyids: + response.status = 400 + return {"error": "ClearKey DRM not available for this catchup content"} + + return service.get_decrypted_catchup_manifest( + provider, content_id, + start_time=start_time_int, + end_time=end_time_int, + keyids=keyids, + epg_id=epg_id, + highest_quality_only=highest_quality_only, + ) + + # ------------------------------------------------------------------ + # Live / VOD / event / recording path + # ------------------------------------------------------------------ + drm_configs = _get_drm_configs( + content_type, provider, content_id, country=country + ) + + drm_dict = {} + for config in drm_configs: + drm_dict.update( + config.to_dict() if hasattr(config, "to_dict") else config + ) + + has_clearkey = "org.w3.clearkey" in drm_dict + is_unencrypted = "none" in drm_dict + + if has_clearkey: + if not service.media_proxy_url: + response.status = 503 + return {"error": "Media proxy not configured (MEDIA_PROXY_URL not set)"} + + keyids = ( + drm_dict["org.w3.clearkey"] + .get("license", {}) + .get("keyids", {}) + ) + if not keyids: + response.status = 400 + return {"error": "ClearKey DRM found but no key IDs available"} + + return service.get_decrypted_manifest( + provider, content_id, keyids, + highest_quality_only=highest_quality_only, + ) + + elif is_unencrypted: + # Decrypted-stream endpoints do not support catchup — catchup requires a + # live DVR manifest URL which must be resolved via _resolve_stream / the + # catchup path. Unencrypted content here is always VOD or live-redirect. + needs_headers = _stream_needs_headers(content_type, provider, content_id, country) + needs_proxy = manager.needs_proxy(provider) + + if (needs_headers or needs_proxy) and service.media_proxy_url: + return service.get_proxied_manifest( + provider, content_id, + highest_quality_only=highest_quality_only, + ) + elif (needs_headers or needs_proxy) and not service.media_proxy_url: + logger.warning( + f"Provider {provider}/{content_id} needs proxy/headers but MEDIA_PROXY_URL is not set; " + "falling back to redirect (playback may fail)" + ) + manifest_url = _get_manifest_url(content_type, provider, content_id, country=country) + return redirect(manifest_url) + else: + manifest_url = _get_manifest_url(content_type, provider, content_id, country=country) + if not manifest_url: + response.status = 404 + return {"error": f'Manifest not available for {content_type} "{content_id}"'} + return redirect(manifest_url) + + else: + response.status = 400 + return { + "error": ( + f'{content_type.capitalize()} "{content_id}" does not support ' + f"decrypted playback (requires ClearKey or unencrypted)" + ) + } + + 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, + "CONTENT_TYPE_EVENT": CONTENT_TYPE_EVENT, + "CONTENT_TYPE_VOD": CONTENT_TYPE_VOD, + "CONTENT_TYPE_RECORDING": CONTENT_TYPE_RECORDING, + "_get_drm_configs": _get_drm_configs, + "_get_manifest_url": _get_manifest_url, + "_build_drm_header": _build_drm_header, + "_build_stream_headers": _build_stream_headers, + "_stream_needs_headers": _stream_needs_headers, + "_inject_base_url": _inject_base_url, + "_resolve_stream": _resolve_stream, + "_resolve_decrypted_stream": _resolve_decrypted_stream, + } \ No newline at end of file diff --git a/routes/streams/channels.py b/routes/streams/channels.py new file mode 100644 index 0000000..4a35d8f --- /dev/null +++ b/routes/streams/channels.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +""" +Channel-specific stream routes. + +All channel routes delegate to the shared helpers in __init__.py for actual +stream resolution. This module only defines route decorators and the minimal +channel-specific catchup handling logic. +""" + +import time +from bottle import HTTPResponse, request, response +from streaming_providers.base.utils import logger + +from . import make_helpers + + +def setup_channel_routes(app, manager, service): + """Setup all channel-related stream routes.""" + + # Create helpers with manager and service closures + helpers = make_helpers(manager, service) + + CONTENT_TYPE_CHANNEL = helpers["CONTENT_TYPE_CHANNEL"] + _build_drm_header = helpers["_build_drm_header"] + _build_stream_headers = helpers["_build_stream_headers"] + _resolve_stream = helpers["_resolve_stream"] + _resolve_decrypted_stream = helpers["_resolve_decrypted_stream"] + _get_drm_configs = helpers["_get_drm_configs"] + + def _handle_channel_stream(provider, channel_id, *, drm_variant="auto"): + """Shared implementation for /stream/index.mpd and /stream/sw-drm/index.mpd.""" + try: + start_time = request.query.get("start_time") + end_time = request.query.get("end_time") + epg_id = request.query.get("epg_id") + country = request.query.get("country") + is_catchup = bool(start_time and end_time) + + logger.debug( + f"_handle_channel_stream: provider={provider} channel={channel_id} " + f"start_time={start_time!r} end_time={end_time!r} " + f"epg_id={epg_id!r} country={country!r} is_catchup={is_catchup} " + f"drm_variant={drm_variant}" + ) + + # Always defined so the _resolve_stream call below is unconditionally safe, + # even though the ternary guards already prevent None from being passed when + # is_catchup is False. + start_time_int: int | None = None + end_time_int: int | None = None + + if is_catchup: + try: + start_time_int = int(start_time) + end_time_int = int(end_time) + logger.debug(f"CATCHUP: times parsed OK: {start_time_int} to {end_time_int}") + except (ValueError, TypeError): + logger.warning( + f"CATCHUP: could not parse start_time={start_time!r} / end_time={end_time!r} as int" + ) + response.status = 400 + return {"error": "Invalid start_time or end_time format"} + + channels = manager.get_channels(provider_name=provider, fetch_manifests=False) + channel_obj = next((c for c in channels if c.channel_id == channel_id), None) + logger.debug( + f"CATCHUP: channel lookup id={channel_id!r} -> " + + (f"found (catchup_hours attr={getattr(channel_obj, 'catchup_hours', 'MISSING')!r}, " + f"catchup_window attr={getattr(channel_obj, 'catchup_window', 'MISSING')!r})" + if channel_obj else "NOT FOUND in channel list") + ) + + # The model field is catchup_hours (serialises as CatchupHours). + # Fall back to catchup_window for providers using the older name. + catchup_hours = ( + getattr(channel_obj, "catchup_hours", None) + or getattr(channel_obj, "catchup_window", 0) + ) if channel_obj else 0 + logger.debug(f"CATCHUP: resolved catchup_hours={catchup_hours!r}") + + if not catchup_hours: + logger.warning( + f"CATCHUP: rejecting {provider}/{channel_id} — " + f"catchup_hours=0 or attribute not found on channel model" + ) + response.status = 400 + return {"error": f'Catchup not supported for channel "{channel_id}"'} + + age_seconds = int(time.time()) - start_time_int + logger.debug( + f"CATCHUP: window check age={age_seconds}s limit={catchup_hours * 3600}s ({catchup_hours}h)" + ) + if age_seconds > catchup_hours * 3600: + response.status = 400 + return {"error": f"Content outside catchup window (max {catchup_hours} hours)"} + + return _resolve_stream( + CONTENT_TYPE_CHANNEL, provider, channel_id, + country=country, + is_catchup=is_catchup, + start_time=start_time_int if is_catchup else None, + end_time=end_time_int if is_catchup else None, + epg_id=epg_id if is_catchup else None, + drm_variant=drm_variant, + ) + + except HTTPResponse: + raise + except ValueError as e: + label = "sw-drm " if drm_variant == "software" else "" + logger.error(f"{label}stream error for channel {provider}/{channel_id}: {e}") + response.status = 404 + return {"error": str(e)} + except Exception as e: + label = "sw-drm " if drm_variant == "software" else "" + logger.error(f"{label}stream error for channel {provider}/{channel_id}: {e}") + response.status = 500 + return {"error": f"Internal server error: {str(e)}"} + + # ========================================================================= + # CHANNEL ROUTES + # ========================================================================= + + @app.route("/api/providers//channels//manifest") + def get_channel_manifest(provider, channel_id): + """ + Returns JSON with a manifest_url pointing to the stream endpoint. + Attaches x-kodi-drm-configs header. + + Response includes both stream_url (auto DRM) and sw_drm_stream_url + (software / ClearKey DRM) so callers can pick the appropriate variant + without a second round-trip. + + Also includes catchup_stream_url_template — a URL with {start_time} and + {end_time} placeholders (Unix timestamps) that callers can expand for + DVR/catchup playback, avoiding the need to construct the URL manually. + """ + try: + country = request.query.get("country") + base_url = f"{request.urlparts.scheme}://{request.urlparts.netloc}" + qs = f"?country={country}" if country else "" + stream_url = ( + f"{base_url}/api/providers/{provider}/channels/{channel_id}" + f"/stream/index.mpd{qs}" + ) + sw_drm_stream_url = ( + f"{base_url}/api/providers/{provider}/channels/{channel_id}" + f"/stream/sw-drm/index.mpd{qs}" + ) + # Catchup template — callers substitute {start_time}/{end_time} with + # Unix timestamps. Matches the query params consumed by _handle_channel_stream. + catchup_qs_sep = "&" if qs else "?" + catchup_stream_url_template = ( + f"{base_url}/api/providers/{provider}/channels/{channel_id}" + f"/stream/index.mpd{qs}{catchup_qs_sep}" + f"start_time={{start_time}}&end_time={{end_time}}" + ) + + _build_drm_header(CONTENT_TYPE_CHANNEL, provider, channel_id, country=country) + _build_stream_headers(CONTENT_TYPE_CHANNEL, provider, channel_id, country=country) + + return { + "provider": provider, + "channel_id": channel_id, + "manifest_url": stream_url, + "sw_drm_manifest_url": sw_drm_stream_url, + "catchup_stream_url_template": catchup_stream_url_template, + } + + except ValueError as e: + logger.error(f"manifest endpoint error for {provider}/{channel_id}: {e}") + response.status = 404 + return {"error": str(e)} + except Exception as e: + logger.error(f"manifest endpoint error for {provider}/{channel_id}: {e}") + response.status = 500 + return {"error": f"Internal server error: {str(e)}"} + + @app.route("/api/providers//channels//stream/index.mpd") + def get_channel_stream(provider, channel_id): + """Returns HTTP 302 redirect to the actual manifest, or a rewritten + manifest body when media proxy is active. Supports live and catchup.""" + return _handle_channel_stream(provider, channel_id) + + @app.route("/api/providers//channels//stream/sw-drm/index.mpd") + def get_channel_stream_sw_drm(provider, channel_id): + """Software-DRM (ClearKey) variant. Identical transport to the standard + endpoint; passes drm_variant='software' through to _resolve_stream.""" + return _handle_channel_stream(provider, channel_id, drm_variant="software") + + @app.route("/api/providers//channels//stream/decrypted/index.mpd") + def get_channel_stream_decrypted(provider, channel_id): + """Decrypted stream — all quality representations.""" + return _resolve_decrypted_stream( + CONTENT_TYPE_CHANNEL, provider, channel_id, highest_quality_only=False + ) + + @app.route( + "/api/providers//channels//stream/decrypted/ffmpeg/index.mpd" + ) + def get_channel_stream_decrypted_ffmpeg(provider, channel_id): + """Decrypted stream — highest quality only, optimised for ffmpeg.""" + return _resolve_decrypted_stream( + CONTENT_TYPE_CHANNEL, provider, channel_id, highest_quality_only=True + ) + + @app.route("/api/providers//channels//drm") + def get_channel_drm(provider, channel_id): + """ + Return DRM configs for a channel. Supports catchup via query params. + """ + try: + start_time = request.query.get("start_time") + end_time = request.query.get("end_time") + epg_id = request.query.get("epg_id") + country = request.query.get("country") + is_catchup = bool(start_time and end_time) + + if is_catchup: + try: + start_time_int = int(start_time) + end_time_int = int(end_time) + except (ValueError, TypeError): + response.status = 400 + return {"error": "Invalid start_time or end_time format"} + + drm_configs = manager.get_catchup_drm_configs( + provider_name=provider, + channel_id=channel_id, + start_time=start_time_int, + end_time=end_time_int, + epg_id=epg_id, + country=country, + ) + else: + drm_configs = _get_drm_configs( + CONTENT_TYPE_CHANNEL, provider, channel_id, country=country + ) + + merged = {} + for config in drm_configs: + merged.update( + config.to_dict() if hasattr(config, "to_dict") else config + ) + + return { + "provider": provider, + "channel_id": channel_id, + "content_type": CONTENT_TYPE_CHANNEL, + "is_catchup": is_catchup, + "drm_configs": merged, + } + + except ValueError as e: + logger.error(f"DRM endpoint error for channel {provider}/{channel_id}: {e}") + response.status = 404 + return {"error": str(e)} + except Exception as e: + logger.error(f"DRM endpoint error for channel {provider}/{channel_id}: {e}") + response.status = 500 + return {"error": f"Internal server error: {str(e)}"} \ No newline at end of file diff --git a/routes/streams/epg.py b/routes/streams/epg.py new file mode 100644 index 0000000..c86c4b7 --- /dev/null +++ b/routes/streams/epg.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +""" +EPG (Electronic Program Guide) routes. + +These routes provide program schedule information for channels. +""" + +from datetime import datetime, timezone +from bottle import request, response +from streaming_providers.base.utils import logger + + +def setup_epg_routes(app, manager, service): + """Setup all EPG-related routes.""" + + # Note: EPG routes don't need the full helpers factory since they don't + # use the stream resolution helpers. They only need manager. + + @app.route("/api/providers//channels//epg") + def get_channel_epg(provider, channel_id): + try: + kwargs = {"country": request.query.get("country")} + + if request.query.get("start_time"): + start_time_str = request.query.get("start_time") + try: + kwargs["start_time"] = datetime.fromtimestamp( + int(start_time_str), tz=timezone.utc + ) + except (ValueError, TypeError): + try: + kwargs["start_time"] = datetime.fromisoformat( + start_time_str.replace("Z", "+00:00") + ) + except ValueError: + logger.warning(f"Invalid start_time format: {start_time_str}") + + if request.query.get("end_time"): + end_time_str = request.query.get("end_time") + try: + kwargs["end_time"] = datetime.fromtimestamp( + int(end_time_str), tz=timezone.utc + ) + except (ValueError, TypeError): + try: + kwargs["end_time"] = datetime.fromisoformat( + end_time_str.replace("Z", "+00:00") + ) + except ValueError: + logger.warning(f"Invalid end_time format: {end_time_str}") + + epg_data = manager.get_channel_epg( + provider_name=provider, channel_id=channel_id, **kwargs + ) + + response.content_type = "application/json; charset=utf-8" + return {"provider": provider, "channel_id": channel_id, "epg": epg_data} + + except ValueError as e: + logger.error(f"EPG error for {provider}/{channel_id}: {e}") + response.status = 404 + response.content_type = "application/json; charset=utf-8" + return {"error": str(e)} + except Exception as e: + logger.error(f"EPG error for {provider}/{channel_id}: {e}") + response.status = 500 + response.content_type = "application/json; charset=utf-8" + return {"error": f"Internal server error: {str(e)}"} + + @app.route("/api/providers//epg") + def get_provider_epg_xmltv(provider): + try: + response.content_type = "application/xml; charset=utf-8" + response.headers["Content-Disposition"] = ( + f'attachment; filename="{provider}_epg.xml"' + ) + + xmltv_data = manager.get_provider_epg_xmltv( + provider_name=provider, country=request.query.get("country") + ) + + if not xmltv_data: + response.status = 404 + return {"error": f'EPG data not available for provider "{provider}"'} + + return xmltv_data + + except ValueError as e: + logger.error(f"XMLTV EPG error for {provider}: {e}") + response.status = 404 + return {"error": str(e)} + except Exception as e: + logger.error(f"XMLTV EPG error for {provider}: {e}") + response.status = 500 + return {"error": f"Internal server error: {str(e)}"} \ No newline at end of file diff --git a/routes/streams/events.py b/routes/streams/events.py new file mode 100644 index 0000000..df6eecb --- /dev/null +++ b/routes/streams/events.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +""" +Event-specific stream routes. + +Events are temporary/live streams (sports, special broadcasts, etc.). They +share the same transport pattern as channels but without catchup support. +""" + +from bottle import HTTPResponse, request, response +from streaming_providers.base.utils import logger + +from . import make_helpers + + +def setup_event_routes(app, manager, service): + """Setup all event-related stream routes.""" + + # Create helpers with manager and service closures + helpers = make_helpers(manager, service) + + CONTENT_TYPE_EVENT = helpers["CONTENT_TYPE_EVENT"] + _build_drm_header = helpers["_build_drm_header"] + _build_stream_headers = helpers["_build_stream_headers"] + _resolve_stream = helpers["_resolve_stream"] + _resolve_decrypted_stream = helpers["_resolve_decrypted_stream"] + _get_drm_configs = helpers["_get_drm_configs"] + + @app.route("/api/providers//events//manifest") + def get_event_manifest(provider, event_id): + """ + Returns JSON with a manifest_url pointing to the event stream endpoint. + Attaches x-kodi-drm-configs header. + + Response includes both stream_url (auto DRM) and sw_drm_stream_url + (software / ClearKey DRM). + """ + try: + country = request.query.get("country") + base_url = f"{request.urlparts.scheme}://{request.urlparts.netloc}" + qs = f"?country={country}" if country else "" + stream_url = ( + f"{base_url}/api/providers/{provider}/events/{event_id}" + f"/stream/index.mpd{qs}" + ) + sw_drm_stream_url = ( + f"{base_url}/api/providers/{provider}/events/{event_id}" + f"/stream/sw-drm/index.mpd{qs}" + ) + + _build_drm_header(CONTENT_TYPE_EVENT, provider, event_id, country=country) + _build_stream_headers(CONTENT_TYPE_EVENT, provider, event_id, country=country) + + return { + "provider": provider, + "event_id": event_id, + "manifest_url": stream_url, + "sw_drm_manifest_url": sw_drm_stream_url, + } + + except ValueError as e: + logger.error(f"manifest endpoint error for event {provider}/{event_id}: {e}") + response.status = 404 + return {"error": str(e)} + except Exception as e: + logger.error(f"manifest endpoint error for event {provider}/{event_id}: {e}") + response.status = 500 + return {"error": f"Internal server error: {str(e)}"} + + @app.route("/api/providers//events//stream/index.mpd") + def get_event_stream(provider, event_id): + """ + Returns HTTP 302 redirect to the event manifest, or a rewritten + manifest body when media proxy is active. + """ + try: + country = request.query.get("country") + return _resolve_stream( + CONTENT_TYPE_EVENT, provider, event_id, country=country + ) + except HTTPResponse: + raise + except ValueError as e: + logger.error(f"stream error for event {provider}/{event_id}: {e}") + response.status = 404 + return {"error": str(e)} + except Exception as e: + logger.error(f"stream error for event {provider}/{event_id}: {e}") + response.status = 500 + return {"error": f"Internal server error: {str(e)}"} + + @app.route("/api/providers//events//stream/sw-drm/index.mpd") + def get_event_stream_sw_drm(provider, event_id): + """ + Software-DRM (ClearKey) event stream endpoint. + + Identical to the standard event stream endpoint except that + drm_variant='software' is passed through to the resolution helpers. + """ + try: + country = request.query.get("country") + return _resolve_stream( + CONTENT_TYPE_EVENT, provider, event_id, + country=country, drm_variant="software", + ) + except HTTPResponse: + raise + except ValueError as e: + logger.error(f"sw-drm stream error for event {provider}/{event_id}: {e}") + response.status = 404 + return {"error": str(e)} + except Exception as e: + logger.error(f"sw-drm stream error for event {provider}/{event_id}: {e}") + response.status = 500 + return {"error": f"Internal server error: {str(e)}"} + + @app.route( + "/api/providers//events//stream/decrypted/index.mpd" + ) + def get_event_stream_decrypted(provider, event_id): + """Decrypted event stream — all quality representations.""" + return _resolve_decrypted_stream( + CONTENT_TYPE_EVENT, provider, event_id, highest_quality_only=False + ) + + @app.route( + "/api/providers//events//stream/decrypted/ffmpeg/index.mpd" + ) + def get_event_stream_decrypted_ffmpeg(provider, event_id): + """Decrypted event stream — highest quality only, optimised for ffmpeg.""" + return _resolve_decrypted_stream( + CONTENT_TYPE_EVENT, provider, event_id, highest_quality_only=True + ) + + @app.route("/api/providers//events//drm") + def get_event_drm(provider, event_id): + """Return DRM configs for a specific event.""" + try: + country = request.query.get("country") + drm_configs = _get_drm_configs( + CONTENT_TYPE_EVENT, provider, event_id, country=country + ) + + merged = {} + for config in drm_configs: + merged.update( + config.to_dict() if hasattr(config, "to_dict") else config + ) + + return { + "provider": provider, + "event_id": event_id, + "content_type": CONTENT_TYPE_EVENT, + "drm_configs": merged, + } + + except ValueError as e: + logger.error(f"DRM endpoint error for event {provider}/{event_id}: {e}") + response.status = 404 + return {"error": str(e)} + except Exception as e: + logger.error(f"DRM endpoint error for event {provider}/{event_id}: {e}") + response.status = 500 + return {"error": f"Internal server error: {str(e)}"} \ No newline at end of file diff --git a/routes/streams/recordings.py b/routes/streams/recordings.py new file mode 100644 index 0000000..9619f63 --- /dev/null +++ b/routes/streams/recordings.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +""" +Recording stream routes. + +Recordings are always on-demand (pre-captured), so: + - No catchup path (unlike channels) + - No ffmpeg variant (not a live/adaptive stream that needs quality pinning) + - recording_id is a flat identifier, no path hierarchy needed +""" + +from bottle import HTTPResponse, request, response +from streaming_providers.base.utils import logger + +from . import make_helpers + + +def setup_recording_routes(app, manager, service): + """Setup all recording-related stream routes.""" + + # Create helpers with manager and service closures + helpers = make_helpers(manager, service) + + CONTENT_TYPE_RECORDING = helpers["CONTENT_TYPE_RECORDING"] + _build_drm_header = helpers["_build_drm_header"] + _build_stream_headers = helpers["_build_stream_headers"] + _resolve_stream = helpers["_resolve_stream"] + _resolve_decrypted_stream = helpers["_resolve_decrypted_stream"] + _get_drm_configs = helpers["_get_drm_configs"] + + @app.route("/api/providers//recordings//manifest") + def get_recording_manifest(provider, recording_id): + """ + Returns JSON with a manifest_url pointing to the recording stream endpoint. + Attaches x-kodi-drm-configs header. + """ + try: + country = request.query.get("country") + base_url = f"{request.urlparts.scheme}://{request.urlparts.netloc}" + stream_url = ( + f"{base_url}/api/providers/{provider}" + f"/recordings/{recording_id}/stream/index.mpd" + ) + if country: + stream_url += f"?country={country}" + + _build_drm_header( + CONTENT_TYPE_RECORDING, provider, recording_id, country=country + ) + _build_stream_headers( + CONTENT_TYPE_RECORDING, provider, recording_id, country=country + ) + + return { + "provider": provider, + "recording_id": recording_id, + "manifest_url": stream_url, + } + + except ValueError as e: + logger.error( + f"manifest endpoint error for recording {provider}/{recording_id}: {e}" + ) + response.status = 404 + return {"error": str(e)} + except Exception as e: + logger.error( + f"manifest endpoint error for recording {provider}/{recording_id}: {e}" + ) + response.status = 500 + return {"error": f"Internal server error: {str(e)}"} + + @app.route("/api/providers//recordings//stream/index.mpd") + def get_recording_stream(provider, recording_id): + """ + Returns HTTP 302 redirect to the recording manifest, or a rewritten + manifest body when media proxy is active. + """ + try: + country = request.query.get("country") + return _resolve_stream( + CONTENT_TYPE_RECORDING, provider, recording_id, country=country + ) + except HTTPResponse: + raise + except ValueError as e: + logger.error( + f"stream error for recording {provider}/{recording_id}: {e}" + ) + response.status = 404 + return {"error": str(e)} + except Exception as e: + logger.error( + f"stream error for recording {provider}/{recording_id}: {e}" + ) + response.status = 500 + return {"error": f"Internal server error: {str(e)}"} + + @app.route( + "/api/providers//recordings//stream/decrypted/index.mpd" + ) + def get_recording_stream_decrypted(provider, recording_id): + """Decrypted recording stream — all quality representations.""" + return _resolve_decrypted_stream( + CONTENT_TYPE_RECORDING, provider, recording_id, highest_quality_only=False + ) + + @app.route("/api/providers//recordings//drm") + def get_recording_drm(provider, recording_id): + """Return DRM configs for a specific recording.""" + try: + country = request.query.get("country") + drm_configs = _get_drm_configs( + CONTENT_TYPE_RECORDING, provider, recording_id, country=country + ) + + merged = {} + for config in drm_configs: + merged.update( + config.to_dict() if hasattr(config, "to_dict") else config + ) + + return { + "provider": provider, + "recording_id": recording_id, + "content_type": CONTENT_TYPE_RECORDING, + "drm_configs": merged, + } + + except ValueError as e: + logger.error( + f"DRM endpoint error for recording {provider}/{recording_id}: {e}" + ) + response.status = 404 + return {"error": str(e)} + except Exception as e: + logger.error( + f"DRM endpoint error for recording {provider}/{recording_id}: {e}" + ) + response.status = 500 + return {"error": f"Internal server error: {str(e)}"} \ No newline at end of file diff --git a/routes/streams/vod.py b/routes/streams/vod.py new file mode 100644 index 0000000..5ac757c --- /dev/null +++ b/routes/streams/vod.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +""" +VOD (Video on Demand) stream routes. + +VOD content uses the same transport pattern as events but with hierarchical +IDs (paths like "clip_1417600/stream") and without catchup support. +""" + +from bottle import HTTPResponse, request, response +from streaming_providers.base.utils import logger + +from . import make_helpers + + +def setup_vod_routes(app, manager, service): + """Setup all VOD-related stream routes.""" + + # Create helpers with manager and service closures + helpers = make_helpers(manager, service) + + CONTENT_TYPE_VOD = helpers["CONTENT_TYPE_VOD"] + _build_drm_header = helpers["_build_drm_header"] + _build_stream_headers = helpers["_build_stream_headers"] + _resolve_stream = helpers["_resolve_stream"] + _resolve_decrypted_stream = helpers["_resolve_decrypted_stream"] + _get_drm_configs = helpers["_get_drm_configs"] + + @app.route("/api/providers//vod//stream/index.mpd") + def get_vod_stream(provider, path): + # Extract vod_id as the first segment before any slashes + # Example: "clip_1417600/stream" -> "clip_1417600" + vod_id = path.split("/")[0] + + try: + country = request.query.get("country") + return _resolve_stream( + CONTENT_TYPE_VOD, provider, vod_id, country=country + ) + except HTTPResponse: + raise + except ValueError as e: + logger.error(f"stream error for VOD {provider}/{vod_id}: {e}") + response.status = 404 + return {"error": str(e)} + except Exception as e: + logger.error(f"stream error for VOD {provider}/{vod_id}: {e}") + response.status = 500 + return {"error": f"Internal server error: {str(e)}"} + + @app.route("/api/providers//vod//manifest") + def get_vod_manifest(provider, path): + vod_id = path.split("/")[0] + try: + country = request.query.get("country") + base_url = f"{request.urlparts.scheme}://{request.urlparts.netloc}" + stream_url = ( + f"{base_url}/api/providers/{provider}/vod/{vod_id}/stream/index.mpd" + ) + if country: + stream_url += f"?country={country}" + + _build_drm_header(CONTENT_TYPE_VOD, provider, vod_id, country=country) + _build_stream_headers(CONTENT_TYPE_VOD, provider, vod_id, country=country) + + return { + "provider": provider, + "vod_id": vod_id, + "manifest_url": stream_url, + } + + except ValueError as e: + logger.error(f"manifest endpoint error for VOD {provider}/{vod_id}: {e}") + response.status = 404 + return {"error": str(e)} + except Exception as e: + logger.error(f"manifest endpoint error for VOD {provider}/{vod_id}: {e}") + response.status = 500 + return {"error": f"Internal server error: {str(e)}"} + + @app.route("/api/providers//vod//stream/decrypted/index.mpd") + def get_vod_stream_decrypted(provider, path): + vod_id = path.split("/")[0] + return _resolve_decrypted_stream( + CONTENT_TYPE_VOD, provider, vod_id, highest_quality_only=False + ) + + @app.route("/api/providers//vod//stream/decrypted/ffmpeg/index.mpd") + def get_vod_stream_decrypted_ffmpeg(provider, path): + vod_id = path.split("/")[0] + return _resolve_decrypted_stream( + CONTENT_TYPE_VOD, provider, vod_id, highest_quality_only=True + ) + + @app.route("/api/providers//vod//drm") + def get_vod_drm(provider, path): + vod_id = path.split("/")[0] + try: + country = request.query.get("country") + drm_configs = _get_drm_configs( + CONTENT_TYPE_VOD, provider, vod_id, country=country + ) + + merged = {} + for config in drm_configs: + merged.update( + config.to_dict() if hasattr(config, "to_dict") else config + ) + + return { + "provider": provider, + "vod_id": vod_id, + "content_type": CONTENT_TYPE_VOD, + "drm_configs": merged, + } + + except ValueError as e: + logger.error(f"DRM endpoint error for VOD {provider}/{vod_id}: {e}") + response.status = 404 + return {"error": str(e)} + except Exception as e: + logger.error(f"DRM endpoint error for VOD {provider}/{vod_id}: {e}") + response.status = 500 + return {"error": f"Internal server error: {str(e)}"} \ No newline at end of file