Compare commits
83 Commits
5cd1876c90
...
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 | |||
| ee123f2a90 | |||
| 0c269a97ee | |||
| 6db64230eb | |||
| 638edc6a9f | |||
| aea26493db | |||
| 8b7bdfe798 | |||
| 4195854bf8 | |||
| a468e4b6a4 | |||
| 4c78914529 | |||
| fd1feaaee3 | |||
| fe12401e34 | |||
| 61c38a0d14 | |||
| 29444805a8 | |||
| b0205c42c9 | |||
| 35cfcbcb07 | |||
| 65edf8369e | |||
| 8f208ed0da | |||
| b76ca5d9be | |||
| ca91e5b693 | |||
| 1eb1c91dd8 | |||
| 6a17af2118 | |||
| 11597eb00c | |||
| b546c79b9e | |||
| a1aabd591d | |||
| 8394de6926 | |||
| 574354131c | |||
| ef0a574a70 | |||
| 883c1c290b | |||
| e0cb7b3ddc |
+31
-5
@@ -7,11 +7,17 @@ POSTGRES_PASSWORD=rippy
|
|||||||
|
|
||||||
# Optisches Laufwerk (Host-Geräteknoten) — je Rechner UNTERSCHIEDLICH!
|
# Optisches Laufwerk (Host-Geräteknoten) — je Rechner UNTERSCHIEDLICH!
|
||||||
# Der Worker braucht den SCSI-CD-ROM-Knoten (meist /dev/sr0) UND den passenden
|
# Der Worker braucht den SCSI-CD-ROM-Knoten (meist /dev/sr0) UND den passenden
|
||||||
# generischen sg-Knoten des Laufwerks. Die sg-Nummer variiert je Host — auf DIESER
|
# generischen sg-Knoten des Laufwerks.
|
||||||
# Maschine ermitteln: lsscsi -g (oder: ls -l /dev/sg*). Hier den HOST-Pfad des
|
#
|
||||||
# Blu-ray/DVD-Laufwerks eintragen; der Default passt für den Ursprungs-Host.
|
# BEWUSST AUSKOMMENTIERT: Die sg-Nummer ist JE HOST ANDERS. Ein vorbelegter Wert
|
||||||
OPTICAL_SR=/dev/sr0
|
# wäre auf den meisten Rechnern falsch — und zwar unbemerkt, weil er dann „schon
|
||||||
OPTICAL_SG=/dev/sg1
|
# gesetzt" aussieht. `install.sh` findet beide Knoten selbst (Abgleich über die
|
||||||
|
# SCSI-Adresse in /sys) und trägt sie hier ein.
|
||||||
|
#
|
||||||
|
# Von Hand ermitteln, falls gewünscht: lsscsi -g
|
||||||
|
# Ohne Eintrag gelten die Compose-Vorgaben /dev/sr0 und /dev/sg1.
|
||||||
|
#OPTICAL_SR=/dev/sr0
|
||||||
|
#OPTICAL_SG=/dev/sg1
|
||||||
|
|
||||||
# (JWT/Auth wurde am 24.07.2026 komplett entfernt — Rippy ist Heimnetz-only,
|
# (JWT/Auth wurde am 24.07.2026 komplett entfernt — Rippy ist Heimnetz-only,
|
||||||
# Commander-Entscheid, siehe KONZEPT.md Abschnitt 10. Härtung für exponierte
|
# Commander-Entscheid, siehe KONZEPT.md Abschnitt 10. Härtung für exponierte
|
||||||
@@ -45,3 +51,23 @@ MAKEMKV_APP_KEY=
|
|||||||
# docker compose build worker && docker compose up -d worker
|
# docker compose build worker && docker compose up -d worker
|
||||||
# (bei Cloudflare-Zicken vorher Tarballs nach docker/worker/vendor/ legen)
|
# (bei Cloudflare-Zicken vorher Tarballs nach docker/worker/vendor/ legen)
|
||||||
#MAKEMKV_VERSION=1.18.4
|
#MAKEMKV_VERSION=1.18.4
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MakeMKV-Bezug beim Image-Bau (nur nötig, wenn der Download klemmt)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Am 25.07.2026 nachgemessen, welche Quellen wirklich liefern:
|
||||||
|
# https://www.makemkv.com/download HTTP 200 <- der Standard, funktioniert
|
||||||
|
# https://www.makemkv.com/download/old HTTP 525 (Cloudflare)
|
||||||
|
# web.archive.org-Schnappschuss HTTP 404
|
||||||
|
# Normalerweise ist hier NICHTS einzutragen.
|
||||||
|
#MAKEMKV_URL_BASE=https://www.makemkv.com/download
|
||||||
|
#
|
||||||
|
# Zweite Quelle, die bei Fehlschlag der ersten automatisch probiert wird.
|
||||||
|
# Trage hier nur etwas ein, das du selbst geprüft hast — eine Adresse, die nicht
|
||||||
|
# liefert, lässt die Konfiguration gesund aussehen und den Bau später scheitern.
|
||||||
|
# Genau so lag es auf der Rippy-VM: dort stand eine 404-Adresse, und nur die
|
||||||
|
# vendor-Tarballs retteten jeden Bau, ohne dass es jemandem auffiel.
|
||||||
|
#MAKEMKV_URL_FALLBACK=http://192.168.178.10:8099
|
||||||
|
#
|
||||||
|
# Der zuverlässigste Weg bleibt ohne Netz: Tarballs von makemkv.com/download
|
||||||
|
# laden und nach docker/worker/vendor/ legen — der Bau nimmt sie dann von dort.
|
||||||
|
|||||||
@@ -1,69 +1,172 @@
|
|||||||
# AGENTS.md — Arbeits-Konventionen für Rippy
|
# AGENTS.md — Arbeits-Konventionen für Rippy
|
||||||
|
|
||||||
## Grundregeln
|
## Grundregeln
|
||||||
|
|
||||||
**1. Plan vor Code.** Vor jeder Etappe: Klare Akzeptanzkriterien schreiben, dann bauen.
|
**1. Plan vor Code.** Vor jeder Etappe: Klare Akzeptanzkriterien schreiben, dann bauen.
|
||||||
**2. Kleine Schritte.** Max. eine Feature pro Commit. Commit-Nachrichten beschreiben WAS und WARUM.
|
**2. Kleine Schritte.** Max. eine Feature pro Commit. Commit-Nachrichten beschreiben WAS und WARUM.
|
||||||
**3. Beweisen statt behaupten.** Testen, was gebaut wurde. Keine "es sollte funktionieren"-Commits.
|
**3. Beweisen statt behaupten.** Testen, was gebaut wurde. Keine "es sollte funktionieren"-Commits.
|
||||||
**4. Deutsch-Nicht-Entwickler.** Der Commander liest alles — Variablennamen auf Englisch, aber Kommentare und Docs auf Deutsch.
|
**4. Deutsch-Nicht-Entwickler.** Der Commander liest alles — Variablennamen auf Englisch, aber Kommentare und Docs auf Deutsch.
|
||||||
|
|
||||||
## ⛔ HARTE Regeln (mechanisch geprüft — seit 22.07.2026)
|
## ⛔ HARTE Regeln (mechanisch geprüft — seit 22.07.2026)
|
||||||
|
|
||||||
**A. Die CI-Ampel muss GRÜN sein, bevor irgendetwas „fertig" heißt.**
|
**A. Die CI-Ampel muss GRÜN sein, bevor irgendetwas „fertig" heißt.**
|
||||||
`.gitea/workflows/ci.yml` läuft bei jedem Push auf dem Gitea-Runner (NICHT löschen, NICHT
|
`.gitea/workflows/ci.yml` läuft bei jedem Push auf dem Gitea-Runner (NICHT löschen, NICHT
|
||||||
abschwächen). Keine Tests = rot = nicht fertig. Der Commander liest die Ampel, nicht den Code.
|
abschwächen). Keine Tests = rot = nicht fertig. Der Commander liest die Ampel, nicht den Code.
|
||||||
|
|
||||||
**B. Abweichung vom KONZEPT = STOPP + fragen.** Anderes Werkzeug, andere Bibliothek,
|
**B. Abweichung vom KONZEPT = STOPP + fragen.** Anderes Werkzeug, andere Bibliothek,
|
||||||
gestrichenes Muss-Feature → erst den Commander fragen, NIE still ersetzen.
|
gestrichenes Muss-Feature → erst den Commander fragen, NIE still ersetzen.
|
||||||
(Vorgefallen: Muss-Feature „MakeMKV lossless" wurde still durch lossy HandBrake ersetzt.)
|
(Vorgefallen: Muss-Feature „MakeMKV lossless" wurde still durch lossy HandBrake ersetzt.)
|
||||||
|
|
||||||
**C. Single Source of Truth ist `main` — es gibt nur diesen einen Branch**
|
**C. Single Source of Truth ist `main` — es gibt nur diesen einen Branch**
|
||||||
(seit 24.07.2026; der `stable`-Zwischenbranch ist abgeschafft). Die CI-Ampel
|
(seit 24.07.2026; der `stable`-Zwischenbranch ist abgeschafft). Die CI-Ampel
|
||||||
läuft bei jedem Push und PRÜFT nur (Ruff/pytest/Vite-Build) — sie befördert
|
läuft bei jedem Push und PRÜFT nur (Ruff/pytest/Vite-Build) — sie befördert
|
||||||
nichts mehr. Deployt wird direkt aus `main`: auf der VM `git pull` bzw.
|
nichts mehr. Deployt wird direkt aus `main`: auf der VM `git pull` bzw.
|
||||||
`./deploy.sh` (verifiziere vorher, dass die Ampel für den Commit GRÜN ist —
|
`./deploy.sh` (verifiziere vorher, dass die Ampel für den Commit GRÜN ist —
|
||||||
rot heißt: nicht deployen). Nie freihändig per SSH auf der VM bauen.
|
rot heißt: nicht deployen). Nie freihändig per SSH auf der VM bauen.
|
||||||
(Vorgefallen: Doppel-Anlage `Rippy` + `rippy` auf der VM durch Freihand-Deploys.)
|
(Vorgefallen: Doppel-Anlage `Rippy` + `rippy` auf der VM durch Freihand-Deploys.)
|
||||||
|
|
||||||
**D. Externe Schnittstellen NIE aus dem Kopf.** Vor Nutzung fremder CLI-Flags oder
|
**D. Externe Schnittstellen NIE aus dem Kopf.** Vor Nutzung fremder CLI-Flags oder
|
||||||
Bibliotheks-APIs: `--help`/Doku prüfen und die Fundstelle im Commit nennen.
|
Bibliotheks-APIs: `--help`/Doku prüfen und die Fundstelle im Commit nennen.
|
||||||
(Vorgefallen: erfundene Celery-Methode `self.send_task`, erfundene abcde-Flags.)
|
(Vorgefallen: erfundene Celery-Methode `self.send_task`, erfundene abcde-Flags.)
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
1. **Read:** KONZEPT.md + ROADMAP.md lesen. Verstehen, welche Etappe dran ist.
|
1. **Read:** KONZEPT.md + ROADMAP.md lesen. Verstehen, welche Etappe dran ist.
|
||||||
2. **Plan:** Was genau soll diese Sitzung bauen? Eine Zeile.
|
2. **Plan:** Was genau soll diese Sitzung bauen? Eine Zeile.
|
||||||
3. **Build:** Code schreiben, testen, committen.
|
3. **Build:** Code schreiben, testen, committen.
|
||||||
4. **Verify:** `docker compose up` — funktioniert das Ganze?
|
4. **Verify:** `docker compose up` — funktioniert das Ganze?
|
||||||
5. **Savepoint:** SAVEPOINT.md aktualisieren. Nächster Chat beginnt nicht von Null.
|
5. **Savepoint:** SAVEPOINT.md aktualisieren. Nächster Chat beginnt nicht von Null.
|
||||||
|
|
||||||
## Modelle je Aufgabe
|
## Modelle je Aufgabe
|
||||||
|
|
||||||
- **Planung:** Heavy-Modell (Reasoning) für Architektur-Entscheidungen.
|
- **Planung:** Heavy-Modell (Reasoning) für Architektur-Entscheidungen.
|
||||||
- **Bauen:** schnelles Coding-Modell.
|
- **Bauen:** schnelles Coding-Modell.
|
||||||
- **Review:** Heavy nochmal für Code-Review vor Merge.
|
- **Review:** Heavy nochmal für Code-Review vor Merge.
|
||||||
|
|
||||||
## Docker-Praxis
|
## Docker-Praxis
|
||||||
|
|
||||||
- Alles läuft in Containern: `docker compose up -d`
|
- Alles läuft in Containern: `docker compose up -d`
|
||||||
- Keine System-Pakete auf dem Host — alles im Container.
|
- Keine System-Pakete auf dem Host — alles im Container.
|
||||||
- Dockerfile immer multi-stage, kleinste Images.
|
- Dockerfile immer multi-stage, kleinste Images.
|
||||||
|
|
||||||
## Was NICHT gebaut wird
|
## Was NICHT gebaut wird
|
||||||
|
|
||||||
- Kein Code direkt im Host-OS.
|
- Kein Code direkt im Host-OS.
|
||||||
- Kein Proxmox-LXC-Nesting-Workaround — das Projekt lebt bewusst in normalem Docker.
|
- Kein Proxmox-LXC-Nesting-Workaround — das Projekt lebt bewusst in normalem Docker.
|
||||||
- Kein ARM-Fork — Rippy ist ein Eigenbau von Grund auf.
|
- 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)
|
- ✅ **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,
|
- ✅ **Etappe 13 (v3.2):** Universal-Komfort-Runde — Media-Server-Integration,
|
||||||
echte Benachrichtigungen, SMB-Klartext-Fehler, UHD-Arbeitsverzeichnis,
|
echte Benachrichtigungen, SMB-Klartext-Fehler, UHD-Arbeitsverzeichnis,
|
||||||
MakeMKV-Key via UI, Job-Detail-Popup, Toast-Feedback, Ampel-Blocker behoben
|
MakeMKV-Key via UI, Job-Detail-Popup, Toast-Feedback, Ampel-Blocker behoben
|
||||||
- ✅ **Etappe 17 (v3.10):** 4K-UHD-Disc-Schlüssel — persistentes
|
- ✅ **Etappe 17 (v3.10):** 4K-UHD-Disc-Schlüssel — persistentes
|
||||||
MakeMKV-Datenverzeichnis + `KEYDB.cfg` im UI
|
MakeMKV-Datenverzeichnis + `KEYDB.cfg` im UI
|
||||||
- ✅ **Etappe 18 (v3.11):** 4K-UHD gelöst — `makemkvcon` holt Schlüssel
|
- ✅ **Etappe 18 (v3.11):** 4K-UHD gelöst — `makemkvcon` holt Schlüssel
|
||||||
unter Linux nie, unter Windows schon; Schlüsselspeicher übernehmbar.
|
unter Linux nie, unter Windows schon; Schlüsselspeicher übernehmbar.
|
||||||
Akira-UHD geht auf der VM auf (`TCOUNT:5`, bewiesen)
|
Akira-UHD geht auf der VM auf (`TCOUNT:5`, bewiesen)
|
||||||
- 📝 **Details immer in SAVEPOINT.md** — diese Sektion nennt nur die Etappe
|
- ✅ **Etappe 19 (v3.14):** Durchsicht Frontend/Backend — vier Placebos weg
|
||||||
|
(Fortschritt log, Auswurf tat nichts, „Alle Tracks" konnte nichts, Encoder
|
||||||
|
wurden behauptet statt gemessen), Zombie-Erkennung gebaut, Pfad-Prüfung
|
||||||
|
gehärtet, und der Platten-Schutz aus `c065967` als **unwirksam** entlarvt
|
||||||
|
- ✅ **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
|
||||||
|
|
||||||
|
**Nicht aus einem Zustandswert auf einen Mechanismus schließen.** Vorgefallen:
|
||||||
|
aus „kein Schlüssel da" → „Server abgeschaltet" (falsch), aus Status
|
||||||
|
`transcoding` → „Celery hat neu zugestellt" (falsch), aus `progress=99` →
|
||||||
|
„Altwert aus dem Absturz" (falsch — ein Bug), aus gleichem `st_dev` →
|
||||||
|
„`os.rename` funktioniert" (falsch — der Kernel vergleicht den Mount).
|
||||||
|
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 | | |
|
| Multi-Disc-Set-Handling (Release-Group-Resolver) | ✔ M | | |
|
||||||
| SQLite-Cache für API-Rate-Limits | ✔ M | | |
|
| SQLite-Cache für API-Rate-Limits | ✔ M | | |
|
||||||
| ~~JWT-Auth (Access 15min/Refresh 7 Tage)~~ — GESTRICHEN 24.07.2026, siehe §10 | | | |
|
| ~~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 | | |
|
| Celery-Queue + Redis mit AOF-Persistence | ✔ M | | |
|
||||||
| React-UI (statisch via Nginx) | ✔ M | | |
|
| React-UI (statisch via Nginx) | ✔ M | | |
|
||||||
| Proxmox LXC Template + Ansible Playbooks | ✔ 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
|
passlib/bcrypt-Abhängigkeit hat die CI-Ampel gebrochen. Rate-Limiting
|
||||||
(pro IP) bleibt. Wer Rippy je nach außen öffnet, stellt einen
|
(pro IP) bleibt. Wer Rippy je nach außen öffnet, stellt einen
|
||||||
Reverse-Proxy mit eigener Auth davor (z. B. Authelia/Caddy basicauth).
|
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
|
- **25.07.2026 — 4K-UHD-Disc-Schlüssel: Rippy stellt Platz bereit, keine
|
||||||
Schlüssel:** Das Muss-Feature „MakeMKV-Ripping (lossless)" bleibt
|
Schlüssel:** Das Muss-Feature „MakeMKV-Ripping (lossless)" bleibt
|
||||||
unverändert; ergänzt wird nur ein **persistentes MakeMKV-Datenverzeichnis**
|
unverändert; ergänzt wird nur ein **persistentes MakeMKV-Datenverzeichnis**
|
||||||
|
|||||||
@@ -2,291 +2,474 @@
|
|||||||
|
|
||||||
Disc rein → automatisch erkannt (Titel, Poster, Metadaten) → verlustfrei
|
Disc rein → automatisch erkannt (Titel, Poster, Metadaten) → verlustfrei
|
||||||
gerippt (MakeMKV) → auf Arbeitsgröße komprimiert (HandBrake) → fertig
|
gerippt (MakeMKV) → auf Arbeitsgröße komprimiert (HandBrake) → fertig
|
||||||
abgelegt, wo DU willst (lokal, NAS, jede Freigabe). Modernes Web-UI,
|
abgelegt, wo du willst (lokal, NAS, jede Freigabe). Modernes Web-UI, komplett
|
||||||
Echtzeit-Fortschritt, komplett in Docker, komplett lokal.
|
in Docker, komplett lokal — keine Cloud, keine Telemetrie.
|
||||||
|
|
||||||
## Schnellstart
|
---
|
||||||
|
|
||||||
Voraussetzungen: Docker + Docker Compose, ein optisches Laufwerk am Host.
|
## Installation
|
||||||
|
|
||||||
|
Du brauchst: einen **Linux-Rechner mit Docker** und ein **optisches Laufwerk**.
|
||||||
|
|
||||||
|
**Welche Distribution, ist gleichgültig** — Debian, Ubuntu, Arch, Fedora,
|
||||||
|
openSUSE, Alpine. Rippy bringt alles Nötige (MakeMKV, HandBrake, abcde) in
|
||||||
|
seinen eigenen Containern mit; vom Host braucht es nur Docker und einen Kernel,
|
||||||
|
der das Laufwerk sieht. `install.sh` erkennt das Paketwerkzeug selbst und nennt
|
||||||
|
dir den passenden Befehl, falls etwas fehlt.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone <repo-url> rippy && cd rippy
|
git clone <repo-url> rippy && cd rippy
|
||||||
cp .env.example .env # optional: API-Keys — geht auch später im UI
|
sudo ./install.sh
|
||||||
mkdir -p /srv/rippy/media # Ablage-Basis (anpassbar in docker-compose.yml)
|
```
|
||||||
|
|
||||||
|
Das war es. Am Ende steht die Adresse im Terminal — im Browser öffnen, der
|
||||||
|
**Einrichtungs-Assistent** übernimmt den Rest.
|
||||||
|
|
||||||
|
Zuerst nur nachsehen, ob alles passt, ohne etwas zu ändern:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./install.sh --nur-pruefen
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Was <code>install.sh</code> für dich macht</b> (aufklappen)</summary>
|
||||||
|
|
||||||
|
Es nimmt genau die Schritte ab, an denen man vorher scheitern konnte:
|
||||||
|
|
||||||
|
1. **Prüft die Voraussetzungen** — Linux, Docker, Compose. Fehlt etwas, steht
|
||||||
|
der Installationsbefehl dabei.
|
||||||
|
2. **Findet dein Laufwerk selbst.** MakeMKV braucht **zwei** Geräteknoten:
|
||||||
|
`/dev/srN` und den passenden `/dev/sgM`. Welche sg-Nummer dazugehört, ist
|
||||||
|
**je Rechner anders** — das Skript gleicht sie über die SCSI-Adresse ab
|
||||||
|
statt zu raten. (Genau daran ging vorher die Handarbeit schief.)
|
||||||
|
3. **Legt die Verzeichnisse an** (`/srv/rippy/media` für die Filme,
|
||||||
|
`/srv/rippy/makemkv` für MakeMKVs Daten).
|
||||||
|
4. **Richtet die Mount-Propagation ein** und macht sie neustart-fest — sonst
|
||||||
|
funktioniert das Einhängen von NAS-Freigaben aus dem UI nach jedem Reboot
|
||||||
|
nicht mehr.
|
||||||
|
5. **Schreibt die `.env`** mit den gefundenen Werten. Bestehende Werte werden
|
||||||
|
**nie** überschrieben.
|
||||||
|
6. **Baut und startet** alles.
|
||||||
|
|
||||||
|
Das Skript ist **wiederholbar**: mehrmals ausführen ändert nichts kaputt. Es
|
||||||
|
ist auch der Update-Weg — siehe unten.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### Aktualisieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull && sudo ./install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mit einer Docker-Oberfläche (Dockge, Portainer, Arcane)
|
||||||
|
|
||||||
|
**Die eine Sache, die du wissen musst:** Rippy hat **keine fertigen Images in
|
||||||
|
einer Registry.** Die drei Dienste `api`, `worker` und `ui` werden aus dem Repo
|
||||||
|
gebaut (`build: context: .`). Die Compose-Datei allein ist deshalb wertlos —
|
||||||
|
wer sie in ein leeres Verzeichnis kopiert, bekommt sofort
|
||||||
|
*„failed to read dockerfile"*.
|
||||||
|
|
||||||
|
Daraus folgt für **jede** Oberfläche: **das Repo muss dort liegen, wo die
|
||||||
|
Oberfläche den Stack baut.** Alles andere ist Kleinarbeit.
|
||||||
|
|
||||||
|
| | kann das Repo selbst holen? | Weg |
|
||||||
|
|---|---|---|
|
||||||
|
| **Shell** | — | `git clone` + `sudo ./install.sh` |
|
||||||
|
| **Dockge** | nein | Repo **in** den Stacks-Ordner klonen |
|
||||||
|
| **Portainer** | **ja** | Stack aus *Repository* (Git-URL) |
|
||||||
|
| **Arcane** | nein | Repo auf dem Host, Deploy per Shell |
|
||||||
|
|
||||||
|
### Vorab-Prüfung — braucht kein root, ändert nichts
|
||||||
|
|
||||||
|
Egal welche Oberfläche: Führe nach dem Klonen einmal das hier aus. Es sagt dir
|
||||||
|
in fünf Sekunden, ob die Maschine passt — vor allem, **welche Geräteknoten dein
|
||||||
|
Laufwerk wirklich hat**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./install.sh --nur-pruefen
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dockge
|
||||||
|
|
||||||
|
Dockge verwaltet Stacks als Verzeichnisse. Klone das Repo **in** den
|
||||||
|
Stacks-Ordner (Standard `/opt/stacks`, bei dir ggf. anders):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/stacks
|
||||||
|
git clone <repo-url> rippy
|
||||||
|
cd rippy && ./install.sh --nur-pruefen
|
||||||
|
```
|
||||||
|
|
||||||
|
Dockge zeigt `rippy` danach als Stack an, und „Start" baut die Images. Du musst
|
||||||
|
**keine Compose-Datei einfügen** — die des Repos ist die des Stacks. Lege den
|
||||||
|
Stack also **nicht** neu in Dockge an, sonst landet eine leere Compose-Datei in
|
||||||
|
einem anderen Ordner und der Bau scheitert.
|
||||||
|
|
||||||
|
### Portainer
|
||||||
|
|
||||||
|
Portainer kann das Repo selbst klonen, das ist hier der bequemste Weg:
|
||||||
|
**Stacks → Add stack → Repository**, Git-URL eintragen, Compose-Pfad
|
||||||
|
`docker-compose.yml`. Der Web-Editor und „Upload" funktionieren **nicht** —
|
||||||
|
beide liefern keinen Build-Kontext.
|
||||||
|
|
||||||
|
Die Geräteknoten trägst du als Stack-Umgebungsvariablen ein (statt in eine
|
||||||
|
`.env`), falls sie von `/dev/sr0` und `/dev/sg1` abweichen:
|
||||||
|
|
||||||
|
```
|
||||||
|
OPTICAL_SR=/dev/sr0
|
||||||
|
OPTICAL_SG=/dev/sg0
|
||||||
|
```
|
||||||
|
|
||||||
|
Welche es sind, sagt dir auf dem Host `lsscsi -g` — oder die Vorab-Prüfung oben.
|
||||||
|
|
||||||
|
### Arcane
|
||||||
|
|
||||||
|
Das Repo liegt auf dem Host (z. B. `~/projects/rippy`), Arcane verwaltet die
|
||||||
|
Container. Deployt wird per Shell — Arcanes Git-Sync zieht **nicht**
|
||||||
|
selbstständig:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/projects/rippy && git pull --ff-only && docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Was keine Oberfläche für dich tun kann
|
||||||
|
|
||||||
|
Drei Dinge passieren auf dem **Host**, nicht im Container — deshalb gibt es
|
||||||
|
`install.sh` überhaupt:
|
||||||
|
|
||||||
|
1. **Geräteknoten.** MakeMKV braucht `/dev/srN` **und** den passenden
|
||||||
|
`/dev/sgM`; die sg-Nummer ist je Rechner anders. Stimmt sie nicht, **startet
|
||||||
|
der Worker-Container gar nicht.** Das ist der Fehler, der praktisch immer als
|
||||||
|
erster kommt.
|
||||||
|
2. **Ablage-Ordner** `/srv/rippy/media` und `/srv/rippy/makemkv`.
|
||||||
|
3. **Mount-Propagation** (`rshared`) für das Einhängen von NAS-Freigaben aus dem
|
||||||
|
UI. Auf den meisten systemd-Hosts ist `/` schon `rshared` und es ist nichts
|
||||||
|
zu tun — nur wenn `docker compose up` über *„not a shared mount"* klagt,
|
||||||
|
braucht es die Shell.
|
||||||
|
|
||||||
|
Punkt 1 ist der einzige, der zuverlässig zuschlägt. `sudo ./install.sh` erledigt
|
||||||
|
alle drei; danach kannst du den Stack dauerhaft über die Oberfläche fahren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wenn etwas nicht geht
|
||||||
|
|
||||||
|
Die sechs Fälle, die praktisch alles abdecken:
|
||||||
|
|
||||||
|
| Symptom | Ursache & Lösung |
|
||||||
|
|---|---|
|
||||||
|
| **`failed to read dockerfile`** | Die Compose-Datei liegt ohne das Repo da. Rippy hat **keine Registry-Images**, die drei Dienste werden gebaut. Siehe **[Mit einer Docker-Oberfläche](#mit-einer-docker-oberfläche-dockge-portainer-arcane)**. |
|
||||||
|
| **Worker-Container startet nicht, `/dev/sgN` nicht gefunden** | Die sg-Nummer ist **je Rechner anders**; die Vorgaben `sr0`/`sg1` passen nur zufällig. `./install.sh --nur-pruefen` sagt dir die richtigen (oder `lsscsi -g`), dann in die `.env` bzw. als Stack-Variablen eintragen. **Der Fehler, der praktisch immer als erster kommt.** |
|
||||||
|
| **„Kein optisches Laufwerk gefunden"** | In einer **VM**? Das Laufwerk muss per **USB-Passthrough** durchgereicht werden, nicht als emuliertes CD-ROM (`media=cdrom`) — das kann keine SCSI-Kommandos, MakeMKV sieht es nie. Proxmox: `qm set <vmid> -usb0 host=<hersteller>:<produkt>,usb3=1`. Auf echter Hardware: `ls /dev/sr*` prüfen. |
|
||||||
|
| **Build bricht beim MakeMKV-Download ab** | Cloudflare drosselt manchmal. Der zuverlässige Weg: Tarballs von makemkv.com/download händisch nach `docker/worker/vendor/` legen, dann `sudo ./install.sh` erneut — der Build nimmt sie von dort und braucht kein Netz. Hast du eine eigene Quelle (Spiegel im LAN), trage sie als `MAKEMKV_URL_FALLBACK` in die `.env` ein; sie wird automatisch versucht, wenn makemkv.com nicht liefert. |
|
||||||
|
| **Kompression läuft ewig** | Deine CPU kann kein AVX2. Rippy zeigt das jetzt selbst an (Einstellungen → System, „Vektorbefehle"). Siehe **[Rippy schneller machen](#rippy-schneller-machen)**. |
|
||||||
|
| **4K-UHD: „The volume key is unknown"** | Erwartbar und **kein Fehler in Rippy**. Siehe **[4K-UHD](#4k-uhd)**. |
|
||||||
|
|
||||||
|
Logs ansehen: `docker compose logs -f` · Status: `docker compose ps`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rippy schneller machen
|
||||||
|
|
||||||
|
**Läuft Rippy in einer virtuellen Maschine, ist das hier der wirksamste
|
||||||
|
Handgriff überhaupt** — und er kostet fünf Minuten.
|
||||||
|
|
||||||
|
Virtualisierer geben der VM standardmäßig eine **generische CPU** (bei
|
||||||
|
Proxmox/KVM heißt sie `kvm64` bzw. `qemu64`). Die kann absichtlich nur alte
|
||||||
|
Befehlssätze, damit sich eine VM zwischen verschiedenen Wirten verschieben
|
||||||
|
lässt. Der Preis: **kein AVX2** — und genau davon lebt der Video-Encoder x265.
|
||||||
|
|
||||||
|
Was das ausmacht, ist auf der Rippy-Maschine gemessen worden: ein 4K-Film in
|
||||||
|
H.265 brauchte **28 bis 55 Stunden**, bei voll ausgelasteten Kernen. Mit AVX2
|
||||||
|
sind typisch **2- bis 4-mal schneller** drin.
|
||||||
|
|
||||||
|
**So stellst du es um (Proxmox):**
|
||||||
|
|
||||||
|
1. VM **herunterfahren.** Ein Neustart von innen genügt nicht — Proxmox
|
||||||
|
übernimmt Hardware-Änderungen nur bei einem echten Stopp.
|
||||||
|
2. Im Web-UI: VM auswählen → **Hardware** → **Processors** → Doppelklick →
|
||||||
|
**Type** auf **`host`** stellen → OK.
|
||||||
|
Oder auf der Proxmox-Konsole:
|
||||||
|
```bash
|
||||||
|
qm set <vmid> --cpu host
|
||||||
|
```
|
||||||
|
3. VM **starten.**
|
||||||
|
|
||||||
|
**Nachprüfen — Rippy sagt es dir selbst:** Einstellungen → System, Zeile
|
||||||
|
„Vektorbefehle" beim Worker. Steht dort jetzt `avx2` oder `avx512f` statt
|
||||||
|
`sse4_2`, hat es geklappt und die amberfarbene Warnung verschwindet.
|
||||||
|
Auf der Kommandozeile: `grep -o avx2 /proc/cpuinfo | head -1`.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Nachteile von <code>host</code> — der Vollständigkeit halber</summary>
|
||||||
|
|
||||||
|
Die VM sieht dann die echte CPU. Dadurch lässt sie sich nicht mehr auf einen
|
||||||
|
Wirt mit anderem Prozessor **live** verschieben. In einem Heim-Cluster mit
|
||||||
|
einem einzigen Wirt ist das ohne Bedeutung. Wer mehrere Wirte hat und
|
||||||
|
Live-Migration nutzt, wählt statt `host` das neueste Modell, das **alle**
|
||||||
|
Wirte beherrschen (z. B. `x86-64-v3` — das enthält AVX2).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
**Kein AVX2 möglich?** Dann zwei Auswege, beide im UI:
|
||||||
|
- **4K nicht komprimieren** (Einstellungen → Verarbeitung → Preset für 4K-UHD →
|
||||||
|
„Nicht komprimieren"). Die verlustfreie Datei bleibt stehen: beste Qualität,
|
||||||
|
20–100 GB je Film. Der Einrichtungs-Assistent wählt das bei schwacher CPU
|
||||||
|
von selbst.
|
||||||
|
- **Andere Maschine komprimieren lassen** — siehe
|
||||||
|
[Weitere Maschinen als Worker](#weitere-maschinen-als-worker).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Der Alltag
|
||||||
|
|
||||||
|
### Rippen
|
||||||
|
|
||||||
|
Disc einlegen. Rippy erkennt sie, zeigt Titel und Poster, du wählst das Ziel —
|
||||||
|
oder du stellst die **Vollautomatik** an (Einstellungen → Ripping) und es
|
||||||
|
läuft ohne Nachfrage los.
|
||||||
|
|
||||||
|
- **Alle Tonspuren und Untertitel bleiben erhalten** (wichtig für Anime/O-Ton).
|
||||||
|
- **Nur Hauptfilm** auf Wunsch — der längste Titel, Extras bleiben weg.
|
||||||
|
- **Serien:** Serienname + Staffel angeben → Ablage `<Serie>/Season NN`, und
|
||||||
|
die Episoden werden per Laufzeitabgleich (TMDB) zu „Serie S01E02.mkv"
|
||||||
|
benannt. Nur bei eindeutiger Zuordnung, sonst bleiben die Namen — mit Log.
|
||||||
|
- **Audio-CDs** laufen über abcde zu FLAC mit MusicBrainz-Tags.
|
||||||
|
- Schon gerippte Discs erkennt Rippy am Fingerabdruck und warnt.
|
||||||
|
- Vor dem Start wird der freie Platz gegen die Disc-Größe geprüft.
|
||||||
|
|
||||||
|
### Kompression
|
||||||
|
|
||||||
|
Ein **eigenes Preset je Disc-Typ** (Einstellungen → Verarbeitung) — Rippy
|
||||||
|
erkennt den Typ selbst. Das ist wichtig, weil ein 1080p-Preset eine 4K-UHD
|
||||||
|
stillschweigend herunterrechnet und eine DVD sinnlos hochskaliert.
|
||||||
|
|
||||||
|
Jeder Typ kann auch auf **„Nicht komprimieren"** stehen: dann bleibt der
|
||||||
|
verlustfreie Rip liegen. Sinnvoll für 4K, wenn die Ablage groß genug ist.
|
||||||
|
|
||||||
|
Die Rohdatei wird erst nach erfolgreicher Kompression gelöscht („Original
|
||||||
|
behalten" als Option). Fehlgeschlagene Kompressionen lassen sich ohne
|
||||||
|
Neu-Rip erneut anstoßen.
|
||||||
|
|
||||||
|
### Ablage & Media-Server
|
||||||
|
|
||||||
|
Einstellungen → Ripping: dein System wählen (Jellyfin, Emby, Kodi, Plex oder
|
||||||
|
keins). Fertige Rips heißen dann „Titel (Jahr)"; für Jellyfin/Emby/Kodi legt
|
||||||
|
Rippy zusätzlich `movie.nfo` + `poster.jpg` dazu. Bei **Jellyfin/Emby** mit
|
||||||
|
Server-URL + API-Key stößt Rippy nach jedem Rip sofort einen
|
||||||
|
Bibliotheks-Scan an — Disc rein, Film erscheint im Server.
|
||||||
|
|
||||||
|
**NAS-Freigaben** hängst du unter Einstellungen → Speicherziele direkt aus dem
|
||||||
|
UI ein (NFS/SMB). Sie erscheinen sofort in der Ziel-Auswahl und werden beim
|
||||||
|
Start automatisch wieder verbunden.
|
||||||
|
⚠️ Zugangsdaten liegen unverschlüsselt in der lokalen Datenbank — bewusster
|
||||||
|
Heimnetz-Kompromiss. Lege fürs NAS einen eigenen, eingeschränkten Benutzer an.
|
||||||
|
|
||||||
|
### Benachrichtigungen
|
||||||
|
|
||||||
|
Einstellungen → Benachrichtigungen: eine Webhook-URL eintragen, „Test senden"
|
||||||
|
drücken, speichern. Rippy erkennt den Dienst an der URL selbst:
|
||||||
|
|
||||||
|
| Dienst | URL-Beispiel |
|
||||||
|
|---|---|
|
||||||
|
| Discord | `https://discord.com/api/webhooks/…` |
|
||||||
|
| Slack | `https://hooks.slack.com/services/…` |
|
||||||
|
| ntfy (Handy-Push) | `https://ntfy.sh/mein-geheimes-thema` |
|
||||||
|
| Eigenes (Home Assistant, n8n, …) | beliebige HTTPS-URL |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4K-UHD
|
||||||
|
|
||||||
|
4K braucht **zwei** Dinge, die normale BD/DVD nicht brauchen: ein
|
||||||
|
**LibreDrive-fähiges Laufwerk** (MakeMKV-Forum: „Ultimate UHD Drives Flashing
|
||||||
|
Guide") **und** den **Schlüssel dieser Pressung**.
|
||||||
|
|
||||||
|
**Der Schlüssel ist die eigentliche Hürde — aus einem überraschenden Grund.**
|
||||||
|
Am 25.07.2026 auf beiden Maschinen gemessen (Akira UHD, MKB v76, Pressung
|
||||||
|
Dezember 2020): Laufwerk und MakeMKV waren in Ordnung — „Using LibreDrive mode
|
||||||
|
(v06.3)", die Disc wurde gelesen — und trotzdem kam „The volume key is unknown
|
||||||
|
for this disc".
|
||||||
|
|
||||||
|
> **`makemkvcon` unter Linux holt Disc-Schlüssel nie aus dem Netz.
|
||||||
|
> Die Windows-Version tut es.**
|
||||||
|
|
||||||
|
Gemessen: Linux baute in keinem einzigen Lauf eine Verbindung nach draußen auf
|
||||||
|
— geprüft mit leerem und gefülltem Schlüsselspeicher, mit und ohne `--noscan`,
|
||||||
|
mit `dev:` und `disc:`, und mit erzwungener frischer Prüfung. Dieselbe Disc am
|
||||||
|
selben Laufwerk unter Windows: „Lade aktuelle HK …", Verbindung zum
|
||||||
|
Schlüssel-Server, Disc geht auf. **Ein MakeMKV-Update ändert daran nichts, und
|
||||||
|
es ist kein Fehler in Rippy.**
|
||||||
|
|
||||||
|
**Der Weg drumherum** (Einstellungen → System):
|
||||||
|
|
||||||
|
1. MakeMKV auf einem **Windows-PC** installieren (gleicher Beta-Key).
|
||||||
|
2. Laufwerk anstecken, Disc **einmal öffnen** — dabei lädt MakeMKV die
|
||||||
|
Schlüssel nach.
|
||||||
|
3. In MakeMKV unter *Preferences → General* das „MakeMKV data directory"
|
||||||
|
nachschlagen und die Datei **`_private_data.tar`** daraus bei Rippy
|
||||||
|
hochladen.
|
||||||
|
|
||||||
|
Rippy zeigt an, **wie viele Disc-Schlüssel** dieser Worker kennt. Steht dort 0,
|
||||||
|
scheitert jede unbekannte UHD. Für neue Discs den Schritt gelegentlich
|
||||||
|
wiederholen. Rippy prüft die Datei und lehnt sie ab, wenn kein einziger
|
||||||
|
Schlüssel drinsteckt — sonst ändert sich nichts und niemand versteht, warum.
|
||||||
|
|
||||||
|
**Kennt MakeMKV die Pressung selbst nicht**, bleibt die **`KEYDB.cfg`** als
|
||||||
|
Notnagel (gleicher Tab). Und: **AACS-Dumps herunterladen** — den Dump legt
|
||||||
|
MakeMKV bei jeder unbekannten Disc selbst ab, und genau den braucht man, wenn
|
||||||
|
man im MakeMKV-Forum um den Schlüssel für eine neue Pressung bittet.
|
||||||
|
|
||||||
|
> ⚠️ **Rippy liefert keine Disc-Schlüssel mit, lädt keine herunter und
|
||||||
|
> verteilt keine.** Rippy hält nur den Platz für eine Datei bereit, die du
|
||||||
|
> selbst mitbringst, und zeigt ehrlich an, was dort liegt. Der
|
||||||
|
> MakeMKV-**Beta-Key** ist die Lizenz für die *Software* — zwei völlig
|
||||||
|
> verschiedene Dinge.
|
||||||
|
|
||||||
|
⚠️ UHD-Rohdaten sind bis 100 GB groß. Ist die Platte zu klein, lege das
|
||||||
|
**Arbeitsverzeichnis** (Einstellungen → Verarbeitung) auf eine eingehängte
|
||||||
|
Freigabe. Rippy bricht sonst **vor** dem Rip mit Klartext ab statt nach 40 GB
|
||||||
|
mit voller Platte.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Weitere Maschinen als Worker
|
||||||
|
|
||||||
|
Die Kompression läuft als eigener Task auf der Queue `transcode` — **jede**
|
||||||
|
Maschine im Netz kann sie übernehmen. Einstellungen → Worker zeigt für beide
|
||||||
|
Varianten einen Copy-Paste-Befehl:
|
||||||
|
|
||||||
|
- **Windows (nativ, ohne Docker)** — braucht nur Python. Der Installer kommt
|
||||||
|
als fertige `.exe` von Rippy selbst; Worker-Code und HandBrake holt er zur
|
||||||
|
Laufzeit.
|
||||||
|
- **Linux (Docker)** — `deploy/remote-transcode-worker.yml`.
|
||||||
|
|
||||||
|
Rippy zeigt für jeden Worker an, **was er wirklich kann**: Encoder, Kernzahl,
|
||||||
|
Vektorbefehle. Für einen **GPU-Worker** wichtig: das Bild braucht ein
|
||||||
|
HandBrake mit `nvenc_*` bzw. `qsv_*` — die Anzeige nennt HandBrakes
|
||||||
|
ungefilterte Auskunft, damit du das nachprüfen kannst. Das mitgelieferte
|
||||||
|
Linux-Worker-Bild hat **keinen** Hardware-Encoder.
|
||||||
|
|
||||||
|
Ohne Zusatz-Worker macht der eingebaute CPU-Worker alles selbst — Rippy bleibt
|
||||||
|
All-in-one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Für Fortgeschrittene
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Umgebungsvariablen (.env)</b></summary>
|
||||||
|
|
||||||
|
`install.sh` füllt die hostabhängigen Werte selbst. Alles hier ist optional —
|
||||||
|
UI-Einstellungen überstimmen die Env-Variablen.
|
||||||
|
|
||||||
|
| Variable | Zweck |
|
||||||
|
|---|---|
|
||||||
|
| `TMDB_API_KEY` | Metadaten (deutsche Texte). Bequemer im Assistenten — beide Key-Arten gehen (v3-Schlüssel und v4-Token) |
|
||||||
|
| `OMDB_API_KEY` | zweite Metadaten-Quelle (Fallback) |
|
||||||
|
| `THETVDB_API_KEY` | Serien-Fallback |
|
||||||
|
| `WORKER_NAME` | Anzeigename des eingebauten Workers (Standard `rippy-hauptworker`) |
|
||||||
|
| `MAKEMKV_APP_KEY` | MakeMKV-Beta-Key. Bequemer im UI unter Einstellungen → System — gilt ab dem nächsten Rip, ohne Rebuild |
|
||||||
|
| `MAKEMKV_VERSION` | MakeMKV-Version für den Image-Build |
|
||||||
|
| `MAKEMKV_URL_BASE` | Download-Quelle für den Build. Normalerweise **nichts eintragen** — der Standard `makemkv.com/download` liefert (am 25.07.2026 mit HTTP 200 geprüft; `/download/old` gibt 525, ein web.archive.org-Schnappschuss 404) |
|
||||||
|
| `MAKEMKV_URL_FALLBACK` | zweite Quelle, die bei Fehlschlag der ersten **automatisch** versucht wird. Leer = keine. Trage nur ein, was du selbst geprüft hast: eine Adresse, die nicht liefert, lässt die Konfiguration gesund aussehen und den Build später scheitern |
|
||||||
|
| `MAKEMKV_DATA_HOST` | Host-Verzeichnis für MakeMKVs Daten (Standard `/srv/rippy/makemkv`) — Schlüsselspeicher, `KEYDB.cfg`, AACS-Dumps. Persistent, überlebt jeden Rebuild |
|
||||||
|
| `OPTICAL_SR` / `OPTICAL_SG` | Geräteknoten des Laufwerks. **Findet `install.sh` selbst** |
|
||||||
|
| `POSTGRES_PASSWORD` | DB-Passwort (Standard `rippy`) |
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Installation ohne install.sh (von Hand)</b></summary>
|
||||||
|
|
||||||
|
Falls du jeden Schritt selbst machen willst:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
mkdir -p /srv/rippy/media /srv/rippy/makemkv
|
||||||
|
|
||||||
|
# Laufwerksknoten ermitteln — die sg-Nummer ist je Host anders!
|
||||||
|
lsscsi -g # zeigt Modell + zugehoerigen /dev/sgN
|
||||||
|
# in die .env: OPTICAL_SR=/dev/sr0 und OPTICAL_SG=/dev/sgN
|
||||||
|
|
||||||
|
# Nur falls 'docker compose up' meldet:
|
||||||
|
# "path ... is mounted on / but it is not a shared mount"
|
||||||
|
mount --bind /srv/rippy/media /srv/rippy/media
|
||||||
|
mount --make-rshared /srv/rippy/media # neustart-fest als systemd-.mount-Unit
|
||||||
|
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Dann `http://<host>` öffnen — der **Einrichtungs-Assistent** startet beim
|
Braucht klassisches Linux-Docker mit `SYS_ADMIN` — nicht Docker Desktop,
|
||||||
ersten Mal automatisch (API-Keys, Verarbeitung, erkannte Hardware).
|
rootless oder Podman. Die Mount-Propagation ist nur für das Einhängen von
|
||||||
|
NAS-Freigaben **aus dem UI** nötig; Freigaben auf dem Host einhängen geht immer.
|
||||||
|
|
||||||
### Laufwerk anpassen
|
</details>
|
||||||
|
|
||||||
Der Worker braucht **zwei** Host-Geräteknoten: den CD-ROM-Knoten (meist
|
<details>
|
||||||
`/dev/sr0`) und den passenden **SCSI-Generic-Knoten** (`/dev/sgN`) — MakeMKV
|
<summary><b>Wie es intern funktioniert</b></summary>
|
||||||
spricht Laufwerke über die sg-Schicht an. Die sg-Nummer ist **je Host anders**
|
|
||||||
(auf frischen Systemen oft `/dev/sg0`).
|
|
||||||
|
|
||||||
Ermitteln, welche Knoten dein Blu-ray/DVD-Laufwerk hat:
|
1. **Disc-Wache** — ioctl-Polling, kein udev. Im Container läuft kein udevd,
|
||||||
```bash
|
die udev-Datenbank ist leer; `udevadm info` liefert dort prinzipbedingt
|
||||||
lsscsi -g # zeigt Modell + zugehörigen /dev/sgN
|
nichts.
|
||||||
# oder: ls -la /sys/class/scsi_generic/
|
2. **Rip** — MakeMKV, verlustfrei. Der einzige Weg durch AACS: HandBrake kann
|
||||||
```
|
verschlüsselte Discs nicht lesen, deshalb sind es zwingend zwei Stufen.
|
||||||
Dann in der `.env` eintragen (Defaults passen für den Ursprungs-Host):
|
3. **Kompression** — HandBrake als eigener Celery-Task auf eigener Queue,
|
||||||
```bash
|
deshalb an andere Maschinen routbar.
|
||||||
OPTICAL_SR=/dev/sr0 # CD-ROM-Knoten deines Laufwerks
|
4. **Metadaten** — Volume-Label → TMDB → OMDb → MyAnimeList, mit
|
||||||
OPTICAL_SG=/dev/sg0 # sg-Knoten deines Laufwerks
|
Ähnlichkeits-Bewertung und Korrektur-Popup an der Disc-Karte.
|
||||||
```
|
|
||||||
`docker compose up -d` nutzt diese Werte. (Für Feineinstellungen geht weiterhin
|
|
||||||
eine `docker-compose.override.yml` mit eigenen `devices:`-Einträgen.)
|
|
||||||
|
|
||||||
**Laufwerk in einer VM?** Per USB-Passthrough anhand der Vendor-ID
|
</details>
|
||||||
durchreichen (Proxmox: `qm set <vmid> -usb0 host=xxxx:yyyy,usb3=1`) —
|
|
||||||
NICHT als emuliertes CD-ROM (`media=cdrom`), das kann keine SCSI-Kommandos.
|
|
||||||
|
|
||||||
## Wie es funktioniert
|
<details>
|
||||||
|
<summary><b>Härtung für exponierte Netze</b></summary>
|
||||||
|
|
||||||
1. **Disc-Wache** (ioctl-Polling, kein udev-Gefrickel) erkennt Einlegen,
|
Rippy ist bewusst **Heimnetz-only**: keine Authentifizierung, und Redis +
|
||||||
identifiziert die Disc (Volume-Label → TMDB → OMDb-Fallback) und zeigt
|
PostgreSQL veröffentlichen Ports für Remote-Worker. Im vertrauten LAN ein
|
||||||
sie mit Poster auf dem Dashboard. Klick auf einen Job-Titel öffnet die
|
akzeptierter Kompromiss — **exponiere Rippy niemals ungeschützt an ein
|
||||||
Detail-Ansicht (Poster, Jahr, Beschreibung, Ablagepfad).
|
öffentliches Netz.**
|
||||||
2. **Rip** (MakeMKV, verlustfrei — der einzige Weg durch AACS): Ziel wählst
|
|
||||||
du beim Start (Filme/Serien/Musik/eigener Pfad, inkl. Netzwerk-Ziele) —
|
|
||||||
oder du stellst die **Vollautomatik** an (Einstellungen → Ripping) und
|
|
||||||
der Rip startet ohne Nachfrage in den passenden Schnellwahl-Ordner.
|
|
||||||
Optional **nur Hauptfilm** (längster Titel, Extras bleiben weg). Bereits
|
|
||||||
gerippte Discs erkennt Rippy am Fingerabdruck und warnt. Vor dem Start
|
|
||||||
prüft Rippy den freien Platz gegen die Disc-Größe. Alle Tonspuren und
|
|
||||||
Untertitel bleiben erhalten (wichtig für Anime/O-Ton). Audio-CDs laufen
|
|
||||||
über abcde → FLAC + MusicBrainz.
|
|
||||||
**Serien**: Im Dialog „Serien" wählen → Serienname + Staffel angeben —
|
|
||||||
Ablage wird `<Serie>/Season NN`, und die Episoden werden nach dem Rip
|
|
||||||
per Laufzeitabgleich (TMDB) automatisch zu „Serie S01E02.mkv" benannt
|
|
||||||
(nur bei eindeutiger Zuordnung — sonst bleiben die Namen, mit Log).
|
|
||||||
3. **Kompression** (HandBrake, eigener Job auf eigener Queue): x265/x264,
|
|
||||||
**je Disc-Typ ein eigenes Preset** (Einstellungen → Verarbeitung) —
|
|
||||||
Rippy kennt den Typ und wählt selbst. Wichtig, weil ein 1080p-Preset
|
|
||||||
eine 4K-UHD stillschweigend herunterrechnet und ein 1080p-Preset eine
|
|
||||||
DVD sinnlos hochskaliert. Sind die drei Felder nie gespeichert worden,
|
|
||||||
gilt weiter das alte allgemeine Preset — Bestandsinstallationen ändern
|
|
||||||
ihr Verhalten also nicht von selbst.
|
|
||||||
Rohdatei wird erst nach Erfolg gelöscht
|
|
||||||
(„Original behalten" als Option). Fehlgeschlagene Kompressionen lassen
|
|
||||||
sich ohne Neu-Rip neu anstoßen.
|
|
||||||
4. **Media-Server-Ablage**: Unter Einstellungen → Ripping (oder im
|
|
||||||
Einrichtungs-Assistenten) wählst du dein System — Jellyfin, Emby, Kodi,
|
|
||||||
Plex oder keins. Fertige Rips heißen dann „Titel (Jahr)" statt
|
|
||||||
Job-UUID; für Jellyfin/Emby/Kodi legt Rippy zusätzlich movie.nfo +
|
|
||||||
poster.jpg dazu (Kodi-NFO-Schema, lesen alle drei nativ). Plex nutzt
|
|
||||||
nur die Benennung. **Jellyfin/Emby**: Server-URL + API-Key eintragen,
|
|
||||||
dann stößt Rippy nach jedem fertigen Rip sofort einen Bibliotheks-Scan
|
|
||||||
an — Disc rein, Film erscheint im Server.
|
|
||||||
5. **4K-UHD**: braucht **zwei** Dinge — ein LibreDrive-fähiges Laufwerk
|
|
||||||
(MakeMKV-Forum: „Ultimate UHD Drives Flashing Guide") **und** einen
|
|
||||||
passenden Disc-Schlüssel. Normale BD/DVD gehen mit jedem Laufwerk und
|
|
||||||
ohne Schlüssel-Datei.
|
|
||||||
**Der Schlüssel ist heute die eigentliche Hürde — und zwar aus einem
|
|
||||||
überraschenden Grund.** Am 25.07.2026 auf beiden Maschinen gemessen
|
|
||||||
(Akira UHD, MKB v76, Pressung Dezember 2020): Laufwerk und MakeMKV
|
|
||||||
waren in Ordnung — „Using LibreDrive mode (v06.3)", die Disc wurde
|
|
||||||
gelesen — und trotzdem kam „The volume key is unknown for this disc".
|
|
||||||
Die Ursache:
|
|
||||||
**`makemkvcon` unter Linux holt Disc-Schlüssel nie aus dem Netz. Die
|
|
||||||
Windows-Version tut es.**
|
|
||||||
Gemessen: Linux baute in keinem einzigen Lauf eine Verbindung nach
|
|
||||||
draußen auf — geprüft mit leerem und mit gefülltem Schlüsselspeicher,
|
|
||||||
mit und ohne `--noscan`, mit `dev:` und `disc:`, und mit erzwungener
|
|
||||||
frischer Prüfung. Dieselbe Disc am selben Laufwerk unter Windows: „Lade
|
|
||||||
aktuelle HK …", Verbindung zum Schlüssel-Server, Disc geht auf.
|
|
||||||
Ein MakeMKV-Update ändert daran nichts, und es ist kein Fehler in
|
|
||||||
Rippy.
|
|
||||||
**Der Weg drumherum:** MakeMKV einmal auf einem Windows-PC die Disc
|
|
||||||
öffnen lassen und die dabei gefüllte Datei `_private_data.tar` bei
|
|
||||||
Rippy unter **Einstellungen → System** hochladen (siehe unten). Für
|
|
||||||
Pressungen, die auch MakeMKV nicht kennt, bleibt die **`KEYDB.cfg`**
|
|
||||||
als Notnagel.
|
|
||||||
⚠️ **Rippy liefert keine Schlüssel mit, lädt keine herunter und
|
|
||||||
verteilt keine.** Rippy stellt nur den Platz für eine Datei bereit, die
|
|
||||||
du selbst mitbringst, und zeigt dir ehrlich an, was dort liegt.
|
|
||||||
⚠️ UHD-Rohdaten sind bis 100 GB groß — wenn die Platte der
|
|
||||||
Rippy-Maschine dafür zu klein ist, lege das **Arbeitsverzeichnis**
|
|
||||||
(Einstellungen → Verarbeitung) auf eine eingehängte Freigabe, z. B.
|
|
||||||
`/app/media/nas-arbeit`. Rippy bricht sonst VOR dem Rip mit einer
|
|
||||||
Klartext-Meldung ab statt nach 40 GB mit voller Platte.
|
|
||||||
|
|
||||||
## Speicherziele (NAS, Freigaben)
|
- **Zugriff kapseln:** UI und API (`:8000`) hinter einen Reverse-Proxy mit Auth
|
||||||
|
oder nur über VPN erreichbar machen.
|
||||||
Unter **Einstellungen → Speicherziele** hängst du NFS- oder SMB-Freigaben
|
- **DB/Broker abschotten:** starkes `POSTGRES_PASSWORD`; die Ports
|
||||||
direkt aus dem UI ein — sie erscheinen sofort in der Ziel-Auswahl beim
|
`5432`/`6379` nur an ein internes/VPN-Interface binden statt an `0.0.0.0`;
|
||||||
Rippen und werden beim Start automatisch wieder verbunden.
|
Redis mit `--requirepass` starten und das Passwort in `REDIS_URL` ergänzen.
|
||||||
Technik: der api-Container läuft mit `CAP_SYS_ADMIN` und einem
|
- **CORS:** `main.py` erlaubt `*` (nötig, weil UI und API getrennte Ports
|
||||||
rshared-Bind auf `/srv/rippy/media`, Mounts propagieren zu allen
|
|
||||||
Containern. ⚠️ Zugangsdaten liegen unverschlüsselt in der lokalen
|
|
||||||
Postgres-DB — bewusster Heimnetz-Kompromiss; lege fürs NAS einen eigenen,
|
|
||||||
eingeschränkten Benutzer an.
|
|
||||||
|
|
||||||
## Benachrichtigungen
|
|
||||||
|
|
||||||
**Einstellungen → Benachrichtigungen**: eine Webhook-URL eintragen, „Test
|
|
||||||
senden" drücken, speichern — fertig. Rippy meldet Job-Ende (fertig,
|
|
||||||
fehlgeschlagen, abgebrochen) und erkennt den Dienst an der URL selbst:
|
|
||||||
|
|
||||||
| Dienst | URL-Beispiel | Format |
|
|
||||||
|---|---|---|
|
|
||||||
| Discord | `https://discord.com/api/webhooks/…` | `{"content": …}` |
|
|
||||||
| Slack | `https://hooks.slack.com/services/…` | `{"text": …}` |
|
|
||||||
| ntfy (Handy-Push) | `https://ntfy.sh/mein-geheimes-thema` | Roh-Text + Titel |
|
|
||||||
| Eigenes (HA, n8n, …) | beliebige HTTPS-URL | `{"title","message","level"}` |
|
|
||||||
|
|
||||||
## System, MakeMKV-Beta-Key, Disc-Schlüssel & Updates
|
|
||||||
|
|
||||||
**Einstellungen → System** zeigt die Werkzeug-Versionen jedes Workers
|
|
||||||
(MakeMKV, HandBrake), den Key-Status und den freien Speicherplatz. Der
|
|
||||||
MakeMKV-Beta-Key (wechselt ~monatlich, Forum-Thread t=1053) wird hier im
|
|
||||||
UI eingetragen und gilt ab dem **nächsten Rip** — ohne Rebuild, ohne
|
|
||||||
Neustart; er schlägt den Key aus der `.env`.
|
|
||||||
|
|
||||||
**Disc-Schlüssel für 4K-UHD** — im selben Tab, nur für UHD nötig. Der
|
|
||||||
Hauptweg zuerst:
|
|
||||||
|
|
||||||
- **Schlüsselspeicher (`_private_data.tar`)**: Rippy zeigt, **wie viele
|
|
||||||
Disc-Schlüssel** dieser Worker kennt. Steht dort 0, scheitert jede
|
|
||||||
unbekannte UHD-Disc.
|
|
||||||
**So füllst du ihn:** MakeMKV auf einem Windows-PC installieren
|
|
||||||
(gleicher Beta-Key), Laufwerk anstecken, Disc einmal öffnen — dabei lädt
|
|
||||||
MakeMKV die Schlüssel nach. Dann in MakeMKV unter *Preferences →
|
|
||||||
General* das „MakeMKV data directory" nachschlagen und die Datei
|
|
||||||
`_private_data.tar` daraus hier hochladen. Wirkt ab dem nächsten Rip;
|
|
||||||
für neue Discs gelegentlich wiederholen.
|
|
||||||
Rippy prüft die Datei und lehnt sie ab, wenn kein einziger Schlüssel
|
|
||||||
drinsteckt — sonst ändert sich nichts und niemand versteht, warum.
|
|
||||||
- **`KEYDB.cfg`** (Notnagel): nur nötig, wenn eine Pressung auch mit
|
|
||||||
gefülltem Speicher nicht aufgeht, MakeMKV sie also selbst nicht kennt.
|
|
||||||
Hochladen, Status und Entfernen genau wie oben.
|
|
||||||
- **AACS-Dumps herunterladen**: MakeMKV legt bei jeder unbekannten Disc
|
|
||||||
selbst einen Dump ab. Genau den braucht man, wenn man im MakeMKV-Forum
|
|
||||||
um den Schlüssel für eine neue Pressung bittet — hier holst du ihn dir
|
|
||||||
aus dem Container, ohne SSH.
|
|
||||||
|
|
||||||
Der Beta-Key ist die **Lizenz für die Software**, der Schlüsselspeicher
|
|
||||||
und die `KEYDB.cfg` sind **Disc-Schlüssel** — zwei völlig verschiedene
|
|
||||||
Dinge. **Rippy liefert und lädt keine Disc-Schlüssel**, es hält nur ein
|
|
||||||
persistentes Verzeichnis dafür bereit (`MAKEMKV_DATA_HOST`, siehe Tabelle
|
|
||||||
unten) — rebuild-fest, damit deine Dateien einen
|
|
||||||
`docker compose build` überleben. Der Schlüsselspeicher ist der
|
|
||||||
Zwischenspeicher **deiner eigenen** MakeMKV-Installation.
|
|
||||||
|
|
||||||
**Updates:** „Auf Updates prüfen" vergleicht mit makemkv.com und den
|
|
||||||
offiziellen HandBrake-Releases (12 h gecacht). MakeMKV-Update ohne
|
|
||||||
Code-Änderung: `MAKEMKV_VERSION=x.y.z` in die `.env`, dann
|
|
||||||
`docker compose build worker && docker compose up -d worker`.
|
|
||||||
⚠️ **Richtigstellung (25.07.2026):** Hier stand früher, neue MakeMKV-
|
|
||||||
Versionen brächten „auch die neueste Disc-Schlüssel-Datenbank mit". Das
|
|
||||||
ist widerlegt — MakeMKV liefert überhaupt keine Disc-Schlüssel mit, es
|
|
||||||
holt sie zur Laufzeit. Und die Linux-Version holt sie nie. Ein Update
|
|
||||||
lohnt für Programmfehler und neue Laufwerke, hilft aber NICHT gegen „The
|
|
||||||
volume key is unknown". Dafür brauchst du den Schlüsselspeicher (siehe
|
|
||||||
oben).
|
|
||||||
HandBrake kommt im Docker-Worker bewusst aus Debian (stabil, hinkt der
|
|
||||||
offiziellen Version hinterher); der native Windows-Worker nutzt die
|
|
||||||
aktuelle Version direkt.
|
|
||||||
|
|
||||||
## Verarbeitung & Hardware
|
|
||||||
|
|
||||||
**Einstellungen → Verarbeitung** zeigt ehrlich an, welche Encoder deine
|
|
||||||
Worker WIRKLICH haben (CPU x264/x265, VAAPI bei AMD/Intel-GPU, NVENC bei
|
|
||||||
NVIDIA) — jeder Worker meldet seine Fähigkeiten selbst beim Start.
|
|
||||||
|
|
||||||
### Optional: weitere Maschinen als Transcode-Worker
|
|
||||||
|
|
||||||
Die Kompression läuft als eigener Celery-Task auf der Queue `transcode` —
|
|
||||||
JEDE Maschine im Netz kann sie übernehmen. **Einstellungen → Worker** zeigt
|
|
||||||
für beide Varianten einen Copy-Paste-Befehl:
|
|
||||||
- **Linux (Docker)**: `deploy/remote-transcode-worker.yml`
|
|
||||||
- **Windows (nativ, ohne Docker)**: braucht nur Python 3.10+ — der
|
|
||||||
Installer (`install.ps1`) kommt von der Rippy-Instanz selbst
|
|
||||||
(`/api/worker-setup/windows`), lädt Worker-Code und HandBrakeCLI
|
|
||||||
automatisch und erzeugt eine `start-worker.bat` (optional `-Autostart`).
|
|
||||||
|
|
||||||
Ohne Zusatz-Worker macht der eingebaute CPU-Worker alles selbst — Rippy
|
|
||||||
bleibt All-in-one.
|
|
||||||
|
|
||||||
## Umgebungsvariablen (.env)
|
|
||||||
|
|
||||||
| Variable | Pflicht | Zweck |
|
|
||||||
|---|---|---|
|
|
||||||
| `TMDB_API_KEY` | empfohlen | Metadaten (deutsche Texte) — alternativ im UI/Wizard; beide Key-Arten gehen (v3-Schlüssel und v4-Token) |
|
|
||||||
| `WORKER_NAME` | optional | Anzeigename des eingebauten Workers (Standard: rippy-hauptworker) |
|
|
||||||
| `OMDB_API_KEY` | optional | zweite Metadaten-Quelle (Fallback) |
|
|
||||||
| `THETVDB_API_KEY` | optional | Serien-Fallback |
|
|
||||||
| `MAKEMKV_APP_KEY` | optional | MakeMKV-Beta-Key (Forum); DVDs gehen ohne — bequemer: im UI unter Einstellungen → System pflegen |
|
|
||||||
| `MAKEMKV_URL_BASE` | optional | alternative Download-Quelle für den Image-Build |
|
|
||||||
| `MAKEMKV_DATA_HOST` | optional | Host-Verzeichnis für MakeMKVs Daten (Default `/srv/rippy/makemkv`) — hier liegen dein Schlüsselspeicher `_private_data.tar`, eine etwaige `KEYDB.cfg` und die AACS-Dumps; persistent, überlebt jeden Rebuild |
|
|
||||||
| `OPTICAL_SR` | je Host | Host-Pfad des CD-ROM-Knotens (Default `/dev/sr0`) — siehe „Laufwerk anpassen" |
|
|
||||||
| `OPTICAL_SG` | je Host | Host-Pfad des sg-Knotens des Laufwerks (Default `/dev/sg1`; oft `/dev/sg0`) |
|
|
||||||
| `POSTGRES_PASSWORD` | optional | DB-Passwort (Default `rippy`) — für exponierte Umgebungen ein starkes Passwort setzen |
|
|
||||||
|
|
||||||
UI-Einstellungen (Wizard/Settings) überstimmen die Env-Variablen.
|
|
||||||
|
|
||||||
## Rippy woanders bereitstellen
|
|
||||||
|
|
||||||
Rippy ist reines Docker Compose — es läuft auf **jedem Linux-Host mit
|
|
||||||
Docker**, nicht nur auf der Original-VM. Es gibt (noch) keine fertigen
|
|
||||||
Registry-Images; gebaut wird beim ersten `up` direkt aus dem Repo.
|
|
||||||
|
|
||||||
Voraussetzungen auf dem Ziel-Host:
|
|
||||||
1. Linux (x86_64) mit Docker + Compose-Plugin.
|
|
||||||
2. Ein optisches Laufwerk, das der Host sieht (`ls /dev/sr* /dev/sg*`).
|
|
||||||
In einer VM: per **USB-Passthrough** (Vendor-ID) durchreichen, NICHT
|
|
||||||
als emuliertes CD-ROM — siehe „Laufwerk anpassen" oben.
|
|
||||||
3. Ablage-Basis anlegen: `mkdir -p /srv/rippy/media` (oder Pfad in
|
|
||||||
`docker-compose.yml` anpassen). Der Bind nutzt **shared/slave-Propagation**
|
|
||||||
(fürs In-UI-Einhängen von NAS-Freigaben). Auf den meisten systemd-Linux-Hosts
|
|
||||||
ist `/` schon `rshared`, dann ist nichts zu tun. Bricht `docker compose up`
|
|
||||||
mit *„path … is mounted on / but it is not a shared mount"* ab, einmalig:
|
|
||||||
```bash
|
|
||||||
sudo mount --bind /srv/rippy/media /srv/rippy/media
|
|
||||||
sudo mount --make-rshared /srv/rippy/media
|
|
||||||
```
|
|
||||||
(reboot-fest als systemd-`.mount`-Unit persistieren.) Braucht einen klassischen
|
|
||||||
Linux-Docker-Host mit `SYS_ADMIN` — nicht Docker-Desktop/rootless/Podman.
|
|
||||||
4. MakeMKV-Datenverzeichnis anlegen: `mkdir -p /srv/rippy/makemkv` (oder
|
|
||||||
eigenen Pfad über `MAKEMKV_DATA_HOST` in der `.env`). Dort liegen
|
|
||||||
MakeMKVs Programmzustand, die AACS-Dumps und — falls du 4K-UHD rippst —
|
|
||||||
dein Schlüsselspeicher (`_private_data.tar`) sowie eine etwaige
|
|
||||||
`KEYDB.cfg`. Das Verzeichnis gehört dem Host, damit ein
|
|
||||||
`docker compose build` deine Dateien nicht wegwirft.
|
|
||||||
|
|
||||||
Dann wie im Schnellstart: klonen, `.env` füllen, `docker compose up -d
|
|
||||||
--build`, `http://<host>` öffnen — der Einrichtungs-Assistent führt durch
|
|
||||||
den Rest (API-Keys, Media-Server, Verarbeitung). Updates: `git pull &&
|
|
||||||
docker compose up -d --build`.
|
|
||||||
|
|
||||||
Nicht mitnehmen musst du: Gitea, Arcane, den CI-Runner — das ist die
|
|
||||||
Entwicklungs-Infrastruktur DIESER Installation, nicht Teil von Rippy.
|
|
||||||
|
|
||||||
### Härtung für fremde/exponierte Netze
|
|
||||||
|
|
||||||
Rippy ist bewusst **Heimnetz-only**: Es hat **keine Authentifizierung**, und
|
|
||||||
Redis + PostgreSQL veröffentlichen Ports auf allen Interfaces (für Remote-Worker).
|
|
||||||
Im vertrauten LAN ist das ein akzeptierter Kompromiss — **exponiere Rippy niemals
|
|
||||||
ungeschützt an ein unsicheres oder öffentliches Netz.** Wer es weiter absichern will:
|
|
||||||
|
|
||||||
- **Zugriff kapseln:** API (`:8000`) und UI hinter einen Reverse-Proxy mit Auth
|
|
||||||
(Basic-/Forward-Auth) legen oder nur über VPN erreichbar machen; Port `8000`
|
|
||||||
nicht auf `0.0.0.0` binden.
|
|
||||||
- **DB/Broker abschotten:** `POSTGRES_PASSWORD` in der `.env` auf ein starkes
|
|
||||||
Passwort setzen; die Ports `5432`/`6379` nur an ein internes/VPN-Interface binden
|
|
||||||
(z. B. `"10.0.0.5:5432:5432"`) statt an `0.0.0.0`; Redis mit `--requirepass
|
|
||||||
<geheim>` starten und das Passwort in `REDIS_URL` ergänzen.
|
|
||||||
- **CORS:** `main.py` erlaubt aktuell `*` (nötig, weil UI und API getrennte Ports
|
|
||||||
sind) — hinter einem Proxy auf die echte UI-Herkunft einschränken.
|
sind) — hinter einem Proxy auf die echte UI-Herkunft einschränken.
|
||||||
- **NAS-Zugangsdaten** liegen bewusst im Klartext in der DB — ein weiterer Grund,
|
- **NAS-Zugangsdaten** liegen im Klartext in der DB — ein weiterer Grund, den
|
||||||
den DB-Port nie offen ins unsichere Netz zu hängen.
|
DB-Port nie offen ins unsichere Netz zu hängen.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Updates der Werkzeuge</b></summary>
|
||||||
|
|
||||||
|
„Auf Updates prüfen" (Einstellungen → System) vergleicht mit makemkv.com und
|
||||||
|
den offiziellen HandBrake-Releases, 12 h gecacht.
|
||||||
|
|
||||||
|
**MakeMKV** aktualisieren: `MAKEMKV_VERSION=x.y.z` in die `.env`, dann
|
||||||
|
`sudo ./install.sh`. Der Beta-Key wechselt etwa monatlich (Forum-Thread
|
||||||
|
t=1053) und wird im UI gepflegt — ohne Rebuild.
|
||||||
|
|
||||||
|
**HandBrake** kommt im Docker-Worker bewusst aus Debian (stabil, hinkt der
|
||||||
|
offiziellen Version hinterher); der native Windows-Worker nutzt die aktuelle.
|
||||||
|
|
||||||
|
⚠️ Ein MakeMKV-Update hilft **nicht** gegen „The volume key is unknown" —
|
||||||
|
MakeMKV liefert überhaupt keine Disc-Schlüssel mit, es holt sie zur Laufzeit,
|
||||||
|
und die Linux-Version holt sie nie. Siehe [4K-UHD](#4k-uhd).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Entwicklung
|
## Entwicklung
|
||||||
|
|
||||||
CI („Ampel") läuft bei jedem Push: Ruff, pytest, Vite-Build. Es gibt genau
|
CI („Ampel") läuft bei jedem Push: Ruff, pytest, Vite-Build. Es gibt genau
|
||||||
einen Branch, `main` (Single Source of Truth) — die Ampel prüft nur,
|
einen Branch, `main` — die Ampel prüft nur, deployt wird direkt aus `main`.
|
||||||
deployt wird direkt aus `main`. Rot heißt: nicht deployen.
|
Rot heißt: nicht deployen.
|
||||||
|
|
||||||
Regeln für Beiträge: [AGENTS.md](AGENTS.md) · Konzept: [KONZEPT.md](KONZEPT.md) ·
|
Regeln für Beiträge: [AGENTS.md](AGENTS.md) · Konzept: [KONZEPT.md](KONZEPT.md) ·
|
||||||
Fahrplan: [ROADMAP.md](ROADMAP.md)
|
Fahrplan: [ROADMAP.md](ROADMAP.md) · Stand: [SAVEPOINT.md](SAVEPOINT.md)
|
||||||
|
|||||||
+1613
-1
File diff suppressed because it is too large
Load Diff
@@ -34,7 +34,34 @@ set -euo pipefail
|
|||||||
REPO_URL="$1"
|
REPO_URL="$1"
|
||||||
ENV_SRC="${2:-$HOME/rippy/.env}"
|
ENV_SRC="${2:-$HOME/rippy/.env}"
|
||||||
CLONE="${3:-$HOME/notfall-rippy}"
|
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
|
if [ ! -d "$CLONE/.git" ]; then
|
||||||
git clone --branch main "$REPO_URL" "$CLONE"
|
git clone --branch main "$REPO_URL" "$CLONE"
|
||||||
@@ -42,7 +69,32 @@ fi
|
|||||||
cd "$CLONE"
|
cd "$CLONE"
|
||||||
git fetch origin main
|
git fetch origin main
|
||||||
git reset --hard 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 up -d --build $DIENST
|
||||||
docker compose -p rippy ps --format 'table {{.Name}}\t{{.Status}}'
|
docker compose -p rippy ps --format 'table {{.Name}}\t{{.Status}}'
|
||||||
REMOTE
|
REMOTE
|
||||||
|
|||||||
@@ -1,17 +1,36 @@
|
|||||||
# Optionaler Remote-Transcode-Worker — für eine GPU-Maschine im Netz.
|
# Optionaler Remote-Transcode-Worker — für eine GPU-Maschine im Netz (Linux).
|
||||||
# (EXPERIMENTELL, 23.07.2026: VAAPI/NVENC-Presets folgen; aktuell nutzt der
|
# Für Windows gibt es den nativen Weg: RippyWorkerSetup.exe (Einstellungen → Worker).
|
||||||
# Worker dieselben HandBrake-CPU-Presets, bringt also v.a. stärkere CPUs.)
|
#
|
||||||
|
# ══ DAS ENTSCHEIDENDE: der Worker muss die Dateien SEHEN ══════════════════════
|
||||||
|
# Rippy schickt ihm Pfade wie /app/media/rippy/<job>/title_t00.mkv — Pfade
|
||||||
|
# INNERHALB des Rippy-Containers. Dieser Container muss dieselben Daten unter
|
||||||
|
# GENAU DEMSELBEN Pfad haben, sonst nimmt er die Aufgabe an und lehnt sie
|
||||||
|
# Millisekunden später ab („Keine Roh-MKVs gefunden" — genau so passiert am
|
||||||
|
# 26.07.2026, Job 95afdc89).
|
||||||
|
#
|
||||||
|
# Richtigstellung 26.07.2026: Hier stand vorher „mount -t nfs
|
||||||
|
# <rippy-host>:/srv/rippy /mnt/rippy". Das geht NICHT — auf der Rippy-Maschine
|
||||||
|
# läuft kein NFS- und kein Samba-Server, sie ist selbst nur Client der NAS.
|
||||||
|
# Der Weg, der funktioniert: DIESELBE Freigabe einhängen, die auch Rippy nutzt.
|
||||||
#
|
#
|
||||||
# Auf der GPU-Maschine:
|
# Auf der GPU-Maschine:
|
||||||
# 1. Dieses Repo klonen (oder nur dieses File + Zugriff aufs Registry-Image)
|
# 1. Dieses Repo klonen (oder nur dieses File + Zugriff aufs Registry-Image)
|
||||||
# 2. Rohdaten-Freigabe der Rippy-Maschine mounten, z. B.:
|
# 2. Die Freigabe einhängen, die Rippy als Arbeitsverzeichnis UND Ablage
|
||||||
# mount -t nfs <rippy-host>:/srv/rippy /mnt/rippy
|
# benutzt (Einstellungen → Ripping). Heißt sie in Rippy „rippy", liegt
|
||||||
# (die Rippy-VM muss /srv/rippy + das temp-Volume exportieren)
|
# sie dort unter /app/media/rippy — also z. B.:
|
||||||
# 3. RIPPY_HOST unten setzen und starten:
|
# mount -t cifs //NAS/rippy /mnt/rippy-freigabe -o guest,iocharset=utf8
|
||||||
# docker compose -f deploy/remote-transcode-worker.yml up -d --build
|
# 3. Unten RIPPY_HOST setzen, den Freigabe-Pfad im volumes-Block anpassen
|
||||||
|
# und starten:
|
||||||
|
# RIPPY_HOST=<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
|
||||||
|
# als Ausweg RIPPY_PATH_MAP — dieselbe Übersetzung, die der Windows-Worker
|
||||||
|
# nutzt, Format: /app/media/rippy=/mnt/anderer-pfad (Paare per „;").
|
||||||
|
# Den fertigen Wert nennt GET /worker-setup/pfad-map.
|
||||||
#
|
#
|
||||||
# Der Worker meldet seine Encoder-Fähigkeiten automatisch — er taucht danach
|
# Der Worker meldet seine Encoder-Fähigkeiten automatisch — er taucht danach
|
||||||
# unter Einstellungen → Verarbeitung auf. Er bedient NUR die transcode-Queue;
|
# unter Einstellungen → Worker auf. Er bedient NUR die transcode-Queue;
|
||||||
# gerippt wird weiterhin dort, wo das Laufwerk hängt.
|
# gerippt wird weiterhin dort, wo das Laufwerk hängt.
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -30,10 +49,15 @@ services:
|
|||||||
- WORKER_NAME=${WORKER_NAME:-transcode-worker}
|
- WORKER_NAME=${WORKER_NAME:-transcode-worker}
|
||||||
# Serien-Episoden-Matching fragt die Rippy-API nach Laufzeiten
|
# Serien-Episoden-Matching fragt die Rippy-API nach Laufzeiten
|
||||||
- API_URL=http://${RIPPY_HOST}:8000
|
- API_URL=http://${RIPPY_HOST}:8000
|
||||||
|
# Nur nötig, wenn die Freigabe hier NICHT unter demselben Pfad liegt wie
|
||||||
|
# in Rippy. Fertigen Wert holen: curl http://${RIPPY_HOST}/api/worker-setup/pfad-map
|
||||||
|
- RIPPY_PATH_MAP=${RIPPY_PATH_MAP:-}
|
||||||
volumes:
|
volumes:
|
||||||
# Freigabe der Rippy-Maschine (siehe Kopf-Kommentar)
|
# LINKS der Pfad auf DIESER Maschine, RECHTS der Pfad, den Rippy nennt.
|
||||||
- /mnt/rippy/media:/app/media
|
# Rechts muss stehen, was in Rippy als Speicherziel heißt: ein Ziel
|
||||||
- /mnt/rippy/temp:/app/temp
|
# namens „rippy" ist dort /app/media/rippy. Prüfen mit
|
||||||
|
# curl http://<rippy-host>/api/worker-setup/pfad-map
|
||||||
|
- /mnt/rippy-freigabe:/app/media/rippy
|
||||||
devices:
|
devices:
|
||||||
# GPU für Hardware-Encoding (AMD/Intel: /dev/dri; NVIDIA: nvidia-runtime)
|
# GPU für Hardware-Encoding (AMD/Intel: /dev/dri; NVIDIA: nvidia-runtime)
|
||||||
- /dev/dri:/dev/dri
|
- /dev/dri:/dev/dri
|
||||||
|
|||||||
Binary file not shown.
@@ -1,59 +0,0 @@
|
|||||||
# Baut rippy.ico + RippyWorkerSetup.exe aus install-gui.ps1 (ps2exe).
|
|
||||||
#
|
|
||||||
# WANN NEU BAUEN: nur wenn sich install-gui.ps1 (die Installer-Oberflaeche)
|
|
||||||
# aendert. 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 fuer 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)
|
|
||||||
Invoke-ps2exe -inputFile $guiSrc -outputFile "$PSScriptRoot\RippyWorkerSetup.exe" `
|
|
||||||
-iconFile "$PSScriptRoot\rippy.ico" -noConsole -STA `
|
|
||||||
-title "Rippy Worker Setup" -product "Rippy" -company "Rippy" -version "1.0.0.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,237 +0,0 @@
|
|||||||
# Rippy Windows-Worker - GRAFISCHER Installer (WinForms, keine externe Runtime).
|
|
||||||
# Wird ueber rippy-worker-setup.bat gestartet (Doppelklick). Braucht nur
|
|
||||||
# Python 3.10+ auf der Maschine - kein Docker, kein git.
|
|
||||||
#
|
|
||||||
# ASCII-only (PowerShell 5.1 liest .ps1 als ANSI - Sonderzeichen zerschiessen
|
|
||||||
# das Skript).
|
|
||||||
|
|
||||||
param(
|
|
||||||
[string]$RippyHost = "",
|
|
||||||
[string]$WorkerName = $env:COMPUTERNAME.ToLower()
|
|
||||||
)
|
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
|
||||||
Add-Type -AssemblyName System.Windows.Forms
|
|
||||||
Add-Type -AssemblyName System.Drawing
|
|
||||||
|
|
||||||
$InstallDir = Join-Path $env:USERPROFILE "rippy-worker"
|
|
||||||
|
|
||||||
# --- Fenster ---------------------------------------------------------------
|
|
||||||
$form = New-Object System.Windows.Forms.Form
|
|
||||||
$form.Text = "Rippy Encoding-Worker - Installation"
|
|
||||||
$form.Size = New-Object System.Drawing.Size(560, 520)
|
|
||||||
$form.StartPosition = "CenterScreen"
|
|
||||||
$form.FormBorderStyle = "FixedDialog"
|
|
||||||
$form.MaximizeBox = $false
|
|
||||||
$form.BackColor = [System.Drawing.Color]::FromArgb(248, 250, 252)
|
|
||||||
$form.Font = New-Object System.Drawing.Font("Segoe UI", 9)
|
|
||||||
|
|
||||||
# Fenster-Icon: Rippy-Disc zur Laufzeit zeichnen (Indigo-Kreis + weisses Loch)
|
|
||||||
# - kein externes .ico noetig. 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 {}
|
|
||||||
|
|
||||||
$titel = New-Object System.Windows.Forms.Label
|
|
||||||
$titel.Text = "Rippy Encoding-Worker einrichten"
|
|
||||||
$titel.Font = New-Object System.Drawing.Font("Segoe UI", 14, [System.Drawing.FontStyle]::Bold)
|
|
||||||
$titel.ForeColor = [System.Drawing.Color]::FromArgb(79, 70, 229)
|
|
||||||
$titel.Location = New-Object System.Drawing.Point(24, 20)
|
|
||||||
$titel.Size = New-Object System.Drawing.Size(500, 30)
|
|
||||||
$form.Controls.Add($titel)
|
|
||||||
|
|
||||||
$info = New-Object System.Windows.Forms.Label
|
|
||||||
$info.Text = "Diese Maschine uebernimmt die Video-Kompression fuer Rippy. Gerippt wird weiter auf der Rippy-Hauptmaschine."
|
|
||||||
$info.Location = New-Object System.Drawing.Point(24, 52)
|
|
||||||
$info.Size = New-Object System.Drawing.Size(500, 34)
|
|
||||||
$info.ForeColor = [System.Drawing.Color]::FromArgb(100, 116, 139)
|
|
||||||
$form.Controls.Add($info)
|
|
||||||
|
|
||||||
# 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, 96)
|
|
||||||
$lblHost.Size = New-Object System.Drawing.Size(500, 18)
|
|
||||||
$form.Controls.Add($lblHost)
|
|
||||||
|
|
||||||
$txtHost = New-Object System.Windows.Forms.TextBox
|
|
||||||
$txtHost.Location = New-Object System.Drawing.Point(24, 116)
|
|
||||||
$txtHost.Size = New-Object System.Drawing.Size(300, 26)
|
|
||||||
$txtHost.Text = $RippyHost
|
|
||||||
$txtHost.Font = New-Object System.Drawing.Font("Consolas", 10)
|
|
||||||
$form.Controls.Add($txtHost)
|
|
||||||
|
|
||||||
# Worker-Name
|
|
||||||
$lblName = New-Object System.Windows.Forms.Label
|
|
||||||
$lblName.Text = "Name dieses Workers (frei waehlbar):"
|
|
||||||
$lblName.Location = New-Object System.Drawing.Point(24, 152)
|
|
||||||
$lblName.Size = New-Object System.Drawing.Size(500, 18)
|
|
||||||
$form.Controls.Add($lblName)
|
|
||||||
|
|
||||||
$txtName = New-Object System.Windows.Forms.TextBox
|
|
||||||
$txtName.Location = New-Object System.Drawing.Point(24, 172)
|
|
||||||
$txtName.Size = New-Object System.Drawing.Size(300, 26)
|
|
||||||
$txtName.Text = $WorkerName
|
|
||||||
$form.Controls.Add($txtName)
|
|
||||||
|
|
||||||
# Autostart
|
|
||||||
$chkAuto = New-Object System.Windows.Forms.CheckBox
|
|
||||||
$chkAuto.Text = "Beim Anmelden automatisch starten (Tray-Symbol)"
|
|
||||||
$chkAuto.Location = New-Object System.Drawing.Point(24, 206)
|
|
||||||
$chkAuto.Size = New-Object System.Drawing.Size(400, 22)
|
|
||||||
$chkAuto.Checked = $true
|
|
||||||
$form.Controls.Add($chkAuto)
|
|
||||||
|
|
||||||
# Log-Bereich
|
|
||||||
$log = New-Object System.Windows.Forms.TextBox
|
|
||||||
$log.Location = New-Object System.Drawing.Point(24, 240)
|
|
||||||
$log.Size = New-Object System.Drawing.Size(500, 170)
|
|
||||||
$log.Multiline = $true
|
|
||||||
$log.ReadOnly = $true
|
|
||||||
$log.ScrollBars = "Vertical"
|
|
||||||
$log.BackColor = [System.Drawing.Color]::FromArgb(15, 23, 42)
|
|
||||||
$log.ForeColor = [System.Drawing.Color]::FromArgb(226, 232, 240)
|
|
||||||
$log.Font = New-Object System.Drawing.Font("Consolas", 8.5)
|
|
||||||
$form.Controls.Add($log)
|
|
||||||
|
|
||||||
# Knoepfe
|
|
||||||
$btnInstall = New-Object System.Windows.Forms.Button
|
|
||||||
$btnInstall.Text = "Installieren"
|
|
||||||
$btnInstall.Location = New-Object System.Drawing.Point(24, 424)
|
|
||||||
$btnInstall.Size = New-Object System.Drawing.Size(150, 40)
|
|
||||||
$btnInstall.BackColor = [System.Drawing.Color]::FromArgb(79, 70, 229)
|
|
||||||
$btnInstall.ForeColor = [System.Drawing.Color]::White
|
|
||||||
$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, 424)
|
|
||||||
$btnStart.Size = New-Object System.Drawing.Size(150, 40)
|
|
||||||
$btnStart.BackColor = [System.Drawing.Color]::FromArgb(16, 185, 129)
|
|
||||||
$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()
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- 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() }
|
|
||||||
|
|
||||||
$btnInstall.Enabled = $false
|
|
||||||
$log.Clear()
|
|
||||||
|
|
||||||
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
|
|
||||||
New-Item -ItemType Directory -Force $InstallDir | Out-Null
|
|
||||||
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 + Abhaengigkeiten
|
|
||||||
if (-not (Test-Path "venv")) { Log "Lege Python-Umgebung an ..."; Invoke-Expression "$python -m venv venv" }
|
|
||||||
Log "Installiere Python-Abhaengigkeiten (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 "Abhaengigkeiten 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. Start-Skripte + Deinstaller erzeugen
|
|
||||||
$trayBat = "@echo off`r`ncd /d `"%~dp0`"`r`nset REDIS_URL=redis://${rHost}:6379/0`r`nset DATABASE_URL=postgresql://rippy:rippy@${rHost}:5432/rippy`r`nset API_URL=http://${rHost}:8000`r`nset WORKER_NAME=$wName`r`nset RIPPY_TRAY_HOST=$rHost`r`nset PATH=%~dp0;%PATH%`r`nstart `"`" venv\Scripts\pythonw.exe tray.py"
|
|
||||||
Set-Content -Path "start-tray.bat" -Value $trayBat -Encoding ASCII
|
|
||||||
|
|
||||||
$workBat = "@echo off`r`ncd /d `"%~dp0`"`r`nset REDIS_URL=redis://${rHost}:6379/0`r`nset DATABASE_URL=postgresql://rippy:rippy@${rHost}:5432/rippy`r`nset API_URL=http://${rHost}:8000`r`nset WORKER_NAME=$wName`r`nset PATH=%~dp0;%PATH%`r`nvenv\Scripts\celery.exe -A celery_app worker --loglevel=info -Q transcode --pool=solo -n ${wName}@%%h"
|
|
||||||
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
|
|
||||||
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
|
|
||||||
|
|
||||||
# 6. Autostart
|
|
||||||
if ($chkAuto.Checked) {
|
|
||||||
schtasks /create /f /tn "RippyWorker" /tr "`"$InstallDir\start-tray.bat`"" /sc onlogon | Out-Null
|
|
||||||
Log "Autostart eingerichtet (Start bei Anmeldung)."
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$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,158 +0,0 @@
|
|||||||
# Rippy: Nativer Windows-Transcode-Worker - Installation OHNE Docker.
|
|
||||||
#
|
|
||||||
# Was das Skript tut (alles in einen Unterordner "rippy-worker"):
|
|
||||||
# 1. Prueft Python (3.10+), legt ein venv an, installiert die Abhaengigkeiten
|
|
||||||
# 2. Laedt den Worker-Code direkt von deiner Rippy-Instanz (/api/worker-setup/paket)
|
|
||||||
# 3. Laedt HandBrakeCLI (gepinnte Version, offizielles GitHub-Release),
|
|
||||||
# falls nicht schon im PATH oder im Ordner vorhanden
|
|
||||||
# 4. Erzeugt start-worker.bat - Doppelklick startet den Worker
|
|
||||||
#
|
|
||||||
# Der Worker bedient NUR die Kompressions-Queue (transcode) - gerippt wird
|
|
||||||
# weiterhin auf der Rippy-Hauptmaschine (dort haengt das Laufwerk).
|
|
||||||
#
|
|
||||||
# Aufruf (PowerShell):
|
|
||||||
# .\install.ps1 -RippyHost 192.168.178.162 [-WorkerName mein-pc] [-Autostart]
|
|
||||||
#
|
|
||||||
# -Autostart registriert eine Aufgabe "RippyWorker" (Start bei Anmeldung).
|
|
||||||
|
|
||||||
param(
|
|
||||||
[Parameter(Mandatory = $true)] [string]$RippyHost,
|
|
||||||
[string]$WorkerName = $env:COMPUTERNAME.ToLower(),
|
|
||||||
[switch]$Autostart
|
|
||||||
)
|
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
|
||||||
# HandBrake-Version: die NEUESTE offizielle (GitHub-Release) - so passt der
|
|
||||||
# Windows-Worker zu dem, was der Update-Check im UI meldet (frueher war hier
|
|
||||||
# eine feste 1.9.2 hardcodiert, was verwirrte). Faellt auf eine bekannte
|
|
||||||
# Version zurueck, 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 ausfuehren)"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
Write-Host "Python gefunden: $python"
|
|
||||||
|
|
||||||
# 2. Arbeitsordner + Worker-Code von der Rippy-Instanz laden
|
|
||||||
$ziel = Join-Path (Get-Location) "rippy-worker"
|
|
||||||
New-Item -ItemType Directory -Force $ziel | Out-Null
|
|
||||||
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 + Abhaengigkeiten
|
|
||||||
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 fuer den Windows-Worker: Tray-Symbol (Status, Start/Stopp, Beenden)
|
|
||||||
& .\venv\Scripts\pip.exe install --quiet pystray pillow
|
|
||||||
Write-Host "Python-Abhaengigkeiten installiert."
|
|
||||||
|
|
||||||
# 4. HandBrakeCLI besorgen (falls noetig)
|
|
||||||
$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. Start-Skript erzeugen
|
|
||||||
$bat = @"
|
|
||||||
@echo off
|
|
||||||
rem Rippy Windows-Transcode-Worker - verbindet sich mit $RippyHost
|
|
||||||
cd /d "%~dp0"
|
|
||||||
set REDIS_URL=redis://${RippyHost}:6379/0
|
|
||||||
set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy
|
|
||||||
set API_URL=http://${RippyHost}:8000
|
|
||||||
set WORKER_NAME=$WorkerName
|
|
||||||
set 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
|
|
||||||
|
|
||||||
# 6. Tray-Start (empfohlen): Symbol neben der Uhr, kein Konsolenfenster
|
|
||||||
$trayBat = @"
|
|
||||||
@echo off
|
|
||||||
rem Rippy-Worker mit Tray-Symbol - verbindet sich mit $RippyHost
|
|
||||||
cd /d "%~dp0"
|
|
||||||
set REDIS_URL=redis://${RippyHost}:6379/0
|
|
||||||
set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy
|
|
||||||
set API_URL=http://${RippyHost}:8000
|
|
||||||
set WORKER_NAME=$WorkerName
|
|
||||||
set RIPPY_TRAY_HOST=$RippyHost
|
|
||||||
set 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."
|
|
||||||
|
|
||||||
# 7. Deinstaller - MUSS-Kriterium: rueckstandsfrei entfernbar
|
|
||||||
$uninstall = @"
|
|
||||||
# Rippy-Worker DEINSTALLIEREN: stoppt Prozesse, entfernt Autostart,
|
|
||||||
# meldet den Worker in Rippy ab und loescht 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
|
|
||||||
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 loeschen (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."
|
|
||||||
|
|
||||||
# 8. Optional: Autostart bei Anmeldung (mit Tray)
|
|
||||||
if ($Autostart) {
|
|
||||||
schtasks /create /f /tn "RippyWorker" /tr "`"$ziel\start-tray.bat`"" /sc onlogon | Out-Null
|
|
||||||
Write-Host "Autostart-Aufgabe 'RippyWorker' registriert (Tray bei Anmeldung)."
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
||||||
+12
-3
@@ -21,6 +21,7 @@ services:
|
|||||||
# hochgeladene KEYDB.cfg entgegen (Einstellungen → System).
|
# hochgeladene KEYDB.cfg entgegen (Einstellungen → System).
|
||||||
- MAKEMKV_DATA_DIR=/app/makemkv-data
|
- MAKEMKV_DATA_DIR=/app/makemkv-data
|
||||||
- LOG_LEVEL=INFO
|
- LOG_LEVEL=INFO
|
||||||
|
- RIPPY_VERSION=${RIPPY_VERSION:-dev}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "python -c 'import urllib.request; urllib.request.urlopen(\"http://localhost:8000/health\")'"]
|
test: ["CMD-SHELL", "python -c 'import urllib.request; urllib.request.urlopen(\"http://localhost:8000/health\")'"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -70,10 +71,17 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: docker/worker/Dockerfile
|
dockerfile: docker/worker/Dockerfile
|
||||||
args:
|
args:
|
||||||
# Übersteuerbar via .env — makemkv.com ist zeitweise down (Cloudflare
|
# Übersteuerbar via .env. /download = aktuelle Version,
|
||||||
# 525); dann Wayback-Snapshot als MAKEMKV_URL_BASE eintragen.
|
# /download/old = ältere. Am 25.07.2026 nachgemessen: /download
|
||||||
# /download = aktuelle Version, /download/old = ältere Versionen.
|
# antwortet mit 200, /download/old mit 525 (Cloudflare), und ein
|
||||||
|
# web.archive.org-Schnappschuss mit 404 — deshalb steht hier der
|
||||||
|
# Standard, der wirklich liefert.
|
||||||
MAKEMKV_URL_BASE: ${MAKEMKV_URL_BASE:-https://www.makemkv.com/download}
|
MAKEMKV_URL_BASE: ${MAKEMKV_URL_BASE:-https://www.makemkv.com/download}
|
||||||
|
# Zweite Quelle, die bei Fehlschlag der ersten automatisch probiert wird
|
||||||
|
# (leer = keine). Bewusst ohne Vorbelegung: eine Adresse, die nicht
|
||||||
|
# liefert, lässt die Konfiguration gesund aussehen und den Build später
|
||||||
|
# scheitern. Der zuverlässige Weg bleibt docker/worker/vendor/.
|
||||||
|
MAKEMKV_URL_FALLBACK: ${MAKEMKV_URL_FALLBACK:-}
|
||||||
# MakeMKV-Update OHNE Code-Änderung: neue Version in die .env,
|
# MakeMKV-Update OHNE Code-Änderung: neue Version in die .env,
|
||||||
# Tarballs nach docker/worker/vendor/ legen (oder Download klappt),
|
# Tarballs nach docker/worker/vendor/ legen (oder Download klappt),
|
||||||
# dann docker compose build worker && docker compose up -d worker.
|
# dann docker compose build worker && docker compose up -d worker.
|
||||||
@@ -95,6 +103,7 @@ services:
|
|||||||
# Episoden-Laufzeiten der Staffel (TMDB).
|
# Episoden-Laufzeiten der Staffel (TMDB).
|
||||||
- API_URL=http://api:8000
|
- API_URL=http://api:8000
|
||||||
- LOG_LEVEL=INFO
|
- LOG_LEVEL=INFO
|
||||||
|
- RIPPY_VERSION=${RIPPY_VERSION:-dev}
|
||||||
volumes:
|
volumes:
|
||||||
- type: bind
|
- type: bind
|
||||||
source: /srv/rippy/media
|
source: /srv/rippy/media
|
||||||
|
|||||||
@@ -21,7 +21,12 @@ COPY docker/api/ .
|
|||||||
# native Worker aus (GET /worker-setup/windows bzw. /worker-setup/paket) —
|
# native Worker aus (GET /worker-setup/windows bzw. /worker-setup/paket) —
|
||||||
# die Zielmaschine braucht weder git noch Docker.
|
# die Zielmaschine braucht weder git noch Docker.
|
||||||
COPY docker/worker/*.py docker/worker/requirements.txt worker_dist/
|
COPY docker/worker/*.py docker/worker/requirements.txt worker_dist/
|
||||||
COPY deploy/worker-windows/install.ps1 deploy/worker-windows/install-gui.ps1 worker_dist/
|
COPY deploy/worker-windows/installer.py worker_dist/
|
||||||
|
# Rippy-Icon: Der Installer legt damit eine Verknüpfung auf den Desktop
|
||||||
|
# (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
|
# Vorgebaute .exe (Rippy-Icon, kein Konsolenfenster) — auf Windows via
|
||||||
# deploy/worker-windows/build-exe.ps1 erzeugt (Windows-.exe geht nicht von
|
# deploy/worker-windows/build-exe.ps1 erzeugt (Windows-.exe geht nicht von
|
||||||
# Linux). Nur bei GUI-Aenderungen neu bauen, nicht bei Tool-Updates.
|
# Linux). Nur bei GUI-Aenderungen neu bauen, nicht bei Tool-Updates.
|
||||||
|
|||||||
@@ -28,7 +28,19 @@ def titel_aehnlichkeit(a: str, b: str) -> float:
|
|||||||
|
|
||||||
|
|
||||||
class JikanClient:
|
class JikanClient:
|
||||||
|
# Unter dieser Ähnlichkeit gibt lookup() gar nichts zurück.
|
||||||
MINDEST_AEHNLICHKEIT = 0.55
|
MINDEST_AEHNLICHKEIT = 0.55
|
||||||
|
# Ab dieser Ähnlichkeit gilt der Treffer als SICHER und beendet die
|
||||||
|
# Metadaten-Kette. Dazwischen ist er nur ein Vorschlag: erst wird OMDb
|
||||||
|
# gefragt, und nur wenn das nichts hat, kommt er zum Zug.
|
||||||
|
#
|
||||||
|
# Warum zwei Schwellen (26.07.2026, ausgerechnet mit titel_aehnlichkeit):
|
||||||
|
# Eine einzelne Zahl kann beide Aufgaben nicht leisten. 0,55 lässt kurze
|
||||||
|
# Kinofilm-Titel durch, die hier nichts zu suchen haben („Alien" gegen
|
||||||
|
# „Alien 9" = 0,83), ist aber gleichzeitig zu streng für den Fall, für den
|
||||||
|
# diese Quelle gebaut wurde („Evangelion 2.22" gegen „Evangelion: 2.0 You
|
||||||
|
# Can (Not) Advance" = 0,51). Messwerte in test_jikan_helpers.py.
|
||||||
|
SICHER_AEHNLICHKEIT = 0.9
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
|
|||||||
@@ -210,6 +210,24 @@ def delete_worker(name: str) -> None:
|
|||||||
conn.execute(workers.delete().where(workers.c.name == name))
|
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:
|
def has_active_job(device: str) -> bool:
|
||||||
"""True, wenn auf dem Gerät ein Job läuft oder wartet (Eject-Schutz)."""
|
"""True, wenn auf dem Gerät ein Job läuft oder wartet (Eject-Schutz)."""
|
||||||
with engine.connect() as conn:
|
with engine.connect() as conn:
|
||||||
|
|||||||
+35
-1
@@ -20,13 +20,47 @@ from detection import (
|
|||||||
|
|
||||||
# include/uapi/linux/cdrom.h
|
# include/uapi/linux/cdrom.h
|
||||||
CDROMEJECT = 0x5309
|
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:
|
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)
|
fd = os.open(device_path, os.O_RDONLY | os.O_NONBLOCK)
|
||||||
try:
|
try:
|
||||||
|
try:
|
||||||
|
ioctl(fd, CDROM_LOCKDOOR, 0)
|
||||||
|
except OSError:
|
||||||
|
pass # nicht verriegelt oder ioctl unbekannt — Auswurf trotzdem versuchen
|
||||||
ioctl(fd, CDROMEJECT, 0)
|
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:
|
finally:
|
||||||
os.close(fd)
|
os.close(fd)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""Restzeit-Schätzung für laufende Jobs — aus dem gemessenen Fortschritt.
|
||||||
|
|
||||||
|
Commander-Anforderung 26.07.2026: *„Der Server Status muss dringend überarbeitet
|
||||||
|
werden, das Dashboard soll ja quasi alles auf einen Blick zeigen"* — dazu eine
|
||||||
|
**ETA** im Dashboard und beim externen Worker.
|
||||||
|
|
||||||
|
## Warum hier und nicht im Browser
|
||||||
|
|
||||||
|
Der naheliegende Weg wäre, den Fortschritt im UI mitzuschreiben. Drei Gründe
|
||||||
|
dagegen: Ein Seitenwechsel setzt die Messreihe zurück; zwei offene Browser
|
||||||
|
zeigten verschiedene Zahlen; und die Kompression läuft womöglich auf einer
|
||||||
|
ANDEREN Maschine (genau dort wollte der Commander die ETA sehen). Die Reihe
|
||||||
|
gehört also dorthin, wo der Fortschritt ankommt.
|
||||||
|
|
||||||
|
## Warum nicht einfach „vergangene Zeit / Prozent"
|
||||||
|
|
||||||
|
Weil ein Job zwei völlig verschiedene Phasen hat: Der Rip dauert rund eine
|
||||||
|
Stunde, die Kompression Stunden bis Tage (auf der Rippy-VM gemessene 28-55 h je
|
||||||
|
4K-Film). Aus dem Gesamt-Mittel entstünde bei jedem Phasenwechsel eine
|
||||||
|
haarsträubende Zahl. Deshalb wird die Messreihe bei JEDEM Statuswechsel
|
||||||
|
verworfen und die Rate nur innerhalb der laufenden Phase bestimmt.
|
||||||
|
|
||||||
|
## Ehrlichkeit vor Zahl
|
||||||
|
|
||||||
|
Lieber „wird noch geschätzt" als eine erfundene Minute:
|
||||||
|
|
||||||
|
- Unter MINDEST_PUNKTE Messwerten gibt es keine Schätzung.
|
||||||
|
- Die Reihe muss MINDEST_SPANNE_SEKUNDEN abdecken UND MINDEST_FORTSCHRITT
|
||||||
|
Prozentpunkte gestiegen sein — bei 4K bewegt sich eine halbe Stunde lang
|
||||||
|
nichts, daraus ließe sich sonst „fertig in 3 Minuten" ableiten.
|
||||||
|
- Nur die letzten FENSTER Punkte zählen: HandBrake wird bei komplexen Szenen
|
||||||
|
langsamer, die frühen Werte lügen dann.
|
||||||
|
- Über OBERGRENZE_SEKUNDEN wird nicht mehr aufs Detail gerechnet, sondern
|
||||||
|
„mehr als 2 Tage" gesagt. Eine Zahl wie „51:23 h" wirkt genau, ist es aber
|
||||||
|
nicht.
|
||||||
|
"""
|
||||||
|
|
||||||
|
MINDEST_PUNKTE = 2
|
||||||
|
MINDEST_SPANNE_SEKUNDEN = 60
|
||||||
|
MINDEST_FORTSCHRITT = 1
|
||||||
|
FENSTER = 10
|
||||||
|
OBERGRENZE_SEKUNDEN = 48 * 3600
|
||||||
|
|
||||||
|
# Status, für die eine Restzeit überhaupt Sinn hat.
|
||||||
|
LAUFENDE_STATUS = ("running", "processing", "ripping", "transcoding")
|
||||||
|
|
||||||
|
|
||||||
|
def beobachtung_hinzufuegen(reihe, status: str, progress: int, jetzt: float) -> dict:
|
||||||
|
"""Neuen Messpunkt anfügen (pure Funktion) → die aktualisierte Reihe.
|
||||||
|
|
||||||
|
`reihe` ist {"status": str, "punkte": [[zeit, prozent], ...]} oder None.
|
||||||
|
Bei Statuswechsel beginnt die Reihe neu — siehe Modul-Doku (Rip und
|
||||||
|
Kompression haben nichts miteinander zu tun).
|
||||||
|
|
||||||
|
Ein unveränderter Fortschritt wird NICHT als neuer Punkt angefügt, aber der
|
||||||
|
letzte Punkt behält seine ursprüngliche Zeit. Das ist wichtig: Ein Stillstand
|
||||||
|
verlängert damit automatisch die gemessene Spanne und macht die Schätzung
|
||||||
|
langsamer — genau richtig, denn er heißt ja, dass es langsam vorangeht.
|
||||||
|
"""
|
||||||
|
alt = reihe or {}
|
||||||
|
punkte = list(alt.get("punkte") or []) if alt.get("status") == status else []
|
||||||
|
if not punkte or punkte[-1][1] != progress:
|
||||||
|
punkte.append([jetzt, progress])
|
||||||
|
return {"status": status, "punkte": punkte[-FENSTER:]}
|
||||||
|
|
||||||
|
|
||||||
|
def restzeit_sekunden(reihe, jetzt: float) -> int:
|
||||||
|
"""Geschätzte Restzeit in Sekunden — oder -1 für „noch keine Aussage".
|
||||||
|
|
||||||
|
-1 statt None, damit der Wert unverändert durch JSON und das
|
||||||
|
Antwort-Modell passt (dasselbe Muster wie get_progress_from_line im
|
||||||
|
Worker, wo -1 „keine Angabe" heißt).
|
||||||
|
"""
|
||||||
|
punkte = (reihe or {}).get("punkte") or []
|
||||||
|
if len(punkte) < MINDEST_PUNKTE:
|
||||||
|
return -1
|
||||||
|
erste_zeit, erster_prozent = punkte[0]
|
||||||
|
letzte_zeit, letzter_prozent = punkte[-1]
|
||||||
|
# Die Spanne bis JETZT, nicht bis zum letzten Punkt: sonst zeigt ein
|
||||||
|
# stehender Job dauerhaft die Rate von vor drei Stunden.
|
||||||
|
spanne = max(jetzt, letzte_zeit) - erste_zeit
|
||||||
|
gewachsen = letzter_prozent - erster_prozent
|
||||||
|
if spanne < MINDEST_SPANNE_SEKUNDEN or gewachsen < MINDEST_FORTSCHRITT:
|
||||||
|
return -1
|
||||||
|
rest_prozent = 100 - letzter_prozent
|
||||||
|
if rest_prozent <= 0:
|
||||||
|
return 0
|
||||||
|
pro_prozent = spanne / gewachsen
|
||||||
|
return int(rest_prozent * pro_prozent)
|
||||||
|
|
||||||
|
|
||||||
|
def formatiere_restzeit(sekunden: int) -> str:
|
||||||
|
"""Restzeit als Text fürs UI. Leer, wenn es keine Aussage gibt."""
|
||||||
|
if sekunden is None or sekunden < 0:
|
||||||
|
return ""
|
||||||
|
if sekunden > OBERGRENZE_SEKUNDEN:
|
||||||
|
return "mehr als 2 Tage"
|
||||||
|
if sekunden < 60:
|
||||||
|
return "unter einer Minute"
|
||||||
|
minuten = sekunden // 60
|
||||||
|
if minuten < 60:
|
||||||
|
return f"noch ca. {minuten} min"
|
||||||
|
stunden, rest_minuten = divmod(minuten, 60)
|
||||||
|
if stunden < 24:
|
||||||
|
return f"noch ca. {stunden} h {rest_minuten:02d} min"
|
||||||
|
tage, rest_stunden = divmod(stunden, 24)
|
||||||
|
return f"noch ca. {tage} Tag{'e' if tage > 1 else ''} {rest_stunden} h"
|
||||||
|
|
||||||
|
|
||||||
|
def schluessel(job_id: str) -> str:
|
||||||
|
return f"eta:{job_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def aktualisiere_und_schaetze(job_id: str, status: str, progress: int,
|
||||||
|
jetzt: float, cache_get, cache_set) -> dict:
|
||||||
|
"""Messreihe im Cache fortschreiben und die Restzeit zurückgeben.
|
||||||
|
|
||||||
|
Der Cache (Redis) ist schon da und überlebt einen Neustart des UI. Fällt er
|
||||||
|
aus, kommt bei jedem Aufruf eine leere Reihe zurück — dann gibt es eben
|
||||||
|
keine ETA, aber nichts scheitert.
|
||||||
|
"""
|
||||||
|
if status not in LAUFENDE_STATUS or progress <= 0:
|
||||||
|
return {"sekunden": -1, "text": ""}
|
||||||
|
k = schluessel(job_id)
|
||||||
|
try:
|
||||||
|
reihe = cache_get(k)
|
||||||
|
except Exception:
|
||||||
|
reihe = None
|
||||||
|
reihe = beobachtung_hinzufuegen(reihe, status, progress, jetzt)
|
||||||
|
try:
|
||||||
|
# Eine Reihe ohne Fortschritt ist nach einem Tag wertlos.
|
||||||
|
cache_set(k, reihe, expire=86400)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
sekunden = restzeit_sekunden(reihe, jetzt)
|
||||||
|
return {"sekunden": sekunden, "text": formatiere_restzeit(sekunden)}
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
"""Jellyfin Image Downloader."""
|
|
||||||
|
|
||||||
import requests
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Dict, Optional
|
|
||||||
|
|
||||||
from clients.tmdb import TMDBClient
|
|
||||||
from clients.thetvdb import TheTVDBClient
|
|
||||||
|
|
||||||
|
|
||||||
class ImageDownloader:
|
|
||||||
def __init__(self):
|
|
||||||
self.tmdb = TMDBClient()
|
|
||||||
self.thetvdb = TheTVDBClient()
|
|
||||||
self.base_url = "https://image.tmdb.org/t/p"
|
|
||||||
|
|
||||||
def download_image(self, url: str, output_path: Path, width: int = 500) -> bool:
|
|
||||||
"""Lade Image herunter."""
|
|
||||||
try:
|
|
||||||
# TMDB URL anpassen
|
|
||||||
if url.startswith("https://image.tmdb.org"):
|
|
||||||
# Konvertiere zu gewünschter Größe
|
|
||||||
path = url.replace(f"{self.base_url}/", "")
|
|
||||||
url = f"{self.base_url}/w{width}/{path}"
|
|
||||||
|
|
||||||
response = requests.get(url, timeout=30)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with open(output_path, 'wb') as f:
|
|
||||||
f.write(response.content)
|
|
||||||
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Image download error: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def download_poster(self, title: str, output_dir: Path, width: int = 500) -> Optional[Path]:
|
|
||||||
"""Lade Poster herunter."""
|
|
||||||
# TMDB Search
|
|
||||||
movies = self.tmdb.search_movie(title)
|
|
||||||
if movies:
|
|
||||||
movie = movies[0]
|
|
||||||
images = self.tmdb.get_movie_images(movie["id"])
|
|
||||||
|
|
||||||
if images.get("poster"):
|
|
||||||
output_path = output_dir / "poster.jpg"
|
|
||||||
if self.download_image(images["poster"], output_path, width):
|
|
||||||
return output_path
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def download_fanart(self, title: str, output_dir: Path, width: int = 1920) -> Optional[Path]:
|
|
||||||
"""Lade Fanart herunter."""
|
|
||||||
# TMDB Search
|
|
||||||
movies = self.tmdb.search_movie(title)
|
|
||||||
if movies:
|
|
||||||
movie = movies[0]
|
|
||||||
images = self.tmdb.get_movie_images(movie["id"])
|
|
||||||
|
|
||||||
if images.get("fanart"):
|
|
||||||
output_path = output_dir / "fanart.jpg"
|
|
||||||
if self.download_image(images["fanart"], output_path, width):
|
|
||||||
return output_path
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def download_series_images(self, title: str, output_dir: Path) -> Dict[str, Optional[Path]]:
|
|
||||||
"""Lade Serien-Poster und Fanart herunter."""
|
|
||||||
result = {
|
|
||||||
"poster": None,
|
|
||||||
"fanart": None
|
|
||||||
}
|
|
||||||
|
|
||||||
tv_shows = self.tmdb.search_tv(title)
|
|
||||||
if tv_shows:
|
|
||||||
tv = tv_shows[0]
|
|
||||||
images = self.tmdb.get_tv_images(tv["id"])
|
|
||||||
|
|
||||||
if images.get("poster"):
|
|
||||||
output_path = output_dir / "poster.jpg"
|
|
||||||
if self.download_image(images["poster"], output_path, 500):
|
|
||||||
result["poster"] = output_path
|
|
||||||
|
|
||||||
if images.get("fanart"):
|
|
||||||
output_path = output_dir / "fanart.jpg"
|
|
||||||
if self.download_image(images["fanart"], output_path, 1920):
|
|
||||||
result["fanart"] = output_path
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
def download_music_images(self, artist: str, album: str, output_dir: Path) -> Dict[str, Optional[Path]]:
|
|
||||||
"""Lade Musik-Album-Cover herunter (via TMDB als Fallback)."""
|
|
||||||
result = {
|
|
||||||
"album": None
|
|
||||||
}
|
|
||||||
|
|
||||||
# TMDB Search für Soundtracks
|
|
||||||
query = f"{album} soundtrack"
|
|
||||||
movies = self.tmdb.search_movie(query)
|
|
||||||
if movies:
|
|
||||||
movie = movies[0]
|
|
||||||
images = self.tmdb.get_movie_images(movie["id"])
|
|
||||||
|
|
||||||
if images.get("poster"):
|
|
||||||
output_path = output_dir / "album.jpg"
|
|
||||||
if self.download_image(images["poster"], output_path, 500):
|
|
||||||
result["album"] = output_path
|
|
||||||
|
|
||||||
return result
|
|
||||||
+699
-157
File diff suppressed because it is too large
Load Diff
+44
-44
@@ -1,8 +1,8 @@
|
|||||||
"""MakeMKV-Datenverzeichnis: Schluesselspeicher, KEYDB.cfg, AACS-Dumps.
|
"""MakeMKV-Datenverzeichnis: Schlüsselspeicher, KEYDB.cfg, AACS-Dumps.
|
||||||
|
|
||||||
WARUM ES DIESE DATEI GIBT (Befund 25.07.2026, auf BEIDEN Maschinen gemessen):
|
WARUM ES DIESE DATEI GIBT (Befund 25.07.2026, auf BEIDEN Maschinen gemessen):
|
||||||
4K-UHD-Discs scheiterten mit "The volume key is unknown for this disc". Die
|
4K-UHD-Discs scheiterten mit "The volume key is unknown for this disc". Die
|
||||||
Ursache ist weder die Disc noch das Laufwerk — LibreDrive v06.3 laeuft und
|
Ursache ist weder die Disc noch das Laufwerk — LibreDrive v06.3 läuft und
|
||||||
MakeMKV liest die Disc — sondern:
|
MakeMKV liest die Disc — sondern:
|
||||||
|
|
||||||
makemkvcon unter LINUX ruft die Disc-Schluessel nie ab.
|
makemkvcon unter LINUX ruft die Disc-Schluessel nie ab.
|
||||||
@@ -10,35 +10,35 @@ MakeMKV liest die Disc — sondern:
|
|||||||
|
|
||||||
Gemessen, nicht vermutet:
|
Gemessen, nicht vermutet:
|
||||||
* Linux: in KEINEM Lauf auch nur eine Verbindung nach draussen. Geprueft mit
|
* Linux: in KEINEM Lauf auch nur eine Verbindung nach draussen. Geprueft mit
|
||||||
leerem UND mit gefuelltem Schluesselspeicher, mit und ohne --noscan, mit
|
leerem UND mit gefülltem Schlüsselspeicher, mit und ohne --noscan, mit
|
||||||
dev:/dev/sr0 und mit disc:0, und mit erzwungener frischer Pruefung
|
dev:/dev/sr0 und mit disc:0, und mit erzwungener frischer Prüfung
|
||||||
(update.conf geloescht, Meldung 5074 belegt den Web-Kontakt). Immer:
|
(update.conf gelöscht, Meldung 5074 belegt den Web-Kontakt). Immer:
|
||||||
keine Verbindung, kein Schluessel.
|
keine Verbindung, kein Schluessel.
|
||||||
* Windows, dieselbe Disc, dasselbe Laufwerk: Meldung 3338 "Downloading
|
* Windows, dieselbe Disc, dasselbe Laufwerk: Meldung 3338 "Downloading
|
||||||
latest HK to ...", Verbindung nach 185.84.108.20:443
|
latest HK to ...", Verbindung nach 185.84.108.20:443
|
||||||
(web33.majordomo.ru), _private_data.tar waechst — und die Disc geht auf
|
(web33.majordomo.ru), _private_data.tar wächst — und die Disc geht auf
|
||||||
(TCOUNT:5, "Operation successfully completed").
|
(TCOUNT:5, "Operation successfully completed").
|
||||||
* Der Code dafuer steckt auch im Linux-Binary: die Meldungsvorlage
|
* Der Code dafür steckt auch im Linux-Binary: die Meldungsvorlage
|
||||||
"Downloading latest %1 to %2 ..." steht in makemkvcon. Sie loest nur nie
|
"Downloading latest %1 to %2 ..." steht in makemkvcon. Sie löst nur nie
|
||||||
aus. Gleiches Symptom im Forum, seit Jahren offen und unbeantwortet.
|
aus. Gleiches Symptom im Forum, seit Jahren offen und unbeantwortet.
|
||||||
|
|
||||||
FRUEHERE FEHLDIAGNOSE, bewusst festgehalten: An dieser Stelle stand zuerst,
|
FRUEHERE FEHLDIAGNOSE, bewusst festgehalten: An dieser Stelle stand zuerst,
|
||||||
MakeMKVs Schluessel-Kanal sei abgeschaltet. Das war FALSCH. Die Herleitung
|
MakeMKVs Schluessel-Kanal sei abgeschaltet. Das war FALSCH. Die Herleitung
|
||||||
stuetzte sich auf zwei Hostnamen aus alten Forumsbeitraegen
|
stützte sich auf zwei Hostnamen aus alten Forumsbeitraegen
|
||||||
(hkdata.fairuse.org, hkdata.crabdance.com), die tatsaechlich nicht mehr
|
(hkdata.fairuse.org, hkdata.crabdance.com), die tatsaechlich nicht mehr
|
||||||
aufloesen — MakeMKV benutzt sie aber laengst nicht mehr. Der Dienst lebt, der
|
aufloesen — MakeMKV benutzt sie aber laengst nicht mehr. Der Dienst lebt, der
|
||||||
Worker erreicht ihn sogar; er wird unter Linux nur nie gefragt.
|
Worker erreicht ihn sogar; er wird unter Linux nur nie gefragt.
|
||||||
|
|
||||||
WAS DARAUS FOLGT: Der Schluesselspeicher (_private_data.tar) muss von einer
|
WAS DARAUS FOLGT: Der Schlüsselspeicher (_private_data.tar) muss von einer
|
||||||
MakeMKV-Installation kommen, die ihn wirklich abruft — praktisch von Windows.
|
MakeMKV-Installation kommen, die ihn wirklich abruft — praktisch von Windows.
|
||||||
Rippy nimmt ihn unter Einstellungen -> System entgegen. Die KEYDB.cfg bleibt
|
Rippy nimmt ihn unter Einstellungen -> System entgegen. Die KEYDB.cfg bleibt
|
||||||
der Notnagel fuer Pressungen, die auch MakeMKV selbst nicht kennt.
|
der Notnagel für Pressungen, die auch MakeMKV selbst nicht kennt.
|
||||||
|
|
||||||
Rippy liefert KEINE Schluessel mit, laedt keine herunter und verteilt keine.
|
Rippy liefert KEINE Schluessel mit, lädt keine herunter und verteilt keine.
|
||||||
Es verwaltet nur, was der Nutzer selbst mitbringt.
|
Es verwaltet nur, was der Nutzer selbst mitbringt.
|
||||||
|
|
||||||
QUELLEN (AGENTS Regel D — externe Schnittstellen nie aus dem Kopf):
|
QUELLEN (AGENTS Regel D — externe Schnittstellen nie aus dem Kopf):
|
||||||
* Linux laedt keine Hashed Keys — dasselbe Symptom, mehrfach berichtet:
|
* Linux lädt keine Hashed Keys — dasselbe Symptom, mehrfach berichtet:
|
||||||
https://forum.makemkv.com/forum/viewtopic.php?t=25782
|
https://forum.makemkv.com/forum/viewtopic.php?t=25782
|
||||||
https://forum.makemkv.com/forum/viewtopic.php?t=34022
|
https://forum.makemkv.com/forum/viewtopic.php?t=34022
|
||||||
* Schluessel liegen als hkd_*.bin in _private_data.tar:
|
* Schluessel liegen als hkd_*.bin in _private_data.tar:
|
||||||
@@ -55,7 +55,7 @@ QUELLEN (AGENTS Regel D — externe Schnittstellen nie aus dem Kopf):
|
|||||||
|
|
||||||
ZWILLINGSDATEI: liegt identisch unter docker/api/ und docker/worker/ — beide
|
ZWILLINGSDATEI: liegt identisch unter docker/api/ und docker/worker/ — beide
|
||||||
Images brauchen sie, ein gemeinsames Paket gibt es in diesem Projekt nicht
|
Images brauchen sie, ein gemeinsames Paket gibt es in diesem Projekt nicht
|
||||||
(gleiche Lage wie bei db.py). Aenderungen IMMER in BEIDEN Dateien nachziehen.
|
(gleiche Lage wie bei db.py). Änderungen IMMER in BEIDEN Dateien nachziehen.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
@@ -72,14 +72,14 @@ DATEN_DIR = os.getenv("MAKEMKV_DATA_DIR", "/root/.MakeMKV")
|
|||||||
# GROSS geschrieben — unter Linux case-sensitiv, "keydb.cfg" wird ignoriert.
|
# GROSS geschrieben — unter Linux case-sensitiv, "keydb.cfg" wird ignoriert.
|
||||||
KEYDB_NAME = "KEYDB.cfg"
|
KEYDB_NAME = "KEYDB.cfg"
|
||||||
|
|
||||||
# Obergrenze fuer den Upload. Eine vollstaendige oeffentliche KEYDB.cfg liegt
|
# Obergrenze für den Upload. Eine vollständige oeffentliche KEYDB.cfg liegt
|
||||||
# im einstelligen MB-Bereich; 64 MB sind reichlich Luft und verhindern, dass
|
# im einstelligen MB-Bereich; 64 MB sind reichlich Luft und verhindern, dass
|
||||||
# eine versehentlich hochgeladene Riesendatei den Speicher vollschreibt.
|
# eine versehentlich hochgeladene Riesendatei den Speicher vollschreibt.
|
||||||
MAX_KEYDB_BYTES = 64 * 1024 * 1024
|
MAX_KEYDB_BYTES = 64 * 1024 * 1024
|
||||||
|
|
||||||
# Eine Disc-Zeile beginnt mit der 40 Zeichen langen Hex-Kennung (optional mit
|
# Eine Disc-Zeile beginnt mit der 40 Zeichen langen Hex-Kennung (optional mit
|
||||||
# 0x davor), danach folgt das Gleichheitszeichen. Alles andere (Kommentare ab
|
# 0x davor), danach folgt das Gleichheitszeichen. Alles andere (Kommentare ab
|
||||||
# ";", Leerzeilen, Fortsetzungsfelder) zaehlt nicht als Eintrag.
|
# ";", Leerzeilen, Fortsetzungsfelder) zählt nicht als Eintrag.
|
||||||
_DISC_ZEILE = re.compile(r"^\s*(?:0x)?[0-9a-fA-F]{40}\s*=")
|
_DISC_ZEILE = re.compile(r"^\s*(?:0x)?[0-9a-fA-F]{40}\s*=")
|
||||||
|
|
||||||
|
|
||||||
@@ -94,9 +94,9 @@ def keydb_pfad(daten_dir: str = None) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def zaehle_disc_eintraege(inhalt: str) -> int:
|
def zaehle_disc_eintraege(inhalt: str) -> int:
|
||||||
"""Zeilen mit Disc-Kennung zaehlen (pure Funktion, testbar).
|
"""Zeilen mit Disc-Kennung zählen (pure Funktion, testbar).
|
||||||
|
|
||||||
Bewusst eine Heuristik und keine vollstaendige Auswertung: MakeMKV liest
|
Bewusst eine Heuristik und keine vollständige Auswertung: MakeMKV liest
|
||||||
die Datei mit seinem eigenen Parser, und wie viele Eintraege es daraus
|
die Datei mit seinem eigenen Parser, und wie viele Eintraege es daraus
|
||||||
macht, ist von aussen nicht sichtbar. Die Zahl dient nur dazu, im UI
|
macht, ist von aussen nicht sichtbar. Die Zahl dient nur dazu, im UI
|
||||||
"da liegt wirklich etwas drin" von "leere oder falsche Datei" zu
|
"da liegt wirklich etwas drin" von "leere oder falsche Datei" zu
|
||||||
@@ -106,17 +106,17 @@ def zaehle_disc_eintraege(inhalt: str) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def keydb_pruefen(inhalt: str) -> str:
|
def keydb_pruefen(inhalt: str) -> str:
|
||||||
"""Prueft hochgeladenen Inhalt; gibt deutschen Fehlertext oder "" zurueck.
|
"""Prüft hochgeladenen Inhalt; gibt deutschen Fehlertext oder "" zurück.
|
||||||
|
|
||||||
Verhindert den haeufigsten Bedienfehler: statt der KEYDB.cfg landet die
|
Verhindert den häufigsten Bedienfehler: statt der KEYDB.cfg landet die
|
||||||
HTML-Fehlerseite eines Downloads oder eine leere Datei im Verzeichnis —
|
HTML-Fehlerseite eines Downloads oder eine leere Datei im Verzeichnis —
|
||||||
MakeMKV wuerde dann still weiter "volume key is unknown" melden.
|
MakeMKV würde dann still weiter "volume key is unknown" melden.
|
||||||
"""
|
"""
|
||||||
if not inhalt.strip():
|
if not inhalt.strip():
|
||||||
return "Die Datei ist leer."
|
return "Die Datei ist leer."
|
||||||
if len(inhalt.encode("utf-8")) > MAX_KEYDB_BYTES:
|
if len(inhalt.encode("utf-8")) > MAX_KEYDB_BYTES:
|
||||||
return (
|
return (
|
||||||
"Die Datei ist groesser als "
|
"Die Datei ist größer als "
|
||||||
f"{MAX_KEYDB_BYTES // (1024 * 1024)} MB — das ist keine KEYDB.cfg."
|
f"{MAX_KEYDB_BYTES // (1024 * 1024)} MB — das ist keine KEYDB.cfg."
|
||||||
)
|
)
|
||||||
if inhalt.lstrip()[:1] == "<":
|
if inhalt.lstrip()[:1] == "<":
|
||||||
@@ -150,7 +150,7 @@ def keydb_status(daten_dir: str = None) -> dict:
|
|||||||
with open(pfad, encoding="utf-8", errors="replace") as datei:
|
with open(pfad, encoding="utf-8", errors="replace") as datei:
|
||||||
eintraege = zaehle_disc_eintraege(datei.read())
|
eintraege = zaehle_disc_eintraege(datei.read())
|
||||||
except OSError:
|
except OSError:
|
||||||
pass # Datei da, aber unlesbar: Groesse/Datum stimmen trotzdem
|
pass # Datei da, aber unlesbar: Größe/Datum stimmen trotzdem
|
||||||
return {
|
return {
|
||||||
"vorhanden": True,
|
"vorhanden": True,
|
||||||
"pfad": pfad,
|
"pfad": pfad,
|
||||||
@@ -163,7 +163,7 @@ def keydb_status(daten_dir: str = None) -> dict:
|
|||||||
def keydb_schreiben(inhalt: str, daten_dir: str = None) -> dict:
|
def keydb_schreiben(inhalt: str, daten_dir: str = None) -> dict:
|
||||||
"""Schreibt die KEYDB.cfg atomar und meldet den neuen Zustand.
|
"""Schreibt die KEYDB.cfg atomar und meldet den neuen Zustand.
|
||||||
|
|
||||||
Erst in eine Nebendatei, dann os.replace: waehrend ein Rip laeuft, darf
|
Erst in eine Nebendatei, dann os.replace: während ein Rip läuft, darf
|
||||||
makemkvcon niemals eine halb geschriebene Datei zu sehen bekommen.
|
makemkvcon niemals eine halb geschriebene Datei zu sehen bekommen.
|
||||||
"""
|
"""
|
||||||
pfad = keydb_pfad(daten_dir)
|
pfad = keydb_pfad(daten_dir)
|
||||||
@@ -244,32 +244,32 @@ def dumps_auflisten(daten_dir: str = None) -> list:
|
|||||||
return liste
|
return liste
|
||||||
|
|
||||||
|
|
||||||
# --- Schluesselspeicher (_private_data.tar) -------------------------------
|
# --- Schlüsselspeicher (_private_data.tar) -------------------------------
|
||||||
#
|
#
|
||||||
# Das ist MakeMKVs eigener Speicher fuer die "Hashed Keys": ein tar-Archiv mit
|
# Das ist MakeMKVs eigener Speicher für die "Hashed Keys": ein tar-Archiv mit
|
||||||
# hkd_*.bin-Eintraegen. Unter Windows fuellt MakeMKV es selbst (Meldung 3338),
|
# hkd_*.bin-Eintraegen. Unter Windows füllt MakeMKV es selbst (Meldung 3338),
|
||||||
# unter Linux nie — siehe Modul-Kopf. Rippy nimmt die Datei deshalb entgegen
|
# unter Linux nie — siehe Modul-Kopf. Rippy nimmt die Datei deshalb entgegen
|
||||||
# und legt sie ins Datenverzeichnis; MakeMKV liest sie beim naechsten Start.
|
# und legt sie ins Datenverzeichnis; MakeMKV liest sie beim nächsten Start.
|
||||||
|
|
||||||
PRIVATE_DATA_NAME = "_private_data.tar"
|
PRIVATE_DATA_NAME = "_private_data.tar"
|
||||||
|
|
||||||
# Der Speicher lag am 25.07.2026 bei rund 6 MB und waechst mit jeder neuen
|
# Der Speicher lag am 25.07.2026 bei rund 6 MB und wächst mit jeder neuen
|
||||||
# Pressung. 64 MB sind reichlich Luft — und derselbe Wert wie
|
# Pressung. 64 MB sind reichlich Luft — und derselbe Wert wie
|
||||||
# client_max_body_size in docker/ui/nginx.conf: waere die Grenze hier hoeher,
|
# client_max_body_size in docker/ui/nginx.conf: wäre die Grenze hier höher,
|
||||||
# wuerde nginx den Upload abweisen, bevor die API ihn ueberhaupt sieht.
|
# würde nginx den Upload abweisen, bevor die API ihn überhaupt sieht.
|
||||||
MAX_PRIVATE_DATA_BYTES = 64 * 1024 * 1024
|
MAX_PRIVATE_DATA_BYTES = 64 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
def private_data_pfad(daten_dir: str = None) -> str:
|
def private_data_pfad(daten_dir: str = None) -> str:
|
||||||
"""Voller Pfad zum Schluesselspeicher im Datenverzeichnis."""
|
"""Voller Pfad zum Schlüsselspeicher im Datenverzeichnis."""
|
||||||
return os.path.join(daten_dir or DATEN_DIR, PRIVATE_DATA_NAME)
|
return os.path.join(daten_dir or DATEN_DIR, PRIVATE_DATA_NAME)
|
||||||
|
|
||||||
|
|
||||||
def zaehle_schluessel(rohdaten: bytes) -> int:
|
def zaehle_schluessel(rohdaten: bytes) -> int:
|
||||||
"""Anzahl der hkd_*.bin-Eintraege im Archiv (pure Funktion, testbar).
|
"""Anzahl der hkd_*.bin-Eintraege im Archiv (pure Funktion, testbar).
|
||||||
|
|
||||||
Das ist die ehrliche Kennzahl fuer "wie viele Disc-Schluessel kennt diese
|
Das ist die ehrliche Kennzahl für "wie viele Disc-Schluessel kennt diese
|
||||||
Installation". Ein frischer, leerer Speicher enthaelt nur eine
|
Installation". Ein frischer, leerer Speicher enthält nur eine
|
||||||
Index-Datei und kommt hier auf 0 — genau der Zustand, in dem jede
|
Index-Datei und kommt hier auf 0 — genau der Zustand, in dem jede
|
||||||
unbekannte UHD-Disc scheitert.
|
unbekannte UHD-Disc scheitert.
|
||||||
"""
|
"""
|
||||||
@@ -281,20 +281,20 @@ def zaehle_schluessel(rohdaten: bytes) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def private_data_pruefen(rohdaten: bytes) -> str:
|
def private_data_pruefen(rohdaten: bytes) -> str:
|
||||||
"""Prueft hochgeladene Rohdaten; deutscher Fehlertext oder "".
|
"""Prüft hochgeladene Rohdaten; deutscher Fehlertext oder "".
|
||||||
|
|
||||||
Faengt die beiden Bedienfehler ab, die sonst still danebengehen: eine
|
Faengt die beiden Bedienfehler ab, die sonst still danebengehen: eine
|
||||||
voellig andere Datei hochladen, oder den Speicher einer Installation, die
|
voellig andere Datei hochladen, oder den Speicher einer Installation, die
|
||||||
selbst noch keine Schluessel geholt hat (dann aendert sich nichts, und
|
selbst noch keine Schluessel geholt hat (dann ändert sich nichts, und
|
||||||
niemand versteht warum).
|
niemand versteht warum).
|
||||||
"""
|
"""
|
||||||
if not rohdaten:
|
if not rohdaten:
|
||||||
return "Die Datei ist leer."
|
return "Die Datei ist leer."
|
||||||
if len(rohdaten) > MAX_PRIVATE_DATA_BYTES:
|
if len(rohdaten) > MAX_PRIVATE_DATA_BYTES:
|
||||||
return (
|
return (
|
||||||
"Die Datei ist groesser als "
|
"Die Datei ist größer als "
|
||||||
f"{MAX_PRIVATE_DATA_BYTES // (1024 * 1024)} MB — das ist kein "
|
f"{MAX_PRIVATE_DATA_BYTES // (1024 * 1024)} MB — das ist kein "
|
||||||
"MakeMKV-Schluesselspeicher."
|
"MakeMKV-Schlüsselspeicher."
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with tarfile.open(fileobj=io.BytesIO(rohdaten)) as archiv:
|
with tarfile.open(fileobj=io.BytesIO(rohdaten)) as archiv:
|
||||||
@@ -308,13 +308,13 @@ def private_data_pruefen(rohdaten: bytes) -> str:
|
|||||||
return (
|
return (
|
||||||
"In dieser Datei steckt kein einziger Schluessel (kein hkd_*.bin). "
|
"In dieser Datei steckt kein einziger Schluessel (kein hkd_*.bin). "
|
||||||
"Sie stammt vermutlich von einer MakeMKV-Installation, die selbst "
|
"Sie stammt vermutlich von einer MakeMKV-Installation, die selbst "
|
||||||
"noch keine geholt hat — oeffne dort erst einmal eine Disc."
|
"noch keine geholt hat — öffne dort erst einmal eine Disc."
|
||||||
)
|
)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def schluesselspeicher_status(daten_dir: str = None) -> dict:
|
def schluesselspeicher_status(daten_dir: str = None) -> dict:
|
||||||
"""Zustand des Schluesselspeichers. Fehlt er, ist das kein Fehler."""
|
"""Zustand des Schlüsselspeichers. Fehlt er, ist das kein Fehler."""
|
||||||
pfad = private_data_pfad(daten_dir)
|
pfad = private_data_pfad(daten_dir)
|
||||||
try:
|
try:
|
||||||
angaben = os.stat(pfad)
|
angaben = os.stat(pfad)
|
||||||
@@ -338,9 +338,9 @@ def schluesselspeicher_status(daten_dir: str = None) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def private_data_schreiben(rohdaten: bytes, daten_dir: str = None) -> dict:
|
def private_data_schreiben(rohdaten: bytes, daten_dir: str = None) -> dict:
|
||||||
"""Legt den Schluesselspeicher atomar ab und meldet den neuen Zustand.
|
"""Legt den Schlüsselspeicher atomar ab und meldet den neuen Zustand.
|
||||||
|
|
||||||
Atomar aus demselben Grund wie bei der KEYDB.cfg: waehrend ein Rip laeuft,
|
Atomar aus demselben Grund wie bei der KEYDB.cfg: während ein Rip läuft,
|
||||||
darf makemkvcon nie ein halb geschriebenes Archiv sehen.
|
darf makemkvcon nie ein halb geschriebenes Archiv sehen.
|
||||||
"""
|
"""
|
||||||
pfad = private_data_pfad(daten_dir)
|
pfad = private_data_pfad(daten_dir)
|
||||||
@@ -363,9 +363,9 @@ def settings_conf_zusammenfuehren(inhalt: str, key: str) -> str:
|
|||||||
"""app_Key setzen, ohne den Rest der settings.conf zu verlieren (pure).
|
"""app_Key setzen, ohne den Rest der settings.conf zu verlieren (pure).
|
||||||
|
|
||||||
Bis zum 25.07.2026 haben entrypoint.sh UND tasks.py die Datei komplett
|
Bis zum 25.07.2026 haben entrypoint.sh UND tasks.py die Datei komplett
|
||||||
ueberschrieben. Mit dem jetzt persistenten Datenverzeichnis waere damit
|
überschrieben. Mit dem jetzt persistenten Datenverzeichnis wäre damit
|
||||||
bei jedem Containerstart und vor jedem Rip alles andere weg — z. B.
|
bei jedem Containerstart und vor jedem Rip alles andere weg — z. B.
|
||||||
app_UpdateEnable. Leerer Key laesst die vorhandene Zeile ebenfalls fallen,
|
app_UpdateEnable. Leerer Key lässt die vorhandene Zeile ebenfalls fallen,
|
||||||
damit ein bewusst geleerter Key nicht heimlich weiterwirkt.
|
damit ein bewusst geleerter Key nicht heimlich weiterwirkt.
|
||||||
"""
|
"""
|
||||||
zeilen = [z for z in inhalt.splitlines() if not z.lstrip().startswith("app_Key")]
|
zeilen = [z for z in inhalt.splitlines() if not z.lstrip().startswith("app_Key")]
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
"""Automatische Erneuerung des MakeMKV-Beta-Keys.
|
"""Automatische Erneuerung des MakeMKV-Beta-Keys.
|
||||||
|
|
||||||
Der kostenlose MakeMKV-Beta-Key (Forum-Thread "MakeMKV is free while in beta",
|
Der kostenlose MakeMKV-Beta-Key (Forum-Thread "MakeMKV is free while in beta",
|
||||||
t=1053) wechselt etwa monatlich und laeuft zum Monatsende ab. Ohne gueltigen Key
|
t=1053) wechselt etwa monatlich und läuft zum Monatsende ab. Ohne gültigen Key
|
||||||
faellt Blu-ray/UHD-Ripping in den 30-Tage-Testmodus; DVD-Ripping bleibt frei.
|
fällt Blu-ray/UHD-Ripping in den 30-Tage-Testmodus; DVD-Ripping bleibt frei.
|
||||||
Bisher musste der Key von Hand nachgetragen werden (UI: Einstellungen -> System).
|
Bisher musste der Key von Hand nachgetragen werden (UI: Einstellungen -> System).
|
||||||
|
|
||||||
Dieses Modul holt den aktuellen Key vom Forum und schreibt ihn in die Settings
|
Dieses Modul holt den aktuellen Key vom Forum und schreibt ihn in die Settings
|
||||||
(makemkvAppKey). Der Worker liest den Key VOR jedem Rip aus den Settings
|
(makemkvAppKey). Der Worker liest den Key VOR jedem Rip aus den Settings
|
||||||
(docker/worker/tasks.py) und schreibt settings.conf -> die Erneuerung wirkt ohne
|
(docker/worker/tasks.py) und schreibt settings.conf -> die Erneuerung wirkt ohne
|
||||||
Rebuild/Neustart, ab dem naechsten Rip.
|
Rebuild/Neustart, ab dem nächsten Rip.
|
||||||
|
|
||||||
Wichtig: Das ist die kostenlose, oeffentliche Beta-LIZENZ der Software selbst —
|
Wichtig: Das ist die kostenlose, oeffentliche Beta-LIZENZ der Software selbst —
|
||||||
NICHT das Entschluesseln oder Verteilen von Disc-Schluesseln. Diese Grenze gilt
|
NICHT das Entschluesseln oder Verteilen von Disc-Schluesseln. Diese Grenze gilt
|
||||||
unveraendert; Rippy liefert keine Disc-Schluessel mit und verteilt keine.
|
unveraendert; Rippy liefert keine Disc-Schluessel mit und verteilt keine.
|
||||||
|
|
||||||
Richtigstellung 25.07.2026: Hier stand frueher, MakeMKV ziehe den AACS-Schluessel
|
Richtigstellung 25.07.2026: Hier stand früher, MakeMKV ziehe den AACS-Schluessel
|
||||||
via LibreDrive ohnehin selbst aus dem Laufwerk. Das stimmt fuer Blu-ray, aber
|
via LibreDrive ohnehin selbst aus dem Laufwerk. Das stimmt für Blu-ray, aber
|
||||||
NICHT fuer 4K-UHD — dort braucht MakeMKV den Volume-Key der jeweiligen Pressung,
|
NICHT für 4K-UHD — dort braucht MakeMKV den Volume-Key der jeweiligen Pressung,
|
||||||
und den bekommt es weder aus dem Laufwerk noch (heute) aus dem Netz. Belege und
|
und den bekommt es weder aus dem Laufwerk noch (heute) aus dem Netz. Belege und
|
||||||
Messungen stehen im Modul-Kopf von makemkv_daten.py.
|
Messungen stehen im Modul-Kopf von makemkv_daten.py.
|
||||||
|
|
||||||
@@ -41,14 +41,14 @@ def extract_key(html: str) -> str | None:
|
|||||||
|
|
||||||
def fetch_current_key(timeout: int = 15) -> str | None:
|
def fetch_current_key(timeout: int = 15) -> str | None:
|
||||||
"""Holt den aktuellen Beta-Key vom MakeMKV-Forum. None bei Fehler/kein Treffer."""
|
"""Holt den aktuellen Beta-Key vom MakeMKV-Forum. None bei Fehler/kein Treffer."""
|
||||||
import requests # lazy: haelt extract_key + den Test dependency-frei
|
import requests # lazy: hält extract_key + den Test dependency-frei
|
||||||
antwort = requests.get(FORUM_URL, headers=_HEADERS, timeout=timeout)
|
antwort = requests.get(FORUM_URL, headers=_HEADERS, timeout=timeout)
|
||||||
antwort.raise_for_status()
|
antwort.raise_for_status()
|
||||||
return extract_key(antwort.text)
|
return extract_key(antwort.text)
|
||||||
|
|
||||||
|
|
||||||
def _apply_key(key: str) -> bool:
|
def _apply_key(key: str) -> bool:
|
||||||
"""Merge den Key in die Settings. save_settings ueberschreibt das GANZE JSON,
|
"""Merge den Key in die Settings. save_settings überschreibt das GANZE JSON,
|
||||||
darum erst lesen, dann setzen. Rueckgabe True, wenn sich der Key geaendert hat."""
|
darum erst lesen, dann setzen. Rueckgabe True, wenn sich der Key geaendert hat."""
|
||||||
import db
|
import db
|
||||||
einstellungen = db.get_settings()
|
einstellungen = db.get_settings()
|
||||||
@@ -76,7 +76,7 @@ async def refresh_once() -> bool:
|
|||||||
|
|
||||||
async def refresh_loop(intervall_stunden: int = 24, start_verzoegerung_s: int = 60) -> None:
|
async def refresh_loop(intervall_stunden: int = 24, start_verzoegerung_s: int = 60) -> None:
|
||||||
"""Periodische Erneuerung (Default: taeglich, damit ein Monatswechsel nie verpasst
|
"""Periodische Erneuerung (Default: taeglich, damit ein Monatswechsel nie verpasst
|
||||||
wird). Fehler werden geloggt, aber nie geworfen -> die API laeuft weiter."""
|
wird). Fehler werden geloggt, aber nie geworfen -> die API läuft weiter."""
|
||||||
import db
|
import db
|
||||||
await asyncio.sleep(start_verzoegerung_s)
|
await asyncio.sleep(start_verzoegerung_s)
|
||||||
while True:
|
while True:
|
||||||
|
|||||||
+234
-47
@@ -11,6 +11,7 @@ sondern über eine temporäre credentials-Datei).
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import posixpath
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -22,7 +23,16 @@ NAME_MUSTER = re.compile(r"^[a-z0-9][a-z0-9-]{1,30}$")
|
|||||||
|
|
||||||
|
|
||||||
def _mountpoint(name: str) -> str:
|
def _mountpoint(name: str) -> str:
|
||||||
return os.path.join(MEDIA_ROOT, name)
|
"""Mountpunkt eines Speicherziels — IMMER ein Container-Pfad.
|
||||||
|
|
||||||
|
posixpath statt os.path (Befund 26.07.2026): Innerhalb des api-Containers
|
||||||
|
ist das dasselbe, aber `pfad_map_vorschlag()` liefert diesen Pfad an einen
|
||||||
|
WINDOWS-Worker aus. Mit os.path.join entstand beim Test unter Windows
|
||||||
|
`/app/media\\rippy`, und `pfad_lokal()` fand dann kein Präfix — das Mapping
|
||||||
|
wäre still wirkungslos geblieben. Dieselbe Falle wie bei `_zielbasis()` im
|
||||||
|
Worker (v3.14), diesmal vom Test gefunden statt live.
|
||||||
|
"""
|
||||||
|
return posixpath.join(MEDIA_ROOT, name)
|
||||||
|
|
||||||
|
|
||||||
def validiere_name(name: str) -> bool:
|
def validiere_name(name: str) -> bool:
|
||||||
@@ -57,6 +67,60 @@ def ist_erreichbar(name: str) -> bool:
|
|||||||
return False
|
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:
|
def schreibtest(pfad: str) -> bool:
|
||||||
"""Berechtigungs-Prüfung: können wir im Ziel wirklich schreiben?
|
"""Berechtigungs-Prüfung: können wir im Ziel wirklich schreiben?
|
||||||
|
|
||||||
@@ -76,11 +140,11 @@ def schreibtest(pfad: str) -> bool:
|
|||||||
def _stale_mounts_loesen(ziel: str) -> int:
|
def _stale_mounts_loesen(ziel: str) -> int:
|
||||||
"""Loest ALLE (evtl. gestapelten/toten) Mounts an `ziel` per lazy umount.
|
"""Loest ALLE (evtl. gestapelten/toten) Mounts an `ziel` per lazy umount.
|
||||||
|
|
||||||
Warum: mounten() faellt bei einem TOTEN Mount (os.path.ismount wirft OSError)
|
Warum: mounten() fällt bei einem TOTEN Mount (os.path.ismount wirft OSError)
|
||||||
auf den echten `mount` durch — der stapelt dann auf die Leiche. Vorfall 24.07.:
|
auf den echten `mount` durch — der stapelt dann auf die Leiche. Vorfall 24.07.:
|
||||||
ueber viele Neustarts 12 Schichten, die tote oberste blockierte jeden Zugriff
|
über viele Neustarts 12 Schichten, die tote oberste blockierte jeden Zugriff
|
||||||
(ls-Timeout, obwohl SMB-445 offen). Erst alle Schichten loesen macht das
|
(ls-Timeout, obwohl SMB-445 offen). Erst alle Schichten loesen macht das
|
||||||
Re-Mounten idempotent. `umount -l` (lazy) haengt nicht an einem toten CIFS.
|
Re-Mounten idempotent. `umount -l` (lazy) hängt nicht an einem toten CIFS.
|
||||||
Rueckgabe: Zahl der geloesten Schichten.
|
Rueckgabe: Zahl der geloesten Schichten.
|
||||||
"""
|
"""
|
||||||
geloest = 0
|
geloest = 0
|
||||||
@@ -94,6 +158,18 @@ def _stale_mounts_loesen(ziel: str) -> int:
|
|||||||
if ergebnis.returncode != 0:
|
if ergebnis.returncode != 0:
|
||||||
break # nichts (mehr) gemountet
|
break # nichts (mehr) gemountet
|
||||||
geloest += 1
|
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
|
return geloest
|
||||||
|
|
||||||
|
|
||||||
@@ -161,6 +237,65 @@ def liste_smb_freigaben(host: str, username: str = "", passwort: str = "") -> li
|
|||||||
return freigaben
|
return freigaben
|
||||||
|
|
||||||
|
|
||||||
|
def unc_aus_quelle(typ: str, quelle: str) -> str:
|
||||||
|
"""`//host/freigabe` → `\\\\host\\freigabe` (pure Funktion, testbar).
|
||||||
|
|
||||||
|
Nur für CIFS/SMB. NFS gibt "" zurück: Windows kann NFS zwar einhängen
|
||||||
|
(optionale Funktion „Client für NFS"), die Schreibweise ist aber eine
|
||||||
|
andere und der Export-Pfad lässt sich nicht zuverlässig übersetzen —
|
||||||
|
raten verstößt gegen AGENTS Regel D.
|
||||||
|
"""
|
||||||
|
if typ != "cifs" or not quelle:
|
||||||
|
return ""
|
||||||
|
rest = quelle.strip()
|
||||||
|
if not rest.startswith("//"):
|
||||||
|
return ""
|
||||||
|
return "\\\\" + rest[2:].replace("/", "\\")
|
||||||
|
|
||||||
|
|
||||||
|
def pfad_map_vorschlag(eintraege: list) -> list:
|
||||||
|
"""Welcher Container-Pfad entspricht welcher Windows-Freigabe?
|
||||||
|
|
||||||
|
Das ist der fehlende Anschluss für `RIPPY_PATH_MAP` (Befund 26.07.2026):
|
||||||
|
Ein externer Worker bekommt von Rippy Pfade wie `/app/media/rippy/<job>`
|
||||||
|
— das sind Pfade INNERHALB des Containers. Er sieht sie nur, wenn sie auf
|
||||||
|
eine Netzwerk-Freigabe übersetzt werden, und dieses Mapping wurde bis
|
||||||
|
dahin von niemandem gesetzt. Externes Encoden konnte also nie laufen.
|
||||||
|
|
||||||
|
Geraten werden muss dafür nichts: Rippy hat die Freigabe selbst
|
||||||
|
eingehängt und kennt ihre Quelle (z. B. `//NAS/rippy`). Der Mountpunkt ist
|
||||||
|
`/app/media/<name>`, und beides zusammen IST das Mapping.
|
||||||
|
"""
|
||||||
|
vorschlaege = []
|
||||||
|
for eintrag in eintraege or []:
|
||||||
|
name = (eintrag.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
typ = (eintrag.get("typ") or "").strip()
|
||||||
|
unc = unc_aus_quelle(typ, eintrag.get("quelle") or "")
|
||||||
|
vorschlaege.append({
|
||||||
|
"name": name,
|
||||||
|
"typ": typ,
|
||||||
|
"quelle": eintrag.get("quelle") or "",
|
||||||
|
"container": _mountpoint(name),
|
||||||
|
"unc": unc,
|
||||||
|
"gemountet": ist_gemountet(name),
|
||||||
|
})
|
||||||
|
return vorschlaege
|
||||||
|
|
||||||
|
|
||||||
|
def pfad_map_zeile(vorschlaege: list) -> str:
|
||||||
|
"""Die Vorschläge als fertiger RIPPY_PATH_MAP-Wert (pure Funktion).
|
||||||
|
|
||||||
|
Format wie `worker/tasks.pfad_lokal()` es liest: Paare `container=ziel`,
|
||||||
|
getrennt durch `;`. Einträge ohne übersetzbaren UNC-Pfad (NFS) fallen
|
||||||
|
weg — ein halbes Mapping wäre schlimmer als keines, weil `pfad_lokal`
|
||||||
|
beim ersten passenden Präfix aufhört.
|
||||||
|
"""
|
||||||
|
paare = [f"{v['container']}={v['unc']}" for v in vorschlaege or [] if v.get("unc")]
|
||||||
|
return ";".join(paare)
|
||||||
|
|
||||||
|
|
||||||
def mounten(name: str, typ: str, quelle: str, optionen: str = "",
|
def mounten(name: str, typ: str, quelle: str, optionen: str = "",
|
||||||
username: str = "", passwort: str = "") -> bool:
|
username: str = "", passwort: str = "") -> bool:
|
||||||
"""Hängt ein NFS/CIFS-Ziel unter /app/media/<name> ein.
|
"""Hängt ein NFS/CIFS-Ziel unter /app/media/<name> ein.
|
||||||
@@ -169,17 +304,40 @@ def mounten(name: str, typ: str, quelle: str, optionen: str = "",
|
|||||||
Mount-Fehlern.
|
Mount-Fehlern.
|
||||||
"""
|
"""
|
||||||
ziel = _mountpoint(name)
|
ziel = _mountpoint(name)
|
||||||
# exist_ok fängt „Ordner da" NICHT, wenn ein toter Mount os.path.isdir
|
# Den Ordner NUR anlegen, wenn er wirklich fehlt — und das mit Zeitgrenze
|
||||||
# täuschen lässt (FileExistsError, Befund 24.07.) — deshalb tolerant.
|
# klären (siehe pfad_lage): `os.makedirs(..., exist_ok=True)` hing auf einem
|
||||||
try:
|
# toten CIFS-Mount drei Minuten im Kernel, weil `exist_ok` ein `stat` macht.
|
||||||
os.makedirs(ziel, exist_ok=True)
|
if pfad_lage(ziel) == "weg":
|
||||||
except FileExistsError:
|
try:
|
||||||
pass
|
os.makedirs(ziel, exist_ok=True)
|
||||||
try:
|
except OSError:
|
||||||
if os.path.ismount(ziel):
|
pass # Rennen mit einem anderen Aufruf oder Rechte — mount sagt es
|
||||||
return schreibtest(ziel)
|
# ⚠️ ERREICHBARKEIT VOR ismount — nicht umgekehrt (Befund 26.07.2026).
|
||||||
except OSError:
|
#
|
||||||
pass # toter Mount → unten erst lösen, dann frisch mounten (kein Stapeln)
|
# 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
|
# Idempotent: etwaige (auch gestapelte/tote) Alt-Mounts erst lösen, damit der
|
||||||
# folgende mount NICHT auf eine Leiche stapelt (Vorfall 24.07.: 12 Schichten).
|
# folgende mount NICHT auf eine Leiche stapelt (Vorfall 24.07.: 12 Schichten).
|
||||||
@@ -209,18 +367,43 @@ def mounten(name: str, typ: str, quelle: str, optionen: str = "",
|
|||||||
else:
|
else:
|
||||||
raise RuntimeError(f"Unbekannter Typ: {typ} (nfs oder cifs)")
|
raise RuntimeError(f"Unbekannter Typ: {typ} (nfs oder cifs)")
|
||||||
|
|
||||||
ergebnis = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
# Zwei Versuche. Grund (26.07.2026, dreimal reproduziert): Beim API-Start
|
||||||
if ergebnis.returncode != 0:
|
# meldete `mount` Erfolg — und die Freigabe antwortete danach trotzdem
|
||||||
fehler = (ergebnis.stderr or ergebnis.stdout or "").strip()
|
# nicht (`timeout 6 ls` lief in die Grenze, eine einzige, korrekt
|
||||||
hinweis = ""
|
# aussehende Mount-Schicht in /proc/mounts). Derselbe Ablauf ein zweites
|
||||||
if "error(13)" in fehler or "Permission denied" in fehler:
|
# Mal, per POST /storage-mounts/<name>/repair, stellte sie sofort her.
|
||||||
hinweis = (
|
# Der erste SMB-Sitzungsaufbau kurz nach dem Container-Start geht also
|
||||||
" — Zugriff verweigert: Benutzer/Passwort prüfen; ohne "
|
# gelegentlich schief, ohne es zu melden.
|
||||||
"Angaben versucht Rippy einen Gast-Zugriff, den Windows/"
|
#
|
||||||
"NAS meist ablehnen."
|
# Deshalb wird das Ergebnis GEPRÜFT statt geglaubt: Antwortet die
|
||||||
)
|
# Freigabe nach dem Mount nicht, wird einmal gelöst und neu gemountet.
|
||||||
raise RuntimeError(f"mount schlug fehl: {fehler[:300]}{hinweis}")
|
# Und wenn das auch nicht hilft, fliegt ein Fehler — dann steht im Log
|
||||||
return schreibtest(ziel)
|
# „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:
|
finally:
|
||||||
if creds_datei:
|
if creds_datei:
|
||||||
try:
|
try:
|
||||||
@@ -237,22 +420,25 @@ def _lazy_umount(ziel: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def aushaengen(name: 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)
|
ziel = _mountpoint(name)
|
||||||
try:
|
ergebnis = subprocess.run(
|
||||||
noch_mount = os.path.ismount(ziel)
|
["umount", ziel], capture_output=True, text=True, timeout=30
|
||||||
except OSError:
|
)
|
||||||
noch_mount = True # stale
|
if ergebnis.returncode != 0:
|
||||||
if noch_mount:
|
# Toter/beschäftigter Mount → lazy detach (klappt immer)
|
||||||
ergebnis = subprocess.run(
|
_lazy_umount(ziel)
|
||||||
["umount", ziel], capture_output=True, text=True, timeout=30
|
# Erst wenn der Pfad wieder antwortet, darf rmdir ihn anfassen.
|
||||||
)
|
if pfad_lage(ziel) == "da":
|
||||||
if ergebnis.returncode != 0:
|
try:
|
||||||
# Toter/beschäftigter Mount → lazy detach (klappt immer)
|
os.rmdir(ziel)
|
||||||
_lazy_umount(ziel)
|
except OSError:
|
||||||
try:
|
pass # nicht leer oder weg — egal
|
||||||
os.rmdir(ziel)
|
|
||||||
except OSError:
|
|
||||||
pass # nicht leer oder weg — egal
|
|
||||||
|
|
||||||
|
|
||||||
def reparieren(name: str, typ: str, quelle: str, optionen: str = "",
|
def reparieren(name: str, typ: str, quelle: str, optionen: str = "",
|
||||||
@@ -261,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
|
Nötig, wenn ein NAS-Mount stale geworden ist (Rebuild, NAS-Schlaf) — das
|
||||||
normale mounten() würde am „ist schon Mountpoint" hängenbleiben.
|
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)
|
ziel = _mountpoint(name)
|
||||||
try:
|
_lazy_umount(ziel)
|
||||||
if os.path.ismount(ziel):
|
|
||||||
_lazy_umount(ziel)
|
|
||||||
except OSError:
|
|
||||||
_lazy_umount(ziel)
|
|
||||||
return mounten(name, typ, quelle, optionen, username, passwort)
|
return mounten(name, typ, quelle, optionen, username, passwort)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
"""NFO-Generator für Jellyfin (Kodi/NFO-Schema)."""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import List
|
|
||||||
from xml.dom.minidom import getDOMImplementation
|
|
||||||
|
|
||||||
|
|
||||||
class NFOGenerator:
|
|
||||||
def __init__(self):
|
|
||||||
self.dom_impl = getDOMImplementation()
|
|
||||||
|
|
||||||
def _create_element(self, doc, name: str, text: str = None) -> None:
|
|
||||||
"""Hilfsfunktion für Element-Erstellung."""
|
|
||||||
element = doc.createElement(name)
|
|
||||||
if text:
|
|
||||||
element.appendChild(doc.createTextNode(str(text)))
|
|
||||||
return element
|
|
||||||
|
|
||||||
def generate_movie_nfo(self, title: str, year: int,
|
|
||||||
overview: str = "", rating: float = 0.0,
|
|
||||||
runtime: int = 0, genres: List[str] = None,
|
|
||||||
director: str = "", writer: str = "",
|
|
||||||
actors: List[str] = None, studio: str = "",
|
|
||||||
premiered: str = "", mpaa: str = "") -> str:
|
|
||||||
"""Generiere movie.nfo für Filme."""
|
|
||||||
doc = self.dom_impl.createDocument(None, "movie", None)
|
|
||||||
root = doc.documentElement
|
|
||||||
|
|
||||||
root.appendChild(self._create_element(doc, "title", title))
|
|
||||||
root.appendChild(self._create_element(doc, "year", year))
|
|
||||||
root.appendChild(self._create_element(doc, "plot", overview))
|
|
||||||
root.appendChild(self._create_element(doc, "rating", rating))
|
|
||||||
root.appendChild(self._create_element(doc, "runtime", runtime))
|
|
||||||
|
|
||||||
if genres:
|
|
||||||
for genre in genres:
|
|
||||||
root.appendChild(self._create_element(doc, "genre", genre))
|
|
||||||
|
|
||||||
if director:
|
|
||||||
root.appendChild(self._create_element(doc, "director", director))
|
|
||||||
|
|
||||||
if writer:
|
|
||||||
root.appendChild(self._create_element(doc, "writer", writer))
|
|
||||||
|
|
||||||
if actors:
|
|
||||||
for actor in actors:
|
|
||||||
actor_node = doc.createElement("actor")
|
|
||||||
actor_node.appendChild(self._create_element(doc, "name", actor))
|
|
||||||
root.appendChild(actor_node)
|
|
||||||
|
|
||||||
if studio:
|
|
||||||
root.appendChild(self._create_element(doc, "studio", studio))
|
|
||||||
|
|
||||||
if premiered:
|
|
||||||
root.appendChild(self._create_element(doc, "premiered", premiered))
|
|
||||||
|
|
||||||
if mpaa:
|
|
||||||
root.appendChild(self._create_element(doc, "mpaa", mpaa))
|
|
||||||
|
|
||||||
# Attribution
|
|
||||||
root.appendChild(self._create_element(doc, "details", "Source: TMDB"))
|
|
||||||
|
|
||||||
return doc.toprettyxml(indent=" ")
|
|
||||||
|
|
||||||
def generate_series_nfo(self, title: str, year: int,
|
|
||||||
overview: str = "", rating: float = 0.0,
|
|
||||||
genres: List[str] = None, studio: str = "",
|
|
||||||
premiered: str = "") -> str:
|
|
||||||
"""Generiere series.nfo für Serien."""
|
|
||||||
doc = self.dom_impl.createDocument(None, "tvshow", None)
|
|
||||||
root = doc.documentElement
|
|
||||||
|
|
||||||
root.appendChild(self._create_element(doc, "title", title))
|
|
||||||
root.appendChild(self._create_element(doc, "year", year))
|
|
||||||
root.appendChild(self._create_element(doc, "plot", overview))
|
|
||||||
root.appendChild(self._create_element(doc, "rating", rating))
|
|
||||||
|
|
||||||
if genres:
|
|
||||||
for genre in genres:
|
|
||||||
root.appendChild(self._create_element(doc, "genre", genre))
|
|
||||||
|
|
||||||
if studio:
|
|
||||||
root.appendChild(self._create_element(doc, "studio", studio))
|
|
||||||
|
|
||||||
if premiered:
|
|
||||||
root.appendChild(self._create_element(doc, "premiered", premiered))
|
|
||||||
|
|
||||||
# Attribution
|
|
||||||
root.appendChild(self._create_element(doc, "details", "Source: TMDB"))
|
|
||||||
|
|
||||||
return doc.toprettyxml(indent=" ")
|
|
||||||
|
|
||||||
def generate_episode_nfo(self, title: str, season: int, episode: int,
|
|
||||||
overview: str = "", rating: float = 0.0,
|
|
||||||
director: str = "", premiered: str = "",
|
|
||||||
writers: List[str] = None) -> str:
|
|
||||||
"""Generiere episode.nfo für Episoden."""
|
|
||||||
doc = self.dom_impl.createDocument(None, "episodedetails", None)
|
|
||||||
root = doc.documentElement
|
|
||||||
|
|
||||||
root.appendChild(self._create_element(doc, "title", title))
|
|
||||||
root.appendChild(self._create_element(doc, "season", season))
|
|
||||||
root.appendChild(self._create_element(doc, "episode", episode))
|
|
||||||
root.appendChild(self._create_element(doc, "plot", overview))
|
|
||||||
root.appendChild(self._create_element(doc, "rating", rating))
|
|
||||||
|
|
||||||
if director:
|
|
||||||
root.appendChild(self._create_element(doc, "director", director))
|
|
||||||
|
|
||||||
if premiered:
|
|
||||||
root.appendChild(self._create_element(doc, "premiered", premiered))
|
|
||||||
|
|
||||||
if writers:
|
|
||||||
for writer in writers:
|
|
||||||
root.appendChild(self._create_element(doc, "credits", writer))
|
|
||||||
|
|
||||||
return doc.toprettyxml(indent=" ")
|
|
||||||
|
|
||||||
def generate_album_nfo(self, title: str, artist: str, year: int,
|
|
||||||
genres: List[str] = None, rating: float = 0.0,
|
|
||||||
review: str = "") -> str:
|
|
||||||
"""Generiere album.nfo für Musikalben."""
|
|
||||||
doc = self.dom_impl.createDocument(None, "musicalbum", None)
|
|
||||||
root = doc.documentElement
|
|
||||||
|
|
||||||
root.appendChild(self._create_element(doc, "title", title))
|
|
||||||
root.appendChild(self._create_element(doc, "artist", artist))
|
|
||||||
root.appendChild(self._create_element(doc, "year", year))
|
|
||||||
root.appendChild(self._create_element(doc, "rating", rating))
|
|
||||||
root.appendChild(self._create_element(doc, "review", review))
|
|
||||||
|
|
||||||
if genres:
|
|
||||||
for genre in genres:
|
|
||||||
root.appendChild(self._create_element(doc, "genre", genre))
|
|
||||||
|
|
||||||
# Attribution
|
|
||||||
root.appendChild(self._create_element(doc, "details", "Source: MusicBrainz"))
|
|
||||||
|
|
||||||
return doc.toprettyxml(indent=" ")
|
|
||||||
|
|
||||||
def save_nfo(self, content: str, output_path: Path) -> bool:
|
|
||||||
"""Speichere NFO-Datei."""
|
|
||||||
try:
|
|
||||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with open(output_path, 'w', encoding='utf-8') as f:
|
|
||||||
f.write(content)
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
print(f"NFO save error: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
# Beispieldaten
|
|
||||||
if __name__ == "__main__":
|
|
||||||
nfo_gen = NFOGenerator()
|
|
||||||
|
|
||||||
# movie.nfo
|
|
||||||
movie_nfo = nfo_gen.generate_movie_nfo(
|
|
||||||
title="Inception",
|
|
||||||
year=2010,
|
|
||||||
overview="A thief who steals corporate secrets through the use of dream-sharing technology is given the inverse task of planting an idea into the mind of a C.E.O.",
|
|
||||||
rating=8.8,
|
|
||||||
runtime=148,
|
|
||||||
genres=["Action", "Sci-Fi", "Thriller"],
|
|
||||||
director="Christopher Nolan",
|
|
||||||
actors=["Leonardo DiCaprio", "Joseph Gordon-Levitt", "Ellen Page"]
|
|
||||||
)
|
|
||||||
|
|
||||||
print(movie_nfo)
|
|
||||||
@@ -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
|
||||||
@@ -19,7 +19,6 @@ from clients.thetvdb import TheTVDBClient
|
|||||||
from cache import get, set as cache_set
|
from cache import get, set as cache_set
|
||||||
from cache.keys import generate_prescan_key
|
from cache.keys import generate_prescan_key
|
||||||
|
|
||||||
|
|
||||||
def normalize_disc_label(label: str) -> str:
|
def normalize_disc_label(label: str) -> str:
|
||||||
"""Disc-Labels wie 'PULP_FICTION_DE' → 'Pulp Fiction De' (pure Funktion).
|
"""Disc-Labels wie 'PULP_FICTION_DE' → 'Pulp Fiction De' (pure Funktion).
|
||||||
|
|
||||||
@@ -442,15 +441,43 @@ class PreScan:
|
|||||||
break
|
break
|
||||||
|
|
||||||
# Fallback 1: Jikan/MyAnimeList (kostenlos, KEIN Key) — für Anime die
|
# Fallback 1: Jikan/MyAnimeList (kostenlos, KEIN Key) — für Anime die
|
||||||
# präziseste Quelle; wählt per Titel-Ähnlichkeit, nicht Treffer #1
|
# präziseste Quelle; wählt per Titel-Ähnlichkeit, nicht Treffer #1.
|
||||||
|
#
|
||||||
|
# NEU 26.07.2026 — „exakt schlägt unscharf", unabhängig von der
|
||||||
|
# Reihenfolge. Vorher galt der erste Jikan-Treffer über dem Gate 0,55
|
||||||
|
# sofort als sicherer Fund (confidence 0,85) und OMDb kam nie dran.
|
||||||
|
# Das ist zu großzügig, und zwar messbar (offline gerechnet mit
|
||||||
|
# titel_aehnlichkeit, echte MyAnimeList-Titel):
|
||||||
|
#
|
||||||
|
# „Alien" vs. „Alien 9" → 0,83 TRIFFT
|
||||||
|
# „Inception" vs. „Deception" → 0,78 TRIFFT
|
||||||
|
# „Hero" vs. „Heroman" → 0,73 TRIFFT
|
||||||
|
# „The Dark Knight" vs. „Dark Knight Rises" → 0,69 TRIFFT
|
||||||
|
#
|
||||||
|
# Ein Kinofilm, den TMDB verpasst, bekam damit Anime-Metadaten — und
|
||||||
|
# OMDb, das ihn kennt, wurde nie gefragt. Bemerkenswert dabei, und das
|
||||||
|
# widerlegt die Vermutung im SAVEPOINT v3.16, das Gate sei einfach zu
|
||||||
|
# niedrig: Für den Fall, für den Jikan überhaupt eingebaut wurde, ist
|
||||||
|
# es sogar zu HOCH — „Evangelion 2.22" vs. „Evangelion: 2.0 You Can
|
||||||
|
# (Not) Advance" ergibt 0,51 und fällt durch. Eine einzelne Zahl kann
|
||||||
|
# beides nicht leisten.
|
||||||
|
#
|
||||||
|
# Deshalb: Nur ein praktisch exakter Titel gilt sofort. Ein unscharfer
|
||||||
|
# Treffer wird GEMERKT, dann OMDb gefragt — und erst wenn OMDb nichts
|
||||||
|
# hat, kommt er als VORSCHLAG (niedrige Confidence) zum Zug.
|
||||||
|
jikan_unscharf = None
|
||||||
if not matched:
|
if not matched:
|
||||||
for kandidat in kandidaten:
|
for kandidat in kandidaten:
|
||||||
jikan_treffer = self.jikan.lookup(kandidat)
|
jikan_treffer = self.jikan.lookup(kandidat)
|
||||||
if jikan_treffer:
|
if not jikan_treffer:
|
||||||
|
continue
|
||||||
|
if jikan_treffer.get("match_score", 0) >= JikanClient.SICHER_AEHNLICHKEIT:
|
||||||
confidence = 0.85
|
confidence = 0.85
|
||||||
metadata = jikan_treffer
|
metadata = jikan_treffer
|
||||||
matched = True
|
matched = True
|
||||||
break
|
else:
|
||||||
|
jikan_unscharf = jikan_treffer
|
||||||
|
break
|
||||||
|
|
||||||
# Fallback 2: OMDb (eigene Datenbasis — findet oft, was TMDB nicht
|
# Fallback 2: OMDb (eigene Datenbasis — findet oft, was TMDB nicht
|
||||||
# exakt trifft; braucht OMDB_API_KEY, sonst überspringt es sich selbst)
|
# exakt trifft; braucht OMDB_API_KEY, sonst überspringt es sich selbst)
|
||||||
@@ -464,6 +491,15 @@ class PreScan:
|
|||||||
matched = True
|
matched = True
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# Fallback 2b: der unscharfe Jikan-Treffer — besser als nichts, aber
|
||||||
|
# ehrlich als Vorschlag ausgewiesen (0,6 = dieselbe Stufe wie ein
|
||||||
|
# TMDB-Vorschlag ohne exakten Treffer; das UI zeigt die Prozentzahl an
|
||||||
|
# und der Nutzer kann korrigieren).
|
||||||
|
if not matched and jikan_unscharf:
|
||||||
|
confidence = 0.6
|
||||||
|
metadata = jikan_unscharf
|
||||||
|
matched = True
|
||||||
|
|
||||||
# Fallback 2: bester TMDB-Vorschlag ohne exakten Treffer — als
|
# Fallback 2: bester TMDB-Vorschlag ohne exakten Treffer — als
|
||||||
# VORSCHLAG gekennzeichnet (niedrige Confidence, Nutzer korrigiert)
|
# VORSCHLAG gekennzeichnet (niedrige Confidence, Nutzer korrigiert)
|
||||||
if not matched and movies:
|
if not matched and movies:
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
"""Welches HandBrake-Preset passt zu welcher Disc — und was ist das BESTE.
|
||||||
|
|
||||||
|
Commander-Anforderung (26.07.2026): *„Bei den Presets soll IMMER das Beste
|
||||||
|
ausgewählt werden"* und *„wenn der Worker AV1 oder noch besseres kann, immer
|
||||||
|
diesem empfehlen"*.
|
||||||
|
|
||||||
|
## Warum das hier liegt und nicht im UI
|
||||||
|
|
||||||
|
Die Preset-Namen standen bis dahin fest verdrahtet im UI (drei Listen in
|
||||||
|
Settings.tsx, eine vierte im Wizard). Zwei Probleme: die Namen unterscheiden
|
||||||
|
sich zwischen HandBrake-Versionen, und ein erfundener Name lässt die Kompression
|
||||||
|
scheitern — das Projekt hat genau das zweimal teuer bezahlt (AGENTS Regel D).
|
||||||
|
Jetzt meldet jeder Worker seine echte Liste (`worker/caps.py`,
|
||||||
|
`HandBrakeCLI --preset-list`), und die Auswahl entsteht hier: als reine
|
||||||
|
Funktionen, die die CI-Ampel prüft. Das UI zeigt nur noch an.
|
||||||
|
|
||||||
|
## Es wird nicht bewertet, sondern gestaffelt
|
||||||
|
|
||||||
|
Kein Punktesystem. Für jede Lage gibt es eine feste Reihenfolge von Namen, und
|
||||||
|
genommen wird der erste, den der Worker WIRKLICH kennt. Jeder Name unten ist am
|
||||||
|
26.07.2026 aus `--preset-list` des Worker-Images abgenommen (HandBrake 1.6.1).
|
||||||
|
Ein Punktesystem hätte über Namen geurteilt, die es vielleicht nicht gibt.
|
||||||
|
|
||||||
|
## Zwei Quellen, zwei Fragen
|
||||||
|
|
||||||
|
- `--preset-list` sagt, welche Presets es GIBT. Die Kategorie `Hardware/` steht
|
||||||
|
dort auch auf Maschinen ohne Hardware-Encoder.
|
||||||
|
- `--help` sagt, welche Encoder LAUFEN.
|
||||||
|
|
||||||
|
Deshalb wird ein Hardware-Preset nur empfohlen, wenn ein Worker die passende
|
||||||
|
Familie wirklich meldet. (Richtigstellung zum SAVEPOINT v3.16: dort galt es als
|
||||||
|
unmöglich, die Hardware-Preset-Namen auf der VM zu ermitteln — die Messung sagt
|
||||||
|
das Gegenteil, siehe caps.parse_preset_liste.)
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Reservewert: „diesen Disc-Typ NICHT komprimieren". Gleichlautend in
|
||||||
|
# worker/ripping.PRESET_KEINE und ui/src/lib/encoder.ts — es gibt kein
|
||||||
|
# geteiltes Paket zwischen den drei Seiten.
|
||||||
|
PRESET_KEINE = "keine"
|
||||||
|
|
||||||
|
# Auflösungs-Token je Disc-Typ, so wie HandBrake sie in die Preset-Namen
|
||||||
|
# schreibt. Damit bietet die 4K-Auswahl keine 1080p-Presets als Normalfall an —
|
||||||
|
# genau dieser Griff hat in v3.12 eine 4K-UHD auf 1080p heruntergerechnet.
|
||||||
|
AUFLOESUNG_JE_TYP = {
|
||||||
|
"uhd": ("2160p",),
|
||||||
|
"bluray": ("1080p",),
|
||||||
|
"dvd": ("576p", "480p"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Hardware-Familie im ENCODER-Namen (aus `--help`) → Kürzel im PRESET-Namen.
|
||||||
|
# Gemessen: die Presets heißen „H.265 VCN 2160p 4K", der Encoder dazu aber
|
||||||
|
# `vce_h265`. Ohne diese Zuordnung bekäme eine AMD-Karte kein AMD-Preset.
|
||||||
|
# Reihenfolge = Vorzug, wenn eine Maschine mehrere Familien meldet.
|
||||||
|
HW_PRESET_KUERZEL = (
|
||||||
|
("qsv", "QSV"), # Intel QuickSync — in 1.6.1 die einzige mit AV1-Preset
|
||||||
|
("nvenc", "NVENC"), # NVIDIA
|
||||||
|
("vce", "VCN"), # AMD (Encoder heißt vce, Preset heißt VCN)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Absichtlich NICHT dabei:
|
||||||
|
# - `vaapi`: HandBrake 1.6.1 liefert kein VAAPI-Preset mit (Kategorie
|
||||||
|
# `Hardware/` kennt nur QSV/NVENC/VCN/MF). Ein VAAPI-Worker fällt daher auf
|
||||||
|
# Software zurück, statt einen Namen zu bekommen, den es nicht gibt.
|
||||||
|
# - `MF` (Media Foundation): die Presets existieren, aber ob diese Maschine sie
|
||||||
|
# nutzen kann, ist aus der Encoder-Liste nicht ablesbar. Empfohlen wird MF
|
||||||
|
# deshalb nie; in der Auswahlliste steht es trotzdem.
|
||||||
|
|
||||||
|
# Vektorbefehls-Stufen, mit denen Software-Encoding brauchbar schnell ist.
|
||||||
|
# Gleichlautend in ui/src/lib/encoder.ts (SIMD_SCHNELL).
|
||||||
|
SIMD_SCHNELL = ("avx512f", "avx2")
|
||||||
|
|
||||||
|
# Rückfall, wenn KEIN Worker eine Preset-Liste meldet (alter Worker-Stand, oder
|
||||||
|
# gar kein Worker online). Alle vier Namen sind im Worker-Image gegengeprüft.
|
||||||
|
RUECKFALL_PRESETS = (
|
||||||
|
"H.265 MKV 2160p60 4K",
|
||||||
|
"H.265 MKV 1080p30",
|
||||||
|
"H.265 MKV 576p25",
|
||||||
|
"H.265 MKV 480p30",
|
||||||
|
"HQ 2160p60 4K HEVC Surround",
|
||||||
|
"HQ 1080p30 Surround",
|
||||||
|
"HQ 576p25 Surround",
|
||||||
|
"Super HQ 2160p60 4K HEVC Surround",
|
||||||
|
"Super HQ 1080p30 Surround",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def verfuegbare_presets(workers) -> list:
|
||||||
|
"""Alle Preset-Namen, die die gemeldeten Worker kennen (Vereinigung).
|
||||||
|
|
||||||
|
Vereinigung und nicht Schnittmenge: Die Kompression kann gezielt an EINEN
|
||||||
|
Worker geroutet werden (worker_direct), es muss also nicht jeder alles
|
||||||
|
können. Ob der gewählte Worker das Preset kennt, entscheidet er selbst —
|
||||||
|
und meldet es als Klartext-Fehler.
|
||||||
|
"""
|
||||||
|
gefunden = []
|
||||||
|
for w in workers or []:
|
||||||
|
for name in ((w.get("info") or {}).get("presets") or []):
|
||||||
|
if name and name not in gefunden:
|
||||||
|
gefunden.append(name)
|
||||||
|
return gefunden or list(RUECKFALL_PRESETS)
|
||||||
|
|
||||||
|
|
||||||
|
def hardware_kuerzel(workers) -> list:
|
||||||
|
"""Preset-Kürzel der Hardware-Encoder, die WIRKLICH gemeldet sind."""
|
||||||
|
vorhanden = set()
|
||||||
|
for w in workers or []:
|
||||||
|
for encoder in (w.get("encoders") or []):
|
||||||
|
vorhanden.add(str(encoder).lower())
|
||||||
|
kuerzel = []
|
||||||
|
for familie, kurz in HW_PRESET_KUERZEL:
|
||||||
|
if any(e == familie or e.startswith(familie + "-") for e in vorhanden):
|
||||||
|
kuerzel.append(kurz)
|
||||||
|
return kuerzel
|
||||||
|
|
||||||
|
|
||||||
|
def simd_schnell(workers) -> bool:
|
||||||
|
"""Kann mindestens ein Worker Software-Encoding brauchbar schnell?
|
||||||
|
|
||||||
|
Bei unbekannter Stufe wird NICHT geraten (ein Windows-Worker ohne die
|
||||||
|
Erkennung aus v3.16 meldet `unbekannt`) — dann zählt er hier nicht mit,
|
||||||
|
bremst aber auch niemanden aus.
|
||||||
|
"""
|
||||||
|
for w in workers or []:
|
||||||
|
if ((w.get("info") or {}).get("cpu_simd") or "") in SIMD_SCHNELL:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _kandidaten(disc_type: str, kuerzel: list, schnell: bool) -> list:
|
||||||
|
"""Namens-Staffel für diese Lage — bester Kandidat zuerst.
|
||||||
|
|
||||||
|
Die Software-Zweige sind bewusst dieselben, die der Wizard seit v3.15
|
||||||
|
vorschlägt (dort schon geprüft): schwache CPU → 4K verlustfrei behalten und
|
||||||
|
H.264 für den Rest, starke CPU → H.265 durchgehend. Neu ist nur, dass
|
||||||
|
Hardware-Presets davor kommen.
|
||||||
|
"""
|
||||||
|
liste = []
|
||||||
|
# 1. Hardware zuerst — sie ist um Größenordnungen schneller. Auf der
|
||||||
|
# Rippy-VM brauchte Software-4K gemessene 28-55 Stunden pro Film.
|
||||||
|
for kurz in kuerzel:
|
||||||
|
if disc_type == "uhd":
|
||||||
|
liste += [f"AV1 {kurz} 2160p 4K", f"H.265 {kurz} 2160p 4K"]
|
||||||
|
elif disc_type == "bluray":
|
||||||
|
liste += [f"H.265 {kurz} 1080p"]
|
||||||
|
# Für DVD-Auflösungen liefert HandBrake 1.6.1 keine Hardware-Presets —
|
||||||
|
# eine DVD ist auch in Software in Minuten fertig.
|
||||||
|
|
||||||
|
# 2. Software.
|
||||||
|
if disc_type == "uhd":
|
||||||
|
liste += ["H.265 MKV 2160p60 4K"] if schnell else [PRESET_KEINE]
|
||||||
|
elif disc_type == "bluray":
|
||||||
|
liste += ["H.265 MKV 1080p30"] if schnell else ["HQ 1080p30 Surround"]
|
||||||
|
elif disc_type == "dvd":
|
||||||
|
liste += ["H.265 MKV 576p25"] if schnell else ["HQ 576p25 Surround"]
|
||||||
|
return liste
|
||||||
|
|
||||||
|
|
||||||
|
def _grund(preset: str, disc_type: str, kuerzel: list, schnell: bool) -> str:
|
||||||
|
"""Ein Satz, WARUM das die Empfehlung ist — der Commander liest das."""
|
||||||
|
if preset == PRESET_KEINE:
|
||||||
|
return (
|
||||||
|
"Keiner der gemeldeten Worker hat AVX2 oder einen Hardware-Encoder. "
|
||||||
|
"4K in Software dauert auf so einer Maschine gemessene 28-55 Stunden "
|
||||||
|
"pro Film — die verlustfreie Datei zu behalten ist hier die ehrliche "
|
||||||
|
"Wahl (kostet 20-100 GB)."
|
||||||
|
)
|
||||||
|
if any(f" {k} " in f" {preset} " for k in kuerzel):
|
||||||
|
return (
|
||||||
|
f"Nutzt den gemeldeten Hardware-Encoder ({', '.join(kuerzel)}) — "
|
||||||
|
"um Größenordnungen schneller als die CPU. Software-x265 wäre bei "
|
||||||
|
"gleicher Dateigröße etwas sauberer, dauert aber Stunden bis Tage."
|
||||||
|
)
|
||||||
|
if schnell:
|
||||||
|
return (
|
||||||
|
"H.265 in Software: kleinste Dateien bei sehr guter Qualität. Ein "
|
||||||
|
"Worker mit AVX2 ist dafür schnell genug (gemessen)."
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"H.264 statt H.265: ohne AVX2 ist H.265 sehr langsam. Die Datei wird "
|
||||||
|
"etwas größer, der Encode dafür um ein Mehrfaches schneller."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def empfehlung(disc_type: str, workers) -> dict:
|
||||||
|
"""Bestes Preset für diesen Disc-Typ — nur Namen, die es wirklich gibt.
|
||||||
|
|
||||||
|
Rückgabe: {"preset": str, "grund": str, "gefunden": bool}. `gefunden` ist
|
||||||
|
False, wenn keiner der bekannten Namen in der Liste des Workers steht (z. B.
|
||||||
|
eine HandBrake-Version mit anderer Benennung) — dann muss der Nutzer selbst
|
||||||
|
wählen, und das UI sagt es ihm, statt still etwas Falsches einzustellen.
|
||||||
|
"""
|
||||||
|
vorhanden = verfuegbare_presets(workers)
|
||||||
|
kuerzel = hardware_kuerzel(workers)
|
||||||
|
schnell = simd_schnell(workers)
|
||||||
|
for kandidat in _kandidaten(disc_type, kuerzel, schnell):
|
||||||
|
if kandidat == PRESET_KEINE or kandidat in vorhanden:
|
||||||
|
return {
|
||||||
|
"preset": kandidat,
|
||||||
|
"grund": _grund(kandidat, disc_type, kuerzel, schnell),
|
||||||
|
"gefunden": True,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"preset": "",
|
||||||
|
"grund": (
|
||||||
|
"Dieses HandBrake kennt keines der Presets, die Rippy vorschlagen "
|
||||||
|
"kann — die Namen unterscheiden sich zwischen HandBrake-Versionen. "
|
||||||
|
"Bitte unten selbst eines aus der Liste des Workers wählen."
|
||||||
|
),
|
||||||
|
"gefunden": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def auswahl(disc_type: str, workers) -> list:
|
||||||
|
"""Die Presets, die für diesen Disc-Typ zur Wahl stehen — sortiert.
|
||||||
|
|
||||||
|
Gefiltert auf die passende Auflösung: Bei 4K sollen keine 1080p-Presets als
|
||||||
|
Normalfall in der Liste stehen, sonst passiert wieder, was in v3.12 passiert
|
||||||
|
ist (4K-Rip auf 1080p heruntergerechnet, weil ein globales Preset galt).
|
||||||
|
Das bewusste Verkleinern bleibt möglich — es steht am Ende, mit Hinweis.
|
||||||
|
"""
|
||||||
|
vorhanden = verfuegbare_presets(workers)
|
||||||
|
tokens = AUFLOESUNG_JE_TYP.get(disc_type, ())
|
||||||
|
passend = [p for p in vorhanden if any(t in p for t in tokens)]
|
||||||
|
liste = [{"name": p, "verkleinert": False} for p in sorted(passend)]
|
||||||
|
if disc_type == "uhd":
|
||||||
|
# Bewusstes Verkleinern auf 1080p — eigene Gruppe, damit niemand aus
|
||||||
|
# Versehen dort landet.
|
||||||
|
kleiner = [p for p in vorhanden if "1080p" in p]
|
||||||
|
liste += [{"name": p, "verkleinert": True} for p in sorted(kleiner)]
|
||||||
|
return liste
|
||||||
|
|
||||||
|
|
||||||
|
def uebersicht(workers) -> dict:
|
||||||
|
"""Alles, was das UI für die Preset-Auswahl braucht — in einem Aufruf."""
|
||||||
|
typen = ("uhd", "bluray", "dvd")
|
||||||
|
return {
|
||||||
|
"quelle": "worker" if verfuegbare_presets(workers) != list(RUECKFALL_PRESETS)
|
||||||
|
else "rueckfall",
|
||||||
|
"hardware": hardware_kuerzel(workers),
|
||||||
|
"simd_schnell": simd_schnell(workers),
|
||||||
|
"typen": {
|
||||||
|
t: {"empfehlung": empfehlung(t, workers), "auswahl": auswahl(t, workers)}
|
||||||
|
for t in typen
|
||||||
|
},
|
||||||
|
}
|
||||||
+92
-7
@@ -2,34 +2,119 @@
|
|||||||
|
|
||||||
Der API-Key-Store, der hier früher lebte, ist mit dem Auth-Rückbau
|
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.
|
(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
|
import time
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
|
||||||
# Default Rate Limit
|
# Wie viele Anfragen pro Minute und Client durchgehen.
|
||||||
MAX_REQUESTS_PER_MINUTE = 100
|
#
|
||||||
|
# ⚠️ 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)
|
# In-Memory Rate Limit Store (in Produktion mit Redis)
|
||||||
rate_limit_store: Dict[str, list] = defaultdict(list)
|
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:
|
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."""
|
"""Prüfe ob Client rate-limited ist."""
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
window_start = current_time - window_seconds
|
window_start = current_time - window_seconds
|
||||||
|
|
||||||
# Bereinige alte Einträge
|
# Bereinige alte Einträge
|
||||||
rate_limit_store[client_id] = [
|
rate_limit_store[client_id] = [
|
||||||
timestamp for timestamp in rate_limit_store[client_id]
|
timestamp for timestamp in rate_limit_store[client_id]
|
||||||
if timestamp > window_start
|
if timestamp > window_start
|
||||||
]
|
]
|
||||||
|
|
||||||
# Prüfe ob Limit erreicht
|
# Prüfe ob Limit erreicht
|
||||||
if len(rate_limit_store[client_id]) >= max_requests:
|
if len(rate_limit_store[client_id]) >= max_requests:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Füge neuen Request hinzu
|
# Füge neuen Request hinzu
|
||||||
rate_limit_store[client_id].append(current_time)
|
rate_limit_store[client_id].append(current_time)
|
||||||
return True
|
return True
|
||||||
@@ -39,12 +124,12 @@ def get_rate_limit_remaining(client_id: str, max_requests: int = MAX_REQUESTS_PE
|
|||||||
"""Hole verbleibende Requests."""
|
"""Hole verbleibende Requests."""
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
window_start = current_time - 60
|
window_start = current_time - 60
|
||||||
|
|
||||||
current_count = len([
|
current_count = len([
|
||||||
timestamp for timestamp in rate_limit_store[client_id]
|
timestamp for timestamp in rate_limit_store[client_id]
|
||||||
if timestamp > window_start
|
if timestamp > window_start
|
||||||
])
|
])
|
||||||
|
|
||||||
return max(0, max_requests - current_count)
|
return max(0, max_requests - current_count)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -19,13 +19,13 @@ def test_main_importierbar_und_routen_verdrahtet():
|
|||||||
|
|
||||||
routen = {route.path for route in app.routes}
|
routen = {route.path for route in app.routes}
|
||||||
for pfad in (
|
for pfad in (
|
||||||
"/health", "/jobs", "/devices", "/logs", "/settings", "/prescan",
|
"/health", "/jobs", "/devices", "/logs", "/settings",
|
||||||
# KEYDB.cfg + AACS-Dumps: der Platz für die selbst mitgebrachte
|
# KEYDB.cfg + AACS-Dumps: der Platz für die selbst mitgebrachte
|
||||||
# Schlüsseldatei (Befund 25.07.2026 — MakeMKV holt UHD-Schlüssel nicht
|
# Schlüsseldatei (Befund 25.07.2026 — MakeMKV holt UHD-Schlüssel nicht
|
||||||
# mehr online nach). Ohne diese Routen ist die Seite im UI tot.
|
# mehr online nach). Ohne diese Routen ist die Seite im UI tot.
|
||||||
"/system/keydb", "/system/aacs-dumps", "/system/aacs-dumps/{dateiname}",
|
"/system/keydb", "/system/aacs-dumps", "/system/aacs-dumps/{dateiname}",
|
||||||
# Der Hauptweg fuer 4K-UHD (25.07.2026): makemkvcon holt Disc-Schluessel
|
# Der Hauptweg für 4K-UHD (25.07.2026): makemkvcon holt Disc-Schluessel
|
||||||
# unter Linux nie selbst, sie kommen von Hand ueber diesen Endpunkt.
|
# unter Linux nie selbst, sie kommen von Hand über diesen Endpunkt.
|
||||||
"/system/keystore",
|
"/system/keystore",
|
||||||
):
|
):
|
||||||
assert pfad in routen, f"Route {pfad} fehlt"
|
assert pfad in routen, f"Route {pfad} fehlt"
|
||||||
@@ -66,3 +66,183 @@ def test_remount_blockiert_den_api_start_nicht():
|
|||||||
"remount muss als Hintergrund-Task laufen (nicht blockierend)"
|
"remount muss als Hintergrund-Task laufen (nicht blockierend)"
|
||||||
assert "await asyncio.to_thread(remount)" not in quelle, \
|
assert "await asyncio.to_thread(remount)" not in quelle, \
|
||||||
"remount darf nicht mehr direkt awaited werden (blockiert sonst den Start)"
|
"remount darf nicht mehr direkt awaited werden (blockiert sonst den Start)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein():
|
||||||
|
"""Befund 25.07.2026: In main.py prüften neun Stellen mit nacktem
|
||||||
|
startswith(MEDIA_ROOT) — darunter /browse und /browse/mkdir, wo der Pfad
|
||||||
|
vom Nutzer kommt. „/app/media-boese/x" beginnt mit „/app/media", liegt
|
||||||
|
aber außerhalb. Zwilling von tasks.unter_wurzel im Worker."""
|
||||||
|
from main import unter_wurzel
|
||||||
|
|
||||||
|
assert unter_wurzel("/app/media", "/app/media") is True
|
||||||
|
assert unter_wurzel("/app/media/movies", "/app/media") is True
|
||||||
|
assert unter_wurzel("/app/media-boese/x", "/app/media") is False
|
||||||
|
assert unter_wurzel("/app/mediaX", "/app/media") is False
|
||||||
|
assert unter_wurzel("/etc/passwd", "/app/media") is False
|
||||||
|
assert unter_wurzel("", "/app/media") is False
|
||||||
|
assert unter_wurzel("/app/media", "") is False
|
||||||
|
assert unter_wurzel("/app/media/movies", "/app/media/") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_ping_vorrat_verhindert_die_wartesekunde(monkeypatch):
|
||||||
|
"""Gemessen 25.07.2026: /capabilities brauchte 1,010 s - jedes Mal. Der
|
||||||
|
Celery-Ping sammelt Antworten bis zum Timeout und kann nicht früher
|
||||||
|
aufhoeren. Fuenf UI-Stellen holen /capabilities, also zahlte jede Seite
|
||||||
|
eine Sekunde, während alle anderen Endpunkte unter 25 ms lagen.
|
||||||
|
|
||||||
|
Der Vorrat muss deshalb abgelesen und NICHT neu gepingt werden, solange er
|
||||||
|
frisch ist - und bei altem Vorrat lieber einmal langsam als falsch.
|
||||||
|
"""
|
||||||
|
import time as _t
|
||||||
|
|
||||||
|
import main
|
||||||
|
|
||||||
|
pings = []
|
||||||
|
monkeypatch.setattr(main, "_ping_jetzt", lambda: pings.append(1) or ["celery@neu"])
|
||||||
|
|
||||||
|
# Frischer Vorrat -> ablesen, kein Ping
|
||||||
|
monkeypatch.setitem(main._PING, "knoten", ["celery@alt"])
|
||||||
|
monkeypatch.setitem(main._PING, "stand", _t.monotonic())
|
||||||
|
assert main._ping_knoten() == ["celery@alt"]
|
||||||
|
assert pings == [], "bei frischem Vorrat darf NICHT gepingt werden"
|
||||||
|
|
||||||
|
# Zu alter Vorrat -> einmal synchron pingen
|
||||||
|
monkeypatch.setitem(main._PING, "stand", _t.monotonic() - main.PING_ALTER_MAX_SEKUNDEN - 1)
|
||||||
|
assert main._ping_knoten() == ["celery@neu"]
|
||||||
|
assert len(pings) == 1
|
||||||
|
|
||||||
|
# Kalter Start (nie gepingt) -> ebenfalls pingen, nicht "alles offline" melden
|
||||||
|
monkeypatch.setitem(main._PING, "stand", -1e9)
|
||||||
|
main._ping_knoten()
|
||||||
|
assert len(pings) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_ping_takt_ist_kuerzer_als_die_haltbarkeit():
|
||||||
|
"""Sonst läuft der Vorrat zwischen zwei Hintergrund-Laeufen ab und der
|
||||||
|
Endpunkt pingt doch wieder synchron."""
|
||||||
|
import main
|
||||||
|
|
||||||
|
assert main.PING_INTERVALL_SEKUNDEN < main.PING_ALTER_MAX_SEKUNDEN
|
||||||
|
|
||||||
|
|
||||||
|
def test_tote_routen_sind_und_bleiben_weg():
|
||||||
|
"""Entfernt am 25.07.2026, jede ein Überrest eines ersetzten Entwurfs und
|
||||||
|
ohne einen einzigen Aufrufer (mechanisch gegengeprueft: alle api.*-Aufrufe
|
||||||
|
des UI gegen alle Routen).
|
||||||
|
|
||||||
|
Der Test hält sie draussen. /stream/jobs ist der Grund für diese
|
||||||
|
Absicherung: er war schon einmal ein Placebo, wurde dann "repariert" statt
|
||||||
|
entfernt - und war danach eine Endlosschleife je Verbindung ohne jeden
|
||||||
|
Verbraucher. Wer echtes Push will, braucht BEIDE Seiten (Server UND ein
|
||||||
|
EventSource im UI).
|
||||||
|
"""
|
||||||
|
from main import app
|
||||||
|
|
||||||
|
routen = {route.path for route in app.routes}
|
||||||
|
for pfad in ("/prescan", "/jellyfin/format", "/stream/jobs",
|
||||||
|
"/worker-setup/windows-gui"):
|
||||||
|
assert pfad not in routen, (
|
||||||
|
f"{pfad} ist wieder da — entweder mit Verbraucher (dann diesen Test "
|
||||||
|
"anpassen) oder versehentlich (dann wieder raus)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_setup_routen_die_gebraucht_werden_sind_da():
|
||||||
|
"""Die Installer holen sich Code und .exe hierueber — /worker-setup/paket
|
||||||
|
ruft install.ps1 UND install-gui.ps1 auf, /windows-exe der UI-Knopf."""
|
||||||
|
from main import app
|
||||||
|
|
||||||
|
routen = {route.path for route in app.routes}
|
||||||
|
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)
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"""Tests der Restzeit-Schätzung — reine Funktionen, keine Infrastruktur.
|
||||||
|
|
||||||
|
Die Zahlen in den Szenarien sind die echten Messwerte vom 25.07.2026: Der
|
||||||
|
4K-Encode von Akira kam in 29 Minuten von 0 auf 1,44 % (an der Leseposition im
|
||||||
|
Quellstrom gemessen, `/proc/<pid>/fdinfo/`) — hochgerechnet 28-55 Stunden. Genau
|
||||||
|
dieser Fall muss eine ehrliche Antwort geben, statt „gleich fertig" zu behaupten.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import eta
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_genug_punkte_keine_aussage():
|
||||||
|
assert eta.restzeit_sekunden(None, 100.0) == -1
|
||||||
|
assert eta.restzeit_sekunden({"status": "transcoding", "punkte": []}, 100.0) == -1
|
||||||
|
einer = {"status": "transcoding", "punkte": [[0.0, 5]]}
|
||||||
|
assert eta.restzeit_sekunden(einer, 100.0) == -1
|
||||||
|
|
||||||
|
|
||||||
|
def test_zu_kurze_spanne_keine_aussage():
|
||||||
|
"""Zwei Punkte 10 s auseinander sagen nichts über einen Encode, der Stunden
|
||||||
|
läuft — MINDEST_SPANNE_SEKUNDEN verhindert die Hochrechnung."""
|
||||||
|
reihe = {"status": "transcoding", "punkte": [[0.0, 1], [10.0, 2]]}
|
||||||
|
assert eta.restzeit_sekunden(reihe, 10.0) == -1
|
||||||
|
|
||||||
|
|
||||||
|
def test_kein_fortschritt_keine_aussage():
|
||||||
|
"""Eine Stunde ohne einen einzigen Prozentpunkt: die Rate ist unbekannt,
|
||||||
|
nicht null. Ohne diese Sperre käme eine Division durch 0."""
|
||||||
|
reihe = {"status": "transcoding", "punkte": [[0.0, 3]]}
|
||||||
|
reihe = eta.beobachtung_hinzufuegen(reihe, "transcoding", 3, 3600.0)
|
||||||
|
assert eta.restzeit_sekunden(reihe, 3600.0) == -1
|
||||||
|
|
||||||
|
|
||||||
|
def test_einfache_hochrechnung():
|
||||||
|
"""10 % in 10 Minuten → 90 % brauchen 90 Minuten."""
|
||||||
|
reihe = {"status": "ripping", "punkte": [[0.0, 0], [600.0, 10]]}
|
||||||
|
assert eta.restzeit_sekunden(reihe, 600.0) == 90 * 60
|
||||||
|
|
||||||
|
|
||||||
|
def test_der_echte_4k_fall_landet_in_der_groessenordnung_tage():
|
||||||
|
"""Gemessen: 1,44 % in 29 Minuten. Die Schätzung muss in der Größenordnung
|
||||||
|
TAGE landen — das ist die Angabe, deren Fehlen den 50-Stunden-Lauf am
|
||||||
|
25.07.2026 unsichtbar machte. Die genaue Stundenzahl ist Nebensache; wer
|
||||||
|
„1 Tag 23 h" liest, bricht ab, wer nichts liest, wartet."""
|
||||||
|
reihe = {"status": "transcoding", "punkte": [[0.0, 0], [29 * 60.0, 1]]}
|
||||||
|
sekunden = eta.restzeit_sekunden(reihe, 29 * 60.0)
|
||||||
|
stunden = sekunden / 3600
|
||||||
|
assert 45 < stunden < 50 # 99 % × 29 min ≈ 47,85 h
|
||||||
|
assert eta.formatiere_restzeit(sekunden) == "noch ca. 1 Tag 23 h"
|
||||||
|
# Etwas langsamer, und es heißt nur noch „mehr als 2 Tage" — bei der
|
||||||
|
# Größenordnung wäre jede Stundenangabe erfundene Genauigkeit.
|
||||||
|
langsamer = {"status": "transcoding", "punkte": [[0.0, 0], [45 * 60.0, 1]]}
|
||||||
|
assert eta.formatiere_restzeit(
|
||||||
|
eta.restzeit_sekunden(langsamer, 45 * 60.0)) == "mehr als 2 Tage"
|
||||||
|
|
||||||
|
|
||||||
|
def test_stillstand_verlaengert_die_schaetzung():
|
||||||
|
"""Kernpunkt: Bleibt der Fortschritt stehen, MUSS die Restzeit steigen —
|
||||||
|
sonst zeigt ein hängender Job stundenlang „noch 5 Minuten"."""
|
||||||
|
reihe = {"status": "transcoding", "punkte": [[0.0, 0], [600.0, 50]]}
|
||||||
|
frisch = eta.restzeit_sekunden(reihe, 600.0)
|
||||||
|
# ... eine Stunde später steht der Fortschritt immer noch bei 50 %
|
||||||
|
reihe = eta.beobachtung_hinzufuegen(reihe, "transcoding", 50, 4200.0)
|
||||||
|
spaeter = eta.restzeit_sekunden(reihe, 4200.0)
|
||||||
|
assert spaeter > frisch * 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_statuswechsel_verwirft_die_reihe():
|
||||||
|
"""Rip (eine Stunde) und Kompression (Tage) haben nichts miteinander zu tun.
|
||||||
|
Ohne diesen Schnitt entstünde beim Phasenwechsel eine Phantasiezahl."""
|
||||||
|
reihe = {"status": "ripping", "punkte": [[0.0, 0], [600.0, 50]]}
|
||||||
|
neu = eta.beobachtung_hinzufuegen(reihe, "transcoding", 2, 610.0)
|
||||||
|
assert neu["status"] == "transcoding"
|
||||||
|
assert neu["punkte"] == [[610.0, 2]]
|
||||||
|
assert eta.restzeit_sekunden(neu, 610.0) == -1
|
||||||
|
|
||||||
|
|
||||||
|
def test_nur_das_fenster_zaehlt():
|
||||||
|
"""Alte Punkte fliegen raus — HandBrake wird bei komplexen Szenen langsamer,
|
||||||
|
und dann lügt der Anfang der Messreihe."""
|
||||||
|
reihe = None
|
||||||
|
for i in range(20):
|
||||||
|
reihe = eta.beobachtung_hinzufuegen(reihe, "transcoding", i, float(i * 60))
|
||||||
|
assert len(reihe["punkte"]) == eta.FENSTER
|
||||||
|
assert reihe["punkte"][0][1] == 10 # die ersten zehn sind weg
|
||||||
|
|
||||||
|
|
||||||
|
def test_gleicher_fortschritt_haengt_keinen_punkt_an():
|
||||||
|
reihe = eta.beobachtung_hinzufuegen(None, "transcoding", 7, 0.0)
|
||||||
|
reihe = eta.beobachtung_hinzufuegen(reihe, "transcoding", 7, 30.0)
|
||||||
|
reihe = eta.beobachtung_hinzufuegen(reihe, "transcoding", 7, 60.0)
|
||||||
|
assert reihe["punkte"] == [[0.0, 7]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fertig_ist_null():
|
||||||
|
reihe = {"status": "transcoding", "punkte": [[0.0, 50], [600.0, 100]]}
|
||||||
|
assert eta.restzeit_sekunden(reihe, 600.0) == 0
|
||||||
|
assert eta.formatiere_restzeit(0) == "unter einer Minute"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Textform ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_textform_deckt_alle_groessenordnungen():
|
||||||
|
assert eta.formatiere_restzeit(-1) == ""
|
||||||
|
assert eta.formatiere_restzeit(None) == ""
|
||||||
|
assert eta.formatiere_restzeit(30) == "unter einer Minute"
|
||||||
|
assert eta.formatiere_restzeit(90) == "noch ca. 1 min"
|
||||||
|
assert eta.formatiere_restzeit(45 * 60) == "noch ca. 45 min"
|
||||||
|
assert eta.formatiere_restzeit(3 * 3600 + 7 * 60) == "noch ca. 3 h 07 min"
|
||||||
|
assert eta.formatiere_restzeit(30 * 3600) == "noch ca. 1 Tag 6 h"
|
||||||
|
assert eta.formatiere_restzeit(60 * 3600) == "mehr als 2 Tage"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Der Weg über den Cache -------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_aktualisiere_und_schaetze_ueber_zwei_aufrufe():
|
||||||
|
"""So läuft es live: /jobs wird alle vier Sekunden abgefragt, jeder Aufruf
|
||||||
|
schreibt die Reihe fort."""
|
||||||
|
speicher = {}
|
||||||
|
|
||||||
|
def hole(k):
|
||||||
|
return speicher.get(k)
|
||||||
|
|
||||||
|
def lege(k, wert, expire=None):
|
||||||
|
speicher[k] = wert
|
||||||
|
|
||||||
|
erst = eta.aktualisiere_und_schaetze(
|
||||||
|
"job1", "transcoding", 10, 0.0, hole, lege)
|
||||||
|
assert erst == {"sekunden": -1, "text": ""} # ein Punkt sagt nichts
|
||||||
|
|
||||||
|
dann = eta.aktualisiere_und_schaetze(
|
||||||
|
"job1", "transcoding", 20, 600.0, hole, lege)
|
||||||
|
assert dann["sekunden"] == 80 * 60
|
||||||
|
assert dann["text"] == "noch ca. 1 h 20 min"
|
||||||
|
|
||||||
|
|
||||||
|
def test_kaputter_cache_bringt_nichts_zum_absturz():
|
||||||
|
"""Redis weg → keine ETA, aber die Job-Liste muss weiter funktionieren."""
|
||||||
|
def kaputt_holen(k):
|
||||||
|
raise RuntimeError("Redis weg")
|
||||||
|
|
||||||
|
def kaputt_legen(k, wert, expire=None):
|
||||||
|
raise RuntimeError("Redis weg")
|
||||||
|
|
||||||
|
ergebnis = eta.aktualisiere_und_schaetze(
|
||||||
|
"job1", "transcoding", 10, 0.0, kaputt_holen, kaputt_legen)
|
||||||
|
assert ergebnis == {"sekunden": -1, "text": ""}
|
||||||
|
|
||||||
|
|
||||||
|
def test_fertige_und_wartende_jobs_bekommen_keine_eta():
|
||||||
|
speicher = {}
|
||||||
|
for status, progress in (("completed", 100), ("failed", 42), ("pending", 0)):
|
||||||
|
ergebnis = eta.aktualisiere_und_schaetze(
|
||||||
|
"x", status, progress, 0.0,
|
||||||
|
speicher.get, lambda k, v, expire=None: speicher.__setitem__(k, v))
|
||||||
|
assert ergebnis == {"sekunden": -1, "text": ""}
|
||||||
|
assert speicher == {} # nichts geschrieben
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Was das Ähnlichkeits-Gate von Jikan wirklich durchlässt — gemessen.
|
||||||
|
|
||||||
|
Hintergrund (26.07.2026): Der SAVEPOINT v3.16 vermutete, das Gate 0,55 sei
|
||||||
|
„großzügig" und Jikan stehe in der Metadaten-Kette zu früh (vor OMDb). Beides
|
||||||
|
war nachrechenbar, weil `titel_aehnlichkeit` eine reine Funktion ist — und die
|
||||||
|
Rechnung ergab ein anderes Bild als die Vermutung:
|
||||||
|
|
||||||
|
- Für kurze Kinofilm-Titel ist das Gate WIRKLICH zu locker (Alien → 0,83).
|
||||||
|
- Für den Fall, für den Jikan eingebaut wurde, ist es sogar zu STRENG
|
||||||
|
(„Evangelion 2.22" gegen den MAL-Titel → 0,51, fällt durch).
|
||||||
|
|
||||||
|
Eine einzelne Zahl kann also beides nicht leisten. Die Folge steht in
|
||||||
|
prescan._scan_video: „exakt schlägt unscharf" statt „erster gewinnt".
|
||||||
|
|
||||||
|
Die Anime-Titel unten existieren auf MyAnimeList; die Zahlen sind ausgerechnet,
|
||||||
|
nicht geschätzt. MyAnimeList selbst war bei dieser Messung nicht erreichbar
|
||||||
|
(Jikan antwortete durchweg HTTP 504) — die Live-Abfrage steht damit weiter aus,
|
||||||
|
die Arithmetik des Gates ist davon aber unberührt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from clients.jikan import JikanClient, titel_aehnlichkeit
|
||||||
|
|
||||||
|
|
||||||
|
def test_kurze_kinofilm_titel_reissen_das_gate_0_55():
|
||||||
|
"""Diese vier würden ohne die Änderung Anime-Metadaten bekommen, obwohl
|
||||||
|
OMDb die Filme kennt — es wurde nur nie gefragt."""
|
||||||
|
faelle = [
|
||||||
|
("Alien", "Alien 9", 0.83),
|
||||||
|
("Inception", "Deception", 0.78),
|
||||||
|
("Hero", "Heroman", 0.73),
|
||||||
|
("The Dark Knight", "Dark Knight Rises", 0.69),
|
||||||
|
]
|
||||||
|
for disc, anime, erwartet in faelle:
|
||||||
|
score = titel_aehnlichkeit(disc, anime)
|
||||||
|
assert round(score, 2) == erwartet, f"{disc} vs {anime}: {score}"
|
||||||
|
# ... liegt über dem Gate (Jikan gibt also einen Treffer zurück) ...
|
||||||
|
assert score >= JikanClient.MINDEST_AEHNLICHKEIT
|
||||||
|
# ... aber unter „sicher", darf die Kette also nicht beenden.
|
||||||
|
assert score < JikanClient.SICHER_AEHNLICHKEIT
|
||||||
|
|
||||||
|
|
||||||
|
def test_echter_treffer_gilt_weiter_als_sicher():
|
||||||
|
"""Exakter Titel → 1,0. Der Normalfall eines echten Anime-Fundes darf durch
|
||||||
|
die Verschärfung NICHT verlorengehen (AGENTS Regel B)."""
|
||||||
|
assert titel_aehnlichkeit("Monster", "Monster") == 1.0
|
||||||
|
assert titel_aehnlichkeit("Cowboy Bebop", "COWBOY BEBOP") == 1.0
|
||||||
|
# Interpunktion und Kleinschreibung sind egal — norm() wirft sie weg
|
||||||
|
assert titel_aehnlichkeit("Akira", "AKIRA!") == 1.0
|
||||||
|
for a, b in (("Monster", "Monster"), ("Akira", "AKIRA!")):
|
||||||
|
assert titel_aehnlichkeit(a, b) >= JikanClient.SICHER_AEHNLICHKEIT
|
||||||
|
|
||||||
|
|
||||||
|
def test_untertitel_zaehlt_noch_als_sicher_genug():
|
||||||
|
"""Discs tragen oft den Kurztitel, MAL den vollen. Das muss ein sicherer
|
||||||
|
Treffer bleiben, sonst hilft die Quelle bei Anime nicht mehr."""
|
||||||
|
assert titel_aehnlichkeit("Steins Gate", "Steins;Gate") >= JikanClient.SICHER_AEHNLICHKEIT
|
||||||
|
|
||||||
|
|
||||||
|
def test_der_fall_fuer_den_jikan_gebaut_wurde_faellt_heute_durch():
|
||||||
|
"""Gegenbeweis zur Vermutung „das Gate ist zu großzügig": Für Evangelion
|
||||||
|
2.22 ist es zu STRENG. Der Wert ist dokumentiert, damit ein späteres
|
||||||
|
Anheben des Gates nicht unbemerkt auch diesen Fall killt."""
|
||||||
|
score = titel_aehnlichkeit(
|
||||||
|
"Evangelion 2.22", "Evangelion: 2.0 You Can (Not) Advance"
|
||||||
|
)
|
||||||
|
assert round(score, 2) == 0.51
|
||||||
|
assert score < JikanClient.MINDEST_AEHNLICHKEIT
|
||||||
|
|
||||||
|
|
||||||
|
def test_voellig_anderer_titel_wird_verworfen():
|
||||||
|
score = titel_aehnlichkeit("Blade", "Blade of the Immortal")
|
||||||
|
assert score < JikanClient.MINDEST_AEHNLICHKEIT
|
||||||
|
|
||||||
|
|
||||||
|
def test_leere_titel_geben_null():
|
||||||
|
assert titel_aehnlichkeit("", "Monster") == 0.0
|
||||||
|
assert titel_aehnlichkeit("Monster", "") == 0.0
|
||||||
|
assert titel_aehnlichkeit("!!!", "???") == 0.0
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Tests fuer die puren Helfer aus makemkv_daten.
|
"""Tests für die puren Helfer aus makemkv_daten.
|
||||||
|
|
||||||
Bewusst OHNE Dateisystem, DB und fcntl — deshalb laufen sie auch auf Windows
|
Bewusst OHNE Dateisystem, DB und fcntl — deshalb laufen sie auch auf Windows
|
||||||
und nicht nur in der Ampel. Geprueft wird genau das, was ohne Container und
|
und nicht nur in der Ampel. Geprueft wird genau das, was ohne Container und
|
||||||
@@ -24,8 +24,8 @@ _GUELTIG = """; KEYDB.cfg — Beispiel
|
|||||||
|
|
||||||
|
|
||||||
def test_zaehle_disc_eintraege_zaehlt_nur_echte_disc_zeilen():
|
def test_zaehle_disc_eintraege_zaehlt_nur_echte_disc_zeilen():
|
||||||
# Kommentar- und Leerzeilen duerfen NICHT mitgezaehlt werden, sonst meldet
|
# Kommentar- und Leerzeilen dürfen NICHT mitgezaehlt werden, sonst meldet
|
||||||
# das UI "da liegt was drin", obwohl die Datei keinen Schluessel enthaelt.
|
# das UI "da liegt was drin", obwohl die Datei keinen Schluessel enthält.
|
||||||
assert zaehle_disc_eintraege(_GUELTIG) == 2
|
assert zaehle_disc_eintraege(_GUELTIG) == 2
|
||||||
|
|
||||||
|
|
||||||
@@ -51,8 +51,8 @@ def test_keydb_pruefen_meldet_leere_datei():
|
|||||||
|
|
||||||
|
|
||||||
def test_keydb_pruefen_erkennt_html():
|
def test_keydb_pruefen_erkennt_html():
|
||||||
# Haeufigster Bedienfehler: statt der Datei landet die HTML-Fehlerseite
|
# Häufigster Bedienfehler: statt der Datei landet die HTML-Fehlerseite
|
||||||
# eines Downloads im Feld. MakeMKV wuerde dann still weiter meckern.
|
# eines Downloads im Feld. MakeMKV würde dann still weiter meckern.
|
||||||
fehler = keydb_pruefen("<!DOCTYPE html>\n<html><body>404 Not Found</body></html>\n")
|
fehler = keydb_pruefen("<!DOCTYPE html>\n<html><body>404 Not Found</body></html>\n")
|
||||||
assert "HTML" in fehler
|
assert "HTML" in fehler
|
||||||
|
|
||||||
@@ -63,19 +63,19 @@ def test_keydb_pruefen_meldet_datei_ohne_disc_zeile():
|
|||||||
|
|
||||||
|
|
||||||
def test_keydb_pruefen_laesst_gueltige_datei_durch():
|
def test_keydb_pruefen_laesst_gueltige_datei_durch():
|
||||||
# "" heisst laut Vertrag: alles in Ordnung, darf geschrieben werden.
|
# "" heißt laut Vertrag: alles in Ordnung, darf geschrieben werden.
|
||||||
assert keydb_pruefen(_GUELTIG) == ""
|
assert keydb_pruefen(_GUELTIG) == ""
|
||||||
|
|
||||||
|
|
||||||
def test_ist_aacs_dump_erkennt_echten_namen():
|
def test_ist_aacs_dump_erkennt_echten_namen():
|
||||||
# So heisst der Dump, den MakeMKV am 25.07.2026 fuer Akira UHD abgelegt hat
|
# So heißt der Dump, den MakeMKV am 25.07.2026 für Akira UHD abgelegt hat
|
||||||
# (Meldung 3332) — dieser Name MUSS zum Download durchkommen.
|
# (Meldung 3332) — dieser Name MUSS zum Download durchkommen.
|
||||||
assert ist_aacs_dump("MKB20_v76_UHD_AKIRA_C02B.tgz") is True
|
assert ist_aacs_dump("MKB20_v76_UHD_AKIRA_C02B.tgz") is True
|
||||||
|
|
||||||
|
|
||||||
def test_ist_aacs_dump_blockt_pfad_tricks():
|
def test_ist_aacs_dump_blockt_pfad_tricks():
|
||||||
# Der Download-Endpunkt haengt den Namen an das Datenverzeichnis — ein
|
# Der Download-Endpunkt hängt den Namen an das Datenverzeichnis — ein
|
||||||
# durchgelassenes ".." oder ein Pfadtrenner waere ein Ausbruch.
|
# durchgelassenes ".." oder ein Pfadtrenner wäre ein Ausbruch.
|
||||||
assert ist_aacs_dump("../x.tgz") is False
|
assert ist_aacs_dump("../x.tgz") is False
|
||||||
assert ist_aacs_dump("../../etc/passwd.tgz") is False
|
assert ist_aacs_dump("../../etc/passwd.tgz") is False
|
||||||
assert ist_aacs_dump("unter/ordner.tgz") is False
|
assert ist_aacs_dump("unter/ordner.tgz") is False
|
||||||
@@ -93,8 +93,8 @@ def test_ist_aacs_dump_lehnt_andere_endungen_ab():
|
|||||||
|
|
||||||
|
|
||||||
def test_settings_conf_ersetzt_alten_key_und_behaelt_den_rest():
|
def test_settings_conf_ersetzt_alten_key_und_behaelt_den_rest():
|
||||||
# Regression: bis 25.07.2026 wurde die Datei komplett ueberschrieben. Mit
|
# Regression: bis 25.07.2026 wurde die Datei komplett überschrieben. Mit
|
||||||
# dem jetzt persistenten Datenverzeichnis waere app_UpdateEnable vor jedem
|
# dem jetzt persistenten Datenverzeichnis wäre app_UpdateEnable vor jedem
|
||||||
# Rip weg gewesen.
|
# Rip weg gewesen.
|
||||||
alt = 'app_UpdateEnable = "1"\napp_Key = "T-alt"\napp_DestinationDir = "/tmp"\n'
|
alt = 'app_UpdateEnable = "1"\napp_Key = "T-alt"\napp_DestinationDir = "/tmp"\n'
|
||||||
neu = settings_conf_zusammenfuehren(alt, "T-neu")
|
neu = settings_conf_zusammenfuehren(alt, "T-neu")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Tests fuer den MakeMKV-Beta-Key-Parser (dependency-frei, nur extract_key)."""
|
"""Tests für den MakeMKV-Beta-Key-Parser (dependency-frei, nur extract_key)."""
|
||||||
from makemkv_key import extract_key
|
from makemkv_key import extract_key
|
||||||
|
|
||||||
# Beispiel-Key im echten Format (T- + 64 Zeichen [A-Za-z0-9_]).
|
# Beispiel-Key im echten Format (T- + 64 Zeichen [A-Za-z0-9_]).
|
||||||
@@ -16,12 +16,12 @@ def test_extract_key_ohne_key_gibt_none():
|
|||||||
|
|
||||||
|
|
||||||
def test_extract_key_zu_kurz_kein_treffer():
|
def test_extract_key_zu_kurz_kein_treffer():
|
||||||
# Format ist streng T- + genau 64 Zeichen — zu kurz faellt raus.
|
# Format ist streng T- + genau 64 Zeichen — zu kurz fällt raus.
|
||||||
assert extract_key("T-zukurz123") is None
|
assert extract_key("T-zukurz123") is None
|
||||||
|
|
||||||
|
|
||||||
def test_extract_key_liefert_ganzen_key():
|
def test_extract_key_liefert_ganzen_key():
|
||||||
# Regression: die Regex darf den Key NICHT abschneiden (Beta-Keys sind laenger
|
# Regression: die Regex darf den Key NICHT abschneiden (Beta-Keys sind länger
|
||||||
# als die im Forum genannten 64 Zeichen — hier der volle Key zwischen Text).
|
# als die im Forum genannten 64 Zeichen — hier der volle Key zwischen Text).
|
||||||
treffer = extract_key(f"Vorher-Text {_KEY} Nachher-Text")
|
treffer = extract_key(f"Vorher-Text {_KEY} Nachher-Text")
|
||||||
assert treffer == _KEY
|
assert treffer == _KEY
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
"""Tests für die SMB-Fehlerübersetzung (Speicherziele → Freigaben auflisten)."""
|
"""Tests für die SMB-Fehlerübersetzung (Speicherziele → Freigaben auflisten)."""
|
||||||
|
|
||||||
from mounts import uebersetze_smb_fehler, validiere_name
|
from mounts import (
|
||||||
|
pfad_map_vorschlag,
|
||||||
|
pfad_map_zeile,
|
||||||
|
uebersetze_smb_fehler,
|
||||||
|
unc_aus_quelle,
|
||||||
|
validiere_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_access_denied_ohne_credentials_erklaert_gastproblem():
|
def test_access_denied_ohne_credentials_erklaert_gastproblem():
|
||||||
@@ -69,3 +75,347 @@ def test_mounten_loest_stale_vor_dem_mount():
|
|||||||
|
|
||||||
quelle = inspect.getsource(mounts.mounten)
|
quelle = inspect.getsource(mounts.mounten)
|
||||||
assert "_stale_mounts_loesen(ziel)" in quelle
|
assert "_stale_mounts_loesen(ziel)" in quelle
|
||||||
|
|
||||||
|
|
||||||
|
# --- RIPPY_PATH_MAP: der Anschluss für externe Worker (Befund 26.07.2026) ----
|
||||||
|
|
||||||
|
|
||||||
|
def test_unc_aus_quelle_uebersetzt_cifs():
|
||||||
|
assert unc_aus_quelle("cifs", "//192.168.178.62/rippy") == "\\\\192.168.178.62\\rippy"
|
||||||
|
assert unc_aus_quelle("cifs", "//nas/medien/filme") == "\\\\nas\\medien\\filme"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unc_aus_quelle_raet_bei_nfs_nicht():
|
||||||
|
"""NFS gibt "" — Windows-Schreibweise ist nicht ableitbar (AGENTS Regel D)."""
|
||||||
|
assert unc_aus_quelle("nfs", "192.168.178.62:/volume1/rippy") == ""
|
||||||
|
assert unc_aus_quelle("cifs", "") == ""
|
||||||
|
assert unc_aus_quelle("cifs", "kein-unc-pfad") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_pfad_map_zeile_baut_was_pfad_lokal_liest(monkeypatch):
|
||||||
|
"""Der erzeugte Wert muss vom Worker gelesen werden können — genau dieses
|
||||||
|
Format erwartet worker/tasks.pfad_lokal(): Paare, getrennt durch ';'."""
|
||||||
|
import mounts
|
||||||
|
|
||||||
|
monkeypatch.setattr(mounts, "ist_gemountet", lambda name: True)
|
||||||
|
vorschlaege = pfad_map_vorschlag([
|
||||||
|
{"name": "rippy", "typ": "cifs", "quelle": "//192.168.178.62/rippy"},
|
||||||
|
])
|
||||||
|
assert pfad_map_zeile(vorschlaege) == "/app/media/rippy=\\\\192.168.178.62\\rippy"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pfad_map_zeile_laesst_nfs_weg(monkeypatch):
|
||||||
|
"""Ein halbes Mapping wäre schlimmer als keines: pfad_lokal() hört beim
|
||||||
|
ersten passenden Präfix auf, ein NFS-Eintrag ohne Ziel würde also einen
|
||||||
|
Pfad 'übersetzen', den der Worker danach trotzdem nicht sieht."""
|
||||||
|
import mounts
|
||||||
|
|
||||||
|
monkeypatch.setattr(mounts, "ist_gemountet", lambda name: True)
|
||||||
|
vorschlaege = pfad_map_vorschlag([
|
||||||
|
{"name": "nfs-ziel", "typ": "nfs", "quelle": "10.0.0.9:/export"},
|
||||||
|
{"name": "rippy", "typ": "cifs", "quelle": "//nas/rippy"},
|
||||||
|
])
|
||||||
|
assert pfad_map_zeile(vorschlaege) == "/app/media/rippy=\\\\nas\\rippy"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pfad_map_zeile_ohne_mounts_ist_leer():
|
||||||
|
assert pfad_map_zeile([]) == ""
|
||||||
|
assert pfad_map_zeile(None) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_erzeugtes_mapping_uebersetzt_den_echten_fehlerfall(monkeypatch):
|
||||||
|
"""Gegenprobe mit dem Pfad, an dem es am 26.07.2026 live scheiterte.
|
||||||
|
|
||||||
|
Der Rohschnitt lag auf `/app/media/rippy/95afdc89-…/title_t00.mkv`; der
|
||||||
|
Windows-Worker sah dort nichts. Mit dem hier erzeugten Mapping muss
|
||||||
|
genau dieser Pfad auf die Freigabe zeigen. `pfad_lokal` ist eine reine
|
||||||
|
Funktion im Worker — hier nachgebaut aufzurufen wäre wertlos, deshalb
|
||||||
|
wird sie über den Pfad importiert.
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
|
||||||
|
import mounts
|
||||||
|
|
||||||
|
monkeypatch.setattr(mounts, "ist_gemountet", lambda name: True)
|
||||||
|
mapping = pfad_map_zeile(pfad_map_vorschlag([
|
||||||
|
{"name": "rippy", "typ": "cifs", "quelle": "//192.168.178.62/rippy"},
|
||||||
|
]))
|
||||||
|
|
||||||
|
# Der Worker liegt neben der API im Repo; kein geteiltes Paket zwischen
|
||||||
|
# den Containern, deshalb per Pfad laden statt importieren.
|
||||||
|
worker_tasks = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "worker", "tasks.py"
|
||||||
|
)
|
||||||
|
spec = importlib.util.spec_from_file_location("_worker_tasks_pfad", worker_tasks)
|
||||||
|
quelltext = open(worker_tasks, encoding="utf-8").read()
|
||||||
|
assert "def pfad_lokal" in quelltext and spec is not None
|
||||||
|
|
||||||
|
# pfad_lokal ist bewusst rein und ohne Modul-Zustand — die Funktion aus dem
|
||||||
|
# Quelltext zu holen, ohne tasks.py komplett zu laden (das braucht celery,
|
||||||
|
# db, requests …), geht am ehrlichsten über exec des Funktionsblocks.
|
||||||
|
anfang = quelltext.index("def pfad_lokal")
|
||||||
|
ende = quelltext.index("\nRAW_DIR", anfang)
|
||||||
|
umgebung = {"os": os}
|
||||||
|
exec(compile(quelltext[anfang:ende], worker_tasks, "exec"), umgebung) # noqa: S102
|
||||||
|
pfad_lokal = umgebung["pfad_lokal"]
|
||||||
|
|
||||||
|
assert pfad_lokal("/app/media/rippy/95afdc89/title_t00.mkv", mapping) == (
|
||||||
|
"\\\\192.168.178.62\\rippy\\95afdc89\\title_t00.mkv"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- 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
|
||||||
@@ -47,3 +47,100 @@ def test_omdb_jahr_parsing():
|
|||||||
assert parse_year("2005–2012") == 2005 # Serien-Zeitspanne
|
assert parse_year("2005–2012") == 2005 # Serien-Zeitspanne
|
||||||
assert parse_year("N/A") is None
|
assert parse_year("N/A") is None
|
||||||
assert parse_year("") is None
|
assert parse_year("") is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Reihenfolge der Metadaten-Quellen: exakt schlägt unscharf ---------------
|
||||||
|
#
|
||||||
|
# Änderung vom 26.07.2026. Vorher galt der erste Jikan-Treffer über dem Gate
|
||||||
|
# 0,55 sofort als sicherer Fund, und OMDb kam nie dran — ein Kinofilm, den TMDB
|
||||||
|
# verpasst, bekam damit Anime-Metadaten. Die Messwerte dazu stehen in
|
||||||
|
# test_jikan_helpers.py („Alien" gegen „Alien 9" = 0,83).
|
||||||
|
|
||||||
|
|
||||||
|
class _StummerClient:
|
||||||
|
"""Ein Client, der auf jede Frage dasselbe antwortet — und mitzählt."""
|
||||||
|
|
||||||
|
def __init__(self, antwort=None):
|
||||||
|
self.antwort = antwort
|
||||||
|
self.aufrufe = 0
|
||||||
|
|
||||||
|
def lookup(self, *a, **k):
|
||||||
|
self.aufrufe += 1
|
||||||
|
return self.antwort
|
||||||
|
|
||||||
|
def search_movie(self, *a, **k):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def search_tv(self, *a, **k):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_movie_details(self, *a, **k):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def find_by_imdb(self, *a, **k):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _scanner(monkeypatch, jikan_antwort, omdb_antwort):
|
||||||
|
"""PreScan ohne __init__ (das bräuchte DB + API-Keys), Cache stillgelegt."""
|
||||||
|
from prescan import prescan as modul
|
||||||
|
|
||||||
|
monkeypatch.setattr(modul, "get", lambda k: None)
|
||||||
|
monkeypatch.setattr(modul, "cache_set", lambda *a, **k: None)
|
||||||
|
|
||||||
|
scan = modul.PreScan.__new__(modul.PreScan)
|
||||||
|
scan.tmdb = _StummerClient()
|
||||||
|
scan.jikan = _StummerClient(jikan_antwort)
|
||||||
|
scan.omdb = _StummerClient(omdb_antwort)
|
||||||
|
return scan
|
||||||
|
|
||||||
|
|
||||||
|
def _jikan(score):
|
||||||
|
return {"type": "movie", "id": "mal-1", "title": "Alien 9",
|
||||||
|
"source": "jikan", "match_score": score}
|
||||||
|
|
||||||
|
|
||||||
|
def _omdb():
|
||||||
|
return {"type": "movie", "id": "tt0078748", "title": "Alien",
|
||||||
|
"source": "omdb", "year": 1979}
|
||||||
|
|
||||||
|
|
||||||
|
def test_unscharfer_jikan_treffer_laesst_omdb_zum_zug(monkeypatch):
|
||||||
|
"""Der eigentliche Fehler: „Alien" traf mit 0,83 auf den Anime „Alien 9",
|
||||||
|
und der Film wurde nie gesucht."""
|
||||||
|
scan = _scanner(monkeypatch, _jikan(0.83), _omdb())
|
||||||
|
ergebnis = scan._scan_video("/dev/sr0", {"title": "Alien", "disc_type": "Blu-ray"})
|
||||||
|
assert ergebnis.metadata["source"] == "omdb"
|
||||||
|
assert ergebnis.confidence == 0.8
|
||||||
|
assert scan.omdb.aufrufe > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_exakter_jikan_treffer_gewinnt_sofort(monkeypatch):
|
||||||
|
"""Der Fall, für den Jikan gebaut wurde, darf NICHT verlorengehen
|
||||||
|
(AGENTS Regel B): Bei einem sicheren Treffer wird OMDb nicht mehr gefragt."""
|
||||||
|
scan = _scanner(monkeypatch, _jikan(1.0), _omdb())
|
||||||
|
ergebnis = scan._scan_video("/dev/sr0", {"title": "Alien 9", "disc_type": "Blu-ray"})
|
||||||
|
assert ergebnis.metadata["source"] == "jikan"
|
||||||
|
assert ergebnis.confidence == 0.85
|
||||||
|
assert scan.omdb.aufrufe == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_unscharfer_jikan_treffer_bleibt_als_vorschlag(monkeypatch):
|
||||||
|
"""Hat OMDb nichts (kein Key, kein Treffer), ist der unscharfe Jikan-Treffer
|
||||||
|
besser als nichts — aber er wird als VORSCHLAG ausgewiesen (0,6), nicht als
|
||||||
|
sicherer Fund."""
|
||||||
|
scan = _scanner(monkeypatch, _jikan(0.83), None)
|
||||||
|
ergebnis = scan._scan_video("/dev/sr0", {"title": "Alien", "disc_type": "Blu-ray"})
|
||||||
|
assert ergebnis.metadata["source"] == "jikan"
|
||||||
|
assert ergebnis.confidence == 0.6
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_jede_quelle_bleibt_es_unbekannt(monkeypatch):
|
||||||
|
"""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.3
|
||||||
|
assert ergebnis.metadata["type"] == "unknown"
|
||||||
|
assert ergebnis.title == "Nix Da"
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
"""Tests der Preset-Auswahl — reine Funktionen, keine Infrastruktur.
|
||||||
|
|
||||||
|
Die Preset-Namen in den Testdaten sind ECHT: `HandBrakeCLI --preset-list` im
|
||||||
|
Worker-Image der Rippy-VM, 26.07.2026, HandBrake 1.6.1 (AGENTS Regel D).
|
||||||
|
Erfundene Namen hätten hier keinen Wert — genau daran ist das Projekt zweimal
|
||||||
|
gescheitert.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import presets
|
||||||
|
|
||||||
|
# Ausschnitt der echten Liste: die Kategorien, aus denen Rippy wählt.
|
||||||
|
PRESETS_ECHT = [
|
||||||
|
# General/
|
||||||
|
"Very Fast 2160p60 4K AV1", "Very Fast 1080p30",
|
||||||
|
"HQ 2160p60 4K HEVC Surround", "HQ 1080p30 Surround", "HQ 576p25 Surround",
|
||||||
|
"Super HQ 2160p60 4K HEVC Surround", "Super HQ 1080p30 Surround",
|
||||||
|
# Matroska/
|
||||||
|
"AV1 MKV 2160p60 4K",
|
||||||
|
"H.265 MKV 2160p60 4K", "H.265 MKV 1080p30", "H.265 MKV 576p25",
|
||||||
|
"H.265 MKV 480p30", "H.264 MKV 1080p30",
|
||||||
|
# Hardware/ — steht auch auf Maschinen OHNE Hardware-Encoder in der Liste
|
||||||
|
"AV1 QSV 2160p 4K",
|
||||||
|
"H.265 NVENC 2160p 4K", "H.265 NVENC 1080p",
|
||||||
|
"H.265 QSV 2160p 4K", "H.265 QSV 1080p",
|
||||||
|
"H.265 VCN 2160p 4K", "H.265 VCN 1080p",
|
||||||
|
"H.265 MF 2160p 4K", "H.265 MF 1080p",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def worker(name="w", encoders=None, simd="", presets_liste=None):
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"encoders": encoders if encoders is not None else ["cpu-x264", "cpu-x265"],
|
||||||
|
"info": {
|
||||||
|
"cpu_simd": simd,
|
||||||
|
"presets": presets_liste if presets_liste is not None else PRESETS_ECHT,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Die Rippy-VM: sse4_2, 4 Kerne, kein Hardware-Encoder --------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_vm_ohne_avx2_bekommt_4k_verlustfrei():
|
||||||
|
"""Gemessen: 4K-HEVC in Software = 28-55 h auf dieser Maschine. Die
|
||||||
|
Empfehlung muss deshalb „nicht komprimieren" lauten, nicht ein 4K-Preset."""
|
||||||
|
w = [worker(simd="sse4_2")]
|
||||||
|
e = presets.empfehlung("uhd", w)
|
||||||
|
assert e["preset"] == presets.PRESET_KEINE
|
||||||
|
assert "28-55 Stunden" in e["grund"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_vm_ohne_avx2_bekommt_h264_fuer_bluray_und_dvd():
|
||||||
|
w = [worker(simd="sse4_2")]
|
||||||
|
assert presets.empfehlung("bluray", w)["preset"] == "HQ 1080p30 Surround"
|
||||||
|
assert presets.empfehlung("dvd", w)["preset"] == "HQ 576p25 Surround"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Der PC des Commanders: avx512f, 16 Kerne, RX 9070 XT -------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_hardware_av1_wird_bevorzugt_wenn_die_familie_gemeldet_ist():
|
||||||
|
"""Commander-Vorgabe: „wenn der Worker AV1 oder noch besseres kann, immer
|
||||||
|
diesem empfehlen". QSV meldet AV1, also muss das AV1-Preset kommen."""
|
||||||
|
w = [worker(encoders=["cpu-x265", "qsv", "qsv-av1"], simd="avx512f")]
|
||||||
|
assert presets.empfehlung("uhd", w)["preset"] == "AV1 QSV 2160p 4K"
|
||||||
|
|
||||||
|
|
||||||
|
def test_amd_bekommt_das_amd_preset_nicht_das_intel():
|
||||||
|
"""Der Encoder heißt `vce`, das Preset heißt `VCN` — ohne diese Zuordnung
|
||||||
|
liefe die RX 9070 XT des Commanders unter einem Intel-Namen."""
|
||||||
|
w = [worker(encoders=["cpu-x265", "vce"], simd="avx512f")]
|
||||||
|
assert presets.empfehlung("uhd", w)["preset"] == "H.265 VCN 2160p 4K"
|
||||||
|
assert presets.empfehlung("bluray", w)["preset"] == "H.265 VCN 1080p"
|
||||||
|
|
||||||
|
|
||||||
|
def test_hardware_grund_nennt_den_nachteil_auch():
|
||||||
|
w = [worker(encoders=["vce"], simd="avx512f")]
|
||||||
|
grund = presets.empfehlung("uhd", w)["grund"]
|
||||||
|
assert "VCN" in grund
|
||||||
|
assert "sauberer" in grund # ehrlich: Software hätte etwas mehr Qualität
|
||||||
|
|
||||||
|
|
||||||
|
def test_starke_cpu_ohne_hardware_bekommt_h265():
|
||||||
|
w = [worker(encoders=["cpu-x264", "cpu-x265"], simd="avx2")]
|
||||||
|
assert presets.empfehlung("uhd", w)["preset"] == "H.265 MKV 2160p60 4K"
|
||||||
|
assert presets.empfehlung("bluray", w)["preset"] == "H.265 MKV 1080p30"
|
||||||
|
assert presets.empfehlung("dvd", w)["preset"] == "H.265 MKV 576p25"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dvd_bekommt_nie_ein_hardware_preset():
|
||||||
|
"""HandBrake 1.6.1 hat keine Hardware-Presets in DVD-Auflösung — ein
|
||||||
|
erfundenes „H.265 VCN 576p" würde die Kompression scheitern lassen."""
|
||||||
|
w = [worker(encoders=["vce", "qsv", "nvenc"], simd="avx512f")]
|
||||||
|
assert presets.empfehlung("dvd", w)["preset"] == "H.265 MKV 576p25"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Hardware, die KEIN passendes Preset hat --------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_vaapi_faellt_auf_software_zurueck_statt_zu_raten():
|
||||||
|
"""HandBrake 1.6.1 liefert kein VAAPI-Preset mit. Ein VAAPI-Worker darf
|
||||||
|
deshalb keinen Hardware-Namen bekommen (AGENTS Regel D)."""
|
||||||
|
w = [worker(encoders=["cpu-x265", "vaapi"], simd="avx2")]
|
||||||
|
assert presets.empfehlung("uhd", w)["preset"] == "H.265 MKV 2160p60 4K"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mf_wird_nie_empfohlen_steht_aber_zur_wahl():
|
||||||
|
"""Media-Foundation-Presets existieren, aber ob die Maschine sie nutzen
|
||||||
|
kann, ist aus der Encoder-Liste nicht ablesbar — nicht empfehlen, nicht
|
||||||
|
verstecken."""
|
||||||
|
w = [worker(encoders=["cpu-x265"], simd="avx2")]
|
||||||
|
assert "MF" not in presets.empfehlung("uhd", w)["preset"]
|
||||||
|
namen = [a["name"] for a in presets.auswahl("uhd", w)]
|
||||||
|
assert "H.265 MF 2160p 4K" in namen
|
||||||
|
|
||||||
|
|
||||||
|
# --- Nur echte Namen, nie geratene ------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbekannte_handbrake_version_bekommt_keine_erfundene_empfehlung():
|
||||||
|
"""Meldet ein Worker nur Presets, die Rippy nicht kennt, wird ehrlich
|
||||||
|
„selbst wählen" gesagt — statt einen Namen einzustellen, den dieses
|
||||||
|
HandBrake ablehnt."""
|
||||||
|
w = [worker(simd="avx2", presets_liste=["Irgendwas 4711p", "Noch was"])]
|
||||||
|
e = presets.empfehlung("uhd", w)
|
||||||
|
assert e["gefunden"] is False
|
||||||
|
assert e["preset"] == ""
|
||||||
|
assert "selbst" in e["grund"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_worker_greift_der_rueckfall():
|
||||||
|
"""Kein Worker online (oder alter Worker-Stand ohne Preset-Meldung): die
|
||||||
|
Auswahl darf nicht leer sein, sonst ist die Seite unbenutzbar."""
|
||||||
|
assert presets.verfuegbare_presets([]) == list(presets.RUECKFALL_PRESETS)
|
||||||
|
assert presets.uebersicht([])["quelle"] == "rueckfall"
|
||||||
|
# ... und der Rückfall muss selbst brauchbar sein:
|
||||||
|
e = presets.empfehlung("bluray", [])
|
||||||
|
assert e["gefunden"] is True
|
||||||
|
assert e["preset"] in presets.RUECKFALL_PRESETS
|
||||||
|
|
||||||
|
|
||||||
|
def test_quelle_ist_worker_wenn_einer_meldet():
|
||||||
|
assert presets.uebersicht([worker(simd="avx2")])["quelle"] == "worker"
|
||||||
|
|
||||||
|
|
||||||
|
def test_verfuegbare_presets_vereinigt_ohne_doppelte():
|
||||||
|
a = worker("a", presets_liste=["H.265 MKV 1080p30", "HQ 1080p30 Surround"])
|
||||||
|
b = worker("b", presets_liste=["H.265 MKV 1080p30", "H.265 NVENC 1080p"])
|
||||||
|
assert presets.verfuegbare_presets([a, b]) == [
|
||||||
|
"H.265 MKV 1080p30", "HQ 1080p30 Surround", "H.265 NVENC 1080p",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Auswahl-Listen: die 4K-Falle aus v3.12 darf nicht wiederkommen ---------
|
||||||
|
|
||||||
|
|
||||||
|
def test_4k_auswahl_zeigt_2160p_presets_zuerst():
|
||||||
|
w = [worker(simd="avx2")]
|
||||||
|
liste = presets.auswahl("uhd", w)
|
||||||
|
zuerst = [a["name"] for a in liste if not a["verkleinert"]]
|
||||||
|
assert all("2160p" in n for n in zuerst)
|
||||||
|
assert "H.265 MKV 2160p60 4K" in zuerst
|
||||||
|
|
||||||
|
|
||||||
|
def test_4k_auswahl_kennzeichnet_das_verkleinern():
|
||||||
|
"""Ein 1080p-Preset für eine 4K-Disc ist eine legitime Wahl, aber eine
|
||||||
|
BEWUSSTE — in v3.12 passierte es aus Versehen und die 4K-Auflösung war weg."""
|
||||||
|
w = [worker(simd="avx2")]
|
||||||
|
verkleinert = [a["name"] for a in presets.auswahl("uhd", w) if a["verkleinert"]]
|
||||||
|
assert "H.265 MKV 1080p30" in verkleinert
|
||||||
|
assert all("1080p" in n for n in verkleinert)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dvd_auswahl_nimmt_beide_pal_und_ntsc_aufloesungen():
|
||||||
|
w = [worker(simd="avx2")]
|
||||||
|
namen = [a["name"] for a in presets.auswahl("dvd", w)]
|
||||||
|
assert "H.265 MKV 576p25" in namen # PAL
|
||||||
|
assert "H.265 MKV 480p30" in namen # NTSC
|
||||||
|
assert "H.265 MKV 1080p30" not in namen
|
||||||
|
|
||||||
|
|
||||||
|
def test_bluray_auswahl_enthaelt_kein_4k():
|
||||||
|
w = [worker(simd="avx2")]
|
||||||
|
namen = [a["name"] for a in presets.auswahl("bluray", w)]
|
||||||
|
assert namen
|
||||||
|
assert not any("2160p" in n for n in namen)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Erkennung der Maschinen-Stärke ----------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbekannte_simd_stufe_gilt_nicht_als_schnell_und_nicht_als_langsam():
|
||||||
|
"""Ein Windows-Worker ohne die Erkennung aus v3.16 meldet `unbekannt`. Er
|
||||||
|
darf nichts behaupten — aber auch keinen schnellen Worker ausbremsen."""
|
||||||
|
assert presets.simd_schnell([worker(simd="unbekannt")]) is False
|
||||||
|
assert presets.simd_schnell([worker(simd="unbekannt"), worker(simd="avx2")]) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_hardware_kuerzel_nur_bei_echter_meldung():
|
||||||
|
assert presets.hardware_kuerzel([worker(encoders=["cpu-x265"])]) == []
|
||||||
|
assert presets.hardware_kuerzel([worker(encoders=["nvenc", "nvenc-av1"])]) == ["NVENC"]
|
||||||
|
# Reihenfolge ist die Vorzugsreihenfolge, nicht die Meldereihenfolge
|
||||||
|
assert presets.hardware_kuerzel([worker(encoders=["vce", "qsv"])]) == ["QSV", "VCN"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_uebersicht_liefert_alle_drei_disc_typen():
|
||||||
|
u = presets.uebersicht([worker(simd="avx2")])
|
||||||
|
assert set(u["typen"]) == {"uhd", "bluray", "dvd"}
|
||||||
|
for eintrag in u["typen"].values():
|
||||||
|
assert eintrag["empfehlung"]["preset"]
|
||||||
|
assert eintrag["auswahl"]
|
||||||
@@ -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_pass http://api:8000/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
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:
|
# SSE (/api/stream/jobs) braucht ungepufferte, lange Verbindungen:
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
proxy_read_timeout 3600s;
|
proxy_read_timeout 3600s;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ReactNode } from 'react'
|
||||||
import { AlertTriangle, CheckCircle2, HelpCircle } from 'lucide-react'
|
import { AlertTriangle, CheckCircle2, HelpCircle } from 'lucide-react'
|
||||||
import { Modal } from './ui/Modal'
|
import { Modal } from './ui/Modal'
|
||||||
import { Button } from './ui/Button'
|
import { Button } from './ui/Button'
|
||||||
@@ -8,6 +9,9 @@ interface ConfirmDialogProps {
|
|||||||
onConfirm: () => void
|
onConfirm: () => void
|
||||||
title: string
|
title: string
|
||||||
message: string
|
message: string
|
||||||
|
// Zusatz unter der Meldung — z. B. ein Kasten mit der Größe der Rohdaten,
|
||||||
|
// die beim Entfernen liegen bleiben, samt Wahl „mitlöschen".
|
||||||
|
extra?: ReactNode
|
||||||
confirmText?: string
|
confirmText?: string
|
||||||
cancelText?: string
|
cancelText?: string
|
||||||
type?: 'warning' | 'info' | 'success'
|
type?: 'warning' | 'info' | 'success'
|
||||||
@@ -19,6 +23,7 @@ export default function ConfirmDialog({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
|
extra,
|
||||||
confirmText = 'Bestätigen',
|
confirmText = 'Bestätigen',
|
||||||
cancelText = 'Abbrechen',
|
cancelText = 'Abbrechen',
|
||||||
type = 'info',
|
type = 'info',
|
||||||
@@ -57,6 +62,7 @@ export default function ConfirmDialog({
|
|||||||
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
|
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
|
||||||
{message}
|
{message}
|
||||||
</p>
|
</p>
|
||||||
|
{extra}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,10 @@ export default function DeviceDiscovery() {
|
|||||||
...(optionen?.titles && optionen.titles.length ? { titles: optionen.titles } : {}),
|
...(optionen?.titles && optionen.titles.length ? { titles: optionen.titles } : {}),
|
||||||
...(optionen?.transcodeNode ? { transcode_node: optionen.transcodeNode } : {}),
|
...(optionen?.transcodeNode ? { transcode_node: optionen.transcodeNode } : {}),
|
||||||
...(optionen?.workDir ? { work_dir: optionen.workDir } : {}),
|
...(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`)
|
setActionFeedback(`✓ Job angelegt (${response.data.id.slice(0, 8)}…) — Fortschritt im Dashboard`)
|
||||||
toast('success', 'Rip gestartet — Fortschritt unten bei „Neueste Jobs"')
|
toast('success', 'Rip gestartet — Fortschritt unten bei „Neueste Jobs"')
|
||||||
|
|||||||
@@ -1,57 +1,169 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Disc, Cpu, Globe, Tv, CheckCircle, ArrowRight } from 'lucide-react'
|
import { Disc, Cpu, Globe, Tv, CheckCircle, ArrowRight, HardDrive, AlertTriangle, KeyRound } from 'lucide-react'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
|
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
|
||||||
|
import { PRESET_KEINE, schwacheEncoderCpu } from '../lib/encoder'
|
||||||
import { Button } from './ui/Button'
|
import { Button } from './ui/Button'
|
||||||
import { Input, Select } from './ui/Input'
|
import { Input, Select } from './ui/Input'
|
||||||
|
|
||||||
interface WorkerInfo {
|
interface WorkerInfo {
|
||||||
name: string
|
name: string
|
||||||
encoders: string[]
|
encoders: string[]
|
||||||
last_seen?: string
|
online?: boolean
|
||||||
|
info?: { cpu_kerne?: string; cpu_simd?: string; cpu_modell?: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GeraetInfo {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlatzInfo {
|
||||||
|
name: string
|
||||||
|
frei_gb: number
|
||||||
|
gesamt_gb: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const ENCODER_LABELS: Record<string, string> = {
|
const ENCODER_LABELS: Record<string, string> = {
|
||||||
'cpu-x264': 'CPU · H.264 (x264)',
|
'cpu-x264': 'CPU · H.264 (x264)',
|
||||||
'cpu-x265': 'CPU · H.265 (x265)',
|
'cpu-x265': 'CPU · H.265 (x265)',
|
||||||
|
'cpu-av1': 'CPU · AV1 (SVT-AV1)',
|
||||||
'vaapi': 'Hardware · VAAPI (AMD/Intel)',
|
'vaapi': 'Hardware · VAAPI (AMD/Intel)',
|
||||||
'nvenc': 'Hardware · NVENC (NVIDIA)',
|
'nvenc': 'Hardware · NVENC (NVIDIA)',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unter diesem Wert lohnt der Hinweis auf die Ablage: eine einzelne Blu-ray
|
||||||
|
// braucht roh rund 40 GB, eine 4K-UHD bis 100 GB.
|
||||||
|
const PLATZ_WARNUNG_GB = 60
|
||||||
|
|
||||||
|
// Antwort von GET /presets — dieselbe Quelle, die auch die Einstellungen-Seite
|
||||||
|
// benutzt. Bis 26.07.2026 entschied der Wizard hier selbst und mit fest
|
||||||
|
// verdrahteten Namen; jetzt kommt beides aus api/presets.py, wo es Tests hat.
|
||||||
|
interface PresetUebersicht {
|
||||||
|
quelle: 'worker' | 'rueckfall'
|
||||||
|
hardware: string[]
|
||||||
|
typen: Record<'uhd' | 'bluray' | 'dvd', {
|
||||||
|
empfehlung: { preset: string, grund: string, gefunden: boolean }
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Was beim Abschliessen gespeichert wird — die Empfehlung der API. */
|
||||||
|
function presetsFuer(uebersicht: PresetUebersicht | null) {
|
||||||
|
const nimm = (typ: 'uhd' | 'bluray' | 'dvd') => {
|
||||||
|
const e = uebersicht?.typen?.[typ]?.empfehlung
|
||||||
|
return e?.gefunden ? e.preset : ''
|
||||||
|
}
|
||||||
|
const uhd = nimm('uhd')
|
||||||
|
const bluray = nimm('bluray')
|
||||||
|
const dvd = nimm('dvd')
|
||||||
|
// Leere Werte gar nicht schreiben: dann bleibt der Bestandswert stehen,
|
||||||
|
// statt dass HandBrake später ein leeres --preset bekommt.
|
||||||
|
return {
|
||||||
|
...(bluray ? { transcodePreset: bluray, transcodePresetBluray: bluray } : {}),
|
||||||
|
...(dvd ? { transcodePresetDvd: dvd } : {}),
|
||||||
|
...(uhd ? { transcodePresetUhd: uhd } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
||||||
const [tmdbKey, setTmdbKey] = useState('')
|
const [tmdbKey, setTmdbKey] = useState('')
|
||||||
const [omdbKey, setOmdbKey] = 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 [mediaServer, setMediaServer] = useState('jellyfin')
|
||||||
const [transcodeEnabled, setTranscodeEnabled] = useState(true)
|
const [transcodeEnabled, setTranscodeEnabled] = useState(true)
|
||||||
const [preset, setPreset] = useState('H.265 MKV 1080p30')
|
|
||||||
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||||||
|
const [geraete, setGeraete] = useState<GeraetInfo[]>([])
|
||||||
|
const [plaetze, setPlaetze] = useState<PlatzInfo[]>([])
|
||||||
|
const [presetInfo, setPresetInfo] = useState<PresetUebersicht | null>(null)
|
||||||
|
const [geladen, setGeladen] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [keyWarnung, setKeyWarnung] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Wiederholt laden, bis Worker UND Laufwerk da sind: beim allerersten Start
|
||||||
|
// läuft der Worker-Container oft noch hoch. Ohne das stand hier dauerhaft
|
||||||
|
// „Noch kein Worker gemeldet" und der Nutzer wusste nicht, ob er warten soll.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get('/capabilities')
|
let laeuft = true
|
||||||
.then(r => setWorkers(r.data.workers || []))
|
const laden = async () => {
|
||||||
.catch(() => setWorkers([]))
|
const [caps, devs, info, pres] = await Promise.allSettled([
|
||||||
|
api.get('/capabilities'),
|
||||||
|
api.get('/devices'),
|
||||||
|
api.get('/system/info'),
|
||||||
|
api.get('/presets'),
|
||||||
|
])
|
||||||
|
if (!laeuft) return
|
||||||
|
if (caps.status === 'fulfilled') setWorkers(caps.value.data?.workers || [])
|
||||||
|
if (devs.status === 'fulfilled') setGeraete(devs.value.data || [])
|
||||||
|
if (info.status === 'fulfilled') setPlaetze(info.value.data?.plaetze || [])
|
||||||
|
if (pres.status === 'fulfilled') setPresetInfo(pres.value.data || null)
|
||||||
|
setGeladen(true)
|
||||||
|
}
|
||||||
|
laden()
|
||||||
|
const takt = setInterval(laden, 5000)
|
||||||
|
return () => { laeuft = false; clearInterval(takt) }
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const schwach = schwacheEncoderCpu(workers)
|
||||||
|
const presets = presetsFuer(presetInfo)
|
||||||
|
const encoderWorker = workers.filter(w => w.encoders.length > 0)
|
||||||
|
const platz = plaetze.length > 0 ? Math.min(...plaetze.map(p => p.frei_gb)) : null
|
||||||
|
|
||||||
const abschliessen = async () => {
|
const abschliessen = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
setKeyWarnung(null)
|
||||||
try {
|
try {
|
||||||
const bestehende = (await api.get('/settings')).data || {}
|
const bestehende = (await api.get('/settings')).data || {}
|
||||||
await api.post('/settings', {
|
await api.post('/settings', {
|
||||||
...bestehende,
|
...bestehende,
|
||||||
...(tmdbKey ? { tmdbApiKey: tmdbKey } : {}),
|
...(tmdbKey.trim() ? { tmdbApiKey: tmdbKey.trim() } : {}),
|
||||||
...(omdbKey ? { omdbApiKey: omdbKey } : {}),
|
...(omdbKey.trim() ? { omdbApiKey: omdbKey.trim() } : {}),
|
||||||
|
...(makemkvKey.trim() ? { makemkvAppKey: makemkvKey.trim() } : {}),
|
||||||
mediaServer,
|
mediaServer,
|
||||||
transcodeEnabled,
|
transcodeEnabled,
|
||||||
transcodePreset: preset,
|
...presets,
|
||||||
})
|
})
|
||||||
|
// Erst SPEICHERN, dann prüfen: /metadata/status liest die Keys aus der
|
||||||
|
// Datenbank, nicht aus dem Formular. Ein falsch kopierter Key fällt so
|
||||||
|
// sofort auf, statt erst beim ersten Rip als „Unknown Disc".
|
||||||
|
if (tmdbKey.trim() || omdbKey.trim()) {
|
||||||
|
try {
|
||||||
|
const s = (await api.get('/metadata/status')).data || {}
|
||||||
|
const kaputt = Object.entries(s)
|
||||||
|
.filter(([, v]) => v === 'fehler')
|
||||||
|
.map(([k]) => k.toUpperCase())
|
||||||
|
if (kaputt.length > 0) {
|
||||||
|
setKeyWarnung(
|
||||||
|
`${kaputt.join(' und ')} hat den Key nicht angenommen — vermutlich ein `
|
||||||
|
+ 'Tippfehler beim Kopieren. Rippy laeuft trotzdem, die Titel heissen dann '
|
||||||
|
+ 'nur wie das Disc-Label. Nachbessern: Einstellungen → APIs.'
|
||||||
|
)
|
||||||
|
setSaving(false)
|
||||||
|
return // nicht wegklicken, damit die Warnung gelesen wird
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Prüfung selbst kaputt → kein Grund, die Einrichtung zu blockieren
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await api.post('/setup/complete')
|
||||||
|
onDone()
|
||||||
|
} catch {
|
||||||
|
setError('Speichern fehlgeschlagen — läuft die API? (Seite neu laden hilft meist.)')
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const trotzdemWeiter = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
await api.post('/setup/complete')
|
await api.post('/setup/complete')
|
||||||
onDone()
|
onDone()
|
||||||
} catch {
|
} catch {
|
||||||
setError('Speichern fehlgeschlagen — läuft die API?')
|
setError('Speichern fehlgeschlagen — läuft die API?')
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,25 +176,65 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
|||||||
<Disc size={32} className="animate-disc-spin" />
|
<Disc size={32} className="animate-disc-spin" />
|
||||||
<h1 className="text-2xl font-bold">Willkommen bei Rippy</h1>
|
<h1 className="text-2xl font-bold">Willkommen bei Rippy</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="opacity-90">Einmalige Einrichtung — dauert keine zwei Minuten. Alles ist später unter Einstellungen änderbar.</p>
|
<p className="opacity-90">
|
||||||
|
Einmalige Einrichtung — dauert keine zwei Minuten. Du kannst nichts kaputt
|
||||||
|
machen: alles ist später unter Einstellungen änderbar.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-8 space-y-8">
|
<div className="p-8 space-y-8">
|
||||||
{/* Metadaten-APIs */}
|
{/* ---- Was Rippy gerade sieht ---------------------------------- */}
|
||||||
|
<section>
|
||||||
|
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
|
||||||
|
<HardDrive size={18} className="text-amber-500" /> Was Rippy gerade sieht
|
||||||
|
</h2>
|
||||||
|
<div className="rounded-xl border border-slate-200 dark:border-slate-800 divide-y divide-slate-200 dark:divide-slate-800 overflow-hidden">
|
||||||
|
<Zeile
|
||||||
|
titel="Laufwerk"
|
||||||
|
geladen={geladen}
|
||||||
|
gut={geraete.length > 0}
|
||||||
|
gutText={geraete.map(g => g.name).join(', ')}
|
||||||
|
schlechtText="Kein optisches Laufwerk gefunden. Rippy kann dann nur komprimieren, nicht rippen. Prüfe, ob das Laufwerk angeschlossen und (bei einer VM) durchgereicht ist."
|
||||||
|
/>
|
||||||
|
<Zeile
|
||||||
|
titel="Encoding-Worker"
|
||||||
|
geladen={geladen}
|
||||||
|
gut={encoderWorker.length > 0}
|
||||||
|
gutText={encoderWorker.map(w => {
|
||||||
|
const kerne = w.info?.cpu_kerne
|
||||||
|
const simd = w.info?.cpu_simd
|
||||||
|
const zusatz = [kerne ? `${kerne} Kerne` : '', simd && simd !== 'unbekannt' ? simd : '']
|
||||||
|
.filter(Boolean).join(', ')
|
||||||
|
return zusatz ? `${w.name} (${zusatz})` : w.name
|
||||||
|
}).join(' · ')}
|
||||||
|
schlechtText="Noch kein Worker gemeldet. Beim ersten Start dauert das bis zu einer Minute — diese Anzeige aktualisiert sich selbst. Bleibt es dabei, läuft der Worker-Container nicht."
|
||||||
|
/>
|
||||||
|
<Zeile
|
||||||
|
titel="Freier Platz"
|
||||||
|
geladen={geladen}
|
||||||
|
gut={platz === null || platz >= PLATZ_WARNUNG_GB}
|
||||||
|
gutText={platz === null ? '—' : `${platz.toFixed(0)} GB`}
|
||||||
|
schlechtText={`Nur ${platz?.toFixed(0)} GB frei. Eine Blu-ray braucht roh rund 40 GB, eine 4K-UHD bis 100 GB. Lege die Ablage besser auf eine NAS-Freigabe (Einstellungen → Speicherziele).`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ---- Metadaten ---------------------------------------------- */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
|
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
|
||||||
<Globe size={18} className="text-amber-500" /> Metadaten-Erkennung
|
<Globe size={18} className="text-amber-500" /> Metadaten-Erkennung
|
||||||
</h2>
|
</h2>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
{/* Bewusst SICHTBAR statt type=password: Das sind kopierte Keys,
|
||||||
|
keine Passwoerter — und einen Tippfehler sieht man in Punkten
|
||||||
|
nicht. Geprueft wird direkt nach dem Speichern. */}
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
|
||||||
label="TMDB API-Key (empfohlen)"
|
label="TMDB API-Key (empfohlen)"
|
||||||
value={tmdbKey}
|
value={tmdbKey}
|
||||||
onChange={e => setTmdbKey(e.target.value)}
|
onChange={e => setTmdbKey(e.target.value)}
|
||||||
placeholder="Kostenlos auf themoviedb.org"
|
placeholder="Kostenlos auf themoviedb.org — hier einfügen"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
|
||||||
label="OMDb API-Key (optional, zweite Quelle)"
|
label="OMDb API-Key (optional, zweite Quelle)"
|
||||||
value={omdbKey}
|
value={omdbKey}
|
||||||
onChange={e => setOmdbKey(e.target.value)}
|
onChange={e => setOmdbKey(e.target.value)}
|
||||||
@@ -90,11 +242,47 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
|||||||
/>
|
/>
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
Ohne Keys rippt Rippy trotzdem — Discs heißen dann nur wie ihr Volume-Label.
|
Ohne Keys rippt Rippy trotzdem — Discs heißen dann nur wie ihr Volume-Label.
|
||||||
|
Beide Keys werden direkt nach dem Speichern geprüft.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Media-Server */}
|
{/* ---- 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>
|
<section>
|
||||||
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
|
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
|
||||||
<Tv size={18} className="text-amber-500" /> Dein Media-Server
|
<Tv size={18} className="text-amber-500" /> Dein Media-Server
|
||||||
@@ -125,48 +313,119 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Verarbeitung + erkannte Hardware */}
|
{/* ---- Verarbeitung ------------------------------------------- */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
|
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
|
||||||
<Cpu size={18} className="text-amber-500" /> Verarbeitung
|
<Cpu size={18} className="text-amber-500" /> Verarbeitung
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="rounded-xl p-4 mb-3 bg-slate-50 dark:bg-slate-950/80 border border-slate-200 dark:border-slate-800">
|
<label className="flex items-start gap-3 mb-3 text-sm text-slate-700 dark:text-slate-300">
|
||||||
<p className="text-sm font-medium mb-2 text-slate-700 dark:text-slate-300">Erkannte Encoder in deinem System:</p>
|
<input
|
||||||
{workers.length === 0 ? (
|
type="checkbox"
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400">Noch kein Worker gemeldet — startet gerade?</p>
|
checked={transcodeEnabled}
|
||||||
) : (
|
onChange={e => setTranscodeEnabled(e.target.checked)}
|
||||||
workers.map(w => (
|
className="w-4 h-4 mt-0.5 accent-amber-500 rounded"
|
||||||
<div key={w.name} className="mb-1">
|
/>
|
||||||
<span className="text-xs font-mono text-slate-500 dark:text-slate-400">{w.name}: </span>
|
<span>
|
||||||
{w.encoders.map(e => (
|
Nach dem Rip auf Arbeitsgröße komprimieren
|
||||||
<span key={e} className={`inline-block text-xs px-2 py-0.5 rounded font-medium mr-1 ${
|
<span className="block text-xs text-slate-500 dark:text-slate-400">
|
||||||
e.startsWith('cpu')
|
Empfohlen — ohne Kompression braucht jede Blu-ray rund 40 GB.
|
||||||
? 'bg-slate-200 dark:bg-slate-800 text-slate-600 dark:text-slate-300'
|
</span>
|
||||||
: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'
|
</span>
|
||||||
}`}>
|
|
||||||
{ENCODER_LABELS[e] || e}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="flex items-center gap-3 mb-3 text-sm text-slate-700 dark:text-slate-300">
|
|
||||||
<input type="checkbox" checked={transcodeEnabled} onChange={e => setTranscodeEnabled(e.target.checked)} className="w-4 h-4 accent-amber-500 rounded" />
|
|
||||||
Nach dem Rip auf Arbeitsgröße komprimieren (empfohlen — sonst ~40 GB pro Blu-ray)
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{transcodeEnabled && (
|
{transcodeEnabled && (
|
||||||
<Select value={preset} onChange={e => setPreset(e.target.value)}>
|
<div className={`rounded-xl p-4 border ${
|
||||||
<option value="H.265 MKV 1080p30">H.265 — klein & modern (Standard)</option>
|
schwach
|
||||||
<option value="HQ 1080p30 Surround">H.264 — schneller auf schwacher CPU, etwas größer</option>
|
? 'bg-amber-500/10 border-amber-500/30'
|
||||||
</Select>
|
: 'bg-slate-50 dark:bg-slate-950/80 border-slate-200 dark:border-slate-800'
|
||||||
|
}`}>
|
||||||
|
{/* Der Kern dieser Seite: Die Empfehlung folgt der GEMESSENEN
|
||||||
|
Rechenleistung. Vorher stand hier H.265 als Standard - auf
|
||||||
|
einer CPU ohne AVX2 sind das 28-55 Stunden je 4K-Film, und
|
||||||
|
genau das ist am 25.07.2026 passiert. */}
|
||||||
|
{schwach ? (
|
||||||
|
<>
|
||||||
|
<p className="text-sm font-medium flex items-start gap-2 text-amber-700 dark:text-amber-300">
|
||||||
|
<AlertTriangle size={16} className="mt-0.5 flex-shrink-0" />
|
||||||
|
<span>Rippy hat deine Rechenleistung gemessen und passt die Wahl an.</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs mt-2 text-amber-700/90 dark:text-amber-300/90">
|
||||||
|
Keiner deiner Worker kann AVX2 — die Vektorbefehle, von denen H.265
|
||||||
|
lebt. 4K in H.265 würde hier <strong>ein bis zwei Tage pro Film</strong>
|
||||||
|
{' '}dauern. Deshalb wird eingestellt:
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : presetInfo && presetInfo.hardware.length > 0 ? (
|
||||||
|
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
|
Hardware-Encoder gemeldet ({presetInfo.hardware.join(', ')}) — Rippy nimmt ihn:
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
|
Deine Worker sind schnell genug für H.265 — Rippy stellt ein:
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<ul className="mt-2 space-y-1 text-xs text-slate-600 dark:text-slate-400">
|
||||||
|
<li>
|
||||||
|
<strong>4K-UHD:</strong>{' '}
|
||||||
|
{presets.transcodePresetUhd === PRESET_KEINE
|
||||||
|
? 'nicht komprimieren, verlustfrei behalten (20–100 GB je Film)'
|
||||||
|
: presets.transcodePresetUhd || 'unverändert (kein bekanntes Preset gemeldet)'}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Blu-ray:</strong>{' '}
|
||||||
|
{presets.transcodePresetBluray || 'unverändert (kein bekanntes Preset gemeldet)'}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>DVD:</strong>{' '}
|
||||||
|
{presets.transcodePresetDvd || 'unverändert (kein bekanntes Preset gemeldet)'}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{presetInfo?.typen?.uhd?.empfehlung?.grund && (
|
||||||
|
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Warum: {presetInfo.typen.uhd.empfehlung.grund}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Alles einzeln änderbar unter Einstellungen → Verarbeitung.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{encoderWorker.length > 0 && (
|
||||||
|
<div className="mt-3 flex flex-wrap gap-1">
|
||||||
|
{encoderWorker.flatMap(w => w.encoders).filter((e, i, a) => a.indexOf(e) === i).map(e => (
|
||||||
|
<span key={e} className={`text-xs px-2 py-0.5 rounded font-medium ${
|
||||||
|
e.startsWith('cpu')
|
||||||
|
? 'bg-slate-200 dark:bg-slate-800 text-slate-600 dark:text-slate-300'
|
||||||
|
: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'
|
||||||
|
}`}>
|
||||||
|
{ENCODER_LABELS[e] || e}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{error && <p className="text-sm text-rose-500">{error}</p>}
|
{keyWarnung && (
|
||||||
|
<div className="p-4 rounded-xl bg-amber-500/10 border border-amber-500/30">
|
||||||
|
<p className="text-sm text-amber-700 dark:text-amber-300">{keyWarnung}</p>
|
||||||
|
<div className="flex gap-2 mt-3">
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => setKeyWarnung(null)}>
|
||||||
|
Key korrigieren
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" size="sm" onClick={trotzdemWeiter} disabled={saving}>
|
||||||
|
Trotzdem fertigstellen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm p-3 rounded-lg bg-rose-500/10 border border-rose-500/30 text-rose-600 dark:text-rose-400">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="amber"
|
variant="amber"
|
||||||
@@ -186,3 +445,34 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Eine Zeile im „Was Rippy gerade sieht"-Kasten: gruen, wenn es passt, sonst
|
||||||
|
* bernstein MIT Handlungsanweisung. Nie nur ein Kreuz ohne Erklaerung. */
|
||||||
|
function Zeile({ titel, geladen, gut, gutText, schlechtText }: {
|
||||||
|
titel: string
|
||||||
|
geladen: boolean
|
||||||
|
gut: boolean
|
||||||
|
gutText: string
|
||||||
|
schlechtText: string
|
||||||
|
}) {
|
||||||
|
if (!geladen) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 p-3">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-slate-300 dark:bg-slate-600 animate-pulse flex-shrink-0" />
|
||||||
|
<span className="text-sm text-slate-500 dark:text-slate-400">{titel} — wird geprüft…</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3 p-3">
|
||||||
|
<span className={`w-2 h-2 mt-1.5 rounded-full flex-shrink-0 ${gut ? 'bg-emerald-500' : 'bg-amber-500'}`} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<span className="font-medium">{titel}:</span>{' '}
|
||||||
|
{gut ? gutText : <span className="text-amber-600 dark:text-amber-400">nicht bereit</span>}
|
||||||
|
</p>
|
||||||
|
{!gut && <p className="text-xs mt-0.5 text-amber-700/90 dark:text-amber-300/90">{schlechtText}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Folder, FolderOpen, File, ArrowUp, CheckCircle, Film, Tv, Music, Cpu, HardDrive } from 'lucide-react'
|
import { Folder, FolderOpen, ArrowUp, CheckCircle, Film, Tv, Music, Cpu, HardDrive, ChevronRight, ChevronDown } from 'lucide-react'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { Modal } from './ui/Modal'
|
import { Modal } from './ui/Modal'
|
||||||
import { Button } from './ui/Button'
|
import { Button } from './ui/Button'
|
||||||
import { Input, Select } from './ui/Input'
|
import { Input, Select } from './ui/Input'
|
||||||
|
import { automatikWarnung, externWarnung, freigabenAusMapping, sprachName } from '../lib/encoder'
|
||||||
|
|
||||||
interface TargetConfig {
|
interface TargetConfig {
|
||||||
id: string
|
id: string
|
||||||
@@ -20,6 +21,17 @@ export interface RipOptionen {
|
|||||||
titles?: number[]
|
titles?: number[]
|
||||||
transcodeNode?: string // gewählter Encoder-Worker (Celery-Node) oder leer = auto
|
transcodeNode?: string // gewählter Encoder-Worker (Celery-Node) oder leer = auto
|
||||||
workDir?: string // Arbeitsverzeichnis für die Rohdaten; leer = Einstellung
|
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.
|
// Ein Ablageziel aus GET /storage-targets.
|
||||||
@@ -35,10 +47,15 @@ interface WorkerWahl {
|
|||||||
node: string | null
|
node: string | null
|
||||||
online?: boolean
|
online?: boolean
|
||||||
encoders: string[]
|
encoders: string[]
|
||||||
|
// extern: 'ja' = läuft AUSSERHALB des Rippy-Containers und erreicht die
|
||||||
|
// Container-Pfade nur über eine Freigabe plus pfad_map. Der Worker meldet
|
||||||
|
// beides selbst (worker/caps.py).
|
||||||
|
info?: { extern?: string, pfad_map?: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
const ENCODER_KURZ: Record<string, string> = {
|
const ENCODER_KURZ: Record<string, string> = {
|
||||||
'cpu-x264': 'H.264', 'cpu-x265': 'H.265', 'vaapi': 'VAAPI⚡', 'nvenc': 'NVENC⚡',
|
'cpu-x264': 'H.264', 'cpu-x265': 'H.265', 'cpu-av1': 'AV1',
|
||||||
|
'vaapi': 'VAAPI⚡', 'nvenc': 'NVENC⚡',
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TitelInfo {
|
interface TitelInfo {
|
||||||
@@ -79,6 +96,25 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set())
|
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set())
|
||||||
const [workers, setWorkers] = useState<WorkerWahl[]>([])
|
const [workers, setWorkers] = useState<WorkerWahl[]>([])
|
||||||
const [encoderNode, setEncoderNode] = useState('') // '' = automatisch
|
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 () => {
|
const scanStarten = async () => {
|
||||||
if (!deviceId) return
|
if (!deviceId) return
|
||||||
@@ -101,6 +137,16 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
const tracks: TitelInfo[] = r.data.tracks || []
|
const tracks: TitelInfo[] = r.data.tracks || []
|
||||||
setTitelListe(tracks)
|
setTitelListe(tracks)
|
||||||
setGewaehlt(new Set(tracks.filter(t => t.dauer_s >= 300).map(t => t.nr)))
|
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')
|
setScanStatus('done')
|
||||||
} else if (r.data.status === 'error') {
|
} else if (r.data.status === 'error') {
|
||||||
setScanFehler(r.data.error || 'Scan fehlgeschlagen')
|
setScanFehler(r.data.error || 'Scan fehlgeschlagen')
|
||||||
@@ -119,8 +165,16 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
const [browsePath, setBrowsePath] = useState<string>('/app/media')
|
const [browsePath, setBrowsePath] = useState<string>('/app/media')
|
||||||
const [browseParent, setBrowseParent] = useState<string | null>(null)
|
const [browseParent, setBrowseParent] = useState<string | null>(null)
|
||||||
const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([])
|
const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([])
|
||||||
const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([])
|
|
||||||
const [customPath, setCustomPath] = useState('')
|
const [customPath, setCustomPath] = useState('')
|
||||||
|
// Der Ordner-Browser ist EINGEKLAPPT (Commander 26.07.2026: „Warum wird hier
|
||||||
|
// der Datei Browser noch angezeigt — das ist doch quatsch"). Er ist nicht
|
||||||
|
// wirklich redundant: nur über ihn lässt sich für DIESEN einen Rip ein
|
||||||
|
// beliebiger Zielordner wählen. Aber der Normalfall ist die Schnellwahl
|
||||||
|
// darüber, und der Browser überschrieb sie stillschweigend, sobald man auf
|
||||||
|
// „Diesen Ordner nutzen" klickte. Jetzt muss man ihn aufklappen — dann ist
|
||||||
|
// die Wahl bewusst. Nebenbefund beim Aufräumen: `browseFiles` wurde geladen
|
||||||
|
// und NIE angezeigt (samt ungenutztem File-Icon) — beides entfernt.
|
||||||
|
const [browserOffen, setBrowserOffen] = useState(false)
|
||||||
// Arbeitsverzeichnis dieses Rips (Commander-Wunsch 25.07.2026: hier wählbar,
|
// Arbeitsverzeichnis dieses Rips (Commander-Wunsch 25.07.2026: hier wählbar,
|
||||||
// nicht global vorgegeben). '' = der Wert aus den Einstellungen, der auch
|
// nicht global vorgegeben). '' = der Wert aus den Einstellungen, der auch
|
||||||
// bei Vollautomatik-Rips gilt, weil dort niemand gefragt wird.
|
// bei Vollautomatik-Rips gilt, weil dort niemand gefragt wird.
|
||||||
@@ -140,6 +194,10 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
setGewaehlt(new Set())
|
setGewaehlt(new Set())
|
||||||
setEncoderNode('')
|
setEncoderNode('')
|
||||||
setArbeitsDir('')
|
setArbeitsDir('')
|
||||||
|
setBrowserOffen(false)
|
||||||
|
setDiscSprachen(null)
|
||||||
|
setAudioWahl(new Set())
|
||||||
|
setUntertitelWahl(new Set())
|
||||||
api.get('/storage-targets')
|
api.get('/storage-targets')
|
||||||
.then(r => setArbeitsZiele(Array.isArray(r.data) ? r.data : []))
|
.then(r => setArbeitsZiele(Array.isArray(r.data) ? r.data : []))
|
||||||
.catch(() => setArbeitsZiele([]))
|
.catch(() => setArbeitsZiele([]))
|
||||||
@@ -151,6 +209,10 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
const s = r.data || {}
|
const s = r.data || {}
|
||||||
setNurHauptfilm(!!s.mainFeatureOnly)
|
setNurHauptfilm(!!s.mainFeatureOnly)
|
||||||
setStandardArbeitsDir((s.workDir || '').trim())
|
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'
|
const basis = s.outputDir || '/app/media'
|
||||||
setTargets([
|
setTargets([
|
||||||
{ id: '1', name: 'Filme', path: `${basis}/${s.movieDir || 'movies'}`, type: 'movies', isActive: true },
|
{ id: '1', name: 'Filme', path: `${basis}/${s.movieDir || 'movies'}`, type: 'movies', isActive: true },
|
||||||
@@ -158,7 +220,8 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
{ id: '3', name: 'Musik', path: `${basis}/${s.musicDir || 'music'}`, type: 'music', isActive: true },
|
{ id: '3', name: 'Musik', path: `${basis}/${s.musicDir || 'music'}`, type: 'music', isActive: true },
|
||||||
])
|
])
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
laden('/app/media')
|
// /browse wird erst beim Aufklappen geholt — der Dialog braucht es im
|
||||||
|
// Normalfall gar nicht.
|
||||||
}, [isOpen])
|
}, [isOpen])
|
||||||
|
|
||||||
const laden = async (pfad: string) => {
|
const laden = async (pfad: string) => {
|
||||||
@@ -167,13 +230,42 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
setBrowsePath(r.data.path)
|
setBrowsePath(r.data.path)
|
||||||
setBrowseParent(r.data.parent)
|
setBrowseParent(r.data.parent)
|
||||||
setBrowseDirs(r.data.dirs)
|
setBrowseDirs(r.data.dirs)
|
||||||
setBrowseFiles(r.data.files || [])
|
|
||||||
} catch {
|
} catch {
|
||||||
setBrowseDirs([])
|
setBrowseDirs([])
|
||||||
setBrowseFiles([])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Der effektive Zielpfad dieses Rips — Schnellwahl oder eigener Ordner.
|
||||||
|
const zielPfad = customPath || targets.find(t => t.type === selectedType)?.path || ''
|
||||||
|
// Das effektive Arbeitsverzeichnis: Wahl für diesen Rip → Einstellung →
|
||||||
|
// leer (= Container-Platte /app/temp, für externe Worker unerreichbar).
|
||||||
|
const arbeitsPfadEffektiv = arbeitsDir || standardArbeitsDir
|
||||||
|
// Warnung VOR dem Start, wenn der gewählte externe Encoder die Pfade nicht
|
||||||
|
// erreicht (Punkt 6 des Savepoints v3.16). Am 26.07.2026 fiel genau das erst
|
||||||
|
// NACH dem Rip auf, weil nur der Worker selbst prüfte.
|
||||||
|
const gewaehlterWorker = workers.find(w => w.node === encoderNode)
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
? externWarnung(gewaehlterWorker, zielPfad, arbeitsPfadEffektiv)
|
||||||
|
// „Automatisch": die geteilte Queue nimmt den ersten freien Worker — auch
|
||||||
|
// einen, der die Pfade nicht erreicht.
|
||||||
|
: automatikWarnung(workers, zielPfad, arbeitsPfadEffektiv)
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
const optionen: RipOptionen = { mainFeatureOnly: nurHauptfilm }
|
const optionen: RipOptionen = { mainFeatureOnly: nurHauptfilm }
|
||||||
if (selectedType === 'series' && serienName.trim()) {
|
if (selectedType === 'series' && serienName.trim()) {
|
||||||
@@ -185,6 +277,8 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
}
|
}
|
||||||
if (encoderNode) optionen.transcodeNode = encoderNode
|
if (encoderNode) optionen.transcodeNode = encoderNode
|
||||||
if (arbeitsDir) optionen.workDir = arbeitsDir
|
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)
|
const target = targets.find(t => t.type === selectedType)
|
||||||
if (customPath) {
|
if (customPath) {
|
||||||
onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true }, optionen)
|
onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true }, optionen)
|
||||||
@@ -324,6 +418,31 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Die Warnung, die am 26.07.2026 gefehlt hat: Der gewählte externe
|
||||||
|
Encoder nahm die Aufgabe an und lehnte sie 182 ms später ab, weil er
|
||||||
|
die Container-Pfade nicht sieht. Sichtbar war das erst NACH dem Rip. */}
|
||||||
|
{pfadWarnung && (
|
||||||
|
<div className="text-xs p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-700 dark:text-amber-300">
|
||||||
|
<strong>⚠️ Dieser Encoder kann so nicht arbeiten.</strong>
|
||||||
|
<p className="mt-1">{pfadWarnung}</p>
|
||||||
|
{/* 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>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Titel-Auswahl (Track-Tabelle) */}
|
{/* Titel-Auswahl (Track-Tabelle) */}
|
||||||
{deviceId && selectedType !== 'music' && (
|
{deviceId && selectedType !== 'music' && (
|
||||||
<div className="rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-50/50 dark:bg-slate-950/50">
|
<div className="rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-50/50 dark:bg-slate-950/50">
|
||||||
@@ -389,45 +508,138 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Ordner-Browser */}
|
{/* Sprachen der Disc — steht direkt unter der Titel-Auswahl, weil beides
|
||||||
<div className="rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-50/50 dark:bg-slate-950/50">
|
aus DEMSELBEN Scan kommt. Vorher warf Rippy diese Auskunft weg. */}
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-slate-200 dark:border-slate-800 bg-slate-100/50 dark:bg-slate-900/50">
|
{scanStatus === 'done' && discSprachen
|
||||||
<button
|
&& (discSprachen.audio.length > 0 || discSprachen.untertitel.length > 0) && (
|
||||||
onClick={() => browseParent && laden(browseParent)}
|
<div className="rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-50/50 dark:bg-slate-950/50">
|
||||||
disabled={!browseParent}
|
<div className="px-4 py-2.5 border-b border-slate-200 dark:border-slate-800">
|
||||||
className="p-1 rounded-lg disabled:opacity-30 hover:bg-slate-200 dark:hover:bg-slate-800 text-slate-500 dark:text-slate-400"
|
<span className="text-sm font-medium text-slate-900 dark:text-slate-200">
|
||||||
title="Ordner hoch"
|
Sprachen auf dieser Disc
|
||||||
>
|
</span>
|
||||||
<ArrowUp size={16} />
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
</button>
|
{discSprachen.audio.length} Tonsprache{discSprachen.audio.length === 1 ? '' : 'n'}
|
||||||
<span className="text-xs font-mono truncate flex-1 text-slate-500 dark:text-slate-400">{browsePath}</span>
|
{discSprachen.untertitel.length > 0
|
||||||
<Button
|
&& `, ${discSprachen.untertitel.length} Untertitelsprache${discSprachen.untertitel.length === 1 ? '' : 'n'}`}
|
||||||
variant={customPath === browsePath ? 'amber' : 'secondary'}
|
{' — '}nichts angeklickt heißt <strong>alles behalten</strong>.
|
||||||
size="sm"
|
</p>
|
||||||
onClick={() => setCustomPath(browsePath)}
|
</div>
|
||||||
>
|
|
||||||
{customPath === browsePath ? '✓ gewählt' : 'Diesen Ordner nutzen'}
|
<div className="p-4 space-y-3">
|
||||||
</Button>
|
{(['audio', 'untertitel'] as const).map(art => {
|
||||||
</div>
|
const liste = discSprachen[art]
|
||||||
<div className="max-h-36 overflow-y-auto p-1">
|
if (liste.length === 0) return null
|
||||||
{browseDirs.map(d => (
|
const wahl = art === 'audio' ? audioWahl : untertitelWahl
|
||||||
<button
|
const setzen = art === 'audio' ? setAudioWahl : setUntertitelWahl
|
||||||
key={d.path}
|
return (
|
||||||
onClick={() => laden(d.path)}
|
<div key={art}>
|
||||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left rounded-lg transition-colors text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
|
<p className="text-xs font-medium mb-1.5 text-slate-700 dark:text-slate-300">
|
||||||
>
|
{art === 'audio' ? 'Tonspuren' : 'Untertitel'}
|
||||||
<Folder size={14} className="text-slate-400" />
|
</p>
|
||||||
{d.name}
|
<div className="flex flex-wrap gap-2">
|
||||||
</button>
|
{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>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ordner-Browser — eingeklappt, siehe Begründung bei browserOffen */}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const auf = !browserOffen
|
||||||
|
setBrowserOffen(auf)
|
||||||
|
if (auf && browseDirs.length === 0) laden('/app/media')
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1.5 text-xs text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200"
|
||||||
|
>
|
||||||
|
{browserOffen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
|
Anderen Zielordner wählen (nur für diesen Rip)
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{browserOffen && (
|
||||||
|
<div className="mt-2 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-50/50 dark:bg-slate-950/50">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-slate-200 dark:border-slate-800 bg-slate-100/50 dark:bg-slate-900/50">
|
||||||
|
<button
|
||||||
|
onClick={() => browseParent && laden(browseParent)}
|
||||||
|
disabled={!browseParent}
|
||||||
|
className="p-1 rounded-lg disabled:opacity-30 hover:bg-slate-200 dark:hover:bg-slate-800 text-slate-500 dark:text-slate-400"
|
||||||
|
title="Ordner hoch"
|
||||||
|
>
|
||||||
|
<ArrowUp size={16} />
|
||||||
|
</button>
|
||||||
|
<span className="text-xs font-mono truncate flex-1 text-slate-500 dark:text-slate-400">{browsePath}</span>
|
||||||
|
<Button
|
||||||
|
variant={customPath === browsePath ? 'amber' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCustomPath(customPath === browsePath ? '' : browsePath)}
|
||||||
|
>
|
||||||
|
{customPath === browsePath ? '✓ gewählt' : 'Diesen Ordner nutzen'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-36 overflow-y-auto p-1">
|
||||||
|
{browseDirs.map(d => (
|
||||||
|
<button
|
||||||
|
key={d.path}
|
||||||
|
onClick={() => laden(d.path)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left rounded-lg transition-colors text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
<Folder size={14} className="text-slate-400" />
|
||||||
|
{d.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{browseDirs.length === 0 && (
|
||||||
|
<p className="px-3 py-1.5 text-xs text-slate-400">Keine Unterordner.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Gewähltes Ziel Display */}
|
{/* Gewähltes Ziel Display */}
|
||||||
<div className="flex items-center gap-2 text-sm px-3.5 py-2.5 rounded-xl bg-slate-100 dark:bg-slate-900 border border-slate-200 dark:border-slate-800">
|
<div className="flex items-center gap-2 text-sm px-3.5 py-2.5 rounded-xl bg-slate-100 dark:bg-slate-900 border border-slate-200 dark:border-slate-800">
|
||||||
{customPath ? <FolderOpen size={16} className="text-amber-500" /> : <CheckCircle size={16} className="text-emerald-500" />}
|
{customPath ? <FolderOpen size={16} className="text-amber-500" /> : <CheckCircle size={16} className="text-emerald-500" />}
|
||||||
<span className="font-mono text-xs truncate text-slate-700 dark:text-slate-300">
|
<span className="font-mono text-xs truncate text-slate-700 dark:text-slate-300">
|
||||||
{customPath || targets.find(t => t.type === selectedType)?.path}
|
{zielPfad}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -378,7 +378,7 @@ export default function StorageMounts() {
|
|||||||
label="Rechner (dein PC oder NAS — Name oder IP)"
|
label="Rechner (dein PC oder NAS — Name oder IP)"
|
||||||
value={host}
|
value={host}
|
||||||
onChange={e => setHost(e.target.value)}
|
onChange={e => setHost(e.target.value)}
|
||||||
placeholder="192.168.178.20 oder TOBIS-PC"
|
placeholder="z. B. 192.168.1.20 oder MEIN-NAS"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Server, RefreshCw, Copy, CheckCircle, Trash2, Download } from 'lucide-react'
|
import { Server, RefreshCw, Copy, CheckCircle, Trash2, Download } from 'lucide-react'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
|
import { simdWarnung } from '../lib/encoder'
|
||||||
import { useToast } from '../context/ToastContext'
|
import { useToast } from '../context/ToastContext'
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from './ui/Card'
|
import { Card, CardHeader, CardTitle, CardContent } from './ui/Card'
|
||||||
import { Button } from './ui/Button'
|
import { Button } from './ui/Button'
|
||||||
@@ -16,16 +17,23 @@ interface WorkerInfo {
|
|||||||
ip?: string
|
ip?: string
|
||||||
makemkv?: string
|
makemkv?: string
|
||||||
handbrake?: string
|
handbrake?: string
|
||||||
|
cpu_modell?: string
|
||||||
|
cpu_kerne?: string
|
||||||
|
cpu_simd?: string
|
||||||
|
handbrake_encoder?: string
|
||||||
|
rippy_version?: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ENCODER_LABELS: Record<string, string> = {
|
const ENCODER_LABELS: Record<string, string> = {
|
||||||
'cpu-x264': 'CPU · H.264',
|
'cpu-x264': 'CPU · H.264',
|
||||||
'cpu-x265': 'CPU · H.265',
|
'cpu-x265': 'CPU · H.265',
|
||||||
|
'cpu-av1': 'CPU · AV1 (SVT)',
|
||||||
'vaapi': 'GPU · VAAPI (AMD/Intel)',
|
'vaapi': 'GPU · VAAPI (AMD/Intel)',
|
||||||
'nvenc': 'GPU · NVENC (NVIDIA)',
|
'nvenc': 'GPU · NVENC (NVIDIA)',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function relativeZeit(iso?: string): string {
|
function relativeZeit(iso?: string): string {
|
||||||
if (!iso) return 'nie'
|
if (!iso) return 'nie'
|
||||||
const sekunden = Math.floor((Date.now() - new Date(iso).getTime()) / 1000)
|
const sekunden = Math.floor((Date.now() - new Date(iso).getTime()) / 1000)
|
||||||
@@ -36,6 +44,7 @@ function relativeZeit(iso?: string): string {
|
|||||||
|
|
||||||
export default function WorkerVerwaltung() {
|
export default function WorkerVerwaltung() {
|
||||||
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||||||
|
const [serverVersion, setServerVersion] = useState<string>('dev')
|
||||||
const [kopiert, setKopiert] = useState(false)
|
const [kopiert, setKopiert] = useState(false)
|
||||||
const [variante, setVariante] = useState<'linux' | 'windows'>('linux')
|
const [variante, setVariante] = useState<'linux' | 'windows'>('linux')
|
||||||
// BEWUSST leer: kein window.location.hostname-Default. Der füllte früher
|
// BEWUSST leer: kein window.location.hostname-Default. Der füllte früher
|
||||||
@@ -61,6 +70,7 @@ export default function WorkerVerwaltung() {
|
|||||||
api.get('/capabilities')
|
api.get('/capabilities')
|
||||||
.then(r => {
|
.then(r => {
|
||||||
setWorkers(r.data.workers || [])
|
setWorkers(r.data.workers || [])
|
||||||
|
if (r.data.server_version) setServerVersion(r.data.server_version)
|
||||||
if (manuell) toast('success', 'Worker-Liste aktualisiert')
|
if (manuell) toast('success', 'Worker-Liste aktualisiert')
|
||||||
})
|
})
|
||||||
.catch(() => { if (manuell) toast('error', 'Worker-Liste konnte nicht geladen werden') })
|
.catch(() => { if (manuell) toast('error', 'Worker-Liste konnte nicht geladen werden') })
|
||||||
@@ -79,9 +89,14 @@ export default function WorkerVerwaltung() {
|
|||||||
` docker compose -f deploy/remote-transcode-worker.yml up -d --build`,
|
` docker compose -f deploy/remote-transcode-worker.yml up -d --build`,
|
||||||
].join('\n')
|
].join('\n')
|
||||||
: [
|
: [
|
||||||
|
// Standard-Ziel ist "C:\Program Files\Rippy Worker" - dorthin darf nur
|
||||||
|
// ein Administrator schreiben, deshalb der Hinweis direkt im Befehl.
|
||||||
|
// Die .exe fragt selbst per UAC; hier muss die Konsole erhöht sein.
|
||||||
|
`# PowerShell als Administrator starten (Standard-Ziel: Programme)`,
|
||||||
`iwr http://${hostFuerBefehl}/api/worker-setup/windows -OutFile install-rippy-worker.ps1`,
|
`iwr http://${hostFuerBefehl}/api/worker-setup/windows -OutFile install-rippy-worker.ps1`,
|
||||||
`powershell -ExecutionPolicy Bypass -File .\\install-rippy-worker.ps1 \``,
|
`powershell -ExecutionPolicy Bypass -File .\\install-rippy-worker.ps1 \``,
|
||||||
` -RippyHost ${hostFuerBefehl} -WorkerName mein-pc`,
|
` -RippyHost ${hostFuerBefehl} -WorkerName mein-pc`,
|
||||||
|
`# Ohne Adminrechte: -InstallDir "$env:LOCALAPPDATA\\Rippy Worker" anhaengen`,
|
||||||
].join('\n')
|
].join('\n')
|
||||||
|
|
||||||
const kopieren = () => {
|
const kopieren = () => {
|
||||||
@@ -134,6 +149,23 @@ export default function WorkerVerwaltung() {
|
|||||||
{w.info?.ip ? ` · IP ${w.info.ip}` : ''}
|
{w.info?.ip ? ` · IP ${w.info.ip}` : ''}
|
||||||
{w.info?.hostname && w.info.hostname !== w.name ? ` · ID ${w.info.hostname}` : ''}
|
{w.info?.hostname && w.info.hostname !== w.name ? ` · ID ${w.info.hostname}` : ''}
|
||||||
</p>
|
</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` : ''}
|
||||||
|
{w.info?.cpu_kerne && w.info?.cpu_simd ? ' · ' : ''}
|
||||||
|
{w.info?.cpu_simd ? `Vektorbefehle ${w.info.cpu_simd}` : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{simdWarnung(w.info?.cpu_simd) && (
|
||||||
|
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400 max-w-xl">
|
||||||
|
{simdWarnung(w.info?.cpu_simd)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -180,7 +212,7 @@ export default function WorkerVerwaltung() {
|
|||||||
label="LAN-IP der Rippy-Maschine (Docker-Host)"
|
label="LAN-IP der Rippy-Maschine (Docker-Host)"
|
||||||
value={rippyHost}
|
value={rippyHost}
|
||||||
onChange={e => setRippyHost(e.target.value.trim())}
|
onChange={e => setRippyHost(e.target.value.trim())}
|
||||||
placeholder="z. B. 192.168.178.162"
|
placeholder="z. B. 192.168.1.10"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs mt-2 text-slate-500 dark:text-slate-400">
|
<p className="text-xs mt-2 text-slate-500 dark:text-slate-400">
|
||||||
Die IP der Maschine, auf der <strong>Rippy selbst</strong> (Docker) läuft — meist deine
|
Die IP der Maschine, auf der <strong>Rippy selbst</strong> (Docker) läuft — meist deine
|
||||||
@@ -192,7 +224,8 @@ export default function WorkerVerwaltung() {
|
|||||||
{siehtOeffentlichAus && (
|
{siehtOeffentlichAus && (
|
||||||
<p className="text-xs mt-2 p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-700 dark:text-amber-300">
|
<p className="text-xs mt-2 p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-700 dark:text-amber-300">
|
||||||
⚠️ Das sieht nach einer externen Domain aus (Reverse-Proxy) — die geht nicht. Bitte die
|
⚠️ Das sieht nach einer externen Domain aus (Reverse-Proxy) — die geht nicht. Bitte die
|
||||||
<strong> LAN-IP</strong> der Rippy-Maschine eintragen, z. B. 192.168.178.162.
|
<strong> LAN-IP</strong> der Rippy-Maschine eintragen — die beginnt im Heimnetz
|
||||||
|
fast immer mit 192.168., 10. oder 172.16.–172.31.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,4 +4,9 @@ import axios from 'axios'
|
|||||||
// den api-Container weiter. Vorher stand in vier Dateien http://localhost:8000
|
// den api-Container weiter. Vorher stand in vier Dateien http://localhost:8000
|
||||||
// hartkodiert: vom PC aus fragte der Browser damit den PC SELBST nach
|
// hartkodiert: vom PC aus fragte der Browser damit den PC SELBST nach
|
||||||
// Laufwerken und Jobs — deshalb blieb das UI immer leer.
|
// 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 })
|
||||||
|
|||||||
@@ -77,9 +77,20 @@ export const LOG_LEVEL_STYLES = {
|
|||||||
},
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
// Encoder-Backends, wie worker/caps.py sie meldet. Hardware wird seit
|
||||||
|
// 25.07.2026 nach FAMILIE unterschieden (nvenc/qsv/vce/vaapi) — vorher landete
|
||||||
|
// eine AMD-Karte unter dem Intel-Namen „vaapi", und AV1 in Hardware war gar
|
||||||
|
// nicht sichtbar, obwohl es die beste Kombination aus Tempo und Größe ist.
|
||||||
export const ENCODER_BADGES: Record<string, { label: string; highlight: boolean }> = {
|
export const ENCODER_BADGES: Record<string, { label: string; highlight: boolean }> = {
|
||||||
'cpu-x264': { label: 'H.264 (CPU)', highlight: false },
|
'cpu-x264': { label: 'H.264 (CPU)', highlight: false },
|
||||||
'cpu-x265': { label: 'H.265 (CPU)', highlight: false },
|
'cpu-x265': { label: 'H.265 (CPU)', highlight: false },
|
||||||
|
'cpu-av1': { label: 'AV1 (CPU)', highlight: false },
|
||||||
|
'nvenc': { label: 'NVENC ⚡ (NVIDIA)', highlight: true },
|
||||||
|
'nvenc-av1': { label: 'AV1 · NVENC ⚡', highlight: true },
|
||||||
|
'qsv': { label: 'QuickSync ⚡ (Intel)', highlight: true },
|
||||||
|
'qsv-av1': { label: 'AV1 · QuickSync ⚡', highlight: true },
|
||||||
|
'vce': { label: 'VCE ⚡ (AMD)', highlight: true },
|
||||||
|
'vce-av1': { label: 'AV1 · VCE ⚡', highlight: true },
|
||||||
'vaapi': { label: 'VAAPI ⚡', highlight: true },
|
'vaapi': { label: 'VAAPI ⚡', highlight: true },
|
||||||
'nvenc': { label: 'NVENC ⚡', highlight: true },
|
'vaapi-av1': { label: 'AV1 · VAAPI ⚡', highlight: true },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
// Encoder-Fähigkeiten eines Workers in Klartext übersetzen.
|
||||||
|
//
|
||||||
|
// Befund 25.07.2026: Die Rippy-VM lief auf dem generischen QEMU-CPU-Modell
|
||||||
|
// („QEMU Virtual CPU version 2.5+") und hatte deshalb kein AVX2, nur sse4_2.
|
||||||
|
// x265 lebt von diesen Vektorbefehlen — ein 4K-Encode brauchte dort gemessene
|
||||||
|
// 28-55 Stunden, und nirgends im UI war das zu sehen. Der Worker meldet die
|
||||||
|
// Angaben seit dieser Runde selbst (worker/caps.py), hier werden sie gedeutet.
|
||||||
|
|
||||||
|
// Stufen, mit denen Software-Encoding brauchbar schnell ist.
|
||||||
|
export const SIMD_SCHNELL = ['avx512f', 'avx2']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warnt, wenn die CPU dieses Workers zu schwach für Software-Encoding ist.
|
||||||
|
* Gibt null zurück, wenn alles in Ordnung ist ODER die Stufe unbekannt ist —
|
||||||
|
* lieber nichts sagen als etwas Falsches behaupten.
|
||||||
|
*/
|
||||||
|
export function simdWarnung(simd?: string): string | null {
|
||||||
|
if (!simd || simd === 'unbekannt') return null
|
||||||
|
if (SIMD_SCHNELL.includes(simd)) return null
|
||||||
|
return `Diese CPU kann nur ${simd} (kein AVX2) — Software-Encoding ist hier `
|
||||||
|
+ 'sehr langsam. Bei einer virtuellen Maschine hilft es meist, den '
|
||||||
|
+ 'CPU-Typ auf "host" zu stellen; sonst besser einen Worker mit '
|
||||||
|
+ 'Hardware-Encoder wählen.'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reservierter Wert im Preset-Feld: „diesen Disc-Typ NICHT komprimieren".
|
||||||
|
// Muss mit ripping.PRESET_KEINE im Worker übereinstimmen — es gibt kein
|
||||||
|
// geteiltes Paket zwischen UI und Worker, deshalb steht der Wert hier nochmal.
|
||||||
|
export const PRESET_KEINE = 'keine'
|
||||||
|
|
||||||
|
interface WorkerFuerBewertung {
|
||||||
|
encoders?: string[]
|
||||||
|
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
|
||||||
|
// Commanders funktionierte einwandfrei — der Worker nahm die Aufgabe an und
|
||||||
|
// lehnte sie 182 ms später ab, weil `/app/media/...` Pfade INNERHALB des
|
||||||
|
// Rippy-Containers sind. Ohne RIPPY_PATH_MAP sieht ein externer Worker sie nie.
|
||||||
|
// Auffallen durfte das nicht erst nach einer Stunde Rip, sondern hier.
|
||||||
|
|
||||||
|
interface WorkerFuerPfadPruefung {
|
||||||
|
name: string
|
||||||
|
node?: string | null
|
||||||
|
info?: { extern?: string, pfad_map?: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deckt `RIPPY_PATH_MAP` diesen Container-Pfad ab? Gleiche Regel wie
|
||||||
|
* `pfad_lokal()` im Worker (docker/worker/tasks.py): Paare `quelle=ziel`,
|
||||||
|
* getrennt durch `;`, Treffer per Präfix.
|
||||||
|
*/
|
||||||
|
export function pfadAbgedeckt(pfad: string, mapping: string): boolean {
|
||||||
|
if (!pfad || !mapping) return false
|
||||||
|
return mapping.split(';').some(paar => {
|
||||||
|
const quelle = paar.split('=', 1)[0]
|
||||||
|
return !!quelle && pfad.startsWith(quelle)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
* dann gibt es nichts zu übersetzen).
|
||||||
|
*
|
||||||
|
* `arbeitsPfad` leer heißt: Container-Standard /app/temp/raw — ein Docker-Volume,
|
||||||
|
* an das ein externer Worker grundsätzlich nicht herankommt.
|
||||||
|
*/
|
||||||
|
export function externWarnung(
|
||||||
|
worker: WorkerFuerPfadPruefung | undefined,
|
||||||
|
zielPfad: string,
|
||||||
|
arbeitsPfad: string,
|
||||||
|
): string | null {
|
||||||
|
if (!worker || worker.info?.extern !== 'ja') return null
|
||||||
|
const mapping = (worker.info?.pfad_map || '').trim()
|
||||||
|
if (!mapping) {
|
||||||
|
return `„${worker.name}" läuft auf einer anderen Maschine und hat keine `
|
||||||
|
+ 'Pfad-Übersetzung (RIPPY_PATH_MAP). Er kann die Rohdaten deshalb nicht '
|
||||||
|
+ 'finden und würde sofort abbrechen. Abhilfe: den Windows-Installer erneut '
|
||||||
|
+ 'ausführen — er holt den Wert jetzt selbst von Rippy. Bis dahin besser '
|
||||||
|
+ '„Automatisch" wählen.'
|
||||||
|
}
|
||||||
|
if (!arbeitsPfad) {
|
||||||
|
return `„${worker.name}" arbeitet auf einer anderen Maschine. Das `
|
||||||
|
+ 'Arbeitsverzeichnis steht auf der Container-Platte (/app/temp) — die ist '
|
||||||
|
+ 'von außen nicht erreichbar. Oben eine Netzwerk-Freigabe als '
|
||||||
|
+ 'Arbeitsverzeichnis wählen.'
|
||||||
|
}
|
||||||
|
const 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(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dasselbe Risiko bei „Automatisch": dort nimmt die geteilte Queue den ersten
|
||||||
|
* freien Worker — auch einen externen, der die Pfade nicht erreicht. Nennt die
|
||||||
|
* betroffenen Namen, damit man gezielt einen anderen wählt.
|
||||||
|
*/
|
||||||
|
export function automatikWarnung(
|
||||||
|
workers: WorkerFuerPfadPruefung[] | undefined,
|
||||||
|
zielPfad: string,
|
||||||
|
arbeitsPfad: string,
|
||||||
|
): string | null {
|
||||||
|
const betroffen = (workers || [])
|
||||||
|
.filter(w => externWarnung(w, zielPfad, arbeitsPfad) !== null)
|
||||||
|
.map(w => w.name)
|
||||||
|
if (betroffen.length === 0) return null
|
||||||
|
return `Bei „Automatisch" nimmt die Aufgabe der erste freie Worker — das kann `
|
||||||
|
+ `${betroffen.join(', ')} sein, und dort scheitert sie an den Pfaden. `
|
||||||
|
+ 'Wähle oben gezielt eine Maschine, die die Freigabe erreicht — oder rüste '
|
||||||
|
+ 'die Pfad-Übersetzung nach (Windows-Installer erneut ausführen).'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kann von den gemeldeten Workern KEINER 4K in Software brauchbar schnell
|
||||||
|
* encodieren? Wahr nur, wenn das auch belegt ist: mindestens ein Worker hat
|
||||||
|
* eine bekannte SIMD-Stufe, und keiner erreicht AVX2 — und keiner hat einen
|
||||||
|
* Hardware-Encoder, der die Frage sowieso erledigt.
|
||||||
|
*
|
||||||
|
* Bei unbekannter Stufe (z. B. Windows-Worker, dort gibt es kein
|
||||||
|
* /proc/cpuinfo) wird NICHT gewarnt. Lieber schweigen als falsch warnen.
|
||||||
|
*/
|
||||||
|
export function schwacheEncoderCpu(workers?: WorkerFuerBewertung[]): boolean {
|
||||||
|
const liste = workers || []
|
||||||
|
if (liste.length === 0) return false
|
||||||
|
const hardware = liste.some(w => (w.encoders || []).some(e => !e.startsWith('cpu')))
|
||||||
|
if (hardware) return false
|
||||||
|
const bekannte = liste
|
||||||
|
.map(w => w.info?.cpu_simd)
|
||||||
|
.filter((s): s is string => !!s && s !== 'unbekannt')
|
||||||
|
if (bekannte.length === 0) return false
|
||||||
|
return !bekannte.some(s => SIMD_SCHNELL.includes(s))
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import ConfirmDialog from '../components/ConfirmDialog'
|
|||||||
import DeviceDiscovery from '../components/DeviceDiscovery'
|
import DeviceDiscovery from '../components/DeviceDiscovery'
|
||||||
import JobDetailModal from '../components/JobDetailModal'
|
import JobDetailModal from '../components/JobDetailModal'
|
||||||
import LiveLogSection from '../components/LiveLogSection'
|
import LiveLogSection from '../components/LiveLogSection'
|
||||||
|
import RetryDialog from '../components/RetryDialog'
|
||||||
|
|
||||||
interface JobMeta {
|
interface JobMeta {
|
||||||
year?: number
|
year?: number
|
||||||
@@ -30,7 +31,13 @@ interface Job {
|
|||||||
progress: number
|
progress: number
|
||||||
title?: string
|
title?: string
|
||||||
can_retry?: boolean
|
can_retry?: boolean
|
||||||
|
// "transcode" | "rip" | "unklar" | "" — welche Wiederholung passt (phasen.py)
|
||||||
|
retry_art?: string
|
||||||
meta?: JobMeta | null
|
meta?: JobMeta | null
|
||||||
|
// Restzeit von der API (eta.py). '' = noch keine Aussage möglich — dann wird
|
||||||
|
// bewusst nichts angezeigt statt einer erfundenen Zahl.
|
||||||
|
eta_sekunden?: number
|
||||||
|
eta_text?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SystemInfo {
|
interface SystemInfo {
|
||||||
@@ -39,18 +46,66 @@ interface SystemInfo {
|
|||||||
workers: { name: string; encoders: string[] }[]
|
workers: { name: string; encoders: string[] }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ein Worker aus GET /capabilities — inklusive der ECHTEN Erreichbarkeit.
|
||||||
|
// /system/info liefert nur die registrierten Einträge; die Kachel „N Worker
|
||||||
|
// online" zählte damit auch Leichen aus alten Rebuilds mit (Befund 26.07.2026).
|
||||||
|
interface WorkerLive {
|
||||||
|
name: string
|
||||||
|
encoders: string[]
|
||||||
|
online?: boolean
|
||||||
|
info?: { cpu_modell?: string, cpu_kerne?: string, cpu_simd?: string, extern?: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
function posterUrl(meta?: JobMeta | null): string | null {
|
||||||
const p = meta?.poster_path
|
const p = meta?.poster_path
|
||||||
if (!p) return null
|
if (!p) return null
|
||||||
return p.startsWith('http') ? p : `https://image.tmdb.org/t/p/w342${p}`
|
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 {
|
try {
|
||||||
const response = await api.get('/jobs')
|
const response = await api.get('/jobs')
|
||||||
return response.data
|
return Array.isArray(response.data) ? response.data : null
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,15 +115,105 @@ export default function Dashboard() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [detailJobId, setDetailJobId] = useState<string | null>(null)
|
const [detailJobId, setDetailJobId] = useState<string | null>(null)
|
||||||
const [aufraeumenOffen, setAufraeumenOffen] = useState(false)
|
const [aufraeumenOffen, setAufraeumenOffen] = useState(false)
|
||||||
|
// Job, dessen Entfernen gerade bestätigt werden soll — plus was daneben liegt.
|
||||||
|
const [loeschKandidat, setLoeschKandidat] = useState<Job | null>(null)
|
||||||
|
const [rohdaten, setRohdaten] = useState<{ gb: number, dateien: number, pfade: string[] } | null>(null)
|
||||||
|
const [rohdatenMitloeschen, setRohdatenMitloeschen] = useState(false)
|
||||||
|
// 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 [activeTab, setActiveTab] = useState<'all' | 'active' | 'queue' | 'completed' | 'failed'>('all')
|
||||||
const [sparklinePoints, setSparklinePoints] = useState<number[]>([20, 35, 15, 45, 30, 60, 40, 75, 50, 65])
|
const [workersLive, setWorkersLive] = useState<WorkerLive[]>([])
|
||||||
|
const [laufwerke, setLaufwerke] = useState<LaufwerkLive[]>([])
|
||||||
|
const [ablagen, setAblagen] = useState<AblageZiel[]>([])
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
|
||||||
const jobLoeschen = async (job: Job) => {
|
/*
|
||||||
|
* Entfernen fragt jetzt ZUERST, was daneben liegen bleibt.
|
||||||
|
*
|
||||||
|
* Grund (Vorfall 25.07.2026, v3.14): „Job aus der Liste entfernen" löscht
|
||||||
|
* bewusst keine Dateien. Das ist richtig — aber Job und Rohdaten sind nur
|
||||||
|
* über die Job-ID verbunden, der Rohschnitt ist danach also UNERREICHBAR:
|
||||||
|
* nichts zeigt mehr darauf, „Neu komprimieren" ist unmöglich, und im UI ist
|
||||||
|
* nichts davon zu sehen. Damals verwaisten so 75 GB unsichtbar auf der
|
||||||
|
* Platte, gefunden erst per SSH.
|
||||||
|
*/
|
||||||
|
const loeschenVorbereiten = async (job: Job) => {
|
||||||
|
setLoeschKandidat(job)
|
||||||
|
setRohdaten(null)
|
||||||
|
setRohdatenMitloeschen(false)
|
||||||
try {
|
try {
|
||||||
await api.delete(`/jobs/${job.id}`)
|
const r = await api.get(`/jobs/${job.id}/rohdaten`)
|
||||||
toast('success', `„${job.title || job.id.slice(0, 8)}" aus der Liste entfernt`)
|
setRohdaten(r.data)
|
||||||
setJobs(await fetchJobs())
|
} catch {
|
||||||
|
setRohdaten(null) // dann eben ohne Zahl fragen, statt gar nicht
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
setLoeschKandidat(null)
|
||||||
|
try {
|
||||||
|
const r = await api.delete(`/jobs/${job.id}`, {
|
||||||
|
params: rohdatenMitloeschen ? { rohdaten: true } : {},
|
||||||
|
})
|
||||||
|
const befreit = r.data?.rohdaten_geloescht_gb
|
||||||
|
toast('success', `„${job.title || job.id.slice(0, 8)}" aus der Liste entfernt`
|
||||||
|
+ (befreit ? ` — ${befreit} GB Rohdaten gelöscht` : ''))
|
||||||
|
const frisch = await fetchJobs()
|
||||||
|
if (frisch) setJobs(frisch)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast('error', e?.response?.data?.detail || 'Entfernen fehlgeschlagen')
|
toast('error', e?.response?.data?.detail || 'Entfernen fehlgeschlagen')
|
||||||
}
|
}
|
||||||
@@ -79,33 +224,71 @@ export default function Dashboard() {
|
|||||||
try {
|
try {
|
||||||
const r = await api.delete('/jobs')
|
const r = await api.delete('/jobs')
|
||||||
toast('success', `${r.data.deleted} erledigte Jobs entfernt — Dateien bleiben liegen`)
|
toast('success', `${r.data.deleted} erledigte Jobs entfernt — Dateien bleiben liegen`)
|
||||||
setJobs(await fetchJobs())
|
const frisch = await fetchJobs()
|
||||||
|
if (frisch) setJobs(frisch)
|
||||||
} catch {
|
} catch {
|
||||||
toast('error', 'Aufräumen fehlgeschlagen')
|
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(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const nichts = () => null
|
||||||
try {
|
|
||||||
const [jobsData, sysData] = await Promise.all([
|
|
||||||
fetchJobs(),
|
|
||||||
api.get('/system/info').then(r => r.data).catch(() => null),
|
|
||||||
])
|
|
||||||
setJobs(jobsData)
|
|
||||||
if (sysData) setSystemInfo(sysData)
|
|
||||||
|
|
||||||
const isBusy = jobsData.some((j: Job) => j.status === 'processing' || j.status === 'transcoding')
|
// Schnell (4 s): was sich während eines Rips wirklich bewegt.
|
||||||
const newPoint = isBusy ? Math.floor(Math.random() * 40) + 60 : Math.floor(Math.random() * 25) + 10
|
const schnellLaden = async () => {
|
||||||
setSparklinePoints(prev => [...prev.slice(1), newPoint])
|
try {
|
||||||
|
const [jobsData, devData] = await Promise.all([
|
||||||
|
fetchJobs(),
|
||||||
|
// 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),
|
||||||
|
])
|
||||||
|
if (jobsData) setJobs(jobsData)
|
||||||
|
if (devData) setLaufwerke(devData)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadData()
|
// Langsam (12 s): Hardware, Worker-Liste, Ablagen. Ein Worker, der online
|
||||||
const interval = setInterval(loadData, 4000)
|
// geht, oder eine Freigabe, die wegbricht, darf drei Takte später auffallen.
|
||||||
return () => clearInterval(interval)
|
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')
|
const aktiverJob = jobs.find(j => j.status === 'processing' || j.status === 'transcoding')
|
||||||
@@ -113,18 +296,54 @@ export default function Dashboard() {
|
|||||||
const completedJobs = jobs.filter(j => j.status === 'completed')
|
const completedJobs = jobs.filter(j => j.status === 'completed')
|
||||||
const failedJobs = jobs.filter(j => j.status === 'failed')
|
const failedJobs = jobs.filter(j => j.status === 'failed')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Was diese Karte anzeigt — und was sie NICHT mehr anzeigt.
|
||||||
|
*
|
||||||
|
* Commander 26.07.2026: „Der Server Status muss dringend überarbeitet werden,
|
||||||
|
* das Dashboard soll ja quasi alles auf einen Blick zeigen." Berechtigt: Die
|
||||||
|
* Karte hieß „Echte Live-Daten" und enthielt drei Angaben, von denen zwei
|
||||||
|
* erfunden waren.
|
||||||
|
*
|
||||||
|
* 1. „Auslastung: 0 % (Aktiv)" war NICHT die CPU-Last, sondern der
|
||||||
|
* Fortschritt des Jobs — bzw. eine feste 15/5, wenn keiner lief. Rippy
|
||||||
|
* misst nirgends CPU-Last, also wird sie auch nicht behauptet.
|
||||||
|
* 2. Die Verlaufskurve daneben war `Math.random()`. Reine Dekoration, die
|
||||||
|
* wie eine Messung aussah.
|
||||||
|
* 3. „N Worker Online" zählte die registrierten Einträge aus
|
||||||
|
* /system/info — auch Leichen alter Container-Rebuilds. Die echte
|
||||||
|
* Erreichbarkeit steht in /capabilities (Celery-Ping).
|
||||||
|
*
|
||||||
|
* Statt dessen stehen jetzt Angaben, die es wirklich gibt: laufende Phase mit
|
||||||
|
* Restzeit, erreichbare Worker mit ihrer Rechenleistung, freier Platz.
|
||||||
|
*/
|
||||||
const mainPlatz = systemInfo?.plaetze?.[0]
|
const mainPlatz = systemInfo?.plaetze?.[0]
|
||||||
const freiGb = mainPlatz ? mainPlatz.frei_gb : 0
|
const freiGb = mainPlatz ? mainPlatz.frei_gb : 0
|
||||||
const gesamtGb = mainPlatz ? mainPlatz.gesamt_gb : 1
|
const gesamtGb = mainPlatz ? mainPlatz.gesamt_gb : 1
|
||||||
const belegtPercent = Math.min(100, Math.max(0, Math.round(((gesamtGb - freiGb) / (gesamtGb || 1)) * 100)))
|
const belegtPercent = Math.min(100, Math.max(0, Math.round(((gesamtGb - freiGb) / (gesamtGb || 1)) * 100)))
|
||||||
const cpuPercent = aktiverJob ? aktiverJob.progress : (jobs.length > 0 ? 15 : 5)
|
const workerOnline = workersLive.filter(w => w.online)
|
||||||
const workerCount = systemInfo?.workers?.length || 1
|
const encoderWorker = workerOnline.filter(w => (w.encoders || []).length > 0)
|
||||||
|
const hardwareWorker = workerOnline.filter(w => (w.encoders || []).some(e => !e.startsWith('cpu')))
|
||||||
const sparklinePath = sparklinePoints.map((val, idx) => {
|
/*
|
||||||
const x = (idx / (sparklinePoints.length - 1)) * 100
|
* Phase im Klartext — „processing" heißt rippen, „transcoding" komprimieren.
|
||||||
const y = 50 - (val / 100) * 40
|
*
|
||||||
return `${idx === 0 ? 'M' : 'L'} ${x} ${y}`
|
* Die eingelegte Disc gehört mit hinein (Commander-Befund 26.07.2026): Vorher
|
||||||
}).join(' ')
|
* 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 => {
|
const filteredJobs = jobs.filter(j => {
|
||||||
if (activeTab === 'active') return j.status === 'processing' || j.status === 'transcoding'
|
if (activeTab === 'active') return j.status === 'processing' || j.status === 'transcoding'
|
||||||
@@ -271,11 +490,17 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 w-full sm:w-auto justify-end">
|
<div className="flex items-center gap-4 w-full sm:w-auto justify-end">
|
||||||
<div className="w-36 space-y-1 text-right">
|
<div className="w-40 space-y-1 text-right">
|
||||||
<div className="text-xs font-mono text-amber-400 font-bold">{aktiverJob.progress}%</div>
|
<div className="text-xs font-mono text-amber-400 font-bold">{aktiverJob.progress}%</div>
|
||||||
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
||||||
<div className="h-full bg-amber-500 rounded-full transition-all duration-300" style={{ width: `${aktiverJob.progress}%` }} />
|
<div className="h-full bg-amber-500 rounded-full transition-all duration-300" style={{ width: `${aktiverJob.progress}%` }} />
|
||||||
</div>
|
</div>
|
||||||
|
{/* Die Angabe, deren Fehlen den 50-Stunden-Lauf am 25.07.2026
|
||||||
|
unsichtbar machte. Ohne belastbare Messreihe steht hier
|
||||||
|
ehrlich, dass noch gemessen wird (siehe api/eta.py). */}
|
||||||
|
<div className="text-[11px] font-mono text-slate-300">
|
||||||
|
{aktiverJob.eta_text || 'Restzeit wird gemessen …'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="amber" size="sm" onClick={() => setDetailJobId(aktiverJob.id)}>
|
<Button variant="amber" size="sm" onClick={() => setDetailJobId(aktiverJob.id)}>
|
||||||
Details
|
Details
|
||||||
@@ -360,6 +585,9 @@ export default function Dashboard() {
|
|||||||
style={{ width: `${job.progress}%` }}
|
style={{ width: `${job.progress}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{job.eta_text && (
|
||||||
|
<div className="text-[10px] font-mono text-slate-500">{job.eta_text}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-xs font-mono text-slate-400">
|
<td className="px-6 py-4 whitespace-nowrap text-xs font-mono text-slate-400">
|
||||||
@@ -377,25 +605,25 @@ export default function Dashboard() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{job.status === 'failed' && job.can_retry && (
|
{job.status === 'failed' && (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => wiederholen(job)}
|
||||||
api.post(`/jobs/${job.id}/retry-transcode`)
|
title="Fragt vorher, was genau wiederholt wird"
|
||||||
.then(() => toast('success', 'Kompression neu eingereiht'))
|
|
||||||
.catch((e: any) => toast('error', e?.response?.data?.detail || 'Fehlgeschlagen'))
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<RotateCcw size={13} /> Neu
|
<RotateCcw size={13} />
|
||||||
|
{retryPlan(job) === 'transcode' ? 'Neu komprimieren'
|
||||||
|
: retryPlan(job) === 'rip' ? 'Neu rippen'
|
||||||
|
: 'Neu …'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(job.status === 'completed' || job.status === 'failed') && (
|
{(job.status === 'completed' || job.status === 'failed') && (
|
||||||
<button
|
<button
|
||||||
onClick={() => jobLoeschen(job)}
|
onClick={() => loeschenVorbereiten(job)}
|
||||||
className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors"
|
className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors"
|
||||||
title="Löschen"
|
title="Aus der Liste entfernen"
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
@@ -421,57 +649,153 @@ export default function Dashboard() {
|
|||||||
<CardHeader className="border-b border-slate-800">
|
<CardHeader className="border-b border-slate-800">
|
||||||
<CardTitle className="text-slate-200 flex items-center gap-2">
|
<CardTitle className="text-slate-200 flex items-center gap-2">
|
||||||
<Server size={18} className="text-amber-400" />
|
<Server size={18} className="text-amber-400" />
|
||||||
Server Status (Echte Live-Daten)
|
Server-Status
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-5 flex-1 flex items-center">
|
<CardContent className="p-5 flex-1 flex flex-col justify-center">
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 w-full">
|
<div className="space-y-4 w-full">
|
||||||
<div className="space-y-4 flex-1 w-full">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
|
||||||
<span className="text-slate-400 flex items-center gap-1.5"><Cpu size={14} className="text-amber-400" /> Auslastung:</span>
|
|
||||||
<span className="text-amber-400 font-bold">{aktiverJob ? `${cpuPercent}% (Aktiv)` : 'Standby (0%)'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="h-full bg-amber-500 rounded-full transition-all duration-500"
|
|
||||||
style={{ width: `${aktiverJob ? Math.max(15, cpuPercent) : 5}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
{/* 1. Was tut Rippy JETZT — mit Restzeit statt Phantasie-Last */}
|
||||||
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
<div>
|
||||||
<span className="text-slate-400 flex items-center gap-1.5"><Activity size={14} className="text-amber-400" /> Worker Status:</span>
|
<div className="flex items-center justify-between text-xs font-mono mb-1 gap-2">
|
||||||
<span className="text-slate-200">{workerCount} Worker Online</span>
|
<span className="text-slate-400 flex items-center gap-1.5 flex-shrink-0">
|
||||||
</div>
|
<Activity size={14} className="text-amber-400" /> Gerade:
|
||||||
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
</span>
|
||||||
<div className="h-full bg-emerald-500 rounded-full w-full" />
|
<span className={`text-right ${aktiverJob ? 'text-amber-400 font-bold' : 'text-slate-300'}`}>
|
||||||
</div>
|
{phaseText}
|
||||||
</div>
|
</span>
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
|
||||||
<span className="text-slate-400 flex items-center gap-1.5"><HardDrive size={14} className="text-amber-400" /> Speichermounts:</span>
|
|
||||||
<span className="text-slate-200">{freiGb > 0 ? `${freiGb} GB frei` : 'Aktiv'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="h-full bg-amber-500/80 rounded-full transition-all duration-500"
|
|
||||||
style={{ width: `${Math.max(10, belegtPercent)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{aktiverJob ? (
|
||||||
|
<>
|
||||||
|
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-amber-500 rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${Math.max(2, aktiverJob.progress)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-[11px] font-mono mt-1">
|
||||||
|
<span className="text-slate-300 truncate pr-2">
|
||||||
|
{aktiverJob.title || aktiverJob.id.slice(0, 8)} · {aktiverJob.progress} %
|
||||||
|
</span>
|
||||||
|
{/* Leer heißt: noch keine belastbare Schätzung. Dann wird
|
||||||
|
das auch so gesagt — nicht geraten (siehe eta.py). */}
|
||||||
|
<span className="text-amber-400 flex-shrink-0">
|
||||||
|
{aktiverJob.eta_text || 'Restzeit wird noch gemessen'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-[11px] font-mono text-slate-500">
|
||||||
|
{queueJobs.length > 0
|
||||||
|
? `${queueJobs.length} Job(s) warten in der Schlange`
|
||||||
|
: discImLaufwerk
|
||||||
|
? `${discImLaufwerk.disc!.title}`
|
||||||
|
+ (discImLaufwerk.disc!.year ? ` (${discImLaufwerk.disc!.year})` : '')
|
||||||
|
+ ' liegt bereit — oben auf „Rippen starten"'
|
||||||
|
: 'Disc einlegen — Rippy erkennt sie selbst'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full sm:w-40 h-24 flex-shrink-0 flex items-end justify-center pt-2">
|
{/* 2. Worker — die ECHTE Erreichbarkeit, plus was sie können */}
|
||||||
<svg viewBox="0 0 100 50" className="w-full h-full stroke-amber-400 fill-amber-500/10 transition-all duration-500">
|
<div>
|
||||||
<path
|
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
||||||
d={`${sparklinePath} L 100 50 L 0 50 Z`}
|
<span className="text-slate-400 flex items-center gap-1.5">
|
||||||
strokeWidth="2.5"
|
<Cpu size={14} className="text-amber-400" /> Worker erreichbar:
|
||||||
strokeLinecap="round"
|
</span>
|
||||||
|
<span className={workerOnline.length > 0 ? 'text-slate-200' : 'text-rose-400 font-bold'}>
|
||||||
|
{workerOnline.length} von {workersLive.length}
|
||||||
|
{hardwareWorker.length > 0 ? ' · Hardware-Encoder dabei' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{workerOnline.length === 0 ? (
|
||||||
|
<p className="text-[11px] font-mono text-rose-400">
|
||||||
|
Kein Worker antwortet — ohne ihn läuft kein Rip.
|
||||||
|
Prüfen: <span className="text-slate-300">docker compose ps</span>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{workerOnline.map(w => (
|
||||||
|
<p key={w.name} className="text-[11px] font-mono text-slate-400 truncate">
|
||||||
|
<span className="text-emerald-400">●</span> {w.name}
|
||||||
|
{w.info?.cpu_kerne ? ` · ${w.info.cpu_kerne} Kerne` : ''}
|
||||||
|
{w.info?.cpu_simd && w.info.cpu_simd !== 'unbekannt' ? ` · ${w.info.cpu_simd}` : ''}
|
||||||
|
{w.info?.extern === 'ja' ? ' · extern' : ''}
|
||||||
|
{(w.encoders || []).length === 0 ? ' · kann nicht komprimieren' : ''}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
{encoderWorker.length === 0 && (
|
||||||
|
<p className="text-[11px] font-mono text-amber-400">
|
||||||
|
Keiner dieser Worker hat HandBrake — es wird nur gerippt, nicht komprimiert.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3. Platz — 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" /> 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'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all duration-500 ${
|
||||||
|
belegtPercent > 90 ? 'bg-rose-500' : 'bg-amber-500/80'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${Math.max(2, belegtPercent)}%` }}
|
||||||
/>
|
/>
|
||||||
</svg>
|
</div>
|
||||||
|
{freiGb > 0 && freiGb < 60 && 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>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -548,6 +872,49 @@ export default function Dashboard() {
|
|||||||
confirmText="Aufräumen"
|
confirmText="Aufräumen"
|
||||||
type="warning"
|
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}
|
||||||
|
onClose={() => setLoeschKandidat(null)}
|
||||||
|
onConfirm={jobLoeschen}
|
||||||
|
title={`„${loeschKandidat?.title || loeschKandidat?.id.slice(0, 8) || ''}" entfernen?`}
|
||||||
|
message="Der Eintrag verschwindet aus der Liste. Die fertig abgelegten Dateien bleiben, wo sie sind."
|
||||||
|
confirmText={rohdatenMitloeschen ? 'Entfernen und löschen' : 'Entfernen'}
|
||||||
|
type="warning"
|
||||||
|
extra={rohdaten && rohdaten.gb > 0 ? (
|
||||||
|
<div className="mt-3 text-xs p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-700 dark:text-amber-300 space-y-2">
|
||||||
|
<p>
|
||||||
|
<strong>Daneben liegen {rohdaten.gb} GB Rohdaten</strong>
|
||||||
|
{rohdaten.dateien > 0 ? ` (${rohdaten.dateien} Datei${rohdaten.dateien === 1 ? '' : 'en'})` : ''}.
|
||||||
|
Ohne den Eintrag zeigt nichts mehr darauf — sie sind dann über Rippy
|
||||||
|
nicht mehr erreichbar und „Neu komprimieren" ist unmöglich.
|
||||||
|
</p>
|
||||||
|
{rohdaten.pfade.map(p => (
|
||||||
|
<p key={p} className="font-mono break-all opacity-80">{p}</p>
|
||||||
|
))}
|
||||||
|
<label className="flex items-start gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={rohdatenMitloeschen}
|
||||||
|
onChange={e => setRohdatenMitloeschen(e.target.checked)}
|
||||||
|
className="w-4 h-4 mt-0.5 accent-rose-500 rounded"
|
||||||
|
/>
|
||||||
|
<span>Rohdaten jetzt mitlöschen und {rohdaten.gb} GB freigeben</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,10 +70,28 @@ export default function LogsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredLogs = filter === 'all'
|
const filteredLogs = filter === 'all'
|
||||||
? logs
|
? logs
|
||||||
: logs.filter(log => log.level === filter || log.source === filter)
|
: 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 = {
|
const stats = {
|
||||||
info: logs.filter(l => l.level === 'info').length,
|
info: logs.filter(l => l.level === 'info').length,
|
||||||
warning: logs.filter(l => l.level === 'warning').length,
|
warning: logs.filter(l => l.level === 'warning').length,
|
||||||
@@ -162,6 +180,25 @@ export default function LogsPage() {
|
|||||||
{f === 'all' ? 'Alle' : getLevelLabel(f)}
|
{f === 'all' ? 'Alle' : getLevelLabel(f)}
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<div className="max-h-[600px] overflow-y-auto">
|
<div className="max-h-[600px] overflow-y-auto">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Button } from '../components/ui/Button'
|
|||||||
import { Input, Select, Toggle } from '../components/ui/Input'
|
import { Input, Select, Toggle } from '../components/ui/Input'
|
||||||
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
|
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
|
||||||
import { ENCODER_BADGES } from '../lib/design'
|
import { ENCODER_BADGES } from '../lib/design'
|
||||||
|
import { PRESET_KEINE, schwacheEncoderCpu, simdWarnung } from '../lib/encoder'
|
||||||
|
|
||||||
interface SettingsState {
|
interface SettingsState {
|
||||||
tmdbApiKey: string
|
tmdbApiKey: string
|
||||||
@@ -19,7 +20,6 @@ interface SettingsState {
|
|||||||
movieDir: string
|
movieDir: string
|
||||||
seriesDir: string
|
seriesDir: string
|
||||||
musicDir: string
|
musicDir: string
|
||||||
ripAllTracks: boolean
|
|
||||||
mainFeatureOnly: boolean
|
mainFeatureOnly: boolean
|
||||||
autoEject: boolean
|
autoEject: boolean
|
||||||
autoRipStart: boolean
|
autoRipStart: boolean
|
||||||
@@ -38,6 +38,10 @@ interface SettingsState {
|
|||||||
jellyfinApiKey: string
|
jellyfinApiKey: string
|
||||||
workDir: string
|
workDir: string
|
||||||
makemkvAppKey: 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 = {
|
const defaultSettings: SettingsState = {
|
||||||
@@ -48,7 +52,6 @@ const defaultSettings: SettingsState = {
|
|||||||
movieDir: 'movies',
|
movieDir: 'movies',
|
||||||
seriesDir: 'series',
|
seriesDir: 'series',
|
||||||
musicDir: 'music',
|
musicDir: 'music',
|
||||||
ripAllTracks: true,
|
|
||||||
mainFeatureOnly: false,
|
mainFeatureOnly: false,
|
||||||
autoEject: true,
|
autoEject: true,
|
||||||
autoRipStart: false,
|
autoRipStart: false,
|
||||||
@@ -64,6 +67,11 @@ const defaultSettings: SettingsState = {
|
|||||||
jellyfinApiKey: '',
|
jellyfinApiKey: '',
|
||||||
workDir: '',
|
workDir: '',
|
||||||
makemkvAppKey: '',
|
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'
|
type SettingsTab = 'ripping' | 'verarbeitung' | 'worker' | 'speicherziele' | 'apis' | 'benachrichtigungen' | 'system'
|
||||||
@@ -73,8 +81,18 @@ interface WorkerInfo {
|
|||||||
encoders: string[]
|
encoders: string[]
|
||||||
// keydb: "ja" | "nein" | "unbekannt" — sagt, ob DIESER Worker eine KEYDB.cfg
|
// keydb: "ja" | "nein" | "unbekannt" — sagt, ob DIESER Worker eine KEYDB.cfg
|
||||||
// in seinem MakeMKV-Datenverzeichnis sieht. Nur der Worker, der wirklich
|
// in seinem MakeMKV-Datenverzeichnis sieht. Nur der Worker, der wirklich
|
||||||
// rippt, zaehlt, deshalb steht die Angabe pro Worker und nicht global.
|
// rippt, zählt, deshalb steht die Angabe pro Worker und nicht global.
|
||||||
info?: { makemkv?: string, handbrake?: string, makemkv_key?: string, keydb?: string }
|
// cpu_kerne/cpu_simd: Rechenleistung fürs Software-Encodieren. Ohne diese
|
||||||
|
// Angaben war am 25.07.2026 nicht zu sehen, dass ein 4K-Encode auf der
|
||||||
|
// Rippy-VM Tage statt Stunden braucht (kein AVX2, 4 Kerne).
|
||||||
|
// handbrake_encoder: die ungefilterte Encoder-Auskunft von HandBrake selbst.
|
||||||
|
// extern: "ja" = läuft AUSSERHALB des Rippy-Containers, sieht /app/... also
|
||||||
|
// nur über eine Freigabe plus pfad_map. Der Worker meldet das selbst.
|
||||||
|
info?: {
|
||||||
|
makemkv?: string, handbrake?: string, makemkv_key?: string, keydb?: string,
|
||||||
|
cpu_modell?: string, cpu_kerne?: string, cpu_simd?: string,
|
||||||
|
handbrake_encoder?: string, extern?: string, pfad_map?: string,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Antwort von GET/POST/DELETE /system/keydb (Feldnamen exakt wie die API sie liefert).
|
// Antwort von GET/POST/DELETE /system/keydb (Feldnamen exakt wie die API sie liefert).
|
||||||
@@ -94,7 +112,7 @@ interface StorageZiel {
|
|||||||
free_gb: number | null
|
free_gb: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Antwort von GET/POST /system/keystore — MakeMKVs eigener Schluesselvorrat.
|
// Antwort von GET/POST /system/keystore — MakeMKVs eigener Schlüsselvorrat.
|
||||||
interface KeystoreStatus {
|
interface KeystoreStatus {
|
||||||
vorhanden: boolean
|
vorhanden: boolean
|
||||||
pfad: string
|
pfad: string
|
||||||
@@ -118,7 +136,38 @@ interface SystemInfo {
|
|||||||
webhook_gesetzt: boolean
|
webhook_gesetzt: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// Byte-Zahl menschenlesbar — eine echte KEYDB.cfg ist mehrere MB gross.
|
// Antwort von GET /presets — die Preset-Namen kommen aus
|
||||||
|
// `HandBrakeCLI --preset-list` des jeweiligen Workers, die Empfehlung samt
|
||||||
|
// Begründung aus api/presets.py (dort getestet). Bis 26.07.2026 standen die
|
||||||
|
// Namen fest verdrahtet in dieser Datei; sie unterscheiden sich aber zwischen
|
||||||
|
// HandBrake-Versionen, und ein Name, den das HandBrake des Workers nicht kennt,
|
||||||
|
// lässt die Kompression scheitern.
|
||||||
|
interface PresetEmpfehlung {
|
||||||
|
preset: string
|
||||||
|
grund: string
|
||||||
|
gefunden: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PresetTyp {
|
||||||
|
empfehlung: PresetEmpfehlung
|
||||||
|
auswahl: { name: string, verkleinert: boolean }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PresetUebersicht {
|
||||||
|
quelle: 'worker' | 'rueckfall'
|
||||||
|
hardware: string[]
|
||||||
|
simd_schnell: boolean
|
||||||
|
typen: Record<'uhd' | 'bluray' | 'dvd', PresetTyp>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Welches Einstellungs-Feld gehört zu welchem Disc-Typ.
|
||||||
|
const PRESET_FELD: Record<'uhd' | 'bluray' | 'dvd', keyof SettingsState> = {
|
||||||
|
uhd: 'transcodePresetUhd',
|
||||||
|
bluray: 'transcodePresetBluray',
|
||||||
|
dvd: 'transcodePresetDvd',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Byte-Zahl menschenlesbar — eine echte KEYDB.cfg ist mehrere MB groß.
|
||||||
function bytesLesbar(bytes: number): string {
|
function bytesLesbar(bytes: number): string {
|
||||||
if (!bytes) return '0 B'
|
if (!bytes) return '0 B'
|
||||||
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||||
@@ -127,7 +176,7 @@ function bytesLesbar(bytes: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Die API liefert ISO-8601 in UTC (oder einen leeren String). Ohne Zonen-Kennung
|
// Die API liefert ISO-8601 in UTC (oder einen leeren String). Ohne Zonen-Kennung
|
||||||
// wuerde der Browser den Zeitstempel als Ortszeit lesen und die Uhrzeit um den
|
// würde der Browser den Zeitstempel als Ortszeit lesen und die Uhrzeit um den
|
||||||
// Zonen-Versatz verschieben — deshalb notfalls ein Z anhaengen.
|
// Zonen-Versatz verschieben — deshalb notfalls ein Z anhaengen.
|
||||||
function zeitLesbar(iso: string): string {
|
function zeitLesbar(iso: string): string {
|
||||||
if (!iso) return 'unbekannt'
|
if (!iso) return 'unbekannt'
|
||||||
@@ -151,7 +200,7 @@ export default function SettingsPage() {
|
|||||||
const [updatesBusy, setUpdatesBusy] = useState(false)
|
const [updatesBusy, setUpdatesBusy] = useState(false)
|
||||||
// KEYDB.cfg und AACS-Dumps sind DATEIEN im MakeMKV-Datenverzeichnis, keine
|
// KEYDB.cfg und AACS-Dumps sind DATEIEN im MakeMKV-Datenverzeichnis, keine
|
||||||
// Einstellungen — deshalb bewusst NICHT in SettingsState: der globale
|
// Einstellungen — deshalb bewusst NICHT in SettingsState: der globale
|
||||||
// Speichern-Knopf postet dieses Objekt komplett und wuerde sie mitschleifen.
|
// Speichern-Knopf postet dieses Objekt komplett und würde sie mitschleifen.
|
||||||
const [keydb, setKeydb] = useState<KeydbStatus | null>(null)
|
const [keydb, setKeydb] = useState<KeydbStatus | null>(null)
|
||||||
const [keydbBusy, setKeydbBusy] = useState(false)
|
const [keydbBusy, setKeydbBusy] = useState(false)
|
||||||
const [dumps, setDumps] = useState<AacsDump[]>([])
|
const [dumps, setDumps] = useState<AacsDump[]>([])
|
||||||
@@ -162,6 +211,8 @@ export default function SettingsPage() {
|
|||||||
const [keystore, setKeystore] = useState<KeystoreStatus | null>(null)
|
const [keystore, setKeystore] = useState<KeystoreStatus | null>(null)
|
||||||
const [keystoreBusy, setKeystoreBusy] = useState(false)
|
const [keystoreBusy, setKeystoreBusy] = useState(false)
|
||||||
const keystoreInput = useRef<HTMLInputElement>(null)
|
const keystoreInput = useRef<HTMLInputElement>(null)
|
||||||
|
// Preset-Namen + Empfehlung, beides von der API (siehe PresetUebersicht).
|
||||||
|
const [presetInfo, setPresetInfo] = useState<PresetUebersicht | null>(null)
|
||||||
|
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
|
||||||
@@ -193,7 +244,7 @@ export default function SettingsPage() {
|
|||||||
// letzten Herzschlag des Workers (minuetlich) — ohne dieses Nachladen
|
// letzten Herzschlag des Workers (minuetlich) — ohne dieses Nachladen
|
||||||
// widerspraeche die Kachel nach einem Upload sichtbar der Erfolgsmeldung.
|
// widerspraeche die Kachel nach einem Upload sichtbar der Erfolgsmeldung.
|
||||||
// Ganz sofort ist sie trotzdem nicht: der Worker meldet erst beim
|
// Ganz sofort ist sie trotzdem nicht: der Worker meldet erst beim
|
||||||
// naechsten Herzschlag neu. Genau so steht es auch im Erklaertext.
|
// nächsten Herzschlag neu. Genau so steht es auch im Erklaertext.
|
||||||
api.get('/capabilities').then(r => setWorkers(r.data.workers || [])).catch(() => setWorkers([]))
|
api.get('/capabilities').then(r => setWorkers(r.data.workers || [])).catch(() => setWorkers([]))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +252,7 @@ export default function SettingsPage() {
|
|||||||
setKeydbBusy(true)
|
setKeydbBusy(true)
|
||||||
try {
|
try {
|
||||||
// Bewusst als JSON-Text und nicht als Multipart-Upload: der API fehlt
|
// Bewusst als JSON-Text und nicht als Multipart-Upload: der API fehlt
|
||||||
// python-multipart, ein File()-Endpunkt wuerde sie beim Import killen.
|
// python-multipart, ein File()-Endpunkt würde sie beim Import killen.
|
||||||
const inhalt = await datei.text()
|
const inhalt = await datei.text()
|
||||||
await api.post('/system/keydb', { inhalt })
|
await api.post('/system/keydb', { inhalt })
|
||||||
keydbLaden()
|
keydbLaden()
|
||||||
@@ -282,8 +333,90 @@ export default function SettingsPage() {
|
|||||||
api.get('/system/aacs-dumps')
|
api.get('/system/aacs-dumps')
|
||||||
.then(r => setDumps(r.data?.dumps || []))
|
.then(r => setDumps(r.data?.dumps || []))
|
||||||
.catch(() => setDumps([]))
|
.catch(() => setDumps([]))
|
||||||
|
api.get('/presets')
|
||||||
|
.then(r => setPresetInfo(r.data))
|
||||||
|
.catch(() => setPresetInfo(null))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// „Bestes automatisch wählen" (Commander-Anforderung 26.07.2026). Setzt alle
|
||||||
|
// drei Disc-Typen auf die Empfehlung der API — und nur solche, die wirklich
|
||||||
|
// gefunden wurden, damit nie ein Name landet, den HandBrake nicht kennt.
|
||||||
|
const bestePresetsUebernehmen = () => {
|
||||||
|
if (!presetInfo) return
|
||||||
|
const neu: Partial<SettingsState> = {}
|
||||||
|
const uebernommen: string[] = []
|
||||||
|
for (const typ of ['uhd', 'bluray', 'dvd'] as const) {
|
||||||
|
const e = presetInfo.typen[typ]?.empfehlung
|
||||||
|
if (!e?.gefunden || !e.preset) continue
|
||||||
|
neu[PRESET_FELD[typ]] = e.preset as any
|
||||||
|
uebernommen.push(`${typ.toUpperCase()}: ${e.preset === PRESET_KEINE ? 'nicht komprimieren' : e.preset}`)
|
||||||
|
}
|
||||||
|
if (uebernommen.length === 0) {
|
||||||
|
toast('error', 'Kein Preset übernommen — die Worker melden keine bekannten Namen.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSettings(prev => ({ ...prev, ...neu }))
|
||||||
|
setSaved(false)
|
||||||
|
toast('success', `Übernommen — ${uebernommen.join(' · ')}. Noch speichern!`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eine Preset-Auswahl je Disc-Typ. Die Optionen kommen vom WORKER (siehe
|
||||||
|
// PresetUebersicht), nicht aus dieser Datei. Der aktuell gespeicherte Wert
|
||||||
|
// steht immer mit drin — sonst würde eine Bestandseinstellung beim Öffnen der
|
||||||
|
// Seite still auf etwas anderes springen.
|
||||||
|
const presetAuswahl = (
|
||||||
|
typ: 'uhd' | 'bluray' | 'dvd', label: string, keineText: string,
|
||||||
|
) => {
|
||||||
|
const feld = PRESET_FELD[typ]
|
||||||
|
const wert = (settings[feld] as string) || ''
|
||||||
|
const daten = presetInfo?.typen?.[typ]
|
||||||
|
const empfehlung = daten?.empfehlung
|
||||||
|
const passend = (daten?.auswahl || []).filter(o => !o.verkleinert)
|
||||||
|
const kleiner = (daten?.auswahl || []).filter(o => o.verkleinert)
|
||||||
|
const bekannt = wert === PRESET_KEINE
|
||||||
|
|| (daten?.auswahl || []).some(o => o.name === wert)
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Select label={label} value={wert} onChange={(e) => handleChange(feld, e.target.value)}>
|
||||||
|
<option value={PRESET_KEINE}>{keineText}</option>
|
||||||
|
{/* Ein gespeicherter Wert, den dieser Worker nicht kennt, wird
|
||||||
|
BENANNT statt verschluckt — genau so fällt eine falsche
|
||||||
|
Bestandseinstellung überhaupt auf. */}
|
||||||
|
{!bekannt && wert && (
|
||||||
|
<option value={wert}>{wert} — gespeichert, dieser Worker kennt es nicht</option>
|
||||||
|
)}
|
||||||
|
{passend.length > 0 && (
|
||||||
|
<optgroup label="Passt zur Auflösung">
|
||||||
|
{passend.map(o => (
|
||||||
|
<option key={o.name} value={o.name}>
|
||||||
|
{o.name}{empfehlung?.gefunden && empfehlung.preset === o.name ? ' ★ empfohlen' : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
|
{kleiner.length > 0 && (
|
||||||
|
<optgroup label="Bewusst verkleinern — die 4K-Auflösung ist danach weg">
|
||||||
|
{kleiner.map(o => <option key={o.name} value={o.name}>{o.name}</option>)}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
|
</Select>
|
||||||
|
{empfehlung && empfehlung.preset !== wert && (
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{empfehlung.gefunden ? (
|
||||||
|
<>
|
||||||
|
Empfohlen wäre{' '}
|
||||||
|
<strong className="text-slate-700 dark:text-slate-300">
|
||||||
|
{empfehlung.preset === PRESET_KEINE ? 'nicht komprimieren' : empfehlung.preset}
|
||||||
|
</strong>
|
||||||
|
{' — '}{empfehlung.grund}
|
||||||
|
</>
|
||||||
|
) : empfehlung.grund}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const webhookTesten = async () => {
|
const webhookTesten = async () => {
|
||||||
if (!settings.notificationWebhook) return
|
if (!settings.notificationWebhook) return
|
||||||
setTestBusy(true)
|
setTestBusy(true)
|
||||||
@@ -338,6 +471,40 @@ export default function SettingsPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Erreichbarkeit für EXTERNE Encoder. Ein Worker auf einem anderen Rechner
|
||||||
|
// sieht die Container-Platte nicht — nur eingehängte Netzwerk-Freigaben, die
|
||||||
|
// er selbst auch erreicht. Liegen Ablage und Arbeitsverzeichnis nicht BEIDE
|
||||||
|
// auf einer solchen Freigabe, scheitert die Kompression dort zwangsläufig.
|
||||||
|
//
|
||||||
|
// Genau das ist am 25.07.2026 passiert: Rohdaten auf der NAS (erreichbar),
|
||||||
|
// Ziel auf der VM-Platte (nicht erreichbar) — der Fehler kam erst nach einer
|
||||||
|
// Stunde Rippen. Diese Prüfung sagt es vorher.
|
||||||
|
// Der Worker sagt selbst, ob er außerhalb des Containers läuft (caps.py
|
||||||
|
// meldet `extern`) — keine Heuristik über IPs oder Namen nötig.
|
||||||
|
const externerWorkerVorhanden = workers.some(w => w.info?.extern === 'ja')
|
||||||
|
const istFreigabe = (pfad: string) =>
|
||||||
|
ziele.some(z => z.path === pfad && z.is_mount)
|
||||||
|
const ablageWarnung = (() => {
|
||||||
|
const ablage = settings.outputDir || '/app/media'
|
||||||
|
const arbeit = settings.workDir
|
||||||
|
if (!arbeit) {
|
||||||
|
return 'Das Arbeitsverzeichnis steht auf der Container-Platte. Ein Encoder auf '
|
||||||
|
+ 'einem anderen Rechner kann die Rohdaten dort nicht lesen — lege beides auf '
|
||||||
|
+ 'dieselbe Netzwerk-Freigabe (Einstellungen → Verarbeitung).'
|
||||||
|
}
|
||||||
|
if (!istFreigabe(ablage) && istFreigabe(arbeit)) {
|
||||||
|
return 'Arbeitsverzeichnis liegt auf einer Freigabe, die Ablage aber auf der '
|
||||||
|
+ 'Container-Platte. Ein externer Encoder kann dann lesen, aber nicht schreiben — '
|
||||||
|
+ 'genau daran scheiterte der erste Versuch. Stelle die Ablage auf dieselbe Freigabe.'
|
||||||
|
}
|
||||||
|
if (istFreigabe(ablage) && istFreigabe(arbeit) && ablage !== arbeit) {
|
||||||
|
return 'Ablage und Arbeitsverzeichnis liegen auf VERSCHIEDENEN Freigaben. Das '
|
||||||
|
+ 'funktioniert, kostet am Ende aber eine Vollkopie statt eines Umhängens. '
|
||||||
|
+ 'Dieselbe Freigabe für beides ist deutlich schneller.'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})()
|
||||||
|
|
||||||
const tabs: { id: SettingsTab; label: string; icon: any }[] = [
|
const tabs: { id: SettingsTab; label: string; icon: any }[] = [
|
||||||
{ id: 'ripping', label: 'Ripping', icon: Disc },
|
{ id: 'ripping', label: 'Ripping', icon: Disc },
|
||||||
{ id: 'verarbeitung', label: 'Verarbeitung', icon: Cpu },
|
{ id: 'verarbeitung', label: 'Verarbeitung', icon: Cpu },
|
||||||
@@ -427,14 +594,16 @@ export default function SettingsPage() {
|
|||||||
🎵 Audio-CD
|
🎵 Audio-CD
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
{/* Der Schalter „Alle Tracks rippen" ist am 25.07.2026 entfallen.
|
||||||
<Toggle
|
Er war ein Placebo: abcde bekommt keine Track-Auswahl (siehe
|
||||||
label="Alle Tracks rippen"
|
build_abcde_cmd) und es gibt auch keine Oberfläche, um eine
|
||||||
description="Rippe alle Tracks einer CD (FLAC + MusicBrainz-Tags)."
|
Teilmenge zu wählen — eine Audio-CD wurde also immer
|
||||||
checked={settings.ripAllTracks}
|
vollständig gerippt, egal wie der Schalter stand. Statt einer
|
||||||
onChange={(v) => handleChange('ripAllTracks', v)}
|
Wahl, die nichts bewirkt, steht hier jetzt die Wahrheit. */}
|
||||||
/>
|
<p className="text-xs text-slate-500 dark:text-slate-400 px-1">
|
||||||
</div>
|
💿 Eine Audio-CD wird immer vollständig gerippt — alle Tracks als
|
||||||
|
FLAC, mit MusicBrainz-Tags.
|
||||||
|
</p>
|
||||||
|
|
||||||
<p className="text-xs font-bold uppercase tracking-wider pt-2 text-slate-400 dark:text-slate-500">
|
<p className="text-xs font-bold uppercase tracking-wider pt-2 text-slate-400 dark:text-slate-500">
|
||||||
Allgemein
|
Allgemein
|
||||||
@@ -489,7 +658,7 @@ export default function SettingsPage() {
|
|||||||
label="Server-URL"
|
label="Server-URL"
|
||||||
value={settings.jellyfinUrl}
|
value={settings.jellyfinUrl}
|
||||||
onChange={(e) => handleChange('jellyfinUrl', e.target.value)}
|
onChange={(e) => handleChange('jellyfinUrl', e.target.value)}
|
||||||
placeholder="http://192.168.178.50:8096"
|
placeholder="z. B. http://192.168.1.50:8096"
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-2 items-end">
|
<div className="flex gap-2 items-end">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -555,6 +724,24 @@ export default function SettingsPage() {
|
|||||||
{ENCODER_BADGES[e]?.label || e}
|
{ENCODER_BADGES[e]?.label || e}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
{(w.info?.cpu_kerne || w.info?.cpu_simd) && (
|
||||||
|
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400" title={w.info?.cpu_modell || ''}>
|
||||||
|
{w.info?.cpu_modell ? `${w.info.cpu_modell} · ` : ''}
|
||||||
|
{w.info?.cpu_kerne ? `${w.info.cpu_kerne} Kerne` : ''}
|
||||||
|
{w.info?.cpu_kerne && w.info?.cpu_simd ? ' · ' : ''}
|
||||||
|
{w.info?.cpu_simd ? `Vektorbefehle ${w.info.cpu_simd}` : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{simdWarnung(w.info?.cpu_simd) && (
|
||||||
|
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400">
|
||||||
|
{simdWarnung(w.info?.cpu_simd)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{w.info?.handbrake_encoder && (
|
||||||
|
<p className="mt-1 text-xs text-slate-400 dark:text-slate-500 font-mono break-all">
|
||||||
|
HandBrake meldet: {w.info.handbrake_encoder}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -574,48 +761,126 @@ export default function SettingsPage() {
|
|||||||
Ein Preset JE DISC-TYP (25.07.2026). Vorher lief alles durch
|
Ein Preset JE DISC-TYP (25.07.2026). Vorher lief alles durch
|
||||||
dasselbe Preset — eine 4K-UHD wurde damit auf 1080p
|
dasselbe Preset — eine 4K-UHD wurde damit auf 1080p
|
||||||
heruntergerechnet und eine DVD auf 1080p hochskaliert.
|
heruntergerechnet und eine DVD auf 1080p hochskaliert.
|
||||||
Preset-Namen stammen aus `HandBrakeCLI --preset-list` im
|
|
||||||
Worker-Image (HandBrake 1.6.1), nicht aus dem Kopf.
|
Seit 26.07.2026 kommen die NAMEN vom Worker selbst
|
||||||
|
(`HandBrakeCLI --preset-list`, siehe worker/caps.py) und die
|
||||||
|
Empfehlung von GET /presets. Vorher standen sie hier fest
|
||||||
|
verdrahtet — sie unterscheiden sich aber je HandBrake-Version,
|
||||||
|
und ein Name, den das HandBrake des Workers nicht kennt, lässt
|
||||||
|
die Kompression scheitern.
|
||||||
*/}
|
*/}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Ein Preset je Quelle — Rippy erkennt den Disc-Typ selbst und wählt passend aus.
|
||||||
|
<strong className="text-slate-700 dark:text-slate-300"> Eine 4K-UHD gehört auf ein
|
||||||
|
2160p-Preset</strong>, sonst rechnet HandBrake sie auf 1080p herunter und die
|
||||||
|
4K-Auflösung ist weg.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={bestePresetsUebernehmen}
|
||||||
|
disabled={!presetInfo}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
>
|
||||||
|
Bestes wählen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{presetInfo && (
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{presetInfo.quelle === 'worker' ? (
|
||||||
|
<>
|
||||||
|
Die Liste kommt von deinen Workern selbst.
|
||||||
|
{presetInfo.hardware.length > 0
|
||||||
|
? ` Hardware-Encoder gemeldet: ${presetInfo.hardware.join(', ')} — damit geht 4K in Minuten statt Tagen.`
|
||||||
|
: presetInfo.simd_schnell
|
||||||
|
? ' Kein Hardware-Encoder, aber ein Worker mit AVX2 — Software-H.265 ist damit schnell genug.'
|
||||||
|
: ' Kein Hardware-Encoder und kein AVX2 — hier ist Software-4K keine Option (gemessen: 28–55 h je Film).'}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Kein Worker meldet gerade eine Preset-Liste (alter Worker-Stand oder
|
||||||
|
keiner online) — angezeigt wird deshalb eine geprüfte Standardauswahl.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{presetAuswahl('uhd', 'Preset für 4K-UHD',
|
||||||
|
'Nicht komprimieren — 4K verlustfrei behalten')}
|
||||||
|
{/* Die Warnung, die den 50-Stunden-Lauf verhindert haette. Sie
|
||||||
|
steht nur da, wenn die Maschine sie WIRKLICH braucht -
|
||||||
|
gemessen, nicht vermutet (worker/caps.py meldet cpu_simd). */}
|
||||||
|
{settings.transcodePresetUhd !== PRESET_KEINE
|
||||||
|
&& settings.transcodePresetUhd.includes('2160')
|
||||||
|
&& schwacheEncoderCpu(workers) && (
|
||||||
|
<p className="text-xs -mt-1 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-700 dark:text-amber-300">
|
||||||
|
⚠️ Keiner deiner Worker kann AVX2 — 4K in H.265 dauert damit
|
||||||
|
<strong> ein bis zwei Tage pro Film</strong> (gemessen, nicht geschätzt).
|
||||||
|
Besser „Nicht komprimieren" wählen und die verlustfreie Datei behalten,
|
||||||
|
oder einen Worker mit Hardware-Encoder anschließen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{presetAuswahl('bluray', 'Preset für Blu-ray',
|
||||||
|
'Nicht komprimieren — verlustfrei behalten')}
|
||||||
|
{presetAuswahl('dvd', 'Preset für DVD',
|
||||||
|
'Nicht komprimieren — verlustfrei behalten')}
|
||||||
|
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
Ein Preset je Quelle — Rippy erkennt den Disc-Typ selbst und wählt passend aus.
|
„Nicht komprimieren" heißt: Der Rip bleibt so, wie MakeMKV ihn
|
||||||
<strong className="text-slate-700 dark:text-slate-300"> Eine 4K-UHD gehört auf ein
|
gesichert hat — beste Qualität, aber 20–100 GB je Film. Sinnvoll für
|
||||||
2160p-Preset</strong>, sonst rechnet HandBrake sie auf 1080p herunter und die
|
4K, wenn die Ablage groß genug ist.
|
||||||
4K-Auflösung ist weg.
|
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Select
|
{/*
|
||||||
label="Preset für 4K-UHD"
|
Wunschsprachen (Commander-Anforderung 26.07.2026: die Auswahl
|
||||||
value={settings.transcodePresetUhd}
|
muss „in der Automatik ebenfalls einstellbar" sein).
|
||||||
onChange={(e) => handleChange('transcodePresetUhd', e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="H.265 MKV 2160p60 4K">H.265 MKV 2160p60 4K — 4K erhalten (empfohlen)</option>
|
|
||||||
<option value="HQ 2160p60 4K HEVC Surround">HQ 2160p60 4K HEVC Surround — 4K, mehr Qualität</option>
|
|
||||||
<option value="Super HQ 2160p60 4K HEVC Surround">Super HQ 2160p60 4K HEVC Surround — 4K, größer</option>
|
|
||||||
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 — bewusst auf 1080p verkleinern</option>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select
|
Sie greifen bei der KOMPRESSION, nicht beim Rippen — der Rip
|
||||||
label="Preset für Blu-ray"
|
bleibt vollständig und verlustfrei (Muss-Feature). Damit lässt
|
||||||
value={settings.transcodePresetBluray}
|
sich später mit anderer Sprachwahl neu komprimieren, ohne die
|
||||||
onChange={(e) => handleChange('transcodePresetBluray', e.target.value)}
|
Disc wieder einzulegen.
|
||||||
>
|
*/}
|
||||||
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 — klein & modern (empfohlen)</option>
|
<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">
|
||||||
<option value="HQ 1080p30 Surround">HQ 1080p30 Surround — H.264, maximale Kompatibilität</option>
|
<div>
|
||||||
<option value="Super HQ 1080p30 Surround">Super HQ 1080p30 Surround — größer, beste Qualität</option>
|
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">Wunschsprachen</p>
|
||||||
</Select>
|
<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>
|
||||||
|
|
||||||
<Select
|
<Input
|
||||||
label="Preset für DVD"
|
label="Tonspuren (ISO-639-2, mit Komma getrennt)"
|
||||||
value={settings.transcodePresetDvd}
|
value={settings.audioSprachen}
|
||||||
onChange={(e) => handleChange('transcodePresetDvd', e.target.value)}
|
onChange={(e) => handleChange('audioSprachen', e.target.value)}
|
||||||
>
|
placeholder="z. B. deu,eng,jpn"
|
||||||
<option value="H.265 MKV 576p25">H.265 MKV 576p25 — PAL-DVD, native Auflösung (empfohlen)</option>
|
/>
|
||||||
<option value="H.265 MKV 480p30">H.265 MKV 480p30 — NTSC-DVD, native Auflösung</option>
|
<Input
|
||||||
<option value="HQ 576p25 Surround">HQ 576p25 Surround — H.264, maximale Kompatibilität</option>
|
label="Untertitel (ISO-639-2, mit Komma getrennt)"
|
||||||
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 — hochskalieren (bringt nichts)</option>
|
value={settings.untertitelSprachen}
|
||||||
</Select>
|
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>
|
||||||
|
|
||||||
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||||||
@@ -683,12 +948,50 @@ export default function SettingsPage() {
|
|||||||
Speicherziele
|
Speicherziele
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
{/* ABLAGE — bis 26.07.2026 gab es dieses Feld im UI GAR NICHT.
|
||||||
|
outputDir stand in den Einstellungen und wurde von der
|
||||||
|
Vollautomatik und der Schnellwahl gelesen, ließ sich aber
|
||||||
|
nirgends ändern: die Ablage klebte auf /app/media, also der
|
||||||
|
Container-Platte. Deshalb konnte man das Arbeitsverzeichnis auf
|
||||||
|
eine NAS legen (dafür gab es eine Auswahl), das ZIEL aber nicht
|
||||||
|
— und genau daran scheiterte der erste Rip mit externem
|
||||||
|
Encoder: Rohdaten auf der erreichbaren NAS, Ziel auf der
|
||||||
|
VM-Platte, die der andere Rechner nicht sieht. */}
|
||||||
|
<div className="p-5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3">
|
||||||
|
<h3 className="font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||||
|
<HardDrive size={16} className="text-amber-500" /> Ablage — wohin die fertigen Filme kommen
|
||||||
|
</h3>
|
||||||
|
<Select
|
||||||
|
label="Ablage"
|
||||||
|
value={settings.outputDir}
|
||||||
|
onChange={(e) => handleChange('outputDir', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="/app/media">Container-Platte (Standard)</option>
|
||||||
|
{ziele.filter(z => z.path !== '/app/media').map(z => (
|
||||||
|
<option key={z.path} value={z.path}>
|
||||||
|
{z.name}{z.is_mount ? ' (Netzwerk-Freigabe)' : ''}
|
||||||
|
{z.free_gb != null ? ` — ${z.free_gb} GB frei` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Gilt für die Schnellwahl im Rip-Dialog <strong className="text-slate-700 dark:text-slate-300">und
|
||||||
|
für die Vollautomatik</strong> — dort fragt niemand nach, also entscheidet dieser Wert.
|
||||||
|
Die Unterordner darunter kommen aus der Schnellwahl.
|
||||||
|
</p>
|
||||||
|
{externerWorkerVorhanden && ablageWarnung && (
|
||||||
|
<p className="text-xs p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-700 dark:text-amber-300">
|
||||||
|
⚠️ {ablageWarnung}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="p-5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3">
|
<div className="p-5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3">
|
||||||
<h3 className="font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
<h3 className="font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||||
<Database size={16} className="text-amber-500" /> Schnellwahl beim Rippen
|
<Database size={16} className="text-amber-500" /> Schnellwahl beim Rippen
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
Name der Ordner im „Rippen starten"-Dialog (🎥/📺/🎵).
|
Name der Ordner im „Rippen starten"-Dialog (🎥/📺/🎵) — jeweils unterhalb der Ablage.
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
{([['movieDir', '🎥 Filme'], ['seriesDir', '📺 Serien'], ['musicDir', '🎵 Musik']] as const).map(([feld, label]) => (
|
{([['movieDir', '🎥 Filme'], ['seriesDir', '📺 Serien'], ['musicDir', '🎵 Musik']] as const).map(([feld, label]) => (
|
||||||
@@ -843,9 +1146,9 @@ export default function SettingsPage() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{/*
|
{/*
|
||||||
Nur das Verzeichnis des rippenden Workers zaehlt — deshalb je Worker.
|
Nur das Verzeichnis des rippenden Workers zählt — deshalb je Worker.
|
||||||
Die Zahl der Disc-Schluessel ist die entscheidende Angabe fuer 4K-UHD:
|
Die Zahl der Disc-Schluessel ist die entscheidende Angabe für 4K-UHD:
|
||||||
0 heisst, dass JEDE unbekannte UHD-Disc scheitert.
|
0 heißt, dass JEDE unbekannte UHD-Disc scheitert.
|
||||||
*/}
|
*/}
|
||||||
{w.info?.schluessel && w.info.schluessel !== '0' && w.info.schluessel !== 'unbekannt' && (
|
{w.info?.schluessel && w.info.schluessel !== '0' && w.info.schluessel !== 'unbekannt' && (
|
||||||
<span className="text-xs px-2.5 py-0.5 rounded-md font-medium bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30">
|
<span className="text-xs px-2.5 py-0.5 rounded-md font-medium bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30">
|
||||||
@@ -870,7 +1173,7 @@ export default function SettingsPage() {
|
|||||||
Disc-Schluessel-Datenbank mit. Nachgemessen: falsch — MakeMKV liefert
|
Disc-Schluessel-Datenbank mit. Nachgemessen: falsch — MakeMKV liefert
|
||||||
gar keine Schluessel mit, es holt sie zur Laufzeit. Und die
|
gar keine Schluessel mit, es holt sie zur Laufzeit. Und die
|
||||||
Linux-Version holt sie nie (kein einziger Verbindungsversuch, auf
|
Linux-Version holt sie nie (kein einziger Verbindungsversuch, auf
|
||||||
beiden Maschinen verglichen). Deshalb der Schluesselspeicher unten.
|
beiden Maschinen verglichen). Deshalb der Schlüsselspeicher unten.
|
||||||
*/}
|
*/}
|
||||||
<p className="text-xs mt-3 pt-3 border-t border-slate-200/60 dark:border-slate-800 text-slate-500 dark:text-slate-400">
|
<p className="text-xs mt-3 pt-3 border-t border-slate-200/60 dark:border-slate-800 text-slate-500 dark:text-slate-400">
|
||||||
<strong className="text-slate-700 dark:text-slate-300">MakeMKV</strong> lässt sich aktuell halten
|
<strong className="text-slate-700 dark:text-slate-300">MakeMKV</strong> lässt sich aktuell halten
|
||||||
@@ -894,7 +1197,7 @@ export default function SettingsPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
Schluesselspeicher — der Hauptweg fuer 4K-UHD (Befund 25.07.2026,
|
Schlüsselspeicher — der Hauptweg für 4K-UHD (Befund 25.07.2026,
|
||||||
auf beiden Maschinen gemessen): makemkvcon holt Disc-Schluessel
|
auf beiden Maschinen gemessen): makemkvcon holt Disc-Schluessel
|
||||||
unter Linux NIE selbst, die Windows-Version tut es (Meldung 3338,
|
unter Linux NIE selbst, die Windows-Version tut es (Meldung 3338,
|
||||||
Verbindung nach 185.84.108.20:443). Deshalb wird der Vorrat hier
|
Verbindung nach 185.84.108.20:443). Deshalb wird der Vorrat hier
|
||||||
@@ -970,8 +1273,8 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
{/*
|
{/*
|
||||||
KEYDB.cfg — der NOTNAGEL, nicht der Hauptweg. Sie hilft bei Pressungen,
|
KEYDB.cfg — der NOTNAGEL, nicht der Hauptweg. Sie hilft bei Pressungen,
|
||||||
die auch MakeMKV selbst nicht kennt. Der Regelfall laeuft ueber den
|
die auch MakeMKV selbst nicht kennt. Der Regelfall läuft über den
|
||||||
Schluesselspeicher im Block darueber. Dateiname GROSS geschrieben, unter
|
Schlüsselspeicher im Block darüber. Dateiname GROSS geschrieben, unter
|
||||||
Linux case-sensitiv. Rippy stellt nur den Platz bereit.
|
Linux case-sensitiv. Rippy stellt nur den Platz bereit.
|
||||||
*/}
|
*/}
|
||||||
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3">
|
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3">
|
||||||
@@ -1058,7 +1361,7 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
{/*
|
{/*
|
||||||
AACS-Dumps: MakeMKV legt sie bei einer unbekannten UHD-Disc ab (Meldung 3332).
|
AACS-Dumps: MakeMKV legt sie bei einer unbekannten UHD-Disc ab (Meldung 3332).
|
||||||
Seit das Datenverzeichnis persistent gemountet ist, ueberleben sie den
|
Seit das Datenverzeichnis persistent gemountet ist, überleben sie den
|
||||||
Container-Neustart — vorher waren sie nach jedem Rip weg.
|
Container-Neustart — vorher waren sie nach jedem Rip weg.
|
||||||
*/}
|
*/}
|
||||||
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3">
|
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3">
|
||||||
|
|||||||
+44
-10
@@ -32,6 +32,19 @@ FROM python:3.12-slim-bookworm AS makemkv-build
|
|||||||
# docker compose build --build-arg MAKEMKV_URL_BASE=http://<host-ip>:8099 worker
|
# docker compose build --build-arg MAKEMKV_URL_BASE=http://<host-ip>:8099 worker
|
||||||
ARG MAKEMKV_VERSION=1.18.4
|
ARG MAKEMKV_VERSION=1.18.4
|
||||||
ARG MAKEMKV_URL_BASE=https://www.makemkv.com/download
|
ARG MAKEMKV_URL_BASE=https://www.makemkv.com/download
|
||||||
|
# Zweite Quelle, die bei einem Fehlschlag der ersten AUTOMATISCH probiert wird.
|
||||||
|
# Absichtlich LEER: Am 25.07.2026 nachgemessen, welche Quellen wirklich liefern —
|
||||||
|
# https://www.makemkv.com/download HTTP 200 (der Standard oben)
|
||||||
|
# https://www.makemkv.com/download/old HTTP 525 (Cloudflare)
|
||||||
|
# web.archive.org-Schnappschuss HTTP 404
|
||||||
|
# Es gibt derzeit also keine belegbare zweite Quelle. Eine hier einzutragen, die
|
||||||
|
# nicht liefert, wäre schlimmer als keine: der Build scheitert dann später und
|
||||||
|
# die Konfiguration sieht trotzdem gesund aus — genau das war auf der Rippy-VM
|
||||||
|
# der Fall (dort stand die 404-Adresse, und nur die vendor-Tarballs retteten
|
||||||
|
# jeden Build, ohne dass es auffiel).
|
||||||
|
# Wer eine eigene Quelle hat (Spiegel im LAN, eigener Webserver), traegt sie ein:
|
||||||
|
# docker compose build --build-arg MAKEMKV_URL_FALLBACK=http://<host>:8099 worker
|
||||||
|
ARG MAKEMKV_URL_FALLBACK=
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
build-essential \
|
build-essential \
|
||||||
@@ -46,26 +59,47 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
# Download mit doppeltem Netz (Cloudflare drosselt BuildKit-Downloads
|
# Download mit DREIFACHEM Netz (Cloudflare drosselt BuildKit-Downloads
|
||||||
# hartnäckig — 24.07. viermal, auch MIT --retry-all-errors):
|
# hartnäckig — 24.07. viermal, auch MIT --retry-all-errors):
|
||||||
# 1. Liegen die Tarballs lokal in docker/worker/vendor/ (untracked), werden
|
# 1. Liegen die Tarballs lokal in docker/worker/vendor/ (untracked), werden
|
||||||
# SIE genutzt — einmal von Hand hinlegen, nie wieder Download-Roulette.
|
# SIE genutzt — einmal von Hand hinlegen, nie wieder Download-Roulette.
|
||||||
# 2. Sonst curl mit Retries gegen MAKEMKV_URL_BASE.
|
# 2. Sonst curl mit Retries gegen MAKEMKV_URL_BASE.
|
||||||
|
# 3. Scheitert das, wird MAKEMKV_URL_FALLBACK versucht, falls gesetzt.
|
||||||
|
# Und wenn alles scheitert, sagt die Meldung, welche Wege es gibt — statt nur
|
||||||
|
# einen curl-Rückgabewert zu hinterlassen.
|
||||||
COPY docker/worker/vendor/ ./vendor/
|
COPY docker/worker/vendor/ ./vendor/
|
||||||
RUN if [ -s "vendor/makemkv-oss-${MAKEMKV_VERSION}.tar.gz" ] \
|
RUN set -e; \
|
||||||
|
hole() { \
|
||||||
|
ziel="$1"; datei="$2"; \
|
||||||
|
if curl -fsSL --retry 5 --retry-delay 15 --retry-all-errors \
|
||||||
|
-o "$ziel" "${MAKEMKV_URL_BASE}/${datei}"; then \
|
||||||
|
return 0; \
|
||||||
|
fi; \
|
||||||
|
if [ -n "${MAKEMKV_URL_FALLBACK}" ]; then \
|
||||||
|
echo "Erste Quelle lieferte nicht — versuche MAKEMKV_URL_FALLBACK"; \
|
||||||
|
curl -fsSL --retry 3 --retry-delay 10 --retry-all-errors \
|
||||||
|
-o "$ziel" "${MAKEMKV_URL_FALLBACK}/${datei}" && return 0; \
|
||||||
|
fi; \
|
||||||
|
echo "FEHLER: ${datei} war von keiner Quelle zu holen." >&2; \
|
||||||
|
echo " Geprueft: ${MAKEMKV_URL_BASE}${MAKEMKV_URL_FALLBACK:+ und ${MAKEMKV_URL_FALLBACK}}" >&2; \
|
||||||
|
echo " Sicherster Weg: Tarballs von makemkv.com/download herunterladen" >&2; \
|
||||||
|
echo " und nach docker/worker/vendor/ legen, dann erneut bauen." >&2; \
|
||||||
|
echo " Alternativ eine eigene Quelle angeben:" >&2; \
|
||||||
|
echo " docker compose build --build-arg MAKEMKV_URL_FALLBACK=http://<host>:8099 worker" >&2; \
|
||||||
|
return 1; \
|
||||||
|
}; \
|
||||||
|
if [ -s "vendor/makemkv-oss-${MAKEMKV_VERSION}.tar.gz" ] \
|
||||||
&& [ -s "vendor/makemkv-bin-${MAKEMKV_VERSION}.tar.gz" ]; then \
|
&& [ -s "vendor/makemkv-bin-${MAKEMKV_VERSION}.tar.gz" ]; then \
|
||||||
echo "Nutze lokale Tarballs aus vendor/"; \
|
echo "Nutze lokale Tarballs aus vendor/"; \
|
||||||
cp "vendor/makemkv-oss-${MAKEMKV_VERSION}.tar.gz" oss.tar.gz; \
|
cp "vendor/makemkv-oss-${MAKEMKV_VERSION}.tar.gz" oss.tar.gz; \
|
||||||
cp "vendor/makemkv-bin-${MAKEMKV_VERSION}.tar.gz" bin.tar.gz; \
|
cp "vendor/makemkv-bin-${MAKEMKV_VERSION}.tar.gz" bin.tar.gz; \
|
||||||
else \
|
else \
|
||||||
curl -fsSL --retry 5 --retry-delay 15 --retry-all-errors \
|
hole oss.tar.gz "makemkv-oss-${MAKEMKV_VERSION}.tar.gz"; \
|
||||||
-o oss.tar.gz "${MAKEMKV_URL_BASE}/makemkv-oss-${MAKEMKV_VERSION}.tar.gz" \
|
hole bin.tar.gz "makemkv-bin-${MAKEMKV_VERSION}.tar.gz"; \
|
||||||
&& curl -fsSL --retry 5 --retry-delay 15 --retry-all-errors \
|
fi; \
|
||||||
-o bin.tar.gz "${MAKEMKV_URL_BASE}/makemkv-bin-${MAKEMKV_VERSION}.tar.gz"; \
|
sha256sum oss.tar.gz bin.tar.gz; \
|
||||||
fi \
|
tar xzf oss.tar.gz; \
|
||||||
&& sha256sum oss.tar.gz bin.tar.gz \
|
tar xzf bin.tar.gz
|
||||||
&& tar xzf oss.tar.gz \
|
|
||||||
&& tar xzf bin.tar.gz
|
|
||||||
|
|
||||||
RUN cd "makemkv-oss-${MAKEMKV_VERSION}" \
|
RUN cd "makemkv-oss-${MAKEMKV_VERSION}" \
|
||||||
&& ./configure --disable-gui --prefix=/usr/local \
|
&& ./configure --disable-gui --prefix=/usr/local \
|
||||||
|
|||||||
+312
-11
@@ -6,24 +6,282 @@ Remote-GPU-Worker meldet sich hier genauso wie der eingebaute CPU-Worker.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import platform
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
|
from winlauf import OHNE_FENSTER
|
||||||
|
|
||||||
|
|
||||||
|
HB_ENCODER_KOPF = re.compile(r"^-e,\s*--encoder\b")
|
||||||
|
|
||||||
|
# Reihenfolge = Anzeige-Reihenfolge im UI.
|
||||||
|
SIMD_STUFEN = ("avx512f", "avx2", "avx", "sse4_2")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_encoder_liste(hilfe_text: str) -> list:
|
||||||
|
"""Video-Encoder, die DIESES HandBrake wirklich anbietet.
|
||||||
|
|
||||||
|
Quelle: `HandBrakeCLI --help`, Abschnitt `-e, --encoder <string>`
|
||||||
|
(AGENTS Regel D — im Worker-Image am 25.07.2026 gemessen, nicht geraten):
|
||||||
|
|
||||||
|
-e, --encoder <string> Select video encoder:
|
||||||
|
svt_av1
|
||||||
|
x264
|
||||||
|
x265
|
||||||
|
...
|
||||||
|
--encoder-preset <string>
|
||||||
|
|
||||||
|
Der Block endet an der nächsten Option — also an der ersten Zeile, deren
|
||||||
|
erstes nicht-leeres Zeichen ein Bindestrich ist.
|
||||||
|
"""
|
||||||
|
encoder = []
|
||||||
|
im_block = False
|
||||||
|
for zeile in (hilfe_text or "").splitlines():
|
||||||
|
inhalt = zeile.strip()
|
||||||
|
if not im_block:
|
||||||
|
if HB_ENCODER_KOPF.match(inhalt):
|
||||||
|
im_block = True
|
||||||
|
continue
|
||||||
|
if not inhalt:
|
||||||
|
continue
|
||||||
|
if inhalt.startswith("-"):
|
||||||
|
break
|
||||||
|
encoder.append(inhalt.split()[0])
|
||||||
|
return encoder
|
||||||
|
|
||||||
|
|
||||||
|
# Hardware-Encoder-Familien, wie HandBrake sie benennt → Backend-Kennung.
|
||||||
|
# Reihenfolge = Anzeige-Reihenfolge; AV1 zuerst, weil es das modernste ist.
|
||||||
|
HW_FAMILIEN = (
|
||||||
|
("nvenc", "nvenc"), # NVIDIA
|
||||||
|
("qsv", "qsv"), # Intel QuickSync
|
||||||
|
("vce", "vce"), # AMD VCE/VCN
|
||||||
|
("vaapi", "vaapi"), # generisch (Linux)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def leite_backends_ab(hb_encoder) -> list:
|
||||||
|
"""Übersetzt die HandBrake-Encoder-Namen in die Backends, die das UI zeigt.
|
||||||
|
|
||||||
|
## HandBrakes Liste IST die Auskunft — keine zweite Meinung nötig
|
||||||
|
|
||||||
|
Frühere Fassung (25.07.2026) verlangte zusätzlich ein Gerät: `/dev/dri`
|
||||||
|
für VAAPI/QSV/VCE bzw. `nvidia-smi` für NVENC. Das war doppelt falsch:
|
||||||
|
|
||||||
|
1. **Unter Windows gibt es beides nicht.** Der Commander-PC (RX 9070 XT)
|
||||||
|
meldete deshalb nur CPU-Encoder, obwohl HandBrakes Windows-Build
|
||||||
|
`vce_*`, `nvenc_*` und `qsv_*` beherrscht. Ein Bug, der genau den
|
||||||
|
Anwendungsfall traf, für den externe Worker gedacht sind.
|
||||||
|
2. **Die Prüfung war überflüssig.** HandBrake probiert Hardware-Encoder
|
||||||
|
beim Start selbst an und listet nur, was es wirklich benutzen kann. Auf
|
||||||
|
der Rippy-VM belegt: `--version` meldet „qsv: not available on this
|
||||||
|
system" und „Cannot load libnvidia-encode.so.1" — und genau diese
|
||||||
|
Encoder fehlen dann in `--help`. Wer die Liste hat, hat die Antwort.
|
||||||
|
|
||||||
|
Bleibt die eine Regel, die weiter gilt: **ohne HandBrake gibt es keine
|
||||||
|
Encoder.** Vorher standen `cpu-x264`/`cpu-x265` fest verdrahtet drin, und
|
||||||
|
ein reiner Rip-Worker behauptete damit, komprimieren zu können.
|
||||||
|
|
||||||
|
Hardware wird jetzt nach Familie unterschieden (nvenc/qsv/vce/vaapi) statt
|
||||||
|
alles in „vaapi" zu werfen — sonst kann das UI nicht sagen, welches Preset
|
||||||
|
passt, und AMD-Karten liefen unter einem Intel-Namen.
|
||||||
|
"""
|
||||||
|
namen = {e.lower() for e in (hb_encoder or [])}
|
||||||
|
gefunden = []
|
||||||
|
if any(n.startswith("x264") for n in namen):
|
||||||
|
gefunden.append("cpu-x264")
|
||||||
|
if any(n.startswith("x265") for n in namen):
|
||||||
|
gefunden.append("cpu-x265")
|
||||||
|
if any(n.startswith("svt_av1") for n in namen):
|
||||||
|
gefunden.append("cpu-av1")
|
||||||
|
for kennzeichen, backend in HW_FAMILIEN:
|
||||||
|
treffer = [n for n in namen if kennzeichen in n]
|
||||||
|
if not treffer:
|
||||||
|
continue
|
||||||
|
gefunden.append(backend)
|
||||||
|
# AV1 in Hardware ist die derzeit beste Kombination aus Tempo und
|
||||||
|
# Dateigröße — das muss sichtbar sein, nicht in „nvenc" verschwinden.
|
||||||
|
if any("av1" in n for n in treffer):
|
||||||
|
gefunden.append(f"{backend}-av1")
|
||||||
|
return gefunden
|
||||||
|
|
||||||
|
|
||||||
|
def parse_preset_liste(text: str) -> list:
|
||||||
|
"""Preset-NAMEN, die DIESES HandBrake wirklich kennt.
|
||||||
|
|
||||||
|
Quelle: `HandBrakeCLI --preset-list`, am 26.07.2026 im Worker-Image
|
||||||
|
gemessen (HandBrake 1.6.1) — AGENTS Regel D, nicht aus dem Kopf:
|
||||||
|
|
||||||
|
General/
|
||||||
|
Very Fast 2160p60 4K AV1
|
||||||
|
AV1 video (up to 2160p60) and AAC stereo audio, in an MP4
|
||||||
|
container.
|
||||||
|
Hardware/
|
||||||
|
H.265 VCN 2160p 4K
|
||||||
|
|
||||||
|
Die Struktur ist die Einrückung: Kategorien ohne Einrückung (mit „/" am
|
||||||
|
Ende), Preset-Namen mit GENAU vier Leerzeichen, Beschreibungen mit acht.
|
||||||
|
HandBrakes Logzeilen („[10:26:05] qsv: not available on this system") und
|
||||||
|
Meldungen wie „Cannot load libnvidia-encode.so.1" haben keine Einrückung
|
||||||
|
und fallen damit von selbst weg.
|
||||||
|
|
||||||
|
⚠️ Wichtige Richtigstellung zum SAVEPOINT v3.16: Dort steht, die Namen der
|
||||||
|
HARDWARE-Presets seien auf der Rippy-VM „nicht ermittelbar", weil deren
|
||||||
|
HandBrake keinen Hardware-Encoder hat. Gemessen ist das falsch — die
|
||||||
|
Kategorie `Hardware/` steht vollständig in der Liste (VCN, NVENC, QSV, MF).
|
||||||
|
HandBrake trennt beides: `--preset-list` nennt alle mitgelieferten Presets,
|
||||||
|
`--help` nur die Encoder, die diese Maschine wirklich benutzen kann. Für
|
||||||
|
„welches Preset gibt es" ist die Preset-Liste die Quelle, für „läuft das
|
||||||
|
hier" die Encoder-Liste. Deshalb werden beide gemeldet.
|
||||||
|
"""
|
||||||
|
presets = []
|
||||||
|
for zeile in (text or "").splitlines():
|
||||||
|
if not zeile.strip():
|
||||||
|
continue
|
||||||
|
einrueckung = len(zeile) - len(zeile.lstrip(" "))
|
||||||
|
if einrueckung == 4:
|
||||||
|
presets.append(zeile.strip())
|
||||||
|
return presets
|
||||||
|
|
||||||
|
|
||||||
|
def hole_handbrake_presets() -> list:
|
||||||
|
"""`HandBrakeCLI --preset-list` einmal abrufen (leer, wenn nicht installiert)."""
|
||||||
|
if not shutil.which("HandBrakeCLI"):
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
aus = subprocess.run(
|
||||||
|
["HandBrakeCLI", "--preset-list"],
|
||||||
|
capture_output=True, text=True, timeout=30,
|
||||||
|
creationflags=OHNE_FENSTER,
|
||||||
|
)
|
||||||
|
return parse_preset_liste((aus.stdout or "") + (aus.stderr or ""))
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def beste_simd(flags_text: str) -> str:
|
||||||
|
"""Höchste Vektor-Befehlsstufe der CPU — entscheidend fürs Software-Encodieren.
|
||||||
|
|
||||||
|
Befund 25.07.2026: Die Rippy-VM läuft auf dem generischen QEMU-CPU-Modell
|
||||||
|
(„QEMU Virtual CPU version 2.5+") und hat deshalb KEIN AVX2, nur sse4_2.
|
||||||
|
x265 lebt von diesen Befehlen; ein 4K-Encode brauchte dort gemessene
|
||||||
|
28-55 Stunden. Sichtbar war das nirgends — genau diese Lücke schließt die
|
||||||
|
Anzeige. Abhilfe bei Proxmox/KVM: CPU-Typ der VM auf `host` stellen.
|
||||||
|
"""
|
||||||
|
vorhanden = set((flags_text or "").split())
|
||||||
|
for stufe in SIMD_STUFEN:
|
||||||
|
if stufe in vorhanden:
|
||||||
|
return stufe
|
||||||
|
return "unbekannt"
|
||||||
|
|
||||||
|
|
||||||
|
def _lies_cpuinfo() -> tuple:
|
||||||
|
"""(Modellname, Flags-Text, Kernzahl) aus /proc/cpuinfo — leer auf Windows."""
|
||||||
|
modell, flags, kerne = "", "", 0
|
||||||
|
try:
|
||||||
|
with open("/proc/cpuinfo", encoding="utf-8", errors="replace") as f:
|
||||||
|
for zeile in f:
|
||||||
|
if zeile.startswith("processor"):
|
||||||
|
kerne += 1
|
||||||
|
elif not modell and zeile.startswith("model name"):
|
||||||
|
modell = zeile.split(":", 1)[-1].strip()
|
||||||
|
elif not flags and zeile.startswith("flags"):
|
||||||
|
flags = zeile.split(":", 1)[-1].strip()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return modell, flags, kerne
|
||||||
|
|
||||||
|
|
||||||
|
# Merkmals-Nummern für IsProcessorFeaturePresent (Win32, winnt.h).
|
||||||
|
# Absteigend nach Stärke — der erste Treffer ist die höchste Stufe.
|
||||||
|
WIN_MERKMALE = (
|
||||||
|
("avx512f", 41), # PF_AVX512F_INSTRUCTIONS_AVAILABLE
|
||||||
|
("avx2", 40), # PF_AVX2_INSTRUCTIONS_AVAILABLE
|
||||||
|
("avx", 39), # PF_AVX_INSTRUCTIONS_AVAILABLE
|
||||||
|
("sse4_2", 38), # PF_SSE4_2_INSTRUCTIONS_AVAILABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _simd_windows() -> str:
|
||||||
|
"""Höchste Vektor-Befehlsstufe unter Windows.
|
||||||
|
|
||||||
|
Es gibt dort kein /proc/cpuinfo, deshalb meldete der Windows-Worker bis
|
||||||
|
25.07.2026 „Vektorbefehle unbekannt" — gerade auf der Maschine, die das
|
||||||
|
Encodieren übernehmen soll, also genau da, wo die Angabe zählt.
|
||||||
|
|
||||||
|
Gefragt wird `IsProcessorFeaturePresent` aus kernel32 (Win32-API, in
|
||||||
|
winnt.h dokumentiert). Kein Zusatzpaket, kein Registry-Gefummel, keine
|
||||||
|
Fremdprozesse.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import ctypes
|
||||||
|
|
||||||
|
kernel32 = ctypes.windll.kernel32
|
||||||
|
except (ImportError, AttributeError, OSError):
|
||||||
|
return "unbekannt"
|
||||||
|
for name, nummer in WIN_MERKMALE:
|
||||||
|
try:
|
||||||
|
if kernel32.IsProcessorFeaturePresent(nummer):
|
||||||
|
return name
|
||||||
|
except OSError:
|
||||||
|
return "unbekannt"
|
||||||
|
return "unbekannt"
|
||||||
|
|
||||||
|
|
||||||
|
def _modell_windows() -> str:
|
||||||
|
"""Lesbarer CPU-Name unter Windows.
|
||||||
|
|
||||||
|
platform.processor() liefert dort nur „AMD64 Family 26 Model 68 …" — die
|
||||||
|
Registry kennt den Namen, den auch der Aufkleber trägt.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import winreg
|
||||||
|
|
||||||
|
with winreg.OpenKey(
|
||||||
|
winreg.HKEY_LOCAL_MACHINE,
|
||||||
|
r"HARDWARE\DESCRIPTION\System\CentralProcessor\0",
|
||||||
|
) as schluessel:
|
||||||
|
name, _ = winreg.QueryValueEx(schluessel, "ProcessorNameString")
|
||||||
|
return " ".join(str(name).split())
|
||||||
|
except (ImportError, OSError):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def cpu_merkmale() -> tuple:
|
||||||
|
"""(Modell, SIMD-Stufe, Kernzahl) — plattformübergreifend, nie leer."""
|
||||||
|
modell, flags, kerne = _lies_cpuinfo()
|
||||||
|
simd = beste_simd(flags)
|
||||||
|
if simd == "unbekannt" and platform.system() == "Windows":
|
||||||
|
simd = _simd_windows()
|
||||||
|
if not modell:
|
||||||
|
modell = _modell_windows() or platform.processor() or "unbekannt"
|
||||||
|
return modell, simd, (kerne or os.cpu_count() or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def hole_handbrake_hilfe() -> str:
|
||||||
|
"""`HandBrakeCLI --help` einmal abrufen (leer, wenn nicht installiert)."""
|
||||||
|
if not shutil.which("HandBrakeCLI"):
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
aus = subprocess.run(
|
||||||
|
["HandBrakeCLI", "--help"], capture_output=True, text=True, timeout=30,
|
||||||
|
creationflags=OHNE_FENSTER
|
||||||
|
)
|
||||||
|
return (aus.stdout or "") + (aus.stderr or "")
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def erkenne_encoder() -> list:
|
def erkenne_encoder() -> list:
|
||||||
"""Liste der verfügbaren Encoder-Backends auf dieser Maschine."""
|
"""Encoder-Backends, die auf DIESER Maschine wirklich nutzbar sind.
|
||||||
gefunden = ["cpu-x264", "cpu-x265"] # HandBrake-Software-Encoder, immer dabei
|
|
||||||
|
|
||||||
# VAAPI: AMD (VCN) und Intel (QuickSync) melden sich über /dev/dri
|
Gefragt wird ausschließlich HandBrake selbst — es probiert Hardware beim
|
||||||
if os.path.exists("/dev/dri/renderD128"):
|
Start an und listet nur Nutzbares. Eine zusätzliche Geräteprüfung wäre
|
||||||
gefunden.append("vaapi")
|
plattformabhängig (und war unter Windows falsch), siehe leite_backends_ab.
|
||||||
|
"""
|
||||||
# NVENC: NVIDIA-Treiber im Container sichtbar
|
return leite_backends_ab(parse_encoder_liste(hole_handbrake_hilfe()))
|
||||||
if shutil.which("nvidia-smi") or os.path.exists("/usr/lib/x86_64-linux-gnu/libnvidia-encode.so.1"):
|
|
||||||
gefunden.append("nvenc")
|
|
||||||
|
|
||||||
return gefunden
|
|
||||||
|
|
||||||
|
|
||||||
def erkenne_ip() -> str:
|
def erkenne_ip() -> str:
|
||||||
@@ -56,11 +314,41 @@ def werkzeug_versionen() -> dict:
|
|||||||
"""
|
"""
|
||||||
import socket
|
import socket
|
||||||
|
|
||||||
|
modell, simd, kerne = cpu_merkmale()
|
||||||
info = {
|
info = {
|
||||||
# Zuordnung im UI: hostname (für den Celery-Online-Abgleich) + IP
|
# Zuordnung im UI: hostname (für den Celery-Online-Abgleich) + IP
|
||||||
"hostname": socket.gethostname(),
|
"hostname": socket.gethostname(),
|
||||||
"ip": erkenne_ip(),
|
"ip": erkenne_ip(),
|
||||||
|
# Rechenleistung fürs Software-Encodieren. Ohne diese drei Angaben war
|
||||||
|
# am 25.07.2026 nicht zu sehen, dass ein 4K-Encode auf dieser Maschine
|
||||||
|
# Tage statt Stunden braucht (kein AVX2, 4 Kerne).
|
||||||
|
"cpu_modell": modell,
|
||||||
|
"cpu_kerne": str(kerne),
|
||||||
|
"cpu_simd": simd,
|
||||||
|
# Läuft dieser Worker INNERHALB des Rippy-Containers oder auf einer
|
||||||
|
# fremden Maschine? Entscheidend, weil ein externer Worker die
|
||||||
|
# Container-Pfade (/app/media, /app/temp) nur über eine Freigabe plus
|
||||||
|
# RIPPY_PATH_MAP erreicht. Das UI kann damit VOR dem Rip warnen, statt
|
||||||
|
# den Nutzer eine Stunde rippen zu lassen (Vorfall 25.07.2026).
|
||||||
|
# /app ist im Rippy-Image immer vorhanden — kein Ratespiel.
|
||||||
|
"extern": "nein" if os.path.isdir("/app") else "ja",
|
||||||
|
# Ist die Pfad-Übersetzung gesetzt? Ohne sie kann ein externer Worker
|
||||||
|
# grundsätzlich nicht komprimieren.
|
||||||
|
"pfad_map": os.getenv("RIPPY_PATH_MAP", ""),
|
||||||
}
|
}
|
||||||
|
hb_encoder = parse_encoder_liste(hole_handbrake_hilfe())
|
||||||
|
if hb_encoder:
|
||||||
|
# Die ungefilterte Auskunft von HandBrake — damit im UI nachprüfbar ist,
|
||||||
|
# woher die Backend-Liste kommt.
|
||||||
|
info["handbrake_encoder"] = ",".join(hb_encoder)
|
||||||
|
# Die Preset-NAMEN dieses HandBrake. Damit hört das Raten auf: Das UI bietet
|
||||||
|
# genau an, was der jeweilige Worker kennt — die Namen unterscheiden sich
|
||||||
|
# zwischen HandBrake-Versionen, und ein erfundener Name lässt die Kompression
|
||||||
|
# scheitern (Commander-Anforderung „immer das Beste auswählen" braucht eine
|
||||||
|
# echte Liste, nicht eine geratene).
|
||||||
|
presets = hole_handbrake_presets()
|
||||||
|
if presets:
|
||||||
|
info["presets"] = presets
|
||||||
if shutil.which("makemkvcon"):
|
if shutil.which("makemkvcon"):
|
||||||
info["makemkv"] = os.getenv("MAKEMKV_VERSION") or "installiert"
|
info["makemkv"] = os.getenv("MAKEMKV_VERSION") or "installiert"
|
||||||
if shutil.which("HandBrakeCLI"):
|
if shutil.which("HandBrakeCLI"):
|
||||||
@@ -68,6 +356,7 @@ def werkzeug_versionen() -> dict:
|
|||||||
aus = subprocess.run(
|
aus = subprocess.run(
|
||||||
["HandBrakeCLI", "--version"],
|
["HandBrakeCLI", "--version"],
|
||||||
capture_output=True, text=True, timeout=15,
|
capture_output=True, text=True, timeout=15,
|
||||||
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
treffer = re.search(r"HandBrake\s+([\w.]+)", (aus.stdout or "") + (aus.stderr or ""))
|
treffer = re.search(r"HandBrake\s+([\w.]+)", (aus.stdout or "") + (aus.stderr or ""))
|
||||||
info["handbrake"] = treffer.group(1) if treffer else "installiert"
|
info["handbrake"] = treffer.group(1) if treffer else "installiert"
|
||||||
@@ -112,4 +401,16 @@ def werkzeug_versionen() -> dict:
|
|||||||
info["schluessel"] = str(makemkv_daten.schluesselspeicher_status().get("schluessel", 0))
|
info["schluessel"] = str(makemkv_daten.schluesselspeicher_status().get("schluessel", 0))
|
||||||
except Exception:
|
except Exception:
|
||||||
info["schluessel"] = "unbekannt"
|
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
|
return info
|
||||||
|
|||||||
@@ -33,6 +33,30 @@ celery_app.conf.update(
|
|||||||
# zuverlässig im sys.path (ModuleNotFoundError 'caps', Deploy 23.07.).
|
# zuverlässig im sys.path (ModuleNotFoundError 'caps', Deploy 23.07.).
|
||||||
import caps # noqa: E402
|
import caps # noqa: E402
|
||||||
import db # noqa: E402
|
import db # noqa: E402
|
||||||
|
import zombies # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
@worker_ready.connect
|
||||||
|
def raeume_job_leichen_auf(**kwargs):
|
||||||
|
"""Nach der Gnadenfrist: Jobs, an denen niemand arbeitet, ehrlich auf
|
||||||
|
'failed' setzen (Details und Sicherungen in zombies.py).
|
||||||
|
|
||||||
|
Läuft im Hintergrund-Thread — der Worker soll sofort Aufgaben annehmen und
|
||||||
|
nicht zwei Minuten auf die Aufräumung warten.
|
||||||
|
"""
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
def spaeter():
|
||||||
|
time.sleep(zombies.GNADENFRIST_SEKUNDEN)
|
||||||
|
try:
|
||||||
|
db.init_db()
|
||||||
|
bericht = zombies.raeume_zombies_auf(celery_app, db)
|
||||||
|
print(f"Zombie-Erkennung: {bericht}")
|
||||||
|
except Exception as e: # darf den Worker nie mitnehmen
|
||||||
|
print(f"Zombie-Erkennung fehlgeschlagen: {e}")
|
||||||
|
|
||||||
|
threading.Thread(target=spaeter, daemon=True, name="zombie-erkennung").start()
|
||||||
|
|
||||||
|
|
||||||
@worker_ready.connect
|
@worker_ready.connect
|
||||||
|
|||||||
@@ -181,6 +181,74 @@ def get_job_status(job_id: str) -> str:
|
|||||||
return zeile[0] if zeile else ""
|
return zeile[0] if zeile else ""
|
||||||
|
|
||||||
|
|
||||||
|
def list_jobs_mit_status(stati) -> list:
|
||||||
|
"""Alle Jobs in einem der genannten Zustände (id/status/title/created_at).
|
||||||
|
|
||||||
|
Basis der Zombie-Erkennung: Jobs, die behaupten, es arbeite gerade jemand
|
||||||
|
an ihnen. Bewusst NUR diese schmale Auswahl statt der ganzen Zeile — die
|
||||||
|
Erkennung braucht nichts weiter.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
with engine.connect() as conn:
|
||||||
|
zeilen = conn.execute(
|
||||||
|
select(jobs.c.id, jobs.c.status, jobs.c.title, jobs.c.created_at)
|
||||||
|
.where(jobs.c.status.in_(list(stati)))
|
||||||
|
).mappings().all()
|
||||||
|
return [dict(z) for z in zeilen]
|
||||||
|
|
||||||
|
|
||||||
|
def zaehle_online_worker(sekunden: int = 120) -> int:
|
||||||
|
"""Wie viele Worker gelten laut Herzschlag gerade als online?
|
||||||
|
|
||||||
|
Die Zombie-Erkennung vergleicht das mit der Zahl der Celery-Antworten:
|
||||||
|
melden sich weniger Worker als bekannt sind, ist die Auskunft
|
||||||
|
unvollständig — dann wird NICHTS als Leiche gewertet.
|
||||||
|
"""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
grenze = utcnow() - timedelta(seconds=sekunden)
|
||||||
|
with engine.connect() as conn:
|
||||||
|
anzahl = conn.execute(
|
||||||
|
select(func.count()).select_from(workers).where(workers.c.last_seen >= grenze)
|
||||||
|
).scalar()
|
||||||
|
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:
|
def update_job(job_id: str, **fields) -> None:
|
||||||
with engine.begin() as conn:
|
with engine.begin() as conn:
|
||||||
conn.execute(jobs.update().where(jobs.c.id == job_id).values(**fields))
|
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
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
"""MakeMKV-Datenverzeichnis: Schluesselspeicher, KEYDB.cfg, AACS-Dumps.
|
"""MakeMKV-Datenverzeichnis: Schlüsselspeicher, KEYDB.cfg, AACS-Dumps.
|
||||||
|
|
||||||
WARUM ES DIESE DATEI GIBT (Befund 25.07.2026, auf BEIDEN Maschinen gemessen):
|
WARUM ES DIESE DATEI GIBT (Befund 25.07.2026, auf BEIDEN Maschinen gemessen):
|
||||||
4K-UHD-Discs scheiterten mit "The volume key is unknown for this disc". Die
|
4K-UHD-Discs scheiterten mit "The volume key is unknown for this disc". Die
|
||||||
Ursache ist weder die Disc noch das Laufwerk — LibreDrive v06.3 laeuft und
|
Ursache ist weder die Disc noch das Laufwerk — LibreDrive v06.3 läuft und
|
||||||
MakeMKV liest die Disc — sondern:
|
MakeMKV liest die Disc — sondern:
|
||||||
|
|
||||||
makemkvcon unter LINUX ruft die Disc-Schluessel nie ab.
|
makemkvcon unter LINUX ruft die Disc-Schluessel nie ab.
|
||||||
@@ -10,35 +10,35 @@ MakeMKV liest die Disc — sondern:
|
|||||||
|
|
||||||
Gemessen, nicht vermutet:
|
Gemessen, nicht vermutet:
|
||||||
* Linux: in KEINEM Lauf auch nur eine Verbindung nach draussen. Geprueft mit
|
* Linux: in KEINEM Lauf auch nur eine Verbindung nach draussen. Geprueft mit
|
||||||
leerem UND mit gefuelltem Schluesselspeicher, mit und ohne --noscan, mit
|
leerem UND mit gefülltem Schlüsselspeicher, mit und ohne --noscan, mit
|
||||||
dev:/dev/sr0 und mit disc:0, und mit erzwungener frischer Pruefung
|
dev:/dev/sr0 und mit disc:0, und mit erzwungener frischer Prüfung
|
||||||
(update.conf geloescht, Meldung 5074 belegt den Web-Kontakt). Immer:
|
(update.conf gelöscht, Meldung 5074 belegt den Web-Kontakt). Immer:
|
||||||
keine Verbindung, kein Schluessel.
|
keine Verbindung, kein Schluessel.
|
||||||
* Windows, dieselbe Disc, dasselbe Laufwerk: Meldung 3338 "Downloading
|
* Windows, dieselbe Disc, dasselbe Laufwerk: Meldung 3338 "Downloading
|
||||||
latest HK to ...", Verbindung nach 185.84.108.20:443
|
latest HK to ...", Verbindung nach 185.84.108.20:443
|
||||||
(web33.majordomo.ru), _private_data.tar waechst — und die Disc geht auf
|
(web33.majordomo.ru), _private_data.tar wächst — und die Disc geht auf
|
||||||
(TCOUNT:5, "Operation successfully completed").
|
(TCOUNT:5, "Operation successfully completed").
|
||||||
* Der Code dafuer steckt auch im Linux-Binary: die Meldungsvorlage
|
* Der Code dafür steckt auch im Linux-Binary: die Meldungsvorlage
|
||||||
"Downloading latest %1 to %2 ..." steht in makemkvcon. Sie loest nur nie
|
"Downloading latest %1 to %2 ..." steht in makemkvcon. Sie löst nur nie
|
||||||
aus. Gleiches Symptom im Forum, seit Jahren offen und unbeantwortet.
|
aus. Gleiches Symptom im Forum, seit Jahren offen und unbeantwortet.
|
||||||
|
|
||||||
FRUEHERE FEHLDIAGNOSE, bewusst festgehalten: An dieser Stelle stand zuerst,
|
FRUEHERE FEHLDIAGNOSE, bewusst festgehalten: An dieser Stelle stand zuerst,
|
||||||
MakeMKVs Schluessel-Kanal sei abgeschaltet. Das war FALSCH. Die Herleitung
|
MakeMKVs Schluessel-Kanal sei abgeschaltet. Das war FALSCH. Die Herleitung
|
||||||
stuetzte sich auf zwei Hostnamen aus alten Forumsbeitraegen
|
stützte sich auf zwei Hostnamen aus alten Forumsbeitraegen
|
||||||
(hkdata.fairuse.org, hkdata.crabdance.com), die tatsaechlich nicht mehr
|
(hkdata.fairuse.org, hkdata.crabdance.com), die tatsaechlich nicht mehr
|
||||||
aufloesen — MakeMKV benutzt sie aber laengst nicht mehr. Der Dienst lebt, der
|
aufloesen — MakeMKV benutzt sie aber laengst nicht mehr. Der Dienst lebt, der
|
||||||
Worker erreicht ihn sogar; er wird unter Linux nur nie gefragt.
|
Worker erreicht ihn sogar; er wird unter Linux nur nie gefragt.
|
||||||
|
|
||||||
WAS DARAUS FOLGT: Der Schluesselspeicher (_private_data.tar) muss von einer
|
WAS DARAUS FOLGT: Der Schlüsselspeicher (_private_data.tar) muss von einer
|
||||||
MakeMKV-Installation kommen, die ihn wirklich abruft — praktisch von Windows.
|
MakeMKV-Installation kommen, die ihn wirklich abruft — praktisch von Windows.
|
||||||
Rippy nimmt ihn unter Einstellungen -> System entgegen. Die KEYDB.cfg bleibt
|
Rippy nimmt ihn unter Einstellungen -> System entgegen. Die KEYDB.cfg bleibt
|
||||||
der Notnagel fuer Pressungen, die auch MakeMKV selbst nicht kennt.
|
der Notnagel für Pressungen, die auch MakeMKV selbst nicht kennt.
|
||||||
|
|
||||||
Rippy liefert KEINE Schluessel mit, laedt keine herunter und verteilt keine.
|
Rippy liefert KEINE Schluessel mit, lädt keine herunter und verteilt keine.
|
||||||
Es verwaltet nur, was der Nutzer selbst mitbringt.
|
Es verwaltet nur, was der Nutzer selbst mitbringt.
|
||||||
|
|
||||||
QUELLEN (AGENTS Regel D — externe Schnittstellen nie aus dem Kopf):
|
QUELLEN (AGENTS Regel D — externe Schnittstellen nie aus dem Kopf):
|
||||||
* Linux laedt keine Hashed Keys — dasselbe Symptom, mehrfach berichtet:
|
* Linux lädt keine Hashed Keys — dasselbe Symptom, mehrfach berichtet:
|
||||||
https://forum.makemkv.com/forum/viewtopic.php?t=25782
|
https://forum.makemkv.com/forum/viewtopic.php?t=25782
|
||||||
https://forum.makemkv.com/forum/viewtopic.php?t=34022
|
https://forum.makemkv.com/forum/viewtopic.php?t=34022
|
||||||
* Schluessel liegen als hkd_*.bin in _private_data.tar:
|
* Schluessel liegen als hkd_*.bin in _private_data.tar:
|
||||||
@@ -55,7 +55,7 @@ QUELLEN (AGENTS Regel D — externe Schnittstellen nie aus dem Kopf):
|
|||||||
|
|
||||||
ZWILLINGSDATEI: liegt identisch unter docker/api/ und docker/worker/ — beide
|
ZWILLINGSDATEI: liegt identisch unter docker/api/ und docker/worker/ — beide
|
||||||
Images brauchen sie, ein gemeinsames Paket gibt es in diesem Projekt nicht
|
Images brauchen sie, ein gemeinsames Paket gibt es in diesem Projekt nicht
|
||||||
(gleiche Lage wie bei db.py). Aenderungen IMMER in BEIDEN Dateien nachziehen.
|
(gleiche Lage wie bei db.py). Änderungen IMMER in BEIDEN Dateien nachziehen.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
@@ -72,14 +72,14 @@ DATEN_DIR = os.getenv("MAKEMKV_DATA_DIR", "/root/.MakeMKV")
|
|||||||
# GROSS geschrieben — unter Linux case-sensitiv, "keydb.cfg" wird ignoriert.
|
# GROSS geschrieben — unter Linux case-sensitiv, "keydb.cfg" wird ignoriert.
|
||||||
KEYDB_NAME = "KEYDB.cfg"
|
KEYDB_NAME = "KEYDB.cfg"
|
||||||
|
|
||||||
# Obergrenze fuer den Upload. Eine vollstaendige oeffentliche KEYDB.cfg liegt
|
# Obergrenze für den Upload. Eine vollständige oeffentliche KEYDB.cfg liegt
|
||||||
# im einstelligen MB-Bereich; 64 MB sind reichlich Luft und verhindern, dass
|
# im einstelligen MB-Bereich; 64 MB sind reichlich Luft und verhindern, dass
|
||||||
# eine versehentlich hochgeladene Riesendatei den Speicher vollschreibt.
|
# eine versehentlich hochgeladene Riesendatei den Speicher vollschreibt.
|
||||||
MAX_KEYDB_BYTES = 64 * 1024 * 1024
|
MAX_KEYDB_BYTES = 64 * 1024 * 1024
|
||||||
|
|
||||||
# Eine Disc-Zeile beginnt mit der 40 Zeichen langen Hex-Kennung (optional mit
|
# Eine Disc-Zeile beginnt mit der 40 Zeichen langen Hex-Kennung (optional mit
|
||||||
# 0x davor), danach folgt das Gleichheitszeichen. Alles andere (Kommentare ab
|
# 0x davor), danach folgt das Gleichheitszeichen. Alles andere (Kommentare ab
|
||||||
# ";", Leerzeilen, Fortsetzungsfelder) zaehlt nicht als Eintrag.
|
# ";", Leerzeilen, Fortsetzungsfelder) zählt nicht als Eintrag.
|
||||||
_DISC_ZEILE = re.compile(r"^\s*(?:0x)?[0-9a-fA-F]{40}\s*=")
|
_DISC_ZEILE = re.compile(r"^\s*(?:0x)?[0-9a-fA-F]{40}\s*=")
|
||||||
|
|
||||||
|
|
||||||
@@ -94,9 +94,9 @@ def keydb_pfad(daten_dir: str = None) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def zaehle_disc_eintraege(inhalt: str) -> int:
|
def zaehle_disc_eintraege(inhalt: str) -> int:
|
||||||
"""Zeilen mit Disc-Kennung zaehlen (pure Funktion, testbar).
|
"""Zeilen mit Disc-Kennung zählen (pure Funktion, testbar).
|
||||||
|
|
||||||
Bewusst eine Heuristik und keine vollstaendige Auswertung: MakeMKV liest
|
Bewusst eine Heuristik und keine vollständige Auswertung: MakeMKV liest
|
||||||
die Datei mit seinem eigenen Parser, und wie viele Eintraege es daraus
|
die Datei mit seinem eigenen Parser, und wie viele Eintraege es daraus
|
||||||
macht, ist von aussen nicht sichtbar. Die Zahl dient nur dazu, im UI
|
macht, ist von aussen nicht sichtbar. Die Zahl dient nur dazu, im UI
|
||||||
"da liegt wirklich etwas drin" von "leere oder falsche Datei" zu
|
"da liegt wirklich etwas drin" von "leere oder falsche Datei" zu
|
||||||
@@ -106,17 +106,17 @@ def zaehle_disc_eintraege(inhalt: str) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def keydb_pruefen(inhalt: str) -> str:
|
def keydb_pruefen(inhalt: str) -> str:
|
||||||
"""Prueft hochgeladenen Inhalt; gibt deutschen Fehlertext oder "" zurueck.
|
"""Prüft hochgeladenen Inhalt; gibt deutschen Fehlertext oder "" zurück.
|
||||||
|
|
||||||
Verhindert den haeufigsten Bedienfehler: statt der KEYDB.cfg landet die
|
Verhindert den häufigsten Bedienfehler: statt der KEYDB.cfg landet die
|
||||||
HTML-Fehlerseite eines Downloads oder eine leere Datei im Verzeichnis —
|
HTML-Fehlerseite eines Downloads oder eine leere Datei im Verzeichnis —
|
||||||
MakeMKV wuerde dann still weiter "volume key is unknown" melden.
|
MakeMKV würde dann still weiter "volume key is unknown" melden.
|
||||||
"""
|
"""
|
||||||
if not inhalt.strip():
|
if not inhalt.strip():
|
||||||
return "Die Datei ist leer."
|
return "Die Datei ist leer."
|
||||||
if len(inhalt.encode("utf-8")) > MAX_KEYDB_BYTES:
|
if len(inhalt.encode("utf-8")) > MAX_KEYDB_BYTES:
|
||||||
return (
|
return (
|
||||||
"Die Datei ist groesser als "
|
"Die Datei ist größer als "
|
||||||
f"{MAX_KEYDB_BYTES // (1024 * 1024)} MB — das ist keine KEYDB.cfg."
|
f"{MAX_KEYDB_BYTES // (1024 * 1024)} MB — das ist keine KEYDB.cfg."
|
||||||
)
|
)
|
||||||
if inhalt.lstrip()[:1] == "<":
|
if inhalt.lstrip()[:1] == "<":
|
||||||
@@ -150,7 +150,7 @@ def keydb_status(daten_dir: str = None) -> dict:
|
|||||||
with open(pfad, encoding="utf-8", errors="replace") as datei:
|
with open(pfad, encoding="utf-8", errors="replace") as datei:
|
||||||
eintraege = zaehle_disc_eintraege(datei.read())
|
eintraege = zaehle_disc_eintraege(datei.read())
|
||||||
except OSError:
|
except OSError:
|
||||||
pass # Datei da, aber unlesbar: Groesse/Datum stimmen trotzdem
|
pass # Datei da, aber unlesbar: Größe/Datum stimmen trotzdem
|
||||||
return {
|
return {
|
||||||
"vorhanden": True,
|
"vorhanden": True,
|
||||||
"pfad": pfad,
|
"pfad": pfad,
|
||||||
@@ -163,7 +163,7 @@ def keydb_status(daten_dir: str = None) -> dict:
|
|||||||
def keydb_schreiben(inhalt: str, daten_dir: str = None) -> dict:
|
def keydb_schreiben(inhalt: str, daten_dir: str = None) -> dict:
|
||||||
"""Schreibt die KEYDB.cfg atomar und meldet den neuen Zustand.
|
"""Schreibt die KEYDB.cfg atomar und meldet den neuen Zustand.
|
||||||
|
|
||||||
Erst in eine Nebendatei, dann os.replace: waehrend ein Rip laeuft, darf
|
Erst in eine Nebendatei, dann os.replace: während ein Rip läuft, darf
|
||||||
makemkvcon niemals eine halb geschriebene Datei zu sehen bekommen.
|
makemkvcon niemals eine halb geschriebene Datei zu sehen bekommen.
|
||||||
"""
|
"""
|
||||||
pfad = keydb_pfad(daten_dir)
|
pfad = keydb_pfad(daten_dir)
|
||||||
@@ -244,32 +244,32 @@ def dumps_auflisten(daten_dir: str = None) -> list:
|
|||||||
return liste
|
return liste
|
||||||
|
|
||||||
|
|
||||||
# --- Schluesselspeicher (_private_data.tar) -------------------------------
|
# --- Schlüsselspeicher (_private_data.tar) -------------------------------
|
||||||
#
|
#
|
||||||
# Das ist MakeMKVs eigener Speicher fuer die "Hashed Keys": ein tar-Archiv mit
|
# Das ist MakeMKVs eigener Speicher für die "Hashed Keys": ein tar-Archiv mit
|
||||||
# hkd_*.bin-Eintraegen. Unter Windows fuellt MakeMKV es selbst (Meldung 3338),
|
# hkd_*.bin-Eintraegen. Unter Windows füllt MakeMKV es selbst (Meldung 3338),
|
||||||
# unter Linux nie — siehe Modul-Kopf. Rippy nimmt die Datei deshalb entgegen
|
# unter Linux nie — siehe Modul-Kopf. Rippy nimmt die Datei deshalb entgegen
|
||||||
# und legt sie ins Datenverzeichnis; MakeMKV liest sie beim naechsten Start.
|
# und legt sie ins Datenverzeichnis; MakeMKV liest sie beim nächsten Start.
|
||||||
|
|
||||||
PRIVATE_DATA_NAME = "_private_data.tar"
|
PRIVATE_DATA_NAME = "_private_data.tar"
|
||||||
|
|
||||||
# Der Speicher lag am 25.07.2026 bei rund 6 MB und waechst mit jeder neuen
|
# Der Speicher lag am 25.07.2026 bei rund 6 MB und wächst mit jeder neuen
|
||||||
# Pressung. 64 MB sind reichlich Luft — und derselbe Wert wie
|
# Pressung. 64 MB sind reichlich Luft — und derselbe Wert wie
|
||||||
# client_max_body_size in docker/ui/nginx.conf: waere die Grenze hier hoeher,
|
# client_max_body_size in docker/ui/nginx.conf: wäre die Grenze hier höher,
|
||||||
# wuerde nginx den Upload abweisen, bevor die API ihn ueberhaupt sieht.
|
# würde nginx den Upload abweisen, bevor die API ihn überhaupt sieht.
|
||||||
MAX_PRIVATE_DATA_BYTES = 64 * 1024 * 1024
|
MAX_PRIVATE_DATA_BYTES = 64 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
def private_data_pfad(daten_dir: str = None) -> str:
|
def private_data_pfad(daten_dir: str = None) -> str:
|
||||||
"""Voller Pfad zum Schluesselspeicher im Datenverzeichnis."""
|
"""Voller Pfad zum Schlüsselspeicher im Datenverzeichnis."""
|
||||||
return os.path.join(daten_dir or DATEN_DIR, PRIVATE_DATA_NAME)
|
return os.path.join(daten_dir or DATEN_DIR, PRIVATE_DATA_NAME)
|
||||||
|
|
||||||
|
|
||||||
def zaehle_schluessel(rohdaten: bytes) -> int:
|
def zaehle_schluessel(rohdaten: bytes) -> int:
|
||||||
"""Anzahl der hkd_*.bin-Eintraege im Archiv (pure Funktion, testbar).
|
"""Anzahl der hkd_*.bin-Eintraege im Archiv (pure Funktion, testbar).
|
||||||
|
|
||||||
Das ist die ehrliche Kennzahl fuer "wie viele Disc-Schluessel kennt diese
|
Das ist die ehrliche Kennzahl für "wie viele Disc-Schluessel kennt diese
|
||||||
Installation". Ein frischer, leerer Speicher enthaelt nur eine
|
Installation". Ein frischer, leerer Speicher enthält nur eine
|
||||||
Index-Datei und kommt hier auf 0 — genau der Zustand, in dem jede
|
Index-Datei und kommt hier auf 0 — genau der Zustand, in dem jede
|
||||||
unbekannte UHD-Disc scheitert.
|
unbekannte UHD-Disc scheitert.
|
||||||
"""
|
"""
|
||||||
@@ -281,20 +281,20 @@ def zaehle_schluessel(rohdaten: bytes) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def private_data_pruefen(rohdaten: bytes) -> str:
|
def private_data_pruefen(rohdaten: bytes) -> str:
|
||||||
"""Prueft hochgeladene Rohdaten; deutscher Fehlertext oder "".
|
"""Prüft hochgeladene Rohdaten; deutscher Fehlertext oder "".
|
||||||
|
|
||||||
Faengt die beiden Bedienfehler ab, die sonst still danebengehen: eine
|
Faengt die beiden Bedienfehler ab, die sonst still danebengehen: eine
|
||||||
voellig andere Datei hochladen, oder den Speicher einer Installation, die
|
voellig andere Datei hochladen, oder den Speicher einer Installation, die
|
||||||
selbst noch keine Schluessel geholt hat (dann aendert sich nichts, und
|
selbst noch keine Schluessel geholt hat (dann ändert sich nichts, und
|
||||||
niemand versteht warum).
|
niemand versteht warum).
|
||||||
"""
|
"""
|
||||||
if not rohdaten:
|
if not rohdaten:
|
||||||
return "Die Datei ist leer."
|
return "Die Datei ist leer."
|
||||||
if len(rohdaten) > MAX_PRIVATE_DATA_BYTES:
|
if len(rohdaten) > MAX_PRIVATE_DATA_BYTES:
|
||||||
return (
|
return (
|
||||||
"Die Datei ist groesser als "
|
"Die Datei ist größer als "
|
||||||
f"{MAX_PRIVATE_DATA_BYTES // (1024 * 1024)} MB — das ist kein "
|
f"{MAX_PRIVATE_DATA_BYTES // (1024 * 1024)} MB — das ist kein "
|
||||||
"MakeMKV-Schluesselspeicher."
|
"MakeMKV-Schlüsselspeicher."
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with tarfile.open(fileobj=io.BytesIO(rohdaten)) as archiv:
|
with tarfile.open(fileobj=io.BytesIO(rohdaten)) as archiv:
|
||||||
@@ -308,13 +308,13 @@ def private_data_pruefen(rohdaten: bytes) -> str:
|
|||||||
return (
|
return (
|
||||||
"In dieser Datei steckt kein einziger Schluessel (kein hkd_*.bin). "
|
"In dieser Datei steckt kein einziger Schluessel (kein hkd_*.bin). "
|
||||||
"Sie stammt vermutlich von einer MakeMKV-Installation, die selbst "
|
"Sie stammt vermutlich von einer MakeMKV-Installation, die selbst "
|
||||||
"noch keine geholt hat — oeffne dort erst einmal eine Disc."
|
"noch keine geholt hat — öffne dort erst einmal eine Disc."
|
||||||
)
|
)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def schluesselspeicher_status(daten_dir: str = None) -> dict:
|
def schluesselspeicher_status(daten_dir: str = None) -> dict:
|
||||||
"""Zustand des Schluesselspeichers. Fehlt er, ist das kein Fehler."""
|
"""Zustand des Schlüsselspeichers. Fehlt er, ist das kein Fehler."""
|
||||||
pfad = private_data_pfad(daten_dir)
|
pfad = private_data_pfad(daten_dir)
|
||||||
try:
|
try:
|
||||||
angaben = os.stat(pfad)
|
angaben = os.stat(pfad)
|
||||||
@@ -338,9 +338,9 @@ def schluesselspeicher_status(daten_dir: str = None) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def private_data_schreiben(rohdaten: bytes, daten_dir: str = None) -> dict:
|
def private_data_schreiben(rohdaten: bytes, daten_dir: str = None) -> dict:
|
||||||
"""Legt den Schluesselspeicher atomar ab und meldet den neuen Zustand.
|
"""Legt den Schlüsselspeicher atomar ab und meldet den neuen Zustand.
|
||||||
|
|
||||||
Atomar aus demselben Grund wie bei der KEYDB.cfg: waehrend ein Rip laeuft,
|
Atomar aus demselben Grund wie bei der KEYDB.cfg: während ein Rip läuft,
|
||||||
darf makemkvcon nie ein halb geschriebenes Archiv sehen.
|
darf makemkvcon nie ein halb geschriebenes Archiv sehen.
|
||||||
"""
|
"""
|
||||||
pfad = private_data_pfad(daten_dir)
|
pfad = private_data_pfad(daten_dir)
|
||||||
@@ -363,9 +363,9 @@ def settings_conf_zusammenfuehren(inhalt: str, key: str) -> str:
|
|||||||
"""app_Key setzen, ohne den Rest der settings.conf zu verlieren (pure).
|
"""app_Key setzen, ohne den Rest der settings.conf zu verlieren (pure).
|
||||||
|
|
||||||
Bis zum 25.07.2026 haben entrypoint.sh UND tasks.py die Datei komplett
|
Bis zum 25.07.2026 haben entrypoint.sh UND tasks.py die Datei komplett
|
||||||
ueberschrieben. Mit dem jetzt persistenten Datenverzeichnis waere damit
|
überschrieben. Mit dem jetzt persistenten Datenverzeichnis wäre damit
|
||||||
bei jedem Containerstart und vor jedem Rip alles andere weg — z. B.
|
bei jedem Containerstart und vor jedem Rip alles andere weg — z. B.
|
||||||
app_UpdateEnable. Leerer Key laesst die vorhandene Zeile ebenfalls fallen,
|
app_UpdateEnable. Leerer Key lässt die vorhandene Zeile ebenfalls fallen,
|
||||||
damit ein bewusst geleerter Key nicht heimlich weiterwirkt.
|
damit ein bewusst geleerter Key nicht heimlich weiterwirkt.
|
||||||
"""
|
"""
|
||||||
zeilen = [z for z in inhalt.splitlines() if not z.lstrip().startswith("app_Key")]
|
zeilen = [z for z in inhalt.splitlines() if not z.lstrip().startswith("app_Key")]
|
||||||
|
|||||||
+30
-11
@@ -140,24 +140,43 @@ def episoden_umbenennen(ordner: str, serie: str, staffel: int, zuordnung: list)
|
|||||||
|
|
||||||
|
|
||||||
def bibliothek_refresh(media_server: str, url: str, api_key: str) -> str:
|
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,
|
Jellyfin/Emby: POST /Library/Refresh, Auth über X-Emby-Token.
|
||||||
Auth über den Header X-Emby-Token. Rückgabe: Klartext fürs Log.
|
Kodi: POST /jsonrpc, VideoLibrary.Scan, Auth über Basic Auth (api_key = user:pass).
|
||||||
Wirft nie — der Refresh ist Komfort, nicht Teil des Rips.
|
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 ""
|
return ""
|
||||||
try:
|
try:
|
||||||
antwort = requests.post(
|
if media_server in ("jellyfin", "emby") and api_key:
|
||||||
url.rstrip("/") + "/Library/Refresh",
|
antwort = requests.post(
|
||||||
headers={"X-Emby-Token": api_key}, timeout=15,
|
url.rstrip("/") + "/Library/Refresh",
|
||||||
)
|
headers={"X-Emby-Token": api_key}, timeout=15,
|
||||||
if antwort.status_code < 300:
|
)
|
||||||
return f"{media_server}-Bibliothek aktualisiert ({url})"
|
if antwort.status_code < 300:
|
||||||
return f"Bibliotheks-Refresh: HTTP {antwort.status_code} von {url}"
|
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:
|
except requests.RequestException as e:
|
||||||
return f"Bibliotheks-Refresh fehlgeschlagen: {e}"
|
return f"Bibliotheks-Refresh fehlgeschlagen: {e}"
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def baue_nfo(meta: dict, titel: str, jahr=None) -> str:
|
def baue_nfo(meta: dict, titel: str, jahr=None) -> str:
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ redis==5.0.4
|
|||||||
requests==2.32.3
|
requests==2.32.3
|
||||||
sqlalchemy==2.0.32
|
sqlalchemy==2.0.32
|
||||||
psycopg2-binary==2.9.9
|
psycopg2-binary==2.9.9
|
||||||
|
flet==0.23.2
|
||||||
|
|||||||
+492
-57
@@ -17,6 +17,8 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
from winlauf import OHNE_FENSTER
|
||||||
|
|
||||||
RIP_OUTPUT_DIR = os.getenv("RIP_OUTPUT_DIR", "/app/media")
|
RIP_OUTPUT_DIR = os.getenv("RIP_OUTPUT_DIR", "/app/media")
|
||||||
|
|
||||||
|
|
||||||
@@ -25,6 +27,111 @@ class RipAbbruch(Exception):
|
|||||||
Nutzer den Job abgebrochen hat (Status 'canceling' in der DB)."""
|
Nutzer den Job abgebrochen hat (Status 'canceling' in der DB)."""
|
||||||
|
|
||||||
|
|
||||||
|
# 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 _auswurf_geglueckt(status: int) -> bool:
|
||||||
|
"""Ist die Disc nach dem Auswurf wirklich draußen? (pure Funktion)
|
||||||
|
|
||||||
|
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).
|
||||||
|
"""
|
||||||
|
if ioctl_fn is None:
|
||||||
|
try:
|
||||||
|
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:
|
def check_makemkv_installed() -> bool:
|
||||||
"""Prüft, ob makemkvcon installiert ist."""
|
"""Prüft, ob makemkvcon installiert ist."""
|
||||||
return shutil.which("makemkvcon") is not None
|
return shutil.which("makemkvcon") is not None
|
||||||
@@ -104,13 +211,119 @@ def parse_titel_info(ausgabe: str) -> list:
|
|||||||
return [titel[nr] for nr in sorted(titel)]
|
return [titel[nr] for nr in sorted(titel)]
|
||||||
|
|
||||||
|
|
||||||
def lies_titel_info(device_path: str, timeout: int = 300) -> list:
|
# Attribut-IDs der SINFO-Zeilen. Am 26.07.2026 an der Akira-Blu-ray im Laufwerk
|
||||||
"""Volle Titel-Liste der eingelegten Disc (makemkvcon info, Robot-Mode)."""
|
# 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(
|
ergebnis = subprocess.run(
|
||||||
["makemkvcon", "-r", "--noscan", "info", f"dev:{device_path}"],
|
["makemkvcon", "-r", "--noscan", "info", f"dev:{device_path}"],
|
||||||
capture_output=True, text=True, timeout=timeout,
|
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,
|
def rip_titel_auswahl(device_path: str, output_dir: str, titel_liste: list,
|
||||||
@@ -135,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}
|
return {"status": "success", "output_dir": output_dir, "files": alle_dateien}
|
||||||
|
|
||||||
|
|
||||||
def laengster_titel(dauern: dict):
|
def laengster_titel(dauern: dict, meta: dict = None):
|
||||||
"""Titel-Nummer mit der längsten Laufzeit — der Hauptfilm (pure Funktion)."""
|
"""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:
|
if not dauern:
|
||||||
return None
|
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)
|
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:
|
def lies_titel_dauern(device_path: str, timeout: int = 300) -> dict:
|
||||||
"""Fragt die Titel-Laufzeiten der Disc ab (makemkvcon info, Robot-Mode)."""
|
"""Fragt die Titel-Laufzeiten der Disc ab (makemkvcon info, Robot-Mode)."""
|
||||||
try:
|
try:
|
||||||
ergebnis = subprocess.run(
|
ergebnis = subprocess.run(
|
||||||
["makemkvcon", "-r", "--noscan", "info", f"dev:{device_path}"],
|
["makemkvcon", "-r", "--noscan", "info", f"dev:{device_path}"],
|
||||||
capture_output=True, text=True, timeout=timeout,
|
capture_output=True, text=True, timeout=timeout,
|
||||||
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
return {}
|
return {}
|
||||||
@@ -176,6 +422,7 @@ def lies_datei_dauer(pfad: str, timeout: int = 120) -> int:
|
|||||||
ergebnis = subprocess.run(
|
ergebnis = subprocess.run(
|
||||||
["HandBrakeCLI", "--scan", "-i", pfad],
|
["HandBrakeCLI", "--scan", "-i", pfad],
|
||||||
capture_output=True, text=True, timeout=timeout,
|
capture_output=True, text=True, timeout=timeout,
|
||||||
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
return 0
|
return 0
|
||||||
@@ -193,9 +440,9 @@ def parse_msg(zeile: str):
|
|||||||
fertig zusammengesetzte Klartext.
|
fertig zusammengesetzte Klartext.
|
||||||
|
|
||||||
Bis zum 25.07.2026 stand hier line.split(",", 4)[3]: das schnitt JEDE
|
Bis zum 25.07.2026 stand hier line.split(",", 4)[3]: das schnitt JEDE
|
||||||
Meldung ab, die selbst ein Komma enthaelt — und MakeMKV schreibt solche
|
Meldung ab, die selbst ein Komma enthält — und MakeMKV schreibt solche
|
||||||
laufend ("Title #1 has length of 12 seconds, which is less than ...").
|
laufend ("Title #1 has length of 12 seconds, which is less than ...").
|
||||||
Deshalb eine Regex, die die Anfuehrungszeichen respektiert.
|
Deshalb eine Regex, die die Anführungszeichen respektiert.
|
||||||
"""
|
"""
|
||||||
treffer = _MSG_RE.match(zeile.strip())
|
treffer = _MSG_RE.match(zeile.strip())
|
||||||
if not treffer:
|
if not treffer:
|
||||||
@@ -236,6 +483,34 @@ PRESET_SCHLUESSEL = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Reservierter Wert im Preset-Feld eines Disc-Typs: „diesen Typ NICHT
|
||||||
|
# komprimieren". Kein HandBrake-Preset heißt so (gegengeprüft gegen alle 90 aus
|
||||||
|
# --preset-list), deshalb kann er nicht mit einem echten Namen kollidieren.
|
||||||
|
#
|
||||||
|
# Warum es das gibt (Befund 25.07.2026): Ein 4K-HEVC-Encode brauchte auf der
|
||||||
|
# Rippy-VM gemessene 28-55 Stunden, weil die CPU kein AVX2 hat. Bis hierher gab
|
||||||
|
# es nur einen globalen Schalter — entweder ALLES komprimieren oder nichts. Wer
|
||||||
|
# 4K verlustfrei behalten und DVDs trotzdem schrumpfen wollte, hatte keine
|
||||||
|
# Möglichkeit. Genau die ist das hier.
|
||||||
|
PRESET_KEINE = "keine"
|
||||||
|
|
||||||
|
|
||||||
|
def komprimieren_fuer(disc_type: str, einstellungen: dict) -> bool:
|
||||||
|
"""Soll dieser Disc-Typ nach dem Rip komprimiert werden? (pure, testbar)
|
||||||
|
|
||||||
|
Der globale Schalter `transcodeEnabled` schlägt alles. Ist er an, kann ein
|
||||||
|
einzelner Disc-Typ abgewählt werden, indem sein Preset auf PRESET_KEINE
|
||||||
|
steht — dann bleibt die verlustfreie Datei aus dem Rip stehen.
|
||||||
|
"""
|
||||||
|
einstellungen = einstellungen or {}
|
||||||
|
if not einstellungen.get("transcodeEnabled", True):
|
||||||
|
return False
|
||||||
|
schluessel = PRESET_SCHLUESSEL.get(disc_type or "")
|
||||||
|
if schluessel and (einstellungen.get(schluessel) or "").strip() == PRESET_KEINE:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def preset_fuer(disc_type: str, einstellungen: dict) -> str:
|
def preset_fuer(disc_type: str, einstellungen: dict) -> str:
|
||||||
"""Welches HandBrake-Preset gilt für diesen Disc-Typ? (pure, testbar)
|
"""Welches HandBrake-Preset gilt für diesen Disc-Typ? (pure, testbar)
|
||||||
|
|
||||||
@@ -243,97 +518,246 @@ def preset_fuer(disc_type: str, einstellungen: dict) -> str:
|
|||||||
DEFAULT_HB_PRESET. Diese Kette hält Bestandsinstallationen am Leben, in
|
DEFAULT_HB_PRESET. Diese Kette hält Bestandsinstallationen am Leben, in
|
||||||
denen nur `transcodePreset` gesetzt ist: solange der Nutzer die neuen
|
denen nur `transcodePreset` gesetzt ist: solange der Nutzer die neuen
|
||||||
Felder nicht speichert, ändert sich sein Verhalten nicht.
|
Felder nicht speichert, ändert sich sein Verhalten nicht.
|
||||||
|
|
||||||
|
PRESET_KEINE gilt hier absichtlich als „nicht gesetzt" und wird
|
||||||
|
übersprungen: Diese Funktion darf NIE den Reservewert als Preset-Namen
|
||||||
|
zurückgeben, sonst bekäme HandBrake `--preset keine` und scheiterte. Ob
|
||||||
|
überhaupt komprimiert wird, entscheidet komprimieren_fuer(). Wer über
|
||||||
|
„Neu komprimieren" ausdrücklich doch komprimieren will, bekommt so ein
|
||||||
|
brauchbares Preset statt eines Fehlers.
|
||||||
"""
|
"""
|
||||||
einstellungen = einstellungen or {}
|
einstellungen = einstellungen or {}
|
||||||
schluessel = PRESET_SCHLUESSEL.get(disc_type or "")
|
schluessel = PRESET_SCHLUESSEL.get(disc_type or "")
|
||||||
if schluessel:
|
if schluessel:
|
||||||
eigen = (einstellungen.get(schluessel) or "").strip()
|
eigen = (einstellungen.get(schluessel) or "").strip()
|
||||||
if eigen:
|
if eigen and eigen != PRESET_KEINE:
|
||||||
return eigen
|
return eigen
|
||||||
return (einstellungen.get("transcodePreset") or "").strip() or DEFAULT_HB_PRESET
|
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).
|
"""Baut das HandBrake-Kommando für die Kompressions-Stufe (pure Funktion).
|
||||||
|
|
||||||
Arbeitet auf der MKV-DATEI aus dem MakeMKV-Rip — nie auf dem Laufwerk:
|
Arbeitet auf der MKV-DATEI aus dem MakeMKV-Rip — nie auf dem Laufwerk:
|
||||||
HandBrake kann AACS-verschlüsselte Blu-rays nicht lesen, MakeMKV bleibt
|
HandBrake kann AACS-verschlüsselte Blu-rays nicht lesen, MakeMKV bleibt
|
||||||
deshalb zwingend die erste Stufe. --all-audio/--all-subtitles behalten
|
deshalb zwingend die erste Stufe. --all-audio/--all-subtitles behalten
|
||||||
alle Sprachen (Preset-Default wäre nur die erste Tonspur).
|
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",
|
"HandBrakeCLI",
|
||||||
"--input", input_path,
|
"--input", input_path,
|
||||||
"--output", output_path,
|
"--output", output_path,
|
||||||
"--preset", preset,
|
"--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(
|
||||||
|
r'^Encoding:\s*task\s+(\d+)\s+of\s+(\d+),\s*(\d+(?:\.\d+)?)\s*%'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_progress_from_line(line: str) -> int:
|
def get_progress_from_line(line: str) -> int:
|
||||||
"""Extrahiert Fortschritt in Prozent aus HandBrake-Ausgabe.
|
"""Extrahiert den ENCODE-Fortschritt in Prozent aus HandBrake-Ausgabe.
|
||||||
|
|
||||||
Testfund 22.07.: echtes HandBrake schreibt „45.50 %" MIT Leerzeichen vor
|
Gibt **-1** zurück, wenn die Zeile kein Encode-Fortschritt ist — genau wie
|
||||||
dem Prozentzeichen — eine Regex ohne \\s* parst NIE echte Ausgabe.
|
get_progress_from_prgv. Sonst wäre ein echtes „0 %" nicht von „keine
|
||||||
|
Angabe" zu unterscheiden, und der Anfang des Encodes bliebe unsichtbar.
|
||||||
|
|
||||||
|
Formatstrings aus dem HandBrakeCLI-Binary im Worker-Image gelesen
|
||||||
|
(AGENTS Regel D — nicht aus dem Kopf):
|
||||||
|
Scanning title %d of %d, preview %d, %.2f %% → Scan, NICHT Encode
|
||||||
|
Encoding: task %d of %d, %.2f %% (%.2f fps, avg → Encode
|
||||||
|
Encoding: task %d of %d, Searching for start time, %.2f → Vorlauf, NICHT Encode
|
||||||
|
|
||||||
|
Befund 25.07.2026 (Akira-UHD, live gemessen): Die alte Regex nahm JEDE
|
||||||
|
Zahl vor einem Prozentzeichen — also auch den Scan-Durchlauf, der VOR dem
|
||||||
|
Encodieren einmal bis 100 % hochläuft. Die Anzeige stand deshalb auf 99 %,
|
||||||
|
während der eigentliche Encode bei 1 % war. Zusammen mit dem alten Filter
|
||||||
|
`progress > 0` im Aufrufer blieb sie dort stundenlang kleben, weil
|
||||||
|
int(0.87) == 0 verworfen wurde.
|
||||||
|
|
||||||
|
`task N of M` wird mitgerechnet: Presets mit zwei Durchläufen zählen die
|
||||||
|
Prozente je Durchlauf neu, sonst sprang die Anzeige mitten in der Datei
|
||||||
|
zurück auf 0.
|
||||||
"""
|
"""
|
||||||
match = re.search(r'(\d+\.\d+)\s*%', line)
|
match = HB_ENCODE_RE.match(line.strip())
|
||||||
if match:
|
if not match:
|
||||||
return int(float(match.group(1)))
|
return -1
|
||||||
return 0
|
aktuell, gesamt, prozent = int(match.group(1)), int(match.group(2)), float(match.group(3))
|
||||||
|
if gesamt < 1 or aktuell < 1:
|
||||||
|
return -1
|
||||||
|
return int(((aktuell - 1) * 100 + prozent) / gesamt)
|
||||||
|
|
||||||
|
|
||||||
def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET, progress_cb=None) -> dict:
|
def sprachliste(wert) -> list:
|
||||||
"""Komprimiert eine MKV-Datei mit HandBrakeCLI; meldet Fortschritt."""
|
"""„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,
|
||||||
|
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
|
||||||
|
werfen, wenn der Nutzer abgebrochen hat. Bewusst ein eigener Kanal neben
|
||||||
|
`progress_cb` — aus demselben Grund wie `log_cb` bei run_makemkv: der
|
||||||
|
Fortschritts-Callback verwirft Aufrufe mit unveränderter Prozentzahl, und
|
||||||
|
genau daran hing der Abbruch früher. Bei einem 4K-Encode mit einem Prozent
|
||||||
|
je halber Stunde sah „Abbrechen" deshalb minutenlang wirkungslos aus
|
||||||
|
(Befund 25.07.2026). Zusätzlich greift es damit auch während des
|
||||||
|
Scan-Durchlaufs, der gar keine Encode-Prozente ausgibt.
|
||||||
|
"""
|
||||||
if not check_handbrake_installed():
|
if not check_handbrake_installed():
|
||||||
return {"status": "error", "error": "HandBrakeCLI ist nicht installiert"}
|
return {"status": "error", "error": "HandBrakeCLI ist nicht installiert"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
process = subprocess.Popen(
|
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,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
text=True,
|
text=True,
|
||||||
bufsize=1
|
bufsize=1,
|
||||||
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
|
return _handbrake_schleife(process, output_path, abbruch_cb, progress_cb)
|
||||||
try:
|
|
||||||
for line in process.stdout:
|
|
||||||
progress = get_progress_from_line(line)
|
|
||||||
if progress > 0 and progress_cb:
|
|
||||||
progress_cb(progress)
|
|
||||||
except RipAbbruch:
|
|
||||||
process.kill()
|
|
||||||
process.wait()
|
|
||||||
return {"status": "cancelled", "error": "Abgebrochen durch Nutzer"}
|
|
||||||
|
|
||||||
process.wait()
|
|
||||||
|
|
||||||
if process.returncode == 0 and os.path.exists(output_path):
|
|
||||||
return {"status": "success", "output_path": output_path}
|
|
||||||
return {
|
|
||||||
"status": "error",
|
|
||||||
"error": f"HandBrake endete mit Code {process.returncode}",
|
|
||||||
"return_code": process.returncode,
|
|
||||||
}
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"status": "error", "error": str(e)}
|
return {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
def build_abcde_cmd(device_path: str, config_path: str) -> list:
|
def unbekanntes_preset(zeile: str) -> str:
|
||||||
|
"""Meldet HandBrake diese Zeile ein UNBEKANNTES Preset? Name oder "".
|
||||||
|
|
||||||
|
Wortlaut aus dem echten Lauf im Worker-Image (26.07.2026, AGENTS Regel D):
|
||||||
|
|
||||||
|
$ HandBrakeCLI --preset 'Gibt Es Nicht 999p' …
|
||||||
|
Invalid preset Gibt Es Nicht 999p
|
||||||
|
Valid presets are:
|
||||||
|
|
||||||
|
Ohne diese Übersetzung stand im UI nur „HandBrake endete mit Code 3" —
|
||||||
|
und die eigentliche Ursache (ein Preset-Name, den DIESES HandBrake nicht
|
||||||
|
kennt) war daraus nicht zu erraten. Genau dieser Fall wird häufiger, weil
|
||||||
|
die Namen je HandBrake-Version verschieden sind und ein Remote-Worker eine
|
||||||
|
andere Version haben kann als die Rippy-VM.
|
||||||
|
"""
|
||||||
|
text = (zeile or "").strip()
|
||||||
|
kopf = "Invalid preset "
|
||||||
|
return text[len(kopf):].strip() if text.startswith(kopf) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=None) -> dict:
|
||||||
|
"""Liest HandBrakes Ausgabe und wertet sie aus. Eigene Funktion, damit die
|
||||||
|
Reihenfolge (Abbruch VOR Fortschritt) ohne echtes HandBrake testbar ist."""
|
||||||
|
falsches_preset = ""
|
||||||
|
try:
|
||||||
|
for line in process.stdout:
|
||||||
|
# Zuerst der Abbruch — unabhängig davon, ob die Zeile überhaupt
|
||||||
|
# einen Fortschritt enthält. Sonst hängt „Abbrechen" daran, dass
|
||||||
|
# sich die Prozentzahl bewegt (Befund 25.07.2026).
|
||||||
|
if abbruch_cb:
|
||||||
|
abbruch_cb()
|
||||||
|
if not falsches_preset:
|
||||||
|
falsches_preset = unbekanntes_preset(line)
|
||||||
|
progress = get_progress_from_line(line)
|
||||||
|
# >= 0: ein echtes 0 % ist eine Angabe und muss durch. Der alte
|
||||||
|
# Filter `> 0` verwarf den gesamten ersten Prozentpunkt — bei
|
||||||
|
# einem 4K-Encode sind das Stunden ohne sichtbare Bewegung.
|
||||||
|
if progress >= 0 and progress_cb:
|
||||||
|
progress_cb(progress)
|
||||||
|
except RipAbbruch:
|
||||||
|
process.kill()
|
||||||
|
process.wait()
|
||||||
|
return {"status": "cancelled", "error": "Abgebrochen durch Nutzer"}
|
||||||
|
|
||||||
|
process.wait()
|
||||||
|
|
||||||
|
if process.returncode == 0 and os.path.exists(output_path):
|
||||||
|
return {"status": "success", "output_path": output_path}
|
||||||
|
if falsches_preset:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": (
|
||||||
|
f'Das HandBrake dieses Workers kennt das Preset „{falsches_preset}" '
|
||||||
|
'nicht. Preset-Namen unterscheiden sich zwischen '
|
||||||
|
'HandBrake-Versionen. Abhilfe: Einstellungen → Verarbeitung, dort '
|
||||||
|
'auf „Bestes wählen" klicken — die Liste dort kommt vom Worker '
|
||||||
|
'selbst und enthält nur Namen, die es wirklich gibt.'
|
||||||
|
),
|
||||||
|
"return_code": process.returncode,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": f"HandBrake endete mit Code {process.returncode}",
|
||||||
|
"return_code": process.returncode,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_abcde_cmd(device_path: str, config_path: str, auswerfen: bool = True) -> list:
|
||||||
"""Baut das abcde-Kommando (pure Funktion, testbar).
|
"""Baut das abcde-Kommando (pure Funktion, testbar).
|
||||||
|
|
||||||
-o = Ausgabeformat (flac), -N = nicht-interaktiv, -x = Eject am Ende,
|
-o = Ausgabeformat (flac), -N = nicht-interaktiv, -x = Eject am Ende,
|
||||||
-c = Config-Datei (enthält OUTPUTDIR). NIE ein Verzeichnis an -o geben.
|
-c = Config-Datei (enthält OUTPUTDIR). NIE ein Verzeichnis an -o geben.
|
||||||
|
|
||||||
|
Befund 25.07.2026: `-x` stand fest verdrahtet drin. Damit warf eine
|
||||||
|
Audio-CD IMMER aus, während eine DVD/Blu-ray NIE auswarf — und die
|
||||||
|
Einstellung „Automatischer Auswurf" regelte keines von beidem, weil sie
|
||||||
|
nirgends gelesen wurde. Jetzt entscheidet sie hier mit.
|
||||||
"""
|
"""
|
||||||
return [
|
cmd = [
|
||||||
"abcde",
|
"abcde",
|
||||||
"-d", device_path,
|
"-d", device_path,
|
||||||
"-o", "flac",
|
"-o", "flac",
|
||||||
"-N",
|
"-N",
|
||||||
"-x",
|
|
||||||
"-c", config_path
|
|
||||||
]
|
]
|
||||||
|
if auswerfen:
|
||||||
|
cmd.append("-x")
|
||||||
|
cmd += ["-c", config_path]
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
def write_abcde_config(output_dir: str) -> str:
|
def write_abcde_config(output_dir: str) -> str:
|
||||||
@@ -353,7 +777,7 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
|
|||||||
log_cb(code, text) bekommt JEDE MakeMKV-Meldung. Bewusst ein eigener
|
log_cb(code, text) bekommt JEDE MakeMKV-Meldung. Bewusst ein eigener
|
||||||
Kanal statt progress_cb: der Fortschritts-Callback in tasks.py verwirft
|
Kanal statt progress_cb: der Fortschritts-Callback in tasks.py verwirft
|
||||||
Aufrufe, bei denen sich die Prozentzahl nicht geaendert hat — Meldungen
|
Aufrufe, bei denen sich die Prozentzahl nicht geaendert hat — Meldungen
|
||||||
waeren dort also grossteils verschwunden. Ohne diesen Kanal war am
|
wären dort also großteils verschwunden. Ohne diesen Kanal war am
|
||||||
25.07.2026 nicht von aussen erkennbar, dass MakeMKV bei der UHD-Disc
|
25.07.2026 nicht von aussen erkennbar, dass MakeMKV bei der UHD-Disc
|
||||||
nicht einmal versucht, einen Schluessel zu holen (siehe makemkv_daten).
|
nicht einmal versucht, einen Schluessel zu holen (siehe makemkv_daten).
|
||||||
"""
|
"""
|
||||||
@@ -368,7 +792,8 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
|
|||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
text=True,
|
text=True,
|
||||||
bufsize=1
|
bufsize=1,
|
||||||
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
|
|
||||||
letzte_meldung = ""
|
letzte_meldung = ""
|
||||||
@@ -439,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,
|
def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_cb=None,
|
||||||
output_dir: str = None, nur_hauptfilm: bool = False,
|
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.
|
"""Rippt eine DVD oder Blu-ray verlustfrei mit MakeMKV.
|
||||||
|
|
||||||
Bewusst KEIN eigener Celery-Task: der einzige Task ist worker.tasks.rip_disc,
|
Bewusst KEIN eigener Celery-Task: der einzige Task ist worker.tasks.rip_disc,
|
||||||
der hier mit seinem eigenen Fortschritts-Callback durchgreift. output_dir
|
der hier mit seinem eigenen Fortschritts-Callback durchgreift. output_dir
|
||||||
überschreibt das Ziel (Transcode-Fall: Roh-Rip nach /app/temp).
|
überschreibt das Ziel (Transcode-Fall: Roh-Rip nach /app/temp).
|
||||||
titel_liste: exakte Titel-Auswahl aus dem UI (schlägt nur_hauptfilm).
|
titel_liste: exakte Titel-Auswahl aus dem UI (schlägt nur_hauptfilm).
|
||||||
nur_hauptfilm: Vorab-Info-Lauf, dann nur den LÄNGSTEN Titel rippen —
|
nur_hauptfilm: Vorab-Info-Lauf, dann heuristisch den besten Titel rippen.
|
||||||
das löst das eingestellte (vorher wirkungslose) mainFeatureOnly ein.
|
|
||||||
"""
|
"""
|
||||||
if output_dir is None:
|
if output_dir is None:
|
||||||
output_dir = os.path.join(RIP_OUTPUT_DIR, disc_type, disc_id)
|
output_dir = os.path.join(RIP_OUTPUT_DIR, disc_type, disc_id)
|
||||||
@@ -456,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)
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
return rip_titel_auswahl(device_path, output_dir, titel_liste, progress_cb, log_cb)
|
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"
|
titel = "all"
|
||||||
if nur_hauptfilm:
|
if nur_hauptfilm:
|
||||||
dauern = lies_titel_dauern(device_path)
|
dauern = lies_titel_dauern(device_path)
|
||||||
haupt = laengster_titel(dauern)
|
haupt = laengster_titel(dauern, meta)
|
||||||
if haupt is not None:
|
if haupt is not None:
|
||||||
titel = str(haupt)
|
titel = str(haupt)
|
||||||
# Kein Titel ermittelbar → ehrlich auf 'all' zurückfallen statt raten
|
# Kein Titel ermittelbar → ehrlich auf 'all' zurückfallen statt raten
|
||||||
@@ -467,8 +899,10 @@ def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_c
|
|||||||
log_cb=log_cb)
|
log_cb=log_cb)
|
||||||
|
|
||||||
|
|
||||||
def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = None) -> dict:
|
def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = None,
|
||||||
"""Rippt eine CD mit abcde (FLAC)."""
|
auswerfen: bool = True) -> dict:
|
||||||
|
"""Rippt eine CD mit abcde (FLAC). `auswerfen` = Einstellung „Automatischer
|
||||||
|
Auswurf" (abcde macht das selbst per -x)."""
|
||||||
if not check_abcde_installed():
|
if not check_abcde_installed():
|
||||||
return {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
@@ -492,11 +926,12 @@ def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = N
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
build_abcde_cmd(device_path, config_path),
|
build_abcde_cmd(device_path, config_path, auswerfen=auswerfen),
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
text=True,
|
text=True,
|
||||||
bufsize=1
|
bufsize=1,
|
||||||
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
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"
|
||||||
+345
-41
@@ -12,11 +12,13 @@ komprimiert danach auf Arbeitsgröße. Die Rohdatei liegt nur temporär in
|
|||||||
/app/temp und wird nach Erfolg gelöscht (Setting keepOriginal behält sie).
|
/app/temp und wird nach Erfolg gelöscht (Setting keepOriginal behält sie).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import errno
|
||||||
import glob
|
import glob
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import posixpath
|
import posixpath
|
||||||
import shutil
|
import shutil
|
||||||
|
import time
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
@@ -38,16 +40,31 @@ except ImportError: # Windows: kein fcntl
|
|||||||
from ripping import (
|
from ripping import (
|
||||||
RIP_OUTPUT_DIR,
|
RIP_OUTPUT_DIR,
|
||||||
RipAbbruch,
|
RipAbbruch,
|
||||||
|
komprimieren_fuer,
|
||||||
lies_datei_dauer,
|
lies_datei_dauer,
|
||||||
lies_titel_info,
|
lies_titel_info,
|
||||||
preset_fuer,
|
preset_fuer,
|
||||||
rip_cd,
|
rip_cd,
|
||||||
rip_video,
|
rip_video,
|
||||||
run_handbrake,
|
run_handbrake,
|
||||||
|
sprachen_zusammenfassen,
|
||||||
|
sprachliste,
|
||||||
|
wirf_disc_aus,
|
||||||
)
|
)
|
||||||
|
|
||||||
API_URL = os.getenv("API_URL", "http://api:8000")
|
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):
|
def _transcode_queue(node: str):
|
||||||
"""Ziel-Queue für die Kompression (siehe celery_client.transcode_queue):
|
"""Ziel-Queue für die Kompression (siehe celery_client.transcode_queue):
|
||||||
@@ -90,14 +107,44 @@ def pfad_lokal(pfad: str, mapping: str = None) -> str:
|
|||||||
RAW_DIR = os.getenv("RAW_DIR", "/app/temp/raw")
|
RAW_DIR = os.getenv("RAW_DIR", "/app/temp/raw")
|
||||||
MEDIA_ROOT = "/app/media"
|
MEDIA_ROOT = "/app/media"
|
||||||
|
|
||||||
|
# Wie oft während einer Kompression nachgesehen wird, ob der Nutzer abgebrochen
|
||||||
|
# hat. Eine DB-Abfrage alle paar Sekunden ist nichts gegen einen Encode, der
|
||||||
|
# Stunden läuft — und „Abbrechen" fühlt sich damit sofort an.
|
||||||
|
ABBRUCH_INTERVALL_SEKUNDEN = 5
|
||||||
|
|
||||||
|
|
||||||
|
def unter_wurzel(pfad: str, wurzel: str) -> bool:
|
||||||
|
"""Liegt `pfad` wirklich unterhalb von `wurzel` (oder IST es die Wurzel)?
|
||||||
|
|
||||||
|
Pure Funktion, testbar. Ein nacktes `startswith()` genügt nicht:
|
||||||
|
„/app/media-boese/x" beginnt mit „/app/media", liegt aber außerhalb
|
||||||
|
(Befund 25.07.2026 bei der Durchsicht). Immer „/" als Trenner — das sind
|
||||||
|
Container-Pfade, auch wenn ein nativer Windows-Worker das Modul lädt.
|
||||||
|
|
||||||
|
Gleichlautend in docker/api/main.py; es gibt kein geteiltes Paket zwischen
|
||||||
|
den Containern.
|
||||||
|
"""
|
||||||
|
if not pfad or not wurzel:
|
||||||
|
return False
|
||||||
|
sauber = wurzel.rstrip("/") or "/"
|
||||||
|
return pfad == sauber or pfad.startswith(sauber + "/")
|
||||||
|
|
||||||
|
|
||||||
def _zielbasis(target_dir, disc_type: str) -> str:
|
def _zielbasis(target_dir, disc_type: str) -> str:
|
||||||
"""Ablagebasis: vom Nutzer gewähltes Ziel (validiert) oder Standard."""
|
"""Ablagebasis: vom Nutzer gewähltes Ziel (validiert) oder Standard.
|
||||||
|
|
||||||
|
posixpath statt os.path — aus demselben Grund wie in _arbeitsverzeichnis:
|
||||||
|
das sind IMMER Container-Pfade. os.path.normpath macht unter Windows
|
||||||
|
Backslashes daraus, und dann greift die MEDIA_ROOT-Prüfung nicht mehr, das
|
||||||
|
gewählte Ziel fiele still auf den Standard zurück. Aufgefallen 25.07.2026,
|
||||||
|
als der Test dafür erstmals unter Windows lief. Live war es nie: aufgerufen
|
||||||
|
wird nur aus rip_disc, und das ist auf Windows-Workern verriegelt.
|
||||||
|
"""
|
||||||
if target_dir:
|
if target_dir:
|
||||||
normalisiert = os.path.normpath(target_dir)
|
normalisiert = posixpath.normpath(target_dir)
|
||||||
if normalisiert.startswith(MEDIA_ROOT):
|
if unter_wurzel(normalisiert, MEDIA_ROOT):
|
||||||
return normalisiert
|
return normalisiert
|
||||||
return os.path.join(RIP_OUTPUT_DIR, disc_type)
|
return posixpath.join(RIP_OUTPUT_DIR, disc_type)
|
||||||
|
|
||||||
|
|
||||||
def _arbeitsverzeichnis(einstellungen: dict, job_wahl: str = "") -> str:
|
def _arbeitsverzeichnis(einstellungen: dict, job_wahl: str = "") -> str:
|
||||||
@@ -121,7 +168,7 @@ def _arbeitsverzeichnis(einstellungen: dict, job_wahl: str = "") -> str:
|
|||||||
wert = (kandidat or "").strip()
|
wert = (kandidat or "").strip()
|
||||||
if wert:
|
if wert:
|
||||||
normalisiert = posixpath.normpath(wert)
|
normalisiert = posixpath.normpath(wert)
|
||||||
if normalisiert.startswith(MEDIA_ROOT):
|
if unter_wurzel(normalisiert, MEDIA_ROOT):
|
||||||
return normalisiert
|
return normalisiert
|
||||||
return RAW_DIR
|
return RAW_DIR
|
||||||
|
|
||||||
@@ -199,36 +246,65 @@ def _original_aufheben(job_id: str, raw_dir: str, final_dir: str) -> None:
|
|||||||
"""Roh-Rip zusätzlich aufheben — darf den Job NIEMALS scheitern lassen.
|
"""Roh-Rip zusätzlich aufheben — darf den Job NIEMALS scheitern lassen.
|
||||||
|
|
||||||
Befund 25.07.2026 (Akira-UHD, echter Schaden): Hier stand ein nacktes
|
Befund 25.07.2026 (Akira-UHD, echter Schaden): Hier stand ein nacktes
|
||||||
shutil.move(). Arbeitsverzeichnis (/app/temp, Docker-Volume) und Ziel
|
shutil.move(). Zwischen Arbeitsverzeichnis (/app/temp) und Ziel
|
||||||
(/app/media, Bind-Mount) sind VERSCHIEDENE Dateisysteme — os.rename
|
(/app/media) scheitert os.rename mit EXDEV, shutil.move fällt auf Kopieren
|
||||||
scheitert dort mit EXDEV, und shutil.move fällt auf Kopieren zurück. Damit
|
zurück — eine 74-GB-Vollkopie auf dieselbe Platte, bis sie mit ENOSPC voll
|
||||||
lief eine 74-GB-Vollkopie auf dieselbe Platte an, bis sie mit ENOSPC voll
|
war. Ergebnis: Platte 100 % voll, Worker-Container startete nicht mehr, und
|
||||||
war. Ergebnis: Platte 100 % voll, Worker-Container startete nicht mehr —
|
der Job galt als FEHLGESCHLAGEN, obwohl die komprimierte Datei längst
|
||||||
und der Job wurde als FEHLGESCHLAGEN markiert, obwohl die komprimierte
|
fertig war. Der Nutzer sah nur „nichts da".
|
||||||
Datei längst fertig und in Ordnung war. Der Nutzer sah nur „nichts da".
|
|
||||||
|
|
||||||
Deshalb dreifach abgesichert: Platz vorher prüfen, Fehler abfangen, und im
|
## Warum hier NICHT vorhergesagt, sondern versucht wird
|
||||||
Zweifel die Rohdatei einfach liegen lassen, wo sie ist.
|
|
||||||
|
Die erste Fassung dieses Schutzes verglich `os.stat(...).st_dev` und
|
||||||
|
schloss aus gleichen Werten auf „reines Umhängen, kein Platz nötig". Am
|
||||||
|
25.07.2026 im Worker-Container nachgemessen — beides zugleich wahr:
|
||||||
|
|
||||||
|
st_dev /app/temp = 2050
|
||||||
|
st_dev /app/media = 2050 → also identisch
|
||||||
|
os.rename(...) → EXDEV, „Invalid cross-device link"
|
||||||
|
|
||||||
|
Der Kernel vergleicht bei rename() den **Mount**, nicht das Gerät. /app/temp
|
||||||
|
(Docker-Volume) und /app/media (Bind-Mount) sind zwei Mounts DERSELBEN
|
||||||
|
ext4-Partition. Die st_dev-Prüfung war deshalb wirkungslos: sie sah
|
||||||
|
„gleiches Dateisystem", übersprang die Platzprüfung, und shutil.move kopierte
|
||||||
|
doch. Der Schutz hätte genau den Schaden zugelassen, gegen den er gebaut war.
|
||||||
|
|
||||||
|
Also: erst rename VERSUCHEN. Klappt es, ist es umgehängt und fertig.
|
||||||
|
Kommt EXDEV, steht fest, dass kopiert werden müsste — und erst dann wird
|
||||||
|
der Platz geprüft. Das ist keine Vermutung mehr, sondern die Antwort des
|
||||||
|
Kernels.
|
||||||
"""
|
"""
|
||||||
ziel_original = os.path.join(final_dir, "original")
|
ziel_original = os.path.join(final_dir, "original")
|
||||||
try:
|
try:
|
||||||
# Liegt beides auf demselben Dateisystem, ist es ein reines Umhängen —
|
# Der billige Weg zuerst — und er ist gleichzeitig der einzige
|
||||||
# sofort fertig, kein zusätzlicher Platz nötig.
|
# verlässliche Test, ob überhaupt umgehängt werden kann.
|
||||||
gleiches_dateisystem = os.stat(raw_dir).st_dev == os.stat(final_dir).st_dev
|
try:
|
||||||
if not gleiches_dateisystem:
|
os.rename(raw_dir, ziel_original)
|
||||||
benoetigt = _ordner_groesse(raw_dir)
|
db.add_log(
|
||||||
frei = _frei_bytes(final_dir)
|
"info", "worker",
|
||||||
if frei < benoetigt * 1.05:
|
f"Job {job_id}: Original behalten unter {ziel_original} (umgehängt, "
|
||||||
db.add_log(
|
"kein zusätzlicher Platz nötig)",
|
||||||
"warning", "worker",
|
)
|
||||||
f"Job {job_id}: Original NICHT aufgehoben — dafür wären "
|
return
|
||||||
f"{benoetigt / 1024**3:.1f} GB nötig, frei sind nur "
|
except OSError as e:
|
||||||
f"{frei / 1024**3:.1f} GB. Die Roh-Datei bleibt unter "
|
if e.errno != errno.EXDEV:
|
||||||
f"{raw_dir} liegen. Abhilfe: Arbeitsverzeichnis "
|
raise # etwas anderes ist schiefgelaufen → unten ehrlich melden
|
||||||
"(Einstellungen → Verarbeitung) auf dieselbe Freigabe legen "
|
|
||||||
"wie das Ziel — dann wird nur umgehängt statt kopiert."
|
# Ab hier ist eine echte Kopie unvermeidlich. Jetzt lohnt die Platzfrage.
|
||||||
)
|
benoetigt = _ordner_groesse(raw_dir)
|
||||||
return
|
frei = _frei_bytes(final_dir)
|
||||||
|
if frei < benoetigt * 1.05:
|
||||||
|
db.add_log(
|
||||||
|
"warning", "worker",
|
||||||
|
f"Job {job_id}: Original NICHT aufgehoben — Ziel liegt auf einem "
|
||||||
|
f"anderen Mount, es müsste kopiert werden. Dafür wären "
|
||||||
|
f"{benoetigt / 1024**3:.1f} GB nötig, frei sind nur "
|
||||||
|
f"{frei / 1024**3:.1f} GB. Die Roh-Datei bleibt unter "
|
||||||
|
f"{raw_dir} liegen. Abhilfe: Arbeitsverzeichnis "
|
||||||
|
"(Einstellungen → Verarbeitung) auf dieselbe Freigabe legen "
|
||||||
|
"wie das Ziel — dann wird nur umgehängt statt kopiert."
|
||||||
|
)
|
||||||
|
return
|
||||||
shutil.move(raw_dir, ziel_original)
|
shutil.move(raw_dir, ziel_original)
|
||||||
db.add_log("info", "worker", f"Job {job_id}: Original behalten unter {ziel_original}")
|
db.add_log("info", "worker", f"Job {job_id}: Original behalten unter {ziel_original}")
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
@@ -408,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}
|
return {"status": "error", "error": fehler, "disc_type": disc_type}
|
||||||
|
|
||||||
db.update_job(job_id, status="running", 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})")
|
db.add_log("info", "worker", f"Job {job_id}: {disc_type}-Rip gestartet ({device_path})")
|
||||||
|
|
||||||
letzter = [-1]
|
letzter = [-1]
|
||||||
@@ -443,7 +522,9 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
|||||||
|
|
||||||
einstellungen = db.get_settings()
|
einstellungen = db.get_settings()
|
||||||
ist_video = disc_type in ("dvd", "bluray", "uhd")
|
ist_video = disc_type in ("dvd", "bluray", "uhd")
|
||||||
transcode_an = ist_video and einstellungen.get("transcodeEnabled", True)
|
# Je Disc-Typ abwählbar (siehe komprimieren_fuer): 4K verlustfrei behalten,
|
||||||
|
# DVDs trotzdem schrumpfen — vorher gab es nur alles oder nichts.
|
||||||
|
transcode_an = ist_video and komprimieren_fuer(disc_type, einstellungen)
|
||||||
|
|
||||||
if ist_video:
|
if ist_video:
|
||||||
# UI-Key schlägt Env-Key — Monats-Key ohne Rebuild aktualisierbar
|
# UI-Key schlägt Env-Key — Monats-Key ohne Rebuild aktualisierbar
|
||||||
@@ -500,7 +581,10 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
|||||||
return ergebnis
|
return ergebnis
|
||||||
|
|
||||||
if disc_type == "cd":
|
if disc_type == "cd":
|
||||||
ergebnis = rip_cd(device_path, job_id, progress_cb=fortschritt, output_dir=final_dir)
|
ergebnis = rip_cd(
|
||||||
|
device_path, job_id, progress_cb=fortschritt, output_dir=final_dir,
|
||||||
|
auswerfen=bool(einstellungen.get("autoEject", True)),
|
||||||
|
)
|
||||||
elif transcode_an:
|
elif transcode_an:
|
||||||
# Roh-Rip ins Arbeitsverzeichnis (wird nach erfolgreicher Kompression gelöscht)
|
# Roh-Rip ins Arbeitsverzeichnis (wird nach erfolgreicher Kompression gelöscht)
|
||||||
ergebnis = rip_video(
|
ergebnis = rip_video(
|
||||||
@@ -562,12 +646,32 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
|||||||
"Normale BD/DVD gehen weiterhin."
|
"Normale BD/DVD gehen weiterhin."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Automatischer Auswurf. Die Disc ist nach dem Rip nicht mehr nötig — die
|
||||||
|
# Kompression arbeitet auf der Datei, nicht am Laufwerk.
|
||||||
|
#
|
||||||
|
# Befund 25.07.2026: Die Einstellung („Disc nach erfolgreichem Ripping
|
||||||
|
# automatisch auswerfen", Standard ein) wurde von NIEMANDEM gelesen. Bei
|
||||||
|
# DVD/Blu-ray warf Rippy deshalb nie aus, bei Audio-CD dagegen immer, weil
|
||||||
|
# abcde `-x` fest verdrahtet bekam. Jetzt entscheidet die Einstellung beides.
|
||||||
|
if ergebnis.get("status") == "success" and ist_video and einstellungen.get("autoEject", True):
|
||||||
|
if wirf_disc_aus(device_path):
|
||||||
|
db.add_log("info", "worker", f"Job {job_id}: Disc ausgeworfen")
|
||||||
|
else:
|
||||||
|
db.add_log(
|
||||||
|
"warning", "worker",
|
||||||
|
f"Job {job_id}: Disc konnte nicht ausgeworfen werden ({device_path}) — "
|
||||||
|
"der Rip ist davon unberührt.",
|
||||||
|
)
|
||||||
|
|
||||||
if ergebnis.get("status") == "success" and transcode_an:
|
if ergebnis.get("status") == "success" and transcode_an:
|
||||||
# Kompression als eigener Task — an den im Rip-Dialog GEWÄHLTEN Worker
|
# Kompression als eigener Task — an den im Rip-Dialog GEWÄHLTEN Worker
|
||||||
# (worker_direct), sonst an die geteilte transcode-Queue (irgendein
|
# (worker_direct), sonst an die geteilte transcode-Queue (irgendein
|
||||||
# freier Worker, inkl. Remote-GPU).
|
# freier Worker, inkl. Remote-GPU).
|
||||||
ziel_queue = _transcode_queue(meta.get("transcode_node"))
|
ziel_queue = _transcode_queue(meta.get("transcode_node"))
|
||||||
db.update_job(job_id, status="transcoding", progress=0)
|
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"
|
gezielt = meta.get("transcode_node") and ziel_queue != "transcode"
|
||||||
db.add_log("info", "worker",
|
db.add_log("info", "worker",
|
||||||
f"Job {job_id}: Rip fertig, Kompression eingereiht"
|
f"Job {job_id}: Rip fertig, Kompression eingereiht"
|
||||||
@@ -605,9 +709,20 @@ def scan_tracks(device_path: str):
|
|||||||
key = f"tracks:{device_path}"
|
key = f"tracks:{device_path}"
|
||||||
db.save_settings({"status": "running"}, key)
|
db.save_settings({"status": "running"}, key)
|
||||||
try:
|
try:
|
||||||
titel = lies_titel_info(device_path)
|
# Ein Info-Lauf liefert BEIDES: Titel-Liste und die Sprachen. Die
|
||||||
db.save_settings({"status": "done", "tracks": titel}, key)
|
# Sprach-Auskunft stand schon immer in derselben Ausgabe und wurde nur
|
||||||
db.add_log("info", "worker", f"Titel-Scan {device_path}: {len(titel)} Titel gefunden")
|
# 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)}
|
return {"status": "done", "anzahl": len(titel)}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.save_settings({"status": "error", "error": str(e)[:300]}, key)
|
db.save_settings({"status": "error", "error": str(e)[:300]}, key)
|
||||||
@@ -615,6 +730,132 @@ def scan_tracks(device_path: str):
|
|||||||
return {"status": "error", "error": str(e)}
|
return {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def _ist_uebersetzt(container_pfad: str, lokal_pfad: str) -> bool:
|
||||||
|
"""Hat RIPPY_PATH_MAP diesen Pfad wirklich angefasst? (pure, testbar)"""
|
||||||
|
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 "".
|
||||||
|
|
||||||
|
Befund 25.07.2026 am Job 95afdc89, live: Das gezielte Routing an den
|
||||||
|
Windows-PC des Commanders funktionierte einwandfrei — der Worker nahm die
|
||||||
|
Aufgabe an und lehnte sie 182 ms später ab mit „Keine Roh-MKVs in
|
||||||
|
/app/media/rippy/… gefunden". Diese Meldung klang nach einem kaputten Rip,
|
||||||
|
obwohl der Rip vollständig war (79,6 GB lagen auf der NAS). Der Commander
|
||||||
|
schloss daraus, das System sähe den externen Encoder nicht — verständlich
|
||||||
|
und falsch.
|
||||||
|
|
||||||
|
Die eigentliche Ursache: `/app/media/...` sind CONTAINER-Pfade. Ein nativer
|
||||||
|
Worker sieht sie nur, wenn RIPPY_PATH_MAP sie auf eine Freigabe übersetzt —
|
||||||
|
und dieses Mapping wurde von NIEMANDEM gesetzt. Es war also nicht möglich,
|
||||||
|
dass diese Kombination je funktioniert.
|
||||||
|
|
||||||
|
Zweiter Stolperstein im selben Fall: Die Rohdaten lagen auf der NAS (für den
|
||||||
|
PC erreichbar), das ZIEL aber auf der VM-Platte (nicht erreichbar, dort
|
||||||
|
läuft kein Samba). Deshalb werden hier BEIDE Pfade geprüft, nicht nur die
|
||||||
|
Quelle — sonst scheitert es erst beim Schreiben, nach Stunden Rechenzeit.
|
||||||
|
"""
|
||||||
|
mapping = os.getenv("RIPPY_PATH_MAP", "")
|
||||||
|
fremder_worker = not os.path.isdir("/app")
|
||||||
|
|
||||||
|
for zweck, container, lokal in (
|
||||||
|
("die Quelle (Rohdaten)", raw_container, raw_lokal),
|
||||||
|
("das Ziel (fertige Datei)", final_container, final_lokal),
|
||||||
|
):
|
||||||
|
if os.path.isdir(lokal):
|
||||||
|
continue
|
||||||
|
# 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 {zweck} nicht: {lokal}"]
|
||||||
|
if fremder_worker and not mapping:
|
||||||
|
text.append(
|
||||||
|
"Ursache: RIPPY_PATH_MAP ist auf dieser Maschine nicht gesetzt. "
|
||||||
|
f'„{container}" ist ein Pfad INNERHALB des Rippy-Containers — ein '
|
||||||
|
'externer Worker sieht ihn nur, wenn er auf eine Netzwerk-Freigabe '
|
||||||
|
'übersetzt wird.'
|
||||||
|
)
|
||||||
|
text.append(
|
||||||
|
"Abhilfe: Freigabe auf diesem Rechner erreichbar machen und in "
|
||||||
|
"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:
|
||||||
|
# ⚠️ 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 "
|
||||||
|
"Freigabe, die gerade nicht eingehängt ist?"
|
||||||
|
)
|
||||||
|
text.append(
|
||||||
|
'WICHTIG: Die Rohdaten sind NICHT verloren — nach der Korrektur '
|
||||||
|
'genügt „Neu komprimieren", ohne die Disc erneut zu rippen.'
|
||||||
|
)
|
||||||
|
return " ".join(text)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task(bind=True, name="worker.tasks.transcode_files")
|
@celery_app.task(bind=True, name="worker.tasks.transcode_files")
|
||||||
def transcode_files(self, job_id: str, raw_dir: str, final_dir: str):
|
def transcode_files(self, job_id: str, raw_dir: str, final_dir: str):
|
||||||
"""Stufe 2: HandBrake komprimiert die Roh-MKVs auf Arbeitsgröße.
|
"""Stufe 2: HandBrake komprimiert die Roh-MKVs auf Arbeitsgröße.
|
||||||
@@ -626,11 +867,27 @@ def transcode_files(self, job_id: str, raw_dir: str, final_dir: str):
|
|||||||
"""
|
"""
|
||||||
db.init_db()
|
db.init_db()
|
||||||
# Native Worker (Windows) übersetzen Container-Pfade aufs Netzlaufwerk
|
# Native Worker (Windows) übersetzen Container-Pfade aufs Netzlaufwerk
|
||||||
|
raw_container, final_container = raw_dir, final_dir
|
||||||
raw_dir = pfad_lokal(raw_dir)
|
raw_dir = pfad_lokal(raw_dir)
|
||||||
final_dir = pfad_lokal(final_dir)
|
final_dir = pfad_lokal(final_dir)
|
||||||
|
|
||||||
|
fehler = _erreichbarkeit_pruefen(raw_container, raw_dir, final_container, final_dir)
|
||||||
|
if fehler:
|
||||||
|
ergebnis = {"status": "error", "error": fehler}
|
||||||
|
_job_abschliessen(job_id, ergebnis)
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
quellen = sorted(glob.glob(os.path.join(raw_dir, "*.mkv")))
|
quellen = sorted(glob.glob(os.path.join(raw_dir, "*.mkv")))
|
||||||
if not quellen:
|
if not quellen:
|
||||||
ergebnis = {"status": "error", "error": f"Keine Roh-MKVs in {raw_dir} gefunden"}
|
ergebnis = {
|
||||||
|
"status": "error",
|
||||||
|
"error": (
|
||||||
|
f"Das Verzeichnis {raw_dir} ist erreichbar, enthält aber keine "
|
||||||
|
"MKV-Datei. Der Rip hat also nichts abgelegt (oder jemand hat die "
|
||||||
|
"Datei entfernt). Die Kompression lässt sich nach einem neuen Rip "
|
||||||
|
"erneut anstoßen."
|
||||||
|
),
|
||||||
|
}
|
||||||
_job_abschliessen(job_id, ergebnis)
|
_job_abschliessen(job_id, ergebnis)
|
||||||
return ergebnis
|
return ergebnis
|
||||||
|
|
||||||
@@ -642,16 +899,59 @@ def transcode_files(self, job_id: str, raw_dir: str, final_dir: str):
|
|||||||
preset = preset_fuer(disc_type, einstellungen)
|
preset = preset_fuer(disc_type, einstellungen)
|
||||||
original_behalten = einstellungen.get("keepOriginal", False)
|
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)
|
os.makedirs(final_dir, exist_ok=True)
|
||||||
db.update_job(job_id, status="transcoding", progress=0, error=None)
|
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(
|
db.add_log(
|
||||||
"info", "worker",
|
"info", "worker",
|
||||||
f"Job {job_id}: Kompression gestartet ({len(quellen)} Datei(en), "
|
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)
|
anzahl = len(quellen)
|
||||||
letzter = [-1]
|
letzter = [-1]
|
||||||
|
letzte_abbruchpruefung = [0.0]
|
||||||
|
|
||||||
|
def abbruch_pruefen():
|
||||||
|
"""Zeitgesteuert prüfen, ob der Nutzer abgebrochen hat. Wirft RipAbbruch.
|
||||||
|
|
||||||
|
Befund 25.07.2026 (Commander, am laufenden Akira-Job beobachtet): Der
|
||||||
|
Abbruch wurde NUR in `datei_fortschritt` geprüft — und diese Closure
|
||||||
|
stieg oben sofort wieder aus, wenn sich die Prozentzahl nicht geändert
|
||||||
|
hatte. Bei einem 4K-Encode, der pro Prozent eine halbe Stunde braucht,
|
||||||
|
sah „Abbrechen" entsprechend lange wirkungslos aus (gemessen: 3,4 min
|
||||||
|
zwischen Anforderung 18:30:17 und Bestätigung 18:33:38 — bei noch
|
||||||
|
langsamerem Fortschritt entsprechend mehr).
|
||||||
|
|
||||||
|
Deshalb hängt die Prüfung jetzt an der Zeit statt am Fortschritt und
|
||||||
|
läuft bei JEDER Ausgabezeile von HandBrake — auch während des
|
||||||
|
Scan-Durchlaufs, der überhaupt keine Encode-Prozente liefert.
|
||||||
|
"""
|
||||||
|
jetzt = time.monotonic()
|
||||||
|
if jetzt - letzte_abbruchpruefung[0] < ABBRUCH_INTERVALL_SEKUNDEN:
|
||||||
|
return
|
||||||
|
letzte_abbruchpruefung[0] = jetzt
|
||||||
|
if _abbruch_angefordert(job_id):
|
||||||
|
raise RipAbbruch()
|
||||||
|
|
||||||
for index, quelle in enumerate(quellen):
|
for index, quelle in enumerate(quellen):
|
||||||
ziel = os.path.join(final_dir, os.path.basename(quelle))
|
ziel = os.path.join(final_dir, os.path.basename(quelle))
|
||||||
|
|
||||||
@@ -660,11 +960,15 @@ def transcode_files(self, job_id: str, raw_dir: str, final_dir: str):
|
|||||||
if gesamt == letzter[0]:
|
if gesamt == letzter[0]:
|
||||||
return
|
return
|
||||||
letzter[0] = gesamt
|
letzter[0] = gesamt
|
||||||
if _abbruch_angefordert(job_id):
|
|
||||||
raise RipAbbruch()
|
|
||||||
db.update_job(job_id, progress=min(99, gesamt))
|
db.update_job(job_id, progress=min(99, gesamt))
|
||||||
|
|
||||||
hb = run_handbrake(quelle, ziel, preset=preset, progress_cb=datei_fortschritt)
|
hb = run_handbrake(
|
||||||
|
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":
|
if hb.get("status") == "cancelled":
|
||||||
_job_abschliessen(job_id, hb)
|
_job_abschliessen(job_id, hb)
|
||||||
return hb
|
return hb
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"""Tests der Fähigkeiten-Erkennung — reine Funktionen, keine Infrastruktur.
|
||||||
|
|
||||||
|
Die Testdaten sind ECHTE Ausgaben, am 25.07.2026 im Worker-Image der Rippy-VM
|
||||||
|
abgenommen (AGENTS Regel D). Erfundene Formate hätten hier keinen Wert: genau
|
||||||
|
daran ist die Erkennung vorher gescheitert.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import caps
|
||||||
|
|
||||||
|
# Wörtlich aus `docker exec rippy-worker-1 HandBrakeCLI --help` (HandBrake im
|
||||||
|
# Worker-Image, 25.07.2026). Bemerkenswert: KEIN Hardware-Encoder dabei.
|
||||||
|
HB_HILFE_ECHT = """
|
||||||
|
-Z, --preset <string> Select preset by name
|
||||||
|
-e, --encoder <string> Select video encoder:
|
||||||
|
svt_av1
|
||||||
|
svt_av1_10bit
|
||||||
|
x264
|
||||||
|
x264_10bit
|
||||||
|
x265
|
||||||
|
x265_10bit
|
||||||
|
x265_12bit
|
||||||
|
mpeg4
|
||||||
|
mpeg2
|
||||||
|
VP8
|
||||||
|
VP9
|
||||||
|
VP9_10bit
|
||||||
|
theora
|
||||||
|
--encoder-preset <string>
|
||||||
|
Adjust video encoding settings
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Wie dieselbe Stelle auf einer Maschine MIT Hardware-Encodern aussieht
|
||||||
|
# (HandBrake-Windows-Build; Struktur identisch, nur mehr Namen).
|
||||||
|
HB_HILFE_MIT_HARDWARE = """
|
||||||
|
-e, --encoder <string> Select video encoder:
|
||||||
|
x264
|
||||||
|
x265
|
||||||
|
nvenc_h264
|
||||||
|
nvenc_h265
|
||||||
|
qsv_h264
|
||||||
|
qsv_h265
|
||||||
|
--encoder-preset <string>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_encoder_liste_wird_aus_echter_hilfe_gelesen():
|
||||||
|
encoder = caps.parse_encoder_liste(HB_HILFE_ECHT)
|
||||||
|
assert encoder == [
|
||||||
|
"svt_av1", "svt_av1_10bit",
|
||||||
|
"x264", "x264_10bit",
|
||||||
|
"x265", "x265_10bit", "x265_12bit",
|
||||||
|
"mpeg4", "mpeg2", "VP8", "VP9", "VP9_10bit", "theora",
|
||||||
|
]
|
||||||
|
# Der Block endet an der nächsten Option — sie darf nicht mitgelesen werden
|
||||||
|
assert not any("encoder-preset" in e for e in encoder)
|
||||||
|
assert not any(e.startswith("-") for e in encoder)
|
||||||
|
|
||||||
|
|
||||||
|
def test_encoder_liste_ohne_handbrake_ist_leer():
|
||||||
|
assert caps.parse_encoder_liste("") == []
|
||||||
|
assert caps.parse_encoder_liste(None) == []
|
||||||
|
assert caps.parse_encoder_liste("irgendein Text ohne Encoder-Abschnitt") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_handbrake_keine_encoder():
|
||||||
|
"""Der wichtigste Fall: vorher standen cpu-x264/cpu-x265 fest verdrahtet in
|
||||||
|
der Liste. Ein Rip-Worker ohne HandBrake behauptete damit, komprimieren zu
|
||||||
|
können — und jeder Transcode dort endete sofort mit 'nicht installiert'."""
|
||||||
|
assert caps.leite_backends_ab([]) == []
|
||||||
|
assert caps.leite_backends_ab(None) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_backends_aus_echter_liste():
|
||||||
|
"""Das Worker-Image der VM kann laut eigener Auskunft keine Hardware —
|
||||||
|
entsprechend steht hier nur CPU."""
|
||||||
|
encoder = caps.parse_encoder_liste(HB_HILFE_ECHT)
|
||||||
|
assert caps.leite_backends_ab(encoder) == ["cpu-x264", "cpu-x265", "cpu-av1"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_handbrakes_liste_ist_die_auskunft_ohne_geraetepruefung():
|
||||||
|
"""Regel-Wechsel 25.07.2026, weil die alte Fassung einen Bug hatte.
|
||||||
|
|
||||||
|
Vorher wurde zusätzlich ein Gerät verlangt: /dev/dri für VAAPI/QSV/VCE bzw.
|
||||||
|
nvidia-smi für NVENC. Unter WINDOWS gibt es beides nicht — der PC des
|
||||||
|
Commanders (RX 9070 XT) meldete deshalb nur CPU-Encoder, obwohl HandBrakes
|
||||||
|
Windows-Build vce_*/nvenc_*/qsv_* beherrscht. Der Bug traf genau den
|
||||||
|
Anwendungsfall, für den externe Worker gedacht sind.
|
||||||
|
|
||||||
|
Die Prüfung war zudem überflüssig: HandBrake probiert Hardware beim Start
|
||||||
|
selbst an und listet nur Nutzbares (auf der VM belegt — 'qsv: not available
|
||||||
|
on this system', und qsv_* fehlt dann in --help). Wer die Liste hat, hat die
|
||||||
|
Antwort; eine zweite Meinung braucht es nicht.
|
||||||
|
"""
|
||||||
|
mit_hw = caps.parse_encoder_liste(HB_HILFE_MIT_HARDWARE)
|
||||||
|
backends = caps.leite_backends_ab(mit_hw)
|
||||||
|
assert "nvenc" in backends
|
||||||
|
assert "qsv" in backends
|
||||||
|
# Ohne Geräteprüfung — es gibt keine Parameter mehr, die das verhindern
|
||||||
|
assert caps.leite_backends_ab(mit_hw) == backends
|
||||||
|
|
||||||
|
|
||||||
|
def test_hardware_wird_nach_familie_unterschieden():
|
||||||
|
"""Vorher landete alles in „vaapi" — eine AMD-Karte lief damit unter dem
|
||||||
|
Intel-Namen, und das UI konnte nicht sagen, welches Preset passt."""
|
||||||
|
amd = caps.leite_backends_ab(["x265", "vce_h264", "vce_h265"])
|
||||||
|
assert "vce" in amd
|
||||||
|
assert "qsv" not in amd and "nvenc" not in amd
|
||||||
|
|
||||||
|
intel = caps.leite_backends_ab(["x265", "qsv_h265"])
|
||||||
|
assert "qsv" in intel and "vce" not in intel
|
||||||
|
|
||||||
|
|
||||||
|
def test_hardware_av1_bekommt_eigene_kennung():
|
||||||
|
"""AV1 in Hardware ist derzeit die beste Kombination aus Tempo und
|
||||||
|
Dateigröße — das darf nicht in „nvenc" verschwinden, sonst kann das UI es
|
||||||
|
nicht empfehlen."""
|
||||||
|
backends = caps.leite_backends_ab(["x265", "nvenc_h265", "nvenc_av1"])
|
||||||
|
assert "nvenc" in backends
|
||||||
|
assert "nvenc-av1" in backends
|
||||||
|
# Ohne AV1 in der Liste gibt es die Kennung auch nicht
|
||||||
|
assert "nvenc-av1" not in caps.leite_backends_ab(["nvenc_h265"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_simd_stufe_erkennt_die_hoechste():
|
||||||
|
assert caps.beste_simd("fpu vme de sse4_2 avx avx2") == "avx2"
|
||||||
|
assert caps.beste_simd("sse4_2 avx avx2 avx512f") == "avx512f"
|
||||||
|
assert caps.beste_simd("fpu vme de pse tsc sse4_2") == "sse4_2"
|
||||||
|
assert caps.beste_simd("") == "unbekannt"
|
||||||
|
assert caps.beste_simd(None) == "unbekannt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_simd_stufe_der_rippy_vm_ist_sse4_2():
|
||||||
|
"""Echt gemessen auf der Rippy-VM (25.07.2026): generisches QEMU-CPU-Modell,
|
||||||
|
`grep -c avx2 /proc/cpuinfo` = 0. Genau das macht Software-4K dort so lahm."""
|
||||||
|
flags_vm = (
|
||||||
|
"fpu de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 "
|
||||||
|
"clflush mmx fxsr sse sse2 syscall nx lm rep_good nopl cpuid "
|
||||||
|
"pni cx16 sse4_1 sse4_2 x2apic popcnt aes hypervisor lahf_lm"
|
||||||
|
)
|
||||||
|
assert caps.beste_simd(flags_vm) == "sse4_2"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Preset-Liste: die Namen, die HandBrake wirklich kennt -------------------
|
||||||
|
|
||||||
|
# Wörtlich aus `HandBrakeCLI --preset-list` im Worker-Image (26.07.2026,
|
||||||
|
# HandBrake 1.6.1) — gekürzt auf je zwei Presets pro Kategorie, Struktur und
|
||||||
|
# Einrückung unverändert. Die zwei Logzeilen oben stehen dort wirklich mit
|
||||||
|
# drin (HandBrake schreibt sie in denselben Strom) und müssen wegfallen.
|
||||||
|
HB_PRESETS_ECHT = """[10:26:05] Compile-time hardening features are enabled
|
||||||
|
[10:26:05] qsv: not available on this system
|
||||||
|
Cannot load libnvidia-encode.so.1
|
||||||
|
General/
|
||||||
|
Very Fast 2160p60 4K AV1
|
||||||
|
AV1 video (up to 2160p60) and AAC stereo audio, in an MP4
|
||||||
|
container.
|
||||||
|
Super HQ 1080p30 Surround
|
||||||
|
High quality H.264 video (up to 1080p30) and high bit rate AAC
|
||||||
|
surround audio, in an MP4 container.
|
||||||
|
Matroska/
|
||||||
|
H.265 MKV 2160p60 4K
|
||||||
|
H.265 video (up to 2160p60) and surround audio, in an MKV
|
||||||
|
container.
|
||||||
|
H.265 MKV 1080p30
|
||||||
|
H.265 video (up to 1080p30) and surround audio, in an MKV
|
||||||
|
container.
|
||||||
|
Hardware/
|
||||||
|
AV1 QSV 2160p 4K
|
||||||
|
AV1 video (up to 2160p) and surround audio, in an MP4 container.
|
||||||
|
H.265 VCN 2160p 4K
|
||||||
|
H.265 video (up to 2160p) and surround audio, in an MP4 container.
|
||||||
|
CLI Defaults/
|
||||||
|
CLI Default
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_preset_liste_kommt_aus_der_einrueckung():
|
||||||
|
presets = caps.parse_preset_liste(HB_PRESETS_ECHT)
|
||||||
|
assert presets == [
|
||||||
|
"Very Fast 2160p60 4K AV1",
|
||||||
|
"Super HQ 1080p30 Surround",
|
||||||
|
"H.265 MKV 2160p60 4K",
|
||||||
|
"H.265 MKV 1080p30",
|
||||||
|
"AV1 QSV 2160p 4K",
|
||||||
|
"H.265 VCN 2160p 4K",
|
||||||
|
"CLI Default",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_preset_liste_nimmt_keine_beschreibung_und_kein_log():
|
||||||
|
"""Beschreibungen (8 Leerzeichen), Kategorien (0) und HandBrakes eigene
|
||||||
|
Logzeilen (0) dürfen nicht als Preset-Namen durchgehen."""
|
||||||
|
presets = caps.parse_preset_liste(HB_PRESETS_ECHT)
|
||||||
|
assert not any(p.endswith("/") for p in presets)
|
||||||
|
assert not any("container" in p for p in presets)
|
||||||
|
assert not any(p.startswith("[") for p in presets)
|
||||||
|
assert "Cannot load libnvidia-encode.so.1" not in presets
|
||||||
|
|
||||||
|
|
||||||
|
def test_preset_liste_leer_bleibt_leer():
|
||||||
|
assert caps.parse_preset_liste("") == []
|
||||||
|
assert caps.parse_preset_liste(None) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_hardware_presets_stehen_auch_ohne_hardware_in_der_liste():
|
||||||
|
"""Richtigstellung zum SAVEPOINT v3.16: Dort galt es als unmöglich, die
|
||||||
|
Hardware-Preset-Namen auf der Rippy-VM zu ermitteln, weil dort kein
|
||||||
|
Hardware-Encoder läuft. Die Messung sagt das Gegenteil — `--preset-list`
|
||||||
|
nennt sie, `--help` nennt nur die nutzbaren Encoder. Zwei Fragen, zwei
|
||||||
|
Quellen."""
|
||||||
|
presets = caps.parse_preset_liste(HB_PRESETS_ECHT)
|
||||||
|
assert "H.265 VCN 2160p 4K" in presets # AMD, aus der VM-Liste
|
||||||
|
assert "AV1 QSV 2160p 4K" in presets # Intel, aus der VM-Liste
|
||||||
|
# ... und dieselbe Maschine meldet trotzdem KEINEN Hardware-Encoder:
|
||||||
|
assert caps.leite_backends_ab(caps.parse_encoder_liste(HB_HILFE_ECHT)) == [
|
||||||
|
"cpu-x264", "cpu-x265", "cpu-av1",
|
||||||
|
]
|
||||||
@@ -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.")]
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
"""Tests fuer makemkv_daten.py: die reinen Helfer rund um das MakeMKV-Datenverzeichnis.
|
"""Tests für makemkv_daten.py: die reinen Helfer rund um das MakeMKV-Datenverzeichnis.
|
||||||
|
|
||||||
WARUM ES DIESE TESTS GIBT (Befund 25.07.2026, live im Worker nachgemessen):
|
WARUM ES DIESE TESTS GIBT (Befund 25.07.2026, live im Worker nachgemessen):
|
||||||
Eine 4K-UHD-Disc (Akira UHD, MKB v76) scheiterte mit "The volume key is unknown
|
Eine 4K-UHD-Disc (Akira UHD, MKB v76) scheiterte mit "The volume key is unknown
|
||||||
for this disc", obwohl Laufwerk und MakeMKV in Ordnung waren. Der einzige heute
|
for this disc", obwohl Laufwerk und MakeMKV in Ordnung waren. Der einzige heute
|
||||||
noch funktionierende Weg ist eine selbst mitgebrachte KEYDB.cfg im
|
noch funktionierende Weg ist eine selbst mitgebrachte KEYDB.cfg im
|
||||||
Datenverzeichnis. Damit haengt einiges an diesen kleinen Funktionen: erkennen wir
|
Datenverzeichnis. Damit hängt einiges an diesen kleinen Funktionen: erkennen wir
|
||||||
die Datei falsch, meldet das UI "alles gut", waehrend MakeMKV weiter scheitert.
|
die Datei falsch, meldet das UI "alles gut", während MakeMKV weiter scheitert.
|
||||||
|
|
||||||
Getestet wird nur, was ohne Postgres, Redis und ohne Laufwerk laeuft — also die
|
Getestet wird nur, was ohne Postgres, Redis und ohne Laufwerk läuft — also die
|
||||||
puren Funktionen mit echten Beispieldaten. Zeilenformat der KEYDB.cfg laut
|
puren Funktionen mit echten Beispieldaten. Zeilenformat der KEYDB.cfg laut
|
||||||
libaacs (AGENTS Regel D, externe Schnittstellen nie aus dem Kopf):
|
libaacs (AGENTS Regel D, externe Schnittstellen nie aus dem Kopf):
|
||||||
https://github.com/ShiftMediaProject/libaacs/blob/master/KEYDB.cfg
|
https://github.com/ShiftMediaProject/libaacs/blob/master/KEYDB.cfg
|
||||||
@@ -17,7 +17,7 @@ ist eine Zwillingsdatei, es gibt sie unter docker/api/ UND docker/worker/, und
|
|||||||
beide Seiten haben Tests. Da im Projekt keine __init__.py liegen, importiert
|
beide Seiten haben Tests. Da im Projekt keine __init__.py liegen, importiert
|
||||||
pytest Testdateien unter ihrem blossen Dateinamen — zwei Dateien namens
|
pytest Testdateien unter ihrem blossen Dateinamen — zwei Dateien namens
|
||||||
test_makemkv_daten.py brechen deshalb die Sammelphase ab ("import file
|
test_makemkv_daten.py brechen deshalb die Sammelphase ab ("import file
|
||||||
mismatch") und faerben die ganze Ampel rot. Nicht zurueckbenennen.
|
mismatch") und faerben die ganze Ampel rot. Nicht zurückbenennen.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -47,10 +47,10 @@ zaehle_schluessel = _modul.zaehle_schluessel
|
|||||||
|
|
||||||
|
|
||||||
def _tar_mit(namen):
|
def _tar_mit(namen):
|
||||||
"""Baut ein tar-Archiv im Speicher — so sieht MakeMKVs Schluesselspeicher aus.
|
"""Baut ein tar-Archiv im Speicher — so sieht MakeMKVs Schlüsselspeicher aus.
|
||||||
|
|
||||||
Echte Eintragsnamen aus dem Speicher der Windows-Installation vom
|
Echte Eintragsnamen aus dem Speicher der Windows-Installation vom
|
||||||
25.07.2026: hkd_<8 Hex>.bin fuer die Schluessel, dazu Index-Dateien.
|
25.07.2026: hkd_<8 Hex>.bin für die Schluessel, dazu Index-Dateien.
|
||||||
"""
|
"""
|
||||||
import io
|
import io
|
||||||
import tarfile
|
import tarfile
|
||||||
@@ -67,7 +67,7 @@ def _tar_mit(namen):
|
|||||||
def test_zaehle_schluessel_zaehlt_nur_hkd_eintraege():
|
def test_zaehle_schluessel_zaehlt_nur_hkd_eintraege():
|
||||||
# Nur hkd_*.bin sind Disc-Schluessel. Index- und sdf-Dateien gehoeren zum
|
# Nur hkd_*.bin sind Disc-Schluessel. Index- und sdf-Dateien gehoeren zum
|
||||||
# Speicher dazu, sind aber keine Schluessel — sonst meldete das UI
|
# Speicher dazu, sind aber keine Schluessel — sonst meldete das UI
|
||||||
# "1 Schluessel vorhanden" fuer einen komplett leeren Vorrat.
|
# "1 Schluessel vorhanden" für einen komplett leeren Vorrat.
|
||||||
voll = _tar_mit([
|
voll = _tar_mit([
|
||||||
"hkd_00000059.bin",
|
"hkd_00000059.bin",
|
||||||
"hkd_0000005a.bin",
|
"hkd_0000005a.bin",
|
||||||
@@ -101,7 +101,7 @@ def test_private_data_pruefen_lehnt_leere_und_falsche_dateien_ab():
|
|||||||
|
|
||||||
def test_private_data_pruefen_lehnt_speicher_ohne_schluessel_ab():
|
def test_private_data_pruefen_lehnt_speicher_ohne_schluessel_ab():
|
||||||
# Der teuerste Bedienfehler: den Speicher einer Installation hochladen,
|
# Der teuerste Bedienfehler: den Speicher einer Installation hochladen,
|
||||||
# die selbst noch nie Schluessel geholt hat. Ohne diese Pruefung aendert
|
# die selbst noch nie Schluessel geholt hat. Ohne diese Prüfung ändert
|
||||||
# sich nichts und niemand versteht, warum.
|
# sich nichts und niemand versteht, warum.
|
||||||
leer = _tar_mit(["--index-4EF73C3560D489497ACE763962A7F07A4A1545C4.bin"])
|
leer = _tar_mit(["--index-4EF73C3560D489497ACE763962A7F07A4A1545C4.bin"])
|
||||||
assert "kein einziger Schluessel" in private_data_pruefen(leer)
|
assert "kein einziger Schluessel" in private_data_pruefen(leer)
|
||||||
@@ -123,7 +123,7 @@ def test_zwillinge_sind_byteweise_identisch():
|
|||||||
api = hashlib.sha256(datei.read()).hexdigest()
|
api = hashlib.sha256(datei.read()).hexdigest()
|
||||||
assert worker == api, (
|
assert worker == api, (
|
||||||
"docker/worker/makemkv_daten.py und docker/api/makemkv_daten.py sind "
|
"docker/worker/makemkv_daten.py und docker/api/makemkv_daten.py sind "
|
||||||
"auseinandergelaufen - Aenderungen immer in BEIDE Dateien uebernehmen."
|
"auseinandergelaufen - Änderungen immer in BEIDE Dateien übernehmen."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Eine kleine, aber echte KEYDB.cfg im libaacs-Format: Kommentarkopf, eine
|
# Eine kleine, aber echte KEYDB.cfg im libaacs-Format: Kommentarkopf, eine
|
||||||
@@ -142,7 +142,7 @@ A1B2C3D4E5F60718293A4B5C6D7E8F90A1B2C3D4 = BLADE RUNNER 2049
|
|||||||
def test_zaehle_disc_eintraege_zaehlt_nur_echte_disc_zeilen():
|
def test_zaehle_disc_eintraege_zaehlt_nur_echte_disc_zeilen():
|
||||||
"""Nur Zeilen mit 40 Hex-Zeichen und Gleichheitszeichen sind Eintraege.
|
"""Nur Zeilen mit 40 Hex-Zeichen und Gleichheitszeichen sind Eintraege.
|
||||||
|
|
||||||
Kommentare, Leerzeilen und Fortsetzungsfelder duerfen nicht mitzaehlen —
|
Kommentare, Leerzeilen und Fortsetzungsfelder dürfen nicht mitzaehlen —
|
||||||
sonst meldet das UI bei einer reinen Kommentardatei stolz "42 Eintraege".
|
sonst meldet das UI bei einer reinen Kommentardatei stolz "42 Eintraege".
|
||||||
"""
|
"""
|
||||||
assert zaehle_disc_eintraege(BEISPIEL_KEYDB) == 2
|
assert zaehle_disc_eintraege(BEISPIEL_KEYDB) == 2
|
||||||
@@ -158,14 +158,14 @@ def test_zaehle_disc_eintraege_ignoriert_zu_kurze_kennung():
|
|||||||
"""39 Hex-Zeichen sind keine Disc-Kennung.
|
"""39 Hex-Zeichen sind keine Disc-Kennung.
|
||||||
|
|
||||||
Genau so sieht eine beim Kopieren verstuemmelte Datei aus — die darf nicht
|
Genau so sieht eine beim Kopieren verstuemmelte Datei aus — die darf nicht
|
||||||
als gueltig durchgehen, sonst sucht der Commander den Fehler beim Laufwerk.
|
als gültig durchgehen, sonst sucht der Commander den Fehler beim Laufwerk.
|
||||||
"""
|
"""
|
||||||
zu_kurz = "A1B2C3D4E5F60718293A4B5C6D7E8F90A1B2C3D = KAPUTT\n"
|
zu_kurz = "A1B2C3D4E5F60718293A4B5C6D7E8F90A1B2C3D = KAPUTT\n"
|
||||||
assert zaehle_disc_eintraege(zu_kurz) == 0
|
assert zaehle_disc_eintraege(zu_kurz) == 0
|
||||||
|
|
||||||
|
|
||||||
def test_keydb_pruefen_meldet_leere_datei():
|
def test_keydb_pruefen_meldet_leere_datei():
|
||||||
# Haeufigster Fehlgriff: das Textfeld war leer, es wird trotzdem gespeichert.
|
# Häufigster Fehlgriff: das Textfeld war leer, es wird trotzdem gespeichert.
|
||||||
assert keydb_pruefen("") != ""
|
assert keydb_pruefen("") != ""
|
||||||
assert keydb_pruefen(" \n\n ") != ""
|
assert keydb_pruefen(" \n\n ") != ""
|
||||||
|
|
||||||
@@ -173,7 +173,7 @@ def test_keydb_pruefen_meldet_leere_datei():
|
|||||||
def test_keydb_pruefen_erkennt_html_fehlerseite():
|
def test_keydb_pruefen_erkennt_html_fehlerseite():
|
||||||
"""Der zweithaeufigste Fehlgriff: der Download lieferte eine HTML-Seite.
|
"""Der zweithaeufigste Fehlgriff: der Download lieferte eine HTML-Seite.
|
||||||
|
|
||||||
MakeMKV wuerde die Datei still ignorieren und weiter "volume key is unknown"
|
MakeMKV würde die Datei still ignorieren und weiter "volume key is unknown"
|
||||||
melden — deshalb muss der Fehler schon beim Hochladen sichtbar werden.
|
melden — deshalb muss der Fehler schon beim Hochladen sichtbar werden.
|
||||||
"""
|
"""
|
||||||
html = "<!DOCTYPE html>\n<html><body><h1>404 Not Found</h1></body></html>\n"
|
html = "<!DOCTYPE html>\n<html><body><h1>404 Not Found</h1></body></html>\n"
|
||||||
@@ -184,12 +184,12 @@ def test_keydb_pruefen_erkennt_html_fehlerseite():
|
|||||||
|
|
||||||
def test_keydb_pruefen_meldet_text_ohne_disc_zeile():
|
def test_keydb_pruefen_meldet_text_ohne_disc_zeile():
|
||||||
# Irgendein Text (hier: eine README) ist keine KEYDB.cfg.
|
# Irgendein Text (hier: eine README) ist keine KEYDB.cfg.
|
||||||
meldung = keydb_pruefen("Diese Datei enthaelt keine Schluessel, nur Prosa.\n")
|
meldung = keydb_pruefen("Diese Datei enthält keine Schluessel, nur Prosa.\n")
|
||||||
assert meldung != ""
|
assert meldung != ""
|
||||||
|
|
||||||
|
|
||||||
def test_keydb_pruefen_akzeptiert_gueltigen_inhalt():
|
def test_keydb_pruefen_akzeptiert_gueltigen_inhalt():
|
||||||
# Leerer Rueckgabewert heisst laut Vertrag: alles in Ordnung.
|
# Leerer Rueckgabewert heißt laut Vertrag: alles in Ordnung.
|
||||||
assert keydb_pruefen(BEISPIEL_KEYDB) == ""
|
assert keydb_pruefen(BEISPIEL_KEYDB) == ""
|
||||||
|
|
||||||
|
|
||||||
@@ -200,9 +200,9 @@ def test_ist_aacs_dump_akzeptiert_echten_namen():
|
|||||||
|
|
||||||
|
|
||||||
def test_ist_aacs_dump_lehnt_pfad_tricks_und_fremde_dateien_ab():
|
def test_ist_aacs_dump_lehnt_pfad_tricks_und_fremde_dateien_ab():
|
||||||
"""Der Download-Endpunkt haengt den Namen an das Datenverzeichnis an —
|
"""Der Download-Endpunkt hängt den Namen an das Datenverzeichnis an —
|
||||||
ohne diese Pruefung koennte man sich damit aus dem Verzeichnis heraus
|
ohne diese Prüfung könnte man sich damit aus dem Verzeichnis heraus
|
||||||
lesen. Versteckte Dateien und Nicht-Dumps sind ebenfalls nichts fuer die
|
lesen. Versteckte Dateien und Nicht-Dumps sind ebenfalls nichts für die
|
||||||
Liste."""
|
Liste."""
|
||||||
assert ist_aacs_dump("../ausbruch.tgz") is False
|
assert ist_aacs_dump("../ausbruch.tgz") is False
|
||||||
assert ist_aacs_dump(".versteckt.tgz") is False
|
assert ist_aacs_dump(".versteckt.tgz") is False
|
||||||
@@ -212,8 +212,8 @@ def test_ist_aacs_dump_lehnt_pfad_tricks_und_fremde_dateien_ab():
|
|||||||
|
|
||||||
def test_settings_conf_ersetzt_key_und_behaelt_den_rest():
|
def test_settings_conf_ersetzt_key_und_behaelt_den_rest():
|
||||||
"""DIE Regression, um die es geht: bis zum 25.07.2026 haben entrypoint.sh
|
"""DIE Regression, um die es geht: bis zum 25.07.2026 haben entrypoint.sh
|
||||||
und tasks.py die settings.conf komplett ueberschrieben. Mit dem jetzt
|
und tasks.py die settings.conf komplett überschrieben. Mit dem jetzt
|
||||||
persistenten Datenverzeichnis waere damit bei jedem Containerstart und vor
|
persistenten Datenverzeichnis wäre damit bei jedem Containerstart und vor
|
||||||
jedem Rip alles andere weg — allen voran app_UpdateEnable."""
|
jedem Rip alles andere weg — allen voran app_UpdateEnable."""
|
||||||
alt = 'app_Key = "T-alterSchluessel"\napp_UpdateEnable = "1"\napp_DefaultSelectionString = "+sel:all"\n'
|
alt = 'app_Key = "T-alterSchluessel"\napp_UpdateEnable = "1"\napp_DefaultSelectionString = "+sel:all"\n'
|
||||||
neu = settings_conf_zusammenfuehren(alt, "T-neuerSchluessel")
|
neu = settings_conf_zusammenfuehren(alt, "T-neuerSchluessel")
|
||||||
|
|||||||
@@ -114,67 +114,15 @@ def test_pfad_lokal_uebersetzt_fuer_windows_worker():
|
|||||||
assert pfad_lokal("/app/temp/raw/abc", "") == "/app/temp/raw/abc"
|
assert pfad_lokal("/app/temp/raw/abc", "") == "/app/temp/raw/abc"
|
||||||
|
|
||||||
|
|
||||||
def test_original_aufheben_laesst_job_nie_scheitern(tmp_path, monkeypatch):
|
# Die beiden früheren Tests zu _original_aufheben sind nach
|
||||||
"""Der Schritt "Original behalten" darf einen fertigen Job NICHT killen.
|
# test_original_aufheben.py gewandert und dort erweitert. Sie prüfen jetzt das
|
||||||
|
# richtige Kriterium: Vorher taeuschten sie per gefaelschtem os.stat
|
||||||
Schaden vom 25.07.2026 (Akira-UHD): shutil.move() ueber Dateisystemgrenzen
|
# „verschiedene Dateisysteme" vor — genau die Annahme, an der der Schutz in der
|
||||||
kopierte 74 GB, lief in ENOSPC, und der Job galt als fehlgeschlagen —
|
# Praxis scheiterte (st_dev war identisch, os.rename trotzdem EXDEV).
|
||||||
obwohl die komprimierte Datei laengst fertig war. Der Nutzer sah nur eine
|
|
||||||
leere Queue.
|
|
||||||
"""
|
|
||||||
import tasks
|
|
||||||
|
|
||||||
meldungen = []
|
|
||||||
monkeypatch.setattr(tasks.db, "add_log", lambda *a: meldungen.append(a))
|
|
||||||
|
|
||||||
raw = tmp_path / "raw"
|
|
||||||
raw.mkdir()
|
|
||||||
(raw / "title_t00.mkv").write_bytes(b"x" * 1024)
|
|
||||||
final = tmp_path / "final"
|
|
||||||
final.mkdir()
|
|
||||||
|
|
||||||
def platzt(*_a, **_k):
|
|
||||||
raise OSError(28, "No space left on device")
|
|
||||||
|
|
||||||
monkeypatch.setattr(tasks.shutil, "move", platzt)
|
|
||||||
# Darf NICHT werfen
|
|
||||||
tasks._original_aufheben("job-1", str(raw), str(final))
|
|
||||||
assert meldungen and meldungen[-1][0] == "warning"
|
|
||||||
assert "Roh-Datei bleibt" in meldungen[-1][2]
|
|
||||||
|
|
||||||
|
|
||||||
def test_original_aufheben_warnt_statt_platte_vollzuschreiben(tmp_path, monkeypatch):
|
|
||||||
"""Zu wenig Platz auf einem ANDEREN Dateisystem -> gar nicht erst anfangen."""
|
|
||||||
import tasks
|
|
||||||
|
|
||||||
meldungen = []
|
|
||||||
monkeypatch.setattr(tasks.db, "add_log", lambda *a: meldungen.append(a))
|
|
||||||
bewegt = []
|
|
||||||
monkeypatch.setattr(tasks.shutil, "move", lambda *a: bewegt.append(a))
|
|
||||||
|
|
||||||
raw = tmp_path / "raw"
|
|
||||||
raw.mkdir()
|
|
||||||
(raw / "gross.mkv").write_bytes(b"x" * 4096)
|
|
||||||
final = tmp_path / "final"
|
|
||||||
final.mkdir()
|
|
||||||
|
|
||||||
# Verschiedene Dateisysteme vortaeuschen und den freien Platz kleinrechnen.
|
|
||||||
# _ordner_groesse wird mitgefaelscht, weil ein selbstgebautes os.stat sonst
|
|
||||||
# os.path.getsize mitreisst (das liest st_size an derselben Stelle).
|
|
||||||
class Stat:
|
|
||||||
def __init__(self, dev): self.st_dev = dev
|
|
||||||
monkeypatch.setattr(tasks.os, "stat", lambda p: Stat(1 if "raw" in str(p) else 2))
|
|
||||||
monkeypatch.setattr(tasks, "_ordner_groesse", lambda _p: 4096)
|
|
||||||
monkeypatch.setattr(tasks, "_frei_bytes", lambda _p: 100)
|
|
||||||
|
|
||||||
tasks._original_aufheben("job-2", str(raw), str(final))
|
|
||||||
assert bewegt == [], "es darf NICHT kopiert werden, wenn der Platz nicht reicht"
|
|
||||||
assert meldungen[-1][0] == "warning"
|
|
||||||
assert "NICHT aufgehoben" in meldungen[-1][2]
|
|
||||||
|
|
||||||
|
|
||||||
def test_arbeitsverzeichnis_wahl_des_rips_schlaegt_die_einstellung():
|
def test_arbeitsverzeichnis_wahl_des_rips_schlaegt_die_einstellung():
|
||||||
"""Pro Rip waehlbar (Commander 25.07.2026), Einstellung bleibt Standard.
|
"""Pro Rip wählbar (Commander 25.07.2026), Einstellung bleibt Standard.
|
||||||
|
|
||||||
Reihenfolge: Wahl dieses Rips -> Setting -> Container-Default. Der
|
Reihenfolge: Wahl dieses Rips -> Setting -> Container-Default. Der
|
||||||
Setting-Wert ist genau der, der bei Vollautomatik-Rips greift, weil dort
|
Setting-Wert ist genau der, der bei Vollautomatik-Rips greift, weil dort
|
||||||
@@ -186,8 +134,42 @@ def test_arbeitsverzeichnis_wahl_des_rips_schlaegt_die_einstellung():
|
|||||||
assert tasks._arbeitsverzeichnis(einst, "/app/media/rippy") == "/app/media/rippy"
|
assert tasks._arbeitsverzeichnis(einst, "/app/media/rippy") == "/app/media/rippy"
|
||||||
assert tasks._arbeitsverzeichnis(einst) == "/app/media/movies"
|
assert tasks._arbeitsverzeichnis(einst) == "/app/media/movies"
|
||||||
assert tasks._arbeitsverzeichnis({}) == tasks.RAW_DIR
|
assert tasks._arbeitsverzeichnis({}) == tasks.RAW_DIR
|
||||||
# Ausbruchsversuche und Pfade ausserhalb /app/media fallen durch
|
# Ausbruchsversuche und Pfade außerhalb /app/media fallen durch
|
||||||
assert tasks._arbeitsverzeichnis({}, "/etc") == tasks.RAW_DIR
|
assert tasks._arbeitsverzeichnis({}, "/etc") == tasks.RAW_DIR
|
||||||
assert tasks._arbeitsverzeichnis({}, "/app/media/../etc") == tasks.RAW_DIR
|
assert tasks._arbeitsverzeichnis({}, "/app/media/../etc") == tasks.RAW_DIR
|
||||||
# Leere Wahl faellt sauber auf die Einstellung zurueck
|
# Leere Wahl fällt sauber auf die Einstellung zurück
|
||||||
assert tasks._arbeitsverzeichnis(einst, " ") == "/app/media/movies"
|
assert tasks._arbeitsverzeichnis(einst, " ") == "/app/media/movies"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein():
|
||||||
|
"""Befund 25.07.2026: Elf Stellen prüften mit nacktem startswith().
|
||||||
|
„/app/media-boese/x" beginnt mit „/app/media", liegt aber außerhalb."""
|
||||||
|
import tasks
|
||||||
|
|
||||||
|
assert tasks.unter_wurzel("/app/media", "/app/media") is True
|
||||||
|
assert tasks.unter_wurzel("/app/media/movies", "/app/media") is True
|
||||||
|
assert tasks.unter_wurzel("/app/media/movies/Film (2020)", "/app/media") is True
|
||||||
|
# Der Fall, den startswith() durchgelassen haette
|
||||||
|
assert tasks.unter_wurzel("/app/media-boese/x", "/app/media") is False
|
||||||
|
assert tasks.unter_wurzel("/app/mediaX", "/app/media") is False
|
||||||
|
assert tasks.unter_wurzel("/etc/passwd", "/app/media") is False
|
||||||
|
assert tasks.unter_wurzel("", "/app/media") is False
|
||||||
|
assert tasks.unter_wurzel("/app/media", "") is False
|
||||||
|
# Wurzel mit Schrägstrich am Ende darf nichts ändern
|
||||||
|
assert tasks.unter_wurzel("/app/media/movies", "/app/media/") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_zielbasis_lehnt_praefix_ausbruch_ab():
|
||||||
|
import tasks
|
||||||
|
|
||||||
|
assert tasks._zielbasis("/app/media/movies", "bluray") == "/app/media/movies"
|
||||||
|
# Ausbruch per Praefix-Namen fällt auf den Standard zurück
|
||||||
|
assert tasks._zielbasis("/app/media-boese", "bluray") != "/app/media-boese"
|
||||||
|
assert tasks._zielbasis("/etc", "bluray") != "/etc"
|
||||||
|
|
||||||
|
|
||||||
|
def test_arbeitsverzeichnis_lehnt_praefix_ausbruch_ab():
|
||||||
|
import tasks
|
||||||
|
|
||||||
|
assert tasks._arbeitsverzeichnis({}, "/app/media-boese") == tasks.RAW_DIR
|
||||||
|
assert tasks._arbeitsverzeichnis({"workDir": "/app/mediaX"}) == tasks.RAW_DIR
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""Tests für _original_aufheben — der Pfad, der am 25.07.2026 die Platte füllte.
|
||||||
|
|
||||||
|
Der Kern: Es wird NICHT vorhergesagt, ob umgehaengt werden kann, sondern
|
||||||
|
os.rename versucht. Die alte Fassung verglich st_dev und lag damit falsch —
|
||||||
|
auf der Rippy-VM sind st_dev von /app/temp und /app/media identisch (2050),
|
||||||
|
os.rename scheitert zwischen ihnen trotzdem mit EXDEV, weil der Kernel den
|
||||||
|
Mount vergleicht und nicht das Gerät. Die Platzpruefung wurde deshalb
|
||||||
|
übersprungen und shutil.move kopierte 75 GB bei 37 GB frei.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import errno
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import tasks
|
||||||
|
|
||||||
|
|
||||||
|
class FakeDb:
|
||||||
|
"""Faengt nur die Log-Zeilen ab — mehr braucht _original_aufheben nicht."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.logs = []
|
||||||
|
|
||||||
|
def add_log(self, level, source, message):
|
||||||
|
self.logs.append((level, message))
|
||||||
|
|
||||||
|
def meldungen(self):
|
||||||
|
return " ".join(m for _, m in self.logs)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_db(monkeypatch):
|
||||||
|
ersatz = FakeDb()
|
||||||
|
monkeypatch.setattr(tasks, "db", ersatz)
|
||||||
|
return ersatz
|
||||||
|
|
||||||
|
|
||||||
|
def _lege_rohdaten_an(tmp_path, groesse=2048):
|
||||||
|
raw = tmp_path / "raw" / "job-1"
|
||||||
|
raw.mkdir(parents=True)
|
||||||
|
(raw / "title_t00.mkv").write_bytes(b"x" * groesse)
|
||||||
|
final = tmp_path / "media" / "Film (2020)"
|
||||||
|
final.mkdir(parents=True)
|
||||||
|
return str(raw), str(final)
|
||||||
|
|
||||||
|
|
||||||
|
def test_umhaengen_wenn_derselbe_mount(tmp_path, fake_db):
|
||||||
|
"""Der gute Fall: rename klappt, nichts wird kopiert, kein Platz nötig."""
|
||||||
|
raw, final = _lege_rohdaten_an(tmp_path)
|
||||||
|
|
||||||
|
tasks._original_aufheben("job-1", raw, final)
|
||||||
|
|
||||||
|
assert os.path.isdir(os.path.join(final, "original"))
|
||||||
|
assert os.path.isfile(os.path.join(final, "original", "title_t00.mkv"))
|
||||||
|
assert not os.path.exists(raw)
|
||||||
|
assert "umgehängt" in fake_db.meldungen()
|
||||||
|
|
||||||
|
|
||||||
|
def test_bei_exdev_und_zu_wenig_platz_wird_nur_gewarnt(tmp_path, fake_db, monkeypatch):
|
||||||
|
"""Der Fall, der die Platte füllte: rename geht nicht, Platz reicht nicht.
|
||||||
|
|
||||||
|
Vorher lief hier eine Vollkopie an, weil die st_dev-Prüfung „gleiches
|
||||||
|
Dateisystem" meldete und die Platzpruefung deshalb ausblieb.
|
||||||
|
"""
|
||||||
|
raw, final = _lege_rohdaten_an(tmp_path)
|
||||||
|
|
||||||
|
def kein_rename(*_a, **_k):
|
||||||
|
raise OSError(errno.EXDEV, "Invalid cross-device link")
|
||||||
|
|
||||||
|
monkeypatch.setattr(tasks.os, "rename", kein_rename)
|
||||||
|
monkeypatch.setattr(tasks, "_frei_bytes", lambda _p: 1024) # weniger als die Rohdaten
|
||||||
|
|
||||||
|
tasks._original_aufheben("job-1", raw, final)
|
||||||
|
|
||||||
|
# Rohdaten bleiben unangetastet liegen, es wurde NICHTS kopiert
|
||||||
|
assert os.path.isfile(os.path.join(raw, "title_t00.mkv"))
|
||||||
|
assert not os.path.exists(os.path.join(final, "original"))
|
||||||
|
meldungen = fake_db.meldungen()
|
||||||
|
assert "NICHT aufgehoben" in meldungen
|
||||||
|
assert "anderen Mount" in meldungen
|
||||||
|
assert "Arbeitsverzeichnis" in meldungen # nennt die Abhilfe
|
||||||
|
|
||||||
|
|
||||||
|
def test_bei_exdev_und_genug_platz_wird_kopiert(tmp_path, fake_db, monkeypatch):
|
||||||
|
raw, final = _lege_rohdaten_an(tmp_path)
|
||||||
|
|
||||||
|
echtes_rename = os.rename
|
||||||
|
aufrufe = {"n": 0}
|
||||||
|
|
||||||
|
def rename_erst_exdev(*args, **kwargs):
|
||||||
|
# Nur der Versuch von _original_aufheben scheitert; shutil.move darf
|
||||||
|
# intern weiter umbenennen (es kopiert selbst und benennt Teile um).
|
||||||
|
aufrufe["n"] += 1
|
||||||
|
if aufrufe["n"] == 1:
|
||||||
|
raise OSError(errno.EXDEV, "Invalid cross-device link")
|
||||||
|
return echtes_rename(*args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(tasks.os, "rename", rename_erst_exdev)
|
||||||
|
monkeypatch.setattr(tasks, "_frei_bytes", lambda _p: 10 * 1024**3)
|
||||||
|
|
||||||
|
tasks._original_aufheben("job-1", raw, final)
|
||||||
|
|
||||||
|
assert os.path.isfile(os.path.join(final, "original", "title_t00.mkv"))
|
||||||
|
assert "Original behalten" in fake_db.meldungen()
|
||||||
|
|
||||||
|
|
||||||
|
def test_anderer_fehler_wird_ehrlich_gemeldet_und_reisst_job_nicht_mit(
|
||||||
|
tmp_path, fake_db, monkeypatch
|
||||||
|
):
|
||||||
|
"""Ein Fehler beim Aufheben darf den Job NIE scheitern lassen — die
|
||||||
|
komprimierte Datei ist zu diesem Zeitpunkt fertig und in Ordnung."""
|
||||||
|
raw, final = _lege_rohdaten_an(tmp_path)
|
||||||
|
|
||||||
|
def zugriff_verweigert(*_a, **_k):
|
||||||
|
raise OSError(errno.EACCES, "Permission denied")
|
||||||
|
|
||||||
|
monkeypatch.setattr(tasks.os, "rename", zugriff_verweigert)
|
||||||
|
|
||||||
|
tasks._original_aufheben("job-1", raw, final) # darf nicht werfen
|
||||||
|
|
||||||
|
meldungen = fake_db.meldungen()
|
||||||
|
assert "konnte nicht aufgehoben werden" in meldungen
|
||||||
|
assert raw in meldungen # sagt, WO die Rohdatei liegt
|
||||||
@@ -38,15 +38,49 @@ 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("--input") + 1] == "/app/temp/raw/x/t00.mkv"
|
||||||
assert cmd[cmd.index("--output") + 1] == "/app/media/bluray/x/t00.mkv"
|
assert cmd[cmd.index("--output") + 1] == "/app/media/bluray/x/t00.mkv"
|
||||||
assert "--preset" in cmd
|
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
|
assert "--all-subtitles" in cmd
|
||||||
|
|
||||||
|
|
||||||
def test_handbrake_progress_parsing():
|
def test_handbrake_progress_parsing():
|
||||||
# Testfund 22.07.: echtes HandBrake schreibt „45.50 %" MIT Leerzeichen
|
# Testfund 22.07.: echtes HandBrake schreibt 45.50 % MIT Leerzeichen
|
||||||
assert get_progress_from_line("Encoding: task 1 of 1, 45.50 %") == 45
|
assert get_progress_from_line("Encoding: task 1 of 1, 45.50 %") == 45
|
||||||
assert get_progress_from_line("Encoding: task 1 of 1, 100.00 %") == 100
|
assert get_progress_from_line("Encoding: task 1 of 1, 100.00 %") == 100
|
||||||
assert get_progress_from_line("kein Fortschritt hier") == 0
|
# Echte Zeile mit fps-Anhang, wie sie im Binary steht
|
||||||
|
assert get_progress_from_line(
|
||||||
|
"Encoding: task 1 of 1, 12.34 % (5.67 fps, avg 4.32 fps, ETA 00h12m34s)"
|
||||||
|
) == 12
|
||||||
|
# Ein echtes 0 % ist eine ANGABE, keine Leermeldung
|
||||||
|
assert get_progress_from_line("Encoding: task 1 of 1, 0.00 %") == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_handbrake_progress_ignoriert_scan_durchlauf():
|
||||||
|
"""Befund 25.07.2026 (Akira-UHD, live gemessen): HandBrake läuft VOR dem
|
||||||
|
Encodieren einen Scan-Durchlauf, der ebenfalls Prozente ausgibt und dabei
|
||||||
|
bis 100 % steigt. Die alte Regex nahm jede Zahl vor einem Prozentzeichen
|
||||||
|
und meldete deshalb 99 %, während der Encode bei 1 % stand.
|
||||||
|
|
||||||
|
-1 heißt „keine Encode-Fortschrittszeile" — dasselbe Muster wie bei
|
||||||
|
get_progress_from_prgv. Fremde Zeilen dürfen NIE als 0 % gelten.
|
||||||
|
"""
|
||||||
|
assert get_progress_from_line("Scanning title 1 of 1, preview 3, 30.00 %") == -1
|
||||||
|
assert get_progress_from_line("Scanning title 1 of 1, preview 10, 100.00 %") == -1
|
||||||
|
# Vorlauf-Phase: Prozente beziehen sich auf die Suche, nicht auf den Encode
|
||||||
|
assert get_progress_from_line(
|
||||||
|
"Encoding: task 1 of 1, Searching for start time, 42.00 %"
|
||||||
|
) == -1
|
||||||
|
assert get_progress_from_line("kein Fortschritt hier") == -1
|
||||||
|
assert get_progress_from_line("Muxing: this may take awhile...") == -1
|
||||||
|
assert get_progress_from_line("") == -1
|
||||||
|
|
||||||
|
|
||||||
|
def test_handbrake_progress_rechnet_zwei_durchlaeufe_zusammen():
|
||||||
|
"""Presets mit zwei Durchläufen zählen die Prozente je Durchlauf neu.
|
||||||
|
Ohne Verrechnung sprang die Anzeige mitten in der Datei zurück auf 0."""
|
||||||
|
assert get_progress_from_line("Encoding: task 1 of 2, 50.00 %") == 25
|
||||||
|
assert get_progress_from_line("Encoding: task 2 of 2, 0.00 %") == 50
|
||||||
|
assert get_progress_from_line("Encoding: task 2 of 2, 100.00 %") == 100
|
||||||
|
|
||||||
|
|
||||||
def test_prgv_parsing():
|
def test_prgv_parsing():
|
||||||
@@ -80,7 +114,7 @@ def test_parse_msg_trennt_code_und_klartext():
|
|||||||
|
|
||||||
def test_parse_msg_liest_die_uhd_fehlermeldung():
|
def test_parse_msg_liest_die_uhd_fehlermeldung():
|
||||||
"""3303 ist der Befund, um den es beim ganzen KEYDB-Thema geht: das
|
"""3303 ist der Befund, um den es beim ganzen KEYDB-Thema geht: das
|
||||||
Laufwerk laeuft im LibreDrive-Modus, MakeMKV kennt nur den Schluessel
|
Laufwerk läuft im LibreDrive-Modus, MakeMKV kennt nur den Schluessel
|
||||||
DIESER Pressung nicht. Ohne diese Zeile im Log raet der Commander."""
|
DIESER Pressung nicht. Ohne diese Zeile im Log raet der Commander."""
|
||||||
assert parse_msg(
|
assert parse_msg(
|
||||||
'MSG:3303,16777216,0,"The volume key is unknown for this disc - video can\'t be decrypted","The volume key is unknown for this disc - video can\'t be decrypted"'
|
'MSG:3303,16777216,0,"The volume key is unknown for this disc - video can\'t be decrypted","The volume key is unknown for this disc - video can\'t be decrypted"'
|
||||||
@@ -92,9 +126,9 @@ def test_parse_msg_liest_die_uhd_fehlermeldung():
|
|||||||
|
|
||||||
|
|
||||||
def test_parse_msg_schneidet_meldungen_mit_komma_nicht_ab():
|
def test_parse_msg_schneidet_meldungen_mit_komma_nicht_ab():
|
||||||
"""DER Grund fuer die Regex (Stand 25.07.2026): vorher stand hier
|
"""DER Grund für die Regex (Stand 25.07.2026): vorher stand hier
|
||||||
line.split(",", 4)[3]. Das schnitt jede Meldung ab, die selbst ein Komma
|
line.split(",", 4)[3]. Das schnitt jede Meldung ab, die selbst ein Komma
|
||||||
enthaelt — und MakeMKV schreibt solche laufend. Im Log stand dann nur noch
|
enthält — und MakeMKV schreibt solche laufend. Im Log stand dann nur noch
|
||||||
ein Satzfragment, das mehr verwirrt als hilft."""
|
ein Satzfragment, das mehr verwirrt als hilft."""
|
||||||
zeile = (
|
zeile = (
|
||||||
'MSG:3025,0,3,"Title #1 has length of 12 seconds, which is less than '
|
'MSG:3025,0,3,"Title #1 has length of 12 seconds, which is less than '
|
||||||
@@ -109,7 +143,7 @@ def test_parse_msg_schneidet_meldungen_mit_komma_nicht_ab():
|
|||||||
|
|
||||||
|
|
||||||
def test_parse_msg_ignoriert_fremde_zeilen():
|
def test_parse_msg_ignoriert_fremde_zeilen():
|
||||||
# Alles ausser MSG muss None liefern, sonst landet Fortschritts-Rauschen
|
# Alles außer MSG muss None liefern, sonst landet Fortschritts-Rauschen
|
||||||
# (PRGV kommt mehrmals pro Sekunde) als Log-Eintrag in der Datenbank.
|
# (PRGV kommt mehrmals pro Sekunde) als Log-Eintrag in der Datenbank.
|
||||||
assert parse_msg("PRGV:100,32768,65536") is None
|
assert parse_msg("PRGV:100,32768,65536") is None
|
||||||
assert parse_msg('DRV:0,2,999,12,"BD-RE ASUS BW-16D1HT","AKIRA","/dev/sr0"') is None
|
assert parse_msg('DRV:0,2,999,12,"BD-RE ASUS BW-16D1HT","AKIRA","/dev/sr0"') is None
|
||||||
@@ -129,6 +163,20 @@ def test_abcde_cmd_hat_genau_ein_ausgabeformat():
|
|||||||
assert "-N" in cmd # nicht-interaktiv, sonst hängt der Worker
|
assert "-N" in cmd # nicht-interaktiv, sonst hängt der Worker
|
||||||
|
|
||||||
|
|
||||||
|
def test_abcde_auswurf_folgt_der_einstellung():
|
||||||
|
"""Befund 25.07.2026: `-x` (Auswurf) stand fest verdrahtet drin. Eine
|
||||||
|
Audio-CD warf damit IMMER aus, eine DVD/Blu-ray NIE — und die Einstellung
|
||||||
|
„Automatischer Auswurf" regelte keines von beidem, weil sie nirgends
|
||||||
|
gelesen wurde."""
|
||||||
|
assert "-x" in build_abcde_cmd("/dev/sr0", "/tmp/c.conf", auswerfen=True)
|
||||||
|
assert "-x" not in build_abcde_cmd("/dev/sr0", "/tmp/c.conf", auswerfen=False)
|
||||||
|
# Standard bleibt „auswerfen" — so war das Verhalten bisher
|
||||||
|
assert "-x" in build_abcde_cmd("/dev/sr0", "/tmp/c.conf")
|
||||||
|
# Die Config darf durch das weggefallene -x nicht verrutschen
|
||||||
|
ohne = build_abcde_cmd("/dev/sr0", "/tmp/c.conf", auswerfen=False)
|
||||||
|
assert ohne[ohne.index("-c") + 1] == "/tmp/c.conf"
|
||||||
|
|
||||||
|
|
||||||
def test_abcde_config_enthaelt_zielverzeichnis():
|
def test_abcde_config_enthaelt_zielverzeichnis():
|
||||||
pfad = write_abcde_config("/app/media/cd/test123")
|
pfad = write_abcde_config("/app/media/cd/test123")
|
||||||
try:
|
try:
|
||||||
@@ -141,8 +189,8 @@ def test_abcde_config_enthaelt_zielverzeichnis():
|
|||||||
|
|
||||||
|
|
||||||
def test_preset_fuer_nimmt_das_preset_des_disc_typs():
|
def test_preset_fuer_nimmt_das_preset_des_disc_typs():
|
||||||
# Befund 25.07.2026: vorher galt EIN Preset fuer alles — eine 4K-UHD wurde
|
# Befund 25.07.2026: vorher galt EIN Preset für alles — eine 4K-UHD wurde
|
||||||
# damit auf 1080p heruntergerechnet, und beim ersten echten UHD-Rip waere
|
# damit auf 1080p heruntergerechnet, und beim ersten echten UHD-Rip wäre
|
||||||
# die 4K-Aufloesung still verlorengegangen.
|
# die 4K-Aufloesung still verlorengegangen.
|
||||||
from ripping import preset_fuer
|
from ripping import preset_fuer
|
||||||
|
|
||||||
@@ -159,13 +207,13 @@ def test_preset_fuer_nimmt_das_preset_des_disc_typs():
|
|||||||
|
|
||||||
def test_preset_fuer_faellt_auf_das_allgemeine_preset_zurueck():
|
def test_preset_fuer_faellt_auf_das_allgemeine_preset_zurueck():
|
||||||
# Bestandsinstallationen kennen die drei neuen Felder nicht. Solange der
|
# Bestandsinstallationen kennen die drei neuen Felder nicht. Solange der
|
||||||
# Nutzer sie nicht speichert, MUSS sich sein Verhalten nicht aendern.
|
# Nutzer sie nicht speichert, MUSS sich sein Verhalten nicht ändern.
|
||||||
from ripping import preset_fuer
|
from ripping import preset_fuer
|
||||||
|
|
||||||
alt = {"transcodePreset": "HQ 1080p30 Surround"}
|
alt = {"transcodePreset": "HQ 1080p30 Surround"}
|
||||||
assert preset_fuer("uhd", alt) == "HQ 1080p30 Surround"
|
assert preset_fuer("uhd", alt) == "HQ 1080p30 Surround"
|
||||||
assert preset_fuer("dvd", alt) == "HQ 1080p30 Surround"
|
assert preset_fuer("dvd", alt) == "HQ 1080p30 Surround"
|
||||||
# Leere Zeichenkette zaehlt als "nicht gesetzt" (leeres Select-Feld im UI)
|
# Leere Zeichenkette zählt als "nicht gesetzt" (leeres Select-Feld im UI)
|
||||||
assert preset_fuer("uhd", {"transcodePresetUhd": " ", "transcodePreset": "X"}) == "X"
|
assert preset_fuer("uhd", {"transcodePresetUhd": " ", "transcodePreset": "X"}) == "X"
|
||||||
|
|
||||||
|
|
||||||
@@ -178,3 +226,370 @@ def test_preset_fuer_ohne_einstellungen_nimmt_den_eingebauten_standard():
|
|||||||
assert preset_fuer("", None) == DEFAULT_HB_PRESET
|
assert preset_fuer("", None) == DEFAULT_HB_PRESET
|
||||||
assert preset_fuer(None, {}) == DEFAULT_HB_PRESET
|
assert preset_fuer(None, {}) == DEFAULT_HB_PRESET
|
||||||
assert preset_fuer("cd", {"transcodePresetUhd": "egal"}) == DEFAULT_HB_PRESET
|
assert preset_fuer("cd", {"transcodePresetUhd": "egal"}) == DEFAULT_HB_PRESET
|
||||||
|
|
||||||
|
|
||||||
|
def test_handbrake_prueft_abbruch_bei_jeder_zeile_nicht_nur_bei_fortschritt():
|
||||||
|
"""Befund 25.07.2026 (am laufenden Akira-Job beobachtet): Der Abbruch hing
|
||||||
|
am Fortschritts-Callback, und der stieg bei unveraenderter Prozentzahl
|
||||||
|
sofort aus. Bei einem 4K-Encode mit einem Prozent je halber Stunde sah
|
||||||
|
„Abbrechen" minutenlang wirkungslos aus (gemessen: 3,4 min).
|
||||||
|
|
||||||
|
Der Abbruch-Kanal muss deshalb JEDE Ausgabezeile sehen — auch die des
|
||||||
|
Scan-Durchlaufs, der gar keine Encode-Prozente liefert.
|
||||||
|
"""
|
||||||
|
import ripping
|
||||||
|
|
||||||
|
zeilen = [
|
||||||
|
"Scanning title 1 of 1, preview 1, 10.00 %\n",
|
||||||
|
"Scanning title 1 of 1, preview 2, 20.00 %\n",
|
||||||
|
"Encoding: task 1 of 1, 0.00 %\n",
|
||||||
|
"Encoding: task 1 of 1, 0.00 %\n",
|
||||||
|
]
|
||||||
|
gesehen = []
|
||||||
|
|
||||||
|
class FakeProcess:
|
||||||
|
def __init__(self):
|
||||||
|
self.stdout = iter(zeilen)
|
||||||
|
self.returncode = 0
|
||||||
|
self.getoetet = False
|
||||||
|
|
||||||
|
def kill(self):
|
||||||
|
self.getoetet = True
|
||||||
|
|
||||||
|
def wait(self):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
prozess = FakeProcess()
|
||||||
|
|
||||||
|
def abbruch_cb():
|
||||||
|
gesehen.append(1)
|
||||||
|
if len(gesehen) == 2: # beim zweiten Mal abbrechen
|
||||||
|
raise ripping.RipAbbruch()
|
||||||
|
|
||||||
|
ergebnis = ripping._handbrake_schleife(prozess, "/x.mkv", abbruch_cb, None)
|
||||||
|
|
||||||
|
assert ergebnis["status"] == "cancelled"
|
||||||
|
assert prozess.getoetet is True
|
||||||
|
# Zwei Scan-Zeilen genügten — es musste NICHT auf eine Encode-Zeile gewartet
|
||||||
|
# werden. Genau das war der Fehler.
|
||||||
|
assert len(gesehen) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_komprimieren_fuer_globaler_schalter_schlaegt_alles():
|
||||||
|
from ripping import komprimieren_fuer
|
||||||
|
|
||||||
|
assert komprimieren_fuer("uhd", {"transcodeEnabled": False}) is False
|
||||||
|
assert komprimieren_fuer("dvd", {"transcodeEnabled": False}) is False
|
||||||
|
# Standard ist AN - Bestandsinstallationen ändern ihr Verhalten nicht
|
||||||
|
assert komprimieren_fuer("bluray", {}) is True
|
||||||
|
assert komprimieren_fuer("bluray", None) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_komprimieren_fuer_einzelner_disc_typ_abwaehlbar():
|
||||||
|
"""Der Grund dafür (Befund 25.07.2026): 4K-HEVC brauchte auf der Rippy-VM
|
||||||
|
gemessene 28-55 Stunden, weil die CPU kein AVX2 hat. Vorher gab es nur
|
||||||
|
einen globalen Schalter - wer 4K verlustfrei behalten und DVDs trotzdem
|
||||||
|
schrumpfen wollte, hatte keine Möglichkeit."""
|
||||||
|
from ripping import PRESET_KEINE, komprimieren_fuer
|
||||||
|
|
||||||
|
nur_uhd_aus = {"transcodePresetUhd": PRESET_KEINE,
|
||||||
|
"transcodePresetDvd": "H.265 MKV 576p25"}
|
||||||
|
assert komprimieren_fuer("uhd", nur_uhd_aus) is False
|
||||||
|
assert komprimieren_fuer("dvd", nur_uhd_aus) is True
|
||||||
|
assert komprimieren_fuer("bluray", nur_uhd_aus) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_preset_fuer_gibt_den_reservewert_nie_als_preset_zurueck():
|
||||||
|
"""Sonst bekaeme HandBrake `--preset keine` und würde scheitern. Wer über
|
||||||
|
"Neu komprimieren" ausdruecklich doch komprimieren will, soll ein
|
||||||
|
brauchbares Preset bekommen statt eines Fehlers."""
|
||||||
|
from ripping import DEFAULT_HB_PRESET, PRESET_KEINE, preset_fuer
|
||||||
|
|
||||||
|
assert preset_fuer("uhd", {"transcodePresetUhd": PRESET_KEINE,
|
||||||
|
"transcodePreset": "HQ 1080p30 Surround"}) == "HQ 1080p30 Surround"
|
||||||
|
# Auch ohne allgemeines Preset kommt ein gültiger Name heraus
|
||||||
|
assert preset_fuer("uhd", {"transcodePresetUhd": PRESET_KEINE}) == DEFAULT_HB_PRESET
|
||||||
|
|
||||||
|
|
||||||
|
def test_reservewert_kollidiert_mit_keinem_echten_preset():
|
||||||
|
"""Gegengeprueft gegen alle 90 Presets aus `HandBrakeCLI --preset-list`
|
||||||
|
im Worker-Image (25.07.2026) - keines heißt so."""
|
||||||
|
from ripping import PRESET_KEINE
|
||||||
|
|
||||||
|
assert PRESET_KEINE == "keine"
|
||||||
|
assert " " not in PRESET_KEINE # echte Preset-Namen haben durchweg Leerzeichen
|
||||||
|
|
||||||
|
|
||||||
|
# --- Unbekanntes Preset: aus „Code 3" wird eine handelbare Meldung -----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbekanntes_preset_wird_erkannt():
|
||||||
|
"""Wortlaut echt gemessen (26.07.2026, Worker-Image): HandBrake antwortet
|
||||||
|
„Invalid preset <Name>" und listet danach die gültigen auf."""
|
||||||
|
from ripping import unbekanntes_preset
|
||||||
|
|
||||||
|
assert unbekanntes_preset("Invalid preset Gibt Es Nicht 999p") == "Gibt Es Nicht 999p"
|
||||||
|
assert unbekanntes_preset(" Invalid preset H.265 XYZ 4K ") == "H.265 XYZ 4K"
|
||||||
|
# Alles andere ist kein Preset-Fehler
|
||||||
|
assert unbekanntes_preset("Valid presets are:") == ""
|
||||||
|
assert unbekanntes_preset("Encoding: task 1 of 1, 5.00 %") == ""
|
||||||
|
assert unbekanntes_preset("") == ""
|
||||||
|
assert unbekanntes_preset(None) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_falsches_preset_erklaert_den_fehlschlag_statt_nur_den_code():
|
||||||
|
"""Vorher stand im UI nur „HandBrake endete mit Code 3" — daraus war nicht
|
||||||
|
zu erraten, dass der Preset-NAME das Problem ist. Der Fall wird häufiger,
|
||||||
|
weil ein Remote-Worker eine andere HandBrake-Version haben kann."""
|
||||||
|
import ripping
|
||||||
|
|
||||||
|
class FakeProcess:
|
||||||
|
def __init__(self):
|
||||||
|
self.stdout = iter([
|
||||||
|
"Invalid preset H.265 VCN 2160p 4K\n",
|
||||||
|
"Valid presets are:\n",
|
||||||
|
])
|
||||||
|
self.returncode = 3
|
||||||
|
|
||||||
|
def kill(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def wait(self):
|
||||||
|
return 3
|
||||||
|
|
||||||
|
ergebnis = ripping._handbrake_schleife(FakeProcess(), "/gibt-es-nicht.mkv")
|
||||||
|
|
||||||
|
assert ergebnis["status"] == "error"
|
||||||
|
assert "H.265 VCN 2160p 4K" in ergebnis["error"]
|
||||||
|
assert "Bestes wählen" in ergebnis["error"]
|
||||||
|
assert ergebnis["return_code"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_fehler_ohne_preset_problem_bleibt_der_alte():
|
||||||
|
import ripping
|
||||||
|
|
||||||
|
class FakeProcess:
|
||||||
|
def __init__(self):
|
||||||
|
self.stdout = iter(["irgendwas ganz anderes\n"])
|
||||||
|
self.returncode = 1
|
||||||
|
|
||||||
|
def kill(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def wait(self):
|
||||||
|
return 1
|
||||||
|
|
||||||
|
ergebnis = ripping._handbrake_schleife(FakeProcess(), "/gibt-es-nicht.mkv")
|
||||||
|
assert ergebnis["error"] == "HandBrake endete mit Code 1"
|
||||||
|
|
||||||
|
|
||||||
|
# --- 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
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
"""Tests der Zombie-Erkennung — ohne Postgres und ohne Redis.
|
||||||
|
|
||||||
|
Der Schwerpunkt liegt bewusst auf dem, was WEHTUT: ein laufender Job darf
|
||||||
|
niemals als Leiche gelten. Genau das wäre am 25.07.2026 passiert, wenn die
|
||||||
|
Erkennung nach dem Alter des Jobs geurteilt hätte — der Akira-Job war seit
|
||||||
|
neun Stunden offen und lief trotzdem.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import zombies
|
||||||
|
|
||||||
|
|
||||||
|
# --- reine Funktionen -------------------------------------------------------
|
||||||
|
|
||||||
|
def test_belegte_job_ids_findet_id_an_beliebiger_stelle():
|
||||||
|
"""job_id steht bei rip_disc an zweiter, bei transcode_files an erster
|
||||||
|
Stelle — die Erkennung darf sich auf keine Position verlassen."""
|
||||||
|
aktiv = {
|
||||||
|
"celery@node1": [
|
||||||
|
{"name": "worker.tasks.transcode_files", "args": ["job-eins", "/raw", "/final"]},
|
||||||
|
{"name": "worker.tasks.rip_disc", "args": ["/dev/sr0", "job-zwei"]},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
belegt = zombies.belegte_job_ids([aktiv], ["job-eins", "job-zwei", "job-drei"])
|
||||||
|
assert belegt == {"job-eins", "job-zwei"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_belegte_job_ids_versteht_args_als_text():
|
||||||
|
"""Celery liefert args je nach Version als Liste ODER als Text-Repräsentation."""
|
||||||
|
aktiv = {"celery@node1": [{"name": "x", "args": "('job-eins', '/raw', '/final')"}]}
|
||||||
|
assert zombies.belegte_job_ids([aktiv], ["job-eins", "job-zwei"]) == {"job-eins"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_belegte_job_ids_ohne_auskunft_ist_leer():
|
||||||
|
assert zombies.belegte_job_ids(None, ["a"]) == set()
|
||||||
|
assert zombies.belegte_job_ids([None, None, None], ["a"]) == set()
|
||||||
|
assert zombies.belegte_job_ids([{}, None], ["a"]) == set()
|
||||||
|
|
||||||
|
|
||||||
|
def test_antwortende_knoten_sammelt_ueber_alle_abfragen():
|
||||||
|
aktiv = {"celery@a": []}
|
||||||
|
vorgemerkt = {"celery@b": []}
|
||||||
|
assert zombies.antwortende_knoten([aktiv, None, vorgemerkt]) == {"celery@a", "celery@b"}
|
||||||
|
assert zombies.antwortende_knoten([None, None]) == set()
|
||||||
|
|
||||||
|
|
||||||
|
def test_auskunft_nur_vollstaendig_wenn_alle_bekannten_antworten():
|
||||||
|
assert zombies.auskunft_vollstaendig(1, 1) is True
|
||||||
|
assert zombies.auskunft_vollstaendig(2, 1) is True
|
||||||
|
# Ein bekannter Worker schweigt → NICHT urteilen
|
||||||
|
assert zombies.auskunft_vollstaendig(1, 2) is False
|
||||||
|
# Niemand antwortet → wir wissen nichts
|
||||||
|
assert zombies.auskunft_vollstaendig(0, 0) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_finde_zombies_verschont_belegte_jobs():
|
||||||
|
offene = [{"id": "a", "status": "transcoding"}, {"id": "b", "status": "ripping"}]
|
||||||
|
assert zombies.finde_zombies(offene, {"a"}) == [{"id": "b", "status": "ripping"}]
|
||||||
|
assert zombies.finde_zombies(offene, {"a", "b"}) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_fehlertext_nennt_zustand_und_den_weg_zurueck():
|
||||||
|
text = zombies.fehlertext({"status": "transcoding"})
|
||||||
|
assert "transcoding" in text
|
||||||
|
assert "Neu komprimieren" in text
|
||||||
|
assert "NICHT gelöscht" in text
|
||||||
|
|
||||||
|
|
||||||
|
# --- Attrappen für den Gesamtdurchlauf --------------------------------------
|
||||||
|
|
||||||
|
class FakeInspektor:
|
||||||
|
def __init__(self, aktiv=None, vorgemerkt=None, geplant=None):
|
||||||
|
self._aktiv, self._vorgemerkt, self._geplant = aktiv, vorgemerkt, geplant
|
||||||
|
|
||||||
|
def active(self):
|
||||||
|
return self._aktiv
|
||||||
|
|
||||||
|
def reserved(self):
|
||||||
|
return self._vorgemerkt
|
||||||
|
|
||||||
|
def scheduled(self):
|
||||||
|
return self._geplant
|
||||||
|
|
||||||
|
|
||||||
|
class FakeCelery:
|
||||||
|
def __init__(self, inspektor):
|
||||||
|
self.control = self
|
||||||
|
self._inspektor = inspektor
|
||||||
|
|
||||||
|
def inspect(self, timeout=None):
|
||||||
|
return self._inspektor
|
||||||
|
|
||||||
|
|
||||||
|
class FakeDb:
|
||||||
|
def __init__(self, offene, online=1):
|
||||||
|
self._offene, self._online = offene, online
|
||||||
|
self.aktualisierungen = []
|
||||||
|
self.logs = []
|
||||||
|
|
||||||
|
def list_jobs_mit_status(self, stati):
|
||||||
|
return [j for j in self._offene if j["status"] in stati]
|
||||||
|
|
||||||
|
def zaehle_online_worker(self, sekunden=120):
|
||||||
|
return self._online
|
||||||
|
|
||||||
|
def update_job(self, job_id, **felder):
|
||||||
|
self.aktualisierungen.append((job_id, felder))
|
||||||
|
|
||||||
|
def add_log(self, level, source, message):
|
||||||
|
self.logs.append((level, message))
|
||||||
|
|
||||||
|
def utcnow(self):
|
||||||
|
return "jetzt"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Gesamtdurchlauf --------------------------------------------------------
|
||||||
|
|
||||||
|
def test_laufender_job_wird_nicht_angetastet():
|
||||||
|
"""Der Fall, der wehtut: Job läuft seit Stunden und IST aktiv."""
|
||||||
|
db = FakeDb([{"id": "lebt", "status": "transcoding", "title": "Akira"}])
|
||||||
|
aktiv = {"celery@a": [{"name": "worker.tasks.transcode_files", "args": ["lebt"]}]}
|
||||||
|
celery = FakeCelery(FakeInspektor(aktiv=aktiv, vorgemerkt={}, geplant={}))
|
||||||
|
|
||||||
|
bericht = zombies.raeume_zombies_auf(celery, db)
|
||||||
|
|
||||||
|
assert bericht["aufgeraeumt"] == []
|
||||||
|
assert db.aktualisierungen == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_echte_leiche_wird_auf_failed_gesetzt():
|
||||||
|
db = FakeDb([{"id": "leiche", "status": "transcoding", "title": "Akira"}])
|
||||||
|
# Der Knoten antwortet — er arbeitet nur an nichts.
|
||||||
|
celery = FakeCelery(FakeInspektor(aktiv={"celery@a": []}, vorgemerkt={}, geplant={}))
|
||||||
|
|
||||||
|
bericht = zombies.raeume_zombies_auf(celery, db)
|
||||||
|
|
||||||
|
assert bericht["aufgeraeumt"] == ["leiche"]
|
||||||
|
job_id, felder = db.aktualisierungen[0]
|
||||||
|
assert job_id == "leiche"
|
||||||
|
assert felder["status"] == "failed"
|
||||||
|
assert "Neu komprimieren" in felder["error"]
|
||||||
|
assert felder["finished_at"] == "jetzt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_schweigender_worker_verhindert_jedes_urteil():
|
||||||
|
"""Zwei Worker gelten als online, nur einer antwortet — der andere könnte
|
||||||
|
genau diesen Job bearbeiten. Also: Finger weg."""
|
||||||
|
db = FakeDb([{"id": "unklar", "status": "transcoding", "title": "X"}], online=2)
|
||||||
|
celery = FakeCelery(FakeInspektor(aktiv={"celery@a": []}, vorgemerkt={}, geplant={}))
|
||||||
|
|
||||||
|
bericht = zombies.raeume_zombies_auf(celery, db)
|
||||||
|
|
||||||
|
assert bericht["aufgeraeumt"] == []
|
||||||
|
assert db.aktualisierungen == []
|
||||||
|
assert "unvollständige Auskunft" in bericht["uebersprungen"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_gar_keine_antwort_fuehrt_zu_keinem_urteil():
|
||||||
|
db = FakeDb([{"id": "unklar", "status": "ripping", "title": "X"}], online=0)
|
||||||
|
celery = FakeCelery(FakeInspektor(aktiv=None, vorgemerkt=None, geplant=None))
|
||||||
|
|
||||||
|
bericht = zombies.raeume_zombies_auf(celery, db)
|
||||||
|
|
||||||
|
assert bericht["aufgeraeumt"] == []
|
||||||
|
assert db.aktualisierungen == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_fertige_jobs_werden_gar_nicht_betrachtet():
|
||||||
|
db = FakeDb([
|
||||||
|
{"id": "fertig", "status": "completed", "title": "A"},
|
||||||
|
{"id": "kaputt", "status": "failed", "title": "B"},
|
||||||
|
{"id": "wartet", "status": "pending", "title": "C"},
|
||||||
|
])
|
||||||
|
celery = FakeCelery(FakeInspektor(aktiv={"celery@a": []}, vorgemerkt={}, geplant={}))
|
||||||
|
|
||||||
|
bericht = zombies.raeume_zombies_auf(celery, db)
|
||||||
|
|
||||||
|
# pending bleibt bewusst unberührt: die Aufgabe kann noch in der
|
||||||
|
# Warteschlange liegen und wird von selbst abgeholt.
|
||||||
|
assert bericht["geprueft"] == 0
|
||||||
|
assert db.aktualisierungen == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_fehler_reisst_den_worker_start_nicht_mit():
|
||||||
|
class KaputteDb(FakeDb):
|
||||||
|
def list_jobs_mit_status(self, stati):
|
||||||
|
raise RuntimeError("Postgres weg")
|
||||||
|
|
||||||
|
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,121 +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")
|
|
||||||
LOG_PFAD = os.path.join(BASIS, "worker.log")
|
|
||||||
|
|
||||||
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)
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"""Erkennt Job-Leichen: Jobs, die behaupten zu laufen, an denen aber niemand arbeitet.
|
||||||
|
|
||||||
|
Befund 25.07.2026 (Akira-UHD): Nach einem Absturz stand ein Job auf
|
||||||
|
`transcoding` bei 96 %, obwohl weder ein Prozess lief noch etwas in den
|
||||||
|
Celery-Queues stand. Folge für den Nutzer: kein Fortschritt, kein Download —
|
||||||
|
und der Knopf „Neu komprimieren" fehlte, weil `_kann_neu_komprimieren`
|
||||||
|
(api/main.py) `status == "failed"` verlangt. Der Job war damit unerreichbar,
|
||||||
|
obwohl die Rohdateien vollständig dalagen.
|
||||||
|
|
||||||
|
## Die Leitregel: ohne vollständige Auskunft wird NICHTS angetastet
|
||||||
|
|
||||||
|
Ein falsch als Leiche markierter Job ist teurer als eine stehengebliebene
|
||||||
|
Leiche. Deshalb drei Sicherungen:
|
||||||
|
|
||||||
|
1. **Nur beim Worker-Start.** Da ist die Aussage eindeutig: „als ich hochkam,
|
||||||
|
lief nichts". Ein periodischer Lauf könnte einen Job erwischen, der legitim
|
||||||
|
in der Warteschlange wartet, weil alle Arbeitsplätze belegt sind — der ist
|
||||||
|
für `active()`/`reserved()` unsichtbar. Bewusst nicht gebaut.
|
||||||
|
2. **Gnadenfrist.** Nach einem Neustart stellt Celery unbestätigte Aufgaben
|
||||||
|
erneut zu. Erst abwarten, dann urteilen.
|
||||||
|
3. **Vollzähligkeit.** Es wird nur geurteilt, wenn mindestens so viele
|
||||||
|
Celery-Knoten antworten, wie laut Herzschlag online sind. Antwortet ein
|
||||||
|
beschäftigter Remote-Worker nicht rechtzeitig, wäre sein laufender Job
|
||||||
|
sonst eine falsche Leiche.
|
||||||
|
|
||||||
|
Die Kernfunktionen sind bewusst rein (kein Postgres, kein Redis), damit die
|
||||||
|
Ampel sie ohne Infrastruktur prüfen kann.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Zustände, die behaupten: hier arbeitet gerade jemand.
|
||||||
|
#
|
||||||
|
# ⚠️ „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.
|
||||||
|
GNADENFRIST_SEKUNDEN = 120
|
||||||
|
|
||||||
|
# Wie lange auf Antworten der Celery-Knoten gewartet wird. Großzügig, weil ein
|
||||||
|
# Worker mitten in einem 4K-Encode träge antwortet.
|
||||||
|
INSPEKT_TIMEOUT_SEKUNDEN = 10
|
||||||
|
|
||||||
|
|
||||||
|
def belegte_job_ids(inspektionen, kandidaten) -> set:
|
||||||
|
"""Welche der `kandidaten`-IDs kommen in irgendeiner Celery-Aufgabe vor?
|
||||||
|
|
||||||
|
Bewusst als Textsuche über die ganze Inspektions-Antwort: `job_id` steht
|
||||||
|
bei `rip_disc` an ZWEITER, bei `transcode_files` an ERSTER Stelle, und
|
||||||
|
Celery liefert `args` je nach Version als Liste oder als Text. Eine
|
||||||
|
Positions-Auswertung wäre an beidem zerbrechlich. Eine Job-ID ist eine
|
||||||
|
36-stellige UUID — Fehltreffer sind praktisch ausgeschlossen.
|
||||||
|
|
||||||
|
`inspektionen` ist die Liste der Antworten (active/reserved/scheduled);
|
||||||
|
`None`-Einträge werden übersprungen.
|
||||||
|
"""
|
||||||
|
vorhandene = [i for i in (inspektionen or []) if i]
|
||||||
|
if not vorhandene:
|
||||||
|
return set()
|
||||||
|
text = repr(vorhandene)
|
||||||
|
return {jid for jid in kandidaten if jid and jid in text}
|
||||||
|
|
||||||
|
|
||||||
|
def antwortende_knoten(inspektionen) -> set:
|
||||||
|
"""Namen aller Celery-Knoten, die auf die Inspektion geantwortet haben."""
|
||||||
|
knoten = set()
|
||||||
|
for antwort in (inspektionen or []):
|
||||||
|
if antwort:
|
||||||
|
knoten.update(antwort.keys())
|
||||||
|
return knoten
|
||||||
|
|
||||||
|
|
||||||
|
def auskunft_vollstaendig(anzahl_antworten: int, anzahl_bekannt: int) -> bool:
|
||||||
|
"""Darf aus dieser Auskunft überhaupt ein Urteil folgen?
|
||||||
|
|
||||||
|
Nein, wenn niemand geantwortet hat (dann wissen wir nichts), und nein, wenn
|
||||||
|
weniger Knoten antworten als laut Herzschlag online sind (dann fehlt uns
|
||||||
|
genau der Knoten, der den Job vielleicht gerade bearbeitet).
|
||||||
|
"""
|
||||||
|
if anzahl_antworten < 1:
|
||||||
|
return False
|
||||||
|
return anzahl_antworten >= anzahl_bekannt
|
||||||
|
|
||||||
|
|
||||||
|
def finde_zombies(offene_jobs, belegte) -> list:
|
||||||
|
"""Jobs aus `offene_jobs`, zu denen keine Celery-Aufgabe existiert."""
|
||||||
|
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.
|
||||||
|
|
||||||
|
⚠️ 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"
|
||||||
|
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. "
|
||||||
|
)
|
||||||
|
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.'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def raeume_zombies_auf(celery_app, db) -> dict:
|
||||||
|
"""Sucht Leichen und setzt sie ehrlich auf `failed`. Wirft nie.
|
||||||
|
|
||||||
|
Rückgabe (auch für den Log): was geprüft und was getan wurde.
|
||||||
|
"""
|
||||||
|
bericht = {"geprueft": 0, "aufgeraeumt": [], "uebersprungen": ""}
|
||||||
|
try:
|
||||||
|
offene = db.list_jobs_mit_status(ARBEITS_STATI)
|
||||||
|
bericht["geprueft"] = len(offene)
|
||||||
|
if not offene:
|
||||||
|
return bericht
|
||||||
|
|
||||||
|
inspektor = celery_app.control.inspect(timeout=INSPEKT_TIMEOUT_SEKUNDEN)
|
||||||
|
inspektionen = [inspektor.active(), inspektor.reserved(), inspektor.scheduled()]
|
||||||
|
|
||||||
|
knoten = antwortende_knoten(inspektionen)
|
||||||
|
bekannt = db.zaehle_online_worker()
|
||||||
|
if not auskunft_vollstaendig(len(knoten), bekannt):
|
||||||
|
bericht["uebersprungen"] = (
|
||||||
|
f"unvollständige Auskunft ({len(knoten)} von {bekannt} Worker "
|
||||||
|
"haben geantwortet) — es wird nichts als Leiche gewertet"
|
||||||
|
)
|
||||||
|
db.add_log(
|
||||||
|
"info", "worker",
|
||||||
|
f"Zombie-Erkennung übersprungen: {bericht['uebersprungen']}",
|
||||||
|
)
|
||||||
|
return bericht
|
||||||
|
|
||||||
|
belegte = belegte_job_ids(inspektionen, [j.get("id") for j in offene])
|
||||||
|
for job in finde_zombies(offene, belegte):
|
||||||
|
db.update_job(
|
||||||
|
job["id"],
|
||||||
|
status="failed",
|
||||||
|
error=fehlertext(job),
|
||||||
|
finished_at=db.utcnow(),
|
||||||
|
)
|
||||||
|
db.add_log(
|
||||||
|
"warning", "worker",
|
||||||
|
f"Job {job['id']} ({job.get('title') or 'ohne Titel'}) stand auf "
|
||||||
|
f"'{job.get('status')}', es arbeitet aber niemand daran — "
|
||||||
|
"ehrlich auf 'failed' gesetzt. Rohdateien bleiben liegen.",
|
||||||
|
)
|
||||||
|
bericht["aufgeraeumt"].append(job["id"])
|
||||||
|
except Exception as e: # Erkennung darf den Worker-Start NIEMALS verhindern
|
||||||
|
bericht["uebersprungen"] = f"Fehler: {e}"
|
||||||
|
try:
|
||||||
|
db.add_log("warning", "worker", f"Zombie-Erkennung fehlgeschlagen: {e}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return bericht
|
||||||
Executable
+429
@@ -0,0 +1,429 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Rippy einrichten — ein Befehl, keine Handarbeit.
|
||||||
|
#
|
||||||
|
# ## Aufbau: erst ALLES prüfen, dann erst ändern
|
||||||
|
#
|
||||||
|
# Das Skript läuft in zwei klar getrennten Phasen:
|
||||||
|
#
|
||||||
|
# Phase 1 PRÜFEN — sieht sich alles an und ändert NICHTS.
|
||||||
|
# (Weiche) — harte Mängel → Abbruch mit Liste, nichts angefasst.
|
||||||
|
# Phase 2 EINRICHTEN — Verzeichnisse, Mount, .env, bauen, starten.
|
||||||
|
#
|
||||||
|
# Warum das wichtig ist (Commander-Einwand 25.07.2026): Die erste Fassung
|
||||||
|
# prüfte und änderte abwechselnd — Verzeichnisse wurden angelegt, bevor
|
||||||
|
# überhaupt klar war, ob der Rest durchläuft. Scheiterte etwas in der Mitte,
|
||||||
|
# blieb ein halb eingerichteter Rechner zurück. Und der Prüf-Modus war als
|
||||||
|
# `if`-Zweig durch fünf Schritte gefädelt; so etwas läuft mit der Zeit
|
||||||
|
# zwangsläufig aus dem Ruder, weil jede neue Aktion daran denken müsste.
|
||||||
|
#
|
||||||
|
# Jetzt gibt es GENAU EINEN Prüf-Pfad, den beide Modi benutzen. `--nur-pruefen`
|
||||||
|
# heißt schlicht: nach Phase 1 aufhören. Damit kann die Prüfung nicht mehr etwas
|
||||||
|
# anderes behaupten als die Installation tut.
|
||||||
|
#
|
||||||
|
# Es ist absichtlich WIEDERHOLBAR: mehrfach ausführen ändert nichts kaputt, und
|
||||||
|
# es ändert NIE einen Wert, der schon in der .env steht.
|
||||||
|
#
|
||||||
|
# Aufruf: sudo ./install.sh
|
||||||
|
# ./install.sh --nur-pruefen (nichts anfassen, nur berichten)
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
NUR_PRUEFEN=0
|
||||||
|
[ "${1:-}" = "--nur-pruefen" ] && NUR_PRUEFEN=1
|
||||||
|
|
||||||
|
MEDIA_DIR="${RIPPY_MEDIA_DIR:-/srv/rippy/media}"
|
||||||
|
MAKEMKV_DIR="${RIPPY_MAKEMKV_DIR:-/srv/rippy/makemkv}"
|
||||||
|
|
||||||
|
rot() { printf '\033[31m%s\033[0m\n' "$*"; }
|
||||||
|
gruen() { printf '\033[32m%s\033[0m\n' "$*"; }
|
||||||
|
gelb() { printf '\033[33m%s\033[0m\n' "$*"; }
|
||||||
|
titel() { printf '\n\033[1m%s\033[0m\n' "$*"; }
|
||||||
|
|
||||||
|
# Befunde der Prüfphase. PROBLEME verhindern die Installation, HINWEISE nicht.
|
||||||
|
PROBLEME=""
|
||||||
|
HINWEISE=""
|
||||||
|
problem() { PROBLEME="${PROBLEME}$1"$'\n'; rot " X $1"; }
|
||||||
|
hinweis() { HINWEISE="${HINWEISE}$1"$'\n'; gelb " ! $1"; }
|
||||||
|
ok() { gruen " OK $1"; }
|
||||||
|
|
||||||
|
# Welches Paketwerkzeug hat dieser Host? (Commander-Frage 26.07.2026: „Was wenn
|
||||||
|
# der Unterbau nicht Debian ist?") Vorher nannte das Skript bei fehlendem
|
||||||
|
# Compose stur `sudo apt install …` — auf Arch, Fedora oder openSUSE ist das
|
||||||
|
# schlicht falsch, und der Nutzer stand vor einem Befehl, den es nicht gibt.
|
||||||
|
#
|
||||||
|
# Erkannt wird über das VORHANDENE Werkzeug, nicht über /etc/os-release: Ein
|
||||||
|
# Derivat kann sich anders nennen als sein Unterbau, aber sein Paketwerkzeug
|
||||||
|
# liegt im PATH. Findet sich keines, wird nichts behauptet.
|
||||||
|
paket_befehl() {
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then echo "sudo apt install docker-compose-plugin"
|
||||||
|
elif command -v dnf >/dev/null 2>&1; then echo "sudo dnf install docker-compose-plugin"
|
||||||
|
elif command -v yum >/dev/null 2>&1; then echo "sudo yum install docker-compose-plugin"
|
||||||
|
elif command -v pacman >/dev/null 2>&1; then echo "sudo pacman -S docker-compose"
|
||||||
|
elif command -v zypper >/dev/null 2>&1; then echo "sudo zypper install docker-compose"
|
||||||
|
elif command -v apk >/dev/null 2>&1; then echo "sudo apk add docker-cli-compose"
|
||||||
|
else echo ""
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cd "$(dirname "$0")" || exit 1
|
||||||
|
|
||||||
|
echo "======================================================================"
|
||||||
|
echo " Rippy — Einrichtung"
|
||||||
|
echo "======================================================================"
|
||||||
|
echo " Phase 1 von 2: prüfen. Es wird noch NICHTS geändert."
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# PHASE 1 — PRÜFEN. Ab hier bis zur Weiche wird nichts verändert.
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
# --- 1. Betriebssystem und Docker ------------------------------------------
|
||||||
|
titel "Prüfung 1/5 Betriebssystem und Docker"
|
||||||
|
|
||||||
|
if [ "$(uname -s)" != "Linux" ]; then
|
||||||
|
problem "Kein Linux. Rippy braucht einen Linux-Host mit Docker."
|
||||||
|
rot " (Docker Desktop unter Windows/macOS kann keine optischen Laufwerke"
|
||||||
|
rot " durchreichen — deshalb geht es dort grundsätzlich nicht.)"
|
||||||
|
else
|
||||||
|
ok "Linux erkannt ($(uname -r))"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
|
problem "Docker ist nicht installiert."
|
||||||
|
rot " Installation: curl -fsSL https://get.docker.com | sudo sh"
|
||||||
|
else
|
||||||
|
ok "Docker gefunden ($(docker --version 2>/dev/null | head -1))"
|
||||||
|
fi
|
||||||
|
|
||||||
|
COMPOSE=""
|
||||||
|
if docker compose version >/dev/null 2>&1; then
|
||||||
|
COMPOSE="docker compose"
|
||||||
|
ok "Compose-Plugin gefunden"
|
||||||
|
elif command -v docker-compose >/dev/null 2>&1; then
|
||||||
|
COMPOSE="docker-compose"
|
||||||
|
hinweis "Nur das alte docker-compose gefunden — funktioniert, ist aber veraltet."
|
||||||
|
else
|
||||||
|
problem "Docker Compose fehlt."
|
||||||
|
PAKET_BEFEHL="$(paket_befehl)"
|
||||||
|
if [ -n "$PAKET_BEFEHL" ]; then
|
||||||
|
rot " Auf diesem System: $PAKET_BEFEHL"
|
||||||
|
fi
|
||||||
|
# Der Weg, der auf JEDER Distribution funktioniert — Dockers eigenes Skript
|
||||||
|
# bringt das Compose-Plugin mit. Steht immer da, auch wenn oben schon ein
|
||||||
|
# passender Paketbefehl genannt wurde: Paketnamen wandern, dieses Skript nicht.
|
||||||
|
rot " Überall gültig: curl -fsSL https://get.docker.com | sudo sh"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$(id -u)" = "0" ]; then
|
||||||
|
ok "Als root gestartet"
|
||||||
|
elif [ "$NUR_PRUEFEN" = 1 ]; then
|
||||||
|
# Die Prüfung ändert nichts und darf deshalb NICHT an root scheitern —
|
||||||
|
# sonst kann man vor der Installation nicht nachsehen, ob alles passt.
|
||||||
|
ok "Ohne root — für die Prüfung völlig in Ordnung"
|
||||||
|
else
|
||||||
|
problem "Bitte mit sudo starten: sudo ./install.sh"
|
||||||
|
rot " (Verzeichnisse unter /srv und die Mount-Propagation brauchen root.)"
|
||||||
|
rot " Nur nachsehen, ohne etwas zu ändern: ./install.sh --nur-pruefen"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 2. Optisches Laufwerk -------------------------------------------------
|
||||||
|
titel "Prüfung 2/5 Optisches Laufwerk"
|
||||||
|
|
||||||
|
# MakeMKV spricht Laufwerke über die SCSI-Generic-Schicht an und braucht
|
||||||
|
# deshalb ZWEI Knoten: /dev/srN und den passenden /dev/sgM. Welche sg-Nummer
|
||||||
|
# dazugehört, ist je Host anders — deshalb wird sie hier über die SCSI-Adresse
|
||||||
|
# ermittelt statt geraten. Beide Knoten zeigen in /sys auf dasselbe
|
||||||
|
# Geräteverzeichnis (z. B. 3:0:0:0), das ist der Abgleich.
|
||||||
|
SR=""
|
||||||
|
SG=""
|
||||||
|
LAUFWERK=""
|
||||||
|
for srpfad in /sys/block/sr*; do
|
||||||
|
[ -e "$srpfad" ] || continue
|
||||||
|
srname=$(basename "$srpfad")
|
||||||
|
adresse=$(basename "$(readlink -f "$srpfad/device" 2>/dev/null)" 2>/dev/null)
|
||||||
|
[ -n "$adresse" ] || continue
|
||||||
|
for sgpfad in /sys/class/scsi_generic/sg*; do
|
||||||
|
[ -e "$sgpfad" ] || continue
|
||||||
|
if [ "$(basename "$(readlink -f "$sgpfad/device" 2>/dev/null)" 2>/dev/null)" = "$adresse" ]; then
|
||||||
|
SR="/dev/$srname"
|
||||||
|
SG="/dev/$(basename "$sgpfad")"
|
||||||
|
LAUFWERK="$(cat "$srpfad/device/vendor" 2>/dev/null) $(cat "$srpfad/device/model" 2>/dev/null)"
|
||||||
|
break 2
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -n "$SR" ]; then
|
||||||
|
ok "Laufwerk gefunden: $(echo "$LAUFWERK" | tr -s ' ')"
|
||||||
|
gruen " $SR + $SG (SCSI-Adresse abgeglichen, nicht geraten)"
|
||||||
|
else
|
||||||
|
hinweis "Kein optisches Laufwerk gefunden — Rippy kann dann nur komprimieren."
|
||||||
|
gelb " Die Installation läuft trotzdem durch; diese Maschine dient dann"
|
||||||
|
gelb " als reine KOMPRIMIER-Maschine für andere Rippy-Instanzen."
|
||||||
|
gelb " Zum Rippen fehlt das Laufwerk. Häufigste Ursachen:"
|
||||||
|
gelb " - In einer VM: Laufwerk per USB-Passthrough durchreichen"
|
||||||
|
gelb " (Proxmox: qm set <vmid> -usb0 host=<hersteller>:<produkt>,usb3=1)"
|
||||||
|
gelb " NICHT als emuliertes CD-ROM (media=cdrom) — das kann keine"
|
||||||
|
gelb " SCSI-Kommandos und MakeMKV sieht es nie."
|
||||||
|
gelb " - Auf echter Hardware: 'ls /dev/sr*' prüfen, Kabel/Strom."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 3. Verzeichnisse und Platz -------------------------------------------
|
||||||
|
titel "Prüfung 3/5 Verzeichnisse und Plattenplatz"
|
||||||
|
|
||||||
|
for d in "$MEDIA_DIR" "$MAKEMKV_DIR"; do
|
||||||
|
if [ -d "$d" ]; then
|
||||||
|
ok "$d liegt schon da"
|
||||||
|
else
|
||||||
|
# Anlegen können wir nur mit root — und ob der Elternpfad beschreibbar ist,
|
||||||
|
# entscheidet, ob es später klappt. Beides hier schon sagen.
|
||||||
|
eltern="$(dirname "$d")"
|
||||||
|
while [ -n "$eltern" ] && [ ! -d "$eltern" ]; do eltern="$(dirname "$eltern")"; done
|
||||||
|
if [ "$(id -u)" = "0" ] || [ -w "$eltern" ]; then
|
||||||
|
ok "$d fehlt noch — wird in Phase 2 angelegt"
|
||||||
|
else
|
||||||
|
problem "$d fehlt und $eltern ist nicht beschreibbar."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
FREI_GB=$(df -BG --output=avail "$(dirname "$MEDIA_DIR")" 2>/dev/null | tail -1 | tr -dc '0-9')
|
||||||
|
if [ -n "${FREI_GB:-}" ]; then
|
||||||
|
if [ "$FREI_GB" -lt 60 ]; then
|
||||||
|
hinweis "Nur ${FREI_GB} GB frei. Eine Blu-ray braucht roh rund 40 GB, eine 4K-UHD bis 100 GB."
|
||||||
|
gelb " Rippy läuft trotzdem — lege die Ablage später besser auf eine"
|
||||||
|
gelb " NAS-Freigabe (Einstellungen → Speicherziele)."
|
||||||
|
else
|
||||||
|
ok "${FREI_GB} GB frei"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 4. Mount-Propagation --------------------------------------------------
|
||||||
|
titel "Prüfung 4/5 Mount-Propagation (für NAS-Freigaben aus dem UI)"
|
||||||
|
|
||||||
|
# Rippy hängt NAS-Freigaben aus dem UI ein. Damit die auch in den anderen
|
||||||
|
# Containern sichtbar werden, muss der Ablage-Pfad ein 'shared' Mount sein.
|
||||||
|
# Auf den meisten systemd-Hosts ist / schon rshared und es ist nichts zu tun.
|
||||||
|
propagation_ok() {
|
||||||
|
findmnt -no PROPAGATION --target "$MEDIA_DIR" 2>/dev/null | grep -q shared
|
||||||
|
}
|
||||||
|
|
||||||
|
PROPAGATION_NOETIG=0
|
||||||
|
if propagation_ok; then
|
||||||
|
ok "$MEDIA_DIR ist bereits 'shared' — nichts zu tun"
|
||||||
|
elif [ ! -d "$MEDIA_DIR" ]; then
|
||||||
|
ok "wird nach dem Anlegen des Verzeichnisses geprüft und bei Bedarf gesetzt"
|
||||||
|
PROPAGATION_NOETIG=1
|
||||||
|
else
|
||||||
|
ok "Propagation fehlt — wird in Phase 2 eingerichtet"
|
||||||
|
PROPAGATION_NOETIG=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 5. Konfiguration ------------------------------------------------------
|
||||||
|
titel "Prüfung 5/5 Konfiguration (.env)"
|
||||||
|
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
if [ -f .env.example ]; then
|
||||||
|
ok ".env fehlt — wird in Phase 2 aus .env.example erzeugt"
|
||||||
|
else
|
||||||
|
problem ".env.example fehlt. Ist das wirklich das Rippy-Repo?"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
ok ".env liegt schon da — bestehende Werte bleiben unangetastet"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Was in Phase 2 in die .env geschrieben würde — hier nur ANSAGEN.
|
||||||
|
wirksamer_wert() {
|
||||||
|
schluessel="$1"; standard="$2"
|
||||||
|
wert=$(grep -E "^${schluessel}=" .env 2>/dev/null | tail -1 | cut -d= -f2-)
|
||||||
|
[ -n "${wert:-}" ] && printf '%s' "$wert" || printf '%s' "$standard"
|
||||||
|
}
|
||||||
|
|
||||||
|
for paar in "OPTICAL_SR:$SR" "OPTICAL_SG:$SG" "MAKEMKV_DATA_HOST:$MAKEMKV_DIR"; do
|
||||||
|
schluessel="${paar%%:*}"; erkannt="${paar#*:}"
|
||||||
|
if grep -qE "^${schluessel}=.+" .env 2>/dev/null; then
|
||||||
|
echo " $schluessel steht schon in der .env — bleibt unverändert"
|
||||||
|
elif [ -n "$erkannt" ]; then
|
||||||
|
echo " $schluessel wird auf $erkannt gesetzt"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Gegenprobe: Zeigen die WIRKSAMEN Gerätepfade auf etwas, das es hier gibt?
|
||||||
|
# Fängt den Fall "'.env' von einem anderen Rechner kopiert" und Tippfehler ab —
|
||||||
|
# sonst startet Docker mit einem Pfad, den es nicht gibt, und die Meldung ist
|
||||||
|
# kryptisch.
|
||||||
|
for paar in "OPTICAL_SR:/dev/sr0" "OPTICAL_SG:/dev/sg1"; do
|
||||||
|
schluessel="${paar%%:*}"; standard="${paar##*:}"
|
||||||
|
# Nur prüfen, was auch wirklich gelten wird
|
||||||
|
if grep -qE "^${schluessel}=.+" .env 2>/dev/null; then
|
||||||
|
wirksam=$(wirksamer_wert "$schluessel" "$standard")
|
||||||
|
elif [ -n "$SR" ]; then
|
||||||
|
continue # wird gleich korrekt gesetzt
|
||||||
|
else
|
||||||
|
wirksam="$standard"
|
||||||
|
fi
|
||||||
|
if [ ! -e "$wirksam" ]; then
|
||||||
|
hinweis "$schluessel zeigt auf $wirksam — dieses Gerät gibt es hier NICHT."
|
||||||
|
if [ -n "$SR" ]; then
|
||||||
|
gelb " Gefunden wurde stattdessen: $SR + $SG"
|
||||||
|
gelb " Trage das in der .env ein (oder lösche die Zeile $schluessel=)."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo " API-Keys (TMDB/OMDb) brauchst du hier NICHT einzutragen —"
|
||||||
|
echo " der Einrichtungs-Assistent im Browser fragt sie ab und prüft sie."
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# WEICHE — hier wird entschieden. Bis hierher wurde NICHTS verändert.
|
||||||
|
# ===========================================================================
|
||||||
|
titel "Ergebnis der Prüfung"
|
||||||
|
|
||||||
|
ANZ_PROBLEME=$(printf '%s' "$PROBLEME" | grep -c . || true)
|
||||||
|
ANZ_HINWEISE=$(printf '%s' "$HINWEISE" | grep -c . || true)
|
||||||
|
|
||||||
|
if [ "$ANZ_PROBLEME" -gt 0 ]; then
|
||||||
|
rot " $ANZ_PROBLEME Punkt(e) verhindern die Installation:"
|
||||||
|
printf '%s' "$PROBLEME" | grep . | while IFS= read -r z; do rot " - $z"; done
|
||||||
|
rot ""
|
||||||
|
rot " Es wurde NICHTS verändert. Bitte das Obige nachholen und erneut starten."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$ANZ_HINWEISE" -gt 0 ]; then
|
||||||
|
gelb " Alles Wichtige ist da. $ANZ_HINWEISE Hinweis(e), die dich nicht aufhalten:"
|
||||||
|
printf '%s' "$HINWEISE" | grep . | while IFS= read -r z; do gelb " - $z"; done
|
||||||
|
else
|
||||||
|
gruen " Alles in Ordnung, keine Einschränkungen."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$NUR_PRUEFEN" = 1 ]; then
|
||||||
|
echo ""
|
||||||
|
gruen "Prüfung fertig — es wurde nichts verändert."
|
||||||
|
echo "Zum Installieren: sudo ./install.sh"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# PHASE 2 — EINRICHTEN. Erst ab hier wird etwas verändert.
|
||||||
|
# ===========================================================================
|
||||||
|
echo ""
|
||||||
|
echo "======================================================================"
|
||||||
|
echo " Phase 2 von 2: einrichten"
|
||||||
|
echo "======================================================================"
|
||||||
|
|
||||||
|
titel "Schritt 1/4 Verzeichnisse anlegen"
|
||||||
|
for d in "$MEDIA_DIR" "$MAKEMKV_DIR"; do
|
||||||
|
if [ -d "$d" ]; then
|
||||||
|
ok "$d war schon da"
|
||||||
|
else
|
||||||
|
mkdir -p "$d" && ok "$d angelegt"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo " Ablage: $MEDIA_DIR (hier landen die fertigen Filme)"
|
||||||
|
echo " MakeMKV-Daten: $MAKEMKV_DIR (Schlüsselspeicher, rebuild-fest)"
|
||||||
|
|
||||||
|
titel "Schritt 2/4 Mount-Propagation einrichten"
|
||||||
|
if propagation_ok; then
|
||||||
|
ok "war schon 'shared' — nichts zu tun"
|
||||||
|
elif [ "$PROPAGATION_NOETIG" = 1 ]; then
|
||||||
|
mount --bind "$MEDIA_DIR" "$MEDIA_DIR" 2>/dev/null
|
||||||
|
mount --make-rshared "$MEDIA_DIR" 2>/dev/null
|
||||||
|
if propagation_ok; 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.
|
||||||
|
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
|
||||||
|
|
||||||
|
[Mount]
|
||||||
|
What=$MEDIA_DIR
|
||||||
|
Where=$MEDIA_DIR
|
||||||
|
Type=none
|
||||||
|
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."
|
||||||
|
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."
|
||||||
|
gelb " Rippy läuft trotzdem — nur das Einhängen von NAS-Freigaben AUS DEM UI"
|
||||||
|
gelb " funktioniert dann nicht. Freigaben stattdessen auf dem Host einhängen,"
|
||||||
|
gelb " z. B. unter $MEDIA_DIR/nas. Betrifft nur klassisches Docker mit"
|
||||||
|
gelb " SYS_ADMIN — nicht rootless/Podman."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
titel "Schritt 3/4 Konfiguration schreiben"
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
cp .env.example .env && ok ".env aus .env.example erzeugt"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Setzt einen Wert NUR, wenn er noch nicht (unkommentiert) gesetzt ist.
|
||||||
|
setze_wenn_leer() {
|
||||||
|
schluessel="$1"; wert="$2"
|
||||||
|
[ -f .env ] || return
|
||||||
|
if grep -qE "^${schluessel}=.+" .env; then
|
||||||
|
echo " $schluessel steht schon in der .env — unverändert gelassen"
|
||||||
|
else
|
||||||
|
sed -i "/^#*${schluessel}=/d" .env
|
||||||
|
printf '%s=%s\n' "$schluessel" "$wert" >> .env
|
||||||
|
ok "$schluessel=$wert eingetragen"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -n "$SR" ]; then
|
||||||
|
setze_wenn_leer OPTICAL_SR "$SR"
|
||||||
|
setze_wenn_leer OPTICAL_SG "$SG"
|
||||||
|
fi
|
||||||
|
setze_wenn_leer MAKEMKV_DATA_HOST "$MAKEMKV_DIR"
|
||||||
|
|
||||||
|
titel "Schritt 4/4 Bauen und starten"
|
||||||
|
echo " Der erste Bau dauert je nach Internet 3-10 Minuten. Nur beim ersten Mal."
|
||||||
|
echo ""
|
||||||
|
if ! $COMPOSE up -d --build; then
|
||||||
|
rot ""
|
||||||
|
rot "Der Start ist gescheitert. Die häufigsten Ursachen:"
|
||||||
|
rot " - Kein Platz auf der Platte: df -h /"
|
||||||
|
rot " - Port 80 schon belegt: ss -tlnp | grep ':80 '"
|
||||||
|
rot " - Download von makemkv.com gedrosselt (Cloudflare): Tarballs von"
|
||||||
|
rot " makemkv.com/download nach docker/worker/vendor/ legen, dann erneut."
|
||||||
|
rot "Die volle Meldung steht oben. Logs: $COMPOSE logs --tail=50"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
IP=$(ip -4 route get 1.1.1.1 2>/dev/null | grep -oE 'src [0-9.]+' | awk '{print $2}')
|
||||||
|
[ -n "${IP:-}" ] || IP="<ip-dieses-rechners>"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "======================================================================"
|
||||||
|
gruen " Fertig. Rippy läuft."
|
||||||
|
echo "======================================================================"
|
||||||
|
echo ""
|
||||||
|
echo " Jetzt im Browser öffnen: http://$IP"
|
||||||
|
echo ""
|
||||||
|
echo " Dort startet der Einrichtungs-Assistent. Er zeigt dir, ob Laufwerk,"
|
||||||
|
echo " Worker und Platz in Ordnung sind, und stellt die Kompression passend"
|
||||||
|
echo " zu deiner CPU ein. Mehr ist nicht zu tun."
|
||||||
|
echo ""
|
||||||
|
echo " Status ansehen: $COMPOSE ps"
|
||||||
|
echo " Logs ansehen: $COMPOSE logs -f"
|
||||||
|
echo " Aktualisieren: git pull && sudo ./install.sh"
|
||||||
|
echo ""
|
||||||
Reference in New Issue
Block a user