3f1da92cf4
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>
170 lines
5.3 KiB
Python
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")
|