# -*- coding: utf-8 -*- """ AniList GraphQL Client fuer Anime-Metadaten. AniList bietet eine kostenlose GraphQL API ohne API-Key: https://graphql.anilist.co Wichtig: - "Ecchi" ist ein **Genre** bei AniList (nicht ein Tag) - Tags sind granularer: "Shounen", "Harem", "School", "Martial Arts", etc. - Rate-Limit: 90 req/min (wir machen <30/Tag) Abhaengigkeiten: - aiohttp: Fuer GraphQL POST-Requests """ from __future__ import annotations import asyncio import re import time from datetime import datetime, timezone from typing import TYPE_CHECKING from trixy_core.utils.debug import pdebug, perror, pwarn _AIOHTTP_AVAILABLE = False try: import aiohttp _AIOHTTP_AVAILABLE = True except ImportError: pass # Bekannte Anime-Sender (channel_id Kleinbuchstaben) _ANIME_CHANNELS: set[str] = { "pro7_maxx", "pro7maxx", "sixx", "super_rtl", "nick", "rtl2", "rtl_zwei", "aniplus", "animax", } # Indikatoren im Titel die auf Anime hindeuten _ANIME_TITLE_INDICATORS: list[str] = [ "dragon ball", "naruto", "one piece", "attack on titan", "shingeki", "boruto", "demon slayer", "kimetsu", "jujutsu", "my hero academia", "boku no hero", "sailor moon", "pokemon", "pokémon", "digimon", "sword art online", "bleach", "fairy tail", "hunter x hunter", "death note", "fullmetal", "cowboy bebop", "evangelion", "inuyasha", "detektiv conan", "case closed", ] # Genre-Begriffe die auf Anime/Animation hindeuten _ANIME_GENRE_KEYWORDS: list[str] = [ "anime", "zeichentrick", "animation", "animeserie", "animationsfilm", "zeichentrickserie", ] # Bekannte Streaming-Dienste aus externalLinks _STREAMING_SERVICES: set[str] = { "Crunchyroll", "Netflix", "Amazon Prime Video", "Amazon", "Disney Plus", "Disney+", "Wakanim", "Funimation", "Hulu", "HIDIVE", "AnimeLab", "VRV", "ADN", "Anime on Demand", "Joyn", "RTL+", } class AniListClient: """GraphQL-Client fuer die AniList Anime-Datenbank.""" API_URL = "https://graphql.anilist.co" # GraphQL-Query fuer Anime-Suche SEARCH_QUERY = ''' query ($search: String) { Media(search: $search, type: ANIME) { title { romaji english } genres tags { name rank } description(asHtml: false) averageScore episodes status countryOfOrigin relations { edges { relationType node { title { romaji english } type } } } externalLinks { site } } } ''' # Retry-Konfiguration fuer 429 (Rate-Limit) _MAX_RETRIES: int = 3 _RETRY_BASE_DELAY: float = 2.0 async def search(self, title: str) -> dict | None: """ Sucht ein Anime auf AniList per GraphQL. Args: title: Anime-Titel zum Suchen Returns: Enrichment-Dict mit kompakten Feldnamen oder None falls nicht gefunden. Keine URLs oder Bilder — nur Metadaten fuer den Sprachassistenten. """ if not _AIOHTTP_AVAILABLE: perror("[AniList] aiohttp nicht installiert — Enrichment deaktiviert") return None if not title or not title.strip(): return None variables = {"search": title.strip()} for attempt in range(self._MAX_RETRIES): try: async with aiohttp.ClientSession() as session: async with session.post( self.API_URL, json={"query": self.SEARCH_QUERY, "variables": variables}, headers={"Content-Type": "application/json"}, timeout=aiohttp.ClientTimeout(total=15), ) as resp: if resp.status == 429: # Rate-Limit erreicht — warten und erneut versuchen retry_after = float( resp.headers.get("Retry-After", self._RETRY_BASE_DELAY * (attempt + 1)) ) pwarn(f"[AniList] Rate-Limit (429), warte {retry_after:.1f}s (Versuch {attempt + 1})") await asyncio.sleep(retry_after) continue if resp.status != 200: # 404 ist normal fuer Nicht-Anime-Titel (z.B. US-Serien auf pro7_maxx) if resp.status == 404: pdebug(f"[AniList] '{title}' nicht gefunden (kein Anime)") else: perror(f"[AniList] HTTP {resp.status} fuer '{title}'") return None data = await resp.json() except asyncio.TimeoutError: perror(f"[AniList] Timeout bei Suche nach '{title}'") return None except aiohttp.ClientError as exc: perror(f"[AniList] Verbindungsfehler: {exc}") return None except Exception as exc: perror(f"[AniList] Unerwarteter Fehler: {exc}") return None else: # Erfolgreiche Antwort — aus der Retry-Schleife raus break else: # Alle Retries verbraucht perror(f"[AniList] Alle {self._MAX_RETRIES} Versuche fehlgeschlagen fuer '{title}'") return None # Ergebnis parsen media = (data or {}).get("data", {}).get("Media") if not media: pdebug(f"[AniList] Kein Ergebnis fuer '{title}'") return None # Titel bestimmen (englisch bevorzugt, Fallback romaji) titles = media.get("title", {}) resolved_title = titles.get("english") or titles.get("romaji") or title # Titel-Vergleich: Pruefe ob das Ergebnis zum Suchtitel passt. # AniList matched aggressiv — "The Flash" → "Captain Tsubasa" waere falsch. if not self._titles_match(title, resolved_title, titles): pdebug( f"[AniList] Treffer '{resolved_title}' passt nicht zu " f"'{title}' — verworfen" ) return None # Genres direkt uebernehmen (inkl. "Ecchi" als Genre) genres: list[str] = media.get("genres", []) # Tags filtern: nur rank >= 60, max 10 raw_tags = media.get("tags", []) tags = [ tag["name"] for tag in sorted(raw_tags, key=lambda t: t.get("rank", 0), reverse=True) if tag.get("rank", 0) >= 60 ][:10] # Beschreibung bereinigen description = self._strip_html(media.get("description", "") or "") # Auf sinnvolle Laenge kuerzen (max 500 Zeichen) if len(description) > 500: description = description[:497] + "..." # Rating: averageScore ist 0-100, wir wollen 0.0-10.0 avg_score = media.get("averageScore") rating = round(avg_score / 10, 1) if avg_score else None # Streaming-Dienste extrahieren external_links = media.get("externalLinks", []) streaming = self._extract_streaming_services(external_links) # Aehnliche Serien aus Relations (nur SEQUEL, PREQUEL, ALTERNATIVE, SIDE_STORY) similar: list[str] = [] relations = media.get("relations", {}).get("edges", []) _relevant_relations = {"SEQUEL", "PREQUEL", "ALTERNATIVE", "SIDE_STORY", "PARENT"} for edge in relations: rel_type = edge.get("relationType", "") node = edge.get("node", {}) if rel_type in _relevant_relations and node.get("type") == "ANIME": rel_title = node.get("title", {}) rel_name = rel_title.get("english") or rel_title.get("romaji") if rel_name and rel_name not in similar: similar.append(rel_name) # Max 5 aehnliche similar = similar[:5] # Originalsprache aus countryOfOrigin ableiten country = media.get("countryOfOrigin", "") orig_lang_map = {"JP": "ja", "KR": "ko", "CN": "zh", "TW": "zh"} orig_lang = orig_lang_map.get(country, "") enrichment = { "t": resolved_title, "ty": "anime", "g": genres, "tags": tags, "d": description, "rt": rating, "str": streaming, "sim": similar, "orig_lang": orig_lang, # Originalsprache (ja/ko/zh) "dub": ["de"], # Im deutschen TV immer synchronisiert "sub": [], # Untertitel — wird spaeter befuellt "src": "anilist", "at": datetime.now(timezone.utc).isoformat(timespec="seconds"), } pdebug(f"[AniList] Enrichment fuer '{title}' -> '{resolved_title}' ({len(genres)} Genres, {len(tags)} Tags)") return enrichment @staticmethod def is_likely_anime(title: str, genre: str = "", channel_id: str = "") -> bool: """ Heuristik: Ist ein EPG-Eintrag wahrscheinlich Anime? Prueft mehrere Kriterien: - Genre enthaelt "Anime", "Zeichentrick" oder "Animation" - Sender ist bekannt fuer Anime (pro7_maxx, sixx, etc.) - Titel enthaelt bekannte Anime-Indikatoren Args: title: Sendungstitel genre: EPG-Genre-String (kann leer sein) channel_id: Sender-ID in Kleinbuchstaben Returns: True wenn der Eintrag wahrscheinlich Anime ist """ title_lower = title.lower() genre_lower = genre.lower() channel_lower = channel_id.lower().strip() # Genre-Check for keyword in _ANIME_GENRE_KEYWORDS: if keyword in genre_lower: return True # Sender-Check: Bekannte Anime-Sender → immer als Anime behandeln # (EPG-Daten haben oft leere Genre-Felder fuer Anime) if channel_lower in _ANIME_CHANNELS: return True # Titel-Check gegen bekannte Anime for indicator in _ANIME_TITLE_INDICATORS: if indicator in title_lower: return True return False @staticmethod def _titles_match(search: str, found: str, all_titles: dict) -> bool: """ Prueft ob der AniList-Treffer zum Suchtitel passt. Vergleicht den Suchtitel mit allen verfuegbaren Titelvarianten (english, romaji, native). Ein Match liegt vor wenn: - Ein Titel den Suchbegriff enthaelt (oder umgekehrt) - Mindestens 50% der Woerter uebereinstimmen Beispiele: "One Piece" vs "ONE PIECE" → True "Naruto Shippuden" vs "NARUTO: Shippūden" → True "The Flash" vs "Captain Tsubasa" → False """ search_lower = search.lower().strip() search_words = set(search_lower.split()) # Alle Titelvarianten sammeln candidates = [found] for key in ("english", "romaji", "native"): val = all_titles.get(key, "") if val: candidates.append(val) for candidate in candidates: candidate_lower = candidate.lower().strip() # Exakter oder Teilstring-Match if search_lower in candidate_lower or candidate_lower in search_lower: return True # Wort-Ueberlappung: mindestens 50% der Suchwoerter muessen vorkommen candidate_words = set(candidate_lower.split()) if search_words and candidate_words: overlap = search_words & candidate_words if len(overlap) >= max(1, len(search_words) * 0.5): return True return False @staticmethod def _strip_html(text: str) -> str: """Entfernt HTML-Tags aus AniList Beschreibungen.""" if not text: return "" # HTML-Tags entfernen clean = re.sub(r"<[^>]+>", "", text) # Mehrfache Leerzeichen/Zeilenumbrueche bereinigen clean = re.sub(r"\s+", " ", clean).strip() return clean @staticmethod def _extract_streaming_services(external_links: list[dict]) -> list[str]: """ Extrahiert Streaming-Dienst-Namen aus externalLinks. Nur bekannte Streaming-Dienste werden uebernommen — keine Social-Media-Links oder Wikis. Args: external_links: Liste von {"site": "..."} Dicts Returns: Liste von Streaming-Dienst-Namen (dedupliziert) """ services: list[str] = [] seen: set[str] = set() for link in external_links: site = link.get("site", "") if not site: continue # Exakter Match oder normalisierter Match if site in _STREAMING_SERVICES: if site not in seen: services.append(site) seen.add(site) else: # Teilweise Uebereinstimmung pruefen site_lower = site.lower() for known in _STREAMING_SERVICES: if known.lower() in site_lower and known not in seen: services.append(known) seen.add(known) break return services