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>
This commit is contained in:
+169
@@ -0,0 +1,169 @@
|
||||
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")
|
||||
Reference in New Issue
Block a user