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

View File

@@ -25,6 +25,7 @@ CREATE TABLE IF NOT EXISTS movies (
netflix_id TEXT,
netflix_url TEXT,
on_netflix INTEGER DEFAULT 0,
rentable INTEGER,
canon_group INTEGER,
canon_pos INTEGER,
collection_id INTEGER,
@@ -77,6 +78,7 @@ def init():
("on_netflix", "INTEGER DEFAULT 0"),
("canon_group", "INTEGER"),
("canon_pos", "INTEGER"),
("rentable", "INTEGER"),
):
if column not in existing:
conn.execute(f"ALTER TABLE movies ADD COLUMN {column} {kind}")

View File

@@ -18,22 +18,23 @@ from .config import LANGUAGE, MINIMAX_KEY, MINIMAX_MODEL, MINIMAX_URL
from .teaser import THINK_BLOCK
ANZAHL = 12
MAX_ANZAHL = 100
MAX_BESCHREIBUNG = 1000
SYSTEM = (
"Du bist ein belesener Filmkenner mit Schwerpunkt Horror und Genrekino. "
"Du schlägst Filme vor, die zu dem passen, was gesucht wird. "
f"Nenne genau {ANZAHL} Filme, keine Serien. Nur Filme, die es wirklich gibt. "
"Nenne genau {anzahl} Filme, keine Serien. Nur Filme, die es wirklich gibt. "
"Nichts vorschlagen, was in der Sammlung steht oder schon gesehen wurde. "
"Was in der Frage genannt ist, kennt die Person schon — auch nicht vorschlagen. "
"Mische Bekanntes mit Geheimtipps. "
"Antworte nur mit JSON in dieser Form, ohne Text davor oder danach: "
'{"merkmale": ["drei Stichpunkte: was macht das Gesuchte aus"], '
'"kandidaten": ["20 Originaltitel mit Jahr, die dazu passen könnten"], '
'"filme": [{"titel": "Originaltitel", "jahr": 1999, "grund": "..."}]}\n'
'{{"merkmale": ["drei Stichpunkte: was macht das Gesuchte aus"], '
'"kandidaten": ["{kandidaten} Originaltitel mit Jahr, die dazu passen könnten"], '
'"filme": [{{"titel": "Originaltitel", "jahr": 1999, "grund": "..."}}]}}\n'
# Der Umweg über Merkmale und Kandidaten ersetzt das Nachdenken des
# Modells: es sortiert erst, dann wählt es — und trifft deutlich besser.
"Erst merkmale, dann kandidaten, dann die zwölf besten davon als filme.\n"
"Erst merkmale, dann kandidaten, dann die {anzahl} besten davon als filme.\n"
# Die Begründung lesen Leute, die einfach einen Film für den Abend
# suchen — nicht Kritiker. Sie muss auf vier kurze Zeilen passen.
"Für grund gilt: Höchstens 10 Wörter und 90 Zeichen, Deutsch in lateinischer "
@@ -65,6 +66,12 @@ def kuerzen(grund):
return kopf[:schnitt].rstrip(",; –.") + " …"
def system(anzahl):
"""Der Systemprompt für eine gewünschte Anzahl Filme. Die Kandidaten-
liste bleibt ein Stück länger als das Ergebnis."""
return SYSTEM.format(anzahl=anzahl, kandidaten=max(20, anzahl + 8))
def etikett(film):
"""Titel (Jahr) — so kommt ein Film in den Prompt."""
jahr = film.get("year")
@@ -124,7 +131,7 @@ def prompt(beschreibung, sammlung, gesehen, filter_=None):
return "\n\n".join(teile)
def parsen(text):
def parsen(text, anzahl=ANZAHL):
"""Das JSON aus der Antwort ziehen. Das Modell hängt gern einen
Denkblock oder einen Satz davor — es zählt, was zwischen der ersten
und der letzten Klammer steht."""
@@ -152,14 +159,14 @@ def parsen(text):
"grund": kuerzen(str(f.get("grund") or "")),
}
)
return aus[:ANZAHL]
return aus[:anzahl]
async def fragen(beschreibung, sammlung, gesehen, filter_=None):
async def fragen(beschreibung, sammlung, gesehen, filter_=None, anzahl=ANZAHL):
payload = {
"model": MINIMAX_MODEL,
"messages": [
{"role": "system", "content": SYSTEM},
{"role": "system", "content": system(anzahl)},
{"role": "user", "content": prompt(beschreibung, sammlung, gesehen, filter_)},
],
# Ohne Denkblock: M3 grübelt sonst minutenlang über zwölf Filme
@@ -167,7 +174,8 @@ async def fragen(beschreibung, sammlung, gesehen, filter_=None):
# Kandidatenfelder im Prompt leisten das Sortieren stattdessen,
# in gut zehn Sekunden. Niedrige Temperatur: weniger Ausreißer.
"thinking": {"type": "disabled"},
"max_tokens": 4000,
# Rund 40 Token je Film, dazu Merkmale und Kandidaten
"max_tokens": 2000 + 60 * anzahl,
"temperature": 0.3,
}
async with httpx.AsyncClient(
@@ -178,7 +186,7 @@ async def fragen(beschreibung, sammlung, gesehen, filter_=None):
for _ in range(2):
resp = await http.post(MINIMAX_URL, json=payload)
resp.raise_for_status()
filme = parsen(resp.json()["choices"][0]["message"]["content"])
filme = parsen(resp.json()["choices"][0]["message"]["content"], anzahl)
if filme:
return filme
return []

View File

@@ -107,8 +107,10 @@ async def startup():
log.info("Leere Datenbank, starte ersten Sync im Hintergrund")
asyncio.create_task(guarded_sync())
else:
# Altbestand: gemerkte Filme, die noch keine Katalogdaten haben
# Altbestand: gemerkte Filme, die noch keine Katalogdaten haben,
# und Filme, bei denen noch nicht steht, ob man sie leihen kann
asyncio.create_task(sync.katalog_nachtragen())
asyncio.create_task(sync.leihbarkeit_nachtragen())
@app.on_event("shutdown")
@@ -157,15 +159,16 @@ def movie(movie_id: int):
@app.get("/api/search")
async def search_movies(q: str):
async def search_movies(q: str, limit: int = 24):
"""Freie TMDB-Suche für Filme außerhalb des Katalogs."""
if len(q.strip()) < 2:
return []
return await search.search(q.strip())
return await search.search(q.strip(), limit=max(1, min(search.MAX_TREFFER, limit)))
@app.get("/api/discover")
async def stoebern(
limit: int = 40,
sort: str = "rating",
include: str = "",
exclude: str = "",
@@ -192,7 +195,8 @@ async def stoebern(
"jahr_max": jahr_max,
"note_min": note_min,
"note_max": note_max,
}
},
limit=max(1, min(search.MAX_TREFFER, limit)),
)
@@ -249,6 +253,7 @@ async def ki_suche(
jahr_max: int | None = Body(None, embed=True),
note_min: float | None = Body(None, embed=True),
note_max: float | None = Body(None, embed=True),
limit: int = Body(ki.ANZAHL, embed=True),
filmabend_sid: str | None = Cookie(None),
):
"""Vorschläge von der KI, bei TMDB nachgeschlagen. Die eigene Sammlung
@@ -278,7 +283,11 @@ async def ki_suche(
gesehen = watched.alle(sid)
try:
vorschlaege = await ki.fragen(
beschreibung, sammlung, [ki.etikett(g) for g in gesehen], filter_
beschreibung,
sammlung,
[ki.etikett(g) for g in gesehen],
filter_,
max(1, min(ki.MAX_ANZAHL, limit)),
)
except (httpx.HTTPError, KeyError, IndexError):
raise HTTPException(status_code=502, detail="Die KI antwortet gerade nicht.")
@@ -286,6 +295,29 @@ async def ki_suche(
return await ki.aufloesen(vorschlaege, filter_, ausser=ausser)
@app.get("/api/movies/{movie_id}/credits")
async def film_credits(movie_id: int):
"""Regie und Besetzung, live von TMDB."""
return await search.credits(movie_id)
@app.get("/api/personen")
async def personen_suchen(q: str):
"""Schauspieler und Regisseure bei TMDB suchen."""
if len(q.strip()) < 2:
return []
return await search.personen(q.strip())
@app.get("/api/personen/{person_id}/filme")
async def personen_filme(person_id: int):
"""Was jemand gespielt hat und wo er Regie geführt hat."""
try:
return await search.person_filme(person_id)
except httpx.HTTPError:
raise HTTPException(status_code=502, detail="TMDB antwortet gerade nicht.")
@app.get("/api/search/{movie_id}")
async def search_detail(movie_id: int):
"""Volle Daten zu einem Suchtreffer. Erst der Katalog, dann TMDB."""

View File

@@ -7,6 +7,7 @@ landen nicht in der Datenbank.
import asyncio
import random
import time
import unicodedata
import httpx
@@ -16,6 +17,7 @@ from .tmdb import (
DETAIL_PARAMS,
pick_backdrops,
pick_overview,
pick_rentable,
pick_title,
pick_trailer,
)
@@ -67,7 +69,15 @@ async def details_nachladen(http, treffer, je_film=4):
Das alles steckt in der Detailansicht, ein Abruf je Film reicht.
"""
# Nicht alle auf einmal: bei einer langen Filmografie wären das
# über hundert gleichzeitige Abrufe
bremse = asyncio.Semaphore(8)
async def hole(film):
async with bremse:
await hole_einen(film)
async def hole_einen(film):
try:
resp = await http.get(
f"/movie/{film['id']}",
@@ -106,6 +116,27 @@ SORTIERUNG = {
# Weiter hinten liegen nur noch Filme, die kaum jemand kennt
MAX_SEITE = 20
# TMDB liefert zwanzig je Seite; bis hundert sind es fünf Abrufe
MAX_TREFFER = 100
JE_SEITE = 20
async def seiten(http, pfad, params, limit, start=1):
"""So viele Seiten, wie das Limit braucht — alle auf einmal. Was
fehlschlägt, fehlt eben; die Reihenfolge der Seiten bleibt."""
anzahl = -(-limit // JE_SEITE)
async def eine(nummer):
try:
resp = await http.get(pfad, params={**params, "page": nummer})
resp.raise_for_status()
return resp.json()
except httpx.HTTPError:
return {}
antworten = await asyncio.gather(*(eine(start + i) for i in range(anzahl)))
results = [r for a in antworten for r in a.get("results", [])]
return results, antworten[0] if antworten else {}
async def discover(filter_, limit=40):
@@ -149,22 +180,16 @@ async def discover(filter_, limit=40):
params["primary_release_date.lte"] = f"{filter_['jahr_max']}-12-31"
async with client() as http:
resp = await http.get("/discover/movie", params=params)
resp.raise_for_status()
daten = resp.json()
results, erste = await seiten(http, "/discover/movie", params, limit)
# Bei "Zufällig" nochmal von einer beliebigen Seite holen — sonst
# stehen bei jedem Aufruf dieselben zwanzig Filme oben
if filter_.get("sort") == "random":
seiten = min(daten.get("total_pages") or 1, MAX_SEITE)
if seiten > 1:
params["page"] = random.randint(1, seiten)
resp = await http.get("/discover/movie", params=params)
resp.raise_for_status()
daten = resp.json()
results = daten.get("results", [])
# Bei "Zufällig" nochmal von einer beliebigen Stelle holen — sonst
# stehen bei jedem Aufruf dieselben Filme oben
if filter_.get("sort") == "random":
gesamt = min(erste.get("total_pages") or 1, MAX_SEITE)
noetig = -(-limit // JE_SEITE)
if gesamt > noetig:
start = random.randint(1, gesamt - noetig + 1)
results, _ = await seiten(http, "/discover/movie", params, limit, start)
random.shuffle(results)
usable = [r for r in results if r.get("poster_path")]
@@ -187,19 +212,13 @@ async def search(query, limit=24):
begriffe = [query] if schlicht == query else [query, schlicht]
async def einmal(begriff):
try:
resp = await http.get(
"/search/movie",
params={
"query": begriff,
"language": LANGUAGE,
"include_adult": "false",
},
)
resp.raise_for_status()
return resp.json().get("results", [])
except httpx.HTTPError:
return []
results, _ = await seiten(
http,
"/search/movie",
{"query": begriff, "language": LANGUAGE, "include_adult": "false"},
limit,
)
return results
async with client() as http:
results = []
@@ -343,6 +362,114 @@ async def aehnliche(movie_id, limit=12, voll=False, filter_=None):
return treffer
# ── Personen: Schauspieler und Regisseure ────────────────────────────────
MAX_PERSONEN = 12
# Laufzeit und Szenenbilder kosten je Film einen Abruf — bei einer langen
# Filmografie ein paar Sekunden. Einmal geholt, bleibt es einen Tag im
# Speicher; so oft ändert sich eine Filmografie nicht.
PERSONEN_CACHE = {}
PERSONEN_FRIST = 24 * 3600
FACH = {
"Acting": "Schauspiel",
"Directing": "Regie",
"Writing": "Drehbuch",
"Production": "Produktion",
}
async def personen(query):
"""Leute suchen. Wer ein Foto hat, steht vorn — sonst die Bekannten."""
async with client() as http:
try:
resp = await http.get(
"/search/person",
params={"query": query, "language": LANGUAGE, "include_adult": "false"},
)
resp.raise_for_status()
roh = resp.json().get("results", [])
except httpx.HTTPError:
return []
roh.sort(key=lambda p: (not p.get("profile_path"), -(p.get("popularity") or 0)))
return [
{
"id": p["id"],
"name": p.get("name"),
"fach": FACH.get(p.get("known_for_department"), p.get("known_for_department") or ""),
"profile_path": p.get("profile_path"),
"bekannt_fuer": [
k.get("title") or k.get("name")
for k in p.get("known_for", [])
if k.get("title") or k.get("name")
][:3],
}
for p in roh[:MAX_PERSONEN]
]
def credits_aufbereiten(daten):
"""Aus den movie_credits zwei Listen: was jemand gespielt und wo er
Regie geführt hat. Ohne Poster oder doppelt fliegt raus, die
bekanntesten Filme stehen vorn. Kurzfilme und Making-ofs bleiben
drin — die sortiert die Filterleiste aus, und zwar sichtbar."""
def liste(eintraege):
raus, dabei = [], set()
for e in sorted(eintraege, key=lambda e: -(e.get("popularity") or 0)):
if not e.get("poster_path") or e["id"] in dabei:
continue
dabei.add(e["id"])
raus.append(as_result(e))
return raus
return {
"gespielt": liste(daten.get("cast", [])),
"regie": liste([c for c in daten.get("crew", []) if c.get("job") == "Director"]),
}
# Mehr Namen passen nicht in die Filmansicht, und ab da sind es Nebenrollen
MAX_CAST = 8
async def credits(movie_id):
"""Regie und die wichtigsten Darsteller — als Sprungbrett zur
Personensuche. Fehlt etwas, bleiben die Listen leer."""
async with client() as http:
try:
# Ohne Sprache: auf Deutsch kämen russische Namen in Kyrillisch
# und eingedeutschte Umschriften wie "Ilja Wiktorowitsch"
resp = await http.get(f"/movie/{movie_id}/credits", params={"language": "en-US"})
resp.raise_for_status()
daten = resp.json()
except httpx.HTTPError:
return {"regie": [], "cast": []}
def person(p, fach):
return {"id": p["id"], "name": p.get("name"), "fach": fach, "profile_path": p.get("profile_path")}
regie, dabei = [], set()
for c in daten.get("crew", []):
if c.get("job") == "Director" and c["id"] not in dabei:
dabei.add(c["id"])
regie.append(person(c, "Regie"))
cast = sorted(daten.get("cast", []), key=lambda c: c.get("order", 999))[:MAX_CAST]
return {"regie": regie, "cast": [person(c, "Schauspiel") for c in cast]}
async def person_filme(person_id):
gemerkt = PERSONEN_CACHE.get(person_id)
if gemerkt and time.monotonic() - gemerkt[0] < PERSONEN_FRIST:
return gemerkt[1]
async with client() as http:
resp = await http.get(f"/person/{person_id}/movie_credits", params={"language": LANGUAGE})
resp.raise_for_status()
rollen = credits_aufbereiten(resp.json())
await details_nachladen(http, rollen["gespielt"] + rollen["regie"])
PERSONEN_CACHE[person_id] = (time.monotonic(), rollen)
return rollen
async def titel_richten(http, film):
"""Originalschrift durch den englischen Titel ersetzen."""
if not any(
@@ -387,6 +514,7 @@ async def detail(movie_id):
"images": pick_backdrops(data)[:12],
"trailer_key": pick_trailer(data),
"imdb_id": data.get("imdb_id"),
"rentable": pick_rentable(data),
"netflix_id": None,
"netflix_url": None,
"on_netflix": 0,

View File

@@ -36,6 +36,7 @@ def build_movie(data):
"images": tmdb.pick_backdrops(data),
"trailer_key": tmdb.pick_trailer(data),
"imdb_id": data.get("imdb_id"),
"rentable": tmdb.pick_rentable(data),
# Die echte Titel-ID trägt fill_netflix_links nach
"netflix_id": None,
"netflix_url": tmdb.pick_watch_link(data)
@@ -178,6 +179,37 @@ async def katalog_nachtragen():
return len(filme)
async def leihbarkeit_nachtragen():
"""Filme, bei denen noch nicht steht, ob man sie leihen kann — der
Altbestand von vor dieser Spalte. Der tägliche Sync hält es danach
aktuell, hier geht es nur um das erste Mal."""
with db.connect() as conn:
offen = [r["id"] for r in conn.execute("SELECT id FROM movies WHERE rentable IS NULL")]
if not offen:
return 0
bremse = asyncio.Semaphore(TMDB_CONCURRENCY)
async def hole(http, movie_id):
async with bremse:
try:
resp = await http.get(f"/movie/{movie_id}/watch/providers")
resp.raise_for_status()
return movie_id, tmdb.pick_rentable({"watch/providers": resp.json()})
except Exception:
return movie_id, None
async with tmdb.client() as http:
stand = await asyncio.gather(*(hole(http, i) for i in offen))
with db.connect() as conn:
conn.executemany(
"UPDATE movies SET rentable = ? WHERE id = ?",
[(wert, i) for i, wert in stand if wert is not None],
)
log.info("Leihbarkeit für %s Filme nachgetragen", sum(1 for _, w in stand if w is not None))
return len(offen)
async def run():
log.info("Sync startet")
canon_places = await resolve_canon()

View File

@@ -135,6 +135,15 @@ def pick_watch_link(data):
return (providers.get(REGION) or {}).get("link")
def pick_rentable(data):
"""Ob man den Film in Deutschland leihen kann — laut JustWatch, über
TMDB. None, wenn gar keine Anbieterdaten mitkamen."""
results = data.get("watch/providers", {}).get("results")
if results is None:
return None
return 1 if (results.get(REGION) or {}).get("rent") else 0
def pick_trailer(data):
"""Trailer bevorzugt, sonst Teaser. Sprache egal."""
videos = [

Binary file not shown.

View File

@@ -62,6 +62,9 @@ def test_vorgaben_aus_der_leiste():
assert ki.vorgaben({"jahr_min": 1980, "jahr_max": 1989}) == ["Zwischen 1980 und 1989"]
assert ki.vorgaben({}) == []
assert "unbedingt einhalten" in ki.prompt("x", [], [], {"include": ["Horror"]})
assert "Nenne genau 30 Filme" in ki.system(30)
assert "38 Originaltitel" in ki.system(30)
assert '{"merkmale"' in ki.system(12)
def test_titel_muss_zum_treffer_passen():
@@ -77,9 +80,9 @@ def test_titel_muss_zum_treffer_passen():
def test_route_fragt_und_loest_auf(client, film, monkeypatch):
aufrufe = {}
async def fragen(beschreibung, sammlung, gesehen, filter_):
async def fragen(beschreibung, sammlung, gesehen, filter_, anzahl):
aufrufe.update(
beschreibung=beschreibung, sammlung=sammlung, gesehen=gesehen, filter=filter_
beschreibung=beschreibung, sammlung=sammlung, gesehen=gesehen, filter=filter_, anzahl=anzahl
)
return [{"titel": "The Witch", "jahr": 2015, "grund": "Passt."}]
@@ -108,6 +111,9 @@ def test_route_fragt_und_loest_auf(client, film, monkeypatch):
assert aufrufe["filter"]["include"] == ["Horror", "Mystery"]
assert aufrufe["filter"]["jahr_min"] == 2010
assert aufrufe["filter"]["note_max"] is None
assert aufrufe["anzahl"] == 12
client.post("/api/ki-suche", json={"beschreibung": "x", "limit": 40})
assert aufrufe["anzahl"] == 40
# Gesehene dürfen auch drinbleiben — dann zeigt die Karte es an
client.post("/api/ki-suche", json={"beschreibung": "x", "ohne_gesehene": False})
@@ -115,7 +121,7 @@ def test_route_fragt_und_loest_auf(client, film, monkeypatch):
def test_nur_filter_reichen_als_frage(client, monkeypatch):
async def fragen(beschreibung, sammlung, gesehen, filter_):
async def fragen(beschreibung, sammlung, gesehen, filter_, anzahl):
return []
async def aufloesen(vorschlaege, filter_, ausser=()):

View File

@@ -0,0 +1,20 @@
"""Ob ein Film leihbar ist, kommt aus den Anbieterdaten von TMDB."""
from app import db, tmdb
def test_leihbar_aus_anbieterdaten():
de = lambda eintrag: {"watch/providers": {"results": {"DE": eintrag}}}
assert tmdb.pick_rentable(de({"rent": [{"provider_name": "Amazon Video"}]})) == 1
assert tmdb.pick_rentable(de({"flatrate": [{"provider_name": "Netflix"}]})) == 0
assert tmdb.pick_rentable({"watch/providers": {"results": {}}}) == 0
# Ohne Anbieterdaten bleibt es offen — nicht fälschlich "nicht leihbar"
assert tmdb.pick_rentable({}) is None
def test_leihbarkeit_kommt_mit_dem_film(client):
with db.connect() as conn:
db.upsert(conn, {"id": 7, "title": "Abo-Film", "rentable": 0})
db.upsert(conn, {"id": 8, "title": "Unbekannt"})
assert client.get("/api/movies/7").json()["rentable"] == 0
assert client.get("/api/movies/8").json()["rentable"] is None

View File

@@ -0,0 +1,58 @@
"""Personensuche: Schauspieler und Regisseure, dazu ihre Filme."""
from app import main, search
def test_credits_werden_nach_rolle_getrennt():
daten = {
"cast": [
{"id": 1, "title": "Klein", "poster_path": "/a.jpg", "popularity": 2, "genre_ids": [27], "vote_count": 80},
{"id": 2, "title": "Groß", "poster_path": "/b.jpg", "popularity": 9, "release_date": "2001-05-01", "vote_count": 900},
{"id": 3, "title": "Ohne Poster", "poster_path": None, "popularity": 50, "vote_count": 500},
{"id": 2, "title": "Groß nochmal", "poster_path": "/b.jpg", "popularity": 9, "vote_count": 900},
{"id": 6, "title": "Cameo in Doku", "poster_path": "/e.jpg", "popularity": 99, "vote_count": 3},
],
"crew": [
{"id": 4, "title": "Regiearbeit", "job": "Director", "poster_path": "/c.jpg", "popularity": 1, "vote_count": 60},
{"id": 5, "title": "Nur Drehbuch", "job": "Writer", "poster_path": "/d.jpg", "popularity": 1, "vote_count": 60},
],
}
rollen = search.credits_aufbereiten(daten)
# Auch der Cameo bleibt — was raus soll, entscheidet die Filterleiste
assert [f["title"] for f in rollen["gespielt"]] == ["Cameo in Doku", "Groß", "Klein"]
assert rollen["gespielt"][1]["year"] == 2001
assert rollen["gespielt"][2]["genres"] == ["Horror"]
assert [f["title"] for f in rollen["regie"]] == ["Regiearbeit"]
def test_filmografie_kommt_aus_dem_speicher(client):
import asyncio
import time
search.PERSONEN_CACHE[99] = (time.monotonic(), {"gespielt": [{"id": 1}], "regie": []})
# Ohne Netz — käme der Abruf durch, flöge hier ein Fehler
assert asyncio.run(search.person_filme(99))["gespielt"] == [{"id": 1}]
assert client.get("/api/personen/99/filme").json()["gespielt"] == [{"id": 1}]
del search.PERSONEN_CACHE[99]
def test_credits_route(client, monkeypatch):
async def credits(movie_id):
return {"regie": [{"id": 1, "name": "R", "fach": "Regie", "profile_path": None}], "cast": []}
monkeypatch.setattr(main.search, "credits", credits)
assert client.get("/api/movies/5/credits").json()["regie"][0]["fach"] == "Regie"
def test_routen_reichen_durch(client, monkeypatch):
async def personen(query):
return [{"id": 7, "name": query, "fach": "Regie", "profile_path": None, "bekannt_fuer": []}]
async def person_filme(person_id):
return {"gespielt": [], "regie": [{"id": person_id, "title": "x"}]}
monkeypatch.setattr(main.search, "personen", personen)
monkeypatch.setattr(main.search, "person_filme", person_filme)
assert client.get("/api/personen?q=D").json() == []
assert client.get("/api/personen?q=Duvivier").json()[0]["name"] == "Duvivier"
assert client.get("/api/personen/7/filme").json()["regie"][0]["id"] == 7

View File

@@ -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
</button>
<span v-if="host" class="host">Host</span>
<NamensWahl
:items="runde"
:mine="ich"
:host="host"
:anmelden="michSetzen"
/>
<NamensWahl :items="runde" :mine="ich" :host="host" :anmelden="michSetzen" />
</div>
</header>
@@ -280,6 +324,17 @@ onMounted(load)
@gestartet="aehnlichStart = null"
/>
<PersonenTab
v-else-if="suchArt === 'personen'"
:gesehen-ids="gesehenIds"
:ohne-gesehene="ohneGesehene"
:filter="suchFilter"
:ansicht="ansicht"
:start="personStart"
@open="selected = $event"
@gestartet="personStart = null"
/>
<KiTab
v-else
:angemeldet="angemeldet"
@@ -294,6 +349,8 @@ onMounted(load)
<!-- Die KI ordnet selbst nach Passung, da gibt es nichts zu sortieren -->
<FilterSidebar
v-model:sort="suchSort"
v-model:anzahl="suchAnzahl"
:mit-anzahl="suchArt !== 'personen'"
v-model:ansicht="ansicht"
v-model:ohne-gesehene="ohneGesehene"
v-model:ranges="ranges"
@@ -363,12 +420,7 @@ onMounted(load)
@changed="ladeWuensche"
/>
<VerwaltungTab
v-else-if="activeTab === 'verwaltung'"
:items="runde"
:host="host"
:ich="ich"
/>
<VerwaltungTab v-else-if="activeTab === 'verwaltung'" :items="runde" :host="host" :ich="ich" />
<StreamTab v-else-if="activeTab === 'stream'" />
@@ -459,6 +511,7 @@ onMounted(load)
@suggested="nachVorschlag"
@watched="ladeGesehen"
@aehnlich="zurAehnlichkeitsuche"
@person="zurPersonensuche"
/>
</div>
</template>
@@ -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 {

View File

@@ -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
/>
<button
v-if="query"
type="button"
class="leeren"
title="Suche leeren"
@click="query = ''"
>
<button v-if="query" type="button" class="leeren" title="Suche leeren" @click="query = ''">
×
</button>
</div>
@@ -196,12 +185,7 @@ async function oeffnen(movie) {
</p>
<div v-if="!laden" class="grid" :class="ansicht">
<MovieCard
:movie="gewaehlt"
:ansicht="ansicht"
class="ausgangsfilm"
@open="oeffnen"
/>
<MovieCard :movie="gewaehlt" :ansicht="ansicht" class="ausgangsfilm" @open="oeffnen" />
<MovieCard
v-for="film in treffer.filter(passt)"
:key="film.id"
@@ -277,10 +261,7 @@ async function oeffnen(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 */

View File

@@ -16,6 +16,8 @@ const props = defineProps({
ohneSort: Boolean,
// Der Katalog hat zwei Listen zur Wahl: [{ id, label, count }]
quellen: { type: Array, default: () => [] },
// 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 || '')
}
}
</script>
<template>
@@ -69,12 +74,7 @@ function beschriften(regler) {
</button>
</div>
<input
v-if="!ohneSuche"
v-model="search"
type="search"
placeholder="Titel suchen…"
/>
<input v-if="!ohneSuche" v-model="search" type="search" placeholder="Titel suchen…" />
<select v-if="!ohneSort" v-model="sort">
<option v-if="mitRelevanz" value="relevanz">Wie auf IMDb</option>
<option value="random">Zufällig</option>
@@ -84,19 +84,16 @@ function beschriften(regler) {
<option value="title">Titel A–Z</option>
</select>
<label v-if="mitAnzahl" class="anzahl">
<span>Ergebnisse <b>{{ anzahl }}</b></span>
<input v-model.number="anzahl" type="range" min="1" max="100" />
</label>
<div class="ansicht">
<button
type="button"
:class="{ an: ansicht === 'kompakt' }"
@click="ansicht = 'kompakt'"
>
<button type="button" :class="{ an: ansicht === 'kompakt' }" @click="ansicht = 'kompakt'">
Kompakt
</button>
<button
type="button"
:class="{ an: ansicht === 'details' }"
@click="ansicht = 'details'"
>
<button type="button" :class="{ an: ansicht === 'details' }" @click="ansicht = 'details'">
Details
</button>
</div>
@@ -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;

View File

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

View File

@@ -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(() => {
<header>
<img v-if="poster" class="poster" :src="poster" :alt="movie.title" />
<div class="head-text">
<h2>{{ movie.title }}</h2>
<h2>
{{ movie.title }}
<!-- Läuft nur im Abo: dann bleibt fürs Leihen nichts übrig -->
<span v-if="movie.rentable != null && !movie.rentable" class="nicht-leihbar">
Nicht leihbar
</span>
</h2>
<p v-if="movie.original_title !== movie.title" class="original">
{{ movie.original_title }}
</p>
@@ -188,6 +208,22 @@ onUnmounted(() => {
</span>
</p>
<p class="genres">{{ (movie.genres || []).join(' · ') }}</p>
<p v-for="[titel, liste] in [['Regie', leute.regie], ['Mit', leute.cast]]" :key="titel">
<template v-if="liste.length">
<span class="leute-titel">{{ titel }}</span>
<button
v-for="p in liste"
:key="p.id"
type="button"
class="person"
:title="`Filme mit ${p.name}`"
@click="$emit('person', p)"
>
{{ p.name }}
</button>
</template>
</p>
<p v-if="movie.teaser" class="teaser">{{ movie.teaser }}</p>
<div class="links">
<a
@@ -208,6 +244,16 @@ onUnmounted(() => {
>
IMDb
</a>
<!-- TMDBs Anbieterliste für Deutschland: wo man leihen, kaufen
oder streamen kann. IMDb zeigt davon nur Amazon. -->
<a
class="tmdb"
:href="`https://www.themoviedb.org/movie/${movie.id}/watch?locale=DE`"
target="_blank"
rel="noopener"
>
TMDB
</a>
</div>
<div class="vorschlag">
@@ -244,12 +290,8 @@ onUnmounted(() => {
</p>
</div>
<div v-if="host || gesehen" class="gesehen">
<span v-if="gesehen || merken.zustand === 'fertig'" class="ok">
Steht unter „Gesehen“.
</span>
<div v-if="host && !gesehen && merken.zustand !== 'fertig'" class="gesehen">
<button
v-else-if="host"
type="button"
:disabled="merken.zustand === 'laeuft'"
@click="alsGesehen"
@@ -485,6 +527,26 @@ h2 {
font-size: 0.82rem;
}
.leute-titel {
margin-right: 0.35rem;
color: var(--muted);
font-size: 0.78rem;
}
/* Namen als kleine Pillen, ein Klick führt zur Personensuche */
.person {
margin: 0 0.3rem 0.3rem 0;
padding: 0.15rem 0.55rem;
color: var(--text);
font-size: 0.78rem;
border: 1px solid var(--line);
border-radius: 20px;
}
.person:hover {
border-color: var(--accent);
}
.teaser {
color: #cfcfda;
font-size: 0.98rem;
@@ -498,7 +560,8 @@ h2 {
}
.netflix,
.imdb {
.imdb,
.tmdb {
padding: 0.55rem 1.1rem;
font-size: 0.9rem;
font-weight: 600;
@@ -510,11 +573,29 @@ h2 {
background: var(--accent);
}
.tmdb {
color: #0d253f;
background: #01b4e4;
}
.imdb {
color: #111;
background: #f5c518;
}
.nicht-leihbar {
display: inline-block;
margin-left: 0.5rem;
padding: 0.15rem 0.45rem;
color: #fff;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.03em;
vertical-align: middle;
background: var(--accent);
border-radius: 4px;
}
/* Der Textblock richtet seine Kinder linksbündig aus, dadurch war der
Trennstrich nur so breit wie die Knöpfe darunter */
.vorschlag {
@@ -609,7 +690,8 @@ h2 {
}
.netflix:hover,
.imdb:hover {
.imdb:hover,
.tmdb:hover {
filter: brightness(1.15);
}
@@ -749,7 +831,8 @@ h3 {
.gesehen button,
.merken,
.netflix,
.imdb {
.imdb,
.tmdb {
padding: 0.65rem 1rem;
}

View File

@@ -0,0 +1,360 @@
<script setup>
// 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())
}
</script>
<template>
<div class="personen-tab">
<div class="suchzeile">
<input
v-model="query"
type="search"
class="big-search"
placeholder="Wen suchst du?"
autofocus
/>
<button v-if="query" type="button" class="leeren" title="Suche leeren" @click="query = ''">
×
</button>
</div>
<!-- Tippt jemand, stehen die Leute vorn, bis einer gewählt ist -->
<template v-if="query.trim().length >= 2">
<p v-if="!personen.length && !personenLaden" class="note">Nichts gefunden.</p>
<ul v-else class="leute">
<li v-for="p in personen" :key="p.id">
<button type="button" @click="waehlen(p)">
<img v-if="p.profile_path" :src="posterUrl(p.profile_path, 'w185')" :alt="p.name" />
<span v-else class="kein-bild"></span>
<span class="text">
<strong>{{ p.name }}</strong>
<span class="fach">{{ p.fach }}</span>
<span v-if="p.bekannt_fuer.length" class="bekannt">
{{ p.bekannt_fuer.join(' · ') }}
</span>
</span>
</button>
</li>
</ul>
</template>
<template v-else-if="gewaehlt">
<div class="kopf">
<img
v-if="gewaehlt.profile_path"
:src="posterUrl(gewaehlt.profile_path, 'w185')"
:alt="gewaehlt.name"
/>
<div>
<h3>{{ gewaehlt.name }}</h3>
<p class="fach">{{ gewaehlt.fach }}</p>
</div>
</div>
<p v-if="laden" class="note">Suche läuft…</p>
<p v-else-if="fehler" class="note error">{{ fehler }}</p>
<p v-else-if="!abschnitte.length" class="note">Nichts gefunden. Stell die Filter weiter.</p>
<section v-for="[titel, liste] in abschnitte" :key="titel">
<p class="label">
{{ titel }}
<span class="anzahl">{{ liste.length }}</span>
</p>
<div class="grid" :class="ansicht">
<MovieCard
v-for="film in liste"
:key="film.id"
:movie="film"
:ansicht="ansicht"
:gesehen="gesehenIds.has(film.id)"
@open="oeffnen"
/>
</div>
</section>
</template>
</div>
</template>
<style scoped>
.personen-tab {
min-width: 0;
}
.suchzeile {
position: relative;
margin-bottom: 1.5rem;
}
.big-search {
width: 100%;
padding: 0.9rem 2.6rem 0.9rem 1.1rem;
font-size: 1.05rem;
}
.leeren {
position: absolute;
top: 50%;
right: 0.9rem;
color: var(--muted);
font-size: 1.4rem;
line-height: 1;
transform: translateY(-50%);
}
.leeren:hover {
color: var(--text);
}
/* Die Trefferliste: Foto, Name, Fach und wofür man die Person kennt */
.leute {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 0.7rem;
list-style: none;
}
.leute button {
display: flex;
gap: 0.8rem;
align-items: center;
width: 100%;
padding: 0.6rem;
text-align: left;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 10px;
}
.leute button:hover {
border-color: var(--accent);
}
.leute img,
.kein-bild {
flex-shrink: 0;
width: 56px;
height: 84px;
object-fit: cover;
background: #1c1c22;
border-radius: 6px;
}
.text {
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.15rem;
}
.fach,
.bekannt {
color: var(--muted);
font-size: 0.8rem;
}
.bekannt {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kopf {
display: flex;
gap: 1rem;
align-items: center;
margin-bottom: 1.5rem;
}
.kopf img {
width: 64px;
height: 96px;
object-fit: cover;
border-radius: 8px;
}
.kopf h3 {
font-size: 1.3rem;
}
section {
margin-bottom: 2rem;
}
.label {
display: flex;
gap: 0.45rem;
align-items: center;
margin-bottom: 1.1rem;
color: var(--muted);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.anzahl {
padding: 0.1rem 0.45rem;
letter-spacing: 0;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 20px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 1.1rem;
}
.grid.details {
grid-template-columns: repeat(auto-fill, min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem)));
}
.note {
padding: 2rem 0;
color: var(--muted);
font-size: 0.95rem;
text-align: center;
}
.note.error {
color: var(--accent);
}
@media (max-width: 640px) {
.grid {
grid-template-columns: repeat(auto-fill, minmax(105px, 1fr));
gap: 0.6rem;
}
.grid.details {
grid-template-columns: 1fr;
}
.leute {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -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) {
<button v-if="query" type="button" class="leeren" title="Suche leeren" @click="leeren">
×
</button>
<button type="button" class="neu" title="Neu suchen" @click="jetztSuchen">
Suchen
</button>
<button type="button" class="neu" title="Neu suchen" @click="jetztSuchen">Suchen</button>
</div>
<p v-if="loading" class="note">Suche läuft…</p>
<p v-else-if="error" class="note error">{{ error }}</p>
<p v-else-if="!results.length" class="note">
{{ gestoebert ? 'Nichts gefunden. Stell die Filter weiter.' : `Nichts gefunden für „${query}“.` }}
{{
gestoebert ? 'Nichts gefunden. Stell die Filter weiter.' : `Nichts gefunden für „${query}“.`
}}
</p>
<div class="grid" :class="ansicht">
@@ -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 */

View File

@@ -1,5 +1,5 @@
<script setup>
import { computed, reactive, ref } from "vue";
import { computed, reactive, ref } from 'vue'
const props = defineProps({
items: { type: Array, default: () => [] },
@@ -7,111 +7,111 @@ const props = defineProps({
host: Boolean,
// Ohne gewählten Namen bleibt der Reiter zum Mitlesen da
angemeldet: Boolean,
});
})
const emit = defineEmits(["changed"]);
const emit = defineEmits(['changed'])
const entwurf = ref("");
const entwurf = ref('')
// Der Host darf jeden Wunsch anfassen, alle anderen nur die eigenen
const bearbeitet = ref(null);
const bearbeitung = ref("");
const bearbeitet = ref(null)
const bearbeitung = ref('')
function darfAendern(item) {
return props.host || item.mine;
return props.host || item.mine
}
function bearbeiten(item) {
bearbeitet.value = item.id;
bearbeitung.value = item.text;
bearbeitet.value = item.id
bearbeitung.value = item.text
}
async function speichern() {
const text = bearbeitung.value.trim();
const text = bearbeitung.value.trim()
if (text) {
await fetch(`/api/features/${bearbeitet.value}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
emit("changed");
})
emit('changed')
}
bearbeitet.value = null;
bearbeitet.value = null
}
// Die Liste kommt vom Server schon sortiert: offene nach Stimmen, dann
// die erledigten. Hier nur noch in zwei Abschnitte teilen.
const offene = computed(() => props.items.filter((w) => !w.done_at));
const erledigte = computed(() => props.items.filter((w) => w.done_at));
const offene = computed(() => props.items.filter((w) => !w.done_at))
const erledigte = computed(() => props.items.filter((w) => w.done_at))
async function anlegen() {
if (!entwurf.value.trim()) return;
await fetch("/api/features", {
method: "POST",
headers: { "Content-Type": "application/json" },
if (!entwurf.value.trim()) return
await fetch('/api/features', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: entwurf.value }),
});
entwurf.value = "";
emit("changed");
})
entwurf.value = ''
emit('changed')
}
async function loeschen(item) {
await fetch(`/api/features/${item.id}`, { method: "DELETE" });
emit("changed");
await fetch(`/api/features/${item.id}`, { method: 'DELETE' })
emit('changed')
}
async function abstimmen(item) {
await fetch(`/api/features/${item.id}/vote`, { method: "POST" });
emit("changed");
await fetch(`/api/features/${item.id}/vote`, { method: 'POST' })
emit('changed')
}
async function erledigen(item, done) {
await fetch(`/api/features/${item.id}/done`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ done }),
});
emit("changed");
})
emit('changed')
}
// Ergänzungen: aufgeklappt je Wunsch, mit eigenem Entwurf
const aufgeklappt = reactive(new Set());
const notizEntwurf = reactive({});
const aufgeklappt = reactive(new Set())
const notizEntwurf = reactive({})
function umschalten(id) {
if (aufgeklappt.has(id)) aufgeklappt.delete(id);
else aufgeklappt.add(id);
if (aufgeklappt.has(id)) aufgeklappt.delete(id)
else aufgeklappt.add(id)
}
function ergaenzungsLabel(item) {
const n = item.notes.length;
if (!n) return "Ergänzen";
return n === 1 ? "1 Ergänzung" : `${n} Ergänzungen`;
const n = item.notes.length
if (!n) return 'Ergänzen'
return n === 1 ? '1 Ergänzung' : `${n} Ergänzungen`
}
async function anmerken(item) {
const text = (notizEntwurf[item.id] || "").trim();
if (!text) return;
const text = (notizEntwurf[item.id] || '').trim()
if (!text) return
await fetch(`/api/features/${item.id}/notes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
notizEntwurf[item.id] = "";
emit("changed");
})
notizEntwurf[item.id] = ''
emit('changed')
}
async function notizLoeschen(notiz) {
await fetch(`/api/feature-notes/${notiz.id}`, { method: "DELETE" });
emit("changed");
await fetch(`/api/feature-notes/${notiz.id}`, { method: 'DELETE' })
emit('changed')
}
function datum(iso) {
return new Date(iso).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
return new Date(iso).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
</script>
@@ -129,9 +129,7 @@ function datum(iso) {
placeholder="Was fehlt dir auf der Seite?"
@keydown.ctrl.enter="anlegen"
></textarea>
<button type="button" :disabled="!entwurf.trim()" @click="anlegen">
Eintragen
</button>
<button type="button" :disabled="!entwurf.trim()" @click="anlegen">Eintragen</button>
</div>
<section
@@ -172,14 +170,8 @@ function datum(iso) {
@keydown.esc="bearbeitet = null"
></textarea>
<div class="zeile">
<button type="button" class="klein" @click="speichern">
Speichern
</button>
<button
type="button"
class="klein"
@click="bearbeitet = null"
>
<button type="button" class="klein" @click="speichern">Speichern</button>
<button type="button" class="klein" @click="bearbeitet = null">
Abbrechen
</button>
</div>
@@ -188,9 +180,7 @@ function datum(iso) {
<template v-else>
<p class="text">{{ w.text }}</p>
<p class="meta">
<span :class="{ eigen: w.mine }">{{
w.name || "Anonym"
}}</span>
<span :class="{ eigen: w.mine }">{{ w.name || 'Anonym' }}</span>
<span class="punkt">·</span>{{ datum(w.created_at) }}
<template v-if="w.done_at">
<span class="punkt">·</span>erledigt
@@ -210,9 +200,7 @@ function datum(iso) {
<div v-if="aufgeklappt.has(w.id)" class="notizen">
<p v-for="n in w.notes" :key="n.id" class="notiz">
<span class="wer" :class="{ eigen: n.mine }">{{
n.name || "Anonym"
}}</span>
<span class="wer" :class="{ eigen: n.mine }">{{ n.name || 'Anonym' }}</span>
<span class="notiz-text">{{ n.text }}</span>
<button
v-if="host"
@@ -250,12 +238,10 @@ function datum(iso) {
v-if="host"
type="button"
class="weg"
:title="
w.done_at ? 'Wieder öffnen' : 'Als erledigt markieren'
"
:title="w.done_at ? 'Wieder öffnen' : 'Als erledigt markieren'"
@click="erledigen(w, !w.done_at)"
>
{{ w.done_at ? "↺" : "✓" }}
{{ w.done_at ? '↺' : '✓' }}
</button>
<button
v-if="darfAendern(w) && bearbeitet !== w.id"
@@ -288,8 +274,8 @@ function datum(iso) {
.spalten {
display: grid;
grid-template-areas:
"neu erledigt"
"offen erledigt";
'neu erledigt'
'offen erledigt';
grid-template-columns: 1fr 1fr;
grid-template-rows: auto 1fr;
gap: 0 2.5rem;
@@ -558,9 +544,9 @@ h3 {
@media (max-width: 900px) {
.spalten {
grid-template-areas:
"neu"
"offen"
"erledigt";
'neu'
'offen'
'erledigt';
grid-template-columns: 1fr;
}
}

View File

@@ -35,7 +35,7 @@ const TAB_IDS = [
// Netflix und Kanon waren einmal zwei Reiter — heute eine Quelle im Katalog
const QUELLEN = ['netflix', 'canon']
// Ebenso die drei Suchen: heute ein Reiter mit Umschalter darüber
const SUCHARTEN = ['search', 'aehnlich', 'ki']
const SUCHARTEN = ['search', 'aehnlich', 'personen', 'ki']
function gemerkt(schluessel, standard) {
const wert = ref(localStorage.getItem(schluessel) ?? standard)

View File

@@ -5,16 +5,34 @@ import { computed, ref, watch } from 'vue'
// Die Skalen richten sich nach dem ungefilterten Bestand des Reiters —
// sonst wandern die Enden bei jeder Auswahl
export const REGLER = [
{ key: 'vote_count', label: 'Bewertungen', feld: 'vote_count', step: 1, gestaucht: true },
{ key: 'runtime', label: 'Dauer', feld: 'runtime', step: 1, einheit: ' Min.' },
{
key: 'vote_count',
label: 'Bewertungen',
feld: 'vote_count',
step: 1,
gestaucht: true,
},
{
key: 'runtime',
label: 'Dauer',
feld: 'runtime',
step: 1,
einheit: ' Min.',
},
{ key: 'year', label: 'Jahr', feld: 'year', step: 1, roh: true },
{ key: 'vote_average', label: 'Bewertung', feld: 'vote_average', step: 0.1, nachkomma: 1 },
{
key: 'vote_average',
label: 'Bewertung',
feld: 'vote_average',
step: 0.1,
nachkomma: 1,
},
]
// Bei der Suche steht kein Katalog dahinter, aus dem sich Grenzen ableiten
// ließen — TMDB reicht von den Anfängen bis heute
const SUCH_GRENZEN = {
vote_count: { min: 50, max: 50000 },
vote_count: { min: 0, max: 50000 },
runtime: { min: 0, max: 240 },
year: { min: 1900, max: new Date().getFullYear() + 1 },
vote_average: { min: 0, max: 10 },
@@ -22,21 +40,62 @@ const SUCH_GRENZEN = {
// Die 19 Genres, die TMDB kennt
const SUCH_GENRES = [
'Abenteuer', 'Action', 'Animation', 'Dokumentarfilm', 'Drama', 'Familie',
'Fantasy', 'Historie', 'Horror', 'Komödie', 'Krimi', 'Kriegsfilm',
'Liebesfilm', 'Musik', 'Mystery', 'Science Fiction', 'TV-Film', 'Thriller',
'Abenteuer',
'Action',
'Animation',
'Dokumentarfilm',
'Drama',
'Familie',
'Fantasy',
'Historie',
'Horror',
'Komödie',
'Krimi',
'Kriegsfilm',
'Liebesfilm',
'Musik',
'Mystery',
'Science Fiction',
'TV-Film',
'Thriller',
'Western',
].map((genre) => ({ 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,

View File

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