This commit is contained in:
team3
2026-08-29 11:28:25 +02:00
parent 38d5a8cfae
commit f5aeaadc42
20 changed files with 1934 additions and 184 deletions

View File

@@ -15,9 +15,7 @@ IMAGE_BASE = "https://image.tmdb.org/t/p"
MINIMAX_URL = "https://api.minimax.io/v1/chat/completions"
MINIMAX_MODEL = "MiniMax-M3"
# Netflix = 8, Horror = 27
PROVIDER_NETFLIX = 8
GENRE_HORROR = 27
REGION = "DE"
LANGUAGE = "de-DE"

View File

@@ -90,7 +90,9 @@ def row_to_dict(row):
def upsert(conn, movie):
payload = dict(movie)
columns = {row["name"] for row in conn.execute("PRAGMA table_info(movies)")}
# Der Sync trägt Hilfsfelder mit, etwa die Schlagwörter für die Einordnung
payload = {k: v for k, v in movie.items() if k in columns}
for field in JSON_FIELDS:
payload[field] = json.dumps(payload.get(field) or [], ensure_ascii=False)
columns = ", ".join(payload)

62
backend/app/events.py Normal file
View File

@@ -0,0 +1,62 @@
"""Live-Kanal zu allen offenen Seiten.
Server-Sent Events reichen: Es fließt nur in eine Richtung, und der Browser
verbindet nach einem Abbruch von selbst neu. Ein WebSocket wäre mehr Technik
für dasselbe Ergebnis.
Die Abonnenten liegen im Speicher. Das trägt genau so weit wie ein einzelner
Prozess — bei mehreren uvicorn-Workern bekäme jeder nur seine eigenen.
"""
import asyncio
import json
import logging
log = logging.getLogger("events")
# Wenn ein Browser nicht mitkommt, sollen ihn alte Ereignisse nicht
# unbegrenzt im Speicher stapeln
PUFFER = 16
_abonnenten: set[asyncio.Queue] = set()
def anmelden():
queue = asyncio.Queue(maxsize=PUFFER)
_abonnenten.add(queue)
return queue
def abmelden(queue):
_abonnenten.discard(queue)
def anzahl():
return len(_abonnenten)
def senden(typ, daten=None):
"""An alle offenen Seiten schicken. Blockiert nie."""
nachricht = json.dumps({"type": typ, "data": daten}, ensure_ascii=False)
for queue in list(_abonnenten):
try:
queue.put_nowait(nachricht)
except asyncio.QueueFull:
log.warning("Abonnent kommt nicht mit, Ereignis verworfen")
async def stream(queue, request):
"""SSE-Datenstrom. Der Kommentar alle 20 s hält Proxys davon ab,
die Verbindung als tot zu schließen."""
try:
yield ": verbunden\n\n"
while True:
if await request.is_disconnected():
break
try:
nachricht = await asyncio.wait_for(queue.get(), timeout=20)
yield f"data: {nachricht}\n\n"
except asyncio.TimeoutError:
yield ": ping\n\n"
finally:
abmelden(queue)

View File

@@ -1,12 +1,14 @@
import asyncio
import logging
import time
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from fastapi import FastAPI, HTTPException
from fastapi import Body, Cookie, FastAPI, HTTPException, Request, Response
from fastapi.responses import StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from . import db, sync
from . import db, events, search, suggestions, sync
from .config import IMAGE_BASE, STATIC_DIR
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
@@ -35,6 +37,7 @@ async def guarded_sync():
@app.on_event("startup")
async def startup():
db.init()
suggestions.init()
scheduler.add_job(guarded_sync, "cron", hour=4, minute=30, id="daily-sync")
scheduler.start()
if not db.get_meta("last_sync"):
@@ -71,6 +74,137 @@ def movie(movie_id: int):
return found
@app.get("/api/search")
async def search_movies(q: str):
"""Freie TMDB-Suche für Filme außerhalb des Katalogs."""
if len(q.strip()) < 2:
return []
return await search.search(q.strip())
@app.get("/api/search/{movie_id}")
async def search_detail(movie_id: int):
"""Volle Daten zu einem Suchtreffer. Erst der Katalog, dann TMDB."""
found = db.get_movie(movie_id)
if found:
return found
try:
return await search.detail(movie_id)
except Exception:
raise HTTPException(status_code=404, detail="Film nicht gefunden")
def sitzung(response: Response, sid):
"""Vorhandene Sitzung weiterverwenden, sonst eine neue setzen."""
if sid:
return sid
neu = suggestions.neue_session()
response.set_cookie(
suggestions.COOKIE,
neu,
max_age=60 * 60 * 24 * 365,
httponly=True,
samesite="lax",
)
return neu
# Der letzte Dreh, damit eine Seite mitten in der Animation nachziehen kann
letzter_dreh = {"ergebnis": None, "start": 0.0}
@app.get("/api/events")
async def ereignisse(request: Request):
queue = events.anmelden()
return StreamingResponse(
events.stream(queue, request),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@app.post("/api/spin")
def drehen():
"""Einer dreht, alle sehen dasselbe Ergebnis."""
laeuft = (
letzter_dreh["ergebnis"]
and time.monotonic() - letzter_dreh["start"] < suggestions.DREH_DAUER
)
if laeuft:
return {"started": False, "reason": "dreht bereits"}
ergebnis = suggestions.ziehen()
if not ergebnis:
raise HTTPException(status_code=400, detail="Keine Vorschläge")
letzter_dreh["ergebnis"] = ergebnis
letzter_dreh["start"] = time.monotonic()
events.senden("spin", ergebnis)
return {"started": True, **ergebnis}
@app.get("/api/spin")
def letzter():
"""Für Seiten, die mitten im Dreh geladen werden."""
ergebnis = letzter_dreh["ergebnis"]
if not ergebnis:
return {"running": False, "result": None}
vergangen = time.monotonic() - letzter_dreh["start"]
return {
"running": vergangen < suggestions.DREH_DAUER,
# Wer später dazukommt, überspringt den bereits gelaufenen Teil
"elapsed": round(vergangen, 2),
"result": ergebnis,
}
@app.get("/api/suggestions")
def liste_vorschlaege(response: Response, filmabend_sid: str | None = Cookie(None)):
sid = sitzung(response, filmabend_sid)
eintraege = suggestions.alle()
return {
"items": [
{k: v for k, v in e.items() if k != "session_id"} for e in eintraege
],
# Damit die Oberfläche den eigenen Vorschlag hervorheben kann
"mine": next(
(e["id"] for e in eintraege if e["session_id"] == sid),
None,
),
}
@app.post("/api/suggestions")
async def vorschlagen(
response: Response,
movie_id: int = Body(..., embed=True),
name: str | None = Body(None, embed=True),
filmabend_sid: str | None = Cookie(None),
):
sid = sitzung(response, filmabend_sid)
movie = db.get_movie(movie_id)
if not movie:
try:
movie = await search.detail(movie_id)
except Exception:
raise HTTPException(status_code=404, detail="Film nicht gefunden")
# Ein zweiter Vorschlag ersetzt den ersten, statt abgelehnt zu werden
eintrag = suggestions.ersetzen(sid, movie, name)
events.senden("suggestions")
return {k: v for k, v in eintrag.items() if k != "session_id"}
@app.delete("/api/suggestions")
def vorschlag_loeschen(response: Response, filmabend_sid: str | None = Cookie(None)):
sid = sitzung(response, filmabend_sid)
entfernt = suggestions.loeschen(sid)
if entfernt:
events.senden("suggestions")
return {"removed": entfernt}
@app.post("/api/sync")
async def trigger_sync():
if sync_lock.locked():

84
backend/app/search.py Normal file
View File

@@ -0,0 +1,84 @@
"""Freie Filmsuche über TMDB.
Der Katalog deckt nur Netflix und den Kanon ab. Für den Filmabend braucht
es auch Titel, die nirgends davon stehen — die kommen live von TMDB und
landen nicht in der Datenbank.
"""
import httpx
from .config import LANGUAGE, TMDB_BASE, TMDB_TOKEN
from .tmdb import DETAIL_PARAMS, pick_backdrops, pick_overview, pick_trailer
HEADERS = {"Authorization": f"Bearer {TMDB_TOKEN}", "accept": "application/json"}
def client():
return httpx.AsyncClient(base_url=TMDB_BASE, headers=HEADERS, timeout=30.0)
def as_result(item):
release = item.get("release_date") or ""
return {
"id": item["id"],
"title": item.get("title") or item.get("original_title") or "Ohne Titel",
"original_title": item.get("original_title"),
"year": int(release[:4]) if release[:4].isdigit() else None,
"release_date": release or None,
"overview": item.get("overview") or "",
"poster_path": item.get("poster_path"),
"vote_average": item.get("vote_average"),
"vote_count": item.get("vote_count"),
}
async def search(query, limit=24):
async with client() as http:
resp = await http.get(
"/search/movie",
params={"query": query, "language": LANGUAGE, "include_adult": "false"},
)
resp.raise_for_status()
results = resp.json().get("results", [])
# Ohne Poster taugt eine Kachel nichts, und Namenlose sind fast immer
# Dubletten oder Making-ofs
usable = [r for r in results if r.get("poster_path")]
usable.sort(key=lambda r: -(r.get("popularity") or 0))
return [as_result(r) for r in usable[:limit]]
async def detail(movie_id):
"""Vollbild-Daten für einen Treffer, der nicht im Katalog steht."""
async with client() as http:
resp = await http.get(f"/movie/{movie_id}", params=DETAIL_PARAMS)
resp.raise_for_status()
data = resp.json()
release = data.get("release_date") or ""
collection = data.get("belongs_to_collection") or {}
return {
"id": data["id"],
"title": data.get("title") or data.get("original_title") or "Ohne Titel",
"original_title": data.get("original_title"),
"release_date": release or None,
"year": int(release[:4]) if release[:4].isdigit() else None,
"overview": pick_overview(data),
"teaser": "",
"poster_path": data.get("poster_path"),
"backdrop_path": data.get("backdrop_path"),
"vote_average": data.get("vote_average"),
"vote_count": data.get("vote_count"),
"runtime": data.get("runtime"),
"genres": [g["name"] for g in data.get("genres", [])],
"images": pick_backdrops(data)[:12],
"trailer_key": pick_trailer(data),
"imdb_id": data.get("imdb_id"),
"netflix_id": None,
"netflix_url": None,
"on_netflix": 0,
"canon_group": None,
"canon_pos": None,
"collection_id": collection.get("id"),
"collection_name": collection.get("name"),
}

128
backend/app/suggestions.py Normal file
View File

@@ -0,0 +1,128 @@
"""Filmvorschläge fürs Rad.
Jede Sitzung darf einen Film vorschlagen. Die Sitzung erkennt ein Cookie —
ohne Anmeldung geht es nicht genauer, und für einen Filmabend im Freundeskreis
reicht es.
Titel, Jahr und Poster werden mitgespeichert. So bleibt ein Vorschlag
lesbar, auch wenn der Film nie im Katalog war oder Netflix ihn abgibt.
"""
import json
import random
import sqlite3
import uuid
from datetime import datetime, timezone
from .db import connect
SCHEMA = """
CREATE TABLE IF NOT EXISTS suggestions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL UNIQUE,
movie_id INTEGER NOT NULL,
title TEXT NOT NULL,
year INTEGER,
poster_path TEXT,
name TEXT,
created_at TEXT NOT NULL
);
"""
COOKIE = "filmabend_sid"
# So lange läuft die Animation im Browser. Der Server merkt sich das
# Ergebnis mit, damit eine Seite, die mitten im Dreh geladen wird, es
# trotzdem bekommt.
DREH_DAUER = 4.2
def init():
with connect() as conn:
conn.executescript(SCHEMA)
def neue_session():
return uuid.uuid4().hex
def alle():
with connect() as conn:
rows = conn.execute(
"SELECT * FROM suggestions ORDER BY created_at ASC"
).fetchall()
return [dict(r) for r in rows]
def von_session(session_id):
with connect() as conn:
row = conn.execute(
"SELECT * FROM suggestions WHERE session_id = ?", (session_id,)
).fetchone()
return dict(row) if row else None
def anlegen(session_id, movie, name=None):
"""Gibt den Vorschlag zurück, oder None wenn die Sitzung schon einen hat."""
eintrag = {
"session_id": session_id,
"movie_id": movie["id"],
"title": movie["title"],
"year": movie.get("year"),
"poster_path": movie.get("poster_path"),
"name": (name or "").strip()[:40] or None,
"created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
}
try:
with connect() as conn:
conn.execute(
"INSERT INTO suggestions "
"(session_id, movie_id, title, year, poster_path, name, created_at) "
"VALUES (:session_id, :movie_id, :title, :year, :poster_path, "
":name, :created_at)",
eintrag,
)
except sqlite3.IntegrityError:
return None
return von_session(session_id)
def ersetzen(session_id, movie, name=None):
"""Wer schon vorgeschlagen hat, darf seinen Vorschlag ändern."""
with connect() as conn:
conn.execute("DELETE FROM suggestions WHERE session_id = ?", (session_id,))
return anlegen(session_id, movie, name)
def ziehen():
"""Server lost aus, damit alle Seiten dasselbe Ergebnis sehen.
Der Zielwinkel gehört dazu: sonst dreht jede Seite woanders hin und
der Zeiger stünde am Ende auf einem anderen Segment.
"""
eintraege = alle()
if not eintraege:
return None
treffer = random.randrange(len(eintraege))
schritt = 360 / len(eintraege)
runden = 5 + random.randrange(3)
# Der Zeiger steht oben, also muss die Mitte des Zielsegments dorthin
winkel = runden * 360 + (360 - (treffer * schritt + schritt / 2))
return {
"index": treffer,
"winkel": winkel,
"dauer": DREH_DAUER,
"gewinner": {
k: v for k, v in eintraege[treffer].items() if k != "session_id"
},
"anzahl": len(eintraege),
}
def loeschen(session_id):
with connect() as conn:
return conn.execute(
"DELETE FROM suggestions WHERE session_id = ?", (session_id,)
).rowcount

View File

@@ -83,6 +83,11 @@ async def dedupe_images(movies):
log.info("Bilder: %s → %s nach Dedup", before, after)
# TMDB drosselt die Watch-Seiten. Mehr als das pro Lauf bringt nur noch
# leere Antworten — der Rest kommt beim nächsten Sync dran.
LINKS_PRO_LAUF = 250
async def fill_netflix_links(movies, known):
"""Direktlink auf die Netflix-Titelseite, sofern die ID auffindbar ist.
@@ -92,9 +97,14 @@ async def fill_netflix_links(movies, known):
for movie in movies:
movie["netflix_id"] = known.get(movie["id"])
todo = [m["id"] for m in movies if not m["netflix_id"]]
offen = [m for m in movies if not m["netflix_id"]]
# Die meistgesehenen zuerst, die klickt man am ehesten an
offen.sort(key=lambda m: -(m["vote_count"] or 0))
todo = [m["id"] for m in offen[:LINKS_PRO_LAUF]]
if todo:
log.info("Suche Netflix-IDs für %s Filme", len(todo))
log.info(
"Suche Netflix-IDs für %s von %s offenen Filmen", len(todo), len(offen)
)
found = await netflix.fetch_ids(todo)
for movie in movies:
movie["netflix_id"] = movie["netflix_id"] or found.get(movie["id"])
@@ -140,9 +150,8 @@ async def run():
log.info("%s Kanon-Titel aufgelöst", len(canon_places))
async with tmdb.client() as http:
listed = await tmdb.discover_all(http)
netflix_ids = {m["id"] for m in listed}
log.info("%s Filme bei Netflix DE gefunden", len(listed))
netflix_ids = await tmdb.discover_all(http)
log.info("%s Filme bei Netflix DE gefunden", len(netflix_ids))
wanted = sorted(netflix_ids | set(canon_places))
movies = await fetch_details(http, wanted)
@@ -172,8 +181,8 @@ async def run():
db.set_meta(conn, "canon_count", sum(1 for m in movies if m["canon_group"] is not None))
log.info(
"Sync fertig: %s Filme (%s Netflix, %s Kanon), %s Netflix-Links, "
"%s neue Teaser, %s entfernt",
"Sync fertig: %s Filme (%s Netflix, %s Kanon), "
"%s Netflix-Links, %s neue Teaser, %s entfernt",
len(movies),
len(on_netflix),
sum(1 for m in movies if m["canon_group"] is not None),

View File

@@ -6,16 +6,30 @@ from .config import MINIMAX_KEY, MINIMAX_MODEL, MINIMAX_URL
THINK_BLOCK = re.compile(r"<think>.*?</think>", re.DOTALL)
SYSTEM = (
"Du schreibst kurze deutsche Teaser für Horrorfilme. "
BASIS = (
"Du schreibst kurze deutsche Teaser für Filme. "
"Antworte immer auf Deutsch, auch wenn die Vorlage englisch ist. "
"Übersetze die Vorlage nicht, sondern formuliere neu. "
"Genau zwei Sätze, zusammen höchstens 40 Wörter. "
"Atmosphärisch und reißerisch, aber ohne Spoiler und ohne das Ende zu verraten. "
"Ohne Spoiler und ohne das Ende zu verraten. "
"Kein Filmtitel im Text, keine Anführungszeichen, keine Einleitung. "
"Gib nur den Teaser aus."
)
# Ein Horror-Ton über allem macht aus einer Bollywood-Romanze ein
# Dorf-Mysterium. Der Ton muss zum Genre passen.
TON_HORROR = " Schreibe atmosphärisch und reißerisch."
TON_NEUTRAL = (
" Triff den Ton des Films: eine Komödie klingt leicht, ein Drama ernst, "
"ein Abenteuer nach Aufbruch. Erfinde keine Bedrohung, die es nicht gibt."
)
def system_prompt(movie):
genres = movie.get("genres") or []
duester = {"Horror", "Thriller", "Mystery", "Krimi"}
return BASIS + (TON_HORROR if duester & set(genres) else TON_NEUTRAL)
def build_prompt(movie):
year = movie.get("year") or "unbekannt"
@@ -51,7 +65,7 @@ def build_payload(movie):
return {
"model": MINIMAX_MODEL,
"messages": [
{"role": "system", "content": SYSTEM},
{"role": "system", "content": system_prompt(movie)},
{"role": "user", "content": build_prompt(movie)},
],
# M3 denkt sonst über das Token-Limit hinaus und liefert nur den <think>-Block

View File

@@ -1,7 +1,6 @@
import httpx
from .config import (
GENRE_HORROR,
LANGUAGE,
PROVIDER_NETFLIX,
REGION,
@@ -12,16 +11,15 @@ from .config import (
HEADERS = {"Authorization": f"Bearer {TMDB_TOKEN}", "accept": "application/json"}
DISCOVER_PARAMS = {
"with_watch_providers": PROVIDER_NETFLIX,
"watch_region": REGION,
"with_genres": GENRE_HORROR,
"with_watch_monetization_types": "flatrate",
"language": LANGUAGE,
"include_adult": "false",
# JustWatch hängt unbekannte Titel gelegentlich an die Netflix-ID eines
# gleichnamigen bekannten Films. Betroffen waren ausschließlich Einträge
# mit ein oder zwei Stimmen — "LIENZO" bekam die ID von "Die Leinwand".
"vote_count.gte": 3,
# Der volle Katalog hat 5648 Titel, das meiste davon nie gesehen.
# Die Grenze hält auch die Fehlzuordnungen draußen: JustWatch hängt
# unbekannte Titel gelegentlich an die Netflix-ID eines gleichnamigen
# bekannten Films — "LIENZO" bekam die ID von "Die Leinwand".
"vote_count.gte": 50,
}
DETAIL_PARAMS = {
@@ -37,19 +35,26 @@ def client():
async def discover_page(http, page):
resp = await http.get("/discover/movie", params={**DISCOVER_PARAMS, "page": page})
resp = await http.get(
"/discover/movie",
params={
**DISCOVER_PARAMS,
"with_watch_providers": PROVIDER_NETFLIX,
"page": page,
},
)
resp.raise_for_status()
return resp.json()
async def discover_all(http):
"""Alle Netflix-DE-Horrorfilme durchpaginieren."""
"""Alle Film-IDs im Netflix-DE-Abo."""
first = await discover_page(http, 1)
movies = list(first["results"])
ids = {m["id"] for m in first["results"]}
for page in range(2, first["total_pages"] + 1):
data = await discover_page(http, page)
movies.extend(data["results"])
return movies
ids |= {m["id"] for m in data["results"]}
return ids
async def detail(http, movie_id):