commit 3f1da92cf4ffa8dd16a26e3bbc9aad267d575b16 Author: Frank Schwenk Date: Tue Jun 16 15:49:24 2026 +0200 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..20dbd1d --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# PIN für Recap-Erstellung (4–6 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fcf114c --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.env +data/ +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.egg-info/ +.pytest_cache/ +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9c3044b --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..d420aab --- /dev/null +++ b/README.md @@ -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 diff --git a/compose.dev.yml b/compose.dev.yml new file mode 100644 index 0000000..34b6f32 --- /dev/null +++ b/compose.dev.yml @@ -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 diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..a110fe5 --- /dev/null +++ b/compose.yml @@ -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 diff --git a/public/api.js b/public/api.js new file mode 100644 index 0000000..9397c25 --- /dev/null +++ b/public/api.js @@ -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" }, +}; diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..6e4e951 --- /dev/null +++ b/public/app.js @@ -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 ` + + +
+ ${verdict.emoji} ${verdict.text} +

${escapeHtml(item.title)}

+

${escapeHtml(item.tldr)}

+ ${escapeHtml(item.channel || "")} +
+
+ `; +} + +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(); diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 0000000..9e4f5ff Binary files /dev/null and b/public/icon-192.png differ diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 0000000..1c7661c --- /dev/null +++ b/public/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + 3s + diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..0580912 --- /dev/null +++ b/public/index.html @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + Goldfish Recap + + +
+
+
+
+ +
+

Goldfish Recap

+

Second Screen? Kein Problem. Dein Gehirn hat abgespeichert: nein. Wir: ja.

+
+
+ +
+
+ +
+
+

Zuletzt gerettet

+
+ + +
+
+ +
+ 🐟 3-Sekunden-Gedächtnis? Nicht mehr. +
+
+ + + +
+

Neues Recap

+

YouTube-URL rein, Goldfisch macht den Rest.

+ + + +
+ + +
+ +
+
+ + + +
+

PIN bitte

+

Recaps erstellen ist nur für eingeweihte Goldfische.

+ + +
+ + +
+
+
+ + + + diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..592ac6e --- /dev/null +++ b/public/manifest.json @@ -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" + } + ] +} diff --git a/public/r.html b/public/r.html new file mode 100644 index 0000000..ab2f65a --- /dev/null +++ b/public/r.html @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + Goldfish Recap + + +
+
+ ← Alle Recaps + +
+ +
+
+ + + +
+ +

+

+
+
+ +
+

TL;DR

+

+

+
+ +
+

Sprungmarken

+

Klick = direkt zur Stelle auf YouTube. Für dein Goldfisch-Gehirn optimiert.

+
+
+ +
+

+
+ + +
+ +
+
+

Goldfisch lädt dein Recap…

+
+ + +
+ + +
+

PIN bitte

+ + +
+ + +
+
+
+ + + + diff --git a/public/recap.js b/public/recap.js new file mode 100644 index 0000000..49dbc45 --- /dev/null +++ b/public/recap.js @@ -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) => ` + + ${formatTimestamp(s.timestamp_seconds)} + ${escapeHtml(s.emoji || "🐟")} +
+

${escapeHtml(s.title)}

+

${escapeHtml(s.summary)}

+
+
+ ` + ) + .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(() => {}); +} diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..0b84533 --- /dev/null +++ b/public/styles.css @@ -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; } +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..50c2bb6 --- /dev/null +++ b/public/sw.js @@ -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; + }); + }) + ); +}); diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7363531 --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/server/__init__.py b/server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/app.py b/server/app.py new file mode 100644 index 0000000..94f9f79 --- /dev/null +++ b/server/app.py @@ -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") diff --git a/server/auth.py b/server/auth.py new file mode 100644 index 0000000..d1252cf --- /dev/null +++ b/server/auth.py @@ -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) diff --git a/server/db.py b/server/db.py new file mode 100644 index 0000000..1bcbe36 --- /dev/null +++ b/server/db.py @@ -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() diff --git a/server/recap.py b/server/recap.py new file mode 100644 index 0000000..87b151a --- /dev/null +++ b/server/recap.py @@ -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, + } diff --git a/server/settings.py b/server/settings.py new file mode 100644 index 0000000..d699bc6 --- /dev/null +++ b/server/settings.py @@ -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] diff --git a/server/transcripts.py b/server/transcripts.py new file mode 100644 index 0000000..5cc4213 --- /dev/null +++ b/server/transcripts.py @@ -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 diff --git a/server/youtube.py b/server/youtube.py new file mode 100644 index 0000000..0ed3874 --- /dev/null +++ b/server/youtube.py @@ -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 diff --git a/tools/gen_favicon.py b/tools/gen_favicon.py new file mode 100644 index 0000000..c696c13 --- /dev/null +++ b/tools/gen_favicon.py @@ -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() diff --git a/tools/schema.sql b/tools/schema.sql new file mode 100644 index 0000000..7016dfe --- /dev/null +++ b/tools/schema.sql @@ -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);