feat: Worker V2 Flet GUI mit Feature-Parität und neuem Installer

This commit is contained in:
Hitonabi
2026-07-26 22:01:27 +02:00
parent e165e2a0d3
commit 9a151f3d8b
7 changed files with 507 additions and 737 deletions
Binary file not shown.
+22 -18
View File
@@ -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()
})
+20 -22
View File
@@ -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
}
+464
View File
@@ -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)
+1
View File
@@ -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
-423
View File
@@ -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)
-274
View File
@@ -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)