| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388 |
- # -*- 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
|