2a90538473
Ampel / ampel (push) Successful in 30s
Vier Meldungen des Commanders, alle nachgemessen.
1. DEINSTALLER LIEF NICHT MEHR - reproduziert mit echtem PowerShell:
Der Typ [System.Windows.Forms.MessageBox] wurde nicht gefunden.
`Add-Type -AssemblyName System.Windows.Forms` stand EINE ZEILE ZU SPAET, die
MessageBox wurde davor benutzt. Der Deinstaller starb also in seiner ersten
Arbeitszeile, jedes Mal. Zwei weitere Maengel gleich mit:
Kodierung war ASCII trotz Umlauten, und `Remove-Item -Recurse -Force
$PSScriptRoot` loescht den Ordner, in dem das laufende Skript liegt - das
klappt auf Windows nicht zuverlaessig (venv-DLLs sind geladen). Jetzt raeumt
ein losgeloestes cmd nach, sobald PowerShell weg ist. Der erzeugte Deinstaller
ist gegengeprueft: parst, BOM da, Umlaute intakt.
2. VERWALTUNGSFENSTER (Doppelklick aufs Tray). Zeigt Status, Aufgaben und Log,
plus Knoepfe fuer Rippy, Log-in-Rippy und Deinstallieren - Deinstallieren geht
damit auch aus dem Tray-Menue. Eigener PROZESS statt Fenster im Tray, weil
pystray und tkinter beide den Haupt-Thread wollen; tkinter statt WinForms,
weil es bei jeder Windows-Python-Installation dabei ist. Headless gerendert
und angesehen. Alles Fachliche kommt von Rippy (/capabilities, /jobs), damit
dort nicht eine zweite, abweichende Wahrheit steht.
3. DASHBOARD-WIDERSPRUCH. Oben stand "Akira im Laufwerk erkannt", die
Server-Status-Karte gleichzeitig "Bereit - keine Disc in Arbeit / Disc
einlegen". Zwei Aussagen, ein Blick. Die Karte fragt jetzt /devices und sagt
"Disc erkannt - wartet auf Rippen starten" samt Titel.
4. MOUNT-TEMPO. Der Commander: "150 Sekunden? Das ist verrueckt langsam." Recht
hat er. Gemessen ging die Zeit fast komplett in einen FEHLVERSUCH: `umount -l`
ist lazy, der Abbau passiert spaeter, und wer direkt danach mountet, riskiert
dass der Abbau hinter dem neuen Mount landet. Der erste Reparaturversuch
scheiterte dadurch regelmaessig - und der zweite kostete 30 s mount-Timeout
plus Pruefungen. Jetzt werden 1,5 s auf den Abbau gewartet, bevor neu
gemountet wird; die Wache prueft erstmals nach 3 s statt 10 s und benutzt zum
ERKENNEN die einfache schnelle Probe (die Doppelprobe steckt dort, wo der
Wettlauf lauert: direkt nach dem Mount).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
386 lines
13 KiB
Python
386 lines
13 KiB
Python
"""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
|
|
|
|
|
|
# Das Verwaltungsfenster läuft als EIGENER Prozess (Begründung in
|
|
# verwaltung.py: pystray und tkinter wollen beide den Haupt-Thread).
|
|
_verwaltung = {"prozess": None}
|
|
|
|
|
|
def verwaltung_oeffnen(icon=None, item=None):
|
|
"""Kleines Fenster mit Status, Aufgaben und Log (Commander-Wunsch 26.07.2026).
|
|
|
|
Ist es schon offen, wird kein zweites gestartet — sonst sammeln sich bei
|
|
jedem Doppelklick Fenster an.
|
|
"""
|
|
laeuft = _verwaltung["prozess"]
|
|
if laeuft is not None and laeuft.poll() is None:
|
|
return
|
|
pythonw = os.path.join(BASIS, "venv", "Scripts", "pythonw.exe")
|
|
if not os.path.exists(pythonw):
|
|
pythonw = sys.executable
|
|
_verwaltung["prozess"] = subprocess.Popen(
|
|
[pythonw, os.path.join(BASIS, "verwaltung.py")],
|
|
cwd=BASIS,
|
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
)
|
|
|
|
|
|
def deinstallieren(icon, item):
|
|
"""Deinstallation aus dem Tray heraus (Commander-Wunsch 26.07.2026).
|
|
|
|
Das Skript fragt selbst nach (ohne -Force), stoppt alles aus diesem Ordner —
|
|
also auch dieses Tray — und räumt danach auf. Deshalb wird hier nichts mehr
|
|
erwartet: Der eigene Prozess ist gleich weg.
|
|
"""
|
|
skript = os.path.join(BASIS, "uninstall.ps1")
|
|
if not os.path.exists(skript):
|
|
return
|
|
subprocess.Popen(
|
|
["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass",
|
|
"-File", skript],
|
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
)
|
|
|
|
|
|
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,
|
|
# `default=True`: Dieser Eintrag wird beim Doppelklick aufs Symbol
|
|
# ausgelöst — genau das hat der Commander gewünscht. Er steht zusätzlich
|
|
# normal im Menü, damit es auch klappt, wenn der Doppelklick nicht greift.
|
|
pystray.MenuItem("Verwaltung öffnen", verwaltung_oeffnen, default=True),
|
|
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("Worker deinstallieren …", deinstallieren),
|
|
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)
|