From 02368af251129a9c6c1d3040c99e268ee0769cb9 Mon Sep 17 00:00:00 2001 From: Nirvana Date: Sun, 14 Jun 2026 21:05:32 +0200 Subject: [PATCH] fix catchup --- routes/streams.py | 341 +++++++++++++++++++--------------------------- service.py | 166 +++++++++------------- 2 files changed, 201 insertions(+), 306 deletions(-) diff --git a/routes/streams.py b/routes/streams.py index c22349b..e270e3d 100644 --- a/routes/streams.py +++ b/routes/streams.py @@ -7,13 +7,7 @@ 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 shared helpers: - - _validate_catchup_params(provider, channel_id, start_time_raw, end_time_raw) - Validates catchup timestamps and window eligibility for a channel. - Returns an error string on failure, or (start_time_int, end_time_int) on - success. Single source of truth — called by both _handle_channel_stream - and _resolve_decrypted_stream. +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 @@ -23,12 +17,6 @@ vod) are therefore thin wrappers around shared helpers: 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, ...) - Handles /stream/decrypted/ endpoints. Supports live and catchup for - channels (catchup via start_time/end_time query params). Delegates all - fetch/rewrite work to the service layer — never constructs rewriters - directly. - 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}. @@ -38,7 +26,6 @@ Adding VOD in the future means: import base64 import json import re -import time from urllib.parse import urljoin from datetime import datetime @@ -315,67 +302,6 @@ def setup_stream_routes(app, manager, service): count=1, ) - def _validate_catchup_params( - provider: str, - channel_id: str, - start_time_raw, - end_time_raw, - ): - """ - Validate catchup timestamps and window eligibility for a channel. - - Returns a tuple (start_time_int, end_time_int) on success, or raises - ValueError with a human-readable message on any validation failure. - - Single source of truth — called by both _handle_channel_stream and - _resolve_decrypted_stream so the logic is never duplicated. - """ - try: - start_time_int = int(start_time_raw) - end_time_int = int(end_time_raw) - except (ValueError, TypeError): - raise ValueError( - f"Invalid start_time={start_time_raw!r} or end_time={end_time_raw!r}: " - "expected Unix timestamps" - ) - - 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"_validate_catchup_params: channel lookup id={channel_id!r} -> " - + ( - f"found (catchup_hours={getattr(channel_obj, 'catchup_hours', 'MISSING')!r}, " - f"catchup_window={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"_validate_catchup_params: resolved catchup_hours={catchup_hours!r}") - - if not catchup_hours: - raise ValueError(f'Catchup not supported for channel "{channel_id}"') - - age_seconds = int(time.time()) - start_time_int - logger.debug( - f"_validate_catchup_params: window check age={age_seconds}s " - f"limit={catchup_hours * 3600}s ({catchup_hours}h)" - ) - if age_seconds > catchup_hours * 3600: - raise ValueError( - f"Content outside catchup window (max {catchup_hours} hours)" - ) - - return start_time_int, end_time_int - def _resolve_stream( content_type: str, provider: str, @@ -421,8 +347,7 @@ def setup_stream_routes(app, manager, service): if is_catchup: if manager.needs_proxy(provider): return service.get_proxied_catchup_manifest( - provider, content_id, start_time, end_time, epg_id, country, - drm_variant=drm_variant, + provider, content_id, start_time, end_time, epg_id, country ) else: manifest_url = manager.get_catchup_manifest( @@ -565,40 +490,42 @@ def setup_stream_routes(app, manager, service): ): """ Shared handler for decrypted stream endpoints. + Resolves DRM, then returns an appropriately rewritten manifest. - Supports live and catchup content for channels. Catchup is triggered - by the presence of start_time + end_time query parameters (same aliases - as _handle_channel_stream: start_time/start/utc and end_time/end). - - All fetch/rewrite work is delegated to the service layer — this handler - only resolves DRM configs and routes to the appropriate service method. + 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") - # Parse catchup parameters — same aliases as _handle_channel_stream - start_time_raw = ( - request.query.get("start_time") - or request.query.get("start") - or request.query.get("utc") - ) - end_time_raw = request.query.get("end_time") or request.query.get("end") - epg_id = request.query.get("epg_id") - is_catchup = bool(start_time_raw and end_time_raw) and content_type == CONTENT_TYPE_CHANNEL - - start_time_int: int | None = None - end_time_int: int | None = None + # ------------------------------------------------------------------ + # 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: - # _validate_catchup_params raises ValueError with a human-readable - # message on any failure; the except block below converts it to 400/404. - start_time_int, end_time_int = _validate_catchup_params( - provider, content_id, start_time_raw, end_time_raw - ) + 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"} - # Fetch DRM configs — catchup and live use different manager methods - if is_catchup: - drm_configs = manager.get_catchup_drm_configs( + 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, @@ -606,11 +533,36 @@ def setup_stream_routes(app, manager, service): epg_id=epg_id, country=country, ) - else: - drm_configs = _get_drm_configs( - content_type, provider, content_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: @@ -618,12 +570,9 @@ def setup_stream_routes(app, manager, service): config.to_dict() if hasattr(config, "to_dict") else config ) - has_clearkey = "org.w3.clearkey" in drm_dict + has_clearkey = "org.w3.clearkey" in drm_dict is_unencrypted = "none" in drm_dict - # ------------------------------------------------------------------ - # ClearKey (software DRM) path - # ------------------------------------------------------------------ if has_clearkey: if not service.media_proxy_url: response.status = 503 @@ -638,81 +587,38 @@ def setup_stream_routes(app, manager, service): response.status = 400 return {"error": "ClearKey DRM found but no key IDs available"} - if is_catchup: - # Delegate to the service layer — it owns all fetch/rewrite - # logic including proxy decisions and segment header injection. - return service.get_decrypted_catchup_manifest( - provider, content_id, keyids, - start_time=start_time_int, - end_time=end_time_int, - epg_id=epg_id, - country=country, - highest_quality_only=highest_quality_only, - ) - else: - return service.get_decrypted_manifest( - provider, content_id, keyids, - highest_quality_only=highest_quality_only, - ) + return service.get_decrypted_manifest( + provider, content_id, keyids, + highest_quality_only=highest_quality_only, + ) + - # ------------------------------------------------------------------ - # Unencrypted path - # ------------------------------------------------------------------ elif is_unencrypted: - if is_catchup: - # For unencrypted catchup we still need the DVR manifest URL — - # route through the same proxy-aware catchup path as _resolve_stream. - if manager.needs_proxy(provider): - return service.get_proxied_catchup_manifest( - provider, content_id, - start_time_int, end_time_int, epg_id, country, - drm_variant="auto", - ) - else: - manifest_url = manager.get_catchup_manifest( - provider_name=provider, - channel_id=content_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 "{content_id}"' - } - logger.debug( - f"_resolve_decrypted_stream: redirecting to unencrypted " - f"catchup manifest: {manifest_url}" - ) - return redirect(manifest_url) + # 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: - needs_headers = _stream_needs_headers(content_type, provider, content_id, country) - needs_proxy = manager.needs_proxy(provider) + 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) - 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) - - # ------------------------------------------------------------------ - # No supported DRM scheme - # ------------------------------------------------------------------ else: response.status = 400 return { @@ -726,7 +632,7 @@ def setup_stream_routes(app, manager, service): raise except ValueError as e: logger.error(f"API Error in decrypted {content_type} stream: {e}") - response.status = 400 + response.status = 404 return {"error": str(e)} except Exception as e: logger.error(f"API Error in decrypted {content_type} stream: {e}") @@ -867,19 +773,15 @@ def setup_stream_routes(app, manager, service): 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_raw = ( - request.query.get("start_time") - or request.query.get("start") - or request.query.get("utc") - ) - end_time_raw = request.query.get("end_time") or request.query.get("end") - epg_id = request.query.get("epg_id") - country = request.query.get("country") - is_catchup = bool(start_time_raw and end_time_raw) + 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_raw!r} end_time={end_time_raw!r} " + 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}" ) @@ -888,18 +790,55 @@ def setup_stream_routes(app, manager, service): # 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 + end_time_int: int | None = None if is_catchup: - # _validate_catchup_params raises ValueError with a human-readable - # message; the except block below converts it to 400/404. - start_time_int, end_time_int = _validate_catchup_params( - provider, channel_id, start_time_raw, end_time_raw - ) + 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: validated OK: {start_time_int} to {end_time_int}" + 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, @@ -915,7 +854,7 @@ def setup_stream_routes(app, manager, service): 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 = 400 + response.status = 404 return {"error": str(e)} except Exception as e: label = "sw-drm " if drm_variant == "software" else "" diff --git a/service.py b/service.py index 4f1a771..bef0cd9 100644 --- a/service.py +++ b/service.py @@ -393,65 +393,6 @@ class UltimateService: return manifest_response.text, ttl, provider_proxy_url, segment_headers, manifest_response.url - def _fetch_and_rewrite_catchup_manifest( - self, - provider: str, - channel_id: str, - manifest_url: str, - start_time: int, - keyids: Optional[dict] = None, - highest_quality_only: bool = False, - receiver_side: bool = False, - segment_headers: dict = None, - ) -> tuple: - """ - Core catchup manifest fetcher and rewriter. - Shared between proxied and decrypted catchup paths. - - Args: - provider: Provider name - channel_id: Channel ID - manifest_url: Upstream catchup manifest URL - start_time: Requested start time (Unix timestamp) - used for Magenta2 adjustment - keyids: None for proxied (strip DRM), dict for decrypted with ClearKey - highest_quality_only: If True, keep only highest quality video representation - receiver_side: If True, inject ClearKey signaling for receiver-side decryption - segment_headers: Optional headers for segment requests - - Returns: - (rewritten_mpd, ttl) - """ - manifest_text, ttl, provider_proxy_url, fetched_segment_headers, effective_url = \ - self.fetch_manifest_for_rewriter(provider, channel_id, manifest_url) - - # Use provided segment_headers if given, otherwise use fetched ones - final_segment_headers = segment_headers or fetched_segment_headers - - # ==================================================================== - # MAGENTA2 TIMELINE ADJUSTMENT - # ==================================================================== - if provider.lower() == "magenta2" and start_time: - from streaming_providers.providers.magenta2.catchup_adjuster import Magenta2CatchupAdjuster - manifest_text = Magenta2CatchupAdjuster.adjust(manifest_text, start_time) - ttl = min(ttl, 300) # Shorter TTL for adjusted manifests - - # ==================================================================== - # STANDARD PROXY/DECRYPT REWRITING - # ==================================================================== - rewriter = MPDRewriter( - self.media_proxy_url, - provider_proxy_url, - keyids, # None for proxy, dict for decryption - highest_quality_only, - provider=provider, - channel=channel_id, - clearkey_receiver_side=receiver_side, - segment_headers=final_segment_headers, - ) - rewritten_mpd = rewriter.rewrite_mpd(manifest_text, effective_url) - - return rewritten_mpd, ttl - def get_decrypted_manifest( self, provider: str, channel_id: str, keyids: dict, highest_quality_only: bool = False, receiver_side: bool = False, @@ -533,13 +474,12 @@ class UltimateService: end_time: int, epg_id: str = None, country: str = None, - drm_variant: str = "auto", ) -> str: """ Get proxied and rewritten MPD manifest for catchup content using media proxy. + Similar to get_proxied_manifest but for catchup streams. """ - # Use start_time for cache key (requested_start_time is same value, but start_time is guaranteed) - cache_key = f"{channel_id}_catchup_start_{start_time}" if provider.lower() == "magenta2" else f"{channel_id}_catchup_{start_time}_{end_time}" + cache_key = f"{channel_id}_catchup_{start_time}_{end_time}" cached_mpd = self.mpd_cache.get(provider, cache_key) if cached_mpd: @@ -560,7 +500,6 @@ class UltimateService: end_time=end_time, epg_id=epg_id, country=country, - drm_variant=drm_variant, ) if not manifest_url: response.status = 404 @@ -568,16 +507,21 @@ class UltimateService: return json.dumps({"error": "Catchup manifest not available"}) try: - rewritten_mpd, ttl = self._fetch_and_rewrite_catchup_manifest( - provider=provider, - channel_id=channel_id, - manifest_url=manifest_url, - start_time=start_time, - keyids=None, # Proxied mode - strip DRM - highest_quality_only=False, - receiver_side=False, + manifest_text, ttl, provider_proxy_url, segment_headers, effective_url = self.fetch_manifest_for_rewriter( + provider, channel_id, manifest_url ) + rewriter = MPDRewriter( + self.media_proxy_url, + provider_proxy_url, + None, # No keyids for catchup streams + False, # highest_quality_only — not needed for catchup + provider=provider, + channel=channel_id, + segment_headers=segment_headers, + ) + rewritten_mpd = rewriter.rewrite_mpd(manifest_text, effective_url) + self.mpd_cache.set( provider=provider, channel_id=cache_key, @@ -604,29 +548,38 @@ class UltimateService: self, provider: str, channel_id: str, - keyids: dict, start_time: int, end_time: int, + keyids: dict, epg_id: str = None, - country: str = None, highest_quality_only: bool = False, - receiver_side: bool = True, - drm_variant: str = "software", ) -> str: """ - Get decrypted catchup manifest with ClearKey. - Uses the same core fetch/rewrite logic as proxied catchup. + Get a server-side-decrypted MPD manifest for catchup/DVR playback. + + Mirrors get_decrypted_manifest() but fetches the catchup manifest URL + (via manager.get_catchup_manifest) rather than the live channel URL. + receiver_side is always False — the /decrypted/ endpoint contract is + that the server performs decryption, never the client. + + Args: + provider: Provider name. + channel_id: Channel ID. + start_time: Catchup window start (Unix timestamp). + end_time: Catchup window end (Unix timestamp). + keyids: dict of {kid: key} ClearKey pairs. + epg_id: Optional EPG programme ID forwarded to the + provider for asset-based catchup resolution. + highest_quality_only: If True, strip all but the highest-quality + video representation from the manifest. """ + country = request.query.get("country") + logger.info( f"Generating decrypted catchup manifest for {provider}/{channel_id} " - f"({start_time} to {end_time})" + f"(start={start_time} end={end_time} highest_quality_only={highest_quality_only})" ) - if not self.media_proxy_url: - response.status = 503 - response.content_type = "application/json" - return json.dumps({"error": "Media proxy not configured (MEDIA_PROXY_URL not set)"}) - manifest_url = self.manager.get_catchup_manifest( provider_name=provider, channel_id=channel_id, @@ -634,23 +587,29 @@ class UltimateService: end_time=end_time, epg_id=epg_id, country=country, - drm_variant=drm_variant, ) if not manifest_url: response.status = 404 response.content_type = "application/json" - return json.dumps({"error": "Catchup manifest not available"}) + return json.dumps( + {"error": f'Catchup manifest not available for channel "{channel_id}" from provider "{provider}"'} + ) try: - rewritten_mpd, _ = self._fetch_and_rewrite_catchup_manifest( + manifest_text, _ttl, provider_proxy_url, segment_headers, effective_url = \ + self.fetch_manifest_for_rewriter(provider, channel_id, manifest_url) + + rewriter = MPDRewriter( + self.media_proxy_url, + provider_proxy_url, + keyids, + highest_quality_only, provider=provider, - channel_id=channel_id, - manifest_url=manifest_url, - start_time=start_time, - keyids=keyids, # Decrypted mode - inject ClearKey - highest_quality_only=highest_quality_only, - receiver_side=receiver_side, + channel=channel_id, + clearkey_receiver_side=False, # server decrypts — never inject ClearKey CP + segment_headers=segment_headers, ) + rewritten_mpd = rewriter.rewrite_mpd(manifest_text, effective_url) response.content_type = "application/dash+xml; charset=utf-8" return rewritten_mpd @@ -661,7 +620,7 @@ class UltimateService: response.content_type = "application/json" return json.dumps({"error": str(e)}) except Exception as fetch_err: - logger.error(f"Failed to fetch decrypted catchup manifest: {fetch_err}") + logger.error(f"Failed to fetch catchup manifest for decryption: {fetch_err}") response.status = 502 response.content_type = "application/json" return json.dumps({"error": f"Failed to fetch manifest: {str(fetch_err)}"}) @@ -822,16 +781,15 @@ class UltimateService: provider_label = provider_name # Get catchup information from channel - catchup_window = getattr(channel, 'catchup_hours', 0) # in hours - catchup_type = getattr(channel, 'catchup_type', 'append') - catchup_source = getattr(channel, 'catchup_source', '?start_time={utc}&end_time={utcend}') + catchup_window = getattr(channel, 'catchup_window', 0) # in hours + catchup_source = getattr(channel, 'catchup_source', 'default') # Build EXTINF line with catchup tags if available import math if catchup_window > 0: # Convert hours to days (ceil to ensure full coverage) catchup_days = math.ceil(catchup_window / 24) - entry_content += f'#EXTINF:-1 tvg-id="{channel_id}"{epg_id_attr}{chno} tvg-logo="{channel_logo}" group-title="{provider_label}" catchup="{catchup_type}" catchup-days="{catchup_days}" catchup-source="{catchup_source}",{channel_name}\n' + entry_content += f'#EXTINF:-1 tvg-id="{channel_id}"{epg_id_attr}{chno} tvg-logo="{channel_logo}" group-title="{provider_label}" catchup="{catchup_source}" catchup-days="{catchup_days}",{channel_name}\n' else: entry_content += f'#EXTINF:-1 tvg-id="{channel_id}"{epg_id_attr}{chno} tvg-logo="{channel_logo}" group-title="{provider_label}",{channel_name}\n' @@ -896,16 +854,15 @@ class UltimateService: stream_url = f"{base_url}/api/providers/{provider_name}/channels/{channel_id}/stream/decrypted/index.mpd" # Get catchup information from channel - catchup_window = getattr(channel, 'catchup_hours', 0) # in hours - catchup_type = getattr(channel, 'catchup_type', 'append') - catchup_source = getattr(channel, 'catchup_source', '?start_time={utc}&end_time={utcend}') + catchup_window = getattr(channel, 'catchup_window', 0) # in hours + catchup_source = getattr(channel, 'catchup_source', 'default') import math # Build EXTINF line with catchup tags if available if catchup_window > 0: # Convert hours to days (ceil to ensure full coverage) catchup_days = math.ceil(catchup_window / 24) - entry_content += f'#EXTINF:-1 tvg-id="{channel_id}"{epg_id_attr}{chno} tvg-logo="{channel_logo}" group-title="{provider_label}" catchup="{catchup_type}" catchup-days="{catchup_days}" catchup-source="{catchup_source},{channel_name}\n' + entry_content += f'#EXTINF:-1 tvg-id="{channel_id}"{epg_id_attr}{chno} tvg-logo="{channel_logo}" group-title="{provider_label}" catchup="{catchup_source}" catchup-days="{catchup_days}",{channel_name}\n' else: entry_content += f'#EXTINF:-1 tvg-id="{channel_id}"{epg_id_attr}{chno} tvg-logo="{channel_logo}" group-title="{provider_label}",{channel_name}\n' @@ -983,15 +940,14 @@ class UltimateService: stream_url = f"{base_url}/api/providers/{provider_name}/channels/{channel_id}/stream/decrypted/index.mpd" # Get catchup information - catchup_window = getattr(channel, 'catchup_hours', 0) - catchup_source = getattr(channel, 'catchup_source', '?start_time={utc}&end_time={utcend}') - catchup_type = getattr(channel, 'catchup_type', 'append') + catchup_window = getattr(channel, 'catchup_window', 0) + catchup_source = getattr(channel, 'catchup_source', 'default') import math # Add M3U entry with catchup tags if available if catchup_window > 0: catchup_days = math.ceil(catchup_window / 24) - m3u_content += f'#EXTINF:-1 tvg-id="{channel_id}"{epg_id_attr}{chno} tvg-logo="{channel_logo}" group-title="{provider_label}" catchup="{catchup_type}" catchup-days="{catchup_days}" catchup-source="{catchup_source}",{channel_name}\n' + m3u_content += f'#EXTINF:-1 tvg-id="{channel_id}"{epg_id_attr}{chno} tvg-logo="{channel_logo}" group-title="{provider_label}" catchup="{catchup_source}" catchup-days="{catchup_days}",{channel_name}\n' else: m3u_content += f'#EXTINF:-1 tvg-id="{channel_id}"{epg_id_attr}{chno} tvg-logo="{channel_logo}" group-title="{provider_label}",{channel_name}\n'