Compare commits
54 Commits
ee123f2a90
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a14449ef85 | |||
| 4343b0901d | |||
| c3e77399ad | |||
| 75426224d1 | |||
| e5ff5373bb | |||
| 9b30c0a490 | |||
| 061f79ecda | |||
| a32a1bb075 | |||
| 32979d7bde | |||
| d15f4b2ab6 | |||
| a221a161f4 | |||
| 264b2c77f0 | |||
| 16ea54e35d | |||
| 81ba6bd1c6 | |||
| 06c1718a52 | |||
| de500f9890 | |||
| 9a151f3d8b | |||
| e165e2a0d3 | |||
| d15900d1eb | |||
| 4cb1fab416 | |||
| 4915bba0b3 | |||
| 96c400f2f2 | |||
| 936b0837bf | |||
| a9cfcb272a | |||
| 163216a68e | |||
| 9156e8a4a9 | |||
| bfb13f44a5 | |||
| f7555a17d1 | |||
| 19f3dc5330 | |||
| 6438beec05 | |||
| 5f01aa89fc | |||
| f146f33f5d | |||
| a448ebc4de | |||
| b4a0dd551a | |||
| 848deb1c24 | |||
| 9230d0dd7a | |||
| c171f8879c | |||
| 148ac494c2 | |||
| 58f4991d92 | |||
| 2a90538473 | |||
| 4c2331563c | |||
| 4cf7acbb96 | |||
| 549727f648 | |||
| 87484d6863 | |||
| be04a9772e | |||
| 2587fe63af | |||
| 87537bf3e5 | |||
| a15623cc70 | |||
| 8ccca90e3c | |||
| 2554c2633b | |||
| 0d8426d353 | |||
| 9fbaed4af8 | |||
| 108368d583 | |||
| 843c54cd1a |
@@ -55,7 +55,7 @@ Bibliotheks-APIs: `--help`/Doku prüfen und die Fundstelle im Commit nennen.
|
||||
- Kein Proxmox-LXC-Nesting-Workaround — das Projekt lebt bewusst in normalem Docker.
|
||||
- Kein ARM-Fork — Rippy ist ein Eigenbau von Grund auf.
|
||||
|
||||
## Aktueller Stand (25.07.2026)
|
||||
## Aktueller Stand (26.07.2026)
|
||||
|
||||
- ✅ **E2E bewiesen (v3.1):** BD-50 komplett durch die Kette (43 GB → 4,8 GB)
|
||||
- ✅ **Etappe 13 (v3.2):** Universal-Komfort-Runde — Media-Server-Integration,
|
||||
@@ -73,6 +73,25 @@ Bibliotheks-APIs: `--help`/Doku prüfen und die Fundstelle im Commit nennen.
|
||||
- ✅ **Etappe 20 (v3.15):** Aufräum-Runde — `/capabilities` 1,010 s → 0,003 s
|
||||
(Ping im Hintergrund), Kompression je Disc-Typ abwählbar (4K verlustfrei),
|
||||
Wizard empfiehlt nach gemessener CPU, vier tote Routen entfernt
|
||||
- ✅ **Etappe 21 (v3.17):** Der Blocker externes Encoden ist zu —
|
||||
`RIPPY_PATH_MAP` leitet Rippy aus seinen eigenen Mounts ab, der Installer holt
|
||||
es selbst; Presets kommen vom Worker statt aus dem Quelltext; Dashboard ohne
|
||||
Placebos, mit Restzeit. Dazu vier Bestandsfehler, alle live gemessen (u. a.
|
||||
„Neu komprimieren" ging nie, und `os.path.isdir` hing im Kernel)
|
||||
- ✅ **Etappe 22 (v3.18):** Auswurf wirkt endlich (MakeMKV verriegelt die Tür —
|
||||
erst entriegeln, dann prüfen statt glauben), externer Worker meldet sein Log
|
||||
nach Rippy, zeigt den laufenden Job und nimmt mehrere Aufträge an. Dazu **die
|
||||
Mount-Ursache**: Die CIFS-Verbindung lebt in der Netz-Namespace des
|
||||
api-Containers und stirbt mit ihm — eine Wache heilt das jetzt selbst
|
||||
- ✅ **Etappe 23 (v3.19):** Sprachwahl vor dem Rip (Ton + Untertitel, Automatik
|
||||
einstellbar), Auswurf wirkt wirklich (MakeMKV verriegelt die Tür), externer
|
||||
Worker mit Verwaltungsfenster/Deinstaller/Slots, Schlüssel-Automatik für 4K,
|
||||
Mount-Wiederanbindung 202 s → 8 s, Weitergabe an einem frischen Klon geprüft
|
||||
- ✅ **Etappe 24 (v3.20):** Rippy bremste sich selbst aus — das Rate-Limit lag
|
||||
unter der eigenen Last (100/min gegen 123/min), hinter dem Proxy teilten alle
|
||||
Clients einen Eimer, und ein abgewiesener Abruf leerte das UI. Dazu: der
|
||||
„Neu"-Knopf kennt jetzt die Phase (Rip oder Kompression) und fragt, wo er es
|
||||
nicht weiß; zwei Fehler im Deploy-Weg behoben
|
||||
- 📝 **Details immer in SAVEPOINT.md** — diese Sektion nennt nur die Etappe
|
||||
|
||||
## Was diese Sitzungen wiederholt gekostet hat
|
||||
@@ -87,3 +106,67 @@ Jedes Mal hätte eine Messung von unter einer Minute gereicht.
|
||||
**Und die Umkehrung gilt genauso:** gleiches `st_dev` heißt NICHT gleicher
|
||||
Mount. Wo eine Eigenschaft ausprobierbar ist, probiere sie aus, statt sie
|
||||
vorherzusagen.
|
||||
|
||||
**Ein Hintergrund-Prozess, der still scheitert, ist schlimmer als einer, der
|
||||
laut scheitert** (26.07.2026). Ein `except Exception: pass` in einer
|
||||
Vorrats-Schleife hat eine Stunde gekostet: Der Vorrat blieb leer, die Funktion
|
||||
lief direkt aufgerufen einwandfrei, und der Grund stand nirgends. Gefunden erst
|
||||
über die Thread-Zustände (`/proc/<pid>/task/*/stat`, Zustand `D` = im Kernel
|
||||
blockiert). Jede Hintergrund-Schleife MELDET ihren Fehler, und wer einen Vorrat
|
||||
anlegt, macht sein Alter abfragbar (`GET /health/vorraete`) — sonst ist am
|
||||
Endpunkt selbst nichts zu sehen.
|
||||
|
||||
**Ein Rückgabewert ist kein Beweis, wo die Wirkung prüfbar ist** (26.07.2026,
|
||||
zweimal am selben Abend). `CDROMEJECT` quittiert Erfolg auf einem verriegelten
|
||||
Laufwerk und wirft nichts aus; `mount` quittiert Erfolg auf einer Verbindung, die
|
||||
Sekunden später stirbt. Beide Fehler waren monatelang unsichtbar, weil der Code
|
||||
dem Rückgabewert glaubte. Nach einer Aktion den ZUSTAND fragen — und wenn er
|
||||
flattert, zweimal mit Abstand.
|
||||
|
||||
**Bei „zu langsam" die DAUER je Schritt messbar machen, nicht die plausibelste
|
||||
Ursache beheben** (26.07.2026). Eine Mount-Wiederanbindung brauchte 150 s; die
|
||||
erste, sehr plausible Erklärung war falsch, und die Änderung machte es langsamer
|
||||
(202 s). Erst Zeitstempel im Log zeigten die Stelle: ein `os.makedirs` in Zeile
|
||||
eins, das drei Minuten im Kernel hing. Danach 8 s. Wer eine Dauer nicht
|
||||
aufschlüsselt, optimiert die falsche Stelle.
|
||||
|
||||
**Netz-Pfade nie ungebremst anfassen.** `os.path.isdir`/`open` auf einem toten
|
||||
CIFS-Mount blockieren im Kernel und lassen sich aus Python NICHT abbrechen. Ein
|
||||
Kind-Prozess lässt sich abbrechen: `timeout N ls -d <pfad>` (Muster in
|
||||
`mounts.ist_erreichbar` und `rohdaten.verzeichnis_da`). Und „konnte nicht
|
||||
nachsehen" ist etwas anderes als „ist nicht da" — beides zu vermischen erzeugt
|
||||
falsche Aussagen im UI.
|
||||
|
||||
**Ein verpasster Abruf ist keine Nachricht über die Welt** (26.07.2026). Im UI
|
||||
stand fünfmal `catch(() => [])`: Jeder fehlgeschlagene Abruf hieß damit „es gibt
|
||||
keine Jobs, keine Laufwerke, keine Ablagen" — die Liste leerte sich für einen
|
||||
Takt und füllte sich vier Sekunden später wieder. Der Commander meldete das als
|
||||
„wird oft neu geladen", und die Ursache war unsichtbar, weil der Fehlerzweig
|
||||
nichts protokollierte. Wer nichts Neues weiß, behält, was er wusste: bei
|
||||
Fehlschlag `null` und den alten Stand stehen lassen — nie einen leeren Wert, der
|
||||
als Aussage gelesen wird.
|
||||
|
||||
**Eine eigene Schutzbremse gegen die eigene Last rechnen** (26.07.2026). Das
|
||||
Rate-Limit stand auf 100 Anfragen/min, während ein einziger offener Tab 111/min
|
||||
verursacht (Dashboard 75 + Log-Kasten 24 + Laufwerke 12). Rippy bremste sich
|
||||
also permanent selbst aus, und niemand sah es: Der 429 stand in keinem Log, und
|
||||
das UI verbuchte ihn als Leermeldung. Dazu der zweite Fehler — hinter einem
|
||||
Reverse-Proxy ist `request.client.host` IMMER der Proxy, also hatten Browser,
|
||||
zweiter Tab und Windows-Tray EINEN gemeinsamen Eimer (812 von 876 Anfragen kamen
|
||||
scheinbar von einer IP). Wer eine Grenze setzt, rechnet die eigene Grundlast vor,
|
||||
schreibt sie als Kommentar dazu und lässt jedes Greifen protokollieren.
|
||||
|
||||
**Eine geschluckte Warnung ist eine Falle** (26.07.2026). `cp "$ENV_SRC" .env
|
||||
2>/dev/null || echo "WARNUNG: …"` scheiterte auf der Ziel-VM bei JEDEM Deploy,
|
||||
weil die .env dort anders lag. Die Zeile scrollte im Build-Rauschen vorbei,
|
||||
gebaut wurde still mit einer zwei Tage alten Kopie — mit einem toten
|
||||
Download-Notbehelf darin, an dem jeder worker-Build abbrach. Entweder abbrechen
|
||||
oder so laut werden, dass es nicht zu übersehen ist (Dateidatum, Kandidatenliste);
|
||||
ein `|| echo` in einem 200-Zeilen-Log ist keins von beidem.
|
||||
|
||||
**Wenn der Commander eine Korrelation nennt, ist das eine Spur.** „Wenn der
|
||||
Worker installiert ist, wird der Bereich oft neu geladen" klang nach Bauchgefühl
|
||||
und war exakt richtig: `tray.py` fragt `/api/jobs` über Port 80, landet damit im
|
||||
Rate-Limit-Eimer des Browsers und drückt ihn über die Grenze. Dieselbe Lehre wie
|
||||
bei „auf Windows ginge das sofort" (Disc-Schlüssel) — die Beobachtung ernst
|
||||
nehmen, auch wenn die vermutete Erklärung („Celery-Ping?") daneben liegt.
|
||||
|
||||
+17
-1
@@ -36,7 +36,7 @@ Ein modular aufgebautes System, das bei Disc-Einwurf automatisch den Typ erkennt
|
||||
| Multi-Disc-Set-Handling (Release-Group-Resolver) | ✔ M | | |
|
||||
| SQLite-Cache für API-Rate-Limits | ✔ M | | |
|
||||
| ~~JWT-Auth (Access 15min/Refresh 7 Tage)~~ — GESTRICHEN 24.07.2026, siehe §10 | | | |
|
||||
| Rate-Limiting (100/min pro Client-IP) | ✔ M | | |
|
||||
| Rate-Limiting pro Client-IP — ~~100/min~~ **600/min** (26.07.2026, siehe Abweichungen) | ✔ M | | |
|
||||
| Celery-Queue + Redis mit AOF-Persistence | ✔ M | | |
|
||||
| React-UI (statisch via Nginx) | ✔ M | | |
|
||||
| Proxmox LXC Template + Ansible Playbooks | ✔ M | | |
|
||||
@@ -183,6 +183,22 @@ Ein modular aufgebautes System, das bei Disc-Einwurf automatisch den Typ erkennt
|
||||
passlib/bcrypt-Abhängigkeit hat die CI-Ampel gebrochen. Rate-Limiting
|
||||
(pro IP) bleibt. Wer Rippy je nach außen öffnet, stellt einen
|
||||
Reverse-Proxy mit eigener Auth davor (z. B. Authelia/Caddy basicauth).
|
||||
- **26.07.2026 — Rate-Limit von 100/min auf 600/min:** Das Muss-Feature
|
||||
(„Rate-Limiting pro Client-IP") bleibt unverändert, nur die Zahl ändert sich.
|
||||
Begründung: 100/min lag UNTER Rippys eigener Last. Nachgerechnet an den
|
||||
Taktgebern im UI verursacht ein einziger offener Tab 111 Anfragen pro Minute
|
||||
(Dashboard 75 + Log-Kasten 24 + Laufwerks-Suche 12), ein installierter
|
||||
Windows-Worker weitere 12. Die Bremse griff also im Normalbetrieb permanent —
|
||||
97 Antworten mit HTTP 429 im nginx-Log —, und weil das UI einen abgewiesenen
|
||||
Abruf als „nichts da" verbuchte, leerte sich die Job-Liste im Sekundentakt.
|
||||
Genau das hat der Commander als „wird oft neu geladen" gemeldet. 600/min =
|
||||
10 Anfragen pro Sekunde: Luft für mehrere Tabs und Worker, während der Zweck
|
||||
der Bremse (ein Skript in einer Endlosschleife, Hunderte pro Sekunde) weiter
|
||||
erfüllt ist. Die Rechnung steht als Kommentar in `ratelimit.py` und ist durch
|
||||
`test_grenze_deckt_die_eigene_last_ab` festgehalten — wer die Zahl senkt, muss
|
||||
dort vorbei. Zweiter Teil derselben Reparatur: Hinter dem nginx ist
|
||||
`request.client.host` immer der Proxy, alle Clients teilten sich also EINEN
|
||||
Eimer; jetzt gilt `X-Real-IP`.
|
||||
- **25.07.2026 — 4K-UHD-Disc-Schlüssel: Rippy stellt Platz bereit, keine
|
||||
Schlüssel:** Das Muss-Feature „MakeMKV-Ripping (lossless)" bleibt
|
||||
unverändert; ergänzt wird nur ein **persistentes MakeMKV-Datenverzeichnis**
|
||||
|
||||
+1007
-1
File diff suppressed because it is too large
Load Diff
@@ -34,7 +34,34 @@ set -euo pipefail
|
||||
REPO_URL="$1"
|
||||
ENV_SRC="${2:-$HOME/rippy/.env}"
|
||||
CLONE="${3:-$HOME/notfall-rippy}"
|
||||
DIENST="$4"
|
||||
# ⚠️ ${4:-}, nicht "$4" (Befund 26.07.2026): Leere Argumente ueberleben den Weg
|
||||
# durch ssh nicht — die Gegenseite bekommt die Befehlszeile als EINEN String und
|
||||
# parst sie neu, wobei "" ersatzlos verschwindet. Wer also ohne RIPPY_ENV_SRC,
|
||||
# RIPPY_CLONE und ohne Dienst-Argument deployte (der dokumentierte Normalfall!),
|
||||
# bekam von 'set -u' nur ein "line 5: $4: unbound variable" und kein Deploy.
|
||||
DIENST="${4:-}"
|
||||
|
||||
# ⚠️ NICHT DEPLOYEN, WÄHREND EIN JOB LÄUFT (Vorfall 26.07.2026, selbst verursacht)
|
||||
#
|
||||
# `docker compose up -d --build` baut den worker-Container neu — und tötet damit
|
||||
# einen laufenden Rip. Genau das ist passiert: mitten in einem Blu-ray-Rip, bei
|
||||
# 12 %, nach 5,1 GB. Die Warnung stand im SAVEPOINT und half nichts, weil sie
|
||||
# niemand las und nichts sie prüfte. Jetzt prüft es das Skript.
|
||||
#
|
||||
# Übersteuern mit RIPPY_TROTZDEM=1 — dann ist es eine Entscheidung und kein
|
||||
# Versehen.
|
||||
if [ -z "${RIPPY_TROTZDEM:-}" ]; then
|
||||
ANTWORT="$(curl -s -m 5 http://localhost:8000/health/arbeit 2>/dev/null || true)"
|
||||
case "$ANTWORT" in
|
||||
*'"arbeit":true'*|*'"arbeit": true'*)
|
||||
echo "ABBRUCH: Auf dieser Maschine läuft gerade ein Job." >&2
|
||||
echo " Ein Rebuild des worker-Containers würde ihn töten." >&2
|
||||
echo "$ANTWORT" | tr ',' '\n' | grep -E '"(status|titel|progress)"' >&2 || true
|
||||
echo " Warten, oder bewusst überstimmen: RIPPY_TROTZDEM=1 ./deploy.sh" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if [ ! -d "$CLONE/.git" ]; then
|
||||
git clone --branch main "$REPO_URL" "$CLONE"
|
||||
@@ -42,7 +69,32 @@ fi
|
||||
cd "$CLONE"
|
||||
git fetch origin main
|
||||
git reset --hard origin/main
|
||||
cp "$ENV_SRC" .env 2>/dev/null || echo "WARNUNG: keine .env unter $ENV_SRC gefunden"
|
||||
# ⚠️ Eine fehlende .env-Quelle war bisher eine geschluckte Warnung — und damit
|
||||
# eine Falle (Befund 26.07.2026). Auf der Ziel-VM lag die echte .env unter
|
||||
# ~/projects/rippy/.env, der Default zeigte auf ~/rippy/.env. Das `cp` schlug
|
||||
# also jedes Mal fehl, die Zeile scrollte im Build-Ausgabe-Rauschen vorbei, und
|
||||
# gebaut wurde mit der Kopie im Klon: zwei Tage alt, mit einem inzwischen toten
|
||||
# MAKEMKV_URL_BASE-Notbehelf und einem JWT_SECRET_KEY aus der Zeit vor dem
|
||||
# Auth-Rueckbau. Ergebnis: jeder worker-Build brach ab, und die Ursache stand
|
||||
# nirgends.
|
||||
if [ -f "$ENV_SRC" ]; then
|
||||
cp "$ENV_SRC" .env
|
||||
echo "OK: .env aus $ENV_SRC uebernommen."
|
||||
elif [ -f .env ]; then
|
||||
echo "WARNUNG: $ENV_SRC gibt es nicht — es gilt die .env IM KLON ($CLONE/.env)." >&2
|
||||
echo " Stand dieser Datei: $(date -r .env '+%d.%m.%Y %H:%M')" >&2
|
||||
echo " Ist das nicht gewollt: RIPPY_ENV_SRC=<pfad> setzen." >&2
|
||||
ls -1 "$HOME"/*/.env "$HOME"/*/*/.env 2>/dev/null | head -5 | sed 's/^/ Kandidat: /' >&2 || true
|
||||
else
|
||||
echo "ABBRUCH: Keine .env — weder unter $ENV_SRC noch im Klon." >&2
|
||||
echo " Ohne sie baut compose mit leeren Werten (kein DB-Passwort," >&2
|
||||
echo " keine API-Schluessel). RIPPY_ENV_SRC=<pfad> setzen." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sed -i '/^RIPPY_VERSION=/d' .env
|
||||
echo "RIPPY_VERSION=$(git rev-parse --short HEAD)" >> .env
|
||||
|
||||
docker compose -p rippy up -d --build $DIENST
|
||||
docker compose -p rippy ps --format 'table {{.Name}}\t{{.Status}}'
|
||||
REMOTE
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
# mount -t cifs //NAS/rippy /mnt/rippy-freigabe -o guest,iocharset=utf8
|
||||
# 3. Unten RIPPY_HOST setzen, den Freigabe-Pfad im volumes-Block anpassen
|
||||
# und starten:
|
||||
# RIPPY_HOST=192.168.178.162 docker compose \
|
||||
# RIPPY_HOST=<LAN-IP-der-Rippy-Maschine> 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
|
||||
|
||||
Binary file not shown.
@@ -1,68 +0,0 @@
|
||||
# Baut rippy.ico + RippyWorkerSetup.exe aus install-gui.ps1 (ps2exe).
|
||||
#
|
||||
# WANN NEU BAUEN: nur wenn sich install-gui.ps1 (die Installer-Oberfläche)
|
||||
# ändert. NICHT bei MakeMKV-/HandBrake-Updates - die .exe holt HandBrake zur
|
||||
# Laufzeit (GitHub latest) und den Worker-Code live von Rippy, friert also
|
||||
# keine Versionen ein.
|
||||
# AUSFUEHREN AUF WINDOWS (eine Windows-.exe kann nicht von Linux gebaut werden):
|
||||
# powershell -ExecutionPolicy Bypass -File build-exe.ps1
|
||||
# Danach RippyWorkerSetup.exe committen (die API serviert sie).
|
||||
$ErrorActionPreference = "Stop"
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
Set-Location $PSScriptRoot
|
||||
|
||||
$guiSrc = Join-Path $PSScriptRoot "install-gui.ps1"
|
||||
|
||||
# 1. Rippy-Disc-Icon 256x256 zeichnen -> PNG -> ICO-Container
|
||||
$bmp = New-Object System.Drawing.Bitmap 256, 256
|
||||
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
||||
$g.SmoothingMode = "AntiAlias"
|
||||
$g.Clear([System.Drawing.Color]::Transparent)
|
||||
$g.FillEllipse((New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(99, 102, 241))), 8, 8, 240, 240)
|
||||
$g.FillEllipse((New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(147, 51, 234))), 40, 40, 176, 176)
|
||||
$g.FillEllipse([System.Drawing.Brushes]::White, 104, 104, 48, 48)
|
||||
$ms = New-Object System.IO.MemoryStream
|
||||
$bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
$png = $ms.ToArray()
|
||||
|
||||
$icoStream = New-Object System.IO.MemoryStream
|
||||
$bw = New-Object System.IO.BinaryWriter $icoStream
|
||||
$bw.Write([UInt16]0); $bw.Write([UInt16]1); $bw.Write([UInt16]1) # ICONDIR
|
||||
$bw.Write([Byte]0); $bw.Write([Byte]0); $bw.Write([Byte]0); $bw.Write([Byte]0) # 256x256, colors, res
|
||||
$bw.Write([UInt16]1); $bw.Write([UInt16]32) # planes, bpp
|
||||
$bw.Write([UInt32]$png.Length); $bw.Write([UInt32]22) # size, offset
|
||||
$bw.Write($png); $bw.Flush()
|
||||
[System.IO.File]::WriteAllBytes("$PSScriptRoot\rippy.ico", $icoStream.ToArray())
|
||||
Write-Host "rippy.ico erzeugt ($($png.Length) Bytes PNG)"
|
||||
|
||||
# 2. ps2exe sicherstellen - TLS 1.2 ist der Fix für den NuGet-Bootstrap-Fehler
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor 3072
|
||||
if (-not (Get-Module -ListAvailable ps2exe)) {
|
||||
Write-Host "Bootstrappe NuGet + PSGallery ..."
|
||||
try { Get-PackageProvider -Name NuGet -ErrorAction Stop | Out-Null }
|
||||
catch { Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope CurrentUser | Out-Null }
|
||||
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted -ErrorAction SilentlyContinue
|
||||
Write-Host "Installiere ps2exe ..."
|
||||
Install-Module ps2exe -Scope CurrentUser -Force -AllowClobber
|
||||
}
|
||||
Import-Module ps2exe
|
||||
|
||||
# 3. Kompilieren (WinForms -> noConsole + STA, Icon eingebettet)
|
||||
#
|
||||
# -requireAdmin (seit 25.07.2026): Der Worker wird standardmäßig nach
|
||||
# "C:\Program Files\Rippy Worker" installiert, und dorthin darf nur ein
|
||||
# Administrator schreiben. Mit dem Schalter tragt die .exe ein Manifest, das
|
||||
# Windows beim Doppelklick EINMAL per UAC fragen lässt - danach stimmen die
|
||||
# Rechte für alles Weitere. Ohne ihn liefe der Installer unerhöht und
|
||||
# scheiterte beim Anlegen des Ordners; wer das Ziel ins eigene Profil legt,
|
||||
# braucht die Erhöhung nicht, wird aber trotzdem gefragt (normal für einen
|
||||
# Installer und besser als ein Fehlschlag mitten im Lauf).
|
||||
Invoke-ps2exe -inputFile $guiSrc -outputFile "$PSScriptRoot\RippyWorkerSetup.exe" `
|
||||
-iconFile "$PSScriptRoot\rippy.ico" -noConsole -STA -requireAdmin `
|
||||
-title "Rippy Worker Setup" -product "Rippy" -company "Rippy" -version "1.0.1.0"
|
||||
|
||||
if (Test-Path "$PSScriptRoot\RippyWorkerSetup.exe") {
|
||||
Write-Host "OK: RippyWorkerSetup.exe = $((Get-Item "$PSScriptRoot\RippyWorkerSetup.exe").Length) Bytes"
|
||||
} else {
|
||||
Write-Host "FEHLER: keine .exe erzeugt"
|
||||
}
|
||||
@@ -1,550 +0,0 @@
|
||||
# Rippy Windows-Worker - GRAFISCHER Installer (WinForms, keine externe Runtime).
|
||||
# Wird über rippy-worker-setup.bat gestartet (Doppelklick). Braucht nur
|
||||
# Python 3.10+ auf der Maschine - kein Docker, kein git.
|
||||
#
|
||||
# WICHTIG - KODIERUNG: Diese Datei MUSS als UTF-8 MIT BOM gespeichert werden.
|
||||
# PowerShell 5.1 liest .ps1 ohne BOM als ANSI; Umlaute werden dann zu Kauderwelsch
|
||||
# UND das Skript wirft einen Parser-Fehler, weil in "ö" ein Anführungszeichen
|
||||
# steckt. Mit BOM liest 5.1 korrekt UTF-8 - am 25.07.2026 beidseitig gemessen:
|
||||
# ohne BOM: "Größe" + Unerwartetes Token
|
||||
# mit BOM: "Größe" + laeuft
|
||||
# Nach jeder Änderung pruefen: powershell.exe -Command "Parser::ParseFile(...)"
|
||||
|
||||
param(
|
||||
[string]$RippyHost = "",
|
||||
[string]$WorkerName = $env:COMPUTERNAME.ToLower()
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
|
||||
# Standard-Zielverzeichnis: wie jedes andere Programm unter "Programme"
|
||||
# (Commander-Wunsch 25.07.2026 - vorher lag der Worker im Benutzerprofil, was
|
||||
# für ein Programm unüblich ist). GetFolderPath liefert den ECHTEN Pfad;
|
||||
# deutsche Windows-Versionen zeigen im Explorer "Programme" an, der Pfad heißt
|
||||
# trotzdem "C:\Program Files".
|
||||
#
|
||||
# Schreiben dorthin braucht Administratorrechte - deshalb ist die .exe mit
|
||||
# -requireAdmin gebaut (build-exe.ps1) und fragt beim Start einmal per UAC.
|
||||
# Ohne das wäre hier der nächste "Zugriff verweigert" programmiert.
|
||||
$StandardZiel = Join-Path ([Environment]::GetFolderPath("ProgramFiles")) "Rippy Worker"
|
||||
$InstallDir = $StandardZiel
|
||||
|
||||
# --- Farben: dieselben Töne wie das Rippy-Web-UI ---------------------------
|
||||
# Übernommen aus docker/ui/src/lib/design.ts bzw. den Tailwind-Klassen des UI,
|
||||
# damit der Installer nicht wie ein Fremdkörper wirkt.
|
||||
$cBg = [System.Drawing.Color]::FromArgb(15, 23, 42) # slate-900, Fensterfläche
|
||||
$cPanel = [System.Drawing.Color]::FromArgb(2, 6, 23) # slate-950, Log + Eingaben
|
||||
$cFeld = [System.Drawing.Color]::FromArgb(30, 41, 59) # slate-800, Eingabefelder
|
||||
$cText = [System.Drawing.Color]::FromArgb(226, 232, 240) # slate-200
|
||||
$cGedaempft= [System.Drawing.Color]::FromArgb(148, 163, 184) # slate-400, Beschriftungen
|
||||
$cIndigo = [System.Drawing.Color]::FromArgb(99, 102, 241)
|
||||
$cAmber = [System.Drawing.Color]::FromArgb(245, 158, 11)
|
||||
$cEmerald = [System.Drawing.Color]::FromArgb(16, 185, 129)
|
||||
$cPurple = [System.Drawing.Color]::FromArgb(126, 34, 206)
|
||||
|
||||
# --- Fenster ---------------------------------------------------------------
|
||||
$form = New-Object System.Windows.Forms.Form
|
||||
$form.Text = "Rippy Encoding-Worker - Installation"
|
||||
$form.ClientSize = New-Object System.Drawing.Size(560, 648)
|
||||
$form.StartPosition = "CenterScreen"
|
||||
$form.FormBorderStyle = "FixedDialog"
|
||||
$form.MaximizeBox = $false
|
||||
$form.BackColor = $cBg
|
||||
$form.ForeColor = $cText
|
||||
$form.Font = New-Object System.Drawing.Font("Segoe UI", 9)
|
||||
|
||||
# Fenster-Icon: Rippy-Disc zur Laufzeit zeichnen (Indigo-Kreis + weißes Loch)
|
||||
# - kein externes .ico nötig. Erscheint in Titelleiste und Taskleiste.
|
||||
try {
|
||||
$ibmp = New-Object System.Drawing.Bitmap 32, 32
|
||||
$ig = [System.Drawing.Graphics]::FromImage($ibmp)
|
||||
$ig.SmoothingMode = "AntiAlias"
|
||||
$ig.FillEllipse((New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(79, 70, 229))), 1, 1, 30, 30)
|
||||
$ig.FillEllipse([System.Drawing.Brushes]::White, 12, 12, 8, 8)
|
||||
$form.Icon = [System.Drawing.Icon]::FromHandle($ibmp.GetHicon())
|
||||
} catch {}
|
||||
|
||||
# --- Kopfbereich mit Farbverlauf --------------------------------------------
|
||||
# Das Rippy-UI hat oben einen Verlauf amber -> indigo -> purple (siehe
|
||||
# FirstRunWizard.tsx). WinForms kann das nicht von sich aus, also wird der
|
||||
# Verlauf im Paint-Ereignis gezeichnet - inklusive Disc-Symbol.
|
||||
$kopf = New-Object System.Windows.Forms.Panel
|
||||
$kopf.Dock = "Top"
|
||||
$kopf.Height = 104
|
||||
$form.Controls.Add($kopf)
|
||||
$kopf.Add_Paint({
|
||||
param($absender, $e)
|
||||
$flaeche = New-Object System.Drawing.Rectangle 0, 0, $absender.Width, $absender.Height
|
||||
$pinsel = New-Object System.Drawing.Drawing2D.LinearGradientBrush(
|
||||
$flaeche, $cAmber, $cPurple, [System.Drawing.Drawing2D.LinearGradientMode]::Horizontal)
|
||||
# Dreifarbig wie im UI: der Indigo-Ton in der Mitte
|
||||
$mischung = New-Object System.Drawing.Drawing2D.ColorBlend 3
|
||||
$mischung.Colors = @($cAmber, $cIndigo, $cPurple)
|
||||
$mischung.Positions = @(0.0, 0.45, 1.0)
|
||||
$pinsel.InterpolationColors = $mischung
|
||||
$e.Graphics.FillRectangle($pinsel, $flaeche)
|
||||
$pinsel.Dispose()
|
||||
|
||||
# Disc-Symbol: Ring aussen, weisses Loch in der Mitte
|
||||
$e.Graphics.SmoothingMode = "AntiAlias"
|
||||
$weiss = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(235, 255, 255, 255))
|
||||
$e.Graphics.FillEllipse($weiss, 24, 28, 48, 48)
|
||||
$e.Graphics.FillEllipse((New-Object System.Drawing.SolidBrush $cIndigo), 36, 40, 24, 24)
|
||||
$e.Graphics.FillEllipse($weiss, 45, 49, 6, 6)
|
||||
$weiss.Dispose()
|
||||
|
||||
# Texte direkt zeichnen - so bleibt der Verlauf ohne Kästchen sichtbar
|
||||
$fTitel = New-Object System.Drawing.Font("Segoe UI", 15, [System.Drawing.FontStyle]::Bold)
|
||||
$fUnter = New-Object System.Drawing.Font("Segoe UI", 9)
|
||||
$e.Graphics.DrawString("Rippy Encoding-Worker", $fTitel,
|
||||
[System.Drawing.Brushes]::White, 88, 26)
|
||||
$e.Graphics.DrawString(
|
||||
"Diese Maschine übernimmt die Video-Kompression für Rippy." + [char]10 +
|
||||
"Gerippt wird weiter auf der Rippy-Hauptmaschine.", $fUnter,
|
||||
(New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(225, 255, 255, 255))), 90, 56)
|
||||
$fTitel.Dispose(); $fUnter.Dispose()
|
||||
})
|
||||
|
||||
# Rippy-Adresse
|
||||
$lblHost = New-Object System.Windows.Forms.Label
|
||||
$lblHost.Text = "LAN-IP der Rippy-Maschine (Docker-Host) - NICHT dieser PC:"
|
||||
$lblHost.Location = New-Object System.Drawing.Point(24, 120)
|
||||
$lblHost.Size = New-Object System.Drawing.Size(500, 18)
|
||||
$lblHost.ForeColor = $cGedaempft
|
||||
$form.Controls.Add($lblHost)
|
||||
|
||||
$txtHost = New-Object System.Windows.Forms.TextBox
|
||||
$txtHost.Location = New-Object System.Drawing.Point(24, 140)
|
||||
$txtHost.Size = New-Object System.Drawing.Size(300, 26)
|
||||
$txtHost.Text = $RippyHost
|
||||
$txtHost.Font = New-Object System.Drawing.Font("Consolas", 10)
|
||||
$txtHost.BackColor = $cFeld
|
||||
$txtHost.ForeColor = $cText
|
||||
$txtHost.BorderStyle = "FixedSingle"
|
||||
$form.Controls.Add($txtHost)
|
||||
|
||||
# Worker-Name
|
||||
$lblName = New-Object System.Windows.Forms.Label
|
||||
$lblName.Text = "Name dieses Workers (frei wählbar):"
|
||||
$lblName.Location = New-Object System.Drawing.Point(24, 176)
|
||||
$lblName.Size = New-Object System.Drawing.Size(500, 18)
|
||||
$lblName.ForeColor = $cGedaempft
|
||||
$form.Controls.Add($lblName)
|
||||
|
||||
$txtName = New-Object System.Windows.Forms.TextBox
|
||||
$txtName.Location = New-Object System.Drawing.Point(24, 196)
|
||||
$txtName.Size = New-Object System.Drawing.Size(300, 26)
|
||||
$txtName.Text = $WorkerName
|
||||
$txtName.BackColor = $cFeld
|
||||
$txtName.ForeColor = $cText
|
||||
$txtName.BorderStyle = "FixedSingle"
|
||||
$form.Controls.Add($txtName)
|
||||
|
||||
# Zielverzeichnis - frei wählbar, Standard "Programme"
|
||||
$lblPfad = New-Object System.Windows.Forms.Label
|
||||
$lblPfad.Text = "Installieren nach:"
|
||||
$lblPfad.Location = New-Object System.Drawing.Point(24, 232)
|
||||
$lblPfad.Size = New-Object System.Drawing.Size(500, 18)
|
||||
$lblPfad.ForeColor = $cGedaempft
|
||||
$form.Controls.Add($lblPfad)
|
||||
|
||||
$txtPfad = New-Object System.Windows.Forms.TextBox
|
||||
$txtPfad.Location = New-Object System.Drawing.Point(24, 252)
|
||||
$txtPfad.Size = New-Object System.Drawing.Size(374, 26)
|
||||
$txtPfad.Text = $StandardZiel
|
||||
$txtPfad.Font = New-Object System.Drawing.Font("Consolas", 9)
|
||||
$txtPfad.BackColor = $cFeld
|
||||
$txtPfad.ForeColor = $cText
|
||||
$txtPfad.BorderStyle = "FixedSingle"
|
||||
$form.Controls.Add($txtPfad)
|
||||
|
||||
$btnPfad = New-Object System.Windows.Forms.Button
|
||||
$btnPfad.Text = "Ändern ..."
|
||||
$btnPfad.Location = New-Object System.Drawing.Point(408, 251)
|
||||
$btnPfad.Size = New-Object System.Drawing.Size(116, 28)
|
||||
$btnPfad.FlatStyle = "Flat"
|
||||
$btnPfad.BackColor = $cFeld
|
||||
$btnPfad.ForeColor = $cText
|
||||
$btnPfad.FlatAppearance.BorderColor = $cGedaempft
|
||||
$form.Controls.Add($btnPfad)
|
||||
$btnPfad.Add_Click({
|
||||
$dlg = New-Object System.Windows.Forms.FolderBrowserDialog
|
||||
$dlg.Description = "Ordner wählen - darin wird 'Rippy Worker' angelegt"
|
||||
$dlg.ShowNewFolderButton = $true
|
||||
if ($dlg.ShowDialog() -eq "OK") {
|
||||
# Der Dialog liefert den ELTERN-Ordner; der Unterordner kommt von uns,
|
||||
# damit die Deinstallation nie einen fremden Ordner mitlöscht.
|
||||
$txtPfad.Text = (Join-Path $dlg.SelectedPath "Rippy Worker")
|
||||
}
|
||||
})
|
||||
|
||||
# Netzwerk-Freigabe fuer die Rohdaten (RIPPY_PATH_MAP)
|
||||
#
|
||||
# DAS entscheidet, ob dieser Worker ueberhaupt etwas komprimieren kann
|
||||
# (Befund 26.07.2026): Rippy schickt ihm Pfade wie /app/media/rippy/<job> -
|
||||
# Pfade INNERHALB des Rippy-Containers. Ohne Uebersetzung auf eine Freigabe
|
||||
# sieht dieser PC dort nichts, nimmt die Aufgabe an und lehnt sie 182 ms
|
||||
# spaeter ab. Der Nutzer soll das nicht verstehen muessen: Rippy hat die
|
||||
# Freigabe selbst eingehaengt und kennt sie, der Knopf holt sie.
|
||||
$lblMap = New-Object System.Windows.Forms.Label
|
||||
$lblMap.Text = "Netzwerk-Freigabe mit den Rohdaten (holt Rippy selbst):"
|
||||
$lblMap.Location = New-Object System.Drawing.Point(24, 288)
|
||||
$lblMap.Size = New-Object System.Drawing.Size(500, 18)
|
||||
$lblMap.ForeColor = $cGedaempft
|
||||
$form.Controls.Add($lblMap)
|
||||
|
||||
$txtMap = New-Object System.Windows.Forms.TextBox
|
||||
$txtMap.Location = New-Object System.Drawing.Point(24, 308)
|
||||
$txtMap.Size = New-Object System.Drawing.Size(374, 26)
|
||||
$txtMap.Font = New-Object System.Drawing.Font("Consolas", 9)
|
||||
$txtMap.BackColor = $cFeld
|
||||
$txtMap.ForeColor = $cText
|
||||
$txtMap.BorderStyle = "FixedSingle"
|
||||
$form.Controls.Add($txtMap)
|
||||
|
||||
$btnMap = New-Object System.Windows.Forms.Button
|
||||
$btnMap.Text = "Von Rippy holen"
|
||||
$btnMap.Location = New-Object System.Drawing.Point(408, 307)
|
||||
$btnMap.Size = New-Object System.Drawing.Size(116, 28)
|
||||
$btnMap.FlatStyle = "Flat"
|
||||
$btnMap.BackColor = $cFeld
|
||||
$btnMap.ForeColor = $cText
|
||||
$btnMap.FlatAppearance.BorderColor = $cGedaempft
|
||||
$form.Controls.Add($btnMap)
|
||||
|
||||
$lblMapHilfe = New-Object System.Windows.Forms.Label
|
||||
$lblMapHilfe.Text = "Leer lassen und installieren - der Installer holt und prüft den Wert selbst."
|
||||
$lblMapHilfe.Location = New-Object System.Drawing.Point(24, 338)
|
||||
$lblMapHilfe.Size = New-Object System.Drawing.Size(500, 18)
|
||||
$lblMapHilfe.ForeColor = $cGedaempft
|
||||
$form.Controls.Add($lblMapHilfe)
|
||||
|
||||
# Autostart
|
||||
$chkAuto = New-Object System.Windows.Forms.CheckBox
|
||||
$chkAuto.Text = "Beim Anmelden automatisch starten (Tray-Symbol)"
|
||||
$chkAuto.Location = New-Object System.Drawing.Point(24, 364)
|
||||
$chkAuto.Size = New-Object System.Drawing.Size(400, 22)
|
||||
$chkAuto.Checked = $true
|
||||
$chkAuto.ForeColor = $cText
|
||||
$form.Controls.Add($chkAuto)
|
||||
|
||||
# Log-Bereich
|
||||
$log = New-Object System.Windows.Forms.TextBox
|
||||
$log.Location = New-Object System.Drawing.Point(24, 396)
|
||||
$log.Size = New-Object System.Drawing.Size(500, 174)
|
||||
$log.Multiline = $true
|
||||
$log.ReadOnly = $true
|
||||
$log.ScrollBars = "Vertical"
|
||||
$log.BackColor = $cPanel
|
||||
$log.ForeColor = $cText
|
||||
$log.BorderStyle = "FixedSingle"
|
||||
$log.Font = New-Object System.Drawing.Font("Consolas", 8.5)
|
||||
$form.Controls.Add($log)
|
||||
|
||||
# Knöpfe
|
||||
$btnInstall = New-Object System.Windows.Forms.Button
|
||||
$btnInstall.Text = "Installieren"
|
||||
$btnInstall.Location = New-Object System.Drawing.Point(24, 584)
|
||||
$btnInstall.Size = New-Object System.Drawing.Size(150, 40)
|
||||
$btnInstall.BackColor = $cAmber
|
||||
$btnInstall.ForeColor = $cBg
|
||||
$btnInstall.FlatStyle = "Flat"
|
||||
$btnInstall.Font = New-Object System.Drawing.Font("Segoe UI", 10, [System.Drawing.FontStyle]::Bold)
|
||||
$form.Controls.Add($btnInstall)
|
||||
|
||||
$btnStart = New-Object System.Windows.Forms.Button
|
||||
$btnStart.Text = "Worker starten"
|
||||
$btnStart.Location = New-Object System.Drawing.Point(190, 584)
|
||||
$btnStart.Size = New-Object System.Drawing.Size(150, 40)
|
||||
$btnStart.BackColor = $cEmerald
|
||||
$btnStart.ForeColor = [System.Drawing.Color]::White
|
||||
$btnStart.FlatStyle = "Flat"
|
||||
$btnStart.Font = New-Object System.Drawing.Font("Segoe UI", 10, [System.Drawing.FontStyle]::Bold)
|
||||
$btnStart.Visible = $false
|
||||
$form.Controls.Add($btnStart)
|
||||
|
||||
function Log($text) {
|
||||
$log.AppendText($text + "`r`n")
|
||||
[System.Windows.Forms.Application]::DoEvents()
|
||||
}
|
||||
|
||||
# --- RIPPY_PATH_MAP: holen und wirklich pruefen -----------------------------
|
||||
# Rippy leitet den Wert aus seinen eigenen eingehaengten Freigaben ab
|
||||
# (GET /worker-setup/pfad-map) - hier wird nichts geraten. Der Rueckgabewert
|
||||
# ist der fertige Wert fuer die .bat-Dateien, oder "" wenn Rippy keine
|
||||
# Freigabe hat (dann kann dieser Worker nichts komprimieren, und das muss
|
||||
# im Log stehen statt still zu passieren).
|
||||
function Hole-PfadMap($rippyHost) {
|
||||
try {
|
||||
$antwort = Invoke-RestMethod "http://$rippyHost/api/worker-setup/pfad-map" -TimeoutSec 15
|
||||
} catch {
|
||||
Log "HINWEIS: Rippy nach der Freigabe zu fragen schlug fehl ($($_.Exception.Message))."
|
||||
return ""
|
||||
}
|
||||
if ($antwort.hinweis) { Log $antwort.hinweis }
|
||||
if (-not $antwort.mapping) { return "" }
|
||||
return [string]$antwort.mapping
|
||||
}
|
||||
|
||||
# Erreicht DIESER PC die Freigaben wirklich? Ohne diese Probe merkt der Nutzer
|
||||
# den Fehler erst, wenn ein Job Stunden spaeter scheitert.
|
||||
function Pruefe-PfadMap($mapping) {
|
||||
$alleDa = $true
|
||||
foreach ($paar in ($mapping -split ";")) {
|
||||
if ($paar -notmatch "=") { continue }
|
||||
$ziel = ($paar -split "=", 2)[1]
|
||||
if (Test-Path -LiteralPath $ziel -ErrorAction SilentlyContinue) {
|
||||
Log " OK: $ziel ist von hier erreichbar."
|
||||
} else {
|
||||
$alleDa = $false
|
||||
Log " NICHT erreichbar: $ziel"
|
||||
}
|
||||
}
|
||||
if (-not $alleDa) {
|
||||
Log " Meist fehlen nur die Zugangsdaten: die Freigabe einmal im Explorer"
|
||||
Log " oeffnen und 'Anmeldedaten speichern' anhaken, dann erreicht der"
|
||||
Log " Worker sie auch. Die Installation laeuft trotzdem durch."
|
||||
}
|
||||
return $alleDa
|
||||
}
|
||||
|
||||
# --- Installations-Logik ---------------------------------------------------
|
||||
function Do-Install {
|
||||
$rHost = $txtHost.Text.Trim()
|
||||
$wName = $txtName.Text.Trim()
|
||||
if (-not $rHost) { [System.Windows.Forms.MessageBox]::Show("Bitte die LAN-IP der Rippy-Maschine eintragen.", "Fehlt"); return }
|
||||
if (-not $wName) { $wName = $env:COMPUTERNAME.ToLower() }
|
||||
|
||||
# Zielverzeichnis aus dem Feld. script:-Bereich, weil "Worker starten"
|
||||
# und die Abschlussmeldung denselben Pfad brauchen.
|
||||
$zielEingabe = $txtPfad.Text.Trim()
|
||||
if (-not $zielEingabe) { $zielEingabe = $StandardZiel }
|
||||
if (-not [System.IO.Path]::IsPathRooted($zielEingabe)) {
|
||||
[System.Windows.Forms.MessageBox]::Show(
|
||||
"Bitte einen vollständigen Pfad angeben, z. B. C:\Program Files\Rippy Worker.",
|
||||
"Pfad unvollständig")
|
||||
return
|
||||
}
|
||||
$script:InstallDir = $zielEingabe
|
||||
|
||||
$btnInstall.Enabled = $false
|
||||
$log.Clear()
|
||||
Log "Ziel: $InstallDir"
|
||||
|
||||
try {
|
||||
# 1. Python finden
|
||||
$python = $null
|
||||
foreach ($k in @("py -3", "python")) {
|
||||
try {
|
||||
$v = Invoke-Expression "$k --version" 2>&1
|
||||
if ("$v" -match "Python 3\.(\d+)" -and [int]$Matches[1] -ge 10) { $python = $k; break }
|
||||
} catch {}
|
||||
}
|
||||
if (-not $python) {
|
||||
Log "FEHLER: Python 3.10+ nicht gefunden."
|
||||
Log "Installieren mit: winget install Python.Python.3.12"
|
||||
Log "Danach diesen Installer erneut starten."
|
||||
$btnInstall.Enabled = $true
|
||||
return
|
||||
}
|
||||
Log "Python gefunden: $python"
|
||||
|
||||
# 2. Ordner + Worker-Code
|
||||
# Erst PRUEFEN, ob wir dort überhaupt schreiben dürfen. Die .exe ist mit
|
||||
# -requireAdmin gebaut und fragt per UAC, aber wer install-gui.ps1 direkt
|
||||
# startet, hat die Rechte nicht - dann soll hier ein brauchbarer Satz
|
||||
# stehen und nicht "Zugriff verweigert" aus irgendeiner Tiefe.
|
||||
try {
|
||||
New-Item -ItemType Directory -Force $InstallDir -ErrorAction Stop | Out-Null
|
||||
$probe = Join-Path $InstallDir ".schreibtest"
|
||||
Set-Content -Path $probe -Value "x" -ErrorAction Stop
|
||||
Remove-Item -Force $probe -ErrorAction SilentlyContinue
|
||||
} catch {
|
||||
Log "FEHLER: In '$InstallDir' darf nicht geschrieben werden."
|
||||
Log ""
|
||||
Log "Das ist normal bei Ordnern unter 'Programme' - dafür braucht es"
|
||||
Log "Administratorrechte. Zwei Wege:"
|
||||
Log " 1. Diesen Installer mit Rechtsklick > 'Als Administrator"
|
||||
Log " ausführen' starten (der empfohlene Weg), ODER"
|
||||
Log " 2. oben über 'Ändern ...' einen Ordner im eigenen"
|
||||
Log " Benutzerprofil wählen - dort geht es ohne Adminrechte."
|
||||
$btnInstall.Enabled = $true
|
||||
return
|
||||
}
|
||||
Set-Location $InstallDir
|
||||
$HandBrakeVersion = "1.11.2"
|
||||
try {
|
||||
$rel = Invoke-RestMethod "https://api.github.com/repos/HandBrake/HandBrake/releases/latest" -TimeoutSec 15 -Headers @{ "User-Agent" = "rippy-installer" }
|
||||
if ($rel.tag_name) { $HandBrakeVersion = ([string]$rel.tag_name).TrimStart("v") }
|
||||
} catch {}
|
||||
|
||||
Log "Lade Worker-Code von $rHost ..."
|
||||
Invoke-WebRequest "http://$rHost/api/worker-setup/paket" -OutFile worker.zip -UseBasicParsing
|
||||
Expand-Archive worker.zip -DestinationPath . -Force
|
||||
Remove-Item worker.zip
|
||||
|
||||
# 3. venv + Abhängigkeiten
|
||||
if (-not (Test-Path "venv")) { Log "Lege Python-Umgebung an ..."; Invoke-Expression "$python -m venv venv" }
|
||||
Log "Installiere Python-Abhängigkeiten (kann 1-2 min dauern) ..."
|
||||
& .\venv\Scripts\python.exe -m pip install --quiet --upgrade pip
|
||||
& .\venv\Scripts\pip.exe install --quiet -r requirements.txt
|
||||
& .\venv\Scripts\pip.exe install --quiet pystray pillow
|
||||
Log "Abhängigkeiten installiert."
|
||||
|
||||
# 4. HandBrakeCLI
|
||||
if (-not (Test-Path ".\HandBrakeCLI.exe") -and -not (Get-Command HandBrakeCLI.exe -ErrorAction SilentlyContinue)) {
|
||||
Log "Lade HandBrakeCLI $HandBrakeVersion (offizielles Release) ..."
|
||||
$url = "https://github.com/HandBrake/HandBrake/releases/download/$HandBrakeVersion/HandBrakeCLI-$HandBrakeVersion-win-x86_64.zip"
|
||||
Invoke-WebRequest $url -OutFile hb.zip -UseBasicParsing
|
||||
Expand-Archive hb.zip -DestinationPath . -Force
|
||||
Remove-Item hb.zip
|
||||
}
|
||||
Log "HandBrake bereit."
|
||||
|
||||
# 5. Netzwerk-Freigabe klaeren, BEVOR die Start-Skripte geschrieben werden.
|
||||
# Ohne RIPPY_PATH_MAP kann dieser Worker grundsaetzlich nicht
|
||||
# komprimieren - das war bis zum 26.07.2026 der stille Blocker.
|
||||
$mapping = $txtMap.Text.Trim()
|
||||
if ($mapping) {
|
||||
Log "Freigabe (von Hand eingetragen): $mapping"
|
||||
} else {
|
||||
Log "Frage Rippy, ueber welche Freigabe die Rohdaten erreichbar sind ..."
|
||||
$mapping = Hole-PfadMap $rHost
|
||||
if ($mapping) {
|
||||
$txtMap.Text = $mapping
|
||||
Log "Freigabe von Rippy: $mapping"
|
||||
}
|
||||
}
|
||||
if ($mapping) {
|
||||
Log "Pruefe Erreichbarkeit ..."
|
||||
[void](Pruefe-PfadMap $mapping)
|
||||
} else {
|
||||
Log "ACHTUNG: Ohne Freigabe kann dieser Worker NICHTS komprimieren."
|
||||
Log " Der Worker wird trotzdem installiert und meldet sich in"
|
||||
Log " Rippy - Jobs schlagen aber mit einer Klartext-Meldung fehl."
|
||||
}
|
||||
|
||||
# 6. Start-Skripte + Deinstaller erzeugen
|
||||
# RIPPY_PATH_MAP nur setzen, wenn es einen Wert gibt - eine leere
|
||||
# Zuweisung in einer .bat ist harmlos, aber ein gesetztes leeres
|
||||
# RIPPY_PATH_MAP liest pfad_lokal() als "kein Mapping", und die
|
||||
# Fehlermeldung im Worker unterscheidet genau diese beiden Faelle.
|
||||
$mapZeile = if ($mapping) { "set RIPPY_PATH_MAP=$mapping`r`n" } else { "" }
|
||||
|
||||
$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
|
||||
|
||||
$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
|
||||
|
||||
$uninstall = @"
|
||||
param([switch]`$Force)
|
||||
if (-not `$Force) { if ([System.Windows.Forms.MessageBox]::Show("Rippy-Worker '$wName' deinstallieren?","Rippy",4) -ne "Yes") { exit } }
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
Get-CimInstance Win32_Process | Where-Object { `$_.ExecutablePath -like "`$PSScriptRoot*" } | ForEach-Object { Stop-Process -Id `$_.ProcessId -Force -ErrorAction SilentlyContinue }
|
||||
Start-Sleep 2
|
||||
# Autostart-Verknüpfung entfernen - BEIDE möglichen Orte, weil die
|
||||
# Installation je nach Zielverzeichnis persönlich oder maschinenweit war.
|
||||
foreach (`$ordner in @("Startup", "CommonStartup")) {
|
||||
try { Remove-Item -Force (Join-Path ([Environment]::GetFolderPath(`$ordner)) "RippyWorker.lnk") -ErrorAction Stop } catch {}
|
||||
}
|
||||
# ... und die geplante Aufgabe aus älteren Installationen, falls vorhanden.
|
||||
schtasks /delete /tn "RippyWorker" /f 2>`$null | Out-Null
|
||||
try { Invoke-RestMethod -Method Delete "http://$rHost/api/workers/$wName" -TimeoutSec 5 | Out-Null } catch {}
|
||||
Set-Location (Split-Path `$PSScriptRoot -Parent)
|
||||
Remove-Item -Recurse -Force `$PSScriptRoot
|
||||
[System.Windows.Forms.MessageBox]::Show("Rippy-Worker deinstalliert.","Rippy")
|
||||
"@
|
||||
Set-Content -Path "uninstall.ps1" -Value $uninstall -Encoding ASCII
|
||||
|
||||
# 7. Autostart über den Autostart-ORDNER, nicht über schtasks.
|
||||
#
|
||||
# Befund 25.07.2026 (auf dem Commander-PC aufgetreten): schtasks /create
|
||||
# mit /tn "RippyWorker" legt die Aufgabe im WURZELORDNER der
|
||||
# Aufgabenplanung an, und das verlangt Administratorrechte. Der
|
||||
# Installer läuft normal ohne. schtasks schrieb deshalb
|
||||
# "FEHLER: Zugriff verweigert." nach stderr, und weil oben
|
||||
# $ErrorActionPreference = "Stop" steht, wurde daraus ein Abbruch der
|
||||
# GESAMTEN Installation - obwohl alles außer dem Autostart fertig war.
|
||||
# Der Nutzer sah nur "FEHLER: FEHLER: Zugriff verweigert" samt dem
|
||||
# irreführenden Tipp, die IP könne falsch sein.
|
||||
#
|
||||
# Der Autostart-Ordner braucht NIE Adminrechte, und der Nutzer kann die
|
||||
# Verknüpfung dort selbst sehen und löschen. Zwei Fliegen.
|
||||
if ($chkAuto.Checked) {
|
||||
try {
|
||||
# Liegt das Programm unter "Programme", ist es eine Installation
|
||||
# für die ganze Maschine - dann gehört der Autostart in den
|
||||
# Autostart-Ordner ALLER Benutzer. Das passt auch zur Rechtelage:
|
||||
# der Installer läuft dafür ohnehin erhöht, und bei einer
|
||||
# Erhöhung über ein FREMDES Administratorkonto wäre der
|
||||
# benutzereigene Ordner der des Admins - also der falsche.
|
||||
# Bei einer Installation ins eigene Profil bleibt es persönlich.
|
||||
$programme = [Environment]::GetFolderPath("ProgramFiles")
|
||||
$programmeX86 = [Environment]::GetFolderPath("ProgramFilesX86")
|
||||
$maschinenweit = $InstallDir.StartsWith($programme, "OrdinalIgnoreCase") `
|
||||
-or ($programmeX86 -and $InstallDir.StartsWith($programmeX86, "OrdinalIgnoreCase"))
|
||||
$autostartDir = if ($maschinenweit) {
|
||||
[Environment]::GetFolderPath("CommonStartup")
|
||||
} else {
|
||||
[Environment]::GetFolderPath("Startup")
|
||||
}
|
||||
$lnkPfad = Join-Path $autostartDir "RippyWorker.lnk"
|
||||
$wsh = New-Object -ComObject WScript.Shell
|
||||
$lnk = $wsh.CreateShortcut($lnkPfad)
|
||||
$lnk.TargetPath = (Join-Path $InstallDir "start-tray.bat")
|
||||
$lnk.WorkingDirectory = $InstallDir
|
||||
$lnk.Description = "Rippy Encoding-Worker"
|
||||
$lnk.Save()
|
||||
if ($maschinenweit) {
|
||||
Log "Autostart eingerichtet (für alle Benutzer dieser Maschine)."
|
||||
} else {
|
||||
Log "Autostart eingerichtet (für dich, startet bei der Anmeldung)."
|
||||
}
|
||||
} catch {
|
||||
# Nur eine Warnung: der Worker selbst ist fertig und startbar.
|
||||
Log "HINWEIS: Autostart konnte nicht eingerichtet werden ($($_.Exception.Message))."
|
||||
Log " Der Worker ist trotzdem fertig installiert und läuft."
|
||||
Log " Von Hand: start-tray.bat in den Autostart-Ordner verknüpfen"
|
||||
Log " (Windows-Taste + R, dann shell:startup eingeben)."
|
||||
}
|
||||
}
|
||||
|
||||
Log ""
|
||||
Log "FERTIG! Ordner: $InstallDir"
|
||||
Log "Klick 'Worker starten' - er erscheint dann in Rippy unter Einstellungen -> Worker."
|
||||
$btnStart.Visible = $true
|
||||
$btnInstall.Text = "Neu installieren"
|
||||
$btnInstall.Enabled = $true
|
||||
} catch {
|
||||
Log ""
|
||||
Log "FEHLER: $_"
|
||||
Log "Tipp: Ist die IP korrekt und die Rippy-Maschine im Heimnetz erreichbar?"
|
||||
$btnInstall.Enabled = $true
|
||||
}
|
||||
}
|
||||
|
||||
$btnMap.Add_Click({
|
||||
$rHost = $txtHost.Text.Trim()
|
||||
if (-not $rHost) {
|
||||
[System.Windows.Forms.MessageBox]::Show(
|
||||
"Bitte zuerst oben die LAN-IP der Rippy-Maschine eintragen.", "Fehlt")
|
||||
return
|
||||
}
|
||||
$m = Hole-PfadMap $rHost
|
||||
if ($m) {
|
||||
$txtMap.Text = $m
|
||||
Log "Freigabe von Rippy: $m"
|
||||
[void](Pruefe-PfadMap $m)
|
||||
}
|
||||
})
|
||||
$btnInstall.Add_Click({ Do-Install })
|
||||
$btnStart.Add_Click({
|
||||
Start-Process -FilePath (Join-Path $InstallDir "start-tray.bat") -WindowStyle Hidden
|
||||
[System.Windows.Forms.MessageBox]::Show("Worker gestartet - Symbol erscheint unten rechts neben der Uhr.", "Rippy")
|
||||
$form.Close()
|
||||
})
|
||||
|
||||
[void]$form.ShowDialog()
|
||||
@@ -1,259 +0,0 @@
|
||||
# Rippy: Nativer Windows-Transcode-Worker - Installation OHNE Docker.
|
||||
#
|
||||
# Was das Skript tut (alles nach -InstallDir, Standard "C:\Program Files\Rippy Worker"):
|
||||
# 1. Prüft Python (3.10+), legt ein venv an, installiert die Abhängigkeiten
|
||||
# 2. Lädt den Worker-Code direkt von deiner Rippy-Instanz (/api/worker-setup/paket)
|
||||
# 3. Lädt HandBrakeCLI (gepinnte Version, offizielles GitHub-Release),
|
||||
# falls nicht schon im PATH oder im Ordner vorhanden
|
||||
# 4. Fragt Rippy, über welche Netzwerk-Freigabe die Rohdaten erreichbar sind,
|
||||
# und prüft, ob DIESER PC sie wirklich sieht (RIPPY_PATH_MAP)
|
||||
# 5. Erzeugt start-worker.bat - Doppelklick startet den Worker
|
||||
#
|
||||
# Der Worker bedient NUR die Kompressions-Queue (transcode) - gerippt wird
|
||||
# weiterhin auf der Rippy-Hauptmaschine (dort hängt das Laufwerk).
|
||||
#
|
||||
# Aufruf (PowerShell):
|
||||
# .\install.ps1 -RippyHost 192.168.178.162 [-WorkerName mein-pc] [-Autostart]
|
||||
#
|
||||
# -Autostart legt eine Verknüpfung im Autostart-Ordner an (Start bei Anmeldung).
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory = $true)] [string]$RippyHost,
|
||||
[string]$WorkerName = $env:COMPUTERNAME.ToLower(),
|
||||
[switch]$Autostart,
|
||||
# Ü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
|
||||
# (Commander-Wunsch 25.07.2026). Dorthin schreiben braucht Adminrechte -
|
||||
# PowerShell also "Als Administrator" starten, oder hier einen Ordner im
|
||||
# eigenen Profil angeben.
|
||||
[string]$InstallDir = (Join-Path ([Environment]::GetFolderPath("ProgramFiles")) "Rippy Worker")
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
# HandBrake-Version: die NEUESTE offizielle (GitHub-Release) - so passt der
|
||||
# Windows-Worker zu dem, was der Update-Check im UI meldet (früher war hier
|
||||
# eine feste 1.9.2 hardcodiert, was verwirrte). Fällt auf eine bekannte
|
||||
# Version zurück, falls die GitHub-API gerade nicht erreichbar ist.
|
||||
$HandBrakeVersion = "1.11.2"
|
||||
try {
|
||||
$rel = Invoke-RestMethod "https://api.github.com/repos/HandBrake/HandBrake/releases/latest" `
|
||||
-TimeoutSec 15 -Headers @{ "User-Agent" = "rippy-installer" }
|
||||
if ($rel.tag_name) { $HandBrakeVersion = ([string]$rel.tag_name).TrimStart("v") }
|
||||
Write-Host "Neueste HandBrake-Version: $HandBrakeVersion"
|
||||
} catch { Write-Host "GitHub-API nicht erreichbar - nutze HandBrake $HandBrakeVersion" }
|
||||
$HandBrakeUrl = "https://github.com/HandBrake/HandBrake/releases/download/$HandBrakeVersion/HandBrakeCLI-$HandBrakeVersion-win-x86_64.zip"
|
||||
|
||||
Write-Host "== Rippy Windows-Worker Installation ==" -ForegroundColor Cyan
|
||||
|
||||
# 1. Python finden (py-Launcher oder python im PATH)
|
||||
$python = $null
|
||||
foreach ($kandidat in @("py -3", "python")) {
|
||||
try {
|
||||
$version = Invoke-Expression "$kandidat --version" 2>&1
|
||||
if ("$version" -match "Python 3\.(\d+)") {
|
||||
if ([int]$Matches[1] -ge 10) { $python = $kandidat; break }
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
if (-not $python) {
|
||||
Write-Host "FEHLER: Python 3.10+ nicht gefunden." -ForegroundColor Red
|
||||
Write-Host "Installieren mit: winget install Python.Python.3.12 (dann Skript erneut ausführen)"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "Python gefunden: $python"
|
||||
|
||||
# 2. Zielordner + Worker-Code von der Rippy-Instanz laden
|
||||
$ziel = $InstallDir
|
||||
try {
|
||||
New-Item -ItemType Directory -Force $ziel -ErrorAction Stop | Out-Null
|
||||
$probe = Join-Path $ziel ".schreibtest"
|
||||
Set-Content -Path $probe -Value "x" -ErrorAction Stop
|
||||
Remove-Item -Force $probe -ErrorAction SilentlyContinue
|
||||
} catch {
|
||||
Write-Host ""
|
||||
Write-Host "FEHLER: In '$ziel' darf nicht geschrieben werden." -ForegroundColor Red
|
||||
Write-Host "Das ist normal bei Ordnern unter 'Programme'. Zwei Wege:" -ForegroundColor Red
|
||||
Write-Host " 1. PowerShell als Administrator starten und erneut aufrufen, ODER" -ForegroundColor Red
|
||||
Write-Host " 2. ein eigenes Ziel angeben, z. B.:" -ForegroundColor Red
|
||||
Write-Host " -InstallDir `"`$env:LOCALAPPDATA\Rippy Worker`"" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
Write-Host "Ziel: $ziel"
|
||||
Set-Location $ziel
|
||||
|
||||
Write-Host "Lade Worker-Code von http://$RippyHost/api/worker-setup/paket ..."
|
||||
Invoke-WebRequest "http://$RippyHost/api/worker-setup/paket" -OutFile worker.zip -UseBasicParsing
|
||||
Expand-Archive worker.zip -DestinationPath . -Force
|
||||
Remove-Item worker.zip
|
||||
|
||||
# 3. venv + Abhängigkeiten
|
||||
if (-not (Test-Path "venv")) {
|
||||
Write-Host "Lege Python-Umgebung an ..."
|
||||
Invoke-Expression "$python -m venv venv"
|
||||
}
|
||||
& .\venv\Scripts\python.exe -m pip install --quiet --upgrade pip
|
||||
& .\venv\Scripts\pip.exe install --quiet -r requirements.txt
|
||||
# Nur für den Windows-Worker: Tray-Symbol (Status, Start/Stopp, Beenden)
|
||||
& .\venv\Scripts\pip.exe install --quiet pystray pillow
|
||||
Write-Host "Python-Abhängigkeiten installiert."
|
||||
|
||||
# 4. HandBrakeCLI besorgen (falls nötig)
|
||||
$hb = Get-Command HandBrakeCLI.exe -ErrorAction SilentlyContinue
|
||||
if (-not $hb -and -not (Test-Path ".\HandBrakeCLI.exe")) {
|
||||
Write-Host "Lade HandBrakeCLI $HandBrakeVersion (offizielles GitHub-Release) ..."
|
||||
Invoke-WebRequest $HandBrakeUrl -OutFile hb.zip -UseBasicParsing
|
||||
Expand-Archive hb.zip -DestinationPath . -Force
|
||||
Remove-Item hb.zip
|
||||
}
|
||||
if (-not (Test-Path ".\HandBrakeCLI.exe") -and -not $hb) {
|
||||
Write-Host "FEHLER: HandBrakeCLI.exe fehlt." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
Write-Host "HandBrakeCLI bereit."
|
||||
|
||||
# 5. Netzwerk-Freigabe klären (RIPPY_PATH_MAP) - der stille Blocker bis 26.07.2026
|
||||
#
|
||||
# Rippy schickt dem Worker Pfade wie /app/media/rippy/<job>. Das sind Pfade
|
||||
# INNERHALB des Rippy-Containers; dieser PC sieht sie nur, wenn sie auf eine
|
||||
# Netzwerk-Freigabe übersetzt werden. Ohne das nimmt der Worker die Aufgabe an
|
||||
# und lehnt sie Millisekunden später ab ("Keine Roh-MKVs gefunden") - gemessen
|
||||
# am Job 95afdc89. Geraten wird nichts: Rippy hat die Freigabe selbst
|
||||
# eingehängt und kennt ihre Quelle.
|
||||
if (-not $PfadMap) {
|
||||
Write-Host "Frage Rippy, über welche Freigabe die Rohdaten erreichbar sind ..."
|
||||
try {
|
||||
$antwort = Invoke-RestMethod "http://$RippyHost/api/worker-setup/pfad-map" -TimeoutSec 15
|
||||
if ($antwort.hinweis) { Write-Host $antwort.hinweis -ForegroundColor Yellow }
|
||||
if ($antwort.mapping) { $PfadMap = [string]$antwort.mapping }
|
||||
} catch {
|
||||
Write-Host "HINWEIS: Abfrage fehlgeschlagen ($($_.Exception.Message))." -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
if ($PfadMap) {
|
||||
Write-Host "Freigabe: $PfadMap"
|
||||
foreach ($paar in ($PfadMap -split ";")) {
|
||||
if ($paar -notmatch "=") { continue }
|
||||
$zielPfad = ($paar -split "=", 2)[1]
|
||||
if (Test-Path -LiteralPath $zielPfad -ErrorAction SilentlyContinue) {
|
||||
Write-Host " OK: $zielPfad ist von hier erreichbar." -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host " NICHT erreichbar: $zielPfad" -ForegroundColor Yellow
|
||||
Write-Host " Meist fehlen nur die Zugangsdaten: die Freigabe einmal im" -ForegroundColor Yellow
|
||||
Write-Host " Explorer öffnen und 'Anmeldedaten speichern' anhaken." -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Write-Host "ACHTUNG: Ohne Freigabe kann dieser Worker NICHTS komprimieren." -ForegroundColor Yellow
|
||||
Write-Host " Er wird trotzdem installiert; Jobs schlagen dann mit einer" -ForegroundColor Yellow
|
||||
Write-Host " Klartext-Meldung fehl statt still zu hängen." -ForegroundColor Yellow
|
||||
}
|
||||
# Leeres RIPPY_PATH_MAP nicht setzen: pfad_lokal() liest es als "kein Mapping",
|
||||
# und die Fehlermeldung im Worker unterscheidet genau diese beiden Fälle.
|
||||
$mapZeile = if ($PfadMap) { "set RIPPY_PATH_MAP=$PfadMap`r`n" } else { "" }
|
||||
|
||||
# 6. Start-Skript erzeugen
|
||||
$bat = @"
|
||||
@echo off
|
||||
rem Rippy Windows-Transcode-Worker - verbindet sich mit $RippyHost
|
||||
cd /d "%~dp0"
|
||||
set REDIS_URL=redis://${RippyHost}:6379/0
|
||||
set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy
|
||||
set API_URL=http://${RippyHost}:8000
|
||||
set WORKER_NAME=$WorkerName
|
||||
${mapZeile}set PATH=%~dp0;%PATH%
|
||||
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
|
||||
|
||||
# 7. Tray-Start (empfohlen): Symbol neben der Uhr, kein Konsolenfenster
|
||||
$trayBat = @"
|
||||
@echo off
|
||||
rem Rippy-Worker mit Tray-Symbol - verbindet sich mit $RippyHost
|
||||
cd /d "%~dp0"
|
||||
set REDIS_URL=redis://${RippyHost}:6379/0
|
||||
set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy
|
||||
set API_URL=http://${RippyHost}:8000
|
||||
set WORKER_NAME=$WorkerName
|
||||
set RIPPY_TRAY_HOST=$RippyHost
|
||||
${mapZeile}set PATH=%~dp0;%PATH%
|
||||
start "" venv\Scripts\pythonw.exe tray.py
|
||||
"@
|
||||
Set-Content -Path "start-tray.bat" -Value $trayBat -Encoding ASCII
|
||||
Write-Host "start-worker.bat + start-tray.bat erzeugt."
|
||||
|
||||
# 8. Deinstaller - MUSS-Kriterium: rückstandsfrei entfernbar
|
||||
$uninstall = @"
|
||||
# Rippy-Worker DEINSTALLIEREN: stoppt Prozesse, entfernt Autostart,
|
||||
# meldet den Worker in Rippy ab und löscht diesen Ordner komplett.
|
||||
param([switch]`$Force)
|
||||
if (-not `$Force) {
|
||||
`$antwort = Read-Host "Rippy-Worker '$WorkerName' wirklich deinstallieren? (j/n)"
|
||||
if (`$antwort -ne "j") { Write-Host "Abgebrochen."; exit }
|
||||
}
|
||||
Get-CimInstance Win32_Process | Where-Object {
|
||||
`$_.ExecutablePath -like "`$PSScriptRoot*"
|
||||
} | ForEach-Object { Stop-Process -Id `$_.ProcessId -Force -ErrorAction SilentlyContinue }
|
||||
Start-Sleep 2
|
||||
# Autostart-Verknüpfung entfernen - BEIDE möglichen Orte, weil die
|
||||
# Installation je nach Zielverzeichnis persönlich oder maschinenweit war.
|
||||
foreach (`$ordner in @("Startup", "CommonStartup")) {
|
||||
try { Remove-Item -Force (Join-Path ([Environment]::GetFolderPath(`$ordner)) "RippyWorker.lnk") -ErrorAction Stop } catch {}
|
||||
}
|
||||
# ... und die geplante Aufgabe aus älteren Installationen, falls vorhanden.
|
||||
schtasks /delete /tn "RippyWorker" /f 2>`$null | Out-Null
|
||||
try {
|
||||
Invoke-RestMethod -Method Delete "http://$RippyHost/api/workers/$WorkerName" -TimeoutSec 5 | Out-Null
|
||||
Write-Host "Worker-Eintrag in Rippy entfernt."
|
||||
} catch { Write-Host "Hinweis: Eintrag in Rippy ggf. von Hand löschen (Einstellungen -> Worker)." }
|
||||
Set-Location (Split-Path `$PSScriptRoot -Parent)
|
||||
Remove-Item -Recurse -Force `$PSScriptRoot
|
||||
Write-Host "Rippy-Worker deinstalliert." -ForegroundColor Green
|
||||
"@
|
||||
Set-Content -Path "uninstall.ps1" -Value $uninstall -Encoding ASCII
|
||||
Write-Host "uninstall.ps1 erzeugt."
|
||||
|
||||
# 9. Optional: Autostart bei Anmeldung (mit Tray)
|
||||
#
|
||||
# Über den Autostart-ORDNER, nicht über schtasks: Eine Aufgabe im
|
||||
# Wurzelordner der Aufgabenplanung anzulegen verlangt Administratorrechte, und
|
||||
# der Installer läuft normal ohne. Auf dem Commander-PC scheiterte das am
|
||||
# 25.07.2026 mit "FEHLER: Zugriff verweigert." - und weil oben
|
||||
# $ErrorActionPreference = "Stop" steht, riss es die ganze Installation mit,
|
||||
# obwohl nur der Autostart fehlte. Der Autostart-Ordner braucht nie Adminrechte.
|
||||
if ($Autostart) {
|
||||
try {
|
||||
# Unter "Programme" = Installation für die Maschine, also Autostart für
|
||||
# alle Benutzer. Sonst persönlich. Siehe install-gui.ps1 für die
|
||||
# ausführliche Begründung (Rechtelage bei fremdem Admin-Konto).
|
||||
$programme = [Environment]::GetFolderPath("ProgramFiles")
|
||||
$maschinenweit = $ziel.StartsWith($programme, "OrdinalIgnoreCase")
|
||||
$autostartDir = if ($maschinenweit) {
|
||||
[Environment]::GetFolderPath("CommonStartup")
|
||||
} else {
|
||||
[Environment]::GetFolderPath("Startup")
|
||||
}
|
||||
$wsh = New-Object -ComObject WScript.Shell
|
||||
$lnk = $wsh.CreateShortcut((Join-Path $autostartDir "RippyWorker.lnk"))
|
||||
$lnk.TargetPath = (Join-Path $ziel "start-tray.bat")
|
||||
$lnk.WorkingDirectory = $ziel
|
||||
$lnk.Description = "Rippy Encoding-Worker"
|
||||
$lnk.Save()
|
||||
Write-Host "Autostart eingerichtet (Verknüpfung im Autostart-Ordner)."
|
||||
} catch {
|
||||
Write-Host "HINWEIS: Autostart konnte nicht eingerichtet werden ($($_.Exception.Message))." -ForegroundColor Yellow
|
||||
Write-Host " Der Worker ist trotzdem fertig installiert." -ForegroundColor Yellow
|
||||
Write-Host " Von Hand: start-tray.bat in shell:startup verknüpfen." -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "FERTIG." -ForegroundColor Green
|
||||
Write-Host " Starten (mit Tray-Symbol): $ziel\start-tray.bat"
|
||||
Write-Host " Starten (Konsole/Debug): $ziel\start-worker.bat"
|
||||
Write-Host " Deinstallieren: $ziel\uninstall.ps1"
|
||||
Write-Host "Der Worker erscheint in Rippy unter Einstellungen -> Worker als '$WorkerName'."
|
||||
@@ -0,0 +1,412 @@
|
||||
import flet as ft
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import urllib.request
|
||||
import json
|
||||
import zipfile
|
||||
|
||||
|
||||
def main(page: ft.Page):
|
||||
page.title = "Rippy Encoding-Worker Installation"
|
||||
page.window_width = 800
|
||||
page.window_height = 650
|
||||
page.bgcolor = "#0f172a"
|
||||
page.theme_mode = ft.ThemeMode.DARK
|
||||
page.scroll = ft.ScrollMode.AUTO
|
||||
|
||||
# Colors
|
||||
c_panel = "#1e293b"
|
||||
c_text = "#e2e8f0"
|
||||
c_muted = "#94a3b8"
|
||||
c_accent = "#6366f1"
|
||||
c_success = "#10b981"
|
||||
|
||||
# State
|
||||
std_ziel = r"C:\Program Files\Rippy Worker"
|
||||
|
||||
# UI Fields
|
||||
txt_host = ft.TextField(
|
||||
label="LAN-IP der Rippy-Maschine (Docker-Host)",
|
||||
width=400,
|
||||
bgcolor=c_panel,
|
||||
color=c_text,
|
||||
)
|
||||
txt_name = ft.TextField(
|
||||
label="Name dieses Workers (frei wählbar)",
|
||||
width=250,
|
||||
value=os.environ.get("COMPUTERNAME", "worker").lower(),
|
||||
bgcolor=c_panel,
|
||||
color=c_text,
|
||||
)
|
||||
txt_slots = ft.Dropdown(
|
||||
label="Kerne (Gleichzeitig)",
|
||||
width=150,
|
||||
options=[ft.dropdown.Option(str(i)) for i in range(1, 9)],
|
||||
value="2" if os.cpu_count() and os.cpu_count() >= 12 else "1",
|
||||
bgcolor=c_panel,
|
||||
color=c_text,
|
||||
)
|
||||
|
||||
txt_pfad = ft.TextField(
|
||||
label="Installieren nach",
|
||||
value=std_ziel,
|
||||
expand=True,
|
||||
bgcolor=c_panel,
|
||||
color=c_text,
|
||||
)
|
||||
txt_map = ft.TextField(
|
||||
label="Netzwerk-Freigabe mit den Rohdaten",
|
||||
expand=True,
|
||||
bgcolor=c_panel,
|
||||
color=c_text,
|
||||
)
|
||||
|
||||
chk_auto = ft.Checkbox(
|
||||
label="Beim Anmelden automatisch starten (Hintergrund)",
|
||||
value=True,
|
||||
fill_color=c_accent,
|
||||
)
|
||||
chk_desktop = ft.Checkbox(
|
||||
label="Verknüpfung auf dem Desktop anlegen", value=True, fill_color=c_accent
|
||||
)
|
||||
|
||||
log_view = ft.ListView(height=150, auto_scroll=True, spacing=2)
|
||||
|
||||
def log(msg: str):
|
||||
log_view.controls.append(
|
||||
ft.Text(msg, color=c_muted, font_family="Consolas", size=12)
|
||||
)
|
||||
try:
|
||||
page.update()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def check_map(e):
|
||||
r_host = txt_host.value.strip()
|
||||
if not r_host:
|
||||
page.snack_bar = ft.SnackBar(
|
||||
ft.Text("Bitte zuerst die LAN-IP eintragen!"), bgcolor="red"
|
||||
)
|
||||
page.snack_bar.open = True
|
||||
page.update()
|
||||
return
|
||||
|
||||
try:
|
||||
log(f"Frage Rippy nach Freigabe ({r_host})...")
|
||||
with urllib.request.urlopen(
|
||||
f"http://{r_host}/api/worker-setup/pfad-map", timeout=5
|
||||
) as r:
|
||||
data = json.loads(r.read().decode())
|
||||
if data.get("hinweis"):
|
||||
log(data["hinweis"])
|
||||
if data.get("mapping"):
|
||||
txt_map.value = data["mapping"]
|
||||
log(f"Erfolgreich geholt: {data['mapping']}")
|
||||
page.update()
|
||||
except Exception as ex:
|
||||
log(f"Fehler: {ex}")
|
||||
|
||||
def do_install(e):
|
||||
btn_install.disabled = True
|
||||
page.update()
|
||||
threading.Thread(target=run_installation, daemon=True).start()
|
||||
|
||||
def run_installation():
|
||||
r_host = txt_host.value.strip()
|
||||
w_name = txt_name.value.strip()
|
||||
install_dir = txt_pfad.value.strip()
|
||||
|
||||
if not r_host:
|
||||
log("FEHLER: Rippy LAN-IP fehlt!")
|
||||
btn_install.disabled = False
|
||||
page.update()
|
||||
return
|
||||
|
||||
log(f"Zielverzeichnis: {install_dir}")
|
||||
|
||||
# Check Python
|
||||
python_exe = None
|
||||
for cmd in ["py -3", "python"]:
|
||||
try:
|
||||
res = subprocess.run(
|
||||
f"{cmd} --version", shell=True, capture_output=True, text=True
|
||||
)
|
||||
if "Python 3." in res.stdout:
|
||||
python_exe = cmd
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not python_exe:
|
||||
log("FEHLER: Python 3.10+ nicht gefunden.")
|
||||
log("Bitte mit 'winget install Python.Python.3.12' installieren.")
|
||||
btn_install.disabled = False
|
||||
page.update()
|
||||
return
|
||||
|
||||
log(f"Python gefunden: {python_exe}")
|
||||
|
||||
try:
|
||||
os.makedirs(install_dir, exist_ok=True)
|
||||
test_file = os.path.join(install_dir, ".test")
|
||||
with open(test_file, "w") as f:
|
||||
f.write("x")
|
||||
os.remove(test_file)
|
||||
except Exception:
|
||||
log(f"FEHLER: Keine Schreibrechte in {install_dir}")
|
||||
log("Bitte als Administrator starten oder Pfad aendern.")
|
||||
btn_install.disabled = False
|
||||
page.update()
|
||||
return
|
||||
|
||||
# Download worker package
|
||||
log(f"Lade Worker-Code von {r_host}...")
|
||||
try:
|
||||
zip_path = os.path.join(install_dir, "worker.zip")
|
||||
urllib.request.urlretrieve(
|
||||
f"http://{r_host}/api/worker-setup/paket", zip_path
|
||||
)
|
||||
with zipfile.ZipFile(zip_path, "r") as zip_ref:
|
||||
zip_ref.extractall(install_dir)
|
||||
os.remove(zip_path)
|
||||
except Exception as ex:
|
||||
log(f"FEHLER beim Download: {ex}")
|
||||
btn_install.disabled = False
|
||||
page.update()
|
||||
return
|
||||
|
||||
# Setup venv — --clear loescht einen alten venv komplett, damit keine
|
||||
# veralteten Pakete (z.B. flet 0.86 statt 0.23) ueberleben.
|
||||
log("Lege Python-Umgebung an (venv)...")
|
||||
subprocess.run(
|
||||
f"{python_exe} -m venv --clear venv", cwd=install_dir, shell=True
|
||||
)
|
||||
|
||||
log("Installiere Abhaengigkeiten (pip)...")
|
||||
subprocess.run(
|
||||
r"venv\Scripts\python.exe -m pip install --quiet --upgrade pip",
|
||||
cwd=install_dir,
|
||||
shell=True,
|
||||
)
|
||||
subprocess.run(
|
||||
r"venv\Scripts\pip.exe install --quiet -r requirements.txt",
|
||||
cwd=install_dir,
|
||||
shell=True,
|
||||
)
|
||||
subprocess.run(
|
||||
r"venv\Scripts\pip.exe install --quiet requests pillow",
|
||||
cwd=install_dir,
|
||||
shell=True,
|
||||
)
|
||||
|
||||
# Download Handbrake
|
||||
hb_path = os.path.join(install_dir, "HandBrakeCLI.exe")
|
||||
if not os.path.exists(hb_path):
|
||||
log("Lade HandBrakeCLI herunter...")
|
||||
try:
|
||||
hb_ver = "1.7.3" # Fallback if github fails
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
"https://api.github.com/repos/HandBrake/HandBrake/releases/latest",
|
||||
timeout=5,
|
||||
) as r:
|
||||
hb_data = json.loads(r.read().decode())
|
||||
if hb_data.get("tag_name"):
|
||||
hb_ver = hb_data["tag_name"].lstrip("v")
|
||||
except Exception:
|
||||
pass
|
||||
hb_zip = os.path.join(install_dir, "hb.zip")
|
||||
urllib.request.urlretrieve(
|
||||
f"https://github.com/HandBrake/HandBrake/releases/download/{hb_ver}/HandBrakeCLI-{hb_ver}-win-x86_64.zip",
|
||||
hb_zip,
|
||||
)
|
||||
with zipfile.ZipFile(hb_zip, "r") as zip_ref:
|
||||
zip_ref.extractall(install_dir)
|
||||
os.remove(hb_zip)
|
||||
except Exception as ex:
|
||||
log(f"Warnung bei HandBrake-Download: {ex}")
|
||||
|
||||
log("Erstelle Start-Skripte...")
|
||||
slots = txt_slots.value
|
||||
pool_arg = (
|
||||
"--pool=solo"
|
||||
if int(slots) <= 1
|
||||
else f"--pool=threads --concurrency={slots}"
|
||||
)
|
||||
map_zeile = (
|
||||
f"set RIPPY_PATH_MAP={txt_map.value.strip()}\r\n"
|
||||
if txt_map.value.strip()
|
||||
else ""
|
||||
)
|
||||
|
||||
gui_bat = f'@echo off\r\ncd /d "%~dp0"\r\nset REDIS_URL=redis://{r_host}:6379/0\r\nset DATABASE_URL=postgresql://rippy:rippy@{r_host}:5432/rippy\r\nset API_URL=http://{r_host}:8000\r\nset WORKER_NAME={w_name}\r\nset RIPPY_TRAY_HOST={r_host}\r\nset RIPPY_SLOTS={slots}\r\nset TZ=UTC\r\n{map_zeile}set PATH=%~dp0;%PATH%\r\nstart "" venv\\Scripts\\pythonw.exe gui.py'
|
||||
with open(os.path.join(install_dir, "start-gui.bat"), "w") as f:
|
||||
f.write(gui_bat)
|
||||
|
||||
work_bat = f'@echo off\r\ncd /d "%~dp0"\r\nset REDIS_URL=redis://{r_host}:6379/0\r\nset DATABASE_URL=postgresql://rippy:rippy@{r_host}:5432/rippy\r\nset API_URL=http://{r_host}:8000\r\nset WORKER_NAME={w_name}\r\nset RIPPY_SLOTS={slots}\r\nset TZ=UTC\r\n{map_zeile}set PATH=%~dp0;%PATH%\r\nvenv\\Scripts\\celery.exe -A celery_app worker --loglevel=info -Q transcode {pool_arg} -n {w_name}@%%h'
|
||||
with open(os.path.join(install_dir, "start-worker.bat"), "w") as f:
|
||||
f.write(work_bat)
|
||||
|
||||
# Uninstaller
|
||||
uninstall_ps1 = f"""param([switch]$Force)
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
if (-not $Force) {{
|
||||
$antwort = [System.Windows.Forms.MessageBox]::Show("Rippy-Worker '{w_name}' von diesem PC entfernen?", "Rippy Worker deinstallieren", 4, 32)
|
||||
if ($antwort -ne "Yes") {{ exit }}
|
||||
}}
|
||||
Get-WmiObject Win32_Process | Where-Object {{
|
||||
$_.ExecutablePath -like "$PSScriptRoot*" -or $_.CommandLine -like "*gui.py*" -or $_.CommandLine -like "*tray.py*"
|
||||
}} | ForEach-Object {{ Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }}
|
||||
Start-Sleep 3
|
||||
$desktopDir = if ($PSScriptRoot -match "(?i)Program Files") {{ [Environment]::GetFolderPath("CommonDesktopDirectory") }} else {{ [Environment]::GetFolderPath("Desktop") }}
|
||||
Remove-Item -Force "$desktopDir\\Rippy Worker.lnk" -ErrorAction SilentlyContinue
|
||||
schtasks /delete /tn "RippyWorker" /f *>$null
|
||||
Start-Process cmd -ArgumentList "/c ping 127.0.0.1 -n 3 >nul & rmdir /s /q `"$PSScriptRoot`"" -WindowStyle Hidden
|
||||
if (-not $Force) {{ [System.Windows.Forms.MessageBox]::Show("Rippy-Worker entfernt.", "Rippy Worker") }}
|
||||
"""
|
||||
with open(
|
||||
os.path.join(install_dir, "uninstall.ps1"), "w", encoding="utf-8"
|
||||
) as f:
|
||||
f.write(uninstall_ps1)
|
||||
|
||||
# Autostart
|
||||
if chk_auto.value:
|
||||
log("Richte Autostart (Task Scheduler) ein...")
|
||||
bat_path = os.path.join(install_dir, "start-gui.bat")
|
||||
subprocess.run(
|
||||
f'schtasks /create /tn "RippyWorker" /tr "\\"{bat_path}\\"" /sc ONLOGON /rl HIGHEST /f',
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
# Desktop Shortcut — ueber PowerShell/COM, weil win32com im
|
||||
# PyInstaller-Bundle nicht enthalten ist.
|
||||
if chk_desktop.value:
|
||||
try:
|
||||
public_desktop = os.path.join(
|
||||
os.environ.get("PUBLIC", r"C:\Users\Public"), "Desktop"
|
||||
)
|
||||
user_desktop = os.path.join(os.path.expanduser("~"), "Desktop")
|
||||
desktop = (
|
||||
public_desktop
|
||||
if "Program Files" in install_dir and os.path.isdir(public_desktop)
|
||||
else user_desktop
|
||||
)
|
||||
lnk_path = os.path.join(desktop, "Rippy Worker.lnk")
|
||||
bat_target = os.path.join(install_dir, "start-gui.bat")
|
||||
ico_path = os.path.join(install_dir, "rippy.ico")
|
||||
ps_cmd = (
|
||||
f"$ws = New-Object -ComObject WScript.Shell; "
|
||||
f'$sc = $ws.CreateShortcut("{lnk_path}"); '
|
||||
f'$sc.TargetPath = "{bat_target}"; '
|
||||
f'$sc.WorkingDirectory = "{install_dir}"; '
|
||||
f'$sc.IconLocation = "{ico_path}"; '
|
||||
f"$sc.WindowStyle = 7; "
|
||||
f"$sc.Save()"
|
||||
)
|
||||
subprocess.run(
|
||||
["powershell", "-NoProfile", "-Command", ps_cmd],
|
||||
capture_output=True,
|
||||
)
|
||||
log("Desktop-Verknüpfung erstellt.")
|
||||
except Exception as e:
|
||||
log(f"Warnung: Shortcut konnte nicht erstellt werden: {e}")
|
||||
|
||||
log("FERTIG!")
|
||||
btn_start.visible = True
|
||||
btn_install.text = "Neu installieren"
|
||||
btn_install.disabled = False
|
||||
page.update()
|
||||
|
||||
def start_worker(e):
|
||||
install_dir = txt_pfad.value.strip()
|
||||
bat_path = os.path.join(install_dir, "start-gui.bat")
|
||||
# os.startfile startet die .bat ueber die Shell-Verknuepfung —
|
||||
# CREATE_NO_WINDOW wuerde sie stumm schlucken.
|
||||
os.startfile(bat_path)
|
||||
page.window_close()
|
||||
|
||||
btn_install = ft.ElevatedButton(
|
||||
"Installieren",
|
||||
on_click=do_install,
|
||||
bgcolor=c_accent,
|
||||
color=ft.colors.WHITE,
|
||||
width=200,
|
||||
height=40,
|
||||
)
|
||||
btn_start = ft.ElevatedButton(
|
||||
"Worker starten",
|
||||
on_click=start_worker,
|
||||
bgcolor=c_success,
|
||||
color=ft.colors.WHITE,
|
||||
width=200,
|
||||
height=40,
|
||||
visible=False,
|
||||
)
|
||||
|
||||
def get_dir(e: ft.FilePickerResultEvent):
|
||||
if e.path:
|
||||
txt_pfad.value = e.path
|
||||
page.update()
|
||||
|
||||
dir_picker = ft.FilePicker(on_result=get_dir)
|
||||
page.overlay.append(dir_picker)
|
||||
|
||||
page.add(
|
||||
ft.Row(
|
||||
[
|
||||
ft.Icon(ft.icons.DISC_FULL, size=40, color=c_accent),
|
||||
ft.Text(
|
||||
"Rippy Encoding-Worker",
|
||||
size=28,
|
||||
weight=ft.FontWeight.BOLD,
|
||||
color=c_text,
|
||||
),
|
||||
]
|
||||
),
|
||||
ft.Text(
|
||||
"Diese Maschine übernimmt die Video-Kompression für Rippy. Gerippt wird weiter auf der Hauptmaschine.",
|
||||
color=c_muted,
|
||||
),
|
||||
ft.Divider(color="#334155"),
|
||||
ft.Row([txt_host], alignment=ft.MainAxisAlignment.START),
|
||||
ft.Row([txt_name, txt_slots], alignment=ft.MainAxisAlignment.START),
|
||||
ft.Row(
|
||||
[
|
||||
txt_pfad,
|
||||
ft.ElevatedButton(
|
||||
"Durchsuchen ...",
|
||||
on_click=lambda _: dir_picker.get_directory_path(
|
||||
"Installationsordner wählen"
|
||||
),
|
||||
bgcolor=c_panel,
|
||||
color=c_text,
|
||||
),
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.START,
|
||||
),
|
||||
ft.Row(
|
||||
[
|
||||
txt_map,
|
||||
ft.ElevatedButton(
|
||||
"Freigabe holen", on_click=check_map, bgcolor=c_panel, color=c_text
|
||||
),
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.START,
|
||||
),
|
||||
chk_auto,
|
||||
chk_desktop,
|
||||
ft.Container(
|
||||
content=log_view,
|
||||
bgcolor="#020617",
|
||||
padding=10,
|
||||
border_radius=5,
|
||||
border=ft.border.all(1, "#334155"),
|
||||
),
|
||||
ft.Row([btn_install, btn_start]),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ft.app(target=main)
|
||||
@@ -21,6 +21,7 @@ services:
|
||||
# hochgeladene KEYDB.cfg entgegen (Einstellungen → System).
|
||||
- MAKEMKV_DATA_DIR=/app/makemkv-data
|
||||
- LOG_LEVEL=INFO
|
||||
- RIPPY_VERSION=${RIPPY_VERSION:-dev}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c 'import urllib.request; urllib.request.urlopen(\"http://localhost:8000/health\")'"]
|
||||
interval: 30s
|
||||
@@ -102,6 +103,7 @@ services:
|
||||
# Episoden-Laufzeiten der Staffel (TMDB).
|
||||
- API_URL=http://api:8000
|
||||
- LOG_LEVEL=INFO
|
||||
- RIPPY_VERSION=${RIPPY_VERSION:-dev}
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /srv/rippy/media
|
||||
|
||||
@@ -21,7 +21,12 @@ COPY docker/api/ .
|
||||
# native Worker aus (GET /worker-setup/windows bzw. /worker-setup/paket) —
|
||||
# die Zielmaschine braucht weder git noch Docker.
|
||||
COPY docker/worker/*.py docker/worker/requirements.txt worker_dist/
|
||||
COPY deploy/worker-windows/install.ps1 deploy/worker-windows/install-gui.ps1 worker_dist/
|
||||
COPY deploy/worker-windows/installer.py worker_dist/
|
||||
# Rippy-Icon: Der Installer legt damit eine Verknüpfung auf den Desktop
|
||||
# (Commander-Wunsch 26.07.2026). Ohne die Datei auf der Zielmaschine trüge die
|
||||
# Verknüpfung das Standard-Batch-Symbol — und niemand fände sie zwischen den
|
||||
# anderen Icons wieder.
|
||||
COPY deploy/worker-windows/rippy.ico worker_dist/
|
||||
# Vorgebaute .exe (Rippy-Icon, kein Konsolenfenster) — auf Windows via
|
||||
# deploy/worker-windows/build-exe.ps1 erzeugt (Windows-.exe geht nicht von
|
||||
# Linux). Nur bei GUI-Aenderungen neu bauen, nicht bei Tool-Updates.
|
||||
|
||||
@@ -210,6 +210,24 @@ def delete_worker(name: str) -> None:
|
||||
conn.execute(workers.delete().where(workers.c.name == name))
|
||||
|
||||
|
||||
def hat_arbeit() -> bool:
|
||||
"""True, wenn IRGENDEIN Job noch nicht durch ist (egal auf welchem Gerät).
|
||||
|
||||
Gebraucht von der Mount-Wache: Eine Freigabe neu zu verbinden bedeutet ein
|
||||
`umount -l` — mitten in einem laufenden Rip oder Encode wäre das ein
|
||||
Datenverlust. `pending` zählt bewusst mit: so ein Job kann jeden Moment
|
||||
anlaufen.
|
||||
"""
|
||||
with engine.connect() as conn:
|
||||
zeile = conn.execute(
|
||||
select(jobs.c.id)
|
||||
.where(jobs.c.status.in_(
|
||||
("pending", "running", "ripping", "transcoding", "canceling")))
|
||||
.limit(1)
|
||||
).first()
|
||||
return zeile is not None
|
||||
|
||||
|
||||
def has_active_job(device: str) -> bool:
|
||||
"""True, wenn auf dem Gerät ein Job läuft oder wartet (Eject-Schutz)."""
|
||||
with engine.connect() as conn:
|
||||
|
||||
+35
-1
@@ -20,13 +20,47 @@ from detection import (
|
||||
|
||||
# include/uapi/linux/cdrom.h
|
||||
CDROMEJECT = 0x5309
|
||||
CDROM_LOCKDOOR = 0x5329 # 1 = Tür verriegeln, 0 = entriegeln
|
||||
CDROM_DRIVE_STATUS = 0x5326
|
||||
CDS_NO_DISC = 1
|
||||
CDS_TRAY_OPEN = 2
|
||||
|
||||
AUSWURF_WARTEN_SEKUNDEN = 5
|
||||
|
||||
|
||||
def eject(device_path: str) -> None:
|
||||
"""Wirft die Disc aus (CDROMEJECT-ioctl). Wirft OSError bei Fehlern."""
|
||||
"""Wirft die Disc aus und prüft es nach. Wirft OSError, wenn sie drin bleibt.
|
||||
|
||||
⚠️ ERST ENTRIEGELN (Befund 26.07.2026, am laufenden System gemessen): Ein
|
||||
nacktes CDROMEJECT wird von einem verriegelten Laufwerk mit ERFOLG quittiert
|
||||
und tut nichts. MakeMKV verriegelt die Tür während des Rips
|
||||
(`CDROM_LOCKDOOR 1`) und entriegelt sie nicht wieder — danach blieb die
|
||||
Schublade zu, während Rippy „Disc ausgeworfen" ins Log schrieb. Gegenprobe
|
||||
an derselben Disc: mit `CDROM_LOCKDOOR 0` davor geht sie auf (Status 2).
|
||||
Deshalb macht das Werkzeug `eject` immer beides.
|
||||
|
||||
Gleichlautend in worker/ripping.wirf_disc_aus — es gibt kein geteiltes Paket
|
||||
zwischen den Containern; dort steht die ausführliche Herleitung.
|
||||
"""
|
||||
import time
|
||||
|
||||
fd = os.open(device_path, os.O_RDONLY | os.O_NONBLOCK)
|
||||
try:
|
||||
try:
|
||||
ioctl(fd, CDROM_LOCKDOOR, 0)
|
||||
except OSError:
|
||||
pass # nicht verriegelt oder ioctl unbekannt — Auswurf trotzdem versuchen
|
||||
ioctl(fd, CDROMEJECT, 0)
|
||||
# Nachsehen statt hoffen. „Kein Datenträger" zählt mit: ein
|
||||
# Slot-Laufwerk hat keine Schublade.
|
||||
for _ in range(AUSWURF_WARTEN_SEKUNDEN):
|
||||
if ioctl(fd, CDROM_DRIVE_STATUS, 0) in (CDS_TRAY_OPEN, CDS_NO_DISC):
|
||||
return
|
||||
time.sleep(1)
|
||||
raise OSError(
|
||||
"Das Laufwerk hat den Auswurf angenommen, die Disc ist aber noch "
|
||||
"drin. Blockiert etwas die Schublade, oder läuft noch ein Zugriff?"
|
||||
)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
+452
-38
@@ -6,6 +6,7 @@ from typing import List, Optional, Dict
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import posixpath
|
||||
import shutil
|
||||
import time
|
||||
import uuid
|
||||
@@ -17,13 +18,21 @@ import makemkv_daten
|
||||
import makemkv_key
|
||||
import mounts as mount_verwaltung
|
||||
import notify
|
||||
import phasen
|
||||
import presets as preset_auswahl
|
||||
import rohdaten
|
||||
from celery_client import celery_client, start_rip
|
||||
from detection import CDS_DISC_OK, CDS_NO_DISC, CDS_TRAY_OPEN, drive_status
|
||||
|
||||
from config_validation import validate_config, ConfigValidationError
|
||||
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 (
|
||||
MAX_REQUESTS_PER_MINUTE,
|
||||
check_rate_limit,
|
||||
client_kennung,
|
||||
darf_melden,
|
||||
get_rate_limit_remaining,
|
||||
)
|
||||
from prescan import PreScan
|
||||
|
||||
# Auth (JWT/Login/API-Keys) KOMPLETT entfernt — Commander-Entscheid 24.07.2026:
|
||||
@@ -67,6 +76,13 @@ async def startup_event():
|
||||
# Worker-Erreichbarkeit im Hintergrund pingen (siehe _ping_knoten) — sonst
|
||||
# kostet JEDER Aufruf von /capabilities eine ganze Sekunde.
|
||||
asyncio.create_task(_ping_schleife())
|
||||
# Aus demselben Grund im Hintergrund: nachsehen, wo Rohdaten liegen. Ein
|
||||
# schlafendes NAS lässt os.path.isdir bis zum CIFS-Timeout hängen (hier
|
||||
# 10 s gemessen) — und /jobs wird alle 4 Sekunden abgefragt.
|
||||
asyncio.create_task(_rohdaten_schleife())
|
||||
# Freigaben bewachen: Nach einem Rebuild ist die CIFS-Freigabe reproduzierbar
|
||||
# tot, und der zweite Anlauf hilft (Begründung bei _mount_schleife).
|
||||
asyncio.create_task(_mount_schleife())
|
||||
|
||||
|
||||
# Auto-Pre-Scan-Ergebnisse je Laufwerk: das Dashboard zeigt damit sofort,
|
||||
@@ -216,10 +232,29 @@ async def disc_watcher():
|
||||
@app.middleware("http")
|
||||
async def rate_limit_middleware(request: Request, call_next):
|
||||
"""Rate-Limiting Middleware."""
|
||||
client_ip = request.client.host
|
||||
# Nicht `request.client.host` allein: Der Browser spricht über den nginx im
|
||||
# UI-Container, dessen Adresse sonst für ALLE Clients gälte (Herleitung in
|
||||
# ratelimit.client_kennung).
|
||||
client_ip = client_kennung(
|
||||
request.client.host if request.client else "",
|
||||
request.headers.get("x-real-ip") or request.headers.get("x-forwarded-for"),
|
||||
)
|
||||
|
||||
# Rate Limit prüfen
|
||||
if not check_rate_limit(client_ip):
|
||||
# Ein 429 war lange unsichtbar: Das UI verbuchte ihn als „nichts da".
|
||||
# Jetzt steht er im Log, damit die Bremse nicht wieder heimlich greift —
|
||||
# gedrosselt, damit ein Amok-Skript nicht das Log-Fenster flutet.
|
||||
try:
|
||||
if darf_melden(client_ip):
|
||||
db.add_log(
|
||||
"warning", "api",
|
||||
f"Rate-Limit erreicht für {client_ip} — Anfragen werden "
|
||||
f"abgewiesen (Grenze: {MAX_REQUESTS_PER_MINUTE}/min). Läuft "
|
||||
"dort ein Skript in einer Schleife?",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return Response(
|
||||
content=json.dumps({"error": "Rate limit exceeded"}),
|
||||
status_code=429,
|
||||
@@ -254,6 +289,10 @@ class Job(BaseModel):
|
||||
title: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
can_retry: bool = False # Rohdaten vorhanden → „Neu komprimieren" sinnvoll
|
||||
# Welche Wiederholung passt zu DIESEM Fehlschlag? "transcode" | "rip" |
|
||||
# "unklar" | "" — Herleitung und Begründung in phasen.py. Ohne dieses Feld
|
||||
# hieß der Knopf nur „Neu" und komprimierte immer, auch ein Rip-Bruchstück.
|
||||
retry_art: str = ""
|
||||
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.
|
||||
@@ -299,6 +338,65 @@ async def health_check():
|
||||
return {"status": "ok", "service": "api"}
|
||||
|
||||
|
||||
@app.get("/health/arbeit")
|
||||
async def health_arbeit():
|
||||
"""Läuft gerade ein Job? — die Frage VOR einem Deploy.
|
||||
|
||||
⚠️ Warum es das gibt (26.07.2026, selbst verursacht): Ein
|
||||
`docker compose up -d --build` baut den worker-Container neu und tötet damit
|
||||
einen laufenden Rip. Genau das ist passiert — mitten in einem Blu-ray-Rip,
|
||||
bei 12 %, nach 5,1 GB. Im SAVEPOINT stand die Warnung „nicht deployen,
|
||||
während ein Rip läuft" schon; sie half nichts, weil niemand sie las und
|
||||
nichts sie prüfte.
|
||||
|
||||
Jetzt fragt `deploy.sh` hier nach und bricht ab. Ein Satz Code gegen eine
|
||||
verlorene Stunde.
|
||||
"""
|
||||
def sammle():
|
||||
laufend = [
|
||||
{"id": z["id"], "status": z["status"], "progress": z.get("progress") or 0,
|
||||
"titel": z.get("title") or ""}
|
||||
for z in db.list_jobs()
|
||||
if z.get("status") in ("pending", "running", "ripping",
|
||||
"transcoding", "canceling")
|
||||
]
|
||||
return {"arbeit": bool(laufend), "jobs": laufend}
|
||||
|
||||
return await asyncio.to_thread(sammle)
|
||||
|
||||
|
||||
@app.get("/health/vorraete")
|
||||
async def health_vorraete():
|
||||
"""Laufen die Hintergrund-Schleifen wirklich? (Diagnose, kein UI-Endpunkt)
|
||||
|
||||
Zwei Endpunkte hängen an einem Vorrat, den eine Hintergrund-Schleife füllt:
|
||||
/capabilities am Celery-Ping, /jobs an der Rohdaten-Suche. Bleibt so ein
|
||||
Vorrat leer, ist am Endpunkt selbst NICHTS zu sehen — er antwortet nur
|
||||
dauerhaft „nichts gefunden". Genau daran ging am 26.07.2026 eine Stunde
|
||||
verloren. `alter_sekunden` sagt, wann die Schleife das letzte Mal
|
||||
durchgelaufen ist; wächst der Wert über das Intervall, läuft sie nicht mehr.
|
||||
"""
|
||||
jetzt = time.monotonic()
|
||||
return {
|
||||
"ping": {
|
||||
"knoten": _PING["knoten"],
|
||||
"alter_sekunden": None if _PING["stand"] < 0 else round(jetzt - _PING["stand"], 1),
|
||||
"intervall": PING_INTERVALL_SEKUNDEN,
|
||||
},
|
||||
"rohdaten": {
|
||||
"jobs_im_vorrat": len(_ROHDATEN["treffer"]),
|
||||
"mit_treffer": sum(1 for v in _ROHDATEN["treffer"].values() if v),
|
||||
"alter_sekunden": None if _ROHDATEN["stand"] is None
|
||||
else round(jetzt - _ROHDATEN["stand"], 1),
|
||||
"intervall": ROHDATEN_INTERVALL_SEKUNDEN,
|
||||
},
|
||||
"mounts": {
|
||||
"stand": _MOUNT_STAND,
|
||||
"intervall": MOUNT_WACHE_INTERVALL_SEKUNDEN,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {
|
||||
@@ -308,14 +406,188 @@ async def root():
|
||||
}
|
||||
|
||||
|
||||
def _rohdaten_suchen(job_id: str, work_dir: str) -> list:
|
||||
"""Wo liegen die Roh-MKVs dieses Jobs? (Details in rohdaten.py)
|
||||
|
||||
Geprüft wird mit `rohdaten.verzeichnis_da` und NICHT mit os.path.isdir:
|
||||
Letzteres hing am 26.07.2026 unbegrenzt im Kernel, während Rippy die
|
||||
CIFS-Freigabe neu einhängte — und nahm die Hintergrund-Schleife dauerhaft
|
||||
mit. Ausführliche Begründung dort.
|
||||
|
||||
Bleibt trotzdem der langsame Weg (mehrere Kind-Prozesse, im schlechtesten
|
||||
Fall je vier Sekunden). Für die Job-Liste, die das Dashboard alle vier
|
||||
Sekunden abfragt, gibt es deshalb den Vorrat unten.
|
||||
"""
|
||||
return rohdaten.suche(job_id, work_dir, os.listdir, rohdaten.verzeichnis_da)
|
||||
|
||||
|
||||
# Vorrat für die Job-Liste. Dasselbe Muster wie beim Celery-Ping in
|
||||
# /capabilities (v3.15): Der Endpunkt wird alle 4 Sekunden vom Dashboard
|
||||
# abgefragt und darf NIE am Dateisystem hängen. Ein schlafendes NAS hätte das
|
||||
# Dashboard sonst für 10 Sekunden je Aufruf eingefroren.
|
||||
_ROHDATEN = {"treffer": {}, "stand": None}
|
||||
ROHDATEN_INTERVALL_SEKUNDEN = 30
|
||||
|
||||
|
||||
# --- Mount-Wache ------------------------------------------------------------
|
||||
#
|
||||
# ⚠️ Warum es die braucht (26.07.2026, DREIMAL in Folge reproduziert): Nach
|
||||
# `docker compose up -d --build` ist die CIFS-Freigabe tot. `mount` meldet
|
||||
# Rückgabewert 0, /proc/mounts zeigt genau eine korrekt aussehende Schicht, die
|
||||
# Erreichbarkeits-Probe antwortet direkt nach dem Mount sogar — und Sekunden
|
||||
# später läuft jeder Zugriff in die Zeitgrenze. Derselbe Ablauf ein zweites Mal,
|
||||
# ein bis zwei Minuten später, stellt sie zuverlässig her.
|
||||
#
|
||||
# ## DIE URSACHE, gemessen am 26.07.2026
|
||||
#
|
||||
# /proc/fs/cifs/DebugData → Net namespace: 4026532653
|
||||
# api-Container → net:[4026532653] ← dieselbe
|
||||
# worker-Container → net:[4026532540] ← andere
|
||||
#
|
||||
# Die CIFS-Verbindung lebt in der NETZ-NAMESPACE DES API-CONTAINERS — hier wird
|
||||
# sie eingehängt (nur dieser Container hat CAP_SYS_ADMIN). Wird der Container neu
|
||||
# gebaut, stirbt sein Netz-Namespace und damit der Socket. Der Mount steht danach
|
||||
# weiter in /proc/mounts (er ist per rshared auf den Host propagiert) und sieht
|
||||
# vollkommen gesund aus — aber jeder Zugriff läuft in den CIFS-Timeout.
|
||||
#
|
||||
# Deshalb passiert es nach JEDEM Deploy, deshalb sieht `mount` gesund aus, und
|
||||
# deshalb hilft nur ein echtes Neu-Verbinden aus dem neuen Container heraus.
|
||||
#
|
||||
# Und deshalb ist der api-Container die einzige Stelle, die die NAS-Verbindung
|
||||
# hält: Startet er mitten in einem Rip neu, verliert auch der Worker sein Ziel.
|
||||
# Das strukturell zu lösen (Mount auf dem HOST statt im Container) wäre ein
|
||||
# eigener Umbau und widerspräche „Speicherziele über das UI einhängen".
|
||||
MOUNT_WACHE_INTERVALL_SEKUNDEN = 60
|
||||
# Erste Prüfung fast sofort: Genau nach einem Deploy ist die Lage kaputt, und
|
||||
# jede Sekunde Wartezeit ist eine Sekunde, in der Rippy sein Ziel nicht sieht.
|
||||
MOUNT_WACHE_ERSTE_PRUEFUNG_SEKUNDEN = 3
|
||||
_MOUNT_STAND = {}
|
||||
|
||||
|
||||
async def _mount_schleife():
|
||||
"""Sieht nach, ob die Freigaben antworten, und verbindet sie sonst neu."""
|
||||
await asyncio.sleep(MOUNT_WACHE_ERSTE_PRUEFUNG_SEKUNDEN)
|
||||
while True:
|
||||
try:
|
||||
await asyncio.to_thread(_mounts_nachsehen)
|
||||
except Exception as e: # darf nie sterben
|
||||
print(f"Mount-Wache fehlgeschlagen: {type(e).__name__}: {e}")
|
||||
await asyncio.sleep(MOUNT_WACHE_INTERVALL_SEKUNDEN)
|
||||
|
||||
|
||||
def _mounts_nachsehen() -> None:
|
||||
"""Unerreichbare Freigaben neu verbinden — NIE während ein Job läuft.
|
||||
|
||||
Neu verbinden heißt `umount -l`; mitten in einem Rip oder Encode wäre das
|
||||
ein Datenverlust. Deshalb steht die Wache still, solange irgendein Job nicht
|
||||
durch ist — auch bei einem wartenden, der jeden Moment anlaufen kann.
|
||||
"""
|
||||
eintraege = db.list_mounts()
|
||||
if not eintraege or db.hat_arbeit():
|
||||
return
|
||||
for eintrag in eintraege:
|
||||
name = eintrag["name"]
|
||||
# Zum ERKENNEN genügt die einfache, schnelle Probe: Ein toter Mount
|
||||
# antwortet gar nicht, nicht nur manchmal. Die Doppelprobe steckt dort,
|
||||
# wo sie hingehört — in `mounten()`, direkt nach einem frischen Mount, wo
|
||||
# der Wettlauf mit dem lazy umount lauert. Hier kostete sie nur jede
|
||||
# Minute drei Sekunden Warten für nichts.
|
||||
erreichbar = mount_verwaltung.ist_erreichbar(name)
|
||||
vorher = _MOUNT_STAND.get(name)
|
||||
_MOUNT_STAND[name] = erreichbar
|
||||
if erreichbar:
|
||||
if vorher is False:
|
||||
db.add_log("success", "mounts", f"{name}: antwortet wieder")
|
||||
continue
|
||||
# Nur beim ÜBERGANG meckern, nicht jede Minute: Ist das NAS
|
||||
# ausgeschaltet, wäre das sonst ein Log-Wasserfall.
|
||||
if vorher is not False:
|
||||
db.add_log("warning", "mounts",
|
||||
f"{name}: antwortet nicht — wird neu verbunden")
|
||||
begonnen = time.monotonic()
|
||||
try:
|
||||
mount_verwaltung.reparieren(
|
||||
name, eintrag["typ"], eintrag["quelle"],
|
||||
eintrag.get("optionen") or "", eintrag.get("username") or "",
|
||||
eintrag.get("passwort") or "",
|
||||
)
|
||||
_MOUNT_STAND[name] = True
|
||||
# Die DAUER mit ins Log: Sie war die entscheidende Spur, als eine
|
||||
# Wiederanbindung drei Minuten brauchte (ein `stat` auf dem toten
|
||||
# Mount). Steht sie da, muss man sie nicht erst rekonstruieren.
|
||||
db.add_log("success", "mounts",
|
||||
f"{name}: neu verbunden ({time.monotonic() - begonnen:.1f}s)")
|
||||
except Exception as e:
|
||||
db.add_log(
|
||||
"warning", "mounts",
|
||||
f"{name}: Neuverbinden fehlgeschlagen nach "
|
||||
f"{time.monotonic() - begonnen:.1f}s — {e}")
|
||||
|
||||
|
||||
async def _rohdaten_schleife():
|
||||
"""Hält den Rohdaten-Vorrat frisch. Darf nie sterben.
|
||||
|
||||
Der Fehler wird GEMELDET, nicht verschluckt: Ein `except Exception: pass`
|
||||
stand hier zuerst, und als der Vorrat leer blieb, war nicht zu sehen, warum
|
||||
(Sitzung 26.07.2026 — eine Stunde Rätselraten für eine Zeile Log).
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
await asyncio.to_thread(_rohdaten_vorrat_auffrischen)
|
||||
except Exception as e: # DB/NAS weg → beim nächsten Durchlauf erneut
|
||||
print(f"Rohdaten-Vorrat fehlgeschlagen: {type(e).__name__}: {e}")
|
||||
await asyncio.sleep(ROHDATEN_INTERVALL_SEKUNDEN)
|
||||
|
||||
|
||||
def _rohdaten_vorrat_auffrischen() -> None:
|
||||
"""Für jeden fehlgeschlagenen Job nachsehen, wo seine Rohdaten liegen.
|
||||
|
||||
„Konnte nicht nachsehen" behält die letzte bekannte Antwort: Nach einem
|
||||
Container-Neustart stallt der erste Zugriff auf die CIFS-Freigabe mehrere
|
||||
Sekunden. Ohne diese Regel verschwände in dem Fenster der Knopf
|
||||
„Neu komprimieren", und der Nutzer schlösse daraus, seine 74 GB seien weg.
|
||||
"""
|
||||
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
|
||||
alt = _ROHDATEN["treffer"]
|
||||
treffer = {}
|
||||
for zeile in db.list_jobs():
|
||||
if zeile.get("status") != "failed":
|
||||
continue
|
||||
job_id = zeile["id"]
|
||||
ergebnis = rohdaten.suche_mit_status(job_id, work_dir, os.listdir)
|
||||
if not ergebnis["pfade"] and ergebnis["unklar"] and alt.get(job_id):
|
||||
treffer[job_id] = alt[job_id] # letzte bekannte Antwort halten
|
||||
else:
|
||||
treffer[job_id] = ergebnis["pfade"]
|
||||
_ROHDATEN["treffer"] = treffer
|
||||
_ROHDATEN["stand"] = time.monotonic()
|
||||
|
||||
|
||||
def _kann_neu_komprimieren(job: dict, work_dir: str) -> bool:
|
||||
"""Nur wenn Rohdaten wirklich noch daliegen — der „Neu komprimieren"-Knopf
|
||||
an einem Job, der nie gerippt hat, war Unsinn (Befund 24.07.)."""
|
||||
an einem Job, der nie gerippt hat, war Unsinn (Befund 24.07.).
|
||||
|
||||
⚠️ Reparatur 26.07.2026, an der laufenden Instanz gemessen: Gesucht wurde
|
||||
nur im Container-Standard und unter dem AKTUELLEN `workDir`. Der Rip von Job
|
||||
95afdc89 lag aber auf der NAS, weil beim Start eine Wahl NUR FÜR DIESEN RIP
|
||||
getroffen worden war (gibt es seit v3.15) — und die Einstellung selbst stand
|
||||
auf leer. Ergebnis: `can_retry` war `false`, obwohl 79,6 GB intakt dalagen.
|
||||
Der SAVEPOINT v3.16 behauptete „‚Neu komprimieren' genügt" — den Knopf gab
|
||||
es nicht. Jetzt wird an allen möglichen Orten nachgesehen.
|
||||
|
||||
Gelesen wird aus dem Vorrat, nicht live: Diese Funktion hängt an /jobs, und
|
||||
das fragt das Dashboard alle 4 Sekunden. Solange der Vorrat einen Job noch
|
||||
nicht kennt (frischer Fehlschlag), zählt der billige lokale Ort — der liegt
|
||||
auf der Container-Platte und antwortet immer sofort.
|
||||
"""
|
||||
if job.get("status") != "failed":
|
||||
return False
|
||||
if os.path.isdir(os.path.join("/app/temp/raw", job["id"])):
|
||||
return True
|
||||
return unter_wurzel(work_dir, MEDIA_ROOT) and os.path.isdir(os.path.join(work_dir, job["id"]))
|
||||
vorrat = _ROHDATEN["treffer"]
|
||||
if job["id"] in vorrat:
|
||||
return bool(vorrat[job["id"]])
|
||||
# Nur der lokale Ort: /app/temp ist ein Docker-Volume, os.path.isdir kann
|
||||
# dort nicht hängen (im Gegensatz zu allem unter /app/media).
|
||||
return os.path.isdir(posixpath.join(rohdaten.RAW_STANDARD, job["id"]))
|
||||
|
||||
|
||||
@app.get("/jobs", response_model=List[Job])
|
||||
@@ -335,6 +607,7 @@ async def get_jobs():
|
||||
for z in db.list_jobs():
|
||||
modell = _job_row_to_model(z)
|
||||
modell.can_retry = _kann_neu_komprimieren(z, work_dir)
|
||||
modell.retry_art = phasen.retry_art(z)
|
||||
schaetzung = eta.aktualisiere_und_schaetze(
|
||||
z["id"], z.get("status") or "", z.get("progress") or 0,
|
||||
jetzt, cache_get, cache_set,
|
||||
@@ -347,27 +620,15 @@ async def get_jobs():
|
||||
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
|
||||
"""(Bytes, Dateizahl) der Roh-MKVs (Details in rohdaten.py).
|
||||
|
||||
Hier bleibt os.* stehen: Diese Funktion wird nur aufgerufen, NACHDEM
|
||||
`verzeichnis_da` den Pfad innerhalb von Sekunden bestätigt hat — der Mount
|
||||
antwortet also. Und sie läuft nur, wenn ein Mensch auf die Zahl wartet
|
||||
(/rohdaten, Löschen), nie in der Hintergrund-Schleife.
|
||||
"""
|
||||
return rohdaten.groesse(pfade, os.listdir, os.path.isfile, os.path.getsize)
|
||||
|
||||
|
||||
@app.get("/jobs/{job_id}/rohdaten")
|
||||
@@ -389,7 +650,7 @@ async def job_rohdaten(job_id: str):
|
||||
|
||||
def sammle():
|
||||
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
|
||||
pfade = _rohdaten_verzeichnisse(job_id, work_dir)
|
||||
pfade = _rohdaten_suchen(job_id, work_dir)
|
||||
bytes_gesamt, dateien = _rohdaten_groesse(pfade)
|
||||
return {
|
||||
"pfade": pfade,
|
||||
@@ -419,7 +680,7 @@ async def delete_job(job_id: str, rohdaten: bool = False):
|
||||
if rohdaten:
|
||||
def raeume():
|
||||
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
|
||||
pfade = _rohdaten_verzeichnisse(job_id, work_dir)
|
||||
pfade = _rohdaten_suchen(job_id, work_dir)
|
||||
bytes_gesamt, _ = _rohdaten_groesse(pfade)
|
||||
for pfad in pfade:
|
||||
shutil.rmtree(pfad, ignore_errors=True)
|
||||
@@ -455,6 +716,12 @@ class JobCreateRequest(BaseModel):
|
||||
main_feature_only: Optional[bool] = None # pro Rip; None = Setting gilt
|
||||
titles: Optional[List[int]] = None # exakte Titel-Auswahl (Track-Tabelle)
|
||||
transcode_node: Optional[str] = None # gewählter Encoder-Worker (Celery-Node)
|
||||
# Sprachauswahl für DIESEN Rip (ISO-639-2, z. B. ["deu","eng"]).
|
||||
# Leer = alles behalten. Greift bei der Kompression, nicht beim Rippen —
|
||||
# der Rip bleibt vollständig und verlustfrei (Begründung in
|
||||
# worker/ripping.build_handbrake_cmd).
|
||||
audio_sprachen: Optional[List[str]] = None
|
||||
untertitel_sprachen: Optional[List[str]] = None
|
||||
# Arbeitsverzeichnis NUR für diesen Rip (Commander-Wunsch 25.07.2026:
|
||||
# beim Start wählbar, nicht global vorgegeben). Leer = der Wert aus
|
||||
# Einstellungen → Verarbeitung, der auch für Vollautomatik-Rips gilt.
|
||||
@@ -510,6 +777,18 @@ async def create_job(request: JobCreateRequest):
|
||||
if request.series and request.series.strip():
|
||||
meta_dict["series"] = request.series.strip()
|
||||
meta_dict["season"] = max(1, int(request.season or 1))
|
||||
# TMDB-Laufzeiten der Episoden holen (für die Erkennung im Worker)
|
||||
if meta_dict.get("id") and meta_dict.get("type") == "tv":
|
||||
try:
|
||||
from clients.tmdb import TMDBClient
|
||||
staffel = TMDBClient().get_tv_season(meta_dict["id"], meta_dict["season"])
|
||||
if staffel and "episodes" in staffel:
|
||||
meta_dict["episode_runtimes"] = [
|
||||
ep.get("runtime") for ep in staffel["episodes"] if ep.get("runtime")
|
||||
]
|
||||
except Exception as e:
|
||||
print(f"Fehler beim Holen der Staffel-Laufzeiten: {e}")
|
||||
|
||||
if request.main_feature_only is not None:
|
||||
meta_dict["main_feature_only"] = request.main_feature_only
|
||||
if request.titles:
|
||||
@@ -518,6 +797,14 @@ async def create_job(request: JobCreateRequest):
|
||||
meta_dict["titles"] = titel_liste
|
||||
if request.transcode_node:
|
||||
meta_dict["transcode_node"] = request.transcode_node
|
||||
# Sprachauswahl dieses Rips. Nur schreiben, wenn wirklich gewählt wurde —
|
||||
# eine leere Liste würde im Worker als „alles" gelesen, was derselbe Fall
|
||||
# ist, aber die Absicht verschleiert.
|
||||
for feld, wert in (("audio_sprachen", request.audio_sprachen),
|
||||
("untertitel_sprachen", request.untertitel_sprachen)):
|
||||
sauber = [str(s).strip().lower() for s in (wert or []) if str(s).strip()]
|
||||
if sauber:
|
||||
meta_dict[feld] = sauber
|
||||
# Arbeitsverzeichnis dieses Rips. Dieselbe Pfad-Härte wie beim Ziel: muss
|
||||
# unter /app/media liegen, damit man nicht versehentlich 100 GB Rohdaten
|
||||
# irgendwohin in den Container schreibt.
|
||||
@@ -546,6 +833,7 @@ async def get_job_detail(job_id: str):
|
||||
detail = _job_row_to_model(job).dict()
|
||||
detail["target_dir"] = job.get("target_dir")
|
||||
detail["output_path"] = job.get("output_path")
|
||||
detail["retry_art"] = phasen.retry_art(job)
|
||||
try:
|
||||
detail["meta"] = json.loads(job["meta"]) if job.get("meta") else None
|
||||
except ValueError:
|
||||
@@ -730,8 +1018,15 @@ async def scan_tracks_ergebnis(name: str):
|
||||
async def retry_transcode(job_id: str):
|
||||
"""Stößt die Kompression eines Jobs neu an — OHNE die Disc neu zu rippen.
|
||||
|
||||
Voraussetzung: die Rohdateien liegen noch in /app/temp/raw/<job_id>
|
||||
(bei Kompressions-Fehlschlägen bleiben sie dort absichtlich erhalten).
|
||||
Voraussetzung: die Rohdateien liegen noch irgendwo (bei
|
||||
Kompressions-Fehlschlägen bleiben sie absichtlich erhalten).
|
||||
|
||||
⚠️ Reparatur 26.07.2026: Das Roh-Verzeichnis wurde hier aus dem AKTUELLEN
|
||||
Wert von `workDir` errechnet. Wer beim Rip-Start eine andere Ablage gewählt
|
||||
hatte (gibt es seit v3.15), bekam damit einen Pfad, an dem nichts liegt —
|
||||
und der Worker brach mit „Verzeichnis erreichbar, enthält aber keine
|
||||
MKV-Datei" ab. Beim Job 95afdc89 lagen 79,6 GB auf der NAS, gesucht wurde in
|
||||
/app/temp/raw. Jetzt wird nachgesehen statt gerechnet (rohdaten.py).
|
||||
"""
|
||||
job = await asyncio.to_thread(db.get_job, job_id)
|
||||
if not job:
|
||||
@@ -739,12 +1034,19 @@ async def retry_transcode(job_id: str):
|
||||
if job["status"] in ("running", "pending"):
|
||||
raise HTTPException(status_code=409, detail="Job rippt noch")
|
||||
|
||||
# Roh-Verzeichnis: respektiert das konfigurierbare Arbeitsverzeichnis
|
||||
# (Einstellungen → Verarbeitung), sonst Container-Default /app/temp/raw.
|
||||
einstellungen = await asyncio.to_thread(db.get_settings)
|
||||
work_dir = os.path.normpath((einstellungen.get("workDir") or "").strip() or "/")
|
||||
raw_basis = work_dir if unter_wurzel(work_dir, MEDIA_ROOT) else "/app/temp/raw"
|
||||
raw_dir = f"{raw_basis}/{job_id}"
|
||||
gefunden = await asyncio.to_thread(_rohdaten_suchen, job_id, work_dir)
|
||||
if not gefunden:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"Keine Rohdaten zu diesem Job gefunden — weder unter "
|
||||
"/app/temp/raw noch in einem der Ablageziele. Ohne sie muss die "
|
||||
"Disc neu gerippt werden. (Gelöscht? Freigabe nicht eingehängt?)"
|
||||
),
|
||||
)
|
||||
raw_dir = gefunden[0]
|
||||
# Zielordner: der Worker schreibt das geplante Ziel beim Rip-Start nach
|
||||
# output_path (sprechender Name statt UUID) — alter Fallback bleibt.
|
||||
basis = job.get("target_dir") or f"{MEDIA_ROOT}/{job.get('disc_type') or 'bluray'}"
|
||||
@@ -766,6 +1068,86 @@ async def retry_transcode(job_id: str):
|
||||
return {"id": job_id, "status": "transcoding"}
|
||||
|
||||
|
||||
@app.post("/jobs/{job_id}/retry-rip", status_code=201)
|
||||
async def retry_rip(job_id: str):
|
||||
"""Rippt die Disc dieses Jobs NOCH EINMAL — als frischer Job.
|
||||
|
||||
Der Gegenpart zu `retry-transcode`. Nötig, weil es für einen mitten im Rip
|
||||
gestorbenen Job vorher überhaupt keinen richtigen Knopf gab: Das UI bot nur
|
||||
„Neu" an, und das war immer die Kompression. Am 26.07.2026 hätte das aus
|
||||
5,1 GB Bruchstück brav einen Film gemacht, der bei 12 % aufhört.
|
||||
|
||||
Bewusst ein NEUER Job mit neuer ID, nicht ein Wiederbeleben des alten:
|
||||
|
||||
* Das Roh-Verzeichnis heißt <Arbeitsverzeichnis>/<job_id>. Bei gleicher ID
|
||||
läge das alte Bruchstück im neuen Verzeichnis, und die Kompression
|
||||
sammelt am Ende ALLE MKV-Dateien darin ein — sie würde das Bruchstück
|
||||
mitverarbeiten.
|
||||
* Der Fehlschlag bleibt in der Liste nachlesbar, statt überschrieben zu
|
||||
werden.
|
||||
|
||||
Übernommen werden Titel, Ablageziel und alle Metadaten des alten Jobs
|
||||
(Jahr/Poster, Titel-Auswahl, Sprachwunsch, Arbeitsverzeichnis, gewählter
|
||||
Encoder-Worker) — der Nutzer soll seine Wahl nicht neu treffen müssen. Die
|
||||
Phasen-Marke wird NICHT übernommen; die setzt der Rip selbst.
|
||||
"""
|
||||
job = await asyncio.to_thread(db.get_job, job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job nicht gefunden")
|
||||
if job["status"] in ("running", "pending", "transcoding", "canceling"):
|
||||
raise HTTPException(status_code=409, detail="Dieser Job läuft noch")
|
||||
|
||||
device_path = job.get("device")
|
||||
vorhandene = device_discovery.list_optical_devices()
|
||||
if not device_path or device_path not in vorhandene:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
f"Das Laufwerk dieses Jobs ({device_path or 'unbekannt'}) ist nicht "
|
||||
'mehr da. Disc in ein vorhandenes Laufwerk legen und den Rip über '
|
||||
'„Rip starten" neu anstoßen.'
|
||||
),
|
||||
)
|
||||
try:
|
||||
status = await asyncio.to_thread(drive_status, device_path)
|
||||
except OSError as e:
|
||||
raise HTTPException(status_code=409, detail=f"Laufwerk {device_path} antwortet nicht: {e}")
|
||||
if status != CDS_DISC_OK:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
f'Es liegt keine (lesbare) Disc in {device_path}. Ein neuer Rip '
|
||||
'braucht die Disc — sie wurde nach dem Fehlschlag vermutlich '
|
||||
'ausgeworfen. Disc einlegen, dann noch einmal.'
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
meta = json.loads(job.get("meta") or "{}")
|
||||
except ValueError:
|
||||
meta = {}
|
||||
if not isinstance(meta, dict):
|
||||
meta = {}
|
||||
meta.pop(phasen.RIP_FERTIG, None)
|
||||
meta_json = json.dumps(meta) if meta else None
|
||||
|
||||
neue_id = str(uuid.uuid4())
|
||||
ziel = job.get("target_dir")
|
||||
await asyncio.to_thread(
|
||||
db.insert_job, neue_id, device_path, job.get("disc_type"),
|
||||
job.get("title"), ziel, meta_json,
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
db.add_log, "info", "api",
|
||||
f"Job {neue_id} ist der Neu-Rip von {job_id} "
|
||||
f"({job.get('title') or 'ohne Titel'}) auf {device_path}. "
|
||||
"Das unvollständige Rohmaterial des alten Jobs bleibt liegen — es kann "
|
||||
"über den Papierkorb am alten Job mitgelöscht werden.",
|
||||
)
|
||||
start_rip(device_path, neue_id, ziel)
|
||||
return {"id": neue_id, "status": "pending", "device": device_path, "vorher": job_id}
|
||||
|
||||
|
||||
@app.post("/jobs/{job_id}/cancel")
|
||||
async def cancel_job(job_id: str):
|
||||
"""Bittet den Worker, den Job abzubrechen (kooperativ über die DB).
|
||||
@@ -860,7 +1242,10 @@ async def capabilities():
|
||||
zeile["node"] = node
|
||||
return zeilen
|
||||
|
||||
return {"workers": await asyncio.to_thread(sammle)}
|
||||
return {
|
||||
"workers": await asyncio.to_thread(sammle),
|
||||
"server_version": os.getenv("RIPPY_VERSION", "dev")
|
||||
}
|
||||
|
||||
|
||||
@app.get("/presets")
|
||||
@@ -921,8 +1306,8 @@ class MediaServerRefreshRequest(BaseModel):
|
||||
|
||||
@app.post("/mediaserver/refresh")
|
||||
async def mediaserver_refresh(request: MediaServerRefreshRequest):
|
||||
"""Bibliotheks-Scan von Jellyfin/Emby anstoßen — auch als Verbindungs-Test
|
||||
aus den Einstellungen (POST /Library/Refresh, Header X-Emby-Token)."""
|
||||
"""Bibliotheks-Scan von Jellyfin/Emby/Kodi anstoßen — auch als Verbindungs-Test
|
||||
aus den Einstellungen."""
|
||||
import requests as _requests
|
||||
|
||||
url = request.url.strip().rstrip("/")
|
||||
@@ -930,6 +1315,24 @@ async def mediaserver_refresh(request: MediaServerRefreshRequest):
|
||||
raise HTTPException(status_code=422, detail="Server-URL muss mit http(s):// beginnen")
|
||||
|
||||
def anstossen():
|
||||
# Fallback für die reine API-Test-Funktion, die den Server-Typ nicht kennt:
|
||||
# Wir versuchen Kodi (JSON-RPC) und danach Emby/Jellyfin.
|
||||
# Wenn der User Kodi auswählt, hat er oft Basic Auth konfiguriert.
|
||||
auth = None
|
||||
if ":" in request.api_key:
|
||||
user, pw = request.api_key.split(":", 1)
|
||||
auth = (user, pw)
|
||||
|
||||
try:
|
||||
# Kodi JSON-RPC
|
||||
payload = {"jsonrpc": "2.0", "method": "VideoLibrary.Scan", "id": 1}
|
||||
res = _requests.post(url + "/jsonrpc", json=payload, auth=auth, timeout=5)
|
||||
if res.status_code < 300:
|
||||
return res
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Emby / Jellyfin
|
||||
return _requests.post(
|
||||
url + "/Library/Refresh",
|
||||
headers={"X-Emby-Token": request.api_key.strip()}, timeout=15,
|
||||
@@ -1377,8 +1780,13 @@ async def worker_setup_paket():
|
||||
for name in sorted(os.listdir("worker_dist")):
|
||||
if name.endswith(".py") and not name.startswith(("test_", "conftest")):
|
||||
z.write(os.path.join("worker_dist", name), name)
|
||||
elif name == "requirements.txt":
|
||||
# requirements.txt für die venv, rippy.ico für die Verknüpfung
|
||||
elif name in ("requirements.txt", "rippy.ico"):
|
||||
z.write(os.path.join("worker_dist", name), name)
|
||||
|
||||
# Write RIPPY_VERSION to version.txt
|
||||
version_str = os.getenv("RIPPY_VERSION", "dev")
|
||||
z.writestr("version.txt", version_str)
|
||||
return puffer.getvalue()
|
||||
|
||||
inhalt = await asyncio.to_thread(baue)
|
||||
@@ -1390,6 +1798,12 @@ async def worker_setup_paket():
|
||||
)
|
||||
|
||||
|
||||
@app.get("/system/version")
|
||||
async def system_version():
|
||||
"""Gibt die aktuelle Rippy-Version des Servers zurück (aus deploy.sh)."""
|
||||
return {"version": os.getenv("RIPPY_VERSION", "dev")}
|
||||
|
||||
|
||||
@app.get("/system/info")
|
||||
async def system_info():
|
||||
"""System-Selbstauskunft (Einstellungen → System): Werkzeug-Versionen der
|
||||
|
||||
+163
-45
@@ -67,6 +67,60 @@ def ist_erreichbar(name: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def pfad_lage(ziel: str) -> str:
|
||||
"""Existiert dieses Verzeichnis? „da" | „weg" | „unklar" — mit Zeitgrenze.
|
||||
|
||||
⚠️ Der Grund, warum es das gibt (Befund 26.07.2026, an Zeitstempeln gemessen):
|
||||
Eine Wiederanbindung brauchte **3 Minuten 15 Sekunden**, und die Zeit ging
|
||||
nicht in die Mount-Versuche, sondern in die allererste Zeile von `mounten()`:
|
||||
|
||||
os.makedirs(ziel, exist_ok=True)
|
||||
|
||||
`exist_ok` prüft mit `os.path.isdir()`, und ein `stat` auf einen TOTEN
|
||||
CIFS-Mount blockiert im Kernel bis zum SMB-Timeout. Ausgerechnet der Aufruf,
|
||||
der nur „lege den Ordner an, falls er fehlt" bedeutet, hing also drei Minuten
|
||||
— bevor irgendeine der sorgfältig begrenzten Prüfungen überhaupt dran war.
|
||||
Dritter Fund derselben Sorte an einem Tag: os-Aufruf auf einen Netzpfad ohne
|
||||
Zeitgrenze.
|
||||
|
||||
Deshalb wird die Lage jetzt mit einem Kind-Prozess erfragt (`timeout 4 ls`),
|
||||
der sich abbrechen lässt. „unklar" heißt: existiert, antwortet aber nicht —
|
||||
dann ist ein makedirs weder nötig noch möglich.
|
||||
"""
|
||||
try:
|
||||
ergebnis = subprocess.run(
|
||||
["timeout", "4", "ls", "-d", ziel], capture_output=True, timeout=6
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return "unklar"
|
||||
if ergebnis.returncode == 0:
|
||||
return "da"
|
||||
return "unklar" if ergebnis.returncode == 124 else "weg"
|
||||
|
||||
|
||||
def wirklich_erreichbar(name: str, warten=None) -> bool:
|
||||
"""Antwortet die Freigabe auch noch DREI SEKUNDEN später? (zweimal geprüft)
|
||||
|
||||
Warum zweimal (Befund 26.07.2026): Direkt nach einem frischen `mount`
|
||||
antwortete die Freigabe reproduzierbar — und Sekunden später lief jeder
|
||||
Zugriff in die Zeitgrenze. Ursache ist ein Wettlauf beim Aufräumen:
|
||||
`_stale_mounts_loesen` benutzt `umount -l`, und das ist LAZY — es hängt den
|
||||
Mount sofort aus der Sicht aus, der eigentliche Abbau passiert später. Fällt
|
||||
dieser Abbau samt Propagation (rshared) hinter den neuen Mount, zeigt der
|
||||
Pfad wieder auf die Leiche.
|
||||
|
||||
Eine einzige Probe kann das nicht sehen. Zwei mit Abstand schon.
|
||||
"""
|
||||
if warten is None:
|
||||
import time
|
||||
|
||||
warten = time.sleep
|
||||
if not ist_erreichbar(name):
|
||||
return False
|
||||
warten(3)
|
||||
return ist_erreichbar(name)
|
||||
|
||||
|
||||
def schreibtest(pfad: str) -> bool:
|
||||
"""Berechtigungs-Prüfung: können wir im Ziel wirklich schreiben?
|
||||
|
||||
@@ -104,6 +158,18 @@ def _stale_mounts_loesen(ziel: str) -> int:
|
||||
if ergebnis.returncode != 0:
|
||||
break # nichts (mehr) gemountet
|
||||
geloest += 1
|
||||
if geloest:
|
||||
# ⚠️ Kurz durchatmen, BEVOR neu gemountet wird (Befund 26.07.2026).
|
||||
# `umount -l` ist lazy: Es hängt sofort aus der Sicht aus, der eigentliche
|
||||
# Abbau samt Propagation (rshared) passiert später. Wer direkt danach
|
||||
# mountet, riskiert, dass dieser Abbau HINTER dem neuen Mount landet und
|
||||
# der Pfad wieder auf die Leiche zeigt. Genau das hat die erste
|
||||
# Reparatur regelmäßig scheitern lassen — und der zweite Versuch mit
|
||||
# seinen 30 s Timeout war der Grund, warum eine Wiederanbindung rund
|
||||
# 150 Sekunden brauchte. Anderthalb Sekunden Warten sparen die.
|
||||
import time
|
||||
|
||||
time.sleep(1.5)
|
||||
return geloest
|
||||
|
||||
|
||||
@@ -197,8 +263,8 @@ def pfad_map_vorschlag(eintraege: list) -> list:
|
||||
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.
|
||||
eingehängt und kennt ihre Quelle (z. B. `//NAS/rippy`). Der Mountpunkt ist
|
||||
`/app/media/<name>`, und beides zusammen IST das Mapping.
|
||||
"""
|
||||
vorschlaege = []
|
||||
for eintrag in eintraege or []:
|
||||
@@ -238,17 +304,40 @@ def mounten(name: str, typ: str, quelle: str, optionen: str = "",
|
||||
Mount-Fehlern.
|
||||
"""
|
||||
ziel = _mountpoint(name)
|
||||
# exist_ok fängt „Ordner da" NICHT, wenn ein toter Mount os.path.isdir
|
||||
# täuschen lässt (FileExistsError, Befund 24.07.) — deshalb tolerant.
|
||||
try:
|
||||
os.makedirs(ziel, exist_ok=True)
|
||||
except FileExistsError:
|
||||
pass
|
||||
try:
|
||||
if os.path.ismount(ziel):
|
||||
return schreibtest(ziel)
|
||||
except OSError:
|
||||
pass # toter Mount → unten erst lösen, dann frisch mounten (kein Stapeln)
|
||||
# Den Ordner NUR anlegen, wenn er wirklich fehlt — und das mit Zeitgrenze
|
||||
# klären (siehe pfad_lage): `os.makedirs(..., exist_ok=True)` hing auf einem
|
||||
# toten CIFS-Mount drei Minuten im Kernel, weil `exist_ok` ein `stat` macht.
|
||||
if pfad_lage(ziel) == "weg":
|
||||
try:
|
||||
os.makedirs(ziel, exist_ok=True)
|
||||
except OSError:
|
||||
pass # Rennen mit einem anderen Aufruf oder Rechte — mount sagt es
|
||||
# ⚠️ ERREICHBARKEIT VOR ismount — nicht umgekehrt (Befund 26.07.2026).
|
||||
#
|
||||
# Vorher stand hier `if os.path.ismount(ziel): return schreibtest(ziel)`.
|
||||
# Das hatte zwei Folgen, beide am selben Nachmittag gemessen:
|
||||
#
|
||||
# 1. Nach `docker compose up -d --build` war die CIFS-Freigabe TOT (4 von 4
|
||||
# Zugriffen liefen in 10 s Timeout, /storage-mounts meldete
|
||||
# `reachable: false`) — und blieb es. Der Mountpunkt existierte im neuen
|
||||
# Container weiter (Bind-Mount vom Host), `ismount` sagte also „ist schon
|
||||
# da" und das Wiederherstellen brach genau hier ab. Die Reparatur per
|
||||
# POST /storage-mounts/rippy/repair stellte sie in einer Sekunde her —
|
||||
# beim Start hätte dasselbe passieren müssen.
|
||||
# 2. `schreibtest()` öffnet eine Datei OHNE Zeitgrenze. Auf einem toten CIFS
|
||||
# blockiert das im Kernel (Thread-Zustand D), und `alle_remounten()`
|
||||
# hängt beim API-Start dauerhaft in einem Hintergrund-Thread.
|
||||
#
|
||||
# `ist_erreichbar()` hat eine harte Grenze (`timeout 3 ls`). Antwortet die
|
||||
# Freigabe, sind ismount und schreibtest danach gefahrlos. Antwortet sie
|
||||
# nicht, fällt es unten durch: lösen, frisch mounten. Das ist genau der Weg,
|
||||
# den `reparieren()` geht — nur eben automatisch.
|
||||
if ist_erreichbar(name):
|
||||
try:
|
||||
if os.path.ismount(ziel):
|
||||
return schreibtest(ziel)
|
||||
except OSError:
|
||||
pass # unten erst lösen, dann frisch mounten (kein Stapeln)
|
||||
|
||||
# Idempotent: etwaige (auch gestapelte/tote) Alt-Mounts erst lösen, damit der
|
||||
# folgende mount NICHT auf eine Leiche stapelt (Vorfall 24.07.: 12 Schichten).
|
||||
@@ -278,18 +367,43 @@ def mounten(name: str, typ: str, quelle: str, optionen: str = "",
|
||||
else:
|
||||
raise RuntimeError(f"Unbekannter Typ: {typ} (nfs oder cifs)")
|
||||
|
||||
ergebnis = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
if ergebnis.returncode != 0:
|
||||
fehler = (ergebnis.stderr or ergebnis.stdout or "").strip()
|
||||
hinweis = ""
|
||||
if "error(13)" in fehler or "Permission denied" in fehler:
|
||||
hinweis = (
|
||||
" — Zugriff verweigert: Benutzer/Passwort prüfen; ohne "
|
||||
"Angaben versucht Rippy einen Gast-Zugriff, den Windows/"
|
||||
"NAS meist ablehnen."
|
||||
)
|
||||
raise RuntimeError(f"mount schlug fehl: {fehler[:300]}{hinweis}")
|
||||
return schreibtest(ziel)
|
||||
# Zwei Versuche. Grund (26.07.2026, dreimal reproduziert): Beim API-Start
|
||||
# meldete `mount` Erfolg — und die Freigabe antwortete danach trotzdem
|
||||
# nicht (`timeout 6 ls` lief in die Grenze, eine einzige, korrekt
|
||||
# aussehende Mount-Schicht in /proc/mounts). Derselbe Ablauf ein zweites
|
||||
# Mal, per POST /storage-mounts/<name>/repair, stellte sie sofort her.
|
||||
# Der erste SMB-Sitzungsaufbau kurz nach dem Container-Start geht also
|
||||
# gelegentlich schief, ohne es zu melden.
|
||||
#
|
||||
# Deshalb wird das Ergebnis GEPRÜFT statt geglaubt: Antwortet die
|
||||
# Freigabe nach dem Mount nicht, wird einmal gelöst und neu gemountet.
|
||||
# Und wenn das auch nicht hilft, fliegt ein Fehler — dann steht im Log
|
||||
# „FEHLER" statt „eingehängt", was schlicht die Wahrheit ist.
|
||||
for versuch in (1, 2):
|
||||
ergebnis = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
if ergebnis.returncode != 0:
|
||||
fehler = (ergebnis.stderr or ergebnis.stdout or "").strip()
|
||||
hinweis = ""
|
||||
if "error(13)" in fehler or "Permission denied" in fehler:
|
||||
hinweis = (
|
||||
" — Zugriff verweigert: Benutzer/Passwort prüfen; ohne "
|
||||
"Angaben versucht Rippy einen Gast-Zugriff, den Windows/"
|
||||
"NAS meist ablehnen."
|
||||
)
|
||||
raise RuntimeError(f"mount schlug fehl: {fehler[:300]}{hinweis}")
|
||||
# Zweimal mit Abstand prüfen — eine einzige Probe direkt nach dem
|
||||
# Mount sieht den Wettlauf mit dem lazy umount nicht (siehe
|
||||
# wirklich_erreichbar).
|
||||
if wirklich_erreichbar(name):
|
||||
return schreibtest(ziel)
|
||||
if versuch == 1:
|
||||
_lazy_umount(ziel)
|
||||
raise RuntimeError(
|
||||
f"{quelle} wurde eingehängt, antwortet aber nicht dauerhaft (zwei "
|
||||
"Versuche). Läuft die Freigabe? Bei einem NAS im Ruhezustand hilft "
|
||||
"meist ein erneutes Einhängen über Einstellungen → Speicherziele → "
|
||||
"Reparieren."
|
||||
)
|
||||
finally:
|
||||
if creds_datei:
|
||||
try:
|
||||
@@ -306,22 +420,25 @@ def _lazy_umount(ziel: str) -> None:
|
||||
|
||||
|
||||
def aushaengen(name: str) -> None:
|
||||
"""Freigabe abhängen und den Mountpunkt aufräumen.
|
||||
|
||||
Ohne `os.path.ismount`/`os.rmdir` als erste Schritte: Beide `stat`en den
|
||||
Pfad, und auf einem toten CIFS-Mount blockiert das im Kernel (siehe
|
||||
pfad_lage). Ein `umount` auf einen Pfad ohne Mount kostet dagegen nichts.
|
||||
"""
|
||||
ziel = _mountpoint(name)
|
||||
try:
|
||||
noch_mount = os.path.ismount(ziel)
|
||||
except OSError:
|
||||
noch_mount = True # stale
|
||||
if noch_mount:
|
||||
ergebnis = subprocess.run(
|
||||
["umount", ziel], capture_output=True, text=True, timeout=30
|
||||
)
|
||||
if ergebnis.returncode != 0:
|
||||
# Toter/beschäftigter Mount → lazy detach (klappt immer)
|
||||
_lazy_umount(ziel)
|
||||
try:
|
||||
os.rmdir(ziel)
|
||||
except OSError:
|
||||
pass # nicht leer oder weg — egal
|
||||
ergebnis = subprocess.run(
|
||||
["umount", ziel], capture_output=True, text=True, timeout=30
|
||||
)
|
||||
if ergebnis.returncode != 0:
|
||||
# Toter/beschäftigter Mount → lazy detach (klappt immer)
|
||||
_lazy_umount(ziel)
|
||||
# Erst wenn der Pfad wieder antwortet, darf rmdir ihn anfassen.
|
||||
if pfad_lage(ziel) == "da":
|
||||
try:
|
||||
os.rmdir(ziel)
|
||||
except OSError:
|
||||
pass # nicht leer oder weg — egal
|
||||
|
||||
|
||||
def reparieren(name: str, typ: str, quelle: str, optionen: str = "",
|
||||
@@ -330,13 +447,14 @@ def reparieren(name: str, typ: str, quelle: str, optionen: str = "",
|
||||
|
||||
Nötig, wenn ein NAS-Mount stale geworden ist (Rebuild, NAS-Schlaf) — das
|
||||
normale mounten() würde am „ist schon Mountpoint" hängenbleiben.
|
||||
|
||||
`os.path.ismount` stand hier vorher als Vorbedingung — auf einem toten
|
||||
CIFS-Mount blockiert dieses `stat` im Kernel (siehe pfad_lage). Gefragt wird
|
||||
es gar nicht mehr: `umount -l` auf einen Pfad, an dem nichts hängt, kostet
|
||||
nichts und meldet nur einen Rückgabewert, den hier niemand braucht.
|
||||
"""
|
||||
ziel = _mountpoint(name)
|
||||
try:
|
||||
if os.path.ismount(ziel):
|
||||
_lazy_umount(ziel)
|
||||
except OSError:
|
||||
_lazy_umount(ziel)
|
||||
_lazy_umount(ziel)
|
||||
return mounten(name, typ, quelle, optionen, username, passwort)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""In welcher Phase ist ein Job gestorben — und was hilft danach wirklich?
|
||||
|
||||
Commander-Befund 26.07.2026: „Hier gibt es den Button ‚neu' aber WAS wird dann
|
||||
gemacht? Komprimierung? Neu Gerippt? Das muss ja je nach fehlgeschlagenem Job
|
||||
eine Option anbieten."
|
||||
|
||||
Die Frage war berechtigt und der Knopf war falsch: Er hieß nur „Neu" und rief
|
||||
immer `POST /jobs/{id}/retry-transcode` — also immer die KOMPRESSION. Für einen
|
||||
abgebrochenen Transcode ist das goldrichtig (die Roh-MKV liegt vollständig da,
|
||||
man spart eine Stunde Rippen). Für einen abgebrochenen RIP ist es Unsinn: Am
|
||||
26.07.2026 lagen 5,1 GB von rund 40 GB da, und „Neu" hätte daraus brav einen
|
||||
Film komprimiert, der bei 12 % aufhört.
|
||||
|
||||
## Warum es dafür eine MARKE braucht
|
||||
|
||||
Sobald `status = "failed"` steht, ist der vorherige Zustand fort — die Spalte
|
||||
hat nur einen Wert. `zombies.war_im_rip()` im Worker kann die Phase noch sehen,
|
||||
weil sie dort im Moment des Aufräumens vorliegt; die API sieht später nur noch
|
||||
„failed". Deshalb schreibt der Worker die Phase in die Job-Metadaten:
|
||||
|
||||
* Rip-Start → `rip_fertig = false`
|
||||
* Rip fertig (Übergabe an die Kompression) → `rip_fertig = true`
|
||||
* Start eines Transcodes → `rip_fertig = true` (heilt Bestandsjobs mit)
|
||||
|
||||
## Drei Antworten, nicht zwei
|
||||
|
||||
Die Marke FEHLT bei jedem Job, der vor dieser Änderung lief. Diese Jobs
|
||||
„rip" zu nennen wäre geraten — und würde dem Commander an einem
|
||||
Bestandsjob mit 79,6 GB intakter Rohdaten das „Neu komprimieren" wegnehmen,
|
||||
das dort genau richtig ist. Wer die Phase nicht kennt, sagt das: `unklar`
|
||||
lässt das UI beide Wege anbieten, jeden mit einem ehrlichen Satz.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
# Schlüssel in den Job-Metadaten. Muss identisch in worker/tasks.py stehen —
|
||||
# es gibt kein geteiltes Paket zwischen den Containern.
|
||||
RIP_FERTIG = "rip_fertig"
|
||||
|
||||
# Die drei möglichen Antworten.
|
||||
NEU_KOMPRIMIEREN = "transcode"
|
||||
NEU_RIPPEN = "rip"
|
||||
UNKLAR = "unklar"
|
||||
NICHTS = ""
|
||||
|
||||
|
||||
def _meta(meta_json) -> dict:
|
||||
"""Metadaten lesen, ohne an kaputtem JSON zu scheitern."""
|
||||
if isinstance(meta_json, dict):
|
||||
return meta_json
|
||||
if not meta_json:
|
||||
return {}
|
||||
try:
|
||||
gelesen = json.loads(meta_json)
|
||||
except (ValueError, TypeError):
|
||||
return {}
|
||||
return gelesen if isinstance(gelesen, dict) else {}
|
||||
|
||||
|
||||
def retry_art(job) -> str:
|
||||
"""Was soll der Knopf an DIESEM Job anbieten?
|
||||
|
||||
* `NICHTS` — der Job ist nicht fehlgeschlagen, es gibt nichts zu
|
||||
wiederholen.
|
||||
* `NEU_KOMPRIMIEREN`— der Rip war fertig, nur die Kompression starb.
|
||||
* `NEU_RIPPEN` — der Rip selbst starb; alles Rohe ist ein Bruchstück.
|
||||
* `UNKLAR` — kein Vermerk (Bestandsjob), beide Wege anbieten.
|
||||
"""
|
||||
if (job.get("status") or "") != "failed":
|
||||
return NICHTS
|
||||
marke = _meta(job.get("meta")).get(RIP_FERTIG)
|
||||
if marke is True:
|
||||
return NEU_KOMPRIMIEREN
|
||||
if marke is False:
|
||||
return NEU_RIPPEN
|
||||
return UNKLAR
|
||||
+87
-2
@@ -2,19 +2,104 @@
|
||||
|
||||
Der API-Key-Store, der hier früher lebte, ist mit dem Auth-Rückbau
|
||||
(Commander-Entscheid 24.07.2026) entfernt — Heimnetz-only, siehe KONZEPT §10.
|
||||
|
||||
## Was diese Bremse ist — und was nicht
|
||||
|
||||
Sie soll ein Amok-Skript stoppen (Endlosschleife, tausend Anfragen pro Sekunde).
|
||||
Sie ist KEIN Schutz gegen Angreifer; dafür wäre Rippy die falsche Stelle.
|
||||
|
||||
Daraus folgt die Zahl unten: Sie muss deutlich über dem liegen, was Rippy im
|
||||
Normalbetrieb selbst verursacht. Am 26.07.2026 tat sie das nicht — und die
|
||||
Folgen waren dem Commander als „wird oft neu geladen" aufgefallen.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from typing import Dict
|
||||
|
||||
# Default Rate Limit
|
||||
MAX_REQUESTS_PER_MINUTE = 100
|
||||
# Wie viele Anfragen pro Minute und Client durchgehen.
|
||||
#
|
||||
# ⚠️ Bis zum 26.07.2026 stand hier 100 — WENIGER, als das eigene Dashboard
|
||||
# braucht. Nachgerechnet an den tatsächlichen Taktgebern im UI:
|
||||
#
|
||||
# Dashboard (Dashboard.tsx) 5 Endpunkte alle 4 s → 75/min
|
||||
# Log-Kasten (LiveLogSection.tsx) 2 Endpunkte alle 5 s → 24/min
|
||||
# Laufwerke (DeviceDiscovery.tsx) 1 Endpunkt alle 5 s → 12/min
|
||||
# Windows-Tray (tray.py) /jobs alle 5 s → 12/min je Worker
|
||||
# ─────────
|
||||
# EIN offener Tab plus ein Worker 123/min
|
||||
#
|
||||
# Das Limit war also im Normalbetrieb um ein Viertel überschritten: Etwa jede
|
||||
# vierte Anfrage bekam 429, und weil das UI einen Fehlschlag damals als „es gibt
|
||||
# keine Jobs" verbuchte, leerte sich die Liste im Sekundentakt. Zwei offene Tabs
|
||||
# hätten es verdoppelt.
|
||||
#
|
||||
# 600/min = 10 Anfragen pro Sekunde: Platz für mehrere Tabs und Worker, während
|
||||
# eine Endlosschleife (Hunderte pro Sekunde) weiterhin sofort gebremst wird.
|
||||
MAX_REQUESTS_PER_MINUTE = 600
|
||||
|
||||
# In-Memory Rate Limit Store (in Produktion mit Redis)
|
||||
rate_limit_store: Dict[str, list] = defaultdict(list)
|
||||
|
||||
|
||||
def _ist_privat(adresse: str) -> bool:
|
||||
"""Steckt hinter dieser Adresse das eigene Netz (bzw. ein Container)?"""
|
||||
try:
|
||||
ip = ipaddress.ip_address(adresse)
|
||||
except ValueError:
|
||||
return False
|
||||
return ip.is_private or ip.is_loopback
|
||||
|
||||
|
||||
def client_kennung(peer: str, weitergegeben: str = None) -> str:
|
||||
"""Welcher Eimer gilt für diese Anfrage? (pure Funktion, testbar)
|
||||
|
||||
`peer` ist der direkte Absender, `weitergegeben` der Inhalt von
|
||||
`X-Real-IP` (setzt der nginx im UI-Container, siehe ui/nginx.conf).
|
||||
|
||||
Warum überhaupt: Der Browser spricht nie direkt mit der API, sondern über
|
||||
den nginx — für die API sah deshalb JEDE Anfahrt aus dem UI gleich aus. Ein
|
||||
zweiter Tab und der Windows-Tray teilten sich den Eimer mit dem Dashboard,
|
||||
obwohl es drei unabhängige Clients sind.
|
||||
|
||||
Der Kopfzeile wird nur geglaubt, wenn der direkte Absender aus dem privaten
|
||||
Netz kommt — also unser eigener Proxy. Das ist keine Härtung gegen
|
||||
Angreifer (Rippy ist Heimnetz-only, KONZEPT §10), sondern verhindert, dass
|
||||
eine beliebige Kopfzeile die Bremse aushebelt.
|
||||
"""
|
||||
peer = (peer or "").strip()
|
||||
kandidat = (weitergegeben or "").split(",")[0].strip()
|
||||
if kandidat and _ist_privat(peer) and _ist_privat(kandidat):
|
||||
return kandidat
|
||||
return peer or "unbekannt"
|
||||
|
||||
|
||||
# Wann wurde für einen Client zuletzt eine Rate-Limit-Meldung geschrieben?
|
||||
_letzte_meldung: Dict[str, float] = {}
|
||||
|
||||
# Abstand zwischen zwei Meldungen pro Client.
|
||||
MELDE_ABSTAND_SEKUNDEN = 60
|
||||
|
||||
|
||||
def darf_melden(client_id: str, jetzt: float = None) -> bool:
|
||||
"""Soll dieser abgewiesene Aufruf ins Log? Höchstens einmal pro Minute.
|
||||
|
||||
Ein 429 war bisher völlig unsichtbar — das UI verbuchte ihn als „nichts
|
||||
da", und niemand erfuhr, dass die Bremse greift. Jede Abweisung zu
|
||||
protokollieren wäre aber die Ecke ins Gegenteil: Genau der Fall, für den die
|
||||
Bremse gebaut ist (ein Skript in einer Endlosschleife), würde damit das
|
||||
Log-Fenster zumüllen. Also: eine Meldung pro Client und Minute.
|
||||
"""
|
||||
if jetzt is None:
|
||||
jetzt = time.time()
|
||||
vorher = _letzte_meldung.get(client_id, 0.0)
|
||||
if jetzt - vorher < MELDE_ABSTAND_SEKUNDEN:
|
||||
return False
|
||||
_letzte_meldung[client_id] = jetzt
|
||||
return True
|
||||
|
||||
|
||||
def check_rate_limit(client_id: str, max_requests: int = MAX_REQUESTS_PER_MINUTE, window_seconds: int = 60) -> bool:
|
||||
"""Prüfe ob Client rate-limited ist."""
|
||||
current_time = time.time()
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Wo liegen die Roh-MKVs eines Jobs? — Suchen statt annehmen.
|
||||
|
||||
## Der Fund, der dieses Modul nötig gemacht hat (26.07.2026, live gemessen)
|
||||
|
||||
Job `95afdc89` stand auf `failed`, und im Ablageziel lagen **79,6 GB** intakter
|
||||
Rohschnitt (`/app/media/rippy/95afdc89-…/title_t00.mkv`). Der SAVEPOINT v3.16
|
||||
schrieb dazu: „→ ‚Neu komprimieren' genügt, kein Neu-Rip". Die Gegenprobe an der
|
||||
laufenden Instanz sagt: **`can_retry` war `false`** — den Knopf gab es gar nicht.
|
||||
|
||||
Ursache: `_kann_neu_komprimieren` suchte an genau zwei Orten — im
|
||||
Container-Standard `/app/temp/raw/<id>` und unter dem AKTUELLEN Wert der
|
||||
Einstellung `workDir`. Der Rip war aber mit einer Wahl *für diesen einen Rip*
|
||||
auf die NAS gelegt worden (das gibt es seit v3.15 im Rip-Dialog), und die
|
||||
Einstellung selbst stand auf leer. Damit zeigte nichts mehr auf die Datei:
|
||||
|
||||
workDir (Einstellung) = "" → geprüft wurde nur /app/temp/raw
|
||||
tatsächlicher Ort = /app/media/rippy/<id>
|
||||
Ergebnis → 75 GB unsichtbar, Neu-Rip scheinbar unvermeidlich
|
||||
|
||||
Das ist derselbe Fehler, der dieses Projekt schon mehrfach gekostet hat: aus
|
||||
einem Zustandswert (der heutigen Einstellung) auf einen Mechanismus (wohin
|
||||
damals gerippt wurde) geschlossen, statt nachzusehen.
|
||||
|
||||
## Warum gesucht und nicht gespeichert wird
|
||||
|
||||
Den Ort in die Job-Zeile zu schreiben wäre sauberer — aber die jobs-Tabelle
|
||||
bräuchte eine neue Spalte, und `create_all` legt nur fehlende TABELLEN an, keine
|
||||
fehlenden Spalten. Eine Migration für einen Suchraum von einer Handvoll
|
||||
Verzeichnissen ist das falsche Werkzeug, und Bestandsjobs (genau der Fall hier)
|
||||
hätten den Wert ohnehin nicht.
|
||||
|
||||
Der Suchraum ist nämlich klein und geschlossen: `_arbeitsverzeichnis()` im Worker
|
||||
lässt ausschließlich den Container-Standard oder einen Pfad UNTER `/app/media`
|
||||
zu. Es genügt also, `/app/temp/raw/<id>` und `<jedes Speicherziel>/<id>`
|
||||
anzusehen — die oberste Ebene von `/app/media`, ohne Rekursion.
|
||||
|
||||
Verwechslungsgefahr gibt es dabei nicht: Roh-Verzeichnisse heißen exakt wie die
|
||||
Job-ID (vollständige UUID), fertige Ablagen heißen `Titel (Jahr) [kurz-id]`.
|
||||
"""
|
||||
|
||||
import posixpath
|
||||
import subprocess
|
||||
|
||||
# Container-Standard für Roh-Rips (RAW_DIR im Worker).
|
||||
RAW_STANDARD = "/app/temp/raw"
|
||||
MEDIA_ROOT = "/app/media"
|
||||
|
||||
# Harte Obergrenze für EINE Verzeichnis-Prüfung. Siehe verzeichnis_da().
|
||||
PRUEF_TIMEOUT_SEKUNDEN = 4
|
||||
|
||||
|
||||
def kandidaten(job_id: str, work_dir: str, media_unterordner) -> list:
|
||||
"""Alle Orte, an denen die Roh-MKVs dieses Jobs liegen KÖNNTEN (pure).
|
||||
|
||||
`media_unterordner` sind die Namen der obersten Ebene unter /app/media
|
||||
(Ablageziele inkl. eingehängter Freigaben) — die Liste kommt vom Aufrufer,
|
||||
damit diese Funktion ohne Dateisystem testbar bleibt.
|
||||
|
||||
Reihenfolge: Container-Standard, dann die eingestellte Wahl, dann alle
|
||||
Ablageziele. Doppelte fliegen raus, die Reihenfolge bleibt stabil.
|
||||
|
||||
posixpath, nicht os.path: Das sind Container-Pfade. os.path.join baut unter
|
||||
Windows Backslashes daraus, und dann greift keine Prüfung mehr — dieselbe
|
||||
Falle wie bei `_zielbasis()` (v3.14) und `_mountpoint()` (26.07.2026).
|
||||
"""
|
||||
if not job_id:
|
||||
return []
|
||||
orte = [posixpath.join(RAW_STANDARD, job_id)]
|
||||
wahl = (work_dir or "").strip().rstrip("/")
|
||||
if wahl and (wahl == MEDIA_ROOT or wahl.startswith(MEDIA_ROOT + "/")):
|
||||
orte.append(posixpath.join(wahl, job_id))
|
||||
for name in media_unterordner or []:
|
||||
if name:
|
||||
orte.append(posixpath.join(MEDIA_ROOT, name, job_id))
|
||||
gesehen, eindeutig = set(), []
|
||||
for ort in orte:
|
||||
if ort not in gesehen:
|
||||
gesehen.add(ort)
|
||||
eindeutig.append(ort)
|
||||
return eindeutig
|
||||
|
||||
|
||||
def pruefen(pfad: str, laufen=None) -> str:
|
||||
"""Gibt es dieses Verzeichnis? „da" | „weg" | „unklar" — mit HARTER Zeitgrenze.
|
||||
|
||||
Drei Antworten statt zwei, weil „ich konnte nicht nachsehen" etwas anderes
|
||||
ist als „es ist nicht da". Gemessen am 26.07.2026: Nach einem
|
||||
Container-Neustart stallt der ERSTE Zugriff auf die CIFS-Freigabe mehrere
|
||||
Sekunden (die SMB-Sitzung wird neu aufgebaut), danach antwortet sie in
|
||||
0,01 s — zehn von zehn Versuchen. Ohne die Unterscheidung verschwindet in
|
||||
diesem Fenster der Knopf „Neu komprimieren", und der Nutzer schließt daraus,
|
||||
seine 74 GB seien weg. Genau diese Sorte Fehlschluss hat das Projekt schon
|
||||
zweimal bezahlt.
|
||||
|
||||
Begründung der Technik siehe verzeichnis_da.
|
||||
"""
|
||||
if not pfad:
|
||||
return "weg"
|
||||
starten = laufen or subprocess.run
|
||||
try:
|
||||
ergebnis = starten(
|
||||
["timeout", str(PRUEF_TIMEOUT_SEKUNDEN), "ls", "-d", pfad],
|
||||
capture_output=True,
|
||||
timeout=PRUEF_TIMEOUT_SEKUNDEN + 2,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return "unklar"
|
||||
if ergebnis.returncode == 0:
|
||||
return "da"
|
||||
# 124 ist der Rückgabewert von `timeout`, wenn es das Kind abgeschossen hat
|
||||
# (dokumentiert in coreutils). Das heißt: nicht angesehen, nicht „weg".
|
||||
return "unklar" if ergebnis.returncode == 124 else "weg"
|
||||
|
||||
|
||||
def verzeichnis_da(pfad: str, laufen=None) -> bool:
|
||||
"""Gibt es dieses Verzeichnis? — mit HARTER Zeitgrenze.
|
||||
|
||||
## Warum nicht os.path.isdir
|
||||
|
||||
Weil es an einem Netz-Mount unbegrenzt hängen kann, und zwar im Kernel
|
||||
(Prozess-Zustand D, „uninterruptible sleep"). Genau das ist am 26.07.2026
|
||||
passiert: Die Hintergrund-Schleife startete ihren ersten Durchlauf, während
|
||||
Rippy die CIFS-Freigabe nach einem Container-Neustart neu einhängte. Ihr
|
||||
`os.path.isdir` blieb stecken, `asyncio.to_thread` kam nie zurück, die
|
||||
Schleife erreichte ihr `sleep` nie — und war damit für immer tot. Sichtbar
|
||||
war nur, dass `can_retry` dauerhaft `false` blieb; zwei Threads standen im
|
||||
Zustand D.
|
||||
|
||||
Ein Timeout um den Aufruf hätte nichts geholfen: Ein im Kernel hängender
|
||||
Thread lässt sich aus Python nicht abbrechen, jeder Versuch hätte einen
|
||||
weiteren Thread verbrannt, bis der Pool leer ist.
|
||||
|
||||
Ein Kind-PROZESS lässt sich abbrechen. Deshalb `timeout N ls -d <pfad>` —
|
||||
dasselbe Werkzeug, das `mounts.ist_erreichbar` seit dem 24.07.2026 für
|
||||
genau dieses Problem benutzt (dort für den toten NAS-Mount). Läuft es in
|
||||
die Zeitgrenze, gilt das Verzeichnis als „nicht da": Ein Ort, den man nicht
|
||||
innerhalb von Sekunden ansehen kann, ist für einen Rip ohnehin unbrauchbar.
|
||||
|
||||
`laufen` ist einspritzbar, damit das ohne echte Prozesse testbar bleibt.
|
||||
|
||||
Für den Fall „konnte nicht nachsehen" gibt es `pruefen()` mit drei
|
||||
Antworten. Hier gilt nur „da" als ja — wer eine Ja/Nein-Antwort braucht,
|
||||
soll im Zweifel Nein bekommen.
|
||||
"""
|
||||
return pruefen(pfad, laufen) == "da"
|
||||
|
||||
|
||||
def suche(job_id: str, work_dir: str, listdir, isdir) -> list:
|
||||
"""Die Orte, an denen wirklich etwas liegt.
|
||||
|
||||
`listdir` und `isdir` werden übergeben statt importiert — so ist die Suche
|
||||
ohne Dateisystem prüfbar. Für `isdir` gehört `verzeichnis_da` eingesetzt und
|
||||
NICHT os.path.isdir: Die Kandidaten liegen unter /app/media, und dort kann
|
||||
ein Netz-Mount unbegrenzt hängen (Begründung bei verzeichnis_da).
|
||||
|
||||
`listdir` darf os.listdir bleiben: Gelistet wird nur /app/media selbst, und
|
||||
das ist ein lokales Verzeichnis — die Freigaben sind Unterordner davon.
|
||||
"""
|
||||
try:
|
||||
unterordner = sorted(listdir(MEDIA_ROOT))
|
||||
except OSError:
|
||||
unterordner = []
|
||||
gefunden = []
|
||||
for ort in kandidaten(job_id, work_dir, unterordner):
|
||||
try:
|
||||
if isdir(ort):
|
||||
gefunden.append(ort)
|
||||
except OSError:
|
||||
# Toter Mount → als „nicht da" werten. Ein Fehlschlag hier darf die
|
||||
# Job-Liste nicht mitnehmen (Befund 24.07. bei /storage-targets).
|
||||
continue
|
||||
return gefunden
|
||||
|
||||
|
||||
def suche_mit_status(job_id: str, work_dir: str, listdir, pruefer=None) -> dict:
|
||||
"""Wie suche(), aber sagt auch, ob etwas UNGEPRÜFT geblieben ist.
|
||||
|
||||
Rückgabe: {"pfade": [...], "unklar": bool}. `unklar` heißt: Mindestens ein
|
||||
Ort hat nicht geantwortet — ein leeres `pfade` ist dann kein Beweis für
|
||||
„nichts da". Der Aufrufer soll in diesem Fall seine letzte bekannte Antwort
|
||||
behalten, statt Abwesenheit zu behaupten (siehe pruefen()).
|
||||
"""
|
||||
pruefe = pruefer or pruefen
|
||||
try:
|
||||
unterordner = sorted(listdir(MEDIA_ROOT))
|
||||
except OSError:
|
||||
unterordner = []
|
||||
gefunden, unklar = [], False
|
||||
for ort in kandidaten(job_id, work_dir, unterordner):
|
||||
antwort = pruefe(ort)
|
||||
if antwort == "da":
|
||||
gefunden.append(ort)
|
||||
elif antwort == "unklar":
|
||||
unklar = True
|
||||
return {"pfade": gefunden, "unklar": unklar}
|
||||
|
||||
|
||||
def groesse(pfade: list, listdir, isfile, getsize) -> tuple:
|
||||
"""(Bytes, Dateizahl) der Roh-Dateien — flach, nicht rekursiv.
|
||||
|
||||
Flach genügt: MakeMKV legt die Titel als `title_tNN.mkv` direkt in das
|
||||
Job-Verzeichnis, Unterordner entstehen dort nicht.
|
||||
"""
|
||||
bytes_gesamt, dateien = 0, 0
|
||||
for pfad in pfade or []:
|
||||
try:
|
||||
namen = listdir(pfad)
|
||||
except OSError:
|
||||
continue
|
||||
for name in namen:
|
||||
voll = posixpath.join(pfad, name)
|
||||
try:
|
||||
if isfile(voll):
|
||||
bytes_gesamt += getsize(voll)
|
||||
dateien += 1
|
||||
except OSError:
|
||||
continue
|
||||
return bytes_gesamt, dateien
|
||||
@@ -157,3 +157,92 @@ def test_worker_setup_routen_die_gebraucht_werden_sind_da():
|
||||
for pfad in ("/worker-setup/paket", "/worker-setup/windows",
|
||||
"/worker-setup/windows-exe"):
|
||||
assert pfad in routen, f"Route {pfad} fehlt — Worker-Installation kaputt"
|
||||
|
||||
# --- Mount-Wache: heilt, was nach jedem Rebuild kaputt ist -------------------
|
||||
#
|
||||
# Hier statt in test_mounts_helpers.py, weil diese Tests `main` brauchen und
|
||||
# main.py haengt an fcntl (Linux). Dieses Modul ueberspringt sich unter Windows
|
||||
# selbst — es laeuft also genau da, wo auch die Ampel laeuft.
|
||||
|
||||
|
||||
def test_wache_ruehrt_nichts_an_solange_ein_job_laeuft(monkeypatch):
|
||||
"""Neu verbinden heisst `umount -l`. Mitten in einem Rip oder Encode waere
|
||||
das ein Datenverlust - die Wache muss dann stillstehen."""
|
||||
import main
|
||||
|
||||
monkeypatch.setattr(main.db, "list_mounts", lambda: [
|
||||
{"name": "rippy", "typ": "cifs", "quelle": "//nas/rippy"}])
|
||||
monkeypatch.setattr(main.db, "hat_arbeit", lambda: True)
|
||||
monkeypatch.setattr(main.mount_verwaltung, "ist_erreichbar",
|
||||
lambda name: (_ for _ in ()).throw(AssertionError("nicht anfassen!")))
|
||||
|
||||
main._mounts_nachsehen() # darf einfach nichts tun
|
||||
|
||||
|
||||
def test_wache_verbindet_eine_stumme_freigabe_neu(monkeypatch):
|
||||
import main
|
||||
|
||||
repariert, gelogged = [], []
|
||||
monkeypatch.setattr(main.db, "list_mounts", lambda: [
|
||||
{"name": "rippy", "typ": "cifs", "quelle": "//nas/rippy"}])
|
||||
monkeypatch.setattr(main.db, "hat_arbeit", lambda: False)
|
||||
monkeypatch.setattr(main.db, "add_log",
|
||||
lambda lvl, src, msg: gelogged.append((lvl, msg)))
|
||||
monkeypatch.setattr(main.mount_verwaltung, "ist_erreichbar", lambda name: False)
|
||||
monkeypatch.setattr(main.mount_verwaltung, "reparieren",
|
||||
lambda *a, **k: repariert.append(a[0]))
|
||||
main._MOUNT_STAND.clear()
|
||||
|
||||
main._mounts_nachsehen()
|
||||
|
||||
assert repariert == ["rippy"]
|
||||
assert any("neu verbunden" in m for _, m in gelogged)
|
||||
|
||||
|
||||
def test_wache_meckert_nicht_jede_minute(monkeypatch):
|
||||
"""Ist das NAS ausgeschaltet, waere ein Log je Minute ein Wasserfall.
|
||||
Gemeldet wird nur der UEBERGANG."""
|
||||
import main
|
||||
|
||||
gelogged = []
|
||||
monkeypatch.setattr(main.db, "list_mounts", lambda: [
|
||||
{"name": "rippy", "typ": "cifs", "quelle": "//nas/rippy"}])
|
||||
monkeypatch.setattr(main.db, "hat_arbeit", lambda: False)
|
||||
monkeypatch.setattr(main.db, "add_log",
|
||||
lambda lvl, src, msg: gelogged.append(msg))
|
||||
monkeypatch.setattr(main.mount_verwaltung, "ist_erreichbar", lambda name: False)
|
||||
|
||||
def reparieren_scheitert(*a, **k):
|
||||
raise RuntimeError("NAS aus")
|
||||
|
||||
monkeypatch.setattr(main.mount_verwaltung, "reparieren", reparieren_scheitert)
|
||||
main._MOUNT_STAND.clear()
|
||||
|
||||
for _ in range(5):
|
||||
main._mounts_nachsehen()
|
||||
|
||||
# "antwortet nicht" genau EINMAL (der Uebergang), die Fehlschlaege sind
|
||||
# jeweils eigene Meldungen - aber kein wiederholtes "antwortet nicht".
|
||||
assert len([m for m in gelogged if "antwortet nicht" in m]) == 1
|
||||
|
||||
|
||||
def test_wache_meldet_wenn_es_wieder_geht(monkeypatch):
|
||||
import main
|
||||
|
||||
gelogged = []
|
||||
zustand = {"da": False}
|
||||
monkeypatch.setattr(main.db, "list_mounts", lambda: [
|
||||
{"name": "rippy", "typ": "cifs", "quelle": "//nas/rippy"}])
|
||||
monkeypatch.setattr(main.db, "hat_arbeit", lambda: False)
|
||||
monkeypatch.setattr(main.db, "add_log",
|
||||
lambda lvl, src, msg: gelogged.append(msg))
|
||||
monkeypatch.setattr(main.mount_verwaltung, "ist_erreichbar",
|
||||
lambda name: zustand["da"])
|
||||
monkeypatch.setattr(main.mount_verwaltung, "reparieren",
|
||||
lambda *a, **k: None)
|
||||
main._MOUNT_STAND.clear()
|
||||
main._MOUNT_STAND["rippy"] = False
|
||||
|
||||
zustand["da"] = True
|
||||
main._mounts_nachsehen()
|
||||
assert any("antwortet wieder" in m for m in gelogged)
|
||||
|
||||
@@ -163,3 +163,259 @@ def test_erzeugtes_mapping_uebersetzt_den_echten_fehlerfall(monkeypatch):
|
||||
assert pfad_lokal("/app/media/rippy/95afdc89/title_t00.mkv", mapping) == (
|
||||
"\\\\192.168.178.62\\rippy\\95afdc89\\title_t00.mkv"
|
||||
)
|
||||
|
||||
|
||||
# --- Der Mount kam nach einem Rebuild nicht zurueck (Befund 26.07.2026) ------
|
||||
|
||||
|
||||
def test_mounten_geht_bei_totem_mount_den_reparatur_weg(monkeypatch):
|
||||
"""Regression. Vorher galt `os.path.ismount` als Beweis, dass alles steht -
|
||||
und nach `docker compose up -d --build` war die CIFS-Freigabe TOT (4 von 4
|
||||
Zugriffen 10 s Timeout), der Mountpunkt aber weiter vorhanden. Damit brach
|
||||
das Wiederherstellen genau dort ab, und `schreibtest()` (kein Timeout!)
|
||||
blockierte den Start-Thread im Kernel.
|
||||
|
||||
Antwortet die Freigabe nicht, muss geloest und frisch gemountet werden -
|
||||
genau wie reparieren() es tut, nur automatisch. Der Test faengt das an der
|
||||
WIRKUNG: ismount darf dann gar nicht mehr gefragt werden."""
|
||||
import types
|
||||
|
||||
import mounts
|
||||
|
||||
ablauf = []
|
||||
monkeypatch.setattr(mounts.os, "makedirs", lambda *a, **k: None)
|
||||
# pfad_lage gestubbt: Sie ruft selbst `timeout ls` auf, und hier geht es um
|
||||
# die Reihenfolge von Loesen und Mounten.
|
||||
monkeypatch.setattr(mounts, "pfad_lage", lambda ziel: "da")
|
||||
# Vorher tot (der Vor-Check), nach dem Mount dauerhaft erreichbar. Die
|
||||
# Doppelprobe wird hier gestubbt, damit der Test nicht 3 s echt wartet.
|
||||
monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: False)
|
||||
monkeypatch.setattr(mounts, "wirklich_erreichbar", lambda name: True)
|
||||
monkeypatch.setattr(mounts, "_stale_mounts_loesen",
|
||||
lambda ziel: ablauf.append("loesen"))
|
||||
monkeypatch.setattr(mounts, "schreibtest", lambda p: True)
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
ablauf.append(cmd[0])
|
||||
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(mounts.subprocess, "run", fake_run)
|
||||
# ismount darf hier gar nicht mehr gefragt werden
|
||||
monkeypatch.setattr(mounts.os.path, "ismount",
|
||||
lambda p: (_ for _ in ()).throw(AssertionError("zu frueh gefragt")))
|
||||
|
||||
assert mounts.mounten("rippy", "cifs", "//nas/rippy") is True
|
||||
assert ablauf == ["loesen", "mount"]
|
||||
|
||||
|
||||
def test_mounten_prueft_das_ergebnis_und_versucht_es_zweimal(monkeypatch):
|
||||
"""Befund 26.07.2026, dreimal reproduziert: `mount` meldete Erfolg, und die
|
||||
Freigabe antwortete danach TROTZDEM nicht (eine einzige, korrekt aussehende
|
||||
Schicht in /proc/mounts). Derselbe Ablauf ein zweites Mal stellte sie sofort
|
||||
her. Also wird das Ergebnis geprueft statt geglaubt."""
|
||||
import types
|
||||
|
||||
import mounts
|
||||
|
||||
ablauf = []
|
||||
# nie erreichbar: vorher, nach Versuch 1, nach Versuch 2
|
||||
monkeypatch.setattr(mounts.os, "makedirs", lambda *a, **k: None)
|
||||
monkeypatch.setattr(mounts, "pfad_lage", lambda ziel: "da")
|
||||
monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: False)
|
||||
monkeypatch.setattr(mounts, "wirklich_erreichbar", lambda name: False)
|
||||
monkeypatch.setattr(mounts, "_stale_mounts_loesen", lambda ziel: None)
|
||||
monkeypatch.setattr(mounts, "_lazy_umount", lambda ziel: ablauf.append("lazy"))
|
||||
monkeypatch.setattr(mounts, "schreibtest", lambda p: True)
|
||||
monkeypatch.setattr(
|
||||
mounts.subprocess, "run",
|
||||
lambda cmd, **k: (ablauf.append(cmd[0]),
|
||||
types.SimpleNamespace(returncode=0, stdout="", stderr=""))[1])
|
||||
|
||||
import pytest
|
||||
|
||||
with pytest.raises(RuntimeError) as fehler:
|
||||
mounts.mounten("rippy", "cifs", "//nas/rippy")
|
||||
|
||||
# Zweimal gemountet, dazwischen einmal geloest
|
||||
assert ablauf == ["mount", "lazy", "mount"]
|
||||
# Und die Meldung sagt die Wahrheit statt "eingehaengt"
|
||||
assert "antwortet aber nicht" in str(fehler.value)
|
||||
|
||||
|
||||
def test_mounten_laesst_gesunden_mount_in_ruhe(monkeypatch):
|
||||
"""Antwortet die Freigabe, bleibt sie unangetastet - kein Loesen, kein
|
||||
zweites Mounten (das wuerde stapeln)."""
|
||||
import mounts
|
||||
|
||||
monkeypatch.setattr(mounts.os, "makedirs", lambda *a, **k: None)
|
||||
monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: True)
|
||||
monkeypatch.setattr(mounts.os.path, "ismount", lambda p: True)
|
||||
monkeypatch.setattr(mounts, "schreibtest", lambda p: True)
|
||||
monkeypatch.setattr(mounts, "_stale_mounts_loesen",
|
||||
lambda ziel: (_ for _ in ()).throw(AssertionError("nicht loesen!")))
|
||||
|
||||
assert mounts.mounten("rippy", "cifs", "//nas/rippy") is True
|
||||
|
||||
|
||||
def test_wirklich_erreichbar_prueft_zweimal_mit_abstand(monkeypatch):
|
||||
"""Befund 26.07.2026: Direkt nach einem frischen `mount` antwortete die
|
||||
Freigabe - und Sekunden spaeter lief jeder Zugriff in die Zeitgrenze
|
||||
(Wettlauf mit dem lazy umount, dessen Abbau hinter den neuen Mount fiel).
|
||||
Eine EINZIGE Probe kann das nicht sehen."""
|
||||
import mounts
|
||||
|
||||
antworten = iter([True, False]) # erst ja, dann nein
|
||||
gewartet = []
|
||||
monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: next(antworten))
|
||||
assert mounts.wirklich_erreichbar("rippy", warten=gewartet.append) is False
|
||||
assert gewartet == [3]
|
||||
|
||||
|
||||
def test_wirklich_erreichbar_bei_gesunder_freigabe(monkeypatch):
|
||||
import mounts
|
||||
|
||||
monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: True)
|
||||
assert mounts.wirklich_erreichbar("rippy", warten=lambda s: None) is True
|
||||
|
||||
|
||||
def test_wirklich_erreichbar_spart_das_warten_wenn_schon_die_erste_probe_faellt(monkeypatch):
|
||||
import mounts
|
||||
|
||||
gewartet = []
|
||||
monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: False)
|
||||
assert mounts.wirklich_erreichbar("rippy", warten=gewartet.append) is False
|
||||
assert gewartet == [] # nicht drei Sekunden fuer nichts
|
||||
|
||||
|
||||
def test_stale_loesen_wartet_den_lazy_abbau_ab(monkeypatch):
|
||||
"""Der Grund fuer die 150 Sekunden (Befund 26.07.2026): `umount -l` ist lazy,
|
||||
der Abbau passiert spaeter. Wer direkt danach mountet, riskiert, dass der
|
||||
Abbau HINTER dem neuen Mount landet - der erste Reparaturversuch scheiterte
|
||||
dadurch regelmaessig, und der zweite kostete 30 s Timeout."""
|
||||
import time
|
||||
import types
|
||||
|
||||
import mounts
|
||||
|
||||
gewartet = []
|
||||
monkeypatch.setattr(time, "sleep", gewartet.append)
|
||||
aufrufe = []
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
aufrufe.append(cmd)
|
||||
rc = 0 if len(aufrufe) <= 2 else 1
|
||||
return types.SimpleNamespace(returncode=rc, stdout=b"", stderr=b"")
|
||||
|
||||
monkeypatch.setattr(mounts.subprocess, "run", fake_run)
|
||||
assert mounts._stale_mounts_loesen("/app/media/x") == 2
|
||||
assert gewartet == [1.5]
|
||||
|
||||
|
||||
def test_stale_loesen_wartet_nicht_wenn_nichts_zu_loesen_war(monkeypatch):
|
||||
"""War kein Mount da, gibt es auch keinen Abbau abzuwarten - dann darf die
|
||||
Reparatur nicht kuenstlich gebremst werden."""
|
||||
import time
|
||||
import types
|
||||
|
||||
import mounts
|
||||
|
||||
gewartet = []
|
||||
monkeypatch.setattr(time, "sleep", gewartet.append)
|
||||
monkeypatch.setattr(
|
||||
mounts.subprocess, "run",
|
||||
lambda cmd, **k: types.SimpleNamespace(returncode=1, stdout=b"", stderr=b""))
|
||||
assert mounts._stale_mounts_loesen("/app/media/x") == 0
|
||||
assert gewartet == []
|
||||
|
||||
|
||||
def test_pfad_lage_unterscheidet_da_weg_unklar(monkeypatch):
|
||||
"""DER Fund vom 26.07.2026: Eine Wiederanbindung brauchte 3 min 15 s, und die
|
||||
Zeit ging in die ERSTE Zeile von mounten() - `os.makedirs(ziel,
|
||||
exist_ok=True)`. `exist_ok` prueft mit os.path.isdir, und ein `stat` auf einen
|
||||
toten CIFS-Mount blockiert im Kernel bis zum SMB-Timeout. Deshalb wird die
|
||||
Lage jetzt mit einem abbrechbaren Kind-Prozess erfragt."""
|
||||
import types
|
||||
|
||||
import mounts
|
||||
|
||||
def antwort(rc):
|
||||
return lambda cmd, **k: types.SimpleNamespace(returncode=rc, stdout=b"", stderr=b"")
|
||||
|
||||
monkeypatch.setattr(mounts.subprocess, "run", antwort(0))
|
||||
assert mounts.pfad_lage("/app/media/rippy") == "da"
|
||||
monkeypatch.setattr(mounts.subprocess, "run", antwort(2))
|
||||
assert mounts.pfad_lage("/app/media/neu") == "weg"
|
||||
# 124 = `timeout` hat abgeschossen: existiert, antwortet aber nicht
|
||||
monkeypatch.setattr(mounts.subprocess, "run", antwort(124))
|
||||
assert mounts.pfad_lage("/app/media/totes-nas") == "unklar"
|
||||
|
||||
|
||||
def test_pfad_lage_nutzt_eine_zeitgrenze(monkeypatch):
|
||||
import types
|
||||
|
||||
import mounts
|
||||
|
||||
gesehen = {}
|
||||
monkeypatch.setattr(
|
||||
mounts.subprocess, "run",
|
||||
lambda cmd, **k: (gesehen.update(cmd=cmd, kw=k),
|
||||
types.SimpleNamespace(returncode=0))[1])
|
||||
mounts.pfad_lage("/x")
|
||||
assert gesehen["cmd"][0] == "timeout"
|
||||
assert gesehen["kw"]["timeout"] is not None
|
||||
|
||||
|
||||
def test_mounten_legt_den_ordner_nur_an_wenn_er_fehlt(monkeypatch):
|
||||
"""Bei "unklar" (toter Mount) darf makedirs NICHT laufen - genau dort hing es
|
||||
drei Minuten. Der Ordner ist dann ohnehin da."""
|
||||
import types
|
||||
|
||||
import mounts
|
||||
|
||||
angelegt = []
|
||||
monkeypatch.setattr(mounts, "pfad_lage", lambda ziel: "unklar")
|
||||
monkeypatch.setattr(mounts.os, "makedirs",
|
||||
lambda *a, **k: angelegt.append(a[0] if a else ""))
|
||||
monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: False)
|
||||
monkeypatch.setattr(mounts, "wirklich_erreichbar", lambda name: True)
|
||||
monkeypatch.setattr(mounts, "_stale_mounts_loesen", lambda ziel: 0)
|
||||
monkeypatch.setattr(mounts, "schreibtest", lambda p: True)
|
||||
monkeypatch.setattr(
|
||||
mounts.subprocess, "run",
|
||||
lambda cmd, **k: types.SimpleNamespace(returncode=0, stdout="", stderr=""))
|
||||
|
||||
assert mounts.mounten("rippy", "cifs", "//nas/rippy") is True
|
||||
assert angelegt == []
|
||||
|
||||
|
||||
def test_mounten_legt_den_ordner_bei_erstinstallation_an(monkeypatch):
|
||||
import types
|
||||
|
||||
import mounts
|
||||
|
||||
angelegt = []
|
||||
monkeypatch.setattr(mounts, "pfad_lage", lambda ziel: "weg")
|
||||
monkeypatch.setattr(mounts.os, "makedirs",
|
||||
lambda *a, **k: angelegt.append(a[0] if a else ""))
|
||||
monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: False)
|
||||
monkeypatch.setattr(mounts, "wirklich_erreichbar", lambda name: True)
|
||||
monkeypatch.setattr(mounts, "_stale_mounts_loesen", lambda ziel: 0)
|
||||
monkeypatch.setattr(mounts, "schreibtest", lambda p: True)
|
||||
monkeypatch.setattr(
|
||||
mounts.subprocess, "run",
|
||||
lambda cmd, **k: types.SimpleNamespace(returncode=0, stdout="", stderr=""))
|
||||
|
||||
mounts.mounten("rippy", "cifs", "//nas/rippy")
|
||||
assert angelegt == ["/app/media/rippy"]
|
||||
|
||||
|
||||
def test_reparieren_fragt_ismount_nicht_mehr(monkeypatch):
|
||||
"""os.path.ismount ist ein `stat` und blockiert auf einem toten CIFS-Mount.
|
||||
Gebraucht wird es nicht: `umount -l` auf einen leeren Pfad kostet nichts."""
|
||||
import mounts
|
||||
|
||||
monkeypatch.setattr(mounts.os.path, "ismount",
|
||||
lambda p: (_ for _ in ()).throw(AssertionError("nicht fragen!")))
|
||||
monkeypatch.setattr(mounts, "_lazy_umount", lambda ziel: None)
|
||||
monkeypatch.setattr(mounts, "mounten", lambda *a, **k: True)
|
||||
assert mounts.reparieren("rippy", "cifs", "//nas/rippy") is True
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Tests für die Phasen-Erkennung — was darf der „Neu"-Knopf anbieten?"""
|
||||
|
||||
import json
|
||||
|
||||
import phasen
|
||||
|
||||
|
||||
def test_laufender_job_bietet_nichts():
|
||||
assert phasen.retry_art({"status": "running"}) == phasen.NICHTS
|
||||
assert phasen.retry_art({"status": "completed"}) == phasen.NICHTS
|
||||
|
||||
|
||||
def test_toter_transcode_bietet_komprimieren():
|
||||
job = {"status": "failed", "meta": json.dumps({"rip_fertig": True})}
|
||||
assert phasen.retry_art(job) == phasen.NEU_KOMPRIMIEREN
|
||||
|
||||
|
||||
def test_toter_rip_bietet_rippen():
|
||||
"""Der Vorfall vom 26.07.2026: 5,1 GB von 40 GB. Komprimieren wäre falsch."""
|
||||
job = {"status": "failed", "meta": json.dumps({"rip_fertig": False, "year": 1988})}
|
||||
assert phasen.retry_art(job) == phasen.NEU_RIPPEN
|
||||
|
||||
|
||||
def test_bestandsjob_ohne_marke_ist_unklar():
|
||||
"""Jobs von VOR dieser Änderung dürfen nicht geraten werden."""
|
||||
assert phasen.retry_art({"status": "failed", "meta": None}) == phasen.UNKLAR
|
||||
assert phasen.retry_art({"status": "failed"}) == phasen.UNKLAR
|
||||
assert phasen.retry_art(
|
||||
{"status": "failed", "meta": json.dumps({"year": 1988})}
|
||||
) == phasen.UNKLAR
|
||||
|
||||
|
||||
def test_kaputtes_json_ist_unklar_statt_absturz():
|
||||
"""/jobs darf an einer krummen meta-Zeile nicht scheitern."""
|
||||
assert phasen.retry_art({"status": "failed", "meta": "{kein json"}) == phasen.UNKLAR
|
||||
assert phasen.retry_art({"status": "failed", "meta": "[1,2]"}) == phasen.UNKLAR
|
||||
assert phasen.retry_art({"status": "failed", "meta": 7}) == phasen.UNKLAR
|
||||
|
||||
|
||||
def test_marke_wird_auch_als_dict_gelesen():
|
||||
"""Der Detail-Endpunkt hat die Metadaten schon geparst."""
|
||||
job = {"status": "failed", "meta": {"rip_fertig": True}}
|
||||
assert phasen.retry_art(job) == phasen.NEU_KOMPRIMIEREN
|
||||
|
||||
|
||||
def test_der_schluessel_heisst_im_worker_genauso():
|
||||
"""Zwei Container, kein geteiltes Paket — die Marke muss zusammenpassen.
|
||||
|
||||
Ohne diese Prüfung ist ein Tippfehler in einer der beiden Dateien lautlos:
|
||||
Der Worker schreibt `rip_fertig`, die API liest `ripFertig`, und JEDER Job
|
||||
wäre für immer „unklar". Genau diese Sorte Auseinanderdriften hat die
|
||||
Zombie-Erkennung ein Release lang blind gemacht („running" vs. „ripping").
|
||||
"""
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
quelle = (
|
||||
pathlib.Path(__file__).resolve().parents[1] / "worker" / "tasks.py"
|
||||
).read_text(encoding="utf-8")
|
||||
# Der Worker schreibt die Marke über eine Konstante — deren Wert muss hier
|
||||
# ankommen.
|
||||
treffer = re.search(r'^RIP_FERTIG\s*=\s*"([^"]+)"', quelle, re.MULTILINE)
|
||||
assert treffer, "worker/tasks.py definiert RIP_FERTIG nicht mehr"
|
||||
assert treffer.group(1) == phasen.RIP_FERTIG
|
||||
@@ -136,7 +136,11 @@ def test_unscharfer_jikan_treffer_bleibt_als_vorschlag(monkeypatch):
|
||||
|
||||
|
||||
def test_ohne_jede_quelle_bleibt_es_unbekannt(monkeypatch):
|
||||
"""Antwortet keine Quelle, bleibt es beim Disc-Titel mit Confidence 0,3 und
|
||||
`type: unknown` — der Bestand, unverändert. Der Rip laeuft trotzdem, die
|
||||
Datei heisst dann nur wie das Disc-Label."""
|
||||
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 == {}
|
||||
assert ergebnis.confidence == 0.3
|
||||
assert ergebnis.metadata["type"] == "unknown"
|
||||
assert ergebnis.title == "Nix Da"
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Tests für die Anfrage-Bremse.
|
||||
|
||||
Sie hatte bis zum 26.07.2026 keine — und dabei war sie seit Wochen die Ursache
|
||||
eines gemeldeten Fehlers („dieser Bereich wird oft neu geladen"): Das Limit lag
|
||||
mit 100/min UNTER dem, was Rippys eigenes Dashboard verursacht, und weil hinter
|
||||
dem nginx alle Clients dieselbe Adresse hatten, galt es auch noch gemeinsam.
|
||||
"""
|
||||
|
||||
import ratelimit
|
||||
|
||||
|
||||
def test_hinter_dem_proxy_zaehlt_der_echte_client():
|
||||
"""Der ganze Grund für den Fehler: ein Eimer für alle."""
|
||||
assert ratelimit.client_kennung("172.19.0.6", "192.168.178.20") == "192.168.178.20"
|
||||
assert ratelimit.client_kennung("172.19.0.6", "192.168.178.99") == "192.168.178.99"
|
||||
# Zwei Clients hinter demselben Proxy sind zwei Eimer.
|
||||
assert (
|
||||
ratelimit.client_kennung("172.19.0.6", "192.168.178.20")
|
||||
!= ratelimit.client_kennung("172.19.0.6", "192.168.178.99")
|
||||
)
|
||||
|
||||
|
||||
def test_ohne_kopfzeile_gilt_der_direkte_absender():
|
||||
assert ratelimit.client_kennung("192.168.178.20", None) == "192.168.178.20"
|
||||
assert ratelimit.client_kennung("192.168.178.20", "") == "192.168.178.20"
|
||||
|
||||
|
||||
def test_erste_adresse_der_kette_gewinnt():
|
||||
"""X-Forwarded-For kann eine Liste sein — der Ursprung steht vorn."""
|
||||
assert ratelimit.client_kennung(
|
||||
"172.19.0.6", "192.168.178.20, 172.19.0.6"
|
||||
) == "192.168.178.20"
|
||||
|
||||
|
||||
def test_muell_in_der_kopfzeile_wird_ignoriert():
|
||||
"""Sonst könnte ein Tippfehler beliebig viele Eimer aufmachen."""
|
||||
assert ratelimit.client_kennung("172.19.0.6", "nicht-ip") == "172.19.0.6"
|
||||
assert ratelimit.client_kennung("172.19.0.6", "<script>") == "172.19.0.6"
|
||||
|
||||
|
||||
def test_oeffentliche_adresse_in_der_kopfzeile_wird_nicht_geglaubt():
|
||||
"""Heimnetz-only: Was nicht aus dem privaten Netz kommt, zählt nicht.
|
||||
|
||||
Keine Härtung gegen Angreifer (dafür ist Rippy die falsche Stelle), aber die
|
||||
Bremse soll sich nicht mit einer beliebigen Kopfzeile aushebeln lassen.
|
||||
"""
|
||||
assert ratelimit.client_kennung("172.19.0.6", "8.8.8.8") == "172.19.0.6"
|
||||
|
||||
|
||||
def test_ohne_jede_angabe_bleibt_ein_eimer_uebrig():
|
||||
assert ratelimit.client_kennung("", None) == "unbekannt"
|
||||
|
||||
|
||||
def test_limit_greift_erst_nach_der_grenze():
|
||||
ratelimit.reset_rate_limit("test-a")
|
||||
for i in range(10):
|
||||
assert ratelimit.check_rate_limit("test-a", max_requests=10), f"Anfrage {i}"
|
||||
assert not ratelimit.check_rate_limit("test-a", max_requests=10)
|
||||
|
||||
|
||||
def test_eimer_sind_getrennt():
|
||||
ratelimit.reset_rate_limit("test-b")
|
||||
ratelimit.reset_rate_limit("test-c")
|
||||
for _ in range(5):
|
||||
ratelimit.check_rate_limit("test-b", max_requests=5)
|
||||
assert not ratelimit.check_rate_limit("test-b", max_requests=5)
|
||||
assert ratelimit.check_rate_limit("test-c", max_requests=5)
|
||||
|
||||
|
||||
def test_grenze_deckt_die_eigene_last_ab():
|
||||
"""Die Bremse darf Rippy nicht selbst ausbremsen.
|
||||
|
||||
Nachgerechnet aus den Taktgebern im UI (Zahlen im Kopf von ratelimit.py):
|
||||
Dashboard 75/min + Log-Kasten 24/min + Laufwerke 12/min + Tray 12/min je
|
||||
Worker = 123/min für EINEN Tab. Mit 100 war das garantiert rot. Diese Prüfung
|
||||
hält fest, dass die Grenze mit Luft darüber liegt — wer den Wert senkt, muss
|
||||
hier vorbei.
|
||||
"""
|
||||
eigene_last_pro_tab = 75 + 24 + 12
|
||||
tray_pro_worker = 12
|
||||
assert ratelimit.MAX_REQUESTS_PER_MINUTE >= 2 * eigene_last_pro_tab + 3 * tray_pro_worker
|
||||
|
||||
|
||||
def test_meldung_wird_gedrosselt():
|
||||
"""Ein Amok-Skript darf nicht das Log-Fenster fluten."""
|
||||
ratelimit._letzte_meldung.pop("test-d", None)
|
||||
assert ratelimit.darf_melden("test-d", jetzt=1000.0)
|
||||
assert not ratelimit.darf_melden("test-d", jetzt=1001.0)
|
||||
assert not ratelimit.darf_melden("test-d", jetzt=1059.0)
|
||||
assert ratelimit.darf_melden("test-d", jetzt=1061.0)
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Tests der Rohdaten-Suche — mit dem echten Fall, der sie nötig gemacht hat.
|
||||
|
||||
Job `95afdc89` (26.07.2026, an der laufenden Instanz gemessen): 79,6 GB
|
||||
Rohschnitt unter /app/media/rippy/<id>, Einstellung `workDir` leer, `can_retry`
|
||||
= false. Der Knopf „Neu komprimieren" fehlte, obwohl die Datei intakt war.
|
||||
"""
|
||||
|
||||
import rohdaten
|
||||
|
||||
JOB = "95afdc89-2426-4d44-829e-ad6ce1411905"
|
||||
|
||||
|
||||
def test_der_echte_fall_wird_gefunden():
|
||||
"""workDir ist LEER (so stand es live) und der Rohschnitt liegt trotzdem auf
|
||||
der NAS — weil beim Start eine Wahl nur für diesen Rip getroffen wurde."""
|
||||
orte = rohdaten.kandidaten(JOB, "", ["movies", "music", "rippy", "series"])
|
||||
assert f"/app/media/rippy/{JOB}" in orte
|
||||
# Der Container-Standard bleibt der erste Kandidat (schnellster Treffer)
|
||||
assert orte[0] == f"/app/temp/raw/{JOB}"
|
||||
|
||||
|
||||
def test_suche_liefert_nur_was_existiert():
|
||||
vorhanden = {f"/app/media/rippy/{JOB}"}
|
||||
gefunden = rohdaten.suche(
|
||||
JOB, "",
|
||||
listdir=lambda p: ["movies", "rippy"],
|
||||
isdir=lambda p: p in vorhanden,
|
||||
)
|
||||
assert gefunden == [f"/app/media/rippy/{JOB}"]
|
||||
|
||||
|
||||
def test_eingestelltes_arbeitsverzeichnis_kommt_vor_den_zielen():
|
||||
orte = rohdaten.kandidaten(JOB, "/app/media/rippy", ["movies", "rippy"])
|
||||
assert orte[1] == f"/app/media/rippy/{JOB}"
|
||||
# ... und taucht nicht doppelt auf, obwohl „rippy" auch Ablageziel ist
|
||||
assert orte.count(f"/app/media/rippy/{JOB}") == 1
|
||||
|
||||
|
||||
def test_arbeitsverzeichnis_ausserhalb_media_wird_ignoriert():
|
||||
"""Nur /app/media ist erlaubt (so entscheidet _arbeitsverzeichnis im
|
||||
Worker) — ein Pfad daneben darf hier nicht durchrutschen."""
|
||||
orte = rohdaten.kandidaten(JOB, "/etc", [])
|
||||
assert orte == [f"/app/temp/raw/{JOB}"]
|
||||
# Der Klassiker: ein Pfad, der nur mit dem Präfix ANFÄNGT
|
||||
orte = rohdaten.kandidaten(JOB, "/app/media-boese", [])
|
||||
assert orte == [f"/app/temp/raw/{JOB}"]
|
||||
|
||||
|
||||
def test_media_root_selbst_ist_erlaubt():
|
||||
orte = rohdaten.kandidaten(JOB, "/app/media", [])
|
||||
assert f"/app/media/{JOB}" in orte
|
||||
|
||||
|
||||
def test_ohne_job_id_nichts():
|
||||
assert rohdaten.kandidaten("", "/app/media", ["x"]) == []
|
||||
|
||||
|
||||
def test_kaputter_mount_reisst_die_suche_nicht_mit():
|
||||
"""Ein toter CIFS-Mount lässt isdir mit OSError fliegen. Das darf die
|
||||
Job-Liste nicht mitnehmen (Befund 24.07. bei /storage-targets)."""
|
||||
def isdir_kaputt(p):
|
||||
if "totes-nas" in p:
|
||||
raise OSError("Stale file handle")
|
||||
return p == f"/app/temp/raw/{JOB}"
|
||||
|
||||
gefunden = rohdaten.suche(
|
||||
JOB, "", listdir=lambda p: ["totes-nas", "movies"], isdir=isdir_kaputt)
|
||||
assert gefunden == [f"/app/temp/raw/{JOB}"]
|
||||
|
||||
|
||||
def test_listdir_kaputt_faellt_auf_den_standard_zurueck():
|
||||
def listdir_kaputt(p):
|
||||
raise OSError("kein /app/media")
|
||||
|
||||
gefunden = rohdaten.suche(
|
||||
JOB, "", listdir=listdir_kaputt, isdir=lambda p: True)
|
||||
assert gefunden == [f"/app/temp/raw/{JOB}"]
|
||||
|
||||
|
||||
# --- Größe ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_groesse_zaehlt_nur_dateien():
|
||||
dateien = {
|
||||
f"/app/media/rippy/{JOB}/title_t00.mkv": 79604951639,
|
||||
f"/app/media/rippy/{JOB}/title_t01.mkv": 1000,
|
||||
}
|
||||
bytes_gesamt, anzahl = rohdaten.groesse(
|
||||
[f"/app/media/rippy/{JOB}"],
|
||||
listdir=lambda p: ["title_t00.mkv", "title_t01.mkv", "unterordner"],
|
||||
isfile=lambda p: p in dateien,
|
||||
getsize=lambda p: dateien[p],
|
||||
)
|
||||
# Die echte Größe des Akira-Rohschnitts, plus eine zweite Datei
|
||||
assert bytes_gesamt == 79604952639
|
||||
assert anzahl == 2
|
||||
assert round(bytes_gesamt / 1024**3, 1) == 74.1
|
||||
|
||||
|
||||
def test_groesse_ohne_pfade_ist_null():
|
||||
assert rohdaten.groesse([], lambda p: [], lambda p: True, lambda p: 1) == (0, 0)
|
||||
assert rohdaten.groesse(None, lambda p: [], lambda p: True, lambda p: 1) == (0, 0)
|
||||
|
||||
|
||||
def test_groesse_ueberspringt_unlesbares():
|
||||
def getsize_kaputt(p):
|
||||
raise OSError("weg")
|
||||
|
||||
bytes_gesamt, anzahl = rohdaten.groesse(
|
||||
["/x"], lambda p: ["a.mkv"], lambda p: True, getsize_kaputt)
|
||||
assert (bytes_gesamt, anzahl) == (0, 0)
|
||||
|
||||
|
||||
# --- Die harte Zeitgrenze: ein haengender Mount darf nichts toeten -----------
|
||||
#
|
||||
# Am 26.07.2026 hing os.path.isdir an der CIFS-Freigabe im
|
||||
# KERNEL (Prozess-Zustand D). asyncio.to_thread kam nie zurueck, die
|
||||
# Hintergrund-Schleife erreichte ihr sleep nie und war dauerhaft tot.
|
||||
|
||||
|
||||
class _Lauf:
|
||||
"""Merkt sich das Kommando und liefert einen gesetzten Rueckgabewert."""
|
||||
|
||||
def __init__(self, rc=0, wirf=None):
|
||||
self.rc, self.wirf, self.cmd = rc, wirf, None
|
||||
|
||||
def __call__(self, cmd, **kwargs):
|
||||
self.cmd = cmd
|
||||
if self.wirf:
|
||||
raise self.wirf
|
||||
|
||||
class E:
|
||||
returncode = self.rc
|
||||
return E()
|
||||
|
||||
|
||||
def test_verzeichnis_da_nutzt_timeout_und_ls():
|
||||
"""Ein Kind-PROZESS laesst sich abbrechen, ein im Kernel haengender Thread
|
||||
nicht. Deshalb genau dieses Kommando - wie mounts.ist_erreichbar."""
|
||||
lauf = _Lauf(rc=0)
|
||||
assert rohdaten.verzeichnis_da("/app/media/rippy/x", lauf) is True
|
||||
assert lauf.cmd[0] == "timeout"
|
||||
assert lauf.cmd[1] == str(rohdaten.PRUEF_TIMEOUT_SEKUNDEN)
|
||||
assert lauf.cmd[2:] == ["ls", "-d", "/app/media/rippy/x"]
|
||||
|
||||
|
||||
def test_verzeichnis_da_nicht_vorhanden():
|
||||
assert rohdaten.verzeichnis_da("/gibt/es/nicht", _Lauf(rc=2)) is False
|
||||
|
||||
|
||||
def test_verzeichnis_in_der_zeitgrenze_gilt_als_nicht_da():
|
||||
"""`timeout` beendet ls mit 124. Ein Ort, den man nicht in Sekunden ansehen
|
||||
kann, ist fuer einen Rip ohnehin unbrauchbar."""
|
||||
assert rohdaten.verzeichnis_da("/app/media/totes-nas/x", _Lauf(rc=124)) is False
|
||||
|
||||
|
||||
def test_verzeichnis_da_ueberlebt_kaputte_umgebung():
|
||||
import subprocess as sp
|
||||
|
||||
assert rohdaten.verzeichnis_da("/x", _Lauf(wirf=OSError("kein timeout"))) is False
|
||||
assert rohdaten.verzeichnis_da(
|
||||
"/x", _Lauf(wirf=sp.TimeoutExpired("ls", 6))) is False
|
||||
assert rohdaten.verzeichnis_da("", _Lauf(rc=0)) is False
|
||||
|
||||
|
||||
def test_suche_mit_der_zeitgrenze_findet_den_echten_fall():
|
||||
"""Zusammenspiel: os.listdir fuer /app/media (lokal, sicher),
|
||||
verzeichnis_da fuer die Kandidaten (koennen im Netz liegen)."""
|
||||
def laufen(cmd, **kwargs):
|
||||
pfad = cmd[-1]
|
||||
|
||||
class E:
|
||||
returncode = 0 if pfad == f"/app/media/rippy/{JOB}" else 1
|
||||
return E()
|
||||
|
||||
gefunden = rohdaten.suche(
|
||||
JOB, "",
|
||||
listdir=lambda p: ["bluray", "movies", "rippy"],
|
||||
isdir=lambda p: rohdaten.verzeichnis_da(p, laufen),
|
||||
)
|
||||
assert gefunden == [f"/app/media/rippy/{JOB}"]
|
||||
|
||||
|
||||
# --- Drei Antworten: "konnte nicht nachsehen" ist nicht "ist weg" ------------
|
||||
|
||||
|
||||
def test_pruefen_unterscheidet_drei_faelle():
|
||||
assert rohdaten.pruefen("/x", _Lauf(rc=0)) == "da"
|
||||
assert rohdaten.pruefen("/x", _Lauf(rc=2)) == "weg"
|
||||
# 124 = `timeout` hat das Kind abgeschossen (coreutils) -> nicht angesehen
|
||||
assert rohdaten.pruefen("/x", _Lauf(rc=124)) == "unklar"
|
||||
assert rohdaten.pruefen("/x", _Lauf(wirf=OSError("kein timeout"))) == "unklar"
|
||||
assert rohdaten.pruefen("", _Lauf(rc=0)) == "weg"
|
||||
|
||||
|
||||
def test_verzeichnis_da_bleibt_streng():
|
||||
"""Wer Ja/Nein braucht, bekommt im Zweifel Nein."""
|
||||
assert rohdaten.verzeichnis_da("/x", _Lauf(rc=0)) is True
|
||||
assert rohdaten.verzeichnis_da("/x", _Lauf(rc=124)) is False
|
||||
|
||||
|
||||
def test_suche_mit_status_meldet_ungepruefte_orte():
|
||||
"""Der echte Fall: Die Freigabe antwortet nicht, der lokale Ort ist leer.
|
||||
Ein leeres Ergebnis darf dann NICHT als "nichts da" gelten."""
|
||||
def pruefe(pfad):
|
||||
return "unklar" if pfad.startswith("/app/media/rippy/") else "weg"
|
||||
|
||||
e = rohdaten.suche_mit_status(
|
||||
JOB, "", listdir=lambda p: ["rippy"], pruefer=pruefe)
|
||||
assert e == {"pfade": [], "unklar": True}
|
||||
|
||||
|
||||
def test_suche_mit_status_ohne_zweifel():
|
||||
def pruefe(pfad):
|
||||
return "da" if pfad == f"/app/media/rippy/{JOB}" else "weg"
|
||||
|
||||
e = rohdaten.suche_mit_status(
|
||||
JOB, "", listdir=lambda p: ["movies", "rippy"], pruefer=pruefe)
|
||||
assert e == {"pfade": [f"/app/media/rippy/{JOB}"], "unklar": False}
|
||||
|
||||
|
||||
def test_suche_mit_status_findet_trotz_unklarem_anderen_ort():
|
||||
"""Ein Treffer bleibt ein Treffer, auch wenn ein anderer Ort schweigt."""
|
||||
def pruefe(pfad):
|
||||
if pfad == f"/app/media/rippy/{JOB}":
|
||||
return "da"
|
||||
return "unklar" if "totes-nas" in pfad else "weg"
|
||||
|
||||
e = rohdaten.suche_mit_status(
|
||||
JOB, "", listdir=lambda p: ["rippy", "totes-nas"], pruefer=pruefe)
|
||||
assert e["pfade"] == [f"/app/media/rippy/{JOB}"]
|
||||
assert e["unklar"] is True
|
||||
@@ -14,6 +14,15 @@ server {
|
||||
proxy_pass http://api:8000/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
# ⚠️ OHNE DIESE ZEILEN TEILEN SICH ALLE CLIENTS EIN RATE-LIMIT
|
||||
#
|
||||
# Die API begrenzt Anfragen pro Client-IP (ratelimit.py). Ohne
|
||||
# weitergegebene Adresse sah sie als Absender immer DIESEN Container —
|
||||
# Browser, zweiter Tab und der Windows-Tray landeten also in einem
|
||||
# gemeinsamen Eimer. Gemessen am 26.07.2026: 812 von 876 Anfragen kamen
|
||||
# scheinbar von 172.19.0.6, und das UI bekam laufend HTTP 429.
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# SSE (/api/stream/jobs) braucht ungepufferte, lange Verbindungen:
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
|
||||
@@ -91,6 +91,10 @@ export default function DeviceDiscovery() {
|
||||
...(optionen?.titles && optionen.titles.length ? { titles: optionen.titles } : {}),
|
||||
...(optionen?.transcodeNode ? { transcode_node: optionen.transcodeNode } : {}),
|
||||
...(optionen?.workDir ? { work_dir: optionen.workDir } : {}),
|
||||
// Sprachauswahl dieses Rips (leer = alles behalten)
|
||||
...(optionen?.audioSprachen?.length ? { audio_sprachen: optionen.audioSprachen } : {}),
|
||||
...(optionen?.untertitelSprachen?.length
|
||||
? { untertitel_sprachen: optionen.untertitelSprachen } : {}),
|
||||
})
|
||||
setActionFeedback(`✓ Job angelegt (${response.data.id.slice(0, 8)}…) — Fortschritt im Dashboard`)
|
||||
toast('success', 'Rip gestartet — Fortschritt unten bei „Neueste Jobs"')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Disc, Cpu, Globe, Tv, CheckCircle, ArrowRight, HardDrive, AlertTriangle } from 'lucide-react'
|
||||
import { Disc, Cpu, Globe, Tv, CheckCircle, ArrowRight, HardDrive, AlertTriangle, KeyRound } from 'lucide-react'
|
||||
import { api } from '../lib/api'
|
||||
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
|
||||
import { PRESET_KEINE, schwacheEncoderCpu } from '../lib/encoder'
|
||||
@@ -69,6 +69,9 @@ function presetsFuer(uebersicht: PresetUebersicht | null) {
|
||||
export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
||||
const [tmdbKey, setTmdbKey] = useState('')
|
||||
const [omdbKey, setOmdbKey] = useState('')
|
||||
// MakeMKV-Beta-Key: Lizenz fuer die SOFTWARE (Blu-ray), nicht der
|
||||
// Disc-Schluessel einer einzelnen 4K-Disc.
|
||||
const [makemkvKey, setMakemkvKey] = useState('')
|
||||
const [mediaServer, setMediaServer] = useState('jellyfin')
|
||||
const [transcodeEnabled, setTranscodeEnabled] = useState(true)
|
||||
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||||
@@ -119,6 +122,7 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
||||
...bestehende,
|
||||
...(tmdbKey.trim() ? { tmdbApiKey: tmdbKey.trim() } : {}),
|
||||
...(omdbKey.trim() ? { omdbApiKey: omdbKey.trim() } : {}),
|
||||
...(makemkvKey.trim() ? { makemkvAppKey: makemkvKey.trim() } : {}),
|
||||
mediaServer,
|
||||
transcodeEnabled,
|
||||
...presets,
|
||||
@@ -243,6 +247,41 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- MakeMKV-Beta-Key --------------------------------------- */}
|
||||
{/*
|
||||
Neu 26.07.2026, bei der Weitergabe-Prüfung aufgefallen: Der Key war
|
||||
in der README, in der .env und in den Einstellungen zu finden — nur
|
||||
NICHT hier. Ein Fremder installiert also, legt eine Blu-ray ein und
|
||||
bekommt irgendwann einen Fehlschlag, ohne dass ihn beim Einrichten
|
||||
jemand darauf hingewiesen hätte. Das ist die wahrscheinlichste
|
||||
Stolperstelle einer frischen Installation.
|
||||
*/}
|
||||
<section>
|
||||
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
|
||||
<KeyRound size={18} className="text-amber-500" /> MakeMKV-Beta-Key
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
label="Beta-Key (für Blu-ray; DVDs gehen ohne)"
|
||||
value={makemkvKey}
|
||||
onChange={e => setMakemkvKey(e.target.value)}
|
||||
placeholder="T-… — kostenlos im MakeMKV-Forum, Thread t=1053"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
MakeMKV ist für <strong>DVDs dauerhaft kostenlos</strong>. Für
|
||||
<strong> Blu-ray und 4K</strong> braucht es den Beta-Key; ohne ihn läuft
|
||||
eine 30-Tage-Probezeit, danach bricht jeder Blu-ray-Rip ab. Der Key ist
|
||||
kostenlos, wechselt aber etwa monatlich — nachpflegen unter
|
||||
Einstellungen → System, das wirkt sofort ab dem nächsten Rip, ohne
|
||||
Neubau.
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Nicht verwechseln mit dem <strong>Disc-Schlüssel</strong> einer einzelnen
|
||||
4K-Disc — das ist eine andere Sache und steht unter Einstellungen → System.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- Media-Server ------------------------------------------- */}
|
||||
<section>
|
||||
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { AlertTriangle, HelpCircle, RotateCcw, Disc } from 'lucide-react'
|
||||
import { Modal } from './ui/Modal'
|
||||
import { Button } from './ui/Button'
|
||||
|
||||
/*
|
||||
* Was wird bei „Neu" eigentlich wiederholt?
|
||||
*
|
||||
* Commander-Frage 26.07.2026: „Hier gibt es den Button ‚neu' aber WAS wird dann
|
||||
* gemacht? Komprimierung? Neu Gerippt? Das muss ja je nach fehlgeschlagenem Job
|
||||
* eine Option anbieten." Bis dahin hieß der Knopf nur „Neu" und rief immer die
|
||||
* Kompression — auch bei einem Job, dessen RIP abgebrochen war. Aus 5,1 GB
|
||||
* Bruchstück (von rund 40 GB) wäre brav ein Film geworden, der bei 12 % endet.
|
||||
*
|
||||
* Dieser Dialog nennt beim Namen, was passiert, und in welchen Punkten sich die
|
||||
* beiden Wege unterscheiden: Braucht es die Disc? Wie lange dauert es? Was
|
||||
* passiert mit dem, was schon auf der Platte liegt?
|
||||
*
|
||||
* Die Phase kommt aus api/phasen.py. Kennt Rippy sie nicht (Jobs von vor der
|
||||
* Einführung der Marke), wird nicht geraten — dann stehen beide Wege da, und
|
||||
* die Größe der Rohdaten ist die Entscheidungshilfe: Wer ungefähr eine
|
||||
* Disc-Größe daliegen sieht, hatte einen fertigen Rip.
|
||||
*/
|
||||
|
||||
interface RetryDialogProps {
|
||||
offen: boolean
|
||||
titel: string
|
||||
plan: 'transcode' | 'rip' | 'unklar'
|
||||
rohdaten: { gb: number, dateien: number, pfade: string[] } | null
|
||||
onClose: () => void
|
||||
onStart: (art: 'transcode' | 'rip') => void
|
||||
}
|
||||
|
||||
function Rohdaten({ rohdaten }: { rohdaten: RetryDialogProps['rohdaten'] }) {
|
||||
if (!rohdaten) return null
|
||||
if (!rohdaten.gb) {
|
||||
return (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Auf der Platte liegt zu diesem Job nichts (mehr) — neu rippen ist damit
|
||||
der einzige Weg.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Auf der Platte liegen <strong>{rohdaten.gb} GB</strong> Rohdaten
|
||||
{rohdaten.dateien > 0 ? ` in ${rohdaten.dateien} Datei${rohdaten.dateien === 1 ? '' : 'en'}` : ''}.
|
||||
{' '}Eine Blu-ray bringt roh etwa 25–45 GB mit, eine 4K-UHD 50–100 GB, eine
|
||||
DVD 4–8 GB. Passt die Zahl dazu, war der Rip fertig.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RetryDialog({
|
||||
offen, titel, plan, rohdaten, onClose, onStart,
|
||||
}: RetryDialogProps) {
|
||||
const name = titel || 'Dieser Job'
|
||||
|
||||
return (
|
||||
<Modal isOpen={offen} onClose={onClose} maxWidth="lg">
|
||||
{plan === 'transcode' && (
|
||||
<>
|
||||
<div className="flex items-start gap-4">
|
||||
<RotateCcw className="text-indigo-500 w-8 h-8 flex-shrink-0" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<h2 className="text-lg font-bold text-slate-900 dark:text-slate-100">
|
||||
„{name}" neu komprimieren?
|
||||
</h2>
|
||||
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
|
||||
Der Rip war fertig — abgebrochen ist erst die Kompression. Rippy
|
||||
nimmt die vorhandene Roh-Datei und komprimiert sie noch einmal.
|
||||
<strong> Die Disc wird nicht gebraucht</strong>, das Laufwerk
|
||||
bleibt frei, und die Stunde fürs Rippen fällt nicht erneut an.
|
||||
</p>
|
||||
<Rohdaten rohdaten={rohdaten} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex flex-wrap justify-end gap-3 border-t border-slate-200 dark:border-slate-800 pt-4">
|
||||
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
|
||||
<Button variant="ghost" onClick={() => onStart('rip')}>
|
||||
<Disc size={14} /> Doch von der Disc neu rippen
|
||||
</Button>
|
||||
<Button variant="primary" onClick={() => onStart('transcode')}>
|
||||
<RotateCcw size={14} /> Neu komprimieren
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{plan === 'rip' && (
|
||||
<>
|
||||
<div className="flex items-start gap-4">
|
||||
<AlertTriangle className="text-amber-500 w-8 h-8 flex-shrink-0" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<h2 className="text-lg font-bold text-slate-900 dark:text-slate-100">
|
||||
„{name}" neu rippen?
|
||||
</h2>
|
||||
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
|
||||
Hier ist der <strong>Rip selbst</strong> abgebrochen. Was davon
|
||||
auf der Platte liegt, ist ein Bruchstück — komprimieren würde
|
||||
daraus einen Film machen, der mitten drin aufhört. Rippy liest
|
||||
die Disc deshalb von vorn.
|
||||
</p>
|
||||
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
|
||||
<strong>Die Disc muss dafür im Laufwerk liegen.</strong> Nach dem
|
||||
Fehlschlag wurde sie möglicherweise ausgeworfen. Titel, Ablage,
|
||||
Sprachwahl und Encoder-Worker werden vom alten Job übernommen.
|
||||
</p>
|
||||
<Rohdaten rohdaten={rohdaten} />
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Der alte Eintrag bleibt zum Nachlesen stehen; das Bruchstück
|
||||
lässt sich über den Papierkorb daran mitlöschen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-3 border-t border-slate-200 dark:border-slate-800 pt-4">
|
||||
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
|
||||
<Button variant="amber" onClick={() => onStart('rip')}>
|
||||
<Disc size={14} /> Neu rippen
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{plan === 'unklar' && (
|
||||
<>
|
||||
<div className="flex items-start gap-4">
|
||||
<HelpCircle className="text-indigo-500 w-8 h-8 flex-shrink-0" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<h2 className="text-lg font-bold text-slate-900 dark:text-slate-100">
|
||||
„{name}": was soll wiederholt werden?
|
||||
</h2>
|
||||
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
|
||||
Bei diesem Job kann Rippy nicht mehr feststellen, ob der Rip
|
||||
fertig war oder mitten drin abbrach — er ist älter als der
|
||||
Vermerk dafür. Deshalb wird hier nicht geraten:
|
||||
</p>
|
||||
<ul className="text-sm leading-relaxed text-slate-600 dark:text-slate-300 space-y-1 list-disc pl-5">
|
||||
<li>
|
||||
<strong>Neu komprimieren</strong> — schnell, braucht die Disc
|
||||
nicht. Richtig, wenn der Rip durchlief.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Neu rippen</strong> — dauert wieder eine Weile und
|
||||
braucht die Disc im Laufwerk. Immer richtig, nur teurer.
|
||||
</li>
|
||||
</ul>
|
||||
<Rohdaten rohdaten={rohdaten} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex flex-wrap justify-end gap-3 border-t border-slate-200 dark:border-slate-800 pt-4">
|
||||
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
|
||||
{!!rohdaten?.gb && (
|
||||
<Button variant="primary" onClick={() => onStart('transcode')}>
|
||||
<RotateCcw size={14} /> Neu komprimieren
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="amber" onClick={() => onStart('rip')}>
|
||||
<Disc size={14} /> Neu rippen
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { api } from '../lib/api'
|
||||
import { Modal } from './ui/Modal'
|
||||
import { Button } from './ui/Button'
|
||||
import { Input, Select } from './ui/Input'
|
||||
import { automatikWarnung, externWarnung } from '../lib/encoder'
|
||||
import { automatikWarnung, externWarnung, freigabenAusMapping, sprachName } from '../lib/encoder'
|
||||
|
||||
interface TargetConfig {
|
||||
id: string
|
||||
@@ -21,6 +21,17 @@ export interface RipOptionen {
|
||||
titles?: number[]
|
||||
transcodeNode?: string // gewählter Encoder-Worker (Celery-Node) oder leer = auto
|
||||
workDir?: string // Arbeitsverzeichnis für die Rohdaten; leer = Einstellung
|
||||
// Sprachauswahl (ISO-639-2). Leer = alles behalten. Wirkt bei der
|
||||
// KOMPRESSION — der Rip bleibt vollständig und verlustfrei.
|
||||
audioSprachen?: string[]
|
||||
untertitelSprachen?: string[]
|
||||
}
|
||||
|
||||
// Eine Sprache, die die Disc anbietet (aus dem Titel-Scan, MakeMKV SINFO).
|
||||
interface DiscSprache {
|
||||
lang: string // ISO-639-2, z. B. "deu"
|
||||
sprache: string // Klartext, z. B. "German"
|
||||
spuren: number
|
||||
}
|
||||
|
||||
// Ein Ablageziel aus GET /storage-targets.
|
||||
@@ -85,6 +96,25 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set())
|
||||
const [workers, setWorkers] = useState<WorkerWahl[]>([])
|
||||
const [encoderNode, setEncoderNode] = useState('') // '' = automatisch
|
||||
/*
|
||||
* Sprachen der Disc (Commander-Anforderung 26.07.2026: „Die Disc hat Material
|
||||
* in X Sprachen und X Untertiteln — Rippy muss VOR dem Rip fragen: Was genau
|
||||
* willst du haben?").
|
||||
*
|
||||
* Die Auskunft kommt aus demselben Titel-Scan, der schon für die
|
||||
* Titel-Auswahl läuft — MakeMKV liefert sie in derselben Ausgabe mit, sie
|
||||
* wurde bisher nur weggeworfen.
|
||||
*
|
||||
* Leere Auswahl heißt bewusst „alles behalten": Wer nichts anklickt, bekommt
|
||||
* das Verhalten von vorher, und niemand verliert versehentlich seine Tonspur.
|
||||
*/
|
||||
const [discSprachen, setDiscSprachen] = useState<{ audio: DiscSprache[], untertitel: DiscSprache[] } | null>(null)
|
||||
const [audioWahl, setAudioWahl] = useState<Set<string>>(new Set())
|
||||
const [untertitelWahl, setUntertitelWahl] = useState<Set<string>>(new Set())
|
||||
// Wunschsprachen aus den Einstellungen — sie werden beim Scan vorausgewählt,
|
||||
// soweit die Disc sie überhaupt hat.
|
||||
const [standardAudio, setStandardAudio] = useState<string[]>([])
|
||||
const [standardUntertitel, setStandardUntertitel] = useState<string[]>([])
|
||||
|
||||
const scanStarten = async () => {
|
||||
if (!deviceId) return
|
||||
@@ -107,6 +137,16 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
const tracks: TitelInfo[] = r.data.tracks || []
|
||||
setTitelListe(tracks)
|
||||
setGewaehlt(new Set(tracks.filter(t => t.dauer_s >= 300).map(t => t.nr)))
|
||||
const spr = r.data.sprachen || null
|
||||
setDiscSprachen(spr)
|
||||
// Wunschsprachen vorauswählen — aber nur, was die Disc wirklich hat.
|
||||
// Sonst stünde da eine Auswahl, die nichts bewirkt.
|
||||
if (spr) {
|
||||
const vorhanden = (liste: DiscSprache[], wunsch: string[]) =>
|
||||
new Set(liste.filter(s => wunsch.includes(s.lang)).map(s => s.lang))
|
||||
setAudioWahl(vorhanden(spr.audio || [], standardAudio))
|
||||
setUntertitelWahl(vorhanden(spr.untertitel || [], standardUntertitel))
|
||||
}
|
||||
setScanStatus('done')
|
||||
} else if (r.data.status === 'error') {
|
||||
setScanFehler(r.data.error || 'Scan fehlgeschlagen')
|
||||
@@ -155,6 +195,9 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
setEncoderNode('')
|
||||
setArbeitsDir('')
|
||||
setBrowserOffen(false)
|
||||
setDiscSprachen(null)
|
||||
setAudioWahl(new Set())
|
||||
setUntertitelWahl(new Set())
|
||||
api.get('/storage-targets')
|
||||
.then(r => setArbeitsZiele(Array.isArray(r.data) ? r.data : []))
|
||||
.catch(() => setArbeitsZiele([]))
|
||||
@@ -166,6 +209,10 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
const s = r.data || {}
|
||||
setNurHauptfilm(!!s.mainFeatureOnly)
|
||||
setStandardArbeitsDir((s.workDir || '').trim())
|
||||
const codes = (wert: any) => String(wert || '')
|
||||
.split(',').map((t: string) => t.trim().toLowerCase()).filter(Boolean)
|
||||
setStandardAudio(codes(s.audioSprachen))
|
||||
setStandardUntertitel(codes(s.untertitelSprachen))
|
||||
const basis = s.outputDir || '/app/media'
|
||||
setTargets([
|
||||
{ id: '1', name: 'Filme', path: `${basis}/${s.movieDir || 'movies'}`, type: 'movies', isActive: true },
|
||||
@@ -197,6 +244,20 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
// 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)
|
||||
/*
|
||||
* Ein-Klick-Abhilfe: dasselbe Ziel, aber auf der Freigabe, die der gewählte
|
||||
* Worker erreicht. Aus /app/media/movies wird /app/media/<freigabe>/movies.
|
||||
*
|
||||
* Nur wenn es überhaupt eine Freigabe gibt und das Ziel noch nicht darauf
|
||||
* liegt — sonst stünde ein Knopf da, der nichts tut.
|
||||
*/
|
||||
const zielAufFreigabe = (() => {
|
||||
const freigabe = freigabenAusMapping(gewaehlterWorker?.info?.pfad_map)[0]
|
||||
if (!freigabe || !zielPfad) return ''
|
||||
const unterordner = zielPfad.split('/').filter(Boolean).pop() || ''
|
||||
const neu = `/app/media/${freigabe}/${unterordner}`
|
||||
return neu === zielPfad ? '' : neu
|
||||
})()
|
||||
const pfadWarnung = selectedType === 'music'
|
||||
? null
|
||||
: encoderNode
|
||||
@@ -216,6 +277,8 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
}
|
||||
if (encoderNode) optionen.transcodeNode = encoderNode
|
||||
if (arbeitsDir) optionen.workDir = arbeitsDir
|
||||
if (audioWahl.size > 0) optionen.audioSprachen = [...audioWahl]
|
||||
if (untertitelWahl.size > 0) optionen.untertitelSprachen = [...untertitelWahl]
|
||||
const target = targets.find(t => t.type === selectedType)
|
||||
if (customPath) {
|
||||
onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true }, optionen)
|
||||
@@ -362,6 +425,21 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
<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>
|
||||
{/* Ein Knopf statt einer Wegbeschreibung: Er legt das Ziel NUR FÜR
|
||||
DIESEN RIP auf die Freigabe, die der Worker erreicht — ohne die
|
||||
globale Ablage anzufassen. „Geh in die Einstellungen" mitten im
|
||||
Dialog ist lästig, und wer den Rip jetzt starten will, will
|
||||
jetzt eine Lösung. */}
|
||||
{zielAufFreigabe && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() => { setCustomPath(zielAufFreigabe); setBrowserOffen(false) }}
|
||||
>
|
||||
Ziel für diesen Rip auf die Freigabe legen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -430,6 +508,80 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sprachen der Disc — steht direkt unter der Titel-Auswahl, weil beides
|
||||
aus DEMSELBEN Scan kommt. Vorher warf Rippy diese Auskunft weg. */}
|
||||
{scanStatus === 'done' && discSprachen
|
||||
&& (discSprachen.audio.length > 0 || discSprachen.untertitel.length > 0) && (
|
||||
<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="px-4 py-2.5 border-b border-slate-200 dark:border-slate-800">
|
||||
<span className="text-sm font-medium text-slate-900 dark:text-slate-200">
|
||||
Sprachen auf dieser Disc
|
||||
</span>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{discSprachen.audio.length} Tonsprache{discSprachen.audio.length === 1 ? '' : 'n'}
|
||||
{discSprachen.untertitel.length > 0
|
||||
&& `, ${discSprachen.untertitel.length} Untertitelsprache${discSprachen.untertitel.length === 1 ? '' : 'n'}`}
|
||||
{' — '}nichts angeklickt heißt <strong>alles behalten</strong>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-3">
|
||||
{(['audio', 'untertitel'] as const).map(art => {
|
||||
const liste = discSprachen[art]
|
||||
if (liste.length === 0) return null
|
||||
const wahl = art === 'audio' ? audioWahl : untertitelWahl
|
||||
const setzen = art === 'audio' ? setAudioWahl : setUntertitelWahl
|
||||
return (
|
||||
<div key={art}>
|
||||
<p className="text-xs font-medium mb-1.5 text-slate-700 dark:text-slate-300">
|
||||
{art === 'audio' ? 'Tonspuren' : 'Untertitel'}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{liste.map(s => {
|
||||
const an = wahl.has(s.lang)
|
||||
return (
|
||||
<button
|
||||
key={s.lang}
|
||||
onClick={() => {
|
||||
const neu = new Set(wahl)
|
||||
if (an) neu.delete(s.lang); else neu.add(s.lang)
|
||||
setzen(neu)
|
||||
}}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
||||
an
|
||||
? 'bg-amber-500 border-amber-500 text-slate-950 font-bold'
|
||||
: 'bg-slate-100 dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300 hover:border-slate-300 dark:hover:border-slate-600'
|
||||
}`}
|
||||
>
|
||||
{sprachName(s.lang, s.sprache)}
|
||||
<span className="opacity-70"> ({s.lang})</span>
|
||||
{s.spuren > 1 && <span className="opacity-70"> · {s.spuren}×</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Der wichtigste Satz: WANN die Auswahl greift. Sonst glaubt man,
|
||||
die Disc würde unvollständig gerippt. */}
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{audioWahl.size > 0 || untertitelWahl.size > 0 ? (
|
||||
<>
|
||||
Der Rip selbst bleibt <strong>vollständig und verlustfrei</strong> — die
|
||||
Auswahl wirkt beim Komprimieren. Du kannst also später jederzeit
|
||||
„Neu komprimieren" mit anderen Sprachen wählen, ohne die Disc noch
|
||||
einmal einzulegen.
|
||||
</>
|
||||
) : (
|
||||
<>Ohne Auswahl behält Rippy alle Sprachen — wie bisher.</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ordner-Browser — eingeklappt, siehe Begründung bei browserOffen */}
|
||||
<div>
|
||||
<button
|
||||
|
||||
@@ -21,6 +21,7 @@ interface WorkerInfo {
|
||||
cpu_kerne?: string
|
||||
cpu_simd?: string
|
||||
handbrake_encoder?: string
|
||||
rippy_version?: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +44,7 @@ function relativeZeit(iso?: string): string {
|
||||
|
||||
export default function WorkerVerwaltung() {
|
||||
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||||
const [serverVersion, setServerVersion] = useState<string>('dev')
|
||||
const [kopiert, setKopiert] = useState(false)
|
||||
const [variante, setVariante] = useState<'linux' | 'windows'>('linux')
|
||||
// BEWUSST leer: kein window.location.hostname-Default. Der füllte früher
|
||||
@@ -68,6 +70,7 @@ export default function WorkerVerwaltung() {
|
||||
api.get('/capabilities')
|
||||
.then(r => {
|
||||
setWorkers(r.data.workers || [])
|
||||
if (r.data.server_version) setServerVersion(r.data.server_version)
|
||||
if (manuell) toast('success', 'Worker-Liste aktualisiert')
|
||||
})
|
||||
.catch(() => { if (manuell) toast('error', 'Worker-Liste konnte nicht geladen werden') })
|
||||
@@ -146,6 +149,11 @@ export default function WorkerVerwaltung() {
|
||||
{w.info?.ip ? ` · IP ${w.info.ip}` : ''}
|
||||
{w.info?.hostname && w.info.hostname !== w.name ? ` · ID ${w.info.hostname}` : ''}
|
||||
</p>
|
||||
{w.info?.rippy_version && (
|
||||
<p className={`text-xs ${w.info.rippy_version !== serverVersion ? 'text-rose-500 font-semibold' : 'text-slate-500 dark:text-slate-400'}`}>
|
||||
Version {w.info.rippy_version} {w.info.rippy_version !== serverVersion ? `(Veraltet! Server hat ${serverVersion})` : ''}
|
||||
</p>
|
||||
)}
|
||||
{(w.info?.cpu_kerne || w.info?.cpu_simd) && (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400" title={w.info?.cpu_modell || ''}>
|
||||
{w.info?.cpu_kerne ? `${w.info.cpu_kerne} Kerne` : ''}
|
||||
|
||||
@@ -4,4 +4,9 @@ import axios from 'axios'
|
||||
// den api-Container weiter. Vorher stand in vier Dateien http://localhost:8000
|
||||
// hartkodiert: vom PC aus fragte der Browser damit den PC SELBST nach
|
||||
// Laufwerken und Jobs — deshalb blieb das UI immer leer.
|
||||
export const api = axios.create({ baseURL: '/api' })
|
||||
// Zeitgrenze, damit ein hängender Aufruf nicht ewig offen bleibt und sich die
|
||||
// Abfragen des 4-Sekunden-Takts stapeln (Befund 26.07.2026: axios wartet ohne
|
||||
// `timeout` unbegrenzt). 20 s sind reichlich für jeden Endpunkt — die
|
||||
// langsamsten sind der Update-Check und der Titel-Scan-Start, alles andere
|
||||
// antwortet in Millisekunden.
|
||||
export const api = axios.create({ baseURL: '/api', timeout: 20000 })
|
||||
|
||||
@@ -33,6 +33,46 @@ interface WorkerFuerBewertung {
|
||||
info?: { cpu_simd?: string }
|
||||
}
|
||||
|
||||
// --- Sprachnamen für die Auswahl vor dem Rip ---------------------------------
|
||||
//
|
||||
// MakeMKV liefert die Namen auf ENGLISCH („German", „Japanese") und für Spuren
|
||||
// ohne Sprach-Kennzeichnung „Undetermined". Beides an der Akira-Blu-ray gemessen
|
||||
// (26.07.2026): Ton deu 4×, jpn 2×, und 4× — die „und"-Spuren sind typischerweise
|
||||
// Audiokommentare, die kein Sprachfeld tragen.
|
||||
//
|
||||
// „Undetermined" versteht ein Nicht-Entwickler nicht, und der Commander liest
|
||||
// alles auf Deutsch (AGENTS Grundregel 4). Unbekannte Codes behalten MakeMKVs
|
||||
// Namen — geraten wird nichts.
|
||||
const SPRACHNAMEN: Record<string, string> = {
|
||||
und: 'Ohne Sprachangabe',
|
||||
deu: 'Deutsch', ger: 'Deutsch',
|
||||
eng: 'Englisch',
|
||||
jpn: 'Japanisch',
|
||||
fra: 'Französisch', fre: 'Französisch',
|
||||
spa: 'Spanisch',
|
||||
ita: 'Italienisch',
|
||||
nld: 'Niederländisch', dut: 'Niederländisch',
|
||||
rus: 'Russisch',
|
||||
kor: 'Koreanisch',
|
||||
zho: 'Chinesisch', chi: 'Chinesisch',
|
||||
pol: 'Polnisch',
|
||||
tur: 'Türkisch',
|
||||
ces: 'Tschechisch', cze: 'Tschechisch',
|
||||
por: 'Portugiesisch',
|
||||
swe: 'Schwedisch',
|
||||
dan: 'Dänisch',
|
||||
nor: 'Norwegisch',
|
||||
fin: 'Finnisch',
|
||||
hun: 'Ungarisch',
|
||||
ara: 'Arabisch',
|
||||
mul: 'Mehrsprachig',
|
||||
}
|
||||
|
||||
/** Sprachname auf Deutsch — sonst der Name, den MakeMKV geliefert hat. */
|
||||
export function sprachName(lang: string, makemkvName?: string): string {
|
||||
return SPRACHNAMEN[(lang || '').toLowerCase()] || makemkvName || lang || '?'
|
||||
}
|
||||
|
||||
// --- Erreicht ein EXTERNER Worker die Pfade? ---------------------------------
|
||||
//
|
||||
// Befund 26.07.2026, live am Job 95afdc89: Das Routing an den Windows-PC des
|
||||
@@ -60,6 +100,18 @@ export function pfadAbgedeckt(pfad: string, mapping: string): boolean {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Namen der Speicherziele, die dieser Worker über sein Mapping erreicht.
|
||||
* Aus „/app/media/rippy=\\NAS\rippy" wird ["rippy"].
|
||||
*/
|
||||
export function freigabenAusMapping(mapping?: string): string[] {
|
||||
return (mapping || '').split(';')
|
||||
.map(p => p.split('=', 1)[0])
|
||||
.filter(Boolean)
|
||||
.map(p => p.replace(/^\/app\/media\//, ''))
|
||||
.filter(n => n && !n.includes('/'))
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
@@ -88,14 +140,41 @@ export function externWarnung(
|
||||
+ '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).'
|
||||
const zielFehlt = !pfadAbgedeckt(zielPfad, mapping)
|
||||
const arbeitFehlt = !pfadAbgedeckt(arbeitsPfad, mapping)
|
||||
if (!zielFehlt && !arbeitFehlt) return null
|
||||
|
||||
/*
|
||||
* Die Meldung muss sagen, WAS ZU TUN IST — nicht nur, was klemmt.
|
||||
*
|
||||
* Commander-Rückmeldung 26.07.2026: „Das ist ja quatsch. Mein PC hat das Ziel
|
||||
* als Worker direkt auf dem PC eingebunden." Er hatte die Freigabe wirklich
|
||||
* als Netzlaufwerk gemountet — nur war das ARBEITSVERZEICHNIS darauf gelegt
|
||||
* und die ABLAGE nicht. Das Ziel lag weiter auf der VM-Platte
|
||||
* (/app/media/movies), und dorthin kommt sein PC nicht.
|
||||
*
|
||||
* Die alte Meldung war sachlich richtig und trotzdem unbrauchbar: Sie nannte
|
||||
* den Container-Pfad und die Mapping-Zeichenkette, also genau die zwei Dinge,
|
||||
* die ein Nicht-Entwickler nicht deuten kann. Jetzt steht der Handgriff drin,
|
||||
* inklusive des Namens der Freigabe, die dieser Worker wirklich erreicht.
|
||||
*/
|
||||
const freigabe = freigabenAusMapping(mapping)[0] || ''
|
||||
|
||||
const teile: string[] = []
|
||||
if (zielFehlt) {
|
||||
teile.push(`Die Ablage (${zielPfad}) liegt nicht auf der Freigabe.`)
|
||||
if (freigabe) {
|
||||
teile.push(`Abhilfe: Einstellungen → Ripping → „Ablage" auf `
|
||||
+ `„${freigabe}" stellen — das ist die Freigabe, die „${worker.name}" `
|
||||
+ 'wirklich erreicht.')
|
||||
}
|
||||
}
|
||||
if (arbeitFehlt) {
|
||||
teile.push(`Das Arbeitsverzeichnis (${arbeitsPfad || 'Container-Platte'}) `
|
||||
+ 'liegt nicht auf der Freigabe — oben umstellen.')
|
||||
}
|
||||
teile.push('Ohne das scheitert die Kompression (die Rohdaten bleiben erhalten).')
|
||||
return teile.join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ import ConfirmDialog from '../components/ConfirmDialog'
|
||||
import DeviceDiscovery from '../components/DeviceDiscovery'
|
||||
import JobDetailModal from '../components/JobDetailModal'
|
||||
import LiveLogSection from '../components/LiveLogSection'
|
||||
import RetryDialog from '../components/RetryDialog'
|
||||
|
||||
interface JobMeta {
|
||||
year?: number
|
||||
@@ -30,6 +31,8 @@ interface Job {
|
||||
progress: number
|
||||
title?: string
|
||||
can_retry?: boolean
|
||||
// "transcode" | "rip" | "unklar" | "" — welche Wiederholung passt (phasen.py)
|
||||
retry_art?: string
|
||||
meta?: JobMeta | null
|
||||
// Restzeit von der API (eta.py). '' = noch keine Aussage möglich — dann wird
|
||||
// bewusst nichts angezeigt statt einer erfundenen Zahl.
|
||||
@@ -53,18 +56,56 @@ interface WorkerLive {
|
||||
info?: { cpu_modell?: string, cpu_kerne?: string, cpu_simd?: string, extern?: string }
|
||||
}
|
||||
|
||||
// Ein Laufwerk aus GET /devices. Gebraucht, weil die Server-Status-Karte sonst
|
||||
// „keine Disc in Arbeit" behauptet, während oben auf DEMSELBEN Bildschirm
|
||||
// „Akira im Laufwerk erkannt" steht (Commander-Befund 26.07.2026).
|
||||
interface LaufwerkLive {
|
||||
id: string
|
||||
status: string
|
||||
disc?: { title?: string, year?: number | null, disc_type?: string }
|
||||
}
|
||||
|
||||
// Ein Ablageziel aus GET /storage-targets — Verzeichnisse unter /app/media
|
||||
// inklusive der eingehängten Netzwerk-Freigaben, mit freiem Platz.
|
||||
//
|
||||
// Commander-Befund 26.07.2026: Der Server-Status zeigte nur „Media
|
||||
// (/app/media)" — also die Container-Platte. Die eingehängte NAS-Freigabe mit
|
||||
// ihren 2,1 TB fehlte, obwohl genau dort die Rohdaten und (bei externem
|
||||
// Encoden) auch die Ablage liegen müssen. Wer wissen will, ob noch Platz für
|
||||
// eine Disc ist, sah die falsche Zahl.
|
||||
interface AblageZiel {
|
||||
name: string
|
||||
path: string
|
||||
is_mount: boolean
|
||||
free_gb: number | null
|
||||
}
|
||||
|
||||
function posterUrl(meta?: JobMeta | null): string | null {
|
||||
const p = meta?.poster_path
|
||||
if (!p) return null
|
||||
return p.startsWith('http') ? p : `https://image.tmdb.org/t/p/w342${p}`
|
||||
}
|
||||
|
||||
async function fetchJobs(): Promise<Job[]> {
|
||||
/*
|
||||
* Ein fehlgeschlagener Abruf gibt `null` — NICHT eine leere Liste.
|
||||
*
|
||||
* Commander-Befund 26.07.2026: „Wenn der Worker installiert ist, wird dieser
|
||||
* Bereich oft neu geladen." Gemessen war es kein Neuladen: Die Antworten von
|
||||
* /jobs und /capabilities waren über zwanzig Sekunden byteweise IDENTISCH, alle
|
||||
* Endpunkte antworteten in unter 30 ms. Der Grund lag im Fehlerzweig — hier
|
||||
* stand `return []`, und jeder einzelne verpasste Abruf setzte damit die
|
||||
* Job-Liste auf LEER. Für einen Takt zeigte die Tabelle „Keine Jobs", die Zähler
|
||||
* sprangen auf (0), vier Sekunden später war alles wieder da.
|
||||
*
|
||||
* Ein verpasster Abruf ist keine Nachricht über die Welt. Wer nichts Neues
|
||||
* weiß, behält, was er wusste.
|
||||
*/
|
||||
async function fetchJobs(): Promise<Job[] | null> {
|
||||
try {
|
||||
const response = await api.get('/jobs')
|
||||
return response.data
|
||||
return Array.isArray(response.data) ? response.data : null
|
||||
} catch {
|
||||
return []
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,8 +119,13 @@ export default function Dashboard() {
|
||||
const [loeschKandidat, setLoeschKandidat] = useState<Job | null>(null)
|
||||
const [rohdaten, setRohdaten] = useState<{ gb: number, dateien: number, pfade: string[] } | null>(null)
|
||||
const [rohdatenMitloeschen, setRohdatenMitloeschen] = useState(false)
|
||||
// Job, für den ein neuer Versuch bestätigt werden soll (siehe wiederholen()).
|
||||
const [neuKandidat, setNeuKandidat] = useState<Job | null>(null)
|
||||
const [neuRohdaten, setNeuRohdaten] = useState<{ gb: number, dateien: number, pfade: string[] } | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<'all' | 'active' | 'queue' | 'completed' | 'failed'>('all')
|
||||
const [workersLive, setWorkersLive] = useState<WorkerLive[]>([])
|
||||
const [laufwerke, setLaufwerke] = useState<LaufwerkLive[]>([])
|
||||
const [ablagen, setAblagen] = useState<AblageZiel[]>([])
|
||||
const { toast } = useToast()
|
||||
|
||||
/*
|
||||
@@ -104,6 +150,57 @@ export default function Dashboard() {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Der „Neu"-Knopf sagt jetzt, WAS er tut.
|
||||
*
|
||||
* Commander-Frage 26.07.2026: „Hier gibt es den Button ‚neu' aber WAS wird
|
||||
* dann gemacht? Komprimierung? Neu Gerippt?" — Antwort damals: immer
|
||||
* Komprimierung, egal wie der Job gestorben war. Für einen abgebrochenen Rip
|
||||
* war das falsch (5,1 GB Bruchstück von rund 40 GB).
|
||||
*
|
||||
* `job.retry_art` kommt aus api/phasen.py und kennt drei Fälle:
|
||||
* 'transcode' → der Rip war fertig → „Neu komprimieren"
|
||||
* 'rip' → der Rip starb → „Neu rippen" (Disc muss drin sein)
|
||||
* 'unklar' → Bestandsjob → fragen, mit der Rohdaten-Größe als
|
||||
* Entscheidungshilfe
|
||||
*
|
||||
* Sonderfall: Phase 'transcode', aber die Rohdaten sind fort (`can_retry`
|
||||
* false) — dann hilft auch hier nur ein neuer Rip.
|
||||
*/
|
||||
const retryPlan = (job: Job): 'transcode' | 'rip' | 'unklar' => {
|
||||
if (job.retry_art === 'transcode') return job.can_retry ? 'transcode' : 'rip'
|
||||
if (job.retry_art === 'rip') return 'rip'
|
||||
return 'unklar'
|
||||
}
|
||||
|
||||
const wiederholen = async (job: Job) => {
|
||||
setNeuKandidat(job)
|
||||
setNeuRohdaten(null)
|
||||
try {
|
||||
const r = await api.get(`/jobs/${job.id}/rohdaten`)
|
||||
setNeuRohdaten(r.data)
|
||||
} catch {
|
||||
setNeuRohdaten(null) // dann eben ohne Zahl fragen
|
||||
}
|
||||
}
|
||||
|
||||
const neuStarten = async (art: 'transcode' | 'rip') => {
|
||||
const job = neuKandidat
|
||||
if (!job) return
|
||||
setNeuKandidat(null)
|
||||
const weg = art === 'rip' ? 'retry-rip' : 'retry-transcode'
|
||||
try {
|
||||
await api.post(`/jobs/${job.id}/${weg}`)
|
||||
toast('success', art === 'rip'
|
||||
? 'Neuer Rip gestartet — der alte Eintrag bleibt zum Nachlesen stehen'
|
||||
: 'Kompression neu eingereiht — die Disc wird nicht gebraucht')
|
||||
const frisch = await fetchJobs()
|
||||
if (frisch) setJobs(frisch)
|
||||
} catch (e: any) {
|
||||
toast('error', e?.response?.data?.detail || 'Fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
const jobLoeschen = async () => {
|
||||
const job = loeschKandidat
|
||||
if (!job) return
|
||||
@@ -115,7 +212,8 @@ export default function Dashboard() {
|
||||
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())
|
||||
const frisch = await fetchJobs()
|
||||
if (frisch) setJobs(frisch)
|
||||
} catch (e: any) {
|
||||
toast('error', e?.response?.data?.detail || 'Entfernen fehlgeschlagen')
|
||||
}
|
||||
@@ -126,35 +224,71 @@ export default function Dashboard() {
|
||||
try {
|
||||
const r = await api.delete('/jobs')
|
||||
toast('success', `${r.data.deleted} erledigte Jobs entfernt — Dateien bleiben liegen`)
|
||||
setJobs(await fetchJobs())
|
||||
const frisch = await fetchJobs()
|
||||
if (frisch) setJobs(frisch)
|
||||
} catch {
|
||||
toast('error', 'Aufräumen fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* ZWEI TAKTE statt einem.
|
||||
*
|
||||
* ⚠️ JEDER Abruf hier gibt bei Fehlschlag `null` — und `null` bedeutet „nichts
|
||||
* Neues erfahren", nicht „es gibt nichts". Vor dem 26.07.2026 stand überall
|
||||
* `catch(() => [])`: ein einziger verpasster Abruf leerte Job-Liste,
|
||||
* Laufwerke und Ablagen, vier Sekunden später war alles wieder da. Genau das
|
||||
* hat der Commander als „wird oft neu geladen" gemeldet. Siehe `fetchJobs`.
|
||||
*
|
||||
* Und verpasst wurde reichlich: Fünf Endpunkte alle vier Sekunden sind 75
|
||||
* Anfragen pro Minute, dazu Log-Kasten und Laufwerks-Suche — die API wies bei
|
||||
* ihrer damaligen Grenze von 100/min laufend mit HTTP 429 ab (gemessen: 97
|
||||
* Abweisungen im Log). Die Grenze ist jetzt der echten Last angemessen
|
||||
* (ratelimit.py), aber das rechtfertigt keine Verschwendung: Jobs und
|
||||
* Laufwerke ändern sich sekündlich, die drei anderen praktisch nie.
|
||||
* 75/min sind damit 15 + 15/min geworden.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
const nichts = () => null
|
||||
|
||||
// Schnell (4 s): was sich während eines Rips wirklich bewegt.
|
||||
const schnellLaden = async () => {
|
||||
try {
|
||||
const [jobsData, sysData, capsData] = await Promise.all([
|
||||
const [jobsData, devData] = await Promise.all([
|
||||
fetchJobs(),
|
||||
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(() => []),
|
||||
// Die Laufwerke: Ohne sie behauptete die Server-Status-Karte
|
||||
// „keine Disc in Arbeit", während oben die erkannte Disc stand.
|
||||
api.get('/devices').then(r => Array.isArray(r.data) ? r.data : null).catch(nichts),
|
||||
])
|
||||
setJobs(jobsData)
|
||||
if (sysData) setSystemInfo(sysData)
|
||||
setWorkersLive(capsData)
|
||||
if (jobsData) setJobs(jobsData)
|
||||
if (devData) setLaufwerke(devData)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadData()
|
||||
const interval = setInterval(loadData, 4000)
|
||||
return () => clearInterval(interval)
|
||||
// Langsam (12 s): Hardware, Worker-Liste, Ablagen. Ein Worker, der online
|
||||
// geht, oder eine Freigabe, die wegbricht, darf drei Takte später auffallen.
|
||||
const langsamLaden = async () => {
|
||||
const [sysData, capsData, ablData] = await Promise.all([
|
||||
api.get('/system/info').then(r => r.data).catch(nichts),
|
||||
// Für die ECHTE Online-Zahl: /capabilities kennt den Celery-Ping,
|
||||
// /system/info nur die Registrierung.
|
||||
api.get('/capabilities').then(r => r.data?.workers ?? null).catch(nichts),
|
||||
// Die Ablageziele inkl. eingehängter Freigaben — ohne sie zeigte der
|
||||
// Server-Status nur die Container-Platte (Commander-Befund).
|
||||
api.get('/storage-targets').then(r => Array.isArray(r.data) ? r.data : null).catch(nichts),
|
||||
])
|
||||
if (sysData) setSystemInfo(sysData)
|
||||
if (capsData) setWorkersLive(capsData)
|
||||
if (ablData) setAblagen(ablData)
|
||||
}
|
||||
|
||||
schnellLaden()
|
||||
langsamLaden()
|
||||
const schnell = setInterval(schnellLaden, 4000)
|
||||
const langsam = setInterval(langsamLaden, 12000)
|
||||
return () => { clearInterval(schnell); clearInterval(langsam) }
|
||||
}, [])
|
||||
|
||||
const aktiverJob = jobs.find(j => j.status === 'processing' || j.status === 'transcoding')
|
||||
@@ -189,10 +323,27 @@ export default function Dashboard() {
|
||||
const workerOnline = workersLive.filter(w => w.online)
|
||||
const encoderWorker = workerOnline.filter(w => (w.encoders || []).length > 0)
|
||||
const hardwareWorker = workerOnline.filter(w => (w.encoders || []).some(e => !e.startsWith('cpu')))
|
||||
// Phase im Klartext — „processing" heißt rippen, „transcoding" komprimieren.
|
||||
const phaseText = !aktiverJob ? 'Bereit — keine Disc in Arbeit'
|
||||
: aktiverJob.status === 'transcoding' ? 'Kompression läuft (HandBrake)'
|
||||
: 'Rip läuft (MakeMKV, verlustfrei)'
|
||||
/*
|
||||
* Phase im Klartext — „processing" heißt rippen, „transcoding" komprimieren.
|
||||
*
|
||||
* Die eingelegte Disc gehört mit hinein (Commander-Befund 26.07.2026): Vorher
|
||||
* stand hier stur „Bereit — keine Disc in Arbeit / Disc einlegen, Rippy erkennt
|
||||
* sie selbst", während oben auf DEMSELBEN Bildschirm „Akira im Laufwerk
|
||||
* erkannt" prangte. Zwei Aussagen, ein Blick, Widerspruch — und der Nutzer
|
||||
* weiß nicht, welcher er glauben soll.
|
||||
*/
|
||||
const discImLaufwerk = laufwerke.find(l => l.disc?.title)
|
||||
// Nur die echten Netzwerk-Freigaben, nicht die normalen Unterordner von
|
||||
// /app/media (movies/series/music liegen auf der Container-Platte und sind
|
||||
// dort schon mitgezählt).
|
||||
const freigaben = ablagen.filter(a => a.is_mount)
|
||||
const phaseText = aktiverJob
|
||||
? (aktiverJob.status === 'transcoding'
|
||||
? 'Kompression läuft (HandBrake)'
|
||||
: 'Rip läuft (MakeMKV, verlustfrei)')
|
||||
: discImLaufwerk
|
||||
? 'Disc erkannt — wartet auf „Rippen starten"'
|
||||
: 'Bereit — kein Datenträger im Laufwerk'
|
||||
|
||||
const filteredJobs = jobs.filter(j => {
|
||||
if (activeTab === 'active') return j.status === 'processing' || j.status === 'transcoding'
|
||||
@@ -454,17 +605,17 @@ export default function Dashboard() {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{job.status === 'failed' && job.can_retry && (
|
||||
{job.status === 'failed' && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
api.post(`/jobs/${job.id}/retry-transcode`)
|
||||
.then(() => toast('success', 'Kompression neu eingereiht'))
|
||||
.catch((e: any) => toast('error', e?.response?.data?.detail || 'Fehlgeschlagen'))
|
||||
}}
|
||||
onClick={() => wiederholen(job)}
|
||||
title="Fragt vorher, was genau wiederholt wird"
|
||||
>
|
||||
<RotateCcw size={13} /> Neu
|
||||
<RotateCcw size={13} />
|
||||
{retryPlan(job) === 'transcode' ? 'Neu komprimieren'
|
||||
: retryPlan(job) === 'rip' ? 'Neu rippen'
|
||||
: 'Neu …'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -537,7 +688,11 @@ export default function Dashboard() {
|
||||
<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'}
|
||||
: discImLaufwerk
|
||||
? `${discImLaufwerk.disc!.title}`
|
||||
+ (discImLaufwerk.disc!.year ? ` (${discImLaufwerk.disc!.year})` : '')
|
||||
+ ' liegt bereit — oben auf „Rippen starten"'
|
||||
: 'Disc einlegen — Rippy erkennt sie selbst'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -578,11 +733,14 @@ export default function Dashboard() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 3. Platz — war schon immer echt */}
|
||||
{/* 3. Platz — Container-Platte UND die eingehängten Freigaben.
|
||||
Vorher stand hier nur die Container-Platte; wer wissen
|
||||
wollte, ob die NAS noch Platz für eine Disc hat, sah die
|
||||
falsche Zahl (Commander-Befund 26.07.2026). */}
|
||||
<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'}:
|
||||
<HardDrive size={14} className="text-amber-400" /> Container-Platte:
|
||||
</span>
|
||||
<span className={freiGb > 0 && freiGb < 60 ? 'text-amber-400 font-bold' : 'text-slate-200'}>
|
||||
{freiGb > 0 ? `${freiGb} von ${gesamtGb} GB frei` : 'unbekannt'}
|
||||
@@ -596,13 +754,49 @@ export default function Dashboard() {
|
||||
style={{ width: `${Math.max(2, belegtPercent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
{freiGb > 0 && freiGb < 60 && (
|
||||
{freiGb > 0 && freiGb < 60 && freigaben.length === 0 && (
|
||||
<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>
|
||||
|
||||
{/* Die eingehängten Freigaben. Sie sind der Ort, an dem die
|
||||
Rohdaten liegen und — bei externem Encoden — liegen MÜSSEN. */}
|
||||
<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">
|
||||
<Server size={14} className="text-amber-400" /> Freigaben:
|
||||
</span>
|
||||
<span className={freigaben.length > 0 ? 'text-slate-200' : 'text-slate-400'}>
|
||||
{freigaben.length > 0
|
||||
? `${freigaben.length} eingehängt`
|
||||
: 'keine eingehängt'}
|
||||
</span>
|
||||
</div>
|
||||
{freigaben.length === 0 ? (
|
||||
<p className="text-[11px] font-mono text-slate-500">
|
||||
Ohne Freigabe kann ein externer Encoder nichts tun — er sieht die
|
||||
Container-Platte nicht. Einhängen: Einstellungen → Speicherziele.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{freigaben.map(f => (
|
||||
<p key={f.path} className="text-[11px] font-mono text-slate-400 flex items-center justify-between gap-2">
|
||||
<span className="truncate">
|
||||
<span className="text-emerald-400">●</span> {f.name}
|
||||
</span>
|
||||
<span className={f.free_gb != null && f.free_gb < 60
|
||||
? 'text-amber-400 flex-shrink-0'
|
||||
: 'text-slate-300 flex-shrink-0'}>
|
||||
{f.free_gb != null ? `${f.free_gb} GB frei` : 'Platz unbekannt'}
|
||||
</span>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -679,6 +873,16 @@ export default function Dashboard() {
|
||||
type="warning"
|
||||
/>
|
||||
|
||||
{/* „Neu" nennt jetzt beim Namen, was es tut (siehe wiederholen()). */}
|
||||
<RetryDialog
|
||||
offen={neuKandidat !== null}
|
||||
titel={neuKandidat?.title || neuKandidat?.id.slice(0, 8) || ''}
|
||||
plan={neuKandidat ? retryPlan(neuKandidat) : 'unklar'}
|
||||
rohdaten={neuRohdaten}
|
||||
onClose={() => setNeuKandidat(null)}
|
||||
onStart={neuStarten}
|
||||
/>
|
||||
|
||||
{/* Entfernen eines EINZELNEN Jobs — mit der Zahl, die vorher fehlte. */}
|
||||
<ConfirmDialog
|
||||
isOpen={loeschKandidat !== null}
|
||||
|
||||
@@ -74,6 +74,24 @@ export default function LogsPage() {
|
||||
? logs
|
||||
: logs.filter(log => log.level === filter || log.source === filter)
|
||||
|
||||
/*
|
||||
* Quellen zum Anklicken (Commander-Wunsch 26.07.2026: „bessere Log-Ansichten
|
||||
* — kein txt file, direkt von Rippy Logs").
|
||||
*
|
||||
* Der Filter konnte Quellen schon immer, es gab nur keinen Knopf dafür — man
|
||||
* musste die Zeilen mit dem Auge durchsuchen. Seit dieser Runde meldet auch
|
||||
* der externe Windows-Worker hierher (worker/logbruecke.py, Quelle „w:<name>"),
|
||||
* und damit wird die Auswahl erst richtig nützlich: „was macht mein PC?" ist
|
||||
* ein Klick.
|
||||
*
|
||||
* Die Liste wird aus den WIRKLICH vorhandenen Zeilen gebaut, nicht aus einer
|
||||
* festen Aufzählung — sonst fehlt jede neue Maschine.
|
||||
*/
|
||||
const quellen = [...new Set(logs.map(l => l.source).filter(Boolean))].sort()
|
||||
|
||||
// „w:tobisnicerpc" ist als Knopfbeschriftung unschön.
|
||||
const quelleLabel = (q: string) => q.startsWith('w:') ? `Worker ${q.slice(2)}` : q
|
||||
|
||||
const stats = {
|
||||
info: logs.filter(l => l.level === 'info').length,
|
||||
warning: logs.filter(l => l.level === 'warning').length,
|
||||
@@ -162,6 +180,25 @@ export default function LogsPage() {
|
||||
{f === 'all' ? 'Alle' : getLevelLabel(f)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{quellen.length > 1 && (
|
||||
<>
|
||||
<span className="text-xs font-medium text-slate-500 dark:text-slate-400 mx-2">Quelle:</span>
|
||||
{quellen.map(q => (
|
||||
<button
|
||||
key={q}
|
||||
onClick={() => setFilter(q)}
|
||||
className={`px-3 py-1 rounded-full text-xs font-medium transition-all ${
|
||||
filter === q
|
||||
? 'bg-indigo-500 text-white font-bold shadow-md shadow-indigo-500/20'
|
||||
: 'bg-slate-200/70 dark:bg-slate-800 text-slate-700 dark:text-slate-300 hover:bg-slate-300 dark:hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
{quelleLabel(q)}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="max-h-[600px] overflow-y-auto">
|
||||
|
||||
@@ -38,6 +38,10 @@ interface SettingsState {
|
||||
jellyfinApiKey: string
|
||||
workDir: string
|
||||
makemkvAppKey: string
|
||||
// Wunschsprachen (ISO-639-2, Komma-Liste). Greifen bei der Kompression und
|
||||
// sind im Rip-Dialog vorausgewählt. Leer = alle Sprachen behalten.
|
||||
audioSprachen: string
|
||||
untertitelSprachen: string
|
||||
}
|
||||
|
||||
const defaultSettings: SettingsState = {
|
||||
@@ -63,6 +67,11 @@ const defaultSettings: SettingsState = {
|
||||
jellyfinApiKey: '',
|
||||
workDir: '',
|
||||
makemkvAppKey: '',
|
||||
// Leer = alles behalten. Bewusst kein Vorgabewert: Eine stille Vorauswahl
|
||||
// („deu") würde bei einem japanischen Original die Originaltonspur wegwerfen,
|
||||
// ohne dass jemand danach gefragt hat.
|
||||
audioSprachen: '',
|
||||
untertitelSprachen: '',
|
||||
}
|
||||
|
||||
type SettingsTab = 'ripping' | 'verarbeitung' | 'worker' | 'speicherziele' | 'apis' | 'benachrichtigungen' | 'system'
|
||||
@@ -826,6 +835,54 @@ export default function SettingsPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Wunschsprachen (Commander-Anforderung 26.07.2026: die Auswahl
|
||||
muss „in der Automatik ebenfalls einstellbar" sein).
|
||||
|
||||
Sie greifen bei der KOMPRESSION, nicht beim Rippen — der Rip
|
||||
bleibt vollständig und verlustfrei (Muss-Feature). Damit lässt
|
||||
sich später mit anderer Sprachwahl neu komprimieren, ohne die
|
||||
Disc wieder einzulegen.
|
||||
*/}
|
||||
<div className="space-y-3 p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">Wunschsprachen</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Gilt für Vollautomatik-Rips und ist im Rip-Dialog vorausgewählt.
|
||||
<strong className="text-slate-700 dark:text-slate-300"> Leer = alle Sprachen behalten.</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Tonspuren (ISO-639-2, mit Komma getrennt)"
|
||||
value={settings.audioSprachen}
|
||||
onChange={(e) => handleChange('audioSprachen', e.target.value)}
|
||||
placeholder="z. B. deu,eng,jpn"
|
||||
/>
|
||||
<Input
|
||||
label="Untertitel (ISO-639-2, mit Komma getrennt)"
|
||||
value={settings.untertitelSprachen}
|
||||
onChange={(e) => handleChange('untertitelSprachen', e.target.value)}
|
||||
placeholder="z. B. deu"
|
||||
/>
|
||||
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Codes sind dreibuchstabig: <span className="font-mono">deu</span> Deutsch,
|
||||
{' '}<span className="font-mono">eng</span> Englisch,
|
||||
{' '}<span className="font-mono">jpn</span> Japanisch,
|
||||
{' '}<span className="font-mono">fra</span> Französisch,
|
||||
{' '}<span className="font-mono">spa</span> Spanisch,
|
||||
{' '}<span className="font-mono">ita</span> Italienisch.
|
||||
Welche eine Disc wirklich hat, zeigt Rippy beim „Disc scannen" im
|
||||
Rip-Dialog — dort ist die Auswahl auch je Disc änderbar.
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Wichtig: Diese Wahl wirkt beim <strong>Komprimieren</strong>. Der Rip
|
||||
selbst sichert immer alles — sonst wäre eine spätere Änderung nur mit
|
||||
neuem Rip möglich.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||||
<Toggle
|
||||
label="Original behalten"
|
||||
|
||||
+18
-1
@@ -11,6 +11,8 @@ import re
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from winlauf import OHNE_FENSTER
|
||||
|
||||
|
||||
HB_ENCODER_KOPF = re.compile(r"^-e,\s*--encoder\b")
|
||||
|
||||
@@ -152,6 +154,7 @@ def hole_handbrake_presets() -> list:
|
||||
aus = subprocess.run(
|
||||
["HandBrakeCLI", "--preset-list"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
creationflags=OHNE_FENSTER,
|
||||
)
|
||||
return parse_preset_liste((aus.stdout or "") + (aus.stderr or ""))
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
@@ -263,7 +266,8 @@ def hole_handbrake_hilfe() -> str:
|
||||
return ""
|
||||
try:
|
||||
aus = subprocess.run(
|
||||
["HandBrakeCLI", "--help"], capture_output=True, text=True, timeout=30
|
||||
["HandBrakeCLI", "--help"], capture_output=True, text=True, timeout=30,
|
||||
creationflags=OHNE_FENSTER
|
||||
)
|
||||
return (aus.stdout or "") + (aus.stderr or "")
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
@@ -352,6 +356,7 @@ def werkzeug_versionen() -> dict:
|
||||
aus = subprocess.run(
|
||||
["HandBrakeCLI", "--version"],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
creationflags=OHNE_FENSTER,
|
||||
)
|
||||
treffer = re.search(r"HandBrake\s+([\w.]+)", (aus.stdout or "") + (aus.stderr or ""))
|
||||
info["handbrake"] = treffer.group(1) if treffer else "installiert"
|
||||
@@ -396,4 +401,16 @@ def werkzeug_versionen() -> dict:
|
||||
info["schluessel"] = str(makemkv_daten.schluesselspeicher_status().get("schluessel", 0))
|
||||
except Exception:
|
||||
info["schluessel"] = "unbekannt"
|
||||
|
||||
# Version für den Windows-Worker (aus version.txt) oder Linux-Worker (aus Env)
|
||||
try:
|
||||
ver_pfad = os.path.join(os.path.dirname(__file__), "version.txt")
|
||||
if os.path.isfile(ver_pfad):
|
||||
with open(ver_pfad, "r", encoding="utf-8") as f:
|
||||
info["rippy_version"] = f.read().strip()
|
||||
else:
|
||||
info["rippy_version"] = os.getenv("RIPPY_VERSION", "dev")
|
||||
except Exception:
|
||||
info["rippy_version"] = "unbekannt"
|
||||
|
||||
return info
|
||||
|
||||
@@ -217,6 +217,38 @@ def zaehle_online_worker(sekunden: int = 120) -> int:
|
||||
return int(anzahl or 0)
|
||||
|
||||
|
||||
def meta_merken(job_id: str, **felder) -> None:
|
||||
"""Ergänzt EINZELNE Schlüssel in den Job-Metadaten. Wirft nie.
|
||||
|
||||
`update_job(meta=...)` würde die Spalte ersetzen — Poster, Jahr, Titel-Wahl
|
||||
und Sprachwunsch dieses Rips wären damit fort. Also lesen, mischen,
|
||||
schreiben.
|
||||
|
||||
Fehler werden geschluckt: Diese Funktion vermerkt nur, in welcher Phase ein
|
||||
Job steht (api/phasen.py). Ein Rip darf daran nicht scheitern — im
|
||||
schlimmsten Fall fehlt die Marke und der „Neu"-Knopf fragt nach.
|
||||
"""
|
||||
import json
|
||||
|
||||
try:
|
||||
zeile = get_job(job_id)
|
||||
if not zeile:
|
||||
return
|
||||
try:
|
||||
vorher = json.loads(zeile.get("meta") or "{}")
|
||||
except (ValueError, TypeError):
|
||||
vorher = {}
|
||||
if not isinstance(vorher, dict):
|
||||
vorher = {}
|
||||
vorher.update(felder)
|
||||
update_job(job_id, meta=json.dumps(vorher))
|
||||
except Exception as e:
|
||||
try:
|
||||
add_log("warning", "worker", f"Job {job_id}: Metadaten-Vermerk fehlgeschlagen: {e}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def update_job(job_id: str, **fields) -> None:
|
||||
with engine.begin() as conn:
|
||||
conn.execute(jobs.update().where(jobs.c.id == job_id).values(**fields))
|
||||
|
||||
@@ -0,0 +1,760 @@
|
||||
import verwaltung
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
import json
|
||||
import webbrowser
|
||||
import re
|
||||
import flet as ft
|
||||
|
||||
# Tray-Icon: pystray + PIL fuer das Icon-Bild
|
||||
try:
|
||||
import pystray
|
||||
from PIL import Image as PILImage
|
||||
|
||||
TRAY_VERFUEGBAR = True
|
||||
except ImportError:
|
||||
TRAY_VERFUEGBAR = False
|
||||
|
||||
# Environment configuration
|
||||
BASIS = os.path.dirname(os.path.abspath(__file__))
|
||||
RIPPY_HOST = os.getenv("RIPPY_TRAY_HOST", "") or os.getenv("RIPPY_HOST", "")
|
||||
WORKER_NAME = os.getenv("WORKER_NAME", "windows-worker")
|
||||
SLOTS = max(1, int(os.getenv("RIPPY_SLOTS", "1") or "1"))
|
||||
ABFRAGE_TAKT_SEKUNDEN = 4
|
||||
|
||||
|
||||
# Log file path
|
||||
def _log_pfad() -> str:
|
||||
basis_daten = os.getenv("LOCALAPPDATA") or os.getenv("APPDATA") or ""
|
||||
if basis_daten:
|
||||
ordner = os.path.join(basis_daten, "Rippy Worker")
|
||||
os.makedirs(ordner, exist_ok=True)
|
||||
return os.path.join(ordner, "worker.log")
|
||||
return os.path.join(BASIS, "worker.log")
|
||||
|
||||
|
||||
LOG_PFAD = _log_pfad()
|
||||
|
||||
# State variables
|
||||
prozess = None
|
||||
aktuell = {"job": None, "online": False}
|
||||
|
||||
# Windows Standby blocker
|
||||
_wach = {"an": False}
|
||||
|
||||
|
||||
def wach_halten(an: bool) -> None:
|
||||
if _wach["an"] == an:
|
||||
return
|
||||
try:
|
||||
import ctypes
|
||||
|
||||
ES_CONTINUOUS = 0x80000000
|
||||
ES_SYSTEM_REQUIRED = 0x00000001
|
||||
flags = (ES_CONTINUOUS | ES_SYSTEM_REQUIRED) if an else ES_CONTINUOUS
|
||||
ctypes.windll.kernel32.SetThreadExecutionState(flags)
|
||||
_wach["an"] = an
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Celery Subprocess Management
|
||||
def _leser(prozess_ref, datei, log_callback) -> None:
|
||||
try:
|
||||
for zeile in prozess_ref.stdout:
|
||||
try:
|
||||
datei.write(zeile)
|
||||
datei.flush()
|
||||
except OSError:
|
||||
pass
|
||||
if log_callback:
|
||||
log_callback(zeile)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
datei.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def worker_laeuft() -> bool:
|
||||
return prozess is not None and prozess.poll() is None
|
||||
|
||||
|
||||
def worker_starten(log_callback=None):
|
||||
global prozess
|
||||
if worker_laeuft():
|
||||
return
|
||||
celery = os.path.join(BASIS, "venv", "Scripts", "celery.exe")
|
||||
if not os.path.exists(celery):
|
||||
celery = "celery" # fallback to global if testing outside venv
|
||||
|
||||
befehl = [
|
||||
celery,
|
||||
"-A",
|
||||
"celery_app",
|
||||
"worker",
|
||||
"--loglevel=info",
|
||||
"-Q",
|
||||
"transcode",
|
||||
"-n",
|
||||
f"{WORKER_NAME}@%h",
|
||||
]
|
||||
befehl += (
|
||||
["--pool=solo"] if SLOTS == 1 else ["--pool=threads", f"--concurrency={SLOTS}"]
|
||||
)
|
||||
|
||||
log = open(LOG_PFAD, "a", encoding="utf-8", errors="replace")
|
||||
prozess = subprocess.Popen(
|
||||
befehl,
|
||||
cwd=BASIS,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
errors="replace",
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
threading.Thread(
|
||||
target=_leser,
|
||||
args=(prozess, log, log_callback),
|
||||
daemon=True,
|
||||
name="log-leser",
|
||||
).start()
|
||||
|
||||
|
||||
def worker_stoppen():
|
||||
global prozess
|
||||
if worker_laeuft():
|
||||
prozess.terminate()
|
||||
try:
|
||||
prozess.wait(15)
|
||||
except subprocess.TimeoutExpired:
|
||||
prozess.kill()
|
||||
prozess = None
|
||||
wach_halten(False)
|
||||
aktuell["job"] = None
|
||||
|
||||
|
||||
# API Polling
|
||||
def old_hole(pfad: str, host: str = None, timeout: int = 5):
|
||||
ziel = host if host is not None else RIPPY_HOST
|
||||
if not ziel:
|
||||
return None
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://{ziel}/api{pfad}", timeout=timeout) as a:
|
||||
return json.load(a)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def hole_job_und_zustand():
|
||||
caps = verwaltung.hole("/capabilities")
|
||||
jobs = verwaltung.hole("/jobs") or []
|
||||
|
||||
online = False
|
||||
w_info = {}
|
||||
for w in (caps or {}).get("workers") or []:
|
||||
if w.get("name") == WORKER_NAME:
|
||||
online = bool(w.get("online"))
|
||||
w_info = w
|
||||
break
|
||||
|
||||
laufend = None
|
||||
for job in jobs if isinstance(jobs, list) else []:
|
||||
if job.get("status") in ("transcoding", "processing", "running"):
|
||||
laufend = job
|
||||
break
|
||||
|
||||
aktuell["online"] = online
|
||||
aktuell["job"] = laufend
|
||||
wach_halten(bool(laufend))
|
||||
return online, laufend, caps, jobs, w_info
|
||||
|
||||
|
||||
def deinstallieren() -> None:
|
||||
skript = os.path.join(BASIS, "uninstall.ps1")
|
||||
if not os.path.isfile(skript):
|
||||
return
|
||||
subprocess.Popen(
|
||||
["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", skript],
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
|
||||
|
||||
def _bruecke_bauen():
|
||||
try:
|
||||
import db
|
||||
import logbruecke
|
||||
|
||||
db.init_db()
|
||||
return logbruecke.Bruecke(
|
||||
WORKER_NAME,
|
||||
schreiber=lambda level, quelle, text: db.add_log(level, quelle, text),
|
||||
jetzt=time.monotonic,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _schluessel_wache(bruecke=None, log_callback=None):
|
||||
try:
|
||||
import schluessel
|
||||
|
||||
if not schluessel.makemkvcon_pfad():
|
||||
return
|
||||
|
||||
def melden(level, text):
|
||||
msg = f"[schluessel] {level.upper()} {text}"
|
||||
if bruecke:
|
||||
bruecke.zeile(msg)
|
||||
if log_callback:
|
||||
log_callback(msg + "\n")
|
||||
|
||||
stand = None
|
||||
while True:
|
||||
try:
|
||||
stand, _was = schluessel.runde(RIPPY_HOST, stand, melden=melden)
|
||||
except Exception as e:
|
||||
if log_callback:
|
||||
log_callback(
|
||||
f"[schluessel] ERROR Schlüssel-Automatik fehlgeschlagen: {e}\n"
|
||||
)
|
||||
time.sleep(schluessel.TAKT_SEKUNDEN)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
# GUI Application
|
||||
def main(page: ft.Page):
|
||||
page.title = f"Rippy Worker — {WORKER_NAME}"
|
||||
page.window.width = 900
|
||||
page.window.height = 700
|
||||
page.bgcolor = "#0f172a" # Rippy Slate-900 background
|
||||
page.padding = 30
|
||||
page.theme_mode = ft.ThemeMode.DARK
|
||||
page.fonts = {
|
||||
"Inter": "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
||||
}
|
||||
page.theme = ft.Theme(font_family="Inter")
|
||||
|
||||
# --- Tray-Icon -----------------------------------------------------------
|
||||
tray_icon_ref = {"icon": None}
|
||||
|
||||
def _tray_icon_bild():
|
||||
"""Lade rippy.ico oder erzeuge ein Fallback-Bild."""
|
||||
ico_pfad = os.path.join(BASIS, "rippy.ico")
|
||||
if os.path.isfile(ico_pfad):
|
||||
try:
|
||||
return PILImage.open(ico_pfad)
|
||||
except Exception:
|
||||
pass
|
||||
# Fallback: kleines lila Quadrat
|
||||
img = PILImage.new("RGB", (64, 64), (99, 102, 241))
|
||||
return img
|
||||
|
||||
def tray_zeigen(icon, item):
|
||||
"""Tray-Menue: Fenster wieder anzeigen."""
|
||||
page.window.visible = True
|
||||
page.window.minimized = False
|
||||
page.update()
|
||||
|
||||
def tray_beenden(icon, item):
|
||||
"""Tray-Menue: App komplett beenden."""
|
||||
if tray_icon_ref["icon"]:
|
||||
tray_icon_ref["icon"].stop()
|
||||
worker_stoppen()
|
||||
page.window.destroy()
|
||||
|
||||
def tray_starten():
|
||||
"""Erstellt und startet das System-Tray-Icon."""
|
||||
if not TRAY_VERFUEGBAR:
|
||||
return
|
||||
menu = pystray.Menu(
|
||||
pystray.MenuItem("Rippy Worker anzeigen", tray_zeigen, default=True),
|
||||
pystray.Menu.SEPARATOR,
|
||||
pystray.MenuItem("Beenden", tray_beenden),
|
||||
)
|
||||
icon = pystray.Icon(
|
||||
"rippy_worker", _tray_icon_bild(), f"Rippy Worker — {WORKER_NAME}", menu
|
||||
)
|
||||
tray_icon_ref["icon"] = icon
|
||||
threading.Thread(target=icon.run, daemon=True, name="tray-icon").start()
|
||||
|
||||
def on_window_event(e):
|
||||
"""Fenster-Schliessen minimiert ins Tray statt die App zu beenden."""
|
||||
if e.data == "close":
|
||||
if TRAY_VERFUEGBAR and tray_icon_ref["icon"]:
|
||||
page.window.visible = False
|
||||
page.update()
|
||||
else:
|
||||
# Kein Tray verfuegbar — wirklich beenden
|
||||
worker_stoppen()
|
||||
page.window.destroy()
|
||||
|
||||
page.window.prevent_close = True
|
||||
page.window.on_event = on_window_event
|
||||
tray_starten()
|
||||
|
||||
# Colors
|
||||
|
||||
c_panel = "#1e293b"
|
||||
c_text = "#e2e8f0"
|
||||
c_muted = "#94a3b8"
|
||||
c_accent = "#6366f1" # Indigo
|
||||
c_success = "#10b981" # Emerald
|
||||
c_warning = "#f59e0b" # Amber
|
||||
c_danger = "#f43f5e" # Rose
|
||||
|
||||
# Formatter for job status
|
||||
def old_job_text(job: dict) -> str:
|
||||
titel = (job.get("title") or (job.get("id") or "")[:8]) or "?"
|
||||
status = {
|
||||
"transcoding": "komprimiert",
|
||||
"processing": "rippt",
|
||||
"running": "rippt",
|
||||
"pending": "wartet",
|
||||
"completed": "fertig",
|
||||
"failed": "Fehler",
|
||||
"canceling": "bricht ab",
|
||||
}.get(job.get("status") or "", job.get("status") or "?")
|
||||
return f"{titel} — {status}"
|
||||
|
||||
# UI Components
|
||||
status_text = ft.Text(
|
||||
"Verbinde...", color=c_muted, size=18, weight=ft.FontWeight.BOLD
|
||||
)
|
||||
title_text = ft.Text(
|
||||
f"Rippy Worker: {WORKER_NAME}", size=24, weight=ft.FontWeight.BOLD, color=c_text
|
||||
)
|
||||
machine_info = ft.Text(
|
||||
"System-Infos werden geladen...", color=c_muted, size=13, font_family="Consolas"
|
||||
)
|
||||
|
||||
progress_ring = ft.ProgressRing(
|
||||
width=120, height=120, stroke_width=10, value=0, color=c_accent
|
||||
)
|
||||
progress_percentage = ft.Text(
|
||||
"0%", size=28, weight=ft.FontWeight.BOLD, color=c_text
|
||||
)
|
||||
job_title = ft.Text("Nichts in Arbeit", size=20, color=c_muted)
|
||||
eta_text = ft.Text("Bereit", size=14, color=c_muted)
|
||||
|
||||
progress_stack = ft.Stack(
|
||||
[
|
||||
progress_ring,
|
||||
ft.Container(
|
||||
content=progress_percentage,
|
||||
alignment=ft.alignment.center,
|
||||
width=120,
|
||||
height=120,
|
||||
),
|
||||
],
|
||||
width=120,
|
||||
height=120,
|
||||
)
|
||||
|
||||
# Live Log Regex and Layout
|
||||
# [2026-07-26 20:34:00,093: INFO/MainProcess] message...
|
||||
LOG_REGEX = re.compile(
|
||||
r"^\[.*? (\d{2}:\d{2}:\d{2}),.*?: (INFO|WARNING|ERROR|SUCCESS).*?\] (.*)"
|
||||
)
|
||||
|
||||
log_view = ft.ListView(expand=1, spacing=0, auto_scroll=True)
|
||||
queue_view = ft.ListView(height=120, spacing=4, auto_scroll=False)
|
||||
|
||||
def add_log(zeile):
|
||||
z = zeile.strip()
|
||||
if not z:
|
||||
return
|
||||
|
||||
match = LOG_REGEX.match(z)
|
||||
if match:
|
||||
zeit, level, msg = match.groups()
|
||||
color = c_text
|
||||
if level == "INFO":
|
||||
color = "#38bdf8" # sky-400
|
||||
elif level == "WARNING":
|
||||
color = "#fbbf24" # amber-400
|
||||
elif level == "ERROR":
|
||||
color = "#f87171" # red-400
|
||||
elif level == "SUCCESS":
|
||||
color = "#34d399" # emerald-400
|
||||
|
||||
row = ft.Row(
|
||||
[
|
||||
ft.Text(zeit, color=c_muted, size=12, font_family="Consolas"),
|
||||
ft.Text(
|
||||
f"[{level}]",
|
||||
color=color,
|
||||
size=12,
|
||||
font_family="Consolas",
|
||||
weight=ft.FontWeight.BOLD,
|
||||
),
|
||||
ft.Text(
|
||||
msg, color=c_text, size=12, font_family="Consolas", expand=True, selectable=True
|
||||
),
|
||||
],
|
||||
spacing=10,
|
||||
vertical_alignment=ft.CrossAxisAlignment.START,
|
||||
)
|
||||
log_view.controls.append(row)
|
||||
else:
|
||||
log_view.controls.append(
|
||||
ft.Text(z, color=c_muted, size=12, font_family="Consolas", selectable=True)
|
||||
)
|
||||
|
||||
if len(log_view.controls) > 200:
|
||||
log_view.controls.pop(0)
|
||||
try:
|
||||
page.update()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def toggle_worker(e):
|
||||
if not worker_laeuft():
|
||||
worker_starten(add_log)
|
||||
btn_toggle.visible = False
|
||||
page.update()
|
||||
|
||||
btn_toggle = ft.ElevatedButton(
|
||||
text="Worker läuft" if worker_laeuft() else "Worker Starten",
|
||||
icon=ft.icons.CHECK if worker_laeuft() else ft.icons.PLAY_ARROW,
|
||||
bgcolor="#1e293b" if worker_laeuft() else c_success,
|
||||
color="white",
|
||||
disabled=worker_laeuft(),
|
||||
on_click=toggle_worker,
|
||||
)
|
||||
|
||||
def uninstall_click(e):
|
||||
def close_dlg(e):
|
||||
dlg.open = False
|
||||
page.update()
|
||||
|
||||
def do_uninstall(e):
|
||||
deinstallieren()
|
||||
page.window.close()
|
||||
|
||||
dlg = ft.AlertDialog(
|
||||
title=ft.Text("Deinstallieren?"),
|
||||
content=ft.Text(
|
||||
f"Worker '{WORKER_NAME}' wirklich entfernen? Das beendet den Prozess und entfernt ihn aus dem Autostart."
|
||||
),
|
||||
actions=[
|
||||
ft.TextButton("Abbrechen", on_click=close_dlg),
|
||||
ft.TextButton(
|
||||
"Ja, Deinstallieren",
|
||||
on_click=do_uninstall,
|
||||
style=ft.ButtonStyle(color=c_danger),
|
||||
),
|
||||
],
|
||||
actions_alignment=ft.MainAxisAlignment.END,
|
||||
)
|
||||
page.overlay.append(dlg)
|
||||
dlg.open = True
|
||||
page.update()
|
||||
|
||||
btn_uninstall = ft.TextButton(
|
||||
text="Deinstallieren",
|
||||
icon=ft.icons.DELETE,
|
||||
icon_color=c_danger,
|
||||
on_click=uninstall_click,
|
||||
)
|
||||
|
||||
btn_dashboard = ft.TextButton(
|
||||
text="Rippy Dashboard",
|
||||
icon=ft.icons.OPEN_IN_BROWSER,
|
||||
on_click=lambda _: (
|
||||
webbrowser.open(f"http://{RIPPY_HOST}") if RIPPY_HOST else None
|
||||
),
|
||||
)
|
||||
|
||||
btn_logs = ft.TextButton(
|
||||
text="Log in Rippy",
|
||||
icon=ft.icons.FORMAT_ALIGN_LEFT,
|
||||
on_click=lambda _: (
|
||||
webbrowser.open(f"http://{RIPPY_HOST}/logs") if RIPPY_HOST else None
|
||||
),
|
||||
)
|
||||
|
||||
btn_local_log = ft.TextButton(
|
||||
text="Lokales Log",
|
||||
icon=ft.icons.INSERT_DRIVE_FILE,
|
||||
on_click=lambda _: os.startfile(LOG_PFAD) if os.path.exists(LOG_PFAD) else None,
|
||||
)
|
||||
|
||||
# Layout
|
||||
header = ft.Row(
|
||||
[
|
||||
title_text,
|
||||
ft.Container(expand=True),
|
||||
btn_dashboard,
|
||||
btn_logs,
|
||||
btn_local_log,
|
||||
btn_uninstall,
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||
)
|
||||
|
||||
active_poster = ft.Image(
|
||||
src="",
|
||||
visible=False,
|
||||
width=72,
|
||||
height=108,
|
||||
border_radius=10,
|
||||
fit=ft.ImageFit.COVER,
|
||||
)
|
||||
active_poster_container = ft.Container(
|
||||
content=active_poster,
|
||||
border=ft.border.all(1, c_warning),
|
||||
border_radius=10,
|
||||
shadow=ft.BoxShadow(spread_radius=1, blur_radius=5, color=ft.colors.with_opacity(0.3, ft.colors.BLACK)),
|
||||
visible=False
|
||||
)
|
||||
|
||||
dashboard_card = ft.Container(
|
||||
content=ft.Column(
|
||||
[
|
||||
status_text,
|
||||
machine_info,
|
||||
ft.Container(height=5),
|
||||
ft.Row(
|
||||
[
|
||||
active_poster_container,
|
||||
progress_stack,
|
||||
ft.Column(
|
||||
[job_title, eta_text],
|
||||
alignment=ft.MainAxisAlignment.CENTER,
|
||||
spacing=10,
|
||||
),
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.START,
|
||||
spacing=30,
|
||||
),
|
||||
ft.Container(height=10),
|
||||
btn_toggle,
|
||||
]
|
||||
),
|
||||
bgcolor=c_panel,
|
||||
border_radius=15,
|
||||
padding=30,
|
||||
border=ft.border.all(1, "#334155"),
|
||||
)
|
||||
|
||||
queue_card = ft.Container(
|
||||
content=ft.Column(
|
||||
[
|
||||
ft.Text(
|
||||
"Aufgaben (Warteschlange)",
|
||||
size=16,
|
||||
weight=ft.FontWeight.BOLD,
|
||||
color=c_text,
|
||||
),
|
||||
queue_view,
|
||||
]
|
||||
),
|
||||
bgcolor="#020617",
|
||||
border_radius=10,
|
||||
padding=15,
|
||||
border=ft.border.all(1, "#1e293b"),
|
||||
)
|
||||
|
||||
terminal_card = ft.Container(
|
||||
content=log_view,
|
||||
bgcolor="#020617",
|
||||
border_radius=10,
|
||||
padding=15,
|
||||
expand=True,
|
||||
border=ft.border.all(1, "#1e293b"),
|
||||
)
|
||||
|
||||
page.add(
|
||||
header,
|
||||
ft.Container(height=10),
|
||||
dashboard_card,
|
||||
ft.Container(height=10),
|
||||
queue_card,
|
||||
ft.Container(height=10),
|
||||
ft.Text("Live Logs", size=16, weight=ft.FontWeight.BOLD, color=c_text),
|
||||
terminal_card,
|
||||
)
|
||||
|
||||
# Background polling loop
|
||||
def poll_loop():
|
||||
while True:
|
||||
try:
|
||||
online, job, caps, jobs, w_info = hole_job_und_zustand()
|
||||
|
||||
# Update System Info
|
||||
info = w_info.get("info") or {}
|
||||
teile = [
|
||||
t
|
||||
for t in (
|
||||
info.get("cpu_modell"),
|
||||
f"{info.get('cpu_kerne')} Kerne"
|
||||
if info.get("cpu_kerne")
|
||||
else "",
|
||||
info.get("cpu_simd")
|
||||
if info.get("cpu_simd") not in ("", "unbekannt")
|
||||
else "",
|
||||
", ".join(w_info.get("encoders") or []),
|
||||
)
|
||||
if t
|
||||
]
|
||||
machine_info.value = (
|
||||
" · ".join(teile)
|
||||
if teile
|
||||
else f"{SLOTS} Aufträge gleichzeitig möglich"
|
||||
)
|
||||
|
||||
# Update Queue
|
||||
queue_view.controls.clear()
|
||||
if not jobs:
|
||||
queue_view.controls.append(
|
||||
ft.Text(
|
||||
"Keine Aufgaben in Rippy",
|
||||
color=c_muted,
|
||||
font_family="Consolas",
|
||||
size=13,
|
||||
)
|
||||
)
|
||||
else:
|
||||
for j in jobs[:8]:
|
||||
poster = None
|
||||
if j.get("meta") and j["meta"].get("poster_path"):
|
||||
p = j["meta"]["poster_path"]
|
||||
poster = (
|
||||
p
|
||||
if p.startswith("http")
|
||||
else f"https://image.tmdb.org/t/p/w342{p}"
|
||||
)
|
||||
|
||||
row_items = []
|
||||
if poster:
|
||||
row_items.append(
|
||||
ft.Container(
|
||||
content=ft.Image(
|
||||
src=poster,
|
||||
width=32,
|
||||
height=48,
|
||||
border_radius=6,
|
||||
fit=ft.ImageFit.COVER,
|
||||
),
|
||||
border=ft.border.all(1, "#334155"),
|
||||
border_radius=6,
|
||||
shadow=ft.BoxShadow(spread_radius=1, blur_radius=3, color=ft.colors.with_opacity(0.3, ft.colors.BLACK))
|
||||
)
|
||||
)
|
||||
row_items.append(
|
||||
ft.Text(
|
||||
verwaltung.job_text(j).split(",")[0].split(",")[0],
|
||||
color=c_muted,
|
||||
font_family="Consolas",
|
||||
size=13,
|
||||
expand=True,
|
||||
)
|
||||
)
|
||||
queue_view.controls.append(
|
||||
ft.Row(
|
||||
row_items,
|
||||
alignment=ft.MainAxisAlignment.START,
|
||||
spacing=15,
|
||||
)
|
||||
)
|
||||
|
||||
# Update status
|
||||
if not worker_laeuft():
|
||||
status_text.value = "Worker-Prozess gestoppt"
|
||||
status_text.color = c_danger
|
||||
progress_ring.value = 0
|
||||
progress_percentage.value = "0%"
|
||||
job_title.value = "Nichts in Arbeit"
|
||||
eta_text.value = "Bereit"
|
||||
active_poster_container.visible = False
|
||||
btn_toggle.visible = True
|
||||
btn_toggle.disabled = False
|
||||
btn_toggle.text = "Worker Starten"
|
||||
btn_toggle.icon = ft.icons.PLAY_ARROW
|
||||
btn_toggle.bgcolor = c_success
|
||||
elif not online:
|
||||
status_text.value = "Nicht mit Rippy verbunden (offline)"
|
||||
status_text.color = c_warning
|
||||
progress_ring.value = 0
|
||||
progress_percentage.value = "0%"
|
||||
active_poster_container.visible = False
|
||||
btn_toggle.visible = True
|
||||
btn_toggle.disabled = True
|
||||
btn_toggle.text = "Warte auf Verbindung..."
|
||||
btn_toggle.bgcolor = "#1e293b"
|
||||
elif job:
|
||||
status_text.value = "Aktiv: Komprimiert"
|
||||
status_text.color = c_success
|
||||
prog = float(job.get("progress") or 0)
|
||||
progress_ring.value = prog / 100.0
|
||||
progress_percentage.value = f"{int(prog)}%"
|
||||
titel = job.get("title") or (job.get("id") or "")[:8]
|
||||
job_title.value = titel
|
||||
eta = job.get("eta_text") or "Restzeit wird berechnet..."
|
||||
eta_text.value = f"Verbleibend: {eta}"
|
||||
|
||||
# Update Active Poster
|
||||
poster = None
|
||||
if job.get("meta") and job["meta"].get("poster_path"):
|
||||
p = job["meta"]["poster_path"]
|
||||
poster = (
|
||||
p
|
||||
if p.startswith("http")
|
||||
else f"https://image.tmdb.org/t/p/w342{p}"
|
||||
)
|
||||
|
||||
if poster:
|
||||
active_poster.src = poster
|
||||
active_poster_container.visible = True
|
||||
else:
|
||||
active_poster_container.visible = False
|
||||
|
||||
btn_toggle.visible = False
|
||||
else:
|
||||
status_text.value = "Verbunden & Bereit"
|
||||
status_text.color = c_accent
|
||||
progress_ring.value = 0
|
||||
progress_percentage.value = "0%"
|
||||
job_title.value = "Warte auf Encoder-Aufträge..."
|
||||
eta_text.value = "Nichts in Arbeit"
|
||||
active_poster_container.visible = False
|
||||
btn_toggle.visible = False
|
||||
|
||||
page.update()
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(ABFRAGE_TAKT_SEKUNDEN)
|
||||
|
||||
# Start Celery automatically
|
||||
worker_starten(add_log)
|
||||
|
||||
# Init initial logs
|
||||
if os.path.exists(LOG_PFAD):
|
||||
try:
|
||||
with open(LOG_PFAD, "r", encoding="utf-8", errors="replace") as f:
|
||||
lines = f.readlines()[-50:]
|
||||
for line in lines:
|
||||
add_log(line)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
threading.Thread(target=poll_loop, daemon=True).start()
|
||||
threading.Thread(
|
||||
target=_schluessel_wache,
|
||||
args=(_bruecke_bauen(), add_log),
|
||||
daemon=True,
|
||||
name="schluessel-wache",
|
||||
).start()
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ft.app(target=main)
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Celery-Ausgabe des Workers nach Rippy melden — statt in eine txt-Datei.
|
||||
|
||||
Commander-Wunsch 26.07.2026: *„bessere Log-Ansichten (kein txt file → direkt von
|
||||
Rippy Logs)"*. Der native Windows-Worker schrieb sein Log bisher nach
|
||||
`%LOCALAPPDATA%\\Rippy Worker\\worker.log`, und das Tray öffnete die Datei im
|
||||
Editor. Wer wissen wollte, warum der Worker nichts tut, musste sich also an den
|
||||
PC setzen — während Rippy im Browser schon eine Log-Seite hat.
|
||||
|
||||
## Was hier durchgeht — und warum so wenig
|
||||
|
||||
Die JOB-Meldungen stehen längst in Rippy: `tasks.py` schreibt „Kompression
|
||||
gestartet", Fehlschläge, Abbrüche selbst über `db.add_log`. Was fehlte, ist
|
||||
alles, was DANEBEN passiert und den Worker unbrauchbar macht, ohne dass ein Job
|
||||
existiert:
|
||||
|
||||
- Der Worker ist hochgefahren (oder eben nicht).
|
||||
- Die Verbindung zu Redis/Postgres steht nicht.
|
||||
- Er ist abgestürzt (Traceback).
|
||||
|
||||
Alles andere wird verworfen. Celery ist bei `--loglevel=info` gesprächig, die
|
||||
`logs`-Tabelle hat keine automatische Aufräumung, und ein zugemülltes Log ist
|
||||
genauso unbrauchbar wie keins.
|
||||
|
||||
## Das Format ist gemessen, nicht geraten
|
||||
|
||||
Wörtlich aus dem laufenden Worker-Container (26.07.2026, Celery 5.4.0) —
|
||||
AGENTS Regel D:
|
||||
|
||||
[2026-07-26 12:04:18,060: INFO/MainProcess] Connected to redis://redis:6379/0
|
||||
[2026-07-26 12:04:19,063: INFO/MainProcess] mingle: searching for neighbors
|
||||
[2026-07-26 12:04:19,082: INFO/MainProcess] celery@ced31798928d ready.
|
||||
[2026-07-26 12:06:19,094: WARNING/MainProcess] Zombie-Erkennung: {...}
|
||||
|
||||
Dazu der Startbanner (Zeilen ohne diese Klammer) und Warnungen aus Pythons
|
||||
warnings-Modul.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
# `[<Zeitstempel>: <LEVEL>/<Prozess>] <Text>`
|
||||
CELERY_ZEILE = re.compile(r"^\[[^\]]*?:\s*(\w+)/([^\]]+)\]\s*(.*)$")
|
||||
|
||||
# Celery-Level → Rippy-Level. DEBUG fällt bewusst weg.
|
||||
LEVEL_MAP = {
|
||||
"CRITICAL": "error",
|
||||
"ERROR": "error",
|
||||
"WARNING": "warning",
|
||||
"INFO": "info",
|
||||
}
|
||||
|
||||
# INFO-Zeilen sind nur interessant, wenn sie einen ZUSTAND melden. Diese Stücke
|
||||
# sind alle oben im echten Log nachweisbar.
|
||||
INFO_DURCHLASSEN = ("ready.", "Connected to", "Trying again", "connection")
|
||||
|
||||
# ... und diese sind reines Grundrauschen.
|
||||
INFO_VERWERFEN = ("mingle:", "task events", "searching for neighbors", "all alone")
|
||||
|
||||
# Zeilen OHNE Celery-Klammer (Startbanner, Python-warnings) kommen nur durch,
|
||||
# wenn sie nach einem echten Problem aussehen.
|
||||
ROHE_FEHLER = ("Traceback", "Error:", "error(", "Errno", "refused", "Cannot")
|
||||
|
||||
# Schutz für die logs-Tabelle: mehr als das pro Minute ist kein Log, das ist ein
|
||||
# Wasserfall.
|
||||
MAX_ZEILEN_JE_MINUTE = 30
|
||||
|
||||
|
||||
def einordnen(zeile: str):
|
||||
"""(level, text) für eine Ausgabezeile — oder None, wenn sie nichts wert ist.
|
||||
|
||||
Reine Funktion, damit das Urteil ohne laufenden Worker prüfbar ist.
|
||||
"""
|
||||
text = (zeile or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
treffer = CELERY_ZEILE.match(text)
|
||||
if not treffer:
|
||||
# Startbanner und warnings-Ausgaben: nur echte Probleme.
|
||||
if any(s in text for s in ROHE_FEHLER):
|
||||
return ("error", text[:500])
|
||||
return None
|
||||
celery_level, _prozess, inhalt = treffer.groups()
|
||||
level = LEVEL_MAP.get(celery_level.upper())
|
||||
if not level or not inhalt:
|
||||
return None
|
||||
if level == "info":
|
||||
if any(s in inhalt for s in INFO_VERWERFEN):
|
||||
return None
|
||||
if not any(s in inhalt for s in INFO_DURCHLASSEN):
|
||||
return None
|
||||
# „ready." heißt: der Worker nimmt jetzt Aufträge an. Das ist die
|
||||
# Meldung, auf die man wartet, wenn nichts passiert.
|
||||
if "ready." in inhalt:
|
||||
return ("success", inhalt[:500])
|
||||
return (level, inhalt[:500])
|
||||
|
||||
|
||||
def quelle_fuer(worker_name: str) -> str:
|
||||
"""Log-Quelle für diesen Worker.
|
||||
|
||||
Gekappt auf 32 Zeichen, weil `logs.source` genau so breit ist (db.py). Ohne
|
||||
das Kappen scheitert das Insert still bei einem langen Worker-Namen — und
|
||||
dann fehlt genau das Log, das man sucht.
|
||||
"""
|
||||
name = (worker_name or "worker").strip() or "worker"
|
||||
return f"w:{name}"[:32]
|
||||
|
||||
|
||||
class Bruecke:
|
||||
"""Nimmt Ausgabezeilen und schreibt die wichtigen nach Rippy.
|
||||
|
||||
`schreiber(level, quelle, text)` und `jetzt()` sind einspritzbar — so ist
|
||||
die Drosselung ohne Datenbank und ohne echte Uhr prüfbar.
|
||||
"""
|
||||
|
||||
def __init__(self, worker_name: str, schreiber, jetzt):
|
||||
self.quelle = quelle_fuer(worker_name)
|
||||
self.schreiber = schreiber
|
||||
self.jetzt = jetzt
|
||||
self._fenster_start = None
|
||||
self._im_fenster = 0
|
||||
self._unterdrueckt = 0
|
||||
|
||||
def zeile(self, text: str) -> bool:
|
||||
"""Eine Ausgabezeile verarbeiten. True, wenn sie nach Rippy ging."""
|
||||
eingeordnet = einordnen(text)
|
||||
if not eingeordnet:
|
||||
return False
|
||||
level, inhalt = eingeordnet
|
||||
|
||||
jetzt = self.jetzt()
|
||||
if self._fenster_start is None or jetzt - self._fenster_start >= 60:
|
||||
# Neues Fenster: erst melden, wieviel im alten verschluckt wurde.
|
||||
if self._unterdrueckt:
|
||||
self._melden(
|
||||
"warning",
|
||||
f"{self._unterdrueckt} weitere Log-Zeilen unterdrückt "
|
||||
"(mehr als 30 pro Minute) — Details im Worker-Log auf der "
|
||||
"Maschine selbst.",
|
||||
)
|
||||
self._fenster_start = jetzt
|
||||
self._im_fenster = 0
|
||||
self._unterdrueckt = 0
|
||||
|
||||
if self._im_fenster >= MAX_ZEILEN_JE_MINUTE:
|
||||
self._unterdrueckt += 1
|
||||
return False
|
||||
self._im_fenster += 1
|
||||
return self._melden(level, inhalt)
|
||||
|
||||
def _melden(self, level: str, text: str) -> bool:
|
||||
try:
|
||||
self.schreiber(level, self.quelle, text)
|
||||
return True
|
||||
except Exception:
|
||||
# Rippy nicht erreichbar → die lokale Datei bleibt der Rückfall.
|
||||
# Hier NICHTS werfen: der Worker soll weiterarbeiten.
|
||||
return False
|
||||
+30
-11
@@ -140,25 +140,44 @@ def episoden_umbenennen(ordner: str, serie: str, staffel: int, zuordnung: list)
|
||||
|
||||
|
||||
def bibliothek_refresh(media_server: str, url: str, api_key: str) -> str:
|
||||
"""Stößt den Bibliotheks-Scan des Media-Servers an (Jellyfin/Emby).
|
||||
"""Stößt den Bibliotheks-Scan des Media-Servers an (Jellyfin/Emby/Kodi).
|
||||
|
||||
API laut jellyfin.org/docs (Emby-kompatibel): POST /Library/Refresh,
|
||||
Auth über den Header X-Emby-Token. Rückgabe: Klartext fürs Log.
|
||||
Jellyfin/Emby: POST /Library/Refresh, Auth über X-Emby-Token.
|
||||
Kodi: POST /jsonrpc, VideoLibrary.Scan, Auth über Basic Auth (api_key = user:pass).
|
||||
Wirft nie — der Refresh ist Komfort, nicht Teil des Rips.
|
||||
"""
|
||||
if media_server not in ("jellyfin", "emby") or not url or not api_key:
|
||||
if not url or media_server not in ("jellyfin", "emby", "kodi"):
|
||||
return ""
|
||||
try:
|
||||
antwort = requests.post(
|
||||
url.rstrip("/") + "/Library/Refresh",
|
||||
headers={"X-Emby-Token": api_key}, timeout=15,
|
||||
)
|
||||
if antwort.status_code < 300:
|
||||
return f"{media_server}-Bibliothek aktualisiert ({url})"
|
||||
return f"Bibliotheks-Refresh: HTTP {antwort.status_code} von {url}"
|
||||
if media_server in ("jellyfin", "emby") and api_key:
|
||||
antwort = requests.post(
|
||||
url.rstrip("/") + "/Library/Refresh",
|
||||
headers={"X-Emby-Token": api_key}, timeout=15,
|
||||
)
|
||||
if antwort.status_code < 300:
|
||||
return f"{media_server.title()}-Bibliothek aktualisiert ({url})"
|
||||
return f"Bibliotheks-Refresh: HTTP {antwort.status_code} von {url}"
|
||||
|
||||
elif media_server == "kodi":
|
||||
auth = None
|
||||
if api_key and ":" in api_key:
|
||||
user, pw = api_key.split(":", 1)
|
||||
auth = (user, pw)
|
||||
|
||||
payload = {"jsonrpc": "2.0", "method": "VideoLibrary.Scan", "id": 1}
|
||||
antwort = requests.post(
|
||||
url.rstrip("/") + "/jsonrpc",
|
||||
json=payload, auth=auth, timeout=15,
|
||||
)
|
||||
if antwort.status_code < 300:
|
||||
return f"Kodi-Bibliothek aktualisiert ({url})"
|
||||
return f"Kodi-Refresh: HTTP {antwort.status_code} von {url}"
|
||||
|
||||
except requests.RequestException as e:
|
||||
return f"Bibliotheks-Refresh fehlgeschlagen: {e}"
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def baue_nfo(meta: dict, titel: str, jahr=None) -> str:
|
||||
"""Pure Funktion (testbar): minimales movie.nfo/tvshow.nfo im Kodi-Schema."""
|
||||
|
||||
@@ -3,3 +3,4 @@ redis==5.0.4
|
||||
requests==2.32.3
|
||||
sqlalchemy==2.0.32
|
||||
psycopg2-binary==2.9.9
|
||||
flet==0.23.2
|
||||
|
||||
+310
-34
@@ -17,6 +17,8 @@ import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from winlauf import OHNE_FENSTER
|
||||
|
||||
RIP_OUTPUT_DIR = os.getenv("RIP_OUTPUT_DIR", "/app/media")
|
||||
|
||||
|
||||
@@ -25,34 +27,109 @@ class RipAbbruch(Exception):
|
||||
Nutzer den Job abgebrochen hat (Status 'canceling' in der DB)."""
|
||||
|
||||
|
||||
# include/uapi/linux/cdrom.h — dasselbe ioctl wie in api/devices.py
|
||||
# include/uapi/linux/cdrom.h — dieselben ioctls wie in api/devices.py
|
||||
CDROMEJECT = 0x5309
|
||||
CDROM_LOCKDOOR = 0x5329 # 1 = Tür verriegeln, 0 = entriegeln
|
||||
CDROM_DRIVE_STATUS = 0x5326
|
||||
CDROMCLOSETRAY = 0x5319
|
||||
|
||||
# Antworten von CDROM_DRIVE_STATUS (cdrom.h)
|
||||
CDS_NO_DISC = 1
|
||||
CDS_TRAY_OPEN = 2
|
||||
CDS_DRIVE_NOT_READY = 3
|
||||
CDS_DISC_OK = 4
|
||||
|
||||
# Wie lange auf die Schublade gewartet wird. Ein Laufwerk braucht dafür ein
|
||||
# bis zwei Sekunden; fünf sind reichlich und blockieren nichts Wichtiges.
|
||||
AUSWURF_WARTEN_SEKUNDEN = 5
|
||||
|
||||
|
||||
def wirf_disc_aus(device_path: str) -> bool:
|
||||
"""Wirft die Disc aus; gibt zurück, ob es geklappt hat. Wirft NIE.
|
||||
def _auswurf_geglueckt(status: int) -> bool:
|
||||
"""Ist die Disc nach dem Auswurf wirklich draußen? (pure Funktion)
|
||||
|
||||
Befund 25.07.2026: Die Einstellung „Automatischer Auswurf" (Standard: ein,
|
||||
Beschreibung „Disc nach erfolgreichem Ripping automatisch auswerfen") wurde
|
||||
von niemandem gelesen — bei DVD/Blu-ray warf Rippy nie aus.
|
||||
Sowohl „Schublade offen" als auch „kein Datenträger" zählen: Ein
|
||||
Slot-Laufwerk hat keine Schublade und meldet nach dem Auswerfen CDS_NO_DISC.
|
||||
"""
|
||||
return status in (CDS_TRAY_OPEN, CDS_NO_DISC)
|
||||
|
||||
|
||||
def wirf_disc_aus(device_path: str, ioctl_fn=None, oeffnen=None,
|
||||
schliessen=None, warten=None) -> bool:
|
||||
"""Wirft die Disc aus und PRÜFT, ob sie draußen ist. Wirft NIE.
|
||||
|
||||
## Warum `CDROMEJECT` allein nicht genügt (Befund 26.07.2026, gemessen)
|
||||
|
||||
Der Commander meldete: „Der Button gibt es in den Settings, aber es passiert
|
||||
nicht, das Laufwerk geht nicht auf." Am laufenden System nachgestellt:
|
||||
|
||||
wirf_disc_aus("/dev/sr0") → True
|
||||
CDROM_DRIVE_STATUS danach → 4 (Disc drin)
|
||||
|
||||
Das ioctl wird also **angenommen und tut nichts**. Ursache: MakeMKV
|
||||
verriegelt während des Rips die Laufwerkstür (`CDROM_LOCKDOOR 1`) und
|
||||
entriegelt sie nicht wieder. Ein verriegeltes Laufwerk quittiert den Auswurf
|
||||
trotzdem mit Erfolg. Deshalb macht das Werkzeug `eject` immer beides:
|
||||
erst entriegeln, dann auswerfen. Gegenprobe an derselben Disc:
|
||||
|
||||
CDROM_LOCKDOOR 0 + CDROMEJECT → Status 2 (SCHUBLADE OFFEN)
|
||||
|
||||
## Und deshalb wird das Ergebnis geprüft, nicht geglaubt
|
||||
|
||||
Genau diese Sorte Fehler ist zweimal durchgerutscht: In v3.14 stand hier
|
||||
„Auswurf tat nichts, jetzt entscheidet die Einstellung" — die Einstellung
|
||||
wurde danach wirklich gelesen, nur ausgeworfen wurde weiterhin nicht, und im
|
||||
Log stand „Disc ausgeworfen". Ein Rückgabewert eines ioctls beweist nichts;
|
||||
gefragt wird jetzt das Laufwerk.
|
||||
|
||||
Bewusst hier und nicht in detection.py: das Modul ist ein byteweiser
|
||||
Zwilling der API-Kopie. `fcntl` gibt es nur unter Linux — der native
|
||||
Windows-Worker lädt ripping.py ebenfalls, rippt dort aber nie.
|
||||
|
||||
Die drei Parameter sind nur zum Testen einspritzbar (kein echtes Laufwerk).
|
||||
"""
|
||||
try:
|
||||
from fcntl import ioctl
|
||||
except ImportError: # Windows
|
||||
return False
|
||||
try:
|
||||
fd = os.open(device_path, os.O_RDONLY | os.O_NONBLOCK)
|
||||
if ioctl_fn is None:
|
||||
try:
|
||||
ioctl(fd, CDROMEJECT, 0)
|
||||
finally:
|
||||
os.close(fd)
|
||||
return True
|
||||
from fcntl import ioctl
|
||||
except ImportError: # Windows — dieser Worker rippt nie
|
||||
return False
|
||||
ioctl_fn = ioctl
|
||||
if warten is None:
|
||||
import time
|
||||
warten = time.sleep
|
||||
oeffnen = oeffnen or (lambda p: os.open(p, os.O_RDONLY | os.O_NONBLOCK))
|
||||
schliessen = schliessen or os.close
|
||||
|
||||
try:
|
||||
fd = oeffnen(device_path)
|
||||
except OSError:
|
||||
return False
|
||||
try:
|
||||
# Entriegeln ist der entscheidende Schritt. Scheitert er, wird der
|
||||
# Auswurf trotzdem versucht — bei einem nicht verriegelten Laufwerk
|
||||
# (oder einem, das das ioctl nicht kennt) klappt er ohnehin.
|
||||
try:
|
||||
ioctl_fn(fd, CDROM_LOCKDOOR, 0)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
ioctl_fn(fd, CDROMEJECT, 0)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
# Nachsehen statt hoffen: Die Schublade braucht ein bis zwei Sekunden.
|
||||
for _ in range(AUSWURF_WARTEN_SEKUNDEN):
|
||||
try:
|
||||
if _auswurf_geglueckt(ioctl_fn(fd, CDROM_DRIVE_STATUS, 0)):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
warten(1)
|
||||
return False
|
||||
finally:
|
||||
try:
|
||||
schliessen(fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def check_makemkv_installed() -> bool:
|
||||
@@ -134,13 +211,119 @@ def parse_titel_info(ausgabe: str) -> list:
|
||||
return [titel[nr] for nr in sorted(titel)]
|
||||
|
||||
|
||||
def lies_titel_info(device_path: str, timeout: int = 300) -> list:
|
||||
"""Volle Titel-Liste der eingelegten Disc (makemkvcon info, Robot-Mode)."""
|
||||
# Attribut-IDs der SINFO-Zeilen. Am 26.07.2026 an der Akira-Blu-ray im Laufwerk
|
||||
# der Rippy-VM abgenommen (AGENTS Regel D):
|
||||
#
|
||||
# SINFO:0,1,1,6202,"Audio"
|
||||
# SINFO:0,1,3,0,"deu"
|
||||
# SINFO:0,1,4,0,"German"
|
||||
# SINFO:0,1,6,0,"DTS-HD MA"
|
||||
# SINFO:0,1,14,0,"2"
|
||||
# SINFO:0,1,30,0,"DTS-HD MA Stereo German"
|
||||
#
|
||||
# ⚠️ Die Sprache steht in 3/4 — NICHT in 28/29. Die tragen auf JEDEM Stream
|
||||
# „eng"/„English", auch auf dem Videostream einer deutschen Tonspur; das ist
|
||||
# MakeMKVs eigene Anzeigesprache und nicht die des Tracks. Wer 28 nimmt, hält
|
||||
# jede Disc für englisch.
|
||||
SINFO_TYP = 1
|
||||
SINFO_SPRACHE_CODE = 3
|
||||
SINFO_SPRACHE_NAME = 4
|
||||
SINFO_CODEC = 6
|
||||
SINFO_KANAELE = 14
|
||||
SINFO_BESCHREIBUNG = 30
|
||||
|
||||
SINFO_ZEILE = re.compile(r'SINFO:(\d+),(\d+),(\d+),\d+,"(.*)"\s*$')
|
||||
|
||||
|
||||
def parse_stream_info(ausgabe: str) -> dict:
|
||||
"""SINFO-Zeilen → Tonspuren und Untertitel je Titel (pure Funktion).
|
||||
|
||||
Commander-Anforderung 26.07.2026: *„Es muss verfügbar sein, dass man die
|
||||
Sprache von Medien auswählen kann … Rippy muss VOR dem Rip fragen: Was genau
|
||||
willst du haben?"* Die Auskunft liegt längst vor — der Titel-Scan liest sie
|
||||
mit und hat sie bisher weggeworfen.
|
||||
|
||||
Rückgabe: {titel_nr: {"audio": [...], "untertitel": [...]}}, jeder Eintrag
|
||||
{"nr", "lang", "sprache", "codec", "kanaele", "beschreibung"}.
|
||||
"""
|
||||
titel: dict = {}
|
||||
for zeile in (ausgabe or "").splitlines():
|
||||
treffer = SINFO_ZEILE.match(zeile.strip())
|
||||
if not treffer:
|
||||
continue
|
||||
t_nr, s_nr, attr = (int(treffer.group(i)) for i in (1, 2, 3))
|
||||
wert = treffer.group(4)
|
||||
streams = titel.setdefault(t_nr, {})
|
||||
eintrag = streams.setdefault(s_nr, {"nr": s_nr, "typ": "", "lang": "",
|
||||
"sprache": "", "codec": "",
|
||||
"kanaele": "", "beschreibung": ""})
|
||||
if attr == SINFO_TYP:
|
||||
eintrag["typ"] = wert
|
||||
elif attr == SINFO_SPRACHE_CODE:
|
||||
eintrag["lang"] = wert
|
||||
elif attr == SINFO_SPRACHE_NAME:
|
||||
eintrag["sprache"] = wert
|
||||
elif attr == SINFO_CODEC:
|
||||
eintrag["codec"] = wert
|
||||
elif attr == SINFO_KANAELE:
|
||||
eintrag["kanaele"] = wert
|
||||
elif attr == SINFO_BESCHREIBUNG:
|
||||
eintrag["beschreibung"] = wert
|
||||
|
||||
ergebnis = {}
|
||||
for t_nr, streams in titel.items():
|
||||
audio, untertitel = [], []
|
||||
for s_nr in sorted(streams):
|
||||
s = streams[s_nr]
|
||||
# MakeMKV schreibt „Subtitles" (englisch, Plural) — gemessen.
|
||||
if s["typ"] == "Audio":
|
||||
audio.append(s)
|
||||
elif s["typ"].startswith("Subtitle"):
|
||||
untertitel.append(s)
|
||||
ergebnis[t_nr] = {"audio": audio, "untertitel": untertitel}
|
||||
return ergebnis
|
||||
|
||||
|
||||
def sprachen_zusammenfassen(streams_je_titel: dict) -> dict:
|
||||
"""Welche Sprachen hat die DISC insgesamt? (pure Funktion)
|
||||
|
||||
Für die Frage vor dem Rip zählt nicht Titel 3 Stream 7, sondern: „Diese Disc
|
||||
hat Ton auf Deutsch, Englisch und Japanisch, Untertitel auf Deutsch und
|
||||
Englisch." Deshalb über alle Titel vereinigt, Reihenfolge stabil (so wie sie
|
||||
auf der Disc stehen).
|
||||
|
||||
Rückgabe: {"audio": [{"lang","sprache","spuren"}], "untertitel": [...]}
|
||||
"""
|
||||
def sammeln(schluessel):
|
||||
gefunden = {}
|
||||
for streams in (streams_je_titel or {}).values():
|
||||
for s in streams.get(schluessel) or []:
|
||||
code = s.get("lang") or ""
|
||||
if not code:
|
||||
continue
|
||||
eintrag = gefunden.setdefault(
|
||||
code, {"lang": code, "sprache": s.get("sprache") or code,
|
||||
"spuren": 0})
|
||||
eintrag["spuren"] += 1
|
||||
return list(gefunden.values())
|
||||
|
||||
return {"audio": sammeln("audio"), "untertitel": sammeln("untertitel")}
|
||||
|
||||
|
||||
def lies_titel_info(device_path: str, timeout: int = 300) -> tuple:
|
||||
"""(Titel-Liste, Streams je Titel) der eingelegten Disc — EIN Info-Lauf.
|
||||
|
||||
Beides aus demselben Aufruf: Ein `makemkvcon info` dauert je Disc 20–120 s,
|
||||
und die Sprach-Auskunft steht in derselben Ausgabe. Sie zweimal zu holen
|
||||
wäre eine Minute Wartezeit für nichts.
|
||||
"""
|
||||
ergebnis = subprocess.run(
|
||||
["makemkvcon", "-r", "--noscan", "info", f"dev:{device_path}"],
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
creationflags=OHNE_FENSTER,
|
||||
)
|
||||
return parse_titel_info(ergebnis.stdout or "")
|
||||
ausgabe = ergebnis.stdout or ""
|
||||
return parse_titel_info(ausgabe), parse_stream_info(ausgabe)
|
||||
|
||||
|
||||
def rip_titel_auswahl(device_path: str, output_dir: str, titel_liste: list,
|
||||
@@ -165,19 +348,52 @@ def rip_titel_auswahl(device_path: str, output_dir: str, titel_liste: list,
|
||||
return {"status": "success", "output_dir": output_dir, "files": alle_dateien}
|
||||
|
||||
|
||||
def laengster_titel(dauern: dict):
|
||||
"""Titel-Nummer mit der längsten Laufzeit — der Hauptfilm (pure Funktion)."""
|
||||
def laengster_titel(dauern: dict, meta: dict = None):
|
||||
"""Titel-Nummer für den Hauptfilm (pure Funktion).
|
||||
|
||||
Mit meta['runtime'] wird heuristisch der Titel gesucht, dessen Laufzeit am nächsten
|
||||
an der TMDB-Laufzeit liegt (vermeidet 'Play All'-Tracks, die länger als der Film sind).
|
||||
Ohne runtime wird klassisch der längste Titel gewählt.
|
||||
"""
|
||||
if not dauern:
|
||||
return None
|
||||
|
||||
ziel_s = (meta.get("runtime") or 0) * 60 if meta else 0
|
||||
if ziel_s > 0:
|
||||
bester = min(dauern, key=lambda t: abs(dauern[t] - ziel_s))
|
||||
if abs(dauern[bester] - ziel_s) <= 20 * 60:
|
||||
return bester
|
||||
|
||||
return max(dauern, key=dauern.get)
|
||||
|
||||
|
||||
def episoden_titel(dauern: dict, meta: dict) -> list:
|
||||
"""Findet Episoden-Titel anhand der TMDB-Episoden-Laufzeiten.
|
||||
|
||||
Toleranz: +/- 10 Minuten (600 Sekunden) zum Durchschnitt.
|
||||
Gibt eine sortierte Liste der gefundenen Titelnummern zurück.
|
||||
"""
|
||||
if not dauern or not meta or not meta.get("episode_runtimes"):
|
||||
return []
|
||||
|
||||
runtimes_s = [r * 60 for r in meta["episode_runtimes"]]
|
||||
avg_runtime = sum(runtimes_s) / len(runtimes_s)
|
||||
|
||||
gefundene_titel = []
|
||||
for titel, dauer in dauern.items():
|
||||
if abs(dauer - avg_runtime) <= 10 * 60:
|
||||
gefundene_titel.append(int(titel))
|
||||
|
||||
return sorted(gefundene_titel)
|
||||
|
||||
|
||||
def lies_titel_dauern(device_path: str, timeout: int = 300) -> dict:
|
||||
"""Fragt die Titel-Laufzeiten der Disc ab (makemkvcon info, Robot-Mode)."""
|
||||
try:
|
||||
ergebnis = subprocess.run(
|
||||
["makemkvcon", "-r", "--noscan", "info", f"dev:{device_path}"],
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
creationflags=OHNE_FENSTER,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return {}
|
||||
@@ -206,6 +422,7 @@ def lies_datei_dauer(pfad: str, timeout: int = 120) -> int:
|
||||
ergebnis = subprocess.run(
|
||||
["HandBrakeCLI", "--scan", "-i", pfad],
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
creationflags=OHNE_FENSTER,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return 0
|
||||
@@ -318,22 +535,50 @@ def preset_fuer(disc_type: str, einstellungen: dict) -> str:
|
||||
return (einstellungen.get("transcodePreset") or "").strip() or DEFAULT_HB_PRESET
|
||||
|
||||
|
||||
def build_handbrake_cmd(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET) -> list:
|
||||
def build_handbrake_cmd(input_path: str, output_path: str,
|
||||
preset: str = DEFAULT_HB_PRESET,
|
||||
audio_sprachen=None, untertitel_sprachen=None) -> list:
|
||||
"""Baut das HandBrake-Kommando für die Kompressions-Stufe (pure Funktion).
|
||||
|
||||
Arbeitet auf der MKV-DATEI aus dem MakeMKV-Rip — nie auf dem Laufwerk:
|
||||
HandBrake kann AACS-verschlüsselte Blu-rays nicht lesen, MakeMKV bleibt
|
||||
deshalb zwingend die erste Stufe. --all-audio/--all-subtitles behalten
|
||||
alle Sprachen (Preset-Default wäre nur die erste Tonspur).
|
||||
|
||||
## Die Sprachauswahl greift HIER, nicht beim Rippen
|
||||
|
||||
Commander-Anforderung 26.07.2026. Bewusst in der KOMPRESSION und nicht im
|
||||
Rip, aus drei Gründen:
|
||||
|
||||
1. Der Rip bleibt vollständig und verlustfrei — das ist ein Muss-Feature
|
||||
(KONZEPT). Wer später eine andere Sprachwahl will, komprimiert neu, statt
|
||||
die Disc wieder einzulegen.
|
||||
2. MakeMKV kann Tracks nur über eine Auswahl-Profildatei filtern; HandBrake
|
||||
hat dafür dokumentierte Schalter, die genau die Codes nehmen, die MakeMKV
|
||||
liefert (ISO 639-2, „deu"/„eng" — beides am 26.07.2026 gegengeprüft).
|
||||
3. Wird ein Disc-Typ NICHT komprimiert (4K verlustfrei), bleiben ohnehin alle
|
||||
Sprachen drin — und das ist bei einer verlustfreien Ablage richtig.
|
||||
|
||||
`--audio-lang-list` zusammen mit `--first-audio` heißt: HandBrake pickt pro
|
||||
Sprache genau die erste (beste) Tonspur heraus. `--audio-codec copy` reicht
|
||||
diese dann verlustfrei durch, statt sie auf Stereo herunterzurechnen.
|
||||
"""
|
||||
return [
|
||||
befehl = [
|
||||
"HandBrakeCLI",
|
||||
"--input", input_path,
|
||||
"--output", output_path,
|
||||
"--preset", preset,
|
||||
"--all-audio",
|
||||
"--all-subtitles",
|
||||
]
|
||||
audio = [s for s in (audio_sprachen or []) if s]
|
||||
if audio:
|
||||
befehl += ["--audio-lang-list", ",".join(audio)]
|
||||
befehl.append("--first-audio")
|
||||
befehl += ["--audio-codec", "copy", "--audio-fallback", "av_aac"]
|
||||
untertitel = [s for s in (untertitel_sprachen or []) if s]
|
||||
if untertitel:
|
||||
befehl += ["--subtitle-lang-list", ",".join(untertitel)]
|
||||
befehl.append("--all-subtitles")
|
||||
return befehl
|
||||
|
||||
|
||||
HB_ENCODE_RE = re.compile(
|
||||
@@ -374,8 +619,28 @@ def get_progress_from_line(line: str) -> int:
|
||||
return int(((aktuell - 1) * 100 + prozent) / gesamt)
|
||||
|
||||
|
||||
def sprachliste(wert) -> list:
|
||||
"""„deu, eng , ,DEU" → ["deu", "eng"] (pure Funktion).
|
||||
|
||||
Nimmt Komma-Text aus den Einstellungen oder eine fertige Liste. Doppelte und
|
||||
Leeres fliegen raus, Kleinschreibung wird erzwungen — HandBrake will
|
||||
ISO-639-2 in Kleinbuchstaben, und ein „DEU" aus einem Eingabefeld soll nicht
|
||||
still die ganze Auswahl unwirksam machen.
|
||||
"""
|
||||
if not wert:
|
||||
return []
|
||||
teile = wert.split(",") if isinstance(wert, str) else list(wert)
|
||||
sauber = []
|
||||
for t in teile:
|
||||
code = str(t).strip().lower()
|
||||
if code and code not in sauber:
|
||||
sauber.append(code)
|
||||
return sauber
|
||||
|
||||
|
||||
def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET,
|
||||
progress_cb=None, abbruch_cb=None) -> dict:
|
||||
progress_cb=None, abbruch_cb=None,
|
||||
audio_sprachen=None, untertitel_sprachen=None) -> dict:
|
||||
"""Komprimiert eine MKV-Datei mit HandBrakeCLI; meldet Fortschritt.
|
||||
|
||||
`abbruch_cb` wird bei JEDER Ausgabezeile aufgerufen und soll RipAbbruch
|
||||
@@ -392,11 +657,13 @@ def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PR
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
build_handbrake_cmd(input_path, output_path, preset),
|
||||
build_handbrake_cmd(input_path, output_path, preset,
|
||||
audio_sprachen, untertitel_sprachen),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1
|
||||
bufsize=1,
|
||||
creationflags=OHNE_FENSTER,
|
||||
)
|
||||
return _handbrake_schleife(process, output_path, abbruch_cb, progress_cb)
|
||||
except Exception as e:
|
||||
@@ -525,7 +792,8 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1
|
||||
bufsize=1,
|
||||
creationflags=OHNE_FENSTER,
|
||||
)
|
||||
|
||||
letzte_meldung = ""
|
||||
@@ -596,15 +864,14 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
|
||||
|
||||
def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_cb=None,
|
||||
output_dir: str = None, nur_hauptfilm: bool = False,
|
||||
titel_liste: list = None, log_cb=None) -> dict:
|
||||
titel_liste: list = None, log_cb=None, meta: dict = None) -> dict:
|
||||
"""Rippt eine DVD oder Blu-ray verlustfrei mit MakeMKV.
|
||||
|
||||
Bewusst KEIN eigener Celery-Task: der einzige Task ist worker.tasks.rip_disc,
|
||||
der hier mit seinem eigenen Fortschritts-Callback durchgreift. output_dir
|
||||
überschreibt das Ziel (Transcode-Fall: Roh-Rip nach /app/temp).
|
||||
titel_liste: exakte Titel-Auswahl aus dem UI (schlägt nur_hauptfilm).
|
||||
nur_hauptfilm: Vorab-Info-Lauf, dann nur den LÄNGSTEN Titel rippen —
|
||||
das löst das eingestellte (vorher wirkungslose) mainFeatureOnly ein.
|
||||
nur_hauptfilm: Vorab-Info-Lauf, dann heuristisch den besten Titel rippen.
|
||||
"""
|
||||
if output_dir is None:
|
||||
output_dir = os.path.join(RIP_OUTPUT_DIR, disc_type, disc_id)
|
||||
@@ -613,10 +880,18 @@ def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_c
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
return rip_titel_auswahl(device_path, output_dir, titel_liste, progress_cb, log_cb)
|
||||
|
||||
# Serien-Erkennung: Wenn keine Titel explizit gewählt wurden und es eine Serie ist
|
||||
if meta and meta.get("series") and meta.get("episode_runtimes"):
|
||||
dauern = lies_titel_dauern(device_path)
|
||||
episoden = episoden_titel(dauern, meta)
|
||||
if episoden:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
return rip_titel_auswahl(device_path, output_dir, episoden, progress_cb, log_cb)
|
||||
|
||||
titel = "all"
|
||||
if nur_hauptfilm:
|
||||
dauern = lies_titel_dauern(device_path)
|
||||
haupt = laengster_titel(dauern)
|
||||
haupt = laengster_titel(dauern, meta)
|
||||
if haupt is not None:
|
||||
titel = str(haupt)
|
||||
# Kein Titel ermittelbar → ehrlich auf 'all' zurückfallen statt raten
|
||||
@@ -655,7 +930,8 @@ def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = N
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1
|
||||
bufsize=1,
|
||||
creationflags=OHNE_FENSTER,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Schlüssel-Automatik für 4K-UHD — der Windows-PC holt, was Linux nie holt.
|
||||
|
||||
## Das Problem, das das hier löst
|
||||
|
||||
Belegt am 25.07.2026 auf BEIDEN Maschinen: `makemkvcon` unter **Linux** ruft
|
||||
Disc-Schlüssel NIE ab — kein einziger Verbindungsversuch, geprüft mit leerem und
|
||||
gefülltem Speicher, mit und ohne `--noscan`. Die **Windows**-Version tut es
|
||||
(Meldung 3338). Deshalb scheitert jede unbekannte UHD-Disc auf der Rippy-VM mit
|
||||
„The volume key is unknown", und der Weg, der funktioniert, war bisher
|
||||
Handarbeit: Laufwerk an den Windows-PC, Disc öffnen, `_private_data.tar` suchen,
|
||||
im UI hochladen.
|
||||
|
||||
Das macht diese Datei automatisch.
|
||||
|
||||
## Zwei Schichten, absichtlich getrennt
|
||||
|
||||
**1. Der Wächter (verlässlich).** Er sieht `_private_data.tar` nach und lädt sie
|
||||
zu Rippy hoch, sobald sie sich geändert hat. Er braucht keine
|
||||
Laufwerkserkennung, kein Disc-Öffnen, nichts geraten — wenn MakeMKV neue
|
||||
Schlüssel gelernt hat, wandern sie rüber. Das deckt auch den Fall ab, dass der
|
||||
Nutzer die Disc einfach in der MakeMKV-Oberfläche öffnet.
|
||||
|
||||
**2. Das Anstoßen (nach bestem Wissen).** Liegt eine Disc im Laufwerk dieses PCs,
|
||||
wird `makemkvcon info` darauf losgelassen — dabei holt MakeMKV den Schlüssel.
|
||||
|
||||
Warum die Trennung: Das Format der belegten `DRV:`-Zeile konnte auf dem
|
||||
Commander-PC nicht gemessen werden (dort steckt kein optisches Laufwerk, alle 16
|
||||
Plätze melden `DRV:i,256,999,0,"","",""` — gemessen). Geraten wird deshalb nur in
|
||||
Schicht 2, und wenn die Vermutung falsch ist, passiert dort einfach nichts —
|
||||
Schicht 1 arbeitet weiter. Die teure Annahme steckt nie im verlässlichen Teil.
|
||||
|
||||
## Gemessene Fundstellen (AGENTS Regel D)
|
||||
|
||||
- Datenverzeichnis unter Windows: `%USERPROFILE%\\.MakeMKV` — dort lag die echte
|
||||
`_private_data.tar` (6.420.480 Bytes). NICHT `%APPDATA%\\MakeMKV`, wie man
|
||||
vermuten würde.
|
||||
- Programm: `C:\\Program Files (x86)\\MakeMKV\\makemkvcon64.exe` (v1.18.4).
|
||||
- Laufwerksliste: `makemkvcon -r --cache=1 info disc:9999` gibt `DRV:`-Zeilen.
|
||||
- Hochladen: `POST /api/system/keystore` mit ROHEM Körper (kein JSON, kein
|
||||
Multipart — die API hat kein python-multipart).
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import urllib.request
|
||||
|
||||
from winlauf import OHNE_FENSTER
|
||||
|
||||
# `DRV:<index>,<zustand>,<flags>,<?>,"<Laufwerk>","<Disc>","<Gerät>"`
|
||||
DRV_ZEILE = re.compile(r'^DRV:(\d+),(\d+),(\d+),(\d+),"([^"]*)","([^"]*)","([^"]*)"')
|
||||
|
||||
# Wie oft nachgesehen wird. Eine Minute reicht: Discs wechseln nicht im
|
||||
# Sekundentakt, und ein `makemkvcon info` belastet das Laufwerk.
|
||||
TAKT_SEKUNDEN = 60
|
||||
|
||||
|
||||
def daten_verzeichnis() -> str:
|
||||
"""MakeMKVs Datenverzeichnis auf DIESEM Rechner (leer, wenn nicht gefunden).
|
||||
|
||||
`%USERPROFILE%\\.MakeMKV` ist der gemessene Ort (26.07.2026 auf dem
|
||||
Commander-PC, MakeMKV 1.18.4). Die beiden anderen Kandidaten stehen als
|
||||
Rückfall drin, weil MakeMKV über die Versionen umgezogen ist — geprüft wird,
|
||||
welcher wirklich existiert, statt einen zu behaupten.
|
||||
"""
|
||||
profil = os.getenv("USERPROFILE") or os.path.expanduser("~")
|
||||
kandidaten = [
|
||||
os.path.join(profil, ".MakeMKV"),
|
||||
os.path.join(os.getenv("APPDATA") or "", "MakeMKV"),
|
||||
os.path.join(os.getenv("LOCALAPPDATA") or "", "MakeMKV"),
|
||||
]
|
||||
for pfad in kandidaten:
|
||||
if pfad and os.path.isdir(pfad):
|
||||
return pfad
|
||||
return ""
|
||||
|
||||
|
||||
def schluesseldatei() -> str:
|
||||
"""Voller Pfad zu `_private_data.tar` (leer, wenn es sie nicht gibt)."""
|
||||
ordner = daten_verzeichnis()
|
||||
if not ordner:
|
||||
return ""
|
||||
datei = os.path.join(ordner, "_private_data.tar")
|
||||
return datei if os.path.isfile(datei) else ""
|
||||
|
||||
|
||||
def makemkvcon_pfad() -> str:
|
||||
"""makemkvcon auf dieser Maschine (leer, wenn MakeMKV nicht installiert ist).
|
||||
|
||||
Die 64-Bit-Variante zuerst — auf dem Commander-PC liegen beide, und die
|
||||
32-Bit-Version ist nur noch Beiwerk.
|
||||
"""
|
||||
import shutil
|
||||
|
||||
fest = [
|
||||
r"C:\Program Files (x86)\MakeMKV\makemkvcon64.exe",
|
||||
r"C:\Program Files (x86)\MakeMKV\makemkvcon.exe",
|
||||
r"C:\Program Files\MakeMKV\makemkvcon64.exe",
|
||||
r"C:\Program Files\MakeMKV\makemkvcon.exe",
|
||||
]
|
||||
for pfad in fest:
|
||||
if os.path.isfile(pfad):
|
||||
return pfad
|
||||
return shutil.which("makemkvcon64") or shutil.which("makemkvcon") or ""
|
||||
|
||||
|
||||
def parse_laufwerke(ausgabe: str) -> list:
|
||||
"""`DRV:`-Zeilen → Laufwerke mit Disc (pure Funktion).
|
||||
|
||||
Rückgabe: [{"index", "laufwerk", "disc", "geraet"}] — NUR Einträge, bei denen
|
||||
ein Disc-Name steht. Ein leerer Platz sieht so aus (gemessen):
|
||||
|
||||
DRV:0,256,999,0,"","",""
|
||||
|
||||
⚠️ Die BELEGTE Form ist nicht gemessen — auf dem Commander-PC steckt kein
|
||||
optisches Laufwerk. Deshalb ist die Regel bewusst konservativ: ohne
|
||||
Disc-Namen gilt „nichts da", und dann tut die Automatik einfach nichts.
|
||||
Falsch-negativ ist hier harmlos (der Wächter greift trotzdem),
|
||||
falsch-positiv wäre ein `makemkvcon`-Lauf ins Leere.
|
||||
"""
|
||||
gefunden = []
|
||||
for zeile in (ausgabe or "").splitlines():
|
||||
treffer = DRV_ZEILE.match(zeile.strip())
|
||||
if not treffer:
|
||||
continue
|
||||
index, _zustand, _flags, _x, laufwerk, disc, geraet = treffer.groups()
|
||||
if not disc.strip():
|
||||
continue
|
||||
gefunden.append({
|
||||
"index": int(index),
|
||||
"laufwerk": laufwerk,
|
||||
"disc": disc,
|
||||
"geraet": geraet,
|
||||
})
|
||||
return gefunden
|
||||
|
||||
|
||||
def datei_stand(pfad: str) -> tuple:
|
||||
"""(Größe, Änderungszeit) — die Kennung, an der eine Änderung auffällt."""
|
||||
try:
|
||||
s = os.stat(pfad)
|
||||
return (s.st_size, int(s.st_mtime))
|
||||
except OSError:
|
||||
return (0, 0)
|
||||
|
||||
|
||||
def hat_sich_geaendert(vorher: tuple, jetzt: tuple) -> bool:
|
||||
"""Lohnt ein Upload? (pure Funktion)
|
||||
|
||||
Nur wenn die Datei EXISTIERT und sich unterscheidet. Beim allerersten Lauf
|
||||
(vorher = None) wird ebenfalls hochgeladen: Rippy soll den Bestand kennen,
|
||||
auch wenn MakeMKV gerade nichts Neues gelernt hat.
|
||||
"""
|
||||
if jetzt == (0, 0):
|
||||
return False
|
||||
return vorher is None or vorher != jetzt
|
||||
|
||||
|
||||
def laufwerke_lesen(programm: str, laufen=None, timeout: int = 120) -> list:
|
||||
"""Welche Laufwerke dieses PCs haben eine Disc? (leer bei jedem Fehler)"""
|
||||
if not programm:
|
||||
return []
|
||||
starten = laufen or subprocess.run
|
||||
try:
|
||||
ergebnis = starten(
|
||||
[programm, "-r", "--cache=1", "info", "disc:9999"],
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
errors="replace", creationflags=OHNE_FENSTER,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return []
|
||||
return parse_laufwerke(ergebnis.stdout or "")
|
||||
|
||||
|
||||
def disc_oeffnen(programm: str, index: int, laufen=None, timeout: int = 600) -> bool:
|
||||
"""Lässt MakeMKV die Disc lesen — dabei holt es unter Windows den Schlüssel.
|
||||
|
||||
Der Rückgabewert sagt nur, ob der Aufruf durchlief. Ob ein Schlüssel dabei
|
||||
herauskam, entscheidet allein die Datei — deshalb wird danach ihr Stand
|
||||
verglichen und nicht diese Antwort geglaubt.
|
||||
"""
|
||||
if not programm:
|
||||
return False
|
||||
starten = laufen or subprocess.run
|
||||
try:
|
||||
starten(
|
||||
[programm, "-r", "--noscan", "info", f"disc:{index}"],
|
||||
capture_output=True, text=True, timeout=timeout, errors="replace",
|
||||
creationflags=OHNE_FENSTER,
|
||||
)
|
||||
return True
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
|
||||
|
||||
def hochladen(host: str, datei: str, oeffner=None, timeout: int = 120) -> str:
|
||||
"""`_private_data.tar` zu Rippy schicken. "" = geklappt, sonst der Fehler.
|
||||
|
||||
ROHER Körper, kein JSON und kein Multipart — genau so nimmt
|
||||
`POST /system/keystore` die Datei an (der API fehlt python-multipart).
|
||||
"""
|
||||
if not host:
|
||||
return "keine Rippy-Adresse gesetzt"
|
||||
if not datei:
|
||||
return "keine Schlüsseldatei gefunden"
|
||||
try:
|
||||
with open(datei, "rb") as f:
|
||||
inhalt = f.read()
|
||||
except OSError as e:
|
||||
return f"Schlüsseldatei nicht lesbar: {e}"
|
||||
|
||||
anfrage = urllib.request.Request(
|
||||
f"http://{host}/api/system/keystore", data=inhalt, method="POST",
|
||||
headers={"Content-Type": "application/octet-stream"},
|
||||
)
|
||||
macher = oeffner or urllib.request.urlopen
|
||||
try:
|
||||
with macher(anfrage, timeout=timeout) as antwort:
|
||||
antwort.read()
|
||||
return ""
|
||||
except Exception as e:
|
||||
return f"Hochladen fehlgeschlagen: {e}"
|
||||
|
||||
|
||||
def runde(host: str, letzter_stand, melden=None, programm=None,
|
||||
laufen=None, oeffner=None) -> tuple:
|
||||
"""Ein Durchlauf der Automatik. Rückgabe: (neuer_stand, was_passiert_ist).
|
||||
|
||||
Reihenfolge mit Absicht: ERST anstoßen (falls eine Disc liegt), DANN die
|
||||
Datei vergleichen. So wird ein gerade geholter Schlüssel in derselben Runde
|
||||
mitgenommen, statt eine Minute zu warten.
|
||||
"""
|
||||
sage = melden or (lambda level, text: None)
|
||||
prog = programm if programm is not None else makemkvcon_pfad()
|
||||
if not prog:
|
||||
return letzter_stand, "kein-makemkv"
|
||||
|
||||
for laufwerk in laufwerke_lesen(prog, laufen=laufen):
|
||||
sage("info", f"Disc erkannt: {laufwerk['disc']} — MakeMKV liest sie, "
|
||||
"um den Disc-Schlüssel zu holen")
|
||||
disc_oeffnen(prog, laufwerk["index"], laufen=laufen)
|
||||
|
||||
datei = schluesseldatei()
|
||||
jetzt = datei_stand(datei) if datei else (0, 0)
|
||||
if not hat_sich_geaendert(letzter_stand, jetzt):
|
||||
return letzter_stand, "unveraendert"
|
||||
|
||||
fehler = hochladen(host, datei, oeffner=oeffner)
|
||||
if fehler:
|
||||
sage("warning", f"Schlüsselspeicher konnte nicht zu Rippy: {fehler}")
|
||||
return letzter_stand, "fehler"
|
||||
sage("success",
|
||||
f"Schlüsselspeicher an Rippy übergeben ({jetzt[0]} Bytes) — "
|
||||
"wirkt ab dem nächsten Rip")
|
||||
return jetzt, "hochgeladen"
|
||||
+119
-16
@@ -47,11 +47,24 @@ from ripping import (
|
||||
rip_cd,
|
||||
rip_video,
|
||||
run_handbrake,
|
||||
sprachen_zusammenfassen,
|
||||
sprachliste,
|
||||
wirf_disc_aus,
|
||||
)
|
||||
|
||||
API_URL = os.getenv("API_URL", "http://api:8000")
|
||||
|
||||
# Phasen-Marke in den Job-Metadaten: war der RIP fertig, als es schiefging?
|
||||
#
|
||||
# Sobald `status = "failed"` in der Zeile steht, ist die Phase sonst
|
||||
# unwiederbringlich fort — und genau die entscheidet, was danach hilft: Nach
|
||||
# einem toten Transcode genügt „Neu komprimieren"; nach einem toten Rip liegt
|
||||
# nur ein Bruchstück da (Vorfall 26.07.2026: 5,1 GB von rund 40 GB) und es muss
|
||||
# neu gerippt werden. Gelesen wird die Marke in api/phasen.py — der Name steht
|
||||
# in beiden Dateien und wird von test_phasen.py gegeneinander geprüft, weil es
|
||||
# kein geteiltes Paket zwischen den Containern gibt.
|
||||
RIP_FERTIG = "rip_fertig"
|
||||
|
||||
|
||||
def _transcode_queue(node: str):
|
||||
"""Ziel-Queue für die Kompression (siehe celery_client.transcode_queue):
|
||||
@@ -471,6 +484,9 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
||||
return {"status": "error", "error": fehler, "disc_type": disc_type}
|
||||
|
||||
db.update_job(job_id, status="running", disc_type=disc_type)
|
||||
# Ab hier gilt: der Rip läuft, ist aber NICHT fertig. Stirbt der Job jetzt,
|
||||
# ist jede Roh-Datei ein Bruchstück (siehe RIP_FERTIG oben).
|
||||
db.meta_merken(job_id, **{RIP_FERTIG: False})
|
||||
db.add_log("info", "worker", f"Job {job_id}: {disc_type}-Rip gestartet ({device_path})")
|
||||
|
||||
letzter = [-1]
|
||||
@@ -653,6 +669,9 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
||||
# freier Worker, inkl. Remote-GPU).
|
||||
ziel_queue = _transcode_queue(meta.get("transcode_node"))
|
||||
db.update_job(job_id, status="transcoding", progress=0)
|
||||
# Der Rip ist durch. Ab jetzt ist ein Fehlschlag mit „Neu komprimieren"
|
||||
# zu heilen, ohne die Disc noch einmal zu lesen.
|
||||
db.meta_merken(job_id, **{RIP_FERTIG: True})
|
||||
gezielt = meta.get("transcode_node") and ziel_queue != "transcode"
|
||||
db.add_log("info", "worker",
|
||||
f"Job {job_id}: Rip fertig, Kompression eingereiht"
|
||||
@@ -690,9 +709,20 @@ def scan_tracks(device_path: str):
|
||||
key = f"tracks:{device_path}"
|
||||
db.save_settings({"status": "running"}, key)
|
||||
try:
|
||||
titel = lies_titel_info(device_path)
|
||||
db.save_settings({"status": "done", "tracks": titel}, key)
|
||||
db.add_log("info", "worker", f"Titel-Scan {device_path}: {len(titel)} Titel gefunden")
|
||||
# Ein Info-Lauf liefert BEIDES: Titel-Liste und die Sprachen. Die
|
||||
# Sprach-Auskunft stand schon immer in derselben Ausgabe und wurde nur
|
||||
# weggeworfen (Commander-Anforderung 26.07.2026: vor dem Rip fragen,
|
||||
# was man haben will).
|
||||
titel, streams = lies_titel_info(device_path)
|
||||
sprachen = sprachen_zusammenfassen(streams)
|
||||
db.save_settings(
|
||||
{"status": "done", "tracks": titel, "sprachen": sprachen}, key)
|
||||
db.add_log(
|
||||
"info", "worker",
|
||||
f"Titel-Scan {device_path}: {len(titel)} Titel, "
|
||||
f"{len(sprachen['audio'])} Tonsprache(n), "
|
||||
f"{len(sprachen['untertitel'])} Untertitelsprache(n)",
|
||||
)
|
||||
return {"status": "done", "anzahl": len(titel)}
|
||||
except Exception as e:
|
||||
db.save_settings({"status": "error", "error": str(e)[:300]}, key)
|
||||
@@ -705,6 +735,46 @@ def _ist_uebersetzt(container_pfad: str, lokal_pfad: str) -> bool:
|
||||
return container_pfad != lokal_pfad
|
||||
|
||||
|
||||
# Wie weit wird nach einem vorhandenen Elternordner gesucht. Begrenzt, damit auf
|
||||
# einer toten Freigabe nicht endlos geklopft wird.
|
||||
MAX_ELTERN_STUFEN = 12
|
||||
|
||||
|
||||
def erster_vorhandener_ordner(pfad: str, isdir=None, dirname=None) -> str:
|
||||
"""Der nächste EXISTIERENDE Ordner oberhalb von `pfad` — oder "".
|
||||
|
||||
⚠️ Vorfall 26.07.2026, im vollen Durchlauf aufgefallen: Der Rip lief sauber
|
||||
durch (40 GB), die Kompression brach sofort ab mit „Dieser Worker erreicht
|
||||
das Ziel nicht: \\\\NAS\\rippy\\movies\\Akira (1988)". Erreichbar war die
|
||||
Freigabe sehr wohl — es fehlten schlicht ZWEI Ordner: `movies` und der
|
||||
Filmordner darin. Die Prüfung ließ nur EINE fehlende Ebene durchgehen (sie
|
||||
sah nach `dirname`), obwohl gleich darauf `os.makedirs` die ganze Kette
|
||||
anlegt. Ein Ziel, das noch nie beschrieben wurde, war damit systematisch
|
||||
unerreichbar — also jeder erste Film in einer neuen Ablage.
|
||||
|
||||
Es zählt daher, ob IRGENDEIN Vorfahre existiert: Von dem aus kann
|
||||
`makedirs` den Rest bauen. Existiert keiner, ist die Freigabe wirklich weg.
|
||||
|
||||
`isdir`/`dirname` werden BEIM AUFRUF aufgelöst, nicht als Vorgabewert
|
||||
gebunden — sonst zeigt die Vorgabe für immer auf die Funktion von damals,
|
||||
und ein Ersetzen von `os.path.isdir` (im Test wie im Betrieb) ginge ins
|
||||
Leere. Genau darüber bin ich beim Schreiben der Tests gestolpert.
|
||||
"""
|
||||
isdir = isdir or os.path.isdir
|
||||
dirname = dirname or os.path.dirname
|
||||
aktuell = dirname(pfad or "")
|
||||
for _ in range(MAX_ELTERN_STUFEN):
|
||||
if not aktuell:
|
||||
return ""
|
||||
if isdir(aktuell):
|
||||
return aktuell
|
||||
naechster = dirname(aktuell)
|
||||
if naechster == aktuell: # Wurzel erreicht
|
||||
return ""
|
||||
aktuell = naechster
|
||||
return ""
|
||||
|
||||
|
||||
def _erreichbarkeit_pruefen(raw_container: str, raw_lokal: str,
|
||||
final_container: str, final_lokal: str) -> str:
|
||||
"""Kann DIESER Worker Quelle und Ziel überhaupt sehen? Klartext oder "".
|
||||
@@ -731,17 +801,18 @@ def _erreichbarkeit_pruefen(raw_container: str, raw_lokal: str,
|
||||
fremder_worker = not os.path.isdir("/app")
|
||||
|
||||
for zweck, container, lokal in (
|
||||
("Quelle (Rohdaten)", raw_container, raw_lokal),
|
||||
("Ziel (fertige Datei)", final_container, final_lokal),
|
||||
("die Quelle (Rohdaten)", raw_container, raw_lokal),
|
||||
("das Ziel (fertige Datei)", final_container, final_lokal),
|
||||
):
|
||||
if os.path.isdir(lokal):
|
||||
continue
|
||||
# Ziel darf fehlen, solange sein Elternordner erreichbar ist — es wird
|
||||
# gleich angelegt. Nur ein unerreichbarer ELTERN-Pfad ist ein Problem.
|
||||
if zweck.startswith("Ziel") and os.path.isdir(os.path.dirname(lokal) or "."):
|
||||
# Das ZIEL darf fehlen — es wird gleich mit `os.makedirs` angelegt, und
|
||||
# zwar samt aller fehlenden Zwischenebenen. Es genügt also, dass
|
||||
# irgendein Vorfahre existiert (Herleitung in erster_vorhandener_ordner).
|
||||
if zweck.startswith("das Ziel") and erster_vorhandener_ordner(lokal):
|
||||
continue
|
||||
|
||||
text = [f"Dieser Worker erreicht die {zweck} nicht: {lokal}"]
|
||||
text = [f"Dieser Worker erreicht {zweck} nicht: {lokal}"]
|
||||
if fremder_worker and not mapping:
|
||||
text.append(
|
||||
"Ursache: RIPPY_PATH_MAP ist auf dieser Maschine nicht gesetzt. "
|
||||
@@ -754,13 +825,24 @@ def _erreichbarkeit_pruefen(raw_container: str, raw_lokal: str,
|
||||
"start-tray.bat setzen, z. B. "
|
||||
"set RIPPY_PATH_MAP=/app/media=\\\\NAS\\rippy-media"
|
||||
)
|
||||
elif fremder_worker and mapping and not _ist_uebersetzt(container, lokal):
|
||||
text.append(
|
||||
f"RIPPY_PATH_MAP ist gesetzt ({mapping}), deckt diesen Pfad aber "
|
||||
f'nicht ab: „{container}" wurde von keinem Eintrag übersetzt — '
|
||||
'fehlt ein Präfix, oder ist es ein anderes Verzeichnis als erwartet?'
|
||||
)
|
||||
elif fremder_worker and mapping:
|
||||
text.append(f"RIPPY_PATH_MAP ist gesetzt ({mapping}), deckt diesen Pfad aber nicht ab.")
|
||||
if not _ist_uebersetzt(container, lokal):
|
||||
text.append(
|
||||
f'„{container}" wurde von keinem Eintrag übersetzt — fehlt ein '
|
||||
'Präfix, oder ist es ein anderes Verzeichnis als erwartet?'
|
||||
)
|
||||
# ⚠️ Hier stand bis zum 26.07.2026 dieselbe Meldung wie oben — „deckt
|
||||
# diesen Pfad nicht ab" —, obwohl die Karte ihn nachweislich übersetzt
|
||||
# hatte (der UNC-Pfad stand im selben Satz). Wer der Meldung folgte,
|
||||
# suchte den Fehler in der Karte, während die Freigabe schlicht nicht
|
||||
# verbunden war. Eine falsche Ursache ist teurer als gar keine.
|
||||
text.append(
|
||||
f'Übersetzt wurde er korrekt ({container} → {lokal}, Karte: '
|
||||
f'{mapping}) — dieser Pfad ist auf diesem Rechner nur gerade '
|
||||
'nicht erreichbar. Ist die Freigabe verbunden? Anmeldedaten noch '
|
||||
'gültig? Von Hand prüfen: den Pfad im Explorer öffnen.'
|
||||
)
|
||||
else:
|
||||
text.append(
|
||||
"Der Pfad existiert nicht. Liegt das Arbeitsverzeichnis auf einer "
|
||||
@@ -817,12 +899,31 @@ def transcode_files(self, job_id: str, raw_dir: str, final_dir: str):
|
||||
preset = preset_fuer(disc_type, einstellungen)
|
||||
original_behalten = einstellungen.get("keepOriginal", False)
|
||||
|
||||
# Sprachauswahl: Wahl für DIESEN Rip (aus meta) schlägt die Einstellung.
|
||||
# Leer heißt „alles behalten" — genau das Verhalten von vorher.
|
||||
try:
|
||||
job_meta = json.loads(job.get("meta") or "{}")
|
||||
except ValueError:
|
||||
job_meta = {}
|
||||
audio_sprachen = sprachliste(
|
||||
job_meta.get("audio_sprachen") or einstellungen.get("audioSprachen"))
|
||||
untertitel_sprachen = sprachliste(
|
||||
job_meta.get("untertitel_sprachen") or einstellungen.get("untertitelSprachen"))
|
||||
|
||||
os.makedirs(final_dir, exist_ok=True)
|
||||
db.update_job(job_id, status="transcoding", progress=0, error=None)
|
||||
# Wer hier ankommt, hat vollständige Quelldateien — sonst wäre oben schon
|
||||
# abgebrochen worden. Das vermerkt die Marke auch für BESTANDSJOBS, die vor
|
||||
# ihrer Einführung gerippt wurden und sie darum noch nicht tragen.
|
||||
db.meta_merken(job_id, **{RIP_FERTIG: True})
|
||||
db.add_log(
|
||||
"info", "worker",
|
||||
f"Job {job_id}: Kompression gestartet ({len(quellen)} Datei(en), "
|
||||
f"Disc-Typ '{disc_type or 'unbekannt'}', Preset '{preset}')",
|
||||
f"Disc-Typ '{disc_type or 'unbekannt'}', Preset '{preset}'"
|
||||
+ (f", Ton: {','.join(audio_sprachen)}" if audio_sprachen else ", Ton: alle")
|
||||
+ (f", Untertitel: {','.join(untertitel_sprachen)}"
|
||||
if untertitel_sprachen else ", Untertitel: alle")
|
||||
+ ")",
|
||||
)
|
||||
|
||||
anzahl = len(quellen)
|
||||
@@ -865,6 +966,8 @@ def transcode_files(self, job_id: str, raw_dir: str, final_dir: str):
|
||||
quelle, ziel, preset=preset,
|
||||
progress_cb=datei_fortschritt,
|
||||
abbruch_cb=abbruch_pruefen,
|
||||
audio_sprachen=audio_sprachen,
|
||||
untertitel_sprachen=untertitel_sprachen,
|
||||
)
|
||||
if hb.get("status") == "cancelled":
|
||||
_job_abschliessen(job_id, hb)
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Tests für die Erreichbarkeits-Prüfung vor der Kompression.
|
||||
|
||||
Diese Prüfung hatte KEINEN einzigen Test — und genau deshalb ist sie am
|
||||
26.07.2026 im ersten vollständigen Durchlauf umgefallen: Der Rip lief sauber
|
||||
durch (40 GB Akira), die Kompression brach 0,2 s später ab mit „Dieser Worker
|
||||
erreicht das Ziel nicht", obwohl die Freigabe erreichbar war. Es fehlten nur
|
||||
zwei noch nie angelegte Ordner.
|
||||
"""
|
||||
|
||||
import tasks
|
||||
|
||||
|
||||
# --- Der eigentliche Fehler: nur EINE fehlende Ebene war erlaubt -------------
|
||||
|
||||
def _ohne_schluss_trenner(p):
|
||||
"""`\\\\NAS\\rippy\\` und `\\\\NAS\\rippy` sind derselbe Ordner.
|
||||
|
||||
Wichtig, weil `ntpath.dirname` für die UNC-Wurzel MIT abschließendem
|
||||
Backslash zurückkommt. Das echte `os.path.isdir` stört das nicht — eine
|
||||
Attrappe, die stur vergleicht, hätte hier einen Fehler erfunden, den es
|
||||
nicht gibt.
|
||||
"""
|
||||
return p.rstrip("\\/") or p
|
||||
|
||||
|
||||
def _fake_isdir(vorhandene):
|
||||
"""isdir-Ersatz, der nur die genannten Pfade kennt."""
|
||||
bekannt = {_ohne_schluss_trenner(p) for p in vorhandene}
|
||||
return lambda p: _ohne_schluss_trenner(p) in bekannt
|
||||
|
||||
|
||||
def test_findet_den_naechsten_vorhandenen_ordner():
|
||||
da = {"\\\\NAS\\rippy"}
|
||||
assert tasks.erster_vorhandener_ordner(
|
||||
"\\\\NAS\\rippy\\movies\\Akira (1988)",
|
||||
isdir=_fake_isdir(da), dirname=lambda p: p.rsplit("\\", 1)[0] if "\\" in p.strip("\\") else "",
|
||||
) == "\\\\NAS\\rippy"
|
||||
|
||||
|
||||
def test_zwei_fehlende_ebenen_sind_in_ordnung():
|
||||
"""DER Vorfall: `movies` UND der Filmordner fehlten beide.
|
||||
|
||||
`os.makedirs` legt die ganze Kette an — es genügt also, dass irgendein
|
||||
Vorfahre existiert. Die alte Prüfung sah nur nach `dirname` und erklärte
|
||||
damit jeden ERSTEN Film in einer neuen Ablage für unerreichbar.
|
||||
"""
|
||||
da = {"/share"}
|
||||
assert tasks.erster_vorhandener_ordner(
|
||||
"/share/movies/Akira (1988)", isdir=_fake_isdir(da)) == "/share"
|
||||
|
||||
|
||||
def test_gar_nichts_erreichbar_gibt_leer():
|
||||
"""Ist die Freigabe wirklich weg, muss die Prüfung anschlagen."""
|
||||
assert tasks.erster_vorhandener_ordner(
|
||||
"/share/movies/Film", isdir=_fake_isdir(set())) == ""
|
||||
|
||||
|
||||
def test_sucht_nicht_endlos_auf_einer_toten_freigabe():
|
||||
"""Jeder Schritt kostet auf einer toten Freigabe einen Timeout."""
|
||||
gefragt = []
|
||||
|
||||
def zaehlend(p):
|
||||
gefragt.append(p)
|
||||
return False
|
||||
|
||||
tiefer_pfad = "/" + "/".join(f"e{i}" for i in range(50))
|
||||
assert tasks.erster_vorhandener_ordner(tiefer_pfad, isdir=zaehlend) == ""
|
||||
assert len(gefragt) <= tasks.MAX_ELTERN_STUFEN
|
||||
|
||||
|
||||
def test_leerer_pfad_stuerzt_nicht_ab():
|
||||
assert tasks.erster_vorhandener_ordner("", isdir=_fake_isdir(set())) == ""
|
||||
|
||||
|
||||
# --- Die Meldung: eine falsche Ursache ist teurer als gar keine -------------
|
||||
|
||||
def _pruefen(monkeypatch, vorhandene, mapping, im_container=False):
|
||||
import ntpath
|
||||
kennt = _fake_isdir(vorhandene)
|
||||
monkeypatch.setattr(tasks.os.path, "isdir",
|
||||
lambda p: kennt(p) or (im_container and p == "/app"))
|
||||
monkeypatch.setattr(tasks.os.path, "dirname", ntpath.dirname)
|
||||
monkeypatch.setenv("RIPPY_PATH_MAP", mapping)
|
||||
return tasks._erreichbarkeit_pruefen(
|
||||
"/app/media/rippy/job1", "\\\\NAS\\rippy\\job1",
|
||||
"/app/media/rippy/movies/Akira (1988)", "\\\\NAS\\rippy\\movies\\Akira (1988)",
|
||||
)
|
||||
|
||||
|
||||
def test_alles_da_meldet_nichts(monkeypatch):
|
||||
assert _pruefen(monkeypatch, {"\\\\NAS\\rippy\\job1", "\\\\NAS\\rippy\\movies\\Akira (1988)"},
|
||||
"/app/media/rippy=\\\\NAS\\rippy") == ""
|
||||
|
||||
|
||||
def test_ziel_fehlt_ganz_ist_kein_fehler(monkeypatch):
|
||||
"""Der Vorfall vom 26.07.2026 — jetzt geht er durch."""
|
||||
assert _pruefen(monkeypatch, {"\\\\NAS\\rippy\\job1", "\\\\NAS\\rippy"},
|
||||
"/app/media/rippy=\\\\NAS\\rippy") == ""
|
||||
|
||||
|
||||
def test_fehlende_quelle_bleibt_ein_fehler(monkeypatch):
|
||||
meldung = _pruefen(monkeypatch, {"\\\\NAS\\rippy"}, "/app/media/rippy=\\\\NAS\\rippy")
|
||||
assert "Quelle" in meldung
|
||||
assert "Rohdaten sind NICHT verloren" in meldung
|
||||
|
||||
|
||||
def test_uebersetzter_aber_unerreichbarer_pfad_beschuldigt_nicht_die_karte(monkeypatch):
|
||||
"""⚠️ Genau die Falschaussage aus dem Vorfall.
|
||||
|
||||
Die Meldung lautete „RIPPY_PATH_MAP … deckt diesen Pfad aber nicht ab",
|
||||
während sie ihn im selben Satz korrekt übersetzt hatte. Wer dem folgte,
|
||||
suchte in der Karte statt in der Freigabe.
|
||||
"""
|
||||
meldung = _pruefen(monkeypatch, set(), "/app/media/rippy=\\\\NAS\\rippy")
|
||||
assert "deckt diesen Pfad aber nicht ab" not in meldung
|
||||
assert "Übersetzt wurde er korrekt" in meldung
|
||||
assert "Freigabe verbunden" in meldung
|
||||
|
||||
|
||||
def test_nicht_uebersetzter_pfad_beschuldigt_sehr_wohl_die_karte(monkeypatch):
|
||||
"""Hier ist die Karte wirklich schuld — der Pfad blieb unverändert."""
|
||||
monkeypatch.setattr(tasks.os.path, "isdir", lambda p: False)
|
||||
monkeypatch.setenv("RIPPY_PATH_MAP", "/app/temp=Y:\\temp")
|
||||
meldung = tasks._erreichbarkeit_pruefen(
|
||||
"/app/media/rippy/job1", "/app/media/rippy/job1",
|
||||
"/app/media/movies/X", "/app/media/movies/X",
|
||||
)
|
||||
assert "deckt diesen Pfad aber nicht ab" in meldung
|
||||
assert "von keinem Eintrag übersetzt" in meldung
|
||||
|
||||
|
||||
def test_ohne_mapping_nennt_die_wahre_ursache(monkeypatch):
|
||||
meldung = _pruefen(monkeypatch, set(), "")
|
||||
assert "nicht gesetzt" in meldung
|
||||
assert "RIPPY_PATH_MAP" in meldung
|
||||
|
||||
|
||||
def test_artikel_stimmt_in_beiden_meldungen(monkeypatch):
|
||||
"""„erreicht die Ziel" stand wörtlich im Log des Commanders."""
|
||||
quelle = _pruefen(monkeypatch, set(), "")
|
||||
assert "erreicht die Quelle" in quelle
|
||||
# Quelle da, Ziel unerreichbar (kein einziger Vorfahre) → Ziel-Meldung
|
||||
ziel = _pruefen(monkeypatch, {"\\\\NAS\\rippy\\job1"},
|
||||
"/app/media/rippy=\\\\NAS\\rippy")
|
||||
assert "erreicht das Ziel" in ziel
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Tests der Log-Brücke — reine Funktionen, keine Datenbank, keine Uhr.
|
||||
|
||||
Die Beispielzeilen sind WÖRTLICH aus dem laufenden Worker-Container abgenommen
|
||||
(26.07.2026, Celery 5.4.0) — AGENTS Regel D. Erfundene Formate hätten hier
|
||||
keinen Wert: Genau am geratenen Format scheitert so eine Brücke.
|
||||
"""
|
||||
|
||||
import logbruecke
|
||||
|
||||
# Aus `docker compose logs worker`, unverändert.
|
||||
ECHT_CONNECTED = "[2026-07-26 12:04:18,060: INFO/MainProcess] Connected to redis://redis:6379/0"
|
||||
ECHT_MINGLE = "[2026-07-26 12:04:18,063: INFO/MainProcess] mingle: searching for neighbors"
|
||||
ECHT_ALLEIN = "[2026-07-26 12:04:19,071: INFO/MainProcess] mingle: all alone"
|
||||
ECHT_READY = "[2026-07-26 12:04:19,082: INFO/MainProcess] celery@ced31798928d ready."
|
||||
ECHT_WARNUNG = (
|
||||
"[2026-07-26 12:06:19,094: WARNING/MainProcess] Zombie-Erkennung: "
|
||||
"{'geprueft': 0, 'aufgeraeumt': [], 'uebersprungen': ''}"
|
||||
)
|
||||
ECHT_BANNER = " -------------- celery@ced31798928d v5.4.0 (opalescent)"
|
||||
|
||||
|
||||
def test_ready_ist_die_meldung_auf_die_man_wartet():
|
||||
"""Wenn der Worker nichts tut, ist „ready." die Zeile, die man sehen will —
|
||||
deshalb als Erfolg, nicht als Info im Grundrauschen."""
|
||||
assert logbruecke.einordnen(ECHT_READY) == ("success", "celery@ced31798928d ready.")
|
||||
|
||||
|
||||
def test_verbindung_kommt_durch():
|
||||
level, text = logbruecke.einordnen(ECHT_CONNECTED)
|
||||
assert level == "info"
|
||||
assert "redis" in text
|
||||
|
||||
|
||||
def test_warnungen_und_fehler_kommen_immer_durch():
|
||||
level, text = logbruecke.einordnen(ECHT_WARNUNG)
|
||||
assert level == "warning"
|
||||
assert "Zombie-Erkennung" in text
|
||||
assert logbruecke.einordnen(
|
||||
"[2026-07-26 12:00:00,000: ERROR/MainProcess] kaputt")[0] == "error"
|
||||
assert logbruecke.einordnen(
|
||||
"[2026-07-26 12:00:00,000: CRITICAL/MainProcess] ganz kaputt")[0] == "error"
|
||||
|
||||
|
||||
def test_grundrauschen_wird_verworfen():
|
||||
"""Celery ist bei --loglevel=info gesprächig, und die logs-Tabelle hat keine
|
||||
automatische Aufräumung. Ein zugemülltes Log ist so unbrauchbar wie keins."""
|
||||
assert logbruecke.einordnen(ECHT_MINGLE) is None
|
||||
assert logbruecke.einordnen(ECHT_ALLEIN) is None
|
||||
assert logbruecke.einordnen(ECHT_BANNER) is None
|
||||
assert logbruecke.einordnen("") is None
|
||||
assert logbruecke.einordnen(None) is None
|
||||
assert logbruecke.einordnen(
|
||||
"[2026-07-26 12:00:00,000: DEBUG/MainProcess] kleinteiliges Zeug") is None
|
||||
|
||||
|
||||
def test_absturz_ohne_celery_klammer_kommt_durch():
|
||||
"""Ein Traceback trägt kein Celery-Präfix — und ist das Wichtigste, was
|
||||
passieren kann."""
|
||||
assert logbruecke.einordnen("Traceback (most recent call last):")[0] == "error"
|
||||
assert logbruecke.einordnen(
|
||||
"ConnectionRefusedError: [Errno 111] Connection refused")[0] == "error"
|
||||
|
||||
|
||||
def test_quelle_wird_auf_32_zeichen_gekappt():
|
||||
"""`logs.source` ist String(32). Ohne Kappen scheitert das Insert STILL bei
|
||||
einem langen Worker-Namen — und dann fehlt genau das Log, das man sucht."""
|
||||
assert logbruecke.quelle_fuer("tobisnicerpc") == "w:tobisnicerpc"
|
||||
lang = logbruecke.quelle_fuer("x" * 100)
|
||||
assert len(lang) == 32
|
||||
assert lang.startswith("w:")
|
||||
assert logbruecke.quelle_fuer("") == "w:worker"
|
||||
assert logbruecke.quelle_fuer(None) == "w:worker"
|
||||
|
||||
|
||||
# --- Drosselung -------------------------------------------------------------
|
||||
|
||||
|
||||
def _bruecke(uhr):
|
||||
geschrieben = []
|
||||
b = logbruecke.Bruecke(
|
||||
"testpc",
|
||||
schreiber=lambda level, quelle, text: geschrieben.append((level, text)),
|
||||
jetzt=lambda: uhr[0],
|
||||
)
|
||||
return b, geschrieben
|
||||
|
||||
|
||||
def test_drosselung_haelt_bei_30_zeilen_die_minute():
|
||||
uhr = [0.0]
|
||||
b, geschrieben = _bruecke(uhr)
|
||||
for i in range(40):
|
||||
b.zeile(f"[2026-07-26 12:00:00,000: ERROR/MainProcess] Fehler {i}")
|
||||
assert len(geschrieben) == logbruecke.MAX_ZEILEN_JE_MINUTE
|
||||
|
||||
|
||||
def test_naechste_minute_meldet_wieviel_verschluckt_wurde():
|
||||
"""Stilles Verschlucken wäre genau der Fehler, den diese Sitzung schon
|
||||
einmal eine Stunde gekostet hat."""
|
||||
uhr = [0.0]
|
||||
b, geschrieben = _bruecke(uhr)
|
||||
for i in range(40):
|
||||
b.zeile(f"[2026-07-26 12:00:00,000: ERROR/MainProcess] Fehler {i}")
|
||||
uhr[0] = 61.0
|
||||
b.zeile("[2026-07-26 12:01:01,000: ERROR/MainProcess] naechster Fehler")
|
||||
|
||||
unterdrueckt = [t for lvl, t in geschrieben if "unterdrückt" in t]
|
||||
assert len(unterdrueckt) == 1
|
||||
assert "10 weitere" in unterdrueckt[0]
|
||||
|
||||
|
||||
def test_kaputter_schreiber_bringt_den_worker_nicht_um():
|
||||
"""Rippy nicht erreichbar → die lokale Log-Datei bleibt der Rückfall. Der
|
||||
Worker muss weiterarbeiten."""
|
||||
def kaputt(level, quelle, text):
|
||||
raise RuntimeError("Datenbank weg")
|
||||
|
||||
b = logbruecke.Bruecke("testpc", schreiber=kaputt, jetzt=lambda: 0.0)
|
||||
assert b.zeile("[2026-07-26 12:00:00,000: ERROR/MainProcess] Fehler") is False
|
||||
|
||||
|
||||
def test_verworfene_zeilen_zaehlen_nicht_gegen_die_drossel():
|
||||
uhr = [0.0]
|
||||
b, geschrieben = _bruecke(uhr)
|
||||
for _ in range(100):
|
||||
b.zeile(ECHT_MINGLE)
|
||||
b.zeile(ECHT_READY)
|
||||
assert geschrieben == [("success", "celery@ced31798928d ready.")]
|
||||
@@ -38,7 +38,8 @@ def test_handbrake_cmd_arbeitet_auf_datei_nicht_geraet():
|
||||
assert cmd[cmd.index("--input") + 1] == "/app/temp/raw/x/t00.mkv"
|
||||
assert cmd[cmd.index("--output") + 1] == "/app/media/bluray/x/t00.mkv"
|
||||
assert "--preset" in cmd
|
||||
assert "--all-audio" in cmd # alle Sprachen behalten
|
||||
assert "--first-audio" in cmd # beste Spur pro Sprache behalten
|
||||
assert "--audio-codec" in cmd
|
||||
assert "--all-subtitles" in cmd
|
||||
|
||||
|
||||
@@ -380,3 +381,215 @@ def test_fehler_ohne_preset_problem_bleibt_der_alte():
|
||||
|
||||
ergebnis = ripping._handbrake_schleife(FakeProcess(), "/gibt-es-nicht.mkv")
|
||||
assert ergebnis["error"] == "HandBrake endete mit Code 1"
|
||||
|
||||
|
||||
# --- Auswurf: das ioctl meldet Erfolg und tut nichts (Befund 26.07.2026) -----
|
||||
|
||||
|
||||
def _laufwerk(verriegelt=True, kennt_lockdoor=True):
|
||||
"""Ein nachgebautes Laufwerk, das sich wie das echte verhaelt.
|
||||
|
||||
Gemessen am BU40N der Rippy-VM: Nach einem MakeMKV-Rip ist die Tuer
|
||||
verriegelt. CDROMEJECT wird dann ANGENOMMEN und tut nichts - der Status
|
||||
bleibt auf 4 (Disc drin). Erst CDROM_LOCKDOOR 0 macht den Auswurf wirksam.
|
||||
"""
|
||||
import ripping
|
||||
|
||||
zustand = {"verriegelt": verriegelt, "status": ripping.CDS_DISC_OK,
|
||||
"aufrufe": []}
|
||||
|
||||
def ioctl_fn(fd, befehl, arg=0):
|
||||
zustand["aufrufe"].append(befehl)
|
||||
if befehl == ripping.CDROM_LOCKDOOR:
|
||||
if not kennt_lockdoor:
|
||||
raise OSError("ioctl unbekannt")
|
||||
zustand["verriegelt"] = bool(arg)
|
||||
return 0
|
||||
if befehl == ripping.CDROMEJECT:
|
||||
if not zustand["verriegelt"]:
|
||||
zustand["status"] = ripping.CDS_TRAY_OPEN
|
||||
return 0 # <- auch verriegelt: ERFOLG, aber ohne Wirkung
|
||||
if befehl == ripping.CDROM_DRIVE_STATUS:
|
||||
return zustand["status"]
|
||||
raise OSError("unerwartetes ioctl")
|
||||
|
||||
return zustand, ioctl_fn
|
||||
|
||||
|
||||
def test_auswurf_entriegelt_zuerst_und_klappt_dann():
|
||||
import ripping
|
||||
|
||||
zustand, ioctl_fn = _laufwerk(verriegelt=True)
|
||||
ok = ripping.wirf_disc_aus(
|
||||
"/dev/sr0", ioctl_fn=ioctl_fn, oeffnen=lambda p: 42,
|
||||
schliessen=lambda fd: None, warten=lambda s: None)
|
||||
assert ok is True
|
||||
assert zustand["status"] == ripping.CDS_TRAY_OPEN
|
||||
# Reihenfolge: entriegeln VOR auswerfen
|
||||
assert zustand["aufrufe"][0] == ripping.CDROM_LOCKDOOR
|
||||
assert zustand["aufrufe"][1] == ripping.CDROMEJECT
|
||||
|
||||
|
||||
def test_auswurf_meldet_fehlschlag_wenn_die_disc_drin_bleibt():
|
||||
"""Der eigentliche Fehler. Vorher gab wirf_disc_aus True zurueck, weil das
|
||||
ioctl nicht geworfen hatte - und ins Log kam "Disc ausgeworfen", waehrend
|
||||
die Schublade zu blieb. Ein ioctl-Rueckgabewert beweist nichts."""
|
||||
import ripping
|
||||
|
||||
# Ein Laufwerk, das LOCKDOOR nicht kennt und verriegelt bleibt
|
||||
zustand, ioctl_fn = _laufwerk(verriegelt=True, kennt_lockdoor=False)
|
||||
ok = ripping.wirf_disc_aus(
|
||||
"/dev/sr0", ioctl_fn=ioctl_fn, oeffnen=lambda p: 42,
|
||||
schliessen=lambda fd: None, warten=lambda s: None)
|
||||
assert ok is False
|
||||
assert zustand["status"] == ripping.CDS_DISC_OK # nie aufgegangen
|
||||
|
||||
|
||||
def test_auswurf_bei_slot_laufwerk_ohne_schublade():
|
||||
"""Ein Slot-Laufwerk hat keine Schublade und meldet nach dem Auswerfen
|
||||
CDS_NO_DISC. Das muss als Erfolg zaehlen."""
|
||||
import ripping
|
||||
|
||||
assert ripping._auswurf_geglueckt(ripping.CDS_NO_DISC) is True
|
||||
assert ripping._auswurf_geglueckt(ripping.CDS_TRAY_OPEN) is True
|
||||
assert ripping._auswurf_geglueckt(ripping.CDS_DISC_OK) is False
|
||||
assert ripping._auswurf_geglueckt(ripping.CDS_DRIVE_NOT_READY) is False
|
||||
|
||||
|
||||
def test_auswurf_ohne_laufwerk_wirft_nicht():
|
||||
import ripping
|
||||
|
||||
def oeffnen_kaputt(p):
|
||||
raise OSError("kein Laufwerk")
|
||||
|
||||
assert ripping.wirf_disc_aus(
|
||||
"/dev/sr9", ioctl_fn=lambda *a: 0, oeffnen=oeffnen_kaputt,
|
||||
schliessen=lambda fd: None, warten=lambda s: None) is False
|
||||
|
||||
|
||||
# --- Sprachen der Disc: gemessen an der Akira-Blu-ray (26.07.2026) -----------
|
||||
#
|
||||
# Woertlich aus `makemkvcon -r --noscan info dev:/dev/sr0` im Worker-Container
|
||||
# der Rippy-VM, gekuerzt auf die Attribute, die Rippy liest (AGENTS Regel D).
|
||||
|
||||
SINFO_ECHT = """SINFO:0,0,1,6201,"Video"
|
||||
SINFO:0,0,19,0,"1920x1080"
|
||||
SINFO:0,0,28,0,"eng"
|
||||
SINFO:0,0,29,0,"English"
|
||||
SINFO:0,1,1,6202,"Audio"
|
||||
SINFO:0,1,3,0,"deu"
|
||||
SINFO:0,1,4,0,"German"
|
||||
SINFO:0,1,6,0,"DTS-HD MA"
|
||||
SINFO:0,1,14,0,"2"
|
||||
SINFO:0,1,28,0,"eng"
|
||||
SINFO:0,1,29,0,"English"
|
||||
SINFO:0,1,30,0,"DTS-HD MA Stereo German"
|
||||
SINFO:0,2,1,6202,"Audio"
|
||||
SINFO:0,2,3,0,"jpn"
|
||||
SINFO:0,2,4,0,"Japanese"
|
||||
SINFO:0,2,6,0,"DTS-HD MA"
|
||||
SINFO:0,2,14,0,"6"
|
||||
SINFO:0,2,30,0,"DTS-HD MA 5.1 Japanese"
|
||||
SINFO:0,3,1,6203,"Subtitles"
|
||||
SINFO:0,3,3,0,"deu"
|
||||
SINFO:0,3,4,0,"German"
|
||||
SINFO:0,3,6,0,"PGS"
|
||||
SINFO:1,0,1,6202,"Audio"
|
||||
SINFO:1,0,3,0,"eng"
|
||||
SINFO:1,0,4,0,"English"
|
||||
SINFO:1,0,6,0,"AC3"
|
||||
"""
|
||||
|
||||
|
||||
def test_streams_werden_nach_typ_getrennt():
|
||||
from ripping import parse_stream_info
|
||||
|
||||
je_titel = parse_stream_info(SINFO_ECHT)
|
||||
assert set(je_titel) == {0, 1}
|
||||
assert [a["lang"] for a in je_titel[0]["audio"]] == ["deu", "jpn"]
|
||||
assert [u["lang"] for u in je_titel[0]["untertitel"]] == ["deu"]
|
||||
assert je_titel[0]["audio"][1]["kanaele"] == "6"
|
||||
assert je_titel[0]["audio"][0]["codec"] == "DTS-HD MA"
|
||||
|
||||
|
||||
def test_die_sprache_steht_in_attribut_3_nicht_28():
|
||||
"""DIE Falle: 28/29 tragen auf JEDEM Stream "eng"/"English" - auch auf einer
|
||||
deutschen Tonspur und auf dem Videostream. Das ist MakeMKVs Anzeigesprache.
|
||||
Wer 28 nimmt, haelt jede Disc fuer englisch."""
|
||||
from ripping import parse_stream_info
|
||||
|
||||
deutsche_spur = parse_stream_info(SINFO_ECHT)[0]["audio"][0]
|
||||
assert deutsche_spur["lang"] == "deu"
|
||||
assert deutsche_spur["sprache"] == "German"
|
||||
|
||||
|
||||
def test_video_taucht_nicht_als_tonspur_auf():
|
||||
from ripping import parse_stream_info
|
||||
|
||||
je_titel = parse_stream_info(SINFO_ECHT)
|
||||
assert all(a["typ"] == "Audio" for a in je_titel[0]["audio"])
|
||||
|
||||
|
||||
def test_sprachen_werden_ueber_alle_titel_vereinigt():
|
||||
"""Vor dem Rip zaehlt nicht "Titel 3 Stream 7", sondern was die DISC hat."""
|
||||
from ripping import parse_stream_info, sprachen_zusammenfassen
|
||||
|
||||
s = sprachen_zusammenfassen(parse_stream_info(SINFO_ECHT))
|
||||
assert [a["lang"] for a in s["audio"]] == ["deu", "jpn", "eng"]
|
||||
assert [a["sprache"] for a in s["audio"]] == ["German", "Japanese", "English"]
|
||||
assert [u["lang"] for u in s["untertitel"]] == ["deu"]
|
||||
# deu kommt in Titel 0 einmal vor
|
||||
assert s["audio"][0]["spuren"] == 1
|
||||
|
||||
|
||||
def test_leere_ausgabe_gibt_leere_sprachen():
|
||||
from ripping import parse_stream_info, sprachen_zusammenfassen
|
||||
|
||||
assert parse_stream_info("") == {}
|
||||
assert sprachen_zusammenfassen({}) == {"audio": [], "untertitel": []}
|
||||
assert sprachen_zusammenfassen(None) == {"audio": [], "untertitel": []}
|
||||
|
||||
|
||||
# --- Sprachliste aus den Einstellungen --------------------------------------
|
||||
|
||||
|
||||
def test_sprachliste_raeumt_auf():
|
||||
from ripping import sprachliste
|
||||
|
||||
assert sprachliste("deu, eng , ,DEU") == ["deu", "eng"]
|
||||
assert sprachliste(["deu", "deu", "jpn"]) == ["deu", "jpn"]
|
||||
assert sprachliste("") == []
|
||||
assert sprachliste(None) == []
|
||||
|
||||
|
||||
def test_grossschreibung_macht_die_auswahl_nicht_unwirksam():
|
||||
"""HandBrake will ISO-639-2 klein. Ein "DEU" aus einem Eingabefeld darf nicht
|
||||
still die ganze Auswahl aushebeln."""
|
||||
from ripping import sprachliste
|
||||
|
||||
assert sprachliste("DEU,ENG") == ["deu", "eng"]
|
||||
|
||||
|
||||
# --- Das HandBrake-Kommando -------------------------------------------------
|
||||
|
||||
|
||||
def test_handbrake_kommando_ohne_auswahl_bleibt_wie_bisher():
|
||||
from ripping import build_handbrake_cmd
|
||||
|
||||
cmd = build_handbrake_cmd("/a.mkv", "/b.mkv", "H.265 MKV 1080p30")
|
||||
assert "--audio-lang-list" not in cmd
|
||||
assert "--subtitle-lang-list" not in cmd
|
||||
assert "--first-audio" in cmd and "--all-subtitles" in cmd
|
||||
|
||||
|
||||
def test_handbrake_kommando_mit_sprachauswahl():
|
||||
"""--audio-lang-list zusammen mit --first-audio heisst laut HandBrake-Hilfe:
|
||||
nur die erste (beste) Tonspur, die zu einer der Sprachen passen."""
|
||||
from ripping import build_handbrake_cmd
|
||||
|
||||
cmd = build_handbrake_cmd("/a.mkv", "/b.mkv", "H.265 MKV 1080p30",
|
||||
audio_sprachen=["deu", "jpn"],
|
||||
untertitel_sprachen=["deu"])
|
||||
assert cmd[cmd.index("--audio-lang-list") + 1] == "deu,jpn"
|
||||
assert cmd[cmd.index("--subtitle-lang-list") + 1] == "deu"
|
||||
assert "--first-audio" in cmd and "--all-subtitles" in cmd
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Tests der Schlüssel-Automatik — ohne MakeMKV, ohne Laufwerk, ohne Netz.
|
||||
|
||||
Die Testdaten sind gemessen (26.07.2026, Commander-PC, MakeMKV 1.18.4):
|
||||
`makemkvcon -r --cache=1 info disc:9999` auf einer Maschine OHNE optisches
|
||||
Laufwerk gibt sechzehn leere `DRV:`-Zeilen. Die BELEGTE Form ließ sich dort nicht
|
||||
messen — deshalb prüfen die Tests vor allem, dass die Automatik im Zweifel
|
||||
NICHTS tut.
|
||||
"""
|
||||
|
||||
import schluessel
|
||||
|
||||
# Wörtlich aus dem echten Lauf (gekürzt auf drei der sechzehn Zeilen).
|
||||
DRV_LEER = """MSG:1005,0,1,"MakeMKV v1.18.4 win(x64-release) gestartet","%1 gestartet","MakeMKV v1.18.4 win(x64-release)"
|
||||
MSG:5042,0,0,"Das Programm konnte keine verwendbaren optischen Laufwerke finden.","Das Programm konnte keine verwendbaren optischen Laufwerke finden."
|
||||
DRV:0,256,999,0,"","",""
|
||||
DRV:1,256,999,0,"","",""
|
||||
DRV:2,256,999,0,"","",""
|
||||
MSG:5010,0,0,"Das Öffnen der Disk schlug fehl ","Das Öffnen der Disk schlug fehl "
|
||||
TCOUNT:0
|
||||
"""
|
||||
|
||||
# So sieht eine belegte Zeile AUS SICHT DES FORMATS aus — nicht gemessen,
|
||||
# deshalb steht sie nur hier im Test und nicht als Annahme im Code.
|
||||
DRV_MIT_DISC = """DRV:0,2,999,12,"BD-RE HL-DT-ST BU40N","AKIRA","D:"
|
||||
DRV:1,256,999,0,"","",""
|
||||
"""
|
||||
|
||||
|
||||
def test_leere_laufwerke_ergeben_nichts():
|
||||
"""Der gemessene Fall: kein optisches Laufwerk → die Automatik tut nichts."""
|
||||
assert schluessel.parse_laufwerke(DRV_LEER) == []
|
||||
|
||||
|
||||
def test_kein_disc_name_gilt_als_nichts_da():
|
||||
"""Konservativ mit Absicht: Ein falsch-negativ ist harmlos (der Wächter
|
||||
greift trotzdem), ein falsch-positiv wäre ein makemkvcon-Lauf ins Leere."""
|
||||
assert schluessel.parse_laufwerke('DRV:0,2,999,12,"BD-RE BU40N","",""') == []
|
||||
|
||||
|
||||
def test_belegtes_laufwerk_wird_gefunden():
|
||||
gefunden = schluessel.parse_laufwerke(DRV_MIT_DISC)
|
||||
assert len(gefunden) == 1
|
||||
assert gefunden[0]["index"] == 0
|
||||
assert gefunden[0]["disc"] == "AKIRA"
|
||||
assert gefunden[0]["geraet"] == "D:"
|
||||
|
||||
|
||||
def test_muell_und_leer_stuerzen_nicht_ab():
|
||||
assert schluessel.parse_laufwerke("") == []
|
||||
assert schluessel.parse_laufwerke(None) == []
|
||||
assert schluessel.parse_laufwerke("irgendwas ganz anderes") == []
|
||||
|
||||
|
||||
# --- Wann hochgeladen wird --------------------------------------------------
|
||||
|
||||
|
||||
def test_erste_runde_laedt_immer_hoch():
|
||||
"""Rippy soll den Bestand kennen, auch wenn MakeMKV gerade nichts Neues
|
||||
gelernt hat."""
|
||||
assert schluessel.hat_sich_geaendert(None, (6420480, 1753000000)) is True
|
||||
|
||||
|
||||
def test_unveraenderte_datei_wird_nicht_wieder_geschickt():
|
||||
stand = (6420480, 1753000000)
|
||||
assert schluessel.hat_sich_geaendert(stand, stand) is False
|
||||
|
||||
|
||||
def test_geaenderte_datei_wird_geschickt():
|
||||
assert schluessel.hat_sich_geaendert(
|
||||
(6420480, 1753000000), (6500000, 1753009999)) is True
|
||||
|
||||
|
||||
def test_fehlende_datei_loest_keinen_upload_aus():
|
||||
"""(0, 0) heißt „gibt es nicht" — dann gibt es auch nichts zu schicken."""
|
||||
assert schluessel.hat_sich_geaendert(None, (0, 0)) is False
|
||||
assert schluessel.hat_sich_geaendert((6420480, 1753000000), (0, 0)) is False
|
||||
|
||||
|
||||
# --- Der Upload -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_upload_schickt_den_rohen_koerper(tmp_path):
|
||||
"""Kein JSON, kein Multipart: genau so nimmt POST /system/keystore die Datei
|
||||
an (der API fehlt python-multipart)."""
|
||||
datei = tmp_path / "_private_data.tar"
|
||||
datei.write_bytes(b"TAR-INHALT")
|
||||
gesehen = {}
|
||||
|
||||
class Antwort:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return b""
|
||||
|
||||
def oeffner(anfrage, timeout=None):
|
||||
gesehen["url"] = anfrage.full_url
|
||||
gesehen["daten"] = anfrage.data
|
||||
gesehen["typ"] = anfrage.headers.get("Content-type")
|
||||
return Antwort()
|
||||
|
||||
assert schluessel.hochladen("1.2.3.4", str(datei), oeffner=oeffner) == ""
|
||||
assert gesehen["url"] == "http://1.2.3.4/api/system/keystore"
|
||||
assert gesehen["daten"] == b"TAR-INHALT"
|
||||
assert gesehen["typ"] == "application/octet-stream"
|
||||
|
||||
|
||||
def test_upload_ohne_adresse_oder_datei_meldet_klartext():
|
||||
assert "Adresse" in schluessel.hochladen("", "/x.tar")
|
||||
assert "Schlüsseldatei" in schluessel.hochladen("1.2.3.4", "")
|
||||
|
||||
|
||||
def test_upload_fehler_wird_gemeldet_nicht_geworfen(tmp_path):
|
||||
"""Der Worker muss weiterarbeiten, auch wenn Rippy gerade weg ist."""
|
||||
datei = tmp_path / "_private_data.tar"
|
||||
datei.write_bytes(b"x")
|
||||
|
||||
def kaputt(anfrage, timeout=None):
|
||||
raise OSError("Netz weg")
|
||||
|
||||
fehler = schluessel.hochladen("1.2.3.4", str(datei), oeffner=kaputt)
|
||||
assert "Hochladen fehlgeschlagen" in fehler
|
||||
|
||||
|
||||
# --- Eine ganze Runde -------------------------------------------------------
|
||||
|
||||
|
||||
def test_runde_ohne_makemkv_tut_nichts():
|
||||
"""Auf einem PC ohne MakeMKV darf die Automatik nicht herumfummeln."""
|
||||
stand, was = schluessel.runde("1.2.3.4", None, programm="")
|
||||
assert (stand, was) == (None, "kein-makemkv")
|
||||
|
||||
|
||||
def test_runde_stoesst_erst_an_und_prueft_dann(monkeypatch, tmp_path):
|
||||
"""Reihenfolge mit Absicht: Ein gerade geholter Schluessel soll in DERSELBEN
|
||||
Runde mitgenommen werden, nicht erst eine Minute spaeter."""
|
||||
datei = tmp_path / "_private_data.tar"
|
||||
datei.write_bytes(b"neu")
|
||||
monkeypatch.setattr(schluessel, "schluesseldatei", lambda: str(datei))
|
||||
|
||||
ablauf = []
|
||||
|
||||
def laufen(cmd, **kwargs):
|
||||
# disc:9999 = Laufwerke auflisten, disc:0 = Disc oeffnen
|
||||
ablauf.append(cmd[-1])
|
||||
|
||||
class E:
|
||||
stdout = DRV_MIT_DISC if cmd[-1] == "disc:9999" else ""
|
||||
return E()
|
||||
|
||||
class Antwort:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return b""
|
||||
|
||||
meldungen = []
|
||||
stand, was = schluessel.runde(
|
||||
"1.2.3.4", None, melden=lambda lvl, t: meldungen.append((lvl, t)),
|
||||
programm="makemkvcon.exe", laufen=laufen,
|
||||
oeffner=lambda a, timeout=None: Antwort())
|
||||
|
||||
assert ablauf == ["disc:9999", "disc:0"]
|
||||
assert was == "hochgeladen"
|
||||
assert stand == schluessel.datei_stand(str(datei))
|
||||
assert any("Disc erkannt" in t for _, t in meldungen)
|
||||
assert any("wirkt ab dem nächsten Rip" in t for _, t in meldungen)
|
||||
|
||||
|
||||
def test_runde_ohne_aenderung_schickt_nichts(monkeypatch, tmp_path):
|
||||
datei = tmp_path / "_private_data.tar"
|
||||
datei.write_bytes(b"alt")
|
||||
monkeypatch.setattr(schluessel, "schluesseldatei", lambda: str(datei))
|
||||
vorher = schluessel.datei_stand(str(datei))
|
||||
|
||||
def laufen(cmd, **kwargs):
|
||||
class E:
|
||||
stdout = DRV_LEER
|
||||
return E()
|
||||
|
||||
def darf_nicht(anfrage, timeout=None):
|
||||
raise AssertionError("kein Upload erwartet")
|
||||
|
||||
stand, was = schluessel.runde(
|
||||
"1.2.3.4", vorher, programm="makemkvcon.exe", laufen=laufen,
|
||||
oeffner=darf_nicht)
|
||||
assert (stand, was) == (vorher, "unveraendert")
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Tests des Verwaltungsfensters — nur die Logik, nicht die Oberfläche.
|
||||
|
||||
Die Oberfläche selbst (tkinter) ist ausgenommen; geprüft wird, was das Fenster
|
||||
ANZEIGT: Zustand des Workers, Aufgabentexte, Log-Ausschnitt.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import verwaltung
|
||||
|
||||
|
||||
# --- Was Rippy über diesen Worker weiß --------------------------------------
|
||||
|
||||
|
||||
CAPS = {"workers": [
|
||||
{"name": "rippy-hauptworker", "online": True, "encoders": ["cpu-x265"],
|
||||
"info": {"cpu_kerne": "4"}},
|
||||
{"name": "tobisnicerpc", "online": False, "encoders": ["cpu-x265", "vce"],
|
||||
"info": {"cpu_modell": "AMD Ryzen 7 9700X", "cpu_kerne": "16",
|
||||
"cpu_simd": "avx512f"}},
|
||||
]}
|
||||
|
||||
|
||||
def test_zustand_findet_den_eigenen_worker():
|
||||
z = verwaltung.worker_zustand(CAPS, "tobisnicerpc")
|
||||
assert z["bekannt"] is True
|
||||
assert z["online"] is False
|
||||
assert z["encoders"] == ["cpu-x265", "vce"]
|
||||
assert z["info"]["cpu_simd"] == "avx512f"
|
||||
|
||||
|
||||
def test_zustand_bei_unbekanntem_worker():
|
||||
z = verwaltung.worker_zustand(CAPS, "gibt-es-nicht")
|
||||
assert z == {"bekannt": False, "online": False, "encoders": [], "info": {}}
|
||||
|
||||
|
||||
def test_zustand_ohne_antwort_von_rippy():
|
||||
"""Rippy nicht erreichbar → das Fenster muss trotzdem etwas sagen können."""
|
||||
assert verwaltung.worker_zustand(None, "x")["bekannt"] is False
|
||||
assert verwaltung.worker_zustand({}, "x")["bekannt"] is False
|
||||
|
||||
|
||||
# --- Die Statuszeile --------------------------------------------------------
|
||||
|
||||
|
||||
def test_status_nennt_das_wirkliche_problem():
|
||||
"""Drei Lagen, drei klare Sätze — nicht ein „unbekannt" für alles."""
|
||||
unbekannt = verwaltung.worker_zustand(CAPS, "gibt-es-nicht")
|
||||
text, farbe = verwaltung.status_text(unbekannt, None)
|
||||
assert "kennt diesen Worker nicht" in text
|
||||
assert farbe == verwaltung.ROSE
|
||||
|
||||
offline = verwaltung.worker_zustand(CAPS, "tobisnicerpc")
|
||||
text, farbe = verwaltung.status_text(offline, None)
|
||||
assert "NICHT erreichbar" in text
|
||||
assert farbe == verwaltung.ROSE
|
||||
|
||||
online = verwaltung.worker_zustand(CAPS, "rippy-hauptworker")
|
||||
text, farbe = verwaltung.status_text(online, None)
|
||||
assert "bereit" in text
|
||||
assert farbe == verwaltung.EMERALD
|
||||
|
||||
|
||||
def test_status_zeigt_den_laufenden_job():
|
||||
online = verwaltung.worker_zustand(CAPS, "rippy-hauptworker")
|
||||
job = {"title": "Akira", "status": "transcoding", "progress": 42,
|
||||
"eta_text": "noch ca. 3 h 07 min"}
|
||||
text, farbe = verwaltung.status_text(online, job)
|
||||
assert "Akira" in text and "42 %" in text and "3 h 07 min" in text
|
||||
assert farbe == verwaltung.AMBER
|
||||
|
||||
|
||||
# --- Aufgabenliste ----------------------------------------------------------
|
||||
|
||||
|
||||
def test_job_text_uebersetzt_die_status_woerter():
|
||||
"""„transcoding" sagt einem Nicht-Entwickler nichts."""
|
||||
assert verwaltung.job_text(
|
||||
{"title": "Akira", "status": "transcoding", "progress": 7}
|
||||
) == "Akira — komprimiert, 7 %"
|
||||
assert verwaltung.job_text(
|
||||
{"title": "Alien", "status": "completed"}) == "Alien — fertig"
|
||||
assert verwaltung.job_text(
|
||||
{"title": "Alien", "status": "failed"}) == "Alien — Fehler"
|
||||
assert verwaltung.job_text(
|
||||
{"title": "Alien", "status": "pending"}) == "Alien — wartet"
|
||||
|
||||
|
||||
def test_job_text_ohne_titel_nimmt_die_kurze_id():
|
||||
text = verwaltung.job_text(
|
||||
{"id": "95afdc89-2426-4d44", "status": "failed"})
|
||||
assert text == "95afdc89 — Fehler"
|
||||
|
||||
|
||||
def test_job_text_haengt_keine_restzeit_an_fertige_jobs():
|
||||
text = verwaltung.job_text(
|
||||
{"title": "X", "status": "completed", "progress": 100,
|
||||
"eta_text": "noch ca. 5 min"})
|
||||
assert "5 min" not in text
|
||||
assert "%" not in text
|
||||
|
||||
|
||||
# --- Log-Ausschnitt ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_letzte_zeilen_schneidet_hinten_ab():
|
||||
import io as _io
|
||||
|
||||
inhalt = "\n".join(f"Zeile {i}" for i in range(500))
|
||||
zeilen = verwaltung.letzte_zeilen(
|
||||
"egal", anzahl=5, oeffnen=lambda p: _io.StringIO(inhalt))
|
||||
assert zeilen == ["Zeile 495", "Zeile 496", "Zeile 497", "Zeile 498", "Zeile 499"]
|
||||
|
||||
|
||||
def test_letzte_zeilen_ohne_datei_ist_leer():
|
||||
def kaputt(p):
|
||||
raise OSError("keine Datei")
|
||||
|
||||
assert verwaltung.letzte_zeilen("gibts-nicht", oeffnen=kaputt) == []
|
||||
|
||||
|
||||
def test_hole_ohne_adresse_fragt_nicht():
|
||||
"""Ohne RIPPY_TRAY_HOST darf nichts versucht werden — sonst hängt das
|
||||
Fenster beim Öffnen am Timeout."""
|
||||
assert verwaltung.hole("/jobs", host="") is None
|
||||
@@ -188,3 +188,54 @@ def test_fehler_reisst_den_worker_start_nicht_mit():
|
||||
db = KaputteDb([])
|
||||
bericht = zombies.raeume_zombies_auf(FakeCelery(FakeInspektor()), db)
|
||||
assert "Postgres weg" in bericht["uebersprungen"]
|
||||
|
||||
|
||||
# --- Die Luecke, die die Erkennung nutzlos machte (Befund 26.07.2026) --------
|
||||
|
||||
|
||||
def test_running_gilt_als_arbeitsstatus():
|
||||
"""DER Fehler: Die Erkennung suchte "ripping", der Rip setzt aber "running".
|
||||
Ergebnis: Ein abgestuerzter RIP wurde NIE gefunden - und die Meldung
|
||||
`{'geprueft': 0}` sah bei jedem Worker-Start wie Gesundheit aus."""
|
||||
assert "running" in zombies.ARBEITS_STATI
|
||||
assert "transcoding" in zombies.ARBEITS_STATI
|
||||
assert "canceling" in zombies.ARBEITS_STATI
|
||||
|
||||
|
||||
def test_abgestuerzter_rip_wird_gefunden():
|
||||
"""Der konkrete Fall vom 26.07.2026: Job 2182d525 stand auf running/12 %,
|
||||
kein makemkvcon lief, die Rohdatei wuchs nicht mehr - und die Erkennung
|
||||
pruefte null Jobs."""
|
||||
offene = [{"id": "2182d525", "status": "running", "title": "Akira"}]
|
||||
assert zombies.finde_zombies(offene, set()) == offene
|
||||
|
||||
|
||||
def test_arbeitsstati_deckt_ab_was_der_worker_wirklich_schreibt():
|
||||
"""Mechanische Sperre gegen genau dieses Auseinanderlaufen.
|
||||
|
||||
Liest tasks.py und sammelt jeden Status, den der Worker per db.update_job in
|
||||
eine Job-ZEILE schreibt. Jeder davon muss entweder ein Arbeitsstatus sein
|
||||
oder ein Endzustand - sonst gibt es wieder einen Zustand, den niemand
|
||||
aufraeumt. Ein Kommentar haette das nicht verhindert; dieser Test schon.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
|
||||
pfad = os.path.join(os.path.dirname(os.path.abspath(__file__)), "tasks.py")
|
||||
quelle = open(pfad, encoding="utf-8").read()
|
||||
|
||||
# Nur die Aufrufe, die wirklich die Job-Zeile aendern.
|
||||
geschrieben = set()
|
||||
for aufruf in re.finditer(r"db\.update_job\((?:[^()]|\([^()]*\))*\)", quelle):
|
||||
for treffer in re.finditer(r'status\s*=\s*"([a-z]+)"', aufruf.group(0)):
|
||||
geschrieben.add(treffer.group(1))
|
||||
|
||||
endzustaende = {"completed", "failed"}
|
||||
unbeaufsichtigt = geschrieben - set(zombies.ARBEITS_STATI) - endzustaende
|
||||
assert not unbeaufsichtigt, (
|
||||
f"Diese Job-Status schreibt der Worker, aber niemand raeumt sie auf: "
|
||||
f"{sorted(unbeaufsichtigt)}. Entweder in ARBEITS_STATI aufnehmen oder "
|
||||
f"als Endzustand behandeln."
|
||||
)
|
||||
# Gegenprobe, dass der Test wirklich etwas gesehen hat
|
||||
assert "running" in geschrieben
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
"""Tray-Symbol für den NATIVEN Windows-Worker (nicht im Docker-Image genutzt).
|
||||
|
||||
Zeigt den Worker-Status unten rechts neben der Uhr und bietet:
|
||||
Start/Stopp, Rippy im Browser öffnen, Log anzeigen, Beenden. Gestartet
|
||||
wird es über start-tray.bat (setzt die Verbindungs-Umgebung und ruft
|
||||
pythonw.exe auf — kein Konsolenfenster). Der Worker-Prozess (celery,
|
||||
Queue transcode) läuft als Kind-Prozess und stoppt mit dem Tray.
|
||||
|
||||
Abhängigkeiten pystray + Pillow installiert NUR der Windows-Installer
|
||||
(install.ps1) — das Docker-Image bleibt davon unberührt.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import webbrowser
|
||||
|
||||
try:
|
||||
import pystray
|
||||
from PIL import Image, ImageDraw
|
||||
except ImportError:
|
||||
print("Tray braucht pystray + Pillow — install.ps1 erneut ausführen.")
|
||||
sys.exit(1)
|
||||
|
||||
BASIS = os.path.dirname(os.path.abspath(__file__))
|
||||
RIPPY_HOST = os.getenv("RIPPY_TRAY_HOST", "")
|
||||
WORKER_NAME = os.getenv("WORKER_NAME", "windows-worker")
|
||||
|
||||
|
||||
def _log_pfad() -> str:
|
||||
"""Wohin das Worker-Log geschrieben wird.
|
||||
|
||||
NICHT ins Programmverzeichnis (Befund 25.07.2026, Commander-Wunsch): Der
|
||||
Worker wird jetzt standardmäßig nach `C:\\Program Files\\Rippy Worker`
|
||||
installiert, und dort darf ein normaler Benutzer nicht schreiben. Ein Log
|
||||
neben dem Programm hätte den Worker beim Starten scheitern lassen — genau
|
||||
die Art Fehler, die man erst merkt, wenn nichts passiert.
|
||||
|
||||
Deshalb ins Benutzerprofil (`%LOCALAPPDATA%\\Rippy Worker`), wie es sich für
|
||||
veränderliche Daten gehört. Nur falls das nicht klappt, bleibt das
|
||||
Programmverzeichnis der Rückfall — bei einer Installation ins Benutzerprofil
|
||||
(frei wählbar) ist es ohnehin beschreibbar.
|
||||
"""
|
||||
basis_daten = os.getenv("LOCALAPPDATA") or os.getenv("APPDATA") or ""
|
||||
if basis_daten:
|
||||
ordner = os.path.join(basis_daten, "Rippy Worker")
|
||||
try:
|
||||
os.makedirs(ordner, exist_ok=True)
|
||||
return os.path.join(ordner, "worker.log")
|
||||
except OSError:
|
||||
pass
|
||||
return os.path.join(BASIS, "worker.log")
|
||||
|
||||
|
||||
LOG_PFAD = _log_pfad()
|
||||
|
||||
prozess = None
|
||||
|
||||
|
||||
def disc_bild(laeuft: bool) -> Image.Image:
|
||||
"""Disc-Symbol im Rippy-Look: Indigo wenn der Worker läuft, grau sonst."""
|
||||
bild = Image.new("RGBA", (64, 64), (0, 0, 0, 0))
|
||||
zeichner = ImageDraw.Draw(bild)
|
||||
farbe = (99, 102, 241, 255) if laeuft else (125, 132, 145, 255)
|
||||
zeichner.ellipse([4, 4, 60, 60], fill=farbe)
|
||||
zeichner.ellipse([24, 24, 40, 40], fill=(255, 255, 255, 255))
|
||||
return bild
|
||||
|
||||
|
||||
def worker_laeuft() -> bool:
|
||||
return prozess is not None and prozess.poll() is None
|
||||
|
||||
|
||||
def worker_starten(icon=None, item=None):
|
||||
global prozess
|
||||
if worker_laeuft():
|
||||
return
|
||||
celery = os.path.join(BASIS, "venv", "Scripts", "celery.exe")
|
||||
log = open(LOG_PFAD, "a", encoding="utf-8", errors="replace")
|
||||
prozess = subprocess.Popen(
|
||||
[celery, "-A", "celery_app", "worker", "--loglevel=info",
|
||||
"-Q", "transcode", "--pool=solo", "-n", f"{WORKER_NAME}@%h"],
|
||||
cwd=BASIS, stdout=log, stderr=subprocess.STDOUT,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
if icon:
|
||||
icon.icon = disc_bild(True)
|
||||
icon.update_menu()
|
||||
|
||||
|
||||
def worker_stoppen(icon=None, item=None):
|
||||
global prozess
|
||||
if worker_laeuft():
|
||||
prozess.terminate()
|
||||
try:
|
||||
prozess.wait(15)
|
||||
except subprocess.TimeoutExpired:
|
||||
prozess.kill()
|
||||
prozess = None
|
||||
if icon:
|
||||
icon.icon = disc_bild(False)
|
||||
icon.update_menu()
|
||||
|
||||
|
||||
def umschalten(icon, item):
|
||||
if worker_laeuft():
|
||||
worker_stoppen(icon)
|
||||
else:
|
||||
worker_starten(icon)
|
||||
|
||||
|
||||
def rippy_oeffnen(icon, item):
|
||||
if RIPPY_HOST:
|
||||
webbrowser.open(f"http://{RIPPY_HOST}")
|
||||
|
||||
|
||||
def log_anzeigen(icon, item):
|
||||
if os.path.exists(LOG_PFAD):
|
||||
os.startfile(LOG_PFAD) # noqa: S606 — bewusst: Standard-Editor des Nutzers
|
||||
|
||||
|
||||
def beenden(icon, item):
|
||||
worker_stoppen()
|
||||
icon.stop()
|
||||
|
||||
|
||||
menue = pystray.Menu(
|
||||
pystray.MenuItem(
|
||||
lambda item: f'Rippy-Worker "{WORKER_NAME}": {"läuft" if worker_laeuft() else "gestoppt"}',
|
||||
None, enabled=False,
|
||||
),
|
||||
pystray.Menu.SEPARATOR,
|
||||
pystray.MenuItem(
|
||||
lambda item: "Worker stoppen" if worker_laeuft() else "Worker starten",
|
||||
umschalten,
|
||||
),
|
||||
pystray.MenuItem("Rippy öffnen", rippy_oeffnen),
|
||||
pystray.MenuItem("Log anzeigen", log_anzeigen),
|
||||
pystray.Menu.SEPARATOR,
|
||||
pystray.MenuItem("Beenden (stoppt den Worker)", beenden),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
tray = pystray.Icon("rippy-worker", disc_bild(False), "Rippy Worker", menue)
|
||||
worker_starten()
|
||||
tray.icon = disc_bild(worker_laeuft())
|
||||
tray.run()
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Kleines Verwaltungsfenster für den Windows-Worker (Doppelklick aufs Tray).
|
||||
|
||||
Commander-Wunsch 26.07.2026: *„Es wäre cool wenn man einfach mit nem Doppelklick
|
||||
ne kleine Verwaltung hat wo man direkt Logs, Aufgaben, usw. sieht."*
|
||||
|
||||
## Warum ein eigener PROZESS und kein Fenster im Tray
|
||||
|
||||
pystray belegt mit `icon.run()` den Haupt-Thread, und tkinter will seine
|
||||
Ereignisschleife ebenfalls dort haben. Beides in einem Prozess zu verschränken
|
||||
ist eine bekannte Quelle für Fenster, die sich nicht mehr schließen lassen. Als
|
||||
eigener Prozess (`pythonw.exe verwaltung.py`) gibt es das Problem gar nicht: Das
|
||||
Fenster erbt die Umgebung vom Tray (Rippy-Adresse, Worker-Name) und kann
|
||||
abstürzen, ohne den Worker mitzunehmen.
|
||||
|
||||
## Was es anzeigt — und woher
|
||||
|
||||
Alles Fachliche kommt von RIPPY, nicht aus eigener Rechnung: Ob dieser Worker als
|
||||
erreichbar gilt, steht in `/capabilities`; was gerade läuft und wie lange es noch
|
||||
dauert, in `/jobs`. So zeigt das Fenster dieselben Zahlen wie das Dashboard statt
|
||||
einer zweiten, abweichenden Wahrheit.
|
||||
|
||||
Nur das Log kommt lokal: Es ist genau dann die einzige Auskunft, wenn Rippy nicht
|
||||
erreichbar ist.
|
||||
|
||||
tkinter statt WinForms, weil es bei jeder Windows-Python-Installation dabei ist —
|
||||
der Installer soll keine weitere Abhängigkeit brauchen.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import urllib.request
|
||||
|
||||
RIPPY_HOST = os.getenv("RIPPY_TRAY_HOST", "")
|
||||
WORKER_NAME = os.getenv("WORKER_NAME", "windows-worker")
|
||||
SLOTS = os.getenv("RIPPY_SLOTS", "1")
|
||||
BASIS = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# Farben aus dem Rippy-UI (docker/ui/src/lib/design.ts), damit das Fenster nicht
|
||||
# wie ein Fremdkörper wirkt.
|
||||
BG = "#0f172a"
|
||||
PANEL = "#020617"
|
||||
TEXT = "#e2e8f0"
|
||||
GEDAEMPFT = "#94a3b8"
|
||||
AMBER = "#f59e0b"
|
||||
EMERALD = "#10b981"
|
||||
ROSE = "#f43f5e"
|
||||
|
||||
LOG_ZEILEN = 200
|
||||
TAKT_MS = 4000
|
||||
|
||||
|
||||
def letzte_zeilen(pfad: str, anzahl: int = LOG_ZEILEN, oeffnen=None) -> list:
|
||||
"""Die letzten `anzahl` Zeilen einer Datei (pure genug für einen Test).
|
||||
|
||||
Liest bewusst die GANZE Datei und schneidet ab: Das Worker-Log wird nicht
|
||||
groß (die Brücke drosselt auf 30 Zeilen/Minute), und ein Rückwärts-Suchen
|
||||
über Blockgrenzen wäre mehr Code als Nutzen. Fehler beim Lesen geben eine
|
||||
leere Liste — ein Fenster, das wegen des Logs nicht aufgeht, wäre schlimmer.
|
||||
"""
|
||||
macher = oeffnen or (lambda p: open(p, encoding="utf-8", errors="replace"))
|
||||
try:
|
||||
with macher(pfad) as f:
|
||||
return f.read().splitlines()[-anzahl:]
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def hole(pfad: str, host: str = None, timeout: int = 8):
|
||||
"""GET auf Rippys API — None, wenn es nicht klappt."""
|
||||
ziel = host if host is not None else RIPPY_HOST
|
||||
if not ziel:
|
||||
return None
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://{ziel}/api{pfad}", timeout=timeout) as a:
|
||||
return json.load(a)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def worker_zustand(capabilities, name: str) -> dict:
|
||||
"""Was Rippy über DIESEN Worker weiß (pure Funktion).
|
||||
|
||||
Rückgabe: {"bekannt": bool, "online": bool, "encoders": [...], "info": {...}}
|
||||
"""
|
||||
for w in ((capabilities or {}).get("workers") or []):
|
||||
if w.get("name") == name:
|
||||
return {
|
||||
"bekannt": True,
|
||||
"online": bool(w.get("online")),
|
||||
"encoders": w.get("encoders") or [],
|
||||
"info": w.get("info") or {},
|
||||
}
|
||||
return {"bekannt": False, "online": False, "encoders": [], "info": {}}
|
||||
|
||||
|
||||
def job_text(job: dict) -> str:
|
||||
"""Eine Zeile für die Aufgabenliste (pure Funktion)."""
|
||||
titel = (job.get("title") or (job.get("id") or "")[:8]) or "?"
|
||||
status = {
|
||||
"transcoding": "komprimiert",
|
||||
"processing": "rippt",
|
||||
"running": "rippt",
|
||||
"pending": "wartet",
|
||||
"completed": "fertig",
|
||||
"failed": "Fehler",
|
||||
"canceling": "bricht ab",
|
||||
}.get(job.get("status") or "", job.get("status") or "?")
|
||||
zeile = f"{titel} — {status}"
|
||||
if job.get("status") in ("transcoding", "processing", "running"):
|
||||
zeile += f", {job.get('progress') or 0} %"
|
||||
if job.get("eta_text"):
|
||||
zeile += f" ({job['eta_text']})"
|
||||
return zeile
|
||||
|
||||
|
||||
def status_text(zustand: dict, aktueller_job: dict) -> tuple:
|
||||
"""(Text, Farbe) für die Statuszeile (pure Funktion)."""
|
||||
if not zustand.get("bekannt"):
|
||||
return ("Rippy kennt diesen Worker nicht — läuft er? Ist die Adresse richtig?", ROSE)
|
||||
if not zustand.get("online"):
|
||||
return ("Bei Rippy als NICHT erreichbar gemeldet", ROSE)
|
||||
if aktueller_job:
|
||||
return (job_text(aktueller_job), AMBER)
|
||||
return ("Erreichbar und bereit — nichts in Arbeit", EMERALD)
|
||||
|
||||
|
||||
def deinstallieren() -> None:
|
||||
"""Startet den Deinstaller im selben Ordner (fragt dort selbst nach)."""
|
||||
skript = os.path.join(BASIS, "uninstall.ps1")
|
||||
if not os.path.isfile(skript):
|
||||
return
|
||||
subprocess.Popen(
|
||||
["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass",
|
||||
"-File", skript],
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
|
||||
|
||||
def _log_pfad() -> str:
|
||||
"""Dieselbe Wahl wie im Tray (dort ausführlich begründet)."""
|
||||
basis_daten = os.getenv("LOCALAPPDATA") or os.getenv("APPDATA") or ""
|
||||
if basis_daten:
|
||||
kandidat = os.path.join(basis_daten, "Rippy Worker", "worker.log")
|
||||
if os.path.exists(kandidat):
|
||||
return kandidat
|
||||
return os.path.join(BASIS, "worker.log")
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Kind-Prozesse starten, ohne dass ein Konsolenfenster aufblitzt.
|
||||
|
||||
## Der Befund, der das nötig gemacht hat (26.07.2026)
|
||||
|
||||
Commander: *„Es geht übrigens immer alle paar Sekunden ne CMD auf."* Und er hatte
|
||||
recht — es war kein Geist, sondern der Herzschlag des Workers.
|
||||
|
||||
Der Windows-Worker läuft als `pythonw.exe`, also ohne eigene Konsole (das Tray
|
||||
startet ihn mit CREATE_NO_WINDOW). Startet ein Prozess OHNE Konsole ein
|
||||
Konsolenprogramm, legt Windows dafür eine NEUE Konsole an — und die ist sichtbar.
|
||||
`capture_output=True` hilft nicht: Es leitet die Datenströme um, unterdrückt aber
|
||||
kein Fenster.
|
||||
|
||||
Und der Worker startet solche Programme oft: `caps.py` fragt jede Minute
|
||||
`HandBrakeCLI --help`, `--preset-list` und `--version` ab, dazu kommt die
|
||||
Schlüssel-Automatik mit `makemkvcon`. Vier bis fünf Fenster pro Minute, in
|
||||
Schüben — genau das „alle paar Sekunden".
|
||||
|
||||
## Warum eine eigene Datei
|
||||
|
||||
Damit es an EINER Stelle richtig ist. Die betroffenen Aufrufe stehen in caps.py,
|
||||
ripping.py und schluessel.py; jeder hätte das Flag einzeln vergessen können, und
|
||||
genau so ist es passiert. Auf Linux ist `CREATE_NO_WINDOW` nicht vorhanden und
|
||||
`creationflags=0` eine Nulloperation — im Worker-Container gegengeprüft, damit
|
||||
derselbe Code auf beiden Seiten läuft.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
|
||||
# Auf Windows das Flag, auf Linux 0 (dort wird creationflags=0 akzeptiert und
|
||||
# ignoriert — am 26.07.2026 im Worker-Container gemessen).
|
||||
OHNE_FENSTER = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
@@ -28,7 +28,22 @@ Ampel sie ohne Infrastruktur prüfen kann.
|
||||
"""
|
||||
|
||||
# Zustände, die behaupten: hier arbeitet gerade jemand.
|
||||
ARBEITS_STATI = ("ripping", "transcoding", "canceling")
|
||||
#
|
||||
# ⚠️ „running" FEHLTE bis zum 26.07.2026 — und das war der ganze Witz: Die
|
||||
# Zombie-Erkennung aus v3.14 wurde gebaut, um genau einen abgestürzten RIP zu
|
||||
# finden, und hat ihn nie gesehen. Der Rip setzt `status="running"`
|
||||
# (tasks.py, db.update_job), gesucht wurde aber „ripping". Dieser Wert steht
|
||||
# ausschließlich in Celerys Task-Meta und NIE in einer Job-Zeile.
|
||||
#
|
||||
# Die Folge war besonders tückisch: Bei jedem Worker-Start meldete die Erkennung
|
||||
# `{'geprueft': 0, ...}` — und das sah wie „nachgesehen, alles gesund" aus,
|
||||
# während sie in Wahrheit nach einem Status suchte, den es nicht gibt. Gefunden
|
||||
# am 26.07.2026, als ein Rip mitten im Lauf abbrach und der Job danach dauerhaft
|
||||
# auf „processing 12 %" stand, mit einer Restzeit-Schätzung obendrauf.
|
||||
#
|
||||
# „ripping" bleibt bewusst drin: Es schadet nicht und deckt eine etwaige
|
||||
# Bestandsinstallation ab, in der es doch gesetzt wurde.
|
||||
ARBEITS_STATI = ("running", "ripping", "transcoding", "canceling")
|
||||
|
||||
# Wartezeit nach dem Worker-Start, bevor geurteilt wird. Deckt die
|
||||
# Wiederzustellung unbestätigter Aufgaben durch Celery ab.
|
||||
@@ -84,16 +99,50 @@ def finde_zombies(offene_jobs, belegte) -> list:
|
||||
return [job for job in offene_jobs if job.get("id") not in belegte]
|
||||
|
||||
|
||||
# In welcher Phase starb der Job? Entscheidet, was danach hilft.
|
||||
RIP_STATI = ("running", "ripping")
|
||||
|
||||
|
||||
def war_im_rip(job) -> bool:
|
||||
"""Starb der Job WÄHREND des Rippens? (pure Funktion)
|
||||
|
||||
Der Unterschied ist für den Nutzer der ganze Punkt: Nach einem toten
|
||||
TRANSCODE liegt eine vollständige Roh-MKV da — „Neu komprimieren" genügt und
|
||||
spart eine Stunde Rippen. Nach einem toten RIP liegt ein ABGESCHNITTENES
|
||||
Bruchstück da (im Vorfall 26.07.2026: 5,1 GB von rund 40), und Komprimieren
|
||||
ergäbe einen Film, der bei 12 % aufhört.
|
||||
"""
|
||||
return (job.get("status") or "") in RIP_STATI
|
||||
|
||||
|
||||
def fehlertext(job) -> str:
|
||||
"""Ehrlicher Klartext für die Job-Karte — was war, und was jetzt hilft."""
|
||||
"""Ehrlicher Klartext für die Job-Karte — was war, und was jetzt hilft.
|
||||
|
||||
⚠️ Der Text unterscheidet die Phase (26.07.2026). Vorher stand hier für JEDEN
|
||||
Fall „mit 'Neu komprimieren' läuft die Kompression erneut, ohne die Disc noch
|
||||
einmal zu rippen" — geschrieben für einen toten Transcode. Seit „running" mit
|
||||
zu den Arbeitsstati gehört, traf derselbe Satz auch abgebrochene RIPS, und da
|
||||
ist er schlicht falsch: Wer ihn befolgt, bekommt ein Bruchstück komprimiert.
|
||||
"""
|
||||
zuletzt = job.get("status") or "unbekannt"
|
||||
return (
|
||||
kopf = (
|
||||
f"Abgebrochen: Der Vorgang lief nicht mehr (zuletzt gemeldet: {zuletzt}). "
|
||||
"Beim Start des Workers war dazu weder ein Prozess noch eine Aufgabe in "
|
||||
"der Warteschlange zu finden — meistens ein Absturz oder ein Neustart "
|
||||
"mitten im Lauf. Die Rohdateien wurden NICHT gelöscht: mit "
|
||||
"'Neu komprimieren' läuft die Kompression erneut, ohne die Disc noch "
|
||||
"einmal zu rippen."
|
||||
"mitten im Lauf. "
|
||||
)
|
||||
if war_im_rip(job):
|
||||
fortschritt = job.get("progress") or 0
|
||||
return kopf + (
|
||||
f'Der Rip war bei {fortschritt} % — die Roh-Datei ist damit '
|
||||
'UNVOLLSTÄNDIG. Sie wurde nicht gelöscht, aber „Neu komprimieren" '
|
||||
'ergäbe einen Film, der mitten drin aufhört. Richtig ist: Disc '
|
||||
'wieder einlegen und neu rippen.'
|
||||
)
|
||||
return kopf + (
|
||||
'Der Rip war fertig, nur die Kompression nicht. Die Roh-Dateien wurden '
|
||||
'NICHT gelöscht: mit „Neu komprimieren" läuft sie erneut, ohne die Disc '
|
||||
'noch einmal zu rippen.'
|
||||
)
|
||||
|
||||
|
||||
|
||||
+14
-9
@@ -333,9 +333,10 @@ elif [ "$PROPAGATION_NOETIG" = 1 ]; then
|
||||
ok "Propagation eingerichtet"
|
||||
# Neustart-fest machen, sonst ist es nach dem nächsten Reboot wieder weg
|
||||
# und das Einhängen aus dem UI scheitert stillschweigend.
|
||||
UNIT="/etc/systemd/system/$(systemd-escape -p --suffix=mount "$MEDIA_DIR" 2>/dev/null)"
|
||||
if [ -n "${UNIT:-}" ] && [ ! -e "$UNIT" ] && command -v systemctl >/dev/null 2>&1; then
|
||||
cat > "$UNIT" <<UNITEOF
|
||||
if command -v systemd-escape >/dev/null 2>&1 && command -v systemctl >/dev/null 2>&1; then
|
||||
UNIT="/etc/systemd/system/$(systemd-escape -p --suffix=mount "$MEDIA_DIR")"
|
||||
if [ ! -e "$UNIT" ]; then
|
||||
cat > "$UNIT" <<UNITEOF
|
||||
[Unit]
|
||||
Description=Rippy-Ablage als shared mount (für NAS-Freigaben aus dem UI)
|
||||
After=local-fs.target
|
||||
@@ -349,13 +350,17 @@ Options=bind,rshared
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNITEOF
|
||||
systemctl daemon-reload 2>/dev/null
|
||||
if systemctl enable "$(basename "$UNIT")" >/dev/null 2>&1; then
|
||||
ok "neustart-fest gemacht ($(basename "$UNIT"))"
|
||||
else
|
||||
gelb " ! Konnte nicht neustart-fest gemacht werden — nach einem Reboot"
|
||||
gelb " dieses Skript einfach erneut ausführen."
|
||||
systemctl daemon-reload 2>/dev/null
|
||||
if systemctl enable "$(basename "$UNIT")" >/dev/null 2>&1; then
|
||||
ok "neustart-fest gemacht ($(basename "$UNIT"))"
|
||||
else
|
||||
gelb " ! Konnte nicht neustart-fest gemacht werden — nach einem Reboot"
|
||||
gelb " dieses Skript einfach erneut ausführen."
|
||||
fi
|
||||
fi
|
||||
else
|
||||
gelb " ! Kein systemd gefunden — Mount-Propagation ist nicht neustart-fest."
|
||||
gelb " Nach einem Reboot dieses Skript einfach erneut ausführen."
|
||||
fi
|
||||
else
|
||||
gelb " ! Propagation ließ sich nicht einrichten."
|
||||
|
||||
Reference in New Issue
Block a user