update
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
@@ -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
62
backend/app/events.py
Normal 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)
|
||||
@@ -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
84
backend/app/search.py
Normal 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
128
backend/app/suggestions.py
Normal 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
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user