Initial release with Gitea updater
This commit is contained in:
commit
46c2474985
36 files changed
+3674
No files matched your search
@@ -0,0 +1,299 @@
|
||||
"""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()))
|
||||
Reference in new issue
Block a user