This commit is contained in:
team3
2026-09-08 17:57:51 +02:00
parent 4de5018409
commit 13df86e9fc
12 changed files with 1088 additions and 90 deletions

View File

@@ -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):

View File

@@ -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."""

View File

@@ -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):

View File

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

View File

@@ -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"):

View File

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

View 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>

View File

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

View File

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

View File

@@ -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;
}

View File

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