From a90044b0a7c1bdbb1c62ec58044b0258dfc87184 Mon Sep 17 00:00:00 2001 From: team3 Date: Fri, 18 Sep 2026 23:47:25 +0200 Subject: [PATCH] update --- backend/app/db.py | 2 + backend/app/ki.py | 30 +- backend/app/main.py | 42 ++- backend/app/search.py | 182 ++++++++-- backend/app/sync.py | 32 ++ backend/app/tmdb.py | 9 + backend/horror.db.bak | Bin 22609920 -> 22597632 bytes backend/tests/test_ki.py | 12 +- backend/tests/test_leihbar.py | 20 ++ backend/tests/test_personen.py | 58 +++ frontend/src/App.vue | 136 ++++--- frontend/src/components/AehnlichTab.vue | 33 +- frontend/src/components/FilterSidebar.vue | 52 ++- frontend/src/components/KiTab.vue | 9 +- frontend/src/components/MovieDetail.vue | 103 +++++- frontend/src/components/PersonenTab.vue | 360 +++++++++++++++++++ frontend/src/components/SearchTab.vue | 15 +- frontend/src/components/WuenscheTab.vue | 144 ++++---- frontend/src/composables/useEinstellungen.js | 2 +- frontend/src/composables/useFilter.js | 99 ++++- frontend/src/filter.js | 24 ++ 21 files changed, 1111 insertions(+), 253 deletions(-) create mode 100644 backend/tests/test_leihbar.py create mode 100644 backend/tests/test_personen.py create mode 100644 frontend/src/components/PersonenTab.vue 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 856527241a28276d4454034c9e366a6770485a30..de4daf03261290b0f2287b2ddbdc65ca2ac96e06 100644 GIT binary patch delta 2513 zcmajgdw5gz9mnx}b7`9EY0}mdTIne*v{GBrHc79vlu~G+!E&*pj&VwRO4Cb1)1VtS zTl9!RAw`DuvhIg&^RkHr6{@cEz`$+loZ^N~=@hZB3qy2XCZN>MPkf&3dG^PW=k@-b zbDoo5&U5m6PI`LA%{_ZQH1{Vb+BBNP?VA6*^w`w1-tq=Y9;kOGCy=dIQY2C&ts?0Z zNv}u-MT$`*qawvBl1Y)wieynFt0LJH$*xF(BE>0^Ly??{6n{WU7@wotE#Jz97Kt=@ zV9i5iaa+2o5+D(-0T(2}B$y1zkOJ4j6i5XT(qJm2Lk3KP>5vIoa2?EmY`7kBAQxtW z8}c9@3cv$i@IfIIK{1p->2Oz7nd?#Qr$LtX>U>IPabFgZFg$XmZ23CX*7@mh0 z;6?ZqjKCon1r-j%5%@J6g=6p%{04ptzk`?I6?heX53j)=;B|Ndj>DVqNB9$*fVbdn z_%pl%@4`tKgTKIg@IIV^58yO>2;*=D&ca{eZ}4~c2b_a{!g=@zF2Kj|3H%E_h0ow~ z_yYb7U&2MW1ef6}_!_Rj#J0>3MS(}lqvO%@7ZhdY|rMy($CDF6tny!=MgLxac(l z-ODx_lBim^XZOP?8jaJuYp@89`7P>k9G$PDt?DFG60OzJ-O;EeiI7R8MlF*m{8<*i zi?%+EaG#KFZ?j#tSKFiZ=Y(Nl%+_ar$1Vz;w*B@iLXAyz9ImB9-#!Z;i=CFC^V9#k znNHS+-y_4|;3u=OvkfFhFX0H1!gDRDmuypL)h#q0UC}y)kW?O#Cyi$+PdZNq&orLt zJefRMJlFBe;K}B>o+pPVmuDuAnm0cgkYR(UGv&9FWCsITDcnd(GwZHRthOU2|OyOc?rZLOI;h z*cx6NTrFmc9rA5*$JJQ^!H67gTpf_Za!~9HM8sw}jA;Xr|DD!dcy-!nw5AkGpr(vx z7Ed`(1C#FtsAU)ma~=z^(UEls*W<)Gh-I&S(Ho<_%Q3ljpD#AJx$*OO4s2G5Jm8s?e|_IIGB5aP_|2zprsn z9sIbCo=jDpYpQAew^zT^#1c~dbdJnBL_h5i7r_ls4~uD3TtZoUhTn|atJAT=2jl4= z`{3vjnn4{=>#-$-Eah27s`c27bn%>7h}Q`5_H4Wdj$6*p;Me@F-P+qmCJkx)wE5-= l)^!#a%_?7CPDfS!;RYf+9B0STgb>yD-$bgue>r_M^BWZaY%Tx* delta 2421 zcmZA32~<>79LMqZpM_zV<;`Pp5KKluaR~$z!3_jNQ9yA+wA4pMAugz3lq--gcOPao zxzI&hZ4oVv7UF8!YKvByCMhb~VlHi_lG5**ony_+=X+=FIdjg;n|I%xirUkD6%{Rh z)uBQ8k`z?^56LJ=k`+v>GC7Yzy}4?)K+3OFHI+2gps7YpHEF6@Q++hmqN%=`YSmOf zO|@yNT~i&J>eN(OQ(c;>XsW-a2JBJ;Thff{3h!cdBRmx1owZ?rYw7ItKnQ|%;D%sm z4eUfIg52NzfPi zLI3*M=>yzb48P7~o%Ro<2=hry5mLtrRm!Y~*PS&$7mFakzGE{uZFFb2lLILL$XkPj1JBHRUc!z7pt zQ$U3RxCicq`=Ai+hXW`{7gg3_gc1-~b$iFX1cr8V_y&%^QTP^)K@)rj z$KeDt!$~*=r=bPTz*+bnet;k0CpZT`!+E#>7vU280>8p<@H_kgm*G#i0$1T0w8CF- z9d1C|%BT|3LBt?p6fudIMSMgoBEBM45kC=|h+V`X;v^nbBGd5$++01L+GO37>mqK- zRmc!yIA6wgSRyEdd4rS5$zvBO)Y8cyzrz)*%C(kTi_?q6xsmOwQ)r;cX_8#ryj`I^ zcJgN*HYgNpiR;%Jk63;KuYAbMml;#c$re)%vA9=9QMg{*s1V{W5+D*N5+u@&cyXhf z`Xtg4w)qb?ouEfpqFfOD;Olft~>$H%+ zk1?HS0kd9rQ!348Sr|JQ6f>KOC*ROKo_fPg)5#Dto!7Ow=`%AeV++l}lp!yDVq*tM z^7mU;lY}exqx>LdhZ<=)Z*L8zxdzg7-4;wpd*Zq+gy#I-)v-fzIaW#XV{(ME*wO0D zaO%#Ta=qN-JnHnwGaRd&H{?vmVFy=s52JTYw_V^OiS2NNQY86%Q%fdvHbok7h?>sk zkw?FVd8C8L9mKCWI?_E8>9jtns3Rd^A|8=WBHT@d6KERIN_}li0wH}w5=D|k`ik@; zzBZ;ml`2NrS7(+%{VD9$`6Eu8Bf6g1A4f2AfXG0RWRVn+K_aOlX(H(&gGDk#hKLLm z$rKrepCEHMmA=q!g=CS-4e}}Z6M36lBOj74%H?wT|4v$Lx!GM5n=y*Y3(D>-D!gyX z%#yOgQkUEy1z=~s{l;qAhEqp!u?CF6PTA&b45c{*Wm9K9P@18i!X!V{mPL`xAx)CJ zOD>i@&O;8>5oG_^zQ7)5J8N5SYGUEd*%WCsNBMBYlSYXqvFA@_$6B%}%a)mDm8>~d za;HdaKURsSohheaPT{op*!Vutv3;ZC6352H_lry9MQ5_9hm|tCscN@WcKAXzHM1_5 z%X)HqK@G3DlucX1uy>BymPd7T>(shAY%;UJupBx^X=^+=w8k?6MnW!(qDIeX>RM~2 z1-N?;<_=G|`JTl_c5?4%>Q1Y5`@YeHj1d`2+`exdT|LLmgIv@qH}1|O^8wvlJD#|? zHlJF1`a5v+NDh$X0Ot~ClB32^bNj)cWwBQl`&lKDa;R!T3Y&d4o5EQt&!fF8FKirb hyZwV#{d<+UH7WiH !!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 || '') } } -