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 - + @@ -280,6 +324,17 @@ onMounted(load) @gestartet="aehnlichStart = null" /> + + - + @@ -459,6 +511,7 @@ onMounted(load) @suggested="nachVorschlag" @watched="ladeGesehen" @aehnlich="zurAehnlichkeitsuche" + @person="zurPersonensuche" /> @@ -649,10 +702,7 @@ onMounted(load) /* Eine Detailkarte ist so breit wie vier kompakte Karten nebeneinander — oder die halbe Reihe, falls die schmaler ist. */ .grid.details { - grid-template-columns: repeat( - auto-fill, - min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem)) - ); + grid-template-columns: repeat(auto-fill, min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem))); } .note { diff --git a/frontend/src/components/AehnlichTab.vue b/frontend/src/components/AehnlichTab.vue index a0acbc9..1a90a5a 100644 --- a/frontend/src/components/AehnlichTab.vue +++ b/frontend/src/components/AehnlichTab.vue @@ -3,9 +3,6 @@ import { computed, ref, watch } from 'vue' import MovieCard from './MovieCard.vue' import { filterParameter } from '../filter.js' -// Der Ausgangsfilm steht als erste Karte davor, macht einundzwanzig -const TREFFER = 20 - // Eine Detailkarte nimmt die halbe Breite ein, eine kompakte ein Achtel. // Gleich viele Vorschläge wären mal eine Reihe, mal zehn. const VORSCHLAEGE = { kompakt: 20, details: 8 } @@ -24,9 +21,7 @@ const emit = defineEmits(['open', 'gestartet']) const query = ref('') const gefunden = ref([]) // Beim Wechsel der Ansicht wird nur neu geschnitten, nicht neu gesucht -const vorschlaege = computed(() => - gefunden.value.slice(0, VORSCHLAEGE[props.ansicht] ?? 8) -) +const vorschlaege = computed(() => gefunden.value.slice(0, VORSCHLAEGE[props.ansicht] ?? 8)) const gewaehlt = ref(null) const treffer = ref([]) const laden = ref(false) @@ -86,8 +81,8 @@ async function nachladen() { laden.value = true fehler.value = '' try { + // Die Anzahl kommt aus der Leiste, mit filterParameter als 'limit' const params = new URLSearchParams({ - limit: TREFFER, voll: 'true', ...filterParameter(props.filter), }) @@ -123,7 +118,7 @@ watch( waehlen(film) emit('gestartet') }, - { immediate: true } + { immediate: true }, ) // Am Schieberegler hängen viele Ereignisse — erst abwarten, dann holen @@ -134,7 +129,7 @@ watch( clearTimeout(filterTimer) filterTimer = setTimeout(nachladen, 350) }, - { deep: true } + { deep: true }, ) watch(query, (term) => { @@ -160,13 +155,7 @@ async function oeffnen(movie) { placeholder="Von welchem Film ausgehen?" autofocus /> - @@ -196,12 +185,7 @@ async function oeffnen(movie) {

- + [] }, + // Die Suchen lassen wählen, wie viele Treffer es sein sollen + mitAnzahl: Boolean, }) const emit = defineEmits(['toggle', 'all', 'reset']) @@ -23,9 +25,13 @@ const emit = defineEmits(['toggle', 'all', 'reset']) const search = defineModel('search', { type: String, default: '' }) const sort = defineModel('sort', { type: String, default: 'random' }) const ansicht = defineModel('ansicht', { type: String, default: 'kompakt' }) -const ohneGesehene = defineModel('ohneGesehene', { type: Boolean, default: true }) +const ohneGesehene = defineModel('ohneGesehene', { + type: Boolean, + default: true, +}) const ranges = defineModel('ranges', { type: Object, default: () => ({}) }) const quelle = defineModel('quelle', { type: String, default: '' }) +const anzahl = defineModel('anzahl', { type: Number, default: 24 }) // Auch ein verschobener Regler zählt als aktiver Filter const etwasAktiv = computed( @@ -36,7 +42,7 @@ const etwasAktiv = computed( const spanne = ranges.value[r.key] const grenze = props.grenzen[r.key] return spanne && grenze && (spanne[0] > grenze.min || spanne[1] < grenze.max) - }) + }), ) function beschriften(regler) { @@ -50,7 +56,6 @@ function beschriften(regler) { return zahl + (regler.einheit || '') } } -