Files
rippy/docker/worker/tray.py
T
Hitonabi c171f8879c
Ampel / ampel (push) Successful in 31s
feat(schluessel): der Windows-PC holt die 4K-Schluessel jetzt selbst
Auf Commander-Entscheid gebaut. Belegt am 25.07.2026 auf beiden Maschinen:
`makemkvcon` unter LINUX ruft Disc-Schluessel NIE ab, die WINDOWS-Version schon
(Meldung 3338). Deshalb scheiterte jede unbekannte UHD-Disc auf der VM mit "The
volume key is unknown", und der Weg, der funktioniert, war Handarbeit: Laufwerk
an den PC, Disc oeffnen, _private_data.tar suchen, im UI hochladen.

Das laeuft jetzt von selbst - und zwar ZWEISCHICHTIG, mit Absicht:

  1. Der WAECHTER (verlaesslich): sieht _private_data.tar nach und laedt sie zu
     Rippy hoch, sobald sie sich geaendert hat. Braucht keine
     Laufwerkserkennung, kein Disc-Oeffnen, nichts geraten. Deckt auch den Fall
     ab, dass man die Disc einfach in der MakeMKV-Oberflaeche oeffnet.
  2. Das ANSTOSSEN (nach bestem Wissen): liegt eine Disc im Laufwerk, wird
     `makemkvcon info` darauf losgelassen - dabei holt MakeMKV den Schluessel.

Warum getrennt: Das Format der BELEGTEN `DRV:`-Zeile liess sich auf dem
Commander-PC nicht messen, weil dort kein optisches Laufwerk steckt (alle 16
Plaetze melden `DRV:i,256,999,0,"","",""` - das ist gemessen). Geraten wird also
nur in Schicht 2, und wenn die Vermutung falsch ist, passiert dort einfach
nichts - Schicht 1 arbeitet weiter. Die teure Annahme steckt nie im
verlaesslichen Teil.

Gemessene Fundstellen: Datenverzeichnis ist `%USERPROFILE%\.MakeMKV` (NICHT
%APPDATA%\MakeMKV, wie man vermuten wuerde) - dort lag die echte Datei mit
6.420.480 Bytes. Programm: C:\Program Files (x86)\MakeMKV\makemkvcon64.exe,
v1.18.4. Hochgeladen wird mit ROHEM Koerper an POST /system/keystore, weil der
API python-multipart fehlt.

Die Automatik schaltet sich selbst ab, wenn MakeMKV nicht installiert ist: Auf
einem reinen Encoding-PC gibt es nichts zu holen, und eine Schleife, die jede
Minute ins Leere greift, waere nur Rauschen. Ihre Meldungen gehen ueber die
Log-Bruecke auch nach Rippy - das ist genau die Auskunft, auf die man nach dem
Einlegen einer neuen UHD-Disc wartet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 15:14:52 +02:00

424 lines
15 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 _schluessel_wache(bruecke=None):
"""Holt 4K-Disc-Schlüssel und schiebt sie zu Rippy (siehe schluessel.py).
Der Sinn in einem Satz: `makemkvcon` unter LINUX ruft Disc-Schlüssel nie ab,
die WINDOWS-Version schon — und dieser Worker läuft auf Windows. Damit fällt
die Handarbeit weg, `_private_data.tar` nach jeder neuen UHD-Disc selbst
hinüberzutragen.
Schaltet sich selbst ab, wenn MakeMKV hier nicht installiert ist: Auf einem
reinen Encoding-PC gibt es nichts zu holen, und eine Schleife, die jede
Minute ins Leere greift, wäre nur Rauschen.
"""
import schluessel
if not schluessel.makemkvcon_pfad():
return
def melden(level, text):
if bruecke:
bruecke.zeile(f"[schluessel] {level.upper()} {text}")
print(f"Schlüssel-Automatik: {text}")
stand = None
while True:
try:
stand, _was = schluessel.runde(RIPPY_HOST, stand, melden=melden)
except Exception as e: # darf den Worker nie mitnehmen
print(f"Schlüssel-Automatik fehlgeschlagen: {type(e).__name__}: {e}")
time.sleep(schluessel.TAKT_SEKUNDEN)
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()
# Schlüssel-Automatik für 4K-UHD. Eigene Log-Brücke, damit ihre Meldungen
# auch in Rippy landen ("Schlüsselspeicher übergeben") — das ist genau die
# Auskunft, auf die man nach dem Einlegen einer neuen UHD-Disc wartet.
threading.Thread(
target=_schluessel_wache, args=(_bruecke_bauen(),),
daemon=True, name="schluessel-wache",
).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)