diff --git a/deploy/worker-windows/RippyWorkerSetup.exe b/deploy/worker-windows/RippyWorkerSetup.exe index 4b0706e..415103e 100644 Binary files a/deploy/worker-windows/RippyWorkerSetup.exe and b/deploy/worker-windows/RippyWorkerSetup.exe differ diff --git a/deploy/worker-windows/install-gui.ps1 b/deploy/worker-windows/install-gui.ps1 index 4f69181..a8b88dc 100644 --- a/deploy/worker-windows/install-gui.ps1 +++ b/deploy/worker-windows/install-gui.ps1 @@ -482,8 +482,8 @@ function Do-Install { $poolArg = if ($slots -le 1) { "--pool=solo" } else { "--pool=threads --concurrency=$slots" } Log "Gleichzeitige Auftraege: $slots" - $trayBat = "@echo off`r`ncd /d `"%~dp0`"`r`nset REDIS_URL=redis://${rHost}:6379/0`r`nset DATABASE_URL=postgresql://rippy:rippy@${rHost}:5432/rippy`r`nset API_URL=http://${rHost}:8000`r`nset WORKER_NAME=$wName`r`nset RIPPY_TRAY_HOST=$rHost`r`nset RIPPY_SLOTS=$slots`r`nset TZ=UTC`r`n${mapZeile}set PATH=%~dp0;%PATH%`r`nstart `"`" venv\Scripts\pythonw.exe tray.py" - Set-Content -Path "start-tray.bat" -Value $trayBat -Encoding ASCII + $trayBat = "@echo off`r`ncd /d `"%~dp0`"`r`nset REDIS_URL=redis://${rHost}:6379/0`r`nset DATABASE_URL=postgresql://rippy:rippy@${rHost}:5432/rippy`r`nset API_URL=http://${rHost}:8000`r`nset WORKER_NAME=$wName`r`nset RIPPY_TRAY_HOST=$rHost`r`nset RIPPY_SLOTS=$slots`r`nset TZ=UTC`r`n${mapZeile}set PATH=%~dp0;%PATH%`r`nstart `"`" venv\Scripts\pythonw.exe gui.py" + Set-Content -Path "start-gui.bat" -Value $trayBat -Encoding ASCII $workBat = "@echo off`r`ncd /d `"%~dp0`"`r`nset REDIS_URL=redis://${rHost}:6379/0`r`nset DATABASE_URL=postgresql://rippy:rippy@${rHost}:5432/rippy`r`nset API_URL=http://${rHost}:8000`r`nset WORKER_NAME=$wName`r`nset RIPPY_SLOTS=$slots`r`nset TZ=UTC`r`n${mapZeile}set PATH=%~dp0;%PATH%`r`nvenv\Scripts\celery.exe -A celery_app worker --loglevel=info -Q transcode $poolArg -n ${wName}@%%h" Set-Content -Path "start-worker.bat" -Value $workBat -Encoding ASCII @@ -565,9 +565,15 @@ if (-not `$schreibbar -and -not `$istAdmin) { } # 1. Alles beenden, was aus diesem Ordner läuft (Tray, celery, HandBrake). -Get-CimInstance Win32_Process | Where-Object { - `$_.ExecutablePath -like "`$ordner*" -} | ForEach-Object { Stop-Process -Id `$_.ProcessId -Force -ErrorAction SilentlyContinue } +# +# Der Tray-Prozess (pythonw.exe tray.py) kann mehrere Instanzen haben. +# Wir filtern via WMI anhand der CommandLine. +Get-WmiObject Win32_Process | Where-Object { + `$_.ExecutablePath -like "`$PSScriptRoot*" -or `$_.CommandLine -like "*gui.py*" -or `$_.CommandLine -like "*tray.py*" +} | ForEach-Object { + Write-Host "Beende Worker-Prozess: PID `$(`$_.ProcessId) (`$(`$_.Name))" + Stop-Process -Id `$_.ProcessId -Force -ErrorAction SilentlyContinue +} Start-Sleep 3 # 2. Verknüpfungen entfernen — Autostart UND Desktop, jeweils an BEIDEN möglichen @@ -619,7 +625,7 @@ Start-Process cmd.exe -ArgumentList '/c timeout /t 4 /nobreak >nul & rd /s /q "' try { # Der Pfad zur .bat muss in Anführungszeichen, falls Leerzeichen drin sind. # schtasks erwartet diese Escaped-Anführungszeichen: \"C:\Pfad\...\" - $aktion = "\`"$InstallDir\start-tray.bat\`"" + $aktion = "\`"$InstallDir\start-gui.bat\`"" $process = Start-Process schtasks -ArgumentList "/create /tn `"RippyWorker`" /tr $aktion /sc ONLOGON /rl HIGHEST /f" -NoNewWindow -Wait -PassThru if ($process.ExitCode -eq 0) { @@ -655,20 +661,18 @@ Start-Process cmd.exe -ArgumentList '/c timeout /t 4 /nobreak >nul & rd /s /q "' } else { [Environment]::GetFolderPath("Desktop") } - $wsh = New-Object -ComObject WScript.Shell - $lnk = $wsh.CreateShortcut((Join-Path $desktopDir "Rippy Worker.lnk")) - $lnk.TargetPath = (Join-Path $InstallDir "start-tray.bat") - $lnk.WorkingDirectory = $InstallDir - $lnk.Description = "Rippy Encoding-Worker starten (Symbol neben der Uhr)" - $lnk.WindowStyle = 7 # minimiert, damit die .bat nicht aufblitzt - $ico = Join-Path $InstallDir "rippy.ico" - if (Test-Path $ico) { $lnk.IconLocation = $ico } - $lnk.Save() + $wshell = New-Object -ComObject WScript.Shell + $lnk = Join-Path $desktopDir "Rippy Worker.lnk" + $sh = $wshell.CreateShortcut($lnk) + $sh.TargetPath = Join-Path $InstallDir "start-gui.bat" + $sh.WorkingDirectory = $InstallDir + $sh.IconLocation = Join-Path $InstallDir "rippy.ico" + $sh.Save() Log "Verknüpfung 'Rippy Worker' auf dem Desktop angelegt." } catch { Log "HINWEIS: Desktop-Verknüpfung ging nicht ($($_.Exception.Message))." Log " Der Worker ist trotzdem fertig — starten über" - Log " $InstallDir\start-tray.bat" + Log " $InstallDir\start-gui.bat" } } @@ -702,8 +706,8 @@ $btnMap.Add_Click({ }) $btnInstall.Add_Click({ Do-Install }) $btnStart.Add_Click({ - Start-Process -FilePath (Join-Path $InstallDir "start-tray.bat") -WindowStyle Hidden - [System.Windows.Forms.MessageBox]::Show("Worker gestartet - Symbol erscheint unten rechts neben der Uhr.", "Rippy") + Start-Process -FilePath (Join-Path $InstallDir "start-gui.bat") -WindowStyle Hidden + [System.Windows.Forms.MessageBox]::Show("Worker-Dashboard gestartet.", "Rippy") $form.Close() }) diff --git a/deploy/worker-windows/install.ps1 b/deploy/worker-windows/install.ps1 index db39972..ecf84e6 100644 --- a/deploy/worker-windows/install.ps1 +++ b/deploy/worker-windows/install.ps1 @@ -1,4 +1,4 @@ -# Rippy: Nativer Windows-Transcode-Worker - Installation OHNE Docker. +# Rippy: Nativer Windows-Transcode-Worker - Installation OHNE Docker. # # Was das Skript tut (alles nach -InstallDir, Standard "C:\Program Files\Rippy Worker"): # 1. Prüft Python (3.10+), legt ein venv an, installiert die Abhängigkeiten @@ -190,10 +190,10 @@ venv\Scripts\celery.exe -A celery_app worker --loglevel=info -Q transcode $poolA "@ Set-Content -Path "start-worker.bat" -Value $bat -Encoding ASCII -# 7. Tray-Start (empfohlen): Symbol neben der Uhr, kein Konsolenfenster +# 7. GUI-Start (empfohlen): Modernes natives Fenster, kein Konsolenfenster $trayBat = @" @echo off -rem Rippy-Worker mit Tray-Symbol - verbindet sich mit $RippyHost +rem Rippy-Worker GUI - verbindet sich mit $RippyHost cd /d "%~dp0" set REDIS_URL=redis://${RippyHost}:6379/0 set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy @@ -203,10 +203,10 @@ set RIPPY_TRAY_HOST=$RippyHost set RIPPY_SLOTS=$Slots set TZ=UTC ${mapZeile}set PATH=%~dp0;%PATH% -start "" venv\Scripts\pythonw.exe tray.py +start "" venv\Scripts\pythonw.exe gui.py "@ -Set-Content -Path "start-tray.bat" -Value $trayBat -Encoding ASCII -Write-Host "start-worker.bat + start-tray.bat erzeugt." +Set-Content -Path "start-gui.bat" -Value $trayBat -Encoding ASCII +Write-Host "start-worker.bat + start-gui.bat erzeugt." # 8. Deinstaller - MUSS-Kriterium: rückstandsfrei entfernbar $uninstall = @" @@ -245,7 +245,7 @@ if (-not `$schreibbar -and -not `$istAdmin) { } Get-CimInstance Win32_Process | Where-Object { - `$_.ExecutablePath -like "`$PSScriptRoot*" + `$_.ExecutablePath -like "`$PSScriptRoot*" -or `$_.CommandLine -like "*gui.py*" -or `$_.CommandLine -like "*tray.py*" } | ForEach-Object { Stop-Process -Id `$_.ProcessId -Force -ErrorAction SilentlyContinue } Start-Sleep 2 # Autostart-Verknüpfung entfernen - BEIDE möglichen Orte, weil die @@ -324,23 +324,21 @@ if ($Autostart) { # eine Warnung; der Worker läuft davon unberührt. if (-not $KeineDesktopVerknuepfung) { try { - $programme = [Environment]::GetFolderPath("ProgramFiles") - $maschinenweit = $ziel.StartsWith($programme, "OrdinalIgnoreCase") - $desktopDir = if ($maschinenweit) { - [Environment]::GetFolderPath("CommonDesktopDirectory") + if ($KeineDesktopVerknuepfung) { + Write-Host "Desktop-Verknüpfung auf Wunsch übersprungen." } else { - [Environment]::GetFolderPath("Desktop") + $desktop = Join-Path ([Environment]::GetFolderPath("Desktop")) "Rippy Worker.lnk" + $wshell = New-Object -ComObject WScript.Shell + $shortcut = $wshell.CreateShortcut($desktop) + $shortcut.TargetPath = (Join-Path $ziel "start-gui.bat") + $shortcut.WorkingDirectory = $ziel + $shortcut.WindowStyle = 7 + if (Test-Path (Join-Path $ziel "rippy.ico")) { + $shortcut.IconLocation = (Join-Path $ziel "rippy.ico") + } + $shortcut.Save() + Write-Host "Desktop-Verknüpfung 'Rippy Worker' erstellt." } - $wsh = New-Object -ComObject WScript.Shell - $lnk = $wsh.CreateShortcut((Join-Path $desktopDir "Rippy Worker.lnk")) - $lnk.TargetPath = (Join-Path $ziel "start-tray.bat") - $lnk.WorkingDirectory = $ziel - $lnk.Description = "Rippy Encoding-Worker starten (Symbol neben der Uhr)" - $lnk.WindowStyle = 7 - $ico = Join-Path $ziel "rippy.ico" - if (Test-Path $ico) { $lnk.IconLocation = $ico } - $lnk.Save() - Write-Host "Verknüpfung 'Rippy Worker' auf dem Desktop angelegt." } catch { Write-Host "HINWEIS: Desktop-Verknüpfung ging nicht ($($_.Exception.Message))." -ForegroundColor Yellow } diff --git a/docker/worker/gui.py b/docker/worker/gui.py new file mode 100644 index 0000000..5498586 --- /dev/null +++ b/docker/worker/gui.py @@ -0,0 +1,464 @@ +import os +import subprocess +import sys +import threading +import time +import urllib.request +import json +import webbrowser +import flet as ft + +# Environment configuration +BASIS = os.path.dirname(os.path.abspath(__file__)) +RIPPY_HOST = os.getenv("RIPPY_TRAY_HOST", "") or os.getenv("RIPPY_HOST", "") +WORKER_NAME = os.getenv("WORKER_NAME", "windows-worker") +SLOTS = max(1, int(os.getenv("RIPPY_SLOTS", "1") or "1")) +ABFRAGE_TAKT_SEKUNDEN = 4 + +# Log file path +def _log_pfad() -> str: + basis_daten = os.getenv("LOCALAPPDATA") or os.getenv("APPDATA") or "" + if basis_daten: + ordner = os.path.join(basis_daten, "Rippy Worker") + os.makedirs(ordner, exist_ok=True) + return os.path.join(ordner, "worker.log") + return os.path.join(BASIS, "worker.log") + +LOG_PFAD = _log_pfad() + +# State variables +prozess = None +aktuell = {"job": None, "online": False} + +# Windows Standby blocker +_wach = {"an": False} + +def wach_halten(an: bool) -> None: + if _wach["an"] == an: + return + try: + import ctypes + ES_CONTINUOUS = 0x80000000 + ES_SYSTEM_REQUIRED = 0x00000001 + flags = (ES_CONTINUOUS | ES_SYSTEM_REQUIRED) if an else ES_CONTINUOUS + ctypes.windll.kernel32.SetThreadExecutionState(flags) + _wach["an"] = an + except Exception: + pass + +# Celery Subprocess Management +def _leser(prozess_ref, datei, log_callback) -> None: + try: + for zeile in prozess_ref.stdout: + try: + datei.write(zeile) + datei.flush() + except OSError: + pass + if log_callback: + log_callback(zeile) + except Exception: + pass + finally: + try: + datei.close() + except OSError: + pass + +def worker_laeuft() -> bool: + return prozess is not None and prozess.poll() is None + +def worker_starten(log_callback=None): + global prozess + if worker_laeuft(): + return + celery = os.path.join(BASIS, "venv", "Scripts", "celery.exe") + if not os.path.exists(celery): + celery = "celery" # fallback to global if testing outside venv + + befehl = [ + celery, "-A", "celery_app", "worker", "--loglevel=info", + "-Q", "transcode", "-n", f"{WORKER_NAME}@%h", + ] + befehl += (["--pool=solo"] if SLOTS == 1 else ["--pool=threads", f"--concurrency={SLOTS}"]) + + log = open(LOG_PFAD, "a", encoding="utf-8", errors="replace") + prozess = subprocess.Popen( + befehl, cwd=BASIS, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, bufsize=1, errors="replace", + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + threading.Thread( + target=_leser, args=(prozess, log, log_callback), + daemon=True, name="log-leser", + ).start() + +def worker_stoppen(): + global prozess + if worker_laeuft(): + prozess.terminate() + try: + prozess.wait(15) + except subprocess.TimeoutExpired: + prozess.kill() + prozess = None + wach_halten(False) + aktuell["job"] = None + +# API Polling +def hole(pfad: str, host: str = None, timeout: int = 5): + ziel = host if host is not None else RIPPY_HOST + if not ziel: + return None + try: + with urllib.request.urlopen(f"http://{ziel}/api{pfad}", timeout=timeout) as a: + return json.load(a) + except Exception: + return None + +def hole_job_und_zustand(): + caps = hole("/capabilities") + jobs = hole("/jobs") or [] + + online = False + w_info = {} + for w in ((caps or {}).get("workers") or []): + if w.get("name") == WORKER_NAME: + online = bool(w.get("online")) + w_info = w + break + + laufend = None + for job in jobs if isinstance(jobs, list) else []: + if job.get("status") in ("transcoding", "processing", "running"): + laufend = job + break + + aktuell["online"] = online + aktuell["job"] = laufend + wach_halten(bool(laufend)) + return online, laufend, caps, jobs, w_info + +def deinstallieren() -> None: + skript = os.path.join(BASIS, "uninstall.ps1") + if not os.path.isfile(skript): + return + subprocess.Popen( + ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", skript], + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + +def _bruecke_bauen(): + try: + import db + import logbruecke + + db.init_db() + return logbruecke.Bruecke( + WORKER_NAME, + schreiber=lambda level, quelle, text: db.add_log(level, quelle, text), + jetzt=time.monotonic, + ) + except Exception: + return None + +def _schluessel_wache(bruecke=None, log_callback=None): + try: + import schluessel + if not schluessel.makemkvcon_pfad(): + return + + def melden(level, text): + msg = f"[schluessel] {level.upper()} {text}" + if bruecke: + bruecke.zeile(msg) + if log_callback: + log_callback(msg + "\n") + + stand = None + while True: + try: + stand, _was = schluessel.runde(RIPPY_HOST, stand, melden=melden) + except Exception as e: + if log_callback: + log_callback(f"[schluessel] ERROR Schlüssel-Automatik fehlgeschlagen: {e}\n") + time.sleep(schluessel.TAKT_SEKUNDEN) + except ImportError: + pass + +# GUI Application +def main(page: ft.Page): + page.title = f"Rippy Worker — {WORKER_NAME}" + page.window.width = 900 + page.window.height = 700 + page.bgcolor = "#0f172a" # Rippy Slate-900 background + page.padding = 30 + page.theme_mode = ft.ThemeMode.DARK + page.fonts = { + "Inter": "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" + } + page.theme = ft.Theme(font_family="Inter") + + # Colors + c_bg = "#0f172a" + c_panel = "#1e293b" + c_text = "#e2e8f0" + c_muted = "#94a3b8" + c_accent = "#6366f1" # Indigo + c_success = "#10b981" # Emerald + c_warning = "#f59e0b" # Amber + c_danger = "#f43f5e" # Rose + + # Formatter for job status + def job_text(job: dict) -> str: + titel = (job.get("title") or (job.get("id") or "")[:8]) or "?" + status = { + "transcoding": "komprimiert", + "processing": "rippt", + "running": "rippt", + "pending": "wartet", + "completed": "fertig", + "failed": "Fehler", + "canceling": "bricht ab", + }.get(job.get("status") or "", job.get("status") or "?") + return f"{titel} — {status}" + + # UI Components + status_text = ft.Text("Verbinde...", color=c_muted, size=18, weight=ft.FontWeight.BOLD) + title_text = ft.Text(f"Rippy Worker: {WORKER_NAME}", size=24, weight=ft.FontWeight.BOLD, color=c_text) + machine_info = ft.Text("System-Infos werden geladen...", color=c_muted, size=13, font_family="Consolas") + + progress_ring = ft.ProgressRing(width=120, height=120, stroke_width=10, value=0, color=c_accent) + progress_percentage = ft.Text("0%", size=28, weight=ft.FontWeight.BOLD, color=c_text) + job_title = ft.Text("Nichts in Arbeit", size=20, color=c_muted) + eta_text = ft.Text("Bereit", size=14, color=c_muted) + + progress_stack = ft.Stack( + [ + progress_ring, + ft.Container( + content=progress_percentage, + alignment=ft.alignment.center, + width=120, + height=120, + ) + ], + width=120, + height=120, + ) + + log_view = ft.ListView(expand=1, spacing=2, auto_scroll=True) + queue_view = ft.ListView(height=120, spacing=4, auto_scroll=False) + + def add_log(zeile): + # Zeilen anfügen und limitieren + log_view.controls.append(ft.Text(zeile.strip(), color=c_muted, size=12, font_family="Consolas")) + if len(log_view.controls) > 200: + log_view.controls.pop(0) + try: + page.update() + except Exception: + pass + + def toggle_worker(e): + if worker_laeuft(): + worker_stoppen() + btn_toggle.text = "Worker Starten" + btn_toggle.icon = ft.icons.PLAY_ARROW + btn_toggle.bgcolor = c_success + else: + worker_starten(add_log) + btn_toggle.text = "Worker Stoppen" + btn_toggle.icon = ft.icons.STOP + btn_toggle.bgcolor = c_danger + page.update() + + btn_toggle = ft.ElevatedButton( + text="Worker Stoppen" if worker_laeuft() else "Worker Starten", + icon=ft.icons.STOP if worker_laeuft() else ft.icons.PLAY_ARROW, + bgcolor=c_danger if worker_laeuft() else c_success, + color="white", + on_click=toggle_worker + ) + + def uninstall_click(e): + def close_dlg(e): + dlg.open = False + page.update() + + def do_uninstall(e): + deinstallieren() + page.window.close() + + dlg = ft.AlertDialog( + title=ft.Text("Deinstallieren?"), + content=ft.Text(f"Worker '{WORKER_NAME}' wirklich entfernen? Das beendet den Prozess und entfernt ihn aus dem Autostart."), + actions=[ + ft.TextButton("Abbrechen", on_click=close_dlg), + ft.TextButton("Ja, Deinstallieren", on_click=do_uninstall, style=ft.ButtonStyle(color=c_danger)), + ], + actions_alignment=ft.MainAxisAlignment.END, + ) + page.overlay.append(dlg) + dlg.open = True + page.update() + + btn_uninstall = ft.TextButton( + text="Deinstallieren", + icon=ft.icons.DELETE, + icon_color=c_danger, + on_click=uninstall_click + ) + + btn_dashboard = ft.TextButton( + text="Rippy Dashboard", + icon=ft.icons.OPEN_IN_BROWSER, + on_click=lambda _: webbrowser.open(f"http://{RIPPY_HOST}") if RIPPY_HOST else None + ) + + btn_logs = ft.TextButton( + text="Log in Rippy", + icon=ft.icons.FORMAT_ALIGN_LEFT, + on_click=lambda _: webbrowser.open(f"http://{RIPPY_HOST}/logs") if RIPPY_HOST else None + ) + + btn_local_log = ft.TextButton( + text="Lokales Log", + icon=ft.icons.INSERT_DRIVE_FILE, + on_click=lambda _: os.startfile(LOG_PFAD) if os.path.exists(LOG_PFAD) else None + ) + + # Layout + header = ft.Row( + [title_text, ft.Container(expand=True), btn_dashboard, btn_logs, btn_local_log, btn_uninstall], + alignment=ft.MainAxisAlignment.SPACE_BETWEEN + ) + + dashboard_card = ft.Container( + content=ft.Column([ + status_text, + machine_info, + ft.Container(height=5), + ft.Row([ + progress_stack, + ft.Column([job_title, eta_text], alignment=ft.MainAxisAlignment.CENTER, spacing=10) + ], alignment=ft.MainAxisAlignment.START, spacing=30), + ft.Container(height=10), + btn_toggle + ]), + bgcolor=c_panel, + border_radius=15, + padding=30, + border=ft.border.all(1, "#334155") + ) + + queue_card = ft.Container( + content=ft.Column([ + ft.Text("Aufgaben (Warteschlange)", size=16, weight=ft.FontWeight.BOLD, color=c_text), + queue_view + ]), + bgcolor="#020617", + border_radius=10, + padding=15, + border=ft.border.all(1, "#1e293b") + ) + + terminal_card = ft.Container( + content=log_view, + bgcolor="#020617", + border_radius=10, + padding=15, + expand=True, + border=ft.border.all(1, "#1e293b") + ) + + page.add( + header, + ft.Container(height=10), + dashboard_card, + ft.Container(height=10), + queue_card, + ft.Container(height=10), + ft.Text("Live Logs", size=16, weight=ft.FontWeight.BOLD, color=c_text), + terminal_card + ) + + # Background polling loop + def poll_loop(): + while True: + try: + online, job, caps, jobs, w_info = hole_job_und_zustand() + + # Update System Info + info = w_info.get("info") or {} + teile = [t for t in ( + info.get("cpu_modell"), + f"{info.get('cpu_kerne')} Kerne" if info.get("cpu_kerne") else "", + info.get("cpu_simd") if info.get("cpu_simd") not in ("", "unbekannt") else "", + ", ".join(w_info.get("encoders") or []), + ) if t] + machine_info.value = " · ".join(teile) if teile else f"{SLOTS} Aufträge gleichzeitig möglich" + + # Update Queue + queue_view.controls.clear() + if not jobs: + queue_view.controls.append(ft.Text("Keine Aufgaben in Rippy", color=c_muted, font_family="Consolas", size=13)) + else: + for j in jobs[:8]: + queue_view.controls.append(ft.Text(" " + job_text(j), color=c_muted, font_family="Consolas", size=13)) + + # Update status + if not worker_laeuft(): + status_text.value = "Worker-Prozess gestoppt" + status_text.color = c_danger + progress_ring.value = 0 + progress_percentage.value = "0%" + job_title.value = "Nichts in Arbeit" + eta_text.value = "Bereit" + elif not online: + status_text.value = "Nicht mit Rippy verbunden (offline)" + status_text.color = c_warning + progress_ring.value = 0 + progress_percentage.value = "0%" + elif job: + status_text.value = "Aktiv: Komprimiert" + status_text.color = c_success + prog = float(job.get("progress") or 0) + progress_ring.value = prog / 100.0 + progress_percentage.value = f"{int(prog)}%" + titel = job.get("title") or (job.get("id") or "")[:8] + job_title.value = titel + eta = job.get("eta_text") or "Restzeit wird berechnet..." + eta_text.value = f"Verbleibend: {eta}" + else: + status_text.value = "Verbunden & Bereit" + status_text.color = c_accent + progress_ring.value = 0 + progress_percentage.value = "0%" + job_title.value = "Warte auf Aufträge..." + eta_text.value = "Nichts in Arbeit" + + page.update() + except Exception as e: + pass + time.sleep(ABFRAGE_TAKT_SEKUNDEN) + + # Start Celery automatically + worker_starten(add_log) + + # Init initial logs + if os.path.exists(LOG_PFAD): + try: + with open(LOG_PFAD, "r", encoding="utf-8", errors="replace") as f: + lines = f.readlines()[-50:] + for line in lines: + add_log(line) + except OSError: + pass + + threading.Thread(target=poll_loop, daemon=True).start() + threading.Thread(target=_schluessel_wache, args=(_bruecke_bauen(), add_log), daemon=True, name="schluessel-wache").start() + +if __name__ == "__main__": + ft.app(target=main) diff --git a/docker/worker/requirements.txt b/docker/worker/requirements.txt index 099916b..75d4246 100644 --- a/docker/worker/requirements.txt +++ b/docker/worker/requirements.txt @@ -3,3 +3,4 @@ redis==5.0.4 requests==2.32.3 sqlalchemy==2.0.32 psycopg2-binary==2.9.9 +flet==0.23.2 diff --git a/docker/worker/tray.py b/docker/worker/tray.py deleted file mode 100644 index 8a40b8f..0000000 --- a/docker/worker/tray.py +++ /dev/null @@ -1,423 +0,0 @@ -"""Tray-Symbol für den NATIVEN Windows-Worker (nicht im Docker-Image genutzt). - -Zeigt neben der Uhr, was diese Maschine gerade tut, und bietet Start/Stopp, -Rippy im Browser, Log und Beenden. Gestartet wird es über start-tray.bat (setzt -die Verbindungs-Umgebung und ruft pythonw.exe auf — kein Konsolenfenster). Der -Worker-Prozess (celery, Queue transcode) läuft als Kind-Prozess und stoppt mit -dem Tray. - -## Was diese Runde dazugekommen ist (26.07.2026, Commander: „der Worker ist ein -## bisschen dünn") - -1. **Es zeigt, WAS läuft.** Vorher stand im Menü nur „läuft" oder „gestoppt" — - auf einer Maschine, die stundenlang an einem Film rechnet, ist das keine - Auskunft. Jetzt: Titel, Prozent und Restzeit, geholt von Rippys `/jobs` - (die Restzeit rechnet Rippy, siehe api/eta.py — sie ist damit dieselbe Zahl - wie im Dashboard und nicht eine zweite, abweichende Schätzung). -2. **Windows schläft nicht mehr mitten im Encode ein.** `SetThreadExecutionState` - hält die Maschine wach, solange wirklich etwas läuft — und lässt sie danach - sofort wieder einschlafen. -3. **Das Log geht nach Rippy** (logbruecke.py). Die lokale Datei bleibt als - Rückfall, denn sie ist genau dann die einzige Auskunft, wenn Rippy nicht - erreichbar ist. - -Abhängigkeiten pystray + Pillow installiert NUR der Windows-Installer -(install.ps1) — das Docker-Image bleibt davon unberührt. -""" - -import os -import subprocess -import sys -import threading -import time -import webbrowser - -try: - import pystray - from PIL import Image, ImageDraw -except ImportError: - print("Tray braucht pystray + Pillow — install.ps1 erneut ausführen.") - sys.exit(1) - -BASIS = os.path.dirname(os.path.abspath(__file__)) -RIPPY_HOST = os.getenv("RIPPY_TRAY_HOST", "") -WORKER_NAME = os.getenv("WORKER_NAME", "windows-worker") -# Wie viele Aufträge dieser Worker gleichzeitig annimmt. Siehe Kommentar bei -# worker_starten() — auf Windows ist der Pool `threads`, nicht `prefork`. -SLOTS = max(1, int(os.getenv("RIPPY_SLOTS", "1") or "1")) - -# Wie oft beim Dashboard nachgefragt wird, was läuft. Fünf Sekunden sind für ein -# Tray-Menü reichlich und belasten die API nicht (der Endpunkt ist ohnehin der, -# den das Dashboard alle vier Sekunden abfragt). -ABFRAGE_TAKT_SEKUNDEN = 5 - - -def _log_pfad() -> str: - """Wohin das Worker-Log geschrieben wird. - - NICHT ins Programmverzeichnis (Befund 25.07.2026, Commander-Wunsch): Der - Worker wird jetzt standardmäßig nach `C:\\Program Files\\Rippy Worker` - installiert, und dort darf ein normaler Benutzer nicht schreiben. Ein Log - neben dem Programm hätte den Worker beim Starten scheitern lassen — genau - die Art Fehler, die man erst merkt, wenn nichts passiert. - - Deshalb ins Benutzerprofil (`%LOCALAPPDATA%\\Rippy Worker`), wie es sich für - veränderliche Daten gehört. Nur falls das nicht klappt, bleibt das - Programmverzeichnis der Rückfall — bei einer Installation ins Benutzerprofil - (frei wählbar) ist es ohnehin beschreibbar. - """ - basis_daten = os.getenv("LOCALAPPDATA") or os.getenv("APPDATA") or "" - if basis_daten: - ordner = os.path.join(basis_daten, "Rippy Worker") - try: - os.makedirs(ordner, exist_ok=True) - return os.path.join(ordner, "worker.log") - except OSError: - pass - return os.path.join(BASIS, "worker.log") - - -LOG_PFAD = _log_pfad() - -prozess = None -# Was Rippy gerade meldet: None = nichts in Arbeit, sonst -# {"titel": str, "progress": int, "eta": str}. -aktuell = {"job": None} - - -def disc_bild(laeuft: bool) -> Image.Image: - """Disc-Symbol im Rippy-Look: Indigo wenn der Worker läuft, grau sonst.""" - bild = Image.new("RGBA", (64, 64), (0, 0, 0, 0)) - zeichner = ImageDraw.Draw(bild) - farbe = (99, 102, 241, 255) if laeuft else (125, 132, 145, 255) - zeichner.ellipse([4, 4, 60, 60], fill=farbe) - zeichner.ellipse([24, 24, 40, 40], fill=(255, 255, 255, 255)) - return bild - - -def worker_laeuft() -> bool: - return prozess is not None and prozess.poll() is None - - -# --- Windows am Einschlafen hindern ---------------------------------------- -# -# Ein 4K-Encode läuft Stunden. Schläft der PC dabei ein, ist der Job weg — und -# im UI steht bis zum nächsten Worker-Start eine Job-Leiche. Die Zustandsflags -# stehen in winbase.h; ES_CONTINUOUS hält den Zustand, bis er zurückgenommen -# wird. Bewusst OHNE ES_DISPLAY_REQUIRED: der Bildschirm darf ausgehen. -ES_CONTINUOUS = 0x80000000 -ES_SYSTEM_REQUIRED = 0x00000001 - -_wach = {"an": False} - - -def wach_halten(an: bool) -> None: - """Verhindert (an=True) bzw. erlaubt wieder (an=False) den Standby.""" - if _wach["an"] == an: - return - try: - import ctypes - - flags = (ES_CONTINUOUS | ES_SYSTEM_REQUIRED) if an else ES_CONTINUOUS - ctypes.windll.kernel32.SetThreadExecutionState(flags) - _wach["an"] = an - except (ImportError, AttributeError, OSError): - pass # kein Windows oder Aufruf verweigert — dann eben ohne - - -def _log_weiterleiten(zeile: str, bruecke, datei) -> None: - """Eine Ausgabezeile in die Datei UND (wenn wichtig) nach Rippy.""" - try: - datei.write(zeile) - datei.flush() - except OSError: - pass - if bruecke: - bruecke.zeile(zeile) - - -def _leser(prozess_ref, bruecke, datei) -> None: - """Liest die Ausgabe des Worker-Prozesses, solange er lebt.""" - try: - for zeile in prozess_ref.stdout: - _log_weiterleiten(zeile, bruecke, datei) - except Exception: - pass - finally: - try: - datei.close() - except OSError: - pass - - -def _bruecke_bauen(): - """Log-Brücke nach Rippy — oder None, wenn die Datenbank nicht erreichbar ist. - - Scheitert der Aufbau, läuft der Worker ohne Brücke weiter und das lokale Log - bleibt die Auskunft. Ein Tray, das wegen der Log-Anbindung nicht startet, - wäre die schlechtere Wahl. - """ - try: - import db - import logbruecke - - db.init_db() - return logbruecke.Bruecke( - WORKER_NAME, - schreiber=lambda level, quelle, text: db.add_log(level, quelle, text), - jetzt=time.monotonic, - ) - except Exception: - return None - - -def worker_starten(icon=None, item=None): - global prozess - if worker_laeuft(): - return - celery = os.path.join(BASIS, "venv", "Scripts", "celery.exe") - # Auf Windows gibt es KEINEN prefork-Pool (kein fork). `solo` bediente - # genau einen Auftrag; mit `threads` sind mehrere möglich, und das passt - # hier, weil die eigentliche Arbeit ein Kind-Prozess ist (HandBrake) und der - # Thread nur darauf wartet. - befehl = [ - celery, "-A", "celery_app", "worker", "--loglevel=info", - "-Q", "transcode", "-n", f"{WORKER_NAME}@%h", - ] - befehl += (["--pool=solo"] if SLOTS == 1 - else ["--pool=threads", f"--concurrency={SLOTS}"]) - log = open(LOG_PFAD, "a", encoding="utf-8", errors="replace") - prozess = subprocess.Popen( - befehl, cwd=BASIS, - stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - text=True, bufsize=1, errors="replace", - creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), - ) - threading.Thread( - target=_leser, args=(prozess, _bruecke_bauen(), log), - daemon=True, name="log-leser", - ).start() - if icon: - icon.icon = disc_bild(True) - icon.update_menu() - - -def worker_stoppen(icon=None, item=None): - global prozess - if worker_laeuft(): - prozess.terminate() - try: - prozess.wait(15) - except subprocess.TimeoutExpired: - prozess.kill() - prozess = None - wach_halten(False) - aktuell["job"] = None - if icon: - icon.icon = disc_bild(False) - icon.update_menu() - - -def umschalten(icon, item): - if worker_laeuft(): - worker_stoppen(icon) - else: - worker_starten(icon) - - -def _job_holen(): - """Was komprimiert Rippy gerade? (dict oder None) - - Gefragt wird Rippys `/jobs` — dieselbe Quelle wie das Dashboard, damit im - Tray nicht eine zweite, abweichende Zahl steht. Die Restzeit rechnet die API - (api/eta.py), hier wird sie nur angezeigt. - """ - if not RIPPY_HOST: - return None - try: - import json - import urllib.request - - with urllib.request.urlopen( - f"http://{RIPPY_HOST}/api/jobs", timeout=8 - ) as antwort: - jobs = json.load(antwort) - except Exception: - return None - for job in jobs if isinstance(jobs, list) else []: - if job.get("status") == "transcoding": - return { - "titel": job.get("title") or (job.get("id") or "")[:8], - "progress": job.get("progress") or 0, - "eta": job.get("eta_text") or "", - } - return None - - -def _schluessel_wache(bruecke=None): - """Holt 4K-Disc-Schlüssel und schiebt sie zu Rippy (siehe schluessel.py). - - Der Sinn in einem Satz: `makemkvcon` unter LINUX ruft Disc-Schlüssel nie ab, - die WINDOWS-Version schon — und dieser Worker läuft auf Windows. Damit fällt - die Handarbeit weg, `_private_data.tar` nach jeder neuen UHD-Disc selbst - hinüberzutragen. - - Schaltet sich selbst ab, wenn MakeMKV hier nicht installiert ist: Auf einem - reinen Encoding-PC gibt es nichts zu holen, und eine Schleife, die jede - Minute ins Leere greift, wäre nur Rauschen. - """ - import schluessel - - if not schluessel.makemkvcon_pfad(): - return - - def melden(level, text): - if bruecke: - bruecke.zeile(f"[schluessel] {level.upper()} {text}") - print(f"Schlüssel-Automatik: {text}") - - stand = None - while True: - try: - stand, _was = schluessel.runde(RIPPY_HOST, stand, melden=melden) - except Exception as e: # darf den Worker nie mitnehmen - print(f"Schlüssel-Automatik fehlgeschlagen: {type(e).__name__}: {e}") - time.sleep(schluessel.TAKT_SEKUNDEN) - - -def _beobachter(icon): - """Fragt im Takt nach, was läuft — für Menütext und Standby-Sperre.""" - while True: - job = _job_holen() if worker_laeuft() else None - aktuell["job"] = job - # Wach bleiben nur, solange wirklich etwas läuft. - wach_halten(bool(job)) - try: - icon.update_menu() - except Exception: - pass - time.sleep(ABFRAGE_TAKT_SEKUNDEN) - - -def status_text(item=None) -> str: - """Die erste Menüzeile — sagt, was die Maschine tut.""" - if not worker_laeuft(): - return f'Rippy-Worker "{WORKER_NAME}": gestoppt' - job = aktuell["job"] - if not job: - return f'Rippy-Worker "{WORKER_NAME}": bereit, nichts in Arbeit' - return f"Komprimiert: {job['titel']} — {job['progress']} %" - - -def restzeit_text(item=None) -> str: - job = aktuell["job"] - if not job: - return f"{SLOTS} Auftrag gleichzeitig" if SLOTS == 1 else f"{SLOTS} Aufträge gleichzeitig" - return job["eta"] or "Restzeit wird gemessen" - - -def rippy_oeffnen(icon, item): - if RIPPY_HOST: - webbrowser.open(f"http://{RIPPY_HOST}") - - -def log_in_rippy(icon, item): - """Rippys Log-Seite öffnen — Commander-Wunsch: kein txt-File.""" - if RIPPY_HOST: - webbrowser.open(f"http://{RIPPY_HOST}/logs") - - -def log_datei_anzeigen(icon, item): - """Rückfall: das lokale Log. Genau dann die einzige Auskunft, wenn Rippy - nicht erreichbar ist — deshalb bleibt der Eintrag.""" - if os.path.exists(LOG_PFAD): - os.startfile(LOG_PFAD) # noqa: S606 — bewusst: Standard-Editor des Nutzers - - -# Das Verwaltungsfenster läuft als EIGENER Prozess (Begründung in -# verwaltung.py: pystray und tkinter wollen beide den Haupt-Thread). -_verwaltung = {"prozess": None} - - -def verwaltung_oeffnen(icon=None, item=None): - """Kleines Fenster mit Status, Aufgaben und Log (Commander-Wunsch 26.07.2026). - - Ist es schon offen, wird kein zweites gestartet — sonst sammeln sich bei - jedem Doppelklick Fenster an. - """ - laeuft = _verwaltung["prozess"] - if laeuft is not None and laeuft.poll() is None: - return - pythonw = os.path.join(BASIS, "venv", "Scripts", "pythonw.exe") - if not os.path.exists(pythonw): - pythonw = sys.executable - _verwaltung["prozess"] = subprocess.Popen( - [pythonw, os.path.join(BASIS, "verwaltung.py")], - cwd=BASIS, - creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), - ) - - -def deinstallieren(icon, item): - """Deinstallation aus dem Tray heraus (Commander-Wunsch 26.07.2026). - - Das Skript fragt selbst nach (ohne -Force), stoppt alles aus diesem Ordner — - also auch dieses Tray — und räumt danach auf. Deshalb wird hier nichts mehr - erwartet: Der eigene Prozess ist gleich weg. - """ - skript = os.path.join(BASIS, "uninstall.ps1") - if not os.path.exists(skript): - return - subprocess.Popen( - ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", - "-File", skript], - creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), - ) - - -def beenden(icon, item): - worker_stoppen() - icon.stop() - - -menue = pystray.Menu( - pystray.MenuItem(status_text, None, enabled=False), - pystray.MenuItem(restzeit_text, None, enabled=False), - pystray.Menu.SEPARATOR, - # `default=True`: Dieser Eintrag wird beim Doppelklick aufs Symbol - # ausgelöst — genau das hat der Commander gewünscht. Er steht zusätzlich - # normal im Menü, damit es auch klappt, wenn der Doppelklick nicht greift. - pystray.MenuItem("Verwaltung öffnen", verwaltung_oeffnen, default=True), - pystray.MenuItem( - lambda item: "Worker stoppen" if worker_laeuft() else "Worker starten", - umschalten, - ), - pystray.MenuItem("Rippy öffnen", rippy_oeffnen), - pystray.MenuItem("Log in Rippy anzeigen", log_in_rippy), - pystray.MenuItem("Log-Datei auf diesem PC", log_datei_anzeigen), - pystray.Menu.SEPARATOR, - pystray.MenuItem("Worker deinstallieren …", deinstallieren), - pystray.MenuItem("Beenden (stoppt den Worker)", beenden), -) - - -if __name__ == "__main__": - tray = pystray.Icon("rippy-worker", disc_bild(False), "Rippy Worker", menue) - worker_starten() - tray.icon = disc_bild(worker_laeuft()) - threading.Thread( - target=_beobachter, args=(tray,), daemon=True, name="job-beobachter" - ).start() - # Schlüssel-Automatik für 4K-UHD. Eigene Log-Brücke, damit ihre Meldungen - # auch in Rippy landen ("Schlüsselspeicher übergeben") — das ist genau die - # Auskunft, auf die man nach dem Einlegen einer neuen UHD-Disc wartet. - threading.Thread( - target=_schluessel_wache, args=(_bruecke_bauen(),), - daemon=True, name="schluessel-wache", - ).start() - try: - tray.run() - finally: - # Auch bei einem harten Ende die Standby-Sperre zurücknehmen — sonst - # schläft der PC nie wieder ein, und niemand weiß warum. - wach_halten(False) diff --git a/docker/worker/verwaltung.py b/docker/worker/verwaltung.py deleted file mode 100644 index e0a771b..0000000 --- a/docker/worker/verwaltung.py +++ /dev/null @@ -1,274 +0,0 @@ -"""Kleines Verwaltungsfenster für den Windows-Worker (Doppelklick aufs Tray). - -Commander-Wunsch 26.07.2026: *„Es wäre cool wenn man einfach mit nem Doppelklick -ne kleine Verwaltung hat wo man direkt Logs, Aufgaben, usw. sieht."* - -## Warum ein eigener PROZESS und kein Fenster im Tray - -pystray belegt mit `icon.run()` den Haupt-Thread, und tkinter will seine -Ereignisschleife ebenfalls dort haben. Beides in einem Prozess zu verschränken -ist eine bekannte Quelle für Fenster, die sich nicht mehr schließen lassen. Als -eigener Prozess (`pythonw.exe verwaltung.py`) gibt es das Problem gar nicht: Das -Fenster erbt die Umgebung vom Tray (Rippy-Adresse, Worker-Name) und kann -abstürzen, ohne den Worker mitzunehmen. - -## Was es anzeigt — und woher - -Alles Fachliche kommt von RIPPY, nicht aus eigener Rechnung: Ob dieser Worker als -erreichbar gilt, steht in `/capabilities`; was gerade läuft und wie lange es noch -dauert, in `/jobs`. So zeigt das Fenster dieselben Zahlen wie das Dashboard statt -einer zweiten, abweichenden Wahrheit. - -Nur das Log kommt lokal: Es ist genau dann die einzige Auskunft, wenn Rippy nicht -erreichbar ist. - -tkinter statt WinForms, weil es bei jeder Windows-Python-Installation dabei ist — -der Installer soll keine weitere Abhängigkeit brauchen. -""" - -import json -import os -import subprocess -import sys -import urllib.request -import webbrowser - -RIPPY_HOST = os.getenv("RIPPY_TRAY_HOST", "") -WORKER_NAME = os.getenv("WORKER_NAME", "windows-worker") -SLOTS = os.getenv("RIPPY_SLOTS", "1") -BASIS = os.path.dirname(os.path.abspath(__file__)) - -# Farben aus dem Rippy-UI (docker/ui/src/lib/design.ts), damit das Fenster nicht -# wie ein Fremdkörper wirkt. -BG = "#0f172a" -PANEL = "#020617" -TEXT = "#e2e8f0" -GEDAEMPFT = "#94a3b8" -AMBER = "#f59e0b" -EMERALD = "#10b981" -ROSE = "#f43f5e" - -LOG_ZEILEN = 200 -TAKT_MS = 4000 - - -def letzte_zeilen(pfad: str, anzahl: int = LOG_ZEILEN, oeffnen=None) -> list: - """Die letzten `anzahl` Zeilen einer Datei (pure genug für einen Test). - - Liest bewusst die GANZE Datei und schneidet ab: Das Worker-Log wird nicht - groß (die Brücke drosselt auf 30 Zeilen/Minute), und ein Rückwärts-Suchen - über Blockgrenzen wäre mehr Code als Nutzen. Fehler beim Lesen geben eine - leere Liste — ein Fenster, das wegen des Logs nicht aufgeht, wäre schlimmer. - """ - macher = oeffnen or (lambda p: open(p, encoding="utf-8", errors="replace")) - try: - with macher(pfad) as f: - return f.read().splitlines()[-anzahl:] - except OSError: - return [] - - -def hole(pfad: str, host: str = None, timeout: int = 8): - """GET auf Rippys API — None, wenn es nicht klappt.""" - ziel = host if host is not None else RIPPY_HOST - if not ziel: - return None - try: - with urllib.request.urlopen(f"http://{ziel}/api{pfad}", timeout=timeout) as a: - return json.load(a) - except Exception: - return None - - -def worker_zustand(capabilities, name: str) -> dict: - """Was Rippy über DIESEN Worker weiß (pure Funktion). - - Rückgabe: {"bekannt": bool, "online": bool, "encoders": [...], "info": {...}} - """ - for w in ((capabilities or {}).get("workers") or []): - if w.get("name") == name: - return { - "bekannt": True, - "online": bool(w.get("online")), - "encoders": w.get("encoders") or [], - "info": w.get("info") or {}, - } - return {"bekannt": False, "online": False, "encoders": [], "info": {}} - - -def job_text(job: dict) -> str: - """Eine Zeile für die Aufgabenliste (pure Funktion).""" - titel = (job.get("title") or (job.get("id") or "")[:8]) or "?" - status = { - "transcoding": "komprimiert", - "processing": "rippt", - "running": "rippt", - "pending": "wartet", - "completed": "fertig", - "failed": "Fehler", - "canceling": "bricht ab", - }.get(job.get("status") or "", job.get("status") or "?") - zeile = f"{titel} — {status}" - if job.get("status") in ("transcoding", "processing", "running"): - zeile += f", {job.get('progress') or 0} %" - if job.get("eta_text"): - zeile += f" ({job['eta_text']})" - return zeile - - -def status_text(zustand: dict, aktueller_job: dict) -> tuple: - """(Text, Farbe) für die Statuszeile (pure Funktion).""" - if not zustand.get("bekannt"): - return ("Rippy kennt diesen Worker nicht — läuft er? Ist die Adresse richtig?", ROSE) - if not zustand.get("online"): - return ("Bei Rippy als NICHT erreichbar gemeldet", ROSE) - if aktueller_job: - return (job_text(aktueller_job), AMBER) - return ("Erreichbar und bereit — nichts in Arbeit", EMERALD) - - -def deinstallieren() -> None: - """Startet den Deinstaller im selben Ordner (fragt dort selbst nach).""" - skript = os.path.join(BASIS, "uninstall.ps1") - if not os.path.isfile(skript): - return - subprocess.Popen( - ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", - "-File", skript], - creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), - ) - - -def _log_pfad() -> str: - """Dieselbe Wahl wie im Tray (dort ausführlich begründet).""" - basis_daten = os.getenv("LOCALAPPDATA") or os.getenv("APPDATA") or "" - if basis_daten: - kandidat = os.path.join(basis_daten, "Rippy Worker", "worker.log") - if os.path.exists(kandidat): - return kandidat - return os.path.join(BASIS, "worker.log") - - -def fenster_bauen(): # pragma: no cover — reine Oberfläche - import tkinter as tk - from tkinter import scrolledtext - - wurzel = tk.Tk() - wurzel.title(f"Rippy Worker — {WORKER_NAME}") - wurzel.geometry("760x620") - wurzel.configure(bg=BG) - - kopf = tk.Frame(wurzel, bg=BG) - kopf.pack(fill="x", padx=16, pady=(14, 6)) - tk.Label(kopf, text=f"Rippy Worker „{WORKER_NAME}“", bg=BG, fg=TEXT, - font=("Segoe UI", 14, "bold")).pack(anchor="w") - tk.Label(kopf, text=f"Rippy: {RIPPY_HOST or 'keine Adresse gesetzt'} · " - f"{SLOTS} Auftrag/Aufträge gleichzeitig", - bg=BG, fg=GEDAEMPFT, font=("Segoe UI", 9)).pack(anchor="w") - - status = tk.Label(wurzel, text="wird geladen …", bg=BG, fg=GEDAEMPFT, - font=("Segoe UI", 10, "bold"), anchor="w", justify="left", - wraplength=720) - status.pack(fill="x", padx=16, pady=(8, 2)) - - maschine = tk.Label(wurzel, text="", bg=BG, fg=GEDAEMPFT, - font=("Consolas", 9), anchor="w", justify="left") - maschine.pack(fill="x", padx=16) - - tk.Label(wurzel, text="Aufgaben", bg=BG, fg=TEXT, - font=("Segoe UI", 10, "bold")).pack(anchor="w", padx=16, pady=(12, 2)) - aufgaben = tk.Listbox(wurzel, height=6, bg=PANEL, fg=TEXT, - font=("Consolas", 9), borderwidth=0, - highlightthickness=1, highlightbackground="#1e293b", - selectbackground="#1e293b") - aufgaben.pack(fill="x", padx=16) - - tk.Label(wurzel, text="Log dieser Maschine", bg=BG, fg=TEXT, - font=("Segoe UI", 10, "bold")).pack(anchor="w", padx=16, pady=(12, 2)) - log = scrolledtext.ScrolledText(wurzel, height=12, bg=PANEL, fg=GEDAEMPFT, - font=("Consolas", 8), borderwidth=0, - highlightthickness=1, - highlightbackground="#1e293b", wrap="none") - log.pack(fill="both", expand=True, padx=16) - - knopf_reihe = tk.Frame(wurzel, bg=BG) - knopf_reihe.pack(fill="x", padx=16, pady=12) - - def knopf(text, aktion, farbe=AMBER, vordergrund=BG): - b = tk.Button(knopf_reihe, text=text, command=aktion, bg=farbe, - fg=vordergrund, relief="flat", font=("Segoe UI", 9, "bold"), - padx=12, pady=6, cursor="hand2") - b.pack(side="left", padx=(0, 8)) - return b - - knopf("Rippy öffnen", lambda: RIPPY_HOST and webbrowser.open(f"http://{RIPPY_HOST}")) - knopf("Log in Rippy", lambda: RIPPY_HOST and webbrowser.open(f"http://{RIPPY_HOST}/logs"), - farbe="#1e293b", vordergrund=TEXT) - - def deinstallieren_fragen(): - from tkinter import messagebox - - if messagebox.askyesno( - "Rippy Worker deinstallieren", - f"Den Worker „{WORKER_NAME}“ von diesem PC entfernen?\n\n" - "Der Ordner wird gelöscht, der Autostart entfernt und der Eintrag in " - "Rippy abgemeldet. Gerippte Dateien bleiben unberührt.", - ): - deinstallieren() - wurzel.destroy() - - knopf("Deinstallieren", deinstallieren_fragen, farbe=ROSE, vordergrund="white") - - log_pfad = _log_pfad() - - def auffrischen(): - caps = hole("/capabilities") - jobs = hole("/jobs") or [] - zustand = worker_zustand(caps, WORKER_NAME) - laufend = next((j for j in jobs if j.get("status") == "transcoding"), None) - - text, farbe = status_text(zustand, laufend) - status.configure(text=text, fg=farbe) - - info = zustand.get("info") or {} - teile = [t for t in ( - info.get("cpu_modell"), - f"{info.get('cpu_kerne')} Kerne" if info.get("cpu_kerne") else "", - info.get("cpu_simd") if info.get("cpu_simd") not in ("", "unbekannt") else "", - ", ".join(zustand.get("encoders") or []), - ) if t] - maschine.configure(text=" · ".join(teile)) - - aufgaben.delete(0, "end") - if not jobs: - aufgaben.insert("end", " keine Aufgaben in Rippy") - for job in jobs[:8]: - aufgaben.insert("end", " " + job_text(job)) - - neu = "\n".join(letzte_zeilen(log_pfad)) - if neu != log.get("1.0", "end").strip(): - am_ende = log.yview()[1] >= 0.999 - log.delete("1.0", "end") - log.insert("1.0", neu or "(noch keine Zeilen — das Log füllt sich, " - "sobald der Worker etwas tut)") - if am_ende: - log.see("end") - - wurzel.after(TAKT_MS, auffrischen) - - auffrischen() - return wurzel - - -if __name__ == "__main__": # pragma: no cover - try: - fenster_bauen().mainloop() - except Exception as e: - # Ohne Konsole (pythonw) wäre ein Traceback unsichtbar — also sagen, was - # los ist, statt still zu verschwinden. - try: - import tkinter.messagebox as mb - - mb.showerror("Rippy Worker", f"Verwaltung konnte nicht öffnen:\n{e}") - except Exception: - print(f"Verwaltung konnte nicht öffnen: {e}", file=sys.stderr)