Add discovery

This commit is contained in:
Nirvana
2026-03-01 19:58:59 +01:00
parent 9b6766e83d
commit b0ff74a8ee
7 changed files with 535 additions and 349 deletions
@@ -42,7 +42,7 @@ class CatchupOperations:
try:
return provider.get_catchup_manifest(
channel_id=channel_id,
content_id=channel_id,
start_time=start_time,
end_time=end_time,
epg_id=epg_id,
@@ -73,7 +73,7 @@ class CatchupOperations:
try:
return provider.get_catchup_drm(
channel_id=channel_id,
content_id=channel_id,
start_time=start_time,
end_time=end_time,
epg_id=epg_id,
@@ -45,7 +45,7 @@ class ChannelOperations:
if not provider:
raise ValueError(f"Provider '{provider_name}' not found or disabled")
manifest_url = provider.get_manifest(channel_id, **kwargs)
manifest_url = provider.get_manifest(content_id=channel_id, **kwargs)
if manifest_url:
logger.debug(f"Retrieved manifest for '{channel_id}' from '{provider_name}'")
return manifest_url
@@ -329,7 +329,7 @@ class DRMOperations:
# ------------------------------------------------------------------
# Step 0a: Fetch manifest and short-circuit for unencrypted streams
# ------------------------------------------------------------------
manifest_url = provider.get_manifest(channel_id, **kwargs)
manifest_url = provider.get_manifest(content_id=channel_id, **kwargs)
if manifest_url and manifest_url.startswith(('http://', 'https://')):
try:
from .network import HTTPManager
@@ -409,7 +409,7 @@ class DRMOperations:
# ------------------------------------------------------------------
# Step 3: PHASE 2 — Get provider's DRM configs
# ------------------------------------------------------------------
provider_drm_configs = provider.get_drm(channel_id, **kwargs)
provider_drm_configs = provider.get_drm(content_id=channel_id, **kwargs)
# Secondary unencrypted check (in case manifest fetch failed earlier)
if not provider_drm_configs:
@@ -434,7 +434,7 @@ class DRMOperations:
if not pssh_data_list:
logger.debug(f"Phase 2: PSSH cache miss for {cache_key}, fetching manifest")
manifest_url = provider.get_manifest(channel_id, **kwargs)
manifest_url = provider.get_manifest(content_id=channel_id, **kwargs)
if manifest_url:
pssh_data_list = self._extract_pssh_from_manifest(manifest_url, provider_name)
if pssh_data_list:
@@ -549,7 +549,7 @@ class DRMOperations:
logger.warning(f"Provider '{provider_name}' not found for GENERIC plugin")
return None, None
manifest_url = provider.get_manifest(channel_id, **kwargs)
manifest_url = provider.get_manifest(content_id=channel_id, **kwargs)
if manifest_url:
pssh_data_list = self._extract_pssh_from_manifest(manifest_url, provider_name)
if pssh_data_list:
@@ -570,7 +570,7 @@ class DRMOperations:
logger.error(f"Cannot extract real PSSH: provider '{provider_name}' not found")
return None, pssh_data_list
manifest_url = provider.get_manifest(channel_id, **kwargs)
manifest_url = provider.get_manifest(content_id=channel_id, **kwargs)
if manifest_url:
real_pssh = self._extract_pssh_from_manifest(manifest_url, provider_name)
@@ -55,4 +55,44 @@ class EventOperations:
result[name] = []
logger.info(f"Retrieved {total} total events")
return result
return result
def get_event_manifest(
self, provider_name: str, event_id: str, **kwargs
) -> Optional[str]:
"""
Get manifest URL for a specific event.
Delegates to provider.get_manifest() — the same method used for
channels — because manifest resolution is content-type-agnostic.
"""
provider = self.registry.get_provider(provider_name)
if not provider:
raise ValueError(f"Provider '{provider_name}' not found or disabled")
manifest_url = provider.get_manifest(content_id=event_id, **kwargs)
if manifest_url:
logger.debug(
f"Retrieved manifest for event '{event_id}' from '{provider_name}'"
)
return manifest_url
def get_event_drm_configs(
self, provider_name: str, event_id: str, **kwargs
) -> List:
"""
Get DRM configurations for a specific event.
Delegates to provider.get_drm() — the same method used for
channels — because DRM resolution is content-type-agnostic.
"""
provider = self.registry.get_provider(provider_name)
if not provider:
raise ValueError(f"Provider '{provider_name}' not found or disabled")
drm_configs = provider.get_drm(content_id=event_id, **kwargs)
logger.debug(
f"Retrieved {len(drm_configs)} DRM config(s) for event '{event_id}' "
f"from '{provider_name}'"
)
return drm_configs
+6
View File
@@ -224,6 +224,12 @@ class ProviderManager:
def get_all_events(self, start_time=None, end_time=None):
return self.event_ops.get_all_events(start_time, end_time)
def get_event_manifest(self, provider_name: str, event_id: str, **kwargs) -> Optional[str]:
return self.event_ops.get_event_manifest(provider_name, event_id, **kwargs)
def get_event_drm_configs(self, provider_name: str, event_id: str, **kwargs) -> List:
return self.event_ops.get_event_drm_configs(provider_name, event_id, **kwargs)
# ==========================================================================
# SUBSCRIPTION OPERATIONS (delegate to SubscriptionOperations)
# ==========================================================================
+11 -11
View File
@@ -233,7 +233,7 @@ class StreamingProvider(ABC):
return []
@abstractmethod
def get_drm(self, channel_id: str, **kwargs) -> List[DRMConfig]:
def get_drm(self, content_id: str, **kwargs) -> List[DRMConfig]:
"""Get all DRM configurations for a channel by ID"""
return []
@@ -280,7 +280,7 @@ class StreamingProvider(ABC):
return None
@abstractmethod
def get_manifest(self, channel_id: str, **kwargs) -> Optional[str]:
def get_manifest(self, content_id: str, **kwargs) -> Optional[str]:
"""Get manifest URL for a specific channel by ID"""
return None
@@ -660,7 +660,7 @@ class StreamingProvider(ABC):
def get_catchup_manifest(
self,
channel_id: str,
content_id: str,
start_time: int,
end_time: int,
epg_id: Optional[str] = None,
@@ -686,17 +686,17 @@ class StreamingProvider(ABC):
logger.debug(
f"{self.provider_name}: Catchup not supported, falling back to live manifest"
)
return self.get_manifest(channel_id, **kwargs)
return self.get_manifest(content_id, **kwargs)
logger.warning(
f"{self.provider_name}: get_catchup_manifest not implemented, "
f"falling back to live manifest"
)
return self.get_manifest(channel_id, **kwargs)
return self.get_manifest(content_id, **kwargs)
def get_catchup_drm(
self,
channel_id: str,
content_id: str,
start_time: int,
end_time: int,
epg_id: Optional[str] = None,
@@ -706,7 +706,7 @@ class StreamingProvider(ABC):
Get DRM configurations for catchup content.
Args:
channel_id: Channel identifier
content_id: Channel identifier
start_time: Start time as Unix timestamp
end_time: End time as Unix timestamp
epg_id: Optional EPG event ID (might be needed for DRM licensing)
@@ -720,24 +720,24 @@ class StreamingProvider(ABC):
"""
if not self.supports_catchup:
logger.debug(f"{self.provider_name}: Catchup not supported, falling back to live DRM")
return self.get_drm(channel_id, **kwargs)
return self.get_drm(content_id, **kwargs)
logger.debug(
f"{self.provider_name}: get_catchup_drm not implemented, "
f"using live DRM configuration"
)
return self.get_drm(channel_id, **kwargs)
return self.get_drm(content_id, **kwargs)
# ============================================================================
# CATCHUP HELPER METHODS
# ============================================================================
def get_catchup_window_for_channel(self, channel_id: str) -> int:
def get_catchup_window_for_channel(self, content_id: str) -> int:
"""
Get catchup window for a specific channel in HOURS.
Args:
channel_id: Channel identifier
content_id: Channel identifier
Returns:
int: Catchup window in hours for this channel
+469 -329
View File
@@ -1,6 +1,26 @@
#!/usr/bin/env python3
"""
Stream and manifest route handlers
Stream and manifest route handlers.
Architecture
============
All content types (channels, events, future VOD) share identical transport-level
operations: resolve a manifest URL, fetch DRM configs, optionally rewrite the
manifest through a media proxy. The typed route handlers (channel vs event vs
vod) are therefore thin wrappers around 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.
Adding VOD in the future means:
1. Implement manager.get_vod_manifest() / get_vod_drm_configs() (same pattern).
2. Register the three route URLs for /vod/<vod_id>/{stream,manifest,drm}.
3. No changes to the shared helpers.
"""
import base64
@@ -10,31 +30,91 @@ from datetime import datetime
from bottle import HTTPResponse, redirect, request, response
from streaming_providers.base.utils import logger
# ---------------------------------------------------------------------------
# Content-type constants — single source of truth used by helpers and routes
# ---------------------------------------------------------------------------
CONTENT_TYPE_CHANNEL = "channel"
CONTENT_TYPE_EVENT = "event"
# CONTENT_TYPE_VOD = "vod" # uncomment when VOD ops are implemented
def setup_stream_routes(app, manager, service):
"""Setup stream and manifest-related routes"""
"""Setup stream and manifest-related routes."""
def _build_drm_header(provider, channel_id, country=None, is_catchup=False,
start_time=None, end_time=None, epg_id=None):
# =========================================================================
# INTERNAL HELPERS
# =========================================================================
def _get_drm_configs(content_type: str, provider: str, content_id: str, **kwargs):
"""
Fetch DRM configs, serialize to JSON and base64-encode them.
Returns the encoded string, or None if anything fails.
Sets response.headers["x-kodi-drm-configs"] as a side effect on success.
Non-fatal: logs a warning and returns None on any error.
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.
"""
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
# )
else:
raise ValueError(f"Unknown content_type '{content_type}'")
def _get_manifest_url(content_type: str, provider: str, content_id: str, **kwargs) -> str:
"""
Dispatch manifest URL retrieval to the correct manager method.
Returns the raw upstream manifest URL (before any proxy rewriting).
"""
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
# )
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,
):
"""
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.
"""
try:
if is_catchup:
if is_catchup and content_type == CONTENT_TYPE_CHANNEL:
drm_configs = manager.get_catchup_drm_configs(
provider_name=provider,
channel_id=channel_id,
channel_id=content_id,
start_time=start_time,
end_time=end_time,
epg_id=epg_id,
country=country,
)
else:
drm_configs = manager.get_channel_drm_configs(
provider_name=provider, channel_id=channel_id, country=country
drm_configs = _get_drm_configs(
content_type, provider, content_id, country=country
)
merged = {}
@@ -50,70 +130,204 @@ def setup_stream_routes(app, manager, service):
return encoded
except Exception as e:
logger.warning(f"Could not build x-kodi-drm-configs header for "
f"{provider}/{channel_id}: {e}")
logger.warning(
f"Could not build x-kodi-drm-configs header for "
f"{content_type} {provider}/{content_id}: {e}"
)
return None
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: int = None,
end_time_int: int = None,
epg_id: str = None,
):
"""
Core stream resolution: attach DRM header, then either redirect to the
upstream manifest or return a proxy-rewritten manifest body.
Handles three cases in order:
1. Catchup (channel-only for now)
2. Proxy needed → return rewritten MPD body
3. No proxy → HTTP 302 redirect to upstream manifest URL
"""
# --- DRM header (best-effort, never fatal) ---
_build_drm_header(
content_type, provider, content_id,
country=country,
is_catchup=is_catchup,
start_time=start_time_int,
end_time=end_time_int,
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_int, end_time_int, epg_id, country
)
else:
manifest_url = manager.get_catchup_manifest(
provider_name=provider,
channel_id=content_id,
start_time=start_time_int,
end_time=end_time_int,
epg_id=epg_id,
country=country,
)
if not manifest_url:
response.status = 404
return {
"error": f'Catchup manifest not available for channel "{content_id}"'
}
logger.debug(f"Redirecting to catchup manifest: {manifest_url}")
redirect(manifest_url)
# --- Live / event / vod path ---
if manager.needs_proxy(provider):
return service._get_proxied_manifest(provider, content_id)
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} '
f'"{content_id}" from provider "{provider}"'
)
}
logger.debug(f"Redirecting to manifest: {manifest_url}")
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.
"""
try:
country = request.query.get("country")
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:
if manager.needs_proxy(provider) and service.media_proxy_url:
return service._get_proxied_manifest(
provider, content_id,
highest_quality_only=highest_quality_only,
)
else:
return service._get_original_manifest(provider, content_id, country)
else:
response.status = 400
return {
"error": (
f'{content_type.capitalize()} "{content_id}" does not support '
f"decrypted playback (requires ClearKey or unencrypted)"
)
}
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)}"}
# =========================================================================
# CHANNEL ROUTES (preserved exactly — backward-compatible)
# =========================================================================
@app.route("/api/providers/<provider>/channels/<channel_id>/manifest")
def get_channel_manifest(provider, channel_id):
"""
Get channel manifest. Always returns JSON with manifest_url pointing to stream endpoint.
Returns JSON with a manifest_url pointing to the stream endpoint.
Attaches x-kodi-drm-configs header.
"""
try:
country = request.query.get("country")
# Build the stream URL (which will handle both proxy and non-proxy)
base_url = f"{request.urlparts.scheme}://{request.urlparts.netloc}"
stream_url = f"{base_url}/api/providers/{provider}/channels/{channel_id}/stream/index.mpd"
# Add country parameter if provided
stream_url = (
f"{base_url}/api/providers/{provider}/channels/{channel_id}/stream/index.mpd"
)
if country:
stream_url += f"?country={country}"
_build_drm_header(provider, channel_id, country=country)
_build_drm_header(CONTENT_TYPE_CHANNEL, provider, channel_id, country=country)
return {
"provider": provider,
"channel_id": channel_id,
"manifest_url": stream_url, # Now points to /stream/index.mpd endpoint
"manifest_url": stream_url,
}
except ValueError as val_err:
logger.error(
f"API Error in /api/providers/{provider}/channels/{channel_id}/manifest: {str(val_err)}"
)
except ValueError as e:
logger.error(f"manifest endpoint error for {provider}/{channel_id}: {e}")
response.status = 404
return {"error": str(val_err)}
except Exception as api_err:
logger.error(
f"API Error in /api/providers/{provider}/channels/{channel_id}/manifest: {str(api_err)}"
)
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(api_err)}"}
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 rewritten manifest endpoint.
Supports both live and catchup streaming.
Returns HTTP 302 redirect to the actual manifest, or a rewritten
manifest body when media proxy is active. Supports live and catchup.
"""
try:
# Get optional catchup parameters
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")
# Determine if this is a catchup request
is_catchup = bool(start_time and end_time)
if is_catchup:
logger.info(
f"Catchup stream request for {provider}/{channel_id}: "
f"start={start_time}, end={end_time}, epg_id={epg_id}"
)
# Convert Unix timestamps to integers
try:
start_time_int = int(start_time)
end_time_int = int(end_time)
@@ -121,186 +335,287 @@ def setup_stream_routes(app, manager, service):
response.status = 400
return {"error": "Invalid start_time or end_time format"}
# Validate catchup is supported
provider_instance = manager.get_provider(provider)
catchup_hours = getattr(
provider_instance, "catchup_window", 0
) # Now in hours
catchup_hours = getattr(provider_instance, "catchup_window", 0)
if catchup_hours == 0:
response.status = 400
return {"error": f'Catchup not supported for provider "{provider}"'}
# Validate time is within catchup window (in HOURS)
import time
now = int(time.time())
max_age_seconds = catchup_hours * 3600 # Hours to seconds
if (now - start_time_int) > max_age_seconds:
if (int(time.time()) - start_time_int) > catchup_hours * 3600:
response.status = 400
return {
"error": f"Content outside catchup window (max {catchup_hours} hours)"
}
# Set DRM header before any return/redirect
_build_drm_header(
provider, channel_id,
return _resolve_stream(
CONTENT_TYPE_CHANNEL, provider, channel_id,
country=country,
is_catchup=True,
start_time=start_time_int,
end_time=end_time_int,
start_time_int=start_time_int,
end_time_int=end_time_int,
epg_id=epg_id,
)
# Check if provider needs proxy for catchup
if manager.needs_proxy(provider):
# Proxy mode: return rewritten MPD content directly
return service._get_proxied_catchup_manifest(
provider,
channel_id,
start_time_int,
end_time_int,
epg_id,
country,
)
else:
# Direct mode: get catchup manifest URL and redirect
manifest_url = manager.get_catchup_manifest(
provider_name=provider,
channel_id=channel_id,
start_time=start_time_int,
end_time=end_time_int,
epg_id=epg_id,
country=country,
)
if not manifest_url:
response.status = 404
return {
"error": f'Catchup manifest not available for channel "{channel_id}"'
}
logger.debug(f"Redirecting to catchup manifest: {manifest_url}")
redirect(manifest_url)
else:
# Live stream - existing logic
# Set DRM header before any return/redirect
_build_drm_header(provider, channel_id, country=country)
if manager.needs_proxy(provider):
return service._get_proxied_manifest(provider, channel_id)
else:
manifest_url = manager.get_channel_manifest(
provider_name=provider,
channel_id=channel_id,
country=country,
)
if not manifest_url:
response.status = 404
return {
"error": f'Manifest not available for channel "{channel_id}"'
}
logger.debug(f"Redirecting to manifest: {manifest_url}")
redirect(manifest_url)
return _resolve_stream(
CONTENT_TYPE_CHANNEL, provider, channel_id, country=country
)
except HTTPResponse:
raise
except ValueError as val_err:
logger.error(f"API Error in stream: {str(val_err)}")
except ValueError as e:
logger.error(f"stream error for channel {provider}/{channel_id}: {e}")
response.status = 404
return {"error": str(val_err)}
except Exception as api_err:
logger.error(f"API Error in stream: {str(api_err)}")
return {"error": str(e)}
except Exception as e:
logger.error(f"stream error for channel {provider}/{channel_id}: {e}")
response.status = 500
return {"error": f"Internal server error: {str(api_err)}"}
return {"error": f"Internal server error: {str(e)}"}
@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)}"}
# =========================================================================
# EVENT ROUTES
# =========================================================================
@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.
"""
try:
country = request.query.get("country")
base_url = f"{request.urlparts.scheme}://{request.urlparts.netloc}"
stream_url = (
f"{base_url}/api/providers/{provider}/events/{event_id}/stream/index.mpd"
)
if country:
stream_url += f"?country={country}"
_build_drm_header(CONTENT_TYPE_EVENT, provider, event_id, country=country)
return {
"provider": provider,
"event_id": event_id,
"manifest_url": 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/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)}"}
# =========================================================================
# EPG ROUTES (unchanged from original)
# =========================================================================
@app.route("/api/providers/<provider>/channels/<channel_id>/epg")
def get_channel_epg(provider, channel_id):
try:
# Parse optional parameters
kwargs = {"country": request.query.get("country")}
from datetime import timezone
# Handle start_time - can be Unix timestamp (from Kodi) or datetime
if request.query.get("start_time"):
start_time_str = request.query.get("start_time")
try:
# Try to parse as Unix timestamp (integer from Kodi PVR)
start_time_int = int(start_time_str)
kwargs["start_time"] = datetime.fromtimestamp(
start_time_int, tz=timezone.utc
int(start_time_str), tz=timezone.utc
)
except (ValueError, TypeError):
# Try to parse as ISO format string (for manual API calls)
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}")
# Continue without start_time filter
pass
# Handle end_time - can be Unix timestamp or datetime
if request.query.get("end_time"):
end_time_str = request.query.get("end_time")
try:
# Try to parse as Unix timestamp (integer from Kodi PVR)
end_time_int = int(end_time_str)
kwargs["end_time"] = datetime.fromtimestamp(
end_time_int, tz=timezone.utc
int(end_time_str), tz=timezone.utc
)
except (ValueError, TypeError):
# Try to parse as ISO format string (for manual API calls)
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}")
# Continue without end_time filter
pass
# Get EPG data from manager
epg_data = manager.get_channel_epg(
provider_name=provider, channel_id=channel_id, **kwargs
)
# Return as JSON
response.content_type = "application/json; charset=utf-8"
return {"provider": provider, "channel_id": channel_id, "epg": epg_data}
except ValueError as val_err:
# This handles the case where manager raises ValueError for unknown provider
logger.error(
f"API Error in /api/providers/{provider}/channels/{channel_id}/epg: {str(val_err)}"
)
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(val_err)}
except Exception as api_err:
logger.error(
f"API Error in /api/providers/{provider}/channels/{channel_id}/epg: {str(api_err)}"
)
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(api_err)}"}
return {"error": f"Internal server error: {str(e)}"}
@app.route("/api/providers/<provider>/epg")
def get_provider_epg_xmltv(provider):
try:
# Set appropriate headers for XMLTV
response.content_type = "application/xml; charset=utf-8"
response.headers["Content-Disposition"] = (
f'attachment; filename="{provider}_epg.xml"'
)
# Get the XMLTV data from the provider
xmltv_data = manager.get_provider_epg_xmltv(
provider_name=provider, country=request.query.get("country")
)
@@ -311,186 +626,11 @@ def setup_stream_routes(app, manager, service):
return xmltv_data
except ValueError as val_err:
# Handle unknown provider
logger.error(f"API Error in /api/providers/{provider}/epg: {str(val_err)}")
except ValueError as e:
logger.error(f"XMLTV EPG error for {provider}: {e}")
response.status = 404
return {"error": str(val_err)}
except Exception as api_err:
logger.error(f"API Error in /api/providers/{provider}/epg: {str(api_err)}")
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(api_err)}"}
@app.route("/api/providers/<provider>/channels/<channel_id>/drm")
def get_channel_drm(provider, channel_id):
try:
# Get optional catchup parameters
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")
# Determine if this is a catchup request
is_catchup = bool(start_time and end_time)
if is_catchup:
logger.debug(
f"Catchup DRM request for {provider}/{channel_id}: "
f"epg_id={epg_id}"
)
# Convert timestamps
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"}
# Get catchup DRM configs
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:
# Live DRM - existing logic
drm_configs = manager.get_channel_drm_configs(
provider_name=provider, channel_id=channel_id, country=country
)
# Merge all DRM configs into a single dictionary
merged_drm_configs = {}
for config in drm_configs:
if hasattr(config, "to_dict"):
config_dict = config.to_dict()
else:
config_dict = config
merged_drm_configs.update(config_dict)
return {
"provider": provider,
"channel_id": channel_id,
"is_catchup": is_catchup,
"drm_configs": merged_drm_configs,
}
except ValueError as val_err:
logger.error(f"API Error in DRM endpoint: {str(val_err)}")
response.status = 404
return {"error": str(val_err)}
except Exception as api_err:
logger.error(f"API Error in DRM endpoint: {str(api_err)}")
response.status = 500
return {"error": f"Internal server error: {str(api_err)}"}
def _get_decrypted_stream_handler(provider, channel_id, highest_quality_only=False):
"""
Helper function for decrypted stream handling.
Args:
provider: Provider name
channel_id: Channel ID
highest_quality_only: If True, filter to highest quality video only
Returns:
Rewritten manifest or error response
"""
try:
country = request.query.get("country")
# Get DRM configs
drm_configs = manager.get_channel_drm_configs(
provider_name=provider, channel_id=channel_id, country=country
)
# Convert DRM configs to dictionary format
drm_dict = {}
if isinstance(drm_configs, list):
for config in drm_configs:
config_dict = config.to_dict()
drm_dict.update(config_dict)
# Check what type of stream we have
has_clearkey = "org.w3.clearkey" in drm_dict
is_unencrypted = "none" in drm_dict
# Handle ClearKey encrypted streams
if has_clearkey:
if not service.media_proxy_url:
response.status = 503
return {"error": "Media proxy not configured (MEDIA_PROXY_URL not set)"}
clearkey_data = drm_dict["org.w3.clearkey"]
keyids = clearkey_data.get("license", {}).get("keyids", {})
if not keyids:
response.status = 400
return {"error": "ClearKey DRM found but no key IDs available"}
# Return decrypted manifest via proxy
return service._get_decrypted_manifest(
provider, channel_id, keyids, highest_quality_only=highest_quality_only
)
# Handle unencrypted streams
elif is_unencrypted:
# If proxy needed and configured, rewrite manifest
if manager.needs_proxy(provider) and service.media_proxy_url:
return service._get_proxied_manifest(
provider, channel_id, highest_quality_only=highest_quality_only
)
else:
# No proxy needed/configured - passthrough original manifest
# Note: highest_quality_only has no effect on passthrough
return service._get_original_manifest(provider, channel_id, country)
# Other DRM systems not supported
else:
response.status = 400
return {
"error": f'Channel "{channel_id}" does not support decrypted playback (requires ClearKey or unencrypted)'
}
except ValueError as val_err:
logger.error(f"API Error in decrypted stream: {str(val_err)}")
response.status = 404
return {"error": str(val_err)}
except Exception as api_err:
logger.error(f"API Error in decrypted stream: {str(api_err)}")
response.status = 500
return {"error": f"Internal server error: {str(api_err)}"}
@app.route(
"/api/providers/<provider>/channels/<channel_id>/stream/decrypted/index.mpd"
)
def get_channel_stream_decrypted(provider, channel_id):
"""
Returns rewritten manifest for decrypted playback via media proxy.
Supports both ClearKey encrypted and unencrypted streams.
Includes all available video quality representations.
"""
return _get_decrypted_stream_handler(
provider=provider,
channel_id=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):
"""
Returns rewritten manifest for decrypted playback via media proxy.
Supports both ClearKey encrypted and unencrypted streams.
Filters to highest quality video representation only (optimized for ffmpeg).
Audio and subtitle tracks remain unchanged.
"""
return _get_decrypted_stream_handler(
provider=provider,
channel_id=channel_id,
highest_quality_only=True
)
return {"error": f"Internal server error: {str(e)}"}