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>
182 lines
6.2 KiB
Python
182 lines
6.2 KiB
Python
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,
|
|
}
|