"""Jikan API Client (MyAnimeList) — kostenlose Anime-Datenbank OHNE API-Key. Commander-Fund 23.07.: Für Anime-Discs (häufiger Fall im Heimlab) ist MAL die präziseste Quelle — „Evangelion: 2.22" existiert dort exakt, mit Poster und Beschreibung. Kein Key, Rate-Limit 3 req/s (für Disc-Erkennung irrelevant). Doku: https://docs.api.jikan.moe/ """ from difflib import SequenceMatcher from typing import Dict, Optional import requests from cache import get, set as cache_set JIKAN_BASE_URL = "https://api.jikan.moe/v4" def titel_aehnlichkeit(a: str, b: str) -> float: """Normalisierte Titel-Ähnlichkeit 0..1 (pure Funktion, testbar).""" def norm(t: str) -> str: return "".join(c for c in t.lower() if c.isalnum() or c == " ").strip() a_n, b_n = norm(a), norm(b) if not a_n or not b_n: return 0.0 return SequenceMatcher(None, a_n, b_n).ratio() class JikanClient: MINDEST_AEHNLICHKEIT = 0.55 def __init__(self): self.session = requests.Session() def lookup(self, title: str) -> Optional[Dict]: """Sucht per Titel; nimmt den ÄHNLICHSTEN Treffer, nicht blind den ersten.""" cache_key = f"jikan:{title.lower()}" cached = get(cache_key) if cached: return cached try: response = self.session.get( f"{JIKAN_BASE_URL}/anime", params={"q": title, "limit": 5, "sfw": "true"}, timeout=10, ) response.raise_for_status() eintraege = response.json().get("data") or [] except Exception as e: print(f"Jikan API Error: {e}") return None bester, bester_score = None, 0.0 for eintrag in eintraege: varianten = [eintrag.get("title") or ""] if eintrag.get("title_english"): varianten.append(eintrag["title_english"]) score = max(titel_aehnlichkeit(title, v) for v in varianten) if score > bester_score: bester, bester_score = eintrag, score if not bester or bester_score < self.MINDEST_AEHNLICHKEIT: return None bilder = (bester.get("images") or {}).get("jpg") or {} ergebnis = { "type": "movie" if bester.get("type") == "Movie" else "tv", "id": f"mal-{bester.get('mal_id', '')}", "title": bester.get("title_english") or bester.get("title") or title, "year": bester.get("year"), "overview": (bester.get("synopsis") or "")[:600], "poster_path": bilder.get("large_image_url") or bilder.get("image_url") or "", "backdrop_path": "", "runtime": 0, "genres": [g.get("name", "") for g in (bester.get("genres") or []) if g.get("name")], "source": "jikan", "match_score": round(bester_score, 2), } cache_set(cache_key, ergebnis) return ergebnis