feat(worker): der externe Worker wird erwachsen - Log in Rippy, Anzeige, Slots
Ampel / ampel (push) Successful in 28s
Ampel / ampel (push) Successful in 28s
Commander 26.07.2026: "der externe Encoder Worker ist ein bisschen duenn - der koennte noch viel mehr." Drei Punkte, alle am Tray. 1. LOG IN RIPPY STATT TXT-DATEI (ausdruecklich gewuenscht). Das Tray schrieb sein Log nach %LOCALAPPDATA% und oeffnete es im Editor - wer wissen wollte, warum der Worker nichts tut, musste sich an den PC setzen. Neue Bruecke (logbruecke.py) meldet die wichtigen Zeilen nach Rippy, Quelle "w:<name>", und die Logs-Seite hat jetzt Knoepfe je Quelle: "was macht mein PC" ist ein Klick. Der Filter konnte Quellen schon immer, es gab nur keinen Knopf. Durchgelassen wird WENIG und mit Grund: Die Job-Meldungen stehen laengst in Rippy (tasks.py schreibt sie selbst). Es fehlte, was DANEBEN passiert und den Worker unbrauchbar macht, ohne dass ein Job existiert - hochgefahren oder nicht, Verbindung zu Redis/Postgres, Abstuerze. Alles andere fliegt weg: Celery ist bei --loglevel=info gespraechig, die logs-Tabelle hat keine Aufraeumung, und ein zugemuelltes Log ist so unbrauchbar wie keins. Dazu eine Drossel (30 Zeilen/Minute), die MELDET, wieviel sie verschluckt hat. Zeilenformat woertlich aus dem laufenden Container abgenommen (Celery 5.4.0). Die lokale Datei bleibt - sie ist genau dann die einzige Auskunft, wenn Rippy nicht erreichbar ist. 2. DAS TRAY ZEIGT, WAS LAEUFT. Vorher stand dort "laeuft" oder "gestoppt" - auf einer Maschine, die stundenlang an einem Film rechnet, ist das keine Auskunft. Jetzt Titel, Prozent und Restzeit, geholt von Rippys /jobs. Bewusst dieselbe Quelle wie das Dashboard, damit im Tray nicht eine zweite, abweichende Schaetzung steht. Dazu: Windows schlaeft nicht mehr mitten im Encode ein (SetThreadExecutionState, ohne ES_DISPLAY_REQUIRED - der Bildschirm darf ausgehen). Die Sperre wird zurueckgenommen, sobald nichts laeuft, und auch bei einem harten Ende des Trays - sonst schlaeft der PC nie wieder ein und niemand weiss warum. 3. MEHRERE ENCODES GLEICHZEITIG. Der Worker lief fest mit --pool=solo und nahm genau EINEN Auftrag an. Der Installer fragt die Zahl jetzt (GUI: Feld neben dem Namen, mit der erkannten Kernzahl daneben), Vorbelegung ab 12 Kernen zwei, sonst einer: HandBrake nutzt schon alle Kerne, aber x265 skaliert nicht linear. Auf Windows gibt es keinen prefork-Pool (kein fork) - deshalb --pool=threads, was hier passt, weil die Arbeit ein Kind-Prozess ist und der Thread nur wartet. GUI-Layout headless gerendert und angesehen (nichts ueberlappt, 16 Kerne korrekt erkannt), beide .ps1 mit echtem PowerShell 5.1 geprueft, BOM und CRLF erhalten, .exe neu gebaut. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+206
-15
@@ -1,10 +1,25 @@
|
||||
"""Tray-Symbol für den NATIVEN Windows-Worker (nicht im Docker-Image genutzt).
|
||||
|
||||
Zeigt den Worker-Status unten rechts neben der Uhr und bietet:
|
||||
Start/Stopp, Rippy im Browser öffnen, Log anzeigen, 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.
|
||||
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.
|
||||
@@ -13,6 +28,8 @@ Abhängigkeiten pystray + Pillow installiert NUR der Windows-Installer
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import webbrowser
|
||||
|
||||
try:
|
||||
@@ -25,6 +42,14 @@ except ImportError:
|
||||
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:
|
||||
@@ -55,6 +80,9 @@ def _log_pfad() -> str:
|
||||
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:
|
||||
@@ -71,18 +99,104 @@ 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(
|
||||
[celery, "-A", "celery_app", "worker", "--loglevel=info",
|
||||
"-Q", "transcode", "--pool=solo", "-n", f"{WORKER_NAME}@%h"],
|
||||
cwd=BASIS, stdout=log, stderr=subprocess.STDOUT,
|
||||
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()
|
||||
@@ -97,6 +211,8 @@ def worker_stoppen(icon=None, item=None):
|
||||
except subprocess.TimeoutExpired:
|
||||
prozess.kill()
|
||||
prozess = None
|
||||
wach_halten(False)
|
||||
aktuell["job"] = None
|
||||
if icon:
|
||||
icon.icon = disc_bild(False)
|
||||
icon.update_menu()
|
||||
@@ -109,12 +225,80 @@ def umschalten(icon, item):
|
||||
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_anzeigen(icon, item):
|
||||
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
|
||||
|
||||
@@ -125,17 +309,16 @@ def beenden(icon, item):
|
||||
|
||||
|
||||
menue = pystray.Menu(
|
||||
pystray.MenuItem(
|
||||
lambda item: f'Rippy-Worker "{WORKER_NAME}": {"läuft" if worker_laeuft() else "gestoppt"}',
|
||||
None, enabled=False,
|
||||
),
|
||||
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 anzeigen", log_anzeigen),
|
||||
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),
|
||||
)
|
||||
@@ -145,4 +328,12 @@ if __name__ == "__main__":
|
||||
tray = pystray.Icon("rippy-worker", disc_bild(False), "Rippy Worker", menue)
|
||||
worker_starten()
|
||||
tray.icon = disc_bild(worker_laeuft())
|
||||
tray.run()
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user