| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581 |
- # -*- 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
- ]
|