Quellcode durchsuchen

Streaming-Plugin + Staffel/Episoden-Abfrage + Watchlist mit Benachrichtigungen

Streaming-Plugin (komplett neu):
- 5 Streaming-Dienste: Netflix, Amazon Prime, Crunchyroll, Disney+, WOW
- TMDB Discover-basierter Katalog (Top 500 pro Dienst, woechentlicher Sync)
- StreamingCache: JSON + In-Memory-Indices (Service, Genre, Titel, Sub/Dub)
- CatalogSync: woechentlich (full) + taeglich (quick), resume-faehig
- Default Sub/Dub pro Dienst (Crunchyroll: ja-dub/de-sub, Netflix: de+en-dub)

12 Intent-Handler:
- streaming_search: "Welche Horrorfilme gibt es auf Netflix?"
- streaming_find: "Gibt es Naruto auf Crunchyroll?"
- streaming_where: "Wo kann ich Breaking Bad schauen?"
- streaming_new: "Was ist neu auf Netflix?"
- streaming_genre: "Welche Animes gibt es auf Crunchyroll?"
- streaming_language: "Gibt es Naruto mit deutscher Syncro?"
- streaming_compare: "Ist Naruto auf Netflix oder Crunchyroll besser?"
- streaming_recommend: "Empfiehl mir einen guten Anime auf Crunchyroll"
- streaming_seasons: "Wie viele Staffeln hat Detektiv Conan?" (TMDB on-demand)
- streaming_watchlist_add/list/remove: Serien-Watchlist
- streaming_follow_up: Multi-Turn Konversation

Watchlist-System:
- Serien vormerken mit Staffel-/Episoden-/Dub-Snapshot
- Scheduler-Job (alle 12h): prueft TMDB auf Aenderungen
- TTS-Benachrichtigung: "Neuigkeiten fuer Detektiv Conan: 3 neue Episoden"
- Neue Synchronisationen erkannt: "Neue Synchronisation: Deutsch"

TV-Plugin Verbesserungen:
- STT-Aliases: Plugin-spezifische Vosk-Korrekturen (stt_aliases.json)
- Mehrwort-Phrasen im STT-Corrector ("nach utopie" → "naruto")
- Query-Bereinigung (Fuellwoerter "der nächste" entfernt)
- AniList: Titel-Vergleich verhindert Falschzuordnung
- AniList: is_likely_anime fuer pro7_maxx ohne Genre-Feld
- AniList: countryOfOrigin → orig_lang + dub/sub Felder
- TVDB: _login_failed verhindert endlose 401-Requests
- tv_language_search Intent (Syncro/Sub Abfrage)
- Sprach-Feld (lang) im XMLTV-Parser

61 neue Tests (StreamingCache + TMDBDiscover)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
patrick vor 4 Monaten
Ursprung
Commit
f41aaf99b4

+ 0 - 0
plugins/streaming/__init__.py


+ 0 - 0
plugins/streaming/catalog/__init__.py


+ 623 - 0
plugins/streaming/catalog/cache.py

@@ -0,0 +1,623 @@
+# -*- coding: utf-8 -*-
+"""
+Streaming-Katalog-Cache mit In-Memory-Indizes.
+
+Verwaltet Titel-Daten aller konfigurierten Streaming-Dienste als JSON-Datei
+(~3-5 MB). Bietet schnelle Abfragen ueber vorberechnete Indizes nach
+Dienst, Genre, Titel, Sprache (Dub/Sub) und Bewertung.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import re
+from datetime import date
+from pathlib import Path
+
+from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
+
+
+# Artikel, die bei der Titel-Normalisierung entfernt werden
+_STRIP_ARTICLES = {"der", "die", "das", "ein", "eine", "the", "a", "an"}
+
+# Regex fuer Slug-Generierung: nur alphanumerisch + Bindestriche
+_SLUG_RE = re.compile(r"[^a-z0-9]+")
+
+
+class StreamingCache:
+    """
+    Cache fuer Streaming-Katalogdaten mit JSON-Persistenz und In-Memory-Indizes.
+
+    Unterstuetzt Abfragen nach Dienst, Genre, Titel, Sprache (Dub/Sub)
+    und Bewertung. Indizes werden beim Laden oder nach Aenderungen
+    automatisch aufgebaut.
+    """
+
+    FILE_NAME = "streaming_catalog.json"
+
+    def __init__(self, cache_dir: Path) -> None:
+        """
+        Args:
+            cache_dir: Verzeichnis fuer die Cache-Datei.
+        """
+        self._cache_dir = cache_dir
+        self._file_path = cache_dir / self.FILE_NAME
+
+        # Hauptdaten
+        self._titles: dict[str, dict] = {}       # title_slug -> Title-Dict
+        self._services: dict[str, dict] = {}     # service_id -> Service-Info
+        self._recent: dict[str, list[str]] = {}   # service_id -> [title_slugs]
+        self._data: dict = {}                     # Zusatzdaten (watchlist, etc.)
+
+        # In-Memory-Indizes (werden bei _rebuild_indices aufgebaut)
+        self._by_service: dict[str, list[dict]] = {}   # service -> [Titel auf diesem Dienst]
+        self._by_genre: dict[str, list[dict]] = {}     # genre_lower -> [Titel]
+        self._by_title: dict[str, list[dict]] = {}     # normalisierter_titel -> [Titel]
+
+        # Cache-Verzeichnis erstellen
+        self._cache_dir.mkdir(parents=True, exist_ok=True)
+
+    # --- JSON I/O ---
+
+    def load(self) -> bool:
+        """
+        Laedt den Cache aus der JSON-Datei und baut die Indizes auf.
+
+        Toleriert fehlende oder beschaedigte Dateien.
+
+        Returns:
+            True, wenn erfolgreich geladen, False bei Fehler oder fehlender Datei.
+        """
+        if not self._file_path.exists():
+            pdebug("StreamingCache: Keine Cache-Datei vorhanden, starte leer")
+            return False
+
+        try:
+            raw = self._file_path.read_text(encoding="utf-8")
+            data = json.loads(raw)
+
+            self._titles = data.get("titles", {})
+            self._services = data.get("services", {})
+            self._recent = data.get("recent", {})
+            self._data = {k: v for k, v in data.items() if k not in ("titles", "services", "recent")}
+
+            self._rebuild_indices()
+            pinfo(
+                f"StreamingCache: {len(self._titles)} Titel, "
+                f"{len(self._services)} Dienste geladen"
+            )
+            return True
+        except (json.JSONDecodeError, KeyError, TypeError) as e:
+            perror(f"StreamingCache: Fehler beim Laden der Cache-Datei: {e}")
+            self._titles = {}
+            self._services = {}
+            self._recent = {}
+            self._data = {}
+            return False
+
+    def save(self) -> None:
+        """
+        Speichert den Cache atomar in die JSON-Datei.
+
+        Schreibt zuerst in eine temporaere Datei und benennt dann um,
+        um Datenverlust bei Absturz zu vermeiden.
+        """
+        self._save_sync()
+
+    async def save_async(self) -> None:
+        """Async-Speicherung — blockiert den Event-Loop nicht."""
+        await asyncio.to_thread(self._save_sync)
+
+    def _save_sync(self) -> None:
+        """Synchrone Speicherung (kann auch aus Thread aufgerufen werden)."""
+        data = {
+            "titles": self._titles,
+            "services": self._services,
+            "recent": self._recent,
+            **self._data,
+        }
+
+        tmp_path = self._file_path.with_suffix(".tmp")
+        try:
+            tmp_path.write_text(
+                json.dumps(data, ensure_ascii=False, separators=(",", ":")),
+                encoding="utf-8",
+            )
+            tmp_path.replace(self._file_path)
+            pdebug(f"StreamingCache: {len(self._titles)} Titel gespeichert")
+        except OSError as e:
+            perror(f"StreamingCache: Fehler beim Speichern: {e}")
+
+    # --- Index-Verwaltung ---
+
+    def _rebuild_indices(self) -> None:
+        """
+        Baut alle Indizes aus den aktuellen Titeldaten neu auf.
+
+        Indizes:
+        - _by_service: Titel pro Streaming-Dienst
+        - _by_genre: Titel pro Genre (Kleinbuchstaben)
+        - _by_title: Titel pro normalisiertem Namen
+        """
+        self._by_service = {}
+        self._by_genre = {}
+        self._by_title = {}
+
+        for slug, title_data in self._titles.items():
+            # Dienst-Index: aus dem "on"-Dict die Service-IDs extrahieren
+            on_services = title_data.get("on", {})
+            for service_id in on_services:
+                self._by_service.setdefault(service_id, []).append(title_data)
+
+            # Genre-Index
+            for genre in title_data.get("g", []):
+                genre_lower = genre.lower()
+                self._by_genre.setdefault(genre_lower, []).append(title_data)
+
+            # Titel-Index (normalisiert)
+            name = title_data.get("t", "")
+            if name:
+                norm = self._normalize(name)
+                self._by_title.setdefault(norm, []).append(title_data)
+
+        pdebug(
+            f"StreamingCache: Indizes aufgebaut - "
+            f"{len(self._by_service)} Dienste, "
+            f"{len(self._by_genre)} Genres, "
+            f"{len(self._by_title)} Titel"
+        )
+
+    # --- Daten-Mutation ---
+
+    def update_title(self, slug: str, data: dict) -> None:
+        """
+        Fuegt einen Titel hinzu oder aktualisiert einen bestehenden.
+
+        Wenn der Titel bereits existiert, werden die Dienst-Informationen
+        (``on``-Dict) zusammengefuehrt, damit Provider-Daten aus
+        verschiedenen Sync-Laeufen erhalten bleiben.
+
+        Args:
+            slug: Eindeutiger Slug des Titels.
+            data: Title-Dict im Katalogformat.
+        """
+        existing = self._titles.get(slug)
+        if existing:
+            # Bestehende "on"-Eintraege mit neuen zusammenfuehren
+            existing_on = existing.get("on", {})
+            new_on = data.get("on", {})
+            existing_on.update(new_on)
+            data["on"] = existing_on
+        self._titles[slug] = data
+
+    def update_services(self, services: dict) -> None:
+        """
+        Aktualisiert die Dienst-Informationen.
+
+        Args:
+            services: Dict mit service_id -> Service-Info.
+        """
+        self._services.update(services)
+        pdebug(f"StreamingCache: {len(services)} Dienste aktualisiert")
+
+    def set_recent(self, service_id: str, slugs: list[str]) -> None:
+        """
+        Setzt die Liste der kuerzlich hinzugefuegten Titel fuer einen Dienst.
+
+        Args:
+            service_id: ID des Streaming-Dienstes.
+            slugs: Liste der Titel-Slugs.
+        """
+        self._recent[service_id] = slugs
+        pdebug(f"StreamingCache: {len(slugs)} neue Titel fuer '{service_id}' gesetzt")
+
+    def prune_missing(self, service_id: str, current_slugs: set[str]) -> int:
+        """
+        Entfernt einen Dienst aus Titeln, die nicht mehr in current_slugs sind.
+
+        Titel, die auf keinem Dienst mehr verfuegbar sind, werden
+        komplett entfernt.
+
+        Args:
+            service_id: ID des Streaming-Dienstes.
+            current_slugs: Aktuelle Slugs, die auf dem Dienst verfuegbar sind.
+
+        Returns:
+            Anzahl der entfernten Dienst-Zuordnungen.
+        """
+        removed = 0
+        to_delete: list[str] = []
+
+        for slug, title_data in self._titles.items():
+            on_services = title_data.get("on", {})
+            if service_id in on_services and slug not in current_slugs:
+                del on_services[service_id]
+                removed += 1
+                # Titel komplett entfernen, wenn auf keinem Dienst mehr
+                if not on_services:
+                    to_delete.append(slug)
+
+        for slug in to_delete:
+            del self._titles[slug]
+
+        if removed > 0:
+            self._rebuild_indices()
+            pdebug(
+                f"StreamingCache: {removed} Zuordnungen fuer '{service_id}' entfernt, "
+                f"{len(to_delete)} Titel komplett geloescht"
+            )
+
+        return removed
+
+    # --- Abfragen ---
+
+    def search_title(self, query: str, limit: int = 10) -> list[dict]:
+        """
+        Sucht Titel nach Name (unscharfe Suche).
+
+        Args:
+            query: Suchbegriff.
+            limit: Maximale Anzahl Ergebnisse.
+
+        Returns:
+            Liste passender Titel, sortiert nach Bewertung (absteigend).
+        """
+        norm_query = self._normalize(query)
+        if not norm_query:
+            return []
+
+        matches: list[dict] = []
+        for norm_title, titles in self._by_title.items():
+            if norm_query in norm_title or norm_title in norm_query:
+                matches.extend(titles)
+
+        # Nach Bewertung sortieren (beste zuerst)
+        matches.sort(key=lambda t: t.get("rt") or 0.0, reverse=True)
+        return matches[:limit]
+
+    def search_on_service(
+        self,
+        service_id: str,
+        query: str = "",
+        genre: str = "",
+        limit: int = 10,
+    ) -> list[dict]:
+        """
+        Sucht Titel auf einem bestimmten Streaming-Dienst.
+
+        Args:
+            service_id: ID des Streaming-Dienstes.
+            query: Optionaler Suchbegriff fuer den Titel.
+            genre: Optionaler Genre-Filter.
+            limit: Maximale Anzahl Ergebnisse.
+
+        Returns:
+            Liste passender Titel.
+        """
+        candidates = self._by_service.get(service_id, [])
+        if not candidates:
+            return []
+
+        results = candidates
+
+        # Titel-Filter
+        if query:
+            norm_query = self._normalize(query)
+            results = [
+                t for t in results
+                if norm_query in self._normalize(t.get("t", ""))
+            ]
+
+        # Genre-Filter
+        if genre:
+            genre_lower = genre.lower()
+            results = [
+                t for t in results
+                if any(genre_lower in g.lower() for g in t.get("g", []))
+            ]
+
+        # Nach Bewertung sortieren
+        results.sort(key=lambda t: t.get("rt") or 0.0, reverse=True)
+        return results[:limit]
+
+    def find_title(self, query: str) -> dict | None:
+        """
+        Findet den besten einzelnen Treffer fuer eine Suchanfrage.
+
+        Bevorzugt exakte Uebereinstimmungen, dann Teiluebereinstimmungen.
+
+        Args:
+            query: Suchbegriff.
+
+        Returns:
+            Title-Dict oder None.
+        """
+        norm_query = self._normalize(query)
+        if not norm_query:
+            return None
+
+        # Exakte Uebereinstimmung
+        exact = self._by_title.get(norm_query)
+        if exact:
+            return exact[0]
+
+        # Teiluebereinstimmung (bester Treffer nach kuerzester Differenz)
+        best: dict | None = None
+        best_diff = float("inf")
+
+        for norm_title, titles in self._by_title.items():
+            if norm_query in norm_title:
+                diff = len(norm_title) - len(norm_query)
+                if diff < best_diff:
+                    best_diff = diff
+                    best = titles[0]
+
+        return best
+
+    def get_services_for_title(self, slug: str) -> list[str]:
+        """
+        Gibt die Streaming-Dienste zurueck, auf denen ein Titel verfuegbar ist.
+
+        Args:
+            slug: Titel-Slug.
+
+        Returns:
+            Liste von Service-IDs, z.B. ["netflix", "crunchyroll"].
+        """
+        title_data = self._titles.get(slug)
+        if not title_data:
+            return []
+        return list(title_data.get("on", {}).keys())
+
+    def get_recent(self, service_id: str, limit: int = 10) -> list[dict]:
+        """
+        Gibt die kuerzlich hinzugefuegten Titel eines Dienstes zurueck.
+
+        Args:
+            service_id: ID des Streaming-Dienstes.
+            limit: Maximale Anzahl Ergebnisse.
+
+        Returns:
+            Liste der Titel-Dicts.
+        """
+        slugs = self._recent.get(service_id, [])
+        results: list[dict] = []
+        for slug in slugs[:limit]:
+            title_data = self._titles.get(slug)
+            if title_data:
+                results.append(title_data)
+        return results
+
+    def get_by_genre(
+        self,
+        genre: str,
+        service_id: str = "",
+        limit: int = 20,
+    ) -> list[dict]:
+        """
+        Gibt Titel eines bestimmten Genres zurueck.
+
+        Args:
+            genre: Genre-Name (Case-insensitive).
+            service_id: Optionaler Filter auf einen Streaming-Dienst.
+            limit: Maximale Anzahl Ergebnisse.
+
+        Returns:
+            Liste passender Titel, sortiert nach Bewertung.
+        """
+        genre_lower = genre.lower()
+        candidates = self._by_genre.get(genre_lower, [])
+        if not candidates:
+            return []
+
+        results = candidates
+
+        # Dienst-Filter
+        if service_id:
+            results = [
+                t for t in results
+                if service_id in t.get("on", {})
+            ]
+
+        results.sort(key=lambda t: t.get("rt") or 0.0, reverse=True)
+        return results[:limit]
+
+    def get_with_dub(
+        self,
+        language: str,
+        genre: str = "",
+        service_id: str = "",
+    ) -> list[dict]:
+        """
+        Gibt Titel mit einer bestimmten Synchronisation (Dub) zurueck.
+
+        Durchsucht das ``on.{service}.dub``-Array jedes Titels.
+
+        Args:
+            language: Sprachcode, z.B. "de" oder "ja".
+            genre: Optionaler Genre-Filter.
+            service_id: Optionaler Dienst-Filter.
+
+        Returns:
+            Liste passender Titel.
+        """
+        return self._filter_by_language(
+            language, "dub", genre=genre, service_id=service_id,
+        )
+
+    def get_with_sub(
+        self,
+        language: str,
+        genre: str = "",
+        service_id: str = "",
+    ) -> list[dict]:
+        """
+        Gibt Titel mit bestimmten Untertiteln (Sub) zurueck.
+
+        Durchsucht das ``on.{service}.sub``-Array jedes Titels.
+
+        Args:
+            language: Sprachcode, z.B. "de" oder "en".
+            genre: Optionaler Genre-Filter.
+            service_id: Optionaler Dienst-Filter.
+
+        Returns:
+            Liste passender Titel.
+        """
+        return self._filter_by_language(
+            language, "sub", genre=genre, service_id=service_id,
+        )
+
+    def get_recommendations(
+        self,
+        genre: str,
+        service_id: str = "",
+        min_rating: float = 7.0,
+        limit: int = 5,
+    ) -> list[dict]:
+        """
+        Gibt Empfehlungen basierend auf Genre und Mindestbewertung zurueck.
+
+        Args:
+            genre: Genre-Name.
+            service_id: Optionaler Dienst-Filter.
+            min_rating: Minimale Bewertung (0-10).
+            limit: Maximale Anzahl Ergebnisse.
+
+        Returns:
+            Liste empfohlener Titel, sortiert nach Bewertung.
+        """
+        genre_lower = genre.lower()
+        candidates = self._by_genre.get(genre_lower, [])
+        if not candidates:
+            return []
+
+        results: list[dict] = []
+        for title_data in candidates:
+            rating = title_data.get("rt") or 0.0
+            if rating < min_rating:
+                continue
+
+            # Dienst-Filter
+            if service_id and service_id not in title_data.get("on", {}):
+                continue
+
+            results.append(title_data)
+
+        results.sort(key=lambda t: t.get("rt") or 0.0, reverse=True)
+        return results[:limit]
+
+    # --- Hilfsfunktionen ---
+
+    def _filter_by_language(
+        self,
+        language: str,
+        lang_type: str,
+        genre: str = "",
+        service_id: str = "",
+    ) -> list[dict]:
+        """
+        Filtert Titel nach Sprache (Dub oder Sub).
+
+        Durchsucht das ``on``-Dict jedes Titels nach dem angegebenen
+        Sprachtyp und prueft, ob die Sprache enthalten ist.
+
+        Args:
+            language: Sprachcode, z.B. "de".
+            lang_type: "dub" oder "sub".
+            genre: Optionaler Genre-Filter.
+            service_id: Optionaler Dienst-Filter.
+
+        Returns:
+            Liste passender Titel.
+        """
+        lang_lower = language.lower()
+        genre_lower = genre.lower() if genre else ""
+        results: list[dict] = []
+
+        for title_data in self._titles.values():
+            # Genre-Filter
+            if genre_lower:
+                genres = [g.lower() for g in title_data.get("g", [])]
+                if not any(genre_lower in g for g in genres):
+                    continue
+
+            # Sprache pruefen
+            on_services = title_data.get("on", {})
+            matched = False
+
+            for svc_id, svc_info in on_services.items():
+                # Dienst-Filter
+                if service_id and svc_id != service_id:
+                    continue
+
+                langs = svc_info.get(lang_type, [])
+                if lang_lower in langs:
+                    matched = True
+                    break
+
+            if matched:
+                results.append(title_data)
+
+        # Nach Bewertung sortieren
+        results.sort(key=lambda t: t.get("rt") or 0.0, reverse=True)
+        return results
+
+    @staticmethod
+    def _generate_slug(title: str) -> str:
+        """
+        Generiert einen Slug aus dem Titel.
+
+        Slug-Format: Kleinbuchstaben, nicht-alphanumerische Zeichen
+        durch Bindestriche ersetzt. Gleiche Logik wie EPGCache._generate_series_id.
+
+        Args:
+            title: Der Originaltitel.
+
+        Returns:
+            Slug-String.
+        """
+        if not title:
+            return ""
+        slug = _SLUG_RE.sub("-", title.strip().lower())
+        # Fuehrende und abschliessende Bindestriche entfernen
+        return slug.strip("-")
+
+    @staticmethod
+    def _normalize(title: str) -> str:
+        """
+        Normalisiert einen Titel fuer die Suche.
+
+        Konvertiert zu Kleinbuchstaben und entfernt fuehrende Artikel.
+
+        Args:
+            title: Der Originaltitel.
+
+        Returns:
+            Normalisierter Titel.
+        """
+        normalized = title.strip().lower()
+
+        # Fuehrende Artikel entfernen
+        parts = normalized.split()
+        if parts and parts[0] in _STRIP_ARTICLES:
+            normalized = " ".join(parts[1:])
+
+        return normalized
+
+    def get_stats(self) -> dict:
+        """
+        Gibt Statistiken ueber den Cache-Inhalt zurueck.
+
+        Returns:
+            Dict mit Anzahlen fuer Titel, Dienste, Genres, etc.
+        """
+        return {
+            "title_count": len(self._titles),
+            "service_count": len(self._services),
+            "genre_count": len(self._by_genre),
+            "index_service_count": len(self._by_service),
+            "index_title_count": len(self._by_title),
+            "recent_services": len(self._recent),
+        }

+ 282 - 0
plugins/streaming/catalog/sync.py

@@ -0,0 +1,282 @@
+# -*- coding: utf-8 -*-
+"""
+Katalog-Sync-Manager fuer den Streaming-Katalog.
+
+Fuehrt woechentliche Vollsynchronisierungen und taegliche
+Schnellaktualisierungen durch. Der Sync ist Resume-faehig:
+Nach jedem erfolgreich synchronisierten Dienst wird der Status
+gespeichert, sodass ein unterbrochener Sync fortgesetzt werden kann.
+"""
+
+from __future__ import annotations
+
+import json
+from datetime import datetime, timezone
+from pathlib import Path
+
+from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
+
+from .cache import StreamingCache
+from .tmdb_discover import TMDBDiscoverClient
+
+
+class CatalogSync:
+    """
+    Woechentlicher Katalog-Sync mit Resume-Faehigkeit.
+
+    Verwaltet den Synchronisierungszustand und koordiniert
+    den Discover-Client mit dem StreamingCache. Nach jedem
+    synchronisierten Dienst wird der Status persistiert.
+    """
+
+    STATE_FILE = "streaming_sync_state.json"
+
+    # Standard-Sync-Intervall: 7 Tage
+    _SYNC_INTERVAL_DAYS: int = 7
+
+    def __init__(
+        self,
+        config: dict,
+        cache: StreamingCache,
+        discover: TMDBDiscoverClient,
+    ) -> None:
+        """
+        Args:
+            config: Plugin-Konfiguration mit ``cache_dir``, ``services``, etc.
+            cache: StreamingCache-Instanz.
+            discover: TMDBDiscoverClient-Instanz.
+        """
+        self._config = config
+        self._cache = cache
+        self._discover = discover
+
+        cache_dir = Path(config.get("cache_dir", "cache/streaming"))
+        self._state_path = cache_dir / self.STATE_FILE
+        self._state: dict = {}
+
+        # Verzeichnis sicherstellen
+        cache_dir.mkdir(parents=True, exist_ok=True)
+
+    # --- Status-Verwaltung ---
+
+    def load_state(self) -> None:
+        """
+        Laedt den Sync-Status aus der JSON-Datei.
+
+        Toleriert fehlende oder beschaedigte Dateien.
+        """
+        if not self._state_path.exists():
+            pdebug("CatalogSync: Keine Status-Datei vorhanden, starte frisch")
+            self._state = {}
+            return
+
+        try:
+            raw = self._state_path.read_text(encoding="utf-8")
+            self._state = json.loads(raw)
+            pdebug(f"CatalogSync: Status geladen — letzter Sync: {self._state.get('last_sync', 'nie')}")
+        except (json.JSONDecodeError, OSError) as e:
+            perror(f"CatalogSync: Fehler beim Laden der Status-Datei: {e}")
+            self._state = {}
+
+    def save_state(self) -> None:
+        """
+        Speichert den Sync-Status atomar in die JSON-Datei.
+
+        Nutzt tmp+rename fuer Atomizitaet.
+        """
+        tmp_path = self._state_path.with_suffix(".tmp")
+        try:
+            tmp_path.write_text(
+                json.dumps(self._state, ensure_ascii=False, indent=2),
+                encoding="utf-8",
+            )
+            tmp_path.replace(self._state_path)
+            pdebug("CatalogSync: Status gespeichert")
+        except OSError as e:
+            perror(f"CatalogSync: Fehler beim Speichern der Status-Datei: {e}")
+
+    # --- Sync-Steuerung ---
+
+    def needs_sync(self) -> bool:
+        """
+        Prueft, ob ein neuer Vollsync noetig ist.
+
+        Returns:
+            True, wenn der letzte Sync aelter als das Sync-Intervall ist
+            oder noch nie synchronisiert wurde.
+        """
+        last_sync = self._state.get("last_sync")
+        if not last_sync:
+            return True
+
+        try:
+            last_dt = datetime.fromisoformat(last_sync)
+            now = datetime.now(timezone.utc)
+            age_days = (now - last_dt).days
+            return age_days >= self._SYNC_INTERVAL_DAYS
+        except (ValueError, TypeError):
+            pwarn("CatalogSync: Ungueltiges Datum in Status-Datei, Sync noetig")
+            return True
+
+    async def run_full_sync(self) -> dict:
+        """
+        Fuehrt einen vollstaendigen Katalog-Sync fuer alle aktivierten Dienste durch.
+
+        Resume-sicher: Verfolgt, welche Dienste bereits synchronisiert wurden.
+        Bei Unterbrechung werden nur die fehlenden Dienste nachgeholt.
+
+        Returns:
+            Stats-Dict mit service_id -> Titelanzahl.
+        """
+        services = self._config.get("services", {})
+        if not services:
+            pwarn("CatalogSync: Keine Dienste konfiguriert")
+            return {}
+
+        # Bereits synchronisierte Dienste aus dem aktuellen Lauf
+        synced = set(self._state.get("services_synced", []))
+        stats: dict[str, int] = {}
+
+        pinfo(f"CatalogSync: Starte Vollsync fuer {len(services)} Dienste")
+
+        for service_id, service_config in services.items():
+            # Deaktivierte Dienste ueberspringen
+            if not service_config.get("enabled", True):
+                pdebug(f"CatalogSync: '{service_id}' deaktiviert, ueberspringe")
+                continue
+
+            # Bereits synchronisierte Dienste ueberspringen (Resume)
+            if service_id in synced:
+                pdebug(f"CatalogSync: '{service_id}' bereits synchronisiert, ueberspringe")
+                continue
+
+            try:
+                count = await self._sync_service(service_id, service_config)
+                stats[service_id] = count
+
+                # Status nach jedem Dienst speichern (Resume-Punkt)
+                synced.add(service_id)
+                self._state["services_synced"] = list(synced)
+                self.save_state()
+
+                # Cache nach jedem Dienst speichern
+                await self._cache.save_async()
+
+            except Exception as exc:
+                perror(f"CatalogSync: Fehler beim Sync von '{service_id}': {exc}")
+                stats[service_id] = -1
+
+        # Vollsync abgeschlossen — Status zuruecksetzen
+        self._state["last_sync"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
+        self._state["services_synced"] = []
+        self._state["last_full_sync_stats"] = stats
+        self.save_state()
+
+        total = sum(c for c in stats.values() if c > 0)
+        pinfo(f"CatalogSync: Vollsync abgeschlossen — {total} Titel in {len(stats)} Diensten")
+
+        return stats
+
+    async def run_daily_update(self) -> dict:
+        """
+        Fuehrt eine schnelle taegliche Aktualisierung durch.
+
+        Prueft nur die erste Seite pro Dienst (kuerzlich hinzugefuegte Titel).
+        Speichert die neuen Titel als "recent" im Cache.
+
+        Returns:
+            Stats-Dict mit service_id -> Anzahl neuer Titel.
+        """
+        services = self._config.get("services", {})
+        if not services:
+            return {}
+
+        stats: dict[str, int] = {}
+        days = self._config.get("recent_days", 7)
+
+        pdebug(f"CatalogSync: Starte taegliches Update (letzte {days} Tage)")
+
+        for service_id, service_config in services.items():
+            if not service_config.get("enabled", True):
+                continue
+
+            try:
+                titles = await self._discover.discover_recent(service_id, days=days)
+                recent_slugs: list[str] = []
+
+                for title_data in titles:
+                    slug = StreamingCache._generate_slug(title_data.get("t", ""))
+                    if slug:
+                        self._cache.update_title(slug, title_data)
+                        recent_slugs.append(slug)
+
+                self._cache.set_recent(service_id, recent_slugs)
+                stats[service_id] = len(recent_slugs)
+
+            except Exception as exc:
+                perror(f"CatalogSync: Fehler beim Daily-Update von '{service_id}': {exc}")
+                stats[service_id] = -1
+
+        # Cache speichern
+        await self._cache.save_async()
+
+        # Status aktualisieren
+        self._state["last_daily_update"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
+        self._state["last_daily_stats"] = stats
+        self.save_state()
+
+        total = sum(c for c in stats.values() if c > 0)
+        pdebug(f"CatalogSync: Taegliches Update abgeschlossen — {total} neue Titel")
+
+        return stats
+
+    async def _sync_service(self, service_id: str, service_config: dict) -> int:
+        """
+        Synchronisiert einen einzelnen Streaming-Dienst.
+
+        Ruft alle Titel ueber den Discover-Client ab, aktualisiert den Cache
+        und entfernt Titel, die nicht mehr verfuegbar sind (Pruning).
+
+        Args:
+            service_id: ID des Streaming-Dienstes.
+            service_config: Konfiguration des Dienstes.
+
+        Returns:
+            Anzahl der synchronisierten Titel.
+        """
+        max_titles = service_config.get("max_titles", 500)
+
+        pinfo(f"CatalogSync: Synchronisiere '{service_id}' (max {max_titles} Titel)")
+
+        titles = await self._discover.discover_service(service_id, max_titles=max_titles)
+        if not titles:
+            pwarn(f"CatalogSync: Keine Titel fuer '{service_id}' gefunden")
+            return 0
+
+        # Titel in den Cache eintragen
+        current_slugs: set[str] = set()
+        for title_data in titles:
+            slug = StreamingCache._generate_slug(title_data.get("t", ""))
+            if slug:
+                self._cache.update_title(slug, title_data)
+                current_slugs.add(slug)
+
+        # Pruning: Titel entfernen, die nicht mehr auf dem Dienst sind
+        pruned = self._cache.prune_missing(service_id, current_slugs)
+        if pruned > 0:
+            pdebug(f"CatalogSync: {pruned} veraltete Eintraege fuer '{service_id}' entfernt")
+
+        # Dienst-Info aktualisieren
+        self._cache.update_services({
+            service_id: {
+                "name": service_config.get("name", service_id),
+                "last_sync": datetime.now(timezone.utc).isoformat(timespec="seconds"),
+                "title_count": len(current_slugs),
+            },
+        })
+
+        # Indizes neu aufbauen nach allen Aenderungen
+        self._cache._rebuild_indices()
+
+        pinfo(f"CatalogSync: '{service_id}' synchronisiert — {len(current_slugs)} Titel")
+        return len(current_slugs)

+ 581 - 0
plugins/streaming/catalog/tmdb_discover.py

@@ -0,0 +1,581 @@
+# -*- 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
+        ]

+ 20 - 0
plugins/streaming/config.json

@@ -0,0 +1,20 @@
+{
+    "enabled": true,
+    "tmdb_api_key": "",
+    "region": "DE",
+    "language": "de-DE",
+    "services": {
+        "netflix":       {"enabled": true,  "tmdb_provider_id": 8},
+        "amazon_prime":  {"enabled": true,  "tmdb_provider_id": 9},
+        "crunchyroll":   {"enabled": true,  "tmdb_provider_id": 283},
+        "disney_plus":   {"enabled": true,  "tmdb_provider_id": 337},
+        "wow":           {"enabled": true,  "tmdb_provider_id": 30}
+    },
+    "sync": {
+        "sync_day": "sunday",
+        "sync_hour": 4,
+        "max_titles_per_service": 500,
+        "daily_update_enabled": true
+    },
+    "cache_dir": "cache/streaming"
+}

+ 280 - 0
plugins/streaming/intents_de.yml

@@ -0,0 +1,280 @@
+# Streaming-Plugin Intents — Deutsch
+
+streaming_search:
+  samples: 600
+  patterns:
+    - "welche {genre} gibt es auf {service}"
+    - "was fuer {genre} hat {service}"
+    - "{genre} auf {service}"
+    - "welche {genre} (filme|serien) gibt es auf {service}"
+    - "was fuer {genre} (filme|serien) hat {service}"
+    - "{genre} (filme|serien) auf {service}"
+    - "(zeig|zeige) [mir] {genre} auf {service}"
+    - "suche {genre} auf {service}"
+    - "welche {genre} kann ich auf {service} (schauen|sehen|streamen|gucken)"
+    - "hat {service} {genre}"
+    - "hat {service} {genre} (filme|serien)"
+  examples:
+    - "Welche Horrorfilme gibt es auf Netflix?"
+    - "Was fuer Animes hat Crunchyroll?"
+    - "Action auf Amazon Prime"
+    - "Welche Comedy Filme gibt es auf Disney+?"
+    - "Zeig mir Thriller auf Netflix"
+    - "Suche Sci-Fi auf Amazon Prime"
+    - "Welche Anime Serien kann ich auf Crunchyroll schauen?"
+    - "Hat Netflix Horror?"
+    - "Hat Disney+ Kinderfilme?"
+    - "Was fuer Dokumentationen hat WOW?"
+
+streaming_find:
+  samples: 500
+  patterns:
+    - "gibt es {query} auf {service}"
+    - "(laeuft|ist) {query} auf {service}"
+    - "kann ich {query} auf {service} (schauen|sehen|streamen|gucken)"
+    - "hat {service} {query}"
+    - "gibt es {query} [zum] (streamen|schauen)"
+    - "(laeuft|ist|kommt) {query} [irgendwo] [zum] (streamen|schauen)"
+    - "kann ich {query} [irgendwo] (streamen|schauen|sehen|gucken)"
+    - "finde {query} [auf] {service}"
+    - "(ist|laeuft) {query} [noch] auf {service} [verfuegbar]"
+  examples:
+    - "Gibt es Naruto auf Crunchyroll?"
+    - "Laeuft Breaking Bad auf Netflix?"
+    - "Kann ich One Piece auf Crunchyroll schauen?"
+    - "Hat Amazon Prime Herr der Ringe?"
+    - "Gibt es Stranger Things zum Streamen?"
+    - "Kann ich Attack on Titan irgendwo schauen?"
+    - "Ist The Mandalorian auf Disney+?"
+    - "Hat Netflix Wednesday?"
+    - "Finde Naruto auf Crunchyroll"
+    - "Ist Demon Slayer noch auf Crunchyroll verfuegbar?"
+
+streaming_where:
+  samples: 500
+  patterns:
+    - "wo kann ich {query} (schauen|streamen|sehen|gucken)"
+    - "auf welchem (streamingdienst|streaming dienst|dienst|anbieter) gibt es {query}"
+    - "auf welchem (streamingdienst|streaming dienst|dienst|anbieter) (laeuft|ist|kommt) {query}"
+    - "wo (laeuft|ist|gibt es) {query}"
+    - "wo (streamt|zeigt) man {query}"
+    - "bei welchem (anbieter|dienst) (gibt es|laeuft|ist) {query}"
+    - "welcher (streamingdienst|dienst|anbieter) hat {query}"
+  examples:
+    - "Wo kann ich Breaking Bad schauen?"
+    - "Auf welchem Streamingdienst gibt es Naruto?"
+    - "Wo laeuft Stranger Things?"
+    - "Wo gibt es One Piece?"
+    - "Auf welchem Dienst ist The Witcher?"
+    - "Bei welchem Anbieter gibt es Demon Slayer?"
+    - "Welcher Streamingdienst hat Attack on Titan?"
+    - "Wo kann ich Squid Game streamen?"
+    - "Wo kann ich Wednesday sehen?"
+    - "Wo streamt man The Last of Us?"
+
+streaming_new:
+  samples: 400
+  patterns:
+    - "was ist neu auf {service}"
+    - "was gibt es neues auf {service}"
+    - "neue (filme|serien|titel) auf {service}"
+    - "neuheiten auf {service}"
+    - "was ist [diese woche|diesen monat] neu auf {service}"
+    - "was kam [gerade|kuerzlich|letzte woche] neu auf {service}"
+    - "neuerscheinungen [auf] {service}"
+    - "was hat {service} neues"
+  examples:
+    - "Was ist neu auf Netflix?"
+    - "Was gibt es neues auf Disney+?"
+    - "Neue Filme auf Amazon Prime"
+    - "Neue Serien auf Crunchyroll"
+    - "Neuheiten auf Netflix"
+    - "Was ist diese Woche neu auf Disney+?"
+    - "Was kam gerade neu auf Amazon Prime?"
+    - "Neuerscheinungen auf WOW"
+    - "Was hat Netflix neues?"
+    - "Neue Titel auf Crunchyroll"
+
+streaming_genre:
+  samples: 600
+  patterns:
+    - "welche {genre} gibt es auf {service}"
+    - "welche {genre} (filme|serien) (hat|bietet) {service}"
+    - "{genre} (katalog|angebot) [von] {service}"
+    - "alle {genre} auf {service}"
+    - "(zeig|zeige|liste) [mir] [alle] {genre} [auf] {service}"
+    - "was hat {service} an {genre}"
+    - "was hat {service} fuer {genre} [im angebot|im programm]"
+    - "{genre} (serie|film|serien|filme) [auf] {service}"
+  examples:
+    - "Welche Animes gibt es auf Crunchyroll?"
+    - "Welche Horror Filme hat Netflix?"
+    - "Action Katalog von Amazon Prime"
+    - "Alle Dokumentationen auf Disney+"
+    - "Zeig mir alle Thriller auf Netflix"
+    - "Was hat Crunchyroll an Shounen?"
+    - "Was hat Netflix fuer Komoedie im Angebot?"
+    - "Anime Serien auf Crunchyroll"
+    - "Fantasy Filme auf Disney+"
+    - "Krimi Serien auf WOW"
+
+streaming_language:
+  samples: 400
+  patterns:
+    - "gibt es {query} mit deutscher (synchronisation|syncro|synchro|vertonung)"
+    - "gibt es {query} auf deutsch [auf] {service}"
+    - "gibt es {query} mit (untertitel|untertiteln|subs|subtitles) [auf] {service}"
+    - "(laeuft|ist) {query} auf deutsch [auf] {service}"
+    - "(laeuft|ist) {query} mit deutscher (syncro|synchro|synchronisation) [auf] {service}"
+    - "hat {service} {query} auf deutsch"
+    - "hat {service} {query} mit (untertitel|untertiteln|subs)"
+    - "kann ich {query} auf deutsch (schauen|sehen|streamen|gucken)"
+    - "kann ich {query} mit (untertitel|untertiteln|subs) (schauen|sehen|streamen)"
+    - "{query} deutsch [auf] {service}"
+    - "{query} (sub|subs|untertitel) [auf] {service}"
+    - "gibt es {query} mit deutscher (syncro|synchronisation) auf {service}"
+  examples:
+    - "Gibt es Naruto mit deutscher Syncro auf Crunchyroll?"
+    - "Gibt es Attack on Titan auf deutsch?"
+    - "Gibt es One Piece mit Untertiteln auf Crunchyroll?"
+    - "Laeuft Demon Slayer auf deutsch auf Netflix?"
+    - "Hat Crunchyroll Naruto auf deutsch?"
+    - "Kann ich One Piece auf deutsch schauen?"
+    - "Kann ich Attack on Titan mit Untertiteln streamen?"
+    - "Gibt es Spy x Family mit deutscher Synchronisation auf Crunchyroll?"
+    - "Hat Netflix Squid Game mit deutschen Untertiteln?"
+    - "Naruto deutsch auf Crunchyroll"
+
+streaming_compare:
+  samples: 300
+  patterns:
+    - "ist {query} auf {service} oder {service} besser"
+    - "(vergleiche|vergleich) {query} auf {service} und {service}"
+    - "wo ist {query} besser {service} oder {service}"
+    - "hat {service} oder {service} [die] bessere [version von] {query}"
+    - "{query} {service} oder {service}"
+    - "soll ich {query} auf {service} oder [auf] {service} (schauen|gucken|sehen)"
+    - "{query} besser auf {service} oder {service}"
+  examples:
+    - "Ist Naruto auf Netflix oder Crunchyroll besser?"
+    - "Vergleiche One Piece auf Netflix und Crunchyroll"
+    - "Wo ist Attack on Titan besser Netflix oder Crunchyroll?"
+    - "Hat Netflix oder Amazon Prime die bessere Version von Breaking Bad?"
+    - "Naruto Netflix oder Crunchyroll?"
+    - "Soll ich Demon Slayer auf Netflix oder auf Crunchyroll schauen?"
+    - "One Piece besser auf Netflix oder Crunchyroll?"
+
+streaming_recommend:
+  samples: 400
+  patterns:
+    - "(empfiehl|empfehle|empfiehlt) [mir] [einen|eine|ein] [guten|gute|gutes] {genre} [auf] {service}"
+    - "(empfiehl|empfehle|empfiehlt) [mir] [etwas|was] [auf] {service}"
+    - "was (empfiehlst|empfehlst) du [mir] [auf] {service}"
+    - "was [soll|kann] ich [auf] {service} (schauen|sehen|gucken)"
+    - "(gib|geb) [mir] [eine] empfehlung [fuer] {genre} [auf] {service}"
+    - "top {genre} [auf] {service}"
+    - "beste {genre} [auf] {service}"
+    - "gute {genre} [auf] {service}"
+    - "was ist [gerade] gut auf {service}"
+    - "was lohnt sich auf {service}"
+    - "tipps [fuer] {service}"
+    - "[gute] {genre} empfehlung [auf] {service}"
+  examples:
+    - "Empfiehl mir einen guten Anime auf Crunchyroll"
+    - "Empfehle mir etwas auf Netflix"
+    - "Was empfiehlst du mir auf Disney+?"
+    - "Was soll ich auf Amazon Prime schauen?"
+    - "Gib mir eine Empfehlung fuer Horror auf Netflix"
+    - "Top Animes auf Crunchyroll"
+    - "Beste Thriller auf Amazon Prime"
+    - "Gute Komoedie auf Netflix"
+    - "Was ist gerade gut auf Disney+?"
+    - "Was lohnt sich auf Netflix?"
+    - "Tipps fuer Crunchyroll"
+    - "Gute Anime Empfehlung auf Crunchyroll"
+
+streaming_seasons:
+  samples: 400
+  patterns:
+    - "(wie viele|wieviele|wieviel) staffeln hat {query} [auf] {service}"
+    - "(wie viele|wieviele|wieviel) (folgen|episoden) hat {query} [auf] {service}"
+    - "(wie viele|wieviele|wieviel) (folgen|episoden) hat [die] {query} staffel [von] {query}"
+    - "staffeln [von] {query} [auf] {service}"
+    - "anzahl staffeln [von] {query}"
+    - "anzahl (folgen|episoden) [von] {query} [auf] {service}"
+    - "(wie viele|wieviele) (folgen|episoden) hat staffel {query} [von] {query}"
+  examples:
+    - "Wie viele Staffeln hat Detektiv Conan auf Crunchyroll?"
+    - "Wie viele Naruto Episoden gibt es auf Crunchyroll?"
+    - "Wie viele Episoden hat die 3. Staffel Naruto?"
+    - "Wieviel Staffeln hat One Piece?"
+    - "Wie viele Folgen hat Breaking Bad auf Netflix?"
+    - "Staffeln von Attack on Titan auf Crunchyroll"
+    - "Anzahl Episoden von Demon Slayer"
+    - "Wie viele Folgen hat Staffel 2 von Spy x Family?"
+
+streaming_watchlist_add:
+  samples: 300
+  patterns:
+    - "(merke|merk) [dir] {query} [vor]"
+    - "(markiere|markier) {query} [auf meiner liste|auf der watchlist]"
+    - "setze {query} auf [meine|die] (watchlist|merkliste|liste)"
+    - "{query} auf [meine|die] (watchlist|merkliste) [setzen]"
+    - "(speichere|speicher) {query} [auf meiner liste]"
+    - "benachrichtige mich [bei|wenn] {query} [neue folgen|neue episoden|neue syncro]"
+    - "erinnere mich an {query} [wenn|falls] [es] neue (folgen|episoden|syncro) [gibt]"
+  examples:
+    - "Merke dir Detektiv Conan vor"
+    - "Markiere Naruto auf meiner Watchlist"
+    - "Setze One Piece auf meine Liste"
+    - "Benachrichtige mich wenn es neue Folgen von Naruto gibt"
+    - "Erinnere mich an Detektiv Conan wenn neue Syncro kommt"
+    - "Speichere Attack on Titan auf meiner Watchlist"
+
+streaming_watchlist_list:
+  patterns:
+    - "[meine] (streaming|) (watchlist|merkliste)"
+    - "welche serien habe ich [auf meiner liste|markiert|vorgemerkt]"
+    - "was habe ich [mir] [zum streamen] (vorgemerkt|gemerkt|markiert)"
+    - "zeig [mir] meine (streaming|) (watchlist|merkliste|liste)"
+  examples:
+    - "Meine Watchlist"
+    - "Welche Serien habe ich markiert?"
+    - "Was habe ich mir zum Streamen vorgemerkt?"
+    - "Zeig mir meine Streaming Merkliste"
+    - "Meine Streaming Watchlist"
+
+streaming_watchlist_remove:
+  patterns:
+    - "(loesche|entferne|loesch|entfern) {query} [von|aus] [meiner|der] (watchlist|merkliste|liste)"
+    - "{query} [von|aus] [meiner|der] (watchlist|merkliste) (loeschen|entfernen|streichen)"
+    - "(nimm|nehme) {query} [von|aus] [meiner|der] (watchlist|merkliste|liste)"
+  examples:
+    - "Loesche Naruto von meiner Watchlist"
+    - "Entferne One Piece aus der Merkliste"
+    - "Naruto von der Watchlist streichen"
+    - "Nimm Attack on Titan von meiner Liste"
+
+streaming_follow_up:
+  patterns:
+    - "und auf {service}"
+    - "und {service}"
+    - "gibt es das auch auf {service}"
+    - "gibt es das auch auf deutsch"
+    - "gibt es das mit (untertitel|untertiteln|subs)"
+    - "(erzaehl|erzaehl mir|sag mir) mehr [darueber|dazu]"
+    - "mehr (darueber|dazu|infos|informationen)"
+    - "worum geht es [da|darin]"
+    - "was ist das [fuer ein|fuer eine] (film|serie)"
+    - "(wie viele|wieviele) (staffeln|folgen|episoden) hat (das|die serie|der anime)"
+  examples:
+    - "Und auf Netflix?"
+    - "Und auf Amazon Prime?"
+    - "Gibt es das auch auf Disney+?"
+    - "Gibt es das auch auf deutsch?"
+    - "Gibt es das mit Untertiteln?"
+    - "Erzaehl mir mehr"
+    - "Mehr Infos"
+    - "Worum geht es da?"
+    - "Was ist das fuer eine Serie?"
+    - "Wie viele Staffeln hat die Serie?"

Datei-Diff unterdrückt, da er zu groß ist
+ 1357 - 0
plugins/streaming/main.py


+ 2 - 0
plugins/streaming/requirements.txt

@@ -0,0 +1,2 @@
+# aiohttp (schon im Projekt vorhanden)
+# TMDB API Key noetig — in config.json unter "tmdb_api_key" eintragen

+ 54 - 0
plugins/streaming/slot_lists.json

@@ -0,0 +1,54 @@
+{
+    "service": {
+        "values": [
+            "Netflix",
+            "Amazon Prime",
+            "Amazon",
+            "Prime",
+            "Prime Video",
+            "Crunchyroll",
+            "Disney+",
+            "Disney Plus",
+            "Disney",
+            "WOW",
+            "Sky",
+            "Sky Ticket"
+        ],
+        "description": "Streaming-Dienste fuer Katalog-Suche",
+        "allow_unknown": true
+    },
+    "genre": {
+        "values": [
+            "Action",
+            "Abenteuer",
+            "Animation",
+            "Anime",
+            "Comedy",
+            "Dokumentation",
+            "Doku",
+            "Drama",
+            "Ecchi",
+            "Familie",
+            "Fantasy",
+            "Horror",
+            "Isekai",
+            "Kinder",
+            "Komoedie",
+            "Krimi",
+            "Mecha",
+            "Mystery",
+            "Romance",
+            "Romantik",
+            "Science Fiction",
+            "Sci-Fi",
+            "Seinen",
+            "Shoujo",
+            "Shounen",
+            "Slice of Life",
+            "Thriller",
+            "Western"
+        ],
+        "description": "Genres fuer Streaming-Katalog-Suche",
+        "allow_unknown": true
+    }
+}

+ 39 - 0
plugins/streaming/stt_aliases.json

@@ -0,0 +1,39 @@
+{
+    "_comment": "STT-Korrekturen fuer Streaming-Dienst Namen und haeufig gesuchte Titel",
+    "kronchi roll": "crunchyroll",
+    "kranchi roll": "crunchyroll",
+    "chronchi roll": "crunchyroll",
+    "krontschi roll": "crunchyroll",
+    "krantsch roll": "crunchyroll",
+    "crunchy roll": "crunchyroll",
+    "cranchi roll": "crunchyroll",
+    "kronchi": "crunchyroll",
+    "disney plus": "disney+",
+    "disney plass": "disney+",
+    "disneyplus": "disney+",
+    "disni plus": "disney+",
+    "amazon prim": "amazon prime",
+    "amazon preim": "amazon prime",
+    "amazon priem": "amazon prime",
+    "amazone prime": "amazon prime",
+    "preim video": "amazon prime",
+    "prime video": "amazon prime",
+    "netflix": "netflix",
+    "nett flicks": "netflix",
+    "net flicks": "netflix",
+    "net flix": "netflix",
+    "skei": "wow",
+    "sky": "wow",
+    "sky ticket": "wow",
+    "wow tv": "wow",
+    "nach utopie": "naruto",
+    "nach uto": "naruto",
+    "nah ruto": "naruto",
+    "naru to": "naruto",
+    "brecking bett": "breaking bad",
+    "brecking bed": "breaking bad",
+    "annehmen": "anime",
+    "annehme": "anime",
+    "syncro": "synchronisation",
+    "synchro": "synchronisation"
+}

+ 0 - 0
plugins/streaming/tests/__init__.py


+ 568 - 0
plugins/streaming/tests/test_cache.py

@@ -0,0 +1,568 @@
+# -*- coding: utf-8 -*-
+"""
+Tests fuer StreamingCache — JSON-Katalog-Cache mit In-Memory-Indizes.
+
+Deckt JSON I/O, Index-Aufbau, Suche, Filter (Sprache, Genre, Bewertung),
+Slug-Generierung und atomares Speichern ab.
+"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from plugins.streaming.catalog.cache import StreamingCache
+
+
+# ---------------------------------------------------------------------------
+# Hilfsfunktionen und Testdaten
+# ---------------------------------------------------------------------------
+
+def _make_title(
+    name: str,
+    ty: str = "serie",
+    genres: list[str] | None = None,
+    rating: float = 7.0,
+    year: int = 2020,
+    services: dict | None = None,
+    orig_lang: str = "ja",
+    tmdb_id: int = 1,
+) -> dict:
+    """Erzeugt ein realistisches Title-Dict."""
+    if genres is None:
+        genres = ["Action"]
+    if services is None:
+        services = {
+            "netflix": {"dub": ["de", "en"], "sub": ["de"]},
+        }
+    return {
+        "t": name,
+        "ty": ty,
+        "g": genres,
+        "d": f"Beschreibung fuer {name}",
+        "yr": year,
+        "rt": rating,
+        "orig_lang": orig_lang,
+        "on": services,
+        "tmdb_id": tmdb_id,
+    }
+
+
+NARUTO = _make_title(
+    "Naruto Shippuden",
+    genres=["Action", "Anime"],
+    rating=8.1,
+    year=2007,
+    services={
+        "crunchyroll": {"dub": ["ja"], "sub": ["de"]},
+        "netflix": {"dub": ["de", "ja"], "sub": ["de"]},
+    },
+    tmdb_id=46559,
+)
+
+ONE_PIECE = _make_title(
+    "One Piece",
+    genres=["Action", "Anime"],
+    rating=8.7,
+    year=1999,
+    services={
+        "crunchyroll": {"dub": ["ja"], "sub": ["de"]},
+    },
+    tmdb_id=37854,
+)
+
+BREAKING_BAD = _make_title(
+    "Breaking Bad",
+    genres=["Drama", "Thriller"],
+    rating=9.5,
+    year=2008,
+    services={
+        "netflix": {"dub": ["de", "en"], "sub": ["de", "en"]},
+    },
+    orig_lang="en",
+    tmdb_id=1396,
+)
+
+DARK = _make_title(
+    "Dark",
+    genres=["Drama", "Mystery"],
+    rating=8.8,
+    year=2017,
+    services={
+        "netflix": {"dub": ["de"], "sub": ["de", "en"]},
+    },
+    orig_lang="de",
+    tmdb_id=70523,
+)
+
+INCEPTION = _make_title(
+    "Inception",
+    ty="film",
+    genres=["Action", "Science Fiction"],
+    rating=8.4,
+    year=2010,
+    services={
+        "netflix": {"dub": ["de", "en"], "sub": ["de"]},
+        "amazon_prime": {"dub": ["de", "en"], "sub": ["de"]},
+    },
+    orig_lang="en",
+    tmdb_id=27205,
+)
+
+
+def _copy_title(title: dict) -> dict:
+    """Tiefe Kopie eines Title-Dicts (on-Dict separat kopieren)."""
+    import copy
+    return copy.deepcopy(title)
+
+
+def _seed_cache(cache: StreamingCache) -> None:
+    """Befuellt den Cache mit Testdaten und baut Indizes auf."""
+    cache.update_title("naruto-shippuden", _copy_title(NARUTO))
+    cache.update_title("one-piece", _copy_title(ONE_PIECE))
+    cache.update_title("breaking-bad", _copy_title(BREAKING_BAD))
+    cache.update_title("dark", _copy_title(DARK))
+    cache.update_title("inception", _copy_title(INCEPTION))
+    cache._rebuild_indices()
+
+
+@pytest.fixture()
+def cache(tmp_path: Path) -> StreamingCache:
+    """Liefert einen leeren StreamingCache mit temporaerem Verzeichnis."""
+    return StreamingCache(tmp_path)
+
+
+@pytest.fixture()
+def seeded_cache(cache: StreamingCache) -> StreamingCache:
+    """Liefert einen StreamingCache mit vorbereiteten Testdaten."""
+    _seed_cache(cache)
+    return cache
+
+
+# ===================================================================
+# JSON I/O
+# ===================================================================
+
+
+class TestLoadSave:
+    """Tests fuer load(), save() und save_async()."""
+
+    def test_load_fehlende_datei_gibt_false(self, cache: StreamingCache) -> None:
+        """Laden ohne vorhandene Datei gibt False und leere Daten."""
+        ergebnis = cache.load()
+
+        assert ergebnis is False
+        assert cache._titles == {}
+        assert cache._services == {}
+        assert cache._recent == {}
+
+    def test_save_und_load_roundtrip(self, cache: StreamingCache) -> None:
+        """Gespeicherte Daten koennen exakt wieder geladen werden."""
+        _seed_cache(cache)
+        cache.save()
+
+        # Neuen Cache aus demselben Verzeichnis laden
+        cache2 = StreamingCache(cache._cache_dir)
+        ergebnis = cache2.load()
+
+        assert ergebnis is True
+        assert len(cache2._titles) == 5
+        assert cache2._titles["naruto-shippuden"]["t"] == "Naruto Shippuden"
+        assert cache2._titles["breaking-bad"]["rt"] == 9.5
+
+    async def test_save_async_ruft_to_thread_auf(self, cache: StreamingCache) -> None:
+        """save_async() delegiert ueber asyncio.to_thread."""
+        _seed_cache(cache)
+
+        with patch("plugins.streaming.catalog.cache.asyncio.to_thread", new_callable=AsyncMock) as mock_to_thread:
+            await cache.save_async()
+            mock_to_thread.assert_awaited_once_with(cache._save_sync)
+
+    def test_load_korrupte_datei_gibt_false(self, cache: StreamingCache) -> None:
+        """Beschaedigte JSON-Datei fuehrt zu False und leeren Daten."""
+        cache._file_path.write_text("{kaputt:", encoding="utf-8")
+
+        ergebnis = cache.load()
+
+        assert ergebnis is False
+        assert cache._titles == {}
+
+    def test_atomares_schreiben_tmp_rename(self, cache: StreamingCache) -> None:
+        """Speicherung nutzt tmp-Datei + Umbenennung (atomar)."""
+        _seed_cache(cache)
+        cache.save()
+
+        # Nach dem Speichern darf keine .tmp-Datei uebrig sein
+        tmp_datei = cache._file_path.with_suffix(".tmp")
+        assert not tmp_datei.exists()
+
+        # Die eigentliche Datei muss existieren und gueltiges JSON enthalten
+        assert cache._file_path.exists()
+        inhalt = json.loads(cache._file_path.read_text(encoding="utf-8"))
+        assert "titles" in inhalt
+        assert "services" in inhalt
+        assert "recent" in inhalt
+
+
+# ===================================================================
+# Daten-Mutation
+# ===================================================================
+
+
+class TestUpdateTitle:
+    """Tests fuer update_title()."""
+
+    def test_neuer_titel_wird_hinzugefuegt(self, cache: StreamingCache) -> None:
+        """Ein neuer Titel wird korrekt in _titles gespeichert."""
+        cache.update_title("naruto-shippuden", _copy_title(NARUTO))
+
+        assert "naruto-shippuden" in cache._titles
+        assert cache._titles["naruto-shippuden"]["t"] == "Naruto Shippuden"
+
+    def test_bestehender_titel_wird_aktualisiert(self, cache: StreamingCache) -> None:
+        """Beim Update eines Titels werden on-Dicts zusammengefuehrt."""
+        cache.update_title("naruto-shippuden", _copy_title(NARUTO))
+
+        # Zusaetzlichen Dienst hinzufuegen
+        update_daten = _make_title(
+            "Naruto Shippuden",
+            services={"amazon_prime": {"dub": ["de"], "sub": ["de"]}},
+        )
+        cache.update_title("naruto-shippuden", update_daten)
+
+        on = cache._titles["naruto-shippuden"]["on"]
+        # Alte Dienste muessen erhalten bleiben
+        assert "crunchyroll" in on
+        assert "netflix" in on
+        # Neuer Dienst muss vorhanden sein
+        assert "amazon_prime" in on
+
+
+class TestUpdateServices:
+    """Tests fuer update_services()."""
+
+    def test_dienste_werden_gespeichert(self, cache: StreamingCache) -> None:
+        """update_services() speichert Dienst-Informationen."""
+        dienste = {
+            "netflix": {"name": "Netflix", "logo": "netflix.png"},
+            "crunchyroll": {"name": "Crunchyroll", "logo": "cr.png"},
+        }
+        cache.update_services(dienste)
+
+        assert cache._services["netflix"]["name"] == "Netflix"
+        assert cache._services["crunchyroll"]["name"] == "Crunchyroll"
+
+
+class TestSetRecent:
+    """Tests fuer set_recent()."""
+
+    def test_recent_liste_wird_gesetzt(self, cache: StreamingCache) -> None:
+        """set_recent() speichert die Slug-Liste fuer einen Dienst."""
+        cache.set_recent("netflix", ["naruto-shippuden", "dark"])
+
+        assert cache._recent["netflix"] == ["naruto-shippuden", "dark"]
+
+
+class TestPruneMissing:
+    """Tests fuer prune_missing()."""
+
+    def test_entfernt_dienst_zuordnung_und_leere_titel(
+        self, seeded_cache: StreamingCache,
+    ) -> None:
+        """prune_missing() entfernt Dienst-Zuordnungen und leere Titel."""
+        # dark ist nur auf netflix — wenn netflix entfernt wird, wird dark geloescht
+        # naruto ist auf crunchyroll + netflix — netflix wird entfernt, titel bleibt
+        aktuelle_slugs = {"breaking-bad", "inception"}
+        entfernt = seeded_cache.prune_missing("netflix", aktuelle_slugs)
+
+        # naruto-shippuden verliert netflix, dark verliert netflix
+        assert entfernt == 2
+        # dark hat keinen Dienst mehr → komplett geloescht
+        assert "dark" not in seeded_cache._titles
+        # naruto hat noch crunchyroll
+        assert "naruto-shippuden" in seeded_cache._titles
+        assert "netflix" not in seeded_cache._titles["naruto-shippuden"]["on"]
+
+
+# ===================================================================
+# Titel-Suche
+# ===================================================================
+
+
+class TestSearchTitle:
+    """Tests fuer search_title()."""
+
+    def test_exakte_suche(self, seeded_cache: StreamingCache) -> None:
+        """Exakter Titel wird gefunden."""
+        ergebnis = seeded_cache.search_title("Naruto Shippuden")
+
+        assert len(ergebnis) >= 1
+        assert ergebnis[0]["t"] == "Naruto Shippuden"
+
+    def test_teiluebereinstimmung(self, seeded_cache: StreamingCache) -> None:
+        """Teilweise Uebereinstimmung funktioniert."""
+        ergebnis = seeded_cache.search_title("Naruto")
+
+        assert len(ergebnis) >= 1
+        titel_namen = [t["t"] for t in ergebnis]
+        assert "Naruto Shippuden" in titel_namen
+
+    def test_kein_treffer_gibt_leere_liste(self, seeded_cache: StreamingCache) -> None:
+        """Unbekannter Suchbegriff gibt leere Liste."""
+        ergebnis = seeded_cache.search_title("Nichtexistent XYZ")
+
+        assert ergebnis == []
+
+    def test_limit_wird_eingehalten(self, seeded_cache: StreamingCache) -> None:
+        """Das Limit beschraenkt die Ergebnisse."""
+        # "action" sollte mehrere Treffer liefern, die "action" im Titel
+        # enthalten koennten — wir nutzen stattdessen breiteren Begriff
+        ergebnis = seeded_cache.search_title("a", limit=2)
+
+        assert len(ergebnis) <= 2
+
+
+class TestFindTitle:
+    """Tests fuer find_title()."""
+
+    def test_bester_treffer_wird_gefunden(self, seeded_cache: StreamingCache) -> None:
+        """find_title() liefert den besten einzelnen Treffer."""
+        ergebnis = seeded_cache.find_title("Breaking Bad")
+
+        assert ergebnis is not None
+        assert ergebnis["t"] == "Breaking Bad"
+
+    def test_kein_treffer_gibt_none(self, seeded_cache: StreamingCache) -> None:
+        """Unbekannter Suchbegriff gibt None."""
+        ergebnis = seeded_cache.find_title("Nichtexistent XYZ")
+
+        assert ergebnis is None
+
+
+# ===================================================================
+# Dienst-Suche
+# ===================================================================
+
+
+class TestSearchOnService:
+    """Tests fuer search_on_service()."""
+
+    def test_filter_nach_dienst(self, seeded_cache: StreamingCache) -> None:
+        """Nur Titel auf dem angegebenen Dienst werden zurueckgegeben."""
+        ergebnis = seeded_cache.search_on_service("crunchyroll")
+
+        assert len(ergebnis) >= 2  # Naruto + One Piece
+        for titel in ergebnis:
+            assert "crunchyroll" in titel["on"]
+
+    def test_filter_nach_genre(self, seeded_cache: StreamingCache) -> None:
+        """Genre-Filter schraenkt die Ergebnisse ein."""
+        ergebnis = seeded_cache.search_on_service("netflix", genre="Drama")
+
+        for titel in ergebnis:
+            assert any("Drama" in g for g in titel.get("g", []))
+
+    def test_filter_dienst_und_genre(self, seeded_cache: StreamingCache) -> None:
+        """Kombination von Dienst- und Genre-Filter."""
+        ergebnis = seeded_cache.search_on_service(
+            "netflix", genre="Anime",
+        )
+
+        for titel in ergebnis:
+            assert "netflix" in titel["on"]
+            assert any("Anime" in g for g in titel.get("g", []))
+
+
+class TestGetServicesForTitle:
+    """Tests fuer get_services_for_title()."""
+
+    def test_alle_dienste_zurueckgegeben(self, seeded_cache: StreamingCache) -> None:
+        """Gibt alle Dienste zurueck, auf denen der Titel verfuegbar ist."""
+        dienste = seeded_cache.get_services_for_title("naruto-shippuden")
+
+        assert set(dienste) == {"crunchyroll", "netflix"}
+
+    def test_unbekannter_slug_gibt_leere_liste(self, seeded_cache: StreamingCache) -> None:
+        """Unbekannter Slug gibt leere Liste."""
+        dienste = seeded_cache.get_services_for_title("nichtexistent")
+
+        assert dienste == []
+
+
+# ===================================================================
+# Recent
+# ===================================================================
+
+
+class TestGetRecent:
+    """Tests fuer get_recent()."""
+
+    def test_recent_titel_werden_aufgeloest(self, seeded_cache: StreamingCache) -> None:
+        """get_recent() loest Slugs zu Title-Dicts auf."""
+        seeded_cache.set_recent("netflix", ["naruto-shippuden", "dark"])
+
+        ergebnis = seeded_cache.get_recent("netflix")
+
+        assert len(ergebnis) == 2
+        namen = [t["t"] for t in ergebnis]
+        assert "Naruto Shippuden" in namen
+        assert "Dark" in namen
+
+    def test_leere_recent_gibt_leere_liste(self, seeded_cache: StreamingCache) -> None:
+        """Kein recent-Eintrag gibt leere Liste."""
+        ergebnis = seeded_cache.get_recent("disney_plus")
+
+        assert ergebnis == []
+
+
+# ===================================================================
+# Genre-Abfragen
+# ===================================================================
+
+
+class TestGetByGenre:
+    """Tests fuer get_by_genre()."""
+
+    def test_findet_nach_genre(self, seeded_cache: StreamingCache) -> None:
+        """Titel des gesuchten Genres werden gefunden."""
+        ergebnis = seeded_cache.get_by_genre("Action")
+
+        assert len(ergebnis) >= 2  # Naruto, One Piece, Inception
+        for titel in ergebnis:
+            assert any("Action" in g for g in titel.get("g", []))
+
+    def test_mit_dienst_filter(self, seeded_cache: StreamingCache) -> None:
+        """Genre + Dienst-Filter schraenkt Ergebnisse ein."""
+        ergebnis = seeded_cache.get_by_genre("Action", service_id="crunchyroll")
+
+        for titel in ergebnis:
+            assert "crunchyroll" in titel["on"]
+
+    def test_case_insensitive(self, seeded_cache: StreamingCache) -> None:
+        """Genre-Suche ist case-insensitive."""
+        ergebnis_lower = seeded_cache.get_by_genre("action")
+        ergebnis_upper = seeded_cache.get_by_genre("Action")
+
+        assert len(ergebnis_lower) == len(ergebnis_upper)
+
+
+# ===================================================================
+# Sprach-Filter (Dub/Sub)
+# ===================================================================
+
+
+class TestGetWithDub:
+    """Tests fuer get_with_dub()."""
+
+    def test_findet_deutsche_synchronisation(self, seeded_cache: StreamingCache) -> None:
+        """Titel mit deutscher Synchronisation werden gefunden."""
+        ergebnis = seeded_cache.get_with_dub("de")
+
+        assert len(ergebnis) >= 1
+        # Breaking Bad, Dark, Inception haben alle deutsches Dub
+        namen = [t["t"] for t in ergebnis]
+        assert "Breaking Bad" in namen
+
+    def test_filter_nach_dienst(self, seeded_cache: StreamingCache) -> None:
+        """Dub-Suche mit Dienst-Filter."""
+        ergebnis = seeded_cache.get_with_dub("ja", service_id="crunchyroll")
+
+        for titel in ergebnis:
+            cr_info = titel["on"].get("crunchyroll", {})
+            assert "ja" in cr_info.get("dub", [])
+
+    def test_filter_nach_genre(self, seeded_cache: StreamingCache) -> None:
+        """Dub-Suche mit Genre-Filter."""
+        ergebnis = seeded_cache.get_with_dub("de", genre="Drama")
+
+        for titel in ergebnis:
+            assert any("Drama" in g for g in titel.get("g", []))
+
+
+class TestGetWithSub:
+    """Tests fuer get_with_sub()."""
+
+    def test_findet_deutsche_untertitel(self, seeded_cache: StreamingCache) -> None:
+        """Titel mit deutschen Untertiteln werden gefunden."""
+        ergebnis = seeded_cache.get_with_sub("de")
+
+        assert len(ergebnis) >= 3
+
+    def test_kein_treffer_gibt_leere_liste(self, seeded_cache: StreamingCache) -> None:
+        """Unbekannte Sprache gibt leere Liste."""
+        ergebnis = seeded_cache.get_with_sub("xx")
+
+        assert ergebnis == []
+
+
+# ===================================================================
+# Empfehlungen
+# ===================================================================
+
+
+class TestGetRecommendations:
+    """Tests fuer get_recommendations()."""
+
+    def test_filter_nach_bewertung(self, seeded_cache: StreamingCache) -> None:
+        """Nur Titel ueber der Mindestbewertung werden zurueckgegeben."""
+        ergebnis = seeded_cache.get_recommendations("Action", min_rating=8.0)
+
+        for titel in ergebnis:
+            assert (titel.get("rt") or 0) >= 8.0
+
+    def test_sortierung_nach_bewertung_absteigend(
+        self, seeded_cache: StreamingCache,
+    ) -> None:
+        """Ergebnisse sind nach Bewertung absteigend sortiert."""
+        ergebnis = seeded_cache.get_recommendations("Action", min_rating=0.0)
+
+        bewertungen = [t.get("rt", 0) for t in ergebnis]
+        assert bewertungen == sorted(bewertungen, reverse=True)
+
+
+# ===================================================================
+# Slug-Generierung
+# ===================================================================
+
+
+class TestGenerateSlug:
+    """Tests fuer _generate_slug()."""
+
+    def test_einfacher_titel(self) -> None:
+        """'Naruto Shippuden' ergibt 'naruto-shippuden'."""
+        assert StreamingCache._generate_slug("Naruto Shippuden") == "naruto-shippuden"
+
+    def test_sonderzeichen_entfernt(self) -> None:
+        """Sonderzeichen werden durch Bindestriche ersetzt."""
+        assert StreamingCache._generate_slug("Jujutsu Kaisen: 0") == "jujutsu-kaisen-0"
+        assert StreamingCache._generate_slug("Re:Zero") == "re-zero"
+
+    def test_leerer_string(self) -> None:
+        """Leerer String ergibt leeren Slug."""
+        assert StreamingCache._generate_slug("") == ""
+
+    def test_nur_sonderzeichen(self) -> None:
+        """Nur Sonderzeichen ergibt leeren Slug."""
+        assert StreamingCache._generate_slug("!!!???") == ""
+
+
+# ===================================================================
+# Statistiken
+# ===================================================================
+
+
+class TestGetStats:
+    """Tests fuer get_stats()."""
+
+    def test_korrekte_zaehler(self, seeded_cache: StreamingCache) -> None:
+        """get_stats() liefert korrekte Zaehler."""
+        seeded_cache.update_services({"netflix": {"name": "Netflix"}})
+        stats = seeded_cache.get_stats()
+
+        assert stats["title_count"] == 5
+        assert stats["service_count"] == 1
+        assert stats["genre_count"] >= 4  # Action, Anime, Drama, Thriller, ...
+        assert stats["index_service_count"] >= 2  # netflix, crunchyroll, ...

+ 445 - 0
plugins/streaming/tests/test_discover.py

@@ -0,0 +1,445 @@
+# -*- coding: utf-8 -*-
+"""
+Tests fuer TMDBDiscoverClient — TMDB Discover API Client.
+
+Alle HTTP-Requests werden via unittest.mock simuliert.
+Testet Genre-Mapping, Normalisierung, Paginierung, Fehlerbehandlung
+und Standard-Sprach-Zuordnungen pro Dienst.
+"""
+
+from __future__ import annotations
+
+import asyncio
+from datetime import date, timedelta
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from plugins.streaming.catalog.tmdb_discover import (
+    GENRE_MAP_MOVIE,
+    GENRE_MAP_TV,
+    PROVIDER_IDS,
+    TMDBDiscoverClient,
+    _DEFAULT_LANGUAGES,
+)
+
+
+# ---------------------------------------------------------------------------
+# Hilfsfunktionen und Testdaten
+# ---------------------------------------------------------------------------
+
+def _tmdb_movie(
+    title: str = "Inception",
+    movie_id: int = 27205,
+    genre_ids: list[int] | None = None,
+    rating: float = 8.4,
+    release_date: str = "2010-07-16",
+    overview: str = "Ein Dieb stiehlt Geheimnisse aus dem Unterbewusstsein.",
+    original_language: str = "en",
+) -> dict:
+    """Erzeugt ein TMDB-Film-Rohergebnis."""
+    return {
+        "id": movie_id,
+        "title": title,
+        "genre_ids": genre_ids or [28, 878],
+        "vote_average": rating,
+        "release_date": release_date,
+        "overview": overview,
+        "original_language": original_language,
+    }
+
+
+def _tmdb_tv(
+    name: str = "Breaking Bad",
+    tv_id: int = 1396,
+    genre_ids: list[int] | None = None,
+    rating: float = 9.5,
+    first_air_date: str = "2008-01-20",
+    overview: str = "Ein Chemielehrer wird zum Drogenboss.",
+    original_language: str = "en",
+) -> dict:
+    """Erzeugt ein TMDB-TV-Rohergebnis."""
+    return {
+        "id": tv_id,
+        "name": name,
+        "genre_ids": genre_ids or [18, 80],
+        "vote_average": rating,
+        "first_air_date": first_air_date,
+        "overview": overview,
+        "original_language": original_language,
+    }
+
+
+def _discover_page(results: list[dict], page: int = 1, total_pages: int = 1) -> dict:
+    """Erzeugt eine TMDB-Discover-API-Antwort (einzelne Seite)."""
+    return {
+        "page": page,
+        "total_pages": total_pages,
+        "total_results": len(results),
+        "results": results,
+    }
+
+
+@pytest.fixture()
+def client() -> TMDBDiscoverClient:
+    """Liefert einen TMDBDiscoverClient mit Test-API-Key."""
+    return TMDBDiscoverClient(api_key="test-api-key-123", region="DE", language="de-DE")
+
+
+# ===================================================================
+# Konstruktor
+# ===================================================================
+
+
+class TestKonstruktor:
+    """Tests fuer __init__."""
+
+    def test_konfiguration_gespeichert(self) -> None:
+        """API-Key, Region und Sprache werden korrekt gespeichert."""
+        c = TMDBDiscoverClient(api_key="my-key", region="US", language="en-US")
+
+        assert c._api_key == "my-key"
+        assert c._region == "US"
+        assert c._language == "en-US"
+
+
+# ===================================================================
+# Genre-Mapping
+# ===================================================================
+
+
+class TestMapGenres:
+    """Tests fuer _map_genres()."""
+
+    def test_film_genres_korrekt(self, client: TMDBDiscoverClient) -> None:
+        """Film-Genre-IDs werden zu deutschen Namen aufgeloest."""
+        ergebnis = client._map_genres([28, 878, 53], "movie")
+
+        assert ergebnis == ["Action", "Science Fiction", "Thriller"]
+
+    def test_tv_genres_korrekt(self, client: TMDBDiscoverClient) -> None:
+        """TV-Genre-IDs werden zu deutschen Namen aufgeloest."""
+        ergebnis = client._map_genres([10759, 18], "tv")
+
+        assert ergebnis == ["Action & Abenteuer", "Drama"]
+
+    def test_unbekannte_genre_id_uebersprungen(self, client: TMDBDiscoverClient) -> None:
+        """Unbekannte Genre-IDs werden uebersprungen."""
+        ergebnis = client._map_genres([28, 99999, 53], "movie")
+
+        assert ergebnis == ["Action", "Thriller"]
+        assert len(ergebnis) == 2
+
+
+# ===================================================================
+# Normalisierung Film
+# ===================================================================
+
+
+class TestNormalizeMovie:
+    """Tests fuer _normalize_movie()."""
+
+    def test_erstellt_korrektes_titel_dict(self, client: TMDBDiscoverClient) -> None:
+        """Film wird korrekt in das Katalog-Format konvertiert."""
+        rohdaten = _tmdb_movie()
+        ergebnis = client._normalize_movie(rohdaten, "netflix")
+
+        assert ergebnis is not None
+        assert ergebnis["t"] == "Inception"
+        assert ergebnis["ty"] == "film"
+        assert ergebnis["yr"] == 2010
+        assert ergebnis["rt"] == 8.4
+        assert ergebnis["orig_lang"] == "en"
+        assert ergebnis["tmdb_id"] == 27205
+        assert ergebnis["src"] == "tmdb"
+        assert "netflix" in ergebnis["on"]
+
+    def test_standard_dub_sub_netflix(self, client: TMDBDiscoverClient) -> None:
+        """Netflix bekommt de/en Dub und de Sub als Standard."""
+        rohdaten = _tmdb_movie()
+        ergebnis = client._normalize_movie(rohdaten, "netflix")
+
+        netflix_info = ergebnis["on"]["netflix"]
+        assert "de" in netflix_info["dub"]
+        assert "en" in netflix_info["dub"]
+        assert "de" in netflix_info["sub"]
+
+    def test_standard_dub_sub_crunchyroll(self, client: TMDBDiscoverClient) -> None:
+        """Crunchyroll bekommt ja Dub und de Sub als Standard."""
+        rohdaten = _tmdb_movie()
+        ergebnis = client._normalize_movie(rohdaten, "crunchyroll")
+
+        cr_info = ergebnis["on"]["crunchyroll"]
+        assert cr_info["dub"] == ["ja"]
+        assert cr_info["sub"] == ["de"]
+
+    def test_leerer_titel_gibt_none(self, client: TMDBDiscoverClient) -> None:
+        """Film ohne Titel gibt None."""
+        rohdaten = _tmdb_movie(title="")
+        ergebnis = client._normalize_movie(rohdaten, "netflix")
+
+        assert ergebnis is None
+
+    def test_fehlende_felder_graceful(self, client: TMDBDiscoverClient) -> None:
+        """Fehlende optionale Felder werden sauber behandelt."""
+        rohdaten = {"title": "Testfilm", "id": 1}
+        ergebnis = client._normalize_movie(rohdaten, "netflix")
+
+        assert ergebnis is not None
+        assert ergebnis["t"] == "Testfilm"
+        assert ergebnis["yr"] is None
+        assert ergebnis["g"] == []
+
+
+# ===================================================================
+# Normalisierung TV
+# ===================================================================
+
+
+class TestNormalizeTv:
+    """Tests fuer _normalize_tv()."""
+
+    def test_erstellt_korrektes_titel_dict(self, client: TMDBDiscoverClient) -> None:
+        """Serie wird korrekt in das Katalog-Format konvertiert."""
+        rohdaten = _tmdb_tv()
+        ergebnis = client._normalize_tv(rohdaten, "netflix")
+
+        assert ergebnis is not None
+        assert ergebnis["t"] == "Breaking Bad"
+        assert ergebnis["ty"] == "serie"
+        assert ergebnis["yr"] == 2008
+        assert ergebnis["rt"] == 9.5
+        assert ergebnis["tmdb_id"] == 1396
+
+    def test_fehlende_felder_graceful(self, client: TMDBDiscoverClient) -> None:
+        """Fehlende optionale Felder werden sauber behandelt."""
+        rohdaten = {"name": "Testserie", "id": 2}
+        ergebnis = client._normalize_tv(rohdaten, "netflix")
+
+        assert ergebnis is not None
+        assert ergebnis["t"] == "Testserie"
+        assert ergebnis["yr"] is None
+        assert ergebnis["rt"] is None
+
+    def test_leerer_name_gibt_none(self, client: TMDBDiscoverClient) -> None:
+        """Serie ohne Name gibt None."""
+        rohdaten = _tmdb_tv(name="")
+        ergebnis = client._normalize_tv(rohdaten, "netflix")
+
+        assert ergebnis is None
+
+
+# ===================================================================
+# discover_service
+# ===================================================================
+
+
+class TestDiscoverService:
+    """Tests fuer discover_service()."""
+
+    async def test_kombiniert_filme_und_serien(self, client: TMDBDiscoverClient) -> None:
+        """discover_service() kombiniert Filme und Serien."""
+        movie_page = _discover_page([_tmdb_movie()])
+        tv_page = _discover_page([_tmdb_tv()])
+
+        with patch.object(client, "_get", new_callable=AsyncMock) as mock_get:
+            mock_get.side_effect = [movie_page, tv_page]
+
+            ergebnis = await client.discover_service("netflix", max_titles=20)
+
+        assert len(ergebnis) == 2
+        typen = {t["ty"] for t in ergebnis}
+        assert "film" in typen
+        assert "serie" in typen
+
+    async def test_unbekannter_dienst_gibt_leere_liste(
+        self, client: TMDBDiscoverClient,
+    ) -> None:
+        """Unbekannter Dienst gibt leere Liste ohne API-Aufruf."""
+        ergebnis = await client.discover_service("nichtexistent")
+
+        assert ergebnis == []
+
+
+# ===================================================================
+# Paginierung
+# ===================================================================
+
+
+class TestDiscoverMovies:
+    """Tests fuer _discover_movies()."""
+
+    async def test_paginierung_mehrere_seiten(self, client: TMDBDiscoverClient) -> None:
+        """Paginierung liest mehrere Seiten bis max_pages."""
+        seite1 = _discover_page(
+            [_tmdb_movie(title="Film A", movie_id=1)],
+            page=1,
+            total_pages=3,
+        )
+        seite2 = _discover_page(
+            [_tmdb_movie(title="Film B", movie_id=2)],
+            page=2,
+            total_pages=3,
+        )
+
+        with patch.object(client, "_get", new_callable=AsyncMock) as mock_get:
+            mock_get.side_effect = [seite1, seite2]
+
+            ergebnis = await client._discover_movies(provider_id=8, max_pages=2)
+
+        assert len(ergebnis) == 2
+        assert mock_get.call_count == 2
+
+    async def test_leere_ergebnisse_beenden_paginierung(
+        self, client: TMDBDiscoverClient,
+    ) -> None:
+        """Leere Ergebnisseite beendet die Paginierung."""
+        leere_seite = _discover_page([], page=1, total_pages=5)
+
+        with patch.object(client, "_get", new_callable=AsyncMock) as mock_get:
+            mock_get.return_value = leere_seite
+
+            ergebnis = await client._discover_movies(provider_id=8, max_pages=5)
+
+        assert ergebnis == []
+        assert mock_get.call_count == 1
+
+
+# ===================================================================
+# discover_recent
+# ===================================================================
+
+
+class TestDiscoverRecent:
+    """Tests fuer discover_recent()."""
+
+    async def test_nutzt_datumsfilter(self, client: TMDBDiscoverClient) -> None:
+        """discover_recent() uebergibt Datums-Range an die API."""
+        movie_page = _discover_page([_tmdb_movie()])
+        tv_page = _discover_page([_tmdb_tv()])
+
+        with patch.object(client, "_get", new_callable=AsyncMock) as mock_get:
+            mock_get.side_effect = [movie_page, tv_page]
+
+            ergebnis = await client.discover_recent("netflix", days=7)
+
+        assert len(ergebnis) == 2
+
+        # Pruefen, dass Datums-Parameter uebergeben wurden
+        movie_call_params = mock_get.call_args_list[0][1].get("params") or mock_get.call_args_list[0][0][1]
+        assert "primary_release_date.gte" in movie_call_params
+        assert "primary_release_date.lte" in movie_call_params
+
+
+# ===================================================================
+# HTTP-Fehlerbehandlung (_get)
+# ===================================================================
+
+
+class TestGetFehlerbehandlung:
+    """Tests fuer _get() — Fehlerszenarien."""
+
+    async def test_rate_limit_429_retry(self, client: TMDBDiscoverClient) -> None:
+        """429 Rate-Limit fuehrt zu Retry."""
+        # Erstelle Mock-Response-Objekte
+        response_429 = AsyncMock()
+        response_429.status = 429
+        response_429.headers = {"Retry-After": "0.01"}
+
+        response_200 = AsyncMock()
+        response_200.status = 200
+        response_200.json = AsyncMock(return_value={"results": []})
+
+        # aiohttp.ClientSession Mocking
+        mock_session = AsyncMock()
+        mock_session.__aenter__ = AsyncMock(return_value=mock_session)
+        mock_session.__aexit__ = AsyncMock(return_value=False)
+
+        # get() gibt zuerst 429, dann 200
+        mock_resp_ctx_429 = AsyncMock()
+        mock_resp_ctx_429.__aenter__ = AsyncMock(return_value=response_429)
+        mock_resp_ctx_429.__aexit__ = AsyncMock(return_value=False)
+
+        mock_resp_ctx_200 = AsyncMock()
+        mock_resp_ctx_200.__aenter__ = AsyncMock(return_value=response_200)
+        mock_resp_ctx_200.__aexit__ = AsyncMock(return_value=False)
+
+        mock_session.get = MagicMock(side_effect=[mock_resp_ctx_429, mock_resp_ctx_200])
+
+        with patch("plugins.streaming.catalog.tmdb_discover.aiohttp") as mock_aiohttp:
+            mock_aiohttp.ClientSession = MagicMock(return_value=mock_session)
+            mock_aiohttp.ClientTimeout = MagicMock()
+            mock_aiohttp.ClientError = Exception
+
+            ergebnis = await client._get("/discover/movie")
+
+        assert ergebnis == {"results": []}
+
+    async def test_timeout_gibt_none(self, client: TMDBDiscoverClient) -> None:
+        """Timeout fuehrt zu None."""
+        mock_session = AsyncMock()
+        mock_session.__aenter__ = AsyncMock(return_value=mock_session)
+        mock_session.__aexit__ = AsyncMock(return_value=False)
+        mock_session.get = MagicMock(side_effect=asyncio.TimeoutError)
+
+        with patch("plugins.streaming.catalog.tmdb_discover.aiohttp") as mock_aiohttp:
+            mock_aiohttp.ClientSession = MagicMock(return_value=mock_session)
+            mock_aiohttp.ClientTimeout = MagicMock()
+            mock_aiohttp.ClientError = Exception
+
+            ergebnis = await client._get("/discover/movie")
+
+        assert ergebnis is None
+
+    async def test_verbindungsfehler_gibt_none(self, client: TMDBDiscoverClient) -> None:
+        """Verbindungsfehler fuehrt zu None."""
+        mock_session = AsyncMock()
+        mock_session.__aenter__ = AsyncMock(return_value=mock_session)
+        mock_session.__aexit__ = AsyncMock(return_value=False)
+
+        # aiohttp.ClientError simulieren
+        class MockClientError(Exception):
+            pass
+
+        mock_session.get = MagicMock(side_effect=MockClientError("Verbindung fehlgeschlagen"))
+
+        with patch("plugins.streaming.catalog.tmdb_discover.aiohttp") as mock_aiohttp:
+            mock_aiohttp.ClientSession = MagicMock(return_value=mock_session)
+            mock_aiohttp.ClientTimeout = MagicMock()
+            mock_aiohttp.ClientError = MockClientError
+
+            ergebnis = await client._get("/discover/movie")
+
+        assert ergebnis is None
+
+
+# ===================================================================
+# Konstanten und Konfiguration
+# ===================================================================
+
+
+class TestKonstanten:
+    """Tests fuer Modul-Konstanten."""
+
+    def test_provider_ids_erwartete_dienste(self) -> None:
+        """PROVIDER_IDS enthaelt die erwarteten Streaming-Dienste."""
+        erwartete = {"netflix", "amazon_prime", "crunchyroll", "disney_plus", "wow"}
+        assert set(PROVIDER_IDS.keys()) == erwartete
+
+    def test_genre_map_movie_hat_erwartete_genres(self) -> None:
+        """GENRE_MAP_MOVIE enthaelt die wichtigsten Film-Genres."""
+        assert 28 in GENRE_MAP_MOVIE  # Action
+        assert 18 in GENRE_MAP_MOVIE  # Drama
+        assert 53 in GENRE_MAP_MOVIE  # Thriller
+        assert 878 in GENRE_MAP_MOVIE  # Science Fiction
+
+    def test_standard_sprachen_variieren_nach_dienst(self) -> None:
+        """Verschiedene Dienste haben unterschiedliche Standard-Sprachen."""
+        cr = _DEFAULT_LANGUAGES["crunchyroll"]
+        nf = _DEFAULT_LANGUAGES["netflix"]
+
+        # Crunchyroll: Japanisch-Dub
+        assert cr["dub"] == ["ja"]
+        # Netflix: Deutsch/Englisch-Dub
+        assert "de" in nf["dub"]
+        assert "en" in nf["dub"]