cache.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623
  1. # -*- coding: utf-8 -*-
  2. """
  3. Streaming-Katalog-Cache mit In-Memory-Indizes.
  4. Verwaltet Titel-Daten aller konfigurierten Streaming-Dienste als JSON-Datei
  5. (~3-5 MB). Bietet schnelle Abfragen ueber vorberechnete Indizes nach
  6. Dienst, Genre, Titel, Sprache (Dub/Sub) und Bewertung.
  7. """
  8. from __future__ import annotations
  9. import asyncio
  10. import json
  11. import re
  12. from datetime import date
  13. from pathlib import Path
  14. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  15. # Artikel, die bei der Titel-Normalisierung entfernt werden
  16. _STRIP_ARTICLES = {"der", "die", "das", "ein", "eine", "the", "a", "an"}
  17. # Regex fuer Slug-Generierung: nur alphanumerisch + Bindestriche
  18. _SLUG_RE = re.compile(r"[^a-z0-9]+")
  19. class StreamingCache:
  20. """
  21. Cache fuer Streaming-Katalogdaten mit JSON-Persistenz und In-Memory-Indizes.
  22. Unterstuetzt Abfragen nach Dienst, Genre, Titel, Sprache (Dub/Sub)
  23. und Bewertung. Indizes werden beim Laden oder nach Aenderungen
  24. automatisch aufgebaut.
  25. """
  26. FILE_NAME = "streaming_catalog.json"
  27. def __init__(self, cache_dir: Path) -> None:
  28. """
  29. Args:
  30. cache_dir: Verzeichnis fuer die Cache-Datei.
  31. """
  32. self._cache_dir = cache_dir
  33. self._file_path = cache_dir / self.FILE_NAME
  34. # Hauptdaten
  35. self._titles: dict[str, dict] = {} # title_slug -> Title-Dict
  36. self._services: dict[str, dict] = {} # service_id -> Service-Info
  37. self._recent: dict[str, list[str]] = {} # service_id -> [title_slugs]
  38. self._data: dict = {} # Zusatzdaten (watchlist, etc.)
  39. # In-Memory-Indizes (werden bei _rebuild_indices aufgebaut)
  40. self._by_service: dict[str, list[dict]] = {} # service -> [Titel auf diesem Dienst]
  41. self._by_genre: dict[str, list[dict]] = {} # genre_lower -> [Titel]
  42. self._by_title: dict[str, list[dict]] = {} # normalisierter_titel -> [Titel]
  43. # Cache-Verzeichnis erstellen
  44. self._cache_dir.mkdir(parents=True, exist_ok=True)
  45. # --- JSON I/O ---
  46. def load(self) -> bool:
  47. """
  48. Laedt den Cache aus der JSON-Datei und baut die Indizes auf.
  49. Toleriert fehlende oder beschaedigte Dateien.
  50. Returns:
  51. True, wenn erfolgreich geladen, False bei Fehler oder fehlender Datei.
  52. """
  53. if not self._file_path.exists():
  54. pdebug("StreamingCache: Keine Cache-Datei vorhanden, starte leer")
  55. return False
  56. try:
  57. raw = self._file_path.read_text(encoding="utf-8")
  58. data = json.loads(raw)
  59. self._titles = data.get("titles", {})
  60. self._services = data.get("services", {})
  61. self._recent = data.get("recent", {})
  62. self._data = {k: v for k, v in data.items() if k not in ("titles", "services", "recent")}
  63. self._rebuild_indices()
  64. pinfo(
  65. f"StreamingCache: {len(self._titles)} Titel, "
  66. f"{len(self._services)} Dienste geladen"
  67. )
  68. return True
  69. except (json.JSONDecodeError, KeyError, TypeError) as e:
  70. perror(f"StreamingCache: Fehler beim Laden der Cache-Datei: {e}")
  71. self._titles = {}
  72. self._services = {}
  73. self._recent = {}
  74. self._data = {}
  75. return False
  76. def save(self) -> None:
  77. """
  78. Speichert den Cache atomar in die JSON-Datei.
  79. Schreibt zuerst in eine temporaere Datei und benennt dann um,
  80. um Datenverlust bei Absturz zu vermeiden.
  81. """
  82. self._save_sync()
  83. async def save_async(self) -> None:
  84. """Async-Speicherung — blockiert den Event-Loop nicht."""
  85. await asyncio.to_thread(self._save_sync)
  86. def _save_sync(self) -> None:
  87. """Synchrone Speicherung (kann auch aus Thread aufgerufen werden)."""
  88. data = {
  89. "titles": self._titles,
  90. "services": self._services,
  91. "recent": self._recent,
  92. **self._data,
  93. }
  94. tmp_path = self._file_path.with_suffix(".tmp")
  95. try:
  96. tmp_path.write_text(
  97. json.dumps(data, ensure_ascii=False, separators=(",", ":")),
  98. encoding="utf-8",
  99. )
  100. tmp_path.replace(self._file_path)
  101. pdebug(f"StreamingCache: {len(self._titles)} Titel gespeichert")
  102. except OSError as e:
  103. perror(f"StreamingCache: Fehler beim Speichern: {e}")
  104. # --- Index-Verwaltung ---
  105. def _rebuild_indices(self) -> None:
  106. """
  107. Baut alle Indizes aus den aktuellen Titeldaten neu auf.
  108. Indizes:
  109. - _by_service: Titel pro Streaming-Dienst
  110. - _by_genre: Titel pro Genre (Kleinbuchstaben)
  111. - _by_title: Titel pro normalisiertem Namen
  112. """
  113. self._by_service = {}
  114. self._by_genre = {}
  115. self._by_title = {}
  116. for slug, title_data in self._titles.items():
  117. # Dienst-Index: aus dem "on"-Dict die Service-IDs extrahieren
  118. on_services = title_data.get("on", {})
  119. for service_id in on_services:
  120. self._by_service.setdefault(service_id, []).append(title_data)
  121. # Genre-Index
  122. for genre in title_data.get("g", []):
  123. genre_lower = genre.lower()
  124. self._by_genre.setdefault(genre_lower, []).append(title_data)
  125. # Titel-Index (normalisiert)
  126. name = title_data.get("t", "")
  127. if name:
  128. norm = self._normalize(name)
  129. self._by_title.setdefault(norm, []).append(title_data)
  130. pdebug(
  131. f"StreamingCache: Indizes aufgebaut - "
  132. f"{len(self._by_service)} Dienste, "
  133. f"{len(self._by_genre)} Genres, "
  134. f"{len(self._by_title)} Titel"
  135. )
  136. # --- Daten-Mutation ---
  137. def update_title(self, slug: str, data: dict) -> None:
  138. """
  139. Fuegt einen Titel hinzu oder aktualisiert einen bestehenden.
  140. Wenn der Titel bereits existiert, werden die Dienst-Informationen
  141. (``on``-Dict) zusammengefuehrt, damit Provider-Daten aus
  142. verschiedenen Sync-Laeufen erhalten bleiben.
  143. Args:
  144. slug: Eindeutiger Slug des Titels.
  145. data: Title-Dict im Katalogformat.
  146. """
  147. existing = self._titles.get(slug)
  148. if existing:
  149. # Bestehende "on"-Eintraege mit neuen zusammenfuehren
  150. existing_on = existing.get("on", {})
  151. new_on = data.get("on", {})
  152. existing_on.update(new_on)
  153. data["on"] = existing_on
  154. self._titles[slug] = data
  155. def update_services(self, services: dict) -> None:
  156. """
  157. Aktualisiert die Dienst-Informationen.
  158. Args:
  159. services: Dict mit service_id -> Service-Info.
  160. """
  161. self._services.update(services)
  162. pdebug(f"StreamingCache: {len(services)} Dienste aktualisiert")
  163. def set_recent(self, service_id: str, slugs: list[str]) -> None:
  164. """
  165. Setzt die Liste der kuerzlich hinzugefuegten Titel fuer einen Dienst.
  166. Args:
  167. service_id: ID des Streaming-Dienstes.
  168. slugs: Liste der Titel-Slugs.
  169. """
  170. self._recent[service_id] = slugs
  171. pdebug(f"StreamingCache: {len(slugs)} neue Titel fuer '{service_id}' gesetzt")
  172. def prune_missing(self, service_id: str, current_slugs: set[str]) -> int:
  173. """
  174. Entfernt einen Dienst aus Titeln, die nicht mehr in current_slugs sind.
  175. Titel, die auf keinem Dienst mehr verfuegbar sind, werden
  176. komplett entfernt.
  177. Args:
  178. service_id: ID des Streaming-Dienstes.
  179. current_slugs: Aktuelle Slugs, die auf dem Dienst verfuegbar sind.
  180. Returns:
  181. Anzahl der entfernten Dienst-Zuordnungen.
  182. """
  183. removed = 0
  184. to_delete: list[str] = []
  185. for slug, title_data in self._titles.items():
  186. on_services = title_data.get("on", {})
  187. if service_id in on_services and slug not in current_slugs:
  188. del on_services[service_id]
  189. removed += 1
  190. # Titel komplett entfernen, wenn auf keinem Dienst mehr
  191. if not on_services:
  192. to_delete.append(slug)
  193. for slug in to_delete:
  194. del self._titles[slug]
  195. if removed > 0:
  196. self._rebuild_indices()
  197. pdebug(
  198. f"StreamingCache: {removed} Zuordnungen fuer '{service_id}' entfernt, "
  199. f"{len(to_delete)} Titel komplett geloescht"
  200. )
  201. return removed
  202. # --- Abfragen ---
  203. def search_title(self, query: str, limit: int = 10) -> list[dict]:
  204. """
  205. Sucht Titel nach Name (unscharfe Suche).
  206. Args:
  207. query: Suchbegriff.
  208. limit: Maximale Anzahl Ergebnisse.
  209. Returns:
  210. Liste passender Titel, sortiert nach Bewertung (absteigend).
  211. """
  212. norm_query = self._normalize(query)
  213. if not norm_query:
  214. return []
  215. matches: list[dict] = []
  216. for norm_title, titles in self._by_title.items():
  217. if norm_query in norm_title or norm_title in norm_query:
  218. matches.extend(titles)
  219. # Nach Bewertung sortieren (beste zuerst)
  220. matches.sort(key=lambda t: t.get("rt") or 0.0, reverse=True)
  221. return matches[:limit]
  222. def search_on_service(
  223. self,
  224. service_id: str,
  225. query: str = "",
  226. genre: str = "",
  227. limit: int = 10,
  228. ) -> list[dict]:
  229. """
  230. Sucht Titel auf einem bestimmten Streaming-Dienst.
  231. Args:
  232. service_id: ID des Streaming-Dienstes.
  233. query: Optionaler Suchbegriff fuer den Titel.
  234. genre: Optionaler Genre-Filter.
  235. limit: Maximale Anzahl Ergebnisse.
  236. Returns:
  237. Liste passender Titel.
  238. """
  239. candidates = self._by_service.get(service_id, [])
  240. if not candidates:
  241. return []
  242. results = candidates
  243. # Titel-Filter
  244. if query:
  245. norm_query = self._normalize(query)
  246. results = [
  247. t for t in results
  248. if norm_query in self._normalize(t.get("t", ""))
  249. ]
  250. # Genre-Filter
  251. if genre:
  252. genre_lower = genre.lower()
  253. results = [
  254. t for t in results
  255. if any(genre_lower in g.lower() for g in t.get("g", []))
  256. ]
  257. # Nach Bewertung sortieren
  258. results.sort(key=lambda t: t.get("rt") or 0.0, reverse=True)
  259. return results[:limit]
  260. def find_title(self, query: str) -> dict | None:
  261. """
  262. Findet den besten einzelnen Treffer fuer eine Suchanfrage.
  263. Bevorzugt exakte Uebereinstimmungen, dann Teiluebereinstimmungen.
  264. Args:
  265. query: Suchbegriff.
  266. Returns:
  267. Title-Dict oder None.
  268. """
  269. norm_query = self._normalize(query)
  270. if not norm_query:
  271. return None
  272. # Exakte Uebereinstimmung
  273. exact = self._by_title.get(norm_query)
  274. if exact:
  275. return exact[0]
  276. # Teiluebereinstimmung (bester Treffer nach kuerzester Differenz)
  277. best: dict | None = None
  278. best_diff = float("inf")
  279. for norm_title, titles in self._by_title.items():
  280. if norm_query in norm_title:
  281. diff = len(norm_title) - len(norm_query)
  282. if diff < best_diff:
  283. best_diff = diff
  284. best = titles[0]
  285. return best
  286. def get_services_for_title(self, slug: str) -> list[str]:
  287. """
  288. Gibt die Streaming-Dienste zurueck, auf denen ein Titel verfuegbar ist.
  289. Args:
  290. slug: Titel-Slug.
  291. Returns:
  292. Liste von Service-IDs, z.B. ["netflix", "crunchyroll"].
  293. """
  294. title_data = self._titles.get(slug)
  295. if not title_data:
  296. return []
  297. return list(title_data.get("on", {}).keys())
  298. def get_recent(self, service_id: str, limit: int = 10) -> list[dict]:
  299. """
  300. Gibt die kuerzlich hinzugefuegten Titel eines Dienstes zurueck.
  301. Args:
  302. service_id: ID des Streaming-Dienstes.
  303. limit: Maximale Anzahl Ergebnisse.
  304. Returns:
  305. Liste der Titel-Dicts.
  306. """
  307. slugs = self._recent.get(service_id, [])
  308. results: list[dict] = []
  309. for slug in slugs[:limit]:
  310. title_data = self._titles.get(slug)
  311. if title_data:
  312. results.append(title_data)
  313. return results
  314. def get_by_genre(
  315. self,
  316. genre: str,
  317. service_id: str = "",
  318. limit: int = 20,
  319. ) -> list[dict]:
  320. """
  321. Gibt Titel eines bestimmten Genres zurueck.
  322. Args:
  323. genre: Genre-Name (Case-insensitive).
  324. service_id: Optionaler Filter auf einen Streaming-Dienst.
  325. limit: Maximale Anzahl Ergebnisse.
  326. Returns:
  327. Liste passender Titel, sortiert nach Bewertung.
  328. """
  329. genre_lower = genre.lower()
  330. candidates = self._by_genre.get(genre_lower, [])
  331. if not candidates:
  332. return []
  333. results = candidates
  334. # Dienst-Filter
  335. if service_id:
  336. results = [
  337. t for t in results
  338. if service_id in t.get("on", {})
  339. ]
  340. results.sort(key=lambda t: t.get("rt") or 0.0, reverse=True)
  341. return results[:limit]
  342. def get_with_dub(
  343. self,
  344. language: str,
  345. genre: str = "",
  346. service_id: str = "",
  347. ) -> list[dict]:
  348. """
  349. Gibt Titel mit einer bestimmten Synchronisation (Dub) zurueck.
  350. Durchsucht das ``on.{service}.dub``-Array jedes Titels.
  351. Args:
  352. language: Sprachcode, z.B. "de" oder "ja".
  353. genre: Optionaler Genre-Filter.
  354. service_id: Optionaler Dienst-Filter.
  355. Returns:
  356. Liste passender Titel.
  357. """
  358. return self._filter_by_language(
  359. language, "dub", genre=genre, service_id=service_id,
  360. )
  361. def get_with_sub(
  362. self,
  363. language: str,
  364. genre: str = "",
  365. service_id: str = "",
  366. ) -> list[dict]:
  367. """
  368. Gibt Titel mit bestimmten Untertiteln (Sub) zurueck.
  369. Durchsucht das ``on.{service}.sub``-Array jedes Titels.
  370. Args:
  371. language: Sprachcode, z.B. "de" oder "en".
  372. genre: Optionaler Genre-Filter.
  373. service_id: Optionaler Dienst-Filter.
  374. Returns:
  375. Liste passender Titel.
  376. """
  377. return self._filter_by_language(
  378. language, "sub", genre=genre, service_id=service_id,
  379. )
  380. def get_recommendations(
  381. self,
  382. genre: str,
  383. service_id: str = "",
  384. min_rating: float = 7.0,
  385. limit: int = 5,
  386. ) -> list[dict]:
  387. """
  388. Gibt Empfehlungen basierend auf Genre und Mindestbewertung zurueck.
  389. Args:
  390. genre: Genre-Name.
  391. service_id: Optionaler Dienst-Filter.
  392. min_rating: Minimale Bewertung (0-10).
  393. limit: Maximale Anzahl Ergebnisse.
  394. Returns:
  395. Liste empfohlener Titel, sortiert nach Bewertung.
  396. """
  397. genre_lower = genre.lower()
  398. candidates = self._by_genre.get(genre_lower, [])
  399. if not candidates:
  400. return []
  401. results: list[dict] = []
  402. for title_data in candidates:
  403. rating = title_data.get("rt") or 0.0
  404. if rating < min_rating:
  405. continue
  406. # Dienst-Filter
  407. if service_id and service_id not in title_data.get("on", {}):
  408. continue
  409. results.append(title_data)
  410. results.sort(key=lambda t: t.get("rt") or 0.0, reverse=True)
  411. return results[:limit]
  412. # --- Hilfsfunktionen ---
  413. def _filter_by_language(
  414. self,
  415. language: str,
  416. lang_type: str,
  417. genre: str = "",
  418. service_id: str = "",
  419. ) -> list[dict]:
  420. """
  421. Filtert Titel nach Sprache (Dub oder Sub).
  422. Durchsucht das ``on``-Dict jedes Titels nach dem angegebenen
  423. Sprachtyp und prueft, ob die Sprache enthalten ist.
  424. Args:
  425. language: Sprachcode, z.B. "de".
  426. lang_type: "dub" oder "sub".
  427. genre: Optionaler Genre-Filter.
  428. service_id: Optionaler Dienst-Filter.
  429. Returns:
  430. Liste passender Titel.
  431. """
  432. lang_lower = language.lower()
  433. genre_lower = genre.lower() if genre else ""
  434. results: list[dict] = []
  435. for title_data in self._titles.values():
  436. # Genre-Filter
  437. if genre_lower:
  438. genres = [g.lower() for g in title_data.get("g", [])]
  439. if not any(genre_lower in g for g in genres):
  440. continue
  441. # Sprache pruefen
  442. on_services = title_data.get("on", {})
  443. matched = False
  444. for svc_id, svc_info in on_services.items():
  445. # Dienst-Filter
  446. if service_id and svc_id != service_id:
  447. continue
  448. langs = svc_info.get(lang_type, [])
  449. if lang_lower in langs:
  450. matched = True
  451. break
  452. if matched:
  453. results.append(title_data)
  454. # Nach Bewertung sortieren
  455. results.sort(key=lambda t: t.get("rt") or 0.0, reverse=True)
  456. return results
  457. @staticmethod
  458. def _generate_slug(title: str) -> str:
  459. """
  460. Generiert einen Slug aus dem Titel.
  461. Slug-Format: Kleinbuchstaben, nicht-alphanumerische Zeichen
  462. durch Bindestriche ersetzt. Gleiche Logik wie EPGCache._generate_series_id.
  463. Args:
  464. title: Der Originaltitel.
  465. Returns:
  466. Slug-String.
  467. """
  468. if not title:
  469. return ""
  470. slug = _SLUG_RE.sub("-", title.strip().lower())
  471. # Fuehrende und abschliessende Bindestriche entfernen
  472. return slug.strip("-")
  473. @staticmethod
  474. def _normalize(title: str) -> str:
  475. """
  476. Normalisiert einen Titel fuer die Suche.
  477. Konvertiert zu Kleinbuchstaben und entfernt fuehrende Artikel.
  478. Args:
  479. title: Der Originaltitel.
  480. Returns:
  481. Normalisierter Titel.
  482. """
  483. normalized = title.strip().lower()
  484. # Fuehrende Artikel entfernen
  485. parts = normalized.split()
  486. if parts and parts[0] in _STRIP_ARTICLES:
  487. normalized = " ".join(parts[1:])
  488. return normalized
  489. def get_stats(self) -> dict:
  490. """
  491. Gibt Statistiken ueber den Cache-Inhalt zurueck.
  492. Returns:
  493. Dict mit Anzahlen fuer Titel, Dienste, Genres, etc.
  494. """
  495. return {
  496. "title_count": len(self._titles),
  497. "service_count": len(self._services),
  498. "genre_count": len(self._by_genre),
  499. "index_service_count": len(self._by_service),
  500. "index_title_count": len(self._by_title),
  501. "recent_services": len(self._recent),
  502. }