tvdb.py 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. # -*- coding: utf-8 -*-
  2. """
  3. TVDB v4 API Client (Fallback fuer Serien-Metadaten).
  4. TVDB braucht einen kostenlosen API-Key fuer Bearer-Token-Auth.
  5. Wird nur als Fallback verwendet, wenn TMDB kein Ergebnis liefert.
  6. API-Dokumentation: https://thetvdb.github.io/v4-api/
  7. Abhaengigkeiten:
  8. - aiohttp: Fuer HTTP-Requests
  9. """
  10. from __future__ import annotations
  11. import asyncio
  12. import time
  13. from datetime import datetime, timezone
  14. from typing import TYPE_CHECKING
  15. from trixy_core.utils.debug import pdebug, perror, pwarn
  16. _AIOHTTP_AVAILABLE = False
  17. try:
  18. import aiohttp
  19. _AIOHTTP_AVAILABLE = True
  20. except ImportError:
  21. pass
  22. class TVDBClient:
  23. """API-Client fuer TheTVDB v4 (Serien-Fallback)."""
  24. API_BASE = "https://api4.thetvdb.com/v4"
  25. # Token-Lebensdauer: 24 Stunden laut TVDB-Doku, wir erneuern nach 23h
  26. _TOKEN_LIFETIME: float = 23 * 3600
  27. _REQUEST_TIMEOUT: float = 15.0
  28. def __init__(self, api_key: str):
  29. """
  30. Initialisiert den TVDB-Client.
  31. Args:
  32. api_key: TVDB v4 API-Key
  33. """
  34. self._api_key = api_key
  35. self._token: str = ""
  36. self._token_expires: float = 0
  37. self._login_failed: bool = False # Nach 401 nicht endlos wiederholen
  38. @property
  39. def _token_valid(self) -> bool:
  40. """Prueft ob der aktuelle Token noch gueltig ist."""
  41. return bool(self._token) and time.monotonic() < self._token_expires
  42. async def authenticate(self) -> bool:
  43. """
  44. Holt einen Bearer-Token via POST /login.
  45. Returns:
  46. True bei erfolgreicher Authentifizierung
  47. """
  48. if not _AIOHTTP_AVAILABLE:
  49. perror("[TVDB] aiohttp nicht installiert — Enrichment deaktiviert")
  50. return False
  51. try:
  52. async with aiohttp.ClientSession() as session:
  53. async with session.post(
  54. f"{self.API_BASE}/login",
  55. json={"apikey": self._api_key},
  56. headers={"Content-Type": "application/json"},
  57. timeout=aiohttp.ClientTimeout(total=self._REQUEST_TIMEOUT),
  58. ) as resp:
  59. if resp.status != 200:
  60. perror(f"[TVDB] Login fehlgeschlagen: HTTP {resp.status} — TVDB deaktiviert fuer diese Session")
  61. self._login_failed = True
  62. return False
  63. data = await resp.json()
  64. token = data.get("data", {}).get("token", "")
  65. if not token:
  66. perror("[TVDB] Kein Token in Login-Antwort")
  67. return False
  68. self._token = token
  69. self._token_expires = time.monotonic() + self._TOKEN_LIFETIME
  70. pdebug("[TVDB] Authentifizierung erfolgreich")
  71. return True
  72. except asyncio.TimeoutError:
  73. perror("[TVDB] Timeout bei Login")
  74. return False
  75. except aiohttp.ClientError as exc:
  76. perror(f"[TVDB] Verbindungsfehler bei Login: {exc}")
  77. return False
  78. except Exception as exc:
  79. perror(f"[TVDB] Unerwarteter Fehler bei Login: {exc}")
  80. return False
  81. async def _ensure_authenticated(self) -> bool:
  82. """Stellt sicher, dass ein gueltiger Token vorhanden ist."""
  83. if self._token_valid:
  84. return True
  85. if self._login_failed:
  86. return False # Nicht endlos wiederholen nach 401
  87. return await self.authenticate()
  88. async def search(self, title: str) -> dict | None:
  89. """
  90. Sucht eine Serie auf TVDB.
  91. Fuehrt zuerst eine Suche durch, dann holt Details fuer
  92. das beste Ergebnis.
  93. Args:
  94. title: Serien-Titel
  95. Returns:
  96. Enrichment-Dict mit kompakten Feldnamen oder None
  97. """
  98. if not title or not title.strip():
  99. return None
  100. if not await self._ensure_authenticated():
  101. return None
  102. # Suche ausfuehren
  103. search_data = await self._get(f"/search?query={title.strip()}&type=series")
  104. if not search_data:
  105. return None
  106. results = search_data.get("data", [])
  107. if not results:
  108. pdebug(f"[TVDB] Kein Ergebnis fuer '{title}'")
  109. return None
  110. # Bestes Ergebnis nehmen
  111. best = results[0]
  112. tvdb_id = best.get("tvdb_id") or best.get("id")
  113. # Details holen fuer Genres etc.
  114. genres: list[str] = []
  115. description = ""
  116. if tvdb_id:
  117. details = await self._get(f"/series/{tvdb_id}/extended")
  118. if details:
  119. series_data = details.get("data", {})
  120. # Genres extrahieren
  121. for genre_entry in series_data.get("genres", []):
  122. genre_name = genre_entry.get("name", "")
  123. if genre_name:
  124. genres.append(genre_name)
  125. # Beschreibung (deutsch bevorzugt, Fallback englisch)
  126. description = series_data.get("overview") or ""
  127. # Fallback-Beschreibung aus Suchergebnis
  128. if not description:
  129. description = best.get("overview") or best.get("overview_deu") or ""
  130. if len(description) > 500:
  131. description = description[:497] + "..."
  132. enrichment = {
  133. "t": best.get("name") or best.get("translated_name") or title,
  134. "ty": "serie",
  135. "g": genres,
  136. "tags": [],
  137. "d": description,
  138. "rt": None,
  139. "str": [],
  140. "sim": [],
  141. "src": "tvdb",
  142. "at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
  143. }
  144. pdebug(f"[TVDB] Enrichment: '{title}' -> '{enrichment['t']}' ({len(genres)} Genres)")
  145. return enrichment
  146. async def _get(self, endpoint: str) -> dict | None:
  147. """
  148. GET-Request mit Bearer-Token-Auth und Fehlerbehandlung.
  149. Args:
  150. endpoint: API-Endpunkt (z.B. "/search?query=...")
  151. Returns:
  152. JSON-Antwort als Dict oder None bei Fehler
  153. """
  154. if not _AIOHTTP_AVAILABLE:
  155. perror("[TVDB] aiohttp nicht installiert")
  156. return None
  157. if not self._token:
  158. perror("[TVDB] Nicht authentifiziert — bitte zuerst authenticate() aufrufen")
  159. return None
  160. url = f"{self.API_BASE}{endpoint}"
  161. headers = {
  162. "Authorization": f"Bearer {self._token}",
  163. "Accept": "application/json",
  164. }
  165. try:
  166. async with aiohttp.ClientSession() as session:
  167. async with session.get(
  168. url,
  169. headers=headers,
  170. timeout=aiohttp.ClientTimeout(total=self._REQUEST_TIMEOUT),
  171. ) as resp:
  172. if resp.status == 401:
  173. # Token abgelaufen — einmal erneuern und Retry
  174. pwarn("[TVDB] Token abgelaufen, erneuere...")
  175. self._token = ""
  176. self._token_expires = 0
  177. if await self.authenticate():
  178. return await self._get(endpoint)
  179. return None
  180. if resp.status == 429:
  181. retry_after = float(resp.headers.get("Retry-After", "2"))
  182. pwarn(f"[TVDB] Rate-Limit (429), warte {retry_after:.1f}s")
  183. await asyncio.sleep(retry_after)
  184. return await self._get(endpoint)
  185. if resp.status != 200:
  186. perror(f"[TVDB] HTTP {resp.status} fuer {endpoint}")
  187. return None
  188. return await resp.json()
  189. except asyncio.TimeoutError:
  190. perror(f"[TVDB] Timeout fuer {endpoint}")
  191. return None
  192. except aiohttp.ClientError as exc:
  193. perror(f"[TVDB] Verbindungsfehler: {exc}")
  194. return None
  195. except Exception as exc:
  196. perror(f"[TVDB] Unerwarteter Fehler: {exc}")
  197. return None