import asyncio import os from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI, Request from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from lightsync.devices.registry import DeviceRegistry from lightsync.store.show_store import ShowStore from lightsync.audio.engine import MPVEngine # Module-level containers populated in lifespan registry: DeviceRegistry | None = None show_store: ShowStore | None = None engine: MPVEngine | None = None @asynccontextmanager async def lifespan(app: FastAPI): global registry, show_store, engine # Startup registry = DeviceRegistry(Path("devices.json")) await registry.load() show_store = ShowStore(Path("shows")) show_store.ensure_dir() ao = os.environ.get("MPV_AO", "null") engine = MPVEngine(ao=ao) engine.start() app.state.engine = engine from lightsync.api.ws import broadcast_loop task = asyncio.create_task(broadcast_loop(lambda: engine)) yield # Shutdown task.cancel() try: await task except asyncio.CancelledError: pass engine.stop() await registry.save() 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, 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" @app.get("/{filename:path}") async def static_files(request: Request, filename: str): path = frontend_dir / (filename or "index.html") if not path.exists() or not path.is_file(): path = frontend_dir / "index.html" response = FileResponse(path) response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" return response return app app = create_app()