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
/>
-
-
+
[] },
+ // 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 || '')
}
}
-
@@ -69,12 +74,7 @@ function beschriften(regler) {
-
+
+
+
-
+
Kompakt
-
+
Details
@@ -309,6 +306,23 @@ h4 {
text-decoration: line-through;
}
+.anzahl {
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+ color: var(--muted);
+ font-size: 0.82rem;
+}
+
+.anzahl b {
+ color: var(--text);
+}
+
+.controls .anzahl input {
+ padding: 0;
+ accent-color: var(--accent);
+}
+
.ansicht {
display: flex;
gap: 0.3rem;
diff --git a/frontend/src/components/KiTab.vue b/frontend/src/components/KiTab.vue
index 887b6fd..250ed42 100644
--- a/frontend/src/components/KiTab.vue
+++ b/frontend/src/components/KiTab.vue
@@ -28,14 +28,14 @@ const gefragt = ref(false)
// Eine verstellte Leiste ist schon eine Frage: "Horror ab 2015" reicht
const leisteVerstellt = computed(() => {
- const { sort, ...rest } = filterParameter(props.filter)
+ const { sort, limit, ...rest } = filterParameter(props.filter)
return Object.values(rest).some((w) => w !== '' && w != null)
})
const kannFragen = computed(
() =>
!laden.value &&
- (beschreibung.value.trim().length > 0 || mitSammlung.value || leisteVerstellt.value)
+ (beschreibung.value.trim().length > 0 || mitSammlung.value || leisteVerstellt.value),
)
async function fragen() {
@@ -196,10 +196,7 @@ textarea:focus {
}
.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/MovieDetail.vue b/frontend/src/components/MovieDetail.vue
index 7f523e5..ec44349 100644
--- a/frontend/src/components/MovieDetail.vue
+++ b/frontend/src/components/MovieDetail.vue
@@ -14,7 +14,7 @@ const props = defineProps({
})
const emit = defineEmits([
- 'close', 'open', 'suggested', 'watched', 'gemerkt', 'aehnlich',
+ 'close', 'open', 'suggested', 'watched', 'gemerkt', 'aehnlich', 'person',
])
const vorschlagen = ref({ zustand: 'bereit' })
@@ -109,11 +109,25 @@ async function ladeAehnliche(id) {
}
}
+// Regie und Besetzung — jeder Name führt zur Personensuche
+const leute = ref({ regie: [], cast: [] })
+
+async function ladeLeute(id) {
+ leute.value = { regie: [], cast: [] }
+ try {
+ const resp = await fetch(`/api/movies/${id}/credits`)
+ if (resp.ok && props.movie.id === id) leute.value = await resp.json()
+ } catch {
+ /* dann eben ohne */
+ }
+}
+
watch(
() => props.movie.id,
(id) => {
vorschlagen.value = { zustand: 'bereit' }
ladeAehnliche(id)
+ ladeLeute(id)
},
{ immediate: true }
)
@@ -176,7 +190,13 @@ onUnmounted(() => {
-
{{ movie.title }}
+
+ {{ movie.title }}
+
+
+ Nicht leihbar
+
+
{{ movie.original_title }}
@@ -188,6 +208,22 @@ onUnmounted(() => {
{{ (movie.genres || []).join(' · ') }}
+
+
+
+ {{ titel }}
+
+ {{ p.name }}
+
+
+
{{ movie.teaser }}
@@ -244,12 +290,8 @@ onUnmounted(() => {
-
-
- Steht unter „Gesehen“.
-
+
+// Nach Leuten suchen: einen Namen tippen, eine Person wählen, dann
+// stehen ihre Filme da — getrennt nach gespielt und Regie geführt.
+import { computed, ref, watch } from 'vue'
+import MovieCard from './MovieCard.vue'
+import { posterUrl } from '../images'
+import { passtZurLeiste } from '../filter.js'
+
+const props = defineProps({
+ filter: { type: Object, default: () => ({}) },
+ ansicht: { type: String, default: 'kompakt' },
+ gesehenIds: { type: Set, default: () => new Set() },
+ ohneGesehene: { type: Boolean, default: true },
+ // Kommt aus der Filmansicht: mit dieser Person soll es losgehen
+ start: { type: Object, default: null },
+})
+
+const emit = defineEmits(['open', 'gestartet'])
+
+const query = ref('')
+const personen = ref([])
+const personenLaden = ref(false)
+const gewaehlt = ref(null)
+const rollen = ref({ gespielt: [], regie: [] })
+const laden = ref(false)
+const fehler = ref('')
+
+let timer = null
+let laufende = 0
+let laufendeWahl = 0
+
+async function suchen(term) {
+ if (term.trim().length < 2) {
+ personen.value = []
+ return
+ }
+ const meine = ++laufende
+ personenLaden.value = true
+ try {
+ const resp = await fetch(`/api/personen?q=${encodeURIComponent(term.trim())}`)
+ if (!resp.ok) throw new Error('Suche fehlgeschlagen')
+ const daten = await resp.json()
+ if (meine !== laufende) return
+ personen.value = daten
+ } catch {
+ if (meine === laufende) personen.value = []
+ } finally {
+ if (meine === laufende) personenLaden.value = false
+ }
+}
+
+watch(query, () => {
+ clearTimeout(timer)
+ timer = setTimeout(() => suchen(query.value), 350)
+})
+
+async function waehlen(person) {
+ const meine = ++laufendeWahl
+ gewaehlt.value = person
+ query.value = ''
+ personen.value = []
+ laden.value = true
+ fehler.value = ''
+ try {
+ const resp = await fetch(`/api/personen/${person.id}/filme`)
+ if (!resp.ok) throw new Error('Das hat nicht geklappt.')
+ const daten = await resp.json()
+ if (meine !== laufendeWahl) return
+ rollen.value = daten
+ } catch (err) {
+ if (meine === laufendeWahl) fehler.value = err.message
+ } finally {
+ if (meine === laufendeWahl) laden.value = false
+ }
+}
+
+function passt(movie) {
+ if (props.ohneGesehene && props.gesehenIds.has(movie.id)) return false
+ return passtZurLeiste(movie, props.filter)
+}
+
+// Die Reihenfolge aus der Leiste. "Wie auf IMDb" ist hier die von
+// TMDB: die bekanntesten Filme zuerst.
+const wuerfel = new Map()
+function zufall(movie) {
+ if (!wuerfel.has(movie.id)) wuerfel.set(movie.id, Math.random())
+ return wuerfel.get(movie.id)
+}
+
+const SORTS = {
+ random: (a, b) => zufall(a) - zufall(b),
+ rating: (a, b) => (b.vote_average || 0) - (a.vote_average || 0),
+ newest: (a, b) => (b.year || 0) - (a.year || 0),
+ oldest: (a, b) => (a.year || 9999) - (b.year || 9999),
+ title: (a, b) => a.title.localeCompare(b.title, 'de'),
+}
+
+function sortiert(liste) {
+ const sort = SORTS[props.filter.sort]
+ return sort ? [...liste].sort(sort) : liste
+}
+
+const abschnitte = computed(() =>
+ [
+ ['Als Schauspieler', rollen.value.gespielt],
+ ['Als Regisseur', rollen.value.regie],
+ ]
+ .map(([titel, liste]) => [titel, sortiert(liste.filter(passt))])
+ .filter(([, liste]) => liste.length),
+)
+
+watch(
+ () => props.start,
+ (person) => {
+ if (!person) return
+ waehlen(person)
+ emit('gestartet')
+ },
+ { immediate: true }
+)
+
+async function oeffnen(movie) {
+ const resp = await fetch(`/api/search/${movie.id}`)
+ if (resp.ok) emit('open', await resp.json())
+}
+
+
+
+
+
+
+
+ ×
+
+
+
+
+
+ Nichts gefunden.
+
+ -
+
+
+
+
+ {{ p.name }}
+ {{ p.fach }}
+
+ {{ p.bekannt_fuer.join(' · ') }}
+
+
+
+
+
+
+
+
+
+
![]()
+
+
{{ gewaehlt.name }}
+
{{ gewaehlt.fach }}
+
+
+
+ Suche läuft…
+ {{ fehler }}
+ Nichts gefunden. Stell die Filter weiter.
+
+
+
+ {{ titel }}
+ {{ liste.length }}
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/SearchTab.vue b/frontend/src/components/SearchTab.vue
index 10cf094..4fc42e2 100644
--- a/frontend/src/components/SearchTab.vue
+++ b/frontend/src/components/SearchTab.vue
@@ -58,7 +58,7 @@ async function run(term) {
try {
const url = stoebern
? `/api/discover?${new URLSearchParams(filterParameter(props.filter))}`
- : `/api/search?q=${encodeURIComponent(term.trim())}`
+ : `/api/search?q=${encodeURIComponent(term.trim())}&limit=${props.filter.anzahl || 24}`
const resp = await fetch(url)
if (!resp.ok) throw new Error('Suche fehlgeschlagen')
const data = await resp.json()
@@ -113,15 +113,15 @@ async function open(movie) {
×
-
- Suchen
-
+ Suchen
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}“.`
+ }}
@@ -195,10 +195,7 @@ async function open(movie) {
}
.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)));
}
/* Auf dem Handy wie im Katalog: drei Poster nebeneinander */
diff --git a/frontend/src/components/WuenscheTab.vue b/frontend/src/components/WuenscheTab.vue
index 5d83017..bfcec6b 100644
--- a/frontend/src/components/WuenscheTab.vue
+++ b/frontend/src/components/WuenscheTab.vue
@@ -1,5 +1,5 @@
@@ -129,9 +129,7 @@ function datum(iso) {
placeholder="Was fehlt dir auf der Seite?"
@keydown.ctrl.enter="anlegen"
>
-
- Eintragen
-
+ Eintragen
-
- Speichern
-
-
+ Speichern
+
Abbrechen
@@ -188,9 +180,7 @@ function datum(iso) {
{{ w.text }}
- {{
- w.name || "Anonym"
- }}
+ {{ w.name || 'Anonym' }}
·{{ datum(w.created_at) }}
·erledigt
@@ -210,9 +200,7 @@ function datum(iso) {
- {{
- n.name || "Anonym"
- }}
+ {{ n.name || 'Anonym' }}
{{ n.text }}
- {{ w.done_at ? "↺" : "✓" }}
+ {{ w.done_at ? '↺' : '✓' }}
({ genre, count: null }))
export function useFilter({
- activeTab, quelle, netflixMovies, canonMovies, gesehenIds, ohneGesehene,
+ activeTab,
+ quelle,
+ suchArt,
+ netflixMovies,
+ canonMovies,
+ gesehenIds,
+ ohneGesehene,
}) {
const search = ref('')
const sort = ref('random')
- // Suche und Ähnlichkeitsuche haben eine eigene Voreinstellung: TMDB liefert
- // dort eine Reihenfolge nach Verwandtschaft — dieselbe, die IMDb unter
- // "More like this" zeigt. Im Katalog gibt es die nicht.
- const suchSort = ref('relevanz')
+ // Jede Suche hat ihre eigene sinnvolle Reihenfolge: die Titelsuche die
+ // von TMDB (wie auf IMDb), Ähnliche gemischt, bei Personen das Neueste
+ // zuerst. Die Wahl bleibt je Suche erhalten.
+ const suchSorts = ref({
+ search: 'relevanz',
+ aehnlich: 'random',
+ personen: 'newest',
+ })
+ const suchSort = computed({
+ get: () => suchSorts.value[suchArt.value] ?? 'relevanz',
+ set: (wert) => {
+ suchSorts.value[suchArt.value] = wert
+ },
+ })
+
+ // Wie viele Treffer eine Suche bringen soll — je Suche gemerkt. Bei
+ // Personen gibt es das nicht: da sind es eben alle Filme.
+ const suchAnzahlen = ref({ search: 24, aehnlich: 20, ki: 12 })
+ const suchAnzahl = computed({
+ get: () => suchAnzahlen.value[suchArt.value] ?? 24,
+ set: (wert) => {
+ suchAnzahlen.value[suchArt.value] = wert
+ },
+ })
const include = ref([])
const exclude = ref([])
@@ -47,7 +106,9 @@ export function useFilter({
if (suchReiter()) return SUCH_GRENZEN
const out = {}
for (const regler of REGLER) {
- const werte = pool().map((m) => m[regler.feld]).filter((v) => v != null)
+ const werte = pool()
+ .map((m) => m[regler.feld])
+ .filter((v) => v != null)
const min = werte.length ? Math.min(...werte) : 0
const max = werte.length ? Math.max(...werte) : 0
out[regler.key] = {
@@ -60,9 +121,17 @@ export function useFilter({
const ranges = ref({})
+ // Im Suche-Reiter starten zwei Regler nicht am Anschlag: unter 60
+ // Minuten sind es Kurzfilme, unter 50 Stimmen Making-ofs und Cameos.
+ // Wer das sehen will, zieht den Regler runter — bewusst.
+ const SUCH_START = { runtime: 60, vote_count: 50 }
+
function resetRanges() {
ranges.value = Object.fromEntries(
- Object.entries(grenzen.value).map(([k, v]) => [k, [v.min, v.max]])
+ Object.entries(grenzen.value).map(([k, v]) => [
+ k,
+ [suchReiter() && SUCH_START[k] != null ? SUCH_START[k] : v.min, v.max],
+ ]),
)
}
@@ -152,6 +221,7 @@ export function useFilter({
const spanne = (key) => ranges.value[key] || []
return {
sort: suchSort.value,
+ anzahl: suchAnzahl.value,
include: include.value,
exclude: exclude.value,
stimmen: spanne('vote_count'),
@@ -166,6 +236,7 @@ export function useFilter({
search,
sort,
suchSort,
+ suchAnzahl,
include,
exclude,
grenzen,
diff --git a/frontend/src/filter.js b/frontend/src/filter.js
index 8674162..59f8d72 100644
--- a/frontend/src/filter.js
+++ b/frontend/src/filter.js
@@ -11,9 +11,33 @@ function spanne(filter, name, schluessel, ziel) {
return raus
}
+// Die Filter der Leiste auf einen Treffer anwenden, den TMDB schon
+// geliefert hat — ein Regler auf Anschlag schneidet nichts weg
+export function passtZurLeiste(movie, filter) {
+ const g = filter.grenzen || {}
+ const drin = (werte, wert, key) => {
+ if (!werte || wert == null) return true
+ const grenze = g[key]
+ if (!grenze) return true
+ if (werte[0] > grenze.min && wert < werte[0]) return false
+ if (werte[1] < grenze.max && wert > werte[1]) return false
+ return true
+ }
+ const genres = movie.genres || []
+ if ((filter.include || []).length && !genres.some((x) => filter.include.includes(x))) return false
+ if ((filter.exclude || []).some((x) => genres.includes(x))) return false
+ return (
+ drin(filter.stimmen, movie.vote_count, 'vote_count') &&
+ drin(filter.dauer, movie.runtime, 'runtime') &&
+ drin(filter.jahr, movie.year, 'year') &&
+ drin(filter.note, movie.vote_average, 'vote_average')
+ )
+}
+
export function filterParameter(filter) {
return {
sort: filter.sort || 'rating',
+ ...(filter.anzahl ? { limit: filter.anzahl } : {}),
include: (filter.include || []).join(','),
exclude: (filter.exclude || []).join(','),
...spanne(filter, 'stimmen', 'vote_count', 'stimmen'),