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