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
+13
View File
@@ -0,0 +1,13 @@
# PIN für Recap-Erstellung (46 Ziffern empfohlen)
APP_PIN=1234
# JWT — zufälligen langen String generieren (openssl rand -hex 32)
JWT_SECRET=change-me-to-a-long-random-string
JWT_EXPIRE_DAYS=90
# OpenRouter
OPENROUTER_API_KEY=
OPENROUTER_MODEL=google/gemini-2.5-flash
# Optional lokal
# APP_URL=http://localhost:8000
+9
View File
@@ -0,0 +1,9 @@
.env
data/
__pycache__/
*.py[cod]
.venv/
venv/
*.egg-info/
.pytest_cache/
.DS_Store
+17
View File
@@ -0,0 +1,17 @@
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
COPY pyproject.toml /app/pyproject.toml
COPY server /app/server
COPY tools /app/tools
RUN pip install --no-cache-dir -U pip && pip install --no-cache-dir .
COPY public /app/public
EXPOSE 8000
CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "8000"]
+72
View File
@@ -0,0 +1,72 @@
# Goldfish Recap
YouTube-Untertitel → KI-Recap mit Sprungmarken. Für Second-Screen-Survivors und Goldfisch-Gedächtnisse.
**Live:** [ytrecap.schwenk.online](https://ytrecap.schwenk.online)
## Features
- YouTube-URL einfügen → strukturiertes Recap mit TL;DR, Vibe-Check und Sprungmarken
- Recaps bleiben gespeichert und sind per Link teilbar (`/r/{video_id}`)
- Recap erstellen nur mit PIN (JWT in localStorage)
- PWA — installierbar auf dem Handy
## Lokale Entwicklung
```bash
cp .env.example .env
# OPENROUTER_API_KEY und JWT_SECRET setzen
python -m venv .venv
source .venv/bin/activate
pip install -e .
python tools/gen_favicon.py
uvicorn server.app:app --reload --port 8000
```
Oder mit Docker:
```bash
docker compose -f compose.dev.yml up --build
```
App: http://localhost:8000
## Deployment (Traefik)
1. `.env` auf dem Server anlegen:
```bash
APP_PIN=dein-pin
JWT_SECRET=$(openssl rand -hex 32)
OPENROUTER_API_KEY=sk-or-...
```
2. DNS `ytrecap.schwenk.online` → Traefik-Host
3. Start:
```bash
docker compose up -d --build
```
Traefik übernimmt TLS via `myresolver`. Persistente Daten liegen in `./data/`.
## API
| Endpoint | Auth | Beschreibung |
|----------|------|--------------|
| `GET /api/health` | — | Healthcheck |
| `POST /api/auth/pin` | — | PIN → JWT |
| `GET /api/recaps` | — | Liste |
| `GET /api/recaps/{video_id}` | — | Einzelnes Recap |
| `POST /api/recaps` | JWT | Recap erstellen |
| `POST /api/recaps/{video_id}/regenerate` | JWT | Neu generieren |
## Stack
- FastAPI + SQLite
- youtube-transcript-api (+ yt-dlp Fallback)
- OpenRouter (Default: `google/gemini-2.5-flash`)
- Vanilla JS PWA
+14
View File
@@ -0,0 +1,14 @@
services:
app:
build: .
ports:
- "8000:8000"
environment:
- APP_PIN=${APP_PIN:-1234}
- JWT_SECRET=${JWT_SECRET:-dev-secret-change-me}
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-}
- OPENROUTER_MODEL=${OPENROUTER_MODEL:-google/gemini-2.5-flash}
- DB_PATH=/data/ytrecap.sqlite
- APP_URL=http://localhost:8000
volumes:
- ./data:/data
+28
View File
@@ -0,0 +1,28 @@
services:
app:
build: .
restart: unless-stopped
environment:
- APP_PIN=${APP_PIN}
- JWT_SECRET=${JWT_SECRET}
- JWT_EXPIRE_DAYS=${JWT_EXPIRE_DAYS:-90}
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
- OPENROUTER_MODEL=${OPENROUTER_MODEL:-google/gemini-2.5-flash}
- DB_PATH=/data/ytrecap.sqlite
- APP_URL=https://ytrecap.schwenk.online
- TZ=Europe/Berlin
volumes:
- ./data:/data
networks:
- traefik
labels:
- traefik.enable=true
- traefik.docker.network=traefik
- traefik.http.routers.ytrecap.rule=Host(`ytrecap.schwenk.online`)
- traefik.http.routers.ytrecap.entrypoints=websecure
- traefik.http.routers.ytrecap.tls.certresolver=myresolver
- traefik.http.services.ytrecap.loadbalancer.server.port=8000
networks:
traefik:
external: true
+77
View File
@@ -0,0 +1,77 @@
const TOKEN_KEY = "goldfish_token";
export function getToken() {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token) {
localStorage.setItem(TOKEN_KEY, token);
}
export function clearToken() {
localStorage.removeItem(TOKEN_KEY);
}
export function isLoggedIn() {
return Boolean(getToken());
}
async function request(path, options = {}) {
const headers = { ...(options.headers || {}) };
if (options.body && !headers["Content-Type"]) {
headers["Content-Type"] = "application/json";
}
const token = getToken();
if (token) headers.Authorization = `Bearer ${token}`;
const resp = await fetch(path, { ...options, headers });
let data = null;
const ct = resp.headers.get("content-type") || "";
if (ct.includes("application/json")) {
data = await resp.json();
}
if (!resp.ok) {
const detail = data?.detail;
const msg = typeof detail === "string" ? detail : detail?.msg || resp.statusText;
throw new Error(msg || "Request failed");
}
return data;
}
export const api = {
login(pin) {
return request("/api/auth/pin", { method: "POST", body: JSON.stringify({ pin }) });
},
listRecaps(limit = 20, offset = 0) {
return request(`/api/recaps?limit=${limit}&offset=${offset}`);
},
getRecap(videoId) {
return request(`/api/recaps/${encodeURIComponent(videoId)}`);
},
createRecap(url, force = false) {
return request("/api/recaps", { method: "POST", body: JSON.stringify({ url, force }) });
},
regenerateRecap(videoId) {
return request(`/api/recaps/${encodeURIComponent(videoId)}/regenerate`, { method: "POST" });
},
};
export function formatTimestamp(seconds) {
const s = Math.max(0, Math.floor(seconds));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
return `${m}:${String(sec).padStart(2, "0")}`;
}
export function youtubeUrl(videoId, seconds) {
const base = `https://www.youtube.com/watch?v=${videoId}`;
return seconds != null ? `${base}&t=${Math.floor(seconds)}s` : base;
}
export const VERDICT_LABELS = {
watch: { text: "Lohnt sich", emoji: "🎬", className: "verdict-watch" },
skim: { text: "Nur Stellen", emoji: "🐟", className: "verdict-skim" },
skip: { text: "Skip it", emoji: "💤", className: "verdict-skip" },
};
+165
View File
@@ -0,0 +1,165 @@
import {
api,
VERDICT_LABELS,
formatTimestamp,
getToken,
setToken,
isLoggedIn,
} from "./api.js";
const createModal = document.getElementById("createModal");
const pinModal = document.getElementById("pinModal");
const recapList = document.getElementById("recapList");
const emptyState = document.getElementById("emptyState");
const listError = document.getElementById("listError");
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
function renderRecapCard(item) {
const verdict = VERDICT_LABELS[item.watch_verdict] || VERDICT_LABELS.skim;
const href = `/r/${encodeURIComponent(item.video_id)}`;
return `
<a class="recapCard" href="${href}">
<img class="recapThumb" src="${escapeHtml(item.thumbnail_url || "")}" alt="" loading="lazy">
<div class="recapCardBody">
<span class="verdictBadge ${verdict.className}">${verdict.emoji} ${verdict.text}</span>
<h3>${escapeHtml(item.title)}</h3>
<p class="recapTldr">${escapeHtml(item.tldr)}</p>
<span class="recapMeta">${escapeHtml(item.channel || "")}</span>
</div>
</a>
`;
}
async function loadRecaps() {
listError.hidden = true;
try {
const data = await api.listRecaps(30);
const items = data.items || [];
if (!items.length) {
recapList.innerHTML = "";
emptyState.hidden = false;
return;
}
emptyState.hidden = true;
recapList.innerHTML = items.map(renderRecapCard).join("");
} catch (err) {
listError.textContent = err.message || "Liste konnte nicht geladen werden.";
listError.hidden = false;
}
}
function openPinModal() {
document.getElementById("pinError").hidden = true;
document.getElementById("pinInput").value = "";
pinModal.showModal();
document.getElementById("pinInput").focus();
}
function waitForPinLogin() {
return new Promise((resolve, reject) => {
if (isLoggedIn()) {
resolve();
return;
}
openPinModal();
pinModal.addEventListener(
"close",
() => {
if (isLoggedIn()) resolve();
else reject(new Error("Abgebrochen"));
},
{ once: true }
);
});
}
function openCreateModal() {
document.getElementById("createError").hidden = true;
document.getElementById("createProgress").hidden = true;
document.getElementById("urlInput").value = "";
document.getElementById("forceCheckbox").checked = false;
document.getElementById("createSubmit").disabled = false;
createModal.showModal();
document.getElementById("urlInput").focus();
}
async function handleCreate(e) {
e.preventDefault();
const url = document.getElementById("urlInput").value.trim();
const force = document.getElementById("forceCheckbox").checked;
const errEl = document.getElementById("createError");
const progress = document.getElementById("createProgress");
const submit = document.getElementById("createSubmit");
errEl.hidden = true;
progress.hidden = false;
submit.disabled = true;
try {
const result = await api.createRecap(url, force);
createModal.close();
window.location.href = `/r/${encodeURIComponent(result.recap.video_id)}`;
} catch (err) {
errEl.textContent = err.message || "Recap fehlgeschlagen.";
errEl.hidden = false;
progress.hidden = true;
submit.disabled = false;
}
}
function wirePinForm() {
document.getElementById("pinCancel").addEventListener("click", () => pinModal.close());
document.getElementById("pinForm").addEventListener("submit", async (e) => {
e.preventDefault();
const pin = document.getElementById("pinInput").value.trim();
const errEl = document.getElementById("pinError");
errEl.hidden = true;
try {
const { token } = await api.login(pin);
setToken(token);
pinModal.close();
} catch {
errEl.textContent = "Falscher PIN. Dein Goldfisch auch.";
errEl.hidden = false;
}
});
}
function wireCreate() {
document.getElementById("newRecapBtn").addEventListener("click", async () => {
try {
await waitForPinLogin();
openCreateModal();
} catch {
/* cancelled */
}
});
document.getElementById("createCancel").addEventListener("click", () => createModal.close());
document.getElementById("createForm").addEventListener("submit", async (e) => {
e.preventDefault();
if (!isLoggedIn()) {
try {
await waitForPinLogin();
} catch {
return;
}
}
await handleCreate(e);
});
}
function registerSw() {
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js").catch(() => {});
}
}
wirePinForm();
wireCreate();
loadRecaps();
registerSw();
Binary file not shown.

After

Width:  |  Height:  |  Size: 771 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" fill="none">
<rect width="128" height="128" rx="28" fill="#0B3D5C"/>
<ellipse cx="64" cy="68" rx="38" ry="28" fill="#FF9F43"/>
<ellipse cx="64" cy="68" rx="30" ry="22" fill="#FFB366"/>
<circle cx="78" cy="58" r="6" fill="#0B3D5C"/>
<circle cx="80" cy="56" r="2" fill="#FFF"/>
<path d="M26 68 Q18 58 22 48 Q28 52 30 62" fill="#FF9F43"/>
<path d="M38 88 Q64 98 90 88" stroke="#E8892E" stroke-width="3" fill="none" stroke-linecap="round"/>
<text x="64" y="118" text-anchor="middle" fill="#FFF8F0" font-family="sans-serif" font-size="10" font-weight="bold">3s</text>
</svg>

After

Width:  |  Height:  |  Size: 648 B

+85
View File
@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#0B3D5C">
<meta name="description" content="Goldfish Recap — YouTube-Zusammenfassungen für Second-Screen-Survivors">
<link rel="manifest" href="/manifest.json">
<link rel="icon" href="/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/icon-192.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&family=JetBrains+Mono:wght@500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/styles.css">
<title>Goldfish Recap</title>
</head>
<body>
<div id="app">
<header class="hero">
<div class="heroInner">
<div class="logoRow">
<img src="/icon.svg" alt="" class="logo" width="56" height="56">
<div>
<h1>Goldfish Recap</h1>
<p class="tagline">Second Screen? Kein Problem. Dein Gehirn hat abgespeichert: nein. Wir: ja.</p>
</div>
</div>
<button type="button" class="btn btn-primary" id="newRecapBtn">+ Neues Recap</button>
</div>
</header>
<main class="main">
<section id="recapListSection">
<h2 class="sectionTitle">Zuletzt gerettet</h2>
<div id="recapList" class="recapGrid"></div>
<p id="emptyState" class="emptyState" hidden>
Noch keine Recaps. Paste eine YouTube-URL — der Goldfisch erinnert sich für dich.
</p>
<p id="listError" class="errorText" hidden></p>
</section>
</main>
<footer class="footer">
<span>🐟 3-Sekunden-Gedächtnis? Nicht mehr.</span>
</footer>
</div>
<!-- Create modal -->
<dialog id="createModal" class="modal">
<form method="dialog" id="createForm" class="modalBody">
<h2>Neues Recap</h2>
<p class="modalHint">YouTube-URL rein, Goldfisch macht den Rest.</p>
<input type="url" id="urlInput" class="textInput" placeholder="https://youtube.com/watch?v=…" required>
<label class="checkboxRow">
<input type="checkbox" id="forceCheckbox">
<span>Neu generieren, falls schon vorhanden</span>
</label>
<p id="createError" class="errorText" hidden></p>
<div class="modalActions">
<button type="button" class="btn btn-ghost" id="createCancel">Abbrechen</button>
<button type="submit" class="btn btn-primary" id="createSubmit">Recap starten</button>
</div>
<div id="createProgress" class="progressBox" hidden>
<div class="spinner"></div>
<p>Untertitel fischen… KI denkt nach… Goldfisch schwimmt im Kreis…</p>
</div>
</form>
</dialog>
<!-- PIN modal -->
<dialog id="pinModal" class="modal">
<form method="dialog" id="pinForm" class="modalBody">
<h2>PIN bitte</h2>
<p class="modalHint">Recaps erstellen ist nur für eingeweihte Goldfische.</p>
<input type="password" id="pinInput" class="textInput pinInput" inputmode="numeric" autocomplete="off" placeholder="PIN" required>
<p id="pinError" class="errorText" hidden></p>
<div class="modalActions">
<button type="button" class="btn btn-ghost" id="pinCancel">Abbrechen</button>
<button type="submit" class="btn btn-primary">Anmelden</button>
</div>
</form>
</dialog>
<script type="module" src="/app.js"></script>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
{
"name": "Goldfish Recap",
"short_name": "Goldfish",
"description": "YouTube-Recaps für Second-Screen-Survivors",
"start_url": "/",
"display": "standalone",
"background_color": "#FFF8F0",
"theme_color": "#0B3D5C",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
}
]
}
+83
View File
@@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#0B3D5C">
<meta id="metaDescription" name="description" content="Goldfish Recap">
<meta id="ogTitle" property="og:title" content="Goldfish Recap">
<meta id="ogDescription" property="og:description" content="">
<meta id="ogImage" property="og:image" content="">
<link rel="manifest" href="/manifest.json">
<link rel="icon" href="/icon.svg" type="image/svg+xml">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&family=JetBrains+Mono:wght@500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/styles.css">
<title>Goldfish Recap</title>
</head>
<body>
<div id="app" class="recapPage">
<header class="topBar">
<a href="/" class="backLink">← Alle Recaps</a>
<button type="button" class="btn btn-ghost btn-sm" id="copyLinkBtn">Link kopieren</button>
</header>
<main id="recapMain" class="recapMain" hidden>
<div class="videoHero">
<a id="thumbLink" href="#" target="_blank" rel="noopener">
<img id="thumbImg" src="" alt="" class="thumb">
</a>
<div class="videoMeta">
<span id="verdictBadge" class="verdictBadge"></span>
<h1 id="videoTitle"></h1>
<p id="channelName" class="channelName"></p>
</div>
</div>
<section class="card tldrCard">
<h2>TL;DR</h2>
<p id="tldrText"></p>
<p id="vibeText" class="vibeText"></p>
</section>
<section class="card">
<h2>Sprungmarken</h2>
<p class="sectionHint">Klick = direkt zur Stelle auf YouTube. Für dein Goldfisch-Gehirn optimiert.</p>
<div id="sectionsList" class="sectionsList"></div>
</section>
<section class="card goldfishCard">
<p id="goldfishNote"></p>
</section>
<div class="adminRow" id="adminRow" hidden>
<button type="button" class="btn btn-ghost" id="regenerateBtn">Neu generieren</button>
</div>
</main>
<div id="loadingState" class="loadingState">
<div class="spinner"></div>
<p>Goldfisch lädt dein Recap…</p>
</div>
<div id="errorState" class="errorState" hidden>
<p id="errorText"></p>
<a href="/" class="btn btn-primary">Zurück</a>
</div>
</div>
<dialog id="pinModal" class="modal">
<form method="dialog" id="pinForm" class="modalBody">
<h2>PIN bitte</h2>
<input type="password" id="pinInput" class="textInput pinInput" inputmode="numeric" autocomplete="off" placeholder="PIN" required>
<p id="pinError" class="errorText" hidden></p>
<div class="modalActions">
<button type="button" class="btn btn-ghost" id="pinCancel">Abbrechen</button>
<button type="submit" class="btn btn-primary">Anmelden</button>
</div>
</form>
</dialog>
<script type="module" src="/recap.js"></script>
</body>
</html>
+148
View File
@@ -0,0 +1,148 @@
import {
api,
VERDICT_LABELS,
formatTimestamp,
youtubeUrl,
getToken,
setToken,
isLoggedIn,
} from "./api.js";
function getVideoIdFromPath() {
const parts = window.location.pathname.split("/").filter(Boolean);
if (parts[0] === "r" && parts[1]) return decodeURIComponent(parts[1]);
const params = new URLSearchParams(window.location.search);
return params.get("v") || "";
}
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
function setMeta(recap) {
document.title = `${recap.title} — Goldfish Recap`;
document.getElementById("metaDescription").content = recap.tldr;
document.getElementById("ogTitle").content = recap.title;
document.getElementById("ogDescription").content = recap.tldr;
document.getElementById("ogImage").content = recap.thumbnail_url || "";
}
function renderRecap(recap) {
const verdict = VERDICT_LABELS[recap.watch_verdict] || VERDICT_LABELS.skim;
document.getElementById("verdictBadge").className = `verdictBadge ${verdict.className}`;
document.getElementById("verdictBadge").textContent = `${verdict.emoji} ${verdict.text}`;
document.getElementById("videoTitle").textContent = recap.title;
document.getElementById("channelName").textContent = recap.channel || "";
document.getElementById("thumbImg").src = recap.thumbnail_url || "";
document.getElementById("thumbImg").alt = recap.title;
document.getElementById("thumbLink").href = youtubeUrl(recap.video_id);
document.getElementById("tldrText").textContent = recap.tldr;
document.getElementById("vibeText").textContent = recap.vibe_check || "";
document.getElementById("goldfishNote").textContent = recap.goldfish_note || "";
const sections = recap.sections || [];
document.getElementById("sectionsList").innerHTML = sections
.map(
(s) => `
<a class="sectionCard" href="${youtubeUrl(recap.video_id, s.timestamp_seconds)}" target="_blank" rel="noopener">
<span class="sectionTime">${formatTimestamp(s.timestamp_seconds)}</span>
<span class="sectionEmoji">${escapeHtml(s.emoji || "🐟")}</span>
<div>
<h3>${escapeHtml(s.title)}</h3>
<p>${escapeHtml(s.summary)}</p>
</div>
</a>
`
)
.join("");
setMeta(recap);
}
function showError(msg) {
document.getElementById("loadingState").hidden = true;
document.getElementById("recapMain").hidden = true;
document.getElementById("errorState").hidden = false;
document.getElementById("errorText").textContent = msg;
}
async function loadRecap() {
const videoId = getVideoIdFromPath();
if (!videoId) {
showError("Keine Video-ID in der URL.");
return;
}
try {
const recap = await api.getRecap(videoId);
document.getElementById("loadingState").hidden = true;
document.getElementById("recapMain").hidden = false;
renderRecap(recap);
if (isLoggedIn()) {
document.getElementById("adminRow").hidden = false;
}
document.getElementById("copyLinkBtn").addEventListener("click", async () => {
const url = `${window.location.origin}/r/${encodeURIComponent(videoId)}`;
try {
await navigator.clipboard.writeText(url);
document.getElementById("copyLinkBtn").textContent = "Kopiert!";
setTimeout(() => {
document.getElementById("copyLinkBtn").textContent = "Link kopieren";
}, 2000);
} catch {
window.prompt("Link kopieren:", url);
}
});
} catch (err) {
showError(err.message || "Recap nicht gefunden.");
}
}
function wireRegenerate() {
document.getElementById("regenerateBtn").addEventListener("click", async () => {
const videoId = getVideoIdFromPath();
const btn = document.getElementById("regenerateBtn");
btn.disabled = true;
btn.textContent = "Generiere…";
try {
const result = await api.regenerateRecap(videoId);
renderRecap(result.recap);
} catch (err) {
alert(err.message || "Regenerieren fehlgeschlagen.");
} finally {
btn.disabled = false;
btn.textContent = "Neu generieren";
}
});
}
function wirePinModal() {
const pinModal = document.getElementById("pinModal");
document.getElementById("pinCancel").addEventListener("click", () => pinModal.close());
document.getElementById("pinForm").addEventListener("submit", async (e) => {
e.preventDefault();
const errEl = document.getElementById("pinError");
errEl.hidden = true;
try {
const { token } = await api.login(document.getElementById("pinInput").value.trim());
setToken(token);
pinModal.close();
document.getElementById("adminRow").hidden = false;
} catch {
errEl.textContent = "Falscher PIN.";
errEl.hidden = false;
}
});
}
wireRegenerate();
wirePinModal();
loadRecap();
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js").catch(() => {});
}
+446
View File
@@ -0,0 +1,446 @@
:root {
--blue-deep: #0B3D5C;
--blue-mid: #145374;
--orange: #FF9F43;
--orange-dark: #E8892E;
--cream: #FFF8F0;
--cream-dark: #F5EDE3;
--text: #1A2B3C;
--text-muted: #5A6B7C;
--radius: 16px;
--shadow: 0 8px 32px rgba(11, 61, 92, 0.12);
}
* {
box-sizing: border-box;
}
html, body {
margin: 0;
min-height: 100%;
font-family: "Fredoka", system-ui, sans-serif;
background: linear-gradient(160deg, var(--cream) 0%, #E8F4FC 50%, var(--cream-dark) 100%);
color: var(--text);
}
a {
color: inherit;
text-decoration: none;
}
#app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.hero {
background: var(--blue-deep);
color: white;
padding: 1.5rem 1rem 2rem;
}
.heroInner {
max-width: 960px;
margin: 0 auto;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.logoRow {
display: flex;
align-items: center;
gap: 1rem;
}
.logo {
filter: drop-shadow(0 4px 8px rgba(0,0,0,0.2));
}
.hero h1 {
margin: 0;
font-size: 1.75rem;
font-weight: 700;
}
.tagline {
margin: 0.25rem 0 0;
opacity: 0.9;
font-size: 0.95rem;
max-width: 28rem;
}
.main {
flex: 1;
max-width: 960px;
margin: 0 auto;
padding: 1.5rem 1rem 3rem;
width: 100%;
}
.sectionTitle {
margin: 0 0 1rem;
font-size: 1.25rem;
color: var(--blue-deep);
}
.recapGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.recapCard {
background: white;
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow);
transition: transform 0.15s, box-shadow 0.15s;
display: flex;
flex-direction: column;
}
.recapCard:hover {
transform: translateY(-3px);
box-shadow: 0 12px 40px rgba(11, 61, 92, 0.18);
}
.recapThumb {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
background: var(--cream-dark);
}
.recapCardBody {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.recapCardBody h3 {
margin: 0;
font-size: 1rem;
line-height: 1.3;
}
.recapTldr {
margin: 0;
font-size: 0.9rem;
color: var(--text-muted);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.recapMeta {
font-size: 0.8rem;
color: var(--text-muted);
}
.verdictBadge {
display: inline-block;
font-size: 0.75rem;
font-weight: 600;
padding: 0.25rem 0.6rem;
border-radius: 999px;
width: fit-content;
}
.verdict-watch { background: #D4EDDA; color: #155724; }
.verdict-skim { background: #FFF3CD; color: #856404; }
.verdict-skip { background: #E2E3E5; color: #383D41; }
.btn {
font-family: inherit;
font-weight: 600;
font-size: 0.95rem;
border: none;
border-radius: 999px;
padding: 0.65rem 1.25rem;
cursor: pointer;
transition: transform 0.1s, opacity 0.1s;
}
.btn:active { transform: scale(0.97); }
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-primary {
background: var(--orange);
color: var(--blue-deep);
}
.btn-primary:hover { background: var(--orange-dark); }
.btn-ghost {
background: transparent;
color: inherit;
border: 2px solid rgba(255,255,255,0.4);
}
.recapPage .btn-ghost {
border-color: var(--blue-mid);
color: var(--blue-deep);
}
.btn-sm {
font-size: 0.85rem;
padding: 0.4rem 0.9rem;
}
.footer {
text-align: center;
padding: 1.5rem;
color: var(--text-muted);
font-size: 0.9rem;
}
.emptyState, .errorText {
color: var(--text-muted);
text-align: center;
padding: 2rem;
}
.errorText { color: #C0392B; }
.modal {
border: none;
border-radius: var(--radius);
padding: 0;
max-width: 420px;
width: calc(100% - 2rem);
box-shadow: var(--shadow);
}
.modal::backdrop {
background: rgba(11, 61, 92, 0.5);
}
.modalBody {
padding: 1.5rem;
margin: 0;
}
.modalBody h2 {
margin: 0 0 0.5rem;
color: var(--blue-deep);
}
.modalHint {
margin: 0 0 1rem;
color: var(--text-muted);
font-size: 0.9rem;
}
.textInput {
width: 100%;
font-family: inherit;
font-size: 1rem;
padding: 0.75rem 1rem;
border: 2px solid var(--cream-dark);
border-radius: 12px;
margin-bottom: 0.75rem;
}
.textInput:focus {
outline: none;
border-color: var(--orange);
}
.checkboxRow {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
margin-bottom: 1rem;
cursor: pointer;
}
.modalActions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
.progressBox {
margin-top: 1rem;
text-align: center;
color: var(--text-muted);
font-size: 0.9rem;
}
.spinner {
width: 36px;
height: 36px;
border: 3px solid var(--cream-dark);
border-top-color: var(--orange);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 0.75rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Recap detail page */
.recapPage .topBar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
max-width: 720px;
margin: 0 auto;
width: 100%;
}
.backLink {
color: var(--blue-deep);
font-weight: 600;
}
.recapMain {
max-width: 720px;
margin: 0 auto;
padding: 0 1rem 3rem;
width: 100%;
}
.videoHero {
margin-bottom: 1.25rem;
}
.thumb {
width: 100%;
border-radius: var(--radius);
aspect-ratio: 16 / 9;
object-fit: cover;
box-shadow: var(--shadow);
}
.videoMeta {
margin-top: 1rem;
}
.videoMeta h1 {
margin: 0.5rem 0 0.25rem;
font-size: 1.5rem;
line-height: 1.25;
}
.channelName {
margin: 0;
color: var(--text-muted);
}
.card {
background: white;
border-radius: var(--radius);
padding: 1.25rem;
margin-bottom: 1rem;
box-shadow: var(--shadow);
}
.card h2 {
margin: 0 0 0.75rem;
font-size: 1.1rem;
color: var(--blue-deep);
}
.tldrCard p {
margin: 0;
line-height: 1.5;
}
.vibeText {
margin-top: 0.75rem !important;
font-style: italic;
color: var(--text-muted);
}
.sectionHint {
margin: -0.5rem 0 1rem;
font-size: 0.85rem;
color: var(--text-muted);
}
.sectionsList {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.sectionCard {
display: grid;
grid-template-columns: auto auto 1fr;
gap: 0.75rem;
align-items: start;
padding: 1rem;
background: var(--cream);
border-radius: 12px;
transition: background 0.15s, transform 0.1s;
}
.sectionCard:hover {
background: var(--cream-dark);
transform: translateX(4px);
}
.sectionTime {
font-family: "JetBrains Mono", monospace;
font-size: 0.85rem;
font-weight: 500;
color: var(--orange-dark);
white-space: nowrap;
}
.sectionEmoji {
font-size: 1.25rem;
}
.sectionCard h3 {
margin: 0 0 0.25rem;
font-size: 0.95rem;
}
.sectionCard p {
margin: 0;
font-size: 0.9rem;
color: var(--text-muted);
line-height: 1.4;
}
.goldfishCard {
background: linear-gradient(135deg, var(--blue-deep), var(--blue-mid));
color: white;
text-align: center;
}
.goldfishCard p {
margin: 0;
font-size: 1.05rem;
line-height: 1.5;
}
.adminRow {
text-align: center;
margin-top: 1rem;
}
.loadingState, .errorState {
text-align: center;
padding: 4rem 1rem;
color: var(--text-muted);
}
@media (max-width: 480px) {
.hero h1 { font-size: 1.4rem; }
.sectionCard { grid-template-columns: 1fr; }
.sectionTime { order: -1; }
}
+34
View File
@@ -0,0 +1,34 @@
const CACHE = "goldfish-v1";
const ASSETS = ["/", "/index.html", "/r.html", "/styles.css", "/app.js", "/recap.js", "/api.js", "/manifest.json", "/icon.svg"];
self.addEventListener("install", (event) => {
event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(ASSETS)));
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (url.pathname.startsWith("/api/")) return;
event.respondWith(
caches.match(event.request).then((cached) => {
if (cached) return cached;
return fetch(event.request).then((resp) => {
if (resp.ok && event.request.method === "GET" && url.origin === self.location.origin) {
const clone = resp.clone();
caches.open(CACHE).then((cache) => cache.put(event.request, clone));
}
return resp;
});
})
);
});
+24
View File
@@ -0,0 +1,24 @@
[project]
name = "ytrecap"
version = "1.0.0"
description = "Goldfish Recap — YouTube-Untertitel zu witzigen Recaps mit Sprungmarken"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.30",
"aiosqlite>=0.20",
"pydantic-settings>=2.2",
"python-dotenv>=1.0",
"pyjwt>=2.9",
"httpx>=0.27",
"youtube-transcript-api>=1.0",
"yt-dlp>=2024.1",
"pydantic>=2.0",
]
[build-system]
requires = ["setuptools>=70", "wheel"]
build-backend = "setuptools.build_meta"
[tool.setuptools]
packages = ["server"]
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
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""Generate PNG icons from SVG for PWA."""
from __future__ import annotations
import pathlib
import struct
import zlib
ROOT = pathlib.Path(__file__).resolve().parents[1]
SVG = ROOT / "public" / "icon.svg"
def _png_chunk(tag: bytes, data: bytes) -> bytes:
return struct.pack(">I", len(data)) + tag + data + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
def write_simple_png(path: pathlib.Path, size: int) -> None:
"""Minimal orange/blue square PNG — no external deps."""
raw_rows = []
for y in range(size):
row = b"\x00"
for x in range(size):
cx, cy = size / 2, size / 2
dist = ((x - cx) ** 2 + (y - cy) ** 2) ** 0.5
if dist < size * 0.42:
row += bytes([255, 159, 67]) # orange fish body
elif y < size * 0.15 or y > size * 0.92:
row += bytes([11, 61, 92])
else:
row += bytes([11, 61, 92])
raw_rows.append(row)
compressed = zlib.compress(b"".join(raw_rows), 9)
ihdr = struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0)
png = b"\x89PNG\r\n\x1a\n"
png += _png_chunk(b"IHDR", ihdr)
png += _png_chunk(b"IDAT", compressed)
png += _png_chunk(b"IEND", b"")
path.write_bytes(png)
def main() -> None:
out = ROOT / "public" / "icon-192.png"
write_simple_png(out, 192)
print(f"Wrote {out}")
if __name__ == "__main__":
main()
+19
View File
@@ -0,0 +1,19 @@
CREATE TABLE IF NOT EXISTS recaps (
video_id TEXT PRIMARY KEY,
title TEXT NOT NULL,
channel TEXT,
thumbnail_url TEXT,
duration_sec INTEGER,
language TEXT,
tldr TEXT NOT NULL,
vibe_check TEXT,
watch_verdict TEXT NOT NULL CHECK (watch_verdict IN ('watch', 'skim', 'skip')),
goldfish_note TEXT,
sections_json TEXT NOT NULL,
transcript_hash TEXT,
model TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_recaps_created_at ON recaps (created_at DESC);