feat: Worker V2 Flet GUI mit Feature-Parität und neuem Installer
This commit is contained in:
Binary file not shown.
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user