sync_state.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. # -*- coding: utf-8 -*-
  2. """
  3. Sync-State fuer die EPG-Synchronisation.
  4. Verfolgt den Zustand der EPG-Aktualisierung und Enrichment-Warteschlange,
  5. um nach Neustarts nahtlos fortzufahren.
  6. """
  7. from __future__ import annotations
  8. import json
  9. from datetime import datetime, timedelta
  10. from pathlib import Path
  11. from trixy_core.utils.debug import pdebug, perror, pwarn
  12. class SyncState:
  13. """
  14. Persistenter Zustand der EPG-Synchronisation.
  15. Speichert Zeitstempel der letzten Aktualisierungen, die
  16. Enrichment-Warteschlange und bereits angereicherte Titel.
  17. """
  18. FILE_NAME = "sync_state.json"
  19. def __init__(self, cache_dir: Path) -> None:
  20. """
  21. Args:
  22. cache_dir: Verzeichnis fuer die State-Datei.
  23. """
  24. self._cache_dir = cache_dir
  25. self._file_path = cache_dir / self.FILE_NAME
  26. # Zustandsfelder
  27. self.last_epg_fetch: str = ""
  28. self.last_enrichment: str = ""
  29. self.enrichment_queue: list[str] = []
  30. self.enriched_titles: dict[str, str] = {}
  31. self.next_sync_after: str = ""
  32. # Verzeichnis erstellen
  33. self._cache_dir.mkdir(parents=True, exist_ok=True)
  34. def load(self) -> None:
  35. """
  36. Laedt den Sync-State aus der JSON-Datei.
  37. Toleriert fehlende oder beschaedigte Dateien und setzt
  38. dann die Standardwerte.
  39. """
  40. if not self._file_path.exists():
  41. pdebug("SyncState: Keine State-Datei vorhanden, starte mit Standardwerten")
  42. return
  43. try:
  44. raw = self._file_path.read_text(encoding="utf-8")
  45. data = json.loads(raw)
  46. self.last_epg_fetch = data.get("last_epg_fetch", "")
  47. self.last_enrichment = data.get("last_enrichment", "")
  48. self.enrichment_queue = data.get("enrichment_queue", [])
  49. self.enriched_titles = data.get("enriched_titles", {})
  50. self.next_sync_after = data.get("next_sync_after", "")
  51. pdebug(
  52. f"SyncState: Geladen - "
  53. f"Letzter EPG-Fetch: {self.last_epg_fetch or 'nie'}, "
  54. f"Queue: {len(self.enrichment_queue)} Eintraege"
  55. )
  56. except (json.JSONDecodeError, TypeError, KeyError) as e:
  57. pwarn(f"SyncState: Fehler beim Laden, setze Standardwerte: {e}")
  58. self.last_epg_fetch = ""
  59. self.last_enrichment = ""
  60. self.enrichment_queue = []
  61. self.enriched_titles = {}
  62. self.next_sync_after = ""
  63. def save(self) -> None:
  64. """
  65. Speichert den Sync-State atomar in die JSON-Datei.
  66. Schreibt zuerst in eine temporaere Datei und benennt dann um.
  67. """
  68. data = {
  69. "last_epg_fetch": self.last_epg_fetch,
  70. "last_enrichment": self.last_enrichment,
  71. "enrichment_queue": self.enrichment_queue,
  72. "enriched_titles": self.enriched_titles,
  73. "next_sync_after": self.next_sync_after,
  74. }
  75. tmp_path = self._file_path.with_suffix(".tmp")
  76. try:
  77. tmp_path.write_text(
  78. json.dumps(data, ensure_ascii=False, indent=2),
  79. encoding="utf-8",
  80. )
  81. tmp_path.replace(self._file_path)
  82. pdebug("SyncState: State gespeichert")
  83. except OSError as e:
  84. perror(f"SyncState: Fehler beim Speichern: {e}")
  85. def needs_epg_fetch(self) -> bool:
  86. """
  87. Prueft, ob eine neue EPG-Aktualisierung noetig ist.
  88. Gibt True zurueck, wenn noch nie synchronisiert wurde oder
  89. der naechste geplante Sync-Zeitpunkt in der Vergangenheit liegt.
  90. Returns:
  91. True, wenn ein EPG-Fetch durchgefuehrt werden sollte.
  92. """
  93. if not self.last_epg_fetch:
  94. return True
  95. if not self.next_sync_after:
  96. return True
  97. try:
  98. next_sync = datetime.fromisoformat(self.next_sync_after)
  99. return datetime.now().astimezone() >= next_sync
  100. except ValueError:
  101. # Ungueltiger Zeitstempel, sicherheitshalber neu laden
  102. return True
  103. def needs_enrichment(self) -> bool:
  104. """
  105. Prueft, ob Enrichment-Aufgaben in der Warteschlange sind.
  106. Returns:
  107. True, wenn die Warteschlange nicht leer ist.
  108. """
  109. return len(self.enrichment_queue) > 0
  110. def mark_epg_fetched(
  111. self,
  112. retention_days: int,
  113. min_interval_hours: int,
  114. ) -> None:
  115. """
  116. Markiert den EPG-Fetch als abgeschlossen und plant den naechsten.
  117. Args:
  118. retention_days: Wie viele Tage EPG-Daten vorgehalten werden.
  119. min_interval_hours: Minimales Intervall bis zum naechsten Fetch.
  120. """
  121. now = datetime.now().astimezone()
  122. self.last_epg_fetch = now.isoformat()
  123. self.next_sync_after = (now + timedelta(hours=min_interval_hours)).isoformat()
  124. pdebug(
  125. f"SyncState: EPG-Fetch abgeschlossen. "
  126. f"Naechster Sync nach: {self.next_sync_after}"
  127. )
  128. self.save()
  129. def mark_enrichment_done(self) -> None:
  130. """Markiert die aktuelle Enrichment-Runde als abgeschlossen."""
  131. self.last_enrichment = datetime.now().astimezone().isoformat()
  132. pdebug("SyncState: Enrichment-Runde abgeschlossen")
  133. self.save()
  134. def is_enriched(self, series_id: str) -> bool:
  135. """Prueft ob eine Serie bereits angereichert wurde."""
  136. return series_id in self.enriched_titles
  137. def is_in_enrichment_queue(self, series_id: str) -> bool:
  138. """Prueft ob eine Serie in der Enrichment-Queue ist."""
  139. return series_id in self.enrichment_queue
  140. def remove_from_enrichment_queue(self, series_id: str) -> None:
  141. """Entfernt eine Serie aus der Enrichment-Queue."""
  142. if series_id in self.enrichment_queue:
  143. self.enrichment_queue.remove(series_id)
  144. self.save()
  145. def add_to_enrichment_queue(self, series_ids: list[str] | str) -> None:
  146. """
  147. Fuegt Serien-IDs zur Enrichment-Warteschlange hinzu.
  148. IDs, die bereits angereichert wurden, werden uebersprungen.
  149. Args:
  150. series_ids: Liste von Serien-IDs oder einzelne ID.
  151. """
  152. if isinstance(series_ids, str):
  153. series_ids = [series_ids]
  154. added = 0
  155. existing_set = set(self.enrichment_queue)
  156. for series_id in series_ids:
  157. # Bereits angereichert oder schon in der Queue -> ueberspringen
  158. if series_id in self.enriched_titles:
  159. continue
  160. if series_id in existing_set:
  161. continue
  162. self.enrichment_queue.append(series_id)
  163. existing_set.add(series_id)
  164. added += 1
  165. if added > 0:
  166. pdebug(f"SyncState: {added} Serien zur Enrichment-Queue hinzugefuegt")
  167. self.save()
  168. def mark_title_enriched(self, series_id: str, source: str) -> None:
  169. """
  170. Markiert eine Serie als angereichert.
  171. Entfernt die ID aus der Warteschlange und fuegt sie zu den
  172. bereits angereicherten Titeln hinzu.
  173. Args:
  174. series_id: Die Serien-ID.
  175. source: Quelle der Enrichment-Daten (z.B. "tmdb", "omdb").
  176. """
  177. # Aus der Queue entfernen
  178. if series_id in self.enrichment_queue:
  179. self.enrichment_queue.remove(series_id)
  180. # Als angereichert markieren
  181. self.enriched_titles[series_id] = source
  182. pdebug(f"SyncState: '{series_id}' als angereichert markiert (Quelle: {source})")
  183. self.save()