enricher.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  1. # -*- coding: utf-8 -*-
  2. """
  3. Enrichment-Orchestrator fuer TV-Programm-Metadaten.
  4. Routet Titel basierend auf Typ/Genre an den richtigen API-Client:
  5. - Anime → AniList (kostenlos, kein Key)
  6. - Filme → TMDB (Film-Suche)
  7. - Serien → TMDB (Serien-Suche) → TVDB (Fallback)
  8. Features:
  9. - Batch-Verarbeitung mit konfigurierbarer Pause
  10. - Resume-sicher ueber SyncState-Queue
  11. - On-Demand-Enrichment fuer Streaming-Abfragen
  12. - Max-Limit pro Sync-Durchlauf
  13. """
  14. from __future__ import annotations
  15. import asyncio
  16. import re
  17. from typing import TYPE_CHECKING
  18. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  19. from plugins.tv_program.enrichment.anilist import AniListClient
  20. from plugins.tv_program.enrichment.tmdb import TMDBClient
  21. from plugins.tv_program.enrichment.tvdb import TVDBClient
  22. if TYPE_CHECKING:
  23. from plugins.tv_program.epg.cache import EPGCache
  24. from plugins.tv_program.sync.sync_state import SyncState
  25. _SLUG_RE = re.compile(r"[^a-z0-9]+")
  26. def _normalize_series_id(title: str) -> str:
  27. """
  28. Erzeugt eine normalisierte series_id aus einem Titel.
  29. Gleiche Logik wie EPGCache._generate_series_id() —
  30. damit Cache-Lookup und Enrichment-Queue die gleichen IDs verwenden.
  31. Z.B. "Pirates of the Caribbean" → "pirates-of-the-caribbean"
  32. """
  33. if not title:
  34. return ""
  35. return _SLUG_RE.sub("-", title.strip().lower()).strip("-")
  36. class Enricher:
  37. """Orchestriert Enrichment ueber mehrere API-Quellen."""
  38. def __init__(self, config: dict, cache: EPGCache, sync_state: SyncState):
  39. """
  40. Initialisiert den Enricher mit API-Clients basierend auf Config.
  41. Args:
  42. config: Enrichment-Konfiguration aus dem Plugin
  43. cache: EPGCache-Instanz fuer Programmzugriff
  44. sync_state: SyncState-Instanz fuer Queue und Status
  45. """
  46. self._cache = cache
  47. self._sync_state = sync_state
  48. # Batch-Konfiguration
  49. self._batch_size: int = config.get("batch_size", 10)
  50. self._delay: float = config.get("delay_between_batches_seconds", 2)
  51. self._max_per_sync: int = config.get("max_enrichments_per_sync", 200)
  52. # API-Clients initialisieren (nur wenn aktiviert und Key vorhanden)
  53. anilist_cfg = config.get("anilist", {})
  54. self._anilist: AniListClient | None = (
  55. AniListClient() if anilist_cfg.get("enabled", True) else None
  56. )
  57. tmdb_cfg = config.get("tmdb", {})
  58. tmdb_key = tmdb_cfg.get("api_key", "")
  59. self._tmdb: TMDBClient | None = (
  60. TMDBClient(
  61. api_key=tmdb_key,
  62. language=tmdb_cfg.get("language", "de-DE"),
  63. )
  64. if tmdb_cfg.get("enabled", True) and tmdb_key
  65. else None
  66. )
  67. tvdb_cfg = config.get("tvdb", {})
  68. tvdb_key = tvdb_cfg.get("api_key", "")
  69. self._tvdb: TVDBClient | None = (
  70. TVDBClient(api_key=tvdb_key)
  71. if tvdb_cfg.get("enabled", False) and tvdb_key
  72. else None
  73. )
  74. # Log welche Quellen aktiv sind
  75. sources = []
  76. if self._anilist:
  77. sources.append("AniList")
  78. if self._tmdb:
  79. sources.append("TMDB")
  80. if self._tvdb:
  81. sources.append("TVDB")
  82. pinfo(f"[Enricher] Aktive Quellen: {', '.join(sources) or 'keine'}")
  83. async def enrich_pending(self) -> int:
  84. """
  85. Verarbeitet die Enrichment-Queue aus sync_state.
  86. Resume-sicher: Liest die Queue, enriched Titel einzeln und
  87. speichert den Fortschritt nach jedem erfolgreichen Enrichment.
  88. Batch-Verarbeitung: self._batch_size Titel werden verarbeitet,
  89. dann self._delay Sekunden Pause, um Rate-Limits zu vermeiden.
  90. Returns:
  91. Anzahl erfolgreich angereicherter Titel
  92. """
  93. queue = list(self._sync_state.enrichment_queue)
  94. if not queue:
  95. pdebug("[Enricher] Keine Titel in der Enrichment-Queue")
  96. return 0
  97. total = min(len(queue), self._max_per_sync)
  98. pinfo(f"[Enricher] Starte Enrichment fuer {total} von {len(queue)} Titeln")
  99. enriched_count = 0
  100. processed = 0
  101. for i, series_id in enumerate(queue[:total]):
  102. # Batch-Pause nach jeweils batch_size Titeln
  103. if processed > 0 and processed % self._batch_size == 0:
  104. pdebug(f"[Enricher] Batch-Pause ({self._delay}s) nach {processed} Titeln")
  105. await asyncio.sleep(self._delay)
  106. try:
  107. result = await self._enrich_title(series_id)
  108. if result:
  109. # Enrichment im SyncState speichern
  110. self._cache.update_enrichment(series_id, result)
  111. self._sync_state.mark_title_enriched(series_id, result.get("src", "unknown"))
  112. enriched_count += 1
  113. pdebug(f"[Enricher] [{i + 1}/{total}] '{series_id}' erfolgreich ({result.get('src', '?')})")
  114. else:
  115. # Titel als "versucht" markieren (leeres Dict), damit
  116. # er nicht bei jedem Sync erneut versucht wird
  117. self._sync_state.mark_title_enriched(series_id, "none")
  118. pdebug(f"[Enricher] [{i + 1}/{total}] '{series_id}' — kein Ergebnis")
  119. except Exception as exc:
  120. perror(f"[Enricher] Fehler bei '{series_id}': {exc}")
  121. # Bei Fehler nicht aus Queue entfernen → naechster Versuch
  122. continue
  123. processed += 1
  124. pinfo(f"[Enricher] Fertig: {enriched_count}/{processed} Titel erfolgreich enriched")
  125. return enriched_count
  126. def build_enrichment_queue(self, programs: list[dict]) -> list[str]:
  127. """
  128. Scannt Programme fuer neue, noch nicht enriched Titel.
  129. Generiert eine normalisierte series_id fuer jeden Titel und
  130. prueft gegen die bereits enriched Titel im SyncState.
  131. Neue Titel werden zur Queue hinzugefuegt.
  132. Args:
  133. programs: Liste von Programm-Dicts aus dem EPG-Cache
  134. Returns:
  135. Liste der neu zur Queue hinzugefuegten series_ids
  136. """
  137. new_ids: list[str] = []
  138. seen: set[str] = set()
  139. for program in programs:
  140. title = program.get("t", "")
  141. if not title:
  142. continue
  143. series_id = _normalize_series_id(title)
  144. if not series_id:
  145. continue
  146. # Bereits verarbeitet oder in Queue?
  147. if series_id in seen:
  148. continue
  149. if self._sync_state.is_enriched(series_id):
  150. continue
  151. if self._sync_state.is_in_enrichment_queue(series_id):
  152. continue
  153. seen.add(series_id)
  154. self._sync_state.add_to_enrichment_queue(series_id)
  155. new_ids.append(series_id)
  156. if new_ids:
  157. pdebug(f"[Enricher] {len(new_ids)} neue Titel zur Queue hinzugefuegt")
  158. return new_ids
  159. async def _enrich_title(self, series_id: str) -> dict | None:
  160. """
  161. Enriched einen einzelnen Titel ueber die passende API.
  162. Entscheidungsbaum:
  163. 1. Finde ein Programm mit dieser series_id im Cache
  164. 2. Ist es Anime? → AniList
  165. 3. Ist es ein Film? → TMDB search_movie
  166. 4. Ist es eine Serie? → TMDB search_tv → TVDB Fallback
  167. Args:
  168. series_id: Normalisierte Titel-ID
  169. Returns:
  170. Enrichment-Dict oder None
  171. """
  172. # Programm aus Cache finden fuer Kontext (Titel, Genre, Sender)
  173. program = self._find_program_for_series_id(series_id)
  174. if not program:
  175. pdebug(f"[Enricher] Kein Programm im Cache fuer '{series_id}'")
  176. return None
  177. title = program.get("t", "")
  178. genre = program.get("g", "")
  179. channel_id = program.get("ch", "")
  180. program_type = program.get("ty", "")
  181. if not title:
  182. return None
  183. # Entscheidungsbaum: Anime?
  184. if self._anilist and AniListClient.is_likely_anime(title, genre, channel_id):
  185. pdebug(f"[Enricher] '{title}' als Anime erkannt → AniList")
  186. result = await self._anilist.search(title)
  187. if result:
  188. return result
  189. # Film?
  190. if program_type == "film" or self._is_likely_movie(program):
  191. if self._tmdb:
  192. pdebug(f"[Enricher] '{title}' als Film erkannt → TMDB")
  193. # Erscheinungsjahr extrahieren falls vorhanden
  194. year = program.get("year")
  195. return await self._tmdb.search_movie(title, year)
  196. # Serie (Standard-Fallback)
  197. if self._tmdb:
  198. pdebug(f"[Enricher] '{title}' als Serie → TMDB")
  199. result = await self._tmdb.search_tv(title)
  200. if result:
  201. return result
  202. # TVDB als Fallback
  203. if self._tvdb:
  204. pdebug(f"[Enricher] '{title}' → TVDB Fallback")
  205. result = await self._tvdb.search(title)
  206. if result:
  207. return result
  208. return None
  209. async def enrich_on_demand(self, title: str) -> dict | None:
  210. """
  211. On-Demand Enrichment fuer Streaming-Abfragen.
  212. Wenn ein Nutzer "Wo kann ich X streamen?" fragt und der Titel
  213. noch nicht enriched ist, versuchen wir es sofort ueber alle
  214. verfuegbaren Quellen.
  215. Args:
  216. title: Titel der Sendung/des Films
  217. Returns:
  218. Enrichment-Dict oder None
  219. """
  220. if not title or not title.strip():
  221. return None
  222. series_id = _normalize_series_id(title)
  223. # Schon enriched? Direkt zurueckgeben
  224. existing = self._cache.get_enrichment(series_id)
  225. if existing and existing.get("src") != "none":
  226. pdebug(f"[Enricher] On-Demand: '{title}' bereits enriched")
  227. return existing
  228. pdebug(f"[Enricher] On-Demand Enrichment fuer '{title}'")
  229. # Alle Quellen durchprobieren
  230. # 1. AniList (kein Key noetig)
  231. if self._anilist:
  232. result = await self._anilist.search(title)
  233. if result:
  234. self._cache.update_enrichment(series_id, result)
  235. self._sync_state.mark_title_enriched(series_id, result.get("src", "unknown"))
  236. return result
  237. # 2. TMDB Film
  238. if self._tmdb:
  239. result = await self._tmdb.search_movie(title)
  240. if result and result.get("rt") and result["rt"] > 0:
  241. self._cache.update_enrichment(series_id, result)
  242. self._sync_state.mark_title_enriched(series_id, result.get("src", "unknown"))
  243. return result
  244. # 3. TMDB Serie
  245. if self._tmdb:
  246. result = await self._tmdb.search_tv(title)
  247. if result:
  248. self._cache.update_enrichment(series_id, result)
  249. self._sync_state.mark_title_enriched(series_id, result.get("src", "unknown"))
  250. return result
  251. # 4. TVDB Fallback
  252. if self._tvdb:
  253. result = await self._tvdb.search(title)
  254. if result:
  255. self._cache.update_enrichment(series_id, result)
  256. self._sync_state.mark_title_enriched(series_id, result.get("src", "unknown"))
  257. return result
  258. pdebug(f"[Enricher] On-Demand: Kein Ergebnis fuer '{title}'")
  259. return None
  260. def _find_program_for_series_id(self, series_id: str) -> dict | None:
  261. """
  262. Findet ein Programm im Cache anhand der series_id.
  263. Nutzt den _by_series_id Index des Cache fuer O(1) Lookup.
  264. Args:
  265. series_id: Normalisierte Titel-ID
  266. Returns:
  267. Erstes passendes Programm-Dict oder None
  268. """
  269. return self._cache.get_program_by_series_id(series_id)
  270. @staticmethod
  271. def _is_likely_movie(program: dict) -> bool:
  272. """
  273. Heuristik: Ist ein EPG-Eintrag wahrscheinlich ein Film?
  274. Prueft:
  275. - Genre enthaelt "Film", "Spielfilm", "Kinofilm"
  276. - Programm-Typ ist "film"
  277. - Dauer > 70 Minuten (typisch fuer Spielfilme)
  278. Args:
  279. program: Programm-Dict aus dem EPG-Cache
  280. Returns:
  281. True wenn der Eintrag wahrscheinlich ein Film ist
  282. """
  283. genre = (program.get("g", "") or "").lower()
  284. if any(kw in genre for kw in ("spielfilm", "kinofilm", "film", "movie")):
  285. return True
  286. # Dauer-Check: Programme > 70 Min sind oft Filme
  287. duration = program.get("dur", 0)
  288. if isinstance(duration, (int, float)) and duration > 70:
  289. # Aber nur wenn Genre nicht auf Serie hindeutet
  290. if "serie" not in genre:
  291. return True
  292. return False