Files
ytrecap/server/app.py
T
Frank Schwenk 3f1da92cf4 feat: add Goldfish Recap YouTube summary PWA
YouTube subtitles via OpenRouter become persistent, shareable recaps
with timestamp jump links, PIN-protected creation, and Traefik deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 15:49:24 +02:00

170 lines
5.3 KiB
Python

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")