update
This commit is contained in:
@@ -125,12 +125,23 @@ def drop_missing(conn, keep_ids):
|
||||
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():
|
||||
with connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM movies ORDER BY vote_count DESC, title ASC"
|
||||
).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):
|
||||
|
||||
@@ -145,6 +145,75 @@ async def search_movies(q: str):
|
||||
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}")
|
||||
async def search_detail(movie_id: int):
|
||||
"""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.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
|
||||
import httpx
|
||||
|
||||
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"}
|
||||
|
||||
@@ -17,6 +26,19 @@ def client():
|
||||
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):
|
||||
release = item.get("release_date") or ""
|
||||
return {
|
||||
@@ -29,9 +51,127 @@ def as_result(item):
|
||||
"poster_path": item.get("poster_path"),
|
||||
"vote_average": item.get("vote_average"),
|
||||
"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 with client() as http:
|
||||
resp = await http.get(
|
||||
@@ -41,11 +181,154 @@ async def search(query, limit=24):
|
||||
resp.raise_for_status()
|
||||
results = resp.json().get("results", [])
|
||||
|
||||
# Ohne Poster taugt eine Kachel nichts, und Namenlose sind fast immer
|
||||
# Dubletten oder Making-ofs
|
||||
usable = [r for r in results if r.get("poster_path")]
|
||||
usable.sort(key=lambda r: -(r.get("popularity") or 0))
|
||||
return [as_result(r) for r in usable[:limit]]
|
||||
# Ohne Poster taugt eine Kachel nichts, und Namenlose sind fast immer
|
||||
# Dubletten oder Making-ofs
|
||||
usable = [r for r in results if r.get("poster_path")]
|
||||
usable.sort(key=lambda r: -(r.get("popularity") or 0))
|
||||
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):
|
||||
|
||||
@@ -22,7 +22,7 @@ def build_movie(data):
|
||||
"collection_id": collection.get("id"),
|
||||
"collection_name": collection.get("name"),
|
||||
"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"),
|
||||
"release_date": release or None,
|
||||
"year": int(release[:4]) if release[:4].isdigit() else None,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import unicodedata
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import (
|
||||
@@ -63,6 +65,36 @@ async def detail(http, movie_id):
|
||||
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):
|
||||
"""Deutsche Synopsis, sonst englische aus den Translations."""
|
||||
if data.get("overview"):
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { abonnieren } from './live'
|
||||
import MovieCard from './components/MovieCard.vue'
|
||||
import FilterSidebar from './components/FilterSidebar.vue'
|
||||
import AehnlichTab from './components/AehnlichTab.vue'
|
||||
import MerklisteTab from './components/MerklisteTab.vue'
|
||||
import MovieDetail from './components/MovieDetail.vue'
|
||||
import NamensWahl from './components/NamensWahl.vue'
|
||||
@@ -20,6 +21,10 @@ const loading = ref(true)
|
||||
const error = ref('')
|
||||
const search = ref('')
|
||||
const sort = ref('random')
|
||||
// Suche und Ähnlichkeitsuche haben eine eigene Voreinstellung: TMDB liefert
|
||||
// dort eine Reihenfolge nach Verwandtschaft — dieselbe, die IMDb unter
|
||||
// "More like this" zeigt. Im Katalog gibt es die nicht.
|
||||
const suchSort = ref('relevanz')
|
||||
|
||||
// Kompakt zeigt viele Karten nebeneinander, Details dafür mehr je Film
|
||||
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))
|
||||
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
|
||||
const TAB_SCHLUESSEL = 'filmabend_tab'
|
||||
const TAB_IDS = [
|
||||
'netflix', 'canon', 'search', 'wheel', 'merkliste', 'watched', 'wuensche',
|
||||
'verwaltung', 'stream',
|
||||
'netflix', 'canon', 'search', 'aehnlich', 'wheel', 'merkliste', 'watched',
|
||||
'wuensche', 'verwaltung', 'stream',
|
||||
]
|
||||
const gemerkterTab = localStorage.getItem(TAB_SCHLUESSEL)
|
||||
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 },
|
||||
]
|
||||
|
||||
// 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(() => {
|
||||
if (activeTab.value === 'search' || activeTab.value === 'aehnlich') return SUCH_GRENZEN
|
||||
const pool = activeTab.value === 'netflix' ? netflixMovies.value : canonMovies.value
|
||||
const out = {}
|
||||
for (const regler of REGLER) {
|
||||
@@ -135,6 +176,7 @@ function resetGenres() {
|
||||
|
||||
// Nur Genres anbieten, die im offenen Reiter auch Treffer haben
|
||||
const genreOptions = computed(() => {
|
||||
if (activeTab.value === 'search' || activeTab.value === 'aehnlich') return SUCH_GENRES
|
||||
const pool = activeTab.value === 'netflix' ? netflixMovies.value : canonMovies.value
|
||||
const counts = new Map()
|
||||
for (const movie of pool) {
|
||||
@@ -213,6 +255,7 @@ const tabs = computed(() => [
|
||||
{ id: 'netflix', label: 'Netflix', count: netflixMovies.value.length },
|
||||
{ id: 'canon', label: 'Horror-Kanon', count: canonGroups.value.length },
|
||||
{ id: 'search', label: 'Suche', count: null },
|
||||
{ id: 'aehnlich', label: 'Ähnlichkeitsuche', count: null },
|
||||
...(host.value
|
||||
? []
|
||||
: [{ id: 'merkliste', label: 'Sammlungen', count: null }]),
|
||||
@@ -223,6 +266,30 @@ const tabs = computed(() => [
|
||||
...(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)
|
||||
|
||||
// Bei 2500 Karten kommt man sonst nicht mehr nach oben
|
||||
@@ -515,7 +582,7 @@ onUnmounted(() => {
|
||||
<template>
|
||||
<div class="page" :class="{
|
||||
breit: [
|
||||
'search', 'wheel', 'stream', 'watched', 'merkliste', 'wuensche', 'verwaltung',
|
||||
'wheel', 'stream', 'watched', 'merkliste', 'wuensche', 'verwaltung',
|
||||
].includes(activeTab),
|
||||
}">
|
||||
<nav class="tabs">
|
||||
@@ -533,7 +600,39 @@ onUnmounted(() => {
|
||||
<span v-else class="host">Host</span>
|
||||
</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
|
||||
v-else-if="activeTab === 'wheel'"
|
||||
@@ -591,7 +690,7 @@ onUnmounted(() => {
|
||||
:movie="entry.parts[0]"
|
||||
:auf-dem-rad="aufDemRad.includes(entry.parts[0].id)"
|
||||
:ansicht="ansicht"
|
||||
@open="selected = $event"
|
||||
@open="filmOeffnen"
|
||||
/>
|
||||
<SeriesCard
|
||||
v-else
|
||||
@@ -599,7 +698,7 @@ onUnmounted(() => {
|
||||
:start="entry.best"
|
||||
:auf-dem-rad="aufDemRad"
|
||||
:ansicht="ansicht"
|
||||
@open="selected = $event"
|
||||
@open="filmOeffnen"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
@@ -631,7 +730,7 @@ onUnmounted(() => {
|
||||
:movie="selected"
|
||||
:series="series"
|
||||
@close="selected = null"
|
||||
@open="selected = $event"
|
||||
@open="filmOeffnen"
|
||||
:mein-vorschlag="meinVorschlag"
|
||||
:host="host"
|
||||
:gesehen="gesehen.some((e) => e.movie_id === selected.id)"
|
||||
@@ -639,6 +738,7 @@ onUnmounted(() => {
|
||||
@gemerkt="ladeMerkliste"
|
||||
@suggested="nachVorschlag"
|
||||
@watched="ladeGesehen"
|
||||
@aehnlich="zurAehnlichkeitsuche"
|
||||
/>
|
||||
|
||||
</div>
|
||||
@@ -662,10 +762,11 @@ onUnmounted(() => {
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
align-items: stretch;
|
||||
/* Der negative Rand deckt das Polster der Seite ab, sonst scrollen
|
||||
Karten oberhalb der Leiste durchs Bild */
|
||||
margin: -1.5rem -1.25rem 1.25rem;
|
||||
padding: 0.7rem 1.25rem 0;
|
||||
padding: 0 1.25rem;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
@@ -694,7 +795,7 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.6rem 1rem;
|
||||
padding: 0.8rem 1rem 0.6rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
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 },
|
||||
regler: { type: Array, 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'])
|
||||
@@ -46,8 +50,14 @@ function beschriften(regler) {
|
||||
<template>
|
||||
<aside class="sidebar">
|
||||
<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">
|
||||
<option v-if="mitRelevanz" value="relevanz">Wie auf IMDb</option>
|
||||
<option value="random">Zufällig</option>
|
||||
<option value="rating">Beste Bewertung</option>
|
||||
<option value="newest">Neueste zuerst</option>
|
||||
|
||||
@@ -42,7 +42,7 @@ async function oeffnen(item) {
|
||||
<template>
|
||||
<div class="merkliste-tab">
|
||||
<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>
|
||||
|
||||
<section v-for="gruppe in gruppen" :key="gruppe.titel">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { backdropUrl, posterUrl } from '../images'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -11,7 +11,9 @@ const props = defineProps({
|
||||
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' })
|
||||
|
||||
@@ -19,6 +21,18 @@ const schonVorgeschlagen = computed(
|
||||
() => 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() {
|
||||
const weg = props.gemerkt
|
||||
await fetch(weg ? `/api/wishlist/${props.movie.id}` : '/api/wishlist', {
|
||||
@@ -34,7 +48,7 @@ async function zuruecknehmen() {
|
||||
try {
|
||||
const resp = await fetch('/api/suggestions', { method: 'DELETE' })
|
||||
if (!resp.ok) throw new Error()
|
||||
vorschlagen.value = { zustand: 'bereit' }
|
||||
vorschlagen.value = { zustand: 'weg' }
|
||||
emit('suggested')
|
||||
} catch {
|
||||
vorschlagen.value = { zustand: 'fehler' }
|
||||
@@ -80,6 +94,28 @@ const shots = computed(() =>
|
||||
(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
|
||||
const grossesBild = ref(null)
|
||||
|
||||
@@ -107,13 +143,23 @@ function openPart(part) {
|
||||
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(() => {
|
||||
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'
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', onKey)
|
||||
document.documentElement.style.overflow = ''
|
||||
document.documentElement.style.paddingRight = ''
|
||||
document.body.style.overflow = ''
|
||||
})
|
||||
</script>
|
||||
@@ -141,7 +187,7 @@ onUnmounted(() => {
|
||||
</p>
|
||||
<p class="genres">{{ (movie.genres || []).join(' · ') }}</p>
|
||||
<p v-if="movie.teaser" class="teaser">{{ movie.teaser }}</p>
|
||||
<div class="spalte-links">
|
||||
<div class="links">
|
||||
<a
|
||||
v-if="movie.on_netflix"
|
||||
class="netflix"
|
||||
@@ -162,31 +208,28 @@ onUnmounted(() => {
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div v-if="!host" class="vorschlag">
|
||||
<button type="button" class="merken" :class="{ an: gemerkt }" @click="merkenUmschalten">
|
||||
{{ gemerkt ? 'Gemerkt' : 'Merken' }}
|
||||
<div class="vorschlag">
|
||||
<button type="button" class="merken" @click="$emit('aehnlich', movie)">
|
||||
Ähnliche finden
|
||||
</button>
|
||||
<template v-if="schonVorgeschlagen || vorschlagen.zustand === 'fertig'">
|
||||
<span class="ok">Steht auf dem Rad.</span>
|
||||
<button
|
||||
v-if="!host"
|
||||
type="button"
|
||||
class="merken"
|
||||
:class="{ an: gemerkt }"
|
||||
@click="merkenUmschalten"
|
||||
>
|
||||
{{ gemerkt ? 'In Sammlung' : 'Zur Sammlung' }}
|
||||
</button>
|
||||
<template v-if="!host">
|
||||
<button
|
||||
type="button"
|
||||
class="merken"
|
||||
:class="{ an: aufDemRad }"
|
||||
:disabled="vorschlagen.zustand === 'laeuft'"
|
||||
@click="zuruecknehmen"
|
||||
@click="radUmschalten"
|
||||
>
|
||||
Wieder runternehmen
|
||||
</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
|
||||
{{ aufDemRad ? 'Auf dem Rad' : 'Zum Rad' }}
|
||||
</button>
|
||||
<span v-if="vorschlagen.zustand === 'fehler'" class="fehler">
|
||||
Hat nicht geklappt.
|
||||
@@ -213,8 +256,11 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="links">
|
||||
<section v-if="series.length">
|
||||
<section v-if="movie.overview" class="handlung">
|
||||
<h3>Handlung</h3>
|
||||
<p class="overview">{{ movie.overview }}</p>
|
||||
</section>
|
||||
<section v-if="series.length" class="reihe">
|
||||
<h3>{{ movie.collection_name || 'Die Reihe' }}</h3>
|
||||
<div class="series">
|
||||
<button
|
||||
@@ -238,11 +284,6 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="movie.overview">
|
||||
<h3>Handlung</h3>
|
||||
<p class="overview">{{ movie.overview }}</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section v-if="trailer" class="rechts">
|
||||
<div class="video">
|
||||
@@ -255,6 +296,28 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</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">
|
||||
<div class="shots">
|
||||
<button
|
||||
@@ -274,9 +337,14 @@ onUnmounted(() => {
|
||||
</section>
|
||||
</article>
|
||||
|
||||
<div v-if="grossesBild" class="lupe" @click="grossesBild = null">
|
||||
<img :src="grossesBild" :alt="movie.title" />
|
||||
</div>
|
||||
<!-- Muss aus der Ansicht heraus: deren backdrop-filter macht sie zum
|
||||
Bezugsrahmen, und "fixed" richtet sich dann an ihr aus statt am
|
||||
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>
|
||||
</template>
|
||||
|
||||
@@ -288,6 +356,9 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
overflow-y: auto;
|
||||
/* Am unteren Ende soll der Bildlauf stehen bleiben, nicht auf die
|
||||
Seite dahinter überspringen */
|
||||
overscroll-behavior: contain;
|
||||
padding: 2rem 1rem;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
backdrop-filter: blur(4px);
|
||||
@@ -307,17 +378,22 @@ onUnmounted(() => {
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
/* Links Cover, Text und Knöpfe, rechts der Trailer, darunter die Bilder */
|
||||
.sheet > header,
|
||||
.sheet > .spalte-links {
|
||||
grid-column: 1;
|
||||
/* Oben links Cover, Text und Knöpfe, rechts daneben der Trailer. Darunter
|
||||
über die ganze Breite: Handlung, Reihe, Ähnliche, Bilder. */
|
||||
.sheet > header {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
|
||||
.sheet > .rechts {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 2;
|
||||
grid-area: 1 / 2 / span 2;
|
||||
}
|
||||
|
||||
.sheet > .handlung {
|
||||
grid-area: 2 / 1;
|
||||
}
|
||||
|
||||
.sheet > .reihe,
|
||||
.sheet > .aehnliche,
|
||||
.sheet > .shots-section {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
@@ -327,10 +403,12 @@ onUnmounted(() => {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sheet > .rechts,
|
||||
.sheet > .shots-section,
|
||||
.sheet > header,
|
||||
.sheet > .spalte-links {
|
||||
.sheet > .rechts,
|
||||
.sheet > .handlung,
|
||||
.sheet > .reihe,
|
||||
.sheet > .aehnliche,
|
||||
.sheet > .shots-section {
|
||||
grid-column: 1;
|
||||
grid-row: auto;
|
||||
}
|
||||
@@ -403,7 +481,7 @@ h2 {
|
||||
.links {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.35rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.netflix,
|
||||
@@ -424,10 +502,13 @@ h2 {
|
||||
background: #f5c518;
|
||||
}
|
||||
|
||||
/* Der Textblock richtet seine Kinder linksbündig aus, dadurch war der
|
||||
Trennstrich nur so breit wie die Knöpfe darunter */
|
||||
.vorschlag {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
gap: 0.5rem;
|
||||
align-self: stretch;
|
||||
align-items: center;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
@@ -473,7 +554,8 @@ h2 {
|
||||
.gesehen {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
gap: 0.5rem;
|
||||
align-self: stretch;
|
||||
align-items: center;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
<script setup>
|
||||
import { ref, watch } from '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'])
|
||||
|
||||
@@ -8,28 +15,50 @@ const query = ref('')
|
||||
const results = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const searched = ref(false)
|
||||
const gestoebert = ref(false)
|
||||
|
||||
let timer = null
|
||||
let laufende = 0
|
||||
|
||||
async function run(term) {
|
||||
if (term.trim().length < 2) {
|
||||
results.value = []
|
||||
searched.value = false
|
||||
return
|
||||
// Ohne Suchbegriff stöbert TMDB nach den Filtern, mit Begriff sucht es
|
||||
// nach Titeln — dann greifen die Filter erst hier.
|
||||
function passt(movie) {
|
||||
const f = props.filter
|
||||
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
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
const stoebern = term.trim().length < 2
|
||||
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')
|
||||
const data = await resp.json()
|
||||
// Eine ältere Anfrage darf ein neueres Ergebnis nicht überschreiben
|
||||
if (meine !== laufende) return
|
||||
results.value = data
|
||||
searched.value = true
|
||||
results.value = stoebern ? data : data.filter(passt)
|
||||
gestoebert.value = stoebern
|
||||
} catch (err) {
|
||||
if (meine === laufende) error.value = err.message
|
||||
} finally {
|
||||
@@ -37,10 +66,24 @@ async function run(term) {
|
||||
}
|
||||
}
|
||||
|
||||
watch(query, (term) => {
|
||||
function spaeterSuchen() {
|
||||
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) {
|
||||
const resp = await fetch(`/api/search/${movie.id}`)
|
||||
@@ -50,24 +93,35 @@ async function open(movie) {
|
||||
|
||||
<template>
|
||||
<div class="search-tab">
|
||||
<input
|
||||
v-model="query"
|
||||
type="search"
|
||||
class="big-search"
|
||||
placeholder="Globale Suche …"
|
||||
autofocus
|
||||
/>
|
||||
<div class="suchzeile">
|
||||
<input
|
||||
v-model="query"
|
||||
type="search"
|
||||
class="big-search"
|
||||
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-else-if="error" class="note error">{{ error }}</p>
|
||||
<p v-else-if="searched && !results.length" class="note">
|
||||
Nichts gefunden für „{{ query }}“.
|
||||
<p v-else-if="!results.length" class="note">
|
||||
{{ gestoebert ? 'Nichts gefunden. Stell die Filter weiter.' : `Nichts gefunden für „${query}“.` }}
|
||||
</p>
|
||||
<div class="grid">
|
||||
|
||||
<div class="grid" :class="ansicht">
|
||||
<MovieCard
|
||||
v-for="movie in results"
|
||||
:key="movie.id"
|
||||
:movie="movie"
|
||||
:ansicht="ansicht"
|
||||
@open="open"
|
||||
/>
|
||||
</div>
|
||||
@@ -75,19 +129,63 @@ async function open(movie) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.big-search {
|
||||
width: 100%;
|
||||
.search-tab {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.suchzeile {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 0.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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
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: 2.5rem 0;
|
||||
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