diff --git a/backend/app/db.py b/backend/app/db.py
index c54d536..c130338 100644
--- a/backend/app/db.py
+++ b/backend/app/db.py
@@ -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}")
diff --git a/backend/app/ki.py b/backend/app/ki.py
index e80f0f4..417381c 100644
--- a/backend/app/ki.py
+++ b/backend/app/ki.py
@@ -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 []
diff --git a/backend/app/main.py b/backend/app/main.py
index b307734..e692370 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -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."""
diff --git a/backend/app/search.py b/backend/app/search.py
index 3cca02c..7ad9c16 100644
--- a/backend/app/search.py
+++ b/backend/app/search.py
@@ -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,
diff --git a/backend/app/sync.py b/backend/app/sync.py
index c64554c..f25e090 100644
--- a/backend/app/sync.py
+++ b/backend/app/sync.py
@@ -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()
diff --git a/backend/app/tmdb.py b/backend/app/tmdb.py
index c03a8ab..eaa0c7e 100644
--- a/backend/app/tmdb.py
+++ b/backend/app/tmdb.py
@@ -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 = [
diff --git a/backend/horror.db.bak b/backend/horror.db.bak
index 8565272..de4daf0 100644
Binary files a/backend/horror.db.bak and b/backend/horror.db.bak differ
diff --git a/backend/tests/test_ki.py b/backend/tests/test_ki.py
index 5775bad..2315429 100644
--- a/backend/tests/test_ki.py
+++ b/backend/tests/test_ki.py
@@ -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=()):
diff --git a/backend/tests/test_leihbar.py b/backend/tests/test_leihbar.py
new file mode 100644
index 0000000..ad10e5b
--- /dev/null
+++ b/backend/tests/test_leihbar.py
@@ -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
diff --git a/backend/tests/test_personen.py b/backend/tests/test_personen.py
new file mode 100644
index 0000000..fe2754f
--- /dev/null
+++ b/backend/tests/test_personen.py
@@ -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
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 4fe7939..1207843 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -4,6 +4,7 @@ import MovieCard from './components/MovieCard.vue'
import FilterSidebar from './components/FilterSidebar.vue'
import AehnlichTab from './components/AehnlichTab.vue'
import KiTab from './components/KiTab.vue'
+import PersonenTab from './components/PersonenTab.vue'
import MerklisteTab from './components/MerklisteTab.vue'
import SammlungFilter from './components/SammlungFilter.vue'
import MovieDetail from './components/MovieDetail.vue'
@@ -24,11 +25,20 @@ import { useLangeListe } from './composables/useLangeListe'
const { ansicht, ohneGesehene, activeTab, quelle, suchArt, host } = useEinstellungen()
const {
- runde, ich, michSetzen,
- gesehen, gesehenIds, gesehenZahl, ladeGesehen,
- meinVorschlag, aufDemRad, ladeVorschlag,
- merkliste, ladeMerkliste,
- wuensche, ladeWuensche,
+ runde,
+ ich,
+ michSetzen,
+ gesehen,
+ gesehenIds,
+ gesehenZahl,
+ ladeGesehen,
+ meinVorschlag,
+ aufDemRad,
+ ladeVorschlag,
+ merkliste,
+ ladeMerkliste,
+ wuensche,
+ ladeWuensche,
} = useMeins({ host })
// Ohne gewählten Namen darf man alles lesen, aber nichts eintragen —
@@ -37,44 +47,74 @@ const {
const angemeldet = computed(() => !!ich.value)
const {
- movies, loading, error, load,
- netflixMovies, canonMovies, canonGroups, netflixEntries, SORTS,
+ movies,
+ loading,
+ error,
+ load,
+ netflixMovies,
+ canonMovies,
+ canonGroups,
+ netflixEntries,
+ SORTS,
} = useKatalog()
const {
- search, sort, suchSort, include, exclude, grenzen, ranges, genreOptions,
- toggleGenre, toggleAll, resetGenres, nichtGesehen, passt, suchFilter,
-} = useFilter({ activeTab, quelle, netflixMovies, canonMovies, gesehenIds, ohneGesehene })
+ search,
+ sort,
+ suchSort,
+ suchAnzahl,
+ include,
+ exclude,
+ grenzen,
+ ranges,
+ genreOptions,
+ toggleGenre,
+ toggleAll,
+ resetGenres,
+ nichtGesehen,
+ passt,
+ suchFilter,
+} = useFilter({
+ activeTab,
+ quelle,
+ suchArt,
+ netflixMovies,
+ canonMovies,
+ gesehenIds,
+ ohneGesehene,
+})
// Eine Reihe erscheint, sobald ein Teil passt — sortiert wird sie über
// ihren stärksten Teil
function filterGruppen(entries, term) {
return entries
.filter(
- (e) =>
- e.parts.some((m) => passt(m)) &&
- (!term || e.parts.some((m) => matches(m, term)))
+ (e) => e.parts.some((m) => passt(m)) && (!term || e.parts.some((m) => matches(m, term))),
)
.sort((a, b) => SORTS[sort.value](a.parts[a.best], b.parts[b.best]))
}
const visible = computed(() =>
- filterGruppen(netflixEntries.value, search.value.trim().toLowerCase())
+ filterGruppen(netflixEntries.value, search.value.trim().toLowerCase()),
)
// Im Kanon greift die Suche auf die ganze Reihe: wer "Saw" sucht, will den
// Eintrag sehen, egal welcher Teil gerade vorne liegt.
const visibleCanon = computed(() =>
- filterGruppen(canonGroups.value, search.value.trim().toLowerCase())
+ filterGruppen(canonGroups.value, search.value.trim().toLowerCase()),
)
const aktuelleListe = computed(() =>
- quelle.value === 'netflix' ? visible.value : visibleCanon.value
+ quelle.value === 'netflix' ? visible.value : visibleCanon.value,
)
// Die zwei Listen des Katalogs, umschaltbar in der Filterleiste
const quellen = computed(() => [
- { id: 'netflix', label: 'Netflix', count: netflixMovies.value.filter(nichtGesehen).length },
+ {
+ id: 'netflix',
+ label: 'Netflix',
+ count: netflixMovies.value.filter(nichtGesehen).length,
+ },
{
id: 'canon',
label: 'Horror-Kanon',
@@ -98,7 +138,11 @@ const tabs = computed(() => [
{ id: 'wheel', label: 'Rad', count: null },
{ id: 'stream', label: 'Stream', count: null },
{ id: 'watched', label: 'Gesehen', count: gesehenZahl.value },
- { id: 'wuensche', label: 'Wünsche', count: wuensche.value.filter((w) => !w.done_at).length },
+ {
+ id: 'wuensche',
+ label: 'Wünsche',
+ count: wuensche.value.filter((w) => !w.done_at).length,
+ },
...(host.value || angemeldet.value
? [{ id: 'verwaltung', label: 'Verwaltung', count: null }]
: []),
@@ -107,9 +151,7 @@ const tabs = computed(() => [
// Auf dem Handy passt die Filterleiste nicht neben die Karten — dort
// legt sie sich über einen Knopf über die Seite
const filterOffen = ref(false)
-const mitFilter = computed(() =>
- ['katalog', 'suche', 'merkliste'].includes(activeTab.value)
-)
+const mitFilter = computed(() => ['katalog', 'suche', 'merkliste'].includes(activeTab.value))
// Der Sammlungen-Reiter filtert nach Person und Genre, nicht nach Regler —
// dafür hat er eine eigene Leiste
@@ -124,9 +166,7 @@ const leuteMitSammlung = computed(() => {
if (!nach.has(name)) nach.set(name, { name, anzahl: 0, mine: eintrag.mine })
nach.get(name).anzahl += 1
}
- return [...nach.values()].sort(
- (a, b) => b.mine - a.mine || a.name.localeCompare(b.name, 'de')
- )
+ return [...nach.values()].sort((a, b) => b.mine - a.mine || a.name.localeCompare(b.name, 'de'))
})
// Fällt der offene Reiter weg — Hostmodus aus, Name abgewählt —, wäre
@@ -180,9 +220,7 @@ const series = computed(() => {
: []
if (group.length < 2) return []
- return [...group].sort((a, b) =>
- (a.release_date || '').localeCompare(b.release_date || '')
- )
+ return [...group].sort((a, b) => (a.release_date || '').localeCompare(b.release_date || ''))
})
// Aus der Filmansicht heraus zur Ähnlichkeitsuche springen
@@ -195,10 +233,21 @@ function zurAehnlichkeitsuche(movie) {
suchArt.value = 'aehnlich'
}
+// Aus der Filmansicht zu einer Person: Regie oder Besetzung
+const personStart = ref(null)
+
+function zurPersonensuche(person) {
+ personStart.value = person
+ selected.value = null
+ activeTab.value = 'suche'
+ suchArt.value = 'personen'
+}
+
// Die drei Suchen unter einem Reiter, umschaltbar über der Liste
const SUCHARTEN = [
{ id: 'search', label: 'Titel' },
{ id: 'aehnlich', label: 'Ähnliche Filme' },
+ { id: 'personen', label: 'Personen' },
{ id: 'ki', label: 'KI' },
]
@@ -238,12 +287,7 @@ onMounted(load)
Filter
Host
-
{{ movie.original_title }}
@@ -188,6 +208,22 @@ onUnmounted(() => {{{ (movie.genres || []).join(' · ') }}
+ +
+
+ {{ titel }}
+
Nichts gefunden.
+{{ gewaehlt.fach }}
+Suche läuft…
+{{ fehler }}
+Nichts gefunden. Stell die Filter weiter.
+ ++ {{ titel }} + {{ liste.length }} +
+Suche läuft…
{{ error }}
- {{ gestoebert ? 'Nichts gefunden. Stell die Filter weiter.' : `Nichts gefunden für „${query}“.` }} + {{ + gestoebert ? 'Nichts gefunden. Stell die Filter weiter.' : `Nichts gefunden für „${query}“.` + }}
{{ w.text }}