From 4003865a2c3defa019c3cda556fe1b928e3c07ce Mon Sep 17 00:00:00 2001 From: Nirvana Date: Sun, 7 Dec 2025 20:18:31 +0100 Subject: [PATCH] Improve EPG --- lib/streaming_providers/base/provider.py | 238 ++++++++++++++++++++- service.py | 251 +++++++++++++++++++---- 2 files changed, 452 insertions(+), 37 deletions(-) diff --git a/lib/streaming_providers/base/provider.py b/lib/streaming_providers/base/provider.py index 5fc7290..e6fa2c4 100644 --- a/lib/streaming_providers/base/provider.py +++ b/lib/streaming_providers/base/provider.py @@ -426,6 +426,29 @@ class StreamingProvider(ABC): """Get all DRM configurations for a channel by ID""" return [] + @property + def catchup_window(self) -> int: + """ + Return the catchup window in days for this provider. + + Returns: + int: Number of days of catchup available (0 = no catchup support) + + Override in subclass to provide provider-specific catchup window. + Default is 0 (no catchup). + """ + return 0 + + @property + def supports_catchup(self) -> bool: + """ + Check if provider supports catchup/timeshift functionality. + + Returns: + bool: True if catchup is supported + """ + return self.catchup_window > 0 + def get_epg(self, channel_id: str, start_time: Optional[datetime] = None, end_time: Optional[datetime] = None, @@ -465,4 +488,217 @@ class StreamingProvider(ABC): def to_json(self, channels: List[StreamingChannel] = None, indent: int = 2) -> str: """Convert to JSON string""" - return json.dumps(self.to_output_format(channels), indent=indent, ensure_ascii=False) \ No newline at end of file + return json.dumps(self.to_output_format(channels), indent=indent, ensure_ascii=False) + + + # ============================================================================ + # CATCHUP ABSTRACT METHODS + # ============================================================================ + + def get_catchup_manifest(self, + channel_id: str, + start_time: int, + end_time: int, + epg_id: Optional[str] = None, + **kwargs) -> Optional[str]: + """ + Get manifest URL for catchup/timeshift content. + + Args: + channel_id: Channel identifier + start_time: Start time as Unix timestamp + end_time: End time as Unix timestamp + epg_id: Optional EPG event ID (might be needed by some providers) + **kwargs: Additional provider-specific parameters + + Returns: + Manifest URL for catchup content, or None if not supported + + Default implementation falls back to live manifest. + Override in subclass to implement provider-specific catchup logic. + """ + if not self.supports_catchup: + logger.debug(f"{self.provider_name}: Catchup not supported, falling back to live manifest") + return self.get_manifest(channel_id, **kwargs) + + logger.warning(f"{self.provider_name}: get_catchup_manifest not implemented, " + f"falling back to live manifest") + return self.get_manifest(channel_id, **kwargs) + + def get_catchup_drm(self, + channel_id: str, + start_time: int, + end_time: int, + epg_id: Optional[str] = None, + **kwargs) -> List[DRMConfig]: + """ + Get DRM configurations for catchup content. + + Args: + channel_id: Channel identifier + start_time: Start time as Unix timestamp + end_time: End time as Unix timestamp + epg_id: Optional EPG event ID (might be needed for DRM licensing) + **kwargs: Additional provider-specific parameters + + Returns: + List of DRM configurations for catchup content + + Default implementation falls back to live DRM. + Override in subclass if catchup requires different DRM configuration. + """ + if not self.supports_catchup: + logger.debug(f"{self.provider_name}: Catchup not supported, falling back to live DRM") + return self.get_drm(channel_id, **kwargs) + + logger.debug(f"{self.provider_name}: get_catchup_drm not implemented, " + f"using live DRM configuration") + return self.get_drm(channel_id, **kwargs) + + # ============================================================================ + # CATCHUP HELPER METHODS + # ============================================================================ + + def validate_catchup_request(self, + start_time: int, + end_time: int) -> tuple[bool, Optional[str]]: + """ + Validate a catchup request against provider's capabilities. + + Args: + start_time: Start time as Unix timestamp + end_time: End time as Unix timestamp + + Returns: + Tuple of (is_valid, error_message) + If valid: (True, None) + If invalid: (False, "error description") + """ + import time + + # Check if catchup is supported + if not self.supports_catchup: + return False, f"Provider '{self.provider_name}' does not support catchup" + + # Validate timestamps + if start_time >= end_time: + return False, "Invalid time range: start_time must be before end_time" + + # Check if request is in the future + now = int(time.time()) + if start_time > now: + return False, "Cannot request future content" + + # Check if content is within catchup window + max_age_seconds = self.catchup_window * 86400 # days to seconds + content_age = now - start_time + + if content_age > max_age_seconds: + return False, (f"Content is outside catchup window " + f"(requested: {content_age // 86400} days ago, " + f"max: {self.catchup_window} days)") + + return True, None + + def get_catchup_window_for_channel(self, channel_id: str) -> int: + """ + Get catchup window for a specific channel. + + Some providers might have different catchup windows for different channels. + Default implementation returns the provider-wide catchup window. + + Args: + channel_id: Channel identifier + + Returns: + int: Catchup window in days for this channel + + Override in subclass if channels have different catchup windows. + """ + return self.catchup_window + + def format_catchup_time_params(self, + start_time: int, + end_time: int, + format_type: str = 'iso') -> Dict[str, str]: + """ + Format time parameters for provider-specific API calls. + + Different providers expect different time formats in their APIs. + This helper converts Unix timestamps to various formats. + + Args: + start_time: Start time as Unix timestamp + end_time: End time as Unix timestamp + format_type: Format type ('iso', 'unix', 'millis', 'custom') + + Returns: + Dictionary with formatted time parameters + + Override in subclass for provider-specific formatting. + """ + from datetime import datetime + + if format_type == 'iso': + # ISO 8601 format + start_dt = datetime.fromtimestamp(start_time) + end_dt = datetime.fromtimestamp(end_time) + return { + 'start': start_dt.isoformat(), + 'end': end_dt.isoformat() + } + elif format_type == 'unix': + # Unix timestamps (seconds) + return { + 'start': str(start_time), + 'end': str(end_time) + } + elif format_type == 'millis': + # Milliseconds since epoch + return { + 'start': str(start_time * 1000), + 'end': str(end_time * 1000) + } + else: + # Default to unix + return { + 'start': str(start_time), + 'end': str(end_time) + } + + def build_catchup_manifest_url(self, + base_url: str, + start_time: int, + end_time: int, + url_format: str = 'query') -> str: + """ + Build catchup manifest URL with time parameters. + + Helper method to construct manifest URLs with time parameters + in various formats that different providers use. + + Args: + base_url: Base manifest URL + start_time: Start time as Unix timestamp + end_time: End time as Unix timestamp + url_format: Format ('query', 'path', 'fragment') + + Returns: + Complete manifest URL with time parameters + + Override in subclass for provider-specific URL construction. + """ + if url_format == 'query': + # Add as query parameters + separator = '&' if '?' in base_url else '?' + return f"{base_url}{separator}start={start_time}&end={end_time}" + elif url_format == 'path': + # Add to path (e.g., /manifest/start/end.mpd) + return f"{base_url}/{start_time}/{end_time}" + elif url_format == 'fragment': + # Add as URL fragment (e.g., manifest.mpd#t=start,end) + return f"{base_url}#t={start_time},{end_time}" + else: + # Default to query parameters + separator = '&' if '?' in base_url else '?' + return f"{base_url}{separator}start={start_time}&end={end_time}" \ No newline at end of file diff --git a/service.py b/service.py index d4e2010..b0baba0 100644 --- a/service.py +++ b/service.py @@ -361,6 +361,81 @@ class UltimateService: return m3u_content + def _get_proxied_catchup_manifest(self, provider: str, channel_id: str, + start_time: int, end_time: int, + epg_id: str = None, country: str = None) -> str: + """ + Get proxied and rewritten MPD manifest for catchup content. + Similar to _get_proxied_manifest but for catchup streams. + """ + # Generate cache key that includes time parameters + cache_key = f"{channel_id}_catchup_{start_time}_{end_time}" + + # Try cache first (with catchup-specific key) + cached_mpd = self.mpd_cache.get(provider, cache_key) + if cached_mpd: + response.content_type = 'application/dash+xml; charset=utf-8' + return cached_mpd + + logger.info(f"Cache miss for catchup {provider}/{channel_id}, fetching manifest") + + # Get catchup manifest URL + manifest_url = self.manager.get_catchup_manifest( + provider_name=provider, + channel_id=channel_id, + start_time=start_time, + end_time=end_time, + epg_id=epg_id, + country=country + ) + + if not manifest_url: + response.status = 404 + response.content_type = 'application/json' + return json.dumps({'error': f'Catchup manifest not available'}) + + # Get provider's HTTP manager + http_manager = self.manager.get_provider_http_manager(provider) + if not http_manager: + logger.error(f"No HTTP manager found for provider '{provider}'") + response.status = 502 + response.content_type = 'application/json' + return json.dumps({'error': f'Provider "{provider}" not configured properly'}) + + # Fetch manifest via proxy + try: + logger.debug(f"Fetching catchup manifest via proxy: {manifest_url}") + manifest_response = http_manager.get(manifest_url, operation="manifest") + + # Extract cache TTL + ttl = MPDRewriter.extract_cache_ttl(manifest_response.headers) + mpd_ttl = MPDRewriter.extract_mpd_update_period(manifest_response.text) + if mpd_ttl and mpd_ttl < ttl: + ttl = mpd_ttl + + # Rewrite MPD URLs to point to proxy + base_url = f"{request.urlparts.scheme}://{request.urlparts.netloc}" + rewriter = MPDRewriter(base_url, provider) + rewritten_mpd = rewriter.rewrite_mpd(manifest_response.text, manifest_url) + + # Cache the rewritten MPD with catchup-specific key + self.mpd_cache.set( + provider=provider, + channel_id=cache_key, # Use catchup-specific cache key + mpd_content=rewritten_mpd, + ttl=ttl, + original_url=manifest_url + ) + + response.content_type = 'application/dash+xml; charset=utf-8' + return rewritten_mpd + + except Exception as fetch_err: + logger.error(f"Failed to fetch catchup manifest via proxy: {fetch_err}") + response.status = 502 + response.content_type = 'application/json' + return json.dumps({'error': f'Failed to fetch manifest: {str(fetch_err)}'}) + def setup_routes(self): @self.app.route('/api/providers') def list_providers(): @@ -401,11 +476,29 @@ class UltimateService: fetch_manifests=request.query.get('fetch_manifests', 'false').lower() == 'true', country=request.query.get('country') ) + + # Get provider instance to check catchup support + provider_instance = self.manager.get_provider(provider) + provider_catchup_days = getattr(provider_instance, 'catchup_window', 0) + + # Build channel list with catchup info + channels_data = [] + for c in channels: + channel_dict = c.to_dict() + + # Add catchup days - use channel-specific if available, else provider default + if hasattr(c, 'catchup_days'): + channel_dict['CatchupDays'] = c.catchup_days + else: + channel_dict['CatchupDays'] = provider_catchup_days + + channels_data.append(channel_dict) + return { 'provider': provider, - 'country': self.manager.get_provider(provider).country if self.manager.get_provider( - provider) else 'DE', - 'channels': [c.to_dict() for c in channels] + 'country': provider_instance.country if provider_instance else 'DE', + 'catchup_window_days': provider_catchup_days, + 'channels': channels_data } except Exception as api_err: logger.error(f"API Error in /api/providers/{provider}: {str(api_err)}") @@ -446,39 +539,96 @@ class UltimateService: def get_channel_stream(provider, channel_id): """ Returns HTTP 302 redirect to the actual manifest or rewritten manifest endpoint. - This allows players to use this endpoint directly as a stream URL. + Supports both live and catchup streaming. """ try: - # Check if provider needs proxy - if self.manager.needs_proxy(provider): - # Proxy mode: return rewritten MPD content directly - # (not redirect, because /manifest now returns JSON) - return self._get_proxied_manifest(provider, channel_id) + # Get optional catchup parameters + 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') + + # Determine if this is a catchup request + is_catchup = bool(start_time and end_time) + + if is_catchup: + logger.info(f"Catchup stream request for {provider}/{channel_id}: " + f"start={start_time}, end={end_time}, epg_id={epg_id}") + + # Convert Unix timestamps to integers + 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'} + + # Validate catchup is supported + provider_instance = self.manager.get_provider(provider) + catchup_window = getattr(provider_instance, 'catchup_window', 0) + + if catchup_window == 0: + response.status = 400 + return {'error': f'Catchup not supported for provider "{provider}"'} + + # Validate time is within catchup window + import time + now = int(time.time()) + max_age_seconds = catchup_window * 86400 + + if (now - start_time_int) > max_age_seconds: + response.status = 400 + return {'error': f'Content outside catchup window (max {catchup_window} days)'} + + # Check if provider needs proxy for catchup + if self.manager.needs_proxy(provider): + # Proxy mode: return rewritten MPD content directly + return self._get_proxied_catchup_manifest( + provider, channel_id, start_time_int, end_time_int, epg_id, country + ) + else: + # Direct mode: get catchup manifest URL and redirect + manifest_url = self.manager.get_catchup_manifest( + provider_name=provider, + channel_id=channel_id, + start_time=start_time_int, + end_time=end_time_int, + epg_id=epg_id, + country=country + ) + + if not manifest_url: + response.status = 404 + return {'error': f'Catchup manifest not available for channel "{channel_id}"'} + + logger.debug(f"Redirecting to catchup manifest: {manifest_url}") + redirect(manifest_url) else: - # Direct mode: redirect to original manifest URL - manifest_url = self.manager.get_channel_manifest( - provider_name=provider, - channel_id=channel_id, - country=request.query.get('country') - ) + # Live stream - existing logic + if self.manager.needs_proxy(provider): + return self._get_proxied_manifest(provider, channel_id) + else: + manifest_url = self.manager.get_channel_manifest( + provider_name=provider, + channel_id=channel_id, + country=country + ) - if not manifest_url: - response.status = 404 - return { - 'error': f'Manifest not available for channel "{channel_id}" from provider "{provider}"'} + if not manifest_url: + response.status = 404 + return {'error': f'Manifest not available for channel "{channel_id}"'} - logger.debug(f"Redirecting to manifest: {manifest_url}") - redirect(manifest_url) + logger.debug(f"Redirecting to manifest: {manifest_url}") + redirect(manifest_url) except HTTPResponse: - # Re-raise HTTPResponse - this is how Bottle handles redirects raise except ValueError as val_err: - logger.error(f"API Error in /api/providers/{provider}/channels/{channel_id}/stream: {str(val_err)}") + logger.error(f"API Error in stream: {str(val_err)}") response.status = 404 return {'error': str(val_err)} except Exception as api_err: - logger.error(f"API Error in /api/providers/{provider}/channels/{channel_id}/stream: {str(api_err)}") + logger.error(f"API Error in stream: {str(api_err)}") response.status = 500 return {'error': f'Internal server error: {str(api_err)}'} @@ -722,11 +872,43 @@ class UltimateService: @self.app.route('/api/providers//channels//drm') def get_channel_drm(provider, channel_id): try: - drm_configs = self.manager.get_channel_drm_configs( - provider_name=provider, - channel_id=channel_id, - country=request.query.get('country') - ) + # Get optional catchup parameters + 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') + + # Determine if this is a catchup request + is_catchup = bool(start_time and end_time) + + if is_catchup: + logger.debug(f"Catchup DRM request for {provider}/{channel_id}: " + f"epg_id={epg_id}") + + # Convert timestamps + 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'} + + # Get catchup DRM configs + drm_configs = self.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: + # Live DRM - existing logic + drm_configs = self.manager.get_channel_drm_configs( + provider_name=provider, + channel_id=channel_id, + country=country + ) # Merge all DRM configs into a single dictionary merged_drm_configs = {} @@ -735,24 +917,21 @@ class UltimateService: config_dict = config.to_dict() else: config_dict = config - - # config_dict is something like {"com.widevine.alpha": {...}} - # We need to merge it into the main dictionary merged_drm_configs.update(config_dict) return { 'provider': provider, 'channel_id': channel_id, - 'drm_configs': merged_drm_configs # Now an object, not an array + 'is_catchup': is_catchup, + 'drm_configs': merged_drm_configs } except ValueError as val_err: - # This handles the case where manager raises ValueError for unknown provider - logger.error(f"API Error in /api/providers/{provider}/channels/{channel_id}/drm: {str(val_err)}") + logger.error(f"API Error in DRM endpoint: {str(val_err)}") response.status = 404 return {'error': str(val_err)} except Exception as api_err: - logger.error(f"API Error in /api/providers/{provider}/channels/{channel_id}/drm: {str(api_err)}") + logger.error(f"API Error in DRM endpoint: {str(api_err)}") response.status = 500 return {'error': f'Internal server error: {str(api_err)}'}