From 7558be48544ab07dcccbfe817014a06db919fe11 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Fri, 28 Aug 2026 08:55:21 +0200 Subject: [PATCH] fix: CRLF in gui.py wiederherstellen (Zeilenenden-Falle in V2-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WAS: docker/worker/gui.py war CRLF und ist beim Umstellen des db-Imports komplett auf LF gekippt — 1520 Zeilen Diff-Rauschen fuer EINE geaenderte Zeile. Zeilenenden zurueckgedreht, der Inhalt bleibt. WARUM ES PASSIERT IST: Mein Umschreib-Helfer las mit io.open(p, encoding="utf-8") — im Textmodus wandelt Python CRLF beim LESEN zu LF — und schrieb mit newline="" zurueck, also LF. Betroffen war nur gui.py; die uebrigen angefassten Dateien sind ohnehin LF. Richtig waere newline="" AUCH beim Lesen gewesen (dann bleiben die Zeilenenden im String stehen) oder gleich der Binaermodus. Das steht so schon in den Projekt-Notizen und ist mir trotzdem passiert, weil der Helfer generisch war und die Datei nicht danach aussah. Gegenprobe: `file docker/worker/gui.py` sagt wieder "with CRLF line terminators", und `git diff HEAD~2 -- gui.py` zeigt genau eine Zeile. Co-Authored-By: Claude Opus 5 --- docker/worker/gui.py | 1520 +++++++++++++++++++++--------------------- 1 file changed, 760 insertions(+), 760 deletions(-) diff --git a/docker/worker/gui.py b/docker/worker/gui.py index 8d9f128..ac61c2b 100644 --- a/docker/worker/gui.py +++ b/docker/worker/gui.py @@ -1,760 +1,760 @@ -import verwaltung -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 old_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 = verwaltung.hole("/capabilities") - jobs = verwaltung.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: - from rippy import store as 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 old_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=0, 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, selectable=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", selectable=True) - ) - - 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.visible = False - 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=72, - height=108, - border_radius=10, - fit=ft.ImageFit.COVER, - ) - active_poster_container = ft.Container( - content=active_poster, - border=ft.border.all(1, c_warning), - border_radius=10, - shadow=ft.BoxShadow(spread_radius=1, blur_radius=5, color=ft.colors.with_opacity(0.3, ft.colors.BLACK)), - visible=False - ) - - dashboard_card = ft.Container( - content=ft.Column( - [ - status_text, - machine_info, - ft.Container(height=5), - ft.Row( - [ - active_poster_container, - 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.Container( - content=ft.Image( - src=poster, - width=32, - height=48, - border_radius=6, - fit=ft.ImageFit.COVER, - ), - border=ft.border.all(1, "#334155"), - border_radius=6, - shadow=ft.BoxShadow(spread_radius=1, blur_radius=3, color=ft.colors.with_opacity(0.3, ft.colors.BLACK)) - ) - ) - row_items.append( - ft.Text( - verwaltung.job_text(j).split(",")[0].split(",")[0], - color=c_muted, - font_family="Consolas", - size=13, - expand=True, - ) - ) - queue_view.controls.append( - ft.Row( - row_items, - alignment=ft.MainAxisAlignment.START, - spacing=15, - ) - ) - - # Update status - 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_container.visible = False - btn_toggle.visible = True - 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_container.visible = False - btn_toggle.visible = True - btn_toggle.disabled = True - btn_toggle.text = "Warte auf Verbindung..." - btn_toggle.bgcolor = "#1e293b" - 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_container.visible = True - else: - active_poster_container.visible = False - - btn_toggle.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_container.visible = False - btn_toggle.visible = False - - 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) +import verwaltung +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 old_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 = verwaltung.hole("/capabilities") + jobs = verwaltung.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: + from rippy import store as 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 old_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=0, 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, selectable=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", selectable=True) + ) + + 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.visible = False + 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=72, + height=108, + border_radius=10, + fit=ft.ImageFit.COVER, + ) + active_poster_container = ft.Container( + content=active_poster, + border=ft.border.all(1, c_warning), + border_radius=10, + shadow=ft.BoxShadow(spread_radius=1, blur_radius=5, color=ft.colors.with_opacity(0.3, ft.colors.BLACK)), + visible=False + ) + + dashboard_card = ft.Container( + content=ft.Column( + [ + status_text, + machine_info, + ft.Container(height=5), + ft.Row( + [ + active_poster_container, + 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.Container( + content=ft.Image( + src=poster, + width=32, + height=48, + border_radius=6, + fit=ft.ImageFit.COVER, + ), + border=ft.border.all(1, "#334155"), + border_radius=6, + shadow=ft.BoxShadow(spread_radius=1, blur_radius=3, color=ft.colors.with_opacity(0.3, ft.colors.BLACK)) + ) + ) + row_items.append( + ft.Text( + verwaltung.job_text(j).split(",")[0].split(",")[0], + color=c_muted, + font_family="Consolas", + size=13, + expand=True, + ) + ) + queue_view.controls.append( + ft.Row( + row_items, + alignment=ft.MainAxisAlignment.START, + spacing=15, + ) + ) + + # Update status + 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_container.visible = False + btn_toggle.visible = True + 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_container.visible = False + btn_toggle.visible = True + btn_toggle.disabled = True + btn_toggle.text = "Warte auf Verbindung..." + btn_toggle.bgcolor = "#1e293b" + 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_container.visible = True + else: + active_poster_container.visible = False + + btn_toggle.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_container.visible = False + btn_toggle.visible = False + + 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)