from __future__ import annotations import asyncio import logging import pathlib from contextlib import asynccontextmanager from typing import Any from fastapi import Depends, FastAPI, HTTPException, Query from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from server.auth import create_token, require_auth, verify_pin from server.db import count_recaps, ensure_schema, get_db, get_recap, list_recaps, upsert_recap from server.recap import generate_recap, utc_now_iso from server.settings import settings from server.transcripts import estimate_duration_sec, fetch_transcript from server.youtube import extract_video_id, fetch_metadata NO_STORE = {"Cache-Control": "no-store"} def _configure_logging() -> None: fmt = logging.Formatter("%(levelname)s: %(message)s") root = logging.getLogger("server") if not root.handlers: handler = logging.StreamHandler() handler.setFormatter(fmt) root.addHandler(handler) root.setLevel(logging.INFO) root.propagate = False _configure_logging() logger = logging.getLogger(__name__) @asynccontextmanager async def lifespan(app: FastAPI): db = await get_db() await ensure_schema(db) await db.close() yield app = FastAPI(title="Goldfish Recap", version=settings.app_version, lifespan=lifespan) @app.get("/api/health") async def health() -> JSONResponse: return JSONResponse({"ok": True, "version": settings.app_version}) @app.post("/api/auth/pin") async def auth_pin(payload: dict[str, Any]) -> JSONResponse: pin = str(payload.get("pin", "")) if not verify_pin(pin): raise HTTPException(status_code=401, detail="Falscher PIN. Dein Goldfisch auch.") return JSONResponse({"token": create_token()}, headers=NO_STORE) @app.get("/api/recaps") async def api_list_recaps( limit: int = Query(default=20, ge=1, le=100), offset: int = Query(default=0, ge=0), ) -> JSONResponse: db = await get_db() try: items = await list_recaps(db, limit=limit, offset=offset) total = await count_recaps(db) finally: await db.close() return JSONResponse({"items": items, "total": total, "limit": limit, "offset": offset}) @app.get("/api/recaps/{video_id}") async def api_get_recap(video_id: str) -> JSONResponse: db = await get_db() try: recap = await get_recap(db, video_id) finally: await db.close() if not recap: raise HTTPException(status_code=404, detail="Recap nicht gefunden.") return JSONResponse(recap) async def _create_or_regenerate(video_id: str, *, force: bool) -> tuple[dict[str, Any], bool]: db = await get_db() try: existing = await get_recap(db, video_id) if existing and not force: return existing, False metadata = await fetch_metadata(video_id) def _sync_fetch() -> tuple[Any, int | None]: transcript = fetch_transcript(video_id) duration = estimate_duration_sec(transcript.segments) return transcript, duration try: transcript, duration_sec = await asyncio.to_thread(_sync_fetch) except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc try: recap = await generate_recap( metadata=metadata, transcript=transcript, duration_sec=duration_sec, ) except RuntimeError as exc: raise HTTPException(status_code=503, detail=str(exc)) from exc if existing: recap["created_at"] = existing["created_at"] recap["updated_at"] = utc_now_iso() await upsert_recap(db, recap) stored = await get_recap(db, video_id) if not stored: raise HTTPException(status_code=500, detail="Recap speichern fehlgeschlagen.") return stored, True finally: await db.close() @app.post("/api/recaps") async def api_create_recap(payload: dict[str, Any], _: dict = Depends(require_auth)) -> JSONResponse: url = str(payload.get("url", "")).strip() if not url: raise HTTPException(status_code=400, detail="YouTube-URL fehlt.") force = bool(payload.get("force", False)) try: video_id = extract_video_id(url) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc recap, created = await _create_or_regenerate(video_id, force=force) status = 201 if created else 200 return JSONResponse( {"recap": recap, "created": created}, status_code=status, headers=NO_STORE, ) @app.post("/api/recaps/{video_id}/regenerate") async def api_regenerate_recap(video_id: str, _: dict = Depends(require_auth)) -> JSONResponse: try: extract_video_id(video_id) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc recap, _ = await _create_or_regenerate(video_id, force=True) return JSONResponse({"recap": recap, "created": False, "regenerated": True}, headers=NO_STORE) @app.get("/r/{video_id}") async def recap_page(video_id: str) -> FileResponse: public = pathlib.Path(settings.public_dir) return FileResponse(public / "r.html") PUBLIC = pathlib.Path(settings.public_dir) if PUBLIC.exists(): app.mount("/", StaticFiles(directory=str(PUBLIC), html=True), name="static")