# -*- coding: utf-8 -*- """ TMDB Discover Client fuer Streaming-Katalogaufbau. Nutzt die TMDB-Endpunkte ``/discover/movie`` und ``/discover/tv``, um den Katalog pro Streaming-Dienst aufzubauen. Ergebnisse werden in das kompakte Titel-Format des StreamingCache normalisiert. API-Dokumentation: https://developer.themoviedb.org/docs Abhaengigkeiten: - aiohttp: Fuer HTTP-Requests """ from __future__ import annotations import asyncio from datetime import date, datetime, timedelta, timezone from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn _AIOHTTP_AVAILABLE = False try: import aiohttp _AIOHTTP_AVAILABLE = True except ImportError: pass # TMDB Watch-Provider-IDs fuer DE-Region PROVIDER_IDS: dict[str, int] = { "netflix": 8, "amazon_prime": 9, "crunchyroll": 283, "disney_plus": 337, "wow": 30, } # Standard-Dub/Sub je nach Dienst _DEFAULT_LANGUAGES: dict[str, dict[str, list[str]]] = { "crunchyroll": {"dub": ["ja"], "sub": ["de"]}, "netflix": {"dub": ["de", "en"], "sub": ["de"]}, "amazon_prime": {"dub": ["de", "en"], "sub": ["de"]}, "disney_plus": {"dub": ["de", "en"], "sub": ["de"]}, "wow": {"dub": ["de", "en"], "sub": ["de"]}, } # TMDB Genre-IDs -> Deutsche Genre-Namen (Film) GENRE_MAP_MOVIE: dict[int, str] = { 28: "Action", 12: "Abenteuer", 16: "Animation", 35: "Komoedie", 80: "Krimi", 99: "Dokumentation", 18: "Drama", 10751: "Familie", 14: "Fantasy", 36: "Historie", 27: "Horror", 10402: "Musik", 9648: "Mystery", 10749: "Romantik", 878: "Science Fiction", 53: "Thriller", 10752: "Krieg", 37: "Western", } # TMDB Genre-IDs -> Deutsche Genre-Namen (TV) GENRE_MAP_TV: dict[int, str] = { 10759: "Action & Abenteuer", 16: "Animation", 35: "Komoedie", 80: "Krimi", 99: "Dokumentation", 18: "Drama", 10751: "Familie", 10762: "Kinder", 9648: "Mystery", 10763: "Nachrichten", 10764: "Reality", 10765: "Sci-Fi & Fantasy", 10766: "Soap", 10767: "Talk", 10768: "War & Politics", 37: "Western", } class TMDBDiscoverClient: """ TMDB Discover Client fuer den Katalogaufbau pro Streaming-Dienst. Nutzt ``/discover/movie`` und ``/discover/tv`` mit Watch-Provider-Filter, um alle verfuegbaren Titel eines Dienstes zu ermitteln. """ API_BASE = "https://api.themoviedb.org/3" # Timeout fuer einzelne Requests _REQUEST_TIMEOUT: float = 15.0 # Maximale Retry-Versuche bei Rate-Limiting _MAX_RETRIES: int = 3 def __init__( self, api_key: str, region: str = "DE", language: str = "de-DE", ) -> None: """ Initialisiert den TMDB Discover Client. Args: api_key: TMDB API v3 Key. region: Watch-Region fuer Provider-Filterung (Standard: DE). language: Sprache fuer Ergebnisse (Standard: de-DE). """ self._api_key = api_key self._region = region self._language = language async def discover_service( self, service_id: str, max_titles: int = 500, ) -> list[dict]: """ Ermittelt alle Titel eines Streaming-Dienstes via TMDB. Ruft Filme und Serien ab und gibt normalisierte Titel-Dicts zurueck. Die Paginierung wird automatisch durchlaufen (20 Ergebnisse pro Seite, max. 500 Seiten bei TMDB). Args: service_id: ID des Streaming-Dienstes (z.B. "netflix"). max_titles: Maximale Anzahl Titel pro Medientyp. Returns: Liste normalisierter Titel-Dicts. """ provider_id = PROVIDER_IDS.get(service_id) if provider_id is None: pwarn(f"[TMDBDiscover] Unbekannter Dienst: '{service_id}'") return [] # Seiten berechnen (20 Ergebnisse pro Seite) max_pages = min(max_titles // 20 + 1, 500) pinfo(f"[TMDBDiscover] Starte Discover fuer '{service_id}' (max {max_pages} Seiten)") movies = await self._discover_movies(provider_id, max_pages) tv_shows = await self._discover_tv(provider_id, max_pages) # Normalisieren results: list[dict] = [] for item in movies: normalized = self._normalize_movie(item, service_id) if normalized: results.append(normalized) for item in tv_shows: normalized = self._normalize_tv(item, service_id) if normalized: results.append(normalized) pinfo( f"[TMDBDiscover] '{service_id}': " f"{len(movies)} Filme + {len(tv_shows)} Serien = {len(results)} Titel" ) return results async def _discover_movies( self, provider_id: int, max_pages: int = 25, ) -> list[dict]: """ Ruft Filme ueber ``/discover/movie`` ab. Filtert nach Watch-Provider und sortiert nach Popularitaet. Args: provider_id: TMDB Watch-Provider-ID. max_pages: Maximale Anzahl Seiten. Returns: Liste der TMDB-Rohergebnisse. """ results: list[dict] = [] page = 1 total_pages = 1 # Wird nach der ersten Antwort aktualisiert while page <= min(max_pages, total_pages): params = { "watch_region": self._region, "with_watch_providers": str(provider_id), "sort_by": "popularity.desc", "page": str(page), } data = await self._get("/discover/movie", params) if not data: break total_pages = data.get("total_pages", 1) items = data.get("results", []) if not items: break results.extend(items) page += 1 # Kleine Pause, um Rate-Limiting zu vermeiden if page <= min(max_pages, total_pages): await asyncio.sleep(0.25) pdebug(f"[TMDBDiscover] {len(results)} Filme gefunden (Provider {provider_id})") return results async def _discover_tv( self, provider_id: int, max_pages: int = 25, ) -> list[dict]: """ Ruft Serien ueber ``/discover/tv`` ab. Filtert nach Watch-Provider und sortiert nach Popularitaet. Args: provider_id: TMDB Watch-Provider-ID. max_pages: Maximale Anzahl Seiten. Returns: Liste der TMDB-Rohergebnisse. """ results: list[dict] = [] page = 1 total_pages = 1 while page <= min(max_pages, total_pages): params = { "watch_region": self._region, "with_watch_providers": str(provider_id), "sort_by": "popularity.desc", "page": str(page), } data = await self._get("/discover/tv", params) if not data: break total_pages = data.get("total_pages", 1) items = data.get("results", []) if not items: break results.extend(items) page += 1 # Kleine Pause, um Rate-Limiting zu vermeiden if page <= min(max_pages, total_pages): await asyncio.sleep(0.25) pdebug(f"[TMDBDiscover] {len(results)} Serien gefunden (Provider {provider_id})") return results async def get_watch_providers( self, tmdb_id: int, media_type: str, ) -> dict: """ Holt Watch-Provider-Informationen fuer einen Titel. Args: tmdb_id: TMDB-ID des Films/der Serie. media_type: "movie" oder "tv". Returns: Provider-Dict fuer die DE-Region (flatrate, free, etc.) oder leeres Dict bei Fehler. """ data = await self._get(f"/{media_type}/{tmdb_id}/watch/providers") if not data: return {} de_data = data.get("results", {}).get(self._region) if not de_data: pdebug(f"[TMDBDiscover] Keine Provider fuer {self._region} (ID: {tmdb_id})") return {} return de_data async def discover_recent( self, service_id: str, days: int = 7, ) -> list[dict]: """ Ermittelt kuerzlich hinzugefuegte Titel eines Dienstes. Nutzt Datums-Filter, um nur Titel der letzten ``days`` Tage zu finden. Holt nur die erste Seite pro Medientyp. Args: service_id: ID des Streaming-Dienstes. days: Anzahl Tage zurueckblicken. Returns: Liste normalisierter Titel-Dicts. """ provider_id = PROVIDER_IDS.get(service_id) if provider_id is None: pwarn(f"[TMDBDiscover] Unbekannter Dienst: '{service_id}'") return [] today = date.today() since = today - timedelta(days=days) date_gte = since.isoformat() date_lte = today.isoformat() results: list[dict] = [] # Filme mit Release-Datum-Filter movie_params = { "watch_region": self._region, "with_watch_providers": str(provider_id), "sort_by": "primary_release_date.desc", "primary_release_date.gte": date_gte, "primary_release_date.lte": date_lte, "page": "1", } movie_data = await self._get("/discover/movie", movie_params) if movie_data: for item in movie_data.get("results", []): normalized = self._normalize_movie(item, service_id) if normalized: results.append(normalized) # Serien mit Erstausstrahlungsdatum-Filter tv_params = { "watch_region": self._region, "with_watch_providers": str(provider_id), "sort_by": "first_air_date.desc", "first_air_date.gte": date_gte, "first_air_date.lte": date_lte, "page": "1", } tv_data = await self._get("/discover/tv", tv_params) if tv_data: for item in tv_data.get("results", []): normalized = self._normalize_tv(item, service_id) if normalized: results.append(normalized) pdebug( f"[TMDBDiscover] '{service_id}': {len(results)} neue Titel " f"in den letzten {days} Tagen" ) return results # --- HTTP-Hilfsmethode --- async def _get( self, endpoint: str, params: dict | None = None, ) -> dict | None: """ GET-Request mit API-Key, Sprache und Fehlerbehandlung. Behandelt Rate-Limiting (429) mit automatischem Retry. Args: endpoint: API-Endpunkt (z.B. "/discover/movie"). params: Zusaetzliche Query-Parameter. Returns: JSON-Antwort als Dict oder None bei Fehler. """ if not _AIOHTTP_AVAILABLE: perror("[TMDBDiscover] aiohttp nicht installiert — Discover deaktiviert") return None url = f"{self.API_BASE}{endpoint}" query_params: dict[str, str | int] = { "api_key": self._api_key, "language": self._language, } if params: query_params.update(params) retries = 0 while retries <= self._MAX_RETRIES: try: async with aiohttp.ClientSession() as session: async with session.get( url, params=query_params, timeout=aiohttp.ClientTimeout(total=self._REQUEST_TIMEOUT), ) as resp: if resp.status == 401: perror("[TMDBDiscover] Ungueltiger API-Key (401)") return None if resp.status == 429: retry_after = float(resp.headers.get("Retry-After", "2")) retries += 1 if retries > self._MAX_RETRIES: perror( f"[TMDBDiscover] Rate-Limit (429) nach " f"{self._MAX_RETRIES} Versuchen fuer {endpoint}" ) return None pwarn( f"[TMDBDiscover] Rate-Limit (429), " f"warte {retry_after:.1f}s (Versuch {retries}/{self._MAX_RETRIES})" ) await asyncio.sleep(retry_after) continue if resp.status != 200: perror(f"[TMDBDiscover] HTTP {resp.status} fuer {endpoint}") return None return await resp.json() except asyncio.TimeoutError: perror(f"[TMDBDiscover] Timeout fuer {endpoint}") return None except aiohttp.ClientError as exc: perror(f"[TMDBDiscover] Verbindungsfehler: {exc}") return None except Exception as exc: perror(f"[TMDBDiscover] Unerwarteter Fehler: {exc}") return None return None # --- Normalisierung --- def _normalize_movie(self, item: dict, service_id: str) -> dict | None: """ Konvertiert ein TMDB-Film-Ergebnis in das Katalog-Titel-Format. Args: item: TMDB-Rohergebnis aus /discover/movie. service_id: ID des Streaming-Dienstes. Returns: Normalisiertes Title-Dict oder None bei fehlenden Pflichtfeldern. """ title = item.get("title", "").strip() if not title: return None # Beschreibung kuerzen description = (item.get("overview") or "").strip() if len(description) > 500: description = description[:497] + "..." # Rating runden rating = item.get("vote_average") if rating is not None: rating = round(float(rating), 1) # Erscheinungsjahr extrahieren release_date = item.get("release_date", "") year: int | None = None if release_date and len(release_date) >= 4: try: year = int(release_date[:4]) except ValueError: pass # Genres aufloesen genres = self._map_genres(item.get("genre_ids", []), "movie") # Standard-Sprachen fuer den Dienst default_langs = _DEFAULT_LANGUAGES.get(service_id, {"dub": ["de", "en"], "sub": ["de"]}) return { "t": title, "ty": "film", "g": genres, "tags": [], "d": description, "yr": year, "rt": rating, "orig_lang": item.get("original_language", ""), "on": { service_id: { "dub": list(default_langs.get("dub", [])), "sub": list(default_langs.get("sub", [])), }, }, "added_at": date.today().isoformat(), "src": "tmdb", "tmdb_id": item.get("id"), } def _normalize_tv(self, item: dict, service_id: str) -> dict | None: """ Konvertiert ein TMDB-TV-Ergebnis in das Katalog-Titel-Format. Args: item: TMDB-Rohergebnis aus /discover/tv. service_id: ID des Streaming-Dienstes. Returns: Normalisiertes Title-Dict oder None bei fehlenden Pflichtfeldern. """ title = item.get("name", "").strip() if not title: return None # Beschreibung kuerzen description = (item.get("overview") or "").strip() if len(description) > 500: description = description[:497] + "..." # Rating runden rating = item.get("vote_average") if rating is not None: rating = round(float(rating), 1) # Erstausstrahlungsjahr extrahieren first_air_date = item.get("first_air_date", "") year: int | None = None if first_air_date and len(first_air_date) >= 4: try: year = int(first_air_date[:4]) except ValueError: pass # Genres aufloesen genres = self._map_genres(item.get("genre_ids", []), "tv") # Standard-Sprachen fuer den Dienst default_langs = _DEFAULT_LANGUAGES.get(service_id, {"dub": ["de", "en"], "sub": ["de"]}) return { "t": title, "ty": "serie", "g": genres, "tags": [], "d": description, "yr": year, "rt": rating, "orig_lang": item.get("original_language", ""), "on": { service_id: { "dub": list(default_langs.get("dub", [])), "sub": list(default_langs.get("sub", [])), }, }, "added_at": date.today().isoformat(), "src": "tmdb", "tmdb_id": item.get("id"), } def _map_genres(self, genre_ids: list[int], media_type: str) -> list[str]: """ Uebersetzt TMDB-Genre-IDs in deutsche Genre-Namen. Args: genre_ids: Liste von TMDB-Genre-IDs. media_type: "movie" oder "tv". Returns: Liste deutscher Genre-Namen. """ genre_map = GENRE_MAP_MOVIE if media_type == "movie" else GENRE_MAP_TV return [ genre_map[gid] for gid in genre_ids if gid in genre_map ]