#!/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//channels//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//channels//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//channels//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//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//channels//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)}"} @app.route( "/api/providers//channels//stream/decrypted/index.mpd" ) def get_channel_stream_decrypted(provider, channel_id): """ Returns rewritten manifest for decrypted playback via media proxy. This endpoint is used by the decrypted M3U playlists. """ try: # Check if media proxy is configured if not service.media_proxy_url: response.status = 503 return {"error": "Media proxy not configured (MEDIA_PROXY_URL not set)"} country = request.query.get("country") # Get DRM configs to extract ClearKey data drm_configs = manager.get_channel_drm_configs( provider_name=provider, channel_id=channel_id, country=country ) # Convert DRM configs to dictionary format (same as /drm endpoint) drm_dict = {} if isinstance(drm_configs, list): for config in drm_configs: # Use the model's to_dict() method config_dict = config.to_dict() drm_dict.update(config_dict) # Now check for ClearKey in the dictionary format clearkey_data = None if "org.w3.clearkey" in drm_dict: clearkey_data = drm_dict["org.w3.clearkey"] if not clearkey_data: response.status = 400 return {"error": f'Channel "{channel_id}" does not have ClearKey DRM'} # Extract the key information keyids = clearkey_data.get("license", {}).get("keyids", {}) if not keyids: response.status = 400 return {"error": "ClearKey DRM found but no key IDs available"} # Get manifest (check if needs proxy) if manager.needs_proxy(provider): # Get rewritten manifest with media proxy URLs + decrypt params return service._get_decrypted_manifest(provider, channel_id, keyids) else: # No proxy needed - return error (decryption requires media proxy) response.status = 400 return { "error": "Decrypted playback requires provider proxy configuration" } 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)}"}