Files
rippy/docker/worker/gui.py
T
HitonabiandClaude Opus 5 dd1d0b7365
Ampel / ampel (push) Successful in 40s
refactor(core): V2-1 — die vier Ports, ein Store, ein Laufwerks-Treiber
WAS: rippy/ports.py beschreibt Store/Queue/Bus/Drives als Protocol. Zwei
weitere Doppelungen sind zusammengelegt: db.py (API+Worker) wird
rippy/store, und der Auswurf (api/devices.py + ripping.wirf_disc_aus)
wird rippy/drives/linux. Verhalten unveraendert.

WARUM: Drei Betriebsarten tragen nur, wenn ein Modus die Auswahl der
Treiber hinter vier Nahtstellen ist statt ein eigener Codestand
(KONZEPT-V2.md §1). Diese Etappe zieht die Nahtstellen ein, ohne schon
einen zweiten Treiber zu haben — die kommen in V2-2 (SQLite/LocalQueue)
und V2-4 (Windows).

DIE UNANGENEHMERE DOPPELUNG WAR DER AUSWURF: Er stand zweimal da, mit
UNTERSCHIEDLICHEN Vertraegen — devices.eject wirft OSError, ripping.
wirf_disc_aus gibt False zurueck und wirft nie. Beides ist richtig fuer
seine Seite (Browser-Meldung gegen "ein Rip stirbt nicht an einer
klemmenden Schublade"). Jetzt liegt EINE Mechanik darunter
(auswerfen_mit_grund) und beide Vertraege unveraendert darueber.
Die API-Fassung war ausserdem NIE getestet — jetzt schon, inklusive
"reicht ENOENT/EPERM unveraendert weiter".

get_settings hatte den einzigen echten Verhaltensunterschied der beiden
db.py: die Worker-Fassung schluckte jeden Fehler und gab {} zurueck.
Nicht still entschieden, sondern sichtbar gemacht — der Parameter
bei_fehler_leer steht jetzt in der Signatur, mit der offenen Frage im
Docstring. {} heisst fuer den Aufrufer "nichts gesetzt", nicht "konnte
nicht nachsehen"; das ist dieselbe Klasse wie catch(() => []) im alten
UI. Zu entscheiden in V2-2.

ZWEITER BEINAHE-FEHLER DIESER ETAPPE: linux.py importierte detection
auf Modulebene — und das zieht fcntl. Damit waere ripping.py und ueber
es der NATIVE WINDOWS-WORKER nicht mehr ladbar gewesen. Diesmal haben
die Tests es sofort gefangen (4 Sammelfehler). Behoben an der Wurzel:
Konstanten und die reine classify() leben jetzt in drives/cdrom.py,
ganz ohne fcntl. Nebengewinn — die classify-Tests liefen bisher NUR in
der Ampel ("erst nach dem Push bewiesen") und laufen jetzt ueberall.

Ausserdem: .dockerignore-Testmuster brauchen **, sonst greifen sie nur
in der obersten Ebene. Im laufenden Container nachgezaehlt: 30 test_*.py
lagen in den Images.

GEMESSEN: ruff sauber, 301 Tests gruen + 1 uebersprungen (vorher 290;
+4 neue eject-Tests, +7 classify-Tests die jetzt lokal laufen). Kein
Modul liegt mehr doppelt im Repo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:54:28 +02:00

761 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)