"""Simcluster podcast transcripts -- FastAPI app + hourly feed poller.""" import asyncio import logging import os from contextlib import asynccontextmanager from dotenv import load_dotenv from fastapi import FastAPI, HTTPException from fastapi.staticfiles import StaticFiles load_dotenv() # must run before config reads the environment import config # noqa: E402 import db # noqa: E402 import pipeline # noqa: E402 logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") log = logging.getLogger("simcluster") _cycle_lock = asyncio.Lock() async def _run_cycle_locked(): if _cycle_lock.locked(): return {"started": False, "reason": "cycle already running"} async with _cycle_lock: processed = await asyncio.to_thread(pipeline.run_cycle) return {"started": True, "processed": processed} async def _poller(): while True: try: await _run_cycle_locked() except Exception: log.exception("poll cycle crashed") await asyncio.sleep(config.POLL_INTERVAL_SEC) @asynccontextmanager async def lifespan(app): db.init() task = asyncio.create_task(_poller()) yield task.cancel() app = FastAPI(title="simcluster transcripts", lifespan=lifespan) @app.get("/health") def health(): return {"status": "ok"} @app.get("/api/meta") def meta(): """Show branding for the frontend.""" return config.meta() @app.get("/api/episodes") def episodes(): return {"episodes": db.list_episodes()} @app.get("/api/episodes/{num}") def episode(num: int): ep = db.get_episode(num) if not ep: raise HTTPException(404, "no such episode") return ep @app.post("/api/poll") async def poll_now(): """Manually trigger a poll cycle. Dedupe makes this cost-safe.""" return await _run_cycle_locked() @app.post("/api/episodes/{num}/review") async def review_episode(num: int): """Run the editorial review pass on a DONE, not-yet-reviewed episode. Idempotence gate keeps this cost-bounded: once a review is stored the endpoint refuses, so it can't be spammed into a bill. """ ep = db.get_episode(num) if not ep: raise HTTPException(404, "no such episode") if ep["status"] != "done" or not ep.get("transcript"): raise HTTPException(409, f"episode is '{ep['status']}', not reviewable") tr = ep["transcript"] if tr.get("review"): raise HTTPException(409, "already reviewed") import review as review_mod edited, report = await asyncio.to_thread( review_mod.review_turns, tr["turns"], tr.get("host", config.HOST_NAME), tr.get("guest") or ep.get("guest") or "Guest", ep["title"], ep.get("description")) tr.update(raw_turns=tr["turns"], turns=edited, review=report) words = sum(len(t["text"].split()) for t in edited) db.save_transcript(ep["guid"], tr, cost_usd=ep.get("cost_usd"), word_count=words, names_resolved=bool(ep.get("names_resolved"))) return {"episode": num, "review": report} @app.post("/api/episodes/{num}/retry") async def retry(num: int): """Queue a FAILED or backfill-SKIPPED episode. Never re-runs successes.""" ep = db.get_episode(num) if not ep: raise HTTPException(404, "no such episode") if ep["status"] not in ("error", "skipped"): raise HTTPException(409, f"episode is '{ep['status']}', not retryable") db.set_status(ep["guid"], "pending") return await _run_cycle_locked() # Serve the built frontend when it's baked into the image (single-container # deploys). Behind a reverse proxy that serves the SPA itself, this mount is # simply never reached. _dist = os.path.join(os.path.dirname(os.path.abspath(__file__)), "frontend_dist") if os.path.isdir(_dist): app.mount("/", StaticFiles(directory=_dist, html=True), name="frontend")