Files
horror/backend/app/canon.py
2026-08-12 19:30:41 +02:00

129 lines
4.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Die Kanon-Liste gegen TMDB auflösen.
Die Vorlage nennt deutsche Verleihtitel. TMDB kennt die meisten, aber nicht
alle — deshalb mehrere Anläufe pro Titel, vom genauesten zum gröbsten.
"""
import asyncio
import logging
import re
import httpx
from .canon_list import CANON
from .config import LANGUAGE, TMDB_BASE, TMDB_TOKEN
log = logging.getLogger("canon")
HEADERS = {"Authorization": f"Bearer {TMDB_TOKEN}", "accept": "application/json"}
YEAR = re.compile(r"\s*\((\d{4})\)\s*$")
CONCURRENCY = 8
# Wo die Titelsuche danebengreift, steht die TMDB-ID fest.
OVERRIDES = {
"Us": 458723, # sonst "Forgive Us All" von 2025
"Nightmare 2 Die Rache": 10014, # sonst "Blade Master"
"REC 2": 10664, # sonst ein Freddy-Verschnitt von 2025
"Salò oder die 120 Tage von Sodom": 5336,
"Freitag der 13. Teil 3": 9728,
"Freitag der 13. Teil 7 Jason im Blutrausch": 10281,
# Reihen, in denen die Suche Teile durcheinanderwarf: ohne Jahresangabe
# liefert TMDB den jüngsten Titel zuerst, also festnageln
"Phantasm Das Böse": 9638,
"Phantasm: Ravager Das Böse V": 262848,
"Freitag der 13.": 4488,
"Freitag der 13. (2009)": 13207,
"Final Destination": 9532,
"The Final Destination": 19912,
"Final Destination Bloodlines": 574475,
"A Quiet Place": 447332,
"A Quiet Place 2": 520763,
# "Das Böse" ist auf Deutsch sowohl Phantasm als auch Amityville Horror
"Das Böse (1979)": 11449,
# Gemeint ist das japanische Ring 2, nicht das US-Remake "The Ring Two"
"Ring 2": 9669,
# Traf einen gleichnamigen Film von 1976 statt Romeros Teil von 2007
"Diary of the Dead": 13025,
}
def parse_entry(raw):
"""'Psycho (1960)' → ('Psycho', 1960). Das Jahr trennt Gleichnamige."""
match = YEAR.search(raw)
if not match:
return raw.strip(), None
return YEAR.sub("", raw).strip(), int(match.group(1))
def variants(title):
"""Vom vollen Titel zu immer gröberen Formen."""
yield title
# Deutsche Verleihtitel hängen oft einen Untertitel an
for sep in (" ", " - ", ": "):
if sep in title:
head = title.split(sep)[0].strip()
if len(head) > 2:
yield head
plain = title.replace("", "").replace("", "'").strip()
if plain != title:
yield plain
async def search(http, title, year):
for variant in variants(title):
params = {"query": variant, "language": LANGUAGE, "include_adult": "true"}
if year:
params["primary_release_year"] = year
resp = await http.get("/search/movie", params=params)
resp.raise_for_status()
results = resp.json().get("results")
if results:
return results[0], variant
# Jahr kann in der Vorlage abweichen — zweiter Anlauf ohne
if year:
params.pop("primary_release_year")
resp = await http.get("/search/movie", params=params)
resp.raise_for_status()
results = resp.json().get("results")
if results:
near = [
r
for r in results
if (r.get("release_date") or "")[:4].isdigit()
and abs(int(r["release_date"][:4]) - year) <= 1
]
if near:
return near[0], variant
return None, None
def client():
return httpx.AsyncClient(base_url=TMDB_BASE, headers=HEADERS, timeout=30.0)
async def resolve_all():
"""Gibt je Kanon-Eintrag die Liste der gefundenen TMDB-Treffer zurück."""
semaphore = asyncio.Semaphore(CONCURRENCY)
async def one(http, raw):
if raw in OVERRIDES:
async with semaphore:
resp = await http.get(
f"/movie/{OVERRIDES[raw]}", params={"language": LANGUAGE}
)
resp.raise_for_status()
return {"raw": raw, "hit": resp.json(), "used": "override"}
title, year = parse_entry(raw)
async with semaphore:
hit, used = await search(http, title, year)
return {"raw": raw, "title": title, "year": year, "hit": hit, "used": used}
async with client() as http:
groups = await asyncio.gather(
*(
asyncio.gather(*(one(http, raw) for raw in group))
for group in CANON
)
)
return list(groups)