From 0c269a97ee48aeb5a22d01bc84c3be27b5218275 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Sun, 26 Jul 2026 12:59:26 +0200 Subject: [PATCH] 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 --- docker/api/clients/jikan.py | 12 ++++ docker/api/prescan/prescan.py | 44 ++++++++++++-- docker/api/test_jikan_helpers.py | 78 +++++++++++++++++++++++++ docker/api/test_prescan_helpers.py | 93 ++++++++++++++++++++++++++++++ 4 files changed, 223 insertions(+), 4 deletions(-) create mode 100644 docker/api/test_jikan_helpers.py diff --git a/docker/api/clients/jikan.py b/docker/api/clients/jikan.py index f1c7512..740cc07 100644 --- a/docker/api/clients/jikan.py +++ b/docker/api/clients/jikan.py @@ -28,7 +28,19 @@ def titel_aehnlichkeit(a: str, b: str) -> float: 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() diff --git a/docker/api/prescan/prescan.py b/docker/api/prescan/prescan.py index ff0b118..99635fc 100644 --- a/docker/api/prescan/prescan.py +++ b/docker/api/prescan/prescan.py @@ -19,7 +19,6 @@ from clients.thetvdb import TheTVDBClient from cache import get, set as cache_set from cache.keys import generate_prescan_key - def normalize_disc_label(label: str) -> str: """Disc-Labels wie 'PULP_FICTION_DE' → 'Pulp Fiction De' (pure Funktion). @@ -442,15 +441,43 @@ class PreScan: break # Fallback 1: Jikan/MyAnimeList (kostenlos, KEIN Key) — für Anime die - # präziseste Quelle; wählt per Titel-Ähnlichkeit, nicht Treffer #1 + # präziseste Quelle; wählt per Titel-Ähnlichkeit, nicht Treffer #1. + # + # NEU 26.07.2026 — „exakt schlägt unscharf", unabhängig von der + # Reihenfolge. Vorher galt der erste Jikan-Treffer über dem Gate 0,55 + # sofort als sicherer Fund (confidence 0,85) und OMDb kam nie dran. + # Das ist zu großzügig, und zwar messbar (offline gerechnet mit + # titel_aehnlichkeit, echte MyAnimeList-Titel): + # + # „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 — und + # OMDb, das ihn kennt, wurde nie gefragt. Bemerkenswert dabei, und das + # widerlegt die Vermutung im SAVEPOINT v3.16, das Gate sei einfach zu + # niedrig: Für den Fall, für den Jikan überhaupt eingebaut wurde, ist + # es sogar zu HOCH — „Evangelion 2.22" vs. „Evangelion: 2.0 You Can + # (Not) Advance" ergibt 0,51 und fällt durch. Eine einzelne Zahl kann + # beides nicht leisten. + # + # Deshalb: Nur ein praktisch exakter Titel gilt sofort. Ein unscharfer + # Treffer wird GEMERKT, dann OMDb gefragt — und erst wenn OMDb nichts + # hat, kommt er als VORSCHLAG (niedrige Confidence) zum Zug. + jikan_unscharf = None if not matched: for kandidat in kandidaten: jikan_treffer = self.jikan.lookup(kandidat) - if jikan_treffer: + if not jikan_treffer: + continue + if jikan_treffer.get("match_score", 0) >= JikanClient.SICHER_AEHNLICHKEIT: confidence = 0.85 metadata = jikan_treffer matched = True - break + else: + jikan_unscharf = jikan_treffer + break # Fallback 2: OMDb (eigene Datenbasis — findet oft, was TMDB nicht # exakt trifft; braucht OMDB_API_KEY, sonst überspringt es sich selbst) @@ -464,6 +491,15 @@ class PreScan: matched = True break + # Fallback 2b: der unscharfe Jikan-Treffer — besser als nichts, aber + # ehrlich als Vorschlag ausgewiesen (0,6 = dieselbe Stufe wie ein + # TMDB-Vorschlag ohne exakten Treffer; das UI zeigt die Prozentzahl an + # und der Nutzer kann korrigieren). + if not matched and jikan_unscharf: + confidence = 0.6 + metadata = jikan_unscharf + matched = True + # Fallback 2: bester TMDB-Vorschlag ohne exakten Treffer — als # VORSCHLAG gekennzeichnet (niedrige Confidence, Nutzer korrigiert) if not matched and movies: diff --git a/docker/api/test_jikan_helpers.py b/docker/api/test_jikan_helpers.py new file mode 100644 index 0000000..9df6e74 --- /dev/null +++ b/docker/api/test_jikan_helpers.py @@ -0,0 +1,78 @@ +"""Was das Ähnlichkeits-Gate von Jikan wirklich durchlässt — gemessen. + +Hintergrund (26.07.2026): Der SAVEPOINT v3.16 vermutete, das Gate 0,55 sei +„großzügig" und Jikan stehe in der Metadaten-Kette zu früh (vor OMDb). Beides +war nachrechenbar, weil `titel_aehnlichkeit` eine reine Funktion ist — und die +Rechnung ergab ein anderes Bild als die Vermutung: + +- Für kurze Kinofilm-Titel ist das Gate WIRKLICH zu locker (Alien → 0,83). +- Für den Fall, für den Jikan eingebaut wurde, ist es sogar zu STRENG + („Evangelion 2.22" gegen den MAL-Titel → 0,51, fällt durch). + +Eine einzelne Zahl kann also beides nicht leisten. Die Folge steht in +prescan._scan_video: „exakt schlägt unscharf" statt „erster gewinnt". + +Die Anime-Titel unten existieren auf MyAnimeList; die Zahlen sind ausgerechnet, +nicht geschätzt. MyAnimeList selbst war bei dieser Messung nicht erreichbar +(Jikan antwortete durchweg HTTP 504) — die Live-Abfrage steht damit weiter aus, +die Arithmetik des Gates ist davon aber unberührt. +""" + +from clients.jikan import JikanClient, titel_aehnlichkeit + + +def test_kurze_kinofilm_titel_reissen_das_gate_0_55(): + """Diese vier würden ohne die Änderung Anime-Metadaten bekommen, obwohl + OMDb die Filme kennt — es wurde nur nie gefragt.""" + faelle = [ + ("Alien", "Alien 9", 0.83), + ("Inception", "Deception", 0.78), + ("Hero", "Heroman", 0.73), + ("The Dark Knight", "Dark Knight Rises", 0.69), + ] + for disc, anime, erwartet in faelle: + score = titel_aehnlichkeit(disc, anime) + assert round(score, 2) == erwartet, f"{disc} vs {anime}: {score}" + # ... liegt über dem Gate (Jikan gibt also einen Treffer zurück) ... + assert score >= JikanClient.MINDEST_AEHNLICHKEIT + # ... aber unter „sicher", darf die Kette also nicht beenden. + assert score < JikanClient.SICHER_AEHNLICHKEIT + + +def test_echter_treffer_gilt_weiter_als_sicher(): + """Exakter Titel → 1,0. Der Normalfall eines echten Anime-Fundes darf durch + die Verschärfung NICHT verlorengehen (AGENTS Regel B).""" + assert titel_aehnlichkeit("Monster", "Monster") == 1.0 + assert titel_aehnlichkeit("Cowboy Bebop", "COWBOY BEBOP") == 1.0 + # Interpunktion und Kleinschreibung sind egal — norm() wirft sie weg + assert titel_aehnlichkeit("Akira", "AKIRA!") == 1.0 + for a, b in (("Monster", "Monster"), ("Akira", "AKIRA!")): + assert titel_aehnlichkeit(a, b) >= JikanClient.SICHER_AEHNLICHKEIT + + +def test_untertitel_zaehlt_noch_als_sicher_genug(): + """Discs tragen oft den Kurztitel, MAL den vollen. Das muss ein sicherer + Treffer bleiben, sonst hilft die Quelle bei Anime nicht mehr.""" + assert titel_aehnlichkeit("Steins Gate", "Steins;Gate") >= JikanClient.SICHER_AEHNLICHKEIT + + +def test_der_fall_fuer_den_jikan_gebaut_wurde_faellt_heute_durch(): + """Gegenbeweis zur Vermutung „das Gate ist zu großzügig": Für Evangelion + 2.22 ist es zu STRENG. Der Wert ist dokumentiert, damit ein späteres + Anheben des Gates nicht unbemerkt auch diesen Fall killt.""" + score = titel_aehnlichkeit( + "Evangelion 2.22", "Evangelion: 2.0 You Can (Not) Advance" + ) + assert round(score, 2) == 0.51 + assert score < JikanClient.MINDEST_AEHNLICHKEIT + + +def test_voellig_anderer_titel_wird_verworfen(): + score = titel_aehnlichkeit("Blade", "Blade of the Immortal") + assert score < JikanClient.MINDEST_AEHNLICHKEIT + + +def test_leere_titel_geben_null(): + assert titel_aehnlichkeit("", "Monster") == 0.0 + assert titel_aehnlichkeit("Monster", "") == 0.0 + assert titel_aehnlichkeit("!!!", "???") == 0.0 diff --git a/docker/api/test_prescan_helpers.py b/docker/api/test_prescan_helpers.py index 5708f93..8e67650 100644 --- a/docker/api/test_prescan_helpers.py +++ b/docker/api/test_prescan_helpers.py @@ -47,3 +47,96 @@ def test_omdb_jahr_parsing(): assert parse_year("2005–2012") == 2005 # Serien-Zeitspanne assert parse_year("N/A") is None assert parse_year("") is None + + +# --- Reihenfolge der Metadaten-Quellen: exakt schlägt unscharf --------------- +# +# Änderung vom 26.07.2026. Vorher galt der erste Jikan-Treffer über dem Gate +# 0,55 sofort als sicherer Fund, und OMDb kam nie dran — ein Kinofilm, den TMDB +# verpasst, bekam damit Anime-Metadaten. Die Messwerte dazu stehen in +# test_jikan_helpers.py („Alien" gegen „Alien 9" = 0,83). + + +class _StummerClient: + """Ein Client, der auf jede Frage dasselbe antwortet — und mitzählt.""" + + def __init__(self, antwort=None): + self.antwort = antwort + self.aufrufe = 0 + + def lookup(self, *a, **k): + self.aufrufe += 1 + return self.antwort + + def search_movie(self, *a, **k): + return [] + + def search_tv(self, *a, **k): + return [] + + def get_movie_details(self, *a, **k): + return None + + def find_by_imdb(self, *a, **k): + return None + + +def _scanner(monkeypatch, jikan_antwort, omdb_antwort): + """PreScan ohne __init__ (das bräuchte DB + API-Keys), Cache stillgelegt.""" + from prescan import prescan as modul + + monkeypatch.setattr(modul, "get", lambda k: None) + monkeypatch.setattr(modul, "cache_set", lambda *a, **k: None) + + scan = modul.PreScan.__new__(modul.PreScan) + scan.tmdb = _StummerClient() + scan.jikan = _StummerClient(jikan_antwort) + scan.omdb = _StummerClient(omdb_antwort) + return scan + + +def _jikan(score): + return {"type": "movie", "id": "mal-1", "title": "Alien 9", + "source": "jikan", "match_score": score} + + +def _omdb(): + return {"type": "movie", "id": "tt0078748", "title": "Alien", + "source": "omdb", "year": 1979} + + +def test_unscharfer_jikan_treffer_laesst_omdb_zum_zug(monkeypatch): + """Der eigentliche Fehler: „Alien" traf mit 0,83 auf den Anime „Alien 9", + und der Film wurde nie gesucht.""" + scan = _scanner(monkeypatch, _jikan(0.83), _omdb()) + ergebnis = scan._scan_video("/dev/sr0", {"title": "Alien", "disc_type": "Blu-ray"}) + assert ergebnis.metadata["source"] == "omdb" + assert ergebnis.confidence == 0.8 + assert scan.omdb.aufrufe > 0 + + +def test_exakter_jikan_treffer_gewinnt_sofort(monkeypatch): + """Der Fall, für den Jikan gebaut wurde, darf NICHT verlorengehen + (AGENTS Regel B): Bei einem sicheren Treffer wird OMDb nicht mehr gefragt.""" + scan = _scanner(monkeypatch, _jikan(1.0), _omdb()) + ergebnis = scan._scan_video("/dev/sr0", {"title": "Alien 9", "disc_type": "Blu-ray"}) + assert ergebnis.metadata["source"] == "jikan" + assert ergebnis.confidence == 0.85 + assert scan.omdb.aufrufe == 0 + + +def test_unscharfer_jikan_treffer_bleibt_als_vorschlag(monkeypatch): + """Hat OMDb nichts (kein Key, kein Treffer), ist der unscharfe Jikan-Treffer + besser als nichts — aber er wird als VORSCHLAG ausgewiesen (0,6), nicht als + sicherer Fund.""" + scan = _scanner(monkeypatch, _jikan(0.83), None) + ergebnis = scan._scan_video("/dev/sr0", {"title": "Alien", "disc_type": "Blu-ray"}) + assert ergebnis.metadata["source"] == "jikan" + assert ergebnis.confidence == 0.6 + + +def test_ohne_jede_quelle_bleibt_es_unbekannt(monkeypatch): + scan = _scanner(monkeypatch, None, None) + ergebnis = scan._scan_video("/dev/sr0", {"title": "Nix Da", "disc_type": "DVD"}) + assert ergebnis.confidence == 0.0 + assert ergebnis.metadata == {}