Files
script.service.ultimate/routes/streams.py
T
2026-01-29 17:27:04 +01:00

437 lines
18 KiB
Python

#!/usr/bin/env python3
"""
Stream and manifest route handlers
"""
from datetime import datetime
from bottle import HTTPResponse, redirect, request, response
from streaming_providers.base.utils import logger
def setup_stream_routes(app, manager, service):
"""Setup stream and manifest-related routes"""
@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.
"""
try:
# 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
country = request.query.get("country")
if country:
stream_url += f"?country={country}"
return {
"provider": provider,
"channel_id": channel_id,
"manifest_url": stream_url, # Now points to /stream/index.mpd endpoint
}
except ValueError as val_err:
logger.error(
f"API Error in /api/providers/{provider}/channels/{channel_id}/manifest: {str(val_err)}"
)
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)}"
)
response.status = 500
return {"error": f"Internal server error: {str(api_err)}"}
@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.
"""
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)
except (ValueError, TypeError):
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
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:
response.status = 400
return {
"error": f"Content outside catchup window (max {catchup_hours} hours)"
}
# 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
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)
except HTTPResponse:
raise
except ValueError as val_err:
logger.error(f"API Error in stream: {str(val_err)}")
response.status = 404
return {"error": str(val_err)}
except Exception as api_err:
logger.error(f"API Error in stream: {str(api_err)}")
response.status = 500
return {"error": f"Internal server error: {str(api_err)}"}
@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
)
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
)
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)}"
)
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)}"
)
response.status = 500
response.content_type = "application/json; charset=utf-8"
return {"error": f"Internal server error: {str(api_err)}"}
@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")
)
if not xmltv_data:
response.status = 404
return {"error": f'EPG data not available for provider "{provider}"'}
return xmltv_data
except ValueError as val_err:
# Handle unknown provider
logger.error(f"API Error in /api/providers/{provider}/epg: {str(val_err)}")
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)}")
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
)