update
This commit is contained in:
@@ -125,12 +125,23 @@ def drop_missing(conn, keep_ids):
|
|||||||
return len(gone)
|
return len(gone)
|
||||||
|
|
||||||
|
|
||||||
|
# Ein Film bringt im Schnitt vierzehn Bildpfade mit, einer sogar 179.
|
||||||
|
# Die Karte zeigt vier. Der Rest kommt beim Öffnen über /api/movies/{id}.
|
||||||
|
KATALOG_BILDER = 6
|
||||||
|
|
||||||
|
|
||||||
def all_movies():
|
def all_movies():
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT * FROM movies ORDER BY vote_count DESC, title ASC"
|
"SELECT * FROM movies ORDER BY vote_count DESC, title ASC"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [row_to_dict(r) for r in rows]
|
filme = []
|
||||||
|
for row in rows:
|
||||||
|
film = row_to_dict(row)
|
||||||
|
if film.get("images"):
|
||||||
|
film["images"] = film["images"][:KATALOG_BILDER]
|
||||||
|
filme.append(film)
|
||||||
|
return filme
|
||||||
|
|
||||||
|
|
||||||
def get_movie(movie_id):
|
def get_movie(movie_id):
|
||||||
|
|||||||
@@ -145,6 +145,75 @@ async def search_movies(q: str):
|
|||||||
return await search.search(q.strip())
|
return await search.search(q.strip())
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/discover")
|
||||||
|
async def stoebern(
|
||||||
|
sort: str = "rating",
|
||||||
|
include: str = "",
|
||||||
|
exclude: str = "",
|
||||||
|
stimmen_min: int | None = None,
|
||||||
|
stimmen_max: int | None = None,
|
||||||
|
dauer_min: int | None = None,
|
||||||
|
dauer_max: int | None = None,
|
||||||
|
jahr_min: int | None = None,
|
||||||
|
jahr_max: int | None = None,
|
||||||
|
note_min: float | None = None,
|
||||||
|
note_max: float | None = None,
|
||||||
|
):
|
||||||
|
"""Suche ohne Suchbegriff: TMDB stöbert nach den Filtern der Leiste."""
|
||||||
|
return await search.discover(
|
||||||
|
{
|
||||||
|
"sort": sort,
|
||||||
|
"include": [g for g in include.split(",") if g],
|
||||||
|
"exclude": [g for g in exclude.split(",") if g],
|
||||||
|
"stimmen_min": stimmen_min,
|
||||||
|
"stimmen_max": stimmen_max,
|
||||||
|
"dauer_min": dauer_min,
|
||||||
|
"dauer_max": dauer_max,
|
||||||
|
"jahr_min": jahr_min,
|
||||||
|
"jahr_max": jahr_max,
|
||||||
|
"note_min": note_min,
|
||||||
|
"note_max": note_max,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/movies/{movie_id}/aehnliche")
|
||||||
|
async def aehnliche_filme(
|
||||||
|
movie_id: int,
|
||||||
|
limit: int = 12,
|
||||||
|
voll: bool = False,
|
||||||
|
sort: str = "",
|
||||||
|
include: str = "",
|
||||||
|
exclude: str = "",
|
||||||
|
stimmen_min: int | None = None,
|
||||||
|
stimmen_max: int | None = None,
|
||||||
|
jahr_min: int | None = None,
|
||||||
|
jahr_max: int | None = None,
|
||||||
|
note_min: float | None = None,
|
||||||
|
note_max: float | None = None,
|
||||||
|
):
|
||||||
|
"""Was Leute noch geschaut haben, die diesen Film mochten."""
|
||||||
|
try:
|
||||||
|
return await search.aehnliche(
|
||||||
|
movie_id,
|
||||||
|
limit=limit,
|
||||||
|
voll=voll,
|
||||||
|
filter_={
|
||||||
|
"sort": sort,
|
||||||
|
"include": [g for g in include.split(",") if g],
|
||||||
|
"exclude": [g for g in exclude.split(",") if g],
|
||||||
|
"stimmen_min": stimmen_min,
|
||||||
|
"stimmen_max": stimmen_max,
|
||||||
|
"jahr_min": jahr_min,
|
||||||
|
"jahr_max": jahr_max,
|
||||||
|
"note_min": note_min,
|
||||||
|
"note_max": note_max,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
@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."""
|
||||||
|
|||||||
@@ -5,10 +5,19 @@ es auch Titel, die nirgends davon stehen — die kommen live von TMDB und
|
|||||||
landen nicht in der Datenbank.
|
landen nicht in der Datenbank.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from .config import LANGUAGE, TMDB_BASE, TMDB_TOKEN
|
from .config import LANGUAGE, TMDB_BASE, TMDB_TOKEN
|
||||||
from .tmdb import DETAIL_PARAMS, pick_backdrops, pick_overview, pick_trailer
|
from .tmdb import (
|
||||||
|
DETAIL_PARAMS,
|
||||||
|
pick_backdrops,
|
||||||
|
pick_overview,
|
||||||
|
pick_title,
|
||||||
|
pick_trailer,
|
||||||
|
)
|
||||||
|
|
||||||
HEADERS = {"Authorization": f"Bearer {TMDB_TOKEN}", "accept": "application/json"}
|
HEADERS = {"Authorization": f"Bearer {TMDB_TOKEN}", "accept": "application/json"}
|
||||||
|
|
||||||
@@ -17,6 +26,19 @@ def client():
|
|||||||
return httpx.AsyncClient(base_url=TMDB_BASE, headers=HEADERS, timeout=30.0)
|
return httpx.AsyncClient(base_url=TMDB_BASE, headers=HEADERS, timeout=30.0)
|
||||||
|
|
||||||
|
|
||||||
|
# Die Namen kommen von TMDB selbst und ändern sich praktisch nie. Sie hier
|
||||||
|
# festzuhalten spart bei jeder Suche einen Abruf.
|
||||||
|
GENRES = {
|
||||||
|
28: "Action", 12: "Abenteuer", 16: "Animation", 35: "Komödie",
|
||||||
|
80: "Krimi", 99: "Dokumentarfilm", 18: "Drama", 10751: "Familie",
|
||||||
|
14: "Fantasy", 36: "Historie", 27: "Horror", 10402: "Musik",
|
||||||
|
9648: "Mystery", 10749: "Liebesfilm", 878: "Science Fiction",
|
||||||
|
10770: "TV-Film", 53: "Thriller", 10752: "Kriegsfilm", 37: "Western",
|
||||||
|
}
|
||||||
|
|
||||||
|
NACH_NAME = {name: kennung for kennung, name in GENRES.items()}
|
||||||
|
|
||||||
|
|
||||||
def as_result(item):
|
def as_result(item):
|
||||||
release = item.get("release_date") or ""
|
release = item.get("release_date") or ""
|
||||||
return {
|
return {
|
||||||
@@ -29,9 +51,127 @@ def as_result(item):
|
|||||||
"poster_path": item.get("poster_path"),
|
"poster_path": item.get("poster_path"),
|
||||||
"vote_average": item.get("vote_average"),
|
"vote_average": item.get("vote_average"),
|
||||||
"vote_count": item.get("vote_count"),
|
"vote_count": item.get("vote_count"),
|
||||||
|
"genres": [GENRES[g] for g in item.get("genre_ids", []) if g in GENRES],
|
||||||
|
# Trefferlisten sind knapp — details_nachladen() füllt nach
|
||||||
|
"images": [],
|
||||||
|
"runtime": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def details_nachladen(http, treffer, je_film=4):
|
||||||
|
"""Bilder, Laufzeit und lesbarer Titel — alle Abrufe gleichzeitig.
|
||||||
|
|
||||||
|
Trefferlisten von TMDB sind knapp: keine Bilder, keine Laufzeit, und
|
||||||
|
bei indischen oder japanischen Filmen steht der Titel in Originalschrift.
|
||||||
|
Das alles steckt in der Detailansicht, ein Abruf je Film reicht.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def hole(film):
|
||||||
|
try:
|
||||||
|
resp = await http.get(
|
||||||
|
f"/movie/{film['id']}",
|
||||||
|
params={
|
||||||
|
"language": LANGUAGE,
|
||||||
|
"append_to_response": "images,translations",
|
||||||
|
"include_image_language": "de,en,null",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
daten = resp.json()
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return
|
||||||
|
|
||||||
|
film["title"] = pick_title(daten)
|
||||||
|
film["runtime"] = daten.get("runtime")
|
||||||
|
backdrops = daten.get("images", {}).get("backdrops", [])
|
||||||
|
# Ohne Sprachkennung steht kein Text im Bild — das sind die Szenen
|
||||||
|
ohne_text = [b for b in backdrops if not b.get("iso_639_1")]
|
||||||
|
film["images"] = [
|
||||||
|
b["file_path"] for b in (ohne_text or backdrops)[:je_film]
|
||||||
|
]
|
||||||
|
|
||||||
|
await asyncio.gather(*(hole(f) for f in treffer))
|
||||||
|
|
||||||
|
|
||||||
|
SORTIERUNG = {
|
||||||
|
"rating": "vote_average.desc",
|
||||||
|
"newest": "primary_release_date.desc",
|
||||||
|
"oldest": "primary_release_date.asc",
|
||||||
|
"title": "title.asc",
|
||||||
|
# TMDB kennt keine Zufallsordnung. Statt dessen holt discover() eine
|
||||||
|
# zufällige Seite aus den Beliebtesten.
|
||||||
|
"random": "popularity.desc",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Weiter hinten liegen nur noch Filme, die kaum jemand kennt
|
||||||
|
MAX_SEITE = 20
|
||||||
|
|
||||||
|
|
||||||
|
async def discover(filter_, limit=40):
|
||||||
|
"""Stöbern ohne Suchbegriff: TMDB sucht nach den Filtern aus der Leiste.
|
||||||
|
|
||||||
|
Anders als /search/movie versteht /discover/movie Genres, Jahre und
|
||||||
|
Bewertungen — damit lässt sich die Leiste direkt weiterreichen.
|
||||||
|
"""
|
||||||
|
params = {
|
||||||
|
"language": LANGUAGE,
|
||||||
|
"include_adult": "false",
|
||||||
|
"sort_by": SORTIERUNG.get(filter_.get("sort"), "popularity.desc"),
|
||||||
|
# Ohne Untergrenze stehen Filme mit einer einzigen Stimme ganz oben
|
||||||
|
"vote_count.gte": filter_.get("stimmen_min") or 50,
|
||||||
|
}
|
||||||
|
einbeziehen = [
|
||||||
|
NACH_NAME[g] for g in (filter_.get("include") or []) if g in NACH_NAME
|
||||||
|
]
|
||||||
|
ausschliessen = [
|
||||||
|
NACH_NAME[g] for g in (filter_.get("exclude") or []) if g in NACH_NAME
|
||||||
|
]
|
||||||
|
if einbeziehen:
|
||||||
|
params["with_genres"] = ",".join(str(g) for g in einbeziehen)
|
||||||
|
if ausschliessen:
|
||||||
|
params["without_genres"] = ",".join(str(g) for g in ausschliessen)
|
||||||
|
|
||||||
|
for schluessel, ziel in (
|
||||||
|
("stimmen_max", "vote_count.lte"),
|
||||||
|
("dauer_min", "with_runtime.gte"),
|
||||||
|
("dauer_max", "with_runtime.lte"),
|
||||||
|
("note_min", "vote_average.gte"),
|
||||||
|
("note_max", "vote_average.lte"),
|
||||||
|
):
|
||||||
|
wert = filter_.get(schluessel)
|
||||||
|
if wert is not None:
|
||||||
|
params[ziel] = wert
|
||||||
|
|
||||||
|
if filter_.get("jahr_min"):
|
||||||
|
params["primary_release_date.gte"] = f"{filter_['jahr_min']}-01-01"
|
||||||
|
if filter_.get("jahr_max"):
|
||||||
|
params["primary_release_date.lte"] = f"{filter_['jahr_max']}-12-31"
|
||||||
|
|
||||||
|
async with client() as http:
|
||||||
|
resp = await http.get("/discover/movie", params=params)
|
||||||
|
resp.raise_for_status()
|
||||||
|
daten = resp.json()
|
||||||
|
|
||||||
|
# Bei "Zufällig" nochmal von einer beliebigen Seite holen — sonst
|
||||||
|
# stehen bei jedem Aufruf dieselben zwanzig Filme oben
|
||||||
|
if filter_.get("sort") == "random":
|
||||||
|
seiten = min(daten.get("total_pages") or 1, MAX_SEITE)
|
||||||
|
if seiten > 1:
|
||||||
|
params["page"] = random.randint(1, seiten)
|
||||||
|
resp = await http.get("/discover/movie", params=params)
|
||||||
|
resp.raise_for_status()
|
||||||
|
daten = resp.json()
|
||||||
|
|
||||||
|
results = daten.get("results", [])
|
||||||
|
if filter_.get("sort") == "random":
|
||||||
|
random.shuffle(results)
|
||||||
|
|
||||||
|
usable = [r for r in results if r.get("poster_path")]
|
||||||
|
treffer = [as_result(r) for r in usable[:limit]]
|
||||||
|
await details_nachladen(http, treffer)
|
||||||
|
return treffer
|
||||||
|
|
||||||
|
|
||||||
async def search(query, limit=24):
|
async def search(query, limit=24):
|
||||||
async with client() as http:
|
async with client() as http:
|
||||||
resp = await http.get(
|
resp = await http.get(
|
||||||
@@ -41,11 +181,154 @@ async def search(query, limit=24):
|
|||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
results = resp.json().get("results", [])
|
results = resp.json().get("results", [])
|
||||||
|
|
||||||
# Ohne Poster taugt eine Kachel nichts, und Namenlose sind fast immer
|
# Ohne Poster taugt eine Kachel nichts, und Namenlose sind fast immer
|
||||||
# Dubletten oder Making-ofs
|
# Dubletten oder Making-ofs
|
||||||
usable = [r for r in results if r.get("poster_path")]
|
usable = [r for r in results if r.get("poster_path")]
|
||||||
usable.sort(key=lambda r: -(r.get("popularity") or 0))
|
usable.sort(key=lambda r: -(r.get("popularity") or 0))
|
||||||
return [as_result(r) for r in usable[:limit]]
|
treffer = [as_result(r) for r in usable[:limit]]
|
||||||
|
await details_nachladen(http, treffer)
|
||||||
|
return treffer
|
||||||
|
|
||||||
|
|
||||||
|
# TMDB hat je Film gut dreißig Seiten Empfehlungen. Weiter hinten wird die
|
||||||
|
# Verwandtschaft dünn, und jede Seite kostet einen Abruf.
|
||||||
|
MAX_AEHNLICH_SEITEN = 8
|
||||||
|
|
||||||
|
|
||||||
|
def passt_zur_leiste(item, filter_):
|
||||||
|
"""Die Filter der Leiste auf einen knappen Treffer anwenden.
|
||||||
|
|
||||||
|
Die Laufzeit fehlt hier noch — die steht erst in der Detailantwort.
|
||||||
|
Nach ihr filtert weiter die Oberfläche.
|
||||||
|
"""
|
||||||
|
genres = {GENRES[g] for g in item.get("genre_ids", []) if g in GENRES}
|
||||||
|
einbeziehen = set(filter_.get("include") or [])
|
||||||
|
ausschliessen = set(filter_.get("exclude") or [])
|
||||||
|
if einbeziehen and not (genres & einbeziehen):
|
||||||
|
return False
|
||||||
|
if genres & ausschliessen:
|
||||||
|
return False
|
||||||
|
|
||||||
|
release = item.get("release_date") or ""
|
||||||
|
jahr = int(release[:4]) if release[:4].isdigit() else None
|
||||||
|
for wert, unten, oben in (
|
||||||
|
(item.get("vote_count"), filter_.get("stimmen_min"), filter_.get("stimmen_max")),
|
||||||
|
(item.get("vote_average"), filter_.get("note_min"), filter_.get("note_max")),
|
||||||
|
(jahr, filter_.get("jahr_min"), filter_.get("jahr_max")),
|
||||||
|
):
|
||||||
|
if wert is None:
|
||||||
|
continue
|
||||||
|
if unten is not None and wert < unten:
|
||||||
|
return False
|
||||||
|
if oben is not None and wert > oben:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def brauchbare(roh, filter_):
|
||||||
|
"""Dubletten und Posterlose raus, dann die Filter drüber."""
|
||||||
|
raus, gesehen = [], set()
|
||||||
|
for item in roh:
|
||||||
|
if not item.get("poster_path") or item["id"] in gesehen:
|
||||||
|
continue
|
||||||
|
gesehen.add(item["id"])
|
||||||
|
if passt_zur_leiste(item, filter_):
|
||||||
|
raus.append(item)
|
||||||
|
return raus
|
||||||
|
|
||||||
|
|
||||||
|
# "relevanz" gehört nicht dazu: dort bleibt TMDBs eigene Ordnung stehen.
|
||||||
|
# Die geht nach Verwandtschaft — dieselbe, die IMDb unter "More like this"
|
||||||
|
# zeigt — und ist für Ähnliche das sinnvollste Erste.
|
||||||
|
UMSORTIERBAR = ("rating", "newest", "oldest", "title", "random")
|
||||||
|
|
||||||
|
|
||||||
|
def sortiert(kandidaten, sort):
|
||||||
|
"""Die Reihenfolge aus der Leiste auf die Kandidaten anwenden."""
|
||||||
|
if sort == "rating":
|
||||||
|
return sorted(kandidaten, key=lambda r: -(r.get("vote_average") or 0))
|
||||||
|
if sort == "newest":
|
||||||
|
return sorted(
|
||||||
|
kandidaten, key=lambda r: r.get("release_date") or "", reverse=True
|
||||||
|
)
|
||||||
|
if sort == "oldest":
|
||||||
|
return sorted(kandidaten, key=lambda r: r.get("release_date") or "9999")
|
||||||
|
if sort == "title":
|
||||||
|
return sorted(kandidaten, key=lambda r: (r.get("title") or "").lower())
|
||||||
|
if sort == "random":
|
||||||
|
gemischt = list(kandidaten)
|
||||||
|
random.shuffle(gemischt)
|
||||||
|
return gemischt
|
||||||
|
return kandidaten
|
||||||
|
|
||||||
|
|
||||||
|
async def aehnliche(movie_id, limit=12, voll=False, filter_=None):
|
||||||
|
"""Was Leute noch geschaut haben, die diesen Film mochten.
|
||||||
|
|
||||||
|
TMDB bietet zwei Wege an. /similar geht nur nach Genre und Schlagwörtern
|
||||||
|
und liefert für einen Horrorfilm hunderttausend Treffer — unbrauchbar.
|
||||||
|
/recommendations beruht auf dem Verhalten der Nutzer und trifft.
|
||||||
|
|
||||||
|
Filtern muss diese Funktion selbst: /recommendations kennt anders als
|
||||||
|
/discover keine Genres oder Jahre. Reicht die erste Seite nicht, holt
|
||||||
|
sie weitere nach — sonst bleiben bei enger Leiste drei Karten übrig.
|
||||||
|
"""
|
||||||
|
filter_ = filter_ or {}
|
||||||
|
sort = filter_.get("sort") or ""
|
||||||
|
|
||||||
|
async with client() as http:
|
||||||
|
|
||||||
|
async def seite(nr):
|
||||||
|
try:
|
||||||
|
resp = await http.get(
|
||||||
|
f"/movie/{movie_id}/recommendations",
|
||||||
|
params={"language": LANGUAGE, "page": nr},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
erste = await seite(1)
|
||||||
|
roh = list(erste.get("results", []))
|
||||||
|
seiten = min(erste.get("total_pages") or 1, MAX_AEHNLICH_SEITEN)
|
||||||
|
|
||||||
|
# Alle übrigen Seiten gleichzeitig — nacheinander wären das bei
|
||||||
|
# enger Leiste sieben Wartezeiten hintereinander.
|
||||||
|
# Auch beim Sortieren: die besten zwanzig von zwanzig wären keine.
|
||||||
|
zu_wenig = len(brauchbare(roh, filter_)) < limit
|
||||||
|
if (zu_wenig or sort in UMSORTIERBAR) and seiten > 1:
|
||||||
|
for daten in await asyncio.gather(
|
||||||
|
*(seite(nr) for nr in range(2, seiten + 1))
|
||||||
|
):
|
||||||
|
roh.extend(daten.get("results", []))
|
||||||
|
|
||||||
|
kandidaten = sortiert(brauchbare(roh, filter_), sort)
|
||||||
|
treffer = [as_result(r) for r in kandidaten[:limit]]
|
||||||
|
# Für die Kachelreihe reicht der Titel, für Karten braucht es mehr
|
||||||
|
if voll:
|
||||||
|
await details_nachladen(http, treffer)
|
||||||
|
else:
|
||||||
|
await asyncio.gather(*(titel_richten(http, f) for f in treffer))
|
||||||
|
return treffer
|
||||||
|
|
||||||
|
|
||||||
|
async def titel_richten(http, film):
|
||||||
|
"""Originalschrift durch den englischen Titel ersetzen."""
|
||||||
|
if not any(
|
||||||
|
z.isalpha() and not ("a" <= z.lower() <= "z" or z in "äöüßàâçéèêëîïôùûÿñæœ")
|
||||||
|
for z in film["title"]
|
||||||
|
):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
resp = await http.get(
|
||||||
|
f"/movie/{film['id']}",
|
||||||
|
params={"language": LANGUAGE, "append_to_response": "translations"},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
film["title"] = pick_title(resp.json())
|
||||||
|
except httpx.HTTPError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def detail(movie_id):
|
async def detail(movie_id):
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ def build_movie(data):
|
|||||||
"collection_id": collection.get("id"),
|
"collection_id": collection.get("id"),
|
||||||
"collection_name": collection.get("name"),
|
"collection_name": collection.get("name"),
|
||||||
"id": data["id"],
|
"id": data["id"],
|
||||||
"title": data.get("title") or data.get("original_title") or "Ohne Titel",
|
"title": tmdb.pick_title(data),
|
||||||
"original_title": data.get("original_title"),
|
"original_title": data.get("original_title"),
|
||||||
"release_date": release or None,
|
"release_date": release or None,
|
||||||
"year": int(release[:4]) if release[:4].isdigit() else None,
|
"year": int(release[:4]) if release[:4].isdigit() else None,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import unicodedata
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from .config import (
|
from .config import (
|
||||||
@@ -63,6 +65,36 @@ async def detail(http, movie_id):
|
|||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _fremde_schrift(text):
|
||||||
|
"""Steht der Titel in einer nichtlateinischen Schrift?"""
|
||||||
|
for zeichen in text or "":
|
||||||
|
if zeichen.isalpha() and "LATIN" not in unicodedata.name(zeichen, ""):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def pick_title(data):
|
||||||
|
"""Deutscher Titel, sonst der englische aus den Translations.
|
||||||
|
|
||||||
|
Für indische, chinesische und koreanische Filme hat TMDB oft keine
|
||||||
|
deutsche Fassung und gibt den Originaltitel zurück — in Originalschrift.
|
||||||
|
Damit kann hier niemand etwas anfangen; IMDb zeigt an der Stelle den
|
||||||
|
umschriebenen Titel, und den liefert TMDB in den Translations mit.
|
||||||
|
"""
|
||||||
|
titel = data.get("title") or data.get("original_title") or ""
|
||||||
|
if titel and not _fremde_schrift(titel):
|
||||||
|
return titel
|
||||||
|
|
||||||
|
for entry in data.get("translations", {}).get("translations", []):
|
||||||
|
if entry.get("iso_639_1") != "en":
|
||||||
|
continue
|
||||||
|
englisch = entry.get("data", {}).get("title")
|
||||||
|
if englisch and not _fremde_schrift(englisch):
|
||||||
|
return englisch
|
||||||
|
|
||||||
|
return titel or "Ohne Titel"
|
||||||
|
|
||||||
|
|
||||||
def pick_overview(data):
|
def pick_overview(data):
|
||||||
"""Deutsche Synopsis, sonst englische aus den Translations."""
|
"""Deutsche Synopsis, sonst englische aus den Translations."""
|
||||||
if data.get("overview"):
|
if data.get("overview"):
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
|||||||
import { abonnieren } from './live'
|
import { abonnieren } from './live'
|
||||||
import MovieCard from './components/MovieCard.vue'
|
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 MerklisteTab from './components/MerklisteTab.vue'
|
import MerklisteTab from './components/MerklisteTab.vue'
|
||||||
import MovieDetail from './components/MovieDetail.vue'
|
import MovieDetail from './components/MovieDetail.vue'
|
||||||
import NamensWahl from './components/NamensWahl.vue'
|
import NamensWahl from './components/NamensWahl.vue'
|
||||||
@@ -20,6 +21,10 @@ const loading = ref(true)
|
|||||||
const error = ref('')
|
const error = ref('')
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
const sort = ref('random')
|
const sort = ref('random')
|
||||||
|
// Suche und Ähnlichkeitsuche haben eine eigene Voreinstellung: TMDB liefert
|
||||||
|
// dort eine Reihenfolge nach Verwandtschaft — dieselbe, die IMDb unter
|
||||||
|
// "More like this" zeigt. Im Katalog gibt es die nicht.
|
||||||
|
const suchSort = ref('relevanz')
|
||||||
|
|
||||||
// Kompakt zeigt viele Karten nebeneinander, Details dafür mehr je Film
|
// Kompakt zeigt viele Karten nebeneinander, Details dafür mehr je Film
|
||||||
const ANSICHT_SCHLUESSEL = 'filmabend_ansicht'
|
const ANSICHT_SCHLUESSEL = 'filmabend_ansicht'
|
||||||
@@ -27,11 +32,29 @@ const ansicht = ref(localStorage.getItem(ANSICHT_SCHLUESSEL) || 'kompakt')
|
|||||||
|
|
||||||
watch(ansicht, (wert) => localStorage.setItem(ANSICHT_SCHLUESSEL, wert))
|
watch(ansicht, (wert) => localStorage.setItem(ANSICHT_SCHLUESSEL, wert))
|
||||||
const selected = ref(null)
|
const selected = ref(null)
|
||||||
|
|
||||||
|
// Der Katalog liefert nur sechs Bildpfade je Film. Beim Öffnen holen wir
|
||||||
|
// den vollen Datensatz nach — die Ansicht steht sofort, die restlichen
|
||||||
|
// Bilder kommen gleich danach.
|
||||||
|
//
|
||||||
|
// Über /api/search, nicht über /api/movies: Empfehlungen führen auch zu
|
||||||
|
// Filmen außerhalb des Katalogs. Dort greift der Rückgriff auf TMDB,
|
||||||
|
// sonst fehlten Trailer, Bilder, Laufzeit und der IMDb-Knopf.
|
||||||
|
async function filmOeffnen(movie) {
|
||||||
|
selected.value = movie
|
||||||
|
if (!movie?.id) return
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/search/${movie.id}`)
|
||||||
|
if (resp.ok && selected.value?.id === movie.id) selected.value = await resp.json()
|
||||||
|
} catch {
|
||||||
|
/* dann eben mit den Listendaten */
|
||||||
|
}
|
||||||
|
}
|
||||||
// Nach dem Neuladen soll derselbe Reiter offen sein
|
// Nach dem Neuladen soll derselbe Reiter offen sein
|
||||||
const TAB_SCHLUESSEL = 'filmabend_tab'
|
const TAB_SCHLUESSEL = 'filmabend_tab'
|
||||||
const TAB_IDS = [
|
const TAB_IDS = [
|
||||||
'netflix', 'canon', 'search', 'wheel', 'merkliste', 'watched', 'wuensche',
|
'netflix', 'canon', 'search', 'aehnlich', 'wheel', 'merkliste', 'watched',
|
||||||
'verwaltung', 'stream',
|
'wuensche', 'verwaltung', 'stream',
|
||||||
]
|
]
|
||||||
const gemerkterTab = localStorage.getItem(TAB_SCHLUESSEL)
|
const gemerkterTab = localStorage.getItem(TAB_SCHLUESSEL)
|
||||||
const activeTab = ref(TAB_IDS.includes(gemerkterTab) ? gemerkterTab : 'netflix')
|
const activeTab = ref(TAB_IDS.includes(gemerkterTab) ? gemerkterTab : 'netflix')
|
||||||
@@ -54,7 +77,25 @@ const REGLER = [
|
|||||||
{ key: 'vote_average', label: 'Bewertung', feld: 'vote_average', step: 0.1, nachkomma: 1 },
|
{ key: 'vote_average', label: 'Bewertung', feld: 'vote_average', step: 0.1, nachkomma: 1 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Bei der Suche steht kein Katalog dahinter, aus dem sich Grenzen ableiten
|
||||||
|
// ließen — TMDB reicht von den Anfängen bis heute
|
||||||
|
const SUCH_GRENZEN = {
|
||||||
|
vote_count: { min: 50, max: 50000 },
|
||||||
|
runtime: { min: 0, max: 240 },
|
||||||
|
year: { min: 1900, max: new Date().getFullYear() + 1 },
|
||||||
|
vote_average: { min: 0, max: 10 },
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die 19 Genres, die TMDB kennt
|
||||||
|
const SUCH_GENRES = [
|
||||||
|
'Abenteuer', 'Action', 'Animation', 'Dokumentarfilm', 'Drama', 'Familie',
|
||||||
|
'Fantasy', 'Historie', 'Horror', 'Komödie', 'Krimi', 'Kriegsfilm',
|
||||||
|
'Liebesfilm', 'Musik', 'Mystery', 'Science Fiction', 'TV-Film', 'Thriller',
|
||||||
|
'Western',
|
||||||
|
].map((genre) => ({ genre, count: null }))
|
||||||
|
|
||||||
const grenzen = computed(() => {
|
const grenzen = computed(() => {
|
||||||
|
if (activeTab.value === 'search' || activeTab.value === 'aehnlich') return SUCH_GRENZEN
|
||||||
const pool = activeTab.value === 'netflix' ? netflixMovies.value : canonMovies.value
|
const pool = activeTab.value === 'netflix' ? netflixMovies.value : canonMovies.value
|
||||||
const out = {}
|
const out = {}
|
||||||
for (const regler of REGLER) {
|
for (const regler of REGLER) {
|
||||||
@@ -135,6 +176,7 @@ function resetGenres() {
|
|||||||
|
|
||||||
// Nur Genres anbieten, die im offenen Reiter auch Treffer haben
|
// Nur Genres anbieten, die im offenen Reiter auch Treffer haben
|
||||||
const genreOptions = computed(() => {
|
const genreOptions = computed(() => {
|
||||||
|
if (activeTab.value === 'search' || activeTab.value === 'aehnlich') return SUCH_GENRES
|
||||||
const pool = activeTab.value === 'netflix' ? netflixMovies.value : canonMovies.value
|
const pool = activeTab.value === 'netflix' ? netflixMovies.value : canonMovies.value
|
||||||
const counts = new Map()
|
const counts = new Map()
|
||||||
for (const movie of pool) {
|
for (const movie of pool) {
|
||||||
@@ -213,6 +255,7 @@ const tabs = computed(() => [
|
|||||||
{ id: 'netflix', label: 'Netflix', count: netflixMovies.value.length },
|
{ id: 'netflix', label: 'Netflix', count: netflixMovies.value.length },
|
||||||
{ id: 'canon', label: 'Horror-Kanon', count: canonGroups.value.length },
|
{ id: 'canon', label: 'Horror-Kanon', count: canonGroups.value.length },
|
||||||
{ id: 'search', label: 'Suche', count: null },
|
{ id: 'search', label: 'Suche', count: null },
|
||||||
|
{ id: 'aehnlich', label: 'Ähnlichkeitsuche', count: null },
|
||||||
...(host.value
|
...(host.value
|
||||||
? []
|
? []
|
||||||
: [{ id: 'merkliste', label: 'Sammlungen', count: null }]),
|
: [{ id: 'merkliste', label: 'Sammlungen', count: null }]),
|
||||||
@@ -223,6 +266,30 @@ const tabs = computed(() => [
|
|||||||
...(host.value ? [{ id: 'verwaltung', label: 'Verwaltung', count: null }] : []),
|
...(host.value ? [{ id: 'verwaltung', label: 'Verwaltung', count: null }] : []),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// Was die Leiste eingestellt hat — der Suche-Reiter schickt es an TMDB
|
||||||
|
const suchFilter = computed(() => {
|
||||||
|
const spanne = (key) => ranges.value[key] || []
|
||||||
|
return {
|
||||||
|
sort: suchSort.value,
|
||||||
|
include: include.value,
|
||||||
|
exclude: exclude.value,
|
||||||
|
stimmen: spanne('vote_count'),
|
||||||
|
dauer: spanne('runtime'),
|
||||||
|
jahr: spanne('year'),
|
||||||
|
note: spanne('vote_average'),
|
||||||
|
grenzen: grenzen.value,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Aus der Filmansicht heraus zur Ähnlichkeitsuche springen
|
||||||
|
const aehnlichStart = ref(null)
|
||||||
|
|
||||||
|
function zurAehnlichkeitsuche(movie) {
|
||||||
|
aehnlichStart.value = movie
|
||||||
|
selected.value = null
|
||||||
|
activeTab.value = 'aehnlich'
|
||||||
|
}
|
||||||
|
|
||||||
const rad = ref(null)
|
const rad = ref(null)
|
||||||
|
|
||||||
// Bei 2500 Karten kommt man sonst nicht mehr nach oben
|
// Bei 2500 Karten kommt man sonst nicht mehr nach oben
|
||||||
@@ -515,7 +582,7 @@ onUnmounted(() => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="page" :class="{
|
<div class="page" :class="{
|
||||||
breit: [
|
breit: [
|
||||||
'search', 'wheel', 'stream', 'watched', 'merkliste', 'wuensche', 'verwaltung',
|
'wheel', 'stream', 'watched', 'merkliste', 'wuensche', 'verwaltung',
|
||||||
].includes(activeTab),
|
].includes(activeTab),
|
||||||
}">
|
}">
|
||||||
<nav class="tabs">
|
<nav class="tabs">
|
||||||
@@ -533,7 +600,39 @@ onUnmounted(() => {
|
|||||||
<span v-else class="host">Host</span>
|
<span v-else class="host">Host</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<SearchTab v-if="activeTab === 'search'" @open="selected = $event" />
|
<div v-if="activeTab === 'search' || activeTab === 'aehnlich'" class="layout">
|
||||||
|
<SearchTab
|
||||||
|
v-if="activeTab === 'search'"
|
||||||
|
:filter="suchFilter"
|
||||||
|
:ansicht="ansicht"
|
||||||
|
@open="selected = $event"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AehnlichTab
|
||||||
|
v-else
|
||||||
|
:filter="suchFilter"
|
||||||
|
:ansicht="ansicht"
|
||||||
|
:start="aehnlichStart"
|
||||||
|
@open="filmOeffnen"
|
||||||
|
@gestartet="aehnlichStart = null"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FilterSidebar
|
||||||
|
v-model:sort="suchSort"
|
||||||
|
v-model:ansicht="ansicht"
|
||||||
|
v-model:ranges="ranges"
|
||||||
|
:regler="REGLER"
|
||||||
|
:grenzen="grenzen"
|
||||||
|
:options="genreOptions"
|
||||||
|
:include="include"
|
||||||
|
:exclude="exclude"
|
||||||
|
:ohne-suche="true"
|
||||||
|
:mit-relevanz="true"
|
||||||
|
@toggle="toggleGenre"
|
||||||
|
@all="toggleAll"
|
||||||
|
@reset="resetGenres"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<WheelTab
|
<WheelTab
|
||||||
v-else-if="activeTab === 'wheel'"
|
v-else-if="activeTab === 'wheel'"
|
||||||
@@ -591,7 +690,7 @@ onUnmounted(() => {
|
|||||||
:movie="entry.parts[0]"
|
:movie="entry.parts[0]"
|
||||||
:auf-dem-rad="aufDemRad.includes(entry.parts[0].id)"
|
:auf-dem-rad="aufDemRad.includes(entry.parts[0].id)"
|
||||||
:ansicht="ansicht"
|
:ansicht="ansicht"
|
||||||
@open="selected = $event"
|
@open="filmOeffnen"
|
||||||
/>
|
/>
|
||||||
<SeriesCard
|
<SeriesCard
|
||||||
v-else
|
v-else
|
||||||
@@ -599,7 +698,7 @@ onUnmounted(() => {
|
|||||||
:start="entry.best"
|
:start="entry.best"
|
||||||
:auf-dem-rad="aufDemRad"
|
:auf-dem-rad="aufDemRad"
|
||||||
:ansicht="ansicht"
|
:ansicht="ansicht"
|
||||||
@open="selected = $event"
|
@open="filmOeffnen"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -631,7 +730,7 @@ onUnmounted(() => {
|
|||||||
:movie="selected"
|
:movie="selected"
|
||||||
:series="series"
|
:series="series"
|
||||||
@close="selected = null"
|
@close="selected = null"
|
||||||
@open="selected = $event"
|
@open="filmOeffnen"
|
||||||
:mein-vorschlag="meinVorschlag"
|
:mein-vorschlag="meinVorschlag"
|
||||||
:host="host"
|
:host="host"
|
||||||
:gesehen="gesehen.some((e) => e.movie_id === selected.id)"
|
:gesehen="gesehen.some((e) => e.movie_id === selected.id)"
|
||||||
@@ -639,6 +738,7 @@ onUnmounted(() => {
|
|||||||
@gemerkt="ladeMerkliste"
|
@gemerkt="ladeMerkliste"
|
||||||
@suggested="nachVorschlag"
|
@suggested="nachVorschlag"
|
||||||
@watched="ladeGesehen"
|
@watched="ladeGesehen"
|
||||||
|
@aehnlich="zurAehnlichkeitsuche"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -662,10 +762,11 @@ onUnmounted(() => {
|
|||||||
z-index: 10;
|
z-index: 10;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
|
align-items: stretch;
|
||||||
/* Der negative Rand deckt das Polster der Seite ab, sonst scrollen
|
/* Der negative Rand deckt das Polster der Seite ab, sonst scrollen
|
||||||
Karten oberhalb der Leiste durchs Bild */
|
Karten oberhalb der Leiste durchs Bild */
|
||||||
margin: -1.5rem -1.25rem 1.25rem;
|
margin: -1.5rem -1.25rem 1.25rem;
|
||||||
padding: 0.7rem 1.25rem 0;
|
padding: 0 1.25rem;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
@@ -694,7 +795,7 @@ onUnmounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
padding: 0.6rem 1rem;
|
padding: 0.8rem 1rem 0.6rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
border-bottom: 2px solid transparent;
|
border-bottom: 2px solid transparent;
|
||||||
|
|||||||
288
frontend/src/components/AehnlichTab.vue
Normal file
288
frontend/src/components/AehnlichTab.vue
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import MovieCard from './MovieCard.vue'
|
||||||
|
import { filterParameter } from '../filter.js'
|
||||||
|
|
||||||
|
// Der Ausgangsfilm steht als erste Karte davor, macht einundzwanzig
|
||||||
|
const TREFFER = 20
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
filter: { type: Object, default: () => ({}) },
|
||||||
|
ansicht: { type: String, default: 'kompakt' },
|
||||||
|
// Kommt aus der Filmansicht: von diesem Film soll es losgehen
|
||||||
|
start: { type: Object, default: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['open', 'gestartet'])
|
||||||
|
|
||||||
|
const query = ref('')
|
||||||
|
const vorschlaege = ref([])
|
||||||
|
const gewaehlt = ref(null)
|
||||||
|
const treffer = ref([])
|
||||||
|
const laden = ref(false)
|
||||||
|
const fehler = ref('')
|
||||||
|
|
||||||
|
let timer = null
|
||||||
|
let filterTimer = null
|
||||||
|
let laufende = 0
|
||||||
|
let laufendeWahl = 0
|
||||||
|
let laufendeTreffer = 0
|
||||||
|
|
||||||
|
// Erst suchen, dann einen Film auswählen — danach zeigt der Reiter, was
|
||||||
|
// dazu passt.
|
||||||
|
async function suchen(term) {
|
||||||
|
if (term.trim().length < 2) {
|
||||||
|
vorschlaege.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const meine = ++laufende
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/search?q=${encodeURIComponent(term.trim())}`)
|
||||||
|
if (!resp.ok) throw new Error('Suche fehlgeschlagen')
|
||||||
|
const daten = await resp.json()
|
||||||
|
if (meine !== laufende) return
|
||||||
|
vorschlaege.value = daten.slice(0, 8)
|
||||||
|
} catch (err) {
|
||||||
|
if (meine === laufende) fehler.value = err.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waehlen(film) {
|
||||||
|
const meine = ++laufendeWahl
|
||||||
|
gewaehlt.value = film
|
||||||
|
vorschlaege.value = []
|
||||||
|
query.value = ''
|
||||||
|
nachladen()
|
||||||
|
|
||||||
|
// Den vollen Datensatz holen, damit die erste Karte alles zeigt.
|
||||||
|
// Läuft neben den Ähnlichen her, nicht davor.
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/search/${film.id}`)
|
||||||
|
if (resp.ok && meine === laufendeWahl) gewaehlt.value = await resp.json()
|
||||||
|
} catch {
|
||||||
|
/* dann bleibt der Datensatz aus der Trefferliste */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Filter gehen ans Backend: /recommendations kennt sie nicht, also
|
||||||
|
// blättert es dort so lange nach, bis zwanzig passende beisammen sind.
|
||||||
|
async function nachladen() {
|
||||||
|
const film = gewaehlt.value
|
||||||
|
if (!film) return
|
||||||
|
const meine = ++laufendeTreffer
|
||||||
|
// Erst wegräumen: sonst stehen unter der neuen Überschrift noch die
|
||||||
|
// Treffer von vorhin, bis die Antwort da ist
|
||||||
|
treffer.value = []
|
||||||
|
laden.value = true
|
||||||
|
fehler.value = ''
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
limit: TREFFER,
|
||||||
|
voll: 'true',
|
||||||
|
...filterParameter(props.filter),
|
||||||
|
})
|
||||||
|
const resp = await fetch(`/api/movies/${film.id}/aehnliche?${params}`)
|
||||||
|
if (!resp.ok) throw new Error('Konnte nichts Ähnliches finden')
|
||||||
|
const daten = await resp.json()
|
||||||
|
// Eine ältere Anfrage darf ein neueres Ergebnis nicht überschreiben
|
||||||
|
if (meine !== laufendeTreffer) return
|
||||||
|
treffer.value = daten
|
||||||
|
} catch (err) {
|
||||||
|
if (meine === laufendeTreffer) fehler.value = err.message
|
||||||
|
} finally {
|
||||||
|
if (meine === laufendeTreffer) laden.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Genres, Jahr, Stimmen und Note filtert schon das Backend. Übrig bleibt
|
||||||
|
// die Laufzeit: die steht erst in der Detailantwort, nicht in der Liste.
|
||||||
|
function passt(movie) {
|
||||||
|
const werte = props.filter.dauer
|
||||||
|
const grenze = props.filter.grenzen?.runtime
|
||||||
|
if (!werte || !grenze || movie.runtime == null) return true
|
||||||
|
if (werte[0] > grenze.min && movie.runtime < werte[0]) return false
|
||||||
|
if (werte[1] < grenze.max && movie.runtime > werte[1]) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.start,
|
||||||
|
(film) => {
|
||||||
|
if (!film) return
|
||||||
|
waehlen(film)
|
||||||
|
emit('gestartet')
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Am Schieberegler hängen viele Ereignisse — erst abwarten, dann holen
|
||||||
|
watch(
|
||||||
|
() => props.filter,
|
||||||
|
() => {
|
||||||
|
if (!gewaehlt.value) return
|
||||||
|
clearTimeout(filterTimer)
|
||||||
|
filterTimer = setTimeout(nachladen, 350)
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(query, (term) => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = setTimeout(() => suchen(term), 300)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function oeffnen(movie) {
|
||||||
|
const resp = await fetch(`/api/search/${movie.id}`)
|
||||||
|
if (resp.ok) emit('open', await resp.json())
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="aehnlich-tab">
|
||||||
|
<!-- Die Suchleiste bleibt oben stehen: von hier geht es jederzeit
|
||||||
|
zu einem anderen Ausgangsfilm -->
|
||||||
|
<div class="suchzeile">
|
||||||
|
<input
|
||||||
|
v-model="query"
|
||||||
|
type="search"
|
||||||
|
class="big-search"
|
||||||
|
placeholder="Von welchem Film ausgehen?"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="query"
|
||||||
|
type="button"
|
||||||
|
class="leeren"
|
||||||
|
title="Suche leeren"
|
||||||
|
@click="query = ''"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tippt jemand, stehen die Vorschläge vorn, bis einer gewählt ist -->
|
||||||
|
<template v-if="query.trim().length >= 2">
|
||||||
|
<p v-if="!vorschlaege.length" class="note">Nichts gefunden.</p>
|
||||||
|
<div v-else class="grid" :class="ansicht">
|
||||||
|
<MovieCard
|
||||||
|
v-for="film in vorschlaege"
|
||||||
|
:key="film.id"
|
||||||
|
:movie="film"
|
||||||
|
:ansicht="ansicht"
|
||||||
|
@open="waehlen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="gewaehlt">
|
||||||
|
<p class="label">Ähnlich wie „{{ gewaehlt.title }}“</p>
|
||||||
|
|
||||||
|
<p v-if="laden" class="note">Suche läuft…</p>
|
||||||
|
<p v-else-if="fehler" class="note error">{{ fehler }}</p>
|
||||||
|
<p v-else-if="!treffer.filter(passt).length" class="note">
|
||||||
|
Nichts gefunden. Stell die Filter weiter.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="!laden" class="grid" :class="ansicht">
|
||||||
|
<MovieCard
|
||||||
|
:movie="gewaehlt"
|
||||||
|
:ansicht="ansicht"
|
||||||
|
class="ausgangsfilm"
|
||||||
|
@open="oeffnen"
|
||||||
|
/>
|
||||||
|
<MovieCard
|
||||||
|
v-for="film in treffer.filter(passt)"
|
||||||
|
:key="film.id"
|
||||||
|
:movie="film"
|
||||||
|
:ansicht="ansicht"
|
||||||
|
@open="oeffnen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p v-else class="note">
|
||||||
|
Such einen Film. Danach zeigt dir dieser Reiter, was dazu passt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.aehnlich-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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jahr {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
color: #f5c518;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
margin-bottom: 1.1rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.09em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Der Ausgangsfilm steht vorn und hebt sich ab */
|
||||||
|
.ausgangsfilm {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.aehnlich-tab > .note {
|
||||||
|
display: block;
|
||||||
|
padding: 2rem 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aehnlich-tab > .note.error {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -8,6 +8,10 @@ const props = defineProps({
|
|||||||
exclude: { type: Array, required: true },
|
exclude: { type: Array, required: true },
|
||||||
regler: { type: Array, default: () => [] },
|
regler: { type: Array, default: () => [] },
|
||||||
grenzen: { type: Object, default: () => ({}) },
|
grenzen: { type: Object, default: () => ({}) },
|
||||||
|
// Der Suche-Reiter hat ein eigenes, großes Suchfeld über der Liste
|
||||||
|
ohneSuche: Boolean,
|
||||||
|
// Nur wo TMDB die Reihenfolge liefert: Suche und Ähnlichkeitsuche
|
||||||
|
mitRelevanz: Boolean,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['toggle', 'all', 'reset'])
|
const emit = defineEmits(['toggle', 'all', 'reset'])
|
||||||
@@ -46,8 +50,14 @@ function beschriften(regler) {
|
|||||||
<template>
|
<template>
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<input v-model="search" type="search" placeholder="Titel suchen…" />
|
<input
|
||||||
|
v-if="!ohneSuche"
|
||||||
|
v-model="search"
|
||||||
|
type="search"
|
||||||
|
placeholder="Titel suchen…"
|
||||||
|
/>
|
||||||
<select v-model="sort">
|
<select v-model="sort">
|
||||||
|
<option v-if="mitRelevanz" value="relevanz">Wie auf IMDb</option>
|
||||||
<option value="random">Zufällig</option>
|
<option value="random">Zufällig</option>
|
||||||
<option value="rating">Beste Bewertung</option>
|
<option value="rating">Beste Bewertung</option>
|
||||||
<option value="newest">Neueste zuerst</option>
|
<option value="newest">Neueste zuerst</option>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ async function oeffnen(item) {
|
|||||||
<template>
|
<template>
|
||||||
<div class="merkliste-tab">
|
<div class="merkliste-tab">
|
||||||
<p v-if="!items.length" class="note">
|
<p v-if="!items.length" class="note">
|
||||||
Noch nichts gemerkt. Öffne einen Film und klicke auf „Merken“.
|
Noch nichts gemerkt. Öffne einen Film und klicke auf „Zur Sammlung“.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<section v-for="gruppe in gruppen" :key="gruppe.titel">
|
<section v-for="gruppe in gruppen" :key="gruppe.titel">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { backdropUrl, posterUrl } from '../images'
|
import { backdropUrl, posterUrl } from '../images'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -11,7 +11,9 @@ const props = defineProps({
|
|||||||
gemerkt: { type: Boolean, default: false },
|
gemerkt: { type: Boolean, default: false },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['close', 'open', 'suggested', 'watched', 'gemerkt'])
|
const emit = defineEmits([
|
||||||
|
'close', 'open', 'suggested', 'watched', 'gemerkt', 'aehnlich',
|
||||||
|
])
|
||||||
|
|
||||||
const vorschlagen = ref({ zustand: 'bereit' })
|
const vorschlagen = ref({ zustand: 'bereit' })
|
||||||
|
|
||||||
@@ -19,6 +21,18 @@ const schonVorgeschlagen = computed(
|
|||||||
() => props.meinVorschlag?.movie_id === props.movie.id
|
() => props.meinVorschlag?.movie_id === props.movie.id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Der eigene Zustand geht vor: nach einem Klick steht der Knopf sofort
|
||||||
|
// richtig, ohne auf die neu geladene Vorschlagsliste zu warten.
|
||||||
|
const aufDemRad = computed(() => {
|
||||||
|
if (vorschlagen.value.zustand === 'fertig') return true
|
||||||
|
if (vorschlagen.value.zustand === 'weg') return false
|
||||||
|
return schonVorgeschlagen.value
|
||||||
|
})
|
||||||
|
|
||||||
|
function radUmschalten() {
|
||||||
|
return aufDemRad.value ? zuruecknehmen() : abschicken()
|
||||||
|
}
|
||||||
|
|
||||||
async function merkenUmschalten() {
|
async function merkenUmschalten() {
|
||||||
const weg = props.gemerkt
|
const weg = props.gemerkt
|
||||||
await fetch(weg ? `/api/wishlist/${props.movie.id}` : '/api/wishlist', {
|
await fetch(weg ? `/api/wishlist/${props.movie.id}` : '/api/wishlist', {
|
||||||
@@ -34,7 +48,7 @@ async function zuruecknehmen() {
|
|||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/suggestions', { method: 'DELETE' })
|
const resp = await fetch('/api/suggestions', { method: 'DELETE' })
|
||||||
if (!resp.ok) throw new Error()
|
if (!resp.ok) throw new Error()
|
||||||
vorschlagen.value = { zustand: 'bereit' }
|
vorschlagen.value = { zustand: 'weg' }
|
||||||
emit('suggested')
|
emit('suggested')
|
||||||
} catch {
|
} catch {
|
||||||
vorschlagen.value = { zustand: 'fehler' }
|
vorschlagen.value = { zustand: 'fehler' }
|
||||||
@@ -80,6 +94,28 @@ const shots = computed(() =>
|
|||||||
(props.movie.images || []).map((path) => backdropUrl(path, 'w780'))
|
(props.movie.images || []).map((path) => backdropUrl(path, 'w780'))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Was Leute noch geschaut haben, die diesen Film mochten
|
||||||
|
const aehnliche = ref([])
|
||||||
|
|
||||||
|
async function ladeAehnliche(id) {
|
||||||
|
aehnliche.value = []
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/movies/${id}/aehnliche`)
|
||||||
|
if (resp.ok && props.movie.id === id) aehnliche.value = await resp.json()
|
||||||
|
} catch {
|
||||||
|
/* dann eben ohne */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.movie.id,
|
||||||
|
(id) => {
|
||||||
|
vorschlagen.value = { zustand: 'bereit' }
|
||||||
|
ladeAehnliche(id)
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
// Ein angeklicktes Bild legt sich in voller Größe über die Ansicht
|
// Ein angeklicktes Bild legt sich in voller Größe über die Ansicht
|
||||||
const grossesBild = ref(null)
|
const grossesBild = ref(null)
|
||||||
|
|
||||||
@@ -107,13 +143,23 @@ function openPart(part) {
|
|||||||
document.querySelector('.backdrop')?.scrollTo({ top: 0 })
|
document.querySelector('.backdrop')?.scrollTo({ top: 0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Die Sperre muss aufs Wurzelelement: den Bildlauf macht hier <html>,
|
||||||
|
// und an <body> allein läuft die Seite hinter der Ansicht weiter mit.
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('keydown', onKey)
|
document.addEventListener('keydown', onKey)
|
||||||
|
const wurzel = document.documentElement
|
||||||
|
// Mit der Sperre verschwindet die Bildlaufleiste. Ohne Ausgleich
|
||||||
|
// rutscht die ganze Seite um deren Breite zur Seite.
|
||||||
|
const leiste = window.innerWidth - wurzel.clientWidth
|
||||||
|
wurzel.style.overflow = 'hidden'
|
||||||
|
wurzel.style.paddingRight = leiste ? `${leiste}px` : ''
|
||||||
document.body.style.overflow = 'hidden'
|
document.body.style.overflow = 'hidden'
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('keydown', onKey)
|
document.removeEventListener('keydown', onKey)
|
||||||
|
document.documentElement.style.overflow = ''
|
||||||
|
document.documentElement.style.paddingRight = ''
|
||||||
document.body.style.overflow = ''
|
document.body.style.overflow = ''
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -141,7 +187,7 @@ onUnmounted(() => {
|
|||||||
</p>
|
</p>
|
||||||
<p class="genres">{{ (movie.genres || []).join(' · ') }}</p>
|
<p class="genres">{{ (movie.genres || []).join(' · ') }}</p>
|
||||||
<p v-if="movie.teaser" class="teaser">{{ movie.teaser }}</p>
|
<p v-if="movie.teaser" class="teaser">{{ movie.teaser }}</p>
|
||||||
<div class="spalte-links">
|
<div class="links">
|
||||||
<a
|
<a
|
||||||
v-if="movie.on_netflix"
|
v-if="movie.on_netflix"
|
||||||
class="netflix"
|
class="netflix"
|
||||||
@@ -162,31 +208,28 @@ onUnmounted(() => {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!host" class="vorschlag">
|
<div class="vorschlag">
|
||||||
<button type="button" class="merken" :class="{ an: gemerkt }" @click="merkenUmschalten">
|
<button type="button" class="merken" @click="$emit('aehnlich', movie)">
|
||||||
{{ gemerkt ? 'Gemerkt' : 'Merken' }}
|
Ähnliche finden
|
||||||
</button>
|
</button>
|
||||||
<template v-if="schonVorgeschlagen || vorschlagen.zustand === 'fertig'">
|
<button
|
||||||
<span class="ok">Steht auf dem Rad.</span>
|
v-if="!host"
|
||||||
|
type="button"
|
||||||
|
class="merken"
|
||||||
|
:class="{ an: gemerkt }"
|
||||||
|
@click="merkenUmschalten"
|
||||||
|
>
|
||||||
|
{{ gemerkt ? 'In Sammlung' : 'Zur Sammlung' }}
|
||||||
|
</button>
|
||||||
|
<template v-if="!host">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
class="merken"
|
||||||
|
:class="{ an: aufDemRad }"
|
||||||
:disabled="vorschlagen.zustand === 'laeuft'"
|
:disabled="vorschlagen.zustand === 'laeuft'"
|
||||||
@click="zuruecknehmen"
|
@click="radUmschalten"
|
||||||
>
|
>
|
||||||
Wieder runternehmen
|
{{ aufDemRad ? 'Auf dem Rad' : 'Zum Rad' }}
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<span v-if="meinVorschlag" class="hinweis">
|
|
||||||
Du hast „{{ meinVorschlag.title }}“ vorgeschlagen. Ein neuer
|
|
||||||
Vorschlag ersetzt ihn.
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
:disabled="vorschlagen.zustand === 'laeuft'"
|
|
||||||
@click="abschicken"
|
|
||||||
>
|
|
||||||
Vorschlagen
|
|
||||||
</button>
|
</button>
|
||||||
<span v-if="vorschlagen.zustand === 'fehler'" class="fehler">
|
<span v-if="vorschlagen.zustand === 'fehler'" class="fehler">
|
||||||
Hat nicht geklappt.
|
Hat nicht geklappt.
|
||||||
@@ -213,8 +256,11 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="links">
|
<section v-if="movie.overview" class="handlung">
|
||||||
<section v-if="series.length">
|
<h3>Handlung</h3>
|
||||||
|
<p class="overview">{{ movie.overview }}</p>
|
||||||
|
</section>
|
||||||
|
<section v-if="series.length" class="reihe">
|
||||||
<h3>{{ movie.collection_name || 'Die Reihe' }}</h3>
|
<h3>{{ movie.collection_name || 'Die Reihe' }}</h3>
|
||||||
<div class="series">
|
<div class="series">
|
||||||
<button
|
<button
|
||||||
@@ -238,11 +284,6 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="movie.overview">
|
|
||||||
<h3>Handlung</h3>
|
|
||||||
<p class="overview">{{ movie.overview }}</p>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section v-if="trailer" class="rechts">
|
<section v-if="trailer" class="rechts">
|
||||||
<div class="video">
|
<div class="video">
|
||||||
@@ -255,6 +296,28 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section v-if="aehnliche.length" class="aehnliche">
|
||||||
|
<h3>Ähnliche Filme</h3>
|
||||||
|
<div class="series">
|
||||||
|
<button
|
||||||
|
v-for="film in aehnliche"
|
||||||
|
:key="film.id"
|
||||||
|
class="part"
|
||||||
|
type="button"
|
||||||
|
@click="$emit('open', film)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="posterUrl(film.poster_path, 'w154')"
|
||||||
|
:src="posterUrl(film.poster_path, 'w154')"
|
||||||
|
:alt="film.title"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<span v-else class="no-poster">Kein Cover</span>
|
||||||
|
<span class="part-title">{{ film.title }}</span>
|
||||||
|
<span class="part-year">{{ film.year }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<section v-if="shots.length" class="shots-section">
|
<section v-if="shots.length" class="shots-section">
|
||||||
<div class="shots">
|
<div class="shots">
|
||||||
<button
|
<button
|
||||||
@@ -274,9 +337,14 @@ onUnmounted(() => {
|
|||||||
</section>
|
</section>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<div v-if="grossesBild" class="lupe" @click="grossesBild = null">
|
<!-- Muss aus der Ansicht heraus: deren backdrop-filter macht sie zum
|
||||||
<img :src="grossesBild" :alt="movie.title" />
|
Bezugsrahmen, und "fixed" richtet sich dann an ihr aus statt am
|
||||||
</div>
|
Fenster — das Bild landete am Anfang der Ansicht statt im Blick. -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="grossesBild" class="lupe" @click="grossesBild = null">
|
||||||
|
<img :src="grossesBild" :alt="movie.title" />
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -288,6 +356,9 @@ onUnmounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
/* Am unteren Ende soll der Bildlauf stehen bleiben, nicht auf die
|
||||||
|
Seite dahinter überspringen */
|
||||||
|
overscroll-behavior: contain;
|
||||||
padding: 2rem 1rem;
|
padding: 2rem 1rem;
|
||||||
background: rgba(0, 0, 0, 0.8);
|
background: rgba(0, 0, 0, 0.8);
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
@@ -307,17 +378,22 @@ onUnmounted(() => {
|
|||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Links Cover, Text und Knöpfe, rechts der Trailer, darunter die Bilder */
|
/* Oben links Cover, Text und Knöpfe, rechts daneben der Trailer. Darunter
|
||||||
.sheet > header,
|
über die ganze Breite: Handlung, Reihe, Ähnliche, Bilder. */
|
||||||
.sheet > .spalte-links {
|
.sheet > header {
|
||||||
grid-column: 1;
|
grid-area: 1 / 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sheet > .rechts {
|
.sheet > .rechts {
|
||||||
grid-column: 2;
|
grid-area: 1 / 2 / span 2;
|
||||||
grid-row: 1 / span 2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sheet > .handlung {
|
||||||
|
grid-area: 2 / 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet > .reihe,
|
||||||
|
.sheet > .aehnliche,
|
||||||
.sheet > .shots-section {
|
.sheet > .shots-section {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
@@ -327,10 +403,12 @@ onUnmounted(() => {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sheet > .rechts,
|
|
||||||
.sheet > .shots-section,
|
|
||||||
.sheet > header,
|
.sheet > header,
|
||||||
.sheet > .spalte-links {
|
.sheet > .rechts,
|
||||||
|
.sheet > .handlung,
|
||||||
|
.sheet > .reihe,
|
||||||
|
.sheet > .aehnliche,
|
||||||
|
.sheet > .shots-section {
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
grid-row: auto;
|
grid-row: auto;
|
||||||
}
|
}
|
||||||
@@ -403,7 +481,7 @@ h2 {
|
|||||||
.links {
|
.links {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-top: 0.35rem;
|
margin-top: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.netflix,
|
.netflix,
|
||||||
@@ -424,10 +502,13 @@ h2 {
|
|||||||
background: #f5c518;
|
background: #f5c518;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Der Textblock richtet seine Kinder linksbündig aus, dadurch war der
|
||||||
|
Trennstrich nur so breit wie die Knöpfe darunter */
|
||||||
.vorschlag {
|
.vorschlag {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.4rem;
|
gap: 0.5rem;
|
||||||
|
align-self: stretch;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
padding-top: 0.75rem;
|
padding-top: 0.75rem;
|
||||||
@@ -473,7 +554,8 @@ h2 {
|
|||||||
.gesehen {
|
.gesehen {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.4rem;
|
gap: 0.5rem;
|
||||||
|
align-self: stretch;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 0.6rem;
|
margin-top: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import MovieCard from './MovieCard.vue'
|
import MovieCard from './MovieCard.vue'
|
||||||
|
import { filterParameter } from '../filter.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
// Was die Filterleiste eingestellt hat
|
||||||
|
filter: { type: Object, default: () => ({}) },
|
||||||
|
ansicht: { type: String, default: 'kompakt' },
|
||||||
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['open'])
|
const emit = defineEmits(['open'])
|
||||||
|
|
||||||
@@ -8,28 +15,50 @@ const query = ref('')
|
|||||||
const results = ref([])
|
const results = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const searched = ref(false)
|
const gestoebert = ref(false)
|
||||||
|
|
||||||
let timer = null
|
let timer = null
|
||||||
let laufende = 0
|
let laufende = 0
|
||||||
|
|
||||||
async function run(term) {
|
// Ohne Suchbegriff stöbert TMDB nach den Filtern, mit Begriff sucht es
|
||||||
if (term.trim().length < 2) {
|
// nach Titeln — dann greifen die Filter erst hier.
|
||||||
results.value = []
|
function passt(movie) {
|
||||||
searched.value = false
|
const f = props.filter
|
||||||
return
|
const g = f.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 ((f.include || []).length && !genres.some((x) => f.include.includes(x))) return false
|
||||||
|
if ((f.exclude || []).some((x) => genres.includes(x))) return false
|
||||||
|
return (
|
||||||
|
drin(f.stimmen, movie.vote_count, 'vote_count') &&
|
||||||
|
drin(f.jahr, movie.year, 'year') &&
|
||||||
|
drin(f.note, movie.vote_average, 'vote_average')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(term) {
|
||||||
const meine = ++laufende
|
const meine = ++laufende
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
|
const stoebern = term.trim().length < 2
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/api/search?q=${encodeURIComponent(term.trim())}`)
|
const url = stoebern
|
||||||
|
? `/api/discover?${new URLSearchParams(filterParameter(props.filter))}`
|
||||||
|
: `/api/search?q=${encodeURIComponent(term.trim())}`
|
||||||
|
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()
|
||||||
// Eine ältere Anfrage darf ein neueres Ergebnis nicht überschreiben
|
// Eine ältere Anfrage darf ein neueres Ergebnis nicht überschreiben
|
||||||
if (meine !== laufende) return
|
if (meine !== laufende) return
|
||||||
results.value = data
|
results.value = stoebern ? data : data.filter(passt)
|
||||||
searched.value = true
|
gestoebert.value = stoebern
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (meine === laufende) error.value = err.message
|
if (meine === laufende) error.value = err.message
|
||||||
} finally {
|
} finally {
|
||||||
@@ -37,10 +66,24 @@ async function run(term) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(query, (term) => {
|
function spaeterSuchen() {
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
timer = setTimeout(() => run(term), 350)
|
timer = setTimeout(() => run(query.value), 350)
|
||||||
})
|
}
|
||||||
|
|
||||||
|
// Enter sucht sofort, statt die Tippbremse abzuwarten
|
||||||
|
function jetztSuchen() {
|
||||||
|
clearTimeout(timer)
|
||||||
|
run(query.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function leeren() {
|
||||||
|
query.value = ''
|
||||||
|
jetztSuchen()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(query, spaeterSuchen)
|
||||||
|
watch(() => props.filter, spaeterSuchen, { deep: true, immediate: true })
|
||||||
|
|
||||||
async function open(movie) {
|
async function open(movie) {
|
||||||
const resp = await fetch(`/api/search/${movie.id}`)
|
const resp = await fetch(`/api/search/${movie.id}`)
|
||||||
@@ -50,24 +93,35 @@ async function open(movie) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="search-tab">
|
<div class="search-tab">
|
||||||
<input
|
<div class="suchzeile">
|
||||||
v-model="query"
|
<input
|
||||||
type="search"
|
v-model="query"
|
||||||
class="big-search"
|
type="search"
|
||||||
placeholder="Globale Suche …"
|
class="big-search"
|
||||||
autofocus
|
placeholder="Globale Suche …"
|
||||||
/>
|
autofocus
|
||||||
|
@keyup.enter="jetztSuchen"
|
||||||
|
/>
|
||||||
|
<button v-if="query" type="button" class="leeren" title="Suche leeren" @click="leeren">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<button type="button" class="neu" title="Neu suchen" @click="jetztSuchen">
|
||||||
|
Suchen
|
||||||
|
</button>
|
||||||
|
</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="searched && !results.length" class="note">
|
<p v-else-if="!results.length" class="note">
|
||||||
Nichts gefunden für „{{ query }}“.
|
{{ gestoebert ? 'Nichts gefunden. Stell die Filter weiter.' : `Nichts gefunden für „${query}“.` }}
|
||||||
</p>
|
</p>
|
||||||
<div class="grid">
|
|
||||||
|
<div class="grid" :class="ansicht">
|
||||||
<MovieCard
|
<MovieCard
|
||||||
v-for="movie in results"
|
v-for="movie in results"
|
||||||
:key="movie.id"
|
:key="movie.id"
|
||||||
:movie="movie"
|
:movie="movie"
|
||||||
|
:ansicht="ansicht"
|
||||||
@open="open"
|
@open="open"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,19 +129,63 @@ async function open(movie) {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.big-search {
|
.search-tab {
|
||||||
width: 100%;
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suchzeile {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
padding: 0.9rem 1.1rem;
|
}
|
||||||
|
|
||||||
|
.big-search {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.9rem 2.6rem 0.9rem 1.1rem;
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.leeren {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 7.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
line-height: 1;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leeren:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.neu {
|
||||||
|
padding: 0 1.4rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neu:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||||
gap: 1.1rem;
|
gap: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.grid.details {
|
||||||
|
grid-template-columns: repeat(
|
||||||
|
auto-fill,
|
||||||
|
min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
.note {
|
.note {
|
||||||
padding: 2.5rem 0;
|
padding: 2.5rem 0;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
|||||||
24
frontend/src/filter.js
Normal file
24
frontend/src/filter.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// Die Filterleiste steht auf Anschlag, solange niemand sie anfasst. Genau
|
||||||
|
// dann darf sie auch nichts wegschneiden — also wandert nur ins Backend,
|
||||||
|
// was tatsächlich verstellt wurde.
|
||||||
|
function spanne(filter, name, schluessel, ziel) {
|
||||||
|
const werte = filter[name]
|
||||||
|
const grenze = filter.grenzen?.[schluessel]
|
||||||
|
if (!werte || !grenze) return {}
|
||||||
|
const raus = {}
|
||||||
|
if (werte[0] > grenze.min) raus[`${ziel}_min`] = werte[0]
|
||||||
|
if (werte[1] < grenze.max) raus[`${ziel}_max`] = werte[1]
|
||||||
|
return raus
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterParameter(filter) {
|
||||||
|
return {
|
||||||
|
sort: filter.sort || 'rating',
|
||||||
|
include: (filter.include || []).join(','),
|
||||||
|
exclude: (filter.exclude || []).join(','),
|
||||||
|
...spanne(filter, 'stimmen', 'vote_count', 'stimmen'),
|
||||||
|
...spanne(filter, 'dauer', 'runtime', 'dauer'),
|
||||||
|
...spanne(filter, 'jahr', 'year', 'jahr'),
|
||||||
|
...spanne(filter, 'note', 'vote_average', 'note'),
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user