import asyncio import contextlib from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI, HTTPException from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles import app.models # noqa: F401 — registers models on Base.metadata before create_all from app.config import settings from app.db import Base, async_session_maker, engine from app.routes.auth import router as auth_router from app.routes.codex import router as codex_router from app.routes.shop import router as shop_router from app.session_cleanup import delete_expired_sessions from app.ws import AUDIO_DIR from app.ws import router as ws_router FRONTEND_DIST = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist" SESSION_CLEANUP_INTERVAL_SECONDS = 30 * 60 async def _session_cleanup_loop() -> None: """Periodically sweeps expired auth_sessions rows so the table doesn't grow forever — get_current_user already rejects expired sessions on read, this just deletes the rows themselves.""" try: while True: await asyncio.sleep(SESSION_CLEANUP_INTERVAL_SECONDS) try: async with async_session_maker() as db: await delete_expired_sessions(db) except Exception: # A transient DB hiccup shouldn't kill the sweep loop — # just try again next interval. pass except asyncio.CancelledError: pass @asynccontextmanager async def lifespan(app: FastAPI): AUDIO_DIR.mkdir(parents=True, exist_ok=True) async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) cleanup_task = asyncio.create_task(_session_cleanup_loop()) try: yield finally: cleanup_task.cancel() with contextlib.suppress(asyncio.CancelledError): await cleanup_task app = FastAPI(title="Quantumancy", lifespan=lifespan) app.include_router(auth_router) app.include_router(codex_router) app.include_router(shop_router) app.include_router(ws_router) @app.get("/healthz") async def healthz(): return {"status": "ok"} app.mount( "/assets", StaticFiles(directory=FRONTEND_DIST / "assets", check_dir=False), name="frontend-assets", ) app.mount( "/audio", StaticFiles(directory=AUDIO_DIR, check_dir=False), name="spirit-audio", ) @app.get("/{full_path:path}") async def serve_spa(full_path: str): index_file = FRONTEND_DIST / "index.html" if not index_file.exists(): raise HTTPException( status_code=503, detail="Frontend not built. Run `npm run build` in frontend/ and restart.", ) return FileResponse(index_file)