mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-10-18 22:04:02 +02:00
fix catchup
This commit is contained in:
+140
-201
@@ -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
@@ -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'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user