anilist.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388
  1. # -*- coding: utf-8 -*-
  2. """
  3. AniList GraphQL Client fuer Anime-Metadaten.
  4. AniList bietet eine kostenlose GraphQL API ohne API-Key:
  5. https://graphql.anilist.co
  6. Wichtig:
  7. - "Ecchi" ist ein **Genre** bei AniList (nicht ein Tag)
  8. - Tags sind granularer: "Shounen", "Harem", "School", "Martial Arts", etc.
  9. - Rate-Limit: 90 req/min (wir machen <30/Tag)
  10. Abhaengigkeiten:
  11. - aiohttp: Fuer GraphQL POST-Requests
  12. """
  13. from __future__ import annotations
  14. import asyncio
  15. import re
  16. import time
  17. from datetime import datetime, timezone
  18. from typing import TYPE_CHECKING
  19. from trixy_core.utils.debug import pdebug, perror, pwarn
  20. _AIOHTTP_AVAILABLE = False
  21. try:
  22. import aiohttp
  23. _AIOHTTP_AVAILABLE = True
  24. except ImportError:
  25. pass
  26. # Bekannte Anime-Sender (channel_id Kleinbuchstaben)
  27. _ANIME_CHANNELS: set[str] = {
  28. "pro7_maxx", "pro7maxx", "sixx", "super_rtl",
  29. "nick", "rtl2", "rtl_zwei", "aniplus", "animax",
  30. }
  31. # Indikatoren im Titel die auf Anime hindeuten
  32. _ANIME_TITLE_INDICATORS: list[str] = [
  33. "dragon ball", "naruto", "one piece", "attack on titan",
  34. "shingeki", "boruto", "demon slayer", "kimetsu",
  35. "jujutsu", "my hero academia", "boku no hero",
  36. "sailor moon", "pokemon", "pokémon", "digimon",
  37. "sword art online", "bleach", "fairy tail",
  38. "hunter x hunter", "death note", "fullmetal",
  39. "cowboy bebop", "evangelion", "inuyasha",
  40. "detektiv conan", "case closed",
  41. ]
  42. # Genre-Begriffe die auf Anime/Animation hindeuten
  43. _ANIME_GENRE_KEYWORDS: list[str] = [
  44. "anime", "zeichentrick", "animation", "animeserie",
  45. "animationsfilm", "zeichentrickserie",
  46. ]
  47. # Bekannte Streaming-Dienste aus externalLinks
  48. _STREAMING_SERVICES: set[str] = {
  49. "Crunchyroll", "Netflix", "Amazon Prime Video", "Amazon",
  50. "Disney Plus", "Disney+", "Wakanim", "Funimation",
  51. "Hulu", "HIDIVE", "AnimeLab", "VRV", "ADN",
  52. "Anime on Demand", "Joyn", "RTL+",
  53. }
  54. class AniListClient:
  55. """GraphQL-Client fuer die AniList Anime-Datenbank."""
  56. API_URL = "https://graphql.anilist.co"
  57. # GraphQL-Query fuer Anime-Suche
  58. SEARCH_QUERY = '''
  59. query ($search: String) {
  60. Media(search: $search, type: ANIME) {
  61. title {
  62. romaji
  63. english
  64. }
  65. genres
  66. tags {
  67. name
  68. rank
  69. }
  70. description(asHtml: false)
  71. averageScore
  72. episodes
  73. status
  74. countryOfOrigin
  75. relations {
  76. edges {
  77. relationType
  78. node {
  79. title {
  80. romaji
  81. english
  82. }
  83. type
  84. }
  85. }
  86. }
  87. externalLinks {
  88. site
  89. }
  90. }
  91. }
  92. '''
  93. # Retry-Konfiguration fuer 429 (Rate-Limit)
  94. _MAX_RETRIES: int = 3
  95. _RETRY_BASE_DELAY: float = 2.0
  96. async def search(self, title: str) -> dict | None:
  97. """
  98. Sucht ein Anime auf AniList per GraphQL.
  99. Args:
  100. title: Anime-Titel zum Suchen
  101. Returns:
  102. Enrichment-Dict mit kompakten Feldnamen oder None falls
  103. nicht gefunden. Keine URLs oder Bilder — nur Metadaten
  104. fuer den Sprachassistenten.
  105. """
  106. if not _AIOHTTP_AVAILABLE:
  107. perror("[AniList] aiohttp nicht installiert — Enrichment deaktiviert")
  108. return None
  109. if not title or not title.strip():
  110. return None
  111. variables = {"search": title.strip()}
  112. for attempt in range(self._MAX_RETRIES):
  113. try:
  114. async with aiohttp.ClientSession() as session:
  115. async with session.post(
  116. self.API_URL,
  117. json={"query": self.SEARCH_QUERY, "variables": variables},
  118. headers={"Content-Type": "application/json"},
  119. timeout=aiohttp.ClientTimeout(total=15),
  120. ) as resp:
  121. if resp.status == 429:
  122. # Rate-Limit erreicht — warten und erneut versuchen
  123. retry_after = float(
  124. resp.headers.get("Retry-After", self._RETRY_BASE_DELAY * (attempt + 1))
  125. )
  126. pwarn(f"[AniList] Rate-Limit (429), warte {retry_after:.1f}s (Versuch {attempt + 1})")
  127. await asyncio.sleep(retry_after)
  128. continue
  129. if resp.status != 200:
  130. # 404 ist normal fuer Nicht-Anime-Titel (z.B. US-Serien auf pro7_maxx)
  131. if resp.status == 404:
  132. pdebug(f"[AniList] '{title}' nicht gefunden (kein Anime)")
  133. else:
  134. perror(f"[AniList] HTTP {resp.status} fuer '{title}'")
  135. return None
  136. data = await resp.json()
  137. except asyncio.TimeoutError:
  138. perror(f"[AniList] Timeout bei Suche nach '{title}'")
  139. return None
  140. except aiohttp.ClientError as exc:
  141. perror(f"[AniList] Verbindungsfehler: {exc}")
  142. return None
  143. except Exception as exc:
  144. perror(f"[AniList] Unerwarteter Fehler: {exc}")
  145. return None
  146. else:
  147. # Erfolgreiche Antwort — aus der Retry-Schleife raus
  148. break
  149. else:
  150. # Alle Retries verbraucht
  151. perror(f"[AniList] Alle {self._MAX_RETRIES} Versuche fehlgeschlagen fuer '{title}'")
  152. return None
  153. # Ergebnis parsen
  154. media = (data or {}).get("data", {}).get("Media")
  155. if not media:
  156. pdebug(f"[AniList] Kein Ergebnis fuer '{title}'")
  157. return None
  158. # Titel bestimmen (englisch bevorzugt, Fallback romaji)
  159. titles = media.get("title", {})
  160. resolved_title = titles.get("english") or titles.get("romaji") or title
  161. # Titel-Vergleich: Pruefe ob das Ergebnis zum Suchtitel passt.
  162. # AniList matched aggressiv — "The Flash" → "Captain Tsubasa" waere falsch.
  163. if not self._titles_match(title, resolved_title, titles):
  164. pdebug(
  165. f"[AniList] Treffer '{resolved_title}' passt nicht zu "
  166. f"'{title}' — verworfen"
  167. )
  168. return None
  169. # Genres direkt uebernehmen (inkl. "Ecchi" als Genre)
  170. genres: list[str] = media.get("genres", [])
  171. # Tags filtern: nur rank >= 60, max 10
  172. raw_tags = media.get("tags", [])
  173. tags = [
  174. tag["name"]
  175. for tag in sorted(raw_tags, key=lambda t: t.get("rank", 0), reverse=True)
  176. if tag.get("rank", 0) >= 60
  177. ][:10]
  178. # Beschreibung bereinigen
  179. description = self._strip_html(media.get("description", "") or "")
  180. # Auf sinnvolle Laenge kuerzen (max 500 Zeichen)
  181. if len(description) > 500:
  182. description = description[:497] + "..."
  183. # Rating: averageScore ist 0-100, wir wollen 0.0-10.0
  184. avg_score = media.get("averageScore")
  185. rating = round(avg_score / 10, 1) if avg_score else None
  186. # Streaming-Dienste extrahieren
  187. external_links = media.get("externalLinks", [])
  188. streaming = self._extract_streaming_services(external_links)
  189. # Aehnliche Serien aus Relations (nur SEQUEL, PREQUEL, ALTERNATIVE, SIDE_STORY)
  190. similar: list[str] = []
  191. relations = media.get("relations", {}).get("edges", [])
  192. _relevant_relations = {"SEQUEL", "PREQUEL", "ALTERNATIVE", "SIDE_STORY", "PARENT"}
  193. for edge in relations:
  194. rel_type = edge.get("relationType", "")
  195. node = edge.get("node", {})
  196. if rel_type in _relevant_relations and node.get("type") == "ANIME":
  197. rel_title = node.get("title", {})
  198. rel_name = rel_title.get("english") or rel_title.get("romaji")
  199. if rel_name and rel_name not in similar:
  200. similar.append(rel_name)
  201. # Max 5 aehnliche
  202. similar = similar[:5]
  203. # Originalsprache aus countryOfOrigin ableiten
  204. country = media.get("countryOfOrigin", "")
  205. orig_lang_map = {"JP": "ja", "KR": "ko", "CN": "zh", "TW": "zh"}
  206. orig_lang = orig_lang_map.get(country, "")
  207. enrichment = {
  208. "t": resolved_title,
  209. "ty": "anime",
  210. "g": genres,
  211. "tags": tags,
  212. "d": description,
  213. "rt": rating,
  214. "str": streaming,
  215. "sim": similar,
  216. "orig_lang": orig_lang, # Originalsprache (ja/ko/zh)
  217. "dub": ["de"], # Im deutschen TV immer synchronisiert
  218. "sub": [], # Untertitel — wird spaeter befuellt
  219. "src": "anilist",
  220. "at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
  221. }
  222. pdebug(f"[AniList] Enrichment fuer '{title}' -> '{resolved_title}' ({len(genres)} Genres, {len(tags)} Tags)")
  223. return enrichment
  224. @staticmethod
  225. def is_likely_anime(title: str, genre: str = "", channel_id: str = "") -> bool:
  226. """
  227. Heuristik: Ist ein EPG-Eintrag wahrscheinlich Anime?
  228. Prueft mehrere Kriterien:
  229. - Genre enthaelt "Anime", "Zeichentrick" oder "Animation"
  230. - Sender ist bekannt fuer Anime (pro7_maxx, sixx, etc.)
  231. - Titel enthaelt bekannte Anime-Indikatoren
  232. Args:
  233. title: Sendungstitel
  234. genre: EPG-Genre-String (kann leer sein)
  235. channel_id: Sender-ID in Kleinbuchstaben
  236. Returns:
  237. True wenn der Eintrag wahrscheinlich Anime ist
  238. """
  239. title_lower = title.lower()
  240. genre_lower = genre.lower()
  241. channel_lower = channel_id.lower().strip()
  242. # Genre-Check
  243. for keyword in _ANIME_GENRE_KEYWORDS:
  244. if keyword in genre_lower:
  245. return True
  246. # Sender-Check: Bekannte Anime-Sender → immer als Anime behandeln
  247. # (EPG-Daten haben oft leere Genre-Felder fuer Anime)
  248. if channel_lower in _ANIME_CHANNELS:
  249. return True
  250. # Titel-Check gegen bekannte Anime
  251. for indicator in _ANIME_TITLE_INDICATORS:
  252. if indicator in title_lower:
  253. return True
  254. return False
  255. @staticmethod
  256. def _titles_match(search: str, found: str, all_titles: dict) -> bool:
  257. """
  258. Prueft ob der AniList-Treffer zum Suchtitel passt.
  259. Vergleicht den Suchtitel mit allen verfuegbaren Titelvarianten
  260. (english, romaji, native). Ein Match liegt vor wenn:
  261. - Ein Titel den Suchbegriff enthaelt (oder umgekehrt)
  262. - Mindestens 50% der Woerter uebereinstimmen
  263. Beispiele:
  264. "One Piece" vs "ONE PIECE" → True
  265. "Naruto Shippuden" vs "NARUTO: Shippūden" → True
  266. "The Flash" vs "Captain Tsubasa" → False
  267. """
  268. search_lower = search.lower().strip()
  269. search_words = set(search_lower.split())
  270. # Alle Titelvarianten sammeln
  271. candidates = [found]
  272. for key in ("english", "romaji", "native"):
  273. val = all_titles.get(key, "")
  274. if val:
  275. candidates.append(val)
  276. for candidate in candidates:
  277. candidate_lower = candidate.lower().strip()
  278. # Exakter oder Teilstring-Match
  279. if search_lower in candidate_lower or candidate_lower in search_lower:
  280. return True
  281. # Wort-Ueberlappung: mindestens 50% der Suchwoerter muessen vorkommen
  282. candidate_words = set(candidate_lower.split())
  283. if search_words and candidate_words:
  284. overlap = search_words & candidate_words
  285. if len(overlap) >= max(1, len(search_words) * 0.5):
  286. return True
  287. return False
  288. @staticmethod
  289. def _strip_html(text: str) -> str:
  290. """Entfernt HTML-Tags aus AniList Beschreibungen."""
  291. if not text:
  292. return ""
  293. # HTML-Tags entfernen
  294. clean = re.sub(r"<[^>]+>", "", text)
  295. # Mehrfache Leerzeichen/Zeilenumbrueche bereinigen
  296. clean = re.sub(r"\s+", " ", clean).strip()
  297. return clean
  298. @staticmethod
  299. def _extract_streaming_services(external_links: list[dict]) -> list[str]:
  300. """
  301. Extrahiert Streaming-Dienst-Namen aus externalLinks.
  302. Nur bekannte Streaming-Dienste werden uebernommen —
  303. keine Social-Media-Links oder Wikis.
  304. Args:
  305. external_links: Liste von {"site": "..."} Dicts
  306. Returns:
  307. Liste von Streaming-Dienst-Namen (dedupliziert)
  308. """
  309. services: list[str] = []
  310. seen: set[str] = set()
  311. for link in external_links:
  312. site = link.get("site", "")
  313. if not site:
  314. continue
  315. # Exakter Match oder normalisierter Match
  316. if site in _STREAMING_SERVICES:
  317. if site not in seen:
  318. services.append(site)
  319. seen.add(site)
  320. else:
  321. # Teilweise Uebereinstimmung pruefen
  322. site_lower = site.lower()
  323. for known in _STREAMING_SERVICES:
  324. if known.lower() in site_lower and known not in seen:
  325. services.append(known)
  326. seen.add(known)
  327. break
  328. return services