cdf04bd8eb
Lint & Validate / lint (push) Has been cancelled
- 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)
268 lines
11 KiB
Python
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
|