Files
raspberry-pi-mixer/src/network/stream_routes.py
T
shawn cdf04bd8eb
Lint & Validate / lint (push) Has been cancelled
P6-R1: Audio/video streaming pipeline
- src/streaming/ module: camera detection (USB webcam + Pi Camera Module via
  v4l2/libcamera), GStreamer pipeline builder with V4L2/OMX/x264 h264 encoders,
  platform presets (YouTube, Twitch, Facebook, custom RTMP), streamer lifecycle
  manager with auto-reconnect, scene management, stream statistics
- Keyboard shortcut controller (evdev + pynput fallback) for stream control:
  Ctrl+Shift+S (start/stop), Ctrl+Shift+1-9 (scene switch), Ctrl+Shift+R (reconnect)
- REST API: /api/v1/stream/* endpoints for start/stop/status, scenes, cameras,
  platforms, and hotkey listing — integrated into NetworkServer
- StreamStateProvider bridging layer connects REST routes to Streamer instance
- GStreamer pipeline supports ALSA/JACK/PulseAudio audio capture and
  V4L2/libcamera video capture with H.264 hardware encoding on RPi4B
- Low-latency streaming settings for live performance (zerolatency, no B-frames)
- 89 streaming tests (709 total project tests pass)
2026-05-19 22:13:51 -04:00

268 lines
11 KiB
Python

"""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