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