Compare commits
7 Commits
a468e4b6a4
...
ee123f2a90
| Author | SHA1 | Date | |
|---|---|---|---|
| ee123f2a90 | |||
| 0c269a97ee | |||
| 6db64230eb | |||
| 638edc6a9f | |||
| aea26493db | |||
| 8b7bdfe798 | |||
| 4195854bf8 |
@@ -11,6 +11,12 @@ in Docker, komplett lokal — keine Cloud, keine Telemetrie.
|
|||||||
|
|
||||||
Du brauchst: einen **Linux-Rechner mit Docker** und ein **optisches Laufwerk**.
|
Du brauchst: einen **Linux-Rechner mit Docker** und ein **optisches Laufwerk**.
|
||||||
|
|
||||||
|
**Welche Distribution, ist gleichgültig** — Debian, Ubuntu, Arch, Fedora,
|
||||||
|
openSUSE, Alpine. Rippy bringt alles Nötige (MakeMKV, HandBrake, abcde) in
|
||||||
|
seinen eigenen Containern mit; vom Host braucht es nur Docker und einen Kernel,
|
||||||
|
der das Laufwerk sieht. `install.sh` erkennt das Paketwerkzeug selbst und nennt
|
||||||
|
dir den passenden Befehl, falls etwas fehlt.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone <repo-url> rippy && cd rippy
|
git clone <repo-url> rippy && cd rippy
|
||||||
sudo ./install.sh
|
sudo ./install.sh
|
||||||
|
|||||||
@@ -1,17 +1,36 @@
|
|||||||
# Optionaler Remote-Transcode-Worker — für eine GPU-Maschine im Netz.
|
# Optionaler Remote-Transcode-Worker — für eine GPU-Maschine im Netz (Linux).
|
||||||
# (EXPERIMENTELL, 23.07.2026: VAAPI/NVENC-Presets folgen; aktuell nutzt der
|
# Für Windows gibt es den nativen Weg: RippyWorkerSetup.exe (Einstellungen → Worker).
|
||||||
# Worker dieselben HandBrake-CPU-Presets, bringt also v.a. stärkere CPUs.)
|
#
|
||||||
|
# ══ DAS ENTSCHEIDENDE: der Worker muss die Dateien SEHEN ══════════════════════
|
||||||
|
# Rippy schickt ihm Pfade wie /app/media/rippy/<job>/title_t00.mkv — Pfade
|
||||||
|
# INNERHALB des Rippy-Containers. Dieser Container muss dieselben Daten unter
|
||||||
|
# GENAU DEMSELBEN Pfad haben, sonst nimmt er die Aufgabe an und lehnt sie
|
||||||
|
# Millisekunden später ab („Keine Roh-MKVs gefunden" — genau so passiert am
|
||||||
|
# 26.07.2026, Job 95afdc89).
|
||||||
|
#
|
||||||
|
# Richtigstellung 26.07.2026: Hier stand vorher „mount -t nfs
|
||||||
|
# <rippy-host>:/srv/rippy /mnt/rippy". Das geht NICHT — auf der Rippy-Maschine
|
||||||
|
# läuft kein NFS- und kein Samba-Server, sie ist selbst nur Client der NAS.
|
||||||
|
# Der Weg, der funktioniert: DIESELBE Freigabe einhängen, die auch Rippy nutzt.
|
||||||
#
|
#
|
||||||
# Auf der GPU-Maschine:
|
# Auf der GPU-Maschine:
|
||||||
# 1. Dieses Repo klonen (oder nur dieses File + Zugriff aufs Registry-Image)
|
# 1. Dieses Repo klonen (oder nur dieses File + Zugriff aufs Registry-Image)
|
||||||
# 2. Rohdaten-Freigabe der Rippy-Maschine mounten, z. B.:
|
# 2. Die Freigabe einhängen, die Rippy als Arbeitsverzeichnis UND Ablage
|
||||||
# mount -t nfs <rippy-host>:/srv/rippy /mnt/rippy
|
# benutzt (Einstellungen → Ripping). Heißt sie in Rippy „rippy", liegt
|
||||||
# (die Rippy-VM muss /srv/rippy + das temp-Volume exportieren)
|
# sie dort unter /app/media/rippy — also z. B.:
|
||||||
# 3. RIPPY_HOST unten setzen und starten:
|
# mount -t cifs //NAS/rippy /mnt/rippy-freigabe -o guest,iocharset=utf8
|
||||||
# docker compose -f deploy/remote-transcode-worker.yml up -d --build
|
# 3. Unten RIPPY_HOST setzen, den Freigabe-Pfad im volumes-Block anpassen
|
||||||
|
# und starten:
|
||||||
|
# RIPPY_HOST=192.168.178.162 docker compose \
|
||||||
|
# -f deploy/remote-transcode-worker.yml up -d --build
|
||||||
|
#
|
||||||
|
# Passt der Pfad im Container nicht (weil die Freigabe woanders hängt), gibt es
|
||||||
|
# als Ausweg RIPPY_PATH_MAP — dieselbe Übersetzung, die der Windows-Worker
|
||||||
|
# nutzt, Format: /app/media/rippy=/mnt/anderer-pfad (Paare per „;").
|
||||||
|
# Den fertigen Wert nennt GET /worker-setup/pfad-map.
|
||||||
#
|
#
|
||||||
# Der Worker meldet seine Encoder-Fähigkeiten automatisch — er taucht danach
|
# Der Worker meldet seine Encoder-Fähigkeiten automatisch — er taucht danach
|
||||||
# unter Einstellungen → Verarbeitung auf. Er bedient NUR die transcode-Queue;
|
# unter Einstellungen → Worker auf. Er bedient NUR die transcode-Queue;
|
||||||
# gerippt wird weiterhin dort, wo das Laufwerk hängt.
|
# gerippt wird weiterhin dort, wo das Laufwerk hängt.
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -30,10 +49,15 @@ services:
|
|||||||
- WORKER_NAME=${WORKER_NAME:-transcode-worker}
|
- WORKER_NAME=${WORKER_NAME:-transcode-worker}
|
||||||
# Serien-Episoden-Matching fragt die Rippy-API nach Laufzeiten
|
# Serien-Episoden-Matching fragt die Rippy-API nach Laufzeiten
|
||||||
- API_URL=http://${RIPPY_HOST}:8000
|
- API_URL=http://${RIPPY_HOST}:8000
|
||||||
|
# Nur nötig, wenn die Freigabe hier NICHT unter demselben Pfad liegt wie
|
||||||
|
# in Rippy. Fertigen Wert holen: curl http://${RIPPY_HOST}/api/worker-setup/pfad-map
|
||||||
|
- RIPPY_PATH_MAP=${RIPPY_PATH_MAP:-}
|
||||||
volumes:
|
volumes:
|
||||||
# Freigabe der Rippy-Maschine (siehe Kopf-Kommentar)
|
# LINKS der Pfad auf DIESER Maschine, RECHTS der Pfad, den Rippy nennt.
|
||||||
- /mnt/rippy/media:/app/media
|
# Rechts muss stehen, was in Rippy als Speicherziel heißt: ein Ziel
|
||||||
- /mnt/rippy/temp:/app/temp
|
# namens „rippy" ist dort /app/media/rippy. Prüfen mit
|
||||||
|
# curl http://<rippy-host>/api/worker-setup/pfad-map
|
||||||
|
- /mnt/rippy-freigabe:/app/media/rippy
|
||||||
devices:
|
devices:
|
||||||
# GPU für Hardware-Encoding (AMD/Intel: /dev/dri; NVIDIA: nvidia-runtime)
|
# GPU für Hardware-Encoding (AMD/Intel: /dev/dri; NVIDIA: nvidia-runtime)
|
||||||
- /dev/dri:/dev/dri
|
- /dev/dri:/dev/dri
|
||||||
|
|||||||
Binary file not shown.
@@ -47,7 +47,7 @@ $cPurple = [System.Drawing.Color]::FromArgb(126, 34, 206)
|
|||||||
# --- Fenster ---------------------------------------------------------------
|
# --- Fenster ---------------------------------------------------------------
|
||||||
$form = New-Object System.Windows.Forms.Form
|
$form = New-Object System.Windows.Forms.Form
|
||||||
$form.Text = "Rippy Encoding-Worker - Installation"
|
$form.Text = "Rippy Encoding-Worker - Installation"
|
||||||
$form.ClientSize = New-Object System.Drawing.Size(560, 560)
|
$form.ClientSize = New-Object System.Drawing.Size(560, 648)
|
||||||
$form.StartPosition = "CenterScreen"
|
$form.StartPosition = "CenterScreen"
|
||||||
$form.FormBorderStyle = "FixedDialog"
|
$form.FormBorderStyle = "FixedDialog"
|
||||||
$form.MaximizeBox = $false
|
$form.MaximizeBox = $false
|
||||||
@@ -152,7 +152,7 @@ $form.Controls.Add($lblPfad)
|
|||||||
|
|
||||||
$txtPfad = New-Object System.Windows.Forms.TextBox
|
$txtPfad = New-Object System.Windows.Forms.TextBox
|
||||||
$txtPfad.Location = New-Object System.Drawing.Point(24, 252)
|
$txtPfad.Location = New-Object System.Drawing.Point(24, 252)
|
||||||
$txtPfad.Size = New-Object System.Drawing.Size(390, 26)
|
$txtPfad.Size = New-Object System.Drawing.Size(374, 26)
|
||||||
$txtPfad.Text = $StandardZiel
|
$txtPfad.Text = $StandardZiel
|
||||||
$txtPfad.Font = New-Object System.Drawing.Font("Consolas", 9)
|
$txtPfad.Font = New-Object System.Drawing.Font("Consolas", 9)
|
||||||
$txtPfad.BackColor = $cFeld
|
$txtPfad.BackColor = $cFeld
|
||||||
@@ -162,8 +162,8 @@ $form.Controls.Add($txtPfad)
|
|||||||
|
|
||||||
$btnPfad = New-Object System.Windows.Forms.Button
|
$btnPfad = New-Object System.Windows.Forms.Button
|
||||||
$btnPfad.Text = "Ändern ..."
|
$btnPfad.Text = "Ändern ..."
|
||||||
$btnPfad.Location = New-Object System.Drawing.Point(424, 251)
|
$btnPfad.Location = New-Object System.Drawing.Point(408, 251)
|
||||||
$btnPfad.Size = New-Object System.Drawing.Size(100, 28)
|
$btnPfad.Size = New-Object System.Drawing.Size(116, 28)
|
||||||
$btnPfad.FlatStyle = "Flat"
|
$btnPfad.FlatStyle = "Flat"
|
||||||
$btnPfad.BackColor = $cFeld
|
$btnPfad.BackColor = $cFeld
|
||||||
$btnPfad.ForeColor = $cText
|
$btnPfad.ForeColor = $cText
|
||||||
@@ -180,10 +180,51 @@ $btnPfad.Add_Click({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# 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
|
# Autostart
|
||||||
$chkAuto = New-Object System.Windows.Forms.CheckBox
|
$chkAuto = New-Object System.Windows.Forms.CheckBox
|
||||||
$chkAuto.Text = "Beim Anmelden automatisch starten (Tray-Symbol)"
|
$chkAuto.Text = "Beim Anmelden automatisch starten (Tray-Symbol)"
|
||||||
$chkAuto.Location = New-Object System.Drawing.Point(24, 290)
|
$chkAuto.Location = New-Object System.Drawing.Point(24, 364)
|
||||||
$chkAuto.Size = New-Object System.Drawing.Size(400, 22)
|
$chkAuto.Size = New-Object System.Drawing.Size(400, 22)
|
||||||
$chkAuto.Checked = $true
|
$chkAuto.Checked = $true
|
||||||
$chkAuto.ForeColor = $cText
|
$chkAuto.ForeColor = $cText
|
||||||
@@ -191,8 +232,8 @@ $form.Controls.Add($chkAuto)
|
|||||||
|
|
||||||
# Log-Bereich
|
# Log-Bereich
|
||||||
$log = New-Object System.Windows.Forms.TextBox
|
$log = New-Object System.Windows.Forms.TextBox
|
||||||
$log.Location = New-Object System.Drawing.Point(24, 322)
|
$log.Location = New-Object System.Drawing.Point(24, 396)
|
||||||
$log.Size = New-Object System.Drawing.Size(500, 150)
|
$log.Size = New-Object System.Drawing.Size(500, 174)
|
||||||
$log.Multiline = $true
|
$log.Multiline = $true
|
||||||
$log.ReadOnly = $true
|
$log.ReadOnly = $true
|
||||||
$log.ScrollBars = "Vertical"
|
$log.ScrollBars = "Vertical"
|
||||||
@@ -205,7 +246,7 @@ $form.Controls.Add($log)
|
|||||||
# Knöpfe
|
# Knöpfe
|
||||||
$btnInstall = New-Object System.Windows.Forms.Button
|
$btnInstall = New-Object System.Windows.Forms.Button
|
||||||
$btnInstall.Text = "Installieren"
|
$btnInstall.Text = "Installieren"
|
||||||
$btnInstall.Location = New-Object System.Drawing.Point(24, 486)
|
$btnInstall.Location = New-Object System.Drawing.Point(24, 584)
|
||||||
$btnInstall.Size = New-Object System.Drawing.Size(150, 40)
|
$btnInstall.Size = New-Object System.Drawing.Size(150, 40)
|
||||||
$btnInstall.BackColor = $cAmber
|
$btnInstall.BackColor = $cAmber
|
||||||
$btnInstall.ForeColor = $cBg
|
$btnInstall.ForeColor = $cBg
|
||||||
@@ -215,7 +256,7 @@ $form.Controls.Add($btnInstall)
|
|||||||
|
|
||||||
$btnStart = New-Object System.Windows.Forms.Button
|
$btnStart = New-Object System.Windows.Forms.Button
|
||||||
$btnStart.Text = "Worker starten"
|
$btnStart.Text = "Worker starten"
|
||||||
$btnStart.Location = New-Object System.Drawing.Point(190, 486)
|
$btnStart.Location = New-Object System.Drawing.Point(190, 584)
|
||||||
$btnStart.Size = New-Object System.Drawing.Size(150, 40)
|
$btnStart.Size = New-Object System.Drawing.Size(150, 40)
|
||||||
$btnStart.BackColor = $cEmerald
|
$btnStart.BackColor = $cEmerald
|
||||||
$btnStart.ForeColor = [System.Drawing.Color]::White
|
$btnStart.ForeColor = [System.Drawing.Color]::White
|
||||||
@@ -229,6 +270,46 @@ function Log($text) {
|
|||||||
[System.Windows.Forms.Application]::DoEvents()
|
[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 ---------------------------------------------------
|
# --- Installations-Logik ---------------------------------------------------
|
||||||
function Do-Install {
|
function Do-Install {
|
||||||
$rHost = $txtHost.Text.Trim()
|
$rHost = $txtHost.Text.Trim()
|
||||||
@@ -322,11 +403,40 @@ function Do-Install {
|
|||||||
}
|
}
|
||||||
Log "HandBrake bereit."
|
Log "HandBrake bereit."
|
||||||
|
|
||||||
# 5. Start-Skripte + Deinstaller erzeugen
|
# 5. Netzwerk-Freigabe klaeren, BEVOR die Start-Skripte geschrieben werden.
|
||||||
$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 PATH=%~dp0;%PATH%`r`nstart `"`" venv\Scripts\pythonw.exe tray.py"
|
# 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 { "" }
|
||||||
|
|
||||||
|
$trayBat = "@echo off`r`ncd /d `"%~dp0`"`r`nset REDIS_URL=redis://${rHost}:6379/0`r`nset DATABASE_URL=postgresql://rippy:rippy@${rHost}:5432/rippy`r`nset API_URL=http://${rHost}:8000`r`nset WORKER_NAME=$wName`r`nset RIPPY_TRAY_HOST=$rHost`r`n${mapZeile}set PATH=%~dp0;%PATH%`r`nstart `"`" venv\Scripts\pythonw.exe tray.py"
|
||||||
Set-Content -Path "start-tray.bat" -Value $trayBat -Encoding ASCII
|
Set-Content -Path "start-tray.bat" -Value $trayBat -Encoding ASCII
|
||||||
|
|
||||||
$workBat = "@echo off`r`ncd /d `"%~dp0`"`r`nset REDIS_URL=redis://${rHost}:6379/0`r`nset DATABASE_URL=postgresql://rippy:rippy@${rHost}:5432/rippy`r`nset API_URL=http://${rHost}:8000`r`nset WORKER_NAME=$wName`r`nset PATH=%~dp0;%PATH%`r`nvenv\Scripts\celery.exe -A celery_app worker --loglevel=info -Q transcode --pool=solo -n ${wName}@%%h"
|
$workBat = "@echo off`r`ncd /d `"%~dp0`"`r`nset REDIS_URL=redis://${rHost}:6379/0`r`nset DATABASE_URL=postgresql://rippy:rippy@${rHost}:5432/rippy`r`nset API_URL=http://${rHost}:8000`r`nset WORKER_NAME=$wName`r`n${mapZeile}set PATH=%~dp0;%PATH%`r`nvenv\Scripts\celery.exe -A celery_app worker --loglevel=info -Q transcode --pool=solo -n ${wName}@%%h"
|
||||||
Set-Content -Path "start-worker.bat" -Value $workBat -Encoding ASCII
|
Set-Content -Path "start-worker.bat" -Value $workBat -Encoding ASCII
|
||||||
|
|
||||||
$uninstall = @"
|
$uninstall = @"
|
||||||
@@ -349,7 +459,7 @@ Remove-Item -Recurse -Force `$PSScriptRoot
|
|||||||
"@
|
"@
|
||||||
Set-Content -Path "uninstall.ps1" -Value $uninstall -Encoding ASCII
|
Set-Content -Path "uninstall.ps1" -Value $uninstall -Encoding ASCII
|
||||||
|
|
||||||
# 6. Autostart über den Autostart-ORDNER, nicht über schtasks.
|
# 7. Autostart über den Autostart-ORDNER, nicht über schtasks.
|
||||||
#
|
#
|
||||||
# Befund 25.07.2026 (auf dem Commander-PC aufgetreten): schtasks /create
|
# Befund 25.07.2026 (auf dem Commander-PC aufgetreten): schtasks /create
|
||||||
# mit /tn "RippyWorker" legt die Aufgabe im WURZELORDNER der
|
# mit /tn "RippyWorker" legt die Aufgabe im WURZELORDNER der
|
||||||
@@ -416,6 +526,20 @@ Remove-Item -Recurse -Force `$PSScriptRoot
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$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 })
|
$btnInstall.Add_Click({ Do-Install })
|
||||||
$btnStart.Add_Click({
|
$btnStart.Add_Click({
|
||||||
Start-Process -FilePath (Join-Path $InstallDir "start-tray.bat") -WindowStyle Hidden
|
Start-Process -FilePath (Join-Path $InstallDir "start-tray.bat") -WindowStyle Hidden
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
# 2. Lädt den Worker-Code direkt von deiner Rippy-Instanz (/api/worker-setup/paket)
|
# 2. Lädt den Worker-Code direkt von deiner Rippy-Instanz (/api/worker-setup/paket)
|
||||||
# 3. Lädt HandBrakeCLI (gepinnte Version, offizielles GitHub-Release),
|
# 3. Lädt HandBrakeCLI (gepinnte Version, offizielles GitHub-Release),
|
||||||
# falls nicht schon im PATH oder im Ordner vorhanden
|
# falls nicht schon im PATH oder im Ordner vorhanden
|
||||||
# 4. Erzeugt start-worker.bat - Doppelklick startet den Worker
|
# 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
|
# Der Worker bedient NUR die Kompressions-Queue (transcode) - gerippt wird
|
||||||
# weiterhin auf der Rippy-Hauptmaschine (dort hängt das Laufwerk).
|
# weiterhin auf der Rippy-Hauptmaschine (dort hängt das Laufwerk).
|
||||||
@@ -13,12 +15,18 @@
|
|||||||
# Aufruf (PowerShell):
|
# Aufruf (PowerShell):
|
||||||
# .\install.ps1 -RippyHost 192.168.178.162 [-WorkerName mein-pc] [-Autostart]
|
# .\install.ps1 -RippyHost 192.168.178.162 [-WorkerName mein-pc] [-Autostart]
|
||||||
#
|
#
|
||||||
# -Autostart registriert eine Aufgabe "RippyWorker" (Start bei Anmeldung).
|
# -Autostart legt eine Verknüpfung im Autostart-Ordner an (Start bei Anmeldung).
|
||||||
|
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory = $true)] [string]$RippyHost,
|
[Parameter(Mandatory = $true)] [string]$RippyHost,
|
||||||
[string]$WorkerName = $env:COMPUTERNAME.ToLower(),
|
[string]$WorkerName = $env:COMPUTERNAME.ToLower(),
|
||||||
[switch]$Autostart,
|
[switch]$Autostart,
|
||||||
|
# Ü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 = "",
|
||||||
# Zielverzeichnis. Standard ist "Programme", wie bei jedem anderen Programm
|
# Zielverzeichnis. Standard ist "Programme", wie bei jedem anderen Programm
|
||||||
# (Commander-Wunsch 25.07.2026). Dorthin schreiben braucht Adminrechte -
|
# (Commander-Wunsch 25.07.2026). Dorthin schreiben braucht Adminrechte -
|
||||||
# PowerShell also "Als Administrator" starten, oder hier einen Ordner im
|
# PowerShell also "Als Administrator" starten, oder hier einen Ordner im
|
||||||
@@ -108,7 +116,47 @@ if (-not (Test-Path ".\HandBrakeCLI.exe") -and -not $hb) {
|
|||||||
}
|
}
|
||||||
Write-Host "HandBrakeCLI bereit."
|
Write-Host "HandBrakeCLI bereit."
|
||||||
|
|
||||||
# 5. Start-Skript erzeugen
|
# 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 { "" }
|
||||||
|
|
||||||
|
# 6. Start-Skript erzeugen
|
||||||
$bat = @"
|
$bat = @"
|
||||||
@echo off
|
@echo off
|
||||||
rem Rippy Windows-Transcode-Worker - verbindet sich mit $RippyHost
|
rem Rippy Windows-Transcode-Worker - verbindet sich mit $RippyHost
|
||||||
@@ -117,12 +165,12 @@ set REDIS_URL=redis://${RippyHost}:6379/0
|
|||||||
set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy
|
set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy
|
||||||
set API_URL=http://${RippyHost}:8000
|
set API_URL=http://${RippyHost}:8000
|
||||||
set WORKER_NAME=$WorkerName
|
set WORKER_NAME=$WorkerName
|
||||||
set PATH=%~dp0;%PATH%
|
${mapZeile}set PATH=%~dp0;%PATH%
|
||||||
venv\Scripts\celery.exe -A celery_app worker --loglevel=info -Q transcode --pool=solo -n ${WorkerName}@%%h
|
venv\Scripts\celery.exe -A celery_app worker --loglevel=info -Q transcode --pool=solo -n ${WorkerName}@%%h
|
||||||
"@
|
"@
|
||||||
Set-Content -Path "start-worker.bat" -Value $bat -Encoding ASCII
|
Set-Content -Path "start-worker.bat" -Value $bat -Encoding ASCII
|
||||||
|
|
||||||
# 6. Tray-Start (empfohlen): Symbol neben der Uhr, kein Konsolenfenster
|
# 7. Tray-Start (empfohlen): Symbol neben der Uhr, kein Konsolenfenster
|
||||||
$trayBat = @"
|
$trayBat = @"
|
||||||
@echo off
|
@echo off
|
||||||
rem Rippy-Worker mit Tray-Symbol - verbindet sich mit $RippyHost
|
rem Rippy-Worker mit Tray-Symbol - verbindet sich mit $RippyHost
|
||||||
@@ -132,13 +180,13 @@ set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy
|
|||||||
set API_URL=http://${RippyHost}:8000
|
set API_URL=http://${RippyHost}:8000
|
||||||
set WORKER_NAME=$WorkerName
|
set WORKER_NAME=$WorkerName
|
||||||
set RIPPY_TRAY_HOST=$RippyHost
|
set RIPPY_TRAY_HOST=$RippyHost
|
||||||
set PATH=%~dp0;%PATH%
|
${mapZeile}set PATH=%~dp0;%PATH%
|
||||||
start "" venv\Scripts\pythonw.exe tray.py
|
start "" venv\Scripts\pythonw.exe tray.py
|
||||||
"@
|
"@
|
||||||
Set-Content -Path "start-tray.bat" -Value $trayBat -Encoding ASCII
|
Set-Content -Path "start-tray.bat" -Value $trayBat -Encoding ASCII
|
||||||
Write-Host "start-worker.bat + start-tray.bat erzeugt."
|
Write-Host "start-worker.bat + start-tray.bat erzeugt."
|
||||||
|
|
||||||
# 7. Deinstaller - MUSS-Kriterium: rückstandsfrei entfernbar
|
# 8. Deinstaller - MUSS-Kriterium: rückstandsfrei entfernbar
|
||||||
$uninstall = @"
|
$uninstall = @"
|
||||||
# Rippy-Worker DEINSTALLIEREN: stoppt Prozesse, entfernt Autostart,
|
# Rippy-Worker DEINSTALLIEREN: stoppt Prozesse, entfernt Autostart,
|
||||||
# meldet den Worker in Rippy ab und löscht diesen Ordner komplett.
|
# meldet den Worker in Rippy ab und löscht diesen Ordner komplett.
|
||||||
@@ -169,7 +217,7 @@ Write-Host "Rippy-Worker deinstalliert." -ForegroundColor Green
|
|||||||
Set-Content -Path "uninstall.ps1" -Value $uninstall -Encoding ASCII
|
Set-Content -Path "uninstall.ps1" -Value $uninstall -Encoding ASCII
|
||||||
Write-Host "uninstall.ps1 erzeugt."
|
Write-Host "uninstall.ps1 erzeugt."
|
||||||
|
|
||||||
# 8. Optional: Autostart bei Anmeldung (mit Tray)
|
# 9. Optional: Autostart bei Anmeldung (mit Tray)
|
||||||
#
|
#
|
||||||
# Über den Autostart-ORDNER, nicht über schtasks: Eine Aufgabe im
|
# Über den Autostart-ORDNER, nicht über schtasks: Eine Aufgabe im
|
||||||
# Wurzelordner der Aufgabenplanung anzulegen verlangt Administratorrechte, und
|
# Wurzelordner der Aufgabenplanung anzulegen verlangt Administratorrechte, und
|
||||||
|
|||||||
@@ -28,7 +28,19 @@ def titel_aehnlichkeit(a: str, b: str) -> float:
|
|||||||
|
|
||||||
|
|
||||||
class JikanClient:
|
class JikanClient:
|
||||||
|
# Unter dieser Ähnlichkeit gibt lookup() gar nichts zurück.
|
||||||
MINDEST_AEHNLICHKEIT = 0.55
|
MINDEST_AEHNLICHKEIT = 0.55
|
||||||
|
# Ab dieser Ähnlichkeit gilt der Treffer als SICHER und beendet die
|
||||||
|
# Metadaten-Kette. Dazwischen ist er nur ein Vorschlag: erst wird OMDb
|
||||||
|
# gefragt, und nur wenn das nichts hat, kommt er zum Zug.
|
||||||
|
#
|
||||||
|
# Warum zwei Schwellen (26.07.2026, ausgerechnet mit titel_aehnlichkeit):
|
||||||
|
# Eine einzelne Zahl kann beide Aufgaben nicht leisten. 0,55 lässt kurze
|
||||||
|
# Kinofilm-Titel durch, die hier nichts zu suchen haben („Alien" gegen
|
||||||
|
# „Alien 9" = 0,83), ist aber gleichzeitig zu streng für den Fall, für den
|
||||||
|
# diese Quelle gebaut wurde („Evangelion 2.22" gegen „Evangelion: 2.0 You
|
||||||
|
# Can (Not) Advance" = 0,51). Messwerte in test_jikan_helpers.py.
|
||||||
|
SICHER_AEHNLICHKEIT = 0.9
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""Restzeit-Schätzung für laufende Jobs — aus dem gemessenen Fortschritt.
|
||||||
|
|
||||||
|
Commander-Anforderung 26.07.2026: *„Der Server Status muss dringend überarbeitet
|
||||||
|
werden, das Dashboard soll ja quasi alles auf einen Blick zeigen"* — dazu eine
|
||||||
|
**ETA** im Dashboard und beim externen Worker.
|
||||||
|
|
||||||
|
## Warum hier und nicht im Browser
|
||||||
|
|
||||||
|
Der naheliegende Weg wäre, den Fortschritt im UI mitzuschreiben. Drei Gründe
|
||||||
|
dagegen: Ein Seitenwechsel setzt die Messreihe zurück; zwei offene Browser
|
||||||
|
zeigten verschiedene Zahlen; und die Kompression läuft womöglich auf einer
|
||||||
|
ANDEREN Maschine (genau dort wollte der Commander die ETA sehen). Die Reihe
|
||||||
|
gehört also dorthin, wo der Fortschritt ankommt.
|
||||||
|
|
||||||
|
## Warum nicht einfach „vergangene Zeit / Prozent"
|
||||||
|
|
||||||
|
Weil ein Job zwei völlig verschiedene Phasen hat: Der Rip dauert rund eine
|
||||||
|
Stunde, die Kompression Stunden bis Tage (auf der Rippy-VM gemessene 28-55 h je
|
||||||
|
4K-Film). Aus dem Gesamt-Mittel entstünde bei jedem Phasenwechsel eine
|
||||||
|
haarsträubende Zahl. Deshalb wird die Messreihe bei JEDEM Statuswechsel
|
||||||
|
verworfen und die Rate nur innerhalb der laufenden Phase bestimmt.
|
||||||
|
|
||||||
|
## Ehrlichkeit vor Zahl
|
||||||
|
|
||||||
|
Lieber „wird noch geschätzt" als eine erfundene Minute:
|
||||||
|
|
||||||
|
- Unter MINDEST_PUNKTE Messwerten gibt es keine Schätzung.
|
||||||
|
- Die Reihe muss MINDEST_SPANNE_SEKUNDEN abdecken UND MINDEST_FORTSCHRITT
|
||||||
|
Prozentpunkte gestiegen sein — bei 4K bewegt sich eine halbe Stunde lang
|
||||||
|
nichts, daraus ließe sich sonst „fertig in 3 Minuten" ableiten.
|
||||||
|
- Nur die letzten FENSTER Punkte zählen: HandBrake wird bei komplexen Szenen
|
||||||
|
langsamer, die frühen Werte lügen dann.
|
||||||
|
- Über OBERGRENZE_SEKUNDEN wird nicht mehr aufs Detail gerechnet, sondern
|
||||||
|
„mehr als 2 Tage" gesagt. Eine Zahl wie „51:23 h" wirkt genau, ist es aber
|
||||||
|
nicht.
|
||||||
|
"""
|
||||||
|
|
||||||
|
MINDEST_PUNKTE = 2
|
||||||
|
MINDEST_SPANNE_SEKUNDEN = 60
|
||||||
|
MINDEST_FORTSCHRITT = 1
|
||||||
|
FENSTER = 10
|
||||||
|
OBERGRENZE_SEKUNDEN = 48 * 3600
|
||||||
|
|
||||||
|
# Status, für die eine Restzeit überhaupt Sinn hat.
|
||||||
|
LAUFENDE_STATUS = ("running", "processing", "ripping", "transcoding")
|
||||||
|
|
||||||
|
|
||||||
|
def beobachtung_hinzufuegen(reihe, status: str, progress: int, jetzt: float) -> dict:
|
||||||
|
"""Neuen Messpunkt anfügen (pure Funktion) → die aktualisierte Reihe.
|
||||||
|
|
||||||
|
`reihe` ist {"status": str, "punkte": [[zeit, prozent], ...]} oder None.
|
||||||
|
Bei Statuswechsel beginnt die Reihe neu — siehe Modul-Doku (Rip und
|
||||||
|
Kompression haben nichts miteinander zu tun).
|
||||||
|
|
||||||
|
Ein unveränderter Fortschritt wird NICHT als neuer Punkt angefügt, aber der
|
||||||
|
letzte Punkt behält seine ursprüngliche Zeit. Das ist wichtig: Ein Stillstand
|
||||||
|
verlängert damit automatisch die gemessene Spanne und macht die Schätzung
|
||||||
|
langsamer — genau richtig, denn er heißt ja, dass es langsam vorangeht.
|
||||||
|
"""
|
||||||
|
alt = reihe or {}
|
||||||
|
punkte = list(alt.get("punkte") or []) if alt.get("status") == status else []
|
||||||
|
if not punkte or punkte[-1][1] != progress:
|
||||||
|
punkte.append([jetzt, progress])
|
||||||
|
return {"status": status, "punkte": punkte[-FENSTER:]}
|
||||||
|
|
||||||
|
|
||||||
|
def restzeit_sekunden(reihe, jetzt: float) -> int:
|
||||||
|
"""Geschätzte Restzeit in Sekunden — oder -1 für „noch keine Aussage".
|
||||||
|
|
||||||
|
-1 statt None, damit der Wert unverändert durch JSON und das
|
||||||
|
Antwort-Modell passt (dasselbe Muster wie get_progress_from_line im
|
||||||
|
Worker, wo -1 „keine Angabe" heißt).
|
||||||
|
"""
|
||||||
|
punkte = (reihe or {}).get("punkte") or []
|
||||||
|
if len(punkte) < MINDEST_PUNKTE:
|
||||||
|
return -1
|
||||||
|
erste_zeit, erster_prozent = punkte[0]
|
||||||
|
letzte_zeit, letzter_prozent = punkte[-1]
|
||||||
|
# Die Spanne bis JETZT, nicht bis zum letzten Punkt: sonst zeigt ein
|
||||||
|
# stehender Job dauerhaft die Rate von vor drei Stunden.
|
||||||
|
spanne = max(jetzt, letzte_zeit) - erste_zeit
|
||||||
|
gewachsen = letzter_prozent - erster_prozent
|
||||||
|
if spanne < MINDEST_SPANNE_SEKUNDEN or gewachsen < MINDEST_FORTSCHRITT:
|
||||||
|
return -1
|
||||||
|
rest_prozent = 100 - letzter_prozent
|
||||||
|
if rest_prozent <= 0:
|
||||||
|
return 0
|
||||||
|
pro_prozent = spanne / gewachsen
|
||||||
|
return int(rest_prozent * pro_prozent)
|
||||||
|
|
||||||
|
|
||||||
|
def formatiere_restzeit(sekunden: int) -> str:
|
||||||
|
"""Restzeit als Text fürs UI. Leer, wenn es keine Aussage gibt."""
|
||||||
|
if sekunden is None or sekunden < 0:
|
||||||
|
return ""
|
||||||
|
if sekunden > OBERGRENZE_SEKUNDEN:
|
||||||
|
return "mehr als 2 Tage"
|
||||||
|
if sekunden < 60:
|
||||||
|
return "unter einer Minute"
|
||||||
|
minuten = sekunden // 60
|
||||||
|
if minuten < 60:
|
||||||
|
return f"noch ca. {minuten} min"
|
||||||
|
stunden, rest_minuten = divmod(minuten, 60)
|
||||||
|
if stunden < 24:
|
||||||
|
return f"noch ca. {stunden} h {rest_minuten:02d} min"
|
||||||
|
tage, rest_stunden = divmod(stunden, 24)
|
||||||
|
return f"noch ca. {tage} Tag{'e' if tage > 1 else ''} {rest_stunden} h"
|
||||||
|
|
||||||
|
|
||||||
|
def schluessel(job_id: str) -> str:
|
||||||
|
return f"eta:{job_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def aktualisiere_und_schaetze(job_id: str, status: str, progress: int,
|
||||||
|
jetzt: float, cache_get, cache_set) -> dict:
|
||||||
|
"""Messreihe im Cache fortschreiben und die Restzeit zurückgeben.
|
||||||
|
|
||||||
|
Der Cache (Redis) ist schon da und überlebt einen Neustart des UI. Fällt er
|
||||||
|
aus, kommt bei jedem Aufruf eine leere Reihe zurück — dann gibt es eben
|
||||||
|
keine ETA, aber nichts scheitert.
|
||||||
|
"""
|
||||||
|
if status not in LAUFENDE_STATUS or progress <= 0:
|
||||||
|
return {"sekunden": -1, "text": ""}
|
||||||
|
k = schluessel(job_id)
|
||||||
|
try:
|
||||||
|
reihe = cache_get(k)
|
||||||
|
except Exception:
|
||||||
|
reihe = None
|
||||||
|
reihe = beobachtung_hinzufuegen(reihe, status, progress, jetzt)
|
||||||
|
try:
|
||||||
|
# Eine Reihe ohne Fortschritt ist nach einem Tag wertlos.
|
||||||
|
cache_set(k, reihe, expire=86400)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
sekunden = restzeit_sekunden(reihe, jetzt)
|
||||||
|
return {"sekunden": sekunden, "text": formatiere_restzeit(sekunden)}
|
||||||
+164
-6
@@ -12,15 +12,17 @@ import uuid
|
|||||||
|
|
||||||
import db
|
import db
|
||||||
import devices as device_discovery
|
import devices as device_discovery
|
||||||
|
import eta
|
||||||
import makemkv_daten
|
import makemkv_daten
|
||||||
import makemkv_key
|
import makemkv_key
|
||||||
import mounts as mount_verwaltung
|
import mounts as mount_verwaltung
|
||||||
import notify
|
import notify
|
||||||
|
import presets as preset_auswahl
|
||||||
from celery_client import celery_client, start_rip
|
from celery_client import celery_client, start_rip
|
||||||
from detection import CDS_DISC_OK, CDS_NO_DISC, CDS_TRAY_OPEN, drive_status
|
from detection import CDS_DISC_OK, CDS_NO_DISC, CDS_TRAY_OPEN, drive_status
|
||||||
|
|
||||||
from config_validation import validate_config, ConfigValidationError
|
from config_validation import validate_config, ConfigValidationError
|
||||||
from cache import init_cache
|
from cache import get as cache_get, init_cache, set as cache_set
|
||||||
from ratelimit import check_rate_limit, get_rate_limit_remaining
|
from ratelimit import check_rate_limit, get_rate_limit_remaining
|
||||||
from prescan import PreScan
|
from prescan import PreScan
|
||||||
|
|
||||||
@@ -253,6 +255,10 @@ class Job(BaseModel):
|
|||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
can_retry: bool = False # Rohdaten vorhanden → „Neu komprimieren" sinnvoll
|
can_retry: bool = False # Rohdaten vorhanden → „Neu komprimieren" sinnvoll
|
||||||
meta: Optional[Dict] = None # Disc-Metadaten (Poster/Jahr/Plot) — fürs Thumbnail in der Jobliste + aktivem Rip-Header
|
meta: Optional[Dict] = None # Disc-Metadaten (Poster/Jahr/Plot) — fürs Thumbnail in der Jobliste + aktivem Rip-Header
|
||||||
|
# Restzeit-Schätzung (siehe eta.py). -1/"" heißt „noch keine Aussage" —
|
||||||
|
# bewusst ehrlich statt einer erfundenen Minutenzahl.
|
||||||
|
eta_sekunden: int = -1
|
||||||
|
eta_text: str = ""
|
||||||
|
|
||||||
class Device(BaseModel):
|
class Device(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
@@ -314,30 +320,119 @@ def _kann_neu_komprimieren(job: dict, work_dir: str) -> bool:
|
|||||||
|
|
||||||
@app.get("/jobs", response_model=List[Job])
|
@app.get("/jobs", response_model=List[Job])
|
||||||
async def get_jobs():
|
async def get_jobs():
|
||||||
"""Holt alle Jobs aus der Datenbank (neueste zuerst)."""
|
"""Holt alle Jobs aus der Datenbank (neueste zuerst) — inkl. Restzeit.
|
||||||
|
|
||||||
|
Die ETA entsteht HIER, weil hier der Fortschritt vorbeikommt: jeder Aufruf
|
||||||
|
schreibt die Messreihe im Cache fort (eta.py). Im Browser zu rechnen wäre
|
||||||
|
einfacher gewesen und dreifach schlechter — ein Seitenwechsel setzte die
|
||||||
|
Reihe zurück, zwei Tabs zeigten verschiedene Zahlen, und für einen externen
|
||||||
|
Encoder-Worker gäbe es gar keine (genau dort wollte der Commander sie).
|
||||||
|
"""
|
||||||
def sammle():
|
def sammle():
|
||||||
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
|
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
|
||||||
|
jetzt = time.monotonic()
|
||||||
modelle = []
|
modelle = []
|
||||||
for z in db.list_jobs():
|
for z in db.list_jobs():
|
||||||
modell = _job_row_to_model(z)
|
modell = _job_row_to_model(z)
|
||||||
modell.can_retry = _kann_neu_komprimieren(z, work_dir)
|
modell.can_retry = _kann_neu_komprimieren(z, work_dir)
|
||||||
|
schaetzung = eta.aktualisiere_und_schaetze(
|
||||||
|
z["id"], z.get("status") or "", z.get("progress") or 0,
|
||||||
|
jetzt, cache_get, cache_set,
|
||||||
|
)
|
||||||
|
modell.eta_sekunden = schaetzung["sekunden"]
|
||||||
|
modell.eta_text = schaetzung["text"]
|
||||||
modelle.append(modell)
|
modelle.append(modell)
|
||||||
return modelle
|
return modelle
|
||||||
|
|
||||||
return await asyncio.to_thread(sammle)
|
return await asyncio.to_thread(sammle)
|
||||||
|
|
||||||
|
|
||||||
|
def _rohdaten_verzeichnisse(job_id: str, work_dir: str) -> list:
|
||||||
|
"""Wo könnten Roh-MKVs dieses Jobs liegen? (beide möglichen Orte)"""
|
||||||
|
kandidaten = [os.path.join("/app/temp/raw", job_id)]
|
||||||
|
if unter_wurzel(work_dir, MEDIA_ROOT):
|
||||||
|
kandidaten.append(os.path.join(work_dir, job_id))
|
||||||
|
return [p for p in kandidaten if os.path.isdir(p)]
|
||||||
|
|
||||||
|
|
||||||
|
def _rohdaten_groesse(pfade: list) -> tuple:
|
||||||
|
"""(Bytes, Dateizahl) der Roh-MKVs — nur die Dateien, nicht rekursiv tief."""
|
||||||
|
bytes_gesamt, dateien = 0, 0
|
||||||
|
for pfad in pfade:
|
||||||
|
try:
|
||||||
|
for name in os.listdir(pfad):
|
||||||
|
voll = os.path.join(pfad, name)
|
||||||
|
if os.path.isfile(voll):
|
||||||
|
bytes_gesamt += os.path.getsize(voll)
|
||||||
|
dateien += 1
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return bytes_gesamt, dateien
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/jobs/{job_id}/rohdaten")
|
||||||
|
async def job_rohdaten(job_id: str):
|
||||||
|
"""Was bleibt liegen, wenn dieser Job aus der Liste fliegt?
|
||||||
|
|
||||||
|
⚠️ Der Grund für diesen Endpunkt (Vorfall 25.07.2026, v3.14): „Job aus der
|
||||||
|
Liste entfernen" löscht bewusst keine Dateien — richtig, aber der Rohschnitt
|
||||||
|
ist danach UNERREICHBAR, weil Job und Dateien nur über die Job-ID verbunden
|
||||||
|
sind. Ein 75-GB-Rohschnitt verwaiste so unsichtbar auf der Platte: kein
|
||||||
|
Eintrag zeigte mehr darauf, „Neu komprimieren" war unmöglich, und im UI war
|
||||||
|
nichts davon zu sehen. Erst ein Blick per SSH brachte es zutage.
|
||||||
|
|
||||||
|
Jetzt fragt das UI vor dem Entfernen hier nach und sagt die Zahl.
|
||||||
|
"""
|
||||||
|
job = await asyncio.to_thread(db.get_job, job_id)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="Job nicht gefunden")
|
||||||
|
|
||||||
|
def sammle():
|
||||||
|
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
|
||||||
|
pfade = _rohdaten_verzeichnisse(job_id, work_dir)
|
||||||
|
bytes_gesamt, dateien = _rohdaten_groesse(pfade)
|
||||||
|
return {
|
||||||
|
"pfade": pfade,
|
||||||
|
"dateien": dateien,
|
||||||
|
"bytes": bytes_gesamt,
|
||||||
|
"gb": round(bytes_gesamt / 1024**3, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
return await asyncio.to_thread(sammle)
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/jobs/{job_id}")
|
@app.delete("/jobs/{job_id}")
|
||||||
async def delete_job(job_id: str):
|
async def delete_job(job_id: str, rohdaten: bool = False):
|
||||||
"""Entfernt einen erledigten Job aus der Liste (Dateien bleiben liegen)."""
|
"""Entfernt einen erledigten Job aus der Liste.
|
||||||
|
|
||||||
|
`rohdaten=true` löscht zusätzlich die Roh-MKVs. Ohne den Schalter bleiben
|
||||||
|
sie liegen (Standard wie bisher) — das UI nennt vorher die Größe, damit die
|
||||||
|
Entscheidung bewusst fällt und kein Rohschnitt unsichtbar verwaist.
|
||||||
|
"""
|
||||||
job = await asyncio.to_thread(db.get_job, job_id)
|
job = await asyncio.to_thread(db.get_job, job_id)
|
||||||
if not job:
|
if not job:
|
||||||
raise HTTPException(status_code=404, detail="Job nicht gefunden")
|
raise HTTPException(status_code=404, detail="Job nicht gefunden")
|
||||||
if job["status"] not in ("completed", "failed"):
|
if job["status"] not in ("completed", "failed"):
|
||||||
raise HTTPException(status_code=409, detail="Job läuft noch — erst abbrechen")
|
raise HTTPException(status_code=409, detail="Job läuft noch — erst abbrechen")
|
||||||
|
|
||||||
|
geloescht_gb = 0.0
|
||||||
|
if rohdaten:
|
||||||
|
def raeume():
|
||||||
|
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
|
||||||
|
pfade = _rohdaten_verzeichnisse(job_id, work_dir)
|
||||||
|
bytes_gesamt, _ = _rohdaten_groesse(pfade)
|
||||||
|
for pfad in pfade:
|
||||||
|
shutil.rmtree(pfad, ignore_errors=True)
|
||||||
|
return round(bytes_gesamt / 1024**3, 1)
|
||||||
|
|
||||||
|
geloescht_gb = await asyncio.to_thread(raeume)
|
||||||
|
|
||||||
await asyncio.to_thread(db.delete_job, job_id)
|
await asyncio.to_thread(db.delete_job, job_id)
|
||||||
await asyncio.to_thread(db.add_log, "info", "api", f"Job {job_id} aus der Liste entfernt")
|
vermerk = f" (inkl. {geloescht_gb} GB Rohdaten gelöscht)" if rohdaten else ""
|
||||||
return {"status": "deleted"}
|
await asyncio.to_thread(
|
||||||
|
db.add_log, "info", "api", f"Job {job_id} aus der Liste entfernt{vermerk}"
|
||||||
|
)
|
||||||
|
return {"status": "deleted", "rohdaten_geloescht_gb": geloescht_gb}
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/jobs")
|
@app.delete("/jobs")
|
||||||
@@ -768,6 +863,24 @@ async def capabilities():
|
|||||||
return {"workers": await asyncio.to_thread(sammle)}
|
return {"workers": await asyncio.to_thread(sammle)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/presets")
|
||||||
|
async def preset_uebersicht():
|
||||||
|
"""Welche HandBrake-Presets es WIRKLICH gibt — und welches das beste ist.
|
||||||
|
|
||||||
|
Die Namen kommen von den Workern selbst (`HandBrakeCLI --preset-list`, siehe
|
||||||
|
worker/caps.py), die Staffelung aus presets.py. Damit endet das Raten:
|
||||||
|
vorher standen die Namen fest verdrahtet im UI, und ein Name, den das
|
||||||
|
jeweilige HandBrake nicht kennt, ließ die Kompression scheitern.
|
||||||
|
|
||||||
|
Commander-Anforderung 26.07.2026: „Bei den Presets soll IMMER das Beste
|
||||||
|
ausgewählt werden" — die Begründung steht bei jeder Empfehlung mit dabei.
|
||||||
|
"""
|
||||||
|
def sammle():
|
||||||
|
return preset_auswahl.uebersicht(db.list_workers())
|
||||||
|
|
||||||
|
return await asyncio.to_thread(sammle)
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/workers/{name}")
|
@app.delete("/workers/{name}")
|
||||||
async def delete_worker(name: str):
|
async def delete_worker(name: str):
|
||||||
"""Verwaisten Worker-Eintrag entfernen (alte Container-IDs nach Rebuilds).
|
"""Verwaisten Worker-Eintrag entfernen (alte Container-IDs nach Rebuilds).
|
||||||
@@ -1201,6 +1314,51 @@ async def worker_setup_windows_exe():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/worker-setup/pfad-map")
|
||||||
|
async def worker_setup_pfad_map():
|
||||||
|
"""Was ein externer Worker für `RIPPY_PATH_MAP` eintragen muss.
|
||||||
|
|
||||||
|
DER fehlende Anschluss (Befund 26.07.2026): Ein Windows-Worker bekommt von
|
||||||
|
Rippy Container-Pfade (`/app/media/rippy/<job>`). Ohne Übersetzung auf eine
|
||||||
|
Freigabe sieht er sie nicht — und das Mapping setzte niemand. Externes
|
||||||
|
Encoden konnte deshalb nie funktionieren, obwohl das Celery-Routing
|
||||||
|
einwandfrei arbeitete (Job 95afdc89: angenommen, 182 ms später abgelehnt).
|
||||||
|
|
||||||
|
Geraten wird hier nichts: Rippy hat die Freigabe selbst eingehängt und
|
||||||
|
kennt ihre Quelle. Der Installer holt den Vorschlag von hier und schreibt
|
||||||
|
ihn in start-tray.bat — der Nutzer muss nichts über Container-Pfade wissen.
|
||||||
|
"""
|
||||||
|
def sammle():
|
||||||
|
vorschlaege = mount_verwaltung.pfad_map_vorschlag(db.list_mounts())
|
||||||
|
mapping = mount_verwaltung.pfad_map_zeile(vorschlaege)
|
||||||
|
if mapping:
|
||||||
|
hinweis = (
|
||||||
|
"Diese Freigabe(n) hängt Rippy selbst ein — der Worker erreicht "
|
||||||
|
"sie unter demselben Namen im Netzwerk. Wichtig: Arbeits"
|
||||||
|
"verzeichnis UND Ablage müssen darunter liegen, sonst kann der "
|
||||||
|
"Worker lesen, aber nicht schreiben (oder umgekehrt)."
|
||||||
|
)
|
||||||
|
elif vorschlaege:
|
||||||
|
hinweis = (
|
||||||
|
'Es sind nur NFS-Ziele eingehängt. Windows kann NFS zwar über '
|
||||||
|
'die Funktion „Client für NFS" einbinden, die Pfad-Schreibweise '
|
||||||
|
'lässt sich aber nicht zuverlässig ableiten — bitte selbst '
|
||||||
|
'eintragen, Format: /app/media/<name>=Z:\\ (oder UNC-Pfad).'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
hinweis = (
|
||||||
|
"Rippy hat keine Netzwerk-Freigabe eingehängt. Ein externer "
|
||||||
|
"Worker kann dann NICHTS komprimieren: Rohdaten und Ziel liegen "
|
||||||
|
"auf der Platte der Rippy-Maschine, und dorthin gibt es keinen "
|
||||||
|
"Netzwerk-Zugang. Abhilfe: unter Einstellungen → Speicherziele "
|
||||||
|
"eine Freigabe (NAS oder PC) einhängen und sie als "
|
||||||
|
"Arbeitsverzeichnis UND Ablage wählen."
|
||||||
|
)
|
||||||
|
return {"mapping": mapping, "eintraege": vorschlaege, "hinweis": hinweis}
|
||||||
|
|
||||||
|
return await asyncio.to_thread(sammle)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/worker-setup/paket")
|
@app.get("/worker-setup/paket")
|
||||||
async def worker_setup_paket():
|
async def worker_setup_paket():
|
||||||
"""Worker-Quellcode als Zip — der Windows-Installer lädt ihn von hier.
|
"""Worker-Quellcode als Zip — der Windows-Installer lädt ihn von hier.
|
||||||
|
|||||||
+70
-1
@@ -11,6 +11,7 @@ sondern über eine temporäre credentials-Datei).
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import posixpath
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -22,7 +23,16 @@ NAME_MUSTER = re.compile(r"^[a-z0-9][a-z0-9-]{1,30}$")
|
|||||||
|
|
||||||
|
|
||||||
def _mountpoint(name: str) -> str:
|
def _mountpoint(name: str) -> str:
|
||||||
return os.path.join(MEDIA_ROOT, name)
|
"""Mountpunkt eines Speicherziels — IMMER ein Container-Pfad.
|
||||||
|
|
||||||
|
posixpath statt os.path (Befund 26.07.2026): Innerhalb des api-Containers
|
||||||
|
ist das dasselbe, aber `pfad_map_vorschlag()` liefert diesen Pfad an einen
|
||||||
|
WINDOWS-Worker aus. Mit os.path.join entstand beim Test unter Windows
|
||||||
|
`/app/media\\rippy`, und `pfad_lokal()` fand dann kein Präfix — das Mapping
|
||||||
|
wäre still wirkungslos geblieben. Dieselbe Falle wie bei `_zielbasis()` im
|
||||||
|
Worker (v3.14), diesmal vom Test gefunden statt live.
|
||||||
|
"""
|
||||||
|
return posixpath.join(MEDIA_ROOT, name)
|
||||||
|
|
||||||
|
|
||||||
def validiere_name(name: str) -> bool:
|
def validiere_name(name: str) -> bool:
|
||||||
@@ -161,6 +171,65 @@ def liste_smb_freigaben(host: str, username: str = "", passwort: str = "") -> li
|
|||||||
return freigaben
|
return freigaben
|
||||||
|
|
||||||
|
|
||||||
|
def unc_aus_quelle(typ: str, quelle: str) -> str:
|
||||||
|
"""`//host/freigabe` → `\\\\host\\freigabe` (pure Funktion, testbar).
|
||||||
|
|
||||||
|
Nur für CIFS/SMB. NFS gibt "" zurück: Windows kann NFS zwar einhängen
|
||||||
|
(optionale Funktion „Client für NFS"), die Schreibweise ist aber eine
|
||||||
|
andere und der Export-Pfad lässt sich nicht zuverlässig übersetzen —
|
||||||
|
raten verstößt gegen AGENTS Regel D.
|
||||||
|
"""
|
||||||
|
if typ != "cifs" or not quelle:
|
||||||
|
return ""
|
||||||
|
rest = quelle.strip()
|
||||||
|
if not rest.startswith("//"):
|
||||||
|
return ""
|
||||||
|
return "\\\\" + rest[2:].replace("/", "\\")
|
||||||
|
|
||||||
|
|
||||||
|
def pfad_map_vorschlag(eintraege: list) -> list:
|
||||||
|
"""Welcher Container-Pfad entspricht welcher Windows-Freigabe?
|
||||||
|
|
||||||
|
Das ist der fehlende Anschluss für `RIPPY_PATH_MAP` (Befund 26.07.2026):
|
||||||
|
Ein externer Worker bekommt von Rippy Pfade wie `/app/media/rippy/<job>`
|
||||||
|
— das sind Pfade INNERHALB des Containers. Er sieht sie nur, wenn sie auf
|
||||||
|
eine Netzwerk-Freigabe übersetzt werden, und dieses Mapping wurde bis
|
||||||
|
dahin von niemandem gesetzt. Externes Encoden konnte also nie laufen.
|
||||||
|
|
||||||
|
Geraten werden muss dafür nichts: Rippy hat die Freigabe selbst
|
||||||
|
eingehängt und kennt ihre Quelle (`//192.168.178.62/rippy`). Der
|
||||||
|
Mountpunkt ist `/app/media/<name>`, und beides zusammen IST das Mapping.
|
||||||
|
"""
|
||||||
|
vorschlaege = []
|
||||||
|
for eintrag in eintraege or []:
|
||||||
|
name = (eintrag.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
typ = (eintrag.get("typ") or "").strip()
|
||||||
|
unc = unc_aus_quelle(typ, eintrag.get("quelle") or "")
|
||||||
|
vorschlaege.append({
|
||||||
|
"name": name,
|
||||||
|
"typ": typ,
|
||||||
|
"quelle": eintrag.get("quelle") or "",
|
||||||
|
"container": _mountpoint(name),
|
||||||
|
"unc": unc,
|
||||||
|
"gemountet": ist_gemountet(name),
|
||||||
|
})
|
||||||
|
return vorschlaege
|
||||||
|
|
||||||
|
|
||||||
|
def pfad_map_zeile(vorschlaege: list) -> str:
|
||||||
|
"""Die Vorschläge als fertiger RIPPY_PATH_MAP-Wert (pure Funktion).
|
||||||
|
|
||||||
|
Format wie `worker/tasks.pfad_lokal()` es liest: Paare `container=ziel`,
|
||||||
|
getrennt durch `;`. Einträge ohne übersetzbaren UNC-Pfad (NFS) fallen
|
||||||
|
weg — ein halbes Mapping wäre schlimmer als keines, weil `pfad_lokal`
|
||||||
|
beim ersten passenden Präfix aufhört.
|
||||||
|
"""
|
||||||
|
paare = [f"{v['container']}={v['unc']}" for v in vorschlaege or [] if v.get("unc")]
|
||||||
|
return ";".join(paare)
|
||||||
|
|
||||||
|
|
||||||
def mounten(name: str, typ: str, quelle: str, optionen: str = "",
|
def mounten(name: str, typ: str, quelle: str, optionen: str = "",
|
||||||
username: str = "", passwort: str = "") -> bool:
|
username: str = "", passwort: str = "") -> bool:
|
||||||
"""Hängt ein NFS/CIFS-Ziel unter /app/media/<name> ein.
|
"""Hängt ein NFS/CIFS-Ziel unter /app/media/<name> ein.
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ from clients.thetvdb import TheTVDBClient
|
|||||||
from cache import get, set as cache_set
|
from cache import get, set as cache_set
|
||||||
from cache.keys import generate_prescan_key
|
from cache.keys import generate_prescan_key
|
||||||
|
|
||||||
|
|
||||||
def normalize_disc_label(label: str) -> str:
|
def normalize_disc_label(label: str) -> str:
|
||||||
"""Disc-Labels wie 'PULP_FICTION_DE' → 'Pulp Fiction De' (pure Funktion).
|
"""Disc-Labels wie 'PULP_FICTION_DE' → 'Pulp Fiction De' (pure Funktion).
|
||||||
|
|
||||||
@@ -442,14 +441,42 @@ class PreScan:
|
|||||||
break
|
break
|
||||||
|
|
||||||
# Fallback 1: Jikan/MyAnimeList (kostenlos, KEIN Key) — für Anime die
|
# Fallback 1: Jikan/MyAnimeList (kostenlos, KEIN Key) — für Anime die
|
||||||
# präziseste Quelle; wählt per Titel-Ähnlichkeit, nicht Treffer #1
|
# präziseste Quelle; wählt per Titel-Ähnlichkeit, nicht Treffer #1.
|
||||||
|
#
|
||||||
|
# NEU 26.07.2026 — „exakt schlägt unscharf", unabhängig von der
|
||||||
|
# Reihenfolge. Vorher galt der erste Jikan-Treffer über dem Gate 0,55
|
||||||
|
# sofort als sicherer Fund (confidence 0,85) und OMDb kam nie dran.
|
||||||
|
# Das ist zu großzügig, und zwar messbar (offline gerechnet mit
|
||||||
|
# titel_aehnlichkeit, echte MyAnimeList-Titel):
|
||||||
|
#
|
||||||
|
# „Alien" vs. „Alien 9" → 0,83 TRIFFT
|
||||||
|
# „Inception" vs. „Deception" → 0,78 TRIFFT
|
||||||
|
# „Hero" vs. „Heroman" → 0,73 TRIFFT
|
||||||
|
# „The Dark Knight" vs. „Dark Knight Rises" → 0,69 TRIFFT
|
||||||
|
#
|
||||||
|
# Ein Kinofilm, den TMDB verpasst, bekam damit Anime-Metadaten — und
|
||||||
|
# OMDb, das ihn kennt, wurde nie gefragt. Bemerkenswert dabei, und das
|
||||||
|
# widerlegt die Vermutung im SAVEPOINT v3.16, das Gate sei einfach zu
|
||||||
|
# niedrig: Für den Fall, für den Jikan überhaupt eingebaut wurde, ist
|
||||||
|
# es sogar zu HOCH — „Evangelion 2.22" vs. „Evangelion: 2.0 You Can
|
||||||
|
# (Not) Advance" ergibt 0,51 und fällt durch. Eine einzelne Zahl kann
|
||||||
|
# beides nicht leisten.
|
||||||
|
#
|
||||||
|
# Deshalb: Nur ein praktisch exakter Titel gilt sofort. Ein unscharfer
|
||||||
|
# Treffer wird GEMERKT, dann OMDb gefragt — und erst wenn OMDb nichts
|
||||||
|
# hat, kommt er als VORSCHLAG (niedrige Confidence) zum Zug.
|
||||||
|
jikan_unscharf = None
|
||||||
if not matched:
|
if not matched:
|
||||||
for kandidat in kandidaten:
|
for kandidat in kandidaten:
|
||||||
jikan_treffer = self.jikan.lookup(kandidat)
|
jikan_treffer = self.jikan.lookup(kandidat)
|
||||||
if jikan_treffer:
|
if not jikan_treffer:
|
||||||
|
continue
|
||||||
|
if jikan_treffer.get("match_score", 0) >= JikanClient.SICHER_AEHNLICHKEIT:
|
||||||
confidence = 0.85
|
confidence = 0.85
|
||||||
metadata = jikan_treffer
|
metadata = jikan_treffer
|
||||||
matched = True
|
matched = True
|
||||||
|
else:
|
||||||
|
jikan_unscharf = jikan_treffer
|
||||||
break
|
break
|
||||||
|
|
||||||
# Fallback 2: OMDb (eigene Datenbasis — findet oft, was TMDB nicht
|
# Fallback 2: OMDb (eigene Datenbasis — findet oft, was TMDB nicht
|
||||||
@@ -464,6 +491,15 @@ class PreScan:
|
|||||||
matched = True
|
matched = True
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# Fallback 2b: der unscharfe Jikan-Treffer — besser als nichts, aber
|
||||||
|
# ehrlich als Vorschlag ausgewiesen (0,6 = dieselbe Stufe wie ein
|
||||||
|
# TMDB-Vorschlag ohne exakten Treffer; das UI zeigt die Prozentzahl an
|
||||||
|
# und der Nutzer kann korrigieren).
|
||||||
|
if not matched and jikan_unscharf:
|
||||||
|
confidence = 0.6
|
||||||
|
metadata = jikan_unscharf
|
||||||
|
matched = True
|
||||||
|
|
||||||
# Fallback 2: bester TMDB-Vorschlag ohne exakten Treffer — als
|
# Fallback 2: bester TMDB-Vorschlag ohne exakten Treffer — als
|
||||||
# VORSCHLAG gekennzeichnet (niedrige Confidence, Nutzer korrigiert)
|
# VORSCHLAG gekennzeichnet (niedrige Confidence, Nutzer korrigiert)
|
||||||
if not matched and movies:
|
if not matched and movies:
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
"""Welches HandBrake-Preset passt zu welcher Disc — und was ist das BESTE.
|
||||||
|
|
||||||
|
Commander-Anforderung (26.07.2026): *„Bei den Presets soll IMMER das Beste
|
||||||
|
ausgewählt werden"* und *„wenn der Worker AV1 oder noch besseres kann, immer
|
||||||
|
diesem empfehlen"*.
|
||||||
|
|
||||||
|
## Warum das hier liegt und nicht im UI
|
||||||
|
|
||||||
|
Die Preset-Namen standen bis dahin fest verdrahtet im UI (drei Listen in
|
||||||
|
Settings.tsx, eine vierte im Wizard). Zwei Probleme: die Namen unterscheiden
|
||||||
|
sich zwischen HandBrake-Versionen, und ein erfundener Name lässt die Kompression
|
||||||
|
scheitern — das Projekt hat genau das zweimal teuer bezahlt (AGENTS Regel D).
|
||||||
|
Jetzt meldet jeder Worker seine echte Liste (`worker/caps.py`,
|
||||||
|
`HandBrakeCLI --preset-list`), und die Auswahl entsteht hier: als reine
|
||||||
|
Funktionen, die die CI-Ampel prüft. Das UI zeigt nur noch an.
|
||||||
|
|
||||||
|
## Es wird nicht bewertet, sondern gestaffelt
|
||||||
|
|
||||||
|
Kein Punktesystem. Für jede Lage gibt es eine feste Reihenfolge von Namen, und
|
||||||
|
genommen wird der erste, den der Worker WIRKLICH kennt. Jeder Name unten ist am
|
||||||
|
26.07.2026 aus `--preset-list` des Worker-Images abgenommen (HandBrake 1.6.1).
|
||||||
|
Ein Punktesystem hätte über Namen geurteilt, die es vielleicht nicht gibt.
|
||||||
|
|
||||||
|
## Zwei Quellen, zwei Fragen
|
||||||
|
|
||||||
|
- `--preset-list` sagt, welche Presets es GIBT. Die Kategorie `Hardware/` steht
|
||||||
|
dort auch auf Maschinen ohne Hardware-Encoder.
|
||||||
|
- `--help` sagt, welche Encoder LAUFEN.
|
||||||
|
|
||||||
|
Deshalb wird ein Hardware-Preset nur empfohlen, wenn ein Worker die passende
|
||||||
|
Familie wirklich meldet. (Richtigstellung zum SAVEPOINT v3.16: dort galt es als
|
||||||
|
unmöglich, die Hardware-Preset-Namen auf der VM zu ermitteln — die Messung sagt
|
||||||
|
das Gegenteil, siehe caps.parse_preset_liste.)
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Reservewert: „diesen Disc-Typ NICHT komprimieren". Gleichlautend in
|
||||||
|
# worker/ripping.PRESET_KEINE und ui/src/lib/encoder.ts — es gibt kein
|
||||||
|
# geteiltes Paket zwischen den drei Seiten.
|
||||||
|
PRESET_KEINE = "keine"
|
||||||
|
|
||||||
|
# Auflösungs-Token je Disc-Typ, so wie HandBrake sie in die Preset-Namen
|
||||||
|
# schreibt. Damit bietet die 4K-Auswahl keine 1080p-Presets als Normalfall an —
|
||||||
|
# genau dieser Griff hat in v3.12 eine 4K-UHD auf 1080p heruntergerechnet.
|
||||||
|
AUFLOESUNG_JE_TYP = {
|
||||||
|
"uhd": ("2160p",),
|
||||||
|
"bluray": ("1080p",),
|
||||||
|
"dvd": ("576p", "480p"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Hardware-Familie im ENCODER-Namen (aus `--help`) → Kürzel im PRESET-Namen.
|
||||||
|
# Gemessen: die Presets heißen „H.265 VCN 2160p 4K", der Encoder dazu aber
|
||||||
|
# `vce_h265`. Ohne diese Zuordnung bekäme eine AMD-Karte kein AMD-Preset.
|
||||||
|
# Reihenfolge = Vorzug, wenn eine Maschine mehrere Familien meldet.
|
||||||
|
HW_PRESET_KUERZEL = (
|
||||||
|
("qsv", "QSV"), # Intel QuickSync — in 1.6.1 die einzige mit AV1-Preset
|
||||||
|
("nvenc", "NVENC"), # NVIDIA
|
||||||
|
("vce", "VCN"), # AMD (Encoder heißt vce, Preset heißt VCN)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Absichtlich NICHT dabei:
|
||||||
|
# - `vaapi`: HandBrake 1.6.1 liefert kein VAAPI-Preset mit (Kategorie
|
||||||
|
# `Hardware/` kennt nur QSV/NVENC/VCN/MF). Ein VAAPI-Worker fällt daher auf
|
||||||
|
# Software zurück, statt einen Namen zu bekommen, den es nicht gibt.
|
||||||
|
# - `MF` (Media Foundation): die Presets existieren, aber ob diese Maschine sie
|
||||||
|
# nutzen kann, ist aus der Encoder-Liste nicht ablesbar. Empfohlen wird MF
|
||||||
|
# deshalb nie; in der Auswahlliste steht es trotzdem.
|
||||||
|
|
||||||
|
# Vektorbefehls-Stufen, mit denen Software-Encoding brauchbar schnell ist.
|
||||||
|
# Gleichlautend in ui/src/lib/encoder.ts (SIMD_SCHNELL).
|
||||||
|
SIMD_SCHNELL = ("avx512f", "avx2")
|
||||||
|
|
||||||
|
# Rückfall, wenn KEIN Worker eine Preset-Liste meldet (alter Worker-Stand, oder
|
||||||
|
# gar kein Worker online). Alle vier Namen sind im Worker-Image gegengeprüft.
|
||||||
|
RUECKFALL_PRESETS = (
|
||||||
|
"H.265 MKV 2160p60 4K",
|
||||||
|
"H.265 MKV 1080p30",
|
||||||
|
"H.265 MKV 576p25",
|
||||||
|
"H.265 MKV 480p30",
|
||||||
|
"HQ 2160p60 4K HEVC Surround",
|
||||||
|
"HQ 1080p30 Surround",
|
||||||
|
"HQ 576p25 Surround",
|
||||||
|
"Super HQ 2160p60 4K HEVC Surround",
|
||||||
|
"Super HQ 1080p30 Surround",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def verfuegbare_presets(workers) -> list:
|
||||||
|
"""Alle Preset-Namen, die die gemeldeten Worker kennen (Vereinigung).
|
||||||
|
|
||||||
|
Vereinigung und nicht Schnittmenge: Die Kompression kann gezielt an EINEN
|
||||||
|
Worker geroutet werden (worker_direct), es muss also nicht jeder alles
|
||||||
|
können. Ob der gewählte Worker das Preset kennt, entscheidet er selbst —
|
||||||
|
und meldet es als Klartext-Fehler.
|
||||||
|
"""
|
||||||
|
gefunden = []
|
||||||
|
for w in workers or []:
|
||||||
|
for name in ((w.get("info") or {}).get("presets") or []):
|
||||||
|
if name and name not in gefunden:
|
||||||
|
gefunden.append(name)
|
||||||
|
return gefunden or list(RUECKFALL_PRESETS)
|
||||||
|
|
||||||
|
|
||||||
|
def hardware_kuerzel(workers) -> list:
|
||||||
|
"""Preset-Kürzel der Hardware-Encoder, die WIRKLICH gemeldet sind."""
|
||||||
|
vorhanden = set()
|
||||||
|
for w in workers or []:
|
||||||
|
for encoder in (w.get("encoders") or []):
|
||||||
|
vorhanden.add(str(encoder).lower())
|
||||||
|
kuerzel = []
|
||||||
|
for familie, kurz in HW_PRESET_KUERZEL:
|
||||||
|
if any(e == familie or e.startswith(familie + "-") for e in vorhanden):
|
||||||
|
kuerzel.append(kurz)
|
||||||
|
return kuerzel
|
||||||
|
|
||||||
|
|
||||||
|
def simd_schnell(workers) -> bool:
|
||||||
|
"""Kann mindestens ein Worker Software-Encoding brauchbar schnell?
|
||||||
|
|
||||||
|
Bei unbekannter Stufe wird NICHT geraten (ein Windows-Worker ohne die
|
||||||
|
Erkennung aus v3.16 meldet `unbekannt`) — dann zählt er hier nicht mit,
|
||||||
|
bremst aber auch niemanden aus.
|
||||||
|
"""
|
||||||
|
for w in workers or []:
|
||||||
|
if ((w.get("info") or {}).get("cpu_simd") or "") in SIMD_SCHNELL:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _kandidaten(disc_type: str, kuerzel: list, schnell: bool) -> list:
|
||||||
|
"""Namens-Staffel für diese Lage — bester Kandidat zuerst.
|
||||||
|
|
||||||
|
Die Software-Zweige sind bewusst dieselben, die der Wizard seit v3.15
|
||||||
|
vorschlägt (dort schon geprüft): schwache CPU → 4K verlustfrei behalten und
|
||||||
|
H.264 für den Rest, starke CPU → H.265 durchgehend. Neu ist nur, dass
|
||||||
|
Hardware-Presets davor kommen.
|
||||||
|
"""
|
||||||
|
liste = []
|
||||||
|
# 1. Hardware zuerst — sie ist um Größenordnungen schneller. Auf der
|
||||||
|
# Rippy-VM brauchte Software-4K gemessene 28-55 Stunden pro Film.
|
||||||
|
for kurz in kuerzel:
|
||||||
|
if disc_type == "uhd":
|
||||||
|
liste += [f"AV1 {kurz} 2160p 4K", f"H.265 {kurz} 2160p 4K"]
|
||||||
|
elif disc_type == "bluray":
|
||||||
|
liste += [f"H.265 {kurz} 1080p"]
|
||||||
|
# Für DVD-Auflösungen liefert HandBrake 1.6.1 keine Hardware-Presets —
|
||||||
|
# eine DVD ist auch in Software in Minuten fertig.
|
||||||
|
|
||||||
|
# 2. Software.
|
||||||
|
if disc_type == "uhd":
|
||||||
|
liste += ["H.265 MKV 2160p60 4K"] if schnell else [PRESET_KEINE]
|
||||||
|
elif disc_type == "bluray":
|
||||||
|
liste += ["H.265 MKV 1080p30"] if schnell else ["HQ 1080p30 Surround"]
|
||||||
|
elif disc_type == "dvd":
|
||||||
|
liste += ["H.265 MKV 576p25"] if schnell else ["HQ 576p25 Surround"]
|
||||||
|
return liste
|
||||||
|
|
||||||
|
|
||||||
|
def _grund(preset: str, disc_type: str, kuerzel: list, schnell: bool) -> str:
|
||||||
|
"""Ein Satz, WARUM das die Empfehlung ist — der Commander liest das."""
|
||||||
|
if preset == PRESET_KEINE:
|
||||||
|
return (
|
||||||
|
"Keiner der gemeldeten Worker hat AVX2 oder einen Hardware-Encoder. "
|
||||||
|
"4K in Software dauert auf so einer Maschine gemessene 28-55 Stunden "
|
||||||
|
"pro Film — die verlustfreie Datei zu behalten ist hier die ehrliche "
|
||||||
|
"Wahl (kostet 20-100 GB)."
|
||||||
|
)
|
||||||
|
if any(f" {k} " in f" {preset} " for k in kuerzel):
|
||||||
|
return (
|
||||||
|
f"Nutzt den gemeldeten Hardware-Encoder ({', '.join(kuerzel)}) — "
|
||||||
|
"um Größenordnungen schneller als die CPU. Software-x265 wäre bei "
|
||||||
|
"gleicher Dateigröße etwas sauberer, dauert aber Stunden bis Tage."
|
||||||
|
)
|
||||||
|
if schnell:
|
||||||
|
return (
|
||||||
|
"H.265 in Software: kleinste Dateien bei sehr guter Qualität. Ein "
|
||||||
|
"Worker mit AVX2 ist dafür schnell genug (gemessen)."
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"H.264 statt H.265: ohne AVX2 ist H.265 sehr langsam. Die Datei wird "
|
||||||
|
"etwas größer, der Encode dafür um ein Mehrfaches schneller."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def empfehlung(disc_type: str, workers) -> dict:
|
||||||
|
"""Bestes Preset für diesen Disc-Typ — nur Namen, die es wirklich gibt.
|
||||||
|
|
||||||
|
Rückgabe: {"preset": str, "grund": str, "gefunden": bool}. `gefunden` ist
|
||||||
|
False, wenn keiner der bekannten Namen in der Liste des Workers steht (z. B.
|
||||||
|
eine HandBrake-Version mit anderer Benennung) — dann muss der Nutzer selbst
|
||||||
|
wählen, und das UI sagt es ihm, statt still etwas Falsches einzustellen.
|
||||||
|
"""
|
||||||
|
vorhanden = verfuegbare_presets(workers)
|
||||||
|
kuerzel = hardware_kuerzel(workers)
|
||||||
|
schnell = simd_schnell(workers)
|
||||||
|
for kandidat in _kandidaten(disc_type, kuerzel, schnell):
|
||||||
|
if kandidat == PRESET_KEINE or kandidat in vorhanden:
|
||||||
|
return {
|
||||||
|
"preset": kandidat,
|
||||||
|
"grund": _grund(kandidat, disc_type, kuerzel, schnell),
|
||||||
|
"gefunden": True,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"preset": "",
|
||||||
|
"grund": (
|
||||||
|
"Dieses HandBrake kennt keines der Presets, die Rippy vorschlagen "
|
||||||
|
"kann — die Namen unterscheiden sich zwischen HandBrake-Versionen. "
|
||||||
|
"Bitte unten selbst eines aus der Liste des Workers wählen."
|
||||||
|
),
|
||||||
|
"gefunden": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def auswahl(disc_type: str, workers) -> list:
|
||||||
|
"""Die Presets, die für diesen Disc-Typ zur Wahl stehen — sortiert.
|
||||||
|
|
||||||
|
Gefiltert auf die passende Auflösung: Bei 4K sollen keine 1080p-Presets als
|
||||||
|
Normalfall in der Liste stehen, sonst passiert wieder, was in v3.12 passiert
|
||||||
|
ist (4K-Rip auf 1080p heruntergerechnet, weil ein globales Preset galt).
|
||||||
|
Das bewusste Verkleinern bleibt möglich — es steht am Ende, mit Hinweis.
|
||||||
|
"""
|
||||||
|
vorhanden = verfuegbare_presets(workers)
|
||||||
|
tokens = AUFLOESUNG_JE_TYP.get(disc_type, ())
|
||||||
|
passend = [p for p in vorhanden if any(t in p for t in tokens)]
|
||||||
|
liste = [{"name": p, "verkleinert": False} for p in sorted(passend)]
|
||||||
|
if disc_type == "uhd":
|
||||||
|
# Bewusstes Verkleinern auf 1080p — eigene Gruppe, damit niemand aus
|
||||||
|
# Versehen dort landet.
|
||||||
|
kleiner = [p for p in vorhanden if "1080p" in p]
|
||||||
|
liste += [{"name": p, "verkleinert": True} for p in sorted(kleiner)]
|
||||||
|
return liste
|
||||||
|
|
||||||
|
|
||||||
|
def uebersicht(workers) -> dict:
|
||||||
|
"""Alles, was das UI für die Preset-Auswahl braucht — in einem Aufruf."""
|
||||||
|
typen = ("uhd", "bluray", "dvd")
|
||||||
|
return {
|
||||||
|
"quelle": "worker" if verfuegbare_presets(workers) != list(RUECKFALL_PRESETS)
|
||||||
|
else "rueckfall",
|
||||||
|
"hardware": hardware_kuerzel(workers),
|
||||||
|
"simd_schnell": simd_schnell(workers),
|
||||||
|
"typen": {
|
||||||
|
t: {"empfehlung": empfehlung(t, workers), "auswahl": auswahl(t, workers)}
|
||||||
|
for t in typen
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"""Tests der Restzeit-Schätzung — reine Funktionen, keine Infrastruktur.
|
||||||
|
|
||||||
|
Die Zahlen in den Szenarien sind die echten Messwerte vom 25.07.2026: Der
|
||||||
|
4K-Encode von Akira kam in 29 Minuten von 0 auf 1,44 % (an der Leseposition im
|
||||||
|
Quellstrom gemessen, `/proc/<pid>/fdinfo/`) — hochgerechnet 28-55 Stunden. Genau
|
||||||
|
dieser Fall muss eine ehrliche Antwort geben, statt „gleich fertig" zu behaupten.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import eta
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_genug_punkte_keine_aussage():
|
||||||
|
assert eta.restzeit_sekunden(None, 100.0) == -1
|
||||||
|
assert eta.restzeit_sekunden({"status": "transcoding", "punkte": []}, 100.0) == -1
|
||||||
|
einer = {"status": "transcoding", "punkte": [[0.0, 5]]}
|
||||||
|
assert eta.restzeit_sekunden(einer, 100.0) == -1
|
||||||
|
|
||||||
|
|
||||||
|
def test_zu_kurze_spanne_keine_aussage():
|
||||||
|
"""Zwei Punkte 10 s auseinander sagen nichts über einen Encode, der Stunden
|
||||||
|
läuft — MINDEST_SPANNE_SEKUNDEN verhindert die Hochrechnung."""
|
||||||
|
reihe = {"status": "transcoding", "punkte": [[0.0, 1], [10.0, 2]]}
|
||||||
|
assert eta.restzeit_sekunden(reihe, 10.0) == -1
|
||||||
|
|
||||||
|
|
||||||
|
def test_kein_fortschritt_keine_aussage():
|
||||||
|
"""Eine Stunde ohne einen einzigen Prozentpunkt: die Rate ist unbekannt,
|
||||||
|
nicht null. Ohne diese Sperre käme eine Division durch 0."""
|
||||||
|
reihe = {"status": "transcoding", "punkte": [[0.0, 3]]}
|
||||||
|
reihe = eta.beobachtung_hinzufuegen(reihe, "transcoding", 3, 3600.0)
|
||||||
|
assert eta.restzeit_sekunden(reihe, 3600.0) == -1
|
||||||
|
|
||||||
|
|
||||||
|
def test_einfache_hochrechnung():
|
||||||
|
"""10 % in 10 Minuten → 90 % brauchen 90 Minuten."""
|
||||||
|
reihe = {"status": "ripping", "punkte": [[0.0, 0], [600.0, 10]]}
|
||||||
|
assert eta.restzeit_sekunden(reihe, 600.0) == 90 * 60
|
||||||
|
|
||||||
|
|
||||||
|
def test_der_echte_4k_fall_landet_in_der_groessenordnung_tage():
|
||||||
|
"""Gemessen: 1,44 % in 29 Minuten. Die Schätzung muss in der Größenordnung
|
||||||
|
TAGE landen — das ist die Angabe, deren Fehlen den 50-Stunden-Lauf am
|
||||||
|
25.07.2026 unsichtbar machte. Die genaue Stundenzahl ist Nebensache; wer
|
||||||
|
„1 Tag 23 h" liest, bricht ab, wer nichts liest, wartet."""
|
||||||
|
reihe = {"status": "transcoding", "punkte": [[0.0, 0], [29 * 60.0, 1]]}
|
||||||
|
sekunden = eta.restzeit_sekunden(reihe, 29 * 60.0)
|
||||||
|
stunden = sekunden / 3600
|
||||||
|
assert 45 < stunden < 50 # 99 % × 29 min ≈ 47,85 h
|
||||||
|
assert eta.formatiere_restzeit(sekunden) == "noch ca. 1 Tag 23 h"
|
||||||
|
# Etwas langsamer, und es heißt nur noch „mehr als 2 Tage" — bei der
|
||||||
|
# Größenordnung wäre jede Stundenangabe erfundene Genauigkeit.
|
||||||
|
langsamer = {"status": "transcoding", "punkte": [[0.0, 0], [45 * 60.0, 1]]}
|
||||||
|
assert eta.formatiere_restzeit(
|
||||||
|
eta.restzeit_sekunden(langsamer, 45 * 60.0)) == "mehr als 2 Tage"
|
||||||
|
|
||||||
|
|
||||||
|
def test_stillstand_verlaengert_die_schaetzung():
|
||||||
|
"""Kernpunkt: Bleibt der Fortschritt stehen, MUSS die Restzeit steigen —
|
||||||
|
sonst zeigt ein hängender Job stundenlang „noch 5 Minuten"."""
|
||||||
|
reihe = {"status": "transcoding", "punkte": [[0.0, 0], [600.0, 50]]}
|
||||||
|
frisch = eta.restzeit_sekunden(reihe, 600.0)
|
||||||
|
# ... eine Stunde später steht der Fortschritt immer noch bei 50 %
|
||||||
|
reihe = eta.beobachtung_hinzufuegen(reihe, "transcoding", 50, 4200.0)
|
||||||
|
spaeter = eta.restzeit_sekunden(reihe, 4200.0)
|
||||||
|
assert spaeter > frisch * 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_statuswechsel_verwirft_die_reihe():
|
||||||
|
"""Rip (eine Stunde) und Kompression (Tage) haben nichts miteinander zu tun.
|
||||||
|
Ohne diesen Schnitt entstünde beim Phasenwechsel eine Phantasiezahl."""
|
||||||
|
reihe = {"status": "ripping", "punkte": [[0.0, 0], [600.0, 50]]}
|
||||||
|
neu = eta.beobachtung_hinzufuegen(reihe, "transcoding", 2, 610.0)
|
||||||
|
assert neu["status"] == "transcoding"
|
||||||
|
assert neu["punkte"] == [[610.0, 2]]
|
||||||
|
assert eta.restzeit_sekunden(neu, 610.0) == -1
|
||||||
|
|
||||||
|
|
||||||
|
def test_nur_das_fenster_zaehlt():
|
||||||
|
"""Alte Punkte fliegen raus — HandBrake wird bei komplexen Szenen langsamer,
|
||||||
|
und dann lügt der Anfang der Messreihe."""
|
||||||
|
reihe = None
|
||||||
|
for i in range(20):
|
||||||
|
reihe = eta.beobachtung_hinzufuegen(reihe, "transcoding", i, float(i * 60))
|
||||||
|
assert len(reihe["punkte"]) == eta.FENSTER
|
||||||
|
assert reihe["punkte"][0][1] == 10 # die ersten zehn sind weg
|
||||||
|
|
||||||
|
|
||||||
|
def test_gleicher_fortschritt_haengt_keinen_punkt_an():
|
||||||
|
reihe = eta.beobachtung_hinzufuegen(None, "transcoding", 7, 0.0)
|
||||||
|
reihe = eta.beobachtung_hinzufuegen(reihe, "transcoding", 7, 30.0)
|
||||||
|
reihe = eta.beobachtung_hinzufuegen(reihe, "transcoding", 7, 60.0)
|
||||||
|
assert reihe["punkte"] == [[0.0, 7]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fertig_ist_null():
|
||||||
|
reihe = {"status": "transcoding", "punkte": [[0.0, 50], [600.0, 100]]}
|
||||||
|
assert eta.restzeit_sekunden(reihe, 600.0) == 0
|
||||||
|
assert eta.formatiere_restzeit(0) == "unter einer Minute"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Textform ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_textform_deckt_alle_groessenordnungen():
|
||||||
|
assert eta.formatiere_restzeit(-1) == ""
|
||||||
|
assert eta.formatiere_restzeit(None) == ""
|
||||||
|
assert eta.formatiere_restzeit(30) == "unter einer Minute"
|
||||||
|
assert eta.formatiere_restzeit(90) == "noch ca. 1 min"
|
||||||
|
assert eta.formatiere_restzeit(45 * 60) == "noch ca. 45 min"
|
||||||
|
assert eta.formatiere_restzeit(3 * 3600 + 7 * 60) == "noch ca. 3 h 07 min"
|
||||||
|
assert eta.formatiere_restzeit(30 * 3600) == "noch ca. 1 Tag 6 h"
|
||||||
|
assert eta.formatiere_restzeit(60 * 3600) == "mehr als 2 Tage"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Der Weg über den Cache -------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_aktualisiere_und_schaetze_ueber_zwei_aufrufe():
|
||||||
|
"""So läuft es live: /jobs wird alle vier Sekunden abgefragt, jeder Aufruf
|
||||||
|
schreibt die Reihe fort."""
|
||||||
|
speicher = {}
|
||||||
|
|
||||||
|
def hole(k):
|
||||||
|
return speicher.get(k)
|
||||||
|
|
||||||
|
def lege(k, wert, expire=None):
|
||||||
|
speicher[k] = wert
|
||||||
|
|
||||||
|
erst = eta.aktualisiere_und_schaetze(
|
||||||
|
"job1", "transcoding", 10, 0.0, hole, lege)
|
||||||
|
assert erst == {"sekunden": -1, "text": ""} # ein Punkt sagt nichts
|
||||||
|
|
||||||
|
dann = eta.aktualisiere_und_schaetze(
|
||||||
|
"job1", "transcoding", 20, 600.0, hole, lege)
|
||||||
|
assert dann["sekunden"] == 80 * 60
|
||||||
|
assert dann["text"] == "noch ca. 1 h 20 min"
|
||||||
|
|
||||||
|
|
||||||
|
def test_kaputter_cache_bringt_nichts_zum_absturz():
|
||||||
|
"""Redis weg → keine ETA, aber die Job-Liste muss weiter funktionieren."""
|
||||||
|
def kaputt_holen(k):
|
||||||
|
raise RuntimeError("Redis weg")
|
||||||
|
|
||||||
|
def kaputt_legen(k, wert, expire=None):
|
||||||
|
raise RuntimeError("Redis weg")
|
||||||
|
|
||||||
|
ergebnis = eta.aktualisiere_und_schaetze(
|
||||||
|
"job1", "transcoding", 10, 0.0, kaputt_holen, kaputt_legen)
|
||||||
|
assert ergebnis == {"sekunden": -1, "text": ""}
|
||||||
|
|
||||||
|
|
||||||
|
def test_fertige_und_wartende_jobs_bekommen_keine_eta():
|
||||||
|
speicher = {}
|
||||||
|
for status, progress in (("completed", 100), ("failed", 42), ("pending", 0)):
|
||||||
|
ergebnis = eta.aktualisiere_und_schaetze(
|
||||||
|
"x", status, progress, 0.0,
|
||||||
|
speicher.get, lambda k, v, expire=None: speicher.__setitem__(k, v))
|
||||||
|
assert ergebnis == {"sekunden": -1, "text": ""}
|
||||||
|
assert speicher == {} # nichts geschrieben
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Was das Ähnlichkeits-Gate von Jikan wirklich durchlässt — gemessen.
|
||||||
|
|
||||||
|
Hintergrund (26.07.2026): Der SAVEPOINT v3.16 vermutete, das Gate 0,55 sei
|
||||||
|
„großzügig" und Jikan stehe in der Metadaten-Kette zu früh (vor OMDb). Beides
|
||||||
|
war nachrechenbar, weil `titel_aehnlichkeit` eine reine Funktion ist — und die
|
||||||
|
Rechnung ergab ein anderes Bild als die Vermutung:
|
||||||
|
|
||||||
|
- Für kurze Kinofilm-Titel ist das Gate WIRKLICH zu locker (Alien → 0,83).
|
||||||
|
- Für den Fall, für den Jikan eingebaut wurde, ist es sogar zu STRENG
|
||||||
|
(„Evangelion 2.22" gegen den MAL-Titel → 0,51, fällt durch).
|
||||||
|
|
||||||
|
Eine einzelne Zahl kann also beides nicht leisten. Die Folge steht in
|
||||||
|
prescan._scan_video: „exakt schlägt unscharf" statt „erster gewinnt".
|
||||||
|
|
||||||
|
Die Anime-Titel unten existieren auf MyAnimeList; die Zahlen sind ausgerechnet,
|
||||||
|
nicht geschätzt. MyAnimeList selbst war bei dieser Messung nicht erreichbar
|
||||||
|
(Jikan antwortete durchweg HTTP 504) — die Live-Abfrage steht damit weiter aus,
|
||||||
|
die Arithmetik des Gates ist davon aber unberührt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from clients.jikan import JikanClient, titel_aehnlichkeit
|
||||||
|
|
||||||
|
|
||||||
|
def test_kurze_kinofilm_titel_reissen_das_gate_0_55():
|
||||||
|
"""Diese vier würden ohne die Änderung Anime-Metadaten bekommen, obwohl
|
||||||
|
OMDb die Filme kennt — es wurde nur nie gefragt."""
|
||||||
|
faelle = [
|
||||||
|
("Alien", "Alien 9", 0.83),
|
||||||
|
("Inception", "Deception", 0.78),
|
||||||
|
("Hero", "Heroman", 0.73),
|
||||||
|
("The Dark Knight", "Dark Knight Rises", 0.69),
|
||||||
|
]
|
||||||
|
for disc, anime, erwartet in faelle:
|
||||||
|
score = titel_aehnlichkeit(disc, anime)
|
||||||
|
assert round(score, 2) == erwartet, f"{disc} vs {anime}: {score}"
|
||||||
|
# ... liegt über dem Gate (Jikan gibt also einen Treffer zurück) ...
|
||||||
|
assert score >= JikanClient.MINDEST_AEHNLICHKEIT
|
||||||
|
# ... aber unter „sicher", darf die Kette also nicht beenden.
|
||||||
|
assert score < JikanClient.SICHER_AEHNLICHKEIT
|
||||||
|
|
||||||
|
|
||||||
|
def test_echter_treffer_gilt_weiter_als_sicher():
|
||||||
|
"""Exakter Titel → 1,0. Der Normalfall eines echten Anime-Fundes darf durch
|
||||||
|
die Verschärfung NICHT verlorengehen (AGENTS Regel B)."""
|
||||||
|
assert titel_aehnlichkeit("Monster", "Monster") == 1.0
|
||||||
|
assert titel_aehnlichkeit("Cowboy Bebop", "COWBOY BEBOP") == 1.0
|
||||||
|
# Interpunktion und Kleinschreibung sind egal — norm() wirft sie weg
|
||||||
|
assert titel_aehnlichkeit("Akira", "AKIRA!") == 1.0
|
||||||
|
for a, b in (("Monster", "Monster"), ("Akira", "AKIRA!")):
|
||||||
|
assert titel_aehnlichkeit(a, b) >= JikanClient.SICHER_AEHNLICHKEIT
|
||||||
|
|
||||||
|
|
||||||
|
def test_untertitel_zaehlt_noch_als_sicher_genug():
|
||||||
|
"""Discs tragen oft den Kurztitel, MAL den vollen. Das muss ein sicherer
|
||||||
|
Treffer bleiben, sonst hilft die Quelle bei Anime nicht mehr."""
|
||||||
|
assert titel_aehnlichkeit("Steins Gate", "Steins;Gate") >= JikanClient.SICHER_AEHNLICHKEIT
|
||||||
|
|
||||||
|
|
||||||
|
def test_der_fall_fuer_den_jikan_gebaut_wurde_faellt_heute_durch():
|
||||||
|
"""Gegenbeweis zur Vermutung „das Gate ist zu großzügig": Für Evangelion
|
||||||
|
2.22 ist es zu STRENG. Der Wert ist dokumentiert, damit ein späteres
|
||||||
|
Anheben des Gates nicht unbemerkt auch diesen Fall killt."""
|
||||||
|
score = titel_aehnlichkeit(
|
||||||
|
"Evangelion 2.22", "Evangelion: 2.0 You Can (Not) Advance"
|
||||||
|
)
|
||||||
|
assert round(score, 2) == 0.51
|
||||||
|
assert score < JikanClient.MINDEST_AEHNLICHKEIT
|
||||||
|
|
||||||
|
|
||||||
|
def test_voellig_anderer_titel_wird_verworfen():
|
||||||
|
score = titel_aehnlichkeit("Blade", "Blade of the Immortal")
|
||||||
|
assert score < JikanClient.MINDEST_AEHNLICHKEIT
|
||||||
|
|
||||||
|
|
||||||
|
def test_leere_titel_geben_null():
|
||||||
|
assert titel_aehnlichkeit("", "Monster") == 0.0
|
||||||
|
assert titel_aehnlichkeit("Monster", "") == 0.0
|
||||||
|
assert titel_aehnlichkeit("!!!", "???") == 0.0
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
"""Tests für die SMB-Fehlerübersetzung (Speicherziele → Freigaben auflisten)."""
|
"""Tests für die SMB-Fehlerübersetzung (Speicherziele → Freigaben auflisten)."""
|
||||||
|
|
||||||
from mounts import uebersetze_smb_fehler, validiere_name
|
from mounts import (
|
||||||
|
pfad_map_vorschlag,
|
||||||
|
pfad_map_zeile,
|
||||||
|
uebersetze_smb_fehler,
|
||||||
|
unc_aus_quelle,
|
||||||
|
validiere_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_access_denied_ohne_credentials_erklaert_gastproblem():
|
def test_access_denied_ohne_credentials_erklaert_gastproblem():
|
||||||
@@ -69,3 +75,91 @@ def test_mounten_loest_stale_vor_dem_mount():
|
|||||||
|
|
||||||
quelle = inspect.getsource(mounts.mounten)
|
quelle = inspect.getsource(mounts.mounten)
|
||||||
assert "_stale_mounts_loesen(ziel)" in quelle
|
assert "_stale_mounts_loesen(ziel)" in quelle
|
||||||
|
|
||||||
|
|
||||||
|
# --- RIPPY_PATH_MAP: der Anschluss für externe Worker (Befund 26.07.2026) ----
|
||||||
|
|
||||||
|
|
||||||
|
def test_unc_aus_quelle_uebersetzt_cifs():
|
||||||
|
assert unc_aus_quelle("cifs", "//192.168.178.62/rippy") == "\\\\192.168.178.62\\rippy"
|
||||||
|
assert unc_aus_quelle("cifs", "//nas/medien/filme") == "\\\\nas\\medien\\filme"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unc_aus_quelle_raet_bei_nfs_nicht():
|
||||||
|
"""NFS gibt "" — Windows-Schreibweise ist nicht ableitbar (AGENTS Regel D)."""
|
||||||
|
assert unc_aus_quelle("nfs", "192.168.178.62:/volume1/rippy") == ""
|
||||||
|
assert unc_aus_quelle("cifs", "") == ""
|
||||||
|
assert unc_aus_quelle("cifs", "kein-unc-pfad") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_pfad_map_zeile_baut_was_pfad_lokal_liest(monkeypatch):
|
||||||
|
"""Der erzeugte Wert muss vom Worker gelesen werden können — genau dieses
|
||||||
|
Format erwartet worker/tasks.pfad_lokal(): Paare, getrennt durch ';'."""
|
||||||
|
import mounts
|
||||||
|
|
||||||
|
monkeypatch.setattr(mounts, "ist_gemountet", lambda name: True)
|
||||||
|
vorschlaege = pfad_map_vorschlag([
|
||||||
|
{"name": "rippy", "typ": "cifs", "quelle": "//192.168.178.62/rippy"},
|
||||||
|
])
|
||||||
|
assert pfad_map_zeile(vorschlaege) == "/app/media/rippy=\\\\192.168.178.62\\rippy"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pfad_map_zeile_laesst_nfs_weg(monkeypatch):
|
||||||
|
"""Ein halbes Mapping wäre schlimmer als keines: pfad_lokal() hört beim
|
||||||
|
ersten passenden Präfix auf, ein NFS-Eintrag ohne Ziel würde also einen
|
||||||
|
Pfad 'übersetzen', den der Worker danach trotzdem nicht sieht."""
|
||||||
|
import mounts
|
||||||
|
|
||||||
|
monkeypatch.setattr(mounts, "ist_gemountet", lambda name: True)
|
||||||
|
vorschlaege = pfad_map_vorschlag([
|
||||||
|
{"name": "nfs-ziel", "typ": "nfs", "quelle": "10.0.0.9:/export"},
|
||||||
|
{"name": "rippy", "typ": "cifs", "quelle": "//nas/rippy"},
|
||||||
|
])
|
||||||
|
assert pfad_map_zeile(vorschlaege) == "/app/media/rippy=\\\\nas\\rippy"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pfad_map_zeile_ohne_mounts_ist_leer():
|
||||||
|
assert pfad_map_zeile([]) == ""
|
||||||
|
assert pfad_map_zeile(None) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_erzeugtes_mapping_uebersetzt_den_echten_fehlerfall(monkeypatch):
|
||||||
|
"""Gegenprobe mit dem Pfad, an dem es am 26.07.2026 live scheiterte.
|
||||||
|
|
||||||
|
Der Rohschnitt lag auf `/app/media/rippy/95afdc89-…/title_t00.mkv`; der
|
||||||
|
Windows-Worker sah dort nichts. Mit dem hier erzeugten Mapping muss
|
||||||
|
genau dieser Pfad auf die Freigabe zeigen. `pfad_lokal` ist eine reine
|
||||||
|
Funktion im Worker — hier nachgebaut aufzurufen wäre wertlos, deshalb
|
||||||
|
wird sie über den Pfad importiert.
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
|
||||||
|
import mounts
|
||||||
|
|
||||||
|
monkeypatch.setattr(mounts, "ist_gemountet", lambda name: True)
|
||||||
|
mapping = pfad_map_zeile(pfad_map_vorschlag([
|
||||||
|
{"name": "rippy", "typ": "cifs", "quelle": "//192.168.178.62/rippy"},
|
||||||
|
]))
|
||||||
|
|
||||||
|
# Der Worker liegt neben der API im Repo; kein geteiltes Paket zwischen
|
||||||
|
# den Containern, deshalb per Pfad laden statt importieren.
|
||||||
|
worker_tasks = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "worker", "tasks.py"
|
||||||
|
)
|
||||||
|
spec = importlib.util.spec_from_file_location("_worker_tasks_pfad", worker_tasks)
|
||||||
|
quelltext = open(worker_tasks, encoding="utf-8").read()
|
||||||
|
assert "def pfad_lokal" in quelltext and spec is not None
|
||||||
|
|
||||||
|
# pfad_lokal ist bewusst rein und ohne Modul-Zustand — die Funktion aus dem
|
||||||
|
# Quelltext zu holen, ohne tasks.py komplett zu laden (das braucht celery,
|
||||||
|
# db, requests …), geht am ehrlichsten über exec des Funktionsblocks.
|
||||||
|
anfang = quelltext.index("def pfad_lokal")
|
||||||
|
ende = quelltext.index("\nRAW_DIR", anfang)
|
||||||
|
umgebung = {"os": os}
|
||||||
|
exec(compile(quelltext[anfang:ende], worker_tasks, "exec"), umgebung) # noqa: S102
|
||||||
|
pfad_lokal = umgebung["pfad_lokal"]
|
||||||
|
|
||||||
|
assert pfad_lokal("/app/media/rippy/95afdc89/title_t00.mkv", mapping) == (
|
||||||
|
"\\\\192.168.178.62\\rippy\\95afdc89\\title_t00.mkv"
|
||||||
|
)
|
||||||
|
|||||||
@@ -47,3 +47,96 @@ def test_omdb_jahr_parsing():
|
|||||||
assert parse_year("2005–2012") == 2005 # Serien-Zeitspanne
|
assert parse_year("2005–2012") == 2005 # Serien-Zeitspanne
|
||||||
assert parse_year("N/A") is None
|
assert parse_year("N/A") is None
|
||||||
assert parse_year("") is None
|
assert parse_year("") is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Reihenfolge der Metadaten-Quellen: exakt schlägt unscharf ---------------
|
||||||
|
#
|
||||||
|
# Änderung vom 26.07.2026. Vorher galt der erste Jikan-Treffer über dem Gate
|
||||||
|
# 0,55 sofort als sicherer Fund, und OMDb kam nie dran — ein Kinofilm, den TMDB
|
||||||
|
# verpasst, bekam damit Anime-Metadaten. Die Messwerte dazu stehen in
|
||||||
|
# test_jikan_helpers.py („Alien" gegen „Alien 9" = 0,83).
|
||||||
|
|
||||||
|
|
||||||
|
class _StummerClient:
|
||||||
|
"""Ein Client, der auf jede Frage dasselbe antwortet — und mitzählt."""
|
||||||
|
|
||||||
|
def __init__(self, antwort=None):
|
||||||
|
self.antwort = antwort
|
||||||
|
self.aufrufe = 0
|
||||||
|
|
||||||
|
def lookup(self, *a, **k):
|
||||||
|
self.aufrufe += 1
|
||||||
|
return self.antwort
|
||||||
|
|
||||||
|
def search_movie(self, *a, **k):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def search_tv(self, *a, **k):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_movie_details(self, *a, **k):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def find_by_imdb(self, *a, **k):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _scanner(monkeypatch, jikan_antwort, omdb_antwort):
|
||||||
|
"""PreScan ohne __init__ (das bräuchte DB + API-Keys), Cache stillgelegt."""
|
||||||
|
from prescan import prescan as modul
|
||||||
|
|
||||||
|
monkeypatch.setattr(modul, "get", lambda k: None)
|
||||||
|
monkeypatch.setattr(modul, "cache_set", lambda *a, **k: None)
|
||||||
|
|
||||||
|
scan = modul.PreScan.__new__(modul.PreScan)
|
||||||
|
scan.tmdb = _StummerClient()
|
||||||
|
scan.jikan = _StummerClient(jikan_antwort)
|
||||||
|
scan.omdb = _StummerClient(omdb_antwort)
|
||||||
|
return scan
|
||||||
|
|
||||||
|
|
||||||
|
def _jikan(score):
|
||||||
|
return {"type": "movie", "id": "mal-1", "title": "Alien 9",
|
||||||
|
"source": "jikan", "match_score": score}
|
||||||
|
|
||||||
|
|
||||||
|
def _omdb():
|
||||||
|
return {"type": "movie", "id": "tt0078748", "title": "Alien",
|
||||||
|
"source": "omdb", "year": 1979}
|
||||||
|
|
||||||
|
|
||||||
|
def test_unscharfer_jikan_treffer_laesst_omdb_zum_zug(monkeypatch):
|
||||||
|
"""Der eigentliche Fehler: „Alien" traf mit 0,83 auf den Anime „Alien 9",
|
||||||
|
und der Film wurde nie gesucht."""
|
||||||
|
scan = _scanner(monkeypatch, _jikan(0.83), _omdb())
|
||||||
|
ergebnis = scan._scan_video("/dev/sr0", {"title": "Alien", "disc_type": "Blu-ray"})
|
||||||
|
assert ergebnis.metadata["source"] == "omdb"
|
||||||
|
assert ergebnis.confidence == 0.8
|
||||||
|
assert scan.omdb.aufrufe > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_exakter_jikan_treffer_gewinnt_sofort(monkeypatch):
|
||||||
|
"""Der Fall, für den Jikan gebaut wurde, darf NICHT verlorengehen
|
||||||
|
(AGENTS Regel B): Bei einem sicheren Treffer wird OMDb nicht mehr gefragt."""
|
||||||
|
scan = _scanner(monkeypatch, _jikan(1.0), _omdb())
|
||||||
|
ergebnis = scan._scan_video("/dev/sr0", {"title": "Alien 9", "disc_type": "Blu-ray"})
|
||||||
|
assert ergebnis.metadata["source"] == "jikan"
|
||||||
|
assert ergebnis.confidence == 0.85
|
||||||
|
assert scan.omdb.aufrufe == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_unscharfer_jikan_treffer_bleibt_als_vorschlag(monkeypatch):
|
||||||
|
"""Hat OMDb nichts (kein Key, kein Treffer), ist der unscharfe Jikan-Treffer
|
||||||
|
besser als nichts — aber er wird als VORSCHLAG ausgewiesen (0,6), nicht als
|
||||||
|
sicherer Fund."""
|
||||||
|
scan = _scanner(monkeypatch, _jikan(0.83), None)
|
||||||
|
ergebnis = scan._scan_video("/dev/sr0", {"title": "Alien", "disc_type": "Blu-ray"})
|
||||||
|
assert ergebnis.metadata["source"] == "jikan"
|
||||||
|
assert ergebnis.confidence == 0.6
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_jede_quelle_bleibt_es_unbekannt(monkeypatch):
|
||||||
|
scan = _scanner(monkeypatch, None, None)
|
||||||
|
ergebnis = scan._scan_video("/dev/sr0", {"title": "Nix Da", "disc_type": "DVD"})
|
||||||
|
assert ergebnis.confidence == 0.0
|
||||||
|
assert ergebnis.metadata == {}
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
"""Tests der Preset-Auswahl — reine Funktionen, keine Infrastruktur.
|
||||||
|
|
||||||
|
Die Preset-Namen in den Testdaten sind ECHT: `HandBrakeCLI --preset-list` im
|
||||||
|
Worker-Image der Rippy-VM, 26.07.2026, HandBrake 1.6.1 (AGENTS Regel D).
|
||||||
|
Erfundene Namen hätten hier keinen Wert — genau daran ist das Projekt zweimal
|
||||||
|
gescheitert.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import presets
|
||||||
|
|
||||||
|
# Ausschnitt der echten Liste: die Kategorien, aus denen Rippy wählt.
|
||||||
|
PRESETS_ECHT = [
|
||||||
|
# General/
|
||||||
|
"Very Fast 2160p60 4K AV1", "Very Fast 1080p30",
|
||||||
|
"HQ 2160p60 4K HEVC Surround", "HQ 1080p30 Surround", "HQ 576p25 Surround",
|
||||||
|
"Super HQ 2160p60 4K HEVC Surround", "Super HQ 1080p30 Surround",
|
||||||
|
# Matroska/
|
||||||
|
"AV1 MKV 2160p60 4K",
|
||||||
|
"H.265 MKV 2160p60 4K", "H.265 MKV 1080p30", "H.265 MKV 576p25",
|
||||||
|
"H.265 MKV 480p30", "H.264 MKV 1080p30",
|
||||||
|
# Hardware/ — steht auch auf Maschinen OHNE Hardware-Encoder in der Liste
|
||||||
|
"AV1 QSV 2160p 4K",
|
||||||
|
"H.265 NVENC 2160p 4K", "H.265 NVENC 1080p",
|
||||||
|
"H.265 QSV 2160p 4K", "H.265 QSV 1080p",
|
||||||
|
"H.265 VCN 2160p 4K", "H.265 VCN 1080p",
|
||||||
|
"H.265 MF 2160p 4K", "H.265 MF 1080p",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def worker(name="w", encoders=None, simd="", presets_liste=None):
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"encoders": encoders if encoders is not None else ["cpu-x264", "cpu-x265"],
|
||||||
|
"info": {
|
||||||
|
"cpu_simd": simd,
|
||||||
|
"presets": presets_liste if presets_liste is not None else PRESETS_ECHT,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Die Rippy-VM: sse4_2, 4 Kerne, kein Hardware-Encoder --------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_vm_ohne_avx2_bekommt_4k_verlustfrei():
|
||||||
|
"""Gemessen: 4K-HEVC in Software = 28-55 h auf dieser Maschine. Die
|
||||||
|
Empfehlung muss deshalb „nicht komprimieren" lauten, nicht ein 4K-Preset."""
|
||||||
|
w = [worker(simd="sse4_2")]
|
||||||
|
e = presets.empfehlung("uhd", w)
|
||||||
|
assert e["preset"] == presets.PRESET_KEINE
|
||||||
|
assert "28-55 Stunden" in e["grund"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_vm_ohne_avx2_bekommt_h264_fuer_bluray_und_dvd():
|
||||||
|
w = [worker(simd="sse4_2")]
|
||||||
|
assert presets.empfehlung("bluray", w)["preset"] == "HQ 1080p30 Surround"
|
||||||
|
assert presets.empfehlung("dvd", w)["preset"] == "HQ 576p25 Surround"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Der PC des Commanders: avx512f, 16 Kerne, RX 9070 XT -------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_hardware_av1_wird_bevorzugt_wenn_die_familie_gemeldet_ist():
|
||||||
|
"""Commander-Vorgabe: „wenn der Worker AV1 oder noch besseres kann, immer
|
||||||
|
diesem empfehlen". QSV meldet AV1, also muss das AV1-Preset kommen."""
|
||||||
|
w = [worker(encoders=["cpu-x265", "qsv", "qsv-av1"], simd="avx512f")]
|
||||||
|
assert presets.empfehlung("uhd", w)["preset"] == "AV1 QSV 2160p 4K"
|
||||||
|
|
||||||
|
|
||||||
|
def test_amd_bekommt_das_amd_preset_nicht_das_intel():
|
||||||
|
"""Der Encoder heißt `vce`, das Preset heißt `VCN` — ohne diese Zuordnung
|
||||||
|
liefe die RX 9070 XT des Commanders unter einem Intel-Namen."""
|
||||||
|
w = [worker(encoders=["cpu-x265", "vce"], simd="avx512f")]
|
||||||
|
assert presets.empfehlung("uhd", w)["preset"] == "H.265 VCN 2160p 4K"
|
||||||
|
assert presets.empfehlung("bluray", w)["preset"] == "H.265 VCN 1080p"
|
||||||
|
|
||||||
|
|
||||||
|
def test_hardware_grund_nennt_den_nachteil_auch():
|
||||||
|
w = [worker(encoders=["vce"], simd="avx512f")]
|
||||||
|
grund = presets.empfehlung("uhd", w)["grund"]
|
||||||
|
assert "VCN" in grund
|
||||||
|
assert "sauberer" in grund # ehrlich: Software hätte etwas mehr Qualität
|
||||||
|
|
||||||
|
|
||||||
|
def test_starke_cpu_ohne_hardware_bekommt_h265():
|
||||||
|
w = [worker(encoders=["cpu-x264", "cpu-x265"], simd="avx2")]
|
||||||
|
assert presets.empfehlung("uhd", w)["preset"] == "H.265 MKV 2160p60 4K"
|
||||||
|
assert presets.empfehlung("bluray", w)["preset"] == "H.265 MKV 1080p30"
|
||||||
|
assert presets.empfehlung("dvd", w)["preset"] == "H.265 MKV 576p25"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dvd_bekommt_nie_ein_hardware_preset():
|
||||||
|
"""HandBrake 1.6.1 hat keine Hardware-Presets in DVD-Auflösung — ein
|
||||||
|
erfundenes „H.265 VCN 576p" würde die Kompression scheitern lassen."""
|
||||||
|
w = [worker(encoders=["vce", "qsv", "nvenc"], simd="avx512f")]
|
||||||
|
assert presets.empfehlung("dvd", w)["preset"] == "H.265 MKV 576p25"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Hardware, die KEIN passendes Preset hat --------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_vaapi_faellt_auf_software_zurueck_statt_zu_raten():
|
||||||
|
"""HandBrake 1.6.1 liefert kein VAAPI-Preset mit. Ein VAAPI-Worker darf
|
||||||
|
deshalb keinen Hardware-Namen bekommen (AGENTS Regel D)."""
|
||||||
|
w = [worker(encoders=["cpu-x265", "vaapi"], simd="avx2")]
|
||||||
|
assert presets.empfehlung("uhd", w)["preset"] == "H.265 MKV 2160p60 4K"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mf_wird_nie_empfohlen_steht_aber_zur_wahl():
|
||||||
|
"""Media-Foundation-Presets existieren, aber ob die Maschine sie nutzen
|
||||||
|
kann, ist aus der Encoder-Liste nicht ablesbar — nicht empfehlen, nicht
|
||||||
|
verstecken."""
|
||||||
|
w = [worker(encoders=["cpu-x265"], simd="avx2")]
|
||||||
|
assert "MF" not in presets.empfehlung("uhd", w)["preset"]
|
||||||
|
namen = [a["name"] for a in presets.auswahl("uhd", w)]
|
||||||
|
assert "H.265 MF 2160p 4K" in namen
|
||||||
|
|
||||||
|
|
||||||
|
# --- Nur echte Namen, nie geratene ------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbekannte_handbrake_version_bekommt_keine_erfundene_empfehlung():
|
||||||
|
"""Meldet ein Worker nur Presets, die Rippy nicht kennt, wird ehrlich
|
||||||
|
„selbst wählen" gesagt — statt einen Namen einzustellen, den dieses
|
||||||
|
HandBrake ablehnt."""
|
||||||
|
w = [worker(simd="avx2", presets_liste=["Irgendwas 4711p", "Noch was"])]
|
||||||
|
e = presets.empfehlung("uhd", w)
|
||||||
|
assert e["gefunden"] is False
|
||||||
|
assert e["preset"] == ""
|
||||||
|
assert "selbst" in e["grund"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_worker_greift_der_rueckfall():
|
||||||
|
"""Kein Worker online (oder alter Worker-Stand ohne Preset-Meldung): die
|
||||||
|
Auswahl darf nicht leer sein, sonst ist die Seite unbenutzbar."""
|
||||||
|
assert presets.verfuegbare_presets([]) == list(presets.RUECKFALL_PRESETS)
|
||||||
|
assert presets.uebersicht([])["quelle"] == "rueckfall"
|
||||||
|
# ... und der Rückfall muss selbst brauchbar sein:
|
||||||
|
e = presets.empfehlung("bluray", [])
|
||||||
|
assert e["gefunden"] is True
|
||||||
|
assert e["preset"] in presets.RUECKFALL_PRESETS
|
||||||
|
|
||||||
|
|
||||||
|
def test_quelle_ist_worker_wenn_einer_meldet():
|
||||||
|
assert presets.uebersicht([worker(simd="avx2")])["quelle"] == "worker"
|
||||||
|
|
||||||
|
|
||||||
|
def test_verfuegbare_presets_vereinigt_ohne_doppelte():
|
||||||
|
a = worker("a", presets_liste=["H.265 MKV 1080p30", "HQ 1080p30 Surround"])
|
||||||
|
b = worker("b", presets_liste=["H.265 MKV 1080p30", "H.265 NVENC 1080p"])
|
||||||
|
assert presets.verfuegbare_presets([a, b]) == [
|
||||||
|
"H.265 MKV 1080p30", "HQ 1080p30 Surround", "H.265 NVENC 1080p",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Auswahl-Listen: die 4K-Falle aus v3.12 darf nicht wiederkommen ---------
|
||||||
|
|
||||||
|
|
||||||
|
def test_4k_auswahl_zeigt_2160p_presets_zuerst():
|
||||||
|
w = [worker(simd="avx2")]
|
||||||
|
liste = presets.auswahl("uhd", w)
|
||||||
|
zuerst = [a["name"] for a in liste if not a["verkleinert"]]
|
||||||
|
assert all("2160p" in n for n in zuerst)
|
||||||
|
assert "H.265 MKV 2160p60 4K" in zuerst
|
||||||
|
|
||||||
|
|
||||||
|
def test_4k_auswahl_kennzeichnet_das_verkleinern():
|
||||||
|
"""Ein 1080p-Preset für eine 4K-Disc ist eine legitime Wahl, aber eine
|
||||||
|
BEWUSSTE — in v3.12 passierte es aus Versehen und die 4K-Auflösung war weg."""
|
||||||
|
w = [worker(simd="avx2")]
|
||||||
|
verkleinert = [a["name"] for a in presets.auswahl("uhd", w) if a["verkleinert"]]
|
||||||
|
assert "H.265 MKV 1080p30" in verkleinert
|
||||||
|
assert all("1080p" in n for n in verkleinert)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dvd_auswahl_nimmt_beide_pal_und_ntsc_aufloesungen():
|
||||||
|
w = [worker(simd="avx2")]
|
||||||
|
namen = [a["name"] for a in presets.auswahl("dvd", w)]
|
||||||
|
assert "H.265 MKV 576p25" in namen # PAL
|
||||||
|
assert "H.265 MKV 480p30" in namen # NTSC
|
||||||
|
assert "H.265 MKV 1080p30" not in namen
|
||||||
|
|
||||||
|
|
||||||
|
def test_bluray_auswahl_enthaelt_kein_4k():
|
||||||
|
w = [worker(simd="avx2")]
|
||||||
|
namen = [a["name"] for a in presets.auswahl("bluray", w)]
|
||||||
|
assert namen
|
||||||
|
assert not any("2160p" in n for n in namen)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Erkennung der Maschinen-Stärke ----------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbekannte_simd_stufe_gilt_nicht_als_schnell_und_nicht_als_langsam():
|
||||||
|
"""Ein Windows-Worker ohne die Erkennung aus v3.16 meldet `unbekannt`. Er
|
||||||
|
darf nichts behaupten — aber auch keinen schnellen Worker ausbremsen."""
|
||||||
|
assert presets.simd_schnell([worker(simd="unbekannt")]) is False
|
||||||
|
assert presets.simd_schnell([worker(simd="unbekannt"), worker(simd="avx2")]) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_hardware_kuerzel_nur_bei_echter_meldung():
|
||||||
|
assert presets.hardware_kuerzel([worker(encoders=["cpu-x265"])]) == []
|
||||||
|
assert presets.hardware_kuerzel([worker(encoders=["nvenc", "nvenc-av1"])]) == ["NVENC"]
|
||||||
|
# Reihenfolge ist die Vorzugsreihenfolge, nicht die Meldereihenfolge
|
||||||
|
assert presets.hardware_kuerzel([worker(encoders=["vce", "qsv"])]) == ["QSV", "VCN"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_uebersicht_liefert_alle_drei_disc_typen():
|
||||||
|
u = presets.uebersicht([worker(simd="avx2")])
|
||||||
|
assert set(u["typen"]) == {"uhd", "bluray", "dvd"}
|
||||||
|
for eintrag in u["typen"].values():
|
||||||
|
assert eintrag["empfehlung"]["preset"]
|
||||||
|
assert eintrag["auswahl"]
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ReactNode } from 'react'
|
||||||
import { AlertTriangle, CheckCircle2, HelpCircle } from 'lucide-react'
|
import { AlertTriangle, CheckCircle2, HelpCircle } from 'lucide-react'
|
||||||
import { Modal } from './ui/Modal'
|
import { Modal } from './ui/Modal'
|
||||||
import { Button } from './ui/Button'
|
import { Button } from './ui/Button'
|
||||||
@@ -8,6 +9,9 @@ interface ConfirmDialogProps {
|
|||||||
onConfirm: () => void
|
onConfirm: () => void
|
||||||
title: string
|
title: string
|
||||||
message: string
|
message: string
|
||||||
|
// Zusatz unter der Meldung — z. B. ein Kasten mit der Größe der Rohdaten,
|
||||||
|
// die beim Entfernen liegen bleiben, samt Wahl „mitlöschen".
|
||||||
|
extra?: ReactNode
|
||||||
confirmText?: string
|
confirmText?: string
|
||||||
cancelText?: string
|
cancelText?: string
|
||||||
type?: 'warning' | 'info' | 'success'
|
type?: 'warning' | 'info' | 'success'
|
||||||
@@ -19,6 +23,7 @@ export default function ConfirmDialog({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
|
extra,
|
||||||
confirmText = 'Bestätigen',
|
confirmText = 'Bestätigen',
|
||||||
cancelText = 'Abbrechen',
|
cancelText = 'Abbrechen',
|
||||||
type = 'info',
|
type = 'info',
|
||||||
@@ -57,6 +62,7 @@ export default function ConfirmDialog({
|
|||||||
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
|
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
|
||||||
{message}
|
{message}
|
||||||
</p>
|
</p>
|
||||||
|
{extra}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -37,24 +37,32 @@ const ENCODER_LABELS: Record<string, string> = {
|
|||||||
// braucht roh rund 40 GB, eine 4K-UHD bis 100 GB.
|
// braucht roh rund 40 GB, eine 4K-UHD bis 100 GB.
|
||||||
const PLATZ_WARNUNG_GB = 60
|
const PLATZ_WARNUNG_GB = 60
|
||||||
|
|
||||||
/** Was beim Abschliessen gespeichert wird — abhängig davon, was die Maschine kann. */
|
// Antwort von GET /presets — dieselbe Quelle, die auch die Einstellungen-Seite
|
||||||
function presetsFuer(schwach: boolean) {
|
// benutzt. Bis 26.07.2026 entschied der Wizard hier selbst und mit fest
|
||||||
if (schwach) {
|
// verdrahteten Namen; jetzt kommt beides aus api/presets.py, wo es Tests hat.
|
||||||
// Gemessen auf einer CPU ohne AVX2: 4K-HEVC = 28-55 h pro Film. Also 4K
|
interface PresetUebersicht {
|
||||||
// verlustfrei behalten und für den Rest H.264 nehmen (deutlich schneller
|
quelle: 'worker' | 'rueckfall'
|
||||||
// als H.265, dafür etwas größer).
|
hardware: string[]
|
||||||
return {
|
typen: Record<'uhd' | 'bluray' | 'dvd', {
|
||||||
transcodePreset: 'HQ 1080p30 Surround',
|
empfehlung: { preset: string, grund: string, gefunden: boolean }
|
||||||
transcodePresetBluray: 'HQ 1080p30 Surround',
|
}>
|
||||||
transcodePresetDvd: 'HQ 576p25 Surround',
|
}
|
||||||
transcodePresetUhd: PRESET_KEINE,
|
|
||||||
}
|
/** Was beim Abschliessen gespeichert wird — die Empfehlung der API. */
|
||||||
|
function presetsFuer(uebersicht: PresetUebersicht | null) {
|
||||||
|
const nimm = (typ: 'uhd' | 'bluray' | 'dvd') => {
|
||||||
|
const e = uebersicht?.typen?.[typ]?.empfehlung
|
||||||
|
return e?.gefunden ? e.preset : ''
|
||||||
}
|
}
|
||||||
|
const uhd = nimm('uhd')
|
||||||
|
const bluray = nimm('bluray')
|
||||||
|
const dvd = nimm('dvd')
|
||||||
|
// Leere Werte gar nicht schreiben: dann bleibt der Bestandswert stehen,
|
||||||
|
// statt dass HandBrake später ein leeres --preset bekommt.
|
||||||
return {
|
return {
|
||||||
transcodePreset: 'H.265 MKV 1080p30',
|
...(bluray ? { transcodePreset: bluray, transcodePresetBluray: bluray } : {}),
|
||||||
transcodePresetBluray: 'H.265 MKV 1080p30',
|
...(dvd ? { transcodePresetDvd: dvd } : {}),
|
||||||
transcodePresetDvd: 'H.265 MKV 576p25',
|
...(uhd ? { transcodePresetUhd: uhd } : {}),
|
||||||
transcodePresetUhd: 'H.265 MKV 2160p60 4K',
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +74,7 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
|||||||
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||||||
const [geraete, setGeraete] = useState<GeraetInfo[]>([])
|
const [geraete, setGeraete] = useState<GeraetInfo[]>([])
|
||||||
const [plaetze, setPlaetze] = useState<PlatzInfo[]>([])
|
const [plaetze, setPlaetze] = useState<PlatzInfo[]>([])
|
||||||
|
const [presetInfo, setPresetInfo] = useState<PresetUebersicht | null>(null)
|
||||||
const [geladen, setGeladen] = useState(false)
|
const [geladen, setGeladen] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -77,15 +86,17 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let laeuft = true
|
let laeuft = true
|
||||||
const laden = async () => {
|
const laden = async () => {
|
||||||
const [caps, devs, info] = await Promise.allSettled([
|
const [caps, devs, info, pres] = await Promise.allSettled([
|
||||||
api.get('/capabilities'),
|
api.get('/capabilities'),
|
||||||
api.get('/devices'),
|
api.get('/devices'),
|
||||||
api.get('/system/info'),
|
api.get('/system/info'),
|
||||||
|
api.get('/presets'),
|
||||||
])
|
])
|
||||||
if (!laeuft) return
|
if (!laeuft) return
|
||||||
if (caps.status === 'fulfilled') setWorkers(caps.value.data?.workers || [])
|
if (caps.status === 'fulfilled') setWorkers(caps.value.data?.workers || [])
|
||||||
if (devs.status === 'fulfilled') setGeraete(devs.value.data || [])
|
if (devs.status === 'fulfilled') setGeraete(devs.value.data || [])
|
||||||
if (info.status === 'fulfilled') setPlaetze(info.value.data?.plaetze || [])
|
if (info.status === 'fulfilled') setPlaetze(info.value.data?.plaetze || [])
|
||||||
|
if (pres.status === 'fulfilled') setPresetInfo(pres.value.data || null)
|
||||||
setGeladen(true)
|
setGeladen(true)
|
||||||
}
|
}
|
||||||
laden()
|
laden()
|
||||||
@@ -94,7 +105,7 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const schwach = schwacheEncoderCpu(workers)
|
const schwach = schwacheEncoderCpu(workers)
|
||||||
const presets = presetsFuer(schwach)
|
const presets = presetsFuer(presetInfo)
|
||||||
const encoderWorker = workers.filter(w => w.encoders.length > 0)
|
const encoderWorker = workers.filter(w => w.encoders.length > 0)
|
||||||
const platz = plaetze.length > 0 ? Math.min(...plaetze.map(p => p.frei_gb)) : null
|
const platz = plaetze.length > 0 ? Math.min(...plaetze.map(p => p.frei_gb)) : null
|
||||||
|
|
||||||
@@ -306,6 +317,10 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
|||||||
{' '}dauern. Deshalb wird eingestellt:
|
{' '}dauern. Deshalb wird eingestellt:
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
|
) : presetInfo && presetInfo.hardware.length > 0 ? (
|
||||||
|
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
|
Hardware-Encoder gemeldet ({presetInfo.hardware.join(', ')}) — Rippy nimmt ihn:
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
Deine Worker sind schnell genug für H.265 — Rippy stellt ein:
|
Deine Worker sind schnell genug für H.265 — Rippy stellt ein:
|
||||||
@@ -316,11 +331,22 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
|||||||
<strong>4K-UHD:</strong>{' '}
|
<strong>4K-UHD:</strong>{' '}
|
||||||
{presets.transcodePresetUhd === PRESET_KEINE
|
{presets.transcodePresetUhd === PRESET_KEINE
|
||||||
? 'nicht komprimieren, verlustfrei behalten (20–100 GB je Film)'
|
? 'nicht komprimieren, verlustfrei behalten (20–100 GB je Film)'
|
||||||
: presets.transcodePresetUhd}
|
: presets.transcodePresetUhd || 'unverändert (kein bekanntes Preset gemeldet)'}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Blu-ray:</strong>{' '}
|
||||||
|
{presets.transcodePresetBluray || 'unverändert (kein bekanntes Preset gemeldet)'}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>DVD:</strong>{' '}
|
||||||
|
{presets.transcodePresetDvd || 'unverändert (kein bekanntes Preset gemeldet)'}
|
||||||
</li>
|
</li>
|
||||||
<li><strong>Blu-ray:</strong> {presets.transcodePresetBluray}</li>
|
|
||||||
<li><strong>DVD:</strong> {presets.transcodePresetDvd}</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
{presetInfo?.typen?.uhd?.empfehlung?.grund && (
|
||||||
|
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Warum: {presetInfo.typen.uhd.empfehlung.grund}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
|
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
|
||||||
Alles einzeln änderbar unter Einstellungen → Verarbeitung.
|
Alles einzeln änderbar unter Einstellungen → Verarbeitung.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Folder, FolderOpen, File, ArrowUp, CheckCircle, Film, Tv, Music, Cpu, HardDrive } from 'lucide-react'
|
import { Folder, FolderOpen, ArrowUp, CheckCircle, Film, Tv, Music, Cpu, HardDrive, ChevronRight, ChevronDown } from 'lucide-react'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { Modal } from './ui/Modal'
|
import { Modal } from './ui/Modal'
|
||||||
import { Button } from './ui/Button'
|
import { Button } from './ui/Button'
|
||||||
import { Input, Select } from './ui/Input'
|
import { Input, Select } from './ui/Input'
|
||||||
|
import { automatikWarnung, externWarnung } from '../lib/encoder'
|
||||||
|
|
||||||
interface TargetConfig {
|
interface TargetConfig {
|
||||||
id: string
|
id: string
|
||||||
@@ -35,6 +36,10 @@ interface WorkerWahl {
|
|||||||
node: string | null
|
node: string | null
|
||||||
online?: boolean
|
online?: boolean
|
||||||
encoders: string[]
|
encoders: string[]
|
||||||
|
// extern: 'ja' = läuft AUSSERHALB des Rippy-Containers und erreicht die
|
||||||
|
// Container-Pfade nur über eine Freigabe plus pfad_map. Der Worker meldet
|
||||||
|
// beides selbst (worker/caps.py).
|
||||||
|
info?: { extern?: string, pfad_map?: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
const ENCODER_KURZ: Record<string, string> = {
|
const ENCODER_KURZ: Record<string, string> = {
|
||||||
@@ -120,8 +125,16 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
const [browsePath, setBrowsePath] = useState<string>('/app/media')
|
const [browsePath, setBrowsePath] = useState<string>('/app/media')
|
||||||
const [browseParent, setBrowseParent] = useState<string | null>(null)
|
const [browseParent, setBrowseParent] = useState<string | null>(null)
|
||||||
const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([])
|
const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([])
|
||||||
const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([])
|
|
||||||
const [customPath, setCustomPath] = useState('')
|
const [customPath, setCustomPath] = useState('')
|
||||||
|
// Der Ordner-Browser ist EINGEKLAPPT (Commander 26.07.2026: „Warum wird hier
|
||||||
|
// der Datei Browser noch angezeigt — das ist doch quatsch"). Er ist nicht
|
||||||
|
// wirklich redundant: nur über ihn lässt sich für DIESEN einen Rip ein
|
||||||
|
// beliebiger Zielordner wählen. Aber der Normalfall ist die Schnellwahl
|
||||||
|
// darüber, und der Browser überschrieb sie stillschweigend, sobald man auf
|
||||||
|
// „Diesen Ordner nutzen" klickte. Jetzt muss man ihn aufklappen — dann ist
|
||||||
|
// die Wahl bewusst. Nebenbefund beim Aufräumen: `browseFiles` wurde geladen
|
||||||
|
// und NIE angezeigt (samt ungenutztem File-Icon) — beides entfernt.
|
||||||
|
const [browserOffen, setBrowserOffen] = useState(false)
|
||||||
// Arbeitsverzeichnis dieses Rips (Commander-Wunsch 25.07.2026: hier wählbar,
|
// Arbeitsverzeichnis dieses Rips (Commander-Wunsch 25.07.2026: hier wählbar,
|
||||||
// nicht global vorgegeben). '' = der Wert aus den Einstellungen, der auch
|
// nicht global vorgegeben). '' = der Wert aus den Einstellungen, der auch
|
||||||
// bei Vollautomatik-Rips gilt, weil dort niemand gefragt wird.
|
// bei Vollautomatik-Rips gilt, weil dort niemand gefragt wird.
|
||||||
@@ -141,6 +154,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
setGewaehlt(new Set())
|
setGewaehlt(new Set())
|
||||||
setEncoderNode('')
|
setEncoderNode('')
|
||||||
setArbeitsDir('')
|
setArbeitsDir('')
|
||||||
|
setBrowserOffen(false)
|
||||||
api.get('/storage-targets')
|
api.get('/storage-targets')
|
||||||
.then(r => setArbeitsZiele(Array.isArray(r.data) ? r.data : []))
|
.then(r => setArbeitsZiele(Array.isArray(r.data) ? r.data : []))
|
||||||
.catch(() => setArbeitsZiele([]))
|
.catch(() => setArbeitsZiele([]))
|
||||||
@@ -159,7 +173,8 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
{ id: '3', name: 'Musik', path: `${basis}/${s.musicDir || 'music'}`, type: 'music', isActive: true },
|
{ id: '3', name: 'Musik', path: `${basis}/${s.musicDir || 'music'}`, type: 'music', isActive: true },
|
||||||
])
|
])
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
laden('/app/media')
|
// /browse wird erst beim Aufklappen geholt — der Dialog braucht es im
|
||||||
|
// Normalfall gar nicht.
|
||||||
}, [isOpen])
|
}, [isOpen])
|
||||||
|
|
||||||
const laden = async (pfad: string) => {
|
const laden = async (pfad: string) => {
|
||||||
@@ -168,13 +183,28 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
setBrowsePath(r.data.path)
|
setBrowsePath(r.data.path)
|
||||||
setBrowseParent(r.data.parent)
|
setBrowseParent(r.data.parent)
|
||||||
setBrowseDirs(r.data.dirs)
|
setBrowseDirs(r.data.dirs)
|
||||||
setBrowseFiles(r.data.files || [])
|
|
||||||
} catch {
|
} catch {
|
||||||
setBrowseDirs([])
|
setBrowseDirs([])
|
||||||
setBrowseFiles([])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Der effektive Zielpfad dieses Rips — Schnellwahl oder eigener Ordner.
|
||||||
|
const zielPfad = customPath || targets.find(t => t.type === selectedType)?.path || ''
|
||||||
|
// Das effektive Arbeitsverzeichnis: Wahl für diesen Rip → Einstellung →
|
||||||
|
// leer (= Container-Platte /app/temp, für externe Worker unerreichbar).
|
||||||
|
const arbeitsPfadEffektiv = arbeitsDir || standardArbeitsDir
|
||||||
|
// Warnung VOR dem Start, wenn der gewählte externe Encoder die Pfade nicht
|
||||||
|
// erreicht (Punkt 6 des Savepoints v3.16). Am 26.07.2026 fiel genau das erst
|
||||||
|
// NACH dem Rip auf, weil nur der Worker selbst prüfte.
|
||||||
|
const gewaehlterWorker = workers.find(w => w.node === encoderNode)
|
||||||
|
const pfadWarnung = selectedType === 'music'
|
||||||
|
? null
|
||||||
|
: encoderNode
|
||||||
|
? externWarnung(gewaehlterWorker, zielPfad, arbeitsPfadEffektiv)
|
||||||
|
// „Automatisch": die geteilte Queue nimmt den ersten freien Worker — auch
|
||||||
|
// einen, der die Pfade nicht erreicht.
|
||||||
|
: automatikWarnung(workers, zielPfad, arbeitsPfadEffektiv)
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
const optionen: RipOptionen = { mainFeatureOnly: nurHauptfilm }
|
const optionen: RipOptionen = { mainFeatureOnly: nurHauptfilm }
|
||||||
if (selectedType === 'series' && serienName.trim()) {
|
if (selectedType === 'series' && serienName.trim()) {
|
||||||
@@ -325,6 +355,16 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Die Warnung, die am 26.07.2026 gefehlt hat: Der gewählte externe
|
||||||
|
Encoder nahm die Aufgabe an und lehnte sie 182 ms später ab, weil er
|
||||||
|
die Container-Pfade nicht sieht. Sichtbar war das erst NACH dem Rip. */}
|
||||||
|
{pfadWarnung && (
|
||||||
|
<div className="text-xs p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-700 dark:text-amber-300">
|
||||||
|
<strong>⚠️ Dieser Encoder kann so nicht arbeiten.</strong>
|
||||||
|
<p className="mt-1">{pfadWarnung}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Titel-Auswahl (Track-Tabelle) */}
|
{/* Titel-Auswahl (Track-Tabelle) */}
|
||||||
{deviceId && selectedType !== 'music' && (
|
{deviceId && selectedType !== 'music' && (
|
||||||
<div className="rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-50/50 dark:bg-slate-950/50">
|
<div className="rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-50/50 dark:bg-slate-950/50">
|
||||||
@@ -390,8 +430,22 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Ordner-Browser */}
|
{/* Ordner-Browser — eingeklappt, siehe Begründung bei browserOffen */}
|
||||||
<div className="rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-50/50 dark:bg-slate-950/50">
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const auf = !browserOffen
|
||||||
|
setBrowserOffen(auf)
|
||||||
|
if (auf && browseDirs.length === 0) laden('/app/media')
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1.5 text-xs text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200"
|
||||||
|
>
|
||||||
|
{browserOffen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
|
Anderen Zielordner wählen (nur für diesen Rip)
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{browserOffen && (
|
||||||
|
<div className="mt-2 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-50/50 dark:bg-slate-950/50">
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-slate-200 dark:border-slate-800 bg-slate-100/50 dark:bg-slate-900/50">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-slate-200 dark:border-slate-800 bg-slate-100/50 dark:bg-slate-900/50">
|
||||||
<button
|
<button
|
||||||
onClick={() => browseParent && laden(browseParent)}
|
onClick={() => browseParent && laden(browseParent)}
|
||||||
@@ -405,7 +459,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
<Button
|
<Button
|
||||||
variant={customPath === browsePath ? 'amber' : 'secondary'}
|
variant={customPath === browsePath ? 'amber' : 'secondary'}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setCustomPath(browsePath)}
|
onClick={() => setCustomPath(customPath === browsePath ? '' : browsePath)}
|
||||||
>
|
>
|
||||||
{customPath === browsePath ? '✓ gewählt' : 'Diesen Ordner nutzen'}
|
{customPath === browsePath ? '✓ gewählt' : 'Diesen Ordner nutzen'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -421,14 +475,19 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
{d.name}
|
{d.name}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
{browseDirs.length === 0 && (
|
||||||
|
<p className="px-3 py-1.5 text-xs text-slate-400">Keine Unterordner.</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Gewähltes Ziel Display */}
|
{/* Gewähltes Ziel Display */}
|
||||||
<div className="flex items-center gap-2 text-sm px-3.5 py-2.5 rounded-xl bg-slate-100 dark:bg-slate-900 border border-slate-200 dark:border-slate-800">
|
<div className="flex items-center gap-2 text-sm px-3.5 py-2.5 rounded-xl bg-slate-100 dark:bg-slate-900 border border-slate-200 dark:border-slate-800">
|
||||||
{customPath ? <FolderOpen size={16} className="text-amber-500" /> : <CheckCircle size={16} className="text-emerald-500" />}
|
{customPath ? <FolderOpen size={16} className="text-amber-500" /> : <CheckCircle size={16} className="text-emerald-500" />}
|
||||||
<span className="font-mono text-xs truncate text-slate-700 dark:text-slate-300">
|
<span className="font-mono text-xs truncate text-slate-700 dark:text-slate-300">
|
||||||
{customPath || targets.find(t => t.type === selectedType)?.path}
|
{zielPfad}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,91 @@ interface WorkerFuerBewertung {
|
|||||||
info?: { cpu_simd?: string }
|
info?: { cpu_simd?: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Erreicht ein EXTERNER Worker die Pfade? ---------------------------------
|
||||||
|
//
|
||||||
|
// Befund 26.07.2026, live am Job 95afdc89: Das Routing an den Windows-PC des
|
||||||
|
// Commanders funktionierte einwandfrei — der Worker nahm die Aufgabe an und
|
||||||
|
// lehnte sie 182 ms später ab, weil `/app/media/...` Pfade INNERHALB des
|
||||||
|
// Rippy-Containers sind. Ohne RIPPY_PATH_MAP sieht ein externer Worker sie nie.
|
||||||
|
// Auffallen durfte das nicht erst nach einer Stunde Rip, sondern hier.
|
||||||
|
|
||||||
|
interface WorkerFuerPfadPruefung {
|
||||||
|
name: string
|
||||||
|
node?: string | null
|
||||||
|
info?: { extern?: string, pfad_map?: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deckt `RIPPY_PATH_MAP` diesen Container-Pfad ab? Gleiche Regel wie
|
||||||
|
* `pfad_lokal()` im Worker (docker/worker/tasks.py): Paare `quelle=ziel`,
|
||||||
|
* getrennt durch `;`, Treffer per Präfix.
|
||||||
|
*/
|
||||||
|
export function pfadAbgedeckt(pfad: string, mapping: string): boolean {
|
||||||
|
if (!pfad || !mapping) return false
|
||||||
|
return mapping.split(';').some(paar => {
|
||||||
|
const quelle = paar.split('=', 1)[0]
|
||||||
|
return !!quelle && pfad.startsWith(quelle)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warnt, BEVOR gerippt wird, wenn der gewählte Encoder die Pfade nicht
|
||||||
|
* erreichen kann. null = alles in Ordnung (oder der Worker läuft im Container,
|
||||||
|
* dann gibt es nichts zu übersetzen).
|
||||||
|
*
|
||||||
|
* `arbeitsPfad` leer heißt: Container-Standard /app/temp/raw — ein Docker-Volume,
|
||||||
|
* an das ein externer Worker grundsätzlich nicht herankommt.
|
||||||
|
*/
|
||||||
|
export function externWarnung(
|
||||||
|
worker: WorkerFuerPfadPruefung | undefined,
|
||||||
|
zielPfad: string,
|
||||||
|
arbeitsPfad: string,
|
||||||
|
): string | null {
|
||||||
|
if (!worker || worker.info?.extern !== 'ja') return null
|
||||||
|
const mapping = (worker.info?.pfad_map || '').trim()
|
||||||
|
if (!mapping) {
|
||||||
|
return `„${worker.name}" läuft auf einer anderen Maschine und hat keine `
|
||||||
|
+ 'Pfad-Übersetzung (RIPPY_PATH_MAP). Er kann die Rohdaten deshalb nicht '
|
||||||
|
+ 'finden und würde sofort abbrechen. Abhilfe: den Windows-Installer erneut '
|
||||||
|
+ 'ausführen — er holt den Wert jetzt selbst von Rippy. Bis dahin besser '
|
||||||
|
+ '„Automatisch" wählen.'
|
||||||
|
}
|
||||||
|
if (!arbeitsPfad) {
|
||||||
|
return `„${worker.name}" arbeitet auf einer anderen Maschine. Das `
|
||||||
|
+ 'Arbeitsverzeichnis steht auf der Container-Platte (/app/temp) — die ist '
|
||||||
|
+ 'von außen nicht erreichbar. Oben eine Netzwerk-Freigabe als '
|
||||||
|
+ 'Arbeitsverzeichnis wählen.'
|
||||||
|
}
|
||||||
|
const fehlt: string[] = []
|
||||||
|
if (!pfadAbgedeckt(arbeitsPfad, mapping)) fehlt.push(`Rohdaten (${arbeitsPfad})`)
|
||||||
|
if (!pfadAbgedeckt(zielPfad, mapping)) fehlt.push(`Ziel (${zielPfad})`)
|
||||||
|
if (fehlt.length === 0) return null
|
||||||
|
return `„${worker.name}" erreicht ${fehlt.join(' und ')} nicht — seine `
|
||||||
|
+ `Übersetzung deckt nur „${mapping}" ab. Beides muss auf einer Freigabe `
|
||||||
|
+ 'liegen, die diese Maschine sieht, sonst scheitert die Kompression '
|
||||||
|
+ '(die Rohdaten bleiben dabei erhalten).'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dasselbe Risiko bei „Automatisch": dort nimmt die geteilte Queue den ersten
|
||||||
|
* freien Worker — auch einen externen, der die Pfade nicht erreicht. Nennt die
|
||||||
|
* betroffenen Namen, damit man gezielt einen anderen wählt.
|
||||||
|
*/
|
||||||
|
export function automatikWarnung(
|
||||||
|
workers: WorkerFuerPfadPruefung[] | undefined,
|
||||||
|
zielPfad: string,
|
||||||
|
arbeitsPfad: string,
|
||||||
|
): string | null {
|
||||||
|
const betroffen = (workers || [])
|
||||||
|
.filter(w => externWarnung(w, zielPfad, arbeitsPfad) !== null)
|
||||||
|
.map(w => w.name)
|
||||||
|
if (betroffen.length === 0) return null
|
||||||
|
return `Bei „Automatisch" nimmt die Aufgabe der erste freie Worker — das kann `
|
||||||
|
+ `${betroffen.join(', ')} sein, und dort scheitert sie an den Pfaden. `
|
||||||
|
+ 'Wähle oben gezielt eine Maschine, die die Freigabe erreicht — oder rüste '
|
||||||
|
+ 'die Pfad-Übersetzung nach (Windows-Installer erneut ausführen).'
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kann von den gemeldeten Workern KEINER 4K in Software brauchbar schnell
|
* Kann von den gemeldeten Workern KEINER 4K in Software brauchbar schnell
|
||||||
* encodieren? Wahr nur, wenn das auch belegt ist: mindestens ein Worker hat
|
* encodieren? Wahr nur, wenn das auch belegt ist: mindestens ein Worker hat
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ interface Job {
|
|||||||
title?: string
|
title?: string
|
||||||
can_retry?: boolean
|
can_retry?: boolean
|
||||||
meta?: JobMeta | null
|
meta?: JobMeta | null
|
||||||
|
// Restzeit von der API (eta.py). '' = noch keine Aussage möglich — dann wird
|
||||||
|
// bewusst nichts angezeigt statt einer erfundenen Zahl.
|
||||||
|
eta_sekunden?: number
|
||||||
|
eta_text?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SystemInfo {
|
interface SystemInfo {
|
||||||
@@ -39,6 +43,16 @@ interface SystemInfo {
|
|||||||
workers: { name: string; encoders: string[] }[]
|
workers: { name: string; encoders: string[] }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ein Worker aus GET /capabilities — inklusive der ECHTEN Erreichbarkeit.
|
||||||
|
// /system/info liefert nur die registrierten Einträge; die Kachel „N Worker
|
||||||
|
// online" zählte damit auch Leichen aus alten Rebuilds mit (Befund 26.07.2026).
|
||||||
|
interface WorkerLive {
|
||||||
|
name: string
|
||||||
|
encoders: string[]
|
||||||
|
online?: boolean
|
||||||
|
info?: { cpu_modell?: string, cpu_kerne?: string, cpu_simd?: string, extern?: string }
|
||||||
|
}
|
||||||
|
|
||||||
function posterUrl(meta?: JobMeta | null): string | null {
|
function posterUrl(meta?: JobMeta | null): string | null {
|
||||||
const p = meta?.poster_path
|
const p = meta?.poster_path
|
||||||
if (!p) return null
|
if (!p) return null
|
||||||
@@ -60,14 +74,47 @@ export default function Dashboard() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [detailJobId, setDetailJobId] = useState<string | null>(null)
|
const [detailJobId, setDetailJobId] = useState<string | null>(null)
|
||||||
const [aufraeumenOffen, setAufraeumenOffen] = useState(false)
|
const [aufraeumenOffen, setAufraeumenOffen] = useState(false)
|
||||||
|
// Job, dessen Entfernen gerade bestätigt werden soll — plus was daneben liegt.
|
||||||
|
const [loeschKandidat, setLoeschKandidat] = useState<Job | null>(null)
|
||||||
|
const [rohdaten, setRohdaten] = useState<{ gb: number, dateien: number, pfade: string[] } | null>(null)
|
||||||
|
const [rohdatenMitloeschen, setRohdatenMitloeschen] = useState(false)
|
||||||
const [activeTab, setActiveTab] = useState<'all' | 'active' | 'queue' | 'completed' | 'failed'>('all')
|
const [activeTab, setActiveTab] = useState<'all' | 'active' | 'queue' | 'completed' | 'failed'>('all')
|
||||||
const [sparklinePoints, setSparklinePoints] = useState<number[]>([20, 35, 15, 45, 30, 60, 40, 75, 50, 65])
|
const [workersLive, setWorkersLive] = useState<WorkerLive[]>([])
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
|
||||||
const jobLoeschen = async (job: Job) => {
|
/*
|
||||||
|
* Entfernen fragt jetzt ZUERST, was daneben liegen bleibt.
|
||||||
|
*
|
||||||
|
* Grund (Vorfall 25.07.2026, v3.14): „Job aus der Liste entfernen" löscht
|
||||||
|
* bewusst keine Dateien. Das ist richtig — aber Job und Rohdaten sind nur
|
||||||
|
* über die Job-ID verbunden, der Rohschnitt ist danach also UNERREICHBAR:
|
||||||
|
* nichts zeigt mehr darauf, „Neu komprimieren" ist unmöglich, und im UI ist
|
||||||
|
* nichts davon zu sehen. Damals verwaisten so 75 GB unsichtbar auf der
|
||||||
|
* Platte, gefunden erst per SSH.
|
||||||
|
*/
|
||||||
|
const loeschenVorbereiten = async (job: Job) => {
|
||||||
|
setLoeschKandidat(job)
|
||||||
|
setRohdaten(null)
|
||||||
|
setRohdatenMitloeschen(false)
|
||||||
try {
|
try {
|
||||||
await api.delete(`/jobs/${job.id}`)
|
const r = await api.get(`/jobs/${job.id}/rohdaten`)
|
||||||
toast('success', `„${job.title || job.id.slice(0, 8)}" aus der Liste entfernt`)
|
setRohdaten(r.data)
|
||||||
|
} catch {
|
||||||
|
setRohdaten(null) // dann eben ohne Zahl fragen, statt gar nicht
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobLoeschen = async () => {
|
||||||
|
const job = loeschKandidat
|
||||||
|
if (!job) return
|
||||||
|
setLoeschKandidat(null)
|
||||||
|
try {
|
||||||
|
const r = await api.delete(`/jobs/${job.id}`, {
|
||||||
|
params: rohdatenMitloeschen ? { rohdaten: true } : {},
|
||||||
|
})
|
||||||
|
const befreit = r.data?.rohdaten_geloescht_gb
|
||||||
|
toast('success', `„${job.title || job.id.slice(0, 8)}" aus der Liste entfernt`
|
||||||
|
+ (befreit ? ` — ${befreit} GB Rohdaten gelöscht` : ''))
|
||||||
setJobs(await fetchJobs())
|
setJobs(await fetchJobs())
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast('error', e?.response?.data?.detail || 'Entfernen fehlgeschlagen')
|
toast('error', e?.response?.data?.detail || 'Entfernen fehlgeschlagen')
|
||||||
@@ -88,16 +135,18 @@ export default function Dashboard() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
const [jobsData, sysData] = await Promise.all([
|
const [jobsData, sysData, capsData] = await Promise.all([
|
||||||
fetchJobs(),
|
fetchJobs(),
|
||||||
api.get('/system/info').then(r => r.data).catch(() => null),
|
api.get('/system/info').then(r => r.data).catch(() => null),
|
||||||
|
// Für die ECHTE Online-Zahl: /capabilities kennt den Celery-Ping,
|
||||||
|
// /system/info nur die Registrierung. Der Endpunkt ist seit v3.15
|
||||||
|
// schnell (0,003 s, Ping läuft im Hintergrund) — er darf hier also
|
||||||
|
// im 4-Sekunden-Takt mitlaufen.
|
||||||
|
api.get('/capabilities').then(r => r.data?.workers || []).catch(() => []),
|
||||||
])
|
])
|
||||||
setJobs(jobsData)
|
setJobs(jobsData)
|
||||||
if (sysData) setSystemInfo(sysData)
|
if (sysData) setSystemInfo(sysData)
|
||||||
|
setWorkersLive(capsData)
|
||||||
const isBusy = jobsData.some((j: Job) => j.status === 'processing' || j.status === 'transcoding')
|
|
||||||
const newPoint = isBusy ? Math.floor(Math.random() * 40) + 60 : Math.floor(Math.random() * 25) + 10
|
|
||||||
setSparklinePoints(prev => [...prev.slice(1), newPoint])
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -113,18 +162,37 @@ export default function Dashboard() {
|
|||||||
const completedJobs = jobs.filter(j => j.status === 'completed')
|
const completedJobs = jobs.filter(j => j.status === 'completed')
|
||||||
const failedJobs = jobs.filter(j => j.status === 'failed')
|
const failedJobs = jobs.filter(j => j.status === 'failed')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Was diese Karte anzeigt — und was sie NICHT mehr anzeigt.
|
||||||
|
*
|
||||||
|
* Commander 26.07.2026: „Der Server Status muss dringend überarbeitet werden,
|
||||||
|
* das Dashboard soll ja quasi alles auf einen Blick zeigen." Berechtigt: Die
|
||||||
|
* Karte hieß „Echte Live-Daten" und enthielt drei Angaben, von denen zwei
|
||||||
|
* erfunden waren.
|
||||||
|
*
|
||||||
|
* 1. „Auslastung: 0 % (Aktiv)" war NICHT die CPU-Last, sondern der
|
||||||
|
* Fortschritt des Jobs — bzw. eine feste 15/5, wenn keiner lief. Rippy
|
||||||
|
* misst nirgends CPU-Last, also wird sie auch nicht behauptet.
|
||||||
|
* 2. Die Verlaufskurve daneben war `Math.random()`. Reine Dekoration, die
|
||||||
|
* wie eine Messung aussah.
|
||||||
|
* 3. „N Worker Online" zählte die registrierten Einträge aus
|
||||||
|
* /system/info — auch Leichen alter Container-Rebuilds. Die echte
|
||||||
|
* Erreichbarkeit steht in /capabilities (Celery-Ping).
|
||||||
|
*
|
||||||
|
* Statt dessen stehen jetzt Angaben, die es wirklich gibt: laufende Phase mit
|
||||||
|
* Restzeit, erreichbare Worker mit ihrer Rechenleistung, freier Platz.
|
||||||
|
*/
|
||||||
const mainPlatz = systemInfo?.plaetze?.[0]
|
const mainPlatz = systemInfo?.plaetze?.[0]
|
||||||
const freiGb = mainPlatz ? mainPlatz.frei_gb : 0
|
const freiGb = mainPlatz ? mainPlatz.frei_gb : 0
|
||||||
const gesamtGb = mainPlatz ? mainPlatz.gesamt_gb : 1
|
const gesamtGb = mainPlatz ? mainPlatz.gesamt_gb : 1
|
||||||
const belegtPercent = Math.min(100, Math.max(0, Math.round(((gesamtGb - freiGb) / (gesamtGb || 1)) * 100)))
|
const belegtPercent = Math.min(100, Math.max(0, Math.round(((gesamtGb - freiGb) / (gesamtGb || 1)) * 100)))
|
||||||
const cpuPercent = aktiverJob ? aktiverJob.progress : (jobs.length > 0 ? 15 : 5)
|
const workerOnline = workersLive.filter(w => w.online)
|
||||||
const workerCount = systemInfo?.workers?.length || 1
|
const encoderWorker = workerOnline.filter(w => (w.encoders || []).length > 0)
|
||||||
|
const hardwareWorker = workerOnline.filter(w => (w.encoders || []).some(e => !e.startsWith('cpu')))
|
||||||
const sparklinePath = sparklinePoints.map((val, idx) => {
|
// Phase im Klartext — „processing" heißt rippen, „transcoding" komprimieren.
|
||||||
const x = (idx / (sparklinePoints.length - 1)) * 100
|
const phaseText = !aktiverJob ? 'Bereit — keine Disc in Arbeit'
|
||||||
const y = 50 - (val / 100) * 40
|
: aktiverJob.status === 'transcoding' ? 'Kompression läuft (HandBrake)'
|
||||||
return `${idx === 0 ? 'M' : 'L'} ${x} ${y}`
|
: 'Rip läuft (MakeMKV, verlustfrei)'
|
||||||
}).join(' ')
|
|
||||||
|
|
||||||
const filteredJobs = jobs.filter(j => {
|
const filteredJobs = jobs.filter(j => {
|
||||||
if (activeTab === 'active') return j.status === 'processing' || j.status === 'transcoding'
|
if (activeTab === 'active') return j.status === 'processing' || j.status === 'transcoding'
|
||||||
@@ -271,11 +339,17 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 w-full sm:w-auto justify-end">
|
<div className="flex items-center gap-4 w-full sm:w-auto justify-end">
|
||||||
<div className="w-36 space-y-1 text-right">
|
<div className="w-40 space-y-1 text-right">
|
||||||
<div className="text-xs font-mono text-amber-400 font-bold">{aktiverJob.progress}%</div>
|
<div className="text-xs font-mono text-amber-400 font-bold">{aktiverJob.progress}%</div>
|
||||||
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
||||||
<div className="h-full bg-amber-500 rounded-full transition-all duration-300" style={{ width: `${aktiverJob.progress}%` }} />
|
<div className="h-full bg-amber-500 rounded-full transition-all duration-300" style={{ width: `${aktiverJob.progress}%` }} />
|
||||||
</div>
|
</div>
|
||||||
|
{/* Die Angabe, deren Fehlen den 50-Stunden-Lauf am 25.07.2026
|
||||||
|
unsichtbar machte. Ohne belastbare Messreihe steht hier
|
||||||
|
ehrlich, dass noch gemessen wird (siehe api/eta.py). */}
|
||||||
|
<div className="text-[11px] font-mono text-slate-300">
|
||||||
|
{aktiverJob.eta_text || 'Restzeit wird gemessen …'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="amber" size="sm" onClick={() => setDetailJobId(aktiverJob.id)}>
|
<Button variant="amber" size="sm" onClick={() => setDetailJobId(aktiverJob.id)}>
|
||||||
Details
|
Details
|
||||||
@@ -360,6 +434,9 @@ export default function Dashboard() {
|
|||||||
style={{ width: `${job.progress}%` }}
|
style={{ width: `${job.progress}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{job.eta_text && (
|
||||||
|
<div className="text-[10px] font-mono text-slate-500">{job.eta_text}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-xs font-mono text-slate-400">
|
<td className="px-6 py-4 whitespace-nowrap text-xs font-mono text-slate-400">
|
||||||
@@ -393,9 +470,9 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
{(job.status === 'completed' || job.status === 'failed') && (
|
{(job.status === 'completed' || job.status === 'failed') && (
|
||||||
<button
|
<button
|
||||||
onClick={() => jobLoeschen(job)}
|
onClick={() => loeschenVorbereiten(job)}
|
||||||
className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors"
|
className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors"
|
||||||
title="Löschen"
|
title="Aus der Liste entfernen"
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
@@ -421,57 +498,110 @@ export default function Dashboard() {
|
|||||||
<CardHeader className="border-b border-slate-800">
|
<CardHeader className="border-b border-slate-800">
|
||||||
<CardTitle className="text-slate-200 flex items-center gap-2">
|
<CardTitle className="text-slate-200 flex items-center gap-2">
|
||||||
<Server size={18} className="text-amber-400" />
|
<Server size={18} className="text-amber-400" />
|
||||||
Server Status (Echte Live-Daten)
|
Server-Status
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-5 flex-1 flex items-center">
|
<CardContent className="p-5 flex-1 flex flex-col justify-center">
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 w-full">
|
<div className="space-y-4 w-full">
|
||||||
<div className="space-y-4 flex-1 w-full">
|
|
||||||
|
{/* 1. Was tut Rippy JETZT — mit Restzeit statt Phantasie-Last */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
<div className="flex items-center justify-between text-xs font-mono mb-1 gap-2">
|
||||||
<span className="text-slate-400 flex items-center gap-1.5"><Cpu size={14} className="text-amber-400" /> Auslastung:</span>
|
<span className="text-slate-400 flex items-center gap-1.5 flex-shrink-0">
|
||||||
<span className="text-amber-400 font-bold">{aktiverJob ? `${cpuPercent}% (Aktiv)` : 'Standby (0%)'}</span>
|
<Activity size={14} className="text-amber-400" /> Gerade:
|
||||||
|
</span>
|
||||||
|
<span className={`text-right ${aktiverJob ? 'text-amber-400 font-bold' : 'text-slate-300'}`}>
|
||||||
|
{phaseText}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{aktiverJob ? (
|
||||||
|
<>
|
||||||
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-amber-500 rounded-full transition-all duration-500"
|
className="h-full bg-amber-500 rounded-full transition-all duration-500"
|
||||||
style={{ width: `${aktiverJob ? Math.max(15, cpuPercent) : 5}%` }}
|
style={{ width: `${Math.max(2, aktiverJob.progress)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-[11px] font-mono mt-1">
|
||||||
|
<span className="text-slate-300 truncate pr-2">
|
||||||
|
{aktiverJob.title || aktiverJob.id.slice(0, 8)} · {aktiverJob.progress} %
|
||||||
|
</span>
|
||||||
|
{/* Leer heißt: noch keine belastbare Schätzung. Dann wird
|
||||||
|
das auch so gesagt — nicht geraten (siehe eta.py). */}
|
||||||
|
<span className="text-amber-400 flex-shrink-0">
|
||||||
|
{aktiverJob.eta_text || 'Restzeit wird noch gemessen'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-[11px] font-mono text-slate-500">
|
||||||
|
{queueJobs.length > 0
|
||||||
|
? `${queueJobs.length} Job(s) warten in der Schlange`
|
||||||
|
: 'Disc einlegen — Rippy erkennt sie selbst'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 2. Worker — die ECHTE Erreichbarkeit, plus was sie können */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
||||||
<span className="text-slate-400 flex items-center gap-1.5"><Activity size={14} className="text-amber-400" /> Worker Status:</span>
|
<span className="text-slate-400 flex items-center gap-1.5">
|
||||||
<span className="text-slate-200">{workerCount} Worker Online</span>
|
<Cpu size={14} className="text-amber-400" /> Worker erreichbar:
|
||||||
|
</span>
|
||||||
|
<span className={workerOnline.length > 0 ? 'text-slate-200' : 'text-rose-400 font-bold'}>
|
||||||
|
{workerOnline.length} von {workersLive.length}
|
||||||
|
{hardwareWorker.length > 0 ? ' · Hardware-Encoder dabei' : ''}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
{workerOnline.length === 0 ? (
|
||||||
<div className="h-full bg-emerald-500 rounded-full w-full" />
|
<p className="text-[11px] font-mono text-rose-400">
|
||||||
|
Kein Worker antwortet — ohne ihn läuft kein Rip.
|
||||||
|
Prüfen: <span className="text-slate-300">docker compose ps</span>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{workerOnline.map(w => (
|
||||||
|
<p key={w.name} className="text-[11px] font-mono text-slate-400 truncate">
|
||||||
|
<span className="text-emerald-400">●</span> {w.name}
|
||||||
|
{w.info?.cpu_kerne ? ` · ${w.info.cpu_kerne} Kerne` : ''}
|
||||||
|
{w.info?.cpu_simd && w.info.cpu_simd !== 'unbekannt' ? ` · ${w.info.cpu_simd}` : ''}
|
||||||
|
{w.info?.extern === 'ja' ? ' · extern' : ''}
|
||||||
|
{(w.encoders || []).length === 0 ? ' · kann nicht komprimieren' : ''}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
{encoderWorker.length === 0 && (
|
||||||
|
<p className="text-[11px] font-mono text-amber-400">
|
||||||
|
Keiner dieser Worker hat HandBrake — es wird nur gerippt, nicht komprimiert.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 3. Platz — war schon immer echt */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
||||||
<span className="text-slate-400 flex items-center gap-1.5"><HardDrive size={14} className="text-amber-400" /> Speichermounts:</span>
|
<span className="text-slate-400 flex items-center gap-1.5">
|
||||||
<span className="text-slate-200">{freiGb > 0 ? `${freiGb} GB frei` : 'Aktiv'}</span>
|
<HardDrive size={14} className="text-amber-400" /> {mainPlatz?.name || 'Ablage'}:
|
||||||
|
</span>
|
||||||
|
<span className={freiGb > 0 && freiGb < 60 ? 'text-amber-400 font-bold' : 'text-slate-200'}>
|
||||||
|
{freiGb > 0 ? `${freiGb} von ${gesamtGb} GB frei` : 'unbekannt'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-amber-500/80 rounded-full transition-all duration-500"
|
className={`h-full rounded-full transition-all duration-500 ${
|
||||||
style={{ width: `${Math.max(10, belegtPercent)}%` }}
|
belegtPercent > 90 ? 'bg-rose-500' : 'bg-amber-500/80'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${Math.max(2, belegtPercent)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{freiGb > 0 && freiGb < 60 && (
|
||||||
</div>
|
<p className="text-[11px] font-mono text-amber-400 mt-1">
|
||||||
|
Eine Blu-ray braucht roh ~40 GB, eine 4K-UHD bis 100 GB —
|
||||||
<div className="w-full sm:w-40 h-24 flex-shrink-0 flex items-end justify-center pt-2">
|
das reicht nicht mehr für jede Disc.
|
||||||
<svg viewBox="0 0 100 50" className="w-full h-full stroke-amber-400 fill-amber-500/10 transition-all duration-500">
|
</p>
|
||||||
<path
|
)}
|
||||||
d={`${sparklinePath} L 100 50 L 0 50 Z`}
|
|
||||||
strokeWidth="2.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -548,6 +678,39 @@ export default function Dashboard() {
|
|||||||
confirmText="Aufräumen"
|
confirmText="Aufräumen"
|
||||||
type="warning"
|
type="warning"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Entfernen eines EINZELNEN Jobs — mit der Zahl, die vorher fehlte. */}
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={loeschKandidat !== null}
|
||||||
|
onClose={() => setLoeschKandidat(null)}
|
||||||
|
onConfirm={jobLoeschen}
|
||||||
|
title={`„${loeschKandidat?.title || loeschKandidat?.id.slice(0, 8) || ''}" entfernen?`}
|
||||||
|
message="Der Eintrag verschwindet aus der Liste. Die fertig abgelegten Dateien bleiben, wo sie sind."
|
||||||
|
confirmText={rohdatenMitloeschen ? 'Entfernen und löschen' : 'Entfernen'}
|
||||||
|
type="warning"
|
||||||
|
extra={rohdaten && rohdaten.gb > 0 ? (
|
||||||
|
<div className="mt-3 text-xs p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-700 dark:text-amber-300 space-y-2">
|
||||||
|
<p>
|
||||||
|
<strong>Daneben liegen {rohdaten.gb} GB Rohdaten</strong>
|
||||||
|
{rohdaten.dateien > 0 ? ` (${rohdaten.dateien} Datei${rohdaten.dateien === 1 ? '' : 'en'})` : ''}.
|
||||||
|
Ohne den Eintrag zeigt nichts mehr darauf — sie sind dann über Rippy
|
||||||
|
nicht mehr erreichbar und „Neu komprimieren" ist unmöglich.
|
||||||
|
</p>
|
||||||
|
{rohdaten.pfade.map(p => (
|
||||||
|
<p key={p} className="font-mono break-all opacity-80">{p}</p>
|
||||||
|
))}
|
||||||
|
<label className="flex items-start gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={rohdatenMitloeschen}
|
||||||
|
onChange={e => setRohdatenMitloeschen(e.target.checked)}
|
||||||
|
className="w-4 h-4 mt-0.5 accent-rose-500 rounded"
|
||||||
|
/>
|
||||||
|
<span>Rohdaten jetzt mitlöschen und {rohdaten.gb} GB freigeben</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,6 +127,37 @@ interface SystemInfo {
|
|||||||
webhook_gesetzt: boolean
|
webhook_gesetzt: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Antwort von GET /presets — die Preset-Namen kommen aus
|
||||||
|
// `HandBrakeCLI --preset-list` des jeweiligen Workers, die Empfehlung samt
|
||||||
|
// Begründung aus api/presets.py (dort getestet). Bis 26.07.2026 standen die
|
||||||
|
// Namen fest verdrahtet in dieser Datei; sie unterscheiden sich aber zwischen
|
||||||
|
// HandBrake-Versionen, und ein Name, den das HandBrake des Workers nicht kennt,
|
||||||
|
// lässt die Kompression scheitern.
|
||||||
|
interface PresetEmpfehlung {
|
||||||
|
preset: string
|
||||||
|
grund: string
|
||||||
|
gefunden: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PresetTyp {
|
||||||
|
empfehlung: PresetEmpfehlung
|
||||||
|
auswahl: { name: string, verkleinert: boolean }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PresetUebersicht {
|
||||||
|
quelle: 'worker' | 'rueckfall'
|
||||||
|
hardware: string[]
|
||||||
|
simd_schnell: boolean
|
||||||
|
typen: Record<'uhd' | 'bluray' | 'dvd', PresetTyp>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Welches Einstellungs-Feld gehört zu welchem Disc-Typ.
|
||||||
|
const PRESET_FELD: Record<'uhd' | 'bluray' | 'dvd', keyof SettingsState> = {
|
||||||
|
uhd: 'transcodePresetUhd',
|
||||||
|
bluray: 'transcodePresetBluray',
|
||||||
|
dvd: 'transcodePresetDvd',
|
||||||
|
}
|
||||||
|
|
||||||
// Byte-Zahl menschenlesbar — eine echte KEYDB.cfg ist mehrere MB groß.
|
// Byte-Zahl menschenlesbar — eine echte KEYDB.cfg ist mehrere MB groß.
|
||||||
function bytesLesbar(bytes: number): string {
|
function bytesLesbar(bytes: number): string {
|
||||||
if (!bytes) return '0 B'
|
if (!bytes) return '0 B'
|
||||||
@@ -171,6 +202,8 @@ export default function SettingsPage() {
|
|||||||
const [keystore, setKeystore] = useState<KeystoreStatus | null>(null)
|
const [keystore, setKeystore] = useState<KeystoreStatus | null>(null)
|
||||||
const [keystoreBusy, setKeystoreBusy] = useState(false)
|
const [keystoreBusy, setKeystoreBusy] = useState(false)
|
||||||
const keystoreInput = useRef<HTMLInputElement>(null)
|
const keystoreInput = useRef<HTMLInputElement>(null)
|
||||||
|
// Preset-Namen + Empfehlung, beides von der API (siehe PresetUebersicht).
|
||||||
|
const [presetInfo, setPresetInfo] = useState<PresetUebersicht | null>(null)
|
||||||
|
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
|
||||||
@@ -291,8 +324,90 @@ export default function SettingsPage() {
|
|||||||
api.get('/system/aacs-dumps')
|
api.get('/system/aacs-dumps')
|
||||||
.then(r => setDumps(r.data?.dumps || []))
|
.then(r => setDumps(r.data?.dumps || []))
|
||||||
.catch(() => setDumps([]))
|
.catch(() => setDumps([]))
|
||||||
|
api.get('/presets')
|
||||||
|
.then(r => setPresetInfo(r.data))
|
||||||
|
.catch(() => setPresetInfo(null))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// „Bestes automatisch wählen" (Commander-Anforderung 26.07.2026). Setzt alle
|
||||||
|
// drei Disc-Typen auf die Empfehlung der API — und nur solche, die wirklich
|
||||||
|
// gefunden wurden, damit nie ein Name landet, den HandBrake nicht kennt.
|
||||||
|
const bestePresetsUebernehmen = () => {
|
||||||
|
if (!presetInfo) return
|
||||||
|
const neu: Partial<SettingsState> = {}
|
||||||
|
const uebernommen: string[] = []
|
||||||
|
for (const typ of ['uhd', 'bluray', 'dvd'] as const) {
|
||||||
|
const e = presetInfo.typen[typ]?.empfehlung
|
||||||
|
if (!e?.gefunden || !e.preset) continue
|
||||||
|
neu[PRESET_FELD[typ]] = e.preset as any
|
||||||
|
uebernommen.push(`${typ.toUpperCase()}: ${e.preset === PRESET_KEINE ? 'nicht komprimieren' : e.preset}`)
|
||||||
|
}
|
||||||
|
if (uebernommen.length === 0) {
|
||||||
|
toast('error', 'Kein Preset übernommen — die Worker melden keine bekannten Namen.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSettings(prev => ({ ...prev, ...neu }))
|
||||||
|
setSaved(false)
|
||||||
|
toast('success', `Übernommen — ${uebernommen.join(' · ')}. Noch speichern!`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eine Preset-Auswahl je Disc-Typ. Die Optionen kommen vom WORKER (siehe
|
||||||
|
// PresetUebersicht), nicht aus dieser Datei. Der aktuell gespeicherte Wert
|
||||||
|
// steht immer mit drin — sonst würde eine Bestandseinstellung beim Öffnen der
|
||||||
|
// Seite still auf etwas anderes springen.
|
||||||
|
const presetAuswahl = (
|
||||||
|
typ: 'uhd' | 'bluray' | 'dvd', label: string, keineText: string,
|
||||||
|
) => {
|
||||||
|
const feld = PRESET_FELD[typ]
|
||||||
|
const wert = (settings[feld] as string) || ''
|
||||||
|
const daten = presetInfo?.typen?.[typ]
|
||||||
|
const empfehlung = daten?.empfehlung
|
||||||
|
const passend = (daten?.auswahl || []).filter(o => !o.verkleinert)
|
||||||
|
const kleiner = (daten?.auswahl || []).filter(o => o.verkleinert)
|
||||||
|
const bekannt = wert === PRESET_KEINE
|
||||||
|
|| (daten?.auswahl || []).some(o => o.name === wert)
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Select label={label} value={wert} onChange={(e) => handleChange(feld, e.target.value)}>
|
||||||
|
<option value={PRESET_KEINE}>{keineText}</option>
|
||||||
|
{/* Ein gespeicherter Wert, den dieser Worker nicht kennt, wird
|
||||||
|
BENANNT statt verschluckt — genau so fällt eine falsche
|
||||||
|
Bestandseinstellung überhaupt auf. */}
|
||||||
|
{!bekannt && wert && (
|
||||||
|
<option value={wert}>{wert} — gespeichert, dieser Worker kennt es nicht</option>
|
||||||
|
)}
|
||||||
|
{passend.length > 0 && (
|
||||||
|
<optgroup label="Passt zur Auflösung">
|
||||||
|
{passend.map(o => (
|
||||||
|
<option key={o.name} value={o.name}>
|
||||||
|
{o.name}{empfehlung?.gefunden && empfehlung.preset === o.name ? ' ★ empfohlen' : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
|
{kleiner.length > 0 && (
|
||||||
|
<optgroup label="Bewusst verkleinern — die 4K-Auflösung ist danach weg">
|
||||||
|
{kleiner.map(o => <option key={o.name} value={o.name}>{o.name}</option>)}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
|
</Select>
|
||||||
|
{empfehlung && empfehlung.preset !== wert && (
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{empfehlung.gefunden ? (
|
||||||
|
<>
|
||||||
|
Empfohlen wäre{' '}
|
||||||
|
<strong className="text-slate-700 dark:text-slate-300">
|
||||||
|
{empfehlung.preset === PRESET_KEINE ? 'nicht komprimieren' : empfehlung.preset}
|
||||||
|
</strong>
|
||||||
|
{' — '}{empfehlung.grund}
|
||||||
|
</>
|
||||||
|
) : empfehlung.grund}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const webhookTesten = async () => {
|
const webhookTesten = async () => {
|
||||||
if (!settings.notificationWebhook) return
|
if (!settings.notificationWebhook) return
|
||||||
setTestBusy(true)
|
setTestBusy(true)
|
||||||
@@ -637,28 +752,54 @@ export default function SettingsPage() {
|
|||||||
Ein Preset JE DISC-TYP (25.07.2026). Vorher lief alles durch
|
Ein Preset JE DISC-TYP (25.07.2026). Vorher lief alles durch
|
||||||
dasselbe Preset — eine 4K-UHD wurde damit auf 1080p
|
dasselbe Preset — eine 4K-UHD wurde damit auf 1080p
|
||||||
heruntergerechnet und eine DVD auf 1080p hochskaliert.
|
heruntergerechnet und eine DVD auf 1080p hochskaliert.
|
||||||
Preset-Namen stammen aus `HandBrakeCLI --preset-list` im
|
|
||||||
Worker-Image (HandBrake 1.6.1), nicht aus dem Kopf.
|
Seit 26.07.2026 kommen die NAMEN vom Worker selbst
|
||||||
|
(`HandBrakeCLI --preset-list`, siehe worker/caps.py) und die
|
||||||
|
Empfehlung von GET /presets. Vorher standen sie hier fest
|
||||||
|
verdrahtet — sie unterscheiden sich aber je HandBrake-Version,
|
||||||
|
und ein Name, den das HandBrake des Workers nicht kennt, lässt
|
||||||
|
die Kompression scheitern.
|
||||||
*/}
|
*/}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
Ein Preset je Quelle — Rippy erkennt den Disc-Typ selbst und wählt passend aus.
|
Ein Preset je Quelle — Rippy erkennt den Disc-Typ selbst und wählt passend aus.
|
||||||
<strong className="text-slate-700 dark:text-slate-300"> Eine 4K-UHD gehört auf ein
|
<strong className="text-slate-700 dark:text-slate-300"> Eine 4K-UHD gehört auf ein
|
||||||
2160p-Preset</strong>, sonst rechnet HandBrake sie auf 1080p herunter und die
|
2160p-Preset</strong>, sonst rechnet HandBrake sie auf 1080p herunter und die
|
||||||
4K-Auflösung ist weg.
|
4K-Auflösung ist weg.
|
||||||
</p>
|
</p>
|
||||||
|
<Button
|
||||||
<Select
|
variant="secondary"
|
||||||
label="Preset für 4K-UHD"
|
onClick={bestePresetsUebernehmen}
|
||||||
value={settings.transcodePresetUhd}
|
disabled={!presetInfo}
|
||||||
onChange={(e) => handleChange('transcodePresetUhd', e.target.value)}
|
className="flex-shrink-0"
|
||||||
>
|
>
|
||||||
<option value={PRESET_KEINE}>Nicht komprimieren — 4K verlustfrei behalten</option>
|
Bestes wählen
|
||||||
<option value="H.265 MKV 2160p60 4K">H.265 MKV 2160p60 4K — 4K erhalten</option>
|
</Button>
|
||||||
<option value="HQ 2160p60 4K HEVC Surround">HQ 2160p60 4K HEVC Surround — 4K, mehr Qualität</option>
|
</div>
|
||||||
<option value="Super HQ 2160p60 4K HEVC Surround">Super HQ 2160p60 4K HEVC Surround — 4K, größer</option>
|
|
||||||
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 — bewusst auf 1080p verkleinern</option>
|
{presetInfo && (
|
||||||
</Select>
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{presetInfo.quelle === 'worker' ? (
|
||||||
|
<>
|
||||||
|
Die Liste kommt von deinen Workern selbst.
|
||||||
|
{presetInfo.hardware.length > 0
|
||||||
|
? ` Hardware-Encoder gemeldet: ${presetInfo.hardware.join(', ')} — damit geht 4K in Minuten statt Tagen.`
|
||||||
|
: presetInfo.simd_schnell
|
||||||
|
? ' Kein Hardware-Encoder, aber ein Worker mit AVX2 — Software-H.265 ist damit schnell genug.'
|
||||||
|
: ' Kein Hardware-Encoder und kein AVX2 — hier ist Software-4K keine Option (gemessen: 28–55 h je Film).'}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Kein Worker meldet gerade eine Preset-Liste (alter Worker-Stand oder
|
||||||
|
keiner online) — angezeigt wird deshalb eine geprüfte Standardauswahl.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{presetAuswahl('uhd', 'Preset für 4K-UHD',
|
||||||
|
'Nicht komprimieren — 4K verlustfrei behalten')}
|
||||||
{/* Die Warnung, die den 50-Stunden-Lauf verhindert haette. Sie
|
{/* Die Warnung, die den 50-Stunden-Lauf verhindert haette. Sie
|
||||||
steht nur da, wenn die Maschine sie WIRKLICH braucht -
|
steht nur da, wenn die Maschine sie WIRKLICH braucht -
|
||||||
gemessen, nicht vermutet (worker/caps.py meldet cpu_simd). */}
|
gemessen, nicht vermutet (worker/caps.py meldet cpu_simd). */}
|
||||||
@@ -673,28 +814,10 @@ export default function SettingsPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Select
|
{presetAuswahl('bluray', 'Preset für Blu-ray',
|
||||||
label="Preset für Blu-ray"
|
'Nicht komprimieren — verlustfrei behalten')}
|
||||||
value={settings.transcodePresetBluray}
|
{presetAuswahl('dvd', 'Preset für DVD',
|
||||||
onChange={(e) => handleChange('transcodePresetBluray', e.target.value)}
|
'Nicht komprimieren — verlustfrei behalten')}
|
||||||
>
|
|
||||||
<option value={PRESET_KEINE}>Nicht komprimieren — verlustfrei behalten</option>
|
|
||||||
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 — klein & modern (empfohlen)</option>
|
|
||||||
<option value="HQ 1080p30 Surround">HQ 1080p30 Surround — H.264, maximale Kompatibilität</option>
|
|
||||||
<option value="Super HQ 1080p30 Surround">Super HQ 1080p30 Surround — größer, beste Qualität</option>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
label="Preset für DVD"
|
|
||||||
value={settings.transcodePresetDvd}
|
|
||||||
onChange={(e) => handleChange('transcodePresetDvd', e.target.value)}
|
|
||||||
>
|
|
||||||
<option value={PRESET_KEINE}>Nicht komprimieren — verlustfrei behalten</option>
|
|
||||||
<option value="H.265 MKV 576p25">H.265 MKV 576p25 — PAL-DVD, native Auflösung (empfohlen)</option>
|
|
||||||
<option value="H.265 MKV 480p30">H.265 MKV 480p30 — NTSC-DVD, native Auflösung</option>
|
|
||||||
<option value="HQ 576p25 Surround">HQ 576p25 Surround — H.264, maximale Kompatibilität</option>
|
|
||||||
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 — hochskalieren (bringt nichts)</option>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
„Nicht komprimieren" heißt: Der Rip bleibt so, wie MakeMKV ihn
|
„Nicht komprimieren" heißt: Der Rip bleibt so, wie MakeMKV ihn
|
||||||
|
|||||||
@@ -106,6 +106,58 @@ def leite_backends_ab(hb_encoder) -> list:
|
|||||||
return gefunden
|
return gefunden
|
||||||
|
|
||||||
|
|
||||||
|
def parse_preset_liste(text: str) -> list:
|
||||||
|
"""Preset-NAMEN, die DIESES HandBrake wirklich kennt.
|
||||||
|
|
||||||
|
Quelle: `HandBrakeCLI --preset-list`, am 26.07.2026 im Worker-Image
|
||||||
|
gemessen (HandBrake 1.6.1) — AGENTS Regel D, nicht aus dem Kopf:
|
||||||
|
|
||||||
|
General/
|
||||||
|
Very Fast 2160p60 4K AV1
|
||||||
|
AV1 video (up to 2160p60) and AAC stereo audio, in an MP4
|
||||||
|
container.
|
||||||
|
Hardware/
|
||||||
|
H.265 VCN 2160p 4K
|
||||||
|
|
||||||
|
Die Struktur ist die Einrückung: Kategorien ohne Einrückung (mit „/" am
|
||||||
|
Ende), Preset-Namen mit GENAU vier Leerzeichen, Beschreibungen mit acht.
|
||||||
|
HandBrakes Logzeilen („[10:26:05] qsv: not available on this system") und
|
||||||
|
Meldungen wie „Cannot load libnvidia-encode.so.1" haben keine Einrückung
|
||||||
|
und fallen damit von selbst weg.
|
||||||
|
|
||||||
|
⚠️ Wichtige Richtigstellung zum SAVEPOINT v3.16: Dort steht, die Namen der
|
||||||
|
HARDWARE-Presets seien auf der Rippy-VM „nicht ermittelbar", weil deren
|
||||||
|
HandBrake keinen Hardware-Encoder hat. Gemessen ist das falsch — die
|
||||||
|
Kategorie `Hardware/` steht vollständig in der Liste (VCN, NVENC, QSV, MF).
|
||||||
|
HandBrake trennt beides: `--preset-list` nennt alle mitgelieferten Presets,
|
||||||
|
`--help` nur die Encoder, die diese Maschine wirklich benutzen kann. Für
|
||||||
|
„welches Preset gibt es" ist die Preset-Liste die Quelle, für „läuft das
|
||||||
|
hier" die Encoder-Liste. Deshalb werden beide gemeldet.
|
||||||
|
"""
|
||||||
|
presets = []
|
||||||
|
for zeile in (text or "").splitlines():
|
||||||
|
if not zeile.strip():
|
||||||
|
continue
|
||||||
|
einrueckung = len(zeile) - len(zeile.lstrip(" "))
|
||||||
|
if einrueckung == 4:
|
||||||
|
presets.append(zeile.strip())
|
||||||
|
return presets
|
||||||
|
|
||||||
|
|
||||||
|
def hole_handbrake_presets() -> list:
|
||||||
|
"""`HandBrakeCLI --preset-list` einmal abrufen (leer, wenn nicht installiert)."""
|
||||||
|
if not shutil.which("HandBrakeCLI"):
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
aus = subprocess.run(
|
||||||
|
["HandBrakeCLI", "--preset-list"],
|
||||||
|
capture_output=True, text=True, timeout=30,
|
||||||
|
)
|
||||||
|
return parse_preset_liste((aus.stdout or "") + (aus.stderr or ""))
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
def beste_simd(flags_text: str) -> str:
|
def beste_simd(flags_text: str) -> str:
|
||||||
"""Höchste Vektor-Befehlsstufe der CPU — entscheidend fürs Software-Encodieren.
|
"""Höchste Vektor-Befehlsstufe der CPU — entscheidend fürs Software-Encodieren.
|
||||||
|
|
||||||
@@ -285,6 +337,14 @@ def werkzeug_versionen() -> dict:
|
|||||||
# Die ungefilterte Auskunft von HandBrake — damit im UI nachprüfbar ist,
|
# Die ungefilterte Auskunft von HandBrake — damit im UI nachprüfbar ist,
|
||||||
# woher die Backend-Liste kommt.
|
# woher die Backend-Liste kommt.
|
||||||
info["handbrake_encoder"] = ",".join(hb_encoder)
|
info["handbrake_encoder"] = ",".join(hb_encoder)
|
||||||
|
# Die Preset-NAMEN dieses HandBrake. Damit hört das Raten auf: Das UI bietet
|
||||||
|
# genau an, was der jeweilige Worker kennt — die Namen unterscheiden sich
|
||||||
|
# zwischen HandBrake-Versionen, und ein erfundener Name lässt die Kompression
|
||||||
|
# scheitern (Commander-Anforderung „immer das Beste auswählen" braucht eine
|
||||||
|
# echte Liste, nicht eine geratene).
|
||||||
|
presets = hole_handbrake_presets()
|
||||||
|
if presets:
|
||||||
|
info["presets"] = presets
|
||||||
if shutil.which("makemkvcon"):
|
if shutil.which("makemkvcon"):
|
||||||
info["makemkv"] = os.getenv("MAKEMKV_VERSION") or "installiert"
|
info["makemkv"] = os.getenv("MAKEMKV_VERSION") or "installiert"
|
||||||
if shutil.which("HandBrakeCLI"):
|
if shutil.which("HandBrakeCLI"):
|
||||||
|
|||||||
@@ -403,9 +403,30 @@ def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PR
|
|||||||
return {"status": "error", "error": str(e)}
|
return {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def unbekanntes_preset(zeile: str) -> str:
|
||||||
|
"""Meldet HandBrake diese Zeile ein UNBEKANNTES Preset? Name oder "".
|
||||||
|
|
||||||
|
Wortlaut aus dem echten Lauf im Worker-Image (26.07.2026, AGENTS Regel D):
|
||||||
|
|
||||||
|
$ HandBrakeCLI --preset 'Gibt Es Nicht 999p' …
|
||||||
|
Invalid preset Gibt Es Nicht 999p
|
||||||
|
Valid presets are:
|
||||||
|
|
||||||
|
Ohne diese Übersetzung stand im UI nur „HandBrake endete mit Code 3" —
|
||||||
|
und die eigentliche Ursache (ein Preset-Name, den DIESES HandBrake nicht
|
||||||
|
kennt) war daraus nicht zu erraten. Genau dieser Fall wird häufiger, weil
|
||||||
|
die Namen je HandBrake-Version verschieden sind und ein Remote-Worker eine
|
||||||
|
andere Version haben kann als die Rippy-VM.
|
||||||
|
"""
|
||||||
|
text = (zeile or "").strip()
|
||||||
|
kopf = "Invalid preset "
|
||||||
|
return text[len(kopf):].strip() if text.startswith(kopf) else ""
|
||||||
|
|
||||||
|
|
||||||
def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=None) -> dict:
|
def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=None) -> dict:
|
||||||
"""Liest HandBrakes Ausgabe und wertet sie aus. Eigene Funktion, damit die
|
"""Liest HandBrakes Ausgabe und wertet sie aus. Eigene Funktion, damit die
|
||||||
Reihenfolge (Abbruch VOR Fortschritt) ohne echtes HandBrake testbar ist."""
|
Reihenfolge (Abbruch VOR Fortschritt) ohne echtes HandBrake testbar ist."""
|
||||||
|
falsches_preset = ""
|
||||||
try:
|
try:
|
||||||
for line in process.stdout:
|
for line in process.stdout:
|
||||||
# Zuerst der Abbruch — unabhängig davon, ob die Zeile überhaupt
|
# Zuerst der Abbruch — unabhängig davon, ob die Zeile überhaupt
|
||||||
@@ -413,6 +434,8 @@ def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=
|
|||||||
# sich die Prozentzahl bewegt (Befund 25.07.2026).
|
# sich die Prozentzahl bewegt (Befund 25.07.2026).
|
||||||
if abbruch_cb:
|
if abbruch_cb:
|
||||||
abbruch_cb()
|
abbruch_cb()
|
||||||
|
if not falsches_preset:
|
||||||
|
falsches_preset = unbekanntes_preset(line)
|
||||||
progress = get_progress_from_line(line)
|
progress = get_progress_from_line(line)
|
||||||
# >= 0: ein echtes 0 % ist eine Angabe und muss durch. Der alte
|
# >= 0: ein echtes 0 % ist eine Angabe und muss durch. Der alte
|
||||||
# Filter `> 0` verwarf den gesamten ersten Prozentpunkt — bei
|
# Filter `> 0` verwarf den gesamten ersten Prozentpunkt — bei
|
||||||
@@ -428,6 +451,18 @@ def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=
|
|||||||
|
|
||||||
if process.returncode == 0 and os.path.exists(output_path):
|
if process.returncode == 0 and os.path.exists(output_path):
|
||||||
return {"status": "success", "output_path": output_path}
|
return {"status": "success", "output_path": output_path}
|
||||||
|
if falsches_preset:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": (
|
||||||
|
f'Das HandBrake dieses Workers kennt das Preset „{falsches_preset}" '
|
||||||
|
'nicht. Preset-Namen unterscheiden sich zwischen '
|
||||||
|
'HandBrake-Versionen. Abhilfe: Einstellungen → Verarbeitung, dort '
|
||||||
|
'auf „Bestes wählen" klicken — die Liste dort kommt vom Worker '
|
||||||
|
'selbst und enthält nur Namen, die es wirklich gibt.'
|
||||||
|
),
|
||||||
|
"return_code": process.returncode,
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"error": f"HandBrake endete mit Code {process.returncode}",
|
"error": f"HandBrake endete mit Code {process.returncode}",
|
||||||
|
|||||||
@@ -138,3 +138,79 @@ def test_simd_stufe_der_rippy_vm_ist_sse4_2():
|
|||||||
"pni cx16 sse4_1 sse4_2 x2apic popcnt aes hypervisor lahf_lm"
|
"pni cx16 sse4_1 sse4_2 x2apic popcnt aes hypervisor lahf_lm"
|
||||||
)
|
)
|
||||||
assert caps.beste_simd(flags_vm) == "sse4_2"
|
assert caps.beste_simd(flags_vm) == "sse4_2"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Preset-Liste: die Namen, die HandBrake wirklich kennt -------------------
|
||||||
|
|
||||||
|
# Wörtlich aus `HandBrakeCLI --preset-list` im Worker-Image (26.07.2026,
|
||||||
|
# HandBrake 1.6.1) — gekürzt auf je zwei Presets pro Kategorie, Struktur und
|
||||||
|
# Einrückung unverändert. Die zwei Logzeilen oben stehen dort wirklich mit
|
||||||
|
# drin (HandBrake schreibt sie in denselben Strom) und müssen wegfallen.
|
||||||
|
HB_PRESETS_ECHT = """[10:26:05] Compile-time hardening features are enabled
|
||||||
|
[10:26:05] qsv: not available on this system
|
||||||
|
Cannot load libnvidia-encode.so.1
|
||||||
|
General/
|
||||||
|
Very Fast 2160p60 4K AV1
|
||||||
|
AV1 video (up to 2160p60) and AAC stereo audio, in an MP4
|
||||||
|
container.
|
||||||
|
Super HQ 1080p30 Surround
|
||||||
|
High quality H.264 video (up to 1080p30) and high bit rate AAC
|
||||||
|
surround audio, in an MP4 container.
|
||||||
|
Matroska/
|
||||||
|
H.265 MKV 2160p60 4K
|
||||||
|
H.265 video (up to 2160p60) and surround audio, in an MKV
|
||||||
|
container.
|
||||||
|
H.265 MKV 1080p30
|
||||||
|
H.265 video (up to 1080p30) and surround audio, in an MKV
|
||||||
|
container.
|
||||||
|
Hardware/
|
||||||
|
AV1 QSV 2160p 4K
|
||||||
|
AV1 video (up to 2160p) and surround audio, in an MP4 container.
|
||||||
|
H.265 VCN 2160p 4K
|
||||||
|
H.265 video (up to 2160p) and surround audio, in an MP4 container.
|
||||||
|
CLI Defaults/
|
||||||
|
CLI Default
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_preset_liste_kommt_aus_der_einrueckung():
|
||||||
|
presets = caps.parse_preset_liste(HB_PRESETS_ECHT)
|
||||||
|
assert presets == [
|
||||||
|
"Very Fast 2160p60 4K AV1",
|
||||||
|
"Super HQ 1080p30 Surround",
|
||||||
|
"H.265 MKV 2160p60 4K",
|
||||||
|
"H.265 MKV 1080p30",
|
||||||
|
"AV1 QSV 2160p 4K",
|
||||||
|
"H.265 VCN 2160p 4K",
|
||||||
|
"CLI Default",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_preset_liste_nimmt_keine_beschreibung_und_kein_log():
|
||||||
|
"""Beschreibungen (8 Leerzeichen), Kategorien (0) und HandBrakes eigene
|
||||||
|
Logzeilen (0) dürfen nicht als Preset-Namen durchgehen."""
|
||||||
|
presets = caps.parse_preset_liste(HB_PRESETS_ECHT)
|
||||||
|
assert not any(p.endswith("/") for p in presets)
|
||||||
|
assert not any("container" in p for p in presets)
|
||||||
|
assert not any(p.startswith("[") for p in presets)
|
||||||
|
assert "Cannot load libnvidia-encode.so.1" not in presets
|
||||||
|
|
||||||
|
|
||||||
|
def test_preset_liste_leer_bleibt_leer():
|
||||||
|
assert caps.parse_preset_liste("") == []
|
||||||
|
assert caps.parse_preset_liste(None) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_hardware_presets_stehen_auch_ohne_hardware_in_der_liste():
|
||||||
|
"""Richtigstellung zum SAVEPOINT v3.16: Dort galt es als unmöglich, die
|
||||||
|
Hardware-Preset-Namen auf der Rippy-VM zu ermitteln, weil dort kein
|
||||||
|
Hardware-Encoder läuft. Die Messung sagt das Gegenteil — `--preset-list`
|
||||||
|
nennt sie, `--help` nennt nur die nutzbaren Encoder. Zwei Fragen, zwei
|
||||||
|
Quellen."""
|
||||||
|
presets = caps.parse_preset_liste(HB_PRESETS_ECHT)
|
||||||
|
assert "H.265 VCN 2160p 4K" in presets # AMD, aus der VM-Liste
|
||||||
|
assert "AV1 QSV 2160p 4K" in presets # Intel, aus der VM-Liste
|
||||||
|
# ... und dieselbe Maschine meldet trotzdem KEINEN Hardware-Encoder:
|
||||||
|
assert caps.leite_backends_ab(caps.parse_encoder_liste(HB_HILFE_ECHT)) == [
|
||||||
|
"cpu-x264", "cpu-x265", "cpu-av1",
|
||||||
|
]
|
||||||
|
|||||||
@@ -317,3 +317,66 @@ def test_reservewert_kollidiert_mit_keinem_echten_preset():
|
|||||||
|
|
||||||
assert PRESET_KEINE == "keine"
|
assert PRESET_KEINE == "keine"
|
||||||
assert " " not in PRESET_KEINE # echte Preset-Namen haben durchweg Leerzeichen
|
assert " " not in PRESET_KEINE # echte Preset-Namen haben durchweg Leerzeichen
|
||||||
|
|
||||||
|
|
||||||
|
# --- Unbekanntes Preset: aus „Code 3" wird eine handelbare Meldung -----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbekanntes_preset_wird_erkannt():
|
||||||
|
"""Wortlaut echt gemessen (26.07.2026, Worker-Image): HandBrake antwortet
|
||||||
|
„Invalid preset <Name>" und listet danach die gültigen auf."""
|
||||||
|
from ripping import unbekanntes_preset
|
||||||
|
|
||||||
|
assert unbekanntes_preset("Invalid preset Gibt Es Nicht 999p") == "Gibt Es Nicht 999p"
|
||||||
|
assert unbekanntes_preset(" Invalid preset H.265 XYZ 4K ") == "H.265 XYZ 4K"
|
||||||
|
# Alles andere ist kein Preset-Fehler
|
||||||
|
assert unbekanntes_preset("Valid presets are:") == ""
|
||||||
|
assert unbekanntes_preset("Encoding: task 1 of 1, 5.00 %") == ""
|
||||||
|
assert unbekanntes_preset("") == ""
|
||||||
|
assert unbekanntes_preset(None) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_falsches_preset_erklaert_den_fehlschlag_statt_nur_den_code():
|
||||||
|
"""Vorher stand im UI nur „HandBrake endete mit Code 3" — daraus war nicht
|
||||||
|
zu erraten, dass der Preset-NAME das Problem ist. Der Fall wird häufiger,
|
||||||
|
weil ein Remote-Worker eine andere HandBrake-Version haben kann."""
|
||||||
|
import ripping
|
||||||
|
|
||||||
|
class FakeProcess:
|
||||||
|
def __init__(self):
|
||||||
|
self.stdout = iter([
|
||||||
|
"Invalid preset H.265 VCN 2160p 4K\n",
|
||||||
|
"Valid presets are:\n",
|
||||||
|
])
|
||||||
|
self.returncode = 3
|
||||||
|
|
||||||
|
def kill(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def wait(self):
|
||||||
|
return 3
|
||||||
|
|
||||||
|
ergebnis = ripping._handbrake_schleife(FakeProcess(), "/gibt-es-nicht.mkv")
|
||||||
|
|
||||||
|
assert ergebnis["status"] == "error"
|
||||||
|
assert "H.265 VCN 2160p 4K" in ergebnis["error"]
|
||||||
|
assert "Bestes wählen" in ergebnis["error"]
|
||||||
|
assert ergebnis["return_code"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_fehler_ohne_preset_problem_bleibt_der_alte():
|
||||||
|
import ripping
|
||||||
|
|
||||||
|
class FakeProcess:
|
||||||
|
def __init__(self):
|
||||||
|
self.stdout = iter(["irgendwas ganz anderes\n"])
|
||||||
|
self.returncode = 1
|
||||||
|
|
||||||
|
def kill(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def wait(self):
|
||||||
|
return 1
|
||||||
|
|
||||||
|
ergebnis = ripping._handbrake_schleife(FakeProcess(), "/gibt-es-nicht.mkv")
|
||||||
|
assert ergebnis["error"] == "HandBrake endete mit Code 1"
|
||||||
|
|||||||
+27
-1
@@ -47,6 +47,25 @@ problem() { PROBLEME="${PROBLEME}$1"$'\n'; rot " X $1"; }
|
|||||||
hinweis() { HINWEISE="${HINWEISE}$1"$'\n'; gelb " ! $1"; }
|
hinweis() { HINWEISE="${HINWEISE}$1"$'\n'; gelb " ! $1"; }
|
||||||
ok() { gruen " OK $1"; }
|
ok() { gruen " OK $1"; }
|
||||||
|
|
||||||
|
# Welches Paketwerkzeug hat dieser Host? (Commander-Frage 26.07.2026: „Was wenn
|
||||||
|
# der Unterbau nicht Debian ist?") Vorher nannte das Skript bei fehlendem
|
||||||
|
# Compose stur `sudo apt install …` — auf Arch, Fedora oder openSUSE ist das
|
||||||
|
# schlicht falsch, und der Nutzer stand vor einem Befehl, den es nicht gibt.
|
||||||
|
#
|
||||||
|
# Erkannt wird über das VORHANDENE Werkzeug, nicht über /etc/os-release: Ein
|
||||||
|
# Derivat kann sich anders nennen als sein Unterbau, aber sein Paketwerkzeug
|
||||||
|
# liegt im PATH. Findet sich keines, wird nichts behauptet.
|
||||||
|
paket_befehl() {
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then echo "sudo apt install docker-compose-plugin"
|
||||||
|
elif command -v dnf >/dev/null 2>&1; then echo "sudo dnf install docker-compose-plugin"
|
||||||
|
elif command -v yum >/dev/null 2>&1; then echo "sudo yum install docker-compose-plugin"
|
||||||
|
elif command -v pacman >/dev/null 2>&1; then echo "sudo pacman -S docker-compose"
|
||||||
|
elif command -v zypper >/dev/null 2>&1; then echo "sudo zypper install docker-compose"
|
||||||
|
elif command -v apk >/dev/null 2>&1; then echo "sudo apk add docker-cli-compose"
|
||||||
|
else echo ""
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
cd "$(dirname "$0")" || exit 1
|
cd "$(dirname "$0")" || exit 1
|
||||||
|
|
||||||
echo "======================================================================"
|
echo "======================================================================"
|
||||||
@@ -85,7 +104,14 @@ elif command -v docker-compose >/dev/null 2>&1; then
|
|||||||
hinweis "Nur das alte docker-compose gefunden — funktioniert, ist aber veraltet."
|
hinweis "Nur das alte docker-compose gefunden — funktioniert, ist aber veraltet."
|
||||||
else
|
else
|
||||||
problem "Docker Compose fehlt."
|
problem "Docker Compose fehlt."
|
||||||
rot " Installation (Debian/Ubuntu): sudo apt install docker-compose-plugin"
|
PAKET_BEFEHL="$(paket_befehl)"
|
||||||
|
if [ -n "$PAKET_BEFEHL" ]; then
|
||||||
|
rot " Auf diesem System: $PAKET_BEFEHL"
|
||||||
|
fi
|
||||||
|
# Der Weg, der auf JEDER Distribution funktioniert — Dockers eigenes Skript
|
||||||
|
# bringt das Compose-Plugin mit. Steht immer da, auch wenn oben schon ein
|
||||||
|
# passender Paketbefehl genannt wurde: Paketnamen wandern, dieses Skript nicht.
|
||||||
|
rot " Überall gültig: curl -fsSL https://get.docker.com | sudo sh"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$(id -u)" = "0" ]; then
|
if [ "$(id -u)" = "0" ]; then
|
||||||
|
|||||||
Reference in New Issue
Block a user