update
This commit is contained in:
@@ -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}")
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
@@ -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=()):
|
||||
|
||||
20
backend/tests/test_leihbar.py
Normal file
20
backend/tests/test_leihbar.py
Normal 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
|
||||
58
backend/tests/test_personen.py
Normal file
58
backend/tests/test_personen.py
Normal 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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
360
frontend/src/components/PersonenTab.vue
Normal file
360
frontend/src/components/PersonenTab.vue
Normal 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>
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user