update
This commit is contained in:
@@ -25,6 +25,7 @@ CREATE TABLE IF NOT EXISTS movies (
|
|||||||
netflix_id TEXT,
|
netflix_id TEXT,
|
||||||
netflix_url TEXT,
|
netflix_url TEXT,
|
||||||
on_netflix INTEGER DEFAULT 0,
|
on_netflix INTEGER DEFAULT 0,
|
||||||
|
rentable INTEGER,
|
||||||
canon_group INTEGER,
|
canon_group INTEGER,
|
||||||
canon_pos INTEGER,
|
canon_pos INTEGER,
|
||||||
collection_id INTEGER,
|
collection_id INTEGER,
|
||||||
@@ -77,6 +78,7 @@ def init():
|
|||||||
("on_netflix", "INTEGER DEFAULT 0"),
|
("on_netflix", "INTEGER DEFAULT 0"),
|
||||||
("canon_group", "INTEGER"),
|
("canon_group", "INTEGER"),
|
||||||
("canon_pos", "INTEGER"),
|
("canon_pos", "INTEGER"),
|
||||||
|
("rentable", "INTEGER"),
|
||||||
):
|
):
|
||||||
if column not in existing:
|
if column not in existing:
|
||||||
conn.execute(f"ALTER TABLE movies ADD COLUMN {column} {kind}")
|
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
|
from .teaser import THINK_BLOCK
|
||||||
|
|
||||||
ANZAHL = 12
|
ANZAHL = 12
|
||||||
|
MAX_ANZAHL = 100
|
||||||
MAX_BESCHREIBUNG = 1000
|
MAX_BESCHREIBUNG = 1000
|
||||||
|
|
||||||
SYSTEM = (
|
SYSTEM = (
|
||||||
"Du bist ein belesener Filmkenner mit Schwerpunkt Horror und Genrekino. "
|
"Du bist ein belesener Filmkenner mit Schwerpunkt Horror und Genrekino. "
|
||||||
"Du schlägst Filme vor, die zu dem passen, was gesucht wird. "
|
"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. "
|
"Nichts vorschlagen, was in der Sammlung steht oder schon gesehen wurde. "
|
||||||
"Was in der Frage genannt ist, kennt die Person schon — auch nicht vorschlagen. "
|
"Was in der Frage genannt ist, kennt die Person schon — auch nicht vorschlagen. "
|
||||||
"Mische Bekanntes mit Geheimtipps. "
|
"Mische Bekanntes mit Geheimtipps. "
|
||||||
"Antworte nur mit JSON in dieser Form, ohne Text davor oder danach: "
|
"Antworte nur mit JSON in dieser Form, ohne Text davor oder danach: "
|
||||||
'{"merkmale": ["drei Stichpunkte: was macht das Gesuchte aus"], '
|
'{{"merkmale": ["drei Stichpunkte: was macht das Gesuchte aus"], '
|
||||||
'"kandidaten": ["20 Originaltitel mit Jahr, die dazu passen könnten"], '
|
'"kandidaten": ["{kandidaten} Originaltitel mit Jahr, die dazu passen könnten"], '
|
||||||
'"filme": [{"titel": "Originaltitel", "jahr": 1999, "grund": "..."}]}\n'
|
'"filme": [{{"titel": "Originaltitel", "jahr": 1999, "grund": "..."}}]}}\n'
|
||||||
# Der Umweg über Merkmale und Kandidaten ersetzt das Nachdenken des
|
# Der Umweg über Merkmale und Kandidaten ersetzt das Nachdenken des
|
||||||
# Modells: es sortiert erst, dann wählt es — und trifft deutlich besser.
|
# 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
|
# Die Begründung lesen Leute, die einfach einen Film für den Abend
|
||||||
# suchen — nicht Kritiker. Sie muss auf vier kurze Zeilen passen.
|
# 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 "
|
"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(",; –.") + " …"
|
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):
|
def etikett(film):
|
||||||
"""Titel (Jahr) — so kommt ein Film in den Prompt."""
|
"""Titel (Jahr) — so kommt ein Film in den Prompt."""
|
||||||
jahr = film.get("year")
|
jahr = film.get("year")
|
||||||
@@ -124,7 +131,7 @@ def prompt(beschreibung, sammlung, gesehen, filter_=None):
|
|||||||
return "\n\n".join(teile)
|
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
|
"""Das JSON aus der Antwort ziehen. Das Modell hängt gern einen
|
||||||
Denkblock oder einen Satz davor — es zählt, was zwischen der ersten
|
Denkblock oder einen Satz davor — es zählt, was zwischen der ersten
|
||||||
und der letzten Klammer steht."""
|
und der letzten Klammer steht."""
|
||||||
@@ -152,14 +159,14 @@ def parsen(text):
|
|||||||
"grund": kuerzen(str(f.get("grund") or "")),
|
"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 = {
|
payload = {
|
||||||
"model": MINIMAX_MODEL,
|
"model": MINIMAX_MODEL,
|
||||||
"messages": [
|
"messages": [
|
||||||
{"role": "system", "content": SYSTEM},
|
{"role": "system", "content": system(anzahl)},
|
||||||
{"role": "user", "content": prompt(beschreibung, sammlung, gesehen, filter_)},
|
{"role": "user", "content": prompt(beschreibung, sammlung, gesehen, filter_)},
|
||||||
],
|
],
|
||||||
# Ohne Denkblock: M3 grübelt sonst minutenlang über zwölf Filme
|
# 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,
|
# Kandidatenfelder im Prompt leisten das Sortieren stattdessen,
|
||||||
# in gut zehn Sekunden. Niedrige Temperatur: weniger Ausreißer.
|
# in gut zehn Sekunden. Niedrige Temperatur: weniger Ausreißer.
|
||||||
"thinking": {"type": "disabled"},
|
"thinking": {"type": "disabled"},
|
||||||
"max_tokens": 4000,
|
# Rund 40 Token je Film, dazu Merkmale und Kandidaten
|
||||||
|
"max_tokens": 2000 + 60 * anzahl,
|
||||||
"temperature": 0.3,
|
"temperature": 0.3,
|
||||||
}
|
}
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
@@ -178,7 +186,7 @@ async def fragen(beschreibung, sammlung, gesehen, filter_=None):
|
|||||||
for _ in range(2):
|
for _ in range(2):
|
||||||
resp = await http.post(MINIMAX_URL, json=payload)
|
resp = await http.post(MINIMAX_URL, json=payload)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
filme = parsen(resp.json()["choices"][0]["message"]["content"])
|
filme = parsen(resp.json()["choices"][0]["message"]["content"], anzahl)
|
||||||
if filme:
|
if filme:
|
||||||
return filme
|
return filme
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -107,8 +107,10 @@ async def startup():
|
|||||||
log.info("Leere Datenbank, starte ersten Sync im Hintergrund")
|
log.info("Leere Datenbank, starte ersten Sync im Hintergrund")
|
||||||
asyncio.create_task(guarded_sync())
|
asyncio.create_task(guarded_sync())
|
||||||
else:
|
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.katalog_nachtragen())
|
||||||
|
asyncio.create_task(sync.leihbarkeit_nachtragen())
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("shutdown")
|
@app.on_event("shutdown")
|
||||||
@@ -157,15 +159,16 @@ def movie(movie_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/search")
|
@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."""
|
"""Freie TMDB-Suche für Filme außerhalb des Katalogs."""
|
||||||
if len(q.strip()) < 2:
|
if len(q.strip()) < 2:
|
||||||
return []
|
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")
|
@app.get("/api/discover")
|
||||||
async def stoebern(
|
async def stoebern(
|
||||||
|
limit: int = 40,
|
||||||
sort: str = "rating",
|
sort: str = "rating",
|
||||||
include: str = "",
|
include: str = "",
|
||||||
exclude: str = "",
|
exclude: str = "",
|
||||||
@@ -192,7 +195,8 @@ async def stoebern(
|
|||||||
"jahr_max": jahr_max,
|
"jahr_max": jahr_max,
|
||||||
"note_min": note_min,
|
"note_min": note_min,
|
||||||
"note_max": note_max,
|
"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),
|
jahr_max: int | None = Body(None, embed=True),
|
||||||
note_min: float | None = Body(None, embed=True),
|
note_min: float | None = Body(None, embed=True),
|
||||||
note_max: 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),
|
filmabend_sid: str | None = Cookie(None),
|
||||||
):
|
):
|
||||||
"""Vorschläge von der KI, bei TMDB nachgeschlagen. Die eigene Sammlung
|
"""Vorschläge von der KI, bei TMDB nachgeschlagen. Die eigene Sammlung
|
||||||
@@ -278,7 +283,11 @@ async def ki_suche(
|
|||||||
gesehen = watched.alle(sid)
|
gesehen = watched.alle(sid)
|
||||||
try:
|
try:
|
||||||
vorschlaege = await ki.fragen(
|
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):
|
except (httpx.HTTPError, KeyError, IndexError):
|
||||||
raise HTTPException(status_code=502, detail="Die KI antwortet gerade nicht.")
|
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)
|
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}")
|
@app.get("/api/search/{movie_id}")
|
||||||
async def search_detail(movie_id: int):
|
async def search_detail(movie_id: int):
|
||||||
"""Volle Daten zu einem Suchtreffer. Erst der Katalog, dann TMDB."""
|
"""Volle Daten zu einem Suchtreffer. Erst der Katalog, dann TMDB."""
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ landen nicht in der Datenbank.
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import random
|
import random
|
||||||
|
import time
|
||||||
import unicodedata
|
import unicodedata
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -16,6 +17,7 @@ from .tmdb import (
|
|||||||
DETAIL_PARAMS,
|
DETAIL_PARAMS,
|
||||||
pick_backdrops,
|
pick_backdrops,
|
||||||
pick_overview,
|
pick_overview,
|
||||||
|
pick_rentable,
|
||||||
pick_title,
|
pick_title,
|
||||||
pick_trailer,
|
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.
|
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 def hole(film):
|
||||||
|
async with bremse:
|
||||||
|
await hole_einen(film)
|
||||||
|
|
||||||
|
async def hole_einen(film):
|
||||||
try:
|
try:
|
||||||
resp = await http.get(
|
resp = await http.get(
|
||||||
f"/movie/{film['id']}",
|
f"/movie/{film['id']}",
|
||||||
@@ -106,6 +116,27 @@ SORTIERUNG = {
|
|||||||
|
|
||||||
# Weiter hinten liegen nur noch Filme, die kaum jemand kennt
|
# Weiter hinten liegen nur noch Filme, die kaum jemand kennt
|
||||||
MAX_SEITE = 20
|
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):
|
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"
|
params["primary_release_date.lte"] = f"{filter_['jahr_max']}-12-31"
|
||||||
|
|
||||||
async with client() as http:
|
async with client() as http:
|
||||||
resp = await http.get("/discover/movie", params=params)
|
results, erste = await seiten(http, "/discover/movie", params, limit)
|
||||||
resp.raise_for_status()
|
|
||||||
daten = resp.json()
|
|
||||||
|
|
||||||
# Bei "Zufällig" nochmal von einer beliebigen Seite holen — sonst
|
# Bei "Zufällig" nochmal von einer beliebigen Stelle holen — sonst
|
||||||
# stehen bei jedem Aufruf dieselben zwanzig Filme oben
|
# stehen bei jedem Aufruf dieselben 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", [])
|
|
||||||
if filter_.get("sort") == "random":
|
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)
|
random.shuffle(results)
|
||||||
|
|
||||||
usable = [r for r in results if r.get("poster_path")]
|
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]
|
begriffe = [query] if schlicht == query else [query, schlicht]
|
||||||
|
|
||||||
async def einmal(begriff):
|
async def einmal(begriff):
|
||||||
try:
|
results, _ = await seiten(
|
||||||
resp = await http.get(
|
http,
|
||||||
"/search/movie",
|
"/search/movie",
|
||||||
params={
|
{"query": begriff, "language": LANGUAGE, "include_adult": "false"},
|
||||||
"query": begriff,
|
limit,
|
||||||
"language": LANGUAGE,
|
|
||||||
"include_adult": "false",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
return results
|
||||||
return resp.json().get("results", [])
|
|
||||||
except httpx.HTTPError:
|
|
||||||
return []
|
|
||||||
|
|
||||||
async with client() as http:
|
async with client() as http:
|
||||||
results = []
|
results = []
|
||||||
@@ -343,6 +362,114 @@ async def aehnliche(movie_id, limit=12, voll=False, filter_=None):
|
|||||||
return treffer
|
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):
|
async def titel_richten(http, film):
|
||||||
"""Originalschrift durch den englischen Titel ersetzen."""
|
"""Originalschrift durch den englischen Titel ersetzen."""
|
||||||
if not any(
|
if not any(
|
||||||
@@ -387,6 +514,7 @@ async def detail(movie_id):
|
|||||||
"images": pick_backdrops(data)[:12],
|
"images": pick_backdrops(data)[:12],
|
||||||
"trailer_key": pick_trailer(data),
|
"trailer_key": pick_trailer(data),
|
||||||
"imdb_id": data.get("imdb_id"),
|
"imdb_id": data.get("imdb_id"),
|
||||||
|
"rentable": pick_rentable(data),
|
||||||
"netflix_id": None,
|
"netflix_id": None,
|
||||||
"netflix_url": None,
|
"netflix_url": None,
|
||||||
"on_netflix": 0,
|
"on_netflix": 0,
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ def build_movie(data):
|
|||||||
"images": tmdb.pick_backdrops(data),
|
"images": tmdb.pick_backdrops(data),
|
||||||
"trailer_key": tmdb.pick_trailer(data),
|
"trailer_key": tmdb.pick_trailer(data),
|
||||||
"imdb_id": data.get("imdb_id"),
|
"imdb_id": data.get("imdb_id"),
|
||||||
|
"rentable": tmdb.pick_rentable(data),
|
||||||
# Die echte Titel-ID trägt fill_netflix_links nach
|
# Die echte Titel-ID trägt fill_netflix_links nach
|
||||||
"netflix_id": None,
|
"netflix_id": None,
|
||||||
"netflix_url": tmdb.pick_watch_link(data)
|
"netflix_url": tmdb.pick_watch_link(data)
|
||||||
@@ -178,6 +179,37 @@ async def katalog_nachtragen():
|
|||||||
return len(filme)
|
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():
|
async def run():
|
||||||
log.info("Sync startet")
|
log.info("Sync startet")
|
||||||
canon_places = await resolve_canon()
|
canon_places = await resolve_canon()
|
||||||
|
|||||||
@@ -135,6 +135,15 @@ def pick_watch_link(data):
|
|||||||
return (providers.get(REGION) or {}).get("link")
|
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):
|
def pick_trailer(data):
|
||||||
"""Trailer bevorzugt, sonst Teaser. Sprache egal."""
|
"""Trailer bevorzugt, sonst Teaser. Sprache egal."""
|
||||||
videos = [
|
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({"jahr_min": 1980, "jahr_max": 1989}) == ["Zwischen 1980 und 1989"]
|
||||||
assert ki.vorgaben({}) == []
|
assert ki.vorgaben({}) == []
|
||||||
assert "unbedingt einhalten" in ki.prompt("x", [], [], {"include": ["Horror"]})
|
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():
|
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):
|
def test_route_fragt_und_loest_auf(client, film, monkeypatch):
|
||||||
aufrufe = {}
|
aufrufe = {}
|
||||||
|
|
||||||
async def fragen(beschreibung, sammlung, gesehen, filter_):
|
async def fragen(beschreibung, sammlung, gesehen, filter_, anzahl):
|
||||||
aufrufe.update(
|
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."}]
|
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"]["include"] == ["Horror", "Mystery"]
|
||||||
assert aufrufe["filter"]["jahr_min"] == 2010
|
assert aufrufe["filter"]["jahr_min"] == 2010
|
||||||
assert aufrufe["filter"]["note_max"] is None
|
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
|
# Gesehene dürfen auch drinbleiben — dann zeigt die Karte es an
|
||||||
client.post("/api/ki-suche", json={"beschreibung": "x", "ohne_gesehene": False})
|
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):
|
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 []
|
return []
|
||||||
|
|
||||||
async def aufloesen(vorschlaege, filter_, ausser=()):
|
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 FilterSidebar from './components/FilterSidebar.vue'
|
||||||
import AehnlichTab from './components/AehnlichTab.vue'
|
import AehnlichTab from './components/AehnlichTab.vue'
|
||||||
import KiTab from './components/KiTab.vue'
|
import KiTab from './components/KiTab.vue'
|
||||||
|
import PersonenTab from './components/PersonenTab.vue'
|
||||||
import MerklisteTab from './components/MerklisteTab.vue'
|
import MerklisteTab from './components/MerklisteTab.vue'
|
||||||
import SammlungFilter from './components/SammlungFilter.vue'
|
import SammlungFilter from './components/SammlungFilter.vue'
|
||||||
import MovieDetail from './components/MovieDetail.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 { ansicht, ohneGesehene, activeTab, quelle, suchArt, host } = useEinstellungen()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
runde, ich, michSetzen,
|
runde,
|
||||||
gesehen, gesehenIds, gesehenZahl, ladeGesehen,
|
ich,
|
||||||
meinVorschlag, aufDemRad, ladeVorschlag,
|
michSetzen,
|
||||||
merkliste, ladeMerkliste,
|
gesehen,
|
||||||
wuensche, ladeWuensche,
|
gesehenIds,
|
||||||
|
gesehenZahl,
|
||||||
|
ladeGesehen,
|
||||||
|
meinVorschlag,
|
||||||
|
aufDemRad,
|
||||||
|
ladeVorschlag,
|
||||||
|
merkliste,
|
||||||
|
ladeMerkliste,
|
||||||
|
wuensche,
|
||||||
|
ladeWuensche,
|
||||||
} = useMeins({ host })
|
} = useMeins({ host })
|
||||||
|
|
||||||
// Ohne gewählten Namen darf man alles lesen, aber nichts eintragen —
|
// Ohne gewählten Namen darf man alles lesen, aber nichts eintragen —
|
||||||
@@ -37,44 +47,74 @@ const {
|
|||||||
const angemeldet = computed(() => !!ich.value)
|
const angemeldet = computed(() => !!ich.value)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
movies, loading, error, load,
|
movies,
|
||||||
netflixMovies, canonMovies, canonGroups, netflixEntries, SORTS,
|
loading,
|
||||||
|
error,
|
||||||
|
load,
|
||||||
|
netflixMovies,
|
||||||
|
canonMovies,
|
||||||
|
canonGroups,
|
||||||
|
netflixEntries,
|
||||||
|
SORTS,
|
||||||
} = useKatalog()
|
} = useKatalog()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
search, sort, suchSort, include, exclude, grenzen, ranges, genreOptions,
|
search,
|
||||||
toggleGenre, toggleAll, resetGenres, nichtGesehen, passt, suchFilter,
|
sort,
|
||||||
} = useFilter({ activeTab, quelle, netflixMovies, canonMovies, gesehenIds, ohneGesehene })
|
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
|
// Eine Reihe erscheint, sobald ein Teil passt — sortiert wird sie über
|
||||||
// ihren stärksten Teil
|
// ihren stärksten Teil
|
||||||
function filterGruppen(entries, term) {
|
function filterGruppen(entries, term) {
|
||||||
return entries
|
return entries
|
||||||
.filter(
|
.filter(
|
||||||
(e) =>
|
(e) => e.parts.some((m) => passt(m)) && (!term || e.parts.some((m) => matches(m, term))),
|
||||||
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]))
|
.sort((a, b) => SORTS[sort.value](a.parts[a.best], b.parts[b.best]))
|
||||||
}
|
}
|
||||||
|
|
||||||
const visible = computed(() =>
|
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
|
// Im Kanon greift die Suche auf die ganze Reihe: wer "Saw" sucht, will den
|
||||||
// Eintrag sehen, egal welcher Teil gerade vorne liegt.
|
// Eintrag sehen, egal welcher Teil gerade vorne liegt.
|
||||||
const visibleCanon = computed(() =>
|
const visibleCanon = computed(() =>
|
||||||
filterGruppen(canonGroups.value, search.value.trim().toLowerCase())
|
filterGruppen(canonGroups.value, search.value.trim().toLowerCase()),
|
||||||
)
|
)
|
||||||
|
|
||||||
const aktuelleListe = computed(() =>
|
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
|
// Die zwei Listen des Katalogs, umschaltbar in der Filterleiste
|
||||||
const quellen = computed(() => [
|
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',
|
id: 'canon',
|
||||||
label: 'Horror-Kanon',
|
label: 'Horror-Kanon',
|
||||||
@@ -98,7 +138,11 @@ const tabs = computed(() => [
|
|||||||
{ id: 'wheel', label: 'Rad', count: null },
|
{ id: 'wheel', label: 'Rad', count: null },
|
||||||
{ id: 'stream', label: 'Stream', count: null },
|
{ id: 'stream', label: 'Stream', count: null },
|
||||||
{ id: 'watched', label: 'Gesehen', count: gesehenZahl.value },
|
{ 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
|
...(host.value || angemeldet.value
|
||||||
? [{ id: 'verwaltung', label: 'Verwaltung', count: null }]
|
? [{ id: 'verwaltung', label: 'Verwaltung', count: null }]
|
||||||
: []),
|
: []),
|
||||||
@@ -107,9 +151,7 @@ const tabs = computed(() => [
|
|||||||
// Auf dem Handy passt die Filterleiste nicht neben die Karten — dort
|
// Auf dem Handy passt die Filterleiste nicht neben die Karten — dort
|
||||||
// legt sie sich über einen Knopf über die Seite
|
// legt sie sich über einen Knopf über die Seite
|
||||||
const filterOffen = ref(false)
|
const filterOffen = ref(false)
|
||||||
const mitFilter = computed(() =>
|
const mitFilter = computed(() => ['katalog', 'suche', 'merkliste'].includes(activeTab.value))
|
||||||
['katalog', 'suche', 'merkliste'].includes(activeTab.value)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Der Sammlungen-Reiter filtert nach Person und Genre, nicht nach Regler —
|
// Der Sammlungen-Reiter filtert nach Person und Genre, nicht nach Regler —
|
||||||
// dafür hat er eine eigene Leiste
|
// 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 })
|
if (!nach.has(name)) nach.set(name, { name, anzahl: 0, mine: eintrag.mine })
|
||||||
nach.get(name).anzahl += 1
|
nach.get(name).anzahl += 1
|
||||||
}
|
}
|
||||||
return [...nach.values()].sort(
|
return [...nach.values()].sort((a, b) => b.mine - a.mine || a.name.localeCompare(b.name, 'de'))
|
||||||
(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
|
// 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 []
|
if (group.length < 2) return []
|
||||||
return [...group].sort((a, b) =>
|
return [...group].sort((a, b) => (a.release_date || '').localeCompare(b.release_date || ''))
|
||||||
(a.release_date || '').localeCompare(b.release_date || '')
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Aus der Filmansicht heraus zur Ähnlichkeitsuche springen
|
// Aus der Filmansicht heraus zur Ähnlichkeitsuche springen
|
||||||
@@ -195,10 +233,21 @@ function zurAehnlichkeitsuche(movie) {
|
|||||||
suchArt.value = 'aehnlich'
|
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
|
// Die drei Suchen unter einem Reiter, umschaltbar über der Liste
|
||||||
const SUCHARTEN = [
|
const SUCHARTEN = [
|
||||||
{ id: 'search', label: 'Titel' },
|
{ id: 'search', label: 'Titel' },
|
||||||
{ id: 'aehnlich', label: 'Ähnliche Filme' },
|
{ id: 'aehnlich', label: 'Ähnliche Filme' },
|
||||||
|
{ id: 'personen', label: 'Personen' },
|
||||||
{ id: 'ki', label: 'KI' },
|
{ id: 'ki', label: 'KI' },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -238,12 +287,7 @@ onMounted(load)
|
|||||||
Filter
|
Filter
|
||||||
</button>
|
</button>
|
||||||
<span v-if="host" class="host">Host</span>
|
<span v-if="host" class="host">Host</span>
|
||||||
<NamensWahl
|
<NamensWahl :items="runde" :mine="ich" :host="host" :anmelden="michSetzen" />
|
||||||
:items="runde"
|
|
||||||
:mine="ich"
|
|
||||||
:host="host"
|
|
||||||
:anmelden="michSetzen"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -280,6 +324,17 @@ onMounted(load)
|
|||||||
@gestartet="aehnlichStart = null"
|
@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
|
<KiTab
|
||||||
v-else
|
v-else
|
||||||
:angemeldet="angemeldet"
|
:angemeldet="angemeldet"
|
||||||
@@ -294,6 +349,8 @@ onMounted(load)
|
|||||||
<!-- Die KI ordnet selbst nach Passung, da gibt es nichts zu sortieren -->
|
<!-- Die KI ordnet selbst nach Passung, da gibt es nichts zu sortieren -->
|
||||||
<FilterSidebar
|
<FilterSidebar
|
||||||
v-model:sort="suchSort"
|
v-model:sort="suchSort"
|
||||||
|
v-model:anzahl="suchAnzahl"
|
||||||
|
:mit-anzahl="suchArt !== 'personen'"
|
||||||
v-model:ansicht="ansicht"
|
v-model:ansicht="ansicht"
|
||||||
v-model:ohne-gesehene="ohneGesehene"
|
v-model:ohne-gesehene="ohneGesehene"
|
||||||
v-model:ranges="ranges"
|
v-model:ranges="ranges"
|
||||||
@@ -363,12 +420,7 @@ onMounted(load)
|
|||||||
@changed="ladeWuensche"
|
@changed="ladeWuensche"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<VerwaltungTab
|
<VerwaltungTab v-else-if="activeTab === 'verwaltung'" :items="runde" :host="host" :ich="ich" />
|
||||||
v-else-if="activeTab === 'verwaltung'"
|
|
||||||
:items="runde"
|
|
||||||
:host="host"
|
|
||||||
:ich="ich"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<StreamTab v-else-if="activeTab === 'stream'" />
|
<StreamTab v-else-if="activeTab === 'stream'" />
|
||||||
|
|
||||||
@@ -459,6 +511,7 @@ onMounted(load)
|
|||||||
@suggested="nachVorschlag"
|
@suggested="nachVorschlag"
|
||||||
@watched="ladeGesehen"
|
@watched="ladeGesehen"
|
||||||
@aehnlich="zurAehnlichkeitsuche"
|
@aehnlich="zurAehnlichkeitsuche"
|
||||||
|
@person="zurPersonensuche"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -649,10 +702,7 @@ onMounted(load)
|
|||||||
/* Eine Detailkarte ist so breit wie vier kompakte Karten nebeneinander —
|
/* Eine Detailkarte ist so breit wie vier kompakte Karten nebeneinander —
|
||||||
oder die halbe Reihe, falls die schmaler ist. */
|
oder die halbe Reihe, falls die schmaler ist. */
|
||||||
.grid.details {
|
.grid.details {
|
||||||
grid-template-columns: repeat(
|
grid-template-columns: repeat(auto-fill, min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem)));
|
||||||
auto-fill,
|
|
||||||
min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.note {
|
.note {
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ import { computed, ref, watch } from 'vue'
|
|||||||
import MovieCard from './MovieCard.vue'
|
import MovieCard from './MovieCard.vue'
|
||||||
import { filterParameter } from '../filter.js'
|
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.
|
// Eine Detailkarte nimmt die halbe Breite ein, eine kompakte ein Achtel.
|
||||||
// Gleich viele Vorschläge wären mal eine Reihe, mal zehn.
|
// Gleich viele Vorschläge wären mal eine Reihe, mal zehn.
|
||||||
const VORSCHLAEGE = { kompakt: 20, details: 8 }
|
const VORSCHLAEGE = { kompakt: 20, details: 8 }
|
||||||
@@ -24,9 +21,7 @@ const emit = defineEmits(['open', 'gestartet'])
|
|||||||
const query = ref('')
|
const query = ref('')
|
||||||
const gefunden = ref([])
|
const gefunden = ref([])
|
||||||
// Beim Wechsel der Ansicht wird nur neu geschnitten, nicht neu gesucht
|
// Beim Wechsel der Ansicht wird nur neu geschnitten, nicht neu gesucht
|
||||||
const vorschlaege = computed(() =>
|
const vorschlaege = computed(() => gefunden.value.slice(0, VORSCHLAEGE[props.ansicht] ?? 8))
|
||||||
gefunden.value.slice(0, VORSCHLAEGE[props.ansicht] ?? 8)
|
|
||||||
)
|
|
||||||
const gewaehlt = ref(null)
|
const gewaehlt = ref(null)
|
||||||
const treffer = ref([])
|
const treffer = ref([])
|
||||||
const laden = ref(false)
|
const laden = ref(false)
|
||||||
@@ -86,8 +81,8 @@ async function nachladen() {
|
|||||||
laden.value = true
|
laden.value = true
|
||||||
fehler.value = ''
|
fehler.value = ''
|
||||||
try {
|
try {
|
||||||
|
// Die Anzahl kommt aus der Leiste, mit filterParameter als 'limit'
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
limit: TREFFER,
|
|
||||||
voll: 'true',
|
voll: 'true',
|
||||||
...filterParameter(props.filter),
|
...filterParameter(props.filter),
|
||||||
})
|
})
|
||||||
@@ -123,7 +118,7 @@ watch(
|
|||||||
waehlen(film)
|
waehlen(film)
|
||||||
emit('gestartet')
|
emit('gestartet')
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
// Am Schieberegler hängen viele Ereignisse — erst abwarten, dann holen
|
// Am Schieberegler hängen viele Ereignisse — erst abwarten, dann holen
|
||||||
@@ -134,7 +129,7 @@ watch(
|
|||||||
clearTimeout(filterTimer)
|
clearTimeout(filterTimer)
|
||||||
filterTimer = setTimeout(nachladen, 350)
|
filterTimer = setTimeout(nachladen, 350)
|
||||||
},
|
},
|
||||||
{ deep: true }
|
{ deep: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(query, (term) => {
|
watch(query, (term) => {
|
||||||
@@ -160,13 +155,7 @@ async function oeffnen(movie) {
|
|||||||
placeholder="Von welchem Film ausgehen?"
|
placeholder="Von welchem Film ausgehen?"
|
||||||
autofocus
|
autofocus
|
||||||
/>
|
/>
|
||||||
<button
|
<button v-if="query" type="button" class="leeren" title="Suche leeren" @click="query = ''">
|
||||||
v-if="query"
|
|
||||||
type="button"
|
|
||||||
class="leeren"
|
|
||||||
title="Suche leeren"
|
|
||||||
@click="query = ''"
|
|
||||||
>
|
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -196,12 +185,7 @@ async function oeffnen(movie) {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div v-if="!laden" class="grid" :class="ansicht">
|
<div v-if="!laden" class="grid" :class="ansicht">
|
||||||
<MovieCard
|
<MovieCard :movie="gewaehlt" :ansicht="ansicht" class="ausgangsfilm" @open="oeffnen" />
|
||||||
:movie="gewaehlt"
|
|
||||||
:ansicht="ansicht"
|
|
||||||
class="ausgangsfilm"
|
|
||||||
@open="oeffnen"
|
|
||||||
/>
|
|
||||||
<MovieCard
|
<MovieCard
|
||||||
v-for="film in treffer.filter(passt)"
|
v-for="film in treffer.filter(passt)"
|
||||||
:key="film.id"
|
:key="film.id"
|
||||||
@@ -277,10 +261,7 @@ async function oeffnen(movie) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.grid.details {
|
.grid.details {
|
||||||
grid-template-columns: repeat(
|
grid-template-columns: repeat(auto-fill, min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem)));
|
||||||
auto-fill,
|
|
||||||
min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Auf dem Handy wie im Katalog: drei Poster nebeneinander */
|
/* Auf dem Handy wie im Katalog: drei Poster nebeneinander */
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const props = defineProps({
|
|||||||
ohneSort: Boolean,
|
ohneSort: Boolean,
|
||||||
// Der Katalog hat zwei Listen zur Wahl: [{ id, label, count }]
|
// Der Katalog hat zwei Listen zur Wahl: [{ id, label, count }]
|
||||||
quellen: { type: Array, default: () => [] },
|
quellen: { type: Array, default: () => [] },
|
||||||
|
// Die Suchen lassen wählen, wie viele Treffer es sein sollen
|
||||||
|
mitAnzahl: Boolean,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['toggle', 'all', 'reset'])
|
const emit = defineEmits(['toggle', 'all', 'reset'])
|
||||||
@@ -23,9 +25,13 @@ const emit = defineEmits(['toggle', 'all', 'reset'])
|
|||||||
const search = defineModel('search', { type: String, default: '' })
|
const search = defineModel('search', { type: String, default: '' })
|
||||||
const sort = defineModel('sort', { type: String, default: 'random' })
|
const sort = defineModel('sort', { type: String, default: 'random' })
|
||||||
const ansicht = defineModel('ansicht', { type: String, default: 'kompakt' })
|
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 ranges = defineModel('ranges', { type: Object, default: () => ({}) })
|
||||||
const quelle = defineModel('quelle', { type: String, 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
|
// Auch ein verschobener Regler zählt als aktiver Filter
|
||||||
const etwasAktiv = computed(
|
const etwasAktiv = computed(
|
||||||
@@ -36,7 +42,7 @@ const etwasAktiv = computed(
|
|||||||
const spanne = ranges.value[r.key]
|
const spanne = ranges.value[r.key]
|
||||||
const grenze = props.grenzen[r.key]
|
const grenze = props.grenzen[r.key]
|
||||||
return spanne && grenze && (spanne[0] > grenze.min || spanne[1] < grenze.max)
|
return spanne && grenze && (spanne[0] > grenze.min || spanne[1] < grenze.max)
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
function beschriften(regler) {
|
function beschriften(regler) {
|
||||||
@@ -50,7 +56,6 @@ function beschriften(regler) {
|
|||||||
return zahl + (regler.einheit || '')
|
return zahl + (regler.einheit || '')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -69,12 +74,7 @@ function beschriften(regler) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<input v-if="!ohneSuche" v-model="search" type="search" placeholder="Titel suchen…" />
|
||||||
v-if="!ohneSuche"
|
|
||||||
v-model="search"
|
|
||||||
type="search"
|
|
||||||
placeholder="Titel suchen…"
|
|
||||||
/>
|
|
||||||
<select v-if="!ohneSort" v-model="sort">
|
<select v-if="!ohneSort" v-model="sort">
|
||||||
<option v-if="mitRelevanz" value="relevanz">Wie auf IMDb</option>
|
<option v-if="mitRelevanz" value="relevanz">Wie auf IMDb</option>
|
||||||
<option value="random">Zufällig</option>
|
<option value="random">Zufällig</option>
|
||||||
@@ -84,19 +84,16 @@ function beschriften(regler) {
|
|||||||
<option value="title">Titel A–Z</option>
|
<option value="title">Titel A–Z</option>
|
||||||
</select>
|
</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">
|
<div class="ansicht">
|
||||||
<button
|
<button type="button" :class="{ an: ansicht === 'kompakt' }" @click="ansicht = 'kompakt'">
|
||||||
type="button"
|
|
||||||
:class="{ an: ansicht === 'kompakt' }"
|
|
||||||
@click="ansicht = 'kompakt'"
|
|
||||||
>
|
|
||||||
Kompakt
|
Kompakt
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" :class="{ an: ansicht === 'details' }" @click="ansicht = 'details'">
|
||||||
type="button"
|
|
||||||
:class="{ an: ansicht === 'details' }"
|
|
||||||
@click="ansicht = 'details'"
|
|
||||||
>
|
|
||||||
Details
|
Details
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -309,6 +306,23 @@ h4 {
|
|||||||
text-decoration: line-through;
|
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 {
|
.ansicht {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
|
|||||||
@@ -28,14 +28,14 @@ const gefragt = ref(false)
|
|||||||
|
|
||||||
// Eine verstellte Leiste ist schon eine Frage: "Horror ab 2015" reicht
|
// Eine verstellte Leiste ist schon eine Frage: "Horror ab 2015" reicht
|
||||||
const leisteVerstellt = computed(() => {
|
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)
|
return Object.values(rest).some((w) => w !== '' && w != null)
|
||||||
})
|
})
|
||||||
|
|
||||||
const kannFragen = computed(
|
const kannFragen = computed(
|
||||||
() =>
|
() =>
|
||||||
!laden.value &&
|
!laden.value &&
|
||||||
(beschreibung.value.trim().length > 0 || mitSammlung.value || leisteVerstellt.value)
|
(beschreibung.value.trim().length > 0 || mitSammlung.value || leisteVerstellt.value),
|
||||||
)
|
)
|
||||||
|
|
||||||
async function fragen() {
|
async function fragen() {
|
||||||
@@ -196,10 +196,7 @@ textarea:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.grid.details {
|
.grid.details {
|
||||||
grid-template-columns: repeat(
|
grid-template-columns: repeat(auto-fill, min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem)));
|
||||||
auto-fill,
|
|
||||||
min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.note {
|
.note {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
'close', 'open', 'suggested', 'watched', 'gemerkt', 'aehnlich',
|
'close', 'open', 'suggested', 'watched', 'gemerkt', 'aehnlich', 'person',
|
||||||
])
|
])
|
||||||
|
|
||||||
const vorschlagen = ref({ zustand: 'bereit' })
|
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(
|
watch(
|
||||||
() => props.movie.id,
|
() => props.movie.id,
|
||||||
(id) => {
|
(id) => {
|
||||||
vorschlagen.value = { zustand: 'bereit' }
|
vorschlagen.value = { zustand: 'bereit' }
|
||||||
ladeAehnliche(id)
|
ladeAehnliche(id)
|
||||||
|
ladeLeute(id)
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
@@ -176,7 +190,13 @@ onUnmounted(() => {
|
|||||||
<header>
|
<header>
|
||||||
<img v-if="poster" class="poster" :src="poster" :alt="movie.title" />
|
<img v-if="poster" class="poster" :src="poster" :alt="movie.title" />
|
||||||
<div class="head-text">
|
<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">
|
<p v-if="movie.original_title !== movie.title" class="original">
|
||||||
{{ movie.original_title }}
|
{{ movie.original_title }}
|
||||||
</p>
|
</p>
|
||||||
@@ -188,6 +208,22 @@ onUnmounted(() => {
|
|||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="genres">{{ (movie.genres || []).join(' · ') }}</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>
|
<p v-if="movie.teaser" class="teaser">{{ movie.teaser }}</p>
|
||||||
<div class="links">
|
<div class="links">
|
||||||
<a
|
<a
|
||||||
@@ -208,6 +244,16 @@ onUnmounted(() => {
|
|||||||
>
|
>
|
||||||
IMDb
|
IMDb
|
||||||
</a>
|
</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>
|
||||||
|
|
||||||
<div class="vorschlag">
|
<div class="vorschlag">
|
||||||
@@ -244,12 +290,8 @@ onUnmounted(() => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="host || gesehen" class="gesehen">
|
<div v-if="host && !gesehen && merken.zustand !== 'fertig'" class="gesehen">
|
||||||
<span v-if="gesehen || merken.zustand === 'fertig'" class="ok">
|
|
||||||
Steht unter „Gesehen“.
|
|
||||||
</span>
|
|
||||||
<button
|
<button
|
||||||
v-else-if="host"
|
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="merken.zustand === 'laeuft'"
|
:disabled="merken.zustand === 'laeuft'"
|
||||||
@click="alsGesehen"
|
@click="alsGesehen"
|
||||||
@@ -485,6 +527,26 @@ h2 {
|
|||||||
font-size: 0.82rem;
|
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 {
|
.teaser {
|
||||||
color: #cfcfda;
|
color: #cfcfda;
|
||||||
font-size: 0.98rem;
|
font-size: 0.98rem;
|
||||||
@@ -498,7 +560,8 @@ h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.netflix,
|
.netflix,
|
||||||
.imdb {
|
.imdb,
|
||||||
|
.tmdb {
|
||||||
padding: 0.55rem 1.1rem;
|
padding: 0.55rem 1.1rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -510,11 +573,29 @@ h2 {
|
|||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tmdb {
|
||||||
|
color: #0d253f;
|
||||||
|
background: #01b4e4;
|
||||||
|
}
|
||||||
|
|
||||||
.imdb {
|
.imdb {
|
||||||
color: #111;
|
color: #111;
|
||||||
background: #f5c518;
|
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
|
/* Der Textblock richtet seine Kinder linksbündig aus, dadurch war der
|
||||||
Trennstrich nur so breit wie die Knöpfe darunter */
|
Trennstrich nur so breit wie die Knöpfe darunter */
|
||||||
.vorschlag {
|
.vorschlag {
|
||||||
@@ -609,7 +690,8 @@ h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.netflix:hover,
|
.netflix:hover,
|
||||||
.imdb:hover {
|
.imdb:hover,
|
||||||
|
.tmdb:hover {
|
||||||
filter: brightness(1.15);
|
filter: brightness(1.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -749,7 +831,8 @@ h3 {
|
|||||||
.gesehen button,
|
.gesehen button,
|
||||||
.merken,
|
.merken,
|
||||||
.netflix,
|
.netflix,
|
||||||
.imdb {
|
.imdb,
|
||||||
|
.tmdb {
|
||||||
padding: 0.65rem 1rem;
|
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 {
|
try {
|
||||||
const url = stoebern
|
const url = stoebern
|
||||||
? `/api/discover?${new URLSearchParams(filterParameter(props.filter))}`
|
? `/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)
|
const resp = await fetch(url)
|
||||||
if (!resp.ok) throw new Error('Suche fehlgeschlagen')
|
if (!resp.ok) throw new Error('Suche fehlgeschlagen')
|
||||||
const data = await resp.json()
|
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 v-if="query" type="button" class="leeren" title="Suche leeren" @click="leeren">
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="neu" title="Neu suchen" @click="jetztSuchen">
|
<button type="button" class="neu" title="Neu suchen" @click="jetztSuchen">Suchen</button>
|
||||||
Suchen
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="loading" class="note">Suche läuft…</p>
|
<p v-if="loading" class="note">Suche läuft…</p>
|
||||||
<p v-else-if="error" class="note error">{{ error }}</p>
|
<p v-else-if="error" class="note error">{{ error }}</p>
|
||||||
<p v-else-if="!results.length" class="note">
|
<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>
|
</p>
|
||||||
|
|
||||||
<div class="grid" :class="ansicht">
|
<div class="grid" :class="ansicht">
|
||||||
@@ -195,10 +195,7 @@ async function open(movie) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.grid.details {
|
.grid.details {
|
||||||
grid-template-columns: repeat(
|
grid-template-columns: repeat(auto-fill, min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem)));
|
||||||
auto-fill,
|
|
||||||
min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Auf dem Handy wie im Katalog: drei Poster nebeneinander */
|
/* Auf dem Handy wie im Katalog: drei Poster nebeneinander */
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, reactive, ref } from "vue";
|
import { computed, reactive, ref } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
items: { type: Array, default: () => [] },
|
items: { type: Array, default: () => [] },
|
||||||
@@ -7,111 +7,111 @@ const props = defineProps({
|
|||||||
host: Boolean,
|
host: Boolean,
|
||||||
// Ohne gewählten Namen bleibt der Reiter zum Mitlesen da
|
// Ohne gewählten Namen bleibt der Reiter zum Mitlesen da
|
||||||
angemeldet: Boolean,
|
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
|
// Der Host darf jeden Wunsch anfassen, alle anderen nur die eigenen
|
||||||
const bearbeitet = ref(null);
|
const bearbeitet = ref(null)
|
||||||
const bearbeitung = ref("");
|
const bearbeitung = ref('')
|
||||||
|
|
||||||
function darfAendern(item) {
|
function darfAendern(item) {
|
||||||
return props.host || item.mine;
|
return props.host || item.mine
|
||||||
}
|
}
|
||||||
|
|
||||||
function bearbeiten(item) {
|
function bearbeiten(item) {
|
||||||
bearbeitet.value = item.id;
|
bearbeitet.value = item.id
|
||||||
bearbeitung.value = item.text;
|
bearbeitung.value = item.text
|
||||||
}
|
}
|
||||||
|
|
||||||
async function speichern() {
|
async function speichern() {
|
||||||
const text = bearbeitung.value.trim();
|
const text = bearbeitung.value.trim()
|
||||||
if (text) {
|
if (text) {
|
||||||
await fetch(`/api/features/${bearbeitet.value}`, {
|
await fetch(`/api/features/${bearbeitet.value}`, {
|
||||||
method: "PATCH",
|
method: 'PATCH',
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ text }),
|
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 Liste kommt vom Server schon sortiert: offene nach Stimmen, dann
|
||||||
// die erledigten. Hier nur noch in zwei Abschnitte teilen.
|
// die erledigten. Hier nur noch in zwei Abschnitte teilen.
|
||||||
const offene = 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));
|
const erledigte = computed(() => props.items.filter((w) => w.done_at))
|
||||||
|
|
||||||
async function anlegen() {
|
async function anlegen() {
|
||||||
if (!entwurf.value.trim()) return;
|
if (!entwurf.value.trim()) return
|
||||||
await fetch("/api/features", {
|
await fetch('/api/features', {
|
||||||
method: "POST",
|
method: 'POST',
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ text: entwurf.value }),
|
body: JSON.stringify({ text: entwurf.value }),
|
||||||
});
|
})
|
||||||
entwurf.value = "";
|
entwurf.value = ''
|
||||||
emit("changed");
|
emit('changed')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loeschen(item) {
|
async function loeschen(item) {
|
||||||
await fetch(`/api/features/${item.id}`, { method: "DELETE" });
|
await fetch(`/api/features/${item.id}`, { method: 'DELETE' })
|
||||||
emit("changed");
|
emit('changed')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function abstimmen(item) {
|
async function abstimmen(item) {
|
||||||
await fetch(`/api/features/${item.id}/vote`, { method: "POST" });
|
await fetch(`/api/features/${item.id}/vote`, { method: 'POST' })
|
||||||
emit("changed");
|
emit('changed')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function erledigen(item, done) {
|
async function erledigen(item, done) {
|
||||||
await fetch(`/api/features/${item.id}/done`, {
|
await fetch(`/api/features/${item.id}/done`, {
|
||||||
method: "PATCH",
|
method: 'PATCH',
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ done }),
|
body: JSON.stringify({ done }),
|
||||||
});
|
})
|
||||||
emit("changed");
|
emit('changed')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ergänzungen: aufgeklappt je Wunsch, mit eigenem Entwurf
|
// Ergänzungen: aufgeklappt je Wunsch, mit eigenem Entwurf
|
||||||
const aufgeklappt = reactive(new Set());
|
const aufgeklappt = reactive(new Set())
|
||||||
const notizEntwurf = reactive({});
|
const notizEntwurf = reactive({})
|
||||||
|
|
||||||
function umschalten(id) {
|
function umschalten(id) {
|
||||||
if (aufgeklappt.has(id)) aufgeklappt.delete(id);
|
if (aufgeklappt.has(id)) aufgeklappt.delete(id)
|
||||||
else aufgeklappt.add(id);
|
else aufgeklappt.add(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ergaenzungsLabel(item) {
|
function ergaenzungsLabel(item) {
|
||||||
const n = item.notes.length;
|
const n = item.notes.length
|
||||||
if (!n) return "Ergänzen";
|
if (!n) return 'Ergänzen'
|
||||||
return n === 1 ? "1 Ergänzung" : `${n} Ergänzungen`;
|
return n === 1 ? '1 Ergänzung' : `${n} Ergänzungen`
|
||||||
}
|
}
|
||||||
|
|
||||||
async function anmerken(item) {
|
async function anmerken(item) {
|
||||||
const text = (notizEntwurf[item.id] || "").trim();
|
const text = (notizEntwurf[item.id] || '').trim()
|
||||||
if (!text) return;
|
if (!text) return
|
||||||
await fetch(`/api/features/${item.id}/notes`, {
|
await fetch(`/api/features/${item.id}/notes`, {
|
||||||
method: "POST",
|
method: 'POST',
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ text }),
|
body: JSON.stringify({ text }),
|
||||||
});
|
})
|
||||||
notizEntwurf[item.id] = "";
|
notizEntwurf[item.id] = ''
|
||||||
emit("changed");
|
emit('changed')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function notizLoeschen(notiz) {
|
async function notizLoeschen(notiz) {
|
||||||
await fetch(`/api/feature-notes/${notiz.id}`, { method: "DELETE" });
|
await fetch(`/api/feature-notes/${notiz.id}`, { method: 'DELETE' })
|
||||||
emit("changed");
|
emit('changed')
|
||||||
}
|
}
|
||||||
|
|
||||||
function datum(iso) {
|
function datum(iso) {
|
||||||
return new Date(iso).toLocaleDateString("de-DE", {
|
return new Date(iso).toLocaleDateString('de-DE', {
|
||||||
day: "2-digit",
|
day: '2-digit',
|
||||||
month: "2-digit",
|
month: '2-digit',
|
||||||
year: "numeric",
|
year: 'numeric',
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -129,9 +129,7 @@ function datum(iso) {
|
|||||||
placeholder="Was fehlt dir auf der Seite?"
|
placeholder="Was fehlt dir auf der Seite?"
|
||||||
@keydown.ctrl.enter="anlegen"
|
@keydown.ctrl.enter="anlegen"
|
||||||
></textarea>
|
></textarea>
|
||||||
<button type="button" :disabled="!entwurf.trim()" @click="anlegen">
|
<button type="button" :disabled="!entwurf.trim()" @click="anlegen">Eintragen</button>
|
||||||
Eintragen
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
@@ -172,14 +170,8 @@ function datum(iso) {
|
|||||||
@keydown.esc="bearbeitet = null"
|
@keydown.esc="bearbeitet = null"
|
||||||
></textarea>
|
></textarea>
|
||||||
<div class="zeile">
|
<div class="zeile">
|
||||||
<button type="button" class="klein" @click="speichern">
|
<button type="button" class="klein" @click="speichern">Speichern</button>
|
||||||
Speichern
|
<button type="button" class="klein" @click="bearbeitet = null">
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="klein"
|
|
||||||
@click="bearbeitet = null"
|
|
||||||
>
|
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,9 +180,7 @@ function datum(iso) {
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<p class="text">{{ w.text }}</p>
|
<p class="text">{{ w.text }}</p>
|
||||||
<p class="meta">
|
<p class="meta">
|
||||||
<span :class="{ eigen: w.mine }">{{
|
<span :class="{ eigen: w.mine }">{{ w.name || 'Anonym' }}</span>
|
||||||
w.name || "Anonym"
|
|
||||||
}}</span>
|
|
||||||
<span class="punkt">·</span>{{ datum(w.created_at) }}
|
<span class="punkt">·</span>{{ datum(w.created_at) }}
|
||||||
<template v-if="w.done_at">
|
<template v-if="w.done_at">
|
||||||
<span class="punkt">·</span>erledigt
|
<span class="punkt">·</span>erledigt
|
||||||
@@ -210,9 +200,7 @@ function datum(iso) {
|
|||||||
|
|
||||||
<div v-if="aufgeklappt.has(w.id)" class="notizen">
|
<div v-if="aufgeklappt.has(w.id)" class="notizen">
|
||||||
<p v-for="n in w.notes" :key="n.id" class="notiz">
|
<p v-for="n in w.notes" :key="n.id" class="notiz">
|
||||||
<span class="wer" :class="{ eigen: n.mine }">{{
|
<span class="wer" :class="{ eigen: n.mine }">{{ n.name || 'Anonym' }}</span>
|
||||||
n.name || "Anonym"
|
|
||||||
}}</span>
|
|
||||||
<span class="notiz-text">{{ n.text }}</span>
|
<span class="notiz-text">{{ n.text }}</span>
|
||||||
<button
|
<button
|
||||||
v-if="host"
|
v-if="host"
|
||||||
@@ -250,12 +238,10 @@ function datum(iso) {
|
|||||||
v-if="host"
|
v-if="host"
|
||||||
type="button"
|
type="button"
|
||||||
class="weg"
|
class="weg"
|
||||||
:title="
|
:title="w.done_at ? 'Wieder öffnen' : 'Als erledigt markieren'"
|
||||||
w.done_at ? 'Wieder öffnen' : 'Als erledigt markieren'
|
|
||||||
"
|
|
||||||
@click="erledigen(w, !w.done_at)"
|
@click="erledigen(w, !w.done_at)"
|
||||||
>
|
>
|
||||||
{{ w.done_at ? "↺" : "✓" }}
|
{{ w.done_at ? '↺' : '✓' }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="darfAendern(w) && bearbeitet !== w.id"
|
v-if="darfAendern(w) && bearbeitet !== w.id"
|
||||||
@@ -288,8 +274,8 @@ function datum(iso) {
|
|||||||
.spalten {
|
.spalten {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"neu erledigt"
|
'neu erledigt'
|
||||||
"offen erledigt";
|
'offen erledigt';
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
grid-template-rows: auto 1fr;
|
grid-template-rows: auto 1fr;
|
||||||
gap: 0 2.5rem;
|
gap: 0 2.5rem;
|
||||||
@@ -558,9 +544,9 @@ h3 {
|
|||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.spalten {
|
.spalten {
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"neu"
|
'neu'
|
||||||
"offen"
|
'offen'
|
||||||
"erledigt";
|
'erledigt';
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const TAB_IDS = [
|
|||||||
// Netflix und Kanon waren einmal zwei Reiter — heute eine Quelle im Katalog
|
// Netflix und Kanon waren einmal zwei Reiter — heute eine Quelle im Katalog
|
||||||
const QUELLEN = ['netflix', 'canon']
|
const QUELLEN = ['netflix', 'canon']
|
||||||
// Ebenso die drei Suchen: heute ein Reiter mit Umschalter darüber
|
// 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) {
|
function gemerkt(schluessel, standard) {
|
||||||
const wert = ref(localStorage.getItem(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 —
|
// Die Skalen richten sich nach dem ungefilterten Bestand des Reiters —
|
||||||
// sonst wandern die Enden bei jeder Auswahl
|
// sonst wandern die Enden bei jeder Auswahl
|
||||||
export const REGLER = [
|
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: '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
|
// Bei der Suche steht kein Katalog dahinter, aus dem sich Grenzen ableiten
|
||||||
// ließen — TMDB reicht von den Anfängen bis heute
|
// ließen — TMDB reicht von den Anfängen bis heute
|
||||||
const SUCH_GRENZEN = {
|
const SUCH_GRENZEN = {
|
||||||
vote_count: { min: 50, max: 50000 },
|
vote_count: { min: 0, max: 50000 },
|
||||||
runtime: { min: 0, max: 240 },
|
runtime: { min: 0, max: 240 },
|
||||||
year: { min: 1900, max: new Date().getFullYear() + 1 },
|
year: { min: 1900, max: new Date().getFullYear() + 1 },
|
||||||
vote_average: { min: 0, max: 10 },
|
vote_average: { min: 0, max: 10 },
|
||||||
@@ -22,21 +40,62 @@ const SUCH_GRENZEN = {
|
|||||||
|
|
||||||
// Die 19 Genres, die TMDB kennt
|
// Die 19 Genres, die TMDB kennt
|
||||||
const SUCH_GENRES = [
|
const SUCH_GENRES = [
|
||||||
'Abenteuer', 'Action', 'Animation', 'Dokumentarfilm', 'Drama', 'Familie',
|
'Abenteuer',
|
||||||
'Fantasy', 'Historie', 'Horror', 'Komödie', 'Krimi', 'Kriegsfilm',
|
'Action',
|
||||||
'Liebesfilm', 'Musik', 'Mystery', 'Science Fiction', 'TV-Film', 'Thriller',
|
'Animation',
|
||||||
|
'Dokumentarfilm',
|
||||||
|
'Drama',
|
||||||
|
'Familie',
|
||||||
|
'Fantasy',
|
||||||
|
'Historie',
|
||||||
|
'Horror',
|
||||||
|
'Komödie',
|
||||||
|
'Krimi',
|
||||||
|
'Kriegsfilm',
|
||||||
|
'Liebesfilm',
|
||||||
|
'Musik',
|
||||||
|
'Mystery',
|
||||||
|
'Science Fiction',
|
||||||
|
'TV-Film',
|
||||||
|
'Thriller',
|
||||||
'Western',
|
'Western',
|
||||||
].map((genre) => ({ genre, count: null }))
|
].map((genre) => ({ genre, count: null }))
|
||||||
|
|
||||||
export function useFilter({
|
export function useFilter({
|
||||||
activeTab, quelle, netflixMovies, canonMovies, gesehenIds, ohneGesehene,
|
activeTab,
|
||||||
|
quelle,
|
||||||
|
suchArt,
|
||||||
|
netflixMovies,
|
||||||
|
canonMovies,
|
||||||
|
gesehenIds,
|
||||||
|
ohneGesehene,
|
||||||
}) {
|
}) {
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
const sort = ref('random')
|
const sort = ref('random')
|
||||||
// Suche und Ähnlichkeitsuche haben eine eigene Voreinstellung: TMDB liefert
|
// Jede Suche hat ihre eigene sinnvolle Reihenfolge: die Titelsuche die
|
||||||
// dort eine Reihenfolge nach Verwandtschaft — dieselbe, die IMDb unter
|
// von TMDB (wie auf IMDb), Ähnliche gemischt, bei Personen das Neueste
|
||||||
// "More like this" zeigt. Im Katalog gibt es die nicht.
|
// zuerst. Die Wahl bleibt je Suche erhalten.
|
||||||
const suchSort = ref('relevanz')
|
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 include = ref([])
|
||||||
const exclude = ref([])
|
const exclude = ref([])
|
||||||
|
|
||||||
@@ -47,7 +106,9 @@ export function useFilter({
|
|||||||
if (suchReiter()) return SUCH_GRENZEN
|
if (suchReiter()) return SUCH_GRENZEN
|
||||||
const out = {}
|
const out = {}
|
||||||
for (const regler of REGLER) {
|
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 min = werte.length ? Math.min(...werte) : 0
|
||||||
const max = werte.length ? Math.max(...werte) : 0
|
const max = werte.length ? Math.max(...werte) : 0
|
||||||
out[regler.key] = {
|
out[regler.key] = {
|
||||||
@@ -60,9 +121,17 @@ export function useFilter({
|
|||||||
|
|
||||||
const ranges = ref({})
|
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() {
|
function resetRanges() {
|
||||||
ranges.value = Object.fromEntries(
|
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] || []
|
const spanne = (key) => ranges.value[key] || []
|
||||||
return {
|
return {
|
||||||
sort: suchSort.value,
|
sort: suchSort.value,
|
||||||
|
anzahl: suchAnzahl.value,
|
||||||
include: include.value,
|
include: include.value,
|
||||||
exclude: exclude.value,
|
exclude: exclude.value,
|
||||||
stimmen: spanne('vote_count'),
|
stimmen: spanne('vote_count'),
|
||||||
@@ -166,6 +236,7 @@ export function useFilter({
|
|||||||
search,
|
search,
|
||||||
sort,
|
sort,
|
||||||
suchSort,
|
suchSort,
|
||||||
|
suchAnzahl,
|
||||||
include,
|
include,
|
||||||
exclude,
|
exclude,
|
||||||
grenzen,
|
grenzen,
|
||||||
|
|||||||
@@ -11,9 +11,33 @@ function spanne(filter, name, schluessel, ziel) {
|
|||||||
return raus
|
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) {
|
export function filterParameter(filter) {
|
||||||
return {
|
return {
|
||||||
sort: filter.sort || 'rating',
|
sort: filter.sort || 'rating',
|
||||||
|
...(filter.anzahl ? { limit: filter.anzahl } : {}),
|
||||||
include: (filter.include || []).join(','),
|
include: (filter.include || []).join(','),
|
||||||
exclude: (filter.exclude || []).join(','),
|
exclude: (filter.exclude || []).join(','),
|
||||||
...spanne(filter, 'stimmen', 'vote_count', 'stimmen'),
|
...spanne(filter, 'stimmen', 'vote_count', 'stimmen'),
|
||||||
|
|||||||
Reference in New Issue
Block a user