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:
Frank Schwenk
2026-06-16 15:49:24 +02:00
commit 3f1da92cf4
27 changed files with 2070 additions and 0 deletions
View File
+169
View File
@@ -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")
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
import hmac
from datetime import datetime, timedelta, timezone
from typing import Any
import jwt
from fastapi import Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from server.settings import settings
_bearer = HTTPBearer(auto_error=False)
ALGORITHM = "HS256"
def verify_pin(pin: str) -> bool:
return hmac.compare_digest(pin.strip(), settings.app_pin.strip())
def create_token() -> str:
expire = datetime.now(timezone.utc) + timedelta(days=settings.jwt_expire_days)
payload = {"sub": "admin", "exp": expire}
return jwt.encode(payload, settings.jwt_secret, algorithm=ALGORITHM)
def decode_token(token: str) -> dict[str, Any]:
try:
return jwt.decode(token, settings.jwt_secret, algorithms=[ALGORITHM])
except jwt.PyJWTError as exc:
raise HTTPException(status_code=401, detail="Invalid token") from exc
async def require_auth(
creds: HTTPAuthorizationCredentials | None = Depends(_bearer),
) -> dict[str, Any]:
if creds is None or creds.scheme.lower() != "bearer":
raise HTTPException(status_code=401, detail="Missing token")
return decode_token(creds.credentials)
+105
View File
@@ -0,0 +1,105 @@
from __future__ import annotations
import json
import pathlib
from typing import Any
import aiosqlite
from server.settings import settings
SCHEMA_PATH = str(pathlib.Path(__file__).resolve().parents[1] / "tools" / "schema.sql")
async def get_db() -> aiosqlite.Connection:
pathlib.Path(settings.db_path).parent.mkdir(parents=True, exist_ok=True)
db = await aiosqlite.connect(settings.db_path)
db.row_factory = aiosqlite.Row
await db.execute("PRAGMA foreign_keys = ON;")
return db
async def ensure_schema(db: aiosqlite.Connection) -> None:
schema = pathlib.Path(SCHEMA_PATH).read_text(encoding="utf-8")
await db.executescript(schema)
await db.commit()
def row_to_recap(row: aiosqlite.Row) -> dict[str, Any]:
data = dict(row)
data["sections"] = json.loads(data.pop("sections_json"))
return data
async def get_recap(db: aiosqlite.Connection, video_id: str) -> dict[str, Any] | None:
cur = await db.execute("SELECT * FROM recaps WHERE video_id = ?", (video_id,))
row = await cur.fetchone()
await cur.close()
return row_to_recap(row) if row else None
async def list_recaps(db: aiosqlite.Connection, *, limit: int = 20, offset: int = 0) -> list[dict[str, Any]]:
cur = await db.execute(
"""
SELECT video_id, title, channel, thumbnail_url, duration_sec, language,
tldr, vibe_check, watch_verdict, goldfish_note, created_at, updated_at
FROM recaps
ORDER BY created_at DESC
LIMIT ? OFFSET ?
""",
(limit, offset),
)
rows = await cur.fetchall()
await cur.close()
return [dict(row) for row in rows]
async def count_recaps(db: aiosqlite.Connection) -> int:
cur = await db.execute("SELECT COUNT(*) FROM recaps")
row = await cur.fetchone()
await cur.close()
return int(row[0]) if row else 0
async def upsert_recap(db: aiosqlite.Connection, recap: dict[str, Any]) -> None:
await db.execute(
"""
INSERT INTO recaps (
video_id, title, channel, thumbnail_url, duration_sec, language,
tldr, vibe_check, watch_verdict, goldfish_note, sections_json,
transcript_hash, model, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(video_id) DO UPDATE SET
title = excluded.title,
channel = excluded.channel,
thumbnail_url = excluded.thumbnail_url,
duration_sec = excluded.duration_sec,
language = excluded.language,
tldr = excluded.tldr,
vibe_check = excluded.vibe_check,
watch_verdict = excluded.watch_verdict,
goldfish_note = excluded.goldfish_note,
sections_json = excluded.sections_json,
transcript_hash = excluded.transcript_hash,
model = excluded.model,
updated_at = excluded.updated_at
""",
(
recap["video_id"],
recap["title"],
recap.get("channel"),
recap.get("thumbnail_url"),
recap.get("duration_sec"),
recap.get("language"),
recap["tldr"],
recap.get("vibe_check"),
recap["watch_verdict"],
recap.get("goldfish_note"),
json.dumps(recap["sections"], ensure_ascii=False),
recap.get("transcript_hash"),
recap.get("model"),
recap["created_at"],
recap["updated_at"],
),
)
await db.commit()
+181
View File
@@ -0,0 +1,181 @@
from __future__ import annotations
import json
import logging
import re
from datetime import datetime, timezone
from typing import Any, Literal
import httpx
from pydantic import BaseModel, Field, ValidationError, field_validator
from server.settings import settings
from server.transcripts import Transcript, format_transcript_for_prompt, transcript_hash
logger = logging.getLogger(__name__)
RECAP_SYSTEM = (
"Du bist der Goldfish Recap Bot — trocken-witzig, ADHD-aware, nie herablassend. "
"Du fasst YouTube-Videos für Leute zusammen, die nebenbei gespielt haben oder das Video "
"nicht schauen wollen. Antworte NUR mit validem JSON, ohne Markdown oder Erklärungen."
)
WatchVerdict = Literal["watch", "skim", "skip"]
class RecapSection(BaseModel):
title: str = Field(min_length=1, max_length=120)
summary: str = Field(min_length=1, max_length=500)
timestamp_seconds: int = Field(ge=0)
emoji: str = Field(default="🐟", max_length=8)
class RecapPayload(BaseModel):
tldr: str = Field(min_length=1, max_length=400)
vibe_check: str = Field(min_length=1, max_length=200)
watch_verdict: WatchVerdict
goldfish_note: str = Field(min_length=1, max_length=300)
sections: list[RecapSection] = Field(min_length=2, max_length=12)
@field_validator("sections")
@classmethod
def sort_sections(cls, sections: list[RecapSection]) -> list[RecapSection]:
return sorted(sections, key=lambda s: s.timestamp_seconds)
def utc_now_iso() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
def _extract_json(text: str) -> dict[str, Any]:
cleaned = text.strip()
if cleaned.startswith("```"):
cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned)
cleaned = re.sub(r"\s*```$", "", cleaned)
return json.loads(cleaned)
async def _call_openrouter(prompt: str, *, system: str = RECAP_SYSTEM) -> str | None:
if not settings.openrouter_api_key:
raise RuntimeError("OPENROUTER_API_KEY nicht gesetzt.")
async with httpx.AsyncClient(timeout=120.0) as client:
resp = await client.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={
"Authorization": f"Bearer {settings.openrouter_api_key}",
"HTTP-Referer": settings.app_url,
"X-Title": "Goldfish Recap",
},
json={
"model": settings.openrouter_model,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": prompt},
],
"max_tokens": 4096,
"response_format": {"type": "json_object"},
},
)
resp.raise_for_status()
data = resp.json()
if data.get("error"):
logger.error("OpenRouter error: %s", data["error"])
return None
choices = data.get("choices") or []
if not choices:
return None
content = (choices[0].get("message") or {}).get("content")
return str(content).strip() if content else None
def _build_prompt(
*,
title: str,
channel: str,
duration_sec: int | None,
language: str,
transcript_text: str,
) -> str:
duration_hint = f"{duration_sec // 60} Minuten" if duration_sec else "unbekannt"
return (
f"Video: {title}\n"
f"Kanal: {channel or 'unbekannt'}\n"
f"Dauer: ca. {duration_hint}\n"
f"Untertitel-Sprache: {language}\n\n"
"Erstelle ein Recap als JSON mit exakt diesen Feldern:\n"
"{\n"
' "tldr": "1-2 Sätze, das Wichtigste",\n'
' "vibe_check": "1 Satz Stimmung, z.B. Second-Screen-Energie 7/10",\n'
' "watch_verdict": "watch|skim|skip",\n'
' "goldfish_note": "witziger Abschluss für Goldfisch-Gedächtnis",\n'
' "sections": [\n'
" {\n"
' "title": "kurzer Abschnittstitel",\n'
' "summary": "2-3 Sätze",\n'
' "timestamp_seconds": 142,\n'
' "emoji": "🐟"\n'
" }\n"
" ]\n"
"}\n\n"
"Regeln:\n"
"- 4-8 sections, timestamp_seconds MÜSSEN aus dem Transcript stammen (keine erfundenen Zeiten)\n"
"- watch = lohnt sich ganz anzusehen, skim = nur Sprungmarken, skip = TL;DR reicht\n"
"- Deutsch, witzig aber informativ\n\n"
f"Transcript:\n{transcript_text}"
)
async def generate_recap(
*,
metadata: dict[str, Any],
transcript: Transcript,
duration_sec: int | None,
) -> dict[str, Any]:
transcript_text = format_transcript_for_prompt(transcript.segments)
prompt = _build_prompt(
title=metadata["title"],
channel=metadata.get("channel") or "",
duration_sec=duration_sec,
language=transcript.language,
transcript_text=transcript_text,
)
raw = await _call_openrouter(prompt)
if not raw:
raise RuntimeError("OpenRouter hat keine Antwort geliefert. Später nochmal versuchen.")
try:
parsed = _extract_json(raw)
payload = RecapPayload.model_validate(parsed)
except (json.JSONDecodeError, ValidationError) as exc:
logger.warning("Invalid recap JSON, retrying: %s", exc)
fix_prompt = (
f"Fixiere dieses JSON und gib NUR valides JSON zurück:\n{raw}\n\n"
f"Fehler: {exc}"
)
fixed = await _call_openrouter(fix_prompt)
if not fixed:
raise RuntimeError("Recap konnte nicht generiert werden.") from exc
payload = RecapPayload.model_validate(_extract_json(fixed))
now = utc_now_iso()
return {
"video_id": metadata["video_id"],
"title": metadata["title"],
"channel": metadata.get("channel"),
"thumbnail_url": metadata.get("thumbnail_url"),
"duration_sec": duration_sec,
"language": transcript.language,
"tldr": payload.tldr,
"vibe_check": payload.vibe_check,
"watch_verdict": payload.watch_verdict,
"goldfish_note": payload.goldfish_note,
"sections": [s.model_dump() for s in payload.sections],
"transcript_hash": transcript_hash(transcript.segments),
"model": settings.openrouter_model,
"created_at": now,
"updated_at": now,
}
+22
View File
@@ -0,0 +1,22 @@
from __future__ import annotations
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
app_pin: str = "1234"
jwt_secret: str = "dev-secret-change-me"
jwt_expire_days: int = 90
openrouter_api_key: str = ""
openrouter_model: str = "google/gemini-2.5-flash"
db_path: str = "data/ytrecap.sqlite"
public_dir: str = "public"
app_url: str = "http://localhost:8000"
app_version: str = "1.0.0"
settings = Settings() # type: ignore[call-arg]
+178
View File
@@ -0,0 +1,178 @@
from __future__ import annotations
import hashlib
import logging
import re
from dataclasses import dataclass
from youtube_transcript_api import YouTubeTranscriptApi
from youtube_transcript_api._errors import (
NoTranscriptFound,
TranscriptsDisabled,
VideoUnavailable,
YouTubeRequestFailed,
)
logger = logging.getLogger(__name__)
PREFERRED_LANGUAGES = ("de", "en", "de-DE", "en-US", "en-GB")
@dataclass
class TranscriptSegment:
start: float
text: str
@dataclass
class Transcript:
language: str
segments: list[TranscriptSegment]
source: str
def _hash_transcript(segments: list[TranscriptSegment]) -> str:
payload = "\n".join(f"{s.start:.2f}:{s.text}" for s in segments)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def transcript_hash(segments: list[TranscriptSegment]) -> str:
return _hash_transcript(segments)
def format_transcript_for_prompt(segments: list[TranscriptSegment], *, max_chars: int = 120_000) -> str:
lines: list[str] = []
total = 0
for seg in segments:
minutes = int(seg.start // 60)
seconds = int(seg.start % 60)
line = f"[{minutes:02d}:{seconds:02d}] {seg.text.strip()}"
if total + len(line) + 1 > max_chars:
lines.append("[... Transcript gekürzt wegen Länge ...]")
break
lines.append(line)
total += len(line) + 1
return "\n".join(lines)
def _normalize_ytt_segments(raw: list[dict]) -> list[TranscriptSegment]:
return [TranscriptSegment(start=float(item["start"]), text=str(item["text"])) for item in raw]
def _fetch_with_youtube_transcript_api(video_id: str) -> Transcript:
api = YouTubeTranscriptApi()
try:
fetched = api.fetch(video_id, languages=list(PREFERRED_LANGUAGES))
segments = _normalize_ytt_segments(
[{"start": s.start, "text": s.text} for s in fetched]
)
return Transcript(language=fetched.language_code, segments=segments, source="youtube-transcript-api")
except (TranscriptsDisabled, NoTranscriptFound, VideoUnavailable):
raise
except YouTubeRequestFailed as exc:
logger.warning("youtube-transcript-api failed for %s: %s", video_id, exc)
raise
def _parse_vtt(content: str) -> list[TranscriptSegment]:
segments: list[TranscriptSegment] = []
blocks = re.split(r"\n\n+", content.strip())
for block in blocks:
lines = [ln.strip() for ln in block.splitlines() if ln.strip()]
if len(lines) < 2:
continue
time_line = lines[0] if "-->" in lines[0] else (lines[1] if len(lines) > 1 and "-->" in lines[1] else "")
if "-->" not in time_line:
continue
start_raw = time_line.split("-->")[0].strip()
match = re.match(r"(?:(\d+):)?(\d+):(\d+(?:\.\d+)?)", start_raw)
if not match:
continue
hours = int(match.group(1) or 0)
minutes = int(match.group(2))
seconds = float(match.group(3))
start = hours * 3600 + minutes * 60 + seconds
text_lines = [ln for ln in lines if "-->" not in ln and not ln.isdigit()]
text = " ".join(text_lines).strip()
text = re.sub(r"<[^>]+>", "", text)
if text:
segments.append(TranscriptSegment(start=start, text=text))
return segments
def _fetch_with_ytdlp(video_id: str) -> Transcript:
import yt_dlp
url = f"https://www.youtube.com/watch?v={video_id}"
opts: dict = {
"skip_download": True,
"quiet": True,
"no_warnings": True,
"writesubtitles": True,
"writeautomaticsub": True,
"subtitleslangs": list(PREFERRED_LANGUAGES),
"subtitlesformat": "vtt",
}
with yt_dlp.YoutubeDL(opts) as ydl:
info = ydl.extract_info(url, download=False)
subtitles = info.get("subtitles") or {}
automatic = info.get("automatic_captions") or {}
tracks = {**automatic, **subtitles}
chosen_lang = None
for lang in PREFERRED_LANGUAGES:
if lang in tracks:
chosen_lang = lang
break
if not chosen_lang:
for lang in tracks:
chosen_lang = lang
break
if not chosen_lang:
raise ValueError("Keine Untertitel gefunden.")
formats = tracks[chosen_lang]
vtt_url = None
for fmt in formats:
if fmt.get("ext") == "vtt" or "vtt" in (fmt.get("url") or ""):
vtt_url = fmt.get("url")
break
if not vtt_url and formats:
vtt_url = formats[0].get("url")
if not vtt_url:
raise ValueError("Untertitel-URL nicht verfügbar.")
import httpx
resp = httpx.get(vtt_url, timeout=30.0, follow_redirects=True)
resp.raise_for_status()
segments = _parse_vtt(resp.text)
if not segments:
raise ValueError("Untertitel konnten nicht geparst werden.")
return Transcript(language=chosen_lang, segments=segments, source="yt-dlp")
def fetch_transcript(video_id: str) -> Transcript:
try:
return _fetch_with_youtube_transcript_api(video_id)
except (TranscriptsDisabled, NoTranscriptFound):
pass
except VideoUnavailable as exc:
raise ValueError("Video nicht verfügbar.") from exc
try:
return _fetch_with_ytdlp(video_id)
except Exception as exc:
logger.exception("yt-dlp transcript fallback failed for %s", video_id)
raise ValueError(
"Dieses Video hat keine Captions. Dein Goldfisch kann leider nicht ins Leere starren."
) from exc
def estimate_duration_sec(segments: list[TranscriptSegment]) -> int | None:
if not segments:
return None
last = segments[-1]
return int(last.start) + 30
+61
View File
@@ -0,0 +1,61 @@
from __future__ import annotations
import re
from typing import Any
from urllib.parse import parse_qs, urlparse
import httpx
VIDEO_ID_RE = re.compile(r"^[a-zA-Z0-9_-]{11}$")
def extract_video_id(url_or_id: str) -> str:
raw = url_or_id.strip()
if VIDEO_ID_RE.match(raw):
return raw
parsed = urlparse(raw)
host = (parsed.netloc or "").lower().replace("www.", "")
if host in ("youtu.be",):
candidate = parsed.path.lstrip("/").split("/")[0]
if VIDEO_ID_RE.match(candidate):
return candidate
if host in ("youtube.com", "m.youtube.com", "music.youtube.com"):
if parsed.path == "/watch":
qs = parse_qs(parsed.query)
vid = qs.get("v", [""])[0]
if VIDEO_ID_RE.match(vid):
return vid
match = re.match(r"^/(embed|shorts|live)/([a-zA-Z0-9_-]{11})", parsed.path)
if match:
return match.group(2)
raise ValueError("Keine gültige YouTube-URL oder Video-ID.")
async def fetch_metadata(video_id: str) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.get(
"https://www.youtube.com/oembed",
params={"url": f"https://www.youtube.com/watch?v={video_id}", "format": "json"},
)
if resp.status_code == 404:
raise ValueError("Video nicht gefunden.")
resp.raise_for_status()
data = resp.json()
return {
"video_id": video_id,
"title": data.get("title") or "Unbekanntes Video",
"channel": data.get("author_name") or "",
"thumbnail_url": data.get("thumbnail_url") or f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg",
}
def youtube_watch_url(video_id: str, timestamp_seconds: int | None = None) -> str:
base = f"https://www.youtube.com/watch?v={video_id}"
if timestamp_seconds is not None and timestamp_seconds >= 0:
return f"{base}&t={int(timestamp_seconds)}s"
return base