tmdb_discover.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581
  1. # -*- coding: utf-8 -*-
  2. """
  3. TMDB Discover Client fuer Streaming-Katalogaufbau.
  4. Nutzt die TMDB-Endpunkte ``/discover/movie`` und ``/discover/tv``,
  5. um den Katalog pro Streaming-Dienst aufzubauen. Ergebnisse werden
  6. in das kompakte Titel-Format des StreamingCache normalisiert.
  7. API-Dokumentation: https://developer.themoviedb.org/docs
  8. Abhaengigkeiten:
  9. - aiohttp: Fuer HTTP-Requests
  10. """
  11. from __future__ import annotations
  12. import asyncio
  13. from datetime import date, datetime, timedelta, timezone
  14. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  15. _AIOHTTP_AVAILABLE = False
  16. try:
  17. import aiohttp
  18. _AIOHTTP_AVAILABLE = True
  19. except ImportError:
  20. pass
  21. # TMDB Watch-Provider-IDs fuer DE-Region
  22. PROVIDER_IDS: dict[str, int] = {
  23. "netflix": 8,
  24. "amazon_prime": 9,
  25. "crunchyroll": 283,
  26. "disney_plus": 337,
  27. "wow": 30,
  28. }
  29. # Standard-Dub/Sub je nach Dienst
  30. _DEFAULT_LANGUAGES: dict[str, dict[str, list[str]]] = {
  31. "crunchyroll": {"dub": ["ja"], "sub": ["de"]},
  32. "netflix": {"dub": ["de", "en"], "sub": ["de"]},
  33. "amazon_prime": {"dub": ["de", "en"], "sub": ["de"]},
  34. "disney_plus": {"dub": ["de", "en"], "sub": ["de"]},
  35. "wow": {"dub": ["de", "en"], "sub": ["de"]},
  36. }
  37. # TMDB Genre-IDs -> Deutsche Genre-Namen (Film)
  38. GENRE_MAP_MOVIE: dict[int, str] = {
  39. 28: "Action",
  40. 12: "Abenteuer",
  41. 16: "Animation",
  42. 35: "Komoedie",
  43. 80: "Krimi",
  44. 99: "Dokumentation",
  45. 18: "Drama",
  46. 10751: "Familie",
  47. 14: "Fantasy",
  48. 36: "Historie",
  49. 27: "Horror",
  50. 10402: "Musik",
  51. 9648: "Mystery",
  52. 10749: "Romantik",
  53. 878: "Science Fiction",
  54. 53: "Thriller",
  55. 10752: "Krieg",
  56. 37: "Western",
  57. }
  58. # TMDB Genre-IDs -> Deutsche Genre-Namen (TV)
  59. GENRE_MAP_TV: dict[int, str] = {
  60. 10759: "Action & Abenteuer",
  61. 16: "Animation",
  62. 35: "Komoedie",
  63. 80: "Krimi",
  64. 99: "Dokumentation",
  65. 18: "Drama",
  66. 10751: "Familie",
  67. 10762: "Kinder",
  68. 9648: "Mystery",
  69. 10763: "Nachrichten",
  70. 10764: "Reality",
  71. 10765: "Sci-Fi & Fantasy",
  72. 10766: "Soap",
  73. 10767: "Talk",
  74. 10768: "War & Politics",
  75. 37: "Western",
  76. }
  77. class TMDBDiscoverClient:
  78. """
  79. TMDB Discover Client fuer den Katalogaufbau pro Streaming-Dienst.
  80. Nutzt ``/discover/movie`` und ``/discover/tv`` mit Watch-Provider-Filter,
  81. um alle verfuegbaren Titel eines Dienstes zu ermitteln.
  82. """
  83. API_BASE = "https://api.themoviedb.org/3"
  84. # Timeout fuer einzelne Requests
  85. _REQUEST_TIMEOUT: float = 15.0
  86. # Maximale Retry-Versuche bei Rate-Limiting
  87. _MAX_RETRIES: int = 3
  88. def __init__(
  89. self,
  90. api_key: str,
  91. region: str = "DE",
  92. language: str = "de-DE",
  93. ) -> None:
  94. """
  95. Initialisiert den TMDB Discover Client.
  96. Args:
  97. api_key: TMDB API v3 Key.
  98. region: Watch-Region fuer Provider-Filterung (Standard: DE).
  99. language: Sprache fuer Ergebnisse (Standard: de-DE).
  100. """
  101. self._api_key = api_key
  102. self._region = region
  103. self._language = language
  104. async def discover_service(
  105. self,
  106. service_id: str,
  107. max_titles: int = 500,
  108. ) -> list[dict]:
  109. """
  110. Ermittelt alle Titel eines Streaming-Dienstes via TMDB.
  111. Ruft Filme und Serien ab und gibt normalisierte Titel-Dicts zurueck.
  112. Die Paginierung wird automatisch durchlaufen (20 Ergebnisse pro Seite,
  113. max. 500 Seiten bei TMDB).
  114. Args:
  115. service_id: ID des Streaming-Dienstes (z.B. "netflix").
  116. max_titles: Maximale Anzahl Titel pro Medientyp.
  117. Returns:
  118. Liste normalisierter Titel-Dicts.
  119. """
  120. provider_id = PROVIDER_IDS.get(service_id)
  121. if provider_id is None:
  122. pwarn(f"[TMDBDiscover] Unbekannter Dienst: '{service_id}'")
  123. return []
  124. # Seiten berechnen (20 Ergebnisse pro Seite)
  125. max_pages = min(max_titles // 20 + 1, 500)
  126. pinfo(f"[TMDBDiscover] Starte Discover fuer '{service_id}' (max {max_pages} Seiten)")
  127. movies = await self._discover_movies(provider_id, max_pages)
  128. tv_shows = await self._discover_tv(provider_id, max_pages)
  129. # Normalisieren
  130. results: list[dict] = []
  131. for item in movies:
  132. normalized = self._normalize_movie(item, service_id)
  133. if normalized:
  134. results.append(normalized)
  135. for item in tv_shows:
  136. normalized = self._normalize_tv(item, service_id)
  137. if normalized:
  138. results.append(normalized)
  139. pinfo(
  140. f"[TMDBDiscover] '{service_id}': "
  141. f"{len(movies)} Filme + {len(tv_shows)} Serien = {len(results)} Titel"
  142. )
  143. return results
  144. async def _discover_movies(
  145. self,
  146. provider_id: int,
  147. max_pages: int = 25,
  148. ) -> list[dict]:
  149. """
  150. Ruft Filme ueber ``/discover/movie`` ab.
  151. Filtert nach Watch-Provider und sortiert nach Popularitaet.
  152. Args:
  153. provider_id: TMDB Watch-Provider-ID.
  154. max_pages: Maximale Anzahl Seiten.
  155. Returns:
  156. Liste der TMDB-Rohergebnisse.
  157. """
  158. results: list[dict] = []
  159. page = 1
  160. total_pages = 1 # Wird nach der ersten Antwort aktualisiert
  161. while page <= min(max_pages, total_pages):
  162. params = {
  163. "watch_region": self._region,
  164. "with_watch_providers": str(provider_id),
  165. "sort_by": "popularity.desc",
  166. "page": str(page),
  167. }
  168. data = await self._get("/discover/movie", params)
  169. if not data:
  170. break
  171. total_pages = data.get("total_pages", 1)
  172. items = data.get("results", [])
  173. if not items:
  174. break
  175. results.extend(items)
  176. page += 1
  177. # Kleine Pause, um Rate-Limiting zu vermeiden
  178. if page <= min(max_pages, total_pages):
  179. await asyncio.sleep(0.25)
  180. pdebug(f"[TMDBDiscover] {len(results)} Filme gefunden (Provider {provider_id})")
  181. return results
  182. async def _discover_tv(
  183. self,
  184. provider_id: int,
  185. max_pages: int = 25,
  186. ) -> list[dict]:
  187. """
  188. Ruft Serien ueber ``/discover/tv`` ab.
  189. Filtert nach Watch-Provider und sortiert nach Popularitaet.
  190. Args:
  191. provider_id: TMDB Watch-Provider-ID.
  192. max_pages: Maximale Anzahl Seiten.
  193. Returns:
  194. Liste der TMDB-Rohergebnisse.
  195. """
  196. results: list[dict] = []
  197. page = 1
  198. total_pages = 1
  199. while page <= min(max_pages, total_pages):
  200. params = {
  201. "watch_region": self._region,
  202. "with_watch_providers": str(provider_id),
  203. "sort_by": "popularity.desc",
  204. "page": str(page),
  205. }
  206. data = await self._get("/discover/tv", params)
  207. if not data:
  208. break
  209. total_pages = data.get("total_pages", 1)
  210. items = data.get("results", [])
  211. if not items:
  212. break
  213. results.extend(items)
  214. page += 1
  215. # Kleine Pause, um Rate-Limiting zu vermeiden
  216. if page <= min(max_pages, total_pages):
  217. await asyncio.sleep(0.25)
  218. pdebug(f"[TMDBDiscover] {len(results)} Serien gefunden (Provider {provider_id})")
  219. return results
  220. async def get_watch_providers(
  221. self,
  222. tmdb_id: int,
  223. media_type: str,
  224. ) -> dict:
  225. """
  226. Holt Watch-Provider-Informationen fuer einen Titel.
  227. Args:
  228. tmdb_id: TMDB-ID des Films/der Serie.
  229. media_type: "movie" oder "tv".
  230. Returns:
  231. Provider-Dict fuer die DE-Region (flatrate, free, etc.)
  232. oder leeres Dict bei Fehler.
  233. """
  234. data = await self._get(f"/{media_type}/{tmdb_id}/watch/providers")
  235. if not data:
  236. return {}
  237. de_data = data.get("results", {}).get(self._region)
  238. if not de_data:
  239. pdebug(f"[TMDBDiscover] Keine Provider fuer {self._region} (ID: {tmdb_id})")
  240. return {}
  241. return de_data
  242. async def discover_recent(
  243. self,
  244. service_id: str,
  245. days: int = 7,
  246. ) -> list[dict]:
  247. """
  248. Ermittelt kuerzlich hinzugefuegte Titel eines Dienstes.
  249. Nutzt Datums-Filter, um nur Titel der letzten ``days`` Tage
  250. zu finden. Holt nur die erste Seite pro Medientyp.
  251. Args:
  252. service_id: ID des Streaming-Dienstes.
  253. days: Anzahl Tage zurueckblicken.
  254. Returns:
  255. Liste normalisierter Titel-Dicts.
  256. """
  257. provider_id = PROVIDER_IDS.get(service_id)
  258. if provider_id is None:
  259. pwarn(f"[TMDBDiscover] Unbekannter Dienst: '{service_id}'")
  260. return []
  261. today = date.today()
  262. since = today - timedelta(days=days)
  263. date_gte = since.isoformat()
  264. date_lte = today.isoformat()
  265. results: list[dict] = []
  266. # Filme mit Release-Datum-Filter
  267. movie_params = {
  268. "watch_region": self._region,
  269. "with_watch_providers": str(provider_id),
  270. "sort_by": "primary_release_date.desc",
  271. "primary_release_date.gte": date_gte,
  272. "primary_release_date.lte": date_lte,
  273. "page": "1",
  274. }
  275. movie_data = await self._get("/discover/movie", movie_params)
  276. if movie_data:
  277. for item in movie_data.get("results", []):
  278. normalized = self._normalize_movie(item, service_id)
  279. if normalized:
  280. results.append(normalized)
  281. # Serien mit Erstausstrahlungsdatum-Filter
  282. tv_params = {
  283. "watch_region": self._region,
  284. "with_watch_providers": str(provider_id),
  285. "sort_by": "first_air_date.desc",
  286. "first_air_date.gte": date_gte,
  287. "first_air_date.lte": date_lte,
  288. "page": "1",
  289. }
  290. tv_data = await self._get("/discover/tv", tv_params)
  291. if tv_data:
  292. for item in tv_data.get("results", []):
  293. normalized = self._normalize_tv(item, service_id)
  294. if normalized:
  295. results.append(normalized)
  296. pdebug(
  297. f"[TMDBDiscover] '{service_id}': {len(results)} neue Titel "
  298. f"in den letzten {days} Tagen"
  299. )
  300. return results
  301. # --- HTTP-Hilfsmethode ---
  302. async def _get(
  303. self,
  304. endpoint: str,
  305. params: dict | None = None,
  306. ) -> dict | None:
  307. """
  308. GET-Request mit API-Key, Sprache und Fehlerbehandlung.
  309. Behandelt Rate-Limiting (429) mit automatischem Retry.
  310. Args:
  311. endpoint: API-Endpunkt (z.B. "/discover/movie").
  312. params: Zusaetzliche Query-Parameter.
  313. Returns:
  314. JSON-Antwort als Dict oder None bei Fehler.
  315. """
  316. if not _AIOHTTP_AVAILABLE:
  317. perror("[TMDBDiscover] aiohttp nicht installiert — Discover deaktiviert")
  318. return None
  319. url = f"{self.API_BASE}{endpoint}"
  320. query_params: dict[str, str | int] = {
  321. "api_key": self._api_key,
  322. "language": self._language,
  323. }
  324. if params:
  325. query_params.update(params)
  326. retries = 0
  327. while retries <= self._MAX_RETRIES:
  328. try:
  329. async with aiohttp.ClientSession() as session:
  330. async with session.get(
  331. url,
  332. params=query_params,
  333. timeout=aiohttp.ClientTimeout(total=self._REQUEST_TIMEOUT),
  334. ) as resp:
  335. if resp.status == 401:
  336. perror("[TMDBDiscover] Ungueltiger API-Key (401)")
  337. return None
  338. if resp.status == 429:
  339. retry_after = float(resp.headers.get("Retry-After", "2"))
  340. retries += 1
  341. if retries > self._MAX_RETRIES:
  342. perror(
  343. f"[TMDBDiscover] Rate-Limit (429) nach "
  344. f"{self._MAX_RETRIES} Versuchen fuer {endpoint}"
  345. )
  346. return None
  347. pwarn(
  348. f"[TMDBDiscover] Rate-Limit (429), "
  349. f"warte {retry_after:.1f}s (Versuch {retries}/{self._MAX_RETRIES})"
  350. )
  351. await asyncio.sleep(retry_after)
  352. continue
  353. if resp.status != 200:
  354. perror(f"[TMDBDiscover] HTTP {resp.status} fuer {endpoint}")
  355. return None
  356. return await resp.json()
  357. except asyncio.TimeoutError:
  358. perror(f"[TMDBDiscover] Timeout fuer {endpoint}")
  359. return None
  360. except aiohttp.ClientError as exc:
  361. perror(f"[TMDBDiscover] Verbindungsfehler: {exc}")
  362. return None
  363. except Exception as exc:
  364. perror(f"[TMDBDiscover] Unerwarteter Fehler: {exc}")
  365. return None
  366. return None
  367. # --- Normalisierung ---
  368. def _normalize_movie(self, item: dict, service_id: str) -> dict | None:
  369. """
  370. Konvertiert ein TMDB-Film-Ergebnis in das Katalog-Titel-Format.
  371. Args:
  372. item: TMDB-Rohergebnis aus /discover/movie.
  373. service_id: ID des Streaming-Dienstes.
  374. Returns:
  375. Normalisiertes Title-Dict oder None bei fehlenden Pflichtfeldern.
  376. """
  377. title = item.get("title", "").strip()
  378. if not title:
  379. return None
  380. # Beschreibung kuerzen
  381. description = (item.get("overview") or "").strip()
  382. if len(description) > 500:
  383. description = description[:497] + "..."
  384. # Rating runden
  385. rating = item.get("vote_average")
  386. if rating is not None:
  387. rating = round(float(rating), 1)
  388. # Erscheinungsjahr extrahieren
  389. release_date = item.get("release_date", "")
  390. year: int | None = None
  391. if release_date and len(release_date) >= 4:
  392. try:
  393. year = int(release_date[:4])
  394. except ValueError:
  395. pass
  396. # Genres aufloesen
  397. genres = self._map_genres(item.get("genre_ids", []), "movie")
  398. # Standard-Sprachen fuer den Dienst
  399. default_langs = _DEFAULT_LANGUAGES.get(service_id, {"dub": ["de", "en"], "sub": ["de"]})
  400. return {
  401. "t": title,
  402. "ty": "film",
  403. "g": genres,
  404. "tags": [],
  405. "d": description,
  406. "yr": year,
  407. "rt": rating,
  408. "orig_lang": item.get("original_language", ""),
  409. "on": {
  410. service_id: {
  411. "dub": list(default_langs.get("dub", [])),
  412. "sub": list(default_langs.get("sub", [])),
  413. },
  414. },
  415. "added_at": date.today().isoformat(),
  416. "src": "tmdb",
  417. "tmdb_id": item.get("id"),
  418. }
  419. def _normalize_tv(self, item: dict, service_id: str) -> dict | None:
  420. """
  421. Konvertiert ein TMDB-TV-Ergebnis in das Katalog-Titel-Format.
  422. Args:
  423. item: TMDB-Rohergebnis aus /discover/tv.
  424. service_id: ID des Streaming-Dienstes.
  425. Returns:
  426. Normalisiertes Title-Dict oder None bei fehlenden Pflichtfeldern.
  427. """
  428. title = item.get("name", "").strip()
  429. if not title:
  430. return None
  431. # Beschreibung kuerzen
  432. description = (item.get("overview") or "").strip()
  433. if len(description) > 500:
  434. description = description[:497] + "..."
  435. # Rating runden
  436. rating = item.get("vote_average")
  437. if rating is not None:
  438. rating = round(float(rating), 1)
  439. # Erstausstrahlungsjahr extrahieren
  440. first_air_date = item.get("first_air_date", "")
  441. year: int | None = None
  442. if first_air_date and len(first_air_date) >= 4:
  443. try:
  444. year = int(first_air_date[:4])
  445. except ValueError:
  446. pass
  447. # Genres aufloesen
  448. genres = self._map_genres(item.get("genre_ids", []), "tv")
  449. # Standard-Sprachen fuer den Dienst
  450. default_langs = _DEFAULT_LANGUAGES.get(service_id, {"dub": ["de", "en"], "sub": ["de"]})
  451. return {
  452. "t": title,
  453. "ty": "serie",
  454. "g": genres,
  455. "tags": [],
  456. "d": description,
  457. "yr": year,
  458. "rt": rating,
  459. "orig_lang": item.get("original_language", ""),
  460. "on": {
  461. service_id: {
  462. "dub": list(default_langs.get("dub", [])),
  463. "sub": list(default_langs.get("sub", [])),
  464. },
  465. },
  466. "added_at": date.today().isoformat(),
  467. "src": "tmdb",
  468. "tmdb_id": item.get("id"),
  469. }
  470. def _map_genres(self, genre_ids: list[int], media_type: str) -> list[str]:
  471. """
  472. Uebersetzt TMDB-Genre-IDs in deutsche Genre-Namen.
  473. Args:
  474. genre_ids: Liste von TMDB-Genre-IDs.
  475. media_type: "movie" oder "tv".
  476. Returns:
  477. Liste deutscher Genre-Namen.
  478. """
  479. genre_map = GENRE_MAP_MOVIE if media_type == "movie" else GENRE_MAP_TV
  480. return [
  481. genre_map[gid]
  482. for gid in genre_ids
  483. if gid in genre_map
  484. ]