"""Tray-Symbol für den NATIVEN Windows-Worker (nicht im Docker-Image genutzt). Zeigt neben der Uhr, was diese Maschine gerade tut, und bietet Start/Stopp, Rippy im Browser, Log und Beenden. Gestartet wird es über start-tray.bat (setzt die Verbindungs-Umgebung und ruft pythonw.exe auf — kein Konsolenfenster). Der Worker-Prozess (celery, Queue transcode) läuft als Kind-Prozess und stoppt mit dem Tray. ## Was diese Runde dazugekommen ist (26.07.2026, Commander: „der Worker ist ein ## bisschen dünn") 1. **Es zeigt, WAS läuft.** Vorher stand im Menü nur „läuft" oder „gestoppt" — auf einer Maschine, die stundenlang an einem Film rechnet, ist das keine Auskunft. Jetzt: Titel, Prozent und Restzeit, geholt von Rippys `/jobs` (die Restzeit rechnet Rippy, siehe api/eta.py — sie ist damit dieselbe Zahl wie im Dashboard und nicht eine zweite, abweichende Schätzung). 2. **Windows schläft nicht mehr mitten im Encode ein.** `SetThreadExecutionState` hält die Maschine wach, solange wirklich etwas läuft — und lässt sie danach sofort wieder einschlafen. 3. **Das Log geht nach Rippy** (logbruecke.py). Die lokale Datei bleibt als Rückfall, denn sie ist genau dann die einzige Auskunft, wenn Rippy nicht erreichbar ist. Abhängigkeiten pystray + Pillow installiert NUR der Windows-Installer (install.ps1) — das Docker-Image bleibt davon unberührt. """ import os import subprocess import sys import threading import time import webbrowser try: import pystray from PIL import Image, ImageDraw except ImportError: print("Tray braucht pystray + Pillow — install.ps1 erneut ausführen.") sys.exit(1) BASIS = os.path.dirname(os.path.abspath(__file__)) RIPPY_HOST = os.getenv("RIPPY_TRAY_HOST", "") WORKER_NAME = os.getenv("WORKER_NAME", "windows-worker") # Wie viele Aufträge dieser Worker gleichzeitig annimmt. Siehe Kommentar bei # worker_starten() — auf Windows ist der Pool `threads`, nicht `prefork`. SLOTS = max(1, int(os.getenv("RIPPY_SLOTS", "1") or "1")) # Wie oft beim Dashboard nachgefragt wird, was läuft. Fünf Sekunden sind für ein # Tray-Menü reichlich und belasten die API nicht (der Endpunkt ist ohnehin der, # den das Dashboard alle vier Sekunden abfragt). ABFRAGE_TAKT_SEKUNDEN = 5 def _log_pfad() -> str: """Wohin das Worker-Log geschrieben wird. NICHT ins Programmverzeichnis (Befund 25.07.2026, Commander-Wunsch): Der Worker wird jetzt standardmäßig nach `C:\\Program Files\\Rippy Worker` installiert, und dort darf ein normaler Benutzer nicht schreiben. Ein Log neben dem Programm hätte den Worker beim Starten scheitern lassen — genau die Art Fehler, die man erst merkt, wenn nichts passiert. Deshalb ins Benutzerprofil (`%LOCALAPPDATA%\\Rippy Worker`), wie es sich für veränderliche Daten gehört. Nur falls das nicht klappt, bleibt das Programmverzeichnis der Rückfall — bei einer Installation ins Benutzerprofil (frei wählbar) ist es ohnehin beschreibbar. """ basis_daten = os.getenv("LOCALAPPDATA") or os.getenv("APPDATA") or "" if basis_daten: ordner = os.path.join(basis_daten, "Rippy Worker") try: os.makedirs(ordner, exist_ok=True) return os.path.join(ordner, "worker.log") except OSError: pass return os.path.join(BASIS, "worker.log") LOG_PFAD = _log_pfad() prozess = None # Was Rippy gerade meldet: None = nichts in Arbeit, sonst # {"titel": str, "progress": int, "eta": str}. aktuell = {"job": None} def disc_bild(laeuft: bool) -> Image.Image: """Disc-Symbol im Rippy-Look: Indigo wenn der Worker läuft, grau sonst.""" bild = Image.new("RGBA", (64, 64), (0, 0, 0, 0)) zeichner = ImageDraw.Draw(bild) farbe = (99, 102, 241, 255) if laeuft else (125, 132, 145, 255) zeichner.ellipse([4, 4, 60, 60], fill=farbe) zeichner.ellipse([24, 24, 40, 40], fill=(255, 255, 255, 255)) return bild def worker_laeuft() -> bool: return prozess is not None and prozess.poll() is None # --- Windows am Einschlafen hindern ---------------------------------------- # # Ein 4K-Encode läuft Stunden. Schläft der PC dabei ein, ist der Job weg — und # im UI steht bis zum nächsten Worker-Start eine Job-Leiche. Die Zustandsflags # stehen in winbase.h; ES_CONTINUOUS hält den Zustand, bis er zurückgenommen # wird. Bewusst OHNE ES_DISPLAY_REQUIRED: der Bildschirm darf ausgehen. ES_CONTINUOUS = 0x80000000 ES_SYSTEM_REQUIRED = 0x00000001 _wach = {"an": False} def wach_halten(an: bool) -> None: """Verhindert (an=True) bzw. erlaubt wieder (an=False) den Standby.""" if _wach["an"] == an: return try: import ctypes flags = (ES_CONTINUOUS | ES_SYSTEM_REQUIRED) if an else ES_CONTINUOUS ctypes.windll.kernel32.SetThreadExecutionState(flags) _wach["an"] = an except (ImportError, AttributeError, OSError): pass # kein Windows oder Aufruf verweigert — dann eben ohne def _log_weiterleiten(zeile: str, bruecke, datei) -> None: """Eine Ausgabezeile in die Datei UND (wenn wichtig) nach Rippy.""" try: datei.write(zeile) datei.flush() except OSError: pass if bruecke: bruecke.zeile(zeile) def _leser(prozess_ref, bruecke, datei) -> None: """Liest die Ausgabe des Worker-Prozesses, solange er lebt.""" try: for zeile in prozess_ref.stdout: _log_weiterleiten(zeile, bruecke, datei) except Exception: pass finally: try: datei.close() except OSError: pass def _bruecke_bauen(): """Log-Brücke nach Rippy — oder None, wenn die Datenbank nicht erreichbar ist. Scheitert der Aufbau, läuft der Worker ohne Brücke weiter und das lokale Log bleibt die Auskunft. Ein Tray, das wegen der Log-Anbindung nicht startet, wäre die schlechtere Wahl. """ 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 worker_starten(icon=None, item=None): global prozess if worker_laeuft(): return celery = os.path.join(BASIS, "venv", "Scripts", "celery.exe") # Auf Windows gibt es KEINEN prefork-Pool (kein fork). `solo` bediente # genau einen Auftrag; mit `threads` sind mehrere möglich, und das passt # hier, weil die eigentliche Arbeit ein Kind-Prozess ist (HandBrake) und der # Thread nur darauf wartet. 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, _bruecke_bauen(), log), daemon=True, name="log-leser", ).start() if icon: icon.icon = disc_bild(True) icon.update_menu() def worker_stoppen(icon=None, item=None): global prozess if worker_laeuft(): prozess.terminate() try: prozess.wait(15) except subprocess.TimeoutExpired: prozess.kill() prozess = None wach_halten(False) aktuell["job"] = None if icon: icon.icon = disc_bild(False) icon.update_menu() def umschalten(icon, item): if worker_laeuft(): worker_stoppen(icon) else: worker_starten(icon) def _job_holen(): """Was komprimiert Rippy gerade? (dict oder None) Gefragt wird Rippys `/jobs` — dieselbe Quelle wie das Dashboard, damit im Tray nicht eine zweite, abweichende Zahl steht. Die Restzeit rechnet die API (api/eta.py), hier wird sie nur angezeigt. """ if not RIPPY_HOST: return None try: import json import urllib.request with urllib.request.urlopen( f"http://{RIPPY_HOST}/api/jobs", timeout=8 ) as antwort: jobs = json.load(antwort) except Exception: return None for job in jobs if isinstance(jobs, list) else []: if job.get("status") == "transcoding": return { "titel": job.get("title") or (job.get("id") or "")[:8], "progress": job.get("progress") or 0, "eta": job.get("eta_text") or "", } return None def _beobachter(icon): """Fragt im Takt nach, was läuft — für Menütext und Standby-Sperre.""" while True: job = _job_holen() if worker_laeuft() else None aktuell["job"] = job # Wach bleiben nur, solange wirklich etwas läuft. wach_halten(bool(job)) try: icon.update_menu() except Exception: pass time.sleep(ABFRAGE_TAKT_SEKUNDEN) def status_text(item=None) -> str: """Die erste Menüzeile — sagt, was die Maschine tut.""" if not worker_laeuft(): return f'Rippy-Worker "{WORKER_NAME}": gestoppt' job = aktuell["job"] if not job: return f'Rippy-Worker "{WORKER_NAME}": bereit, nichts in Arbeit' return f"Komprimiert: {job['titel']} — {job['progress']} %" def restzeit_text(item=None) -> str: job = aktuell["job"] if not job: return f"{SLOTS} Auftrag gleichzeitig" if SLOTS == 1 else f"{SLOTS} Aufträge gleichzeitig" return job["eta"] or "Restzeit wird gemessen" def rippy_oeffnen(icon, item): if RIPPY_HOST: webbrowser.open(f"http://{RIPPY_HOST}") def log_in_rippy(icon, item): """Rippys Log-Seite öffnen — Commander-Wunsch: kein txt-File.""" if RIPPY_HOST: webbrowser.open(f"http://{RIPPY_HOST}/logs") def log_datei_anzeigen(icon, item): """Rückfall: das lokale Log. Genau dann die einzige Auskunft, wenn Rippy nicht erreichbar ist — deshalb bleibt der Eintrag.""" if os.path.exists(LOG_PFAD): os.startfile(LOG_PFAD) # noqa: S606 — bewusst: Standard-Editor des Nutzers def beenden(icon, item): worker_stoppen() icon.stop() menue = pystray.Menu( pystray.MenuItem(status_text, None, enabled=False), pystray.MenuItem(restzeit_text, None, enabled=False), pystray.Menu.SEPARATOR, pystray.MenuItem( lambda item: "Worker stoppen" if worker_laeuft() else "Worker starten", umschalten, ), pystray.MenuItem("Rippy öffnen", rippy_oeffnen), pystray.MenuItem("Log in Rippy anzeigen", log_in_rippy), pystray.MenuItem("Log-Datei auf diesem PC", log_datei_anzeigen), pystray.Menu.SEPARATOR, pystray.MenuItem("Beenden (stoppt den Worker)", beenden), ) if __name__ == "__main__": tray = pystray.Icon("rippy-worker", disc_bild(False), "Rippy Worker", menue) worker_starten() tray.icon = disc_bild(worker_laeuft()) threading.Thread( target=_beobachter, args=(tray,), daemon=True, name="job-beobachter" ).start() try: tray.run() finally: # Auch bei einem harten Ende die Standby-Sperre zurücknehmen — sonst # schläft der PC nie wieder ein, und niemand weiß warum. wach_halten(False)