8 Commits

Author SHA1 Message Date
Hitonabi a14449ef85 fix(worker-ui): fix syntax error in gui.py and ruff error in test_verwaltung.py
Ampel / ampel (push) Successful in 39s
2026-07-27 17:17:30 +02:00
Hitonabi 4343b0901d feat(worker-ui): adjust thumbnails, log view, and button logic to match web dashboard
Ampel / ampel (push) Failing after 40s
2026-07-27 17:16:00 +02:00
Hitonabi c3e77399ad fix(worker): mock ntpath.dirname in test_erreichbarkeit so it passes on Linux CI runner
Ampel / ampel (push) Failing after 39s
2026-07-27 17:13:45 +02:00
Hitonabi 75426224d1 fix(worker): ensure sys.path in test_verwaltung for pytest importlib mode
Ampel / ampel (push) Failing after 39s
2026-07-27 17:11:11 +02:00
Hitonabi e5ff5373bb fix(worker): restore pure logic module verwaltung.py and its tests (SoC)
Ampel / ampel (push) Failing after 39s
2026-07-27 17:06:38 +02:00
Hitonabi 9b30c0a490 chore: remove obsolete test_verwaltung.py
Ampel / ampel (push) Failing after 40s
2026-07-27 17:02:37 +02:00
Hitonabi 061f79ecda fix: ruff linter errors (multiple statements, bare except, unused vars)
Ampel / ampel (push) Failing after 38s
2026-07-27 17:00:52 +02:00
Hitonabi a32a1bb075 fix: bare except in main.py
Ampel / ampel (push) Failing after 38s
2026-07-27 16:56:36 +02:00
6 changed files with 1450 additions and 994 deletions
+412 -271
View File
@@ -1,271 +1,412 @@
import flet as ft import flet as ft
import os import os
import subprocess import subprocess
import threading import threading
import urllib.request import urllib.request
import json import json
import time import zipfile
import zipfile
def main(page: ft.Page): def main(page: ft.Page):
page.title = "Rippy Encoding-Worker Installation" page.title = "Rippy Encoding-Worker Installation"
page.window_width = 800 page.window_width = 800
page.window_height = 650 page.window_height = 650
page.bgcolor = "#0f172a" page.bgcolor = "#0f172a"
page.theme_mode = ft.ThemeMode.DARK page.theme_mode = ft.ThemeMode.DARK
page.scroll = ft.ScrollMode.AUTO page.scroll = ft.ScrollMode.AUTO
# Colors # Colors
c_panel = "#1e293b" c_panel = "#1e293b"
c_text = "#e2e8f0" c_text = "#e2e8f0"
c_muted = "#94a3b8" c_muted = "#94a3b8"
c_accent = "#6366f1" c_accent = "#6366f1"
c_success = "#10b981" c_success = "#10b981"
# State # State
std_ziel = r"C:\Program Files\Rippy Worker" std_ziel = r"C:\Program Files\Rippy Worker"
# UI Fields # UI Fields
txt_host = ft.TextField(label="LAN-IP der Rippy-Maschine (Docker-Host)", width=400, bgcolor=c_panel, color=c_text) txt_host = ft.TextField(
txt_name = ft.TextField(label="Name dieses Workers (frei wählbar)", width=250, value=os.environ.get("COMPUTERNAME", "worker").lower(), bgcolor=c_panel, color=c_text) label="LAN-IP der Rippy-Maschine (Docker-Host)",
txt_slots = ft.Dropdown( width=400,
label="Kerne (Gleichzeitig)", bgcolor=c_panel,
width=150, color=c_text,
options=[ft.dropdown.Option(str(i)) for i in range(1, 9)], )
value="2" if os.cpu_count() and os.cpu_count() >= 12 else "1", txt_name = ft.TextField(
bgcolor=c_panel, label="Name dieses Workers (frei wählbar)",
color=c_text width=250,
) value=os.environ.get("COMPUTERNAME", "worker").lower(),
bgcolor=c_panel,
txt_pfad = ft.TextField(label="Installieren nach", value=std_ziel, expand=True, bgcolor=c_panel, color=c_text) color=c_text,
txt_map = ft.TextField(label="Netzwerk-Freigabe mit den Rohdaten", expand=True, bgcolor=c_panel, color=c_text) )
txt_slots = ft.Dropdown(
chk_auto = ft.Checkbox(label="Beim Anmelden automatisch starten (Hintergrund)", value=True, fill_color=c_accent) label="Kerne (Gleichzeitig)",
chk_desktop = ft.Checkbox(label="Verknüpfung auf dem Desktop anlegen", value=True, fill_color=c_accent) width=150,
options=[ft.dropdown.Option(str(i)) for i in range(1, 9)],
log_view = ft.ListView(height=150, auto_scroll=True, spacing=2) value="2" if os.cpu_count() and os.cpu_count() >= 12 else "1",
bgcolor=c_panel,
def log(msg: str): color=c_text,
log_view.controls.append(ft.Text(msg, color=c_muted, font_family="Consolas", size=12)) )
try:
page.update() txt_pfad = ft.TextField(
except: label="Installieren nach",
pass value=std_ziel,
expand=True,
def check_map(e): bgcolor=c_panel,
r_host = txt_host.value.strip() color=c_text,
if not r_host: )
page.snack_bar = ft.SnackBar(ft.Text("Bitte zuerst die LAN-IP eintragen!"), bgcolor="red") txt_map = ft.TextField(
page.snack_bar.open = True label="Netzwerk-Freigabe mit den Rohdaten",
page.update() expand=True,
return bgcolor=c_panel,
color=c_text,
try: )
log(f"Frage Rippy nach Freigabe ({r_host})...")
with urllib.request.urlopen(f"http://{r_host}/api/worker-setup/pfad-map", timeout=5) as r: chk_auto = ft.Checkbox(
data = json.loads(r.read().decode()) label="Beim Anmelden automatisch starten (Hintergrund)",
if data.get("hinweis"): value=True,
log(data["hinweis"]) fill_color=c_accent,
if data.get("mapping"): )
txt_map.value = data["mapping"] chk_desktop = ft.Checkbox(
log(f"Erfolgreich geholt: {data['mapping']}") label="Verknüpfung auf dem Desktop anlegen", value=True, fill_color=c_accent
page.update() )
except Exception as ex:
log(f"Fehler: {ex}") log_view = ft.ListView(height=150, auto_scroll=True, spacing=2)
def do_install(e): def log(msg: str):
btn_install.disabled = True log_view.controls.append(
page.update() ft.Text(msg, color=c_muted, font_family="Consolas", size=12)
threading.Thread(target=run_installation, daemon=True).start() )
try:
def run_installation(): page.update()
r_host = txt_host.value.strip() except Exception:
w_name = txt_name.value.strip() pass
install_dir = txt_pfad.value.strip()
def check_map(e):
if not r_host: r_host = txt_host.value.strip()
log("FEHLER: Rippy LAN-IP fehlt!") if not r_host:
btn_install.disabled = False page.snack_bar = ft.SnackBar(
page.update() ft.Text("Bitte zuerst die LAN-IP eintragen!"), bgcolor="red"
return )
page.snack_bar.open = True
log(f"Zielverzeichnis: {install_dir}") page.update()
return
# Check Python
python_exe = None try:
for cmd in ["py -3", "python"]: log(f"Frage Rippy nach Freigabe ({r_host})...")
try: with urllib.request.urlopen(
res = subprocess.run(f"{cmd} --version", shell=True, capture_output=True, text=True) f"http://{r_host}/api/worker-setup/pfad-map", timeout=5
if "Python 3." in res.stdout: ) as r:
python_exe = cmd data = json.loads(r.read().decode())
break if data.get("hinweis"):
except: pass log(data["hinweis"])
if data.get("mapping"):
if not python_exe: txt_map.value = data["mapping"]
log("FEHLER: Python 3.10+ nicht gefunden.") log(f"Erfolgreich geholt: {data['mapping']}")
log("Bitte mit 'winget install Python.Python.3.12' installieren.") page.update()
btn_install.disabled = False except Exception as ex:
page.update() log(f"Fehler: {ex}")
return
def do_install(e):
log(f"Python gefunden: {python_exe}") btn_install.disabled = True
page.update()
try: threading.Thread(target=run_installation, daemon=True).start()
os.makedirs(install_dir, exist_ok=True)
test_file = os.path.join(install_dir, ".test") def run_installation():
with open(test_file, "w") as f: f.write("x") r_host = txt_host.value.strip()
os.remove(test_file) w_name = txt_name.value.strip()
except Exception as ex: install_dir = txt_pfad.value.strip()
log(f"FEHLER: Keine Schreibrechte in {install_dir}")
log("Bitte als Administrator starten oder Pfad aendern.") if not r_host:
btn_install.disabled = False log("FEHLER: Rippy LAN-IP fehlt!")
page.update() btn_install.disabled = False
return page.update()
return
# Download worker package
log(f"Lade Worker-Code von {r_host}...") log(f"Zielverzeichnis: {install_dir}")
try:
zip_path = os.path.join(install_dir, "worker.zip") # Check Python
urllib.request.urlretrieve(f"http://{r_host}/api/worker-setup/paket", zip_path) python_exe = None
with zipfile.ZipFile(zip_path, 'r') as zip_ref: for cmd in ["py -3", "python"]:
zip_ref.extractall(install_dir) try:
os.remove(zip_path) res = subprocess.run(
except Exception as ex: f"{cmd} --version", shell=True, capture_output=True, text=True
log(f"FEHLER beim Download: {ex}") )
btn_install.disabled = False if "Python 3." in res.stdout:
page.update() python_exe = cmd
return break
except Exception:
# Setup venv — --clear loescht einen alten venv komplett, damit keine pass
# veralteten Pakete (z.B. flet 0.86 statt 0.23) ueberleben.
log("Lege Python-Umgebung an (venv)...") if not python_exe:
subprocess.run(f"{python_exe} -m venv --clear venv", cwd=install_dir, shell=True) log("FEHLER: Python 3.10+ nicht gefunden.")
log("Bitte mit 'winget install Python.Python.3.12' installieren.")
log("Installiere Abhaengigkeiten (pip)...") btn_install.disabled = False
subprocess.run(r"venv\Scripts\python.exe -m pip install --quiet --upgrade pip", cwd=install_dir, shell=True) page.update()
subprocess.run(r"venv\Scripts\pip.exe install --quiet -r requirements.txt", cwd=install_dir, shell=True) return
subprocess.run(r"venv\Scripts\pip.exe install --quiet requests pillow", cwd=install_dir, shell=True)
log(f"Python gefunden: {python_exe}")
# Download Handbrake
hb_path = os.path.join(install_dir, "HandBrakeCLI.exe") try:
if not os.path.exists(hb_path): os.makedirs(install_dir, exist_ok=True)
log("Lade HandBrakeCLI herunter...") test_file = os.path.join(install_dir, ".test")
try: with open(test_file, "w") as f:
hb_ver = "1.7.3" # Fallback if github fails f.write("x")
try: os.remove(test_file)
with urllib.request.urlopen("https://api.github.com/repos/HandBrake/HandBrake/releases/latest", timeout=5) as r: except Exception:
hb_data = json.loads(r.read().decode()) log(f"FEHLER: Keine Schreibrechte in {install_dir}")
if hb_data.get("tag_name"): hb_ver = hb_data["tag_name"].lstrip("v") log("Bitte als Administrator starten oder Pfad aendern.")
except: pass btn_install.disabled = False
hb_zip = os.path.join(install_dir, "hb.zip") page.update()
urllib.request.urlretrieve(f"https://github.com/HandBrake/HandBrake/releases/download/{hb_ver}/HandBrakeCLI-{hb_ver}-win-x86_64.zip", hb_zip) return
with zipfile.ZipFile(hb_zip, 'r') as zip_ref:
zip_ref.extractall(install_dir) # Download worker package
os.remove(hb_zip) log(f"Lade Worker-Code von {r_host}...")
except Exception as ex: try:
log(f"Warnung bei HandBrake-Download: {ex}") zip_path = os.path.join(install_dir, "worker.zip")
urllib.request.urlretrieve(
log("Erstelle Start-Skripte...") f"http://{r_host}/api/worker-setup/paket", zip_path
slots = txt_slots.value )
pool_arg = "--pool=solo" if int(slots) <= 1 else f"--pool=threads --concurrency={slots}" with zipfile.ZipFile(zip_path, "r") as zip_ref:
map_zeile = f"set RIPPY_PATH_MAP={txt_map.value.strip()}\r\n" if txt_map.value.strip() else "" zip_ref.extractall(install_dir)
os.remove(zip_path)
gui_bat = f"@echo off\r\ncd /d \"%~dp0\"\r\nset REDIS_URL=redis://{r_host}:6379/0\r\nset DATABASE_URL=postgresql://rippy:rippy@{r_host}:5432/rippy\r\nset API_URL=http://{r_host}:8000\r\nset WORKER_NAME={w_name}\r\nset RIPPY_TRAY_HOST={r_host}\r\nset RIPPY_SLOTS={slots}\r\nset TZ=UTC\r\n{map_zeile}set PATH=%~dp0;%PATH%\r\nstart \"\" venv\\Scripts\\pythonw.exe gui.py" except Exception as ex:
with open(os.path.join(install_dir, "start-gui.bat"), "w") as f: f.write(gui_bat) log(f"FEHLER beim Download: {ex}")
btn_install.disabled = False
work_bat = f"@echo off\r\ncd /d \"%~dp0\"\r\nset REDIS_URL=redis://{r_host}:6379/0\r\nset DATABASE_URL=postgresql://rippy:rippy@{r_host}:5432/rippy\r\nset API_URL=http://{r_host}:8000\r\nset WORKER_NAME={w_name}\r\nset RIPPY_SLOTS={slots}\r\nset TZ=UTC\r\n{map_zeile}set PATH=%~dp0;%PATH%\r\nvenv\\Scripts\\celery.exe -A celery_app worker --loglevel=info -Q transcode {pool_arg} -n {w_name}@%%h" page.update()
with open(os.path.join(install_dir, "start-worker.bat"), "w") as f: f.write(work_bat) return
# Uninstaller # Setup venv — --clear loescht einen alten venv komplett, damit keine
uninstall_ps1 = f"""param([switch]$Force) # veralteten Pakete (z.B. flet 0.86 statt 0.23) ueberleben.
Add-Type -AssemblyName System.Windows.Forms log("Lege Python-Umgebung an (venv)...")
if (-not $Force) {{ subprocess.run(
$antwort = [System.Windows.Forms.MessageBox]::Show("Rippy-Worker '{w_name}' von diesem PC entfernen?", "Rippy Worker deinstallieren", 4, 32) f"{python_exe} -m venv --clear venv", cwd=install_dir, shell=True
if ($antwort -ne "Yes") {{ exit }} )
}}
Get-WmiObject Win32_Process | Where-Object {{ log("Installiere Abhaengigkeiten (pip)...")
$_.ExecutablePath -like "$PSScriptRoot*" -or $_.CommandLine -like "*gui.py*" -or $_.CommandLine -like "*tray.py*" subprocess.run(
}} | ForEach-Object {{ Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }} r"venv\Scripts\python.exe -m pip install --quiet --upgrade pip",
Start-Sleep 3 cwd=install_dir,
$desktopDir = if ($PSScriptRoot -match "(?i)Program Files") {{ [Environment]::GetFolderPath("CommonDesktopDirectory") }} else {{ [Environment]::GetFolderPath("Desktop") }} shell=True,
Remove-Item -Force "$desktopDir\\Rippy Worker.lnk" -ErrorAction SilentlyContinue )
schtasks /delete /tn "RippyWorker" /f *>$null subprocess.run(
Start-Process cmd -ArgumentList "/c ping 127.0.0.1 -n 3 >nul & rmdir /s /q `"$PSScriptRoot`"" -WindowStyle Hidden r"venv\Scripts\pip.exe install --quiet -r requirements.txt",
if (-not $Force) {{ [System.Windows.Forms.MessageBox]::Show("Rippy-Worker entfernt.", "Rippy Worker") }} cwd=install_dir,
""" shell=True,
with open(os.path.join(install_dir, "uninstall.ps1"), "w", encoding="utf-8") as f: f.write(uninstall_ps1) )
subprocess.run(
# Autostart r"venv\Scripts\pip.exe install --quiet requests pillow",
if chk_auto.value: cwd=install_dir,
log("Richte Autostart (Task Scheduler) ein...") shell=True,
bat_path = os.path.join(install_dir, "start-gui.bat") )
subprocess.run(f'schtasks /create /tn "RippyWorker" /tr "\\"{bat_path}\\"" /sc ONLOGON /rl HIGHEST /f', shell=True, capture_output=True)
# Download Handbrake
# Desktop Shortcut — ueber PowerShell/COM, weil win32com im hb_path = os.path.join(install_dir, "HandBrakeCLI.exe")
# PyInstaller-Bundle nicht enthalten ist. if not os.path.exists(hb_path):
if chk_desktop.value: log("Lade HandBrakeCLI herunter...")
try: try:
public_desktop = os.path.join(os.environ.get("PUBLIC", r"C:\Users\Public"), "Desktop") hb_ver = "1.7.3" # Fallback if github fails
user_desktop = os.path.join(os.path.expanduser("~"), "Desktop") try:
desktop = public_desktop if "Program Files" in install_dir and os.path.isdir(public_desktop) else user_desktop with urllib.request.urlopen(
lnk_path = os.path.join(desktop, "Rippy Worker.lnk") "https://api.github.com/repos/HandBrake/HandBrake/releases/latest",
bat_target = os.path.join(install_dir, "start-gui.bat") timeout=5,
ico_path = os.path.join(install_dir, "rippy.ico") ) as r:
ps_cmd = ( hb_data = json.loads(r.read().decode())
f'$ws = New-Object -ComObject WScript.Shell; ' if hb_data.get("tag_name"):
f'$sc = $ws.CreateShortcut(\"{lnk_path}\"); ' hb_ver = hb_data["tag_name"].lstrip("v")
f'$sc.TargetPath = \"{bat_target}\"; ' except Exception:
f'$sc.WorkingDirectory = \"{install_dir}\"; ' pass
f'$sc.IconLocation = \"{ico_path}\"; ' hb_zip = os.path.join(install_dir, "hb.zip")
f'$sc.WindowStyle = 7; ' urllib.request.urlretrieve(
f'$sc.Save()' f"https://github.com/HandBrake/HandBrake/releases/download/{hb_ver}/HandBrakeCLI-{hb_ver}-win-x86_64.zip",
) hb_zip,
subprocess.run(["powershell", "-NoProfile", "-Command", ps_cmd], capture_output=True) )
log("Desktop-Verknüpfung erstellt.") with zipfile.ZipFile(hb_zip, "r") as zip_ref:
except Exception as e: zip_ref.extractall(install_dir)
log(f"Warnung: Shortcut konnte nicht erstellt werden: {e}") os.remove(hb_zip)
except Exception as ex:
log("FERTIG!") log(f"Warnung bei HandBrake-Download: {ex}")
btn_start.visible = True
btn_install.text = "Neu installieren" log("Erstelle Start-Skripte...")
btn_install.disabled = False slots = txt_slots.value
page.update() pool_arg = (
"--pool=solo"
def start_worker(e): if int(slots) <= 1
install_dir = txt_pfad.value.strip() else f"--pool=threads --concurrency={slots}"
bat_path = os.path.join(install_dir, "start-gui.bat") )
# os.startfile startet die .bat ueber die Shell-Verknuepfung — map_zeile = (
# CREATE_NO_WINDOW wuerde sie stumm schlucken. f"set RIPPY_PATH_MAP={txt_map.value.strip()}\r\n"
os.startfile(bat_path) if txt_map.value.strip()
page.window_close() else ""
)
btn_install = ft.ElevatedButton("Installieren", on_click=do_install, bgcolor=c_accent, color=ft.colors.WHITE, width=200, height=40)
btn_start = ft.ElevatedButton("Worker starten", on_click=start_worker, bgcolor=c_success, color=ft.colors.WHITE, width=200, height=40, visible=False) gui_bat = f'@echo off\r\ncd /d "%~dp0"\r\nset REDIS_URL=redis://{r_host}:6379/0\r\nset DATABASE_URL=postgresql://rippy:rippy@{r_host}:5432/rippy\r\nset API_URL=http://{r_host}:8000\r\nset WORKER_NAME={w_name}\r\nset RIPPY_TRAY_HOST={r_host}\r\nset RIPPY_SLOTS={slots}\r\nset TZ=UTC\r\n{map_zeile}set PATH=%~dp0;%PATH%\r\nstart "" venv\\Scripts\\pythonw.exe gui.py'
with open(os.path.join(install_dir, "start-gui.bat"), "w") as f:
def get_dir(e: ft.FilePickerResultEvent): f.write(gui_bat)
if e.path:
txt_pfad.value = e.path work_bat = f'@echo off\r\ncd /d "%~dp0"\r\nset REDIS_URL=redis://{r_host}:6379/0\r\nset DATABASE_URL=postgresql://rippy:rippy@{r_host}:5432/rippy\r\nset API_URL=http://{r_host}:8000\r\nset WORKER_NAME={w_name}\r\nset RIPPY_SLOTS={slots}\r\nset TZ=UTC\r\n{map_zeile}set PATH=%~dp0;%PATH%\r\nvenv\\Scripts\\celery.exe -A celery_app worker --loglevel=info -Q transcode {pool_arg} -n {w_name}@%%h'
page.update() with open(os.path.join(install_dir, "start-worker.bat"), "w") as f:
f.write(work_bat)
dir_picker = ft.FilePicker(on_result=get_dir)
page.overlay.append(dir_picker) # Uninstaller
uninstall_ps1 = f"""param([switch]$Force)
page.add( Add-Type -AssemblyName System.Windows.Forms
ft.Row([ if (-not $Force) {{
ft.Icon(ft.icons.DISC_FULL, size=40, color=c_accent), $antwort = [System.Windows.Forms.MessageBox]::Show("Rippy-Worker '{w_name}' von diesem PC entfernen?", "Rippy Worker deinstallieren", 4, 32)
ft.Text("Rippy Encoding-Worker", size=28, weight=ft.FontWeight.BOLD, color=c_text) if ($antwort -ne "Yes") {{ exit }}
]), }}
ft.Text("Diese Maschine übernimmt die Video-Kompression für Rippy. Gerippt wird weiter auf der Hauptmaschine.", color=c_muted), Get-WmiObject Win32_Process | Where-Object {{
ft.Divider(color="#334155"), $_.ExecutablePath -like "$PSScriptRoot*" -or $_.CommandLine -like "*gui.py*" -or $_.CommandLine -like "*tray.py*"
ft.Row([txt_host], alignment=ft.MainAxisAlignment.START), }} | ForEach-Object {{ Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }}
ft.Row([txt_name, txt_slots], alignment=ft.MainAxisAlignment.START), Start-Sleep 3
ft.Row([txt_pfad, ft.ElevatedButton("Durchsuchen ...", on_click=lambda _: dir_picker.get_directory_path("Installationsordner wählen"), bgcolor=c_panel, color=c_text)], alignment=ft.MainAxisAlignment.START), $desktopDir = if ($PSScriptRoot -match "(?i)Program Files") {{ [Environment]::GetFolderPath("CommonDesktopDirectory") }} else {{ [Environment]::GetFolderPath("Desktop") }}
ft.Row([txt_map, ft.ElevatedButton("Freigabe holen", on_click=check_map, bgcolor=c_panel, color=c_text)], alignment=ft.MainAxisAlignment.START), Remove-Item -Force "$desktopDir\\Rippy Worker.lnk" -ErrorAction SilentlyContinue
chk_auto, schtasks /delete /tn "RippyWorker" /f *>$null
chk_desktop, Start-Process cmd -ArgumentList "/c ping 127.0.0.1 -n 3 >nul & rmdir /s /q `"$PSScriptRoot`"" -WindowStyle Hidden
ft.Container(content=log_view, bgcolor="#020617", padding=10, border_radius=5, border=ft.border.all(1, "#334155")), if (-not $Force) {{ [System.Windows.Forms.MessageBox]::Show("Rippy-Worker entfernt.", "Rippy Worker") }}
ft.Row([btn_install, btn_start]) """
) with open(
os.path.join(install_dir, "uninstall.ps1"), "w", encoding="utf-8"
if __name__ == "__main__": ) as f:
ft.app(target=main) f.write(uninstall_ps1)
# Autostart
if chk_auto.value:
log("Richte Autostart (Task Scheduler) ein...")
bat_path = os.path.join(install_dir, "start-gui.bat")
subprocess.run(
f'schtasks /create /tn "RippyWorker" /tr "\\"{bat_path}\\"" /sc ONLOGON /rl HIGHEST /f',
shell=True,
capture_output=True,
)
# Desktop Shortcut — ueber PowerShell/COM, weil win32com im
# PyInstaller-Bundle nicht enthalten ist.
if chk_desktop.value:
try:
public_desktop = os.path.join(
os.environ.get("PUBLIC", r"C:\Users\Public"), "Desktop"
)
user_desktop = os.path.join(os.path.expanduser("~"), "Desktop")
desktop = (
public_desktop
if "Program Files" in install_dir and os.path.isdir(public_desktop)
else user_desktop
)
lnk_path = os.path.join(desktop, "Rippy Worker.lnk")
bat_target = os.path.join(install_dir, "start-gui.bat")
ico_path = os.path.join(install_dir, "rippy.ico")
ps_cmd = (
f"$ws = New-Object -ComObject WScript.Shell; "
f'$sc = $ws.CreateShortcut("{lnk_path}"); '
f'$sc.TargetPath = "{bat_target}"; '
f'$sc.WorkingDirectory = "{install_dir}"; '
f'$sc.IconLocation = "{ico_path}"; '
f"$sc.WindowStyle = 7; "
f"$sc.Save()"
)
subprocess.run(
["powershell", "-NoProfile", "-Command", ps_cmd],
capture_output=True,
)
log("Desktop-Verknüpfung erstellt.")
except Exception as e:
log(f"Warnung: Shortcut konnte nicht erstellt werden: {e}")
log("FERTIG!")
btn_start.visible = True
btn_install.text = "Neu installieren"
btn_install.disabled = False
page.update()
def start_worker(e):
install_dir = txt_pfad.value.strip()
bat_path = os.path.join(install_dir, "start-gui.bat")
# os.startfile startet die .bat ueber die Shell-Verknuepfung —
# CREATE_NO_WINDOW wuerde sie stumm schlucken.
os.startfile(bat_path)
page.window_close()
btn_install = ft.ElevatedButton(
"Installieren",
on_click=do_install,
bgcolor=c_accent,
color=ft.colors.WHITE,
width=200,
height=40,
)
btn_start = ft.ElevatedButton(
"Worker starten",
on_click=start_worker,
bgcolor=c_success,
color=ft.colors.WHITE,
width=200,
height=40,
visible=False,
)
def get_dir(e: ft.FilePickerResultEvent):
if e.path:
txt_pfad.value = e.path
page.update()
dir_picker = ft.FilePicker(on_result=get_dir)
page.overlay.append(dir_picker)
page.add(
ft.Row(
[
ft.Icon(ft.icons.DISC_FULL, size=40, color=c_accent),
ft.Text(
"Rippy Encoding-Worker",
size=28,
weight=ft.FontWeight.BOLD,
color=c_text,
),
]
),
ft.Text(
"Diese Maschine übernimmt die Video-Kompression für Rippy. Gerippt wird weiter auf der Hauptmaschine.",
color=c_muted,
),
ft.Divider(color="#334155"),
ft.Row([txt_host], alignment=ft.MainAxisAlignment.START),
ft.Row([txt_name, txt_slots], alignment=ft.MainAxisAlignment.START),
ft.Row(
[
txt_pfad,
ft.ElevatedButton(
"Durchsuchen ...",
on_click=lambda _: dir_picker.get_directory_path(
"Installationsordner wählen"
),
bgcolor=c_panel,
color=c_text,
),
],
alignment=ft.MainAxisAlignment.START,
),
ft.Row(
[
txt_map,
ft.ElevatedButton(
"Freigabe holen", on_click=check_map, bgcolor=c_panel, color=c_text
),
],
alignment=ft.MainAxisAlignment.START,
),
chk_auto,
chk_desktop,
ft.Container(
content=log_view,
bgcolor="#020617",
padding=10,
border_radius=5,
border=ft.border.all(1, "#334155"),
),
ft.Row([btn_install, btn_start]),
)
if __name__ == "__main__":
ft.app(target=main)
+1 -1
View File
@@ -1329,7 +1329,7 @@ async def mediaserver_refresh(request: MediaServerRefreshRequest):
res = _requests.post(url + "/jsonrpc", json=payload, auth=auth, timeout=5) res = _requests.post(url + "/jsonrpc", json=payload, auth=auth, timeout=5)
if res.status_code < 300: if res.status_code < 300:
return res return res
except: except Exception:
pass pass
# Emby / Jellyfin # Emby / Jellyfin
+760 -599
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -75,9 +75,11 @@ def test_leerer_pfad_stuerzt_nicht_ab():
# --- Die Meldung: eine falsche Ursache ist teurer als gar keine ------------- # --- Die Meldung: eine falsche Ursache ist teurer als gar keine -------------
def _pruefen(monkeypatch, vorhandene, mapping, im_container=False): def _pruefen(monkeypatch, vorhandene, mapping, im_container=False):
import ntpath
kennt = _fake_isdir(vorhandene) kennt = _fake_isdir(vorhandene)
monkeypatch.setattr(tasks.os.path, "isdir", monkeypatch.setattr(tasks.os.path, "isdir",
lambda p: kennt(p) or (im_container and p == "/app")) lambda p: kennt(p) or (im_container and p == "/app"))
monkeypatch.setattr(tasks.os.path, "dirname", ntpath.dirname)
monkeypatch.setenv("RIPPY_PATH_MAP", mapping) monkeypatch.setenv("RIPPY_PATH_MAP", mapping)
return tasks._erreichbarkeit_pruefen( return tasks._erreichbarkeit_pruefen(
"/app/media/rippy/job1", "\\\\NAS\\rippy\\job1", "/app/media/rippy/job1", "\\\\NAS\\rippy\\job1",
+126 -123
View File
@@ -1,123 +1,126 @@
"""Tests des Verwaltungsfensters — nur die Logik, nicht die Oberfläche. """Tests des Verwaltungsfensters — nur die Logik, nicht die Oberfläche.
Die Oberfläche selbst (tkinter) ist ausgenommen; geprüft wird, was das Fenster Die Oberfläche selbst (tkinter) ist ausgenommen; geprüft wird, was das Fenster
ANZEIGT: Zustand des Workers, Aufgabentexte, Log-Ausschnitt. ANZEIGT: Zustand des Workers, Aufgabentexte, Log-Ausschnitt.
""" """
import verwaltung import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# --- Was Rippy über diesen Worker weiß -------------------------------------- import verwaltung
CAPS = {"workers": [ # --- Was Rippy über diesen Worker weiß --------------------------------------
{"name": "rippy-hauptworker", "online": True, "encoders": ["cpu-x265"],
"info": {"cpu_kerne": "4"}},
{"name": "tobisnicerpc", "online": False, "encoders": ["cpu-x265", "vce"], CAPS = {"workers": [
"info": {"cpu_modell": "AMD Ryzen 7 9700X", "cpu_kerne": "16", {"name": "rippy-hauptworker", "online": True, "encoders": ["cpu-x265"],
"cpu_simd": "avx512f"}}, "info": {"cpu_kerne": "4"}},
]} {"name": "tobisnicerpc", "online": False, "encoders": ["cpu-x265", "vce"],
"info": {"cpu_modell": "AMD Ryzen 7 9700X", "cpu_kerne": "16",
"cpu_simd": "avx512f"}},
def test_zustand_findet_den_eigenen_worker(): ]}
z = verwaltung.worker_zustand(CAPS, "tobisnicerpc")
assert z["bekannt"] is True
assert z["online"] is False def test_zustand_findet_den_eigenen_worker():
assert z["encoders"] == ["cpu-x265", "vce"] z = verwaltung.worker_zustand(CAPS, "tobisnicerpc")
assert z["info"]["cpu_simd"] == "avx512f" assert z["bekannt"] is True
assert z["online"] is False
assert z["encoders"] == ["cpu-x265", "vce"]
def test_zustand_bei_unbekanntem_worker(): assert z["info"]["cpu_simd"] == "avx512f"
z = verwaltung.worker_zustand(CAPS, "gibt-es-nicht")
assert z == {"bekannt": False, "online": False, "encoders": [], "info": {}}
def test_zustand_bei_unbekanntem_worker():
z = verwaltung.worker_zustand(CAPS, "gibt-es-nicht")
def test_zustand_ohne_antwort_von_rippy(): assert z == {"bekannt": False, "online": False, "encoders": [], "info": {}}
"""Rippy nicht erreichbar → das Fenster muss trotzdem etwas sagen können."""
assert verwaltung.worker_zustand(None, "x")["bekannt"] is False
assert verwaltung.worker_zustand({}, "x")["bekannt"] is False def test_zustand_ohne_antwort_von_rippy():
"""Rippy nicht erreichbar → das Fenster muss trotzdem etwas sagen können."""
assert verwaltung.worker_zustand(None, "x")["bekannt"] is False
# --- Die Statuszeile -------------------------------------------------------- assert verwaltung.worker_zustand({}, "x")["bekannt"] is False
def test_status_nennt_das_wirkliche_problem(): # --- Die Statuszeile --------------------------------------------------------
"""Drei Lagen, drei klare Sätze — nicht ein „unbekannt" für alles."""
unbekannt = verwaltung.worker_zustand(CAPS, "gibt-es-nicht")
text, farbe = verwaltung.status_text(unbekannt, None) def test_status_nennt_das_wirkliche_problem():
assert "kennt diesen Worker nicht" in text """Drei Lagen, drei klare Sätze — nicht ein „unbekannt" für alles."""
assert farbe == verwaltung.ROSE unbekannt = verwaltung.worker_zustand(CAPS, "gibt-es-nicht")
text, farbe = verwaltung.status_text(unbekannt, None)
offline = verwaltung.worker_zustand(CAPS, "tobisnicerpc") assert "kennt diesen Worker nicht" in text
text, farbe = verwaltung.status_text(offline, None) assert farbe == verwaltung.ROSE
assert "NICHT erreichbar" in text
assert farbe == verwaltung.ROSE offline = verwaltung.worker_zustand(CAPS, "tobisnicerpc")
text, farbe = verwaltung.status_text(offline, None)
online = verwaltung.worker_zustand(CAPS, "rippy-hauptworker") assert "NICHT erreichbar" in text
text, farbe = verwaltung.status_text(online, None) assert farbe == verwaltung.ROSE
assert "bereit" in text
assert farbe == verwaltung.EMERALD online = verwaltung.worker_zustand(CAPS, "rippy-hauptworker")
text, farbe = verwaltung.status_text(online, None)
assert "bereit" in text
def test_status_zeigt_den_laufenden_job(): assert farbe == verwaltung.EMERALD
online = verwaltung.worker_zustand(CAPS, "rippy-hauptworker")
job = {"title": "Akira", "status": "transcoding", "progress": 42,
"eta_text": "noch ca. 3 h 07 min"} def test_status_zeigt_den_laufenden_job():
text, farbe = verwaltung.status_text(online, job) online = verwaltung.worker_zustand(CAPS, "rippy-hauptworker")
assert "Akira" in text and "42 %" in text and "3 h 07 min" in text job = {"title": "Akira", "status": "transcoding", "progress": 42,
assert farbe == verwaltung.AMBER "eta_text": "noch ca. 3 h 07 min"}
text, farbe = verwaltung.status_text(online, job)
assert "Akira" in text and "42 %" in text and "3 h 07 min" in text
# --- Aufgabenliste ---------------------------------------------------------- assert farbe == verwaltung.AMBER
def test_job_text_uebersetzt_die_status_woerter(): # --- Aufgabenliste ----------------------------------------------------------
"""„transcoding" sagt einem Nicht-Entwickler nichts."""
assert verwaltung.job_text(
{"title": "Akira", "status": "transcoding", "progress": 7} def test_job_text_uebersetzt_die_status_woerter():
) == "Akira — komprimiert, 7 %" """„transcoding" sagt einem Nicht-Entwickler nichts."""
assert verwaltung.job_text( assert verwaltung.job_text(
{"title": "Alien", "status": "completed"}) == "Alien — fertig" {"title": "Akira", "status": "transcoding", "progress": 7}
assert verwaltung.job_text( ) == "Akira — komprimiert, 7 %"
{"title": "Alien", "status": "failed"}) == "Alien — Fehler" assert verwaltung.job_text(
assert verwaltung.job_text( {"title": "Alien", "status": "completed"}) == "Alien — fertig"
{"title": "Alien", "status": "pending"}) == "Alien — wartet" assert verwaltung.job_text(
{"title": "Alien", "status": "failed"}) == "Alien — Fehler"
assert verwaltung.job_text(
def test_job_text_ohne_titel_nimmt_die_kurze_id(): {"title": "Alien", "status": "pending"}) == "Alien — wartet"
text = verwaltung.job_text(
{"id": "95afdc89-2426-4d44", "status": "failed"})
assert text == "95afdc89 — Fehler" def test_job_text_ohne_titel_nimmt_die_kurze_id():
text = verwaltung.job_text(
{"id": "95afdc89-2426-4d44", "status": "failed"})
def test_job_text_haengt_keine_restzeit_an_fertige_jobs(): assert text == "95afdc89 — Fehler"
text = verwaltung.job_text(
{"title": "X", "status": "completed", "progress": 100,
"eta_text": "noch ca. 5 min"}) def test_job_text_haengt_keine_restzeit_an_fertige_jobs():
assert "5 min" not in text text = verwaltung.job_text(
assert "%" not in text {"title": "X", "status": "completed", "progress": 100,
"eta_text": "noch ca. 5 min"})
assert "5 min" not in text
# --- Log-Ausschnitt --------------------------------------------------------- assert "%" not in text
def test_letzte_zeilen_schneidet_hinten_ab(): # --- Log-Ausschnitt ---------------------------------------------------------
import io as _io
inhalt = "\n".join(f"Zeile {i}" for i in range(500)) def test_letzte_zeilen_schneidet_hinten_ab():
zeilen = verwaltung.letzte_zeilen( import io as _io
"egal", anzahl=5, oeffnen=lambda p: _io.StringIO(inhalt))
assert zeilen == ["Zeile 495", "Zeile 496", "Zeile 497", "Zeile 498", "Zeile 499"] inhalt = "\n".join(f"Zeile {i}" for i in range(500))
zeilen = verwaltung.letzte_zeilen(
"egal", anzahl=5, oeffnen=lambda p: _io.StringIO(inhalt))
def test_letzte_zeilen_ohne_datei_ist_leer(): assert zeilen == ["Zeile 495", "Zeile 496", "Zeile 497", "Zeile 498", "Zeile 499"]
def kaputt(p):
raise OSError("keine Datei")
def test_letzte_zeilen_ohne_datei_ist_leer():
assert verwaltung.letzte_zeilen("gibts-nicht", oeffnen=kaputt) == [] def kaputt(p):
raise OSError("keine Datei")
def test_hole_ohne_adresse_fragt_nicht(): assert verwaltung.letzte_zeilen("gibts-nicht", oeffnen=kaputt) == []
"""Ohne RIPPY_TRAY_HOST darf nichts versucht werden — sonst hängt das
Fenster beim Öffnen am Timeout."""
assert verwaltung.hole("/jobs", host="") is None def test_hole_ohne_adresse_fragt_nicht():
"""Ohne RIPPY_TRAY_HOST darf nichts versucht werden — sonst hängt das
Fenster beim Öffnen am Timeout."""
assert verwaltung.hole("/jobs", host="") is None
+149
View File
@@ -0,0 +1,149 @@
"""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 urllib.request
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")