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:
Binary file not shown.
@@ -135,13 +135,43 @@ $form.Controls.Add($lblName)
|
|||||||
|
|
||||||
$txtName = New-Object System.Windows.Forms.TextBox
|
$txtName = New-Object System.Windows.Forms.TextBox
|
||||||
$txtName.Location = New-Object System.Drawing.Point(24, 196)
|
$txtName.Location = New-Object System.Drawing.Point(24, 196)
|
||||||
$txtName.Size = New-Object System.Drawing.Size(300, 26)
|
$txtName.Size = New-Object System.Drawing.Size(240, 26)
|
||||||
$txtName.Text = $WorkerName
|
$txtName.Text = $WorkerName
|
||||||
$txtName.BackColor = $cFeld
|
$txtName.BackColor = $cFeld
|
||||||
$txtName.ForeColor = $cText
|
$txtName.ForeColor = $cText
|
||||||
$txtName.BorderStyle = "FixedSingle"
|
$txtName.BorderStyle = "FixedSingle"
|
||||||
$form.Controls.Add($txtName)
|
$form.Controls.Add($txtName)
|
||||||
|
|
||||||
|
# Gleichzeitige Auftraege (Commander 26.07.2026: "Mehrere Encodes gleichzeitig").
|
||||||
|
# Vorher lief der Worker fest mit --pool=solo und nahm genau EINEN Auftrag an.
|
||||||
|
# Vorbelegung nach Kernzahl: ab 12 Kernen zwei, sonst einer. Grund: HandBrake
|
||||||
|
# nutzt schon alle Kerne, aber x265 skaliert nicht linear - auf einer breiten
|
||||||
|
# CPU bringt ein zweiter Encode mehr Durchsatz, auf einer schmalen bremst er nur.
|
||||||
|
$lblSlots = New-Object System.Windows.Forms.Label
|
||||||
|
$lblSlots.Text = "Gleichzeitig:"
|
||||||
|
$lblSlots.Location = New-Object System.Drawing.Point(280, 176)
|
||||||
|
$lblSlots.Size = New-Object System.Drawing.Size(120, 18)
|
||||||
|
$lblSlots.ForeColor = $cGedaempft
|
||||||
|
$form.Controls.Add($lblSlots)
|
||||||
|
|
||||||
|
$numSlots = New-Object System.Windows.Forms.NumericUpDown
|
||||||
|
$numSlots.Location = New-Object System.Drawing.Point(280, 196)
|
||||||
|
$numSlots.Size = New-Object System.Drawing.Size(70, 26)
|
||||||
|
$numSlots.Minimum = 1
|
||||||
|
$numSlots.Maximum = 8
|
||||||
|
$numSlots.Value = $(if ([Environment]::ProcessorCount -ge 12) { 2 } else { 1 })
|
||||||
|
$numSlots.BackColor = $cFeld
|
||||||
|
$numSlots.ForeColor = $cText
|
||||||
|
$numSlots.BorderStyle = "FixedSingle"
|
||||||
|
$form.Controls.Add($numSlots)
|
||||||
|
|
||||||
|
$lblKerne = New-Object System.Windows.Forms.Label
|
||||||
|
$lblKerne.Text = "von $([Environment]::ProcessorCount) Kernen"
|
||||||
|
$lblKerne.Location = New-Object System.Drawing.Point(358, 201)
|
||||||
|
$lblKerne.Size = New-Object System.Drawing.Size(166, 18)
|
||||||
|
$lblKerne.ForeColor = $cGedaempft
|
||||||
|
$form.Controls.Add($lblKerne)
|
||||||
|
|
||||||
# Zielverzeichnis - frei wählbar, Standard "Programme"
|
# Zielverzeichnis - frei wählbar, Standard "Programme"
|
||||||
$lblPfad = New-Object System.Windows.Forms.Label
|
$lblPfad = New-Object System.Windows.Forms.Label
|
||||||
$lblPfad.Text = "Installieren nach:"
|
$lblPfad.Text = "Installieren nach:"
|
||||||
@@ -433,10 +463,18 @@ function Do-Install {
|
|||||||
# Fehlermeldung im Worker unterscheidet genau diese beiden Faelle.
|
# Fehlermeldung im Worker unterscheidet genau diese beiden Faelle.
|
||||||
$mapZeile = if ($mapping) { "set RIPPY_PATH_MAP=$mapping`r`n" } else { "" }
|
$mapZeile = if ($mapping) { "set RIPPY_PATH_MAP=$mapping`r`n" } else { "" }
|
||||||
|
|
||||||
$trayBat = "@echo off`r`ncd /d `"%~dp0`"`r`nset REDIS_URL=redis://${rHost}:6379/0`r`nset DATABASE_URL=postgresql://rippy:rippy@${rHost}:5432/rippy`r`nset API_URL=http://${rHost}:8000`r`nset WORKER_NAME=$wName`r`nset RIPPY_TRAY_HOST=$rHost`r`n${mapZeile}set PATH=%~dp0;%PATH%`r`nstart `"`" venv\Scripts\pythonw.exe tray.py"
|
# Gleichzeitige Auftraege. Auf Windows gibt es KEINEN prefork-Pool (kein
|
||||||
|
# fork) - `solo` bedient genau einen Auftrag, `threads` mehrere. Das passt
|
||||||
|
# hier, weil die eigentliche Arbeit ein Kind-Prozess ist (HandBrake) und
|
||||||
|
# der Thread nur darauf wartet.
|
||||||
|
$slots = [int]$numSlots.Value
|
||||||
|
$poolArg = if ($slots -le 1) { "--pool=solo" } else { "--pool=threads --concurrency=$slots" }
|
||||||
|
Log "Gleichzeitige Auftraege: $slots"
|
||||||
|
|
||||||
|
$trayBat = "@echo off`r`ncd /d `"%~dp0`"`r`nset REDIS_URL=redis://${rHost}:6379/0`r`nset DATABASE_URL=postgresql://rippy:rippy@${rHost}:5432/rippy`r`nset API_URL=http://${rHost}:8000`r`nset WORKER_NAME=$wName`r`nset RIPPY_TRAY_HOST=$rHost`r`nset RIPPY_SLOTS=$slots`r`n${mapZeile}set PATH=%~dp0;%PATH%`r`nstart `"`" venv\Scripts\pythonw.exe tray.py"
|
||||||
Set-Content -Path "start-tray.bat" -Value $trayBat -Encoding ASCII
|
Set-Content -Path "start-tray.bat" -Value $trayBat -Encoding ASCII
|
||||||
|
|
||||||
$workBat = "@echo off`r`ncd /d `"%~dp0`"`r`nset REDIS_URL=redis://${rHost}:6379/0`r`nset DATABASE_URL=postgresql://rippy:rippy@${rHost}:5432/rippy`r`nset API_URL=http://${rHost}:8000`r`nset WORKER_NAME=$wName`r`n${mapZeile}set PATH=%~dp0;%PATH%`r`nvenv\Scripts\celery.exe -A celery_app worker --loglevel=info -Q transcode --pool=solo -n ${wName}@%%h"
|
$workBat = "@echo off`r`ncd /d `"%~dp0`"`r`nset REDIS_URL=redis://${rHost}:6379/0`r`nset DATABASE_URL=postgresql://rippy:rippy@${rHost}:5432/rippy`r`nset API_URL=http://${rHost}:8000`r`nset WORKER_NAME=$wName`r`nset RIPPY_SLOTS=$slots`r`n${mapZeile}set PATH=%~dp0;%PATH%`r`nvenv\Scripts\celery.exe -A celery_app worker --loglevel=info -Q transcode $poolArg -n ${wName}@%%h"
|
||||||
Set-Content -Path "start-worker.bat" -Value $workBat -Encoding ASCII
|
Set-Content -Path "start-worker.bat" -Value $workBat -Encoding ASCII
|
||||||
|
|
||||||
$uninstall = @"
|
$uninstall = @"
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ param(
|
|||||||
# Von Hand nur, wenn dieser PC die Freigabe anders erreicht, z. B.
|
# Von Hand nur, wenn dieser PC die Freigabe anders erreicht, z. B.
|
||||||
# -PfadMap "/app/media/rippy=Z:\"
|
# -PfadMap "/app/media/rippy=Z:\"
|
||||||
[string]$PfadMap = "",
|
[string]$PfadMap = "",
|
||||||
|
# Wie viele Kompressions-Aufträge dieser PC gleichzeitig annimmt. 0 = nach
|
||||||
|
# Kernzahl entscheiden (ab 12 Kernen zwei, sonst einer): HandBrake nutzt
|
||||||
|
# schon alle Kerne, aber x265 skaliert nicht linear — auf einer breiten CPU
|
||||||
|
# bringt ein zweiter Encode mehr Durchsatz, auf einer schmalen bremst er nur.
|
||||||
|
[int]$Slots = 0,
|
||||||
# Zielverzeichnis. Standard ist "Programme", wie bei jedem anderen Programm
|
# Zielverzeichnis. Standard ist "Programme", wie bei jedem anderen Programm
|
||||||
# (Commander-Wunsch 25.07.2026). Dorthin schreiben braucht Adminrechte -
|
# (Commander-Wunsch 25.07.2026). Dorthin schreiben braucht Adminrechte -
|
||||||
# PowerShell also "Als Administrator" starten, oder hier einen Ordner im
|
# PowerShell also "Als Administrator" starten, oder hier einen Ordner im
|
||||||
@@ -156,6 +161,15 @@ if ($PfadMap) {
|
|||||||
# und die Fehlermeldung im Worker unterscheidet genau diese beiden Fälle.
|
# und die Fehlermeldung im Worker unterscheidet genau diese beiden Fälle.
|
||||||
$mapZeile = if ($PfadMap) { "set RIPPY_PATH_MAP=$PfadMap`r`n" } else { "" }
|
$mapZeile = if ($PfadMap) { "set RIPPY_PATH_MAP=$PfadMap`r`n" } else { "" }
|
||||||
|
|
||||||
|
# Gleichzeitige Aufträge. Auf Windows gibt es KEINEN prefork-Pool (kein fork) —
|
||||||
|
# `solo` bedient genau einen Auftrag, `threads` mehrere. Das passt hier, weil die
|
||||||
|
# eigentliche Arbeit ein Kind-Prozess ist (HandBrake) und der Thread nur wartet.
|
||||||
|
if ($Slots -le 0) {
|
||||||
|
$Slots = if ([Environment]::ProcessorCount -ge 12) { 2 } else { 1 }
|
||||||
|
}
|
||||||
|
$poolArg = if ($Slots -le 1) { "--pool=solo" } else { "--pool=threads --concurrency=$Slots" }
|
||||||
|
Write-Host "Gleichzeitige Aufträge: $Slots (von $([Environment]::ProcessorCount) Kernen)"
|
||||||
|
|
||||||
# 6. Start-Skript erzeugen
|
# 6. Start-Skript erzeugen
|
||||||
$bat = @"
|
$bat = @"
|
||||||
@echo off
|
@echo off
|
||||||
@@ -165,8 +179,9 @@ set REDIS_URL=redis://${RippyHost}:6379/0
|
|||||||
set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy
|
set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy
|
||||||
set API_URL=http://${RippyHost}:8000
|
set API_URL=http://${RippyHost}:8000
|
||||||
set WORKER_NAME=$WorkerName
|
set WORKER_NAME=$WorkerName
|
||||||
|
set RIPPY_SLOTS=$Slots
|
||||||
${mapZeile}set PATH=%~dp0;%PATH%
|
${mapZeile}set PATH=%~dp0;%PATH%
|
||||||
venv\Scripts\celery.exe -A celery_app worker --loglevel=info -Q transcode --pool=solo -n ${WorkerName}@%%h
|
venv\Scripts\celery.exe -A celery_app worker --loglevel=info -Q transcode $poolArg -n ${WorkerName}@%%h
|
||||||
"@
|
"@
|
||||||
Set-Content -Path "start-worker.bat" -Value $bat -Encoding ASCII
|
Set-Content -Path "start-worker.bat" -Value $bat -Encoding ASCII
|
||||||
|
|
||||||
@@ -180,6 +195,7 @@ set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy
|
|||||||
set API_URL=http://${RippyHost}:8000
|
set API_URL=http://${RippyHost}:8000
|
||||||
set WORKER_NAME=$WorkerName
|
set WORKER_NAME=$WorkerName
|
||||||
set RIPPY_TRAY_HOST=$RippyHost
|
set RIPPY_TRAY_HOST=$RippyHost
|
||||||
|
set RIPPY_SLOTS=$Slots
|
||||||
${mapZeile}set PATH=%~dp0;%PATH%
|
${mapZeile}set PATH=%~dp0;%PATH%
|
||||||
start "" venv\Scripts\pythonw.exe tray.py
|
start "" venv\Scripts\pythonw.exe tray.py
|
||||||
"@
|
"@
|
||||||
|
|||||||
@@ -74,6 +74,24 @@ export default function LogsPage() {
|
|||||||
? logs
|
? logs
|
||||||
: logs.filter(log => log.level === filter || log.source === filter)
|
: logs.filter(log => log.level === filter || log.source === filter)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Quellen zum Anklicken (Commander-Wunsch 26.07.2026: „bessere Log-Ansichten
|
||||||
|
* — kein txt file, direkt von Rippy Logs").
|
||||||
|
*
|
||||||
|
* Der Filter konnte Quellen schon immer, es gab nur keinen Knopf dafür — man
|
||||||
|
* musste die Zeilen mit dem Auge durchsuchen. Seit dieser Runde meldet auch
|
||||||
|
* der externe Windows-Worker hierher (worker/logbruecke.py, Quelle „w:<name>"),
|
||||||
|
* und damit wird die Auswahl erst richtig nützlich: „was macht mein PC?" ist
|
||||||
|
* ein Klick.
|
||||||
|
*
|
||||||
|
* Die Liste wird aus den WIRKLICH vorhandenen Zeilen gebaut, nicht aus einer
|
||||||
|
* festen Aufzählung — sonst fehlt jede neue Maschine.
|
||||||
|
*/
|
||||||
|
const quellen = [...new Set(logs.map(l => l.source).filter(Boolean))].sort()
|
||||||
|
|
||||||
|
// „w:tobisnicerpc" ist als Knopfbeschriftung unschön.
|
||||||
|
const quelleLabel = (q: string) => q.startsWith('w:') ? `Worker ${q.slice(2)}` : q
|
||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
info: logs.filter(l => l.level === 'info').length,
|
info: logs.filter(l => l.level === 'info').length,
|
||||||
warning: logs.filter(l => l.level === 'warning').length,
|
warning: logs.filter(l => l.level === 'warning').length,
|
||||||
@@ -162,6 +180,25 @@ export default function LogsPage() {
|
|||||||
{f === 'all' ? 'Alle' : getLevelLabel(f)}
|
{f === 'all' ? 'Alle' : getLevelLabel(f)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{quellen.length > 1 && (
|
||||||
|
<>
|
||||||
|
<span className="text-xs font-medium text-slate-500 dark:text-slate-400 mx-2">Quelle:</span>
|
||||||
|
{quellen.map(q => (
|
||||||
|
<button
|
||||||
|
key={q}
|
||||||
|
onClick={() => setFilter(q)}
|
||||||
|
className={`px-3 py-1 rounded-full text-xs font-medium transition-all ${
|
||||||
|
filter === q
|
||||||
|
? 'bg-indigo-500 text-white font-bold shadow-md shadow-indigo-500/20'
|
||||||
|
: 'bg-slate-200/70 dark:bg-slate-800 text-slate-700 dark:text-slate-300 hover:bg-slate-300 dark:hover:bg-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{quelleLabel(q)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-h-[600px] overflow-y-auto">
|
<div className="max-h-[600px] overflow-y-auto">
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""Celery-Ausgabe des Workers nach Rippy melden — statt in eine txt-Datei.
|
||||||
|
|
||||||
|
Commander-Wunsch 26.07.2026: *„bessere Log-Ansichten (kein txt file → direkt von
|
||||||
|
Rippy Logs)"*. Der native Windows-Worker schrieb sein Log bisher nach
|
||||||
|
`%LOCALAPPDATA%\\Rippy Worker\\worker.log`, und das Tray öffnete die Datei im
|
||||||
|
Editor. Wer wissen wollte, warum der Worker nichts tut, musste sich also an den
|
||||||
|
PC setzen — während Rippy im Browser schon eine Log-Seite hat.
|
||||||
|
|
||||||
|
## Was hier durchgeht — und warum so wenig
|
||||||
|
|
||||||
|
Die JOB-Meldungen stehen längst in Rippy: `tasks.py` schreibt „Kompression
|
||||||
|
gestartet", Fehlschläge, Abbrüche selbst über `db.add_log`. Was fehlte, ist
|
||||||
|
alles, was DANEBEN passiert und den Worker unbrauchbar macht, ohne dass ein Job
|
||||||
|
existiert:
|
||||||
|
|
||||||
|
- Der Worker ist hochgefahren (oder eben nicht).
|
||||||
|
- Die Verbindung zu Redis/Postgres steht nicht.
|
||||||
|
- Er ist abgestürzt (Traceback).
|
||||||
|
|
||||||
|
Alles andere wird verworfen. Celery ist bei `--loglevel=info` gesprächig, die
|
||||||
|
`logs`-Tabelle hat keine automatische Aufräumung, und ein zugemülltes Log ist
|
||||||
|
genauso unbrauchbar wie keins.
|
||||||
|
|
||||||
|
## Das Format ist gemessen, nicht geraten
|
||||||
|
|
||||||
|
Wörtlich aus dem laufenden Worker-Container (26.07.2026, Celery 5.4.0) —
|
||||||
|
AGENTS Regel D:
|
||||||
|
|
||||||
|
[2026-07-26 12:04:18,060: INFO/MainProcess] Connected to redis://redis:6379/0
|
||||||
|
[2026-07-26 12:04:19,063: INFO/MainProcess] mingle: searching for neighbors
|
||||||
|
[2026-07-26 12:04:19,082: INFO/MainProcess] celery@ced31798928d ready.
|
||||||
|
[2026-07-26 12:06:19,094: WARNING/MainProcess] Zombie-Erkennung: {...}
|
||||||
|
|
||||||
|
Dazu der Startbanner (Zeilen ohne diese Klammer) und Warnungen aus Pythons
|
||||||
|
warnings-Modul.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# `[<Zeitstempel>: <LEVEL>/<Prozess>] <Text>`
|
||||||
|
CELERY_ZEILE = re.compile(r"^\[[^\]]*?:\s*(\w+)/([^\]]+)\]\s*(.*)$")
|
||||||
|
|
||||||
|
# Celery-Level → Rippy-Level. DEBUG fällt bewusst weg.
|
||||||
|
LEVEL_MAP = {
|
||||||
|
"CRITICAL": "error",
|
||||||
|
"ERROR": "error",
|
||||||
|
"WARNING": "warning",
|
||||||
|
"INFO": "info",
|
||||||
|
}
|
||||||
|
|
||||||
|
# INFO-Zeilen sind nur interessant, wenn sie einen ZUSTAND melden. Diese Stücke
|
||||||
|
# sind alle oben im echten Log nachweisbar.
|
||||||
|
INFO_DURCHLASSEN = ("ready.", "Connected to", "Trying again", "connection")
|
||||||
|
|
||||||
|
# ... und diese sind reines Grundrauschen.
|
||||||
|
INFO_VERWERFEN = ("mingle:", "task events", "searching for neighbors", "all alone")
|
||||||
|
|
||||||
|
# Zeilen OHNE Celery-Klammer (Startbanner, Python-warnings) kommen nur durch,
|
||||||
|
# wenn sie nach einem echten Problem aussehen.
|
||||||
|
ROHE_FEHLER = ("Traceback", "Error:", "error(", "Errno", "refused", "Cannot")
|
||||||
|
|
||||||
|
# Schutz für die logs-Tabelle: mehr als das pro Minute ist kein Log, das ist ein
|
||||||
|
# Wasserfall.
|
||||||
|
MAX_ZEILEN_JE_MINUTE = 30
|
||||||
|
|
||||||
|
|
||||||
|
def einordnen(zeile: str):
|
||||||
|
"""(level, text) für eine Ausgabezeile — oder None, wenn sie nichts wert ist.
|
||||||
|
|
||||||
|
Reine Funktion, damit das Urteil ohne laufenden Worker prüfbar ist.
|
||||||
|
"""
|
||||||
|
text = (zeile or "").strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
treffer = CELERY_ZEILE.match(text)
|
||||||
|
if not treffer:
|
||||||
|
# Startbanner und warnings-Ausgaben: nur echte Probleme.
|
||||||
|
if any(s in text for s in ROHE_FEHLER):
|
||||||
|
return ("error", text[:500])
|
||||||
|
return None
|
||||||
|
celery_level, _prozess, inhalt = treffer.groups()
|
||||||
|
level = LEVEL_MAP.get(celery_level.upper())
|
||||||
|
if not level or not inhalt:
|
||||||
|
return None
|
||||||
|
if level == "info":
|
||||||
|
if any(s in inhalt for s in INFO_VERWERFEN):
|
||||||
|
return None
|
||||||
|
if not any(s in inhalt for s in INFO_DURCHLASSEN):
|
||||||
|
return None
|
||||||
|
# „ready." heißt: der Worker nimmt jetzt Aufträge an. Das ist die
|
||||||
|
# Meldung, auf die man wartet, wenn nichts passiert.
|
||||||
|
if "ready." in inhalt:
|
||||||
|
return ("success", inhalt[:500])
|
||||||
|
return (level, inhalt[:500])
|
||||||
|
|
||||||
|
|
||||||
|
def quelle_fuer(worker_name: str) -> str:
|
||||||
|
"""Log-Quelle für diesen Worker.
|
||||||
|
|
||||||
|
Gekappt auf 32 Zeichen, weil `logs.source` genau so breit ist (db.py). Ohne
|
||||||
|
das Kappen scheitert das Insert still bei einem langen Worker-Namen — und
|
||||||
|
dann fehlt genau das Log, das man sucht.
|
||||||
|
"""
|
||||||
|
name = (worker_name or "worker").strip() or "worker"
|
||||||
|
return f"w:{name}"[:32]
|
||||||
|
|
||||||
|
|
||||||
|
class Bruecke:
|
||||||
|
"""Nimmt Ausgabezeilen und schreibt die wichtigen nach Rippy.
|
||||||
|
|
||||||
|
`schreiber(level, quelle, text)` und `jetzt()` sind einspritzbar — so ist
|
||||||
|
die Drosselung ohne Datenbank und ohne echte Uhr prüfbar.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, worker_name: str, schreiber, jetzt):
|
||||||
|
self.quelle = quelle_fuer(worker_name)
|
||||||
|
self.schreiber = schreiber
|
||||||
|
self.jetzt = jetzt
|
||||||
|
self._fenster_start = None
|
||||||
|
self._im_fenster = 0
|
||||||
|
self._unterdrueckt = 0
|
||||||
|
|
||||||
|
def zeile(self, text: str) -> bool:
|
||||||
|
"""Eine Ausgabezeile verarbeiten. True, wenn sie nach Rippy ging."""
|
||||||
|
eingeordnet = einordnen(text)
|
||||||
|
if not eingeordnet:
|
||||||
|
return False
|
||||||
|
level, inhalt = eingeordnet
|
||||||
|
|
||||||
|
jetzt = self.jetzt()
|
||||||
|
if self._fenster_start is None or jetzt - self._fenster_start >= 60:
|
||||||
|
# Neues Fenster: erst melden, wieviel im alten verschluckt wurde.
|
||||||
|
if self._unterdrueckt:
|
||||||
|
self._melden(
|
||||||
|
"warning",
|
||||||
|
f"{self._unterdrueckt} weitere Log-Zeilen unterdrückt "
|
||||||
|
"(mehr als 30 pro Minute) — Details im Worker-Log auf der "
|
||||||
|
"Maschine selbst.",
|
||||||
|
)
|
||||||
|
self._fenster_start = jetzt
|
||||||
|
self._im_fenster = 0
|
||||||
|
self._unterdrueckt = 0
|
||||||
|
|
||||||
|
if self._im_fenster >= MAX_ZEILEN_JE_MINUTE:
|
||||||
|
self._unterdrueckt += 1
|
||||||
|
return False
|
||||||
|
self._im_fenster += 1
|
||||||
|
return self._melden(level, inhalt)
|
||||||
|
|
||||||
|
def _melden(self, level: str, text: str) -> bool:
|
||||||
|
try:
|
||||||
|
self.schreiber(level, self.quelle, text)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
# Rippy nicht erreichbar → die lokale Datei bleibt der Rückfall.
|
||||||
|
# Hier NICHTS werfen: der Worker soll weiterarbeiten.
|
||||||
|
return False
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""Tests der Log-Brücke — reine Funktionen, keine Datenbank, keine Uhr.
|
||||||
|
|
||||||
|
Die Beispielzeilen sind WÖRTLICH aus dem laufenden Worker-Container abgenommen
|
||||||
|
(26.07.2026, Celery 5.4.0) — AGENTS Regel D. Erfundene Formate hätten hier
|
||||||
|
keinen Wert: Genau am geratenen Format scheitert so eine Brücke.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logbruecke
|
||||||
|
|
||||||
|
# Aus `docker compose logs worker`, unverändert.
|
||||||
|
ECHT_CONNECTED = "[2026-07-26 12:04:18,060: INFO/MainProcess] Connected to redis://redis:6379/0"
|
||||||
|
ECHT_MINGLE = "[2026-07-26 12:04:18,063: INFO/MainProcess] mingle: searching for neighbors"
|
||||||
|
ECHT_ALLEIN = "[2026-07-26 12:04:19,071: INFO/MainProcess] mingle: all alone"
|
||||||
|
ECHT_READY = "[2026-07-26 12:04:19,082: INFO/MainProcess] celery@ced31798928d ready."
|
||||||
|
ECHT_WARNUNG = (
|
||||||
|
"[2026-07-26 12:06:19,094: WARNING/MainProcess] Zombie-Erkennung: "
|
||||||
|
"{'geprueft': 0, 'aufgeraeumt': [], 'uebersprungen': ''}"
|
||||||
|
)
|
||||||
|
ECHT_BANNER = " -------------- celery@ced31798928d v5.4.0 (opalescent)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ready_ist_die_meldung_auf_die_man_wartet():
|
||||||
|
"""Wenn der Worker nichts tut, ist „ready." die Zeile, die man sehen will —
|
||||||
|
deshalb als Erfolg, nicht als Info im Grundrauschen."""
|
||||||
|
assert logbruecke.einordnen(ECHT_READY) == ("success", "celery@ced31798928d ready.")
|
||||||
|
|
||||||
|
|
||||||
|
def test_verbindung_kommt_durch():
|
||||||
|
level, text = logbruecke.einordnen(ECHT_CONNECTED)
|
||||||
|
assert level == "info"
|
||||||
|
assert "redis" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_warnungen_und_fehler_kommen_immer_durch():
|
||||||
|
level, text = logbruecke.einordnen(ECHT_WARNUNG)
|
||||||
|
assert level == "warning"
|
||||||
|
assert "Zombie-Erkennung" in text
|
||||||
|
assert logbruecke.einordnen(
|
||||||
|
"[2026-07-26 12:00:00,000: ERROR/MainProcess] kaputt")[0] == "error"
|
||||||
|
assert logbruecke.einordnen(
|
||||||
|
"[2026-07-26 12:00:00,000: CRITICAL/MainProcess] ganz kaputt")[0] == "error"
|
||||||
|
|
||||||
|
|
||||||
|
def test_grundrauschen_wird_verworfen():
|
||||||
|
"""Celery ist bei --loglevel=info gesprächig, und die logs-Tabelle hat keine
|
||||||
|
automatische Aufräumung. Ein zugemülltes Log ist so unbrauchbar wie keins."""
|
||||||
|
assert logbruecke.einordnen(ECHT_MINGLE) is None
|
||||||
|
assert logbruecke.einordnen(ECHT_ALLEIN) is None
|
||||||
|
assert logbruecke.einordnen(ECHT_BANNER) is None
|
||||||
|
assert logbruecke.einordnen("") is None
|
||||||
|
assert logbruecke.einordnen(None) is None
|
||||||
|
assert logbruecke.einordnen(
|
||||||
|
"[2026-07-26 12:00:00,000: DEBUG/MainProcess] kleinteiliges Zeug") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_absturz_ohne_celery_klammer_kommt_durch():
|
||||||
|
"""Ein Traceback trägt kein Celery-Präfix — und ist das Wichtigste, was
|
||||||
|
passieren kann."""
|
||||||
|
assert logbruecke.einordnen("Traceback (most recent call last):")[0] == "error"
|
||||||
|
assert logbruecke.einordnen(
|
||||||
|
"ConnectionRefusedError: [Errno 111] Connection refused")[0] == "error"
|
||||||
|
|
||||||
|
|
||||||
|
def test_quelle_wird_auf_32_zeichen_gekappt():
|
||||||
|
"""`logs.source` ist String(32). Ohne Kappen scheitert das Insert STILL bei
|
||||||
|
einem langen Worker-Namen — und dann fehlt genau das Log, das man sucht."""
|
||||||
|
assert logbruecke.quelle_fuer("tobisnicerpc") == "w:tobisnicerpc"
|
||||||
|
lang = logbruecke.quelle_fuer("x" * 100)
|
||||||
|
assert len(lang) == 32
|
||||||
|
assert lang.startswith("w:")
|
||||||
|
assert logbruecke.quelle_fuer("") == "w:worker"
|
||||||
|
assert logbruecke.quelle_fuer(None) == "w:worker"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Drosselung -------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _bruecke(uhr):
|
||||||
|
geschrieben = []
|
||||||
|
b = logbruecke.Bruecke(
|
||||||
|
"testpc",
|
||||||
|
schreiber=lambda level, quelle, text: geschrieben.append((level, text)),
|
||||||
|
jetzt=lambda: uhr[0],
|
||||||
|
)
|
||||||
|
return b, geschrieben
|
||||||
|
|
||||||
|
|
||||||
|
def test_drosselung_haelt_bei_30_zeilen_die_minute():
|
||||||
|
uhr = [0.0]
|
||||||
|
b, geschrieben = _bruecke(uhr)
|
||||||
|
for i in range(40):
|
||||||
|
b.zeile(f"[2026-07-26 12:00:00,000: ERROR/MainProcess] Fehler {i}")
|
||||||
|
assert len(geschrieben) == logbruecke.MAX_ZEILEN_JE_MINUTE
|
||||||
|
|
||||||
|
|
||||||
|
def test_naechste_minute_meldet_wieviel_verschluckt_wurde():
|
||||||
|
"""Stilles Verschlucken wäre genau der Fehler, den diese Sitzung schon
|
||||||
|
einmal eine Stunde gekostet hat."""
|
||||||
|
uhr = [0.0]
|
||||||
|
b, geschrieben = _bruecke(uhr)
|
||||||
|
for i in range(40):
|
||||||
|
b.zeile(f"[2026-07-26 12:00:00,000: ERROR/MainProcess] Fehler {i}")
|
||||||
|
uhr[0] = 61.0
|
||||||
|
b.zeile("[2026-07-26 12:01:01,000: ERROR/MainProcess] naechster Fehler")
|
||||||
|
|
||||||
|
unterdrueckt = [t for lvl, t in geschrieben if "unterdrückt" in t]
|
||||||
|
assert len(unterdrueckt) == 1
|
||||||
|
assert "10 weitere" in unterdrueckt[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_kaputter_schreiber_bringt_den_worker_nicht_um():
|
||||||
|
"""Rippy nicht erreichbar → die lokale Log-Datei bleibt der Rückfall. Der
|
||||||
|
Worker muss weiterarbeiten."""
|
||||||
|
def kaputt(level, quelle, text):
|
||||||
|
raise RuntimeError("Datenbank weg")
|
||||||
|
|
||||||
|
b = logbruecke.Bruecke("testpc", schreiber=kaputt, jetzt=lambda: 0.0)
|
||||||
|
assert b.zeile("[2026-07-26 12:00:00,000: ERROR/MainProcess] Fehler") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_verworfene_zeilen_zaehlen_nicht_gegen_die_drossel():
|
||||||
|
uhr = [0.0]
|
||||||
|
b, geschrieben = _bruecke(uhr)
|
||||||
|
for _ in range(100):
|
||||||
|
b.zeile(ECHT_MINGLE)
|
||||||
|
b.zeile(ECHT_READY)
|
||||||
|
assert geschrieben == [("success", "celery@ced31798928d ready.")]
|
||||||
+206
-15
@@ -1,10 +1,25 @@
|
|||||||
"""Tray-Symbol für den NATIVEN Windows-Worker (nicht im Docker-Image genutzt).
|
"""Tray-Symbol für den NATIVEN Windows-Worker (nicht im Docker-Image genutzt).
|
||||||
|
|
||||||
Zeigt den Worker-Status unten rechts neben der Uhr und bietet:
|
Zeigt neben der Uhr, was diese Maschine gerade tut, und bietet Start/Stopp,
|
||||||
Start/Stopp, Rippy im Browser öffnen, Log anzeigen, Beenden. Gestartet
|
Rippy im Browser, Log und Beenden. Gestartet wird es über start-tray.bat (setzt
|
||||||
wird es über start-tray.bat (setzt die Verbindungs-Umgebung und ruft
|
die Verbindungs-Umgebung und ruft pythonw.exe auf — kein Konsolenfenster). Der
|
||||||
pythonw.exe auf — kein Konsolenfenster). Der Worker-Prozess (celery,
|
Worker-Prozess (celery, Queue transcode) läuft als Kind-Prozess und stoppt mit
|
||||||
Queue transcode) läuft als Kind-Prozess und stoppt mit dem Tray.
|
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
|
Abhängigkeiten pystray + Pillow installiert NUR der Windows-Installer
|
||||||
(install.ps1) — das Docker-Image bleibt davon unberührt.
|
(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 os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
import webbrowser
|
import webbrowser
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -25,6 +42,14 @@ except ImportError:
|
|||||||
BASIS = os.path.dirname(os.path.abspath(__file__))
|
BASIS = os.path.dirname(os.path.abspath(__file__))
|
||||||
RIPPY_HOST = os.getenv("RIPPY_TRAY_HOST", "")
|
RIPPY_HOST = os.getenv("RIPPY_TRAY_HOST", "")
|
||||||
WORKER_NAME = os.getenv("WORKER_NAME", "windows-worker")
|
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:
|
def _log_pfad() -> str:
|
||||||
@@ -55,6 +80,9 @@ def _log_pfad() -> str:
|
|||||||
LOG_PFAD = _log_pfad()
|
LOG_PFAD = _log_pfad()
|
||||||
|
|
||||||
prozess = None
|
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:
|
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
|
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):
|
def worker_starten(icon=None, item=None):
|
||||||
global prozess
|
global prozess
|
||||||
if worker_laeuft():
|
if worker_laeuft():
|
||||||
return
|
return
|
||||||
celery = os.path.join(BASIS, "venv", "Scripts", "celery.exe")
|
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")
|
log = open(LOG_PFAD, "a", encoding="utf-8", errors="replace")
|
||||||
prozess = subprocess.Popen(
|
prozess = subprocess.Popen(
|
||||||
[celery, "-A", "celery_app", "worker", "--loglevel=info",
|
befehl, cwd=BASIS,
|
||||||
"-Q", "transcode", "--pool=solo", "-n", f"{WORKER_NAME}@%h"],
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||||
cwd=BASIS, stdout=log, stderr=subprocess.STDOUT,
|
text=True, bufsize=1, errors="replace",
|
||||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||||
)
|
)
|
||||||
|
threading.Thread(
|
||||||
|
target=_leser, args=(prozess, _bruecke_bauen(), log),
|
||||||
|
daemon=True, name="log-leser",
|
||||||
|
).start()
|
||||||
if icon:
|
if icon:
|
||||||
icon.icon = disc_bild(True)
|
icon.icon = disc_bild(True)
|
||||||
icon.update_menu()
|
icon.update_menu()
|
||||||
@@ -97,6 +211,8 @@ def worker_stoppen(icon=None, item=None):
|
|||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
prozess.kill()
|
prozess.kill()
|
||||||
prozess = None
|
prozess = None
|
||||||
|
wach_halten(False)
|
||||||
|
aktuell["job"] = None
|
||||||
if icon:
|
if icon:
|
||||||
icon.icon = disc_bild(False)
|
icon.icon = disc_bild(False)
|
||||||
icon.update_menu()
|
icon.update_menu()
|
||||||
@@ -109,12 +225,80 @@ def umschalten(icon, item):
|
|||||||
worker_starten(icon)
|
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):
|
def rippy_oeffnen(icon, item):
|
||||||
if RIPPY_HOST:
|
if RIPPY_HOST:
|
||||||
webbrowser.open(f"http://{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):
|
if os.path.exists(LOG_PFAD):
|
||||||
os.startfile(LOG_PFAD) # noqa: S606 — bewusst: Standard-Editor des Nutzers
|
os.startfile(LOG_PFAD) # noqa: S606 — bewusst: Standard-Editor des Nutzers
|
||||||
|
|
||||||
@@ -125,17 +309,16 @@ def beenden(icon, item):
|
|||||||
|
|
||||||
|
|
||||||
menue = pystray.Menu(
|
menue = pystray.Menu(
|
||||||
pystray.MenuItem(
|
pystray.MenuItem(status_text, None, enabled=False),
|
||||||
lambda item: f'Rippy-Worker "{WORKER_NAME}": {"läuft" if worker_laeuft() else "gestoppt"}',
|
pystray.MenuItem(restzeit_text, None, enabled=False),
|
||||||
None, enabled=False,
|
|
||||||
),
|
|
||||||
pystray.Menu.SEPARATOR,
|
pystray.Menu.SEPARATOR,
|
||||||
pystray.MenuItem(
|
pystray.MenuItem(
|
||||||
lambda item: "Worker stoppen" if worker_laeuft() else "Worker starten",
|
lambda item: "Worker stoppen" if worker_laeuft() else "Worker starten",
|
||||||
umschalten,
|
umschalten,
|
||||||
),
|
),
|
||||||
pystray.MenuItem("Rippy öffnen", rippy_oeffnen),
|
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.Menu.SEPARATOR,
|
||||||
pystray.MenuItem("Beenden (stoppt den Worker)", beenden),
|
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)
|
tray = pystray.Icon("rippy-worker", disc_bild(False), "Rippy Worker", menue)
|
||||||
worker_starten()
|
worker_starten()
|
||||||
tray.icon = disc_bild(worker_laeuft())
|
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