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
This commit is contained in:
Claude
2026-04-06 13:07:45 +00:00
parent 5e400a6834
commit de455400d2
2 changed files with 49 additions and 1 deletions

47
lightsync/api/audio.py Normal file
View File

@@ -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()

View File

@@ -45,10 +45,11 @@ def create_app() -> FastAPI:
app = FastAPI(title="LightSync", lifespan=lifespan, redirect_slashes=False) app = FastAPI(title="LightSync", lifespan=lifespan, redirect_slashes=False)
# API routes (registered before static mount — see Pattern 7) # 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(shows.router, prefix="/api/shows")
app.include_router(devices.router, prefix="/api/devices") app.include_router(devices.router, prefix="/api/devices")
app.include_router(ws.router) app.include_router(ws.router)
app.include_router(audio.router, prefix="/api/audio")
# Static file serving — no-cache headers to prevent stale JS/CSS # Static file serving — no-cache headers to prevent stale JS/CSS
frontend_dir = Path(__file__).parent / "frontend" frontend_dir = Path(__file__).parent / "frontend"