# -*- coding: utf-8 -*- """ Sync-State fuer die EPG-Synchronisation. Verfolgt den Zustand der EPG-Aktualisierung und Enrichment-Warteschlange, um nach Neustarts nahtlos fortzufahren. """ from __future__ import annotations import json from datetime import datetime, timedelta from pathlib import Path from trixy_core.utils.debug import pdebug, perror, pwarn class SyncState: """ Persistenter Zustand der EPG-Synchronisation. Speichert Zeitstempel der letzten Aktualisierungen, die Enrichment-Warteschlange und bereits angereicherte Titel. """ FILE_NAME = "sync_state.json" def __init__(self, cache_dir: Path) -> None: """ Args: cache_dir: Verzeichnis fuer die State-Datei. """ self._cache_dir = cache_dir self._file_path = cache_dir / self.FILE_NAME # Zustandsfelder self.last_epg_fetch: str = "" self.last_enrichment: str = "" self.enrichment_queue: list[str] = [] self.enriched_titles: dict[str, str] = {} self.next_sync_after: str = "" # Verzeichnis erstellen self._cache_dir.mkdir(parents=True, exist_ok=True) def load(self) -> None: """ Laedt den Sync-State aus der JSON-Datei. Toleriert fehlende oder beschaedigte Dateien und setzt dann die Standardwerte. """ if not self._file_path.exists(): pdebug("SyncState: Keine State-Datei vorhanden, starte mit Standardwerten") return try: raw = self._file_path.read_text(encoding="utf-8") data = json.loads(raw) self.last_epg_fetch = data.get("last_epg_fetch", "") self.last_enrichment = data.get("last_enrichment", "") self.enrichment_queue = data.get("enrichment_queue", []) self.enriched_titles = data.get("enriched_titles", {}) self.next_sync_after = data.get("next_sync_after", "") pdebug( f"SyncState: Geladen - " f"Letzter EPG-Fetch: {self.last_epg_fetch or 'nie'}, " f"Queue: {len(self.enrichment_queue)} Eintraege" ) except (json.JSONDecodeError, TypeError, KeyError) as e: pwarn(f"SyncState: Fehler beim Laden, setze Standardwerte: {e}") self.last_epg_fetch = "" self.last_enrichment = "" self.enrichment_queue = [] self.enriched_titles = {} self.next_sync_after = "" def save(self) -> None: """ Speichert den Sync-State atomar in die JSON-Datei. Schreibt zuerst in eine temporaere Datei und benennt dann um. """ data = { "last_epg_fetch": self.last_epg_fetch, "last_enrichment": self.last_enrichment, "enrichment_queue": self.enrichment_queue, "enriched_titles": self.enriched_titles, "next_sync_after": self.next_sync_after, } tmp_path = self._file_path.with_suffix(".tmp") try: tmp_path.write_text( json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8", ) tmp_path.replace(self._file_path) pdebug("SyncState: State gespeichert") except OSError as e: perror(f"SyncState: Fehler beim Speichern: {e}") def needs_epg_fetch(self) -> bool: """ Prueft, ob eine neue EPG-Aktualisierung noetig ist. Gibt True zurueck, wenn noch nie synchronisiert wurde oder der naechste geplante Sync-Zeitpunkt in der Vergangenheit liegt. Returns: True, wenn ein EPG-Fetch durchgefuehrt werden sollte. """ if not self.last_epg_fetch: return True if not self.next_sync_after: return True try: next_sync = datetime.fromisoformat(self.next_sync_after) return datetime.now().astimezone() >= next_sync except ValueError: # Ungueltiger Zeitstempel, sicherheitshalber neu laden return True def needs_enrichment(self) -> bool: """ Prueft, ob Enrichment-Aufgaben in der Warteschlange sind. Returns: True, wenn die Warteschlange nicht leer ist. """ return len(self.enrichment_queue) > 0 def mark_epg_fetched( self, retention_days: int, min_interval_hours: int, ) -> None: """ Markiert den EPG-Fetch als abgeschlossen und plant den naechsten. Args: retention_days: Wie viele Tage EPG-Daten vorgehalten werden. min_interval_hours: Minimales Intervall bis zum naechsten Fetch. """ now = datetime.now().astimezone() self.last_epg_fetch = now.isoformat() self.next_sync_after = (now + timedelta(hours=min_interval_hours)).isoformat() pdebug( f"SyncState: EPG-Fetch abgeschlossen. " f"Naechster Sync nach: {self.next_sync_after}" ) self.save() def mark_enrichment_done(self) -> None: """Markiert die aktuelle Enrichment-Runde als abgeschlossen.""" self.last_enrichment = datetime.now().astimezone().isoformat() pdebug("SyncState: Enrichment-Runde abgeschlossen") self.save() def is_enriched(self, series_id: str) -> bool: """Prueft ob eine Serie bereits angereichert wurde.""" return series_id in self.enriched_titles def is_in_enrichment_queue(self, series_id: str) -> bool: """Prueft ob eine Serie in der Enrichment-Queue ist.""" return series_id in self.enrichment_queue def remove_from_enrichment_queue(self, series_id: str) -> None: """Entfernt eine Serie aus der Enrichment-Queue.""" if series_id in self.enrichment_queue: self.enrichment_queue.remove(series_id) self.save() def add_to_enrichment_queue(self, series_ids: list[str] | str) -> None: """ Fuegt Serien-IDs zur Enrichment-Warteschlange hinzu. IDs, die bereits angereichert wurden, werden uebersprungen. Args: series_ids: Liste von Serien-IDs oder einzelne ID. """ if isinstance(series_ids, str): series_ids = [series_ids] added = 0 existing_set = set(self.enrichment_queue) for series_id in series_ids: # Bereits angereichert oder schon in der Queue -> ueberspringen if series_id in self.enriched_titles: continue if series_id in existing_set: continue self.enrichment_queue.append(series_id) existing_set.add(series_id) added += 1 if added > 0: pdebug(f"SyncState: {added} Serien zur Enrichment-Queue hinzugefuegt") self.save() def mark_title_enriched(self, series_id: str, source: str) -> None: """ Markiert eine Serie als angereichert. Entfernt die ID aus der Warteschlange und fuegt sie zu den bereits angereicherten Titeln hinzu. Args: series_id: Die Serien-ID. source: Quelle der Enrichment-Daten (z.B. "tmdb", "omdb"). """ # Aus der Queue entfernen if series_id in self.enrichment_queue: self.enrichment_queue.remove(series_id) # Als angereichert markieren self.enriched_titles[series_id] = source pdebug(f"SyncState: '{series_id}' als angereichert markiert (Quelle: {source})") self.save()