From de455400d2c2b4b06ee1dd05d1ba5b02f34c55fe Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 13:07:45 +0000 Subject: [PATCH] feat(02-02): add REST audio load endpoint with file validation - Create lightsync/api/audio.py with POST /load and GET /state endpoints - Validate file existence and extension (.mp3/.wav/.flac/.ogg) - Use asyncio.to_thread for engine.load to avoid blocking event loop - Return 404/400/503 on error conditions - Register audio router at /api/audio prefix in main.py - AUD-02 (YouTube) noted as Phase 6 deferral --- lightsync/api/audio.py | 47 ++++++++++++++++++++++++++++++++++++++++++ lightsync/main.py | 3 ++- 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 lightsync/api/audio.py diff --git a/lightsync/api/audio.py b/lightsync/api/audio.py new file mode 100644 index 0000000..421d27d --- /dev/null +++ b/lightsync/api/audio.py @@ -0,0 +1,47 @@ +"""Audio loading and status endpoints.""" +import asyncio +from pathlib import Path +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel + +router = APIRouter() + +SUPPORTED_EXTENSIONS = {".mp3", ".wav", ".flac", ".ogg"} + + +class LoadRequest(BaseModel): + path: str + + +class AudioState(BaseModel): + position: float + paused: bool + duration: float | None + loaded: bool + file: str | None + + +@router.post("/load") +async def load_audio(req: LoadRequest, request: Request): + """Load an audio file by server path (AUD-01). YouTube URL deferred to Phase 6 (AUD-02 stub).""" + engine = request.app.state.engine + if engine is None: + raise HTTPException(status_code=503, detail="Audio engine not ready") + + p = Path(req.path) + if not p.exists(): + raise HTTPException(status_code=404, detail=f"File not found: {req.path}") + if p.suffix.lower() not in SUPPORTED_EXTENSIONS: + raise HTTPException(status_code=400, detail=f"Unsupported format: {p.suffix}. Supported: {', '.join(sorted(SUPPORTED_EXTENSIONS))}") + + await asyncio.to_thread(engine.load, req.path) + return {"status": "loaded", "path": req.path} + + +@router.get("/state") +async def get_audio_state(request: Request): + """Get current audio playback state.""" + engine = request.app.state.engine + if engine is None: + raise HTTPException(status_code=503, detail="Audio engine not ready") + return engine.get_state() diff --git a/lightsync/main.py b/lightsync/main.py index 5dbbd00..bf5db44 100644 --- a/lightsync/main.py +++ b/lightsync/main.py @@ -45,10 +45,11 @@ def create_app() -> FastAPI: app = FastAPI(title="LightSync", lifespan=lifespan, redirect_slashes=False) # API routes (registered before static mount — see Pattern 7) - from lightsync.api import shows, devices, ws + from lightsync.api import shows, devices, ws, audio app.include_router(shows.router, prefix="/api/shows") app.include_router(devices.router, prefix="/api/devices") app.include_router(ws.router) + app.include_router(audio.router, prefix="/api/audio") # Static file serving — no-cache headers to prevent stale JS/CSS frontend_dir = Path(__file__).parent / "frontend"