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