9 Commits

11 changed files with 899 additions and 1832 deletions
+27 -1
View File
@@ -1,6 +1,32 @@
# 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
> 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) —
# die Zielmaschine braucht weder git noch Docker.
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
# (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
+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
sqlalchemy==2.0.32
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)