9 Commits

11 changed files with 899 additions and 1832 deletions
+27 -1
View File
@@ -1,6 +1,32 @@
# SAVEPOINT — Rippy # SAVEPOINT — Rippy
## Aktueller Stand: v3.20 — Rippy bremste sich selbst aus (26.07.2026, Nacht) ## Aktueller Stand: v3.21 — Rippy Windows Worker (27.07.2026)
> **Etappe 25: Rippy Windows Worker komplett modernisiert.**
> Der Installer und der Worker selbst wurden auf eine Flet-App (mit pystray) migriert,
> sodass das Deployment als Standalone `.exe` ohne Python-Abhängigkeit auf dem
> Host-System sauber funktioniert.
### Was wurde gebaut?
1. **Installer (`installer.py`) in Flet**:
- Die alte GUI (CustomTkinter/win32com) flog raus.
- Neues Design mit "Freigabe holen" & "Durchsuchen".
- Setup als Standalone `.exe` via `flet pack`.
- Bugfix für stale Dependencies bei Upgrades: `venv --clear`.
- Desktop Shortcuts nativ via PowerShell (kein `win32com` mehr nötig).
2. **Worker GUI (`gui.py`) in Flet**:
- Feature-Parität mit dem alten Worker (Log, Status, Restart).
- Neues UI (an das Web-Dashboard angelehnt): Thumbnails für Jobs, farbiges und geparstes Live-Log (`INFO`/`ERROR`/`WARNING` mit `regex`).
- Button-Logik überarbeitet: Bei Status "Bereit" ist der Button nicht mehr irreführend anklickbar, sondern zeigt "Worker läuft".
- **System Tray (pystray):** Ein Klick auf das X minimiert den Worker nun in das System Tray, von wo er den Hintergrundprozess am Leben hält.
3. **Deploy-Weg (Arcane)**:
- Worker `.exe` wird vom API-Container gebaut, liegt in `worker_dist` und kann von Usern heruntergeladen werden.
- Pinned `flet==0.23.2` um API-Probleme in der alten Version zu verhindern. (Ein späteres Upgrade auf Flet 0.86+ ist vermerkt).
## Letzter Stand davor: v3.20 — Rippy bremste sich selbst aus (26.07.2026, Nacht)
> **Zwei Commander-Meldungen, eine gemeinsame Wurzel: Rippy behinderte sich > **Zwei Commander-Meldungen, eine gemeinsame Wurzel: Rippy behinderte sich
> selbst und schwieg darüber.** Dazu zwei Fehler im Deploy-Weg, die jeden > selbst und schwieg darüber.** Dazu zwei Fehler im Deploy-Weg, die jeden
Binary file not shown.
-68
View File
@@ -1,68 +0,0 @@
# Baut rippy.ico + RippyWorkerSetup.exe aus install-gui.ps1 (ps2exe).
#
# WANN NEU BAUEN: nur wenn sich install-gui.ps1 (die Installer-Oberfläche)
# ändert. NICHT bei MakeMKV-/HandBrake-Updates - die .exe holt HandBrake zur
# Laufzeit (GitHub latest) und den Worker-Code live von Rippy, friert also
# keine Versionen ein.
# AUSFUEHREN AUF WINDOWS (eine Windows-.exe kann nicht von Linux gebaut werden):
# powershell -ExecutionPolicy Bypass -File build-exe.ps1
# Danach RippyWorkerSetup.exe committen (die API serviert sie).
$ErrorActionPreference = "Stop"
Add-Type -AssemblyName System.Drawing
Set-Location $PSScriptRoot
$guiSrc = Join-Path $PSScriptRoot "install-gui.ps1"
# 1. Rippy-Disc-Icon 256x256 zeichnen -> PNG -> ICO-Container
$bmp = New-Object System.Drawing.Bitmap 256, 256
$g = [System.Drawing.Graphics]::FromImage($bmp)
$g.SmoothingMode = "AntiAlias"
$g.Clear([System.Drawing.Color]::Transparent)
$g.FillEllipse((New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(99, 102, 241))), 8, 8, 240, 240)
$g.FillEllipse((New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(147, 51, 234))), 40, 40, 176, 176)
$g.FillEllipse([System.Drawing.Brushes]::White, 104, 104, 48, 48)
$ms = New-Object System.IO.MemoryStream
$bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
$png = $ms.ToArray()
$icoStream = New-Object System.IO.MemoryStream
$bw = New-Object System.IO.BinaryWriter $icoStream
$bw.Write([UInt16]0); $bw.Write([UInt16]1); $bw.Write([UInt16]1) # ICONDIR
$bw.Write([Byte]0); $bw.Write([Byte]0); $bw.Write([Byte]0); $bw.Write([Byte]0) # 256x256, colors, res
$bw.Write([UInt16]1); $bw.Write([UInt16]32) # planes, bpp
$bw.Write([UInt32]$png.Length); $bw.Write([UInt32]22) # size, offset
$bw.Write($png); $bw.Flush()
[System.IO.File]::WriteAllBytes("$PSScriptRoot\rippy.ico", $icoStream.ToArray())
Write-Host "rippy.ico erzeugt ($($png.Length) Bytes PNG)"
# 2. ps2exe sicherstellen - TLS 1.2 ist der Fix für den NuGet-Bootstrap-Fehler
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor 3072
if (-not (Get-Module -ListAvailable ps2exe)) {
Write-Host "Bootstrappe NuGet + PSGallery ..."
try { Get-PackageProvider -Name NuGet -ErrorAction Stop | Out-Null }
catch { Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope CurrentUser | Out-Null }
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted -ErrorAction SilentlyContinue
Write-Host "Installiere ps2exe ..."
Install-Module ps2exe -Scope CurrentUser -Force -AllowClobber
}
Import-Module ps2exe
# 3. Kompilieren (WinForms -> noConsole + STA, Icon eingebettet)
#
# -requireAdmin (seit 25.07.2026): Der Worker wird standardmäßig nach
# "C:\Program Files\Rippy Worker" installiert, und dorthin darf nur ein
# Administrator schreiben. Mit dem Schalter tragt die .exe ein Manifest, das
# Windows beim Doppelklick EINMAL per UAC fragen lässt - danach stimmen die
# Rechte für alles Weitere. Ohne ihn liefe der Installer unerhöht und
# scheiterte beim Anlegen des Ordners; wer das Ziel ins eigene Profil legt,
# braucht die Erhöhung nicht, wird aber trotzdem gefragt (normal für einen
# Installer und besser als ein Fehlschlag mitten im Lauf).
Invoke-ps2exe -inputFile $guiSrc -outputFile "$PSScriptRoot\RippyWorkerSetup.exe" `
-iconFile "$PSScriptRoot\rippy.ico" -noConsole -STA -requireAdmin `
-title "Rippy Worker Setup" -product "Rippy" -company "Rippy" -version "1.0.1.0"
if (Test-Path "$PSScriptRoot\RippyWorkerSetup.exe") {
Write-Host "OK: RippyWorkerSetup.exe = $((Get-Item "$PSScriptRoot\RippyWorkerSetup.exe").Length) Bytes"
} else {
Write-Host "FEHLER: keine .exe erzeugt"
}
-710
View File
@@ -1,710 +0,0 @@
# Rippy Windows-Worker - GRAFISCHER Installer (WinForms, keine externe Runtime).
# Wird über rippy-worker-setup.bat gestartet (Doppelklick). Braucht nur
# Python 3.10+ auf der Maschine - kein Docker, kein git.
#
# WICHTIG - KODIERUNG: Diese Datei MUSS als UTF-8 MIT BOM gespeichert werden.
# PowerShell 5.1 liest .ps1 ohne BOM als ANSI; Umlaute werden dann zu Kauderwelsch
# UND das Skript wirft einen Parser-Fehler, weil in "ö" ein Anführungszeichen
# steckt. Mit BOM liest 5.1 korrekt UTF-8 - am 25.07.2026 beidseitig gemessen:
# ohne BOM: "Größe" + Unerwartetes Token
# mit BOM: "Größe" + laeuft
# Nach jeder Änderung pruefen: powershell.exe -Command "Parser::ParseFile(...)"
param(
[string]$RippyHost = "",
[string]$WorkerName = $env:COMPUTERNAME.ToLower()
)
$ErrorActionPreference = "Stop"
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
# Standard-Zielverzeichnis: wie jedes andere Programm unter "Programme"
# (Commander-Wunsch 25.07.2026 - vorher lag der Worker im Benutzerprofil, was
# für ein Programm unüblich ist). GetFolderPath liefert den ECHTEN Pfad;
# deutsche Windows-Versionen zeigen im Explorer "Programme" an, der Pfad heißt
# trotzdem "C:\Program Files".
#
# Schreiben dorthin braucht Administratorrechte - deshalb ist die .exe mit
# -requireAdmin gebaut (build-exe.ps1) und fragt beim Start einmal per UAC.
# Ohne das wäre hier der nächste "Zugriff verweigert" programmiert.
$StandardZiel = Join-Path ([Environment]::GetFolderPath("ProgramFiles")) "Rippy Worker"
$InstallDir = $StandardZiel
# --- Farben: dieselben Töne wie das Rippy-Web-UI ---------------------------
# Übernommen aus docker/ui/src/lib/design.ts bzw. den Tailwind-Klassen des UI,
# damit der Installer nicht wie ein Fremdkörper wirkt.
$cBg = [System.Drawing.Color]::FromArgb(15, 23, 42) # slate-900, Fensterfläche
$cPanel = [System.Drawing.Color]::FromArgb(2, 6, 23) # slate-950, Log + Eingaben
$cFeld = [System.Drawing.Color]::FromArgb(30, 41, 59) # slate-800, Eingabefelder
$cText = [System.Drawing.Color]::FromArgb(226, 232, 240) # slate-200
$cGedaempft= [System.Drawing.Color]::FromArgb(148, 163, 184) # slate-400, Beschriftungen
$cIndigo = [System.Drawing.Color]::FromArgb(99, 102, 241)
$cAmber = [System.Drawing.Color]::FromArgb(245, 158, 11)
$cEmerald = [System.Drawing.Color]::FromArgb(16, 185, 129)
$cPurple = [System.Drawing.Color]::FromArgb(126, 34, 206)
# --- Fenster ---------------------------------------------------------------
$form = New-Object System.Windows.Forms.Form
$form.Text = "Rippy Encoding-Worker - Installation"
$form.ClientSize = New-Object System.Drawing.Size(560, 672)
$form.StartPosition = "CenterScreen"
$form.FormBorderStyle = "FixedDialog"
$form.MaximizeBox = $false
$form.BackColor = $cBg
$form.ForeColor = $cText
$form.Font = New-Object System.Drawing.Font("Segoe UI", 9)
# Fenster-Icon: Rippy-Disc zur Laufzeit zeichnen (Indigo-Kreis + weißes Loch)
# - kein externes .ico nötig. Erscheint in Titelleiste und Taskleiste.
try {
$ibmp = New-Object System.Drawing.Bitmap 32, 32
$ig = [System.Drawing.Graphics]::FromImage($ibmp)
$ig.SmoothingMode = "AntiAlias"
$ig.FillEllipse((New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(79, 70, 229))), 1, 1, 30, 30)
$ig.FillEllipse([System.Drawing.Brushes]::White, 12, 12, 8, 8)
$form.Icon = [System.Drawing.Icon]::FromHandle($ibmp.GetHicon())
} catch {}
# --- Kopfbereich mit Farbverlauf --------------------------------------------
# Das Rippy-UI hat oben einen Verlauf amber -> indigo -> purple (siehe
# FirstRunWizard.tsx). WinForms kann das nicht von sich aus, also wird der
# Verlauf im Paint-Ereignis gezeichnet - inklusive Disc-Symbol.
$kopf = New-Object System.Windows.Forms.Panel
$kopf.Dock = "Top"
$kopf.Height = 104
$form.Controls.Add($kopf)
$kopf.Add_Paint({
param($absender, $e)
$flaeche = New-Object System.Drawing.Rectangle 0, 0, $absender.Width, $absender.Height
$pinsel = New-Object System.Drawing.Drawing2D.LinearGradientBrush(
$flaeche, $cAmber, $cPurple, [System.Drawing.Drawing2D.LinearGradientMode]::Horizontal)
# Dreifarbig wie im UI: der Indigo-Ton in der Mitte
$mischung = New-Object System.Drawing.Drawing2D.ColorBlend 3
$mischung.Colors = @($cAmber, $cIndigo, $cPurple)
$mischung.Positions = @(0.0, 0.45, 1.0)
$pinsel.InterpolationColors = $mischung
$e.Graphics.FillRectangle($pinsel, $flaeche)
$pinsel.Dispose()
# Disc-Symbol: Ring aussen, weisses Loch in der Mitte
$e.Graphics.SmoothingMode = "AntiAlias"
$weiss = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(235, 255, 255, 255))
$e.Graphics.FillEllipse($weiss, 24, 28, 48, 48)
$e.Graphics.FillEllipse((New-Object System.Drawing.SolidBrush $cIndigo), 36, 40, 24, 24)
$e.Graphics.FillEllipse($weiss, 45, 49, 6, 6)
$weiss.Dispose()
# Texte direkt zeichnen - so bleibt der Verlauf ohne Kästchen sichtbar
$fTitel = New-Object System.Drawing.Font("Segoe UI", 15, [System.Drawing.FontStyle]::Bold)
$fUnter = New-Object System.Drawing.Font("Segoe UI", 9)
$e.Graphics.DrawString("Rippy Encoding-Worker", $fTitel,
[System.Drawing.Brushes]::White, 88, 26)
$e.Graphics.DrawString(
"Diese Maschine übernimmt die Video-Kompression für Rippy." + [char]10 +
"Gerippt wird weiter auf der Rippy-Hauptmaschine.", $fUnter,
(New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(225, 255, 255, 255))), 90, 56)
$fTitel.Dispose(); $fUnter.Dispose()
})
# Rippy-Adresse
$lblHost = New-Object System.Windows.Forms.Label
$lblHost.Text = "LAN-IP der Rippy-Maschine (Docker-Host) - NICHT dieser PC:"
$lblHost.Location = New-Object System.Drawing.Point(24, 120)
$lblHost.Size = New-Object System.Drawing.Size(500, 18)
$lblHost.ForeColor = $cGedaempft
$form.Controls.Add($lblHost)
$txtHost = New-Object System.Windows.Forms.TextBox
$txtHost.Location = New-Object System.Drawing.Point(24, 140)
$txtHost.Size = New-Object System.Drawing.Size(300, 26)
$txtHost.Text = $RippyHost
$txtHost.Font = New-Object System.Drawing.Font("Consolas", 10)
$txtHost.BackColor = $cFeld
$txtHost.ForeColor = $cText
$txtHost.BorderStyle = "FixedSingle"
$form.Controls.Add($txtHost)
# Worker-Name
$lblName = New-Object System.Windows.Forms.Label
$lblName.Text = "Name dieses Workers (frei wählbar):"
$lblName.Location = New-Object System.Drawing.Point(24, 176)
$lblName.Size = New-Object System.Drawing.Size(500, 18)
$lblName.ForeColor = $cGedaempft
$form.Controls.Add($lblName)
$txtName = New-Object System.Windows.Forms.TextBox
$txtName.Location = New-Object System.Drawing.Point(24, 196)
$txtName.Size = New-Object System.Drawing.Size(240, 26)
$txtName.Text = $WorkerName
$txtName.BackColor = $cFeld
$txtName.ForeColor = $cText
$txtName.BorderStyle = "FixedSingle"
$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"
$lblPfad = New-Object System.Windows.Forms.Label
$lblPfad.Text = "Installieren nach:"
$lblPfad.Location = New-Object System.Drawing.Point(24, 232)
$lblPfad.Size = New-Object System.Drawing.Size(500, 18)
$lblPfad.ForeColor = $cGedaempft
$form.Controls.Add($lblPfad)
$txtPfad = New-Object System.Windows.Forms.TextBox
$txtPfad.Location = New-Object System.Drawing.Point(24, 252)
$txtPfad.Size = New-Object System.Drawing.Size(374, 26)
$txtPfad.Text = $StandardZiel
$txtPfad.Font = New-Object System.Drawing.Font("Consolas", 9)
$txtPfad.BackColor = $cFeld
$txtPfad.ForeColor = $cText
$txtPfad.BorderStyle = "FixedSingle"
$form.Controls.Add($txtPfad)
$btnPfad = New-Object System.Windows.Forms.Button
$btnPfad.Text = "Ändern ..."
$btnPfad.Location = New-Object System.Drawing.Point(408, 251)
$btnPfad.Size = New-Object System.Drawing.Size(116, 28)
$btnPfad.FlatStyle = "Flat"
$btnPfad.BackColor = $cFeld
$btnPfad.ForeColor = $cText
$btnPfad.FlatAppearance.BorderColor = $cGedaempft
$form.Controls.Add($btnPfad)
$btnPfad.Add_Click({
$dlg = New-Object System.Windows.Forms.FolderBrowserDialog
$dlg.Description = "Ordner wählen - darin wird 'Rippy Worker' angelegt"
$dlg.ShowNewFolderButton = $true
if ($dlg.ShowDialog() -eq "OK") {
# Der Dialog liefert den ELTERN-Ordner; der Unterordner kommt von uns,
# damit die Deinstallation nie einen fremden Ordner mitlöscht.
$txtPfad.Text = (Join-Path $dlg.SelectedPath "Rippy Worker")
}
})
# Netzwerk-Freigabe fuer die Rohdaten (RIPPY_PATH_MAP)
#
# DAS entscheidet, ob dieser Worker ueberhaupt etwas komprimieren kann
# (Befund 26.07.2026): Rippy schickt ihm Pfade wie /app/media/rippy/<job> -
# Pfade INNERHALB des Rippy-Containers. Ohne Uebersetzung auf eine Freigabe
# sieht dieser PC dort nichts, nimmt die Aufgabe an und lehnt sie 182 ms
# spaeter ab. Der Nutzer soll das nicht verstehen muessen: Rippy hat die
# Freigabe selbst eingehaengt und kennt sie, der Knopf holt sie.
$lblMap = New-Object System.Windows.Forms.Label
$lblMap.Text = "Netzwerk-Freigabe mit den Rohdaten (holt Rippy selbst):"
$lblMap.Location = New-Object System.Drawing.Point(24, 288)
$lblMap.Size = New-Object System.Drawing.Size(500, 18)
$lblMap.ForeColor = $cGedaempft
$form.Controls.Add($lblMap)
$txtMap = New-Object System.Windows.Forms.TextBox
$txtMap.Location = New-Object System.Drawing.Point(24, 308)
$txtMap.Size = New-Object System.Drawing.Size(374, 26)
$txtMap.Font = New-Object System.Drawing.Font("Consolas", 9)
$txtMap.BackColor = $cFeld
$txtMap.ForeColor = $cText
$txtMap.BorderStyle = "FixedSingle"
$form.Controls.Add($txtMap)
$btnMap = New-Object System.Windows.Forms.Button
$btnMap.Text = "Von Rippy holen"
$btnMap.Location = New-Object System.Drawing.Point(408, 307)
$btnMap.Size = New-Object System.Drawing.Size(116, 28)
$btnMap.FlatStyle = "Flat"
$btnMap.BackColor = $cFeld
$btnMap.ForeColor = $cText
$btnMap.FlatAppearance.BorderColor = $cGedaempft
$form.Controls.Add($btnMap)
$lblMapHilfe = New-Object System.Windows.Forms.Label
$lblMapHilfe.Text = "Leer lassen und installieren - der Installer holt und prüft den Wert selbst."
$lblMapHilfe.Location = New-Object System.Drawing.Point(24, 338)
$lblMapHilfe.Size = New-Object System.Drawing.Size(500, 18)
$lblMapHilfe.ForeColor = $cGedaempft
$form.Controls.Add($lblMapHilfe)
# Autostart
$chkAuto = New-Object System.Windows.Forms.CheckBox
$chkAuto.Text = "Beim Anmelden automatisch starten (Tray-Symbol)"
$chkAuto.Location = New-Object System.Drawing.Point(24, 364)
$chkAuto.Size = New-Object System.Drawing.Size(400, 22)
$chkAuto.Checked = $true
$chkAuto.ForeColor = $cText
$form.Controls.Add($chkAuto)
# Verknuepfung auf dem Desktop (Commander-Wunsch 26.07.2026). Vorher lagen die
# Startdateien nur im Programmordner - wer den Worker von Hand starten wollte,
# musste erst "C:\Program Files\Rippy Worker" suchen.
$chkDesktop = New-Object System.Windows.Forms.CheckBox
$chkDesktop.Text = "Verknüpfung auf dem Desktop anlegen"
$chkDesktop.Location = New-Object System.Drawing.Point(24, 388)
$chkDesktop.Size = New-Object System.Drawing.Size(400, 22)
$chkDesktop.Checked = $true
$chkDesktop.ForeColor = $cText
$form.Controls.Add($chkDesktop)
# Log-Bereich
$log = New-Object System.Windows.Forms.TextBox
$log.Location = New-Object System.Drawing.Point(24, 420)
$log.Size = New-Object System.Drawing.Size(500, 174)
$log.Multiline = $true
$log.ReadOnly = $true
$log.ScrollBars = "Vertical"
$log.BackColor = $cPanel
$log.ForeColor = $cText
$log.BorderStyle = "FixedSingle"
$log.Font = New-Object System.Drawing.Font("Consolas", 8.5)
$form.Controls.Add($log)
# Knöpfe
$btnInstall = New-Object System.Windows.Forms.Button
$btnInstall.Text = "Installieren"
$btnInstall.Location = New-Object System.Drawing.Point(24, 608)
$btnInstall.Size = New-Object System.Drawing.Size(150, 40)
$btnInstall.BackColor = $cAmber
$btnInstall.ForeColor = $cBg
$btnInstall.FlatStyle = "Flat"
$btnInstall.Font = New-Object System.Drawing.Font("Segoe UI", 10, [System.Drawing.FontStyle]::Bold)
$form.Controls.Add($btnInstall)
$btnStart = New-Object System.Windows.Forms.Button
$btnStart.Text = "Worker starten"
$btnStart.Location = New-Object System.Drawing.Point(190, 608)
$btnStart.Size = New-Object System.Drawing.Size(150, 40)
$btnStart.BackColor = $cEmerald
$btnStart.ForeColor = [System.Drawing.Color]::White
$btnStart.FlatStyle = "Flat"
$btnStart.Font = New-Object System.Drawing.Font("Segoe UI", 10, [System.Drawing.FontStyle]::Bold)
$btnStart.Visible = $false
$form.Controls.Add($btnStart)
function Log($text) {
$log.AppendText($text + "`r`n")
[System.Windows.Forms.Application]::DoEvents()
}
# --- RIPPY_PATH_MAP: holen und wirklich pruefen -----------------------------
# Rippy leitet den Wert aus seinen eigenen eingehaengten Freigaben ab
# (GET /worker-setup/pfad-map) - hier wird nichts geraten. Der Rueckgabewert
# ist der fertige Wert fuer die .bat-Dateien, oder "" wenn Rippy keine
# Freigabe hat (dann kann dieser Worker nichts komprimieren, und das muss
# im Log stehen statt still zu passieren).
function Hole-PfadMap($rippyHost) {
try {
$antwort = Invoke-RestMethod "http://$rippyHost/api/worker-setup/pfad-map" -TimeoutSec 15
} catch {
Log "HINWEIS: Rippy nach der Freigabe zu fragen schlug fehl ($($_.Exception.Message))."
return ""
}
if ($antwort.hinweis) { Log $antwort.hinweis }
if (-not $antwort.mapping) { return "" }
return [string]$antwort.mapping
}
# Erreicht DIESER PC die Freigaben wirklich? Ohne diese Probe merkt der Nutzer
# den Fehler erst, wenn ein Job Stunden spaeter scheitert.
function Pruefe-PfadMap($mapping) {
$alleDa = $true
foreach ($paar in ($mapping -split ";")) {
if ($paar -notmatch "=") { continue }
$ziel = ($paar -split "=", 2)[1]
if (Test-Path -LiteralPath $ziel -ErrorAction SilentlyContinue) {
Log " OK: $ziel ist von hier erreichbar."
} else {
$alleDa = $false
Log " NICHT erreichbar: $ziel"
}
}
if (-not $alleDa) {
Log " Meist fehlen nur die Zugangsdaten: die Freigabe einmal im Explorer"
Log " oeffnen und 'Anmeldedaten speichern' anhaken, dann erreicht der"
Log " Worker sie auch. Die Installation laeuft trotzdem durch."
}
return $alleDa
}
# --- Installations-Logik ---------------------------------------------------
function Do-Install {
$rHost = $txtHost.Text.Trim()
$wName = $txtName.Text.Trim()
if (-not $rHost) { [System.Windows.Forms.MessageBox]::Show("Bitte die LAN-IP der Rippy-Maschine eintragen.", "Fehlt"); return }
if (-not $wName) { $wName = $env:COMPUTERNAME.ToLower() }
# Zielverzeichnis aus dem Feld. script:-Bereich, weil "Worker starten"
# und die Abschlussmeldung denselben Pfad brauchen.
$zielEingabe = $txtPfad.Text.Trim()
if (-not $zielEingabe) { $zielEingabe = $StandardZiel }
if (-not [System.IO.Path]::IsPathRooted($zielEingabe)) {
[System.Windows.Forms.MessageBox]::Show(
"Bitte einen vollständigen Pfad angeben, z. B. C:\Program Files\Rippy Worker.",
"Pfad unvollständig")
return
}
$script:InstallDir = $zielEingabe
$btnInstall.Enabled = $false
$log.Clear()
Log "Ziel: $InstallDir"
try {
# 1. Python finden
$python = $null
foreach ($k in @("py -3", "python")) {
try {
$v = Invoke-Expression "$k --version" 2>&1
if ("$v" -match "Python 3\.(\d+)" -and [int]$Matches[1] -ge 10) { $python = $k; break }
} catch {}
}
if (-not $python) {
Log "FEHLER: Python 3.10+ nicht gefunden."
Log "Installieren mit: winget install Python.Python.3.12"
Log "Danach diesen Installer erneut starten."
$btnInstall.Enabled = $true
return
}
Log "Python gefunden: $python"
# 2. Ordner + Worker-Code
# Erst PRUEFEN, ob wir dort überhaupt schreiben dürfen. Die .exe ist mit
# -requireAdmin gebaut und fragt per UAC, aber wer install-gui.ps1 direkt
# startet, hat die Rechte nicht - dann soll hier ein brauchbarer Satz
# stehen und nicht "Zugriff verweigert" aus irgendeiner Tiefe.
try {
New-Item -ItemType Directory -Force $InstallDir -ErrorAction Stop | Out-Null
$probe = Join-Path $InstallDir ".schreibtest"
Set-Content -Path $probe -Value "x" -ErrorAction Stop
Remove-Item -Force $probe -ErrorAction SilentlyContinue
} catch {
Log "FEHLER: In '$InstallDir' darf nicht geschrieben werden."
Log ""
Log "Das ist normal bei Ordnern unter 'Programme' - dafür braucht es"
Log "Administratorrechte. Zwei Wege:"
Log " 1. Diesen Installer mit Rechtsklick > 'Als Administrator"
Log " ausführen' starten (der empfohlene Weg), ODER"
Log " 2. oben über 'Ändern ...' einen Ordner im eigenen"
Log " Benutzerprofil wählen - dort geht es ohne Adminrechte."
$btnInstall.Enabled = $true
return
}
Set-Location $InstallDir
$HandBrakeVersion = "1.11.2"
try {
$rel = Invoke-RestMethod "https://api.github.com/repos/HandBrake/HandBrake/releases/latest" -TimeoutSec 15 -Headers @{ "User-Agent" = "rippy-installer" }
if ($rel.tag_name) { $HandBrakeVersion = ([string]$rel.tag_name).TrimStart("v") }
} catch {}
Log "Lade Worker-Code von $rHost ..."
Invoke-WebRequest "http://$rHost/api/worker-setup/paket" -OutFile worker.zip -UseBasicParsing
Expand-Archive worker.zip -DestinationPath . -Force
Remove-Item worker.zip
# 3. venv + Abhängigkeiten
if (-not (Test-Path "venv")) { Log "Lege Python-Umgebung an ..."; Invoke-Expression "$python -m venv venv" }
Log "Installiere Python-Abhängigkeiten (kann 1-2 min dauern) ..."
& .\venv\Scripts\python.exe -m pip install --quiet --upgrade pip
& .\venv\Scripts\pip.exe install --quiet -r requirements.txt
& .\venv\Scripts\pip.exe install --quiet pystray pillow
Log "Abhängigkeiten installiert."
# 4. HandBrakeCLI
if (-not (Test-Path ".\HandBrakeCLI.exe") -and -not (Get-Command HandBrakeCLI.exe -ErrorAction SilentlyContinue)) {
Log "Lade HandBrakeCLI $HandBrakeVersion (offizielles Release) ..."
$url = "https://github.com/HandBrake/HandBrake/releases/download/$HandBrakeVersion/HandBrakeCLI-$HandBrakeVersion-win-x86_64.zip"
Invoke-WebRequest $url -OutFile hb.zip -UseBasicParsing
Expand-Archive hb.zip -DestinationPath . -Force
Remove-Item hb.zip
}
Log "HandBrake bereit."
# 5. Netzwerk-Freigabe klaeren, BEVOR die Start-Skripte geschrieben werden.
# Ohne RIPPY_PATH_MAP kann dieser Worker grundsaetzlich nicht
# komprimieren - das war bis zum 26.07.2026 der stille Blocker.
$mapping = $txtMap.Text.Trim()
if ($mapping) {
Log "Freigabe (von Hand eingetragen): $mapping"
} else {
Log "Frage Rippy, ueber welche Freigabe die Rohdaten erreichbar sind ..."
$mapping = Hole-PfadMap $rHost
if ($mapping) {
$txtMap.Text = $mapping
Log "Freigabe von Rippy: $mapping"
}
}
if ($mapping) {
Log "Pruefe Erreichbarkeit ..."
[void](Pruefe-PfadMap $mapping)
} else {
Log "ACHTUNG: Ohne Freigabe kann dieser Worker NICHTS komprimieren."
Log " Der Worker wird trotzdem installiert und meldet sich in"
Log " Rippy - Jobs schlagen aber mit einer Klartext-Meldung fehl."
}
# 6. Start-Skripte + Deinstaller erzeugen
# RIPPY_PATH_MAP nur setzen, wenn es einen Wert gibt - eine leere
# Zuweisung in einer .bat ist harmlos, aber ein gesetztes leeres
# RIPPY_PATH_MAP liest pfad_lokal() als "kein Mapping", und die
# Fehlermeldung im Worker unterscheidet genau diese beiden Faelle.
$mapZeile = if ($mapping) { "set RIPPY_PATH_MAP=$mapping`r`n" } else { "" }
# 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`nset TZ=UTC`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
$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`nset TZ=UTC`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
# --- Deinstaller ---------------------------------------------------
#
# ⚠️ REPARIERT 26.07.2026. Der Commander meldete: „Der Uninstaller vom
# Worker funktioniert nicht mehr." Reproduziert mit echtem PowerShell:
#
# Der Typ [System.Windows.Forms.MessageBox] wurde nicht gefunden.
#
# Ursache: `Add-Type -AssemblyName System.Windows.Forms` stand EINE ZEILE
# ZU SPÄT — die MessageBox wurde davor benutzt. Der Deinstaller starb also
# in seiner ersten Arbeitszeile, jedes Mal.
#
# Zwei weitere Mängel gleich mit erledigt:
# - Kodierung war ASCII, obwohl Umlaute drin stehen (Kauderwelsch).
# - `Remove-Item -Recurse -Force $PSScriptRoot` löscht den Ordner, in dem
# das laufende Skript selbst liegt. Das klappt auf Windows nicht
# zuverlässig (die venv-DLLs sind noch geladen). Jetzt räumt ein
# losgelöstes cmd den Ordner ab, nachdem PowerShell beendet ist.
$uninstall = @"
# Rippy-Worker DEINSTALLIEREN rückstandsfrei (MUSS-Kriterium).
# Aufruf ohne Parameter fragt nach; -Force fragt nicht (so ruft das Tray auf).
param([switch]`$Force)
# ZUERST die Assembly laden, DANN die MessageBox benutzen. Genau daran ist der
# alte Deinstaller gescheitert.
Add-Type -AssemblyName System.Windows.Forms
if (-not `$Force) {
`$antwort = [System.Windows.Forms.MessageBox]::Show(
"Den Rippy-Worker '$wName' von diesem PC entfernen?",
"Rippy Worker deinstallieren",
[System.Windows.Forms.MessageBoxButtons]::YesNo,
[System.Windows.Forms.MessageBoxIcon]::Warning)
if (`$antwort -ne [System.Windows.Forms.DialogResult]::Yes) { exit }
}
`$ordner = `$PSScriptRoot
# ADMINRECHTE. Der Standard-Zielordner liegt unter "C:\Program Files", und
# der gehört nicht dem Benutzer jedes Remove-Item darin scheitert mit "Der
# Zugriff auf den Pfad wurde verweigert" (Commander-Befund 26.07.2026, an
# HandBrakes doc\LICENSE und Konsorten aufgelaufen). Die Installer-.exe ist mit
# -requireAdmin gebaut und fragt beim Start per UAC; der Deinstaller hatte
# nichts Vergleichbares und war damit im Standardfall nutzlos.
#
# GEMESSEN statt angenommen: Es wird ein Schreibversuch gemacht. Wer den Worker
# ins eigene Profil installiert hat, braucht keine Erhöhung und bekommt auch
# keine UAC-Frage.
`$schreibbar = `$false
try {
`$probe = Join-Path `$ordner ".deinstall-probe"
Set-Content -Path `$probe -Value "x" -ErrorAction Stop
Remove-Item -Force `$probe -ErrorAction SilentlyContinue
`$schreibbar = `$true
} catch { }
`$istAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not `$schreibbar -and -not `$istAdmin) {
# Sich selbst erhöht neu starten. -Force, weil oben schon zugestimmt wurde
# sonst käme die Frage zweimal.
try {
Start-Process powershell.exe -Verb RunAs -ArgumentList @(
"-NoProfile", "-ExecutionPolicy", "Bypass",
"-File", ('"' + `$PSCommandPath + '"'), "-Force")
} catch {
[System.Windows.Forms.MessageBox]::Show(
"Zum Entfernen sind Administratorrechte nötig (der Ordner liegt " +
"unter 'Programme'), und die Anfrage wurde abgelehnt.`n`n" +
"Alternative: PowerShell mit Rechtsklick 'Als Administrator " +
"ausführen' starten und dieses Skript erneut aufrufen.",
"Rippy Worker", 0, [System.Windows.Forms.MessageBoxIcon]::Warning) | Out-Null
}
exit
}
# 1. Alles beenden, was aus diesem Ordner läuft (Tray, celery, HandBrake).
Get-CimInstance Win32_Process | Where-Object {
`$_.ExecutablePath -like "`$ordner*"
} | ForEach-Object { Stop-Process -Id `$_.ProcessId -Force -ErrorAction SilentlyContinue }
Start-Sleep 3
# 2. Verknüpfungen entfernen Autostart UND Desktop, jeweils an BEIDEN möglichen
# Orten, weil die Installation je nach Zielverzeichnis persönlich oder
# maschinenweit war. Rückstandsfrei entfernbar" ist ein MUSS-Kriterium; eine
# tote Verknüpfung auf dem Desktop wäre genau so ein Rückstand.
foreach (`$ort in @("Startup", "CommonStartup")) {
`$lnk = Join-Path ([Environment]::GetFolderPath(`$ort)) "RippyWorker.lnk"
if (Test-Path `$lnk) { Remove-Item -Force `$lnk -ErrorAction SilentlyContinue }
}
foreach (`$ort in @("Desktop", "CommonDesktopDirectory")) {
`$lnk = Join-Path ([Environment]::GetFolderPath(`$ort)) "Rippy Worker.lnk"
if (Test-Path `$lnk) { Remove-Item -Force `$lnk -ErrorAction SilentlyContinue }
}
# ... und die geplante Aufgabe aus älteren Installationen, falls vorhanden.
schtasks /delete /tn "RippyWorker" /f 2>`$null | Out-Null
# 3. In Rippy abmelden, damit dort keine Worker-Leiche stehen bleibt.
try {
Invoke-RestMethod -Method Delete "http://$rHost/api/workers/$wName" -TimeoutSec 5 | Out-Null
} catch { }
# 4. Ordner abräumen. NICHT von hier aus: Dieses Skript liegt darin, und die
# venv-DLLs sind noch geladen. Ein losgelöstes cmd wartet, bis PowerShell
# weg ist, und räumt dann auf.
Set-Location `$env:TEMP
Start-Process cmd.exe -ArgumentList '/c timeout /t 4 /nobreak >nul & rd /s /q "'"`$ordner"'"' -WindowStyle Hidden
[System.Windows.Forms.MessageBox]::Show(
"Rippy-Worker entfernt. Der Ordner verschwindet in wenigen Sekunden.",
"Rippy Worker") | Out-Null
"@
# UTF-8 MIT BOM, nicht ASCII: Die Datei enthält Umlaute, und PowerShell
# 5.1 liest .ps1 ohne BOM als ANSI (dokumentiert im Kopf dieser Datei).
[System.IO.File]::WriteAllText(
(Join-Path $InstallDir "uninstall.ps1"), $uninstall,
(New-Object System.Text.UTF8Encoding $true))
# 7. Autostart über den Task Scheduler (als Dienst-Ersatz).
#
# Commander-Wunsch 26.07.2026: SchTasks.exe statt Startmenü-Verknüpfung.
# Da der Installer jetzt zwingend mit Administratorrechten läuft (für
# das "Programme"-Verzeichnis), funktioniert schtasks problemlos.
# /RL HIGHEST verhindert UAC-Popups beim automatischen Start.
# /SC ONLOGON startet den Worker bei der Benutzeranmeldung — das ist
# wichtig, damit er Zugriff auf die eingehängten Netzwerklaufwerke
# des Benutzers hat (der SYSTEM-Account hätte das bei ONSTART nicht).
if ($chkAuto.Checked) {
try {
# Der Pfad zur .bat muss in Anführungszeichen, falls Leerzeichen drin sind.
# schtasks erwartet diese Escaped-Anführungszeichen: \"C:\Pfad\...\"
$aktion = "\`"$InstallDir\start-tray.bat\`""
$process = Start-Process schtasks -ArgumentList "/create /tn `"RippyWorker`" /tr $aktion /sc ONLOGON /rl HIGHEST /f" -NoNewWindow -Wait -PassThru
if ($process.ExitCode -eq 0) {
Log "Autostart als Aufgabe (Task Scheduler) eingerichtet (startet bei Anmeldung)."
} else {
throw "ExitCode $($process.ExitCode)"
}
} catch {
# Nur eine Warnung: der Worker selbst ist fertig und startbar.
Log "HINWEIS: Autostart-Aufgabe konnte nicht eingerichtet werden ($_)."
Log " Der Worker ist trotzdem fertig installiert und läuft."
}
}
# 8. Verknüpfung auf dem Desktop (Commander-Wunsch 26.07.2026: „wenn man
# den Worker installiert braucht man auch eine exe auf dem Desktop
# zum Starten"). Vorher lagen die Startdateien nur im Programmordner —
# wer den Worker von Hand starten wollte, musste erst
# "C:\Program Files\Rippy Worker" suchen.
#
# Dieselbe Rechte-Überlegung wie beim Autostart: bei einer Installation
# unter "Programme" auf den Desktop ALLER Benutzer, sonst auf den
# eigenen. Und ein Fehlschlag hier ist nur eine Warnung — der Worker
# läuft davon unberührt.
if ($chkDesktop.Checked) {
try {
$programme = [Environment]::GetFolderPath("ProgramFiles")
$programmeX86 = [Environment]::GetFolderPath("ProgramFilesX86")
$maschinenweit = $InstallDir.StartsWith($programme, "OrdinalIgnoreCase") `
-or ($programmeX86 -and $InstallDir.StartsWith($programmeX86, "OrdinalIgnoreCase"))
$desktopDir = if ($maschinenweit) {
[Environment]::GetFolderPath("CommonDesktopDirectory")
} else {
[Environment]::GetFolderPath("Desktop")
}
$wsh = New-Object -ComObject WScript.Shell
$lnk = $wsh.CreateShortcut((Join-Path $desktopDir "Rippy Worker.lnk"))
$lnk.TargetPath = (Join-Path $InstallDir "start-tray.bat")
$lnk.WorkingDirectory = $InstallDir
$lnk.Description = "Rippy Encoding-Worker starten (Symbol neben der Uhr)"
$lnk.WindowStyle = 7 # minimiert, damit die .bat nicht aufblitzt
$ico = Join-Path $InstallDir "rippy.ico"
if (Test-Path $ico) { $lnk.IconLocation = $ico }
$lnk.Save()
Log "Verknüpfung 'Rippy Worker' auf dem Desktop angelegt."
} catch {
Log "HINWEIS: Desktop-Verknüpfung ging nicht ($($_.Exception.Message))."
Log " Der Worker ist trotzdem fertig — starten über"
Log " $InstallDir\start-tray.bat"
}
}
Log ""
Log "FERTIG! Ordner: $InstallDir"
Log "Klick 'Worker starten' - er erscheint dann in Rippy unter Einstellungen -> Worker."
$btnStart.Visible = $true
$btnInstall.Text = "Neu installieren"
$btnInstall.Enabled = $true
} catch {
Log ""
Log "FEHLER: $_"
Log "Tipp: Ist die IP korrekt und die Rippy-Maschine im Heimnetz erreichbar?"
$btnInstall.Enabled = $true
}
}
$btnMap.Add_Click({
$rHost = $txtHost.Text.Trim()
if (-not $rHost) {
[System.Windows.Forms.MessageBox]::Show(
"Bitte zuerst oben die LAN-IP der Rippy-Maschine eintragen.", "Fehlt")
return
}
$m = Hole-PfadMap $rHost
if ($m) {
$txtMap.Text = $m
Log "Freigabe von Rippy: $m"
[void](Pruefe-PfadMap $m)
}
})
$btnInstall.Add_Click({ Do-Install })
$btnStart.Add_Click({
Start-Process -FilePath (Join-Path $InstallDir "start-tray.bat") -WindowStyle Hidden
[System.Windows.Forms.MessageBox]::Show("Worker gestartet - Symbol erscheint unten rechts neben der Uhr.", "Rippy")
$form.Close()
})
[void]$form.ShowDialog()
-355
View File
@@ -1,355 +0,0 @@
# Rippy: Nativer Windows-Transcode-Worker - Installation OHNE Docker.
#
# Was das Skript tut (alles nach -InstallDir, Standard "C:\Program Files\Rippy Worker"):
# 1. Prüft Python (3.10+), legt ein venv an, installiert die Abhängigkeiten
# 2. Lädt den Worker-Code direkt von deiner Rippy-Instanz (/api/worker-setup/paket)
# 3. Lädt HandBrakeCLI (gepinnte Version, offizielles GitHub-Release),
# falls nicht schon im PATH oder im Ordner vorhanden
# 4. Fragt Rippy, über welche Netzwerk-Freigabe die Rohdaten erreichbar sind,
# und prüft, ob DIESER PC sie wirklich sieht (RIPPY_PATH_MAP)
# 5. Erzeugt start-worker.bat - Doppelklick startet den Worker
#
# Der Worker bedient NUR die Kompressions-Queue (transcode) - gerippt wird
# weiterhin auf der Rippy-Hauptmaschine (dort hängt das Laufwerk).
#
# Aufruf (PowerShell):
# .\install.ps1 -RippyHost <LAN-IP-der-Rippy-Maschine> [-WorkerName mein-pc] [-Autostart]
# (die IP steht in Rippy unter Einstellungen -> Worker; NICHT die dieses PCs)
#
# -Autostart legt eine Verknüpfung im Autostart-Ordner an (Start bei Anmeldung).
param(
[Parameter(Mandatory = $true)] [string]$RippyHost,
[string]$WorkerName = $env:COMPUTERNAME.ToLower(),
[switch]$Autostart,
# Verknüpfung auf dem Desktop. Standard AN (Commander-Wunsch 26.07.2026) —
# mit -KeineDesktopVerknuepfung abschaltbar.
[switch]$KeineDesktopVerknuepfung,
# Übersetzung der Rippy-Container-Pfade auf Freigaben, die DIESER PC sieht.
# Leer lassen: dann holt der Installer den Wert von Rippy selbst
# (GET /worker-setup/pfad-map, abgeleitet aus dessen eigenen Mounts).
# Von Hand nur, wenn dieser PC die Freigabe anders erreicht, z. B.
# -PfadMap "/app/media/rippy=Z:\"
[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
# (Commander-Wunsch 25.07.2026). Dorthin schreiben braucht Adminrechte -
# PowerShell also "Als Administrator" starten, oder hier einen Ordner im
# eigenen Profil angeben.
[string]$InstallDir = (Join-Path ([Environment]::GetFolderPath("ProgramFiles")) "Rippy Worker")
)
$ErrorActionPreference = "Stop"
# HandBrake-Version: die NEUESTE offizielle (GitHub-Release) - so passt der
# Windows-Worker zu dem, was der Update-Check im UI meldet (früher war hier
# eine feste 1.9.2 hardcodiert, was verwirrte). Fällt auf eine bekannte
# Version zurück, falls die GitHub-API gerade nicht erreichbar ist.
$HandBrakeVersion = "1.11.2"
try {
$rel = Invoke-RestMethod "https://api.github.com/repos/HandBrake/HandBrake/releases/latest" `
-TimeoutSec 15 -Headers @{ "User-Agent" = "rippy-installer" }
if ($rel.tag_name) { $HandBrakeVersion = ([string]$rel.tag_name).TrimStart("v") }
Write-Host "Neueste HandBrake-Version: $HandBrakeVersion"
} catch { Write-Host "GitHub-API nicht erreichbar - nutze HandBrake $HandBrakeVersion" }
$HandBrakeUrl = "https://github.com/HandBrake/HandBrake/releases/download/$HandBrakeVersion/HandBrakeCLI-$HandBrakeVersion-win-x86_64.zip"
Write-Host "== Rippy Windows-Worker Installation ==" -ForegroundColor Cyan
# 1. Python finden (py-Launcher oder python im PATH)
$python = $null
foreach ($kandidat in @("py -3", "python")) {
try {
$version = Invoke-Expression "$kandidat --version" 2>&1
if ("$version" -match "Python 3\.(\d+)") {
if ([int]$Matches[1] -ge 10) { $python = $kandidat; break }
}
} catch {}
}
if (-not $python) {
Write-Host "FEHLER: Python 3.10+ nicht gefunden." -ForegroundColor Red
Write-Host "Installieren mit: winget install Python.Python.3.12 (dann Skript erneut ausführen)"
exit 1
}
Write-Host "Python gefunden: $python"
# 2. Zielordner + Worker-Code von der Rippy-Instanz laden
$ziel = $InstallDir
try {
New-Item -ItemType Directory -Force $ziel -ErrorAction Stop | Out-Null
$probe = Join-Path $ziel ".schreibtest"
Set-Content -Path $probe -Value "x" -ErrorAction Stop
Remove-Item -Force $probe -ErrorAction SilentlyContinue
} catch {
Write-Host ""
Write-Host "FEHLER: In '$ziel' darf nicht geschrieben werden." -ForegroundColor Red
Write-Host "Das ist normal bei Ordnern unter 'Programme'. Zwei Wege:" -ForegroundColor Red
Write-Host " 1. PowerShell als Administrator starten und erneut aufrufen, ODER" -ForegroundColor Red
Write-Host " 2. ein eigenes Ziel angeben, z. B.:" -ForegroundColor Red
Write-Host " -InstallDir `"`$env:LOCALAPPDATA\Rippy Worker`"" -ForegroundColor Red
exit 1
}
Write-Host "Ziel: $ziel"
Set-Location $ziel
Write-Host "Lade Worker-Code von http://$RippyHost/api/worker-setup/paket ..."
Invoke-WebRequest "http://$RippyHost/api/worker-setup/paket" -OutFile worker.zip -UseBasicParsing
Expand-Archive worker.zip -DestinationPath . -Force
Remove-Item worker.zip
# 3. venv + Abhängigkeiten
if (-not (Test-Path "venv")) {
Write-Host "Lege Python-Umgebung an ..."
Invoke-Expression "$python -m venv venv"
}
& .\venv\Scripts\python.exe -m pip install --quiet --upgrade pip
& .\venv\Scripts\pip.exe install --quiet -r requirements.txt
# Nur für den Windows-Worker: Tray-Symbol (Status, Start/Stopp, Beenden)
& .\venv\Scripts\pip.exe install --quiet pystray pillow
Write-Host "Python-Abhängigkeiten installiert."
# 4. HandBrakeCLI besorgen (falls nötig)
$hb = Get-Command HandBrakeCLI.exe -ErrorAction SilentlyContinue
if (-not $hb -and -not (Test-Path ".\HandBrakeCLI.exe")) {
Write-Host "Lade HandBrakeCLI $HandBrakeVersion (offizielles GitHub-Release) ..."
Invoke-WebRequest $HandBrakeUrl -OutFile hb.zip -UseBasicParsing
Expand-Archive hb.zip -DestinationPath . -Force
Remove-Item hb.zip
}
if (-not (Test-Path ".\HandBrakeCLI.exe") -and -not $hb) {
Write-Host "FEHLER: HandBrakeCLI.exe fehlt." -ForegroundColor Red
exit 1
}
Write-Host "HandBrakeCLI bereit."
# 5. Netzwerk-Freigabe klären (RIPPY_PATH_MAP) - der stille Blocker bis 26.07.2026
#
# Rippy schickt dem Worker Pfade wie /app/media/rippy/<job>. Das sind Pfade
# INNERHALB des Rippy-Containers; dieser PC sieht sie nur, wenn sie auf eine
# Netzwerk-Freigabe übersetzt werden. Ohne das nimmt der Worker die Aufgabe an
# und lehnt sie Millisekunden später ab ("Keine Roh-MKVs gefunden") - gemessen
# am Job 95afdc89. Geraten wird nichts: Rippy hat die Freigabe selbst
# eingehängt und kennt ihre Quelle.
if (-not $PfadMap) {
Write-Host "Frage Rippy, über welche Freigabe die Rohdaten erreichbar sind ..."
try {
$antwort = Invoke-RestMethod "http://$RippyHost/api/worker-setup/pfad-map" -TimeoutSec 15
if ($antwort.hinweis) { Write-Host $antwort.hinweis -ForegroundColor Yellow }
if ($antwort.mapping) { $PfadMap = [string]$antwort.mapping }
} catch {
Write-Host "HINWEIS: Abfrage fehlgeschlagen ($($_.Exception.Message))." -ForegroundColor Yellow
}
}
if ($PfadMap) {
Write-Host "Freigabe: $PfadMap"
foreach ($paar in ($PfadMap -split ";")) {
if ($paar -notmatch "=") { continue }
$zielPfad = ($paar -split "=", 2)[1]
if (Test-Path -LiteralPath $zielPfad -ErrorAction SilentlyContinue) {
Write-Host " OK: $zielPfad ist von hier erreichbar." -ForegroundColor Green
} else {
Write-Host " NICHT erreichbar: $zielPfad" -ForegroundColor Yellow
Write-Host " Meist fehlen nur die Zugangsdaten: die Freigabe einmal im" -ForegroundColor Yellow
Write-Host " Explorer öffnen und 'Anmeldedaten speichern' anhaken." -ForegroundColor Yellow
}
}
} else {
Write-Host "ACHTUNG: Ohne Freigabe kann dieser Worker NICHTS komprimieren." -ForegroundColor Yellow
Write-Host " Er wird trotzdem installiert; Jobs schlagen dann mit einer" -ForegroundColor Yellow
Write-Host " Klartext-Meldung fehl statt still zu hängen." -ForegroundColor Yellow
}
# Leeres RIPPY_PATH_MAP nicht setzen: pfad_lokal() liest es als "kein Mapping",
# und die Fehlermeldung im Worker unterscheidet genau diese beiden Fälle.
$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
$bat = @"
@echo off
rem Rippy Windows-Transcode-Worker - verbindet sich mit $RippyHost
cd /d "%~dp0"
set REDIS_URL=redis://${RippyHost}:6379/0
set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy
set API_URL=http://${RippyHost}:8000
set WORKER_NAME=$WorkerName
set RIPPY_SLOTS=$Slots
set TZ=UTC
${mapZeile}set PATH=%~dp0;%PATH%
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
# 7. Tray-Start (empfohlen): Symbol neben der Uhr, kein Konsolenfenster
$trayBat = @"
@echo off
rem Rippy-Worker mit Tray-Symbol - verbindet sich mit $RippyHost
cd /d "%~dp0"
set REDIS_URL=redis://${RippyHost}:6379/0
set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy
set API_URL=http://${RippyHost}:8000
set WORKER_NAME=$WorkerName
set RIPPY_TRAY_HOST=$RippyHost
set RIPPY_SLOTS=$Slots
set TZ=UTC
${mapZeile}set PATH=%~dp0;%PATH%
start "" venv\Scripts\pythonw.exe tray.py
"@
Set-Content -Path "start-tray.bat" -Value $trayBat -Encoding ASCII
Write-Host "start-worker.bat + start-tray.bat erzeugt."
# 8. Deinstaller - MUSS-Kriterium: rückstandsfrei entfernbar
$uninstall = @"
# Rippy-Worker DEINSTALLIEREN: stoppt Prozesse, entfernt Autostart,
# meldet den Worker in Rippy ab und löscht diesen Ordner komplett.
param([switch]`$Force)
if (-not `$Force) {
`$antwort = Read-Host "Rippy-Worker '$WorkerName' wirklich deinstallieren? (j/n)"
if (`$antwort -ne "j") { Write-Host "Abgebrochen."; exit }
}
# ADMINRECHTE. Der Standard-Zielordner liegt unter "C:\Program Files", und der
# gehört nicht dem Benutzer jedes Remove-Item darin scheitert mit "Der Zugriff
# auf den Pfad wurde verweigert" (Commander-Befund 26.07.2026). Geprüft wird per
# Schreibversuch, nicht per Annahme: Wer ins eigene Profil installiert hat,
# bekommt keine UAC-Frage.
`$schreibbar = `$false
try {
`$probe = Join-Path `$PSScriptRoot ".deinstall-probe"
Set-Content -Path `$probe -Value "x" -ErrorAction Stop
Remove-Item -Force `$probe -ErrorAction SilentlyContinue
`$schreibbar = `$true
} catch { }
`$istAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not `$schreibbar -and -not `$istAdmin) {
Write-Host "Der Ordner liegt unter 'Programme' starte mich mit Administratorrechten neu ..." -ForegroundColor Yellow
try {
Start-Process powershell.exe -Verb RunAs -ArgumentList @(
"-NoProfile", "-ExecutionPolicy", "Bypass",
"-File", ('"' + `$PSCommandPath + '"'), "-Force")
} catch {
Write-Host "FEHLER: Erhöhung abgelehnt. PowerShell mit Rechtsklick 'Als Administrator ausführen' starten und erneut aufrufen." -ForegroundColor Red
}
exit
}
Get-CimInstance Win32_Process | Where-Object {
`$_.ExecutablePath -like "`$PSScriptRoot*"
} | ForEach-Object { Stop-Process -Id `$_.ProcessId -Force -ErrorAction SilentlyContinue }
Start-Sleep 2
# Autostart-Verknüpfung entfernen - BEIDE möglichen Orte, weil die
# Installation je nach Zielverzeichnis persönlich oder maschinenweit war.
foreach (`$ordner in @("Startup", "CommonStartup")) {
try { Remove-Item -Force (Join-Path ([Environment]::GetFolderPath(`$ordner)) "RippyWorker.lnk") -ErrorAction Stop } catch {}
}
# ... und die Desktop-Verknüpfung. Rückstandsfrei entfernbar" ist ein
# MUSS-Kriterium; ein totes Symbol auf dem Desktop wäre genau so ein Rückstand.
foreach (`$ordner in @("Desktop", "CommonDesktopDirectory")) {
try { Remove-Item -Force (Join-Path ([Environment]::GetFolderPath(`$ordner)) "Rippy Worker.lnk") -ErrorAction Stop } catch {}
}
# ... und die geplante Aufgabe aus älteren Installationen, falls vorhanden.
schtasks /delete /tn "RippyWorker" /f 2>`$null | Out-Null
try {
Invoke-RestMethod -Method Delete "http://$RippyHost/api/workers/$WorkerName" -TimeoutSec 5 | Out-Null
Write-Host "Worker-Eintrag in Rippy entfernt."
} catch { Write-Host "Hinweis: Eintrag in Rippy ggf. von Hand löschen (Einstellungen -> Worker)." }
# Ordner abräumen NICHT von hier aus: Dieses Skript liegt darin, und die
# venv-DLLs sind noch geladen. `Remove-Item -Recurse -Force` auf den eigenen
# Ordner klappt auf Windows nicht zuverlässig (Befund 26.07.2026). Ein
# losgelöstes cmd wartet, bis PowerShell weg ist, und räumt dann auf.
Set-Location `$env:TEMP
Start-Process cmd.exe -ArgumentList '/c timeout /t 4 /nobreak >nul & rd /s /q "'"`$PSScriptRoot"'"' -WindowStyle Hidden
Write-Host "Rippy-Worker deinstalliert (Ordner verschwindet in wenigen Sekunden)." -ForegroundColor Green
"@
# UTF-8 MIT BOM statt ASCII: die Datei enthält Umlaute, und PowerShell 5.1 liest
# .ps1 ohne BOM als ANSI.
[System.IO.File]::WriteAllText(
(Join-Path $ziel "uninstall.ps1"), $uninstall,
(New-Object System.Text.UTF8Encoding $true))
Write-Host "uninstall.ps1 erzeugt."
# 9. Optional: Autostart bei Anmeldung (mit Tray)
#
# Über den Autostart-ORDNER, nicht über schtasks: Eine Aufgabe im
# Wurzelordner der Aufgabenplanung anzulegen verlangt Administratorrechte, und
# der Installer läuft normal ohne. Auf dem Commander-PC scheiterte das am
# 25.07.2026 mit "FEHLER: Zugriff verweigert." - und weil oben
# $ErrorActionPreference = "Stop" steht, riss es die ganze Installation mit,
# obwohl nur der Autostart fehlte. Der Autostart-Ordner braucht nie Adminrechte.
if ($Autostart) {
try {
# Unter "Programme" = Installation für die Maschine, also Autostart für
# alle Benutzer. Sonst persönlich. Siehe install-gui.ps1 für die
# ausführliche Begründung (Rechtelage bei fremdem Admin-Konto).
$programme = [Environment]::GetFolderPath("ProgramFiles")
$maschinenweit = $ziel.StartsWith($programme, "OrdinalIgnoreCase")
$autostartDir = if ($maschinenweit) {
[Environment]::GetFolderPath("CommonStartup")
} else {
[Environment]::GetFolderPath("Startup")
}
$wsh = New-Object -ComObject WScript.Shell
$lnk = $wsh.CreateShortcut((Join-Path $autostartDir "RippyWorker.lnk"))
$lnk.TargetPath = (Join-Path $ziel "start-tray.bat")
$lnk.WorkingDirectory = $ziel
$lnk.Description = "Rippy Encoding-Worker"
# WindowStyle 7 = minimiert: start-tray.bat startet pythonw (kein
# Konsolenfenster), aber die .bat selbst blitzt sonst kurz auf.
$lnk.WindowStyle = 7
$ico = Join-Path $ziel "rippy.ico"
if (Test-Path $ico) { $lnk.IconLocation = $ico }
$lnk.Save()
Write-Host "Autostart eingerichtet (Verknüpfung im Autostart-Ordner)."
} catch {
Write-Host "HINWEIS: Autostart konnte nicht eingerichtet werden ($($_.Exception.Message))." -ForegroundColor Yellow
Write-Host " Der Worker ist trotzdem fertig installiert." -ForegroundColor Yellow
Write-Host " Von Hand: start-tray.bat in shell:startup verknüpfen." -ForegroundColor Yellow
}
}
# 10. Verknüpfung auf dem Desktop (Commander-Wunsch 26.07.2026). Vorher lagen die
# Startdateien nur im Programmordner — wer den Worker von Hand starten wollte,
# musste erst "C:\Program Files\Rippy Worker" suchen. Ein Fehlschlag hier ist nur
# eine Warnung; der Worker läuft davon unberührt.
if (-not $KeineDesktopVerknuepfung) {
try {
$programme = [Environment]::GetFolderPath("ProgramFiles")
$maschinenweit = $ziel.StartsWith($programme, "OrdinalIgnoreCase")
$desktopDir = if ($maschinenweit) {
[Environment]::GetFolderPath("CommonDesktopDirectory")
} else {
[Environment]::GetFolderPath("Desktop")
}
$wsh = New-Object -ComObject WScript.Shell
$lnk = $wsh.CreateShortcut((Join-Path $desktopDir "Rippy Worker.lnk"))
$lnk.TargetPath = (Join-Path $ziel "start-tray.bat")
$lnk.WorkingDirectory = $ziel
$lnk.Description = "Rippy Encoding-Worker starten (Symbol neben der Uhr)"
$lnk.WindowStyle = 7
$ico = Join-Path $ziel "rippy.ico"
if (Test-Path $ico) { $lnk.IconLocation = $ico }
$lnk.Save()
Write-Host "Verknüpfung 'Rippy Worker' auf dem Desktop angelegt."
} catch {
Write-Host "HINWEIS: Desktop-Verknüpfung ging nicht ($($_.Exception.Message))." -ForegroundColor Yellow
}
}
Write-Host ""
Write-Host "FERTIG." -ForegroundColor Green
Write-Host " Auf dem Desktop: Rippy Worker"
Write-Host " Starten (mit Tray-Symbol): $ziel\start-tray.bat"
Write-Host " Starten (Konsole/Debug): $ziel\start-worker.bat"
Write-Host " Deinstallieren: $ziel\uninstall.ps1"
Write-Host "Der Worker erscheint in Rippy unter Einstellungen -> Worker als '$WorkerName'."
+271
View File
@@ -0,0 +1,271 @@
import flet as ft
import os
import subprocess
import threading
import urllib.request
import json
import time
import zipfile
def main(page: ft.Page):
page.title = "Rippy Encoding-Worker Installation"
page.window_width = 800
page.window_height = 650
page.bgcolor = "#0f172a"
page.theme_mode = ft.ThemeMode.DARK
page.scroll = ft.ScrollMode.AUTO
# Colors
c_panel = "#1e293b"
c_text = "#e2e8f0"
c_muted = "#94a3b8"
c_accent = "#6366f1"
c_success = "#10b981"
# State
std_ziel = r"C:\Program Files\Rippy Worker"
# UI Fields
txt_host = ft.TextField(label="LAN-IP der Rippy-Maschine (Docker-Host)", width=400, bgcolor=c_panel, color=c_text)
txt_name = ft.TextField(label="Name dieses Workers (frei wählbar)", width=250, value=os.environ.get("COMPUTERNAME", "worker").lower(), bgcolor=c_panel, color=c_text)
txt_slots = ft.Dropdown(
label="Kerne (Gleichzeitig)",
width=150,
options=[ft.dropdown.Option(str(i)) for i in range(1, 9)],
value="2" if os.cpu_count() and os.cpu_count() >= 12 else "1",
bgcolor=c_panel,
color=c_text
)
txt_pfad = ft.TextField(label="Installieren nach", value=std_ziel, expand=True, bgcolor=c_panel, color=c_text)
txt_map = ft.TextField(label="Netzwerk-Freigabe mit den Rohdaten", expand=True, bgcolor=c_panel, color=c_text)
chk_auto = ft.Checkbox(label="Beim Anmelden automatisch starten (Hintergrund)", value=True, fill_color=c_accent)
chk_desktop = ft.Checkbox(label="Verknüpfung auf dem Desktop anlegen", value=True, fill_color=c_accent)
log_view = ft.ListView(height=150, auto_scroll=True, spacing=2)
def log(msg: str):
log_view.controls.append(ft.Text(msg, color=c_muted, font_family="Consolas", size=12))
try:
page.update()
except:
pass
def check_map(e):
r_host = txt_host.value.strip()
if not r_host:
page.snack_bar = ft.SnackBar(ft.Text("Bitte zuerst die LAN-IP eintragen!"), bgcolor="red")
page.snack_bar.open = True
page.update()
return
try:
log(f"Frage Rippy nach Freigabe ({r_host})...")
with urllib.request.urlopen(f"http://{r_host}/api/worker-setup/pfad-map", timeout=5) as r:
data = json.loads(r.read().decode())
if data.get("hinweis"):
log(data["hinweis"])
if data.get("mapping"):
txt_map.value = data["mapping"]
log(f"Erfolgreich geholt: {data['mapping']}")
page.update()
except Exception as ex:
log(f"Fehler: {ex}")
def do_install(e):
btn_install.disabled = True
page.update()
threading.Thread(target=run_installation, daemon=True).start()
def run_installation():
r_host = txt_host.value.strip()
w_name = txt_name.value.strip()
install_dir = txt_pfad.value.strip()
if not r_host:
log("FEHLER: Rippy LAN-IP fehlt!")
btn_install.disabled = False
page.update()
return
log(f"Zielverzeichnis: {install_dir}")
# Check Python
python_exe = None
for cmd in ["py -3", "python"]:
try:
res = subprocess.run(f"{cmd} --version", shell=True, capture_output=True, text=True)
if "Python 3." in res.stdout:
python_exe = cmd
break
except: pass
if not python_exe:
log("FEHLER: Python 3.10+ nicht gefunden.")
log("Bitte mit 'winget install Python.Python.3.12' installieren.")
btn_install.disabled = False
page.update()
return
log(f"Python gefunden: {python_exe}")
try:
os.makedirs(install_dir, exist_ok=True)
test_file = os.path.join(install_dir, ".test")
with open(test_file, "w") as f: f.write("x")
os.remove(test_file)
except Exception as ex:
log(f"FEHLER: Keine Schreibrechte in {install_dir}")
log("Bitte als Administrator starten oder Pfad aendern.")
btn_install.disabled = False
page.update()
return
# Download worker package
log(f"Lade Worker-Code von {r_host}...")
try:
zip_path = os.path.join(install_dir, "worker.zip")
urllib.request.urlretrieve(f"http://{r_host}/api/worker-setup/paket", zip_path)
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
zip_ref.extractall(install_dir)
os.remove(zip_path)
except Exception as ex:
log(f"FEHLER beim Download: {ex}")
btn_install.disabled = False
page.update()
return
# Setup venv — --clear loescht einen alten venv komplett, damit keine
# veralteten Pakete (z.B. flet 0.86 statt 0.23) ueberleben.
log("Lege Python-Umgebung an (venv)...")
subprocess.run(f"{python_exe} -m venv --clear venv", cwd=install_dir, shell=True)
log("Installiere Abhaengigkeiten (pip)...")
subprocess.run(r"venv\Scripts\python.exe -m pip install --quiet --upgrade pip", cwd=install_dir, shell=True)
subprocess.run(r"venv\Scripts\pip.exe install --quiet -r requirements.txt", cwd=install_dir, shell=True)
subprocess.run(r"venv\Scripts\pip.exe install --quiet requests pillow", cwd=install_dir, shell=True)
# Download Handbrake
hb_path = os.path.join(install_dir, "HandBrakeCLI.exe")
if not os.path.exists(hb_path):
log("Lade HandBrakeCLI herunter...")
try:
hb_ver = "1.7.3" # Fallback if github fails
try:
with urllib.request.urlopen("https://api.github.com/repos/HandBrake/HandBrake/releases/latest", timeout=5) as r:
hb_data = json.loads(r.read().decode())
if hb_data.get("tag_name"): hb_ver = hb_data["tag_name"].lstrip("v")
except: pass
hb_zip = os.path.join(install_dir, "hb.zip")
urllib.request.urlretrieve(f"https://github.com/HandBrake/HandBrake/releases/download/{hb_ver}/HandBrakeCLI-{hb_ver}-win-x86_64.zip", hb_zip)
with zipfile.ZipFile(hb_zip, 'r') as zip_ref:
zip_ref.extractall(install_dir)
os.remove(hb_zip)
except Exception as ex:
log(f"Warnung bei HandBrake-Download: {ex}")
log("Erstelle Start-Skripte...")
slots = txt_slots.value
pool_arg = "--pool=solo" if int(slots) <= 1 else f"--pool=threads --concurrency={slots}"
map_zeile = f"set RIPPY_PATH_MAP={txt_map.value.strip()}\r\n" if txt_map.value.strip() else ""
gui_bat = f"@echo off\r\ncd /d \"%~dp0\"\r\nset REDIS_URL=redis://{r_host}:6379/0\r\nset DATABASE_URL=postgresql://rippy:rippy@{r_host}:5432/rippy\r\nset API_URL=http://{r_host}:8000\r\nset WORKER_NAME={w_name}\r\nset RIPPY_TRAY_HOST={r_host}\r\nset RIPPY_SLOTS={slots}\r\nset TZ=UTC\r\n{map_zeile}set PATH=%~dp0;%PATH%\r\nstart \"\" venv\\Scripts\\pythonw.exe gui.py"
with open(os.path.join(install_dir, "start-gui.bat"), "w") as f: f.write(gui_bat)
work_bat = f"@echo off\r\ncd /d \"%~dp0\"\r\nset REDIS_URL=redis://{r_host}:6379/0\r\nset DATABASE_URL=postgresql://rippy:rippy@{r_host}:5432/rippy\r\nset API_URL=http://{r_host}:8000\r\nset WORKER_NAME={w_name}\r\nset RIPPY_SLOTS={slots}\r\nset TZ=UTC\r\n{map_zeile}set PATH=%~dp0;%PATH%\r\nvenv\\Scripts\\celery.exe -A celery_app worker --loglevel=info -Q transcode {pool_arg} -n {w_name}@%%h"
with open(os.path.join(install_dir, "start-worker.bat"), "w") as f: f.write(work_bat)
# Uninstaller
uninstall_ps1 = f"""param([switch]$Force)
Add-Type -AssemblyName System.Windows.Forms
if (-not $Force) {{
$antwort = [System.Windows.Forms.MessageBox]::Show("Rippy-Worker '{w_name}' von diesem PC entfernen?", "Rippy Worker deinstallieren", 4, 32)
if ($antwort -ne "Yes") {{ exit }}
}}
Get-WmiObject Win32_Process | Where-Object {{
$_.ExecutablePath -like "$PSScriptRoot*" -or $_.CommandLine -like "*gui.py*" -or $_.CommandLine -like "*tray.py*"
}} | ForEach-Object {{ Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }}
Start-Sleep 3
$desktopDir = if ($PSScriptRoot -match "(?i)Program Files") {{ [Environment]::GetFolderPath("CommonDesktopDirectory") }} else {{ [Environment]::GetFolderPath("Desktop") }}
Remove-Item -Force "$desktopDir\\Rippy Worker.lnk" -ErrorAction SilentlyContinue
schtasks /delete /tn "RippyWorker" /f *>$null
Start-Process cmd -ArgumentList "/c ping 127.0.0.1 -n 3 >nul & rmdir /s /q `"$PSScriptRoot`"" -WindowStyle Hidden
if (-not $Force) {{ [System.Windows.Forms.MessageBox]::Show("Rippy-Worker entfernt.", "Rippy Worker") }}
"""
with open(os.path.join(install_dir, "uninstall.ps1"), "w", encoding="utf-8") as f: f.write(uninstall_ps1)
# Autostart
if chk_auto.value:
log("Richte Autostart (Task Scheduler) ein...")
bat_path = os.path.join(install_dir, "start-gui.bat")
subprocess.run(f'schtasks /create /tn "RippyWorker" /tr "\\"{bat_path}\\"" /sc ONLOGON /rl HIGHEST /f', shell=True, capture_output=True)
# Desktop Shortcut — ueber PowerShell/COM, weil win32com im
# PyInstaller-Bundle nicht enthalten ist.
if chk_desktop.value:
try:
public_desktop = os.path.join(os.environ.get("PUBLIC", r"C:\Users\Public"), "Desktop")
user_desktop = os.path.join(os.path.expanduser("~"), "Desktop")
desktop = public_desktop if "Program Files" in install_dir and os.path.isdir(public_desktop) else user_desktop
lnk_path = os.path.join(desktop, "Rippy Worker.lnk")
bat_target = os.path.join(install_dir, "start-gui.bat")
ico_path = os.path.join(install_dir, "rippy.ico")
ps_cmd = (
f'$ws = New-Object -ComObject WScript.Shell; '
f'$sc = $ws.CreateShortcut(\"{lnk_path}\"); '
f'$sc.TargetPath = \"{bat_target}\"; '
f'$sc.WorkingDirectory = \"{install_dir}\"; '
f'$sc.IconLocation = \"{ico_path}\"; '
f'$sc.WindowStyle = 7; '
f'$sc.Save()'
)
subprocess.run(["powershell", "-NoProfile", "-Command", ps_cmd], capture_output=True)
log("Desktop-Verknüpfung erstellt.")
except Exception as e:
log(f"Warnung: Shortcut konnte nicht erstellt werden: {e}")
log("FERTIG!")
btn_start.visible = True
btn_install.text = "Neu installieren"
btn_install.disabled = False
page.update()
def start_worker(e):
install_dir = txt_pfad.value.strip()
bat_path = os.path.join(install_dir, "start-gui.bat")
# os.startfile startet die .bat ueber die Shell-Verknuepfung —
# CREATE_NO_WINDOW wuerde sie stumm schlucken.
os.startfile(bat_path)
page.window_close()
btn_install = ft.ElevatedButton("Installieren", on_click=do_install, bgcolor=c_accent, color=ft.colors.WHITE, width=200, height=40)
btn_start = ft.ElevatedButton("Worker starten", on_click=start_worker, bgcolor=c_success, color=ft.colors.WHITE, width=200, height=40, visible=False)
def get_dir(e: ft.FilePickerResultEvent):
if e.path:
txt_pfad.value = e.path
page.update()
dir_picker = ft.FilePicker(on_result=get_dir)
page.overlay.append(dir_picker)
page.add(
ft.Row([
ft.Icon(ft.icons.DISC_FULL, size=40, color=c_accent),
ft.Text("Rippy Encoding-Worker", size=28, weight=ft.FontWeight.BOLD, color=c_text)
]),
ft.Text("Diese Maschine übernimmt die Video-Kompression für Rippy. Gerippt wird weiter auf der Hauptmaschine.", color=c_muted),
ft.Divider(color="#334155"),
ft.Row([txt_host], alignment=ft.MainAxisAlignment.START),
ft.Row([txt_name, txt_slots], alignment=ft.MainAxisAlignment.START),
ft.Row([txt_pfad, ft.ElevatedButton("Durchsuchen ...", on_click=lambda _: dir_picker.get_directory_path("Installationsordner wählen"), bgcolor=c_panel, color=c_text)], alignment=ft.MainAxisAlignment.START),
ft.Row([txt_map, ft.ElevatedButton("Freigabe holen", on_click=check_map, bgcolor=c_panel, color=c_text)], alignment=ft.MainAxisAlignment.START),
chk_auto,
chk_desktop,
ft.Container(content=log_view, bgcolor="#020617", padding=10, border_radius=5, border=ft.border.all(1, "#334155")),
ft.Row([btn_install, btn_start])
)
if __name__ == "__main__":
ft.app(target=main)
+1 -1
View File
@@ -21,7 +21,7 @@ COPY docker/api/ .
# native Worker aus (GET /worker-setup/windows bzw. /worker-setup/paket) — # native Worker aus (GET /worker-setup/windows bzw. /worker-setup/paket) —
# die Zielmaschine braucht weder git noch Docker. # die Zielmaschine braucht weder git noch Docker.
COPY docker/worker/*.py docker/worker/requirements.txt worker_dist/ COPY docker/worker/*.py docker/worker/requirements.txt worker_dist/
COPY deploy/worker-windows/install.ps1 deploy/worker-windows/install-gui.ps1 worker_dist/ COPY deploy/worker-windows/installer.py worker_dist/
# Rippy-Icon: Der Installer legt damit eine Verknüpfung auf den Desktop # Rippy-Icon: Der Installer legt damit eine Verknüpfung auf den Desktop
# (Commander-Wunsch 26.07.2026). Ohne die Datei auf der Zielmaschine trüge die # (Commander-Wunsch 26.07.2026). Ohne die Datei auf der Zielmaschine trüge die
# Verknüpfung das Standard-Batch-Symbol — und niemand fände sie zwischen den # Verknüpfung das Standard-Batch-Symbol — und niemand fände sie zwischen den
+599
View File
@@ -0,0 +1,599 @@
import os
import subprocess
import sys
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 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 = hole("/capabilities")
jobs = 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:
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 _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_bg = "#0f172a"
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 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=2, 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)
], 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"))
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.text = "Worker läuft"
btn_toggle.icon = ft.icons.CHECK
btn_toggle.bgcolor = "#1e293b"
btn_toggle.disabled = True
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=64, height=96, border_radius=8, fit=ft.ImageFit.COVER)
dashboard_card = ft.Container(
content=ft.Column([
status_text,
machine_info,
ft.Container(height=5),
ft.Row([
active_poster,
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.Image(src=poster, width=40, height=60, border_radius=5, fit=ft.ImageFit.COVER))
row_items.append(ft.Text(job_text(j), 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.visible = False
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.visible = False
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.visible = True
else:
active_poster.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.visible = False
btn_toggle.disabled = True
btn_toggle.text = "Worker läuft"
btn_toggle.icon = ft.icons.CHECK
btn_toggle.bgcolor = "#1e293b"
page.update()
except Exception as e:
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()
def worker_stoppen():
"""Celery-Prozess sauber beenden."""
global prozess
if prozess and prozess.poll() is None:
try:
prozess.terminate()
prozess.wait(timeout=5)
except Exception:
try:
prozess.kill()
except Exception:
pass
if __name__ == "__main__":
ft.app(target=main)
+1
View File
@@ -3,3 +3,4 @@ redis==5.0.4
requests==2.32.3 requests==2.32.3
sqlalchemy==2.0.32 sqlalchemy==2.0.32
psycopg2-binary==2.9.9 psycopg2-binary==2.9.9
flet==0.23.2
-423
View File
@@ -1,423 +0,0 @@
"""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)
-274
View File
@@ -1,274 +0,0 @@
"""Kleines Verwaltungsfenster für den Windows-Worker (Doppelklick aufs Tray).
Commander-Wunsch 26.07.2026: *Es wäre cool wenn man einfach mit nem Doppelklick
ne kleine Verwaltung hat wo man direkt Logs, Aufgaben, usw. sieht."*
## Warum ein eigener PROZESS und kein Fenster im Tray
pystray belegt mit `icon.run()` den Haupt-Thread, und tkinter will seine
Ereignisschleife ebenfalls dort haben. Beides in einem Prozess zu verschränken
ist eine bekannte Quelle für Fenster, die sich nicht mehr schließen lassen. Als
eigener Prozess (`pythonw.exe verwaltung.py`) gibt es das Problem gar nicht: Das
Fenster erbt die Umgebung vom Tray (Rippy-Adresse, Worker-Name) und kann
abstürzen, ohne den Worker mitzunehmen.
## Was es anzeigt — und woher
Alles Fachliche kommt von RIPPY, nicht aus eigener Rechnung: Ob dieser Worker als
erreichbar gilt, steht in `/capabilities`; was gerade läuft und wie lange es noch
dauert, in `/jobs`. So zeigt das Fenster dieselben Zahlen wie das Dashboard statt
einer zweiten, abweichenden Wahrheit.
Nur das Log kommt lokal: Es ist genau dann die einzige Auskunft, wenn Rippy nicht
erreichbar ist.
tkinter statt WinForms, weil es bei jeder Windows-Python-Installation dabei ist
der Installer soll keine weitere Abhängigkeit brauchen.
"""
import json
import os
import subprocess
import sys
import urllib.request
import webbrowser
RIPPY_HOST = os.getenv("RIPPY_TRAY_HOST", "")
WORKER_NAME = os.getenv("WORKER_NAME", "windows-worker")
SLOTS = os.getenv("RIPPY_SLOTS", "1")
BASIS = os.path.dirname(os.path.abspath(__file__))
# Farben aus dem Rippy-UI (docker/ui/src/lib/design.ts), damit das Fenster nicht
# wie ein Fremdkörper wirkt.
BG = "#0f172a"
PANEL = "#020617"
TEXT = "#e2e8f0"
GEDAEMPFT = "#94a3b8"
AMBER = "#f59e0b"
EMERALD = "#10b981"
ROSE = "#f43f5e"
LOG_ZEILEN = 200
TAKT_MS = 4000
def letzte_zeilen(pfad: str, anzahl: int = LOG_ZEILEN, oeffnen=None) -> list:
"""Die letzten `anzahl` Zeilen einer Datei (pure genug für einen Test).
Liest bewusst die GANZE Datei und schneidet ab: Das Worker-Log wird nicht
groß (die Brücke drosselt auf 30 Zeilen/Minute), und ein Rückwärts-Suchen
über Blockgrenzen wäre mehr Code als Nutzen. Fehler beim Lesen geben eine
leere Liste ein Fenster, das wegen des Logs nicht aufgeht, wäre schlimmer.
"""
macher = oeffnen or (lambda p: open(p, encoding="utf-8", errors="replace"))
try:
with macher(pfad) as f:
return f.read().splitlines()[-anzahl:]
except OSError:
return []
def hole(pfad: str, host: str = None, timeout: int = 8):
"""GET auf Rippys API — None, wenn es nicht klappt."""
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 worker_zustand(capabilities, name: str) -> dict:
"""Was Rippy über DIESEN Worker weiß (pure Funktion).
Rückgabe: {"bekannt": bool, "online": bool, "encoders": [...], "info": {...}}
"""
for w in ((capabilities or {}).get("workers") or []):
if w.get("name") == name:
return {
"bekannt": True,
"online": bool(w.get("online")),
"encoders": w.get("encoders") or [],
"info": w.get("info") or {},
}
return {"bekannt": False, "online": False, "encoders": [], "info": {}}
def job_text(job: dict) -> str:
"""Eine Zeile für die Aufgabenliste (pure Funktion)."""
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 "?")
zeile = f"{titel}{status}"
if job.get("status") in ("transcoding", "processing", "running"):
zeile += f", {job.get('progress') or 0} %"
if job.get("eta_text"):
zeile += f" ({job['eta_text']})"
return zeile
def status_text(zustand: dict, aktueller_job: dict) -> tuple:
"""(Text, Farbe) für die Statuszeile (pure Funktion)."""
if not zustand.get("bekannt"):
return ("Rippy kennt diesen Worker nicht — läuft er? Ist die Adresse richtig?", ROSE)
if not zustand.get("online"):
return ("Bei Rippy als NICHT erreichbar gemeldet", ROSE)
if aktueller_job:
return (job_text(aktueller_job), AMBER)
return ("Erreichbar und bereit — nichts in Arbeit", EMERALD)
def deinstallieren() -> None:
"""Startet den Deinstaller im selben Ordner (fragt dort selbst nach)."""
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 _log_pfad() -> str:
"""Dieselbe Wahl wie im Tray (dort ausführlich begründet)."""
basis_daten = os.getenv("LOCALAPPDATA") or os.getenv("APPDATA") or ""
if basis_daten:
kandidat = os.path.join(basis_daten, "Rippy Worker", "worker.log")
if os.path.exists(kandidat):
return kandidat
return os.path.join(BASIS, "worker.log")
def fenster_bauen(): # pragma: no cover — reine Oberfläche
import tkinter as tk
from tkinter import scrolledtext
wurzel = tk.Tk()
wurzel.title(f"Rippy Worker — {WORKER_NAME}")
wurzel.geometry("760x620")
wurzel.configure(bg=BG)
kopf = tk.Frame(wurzel, bg=BG)
kopf.pack(fill="x", padx=16, pady=(14, 6))
tk.Label(kopf, text=f"Rippy Worker „{WORKER_NAME}", bg=BG, fg=TEXT,
font=("Segoe UI", 14, "bold")).pack(anchor="w")
tk.Label(kopf, text=f"Rippy: {RIPPY_HOST or 'keine Adresse gesetzt'} · "
f"{SLOTS} Auftrag/Aufträge gleichzeitig",
bg=BG, fg=GEDAEMPFT, font=("Segoe UI", 9)).pack(anchor="w")
status = tk.Label(wurzel, text="wird geladen …", bg=BG, fg=GEDAEMPFT,
font=("Segoe UI", 10, "bold"), anchor="w", justify="left",
wraplength=720)
status.pack(fill="x", padx=16, pady=(8, 2))
maschine = tk.Label(wurzel, text="", bg=BG, fg=GEDAEMPFT,
font=("Consolas", 9), anchor="w", justify="left")
maschine.pack(fill="x", padx=16)
tk.Label(wurzel, text="Aufgaben", bg=BG, fg=TEXT,
font=("Segoe UI", 10, "bold")).pack(anchor="w", padx=16, pady=(12, 2))
aufgaben = tk.Listbox(wurzel, height=6, bg=PANEL, fg=TEXT,
font=("Consolas", 9), borderwidth=0,
highlightthickness=1, highlightbackground="#1e293b",
selectbackground="#1e293b")
aufgaben.pack(fill="x", padx=16)
tk.Label(wurzel, text="Log dieser Maschine", bg=BG, fg=TEXT,
font=("Segoe UI", 10, "bold")).pack(anchor="w", padx=16, pady=(12, 2))
log = scrolledtext.ScrolledText(wurzel, height=12, bg=PANEL, fg=GEDAEMPFT,
font=("Consolas", 8), borderwidth=0,
highlightthickness=1,
highlightbackground="#1e293b", wrap="none")
log.pack(fill="both", expand=True, padx=16)
knopf_reihe = tk.Frame(wurzel, bg=BG)
knopf_reihe.pack(fill="x", padx=16, pady=12)
def knopf(text, aktion, farbe=AMBER, vordergrund=BG):
b = tk.Button(knopf_reihe, text=text, command=aktion, bg=farbe,
fg=vordergrund, relief="flat", font=("Segoe UI", 9, "bold"),
padx=12, pady=6, cursor="hand2")
b.pack(side="left", padx=(0, 8))
return b
knopf("Rippy öffnen", lambda: RIPPY_HOST and webbrowser.open(f"http://{RIPPY_HOST}"))
knopf("Log in Rippy", lambda: RIPPY_HOST and webbrowser.open(f"http://{RIPPY_HOST}/logs"),
farbe="#1e293b", vordergrund=TEXT)
def deinstallieren_fragen():
from tkinter import messagebox
if messagebox.askyesno(
"Rippy Worker deinstallieren",
f"Den Worker „{WORKER_NAME}“ von diesem PC entfernen?\n\n"
"Der Ordner wird gelöscht, der Autostart entfernt und der Eintrag in "
"Rippy abgemeldet. Gerippte Dateien bleiben unberührt.",
):
deinstallieren()
wurzel.destroy()
knopf("Deinstallieren", deinstallieren_fragen, farbe=ROSE, vordergrund="white")
log_pfad = _log_pfad()
def auffrischen():
caps = hole("/capabilities")
jobs = hole("/jobs") or []
zustand = worker_zustand(caps, WORKER_NAME)
laufend = next((j for j in jobs if j.get("status") == "transcoding"), None)
text, farbe = status_text(zustand, laufend)
status.configure(text=text, fg=farbe)
info = zustand.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(zustand.get("encoders") or []),
) if t]
maschine.configure(text=" · ".join(teile))
aufgaben.delete(0, "end")
if not jobs:
aufgaben.insert("end", " keine Aufgaben in Rippy")
for job in jobs[:8]:
aufgaben.insert("end", " " + job_text(job))
neu = "\n".join(letzte_zeilen(log_pfad))
if neu != log.get("1.0", "end").strip():
am_ende = log.yview()[1] >= 0.999
log.delete("1.0", "end")
log.insert("1.0", neu or "(noch keine Zeilen — das Log füllt sich, "
"sobald der Worker etwas tut)")
if am_ende:
log.see("end")
wurzel.after(TAKT_MS, auffrischen)
auffrischen()
return wurzel
if __name__ == "__main__": # pragma: no cover
try:
fenster_bauen().mainloop()
except Exception as e:
# Ohne Konsole (pythonw) wäre ein Traceback unsichtbar — also sagen, was
# los ist, statt still zu verschwinden.
try:
import tkinter.messagebox as mb
mb.showerror("Rippy Worker", f"Verwaltung konnte nicht öffnen:\n{e}")
except Exception:
print(f"Verwaltung konnte nicht öffnen: {e}", file=sys.stderr)