From 061f79ecdae5be13c8f8279e1d2b15315dc3e8a5 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Mon, 27 Jul 2026 17:00:52 +0200 Subject: [PATCH] fix: ruff linter errors (multiple statements, bare except, unused vars) --- deploy/worker-windows/installer.py | 683 ++++++++------ docker/worker/gui.py | 1346 +++++++++++++++------------- 2 files changed, 1159 insertions(+), 870 deletions(-) diff --git a/deploy/worker-windows/installer.py b/deploy/worker-windows/installer.py index 80cc281..413be28 100644 --- a/deploy/worker-windows/installer.py +++ b/deploy/worker-windows/installer.py @@ -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) diff --git a/docker/worker/gui.py b/docker/worker/gui.py index dc3fa74..93154fb 100644 --- a/docker/worker/gui.py +++ b/docker/worker/gui.py @@ -1,599 +1,747 @@ -import os -import subprocess -import sys -import threading -import time -import urllib.request -import json -import webbrowser -import re -import flet as ft - -# Tray-Icon: pystray + PIL fuer das Icon-Bild -try: - import pystray - from PIL import Image as PILImage - TRAY_VERFUEGBAR = True -except ImportError: - TRAY_VERFUEGBAR = False - -# Environment configuration -BASIS = os.path.dirname(os.path.abspath(__file__)) -RIPPY_HOST = os.getenv("RIPPY_TRAY_HOST", "") or os.getenv("RIPPY_HOST", "") -WORKER_NAME = os.getenv("WORKER_NAME", "windows-worker") -SLOTS = max(1, int(os.getenv("RIPPY_SLOTS", "1") or "1")) -ABFRAGE_TAKT_SEKUNDEN = 4 - -# Log file path -def _log_pfad() -> str: - basis_daten = os.getenv("LOCALAPPDATA") or os.getenv("APPDATA") or "" - if basis_daten: - ordner = os.path.join(basis_daten, "Rippy Worker") - os.makedirs(ordner, exist_ok=True) - return os.path.join(ordner, "worker.log") - return os.path.join(BASIS, "worker.log") - -LOG_PFAD = _log_pfad() - -# State variables -prozess = None -aktuell = {"job": None, "online": False} - -# Windows Standby blocker -_wach = {"an": False} - -def wach_halten(an: bool) -> None: - if _wach["an"] == an: - return - try: - import ctypes - ES_CONTINUOUS = 0x80000000 - ES_SYSTEM_REQUIRED = 0x00000001 - flags = (ES_CONTINUOUS | ES_SYSTEM_REQUIRED) if an else ES_CONTINUOUS - ctypes.windll.kernel32.SetThreadExecutionState(flags) - _wach["an"] = an - except Exception: - pass - -# Celery Subprocess Management -def _leser(prozess_ref, datei, log_callback) -> None: - try: - for zeile in prozess_ref.stdout: - try: - datei.write(zeile) - datei.flush() - except OSError: - pass - if log_callback: - log_callback(zeile) - except Exception: - pass - finally: - try: - datei.close() - except OSError: - pass - -def worker_laeuft() -> bool: - return prozess is not None and prozess.poll() is None - -def worker_starten(log_callback=None): - global prozess - if worker_laeuft(): - return - celery = os.path.join(BASIS, "venv", "Scripts", "celery.exe") - if not os.path.exists(celery): - celery = "celery" # fallback to global if testing outside venv - - befehl = [ - celery, "-A", "celery_app", "worker", "--loglevel=info", - "-Q", "transcode", "-n", f"{WORKER_NAME}@%h", - ] - befehl += (["--pool=solo"] if SLOTS == 1 else ["--pool=threads", f"--concurrency={SLOTS}"]) - - log = open(LOG_PFAD, "a", encoding="utf-8", errors="replace") - prozess = subprocess.Popen( - befehl, cwd=BASIS, - stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - text=True, bufsize=1, errors="replace", - creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), - ) - threading.Thread( - target=_leser, args=(prozess, log, log_callback), - daemon=True, name="log-leser", - ).start() - -def worker_stoppen(): - global prozess - if worker_laeuft(): - prozess.terminate() - try: - prozess.wait(15) - except subprocess.TimeoutExpired: - prozess.kill() - prozess = None - wach_halten(False) - aktuell["job"] = None - -# API Polling -def hole(pfad: str, host: str = None, timeout: int = 5): - ziel = host if host is not None else RIPPY_HOST - if not ziel: - return None - try: - with urllib.request.urlopen(f"http://{ziel}/api{pfad}", timeout=timeout) as a: - return json.load(a) - except Exception: - return None - -def hole_job_und_zustand(): - caps = hole("/capabilities") - jobs = hole("/jobs") or [] - - online = False - w_info = {} - for w in ((caps or {}).get("workers") or []): - if w.get("name") == WORKER_NAME: - online = bool(w.get("online")) - w_info = w - break - - laufend = None - for job in jobs if isinstance(jobs, list) else []: - if job.get("status") in ("transcoding", "processing", "running"): - laufend = job - break - - aktuell["online"] = online - aktuell["job"] = laufend - wach_halten(bool(laufend)) - return online, laufend, caps, jobs, w_info - -def deinstallieren() -> None: - skript = os.path.join(BASIS, "uninstall.ps1") - if not os.path.isfile(skript): - return - subprocess.Popen( - ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", skript], - creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), - ) - -def _bruecke_bauen(): - try: - import db - import logbruecke - - db.init_db() - return logbruecke.Bruecke( - WORKER_NAME, - schreiber=lambda level, quelle, text: db.add_log(level, quelle, text), - jetzt=time.monotonic, - ) - except Exception: - return None - -def _schluessel_wache(bruecke=None, log_callback=None): - try: - import schluessel - if not schluessel.makemkvcon_pfad(): - return - - def melden(level, text): - msg = f"[schluessel] {level.upper()} {text}" - if bruecke: - bruecke.zeile(msg) - if log_callback: - log_callback(msg + "\n") - - stand = None - while True: - try: - stand, _was = schluessel.runde(RIPPY_HOST, stand, melden=melden) - except Exception as e: - if log_callback: - log_callback(f"[schluessel] ERROR Schlüssel-Automatik fehlgeschlagen: {e}\n") - time.sleep(schluessel.TAKT_SEKUNDEN) - except ImportError: - pass - -# GUI Application -def main(page: ft.Page): - page.title = f"Rippy Worker — {WORKER_NAME}" - page.window.width = 900 - page.window.height = 700 - page.bgcolor = "#0f172a" # Rippy Slate-900 background - page.padding = 30 - page.theme_mode = ft.ThemeMode.DARK - page.fonts = { - "Inter": "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" - } - page.theme = ft.Theme(font_family="Inter") - - # --- Tray-Icon ----------------------------------------------------------- - tray_icon_ref = {"icon": None} - - def _tray_icon_bild(): - """Lade rippy.ico oder erzeuge ein Fallback-Bild.""" - ico_pfad = os.path.join(BASIS, "rippy.ico") - if os.path.isfile(ico_pfad): - try: - return PILImage.open(ico_pfad) - except Exception: - pass - # Fallback: kleines lila Quadrat - img = PILImage.new("RGB", (64, 64), (99, 102, 241)) - return img - - def tray_zeigen(icon, item): - """Tray-Menue: Fenster wieder anzeigen.""" - page.window.visible = True - page.window.minimized = False - page.update() - - def tray_beenden(icon, item): - """Tray-Menue: App komplett beenden.""" - if tray_icon_ref["icon"]: - tray_icon_ref["icon"].stop() - worker_stoppen() - page.window.destroy() - - def tray_starten(): - """Erstellt und startet das System-Tray-Icon.""" - if not TRAY_VERFUEGBAR: - return - menu = pystray.Menu( - pystray.MenuItem("Rippy Worker anzeigen", tray_zeigen, default=True), - pystray.Menu.SEPARATOR, - pystray.MenuItem("Beenden", tray_beenden), - ) - icon = pystray.Icon("rippy_worker", _tray_icon_bild(), - f"Rippy Worker — {WORKER_NAME}", menu) - tray_icon_ref["icon"] = icon - threading.Thread(target=icon.run, daemon=True, name="tray-icon").start() - - def on_window_event(e): - """Fenster-Schliessen minimiert ins Tray statt die App zu beenden.""" - if e.data == "close": - if TRAY_VERFUEGBAR and tray_icon_ref["icon"]: - page.window.visible = False - page.update() - else: - # Kein Tray verfuegbar — wirklich beenden - worker_stoppen() - page.window.destroy() - - page.window.prevent_close = True - page.window.on_event = on_window_event - tray_starten() - - # Colors - c_bg = "#0f172a" - c_panel = "#1e293b" - c_text = "#e2e8f0" - c_muted = "#94a3b8" - c_accent = "#6366f1" # Indigo - c_success = "#10b981" # Emerald - c_warning = "#f59e0b" # Amber - c_danger = "#f43f5e" # Rose - - # Formatter for job status - def job_text(job: dict) -> str: - titel = (job.get("title") or (job.get("id") or "")[:8]) or "?" - status = { - "transcoding": "komprimiert", - "processing": "rippt", - "running": "rippt", - "pending": "wartet", - "completed": "fertig", - "failed": "Fehler", - "canceling": "bricht ab", - }.get(job.get("status") or "", job.get("status") or "?") - return f"{titel} — {status}" - - # UI Components - status_text = ft.Text("Verbinde...", color=c_muted, size=18, weight=ft.FontWeight.BOLD) - title_text = ft.Text(f"Rippy Worker: {WORKER_NAME}", size=24, weight=ft.FontWeight.BOLD, color=c_text) - machine_info = ft.Text("System-Infos werden geladen...", color=c_muted, size=13, font_family="Consolas") - - progress_ring = ft.ProgressRing(width=120, height=120, stroke_width=10, value=0, color=c_accent) - progress_percentage = ft.Text("0%", size=28, weight=ft.FontWeight.BOLD, color=c_text) - job_title = ft.Text("Nichts in Arbeit", size=20, color=c_muted) - eta_text = ft.Text("Bereit", size=14, color=c_muted) - - progress_stack = ft.Stack( - [ - progress_ring, - ft.Container( - content=progress_percentage, - alignment=ft.alignment.center, - width=120, - height=120, - ) - ], - width=120, - height=120, - ) - - # Live Log Regex and Layout - # [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_view = ft.ListView(expand=1, spacing=2, auto_scroll=True) - queue_view = ft.ListView(height=120, spacing=4, auto_scroll=False) - - def add_log(zeile): - z = zeile.strip() - if not z: return - - match = LOG_REGEX.match(z) - if match: - zeit, level, msg = match.groups() - color = c_text - if level == "INFO": color = "#38bdf8" # sky-400 - elif level == "WARNING": color = "#fbbf24" # amber-400 - elif level == "ERROR": color = "#f87171" # red-400 - elif level == "SUCCESS": color = "#34d399" # emerald-400 - - row = ft.Row([ - 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(msg, color=c_text, size=12, font_family="Consolas", expand=True) - ], spacing=10, vertical_alignment=ft.CrossAxisAlignment.START) - log_view.controls.append(row) - else: - log_view.controls.append(ft.Text(z, color=c_muted, size=12, font_family="Consolas")) - - if len(log_view.controls) > 200: - log_view.controls.pop(0) - try: - page.update() - except Exception: - pass - - def toggle_worker(e): - if not worker_laeuft(): - worker_starten(add_log) - btn_toggle.text = "Worker läuft" - btn_toggle.icon = ft.icons.CHECK - btn_toggle.bgcolor = "#1e293b" - btn_toggle.disabled = True - page.update() - - btn_toggle = ft.ElevatedButton( - text="Worker läuft" if worker_laeuft() else "Worker Starten", - icon=ft.icons.CHECK if worker_laeuft() else ft.icons.PLAY_ARROW, - bgcolor="#1e293b" if worker_laeuft() else c_success, - color="white", - disabled=worker_laeuft(), - on_click=toggle_worker - ) - - def uninstall_click(e): - def close_dlg(e): - dlg.open = False - page.update() - - def do_uninstall(e): - deinstallieren() - page.window.close() - - dlg = ft.AlertDialog( - title=ft.Text("Deinstallieren?"), - content=ft.Text(f"Worker '{WORKER_NAME}' wirklich entfernen? Das beendet den Prozess und entfernt ihn aus dem Autostart."), - actions=[ - ft.TextButton("Abbrechen", on_click=close_dlg), - ft.TextButton("Ja, Deinstallieren", on_click=do_uninstall, style=ft.ButtonStyle(color=c_danger)), - ], - actions_alignment=ft.MainAxisAlignment.END, - ) - page.overlay.append(dlg) - dlg.open = True - page.update() - - btn_uninstall = ft.TextButton( - text="Deinstallieren", - icon=ft.icons.DELETE, - icon_color=c_danger, - on_click=uninstall_click - ) - - btn_dashboard = ft.TextButton( - text="Rippy Dashboard", - icon=ft.icons.OPEN_IN_BROWSER, - on_click=lambda _: webbrowser.open(f"http://{RIPPY_HOST}") if RIPPY_HOST else None - ) - - btn_logs = ft.TextButton( - text="Log in Rippy", - icon=ft.icons.FORMAT_ALIGN_LEFT, - on_click=lambda _: webbrowser.open(f"http://{RIPPY_HOST}/logs") if RIPPY_HOST else None - ) - - btn_local_log = ft.TextButton( - text="Lokales Log", - icon=ft.icons.INSERT_DRIVE_FILE, - on_click=lambda _: os.startfile(LOG_PFAD) if os.path.exists(LOG_PFAD) else None - ) - - # Layout - header = ft.Row( - [title_text, ft.Container(expand=True), btn_dashboard, btn_logs, btn_local_log, btn_uninstall], - alignment=ft.MainAxisAlignment.SPACE_BETWEEN - ) - - active_poster = ft.Image(src="", visible=False, width=64, height=96, border_radius=8, fit=ft.ImageFit.COVER) - - dashboard_card = ft.Container( - content=ft.Column([ - status_text, - machine_info, - ft.Container(height=5), - ft.Row([ - active_poster, - progress_stack, - ft.Column([job_title, eta_text], alignment=ft.MainAxisAlignment.CENTER, spacing=10) - ], alignment=ft.MainAxisAlignment.START, spacing=30), - ft.Container(height=10), - btn_toggle - ]), - bgcolor=c_panel, - border_radius=15, - padding=30, - border=ft.border.all(1, "#334155") - ) - - queue_card = ft.Container( - content=ft.Column([ - ft.Text("Aufgaben (Warteschlange)", size=16, weight=ft.FontWeight.BOLD, color=c_text), - queue_view - ]), - bgcolor="#020617", - border_radius=10, - padding=15, - border=ft.border.all(1, "#1e293b") - ) - - terminal_card = ft.Container( - content=log_view, - bgcolor="#020617", - border_radius=10, - padding=15, - expand=True, - border=ft.border.all(1, "#1e293b") - ) - - page.add( - header, - ft.Container(height=10), - dashboard_card, - ft.Container(height=10), - queue_card, - ft.Container(height=10), - ft.Text("Live Logs", size=16, weight=ft.FontWeight.BOLD, color=c_text), - terminal_card - ) - - # Background polling loop - def poll_loop(): - while True: - try: - online, job, caps, jobs, w_info = hole_job_und_zustand() - - # Update System Info - info = w_info.get("info") or {} - teile = [t for t in ( - info.get("cpu_modell"), - f"{info.get('cpu_kerne')} Kerne" if info.get("cpu_kerne") else "", - info.get("cpu_simd") if info.get("cpu_simd") not in ("", "unbekannt") else "", - ", ".join(w_info.get("encoders") or []), - ) if t] - machine_info.value = " · ".join(teile) if teile else f"{SLOTS} Aufträge gleichzeitig möglich" - - # Update Queue - queue_view.controls.clear() - if not jobs: - queue_view.controls.append(ft.Text("Keine Aufgaben in Rippy", color=c_muted, font_family="Consolas", size=13)) - else: - for j in jobs[:8]: - poster = None - if j.get("meta") and j["meta"].get("poster_path"): - p = j["meta"]["poster_path"] - poster = p if p.startswith("http") else f"https://image.tmdb.org/t/p/w342{p}" - - row_items = [] - if poster: - row_items.append(ft.Image(src=poster, width=40, height=60, border_radius=5, fit=ft.ImageFit.COVER)) - row_items.append(ft.Text(job_text(j), 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 - if not worker_laeuft(): - status_text.value = "Worker-Prozess gestoppt" - status_text.color = c_danger - progress_ring.value = 0 - progress_percentage.value = "0%" - job_title.value = "Nichts in Arbeit" - eta_text.value = "Bereit" - active_poster.visible = False - btn_toggle.disabled = False - btn_toggle.text = "Worker Starten" - btn_toggle.icon = ft.icons.PLAY_ARROW - btn_toggle.bgcolor = c_success - elif not online: - status_text.value = "Nicht mit Rippy verbunden (offline)" - status_text.color = c_warning - progress_ring.value = 0 - progress_percentage.value = "0%" - active_poster.visible = False - elif job: - status_text.value = "Aktiv: Komprimiert" - status_text.color = c_success - prog = float(job.get("progress") or 0) - progress_ring.value = prog / 100.0 - progress_percentage.value = f"{int(prog)}%" - titel = job.get("title") or (job.get("id") or "")[:8] - job_title.value = titel - eta = job.get("eta_text") or "Restzeit wird berechnet..." - eta_text.value = f"Verbleibend: {eta}" - - # Update Active Poster - poster = None - if job.get("meta") and job["meta"].get("poster_path"): - p = job["meta"]["poster_path"] - poster = p if p.startswith("http") else f"https://image.tmdb.org/t/p/w342{p}" - - if poster: - active_poster.src = poster - active_poster.visible = True - else: - active_poster.visible = False - else: - status_text.value = "Verbunden & Bereit" - status_text.color = c_accent - progress_ring.value = 0 - progress_percentage.value = "0%" - job_title.value = "Warte auf Encoder-Aufträge..." - eta_text.value = "Nichts in Arbeit" - active_poster.visible = False - - btn_toggle.disabled = True - btn_toggle.text = "Worker läuft" - btn_toggle.icon = ft.icons.CHECK - btn_toggle.bgcolor = "#1e293b" - - page.update() - except Exception as e: - pass - time.sleep(ABFRAGE_TAKT_SEKUNDEN) - - # Start Celery automatically - worker_starten(add_log) - - # Init initial logs - if os.path.exists(LOG_PFAD): - try: - with open(LOG_PFAD, "r", encoding="utf-8", errors="replace") as f: - lines = f.readlines()[-50:] - for line in lines: - add_log(line) - except OSError: - pass - - threading.Thread(target=poll_loop, daemon=True).start() - threading.Thread(target=_schluessel_wache, args=(_bruecke_bauen(), add_log), daemon=True, name="schluessel-wache").start() - -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__": - ft.app(target=main) +import os +import subprocess +import threading +import time +import urllib.request +import json +import webbrowser +import re +import flet as ft + +# Tray-Icon: pystray + PIL fuer das Icon-Bild +try: + import pystray + from PIL import Image as PILImage + + TRAY_VERFUEGBAR = True +except ImportError: + TRAY_VERFUEGBAR = False + +# Environment configuration +BASIS = os.path.dirname(os.path.abspath(__file__)) +RIPPY_HOST = os.getenv("RIPPY_TRAY_HOST", "") or os.getenv("RIPPY_HOST", "") +WORKER_NAME = os.getenv("WORKER_NAME", "windows-worker") +SLOTS = max(1, int(os.getenv("RIPPY_SLOTS", "1") or "1")) +ABFRAGE_TAKT_SEKUNDEN = 4 + + +# Log file path +def _log_pfad() -> str: + basis_daten = os.getenv("LOCALAPPDATA") or os.getenv("APPDATA") or "" + if basis_daten: + ordner = os.path.join(basis_daten, "Rippy Worker") + os.makedirs(ordner, exist_ok=True) + return os.path.join(ordner, "worker.log") + return os.path.join(BASIS, "worker.log") + + +LOG_PFAD = _log_pfad() + +# State variables +prozess = None +aktuell = {"job": None, "online": False} + +# Windows Standby blocker +_wach = {"an": False} + + +def wach_halten(an: bool) -> None: + if _wach["an"] == an: + return + try: + import ctypes + + ES_CONTINUOUS = 0x80000000 + ES_SYSTEM_REQUIRED = 0x00000001 + flags = (ES_CONTINUOUS | ES_SYSTEM_REQUIRED) if an else ES_CONTINUOUS + ctypes.windll.kernel32.SetThreadExecutionState(flags) + _wach["an"] = an + except Exception: + pass + + +# Celery Subprocess Management +def _leser(prozess_ref, datei, log_callback) -> None: + try: + for zeile in prozess_ref.stdout: + try: + datei.write(zeile) + datei.flush() + except OSError: + pass + if log_callback: + log_callback(zeile) + except Exception: + pass + finally: + try: + datei.close() + except OSError: + pass + + +def worker_laeuft() -> bool: + return prozess is not None and prozess.poll() is None + + +def worker_starten(log_callback=None): + global prozess + if worker_laeuft(): + return + celery = os.path.join(BASIS, "venv", "Scripts", "celery.exe") + if not os.path.exists(celery): + celery = "celery" # fallback to global if testing outside venv + + befehl = [ + celery, + "-A", + "celery_app", + "worker", + "--loglevel=info", + "-Q", + "transcode", + "-n", + f"{WORKER_NAME}@%h", + ] + befehl += ( + ["--pool=solo"] if SLOTS == 1 else ["--pool=threads", f"--concurrency={SLOTS}"] + ) + + log = open(LOG_PFAD, "a", encoding="utf-8", errors="replace") + prozess = subprocess.Popen( + befehl, + cwd=BASIS, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + errors="replace", + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + threading.Thread( + target=_leser, + args=(prozess, log, log_callback), + daemon=True, + name="log-leser", + ).start() + + +def worker_stoppen(): + global prozess + if worker_laeuft(): + prozess.terminate() + try: + prozess.wait(15) + except subprocess.TimeoutExpired: + prozess.kill() + prozess = None + wach_halten(False) + aktuell["job"] = None + + +# API Polling +def hole(pfad: str, host: str = None, timeout: int = 5): + ziel = host if host is not None else RIPPY_HOST + if not ziel: + return None + try: + with urllib.request.urlopen(f"http://{ziel}/api{pfad}", timeout=timeout) as a: + return json.load(a) + except Exception: + return None + + +def hole_job_und_zustand(): + caps = hole("/capabilities") + jobs = hole("/jobs") or [] + + online = False + w_info = {} + for w in (caps or {}).get("workers") or []: + if w.get("name") == WORKER_NAME: + online = bool(w.get("online")) + w_info = w + break + + laufend = None + for job in jobs if isinstance(jobs, list) else []: + if job.get("status") in ("transcoding", "processing", "running"): + laufend = job + break + + aktuell["online"] = online + aktuell["job"] = laufend + wach_halten(bool(laufend)) + return online, laufend, caps, jobs, w_info + + +def deinstallieren() -> None: + skript = os.path.join(BASIS, "uninstall.ps1") + if not os.path.isfile(skript): + return + subprocess.Popen( + ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", skript], + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + + +def _bruecke_bauen(): + try: + import db + import logbruecke + + db.init_db() + return logbruecke.Bruecke( + WORKER_NAME, + schreiber=lambda level, quelle, text: db.add_log(level, quelle, text), + jetzt=time.monotonic, + ) + except Exception: + return None + + +def _schluessel_wache(bruecke=None, log_callback=None): + try: + import schluessel + + if not schluessel.makemkvcon_pfad(): + return + + def melden(level, text): + msg = f"[schluessel] {level.upper()} {text}" + if bruecke: + bruecke.zeile(msg) + if log_callback: + log_callback(msg + "\n") + + stand = None + while True: + try: + stand, _was = schluessel.runde(RIPPY_HOST, stand, melden=melden) + except Exception as e: + if log_callback: + log_callback( + f"[schluessel] ERROR Schlüssel-Automatik fehlgeschlagen: {e}\n" + ) + time.sleep(schluessel.TAKT_SEKUNDEN) + except ImportError: + pass + + +# GUI Application +def main(page: ft.Page): + page.title = f"Rippy Worker — {WORKER_NAME}" + page.window.width = 900 + page.window.height = 700 + page.bgcolor = "#0f172a" # Rippy Slate-900 background + page.padding = 30 + page.theme_mode = ft.ThemeMode.DARK + page.fonts = { + "Inter": "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" + } + page.theme = ft.Theme(font_family="Inter") + + # --- Tray-Icon ----------------------------------------------------------- + tray_icon_ref = {"icon": None} + + def _tray_icon_bild(): + """Lade rippy.ico oder erzeuge ein Fallback-Bild.""" + ico_pfad = os.path.join(BASIS, "rippy.ico") + if os.path.isfile(ico_pfad): + try: + return PILImage.open(ico_pfad) + except Exception: + pass + # Fallback: kleines lila Quadrat + img = PILImage.new("RGB", (64, 64), (99, 102, 241)) + return img + + def tray_zeigen(icon, item): + """Tray-Menue: Fenster wieder anzeigen.""" + page.window.visible = True + page.window.minimized = False + page.update() + + def tray_beenden(icon, item): + """Tray-Menue: App komplett beenden.""" + if tray_icon_ref["icon"]: + tray_icon_ref["icon"].stop() + worker_stoppen() + page.window.destroy() + + def tray_starten(): + """Erstellt und startet das System-Tray-Icon.""" + if not TRAY_VERFUEGBAR: + return + menu = pystray.Menu( + pystray.MenuItem("Rippy Worker anzeigen", tray_zeigen, default=True), + pystray.Menu.SEPARATOR, + pystray.MenuItem("Beenden", tray_beenden), + ) + icon = pystray.Icon( + "rippy_worker", _tray_icon_bild(), f"Rippy Worker — {WORKER_NAME}", menu + ) + tray_icon_ref["icon"] = icon + threading.Thread(target=icon.run, daemon=True, name="tray-icon").start() + + def on_window_event(e): + """Fenster-Schliessen minimiert ins Tray statt die App zu beenden.""" + if e.data == "close": + if TRAY_VERFUEGBAR and tray_icon_ref["icon"]: + page.window.visible = False + page.update() + else: + # Kein Tray verfuegbar — wirklich beenden + worker_stoppen() + page.window.destroy() + + page.window.prevent_close = True + page.window.on_event = on_window_event + tray_starten() + + # Colors + + c_panel = "#1e293b" + c_text = "#e2e8f0" + c_muted = "#94a3b8" + c_accent = "#6366f1" # Indigo + c_success = "#10b981" # Emerald + c_warning = "#f59e0b" # Amber + c_danger = "#f43f5e" # Rose + + # Formatter for job status + def job_text(job: dict) -> str: + titel = (job.get("title") or (job.get("id") or "")[:8]) or "?" + status = { + "transcoding": "komprimiert", + "processing": "rippt", + "running": "rippt", + "pending": "wartet", + "completed": "fertig", + "failed": "Fehler", + "canceling": "bricht ab", + }.get(job.get("status") or "", job.get("status") or "?") + return f"{titel} — {status}" + + # UI Components + status_text = ft.Text( + "Verbinde...", color=c_muted, size=18, weight=ft.FontWeight.BOLD + ) + title_text = ft.Text( + f"Rippy Worker: {WORKER_NAME}", size=24, weight=ft.FontWeight.BOLD, color=c_text + ) + machine_info = ft.Text( + "System-Infos werden geladen...", color=c_muted, size=13, font_family="Consolas" + ) + + progress_ring = ft.ProgressRing( + width=120, height=120, stroke_width=10, value=0, color=c_accent + ) + progress_percentage = ft.Text( + "0%", size=28, weight=ft.FontWeight.BOLD, color=c_text + ) + job_title = ft.Text("Nichts in Arbeit", size=20, color=c_muted) + eta_text = ft.Text("Bereit", size=14, color=c_muted) + + progress_stack = ft.Stack( + [ + progress_ring, + ft.Container( + content=progress_percentage, + alignment=ft.alignment.center, + width=120, + height=120, + ), + ], + width=120, + height=120, + ) + + # Live Log Regex and Layout + # [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_view = ft.ListView(expand=1, spacing=2, auto_scroll=True) + queue_view = ft.ListView(height=120, spacing=4, auto_scroll=False) + + def add_log(zeile): + z = zeile.strip() + if not z: + return + + match = LOG_REGEX.match(z) + if match: + zeit, level, msg = match.groups() + color = c_text + if level == "INFO": + color = "#38bdf8" # sky-400 + elif level == "WARNING": + color = "#fbbf24" # amber-400 + elif level == "ERROR": + color = "#f87171" # red-400 + elif level == "SUCCESS": + color = "#34d399" # emerald-400 + + row = ft.Row( + [ + 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( + msg, color=c_text, size=12, font_family="Consolas", expand=True + ), + ], + spacing=10, + vertical_alignment=ft.CrossAxisAlignment.START, + ) + log_view.controls.append(row) + else: + log_view.controls.append( + ft.Text(z, color=c_muted, size=12, font_family="Consolas") + ) + + if len(log_view.controls) > 200: + log_view.controls.pop(0) + try: + page.update() + except Exception: + pass + + def toggle_worker(e): + if not worker_laeuft(): + worker_starten(add_log) + btn_toggle.text = "Worker läuft" + btn_toggle.icon = ft.icons.CHECK + btn_toggle.bgcolor = "#1e293b" + btn_toggle.disabled = True + page.update() + + btn_toggle = ft.ElevatedButton( + text="Worker läuft" if worker_laeuft() else "Worker Starten", + icon=ft.icons.CHECK if worker_laeuft() else ft.icons.PLAY_ARROW, + bgcolor="#1e293b" if worker_laeuft() else c_success, + color="white", + disabled=worker_laeuft(), + on_click=toggle_worker, + ) + + def uninstall_click(e): + def close_dlg(e): + dlg.open = False + page.update() + + def do_uninstall(e): + deinstallieren() + page.window.close() + + dlg = ft.AlertDialog( + title=ft.Text("Deinstallieren?"), + content=ft.Text( + f"Worker '{WORKER_NAME}' wirklich entfernen? Das beendet den Prozess und entfernt ihn aus dem Autostart." + ), + actions=[ + ft.TextButton("Abbrechen", on_click=close_dlg), + ft.TextButton( + "Ja, Deinstallieren", + on_click=do_uninstall, + style=ft.ButtonStyle(color=c_danger), + ), + ], + actions_alignment=ft.MainAxisAlignment.END, + ) + page.overlay.append(dlg) + dlg.open = True + page.update() + + btn_uninstall = ft.TextButton( + text="Deinstallieren", + icon=ft.icons.DELETE, + icon_color=c_danger, + on_click=uninstall_click, + ) + + btn_dashboard = ft.TextButton( + text="Rippy Dashboard", + icon=ft.icons.OPEN_IN_BROWSER, + on_click=lambda _: ( + webbrowser.open(f"http://{RIPPY_HOST}") if RIPPY_HOST else None + ), + ) + + btn_logs = ft.TextButton( + text="Log in Rippy", + icon=ft.icons.FORMAT_ALIGN_LEFT, + on_click=lambda _: ( + webbrowser.open(f"http://{RIPPY_HOST}/logs") if RIPPY_HOST else None + ), + ) + + btn_local_log = ft.TextButton( + text="Lokales Log", + icon=ft.icons.INSERT_DRIVE_FILE, + on_click=lambda _: os.startfile(LOG_PFAD) if os.path.exists(LOG_PFAD) else None, + ) + + # Layout + header = ft.Row( + [ + title_text, + ft.Container(expand=True), + btn_dashboard, + btn_logs, + btn_local_log, + btn_uninstall, + ], + alignment=ft.MainAxisAlignment.SPACE_BETWEEN, + ) + + active_poster = ft.Image( + src="", + visible=False, + width=64, + height=96, + border_radius=8, + fit=ft.ImageFit.COVER, + ) + + dashboard_card = ft.Container( + content=ft.Column( + [ + status_text, + machine_info, + ft.Container(height=5), + ft.Row( + [ + active_poster, + progress_stack, + ft.Column( + [job_title, eta_text], + alignment=ft.MainAxisAlignment.CENTER, + spacing=10, + ), + ], + alignment=ft.MainAxisAlignment.START, + spacing=30, + ), + ft.Container(height=10), + btn_toggle, + ] + ), + bgcolor=c_panel, + border_radius=15, + padding=30, + border=ft.border.all(1, "#334155"), + ) + + queue_card = ft.Container( + content=ft.Column( + [ + ft.Text( + "Aufgaben (Warteschlange)", + size=16, + weight=ft.FontWeight.BOLD, + color=c_text, + ), + queue_view, + ] + ), + bgcolor="#020617", + border_radius=10, + padding=15, + border=ft.border.all(1, "#1e293b"), + ) + + terminal_card = ft.Container( + content=log_view, + bgcolor="#020617", + border_radius=10, + padding=15, + expand=True, + border=ft.border.all(1, "#1e293b"), + ) + + page.add( + header, + ft.Container(height=10), + dashboard_card, + ft.Container(height=10), + queue_card, + ft.Container(height=10), + ft.Text("Live Logs", size=16, weight=ft.FontWeight.BOLD, color=c_text), + terminal_card, + ) + + # Background polling loop + def poll_loop(): + while True: + try: + online, job, caps, jobs, w_info = hole_job_und_zustand() + + # Update System Info + info = w_info.get("info") or {} + teile = [ + t + for t in ( + info.get("cpu_modell"), + f"{info.get('cpu_kerne')} Kerne" + if info.get("cpu_kerne") + else "", + info.get("cpu_simd") + if info.get("cpu_simd") not in ("", "unbekannt") + else "", + ", ".join(w_info.get("encoders") or []), + ) + if t + ] + machine_info.value = ( + " · ".join(teile) + if teile + else f"{SLOTS} Aufträge gleichzeitig möglich" + ) + + # Update Queue + queue_view.controls.clear() + if not jobs: + queue_view.controls.append( + ft.Text( + "Keine Aufgaben in Rippy", + color=c_muted, + font_family="Consolas", + size=13, + ) + ) + else: + for j in jobs[:8]: + poster = None + if j.get("meta") and j["meta"].get("poster_path"): + p = j["meta"]["poster_path"] + poster = ( + p + if p.startswith("http") + else f"https://image.tmdb.org/t/p/w342{p}" + ) + + row_items = [] + if poster: + row_items.append( + ft.Image( + src=poster, + width=40, + height=60, + border_radius=5, + fit=ft.ImageFit.COVER, + ) + ) + row_items.append( + ft.Text( + job_text(j), + 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 + if not worker_laeuft(): + status_text.value = "Worker-Prozess gestoppt" + status_text.color = c_danger + progress_ring.value = 0 + progress_percentage.value = "0%" + job_title.value = "Nichts in Arbeit" + eta_text.value = "Bereit" + active_poster.visible = False + btn_toggle.disabled = False + btn_toggle.text = "Worker Starten" + btn_toggle.icon = ft.icons.PLAY_ARROW + btn_toggle.bgcolor = c_success + elif not online: + status_text.value = "Nicht mit Rippy verbunden (offline)" + status_text.color = c_warning + progress_ring.value = 0 + progress_percentage.value = "0%" + active_poster.visible = False + elif job: + status_text.value = "Aktiv: Komprimiert" + status_text.color = c_success + prog = float(job.get("progress") or 0) + progress_ring.value = prog / 100.0 + progress_percentage.value = f"{int(prog)}%" + titel = job.get("title") or (job.get("id") or "")[:8] + job_title.value = titel + eta = job.get("eta_text") or "Restzeit wird berechnet..." + eta_text.value = f"Verbleibend: {eta}" + + # Update Active Poster + poster = None + if job.get("meta") and job["meta"].get("poster_path"): + p = job["meta"]["poster_path"] + poster = ( + p + if p.startswith("http") + else f"https://image.tmdb.org/t/p/w342{p}" + ) + + if poster: + active_poster.src = poster + active_poster.visible = True + else: + active_poster.visible = False + else: + status_text.value = "Verbunden & Bereit" + status_text.color = c_accent + progress_ring.value = 0 + progress_percentage.value = "0%" + job_title.value = "Warte auf Encoder-Aufträge..." + eta_text.value = "Nichts in Arbeit" + active_poster.visible = False + + btn_toggle.disabled = True + btn_toggle.text = "Worker läuft" + btn_toggle.icon = ft.icons.CHECK + btn_toggle.bgcolor = "#1e293b" + + page.update() + except Exception: + pass + time.sleep(ABFRAGE_TAKT_SEKUNDEN) + + # Start Celery automatically + worker_starten(add_log) + + # Init initial logs + if os.path.exists(LOG_PFAD): + try: + with open(LOG_PFAD, "r", encoding="utf-8", errors="replace") as f: + lines = f.readlines()[-50:] + for line in lines: + add_log(line) + except OSError: + pass + + threading.Thread(target=poll_loop, daemon=True).start() + threading.Thread( + target=_schluessel_wache, + args=(_bruecke_bauen(), add_log), + daemon=True, + name="schluessel-wache", + ).start() + + + +if __name__ == "__main__": + ft.app(target=main)