diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py new file mode 100644 index 0000000..6996cee --- /dev/null +++ b/backend/app/api/routes.py @@ -0,0 +1,259 @@ +"""FastAPI REST API routes for Deltacast SDI recorder control.""" + +import logging +from fastapi import APIRouter, HTTPException, Depends +from typing import Any, Optional +from pydantic import BaseModel, Field + +from ..models import RecorderConfig, PortStatus, SCTE35Marker +from ..recorders.recorder import RecorderManager +from ..recorders.scte35 import SCTE35Manager + +logger = logging.getLogger(__name__) + +# ============================================================================ +# Pydantic Models for Request Validation +# ============================================================================ + + +class SCTE35InjectionRequest(BaseModel): + """Request model for SCTE35 marker injection endpoint.""" + event_id: int = Field(..., description="Unique event identifier") + duration_seconds: float = Field(..., gt=0, description="Duration in seconds") + webhook_url: Optional[str] = Field(None, description="Optional webhook URL") + out_of_network: bool = Field(True, description="Out of network indicator") + splice_immediate: bool = Field(False, description="Splice immediate flag") + + +router = APIRouter(prefix="/api") + +# These will be set by main.py during startup +recorder_manager: RecorderManager | None = None +scte35_manager: SCTE35Manager | None = None + + +def get_recorder_manager() -> RecorderManager: + """Dependency to get the RecorderManager instance.""" + if recorder_manager is None: + raise HTTPException(status_code=503, detail="Recorder not initialized") + return recorder_manager + + +def get_scte35_manager() -> SCTE35Manager: + """Dependency to get the SCTE35Manager instance.""" + if scte35_manager is None: + raise HTTPException(status_code=503, detail="SCTE35 manager not initialized") + return scte35_manager + + +# ============================================================================ +# Health and Status Endpoints +# ============================================================================ + + +@router.get("/health") +async def health(manager: RecorderManager = Depends(get_recorder_manager)) -> dict[str, Any]: + """ + Health check endpoint. + + Returns: + dict with status, port count, and recording count + """ + try: + all_status = manager.get_all_status() + recording_count = sum(1 for status in all_status if status.is_recording) + + return { + "status": "ok", + "ports": len(all_status), + "recording_count": recording_count, + } + except Exception as e: + logger.error(f"Health check error: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + +# ============================================================================ +# Port Status Endpoints +# ============================================================================ + + +@router.get("/ports", response_model=list[PortStatus]) +async def list_ports(manager: RecorderManager = Depends(get_recorder_manager)) -> list[PortStatus]: + """ + Get status of all recorder ports. + + Returns: + List of PortStatus objects for all ports + """ + try: + return manager.get_all_status() + except Exception as e: + logger.error(f"Error listing ports: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + +@router.get("/ports/{port_index}", response_model=PortStatus) +async def get_port( + port_index: int, + manager: RecorderManager = Depends(get_recorder_manager), +) -> PortStatus: + """ + Get status of a specific recorder port. + + Args: + port_index: 0-based port index + + Returns: + PortStatus for the specified port + + Raises: + HTTPException(404): If port index is invalid + """ + try: + return manager.get_status(port_index) + except ValueError as e: + logger.warning(f"Invalid port index {port_index}: {e}") + raise HTTPException(status_code=404, detail=f"Port {port_index} not found") + except Exception as e: + logger.error(f"Error getting port {port_index}: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + +# ============================================================================ +# Recording Control Endpoints +# ============================================================================ + + +@router.post("/ports/{port_index}/start") +async def start_recording( + port_index: int, + config: RecorderConfig, + manager: RecorderManager = Depends(get_recorder_manager), +) -> dict[str, Any]: + """ + Start recording on a specific port. + + Args: + port_index: 0-based port index + config: RecorderConfig with codec and output settings + + Returns: + Message confirmation with port number + + Raises: + HTTPException(400): If port is already recording or config is invalid + HTTPException(404): If port index is invalid + """ + try: + await manager.start_recording(port_index, config) + logger.info(f"Started recording on port {port_index}") + return { + "message": "Recording started", + "port": port_index, + } + except ValueError as e: + logger.warning(f"Invalid port {port_index}: {e}") + raise HTTPException(status_code=404, detail=f"Port {port_index} not found") + except RuntimeError as e: + logger.warning(f"Cannot start recording on port {port_index}: {e}") + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Error starting recording on port {port_index}: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + +@router.post("/ports/{port_index}/stop") +async def stop_recording( + port_index: int, + manager: RecorderManager = Depends(get_recorder_manager), +) -> dict[str, Any]: + """ + Stop recording on a specific port. + + Args: + port_index: 0-based port index + + Returns: + Message confirmation with port number + + Raises: + HTTPException(404): If port index is invalid + """ + try: + await manager.stop_recording(port_index) + logger.info(f"Stopped recording on port {port_index}") + return { + "message": "Recording stopped", + "port": port_index, + } + except ValueError as e: + logger.warning(f"Invalid port {port_index}: {e}") + raise HTTPException(status_code=404, detail=f"Port {port_index} not found") + except Exception as e: + logger.error(f"Error stopping recording on port {port_index}: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + +# ============================================================================ +# SCTE35 Ad Break Endpoints +# ============================================================================ + + +@router.post("/scte35/inject", response_model=SCTE35Marker) +async def inject_scte35_marker( + request: SCTE35InjectionRequest, + manager: SCTE35Manager = Depends(get_scte35_manager), +) -> SCTE35Marker: + """ + Inject a SCTE35 ad break marker. + + Args: + request: SCTE35InjectionRequest with: + - event_id: int (unique event identifier) + - duration_seconds: float (duration in seconds) + - webhook_url: str | None (optional callback URL) + - out_of_network: bool (whether out-of-network ad) + - splice_immediate: bool (whether splice immediately) + + Returns: + SCTE35Marker with timestamp and metadata + + Raises: + HTTPException(400): If parameters are invalid + """ + try: + marker = await manager.inject_marker( + event_id=request.event_id, + duration_seconds=request.duration_seconds, + webhook_url=request.webhook_url, + out_of_network=request.out_of_network, + splice_immediate=request.splice_immediate, + ) + + logger.info(f"Injected SCTE35 marker with event_id {request.event_id}") + return marker + + except ValueError as e: + logger.warning(f"Invalid SCTE35 parameters: {e}") + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Error injecting SCTE35 marker: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + +@router.get("/scte35/history", response_model=list[SCTE35Marker]) +async def get_scte35_history( + manager: SCTE35Manager = Depends(get_scte35_manager), +) -> list[SCTE35Marker]: + """ + Get history of injected SCTE35 markers. + + Returns: + List of SCTE35Marker objects in chronological order + """ + try: + return manager.get_marker_history() + except Exception as e: + logger.error(f"Error retrieving SCTE35 history: {e}") + raise HTTPException(status_code=500, detail="Internal server error")