cache.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578
  1. # -*- coding: utf-8 -*-
  2. """
  3. EPG-Cache mit In-Memory-Indizes.
  4. Verwaltet Sendungsdaten als JSON-Datei und bietet schnelle Abfragen
  5. ueber vorberechnete Indizes (nach Sender, Titel, Serien-ID, Tags).
  6. """
  7. from __future__ import annotations
  8. import bisect
  9. import json
  10. import re
  11. from datetime import date, datetime, timedelta
  12. from pathlib import Path
  13. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  14. # Artikel, die bei der Titel-Normalisierung entfernt werden
  15. _STRIP_ARTICLES = {"der", "die", "das", "ein", "eine", "the", "a", "an"}
  16. # Regex fuer Serien-ID-Generierung: nur alphanumerisch + Bindestriche
  17. _SLUG_RE = re.compile(r"[^a-z0-9]+")
  18. class EPGCache:
  19. """
  20. Cache fuer EPG-Sendungsdaten mit JSON-Persistenz und In-Memory-Indizes.
  21. Unterstuetzt Abfragen nach Sender, Titel, Serien-ID, Tags und Genre.
  22. Indizes werden beim Laden oder nach Aenderungen automatisch aufgebaut.
  23. """
  24. FILE_NAME = "epg.json"
  25. def __init__(self, cache_dir: Path) -> None:
  26. """
  27. Args:
  28. cache_dir: Verzeichnis fuer die Cache-Datei.
  29. """
  30. self._cache_dir = cache_dir
  31. self._file_path = cache_dir / self.FILE_NAME
  32. # Hauptdaten: {program_id: program_dict}
  33. self._programs: dict[str, dict] = {}
  34. # Enrichment-Daten: {series_id: enrichment_dict}
  35. self._enrichments: dict[str, dict] = {}
  36. # Erinnerungen: [{title, sid, active, satellite_id, ...}]
  37. self._reminders: list[dict] = []
  38. # Indizes (werden bei _rebuild_indices aufgebaut)
  39. self._by_channel: dict[str, list[dict]] = {}
  40. self._by_title: dict[str, list[dict]] = {}
  41. self._by_series_id: dict[str, list[dict]] = {}
  42. self._by_tag: dict[str, list[dict]] = {}
  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("EPGCache: 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._programs = data.get("programs", {})
  60. self._enrichments = data.get("enrichments", {})
  61. self._reminders = data.get("reminders", [])
  62. self._rebuild_indices()
  63. pinfo(
  64. f"EPGCache: {len(self._programs)} Sendungen, "
  65. f"{len(self._enrichments)} Enrichments geladen"
  66. )
  67. return True
  68. except (json.JSONDecodeError, KeyError, TypeError) as e:
  69. perror(f"EPGCache: Fehler beim Laden der Cache-Datei: {e}")
  70. self._programs = {}
  71. self._enrichments = {}
  72. self._reminders = []
  73. return False
  74. def save(self) -> None:
  75. """
  76. Speichert den Cache atomar in die JSON-Datei.
  77. Schreibt zuerst in eine temporaere Datei und benennt dann um,
  78. um Datenverlust bei Absturz zu vermeiden.
  79. """
  80. self._save_sync()
  81. async def save_async(self) -> None:
  82. """Async-Speicherung — blockiert den Event-Loop nicht."""
  83. import asyncio
  84. await asyncio.to_thread(self._save_sync)
  85. def _save_sync(self) -> None:
  86. """Synchrone Speicherung (kann auch aus Thread aufgerufen werden)."""
  87. data = {
  88. "programs": self._programs,
  89. "enrichments": self._enrichments,
  90. "reminders": self._reminders,
  91. }
  92. tmp_path = self._file_path.with_suffix(".tmp")
  93. try:
  94. tmp_path.write_text(
  95. json.dumps(data, ensure_ascii=False, separators=(",", ":")),
  96. encoding="utf-8",
  97. )
  98. tmp_path.replace(self._file_path)
  99. pdebug(f"EPGCache: {len(self._programs)} Sendungen gespeichert")
  100. except OSError as e:
  101. perror(f"EPGCache: Fehler beim Speichern: {e}")
  102. # --- Index-Verwaltung ---
  103. def _rebuild_indices(self) -> None:
  104. """
  105. Baut alle Indizes aus den aktuellen Programmdaten neu auf.
  106. Indizes:
  107. - _by_channel: Sendungen pro Sender, sortiert nach Startzeit
  108. - _by_title: Sendungen pro normalisiertem Titel
  109. - _by_series_id: Sendungen pro Serien-ID
  110. - _by_tag: Sendungen pro Tag (aus Enrichment-Daten)
  111. """
  112. self._by_channel = {}
  113. self._by_title = {}
  114. self._by_series_id = {}
  115. self._by_tag = {}
  116. for prog_id, program in self._programs.items():
  117. # Sender-Index
  118. channel = program.get("ch", "")
  119. if channel:
  120. self._by_channel.setdefault(channel, []).append(program)
  121. # Titel-Index (normalisiert)
  122. title = program.get("t", "")
  123. if title:
  124. norm_title = self._normalize_title(title)
  125. self._by_title.setdefault(norm_title, []).append(program)
  126. # Serien-ID-Index
  127. series_id = self._generate_series_id(title)
  128. if series_id:
  129. self._by_series_id.setdefault(series_id, []).append(program)
  130. # Sender-Listen nach Startzeit sortieren (fuer binaere Suche)
  131. for channel in self._by_channel:
  132. self._by_channel[channel].sort(key=lambda p: p.get("s", ""))
  133. # Tag-Index aus Enrichment-Daten aufbauen
  134. for series_id, enrichment in self._enrichments.items():
  135. tags = enrichment.get("tags", [])
  136. programs = self._by_series_id.get(series_id, [])
  137. for tag in tags:
  138. tag_lower = tag.lower()
  139. self._by_tag.setdefault(tag_lower, []).extend(programs)
  140. pdebug(
  141. f"EPGCache: Indizes aufgebaut - "
  142. f"{len(self._by_channel)} Sender, "
  143. f"{len(self._by_title)} Titel, "
  144. f"{len(self._by_series_id)} Serien, "
  145. f"{len(self._by_tag)} Tags"
  146. )
  147. # --- Daten-Mutation ---
  148. def update_programs(self, programs: list[dict]) -> int:
  149. """
  150. Fuegt neue Sendungen hinzu oder aktualisiert bestehende.
  151. Args:
  152. programs: Liste von Programm-Dicts.
  153. Returns:
  154. Anzahl der neu hinzugefuegten Sendungen.
  155. """
  156. new_count = 0
  157. for program in programs:
  158. prog_id = self._make_program_id(program)
  159. if prog_id not in self._programs:
  160. new_count += 1
  161. self._programs[prog_id] = program
  162. if new_count > 0:
  163. self._rebuild_indices()
  164. pdebug(f"EPGCache: {new_count} neue Sendungen hinzugefuegt")
  165. return new_count
  166. def update_enrichment(self, series_id: str, data: dict) -> None:
  167. """
  168. Aktualisiert Enrichment-Daten fuer eine Serie.
  169. Args:
  170. series_id: Die Serien-ID.
  171. data: Enrichment-Dict (z.B. mit Tags, Beschreibung, Streaming-Info).
  172. """
  173. self._enrichments[series_id] = data
  174. # Tag-Index neu aufbauen, da sich Tags geaendert haben koennten
  175. self._rebuild_tag_index()
  176. pdebug(f"EPGCache: Enrichment fuer '{series_id}' aktualisiert")
  177. def prune_old(self, retention_days: int = 14) -> int:
  178. """
  179. Entfernt Sendungen, die aelter als retention_days sind.
  180. Args:
  181. retention_days: Anzahl Tage, die behalten werden.
  182. Returns:
  183. Anzahl der entfernten Sendungen.
  184. """
  185. cutoff = datetime.now().astimezone() - timedelta(days=retention_days)
  186. cutoff_iso = cutoff.isoformat()
  187. to_remove = [
  188. prog_id
  189. for prog_id, program in self._programs.items()
  190. if program.get("e", program.get("s", "")) < cutoff_iso
  191. ]
  192. for prog_id in to_remove:
  193. del self._programs[prog_id]
  194. if to_remove:
  195. self._rebuild_indices()
  196. pinfo(f"EPGCache: {len(to_remove)} alte Sendungen entfernt")
  197. return len(to_remove)
  198. # --- Abfragen ---
  199. def get_program_at(self, channel_id: str, time: datetime) -> dict | None:
  200. """
  201. Findet die Sendung, die zu einem bestimmten Zeitpunkt auf einem
  202. Sender laeuft. Nutzt binaere Suche auf dem Sender-Index.
  203. Args:
  204. channel_id: Interne Sender-ID.
  205. time: Zeitpunkt.
  206. Returns:
  207. Programm-Dict oder None.
  208. """
  209. programs = self._by_channel.get(channel_id, [])
  210. if not programs:
  211. return None
  212. time_iso = time.isoformat()
  213. # Binaere Suche: finde Position, wo time einsortiert wuerde
  214. idx = bisect.bisect_right(
  215. programs, time_iso, key=lambda p: p.get("s", "")
  216. )
  217. # Die Sendung vor dieser Position koennte laufen
  218. if idx > 0:
  219. candidate = programs[idx - 1]
  220. start = candidate.get("s", "")
  221. end = candidate.get("e", "")
  222. # Pruefen, ob die Sendung noch laeuft
  223. if start <= time_iso and (not end or end > time_iso):
  224. return candidate
  225. return None
  226. def get_programs_on_channel(self, channel_id: str, target_date: date) -> list[dict]:
  227. """
  228. Gibt alle Sendungen eines Senders an einem bestimmten Tag zurueck.
  229. Args:
  230. channel_id: Interne Sender-ID.
  231. target_date: Datum.
  232. Returns:
  233. Liste der Sendungen, sortiert nach Startzeit.
  234. """
  235. programs = self._by_channel.get(channel_id, [])
  236. if not programs:
  237. return []
  238. # Tagesgrenzen als ISO-Strings (naive Filterung)
  239. day_start = datetime(target_date.year, target_date.month, target_date.day).isoformat()
  240. day_end = datetime(
  241. target_date.year, target_date.month, target_date.day, 23, 59, 59
  242. ).isoformat()
  243. # Binaere Suche fuer den Tagesanfang
  244. start_idx = bisect.bisect_left(
  245. programs, day_start, key=lambda p: p.get("s", "")
  246. )
  247. result: list[dict] = []
  248. for i in range(start_idx, len(programs)):
  249. prog = programs[i]
  250. prog_start = prog.get("s", "")
  251. if prog_start > day_end:
  252. break
  253. result.append(prog)
  254. return result
  255. def get_primetime(self, channel_id: str, target_date: date) -> dict | None:
  256. """
  257. Gibt die Primetime-Sendung (20:15) eines Senders zurueck.
  258. Args:
  259. channel_id: Interne Sender-ID.
  260. target_date: Datum.
  261. Returns:
  262. Programm-Dict oder None.
  263. """
  264. primetime = datetime(
  265. target_date.year, target_date.month, target_date.day, 20, 15
  266. ).astimezone()
  267. return self.get_program_at(channel_id, primetime)
  268. def search_title(self, query: str, limit: int = 10) -> list[dict]:
  269. """
  270. Sucht Sendungen nach Titel (unscharfe Suche).
  271. Zukuenftige Sendungen werden bevorzugt zurueckgegeben.
  272. Args:
  273. query: Suchbegriff.
  274. limit: Maximale Anzahl Ergebnisse.
  275. Returns:
  276. Liste passender Sendungen.
  277. """
  278. norm_query = self._normalize_title(query)
  279. if not norm_query:
  280. return []
  281. now_iso = datetime.now().astimezone().isoformat()
  282. matches: list[dict] = []
  283. for norm_title, programs in self._by_title.items():
  284. if norm_query in norm_title or norm_title in norm_query:
  285. matches.extend(programs)
  286. # Zukuenftige Sendungen zuerst, dann nach Startzeit sortieren
  287. matches.sort(
  288. key=lambda p: (
  289. 0 if p.get("s", "") >= now_iso else 1,
  290. p.get("s", ""),
  291. )
  292. )
  293. return matches[:limit]
  294. def search_by_tag(self, tag: str) -> list[dict]:
  295. """
  296. Sucht Sendungen nach Tag (aus Enrichment-Daten).
  297. Args:
  298. tag: Der gesuchte Tag.
  299. Returns:
  300. Liste passender Sendungen.
  301. """
  302. return list(self._by_tag.get(tag.lower(), []))
  303. def search_by_genre(self, genre: str) -> list[dict]:
  304. """
  305. Sucht Sendungen nach Genre.
  306. Args:
  307. genre: Der gesuchte Genre-String.
  308. Returns:
  309. Liste passender Sendungen.
  310. """
  311. genre_lower = genre.lower()
  312. results: list[dict] = []
  313. for program in self._programs.values():
  314. categories = program.get("g", [])
  315. if any(genre_lower in cat.lower() for cat in categories):
  316. results.append(program)
  317. # Nach Startzeit sortieren
  318. results.sort(key=lambda p: p.get("s", ""))
  319. return results
  320. def get_next_airing(self, series_id: str) -> dict | None:
  321. """
  322. Findet die naechste Ausstrahlung einer Serie.
  323. Args:
  324. series_id: Die Serien-ID.
  325. Returns:
  326. Programm-Dict oder None.
  327. """
  328. programs = self._by_series_id.get(series_id, [])
  329. if not programs:
  330. return None
  331. now_iso = datetime.now().astimezone().isoformat()
  332. for program in sorted(programs, key=lambda p: p.get("s", "")):
  333. if program.get("s", "") >= now_iso:
  334. return program
  335. return None
  336. def get_all_airings(self, series_id: str) -> list[dict]:
  337. """
  338. Gibt alle Ausstrahlungen einer Serie zurueck.
  339. Args:
  340. series_id: Die Serien-ID.
  341. Returns:
  342. Liste aller Ausstrahlungen, sortiert nach Startzeit.
  343. """
  344. programs = self._by_series_id.get(series_id, [])
  345. return sorted(programs, key=lambda p: p.get("s", ""))
  346. def get_streaming_info(self, series_id: str) -> list[str]:
  347. """
  348. Gibt Streaming-Anbieter fuer eine Serie zurueck.
  349. Args:
  350. series_id: Die Serien-ID.
  351. Returns:
  352. Liste der Streaming-Anbieter.
  353. """
  354. enrichment = self._enrichments.get(series_id)
  355. if enrichment:
  356. return enrichment.get("streaming", [])
  357. return []
  358. def get_enrichment(self, series_id: str) -> dict | None:
  359. """
  360. Gibt die Enrichment-Daten fuer eine Serie zurueck.
  361. Args:
  362. series_id: Die Serien-ID.
  363. Returns:
  364. Enrichment-Dict oder None.
  365. """
  366. return self._enrichments.get(series_id)
  367. def get_similar_shows(self, series_id: str) -> list[str]:
  368. """
  369. Gibt aehnliche Sendungen basierend auf Enrichment-Daten zurueck.
  370. Args:
  371. series_id: Die Serien-ID.
  372. Returns:
  373. Liste von aehnlichen Serien-IDs.
  374. """
  375. enrichment = self._enrichments.get(series_id)
  376. if enrichment:
  377. return enrichment.get("similar", [])
  378. return []
  379. # --- Hilfsfunktionen ---
  380. def _rebuild_tag_index(self) -> None:
  381. """Baut nur den Tag-Index neu auf (nach Enrichment-Aenderungen)."""
  382. self._by_tag = {}
  383. for series_id, enrichment in self._enrichments.items():
  384. tags = enrichment.get("tags", [])
  385. programs = self._by_series_id.get(series_id, [])
  386. for tag in tags:
  387. tag_lower = tag.lower()
  388. self._by_tag.setdefault(tag_lower, []).extend(programs)
  389. @staticmethod
  390. def _make_program_id(program: dict) -> str:
  391. """Generiert eine eindeutige ID fuer eine Sendung (MD5 aus ch+st)."""
  392. import hashlib
  393. key = f"{program.get('ch', '')}_{program.get('s', '')}"
  394. return hashlib.md5(key.encode()).hexdigest()[:12]
  395. @staticmethod
  396. def _normalize_title(title: str) -> str:
  397. """
  398. Normalisiert einen Sendungstitel fuer die Suche.
  399. Konvertiert zu Kleinbuchstaben und entfernt fuehrende Artikel.
  400. Args:
  401. title: Der Originaltitel.
  402. Returns:
  403. Normalisierter Titel.
  404. """
  405. normalized = title.strip().lower()
  406. # Fuehrende Artikel entfernen
  407. parts = normalized.split()
  408. if parts and parts[0] in _STRIP_ARTICLES:
  409. normalized = " ".join(parts[1:])
  410. return normalized
  411. @staticmethod
  412. def _generate_series_id(title: str) -> str:
  413. """
  414. Generiert eine Serien-ID aus dem Titel.
  415. Slug-Format: Kleinbuchstaben, nicht-alphanumerische Zeichen
  416. durch Bindestriche ersetzt.
  417. Args:
  418. title: Der Sendungstitel.
  419. Returns:
  420. Serien-ID als Slug.
  421. """
  422. if not title:
  423. return ""
  424. slug = _SLUG_RE.sub("-", title.strip().lower())
  425. # Fuehrende und abschliessende Bindestriche entfernen
  426. return slug.strip("-")
  427. def get_all_programs(self) -> list[dict]:
  428. """Gibt alle Programme als Liste zurueck."""
  429. return list(self._programs.values())
  430. def get_program_by_series_id(self, series_id: str) -> dict | None:
  431. """Gibt das erste Programm fuer eine Serien-ID zurueck."""
  432. programs = self._by_series_id.get(series_id, [])
  433. return programs[0] if programs else None
  434. @property
  435. def reminders(self) -> list[dict]:
  436. """Gibt die Erinnerungsliste zurueck (direkte Referenz fuer Modifikation)."""
  437. return self._reminders
  438. def get_stats(self) -> dict:
  439. """
  440. Gibt Statistiken ueber den Cache-Inhalt zurueck.
  441. Returns:
  442. Dict mit Anzahlen fuer Programme, Sender, Enrichments, etc.
  443. """
  444. return {
  445. "program_count": len(self._programs),
  446. "channel_count": len(self._by_channel),
  447. "enriched_count": len(self._enrichments),
  448. "reminder_count": len(self._reminders),
  449. "title_count": len(self._by_title),
  450. "series_count": len(self._by_series_id),
  451. "tag_count": len(self._by_tag),
  452. }