7 Commits

Author SHA1 Message Date
Hitonabi ee123f2a90 fix(install): Paketbefehl je Distribution statt stur apt
Ampel / ampel (push) Failing after 29s
Commander-Frage: "Was wenn der Unterbau nicht Debian ist?" Berechtigt - bei
fehlendem Compose nannte install.sh stur `sudo apt install
docker-compose-plugin`. Auf Arch, Fedora oder openSUSE ist das ein Befehl, den
es nicht gibt.

Erkannt wird ueber das VORHANDENE Paketwerkzeug (apt-get/dnf/yum/pacman/zypper/
apk), nicht ueber /etc/os-release: Ein Derivat kann sich anders nennen als sein
Unterbau, sein Paketwerkzeug liegt aber im PATH. Findet sich keines, wird nichts
behauptet. Auf der Rippy-VM gegengeprueft (Ubuntu 26.04 -> apt), unter Git Bash
gegengeprueft (kein Treffer -> keine Behauptung).

Dazu steht IMMER der Weg da, der auf jeder Distribution funktioniert:
get.docker.com bringt das Compose-Plugin mit. Paketnamen wandern, dieses Skript
nicht.

README stellt jetzt oben klar, dass die Distribution gleichgueltig ist - Rippy
bringt MakeMKV, HandBrake und abcde in seinen eigenen Containern mit, vom Host
braucht es nur Docker und einen Kernel, der das Laufwerk sieht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 12:59:42 +02:00
Hitonabi 0c269a97ee fix(metadaten): exakt schlaegt unscharf - Jikan beendet die Kette nur bei Treffer
Der SAVEPOINT v3.16 vermutete, das Aehnlichkeits-Gate 0,55 sei "grosszuegig" und
Jikan stehe in der Kette zu frueh (vor OMDb). Nachgerechnet - titel_aehnlichkeit
ist eine reine Funktion - ergibt sich ein anderes Bild als vermutet:

  "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 mit Confidence
0,85 - und OMDb, das ihn kennt, wurde nie gefragt.

Bemerkenswert dabei, und das widerlegt die Vermutung "einfach zu niedrig": Fuer
den Fall, fuer den Jikan ueberhaupt eingebaut wurde, ist das Gate sogar zu HOCH.
"Evangelion 2.22" gegen den MAL-Titel "Evangelion: 2.0 You Can (Not) Advance"
ergibt 0,51 und faellt durch. Eine einzelne Zahl kann beides nicht leisten.

Deshalb zwei Schwellen statt Umsortieren (Reihenfolge bleibt, AGENTS Regel B):
Nur ein praktisch exakter Titel (>= 0,9) beendet die Kette. Ein unscharfer
Treffer wird GEMERKT, dann wird OMDb gefragt - und erst wenn OMDb nichts hat,
kommt er als VORSCHLAG mit Confidence 0,6 zum Zug. Damit gewinnt "exakt" immer
gegen "unscharf", egal aus welcher Quelle.

Antwort auf die Commander-Frage "JIKAN ist drin - wird das genutzt?": ja, und ab
jetzt an der richtigen Stelle.

Ehrlicher Vorbehalt: Live gegengeprueft ist das nicht - MyAnimeList war waehrend
dieser Sitzung durchweg weg (Jikan antwortete HTTP 504 auf jede Anfrage). Die
Arithmetik des Gates ist davon unberuehrt und in test_jikan_helpers.py
festgehalten, die Kettenlogik in test_prescan_helpers.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 12:59:26 +02:00
Hitonabi 6db64230eb fix(dashboard): zwei Placebos raus, Restzeit rein - und Loeschen sagt die Zahl
Commander: "Der Server Status muss dringend ueberarbeitet werden, das Dashboard
soll ja quasi alles auf einen Blick zeigen." Berechtigt - die Karte hiess "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 bzw. 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" zaehlte die registrierten Eintraege aus /system/info - auch
   Leichen alter Container-Rebuilds. Die echte Erreichbarkeit steht in
   /capabilities (Celery-Ping) und wird jetzt von dort geholt.

Statt dessen: laufende Phase im Klartext mit RESTZEIT, jeder erreichbare Worker
mit Kernen/Vektorbefehlen/extern, freier Platz mit Warnung, wenn er nicht mehr
fuer eine Disc reicht. Und wenn kein Worker antwortet, steht das rot da statt
"1 Worker Online".

Restzeit auch im aktiven Rip-Banner und in der Job-Tabelle. Solange die
Datenlage duenn ist, steht dort "Restzeit wird gemessen" - keine erfundene Zahl.

Job entfernen fragt jetzt vorher nach den Rohdaten und nennt die GB, die daneben
liegen bleiben und danach nicht mehr erreichbar sind - auf Wunsch loescht es sie
mit. Der Fall aus v3.14, bei dem 75 GB unsichtbar verwaisten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 12:59:08 +02:00
Hitonabi 638edc6a9f feat(ui): Presets vom Worker, Warnung vor unerreichbaren Pfaden, Browser weg
Vier Commander-Punkte auf einmal, alle am selben Ort.

Presets (Punkt 1 + 3b): Die drei Auswahllisten in Einstellungen kommen jetzt
vom Worker statt aus dieser Datei, gefiltert auf die passende Aufloesung - die
4K-Liste bietet keine 1080p-Presets mehr als Normalfall an (genau dieser Griff
rechnete in v3.12 eine 4K-UHD auf 1080p herunter; bewusstes Verkleinern steht
jetzt in einer eigenen, benannten Gruppe). Knopf "Bestes waehlen" uebernimmt die
Empfehlung fuer alle drei Disc-Typen, und unter jeder Liste steht, WARUM. Ein
gespeicherter Wert, den der Worker nicht kennt, wird benannt statt verschluckt -
so faellt eine falsche Bestandseinstellung ueberhaupt auf.
Der Wizard entscheidet nicht mehr selbst, sondern nimmt dieselbe Empfehlung.
Damit gibt es nur noch EINE Stelle mit dieser Logik, und die hat Tests.

Warnung vor dem Start (Punkt 6 des Savepoints): Waehlt man einen externen
Encoder, der Quelle oder Ziel nicht erreicht, steht das JETZT im Rip-Dialog -
nicht erst nach einer Stunde Rip. Geprueft wird mit derselben Regel, die
pfad_lokal() im Worker anwendet. Bei "Automatisch" wird genannt, welcher Worker
die Aufgabe kaputtmachen koennte: die geteilte Queue nimmt den ersten freien.

Datei-Browser (Punkt 7): "Warum wird hier der Datei Browser noch angezeigt - das
ist doch quatsch." Er ist nicht wirklich redundant (nur ueber ihn geht ein
freier Zielordner fuer EINEN Rip), aber er ueberschrieb die Schnellwahl
stillschweigend. Jetzt eingeklappt - wer ihn aufklappt, entscheidet bewusst, und
/browse wird erst dann geholt. Nebenbefund: browseFiles wurde geladen und NIE
angezeigt, samt ungenutztem File-Icon - beides raus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 12:58:53 +02:00
Hitonabi aea26493db feat(installer): der Windows-Installer holt die Freigabe jetzt selbst von Rippy
DER BLOCKER aus dem SAVEPOINT v3.16 ist zu. Beide Installer (Kommandozeile und
GUI) fragen GET /worker-setup/pfad-map, pruefen mit Test-Path, ob DIESER PC die
Freigabe wirklich erreicht, und schreiben `set RIPPY_PATH_MAP=...` in
start-tray.bat und start-worker.bat.

Der Commander muss dafuer nichts ueber Container-Pfade wissen - das Feld bleibt
leer, der Installer holt den Wert. Von Hand geht es trotzdem (Knopf "Von Rippy
holen" bzw. -PfadMap), falls dieser PC die Freigabe anders erreicht.

Ist nichts erreichbar, steht das als Klartext im Log samt dem haeufigsten Grund
(fehlende Zugangsdaten - Freigabe einmal im Explorer oeffnen). Die Installation
laeuft weiter: ein Worker, der sich meldet und ehrlich scheitert, ist besser als
einer, der nicht existiert. Ein LEERES RIPPY_PATH_MAP wird bewusst nicht
gesetzt - pfad_lokal() liest das als "kein Mapping", und die Fehlermeldung im
Worker unterscheidet genau diese beiden Faelle.

GUI: neues Feld samt Knopf, Fenster 560->648 px. Layout headless gerendert und
angesehen (nichts ueberlappt, Umlaute korrekt), beide Dateien mit echtem
PowerShell 5.1 auf Parser-Fehler geprueft, UTF-8-BOM erhalten. .exe neu gebaut.
install.ps1 hatte durch ein Werkzeug LF statt CRLF bekommen - zurueckgedreht.

remote-transcode-worker.yml richtiggestellt: Dort stand "mount -t nfs
<rippy-host>:/srv/rippy" - das geht NICHT, auf der Rippy-Maschine laeuft kein
NFS- und kein Samba-Server, sie ist selbst nur Client der NAS. Der Weg, der
funktioniert: dieselbe Freigabe einhaengen, die auch Rippy nutzt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 12:58:23 +02:00
Hitonabi 8b7bdfe798 feat(api): drei Endpunkte, die das Raten beenden - plus Restzeit in /jobs
GET /worker-setup/pfad-map - was ein externer Worker fuer RIPPY_PATH_MAP
eintragen muss, abgeleitet aus Rippys eigenen Mounts. DAS war der stille
Blocker: /app/media/... sind Container-Pfade, ein externer Worker sieht sie nur
uebersetzt, und dieses Mapping setzte NIEMAND. Externes Encoden konnte deshalb
nie funktionieren, obwohl das Celery-Routing einwandfrei arbeitete (Job
95afdc89: angenommen, 182 ms spaeter abgelehnt). Hat Rippy keine Freigabe, sagt
der Endpunkt das im Klartext samt Abhilfe - statt ein leeres Mapping zu liefern.

GET /presets - Preset-Namen der Worker plus Empfehlung MIT Begruendung. Die
Namen standen bisher fest verdrahtet an vier Stellen im UI.

GET /jobs/{id}/rohdaten + DELETE /jobs/{id}?rohdaten=true - was liegen bleibt,
wenn ein Job aus der Liste fliegt. Grund (v3.14): Entfernen loescht bewusst
keine Dateien, aber Job und Rohdaten haengen nur an der Job-ID - der Rohschnitt
ist danach UNERREICHBAR. Damals verwaisten so 75 GB unsichtbar, gefunden erst
per SSH.

/jobs liefert jetzt eta_sekunden + eta_text. Die Schaetzung entsteht in der API
und nicht im Browser, aus drei Gruenden: ein Seitenwechsel setzte die Messreihe
zurueck, zwei offene Tabs zeigten verschiedene Zahlen, und fuer einen externen
Encoder-Worker gaebe es gar keine - genau dort wollte der Commander sie sehen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 12:58:06 +02:00
Hitonabi 4195854bf8 feat(bausteine): Preset-Liste, Pfad-Vorschlag und Restzeit - alles gemessen
Vier neue reine Bausteine, jeder mit Tests. Sie beantworten Fragen, die Rippy
bisher geraten oder gar nicht gestellt hat.

1. caps.parse_preset_liste - die Preset-NAMEN, die das HandBrake DIESES Workers
   wirklich kennt (`--preset-list`, Format im Worker-Image gemessen). Damit
   endet das Raten: die Namen unterscheiden sich je HandBrake-Version, und ein
   erfundener Name laesst die Kompression scheitern.
   Richtigstellung zum SAVEPOINT v3.16: Dort galt es als unmoeglich, die
   Hardware-Preset-Namen auf der Rippy-VM zu ermitteln, weil dort kein
   Hardware-Encoder laeuft. Gemessen ist das falsch - die Kategorie `Hardware/`
   steht vollstaendig in der Liste (VCN, NVENC, QSV, MF). HandBrake trennt
   zwei Fragen: --preset-list nennt alle Presets, --help nur die nutzbaren
   Encoder. Zwei Fragen, zwei Quellen.

2. mounts.pfad_map_vorschlag/pfad_map_zeile - der fehlende Anschluss fuer
   RIPPY_PATH_MAP. Geraten werden muss dafuer nichts: Rippy hat die Freigabe
   selbst eingehaengt und kennt ihre Quelle (//host/share). Mountpunkt plus
   Quelle IST das Mapping. NFS gibt bewusst "" - Windows-Schreibweise ist nicht
   ableitbar (AGENTS Regel D).
   Der Test fand dabei sofort die dokumentierte Windows-Falle: os.path.join
   baute `/app/media\rippy` in einen Container-Pfad, das Mapping waere still
   wirkungslos geblieben. _mountpoint nutzt jetzt posixpath.

3. presets.empfehlung - "immer das Beste" (Commander-Anforderung) ohne
   Punktesystem: fuer jede Lage eine feste Reihenfolge echter Namen, genommen
   wird der erste, den der Worker kennt. Hardware nur, wenn die Familie
   wirklich gemeldet ist (vce -> Preset heisst VCN, sonst liefe eine AMD-Karte
   unter dem Intel-Namen). vaapi und MF werden nie empfohlen: fuer vaapi gibt
   es kein Preset, bei MF ist die Nutzbarkeit nicht ablesbar.

4. eta - Restzeit aus dem gemessenen Fortschritt. Messreihe je Phase (Rip und
   Kompression haben nichts miteinander zu tun), Stillstand verlaengert die
   Schaetzung, und unter zwei Messwerten oder 60 Sekunden Spanne gibt es
   ehrlich keine Aussage. Test mit den echten 25.07.-Werten: 1,44 % in 29 min
   landet bei "noch ca. 1 Tag 23 h" - genau die Angabe, deren Fehlen den
   50-Stunden-Lauf unsichtbar machte.

Dazu: HandBrakes "Invalid preset <Name>" wird uebersetzt. Vorher stand im UI
nur "HandBrake endete mit Code 3" - dass der Preset-NAME das Problem ist, war
daraus nicht zu erraten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 12:57:51 +02:00
27 changed files with 2426 additions and 210 deletions
+6
View File
@@ -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
+36 -12
View File
@@ -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.
+137 -13
View File
@@ -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
+56 -8
View File
@@ -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
+12
View File
@@ -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()
+136
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+40 -4
View File
@@ -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,15 +441,43 @@ 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
break else:
jikan_unscharf = jikan_treffer
break
# Fallback 2: OMDb (eigene Datenbasis — findet oft, was TMDB nicht # Fallback 2: OMDb (eigene Datenbasis — findet oft, was TMDB nicht
# exakt trifft; braucht OMDB_API_KEY, sonst überspringt es sich selbst) # exakt trifft; braucht OMDB_API_KEY, sonst überspringt es sich selbst)
@@ -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:
+245
View File
@@ -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
},
}
+159
View File
@@ -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
+78
View File
@@ -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
+95 -1
View File
@@ -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"
)
+93
View File
@@ -47,3 +47,96 @@ def test_omdb_jahr_parsing():
assert parse_year("20052012") == 2005 # Serien-Zeitspanne assert parse_year("20052012") == 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 == {}
+212
View File
@@ -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>
+47 -21
View File
@@ -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 (20100 GB je Film)' ? 'nicht komprimieren, verlustfrei behalten (20100 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>
+97 -38
View File
@@ -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,45 +430,64 @@ 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>
<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={() => {
onClick={() => browseParent && laden(browseParent)} const auf = !browserOffen
disabled={!browseParent} setBrowserOffen(auf)
className="p-1 rounded-lg disabled:opacity-30 hover:bg-slate-200 dark:hover:bg-slate-800 text-slate-500 dark:text-slate-400" if (auf && browseDirs.length === 0) laden('/app/media')
title="Ordner hoch" }}
> 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"
<ArrowUp size={16} /> >
</button> {browserOffen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span className="text-xs font-mono truncate flex-1 text-slate-500 dark:text-slate-400">{browsePath}</span> Anderen Zielordner wählen (nur für diesen Rip)
<Button </button>
variant={customPath === browsePath ? 'amber' : 'secondary'}
size="sm" {browserOffen && (
onClick={() => setCustomPath(browsePath)} <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">
{customPath === browsePath ? '✓ gewählt' : 'Diesen Ordner nutzen'} <button
</Button> onClick={() => browseParent && laden(browseParent)}
</div> disabled={!browseParent}
<div className="max-h-36 overflow-y-auto p-1"> className="p-1 rounded-lg disabled:opacity-30 hover:bg-slate-200 dark:hover:bg-slate-800 text-slate-500 dark:text-slate-400"
{browseDirs.map(d => ( title="Ordner hoch"
<button >
key={d.path} <ArrowUp size={16} />
onClick={() => laden(d.path)} </button>
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left rounded-lg transition-colors text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800" <span className="text-xs font-mono truncate flex-1 text-slate-500 dark:text-slate-400">{browsePath}</span>
> <Button
<Folder size={14} className="text-slate-400" /> variant={customPath === browsePath ? 'amber' : 'secondary'}
{d.name} size="sm"
</button> onClick={() => setCustomPath(customPath === browsePath ? '' : browsePath)}
))} >
</div> {customPath === browsePath ? '✓ gewählt' : 'Diesen Ordner nutzen'}
</Button>
</div>
<div className="max-h-36 overflow-y-auto p-1">
{browseDirs.map(d => (
<button
key={d.path}
onClick={() => laden(d.path)}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left rounded-lg transition-colors text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
>
<Folder size={14} className="text-slate-400" />
{d.name}
</button>
))}
{browseDirs.length === 0 && (
<p className="px-3 py-1.5 text-xs text-slate-400">Keine Unterordner.</p>
)}
</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>
+85
View File
@@ -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
+227 -64
View File
@@ -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">
<div>
<div className="flex items-center justify-between text-xs font-mono mb-1">
<span className="text-slate-400 flex items-center gap-1.5"><Cpu size={14} className="text-amber-400" /> Auslastung:</span>
<span className="text-amber-400 font-bold">{aktiverJob ? `${cpuPercent}% (Aktiv)` : 'Standby (0%)'}</span>
</div>
<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-500"
style={{ width: `${aktiverJob ? Math.max(15, cpuPercent) : 5}%` }}
/>
</div>
</div>
<div> {/* 1. Was tut Rippy JETZT — mit Restzeit statt Phantasie-Last */}
<div className="flex items-center justify-between text-xs font-mono mb-1"> <div>
<span className="text-slate-400 flex items-center gap-1.5"><Activity size={14} className="text-amber-400" /> Worker Status:</span> <div className="flex items-center justify-between text-xs font-mono mb-1 gap-2">
<span className="text-slate-200">{workerCount} Worker Online</span> <span className="text-slate-400 flex items-center gap-1.5 flex-shrink-0">
</div> <Activity size={14} className="text-amber-400" /> Gerade:
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden"> </span>
<div className="h-full bg-emerald-500 rounded-full w-full" /> <span className={`text-right ${aktiverJob ? 'text-amber-400 font-bold' : 'text-slate-300'}`}>
</div> {phaseText}
</div> </span>
<div>
<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-200">{freiGb > 0 ? `${freiGb} GB frei` : 'Aktiv'}</span>
</div>
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
<div
className="h-full bg-amber-500/80 rounded-full transition-all duration-500"
style={{ width: `${Math.max(10, belegtPercent)}%` }}
/>
</div>
</div> </div>
{aktiverJob ? (
<>
<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-500"
style={{ width: `${Math.max(2, aktiverJob.progress)}%` }}
/>
</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>
<div className="w-full sm:w-40 h-24 flex-shrink-0 flex items-end justify-center pt-2"> {/* 2. Worker — die ECHTE Erreichbarkeit, plus was sie können */}
<svg viewBox="0 0 100 50" className="w-full h-full stroke-amber-400 fill-amber-500/10 transition-all duration-500"> <div>
<path <div className="flex items-center justify-between text-xs font-mono mb-1">
d={`${sparklinePath} L 100 50 L 0 50 Z`} <span className="text-slate-400 flex items-center gap-1.5">
strokeWidth="2.5" <Cpu size={14} className="text-amber-400" /> Worker erreichbar:
strokeLinecap="round" </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>
{workerOnline.length === 0 ? (
<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>
{/* 3. Platz — war schon immer echt */}
<div>
<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" /> {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 className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
belegtPercent > 90 ? 'bg-rose-500' : 'bg-amber-500/80'
}`}
style={{ width: `${Math.max(2, belegtPercent)}%` }}
/> />
</svg> </div>
{freiGb > 0 && freiGb < 60 && (
<p className="text-[11px] font-mono text-amber-400 mt-1">
Eine Blu-ray braucht roh ~40 GB, eine 4K-UHD bis 100 GB
das reicht nicht mehr für jede Disc.
</p>
)}
</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>
) )
} }
+164 -41
View File
@@ -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">
<p className="text-xs text-slate-500 dark:text-slate-400"> <div className="flex items-start justify-between gap-3">
Ein Preset je Quelle Rippy erkennt den Disc-Typ selbst und wählt passend aus. <p className="text-xs text-slate-500 dark:text-slate-400">
<strong className="text-slate-700 dark:text-slate-300"> Eine 4K-UHD gehört auf ein Ein Preset je Quelle Rippy erkennt den Disc-Typ selbst und wählt passend aus.
2160p-Preset</strong>, sonst rechnet HandBrake sie auf 1080p herunter und die <strong className="text-slate-700 dark:text-slate-300"> Eine 4K-UHD gehört auf ein
4K-Auflösung ist weg. 2160p-Preset</strong>, sonst rechnet HandBrake sie auf 1080p herunter und die
</p> 4K-Auflösung ist weg.
</p>
<Button
variant="secondary"
onClick={bestePresetsUebernehmen}
disabled={!presetInfo}
className="flex-shrink-0"
>
Bestes wählen
</Button>
</div>
<Select {presetInfo && (
label="Preset für 4K-UHD" <p className="text-xs text-slate-500 dark:text-slate-400">
value={settings.transcodePresetUhd} {presetInfo.quelle === 'worker' ? (
onChange={(e) => handleChange('transcodePresetUhd', e.target.value)} <>
> Die Liste kommt von deinen Workern selbst.
<option value={PRESET_KEINE}>Nicht komprimieren 4K verlustfrei behalten</option> {presetInfo.hardware.length > 0
<option value="H.265 MKV 2160p60 4K">H.265 MKV 2160p60 4K 4K erhalten</option> ? ` Hardware-Encoder gemeldet: ${presetInfo.hardware.join(', ')} — damit geht 4K in Minuten statt Tagen.`
<option value="HQ 2160p60 4K HEVC Surround">HQ 2160p60 4K HEVC Surround 4K, mehr Qualität</option> : presetInfo.simd_schnell
<option value="Super HQ 2160p60 4K HEVC Surround">Super HQ 2160p60 4K HEVC Surround 4K, größer</option> ? ' Kein Hardware-Encoder, aber ein Worker mit AVX2 — Software-H.265 ist damit schnell genug.'
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 bewusst auf 1080p verkleinern</option> : ' Kein Hardware-Encoder und kein AVX2 — hier ist Software-4K keine Option (gemessen: 2855 h je Film).'}
</Select> </>
) : (
<>
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 &amp; 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
+60
View File
@@ -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"):
+35
View File
@@ -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}",
+76
View File
@@ -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",
]
+63
View File
@@ -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
View File
@@ -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