# -*- 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), }