fix catchup

This commit is contained in:
Nirvana
2026-06-14 21:05:32 +02:00
parent 8671284c6b
commit 02368af251
2 changed files with 201 additions and 306 deletions
+140 -201
View File
@@ -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/<vod_id>/{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 ""
+61 -105
View File
@@ -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'