mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-09-21 00:22:30 +02:00
magenta2 search
This commit is contained in:
@@ -1865,47 +1865,24 @@ class VodManager:
|
||||
return {"entries": entries, "next_cursor": None, "total": len(entries)}
|
||||
|
||||
def _get_search_url_template(self) -> Optional[str]:
|
||||
"""
|
||||
Resolve the search URL template from available sources.
|
||||
|
||||
Priority order:
|
||||
1. endpoint_manager.get_endpoint("search_template") - if available
|
||||
2. provider_config.get_search_url_template() - direct manifest access
|
||||
3. None (fallback logging only - no hardcoded fallback)
|
||||
|
||||
Returns:
|
||||
Raw template string with {clientModel} and {query} placeholders,
|
||||
or None if not found.
|
||||
"""
|
||||
# Priority 1: Try endpoint manager (discovered via manifest)
|
||||
if hasattr(self._provider, 'endpoint_manager'):
|
||||
template = self._provider.endpoint_manager.get_endpoint("search_template")
|
||||
if template:
|
||||
logger.debug(f"{self._provider}: Found search template via endpoint_manager")
|
||||
return template
|
||||
# This won't work because VodManager doesn't have endpoint_manager
|
||||
|
||||
# Priority 2: Try provider config directly
|
||||
if hasattr(self._provider, 'provider_config') and self._provider.provider_config:
|
||||
template = self._provider.provider_config.get_search_url_template()
|
||||
# Priority 2: Try provider config directly (THIS IS THE CORRECT PATH)
|
||||
if self._provider_config:
|
||||
template = self._provider_config.get_search_url_template()
|
||||
if template:
|
||||
logger.debug(f"{self._provider}: Found search template via provider_config")
|
||||
return template
|
||||
|
||||
# Priority 3: Try manifest.tv_hubs.base_urls directly (fallback)
|
||||
if hasattr(self._provider, 'provider_config'):
|
||||
manifest = getattr(self._provider.provider_config, "manifest", None)
|
||||
if manifest:
|
||||
tv_hubs = getattr(manifest, "tv_hubs", None)
|
||||
if tv_hubs:
|
||||
template = tv_hubs.base_urls.get("searchUrl")
|
||||
if template:
|
||||
logger.debug(f"{self._provider}: Found search template via manifest.tv_hubs")
|
||||
return template
|
||||
if self._provider_config and hasattr(self._provider_config, 'manifest') and self._provider_config.manifest:
|
||||
template = self._provider_config.manifest.tv_hubs.base_urls.get("searchUrl")
|
||||
if template:
|
||||
logger.debug(f"{self._provider}: Found search template via manifest.tv_hubs")
|
||||
return template
|
||||
|
||||
logger.warning(
|
||||
f"{self._provider}: Search URL template not found in any source. "
|
||||
"Search functionality will not work."
|
||||
)
|
||||
logger.warning(f"{self._provider}: Search URL template not found in any source.")
|
||||
return None
|
||||
|
||||
def _parse_search_groups(self, data: Dict) -> List[Union[VodCategory, VodItem]]:
|
||||
|
||||
-1401
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,744 @@
|
||||
#!/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.
|
||||
|
||||
_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
|
||||
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
|
||||
from .epg import setup_epg_routes
|
||||
|
||||
# Pass manager and service to all submodules
|
||||
setup_channel_routes(app, manager, service)
|
||||
setup_event_routes(app, manager, service)
|
||||
setup_vod_routes(app, manager, service)
|
||||
setup_recording_routes(app, manager, service)
|
||||
setup_epg_routes(app, manager, service)
|
||||
|
||||
# =========================================================================
|
||||
# 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")
|
||||
|
||||
# Create helpers to use the manager methods
|
||||
helpers = make_helpers(manager, service)
|
||||
_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 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 None 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.
|
||||
"""
|
||||
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 = {}
|
||||
for config in drm_configs:
|
||||
config_dict = config.to_dict() if hasattr(config, "to_dict") else config
|
||||
merged.update(config_dict)
|
||||
|
||||
encoded = base64.b64encode(
|
||||
json.dumps(merged).encode("utf-8")
|
||||
).decode("ascii")
|
||||
|
||||
response.headers["x-kodi-drm-configs"] = encoded
|
||||
return encoded
|
||||
|
||||
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 None
|
||||
|
||||
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 _resolve_stream(
|
||||
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",
|
||||
):
|
||||
"""
|
||||
Core stream resolution: attach DRM header, then either redirect to the
|
||||
upstream manifest or return a proxy-rewritten manifest body.
|
||||
|
||||
Args:
|
||||
drm_variant: 'auto' (provider decides) or 'software' (prefer ClearKey /
|
||||
software-decodable DRM). Threaded through to all helpers so
|
||||
providers can return the correct manifest URL and DRM configs.
|
||||
"""
|
||||
# --- DRM header (best-effort, never fatal) ---
|
||||
_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,
|
||||
)
|
||||
|
||||
# --- Catchup path (channel-specific) ---
|
||||
if is_catchup:
|
||||
if manager.needs_proxy(provider):
|
||||
return service.get_proxied_catchup_manifest(
|
||||
provider, content_id, start_time, end_time, epg_id, country
|
||||
)
|
||||
else:
|
||||
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}")
|
||||
redirect(manifest_url)
|
||||
|
||||
# --- Live / event / vod path ---
|
||||
if manager.needs_proxy(provider):
|
||||
# Check for ClearKey DRM — if present, rewrite manifest with ClearKey signaling
|
||||
# so the receiver can decrypt itself, rather than serving a plain proxy stream
|
||||
# with stripped ContentProtection that the player cannot handle.
|
||||
try:
|
||||
drm_configs = _get_drm_configs(
|
||||
content_type, provider, content_id,
|
||||
country=country, drm_variant=drm_variant,
|
||||
)
|
||||
drm_dict = {}
|
||||
for config in drm_configs:
|
||||
drm_dict.update(
|
||||
config.to_dict() if hasattr(config, "to_dict") else config
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Could not fetch DRM configs for {content_type} "
|
||||
f"{provider}/{content_id} during proxy resolution: {e}"
|
||||
)
|
||||
drm_dict = {}
|
||||
|
||||
keyids = (
|
||||
drm_dict.get("org.w3.clearkey", {})
|
||||
.get("license", {})
|
||||
.get("keyids", {})
|
||||
)
|
||||
|
||||
# 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" and not keyids:
|
||||
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"}
|
||||
|
||||
if keyids:
|
||||
logger.debug(
|
||||
f"ClearKey DRM detected for {provider}/{content_id} "
|
||||
f"(variant={drm_variant}) — using receiver-side ClearKey rewrite"
|
||||
)
|
||||
return service.get_decrypted_manifest(
|
||||
provider, content_id, keyids,
|
||||
receiver_side=True,
|
||||
drm_variant=drm_variant,
|
||||
)
|
||||
else:
|
||||
return service.get_proxied_manifest(provider, content_id)
|
||||
else:
|
||||
# Check if provider requires manifest context (needs HTTP manager to fetch)
|
||||
provider_instance = manager.get_provider(provider)
|
||||
|
||||
if provider_instance is None:
|
||||
logger.warning(
|
||||
f"_resolve_stream: 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_decrypted_stream(
|
||||
content_type: str,
|
||||
provider: str,
|
||||
content_id: str,
|
||||
highest_quality_only: bool = False,
|
||||
):
|
||||
"""
|
||||
Shared handler for decrypted stream endpoints.
|
||||
Resolves DRM, then returns an appropriately rewritten manifest.
|
||||
|
||||
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")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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:
|
||||
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"}
|
||||
|
||||
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,
|
||||
end_time=end_time_int,
|
||||
epg_id=epg_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:
|
||||
drm_dict.update(
|
||||
config.to_dict() if hasattr(config, "to_dict") else config
|
||||
)
|
||||
|
||||
has_clearkey = "org.w3.clearkey" in drm_dict
|
||||
is_unencrypted = "none" in drm_dict
|
||||
|
||||
if has_clearkey:
|
||||
if not service.media_proxy_url:
|
||||
response.status = 503
|
||||
return {"error": "Media proxy not configured (MEDIA_PROXY_URL not set)"}
|
||||
|
||||
keyids = (
|
||||
drm_dict["org.w3.clearkey"]
|
||||
.get("license", {})
|
||||
.get("keyids", {})
|
||||
)
|
||||
if not keyids:
|
||||
response.status = 400
|
||||
return {"error": "ClearKey DRM found but no key IDs available"}
|
||||
|
||||
return service.get_decrypted_manifest(
|
||||
provider, content_id, keyids,
|
||||
highest_quality_only=highest_quality_only,
|
||||
)
|
||||
|
||||
elif is_unencrypted:
|
||||
# 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:
|
||||
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)
|
||||
|
||||
else:
|
||||
response.status = 400
|
||||
return {
|
||||
"error": (
|
||||
f'{content_type.capitalize()} "{content_id}" does not support '
|
||||
f"decrypted playback (requires ClearKey or unencrypted)"
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
"_resolve_stream": _resolve_stream,
|
||||
"_resolve_decrypted_stream": _resolve_decrypted_stream,
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Channel-specific stream routes.
|
||||
|
||||
All channel routes delegate to the shared helpers in __init__.py for actual
|
||||
stream resolution. This module only defines route decorators and the minimal
|
||||
channel-specific catchup handling logic.
|
||||
"""
|
||||
|
||||
import time
|
||||
from bottle import HTTPResponse, request, response
|
||||
from streaming_providers.base.utils import logger
|
||||
|
||||
from . import make_helpers
|
||||
|
||||
|
||||
def setup_channel_routes(app, manager, service):
|
||||
"""Setup all channel-related stream routes."""
|
||||
|
||||
# Create helpers with manager and service closures
|
||||
helpers = make_helpers(manager, service)
|
||||
|
||||
CONTENT_TYPE_CHANNEL = helpers["CONTENT_TYPE_CHANNEL"]
|
||||
_build_drm_header = helpers["_build_drm_header"]
|
||||
_build_stream_headers = helpers["_build_stream_headers"]
|
||||
_resolve_stream = helpers["_resolve_stream"]
|
||||
_resolve_decrypted_stream = helpers["_resolve_decrypted_stream"]
|
||||
_get_drm_configs = helpers["_get_drm_configs"]
|
||||
|
||||
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 = 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!r} end_time={end_time!r} "
|
||||
f"epg_id={epg_id!r} country={country!r} is_catchup={is_catchup} "
|
||||
f"drm_variant={drm_variant}"
|
||||
)
|
||||
|
||||
# Always defined so the _resolve_stream call below is unconditionally safe,
|
||||
# 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
|
||||
|
||||
if is_catchup:
|
||||
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: 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}"'}
|
||||
|
||||
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,
|
||||
is_catchup=is_catchup,
|
||||
start_time=start_time_int if is_catchup else None,
|
||||
end_time=end_time_int if is_catchup else None,
|
||||
epg_id=epg_id if is_catchup else None,
|
||||
drm_variant=drm_variant,
|
||||
)
|
||||
|
||||
except HTTPResponse:
|
||||
raise
|
||||
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 = 404
|
||||
return {"error": str(e)}
|
||||
except Exception as e:
|
||||
label = "sw-drm " if drm_variant == "software" else ""
|
||||
logger.error(f"{label}stream error for channel {provider}/{channel_id}: {e}")
|
||||
response.status = 500
|
||||
return {"error": f"Internal server error: {str(e)}"}
|
||||
|
||||
# =========================================================================
|
||||
# CHANNEL ROUTES
|
||||
# =========================================================================
|
||||
|
||||
@app.route("/api/providers/<provider>/channels/<channel_id>/manifest")
|
||||
def get_channel_manifest(provider, channel_id):
|
||||
"""
|
||||
Returns JSON with a manifest_url pointing to the stream endpoint.
|
||||
Attaches x-kodi-drm-configs header.
|
||||
|
||||
Response includes both stream_url (auto DRM) and sw_drm_stream_url
|
||||
(software / ClearKey DRM) so callers can pick the appropriate variant
|
||||
without a second round-trip.
|
||||
|
||||
Also includes catchup_stream_url_template — a URL with {start_time} and
|
||||
{end_time} placeholders (Unix timestamps) that callers can expand for
|
||||
DVR/catchup playback, avoiding the need to construct the URL manually.
|
||||
"""
|
||||
try:
|
||||
country = request.query.get("country")
|
||||
base_url = f"{request.urlparts.scheme}://{request.urlparts.netloc}"
|
||||
qs = f"?country={country}" if country else ""
|
||||
stream_url = (
|
||||
f"{base_url}/api/providers/{provider}/channels/{channel_id}"
|
||||
f"/stream/index.mpd{qs}"
|
||||
)
|
||||
sw_drm_stream_url = (
|
||||
f"{base_url}/api/providers/{provider}/channels/{channel_id}"
|
||||
f"/stream/sw-drm/index.mpd{qs}"
|
||||
)
|
||||
# Catchup template — callers substitute {start_time}/{end_time} with
|
||||
# Unix timestamps. Matches the query params consumed by _handle_channel_stream.
|
||||
catchup_qs_sep = "&" if qs else "?"
|
||||
catchup_stream_url_template = (
|
||||
f"{base_url}/api/providers/{provider}/channels/{channel_id}"
|
||||
f"/stream/index.mpd{qs}{catchup_qs_sep}"
|
||||
f"start_time={{start_time}}&end_time={{end_time}}"
|
||||
)
|
||||
|
||||
_build_drm_header(CONTENT_TYPE_CHANNEL, provider, channel_id, country=country)
|
||||
_build_stream_headers(CONTENT_TYPE_CHANNEL, provider, channel_id, country=country)
|
||||
|
||||
return {
|
||||
"provider": provider,
|
||||
"channel_id": channel_id,
|
||||
"manifest_url": stream_url,
|
||||
"sw_drm_manifest_url": sw_drm_stream_url,
|
||||
"catchup_stream_url_template": catchup_stream_url_template,
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"manifest endpoint error for {provider}/{channel_id}: {e}")
|
||||
response.status = 404
|
||||
return {"error": str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"manifest endpoint error for {provider}/{channel_id}: {e}")
|
||||
response.status = 500
|
||||
return {"error": f"Internal server error: {str(e)}"}
|
||||
|
||||
@app.route("/api/providers/<provider>/channels/<channel_id>/stream/index.mpd")
|
||||
def get_channel_stream(provider, channel_id):
|
||||
"""Returns HTTP 302 redirect to the actual manifest, or a rewritten
|
||||
manifest body when media proxy is active. Supports live and catchup."""
|
||||
return _handle_channel_stream(provider, channel_id)
|
||||
|
||||
@app.route("/api/providers/<provider>/channels/<channel_id>/stream/sw-drm/index.mpd")
|
||||
def get_channel_stream_sw_drm(provider, channel_id):
|
||||
"""Software-DRM (ClearKey) variant. Identical transport to the standard
|
||||
endpoint; passes drm_variant='software' through to _resolve_stream."""
|
||||
return _handle_channel_stream(provider, channel_id, drm_variant="software")
|
||||
|
||||
@app.route("/api/providers/<provider>/channels/<channel_id>/stream/decrypted/index.mpd")
|
||||
def get_channel_stream_decrypted(provider, channel_id):
|
||||
"""Decrypted stream — all quality representations."""
|
||||
return _resolve_decrypted_stream(
|
||||
CONTENT_TYPE_CHANNEL, provider, channel_id, highest_quality_only=False
|
||||
)
|
||||
|
||||
@app.route(
|
||||
"/api/providers/<provider>/channels/<channel_id>/stream/decrypted/ffmpeg/index.mpd"
|
||||
)
|
||||
def get_channel_stream_decrypted_ffmpeg(provider, channel_id):
|
||||
"""Decrypted stream — highest quality only, optimised for ffmpeg."""
|
||||
return _resolve_decrypted_stream(
|
||||
CONTENT_TYPE_CHANNEL, provider, channel_id, highest_quality_only=True
|
||||
)
|
||||
|
||||
@app.route("/api/providers/<provider>/channels/<channel_id>/drm")
|
||||
def get_channel_drm(provider, channel_id):
|
||||
"""
|
||||
Return DRM configs for a channel. Supports catchup via query params.
|
||||
"""
|
||||
try:
|
||||
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)
|
||||
|
||||
if is_catchup:
|
||||
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"}
|
||||
|
||||
drm_configs = manager.get_catchup_drm_configs(
|
||||
provider_name=provider,
|
||||
channel_id=channel_id,
|
||||
start_time=start_time_int,
|
||||
end_time=end_time_int,
|
||||
epg_id=epg_id,
|
||||
country=country,
|
||||
)
|
||||
else:
|
||||
drm_configs = _get_drm_configs(
|
||||
CONTENT_TYPE_CHANNEL, provider, channel_id, country=country
|
||||
)
|
||||
|
||||
merged = {}
|
||||
for config in drm_configs:
|
||||
merged.update(
|
||||
config.to_dict() if hasattr(config, "to_dict") else config
|
||||
)
|
||||
|
||||
return {
|
||||
"provider": provider,
|
||||
"channel_id": channel_id,
|
||||
"content_type": CONTENT_TYPE_CHANNEL,
|
||||
"is_catchup": is_catchup,
|
||||
"drm_configs": merged,
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"DRM endpoint error for channel {provider}/{channel_id}: {e}")
|
||||
response.status = 404
|
||||
return {"error": str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"DRM endpoint error for channel {provider}/{channel_id}: {e}")
|
||||
response.status = 500
|
||||
return {"error": f"Internal server error: {str(e)}"}
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
EPG (Electronic Program Guide) routes.
|
||||
|
||||
These routes provide program schedule information for channels.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from bottle import request, response
|
||||
from streaming_providers.base.utils import logger
|
||||
|
||||
|
||||
def setup_epg_routes(app, manager, service):
|
||||
"""Setup all EPG-related routes."""
|
||||
|
||||
# Note: EPG routes don't need the full helpers factory since they don't
|
||||
# use the stream resolution helpers. They only need manager.
|
||||
|
||||
@app.route("/api/providers/<provider>/channels/<channel_id>/epg")
|
||||
def get_channel_epg(provider, channel_id):
|
||||
try:
|
||||
kwargs = {"country": request.query.get("country")}
|
||||
|
||||
if request.query.get("start_time"):
|
||||
start_time_str = request.query.get("start_time")
|
||||
try:
|
||||
kwargs["start_time"] = datetime.fromtimestamp(
|
||||
int(start_time_str), tz=timezone.utc
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
try:
|
||||
kwargs["start_time"] = datetime.fromisoformat(
|
||||
start_time_str.replace("Z", "+00:00")
|
||||
)
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid start_time format: {start_time_str}")
|
||||
|
||||
if request.query.get("end_time"):
|
||||
end_time_str = request.query.get("end_time")
|
||||
try:
|
||||
kwargs["end_time"] = datetime.fromtimestamp(
|
||||
int(end_time_str), tz=timezone.utc
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
try:
|
||||
kwargs["end_time"] = datetime.fromisoformat(
|
||||
end_time_str.replace("Z", "+00:00")
|
||||
)
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid end_time format: {end_time_str}")
|
||||
|
||||
epg_data = manager.get_channel_epg(
|
||||
provider_name=provider, channel_id=channel_id, **kwargs
|
||||
)
|
||||
|
||||
response.content_type = "application/json; charset=utf-8"
|
||||
return {"provider": provider, "channel_id": channel_id, "epg": epg_data}
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"EPG error for {provider}/{channel_id}: {e}")
|
||||
response.status = 404
|
||||
response.content_type = "application/json; charset=utf-8"
|
||||
return {"error": str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"EPG error for {provider}/{channel_id}: {e}")
|
||||
response.status = 500
|
||||
response.content_type = "application/json; charset=utf-8"
|
||||
return {"error": f"Internal server error: {str(e)}"}
|
||||
|
||||
@app.route("/api/providers/<provider>/epg")
|
||||
def get_provider_epg_xmltv(provider):
|
||||
try:
|
||||
response.content_type = "application/xml; charset=utf-8"
|
||||
response.headers["Content-Disposition"] = (
|
||||
f'attachment; filename="{provider}_epg.xml"'
|
||||
)
|
||||
|
||||
xmltv_data = manager.get_provider_epg_xmltv(
|
||||
provider_name=provider, country=request.query.get("country")
|
||||
)
|
||||
|
||||
if not xmltv_data:
|
||||
response.status = 404
|
||||
return {"error": f'EPG data not available for provider "{provider}"'}
|
||||
|
||||
return xmltv_data
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"XMLTV EPG error for {provider}: {e}")
|
||||
response.status = 404
|
||||
return {"error": str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"XMLTV EPG error for {provider}: {e}")
|
||||
response.status = 500
|
||||
return {"error": f"Internal server error: {str(e)}"}
|
||||
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Event-specific stream routes.
|
||||
|
||||
Events are temporary/live streams (sports, special broadcasts, etc.). They
|
||||
share the same transport pattern as channels but without catchup support.
|
||||
"""
|
||||
|
||||
from bottle import HTTPResponse, request, response
|
||||
from streaming_providers.base.utils import logger
|
||||
|
||||
from . import make_helpers
|
||||
|
||||
|
||||
def setup_event_routes(app, manager, service):
|
||||
"""Setup all event-related stream routes."""
|
||||
|
||||
# Create helpers with manager and service closures
|
||||
helpers = make_helpers(manager, service)
|
||||
|
||||
CONTENT_TYPE_EVENT = helpers["CONTENT_TYPE_EVENT"]
|
||||
_build_drm_header = helpers["_build_drm_header"]
|
||||
_build_stream_headers = helpers["_build_stream_headers"]
|
||||
_resolve_stream = helpers["_resolve_stream"]
|
||||
_resolve_decrypted_stream = helpers["_resolve_decrypted_stream"]
|
||||
_get_drm_configs = helpers["_get_drm_configs"]
|
||||
|
||||
@app.route("/api/providers/<provider>/events/<event_id>/manifest")
|
||||
def get_event_manifest(provider, event_id):
|
||||
"""
|
||||
Returns JSON with a manifest_url pointing to the event stream endpoint.
|
||||
Attaches x-kodi-drm-configs header.
|
||||
|
||||
Response includes both stream_url (auto DRM) and sw_drm_stream_url
|
||||
(software / ClearKey DRM).
|
||||
"""
|
||||
try:
|
||||
country = request.query.get("country")
|
||||
base_url = f"{request.urlparts.scheme}://{request.urlparts.netloc}"
|
||||
qs = f"?country={country}" if country else ""
|
||||
stream_url = (
|
||||
f"{base_url}/api/providers/{provider}/events/{event_id}"
|
||||
f"/stream/index.mpd{qs}"
|
||||
)
|
||||
sw_drm_stream_url = (
|
||||
f"{base_url}/api/providers/{provider}/events/{event_id}"
|
||||
f"/stream/sw-drm/index.mpd{qs}"
|
||||
)
|
||||
|
||||
_build_drm_header(CONTENT_TYPE_EVENT, provider, event_id, country=country)
|
||||
_build_stream_headers(CONTENT_TYPE_EVENT, provider, event_id, country=country)
|
||||
|
||||
return {
|
||||
"provider": provider,
|
||||
"event_id": event_id,
|
||||
"manifest_url": stream_url,
|
||||
"sw_drm_manifest_url": sw_drm_stream_url,
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"manifest endpoint error for event {provider}/{event_id}: {e}")
|
||||
response.status = 404
|
||||
return {"error": str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"manifest endpoint error for event {provider}/{event_id}: {e}")
|
||||
response.status = 500
|
||||
return {"error": f"Internal server error: {str(e)}"}
|
||||
|
||||
@app.route("/api/providers/<provider>/events/<event_id>/stream/index.mpd")
|
||||
def get_event_stream(provider, event_id):
|
||||
"""
|
||||
Returns HTTP 302 redirect to the event manifest, or a rewritten
|
||||
manifest body when media proxy is active.
|
||||
"""
|
||||
try:
|
||||
country = request.query.get("country")
|
||||
return _resolve_stream(
|
||||
CONTENT_TYPE_EVENT, provider, event_id, country=country
|
||||
)
|
||||
except HTTPResponse:
|
||||
raise
|
||||
except ValueError as e:
|
||||
logger.error(f"stream error for event {provider}/{event_id}: {e}")
|
||||
response.status = 404
|
||||
return {"error": str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"stream error for event {provider}/{event_id}: {e}")
|
||||
response.status = 500
|
||||
return {"error": f"Internal server error: {str(e)}"}
|
||||
|
||||
@app.route("/api/providers/<provider>/events/<event_id>/stream/sw-drm/index.mpd")
|
||||
def get_event_stream_sw_drm(provider, event_id):
|
||||
"""
|
||||
Software-DRM (ClearKey) event stream endpoint.
|
||||
|
||||
Identical to the standard event stream endpoint except that
|
||||
drm_variant='software' is passed through to the resolution helpers.
|
||||
"""
|
||||
try:
|
||||
country = request.query.get("country")
|
||||
return _resolve_stream(
|
||||
CONTENT_TYPE_EVENT, provider, event_id,
|
||||
country=country, drm_variant="software",
|
||||
)
|
||||
except HTTPResponse:
|
||||
raise
|
||||
except ValueError as e:
|
||||
logger.error(f"sw-drm stream error for event {provider}/{event_id}: {e}")
|
||||
response.status = 404
|
||||
return {"error": str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"sw-drm stream error for event {provider}/{event_id}: {e}")
|
||||
response.status = 500
|
||||
return {"error": f"Internal server error: {str(e)}"}
|
||||
|
||||
@app.route(
|
||||
"/api/providers/<provider>/events/<event_id>/stream/decrypted/index.mpd"
|
||||
)
|
||||
def get_event_stream_decrypted(provider, event_id):
|
||||
"""Decrypted event stream — all quality representations."""
|
||||
return _resolve_decrypted_stream(
|
||||
CONTENT_TYPE_EVENT, provider, event_id, highest_quality_only=False
|
||||
)
|
||||
|
||||
@app.route(
|
||||
"/api/providers/<provider>/events/<event_id>/stream/decrypted/ffmpeg/index.mpd"
|
||||
)
|
||||
def get_event_stream_decrypted_ffmpeg(provider, event_id):
|
||||
"""Decrypted event stream — highest quality only, optimised for ffmpeg."""
|
||||
return _resolve_decrypted_stream(
|
||||
CONTENT_TYPE_EVENT, provider, event_id, highest_quality_only=True
|
||||
)
|
||||
|
||||
@app.route("/api/providers/<provider>/events/<event_id>/drm")
|
||||
def get_event_drm(provider, event_id):
|
||||
"""Return DRM configs for a specific event."""
|
||||
try:
|
||||
country = request.query.get("country")
|
||||
drm_configs = _get_drm_configs(
|
||||
CONTENT_TYPE_EVENT, provider, event_id, country=country
|
||||
)
|
||||
|
||||
merged = {}
|
||||
for config in drm_configs:
|
||||
merged.update(
|
||||
config.to_dict() if hasattr(config, "to_dict") else config
|
||||
)
|
||||
|
||||
return {
|
||||
"provider": provider,
|
||||
"event_id": event_id,
|
||||
"content_type": CONTENT_TYPE_EVENT,
|
||||
"drm_configs": merged,
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"DRM endpoint error for event {provider}/{event_id}: {e}")
|
||||
response.status = 404
|
||||
return {"error": str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"DRM endpoint error for event {provider}/{event_id}: {e}")
|
||||
response.status = 500
|
||||
return {"error": f"Internal server error: {str(e)}"}
|
||||
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Recording stream routes.
|
||||
|
||||
Recordings are always on-demand (pre-captured), so:
|
||||
- No catchup path (unlike channels)
|
||||
- No ffmpeg variant (not a live/adaptive stream that needs quality pinning)
|
||||
- recording_id is a flat identifier, no path hierarchy needed
|
||||
"""
|
||||
|
||||
from bottle import HTTPResponse, request, response
|
||||
from streaming_providers.base.utils import logger
|
||||
|
||||
from . import make_helpers
|
||||
|
||||
|
||||
def setup_recording_routes(app, manager, service):
|
||||
"""Setup all recording-related stream routes."""
|
||||
|
||||
# Create helpers with manager and service closures
|
||||
helpers = make_helpers(manager, service)
|
||||
|
||||
CONTENT_TYPE_RECORDING = helpers["CONTENT_TYPE_RECORDING"]
|
||||
_build_drm_header = helpers["_build_drm_header"]
|
||||
_build_stream_headers = helpers["_build_stream_headers"]
|
||||
_resolve_stream = helpers["_resolve_stream"]
|
||||
_resolve_decrypted_stream = helpers["_resolve_decrypted_stream"]
|
||||
_get_drm_configs = helpers["_get_drm_configs"]
|
||||
|
||||
@app.route("/api/providers/<provider>/recordings/<recording_id>/manifest")
|
||||
def get_recording_manifest(provider, recording_id):
|
||||
"""
|
||||
Returns JSON with a manifest_url pointing to the recording stream endpoint.
|
||||
Attaches x-kodi-drm-configs header.
|
||||
"""
|
||||
try:
|
||||
country = request.query.get("country")
|
||||
base_url = f"{request.urlparts.scheme}://{request.urlparts.netloc}"
|
||||
stream_url = (
|
||||
f"{base_url}/api/providers/{provider}"
|
||||
f"/recordings/{recording_id}/stream/index.mpd"
|
||||
)
|
||||
if country:
|
||||
stream_url += f"?country={country}"
|
||||
|
||||
_build_drm_header(
|
||||
CONTENT_TYPE_RECORDING, provider, recording_id, country=country
|
||||
)
|
||||
_build_stream_headers(
|
||||
CONTENT_TYPE_RECORDING, provider, recording_id, country=country
|
||||
)
|
||||
|
||||
return {
|
||||
"provider": provider,
|
||||
"recording_id": recording_id,
|
||||
"manifest_url": stream_url,
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(
|
||||
f"manifest endpoint error for recording {provider}/{recording_id}: {e}"
|
||||
)
|
||||
response.status = 404
|
||||
return {"error": str(e)}
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"manifest endpoint error for recording {provider}/{recording_id}: {e}"
|
||||
)
|
||||
response.status = 500
|
||||
return {"error": f"Internal server error: {str(e)}"}
|
||||
|
||||
@app.route("/api/providers/<provider>/recordings/<recording_id>/stream/index.mpd")
|
||||
def get_recording_stream(provider, recording_id):
|
||||
"""
|
||||
Returns HTTP 302 redirect to the recording manifest, or a rewritten
|
||||
manifest body when media proxy is active.
|
||||
"""
|
||||
try:
|
||||
country = request.query.get("country")
|
||||
return _resolve_stream(
|
||||
CONTENT_TYPE_RECORDING, provider, recording_id, country=country
|
||||
)
|
||||
except HTTPResponse:
|
||||
raise
|
||||
except ValueError as e:
|
||||
logger.error(
|
||||
f"stream error for recording {provider}/{recording_id}: {e}"
|
||||
)
|
||||
response.status = 404
|
||||
return {"error": str(e)}
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"stream error for recording {provider}/{recording_id}: {e}"
|
||||
)
|
||||
response.status = 500
|
||||
return {"error": f"Internal server error: {str(e)}"}
|
||||
|
||||
@app.route(
|
||||
"/api/providers/<provider>/recordings/<recording_id>/stream/decrypted/index.mpd"
|
||||
)
|
||||
def get_recording_stream_decrypted(provider, recording_id):
|
||||
"""Decrypted recording stream — all quality representations."""
|
||||
return _resolve_decrypted_stream(
|
||||
CONTENT_TYPE_RECORDING, provider, recording_id, highest_quality_only=False
|
||||
)
|
||||
|
||||
@app.route("/api/providers/<provider>/recordings/<recording_id>/drm")
|
||||
def get_recording_drm(provider, recording_id):
|
||||
"""Return DRM configs for a specific recording."""
|
||||
try:
|
||||
country = request.query.get("country")
|
||||
drm_configs = _get_drm_configs(
|
||||
CONTENT_TYPE_RECORDING, provider, recording_id, country=country
|
||||
)
|
||||
|
||||
merged = {}
|
||||
for config in drm_configs:
|
||||
merged.update(
|
||||
config.to_dict() if hasattr(config, "to_dict") else config
|
||||
)
|
||||
|
||||
return {
|
||||
"provider": provider,
|
||||
"recording_id": recording_id,
|
||||
"content_type": CONTENT_TYPE_RECORDING,
|
||||
"drm_configs": merged,
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(
|
||||
f"DRM endpoint error for recording {provider}/{recording_id}: {e}"
|
||||
)
|
||||
response.status = 404
|
||||
return {"error": str(e)}
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"DRM endpoint error for recording {provider}/{recording_id}: {e}"
|
||||
)
|
||||
response.status = 500
|
||||
return {"error": f"Internal server error: {str(e)}"}
|
||||
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
VOD (Video on Demand) stream routes.
|
||||
|
||||
VOD content uses the same transport pattern as events but with hierarchical
|
||||
IDs (paths like "clip_1417600/stream") and without catchup support.
|
||||
"""
|
||||
|
||||
from bottle import HTTPResponse, request, response
|
||||
from streaming_providers.base.utils import logger
|
||||
|
||||
from . import make_helpers
|
||||
|
||||
|
||||
def setup_vod_routes(app, manager, service):
|
||||
"""Setup all VOD-related stream routes."""
|
||||
|
||||
# Create helpers with manager and service closures
|
||||
helpers = make_helpers(manager, service)
|
||||
|
||||
CONTENT_TYPE_VOD = helpers["CONTENT_TYPE_VOD"]
|
||||
_build_drm_header = helpers["_build_drm_header"]
|
||||
_build_stream_headers = helpers["_build_stream_headers"]
|
||||
_resolve_stream = helpers["_resolve_stream"]
|
||||
_resolve_decrypted_stream = helpers["_resolve_decrypted_stream"]
|
||||
_get_drm_configs = helpers["_get_drm_configs"]
|
||||
|
||||
@app.route("/api/providers/<provider>/vod/<path:path>/stream/index.mpd")
|
||||
def get_vod_stream(provider, path):
|
||||
# Extract vod_id as the first segment before any slashes
|
||||
# Example: "clip_1417600/stream" -> "clip_1417600"
|
||||
vod_id = path.split("/")[0]
|
||||
|
||||
try:
|
||||
country = request.query.get("country")
|
||||
return _resolve_stream(
|
||||
CONTENT_TYPE_VOD, provider, vod_id, country=country
|
||||
)
|
||||
except HTTPResponse:
|
||||
raise
|
||||
except ValueError as e:
|
||||
logger.error(f"stream error for VOD {provider}/{vod_id}: {e}")
|
||||
response.status = 404
|
||||
return {"error": str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"stream error for VOD {provider}/{vod_id}: {e}")
|
||||
response.status = 500
|
||||
return {"error": f"Internal server error: {str(e)}"}
|
||||
|
||||
@app.route("/api/providers/<provider>/vod/<path:path>/manifest")
|
||||
def get_vod_manifest(provider, path):
|
||||
vod_id = path.split("/")[0]
|
||||
try:
|
||||
country = request.query.get("country")
|
||||
base_url = f"{request.urlparts.scheme}://{request.urlparts.netloc}"
|
||||
stream_url = (
|
||||
f"{base_url}/api/providers/{provider}/vod/{vod_id}/stream/index.mpd"
|
||||
)
|
||||
if country:
|
||||
stream_url += f"?country={country}"
|
||||
|
||||
_build_drm_header(CONTENT_TYPE_VOD, provider, vod_id, country=country)
|
||||
_build_stream_headers(CONTENT_TYPE_VOD, provider, vod_id, country=country)
|
||||
|
||||
return {
|
||||
"provider": provider,
|
||||
"vod_id": vod_id,
|
||||
"manifest_url": stream_url,
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"manifest endpoint error for VOD {provider}/{vod_id}: {e}")
|
||||
response.status = 404
|
||||
return {"error": str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"manifest endpoint error for VOD {provider}/{vod_id}: {e}")
|
||||
response.status = 500
|
||||
return {"error": f"Internal server error: {str(e)}"}
|
||||
|
||||
@app.route("/api/providers/<provider>/vod/<path:path>/stream/decrypted/index.mpd")
|
||||
def get_vod_stream_decrypted(provider, path):
|
||||
vod_id = path.split("/")[0]
|
||||
return _resolve_decrypted_stream(
|
||||
CONTENT_TYPE_VOD, provider, vod_id, highest_quality_only=False
|
||||
)
|
||||
|
||||
@app.route("/api/providers/<provider>/vod/<path:path>/stream/decrypted/ffmpeg/index.mpd")
|
||||
def get_vod_stream_decrypted_ffmpeg(provider, path):
|
||||
vod_id = path.split("/")[0]
|
||||
return _resolve_decrypted_stream(
|
||||
CONTENT_TYPE_VOD, provider, vod_id, highest_quality_only=True
|
||||
)
|
||||
|
||||
@app.route("/api/providers/<provider>/vod/<path:path>/drm")
|
||||
def get_vod_drm(provider, path):
|
||||
vod_id = path.split("/")[0]
|
||||
try:
|
||||
country = request.query.get("country")
|
||||
drm_configs = _get_drm_configs(
|
||||
CONTENT_TYPE_VOD, provider, vod_id, country=country
|
||||
)
|
||||
|
||||
merged = {}
|
||||
for config in drm_configs:
|
||||
merged.update(
|
||||
config.to_dict() if hasattr(config, "to_dict") else config
|
||||
)
|
||||
|
||||
return {
|
||||
"provider": provider,
|
||||
"vod_id": vod_id,
|
||||
"content_type": CONTENT_TYPE_VOD,
|
||||
"drm_configs": merged,
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"DRM endpoint error for VOD {provider}/{vod_id}: {e}")
|
||||
response.status = 404
|
||||
return {"error": str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"DRM endpoint error for VOD {provider}/{vod_id}: {e}")
|
||||
response.status = 500
|
||||
return {"error": f"Internal server error: {str(e)}"}
|
||||
Reference in New Issue
Block a user