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