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) return len(gone)
# Ein Film bringt im Schnitt vierzehn Bildpfade mit, einer sogar 179.
# Die Karte zeigt vier. Der Rest kommt beim Öffnen über /api/movies/{id}.
KATALOG_BILDER = 6
def all_movies(): def all_movies():
with connect() as conn: with connect() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT * FROM movies ORDER BY vote_count DESC, title ASC" "SELECT * FROM movies ORDER BY vote_count DESC, title ASC"
).fetchall() ).fetchall()
return [row_to_dict(r) for r in rows] filme = []
for row in rows:
film = row_to_dict(row)
if film.get("images"):
film["images"] = film["images"][:KATALOG_BILDER]
filme.append(film)
return filme
def get_movie(movie_id): def get_movie(movie_id):

View File

@@ -145,6 +145,75 @@ async def search_movies(q: str):
return await search.search(q.strip()) return await search.search(q.strip())
@app.get("/api/discover")
async def stoebern(
sort: str = "rating",
include: str = "",
exclude: str = "",
stimmen_min: int | None = None,
stimmen_max: int | None = None,
dauer_min: int | None = None,
dauer_max: int | None = None,
jahr_min: int | None = None,
jahr_max: int | None = None,
note_min: float | None = None,
note_max: float | None = None,
):
"""Suche ohne Suchbegriff: TMDB stöbert nach den Filtern der Leiste."""
return await search.discover(
{
"sort": sort,
"include": [g for g in include.split(",") if g],
"exclude": [g for g in exclude.split(",") if g],
"stimmen_min": stimmen_min,
"stimmen_max": stimmen_max,
"dauer_min": dauer_min,
"dauer_max": dauer_max,
"jahr_min": jahr_min,
"jahr_max": jahr_max,
"note_min": note_min,
"note_max": note_max,
}
)
@app.get("/api/movies/{movie_id}/aehnliche")
async def aehnliche_filme(
movie_id: int,
limit: int = 12,
voll: bool = False,
sort: str = "",
include: str = "",
exclude: str = "",
stimmen_min: int | None = None,
stimmen_max: int | None = None,
jahr_min: int | None = None,
jahr_max: int | None = None,
note_min: float | None = None,
note_max: float | None = None,
):
"""Was Leute noch geschaut haben, die diesen Film mochten."""
try:
return await search.aehnliche(
movie_id,
limit=limit,
voll=voll,
filter_={
"sort": sort,
"include": [g for g in include.split(",") if g],
"exclude": [g for g in exclude.split(",") if g],
"stimmen_min": stimmen_min,
"stimmen_max": stimmen_max,
"jahr_min": jahr_min,
"jahr_max": jahr_max,
"note_min": note_min,
"note_max": note_max,
},
)
except Exception:
return []
@app.get("/api/search/{movie_id}") @app.get("/api/search/{movie_id}")
async def search_detail(movie_id: int): async def search_detail(movie_id: int):
"""Volle Daten zu einem Suchtreffer. Erst der Katalog, dann TMDB.""" """Volle Daten zu einem Suchtreffer. Erst der Katalog, dann TMDB."""

View File

@@ -5,10 +5,19 @@ es auch Titel, die nirgends davon stehen — die kommen live von TMDB und
landen nicht in der Datenbank. landen nicht in der Datenbank.
""" """
import asyncio
import random
import httpx import httpx
from .config import LANGUAGE, TMDB_BASE, TMDB_TOKEN from .config import LANGUAGE, TMDB_BASE, TMDB_TOKEN
from .tmdb import DETAIL_PARAMS, pick_backdrops, pick_overview, pick_trailer from .tmdb import (
DETAIL_PARAMS,
pick_backdrops,
pick_overview,
pick_title,
pick_trailer,
)
HEADERS = {"Authorization": f"Bearer {TMDB_TOKEN}", "accept": "application/json"} HEADERS = {"Authorization": f"Bearer {TMDB_TOKEN}", "accept": "application/json"}
@@ -17,6 +26,19 @@ def client():
return httpx.AsyncClient(base_url=TMDB_BASE, headers=HEADERS, timeout=30.0) return httpx.AsyncClient(base_url=TMDB_BASE, headers=HEADERS, timeout=30.0)
# Die Namen kommen von TMDB selbst und ändern sich praktisch nie. Sie hier
# festzuhalten spart bei jeder Suche einen Abruf.
GENRES = {
28: "Action", 12: "Abenteuer", 16: "Animation", 35: "Komödie",
80: "Krimi", 99: "Dokumentarfilm", 18: "Drama", 10751: "Familie",
14: "Fantasy", 36: "Historie", 27: "Horror", 10402: "Musik",
9648: "Mystery", 10749: "Liebesfilm", 878: "Science Fiction",
10770: "TV-Film", 53: "Thriller", 10752: "Kriegsfilm", 37: "Western",
}
NACH_NAME = {name: kennung for kennung, name in GENRES.items()}
def as_result(item): def as_result(item):
release = item.get("release_date") or "" release = item.get("release_date") or ""
return { return {
@@ -29,9 +51,127 @@ def as_result(item):
"poster_path": item.get("poster_path"), "poster_path": item.get("poster_path"),
"vote_average": item.get("vote_average"), "vote_average": item.get("vote_average"),
"vote_count": item.get("vote_count"), "vote_count": item.get("vote_count"),
"genres": [GENRES[g] for g in item.get("genre_ids", []) if g in GENRES],
# Trefferlisten sind knapp — details_nachladen() füllt nach
"images": [],
"runtime": None,
} }
async def details_nachladen(http, treffer, je_film=4):
"""Bilder, Laufzeit und lesbarer Titel — alle Abrufe gleichzeitig.
Trefferlisten von TMDB sind knapp: keine Bilder, keine Laufzeit, und
bei indischen oder japanischen Filmen steht der Titel in Originalschrift.
Das alles steckt in der Detailansicht, ein Abruf je Film reicht.
"""
async def hole(film):
try:
resp = await http.get(
f"/movie/{film['id']}",
params={
"language": LANGUAGE,
"append_to_response": "images,translations",
"include_image_language": "de,en,null",
},
)
resp.raise_for_status()
daten = resp.json()
except httpx.HTTPError:
return
film["title"] = pick_title(daten)
film["runtime"] = daten.get("runtime")
backdrops = daten.get("images", {}).get("backdrops", [])
# Ohne Sprachkennung steht kein Text im Bild — das sind die Szenen
ohne_text = [b for b in backdrops if not b.get("iso_639_1")]
film["images"] = [
b["file_path"] for b in (ohne_text or backdrops)[:je_film]
]
await asyncio.gather(*(hole(f) for f in treffer))
SORTIERUNG = {
"rating": "vote_average.desc",
"newest": "primary_release_date.desc",
"oldest": "primary_release_date.asc",
"title": "title.asc",
# TMDB kennt keine Zufallsordnung. Statt dessen holt discover() eine
# zufällige Seite aus den Beliebtesten.
"random": "popularity.desc",
}
# Weiter hinten liegen nur noch Filme, die kaum jemand kennt
MAX_SEITE = 20
async def discover(filter_, limit=40):
"""Stöbern ohne Suchbegriff: TMDB sucht nach den Filtern aus der Leiste.
Anders als /search/movie versteht /discover/movie Genres, Jahre und
Bewertungen — damit lässt sich die Leiste direkt weiterreichen.
"""
params = {
"language": LANGUAGE,
"include_adult": "false",
"sort_by": SORTIERUNG.get(filter_.get("sort"), "popularity.desc"),
# Ohne Untergrenze stehen Filme mit einer einzigen Stimme ganz oben
"vote_count.gte": filter_.get("stimmen_min") or 50,
}
einbeziehen = [
NACH_NAME[g] for g in (filter_.get("include") or []) if g in NACH_NAME
]
ausschliessen = [
NACH_NAME[g] for g in (filter_.get("exclude") or []) if g in NACH_NAME
]
if einbeziehen:
params["with_genres"] = ",".join(str(g) for g in einbeziehen)
if ausschliessen:
params["without_genres"] = ",".join(str(g) for g in ausschliessen)
for schluessel, ziel in (
("stimmen_max", "vote_count.lte"),
("dauer_min", "with_runtime.gte"),
("dauer_max", "with_runtime.lte"),
("note_min", "vote_average.gte"),
("note_max", "vote_average.lte"),
):
wert = filter_.get(schluessel)
if wert is not None:
params[ziel] = wert
if filter_.get("jahr_min"):
params["primary_release_date.gte"] = f"{filter_['jahr_min']}-01-01"
if filter_.get("jahr_max"):
params["primary_release_date.lte"] = f"{filter_['jahr_max']}-12-31"
async with client() as http:
resp = await http.get("/discover/movie", params=params)
resp.raise_for_status()
daten = resp.json()
# Bei "Zufällig" nochmal von einer beliebigen Seite holen — sonst
# stehen bei jedem Aufruf dieselben zwanzig Filme oben
if filter_.get("sort") == "random":
seiten = min(daten.get("total_pages") or 1, MAX_SEITE)
if seiten > 1:
params["page"] = random.randint(1, seiten)
resp = await http.get("/discover/movie", params=params)
resp.raise_for_status()
daten = resp.json()
results = daten.get("results", [])
if filter_.get("sort") == "random":
random.shuffle(results)
usable = [r for r in results if r.get("poster_path")]
treffer = [as_result(r) for r in usable[:limit]]
await details_nachladen(http, treffer)
return treffer
async def search(query, limit=24): async def search(query, limit=24):
async with client() as http: async with client() as http:
resp = await http.get( resp = await http.get(
@@ -41,11 +181,154 @@ async def search(query, limit=24):
resp.raise_for_status() resp.raise_for_status()
results = resp.json().get("results", []) results = resp.json().get("results", [])
# Ohne Poster taugt eine Kachel nichts, und Namenlose sind fast immer # Ohne Poster taugt eine Kachel nichts, und Namenlose sind fast immer
# Dubletten oder Making-ofs # Dubletten oder Making-ofs
usable = [r for r in results if r.get("poster_path")] usable = [r for r in results if r.get("poster_path")]
usable.sort(key=lambda r: -(r.get("popularity") or 0)) usable.sort(key=lambda r: -(r.get("popularity") or 0))
return [as_result(r) for r in usable[:limit]] treffer = [as_result(r) for r in usable[:limit]]
await details_nachladen(http, treffer)
return treffer
# TMDB hat je Film gut dreißig Seiten Empfehlungen. Weiter hinten wird die
# Verwandtschaft dünn, und jede Seite kostet einen Abruf.
MAX_AEHNLICH_SEITEN = 8
def passt_zur_leiste(item, filter_):
"""Die Filter der Leiste auf einen knappen Treffer anwenden.
Die Laufzeit fehlt hier noch — die steht erst in der Detailantwort.
Nach ihr filtert weiter die Oberfläche.
"""
genres = {GENRES[g] for g in item.get("genre_ids", []) if g in GENRES}
einbeziehen = set(filter_.get("include") or [])
ausschliessen = set(filter_.get("exclude") or [])
if einbeziehen and not (genres & einbeziehen):
return False
if genres & ausschliessen:
return False
release = item.get("release_date") or ""
jahr = int(release[:4]) if release[:4].isdigit() else None
for wert, unten, oben in (
(item.get("vote_count"), filter_.get("stimmen_min"), filter_.get("stimmen_max")),
(item.get("vote_average"), filter_.get("note_min"), filter_.get("note_max")),
(jahr, filter_.get("jahr_min"), filter_.get("jahr_max")),
):
if wert is None:
continue
if unten is not None and wert < unten:
return False
if oben is not None and wert > oben:
return False
return True
def brauchbare(roh, filter_):
"""Dubletten und Posterlose raus, dann die Filter drüber."""
raus, gesehen = [], set()
for item in roh:
if not item.get("poster_path") or item["id"] in gesehen:
continue
gesehen.add(item["id"])
if passt_zur_leiste(item, filter_):
raus.append(item)
return raus
# "relevanz" gehört nicht dazu: dort bleibt TMDBs eigene Ordnung stehen.
# Die geht nach Verwandtschaft — dieselbe, die IMDb unter "More like this"
# zeigt — und ist für Ähnliche das sinnvollste Erste.
UMSORTIERBAR = ("rating", "newest", "oldest", "title", "random")
def sortiert(kandidaten, sort):
"""Die Reihenfolge aus der Leiste auf die Kandidaten anwenden."""
if sort == "rating":
return sorted(kandidaten, key=lambda r: -(r.get("vote_average") or 0))
if sort == "newest":
return sorted(
kandidaten, key=lambda r: r.get("release_date") or "", reverse=True
)
if sort == "oldest":
return sorted(kandidaten, key=lambda r: r.get("release_date") or "9999")
if sort == "title":
return sorted(kandidaten, key=lambda r: (r.get("title") or "").lower())
if sort == "random":
gemischt = list(kandidaten)
random.shuffle(gemischt)
return gemischt
return kandidaten
async def aehnliche(movie_id, limit=12, voll=False, filter_=None):
"""Was Leute noch geschaut haben, die diesen Film mochten.
TMDB bietet zwei Wege an. /similar geht nur nach Genre und Schlagwörtern
und liefert für einen Horrorfilm hunderttausend Treffer — unbrauchbar.
/recommendations beruht auf dem Verhalten der Nutzer und trifft.
Filtern muss diese Funktion selbst: /recommendations kennt anders als
/discover keine Genres oder Jahre. Reicht die erste Seite nicht, holt
sie weitere nach — sonst bleiben bei enger Leiste drei Karten übrig.
"""
filter_ = filter_ or {}
sort = filter_.get("sort") or ""
async with client() as http:
async def seite(nr):
try:
resp = await http.get(
f"/movie/{movie_id}/recommendations",
params={"language": LANGUAGE, "page": nr},
)
resp.raise_for_status()
return resp.json()
except httpx.HTTPError:
return {}
erste = await seite(1)
roh = list(erste.get("results", []))
seiten = min(erste.get("total_pages") or 1, MAX_AEHNLICH_SEITEN)
# Alle übrigen Seiten gleichzeitig — nacheinander wären das bei
# enger Leiste sieben Wartezeiten hintereinander.
# Auch beim Sortieren: die besten zwanzig von zwanzig wären keine.
zu_wenig = len(brauchbare(roh, filter_)) < limit
if (zu_wenig or sort in UMSORTIERBAR) and seiten > 1:
for daten in await asyncio.gather(
*(seite(nr) for nr in range(2, seiten + 1))
):
roh.extend(daten.get("results", []))
kandidaten = sortiert(brauchbare(roh, filter_), sort)
treffer = [as_result(r) for r in kandidaten[:limit]]
# Für die Kachelreihe reicht der Titel, für Karten braucht es mehr
if voll:
await details_nachladen(http, treffer)
else:
await asyncio.gather(*(titel_richten(http, f) for f in treffer))
return treffer
async def titel_richten(http, film):
"""Originalschrift durch den englischen Titel ersetzen."""
if not any(
z.isalpha() and not ("a" <= z.lower() <= "z" or z in "äöüßàâçéèêëîïôùûÿñæœ")
for z in film["title"]
):
return
try:
resp = await http.get(
f"/movie/{film['id']}",
params={"language": LANGUAGE, "append_to_response": "translations"},
)
resp.raise_for_status()
film["title"] = pick_title(resp.json())
except httpx.HTTPError:
pass
async def detail(movie_id): async def detail(movie_id):

View File

@@ -22,7 +22,7 @@ def build_movie(data):
"collection_id": collection.get("id"), "collection_id": collection.get("id"),
"collection_name": collection.get("name"), "collection_name": collection.get("name"),
"id": data["id"], "id": data["id"],
"title": data.get("title") or data.get("original_title") or "Ohne Titel", "title": tmdb.pick_title(data),
"original_title": data.get("original_title"), "original_title": data.get("original_title"),
"release_date": release or None, "release_date": release or None,
"year": int(release[:4]) if release[:4].isdigit() else None, "year": int(release[:4]) if release[:4].isdigit() else None,

View File

@@ -1,3 +1,5 @@
import unicodedata
import httpx import httpx
from .config import ( from .config import (
@@ -63,6 +65,36 @@ async def detail(http, movie_id):
return resp.json() return resp.json()
def _fremde_schrift(text):
"""Steht der Titel in einer nichtlateinischen Schrift?"""
for zeichen in text or "":
if zeichen.isalpha() and "LATIN" not in unicodedata.name(zeichen, ""):
return True
return False
def pick_title(data):
"""Deutscher Titel, sonst der englische aus den Translations.
Für indische, chinesische und koreanische Filme hat TMDB oft keine
deutsche Fassung und gibt den Originaltitel zurück — in Originalschrift.
Damit kann hier niemand etwas anfangen; IMDb zeigt an der Stelle den
umschriebenen Titel, und den liefert TMDB in den Translations mit.
"""
titel = data.get("title") or data.get("original_title") or ""
if titel and not _fremde_schrift(titel):
return titel
for entry in data.get("translations", {}).get("translations", []):
if entry.get("iso_639_1") != "en":
continue
englisch = entry.get("data", {}).get("title")
if englisch and not _fremde_schrift(englisch):
return englisch
return titel or "Ohne Titel"
def pick_overview(data): def pick_overview(data):
"""Deutsche Synopsis, sonst englische aus den Translations.""" """Deutsche Synopsis, sonst englische aus den Translations."""
if data.get("overview"): if data.get("overview"):

View File

@@ -3,6 +3,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { abonnieren } from './live' import { abonnieren } from './live'
import MovieCard from './components/MovieCard.vue' import MovieCard from './components/MovieCard.vue'
import FilterSidebar from './components/FilterSidebar.vue' import FilterSidebar from './components/FilterSidebar.vue'
import AehnlichTab from './components/AehnlichTab.vue'
import MerklisteTab from './components/MerklisteTab.vue' import MerklisteTab from './components/MerklisteTab.vue'
import MovieDetail from './components/MovieDetail.vue' import MovieDetail from './components/MovieDetail.vue'
import NamensWahl from './components/NamensWahl.vue' import NamensWahl from './components/NamensWahl.vue'
@@ -20,6 +21,10 @@ const loading = ref(true)
const error = ref('') const error = ref('')
const search = ref('') const search = ref('')
const sort = ref('random') const sort = ref('random')
// Suche und Ähnlichkeitsuche haben eine eigene Voreinstellung: TMDB liefert
// dort eine Reihenfolge nach Verwandtschaft — dieselbe, die IMDb unter
// "More like this" zeigt. Im Katalog gibt es die nicht.
const suchSort = ref('relevanz')
// Kompakt zeigt viele Karten nebeneinander, Details dafür mehr je Film // Kompakt zeigt viele Karten nebeneinander, Details dafür mehr je Film
const ANSICHT_SCHLUESSEL = 'filmabend_ansicht' const ANSICHT_SCHLUESSEL = 'filmabend_ansicht'
@@ -27,11 +32,29 @@ const ansicht = ref(localStorage.getItem(ANSICHT_SCHLUESSEL) || 'kompakt')
watch(ansicht, (wert) => localStorage.setItem(ANSICHT_SCHLUESSEL, wert)) watch(ansicht, (wert) => localStorage.setItem(ANSICHT_SCHLUESSEL, wert))
const selected = ref(null) const selected = ref(null)
// Der Katalog liefert nur sechs Bildpfade je Film. Beim Öffnen holen wir
// den vollen Datensatz nach — die Ansicht steht sofort, die restlichen
// Bilder kommen gleich danach.
//
// Über /api/search, nicht über /api/movies: Empfehlungen führen auch zu
// Filmen außerhalb des Katalogs. Dort greift der Rückgriff auf TMDB,
// sonst fehlten Trailer, Bilder, Laufzeit und der IMDb-Knopf.
async function filmOeffnen(movie) {
selected.value = movie
if (!movie?.id) return
try {
const resp = await fetch(`/api/search/${movie.id}`)
if (resp.ok && selected.value?.id === movie.id) selected.value = await resp.json()
} catch {
/* dann eben mit den Listendaten */
}
}
// Nach dem Neuladen soll derselbe Reiter offen sein // Nach dem Neuladen soll derselbe Reiter offen sein
const TAB_SCHLUESSEL = 'filmabend_tab' const TAB_SCHLUESSEL = 'filmabend_tab'
const TAB_IDS = [ const TAB_IDS = [
'netflix', 'canon', 'search', 'wheel', 'merkliste', 'watched', 'wuensche', 'netflix', 'canon', 'search', 'aehnlich', 'wheel', 'merkliste', 'watched',
'verwaltung', 'stream', 'wuensche', 'verwaltung', 'stream',
] ]
const gemerkterTab = localStorage.getItem(TAB_SCHLUESSEL) const gemerkterTab = localStorage.getItem(TAB_SCHLUESSEL)
const activeTab = ref(TAB_IDS.includes(gemerkterTab) ? gemerkterTab : 'netflix') const activeTab = ref(TAB_IDS.includes(gemerkterTab) ? gemerkterTab : 'netflix')
@@ -54,7 +77,25 @@ const REGLER = [
{ key: 'vote_average', label: 'Bewertung', feld: 'vote_average', step: 0.1, nachkomma: 1 }, { key: 'vote_average', label: 'Bewertung', feld: 'vote_average', step: 0.1, nachkomma: 1 },
] ]
// Bei der Suche steht kein Katalog dahinter, aus dem sich Grenzen ableiten
// ließen — TMDB reicht von den Anfängen bis heute
const SUCH_GRENZEN = {
vote_count: { min: 50, max: 50000 },
runtime: { min: 0, max: 240 },
year: { min: 1900, max: new Date().getFullYear() + 1 },
vote_average: { min: 0, max: 10 },
}
// Die 19 Genres, die TMDB kennt
const SUCH_GENRES = [
'Abenteuer', 'Action', 'Animation', 'Dokumentarfilm', 'Drama', 'Familie',
'Fantasy', 'Historie', 'Horror', 'Komödie', 'Krimi', 'Kriegsfilm',
'Liebesfilm', 'Musik', 'Mystery', 'Science Fiction', 'TV-Film', 'Thriller',
'Western',
].map((genre) => ({ genre, count: null }))
const grenzen = computed(() => { const grenzen = computed(() => {
if (activeTab.value === 'search' || activeTab.value === 'aehnlich') return SUCH_GRENZEN
const pool = activeTab.value === 'netflix' ? netflixMovies.value : canonMovies.value const pool = activeTab.value === 'netflix' ? netflixMovies.value : canonMovies.value
const out = {} const out = {}
for (const regler of REGLER) { for (const regler of REGLER) {
@@ -135,6 +176,7 @@ function resetGenres() {
// Nur Genres anbieten, die im offenen Reiter auch Treffer haben // Nur Genres anbieten, die im offenen Reiter auch Treffer haben
const genreOptions = computed(() => { const genreOptions = computed(() => {
if (activeTab.value === 'search' || activeTab.value === 'aehnlich') return SUCH_GENRES
const pool = activeTab.value === 'netflix' ? netflixMovies.value : canonMovies.value const pool = activeTab.value === 'netflix' ? netflixMovies.value : canonMovies.value
const counts = new Map() const counts = new Map()
for (const movie of pool) { for (const movie of pool) {
@@ -213,6 +255,7 @@ const tabs = computed(() => [
{ id: 'netflix', label: 'Netflix', count: netflixMovies.value.length }, { id: 'netflix', label: 'Netflix', count: netflixMovies.value.length },
{ id: 'canon', label: 'Horror-Kanon', count: canonGroups.value.length }, { id: 'canon', label: 'Horror-Kanon', count: canonGroups.value.length },
{ id: 'search', label: 'Suche', count: null }, { id: 'search', label: 'Suche', count: null },
{ id: 'aehnlich', label: 'Ähnlichkeitsuche', count: null },
...(host.value ...(host.value
? [] ? []
: [{ id: 'merkliste', label: 'Sammlungen', count: null }]), : [{ id: 'merkliste', label: 'Sammlungen', count: null }]),
@@ -223,6 +266,30 @@ const tabs = computed(() => [
...(host.value ? [{ id: 'verwaltung', label: 'Verwaltung', count: null }] : []), ...(host.value ? [{ id: 'verwaltung', label: 'Verwaltung', count: null }] : []),
]) ])
// Was die Leiste eingestellt hat — der Suche-Reiter schickt es an TMDB
const suchFilter = computed(() => {
const spanne = (key) => ranges.value[key] || []
return {
sort: suchSort.value,
include: include.value,
exclude: exclude.value,
stimmen: spanne('vote_count'),
dauer: spanne('runtime'),
jahr: spanne('year'),
note: spanne('vote_average'),
grenzen: grenzen.value,
}
})
// Aus der Filmansicht heraus zur Ähnlichkeitsuche springen
const aehnlichStart = ref(null)
function zurAehnlichkeitsuche(movie) {
aehnlichStart.value = movie
selected.value = null
activeTab.value = 'aehnlich'
}
const rad = ref(null) const rad = ref(null)
// Bei 2500 Karten kommt man sonst nicht mehr nach oben // Bei 2500 Karten kommt man sonst nicht mehr nach oben
@@ -515,7 +582,7 @@ onUnmounted(() => {
<template> <template>
<div class="page" :class="{ <div class="page" :class="{
breit: [ breit: [
'search', 'wheel', 'stream', 'watched', 'merkliste', 'wuensche', 'verwaltung', 'wheel', 'stream', 'watched', 'merkliste', 'wuensche', 'verwaltung',
].includes(activeTab), ].includes(activeTab),
}"> }">
<nav class="tabs"> <nav class="tabs">
@@ -533,7 +600,39 @@ onUnmounted(() => {
<span v-else class="host">Host</span> <span v-else class="host">Host</span>
</nav> </nav>
<SearchTab v-if="activeTab === 'search'" @open="selected = $event" /> <div v-if="activeTab === 'search' || activeTab === 'aehnlich'" class="layout">
<SearchTab
v-if="activeTab === 'search'"
:filter="suchFilter"
:ansicht="ansicht"
@open="selected = $event"
/>
<AehnlichTab
v-else
:filter="suchFilter"
:ansicht="ansicht"
:start="aehnlichStart"
@open="filmOeffnen"
@gestartet="aehnlichStart = null"
/>
<FilterSidebar
v-model:sort="suchSort"
v-model:ansicht="ansicht"
v-model:ranges="ranges"
:regler="REGLER"
:grenzen="grenzen"
:options="genreOptions"
:include="include"
:exclude="exclude"
:ohne-suche="true"
:mit-relevanz="true"
@toggle="toggleGenre"
@all="toggleAll"
@reset="resetGenres"
/>
</div>
<WheelTab <WheelTab
v-else-if="activeTab === 'wheel'" v-else-if="activeTab === 'wheel'"
@@ -591,7 +690,7 @@ onUnmounted(() => {
:movie="entry.parts[0]" :movie="entry.parts[0]"
:auf-dem-rad="aufDemRad.includes(entry.parts[0].id)" :auf-dem-rad="aufDemRad.includes(entry.parts[0].id)"
:ansicht="ansicht" :ansicht="ansicht"
@open="selected = $event" @open="filmOeffnen"
/> />
<SeriesCard <SeriesCard
v-else v-else
@@ -599,7 +698,7 @@ onUnmounted(() => {
:start="entry.best" :start="entry.best"
:auf-dem-rad="aufDemRad" :auf-dem-rad="aufDemRad"
:ansicht="ansicht" :ansicht="ansicht"
@open="selected = $event" @open="filmOeffnen"
/> />
</template> </template>
</div> </div>
@@ -631,7 +730,7 @@ onUnmounted(() => {
:movie="selected" :movie="selected"
:series="series" :series="series"
@close="selected = null" @close="selected = null"
@open="selected = $event" @open="filmOeffnen"
:mein-vorschlag="meinVorschlag" :mein-vorschlag="meinVorschlag"
:host="host" :host="host"
:gesehen="gesehen.some((e) => e.movie_id === selected.id)" :gesehen="gesehen.some((e) => e.movie_id === selected.id)"
@@ -639,6 +738,7 @@ onUnmounted(() => {
@gemerkt="ladeMerkliste" @gemerkt="ladeMerkliste"
@suggested="nachVorschlag" @suggested="nachVorschlag"
@watched="ladeGesehen" @watched="ladeGesehen"
@aehnlich="zurAehnlichkeitsuche"
/> />
</div> </div>
@@ -662,10 +762,11 @@ onUnmounted(() => {
z-index: 10; z-index: 10;
display: flex; display: flex;
gap: 0.35rem; gap: 0.35rem;
align-items: stretch;
/* Der negative Rand deckt das Polster der Seite ab, sonst scrollen /* Der negative Rand deckt das Polster der Seite ab, sonst scrollen
Karten oberhalb der Leiste durchs Bild */ Karten oberhalb der Leiste durchs Bild */
margin: -1.5rem -1.25rem 1.25rem; margin: -1.5rem -1.25rem 1.25rem;
padding: 0.7rem 1.25rem 0; padding: 0 1.25rem;
background: var(--bg); background: var(--bg);
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
} }
@@ -694,7 +795,7 @@ onUnmounted(() => {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.45rem; gap: 0.45rem;
padding: 0.6rem 1rem; padding: 0.8rem 1rem 0.6rem;
color: var(--muted); color: var(--muted);
font-size: 0.95rem; font-size: 0.95rem;
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;

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 }, exclude: { type: Array, required: true },
regler: { type: Array, default: () => [] }, regler: { type: Array, default: () => [] },
grenzen: { type: Object, default: () => ({}) }, grenzen: { type: Object, default: () => ({}) },
// Der Suche-Reiter hat ein eigenes, großes Suchfeld über der Liste
ohneSuche: Boolean,
// Nur wo TMDB die Reihenfolge liefert: Suche und Ähnlichkeitsuche
mitRelevanz: Boolean,
}) })
const emit = defineEmits(['toggle', 'all', 'reset']) const emit = defineEmits(['toggle', 'all', 'reset'])
@@ -46,8 +50,14 @@ function beschriften(regler) {
<template> <template>
<aside class="sidebar"> <aside class="sidebar">
<div class="controls"> <div class="controls">
<input v-model="search" type="search" placeholder="Titel suchen…" /> <input
v-if="!ohneSuche"
v-model="search"
type="search"
placeholder="Titel suchen…"
/>
<select v-model="sort"> <select v-model="sort">
<option v-if="mitRelevanz" value="relevanz">Wie auf IMDb</option>
<option value="random">Zufällig</option> <option value="random">Zufällig</option>
<option value="rating">Beste Bewertung</option> <option value="rating">Beste Bewertung</option>
<option value="newest">Neueste zuerst</option> <option value="newest">Neueste zuerst</option>

View File

@@ -42,7 +42,7 @@ async function oeffnen(item) {
<template> <template>
<div class="merkliste-tab"> <div class="merkliste-tab">
<p v-if="!items.length" class="note"> <p v-if="!items.length" class="note">
Noch nichts gemerkt. Öffne einen Film und klicke auf „Merken“. Noch nichts gemerkt. Öffne einen Film und klicke auf „Zur Sammlung“.
</p> </p>
<section v-for="gruppe in gruppen" :key="gruppe.titel"> <section v-for="gruppe in gruppen" :key="gruppe.titel">

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { backdropUrl, posterUrl } from '../images' import { backdropUrl, posterUrl } from '../images'
const props = defineProps({ const props = defineProps({
@@ -11,7 +11,9 @@ const props = defineProps({
gemerkt: { type: Boolean, default: false }, gemerkt: { type: Boolean, default: false },
}) })
const emit = defineEmits(['close', 'open', 'suggested', 'watched', 'gemerkt']) const emit = defineEmits([
'close', 'open', 'suggested', 'watched', 'gemerkt', 'aehnlich',
])
const vorschlagen = ref({ zustand: 'bereit' }) const vorschlagen = ref({ zustand: 'bereit' })
@@ -19,6 +21,18 @@ const schonVorgeschlagen = computed(
() => props.meinVorschlag?.movie_id === props.movie.id () => props.meinVorschlag?.movie_id === props.movie.id
) )
// Der eigene Zustand geht vor: nach einem Klick steht der Knopf sofort
// richtig, ohne auf die neu geladene Vorschlagsliste zu warten.
const aufDemRad = computed(() => {
if (vorschlagen.value.zustand === 'fertig') return true
if (vorschlagen.value.zustand === 'weg') return false
return schonVorgeschlagen.value
})
function radUmschalten() {
return aufDemRad.value ? zuruecknehmen() : abschicken()
}
async function merkenUmschalten() { async function merkenUmschalten() {
const weg = props.gemerkt const weg = props.gemerkt
await fetch(weg ? `/api/wishlist/${props.movie.id}` : '/api/wishlist', { await fetch(weg ? `/api/wishlist/${props.movie.id}` : '/api/wishlist', {
@@ -34,7 +48,7 @@ async function zuruecknehmen() {
try { try {
const resp = await fetch('/api/suggestions', { method: 'DELETE' }) const resp = await fetch('/api/suggestions', { method: 'DELETE' })
if (!resp.ok) throw new Error() if (!resp.ok) throw new Error()
vorschlagen.value = { zustand: 'bereit' } vorschlagen.value = { zustand: 'weg' }
emit('suggested') emit('suggested')
} catch { } catch {
vorschlagen.value = { zustand: 'fehler' } vorschlagen.value = { zustand: 'fehler' }
@@ -80,6 +94,28 @@ const shots = computed(() =>
(props.movie.images || []).map((path) => backdropUrl(path, 'w780')) (props.movie.images || []).map((path) => backdropUrl(path, 'w780'))
) )
// Was Leute noch geschaut haben, die diesen Film mochten
const aehnliche = ref([])
async function ladeAehnliche(id) {
aehnliche.value = []
try {
const resp = await fetch(`/api/movies/${id}/aehnliche`)
if (resp.ok && props.movie.id === id) aehnliche.value = await resp.json()
} catch {
/* dann eben ohne */
}
}
watch(
() => props.movie.id,
(id) => {
vorschlagen.value = { zustand: 'bereit' }
ladeAehnliche(id)
},
{ immediate: true }
)
// Ein angeklicktes Bild legt sich in voller Größe über die Ansicht // Ein angeklicktes Bild legt sich in voller Größe über die Ansicht
const grossesBild = ref(null) const grossesBild = ref(null)
@@ -107,13 +143,23 @@ function openPart(part) {
document.querySelector('.backdrop')?.scrollTo({ top: 0 }) document.querySelector('.backdrop')?.scrollTo({ top: 0 })
} }
// Die Sperre muss aufs Wurzelelement: den Bildlauf macht hier <html>,
// und an <body> allein läuft die Seite hinter der Ansicht weiter mit.
onMounted(() => { onMounted(() => {
document.addEventListener('keydown', onKey) document.addEventListener('keydown', onKey)
const wurzel = document.documentElement
// Mit der Sperre verschwindet die Bildlaufleiste. Ohne Ausgleich
// rutscht die ganze Seite um deren Breite zur Seite.
const leiste = window.innerWidth - wurzel.clientWidth
wurzel.style.overflow = 'hidden'
wurzel.style.paddingRight = leiste ? `${leiste}px` : ''
document.body.style.overflow = 'hidden' document.body.style.overflow = 'hidden'
}) })
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('keydown', onKey) document.removeEventListener('keydown', onKey)
document.documentElement.style.overflow = ''
document.documentElement.style.paddingRight = ''
document.body.style.overflow = '' document.body.style.overflow = ''
}) })
</script> </script>
@@ -141,7 +187,7 @@ onUnmounted(() => {
</p> </p>
<p class="genres">{{ (movie.genres || []).join(' · ') }}</p> <p class="genres">{{ (movie.genres || []).join(' · ') }}</p>
<p v-if="movie.teaser" class="teaser">{{ movie.teaser }}</p> <p v-if="movie.teaser" class="teaser">{{ movie.teaser }}</p>
<div class="spalte-links"> <div class="links">
<a <a
v-if="movie.on_netflix" v-if="movie.on_netflix"
class="netflix" class="netflix"
@@ -162,31 +208,28 @@ onUnmounted(() => {
</a> </a>
</div> </div>
<div v-if="!host" class="vorschlag"> <div class="vorschlag">
<button type="button" class="merken" :class="{ an: gemerkt }" @click="merkenUmschalten"> <button type="button" class="merken" @click="$emit('aehnlich', movie)">
{{ gemerkt ? 'Gemerkt' : 'Merken' }} Ähnliche finden
</button> </button>
<template v-if="schonVorgeschlagen || vorschlagen.zustand === 'fertig'"> <button
<span class="ok">Steht auf dem Rad.</span> v-if="!host"
type="button"
class="merken"
:class="{ an: gemerkt }"
@click="merkenUmschalten"
>
{{ gemerkt ? 'In Sammlung' : 'Zur Sammlung' }}
</button>
<template v-if="!host">
<button <button
type="button" type="button"
class="merken"
:class="{ an: aufDemRad }"
:disabled="vorschlagen.zustand === 'laeuft'" :disabled="vorschlagen.zustand === 'laeuft'"
@click="zuruecknehmen" @click="radUmschalten"
> >
Wieder runternehmen {{ aufDemRad ? 'Auf dem Rad' : 'Zum Rad' }}
</button>
</template>
<template v-else>
<span v-if="meinVorschlag" class="hinweis">
Du hast „{{ meinVorschlag.title }}“ vorgeschlagen. Ein neuer
Vorschlag ersetzt ihn.
</span>
<button
type="button"
:disabled="vorschlagen.zustand === 'laeuft'"
@click="abschicken"
>
Vorschlagen
</button> </button>
<span v-if="vorschlagen.zustand === 'fehler'" class="fehler"> <span v-if="vorschlagen.zustand === 'fehler'" class="fehler">
Hat nicht geklappt. Hat nicht geklappt.
@@ -213,8 +256,11 @@ onUnmounted(() => {
</div> </div>
</header> </header>
<div class="links"> <section v-if="movie.overview" class="handlung">
<section v-if="series.length"> <h3>Handlung</h3>
<p class="overview">{{ movie.overview }}</p>
</section>
<section v-if="series.length" class="reihe">
<h3>{{ movie.collection_name || 'Die Reihe' }}</h3> <h3>{{ movie.collection_name || 'Die Reihe' }}</h3>
<div class="series"> <div class="series">
<button <button
@@ -238,11 +284,6 @@ onUnmounted(() => {
</div> </div>
</section> </section>
<section v-if="movie.overview">
<h3>Handlung</h3>
<p class="overview">{{ movie.overview }}</p>
</section>
</div>
<section v-if="trailer" class="rechts"> <section v-if="trailer" class="rechts">
<div class="video"> <div class="video">
@@ -255,6 +296,28 @@ onUnmounted(() => {
</div> </div>
</section> </section>
<section v-if="aehnliche.length" class="aehnliche">
<h3>Ähnliche Filme</h3>
<div class="series">
<button
v-for="film in aehnliche"
:key="film.id"
class="part"
type="button"
@click="$emit('open', film)"
>
<img
v-if="posterUrl(film.poster_path, 'w154')"
:src="posterUrl(film.poster_path, 'w154')"
:alt="film.title"
loading="lazy"
/>
<span v-else class="no-poster">Kein Cover</span>
<span class="part-title">{{ film.title }}</span>
<span class="part-year">{{ film.year }}</span>
</button>
</div>
</section>
<section v-if="shots.length" class="shots-section"> <section v-if="shots.length" class="shots-section">
<div class="shots"> <div class="shots">
<button <button
@@ -274,9 +337,14 @@ onUnmounted(() => {
</section> </section>
</article> </article>
<div v-if="grossesBild" class="lupe" @click="grossesBild = null"> <!-- Muss aus der Ansicht heraus: deren backdrop-filter macht sie zum
<img :src="grossesBild" :alt="movie.title" /> Bezugsrahmen, und "fixed" richtet sich dann an ihr aus statt am
</div> Fenster — das Bild landete am Anfang der Ansicht statt im Blick. -->
<Teleport to="body">
<div v-if="grossesBild" class="lupe" @click="grossesBild = null">
<img :src="grossesBild" :alt="movie.title" />
</div>
</Teleport>
</div> </div>
</template> </template>
@@ -288,6 +356,9 @@ onUnmounted(() => {
display: flex; display: flex;
justify-content: center; justify-content: center;
overflow-y: auto; overflow-y: auto;
/* Am unteren Ende soll der Bildlauf stehen bleiben, nicht auf die
Seite dahinter überspringen */
overscroll-behavior: contain;
padding: 2rem 1rem; padding: 2rem 1rem;
background: rgba(0, 0, 0, 0.8); background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
@@ -307,17 +378,22 @@ onUnmounted(() => {
border-radius: 14px; border-radius: 14px;
} }
/* Links Cover, Text und Knöpfe, rechts der Trailer, darunter die Bilder */ /* Oben links Cover, Text und Knöpfe, rechts daneben der Trailer. Darunter
.sheet > header, über die ganze Breite: Handlung, Reihe, Ähnliche, Bilder. */
.sheet > .spalte-links { .sheet > header {
grid-column: 1; grid-area: 1 / 1;
} }
.sheet > .rechts { .sheet > .rechts {
grid-column: 2; grid-area: 1 / 2 / span 2;
grid-row: 1 / span 2;
} }
.sheet > .handlung {
grid-area: 2 / 1;
}
.sheet > .reihe,
.sheet > .aehnliche,
.sheet > .shots-section { .sheet > .shots-section {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
@@ -327,10 +403,12 @@ onUnmounted(() => {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.sheet > .rechts,
.sheet > .shots-section,
.sheet > header, .sheet > header,
.sheet > .spalte-links { .sheet > .rechts,
.sheet > .handlung,
.sheet > .reihe,
.sheet > .aehnliche,
.sheet > .shots-section {
grid-column: 1; grid-column: 1;
grid-row: auto; grid-row: auto;
} }
@@ -403,7 +481,7 @@ h2 {
.links { .links {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
margin-top: 0.35rem; margin-top: 0.75rem;
} }
.netflix, .netflix,
@@ -424,10 +502,13 @@ h2 {
background: #f5c518; background: #f5c518;
} }
/* Der Textblock richtet seine Kinder linksbündig aus, dadurch war der
Trennstrich nur so breit wie die Knöpfe darunter */
.vorschlag { .vorschlag {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.4rem; gap: 0.5rem;
align-self: stretch;
align-items: center; align-items: center;
margin-top: 0.75rem; margin-top: 0.75rem;
padding-top: 0.75rem; padding-top: 0.75rem;
@@ -473,7 +554,8 @@ h2 {
.gesehen { .gesehen {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.4rem; gap: 0.5rem;
align-self: stretch;
align-items: center; align-items: center;
margin-top: 0.6rem; margin-top: 0.6rem;
} }

View File

@@ -1,6 +1,13 @@
<script setup> <script setup>
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import MovieCard from './MovieCard.vue' import MovieCard from './MovieCard.vue'
import { filterParameter } from '../filter.js'
const props = defineProps({
// Was die Filterleiste eingestellt hat
filter: { type: Object, default: () => ({}) },
ansicht: { type: String, default: 'kompakt' },
})
const emit = defineEmits(['open']) const emit = defineEmits(['open'])
@@ -8,28 +15,50 @@ const query = ref('')
const results = ref([]) const results = ref([])
const loading = ref(false) const loading = ref(false)
const error = ref('') const error = ref('')
const searched = ref(false) const gestoebert = ref(false)
let timer = null let timer = null
let laufende = 0 let laufende = 0
async function run(term) { // Ohne Suchbegriff stöbert TMDB nach den Filtern, mit Begriff sucht es
if (term.trim().length < 2) { // nach Titeln — dann greifen die Filter erst hier.
results.value = [] function passt(movie) {
searched.value = false const f = props.filter
return const g = f.grenzen || {}
const drin = (werte, wert, key) => {
if (!werte || wert == null) return true
const grenze = g[key]
if (!grenze) return true
if (werte[0] > grenze.min && wert < werte[0]) return false
if (werte[1] < grenze.max && wert > werte[1]) return false
return true
} }
const genres = movie.genres || []
if ((f.include || []).length && !genres.some((x) => f.include.includes(x))) return false
if ((f.exclude || []).some((x) => genres.includes(x))) return false
return (
drin(f.stimmen, movie.vote_count, 'vote_count') &&
drin(f.jahr, movie.year, 'year') &&
drin(f.note, movie.vote_average, 'vote_average')
)
}
async function run(term) {
const meine = ++laufende const meine = ++laufende
loading.value = true loading.value = true
error.value = '' error.value = ''
const stoebern = term.trim().length < 2
try { try {
const resp = await fetch(`/api/search?q=${encodeURIComponent(term.trim())}`) const url = stoebern
? `/api/discover?${new URLSearchParams(filterParameter(props.filter))}`
: `/api/search?q=${encodeURIComponent(term.trim())}`
const resp = await fetch(url)
if (!resp.ok) throw new Error('Suche fehlgeschlagen') if (!resp.ok) throw new Error('Suche fehlgeschlagen')
const data = await resp.json() const data = await resp.json()
// Eine ältere Anfrage darf ein neueres Ergebnis nicht überschreiben // Eine ältere Anfrage darf ein neueres Ergebnis nicht überschreiben
if (meine !== laufende) return if (meine !== laufende) return
results.value = data results.value = stoebern ? data : data.filter(passt)
searched.value = true gestoebert.value = stoebern
} catch (err) { } catch (err) {
if (meine === laufende) error.value = err.message if (meine === laufende) error.value = err.message
} finally { } finally {
@@ -37,10 +66,24 @@ async function run(term) {
} }
} }
watch(query, (term) => { function spaeterSuchen() {
clearTimeout(timer) clearTimeout(timer)
timer = setTimeout(() => run(term), 350) timer = setTimeout(() => run(query.value), 350)
}) }
// Enter sucht sofort, statt die Tippbremse abzuwarten
function jetztSuchen() {
clearTimeout(timer)
run(query.value)
}
function leeren() {
query.value = ''
jetztSuchen()
}
watch(query, spaeterSuchen)
watch(() => props.filter, spaeterSuchen, { deep: true, immediate: true })
async function open(movie) { async function open(movie) {
const resp = await fetch(`/api/search/${movie.id}`) const resp = await fetch(`/api/search/${movie.id}`)
@@ -50,24 +93,35 @@ async function open(movie) {
<template> <template>
<div class="search-tab"> <div class="search-tab">
<input <div class="suchzeile">
v-model="query" <input
type="search" v-model="query"
class="big-search" type="search"
placeholder="Globale Suche …" class="big-search"
autofocus placeholder="Globale Suche …"
/> autofocus
@keyup.enter="jetztSuchen"
/>
<button v-if="query" type="button" class="leeren" title="Suche leeren" @click="leeren">
×
</button>
<button type="button" class="neu" title="Neu suchen" @click="jetztSuchen">
Suchen
</button>
</div>
<p v-if="loading" class="note">Suche läuft…</p> <p v-if="loading" class="note">Suche läuft…</p>
<p v-else-if="error" class="note error">{{ error }}</p> <p v-else-if="error" class="note error">{{ error }}</p>
<p v-else-if="searched && !results.length" class="note"> <p v-else-if="!results.length" class="note">
Nichts gefunden für „{{ query }}“. {{ gestoebert ? 'Nichts gefunden. Stell die Filter weiter.' : `Nichts gefunden für „${query}“.` }}
</p> </p>
<div class="grid">
<div class="grid" :class="ansicht">
<MovieCard <MovieCard
v-for="movie in results" v-for="movie in results"
:key="movie.id" :key="movie.id"
:movie="movie" :movie="movie"
:ansicht="ansicht"
@open="open" @open="open"
/> />
</div> </div>
@@ -75,19 +129,63 @@ async function open(movie) {
</template> </template>
<style scoped> <style scoped>
.big-search { .search-tab {
width: 100%; min-width: 0;
}
.suchzeile {
position: relative;
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
padding: 0.9rem 1.1rem; }
.big-search {
flex: 1;
padding: 0.9rem 2.6rem 0.9rem 1.1rem;
font-size: 1.05rem; font-size: 1.05rem;
} }
.leeren {
position: absolute;
top: 50%;
right: 7.8rem;
color: var(--muted);
font-size: 1.4rem;
line-height: 1;
transform: translateY(-50%);
}
.leeren:hover {
color: var(--text);
}
.neu {
padding: 0 1.4rem;
font-size: 0.95rem;
font-weight: 600;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 8px;
}
.neu:hover {
border-color: var(--accent);
}
.grid { .grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 1.1rem; gap: 1.1rem;
} }
.grid.details {
grid-template-columns: repeat(
auto-fill,
min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem))
);
}
.note { .note {
padding: 2.5rem 0; padding: 2.5rem 0;
color: var(--muted); color: var(--muted);

24
frontend/src/filter.js Normal file
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'),
}
}