This commit is contained in:
team3
2026-09-18 23:47:25 +02:00
parent db77c1f985
commit a90044b0a7
21 changed files with 1111 additions and 253 deletions

View File

@@ -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}")

View File

@@ -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 []

View File

@@ -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."""

View File

@@ -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,

View File

@@ -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()

View File

@@ -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.

View File

@@ -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=()):

View 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

View 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