Files
Hitonabi 0c269a97ee fix(metadaten): exakt schlaegt unscharf - Jikan beendet die Kette nur bei Treffer
Der SAVEPOINT v3.16 vermutete, das Aehnlichkeits-Gate 0,55 sei "grosszuegig" und
Jikan stehe in der Kette zu frueh (vor OMDb). Nachgerechnet - titel_aehnlichkeit
ist eine reine Funktion - ergibt sich ein anderes Bild als vermutet:

  "Alien"           vs. "Alien 9"           -> 0,83  TRIFFT
  "Inception"        vs. "Deception"         -> 0,78  TRIFFT
  "Hero"             vs. "Heroman"           -> 0,73  TRIFFT
  "The Dark Knight"  vs. "Dark Knight Rises" -> 0,69  TRIFFT

Ein Kinofilm, den TMDB verpasst, bekam damit Anime-Metadaten mit Confidence
0,85 - und OMDb, das ihn kennt, wurde nie gefragt.

Bemerkenswert dabei, und das widerlegt die Vermutung "einfach zu niedrig": Fuer
den Fall, fuer den Jikan ueberhaupt eingebaut wurde, ist das Gate sogar zu HOCH.
"Evangelion 2.22" gegen den MAL-Titel "Evangelion: 2.0 You Can (Not) Advance"
ergibt 0,51 und faellt durch. Eine einzelne Zahl kann beides nicht leisten.

Deshalb zwei Schwellen statt Umsortieren (Reihenfolge bleibt, AGENTS Regel B):
Nur ein praktisch exakter Titel (>= 0,9) beendet die Kette. Ein unscharfer
Treffer wird GEMERKT, dann wird OMDb gefragt - und erst wenn OMDb nichts hat,
kommt er als VORSCHLAG mit Confidence 0,6 zum Zug. Damit gewinnt "exakt" immer
gegen "unscharf", egal aus welcher Quelle.

Antwort auf die Commander-Frage "JIKAN ist drin - wird das genutzt?": ja, und ab
jetzt an der richtigen Stelle.

Ehrlicher Vorbehalt: Live gegengeprueft ist das nicht - MyAnimeList war waehrend
dieser Sitzung durchweg weg (Jikan antwortete HTTP 504 auf jede Anfrage). Die
Arithmetik des Gates ist davon unberuehrt und in test_jikan_helpers.py
festgehalten, die Kettenlogik in test_prescan_helpers.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 12:59:26 +02:00

130 lines
5.1 KiB
Python

"""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:
# Unter dieser Ähnlichkeit gibt lookup() gar nichts zurück.
MINDEST_AEHNLICHKEIT = 0.55
# Ab dieser Ähnlichkeit gilt der Treffer als SICHER und beendet die
# Metadaten-Kette. Dazwischen ist er nur ein Vorschlag: erst wird OMDb
# gefragt, und nur wenn das nichts hat, kommt er zum Zug.
#
# Warum zwei Schwellen (26.07.2026, ausgerechnet mit titel_aehnlichkeit):
# Eine einzelne Zahl kann beide Aufgaben nicht leisten. 0,55 lässt kurze
# Kinofilm-Titel durch, die hier nichts zu suchen haben („Alien" gegen
# „Alien 9" = 0,83), ist aber gleichzeitig zu streng für den Fall, für den
# diese Quelle gebaut wurde („Evangelion 2.22" gegen „Evangelion: 2.0 You
# Can (Not) Advance" = 0,51). Messwerte in test_jikan_helpers.py.
SICHER_AEHNLICHKEIT = 0.9
def __init__(self):
self.session = requests.Session()
def suche(self, title: str) -> list:
"""Roh-Kandidaten für die manuelle Korrektur (ohne Ähnlichkeits-Gate)."""
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:
return []
ergebnisse = []
for e in eintraege:
bilder = (e.get("images") or {}).get("jpg") or {}
ergebnisse.append({
"title": e.get("title_english") or e.get("title") or "",
"year": e.get("year"),
"poster": bilder.get("large_image_url") or bilder.get("image_url") or "",
"overview": (e.get("synopsis") or "")[:200],
"type": "movie" if e.get("type") == "Movie" else "tv",
"source": "jikan",
"id": f"mal-{e.get('mal_id', '')}",
})
return ergebnisse
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
# Jikan wirft sporadisch 504 (Gratis-Dienst) — ein Retry rettet die
# meisten Fälle, mehr wäre Belästigung.
eintraege = None
for versuch in range(2):
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 []
break
except Exception as e:
print(f"Jikan API Error (Versuch {versuch + 1}): {e}")
if versuch == 0:
import time
time.sleep(2)
if eintraege is None:
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