Initial release with Gitea updater

This commit is contained in:
Davito committed 2026-09-23 19:02:23 +02:00
commit 46c2474985
36 files changed
+3674

No files matched your search

+299
View File
@@ -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()))