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 @@ diff --git a/frontend/src/components/FilterSidebar.vue b/frontend/src/components/FilterSidebar.vue new file mode 100644 index 0000000..54d8207 --- /dev/null +++ b/frontend/src/components/FilterSidebar.vue @@ -0,0 +1,239 @@ + + + + + diff --git a/frontend/src/components/MovieCard.vue b/frontend/src/components/MovieCard.vue index 210aaa9..998dde0 100644 --- a/frontend/src/components/MovieCard.vue +++ b/frontend/src/components/MovieCard.vue @@ -12,6 +12,12 @@ const poster = computed(() => posterUrl(props.movie.poster_path, 'w342')) const rating = computed(() => props.movie.vote_average ? props.movie.vote_average.toFixed(1) : null ) + +const meta = computed(() => + [props.movie.year || 'Jahr unbekannt', props.movie.runtime && `${props.movie.runtime} Min.`] + .filter(Boolean) + .join(' · ') +)