mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-09-17 06:32:30 +02:00
958 lines
42 KiB
Python
958 lines
42 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Stream route shared helpers and orchestration.
|
|
|
|
This module contains the core logic that all content types (channels, events,
|
|
VOD, recordings) share. Individual content type modules define only their
|
|
route decorators and call the helpers defined here.
|
|
|
|
Architecture
|
|
============
|
|
All content types share identical transport-level operations: resolve a
|
|
manifest URL, fetch DRM configs, optionally rewrite the manifest through a
|
|
media proxy. The typed route handlers are thin wrappers around two shared
|
|
helpers:
|
|
|
|
_build_drm_header(content_type, provider, content_id, ...)
|
|
Fetches DRM configs via the correct manager method and attaches them as a
|
|
base64-encoded response header. Non-fatal — logs a warning on failure.
|
|
Returns the fetched DRMConfig list (or [] on failure) so callers that
|
|
also need the configs don't have to fetch them a second time.
|
|
|
|
_resolve_stream(content_type, provider, content_id, ...)
|
|
The single place that understands how to turn (type, provider, id) into a
|
|
manifest response — redirect, proxied rewrite, or decrypted rewrite.
|
|
|
|
_resolve_decrypted_stream(content_type, provider, content_id, ...)
|
|
Returns a manifest with server-side decryption (ClearKey keys injected).
|
|
"""
|
|
|
|
import base64
|
|
import json
|
|
import re
|
|
import time
|
|
from urllib.parse import urljoin
|
|
|
|
from bottle import HTTPResponse, redirect, request, response
|
|
from streaming_providers.base.utils import logger
|
|
from streaming_providers.base.utils.manifest_utils import ManifestUtils
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Content-type constants — single source of truth used by helpers and routes
|
|
# ---------------------------------------------------------------------------
|
|
CONTENT_TYPE_CHANNEL = "channel"
|
|
CONTENT_TYPE_EVENT = "event"
|
|
CONTENT_TYPE_VOD = "vod"
|
|
CONTENT_TYPE_RECORDING = "recording"
|
|
|
|
|
|
def setup_stream_routes(app, manager, service):
|
|
"""Register all stream routes from submodules."""
|
|
from .channels import setup_channel_routes
|
|
from .events import setup_event_routes
|
|
from .vod import setup_vod_routes
|
|
from .recordings import setup_recording_routes
|
|
|
|
# Build the shared helpers once and hand the same dict to every submodule,
|
|
# rather than each submodule creating its own independent closure set.
|
|
helpers = make_helpers(manager, service)
|
|
|
|
setup_channel_routes(app, manager, service, helpers)
|
|
setup_event_routes(app, manager, service, helpers)
|
|
setup_vod_routes(app, manager, service, helpers)
|
|
setup_recording_routes(app, manager, service, helpers)
|
|
|
|
# =========================================================================
|
|
# ORIGINAL MANIFEST ENDPOINT (shared across content types)
|
|
# =========================================================================
|
|
|
|
@app.route("/api/providers/<provider>/<content_type>/<content_id>/manifest/original")
|
|
def get_original_manifest(provider, content_type, content_id):
|
|
"""
|
|
Fetch and return the original manifest content without modifications.
|
|
The server fetches the manifest and returns it as-is.
|
|
|
|
Supported content_type values:
|
|
- channels
|
|
- events
|
|
- vod
|
|
- recordings
|
|
|
|
Example:
|
|
GET /api/providers/rtlplus/channels/123/manifest/original
|
|
GET /api/providers/discovery_de/vod/movie123/manifest/original
|
|
"""
|
|
try:
|
|
country = request.query.get("country")
|
|
|
|
_get_manifest_url = helpers["_get_manifest_url"]
|
|
|
|
# Map content_type string to constant
|
|
content_type_map = {
|
|
"channels": CONTENT_TYPE_CHANNEL,
|
|
"events": CONTENT_TYPE_EVENT,
|
|
"vod": CONTENT_TYPE_VOD,
|
|
"recordings": CONTENT_TYPE_RECORDING,
|
|
}
|
|
|
|
if content_type not in content_type_map:
|
|
response.status = 400
|
|
return {"error": f"Invalid content_type: {content_type}"}
|
|
|
|
ct_const = content_type_map[content_type]
|
|
|
|
# Get the upstream manifest URL
|
|
manifest_url = _get_manifest_url(
|
|
ct_const, provider, content_id, country=country
|
|
)
|
|
|
|
if not manifest_url:
|
|
response.status = 404
|
|
return {"error": f'Manifest not available for {content_type} "{content_id}" from provider "{provider}"'}
|
|
|
|
# Use service.fetch_manifest_for_rewriter to fetch manifest
|
|
manifest_text, _, _, _, _ = service.fetch_manifest_for_rewriter(
|
|
provider, content_id, manifest_url
|
|
)
|
|
|
|
# Return unmodified manifest
|
|
response.content_type = "application/dash+xml; charset=utf-8"
|
|
return manifest_text
|
|
|
|
except ValueError as e:
|
|
logger.error(f"Original manifest error for {provider}/{content_type}/{content_id}: {e}")
|
|
response.status = 404
|
|
return {"error": str(e)}
|
|
except Exception as e:
|
|
logger.error(f"Original manifest error for {provider}/{content_type}/{content_id}: {e}")
|
|
response.status = 500
|
|
return {"error": f"Failed to fetch manifest: {str(e)}"}
|
|
|
|
|
|
def _serialize_drm_configs(drm_configs) -> dict:
|
|
"""
|
|
Merge a list of DRMConfig objects (or plain dicts) into a single dict,
|
|
keyed by DRM scheme (e.g. 'org.w3.clearkey', 'none').
|
|
|
|
Shared by every call site that needs to turn a list of DRM configs into
|
|
the merged dict shape used for the response header and for ClearKey
|
|
keyid lookups.
|
|
"""
|
|
merged = {}
|
|
for config in drm_configs:
|
|
config_dict = config.to_dict() if hasattr(config, "to_dict") else config
|
|
merged.update(config_dict)
|
|
return merged
|
|
|
|
|
|
def make_helpers(manager, service):
|
|
"""
|
|
Factory function that creates helper functions with manager and service
|
|
closures. Returns a dict of helpers for submodules to use.
|
|
"""
|
|
|
|
# =========================================================================
|
|
# INTERNAL HELPERS (with manager and service closed over)
|
|
# =========================================================================
|
|
|
|
def _get_drm_configs(
|
|
content_type: str,
|
|
provider: str,
|
|
content_id: str,
|
|
drm_variant: str = "auto",
|
|
**kwargs,
|
|
):
|
|
"""
|
|
Dispatch DRM config retrieval to the correct manager method based on
|
|
content type. Returns a list of DRMConfig objects (may be empty).
|
|
Raises ValueError for unknown provider; re-raises other exceptions.
|
|
|
|
Args:
|
|
drm_variant: 'auto' (provider default) or 'software' (prefer ClearKey).
|
|
Passed through to the provider via **kwargs so individual
|
|
providers can select an appropriate DRM scheme.
|
|
"""
|
|
kwargs.setdefault("drm_variant", drm_variant)
|
|
if content_type == CONTENT_TYPE_CHANNEL:
|
|
return manager.get_channel_drm_configs(
|
|
provider_name=provider, channel_id=content_id, **kwargs
|
|
)
|
|
elif content_type == CONTENT_TYPE_EVENT:
|
|
return manager.get_event_drm_configs(
|
|
provider_name=provider, event_id=content_id, **kwargs
|
|
)
|
|
elif content_type == CONTENT_TYPE_VOD:
|
|
return manager.get_vod_drm_configs(
|
|
provider_name=provider, vod_id=content_id, **kwargs
|
|
)
|
|
elif content_type == CONTENT_TYPE_RECORDING:
|
|
return manager.get_recording_drm_configs(
|
|
provider_name=provider, recording_id=content_id, **kwargs
|
|
)
|
|
else:
|
|
raise ValueError(f"Unknown content_type '{content_type}'")
|
|
|
|
def _get_manifest_url(
|
|
content_type: str,
|
|
provider: str,
|
|
content_id: str,
|
|
drm_variant: str = "auto",
|
|
**kwargs,
|
|
) -> str:
|
|
"""
|
|
Dispatch manifest URL retrieval to the correct manager method.
|
|
Returns the raw upstream manifest URL (before any proxy rewriting).
|
|
|
|
Args:
|
|
drm_variant: 'auto' or 'software'. Passed through so providers that
|
|
expose separate ClearKey manifest URLs can return the
|
|
correct one.
|
|
"""
|
|
kwargs.setdefault("drm_variant", drm_variant)
|
|
if content_type == CONTENT_TYPE_CHANNEL:
|
|
return manager.get_channel_manifest(
|
|
provider_name=provider, channel_id=content_id, **kwargs
|
|
)
|
|
elif content_type == CONTENT_TYPE_EVENT:
|
|
return manager.get_event_manifest(
|
|
provider_name=provider, event_id=content_id, **kwargs
|
|
)
|
|
elif content_type == CONTENT_TYPE_VOD:
|
|
return manager.get_vod_manifest(
|
|
provider_name=provider, vod_id=content_id, **kwargs
|
|
)
|
|
elif content_type == CONTENT_TYPE_RECORDING:
|
|
return manager.get_recording_manifest(
|
|
provider_name=provider, recording_id=content_id, **kwargs
|
|
)
|
|
else:
|
|
raise ValueError(f"Unknown content_type '{content_type}'")
|
|
|
|
def _build_drm_header(
|
|
content_type: str,
|
|
provider: str,
|
|
content_id: str,
|
|
country=None,
|
|
# catchup-specific — only used for channels
|
|
is_catchup: bool = False,
|
|
start_time: int = None,
|
|
end_time: int = None,
|
|
epg_id: str = None,
|
|
drm_variant: str = "auto",
|
|
):
|
|
"""
|
|
Fetch DRM configs, serialise to JSON and attach as a base64-encoded
|
|
response header (x-kodi-drm-configs). Non-fatal: logs a warning and
|
|
returns [] on any error.
|
|
|
|
Args:
|
|
drm_variant: 'auto' or 'software'. Forwarded to the DRM config
|
|
fetch so the provider can return ClearKey configs when
|
|
the software-DRM variant is requested.
|
|
|
|
Returns:
|
|
The list of DRMConfig objects that were fetched (or [] on failure).
|
|
Callers that also need the raw configs (e.g. _resolve_stream) can
|
|
reuse this instead of fetching them again.
|
|
"""
|
|
try:
|
|
if is_catchup and content_type == CONTENT_TYPE_CHANNEL:
|
|
drm_configs = manager.get_catchup_drm_configs(
|
|
provider_name=provider,
|
|
channel_id=content_id,
|
|
start_time=start_time,
|
|
end_time=end_time,
|
|
epg_id=epg_id,
|
|
country=country,
|
|
drm_variant=drm_variant,
|
|
)
|
|
else:
|
|
drm_configs = _get_drm_configs(
|
|
content_type, provider, content_id,
|
|
country=country, drm_variant=drm_variant,
|
|
)
|
|
|
|
merged = _serialize_drm_configs(drm_configs)
|
|
|
|
encoded = base64.b64encode(
|
|
json.dumps(merged).encode("utf-8")
|
|
).decode("ascii")
|
|
|
|
response.headers["x-kodi-drm-configs"] = encoded
|
|
return drm_configs
|
|
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"Could not build x-kodi-drm-configs header for "
|
|
f"{content_type} {provider}/{content_id} (variant={drm_variant}): {e}"
|
|
)
|
|
return []
|
|
|
|
def _build_stream_headers(
|
|
content_type: str,
|
|
provider: str,
|
|
content_id: str,
|
|
country=None,
|
|
# catchup-specific — only used for channels
|
|
is_catchup: bool = False,
|
|
start_time: int = None,
|
|
end_time: int = None,
|
|
epg_id: str = None,
|
|
):
|
|
"""
|
|
Fetch manifest and segment headers from the provider, serialise to JSON
|
|
and attach as a base64-encoded response header (x-kodi-stream-headers).
|
|
Non-fatal: logs a warning and returns None on any error.
|
|
|
|
Payload structure:
|
|
{
|
|
"manifest": { <header-name>: <value>, ... },
|
|
"segment": { <header-name>: <value>, ... }
|
|
}
|
|
Both keys are always present (empty dict if the provider returns nothing).
|
|
"""
|
|
try:
|
|
provider_instance = manager.get_provider(provider)
|
|
if not provider_instance:
|
|
return None
|
|
|
|
kwargs = {}
|
|
if country:
|
|
kwargs["country"] = country
|
|
if is_catchup and content_type == CONTENT_TYPE_CHANNEL:
|
|
kwargs.update(
|
|
start_time=start_time,
|
|
end_time=end_time,
|
|
epg_id=epg_id,
|
|
)
|
|
|
|
manifest_headers = provider_instance.get_manifest_headers(content_id, **kwargs)
|
|
segment_headers = provider_instance.get_segment_headers(content_id, **kwargs)
|
|
|
|
payload = {
|
|
"manifest": manifest_headers or {},
|
|
"segment": segment_headers or {},
|
|
}
|
|
|
|
encoded = base64.b64encode(
|
|
json.dumps(payload).encode("utf-8")
|
|
).decode("ascii")
|
|
|
|
response.headers["x-kodi-stream-headers"] = encoded
|
|
return encoded
|
|
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"Could not build x-kodi-stream-headers header for "
|
|
f"{content_type} {provider}/{content_id}: {e}"
|
|
)
|
|
return None
|
|
|
|
def _stream_needs_headers(
|
|
content_type: str,
|
|
provider: str,
|
|
content_id: str,
|
|
country=None,
|
|
# catchup-specific — forwarded so providers can return catchup-appropriate headers
|
|
is_catchup: bool = False,
|
|
start_time: int = None,
|
|
end_time: int = None,
|
|
epg_id: str = None,
|
|
) -> bool:
|
|
"""
|
|
Returns True if the provider requires manifest or segment headers for
|
|
this content — meaning a plain redirect would lose those headers and
|
|
playback would likely fail.
|
|
|
|
Catchup context (is_catchup, start_time, end_time, epg_id) is forwarded
|
|
to the provider so it can return headers appropriate for the DVR/catchup
|
|
endpoint rather than the live endpoint. Without this, providers that use
|
|
different auth tokens for catchup would return live headers here and the
|
|
proxy decision could be wrong.
|
|
"""
|
|
try:
|
|
provider_instance = manager.get_provider(provider)
|
|
if not provider_instance:
|
|
return False
|
|
kwargs = {}
|
|
if country:
|
|
kwargs["country"] = country
|
|
if is_catchup and content_type == CONTENT_TYPE_CHANNEL:
|
|
kwargs.update(
|
|
start_time=start_time,
|
|
end_time=end_time,
|
|
epg_id=epg_id,
|
|
)
|
|
manifest_headers = provider_instance.get_manifest_headers(content_id, **kwargs) or {}
|
|
segment_headers = provider_instance.get_segment_headers(content_id, **kwargs) or {}
|
|
return bool(manifest_headers or segment_headers)
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"Could not check stream headers for {content_type} {provider}/{content_id}: {e}"
|
|
)
|
|
return False # assume no headers needed; let the redirect attempt proceed
|
|
|
|
def _inject_base_url(manifest_text: str, manifest_url: str) -> str:
|
|
"""Inject BaseURL element into MPD if none exists."""
|
|
existing = ManifestUtils.extract_base_urls(manifest_text)
|
|
if existing:
|
|
return manifest_text
|
|
|
|
# Strip the filename, keep the directory — e.g.
|
|
# https://cdn.example.com/live/stream/index.mpd
|
|
# → https://cdn.example.com/live/stream/
|
|
base_url = urljoin(manifest_url, ".")
|
|
|
|
base_url_element = f"<BaseURL>{base_url}</BaseURL>"
|
|
return re.sub(
|
|
r"(<MPD\b[^>]*>)",
|
|
rf"\1\n {base_url_element}",
|
|
manifest_text,
|
|
count=1,
|
|
)
|
|
|
|
def _validate_catchup_window(provider: str, content_id: str, start_time: int):
|
|
"""
|
|
Checks start_time against the channel's catchup_hours window.
|
|
Returns an error dict (suitable for a 400 response) if the channel
|
|
doesn't support catchup or the window has been exceeded; None if valid.
|
|
|
|
Extracted from channels.py's _handle_channel_stream so
|
|
_resolve_decrypted_stream can apply the same check — previously it
|
|
skipped this validation entirely, letting catchup requests outside the
|
|
provider's DVR window through to the decrypted-stream path when the
|
|
standard path would reject them with a 400.
|
|
"""
|
|
channels = manager.get_channels(provider_name=provider, fetch_manifests=False)
|
|
channel_obj = next((c for c in channels if c.channel_id == content_id), None)
|
|
|
|
catchup_hours = (
|
|
getattr(channel_obj, "catchup_hours", None)
|
|
or getattr(channel_obj, "catchup_window", 0)
|
|
) if channel_obj else 0
|
|
|
|
if not catchup_hours:
|
|
logger.warning(
|
|
f"CATCHUP: rejecting {provider}/{content_id} — "
|
|
f"catchup_hours=0 or attribute not found on channel model"
|
|
)
|
|
return {"error": f'Catchup not supported for channel "{content_id}"'}
|
|
|
|
age_seconds = int(time.time()) - start_time
|
|
if age_seconds > catchup_hours * 3600:
|
|
return {"error": f"Content outside catchup window (max {catchup_hours} hours)"}
|
|
|
|
return None
|
|
|
|
def _redirect_or_fetch(content_type: str, provider: str, content_id: str,
|
|
country, drm_variant: str):
|
|
"""
|
|
Shared tail behavior for every "no proxy involved" outcome: check
|
|
requires_manifest_context and either fetch + inject BaseURL, or do a
|
|
plain redirect. Extracted so no_proxy=True, receiver_side=True's
|
|
non-proxied branch, and receiver_side=False's unencrypted/no-proxy-needed branch all
|
|
get identical, correct behavior instead of three independently
|
|
maintained copies (previously the playable path had none of this at
|
|
all and always redirected unconditionally).
|
|
"""
|
|
provider_instance = manager.get_provider(provider)
|
|
|
|
if provider_instance is None:
|
|
logger.warning(
|
|
f"_redirect_or_fetch: manager.get_provider('{provider}') returned None — "
|
|
"cannot check requires_manifest_context; falling back to redirect"
|
|
)
|
|
|
|
if provider_instance and getattr(provider_instance, 'requires_manifest_context', False):
|
|
logger.debug(
|
|
f"Provider {provider} requires manifest context — fetching manifest directly "
|
|
f"for {content_type}/{content_id}"
|
|
)
|
|
try:
|
|
manifest_url = _get_manifest_url(
|
|
content_type, provider, content_id,
|
|
country=country, drm_variant=drm_variant,
|
|
)
|
|
if not manifest_url:
|
|
response.status = 404
|
|
return {
|
|
"error": (
|
|
f'Manifest not available for {content_type} '
|
|
f'"{content_id}" from provider "{provider}"'
|
|
)
|
|
}
|
|
|
|
manifest_text, _, _, _, effective_url = service.fetch_manifest_for_rewriter(
|
|
provider, content_id, manifest_url
|
|
)
|
|
# Inject the upstream manifest URL as a BaseURL so the player can
|
|
# resolve relative segment URLs correctly. Without this, segments
|
|
# resolve against the local server URL and all requests fail.
|
|
manifest_text = _inject_base_url(manifest_text, effective_url)
|
|
|
|
response.content_type = "application/dash+xml; charset=utf-8"
|
|
return manifest_text
|
|
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Failed to fetch manifest for {content_type}/{content_id} "
|
|
f"from {provider}: {e}"
|
|
)
|
|
response.status = 502
|
|
return {"error": f"Failed to fetch manifest: {str(e)}"}
|
|
else:
|
|
manifest_url = _get_manifest_url(
|
|
content_type, provider, content_id,
|
|
country=country, drm_variant=drm_variant,
|
|
)
|
|
if not manifest_url:
|
|
response.status = 404
|
|
return {
|
|
"error": (
|
|
f'Manifest not available for {content_type} '
|
|
f'"{content_id}" from provider "{provider}"'
|
|
)
|
|
}
|
|
logger.debug(f"Redirecting to manifest: {manifest_url}")
|
|
return redirect(manifest_url)
|
|
|
|
def _resolve_stream_unified(
|
|
content_type: str,
|
|
provider: str,
|
|
content_id: str,
|
|
country=None,
|
|
# catchup-specific — only used for channels
|
|
is_catchup: bool = False,
|
|
start_time: int = None,
|
|
end_time: int = None,
|
|
epg_id: str = None,
|
|
drm_variant: str = "auto",
|
|
receiver_side: bool = True,
|
|
no_proxy: bool = False,
|
|
highest_quality_only: bool = False,
|
|
):
|
|
"""
|
|
Single resolver for every stream endpoint — replaces the former
|
|
_resolve_stream / _resolve_decrypted_stream split (and the later
|
|
mode="auto"/"playable" split). Both wrapper names still exist below
|
|
for existing call sites.
|
|
|
|
Routing is entirely data-driven: whenever content has ClearKey DRM and
|
|
a media proxy is configured, the proxy is used to inject correct
|
|
ClearKey signaling — this is mandatory regardless of who decrypts,
|
|
because plain proxy passthrough leaves the original manifest's
|
|
ContentProtection untouched (not stripped, but also not necessarily
|
|
correct — some providers' raw manifests don't carry adequate ClearKey
|
|
signaling on their own). receiver_side is the only caller-facing
|
|
choice, and it only controls who does the decrypting, not whether the
|
|
proxy gets involved:
|
|
|
|
Args:
|
|
drm_variant: 'auto' (provider decides) or 'software'. Selects
|
|
which upstream DRM/quality variant to request (e.g. a
|
|
provider's L1 vs L3 Widevine stream) — orthogonal to
|
|
receiver_side.
|
|
receiver_side: True - client decrypts (ClearKey signaled in the
|
|
manifest, receiver-side rewrite). This is
|
|
the /stream/index.mpd contract. Content
|
|
that's encrypted with something other than
|
|
ClearKey (e.g. Widevine-only) is passed
|
|
through via proxy/redirect for the client's
|
|
own DRM stack to handle — this mode never
|
|
rejects content outright.
|
|
False - server decrypts, plaintext segments served
|
|
(the /stream/proxied/ contract). Requires
|
|
MEDIA_PROXY_URL whenever ClearKey is
|
|
present. Content that's neither ClearKey
|
|
nor unencrypted is rejected with a 400,
|
|
since the server has no way to decrypt it
|
|
— applies equally to live/VOD and catchup.
|
|
Unencrypted content is unaffected by this flag
|
|
either way — there's nothing to decrypt, so it
|
|
always takes the same proxy/redirect path.
|
|
no_proxy: Orthogonal override, independent of receiver_side —
|
|
forces a redirect/fetch even for providers or content
|
|
that would normally be proxied, skipping ClearKey
|
|
handling and the software-DRM-availability check
|
|
entirely. Always wins over receiver_side, the same way
|
|
it did as a bare `no_proxy` flag on the pre-merge
|
|
_resolve_stream.
|
|
highest_quality_only: Honored wherever the underlying service call
|
|
supports it (get_proxied_manifest, get_decrypted_manifest,
|
|
get_decrypted_catchup_manifest). get_proxied_catchup_manifest
|
|
has no such parameter today, so it's a no-op for
|
|
catchup content that turns out unencrypted. Meaningless
|
|
when no_proxy=True (plain redirect, no rewriter involved).
|
|
"""
|
|
# --- Catchup window validation (channels only), before anything else —
|
|
# previously duplicated between channels.py (for auto/noproxy) and
|
|
# _resolve_decrypted_stream (for decrypt); now the single source of truth
|
|
# for every mode, so channels.py no longer needs its own copy. ---
|
|
if is_catchup and content_type == CONTENT_TYPE_CHANNEL:
|
|
window_error = _validate_catchup_window(provider, content_id, start_time)
|
|
if window_error:
|
|
response.status = 400
|
|
return window_error
|
|
|
|
# --- DRM header (best-effort, never fatal). Also returns the DRM
|
|
# configs it fetched so the branches below can reuse them instead of
|
|
# fetching from the provider a second time. ---
|
|
drm_configs_for_header = _build_drm_header(
|
|
content_type, provider, content_id,
|
|
country=country,
|
|
is_catchup=is_catchup,
|
|
start_time=start_time,
|
|
end_time=end_time,
|
|
epg_id=epg_id,
|
|
drm_variant=drm_variant,
|
|
)
|
|
# --- Stream headers (best-effort, never fatal) ---
|
|
_build_stream_headers(
|
|
content_type, provider, content_id,
|
|
country=country,
|
|
is_catchup=is_catchup,
|
|
start_time=start_time,
|
|
end_time=end_time,
|
|
epg_id=epg_id,
|
|
)
|
|
|
|
drm_dict = _serialize_drm_configs(drm_configs_for_header)
|
|
keyids = (
|
|
drm_dict.get("org.w3.clearkey", {})
|
|
.get("license", {})
|
|
.get("keyids", {})
|
|
)
|
|
is_unencrypted = "none" in drm_dict
|
|
|
|
# ==================================================================
|
|
# Catchup path (channel-specific)
|
|
# ==================================================================
|
|
if is_catchup:
|
|
if no_proxy:
|
|
manifest_url = manager.get_catchup_manifest(
|
|
provider_name=provider,
|
|
channel_id=content_id,
|
|
start_time=start_time,
|
|
end_time=end_time,
|
|
epg_id=epg_id,
|
|
country=country,
|
|
drm_variant=drm_variant,
|
|
)
|
|
if not manifest_url:
|
|
response.status = 404
|
|
return {
|
|
"error": f'Catchup manifest not available for channel "{content_id}"'
|
|
}
|
|
logger.debug(f"Redirecting to catchup manifest: {manifest_url}")
|
|
return redirect(manifest_url)
|
|
|
|
if keyids:
|
|
# Deliberately NOT gated on manager.needs_proxy(provider) — that
|
|
# flag answers a different question (does this provider's
|
|
# manifest/segment fetching need proxying for header/CORS/token
|
|
# reasons) than "does this content's ClearKey need signaling
|
|
# injected" (a per-content fact, not a per-provider one). A
|
|
# provider can have needs_proxy=False while still returning
|
|
# ClearKey content whose manifest doesn't carry adequate
|
|
# signaling on its own — plain passthrough leaves the original
|
|
# ContentProtection untouched, which isn't reliably sufficient
|
|
# for the client to decrypt. So: ClearKey + a configured proxy
|
|
# → always inject, independent of needs_proxy.
|
|
#
|
|
# receiver_side is a separate axis entirely — it only decides
|
|
# who decrypts (client vs server) once we're already here; it
|
|
# never decides whether to use the proxy. MPDRewriter requires
|
|
# media_proxy_url for the injection regardless of receiver_side
|
|
# (see get_decrypted_catchup_manifest / MPDRewriter — segment
|
|
# URLs route through the proxy either way, only the "proxy" vs
|
|
# "decrypt" endpoint differs).
|
|
if service.media_proxy_url:
|
|
return service.get_decrypted_catchup_manifest(
|
|
provider, content_id,
|
|
start_time=start_time, end_time=end_time,
|
|
keyids=keyids, epg_id=epg_id,
|
|
receiver_side=receiver_side,
|
|
highest_quality_only=highest_quality_only,
|
|
)
|
|
elif receiver_side:
|
|
# No proxy configured, but the client can decrypt on its
|
|
# own — best effort: redirect and hope the raw manifest
|
|
# carries adequate signaling.
|
|
manifest_url = manager.get_catchup_manifest(
|
|
provider_name=provider,
|
|
channel_id=content_id,
|
|
start_time=start_time,
|
|
end_time=end_time,
|
|
epg_id=epg_id,
|
|
country=country,
|
|
drm_variant=drm_variant,
|
|
)
|
|
if not manifest_url:
|
|
response.status = 404
|
|
return {
|
|
"error": f'Catchup manifest not available for channel "{content_id}"'
|
|
}
|
|
return redirect(manifest_url)
|
|
else:
|
|
# Server was supposed to decrypt but has nothing to
|
|
# decrypt with.
|
|
response.status = 503
|
|
return {"error": "Media proxy not configured (MEDIA_PROXY_URL not set)"}
|
|
|
|
elif is_unencrypted:
|
|
if manager.needs_proxy(provider):
|
|
return service.get_proxied_catchup_manifest(
|
|
provider, content_id, start_time, end_time, epg_id, country
|
|
)
|
|
manifest_url = manager.get_catchup_manifest(
|
|
provider_name=provider,
|
|
channel_id=content_id,
|
|
start_time=start_time,
|
|
end_time=end_time,
|
|
epg_id=epg_id,
|
|
country=country,
|
|
drm_variant=drm_variant,
|
|
)
|
|
if not manifest_url:
|
|
response.status = 404
|
|
return {
|
|
"error": f'Catchup manifest not available for channel "{content_id}"'
|
|
}
|
|
logger.debug(f"Redirecting to catchup manifest: {manifest_url}")
|
|
return redirect(manifest_url)
|
|
|
|
else:
|
|
# Encrypted but not ClearKey (e.g. Widevine-only catchup).
|
|
if receiver_side:
|
|
# Client handles its own DRM — same proxy-if-needed/redirect
|
|
# passthrough as the unencrypted case above.
|
|
if manager.needs_proxy(provider):
|
|
return service.get_proxied_catchup_manifest(
|
|
provider, content_id, start_time, end_time, epg_id, country
|
|
)
|
|
manifest_url = manager.get_catchup_manifest(
|
|
provider_name=provider,
|
|
channel_id=content_id,
|
|
start_time=start_time,
|
|
end_time=end_time,
|
|
epg_id=epg_id,
|
|
country=country,
|
|
drm_variant=drm_variant,
|
|
)
|
|
if not manifest_url:
|
|
response.status = 404
|
|
return {
|
|
"error": f'Catchup manifest not available for channel "{content_id}"'
|
|
}
|
|
return redirect(manifest_url)
|
|
else:
|
|
# Server can't decrypt non-ClearKey schemes — honest error
|
|
# rather than silently proxying content the client likely
|
|
# can't play anyway.
|
|
response.status = 400
|
|
return {
|
|
"error": (
|
|
f'Catchup content for channel "{content_id}" does not support '
|
|
f"decrypted playback (requires ClearKey or unencrypted)"
|
|
)
|
|
}
|
|
|
|
# ==================================================================
|
|
# Live / event / vod / recording path
|
|
# ==================================================================
|
|
if no_proxy:
|
|
return _redirect_or_fetch(content_type, provider, content_id, country, drm_variant)
|
|
|
|
if keyids:
|
|
# Deliberately NOT gated on manager.needs_proxy(provider) — that
|
|
# flag answers a different question (does this provider's
|
|
# manifest/segment fetching need proxying for header/CORS/token
|
|
# reasons) than "does this content's ClearKey need signaling
|
|
# injected" (a per-content fact, not a per-provider one). A
|
|
# provider can have needs_proxy=False while still returning
|
|
# ClearKey content whose manifest doesn't carry adequate signaling
|
|
# on its own — plain passthrough leaves the original
|
|
# ContentProtection untouched, which isn't reliably sufficient for
|
|
# the client to decrypt. So: ClearKey + a configured proxy →
|
|
# always inject, independent of needs_proxy.
|
|
#
|
|
# receiver_side is a separate axis entirely — it only decides who
|
|
# decrypts (client vs server) once we're already here; it never
|
|
# decides whether to use the proxy. MPDRewriter requires
|
|
# media_proxy_url for the injection regardless of receiver_side
|
|
# (see get_decrypted_manifest / MPDRewriter — segment URLs route
|
|
# through the proxy either way, only the "proxy" vs "decrypt"
|
|
# endpoint differs).
|
|
if service.media_proxy_url:
|
|
logger.debug(
|
|
f"ClearKey DRM detected for {provider}/{content_id} "
|
|
f"(variant={drm_variant}) — using "
|
|
f"{'receiver-side' if receiver_side else 'server-side'} rewrite"
|
|
)
|
|
return service.get_decrypted_manifest(
|
|
provider, content_id, keyids,
|
|
receiver_side=receiver_side,
|
|
drm_variant=drm_variant,
|
|
highest_quality_only=highest_quality_only,
|
|
)
|
|
elif receiver_side:
|
|
# No proxy configured, but the client can decrypt on its own —
|
|
# best effort: redirect/fetch and hope the raw manifest
|
|
# carries adequate signaling.
|
|
return _redirect_or_fetch(content_type, provider, content_id, country, drm_variant)
|
|
else:
|
|
# Server was supposed to decrypt but has nothing to decrypt with.
|
|
response.status = 503
|
|
return {"error": "Media proxy not configured (MEDIA_PROXY_URL not set)"}
|
|
|
|
elif is_unencrypted:
|
|
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)"
|
|
)
|
|
return _redirect_or_fetch(content_type, provider, content_id, country, drm_variant)
|
|
else:
|
|
return _redirect_or_fetch(content_type, provider, content_id, country, drm_variant)
|
|
|
|
else:
|
|
# Encrypted but not ClearKey (e.g. Widevine-only).
|
|
if receiver_side:
|
|
# When the caller explicitly requested software DRM and we found
|
|
# no ClearKey keys, surface a clear error rather than silently
|
|
# serving a Widevine stream the client cannot decrypt.
|
|
if drm_variant == "software":
|
|
logger.warning(
|
|
f"Software DRM requested but no ClearKey keys found for "
|
|
f"{provider}/{content_id}"
|
|
)
|
|
response.status = 400
|
|
return {"error": "Software DRM not available for this content"}
|
|
|
|
# Client handles its own DRM — same proxy-if-needed/redirect
|
|
# passthrough as the unencrypted case above.
|
|
if manager.needs_proxy(provider):
|
|
return service.get_proxied_manifest(
|
|
provider, content_id,
|
|
highest_quality_only=highest_quality_only,
|
|
)
|
|
return _redirect_or_fetch(content_type, provider, content_id, country, drm_variant)
|
|
else:
|
|
# Server can't decrypt non-ClearKey schemes — honest error
|
|
# rather than silently proxying content the client likely
|
|
# can't play anyway.
|
|
response.status = 400
|
|
return {
|
|
"error": (
|
|
f'{content_type.capitalize()} "{content_id}" does not support '
|
|
f"decrypted playback (requires ClearKey or unencrypted)"
|
|
)
|
|
}
|
|
|
|
def _resolve_stream(
|
|
content_type: str,
|
|
provider: str,
|
|
content_id: str,
|
|
country=None,
|
|
is_catchup: bool = False,
|
|
start_time: int = None,
|
|
end_time: int = None,
|
|
epg_id: str = None,
|
|
drm_variant: str = "auto",
|
|
no_proxy: bool = False,
|
|
):
|
|
"""
|
|
Deprecated: thin wrapper around _resolve_stream_unified, kept so
|
|
existing call sites (channels.py, events.py, vod.py, recordings.py)
|
|
don't need to change. New code should call _resolve_stream_unified
|
|
directly with an explicit receiver_side.
|
|
"""
|
|
return _resolve_stream_unified(
|
|
content_type, provider, content_id,
|
|
country=country,
|
|
is_catchup=is_catchup,
|
|
start_time=start_time,
|
|
end_time=end_time,
|
|
epg_id=epg_id,
|
|
drm_variant=drm_variant,
|
|
receiver_side=True,
|
|
no_proxy=no_proxy,
|
|
)
|
|
|
|
def _resolve_decrypted_stream(
|
|
content_type: str,
|
|
provider: str,
|
|
content_id: str,
|
|
highest_quality_only: bool = False,
|
|
):
|
|
"""
|
|
Deprecated: thin wrapper around _resolve_stream_unified, kept so
|
|
existing call sites (channels.py's /stream/proxied/ routes) don't need
|
|
to change. Reads start_time/end_time/epg_id/country from the request
|
|
query string itself, matching the original function's contract — those
|
|
routes never passed catchup args explicitly. New code should call
|
|
_resolve_stream_unified directly with receiver_side=False.
|
|
"""
|
|
try:
|
|
country = request.query.get("country")
|
|
start_time = request.query.get("start_time")
|
|
end_time = request.query.get("end_time")
|
|
epg_id = request.query.get("epg_id")
|
|
is_catchup = bool(start_time and end_time and content_type == CONTENT_TYPE_CHANNEL)
|
|
|
|
if is_catchup:
|
|
try:
|
|
start_time = int(start_time)
|
|
end_time = int(end_time)
|
|
except (ValueError, TypeError):
|
|
response.status = 400
|
|
return {"error": "Invalid start_time or end_time format"}
|
|
|
|
return _resolve_stream_unified(
|
|
content_type, provider, content_id,
|
|
country=country,
|
|
is_catchup=is_catchup,
|
|
start_time=start_time,
|
|
end_time=end_time,
|
|
epg_id=epg_id,
|
|
receiver_side=False,
|
|
highest_quality_only=highest_quality_only,
|
|
)
|
|
|
|
except HTTPResponse:
|
|
raise
|
|
except ValueError as e:
|
|
logger.error(f"API Error in decrypted {content_type} stream: {e}")
|
|
response.status = 404
|
|
return {"error": str(e)}
|
|
except Exception as e:
|
|
logger.error(f"API Error in decrypted {content_type} stream: {e}")
|
|
response.status = 500
|
|
return {"error": f"Internal server error: {str(e)}"}
|
|
|
|
# Return all helpers as a dict for submodules to use
|
|
return {
|
|
"CONTENT_TYPE_CHANNEL": CONTENT_TYPE_CHANNEL,
|
|
"CONTENT_TYPE_EVENT": CONTENT_TYPE_EVENT,
|
|
"CONTENT_TYPE_VOD": CONTENT_TYPE_VOD,
|
|
"CONTENT_TYPE_RECORDING": CONTENT_TYPE_RECORDING,
|
|
"_get_drm_configs": _get_drm_configs,
|
|
"_get_manifest_url": _get_manifest_url,
|
|
"_build_drm_header": _build_drm_header,
|
|
"_build_stream_headers": _build_stream_headers,
|
|
"_stream_needs_headers": _stream_needs_headers,
|
|
"_inject_base_url": _inject_base_url,
|
|
"_validate_catchup_window": _validate_catchup_window,
|
|
"_resolve_stream": _resolve_stream,
|
|
"_resolve_decrypted_stream": _resolve_decrypted_stream,
|
|
"_resolve_stream_unified": _resolve_stream_unified,
|
|
"_serialize_drm_configs": _serialize_drm_configs,
|
|
} |