"""Streaming API routes for the network server. Provides REST endpoints for: - Stream control (start, stop, status) - Scene management - Camera detection - Platform presets - Stream statistics Endpoints: GET /api/v1/stream/status — stream state and stats POST /api/v1/stream/start — start streaming POST /api/v1/stream/stop — stop streaming POST /api/v1/stream/configure — configure stream settings GET /api/v1/stream/cameras — list detected cameras GET /api/v1/stream/platforms — list platform presets GET /api/v1/stream/scenes — list scenes POST /api/v1/stream/scenes — add a scene DELETE /api/v1/stream/scenes/{name} — remove a scene POST /api/v1/stream/scenes/{name}/activate — switch to scene GET /api/v1/stream/hotkeys — list hotkey mappings """ from __future__ import annotations import logging from typing import Callable, Optional, Any from fastapi import APIRouter, HTTPException, Request from .auth import APIKeyAuth, API_KEY_HEADER from ..streaming.platforms import list_platforms, get_platform_profile from ..streaming.camera import detect_cameras from ..streaming.controls import DEFAULT_HOTKEYS logger = logging.getLogger(__name__) # ── Stream state provider adapter ─────────────────────────────────────────── class StreamStateProvider: """Adapter that connects the REST API to the Streamer instance. The NetworkServer sets callbacks on this provider to bridge the REST API layer to the actual Streamer instance. """ def __init__(self): # State self.get_status: Optional[Callable[[], dict]] = None self.get_config: Optional[Callable[[], dict]] = None # Control self.start_stream: Optional[Callable[[], dict]] = None self.stop_stream: Optional[Callable[[], dict]] = None self.configure_stream: Optional[Callable[[dict], dict]] = None # Scenes self.list_scenes: Optional[Callable[[], list[dict]]] = None self.add_scene: Optional[Callable[[dict], dict]] = None self.remove_scene: Optional[Callable[[str], dict]] = None self.activate_scene: Optional[Callable[[str], dict]] = None @staticmethod def check_auth(request: Request, auth: "APIKeyAuth") -> None: """Check API key auth on a request. Raises HTTPException if invalid.""" api_key = request.headers.get(API_KEY_HEADER, "").strip() if not api_key: raise HTTPException(status_code=401, detail="API key required") if not auth.validate(api_key): raise HTTPException(status_code=403, detail="Invalid API key") # ── Router factory ────────────────────────────────────────────────────────── def create_stream_router( state: StreamStateProvider, auth: APIKeyAuth, prefix: str = "", ) -> APIRouter: """Create the FastAPI router with streaming endpoints. Args: state: StreamStateProvider with callbacks wired to the Streamer. auth: API key auth for endpoint protection. prefix: URL prefix (e.g. "/api/v1"). Returns: FastAPI APIRouter with stream endpoints. """ router = APIRouter(prefix=prefix, tags=["streaming"]) # ── Status ───────────────────────────────────────────────────────── @router.get("/stream/status") async def get_stream_status(request: Request): """Get current streaming status and statistics.""" state.check_auth(request, auth) if state.get_status is None: return {"state": "unavailable", "error": "Streaming module not initialized"} try: return state.get_status() except Exception as exc: logger.error("Error getting stream status: %s", exc) return {"state": "error", "error": str(exc)} # ── Control ──────────────────────────────────────────────────────── @router.post("/stream/start") async def start_stream(request: Request): """Start the streaming pipeline.""" state.check_auth(request, auth) if state.start_stream is None: raise HTTPException(status_code=503, detail="Streaming module not initialized") try: result = state.start_stream() return result except Exception as exc: logger.error("Error starting stream: %s", exc) raise HTTPException(status_code=500, detail=str(exc)) @router.post("/stream/stop") async def stop_stream(request: Request): """Stop the streaming pipeline.""" state.check_auth(request, auth) if state.stop_stream is None: raise HTTPException(status_code=503, detail="Streaming module not initialized") try: result = state.stop_stream() return result except Exception as exc: logger.error("Error stopping stream: %s", exc) raise HTTPException(status_code=500, detail=str(exc)) @router.post("/stream/configure") async def configure_stream(request: Request): """Configure streaming settings (platform, key, devices).""" state.check_auth(request, auth) if state.configure_stream is None: raise HTTPException(status_code=503, detail="Streaming module not initialized") try: body = await request.json() result = state.configure_stream(body) return result except Exception as exc: logger.error("Error configuring stream: %s", exc) raise HTTPException(status_code=500, detail=str(exc)) # ── Cameras ──────────────────────────────────────────────────────── @router.get("/stream/cameras") async def list_cameras(request: Request): """List detected cameras.""" state.check_auth(request, auth) try: cameras = detect_cameras() return { "cameras": [c.to_dict() for c in cameras], "count": len(cameras), } except Exception as exc: logger.error("Error detecting cameras: %s", exc) return {"cameras": [], "count": 0, "error": str(exc)} # ── Platforms ────────────────────────────────────────────────────── @router.get("/stream/platforms") async def list_stream_platforms(request: Request): """List available streaming platform presets.""" state.check_auth(request, auth) return { "platforms": list_platforms(), } @router.get("/stream/platforms/{platform_id}") async def get_platform(request: Request, platform_id: str): """Get a specific platform preset.""" state.check_auth(request, auth) profile = get_platform_profile(platform_id) if profile is None: raise HTTPException(status_code=404, detail=f"Platform '{platform_id}' not found") return profile.to_dict() # ── Scenes ───────────────────────────────────────────────────────── @router.get("/stream/scenes") async def list_stream_scenes(request: Request): """List configured streaming scenes.""" state.check_auth(request, auth) if state.list_scenes is None: return {"scenes": [], "error": "Streaming module not initialized"} try: scenes = state.list_scenes() return {"scenes": scenes} except Exception as exc: logger.error("Error listing scenes: %s", exc) return {"scenes": [], "error": str(exc)} @router.post("/stream/scenes") async def add_stream_scene(request: Request): """Add a new streaming scene.""" state.check_auth(request, auth) if state.add_scene is None: raise HTTPException(status_code=503, detail="Streaming module not initialized") try: body = await request.json() result = state.add_scene(body) return result except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) except Exception as exc: logger.error("Error adding scene: %s", exc) raise HTTPException(status_code=500, detail=str(exc)) @router.delete("/stream/scenes/{name}") async def remove_stream_scene(request: Request, name: str): """Remove a streaming scene.""" state.check_auth(request, auth) if state.remove_scene is None: raise HTTPException(status_code=503, detail="Streaming module not initialized") try: result = state.remove_scene(name) return result except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) except Exception as exc: logger.error("Error removing scene: %s", exc) raise HTTPException(status_code=500, detail=str(exc)) @router.post("/stream/scenes/{name}/activate") async def activate_stream_scene(request: Request, name: str): """Activate a streaming scene.""" state.check_auth(request, auth) if state.activate_scene is None: raise HTTPException(status_code=503, detail="Streaming module not initialized") try: result = state.activate_scene(name) return result except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) except Exception as exc: logger.error("Error activating scene: %s", exc) raise HTTPException(status_code=500, detail=str(exc)) # ── Hotkeys ──────────────────────────────────────────────────────── @router.get("/stream/hotkeys") async def list_stream_hotkeys(request: Request): """List configured stream keyboard shortcuts.""" state.check_auth(request, auth) hotkeys = [] for hk in DEFAULT_HOTKEYS: hotkeys.append({ "action": hk.hotkey.value, "modifiers": sorted(hk.modifiers), "key": hk.key, "description": hk.description, }) return {"hotkeys": hotkeys} return router