diff --git a/README.md b/README.md
index 12873a6..961dd92 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,12 @@
-# Horrorfilme
+# Filmabend
-Privater Katalog-Browser mit zwei Reitern:
+Privater Katalog-Browser für den wöchentlichen Filmabend. Vier Reiter:
-- **Netflix** — alle Horrorfilme im Netflix-DE-Abo, aktuell 325.
-- **Kanon** — 194 Einträge aus anerkannten Bestenlisten, davon 28 Filmreihen.
- Zusammen 320 Filme, unabhängig davon, wo sie laufen.
+- **Netflix** — alles im Netflix-DE-Abo ab 50 Stimmen, aktuell 2567 Filme.
+- **Kanon** — 194 Einträge aus Horror-Bestenlisten, davon 28 Filmreihen.
+ Zusammen 319 Filme, unabhängig davon, wo sie laufen.
+- **Suche** — freie TMDB-Suche für alles, was in keinem der beiden steht.
+- **Rad** — Glücksrad über die eingereichten Vorschläge.
Daten kommen von TMDB (Provider-Info stammt dort aus der JustWatch-Partnerschaft).
Die deutschen Teaser schreibt MiniMax M3 beim Sync und landen in der SQLite-Datei.
@@ -81,6 +83,8 @@ FastAPI /api/movies ──► Vue-Grid, Detail-Overlay
| `GET /api/movies` | ganzer Katalog, das Frontend filtert clientseitig |
| `GET /api/movies/{id}` | einzelner Film |
| `GET /api/status` | Anzahl Filme, letzter Sync, läuft gerade einer |
+| `GET /api/search?q=` | freie TMDB-Suche, nicht auf den Katalog beschränkt |
+| `GET /api/search/{id}` | volle Daten, erst aus dem Katalog, sonst von TMDB |
| `GET /api/health` | für den Docker-Healthcheck |
| `POST /api/sync` | Sync von Hand anstoßen |
@@ -90,8 +94,63 @@ Gehört ein Film zu einer Reihe, zeigt die Detailansicht die anderen Teile —
aber nur die, die selbst auf Netflix liegen. Grundlage ist TMDBs
`belongs_to_collection`. Ein Klick springt direkt zum nächsten Teil.
-Aktuell haben 86 Filme eine Reihe, 24 Reihen sind mit mehr als einem Teil
-vertreten. Die größte ist Resident Evil mit fünf Filmen.
+Im Raster stehen Reihen als eine Karte, die durch ihre Teile blättert. Aus
+2567 Netflix-Kacheln werden so 2369 — Herr der Ringe belegt eine statt drei.
+Die Karte startet beim bestbewerteten Teil, danach richtet sich auch ihr
+Platz im Raster.
+
+## Vorschläge und Rad
+
+Im Reiter **Rad** landen die Filmvorschläge. Jede Sitzung darf einen Film
+vorschlagen — erkannt über ein Cookie, ohne Anmeldung. Ein zweiter Vorschlag
+ersetzt den ersten, statt abgelehnt zu werden.
+
+Vorgeschlagen wird aus der Detailansicht heraus, mit optionalem Namen. Das
+klappt auch mit Filmen aus dem Suche-Reiter, die gar nicht im Katalog stehen:
+Titel, Jahr und Poster werden beim Vorschlagen mitgespeichert.
+
+Das Rad verteilt die Vorschläge auf gleich große Segmente und dreht fünf bis
+sieben Runden. Der Zeiger steht oben; das Ziel wird ausgelost und die Drehung
+so berechnet, dass die Segmentmitte dort landet.
+
+**Gedreht wird für alle gleichzeitig.** Die Ziehung passiert im Server, nicht
+im Browser — sonst sähe jede Seite ein anderes Ergebnis. Wer auf „Drehen"
+klickt, stößt nur an; das Ergebnis samt Zielwinkel geht per Server-Sent Events
+an alle offenen Seiten. Neue Vorschläge kommen auf demselben Weg an.
+
+Wer die Seite mitten im Dreh lädt, fragt `GET /api/spin` und steigt in die
+Restanimation ein. Ist der Dreh schon durch, springt das Rad ohne Animation
+auf die Endstellung — sonst zeigte der Zeiger woanders hin als die
+Gewinnerkarte.
+
+Die Abonnenten liegen im Speicher des Prozesses. Das trägt genau so weit wie
+ein einzelner uvicorn-Worker; bei mehreren bekäme jeder nur seine eigenen.
+
+| Endpoint | Zweck |
+| --- | --- |
+| `GET /api/suggestions` | alle Vorschläge, plus die eigene ID |
+| `POST /api/suggestions` | vorschlagen oder ersetzen |
+| `DELETE /api/suggestions` | eigenen Vorschlag zurückziehen |
+| `GET /api/events` | Live-Kanal (SSE) für Dreh und Vorschläge |
+| `POST /api/spin` | Dreh für alle auslösen |
+| `GET /api/spin` | laufenden oder letzten Dreh abfragen |
+
+## Die Filterleiste
+
+Rechts am Fensterrand, fest stehend. Von oben nach unten:
+
+- Suche und Sortierung
+- Vier Bereichsregler: Bewertungen, Dauer, Jahr, Bewertung. Die Skalen kommen
+ aus dem offenen Reiter und wechseln beim Umschalten mit. Bei den Stimmen ist
+ die Skala gestaucht — linear säßen fast alle Filme im linken Prozent.
+- Genres zum Zeigen und zum Ausblenden, je als Badge-Gruppe mit "alle"
+
+Eingeschlossene Genres wirken als ODER. Ein Ausschluss sticht immer: Ein
+Actionfilm mit Komödie fliegt raus, wenn Komödie ausgeblendet ist.
+
+Ein früherer Versuch mit acht Subgenres (Geister, Zombies, Slasher …) ist
+wieder raus. TMDBs Schlagwörter reichten dafür nicht: 77 Filme hatten gar
+keine, der Rest nur allgemeine wie "supernatural horror".
## Der Kanon-Reiter
@@ -129,7 +188,7 @@ stillschweigend. Filme ohne ID versucht der nächste Sync erneut.
Hat ein Film mehrere IDs, gewinnt die erste. Godzilla Minus One liegt bei
Netflix in Farbe und in Schwarzweiß.
-## Warum der Katalog bei drei Stimmen abschneidet
+## Warum der Katalog bei 50 Stimmen abschneidet
JustWatch ordnet unbekannte Titel gelegentlich der Netflix-ID eines
gleichnamigen bekannten Films zu. TMDB übernimmt das ungeprüft. Der Film
@@ -147,23 +206,28 @@ Nachgewiesen an fünf Fällen, alle nach demselben Muster:
Nachweis über TMDBs Watch-Seiten: Dort steht die Netflix-ID im
Weiterleitungslink. Zwei Filme mit derselben ID können nicht beide stimmen.
-Oberhalb von zwei Stimmen trat kein einziger Fall auf, deshalb die Grenze
-bei drei. Das kostet 11 Einträge von 336.
+Oberhalb von zwei Stimmen trat kein einziger Fall auf. Die Grenze liegt
+inzwischen bei 50 Stimmen, weil der Katalog alle Genres umfasst — das hält
+die Fehlzuordnungen ohnehin draußen.
## Grenzen
- Verfügbarkeit hinkt Netflix 24–48 h hinterher. So oft liefert JustWatch an TMDB.
- Es gibt keine Info, ob ein Film deutsche Synchronisation hat. TMDB kennt nur
die Originalsprache, nicht die Tonspuren bei Netflix.
-- Der Netflix-Knopf führt bei 310 von 325 Filmen direkt auf die Titelseite.
- Die restlichen 15 landen auf TMDBs Watch-Seite, weil dort kein Netflix-Link
- hinterlegt ist. Der nächste Sync versucht sie erneut.
+- Der Netflix-Knopf führt bei 730 von 2567 Filmen direkt auf die Titelseite.
+ Der Rest landet auf TMDBs Watch-Seite, von dort ist es ein Klick weiter.
+ Pro Sync kommen 250 dazu, meistgesehene zuerst — mehr auf einmal drosselt
+ TMDB.
- Die Szenenbilder sind TMDB-Backdrops. Der Sync wirft Duplikate raus, indem
er die Bilder selbst vergleicht (dHash plus Farbhistogramm, siehe
`app/imagehash.py`). Motive mit Titeltext fliegen ganz raus. Werbemotive
ohne Text bleiben drin — die lassen sich nicht von Szenenfotos trennen.
-- 15 Filme haben gar keine Bilder, 46 keinen Trailer. Dann zeigt die
- Detailansicht entsprechend weniger.
+- Neun Filme haben gar keine Bilder. Dann zeigt die Detailansicht weniger.
+- Der Teaser-Ton richtet sich nach dem Genre. Horror, Thriller, Mystery und
+ Krimi bleiben reißerisch, alles andere bekommt die Anweisung, den Ton des
+ Films zu treffen. Vorher wurde aus einer Bollywood-Romanze ein Dorf mit
+ verschlingenden Almwiesen.
## Attribution
diff --git a/backend/app/config.py b/backend/app/config.py
index 7182e0b..c7c92c4 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -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"
diff --git a/backend/app/db.py b/backend/app/db.py
index e0bd7be..e4e776f 100644
--- a/backend/app/db.py
+++ b/backend/app/db.py
@@ -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)
diff --git a/backend/app/events.py b/backend/app/events.py
new file mode 100644
index 0000000..572746f
--- /dev/null
+++ b/backend/app/events.py
@@ -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)
diff --git a/backend/app/main.py b/backend/app/main.py
index e3e307d..b3fcf83 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -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():
diff --git a/backend/app/search.py b/backend/app/search.py
new file mode 100644
index 0000000..3f6655e
--- /dev/null
+++ b/backend/app/search.py
@@ -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"),
+ }
diff --git a/backend/app/suggestions.py b/backend/app/suggestions.py
new file mode 100644
index 0000000..74dbc54
--- /dev/null
+++ b/backend/app/suggestions.py
@@ -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
diff --git a/backend/app/sync.py b/backend/app/sync.py
index 91552b4..aea8a39 100644
--- a/backend/app/sync.py
+++ b/backend/app/sync.py
@@ -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),
diff --git a/backend/app/teaser.py b/backend/app/teaser.py
index 93414c3..08d2401 100644
--- a/backend/app/teaser.py
+++ b/backend/app/teaser.py
@@ -6,16 +6,30 @@ from .config import MINIMAX_KEY, MINIMAX_MODEL, MINIMAX_URL
THINK_BLOCK = re.compile(r".*?", 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 -Block
diff --git a/backend/app/tmdb.py b/backend/app/tmdb.py
index 6dda74b..2aec33c 100644
--- a/backend/app/tmdb.py
+++ b/backend/app/tmdb.py
@@ -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):
diff --git a/frontend/index.html b/frontend/index.html
index d961627..bed0198 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -3,7 +3,7 @@
- Horror auf Netflix
+ Filmabend
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 5d5721b..817074f 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -1,7 +1,10 @@
-
-
-
-
Horrorfilme
-
Stand {{ lastSync }}
-
-
-
-
-
-
-
+
-
Lade Katalog…
-
{{ error }}
-
- Noch keine Filme in der Datenbank. Der erste Sync läuft eventuell noch.
-
-
Nichts gefunden für „{{ search }}“.
+
-
-
+
+
+
+
Lade Katalog…
+
{{ error }}
+
+ Noch keine Filme in der Datenbank. Der erste Sync läuft eventuell noch.
+