"""Application factory and wiring. No logic lives here.""" from collections.abc import AsyncIterator from contextlib import asynccontextmanager from pathlib import Path import httpx2 from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from app.adapters.spotify.session import PendingLogins, SessionStore from app.api.routes import router from app.config import AppMode, Settings, settings FRONTEND_DIST = Path(__file__).parent / "static" def create_app( application_settings: Settings | None = None, http_transport: httpx2.AsyncBaseTransport | None = None, ) -> FastAPI: """Build the FastAPI app: API routes plus the built SPA on one port.""" active_settings = application_settings or settings @asynccontextmanager async def lifespan(application: FastAPI) -> AsyncIterator[None]: if active_settings.app_mode is AppMode.LIVE and not active_settings.spotify_client_id: raise RuntimeError("SPOTIFY_CLIENT_ID is required in live mode") async with httpx2.AsyncClient( timeout=active_settings.spotify_timeout_seconds, transport=http_transport, ) as http: application.state.http = http application.state.session_store = SessionStore() application.state.pending_logins = PendingLogins() application.state.settings = active_settings yield app = FastAPI( title="discovery-by-llm", docs_url=None, redoc_url=None, lifespan=lifespan, ) @app.get("/api/health") def health() -> dict[str, str]: return {"status": "ok", "mode": active_settings.app_mode} app.include_router(router) if FRONTEND_DIST.is_dir(): app.mount("/", StaticFiles(directory=FRONTEND_DIST, html=True), name="spa") return app app = create_app()