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 os
import subprocess
import threading
import urllib.request
import json
import time
import zipfile
def main(page: ft.Page):
page.title = "Rippy Encoding-Worker Installation"
page.window_width = 800
page.window_height = 650
page.bgcolor = "#0f172a"
page.theme_mode = ft.ThemeMode.DARK
page.scroll = ft.ScrollMode.AUTO
# Colors
c_panel = "#1e293b"
c_text = "#e2e8f0"
c_muted = "#94a3b8"
c_accent = "#6366f1"
c_success = "#10b981"
# State
std_ziel = r"C:\Program Files\Rippy Worker"
# UI Fields
txt_host = ft.TextField(label="LAN-IP der Rippy-Maschine (Docker-Host)", width=400, bgcolor=c_panel, color=c_text)
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)
txt_slots = ft.Dropdown(
label="Kerne (Gleichzeitig)",
width=150,
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",
bgcolor=c_panel,
color=c_text
)
txt_pfad = ft.TextField(label="Installieren nach", value=std_ziel, expand=True, bgcolor=c_panel, color=c_text)
txt_map = ft.TextField(label="Netzwerk-Freigabe mit den Rohdaten", expand=True, bgcolor=c_panel, color=c_text)
chk_auto = ft.Checkbox(label="Beim Anmelden automatisch starten (Hintergrund)", value=True, fill_color=c_accent)
chk_desktop = ft.Checkbox(label="Verknüpfung auf dem Desktop anlegen", value=True, fill_color=c_accent)
log_view = ft.ListView(height=150, auto_scroll=True, spacing=2)
def log(msg: str):
log_view.controls.append(ft.Text(msg, color=c_muted, font_family="Consolas", size=12))
try:
page.update()
except:
pass
def check_map(e):
r_host = txt_host.value.strip()
if not r_host:
page.snack_bar = ft.SnackBar(ft.Text("Bitte zuerst die LAN-IP eintragen!"), bgcolor="red")
page.snack_bar.open = True
page.update()
return
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:
data = json.loads(r.read().decode())
if data.get("hinweis"):
log(data["hinweis"])
if data.get("mapping"):
txt_map.value = data["mapping"]
log(f"Erfolgreich geholt: {data['mapping']}")
page.update()
except Exception as ex:
log(f"Fehler: {ex}")
def do_install(e):
btn_install.disabled = True
page.update()
threading.Thread(target=run_installation, daemon=True).start()
def run_installation():
r_host = txt_host.value.strip()
w_name = txt_name.value.strip()
install_dir = txt_pfad.value.strip()
if not r_host:
log("FEHLER: Rippy LAN-IP fehlt!")
btn_install.disabled = False
page.update()
return
log(f"Zielverzeichnis: {install_dir}")
# Check Python
python_exe = None
for cmd in ["py -3", "python"]:
try:
res = subprocess.run(f"{cmd} --version", shell=True, capture_output=True, text=True)
if "Python 3." in res.stdout:
python_exe = cmd
break
except: pass
if not python_exe:
log("FEHLER: Python 3.10+ nicht gefunden.")
log("Bitte mit 'winget install Python.Python.3.12' installieren.")
btn_install.disabled = False
page.update()
return
log(f"Python gefunden: {python_exe}")
try:
os.makedirs(install_dir, exist_ok=True)
test_file = os.path.join(install_dir, ".test")
with open(test_file, "w") as f: f.write("x")
os.remove(test_file)
except Exception as ex:
log(f"FEHLER: Keine Schreibrechte in {install_dir}")
log("Bitte als Administrator starten oder Pfad aendern.")
btn_install.disabled = False
page.update()
return
# Download worker package
log(f"Lade Worker-Code von {r_host}...")
try:
zip_path = os.path.join(install_dir, "worker.zip")
urllib.request.urlretrieve(f"http://{r_host}/api/worker-setup/paket", zip_path)
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
zip_ref.extractall(install_dir)
os.remove(zip_path)
except Exception as ex:
log(f"FEHLER beim Download: {ex}")
btn_install.disabled = False
page.update()
return
# Setup venv — --clear loescht einen alten venv komplett, damit keine
# veralteten Pakete (z.B. flet 0.86 statt 0.23) ueberleben.
log("Lege Python-Umgebung an (venv)...")
subprocess.run(f"{python_exe} -m venv --clear venv", cwd=install_dir, shell=True)
log("Installiere Abhaengigkeiten (pip)...")
subprocess.run(r"venv\Scripts\python.exe -m pip install --quiet --upgrade pip", cwd=install_dir, shell=True)
subprocess.run(r"venv\Scripts\pip.exe install --quiet -r requirements.txt", cwd=install_dir, shell=True)
subprocess.run(r"venv\Scripts\pip.exe install --quiet requests pillow", cwd=install_dir, shell=True)
# Download Handbrake
hb_path = os.path.join(install_dir, "HandBrakeCLI.exe")
if not os.path.exists(hb_path):
log("Lade HandBrakeCLI herunter...")
try:
hb_ver = "1.7.3" # Fallback if github fails
try:
with urllib.request.urlopen("https://api.github.com/repos/HandBrake/HandBrake/releases/latest", timeout=5) as r:
hb_data = json.loads(r.read().decode())
if hb_data.get("tag_name"): hb_ver = hb_data["tag_name"].lstrip("v")
except: pass
hb_zip = os.path.join(install_dir, "hb.zip")
urllib.request.urlretrieve(f"https://github.com/HandBrake/HandBrake/releases/download/{hb_ver}/HandBrakeCLI-{hb_ver}-win-x86_64.zip", hb_zip)
with zipfile.ZipFile(hb_zip, 'r') as zip_ref:
zip_ref.extractall(install_dir)
os.remove(hb_zip)
except Exception as ex:
log(f"Warnung bei HandBrake-Download: {ex}")
log("Erstelle Start-Skripte...")
slots = txt_slots.value
pool_arg = "--pool=solo" if int(slots) <= 1 else f"--pool=threads --concurrency={slots}"
map_zeile = f"set RIPPY_PATH_MAP={txt_map.value.strip()}\r\n" if txt_map.value.strip() else ""
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: f.write(gui_bat)
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"
with open(os.path.join(install_dir, "start-worker.bat"), "w") as f: f.write(work_bat)
# Uninstaller
uninstall_ps1 = f"""param([switch]$Force)
Add-Type -AssemblyName System.Windows.Forms
if (-not $Force) {{
$antwort = [System.Windows.Forms.MessageBox]::Show("Rippy-Worker '{w_name}' von diesem PC entfernen?", "Rippy Worker deinstallieren", 4, 32)
if ($antwort -ne "Yes") {{ exit }}
}}
Get-WmiObject Win32_Process | Where-Object {{
$_.ExecutablePath -like "$PSScriptRoot*" -or $_.CommandLine -like "*gui.py*" -or $_.CommandLine -like "*tray.py*"
}} | ForEach-Object {{ Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }}
Start-Sleep 3
$desktopDir = if ($PSScriptRoot -match "(?i)Program Files") {{ [Environment]::GetFolderPath("CommonDesktopDirectory") }} else {{ [Environment]::GetFolderPath("Desktop") }}
Remove-Item -Force "$desktopDir\\Rippy Worker.lnk" -ErrorAction SilentlyContinue
schtasks /delete /tn "RippyWorker" /f *>$null
Start-Process cmd -ArgumentList "/c ping 127.0.0.1 -n 3 >nul & rmdir /s /q `"$PSScriptRoot`"" -WindowStyle Hidden
if (-not $Force) {{ [System.Windows.Forms.MessageBox]::Show("Rippy-Worker entfernt.", "Rippy Worker") }}
"""
with open(os.path.join(install_dir, "uninstall.ps1"), "w", encoding="utf-8") as f: 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)
import flet as ft
import os
import subprocess
import threading
import urllib.request
import json
import zipfile
def main(page: ft.Page):
page.title = "Rippy Encoding-Worker Installation"
page.window_width = 800
page.window_height = 650
page.bgcolor = "#0f172a"
page.theme_mode = ft.ThemeMode.DARK
page.scroll = ft.ScrollMode.AUTO
# Colors
c_panel = "#1e293b"
c_text = "#e2e8f0"
c_muted = "#94a3b8"
c_accent = "#6366f1"
c_success = "#10b981"
# State
std_ziel = r"C:\Program Files\Rippy Worker"
# UI Fields
txt_host = ft.TextField(
label="LAN-IP der Rippy-Maschine (Docker-Host)",
width=400,
bgcolor=c_panel,
color=c_text,
)
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,
)
txt_slots = ft.Dropdown(
label="Kerne (Gleichzeitig)",
width=150,
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",
bgcolor=c_panel,
color=c_text,
)
txt_pfad = ft.TextField(
label="Installieren nach",
value=std_ziel,
expand=True,
bgcolor=c_panel,
color=c_text,
)
txt_map = ft.TextField(
label="Netzwerk-Freigabe mit den Rohdaten",
expand=True,
bgcolor=c_panel,
color=c_text,
)
chk_auto = ft.Checkbox(
label="Beim Anmelden automatisch starten (Hintergrund)",
value=True,
fill_color=c_accent,
)
chk_desktop = ft.Checkbox(
label="Verknüpfung auf dem Desktop anlegen", value=True, fill_color=c_accent
)
log_view = ft.ListView(height=150, auto_scroll=True, spacing=2)
def log(msg: str):
log_view.controls.append(
ft.Text(msg, color=c_muted, font_family="Consolas", size=12)
)
try:
page.update()
except Exception:
pass
def check_map(e):
r_host = txt_host.value.strip()
if not r_host:
page.snack_bar = ft.SnackBar(
ft.Text("Bitte zuerst die LAN-IP eintragen!"), bgcolor="red"
)
page.snack_bar.open = True
page.update()
return
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:
data = json.loads(r.read().decode())
if data.get("hinweis"):
log(data["hinweis"])
if data.get("mapping"):
txt_map.value = data["mapping"]
log(f"Erfolgreich geholt: {data['mapping']}")
page.update()
except Exception as ex:
log(f"Fehler: {ex}")
def do_install(e):
btn_install.disabled = True
page.update()
threading.Thread(target=run_installation, daemon=True).start()
def run_installation():
r_host = txt_host.value.strip()
w_name = txt_name.value.strip()
install_dir = txt_pfad.value.strip()
if not r_host:
log("FEHLER: Rippy LAN-IP fehlt!")
btn_install.disabled = False
page.update()
return
log(f"Zielverzeichnis: {install_dir}")
# Check Python
python_exe = None
for cmd in ["py -3", "python"]:
try:
res = subprocess.run(
f"{cmd} --version", shell=True, capture_output=True, text=True
)
if "Python 3." in res.stdout:
python_exe = cmd
break
except Exception:
pass
if not python_exe:
log("FEHLER: Python 3.10+ nicht gefunden.")
log("Bitte mit 'winget install Python.Python.3.12' installieren.")
btn_install.disabled = False
page.update()
return
log(f"Python gefunden: {python_exe}")
try:
os.makedirs(install_dir, exist_ok=True)
test_file = os.path.join(install_dir, ".test")
with open(test_file, "w") as f:
f.write("x")
os.remove(test_file)
except Exception:
log(f"FEHLER: Keine Schreibrechte in {install_dir}")
log("Bitte als Administrator starten oder Pfad aendern.")
btn_install.disabled = False
page.update()
return
# Download worker package
log(f"Lade Worker-Code von {r_host}...")
try:
zip_path = os.path.join(install_dir, "worker.zip")
urllib.request.urlretrieve(
f"http://{r_host}/api/worker-setup/paket", zip_path
)
with zipfile.ZipFile(zip_path, "r") as zip_ref:
zip_ref.extractall(install_dir)
os.remove(zip_path)
except Exception as ex:
log(f"FEHLER beim Download: {ex}")
btn_install.disabled = False
page.update()
return
# Setup venv — --clear loescht einen alten venv komplett, damit keine
# veralteten Pakete (z.B. flet 0.86 statt 0.23) ueberleben.
log("Lege Python-Umgebung an (venv)...")
subprocess.run(
f"{python_exe} -m venv --clear venv", cwd=install_dir, shell=True
)
log("Installiere Abhaengigkeiten (pip)...")
subprocess.run(
r"venv\Scripts\python.exe -m pip install --quiet --upgrade pip",
cwd=install_dir,
shell=True,
)
subprocess.run(
r"venv\Scripts\pip.exe install --quiet -r requirements.txt",
cwd=install_dir,
shell=True,
)
subprocess.run(
r"venv\Scripts\pip.exe install --quiet requests pillow",
cwd=install_dir,
shell=True,
)
# Download Handbrake
hb_path = os.path.join(install_dir, "HandBrakeCLI.exe")
if not os.path.exists(hb_path):
log("Lade HandBrakeCLI herunter...")
try:
hb_ver = "1.7.3" # Fallback if github fails
try:
with urllib.request.urlopen(
"https://api.github.com/repos/HandBrake/HandBrake/releases/latest",
timeout=5,
) as r:
hb_data = json.loads(r.read().decode())
if hb_data.get("tag_name"):
hb_ver = hb_data["tag_name"].lstrip("v")
except Exception:
pass
hb_zip = os.path.join(install_dir, "hb.zip")
urllib.request.urlretrieve(
f"https://github.com/HandBrake/HandBrake/releases/download/{hb_ver}/HandBrakeCLI-{hb_ver}-win-x86_64.zip",
hb_zip,
)
with zipfile.ZipFile(hb_zip, "r") as zip_ref:
zip_ref.extractall(install_dir)
os.remove(hb_zip)
except Exception as ex:
log(f"Warnung bei HandBrake-Download: {ex}")
log("Erstelle Start-Skripte...")
slots = txt_slots.value
pool_arg = (
"--pool=solo"
if int(slots) <= 1
else f"--pool=threads --concurrency={slots}"
)
map_zeile = (
f"set RIPPY_PATH_MAP={txt_map.value.strip()}\r\n"
if txt_map.value.strip()
else ""
)
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:
f.write(gui_bat)
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'
with open(os.path.join(install_dir, "start-worker.bat"), "w") as f:
f.write(work_bat)
# Uninstaller
uninstall_ps1 = f"""param([switch]$Force)
Add-Type -AssemblyName System.Windows.Forms
if (-not $Force) {{
$antwort = [System.Windows.Forms.MessageBox]::Show("Rippy-Worker '{w_name}' von diesem PC entfernen?", "Rippy Worker deinstallieren", 4, 32)
if ($antwort -ne "Yes") {{ exit }}
}}
Get-WmiObject Win32_Process | Where-Object {{
$_.ExecutablePath -like "$PSScriptRoot*" -or $_.CommandLine -like "*gui.py*" -or $_.CommandLine -like "*tray.py*"
}} | ForEach-Object {{ Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }}
Start-Sleep 3
$desktopDir = if ($PSScriptRoot -match "(?i)Program Files") {{ [Environment]::GetFolderPath("CommonDesktopDirectory") }} else {{ [Environment]::GetFolderPath("Desktop") }}
Remove-Item -Force "$desktopDir\\Rippy Worker.lnk" -ErrorAction SilentlyContinue
schtasks /delete /tn "RippyWorker" /f *>$null
Start-Process cmd -ArgumentList "/c ping 127.0.0.1 -n 3 >nul & rmdir /s /q `"$PSScriptRoot`"" -WindowStyle Hidden
if (-not $Force) {{ [System.Windows.Forms.MessageBox]::Show("Rippy-Worker entfernt.", "Rippy Worker") }}
"""
with open(
os.path.join(install_dir, "uninstall.ps1"), "w", encoding="utf-8"
) as f:
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)
if res.status_code < 300:
return res
except:
except Exception:
pass
# 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 -------------
def _pruefen(monkeypatch, vorhandene, mapping, im_container=False):
import ntpath
kennt = _fake_isdir(vorhandene)
monkeypatch.setattr(tasks.os.path, "isdir",
lambda p: kennt(p) or (im_container and p == "/app"))
monkeypatch.setattr(tasks.os.path, "dirname", ntpath.dirname)
monkeypatch.setenv("RIPPY_PATH_MAP", mapping)
return tasks._erreichbarkeit_pruefen(
"/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.
Die Oberfläche selbst (tkinter) ist ausgenommen; geprüft wird, was das Fenster
ANZEIGT: Zustand des Workers, Aufgabentexte, Log-Ausschnitt.
"""
import verwaltung
# --- Was Rippy über diesen Worker weiß --------------------------------------
CAPS = {"workers": [
{"name": "rippy-hauptworker", "online": True, "encoders": ["cpu-x265"],
"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
assert z["encoders"] == ["cpu-x265", "vce"]
assert z["info"]["cpu_simd"] == "avx512f"
def test_zustand_bei_unbekanntem_worker():
z = verwaltung.worker_zustand(CAPS, "gibt-es-nicht")
assert z == {"bekannt": False, "online": False, "encoders": [], "info": {}}
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
assert verwaltung.worker_zustand({}, "x")["bekannt"] is False
# --- Die Statuszeile --------------------------------------------------------
def test_status_nennt_das_wirkliche_problem():
"""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)
assert "kennt diesen Worker nicht" in text
assert farbe == verwaltung.ROSE
offline = verwaltung.worker_zustand(CAPS, "tobisnicerpc")
text, farbe = verwaltung.status_text(offline, None)
assert "NICHT erreichbar" in text
assert farbe == verwaltung.ROSE
online = verwaltung.worker_zustand(CAPS, "rippy-hauptworker")
text, farbe = verwaltung.status_text(online, None)
assert "bereit" in text
assert farbe == verwaltung.EMERALD
def test_status_zeigt_den_laufenden_job():
online = verwaltung.worker_zustand(CAPS, "rippy-hauptworker")
job = {"title": "Akira", "status": "transcoding", "progress": 42,
"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
assert farbe == verwaltung.AMBER
# --- Aufgabenliste ----------------------------------------------------------
def test_job_text_uebersetzt_die_status_woerter():
"""„transcoding" sagt einem Nicht-Entwickler nichts."""
assert verwaltung.job_text(
{"title": "Akira", "status": "transcoding", "progress": 7}
) == "Akira — komprimiert, 7 %"
assert verwaltung.job_text(
{"title": "Alien", "status": "completed"}) == "Alien — fertig"
assert verwaltung.job_text(
{"title": "Alien", "status": "failed"}) == "Alien — Fehler"
assert verwaltung.job_text(
{"title": "Alien", "status": "pending"}) == "Alien — wartet"
def test_job_text_ohne_titel_nimmt_die_kurze_id():
text = verwaltung.job_text(
{"id": "95afdc89-2426-4d44", "status": "failed"})
assert text == "95afdc89 — Fehler"
def test_job_text_haengt_keine_restzeit_an_fertige_jobs():
text = verwaltung.job_text(
{"title": "X", "status": "completed", "progress": 100,
"eta_text": "noch ca. 5 min"})
assert "5 min" not in text
assert "%" not in text
# --- Log-Ausschnitt ---------------------------------------------------------
def test_letzte_zeilen_schneidet_hinten_ab():
import io as _io
inhalt = "\n".join(f"Zeile {i}" for i in range(500))
zeilen = verwaltung.letzte_zeilen(
"egal", anzahl=5, oeffnen=lambda p: _io.StringIO(inhalt))
assert zeilen == ["Zeile 495", "Zeile 496", "Zeile 497", "Zeile 498", "Zeile 499"]
def test_letzte_zeilen_ohne_datei_ist_leer():
def kaputt(p):
raise OSError("keine Datei")
assert verwaltung.letzte_zeilen("gibts-nicht", oeffnen=kaputt) == []
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
"""Tests des Verwaltungsfensters — nur die Logik, nicht die Oberfläche.
Die Oberfläche selbst (tkinter) ist ausgenommen; geprüft wird, was das Fenster
ANZEIGT: Zustand des Workers, Aufgabentexte, Log-Ausschnitt.
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import verwaltung
# --- Was Rippy über diesen Worker weiß --------------------------------------
CAPS = {"workers": [
{"name": "rippy-hauptworker", "online": True, "encoders": ["cpu-x265"],
"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
assert z["encoders"] == ["cpu-x265", "vce"]
assert z["info"]["cpu_simd"] == "avx512f"
def test_zustand_bei_unbekanntem_worker():
z = verwaltung.worker_zustand(CAPS, "gibt-es-nicht")
assert z == {"bekannt": False, "online": False, "encoders": [], "info": {}}
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
assert verwaltung.worker_zustand({}, "x")["bekannt"] is False
# --- Die Statuszeile --------------------------------------------------------
def test_status_nennt_das_wirkliche_problem():
"""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)
assert "kennt diesen Worker nicht" in text
assert farbe == verwaltung.ROSE
offline = verwaltung.worker_zustand(CAPS, "tobisnicerpc")
text, farbe = verwaltung.status_text(offline, None)
assert "NICHT erreichbar" in text
assert farbe == verwaltung.ROSE
online = verwaltung.worker_zustand(CAPS, "rippy-hauptworker")
text, farbe = verwaltung.status_text(online, None)
assert "bereit" in text
assert farbe == verwaltung.EMERALD
def test_status_zeigt_den_laufenden_job():
online = verwaltung.worker_zustand(CAPS, "rippy-hauptworker")
job = {"title": "Akira", "status": "transcoding", "progress": 42,
"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
assert farbe == verwaltung.AMBER
# --- Aufgabenliste ----------------------------------------------------------
def test_job_text_uebersetzt_die_status_woerter():
"""„transcoding" sagt einem Nicht-Entwickler nichts."""
assert verwaltung.job_text(
{"title": "Akira", "status": "transcoding", "progress": 7}
) == "Akira — komprimiert, 7 %"
assert verwaltung.job_text(
{"title": "Alien", "status": "completed"}) == "Alien — fertig"
assert verwaltung.job_text(
{"title": "Alien", "status": "failed"}) == "Alien — Fehler"
assert verwaltung.job_text(
{"title": "Alien", "status": "pending"}) == "Alien — wartet"
def test_job_text_ohne_titel_nimmt_die_kurze_id():
text = verwaltung.job_text(
{"id": "95afdc89-2426-4d44", "status": "failed"})
assert text == "95afdc89 — Fehler"
def test_job_text_haengt_keine_restzeit_an_fertige_jobs():
text = verwaltung.job_text(
{"title": "X", "status": "completed", "progress": 100,
"eta_text": "noch ca. 5 min"})
assert "5 min" not in text
assert "%" not in text
# --- Log-Ausschnitt ---------------------------------------------------------
def test_letzte_zeilen_schneidet_hinten_ab():
import io as _io
inhalt = "\n".join(f"Zeile {i}" for i in range(500))
zeilen = verwaltung.letzte_zeilen(
"egal", anzahl=5, oeffnen=lambda p: _io.StringIO(inhalt))
assert zeilen == ["Zeile 495", "Zeile 496", "Zeile 497", "Zeile 498", "Zeile 499"]
def test_letzte_zeilen_ohne_datei_ist_leer():
def kaputt(p):
raise OSError("keine Datei")
assert verwaltung.letzte_zeilen("gibts-nicht", oeffnen=kaputt) == []
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")