"""Serielle Hintergrundausführung, Zeitplanung und Retry-Steuerung.""" from __future__ import annotations import traceback from collections import deque from datetime import datetime, timedelta from pathlib import Path from PyQt6.QtCore import QObject, QRunnable, QThreadPool, QTimer, pyqtSignal from syncvito.config_store import ConfigStore from syncvito.file_locks import is_file_locked from syncvito.models import AppConfig, SyncTask, TaskRuntimeState, TriggerMode from syncvito.realtime import RealtimeMonitor from syncvito.sync_engine import SyncEngine, SyncResult, UnavailablePathError __all__ = ["SyncCoordinator", "derive_overall_status"] def derive_overall_status( states: list[TaskRuntimeState], globally_paused: bool, running: bool = False ) -> tuple[str, str]: """Leitet den Tray-Zustand deterministisch aus allen Aufgaben ab.""" if TaskRuntimeState.ERROR in states: return "error", "Fehler" if TaskRuntimeState.WAITING_FOR_RELEASE in states: return "error", "Wartet auf Dateifreigabe" if globally_paused: return "paused", "Pausiert" if TaskRuntimeState.ACTIVE not in states: return "paused", "Inaktiv" return "active", "Synchronisiert" if running else "Bereit" class _WorkerSignals(QObject): progress = pyqtSignal(int, str) finished = pyqtSignal(str, object) failed = pyqtSignal(str, str, bool) class _SyncWorker(QRunnable): def __init__(self, task: SyncTask, engine: SyncEngine) -> None: super().__init__() self.task = task self.engine = engine self.signals = _WorkerSignals() def run(self) -> None: try: result = self.engine.synchronize(self.task, self.signals.progress.emit) except UnavailablePathError as exc: self.signals.failed.emit(self.task.id, str(exc), True) except Exception as exc: # noqa: BLE001 - Worker-Grenze muss UI-Absturz verhindern. detail = f"{exc}\n{traceback.format_exc(limit=4)}" self.signals.failed.emit(self.task.id, detail, False) else: self.signals.finished.emit(self.task.id, result) class _RealtimeBridge(QObject): changed = pyqtSignal(str) class SyncCoordinator(QObject): """Koordiniert genau einen sichtbaren Synchronisationslauf zur Zeit.""" progress_changed = pyqtSignal(int, str) log_created = pyqtSignal(str, str, str) overall_status_changed = pyqtSignal(str, str) task_state_changed = pyqtSignal(str, object) lock_changed = pyqtSignal(str) idle_changed = pyqtSignal(bool) def __init__(self, config: AppConfig, config_store: ConfigStore, parent: QObject | None = None) -> None: super().__init__(parent) self.config = config self.config_store = config_store self.engine = SyncEngine() self.pool = QThreadPool.globalInstance() self.pool.setMaxThreadCount(max(2, self.pool.maxThreadCount())) self._queue: deque[str] = deque() self._running_task_id: str | None = None self._retry_tasks: dict[str, list[Path]] = {} self._lock_messages: dict[str, str] = {} self._task_states: dict[str, TaskRuntimeState] = { task.id: TaskRuntimeState.ACTIVE if task.enabled else TaskRuntimeState.PAUSED for task in config.tasks } self._last_realtime_event: dict[str, datetime] = {} self._bridge = _RealtimeBridge() self._bridge.changed.connect(self._on_realtime_event) self.monitor = RealtimeMonitor(self._bridge.changed.emit) self._schedule_timer = QTimer(self) self._schedule_timer.setInterval(30_000) self._schedule_timer.timeout.connect(self._check_intervals) self._schedule_timer.start() self._retry_timer = QTimer(self) self._retry_timer.setInterval(10_000) self._retry_timer.timeout.connect(self._check_retries) self._retry_timer.start() self.rebuild_monitors() def task_state(self, task_id: str) -> TaskRuntimeState: """Liefert den aktuellen Engine-Zustand einer Aufgabe.""" return self._task_states.get(task_id, TaskRuntimeState.PAUSED) def current_overall_status(self) -> tuple[str, str]: """Liefert den aktuell abgeleiteten Tray-Zustand.""" return derive_overall_status(list(self._task_states.values()), self.config.settings.paused, self.is_running) def refresh_task_states(self) -> None: """Gleicht Laufzeitzustände nach Änderungen an der Aufgabenliste ab.""" known_ids = {task.id for task in self.config.tasks} self._task_states = {task_id: state for task_id, state in self._task_states.items() if task_id in known_ids} self._retry_tasks = {task_id: paths for task_id, paths in self._retry_tasks.items() if task_id in known_ids} self._lock_messages = { task_id: message for task_id, message in self._lock_messages.items() if task_id in known_ids } for task in self.config.tasks: desired = TaskRuntimeState.ACTIVE if task.enabled else TaskRuntimeState.PAUSED current = self._task_states.get(task.id) if current is None or current is TaskRuntimeState.PAUSED or not task.enabled: self._set_task_state(task.id, desired) if not task.enabled: self._retry_tasks.pop(task.id, None) self._lock_messages.pop(task.id, None) enabled_ids = {task.id for task in self.config.tasks if task.enabled} self._queue = deque(task_id for task_id in self._queue if task_id in enabled_ids) self._emit_overall_status() self._emit_lock_summary() @property def is_running(self) -> bool: """Zeigt an, ob momentan ein Worker aktiv ist.""" return self._running_task_id is not None def enqueue(self, task_id: str) -> None: """Stellt eine Aufgabe dedupliziert in die serielle Warteschlange.""" if self.config.settings.paused: self.log_created.emit("Synchronisation", "Hinweis", "SyncVito ist pausiert.") return task = self._task(task_id) if task is None or not task.enabled: return if task_id != self._running_task_id and task_id not in self._queue: self._queue.append(task_id) self._start_next() def enqueue_all(self) -> None: """Stellt alle aktiven Aufgaben zur manuellen Ausführung an.""" for task in self.config.tasks: if task.enabled: self.enqueue(task.id) def set_paused(self, paused: bool) -> None: """Ändert den globalen Pausenzustand.""" self.config.settings.paused = paused self.config_store.save(self.config) if paused: self.log_created.emit("Status", "Hinweis", "Alle automatischen Auslöser sind pausiert.") else: self.log_created.emit("Status", "Erfolg", "Synchronisation fortgesetzt.") self._start_next() self._emit_overall_status() def rebuild_monitors(self) -> None: """Aktualisiert alle Dateisystemüberwachungen.""" errors = self.monitor.rebuild(self.config.tasks) for task_id, message in errors: self._retry_tasks[task_id] = [] self._set_task_state(task_id, TaskRuntimeState.ERROR) self.log_created.emit("Echtzeit", "Fehler", message) self._emit_overall_status() def stop(self) -> None: """Stoppt die Dateisystemüberwachung beim Beenden.""" self.monitor.stop() def _start_next(self) -> None: if self._running_task_id is not None or self.config.settings.paused or not self._queue: return task_id = self._queue.popleft() task = self._task(task_id) if task is None or not task.enabled: self._start_next() return self._running_task_id = task_id self.idle_changed.emit(False) self._emit_overall_status() self.log_created.emit(task.name, "Start", "Synchronisation gestartet.") worker = _SyncWorker(task, self.engine) worker.signals.progress.connect(self.progress_changed) worker.signals.finished.connect(self._on_finished) worker.signals.failed.connect(self._on_failed) self.pool.start(worker) def _on_finished(self, task_id: str, result: SyncResult) -> None: task = self._task(task_id) if task is None: self._complete_current() return task.last_run = datetime.now().isoformat(timespec="seconds") self.config_store.save(self.config) if result.blocked: paths = [blocked.path for blocked in result.blocked] self._retry_tasks[task_id] = paths descriptions: list[str] = [] for blocked in result.blocked: names = ", ".join(f"{process.name} (PID {process.pid})" for process in blocked.processes) descriptions.append(f"{blocked.path.name}: {names or 'sperrender Prozess nicht ermittelbar'}") message = "Zurückgestellt – " + "; ".join(descriptions) self._lock_messages[task_id] = message self._emit_lock_summary() self.log_created.emit(task.name, "Blockiert", message) self._set_task_state(task_id, TaskRuntimeState.WAITING_FOR_RELEASE) elif result.errors: self._retry_tasks[task_id] = [] self._lock_messages.pop(task_id, None) self._emit_lock_summary() self.log_created.emit(task.name, "Fehler", " | ".join(result.errors[:5])) self._set_task_state(task_id, TaskRuntimeState.ERROR) else: self._retry_tasks.pop(task_id, None) self._lock_messages.pop(task_id, None) self._emit_lock_summary() summary = f"{result.copied} kopiert, {result.deleted} gelöscht, {result.conflicts} Konfliktkopien" self.log_created.emit(task.name, "Erfolg", summary) self._set_task_state(task_id, TaskRuntimeState.ACTIVE) self._complete_current() def _on_failed(self, task_id: str, detail: str, inaccessible: bool) -> None: task = self._task(task_id) name = task.name if task else "Aufgabe" self._retry_tasks[task_id] = [] self._lock_messages.pop(task_id, None) self._emit_lock_summary() self._set_task_state(task_id, TaskRuntimeState.ERROR) self.log_created.emit(name, "Fehler", detail) self._complete_current() def _complete_current(self) -> None: self._running_task_id = None self.progress_changed.emit(100, "Abgeschlossen") if not self._queue: self.idle_changed.emit(True) self._emit_overall_status() QTimer.singleShot(200, self._start_next) def _check_intervals(self) -> None: if self.config.settings.paused: return now = datetime.now() for task in self.config.tasks: if not task.enabled or task.trigger is not TriggerMode.INTERVAL: continue try: last_run = datetime.fromisoformat(task.last_run) if task.last_run else datetime.min except ValueError: last_run = datetime.min if now - last_run >= timedelta(minutes=task.interval_minutes): self.enqueue(task.id) def _on_realtime_event(self, task_id: str) -> None: now = datetime.now() last = self._last_realtime_event.get(task_id) self._last_realtime_event[task_id] = now if last and now - last < timedelta(seconds=2): return QTimer.singleShot(1200, lambda: self.enqueue(task_id)) def _check_retries(self) -> None: if self.config.settings.paused or self.is_running: return for task_id, paths in list(self._retry_tasks.items()): if paths and any(is_file_locked(path) for path in paths): continue self.log_created.emit( "Automatischer Versuch", "Hinweis", "Blockierte oder nicht erreichbare Datei wird erneut geprüft." ) self.enqueue(task_id) break def _task(self, task_id: str) -> SyncTask | None: return next((task for task in self.config.tasks if task.id == task_id), None) def _set_task_state(self, task_id: str, state: TaskRuntimeState) -> None: if self._task_states.get(task_id) == state: return self._task_states[task_id] = state self.task_state_changed.emit(task_id, state) self._emit_overall_status() def _emit_overall_status(self) -> None: status, text = self.current_overall_status() self.overall_status_changed.emit(status, text) def _emit_lock_summary(self) -> None: self.lock_changed.emit("\n".join(self._lock_messages.values()))