"""Application factory and dependency wiring.""" from collections.abc import AsyncIterator from contextlib import asynccontextmanager from pathlib import Path import httpx2 from anthropic import AsyncAnthropic from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from app.adapters.anthropic.llm import AnthropicRecommender from app.adapters.spotify.auth import TokenSet, refresh_access_token from app.adapters.spotify.client import SpotifyClient from app.adapters.spotify.session import PendingLogins, SessionStore, SpotifySession from app.api.recommendations import router as recommendations_router from app.api.routes import router from app.config import AppMode, Settings, settings from app.observability.logging import configure_logging from app.observability.timing import RequestTimingMiddleware from app.pipeline.orchestrator import RecommendationPipeline 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 configure_logging(active_settings.app_mode) @asynccontextmanager async def lifespan(application: FastAPI) -> AsyncIterator[None]: _validate_live_settings(active_settings) 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 application.state.seed_session_id = None anthropic_client = AsyncAnthropic( api_key=active_settings.anthropic_api_key or "unused-demo-key" ) application.state.anthropic = anthropic_client application.state.recommendation_pipeline = RecommendationPipeline( AnthropicRecommender(anthropic_client, active_settings), active_settings, ) def spotify_client_factory(session: SpotifySession) -> SpotifyClient: return SpotifyClient(http, session, active_settings) application.state.spotify_client_factory = spotify_client_factory if ( active_settings.app_mode is AppMode.LIVE and active_settings.spotify_seed_refresh_token ): application.state.seed_session_id = await _install_seed_session( http, application.state.session_store, active_settings, ) try: yield finally: await anthropic_client.close() app = FastAPI( title="discovery-by-llm", docs_url=None, redoc_url=None, lifespan=lifespan, ) app.add_middleware(RequestTimingMiddleware) @app.get("/api/health") def health() -> dict[str, str]: return {"status": "ok", "mode": active_settings.app_mode} app.include_router(router) app.include_router(recommendations_router) if FRONTEND_DIST.is_dir(): app.mount("/", StaticFiles(directory=FRONTEND_DIST, html=True), name="spa") return app app = create_app() def _validate_live_settings(application_settings: Settings) -> None: if application_settings.app_mode is not AppMode.LIVE: return if not application_settings.spotify_client_id: raise RuntimeError("SPOTIFY_CLIENT_ID is required in live mode") if not application_settings.anthropic_api_key: raise RuntimeError("ANTHROPIC_API_KEY is required in live mode") async def _install_seed_session( http: httpx2.AsyncClient, session_store: SessionStore, application_settings: Settings, ) -> str: tokens = await refresh_access_token( http, client_id=application_settings.spotify_client_id, tokens=TokenSet( access_token="seed-bootstrap", refresh_token=application_settings.spotify_seed_refresh_token, expires_at=0.0, ), ) bootstrap_session = SpotifySession(tokens=tokens, account_id="", display_name="") current_user = await SpotifyClient( http, bootstrap_session, application_settings, ).fetch_current_user() return session_store.create( SpotifySession( tokens=bootstrap_session.tokens, account_id=current_user.account_id, display_name=current_user.display_name, ) )