tmdb.py 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. # -*- coding: utf-8 -*-
  2. """
  3. TMDB (The Movie Database) API Client fuer Film- und Serien-Metadaten.
  4. TMDB bietet eine kostenlose API-Stufe. Ein API-Key wird benoetigt
  5. und ueber die Plugin-Konfiguration uebergeben.
  6. Features:
  7. - Film-Suche mit optionalem Erscheinungsjahr
  8. - Serien-Suche
  9. - Watch-Provider fuer DE-Region (Streaming-Dienste)
  10. API-Dokumentation: https://developer.themoviedb.org/docs
  11. Abhaengigkeiten:
  12. - aiohttp: Fuer HTTP-Requests
  13. """
  14. from __future__ import annotations
  15. import asyncio
  16. from datetime import datetime, timezone
  17. from typing import TYPE_CHECKING
  18. from trixy_core.utils.debug import pdebug, perror, pwarn
  19. _AIOHTTP_AVAILABLE = False
  20. try:
  21. import aiohttp
  22. _AIOHTTP_AVAILABLE = True
  23. except ImportError:
  24. pass
  25. class TMDBClient:
  26. """API-Client fuer The Movie Database (TMDB)."""
  27. API_BASE = "https://api.themoviedb.org/3"
  28. # Timeout fuer einzelne Requests
  29. _REQUEST_TIMEOUT: float = 15.0
  30. def __init__(self, api_key: str, language: str = "de-DE"):
  31. """
  32. Initialisiert den TMDB-Client.
  33. Args:
  34. api_key: TMDB API v3 Key
  35. language: Sprache fuer Ergebnisse (Standard: de-DE)
  36. """
  37. self._api_key = api_key
  38. self._language = language
  39. async def search_movie(self, title: str, year: int | None = None) -> dict | None:
  40. """
  41. Sucht einen Film auf TMDB.
  42. Args:
  43. title: Film-Titel
  44. year: Optionales Erscheinungsjahr fuer genauere Suche
  45. Returns:
  46. Enrichment-Dict mit kompakten Feldnamen oder None.
  47. Keine URLs oder Bilder — nur Metadaten fuer den
  48. Sprachassistenten.
  49. """
  50. params: dict[str, str | int] = {"query": title.strip()}
  51. if year:
  52. params["year"] = year
  53. data = await self._get("/search/movie", params)
  54. if not data:
  55. return None
  56. results = data.get("results", [])
  57. if not results:
  58. pdebug(f"[TMDB] Kein Film gefunden fuer '{title}'")
  59. return None
  60. # Bestes Ergebnis nehmen (erstes, nach Popularitaet sortiert)
  61. movie = results[0]
  62. tmdb_id = movie.get("id")
  63. # Genre-IDs in Namen aufloesen
  64. genres = await self._resolve_movie_genres(movie.get("genre_ids", []))
  65. # Beschreibung kuerzen
  66. description = (movie.get("overview") or "").strip()
  67. if len(description) > 500:
  68. description = description[:497] + "..."
  69. # Rating: vote_average ist 0-10
  70. rating = movie.get("vote_average")
  71. if rating is not None:
  72. rating = round(float(rating), 1)
  73. # Watch-Provider holen
  74. streaming: list[str] = []
  75. if tmdb_id:
  76. streaming = await self.get_watch_providers("movie", tmdb_id)
  77. enrichment = {
  78. "t": movie.get("title") or title,
  79. "ty": "film",
  80. "g": genres,
  81. "tags": [],
  82. "d": description,
  83. "rt": rating,
  84. "str": streaming,
  85. "sim": [],
  86. "src": "tmdb",
  87. "at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
  88. }
  89. pdebug(f"[TMDB] Film-Enrichment: '{title}' -> '{enrichment['t']}' ({len(genres)} Genres)")
  90. return enrichment
  91. async def search_tv(self, title: str) -> dict | None:
  92. """
  93. Sucht eine Serie auf TMDB.
  94. Args:
  95. title: Serien-Titel
  96. Returns:
  97. Enrichment-Dict mit kompakten Feldnamen oder None.
  98. """
  99. data = await self._get("/search/tv", {"query": title.strip()})
  100. if not data:
  101. return None
  102. results = data.get("results", [])
  103. if not results:
  104. pdebug(f"[TMDB] Keine Serie gefunden fuer '{title}'")
  105. return None
  106. show = results[0]
  107. tmdb_id = show.get("id")
  108. # Genre-IDs in Namen aufloesen
  109. genres = await self._resolve_tv_genres(show.get("genre_ids", []))
  110. # Beschreibung kuerzen
  111. description = (show.get("overview") or "").strip()
  112. if len(description) > 500:
  113. description = description[:497] + "..."
  114. # Rating
  115. rating = show.get("vote_average")
  116. if rating is not None:
  117. rating = round(float(rating), 1)
  118. # Watch-Provider holen
  119. streaming: list[str] = []
  120. if tmdb_id:
  121. streaming = await self.get_watch_providers("tv", tmdb_id)
  122. enrichment = {
  123. "t": show.get("name") or title,
  124. "ty": "serie",
  125. "g": genres,
  126. "tags": [],
  127. "d": description,
  128. "rt": rating,
  129. "str": streaming,
  130. "sim": [],
  131. "src": "tmdb",
  132. "at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
  133. }
  134. pdebug(f"[TMDB] Serien-Enrichment: '{title}' -> '{enrichment['t']}' ({len(genres)} Genres)")
  135. return enrichment
  136. async def get_watch_providers(self, media_type: str, tmdb_id: int) -> list[str]:
  137. """
  138. Holt Streaming-Provider fuer DE-Region.
  139. Gibt nur Provider-Namen zurueck, keine URLs — ist ein
  140. Sprachassistent, der die Info vorliest.
  141. Args:
  142. media_type: "movie" oder "tv"
  143. tmdb_id: TMDB-ID des Films/der Serie
  144. Returns:
  145. Liste von Provider-Namen, z.B. ["Netflix", "Amazon Prime Video"]
  146. """
  147. data = await self._get(f"/{media_type}/{tmdb_id}/watch/providers")
  148. if not data:
  149. return []
  150. # DE-Region filtern
  151. de_data = data.get("results", {}).get("DE")
  152. if not de_data:
  153. pdebug(f"[TMDB] Keine Watch-Provider fuer DE (ID: {tmdb_id})")
  154. return []
  155. providers: list[str] = []
  156. seen: set[str] = set()
  157. # Flatrate (Abo-Streaming) bevorzugt
  158. for provider in de_data.get("flatrate", []):
  159. name = provider.get("provider_name", "")
  160. if name and name not in seen:
  161. providers.append(name)
  162. seen.add(name)
  163. # Auch "free" Provider einschliessen (z.B. Joyn, RTL+)
  164. for provider in de_data.get("free", []):
  165. name = provider.get("provider_name", "")
  166. if name and name not in seen:
  167. providers.append(name)
  168. seen.add(name)
  169. return providers
  170. # --- Genre-Aufloesung ---
  171. # TMDB liefert bei der Suche nur Genre-IDs.
  172. # Statt die Genre-Liste jedes Mal abzufragen, verwenden wir einen
  173. # statischen Cache der gaengigsten Genres.
  174. _MOVIE_GENRES: dict[int, str] = {
  175. 28: "Action", 12: "Abenteuer", 16: "Animation",
  176. 35: "Komoedie", 80: "Krimi", 99: "Dokumentarfilm",
  177. 18: "Drama", 10751: "Familie", 14: "Fantasy",
  178. 36: "Historie", 27: "Horror", 10402: "Musik",
  179. 9648: "Mystery", 10749: "Liebesfilm", 878: "Science-Fiction",
  180. 10770: "TV-Film", 53: "Thriller", 10752: "Kriegsfilm",
  181. 37: "Western",
  182. }
  183. _TV_GENRES: dict[int, str] = {
  184. 10759: "Action & Abenteuer", 16: "Animation",
  185. 35: "Komoedie", 80: "Krimi", 99: "Dokumentarfilm",
  186. 18: "Drama", 10751: "Familie", 10762: "Kinder",
  187. 9648: "Mystery", 10763: "News", 10764: "Reality",
  188. 10765: "Sci-Fi & Fantasy", 10766: "Soap",
  189. 10767: "Talk", 10768: "War & Politics", 37: "Western",
  190. }
  191. async def _resolve_movie_genres(self, genre_ids: list[int]) -> list[str]:
  192. """Loest Film-Genre-IDs in deutsche Genre-Namen auf."""
  193. return [
  194. self._MOVIE_GENRES[gid]
  195. for gid in genre_ids
  196. if gid in self._MOVIE_GENRES
  197. ]
  198. async def _resolve_tv_genres(self, genre_ids: list[int]) -> list[str]:
  199. """Loest Serien-Genre-IDs in deutsche Genre-Namen auf."""
  200. return [
  201. self._TV_GENRES[gid]
  202. for gid in genre_ids
  203. if gid in self._TV_GENRES
  204. ]
  205. # --- HTTP-Hilfsmethode ---
  206. async def _get(self, endpoint: str, params: dict | None = None) -> dict | None:
  207. """
  208. GET-Request mit API-Key und Fehlerbehandlung.
  209. Args:
  210. endpoint: API-Endpunkt (z.B. "/search/movie")
  211. params: Zusaetzliche Query-Parameter
  212. Returns:
  213. JSON-Antwort als Dict oder None bei Fehler
  214. """
  215. if not _AIOHTTP_AVAILABLE:
  216. perror("[TMDB] aiohttp nicht installiert — Enrichment deaktiviert")
  217. return None
  218. url = f"{self.API_BASE}{endpoint}"
  219. query_params: dict[str, str | int] = {
  220. "api_key": self._api_key,
  221. "language": self._language,
  222. }
  223. if params:
  224. query_params.update(params)
  225. try:
  226. async with aiohttp.ClientSession() as session:
  227. async with session.get(
  228. url,
  229. params=query_params,
  230. timeout=aiohttp.ClientTimeout(total=self._REQUEST_TIMEOUT),
  231. ) as resp:
  232. if resp.status == 401:
  233. perror("[TMDB] Ungueltiger API-Key (401)")
  234. return None
  235. if resp.status == 429:
  236. retry_after = float(resp.headers.get("Retry-After", "2"))
  237. pwarn(f"[TMDB] Rate-Limit (429), warte {retry_after:.1f}s")
  238. await asyncio.sleep(retry_after)
  239. # Einmaliger Retry
  240. return await self._get(endpoint, params)
  241. if resp.status != 200:
  242. perror(f"[TMDB] HTTP {resp.status} fuer {endpoint}")
  243. return None
  244. return await resp.json()
  245. except asyncio.TimeoutError:
  246. perror(f"[TMDB] Timeout fuer {endpoint}")
  247. return None
  248. except aiohttp.ClientError as exc:
  249. perror(f"[TMDB] Verbindungsfehler: {exc}")
  250. return None
  251. except Exception as exc:
  252. perror(f"[TMDB] Unerwarteter Fehler: {exc}")
  253. return None