update
This commit is contained in:
@@ -25,6 +25,7 @@ CREATE TABLE IF NOT EXISTS movies (
|
||||
netflix_id TEXT,
|
||||
netflix_url TEXT,
|
||||
on_netflix INTEGER DEFAULT 0,
|
||||
rentable INTEGER,
|
||||
canon_group INTEGER,
|
||||
canon_pos INTEGER,
|
||||
collection_id INTEGER,
|
||||
@@ -77,6 +78,7 @@ def init():
|
||||
("on_netflix", "INTEGER DEFAULT 0"),
|
||||
("canon_group", "INTEGER"),
|
||||
("canon_pos", "INTEGER"),
|
||||
("rentable", "INTEGER"),
|
||||
):
|
||||
if column not in existing:
|
||||
conn.execute(f"ALTER TABLE movies ADD COLUMN {column} {kind}")
|
||||
|
||||
@@ -18,22 +18,23 @@ from .config import LANGUAGE, MINIMAX_KEY, MINIMAX_MODEL, MINIMAX_URL
|
||||
from .teaser import THINK_BLOCK
|
||||
|
||||
ANZAHL = 12
|
||||
MAX_ANZAHL = 100
|
||||
MAX_BESCHREIBUNG = 1000
|
||||
|
||||
SYSTEM = (
|
||||
"Du bist ein belesener Filmkenner mit Schwerpunkt Horror und Genrekino. "
|
||||
"Du schlägst Filme vor, die zu dem passen, was gesucht wird. "
|
||||
f"Nenne genau {ANZAHL} Filme, keine Serien. Nur Filme, die es wirklich gibt. "
|
||||
"Nenne genau {anzahl} Filme, keine Serien. Nur Filme, die es wirklich gibt. "
|
||||
"Nichts vorschlagen, was in der Sammlung steht oder schon gesehen wurde. "
|
||||
"Was in der Frage genannt ist, kennt die Person schon — auch nicht vorschlagen. "
|
||||
"Mische Bekanntes mit Geheimtipps. "
|
||||
"Antworte nur mit JSON in dieser Form, ohne Text davor oder danach: "
|
||||
'{"merkmale": ["drei Stichpunkte: was macht das Gesuchte aus"], '
|
||||
'"kandidaten": ["20 Originaltitel mit Jahr, die dazu passen könnten"], '
|
||||
'"filme": [{"titel": "Originaltitel", "jahr": 1999, "grund": "..."}]}\n'
|
||||
'{{"merkmale": ["drei Stichpunkte: was macht das Gesuchte aus"], '
|
||||
'"kandidaten": ["{kandidaten} Originaltitel mit Jahr, die dazu passen könnten"], '
|
||||
'"filme": [{{"titel": "Originaltitel", "jahr": 1999, "grund": "..."}}]}}\n'
|
||||
# Der Umweg über Merkmale und Kandidaten ersetzt das Nachdenken des
|
||||
# Modells: es sortiert erst, dann wählt es — und trifft deutlich besser.
|
||||
"Erst merkmale, dann kandidaten, dann die zwölf besten davon als filme.\n"
|
||||
"Erst merkmale, dann kandidaten, dann die {anzahl} besten davon als filme.\n"
|
||||
# Die Begründung lesen Leute, die einfach einen Film für den Abend
|
||||
# suchen — nicht Kritiker. Sie muss auf vier kurze Zeilen passen.
|
||||
"Für grund gilt: Höchstens 10 Wörter und 90 Zeichen, Deutsch in lateinischer "
|
||||
@@ -65,6 +66,12 @@ def kuerzen(grund):
|
||||
return kopf[:schnitt].rstrip(",; –.") + " …"
|
||||
|
||||
|
||||
def system(anzahl):
|
||||
"""Der Systemprompt für eine gewünschte Anzahl Filme. Die Kandidaten-
|
||||
liste bleibt ein Stück länger als das Ergebnis."""
|
||||
return SYSTEM.format(anzahl=anzahl, kandidaten=max(20, anzahl + 8))
|
||||
|
||||
|
||||
def etikett(film):
|
||||
"""Titel (Jahr) — so kommt ein Film in den Prompt."""
|
||||
jahr = film.get("year")
|
||||
@@ -124,7 +131,7 @@ def prompt(beschreibung, sammlung, gesehen, filter_=None):
|
||||
return "\n\n".join(teile)
|
||||
|
||||
|
||||
def parsen(text):
|
||||
def parsen(text, anzahl=ANZAHL):
|
||||
"""Das JSON aus der Antwort ziehen. Das Modell hängt gern einen
|
||||
Denkblock oder einen Satz davor — es zählt, was zwischen der ersten
|
||||
und der letzten Klammer steht."""
|
||||
@@ -152,14 +159,14 @@ def parsen(text):
|
||||
"grund": kuerzen(str(f.get("grund") or "")),
|
||||
}
|
||||
)
|
||||
return aus[:ANZAHL]
|
||||
return aus[:anzahl]
|
||||
|
||||
|
||||
async def fragen(beschreibung, sammlung, gesehen, filter_=None):
|
||||
async def fragen(beschreibung, sammlung, gesehen, filter_=None, anzahl=ANZAHL):
|
||||
payload = {
|
||||
"model": MINIMAX_MODEL,
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM},
|
||||
{"role": "system", "content": system(anzahl)},
|
||||
{"role": "user", "content": prompt(beschreibung, sammlung, gesehen, filter_)},
|
||||
],
|
||||
# Ohne Denkblock: M3 grübelt sonst minutenlang über zwölf Filme
|
||||
@@ -167,7 +174,8 @@ async def fragen(beschreibung, sammlung, gesehen, filter_=None):
|
||||
# Kandidatenfelder im Prompt leisten das Sortieren stattdessen,
|
||||
# in gut zehn Sekunden. Niedrige Temperatur: weniger Ausreißer.
|
||||
"thinking": {"type": "disabled"},
|
||||
"max_tokens": 4000,
|
||||
# Rund 40 Token je Film, dazu Merkmale und Kandidaten
|
||||
"max_tokens": 2000 + 60 * anzahl,
|
||||
"temperature": 0.3,
|
||||
}
|
||||
async with httpx.AsyncClient(
|
||||
@@ -178,7 +186,7 @@ async def fragen(beschreibung, sammlung, gesehen, filter_=None):
|
||||
for _ in range(2):
|
||||
resp = await http.post(MINIMAX_URL, json=payload)
|
||||
resp.raise_for_status()
|
||||
filme = parsen(resp.json()["choices"][0]["message"]["content"])
|
||||
filme = parsen(resp.json()["choices"][0]["message"]["content"], anzahl)
|
||||
if filme:
|
||||
return filme
|
||||
return []
|
||||
|
||||
@@ -107,8 +107,10 @@ async def startup():
|
||||
log.info("Leere Datenbank, starte ersten Sync im Hintergrund")
|
||||
asyncio.create_task(guarded_sync())
|
||||
else:
|
||||
# Altbestand: gemerkte Filme, die noch keine Katalogdaten haben
|
||||
# Altbestand: gemerkte Filme, die noch keine Katalogdaten haben,
|
||||
# und Filme, bei denen noch nicht steht, ob man sie leihen kann
|
||||
asyncio.create_task(sync.katalog_nachtragen())
|
||||
asyncio.create_task(sync.leihbarkeit_nachtragen())
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
@@ -157,15 +159,16 @@ def movie(movie_id: int):
|
||||
|
||||
|
||||
@app.get("/api/search")
|
||||
async def search_movies(q: str):
|
||||
async def search_movies(q: str, limit: int = 24):
|
||||
"""Freie TMDB-Suche für Filme außerhalb des Katalogs."""
|
||||
if len(q.strip()) < 2:
|
||||
return []
|
||||
return await search.search(q.strip())
|
||||
return await search.search(q.strip(), limit=max(1, min(search.MAX_TREFFER, limit)))
|
||||
|
||||
|
||||
@app.get("/api/discover")
|
||||
async def stoebern(
|
||||
limit: int = 40,
|
||||
sort: str = "rating",
|
||||
include: str = "",
|
||||
exclude: str = "",
|
||||
@@ -192,7 +195,8 @@ async def stoebern(
|
||||
"jahr_max": jahr_max,
|
||||
"note_min": note_min,
|
||||
"note_max": note_max,
|
||||
}
|
||||
},
|
||||
limit=max(1, min(search.MAX_TREFFER, limit)),
|
||||
)
|
||||
|
||||
|
||||
@@ -249,6 +253,7 @@ async def ki_suche(
|
||||
jahr_max: int | None = Body(None, embed=True),
|
||||
note_min: float | None = Body(None, embed=True),
|
||||
note_max: float | None = Body(None, embed=True),
|
||||
limit: int = Body(ki.ANZAHL, embed=True),
|
||||
filmabend_sid: str | None = Cookie(None),
|
||||
):
|
||||
"""Vorschläge von der KI, bei TMDB nachgeschlagen. Die eigene Sammlung
|
||||
@@ -278,7 +283,11 @@ async def ki_suche(
|
||||
gesehen = watched.alle(sid)
|
||||
try:
|
||||
vorschlaege = await ki.fragen(
|
||||
beschreibung, sammlung, [ki.etikett(g) for g in gesehen], filter_
|
||||
beschreibung,
|
||||
sammlung,
|
||||
[ki.etikett(g) for g in gesehen],
|
||||
filter_,
|
||||
max(1, min(ki.MAX_ANZAHL, limit)),
|
||||
)
|
||||
except (httpx.HTTPError, KeyError, IndexError):
|
||||
raise HTTPException(status_code=502, detail="Die KI antwortet gerade nicht.")
|
||||
@@ -286,6 +295,29 @@ async def ki_suche(
|
||||
return await ki.aufloesen(vorschlaege, filter_, ausser=ausser)
|
||||
|
||||
|
||||
@app.get("/api/movies/{movie_id}/credits")
|
||||
async def film_credits(movie_id: int):
|
||||
"""Regie und Besetzung, live von TMDB."""
|
||||
return await search.credits(movie_id)
|
||||
|
||||
|
||||
@app.get("/api/personen")
|
||||
async def personen_suchen(q: str):
|
||||
"""Schauspieler und Regisseure bei TMDB suchen."""
|
||||
if len(q.strip()) < 2:
|
||||
return []
|
||||
return await search.personen(q.strip())
|
||||
|
||||
|
||||
@app.get("/api/personen/{person_id}/filme")
|
||||
async def personen_filme(person_id: int):
|
||||
"""Was jemand gespielt hat und wo er Regie geführt hat."""
|
||||
try:
|
||||
return await search.person_filme(person_id)
|
||||
except httpx.HTTPError:
|
||||
raise HTTPException(status_code=502, detail="TMDB antwortet gerade nicht.")
|
||||
|
||||
|
||||
@app.get("/api/search/{movie_id}")
|
||||
async def search_detail(movie_id: int):
|
||||
"""Volle Daten zu einem Suchtreffer. Erst der Katalog, dann TMDB."""
|
||||
|
||||
@@ -7,6 +7,7 @@ landen nicht in der Datenbank.
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
import time
|
||||
import unicodedata
|
||||
|
||||
import httpx
|
||||
@@ -16,6 +17,7 @@ from .tmdb import (
|
||||
DETAIL_PARAMS,
|
||||
pick_backdrops,
|
||||
pick_overview,
|
||||
pick_rentable,
|
||||
pick_title,
|
||||
pick_trailer,
|
||||
)
|
||||
@@ -67,7 +69,15 @@ async def details_nachladen(http, treffer, je_film=4):
|
||||
Das alles steckt in der Detailansicht, ein Abruf je Film reicht.
|
||||
"""
|
||||
|
||||
# Nicht alle auf einmal: bei einer langen Filmografie wären das
|
||||
# über hundert gleichzeitige Abrufe
|
||||
bremse = asyncio.Semaphore(8)
|
||||
|
||||
async def hole(film):
|
||||
async with bremse:
|
||||
await hole_einen(film)
|
||||
|
||||
async def hole_einen(film):
|
||||
try:
|
||||
resp = await http.get(
|
||||
f"/movie/{film['id']}",
|
||||
@@ -106,6 +116,27 @@ SORTIERUNG = {
|
||||
|
||||
# Weiter hinten liegen nur noch Filme, die kaum jemand kennt
|
||||
MAX_SEITE = 20
|
||||
# TMDB liefert zwanzig je Seite; bis hundert sind es fünf Abrufe
|
||||
MAX_TREFFER = 100
|
||||
JE_SEITE = 20
|
||||
|
||||
|
||||
async def seiten(http, pfad, params, limit, start=1):
|
||||
"""So viele Seiten, wie das Limit braucht — alle auf einmal. Was
|
||||
fehlschlägt, fehlt eben; die Reihenfolge der Seiten bleibt."""
|
||||
anzahl = -(-limit // JE_SEITE)
|
||||
|
||||
async def eine(nummer):
|
||||
try:
|
||||
resp = await http.get(pfad, params={**params, "page": nummer})
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except httpx.HTTPError:
|
||||
return {}
|
||||
|
||||
antworten = await asyncio.gather(*(eine(start + i) for i in range(anzahl)))
|
||||
results = [r for a in antworten for r in a.get("results", [])]
|
||||
return results, antworten[0] if antworten else {}
|
||||
|
||||
|
||||
async def discover(filter_, limit=40):
|
||||
@@ -149,22 +180,16 @@ async def discover(filter_, limit=40):
|
||||
params["primary_release_date.lte"] = f"{filter_['jahr_max']}-12-31"
|
||||
|
||||
async with client() as http:
|
||||
resp = await http.get("/discover/movie", params=params)
|
||||
resp.raise_for_status()
|
||||
daten = resp.json()
|
||||
results, erste = await seiten(http, "/discover/movie", params, limit)
|
||||
|
||||
# Bei "Zufällig" nochmal von einer beliebigen Seite holen — sonst
|
||||
# stehen bei jedem Aufruf dieselben zwanzig Filme oben
|
||||
if filter_.get("sort") == "random":
|
||||
seiten = min(daten.get("total_pages") or 1, MAX_SEITE)
|
||||
if seiten > 1:
|
||||
params["page"] = random.randint(1, seiten)
|
||||
resp = await http.get("/discover/movie", params=params)
|
||||
resp.raise_for_status()
|
||||
daten = resp.json()
|
||||
|
||||
results = daten.get("results", [])
|
||||
# Bei "Zufällig" nochmal von einer beliebigen Stelle holen — sonst
|
||||
# stehen bei jedem Aufruf dieselben Filme oben
|
||||
if filter_.get("sort") == "random":
|
||||
gesamt = min(erste.get("total_pages") or 1, MAX_SEITE)
|
||||
noetig = -(-limit // JE_SEITE)
|
||||
if gesamt > noetig:
|
||||
start = random.randint(1, gesamt - noetig + 1)
|
||||
results, _ = await seiten(http, "/discover/movie", params, limit, start)
|
||||
random.shuffle(results)
|
||||
|
||||
usable = [r for r in results if r.get("poster_path")]
|
||||
@@ -187,19 +212,13 @@ async def search(query, limit=24):
|
||||
begriffe = [query] if schlicht == query else [query, schlicht]
|
||||
|
||||
async def einmal(begriff):
|
||||
try:
|
||||
resp = await http.get(
|
||||
"/search/movie",
|
||||
params={
|
||||
"query": begriff,
|
||||
"language": LANGUAGE,
|
||||
"include_adult": "false",
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("results", [])
|
||||
except httpx.HTTPError:
|
||||
return []
|
||||
results, _ = await seiten(
|
||||
http,
|
||||
"/search/movie",
|
||||
{"query": begriff, "language": LANGUAGE, "include_adult": "false"},
|
||||
limit,
|
||||
)
|
||||
return results
|
||||
|
||||
async with client() as http:
|
||||
results = []
|
||||
@@ -343,6 +362,114 @@ async def aehnliche(movie_id, limit=12, voll=False, filter_=None):
|
||||
return treffer
|
||||
|
||||
|
||||
# ── Personen: Schauspieler und Regisseure ────────────────────────────────
|
||||
MAX_PERSONEN = 12
|
||||
# Laufzeit und Szenenbilder kosten je Film einen Abruf — bei einer langen
|
||||
# Filmografie ein paar Sekunden. Einmal geholt, bleibt es einen Tag im
|
||||
# Speicher; so oft ändert sich eine Filmografie nicht.
|
||||
PERSONEN_CACHE = {}
|
||||
PERSONEN_FRIST = 24 * 3600
|
||||
|
||||
FACH = {
|
||||
"Acting": "Schauspiel",
|
||||
"Directing": "Regie",
|
||||
"Writing": "Drehbuch",
|
||||
"Production": "Produktion",
|
||||
}
|
||||
|
||||
|
||||
async def personen(query):
|
||||
"""Leute suchen. Wer ein Foto hat, steht vorn — sonst die Bekannten."""
|
||||
async with client() as http:
|
||||
try:
|
||||
resp = await http.get(
|
||||
"/search/person",
|
||||
params={"query": query, "language": LANGUAGE, "include_adult": "false"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
roh = resp.json().get("results", [])
|
||||
except httpx.HTTPError:
|
||||
return []
|
||||
roh.sort(key=lambda p: (not p.get("profile_path"), -(p.get("popularity") or 0)))
|
||||
return [
|
||||
{
|
||||
"id": p["id"],
|
||||
"name": p.get("name"),
|
||||
"fach": FACH.get(p.get("known_for_department"), p.get("known_for_department") or ""),
|
||||
"profile_path": p.get("profile_path"),
|
||||
"bekannt_fuer": [
|
||||
k.get("title") or k.get("name")
|
||||
for k in p.get("known_for", [])
|
||||
if k.get("title") or k.get("name")
|
||||
][:3],
|
||||
}
|
||||
for p in roh[:MAX_PERSONEN]
|
||||
]
|
||||
|
||||
|
||||
def credits_aufbereiten(daten):
|
||||
"""Aus den movie_credits zwei Listen: was jemand gespielt und wo er
|
||||
Regie geführt hat. Ohne Poster oder doppelt fliegt raus, die
|
||||
bekanntesten Filme stehen vorn. Kurzfilme und Making-ofs bleiben
|
||||
drin — die sortiert die Filterleiste aus, und zwar sichtbar."""
|
||||
|
||||
def liste(eintraege):
|
||||
raus, dabei = [], set()
|
||||
for e in sorted(eintraege, key=lambda e: -(e.get("popularity") or 0)):
|
||||
if not e.get("poster_path") or e["id"] in dabei:
|
||||
continue
|
||||
dabei.add(e["id"])
|
||||
raus.append(as_result(e))
|
||||
return raus
|
||||
|
||||
return {
|
||||
"gespielt": liste(daten.get("cast", [])),
|
||||
"regie": liste([c for c in daten.get("crew", []) if c.get("job") == "Director"]),
|
||||
}
|
||||
|
||||
|
||||
# Mehr Namen passen nicht in die Filmansicht, und ab da sind es Nebenrollen
|
||||
MAX_CAST = 8
|
||||
|
||||
|
||||
async def credits(movie_id):
|
||||
"""Regie und die wichtigsten Darsteller — als Sprungbrett zur
|
||||
Personensuche. Fehlt etwas, bleiben die Listen leer."""
|
||||
async with client() as http:
|
||||
try:
|
||||
# Ohne Sprache: auf Deutsch kämen russische Namen in Kyrillisch
|
||||
# und eingedeutschte Umschriften wie "Ilja Wiktorowitsch"
|
||||
resp = await http.get(f"/movie/{movie_id}/credits", params={"language": "en-US"})
|
||||
resp.raise_for_status()
|
||||
daten = resp.json()
|
||||
except httpx.HTTPError:
|
||||
return {"regie": [], "cast": []}
|
||||
|
||||
def person(p, fach):
|
||||
return {"id": p["id"], "name": p.get("name"), "fach": fach, "profile_path": p.get("profile_path")}
|
||||
|
||||
regie, dabei = [], set()
|
||||
for c in daten.get("crew", []):
|
||||
if c.get("job") == "Director" and c["id"] not in dabei:
|
||||
dabei.add(c["id"])
|
||||
regie.append(person(c, "Regie"))
|
||||
cast = sorted(daten.get("cast", []), key=lambda c: c.get("order", 999))[:MAX_CAST]
|
||||
return {"regie": regie, "cast": [person(c, "Schauspiel") for c in cast]}
|
||||
|
||||
|
||||
async def person_filme(person_id):
|
||||
gemerkt = PERSONEN_CACHE.get(person_id)
|
||||
if gemerkt and time.monotonic() - gemerkt[0] < PERSONEN_FRIST:
|
||||
return gemerkt[1]
|
||||
async with client() as http:
|
||||
resp = await http.get(f"/person/{person_id}/movie_credits", params={"language": LANGUAGE})
|
||||
resp.raise_for_status()
|
||||
rollen = credits_aufbereiten(resp.json())
|
||||
await details_nachladen(http, rollen["gespielt"] + rollen["regie"])
|
||||
PERSONEN_CACHE[person_id] = (time.monotonic(), rollen)
|
||||
return rollen
|
||||
|
||||
|
||||
async def titel_richten(http, film):
|
||||
"""Originalschrift durch den englischen Titel ersetzen."""
|
||||
if not any(
|
||||
@@ -387,6 +514,7 @@ async def detail(movie_id):
|
||||
"images": pick_backdrops(data)[:12],
|
||||
"trailer_key": pick_trailer(data),
|
||||
"imdb_id": data.get("imdb_id"),
|
||||
"rentable": pick_rentable(data),
|
||||
"netflix_id": None,
|
||||
"netflix_url": None,
|
||||
"on_netflix": 0,
|
||||
|
||||
@@ -36,6 +36,7 @@ def build_movie(data):
|
||||
"images": tmdb.pick_backdrops(data),
|
||||
"trailer_key": tmdb.pick_trailer(data),
|
||||
"imdb_id": data.get("imdb_id"),
|
||||
"rentable": tmdb.pick_rentable(data),
|
||||
# Die echte Titel-ID trägt fill_netflix_links nach
|
||||
"netflix_id": None,
|
||||
"netflix_url": tmdb.pick_watch_link(data)
|
||||
@@ -178,6 +179,37 @@ async def katalog_nachtragen():
|
||||
return len(filme)
|
||||
|
||||
|
||||
async def leihbarkeit_nachtragen():
|
||||
"""Filme, bei denen noch nicht steht, ob man sie leihen kann — der
|
||||
Altbestand von vor dieser Spalte. Der tägliche Sync hält es danach
|
||||
aktuell, hier geht es nur um das erste Mal."""
|
||||
with db.connect() as conn:
|
||||
offen = [r["id"] for r in conn.execute("SELECT id FROM movies WHERE rentable IS NULL")]
|
||||
if not offen:
|
||||
return 0
|
||||
|
||||
bremse = asyncio.Semaphore(TMDB_CONCURRENCY)
|
||||
|
||||
async def hole(http, movie_id):
|
||||
async with bremse:
|
||||
try:
|
||||
resp = await http.get(f"/movie/{movie_id}/watch/providers")
|
||||
resp.raise_for_status()
|
||||
return movie_id, tmdb.pick_rentable({"watch/providers": resp.json()})
|
||||
except Exception:
|
||||
return movie_id, None
|
||||
|
||||
async with tmdb.client() as http:
|
||||
stand = await asyncio.gather(*(hole(http, i) for i in offen))
|
||||
with db.connect() as conn:
|
||||
conn.executemany(
|
||||
"UPDATE movies SET rentable = ? WHERE id = ?",
|
||||
[(wert, i) for i, wert in stand if wert is not None],
|
||||
)
|
||||
log.info("Leihbarkeit für %s Filme nachgetragen", sum(1 for _, w in stand if w is not None))
|
||||
return len(offen)
|
||||
|
||||
|
||||
async def run():
|
||||
log.info("Sync startet")
|
||||
canon_places = await resolve_canon()
|
||||
|
||||
@@ -135,6 +135,15 @@ def pick_watch_link(data):
|
||||
return (providers.get(REGION) or {}).get("link")
|
||||
|
||||
|
||||
def pick_rentable(data):
|
||||
"""Ob man den Film in Deutschland leihen kann — laut JustWatch, über
|
||||
TMDB. None, wenn gar keine Anbieterdaten mitkamen."""
|
||||
results = data.get("watch/providers", {}).get("results")
|
||||
if results is None:
|
||||
return None
|
||||
return 1 if (results.get(REGION) or {}).get("rent") else 0
|
||||
|
||||
|
||||
def pick_trailer(data):
|
||||
"""Trailer bevorzugt, sonst Teaser. Sprache egal."""
|
||||
videos = [
|
||||
|
||||
Binary file not shown.
@@ -62,6 +62,9 @@ def test_vorgaben_aus_der_leiste():
|
||||
assert ki.vorgaben({"jahr_min": 1980, "jahr_max": 1989}) == ["Zwischen 1980 und 1989"]
|
||||
assert ki.vorgaben({}) == []
|
||||
assert "unbedingt einhalten" in ki.prompt("x", [], [], {"include": ["Horror"]})
|
||||
assert "Nenne genau 30 Filme" in ki.system(30)
|
||||
assert "38 Originaltitel" in ki.system(30)
|
||||
assert '{"merkmale"' in ki.system(12)
|
||||
|
||||
|
||||
def test_titel_muss_zum_treffer_passen():
|
||||
@@ -77,9 +80,9 @@ def test_titel_muss_zum_treffer_passen():
|
||||
def test_route_fragt_und_loest_auf(client, film, monkeypatch):
|
||||
aufrufe = {}
|
||||
|
||||
async def fragen(beschreibung, sammlung, gesehen, filter_):
|
||||
async def fragen(beschreibung, sammlung, gesehen, filter_, anzahl):
|
||||
aufrufe.update(
|
||||
beschreibung=beschreibung, sammlung=sammlung, gesehen=gesehen, filter=filter_
|
||||
beschreibung=beschreibung, sammlung=sammlung, gesehen=gesehen, filter=filter_, anzahl=anzahl
|
||||
)
|
||||
return [{"titel": "The Witch", "jahr": 2015, "grund": "Passt."}]
|
||||
|
||||
@@ -108,6 +111,9 @@ def test_route_fragt_und_loest_auf(client, film, monkeypatch):
|
||||
assert aufrufe["filter"]["include"] == ["Horror", "Mystery"]
|
||||
assert aufrufe["filter"]["jahr_min"] == 2010
|
||||
assert aufrufe["filter"]["note_max"] is None
|
||||
assert aufrufe["anzahl"] == 12
|
||||
client.post("/api/ki-suche", json={"beschreibung": "x", "limit": 40})
|
||||
assert aufrufe["anzahl"] == 40
|
||||
|
||||
# Gesehene dürfen auch drinbleiben — dann zeigt die Karte es an
|
||||
client.post("/api/ki-suche", json={"beschreibung": "x", "ohne_gesehene": False})
|
||||
@@ -115,7 +121,7 @@ def test_route_fragt_und_loest_auf(client, film, monkeypatch):
|
||||
|
||||
|
||||
def test_nur_filter_reichen_als_frage(client, monkeypatch):
|
||||
async def fragen(beschreibung, sammlung, gesehen, filter_):
|
||||
async def fragen(beschreibung, sammlung, gesehen, filter_, anzahl):
|
||||
return []
|
||||
|
||||
async def aufloesen(vorschlaege, filter_, ausser=()):
|
||||
|
||||
20
backend/tests/test_leihbar.py
Normal file
20
backend/tests/test_leihbar.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""Ob ein Film leihbar ist, kommt aus den Anbieterdaten von TMDB."""
|
||||
|
||||
from app import db, tmdb
|
||||
|
||||
|
||||
def test_leihbar_aus_anbieterdaten():
|
||||
de = lambda eintrag: {"watch/providers": {"results": {"DE": eintrag}}}
|
||||
assert tmdb.pick_rentable(de({"rent": [{"provider_name": "Amazon Video"}]})) == 1
|
||||
assert tmdb.pick_rentable(de({"flatrate": [{"provider_name": "Netflix"}]})) == 0
|
||||
assert tmdb.pick_rentable({"watch/providers": {"results": {}}}) == 0
|
||||
# Ohne Anbieterdaten bleibt es offen — nicht fälschlich "nicht leihbar"
|
||||
assert tmdb.pick_rentable({}) is None
|
||||
|
||||
|
||||
def test_leihbarkeit_kommt_mit_dem_film(client):
|
||||
with db.connect() as conn:
|
||||
db.upsert(conn, {"id": 7, "title": "Abo-Film", "rentable": 0})
|
||||
db.upsert(conn, {"id": 8, "title": "Unbekannt"})
|
||||
assert client.get("/api/movies/7").json()["rentable"] == 0
|
||||
assert client.get("/api/movies/8").json()["rentable"] is None
|
||||
58
backend/tests/test_personen.py
Normal file
58
backend/tests/test_personen.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Personensuche: Schauspieler und Regisseure, dazu ihre Filme."""
|
||||
|
||||
from app import main, search
|
||||
|
||||
|
||||
def test_credits_werden_nach_rolle_getrennt():
|
||||
daten = {
|
||||
"cast": [
|
||||
{"id": 1, "title": "Klein", "poster_path": "/a.jpg", "popularity": 2, "genre_ids": [27], "vote_count": 80},
|
||||
{"id": 2, "title": "Groß", "poster_path": "/b.jpg", "popularity": 9, "release_date": "2001-05-01", "vote_count": 900},
|
||||
{"id": 3, "title": "Ohne Poster", "poster_path": None, "popularity": 50, "vote_count": 500},
|
||||
{"id": 2, "title": "Groß nochmal", "poster_path": "/b.jpg", "popularity": 9, "vote_count": 900},
|
||||
{"id": 6, "title": "Cameo in Doku", "poster_path": "/e.jpg", "popularity": 99, "vote_count": 3},
|
||||
],
|
||||
"crew": [
|
||||
{"id": 4, "title": "Regiearbeit", "job": "Director", "poster_path": "/c.jpg", "popularity": 1, "vote_count": 60},
|
||||
{"id": 5, "title": "Nur Drehbuch", "job": "Writer", "poster_path": "/d.jpg", "popularity": 1, "vote_count": 60},
|
||||
],
|
||||
}
|
||||
rollen = search.credits_aufbereiten(daten)
|
||||
# Auch der Cameo bleibt — was raus soll, entscheidet die Filterleiste
|
||||
assert [f["title"] for f in rollen["gespielt"]] == ["Cameo in Doku", "Groß", "Klein"]
|
||||
assert rollen["gespielt"][1]["year"] == 2001
|
||||
assert rollen["gespielt"][2]["genres"] == ["Horror"]
|
||||
assert [f["title"] for f in rollen["regie"]] == ["Regiearbeit"]
|
||||
|
||||
|
||||
def test_filmografie_kommt_aus_dem_speicher(client):
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
search.PERSONEN_CACHE[99] = (time.monotonic(), {"gespielt": [{"id": 1}], "regie": []})
|
||||
# Ohne Netz — käme der Abruf durch, flöge hier ein Fehler
|
||||
assert asyncio.run(search.person_filme(99))["gespielt"] == [{"id": 1}]
|
||||
assert client.get("/api/personen/99/filme").json()["gespielt"] == [{"id": 1}]
|
||||
del search.PERSONEN_CACHE[99]
|
||||
|
||||
|
||||
def test_credits_route(client, monkeypatch):
|
||||
async def credits(movie_id):
|
||||
return {"regie": [{"id": 1, "name": "R", "fach": "Regie", "profile_path": None}], "cast": []}
|
||||
|
||||
monkeypatch.setattr(main.search, "credits", credits)
|
||||
assert client.get("/api/movies/5/credits").json()["regie"][0]["fach"] == "Regie"
|
||||
|
||||
|
||||
def test_routen_reichen_durch(client, monkeypatch):
|
||||
async def personen(query):
|
||||
return [{"id": 7, "name": query, "fach": "Regie", "profile_path": None, "bekannt_fuer": []}]
|
||||
|
||||
async def person_filme(person_id):
|
||||
return {"gespielt": [], "regie": [{"id": person_id, "title": "x"}]}
|
||||
|
||||
monkeypatch.setattr(main.search, "personen", personen)
|
||||
monkeypatch.setattr(main.search, "person_filme", person_filme)
|
||||
assert client.get("/api/personen?q=D").json() == []
|
||||
assert client.get("/api/personen?q=Duvivier").json()[0]["name"] == "Duvivier"
|
||||
assert client.get("/api/personen/7/filme").json()["regie"][0]["id"] == 7
|
||||
Reference in New Issue
Block a user