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
+196 -55
View File
@@ -4,9 +4,9 @@ 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
@@ -26,43 +26,77 @@ def main(page: ft.Page):
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)",
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( txt_slots = ft.Dropdown(
label="Kerne (Gleichzeitig)", label="Kerne (Gleichzeitig)",
width=150, width=150,
options=[ft.dropdown.Option(str(i)) for i in range(1, 9)], 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", value="2" if os.cpu_count() and os.cpu_count() >= 12 else "1",
bgcolor=c_panel, bgcolor=c_panel,
color=c_text color=c_text,
) )
txt_pfad = ft.TextField(label="Installieren nach", value=std_ziel, expand=True, bgcolor=c_panel, color=c_text) txt_pfad = ft.TextField(
txt_map = ft.TextField(label="Netzwerk-Freigabe mit den Rohdaten", expand=True, bgcolor=c_panel, color=c_text) 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_auto = ft.Checkbox(
chk_desktop = ft.Checkbox(label="Verknüpfung auf dem Desktop anlegen", value=True, fill_color=c_accent) 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) log_view = ft.ListView(height=150, auto_scroll=True, spacing=2)
def log(msg: str): def log(msg: str):
log_view.controls.append(ft.Text(msg, color=c_muted, font_family="Consolas", size=12)) log_view.controls.append(
ft.Text(msg, color=c_muted, font_family="Consolas", size=12)
)
try: try:
page.update() page.update()
except: except Exception:
pass pass
def check_map(e): def check_map(e):
r_host = txt_host.value.strip() r_host = txt_host.value.strip()
if not r_host: if not r_host:
page.snack_bar = ft.SnackBar(ft.Text("Bitte zuerst die LAN-IP eintragen!"), bgcolor="red") page.snack_bar = ft.SnackBar(
ft.Text("Bitte zuerst die LAN-IP eintragen!"), bgcolor="red"
)
page.snack_bar.open = True page.snack_bar.open = True
page.update() page.update()
return return
try: try:
log(f"Frage Rippy nach Freigabe ({r_host})...") 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: with urllib.request.urlopen(
f"http://{r_host}/api/worker-setup/pfad-map", timeout=5
) as r:
data = json.loads(r.read().decode()) data = json.loads(r.read().decode())
if data.get("hinweis"): if data.get("hinweis"):
log(data["hinweis"]) log(data["hinweis"])
@@ -95,11 +129,14 @@ def main(page: ft.Page):
python_exe = None python_exe = None
for cmd in ["py -3", "python"]: for cmd in ["py -3", "python"]:
try: try:
res = subprocess.run(f"{cmd} --version", shell=True, capture_output=True, text=True) res = subprocess.run(
f"{cmd} --version", shell=True, capture_output=True, text=True
)
if "Python 3." in res.stdout: if "Python 3." in res.stdout:
python_exe = cmd python_exe = cmd
break break
except: pass except Exception:
pass
if not python_exe: if not python_exe:
log("FEHLER: Python 3.10+ nicht gefunden.") log("FEHLER: Python 3.10+ nicht gefunden.")
@@ -113,9 +150,10 @@ def main(page: ft.Page):
try: try:
os.makedirs(install_dir, exist_ok=True) os.makedirs(install_dir, exist_ok=True)
test_file = os.path.join(install_dir, ".test") test_file = os.path.join(install_dir, ".test")
with open(test_file, "w") as f: f.write("x") with open(test_file, "w") as f:
f.write("x")
os.remove(test_file) os.remove(test_file)
except Exception as ex: except Exception:
log(f"FEHLER: Keine Schreibrechte in {install_dir}") log(f"FEHLER: Keine Schreibrechte in {install_dir}")
log("Bitte als Administrator starten oder Pfad aendern.") log("Bitte als Administrator starten oder Pfad aendern.")
btn_install.disabled = False btn_install.disabled = False
@@ -126,8 +164,10 @@ def main(page: ft.Page):
log(f"Lade Worker-Code von {r_host}...") log(f"Lade Worker-Code von {r_host}...")
try: try:
zip_path = os.path.join(install_dir, "worker.zip") zip_path = os.path.join(install_dir, "worker.zip")
urllib.request.urlretrieve(f"http://{r_host}/api/worker-setup/paket", zip_path) urllib.request.urlretrieve(
with zipfile.ZipFile(zip_path, 'r') as zip_ref: f"http://{r_host}/api/worker-setup/paket", zip_path
)
with zipfile.ZipFile(zip_path, "r") as zip_ref:
zip_ref.extractall(install_dir) zip_ref.extractall(install_dir)
os.remove(zip_path) os.remove(zip_path)
except Exception as ex: except Exception as ex:
@@ -139,12 +179,26 @@ def main(page: ft.Page):
# Setup venv — --clear loescht einen alten venv komplett, damit keine # Setup venv — --clear loescht einen alten venv komplett, damit keine
# veralteten Pakete (z.B. flet 0.86 statt 0.23) ueberleben. # veralteten Pakete (z.B. flet 0.86 statt 0.23) ueberleben.
log("Lege Python-Umgebung an (venv)...") log("Lege Python-Umgebung an (venv)...")
subprocess.run(f"{python_exe} -m venv --clear venv", cwd=install_dir, shell=True) subprocess.run(
f"{python_exe} -m venv --clear venv", cwd=install_dir, shell=True
)
log("Installiere Abhaengigkeiten (pip)...") log("Installiere Abhaengigkeiten (pip)...")
subprocess.run(r"venv\Scripts\python.exe -m pip install --quiet --upgrade pip", cwd=install_dir, shell=True) subprocess.run(
subprocess.run(r"venv\Scripts\pip.exe install --quiet -r requirements.txt", cwd=install_dir, shell=True) r"venv\Scripts\python.exe -m pip install --quiet --upgrade pip",
subprocess.run(r"venv\Scripts\pip.exe install --quiet requests pillow", cwd=install_dir, shell=True) 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 # Download Handbrake
hb_path = os.path.join(install_dir, "HandBrakeCLI.exe") hb_path = os.path.join(install_dir, "HandBrakeCLI.exe")
@@ -153,13 +207,21 @@ def main(page: ft.Page):
try: try:
hb_ver = "1.7.3" # Fallback if github fails hb_ver = "1.7.3" # Fallback if github fails
try: try:
with urllib.request.urlopen("https://api.github.com/repos/HandBrake/HandBrake/releases/latest", timeout=5) as r: with urllib.request.urlopen(
"https://api.github.com/repos/HandBrake/HandBrake/releases/latest",
timeout=5,
) as r:
hb_data = json.loads(r.read().decode()) hb_data = json.loads(r.read().decode())
if hb_data.get("tag_name"): hb_ver = hb_data["tag_name"].lstrip("v") if hb_data.get("tag_name"):
except: pass hb_ver = hb_data["tag_name"].lstrip("v")
except Exception:
pass
hb_zip = os.path.join(install_dir, "hb.zip") 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) urllib.request.urlretrieve(
with zipfile.ZipFile(hb_zip, 'r') as zip_ref: 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) zip_ref.extractall(install_dir)
os.remove(hb_zip) os.remove(hb_zip)
except Exception as ex: except Exception as ex:
@@ -167,14 +229,24 @@ def main(page: ft.Page):
log("Erstelle Start-Skripte...") log("Erstelle Start-Skripte...")
slots = txt_slots.value slots = txt_slots.value
pool_arg = "--pool=solo" if int(slots) <= 1 else f"--pool=threads --concurrency={slots}" pool_arg = (
map_zeile = f"set RIPPY_PATH_MAP={txt_map.value.strip()}\r\n" if txt_map.value.strip() else "" "--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" 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) 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" 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) with open(os.path.join(install_dir, "start-worker.bat"), "w") as f:
f.write(work_bat)
# Uninstaller # Uninstaller
uninstall_ps1 = f"""param([switch]$Force) uninstall_ps1 = f"""param([switch]$Force)
@@ -193,34 +265,50 @@ 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 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") }} 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) with open(
os.path.join(install_dir, "uninstall.ps1"), "w", encoding="utf-8"
) as f:
f.write(uninstall_ps1)
# Autostart # Autostart
if chk_auto.value: if chk_auto.value:
log("Richte Autostart (Task Scheduler) ein...") log("Richte Autostart (Task Scheduler) ein...")
bat_path = os.path.join(install_dir, "start-gui.bat") 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) 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 # Desktop Shortcut — ueber PowerShell/COM, weil win32com im
# PyInstaller-Bundle nicht enthalten ist. # PyInstaller-Bundle nicht enthalten ist.
if chk_desktop.value: if chk_desktop.value:
try: try:
public_desktop = os.path.join(os.environ.get("PUBLIC", r"C:\Users\Public"), "Desktop") public_desktop = os.path.join(
os.environ.get("PUBLIC", r"C:\Users\Public"), "Desktop"
)
user_desktop = os.path.join(os.path.expanduser("~"), "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 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") lnk_path = os.path.join(desktop, "Rippy Worker.lnk")
bat_target = os.path.join(install_dir, "start-gui.bat") bat_target = os.path.join(install_dir, "start-gui.bat")
ico_path = os.path.join(install_dir, "rippy.ico") ico_path = os.path.join(install_dir, "rippy.ico")
ps_cmd = ( ps_cmd = (
f'$ws = New-Object -ComObject WScript.Shell; ' f"$ws = New-Object -ComObject WScript.Shell; "
f'$sc = $ws.CreateShortcut(\"{lnk_path}\"); ' f'$sc = $ws.CreateShortcut("{lnk_path}"); '
f'$sc.TargetPath = \"{bat_target}\"; ' f'$sc.TargetPath = "{bat_target}"; '
f'$sc.WorkingDirectory = \"{install_dir}\"; ' f'$sc.WorkingDirectory = "{install_dir}"; '
f'$sc.IconLocation = \"{ico_path}\"; ' f'$sc.IconLocation = "{ico_path}"; '
f'$sc.WindowStyle = 7; ' f"$sc.WindowStyle = 7; "
f'$sc.Save()' f"$sc.Save()"
)
subprocess.run(
["powershell", "-NoProfile", "-Command", ps_cmd],
capture_output=True,
) )
subprocess.run(["powershell", "-NoProfile", "-Command", ps_cmd], capture_output=True)
log("Desktop-Verknüpfung erstellt.") log("Desktop-Verknüpfung erstellt.")
except Exception as e: except Exception as e:
log(f"Warnung: Shortcut konnte nicht erstellt werden: {e}") log(f"Warnung: Shortcut konnte nicht erstellt werden: {e}")
@@ -239,8 +327,23 @@ if (-not $Force) {{ [System.Windows.Forms.MessageBox]::Show("Rippy-Worker entfer
os.startfile(bat_path) os.startfile(bat_path)
page.window_close() page.window_close()
btn_install = ft.ElevatedButton("Installieren", on_click=do_install, bgcolor=c_accent, color=ft.colors.WHITE, width=200, height=40) btn_install = ft.ElevatedButton(
btn_start = ft.ElevatedButton("Worker starten", on_click=start_worker, bgcolor=c_success, color=ft.colors.WHITE, width=200, height=40, visible=False) "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): def get_dir(e: ft.FilePickerResultEvent):
if e.path: if e.path:
@@ -251,21 +354,59 @@ if (-not $Force) {{ [System.Windows.Forms.MessageBox]::Show("Rippy-Worker entfer
page.overlay.append(dir_picker) page.overlay.append(dir_picker)
page.add( page.add(
ft.Row([ ft.Row(
[
ft.Icon(ft.icons.DISC_FULL, size=40, color=c_accent), 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(
]), "Rippy Encoding-Worker",
ft.Text("Diese Maschine übernimmt die Video-Kompression für Rippy. Gerippt wird weiter auf der Hauptmaschine.", color=c_muted), 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.Divider(color="#334155"),
ft.Row([txt_host], alignment=ft.MainAxisAlignment.START), ft.Row([txt_host], alignment=ft.MainAxisAlignment.START),
ft.Row([txt_name, txt_slots], 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(
ft.Row([txt_map, ft.ElevatedButton("Freigabe holen", on_click=check_map, bgcolor=c_panel, color=c_text)], alignment=ft.MainAxisAlignment.START), [
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_auto,
chk_desktop, chk_desktop,
ft.Container(content=log_view, bgcolor="#020617", padding=10, border_radius=5, border=ft.border.all(1, "#334155")), ft.Container(
ft.Row([btn_install, btn_start]) 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__": if __name__ == "__main__":
ft.app(target=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
+261 -100
View File
@@ -1,6 +1,6 @@
import verwaltung
import os import os
import subprocess import subprocess
import sys
import threading import threading
import time import time
import urllib.request import urllib.request
@@ -13,6 +13,7 @@ import flet as ft
try: try:
import pystray import pystray
from PIL import Image as PILImage from PIL import Image as PILImage
TRAY_VERFUEGBAR = True TRAY_VERFUEGBAR = True
except ImportError: except ImportError:
TRAY_VERFUEGBAR = False TRAY_VERFUEGBAR = False
@@ -24,6 +25,7 @@ WORKER_NAME = os.getenv("WORKER_NAME", "windows-worker")
SLOTS = max(1, int(os.getenv("RIPPY_SLOTS", "1") or "1")) SLOTS = max(1, int(os.getenv("RIPPY_SLOTS", "1") or "1"))
ABFRAGE_TAKT_SEKUNDEN = 4 ABFRAGE_TAKT_SEKUNDEN = 4
# Log file path # Log file path
def _log_pfad() -> str: def _log_pfad() -> str:
basis_daten = os.getenv("LOCALAPPDATA") or os.getenv("APPDATA") or "" basis_daten = os.getenv("LOCALAPPDATA") or os.getenv("APPDATA") or ""
@@ -33,6 +35,7 @@ def _log_pfad() -> str:
return os.path.join(ordner, "worker.log") return os.path.join(ordner, "worker.log")
return os.path.join(BASIS, "worker.log") return os.path.join(BASIS, "worker.log")
LOG_PFAD = _log_pfad() LOG_PFAD = _log_pfad()
# State variables # State variables
@@ -42,11 +45,13 @@ aktuell = {"job": None, "online": False}
# Windows Standby blocker # Windows Standby blocker
_wach = {"an": False} _wach = {"an": False}
def wach_halten(an: bool) -> None: def wach_halten(an: bool) -> None:
if _wach["an"] == an: if _wach["an"] == an:
return return
try: try:
import ctypes import ctypes
ES_CONTINUOUS = 0x80000000 ES_CONTINUOUS = 0x80000000
ES_SYSTEM_REQUIRED = 0x00000001 ES_SYSTEM_REQUIRED = 0x00000001
flags = (ES_CONTINUOUS | ES_SYSTEM_REQUIRED) if an else ES_CONTINUOUS flags = (ES_CONTINUOUS | ES_SYSTEM_REQUIRED) if an else ES_CONTINUOUS
@@ -55,6 +60,7 @@ def wach_halten(an: bool) -> None:
except Exception: except Exception:
pass pass
# Celery Subprocess Management # Celery Subprocess Management
def _leser(prozess_ref, datei, log_callback) -> None: def _leser(prozess_ref, datei, log_callback) -> None:
try: try:
@@ -74,9 +80,11 @@ def _leser(prozess_ref, datei, log_callback) -> None:
except OSError: except OSError:
pass pass
def worker_laeuft() -> bool: def worker_laeuft() -> bool:
return prozess is not None and prozess.poll() is None return prozess is not None and prozess.poll() is None
def worker_starten(log_callback=None): def worker_starten(log_callback=None):
global prozess global prozess
if worker_laeuft(): if worker_laeuft():
@@ -86,23 +94,39 @@ def worker_starten(log_callback=None):
celery = "celery" # fallback to global if testing outside venv celery = "celery" # fallback to global if testing outside venv
befehl = [ befehl = [
celery, "-A", "celery_app", "worker", "--loglevel=info", celery,
"-Q", "transcode", "-n", f"{WORKER_NAME}@%h", "-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}"]) befehl += (
["--pool=solo"] if SLOTS == 1 else ["--pool=threads", f"--concurrency={SLOTS}"]
)
log = open(LOG_PFAD, "a", encoding="utf-8", errors="replace") log = open(LOG_PFAD, "a", encoding="utf-8", errors="replace")
prozess = subprocess.Popen( prozess = subprocess.Popen(
befehl, cwd=BASIS, befehl,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=BASIS,
text=True, bufsize=1, errors="replace", stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
errors="replace",
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
) )
threading.Thread( threading.Thread(
target=_leser, args=(prozess, log, log_callback), target=_leser,
daemon=True, name="log-leser", args=(prozess, log, log_callback),
daemon=True,
name="log-leser",
).start() ).start()
def worker_stoppen(): def worker_stoppen():
global prozess global prozess
if worker_laeuft(): if worker_laeuft():
@@ -115,8 +139,9 @@ def worker_stoppen():
wach_halten(False) wach_halten(False)
aktuell["job"] = None aktuell["job"] = None
# API Polling # API Polling
def hole(pfad: str, host: str = None, timeout: int = 5): def old_hole(pfad: str, host: str = None, timeout: int = 5):
ziel = host if host is not None else RIPPY_HOST ziel = host if host is not None else RIPPY_HOST
if not ziel: if not ziel:
return None return None
@@ -126,13 +151,14 @@ def hole(pfad: str, host: str = None, timeout: int = 5):
except Exception: except Exception:
return None return None
def hole_job_und_zustand(): def hole_job_und_zustand():
caps = hole("/capabilities") caps = verwaltung.hole("/capabilities")
jobs = hole("/jobs") or [] jobs = verwaltung.hole("/jobs") or []
online = False online = False
w_info = {} w_info = {}
for w in ((caps or {}).get("workers") or []): for w in (caps or {}).get("workers") or []:
if w.get("name") == WORKER_NAME: if w.get("name") == WORKER_NAME:
online = bool(w.get("online")) online = bool(w.get("online"))
w_info = w w_info = w
@@ -149,6 +175,7 @@ def hole_job_und_zustand():
wach_halten(bool(laufend)) wach_halten(bool(laufend))
return online, laufend, caps, jobs, w_info return online, laufend, caps, jobs, w_info
def deinstallieren() -> None: def deinstallieren() -> None:
skript = os.path.join(BASIS, "uninstall.ps1") skript = os.path.join(BASIS, "uninstall.ps1")
if not os.path.isfile(skript): if not os.path.isfile(skript):
@@ -158,6 +185,7 @@ def deinstallieren() -> None:
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
) )
def _bruecke_bauen(): def _bruecke_bauen():
try: try:
import db import db
@@ -172,9 +200,11 @@ def _bruecke_bauen():
except Exception: except Exception:
return None return None
def _schluessel_wache(bruecke=None, log_callback=None): def _schluessel_wache(bruecke=None, log_callback=None):
try: try:
import schluessel import schluessel
if not schluessel.makemkvcon_pfad(): if not schluessel.makemkvcon_pfad():
return return
@@ -191,11 +221,14 @@ def _schluessel_wache(bruecke=None, log_callback=None):
stand, _was = schluessel.runde(RIPPY_HOST, stand, melden=melden) stand, _was = schluessel.runde(RIPPY_HOST, stand, melden=melden)
except Exception as e: except Exception as e:
if log_callback: if log_callback:
log_callback(f"[schluessel] ERROR Schlüssel-Automatik fehlgeschlagen: {e}\n") log_callback(
f"[schluessel] ERROR Schlüssel-Automatik fehlgeschlagen: {e}\n"
)
time.sleep(schluessel.TAKT_SEKUNDEN) time.sleep(schluessel.TAKT_SEKUNDEN)
except ImportError: except ImportError:
pass pass
# GUI Application # GUI Application
def main(page: ft.Page): def main(page: ft.Page):
page.title = f"Rippy Worker — {WORKER_NAME}" page.title = f"Rippy Worker — {WORKER_NAME}"
@@ -246,8 +279,9 @@ def main(page: ft.Page):
pystray.Menu.SEPARATOR, pystray.Menu.SEPARATOR,
pystray.MenuItem("Beenden", tray_beenden), pystray.MenuItem("Beenden", tray_beenden),
) )
icon = pystray.Icon("rippy_worker", _tray_icon_bild(), icon = pystray.Icon(
f"Rippy Worker — {WORKER_NAME}", menu) "rippy_worker", _tray_icon_bild(), f"Rippy Worker — {WORKER_NAME}", menu
)
tray_icon_ref["icon"] = icon tray_icon_ref["icon"] = icon
threading.Thread(target=icon.run, daemon=True, name="tray-icon").start() threading.Thread(target=icon.run, daemon=True, name="tray-icon").start()
@@ -267,7 +301,7 @@ def main(page: ft.Page):
tray_starten() tray_starten()
# Colors # Colors
c_bg = "#0f172a"
c_panel = "#1e293b" c_panel = "#1e293b"
c_text = "#e2e8f0" c_text = "#e2e8f0"
c_muted = "#94a3b8" c_muted = "#94a3b8"
@@ -277,7 +311,7 @@ def main(page: ft.Page):
c_danger = "#f43f5e" # Rose c_danger = "#f43f5e" # Rose
# Formatter for job status # Formatter for job status
def job_text(job: dict) -> str: def old_job_text(job: dict) -> str:
titel = (job.get("title") or (job.get("id") or "")[:8]) or "?" titel = (job.get("title") or (job.get("id") or "")[:8]) or "?"
status = { status = {
"transcoding": "komprimiert", "transcoding": "komprimiert",
@@ -291,12 +325,22 @@ def main(page: ft.Page):
return f"{titel}{status}" return f"{titel}{status}"
# UI Components # UI Components
status_text = ft.Text("Verbinde...", color=c_muted, size=18, weight=ft.FontWeight.BOLD) status_text = ft.Text(
title_text = ft.Text(f"Rippy Worker: {WORKER_NAME}", size=24, weight=ft.FontWeight.BOLD, color=c_text) "Verbinde...", color=c_muted, size=18, weight=ft.FontWeight.BOLD
machine_info = ft.Text("System-Infos werden geladen...", color=c_muted, size=13, font_family="Consolas") )
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_ring = ft.ProgressRing(
progress_percentage = ft.Text("0%", size=28, weight=ft.FontWeight.BOLD, color=c_text) 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) job_title = ft.Text("Nichts in Arbeit", size=20, color=c_muted)
eta_text = ft.Text("Bereit", size=14, color=c_muted) eta_text = ft.Text("Bereit", size=14, color=c_muted)
@@ -308,7 +352,7 @@ def main(page: ft.Page):
alignment=ft.alignment.center, alignment=ft.alignment.center,
width=120, width=120,
height=120, height=120,
) ),
], ],
width=120, width=120,
height=120, height=120,
@@ -316,32 +360,53 @@ def main(page: ft.Page):
# Live Log Regex and Layout # Live Log Regex and Layout
# [2026-07-26 20:34:00,093: INFO/MainProcess] message... # [2026-07-26 20:34:00,093: INFO/MainProcess] message...
LOG_REGEX = re.compile(r"^\[.*? (\d{2}:\d{2}:\d{2}),.*?: (INFO|WARNING|ERROR|SUCCESS).*?\] (.*)") LOG_REGEX = re.compile(
r"^\[.*? (\d{2}:\d{2}:\d{2}),.*?: (INFO|WARNING|ERROR|SUCCESS).*?\] (.*)"
)
log_view = ft.ListView(expand=1, spacing=2, auto_scroll=True) log_view = ft.ListView(expand=1, spacing=0, auto_scroll=True)
queue_view = ft.ListView(height=120, spacing=4, auto_scroll=False) queue_view = ft.ListView(height=120, spacing=4, auto_scroll=False)
def add_log(zeile): def add_log(zeile):
z = zeile.strip() z = zeile.strip()
if not z: return if not z:
return
match = LOG_REGEX.match(z) match = LOG_REGEX.match(z)
if match: if match:
zeit, level, msg = match.groups() zeit, level, msg = match.groups()
color = c_text color = c_text
if level == "INFO": color = "#38bdf8" # sky-400 if level == "INFO":
elif level == "WARNING": color = "#fbbf24" # amber-400 color = "#38bdf8" # sky-400
elif level == "ERROR": color = "#f87171" # red-400 elif level == "WARNING":
elif level == "SUCCESS": color = "#34d399" # emerald-400 color = "#fbbf24" # amber-400
elif level == "ERROR":
color = "#f87171" # red-400
elif level == "SUCCESS":
color = "#34d399" # emerald-400
row = ft.Row([ row = ft.Row(
[
ft.Text(zeit, color=c_muted, size=12, font_family="Consolas"), ft.Text(zeit, color=c_muted, size=12, font_family="Consolas"),
ft.Text(f"[{level}]", color=color, size=12, font_family="Consolas", weight=ft.FontWeight.BOLD), ft.Text(
ft.Text(msg, color=c_text, size=12, font_family="Consolas", expand=True) f"[{level}]",
], spacing=10, vertical_alignment=ft.CrossAxisAlignment.START) color=color,
size=12,
font_family="Consolas",
weight=ft.FontWeight.BOLD,
),
ft.Text(
msg, color=c_text, size=12, font_family="Consolas", expand=True, selectable=True
),
],
spacing=10,
vertical_alignment=ft.CrossAxisAlignment.START,
)
log_view.controls.append(row) log_view.controls.append(row)
else: else:
log_view.controls.append(ft.Text(z, color=c_muted, size=12, font_family="Consolas")) log_view.controls.append(
ft.Text(z, color=c_muted, size=12, font_family="Consolas", selectable=True)
)
if len(log_view.controls) > 200: if len(log_view.controls) > 200:
log_view.controls.pop(0) log_view.controls.pop(0)
@@ -353,10 +418,7 @@ def main(page: ft.Page):
def toggle_worker(e): def toggle_worker(e):
if not worker_laeuft(): if not worker_laeuft():
worker_starten(add_log) worker_starten(add_log)
btn_toggle.text = "Worker läuft" btn_toggle.visible = False
btn_toggle.icon = ft.icons.CHECK
btn_toggle.bgcolor = "#1e293b"
btn_toggle.disabled = True
page.update() page.update()
btn_toggle = ft.ElevatedButton( btn_toggle = ft.ElevatedButton(
@@ -365,7 +427,7 @@ def main(page: ft.Page):
bgcolor="#1e293b" if worker_laeuft() else c_success, bgcolor="#1e293b" if worker_laeuft() else c_success,
color="white", color="white",
disabled=worker_laeuft(), disabled=worker_laeuft(),
on_click=toggle_worker on_click=toggle_worker,
) )
def uninstall_click(e): def uninstall_click(e):
@@ -379,10 +441,16 @@ def main(page: ft.Page):
dlg = ft.AlertDialog( dlg = ft.AlertDialog(
title=ft.Text("Deinstallieren?"), title=ft.Text("Deinstallieren?"),
content=ft.Text(f"Worker '{WORKER_NAME}' wirklich entfernen? Das beendet den Prozess und entfernt ihn aus dem Autostart."), content=ft.Text(
f"Worker '{WORKER_NAME}' wirklich entfernen? Das beendet den Prozess und entfernt ihn aus dem Autostart."
),
actions=[ actions=[
ft.TextButton("Abbrechen", on_click=close_dlg), ft.TextButton("Abbrechen", on_click=close_dlg),
ft.TextButton("Ja, Deinstallieren", on_click=do_uninstall, style=ft.ButtonStyle(color=c_danger)), ft.TextButton(
"Ja, Deinstallieren",
on_click=do_uninstall,
style=ft.ButtonStyle(color=c_danger),
),
], ],
actions_alignment=ft.MainAxisAlignment.END, actions_alignment=ft.MainAxisAlignment.END,
) )
@@ -394,63 +462,105 @@ def main(page: ft.Page):
text="Deinstallieren", text="Deinstallieren",
icon=ft.icons.DELETE, icon=ft.icons.DELETE,
icon_color=c_danger, icon_color=c_danger,
on_click=uninstall_click on_click=uninstall_click,
) )
btn_dashboard = ft.TextButton( btn_dashboard = ft.TextButton(
text="Rippy Dashboard", text="Rippy Dashboard",
icon=ft.icons.OPEN_IN_BROWSER, icon=ft.icons.OPEN_IN_BROWSER,
on_click=lambda _: webbrowser.open(f"http://{RIPPY_HOST}") if RIPPY_HOST else None on_click=lambda _: (
webbrowser.open(f"http://{RIPPY_HOST}") if RIPPY_HOST else None
),
) )
btn_logs = ft.TextButton( btn_logs = ft.TextButton(
text="Log in Rippy", text="Log in Rippy",
icon=ft.icons.FORMAT_ALIGN_LEFT, icon=ft.icons.FORMAT_ALIGN_LEFT,
on_click=lambda _: webbrowser.open(f"http://{RIPPY_HOST}/logs") if RIPPY_HOST else None on_click=lambda _: (
webbrowser.open(f"http://{RIPPY_HOST}/logs") if RIPPY_HOST else None
),
) )
btn_local_log = ft.TextButton( btn_local_log = ft.TextButton(
text="Lokales Log", text="Lokales Log",
icon=ft.icons.INSERT_DRIVE_FILE, icon=ft.icons.INSERT_DRIVE_FILE,
on_click=lambda _: os.startfile(LOG_PFAD) if os.path.exists(LOG_PFAD) else None on_click=lambda _: os.startfile(LOG_PFAD) if os.path.exists(LOG_PFAD) else None,
) )
# Layout # Layout
header = ft.Row( header = ft.Row(
[title_text, ft.Container(expand=True), btn_dashboard, btn_logs, btn_local_log, btn_uninstall], [
alignment=ft.MainAxisAlignment.SPACE_BETWEEN title_text,
ft.Container(expand=True),
btn_dashboard,
btn_logs,
btn_local_log,
btn_uninstall,
],
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
) )
active_poster = ft.Image(src="", visible=False, width=64, height=96, border_radius=8, fit=ft.ImageFit.COVER) active_poster = ft.Image(
src="",
visible=False,
width=72,
height=108,
border_radius=10,
fit=ft.ImageFit.COVER,
)
active_poster_container = ft.Container(
content=active_poster,
border=ft.border.all(1, c_warning),
border_radius=10,
shadow=ft.BoxShadow(spread_radius=1, blur_radius=5, color=ft.colors.with_opacity(0.3, ft.colors.BLACK)),
visible=False
)
dashboard_card = ft.Container( dashboard_card = ft.Container(
content=ft.Column([ content=ft.Column(
[
status_text, status_text,
machine_info, machine_info,
ft.Container(height=5), ft.Container(height=5),
ft.Row([ ft.Row(
active_poster, [
active_poster_container,
progress_stack, progress_stack,
ft.Column([job_title, eta_text], alignment=ft.MainAxisAlignment.CENTER, spacing=10) ft.Column(
], alignment=ft.MainAxisAlignment.START, spacing=30), [job_title, eta_text],
alignment=ft.MainAxisAlignment.CENTER,
spacing=10,
),
],
alignment=ft.MainAxisAlignment.START,
spacing=30,
),
ft.Container(height=10), ft.Container(height=10),
btn_toggle btn_toggle,
]), ]
),
bgcolor=c_panel, bgcolor=c_panel,
border_radius=15, border_radius=15,
padding=30, padding=30,
border=ft.border.all(1, "#334155") border=ft.border.all(1, "#334155"),
) )
queue_card = ft.Container( queue_card = ft.Container(
content=ft.Column([ content=ft.Column(
ft.Text("Aufgaben (Warteschlange)", size=16, weight=ft.FontWeight.BOLD, color=c_text), [
queue_view ft.Text(
]), "Aufgaben (Warteschlange)",
size=16,
weight=ft.FontWeight.BOLD,
color=c_text,
),
queue_view,
]
),
bgcolor="#020617", bgcolor="#020617",
border_radius=10, border_radius=10,
padding=15, padding=15,
border=ft.border.all(1, "#1e293b") border=ft.border.all(1, "#1e293b"),
) )
terminal_card = ft.Container( terminal_card = ft.Container(
@@ -459,7 +569,7 @@ def main(page: ft.Page):
border_radius=10, border_radius=10,
padding=15, padding=15,
expand=True, expand=True,
border=ft.border.all(1, "#1e293b") border=ft.border.all(1, "#1e293b"),
) )
page.add( page.add(
@@ -470,7 +580,7 @@ def main(page: ft.Page):
queue_card, queue_card,
ft.Container(height=10), ft.Container(height=10),
ft.Text("Live Logs", size=16, weight=ft.FontWeight.BOLD, color=c_text), ft.Text("Live Logs", size=16, weight=ft.FontWeight.BOLD, color=c_text),
terminal_card terminal_card,
) )
# Background polling loop # Background polling loop
@@ -481,30 +591,80 @@ def main(page: ft.Page):
# Update System Info # Update System Info
info = w_info.get("info") or {} info = w_info.get("info") or {}
teile = [t for t in ( teile = [
t
for t in (
info.get("cpu_modell"), info.get("cpu_modell"),
f"{info.get('cpu_kerne')} Kerne" if info.get("cpu_kerne") else "", f"{info.get('cpu_kerne')} Kerne"
info.get("cpu_simd") if info.get("cpu_simd") not in ("", "unbekannt") else "", 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 []), ", ".join(w_info.get("encoders") or []),
) if t] )
machine_info.value = " · ".join(teile) if teile else f"{SLOTS} Aufträge gleichzeitig möglich" if t
]
machine_info.value = (
" · ".join(teile)
if teile
else f"{SLOTS} Aufträge gleichzeitig möglich"
)
# Update Queue # Update Queue
queue_view.controls.clear() queue_view.controls.clear()
if not jobs: if not jobs:
queue_view.controls.append(ft.Text("Keine Aufgaben in Rippy", color=c_muted, font_family="Consolas", size=13)) queue_view.controls.append(
ft.Text(
"Keine Aufgaben in Rippy",
color=c_muted,
font_family="Consolas",
size=13,
)
)
else: else:
for j in jobs[:8]: for j in jobs[:8]:
poster = None poster = None
if j.get("meta") and j["meta"].get("poster_path"): if j.get("meta") and j["meta"].get("poster_path"):
p = j["meta"]["poster_path"] p = j["meta"]["poster_path"]
poster = p if p.startswith("http") else f"https://image.tmdb.org/t/p/w342{p}" poster = (
p
if p.startswith("http")
else f"https://image.tmdb.org/t/p/w342{p}"
)
row_items = [] row_items = []
if poster: if poster:
row_items.append(ft.Image(src=poster, width=40, height=60, border_radius=5, fit=ft.ImageFit.COVER)) row_items.append(
row_items.append(ft.Text(job_text(j), color=c_muted, font_family="Consolas", size=13, expand=True)) ft.Container(
queue_view.controls.append(ft.Row(row_items, alignment=ft.MainAxisAlignment.START, spacing=15)) content=ft.Image(
src=poster,
width=32,
height=48,
border_radius=6,
fit=ft.ImageFit.COVER,
),
border=ft.border.all(1, "#334155"),
border_radius=6,
shadow=ft.BoxShadow(spread_radius=1, blur_radius=3, color=ft.colors.with_opacity(0.3, ft.colors.BLACK))
)
)
row_items.append(
ft.Text(
verwaltung.job_text(j).split("")[0].split(",")[0],
color=c_muted,
font_family="Consolas",
size=13,
expand=True,
)
)
queue_view.controls.append(
ft.Row(
row_items,
alignment=ft.MainAxisAlignment.START,
spacing=15,
)
)
# Update status # Update status
if not worker_laeuft(): if not worker_laeuft():
@@ -514,7 +674,8 @@ def main(page: ft.Page):
progress_percentage.value = "0%" progress_percentage.value = "0%"
job_title.value = "Nichts in Arbeit" job_title.value = "Nichts in Arbeit"
eta_text.value = "Bereit" eta_text.value = "Bereit"
active_poster.visible = False active_poster_container.visible = False
btn_toggle.visible = True
btn_toggle.disabled = False btn_toggle.disabled = False
btn_toggle.text = "Worker Starten" btn_toggle.text = "Worker Starten"
btn_toggle.icon = ft.icons.PLAY_ARROW btn_toggle.icon = ft.icons.PLAY_ARROW
@@ -524,7 +685,11 @@ def main(page: ft.Page):
status_text.color = c_warning status_text.color = c_warning
progress_ring.value = 0 progress_ring.value = 0
progress_percentage.value = "0%" progress_percentage.value = "0%"
active_poster.visible = False active_poster_container.visible = False
btn_toggle.visible = True
btn_toggle.disabled = True
btn_toggle.text = "Warte auf Verbindung..."
btn_toggle.bgcolor = "#1e293b"
elif job: elif job:
status_text.value = "Aktiv: Komprimiert" status_text.value = "Aktiv: Komprimiert"
status_text.color = c_success status_text.color = c_success
@@ -540,13 +705,19 @@ def main(page: ft.Page):
poster = None poster = None
if job.get("meta") and job["meta"].get("poster_path"): if job.get("meta") and job["meta"].get("poster_path"):
p = job["meta"]["poster_path"] p = job["meta"]["poster_path"]
poster = p if p.startswith("http") else f"https://image.tmdb.org/t/p/w342{p}" poster = (
p
if p.startswith("http")
else f"https://image.tmdb.org/t/p/w342{p}"
)
if poster: if poster:
active_poster.src = poster active_poster.src = poster
active_poster.visible = True active_poster_container.visible = True
else: else:
active_poster.visible = False active_poster_container.visible = False
btn_toggle.visible = False
else: else:
status_text.value = "Verbunden & Bereit" status_text.value = "Verbunden & Bereit"
status_text.color = c_accent status_text.color = c_accent
@@ -554,15 +725,11 @@ def main(page: ft.Page):
progress_percentage.value = "0%" progress_percentage.value = "0%"
job_title.value = "Warte auf Encoder-Aufträge..." job_title.value = "Warte auf Encoder-Aufträge..."
eta_text.value = "Nichts in Arbeit" eta_text.value = "Nichts in Arbeit"
active_poster.visible = False active_poster_container.visible = False
btn_toggle.visible = False
btn_toggle.disabled = True
btn_toggle.text = "Worker läuft"
btn_toggle.icon = ft.icons.CHECK
btn_toggle.bgcolor = "#1e293b"
page.update() page.update()
except Exception as e: except Exception:
pass pass
time.sleep(ABFRAGE_TAKT_SEKUNDEN) time.sleep(ABFRAGE_TAKT_SEKUNDEN)
@@ -580,20 +747,14 @@ def main(page: ft.Page):
pass pass
threading.Thread(target=poll_loop, daemon=True).start() threading.Thread(target=poll_loop, daemon=True).start()
threading.Thread(target=_schluessel_wache, args=(_bruecke_bauen(), add_log), daemon=True, name="schluessel-wache").start() threading.Thread(
target=_schluessel_wache,
args=(_bruecke_bauen(), add_log),
daemon=True,
name="schluessel-wache",
).start()
def worker_stoppen():
"""Celery-Prozess sauber beenden."""
global prozess
if prozess and prozess.poll() is None:
try:
prozess.terminate()
prozess.wait(timeout=5)
except Exception:
try:
prozess.kill()
except Exception:
pass
if __name__ == "__main__": if __name__ == "__main__":
ft.app(target=main) ft.app(target=main)
+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",
+3
View File
@@ -4,6 +4,9 @@ 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 os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import verwaltung import verwaltung
+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")