Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61c38a0d14 | |||
| 29444805a8 | |||
| b0205c42c9 | |||
| 35cfcbcb07 | |||
| 65edf8369e | |||
| 8f208ed0da | |||
| b76ca5d9be | |||
| ca91e5b693 | |||
| 1eb1c91dd8 | |||
| 6a17af2118 | |||
| 11597eb00c | |||
| b546c79b9e | |||
| a1aabd591d | |||
| 8394de6926 | |||
| 574354131c | |||
| ef0a574a70 | |||
| 883c1c290b | |||
| e0cb7b3ddc | |||
| 5cd1876c90 | |||
| b526a0a033 | |||
| 8bb075c656 | |||
| c065967f4d | |||
| e84afc1718 | |||
| f449c4ee34 | |||
| 0935766f61 | |||
| f74f4e54f6 | |||
| 0832ef777c | |||
| 6a082cf77c | |||
| 46a8f50c34 |
+47
-3
@@ -1,14 +1,31 @@
|
|||||||
# Rippy-Umgebung — nach .env kopieren und Werte eintragen.
|
# Rippy-Umgebung — nach .env kopieren und Werte eintragen.
|
||||||
# Die .env liegt NUR auf der VM (gitignored), nie im Repo.
|
# Die .env liegt NUR auf der VM (gitignored), nie im Repo.
|
||||||
|
|
||||||
# PostgreSQL (intern; Compose nutzt aktuell rippy/rippy — Härtung folgt)
|
# PostgreSQL (intern). Default rippy; für eine exponierte Umgebung hier ein starkes
|
||||||
|
# Passwort setzen — Compose nutzt diesen Wert jetzt wirklich (DB + App-Verbindung).
|
||||||
POSTGRES_PASSWORD=rippy
|
POSTGRES_PASSWORD=rippy
|
||||||
|
|
||||||
|
# Optisches Laufwerk (Host-Geräteknoten) — je Rechner UNTERSCHIEDLICH!
|
||||||
|
# Der Worker braucht den SCSI-CD-ROM-Knoten (meist /dev/sr0) UND den passenden
|
||||||
|
# generischen sg-Knoten des Laufwerks.
|
||||||
|
#
|
||||||
|
# BEWUSST AUSKOMMENTIERT: Die sg-Nummer ist JE HOST ANDERS. Ein vorbelegter Wert
|
||||||
|
# wäre auf den meisten Rechnern falsch — und zwar unbemerkt, weil er dann „schon
|
||||||
|
# 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.)
|
# Commander-Entscheid, siehe KONZEPT.md Abschnitt 10. Härtung für exponierte
|
||||||
|
# Netze: siehe README, Abschnitt "Härtung für fremde/exponierte Netze".)
|
||||||
|
|
||||||
# Metadaten-APIs
|
# Metadaten-APIs
|
||||||
# TMDB (Pflicht für Metadaten-Lookup): kostenlos auf themoviedb.org
|
# TMDB (empfohlen für Metadaten-Lookup; ohne Key startet Rippy trotzdem, Key auch
|
||||||
|
# später im UI setzbar): kostenlos auf themoviedb.org
|
||||||
TMDB_API_KEY=
|
TMDB_API_KEY=
|
||||||
# TVDb (optional, Serien-Fallback)
|
# TVDb (optional, Serien-Fallback)
|
||||||
THETVDB_API_KEY=
|
THETVDB_API_KEY=
|
||||||
@@ -22,8 +39,35 @@ OMDB_API_KEY=
|
|||||||
# nächsten Rip, ohne Neustart, und schlägt diesen Env-Wert).
|
# nächsten Rip, ohne Neustart, und schlägt diesen Env-Wert).
|
||||||
MAKEMKV_APP_KEY=
|
MAKEMKV_APP_KEY=
|
||||||
|
|
||||||
|
# MakeMKV-Datenverzeichnis auf dem HOST (bleibt über Rebuilds hinweg erhalten).
|
||||||
|
# Hier hinein gehört die KEYDB.cfg für 4K-UHD-Discs; hier landen auch die
|
||||||
|
# AACS-Dumps fehlgeschlagener Discs. Beides ist ab Einstellungen → System
|
||||||
|
# im Browser erreichbar — dieser Pfad ist nur für den Fall, dass du die
|
||||||
|
# Dateien direkt auf der Maschine anfassen willst.
|
||||||
|
#MAKEMKV_DATA_HOST=/srv/rippy/makemkv
|
||||||
|
|
||||||
# MakeMKV-Version fürs Worker-Image (Einstellungen → System meldet Updates).
|
# MakeMKV-Version fürs Worker-Image (Einstellungen → System meldet Updates).
|
||||||
# Update: Version hier anheben, dann auf der Rippy-Maschine
|
# Update: Version hier anheben, dann auf der Rippy-Maschine
|
||||||
# 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,64 +1,89 @@
|
|||||||
# 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 (24.07.2026)
|
## Aktueller Stand (25.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
|
||||||
- 📝 **Details immer in SAVEPOINT.md** — diese Sektion nennt nur die Etappe
|
- ✅ **Etappe 17 (v3.10):** 4K-UHD-Disc-Schlüssel — persistentes
|
||||||
|
MakeMKV-Datenverzeichnis + `KEYDB.cfg` im UI
|
||||||
|
- ✅ **Etappe 18 (v3.11):** 4K-UHD gelöst — `makemkvcon` holt Schlüssel
|
||||||
|
unter Linux nie, unter Windows schon; Schlüsselspeicher übernehmbar.
|
||||||
|
Akira-UHD geht auf der VM auf (`TCOUNT:5`, bewiesen)
|
||||||
|
- ✅ **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
|
||||||
|
- 📝 **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.
|
||||||
|
|||||||
+18
@@ -127,6 +127,7 @@ Ein modular aufgebautes System, das bei Disc-Einwurf automatisch den Typ erkennt
|
|||||||
| Risiko | Status | Behandlung |
|
| Risiko | Status | Behandlung |
|
||||||
|--------|--------|------------|
|
|--------|--------|------------|
|
||||||
| MakeMKV-Beta-Key-Management | Gelöst | Key-Erneuerung als Cron-Job; DMCA-Ausnahme in DE |
|
| MakeMKV-Beta-Key-Management | Gelöst | Key-Erneuerung als Cron-Job; DMCA-Ausnahme in DE |
|
||||||
|
| **Disc-Schlüssel für 4K-UHD (AACS 2.0)** | **Gelöst mit Handgriff** | Am 25.07.2026 auf beiden Maschinen gemessen: `makemkvcon` unter **Linux** ruft Disc-Schlüssel nie ab (kein einziger Verbindungsversuch, mit leerem wie gefülltem Speicher, mit und ohne `--noscan`, `dev:` wie `disc:`), die **Windows**-Version tut es (Meldung 3338). Rippy stellt ein persistentes Datenverzeichnis bereit und nimmt den Schlüsselspeicher `_private_data.tar` einer Windows-Installation sowie ersatzweise eine `KEYDB.cfg` entgegen; damit ging Akira UHD auf der VM auf. Rippy liefert, lädt und verteilt KEINE Schlüssel. Siehe §10 (25.07.2026) |
|
||||||
| LXC-Device-Node-Änderungen | Gelöst | udev-Resolver (UUID/Serial) |
|
| LXC-Device-Node-Änderungen | Gelöst | udev-Resolver (UUID/Serial) |
|
||||||
| Pending-Queue / State-Manager | Gelöst | Redis mit AOF-Persistence |
|
| Pending-Queue / State-Manager | Gelöst | Redis mit AOF-Persistence |
|
||||||
| Hybrid-Discs | Offen | MVP erkennt nur Standard; als "Kann" notiert |
|
| Hybrid-Discs | Offen | MVP erkennt nur Standard; als "Kann" notiert |
|
||||||
@@ -182,6 +183,23 @@ 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).
|
||||||
|
- **25.07.2026 — 4K-UHD-Disc-Schlüssel: Rippy stellt Platz bereit, keine
|
||||||
|
Schlüssel:** Das Muss-Feature „MakeMKV-Ripping (lossless)" bleibt
|
||||||
|
unverändert; ergänzt wird nur ein **persistentes MakeMKV-Datenverzeichnis**
|
||||||
|
(`MAKEMKV_DATA_HOST`, im Worker `/root/.MakeMKV`, in der API
|
||||||
|
`/app/makemkv-data`) samt Bedienung im UI. Begründung: Am 25.07.2026 auf
|
||||||
|
BEIDEN Maschinen nachgemessen — `makemkvcon` unter Linux ruft Disc-Schlüssel
|
||||||
|
nie ab, die Windows-Version tut es. (Erste Fassung dieses Eintrags behauptete,
|
||||||
|
MakeMKVs Schlüssel-Kanal sei abgeschaltet; das war falsch und wurde am selben
|
||||||
|
Tag richtiggestellt.) Rippy nimmt deshalb den Schlüsselspeicher
|
||||||
|
`_private_data.tar` einer MakeMKV-Installation entgegen und ersatzweise eine
|
||||||
|
`KEYDB.cfg`. **Rippy liefert und verteilt KEINE Disc-Schlüssel und
|
||||||
|
lädt auch keine herunter** — es hält nur den Platz für Dateien bereit,
|
||||||
|
die der Nutzer selbst mitbringt, zeigt ehrlich an, was dort liegt, und gibt
|
||||||
|
die AACS-Dumps heraus, die MakeMKV ohnehin selbst schreibt. Das ist genau
|
||||||
|
die Grenze, die `docker/api/makemkv_key.py` in Zeile 14 zieht: die
|
||||||
|
kostenlose Beta-LIZENZ der Software ist etwas anderes als das
|
||||||
|
Entschlüsseln oder Verteilen von Disc-Schlüsseln.
|
||||||
- **24.07.2026 — Serien-Flow:** Staffel-Ablage <Serie>/Season NN plus
|
- **24.07.2026 — Serien-Flow:** Staffel-Ablage <Serie>/Season NN plus
|
||||||
Episoden-Zuordnung per Laufzeitabgleich (TMDB) — erfüllt Etappe-12-Ziel
|
Episoden-Zuordnung per Laufzeitabgleich (TMDB) — erfüllt Etappe-12-Ziel
|
||||||
„Serien-Episoden-Erkennung" in der ersten Ausbaustufe (nur bei
|
„Serien-Episoden-Erkennung" in der ersten Ausbaustufe (nur bei
|
||||||
|
|||||||
@@ -2,183 +2,468 @@
|
|||||||
|
|
||||||
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**.
|
||||||
|
|
||||||
```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>
|
||||||
|
|
||||||
Standard ist `/dev/sr0` (+ `/dev/sg1` für den Worker — MakeMKV spricht
|
<details>
|
||||||
Laufwerke über die SCSI-Generic-Schicht an). Andere Geräte? Lege eine
|
<summary><b>Wie es intern funktioniert</b></summary>
|
||||||
`docker-compose.override.yml` an:
|
|
||||||
|
|
||||||
```yaml
|
1. **Disc-Wache** — ioctl-Polling, kein udev. Im Container läuft kein udevd,
|
||||||
services:
|
die udev-Datenbank ist leer; `udevadm info` liefert dort prinzipbedingt
|
||||||
api:
|
nichts.
|
||||||
devices: ["/dev/sr1:/dev/sr0"]
|
2. **Rip** — MakeMKV, verlustfrei. Der einzige Weg durch AACS: HandBrake kann
|
||||||
worker:
|
verschlüsselte Discs nicht lesen, deshalb sind es zwingend zwei Stufen.
|
||||||
devices: ["/dev/sr1:/dev/sr0", "/dev/sg2:/dev/sg1"]
|
3. **Kompression** — HandBrake als eigener Celery-Task auf eigener Queue,
|
||||||
```
|
deshalb an andere Maschinen routbar.
|
||||||
|
4. **Metadaten** — Volume-Label → TMDB → OMDb → MyAnimeList, mit
|
||||||
|
Ähnlichkeits-Bewertung und Korrektur-Popup an der Disc-Karte.
|
||||||
|
|
||||||
Welche sg-Nummer dein Laufwerk hat, verrät `lsscsi -g` oder
|
</details>
|
||||||
`ls -la /sys/class/scsi_generic/`.
|
|
||||||
|
|
||||||
**Laufwerk in einer VM?** Per USB-Passthrough anhand der Vendor-ID
|
<details>
|
||||||
durchreichen (Proxmox: `qm set <vmid> -usb0 host=xxxx:yyyy,usb3=1`) —
|
<summary><b>Härtung für exponierte Netze</b></summary>
|
||||||
NICHT als emuliertes CD-ROM (`media=cdrom`), das kann keine SCSI-Kommandos.
|
|
||||||
|
|
||||||
## Wie es funktioniert
|
Rippy ist bewusst **Heimnetz-only**: keine Authentifizierung, und Redis +
|
||||||
|
PostgreSQL veröffentlichen Ports für Remote-Worker. Im vertrauten LAN ein
|
||||||
|
akzeptierter Kompromiss — **exponiere Rippy niemals ungeschützt an ein
|
||||||
|
öffentliches Netz.**
|
||||||
|
|
||||||
1. **Disc-Wache** (ioctl-Polling, kein udev-Gefrickel) erkennt Einlegen,
|
- **Zugriff kapseln:** UI und API (`:8000`) hinter einen Reverse-Proxy mit Auth
|
||||||
identifiziert die Disc (Volume-Label → TMDB → OMDb-Fallback) und zeigt
|
oder nur über VPN erreichbar machen.
|
||||||
sie mit Poster auf dem Dashboard. Klick auf einen Job-Titel öffnet die
|
- **DB/Broker abschotten:** starkes `POSTGRES_PASSWORD`; die Ports
|
||||||
Detail-Ansicht (Poster, Jahr, Beschreibung, Ablagepfad).
|
`5432`/`6379` nur an ein internes/VPN-Interface binden statt an `0.0.0.0`;
|
||||||
2. **Rip** (MakeMKV, verlustfrei — der einzige Weg durch AACS): Ziel wählst
|
Redis mit `--requirepass` starten und das Passwort in `REDIS_URL` ergänzen.
|
||||||
du beim Start (Filme/Serien/Musik/eigener Pfad, inkl. Netzwerk-Ziele) —
|
- **CORS:** `main.py` erlaubt `*` (nötig, weil UI und API getrennte Ports
|
||||||
oder du stellst die **Vollautomatik** an (Einstellungen → Ripping) und
|
sind) — hinter einem Proxy auf die echte UI-Herkunft einschränken.
|
||||||
der Rip startet ohne Nachfrage in den passenden Schnellwahl-Ordner.
|
- **NAS-Zugangsdaten** liegen im Klartext in der DB — ein weiterer Grund, den
|
||||||
Optional **nur Hauptfilm** (längster Titel, Extras bleiben weg). Bereits
|
DB-Port nie offen ins unsichere Netz zu hängen.
|
||||||
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,
|
|
||||||
Preset im UI wählbar; 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 ein LibreDrive-fähiges Laufwerk (MakeMKV-Forum:
|
|
||||||
„Ultimate UHD Drives Flashing Guide"). Normale BD/DVD gehen mit jedem
|
|
||||||
Laufwerk. ⚠️ 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)
|
</details>
|
||||||
|
|
||||||
Unter **Einstellungen → Speicherziele** hängst du NFS- oder SMB-Freigaben
|
<details>
|
||||||
direkt aus dem UI ein — sie erscheinen sofort in der Ziel-Auswahl beim
|
<summary><b>Updates der Werkzeuge</b></summary>
|
||||||
Rippen und werden beim Start automatisch wieder verbunden.
|
|
||||||
Technik: der api-Container läuft mit `CAP_SYS_ADMIN` und einem
|
|
||||||
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
|
„Auf Updates prüfen" (Einstellungen → System) vergleicht mit makemkv.com und
|
||||||
|
den offiziellen HandBrake-Releases, 12 h gecacht.
|
||||||
|
|
||||||
**Einstellungen → Benachrichtigungen**: eine Webhook-URL eintragen, „Test
|
**MakeMKV** aktualisieren: `MAKEMKV_VERSION=x.y.z` in die `.env`, dann
|
||||||
senden" drücken, speichern — fertig. Rippy meldet Job-Ende (fertig,
|
`sudo ./install.sh`. Der Beta-Key wechselt etwa monatlich (Forum-Thread
|
||||||
fehlgeschlagen, abgebrochen) und erkennt den Dienst an der URL selbst:
|
t=1053) und wird im UI gepflegt — ohne Rebuild.
|
||||||
|
|
||||||
| Dienst | URL-Beispiel | Format |
|
**HandBrake** kommt im Docker-Worker bewusst aus Debian (stabil, hinkt der
|
||||||
|---|---|---|
|
offiziellen Version hinterher); der native Windows-Worker nutzt die aktuelle.
|
||||||
| 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 & Updates
|
⚠️ 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).
|
||||||
|
|
||||||
**Einstellungen → System** zeigt die Werkzeug-Versionen jedes Workers
|
</details>
|
||||||
(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`.
|
|
||||||
|
|
||||||
**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` — neue
|
|
||||||
Versionen bringen auch die neueste Disc-Schlüssel-Datenbank mit.
|
|
||||||
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 |
|
|
||||||
|
|
||||||
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 — muss ein rshared-fähiger Bind sein).
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## 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)
|
||||||
|
|||||||
+156
@@ -414,6 +414,162 @@ irrelevant und kann komplett raus."
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Etappe 17 (25.07.2026): 4K-UHD-Schlüssel (KEYDB.cfg)
|
||||||
|
|
||||||
|
**Quelle:** Messung am 25.07. live auf der Rippy-VM im Worker-Container.
|
||||||
|
Eine 4K-UHD (Akira, MKB v76, Pressung Dezember 2020) scheitert an „The
|
||||||
|
volume key is unknown for this disc" — obwohl makemkvcon „Using LibreDrive
|
||||||
|
mode (v06.3)" und „Using direct disc access mode" meldet, die Disc liest
|
||||||
|
und den AACS-Dump ablegt (Meldung 3332). Das Debug-Log geht ohne einen
|
||||||
|
einzigen Netz-Versuch von „Loaded content hash table" direkt auf den
|
||||||
|
Fehler; `_private_data.tar` enthielt nur die Index-Datei und keine einzige
|
||||||
|
`hkd_*.bin`; auch mit gelöschter `update.conf` (Meldung 5074 belegt den
|
||||||
|
Web-Kontakt) und `app_UpdateEnable = "1"` kam kein Schlüssel; die
|
||||||
|
Forum-Schlüssel-Server `hkdata.fairuse.org` und `hkdata.crabdance.com`
|
||||||
|
lösen weltweit nicht mehr auf. **Fazit: Ein MakeMKV-Update löst das
|
||||||
|
nicht** (das behauptete v3.3 — dort richtiggestellt). Der einzige heute
|
||||||
|
funktionierende Weg ist eine `KEYDB.cfg` im MakeMKV-Datenverzeichnis.
|
||||||
|
|
||||||
|
> ⚠️ **Nachtrag am selben Tag (siehe Etappe 18): Die letzte Folgerung war
|
||||||
|
> falsch.** Die beiden toten Hostnamen stammen aus alten Forumsbeiträgen und
|
||||||
|
> werden von MakeMKV längst nicht mehr benutzt — der Dienst lebt. Richtig
|
||||||
|
> ist: `makemkvcon` unter **Linux** fragt nie nach Schlüsseln, die
|
||||||
|
> **Windows**-Version schon. Alles hier Gebaute bleibt richtig und nötig,
|
||||||
|
> nur die `KEYDB.cfg` ist nicht der Haupt-, sondern der Ersatzweg.
|
||||||
|
|
||||||
|
**Gebaut:**
|
||||||
|
- [x] **Persistentes Datenverzeichnis**: `${MAKEMKV_DATA_HOST:-/srv/rippy/makemkv}`
|
||||||
|
vom Host gemountet — Worker `/root/.MakeMKV`, API `/app/makemkv-data`,
|
||||||
|
beide mit `MAKEMKV_DATA_DIR`. `KEYDB.cfg` und AACS-Dumps überleben
|
||||||
|
jeden Rebuild. `.env.example` erklärt die Variable.
|
||||||
|
- [x] **entrypoint.sh entschärft**: `settings.conf` wird ergänzt statt
|
||||||
|
überschrieben (der Beta-Key hatte sonst alles andere gelöscht),
|
||||||
|
`app_UpdateEnable = "1"` gesetzt.
|
||||||
|
- [x] **Zwillings-Modul `makemkv_daten.py`** (identisch in `docker/api/`
|
||||||
|
und `docker/worker/`) als einzige Wahrheit über das Verzeichnis:
|
||||||
|
Status lesen, Inhalt prüfen, atomar schreiben, löschen, Dumps
|
||||||
|
auflisten — alles reine Funktionen, damit die Ampel sie ohne
|
||||||
|
Postgres/Redis testen kann.
|
||||||
|
- [x] **API**: `GET/POST/DELETE /system/keydb`, `GET /system/aacs-dumps`
|
||||||
|
und `GET /system/aacs-dumps/{dateiname}`. Der Inhalt kommt bewusst
|
||||||
|
als JSON-Body — es gibt kein `python-multipart`, ein Endpunkt mit
|
||||||
|
`UploadFile`/`File()` würde die API beim Import töten.
|
||||||
|
- [x] **UI (Einstellungen → System)**: Status der `KEYDB.cfg` (Pfad,
|
||||||
|
Größe, Anzahl Disc-Einträge, Datum), Inhalt einfügen, entfernen,
|
||||||
|
AACS-Dumps herunterladen — ohne SSH auf die VM.
|
||||||
|
- [x] **MakeMKV redet endlich**: `parse_msg()` in `ripping.py` + Log-Callback
|
||||||
|
in `tasks.py` schreiben MakeMKV-Meldungen ins Rippy-Log (gedrosselt:
|
||||||
|
Code 1003 raus, keine Wiederholungen, max. 40 je Rip). UHD-Fehlertext
|
||||||
|
ehrlich neu geschrieben; `caps.py` meldet `keydb: ja | nein | unbekannt`.
|
||||||
|
- [x] **Doku nachgezogen**: README (UHD-Voraussetzungen, System-Panel,
|
||||||
|
`MAKEMKV_DATA_HOST`, Bereitstellung), SAVEPOINT v3.10 inkl.
|
||||||
|
Richtigstellung von v3.3, KONZEPT §8 + §10.
|
||||||
|
|
||||||
|
**Offen aus dieser Runde:**
|
||||||
|
- [ ] **Der Nachweis mit einer echten `KEYDB.cfg` steht aus.** Zum
|
||||||
|
Zeitpunkt der Änderung lag keine Datei vor, die den Akira-Schlüssel
|
||||||
|
enthält — belegt sind der Befund und die Mechanik, NICHT ein
|
||||||
|
erfolgreicher UHD-Rip.
|
||||||
|
- [ ] Damit bleibt auch der volle Transcode-E2E an einer UHD weiter offen
|
||||||
|
(siehe SAVEPOINT v3.7) — an normalen BD/DVD ist die Kette bewiesen.
|
||||||
|
|
||||||
|
**Nachtrag zu „Offen":** Beide Punkte sind mit Etappe 18 erledigt — Akira
|
||||||
|
ging auf der VM auf, allerdings über den Schlüsselspeicher statt über eine
|
||||||
|
`KEYDB.cfg`.
|
||||||
|
|
||||||
|
**Ausdrücklich nicht gebaut (und wird es auch nicht):** Rippy liefert
|
||||||
|
keine Disc-Schlüssel mit, lädt keine herunter und verteilt keine. Es
|
||||||
|
stellt nur den Platz für eine Datei bereit, die der Nutzer selbst
|
||||||
|
mitbringt, und zeigt ehrlich an, was dort liegt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Etappe 18 (25.07.2026): 4K-UHD gelöst — Schlüsselspeicher statt KEYDB.cfg
|
||||||
|
|
||||||
|
**Quelle:** Einwand des Commanders („wenn ich MakeMKV lokal auf meinem
|
||||||
|
Windows-PC installiere, würde es SOFORT gehen — wir übersehen etwas
|
||||||
|
Gewaltiges"). Er hatte recht. Gegenprobe mit demselben Laufwerk und
|
||||||
|
derselben Disc an einem Windows-PC:
|
||||||
|
|
||||||
|
| | Linux (Worker) | Windows |
|
||||||
|
|---|---|---|
|
||||||
|
| Verbindungen beim Disc-Öffnen | **keine einzige** | 185.84.108.20:443 |
|
||||||
|
| Meldung 3338 „Downloading latest HK" | nie | ja |
|
||||||
|
| `_private_data.tar` | 2048 B, 0 Schlüssel | 6,4 MB, 604 Schlüssel |
|
||||||
|
| Disc | „volume key is unknown" | **TCOUNT:5, geht auf** |
|
||||||
|
|
||||||
|
Gegengeprüft mit leerem UND gefülltem Speicher, mit und ohne `--noscan`,
|
||||||
|
mit `dev:/dev/sr0` und `disc:0`, mit gelöschter `update.conf`: Linux
|
||||||
|
fragt nie. Die Meldungsvorlage „Downloading latest %1 to %2 ..." steckt
|
||||||
|
sehr wohl im Linux-Binary — sie löst nur nicht aus. Gleiches Symptom im
|
||||||
|
MakeMKV-Forum, seit Jahren offen (t=25782, t=34022).
|
||||||
|
**Damit ist die Diagnose aus Etappe 17 („der Schlüssel-Kanal ist tot")
|
||||||
|
widerlegt.** Sie stützte sich auf zwei Hostnamen aus alten Forumsbeiträgen,
|
||||||
|
die MakeMKV längst nicht mehr benutzt.
|
||||||
|
|
||||||
|
**Gebaut:**
|
||||||
|
- [x] **Schlüsselspeicher im Modul**: `zaehle_schluessel`,
|
||||||
|
`private_data_pruefen`, `schluesselspeicher_status`,
|
||||||
|
`private_data_schreiben` in `makemkv_daten.py` (beide Zwillinge).
|
||||||
|
Die Prüfung lehnt einen Speicher OHNE `hkd_*.bin` ab — sonst lädt
|
||||||
|
jemand den leeren Vorrat einer frischen Installation hoch, es ändert
|
||||||
|
sich nichts, und niemand versteht warum.
|
||||||
|
- [x] **API**: `GET /system/keystore` und `POST /system/keystore`. Der
|
||||||
|
Rohkörper der Anfrage IST die Datei — binär, deshalb kein JSON und
|
||||||
|
kein Base64; Multipart kann die API ohnehin nicht.
|
||||||
|
- [x] **UI**: neuer Block „Disc-Schlüssel für 4K-UHD" ÜBER dem
|
||||||
|
KEYDB-Block, mit Schlüssel-Anzahl, Upload und Schritt-für-Schritt-
|
||||||
|
Anleitung für den Windows-Umweg. KEYDB.cfg ist jetzt als Notnagel
|
||||||
|
beschriftet. Worker-Plakette zeigt die Schlüssel-Anzahl.
|
||||||
|
- [x] **Fehlertext** in `tasks.py` sagt den Windows-Weg an und nennt die
|
||||||
|
Anzahl bekannter Schlüssel dieses Workers.
|
||||||
|
- [x] **`caps.py`** meldet `schluessel` je Worker.
|
||||||
|
- [x] **Alle Falschaussagen korrigiert**: UI (3 Stellen), Anleitung (2),
|
||||||
|
README (3), KONZEPT §8 + §10, `makemkv_daten.py`-Modulkopf,
|
||||||
|
Worker-Dockerfile, `makemkv_key.py`.
|
||||||
|
|
||||||
|
**Bewiesen:** Nach Übernahme des Windows-Schlüsselspeichers öffnet
|
||||||
|
`makemkvcon` auf der VM die Akira-UHD — „Operation successfully
|
||||||
|
completed", `TCOUNT:5`, fünf Titel, identisch zum Windows-Ergebnis.
|
||||||
|
**Das ist der erste belegte UHD-Disc-Zugriff auf der Rippy-Maschine.**
|
||||||
|
|
||||||
|
**Offen aus dieser Runde:**
|
||||||
|
- [ ] Voller UHD-Rip inklusive Transcode-E2E (nur der Disc-Zugriff ist
|
||||||
|
belegt, nicht die komplette Kette bis zur fertigen Datei).
|
||||||
|
- [ ] Ob sich der Abruf unter Linux doch anstoßen lässt, ist ungeklärt —
|
||||||
|
der Code ist im Binary vorhanden. Bis dahin bleibt der Windows-Umweg.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Etappe 19 (25.07.2026): Preset je Disc-Typ
|
||||||
|
|
||||||
|
**Quelle:** Rückfrage des Commanders beim ersten echten UHD-Rip („merkt
|
||||||
|
Rippy eigentlich, wenn es eine UHD-Disc ist, und wendet direkt das
|
||||||
|
4K-Preset an?"). Antwort war: nein. Die Kompression fragte den Disc-Typ
|
||||||
|
gar nicht — ein globales `transcodePreset` galt für alles, live eingestellt
|
||||||
|
`HQ 1080p30 Surround`. Der laufende 4K-Rip wäre danach auf 1080p
|
||||||
|
heruntergerechnet und der Rohschnitt gelöscht worden (`keepOriginal: False`).
|
||||||
|
|
||||||
|
**Gebaut:**
|
||||||
|
- [x] **`preset_fuer(disc_type, einstellungen)`** in `ripping.py` — pure
|
||||||
|
Funktion, drei Tests. Reihenfolge: Preset des Disc-Typs → allgemeines
|
||||||
|
`transcodePreset` → `DEFAULT_HB_PRESET`. Bestandsinstallationen
|
||||||
|
ändern ihr Verhalten NICHT, solange die neuen Felder ungespeichert sind.
|
||||||
|
- [x] **`transcode_files`** holt den Disc-Typ aus dem Job-Datensatz und
|
||||||
|
schreibt ihn mit ins Log („Disc-Typ 'uhd', Preset '…'").
|
||||||
|
- [x] **UI**: drei Auswahlfelder (DVD / Blu-ray / 4K-UHD) statt einem, mit
|
||||||
|
Erklärung, warum 4K auf ein 2160p-Preset gehört. Preset-Namen aus
|
||||||
|
`HandBrakeCLI --preset-list` im Worker-Image belegt, nicht geraten.
|
||||||
|
- [x] **Sofortmaßnahme am laufenden Job**: `keepOriginal` auf `True`, damit
|
||||||
|
der 4K-Rohschnitt die Kompression überlebt.
|
||||||
|
|
||||||
|
**Offen aus dieser Runde:**
|
||||||
|
- [ ] **Deploy steht aus** — er würde den laufenden Akira-Rip abbrechen.
|
||||||
|
Erst nach Abschluss des Jobs deployen, dann bei Bedarf „Neu
|
||||||
|
komprimieren" mit dem 4K-Preset.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Ideen-Katalog (Rest) — bewusst offen
|
## Ideen-Katalog (Rest) — bewusst offen
|
||||||
|
|
||||||
1. **Design 2.0** — an Gemini übergeben (24.07.2026). Vollständiges
|
1. **Design 2.0** — an Gemini übergeben (24.07.2026). Vollständiges
|
||||||
|
|||||||
+683
-6
@@ -1,6 +1,679 @@
|
|||||||
# SAVEPOINT — Rippy
|
# SAVEPOINT — Rippy
|
||||||
|
|
||||||
## Aktueller Stand: v3.9 — Windows-Installer als echte .exe (Icon, kein Konsolenfenster) (24.07.2026)
|
## Aktueller Stand: v3.15 — Aufräum-Runde: Tempo, tote Routen, 4K-Entscheidung (25.07.2026, 20:00)
|
||||||
|
|
||||||
|
> **Deployt und live gegengeprüft.** Auftrag war „bau alles so um, dass es
|
||||||
|
> sauber ist" mit fünf Vorgaben: saubere Codebase, selbsterklärendes UI,
|
||||||
|
> externe Worker, kein Laggen, idiotensicher. Was noch offen ist, steht unten.
|
||||||
|
|
||||||
|
### Das „Laggen" hatte genau eine Ursache — gemessen und behoben
|
||||||
|
|
||||||
|
Über alle 15 Endpunkte gemessen, die das UI beim Laden braucht:
|
||||||
|
|
||||||
|
| Endpunkt | vorher | nachher |
|
||||||
|
|---|---|---|
|
||||||
|
| `/capabilities` | **1,010 s** | **0,003 s** |
|
||||||
|
| `/system/updates` | 0,491 s | unverändert (hängt am Knopf) |
|
||||||
|
| `/metadata/status` | 0,412 s | unverändert (hängt am Knopf) |
|
||||||
|
| die anderen 12 | < 0,025 s | < 0,010 s |
|
||||||
|
|
||||||
|
Nur `/capabilities` schlug beim **Seitenaufbau** zu — und fünf Stellen holen
|
||||||
|
ihn (Dashboard, Einstellungen, Worker-Tab, Wizard, Rip-Dialog). Jede Seite
|
||||||
|
zahlte eine Sekunde. Ursache ist kein Fehler, sondern das Wesen des
|
||||||
|
Celery-Pings: er sammelt Antworten bis zum Timeout und kann nicht früher
|
||||||
|
aufhören. Den Timeout zu kürzen würde Antworten langsamer Remote-Worker
|
||||||
|
verschlucken — also genau die Maschinen, um die es beim externen Encoding geht.
|
||||||
|
Jetzt pingt eine Hintergrund-Schleife im 5-s-Takt, der Endpunkt liest ab. Ist
|
||||||
|
der Vorrat älter als 30 s, wird einmal synchron gepingt: lieber langsam als
|
||||||
|
falsch („alles offline", obwohl alles läuft). Kompletter Dashboard-Aufbau:
|
||||||
|
**1,03 s → 0,028 s.**
|
||||||
|
|
||||||
|
### 4K ist entschieden — Kompression je Disc-Typ abwählbar
|
||||||
|
|
||||||
|
Die offene Frage aus v3.14 ist gebaut. Bisher gab es nur `transcodeEnabled`:
|
||||||
|
alles oder nichts. Jetzt kann das Preset eines Disc-Typs auf den Reservewert
|
||||||
|
`keine` stehen → die verlustfreie Datei bleibt stehen. Damit ist die sinnvolle
|
||||||
|
Einstellung für diese Maschine erstmals möglich: **4K verlustfrei behalten,
|
||||||
|
DVD und Blu-ray weiter schrumpfen.**
|
||||||
|
|
||||||
|
`komprimieren_fuer()` ist die neue reine Funktion; `preset_fuer()` überspringt
|
||||||
|
den Reservewert bewusst und gibt ihn NIE als Preset-Namen zurück — sonst bekäme
|
||||||
|
HandBrake `--preset keine`. Gegengeprüft: keiner der 90 echten Presets aus
|
||||||
|
`--preset-list` heißt so, und die fünf im UI angebotenen Namen existieren alle.
|
||||||
|
|
||||||
|
### Der Wizard läuft nicht mehr in die 4K-Falle
|
||||||
|
|
||||||
|
Er hatte die Zahlen längst vorliegen (`/capabilities` meldet `cpu_simd` und
|
||||||
|
`cpu_kerne`) — benutzt hat er sie nicht und H.265 als Standard vorgeschlagen.
|
||||||
|
Auf einer CPU ohne AVX2 sind das ein bis zwei Tage pro 4K-Film.
|
||||||
|
|
||||||
|
Jetzt entscheidet die **gemessene** Leistung: schwache CPU → 4K nicht
|
||||||
|
komprimieren, Blu-ray/DVD auf H.264. Stark oder Hardware-Encoder → H.265
|
||||||
|
durchgehend. Und er schreibt **alle vier** Preset-Felder statt nur des
|
||||||
|
allgemeinen; vorher fiel 4K auf ein 1080p-Preset zurück.
|
||||||
|
|
||||||
|
Dazu gehärtet: Kasten „Was Rippy gerade sieht" (Laufwerk, Worker mit
|
||||||
|
Kernen/SIMD, freier Platz) — jede Zeile mit **Handlungsanweisung** statt nur
|
||||||
|
einem Kreuz. Lädt parallel und wiederholt im 5-s-Takt, weil der Worker beim
|
||||||
|
ersten Start noch hochläuft. API-Keys sind sichtbar statt Punkte (kopierte
|
||||||
|
Keys, keine Passwörter — Tippfehler sieht man in Punkten nicht) und werden
|
||||||
|
direkt nach dem Speichern geprüft, mit der Wahl „Key korrigieren" oder
|
||||||
|
„Trotzdem fertigstellen".
|
||||||
|
|
||||||
|
**Gewarnt wird nur, wenn es belegt ist:** `schwacheEncoderCpu()` verlangt
|
||||||
|
mindestens einen Worker mit BEKANNTER SIMD-Stufe und keinen mit
|
||||||
|
Hardware-Encoder. Ein Windows-Worker meldet `unbekannt` (kein `/proc/cpuinfo`)
|
||||||
|
→ dann wird geschwiegen statt falsch gewarnt.
|
||||||
|
|
||||||
|
### Vier tote Routen raus
|
||||||
|
|
||||||
|
`POST /prescan`, `POST /jellyfin/format` (+ `nfo_generator.py` und
|
||||||
|
`image_downloader.py`, die sonst nichts nutzte), `GET /stream/jobs`,
|
||||||
|
`GET /worker-setup/windows-gui`. Jede ein Überrest eines ersetzten Entwurfs,
|
||||||
|
keine mit Aufrufer. `main.py`: 1726 → 1682 Zeilen, dazu 279 Zeilen in zwei
|
||||||
|
gelöschten Modulen. Tests halten beide Seiten fest: die vier müssen WEG
|
||||||
|
bleiben, die drei für die Worker-Installation (`/worker-setup/paket`,
|
||||||
|
`/windows`, `/windows-exe`) müssen DA sein.
|
||||||
|
|
||||||
|
### Externe Worker — vorbereitet, vom Commander zu testen
|
||||||
|
|
||||||
|
Auf **Windows gegengeprüft** (dieser PC): `cpu_kerne` 16 und CPU-Modell kommen
|
||||||
|
korrekt durch, `encoders` ist ohne installiertes HandBrake korrekt **leer**,
|
||||||
|
`cpu_simd` ehrlich `unbekannt` → keine falsche Warnung. Ein GPU-Worker braucht
|
||||||
|
ein HandBrake-Build mit `nvenc_*`/`qsv_*`; die neue Anzeige nennt die
|
||||||
|
ungefilterte HandBrake-Auskunft, damit das nachprüfbar ist.
|
||||||
|
|
||||||
|
### ARM-Vergleich (Vorbild-Projekt)
|
||||||
|
|
||||||
|
Fast alles, was ARM automatisch macht, macht Rippy schon — und meist
|
||||||
|
gründlicher: Metadaten aus drei Quellen statt nur OMDb, Episoden-Erkennung per
|
||||||
|
Laufzeitabgleich, Kompression auf eine eigene Queue routbar. Bezeichnend:
|
||||||
|
ARMs Auto-Auswurf war bei Rippy nur **behauptet** und ist erst in v3.14 echt
|
||||||
|
geworden. Zwei Lücken bleiben, beide **nicht gebaut, als Vorschlag**:
|
||||||
|
- **ISO-Sicherung** für Datenträger, die weder Film noch Audio-CD sind — daran
|
||||||
|
scheitert Rippy heute.
|
||||||
|
- **Mehrere Laufwerke gleichzeitig.** Rippy sieht sie (`/dev/sr[0-9]*`), ob
|
||||||
|
parallel gerippt wird, ist **unbewiesen** — mit einem Laufwerk nicht testbar.
|
||||||
|
|
||||||
|
### Installation: ein Befehl statt Checkliste (`install.sh`)
|
||||||
|
|
||||||
|
Commander-Rückmeldung: „Das Docker Deployment ist mir zu kompliziert." Zu
|
||||||
|
Recht — es war eine Sechs-Schritte-Checkliste, von der zwei Punkte Fachwissen
|
||||||
|
verlangten. Jetzt:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo-url> rippy && cd rippy
|
||||||
|
sudo ./install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Der schlimmste Punkt war das **Laufwerk**: MakeMKV braucht ZWEI Geräteknoten,
|
||||||
|
und die sg-Nummer ist je Host anders. Das Skript gleicht sie über die
|
||||||
|
SCSI-Adresse in `/sys` ab statt zu raten — auf der VM gegengeprüft
|
||||||
|
(`sr0 → 3:0:0:0`, `sg1 → 3:0:0:0` = dasselbe Gerät, korrekt erkannt). Dazu:
|
||||||
|
Verzeichnisse, Mount-Propagation **inklusive neustart-fester systemd-Unit**
|
||||||
|
(vorher stand in der README nur „reboot-fest persistieren", ohne zu sagen wie —
|
||||||
|
nach einem Reboot scheiterte das NAS-Einhängen aus dem UI stillschweigend),
|
||||||
|
`.env` schreiben ohne Bestehendes zu überschreiben, bauen, starten.
|
||||||
|
`./install.sh --nur-pruefen` sieht nur nach. Wiederholbar, damit auch der
|
||||||
|
Update-Weg: `git pull && sudo ./install.sh`.
|
||||||
|
|
||||||
|
**Zwei Fallgruben fielen beim Testen auf, beide meine eigenen:**
|
||||||
|
- `--nur-pruefen` verlangte root und brach ab — ein Prüf-Modus, der nichts
|
||||||
|
ändert, darf daran nicht scheitern.
|
||||||
|
- `.env.example` hatte `OPTICAL_SG=/dev/sg1` **unkommentiert** vorbelegt. Der
|
||||||
|
Installer hätte den erkannten Wert deshalb nicht eingetragen („steht schon
|
||||||
|
drin") und auf jedem fremden Host still eine kaputte Konfiguration
|
||||||
|
hinterlassen — genau das, was er verhindern soll. Beide Gerätezeilen sind
|
||||||
|
jetzt auskommentiert (Compose hat ohnehin Vorgaben), plus eine Gegenprobe:
|
||||||
|
zeigt ein wirksamer Wert auf ein Gerät, das es hier nicht gibt („`.env` von
|
||||||
|
einem anderen Rechner"), wird das benannt statt kryptisch von Docker gemeldet.
|
||||||
|
|
||||||
|
Die `.env`-Logik ist in drei Fällen auf der VM geprüft: frische Datei bekommt
|
||||||
|
den erkannten Wert, ein selbst gesetzter Wert bleibt unangetastet, zweimal
|
||||||
|
ausführen erzeugt genau eine Zeile.
|
||||||
|
|
||||||
|
### README neu aufgebaut
|
||||||
|
|
||||||
|
Vorher 293 Zeilen, in denen der Schnellstart zwischen `lsscsi`, sg-Knoten,
|
||||||
|
USB-Passthrough und `mount --make-rshared` begraben war. Jetzt: Installation in
|
||||||
|
zwei Zeilen oben, dann die Tabelle „Wenn etwas nicht geht" mit den vier Fällen,
|
||||||
|
die praktisch alles abdecken. Alles Technische darunter in aufklappbaren
|
||||||
|
Abschnitten, inklusive der Handarbeits-Variante.
|
||||||
|
|
||||||
|
**Neu und ausdrücklich gewünscht: „Rippy schneller machen"** — der VM-CPU-Typ.
|
||||||
|
Warum Virtualisierer eine generische CPU ohne AVX2 geben, was das kostet
|
||||||
|
(gemessene 28–55 h je 4K-Film), die genauen Proxmox-Schritte (herunterfahren →
|
||||||
|
Hardware → Processors → Type auf `host` → starten, bzw. `qm set <vmid> --cpu
|
||||||
|
host`), **warum ein Neustart von innen nicht genügt**, und wie man nachprüft:
|
||||||
|
Rippy zeigt die Vektorbefehle selbst an. Dazu der Nachteil (keine
|
||||||
|
Live-Migration auf andere CPUs) und die Alternative `x86-64-v3` für Cluster.
|
||||||
|
|
||||||
|
### Der MakeMKV-„Fallback" auf der VM war seit Monaten tot
|
||||||
|
|
||||||
|
Beim Aufräumen der VM-`.env` aufgefallen und nachgemessen (25.07.2026):
|
||||||
|
|
||||||
|
| Quelle | Antwort |
|
||||||
|
|---|---|
|
||||||
|
| `https://www.makemkv.com/download` (Repo-Standard) | **HTTP 200** |
|
||||||
|
| `https://www.makemkv.com/download/old` | HTTP 525 (Cloudflare) |
|
||||||
|
| web.archive.org-Schnappschuss **aus der VM-`.env`** | **HTTP 404** |
|
||||||
|
|
||||||
|
Die VM zeigte also auf eine **kaputte** Adresse. Aufgefallen ist es nie, weil
|
||||||
|
dort die `vendor/`-Tarballs liegen und der Download-Zweig gar nicht erreicht
|
||||||
|
wird. Nimm die Tarballs weg, und jeder Bau scheitert mit 404 — während die
|
||||||
|
Konfiguration gesund aussieht. Genau diese Sorte Fehler ist bei einer Übergabe
|
||||||
|
teuer.
|
||||||
|
|
||||||
|
**Bereinigt** (Sicherung liegt als `.env.sicherung-vor-aufraeumen-20260725`):
|
||||||
|
`MAKEMKV_URL_BASE` raus → es gilt der Standard, der liefert. `JWT_SECRET_KEY`
|
||||||
|
raus → Überrest der in v3.4 ausgebauten Anmeldung, wirkungslos.
|
||||||
|
|
||||||
|
**Gebaut — Rückfall dreistufig und ehrlich:** `vendor/`-Tarballs →
|
||||||
|
`MAKEMKV_URL_BASE` → `MAKEMKV_URL_FALLBACK` (neu, wird automatisch versucht).
|
||||||
|
Der Fallback ist **absichtlich leer vorbelegt**: Es gibt derzeit keine belegbare
|
||||||
|
zweite Quelle, und eine einzutragen, die nicht liefert, wäre schlimmer als
|
||||||
|
keine — siehe oben. Scheitert alles, nennt die Fehlermeldung jetzt die beiden
|
||||||
|
Wege, die funktionieren (Tarballs nach `vendor/`, oder eigene Quelle als
|
||||||
|
`MAKEMKV_URL_FALLBACK`), statt nur einen curl-Rückgabewert zu hinterlassen.
|
||||||
|
|
||||||
|
### NOCH OFFEN
|
||||||
|
|
||||||
|
1. **Externe Worker im Praxistest** (Commander).
|
||||||
|
2. **ISO-Sicherung und Mehr-Laufwerk-Betrieb** — siehe ARM-Vergleich.
|
||||||
|
3. **„Job aus der Liste entfernen" sagt nicht, wie viel daneben liegen bleibt**
|
||||||
|
(v3.14). Der Rohschnitt verwaist dabei unsichtbar.
|
||||||
|
4. **Discord-Webhook und MakeMKV-Key liegen im Klartext** in der
|
||||||
|
settings-Tabelle. Für Heimnetz erwartbar, aber der Webhook ist eine echte
|
||||||
|
Zugangsberechtigung.
|
||||||
|
5. **Der VM-CPU-Typ steht auf `qemu64`.** `host` würde AVX2 freischalten und
|
||||||
|
jeden Software-Encode 2–4× beschleunigen. Ein VM-Neustart, nicht gemacht —
|
||||||
|
**die Anleitung steht jetzt in der README** („Rippy schneller machen").
|
||||||
|
6. **`install.sh` ist nie als root durchgelaufen.** Auf dieser VM sind alle
|
||||||
|
root-Schritte No-Ops (Verzeichnisse da, Propagation schon `shared`), und
|
||||||
|
`sudo` verlangt hier ein Passwort. Bewiesen sind Laufwerkserkennung,
|
||||||
|
`.env`-Logik (drei Fälle) und Syntax; **ungetestet bleiben das Anlegen der
|
||||||
|
Verzeichnisse, `mount --make-rshared` und die systemd-Unit** — die laufen
|
||||||
|
erst bei einer echten Neuinstallation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vorheriger Stand: v3.14 — Durchsicht: vier Placebos und ein unwirksamer Schutz (25.07.2026, 18:40)
|
||||||
|
|
||||||
|
> **Stand 19:30: alles committet, Ampel grün (`a1aabd5`), DEPLOYT und live
|
||||||
|
> gegengeprüft.** Der Akira-Encode wurde um 18:31 abgebrochen (Nachtrag im
|
||||||
|
> Block darunter), danach der Job aus der Liste gelöscht und die Rohdaten
|
||||||
|
> entsorgt. Es läuft nichts, die Platte hat **111 GB frei**.
|
||||||
|
> Was noch zu entscheiden ist, steht unten unter „NOCH OFFEN".
|
||||||
|
|
||||||
|
### DER WICHTIGSTE FUND: der Platten-Schutz aus `c065967` greift nicht
|
||||||
|
|
||||||
|
`_original_aufheben()` entschied per `os.stat().st_dev`, ob umgehängt oder
|
||||||
|
kopiert werden muss. Auf der VM gemessen — **beides gleichzeitig wahr**:
|
||||||
|
|
||||||
|
```
|
||||||
|
st_dev /app/temp = 2050
|
||||||
|
st_dev /app/media = 2050 → 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 Prüfung sah deshalb „gleiches Dateisystem",
|
||||||
|
**übersprang die Platzprüfung**, und `shutil.move` kopierte doch — 75 GB bei
|
||||||
|
37 GB frei. Der Schutz hätte genau den Schaden zugelassen, gegen den er
|
||||||
|
gebaut wurde.
|
||||||
|
|
||||||
|
**Behoben:** `os.rename` wird jetzt VERSUCHT statt vorhergesagt. Klappt es,
|
||||||
|
ist es umgehängt. Kommt EXDEV, steht die Kopie fest — und erst dann wird der
|
||||||
|
Platz geprüft. Vier Tests dazu (`test_original_aufheben.py`), inklusive des
|
||||||
|
Falls, der die Platte füllte.
|
||||||
|
|
||||||
|
### GEMESSEN, Stand 18:30 (alles per Befehl auf der VM geprüft)
|
||||||
|
|
||||||
|
- **Container:** alle 5 `Up`, api/postgres/redis `healthy`. api/ui/worker seit
|
||||||
|
18:00:39 (Deploy von `8bb075c`), postgres/redis älter.
|
||||||
|
- **Platte:** 148 G, 105 G belegt, 37 G frei = 75 G Rohschnitt + 4,9 G Medien
|
||||||
|
(Evangelion) + ~25 G System. **Keine Kopier-Reste**, kein `original`-Ordner.
|
||||||
|
- **Akira-Job:** `failed`, „Abgebrochen durch Nutzer" (Abbruch 18:30:17
|
||||||
|
angefordert, Worker bestätigt 18:33:38 → **3,4 Minuten Verzug**, siehe unten).
|
||||||
|
Es läuft **kein** HandBrake mehr (per `/proc` geprüft, Stand 19:06).
|
||||||
|
- **Der Encode war bei 1,44 %**, gemessen an der Leseposition im Quellstrom
|
||||||
|
(`/proc/<pid>/fdinfo/3`: 1.145.940.149 von 79.604.951.639 Bytes) — exakter
|
||||||
|
als jede Fortschrittsanzeige. Zwei Tempo-Fenster ergaben 395 und 784 KB/s
|
||||||
|
→ **28–55 h** für den Film, bei 3,84 von 4 gesättigten Kernen.
|
||||||
|
- **Achtung bei Prozess-Suchen per `/proc`:** Ein `case "$c" in *HandBrake*)`
|
||||||
|
trifft die eigene Shell mit, weil deren Kommandozeile das Wort enthält. Zwei
|
||||||
|
Fehlalarme in dieser Sitzung. Immer die PID gegenprüfen.
|
||||||
|
- **Die Ursache dafür ist neu und behebbar:** Die VM läuft auf dem generischen
|
||||||
|
QEMU-CPU-Modell (`QEMU Virtual CPU version 2.5+`), `grep -c avx2
|
||||||
|
/proc/cpuinfo` = **0**, nur bis `sse4_2`. x265 lebt von AVX2. Abhilfe:
|
||||||
|
CPU-Typ von VM 106 in Proxmox auf `host` stellen (braucht VM-Neustart).
|
||||||
|
- **HandBrake im Worker-Image kann:** `svt_av1`, `x264`, `x265` (je 10/12-bit),
|
||||||
|
`mpeg4/2`, `VP8/9`, `theora` — und **keinen einzigen Hardware-Encoder**.
|
||||||
|
- **Ampel grün** für `b526a0a` und `8bb075c` (Gitea-API abgefragt).
|
||||||
|
|
||||||
|
### GEBAUT — vier Placebos entfernt bzw. echt gemacht
|
||||||
|
|
||||||
|
1. **Fortschritt log statt Wahrheit.** `get_progress_from_line` matchte jede
|
||||||
|
Zahl vor einem `%` — also auch HandBrakes **Scan-Durchlauf**, der VOR dem
|
||||||
|
Encodieren bis 100 % hochläuft. Dazu warf `if progress > 0` alle echten
|
||||||
|
Werte unter 1,00 % weg. Ergebnis: Anzeige klebte stundenlang auf 99 %.
|
||||||
|
Jetzt wird nur die `Encoding:`-Zeile gelesen, `task N of M` mitgerechnet,
|
||||||
|
und **-1 heißt „keine Angabe"** (Muster von `get_progress_from_prgv`).
|
||||||
|
Formatstrings aus dem Binary gelesen, nicht geraten.
|
||||||
|
**⚠️ Richtigstellung zu v3.13:** Dort steht, `progress=99` sei ein „Altwert
|
||||||
|
aus dem Absturz". Falsch — beide Startpfade setzen auf 0, der Wert war
|
||||||
|
frisch vom Scan-Durchlauf geschrieben. Es war ein Bug, kein Überrest.
|
||||||
|
2. **„Automatischer Auswurf" tat nichts.** Die Einstellung (Standard: ein)
|
||||||
|
wurde von niemandem gelesen: DVD/Blu-ray warfen **nie** aus, Audio-CDs
|
||||||
|
**immer**, weil abcde `-x` fest verdrahtet bekam. Jetzt entscheidet die
|
||||||
|
Einstellung beides (`wirf_disc_aus()` per CDROMEJECT-ioctl, Linux-guarded).
|
||||||
|
3. **„Alle Tracks rippen" konnte nichts bewirken** — abcde bekommt keine
|
||||||
|
Track-Auswahl und es gibt keine Oberfläche dafür. Schalter entfernt, an
|
||||||
|
seiner Stelle steht jetzt die Wahrheit („wird immer vollständig gerippt").
|
||||||
|
4. **Encoder-Auslese behauptete statt zu messen.** `cpu-x264`/`cpu-x265`
|
||||||
|
standen fest verdrahtet drin („immer dabei") — ein Rip-Worker **ohne**
|
||||||
|
HandBrake behauptete damit, komprimieren zu können. Und `vaapi` wurde
|
||||||
|
allein wegen `/dev/dri` gemeldet, ohne zu prüfen, ob HandBrake das kann
|
||||||
|
(dieses Image kann es nicht). Jetzt aus `HandBrakeCLI --help` geparst, plus
|
||||||
|
**CPU-Modell, Kernzahl und Vektorbefehlsstufe** je Worker — mit sichtbarer
|
||||||
|
Warnung im UI, wenn AVX2 fehlt. Genau die Angabe, deren Fehlen den
|
||||||
|
55-Stunden-Encode unsichtbar machte.
|
||||||
|
|
||||||
|
### GEBAUT — Zombie-Erkennung (v3.13 Punkt 2 abgearbeitet)
|
||||||
|
|
||||||
|
`zombies.py`: Beim Worker-Start werden Jobs, die auf `ripping`/`transcoding`/
|
||||||
|
`canceling` stehen, gegen Celerys `active`/`reserved`/`scheduled` gehalten und
|
||||||
|
ehrlich auf `failed` gesetzt, wenn niemand daran arbeitet. Drei Sicherungen,
|
||||||
|
weil ein falsch getöteter Job teurer ist als eine stehende Leiche:
|
||||||
|
|
||||||
|
- **nur beim Start** (da ist „es lief nichts" eindeutig),
|
||||||
|
- **120 s Gnadenfrist** (Celery stellt unbestätigte Aufgaben erneut zu),
|
||||||
|
- **Vollzähligkeit**: antworten weniger Knoten als laut Herzschlag online
|
||||||
|
sind, wird NICHTS gewertet — sonst wäre der laufende Job eines
|
||||||
|
beschäftigten Remote-Workers eine falsche Leiche.
|
||||||
|
|
||||||
|
13 Tests, unter anderem: „laufender Job wird nicht angetastet" und
|
||||||
|
„schweigender Worker verhindert jedes Urteil".
|
||||||
|
|
||||||
|
### GEBAUT — Pfad-Prüfung gehärtet
|
||||||
|
|
||||||
|
Elf Stellen prüften mit nacktem `startswith(MEDIA_ROOT)`. `/app/media-boese/x`
|
||||||
|
beginnt mit `/app/media`, liegt aber außerhalb — betroffen waren auch `/browse`
|
||||||
|
und `/browse/mkdir`, wo der Pfad vom Nutzer kommt. Neuer Zwillings-Helfer
|
||||||
|
`unter_wurzel()` in `api/main.py` und `worker/tasks.py`, alle elf Stellen
|
||||||
|
umgestellt, Tests in beiden.
|
||||||
|
|
||||||
|
Nebenbefund dabei: `_zielbasis()` benutzte `os.path.normpath` — unter Windows
|
||||||
|
werden daraus Backslashes und die Prüfung greift nicht mehr; das gewählte Ziel
|
||||||
|
wäre still auf den Standard zurückgefallen. Genau die Falle, die
|
||||||
|
`_arbeitsverzeichnis()` drei Zeilen weiter dokumentiert und mit `posixpath`
|
||||||
|
vermeidet. Live war es nie (nur aus `rip_disc`, das auf Windows verriegelt
|
||||||
|
ist), jetzt konsistent.
|
||||||
|
|
||||||
|
### GEFUNDEN, NICHT ANGETASTET — vier tote Endpunkte
|
||||||
|
|
||||||
|
Nirgends im UI referenziert (mechanisch gegengeprüft: alle `api.*`-Aufrufe
|
||||||
|
gegen alle Routen):
|
||||||
|
|
||||||
|
| Endpunkt | Lage |
|
||||||
|
|---|---|
|
||||||
|
| `POST /jellyfin/format` | ersetzt durch `medien.py` im Worker; zieht `nfo_generator.py` + `image_downloader.py` in der API mit, die sonst niemand nutzt |
|
||||||
|
| `POST /prescan` | ohne Aufrufer (die `PreScan`-Klasse selbst wird woanders sehr wohl gebraucht) |
|
||||||
|
| `GET /stream/jobs` | niemand konsumiert ihn; der Kommentar behauptet einen „Fix 23.07.", der ihn funktionsfähig machte — gebraucht wird er trotzdem nicht |
|
||||||
|
| `GET /worker-setup/windows-gui` | seit der `.exe` (v3.9) unreferenziert |
|
||||||
|
|
||||||
|
Bewusst nicht entfernt: `test_api_smoke.py` prüft `/prescan` als verdrahtete
|
||||||
|
Route, und Entfernen ist eine Entscheidung, keine Reparatur. **Der Commander
|
||||||
|
entscheidet.**
|
||||||
|
|
||||||
|
### NOCH OFFEN / EHRLICH UNGEKLÄRT
|
||||||
|
|
||||||
|
- **Was die Platte am 25.07. mittags füllte, ist nicht belegt.** Es gibt keine
|
||||||
|
Kopier-Reste, kein `original`-Verzeichnis und **keinen einzigen Log-Eintrag
|
||||||
|
von `_original_aufheben`** — das loggt in beiden Zweigen. Zwischen 10:21:50
|
||||||
|
und 17:49 steht überhaupt nichts im Log. Der EXDEV-Mechanismus ist jetzt
|
||||||
|
belegt (siehe oben), dass er DAMALS zuschlug, ist es nicht.
|
||||||
|
- **`tracks:/dev/sr0` meldet `{"status":"done","tracks":[]}`** — null Titel für
|
||||||
|
eine Disc, die MakeMKV mit `TCOUNT:5` öffnet. Nicht weiter verfolgt.
|
||||||
|
- **Der Discord-Webhook und der MakeMKV-Key liegen im Klartext** in der
|
||||||
|
settings-Tabelle. Für Heimnetz erwartbar, aber der Webhook ist eine echte
|
||||||
|
Zugangsberechtigung.
|
||||||
|
- **Zwei neue API-Tests laufen nur in der Ampel** — `test_api_smoke.py`
|
||||||
|
überspringt sich unter Windows selbst (`main.py` braucht `fcntl`).
|
||||||
|
|
||||||
|
### GEBAUT — „Abbrechen" wirkt jetzt sofort
|
||||||
|
|
||||||
|
Der Fund aus dem Nachtrag (Commander, am laufenden Job beobachtet) ist behoben.
|
||||||
|
Ursache war genau wie dort beschrieben: 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 Prozent je halber
|
||||||
|
Stunde sah „Abbrechen" entsprechend lange wirkungslos aus.
|
||||||
|
|
||||||
|
Jetzt gibt es in `run_handbrake` einen **eigenen Abbruch-Kanal** neben dem
|
||||||
|
Fortschritts-Callback — dasselbe Muster, das `run_makemkv` schon für `log_cb`
|
||||||
|
benutzt, und aus demselben Grund. Er wird bei JEDER Ausgabezeile aufgerufen
|
||||||
|
und ist im Worker auf 5 Sekunden gedrosselt (`ABBRUCH_INTERVALL_SEKUNDEN`).
|
||||||
|
Nebeneffekt: Er greift auch während des Scan-Durchlaufs, der überhaupt keine
|
||||||
|
Encode-Prozente liefert — dort war ein Abbruch vorher grundsätzlich unmöglich.
|
||||||
|
Die Leseschleife ist als `_handbrake_schleife()` herausgezogen, damit die
|
||||||
|
Reihenfolge (Abbruch VOR Fortschritt) ohne echtes HandBrake testbar ist.
|
||||||
|
|
||||||
|
### ERLEDIGT UM 19:30 — deployt und live gegengeprüft
|
||||||
|
|
||||||
|
`a1aabd5` läuft auf der VM. Belege, nicht Behauptungen:
|
||||||
|
|
||||||
|
- **Container:** alle 5 up, api/postgres/redis `healthy`.
|
||||||
|
- **Die neue Auslese antwortet ehrlich** (`GET /capabilities`):
|
||||||
|
`cpu-x264, cpu-x265, cpu-av1` — **kein Phantom-`vaapi`** mehr, obwohl die
|
||||||
|
alte Fassung es bei vorhandenem `/dev/dri` gemeldet hätte. Dazu
|
||||||
|
`cpu_modell: QEMU Virtual CPU version 2.5+`, `cpu_kerne: 4`,
|
||||||
|
`cpu_simd: sse4_2` → die AVX2-Warnung im UI greift.
|
||||||
|
- **Zombie-Erkennung:** 19:26:09, exakt 120 s nach Worker-Start,
|
||||||
|
`{'geprueft': 0, 'aufgeraeumt': []}` — nachgesehen, nichts in Arbeit
|
||||||
|
gefunden, korrekt nichts angetastet.
|
||||||
|
- **604 Disc-Schlüssel** haben den Rebuild überlebt.
|
||||||
|
- **Aufgeräumt:** das unbrauchbare 4K-Fragment (67.633.152 B) gelöscht; der
|
||||||
|
Akira-Job wurde um 19:07 aus der Liste entfernt, wodurch der 75-GB-Rohschnitt
|
||||||
|
verwaiste (kein Job-Datensatz zeigte mehr darauf, im UI unsichtbar,
|
||||||
|
„Neu komprimieren" unmöglich) — auf Entscheid des Commanders gelöscht, samt
|
||||||
|
vier leerer Alt-Ordner. **Platte: 37 GB → 111 GB frei.**
|
||||||
|
|
||||||
|
⚠️ **Merken für die Job-Verwaltung:** „Job aus der Liste entfernen" löscht
|
||||||
|
bewusst keine Dateien — das ist richtig, macht die Rohdaten aber unerreichbar,
|
||||||
|
weil beides nur über die Job-ID verbunden ist. Ein Rohschnitt ohne Job-Zeile
|
||||||
|
taucht nirgends mehr auf. Das UI sollte beim Löschen sagen, wie viel daneben
|
||||||
|
liegen bleibt. Nicht gebaut.
|
||||||
|
|
||||||
|
### NOCH OFFEN
|
||||||
|
|
||||||
|
**1. Die UHD-Strategie** (aus dem Nachtrag, unverändert gültig).
|
||||||
|
Preset-je-Disc-Typ war richtig, reicht aber nicht: Software-HEVC in 4K ist auf
|
||||||
|
dieser CPU keine Option. Drei Wege, keiner davon gebaut:
|
||||||
|
- **UHD gar nicht komprimieren** — Roh-MKV behalten. Ehrlichste Variante,
|
||||||
|
kostet Platz (75–100 GB je Film, gehört dann auf die NAS).
|
||||||
|
- **Hardware-Encoder** — Remote-Worker mit GPU (`nvenc`/`vaapi`). Rippy kann
|
||||||
|
das schon routen (bewiesen v3.7); es fehlt die Maschine. Achtung: Das
|
||||||
|
Worker-Image kann selbst keinen Hardware-Encoder, ein GPU-Worker braucht ein
|
||||||
|
HandBrake-Build mit `nvenc_*`/`qsv_*` — die neue Anzeige sagt das jetzt.
|
||||||
|
- **CPU-Typ der VM auf `host`** — schaltet AVX2 frei, bringt bei x265 typisch
|
||||||
|
Faktor 2–4. Aus 28–55 h werden damit aber immer noch Stunden bis Tage; das
|
||||||
|
allein löst 4K nicht, hilft aber jedem 1080p-Encode.
|
||||||
|
|
||||||
|
**2. Vier tote Endpunkte** — entfernen oder behalten (Tabelle oben). Alle vier
|
||||||
|
sind Überreste eines ersetzten Entwurfs: `/jellyfin/format` (die Arbeit macht
|
||||||
|
seit v3.2 `medien.py` im Worker), `/prescan` (Metadaten-Seite ist seit v3.4
|
||||||
|
weg; die `PreScan`-Klasse selbst wird sehr wohl gebraucht), `/stream/jobs`
|
||||||
|
(kein `EventSource` im UI — das Dashboard pollt `setInterval(…, 4000)`) und
|
||||||
|
`/worker-setup/windows-gui` (seit der `.exe` in v3.9 ohne Aufrufer; die Datei
|
||||||
|
steckt weiter in der `.exe`). **`/stream/jobs` ist der unangenehmste:** keine
|
||||||
|
harmlose Leiche, sondern eine Endlosschleife je Verbindung, die jeder erreichen
|
||||||
|
kann. Empfehlung: alle vier raus, plus `nfo_generator.py` und
|
||||||
|
`image_downloader.py` in der API (nutzt sonst nichts) — und `test_api_smoke.py`
|
||||||
|
prüft `/prescan` als verdrahtete Route, der Test muss also mit.
|
||||||
|
|
||||||
|
**3. Akira liegt jetzt gar nicht mehr vor.** Fragment und Rohschnitt sind
|
||||||
|
gelöscht (siehe oben). Wer den Film will, legt die Disc neu ein — der
|
||||||
|
Schlüsselspeicher steht (604 Schlüssel), der Rip dauert rund eine Stunde. Vor
|
||||||
|
dem nächsten UHD-Versuch aber Punkt 1 entscheiden, sonst läuft dasselbe
|
||||||
|
50-Stunden-Rennen wieder an.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vorheriger Stand: v3.13 — ÜBERGABE (25.07.2026, 18:05)
|
||||||
|
|
||||||
|
> Dieser Block ist eine Übergabe an die nächste Sitzung. Er trennt bewusst
|
||||||
|
> **gemessen** von **vermutet** — in dieser Sitzung wurden zwei Behauptungen
|
||||||
|
> aufgestellt, die sich als falsch erwiesen (siehe „Vertrauensnotiz" unten).
|
||||||
|
> Im Zweifel: selbst nachmessen, nicht diesem Dokument glauben.
|
||||||
|
|
||||||
|
### GEMESSEN, Stand 18:05 (alles per Befehl auf der VM geprüft)
|
||||||
|
|
||||||
|
- **Container:** api/postgres/redis/ui/worker alle `Up`, api + postgres +
|
||||||
|
redis `healthy`.
|
||||||
|
- **Platte:** `/dev/sda2` 148 G, 105 G belegt, **37 G frei** (75 %).
|
||||||
|
- **Läuft gerade:** `HandBrakeCLI --input
|
||||||
|
/app/temp/raw/73b89777-…/title_t00.mkv --output
|
||||||
|
"/app/media/movies/Akira (1988)/title_t00.mkv" --preset
|
||||||
|
"H.265 MKV 2160p60 4K" --all-audio --all-subtitles`
|
||||||
|
- **Job `73b89777-a808-426f-8159-d406d27d0ec8`:** `status='transcoding'`,
|
||||||
|
`progress=99` (⚠️ **Altwert aus dem Absturz, NICHT der echte Fortschritt** —
|
||||||
|
der Lauf hat um 18:02 begonnen), `disc_type='uhd'`,
|
||||||
|
`output_path='/app/media/movies/Akira (1988)'`.
|
||||||
|
- **Dateien:** `…/Akira (1988)/title_t00.mkv` ist **0 Bytes** (HandBrake hat
|
||||||
|
die vorherige 1080p-Fassung beim Start gekürzt und schreibt gerade neu).
|
||||||
|
Roh-Rip `/app/temp/raw/73b89777-…/` = **75 GB, unversehrt.**
|
||||||
|
- **Git:** `8bb075c` ist HEAD und deployt. Davor `c065967`, `e84afc1`,
|
||||||
|
`f449c4e`, `0935766`. Alle Ampeln waren grün.
|
||||||
|
- **Schlüsselspeicher:** 604 Disc-Schlüssel, `/system/keystore` antwortet.
|
||||||
|
- **NAS:** `//192.168.178.62/rippy` auf `/srv/rippy/media/rippy` gemountet,
|
||||||
|
erreichbar, **2311 GB frei**. Wird als Ablageziel gelistet.
|
||||||
|
|
||||||
|
### NACHTRAG 18:35 — der 4K-Lauf wurde abgebrochen, zwei neue Befunde
|
||||||
|
|
||||||
|
- **4K-HEVC ist auf dieser CPU nicht machbar.** Gemessen: von 18:02 bis 18:31
|
||||||
|
kam der Lauf von 0 auf **1 %** → hochgerechnet **~50 Stunden** für den Film.
|
||||||
|
Der Commander hat um 18:31 abgebrochen. **Konsequenz, die noch zu
|
||||||
|
entscheiden ist:** entweder UHD gar nicht komprimieren (Roh-MKV behalten,
|
||||||
|
`transcodeEnabled` für UHD aus), oder ein Hardware-Encoder (Remote-Worker
|
||||||
|
mit GPU, `nvenc`/`vaapi`), oder bewusst bei 1080p bleiben. Das eben
|
||||||
|
gebaute Preset-je-Disc-Typ ist damit richtig, aber allein nicht genug.
|
||||||
|
- **Abbruch wirkt verzögert (FEHLER, nicht gebaut).** Job steht seit 18:31 auf
|
||||||
|
`canceling`, **HandBrake lief um 18:35 immer noch**. Ursache: Der Abbruch
|
||||||
|
wird nur in `datei_fortschritt` geprüft, und diese Closure läuft nur, wenn
|
||||||
|
sich die PROZENTZAHL ändert (`tasks.py`, `if gesamt == letzter[0]: return`).
|
||||||
|
Bei 1 % alle 30 Minuten heißt das: bis zu eine halbe Stunde, in der
|
||||||
|
„Abbrechen" für den Nutzer wirkungslos aussieht. Der Abbruch muss unabhängig
|
||||||
|
vom Fortschritt geprüft werden (z. B. zeitgesteuert beim Lesen der
|
||||||
|
HandBrake-Ausgabe).
|
||||||
|
- **Die 1080p-Fassung ist weg.** HandBrake hat sie beim Start auf 0 Bytes
|
||||||
|
gekürzt; aktuell liegen dort ~64 MB 4K-Fragment. Der **75-GB-Rohschnitt ist
|
||||||
|
unversehrt** — es ist also nichts unwiederbringlich verloren, aber im
|
||||||
|
Akira-Ordner liegt gerade eine unbrauchbare Datei.
|
||||||
|
|
||||||
|
### WAS ALS NÄCHSTES ANSTEHT
|
||||||
|
|
||||||
|
1. **Ausgang des 4K-Laufs prüfen.** Erwartung, die noch NICHT bewiesen ist:
|
||||||
|
Der Job endet **erfolgreich**, und „Original behalten" (Setting steht auf
|
||||||
|
`true`) meldet nur eine **Warnung**, weil 75 GB nicht neben 37 GB freien
|
||||||
|
Platz passen. Genau dieser Pfad ist neu (`_original_aufheben` in
|
||||||
|
`tasks.py`) und in der Praxis noch nie gelaufen. Wenn er hält, ist der
|
||||||
|
Ausfall von heute Mittag strukturell behoben.
|
||||||
|
2. **Zombie-Erkennung fehlt (gefunden, NICHT gebaut).** Nach dem Absturz stand
|
||||||
|
der Job auf `transcoding 96 %`, obwohl weder ein Prozess lief noch etwas in
|
||||||
|
den Celery-Queues stand (beide `llen` = 0). Folge: keine Anzeige, kein
|
||||||
|
Download, und der „Neu komprimieren"-Knopf fehlt, weil
|
||||||
|
`_kann_neu_komprimieren` (main.py) `status == "failed"` verlangt. Der
|
||||||
|
Worker sollte beim Start solche Leichen erkennen und ehrlich auf `failed`
|
||||||
|
setzen.
|
||||||
|
3. **`keepOriginal` steht auf `true`** — von dieser Sitzung gesetzt, um den
|
||||||
|
4K-Rohschnitt zu retten. Bewusst entscheiden, ob das so bleibt: mit
|
||||||
|
Arbeitsverzeichnis auf der NAS-Freigabe wäre es ein reines Umhängen statt
|
||||||
|
einer Vollkopie.
|
||||||
|
4. **75 GB Rohschnitt** liegen weiter in `/app/temp/raw/73b89777-…`. Nach
|
||||||
|
erfolgreichem 4K-Lauf entscheiden, ob er weg kann.
|
||||||
|
|
||||||
|
### VERTRAUENSNOTIZ — zwei Fehlschlüsse dieser Sitzung
|
||||||
|
|
||||||
|
- **„MakeMKVs Schlüssel-Kanal ist abgeschaltet"** — falsch. Stützte sich auf
|
||||||
|
zwei tote Hostnamen aus alten Forumsbeiträgen, die MakeMKV längst nicht mehr
|
||||||
|
benutzt. Richtig: `makemkvcon` unter **Linux** fragt nie, die
|
||||||
|
**Windows**-Version schon. Korrigiert in v3.11.
|
||||||
|
- **„Celery hat die Aufgabe nach dem Deploy erneut zugestellt"** — falsch.
|
||||||
|
Aus dem Status `transcoding` geraten, ohne Prozesse oder Queues zu prüfen.
|
||||||
|
Es war ein Zombie-Eintrag (Punkt 2 oben).
|
||||||
|
|
||||||
|
Beide Male war das Muster dasselbe: **aus einem Zustandswert auf einen
|
||||||
|
Mechanismus geschlossen, statt den Mechanismus zu messen.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vorheriger Stand: v3.12 — Preset je Disc-Typ (25.07.2026)
|
||||||
|
|
||||||
|
- **Der Fund:** Beim ersten echten UHD-Rip aufgefallen — die Kompression
|
||||||
|
fragte den Disc-Typ **gar nicht**: `preset = einstellungen.get(
|
||||||
|
"transcodePreset")`, ein globales Preset für alles. Live eingestellt war
|
||||||
|
`HQ 1080p30 Surround`. Der laufende Akira-Rip wäre also verlustfrei in 4K
|
||||||
|
gerippt und danach **auf 1080p heruntergerechnet** worden — und mit
|
||||||
|
`keepOriginal: False` wäre der 4K-Rohschnitt anschließend gelöscht worden.
|
||||||
|
Aufgefallen ist es nur, weil der Commander gefragt hat, ob Rippy das
|
||||||
|
UHD-Preset automatisch nimmt.
|
||||||
|
- **Sofortmaßnahme am laufenden Job:** `keepOriginal` auf `True` gesetzt
|
||||||
|
(nur dieses eine Feld, gegengeprüft: kein anderer Schlüssel verändert).
|
||||||
|
Damit überlebt der 4K-Rohschnitt die Kompression auf jeden Fall.
|
||||||
|
- **Gebaut:** `preset_fuer(disc_type, einstellungen)` in `ripping.py` (pure,
|
||||||
|
getestet) plus drei Einstellungen `transcodePresetDvd` / `…Bluray` /
|
||||||
|
`…Uhd`. Reihenfolge: Preset des Disc-Typs → allgemeines
|
||||||
|
`transcodePreset` → `DEFAULT_HB_PRESET`. **Bestandsinstallationen ändern
|
||||||
|
ihr Verhalten nicht**, solange die neuen Felder nicht gespeichert sind.
|
||||||
|
`transcode_files` holt den Disc-Typ aus dem Job-Datensatz und schreibt ihn
|
||||||
|
mit ins Log.
|
||||||
|
- **UI (Einstellungen → Verarbeitung):** drei Auswahlfelder statt einem, mit
|
||||||
|
Klartext dazu, warum 4K auf ein 2160p-Preset gehört. Alle Preset-Namen
|
||||||
|
stammen aus `HandBrakeCLI --preset-list` im Worker-Image (1.6.1) — nicht
|
||||||
|
geraten (AGENTS Regel D).
|
||||||
|
- **⚠️ Deploy bewusst zurückgehalten:** `docker compose up -d --build`
|
||||||
|
würde den Worker-Container neu erstellen und den **laufenden Akira-Rip
|
||||||
|
abbrechen**. Erst deployen, wenn der Job durch ist. Der 4K-Rohschnitt ist
|
||||||
|
durch `keepOriginal` geschützt; danach reicht „Neu komprimieren" im UI,
|
||||||
|
um mit dem richtigen Preset in 4K zu komprimieren.
|
||||||
|
- **Nebenbefund:** `ps` gibt es im Worker-Image nicht (python-slim). Frühere
|
||||||
|
Prüfungen auf laufende Rips per `ps | grep` lieferten deshalb still
|
||||||
|
„nichts aktiv" — richtig geht es über `/proc`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vorheriger Stand: v3.11 — 4K-UHD GELÖST: Akira geht auf (25.07.2026)
|
||||||
|
|
||||||
|
- **🎉 Der Durchbruch:** Nach Übernahme des Schlüsselspeichers öffnet
|
||||||
|
`makemkvcon` auf der VM die Akira-UHD: „Operation successfully
|
||||||
|
completed", **`TCOUNT:5`**, fünf Titel — identisch zum Windows-Ergebnis.
|
||||||
|
**Erster belegter UHD-Disc-Zugriff auf der Rippy-Maschine.**
|
||||||
|
- **Die echte Ursache — und sie ist eine andere als in v3.10:**
|
||||||
|
`makemkvcon` unter **Linux** ruft Disc-Schlüssel **nie** ab. Die
|
||||||
|
**Windows**-Version tut es. Gegenprobe mit demselben Laufwerk und
|
||||||
|
derselben Disc:
|
||||||
|
|
||||||
|
| | Linux (Worker) | Windows |
|
||||||
|
|---|---|---|
|
||||||
|
| Verbindungen beim Disc-Öffnen | **keine einzige** | 185.84.108.20:443 |
|
||||||
|
| Meldung 3338 „Downloading latest HK" | nie | ja |
|
||||||
|
| `_private_data.tar` | 2048 B, **0** Schlüssel | 6,4 MB, **604** Schlüssel |
|
||||||
|
| Disc | „volume key is unknown" | **geht auf** |
|
||||||
|
|
||||||
|
Gegengeprüft mit leerem UND gefülltem Speicher, mit und ohne `--noscan`,
|
||||||
|
mit `dev:/dev/sr0` und `disc:0`, mit gelöschter `update.conf`. Immer:
|
||||||
|
keine Verbindung. Die Meldungsvorlage „Downloading latest %1 to %2 ..."
|
||||||
|
steckt sehr wohl im Linux-Binary — sie löst nur nie aus. Gleiches
|
||||||
|
Symptom im MakeMKV-Forum, seit Jahren offen (t=25782, t=34022).
|
||||||
|
- **⚠️ Richtigstellung zu v3.10 (direkt darunter):** Dort steht, MakeMKVs
|
||||||
|
Schlüssel-Kanal sei abgeschaltet. **Das war falsch.** Die Herleitung
|
||||||
|
stützte sich auf zwei Hostnamen aus alten Forumsbeiträgen
|
||||||
|
(`hkdata.fairuse.org`, `hkdata.crabdance.com`), die tatsächlich nicht
|
||||||
|
mehr auflösen — MakeMKV benutzt sie aber längst nicht mehr. **Der Dienst
|
||||||
|
lebt, der Worker erreicht ihn sogar** (Verbindungstest auf
|
||||||
|
185.84.108.20:443 erfolgreich); er wird unter Linux nur nie gefragt.
|
||||||
|
Aufgedeckt hat das der Commander mit dem Einwand, unter Windows ginge es
|
||||||
|
sofort. Alles in v3.10 Gebaute bleibt richtig und nötig — nur die
|
||||||
|
`KEYDB.cfg` ist nicht der Haupt-, sondern der Ersatzweg.
|
||||||
|
- **Gebaut — Schlüsselspeicher übernehmbar:** `GET`/`POST
|
||||||
|
/system/keystore` plus die Helfer in `makemkv_daten.py` (beide
|
||||||
|
Zwillinge). Der Rohkörper der Anfrage IST die Datei — binär, deshalb
|
||||||
|
kein JSON und kein Base64. Die Prüfung lehnt einen Speicher **ohne**
|
||||||
|
`hkd_*.bin` ab, sonst lädt jemand den leeren Vorrat einer frischen
|
||||||
|
Installation hoch und wundert sich, dass nichts passiert.
|
||||||
|
- **Gebaut — UI:** neuer Block „Disc-Schlüssel für 4K-UHD" **über** dem
|
||||||
|
KEYDB-Block, mit Schlüssel-Anzahl, Upload und der Schritt-für-Schritt-
|
||||||
|
Anleitung für den Windows-Umweg. KEYDB.cfg ist jetzt als **Notnagel**
|
||||||
|
beschriftet. Die Worker-Plakette zeigt die Anzahl bekannter Schlüssel;
|
||||||
|
`0` heißt sichtbar „4K-UHD scheitert".
|
||||||
|
- **Gebaut — ehrliche Texte:** Der UHD-Fehlertext nennt jetzt den
|
||||||
|
Windows-Weg und die Anzahl der Schlüssel dieses Workers. Alle
|
||||||
|
Falschaussagen korrigiert: UI (3 Stellen), Anleitung (2), README (3),
|
||||||
|
KONZEPT §8 + §10, Modulkopf von `makemkv_daten.py`, Worker-Dockerfile
|
||||||
|
und `makemkv_key.py` (dort stand: „Den AACS-Schlüssel zieht MakeMKV via
|
||||||
|
LibreDrive ohnehin selbst aus dem Laufwerk" — gilt für Blu-ray, NICHT
|
||||||
|
für UHD).
|
||||||
|
- **So hältst du den Vorrat aktuell:** Laufwerk an den Windows-PC, Disc in
|
||||||
|
MakeMKV öffnen, dann `_private_data.tar` aus dem MakeMKV-Datenverzeichnis
|
||||||
|
(*Preferences → General*) unter Einstellungen → System hochladen. Der
|
||||||
|
Speicher der Windows-Installation liegt bereits auf der VM unter
|
||||||
|
`/srv/rippy/makemkv/`.
|
||||||
|
- **Offen:** Der volle UHD-Rip inklusive Transcode-E2E ist noch nicht
|
||||||
|
durch — belegt ist der Disc-Zugriff, nicht die komplette Kette bis zur
|
||||||
|
fertigen Datei. Und ob sich der Abruf unter Linux doch anstoßen lässt,
|
||||||
|
ist ungeklärt; der Code dafür ist im Binary vorhanden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vorheriger Stand: v3.10 — 4K-UHD: KEYDB.cfg statt Warten auf MakeMKV (25.07.2026)
|
||||||
|
|
||||||
|
- **Der Befund (am 25.07. live auf der VM im Worker-Container
|
||||||
|
nachgemessen — kein Verdacht, alles belegt):** Eine 4K-UHD (Akira,
|
||||||
|
MKB v76, Pressung Dezember 2020) scheitert mit „The volume key is
|
||||||
|
unknown for this disc". **Laufwerk und MakeMKV sind dabei in Ordnung:**
|
||||||
|
makemkvcon meldet „Using LibreDrive mode (v06.3)" und „Using direct
|
||||||
|
disc access mode", liest die Disc und legt den AACS-Dump ab
|
||||||
|
(Meldung 3332). Der Fehler liegt also NICHT an der Hardware.
|
||||||
|
- **Die echte Ursache — MakeMKV fragt gar nicht erst:** Das Debug-Log
|
||||||
|
geht ohne einen einzigen Netz-Versuch von „Loaded content hash table"
|
||||||
|
direkt auf „The volume key is unknown". **Beweise:**
|
||||||
|
`/root/.MakeMKV/_private_data.tar` enthielt nur die Index-Datei und
|
||||||
|
KEINE einzige `hkd_*.bin` — es wurde also nie ein Schlüssel geladen.
|
||||||
|
Auch mit erzwungener frischer Prüfung (`update.conf` gelöscht,
|
||||||
|
Meldung 5074 belegt den Web-Kontakt) und mit `app_UpdateEnable = "1"`
|
||||||
|
kam keiner. Und die im MakeMKV-Forum dokumentierten Schlüssel-Server
|
||||||
|
`hkdata.fairuse.org` und `hkdata.crabdance.com` lösen weltweit nicht
|
||||||
|
mehr auf (NXDOMAIN gegen Fritz!Box, 8.8.8.8 und 1.1.1.1).
|
||||||
|
- **Richtigstellung zu v3.3 (weiter unten korrigiert):** Dort stand, die
|
||||||
|
Ursache sei „Disc neuer als MakeMKVs Schlüssel-DB" und ein
|
||||||
|
MakeMKV-Update werde das lösen. Das ist widerlegt — es gibt keine
|
||||||
|
nachladbare Schlüssel-Datenbank mehr. **Warten auf ein MakeMKV-Update
|
||||||
|
hilft bei diesem Fehler nicht.**
|
||||||
|
- **Der einzige Weg, der heute funktioniert: `KEYDB.cfg`** — GROSS
|
||||||
|
geschrieben (unter Linux case-sensitiv) im MakeMKV-Datenverzeichnis.
|
||||||
|
Rippy macht diesen Weg jetzt begehbar, statt auf MakeMKV zu warten.
|
||||||
|
- **Gebaut — persistentes Datenverzeichnis:** `docker-compose.yml` mountet
|
||||||
|
`${MAKEMKV_DATA_HOST:-/srv/rippy/makemkv}` vom Host — im Worker auf
|
||||||
|
`/root/.MakeMKV`, in der API auf `/app/makemkv-data`; beide Container
|
||||||
|
bekommen `MAKEMKV_DATA_DIR`. Damit überleben `KEYDB.cfg` und die
|
||||||
|
AACS-Dumps jeden Rebuild. `entrypoint.sh` schreibt `settings.conf`
|
||||||
|
jetzt ergänzend statt zerstörend (sonst hätte der Beta-Key den Rest
|
||||||
|
überbügelt) und setzt `app_UpdateEnable = "1"`.
|
||||||
|
- **Gebaut — eine einzige Wahrheit über das Verzeichnis:** neues
|
||||||
|
Zwillings-Modul `makemkv_daten.py` (identisch in `docker/api/` und
|
||||||
|
`docker/worker/`) mit reinen Helfern — Status lesen, Inhalt prüfen,
|
||||||
|
atomar schreiben, löschen, AACS-Dumps auflisten. Nur reine Funktionen,
|
||||||
|
damit die Ampel sie ohne Postgres/Redis testen kann.
|
||||||
|
- **Gebaut — Bedienung im UI:** Einstellungen → System zeigt den
|
||||||
|
`KEYDB.cfg`-Status (Pfad, Größe, Anzahl Disc-Einträge, Datum), nimmt die
|
||||||
|
Datei über einen ganz normalen Datei-Dialog entgegen (der Browser liest
|
||||||
|
sie und schickt den Text als JSON — serverseitig bewusst KEIN
|
||||||
|
Multipart-Upload, es gibt kein `python-multipart`, das würde die API
|
||||||
|
beim Import töten), lehnt
|
||||||
|
unplausiblen Inhalt mit deutschem Klartext ab, kann die Datei wieder
|
||||||
|
entfernen und die AACS-Dumps zum Download anbieten.
|
||||||
|
- **Gebaut — man sieht endlich, was MakeMKV sagt:** `parse_msg()` in
|
||||||
|
`ripping.py` plus Log-Callback in `tasks.py` schreiben MakeMKV-Meldungen
|
||||||
|
ins Rippy-Log (gedrosselt: Code 1003 raus, keine Wiederholungen, max. 40
|
||||||
|
je Rip). Der UHD-Fehlertext ist ehrlich neu geschrieben. `caps.py`
|
||||||
|
meldet zusätzlich `keydb: ja | nein | unbekannt` je Worker.
|
||||||
|
- **Abgrenzung, die überall durchscheint:** **Rippy liefert KEINE
|
||||||
|
Schlüssel mit, lädt keine herunter und verteilt keine.** Rippy stellt
|
||||||
|
nur den Platz für eine Datei bereit, die der Nutzer selbst mitbringt,
|
||||||
|
und zeigt ehrlich an, was dort liegt. Genau die Grenze zieht schon
|
||||||
|
`docker/api/makemkv_key.py` (Zeile 14) für den Beta-Key: das ist die
|
||||||
|
Software-LIZENZ, nicht das Entschlüsseln oder Verteilen von
|
||||||
|
Disc-Schlüsseln.
|
||||||
|
- **⚠️ NOCH NICHT end-to-end bewiesen — ehrlich gesagt:** Zum Zeitpunkt
|
||||||
|
dieser Änderung lag KEINE `KEYDB.cfg` vor, die den Akira-Schlüssel
|
||||||
|
enthält. Belegt sind der Befund oben und die neue Mechanik (Mount,
|
||||||
|
Modul, Endpunkte, UI) — **nicht** ein erfolgreicher UHD-Rip. Der
|
||||||
|
Nachweis steht aus und braucht eine echte Schlüssel-Datei.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vorheriger Stand: v3.9 — Windows-Installer als echte .exe (Icon, kein Konsolenfenster) (24.07.2026)
|
||||||
|
|
||||||
- **RippyWorkerSetup.exe** ersetzt den .vbs/.bat-Weg (Commander-Einwand:
|
- **RippyWorkerSetup.exe** ersetzt den .vbs/.bat-Weg (Commander-Einwand:
|
||||||
.vbs ist abgekündigt + wird als gefährlich geflaggt). install-gui.ps1 via
|
.vbs ist abgekündigt + wird als gefährlich geflaggt). install-gui.ps1 via
|
||||||
@@ -183,16 +856,20 @@ Konvertierung (Infrastruktur steht), AI-Box-NFS-Export, Kodi-Refresh.
|
|||||||
- **UHD-Kette diagnostiziert (Nachtrag, gleicher Tag): Der BU40N-Flash IST
|
- **UHD-Kette diagnostiziert (Nachtrag, gleicher Tag): Der BU40N-Flash IST
|
||||||
erledigt** — makemkvcon meldet „Using LibreDrive mode (v06.3)". Der
|
erledigt** — makemkvcon meldet „Using LibreDrive mode (v06.3)". Der
|
||||||
Summer-Wars-Fehlschlag lag NICHT am Laufwerk, sondern an „The volume key
|
Summer-Wars-Fehlschlag lag NICHT am Laufwerk, sondern an „The volume key
|
||||||
is unknown": die Disc (MKB v82) ist neuer als MakeMKVs Schlüssel-DB —
|
is unknown" — auf der VM mit 1.17.7 UND der aktuellsten 1.18.4
|
||||||
auf der VM mit 1.17.7 UND der aktuellsten 1.18.4 bewiesen. Konsequenzen:
|
reproduziert. Konsequenzen:
|
||||||
MakeMKV auf **1.18.4** gehoben (Scan-Hänger durch überall genutztes
|
MakeMKV auf **1.18.4** gehoben (Scan-Hänger durch überall genutztes
|
||||||
--noscan umgangen, auf der BU40N sauber gelaufen; URL-Base → /download),
|
--noscan umgangen, auf der BU40N sauber gelaufen; URL-Base → /download),
|
||||||
run_makemkv reicht kritische Meldungen (volume key/Key abgelaufen) in
|
run_makemkv reicht kritische Meldungen (volume key/Key abgelaufen) in
|
||||||
den Fehlertext durch, UHD-Klartext unterscheidet jetzt „Laufwerk kann
|
den Fehlertext durch, UHD-Klartext unterscheidet jetzt „Laufwerk kann
|
||||||
kein UHD" von „Disc neuer als Key-DB" inkl. Forum-Dump-Hinweis
|
kein UHD" von „Disc-Schlüssel fehlt" inkl. Forum-Dump-Hinweis
|
||||||
(MakeMKV speichert den AACS-Dump automatisch unter /root/.MakeMKV/).
|
(MakeMKV speichert den AACS-Dump automatisch unter /root/.MakeMKV/).
|
||||||
Summer Wars bleibt bis zu einem MakeMKV-Key-Update nicht entschlüsselbar
|
**⚠️ Korrigiert am 25.07.2026 (siehe v3.10 ganz oben):** Die hier
|
||||||
— das ist Stand der Technik, kein Rippy-Bug.
|
ursprünglich genannte Erklärung „die Disc ist neuer als MakeMKVs
|
||||||
|
Schlüssel-DB, ein MakeMKV-Update löst das" ist FALSCH. Nachgemessen:
|
||||||
|
MakeMKV hat gar keine nachladbare Schlüssel-DB mehr und versucht auch
|
||||||
|
keinen Online-Abruf; die alten Schlüssel-Server sind tot. Der Weg zum
|
||||||
|
Ziel heißt `KEYDB.cfg`, nicht „warten auf die nächste Version".
|
||||||
- **Vollautomatik** (Einstellungen → Ripping): Disc erkannt → Rip startet
|
- **Vollautomatik** (Einstellungen → Ripping): Disc erkannt → Rip startet
|
||||||
ohne Popup in den passenden Schnellwahl-Ordner (Serie/Film/Musik).
|
ohne Popup in den passenden Schnellwahl-Ordner (Serie/Film/Musik).
|
||||||
- **Job-Verwaltung**: „Neu komprimieren" nur noch, wenn Rohdaten wirklich
|
- **Job-Verwaltung**: „Neu komprimieren" nur noch, wenn Rohdaten wirklich
|
||||||
|
|||||||
@@ -1,27 +1,48 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Deploy für Rippy — deployt den aktuellen 'main'-Stand auf die VM.
|
# Deploy fuer Rippy — deployt den aktuellen 'main'-Stand auf die Ziel-VM.
|
||||||
# Single Source of Truth ist seit 24.07.2026 'main' (kein stable-Branch mehr);
|
# Single Source of Truth ist 'main' (kein stable-Branch); die Ampel prueft nur,
|
||||||
# die Ampel prüft nur noch, befördert aber nichts. Vor dem Deploy also selbst
|
# befoerdert nichts. Vor dem Deploy selbst sicherstellen, dass die Ampel fuer den
|
||||||
# sicherstellen, dass die Ampel für den zu deployenden Commit GRÜN ist.
|
# zu deployenden Commit GRUEN ist.
|
||||||
#
|
#
|
||||||
# Nutzt einen EIGENEN Klon (~/notfall-rippy) und fasst Arcanes
|
# Generisch/portabel: Ziel-Host, Repo-URL und .env-Pfad kommen aus der Umgebung —
|
||||||
# Projektverzeichnis (~/projects/rippy) NIE an — 22.07. gelernt: ein git-Klon
|
# KEINE festen Adressen im Repo. Fuer eine Erst-Installation auf einem eigenen Host
|
||||||
# im Arcane-Verzeichnis kollidiert mit dessen Sync (read-only .git-Objekte).
|
# reicht ohnehin der einfache Weg (siehe README):
|
||||||
# Die .env gehört Arcane und wird nur GELESEN (für compose-Interpolation).
|
# git clone <repo> && cd rippy && cp .env.example .env (Werte eintragen)
|
||||||
|
# docker compose up -d --build
|
||||||
|
#
|
||||||
|
# Dieses Skript ist nur die bequeme Remote-Variante: es SSHt zur VM, nutzt dort
|
||||||
|
# einen EIGENEN Klon (Default ~/notfall-rippy) und fasst ein evtl. vorhandenes
|
||||||
|
# Projektverzeichnis NIE an (git-Klon dort kollidiert mit dessen Sync). Die .env
|
||||||
|
# gehoert der VM und wird nur GELESEN (fuer compose-Interpolation).
|
||||||
|
#
|
||||||
|
# Konfiguration ueber Umgebungsvariablen:
|
||||||
|
# RIPPY_VM Ziel-Host fuer ssh, z.B. user@10.0.0.5 (PFLICHT)
|
||||||
|
# RIPPY_REPO_URL Git-Repo-URL (branch main) (PFLICHT)
|
||||||
|
# RIPPY_ENV_SRC Pfad zur .env auf der VM (Default: ~/rippy/.env)
|
||||||
|
# RIPPY_CLONE Arbeits-Klon auf der VM (Default: ~/notfall-rippy)
|
||||||
|
# Optionales Argument $1: nur einen Dienst neu bauen, z.B. ./deploy.sh api
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VM="arcane@192.168.178.162"
|
VM="${RIPPY_VM:?RIPPY_VM setzen, z.B. RIPPY_VM=user@10.0.0.5}"
|
||||||
DIENST="${1:-}" # optional: nur einen Dienst neu bauen, z.B. ./deploy.sh api
|
REPO_URL="${RIPPY_REPO_URL:?RIPPY_REPO_URL setzen (Git-URL des Rippy-Repos)}"
|
||||||
|
ENV_SRC="${RIPPY_ENV_SRC:-}" # leer -> Remote-Default ~/rippy/.env
|
||||||
|
CLONE="${RIPPY_CLONE:-}" # leer -> Remote-Default ~/notfall-rippy
|
||||||
|
DIENST="${1:-}"
|
||||||
|
|
||||||
ssh "$VM" 'bash -s' <<REMOTE
|
ssh "$VM" bash -s -- "$REPO_URL" "$ENV_SRC" "$CLONE" "$DIENST" <<'REMOTE'
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if [ ! -d ~/notfall-rippy/.git ]; then
|
REPO_URL="$1"
|
||||||
git clone --branch main https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/rippy.git ~/notfall-rippy
|
ENV_SRC="${2:-$HOME/rippy/.env}"
|
||||||
|
CLONE="${3:-$HOME/notfall-rippy}"
|
||||||
|
DIENST="$4"
|
||||||
|
|
||||||
|
if [ ! -d "$CLONE/.git" ]; then
|
||||||
|
git clone --branch main "$REPO_URL" "$CLONE"
|
||||||
fi
|
fi
|
||||||
cd ~/notfall-rippy
|
cd "$CLONE"
|
||||||
git fetch origin main
|
git fetch origin main
|
||||||
git reset --hard origin/main
|
git reset --hard origin/main
|
||||||
cp ~/projects/rippy/.env .env 2>/dev/null || echo "WARNUNG: keine .env in Arcanes Projektverzeichnis"
|
cp "$ENV_SRC" .env 2>/dev/null || echo "WARNUNG: keine .env unter $ENV_SRC gefunden"
|
||||||
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
|
||||||
|
|||||||
Binary file not shown.
@@ -1,59 +1,68 @@
|
|||||||
# Baut rippy.ico + RippyWorkerSetup.exe aus install-gui.ps1 (ps2exe).
|
# Baut rippy.ico + RippyWorkerSetup.exe aus install-gui.ps1 (ps2exe).
|
||||||
#
|
#
|
||||||
# WANN NEU BAUEN: nur wenn sich install-gui.ps1 (die Installer-Oberflaeche)
|
# WANN NEU BAUEN: nur wenn sich install-gui.ps1 (die Installer-Oberfläche)
|
||||||
# aendert. NICHT bei MakeMKV-/HandBrake-Updates — die .exe holt HandBrake zur
|
# ändert. NICHT bei MakeMKV-/HandBrake-Updates - die .exe holt HandBrake zur
|
||||||
# Laufzeit (GitHub latest) und den Worker-Code live von Rippy, friert also
|
# Laufzeit (GitHub latest) und den Worker-Code live von Rippy, friert also
|
||||||
# keine Versionen ein.
|
# keine Versionen ein.
|
||||||
# AUSFUEHREN AUF WINDOWS (eine Windows-.exe kann nicht von Linux gebaut werden):
|
# AUSFUEHREN AUF WINDOWS (eine Windows-.exe kann nicht von Linux gebaut werden):
|
||||||
# powershell -ExecutionPolicy Bypass -File build-exe.ps1
|
# powershell -ExecutionPolicy Bypass -File build-exe.ps1
|
||||||
# Danach RippyWorkerSetup.exe committen (die API serviert sie).
|
# Danach RippyWorkerSetup.exe committen (die API serviert sie).
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
Add-Type -AssemblyName System.Drawing
|
Add-Type -AssemblyName System.Drawing
|
||||||
Set-Location $PSScriptRoot
|
Set-Location $PSScriptRoot
|
||||||
|
|
||||||
$guiSrc = Join-Path $PSScriptRoot "install-gui.ps1"
|
$guiSrc = Join-Path $PSScriptRoot "install-gui.ps1"
|
||||||
|
|
||||||
# 1. Rippy-Disc-Icon 256x256 zeichnen -> PNG -> ICO-Container
|
# 1. Rippy-Disc-Icon 256x256 zeichnen -> PNG -> ICO-Container
|
||||||
$bmp = New-Object System.Drawing.Bitmap 256, 256
|
$bmp = New-Object System.Drawing.Bitmap 256, 256
|
||||||
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
||||||
$g.SmoothingMode = "AntiAlias"
|
$g.SmoothingMode = "AntiAlias"
|
||||||
$g.Clear([System.Drawing.Color]::Transparent)
|
$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(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((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)
|
$g.FillEllipse([System.Drawing.Brushes]::White, 104, 104, 48, 48)
|
||||||
$ms = New-Object System.IO.MemoryStream
|
$ms = New-Object System.IO.MemoryStream
|
||||||
$bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
|
$bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||||
$png = $ms.ToArray()
|
$png = $ms.ToArray()
|
||||||
|
|
||||||
$icoStream = New-Object System.IO.MemoryStream
|
$icoStream = New-Object System.IO.MemoryStream
|
||||||
$bw = New-Object System.IO.BinaryWriter $icoStream
|
$bw = New-Object System.IO.BinaryWriter $icoStream
|
||||||
$bw.Write([UInt16]0); $bw.Write([UInt16]1); $bw.Write([UInt16]1) # ICONDIR
|
$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([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([UInt16]1); $bw.Write([UInt16]32) # planes, bpp
|
||||||
$bw.Write([UInt32]$png.Length); $bw.Write([UInt32]22) # size, offset
|
$bw.Write([UInt32]$png.Length); $bw.Write([UInt32]22) # size, offset
|
||||||
$bw.Write($png); $bw.Flush()
|
$bw.Write($png); $bw.Flush()
|
||||||
[System.IO.File]::WriteAllBytes("$PSScriptRoot\rippy.ico", $icoStream.ToArray())
|
[System.IO.File]::WriteAllBytes("$PSScriptRoot\rippy.ico", $icoStream.ToArray())
|
||||||
Write-Host "rippy.ico erzeugt ($($png.Length) Bytes PNG)"
|
Write-Host "rippy.ico erzeugt ($($png.Length) Bytes PNG)"
|
||||||
|
|
||||||
# 2. ps2exe sicherstellen — TLS 1.2 ist der Fix fuer den NuGet-Bootstrap-Fehler
|
# 2. ps2exe sicherstellen - TLS 1.2 ist der Fix für den NuGet-Bootstrap-Fehler
|
||||||
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor 3072
|
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor 3072
|
||||||
if (-not (Get-Module -ListAvailable ps2exe)) {
|
if (-not (Get-Module -ListAvailable ps2exe)) {
|
||||||
Write-Host "Bootstrappe NuGet + PSGallery ..."
|
Write-Host "Bootstrappe NuGet + PSGallery ..."
|
||||||
try { Get-PackageProvider -Name NuGet -ErrorAction Stop | Out-Null }
|
try { Get-PackageProvider -Name NuGet -ErrorAction Stop | Out-Null }
|
||||||
catch { Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope CurrentUser | 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
|
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted -ErrorAction SilentlyContinue
|
||||||
Write-Host "Installiere ps2exe ..."
|
Write-Host "Installiere ps2exe ..."
|
||||||
Install-Module ps2exe -Scope CurrentUser -Force -AllowClobber
|
Install-Module ps2exe -Scope CurrentUser -Force -AllowClobber
|
||||||
}
|
}
|
||||||
Import-Module ps2exe
|
Import-Module ps2exe
|
||||||
|
|
||||||
# 3. Kompilieren (WinForms -> noConsole + STA, Icon eingebettet)
|
# 3. Kompilieren (WinForms -> noConsole + STA, Icon eingebettet)
|
||||||
Invoke-ps2exe -inputFile $guiSrc -outputFile "$PSScriptRoot\RippyWorkerSetup.exe" `
|
#
|
||||||
-iconFile "$PSScriptRoot\rippy.ico" -noConsole -STA `
|
# -requireAdmin (seit 25.07.2026): Der Worker wird standardmäßig nach
|
||||||
-title "Rippy Worker Setup" -product "Rippy" -company "Rippy" -version "1.0.0.0"
|
# "C:\Program Files\Rippy Worker" installiert, und dorthin darf nur ein
|
||||||
|
# Administrator schreiben. Mit dem Schalter tragt die .exe ein Manifest, das
|
||||||
if (Test-Path "$PSScriptRoot\RippyWorkerSetup.exe") {
|
# Windows beim Doppelklick EINMAL per UAC fragen lässt - danach stimmen die
|
||||||
Write-Host "OK: RippyWorkerSetup.exe = $((Get-Item "$PSScriptRoot\RippyWorkerSetup.exe").Length) Bytes"
|
# Rechte für alles Weitere. Ohne ihn liefe der Installer unerhöht und
|
||||||
} else {
|
# scheiterte beim Anlegen des Ordners; wer das Ziel ins eigene Profil legt,
|
||||||
Write-Host "FEHLER: keine .exe erzeugt"
|
# braucht die Erhöhung nicht, wird aber trotzdem gefragt (normal für einen
|
||||||
}
|
# Installer und besser als ein Fehlschlag mitten im Lauf).
|
||||||
|
Invoke-ps2exe -inputFile $guiSrc -outputFile "$PSScriptRoot\RippyWorkerSetup.exe" `
|
||||||
|
-iconFile "$PSScriptRoot\rippy.ico" -noConsole -STA -requireAdmin `
|
||||||
|
-title "Rippy Worker Setup" -product "Rippy" -company "Rippy" -version "1.0.1.0"
|
||||||
|
|
||||||
|
if (Test-Path "$PSScriptRoot\RippyWorkerSetup.exe") {
|
||||||
|
Write-Host "OK: RippyWorkerSetup.exe = $((Get-Item "$PSScriptRoot\RippyWorkerSetup.exe").Length) Bytes"
|
||||||
|
} else {
|
||||||
|
Write-Host "FEHLER: keine .exe erzeugt"
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,237 +1,426 @@
|
|||||||
# Rippy Windows-Worker - GRAFISCHER Installer (WinForms, keine externe Runtime).
|
# Rippy Windows-Worker - GRAFISCHER Installer (WinForms, keine externe Runtime).
|
||||||
# Wird ueber rippy-worker-setup.bat gestartet (Doppelklick). Braucht nur
|
# Wird über rippy-worker-setup.bat gestartet (Doppelklick). Braucht nur
|
||||||
# Python 3.10+ auf der Maschine - kein Docker, kein git.
|
# Python 3.10+ auf der Maschine - kein Docker, kein git.
|
||||||
#
|
#
|
||||||
# ASCII-only (PowerShell 5.1 liest .ps1 als ANSI - Sonderzeichen zerschiessen
|
# WICHTIG - KODIERUNG: Diese Datei MUSS als UTF-8 MIT BOM gespeichert werden.
|
||||||
# das Skript).
|
# PowerShell 5.1 liest .ps1 ohne BOM als ANSI; Umlaute werden dann zu Kauderwelsch
|
||||||
|
# UND das Skript wirft einen Parser-Fehler, weil in "ö" ein Anführungszeichen
|
||||||
param(
|
# steckt. Mit BOM liest 5.1 korrekt UTF-8 - am 25.07.2026 beidseitig gemessen:
|
||||||
[string]$RippyHost = "",
|
# ohne BOM: "Größe" + Unerwartetes Token
|
||||||
[string]$WorkerName = $env:COMPUTERNAME.ToLower()
|
# mit BOM: "Größe" + laeuft
|
||||||
)
|
# Nach jeder Änderung pruefen: powershell.exe -Command "Parser::ParseFile(...)"
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
param(
|
||||||
Add-Type -AssemblyName System.Windows.Forms
|
[string]$RippyHost = "",
|
||||||
Add-Type -AssemblyName System.Drawing
|
[string]$WorkerName = $env:COMPUTERNAME.ToLower()
|
||||||
|
)
|
||||||
$InstallDir = Join-Path $env:USERPROFILE "rippy-worker"
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
# --- Fenster ---------------------------------------------------------------
|
Add-Type -AssemblyName System.Windows.Forms
|
||||||
$form = New-Object System.Windows.Forms.Form
|
Add-Type -AssemblyName System.Drawing
|
||||||
$form.Text = "Rippy Encoding-Worker - Installation"
|
|
||||||
$form.Size = New-Object System.Drawing.Size(560, 520)
|
# Standard-Zielverzeichnis: wie jedes andere Programm unter "Programme"
|
||||||
$form.StartPosition = "CenterScreen"
|
# (Commander-Wunsch 25.07.2026 - vorher lag der Worker im Benutzerprofil, was
|
||||||
$form.FormBorderStyle = "FixedDialog"
|
# für ein Programm unüblich ist). GetFolderPath liefert den ECHTEN Pfad;
|
||||||
$form.MaximizeBox = $false
|
# deutsche Windows-Versionen zeigen im Explorer "Programme" an, der Pfad heißt
|
||||||
$form.BackColor = [System.Drawing.Color]::FromArgb(248, 250, 252)
|
# trotzdem "C:\Program Files".
|
||||||
$form.Font = New-Object System.Drawing.Font("Segoe UI", 9)
|
#
|
||||||
|
# Schreiben dorthin braucht Administratorrechte - deshalb ist die .exe mit
|
||||||
# Fenster-Icon: Rippy-Disc zur Laufzeit zeichnen (Indigo-Kreis + weisses Loch)
|
# -requireAdmin gebaut (build-exe.ps1) und fragt beim Start einmal per UAC.
|
||||||
# - kein externes .ico noetig. Erscheint in Titelleiste und Taskleiste.
|
# Ohne das wäre hier der nächste "Zugriff verweigert" programmiert.
|
||||||
try {
|
$StandardZiel = Join-Path ([Environment]::GetFolderPath("ProgramFiles")) "Rippy Worker"
|
||||||
$ibmp = New-Object System.Drawing.Bitmap 32, 32
|
$InstallDir = $StandardZiel
|
||||||
$ig = [System.Drawing.Graphics]::FromImage($ibmp)
|
|
||||||
$ig.SmoothingMode = "AntiAlias"
|
# --- Farben: dieselben Töne wie das Rippy-Web-UI ---------------------------
|
||||||
$ig.FillEllipse((New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(79, 70, 229))), 1, 1, 30, 30)
|
# Übernommen aus docker/ui/src/lib/design.ts bzw. den Tailwind-Klassen des UI,
|
||||||
$ig.FillEllipse([System.Drawing.Brushes]::White, 12, 12, 8, 8)
|
# damit der Installer nicht wie ein Fremdkörper wirkt.
|
||||||
$form.Icon = [System.Drawing.Icon]::FromHandle($ibmp.GetHicon())
|
$cBg = [System.Drawing.Color]::FromArgb(15, 23, 42) # slate-900, Fensterfläche
|
||||||
} catch {}
|
$cPanel = [System.Drawing.Color]::FromArgb(2, 6, 23) # slate-950, Log + Eingaben
|
||||||
|
$cFeld = [System.Drawing.Color]::FromArgb(30, 41, 59) # slate-800, Eingabefelder
|
||||||
$titel = New-Object System.Windows.Forms.Label
|
$cText = [System.Drawing.Color]::FromArgb(226, 232, 240) # slate-200
|
||||||
$titel.Text = "Rippy Encoding-Worker einrichten"
|
$cGedaempft= [System.Drawing.Color]::FromArgb(148, 163, 184) # slate-400, Beschriftungen
|
||||||
$titel.Font = New-Object System.Drawing.Font("Segoe UI", 14, [System.Drawing.FontStyle]::Bold)
|
$cIndigo = [System.Drawing.Color]::FromArgb(99, 102, 241)
|
||||||
$titel.ForeColor = [System.Drawing.Color]::FromArgb(79, 70, 229)
|
$cAmber = [System.Drawing.Color]::FromArgb(245, 158, 11)
|
||||||
$titel.Location = New-Object System.Drawing.Point(24, 20)
|
$cEmerald = [System.Drawing.Color]::FromArgb(16, 185, 129)
|
||||||
$titel.Size = New-Object System.Drawing.Size(500, 30)
|
$cPurple = [System.Drawing.Color]::FromArgb(126, 34, 206)
|
||||||
$form.Controls.Add($titel)
|
|
||||||
|
# --- Fenster ---------------------------------------------------------------
|
||||||
$info = New-Object System.Windows.Forms.Label
|
$form = New-Object System.Windows.Forms.Form
|
||||||
$info.Text = "Diese Maschine uebernimmt die Video-Kompression fuer Rippy. Gerippt wird weiter auf der Rippy-Hauptmaschine."
|
$form.Text = "Rippy Encoding-Worker - Installation"
|
||||||
$info.Location = New-Object System.Drawing.Point(24, 52)
|
$form.ClientSize = New-Object System.Drawing.Size(560, 560)
|
||||||
$info.Size = New-Object System.Drawing.Size(500, 34)
|
$form.StartPosition = "CenterScreen"
|
||||||
$info.ForeColor = [System.Drawing.Color]::FromArgb(100, 116, 139)
|
$form.FormBorderStyle = "FixedDialog"
|
||||||
$form.Controls.Add($info)
|
$form.MaximizeBox = $false
|
||||||
|
$form.BackColor = $cBg
|
||||||
# Rippy-Adresse
|
$form.ForeColor = $cText
|
||||||
$lblHost = New-Object System.Windows.Forms.Label
|
$form.Font = New-Object System.Drawing.Font("Segoe UI", 9)
|
||||||
$lblHost.Text = "LAN-IP der Rippy-Maschine (Docker-Host) - NICHT dieser PC:"
|
|
||||||
$lblHost.Location = New-Object System.Drawing.Point(24, 96)
|
# Fenster-Icon: Rippy-Disc zur Laufzeit zeichnen (Indigo-Kreis + weißes Loch)
|
||||||
$lblHost.Size = New-Object System.Drawing.Size(500, 18)
|
# - kein externes .ico nötig. Erscheint in Titelleiste und Taskleiste.
|
||||||
$form.Controls.Add($lblHost)
|
try {
|
||||||
|
$ibmp = New-Object System.Drawing.Bitmap 32, 32
|
||||||
$txtHost = New-Object System.Windows.Forms.TextBox
|
$ig = [System.Drawing.Graphics]::FromImage($ibmp)
|
||||||
$txtHost.Location = New-Object System.Drawing.Point(24, 116)
|
$ig.SmoothingMode = "AntiAlias"
|
||||||
$txtHost.Size = New-Object System.Drawing.Size(300, 26)
|
$ig.FillEllipse((New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(79, 70, 229))), 1, 1, 30, 30)
|
||||||
$txtHost.Text = $RippyHost
|
$ig.FillEllipse([System.Drawing.Brushes]::White, 12, 12, 8, 8)
|
||||||
$txtHost.Font = New-Object System.Drawing.Font("Consolas", 10)
|
$form.Icon = [System.Drawing.Icon]::FromHandle($ibmp.GetHicon())
|
||||||
$form.Controls.Add($txtHost)
|
} catch {}
|
||||||
|
|
||||||
# Worker-Name
|
# --- Kopfbereich mit Farbverlauf --------------------------------------------
|
||||||
$lblName = New-Object System.Windows.Forms.Label
|
# Das Rippy-UI hat oben einen Verlauf amber -> indigo -> purple (siehe
|
||||||
$lblName.Text = "Name dieses Workers (frei waehlbar):"
|
# FirstRunWizard.tsx). WinForms kann das nicht von sich aus, also wird der
|
||||||
$lblName.Location = New-Object System.Drawing.Point(24, 152)
|
# Verlauf im Paint-Ereignis gezeichnet - inklusive Disc-Symbol.
|
||||||
$lblName.Size = New-Object System.Drawing.Size(500, 18)
|
$kopf = New-Object System.Windows.Forms.Panel
|
||||||
$form.Controls.Add($lblName)
|
$kopf.Dock = "Top"
|
||||||
|
$kopf.Height = 104
|
||||||
$txtName = New-Object System.Windows.Forms.TextBox
|
$form.Controls.Add($kopf)
|
||||||
$txtName.Location = New-Object System.Drawing.Point(24, 172)
|
$kopf.Add_Paint({
|
||||||
$txtName.Size = New-Object System.Drawing.Size(300, 26)
|
param($absender, $e)
|
||||||
$txtName.Text = $WorkerName
|
$flaeche = New-Object System.Drawing.Rectangle 0, 0, $absender.Width, $absender.Height
|
||||||
$form.Controls.Add($txtName)
|
$pinsel = New-Object System.Drawing.Drawing2D.LinearGradientBrush(
|
||||||
|
$flaeche, $cAmber, $cPurple, [System.Drawing.Drawing2D.LinearGradientMode]::Horizontal)
|
||||||
# Autostart
|
# Dreifarbig wie im UI: der Indigo-Ton in der Mitte
|
||||||
$chkAuto = New-Object System.Windows.Forms.CheckBox
|
$mischung = New-Object System.Drawing.Drawing2D.ColorBlend 3
|
||||||
$chkAuto.Text = "Beim Anmelden automatisch starten (Tray-Symbol)"
|
$mischung.Colors = @($cAmber, $cIndigo, $cPurple)
|
||||||
$chkAuto.Location = New-Object System.Drawing.Point(24, 206)
|
$mischung.Positions = @(0.0, 0.45, 1.0)
|
||||||
$chkAuto.Size = New-Object System.Drawing.Size(400, 22)
|
$pinsel.InterpolationColors = $mischung
|
||||||
$chkAuto.Checked = $true
|
$e.Graphics.FillRectangle($pinsel, $flaeche)
|
||||||
$form.Controls.Add($chkAuto)
|
$pinsel.Dispose()
|
||||||
|
|
||||||
# Log-Bereich
|
# Disc-Symbol: Ring aussen, weisses Loch in der Mitte
|
||||||
$log = New-Object System.Windows.Forms.TextBox
|
$e.Graphics.SmoothingMode = "AntiAlias"
|
||||||
$log.Location = New-Object System.Drawing.Point(24, 240)
|
$weiss = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(235, 255, 255, 255))
|
||||||
$log.Size = New-Object System.Drawing.Size(500, 170)
|
$e.Graphics.FillEllipse($weiss, 24, 28, 48, 48)
|
||||||
$log.Multiline = $true
|
$e.Graphics.FillEllipse((New-Object System.Drawing.SolidBrush $cIndigo), 36, 40, 24, 24)
|
||||||
$log.ReadOnly = $true
|
$e.Graphics.FillEllipse($weiss, 45, 49, 6, 6)
|
||||||
$log.ScrollBars = "Vertical"
|
$weiss.Dispose()
|
||||||
$log.BackColor = [System.Drawing.Color]::FromArgb(15, 23, 42)
|
|
||||||
$log.ForeColor = [System.Drawing.Color]::FromArgb(226, 232, 240)
|
# Texte direkt zeichnen - so bleibt der Verlauf ohne Kästchen sichtbar
|
||||||
$log.Font = New-Object System.Drawing.Font("Consolas", 8.5)
|
$fTitel = New-Object System.Drawing.Font("Segoe UI", 15, [System.Drawing.FontStyle]::Bold)
|
||||||
$form.Controls.Add($log)
|
$fUnter = New-Object System.Drawing.Font("Segoe UI", 9)
|
||||||
|
$e.Graphics.DrawString("Rippy Encoding-Worker", $fTitel,
|
||||||
# Knoepfe
|
[System.Drawing.Brushes]::White, 88, 26)
|
||||||
$btnInstall = New-Object System.Windows.Forms.Button
|
$e.Graphics.DrawString(
|
||||||
$btnInstall.Text = "Installieren"
|
"Diese Maschine übernimmt die Video-Kompression für Rippy." + [char]10 +
|
||||||
$btnInstall.Location = New-Object System.Drawing.Point(24, 424)
|
"Gerippt wird weiter auf der Rippy-Hauptmaschine.", $fUnter,
|
||||||
$btnInstall.Size = New-Object System.Drawing.Size(150, 40)
|
(New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(225, 255, 255, 255))), 90, 56)
|
||||||
$btnInstall.BackColor = [System.Drawing.Color]::FromArgb(79, 70, 229)
|
$fTitel.Dispose(); $fUnter.Dispose()
|
||||||
$btnInstall.ForeColor = [System.Drawing.Color]::White
|
})
|
||||||
$btnInstall.FlatStyle = "Flat"
|
|
||||||
$btnInstall.Font = New-Object System.Drawing.Font("Segoe UI", 10, [System.Drawing.FontStyle]::Bold)
|
# Rippy-Adresse
|
||||||
$form.Controls.Add($btnInstall)
|
$lblHost = New-Object System.Windows.Forms.Label
|
||||||
|
$lblHost.Text = "LAN-IP der Rippy-Maschine (Docker-Host) - NICHT dieser PC:"
|
||||||
$btnStart = New-Object System.Windows.Forms.Button
|
$lblHost.Location = New-Object System.Drawing.Point(24, 120)
|
||||||
$btnStart.Text = "Worker starten"
|
$lblHost.Size = New-Object System.Drawing.Size(500, 18)
|
||||||
$btnStart.Location = New-Object System.Drawing.Point(190, 424)
|
$lblHost.ForeColor = $cGedaempft
|
||||||
$btnStart.Size = New-Object System.Drawing.Size(150, 40)
|
$form.Controls.Add($lblHost)
|
||||||
$btnStart.BackColor = [System.Drawing.Color]::FromArgb(16, 185, 129)
|
|
||||||
$btnStart.ForeColor = [System.Drawing.Color]::White
|
$txtHost = New-Object System.Windows.Forms.TextBox
|
||||||
$btnStart.FlatStyle = "Flat"
|
$txtHost.Location = New-Object System.Drawing.Point(24, 140)
|
||||||
$btnStart.Font = New-Object System.Drawing.Font("Segoe UI", 10, [System.Drawing.FontStyle]::Bold)
|
$txtHost.Size = New-Object System.Drawing.Size(300, 26)
|
||||||
$btnStart.Visible = $false
|
$txtHost.Text = $RippyHost
|
||||||
$form.Controls.Add($btnStart)
|
$txtHost.Font = New-Object System.Drawing.Font("Consolas", 10)
|
||||||
|
$txtHost.BackColor = $cFeld
|
||||||
function Log($text) {
|
$txtHost.ForeColor = $cText
|
||||||
$log.AppendText($text + "`r`n")
|
$txtHost.BorderStyle = "FixedSingle"
|
||||||
[System.Windows.Forms.Application]::DoEvents()
|
$form.Controls.Add($txtHost)
|
||||||
}
|
|
||||||
|
# Worker-Name
|
||||||
# --- Installations-Logik ---------------------------------------------------
|
$lblName = New-Object System.Windows.Forms.Label
|
||||||
function Do-Install {
|
$lblName.Text = "Name dieses Workers (frei wählbar):"
|
||||||
$rHost = $txtHost.Text.Trim()
|
$lblName.Location = New-Object System.Drawing.Point(24, 176)
|
||||||
$wName = $txtName.Text.Trim()
|
$lblName.Size = New-Object System.Drawing.Size(500, 18)
|
||||||
if (-not $rHost) { [System.Windows.Forms.MessageBox]::Show("Bitte die LAN-IP der Rippy-Maschine eintragen.", "Fehlt"); return }
|
$lblName.ForeColor = $cGedaempft
|
||||||
if (-not $wName) { $wName = $env:COMPUTERNAME.ToLower() }
|
$form.Controls.Add($lblName)
|
||||||
|
|
||||||
$btnInstall.Enabled = $false
|
$txtName = New-Object System.Windows.Forms.TextBox
|
||||||
$log.Clear()
|
$txtName.Location = New-Object System.Drawing.Point(24, 196)
|
||||||
|
$txtName.Size = New-Object System.Drawing.Size(300, 26)
|
||||||
try {
|
$txtName.Text = $WorkerName
|
||||||
# 1. Python finden
|
$txtName.BackColor = $cFeld
|
||||||
$python = $null
|
$txtName.ForeColor = $cText
|
||||||
foreach ($k in @("py -3", "python")) {
|
$txtName.BorderStyle = "FixedSingle"
|
||||||
try {
|
$form.Controls.Add($txtName)
|
||||||
$v = Invoke-Expression "$k --version" 2>&1
|
|
||||||
if ("$v" -match "Python 3\.(\d+)" -and [int]$Matches[1] -ge 10) { $python = $k; break }
|
# Zielverzeichnis - frei wählbar, Standard "Programme"
|
||||||
} catch {}
|
$lblPfad = New-Object System.Windows.Forms.Label
|
||||||
}
|
$lblPfad.Text = "Installieren nach:"
|
||||||
if (-not $python) {
|
$lblPfad.Location = New-Object System.Drawing.Point(24, 232)
|
||||||
Log "FEHLER: Python 3.10+ nicht gefunden."
|
$lblPfad.Size = New-Object System.Drawing.Size(500, 18)
|
||||||
Log "Installieren mit: winget install Python.Python.3.12"
|
$lblPfad.ForeColor = $cGedaempft
|
||||||
Log "Danach diesen Installer erneut starten."
|
$form.Controls.Add($lblPfad)
|
||||||
$btnInstall.Enabled = $true
|
|
||||||
return
|
$txtPfad = New-Object System.Windows.Forms.TextBox
|
||||||
}
|
$txtPfad.Location = New-Object System.Drawing.Point(24, 252)
|
||||||
Log "Python gefunden: $python"
|
$txtPfad.Size = New-Object System.Drawing.Size(390, 26)
|
||||||
|
$txtPfad.Text = $StandardZiel
|
||||||
# 2. Ordner + Worker-Code
|
$txtPfad.Font = New-Object System.Drawing.Font("Consolas", 9)
|
||||||
New-Item -ItemType Directory -Force $InstallDir | Out-Null
|
$txtPfad.BackColor = $cFeld
|
||||||
Set-Location $InstallDir
|
$txtPfad.ForeColor = $cText
|
||||||
$HandBrakeVersion = "1.11.2"
|
$txtPfad.BorderStyle = "FixedSingle"
|
||||||
try {
|
$form.Controls.Add($txtPfad)
|
||||||
$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") }
|
$btnPfad = New-Object System.Windows.Forms.Button
|
||||||
} catch {}
|
$btnPfad.Text = "Ändern ..."
|
||||||
|
$btnPfad.Location = New-Object System.Drawing.Point(424, 251)
|
||||||
Log "Lade Worker-Code von $rHost ..."
|
$btnPfad.Size = New-Object System.Drawing.Size(100, 28)
|
||||||
Invoke-WebRequest "http://$rHost/api/worker-setup/paket" -OutFile worker.zip -UseBasicParsing
|
$btnPfad.FlatStyle = "Flat"
|
||||||
Expand-Archive worker.zip -DestinationPath . -Force
|
$btnPfad.BackColor = $cFeld
|
||||||
Remove-Item worker.zip
|
$btnPfad.ForeColor = $cText
|
||||||
|
$btnPfad.FlatAppearance.BorderColor = $cGedaempft
|
||||||
# 3. venv + Abhaengigkeiten
|
$form.Controls.Add($btnPfad)
|
||||||
if (-not (Test-Path "venv")) { Log "Lege Python-Umgebung an ..."; Invoke-Expression "$python -m venv venv" }
|
$btnPfad.Add_Click({
|
||||||
Log "Installiere Python-Abhaengigkeiten (kann 1-2 min dauern) ..."
|
$dlg = New-Object System.Windows.Forms.FolderBrowserDialog
|
||||||
& .\venv\Scripts\python.exe -m pip install --quiet --upgrade pip
|
$dlg.Description = "Ordner wählen - darin wird 'Rippy Worker' angelegt"
|
||||||
& .\venv\Scripts\pip.exe install --quiet -r requirements.txt
|
$dlg.ShowNewFolderButton = $true
|
||||||
& .\venv\Scripts\pip.exe install --quiet pystray pillow
|
if ($dlg.ShowDialog() -eq "OK") {
|
||||||
Log "Abhaengigkeiten installiert."
|
# Der Dialog liefert den ELTERN-Ordner; der Unterordner kommt von uns,
|
||||||
|
# damit die Deinstallation nie einen fremden Ordner mitlöscht.
|
||||||
# 4. HandBrakeCLI
|
$txtPfad.Text = (Join-Path $dlg.SelectedPath "Rippy Worker")
|
||||||
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
|
# Autostart
|
||||||
Expand-Archive hb.zip -DestinationPath . -Force
|
$chkAuto = New-Object System.Windows.Forms.CheckBox
|
||||||
Remove-Item hb.zip
|
$chkAuto.Text = "Beim Anmelden automatisch starten (Tray-Symbol)"
|
||||||
}
|
$chkAuto.Location = New-Object System.Drawing.Point(24, 290)
|
||||||
Log "HandBrake bereit."
|
$chkAuto.Size = New-Object System.Drawing.Size(400, 22)
|
||||||
|
$chkAuto.Checked = $true
|
||||||
# 5. Start-Skripte + Deinstaller erzeugen
|
$chkAuto.ForeColor = $cText
|
||||||
$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"
|
$form.Controls.Add($chkAuto)
|
||||||
Set-Content -Path "start-tray.bat" -Value $trayBat -Encoding ASCII
|
|
||||||
|
# Log-Bereich
|
||||||
$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"
|
$log = New-Object System.Windows.Forms.TextBox
|
||||||
Set-Content -Path "start-worker.bat" -Value $workBat -Encoding ASCII
|
$log.Location = New-Object System.Drawing.Point(24, 322)
|
||||||
|
$log.Size = New-Object System.Drawing.Size(500, 150)
|
||||||
$uninstall = @"
|
$log.Multiline = $true
|
||||||
param([switch]`$Force)
|
$log.ReadOnly = $true
|
||||||
if (-not `$Force) { if ([System.Windows.Forms.MessageBox]::Show("Rippy-Worker '$wName' deinstallieren?","Rippy",4) -ne "Yes") { exit } }
|
$log.ScrollBars = "Vertical"
|
||||||
Add-Type -AssemblyName System.Windows.Forms
|
$log.BackColor = $cPanel
|
||||||
Get-CimInstance Win32_Process | Where-Object { `$_.ExecutablePath -like "`$PSScriptRoot*" } | ForEach-Object { Stop-Process -Id `$_.ProcessId -Force -ErrorAction SilentlyContinue }
|
$log.ForeColor = $cText
|
||||||
Start-Sleep 2
|
$log.BorderStyle = "FixedSingle"
|
||||||
schtasks /delete /tn "RippyWorker" /f 2>`$null | Out-Null
|
$log.Font = New-Object System.Drawing.Font("Consolas", 8.5)
|
||||||
try { Invoke-RestMethod -Method Delete "http://$rHost/api/workers/$wName" -TimeoutSec 5 | Out-Null } catch {}
|
$form.Controls.Add($log)
|
||||||
Set-Location (Split-Path `$PSScriptRoot -Parent)
|
|
||||||
Remove-Item -Recurse -Force `$PSScriptRoot
|
# Knöpfe
|
||||||
[System.Windows.Forms.MessageBox]::Show("Rippy-Worker deinstalliert.","Rippy")
|
$btnInstall = New-Object System.Windows.Forms.Button
|
||||||
"@
|
$btnInstall.Text = "Installieren"
|
||||||
Set-Content -Path "uninstall.ps1" -Value $uninstall -Encoding ASCII
|
$btnInstall.Location = New-Object System.Drawing.Point(24, 486)
|
||||||
|
$btnInstall.Size = New-Object System.Drawing.Size(150, 40)
|
||||||
# 6. Autostart
|
$btnInstall.BackColor = $cAmber
|
||||||
if ($chkAuto.Checked) {
|
$btnInstall.ForeColor = $cBg
|
||||||
schtasks /create /f /tn "RippyWorker" /tr "`"$InstallDir\start-tray.bat`"" /sc onlogon | Out-Null
|
$btnInstall.FlatStyle = "Flat"
|
||||||
Log "Autostart eingerichtet (Start bei Anmeldung)."
|
$btnInstall.Font = New-Object System.Drawing.Font("Segoe UI", 10, [System.Drawing.FontStyle]::Bold)
|
||||||
}
|
$form.Controls.Add($btnInstall)
|
||||||
|
|
||||||
Log ""
|
$btnStart = New-Object System.Windows.Forms.Button
|
||||||
Log "FERTIG! Ordner: $InstallDir"
|
$btnStart.Text = "Worker starten"
|
||||||
Log "Klick 'Worker starten' - er erscheint dann in Rippy unter Einstellungen -> Worker."
|
$btnStart.Location = New-Object System.Drawing.Point(190, 486)
|
||||||
$btnStart.Visible = $true
|
$btnStart.Size = New-Object System.Drawing.Size(150, 40)
|
||||||
$btnInstall.Text = "Neu installieren"
|
$btnStart.BackColor = $cEmerald
|
||||||
$btnInstall.Enabled = $true
|
$btnStart.ForeColor = [System.Drawing.Color]::White
|
||||||
} catch {
|
$btnStart.FlatStyle = "Flat"
|
||||||
Log ""
|
$btnStart.Font = New-Object System.Drawing.Font("Segoe UI", 10, [System.Drawing.FontStyle]::Bold)
|
||||||
Log "FEHLER: $_"
|
$btnStart.Visible = $false
|
||||||
Log "Tipp: Ist die IP korrekt und die Rippy-Maschine im Heimnetz erreichbar?"
|
$form.Controls.Add($btnStart)
|
||||||
$btnInstall.Enabled = $true
|
|
||||||
}
|
function Log($text) {
|
||||||
}
|
$log.AppendText($text + "`r`n")
|
||||||
|
[System.Windows.Forms.Application]::DoEvents()
|
||||||
$btnInstall.Add_Click({ Do-Install })
|
}
|
||||||
$btnStart.Add_Click({
|
|
||||||
Start-Process -FilePath (Join-Path $InstallDir "start-tray.bat") -WindowStyle Hidden
|
# --- Installations-Logik ---------------------------------------------------
|
||||||
[System.Windows.Forms.MessageBox]::Show("Worker gestartet - Symbol erscheint unten rechts neben der Uhr.", "Rippy")
|
function Do-Install {
|
||||||
$form.Close()
|
$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 }
|
||||||
[void]$form.ShowDialog()
|
if (-not $wName) { $wName = $env:COMPUTERNAME.ToLower() }
|
||||||
|
|
||||||
|
# Zielverzeichnis aus dem Feld. script:-Bereich, weil "Worker starten"
|
||||||
|
# und die Abschlussmeldung denselben Pfad brauchen.
|
||||||
|
$zielEingabe = $txtPfad.Text.Trim()
|
||||||
|
if (-not $zielEingabe) { $zielEingabe = $StandardZiel }
|
||||||
|
if (-not [System.IO.Path]::IsPathRooted($zielEingabe)) {
|
||||||
|
[System.Windows.Forms.MessageBox]::Show(
|
||||||
|
"Bitte einen vollständigen Pfad angeben, z. B. C:\Program Files\Rippy Worker.",
|
||||||
|
"Pfad unvollständig")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
$script:InstallDir = $zielEingabe
|
||||||
|
|
||||||
|
$btnInstall.Enabled = $false
|
||||||
|
$log.Clear()
|
||||||
|
Log "Ziel: $InstallDir"
|
||||||
|
|
||||||
|
try {
|
||||||
|
# 1. Python finden
|
||||||
|
$python = $null
|
||||||
|
foreach ($k in @("py -3", "python")) {
|
||||||
|
try {
|
||||||
|
$v = Invoke-Expression "$k --version" 2>&1
|
||||||
|
if ("$v" -match "Python 3\.(\d+)" -and [int]$Matches[1] -ge 10) { $python = $k; break }
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
if (-not $python) {
|
||||||
|
Log "FEHLER: Python 3.10+ nicht gefunden."
|
||||||
|
Log "Installieren mit: winget install Python.Python.3.12"
|
||||||
|
Log "Danach diesen Installer erneut starten."
|
||||||
|
$btnInstall.Enabled = $true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Log "Python gefunden: $python"
|
||||||
|
|
||||||
|
# 2. Ordner + Worker-Code
|
||||||
|
# Erst PRUEFEN, ob wir dort überhaupt schreiben dürfen. Die .exe ist mit
|
||||||
|
# -requireAdmin gebaut und fragt per UAC, aber wer install-gui.ps1 direkt
|
||||||
|
# startet, hat die Rechte nicht - dann soll hier ein brauchbarer Satz
|
||||||
|
# stehen und nicht "Zugriff verweigert" aus irgendeiner Tiefe.
|
||||||
|
try {
|
||||||
|
New-Item -ItemType Directory -Force $InstallDir -ErrorAction Stop | Out-Null
|
||||||
|
$probe = Join-Path $InstallDir ".schreibtest"
|
||||||
|
Set-Content -Path $probe -Value "x" -ErrorAction Stop
|
||||||
|
Remove-Item -Force $probe -ErrorAction SilentlyContinue
|
||||||
|
} catch {
|
||||||
|
Log "FEHLER: In '$InstallDir' darf nicht geschrieben werden."
|
||||||
|
Log ""
|
||||||
|
Log "Das ist normal bei Ordnern unter 'Programme' - dafür braucht es"
|
||||||
|
Log "Administratorrechte. Zwei Wege:"
|
||||||
|
Log " 1. Diesen Installer mit Rechtsklick > 'Als Administrator"
|
||||||
|
Log " ausführen' starten (der empfohlene Weg), ODER"
|
||||||
|
Log " 2. oben über 'Ändern ...' einen Ordner im eigenen"
|
||||||
|
Log " Benutzerprofil wählen - dort geht es ohne Adminrechte."
|
||||||
|
$btnInstall.Enabled = $true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Set-Location $InstallDir
|
||||||
|
$HandBrakeVersion = "1.11.2"
|
||||||
|
try {
|
||||||
|
$rel = Invoke-RestMethod "https://api.github.com/repos/HandBrake/HandBrake/releases/latest" -TimeoutSec 15 -Headers @{ "User-Agent" = "rippy-installer" }
|
||||||
|
if ($rel.tag_name) { $HandBrakeVersion = ([string]$rel.tag_name).TrimStart("v") }
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
Log "Lade Worker-Code von $rHost ..."
|
||||||
|
Invoke-WebRequest "http://$rHost/api/worker-setup/paket" -OutFile worker.zip -UseBasicParsing
|
||||||
|
Expand-Archive worker.zip -DestinationPath . -Force
|
||||||
|
Remove-Item worker.zip
|
||||||
|
|
||||||
|
# 3. venv + Abhängigkeiten
|
||||||
|
if (-not (Test-Path "venv")) { Log "Lege Python-Umgebung an ..."; Invoke-Expression "$python -m venv venv" }
|
||||||
|
Log "Installiere Python-Abhängigkeiten (kann 1-2 min dauern) ..."
|
||||||
|
& .\venv\Scripts\python.exe -m pip install --quiet --upgrade pip
|
||||||
|
& .\venv\Scripts\pip.exe install --quiet -r requirements.txt
|
||||||
|
& .\venv\Scripts\pip.exe install --quiet pystray pillow
|
||||||
|
Log "Abhängigkeiten installiert."
|
||||||
|
|
||||||
|
# 4. HandBrakeCLI
|
||||||
|
if (-not (Test-Path ".\HandBrakeCLI.exe") -and -not (Get-Command HandBrakeCLI.exe -ErrorAction SilentlyContinue)) {
|
||||||
|
Log "Lade HandBrakeCLI $HandBrakeVersion (offizielles Release) ..."
|
||||||
|
$url = "https://github.com/HandBrake/HandBrake/releases/download/$HandBrakeVersion/HandBrakeCLI-$HandBrakeVersion-win-x86_64.zip"
|
||||||
|
Invoke-WebRequest $url -OutFile hb.zip -UseBasicParsing
|
||||||
|
Expand-Archive hb.zip -DestinationPath . -Force
|
||||||
|
Remove-Item hb.zip
|
||||||
|
}
|
||||||
|
Log "HandBrake bereit."
|
||||||
|
|
||||||
|
# 5. 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
|
||||||
|
# Autostart-Verknüpfung entfernen - BEIDE möglichen Orte, weil die
|
||||||
|
# Installation je nach Zielverzeichnis persönlich oder maschinenweit war.
|
||||||
|
foreach (`$ordner in @("Startup", "CommonStartup")) {
|
||||||
|
try { Remove-Item -Force (Join-Path ([Environment]::GetFolderPath(`$ordner)) "RippyWorker.lnk") -ErrorAction Stop } catch {}
|
||||||
|
}
|
||||||
|
# ... und die geplante Aufgabe aus älteren Installationen, falls vorhanden.
|
||||||
|
schtasks /delete /tn "RippyWorker" /f 2>`$null | Out-Null
|
||||||
|
try { Invoke-RestMethod -Method Delete "http://$rHost/api/workers/$wName" -TimeoutSec 5 | Out-Null } catch {}
|
||||||
|
Set-Location (Split-Path `$PSScriptRoot -Parent)
|
||||||
|
Remove-Item -Recurse -Force `$PSScriptRoot
|
||||||
|
[System.Windows.Forms.MessageBox]::Show("Rippy-Worker deinstalliert.","Rippy")
|
||||||
|
"@
|
||||||
|
Set-Content -Path "uninstall.ps1" -Value $uninstall -Encoding ASCII
|
||||||
|
|
||||||
|
# 6. Autostart über den Autostart-ORDNER, nicht über schtasks.
|
||||||
|
#
|
||||||
|
# Befund 25.07.2026 (auf dem Commander-PC aufgetreten): schtasks /create
|
||||||
|
# mit /tn "RippyWorker" legt die Aufgabe im WURZELORDNER der
|
||||||
|
# Aufgabenplanung an, und das verlangt Administratorrechte. Der
|
||||||
|
# Installer läuft normal ohne. schtasks schrieb deshalb
|
||||||
|
# "FEHLER: Zugriff verweigert." nach stderr, und weil oben
|
||||||
|
# $ErrorActionPreference = "Stop" steht, wurde daraus ein Abbruch der
|
||||||
|
# GESAMTEN Installation - obwohl alles außer dem Autostart fertig war.
|
||||||
|
# Der Nutzer sah nur "FEHLER: FEHLER: Zugriff verweigert" samt dem
|
||||||
|
# irreführenden Tipp, die IP könne falsch sein.
|
||||||
|
#
|
||||||
|
# Der Autostart-Ordner braucht NIE Adminrechte, und der Nutzer kann die
|
||||||
|
# Verknüpfung dort selbst sehen und löschen. Zwei Fliegen.
|
||||||
|
if ($chkAuto.Checked) {
|
||||||
|
try {
|
||||||
|
# Liegt das Programm unter "Programme", ist es eine Installation
|
||||||
|
# für die ganze Maschine - dann gehört der Autostart in den
|
||||||
|
# Autostart-Ordner ALLER Benutzer. Das passt auch zur Rechtelage:
|
||||||
|
# der Installer läuft dafür ohnehin erhöht, und bei einer
|
||||||
|
# Erhöhung über ein FREMDES Administratorkonto wäre der
|
||||||
|
# benutzereigene Ordner der des Admins - also der falsche.
|
||||||
|
# Bei einer Installation ins eigene Profil bleibt es persönlich.
|
||||||
|
$programme = [Environment]::GetFolderPath("ProgramFiles")
|
||||||
|
$programmeX86 = [Environment]::GetFolderPath("ProgramFilesX86")
|
||||||
|
$maschinenweit = $InstallDir.StartsWith($programme, "OrdinalIgnoreCase") `
|
||||||
|
-or ($programmeX86 -and $InstallDir.StartsWith($programmeX86, "OrdinalIgnoreCase"))
|
||||||
|
$autostartDir = if ($maschinenweit) {
|
||||||
|
[Environment]::GetFolderPath("CommonStartup")
|
||||||
|
} else {
|
||||||
|
[Environment]::GetFolderPath("Startup")
|
||||||
|
}
|
||||||
|
$lnkPfad = Join-Path $autostartDir "RippyWorker.lnk"
|
||||||
|
$wsh = New-Object -ComObject WScript.Shell
|
||||||
|
$lnk = $wsh.CreateShortcut($lnkPfad)
|
||||||
|
$lnk.TargetPath = (Join-Path $InstallDir "start-tray.bat")
|
||||||
|
$lnk.WorkingDirectory = $InstallDir
|
||||||
|
$lnk.Description = "Rippy Encoding-Worker"
|
||||||
|
$lnk.Save()
|
||||||
|
if ($maschinenweit) {
|
||||||
|
Log "Autostart eingerichtet (für alle Benutzer dieser Maschine)."
|
||||||
|
} else {
|
||||||
|
Log "Autostart eingerichtet (für dich, startet bei der Anmeldung)."
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
# Nur eine Warnung: der Worker selbst ist fertig und startbar.
|
||||||
|
Log "HINWEIS: Autostart konnte nicht eingerichtet werden ($($_.Exception.Message))."
|
||||||
|
Log " Der Worker ist trotzdem fertig installiert und läuft."
|
||||||
|
Log " Von Hand: start-tray.bat in den Autostart-Ordner verknüpfen"
|
||||||
|
Log " (Windows-Taste + R, dann shell:startup eingeben)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log ""
|
||||||
|
Log "FERTIG! Ordner: $InstallDir"
|
||||||
|
Log "Klick 'Worker starten' - er erscheint dann in Rippy unter Einstellungen -> Worker."
|
||||||
|
$btnStart.Visible = $true
|
||||||
|
$btnInstall.Text = "Neu installieren"
|
||||||
|
$btnInstall.Enabled = $true
|
||||||
|
} catch {
|
||||||
|
Log ""
|
||||||
|
Log "FEHLER: $_"
|
||||||
|
Log "Tipp: Ist die IP korrekt und die Rippy-Maschine im Heimnetz erreichbar?"
|
||||||
|
$btnInstall.Enabled = $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$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()
|
||||||
|
|||||||
+211
-158
@@ -1,158 +1,211 @@
|
|||||||
# Rippy: Nativer Windows-Transcode-Worker - Installation OHNE Docker.
|
# Rippy: Nativer Windows-Transcode-Worker - Installation OHNE Docker.
|
||||||
#
|
#
|
||||||
# Was das Skript tut (alles in einen Unterordner "rippy-worker"):
|
# Was das Skript tut (alles nach -InstallDir, Standard "C:\Program Files\Rippy Worker"):
|
||||||
# 1. Prueft Python (3.10+), legt ein venv an, installiert die Abhaengigkeiten
|
# 1. Prüft Python (3.10+), legt ein venv an, installiert die Abhängigkeiten
|
||||||
# 2. Laedt den Worker-Code direkt von deiner Rippy-Instanz (/api/worker-setup/paket)
|
# 2. Lädt den Worker-Code direkt von deiner Rippy-Instanz (/api/worker-setup/paket)
|
||||||
# 3. Laedt HandBrakeCLI (gepinnte Version, offizielles GitHub-Release),
|
# 3. Lädt HandBrakeCLI (gepinnte Version, offizielles GitHub-Release),
|
||||||
# falls nicht schon im PATH oder im Ordner vorhanden
|
# falls nicht schon im PATH oder im Ordner vorhanden
|
||||||
# 4. Erzeugt start-worker.bat - Doppelklick startet den Worker
|
# 4. Erzeugt start-worker.bat - Doppelklick startet den Worker
|
||||||
#
|
#
|
||||||
# Der Worker bedient NUR die Kompressions-Queue (transcode) - gerippt wird
|
# Der Worker bedient NUR die Kompressions-Queue (transcode) - gerippt wird
|
||||||
# weiterhin auf der Rippy-Hauptmaschine (dort haengt das Laufwerk).
|
# weiterhin auf der Rippy-Hauptmaschine (dort hängt das Laufwerk).
|
||||||
#
|
#
|
||||||
# Aufruf (PowerShell):
|
# Aufruf (PowerShell):
|
||||||
# .\install.ps1 -RippyHost 192.168.178.162 [-WorkerName mein-pc] [-Autostart]
|
# .\install.ps1 -RippyHost 192.168.178.162 [-WorkerName mein-pc] [-Autostart]
|
||||||
#
|
#
|
||||||
# -Autostart registriert eine Aufgabe "RippyWorker" (Start bei Anmeldung).
|
# -Autostart registriert eine Aufgabe "RippyWorker" (Start bei Anmeldung).
|
||||||
|
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory = $true)] [string]$RippyHost,
|
[Parameter(Mandatory = $true)] [string]$RippyHost,
|
||||||
[string]$WorkerName = $env:COMPUTERNAME.ToLower(),
|
[string]$WorkerName = $env:COMPUTERNAME.ToLower(),
|
||||||
[switch]$Autostart
|
[switch]$Autostart,
|
||||||
)
|
# Zielverzeichnis. Standard ist "Programme", wie bei jedem anderen Programm
|
||||||
|
# (Commander-Wunsch 25.07.2026). Dorthin schreiben braucht Adminrechte -
|
||||||
$ErrorActionPreference = "Stop"
|
# PowerShell also "Als Administrator" starten, oder hier einen Ordner im
|
||||||
# HandBrake-Version: die NEUESTE offizielle (GitHub-Release) - so passt der
|
# eigenen Profil angeben.
|
||||||
# Windows-Worker zu dem, was der Update-Check im UI meldet (frueher war hier
|
[string]$InstallDir = (Join-Path ([Environment]::GetFolderPath("ProgramFiles")) "Rippy Worker")
|
||||||
# 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"
|
$ErrorActionPreference = "Stop"
|
||||||
try {
|
# HandBrake-Version: die NEUESTE offizielle (GitHub-Release) - so passt der
|
||||||
$rel = Invoke-RestMethod "https://api.github.com/repos/HandBrake/HandBrake/releases/latest" `
|
# Windows-Worker zu dem, was der Update-Check im UI meldet (früher war hier
|
||||||
-TimeoutSec 15 -Headers @{ "User-Agent" = "rippy-installer" }
|
# eine feste 1.9.2 hardcodiert, was verwirrte). Fällt auf eine bekannte
|
||||||
if ($rel.tag_name) { $HandBrakeVersion = ([string]$rel.tag_name).TrimStart("v") }
|
# Version zurück, falls die GitHub-API gerade nicht erreichbar ist.
|
||||||
Write-Host "Neueste HandBrake-Version: $HandBrakeVersion"
|
$HandBrakeVersion = "1.11.2"
|
||||||
} catch { Write-Host "GitHub-API nicht erreichbar - nutze HandBrake $HandBrakeVersion" }
|
try {
|
||||||
$HandBrakeUrl = "https://github.com/HandBrake/HandBrake/releases/download/$HandBrakeVersion/HandBrakeCLI-$HandBrakeVersion-win-x86_64.zip"
|
$rel = Invoke-RestMethod "https://api.github.com/repos/HandBrake/HandBrake/releases/latest" `
|
||||||
|
-TimeoutSec 15 -Headers @{ "User-Agent" = "rippy-installer" }
|
||||||
Write-Host "== Rippy Windows-Worker Installation ==" -ForegroundColor Cyan
|
if ($rel.tag_name) { $HandBrakeVersion = ([string]$rel.tag_name).TrimStart("v") }
|
||||||
|
Write-Host "Neueste HandBrake-Version: $HandBrakeVersion"
|
||||||
# 1. Python finden (py-Launcher oder python im PATH)
|
} catch { Write-Host "GitHub-API nicht erreichbar - nutze HandBrake $HandBrakeVersion" }
|
||||||
$python = $null
|
$HandBrakeUrl = "https://github.com/HandBrake/HandBrake/releases/download/$HandBrakeVersion/HandBrakeCLI-$HandBrakeVersion-win-x86_64.zip"
|
||||||
foreach ($kandidat in @("py -3", "python")) {
|
|
||||||
try {
|
Write-Host "== Rippy Windows-Worker Installation ==" -ForegroundColor Cyan
|
||||||
$version = Invoke-Expression "$kandidat --version" 2>&1
|
|
||||||
if ("$version" -match "Python 3\.(\d+)") {
|
# 1. Python finden (py-Launcher oder python im PATH)
|
||||||
if ([int]$Matches[1] -ge 10) { $python = $kandidat; break }
|
$python = $null
|
||||||
}
|
foreach ($kandidat in @("py -3", "python")) {
|
||||||
} catch {}
|
try {
|
||||||
}
|
$version = Invoke-Expression "$kandidat --version" 2>&1
|
||||||
if (-not $python) {
|
if ("$version" -match "Python 3\.(\d+)") {
|
||||||
Write-Host "FEHLER: Python 3.10+ nicht gefunden." -ForegroundColor Red
|
if ([int]$Matches[1] -ge 10) { $python = $kandidat; break }
|
||||||
Write-Host "Installieren mit: winget install Python.Python.3.12 (dann Skript erneut ausfuehren)"
|
}
|
||||||
exit 1
|
} catch {}
|
||||||
}
|
}
|
||||||
Write-Host "Python gefunden: $python"
|
if (-not $python) {
|
||||||
|
Write-Host "FEHLER: Python 3.10+ nicht gefunden." -ForegroundColor Red
|
||||||
# 2. Arbeitsordner + Worker-Code von der Rippy-Instanz laden
|
Write-Host "Installieren mit: winget install Python.Python.3.12 (dann Skript erneut ausführen)"
|
||||||
$ziel = Join-Path (Get-Location) "rippy-worker"
|
exit 1
|
||||||
New-Item -ItemType Directory -Force $ziel | Out-Null
|
}
|
||||||
Set-Location $ziel
|
Write-Host "Python gefunden: $python"
|
||||||
|
|
||||||
Write-Host "Lade Worker-Code von http://$RippyHost/api/worker-setup/paket ..."
|
# 2. Zielordner + Worker-Code von der Rippy-Instanz laden
|
||||||
Invoke-WebRequest "http://$RippyHost/api/worker-setup/paket" -OutFile worker.zip -UseBasicParsing
|
$ziel = $InstallDir
|
||||||
Expand-Archive worker.zip -DestinationPath . -Force
|
try {
|
||||||
Remove-Item worker.zip
|
New-Item -ItemType Directory -Force $ziel -ErrorAction Stop | Out-Null
|
||||||
|
$probe = Join-Path $ziel ".schreibtest"
|
||||||
# 3. venv + Abhaengigkeiten
|
Set-Content -Path $probe -Value "x" -ErrorAction Stop
|
||||||
if (-not (Test-Path "venv")) {
|
Remove-Item -Force $probe -ErrorAction SilentlyContinue
|
||||||
Write-Host "Lege Python-Umgebung an ..."
|
} catch {
|
||||||
Invoke-Expression "$python -m venv venv"
|
Write-Host ""
|
||||||
}
|
Write-Host "FEHLER: In '$ziel' darf nicht geschrieben werden." -ForegroundColor Red
|
||||||
& .\venv\Scripts\python.exe -m pip install --quiet --upgrade pip
|
Write-Host "Das ist normal bei Ordnern unter 'Programme'. Zwei Wege:" -ForegroundColor Red
|
||||||
& .\venv\Scripts\pip.exe install --quiet -r requirements.txt
|
Write-Host " 1. PowerShell als Administrator starten und erneut aufrufen, ODER" -ForegroundColor Red
|
||||||
# Nur fuer den Windows-Worker: Tray-Symbol (Status, Start/Stopp, Beenden)
|
Write-Host " 2. ein eigenes Ziel angeben, z. B.:" -ForegroundColor Red
|
||||||
& .\venv\Scripts\pip.exe install --quiet pystray pillow
|
Write-Host " -InstallDir `"`$env:LOCALAPPDATA\Rippy Worker`"" -ForegroundColor Red
|
||||||
Write-Host "Python-Abhaengigkeiten installiert."
|
exit 1
|
||||||
|
}
|
||||||
# 4. HandBrakeCLI besorgen (falls noetig)
|
Write-Host "Ziel: $ziel"
|
||||||
$hb = Get-Command HandBrakeCLI.exe -ErrorAction SilentlyContinue
|
Set-Location $ziel
|
||||||
if (-not $hb -and -not (Test-Path ".\HandBrakeCLI.exe")) {
|
|
||||||
Write-Host "Lade HandBrakeCLI $HandBrakeVersion (offizielles GitHub-Release) ..."
|
Write-Host "Lade Worker-Code von http://$RippyHost/api/worker-setup/paket ..."
|
||||||
Invoke-WebRequest $HandBrakeUrl -OutFile hb.zip -UseBasicParsing
|
Invoke-WebRequest "http://$RippyHost/api/worker-setup/paket" -OutFile worker.zip -UseBasicParsing
|
||||||
Expand-Archive hb.zip -DestinationPath . -Force
|
Expand-Archive worker.zip -DestinationPath . -Force
|
||||||
Remove-Item hb.zip
|
Remove-Item worker.zip
|
||||||
}
|
|
||||||
if (-not (Test-Path ".\HandBrakeCLI.exe") -and -not $hb) {
|
# 3. venv + Abhängigkeiten
|
||||||
Write-Host "FEHLER: HandBrakeCLI.exe fehlt." -ForegroundColor Red
|
if (-not (Test-Path "venv")) {
|
||||||
exit 1
|
Write-Host "Lege Python-Umgebung an ..."
|
||||||
}
|
Invoke-Expression "$python -m venv venv"
|
||||||
Write-Host "HandBrakeCLI bereit."
|
}
|
||||||
|
& .\venv\Scripts\python.exe -m pip install --quiet --upgrade pip
|
||||||
# 5. Start-Skript erzeugen
|
& .\venv\Scripts\pip.exe install --quiet -r requirements.txt
|
||||||
$bat = @"
|
# Nur für den Windows-Worker: Tray-Symbol (Status, Start/Stopp, Beenden)
|
||||||
@echo off
|
& .\venv\Scripts\pip.exe install --quiet pystray pillow
|
||||||
rem Rippy Windows-Transcode-Worker - verbindet sich mit $RippyHost
|
Write-Host "Python-Abhängigkeiten installiert."
|
||||||
cd /d "%~dp0"
|
|
||||||
set REDIS_URL=redis://${RippyHost}:6379/0
|
# 4. HandBrakeCLI besorgen (falls nötig)
|
||||||
set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy
|
$hb = Get-Command HandBrakeCLI.exe -ErrorAction SilentlyContinue
|
||||||
set API_URL=http://${RippyHost}:8000
|
if (-not $hb -and -not (Test-Path ".\HandBrakeCLI.exe")) {
|
||||||
set WORKER_NAME=$WorkerName
|
Write-Host "Lade HandBrakeCLI $HandBrakeVersion (offizielles GitHub-Release) ..."
|
||||||
set PATH=%~dp0;%PATH%
|
Invoke-WebRequest $HandBrakeUrl -OutFile hb.zip -UseBasicParsing
|
||||||
venv\Scripts\celery.exe -A celery_app worker --loglevel=info -Q transcode --pool=solo -n ${WorkerName}@%%h
|
Expand-Archive hb.zip -DestinationPath . -Force
|
||||||
"@
|
Remove-Item hb.zip
|
||||||
Set-Content -Path "start-worker.bat" -Value $bat -Encoding ASCII
|
}
|
||||||
|
if (-not (Test-Path ".\HandBrakeCLI.exe") -and -not $hb) {
|
||||||
# 6. Tray-Start (empfohlen): Symbol neben der Uhr, kein Konsolenfenster
|
Write-Host "FEHLER: HandBrakeCLI.exe fehlt." -ForegroundColor Red
|
||||||
$trayBat = @"
|
exit 1
|
||||||
@echo off
|
}
|
||||||
rem Rippy-Worker mit Tray-Symbol - verbindet sich mit $RippyHost
|
Write-Host "HandBrakeCLI bereit."
|
||||||
cd /d "%~dp0"
|
|
||||||
set REDIS_URL=redis://${RippyHost}:6379/0
|
# 5. Start-Skript erzeugen
|
||||||
set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy
|
$bat = @"
|
||||||
set API_URL=http://${RippyHost}:8000
|
@echo off
|
||||||
set WORKER_NAME=$WorkerName
|
rem Rippy Windows-Transcode-Worker - verbindet sich mit $RippyHost
|
||||||
set RIPPY_TRAY_HOST=$RippyHost
|
cd /d "%~dp0"
|
||||||
set PATH=%~dp0;%PATH%
|
set REDIS_URL=redis://${RippyHost}:6379/0
|
||||||
start "" venv\Scripts\pythonw.exe tray.py
|
set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy
|
||||||
"@
|
set API_URL=http://${RippyHost}:8000
|
||||||
Set-Content -Path "start-tray.bat" -Value $trayBat -Encoding ASCII
|
set WORKER_NAME=$WorkerName
|
||||||
Write-Host "start-worker.bat + start-tray.bat erzeugt."
|
set PATH=%~dp0;%PATH%
|
||||||
|
venv\Scripts\celery.exe -A celery_app worker --loglevel=info -Q transcode --pool=solo -n ${WorkerName}@%%h
|
||||||
# 7. Deinstaller - MUSS-Kriterium: rueckstandsfrei entfernbar
|
"@
|
||||||
$uninstall = @"
|
Set-Content -Path "start-worker.bat" -Value $bat -Encoding ASCII
|
||||||
# Rippy-Worker DEINSTALLIEREN: stoppt Prozesse, entfernt Autostart,
|
|
||||||
# meldet den Worker in Rippy ab und loescht diesen Ordner komplett.
|
# 6. Tray-Start (empfohlen): Symbol neben der Uhr, kein Konsolenfenster
|
||||||
param([switch]`$Force)
|
$trayBat = @"
|
||||||
if (-not `$Force) {
|
@echo off
|
||||||
`$antwort = Read-Host "Rippy-Worker '$WorkerName' wirklich deinstallieren? (j/n)"
|
rem Rippy-Worker mit Tray-Symbol - verbindet sich mit $RippyHost
|
||||||
if (`$antwort -ne "j") { Write-Host "Abgebrochen."; exit }
|
cd /d "%~dp0"
|
||||||
}
|
set REDIS_URL=redis://${RippyHost}:6379/0
|
||||||
Get-CimInstance Win32_Process | Where-Object {
|
set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy
|
||||||
`$_.ExecutablePath -like "`$PSScriptRoot*"
|
set API_URL=http://${RippyHost}:8000
|
||||||
} | ForEach-Object { Stop-Process -Id `$_.ProcessId -Force -ErrorAction SilentlyContinue }
|
set WORKER_NAME=$WorkerName
|
||||||
Start-Sleep 2
|
set RIPPY_TRAY_HOST=$RippyHost
|
||||||
schtasks /delete /tn "RippyWorker" /f 2>`$null | Out-Null
|
set PATH=%~dp0;%PATH%
|
||||||
try {
|
start "" venv\Scripts\pythonw.exe tray.py
|
||||||
Invoke-RestMethod -Method Delete "http://$RippyHost/api/workers/$WorkerName" -TimeoutSec 5 | Out-Null
|
"@
|
||||||
Write-Host "Worker-Eintrag in Rippy entfernt."
|
Set-Content -Path "start-tray.bat" -Value $trayBat -Encoding ASCII
|
||||||
} catch { Write-Host "Hinweis: Eintrag in Rippy ggf. von Hand loeschen (Einstellungen -> Worker)." }
|
Write-Host "start-worker.bat + start-tray.bat erzeugt."
|
||||||
Set-Location (Split-Path `$PSScriptRoot -Parent)
|
|
||||||
Remove-Item -Recurse -Force `$PSScriptRoot
|
# 7. Deinstaller - MUSS-Kriterium: rückstandsfrei entfernbar
|
||||||
Write-Host "Rippy-Worker deinstalliert." -ForegroundColor Green
|
$uninstall = @"
|
||||||
"@
|
# Rippy-Worker DEINSTALLIEREN: stoppt Prozesse, entfernt Autostart,
|
||||||
Set-Content -Path "uninstall.ps1" -Value $uninstall -Encoding ASCII
|
# meldet den Worker in Rippy ab und löscht diesen Ordner komplett.
|
||||||
Write-Host "uninstall.ps1 erzeugt."
|
param([switch]`$Force)
|
||||||
|
if (-not `$Force) {
|
||||||
# 8. Optional: Autostart bei Anmeldung (mit Tray)
|
`$antwort = Read-Host "Rippy-Worker '$WorkerName' wirklich deinstallieren? (j/n)"
|
||||||
if ($Autostart) {
|
if (`$antwort -ne "j") { Write-Host "Abgebrochen."; exit }
|
||||||
schtasks /create /f /tn "RippyWorker" /tr "`"$ziel\start-tray.bat`"" /sc onlogon | Out-Null
|
}
|
||||||
Write-Host "Autostart-Aufgabe 'RippyWorker' registriert (Tray bei Anmeldung)."
|
Get-CimInstance Win32_Process | Where-Object {
|
||||||
}
|
`$_.ExecutablePath -like "`$PSScriptRoot*"
|
||||||
|
} | ForEach-Object { Stop-Process -Id `$_.ProcessId -Force -ErrorAction SilentlyContinue }
|
||||||
Write-Host ""
|
Start-Sleep 2
|
||||||
Write-Host "FERTIG." -ForegroundColor Green
|
# Autostart-Verknüpfung entfernen - BEIDE möglichen Orte, weil die
|
||||||
Write-Host " Starten (mit Tray-Symbol): $ziel\start-tray.bat"
|
# Installation je nach Zielverzeichnis persönlich oder maschinenweit war.
|
||||||
Write-Host " Starten (Konsole/Debug): $ziel\start-worker.bat"
|
foreach (`$ordner in @("Startup", "CommonStartup")) {
|
||||||
Write-Host " Deinstallieren: $ziel\uninstall.ps1"
|
try { Remove-Item -Force (Join-Path ([Environment]::GetFolderPath(`$ordner)) "RippyWorker.lnk") -ErrorAction Stop } catch {}
|
||||||
Write-Host "Der Worker erscheint in Rippy unter Einstellungen -> Worker als '$WorkerName'."
|
}
|
||||||
|
# ... und die geplante Aufgabe aus älteren Installationen, falls vorhanden.
|
||||||
|
schtasks /delete /tn "RippyWorker" /f 2>`$null | Out-Null
|
||||||
|
try {
|
||||||
|
Invoke-RestMethod -Method Delete "http://$RippyHost/api/workers/$WorkerName" -TimeoutSec 5 | Out-Null
|
||||||
|
Write-Host "Worker-Eintrag in Rippy entfernt."
|
||||||
|
} catch { Write-Host "Hinweis: Eintrag in Rippy ggf. von Hand löschen (Einstellungen -> Worker)." }
|
||||||
|
Set-Location (Split-Path `$PSScriptRoot -Parent)
|
||||||
|
Remove-Item -Recurse -Force `$PSScriptRoot
|
||||||
|
Write-Host "Rippy-Worker deinstalliert." -ForegroundColor Green
|
||||||
|
"@
|
||||||
|
Set-Content -Path "uninstall.ps1" -Value $uninstall -Encoding ASCII
|
||||||
|
Write-Host "uninstall.ps1 erzeugt."
|
||||||
|
|
||||||
|
# 8. Optional: Autostart bei Anmeldung (mit Tray)
|
||||||
|
#
|
||||||
|
# Über den Autostart-ORDNER, nicht über schtasks: Eine Aufgabe im
|
||||||
|
# Wurzelordner der Aufgabenplanung anzulegen verlangt Administratorrechte, und
|
||||||
|
# der Installer läuft normal ohne. Auf dem Commander-PC scheiterte das am
|
||||||
|
# 25.07.2026 mit "FEHLER: Zugriff verweigert." - und weil oben
|
||||||
|
# $ErrorActionPreference = "Stop" steht, riss es die ganze Installation mit,
|
||||||
|
# obwohl nur der Autostart fehlte. Der Autostart-Ordner braucht nie Adminrechte.
|
||||||
|
if ($Autostart) {
|
||||||
|
try {
|
||||||
|
# Unter "Programme" = Installation für die Maschine, also Autostart für
|
||||||
|
# alle Benutzer. Sonst persönlich. Siehe install-gui.ps1 für die
|
||||||
|
# ausführliche Begründung (Rechtelage bei fremdem Admin-Konto).
|
||||||
|
$programme = [Environment]::GetFolderPath("ProgramFiles")
|
||||||
|
$maschinenweit = $ziel.StartsWith($programme, "OrdinalIgnoreCase")
|
||||||
|
$autostartDir = if ($maschinenweit) {
|
||||||
|
[Environment]::GetFolderPath("CommonStartup")
|
||||||
|
} else {
|
||||||
|
[Environment]::GetFolderPath("Startup")
|
||||||
|
}
|
||||||
|
$wsh = New-Object -ComObject WScript.Shell
|
||||||
|
$lnk = $wsh.CreateShortcut((Join-Path $autostartDir "RippyWorker.lnk"))
|
||||||
|
$lnk.TargetPath = (Join-Path $ziel "start-tray.bat")
|
||||||
|
$lnk.WorkingDirectory = $ziel
|
||||||
|
$lnk.Description = "Rippy Encoding-Worker"
|
||||||
|
$lnk.Save()
|
||||||
|
Write-Host "Autostart eingerichtet (Verknüpfung im Autostart-Ordner)."
|
||||||
|
} catch {
|
||||||
|
Write-Host "HINWEIS: Autostart konnte nicht eingerichtet werden ($($_.Exception.Message))." -ForegroundColor Yellow
|
||||||
|
Write-Host " Der Worker ist trotzdem fertig installiert." -ForegroundColor Yellow
|
||||||
|
Write-Host " Von Hand: start-tray.bat in shell:startup verknüpfen." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "FERTIG." -ForegroundColor Green
|
||||||
|
Write-Host " Starten (mit Tray-Symbol): $ziel\start-tray.bat"
|
||||||
|
Write-Host " Starten (Konsole/Debug): $ziel\start-worker.bat"
|
||||||
|
Write-Host " Deinstallieren: $ziel\uninstall.ps1"
|
||||||
|
Write-Host "Der Worker erscheint in Rippy unter Einstellungen -> Worker als '$WorkerName'."
|
||||||
|
|||||||
+39
-14
@@ -11,11 +11,15 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: docker/api/Dockerfile
|
dockerfile: docker/api/Dockerfile
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgresql://rippy:rippy@postgres:5432/rippy
|
- DATABASE_URL=postgresql://rippy:${POSTGRES_PASSWORD:-rippy}@postgres:5432/rippy
|
||||||
- REDIS_URL=redis://redis:6379/0
|
- REDIS_URL=redis://redis:6379/0
|
||||||
- TMDB_API_KEY=${TMDB_API_KEY:-}
|
- TMDB_API_KEY=${TMDB_API_KEY:-}
|
||||||
- THETVDB_API_KEY=${THETVDB_API_KEY:-}
|
- THETVDB_API_KEY=${THETVDB_API_KEY:-}
|
||||||
- OMDB_API_KEY=${OMDB_API_KEY:-}
|
- OMDB_API_KEY=${OMDB_API_KEY:-}
|
||||||
|
# Dasselbe Host-Verzeichnis wie beim Worker, nur an neutraler Stelle:
|
||||||
|
# die API liest den KEYDB-Status und die AACS-Dumps und nimmt eine
|
||||||
|
# hochgeladene KEYDB.cfg entgegen (Einstellungen → System).
|
||||||
|
- MAKEMKV_DATA_DIR=/app/makemkv-data
|
||||||
- LOG_LEVEL=INFO
|
- LOG_LEVEL=INFO
|
||||||
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\")'"]
|
||||||
@@ -47,8 +51,10 @@ services:
|
|||||||
bind:
|
bind:
|
||||||
propagation: rshared
|
propagation: rshared
|
||||||
- temp:/app/temp
|
- temp:/app/temp
|
||||||
|
# MakeMKV-Datenverzeichnis (dasselbe wie im Worker, siehe dort).
|
||||||
|
- ${MAKEMKV_DATA_HOST:-/srv/rippy/makemkv}:/app/makemkv-data
|
||||||
devices:
|
devices:
|
||||||
- /dev/sr0:/dev/sr0
|
- ${OPTICAL_SR:-/dev/sr0}:/dev/sr0
|
||||||
networks:
|
networks:
|
||||||
- rippy-net
|
- rippy-net
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -64,20 +70,31 @@ 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.
|
||||||
# Einstellungen → System meldet, wenn es eine neuere Version gibt.
|
# Einstellungen → System meldet, wenn es eine neuere Version gibt.
|
||||||
MAKEMKV_VERSION: ${MAKEMKV_VERSION:-1.18.4}
|
MAKEMKV_VERSION: ${MAKEMKV_VERSION:-1.18.4}
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgresql://rippy:rippy@postgres:5432/rippy
|
- DATABASE_URL=postgresql://rippy:${POSTGRES_PASSWORD:-rippy}@postgres:5432/rippy
|
||||||
- REDIS_URL=redis://redis:6379/0
|
- REDIS_URL=redis://redis:6379/0
|
||||||
- RIP_OUTPUT_DIR=/app/media
|
- RIP_OUTPUT_DIR=/app/media
|
||||||
- MAKEMKV_APP_KEY=${MAKEMKV_APP_KEY}
|
- MAKEMKV_APP_KEY=${MAKEMKV_APP_KEY}
|
||||||
|
# MakeMKVs Datenverzeichnis im Container — fest verdrahtet in MakeMKV
|
||||||
|
# selbst (HOME/.MakeMKV, HOME ist /root). Steht hier, damit Worker-Code
|
||||||
|
# und Mount dieselbe Wahrheit benutzen.
|
||||||
|
- MAKEMKV_DATA_DIR=/root/.MakeMKV
|
||||||
# Anzeigename in Einstellungen → Worker (stabil über Rebuilds hinweg;
|
# Anzeigename in Einstellungen → Worker (stabil über Rebuilds hinweg;
|
||||||
# via .env übersteuerbar, z. B. WORKER_NAME=wohnzimmer-vm)
|
# via .env übersteuerbar, z. B. WORKER_NAME=wohnzimmer-vm)
|
||||||
- WORKER_NAME=${WORKER_NAME:-rippy-hauptworker}
|
- WORKER_NAME=${WORKER_NAME:-rippy-hauptworker}
|
||||||
@@ -92,14 +109,22 @@ services:
|
|||||||
bind:
|
bind:
|
||||||
propagation: rslave
|
propagation: rslave
|
||||||
- temp:/app/temp
|
- temp:/app/temp
|
||||||
|
# MakeMKV-Datenverzeichnis PERSISTENT (Befund 25.07.2026, live gemessen):
|
||||||
|
# Hier liegen die KEYDB.cfg — heute die EINZIGE funktionierende
|
||||||
|
# Schlüsselquelle für 4K-UHD, weil MakeMKVs Online-Kanal nichts mehr
|
||||||
|
# liefert —, die AACS-Dumps fehlgeschlagener Discs und settings.conf.
|
||||||
|
# Ohne diesen Mount löschte JEDER `up -d --build` beides; die
|
||||||
|
# Fehlermeldung schickte den Nutzer zu einer Datei, die es nicht mehr gab.
|
||||||
|
- ${MAKEMKV_DATA_HOST:-/srv/rippy/makemkv}:/root/.MakeMKV
|
||||||
devices:
|
devices:
|
||||||
- /dev/sr0:/dev/sr0
|
# Host-Geräteknoten via .env (OPTICAL_SR/OPTICAL_SG) — je Rechner ANDERS!
|
||||||
# MakeMKV spricht Laufwerke über die SCSI-Generic-Schicht an — ohne den
|
# MakeMKV spricht Laufwerke über die SCSI-Generic-Schicht an; ohne den
|
||||||
# sg-Knoten findet es „keine usable optical drives" (Deploy-Befund 23.07.).
|
# passenden sg-Knoten findet es „keine usable optical drives". Den sg-Knoten
|
||||||
# In dieser VM: sg0 = Systemplatte, sg1 = das BD-Laufwerk. Achtung: nach
|
# des Laufwerks auf DIESEM Host ermitteln (lsscsi -g) und in die .env eintragen.
|
||||||
# einem USB-Reconnect zur Laufzeit kann die sg-Nummer wandern → Container
|
# Achtung: nach USB-Reconnect zur Laufzeit kann die sg-Nummer wandern →
|
||||||
# neu starten.
|
# Container neu starten. Defaults (sr0/sg1) passen für den Ursprungs-Host.
|
||||||
- /dev/sg1:/dev/sg1
|
- ${OPTICAL_SR:-/dev/sr0}:/dev/sr0
|
||||||
|
- ${OPTICAL_SG:-/dev/sg1}:/dev/sg1
|
||||||
networks:
|
networks:
|
||||||
- rippy-net
|
- rippy-net
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -132,7 +157,7 @@ services:
|
|||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_USER=rippy
|
- POSTGRES_USER=rippy
|
||||||
- POSTGRES_PASSWORD=rippy
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-rippy}
|
||||||
- POSTGRES_DB=rippy
|
- POSTGRES_DB=rippy
|
||||||
# Veröffentlicht (Befund 24.07.): Remote-Worker (GPU-Maschine, künftiger
|
# Veröffentlicht (Befund 24.07.): Remote-Worker (GPU-Maschine, künftiger
|
||||||
# Windows-Worker) verbinden sich über <rippy-host>:5432/6379 — ohne
|
# Windows-Worker) verbinden sich über <rippy-host>:5432/6379 — ohne
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ def validate_config() -> Settings:
|
|||||||
try:
|
try:
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
||||||
# TMDB API Key ist Pflicht (gemäß KONZEPT.md)
|
# TMDB-Key ist EMPFOHLEN, nicht zwingend: main.py fängt diesen Fehler ab und
|
||||||
|
# gibt nur eine Warnung aus — die API startet auch ohne Key, und der Key lässt
|
||||||
|
# sich jederzeit im UI (Einstellungen → System) nachtragen.
|
||||||
if not settings.tmdb_api_key:
|
if not settings.tmdb_api_key:
|
||||||
raise ConfigValidationError(
|
raise ConfigValidationError(
|
||||||
"TMDB_API_KEY ist erforderlich für Metadaten-Lookup.\n"
|
"TMDB_API_KEY ist nicht gesetzt (empfohlen für Metadaten-Lookup).\n"
|
||||||
"Hole dir einen免费en Key auf https://www.themoviedb.org/\n"
|
"Kostenlosen Key auf https://www.themoviedb.org/ holen und als\n"
|
||||||
"Setze ihn als Umgebungsvariable: TMDB_API_KEY=dein_key"
|
"Umgebungsvariable TMDB_API_KEY setzen — oder später im UI eintragen."
|
||||||
)
|
)
|
||||||
|
|
||||||
return settings
|
return settings
|
||||||
|
|||||||
@@ -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
|
|
||||||
+329
-137
@@ -1,17 +1,18 @@
|
|||||||
from fastapi import FastAPI, HTTPException, Request, Response
|
from fastapi import FastAPI, HTTPException, Request, Response
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse, StreamingResponse
|
from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import List, Optional, Dict
|
from typing import List, Optional, Dict
|
||||||
from pathlib import Path
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import db
|
import db
|
||||||
import devices as device_discovery
|
import devices as device_discovery
|
||||||
|
import makemkv_daten
|
||||||
import makemkv_key
|
import makemkv_key
|
||||||
import mounts as mount_verwaltung
|
import mounts as mount_verwaltung
|
||||||
import notify
|
import notify
|
||||||
@@ -22,8 +23,6 @@ from config_validation import validate_config, ConfigValidationError
|
|||||||
from cache import init_cache
|
from cache import init_cache
|
||||||
from ratelimit import check_rate_limit, get_rate_limit_remaining
|
from ratelimit import check_rate_limit, get_rate_limit_remaining
|
||||||
from prescan import PreScan
|
from prescan import PreScan
|
||||||
from nfo_generator import NFOGenerator
|
|
||||||
from image_downloader import ImageDownloader
|
|
||||||
|
|
||||||
# Auth (JWT/Login/API-Keys) KOMPLETT entfernt — Commander-Entscheid 24.07.2026:
|
# Auth (JWT/Login/API-Keys) KOMPLETT entfernt — Commander-Entscheid 24.07.2026:
|
||||||
# Rippy läuft ausschließlich im Heimnetz, die Endpoints schützten ohnehin
|
# Rippy läuft ausschließlich im Heimnetz, die Endpoints schützten ohnehin
|
||||||
@@ -47,17 +46,25 @@ async def startup_event():
|
|||||||
except ConfigValidationError as e:
|
except ConfigValidationError as e:
|
||||||
print(f"⚠️ Konfigurations-Warnung: {e}")
|
print(f"⚠️ Konfigurations-Warnung: {e}")
|
||||||
|
|
||||||
# Gespeicherte Netzwerk-Speicherziele wiederherstellen
|
# Gespeicherte Netzwerk-Speicherziele wiederherstellen — NICHT-BLOCKIEREND.
|
||||||
|
# Ein zickiger/langsamer Netz-Mount (der CIFS-Schreibtest in mounten() kann im
|
||||||
|
# Kernel haengen, wait_for_response) darf den API-Start NIE blockieren. Vorfall
|
||||||
|
# 24.07.: ~5 min "Waiting for application startup", kein Endpoint bedient, bis der
|
||||||
|
# soft-Mount per Timeout abbrach. Darum im Hintergrund: die Mounts stellen sich
|
||||||
|
# her, sobald der Server antwortet, ohne die API auszubremsen.
|
||||||
def remount():
|
def remount():
|
||||||
for meldung in mount_verwaltung.alle_remounten():
|
for meldung in mount_verwaltung.alle_remounten():
|
||||||
db.add_log("info", "mounts", meldung)
|
db.add_log("info", "mounts", meldung)
|
||||||
await asyncio.to_thread(remount)
|
asyncio.create_task(asyncio.to_thread(remount))
|
||||||
|
|
||||||
asyncio.create_task(disc_watcher())
|
asyncio.create_task(disc_watcher())
|
||||||
# MakeMKV-Beta-Key automatisch aktuell halten (wechselt ~monatlich, laeuft zum
|
# MakeMKV-Beta-Key automatisch aktuell halten (wechselt ~monatlich, läuft zum
|
||||||
# Monatsende ab) — sonst blockt Blu-ray-Ripping irgendwann still. Taeglicher
|
# Monatsende ab) — sonst blockt Blu-ray-Ripping irgendwann still. Taeglicher
|
||||||
# Forum-Abgleich; wirkt ohne Rebuild ab dem naechsten Rip.
|
# Forum-Abgleich; wirkt ohne Rebuild ab dem nächsten Rip.
|
||||||
asyncio.create_task(makemkv_key.refresh_loop())
|
asyncio.create_task(makemkv_key.refresh_loop())
|
||||||
|
# Worker-Erreichbarkeit im Hintergrund pingen (siehe _ping_knoten) — sonst
|
||||||
|
# kostet JEDER Aufruf von /capabilities eine ganze Sekunde.
|
||||||
|
asyncio.create_task(_ping_schleife())
|
||||||
|
|
||||||
|
|
||||||
# Auto-Pre-Scan-Ergebnisse je Laufwerk: das Dashboard zeigt damit sofort,
|
# Auto-Pre-Scan-Ergebnisse je Laufwerk: das Dashboard zeigt damit sofort,
|
||||||
@@ -149,7 +156,7 @@ async def _auto_rip_wenn_aktiviert(pfad: str):
|
|||||||
else:
|
else:
|
||||||
unterordner = einstellungen.get("movieDir") or "movies"
|
unterordner = einstellungen.get("movieDir") or "movies"
|
||||||
ziel = os.path.normpath(os.path.join(basis, unterordner))
|
ziel = os.path.normpath(os.path.join(basis, unterordner))
|
||||||
if not ziel.startswith(MEDIA_ROOT):
|
if not unter_wurzel(ziel, MEDIA_ROOT):
|
||||||
ziel = None
|
ziel = None
|
||||||
|
|
||||||
job_id = str(uuid.uuid4())
|
job_id = str(uuid.uuid4())
|
||||||
@@ -245,6 +252,7 @@ class Job(BaseModel):
|
|||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
can_retry: bool = False # Rohdaten vorhanden → „Neu komprimieren" sinnvoll
|
can_retry: bool = False # Rohdaten vorhanden → „Neu komprimieren" sinnvoll
|
||||||
|
meta: Optional[Dict] = None # Disc-Metadaten (Poster/Jahr/Plot) — fürs Thumbnail in der Jobliste + aktivem Rip-Header
|
||||||
|
|
||||||
class Device(BaseModel):
|
class Device(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
@@ -260,6 +268,13 @@ class Device(BaseModel):
|
|||||||
def _job_row_to_model(zeile: dict) -> Job:
|
def _job_row_to_model(zeile: dict) -> Job:
|
||||||
"""DB-Zeile → UI-Form (Worker-Status 'running' heißt im UI 'processing')."""
|
"""DB-Zeile → UI-Form (Worker-Status 'running' heißt im UI 'processing')."""
|
||||||
status_map = {"running": "processing"}
|
status_map = {"running": "processing"}
|
||||||
|
# meta (JSON-Text) enthält u. a. poster_path — die UI baut daraus das Thumbnail.
|
||||||
|
# Muss hier mit ins Job-Model, sonst schneidet FastAPIs response_model es weg
|
||||||
|
# (Befund 25.07.: meta kam nie in der Jobliste an → Filmstreifen-Platzhalter).
|
||||||
|
try:
|
||||||
|
meta = json.loads(zeile["meta"]) if zeile.get("meta") else None
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
meta = None
|
||||||
return Job(
|
return Job(
|
||||||
id=zeile["id"],
|
id=zeile["id"],
|
||||||
type=zeile.get("disc_type") or "unknown",
|
type=zeile.get("disc_type") or "unknown",
|
||||||
@@ -270,6 +285,7 @@ def _job_row_to_model(zeile: dict) -> Job:
|
|||||||
progress=zeile.get("progress") or 0,
|
progress=zeile.get("progress") or 0,
|
||||||
title=zeile.get("title"),
|
title=zeile.get("title"),
|
||||||
error=zeile.get("error"),
|
error=zeile.get("error"),
|
||||||
|
meta=meta,
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
@@ -293,7 +309,7 @@ def _kann_neu_komprimieren(job: dict, work_dir: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
if os.path.isdir(os.path.join("/app/temp/raw", job["id"])):
|
if os.path.isdir(os.path.join("/app/temp/raw", job["id"])):
|
||||||
return True
|
return True
|
||||||
return work_dir.startswith(MEDIA_ROOT) and os.path.isdir(os.path.join(work_dir, job["id"]))
|
return unter_wurzel(work_dir, MEDIA_ROOT) and os.path.isdir(os.path.join(work_dir, job["id"]))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/jobs", response_model=List[Job])
|
@app.get("/jobs", response_model=List[Job])
|
||||||
@@ -344,6 +360,10 @@ class JobCreateRequest(BaseModel):
|
|||||||
main_feature_only: Optional[bool] = None # pro Rip; None = Setting gilt
|
main_feature_only: Optional[bool] = None # pro Rip; None = Setting gilt
|
||||||
titles: Optional[List[int]] = None # exakte Titel-Auswahl (Track-Tabelle)
|
titles: Optional[List[int]] = None # exakte Titel-Auswahl (Track-Tabelle)
|
||||||
transcode_node: Optional[str] = None # gewählter Encoder-Worker (Celery-Node)
|
transcode_node: Optional[str] = None # gewählter Encoder-Worker (Celery-Node)
|
||||||
|
# Arbeitsverzeichnis NUR für diesen Rip (Commander-Wunsch 25.07.2026:
|
||||||
|
# beim Start wählbar, nicht global vorgegeben). Leer = der Wert aus
|
||||||
|
# Einstellungen → Verarbeitung, der auch für Vollautomatik-Rips gilt.
|
||||||
|
work_dir: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
MEDIA_ROOT = "/app/media"
|
MEDIA_ROOT = "/app/media"
|
||||||
@@ -354,7 +374,7 @@ def _validiere_ziel(target_dir: Optional[str]) -> Optional[str]:
|
|||||||
if not target_dir:
|
if not target_dir:
|
||||||
return None
|
return None
|
||||||
normalisiert = os.path.normpath(target_dir)
|
normalisiert = os.path.normpath(target_dir)
|
||||||
if not normalisiert.startswith(MEDIA_ROOT):
|
if not unter_wurzel(normalisiert, MEDIA_ROOT):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=422,
|
status_code=422,
|
||||||
detail=f"Ziel muss unter {MEDIA_ROOT} liegen (Shares dort einhängen)",
|
detail=f"Ziel muss unter {MEDIA_ROOT} liegen (Shares dort einhängen)",
|
||||||
@@ -403,6 +423,12 @@ async def create_job(request: JobCreateRequest):
|
|||||||
meta_dict["titles"] = titel_liste
|
meta_dict["titles"] = titel_liste
|
||||||
if request.transcode_node:
|
if request.transcode_node:
|
||||||
meta_dict["transcode_node"] = request.transcode_node
|
meta_dict["transcode_node"] = request.transcode_node
|
||||||
|
# Arbeitsverzeichnis dieses Rips. Dieselbe Pfad-Härte wie beim Ziel: muss
|
||||||
|
# unter /app/media liegen, damit man nicht versehentlich 100 GB Rohdaten
|
||||||
|
# irgendwohin in den Container schreibt.
|
||||||
|
arbeits_dir = _validiere_ziel(request.work_dir)
|
||||||
|
if arbeits_dir:
|
||||||
|
meta_dict["work_dir"] = arbeits_dir
|
||||||
meta_json = json.dumps(meta_dict) if meta_dict else None
|
meta_json = json.dumps(meta_dict) if meta_dict else None
|
||||||
|
|
||||||
job_id = str(uuid.uuid4())
|
job_id = str(uuid.uuid4())
|
||||||
@@ -437,10 +463,24 @@ def _sicherer_dateiname(name: str) -> bool:
|
|||||||
return bool(name) and "/" not in name and "\\" not in name and not name.startswith(".")
|
return bool(name) and "/" not in name and "\\" not in name and not name.startswith(".")
|
||||||
|
|
||||||
|
|
||||||
|
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 hier nicht:
|
||||||
|
„/app/media-boese/x" beginnt mit „/app/media", liegt aber außerhalb
|
||||||
|
(Befund 25.07.2026 bei der Durchsicht). Deshalb Gleichheit ODER Wurzel
|
||||||
|
samt Trennzeichen. Erwartet werden normalisierte Container-Pfade mit „/".
|
||||||
|
"""
|
||||||
|
if not pfad or not wurzel:
|
||||||
|
return False
|
||||||
|
sauber = wurzel.rstrip("/") or "/"
|
||||||
|
return pfad == sauber or pfad.startswith(sauber + "/")
|
||||||
|
|
||||||
|
|
||||||
def _job_ausgabeordner(job: dict) -> str:
|
def _job_ausgabeordner(job: dict) -> str:
|
||||||
"""Validierter Ausgabeordner eines Jobs — strikt unter /app/media."""
|
"""Validierter Ausgabeordner eines Jobs — strikt unter /app/media."""
|
||||||
ausgabe = os.path.normpath(job.get("output_path") or "")
|
ausgabe = os.path.normpath(job.get("output_path") or "")
|
||||||
if not ausgabe.startswith(MEDIA_ROOT):
|
if not unter_wurzel(ausgabe, MEDIA_ROOT):
|
||||||
raise HTTPException(status_code=404, detail="Job hat keinen Ausgabeordner unter /app/media")
|
raise HTTPException(status_code=404, detail="Job hat keinen Ausgabeordner unter /app/media")
|
||||||
return ausgabe
|
return ausgabe
|
||||||
|
|
||||||
@@ -497,7 +537,7 @@ async def download_job_file(job_id: str, dateiname: str):
|
|||||||
pfad = os.path.join(ausgabe, dateiname)
|
pfad = os.path.join(ausgabe, dateiname)
|
||||||
|
|
||||||
def pruefe():
|
def pruefe():
|
||||||
return os.path.isfile(pfad) and os.path.realpath(pfad).startswith(MEDIA_ROOT)
|
return os.path.isfile(pfad) and unter_wurzel(os.path.realpath(pfad), MEDIA_ROOT)
|
||||||
|
|
||||||
if not await asyncio.to_thread(pruefe):
|
if not await asyncio.to_thread(pruefe):
|
||||||
raise HTTPException(status_code=404, detail="Datei nicht gefunden")
|
raise HTTPException(status_code=404, detail="Datei nicht gefunden")
|
||||||
@@ -608,7 +648,7 @@ async def retry_transcode(job_id: str):
|
|||||||
# (Einstellungen → Verarbeitung), sonst Container-Default /app/temp/raw.
|
# (Einstellungen → Verarbeitung), sonst Container-Default /app/temp/raw.
|
||||||
einstellungen = await asyncio.to_thread(db.get_settings)
|
einstellungen = await asyncio.to_thread(db.get_settings)
|
||||||
work_dir = os.path.normpath((einstellungen.get("workDir") or "").strip() or "/")
|
work_dir = os.path.normpath((einstellungen.get("workDir") or "").strip() or "/")
|
||||||
raw_basis = work_dir if work_dir.startswith(MEDIA_ROOT) else "/app/temp/raw"
|
raw_basis = work_dir if unter_wurzel(work_dir, MEDIA_ROOT) else "/app/temp/raw"
|
||||||
raw_dir = f"{raw_basis}/{job_id}"
|
raw_dir = f"{raw_basis}/{job_id}"
|
||||||
# Zielordner: der Worker schreibt das geplante Ziel beim Rip-Start nach
|
# Zielordner: der Worker schreibt das geplante Ziel beim Rip-Start nach
|
||||||
# output_path (sprechender Name statt UUID) — alter Fallback bleibt.
|
# output_path (sprechender Name statt UUID) — alter Fallback bleibt.
|
||||||
@@ -649,6 +689,55 @@ async def cancel_job(job_id: str):
|
|||||||
return {"id": job_id, "status": "canceling"}
|
return {"id": job_id, "status": "canceling"}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Worker-Erreichbarkeit: gepingt wird im Hintergrund, nicht im Request ---
|
||||||
|
#
|
||||||
|
# Befund 25.07.2026 (gemessen): /capabilities brauchte **1,010 s** — und zwar
|
||||||
|
# jedes Mal. Ursache ist kein Fehler, sondern das Wesen des Celery-Pings: er
|
||||||
|
# sammelt Antworten bis zum Timeout und kann nicht früher aufhören, weil er
|
||||||
|
# nicht weiß, wie viele Worker noch antworten wollen. Fünf UI-Stellen holen
|
||||||
|
# /capabilities (Dashboard, Einstellungen, Worker-Tab, Wizard, Rip-Dialog) —
|
||||||
|
# jede Seite zahlte also eine Sekunde, obwohl alle anderen Endpunkte unter
|
||||||
|
# 25 ms liegen. Genau das war das „Laggen".
|
||||||
|
#
|
||||||
|
# Jetzt pingt ein Hintergrund-Lauf im festen Takt, und der Endpunkt liest nur
|
||||||
|
# ab. Ist der Vorrat älter als PING_ALTER_MAX (Lauf noch nicht angelaufen oder
|
||||||
|
# gestorben), wird EINMAL synchron gepingt und der Vorrat wieder gefüllt —
|
||||||
|
# lieber eine langsame Antwort als eine falsche.
|
||||||
|
_PING = {"knoten": [], "stand": -1e9}
|
||||||
|
PING_INTERVALL_SEKUNDEN = 5
|
||||||
|
PING_ALTER_MAX_SEKUNDEN = 30
|
||||||
|
|
||||||
|
|
||||||
|
def _ping_jetzt() -> list:
|
||||||
|
"""Pingt sofort (blockiert ~1 s) und füllt den Vorrat."""
|
||||||
|
try:
|
||||||
|
antworten = celery_client.control.ping(timeout=1.0) or []
|
||||||
|
knoten = [k for antwort in antworten for k in antwort.keys()]
|
||||||
|
except Exception:
|
||||||
|
knoten = []
|
||||||
|
_PING["knoten"] = knoten
|
||||||
|
_PING["stand"] = time.monotonic()
|
||||||
|
return knoten
|
||||||
|
|
||||||
|
|
||||||
|
def _ping_knoten() -> list:
|
||||||
|
"""Erreichbare Celery-Knoten aus dem Vorrat — ohne zu warten."""
|
||||||
|
if time.monotonic() - _PING["stand"] <= PING_ALTER_MAX_SEKUNDEN:
|
||||||
|
return _PING["knoten"]
|
||||||
|
return _ping_jetzt()
|
||||||
|
|
||||||
|
|
||||||
|
async def _ping_schleife():
|
||||||
|
"""Hält den Ping-Vorrat frisch. Darf nie sterben, sonst wird jeder
|
||||||
|
/capabilities-Aufruf wieder langsam."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(_ping_jetzt)
|
||||||
|
except Exception: # Broker weg → beim nächsten Durchlauf erneut
|
||||||
|
pass
|
||||||
|
await asyncio.sleep(PING_INTERVALL_SEKUNDEN)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/capabilities")
|
@app.get("/capabilities")
|
||||||
async def capabilities():
|
async def capabilities():
|
||||||
"""Welche Encoder sind auf welchen Workern WIRKLICH verfügbar — inkl.
|
"""Welche Encoder sind auf welchen Workern WIRKLICH verfügbar — inkl.
|
||||||
@@ -659,11 +748,7 @@ async def capabilities():
|
|||||||
"""
|
"""
|
||||||
def sammle():
|
def sammle():
|
||||||
zeilen = db.list_workers()
|
zeilen = db.list_workers()
|
||||||
try:
|
ping_knoten = _ping_knoten()
|
||||||
antworten = celery_client.control.ping(timeout=1.0) or []
|
|
||||||
ping_knoten = [k for antwort in antworten for k in antwort.keys()]
|
|
||||||
except Exception:
|
|
||||||
ping_knoten = []
|
|
||||||
for zeile in zeilen:
|
for zeile in zeilen:
|
||||||
# Celery-Knotenname = <name>@<hostname>. Der Ping liefert ihn voll;
|
# Celery-Knotenname = <name>@<hostname>. Der Ping liefert ihn voll;
|
||||||
# gematcht wird über den Hostname (der Docker-Worker heißt celery@…,
|
# gematcht wird über den Hostname (der Docker-Worker heißt celery@…,
|
||||||
@@ -963,7 +1048,7 @@ async def delete_storage_mount(name: str):
|
|||||||
async def browse(path: str = MEDIA_ROOT):
|
async def browse(path: str = MEDIA_ROOT):
|
||||||
"""Server-seitiger Ordner-Browser für die Ziel-Auswahl (nur unter /app/media)."""
|
"""Server-seitiger Ordner-Browser für die Ziel-Auswahl (nur unter /app/media)."""
|
||||||
normalisiert = os.path.normpath(path)
|
normalisiert = os.path.normpath(path)
|
||||||
if not normalisiert.startswith(MEDIA_ROOT):
|
if not unter_wurzel(normalisiert, MEDIA_ROOT):
|
||||||
raise HTTPException(status_code=422, detail=f"Nur Pfade unter {MEDIA_ROOT}")
|
raise HTTPException(status_code=422, detail=f"Nur Pfade unter {MEDIA_ROOT}")
|
||||||
|
|
||||||
def liste():
|
def liste():
|
||||||
@@ -1004,7 +1089,7 @@ class MkdirRequest(BaseModel):
|
|||||||
async def browse_mkdir(request: MkdirRequest):
|
async def browse_mkdir(request: MkdirRequest):
|
||||||
"""Neuen Ordner unter /app/media anlegen (Speicherziele-Verwaltung)."""
|
"""Neuen Ordner unter /app/media anlegen (Speicherziele-Verwaltung)."""
|
||||||
basis = os.path.normpath(request.path)
|
basis = os.path.normpath(request.path)
|
||||||
if not basis.startswith(MEDIA_ROOT):
|
if not unter_wurzel(basis, MEDIA_ROOT):
|
||||||
raise HTTPException(status_code=422, detail=f"Nur Pfade unter {MEDIA_ROOT}")
|
raise HTTPException(status_code=422, detail=f"Nur Pfade unter {MEDIA_ROOT}")
|
||||||
name = request.name.strip()
|
name = request.name.strip()
|
||||||
if not name or "/" in name or "\\" in name or name.startswith("."):
|
if not name or "/" in name or "\\" in name or name.startswith("."):
|
||||||
@@ -1094,14 +1179,10 @@ async def worker_setup_windows():
|
|||||||
return FileResponse(pfad, media_type="text/plain", filename="install-rippy-worker.ps1")
|
return FileResponse(pfad, media_type="text/plain", filename="install-rippy-worker.ps1")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/worker-setup/windows-gui")
|
# GET /worker-setup/windows-gui entfernt am 25.07.2026: ohne Aufrufer, seit die
|
||||||
async def worker_setup_windows_gui():
|
# .exe den .bat-Umweg ersetzt hat (v3.9 — .vbs/.bat wird als gefährlich
|
||||||
"""Grafischer Windows-Installer (WinForms, PowerShell-Quelle) — Rückfall
|
# geflaggt, Commander-Einwand). Die Datei install-gui.ps1 selbst lebt weiter,
|
||||||
für Fortgeschrittene; der Normalweg ist die .exe unten."""
|
# sie steckt in der .exe; nur diese Route war verwaist.
|
||||||
pfad = "worker_dist/install-gui.ps1"
|
|
||||||
if not os.path.isfile(pfad):
|
|
||||||
raise HTTPException(status_code=404, detail="GUI-Installer nicht im Image — API neu bauen")
|
|
||||||
return FileResponse(pfad, media_type="text/plain", filename="rippy-worker-gui.ps1")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/worker-setup/windows-exe")
|
@app.get("/worker-setup/windows-exe")
|
||||||
@@ -1176,6 +1257,203 @@ async def system_info():
|
|||||||
return await asyncio.to_thread(sammle)
|
return await asyncio.to_thread(sammle)
|
||||||
|
|
||||||
|
|
||||||
|
# Eigene Wurzel für die Datei-Härtung der AACS-Dumps. Bewusst NICHT die
|
||||||
|
# MEDIA_ROOT-Helfer (_sicherer_dateiname/_validiere_ziel/_job_ausgabeordner):
|
||||||
|
# die prüfen hart gegen /app/media und würden hier IMMER 404 liefern.
|
||||||
|
# Das MakeMKV-Datenverzeichnis liegt woanders (in der API auf
|
||||||
|
# /app/makemkv-data, im Worker auf /root/.MakeMKV — laut docker-compose.yml
|
||||||
|
# beides dasselbe Host-Verzeichnis).
|
||||||
|
MAKEMKV_DATA_ROOT = os.path.realpath(makemkv_daten.DATEN_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
class KeydbRequest(BaseModel):
|
||||||
|
inhalt: str # voller Text der KEYDB.cfg (kein Upload — es gibt kein python-multipart)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/system/keydb")
|
||||||
|
async def get_keydb_status():
|
||||||
|
"""Was liegt gerade als KEYDB.cfg im MakeMKV-Datenverzeichnis?
|
||||||
|
|
||||||
|
Hintergrund (Befund 25.07.2026, live auf der VM nachgemessen): Bei
|
||||||
|
4K-UHD-Discs meldet MakeMKV "The volume key is unknown for this disc" und
|
||||||
|
holt den Schlüssel NICHT mehr online nach — die dokumentierten
|
||||||
|
Schlüssel-Server lösen weltweit nicht mehr auf. Der einzige heute
|
||||||
|
funktionierende Weg ist eine KEYDB.cfg, die der Nutzer selbst mitbringt.
|
||||||
|
Rippy liefert KEINE Schlüssel mit, lädt keine herunter und verteilt keine —
|
||||||
|
es stellt nur den Platz bereit und zeigt ehrlich an, was dort liegt.
|
||||||
|
|
||||||
|
Fehlendes Verzeichnis oder fehlende Datei ist der NORMALFALL: dann kommt
|
||||||
|
200 mit vorhanden=false zurück, niemals 404 oder 500.
|
||||||
|
"""
|
||||||
|
def sammle():
|
||||||
|
return makemkv_daten.keydb_status()
|
||||||
|
|
||||||
|
return await asyncio.to_thread(sammle)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/system/keydb")
|
||||||
|
async def set_keydb(request: KeydbRequest):
|
||||||
|
"""Legt die vom Nutzer mitgebrachte KEYDB.cfg ab (atomar, ersetzt die alte).
|
||||||
|
|
||||||
|
WICHTIG für die Ehrlichkeit: Die Datei wirkt erst beim NÄCHSTEN Rip —
|
||||||
|
makemkvcon liest sie beim Prozessstart, ein bereits laufender Rip merkt
|
||||||
|
nichts davon. Genau so steht es auch im Log-Eintrag.
|
||||||
|
"""
|
||||||
|
# Reine Prüfung (kein Dateisystem) — fängt den häufigsten Bedienfehler ab:
|
||||||
|
# statt der KEYDB.cfg landet die HTML-Fehlerseite eines Downloads im Feld.
|
||||||
|
fehler = makemkv_daten.keydb_pruefen(request.inhalt)
|
||||||
|
if fehler:
|
||||||
|
raise HTTPException(status_code=422, detail=fehler)
|
||||||
|
|
||||||
|
def schreibe():
|
||||||
|
return makemkv_daten.keydb_schreiben(request.inhalt)
|
||||||
|
|
||||||
|
try:
|
||||||
|
status = await asyncio.to_thread(schreibe)
|
||||||
|
except OSError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=(
|
||||||
|
f"KEYDB.cfg konnte nicht geschrieben werden: {e}. "
|
||||||
|
"Prüfe, ob das MakeMKV-Datenverzeichnis auf der VM existiert und "
|
||||||
|
"beschreibbar ist (Standard: /srv/rippy/makemkv)."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await asyncio.to_thread(
|
||||||
|
db.add_log, "success", "makemkv-keydb",
|
||||||
|
f"KEYDB.cfg abgelegt: {status['eintraege']} Zeilen mit Disc-Kennung, "
|
||||||
|
f"{status['groesse_bytes']} Bytes ({status['pfad']}). "
|
||||||
|
"Wirkt erst beim NÄCHSTEN Rip — MakeMKV liest die Datei beim Start.",
|
||||||
|
)
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/system/keydb")
|
||||||
|
async def delete_keydb():
|
||||||
|
"""Entfernt die KEYDB.cfg (z. B. nach einem Fehlgriff beim Einfügen).
|
||||||
|
|
||||||
|
Auch hier gilt: Die Änderung wirkt erst beim NÄCHSTEN Rip. Fehlt die Datei
|
||||||
|
schon, ist das kein Fehler — es kommt derselbe Zustand mit vorhanden=false.
|
||||||
|
"""
|
||||||
|
def loesche():
|
||||||
|
return makemkv_daten.keydb_loeschen()
|
||||||
|
|
||||||
|
try:
|
||||||
|
status = await asyncio.to_thread(loesche)
|
||||||
|
except OSError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"KEYDB.cfg konnte nicht entfernt werden: {e}",
|
||||||
|
)
|
||||||
|
await asyncio.to_thread(
|
||||||
|
db.add_log, "warning", "makemkv-keydb",
|
||||||
|
"KEYDB.cfg entfernt. Ab dem NÄCHSTEN Rip fehlen die selbst mitgebrachten "
|
||||||
|
"Schlüssel wieder — UHD-Discs können dann erneut an "
|
||||||
|
"'The volume key is unknown for this disc' scheitern.",
|
||||||
|
)
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/system/keystore")
|
||||||
|
async def get_keystore():
|
||||||
|
"""Wie viele Disc-Schlüssel kennt diese Rippy-Installation?
|
||||||
|
|
||||||
|
Der Schlüsselspeicher (_private_data.tar) ist MakeMKVs eigener Vorrat.
|
||||||
|
Unter Windows füllt MakeMKV ihn selbst; unter Linux nie — deshalb muss er
|
||||||
|
hier von Hand hereingereicht werden (Befund 25.07.2026, siehe
|
||||||
|
makemkv_daten.py). Fehlt er, ist das der Normalfall: 200 mit
|
||||||
|
vorhanden=false, nie 404.
|
||||||
|
"""
|
||||||
|
def sammle():
|
||||||
|
return makemkv_daten.schluesselspeicher_status()
|
||||||
|
|
||||||
|
return await asyncio.to_thread(sammle)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/system/keystore")
|
||||||
|
async def set_keystore(request: Request):
|
||||||
|
"""Nimmt den Schlüsselspeicher einer MakeMKV-Installation entgegen.
|
||||||
|
|
||||||
|
Der Rohkörper der Anfrage IST die Datei — bewusst kein Multipart-Upload
|
||||||
|
(python-multipart fehlt) und bewusst kein JSON: _private_data.tar ist
|
||||||
|
binär, und Base64 würde sie nur unnötig aufblähen.
|
||||||
|
|
||||||
|
Wirkt ab dem NÄCHSTEN Rip: makemkvcon liest den Speicher beim Start.
|
||||||
|
"""
|
||||||
|
rohdaten = await request.body()
|
||||||
|
|
||||||
|
def pruefe_und_schreibe():
|
||||||
|
# Prüfung liest ein mehrere MB großes tar — gehört deshalb mit in
|
||||||
|
# den Thread und nicht in die Ereignisschleife.
|
||||||
|
fehler = makemkv_daten.private_data_pruefen(rohdaten)
|
||||||
|
if fehler:
|
||||||
|
return fehler, None
|
||||||
|
return "", makemkv_daten.private_data_schreiben(rohdaten)
|
||||||
|
|
||||||
|
try:
|
||||||
|
fehler, status = await asyncio.to_thread(pruefe_und_schreibe)
|
||||||
|
except OSError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=(
|
||||||
|
f"Schlüsselspeicher konnte nicht geschrieben werden: {e}. "
|
||||||
|
"Prüfe, ob das MakeMKV-Datenverzeichnis auf der VM existiert "
|
||||||
|
"und beschreibbar ist (Standard: /srv/rippy/makemkv)."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if fehler:
|
||||||
|
raise HTTPException(status_code=422, detail=fehler)
|
||||||
|
await asyncio.to_thread(
|
||||||
|
db.add_log, "success", "makemkv-keydb",
|
||||||
|
f"Schlüsselspeicher übernommen: {status['schluessel']} Disc-Schlüssel, "
|
||||||
|
f"{status['groesse_bytes']} Bytes. Wirkt ab dem NÄCHSTEN Rip.",
|
||||||
|
)
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/system/aacs-dumps")
|
||||||
|
async def get_aacs_dumps():
|
||||||
|
"""AACS-Dumps, die MakeMKV selbst abgelegt hat (neueste zuerst).
|
||||||
|
|
||||||
|
MakeMKV schreibt sie beim gescheiterten UHD-Versuch ins Datenverzeichnis
|
||||||
|
(Meldung 3332 "Saved AACS dump file as file:///root/.MakeMKV/<name>.tgz",
|
||||||
|
am 25.07.2026 so beobachtet). Rippy wertet sie nicht aus und schickt sie
|
||||||
|
nirgendwohin — es zeigt nur, dass sie da sind, damit der Nutzer selbst
|
||||||
|
entscheiden kann, was er damit tut.
|
||||||
|
"""
|
||||||
|
def liste():
|
||||||
|
return {"dumps": makemkv_daten.dumps_auflisten()}
|
||||||
|
|
||||||
|
return await asyncio.to_thread(liste)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/system/aacs-dumps/{dateiname}")
|
||||||
|
async def download_aacs_dump(dateiname: str):
|
||||||
|
"""Lädt EINEN AACS-Dump herunter.
|
||||||
|
|
||||||
|
Pfad-Validierung genauso streng wie beim Job-Datei-Download: nackter Name
|
||||||
|
ohne Pfadtrenner und ohne führenden Punkt (ist_aacs_dump) PLUS realpath,
|
||||||
|
der das MakeMKV-Datenverzeichnis nicht verlassen darf (kein ..-Ausbruch,
|
||||||
|
kein Symlink nach draußen).
|
||||||
|
"""
|
||||||
|
if not makemkv_daten.ist_aacs_dump(dateiname):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="Kein gültiger Dump-Name — erwartet wird eine .tgz-Datei ohne Pfadangabe.",
|
||||||
|
)
|
||||||
|
pfad = os.path.join(MAKEMKV_DATA_ROOT, dateiname)
|
||||||
|
|
||||||
|
def pruefe():
|
||||||
|
return os.path.isfile(pfad) and unter_wurzel(os.path.realpath(pfad), MAKEMKV_DATA_ROOT)
|
||||||
|
|
||||||
|
if not await asyncio.to_thread(pruefe):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="Dump nicht gefunden — MakeMKV legt ihn erst beim gescheiterten UHD-Versuch an.",
|
||||||
|
)
|
||||||
|
return FileResponse(pfad, filename=dateiname, media_type="application/gzip")
|
||||||
|
|
||||||
|
|
||||||
class NotificationTestRequest(BaseModel):
|
class NotificationTestRequest(BaseModel):
|
||||||
url: str
|
url: str
|
||||||
|
|
||||||
@@ -1264,21 +1542,13 @@ async def get_devices():
|
|||||||
return geraete
|
return geraete
|
||||||
|
|
||||||
|
|
||||||
# SSE-Stream für Echtzeit-Updates
|
# GET /stream/jobs (SSE) entfernt am 25.07.2026. Der Stream war zweimal falsch:
|
||||||
@app.get("/stream/jobs")
|
# erst ein Placebo (er sendete nur, wenn eine Liste `sse_connections` gefüllt
|
||||||
async def job_stream():
|
# war, und nichts füllte sie je), dann am 23.07. funktionsfähig gemacht — aber
|
||||||
"""SSE-Stream für Job-Updates.
|
# einen Verbraucher hat er nie bekommen. Im UI gibt es kein `EventSource`; das
|
||||||
|
# Dashboard holt die Jobs mit `setInterval(loadData, 4000)`. Damit war er keine
|
||||||
Fix 23.07.: Der alte Generator sendete nur, wenn `sse_connections` gefüllt
|
# harmlose Leiche, sondern eine Endlosschleife je Verbindung, die jeder im
|
||||||
war — aber NICHTS hat diese Liste je befüllt. Der Stream war ein Placebo.
|
# Heimnetz aufmachen konnte. Wer echtes Push will, braucht BEIDE Seiten.
|
||||||
"""
|
|
||||||
async def event_generator():
|
|
||||||
while True:
|
|
||||||
jobs = await get_jobs()
|
|
||||||
yield f"data: {json.dumps([j.dict() for j in jobs])}\n\n"
|
|
||||||
await asyncio.sleep(2)
|
|
||||||
|
|
||||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
|
||||||
|
|
||||||
|
|
||||||
# /metadata/lookup + /metadata/confirm entfernt (24.07., mit der
|
# /metadata/lookup + /metadata/confirm entfernt (24.07., mit der
|
||||||
@@ -1391,99 +1661,21 @@ async def metadata_override(request: MetadataOverride):
|
|||||||
return ergebnis
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
# Pre-Scan Endpoint
|
# POST /prescan und POST /jellyfin/format entfernt am 25.07.2026 — beide waren
|
||||||
class PreScanRequest(BaseModel):
|
# Überreste eines ersetzten Entwurfs, ohne einen einzigen Aufrufer:
|
||||||
device_path: str
|
#
|
||||||
|
# /prescan war der Endpunkt hinter der Metadaten-Vorschau-Seite. Die
|
||||||
|
# Seite ist seit v3.4 weg (Korrektur-Popup ist der einzige
|
||||||
@app.post("/prescan")
|
# Weg), der Endpunkt blieb liegen. Der Pre-Scan selbst lebt:
|
||||||
async def run_prescan(request: PreScanRequest):
|
# der Disc-Watcher ruft PreScan direkt im Prozess auf, das
|
||||||
"""Führe Pre-Scan durch."""
|
# Ergebnis landet auf der Disc-Karte. Nur der HTTP-Weg
|
||||||
try:
|
# dorthin hatte keinen Nutzer.
|
||||||
prescan = PreScan()
|
#
|
||||||
result = prescan.scan(request.device_path)
|
# /jellyfin/format schrieb NFO-Dateien und lud Poster — in der API. Seit v3.2
|
||||||
return result.to_dict()
|
# macht das der Worker (medien.py), und das ist die richtige
|
||||||
except Exception as e:
|
# Stelle: er kennt den Ausgabeordner und ist direkt nach dem
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
# Rip am Zug. Mit dem Endpunkt fallen nfo_generator.py und
|
||||||
|
# image_downloader.py in der API weg; sonst nutzte sie nichts.
|
||||||
|
|
||||||
# Jellyfin-Formatierung Endpoints
|
|
||||||
class JellyfinFormatRequest(BaseModel):
|
|
||||||
title: str
|
|
||||||
year: Optional[int]
|
|
||||||
metadata: Dict
|
|
||||||
disc_type: str
|
|
||||||
output_dir: str
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/jellyfin/format")
|
|
||||||
async def jellyfin_format(request: JellyfinFormatRequest):
|
|
||||||
"""Formatiere für Jellyfin (NFO + Images)."""
|
|
||||||
try:
|
|
||||||
nfo_gen = NFOGenerator()
|
|
||||||
img_downloader = ImageDownloader()
|
|
||||||
|
|
||||||
# Ordnerstruktur erstellen
|
|
||||||
output_path = Path(request.output_dir)
|
|
||||||
|
|
||||||
if request.disc_type in ["dvd", "bluray"]:
|
|
||||||
# Film-Formatierung
|
|
||||||
title = request.metadata.get("title", request.title)
|
|
||||||
year = request.year or request.metadata.get("year")
|
|
||||||
|
|
||||||
# movie.nfo
|
|
||||||
movie_nfo = nfo_gen.generate_movie_nfo(
|
|
||||||
title=title,
|
|
||||||
year=year or 2000,
|
|
||||||
overview=request.metadata.get("overview", ""),
|
|
||||||
rating=request.metadata.get("rating", 0),
|
|
||||||
runtime=request.metadata.get("runtime", 0),
|
|
||||||
genres=request.metadata.get("genres", []),
|
|
||||||
director=request.metadata.get("director", ""),
|
|
||||||
actors=request.metadata.get("actors", [])
|
|
||||||
)
|
|
||||||
|
|
||||||
nfo_path = output_path / "movie.nfo"
|
|
||||||
nfo_gen.save_nfo(movie_nfo, nfo_path)
|
|
||||||
|
|
||||||
# Poster und Fanart
|
|
||||||
img_downloader.download_poster(title, output_path, 500)
|
|
||||||
img_downloader.download_fanart(title, output_path, 1920)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "formatted",
|
|
||||||
"nfo_path": str(nfo_path),
|
|
||||||
"poster_path": str(output_path / "poster.jpg"),
|
|
||||||
"fanart_path": str(output_path / "fanart.jpg")
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
# Audio-Formatierung
|
|
||||||
artist = request.metadata.get("artist", "Unknown Artist")
|
|
||||||
album = request.metadata.get("title", request.title)
|
|
||||||
# Review-Fix 22.07.: `year` war hier undefiniert (existierte nur im Film-Zweig)
|
|
||||||
year = request.year or request.metadata.get("year")
|
|
||||||
|
|
||||||
# album.nfo
|
|
||||||
album_nfo = nfo_gen.generate_album_nfo(
|
|
||||||
title=album,
|
|
||||||
artist=artist,
|
|
||||||
year=year or 2000,
|
|
||||||
genres=request.metadata.get("genres", [])
|
|
||||||
)
|
|
||||||
|
|
||||||
nfo_path = output_path / "album.nfo"
|
|
||||||
nfo_gen.save_nfo(album_nfo, nfo_path)
|
|
||||||
|
|
||||||
# Album-Cover
|
|
||||||
img_downloader.download_music_images(artist, album, output_path)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "formatted",
|
|
||||||
"nfo_path": str(nfo_path),
|
|
||||||
"album_cover_path": str(output_path / "album.jpg")
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
# Auth-Endpoints (/token, /api-keys) entfernt — Commander-Entscheid 24.07.:
|
# Auth-Endpoints (/token, /api-keys) entfernt — Commander-Entscheid 24.07.:
|
||||||
|
|||||||
@@ -0,0 +1,375 @@
|
|||||||
|
"""MakeMKV-Datenverzeichnis: Schlüsselspeicher, KEYDB.cfg, AACS-Dumps.
|
||||||
|
|
||||||
|
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
|
||||||
|
Ursache ist weder die Disc noch das Laufwerk — LibreDrive v06.3 läuft und
|
||||||
|
MakeMKV liest die Disc — sondern:
|
||||||
|
|
||||||
|
makemkvcon unter LINUX ruft die Disc-Schluessel nie ab.
|
||||||
|
Die Windows-Version tut es.
|
||||||
|
|
||||||
|
Gemessen, nicht vermutet:
|
||||||
|
* Linux: in KEINEM Lauf auch nur eine Verbindung nach draussen. Geprueft mit
|
||||||
|
leerem UND mit gefülltem Schlüsselspeicher, mit und ohne --noscan, mit
|
||||||
|
dev:/dev/sr0 und mit disc:0, und mit erzwungener frischer Prüfung
|
||||||
|
(update.conf gelöscht, Meldung 5074 belegt den Web-Kontakt). Immer:
|
||||||
|
keine Verbindung, kein Schluessel.
|
||||||
|
* Windows, dieselbe Disc, dasselbe Laufwerk: Meldung 3338 "Downloading
|
||||||
|
latest HK to ...", Verbindung nach 185.84.108.20:443
|
||||||
|
(web33.majordomo.ru), _private_data.tar wächst — und die Disc geht auf
|
||||||
|
(TCOUNT:5, "Operation successfully completed").
|
||||||
|
* Der Code dafür steckt auch im Linux-Binary: die Meldungsvorlage
|
||||||
|
"Downloading latest %1 to %2 ..." steht in makemkvcon. Sie löst nur nie
|
||||||
|
aus. Gleiches Symptom im Forum, seit Jahren offen und unbeantwortet.
|
||||||
|
|
||||||
|
FRUEHERE FEHLDIAGNOSE, bewusst festgehalten: An dieser Stelle stand zuerst,
|
||||||
|
MakeMKVs Schluessel-Kanal sei abgeschaltet. Das war FALSCH. Die Herleitung
|
||||||
|
stützte sich auf zwei Hostnamen aus alten Forumsbeitraegen
|
||||||
|
(hkdata.fairuse.org, hkdata.crabdance.com), die tatsaechlich nicht mehr
|
||||||
|
aufloesen — MakeMKV benutzt sie aber laengst nicht mehr. Der Dienst lebt, der
|
||||||
|
Worker erreicht ihn sogar; er wird unter Linux nur nie gefragt.
|
||||||
|
|
||||||
|
WAS DARAUS FOLGT: Der Schlüsselspeicher (_private_data.tar) muss von einer
|
||||||
|
MakeMKV-Installation kommen, die ihn wirklich abruft — praktisch von Windows.
|
||||||
|
Rippy nimmt ihn unter Einstellungen -> System entgegen. Die KEYDB.cfg bleibt
|
||||||
|
der Notnagel für Pressungen, die auch MakeMKV selbst nicht kennt.
|
||||||
|
|
||||||
|
Rippy liefert KEINE Schluessel mit, lädt keine herunter und verteilt keine.
|
||||||
|
Es verwaltet nur, was der Nutzer selbst mitbringt.
|
||||||
|
|
||||||
|
QUELLEN (AGENTS Regel D — externe Schnittstellen nie aus dem Kopf):
|
||||||
|
* 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=34022
|
||||||
|
* Schluessel liegen als hkd_*.bin in _private_data.tar:
|
||||||
|
https://forum.makemkv.com/forum/viewtopic.php?t=32675
|
||||||
|
* Datenverzeichnis und Dateiname KEYDB.cfg GROSS geschrieben (unter Linux
|
||||||
|
case-sensitiv), dort geloest mit "cp KEYDB.cfg ~/.MakeMKV/":
|
||||||
|
https://forum.makemkv.com/forum/viewtopic.php?t=30636
|
||||||
|
* Zeilenformat der KEYDB.cfg (libaacs): Disc-Kennung = 40 Hex-Zeichen,
|
||||||
|
optional mit 0x-Praefix, dann "= Titel", danach optionale Felder wie
|
||||||
|
"| V | <32 Hex>"; Zeilen ab ";" sind Kommentare:
|
||||||
|
https://github.com/ShiftMediaProject/libaacs/blob/master/KEYDB.cfg
|
||||||
|
* Den AACS-Dump legt MakeMKV selbst ab, Meldung 3332 "Saved AACS dump file
|
||||||
|
as file:///root/.MakeMKV/<name>.tgz" (am 25.07.2026 so beobachtet).
|
||||||
|
|
||||||
|
ZWILLINGSDATEI: liegt identisch unter docker/api/ und docker/worker/ — beide
|
||||||
|
Images brauchen sie, ein gemeinsames Paket gibt es in diesem Projekt nicht
|
||||||
|
(gleiche Lage wie bei db.py). Änderungen IMMER in BEIDEN Dateien nachziehen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tarfile
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
# Container-Pfad aus der Umgebung: /root/.MakeMKV im Worker (nur dort sucht
|
||||||
|
# makemkvcon), /app/makemkv-data in der API. Beide zeigen laut
|
||||||
|
# docker-compose.yml auf DASSELBE Host-Verzeichnis.
|
||||||
|
DATEN_DIR = os.getenv("MAKEMKV_DATA_DIR", "/root/.MakeMKV")
|
||||||
|
|
||||||
|
# GROSS geschrieben — unter Linux case-sensitiv, "keydb.cfg" wird ignoriert.
|
||||||
|
KEYDB_NAME = "KEYDB.cfg"
|
||||||
|
|
||||||
|
# Obergrenze für den Upload. Eine vollständige oeffentliche KEYDB.cfg liegt
|
||||||
|
# im einstelligen MB-Bereich; 64 MB sind reichlich Luft und verhindern, dass
|
||||||
|
# eine versehentlich hochgeladene Riesendatei den Speicher vollschreibt.
|
||||||
|
MAX_KEYDB_BYTES = 64 * 1024 * 1024
|
||||||
|
|
||||||
|
# Eine Disc-Zeile beginnt mit der 40 Zeichen langen Hex-Kennung (optional mit
|
||||||
|
# 0x davor), danach folgt das Gleichheitszeichen. Alles andere (Kommentare ab
|
||||||
|
# ";", Leerzeilen, Fortsetzungsfelder) zählt nicht als Eintrag.
|
||||||
|
_DISC_ZEILE = re.compile(r"^\s*(?:0x)?[0-9a-fA-F]{40}\s*=")
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(zeitstempel: float) -> str:
|
||||||
|
"""Unix-Zeit -> ISO-8601 in UTC (sekundengenau), wie db.utcnow() es tut."""
|
||||||
|
return datetime.fromtimestamp(zeitstempel, timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def keydb_pfad(daten_dir: str = None) -> str:
|
||||||
|
"""Voller Pfad zur KEYDB.cfg im Datenverzeichnis."""
|
||||||
|
return os.path.join(daten_dir or DATEN_DIR, KEYDB_NAME)
|
||||||
|
|
||||||
|
|
||||||
|
def zaehle_disc_eintraege(inhalt: str) -> int:
|
||||||
|
"""Zeilen mit Disc-Kennung zählen (pure Funktion, testbar).
|
||||||
|
|
||||||
|
Bewusst eine Heuristik und keine vollständige Auswertung: MakeMKV liest
|
||||||
|
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
|
||||||
|
"da liegt wirklich etwas drin" von "leere oder falsche Datei" zu
|
||||||
|
unterscheiden — sie wird deshalb auch genau so beschriftet.
|
||||||
|
"""
|
||||||
|
return sum(1 for zeile in inhalt.splitlines() if _DISC_ZEILE.match(zeile))
|
||||||
|
|
||||||
|
|
||||||
|
def keydb_pruefen(inhalt: str) -> str:
|
||||||
|
"""Prüft hochgeladenen Inhalt; gibt deutschen Fehlertext oder "" zurück.
|
||||||
|
|
||||||
|
Verhindert den häufigsten Bedienfehler: statt der KEYDB.cfg landet die
|
||||||
|
HTML-Fehlerseite eines Downloads oder eine leere Datei im Verzeichnis —
|
||||||
|
MakeMKV würde dann still weiter "volume key is unknown" melden.
|
||||||
|
"""
|
||||||
|
if not inhalt.strip():
|
||||||
|
return "Die Datei ist leer."
|
||||||
|
if len(inhalt.encode("utf-8")) > MAX_KEYDB_BYTES:
|
||||||
|
return (
|
||||||
|
"Die Datei ist größer als "
|
||||||
|
f"{MAX_KEYDB_BYTES // (1024 * 1024)} MB — das ist keine KEYDB.cfg."
|
||||||
|
)
|
||||||
|
if inhalt.lstrip()[:1] == "<":
|
||||||
|
return (
|
||||||
|
"Das sieht nach HTML aus, nicht nach einer KEYDB.cfg — "
|
||||||
|
"vermutlich wurde eine Fehlerseite statt der Datei geladen."
|
||||||
|
)
|
||||||
|
if zaehle_disc_eintraege(inhalt) == 0:
|
||||||
|
return (
|
||||||
|
"Keine einzige Zeile mit Disc-Kennung gefunden (40 Hex-Zeichen, "
|
||||||
|
"dann ein Gleichheitszeichen). Das ist keine KEYDB.cfg."
|
||||||
|
)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def keydb_status(daten_dir: str = None) -> dict:
|
||||||
|
"""Zustand der KEYDB.cfg. Fehlt sie, ist das der Normalfall, kein Fehler."""
|
||||||
|
pfad = keydb_pfad(daten_dir)
|
||||||
|
try:
|
||||||
|
angaben = os.stat(pfad)
|
||||||
|
except OSError:
|
||||||
|
return {
|
||||||
|
"vorhanden": False,
|
||||||
|
"pfad": pfad,
|
||||||
|
"groesse_bytes": 0,
|
||||||
|
"eintraege": 0,
|
||||||
|
"geaendert": "",
|
||||||
|
}
|
||||||
|
eintraege = 0
|
||||||
|
try:
|
||||||
|
with open(pfad, encoding="utf-8", errors="replace") as datei:
|
||||||
|
eintraege = zaehle_disc_eintraege(datei.read())
|
||||||
|
except OSError:
|
||||||
|
pass # Datei da, aber unlesbar: Größe/Datum stimmen trotzdem
|
||||||
|
return {
|
||||||
|
"vorhanden": True,
|
||||||
|
"pfad": pfad,
|
||||||
|
"groesse_bytes": angaben.st_size,
|
||||||
|
"eintraege": eintraege,
|
||||||
|
"geaendert": _iso(angaben.st_mtime),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def keydb_schreiben(inhalt: str, daten_dir: str = None) -> dict:
|
||||||
|
"""Schreibt die KEYDB.cfg atomar und meldet den neuen Zustand.
|
||||||
|
|
||||||
|
Erst in eine Nebendatei, dann os.replace: während ein Rip läuft, darf
|
||||||
|
makemkvcon niemals eine halb geschriebene Datei zu sehen bekommen.
|
||||||
|
"""
|
||||||
|
pfad = keydb_pfad(daten_dir)
|
||||||
|
os.makedirs(os.path.dirname(pfad), exist_ok=True)
|
||||||
|
neben = pfad + ".neu"
|
||||||
|
try:
|
||||||
|
with open(neben, "w", encoding="utf-8", newline="\n") as datei:
|
||||||
|
datei.write(inhalt)
|
||||||
|
os.replace(neben, pfad)
|
||||||
|
except OSError:
|
||||||
|
# Die Nebendatei nie liegen lassen: eine halb geschriebene
|
||||||
|
# KEYDB.cfg.neu verwirrt jeden, der ins Verzeichnis schaut, und
|
||||||
|
# belegt im Extremfall 64 MB, die niemand mehr aufräumt.
|
||||||
|
try:
|
||||||
|
os.remove(neben)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
return keydb_status(daten_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def keydb_loeschen(daten_dir: str = None) -> dict:
|
||||||
|
"""Entfernt die KEYDB.cfg (z. B. nach einem Fehlgriff beim Hochladen).
|
||||||
|
|
||||||
|
Nur "Datei war schon weg" wird geschluckt — das ist das gewünschte
|
||||||
|
Ergebnis. Jeder andere Fehler (schreibgeschützter Mount, fremder
|
||||||
|
Eigentümer) MUSS nach oben durch: sonst meldete die API einen Erfolg,
|
||||||
|
den es nicht gab, und die Datei wirkte beim nächsten Rip weiter.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
os.remove(keydb_pfad(daten_dir))
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
return keydb_status(daten_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def ist_aacs_dump(name: str) -> bool:
|
||||||
|
"""Dateiname eines AACS-Dumps? (pure Funktion, testbar)
|
||||||
|
|
||||||
|
MakeMKV legt ihn als <MKB..._NAME_....>.tgz direkt im Datenverzeichnis ab
|
||||||
|
(Meldung 3332). Pfadtrenner und fuehrende Punkte werden hier schon
|
||||||
|
ausgeschlossen, damit der Download-Endpunkt keine Pfad-Tricks erlaubt.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
name.endswith(".tgz")
|
||||||
|
and "/" not in name
|
||||||
|
and "\\" not in name
|
||||||
|
and not name.startswith(".")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def dumps_auflisten(daten_dir: str = None) -> list:
|
||||||
|
"""Alle AACS-Dumps im Datenverzeichnis, neueste zuerst."""
|
||||||
|
ordner = daten_dir or DATEN_DIR
|
||||||
|
try:
|
||||||
|
namen = os.listdir(ordner)
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
liste = []
|
||||||
|
for name in namen:
|
||||||
|
if not ist_aacs_dump(name):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
angaben = os.stat(os.path.join(ordner, name))
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
liste.append(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"groesse_bytes": angaben.st_size,
|
||||||
|
"geaendert": _iso(angaben.st_mtime),
|
||||||
|
"_sort": angaben.st_mtime,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
liste.sort(key=lambda eintrag: eintrag["_sort"], reverse=True)
|
||||||
|
for eintrag in liste:
|
||||||
|
del eintrag["_sort"]
|
||||||
|
return liste
|
||||||
|
|
||||||
|
|
||||||
|
# --- Schlüsselspeicher (_private_data.tar) -------------------------------
|
||||||
|
#
|
||||||
|
# Das ist MakeMKVs eigener Speicher für die "Hashed Keys": ein tar-Archiv mit
|
||||||
|
# hkd_*.bin-Eintraegen. Unter Windows füllt MakeMKV es selbst (Meldung 3338),
|
||||||
|
# unter Linux nie — siehe Modul-Kopf. Rippy nimmt die Datei deshalb entgegen
|
||||||
|
# und legt sie ins Datenverzeichnis; MakeMKV liest sie beim nächsten Start.
|
||||||
|
|
||||||
|
PRIVATE_DATA_NAME = "_private_data.tar"
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# client_max_body_size in docker/ui/nginx.conf: wäre die Grenze hier höher,
|
||||||
|
# würde nginx den Upload abweisen, bevor die API ihn überhaupt sieht.
|
||||||
|
MAX_PRIVATE_DATA_BYTES = 64 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def private_data_pfad(daten_dir: str = None) -> str:
|
||||||
|
"""Voller Pfad zum Schlüsselspeicher im Datenverzeichnis."""
|
||||||
|
return os.path.join(daten_dir or DATEN_DIR, PRIVATE_DATA_NAME)
|
||||||
|
|
||||||
|
|
||||||
|
def zaehle_schluessel(rohdaten: bytes) -> int:
|
||||||
|
"""Anzahl der hkd_*.bin-Eintraege im Archiv (pure Funktion, testbar).
|
||||||
|
|
||||||
|
Das ist die ehrliche Kennzahl für "wie viele Disc-Schluessel kennt diese
|
||||||
|
Installation". Ein frischer, leerer Speicher enthält nur eine
|
||||||
|
Index-Datei und kommt hier auf 0 — genau der Zustand, in dem jede
|
||||||
|
unbekannte UHD-Disc scheitert.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with tarfile.open(fileobj=io.BytesIO(rohdaten)) as archiv:
|
||||||
|
return sum(1 for name in archiv.getnames() if name.startswith("hkd_"))
|
||||||
|
except (tarfile.TarError, OSError, EOFError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def private_data_pruefen(rohdaten: bytes) -> str:
|
||||||
|
"""Prüft hochgeladene Rohdaten; deutscher Fehlertext oder "".
|
||||||
|
|
||||||
|
Faengt die beiden Bedienfehler ab, die sonst still danebengehen: eine
|
||||||
|
voellig andere Datei hochladen, oder den Speicher einer Installation, die
|
||||||
|
selbst noch keine Schluessel geholt hat (dann ändert sich nichts, und
|
||||||
|
niemand versteht warum).
|
||||||
|
"""
|
||||||
|
if not rohdaten:
|
||||||
|
return "Die Datei ist leer."
|
||||||
|
if len(rohdaten) > MAX_PRIVATE_DATA_BYTES:
|
||||||
|
return (
|
||||||
|
"Die Datei ist größer als "
|
||||||
|
f"{MAX_PRIVATE_DATA_BYTES // (1024 * 1024)} MB — das ist kein "
|
||||||
|
"MakeMKV-Schlüsselspeicher."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with tarfile.open(fileobj=io.BytesIO(rohdaten)) as archiv:
|
||||||
|
namen = archiv.getnames()
|
||||||
|
except (tarfile.TarError, OSError, EOFError):
|
||||||
|
return (
|
||||||
|
"Das ist kein tar-Archiv. Erwartet wird die Datei "
|
||||||
|
f"{PRIVATE_DATA_NAME} aus dem MakeMKV-Datenverzeichnis."
|
||||||
|
)
|
||||||
|
if not any(name.startswith("hkd_") for name in namen):
|
||||||
|
return (
|
||||||
|
"In dieser Datei steckt kein einziger Schluessel (kein hkd_*.bin). "
|
||||||
|
"Sie stammt vermutlich von einer MakeMKV-Installation, die selbst "
|
||||||
|
"noch keine geholt hat — öffne dort erst einmal eine Disc."
|
||||||
|
)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def schluesselspeicher_status(daten_dir: str = None) -> dict:
|
||||||
|
"""Zustand des Schlüsselspeichers. Fehlt er, ist das kein Fehler."""
|
||||||
|
pfad = private_data_pfad(daten_dir)
|
||||||
|
try:
|
||||||
|
angaben = os.stat(pfad)
|
||||||
|
with open(pfad, "rb") as datei:
|
||||||
|
schluessel = zaehle_schluessel(datei.read())
|
||||||
|
except OSError:
|
||||||
|
return {
|
||||||
|
"vorhanden": False,
|
||||||
|
"pfad": pfad,
|
||||||
|
"groesse_bytes": 0,
|
||||||
|
"schluessel": 0,
|
||||||
|
"geaendert": "",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"vorhanden": True,
|
||||||
|
"pfad": pfad,
|
||||||
|
"groesse_bytes": angaben.st_size,
|
||||||
|
"schluessel": schluessel,
|
||||||
|
"geaendert": _iso(angaben.st_mtime),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def private_data_schreiben(rohdaten: bytes, daten_dir: str = None) -> dict:
|
||||||
|
"""Legt den Schlüsselspeicher atomar ab und meldet den neuen Zustand.
|
||||||
|
|
||||||
|
Atomar aus demselben Grund wie bei der KEYDB.cfg: während ein Rip läuft,
|
||||||
|
darf makemkvcon nie ein halb geschriebenes Archiv sehen.
|
||||||
|
"""
|
||||||
|
pfad = private_data_pfad(daten_dir)
|
||||||
|
os.makedirs(os.path.dirname(pfad), exist_ok=True)
|
||||||
|
neben = pfad + ".neu"
|
||||||
|
try:
|
||||||
|
with open(neben, "wb") as datei:
|
||||||
|
datei.write(rohdaten)
|
||||||
|
os.replace(neben, pfad)
|
||||||
|
except OSError:
|
||||||
|
try:
|
||||||
|
os.remove(neben)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
return schluesselspeicher_status(daten_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def settings_conf_zusammenfuehren(inhalt: str, key: str) -> str:
|
||||||
|
"""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
|
||||||
|
überschrieben. Mit dem jetzt persistenten Datenverzeichnis wäre damit
|
||||||
|
bei jedem Containerstart und vor jedem Rip alles andere weg — z. B.
|
||||||
|
app_UpdateEnable. Leerer Key lässt die vorhandene Zeile ebenfalls fallen,
|
||||||
|
damit ein bewusst geleerter Key nicht heimlich weiterwirkt.
|
||||||
|
"""
|
||||||
|
zeilen = [z for z in inhalt.splitlines() if not z.lstrip().startswith("app_Key")]
|
||||||
|
if key:
|
||||||
|
zeilen.append('app_Key = "{}"'.format(key))
|
||||||
|
text = "\n".join(zeilen).strip("\n")
|
||||||
|
return text + "\n" if text else ""
|
||||||
@@ -1,18 +1,24 @@
|
|||||||
"""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. Den AACS-Schluessel
|
NICHT das Entschluesseln oder Verteilen von Disc-Schluesseln. Diese Grenze gilt
|
||||||
zieht MakeMKV via LibreDrive ohnehin selbst aus dem Laufwerk.
|
unveraendert; Rippy liefert keine Disc-Schluessel mit und verteilt keine.
|
||||||
|
|
||||||
|
Richtigstellung 25.07.2026: Hier stand früher, MakeMKV ziehe den AACS-Schluessel
|
||||||
|
via LibreDrive ohnehin selbst aus dem Laufwerk. Das stimmt für Blu-ray, aber
|
||||||
|
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
|
||||||
|
Messungen stehen im Modul-Kopf von makemkv_daten.py.
|
||||||
|
|
||||||
Quelle/Format dokumentiert (AGENTS Regel D — nicht geraten):
|
Quelle/Format dokumentiert (AGENTS Regel D — nicht geraten):
|
||||||
- Forum-Thread: https://forum.makemkv.com/forum/viewtopic.php?t=1053
|
- Forum-Thread: https://forum.makemkv.com/forum/viewtopic.php?t=1053
|
||||||
@@ -35,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()
|
||||||
@@ -70,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:
|
||||||
|
|||||||
+29
-1
@@ -73,6 +73,30 @@ def schreibtest(pfad: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _stale_mounts_loesen(ziel: str) -> int:
|
||||||
|
"""Loest ALLE (evtl. gestapelten/toten) Mounts an `ziel` per lazy umount.
|
||||||
|
|
||||||
|
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.:
|
||||||
|
über viele Neustarts 12 Schichten, die tote oberste blockierte jeden Zugriff
|
||||||
|
(ls-Timeout, obwohl SMB-445 offen). Erst alle Schichten loesen macht das
|
||||||
|
Re-Mounten idempotent. `umount -l` (lazy) hängt nicht an einem toten CIFS.
|
||||||
|
Rueckgabe: Zahl der geloesten Schichten.
|
||||||
|
"""
|
||||||
|
geloest = 0
|
||||||
|
for _ in range(20): # harte Obergrenze gegen Endlosschleife
|
||||||
|
try:
|
||||||
|
ergebnis = subprocess.run(
|
||||||
|
["umount", "-l", ziel], capture_output=True, timeout=10
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
break
|
||||||
|
if ergebnis.returncode != 0:
|
||||||
|
break # nichts (mehr) gemountet
|
||||||
|
geloest += 1
|
||||||
|
return geloest
|
||||||
|
|
||||||
|
|
||||||
def uebersetze_smb_fehler(fehler: str, mit_credentials: bool) -> str:
|
def uebersetze_smb_fehler(fehler: str, mit_credentials: bool) -> str:
|
||||||
"""Pure Funktion (testbar): NT_STATUS-Kauderwelsch → handelbarer Klartext.
|
"""Pure Funktion (testbar): NT_STATUS-Kauderwelsch → handelbarer Klartext.
|
||||||
|
|
||||||
@@ -155,7 +179,11 @@ def mounten(name: str, typ: str, quelle: str, optionen: str = "",
|
|||||||
if os.path.ismount(ziel):
|
if os.path.ismount(ziel):
|
||||||
return schreibtest(ziel)
|
return schreibtest(ziel)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass # toter Mount → als „nicht gemountet" behandeln und neu mounten
|
pass # toter Mount → unten erst lösen, dann frisch mounten (kein Stapeln)
|
||||||
|
|
||||||
|
# Idempotent: etwaige (auch gestapelte/tote) Alt-Mounts erst lösen, damit der
|
||||||
|
# folgende mount NICHT auf eine Leiche stapelt (Vorfall 24.07.: 12 Schichten).
|
||||||
|
_stale_mounts_loesen(ziel)
|
||||||
|
|
||||||
creds_datei = None
|
creds_datei = None
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -18,7 +18,16 @@ def test_main_importierbar_und_routen_verdrahtet():
|
|||||||
from main import app
|
from main import app
|
||||||
|
|
||||||
routen = {route.path for route in app.routes}
|
routen = {route.path for route in app.routes}
|
||||||
for pfad in ("/health", "/jobs", "/devices", "/logs", "/settings", "/prescan"):
|
for pfad in (
|
||||||
|
"/health", "/jobs", "/devices", "/logs", "/settings",
|
||||||
|
# KEYDB.cfg + AACS-Dumps: der Platz für die selbst mitgebrachte
|
||||||
|
# Schlüsseldatei (Befund 25.07.2026 — MakeMKV holt UHD-Schlüssel nicht
|
||||||
|
# mehr online nach). Ohne diese Routen ist die Seite im UI tot.
|
||||||
|
"/system/keydb", "/system/aacs-dumps", "/system/aacs-dumps/{dateiname}",
|
||||||
|
# Der Hauptweg für 4K-UHD (25.07.2026): makemkvcon holt Disc-Schluessel
|
||||||
|
# unter Linux nie selbst, sie kommen von Hand über diesen Endpunkt.
|
||||||
|
"/system/keystore",
|
||||||
|
):
|
||||||
assert pfad in routen, f"Route {pfad} fehlt"
|
assert pfad in routen, f"Route {pfad} fehlt"
|
||||||
|
|
||||||
|
|
||||||
@@ -42,3 +51,109 @@ def test_worker_task_name_passt_zum_celery_client():
|
|||||||
|
|
||||||
quelle = inspect.getsource(celery_client.start_rip)
|
quelle = inspect.getsource(celery_client.start_rip)
|
||||||
assert '"worker.tasks.rip_disc"' in quelle
|
assert '"worker.tasks.rip_disc"' in quelle
|
||||||
|
|
||||||
|
|
||||||
|
def test_remount_blockiert_den_api_start_nicht():
|
||||||
|
"""Regression (Vorfall 24.07.): ein haengender Netz-Mount (CIFS-Schreibtest kann
|
||||||
|
im Kernel haengen, wait_for_response) darf den API-Start NICHT blockieren. remount
|
||||||
|
muss als Hintergrund-Task laufen (create_task), nicht direkt awaited werden."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
import main
|
||||||
|
|
||||||
|
quelle = inspect.getsource(main.startup_event)
|
||||||
|
assert "create_task(asyncio.to_thread(remount))" in quelle, \
|
||||||
|
"remount muss als Hintergrund-Task laufen (nicht blockierend)"
|
||||||
|
assert "await asyncio.to_thread(remount)" not in quelle, \
|
||||||
|
"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"
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""Tests für die puren Helfer aus makemkv_daten.
|
||||||
|
|
||||||
|
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
|
||||||
|
ohne echte Disc entscheidbar ist: das Zeilenformat der KEYDB.cfg, die
|
||||||
|
Plausibilitaetspruefung beim Hochladen, die Namenshaerte der AACS-Dumps und
|
||||||
|
das Zusammenfuehren der settings.conf.
|
||||||
|
"""
|
||||||
|
from makemkv_daten import (
|
||||||
|
ist_aacs_dump,
|
||||||
|
keydb_pruefen,
|
||||||
|
settings_conf_zusammenfuehren,
|
||||||
|
zaehle_disc_eintraege,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Echte Beispielzeilen im libaacs-Format: 40 Hex-Zeichen Disc-Kennung, dann
|
||||||
|
# "= Titel". Zweite Zeile mit 0x-Praefix, weil die oeffentlichen Dateien beide
|
||||||
|
# Schreibweisen mischen (Fundstelle steht im Modul-Docstring von makemkv_daten).
|
||||||
|
_GUELTIG = """; KEYDB.cfg — Beispiel
|
||||||
|
|
||||||
|
0123456789ABCDEF0123456789ABCDEF01234567 = Akira
|
||||||
|
0xFEDCBA9876543210FEDCBA9876543210FEDCBA98 = Blade Runner | V | 00112233445566778899AABBCCDDEEFF
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_zaehle_disc_eintraege_zaehlt_nur_echte_disc_zeilen():
|
||||||
|
# Kommentar- und Leerzeilen dürfen NICHT mitgezaehlt werden, sonst meldet
|
||||||
|
# das UI "da liegt was drin", obwohl die Datei keinen Schluessel enthält.
|
||||||
|
assert zaehle_disc_eintraege(_GUELTIG) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_zaehle_disc_eintraege_ohne_disc_zeile_ist_null():
|
||||||
|
nur_kommentare = "; nur ein Kommentar\n\n;noch einer\n"
|
||||||
|
assert zaehle_disc_eintraege(nur_kommentare) == 0
|
||||||
|
assert zaehle_disc_eintraege("") == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_zaehle_disc_eintraege_akzeptiert_0x_praefix_einzeln():
|
||||||
|
assert zaehle_disc_eintraege("0x0123456789abcdef0123456789abcdef01234567 = Tenet") == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_zaehle_disc_eintraege_lehnt_zu_kurze_kennung_ab():
|
||||||
|
# 39 statt 40 Hex-Zeichen: das ist keine Disc-Kennung, sondern Tippfehler
|
||||||
|
# oder eine abgeschnittene Datei — darf nicht als Eintrag durchgehen.
|
||||||
|
assert zaehle_disc_eintraege("0123456789ABCDEF0123456789ABCDEF0123456 = Kurz") == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_keydb_pruefen_meldet_leere_datei():
|
||||||
|
assert keydb_pruefen("") != ""
|
||||||
|
assert keydb_pruefen(" \n\n ") != ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_keydb_pruefen_erkennt_html():
|
||||||
|
# Häufigster Bedienfehler: statt der Datei landet die HTML-Fehlerseite
|
||||||
|
# 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")
|
||||||
|
assert "HTML" in fehler
|
||||||
|
|
||||||
|
|
||||||
|
def test_keydb_pruefen_meldet_datei_ohne_disc_zeile():
|
||||||
|
# Text ist da, aber keine einzige Disc-Kennung — z. B. eine Liesmich-Datei.
|
||||||
|
assert keydb_pruefen("Das hier ist irgendein Text ohne Schluessel.\n") != ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_keydb_pruefen_laesst_gueltige_datei_durch():
|
||||||
|
# "" heißt laut Vertrag: alles in Ordnung, darf geschrieben werden.
|
||||||
|
assert keydb_pruefen(_GUELTIG) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_ist_aacs_dump_erkennt_echten_namen():
|
||||||
|
# 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.
|
||||||
|
assert ist_aacs_dump("MKB20_v76_UHD_AKIRA_C02B.tgz") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_ist_aacs_dump_blockt_pfad_tricks():
|
||||||
|
# Der Download-Endpunkt hängt den Namen an das Datenverzeichnis — ein
|
||||||
|
# durchgelassenes ".." oder ein Pfadtrenner wäre ein Ausbruch.
|
||||||
|
assert ist_aacs_dump("../x.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
|
||||||
|
assert ist_aacs_dump(".versteckt.tgz") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_ist_aacs_dump_lehnt_andere_endungen_ab():
|
||||||
|
# Nur die Dumps sollen abholbar sein — nicht settings.conf, nicht
|
||||||
|
# _private_data.tar und schon gar nicht die KEYDB.cfg selbst.
|
||||||
|
assert ist_aacs_dump("KEYDB.cfg") is False
|
||||||
|
assert ist_aacs_dump("settings.conf") is False
|
||||||
|
assert ist_aacs_dump("_private_data.tar") is False
|
||||||
|
assert ist_aacs_dump("") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_conf_ersetzt_alten_key_und_behaelt_den_rest():
|
||||||
|
# Regression: bis 25.07.2026 wurde die Datei komplett überschrieben. Mit
|
||||||
|
# dem jetzt persistenten Datenverzeichnis wäre app_UpdateEnable vor jedem
|
||||||
|
# Rip weg gewesen.
|
||||||
|
alt = 'app_UpdateEnable = "1"\napp_Key = "T-alt"\napp_DestinationDir = "/tmp"\n'
|
||||||
|
neu = settings_conf_zusammenfuehren(alt, "T-neu")
|
||||||
|
assert 'app_Key = "T-neu"' in neu
|
||||||
|
assert "T-alt" not in neu
|
||||||
|
assert 'app_UpdateEnable = "1"' in neu
|
||||||
|
assert 'app_DestinationDir = "/tmp"' in neu
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_conf_leerer_key_entfernt_die_zeile():
|
||||||
|
# Ein bewusst geleerter Key darf nicht heimlich weiterwirken.
|
||||||
|
neu = settings_conf_zusammenfuehren('app_Key = "T-alt"\napp_UpdateEnable = "1"\n', "")
|
||||||
|
assert "app_Key" not in neu
|
||||||
|
assert 'app_UpdateEnable = "1"' in neu
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_conf_aus_dem_nichts_endet_mit_zeilenumbruch():
|
||||||
|
# Erster Start: es gibt noch keine settings.conf. MakeMKV erwartet eine
|
||||||
|
# Datei mit abschliessendem Zeilenumbruch.
|
||||||
|
assert settings_conf_zusammenfuehren("", "T-neu") == 'app_Key = "T-neu"\n'
|
||||||
|
assert settings_conf_zusammenfuehren("", "") == ""
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -39,3 +39,33 @@ def test_unbekannter_fehler_bleibt_erhalten_und_gekappt():
|
|||||||
def test_validiere_name_bleibt_streng():
|
def test_validiere_name_bleibt_streng():
|
||||||
assert validiere_name("nas-filme")
|
assert validiere_name("nas-filme")
|
||||||
assert not validiere_name("NAS Filme")
|
assert not validiere_name("NAS Filme")
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_mounts_loesen_loest_bis_nichts_mehr_geht(monkeypatch):
|
||||||
|
"""Löst gestapelte Schichten per lazy umount, bis umount nichts mehr findet
|
||||||
|
(returncode != 0), und meldet die Zahl der gelösten Schichten."""
|
||||||
|
import types
|
||||||
|
|
||||||
|
import mounts
|
||||||
|
|
||||||
|
aufrufe = []
|
||||||
|
|
||||||
|
def fake_run(cmd, **kwargs):
|
||||||
|
aufrufe.append(cmd)
|
||||||
|
rc = 0 if len(aufrufe) <= 3 else 1 # 3 Schichten lösen, dann leer
|
||||||
|
return types.SimpleNamespace(returncode=rc, stdout=b"", stderr=b"")
|
||||||
|
|
||||||
|
monkeypatch.setattr(mounts.subprocess, "run", fake_run)
|
||||||
|
assert mounts._stale_mounts_loesen("/app/media/x") == 3
|
||||||
|
assert all(cmd[:2] == ["umount", "-l"] for cmd in aufrufe)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mounten_loest_stale_vor_dem_mount():
|
||||||
|
"""Regression (Vorfall 24.07.): mounten() muss Alt-Mounts LÖSEN, bevor es neu
|
||||||
|
mountet — sonst stapelt es auf eine Mount-Leiche (12 Schichten, ls-Timeout)."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
import mounts
|
||||||
|
|
||||||
|
quelle = inspect.getsource(mounts.mounten)
|
||||||
|
assert "_stale_mounts_loesen(ziel)" in quelle
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
FROM postgres:16
|
|
||||||
|
|
||||||
COPY init.sql /docker-entrypoint-initdb.d/
|
|
||||||
|
|
||||||
EXPOSE 5432
|
|
||||||
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
CREATE DATABASE rippy;
|
|
||||||
CREATE USER rippy WITH PASSWORD 'rippy123';
|
|
||||||
GRANT ALL PRIVILEGES ON DATABASE rippy TO rippy;
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
FROM redis:7-alpine
|
|
||||||
|
|
||||||
COPY redis.conf /usr/local/etc/redis/redis.conf
|
|
||||||
|
|
||||||
CMD ["redis-server", "/usr/local/etc/redis/redis.conf"]
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
appendonly yes
|
|
||||||
appendfsync everysec
|
|
||||||
timeout 0
|
|
||||||
tcp-keepalive 300
|
|
||||||
loglevel notice
|
|
||||||
databases 16
|
|
||||||
@@ -17,6 +17,12 @@ server {
|
|||||||
# 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;
|
||||||
|
# Der nginx-Default ist 1 MB. Eine echte KEYDB.cfg ist deutlich groesser,
|
||||||
|
# der Upload ueber POST /api/system/keydb wuerde also schon hier mit
|
||||||
|
# 413 abgewiesen — die API bekaeme die Anfrage nie zu sehen und das UI
|
||||||
|
# haette keinen detail-Text, den es anzeigen koennte. 64m entspricht dem
|
||||||
|
# Limit MAX_KEYDB_BYTES in makemkv_daten.py.
|
||||||
|
client_max_body_size 64m;
|
||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
|||||||
+15
-1
@@ -1,14 +1,25 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { LayoutDashboard, BookOpen, FileText, Settings as SettingsIcon } from 'lucide-react'
|
import { LayoutDashboard, BookOpen, FileText, Settings as SettingsIcon } from 'lucide-react'
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
import SettingsPage from './pages/Settings'
|
import SettingsPage from './pages/Settings'
|
||||||
import LogsPage from './pages/Logs'
|
import LogsPage from './pages/Logs'
|
||||||
import AnleitungPage from './pages/Anleitung'
|
import AnleitungPage from './pages/Anleitung'
|
||||||
|
import FirstRunWizard from './components/FirstRunWizard'
|
||||||
|
import { api } from './lib/api'
|
||||||
|
|
||||||
type Page = 'dashboard' | 'anleitung' | 'logs' | 'settings'
|
type Page = 'dashboard' | 'anleitung' | 'logs' | 'settings'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [currentPage, setCurrentPage] = useState<Page>('dashboard')
|
const [currentPage, setCurrentPage] = useState<Page>('dashboard')
|
||||||
|
// First-Run: solange der Einrichtungs-Assistent nicht abgeschlossen ist, zeigen wir
|
||||||
|
// ihn statt des Dashboards. null = /setup noch nicht geprüft (kein Aufblitzen).
|
||||||
|
const [setupDone, setSetupDone] = useState<boolean | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get('/setup')
|
||||||
|
.then((r) => setSetupDone(Boolean(r.data?.done)))
|
||||||
|
.catch(() => setSetupDone(true)) // API/Setup nicht erreichbar → nicht blockieren
|
||||||
|
}, [])
|
||||||
|
|
||||||
const navItems: { id: Page; label: string; icon: any }[] = [
|
const navItems: { id: Page; label: string; icon: any }[] = [
|
||||||
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
@@ -17,6 +28,9 @@ export default function App() {
|
|||||||
{ id: 'settings', label: 'Einstellungen', icon: SettingsIcon },
|
{ id: 'settings', label: 'Einstellungen', icon: SettingsIcon },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if (setupDone === null) return null // kurzer Moment, bis /setup geantwortet hat
|
||||||
|
if (!setupDone) return <FirstRunWizard onDone={() => setSetupDone(true)} />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#080b11] text-slate-100 font-sans selection:bg-amber-500/30 selection:text-amber-300">
|
<div className="min-h-screen bg-[#080b11] text-slate-100 font-sans selection:bg-amber-500/30 selection:text-amber-300">
|
||||||
{/* Top Header Navigation Bar */}
|
{/* Top Header Navigation Bar */}
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export default function DeviceDiscovery() {
|
|||||||
...(optionen?.mainFeatureOnly !== undefined ? { main_feature_only: optionen.mainFeatureOnly } : {}),
|
...(optionen?.mainFeatureOnly !== undefined ? { main_feature_only: optionen.mainFeatureOnly } : {}),
|
||||||
...(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 } : {}),
|
||||||
})
|
})
|
||||||
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,154 @@
|
|||||||
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 } 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
|
||||||
|
|
||||||
|
/** Was beim Abschliessen gespeichert wird — abhängig davon, was die Maschine kann. */
|
||||||
|
function presetsFuer(schwach: boolean) {
|
||||||
|
if (schwach) {
|
||||||
|
// Gemessen auf einer CPU ohne AVX2: 4K-HEVC = 28-55 h pro Film. Also 4K
|
||||||
|
// verlustfrei behalten und für den Rest H.264 nehmen (deutlich schneller
|
||||||
|
// als H.265, dafür etwas größer).
|
||||||
|
return {
|
||||||
|
transcodePreset: 'HQ 1080p30 Surround',
|
||||||
|
transcodePresetBluray: 'HQ 1080p30 Surround',
|
||||||
|
transcodePresetDvd: 'HQ 576p25 Surround',
|
||||||
|
transcodePresetUhd: PRESET_KEINE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
transcodePreset: 'H.265 MKV 1080p30',
|
||||||
|
transcodePresetBluray: 'H.265 MKV 1080p30',
|
||||||
|
transcodePresetDvd: 'H.265 MKV 576p25',
|
||||||
|
transcodePresetUhd: 'H.265 MKV 2160p60 4K',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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('')
|
||||||
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 [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] = await Promise.allSettled([
|
||||||
|
api.get('/capabilities'),
|
||||||
|
api.get('/devices'),
|
||||||
|
api.get('/system/info'),
|
||||||
|
])
|
||||||
|
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 || [])
|
||||||
|
setGeladen(true)
|
||||||
|
}
|
||||||
|
laden()
|
||||||
|
const takt = setInterval(laden, 5000)
|
||||||
|
return () => { laeuft = false; clearInterval(takt) }
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const schwach = schwacheEncoderCpu(workers)
|
||||||
|
const presets = presetsFuer(schwach)
|
||||||
|
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() } : {}),
|
||||||
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 +161,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 +227,12 @@ 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 */}
|
{/* ---- 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 +263,104 @@ 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>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<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}
|
||||||
|
</li>
|
||||||
|
<li><strong>Blu-ray:</strong> {presets.transcodePresetBluray}</li>
|
||||||
|
<li><strong>DVD:</strong> {presets.transcodePresetDvd}</li>
|
||||||
|
</ul>
|
||||||
|
<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 +380,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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Folder, FolderOpen, File, ArrowUp, CheckCircle, Film, Tv, Music, Cpu } from 'lucide-react'
|
import { Folder, FolderOpen, File, ArrowUp, CheckCircle, Film, Tv, Music, Cpu, HardDrive } 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'
|
||||||
@@ -19,6 +19,15 @@ export interface RipOptionen {
|
|||||||
mainFeatureOnly?: boolean
|
mainFeatureOnly?: boolean
|
||||||
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Ablageziel aus GET /storage-targets.
|
||||||
|
interface StorageZiel {
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
is_mount: boolean
|
||||||
|
free_gb: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WorkerWahl {
|
interface WorkerWahl {
|
||||||
@@ -29,7 +38,8 @@ interface WorkerWahl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
@@ -112,6 +122,12 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([])
|
const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([])
|
||||||
const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([])
|
const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([])
|
||||||
const [customPath, setCustomPath] = useState('')
|
const [customPath, setCustomPath] = useState('')
|
||||||
|
// Arbeitsverzeichnis dieses Rips (Commander-Wunsch 25.07.2026: hier wählbar,
|
||||||
|
// nicht global vorgegeben). '' = der Wert aus den Einstellungen, der auch
|
||||||
|
// bei Vollautomatik-Rips gilt, weil dort niemand gefragt wird.
|
||||||
|
const [arbeitsZiele, setArbeitsZiele] = useState<StorageZiel[]>([])
|
||||||
|
const [arbeitsDir, setArbeitsDir] = useState('')
|
||||||
|
const [standardArbeitsDir, setStandardArbeitsDir] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return
|
if (!isOpen) return
|
||||||
@@ -124,6 +140,10 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
setTitelListe([])
|
setTitelListe([])
|
||||||
setGewaehlt(new Set())
|
setGewaehlt(new Set())
|
||||||
setEncoderNode('')
|
setEncoderNode('')
|
||||||
|
setArbeitsDir('')
|
||||||
|
api.get('/storage-targets')
|
||||||
|
.then(r => setArbeitsZiele(Array.isArray(r.data) ? r.data : []))
|
||||||
|
.catch(() => setArbeitsZiele([]))
|
||||||
// Online-Worker für die Encoder-Wahl (nur relevant, wenn ≥2 verfügbar)
|
// Online-Worker für die Encoder-Wahl (nur relevant, wenn ≥2 verfügbar)
|
||||||
api.get('/capabilities').then(r => {
|
api.get('/capabilities').then(r => {
|
||||||
setWorkers((r.data.workers || []).filter((w: WorkerWahl) => w.online && w.node))
|
setWorkers((r.data.workers || []).filter((w: WorkerWahl) => w.online && w.node))
|
||||||
@@ -131,6 +151,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
api.get('/settings').then(r => {
|
api.get('/settings').then(r => {
|
||||||
const s = r.data || {}
|
const s = r.data || {}
|
||||||
setNurHauptfilm(!!s.mainFeatureOnly)
|
setNurHauptfilm(!!s.mainFeatureOnly)
|
||||||
|
setStandardArbeitsDir((s.workDir || '').trim())
|
||||||
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 },
|
||||||
@@ -164,6 +185,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
optionen.titles = [...gewaehlt].sort((a, b) => a - b)
|
optionen.titles = [...gewaehlt].sort((a, b) => a - b)
|
||||||
}
|
}
|
||||||
if (encoderNode) optionen.transcodeNode = encoderNode
|
if (encoderNode) optionen.transcodeNode = encoderNode
|
||||||
|
if (arbeitsDir) optionen.workDir = arbeitsDir
|
||||||
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)
|
||||||
@@ -245,6 +267,42 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/*
|
||||||
|
Arbeitsverzeichnis für DIESEN Rip (Commander-Wunsch 25.07.2026).
|
||||||
|
Warum es hier steht: Der Roh-Rip einer 4K-UHD ist bis zu 100 GB groß
|
||||||
|
und lag bisher immer auf der Container-Platte — am 25.07. lief sie
|
||||||
|
damit voll (74 GB Rohschnitt auf 148 GB Platte). Die Wahl gehört zur
|
||||||
|
Disc, nicht in eine globale Einstellung. Leer = der Wert aus
|
||||||
|
Einstellungen → Verarbeitung; genau der greift auch bei
|
||||||
|
Vollautomatik-Rips, weil dort niemand gefragt wird.
|
||||||
|
Musik-Rips gehen direkt als FLAC ins Ziel, ohne Roh-Zwischenstufe.
|
||||||
|
*/}
|
||||||
|
{selectedType !== 'music' && (
|
||||||
|
<div>
|
||||||
|
<Select
|
||||||
|
label="Arbeitsverzeichnis für die Rohdaten"
|
||||||
|
value={arbeitsDir}
|
||||||
|
onChange={e => setArbeitsDir(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
Standard aus den Einstellungen
|
||||||
|
{standardArbeitsDir ? ` (${standardArbeitsDir})` : ' (Container-Platte)'}
|
||||||
|
</option>
|
||||||
|
{arbeitsZiele.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 mt-1.5 text-slate-500 dark:text-slate-400 flex items-center gap-1.5">
|
||||||
|
<HardDrive size={13} />
|
||||||
|
Bei 4K-UHD bis zu 100 GB — nimm eine Freigabe mit Platz, am besten dieselbe wie das Ziel oben.
|
||||||
|
Dann muss Rippy am Ende nur umhängen statt zu kopieren.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Encoder-/Worker-Wahl — nur wenn mehrere Worker online sind
|
{/* Encoder-/Worker-Wahl — nur wenn mehrere Worker online sind
|
||||||
(sonst gibt es nichts zu wählen). Musik wird nicht komprimiert. */}
|
(sonst gibt es nichts zu wählen). Musik wird nicht komprimiert. */}
|
||||||
{selectedType !== 'music' && workers.length >= 2 && (
|
{selectedType !== 'music' && workers.length >= 2 && (
|
||||||
|
|||||||
@@ -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,22 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
@@ -79,9 +86,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 +146,18 @@ 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?.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">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ReactNode, useEffect } from 'react'
|
import { ReactNode, useEffect } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
|
|
||||||
interface ModalProps {
|
interface ModalProps {
|
||||||
@@ -35,7 +36,15 @@ export function Modal({ isOpen, onClose, title, children, maxWidth = 'lg' }: Mod
|
|||||||
'4xl': 'max-w-4xl',
|
'4xl': 'max-w-4xl',
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// WICHTIG (Befund 25.07.2026): Der Dialog wird per Portal direkt an
|
||||||
|
// document.body gehängt statt dort zu bleiben, wo er im Baum steht.
|
||||||
|
// Grund: `.glass-panel` in index.css setzt `backdrop-filter: blur(16px)`,
|
||||||
|
// und ein Element mit backdrop-filter wird zum Bezugsrahmen für
|
||||||
|
// `position: fixed` seiner Nachfahren. Ein Modal INNERHALB einer Card war
|
||||||
|
// damit nicht mehr am Fenster ausgerichtet, sondern an der Card — es klebte
|
||||||
|
// im Panel und wurde am Rand abgeschnitten (gemeldet für „Rippen starten"
|
||||||
|
// im Laufwerke-Tab). Das Portal löst das für ALLE Dialoge auf einmal.
|
||||||
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 overflow-y-auto">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 overflow-y-auto">
|
||||||
{/* Glass Backdrop */}
|
{/* Glass Backdrop */}
|
||||||
<div
|
<div
|
||||||
@@ -61,6 +70,7 @@ export function Modal({ isOpen, onClose, title, children, maxWidth = 'lg' }: Mod
|
|||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export const LOG_LEVEL_STYLES = {
|
|||||||
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 },
|
||||||
'vaapi': { label: 'VAAPI ⚡', highlight: true },
|
'vaapi': { label: 'VAAPI ⚡', highlight: true },
|
||||||
'nvenc': { label: 'NVENC ⚡', highlight: true },
|
'nvenc': { label: 'NVENC ⚡', highlight: true },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// 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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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))
|
||||||
|
}
|
||||||
@@ -134,12 +134,15 @@ export default function AnleitungPage() {
|
|||||||
Einstellungen → System eintragen — gilt ab dem nächsten Rip, ohne Neustart. DVDs gehen
|
Einstellungen → System eintragen — gilt ab dem nächsten Rip, ohne Neustart. DVDs gehen
|
||||||
immer auch ohne Key. Dort stehen auch die Werkzeug-Versionen und der freie Speicherplatz.
|
immer auch ohne Key. Dort stehen auch die Werkzeug-Versionen und der freie Speicherplatz.
|
||||||
</p>
|
</p>
|
||||||
|
{/* 25.07.2026 richtiggestellt: Updates bringen keine Disc-Schluessel mit — siehe UHD-Absatz unten. */}
|
||||||
<p>
|
<p>
|
||||||
<span className={fett}>Updates:</span> „Auf Updates prüfen" (ebenfalls Einstellungen →
|
<span className={fett}>Updates:</span> „Auf Updates prüfen" (ebenfalls Einstellungen →
|
||||||
System) vergleicht die installierten Versionen mit makemkv.com und den offiziellen
|
System) vergleicht die installierten Versionen mit makemkv.com und den offiziellen
|
||||||
HandBrake-Releases. Gibt es ein MakeMKV-Update, zeigt Rippy den fertigen
|
HandBrake-Releases. Gibt es ein MakeMKV-Update, zeigt Rippy den fertigen
|
||||||
Update-Befehl an — wichtig, weil neue Versionen auch die neueste
|
Update-Befehl an — neue Versionen bringen bessere Laufwerks-Unterstützung und
|
||||||
Disc-Schlüssel-Datenbank mitbringen.
|
Fehlerbehebungen. <span className={fett}>Disc-Schlüssel für 4K-UHD kommen dagegen nicht
|
||||||
|
aus einem Update</span> — die holt MakeMKV zur Laufzeit, und die Linux-Version tut das
|
||||||
|
nie. Wie du sie trotzdem bekommst, steht unter „Häufige Fragen".
|
||||||
</p>
|
</p>
|
||||||
</Abschnitt>
|
</Abschnitt>
|
||||||
|
|
||||||
@@ -157,11 +160,35 @@ export default function AnleitungPage() {
|
|||||||
<span className={fett}>„Diese Disc wurde bereits gerippt"?</span> Rippy erkennt Discs am
|
<span className={fett}>„Diese Disc wurde bereits gerippt"?</span> Rippy erkennt Discs am
|
||||||
Fingerabdruck. Nochmal rippen geht trotzdem — der Hinweis verhindert nur Versehen.
|
Fingerabdruck. Nochmal rippen geht trotzdem — der Hinweis verhindert nur Versehen.
|
||||||
</p>
|
</p>
|
||||||
|
{/*
|
||||||
|
25.07.2026 auf BEIDEN Maschinen nachgemessen und erneut korrigiert. Es
|
||||||
|
stand hier nacheinander zweierlei Falsches: erst "ein MakeMKV-Update macht
|
||||||
|
die Disc rippbar", dann "MakeMKVs Schluessel-Kanal ist tot". Richtig ist:
|
||||||
|
makemkvcon unter Linux ruft die Schluessel nie ab, die Windows-Version
|
||||||
|
schon (Meldung 3338, Verbindung nach 185.84.108.20:443).
|
||||||
|
*/}
|
||||||
<p>
|
<p>
|
||||||
<span className={fett}>4K-UHD schlägt fehl mit „volume key is unknown"?</span> Das Laufwerk
|
<span className={fett}>4K-UHD schlägt fehl mit „volume key is unknown"?</span> Das Laufwerk
|
||||||
liest die Disc (LibreDrive), aber MakeMKV kennt den Schlüssel dieser (zu neuen) Pressung
|
liest die Disc einwandfrei (LibreDrive) — MakeMKV fehlt nur der Schlüssel dieser Pressung.
|
||||||
noch nicht. Den automatisch gespeicherten AACS-Dump im MakeMKV-Forum einreichen — mit einem
|
Der Grund liegt nicht bei dir und nicht bei Rippy: <span className={fett}>die Linux-Version
|
||||||
der nächsten Updates ist die Disc rippbar.
|
von MakeMKV holt Disc-Schlüssel nie selbst aus dem Netz</span>. Die Windows-Version tut es.
|
||||||
|
Ein MakeMKV-Update ändert daran nichts.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className={fett}>Der Weg drumherum:</span> MakeMKV einmalig auf einem Windows-PC
|
||||||
|
installieren (gleicher Beta-Key), das Laufwerk dort anstecken, die Disc öffnen — MakeMKV
|
||||||
|
lädt die Schlüssel dabei nach. Dann in MakeMKV unter <em>Preferences → General</em> das
|
||||||
|
„MakeMKV data directory" nachschlagen und die Datei <code>_private_data.tar</code> daraus
|
||||||
|
bei Rippy unter Einstellungen → System hochladen. Wirkt ab dem nächsten Rip. Für neue
|
||||||
|
Discs gelegentlich wiederholen — der Block dort zeigt dir, wie viele Schlüssel Rippy kennt.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Geht eine Pressung auch damit nicht auf, kennt MakeMKV sie selbst nicht. Dann bleiben zwei
|
||||||
|
Dinge: eine <code>KEYDB.cfg</code> (ebenfalls dort hochladbar, der Notnagel), oder den
|
||||||
|
<span className={fett}> AACS-Dump</span> im MakeMKV-Forum im Bereich „Ultra HD Blu-ray"
|
||||||
|
einreichen — der bleibt jetzt erhalten und steht unter Einstellungen → System zum
|
||||||
|
Herunterladen. <span className={fett}>Rippy liefert keine Schlüssel mit und lädt keine
|
||||||
|
herunter</span> — es verwaltet nur, was du selbst mitbringst.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<span className={fett}>„Neu komprimieren" fehlt bei einem Fehl-Job?</span> Der Knopf
|
<span className={fett}>„Neu komprimieren" fehlt bei einem Fehl-Job?</span> Der Knopf
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { Save, Disc, Cpu, Database, Globe, AlertCircle, CheckCircle, HardDrive, Wrench, Send, Tv } from 'lucide-react'
|
import { Save, Disc, Cpu, Database, Globe, AlertCircle, CheckCircle, HardDrive, Wrench, Send, Tv, KeyRound, Upload, Trash2, Download } from 'lucide-react'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useToast } from '../context/ToastContext'
|
import { useToast } from '../context/ToastContext'
|
||||||
import StorageMounts from '../components/StorageMounts'
|
import StorageMounts from '../components/StorageMounts'
|
||||||
@@ -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,13 +20,18 @@ 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
|
||||||
notificationWebhook: string
|
notificationWebhook: string
|
||||||
transcodeEnabled: boolean
|
transcodeEnabled: boolean
|
||||||
|
// transcodePreset bleibt als Rückfall für Bestandsinstallationen bestehen —
|
||||||
|
// solange die drei Felder darunter nicht gespeichert sind, gilt weiter dieser
|
||||||
|
// Wert (siehe preset_fuer() in docker/worker/ripping.py).
|
||||||
transcodePreset: string
|
transcodePreset: string
|
||||||
|
transcodePresetDvd: string
|
||||||
|
transcodePresetBluray: string
|
||||||
|
transcodePresetUhd: string
|
||||||
keepOriginal: boolean
|
keepOriginal: boolean
|
||||||
mediaServer: string
|
mediaServer: string
|
||||||
jellyfinUrl: string
|
jellyfinUrl: string
|
||||||
@@ -42,13 +48,15 @@ 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,
|
||||||
notificationWebhook: '',
|
notificationWebhook: '',
|
||||||
transcodeEnabled: true,
|
transcodeEnabled: true,
|
||||||
transcodePreset: 'H.265 MKV 1080p30',
|
transcodePreset: 'H.265 MKV 1080p30',
|
||||||
|
transcodePresetDvd: 'H.265 MKV 576p25',
|
||||||
|
transcodePresetBluray: 'H.265 MKV 1080p30',
|
||||||
|
transcodePresetUhd: 'H.265 MKV 2160p60 4K',
|
||||||
keepOriginal: false,
|
keepOriginal: false,
|
||||||
mediaServer: 'none',
|
mediaServer: 'none',
|
||||||
jellyfinUrl: '',
|
jellyfinUrl: '',
|
||||||
@@ -62,7 +70,51 @@ type SettingsTab = 'ripping' | 'verarbeitung' | 'worker' | 'speicherziele' | 'ap
|
|||||||
interface WorkerInfo {
|
interface WorkerInfo {
|
||||||
name: string
|
name: string
|
||||||
encoders: string[]
|
encoders: string[]
|
||||||
info?: { makemkv?: string, handbrake?: string, makemkv_key?: string }
|
// keydb: "ja" | "nein" | "unbekannt" — sagt, ob DIESER Worker eine KEYDB.cfg
|
||||||
|
// in seinem MakeMKV-Datenverzeichnis sieht. Nur der Worker, der wirklich
|
||||||
|
// rippt, zählt, deshalb steht die Angabe pro Worker und nicht global.
|
||||||
|
// 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.
|
||||||
|
info?: {
|
||||||
|
makemkv?: string, handbrake?: string, makemkv_key?: string, keydb?: string,
|
||||||
|
cpu_modell?: string, cpu_kerne?: string, cpu_simd?: string,
|
||||||
|
handbrake_encoder?: string,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Antwort von GET/POST/DELETE /system/keydb (Feldnamen exakt wie die API sie liefert).
|
||||||
|
interface KeydbStatus {
|
||||||
|
vorhanden: boolean
|
||||||
|
pfad: string
|
||||||
|
groesse_bytes: number
|
||||||
|
eintraege: number
|
||||||
|
geaendert: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Ablageziel aus GET /storage-targets (Feldnamen exakt wie die API liefert).
|
||||||
|
interface StorageZiel {
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
is_mount: boolean
|
||||||
|
free_gb: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Antwort von GET/POST /system/keystore — MakeMKVs eigener Schlüsselvorrat.
|
||||||
|
interface KeystoreStatus {
|
||||||
|
vorhanden: boolean
|
||||||
|
pfad: string
|
||||||
|
groesse_bytes: number
|
||||||
|
schluessel: number
|
||||||
|
geaendert: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein AACS-Dump aus GET /system/aacs-dumps.
|
||||||
|
interface AacsDump {
|
||||||
|
name: string
|
||||||
|
groesse_bytes: number
|
||||||
|
geaendert: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SystemInfo {
|
interface SystemInfo {
|
||||||
@@ -73,6 +125,24 @@ interface SystemInfo {
|
|||||||
webhook_gesetzt: boolean
|
webhook_gesetzt: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Byte-Zahl menschenlesbar — eine echte KEYDB.cfg ist mehrere MB groß.
|
||||||
|
function bytesLesbar(bytes: number): string {
|
||||||
|
if (!bytes) return '0 B'
|
||||||
|
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||||
|
if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`
|
||||||
|
return `${bytes} B`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die API liefert ISO-8601 in UTC (oder einen leeren String). Ohne Zonen-Kennung
|
||||||
|
// würde der Browser den Zeitstempel als Ortszeit lesen und die Uhrzeit um den
|
||||||
|
// Zonen-Versatz verschieben — deshalb notfalls ein Z anhaengen.
|
||||||
|
function zeitLesbar(iso: string): string {
|
||||||
|
if (!iso) return 'unbekannt'
|
||||||
|
const mitZone = /[Zz]$|[+-]\d{2}:?\d{2}$/.test(iso) ? iso : `${iso}Z`
|
||||||
|
const d = new Date(mitZone)
|
||||||
|
return isNaN(d.getTime()) ? 'unbekannt' : d.toLocaleString('de-DE')
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const [settings, setSettings] = useState<SettingsState>(defaultSettings)
|
const [settings, setSettings] = useState<SettingsState>(defaultSettings)
|
||||||
const [activeTab, setActiveTab] = useState<SettingsTab>('ripping')
|
const [activeTab, setActiveTab] = useState<SettingsTab>('ripping')
|
||||||
@@ -86,6 +156,19 @@ export default function SettingsPage() {
|
|||||||
const [quellenBusy, setQuellenBusy] = useState(false)
|
const [quellenBusy, setQuellenBusy] = useState(false)
|
||||||
const [updates, setUpdates] = useState<Record<string, { installiert?: string, verfuegbar?: string, update?: boolean }> | null>(null)
|
const [updates, setUpdates] = useState<Record<string, { installiert?: string, verfuegbar?: string, update?: boolean }> | null>(null)
|
||||||
const [updatesBusy, setUpdatesBusy] = useState(false)
|
const [updatesBusy, setUpdatesBusy] = useState(false)
|
||||||
|
// KEYDB.cfg und AACS-Dumps sind DATEIEN im MakeMKV-Datenverzeichnis, keine
|
||||||
|
// Einstellungen — deshalb bewusst NICHT in SettingsState: der globale
|
||||||
|
// Speichern-Knopf postet dieses Objekt komplett und würde sie mitschleifen.
|
||||||
|
const [keydb, setKeydb] = useState<KeydbStatus | null>(null)
|
||||||
|
const [keydbBusy, setKeydbBusy] = useState(false)
|
||||||
|
const [dumps, setDumps] = useState<AacsDump[]>([])
|
||||||
|
const keydbInput = useRef<HTMLInputElement>(null)
|
||||||
|
// Ablageziele unter /app/media inkl. eingehängter Freigaben — speist die
|
||||||
|
// Auswahl des Arbeitsverzeichnisses (vorher musste man den Pfad tippen).
|
||||||
|
const [ziele, setZiele] = useState<StorageZiel[]>([])
|
||||||
|
const [keystore, setKeystore] = useState<KeystoreStatus | null>(null)
|
||||||
|
const [keystoreBusy, setKeystoreBusy] = useState(false)
|
||||||
|
const keystoreInput = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
|
||||||
@@ -106,6 +189,69 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Status der KEYDB.cfg + Liste der AACS-Dumps frisch holen. Wird beim Laden
|
||||||
|
// der Seite und nach jeder Aktion aufgerufen — die API ist die Wahrheit,
|
||||||
|
// nicht das, was wir gerade hochgeschickt haben.
|
||||||
|
const keydbLaden = () => {
|
||||||
|
api.get('/system/keydb').then(r => setKeydb(r.data)).catch(() => setKeydb(null))
|
||||||
|
api.get('/system/keystore').then(r => setKeystore(r.data)).catch(() => setKeystore(null))
|
||||||
|
api.get('/system/aacs-dumps').then(r => setDumps(r.data?.dumps || [])).catch(() => setDumps([]))
|
||||||
|
// Die Worker-Plaketten oben kommen aus /capabilities und stammen aus dem
|
||||||
|
// letzten Herzschlag des Workers (minuetlich) — ohne dieses Nachladen
|
||||||
|
// widerspraeche die Kachel nach einem Upload sichtbar der Erfolgsmeldung.
|
||||||
|
// Ganz sofort ist sie trotzdem nicht: der Worker meldet erst beim
|
||||||
|
// nächsten Herzschlag neu. Genau so steht es auch im Erklaertext.
|
||||||
|
api.get('/capabilities').then(r => setWorkers(r.data.workers || [])).catch(() => setWorkers([]))
|
||||||
|
}
|
||||||
|
|
||||||
|
const keydbHochladen = async (datei: File) => {
|
||||||
|
setKeydbBusy(true)
|
||||||
|
try {
|
||||||
|
// Bewusst als JSON-Text und nicht als Multipart-Upload: der API fehlt
|
||||||
|
// python-multipart, ein File()-Endpunkt würde sie beim Import killen.
|
||||||
|
const inhalt = await datei.text()
|
||||||
|
await api.post('/system/keydb', { inhalt })
|
||||||
|
keydbLaden()
|
||||||
|
toast('success', 'KEYDB.cfg gespeichert — sie wirkt ab dem nächsten Rip.')
|
||||||
|
} catch (e: any) {
|
||||||
|
toast('error', e?.response?.data?.detail || 'Hochladen fehlgeschlagen — ist das wirklich eine KEYDB.cfg, und läuft die API?')
|
||||||
|
} finally {
|
||||||
|
setKeydbBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const keystoreHochladen = async (datei: File) => {
|
||||||
|
setKeystoreBusy(true)
|
||||||
|
try {
|
||||||
|
// Die Datei wandert als roher Anfrage-Körper zur API — _private_data.tar
|
||||||
|
// ist binär, JSON oder Base64 wäre nur unnötiger Ballast, und Multipart
|
||||||
|
// kann die API nicht (kein python-multipart).
|
||||||
|
await api.post('/system/keystore', datei, {
|
||||||
|
headers: { 'Content-Type': 'application/octet-stream' },
|
||||||
|
})
|
||||||
|
keydbLaden()
|
||||||
|
toast('success', 'Schlüsselspeicher übernommen — wirkt ab dem nächsten Rip.')
|
||||||
|
} catch (e: any) {
|
||||||
|
toast('error', e?.response?.data?.detail
|
||||||
|
|| 'Übernahme fehlgeschlagen — ist das wirklich die Datei _private_data.tar, und läuft die API?')
|
||||||
|
} finally {
|
||||||
|
setKeystoreBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const keydbEntfernen = async () => {
|
||||||
|
setKeydbBusy(true)
|
||||||
|
try {
|
||||||
|
await api.delete('/system/keydb')
|
||||||
|
keydbLaden()
|
||||||
|
toast('success', 'KEYDB.cfg entfernt — Rippy nutzt jetzt keine Schlüsseldatei mehr.')
|
||||||
|
} catch (e: any) {
|
||||||
|
toast('error', e?.response?.data?.detail || 'Entfernen fehlgeschlagen — läuft die API?')
|
||||||
|
} finally {
|
||||||
|
setKeydbBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const quellenPruefen = async () => {
|
const quellenPruefen = async () => {
|
||||||
setQuellenBusy(true)
|
setQuellenBusy(true)
|
||||||
try {
|
try {
|
||||||
@@ -131,6 +277,18 @@ export default function SettingsPage() {
|
|||||||
api.get('/system/info')
|
api.get('/system/info')
|
||||||
.then(r => setSystemInfo(r.data))
|
.then(r => setSystemInfo(r.data))
|
||||||
.catch(() => setSystemInfo(null))
|
.catch(() => setSystemInfo(null))
|
||||||
|
api.get('/system/keydb')
|
||||||
|
.then(r => setKeydb(r.data))
|
||||||
|
.catch(() => setKeydb(null))
|
||||||
|
api.get('/system/keystore')
|
||||||
|
.then(r => setKeystore(r.data))
|
||||||
|
.catch(() => setKeystore(null))
|
||||||
|
api.get('/storage-targets')
|
||||||
|
.then(r => setZiele(Array.isArray(r.data) ? r.data : []))
|
||||||
|
.catch(() => setZiele([]))
|
||||||
|
api.get('/system/aacs-dumps')
|
||||||
|
.then(r => setDumps(r.data?.dumps || []))
|
||||||
|
.catch(() => setDumps([]))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const webhookTesten = async () => {
|
const webhookTesten = async () => {
|
||||||
@@ -276,14 +434,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
|
||||||
@@ -404,6 +564,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>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -419,16 +597,75 @@ export default function SettingsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Select
|
{/*
|
||||||
label="HandBrake-Preset"
|
Ein Preset JE DISC-TYP (25.07.2026). Vorher lief alles durch
|
||||||
value={settings.transcodePreset}
|
dasselbe Preset — eine 4K-UHD wurde damit auf 1080p
|
||||||
onChange={(e) => handleChange('transcodePreset', e.target.value)}
|
heruntergerechnet und eine DVD auf 1080p hochskaliert.
|
||||||
>
|
Preset-Namen stammen aus `HandBrakeCLI --preset-list` im
|
||||||
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 — klein & modern (Standard)</option>
|
Worker-Image (HandBrake 1.6.1), nicht aus dem Kopf.
|
||||||
<option value="H.265 MKV 2160p60 4K">H.265 MKV 2160p60 4K — für UHD-Quellen</option>
|
*/}
|
||||||
<option value="HQ 1080p30 Surround">HQ 1080p30 Surround — H.264, maximale Kompatibilität</option>
|
<div className="space-y-3">
|
||||||
<option value="Super HQ 1080p30 Surround">Super HQ 1080p30 Surround — größer, beste Qualität</option>
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
</Select>
|
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>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Preset für 4K-UHD"
|
||||||
|
value={settings.transcodePresetUhd}
|
||||||
|
onChange={(e) => handleChange('transcodePresetUhd', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value={PRESET_KEINE}>Nicht komprimieren — 4K verlustfrei behalten</option>
|
||||||
|
<option value="H.265 MKV 2160p60 4K">H.265 MKV 2160p60 4K — 4K erhalten</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>
|
||||||
|
{/* 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Preset für Blu-ray"
|
||||||
|
value={settings.transcodePresetBluray}
|
||||||
|
onChange={(e) => handleChange('transcodePresetBluray', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value={PRESET_KEINE}>Nicht komprimieren — verlustfrei behalten</option>
|
||||||
|
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 — klein & modern (empfohlen)</option>
|
||||||
|
<option value="HQ 1080p30 Surround">HQ 1080p30 Surround — H.264, maximale Kompatibilität</option>
|
||||||
|
<option value="Super HQ 1080p30 Surround">Super HQ 1080p30 Surround — größer, beste Qualität</option>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Preset für DVD"
|
||||||
|
value={settings.transcodePresetDvd}
|
||||||
|
onChange={(e) => handleChange('transcodePresetDvd', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value={PRESET_KEINE}>Nicht komprimieren — verlustfrei behalten</option>
|
||||||
|
<option value="H.265 MKV 576p25">H.265 MKV 576p25 — PAL-DVD, native Auflösung (empfohlen)</option>
|
||||||
|
<option value="H.265 MKV 480p30">H.265 MKV 480p30 — NTSC-DVD, native Auflösung</option>
|
||||||
|
<option value="HQ 576p25 Surround">HQ 576p25 Surround — H.264, maximale Kompatibilität</option>
|
||||||
|
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 — hochskalieren (bringt nichts)</option>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
„Nicht komprimieren" heißt: Der Rip bleibt so, wie MakeMKV ihn
|
||||||
|
gesichert hat — beste Qualität, aber 20–100 GB je Film. Sinnvoll für
|
||||||
|
4K, wenn die Ablage groß genug ist.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||||||
<Toggle
|
<Toggle
|
||||||
@@ -439,14 +676,35 @@ export default function SettingsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Input
|
{/*
|
||||||
label="Arbeitsverzeichnis für Roh-Rips (optional)"
|
Auswahl statt Eintippen (Befund 25.07.2026): Das Feld war ein
|
||||||
|
freies Textfeld — man musste den Container-Pfad KENNEN
|
||||||
|
(/app/media/…), um eine Netzwerk-Freigabe zu treffen. Genau
|
||||||
|
daran ist es gescheitert, und der 74-GB-Rohschnitt landete auf
|
||||||
|
der VM-Platte, bis sie voll war. Die Ziele kommen aus
|
||||||
|
/storage-targets — dieselbe Liste wie bei den Speicherzielen,
|
||||||
|
inklusive freiem Platz.
|
||||||
|
*/}
|
||||||
|
<Select
|
||||||
|
label="Arbeitsverzeichnis für Roh-Rips (Standard)"
|
||||||
value={settings.workDir}
|
value={settings.workDir}
|
||||||
onChange={(e) => handleChange('workDir', e.target.value)}
|
onChange={(e) => handleChange('workDir', e.target.value)}
|
||||||
placeholder="leer = /app/temp (Container-Platte)"
|
>
|
||||||
/>
|
<option value="">Container-Platte (Standard) — klein, nur für DVD/Blu-ray</option>
|
||||||
|
{ziele.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">
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
Wichtig für 4K-UHD: Rohdaten sind bis 100 GB groß.
|
<strong className="text-slate-700 dark:text-slate-300">Der Standard</strong> — beim
|
||||||
|
„Rippen starten" kannst du für jede Disc etwas anderes wählen. <strong className="text-slate-700 dark:text-slate-300">
|
||||||
|
Läuft die Vollautomatik</strong>, fragt dich niemand: dann gilt genau dieser Wert.
|
||||||
|
Wichtig für 4K-UHD: Rohdaten sind bis 100 GB groß und passen selten auf die
|
||||||
|
Container-Platte. Am besten dieselbe Freigabe wie das Ziel — dann muss Rippy die
|
||||||
|
Rohdatei am Ende nur umhängen statt sie zu kopieren.
|
||||||
{systemInfo?.plaetze?.length ? (
|
{systemInfo?.plaetze?.length ? (
|
||||||
<> Aktuell frei: {systemInfo.plaetze.map(p => `${p.name}: ${p.frei_gb} GB`).join(' · ')}</>
|
<> Aktuell frei: {systemInfo.plaetze.map(p => `${p.name}: ${p.frei_gb} GB`).join(' · ')}</>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -633,13 +891,42 @@ export default function SettingsPage() {
|
|||||||
HandBrake {w.info.handbrake}
|
HandBrake {w.info.handbrake}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{/*
|
||||||
|
Nur das Verzeichnis des rippenden Workers zählt — deshalb je Worker.
|
||||||
|
Die Zahl der Disc-Schluessel ist die entscheidende Angabe für 4K-UHD:
|
||||||
|
0 heißt, dass JEDE unbekannte UHD-Disc scheitert.
|
||||||
|
*/}
|
||||||
|
{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">
|
||||||
|
{w.info.schluessel} Disc-Schlüssel
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{w.info?.schluessel === '0' && (
|
||||||
|
<span className="text-xs px-2.5 py-0.5 rounded-md font-medium bg-amber-500/15 text-amber-600 dark:text-amber-400 border border-amber-500/30">
|
||||||
|
keine Disc-Schlüssel — 4K-UHD scheitert
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{w.info?.keydb === 'ja' && (
|
||||||
|
<span className="text-xs px-2.5 py-0.5 rounded-md font-medium bg-slate-200 dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-300 dark:border-slate-700">
|
||||||
|
+ KEYDB.cfg
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
{/*
|
||||||
|
Bis 25.07.2026 stand hier, MakeMKV-Updates braechten die neueste
|
||||||
|
Disc-Schluessel-Datenbank mit. Nachgemessen: falsch — MakeMKV liefert
|
||||||
|
gar keine Schluessel mit, es holt sie zur Laufzeit. Und die
|
||||||
|
Linux-Version holt sie nie (kein einziger Verbindungsversuch, auf
|
||||||
|
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
|
||||||
(Update-Check unten; Version über <code>MAKEMKV_VERSION</code> + Rebuild) — wichtig wegen der
|
(Update-Check unten; Version über <code>MAKEMKV_VERSION</code> + Rebuild) — neue Versionen bringen
|
||||||
Disc-Schlüssel-Datenbank.
|
vor allem Laufwerks-Unterstützung und Fehlerbehebungen. <strong>Disc-Schlüssel für 4K-UHD kommen
|
||||||
|
NICHT aus einem Update</strong> — die holt MakeMKV zur Laufzeit, und die Linux-Version tut das
|
||||||
|
nie. Deshalb der Block „Disc-Schlüssel für 4K-UHD" weiter unten.
|
||||||
{' '}<strong className="text-slate-700 dark:text-slate-300">HandBrake</strong> im eingebauten
|
{' '}<strong className="text-slate-700 dark:text-slate-300">HandBrake</strong> im eingebauten
|
||||||
Docker-Worker ist bewusst die stabile <strong>Debian-Version</strong> — für die Kompression völlig
|
Docker-Worker ist bewusst die stabile <strong>Debian-Version</strong> — für die Kompression völlig
|
||||||
ausreichend und wird <strong>nicht separat aktualisiert</strong>. Native Worker (Windows) holen
|
ausreichend und wird <strong>nicht separat aktualisiert</strong>. Native Worker (Windows) holen
|
||||||
@@ -655,6 +942,213 @@ export default function SettingsPage() {
|
|||||||
placeholder="T-… (Forum-Thread „MakeMKV is free while in beta”)"
|
placeholder="T-… (Forum-Thread „MakeMKV is free while in beta”)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
Schlüsselspeicher — der Hauptweg für 4K-UHD (Befund 25.07.2026,
|
||||||
|
auf beiden Maschinen gemessen): makemkvcon holt Disc-Schluessel
|
||||||
|
unter Linux NIE selbst, die Windows-Version tut es (Meldung 3338,
|
||||||
|
Verbindung nach 185.84.108.20:443). Deshalb wird der Vorrat hier
|
||||||
|
von Hand hereingereicht. Frueher stand an dieser Stelle die These,
|
||||||
|
MakeMKVs Schluessel-Kanal sei abgeschaltet — das war falsch.
|
||||||
|
*/}
|
||||||
|
<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="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-semibold text-slate-900 dark:text-slate-200 flex items-center gap-2">
|
||||||
|
<Database size={16} className="text-amber-500" />
|
||||||
|
Disc-Schlüssel für 4K-UHD
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
ref={keystoreInput}
|
||||||
|
accept=".tar"
|
||||||
|
onChange={(e) => {
|
||||||
|
const datei = e.target.files?.[0]
|
||||||
|
e.target.value = ''
|
||||||
|
if (datei) keystoreHochladen(datei)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="amber"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => keystoreInput.current?.click()}
|
||||||
|
disabled={keystoreBusy}
|
||||||
|
>
|
||||||
|
<Upload size={14} />
|
||||||
|
{keystoreBusy ? 'Übernehme…' : '_private_data.tar übernehmen'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{keystore?.vorhanden && keystore.schluessel > 0 ? (
|
||||||
|
<div className="p-3 rounded-lg text-sm bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30">
|
||||||
|
<p className="font-semibold">{keystore.schluessel} Disc-Schlüssel vorhanden.</p>
|
||||||
|
<p className="text-xs mt-1">
|
||||||
|
{bytesLesbar(keystore.groesse_bytes)} · zuletzt geändert {zeitLesbar(keystore.geaendert)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-3 rounded-lg text-sm bg-amber-500/15 text-amber-600 dark:text-amber-400 border border-amber-500/30">
|
||||||
|
<p className="font-semibold">Kein einziger Disc-Schlüssel vorhanden.</p>
|
||||||
|
<p className="text-xs mt-1">
|
||||||
|
DVDs und normale Blu-rays laufen trotzdem. 4K-UHD-Discs scheitern dagegen mit
|
||||||
|
„The volume key is unknown for this disc" — solange hier nichts liegt, jede einzelne.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
<strong className="text-slate-700 dark:text-slate-300">Warum das nötig ist:</strong> MakeMKV
|
||||||
|
holt sich diese Schlüssel eigentlich selbst aus dem Netz. Die <strong>Linux-Version tut das
|
||||||
|
nicht</strong> — am 25.07.2026 nachgemessen: sie baut dabei nicht eine einzige Verbindung auf.
|
||||||
|
Die Windows-Version schon. Ein MakeMKV-Update ändert daran nichts, es ist kein Fehler in Rippy.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
<strong className="text-slate-700 dark:text-slate-300">So füllst du den Vorrat:</strong> MakeMKV
|
||||||
|
auf einem Windows-PC installieren (gleicher Beta-Key), das Laufwerk dort anstecken und die Disc
|
||||||
|
einmal öffnen. MakeMKV lädt die Schlüssel dabei nach. Danach unter <em>Preferences → General</em>
|
||||||
|
das „MakeMKV data directory" nachschlagen, die Datei <code>_private_data.tar</code> daraus hier
|
||||||
|
hochladen — fertig. Für neue Discs von Zeit zu Zeit wiederholen.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Das ist der Zwischenspeicher <strong className="text-slate-700 dark:text-slate-300">deiner
|
||||||
|
eigenen</strong> MakeMKV-Installation, über MakeMKVs offiziellen Weg mit deiner eigenen Lizenz
|
||||||
|
geholt. Rippy liefert keine Schlüssel mit und lädt keine herunter.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
KEYDB.cfg — der NOTNAGEL, nicht der Hauptweg. Sie hilft bei Pressungen,
|
||||||
|
die auch MakeMKV selbst nicht kennt. Der Regelfall läuft über den
|
||||||
|
Schlüsselspeicher im Block darüber. Dateiname GROSS geschrieben, unter
|
||||||
|
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="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-semibold text-slate-900 dark:text-slate-200 flex items-center gap-2">
|
||||||
|
<KeyRound size={16} className="text-amber-500" />
|
||||||
|
KEYDB.cfg (Notnagel)
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/*
|
||||||
|
Versteckter Datei-Dialog: der Inhalt wird im Browser gelesen und als
|
||||||
|
JSON gepostet. Kein Multipart — der API fehlt python-multipart.
|
||||||
|
*/}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
ref={keydbInput}
|
||||||
|
accept=".cfg,text/plain"
|
||||||
|
onChange={(e) => {
|
||||||
|
const datei = e.target.files?.[0]
|
||||||
|
// Wert leeren, damit dieselbe Datei erneut gewählt werden kann.
|
||||||
|
e.target.value = ''
|
||||||
|
if (datei) keydbHochladen(datei)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="amber"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => keydbInput.current?.click()}
|
||||||
|
disabled={keydbBusy}
|
||||||
|
>
|
||||||
|
<Upload size={14} />
|
||||||
|
{keydbBusy ? 'Arbeite…' : 'KEYDB.cfg hochladen'}
|
||||||
|
</Button>
|
||||||
|
{keydb?.vorhanden && (
|
||||||
|
<Button variant="danger" size="sm" onClick={keydbEntfernen} disabled={keydbBusy}>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
Entfernen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{keydb?.vorhanden ? (
|
||||||
|
<div className="p-3 rounded-lg text-sm bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30">
|
||||||
|
<p className="font-semibold">Eine KEYDB.cfg liegt bereit.</p>
|
||||||
|
<p className="text-xs mt-1">
|
||||||
|
{bytesLesbar(keydb.groesse_bytes)} · {keydb.eintraege} Zeilen mit Disc-Kennung ·
|
||||||
|
zuletzt geändert {zeitLesbar(keydb.geaendert)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs mt-1 font-mono opacity-80">{keydb.pfad}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-3 rounded-lg text-sm bg-slate-200 dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-300 dark:border-slate-700">
|
||||||
|
<p className="font-semibold">Keine KEYDB.cfg hinterlegt.</p>
|
||||||
|
<p className="text-xs mt-1">
|
||||||
|
Das ist normal und meistens auch nicht nötig — der Regelfall läuft über den
|
||||||
|
Schlüsselspeicher oben.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Die <strong className="text-slate-700 dark:text-slate-300">KEYDB.cfg</strong> ist eine reine
|
||||||
|
Textdatei mit Disc-Schlüsseln. Sie ist der <strong className="text-slate-700 dark:text-slate-300">
|
||||||
|
Notnagel</strong> für den Fall, dass eine Pressung selbst über den Schlüsselspeicher nicht
|
||||||
|
aufgeht — also auch MakeMKV sie nicht kennt. Zuerst immer den Weg darüber versuchen.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
<strong className="text-slate-700 dark:text-slate-300">Wichtig:</strong> 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. Die Zahl oben ist
|
||||||
|
genau das: die gezählten Zeilen mit einer Disc-Kennung in deiner Datei — keine von MakeMKV
|
||||||
|
bestätigte Anzahl brauchbarer Schlüssel.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Eine hochgeladene Datei ersetzt die bisherige und wirkt
|
||||||
|
<strong className="text-slate-700 dark:text-slate-300"> ab dem nächsten Rip</strong> — laufende
|
||||||
|
Jobs bleiben unberührt, ein Neustart ist nicht nötig. Die Plakette am Worker weiter oben
|
||||||
|
folgt erst mit dessen nächstem Herzschlag (etwa eine Minute) — dieser Kasten hier ist sofort
|
||||||
|
aktuell.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
AACS-Dumps: MakeMKV legt sie bei einer unbekannten UHD-Disc ab (Meldung 3332).
|
||||||
|
Seit das Datenverzeichnis persistent gemountet ist, überleben sie den
|
||||||
|
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="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-semibold text-slate-900 dark:text-slate-200">AACS-Dumps</p>
|
||||||
|
<Button variant="secondary" size="sm" onClick={keydbLaden}>
|
||||||
|
Liste aktualisieren
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{dumps.length === 0 ? (
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Noch kein Dump vorhanden. MakeMKV legt hier automatisch einen ab, sobald eine 4K-UHD-Disc
|
||||||
|
an einem unbekannten Schlüssel scheitert.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{dumps.map(d => (
|
||||||
|
<a
|
||||||
|
key={d.name}
|
||||||
|
href={`/api/system/aacs-dumps/${encodeURIComponent(d.name)}`}
|
||||||
|
download
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors text-amber-600 dark:text-amber-300 hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
<Download size={14} className="flex-shrink-0" />
|
||||||
|
<span className="truncate min-w-0 font-mono">{d.name}</span>
|
||||||
|
<span className="ml-auto text-xs flex-shrink-0 text-slate-500 dark:text-slate-400 font-mono">
|
||||||
|
{bytesLesbar(d.groesse_bytes)} · {zeitLesbar(d.geaendert)}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Ein AACS-Dump ist das, was das Laufwerk von der Disc gelesen hat, bevor der Schlüssel fehlte.
|
||||||
|
Er enthält keinen Schlüssel und nützt dir allein nichts — aber du kannst ihn herunterladen und
|
||||||
|
im MakeMKV-Forum im Bereich <strong className="text-slate-700 dark:text-slate-300">Ultra HD
|
||||||
|
Blu-ray</strong> einreichen. Dort können Leute daraus den Schlüssel für deine Pressung ermitteln;
|
||||||
|
den trägst du dann in deine KEYDB.cfg ein.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 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">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-sm font-semibold text-slate-900 dark:text-slate-200">Update-Check</p>
|
<p className="text-sm font-semibold text-slate-900 dark:text-slate-200">Update-Check</p>
|
||||||
@@ -672,7 +1166,9 @@ export default function SettingsPage() {
|
|||||||
<p className="text-xs mt-1">
|
<p className="text-xs mt-1">
|
||||||
⚠️ Update verfügbar — in der .env <code>MAKEMKV_VERSION={updates.makemkv.verfuegbar}</code> setzen,
|
⚠️ Update verfügbar — in der .env <code>MAKEMKV_VERSION={updates.makemkv.verfuegbar}</code> setzen,
|
||||||
dann auf der Rippy-Maschine <code>docker compose build worker && docker compose up -d worker</code>.
|
dann auf der Rippy-Maschine <code>docker compose build worker && docker compose up -d worker</code>.
|
||||||
Neue Versionen bringen auch die neueste Disc-Schlüssel-Datenbank mit.
|
{/* 25.07.2026 richtiggestellt: ein Update bringt KEINE Disc-Schluessel mit. */}
|
||||||
|
Neue Versionen bringen Laufwerks-Unterstützung und Fehlerbehebungen — aber
|
||||||
|
<strong> keine Disc-Schlüssel</strong>: die kommen aus dem Schlüsselspeicher.
|
||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
: <span className="text-xs ml-1 text-emerald-600 dark:text-emerald-400">✓ aktuell</span>}
|
: <span className="text-xs ml-1 text-emerald-600 dark:text-emerald-400">✓ aktuell</span>}
|
||||||
|
|||||||
+51
-13
@@ -16,9 +16,13 @@ FROM python:3.12-slim-bookworm AS makemkv-build
|
|||||||
# 1.18er-Laufwerks-Scan-Hängers (Forum t=38128) und der Flash-Fähigkeit
|
# 1.18er-Laufwerks-Scan-Hängers (Forum t=38128) und der Flash-Fähigkeit
|
||||||
# gepinnt. Beides erledigt: der Scan wird überall mit --noscan umgangen
|
# gepinnt. Beides erledigt: der Scan wird überall mit --noscan umgangen
|
||||||
# (info-Lauf mit 1.18.4 auf der BU40N sauber durchgelaufen, 24.07.), und
|
# (info-Lauf mit 1.18.4 auf der BU40N sauber durchgelaufen, 24.07.), und
|
||||||
# das Laufwerk ist geflasht (LibreDrive v06.3 bestätigt). Die AKTUELLE
|
# das Laufwerk ist geflasht (LibreDrive v06.3 bestätigt).
|
||||||
# Version zählt, weil sie die neueste AACS-Schlüssel-Datenbank mitbringt —
|
# Richtigstellung 25.07.2026: Hier stand, die aktuelle Version zähle, weil sie
|
||||||
# 1.17.7 kannte z. B. den Key der Summer-Wars-UHD (MKB v82) nicht.
|
# "die neueste AACS-Schlüssel-Datenbank mitbringt". Das ist widerlegt — MakeMKV
|
||||||
|
# bringt gar keine Disc-Schlüssel mit, und der Online-Kanal liefert nichts mehr
|
||||||
|
# (Messungen im Modul-Kopf von makemkv_daten.py). Aktuell bleiben lohnt sich
|
||||||
|
# trotzdem: Laufwerks-Unterstützung und Fehlerbehebungen. Schlüssel für neue
|
||||||
|
# UHD-Pressungen kommen ausschließlich aus der KEYDB.cfg im Datenverzeichnis.
|
||||||
# MAKEMKV_URL_BASE ist übersteuerbar (Build-Arg): /download hat nur die
|
# MAKEMKV_URL_BASE ist übersteuerbar (Build-Arg): /download hat nur die
|
||||||
# aktuelle Version, /download/old die älteren. Wenn Cloudflare BuildKit-
|
# aktuelle Version, /download/old die älteren. Wenn Cloudflare BuildKit-
|
||||||
# Downloads drosselt (24.07.: außerhalb des Builds ging alles, im Build
|
# Downloads drosselt (24.07.: außerhalb des Builds ging alles, im Build
|
||||||
@@ -28,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 \
|
||||||
@@ -42,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 \
|
||||||
|
|||||||
+155
-9
@@ -6,26 +6,133 @@ 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
|
||||||
|
|
||||||
|
|
||||||
def erkenne_encoder() -> list:
|
HB_ENCODER_KOPF = re.compile(r"^-e,\s*--encoder\b")
|
||||||
"""Liste der verfügbaren Encoder-Backends auf dieser Maschine."""
|
|
||||||
gefunden = ["cpu-x264", "cpu-x265"] # HandBrake-Software-Encoder, immer dabei
|
|
||||||
|
|
||||||
# VAAPI: AMD (VCN) und Intel (QuickSync) melden sich über /dev/dri
|
# Reihenfolge = Anzeige-Reihenfolge im UI.
|
||||||
if os.path.exists("/dev/dri/renderD128"):
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def leite_backends_ab(hb_encoder, dri_da: bool, nvidia_da: bool) -> list:
|
||||||
|
"""Übersetzt die HandBrake-Encoder-Namen in die Backends, die das UI zeigt.
|
||||||
|
|
||||||
|
Zwei Ehrlichkeits-Regeln, die vorher fehlten (Befund 25.07.2026):
|
||||||
|
* Ohne HandBrake gibt es KEINE Encoder. Vorher standen `cpu-x264` und
|
||||||
|
`cpu-x265` fest verdrahtet in der Liste („immer dabei") — ein reiner
|
||||||
|
Rip-Worker ohne HandBrake behauptete damit, komprimieren zu können.
|
||||||
|
* Hardware zählt nur, wenn das Gerät da ist UND HandBrake den Encoder
|
||||||
|
überhaupt kennt. Vorher genügte `/dev/dri` — das Worker-Image kann
|
||||||
|
laut eigener Auskunft aber gar keinen Hardware-Encoder.
|
||||||
|
"""
|
||||||
|
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")
|
||||||
|
if dri_da and any(("qsv" in n) or ("vce" in n) or ("vaapi" in n) for n in namen):
|
||||||
gefunden.append("vaapi")
|
gefunden.append("vaapi")
|
||||||
|
if nvidia_da and any("nvenc" in n for n in namen):
|
||||||
# NVENC: NVIDIA-Treiber im Container sichtbar
|
|
||||||
if shutil.which("nvidia-smi") or os.path.exists("/usr/lib/x86_64-linux-gnu/libnvidia-encode.so.1"):
|
|
||||||
gefunden.append("nvenc")
|
gefunden.append("nvenc")
|
||||||
|
|
||||||
return gefunden
|
return gefunden
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
return (aus.stdout or "") + (aus.stderr or "")
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def erkenne_encoder() -> list:
|
||||||
|
"""Encoder-Backends, die auf DIESER Maschine wirklich nutzbar sind."""
|
||||||
|
hb_encoder = parse_encoder_liste(hole_handbrake_hilfe())
|
||||||
|
dri_da = os.path.exists("/dev/dri/renderD128")
|
||||||
|
nvidia_da = bool(
|
||||||
|
shutil.which("nvidia-smi")
|
||||||
|
or os.path.exists("/usr/lib/x86_64-linux-gnu/libnvidia-encode.so.1")
|
||||||
|
)
|
||||||
|
return leite_backends_ab(hb_encoder, dri_da, nvidia_da)
|
||||||
|
|
||||||
|
|
||||||
def erkenne_ip() -> str:
|
def erkenne_ip() -> str:
|
||||||
"""Beste erratbare eigene IP (UDP-Route-Trick, KEIN echter Traffic).
|
"""Beste erratbare eigene IP (UDP-Route-Trick, KEIN echter Traffic).
|
||||||
|
|
||||||
@@ -56,11 +163,23 @@ def werkzeug_versionen() -> dict:
|
|||||||
"""
|
"""
|
||||||
import socket
|
import socket
|
||||||
|
|
||||||
|
modell, flags, kerne = _lies_cpuinfo()
|
||||||
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 or (platform.processor() or "unbekannt"),
|
||||||
|
"cpu_kerne": str(kerne or os.cpu_count() or 0),
|
||||||
|
"cpu_simd": beste_simd(flags),
|
||||||
}
|
}
|
||||||
|
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)
|
||||||
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"):
|
||||||
@@ -85,4 +204,31 @@ def werkzeug_versionen() -> dict:
|
|||||||
info["makemkv_key"] = "env"
|
info["makemkv_key"] = "env"
|
||||||
else:
|
else:
|
||||||
info["makemkv_key"] = "keiner"
|
info["makemkv_key"] = "keiner"
|
||||||
|
# Sieht DIESER Worker eine KEYDB.cfg? Die API zeigt ihren eigenen Mount —
|
||||||
|
# bei einem Remote-Worker kann das etwas ganz anderes sein, und nur das
|
||||||
|
# Verzeichnis des rippenden Workers zählt (Befund 25.07.2026).
|
||||||
|
#
|
||||||
|
# Gemeldet wird die Angabe NUR, wenn das Datenverzeichnis hier wirklich
|
||||||
|
# eingehängt ist (os.path.ismount). Ein Remote-Transcode-Worker benutzt
|
||||||
|
# dasselbe Image — makemkvcon ist dort also vorhanden und der entrypoint
|
||||||
|
# legt den Ordner an —, er bekommt den Mount aber nicht und rippt nie.
|
||||||
|
# Ohne diese Prüfung trüge er im UI dauerhaft die Warnung "keine
|
||||||
|
# KEYDB.cfg", obwohl ihn das gar nichts angeht.
|
||||||
|
try:
|
||||||
|
import makemkv_daten
|
||||||
|
daten_dir = makemkv_daten.DATEN_DIR
|
||||||
|
except Exception:
|
||||||
|
daten_dir = ""
|
||||||
|
if shutil.which("makemkvcon") and daten_dir and os.path.ismount(daten_dir):
|
||||||
|
try:
|
||||||
|
info["keydb"] = "ja" if makemkv_daten.keydb_status().get("vorhanden") else "nein"
|
||||||
|
except Exception:
|
||||||
|
info["keydb"] = "unbekannt"
|
||||||
|
# Die entscheidende Zahl für 4K-UHD: wie viele Disc-Schlüssel kennt
|
||||||
|
# dieser Worker? 0 heißt, dass jede unbekannte UHD-Disc scheitert —
|
||||||
|
# makemkvcon holt sie unter Linux nie selbst (Befund 25.07.2026).
|
||||||
|
try:
|
||||||
|
info["schluessel"] = str(makemkv_daten.schluesselspeicher_status().get("schluessel", 0))
|
||||||
|
except Exception:
|
||||||
|
info["schluessel"] = "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,42 @@ 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 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))
|
||||||
|
|||||||
@@ -1,11 +1,51 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Schreibt den MakeMKV-Beta-Key aus der Umgebung in die Settings (falls gesetzt).
|
# Bereitet MakeMKVs Datenverzeichnis vor, BEVOR der Worker startet.
|
||||||
# Ohne Key: DVD-Ripping geht immer, Blu-ray läuft im 30-Tage-Testmodus.
|
#
|
||||||
|
# Wichtig (Befund 25.07.2026): Dieses Verzeichnis ist jetzt ein persistenter
|
||||||
|
# Mount vom Host (docker-compose.yml). Darin liegen KEYDB.cfg (die einzige
|
||||||
|
# heute funktionierende Schlüsselquelle für 4K-UHD), die AACS-Dumps
|
||||||
|
# fehlgeschlagener Discs und MakeMKVs settings.conf. Deshalb wird die
|
||||||
|
# settings.conf hier ERGÄNZT statt überschrieben — vorher hat dieses Skript
|
||||||
|
# sie bei jedem Start plattgemacht und dabei alles außer app_Key gelöscht.
|
||||||
|
#
|
||||||
|
# Ohne Beta-Key: DVD-Ripping geht immer, Blu-ray läuft im 30-Tage-Testmodus.
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
if [ -n "${MAKEMKV_APP_KEY}" ]; then
|
DATEN_DIR="${MAKEMKV_DATA_DIR:-/root/.MakeMKV}"
|
||||||
mkdir -p /root/.MakeMKV
|
CONF="${DATEN_DIR}/settings.conf"
|
||||||
printf 'app_Key = "%s"\n' "${MAKEMKV_APP_KEY}" > /root/.MakeMKV/settings.conf
|
|
||||||
|
# Die ganze Vorbereitung ist BEST EFFORT und läuft deshalb in einer Subshell
|
||||||
|
# ohne set -e. Grund: Das Datenverzeichnis kommt jetzt vom Host und kann
|
||||||
|
# schreibgeschützt sein oder einer fremden UID gehören (Freigabe, root_squash).
|
||||||
|
# Vorher war das unmöglich — geschrieben wurde containerintern. Bräche der
|
||||||
|
# Start daran ab, ergäbe "restart: unless-stopped" eine Endlosschleife, in der
|
||||||
|
# auch reines DVD-Rippen tot wäre, obwohl das die Datei gar nicht braucht.
|
||||||
|
# Denselben Weg geht tasks.py: OSError wird zur Warnung, der Job läuft weiter.
|
||||||
|
if ! (
|
||||||
|
set +e
|
||||||
|
mkdir -p "${DATEN_DIR}" || exit 1
|
||||||
|
[ -f "${CONF}" ] || touch "${CONF}" || exit 1
|
||||||
|
|
||||||
|
# app_Key aus der Umgebung setzen: alte Zeile raus, neue ans Ende. Alles
|
||||||
|
# andere in der Datei bleibt stehen. (grep -v liefert Exit 1, wenn nichts
|
||||||
|
# übrig bleibt — das ist hier kein Fehler.)
|
||||||
|
if [ -n "${MAKEMKV_APP_KEY}" ]; then
|
||||||
|
grep -v '^app_Key' "${CONF}" > "${CONF}.neu" 2>/dev/null
|
||||||
|
printf 'app_Key = "%s"\n' "${MAKEMKV_APP_KEY}" >> "${CONF}.neu" || exit 1
|
||||||
|
mv "${CONF}.neu" "${CONF}" || exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Web-Kontakt explizit einschalten. MakeMKV hat das per Default ohnehin an
|
||||||
|
# (Meldung 5074, am 25.07. im Worker verifiziert) — explizit steht es hier,
|
||||||
|
# damit die Einstellung nachvollziehbar ist und nicht versehentlich kippt.
|
||||||
|
# Quelle: forum.makemkv.com/forum/viewtopic.php?t=20364 (headless settings.conf)
|
||||||
|
grep -q '^app_UpdateEnable' "${CONF}" || printf 'app_UpdateEnable = "1"\n' >> "${CONF}" || exit 1
|
||||||
|
exit 0
|
||||||
|
); then
|
||||||
|
echo "WARNUNG: ${DATEN_DIR} ist nicht beschreibbar." >&2
|
||||||
|
echo " MakeMKV-Beta-Key und app_UpdateEnable wurden NICHT gesetzt." >&2
|
||||||
|
echo " Der Worker startet trotzdem. Prüfe Rechte und Eigentümer des" >&2
|
||||||
|
echo " Host-Verzeichnisses (Standard: /srv/rippy/makemkv)." >&2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|||||||
@@ -0,0 +1,375 @@
|
|||||||
|
"""MakeMKV-Datenverzeichnis: Schlüsselspeicher, KEYDB.cfg, AACS-Dumps.
|
||||||
|
|
||||||
|
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
|
||||||
|
Ursache ist weder die Disc noch das Laufwerk — LibreDrive v06.3 läuft und
|
||||||
|
MakeMKV liest die Disc — sondern:
|
||||||
|
|
||||||
|
makemkvcon unter LINUX ruft die Disc-Schluessel nie ab.
|
||||||
|
Die Windows-Version tut es.
|
||||||
|
|
||||||
|
Gemessen, nicht vermutet:
|
||||||
|
* Linux: in KEINEM Lauf auch nur eine Verbindung nach draussen. Geprueft mit
|
||||||
|
leerem UND mit gefülltem Schlüsselspeicher, mit und ohne --noscan, mit
|
||||||
|
dev:/dev/sr0 und mit disc:0, und mit erzwungener frischer Prüfung
|
||||||
|
(update.conf gelöscht, Meldung 5074 belegt den Web-Kontakt). Immer:
|
||||||
|
keine Verbindung, kein Schluessel.
|
||||||
|
* Windows, dieselbe Disc, dasselbe Laufwerk: Meldung 3338 "Downloading
|
||||||
|
latest HK to ...", Verbindung nach 185.84.108.20:443
|
||||||
|
(web33.majordomo.ru), _private_data.tar wächst — und die Disc geht auf
|
||||||
|
(TCOUNT:5, "Operation successfully completed").
|
||||||
|
* Der Code dafür steckt auch im Linux-Binary: die Meldungsvorlage
|
||||||
|
"Downloading latest %1 to %2 ..." steht in makemkvcon. Sie löst nur nie
|
||||||
|
aus. Gleiches Symptom im Forum, seit Jahren offen und unbeantwortet.
|
||||||
|
|
||||||
|
FRUEHERE FEHLDIAGNOSE, bewusst festgehalten: An dieser Stelle stand zuerst,
|
||||||
|
MakeMKVs Schluessel-Kanal sei abgeschaltet. Das war FALSCH. Die Herleitung
|
||||||
|
stützte sich auf zwei Hostnamen aus alten Forumsbeitraegen
|
||||||
|
(hkdata.fairuse.org, hkdata.crabdance.com), die tatsaechlich nicht mehr
|
||||||
|
aufloesen — MakeMKV benutzt sie aber laengst nicht mehr. Der Dienst lebt, der
|
||||||
|
Worker erreicht ihn sogar; er wird unter Linux nur nie gefragt.
|
||||||
|
|
||||||
|
WAS DARAUS FOLGT: Der Schlüsselspeicher (_private_data.tar) muss von einer
|
||||||
|
MakeMKV-Installation kommen, die ihn wirklich abruft — praktisch von Windows.
|
||||||
|
Rippy nimmt ihn unter Einstellungen -> System entgegen. Die KEYDB.cfg bleibt
|
||||||
|
der Notnagel für Pressungen, die auch MakeMKV selbst nicht kennt.
|
||||||
|
|
||||||
|
Rippy liefert KEINE Schluessel mit, lädt keine herunter und verteilt keine.
|
||||||
|
Es verwaltet nur, was der Nutzer selbst mitbringt.
|
||||||
|
|
||||||
|
QUELLEN (AGENTS Regel D — externe Schnittstellen nie aus dem Kopf):
|
||||||
|
* 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=34022
|
||||||
|
* Schluessel liegen als hkd_*.bin in _private_data.tar:
|
||||||
|
https://forum.makemkv.com/forum/viewtopic.php?t=32675
|
||||||
|
* Datenverzeichnis und Dateiname KEYDB.cfg GROSS geschrieben (unter Linux
|
||||||
|
case-sensitiv), dort geloest mit "cp KEYDB.cfg ~/.MakeMKV/":
|
||||||
|
https://forum.makemkv.com/forum/viewtopic.php?t=30636
|
||||||
|
* Zeilenformat der KEYDB.cfg (libaacs): Disc-Kennung = 40 Hex-Zeichen,
|
||||||
|
optional mit 0x-Praefix, dann "= Titel", danach optionale Felder wie
|
||||||
|
"| V | <32 Hex>"; Zeilen ab ";" sind Kommentare:
|
||||||
|
https://github.com/ShiftMediaProject/libaacs/blob/master/KEYDB.cfg
|
||||||
|
* Den AACS-Dump legt MakeMKV selbst ab, Meldung 3332 "Saved AACS dump file
|
||||||
|
as file:///root/.MakeMKV/<name>.tgz" (am 25.07.2026 so beobachtet).
|
||||||
|
|
||||||
|
ZWILLINGSDATEI: liegt identisch unter docker/api/ und docker/worker/ — beide
|
||||||
|
Images brauchen sie, ein gemeinsames Paket gibt es in diesem Projekt nicht
|
||||||
|
(gleiche Lage wie bei db.py). Änderungen IMMER in BEIDEN Dateien nachziehen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tarfile
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
# Container-Pfad aus der Umgebung: /root/.MakeMKV im Worker (nur dort sucht
|
||||||
|
# makemkvcon), /app/makemkv-data in der API. Beide zeigen laut
|
||||||
|
# docker-compose.yml auf DASSELBE Host-Verzeichnis.
|
||||||
|
DATEN_DIR = os.getenv("MAKEMKV_DATA_DIR", "/root/.MakeMKV")
|
||||||
|
|
||||||
|
# GROSS geschrieben — unter Linux case-sensitiv, "keydb.cfg" wird ignoriert.
|
||||||
|
KEYDB_NAME = "KEYDB.cfg"
|
||||||
|
|
||||||
|
# Obergrenze für den Upload. Eine vollständige oeffentliche KEYDB.cfg liegt
|
||||||
|
# im einstelligen MB-Bereich; 64 MB sind reichlich Luft und verhindern, dass
|
||||||
|
# eine versehentlich hochgeladene Riesendatei den Speicher vollschreibt.
|
||||||
|
MAX_KEYDB_BYTES = 64 * 1024 * 1024
|
||||||
|
|
||||||
|
# Eine Disc-Zeile beginnt mit der 40 Zeichen langen Hex-Kennung (optional mit
|
||||||
|
# 0x davor), danach folgt das Gleichheitszeichen. Alles andere (Kommentare ab
|
||||||
|
# ";", Leerzeilen, Fortsetzungsfelder) zählt nicht als Eintrag.
|
||||||
|
_DISC_ZEILE = re.compile(r"^\s*(?:0x)?[0-9a-fA-F]{40}\s*=")
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(zeitstempel: float) -> str:
|
||||||
|
"""Unix-Zeit -> ISO-8601 in UTC (sekundengenau), wie db.utcnow() es tut."""
|
||||||
|
return datetime.fromtimestamp(zeitstempel, timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def keydb_pfad(daten_dir: str = None) -> str:
|
||||||
|
"""Voller Pfad zur KEYDB.cfg im Datenverzeichnis."""
|
||||||
|
return os.path.join(daten_dir or DATEN_DIR, KEYDB_NAME)
|
||||||
|
|
||||||
|
|
||||||
|
def zaehle_disc_eintraege(inhalt: str) -> int:
|
||||||
|
"""Zeilen mit Disc-Kennung zählen (pure Funktion, testbar).
|
||||||
|
|
||||||
|
Bewusst eine Heuristik und keine vollständige Auswertung: MakeMKV liest
|
||||||
|
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
|
||||||
|
"da liegt wirklich etwas drin" von "leere oder falsche Datei" zu
|
||||||
|
unterscheiden — sie wird deshalb auch genau so beschriftet.
|
||||||
|
"""
|
||||||
|
return sum(1 for zeile in inhalt.splitlines() if _DISC_ZEILE.match(zeile))
|
||||||
|
|
||||||
|
|
||||||
|
def keydb_pruefen(inhalt: str) -> str:
|
||||||
|
"""Prüft hochgeladenen Inhalt; gibt deutschen Fehlertext oder "" zurück.
|
||||||
|
|
||||||
|
Verhindert den häufigsten Bedienfehler: statt der KEYDB.cfg landet die
|
||||||
|
HTML-Fehlerseite eines Downloads oder eine leere Datei im Verzeichnis —
|
||||||
|
MakeMKV würde dann still weiter "volume key is unknown" melden.
|
||||||
|
"""
|
||||||
|
if not inhalt.strip():
|
||||||
|
return "Die Datei ist leer."
|
||||||
|
if len(inhalt.encode("utf-8")) > MAX_KEYDB_BYTES:
|
||||||
|
return (
|
||||||
|
"Die Datei ist größer als "
|
||||||
|
f"{MAX_KEYDB_BYTES // (1024 * 1024)} MB — das ist keine KEYDB.cfg."
|
||||||
|
)
|
||||||
|
if inhalt.lstrip()[:1] == "<":
|
||||||
|
return (
|
||||||
|
"Das sieht nach HTML aus, nicht nach einer KEYDB.cfg — "
|
||||||
|
"vermutlich wurde eine Fehlerseite statt der Datei geladen."
|
||||||
|
)
|
||||||
|
if zaehle_disc_eintraege(inhalt) == 0:
|
||||||
|
return (
|
||||||
|
"Keine einzige Zeile mit Disc-Kennung gefunden (40 Hex-Zeichen, "
|
||||||
|
"dann ein Gleichheitszeichen). Das ist keine KEYDB.cfg."
|
||||||
|
)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def keydb_status(daten_dir: str = None) -> dict:
|
||||||
|
"""Zustand der KEYDB.cfg. Fehlt sie, ist das der Normalfall, kein Fehler."""
|
||||||
|
pfad = keydb_pfad(daten_dir)
|
||||||
|
try:
|
||||||
|
angaben = os.stat(pfad)
|
||||||
|
except OSError:
|
||||||
|
return {
|
||||||
|
"vorhanden": False,
|
||||||
|
"pfad": pfad,
|
||||||
|
"groesse_bytes": 0,
|
||||||
|
"eintraege": 0,
|
||||||
|
"geaendert": "",
|
||||||
|
}
|
||||||
|
eintraege = 0
|
||||||
|
try:
|
||||||
|
with open(pfad, encoding="utf-8", errors="replace") as datei:
|
||||||
|
eintraege = zaehle_disc_eintraege(datei.read())
|
||||||
|
except OSError:
|
||||||
|
pass # Datei da, aber unlesbar: Größe/Datum stimmen trotzdem
|
||||||
|
return {
|
||||||
|
"vorhanden": True,
|
||||||
|
"pfad": pfad,
|
||||||
|
"groesse_bytes": angaben.st_size,
|
||||||
|
"eintraege": eintraege,
|
||||||
|
"geaendert": _iso(angaben.st_mtime),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def keydb_schreiben(inhalt: str, daten_dir: str = None) -> dict:
|
||||||
|
"""Schreibt die KEYDB.cfg atomar und meldet den neuen Zustand.
|
||||||
|
|
||||||
|
Erst in eine Nebendatei, dann os.replace: während ein Rip läuft, darf
|
||||||
|
makemkvcon niemals eine halb geschriebene Datei zu sehen bekommen.
|
||||||
|
"""
|
||||||
|
pfad = keydb_pfad(daten_dir)
|
||||||
|
os.makedirs(os.path.dirname(pfad), exist_ok=True)
|
||||||
|
neben = pfad + ".neu"
|
||||||
|
try:
|
||||||
|
with open(neben, "w", encoding="utf-8", newline="\n") as datei:
|
||||||
|
datei.write(inhalt)
|
||||||
|
os.replace(neben, pfad)
|
||||||
|
except OSError:
|
||||||
|
# Die Nebendatei nie liegen lassen: eine halb geschriebene
|
||||||
|
# KEYDB.cfg.neu verwirrt jeden, der ins Verzeichnis schaut, und
|
||||||
|
# belegt im Extremfall 64 MB, die niemand mehr aufräumt.
|
||||||
|
try:
|
||||||
|
os.remove(neben)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
return keydb_status(daten_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def keydb_loeschen(daten_dir: str = None) -> dict:
|
||||||
|
"""Entfernt die KEYDB.cfg (z. B. nach einem Fehlgriff beim Hochladen).
|
||||||
|
|
||||||
|
Nur "Datei war schon weg" wird geschluckt — das ist das gewünschte
|
||||||
|
Ergebnis. Jeder andere Fehler (schreibgeschützter Mount, fremder
|
||||||
|
Eigentümer) MUSS nach oben durch: sonst meldete die API einen Erfolg,
|
||||||
|
den es nicht gab, und die Datei wirkte beim nächsten Rip weiter.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
os.remove(keydb_pfad(daten_dir))
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
return keydb_status(daten_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def ist_aacs_dump(name: str) -> bool:
|
||||||
|
"""Dateiname eines AACS-Dumps? (pure Funktion, testbar)
|
||||||
|
|
||||||
|
MakeMKV legt ihn als <MKB..._NAME_....>.tgz direkt im Datenverzeichnis ab
|
||||||
|
(Meldung 3332). Pfadtrenner und fuehrende Punkte werden hier schon
|
||||||
|
ausgeschlossen, damit der Download-Endpunkt keine Pfad-Tricks erlaubt.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
name.endswith(".tgz")
|
||||||
|
and "/" not in name
|
||||||
|
and "\\" not in name
|
||||||
|
and not name.startswith(".")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def dumps_auflisten(daten_dir: str = None) -> list:
|
||||||
|
"""Alle AACS-Dumps im Datenverzeichnis, neueste zuerst."""
|
||||||
|
ordner = daten_dir or DATEN_DIR
|
||||||
|
try:
|
||||||
|
namen = os.listdir(ordner)
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
liste = []
|
||||||
|
for name in namen:
|
||||||
|
if not ist_aacs_dump(name):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
angaben = os.stat(os.path.join(ordner, name))
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
liste.append(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"groesse_bytes": angaben.st_size,
|
||||||
|
"geaendert": _iso(angaben.st_mtime),
|
||||||
|
"_sort": angaben.st_mtime,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
liste.sort(key=lambda eintrag: eintrag["_sort"], reverse=True)
|
||||||
|
for eintrag in liste:
|
||||||
|
del eintrag["_sort"]
|
||||||
|
return liste
|
||||||
|
|
||||||
|
|
||||||
|
# --- Schlüsselspeicher (_private_data.tar) -------------------------------
|
||||||
|
#
|
||||||
|
# Das ist MakeMKVs eigener Speicher für die "Hashed Keys": ein tar-Archiv mit
|
||||||
|
# hkd_*.bin-Eintraegen. Unter Windows füllt MakeMKV es selbst (Meldung 3338),
|
||||||
|
# unter Linux nie — siehe Modul-Kopf. Rippy nimmt die Datei deshalb entgegen
|
||||||
|
# und legt sie ins Datenverzeichnis; MakeMKV liest sie beim nächsten Start.
|
||||||
|
|
||||||
|
PRIVATE_DATA_NAME = "_private_data.tar"
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# client_max_body_size in docker/ui/nginx.conf: wäre die Grenze hier höher,
|
||||||
|
# würde nginx den Upload abweisen, bevor die API ihn überhaupt sieht.
|
||||||
|
MAX_PRIVATE_DATA_BYTES = 64 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def private_data_pfad(daten_dir: str = None) -> str:
|
||||||
|
"""Voller Pfad zum Schlüsselspeicher im Datenverzeichnis."""
|
||||||
|
return os.path.join(daten_dir or DATEN_DIR, PRIVATE_DATA_NAME)
|
||||||
|
|
||||||
|
|
||||||
|
def zaehle_schluessel(rohdaten: bytes) -> int:
|
||||||
|
"""Anzahl der hkd_*.bin-Eintraege im Archiv (pure Funktion, testbar).
|
||||||
|
|
||||||
|
Das ist die ehrliche Kennzahl für "wie viele Disc-Schluessel kennt diese
|
||||||
|
Installation". Ein frischer, leerer Speicher enthält nur eine
|
||||||
|
Index-Datei und kommt hier auf 0 — genau der Zustand, in dem jede
|
||||||
|
unbekannte UHD-Disc scheitert.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with tarfile.open(fileobj=io.BytesIO(rohdaten)) as archiv:
|
||||||
|
return sum(1 for name in archiv.getnames() if name.startswith("hkd_"))
|
||||||
|
except (tarfile.TarError, OSError, EOFError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def private_data_pruefen(rohdaten: bytes) -> str:
|
||||||
|
"""Prüft hochgeladene Rohdaten; deutscher Fehlertext oder "".
|
||||||
|
|
||||||
|
Faengt die beiden Bedienfehler ab, die sonst still danebengehen: eine
|
||||||
|
voellig andere Datei hochladen, oder den Speicher einer Installation, die
|
||||||
|
selbst noch keine Schluessel geholt hat (dann ändert sich nichts, und
|
||||||
|
niemand versteht warum).
|
||||||
|
"""
|
||||||
|
if not rohdaten:
|
||||||
|
return "Die Datei ist leer."
|
||||||
|
if len(rohdaten) > MAX_PRIVATE_DATA_BYTES:
|
||||||
|
return (
|
||||||
|
"Die Datei ist größer als "
|
||||||
|
f"{MAX_PRIVATE_DATA_BYTES // (1024 * 1024)} MB — das ist kein "
|
||||||
|
"MakeMKV-Schlüsselspeicher."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with tarfile.open(fileobj=io.BytesIO(rohdaten)) as archiv:
|
||||||
|
namen = archiv.getnames()
|
||||||
|
except (tarfile.TarError, OSError, EOFError):
|
||||||
|
return (
|
||||||
|
"Das ist kein tar-Archiv. Erwartet wird die Datei "
|
||||||
|
f"{PRIVATE_DATA_NAME} aus dem MakeMKV-Datenverzeichnis."
|
||||||
|
)
|
||||||
|
if not any(name.startswith("hkd_") for name in namen):
|
||||||
|
return (
|
||||||
|
"In dieser Datei steckt kein einziger Schluessel (kein hkd_*.bin). "
|
||||||
|
"Sie stammt vermutlich von einer MakeMKV-Installation, die selbst "
|
||||||
|
"noch keine geholt hat — öffne dort erst einmal eine Disc."
|
||||||
|
)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def schluesselspeicher_status(daten_dir: str = None) -> dict:
|
||||||
|
"""Zustand des Schlüsselspeichers. Fehlt er, ist das kein Fehler."""
|
||||||
|
pfad = private_data_pfad(daten_dir)
|
||||||
|
try:
|
||||||
|
angaben = os.stat(pfad)
|
||||||
|
with open(pfad, "rb") as datei:
|
||||||
|
schluessel = zaehle_schluessel(datei.read())
|
||||||
|
except OSError:
|
||||||
|
return {
|
||||||
|
"vorhanden": False,
|
||||||
|
"pfad": pfad,
|
||||||
|
"groesse_bytes": 0,
|
||||||
|
"schluessel": 0,
|
||||||
|
"geaendert": "",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"vorhanden": True,
|
||||||
|
"pfad": pfad,
|
||||||
|
"groesse_bytes": angaben.st_size,
|
||||||
|
"schluessel": schluessel,
|
||||||
|
"geaendert": _iso(angaben.st_mtime),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def private_data_schreiben(rohdaten: bytes, daten_dir: str = None) -> dict:
|
||||||
|
"""Legt den Schlüsselspeicher atomar ab und meldet den neuen Zustand.
|
||||||
|
|
||||||
|
Atomar aus demselben Grund wie bei der KEYDB.cfg: während ein Rip läuft,
|
||||||
|
darf makemkvcon nie ein halb geschriebenes Archiv sehen.
|
||||||
|
"""
|
||||||
|
pfad = private_data_pfad(daten_dir)
|
||||||
|
os.makedirs(os.path.dirname(pfad), exist_ok=True)
|
||||||
|
neben = pfad + ".neu"
|
||||||
|
try:
|
||||||
|
with open(neben, "wb") as datei:
|
||||||
|
datei.write(rohdaten)
|
||||||
|
os.replace(neben, pfad)
|
||||||
|
except OSError:
|
||||||
|
try:
|
||||||
|
os.remove(neben)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
return schluesselspeicher_status(daten_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def settings_conf_zusammenfuehren(inhalt: str, key: str) -> str:
|
||||||
|
"""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
|
||||||
|
überschrieben. Mit dem jetzt persistenten Datenverzeichnis wäre damit
|
||||||
|
bei jedem Containerstart und vor jedem Rip alles andere weg — z. B.
|
||||||
|
app_UpdateEnable. Leerer Key lässt die vorhandene Zeile ebenfalls fallen,
|
||||||
|
damit ein bewusst geleerter Key nicht heimlich weiterwirkt.
|
||||||
|
"""
|
||||||
|
zeilen = [z for z in inhalt.splitlines() if not z.lstrip().startswith("app_Key")]
|
||||||
|
if key:
|
||||||
|
zeilen.append('app_Key = "{}"'.format(key))
|
||||||
|
text = "\n".join(zeilen).strip("\n")
|
||||||
|
return text + "\n" if text else ""
|
||||||
+243
-52
@@ -25,6 +25,36 @@ 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 — dasselbe ioctl wie in api/devices.py
|
||||||
|
CDROMEJECT = 0x5309
|
||||||
|
|
||||||
|
|
||||||
|
def wirf_disc_aus(device_path: str) -> bool:
|
||||||
|
"""Wirft die Disc aus; gibt zurück, ob es geklappt hat. Wirft NIE.
|
||||||
|
|
||||||
|
Befund 25.07.2026: Die Einstellung „Automatischer Auswurf" (Standard: ein,
|
||||||
|
Beschreibung „Disc nach erfolgreichem Ripping automatisch auswerfen") wurde
|
||||||
|
von niemandem gelesen — bei DVD/Blu-ray warf Rippy nie aus.
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from fcntl import ioctl
|
||||||
|
except ImportError: # Windows
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
fd = os.open(device_path, os.O_RDONLY | os.O_NONBLOCK)
|
||||||
|
try:
|
||||||
|
ioctl(fd, CDROMEJECT, 0)
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
@@ -114,7 +144,7 @@ def lies_titel_info(device_path: str, timeout: int = 300) -> list:
|
|||||||
|
|
||||||
|
|
||||||
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,
|
||||||
progress_cb=None) -> dict:
|
progress_cb=None, log_cb=None) -> dict:
|
||||||
"""Rippt GENAU die gewählten Titel (makemkvcon kann pro Aufruf nur einen
|
"""Rippt GENAU die gewählten Titel (makemkvcon kann pro Aufruf nur einen
|
||||||
Titel oder 'all' — also ein Aufruf je Titel, Fortschritt anteilig)."""
|
Titel oder 'all' — also ein Aufruf je Titel, Fortschritt anteilig)."""
|
||||||
gesamt = len(titel_liste)
|
gesamt = len(titel_liste)
|
||||||
@@ -124,7 +154,8 @@ def rip_titel_auswahl(device_path: str, output_dir: str, titel_liste: list,
|
|||||||
if progress_cb:
|
if progress_cb:
|
||||||
progress_cb(int((_i * 100 + p) / gesamt))
|
progress_cb(int((_i * 100 + p) / gesamt))
|
||||||
|
|
||||||
ergebnis = run_makemkv(device_path, output_dir, progress_cb=anteilig, titel=str(nr))
|
ergebnis = run_makemkv(device_path, output_dir, progress_cb=anteilig, titel=str(nr),
|
||||||
|
log_cb=log_cb)
|
||||||
if ergebnis.get("status") == "cancelled":
|
if ergebnis.get("status") == "cancelled":
|
||||||
return ergebnis
|
return ergebnis
|
||||||
if ergebnis.get("status") != "success":
|
if ergebnis.get("status") != "success":
|
||||||
@@ -181,6 +212,27 @@ def lies_datei_dauer(pfad: str, timeout: int = 120) -> int:
|
|||||||
return parse_scan_dauer((ergebnis.stdout or "") + (ergebnis.stderr or ""))
|
return parse_scan_dauer((ergebnis.stdout or "") + (ergebnis.stderr or ""))
|
||||||
|
|
||||||
|
|
||||||
|
_MSG_RE = re.compile(r'^MSG:(\d+),\d+,\d+,"((?:[^"\\]|\\.)*)"')
|
||||||
|
|
||||||
|
|
||||||
|
def parse_msg(zeile: str):
|
||||||
|
"""MSG-Zeile -> (code, klartext) oder None (pure Funktion, testbar).
|
||||||
|
|
||||||
|
Format laut https://www.makemkv.com/developers/usage.txt:
|
||||||
|
MSG:code,flags,count,"message","format","param0",... — Feld 4 ist der
|
||||||
|
fertig zusammengesetzte Klartext.
|
||||||
|
|
||||||
|
Bis zum 25.07.2026 stand hier line.split(",", 4)[3]: das schnitt JEDE
|
||||||
|
Meldung ab, die selbst ein Komma enthält — und MakeMKV schreibt solche
|
||||||
|
laufend ("Title #1 has length of 12 seconds, which is less than ...").
|
||||||
|
Deshalb eine Regex, die die Anführungszeichen respektiert.
|
||||||
|
"""
|
||||||
|
treffer = _MSG_RE.match(zeile.strip())
|
||||||
|
if not treffer:
|
||||||
|
return None
|
||||||
|
return int(treffer.group(1)), treffer.group(2).replace('\\"', '"')
|
||||||
|
|
||||||
|
|
||||||
def get_progress_from_prgv(line: str) -> int:
|
def get_progress_from_prgv(line: str) -> int:
|
||||||
"""Extrahiert Gesamt-Fortschritt (0-100) aus einer PRGV-Zeile.
|
"""Extrahiert Gesamt-Fortschritt (0-100) aus einer PRGV-Zeile.
|
||||||
|
|
||||||
@@ -203,6 +255,68 @@ def check_handbrake_installed() -> bool:
|
|||||||
|
|
||||||
DEFAULT_HB_PRESET = "H.265 MKV 1080p30"
|
DEFAULT_HB_PRESET = "H.265 MKV 1080p30"
|
||||||
|
|
||||||
|
# Ein Preset je Disc-Typ (Befund 25.07.2026). Vorher galt EIN Preset für
|
||||||
|
# alles: eine 4K-UHD wurde damit auf 1080p heruntergerechnet und eine DVD auf
|
||||||
|
# 1080p hochskaliert — beides Unsinn, und beim ersten UHD-Rip wäre die 4K-
|
||||||
|
# Auflösung still verlorengegangen. Den Disc-Typ kennt Rippy ohnehin.
|
||||||
|
PRESET_SCHLUESSEL = {
|
||||||
|
"dvd": "transcodePresetDvd",
|
||||||
|
"bluray": "transcodePresetBluray",
|
||||||
|
"uhd": "transcodePresetUhd",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
"""Welches HandBrake-Preset gilt für diesen Disc-Typ? (pure, testbar)
|
||||||
|
|
||||||
|
Reihenfolge: Preset des Disc-Typs → allgemeines transcodePreset →
|
||||||
|
DEFAULT_HB_PRESET. Diese Kette hält Bestandsinstallationen am Leben, in
|
||||||
|
denen nur `transcodePreset` gesetzt ist: solange der Nutzer die neuen
|
||||||
|
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 {}
|
||||||
|
schluessel = PRESET_SCHLUESSEL.get(disc_type or "")
|
||||||
|
if schluessel:
|
||||||
|
eigen = (einstellungen.get(schluessel) or "").strip()
|
||||||
|
if eigen and eigen != PRESET_KEINE:
|
||||||
|
return eigen
|
||||||
|
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) -> list:
|
||||||
"""Baut das HandBrake-Kommando für die Kompressions-Stufe (pure Funktion).
|
"""Baut das HandBrake-Kommando für die Kompressions-Stufe (pure Funktion).
|
||||||
@@ -222,20 +336,57 @@ def build_handbrake_cmd(input_path: str, output_path: str, preset: str = DEFAULT
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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 run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET,
|
||||||
"""Komprimiert eine MKV-Datei mit HandBrakeCLI; meldet Fortschritt."""
|
progress_cb=None, abbruch_cb=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"}
|
||||||
|
|
||||||
@@ -247,44 +398,64 @@ def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PR
|
|||||||
text=True,
|
text=True,
|
||||||
bufsize=1
|
bufsize=1
|
||||||
)
|
)
|
||||||
|
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 _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."""
|
||||||
|
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()
|
||||||
|
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}
|
||||||
|
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:
|
||||||
@@ -297,8 +468,17 @@ def write_abcde_config(output_dir: str) -> str:
|
|||||||
return tmp.name
|
return tmp.name
|
||||||
|
|
||||||
|
|
||||||
def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str = "all") -> dict:
|
def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str = "all",
|
||||||
"""Rippt eine DVD/Blu-ray verlustfrei mit makemkvcon; meldet Fortschritt."""
|
log_cb=None) -> dict:
|
||||||
|
"""Rippt eine DVD/Blu-ray verlustfrei mit makemkvcon; meldet Fortschritt.
|
||||||
|
|
||||||
|
log_cb(code, text) bekommt JEDE MakeMKV-Meldung. Bewusst ein eigener
|
||||||
|
Kanal statt progress_cb: der Fortschritts-Callback in tasks.py verwirft
|
||||||
|
Aufrufe, bei denen sich die Prozentzahl nicht geaendert hat — Meldungen
|
||||||
|
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
|
||||||
|
nicht einmal versucht, einen Schluessel zu holen (siehe makemkv_daten).
|
||||||
|
"""
|
||||||
if not check_makemkv_installed():
|
if not check_makemkv_installed():
|
||||||
return {"status": "error", "error": "makemkvcon ist nicht installiert"}
|
return {"status": "error", "error": "makemkvcon ist nicht installiert"}
|
||||||
|
|
||||||
@@ -328,15 +508,23 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
|
|||||||
try:
|
try:
|
||||||
for line in process.stdout:
|
for line in process.stdout:
|
||||||
progress = get_progress_from_prgv(line)
|
progress = get_progress_from_prgv(line)
|
||||||
if progress >= 0 and progress_cb:
|
if progress >= 0:
|
||||||
progress_cb(progress)
|
if progress_cb:
|
||||||
elif line.startswith("MSG:"):
|
progress_cb(progress)
|
||||||
# MSG:code,flags,count,"message",... — Klartext ist Feld 4
|
continue
|
||||||
teile = line.split(",", 4)
|
meldung = parse_msg(line)
|
||||||
if len(teile) >= 4:
|
if meldung is None:
|
||||||
letzte_meldung = teile[3].strip('"')
|
continue
|
||||||
if any(muster in letzte_meldung for muster in KRITISCH):
|
code, letzte_meldung = meldung
|
||||||
kritische_meldungen.append(letzte_meldung)
|
if any(muster in letzte_meldung for muster in KRITISCH):
|
||||||
|
kritische_meldungen.append(letzte_meldung)
|
||||||
|
if log_cb:
|
||||||
|
try:
|
||||||
|
log_cb(code, letzte_meldung)
|
||||||
|
except RipAbbruch:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
pass # Protokollieren darf einen laufenden Rip nie beenden
|
||||||
except RipAbbruch:
|
except RipAbbruch:
|
||||||
process.kill()
|
process.kill()
|
||||||
process.wait()
|
process.wait()
|
||||||
@@ -373,7 +561,7 @@ 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) -> dict:
|
titel_liste: list = None, log_cb=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,
|
||||||
@@ -388,7 +576,7 @@ def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_c
|
|||||||
|
|
||||||
if titel_liste:
|
if titel_liste:
|
||||||
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)
|
return rip_titel_auswahl(device_path, output_dir, titel_liste, progress_cb, log_cb)
|
||||||
|
|
||||||
titel = "all"
|
titel = "all"
|
||||||
if nur_hauptfilm:
|
if nur_hauptfilm:
|
||||||
@@ -397,11 +585,14 @@ def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_c
|
|||||||
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
|
||||||
return run_makemkv(device_path, output_dir, progress_cb=progress_cb, titel=titel)
|
return run_makemkv(device_path, output_dir, progress_cb=progress_cb, titel=titel,
|
||||||
|
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",
|
||||||
@@ -425,7 +616,7 @@ 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,
|
||||||
|
|||||||
+273
-42
@@ -12,14 +12,18 @@ 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 shutil
|
import shutil
|
||||||
|
import time
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
import db
|
import db
|
||||||
|
import makemkv_daten
|
||||||
import medien
|
import medien
|
||||||
import notify
|
import notify
|
||||||
from celery_app import celery_app
|
from celery_app import celery_app
|
||||||
@@ -34,14 +38,16 @@ except ImportError: # Windows: kein fcntl
|
|||||||
disc_size_bytes = None
|
disc_size_bytes = None
|
||||||
|
|
||||||
from ripping import (
|
from ripping import (
|
||||||
DEFAULT_HB_PRESET,
|
|
||||||
RIP_OUTPUT_DIR,
|
RIP_OUTPUT_DIR,
|
||||||
RipAbbruch,
|
RipAbbruch,
|
||||||
|
komprimieren_fuer,
|
||||||
lies_datei_dauer,
|
lies_datei_dauer,
|
||||||
lies_titel_info,
|
lies_titel_info,
|
||||||
|
preset_fuer,
|
||||||
rip_cd,
|
rip_cd,
|
||||||
rip_video,
|
rip_video,
|
||||||
run_handbrake,
|
run_handbrake,
|
||||||
|
wirf_disc_aus,
|
||||||
)
|
)
|
||||||
|
|
||||||
API_URL = os.getenv("API_URL", "http://api:8000")
|
API_URL = os.getenv("API_URL", "http://api:8000")
|
||||||
@@ -88,29 +94,69 @@ 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) -> str:
|
def _arbeitsverzeichnis(einstellungen: dict, job_wahl: str = "") -> str:
|
||||||
"""Basis für Roh-Rips: UI-Setting `workDir` (unter /app/media, z. B. eine
|
"""Basis für Roh-Rips. Reihenfolge: Wahl DIESES Rips → UI-Setting
|
||||||
NAS-Freigabe) schlägt den Container-Default /app/temp/raw.
|
`workDir` → Container-Default /app/temp/raw.
|
||||||
|
|
||||||
Hintergrund (Commander 24.07.): Die VM-Platte (150 GB) reicht für BD-50,
|
Hintergrund (Commander 24.07.): Die VM-Platte (150 GB) reicht für BD-50,
|
||||||
aber eine 4K-UHD (bis 100 GB roh + Kompression daneben) sprengt sie —
|
aber eine 4K-UHD (bis 100 GB roh + Kompression daneben) sprengt sie —
|
||||||
das Arbeitsverzeichnis muss deshalb auf ein großes Ziel umlegbar sein.
|
das Arbeitsverzeichnis muss deshalb auf ein großes Ziel umlegbar sein.
|
||||||
|
|
||||||
|
Pro Rip wählbar seit 25.07.2026 (Commander-Wunsch): beim „Rippen starten"
|
||||||
|
entscheidet man je Disc, wo die Rohdaten landen. Das Setting bleibt der
|
||||||
|
Standard — und ist damit der Wert, der bei Vollautomatik-Rips greift, bei
|
||||||
|
denen niemand gefragt wird.
|
||||||
"""
|
"""
|
||||||
work_dir = (einstellungen.get("workDir") or "").strip()
|
# posixpath statt os.path: Das sind IMMER Container-Pfade (/app/media/...),
|
||||||
if work_dir:
|
# auch wenn ein nativer Windows-Worker dieses Modul lädt — der übersetzt
|
||||||
normalisiert = os.path.normpath(work_dir)
|
# sie erst später mit pfad_lokal(). os.path.normpath macht unter Windows
|
||||||
if normalisiert.startswith(MEDIA_ROOT):
|
# Backslashes daraus, und dann greift die MEDIA_ROOT-Prüfung nicht mehr.
|
||||||
return normalisiert
|
for kandidat in (job_wahl, einstellungen.get("workDir")):
|
||||||
|
wert = (kandidat or "").strip()
|
||||||
|
if wert:
|
||||||
|
normalisiert = posixpath.normpath(wert)
|
||||||
|
if unter_wurzel(normalisiert, MEDIA_ROOT):
|
||||||
|
return normalisiert
|
||||||
return RAW_DIR
|
return RAW_DIR
|
||||||
|
|
||||||
|
|
||||||
@@ -147,19 +193,117 @@ def _makemkv_key_anwenden(einstellungen: dict) -> None:
|
|||||||
|
|
||||||
Damit ist der Monats-Key ohne Rebuild/Neustart aktualisierbar
|
Damit ist der Monats-Key ohne Rebuild/Neustart aktualisierbar
|
||||||
(Einstellungen → System). Format wie entrypoint.sh: settings.conf.
|
(Einstellungen → System). Format wie entrypoint.sh: settings.conf.
|
||||||
|
|
||||||
|
Ergänzend statt überschreibend (Befund 25.07.2026): das Datenverzeichnis
|
||||||
|
ist jetzt persistent, und hier stand vorher ein open(..., "w") — das warf
|
||||||
|
vor JEDEM Rip alles andere aus der settings.conf, z. B. app_UpdateEnable
|
||||||
|
aus dem entrypoint. Beide Schreiber müssen gleich arbeiten, sonst kommt
|
||||||
|
der Fehler beim nächsten Rip still zurück.
|
||||||
"""
|
"""
|
||||||
key = (einstellungen.get("makemkvAppKey") or "").strip()
|
key = (einstellungen.get("makemkvAppKey") or "").strip()
|
||||||
if not key:
|
if not key:
|
||||||
return
|
return
|
||||||
ordner = os.path.expanduser("~/.MakeMKV")
|
pfad = os.path.join(makemkv_daten.DATEN_DIR, "settings.conf")
|
||||||
try:
|
try:
|
||||||
os.makedirs(ordner, exist_ok=True)
|
os.makedirs(makemkv_daten.DATEN_DIR, exist_ok=True)
|
||||||
with open(os.path.join(ordner, "settings.conf"), "w") as f:
|
try:
|
||||||
f.write(f'app_Key = "{key}"\n')
|
with open(pfad, encoding="utf-8", errors="replace") as f:
|
||||||
|
alt = f.read()
|
||||||
|
except OSError:
|
||||||
|
alt = ""
|
||||||
|
with open(pfad, "w", encoding="utf-8", newline="\n") as f:
|
||||||
|
f.write(makemkv_daten.settings_conf_zusammenfuehren(alt, key))
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
db.add_log("warning", "worker", f"MakeMKV-Key konnte nicht gesetzt werden: {e}")
|
db.add_log("warning", "worker", f"MakeMKV-Key konnte nicht gesetzt werden: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _ordner_groesse(pfad: str) -> int:
|
||||||
|
"""Belegter Platz eines Ordners in Bytes (0, wenn nicht lesbar)."""
|
||||||
|
summe = 0
|
||||||
|
for wurzel, _, dateien in os.walk(pfad):
|
||||||
|
for name in dateien:
|
||||||
|
try:
|
||||||
|
summe += os.path.getsize(os.path.join(wurzel, name))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return summe
|
||||||
|
|
||||||
|
|
||||||
|
def _original_aufheben(job_id: str, raw_dir: str, final_dir: str) -> None:
|
||||||
|
"""Roh-Rip zusätzlich aufheben — darf den Job NIEMALS scheitern lassen.
|
||||||
|
|
||||||
|
Befund 25.07.2026 (Akira-UHD, echter Schaden): Hier stand ein nacktes
|
||||||
|
shutil.move(). Zwischen Arbeitsverzeichnis (/app/temp) und Ziel
|
||||||
|
(/app/media) scheitert os.rename mit EXDEV, shutil.move fällt auf Kopieren
|
||||||
|
zurück — eine 74-GB-Vollkopie auf dieselbe Platte, bis sie mit ENOSPC voll
|
||||||
|
war. Ergebnis: Platte 100 % voll, Worker-Container startete nicht mehr, und
|
||||||
|
der Job galt als FEHLGESCHLAGEN, obwohl die komprimierte Datei längst
|
||||||
|
fertig war. Der Nutzer sah nur „nichts da".
|
||||||
|
|
||||||
|
## Warum hier NICHT vorhergesagt, sondern versucht wird
|
||||||
|
|
||||||
|
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")
|
||||||
|
try:
|
||||||
|
# Der billige Weg zuerst — und er ist gleichzeitig der einzige
|
||||||
|
# verlässliche Test, ob überhaupt umgehängt werden kann.
|
||||||
|
try:
|
||||||
|
os.rename(raw_dir, ziel_original)
|
||||||
|
db.add_log(
|
||||||
|
"info", "worker",
|
||||||
|
f"Job {job_id}: Original behalten unter {ziel_original} (umgehängt, "
|
||||||
|
"kein zusätzlicher Platz nötig)",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
except OSError as e:
|
||||||
|
if e.errno != errno.EXDEV:
|
||||||
|
raise # etwas anderes ist schiefgelaufen → unten ehrlich melden
|
||||||
|
|
||||||
|
# Ab hier ist eine echte Kopie unvermeidlich. Jetzt lohnt die Platzfrage.
|
||||||
|
benoetigt = _ordner_groesse(raw_dir)
|
||||||
|
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)
|
||||||
|
db.add_log("info", "worker", f"Job {job_id}: Original behalten unter {ziel_original}")
|
||||||
|
except OSError as e:
|
||||||
|
# Halb geschriebene Kopie wegräumen, sonst belegt sie für immer Platz.
|
||||||
|
shutil.rmtree(ziel_original, ignore_errors=True)
|
||||||
|
db.add_log(
|
||||||
|
"warning", "worker",
|
||||||
|
f"Job {job_id}: Original konnte nicht aufgehoben werden ({e}). "
|
||||||
|
f"Die komprimierte Datei ist fertig; die Roh-Datei bleibt unter {raw_dir}."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _abbruch_angefordert(job_id: str) -> bool:
|
def _abbruch_angefordert(job_id: str) -> bool:
|
||||||
"""Kooperativer Abbruch: hat der Nutzer über die API abgebrochen?"""
|
"""Kooperativer Abbruch: hat der Nutzer über die API abgebrochen?"""
|
||||||
return db.get_job_status(job_id) == "canceling"
|
return db.get_job_status(job_id) == "canceling"
|
||||||
@@ -344,9 +488,27 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
|||||||
)
|
)
|
||||||
db.update_job(job_id, progress=progress)
|
db.update_job(job_id, progress=progress)
|
||||||
|
|
||||||
|
# MakeMKV-Meldungen ins Log (Befund 25.07.2026): Bis dahin überlebte NUR
|
||||||
|
# die letzte Zeile ("Failed to open disc"), und die sagt nichts. Dass
|
||||||
|
# MakeMKV bei der UHD-Disc nicht einmal versucht, einen Schlüssel zu
|
||||||
|
# holen, war deshalb nur per Hand-Lauf im Container zu sehen.
|
||||||
|
# Gedrosselt, weil das UI global nur die letzten 200 Zeilen zeigt: jede
|
||||||
|
# Meldung höchstens einmal, insgesamt höchstens MAX_MELDUNGEN je Rip.
|
||||||
|
# Code 1003 ist MakeMKVs eigenes DEBUG-Rauschen (am 25.07. beobachtet).
|
||||||
|
MAX_MELDUNGEN = 40
|
||||||
|
gesehen = set()
|
||||||
|
|
||||||
|
def melde_makemkv(code: int, text: str):
|
||||||
|
if code == 1003 or len(gesehen) >= MAX_MELDUNGEN or text in gesehen:
|
||||||
|
return
|
||||||
|
gesehen.add(text)
|
||||||
|
db.add_log("info", "makemkv", f"Job {job_id}: {text[:300]}")
|
||||||
|
|
||||||
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
|
||||||
@@ -382,7 +544,8 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
|||||||
# Geplantes Ziel sofort sichtbar machen (UI-Detail + retry-transcode)
|
# Geplantes Ziel sofort sichtbar machen (UI-Detail + retry-transcode)
|
||||||
db.update_job(job_id, output_path=final_dir)
|
db.update_job(job_id, output_path=final_dir)
|
||||||
|
|
||||||
raw_dir = os.path.join(_arbeitsverzeichnis(einstellungen), job_id)
|
# meta["work_dir"] = die Wahl aus dem Rip-Dialog; leer = Setting/Default.
|
||||||
|
raw_dir = os.path.join(_arbeitsverzeichnis(einstellungen, meta.get("work_dir")), job_id)
|
||||||
|
|
||||||
# Platz-Check VOR dem Rip: Disc-Größe ist per ioctl bekannt — eine volle
|
# Platz-Check VOR dem Rip: Disc-Größe ist per ioctl bekannt — eine volle
|
||||||
# Platte nach 40 GB wäre der teuerste Fehlschlag (4K-UHD: bis 100 GB roh).
|
# Platte nach 40 GB wäre der teuerste Fehlschlag (4K-UHD: bis 100 GB roh).
|
||||||
@@ -402,7 +565,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(
|
||||||
@@ -411,6 +577,7 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
|||||||
output_dir=raw_dir,
|
output_dir=raw_dir,
|
||||||
nur_hauptfilm=nur_hauptfilm,
|
nur_hauptfilm=nur_hauptfilm,
|
||||||
titel_liste=titel_liste,
|
titel_liste=titel_liste,
|
||||||
|
log_cb=melde_makemkv,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
ergebnis = rip_video(
|
ergebnis = rip_video(
|
||||||
@@ -418,26 +585,44 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
|||||||
progress_cb=fortschritt, output_dir=final_dir,
|
progress_cb=fortschritt, output_dir=final_dir,
|
||||||
nur_hauptfilm=nur_hauptfilm,
|
nur_hauptfilm=nur_hauptfilm,
|
||||||
titel_liste=titel_liste,
|
titel_liste=titel_liste,
|
||||||
|
log_cb=melde_makemkv,
|
||||||
)
|
)
|
||||||
|
|
||||||
# UHD-Fehler in Klartext übersetzen — "Failed to open disc" allein hilft
|
# MakeMKV-Fehler in Klartext übersetzen — "Failed to open disc" allein
|
||||||
# niemandem. Zwei bekannte Ursachen (Befunde 24.07., Summer-Wars-UHD):
|
# hilft niemandem.
|
||||||
if ergebnis.get("status") == "error" and disc_type == "uhd":
|
if ergebnis.get("status") == "error":
|
||||||
fehler_text = ergebnis.get("error") or ""
|
fehler_text = ergebnis.get("error") or ""
|
||||||
if "volume key is unknown" in fehler_text:
|
if "volume key is unknown" in fehler_text:
|
||||||
# LibreDrive lief bereits — MakeMKV kennt nur den Disc-Schlüssel
|
# Befund 25.07.2026, auf BEIDEN Maschinen gemessen: Laufwerk und
|
||||||
# nicht: Version zu alt ODER Disc neuer als die Key-Datenbank.
|
# MakeMKV sind in Ordnung. makemkvcon unter Linux ruft die
|
||||||
|
# Disc-Schlüssel schlicht nie ab — die Windows-Version tut es
|
||||||
|
# (Meldung 3338). Hier stand vorher erst "Disc zu neu" und danach
|
||||||
|
# "der Schlüssel-Kanal ist tot"; beides war falsch und hat in die
|
||||||
|
# Irre geschickt. Herleitung im Kopf von makemkv_daten.py.
|
||||||
|
speicher = makemkv_daten.schluesselspeicher_status()
|
||||||
|
anzahl = speicher.get("schluessel", 0)
|
||||||
ergebnis["error"] += (
|
ergebnis["error"] += (
|
||||||
" — Klartext: Das Laufwerk liest die Disc (LibreDrive OK), "
|
" — Klartext: Laufwerk und Rippy sind in Ordnung, MakeMKV "
|
||||||
"aber MakeMKV kennt den Schlüssel dieser Disc nicht. Erst "
|
"liest die Disc. Es fehlt nur der Schlüssel dieser Pressung. "
|
||||||
"prüfen: MakeMKV aktuell? (Einstellungen → System; Update = "
|
"Der Grund: makemkvcon holt Schlüssel unter Linux nie selbst "
|
||||||
"Image-Rebuild). Ist es aktuell, ist die Disc neuer als die "
|
"nach — die Windows-Version schon. "
|
||||||
"Schlüssel-Datenbank — MakeMKV hat einen AACS-Dump unter "
|
+ (
|
||||||
"/root/.MakeMKV/ im Worker gespeichert; im MakeMKV-Forum "
|
"Dieser Worker kennt aktuell GAR KEINEN Disc-Schlüssel. "
|
||||||
"(Bereich 'Ultra HD Blu-ray') einreichen, mit einem der "
|
if not anzahl
|
||||||
"nächsten Updates ist die Disc dann rippbar."
|
else f"Dieser Worker kennt {anzahl} Disc-Schlüssel, "
|
||||||
|
"diese Pressung ist nicht dabei. "
|
||||||
|
)
|
||||||
|
+ "Abhilfe: MakeMKV auf einem Windows-PC installieren, die "
|
||||||
|
"Disc dort einmal öffnen, dann die Datei _private_data.tar "
|
||||||
|
"aus dem MakeMKV-Datenverzeichnis unter Einstellungen → "
|
||||||
|
"System hochladen. Wirkt ab dem nächsten Rip. Klappt auch "
|
||||||
|
"das nicht, kennt MakeMKV die Pressung selbst nicht — dann "
|
||||||
|
"hilft nur eine KEYDB.cfg (ebenfalls dort hochladbar) oder "
|
||||||
|
"das Einreichen des AACS-Dumps im MakeMKV-Forum, Bereich "
|
||||||
|
"'Ultra HD Blu-ray'. Der Dump steht unter Einstellungen → "
|
||||||
|
"System zum Download bereit."
|
||||||
)
|
)
|
||||||
elif "Failed to open disc" in fehler_text:
|
elif disc_type == "uhd" and "Failed to open disc" in fehler_text:
|
||||||
ergebnis["error"] += (
|
ergebnis["error"] += (
|
||||||
" — 4K-UHD erkannt: Das Laufwerk kann UHD-Discs vermutlich "
|
" — 4K-UHD erkannt: Das Laufwerk kann UHD-Discs vermutlich "
|
||||||
"nicht entschlüsseln. Dafür ist eine LibreDrive-Firmware nötig "
|
"nicht entschlüsseln. Dafür ist eine LibreDrive-Firmware nötig "
|
||||||
@@ -445,6 +630,23 @@ 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
|
||||||
@@ -518,18 +720,47 @@ def transcode_files(self, job_id: str, raw_dir: str, final_dir: str):
|
|||||||
return ergebnis
|
return ergebnis
|
||||||
|
|
||||||
einstellungen = db.get_settings()
|
einstellungen = db.get_settings()
|
||||||
preset = einstellungen.get("transcodePreset") or DEFAULT_HB_PRESET
|
# Preset nach Disc-Typ (Befund 25.07.2026): vorher lief JEDE Quelle durch
|
||||||
|
# dasselbe Preset — eine 4K-UHD wurde damit auf 1080p heruntergerechnet.
|
||||||
|
job = db.get_job(job_id) or {}
|
||||||
|
disc_type = job.get("disc_type") or ""
|
||||||
|
preset = preset_fuer(disc_type, einstellungen)
|
||||||
original_behalten = einstellungen.get("keepOriginal", False)
|
original_behalten = einstellungen.get("keepOriginal", False)
|
||||||
|
|
||||||
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)
|
||||||
db.add_log(
|
db.add_log(
|
||||||
"info", "worker",
|
"info", "worker",
|
||||||
f"Job {job_id}: Kompression gestartet ({len(quellen)} Datei(en), Preset '{preset}')",
|
f"Job {job_id}: Kompression gestartet ({len(quellen)} Datei(en), "
|
||||||
|
f"Disc-Typ '{disc_type or 'unbekannt'}', Preset '{preset}')",
|
||||||
)
|
)
|
||||||
|
|
||||||
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))
|
||||||
|
|
||||||
@@ -538,11 +769,13 @@ 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,
|
||||||
|
)
|
||||||
if hb.get("status") == "cancelled":
|
if hb.get("status") == "cancelled":
|
||||||
_job_abschliessen(job_id, hb)
|
_job_abschliessen(job_id, hb)
|
||||||
return hb
|
return hb
|
||||||
@@ -558,9 +791,7 @@ def transcode_files(self, job_id: str, raw_dir: str, final_dir: str):
|
|||||||
return ergebnis
|
return ergebnis
|
||||||
|
|
||||||
if original_behalten:
|
if original_behalten:
|
||||||
ziel_original = os.path.join(final_dir, "original")
|
_original_aufheben(job_id, raw_dir, final_dir)
|
||||||
shutil.move(raw_dir, ziel_original)
|
|
||||||
db.add_log("info", "worker", f"Job {job_id}: Original behalten unter {ziel_original}")
|
|
||||||
else:
|
else:
|
||||||
shutil.rmtree(raw_dir, ignore_errors=True)
|
shutil.rmtree(raw_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""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([], dri_da=True, nvidia_da=True) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_backends_aus_echter_liste():
|
||||||
|
encoder = caps.parse_encoder_liste(HB_HILFE_ECHT)
|
||||||
|
# Kein /dev/dri, kein NVIDIA — und HandBrake kann hier ohnehin keine Hardware
|
||||||
|
assert caps.leite_backends_ab(encoder, dri_da=False, nvidia_da=False) == [
|
||||||
|
"cpu-x264", "cpu-x265", "cpu-av1",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_hardware_nur_wenn_geraet_UND_handbrake_es_kann():
|
||||||
|
"""Befund 25.07.2026: Vorher genügte /dev/dri, um 'vaapi' zu melden. Das
|
||||||
|
Worker-Image kann laut eigener Auskunft aber gar keinen Hardware-Encoder —
|
||||||
|
Rippy hätte VAAPI versprochen und wäre beim Encodieren gescheitert."""
|
||||||
|
ohne_hw = caps.parse_encoder_liste(HB_HILFE_ECHT)
|
||||||
|
assert "vaapi" not in caps.leite_backends_ab(ohne_hw, dri_da=True, nvidia_da=True)
|
||||||
|
assert "nvenc" not in caps.leite_backends_ab(ohne_hw, dri_da=True, nvidia_da=True)
|
||||||
|
|
||||||
|
mit_hw = caps.parse_encoder_liste(HB_HILFE_MIT_HARDWARE)
|
||||||
|
# Gerät fehlt → trotz Können kein Hardware-Backend
|
||||||
|
assert caps.leite_backends_ab(mit_hw, dri_da=False, nvidia_da=False) == [
|
||||||
|
"cpu-x264", "cpu-x265",
|
||||||
|
]
|
||||||
|
# Beides da → beides gemeldet
|
||||||
|
beides = caps.leite_backends_ab(mit_hw, dri_da=True, nvidia_da=True)
|
||||||
|
assert "vaapi" in beides # qsv_* vorhanden
|
||||||
|
assert "nvenc" in beides
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
"""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):
|
||||||
|
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
|
||||||
|
noch funktionierende Weg ist eine selbst mitgebrachte KEYDB.cfg im
|
||||||
|
Datenverzeichnis. Damit hängt einiges an diesen kleinen Funktionen: erkennen wir
|
||||||
|
die Datei falsch, meldet das UI "alles gut", während MakeMKV weiter scheitert.
|
||||||
|
|
||||||
|
Getestet wird nur, was ohne Postgres, Redis und ohne Laufwerk läuft — also die
|
||||||
|
puren Funktionen mit echten Beispieldaten. Zeilenformat der KEYDB.cfg laut
|
||||||
|
libaacs (AGENTS Regel D, externe Schnittstellen nie aus dem Kopf):
|
||||||
|
https://github.com/ShiftMediaProject/libaacs/blob/master/KEYDB.cfg
|
||||||
|
|
||||||
|
WARUM DER DATEINAME "_worker" HINTEN DRANHAENGT (25.07.2026): makemkv_daten.py
|
||||||
|
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
|
||||||
|
pytest Testdateien unter ihrem blossen Dateinamen — zwei Dateien namens
|
||||||
|
test_makemkv_daten.py brechen deshalb die Sammelphase ab ("import file
|
||||||
|
mismatch") und faerben die ganze Ampel rot. Nicht zurückbenennen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
|
||||||
|
# WICHTIG (Prüfbefund 25.07.2026): Ein schlichtes "from makemkv_daten import ..."
|
||||||
|
# lädt bei "pytest -q" vom Repo-Wurzelverzeichnis NICHT diese Datei, sondern die
|
||||||
|
# API-Kopie — docker/api wird zuerst gesammelt, und jeder weitere Import trifft
|
||||||
|
# nur noch den sys.modules-Cache. Die Tests hier hätten den Worker-Zwilling also
|
||||||
|
# nie angefasst und eine Abweichung wäre grün durchgelaufen. Deshalb wird er
|
||||||
|
# ausdrücklich über seinen Pfad geladen.
|
||||||
|
_HIER = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
_WORKER_MODUL = os.path.join(_HIER, "makemkv_daten.py")
|
||||||
|
_API_MODUL = os.path.abspath(os.path.join(_HIER, "..", "api", "makemkv_daten.py"))
|
||||||
|
|
||||||
|
_spec = importlib.util.spec_from_file_location("makemkv_daten_worker_kopie", _WORKER_MODUL)
|
||||||
|
_modul = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(_modul)
|
||||||
|
|
||||||
|
ist_aacs_dump = _modul.ist_aacs_dump
|
||||||
|
keydb_pruefen = _modul.keydb_pruefen
|
||||||
|
settings_conf_zusammenfuehren = _modul.settings_conf_zusammenfuehren
|
||||||
|
zaehle_disc_eintraege = _modul.zaehle_disc_eintraege
|
||||||
|
private_data_pruefen = _modul.private_data_pruefen
|
||||||
|
zaehle_schluessel = _modul.zaehle_schluessel
|
||||||
|
|
||||||
|
|
||||||
|
def _tar_mit(namen):
|
||||||
|
"""Baut ein tar-Archiv im Speicher — so sieht MakeMKVs Schlüsselspeicher aus.
|
||||||
|
|
||||||
|
Echte Eintragsnamen aus dem Speicher der Windows-Installation vom
|
||||||
|
25.07.2026: hkd_<8 Hex>.bin für die Schluessel, dazu Index-Dateien.
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
import tarfile
|
||||||
|
|
||||||
|
puffer = io.BytesIO()
|
||||||
|
with tarfile.open(fileobj=puffer, mode="w") as archiv:
|
||||||
|
for name in namen:
|
||||||
|
eintrag = tarfile.TarInfo(name)
|
||||||
|
eintrag.size = 3
|
||||||
|
archiv.addfile(eintrag, io.BytesIO(b"abc"))
|
||||||
|
return puffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def test_zaehle_schluessel_zaehlt_nur_hkd_eintraege():
|
||||||
|
# Nur hkd_*.bin sind Disc-Schluessel. Index- und sdf-Dateien gehoeren zum
|
||||||
|
# Speicher dazu, sind aber keine Schluessel — sonst meldete das UI
|
||||||
|
# "1 Schluessel vorhanden" für einen komplett leeren Vorrat.
|
||||||
|
voll = _tar_mit([
|
||||||
|
"hkd_00000059.bin",
|
||||||
|
"hkd_0000005a.bin",
|
||||||
|
"sdf_000000a6.bin",
|
||||||
|
"--index-A2E950B3C3FC57DA9CB856DCAFBA5275F40423DB.bin",
|
||||||
|
])
|
||||||
|
assert zaehle_schluessel(voll) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_zaehle_schluessel_leerer_speicher_ist_null():
|
||||||
|
# Genau dieser Zustand lag am 25.07.2026 auf der VM vor: ein Archiv mit
|
||||||
|
# ausschliesslich der Index-Datei. Jede unbekannte UHD-Disc scheitert dann.
|
||||||
|
leer = _tar_mit(["--index-4EF73C3560D489497ACE763962A7F07A4A1545C4.bin"])
|
||||||
|
assert zaehle_schluessel(leer) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_zaehle_schluessel_bei_muell_kein_absturz():
|
||||||
|
# Darf niemals werfen — die Zahl landet im Worker-Herzschlag.
|
||||||
|
assert zaehle_schluessel(b"") == 0
|
||||||
|
assert zaehle_schluessel(b"das ist kein tar") == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_private_data_pruefen_nimmt_echten_speicher_an():
|
||||||
|
assert private_data_pruefen(_tar_mit(["hkd_00000059.bin"])) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_private_data_pruefen_lehnt_leere_und_falsche_dateien_ab():
|
||||||
|
assert "leer" in private_data_pruefen(b"")
|
||||||
|
assert "tar-Archiv" in private_data_pruefen(b"<html>Fehlerseite</html>")
|
||||||
|
|
||||||
|
|
||||||
|
def test_private_data_pruefen_lehnt_speicher_ohne_schluessel_ab():
|
||||||
|
# Der teuerste Bedienfehler: den Speicher einer Installation hochladen,
|
||||||
|
# die selbst noch nie Schluessel geholt hat. Ohne diese Prüfung ändert
|
||||||
|
# sich nichts und niemand versteht, warum.
|
||||||
|
leer = _tar_mit(["--index-4EF73C3560D489497ACE763962A7F07A4A1545C4.bin"])
|
||||||
|
assert "kein einziger Schluessel" in private_data_pruefen(leer)
|
||||||
|
|
||||||
|
|
||||||
|
def test_zwillinge_sind_byteweise_identisch():
|
||||||
|
"""docker/api/makemkv_daten.py MUSS dieselbe Datei sein wie diese hier.
|
||||||
|
|
||||||
|
Das Modul existiert bewusst doppelt — es gibt in diesem Projekt kein
|
||||||
|
gemeinsames Paket für API und Worker (gleiche Lage wie bei db.py). Genau
|
||||||
|
deshalb braucht es einen Wächter: laufen die beiden auseinander, zeigt das
|
||||||
|
UI etwas anderes an, als der rippende Worker tatsächlich sieht, und es
|
||||||
|
fällt niemandem auf. Dieser Test ist die einzige Stelle, die das
|
||||||
|
mechanisch prüft.
|
||||||
|
"""
|
||||||
|
with open(_WORKER_MODUL, "rb") as datei:
|
||||||
|
worker = hashlib.sha256(datei.read()).hexdigest()
|
||||||
|
with open(_API_MODUL, "rb") as datei:
|
||||||
|
api = hashlib.sha256(datei.read()).hexdigest()
|
||||||
|
assert worker == api, (
|
||||||
|
"docker/worker/makemkv_daten.py und docker/api/makemkv_daten.py sind "
|
||||||
|
"auseinandergelaufen - Änderungen immer in BEIDE Dateien übernehmen."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Eine kleine, aber echte KEYDB.cfg im libaacs-Format: Kommentarkopf, eine
|
||||||
|
# Disc-Zeile MIT 0x-Praefix, eine OHNE, dazu ein Fortsetzungsfeld und eine
|
||||||
|
# Leerzeile. Erwartete Zahl der Eintraege: 2.
|
||||||
|
BEISPIEL_KEYDB = """; KEYDB.cfg
|
||||||
|
; Kommentarzeilen beginnen mit einem Semikolon
|
||||||
|
|
||||||
|
0x8F4E2C1A9B7D3E5F0A6C8B2D4E1F3A5C7B9D0E2F = AKIRA
|
||||||
|
| V | 0123456789ABCDEF0123456789ABCDEF
|
||||||
|
|
||||||
|
A1B2C3D4E5F60718293A4B5C6D7E8F90A1B2C3D4 = BLADE RUNNER 2049
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_zaehle_disc_eintraege_zaehlt_nur_echte_disc_zeilen():
|
||||||
|
"""Nur Zeilen mit 40 Hex-Zeichen und Gleichheitszeichen sind Eintraege.
|
||||||
|
|
||||||
|
Kommentare, Leerzeilen und Fortsetzungsfelder dürfen nicht mitzaehlen —
|
||||||
|
sonst meldet das UI bei einer reinen Kommentardatei stolz "42 Eintraege".
|
||||||
|
"""
|
||||||
|
assert zaehle_disc_eintraege(BEISPIEL_KEYDB) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_zaehle_disc_eintraege_ignoriert_kommentare_und_leerzeilen():
|
||||||
|
# Eine Datei ganz ohne Disc-Zeile hat null Eintraege, nicht drei.
|
||||||
|
nur_beiwerk = "; nur ein Kommentar\n\n| V | 0123456789ABCDEF0123456789ABCDEF\n"
|
||||||
|
assert zaehle_disc_eintraege(nur_beiwerk) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_zaehle_disc_eintraege_ignoriert_zu_kurze_kennung():
|
||||||
|
"""39 Hex-Zeichen sind keine Disc-Kennung.
|
||||||
|
|
||||||
|
Genau so sieht eine beim Kopieren verstuemmelte Datei aus — die darf nicht
|
||||||
|
als gültig durchgehen, sonst sucht der Commander den Fehler beim Laufwerk.
|
||||||
|
"""
|
||||||
|
zu_kurz = "A1B2C3D4E5F60718293A4B5C6D7E8F90A1B2C3D = KAPUTT\n"
|
||||||
|
assert zaehle_disc_eintraege(zu_kurz) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_keydb_pruefen_meldet_leere_datei():
|
||||||
|
# Häufigster Fehlgriff: das Textfeld war leer, es wird trotzdem gespeichert.
|
||||||
|
assert keydb_pruefen("") != ""
|
||||||
|
assert keydb_pruefen(" \n\n ") != ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_keydb_pruefen_erkennt_html_fehlerseite():
|
||||||
|
"""Der zweithaeufigste Fehlgriff: der Download lieferte eine HTML-Seite.
|
||||||
|
|
||||||
|
MakeMKV würde die Datei still ignorieren und weiter "volume key is unknown"
|
||||||
|
melden — deshalb muss der Fehler schon beim Hochladen sichtbar werden.
|
||||||
|
"""
|
||||||
|
html = "<!DOCTYPE html>\n<html><body><h1>404 Not Found</h1></body></html>\n"
|
||||||
|
meldung = keydb_pruefen(html)
|
||||||
|
assert meldung != ""
|
||||||
|
assert "HTML" in meldung
|
||||||
|
|
||||||
|
|
||||||
|
def test_keydb_pruefen_meldet_text_ohne_disc_zeile():
|
||||||
|
# Irgendein Text (hier: eine README) ist keine KEYDB.cfg.
|
||||||
|
meldung = keydb_pruefen("Diese Datei enthält keine Schluessel, nur Prosa.\n")
|
||||||
|
assert meldung != ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_keydb_pruefen_akzeptiert_gueltigen_inhalt():
|
||||||
|
# Leerer Rueckgabewert heißt laut Vertrag: alles in Ordnung.
|
||||||
|
assert keydb_pruefen(BEISPIEL_KEYDB) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_ist_aacs_dump_akzeptiert_echten_namen():
|
||||||
|
"""Name aus der Praxis: so legt MakeMKV den Dump laut Meldung 3332 ab
|
||||||
|
(am 25.07.2026 im Worker so beobachtet)."""
|
||||||
|
assert ist_aacs_dump("MKB20_v76_UHD_AKIRA_C02B.tgz") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_ist_aacs_dump_lehnt_pfad_tricks_und_fremde_dateien_ab():
|
||||||
|
"""Der Download-Endpunkt hängt den Namen an das Datenverzeichnis an —
|
||||||
|
ohne diese Prüfung könnte man sich damit aus dem Verzeichnis heraus
|
||||||
|
lesen. Versteckte Dateien und Nicht-Dumps sind ebenfalls nichts für die
|
||||||
|
Liste."""
|
||||||
|
assert ist_aacs_dump("../ausbruch.tgz") is False
|
||||||
|
assert ist_aacs_dump(".versteckt.tgz") is False
|
||||||
|
assert ist_aacs_dump("irgendwas.txt") is False
|
||||||
|
assert ist_aacs_dump("..\\windows\\ausbruch.tgz") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_conf_ersetzt_key_und_behaelt_den_rest():
|
||||||
|
"""DIE Regression, um die es geht: bis zum 25.07.2026 haben entrypoint.sh
|
||||||
|
und tasks.py die settings.conf komplett überschrieben. Mit dem jetzt
|
||||||
|
persistenten Datenverzeichnis wäre damit bei jedem Containerstart und vor
|
||||||
|
jedem Rip alles andere weg — allen voran app_UpdateEnable."""
|
||||||
|
alt = 'app_Key = "T-alterSchluessel"\napp_UpdateEnable = "1"\napp_DefaultSelectionString = "+sel:all"\n'
|
||||||
|
neu = settings_conf_zusammenfuehren(alt, "T-neuerSchluessel")
|
||||||
|
assert 'app_Key = "T-neuerSchluessel"' in neu
|
||||||
|
assert 'app_Key = "T-alterSchluessel"' not in neu
|
||||||
|
assert 'app_UpdateEnable = "1"' in neu
|
||||||
|
assert 'app_DefaultSelectionString = "+sel:all"' in neu
|
||||||
|
# Genau EINE app_Key-Zeile, sonst gewinnt am Ende die falsche.
|
||||||
|
assert neu.count("app_Key") == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_conf_leerer_key_entfernt_die_zeile():
|
||||||
|
# Ein bewusst geleerter Key darf nicht heimlich weiterwirken.
|
||||||
|
alt = 'app_Key = "T-alterSchluessel"\napp_UpdateEnable = "1"\n'
|
||||||
|
neu = settings_conf_zusammenfuehren(alt, "")
|
||||||
|
assert "app_Key" not in neu
|
||||||
|
assert 'app_UpdateEnable = "1"' in neu
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_conf_aus_dem_nichts_ergibt_saubere_datei():
|
||||||
|
"""Erststart: die Datei gibt es noch gar nicht. Der abschliessende
|
||||||
|
Zeilenumbruch ist Absicht — MakeMKV liest die Datei zeilenweise."""
|
||||||
|
assert settings_conf_zusammenfuehren("", "T-neuerSchluessel") == 'app_Key = "T-neuerSchluessel"\n'
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_conf_ohne_key_und_ohne_inhalt_bleibt_leer():
|
||||||
|
# Kein Inhalt, kein Key: keine Datei mit einer einsamen Leerzeile erzeugen.
|
||||||
|
assert settings_conf_zusammenfuehren("", "") == ""
|
||||||
@@ -112,3 +112,64 @@ def test_pfad_lokal_uebersetzt_fuer_windows_worker():
|
|||||||
assert pfad_lokal("/app/media/movies/Film (2020)", mapping) == "Z:\\media\\movies\\Film (2020)"
|
assert pfad_lokal("/app/media/movies/Film (2020)", mapping) == "Z:\\media\\movies\\Film (2020)"
|
||||||
assert pfad_lokal("/woanders/x", mapping) == "/woanders/x"
|
assert pfad_lokal("/woanders/x", mapping) == "/woanders/x"
|
||||||
assert pfad_lokal("/app/temp/raw/abc", "") == "/app/temp/raw/abc"
|
assert pfad_lokal("/app/temp/raw/abc", "") == "/app/temp/raw/abc"
|
||||||
|
|
||||||
|
|
||||||
|
# Die beiden früheren Tests zu _original_aufheben sind nach
|
||||||
|
# test_original_aufheben.py gewandert und dort erweitert. Sie prüfen jetzt das
|
||||||
|
# richtige Kriterium: Vorher taeuschten sie per gefaelschtem os.stat
|
||||||
|
# „verschiedene Dateisysteme" vor — genau die Annahme, an der der Schutz in der
|
||||||
|
# Praxis scheiterte (st_dev war identisch, os.rename trotzdem EXDEV).
|
||||||
|
|
||||||
|
|
||||||
|
def test_arbeitsverzeichnis_wahl_des_rips_schlaegt_die_einstellung():
|
||||||
|
"""Pro Rip wählbar (Commander 25.07.2026), Einstellung bleibt Standard.
|
||||||
|
|
||||||
|
Reihenfolge: Wahl dieses Rips -> Setting -> Container-Default. Der
|
||||||
|
Setting-Wert ist genau der, der bei Vollautomatik-Rips greift, weil dort
|
||||||
|
niemand gefragt wird.
|
||||||
|
"""
|
||||||
|
import tasks
|
||||||
|
|
||||||
|
einst = {"workDir": "/app/media/movies"}
|
||||||
|
assert tasks._arbeitsverzeichnis(einst, "/app/media/rippy") == "/app/media/rippy"
|
||||||
|
assert tasks._arbeitsverzeichnis(einst) == "/app/media/movies"
|
||||||
|
assert tasks._arbeitsverzeichnis({}) == tasks.RAW_DIR
|
||||||
|
# Ausbruchsversuche und Pfade außerhalb /app/media fallen durch
|
||||||
|
assert tasks._arbeitsverzeichnis({}, "/etc") == tasks.RAW_DIR
|
||||||
|
assert tasks._arbeitsverzeichnis({}, "/app/media/../etc") == tasks.RAW_DIR
|
||||||
|
# Leere Wahl fällt sauber auf die Einstellung zurück
|
||||||
|
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
|
||||||
@@ -13,6 +13,7 @@ from ripping import (
|
|||||||
build_makemkv_cmd,
|
build_makemkv_cmd,
|
||||||
get_progress_from_line,
|
get_progress_from_line,
|
||||||
get_progress_from_prgv,
|
get_progress_from_prgv,
|
||||||
|
parse_msg,
|
||||||
write_abcde_config,
|
write_abcde_config,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -42,10 +43,43 @@ def test_handbrake_cmd_arbeitet_auf_datei_nicht_geraet():
|
|||||||
|
|
||||||
|
|
||||||
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():
|
||||||
@@ -63,6 +97,60 @@ def test_prgv_parsing_ignoriert_fremde_zeilen():
|
|||||||
assert get_progress_from_prgv("PRGV:kaputt") == -1
|
assert get_progress_from_prgv("PRGV:kaputt") == -1
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_msg_trennt_code_und_klartext():
|
||||||
|
"""Echte Zeilen aus einem makemkvcon-Lauf vom 25.07.2026 (Akira UHD).
|
||||||
|
|
||||||
|
Feld 4 ist laut https://www.makemkv.com/developers/usage.txt der fertig
|
||||||
|
zusammengesetzte Klartext — genau der landet im Rippy-Log.
|
||||||
|
"""
|
||||||
|
assert parse_msg(
|
||||||
|
'MSG:1005,0,1,"MakeMKV v1.18.4 linux(x64-release) started","%1 started","MakeMKV v1.18.4 linux(x64-release)"'
|
||||||
|
) == (1005, "MakeMKV v1.18.4 linux(x64-release) started")
|
||||||
|
assert parse_msg(
|
||||||
|
'MSG:1011,0,1,"Using LibreDrive mode (v06.3 id=866A98CB9C4E)","%1","Using LibreDrive mode (v06.3 id=866A98CB9C4E)"'
|
||||||
|
) == (1011, "Using LibreDrive mode (v06.3 id=866A98CB9C4E)")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_msg_liest_die_uhd_fehlermeldung():
|
||||||
|
"""3303 ist der Befund, um den es beim ganzen KEYDB-Thema geht: das
|
||||||
|
Laufwerk läuft im LibreDrive-Modus, MakeMKV kennt nur den Schluessel
|
||||||
|
DIESER Pressung nicht. Ohne diese Zeile im Log raet der Commander."""
|
||||||
|
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"'
|
||||||
|
) == (3303, "The volume key is unknown for this disc - video can't be decrypted")
|
||||||
|
assert parse_msg('MSG:5010,0,0,"Failed to open disc","Failed to open disc"') == (
|
||||||
|
5010,
|
||||||
|
"Failed to open disc",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_msg_schneidet_meldungen_mit_komma_nicht_ab():
|
||||||
|
"""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
|
||||||
|
enthält — und MakeMKV schreibt solche laufend. Im Log stand dann nur noch
|
||||||
|
ein Satzfragment, das mehr verwirrt als hilft."""
|
||||||
|
zeile = (
|
||||||
|
'MSG:3025,0,3,"Title #1 has length of 12 seconds, which is less than '
|
||||||
|
'minimum title length of 120 seconds and was therefore skipped",'
|
||||||
|
'"Title #%1 has length of %2 seconds which is less than minimum title '
|
||||||
|
'length of %3 seconds and was therefore skipped","1","12","120"'
|
||||||
|
)
|
||||||
|
code, text = parse_msg(zeile)
|
||||||
|
assert code == 3025
|
||||||
|
assert text.endswith("and was therefore skipped")
|
||||||
|
assert "which is less than" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_msg_ignoriert_fremde_zeilen():
|
||||||
|
# Alles außer MSG muss None liefern, sonst landet Fortschritts-Rauschen
|
||||||
|
# (PRGV kommt mehrmals pro Sekunde) als Log-Eintrag in der Datenbank.
|
||||||
|
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("TCOUNT:5") is None
|
||||||
|
assert parse_msg("") is None
|
||||||
|
assert parse_msg("irgendwelcher Muell ohne Struktur") is None
|
||||||
|
|
||||||
|
|
||||||
def test_abcde_cmd_hat_genau_ein_ausgabeformat():
|
def test_abcde_cmd_hat_genau_ein_ausgabeformat():
|
||||||
"""Review-Fund 22.07.: '-o' stand doppelt (Format UND Verzeichnis) — abcde
|
"""Review-Fund 22.07.: '-o' stand doppelt (Format UND Verzeichnis) — abcde
|
||||||
parste das Verzeichnis als Format, CD-Ripping war nie funktionsfähig."""
|
parste das Verzeichnis als Format, CD-Ripping war nie funktionsfähig."""
|
||||||
@@ -74,6 +162,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:
|
||||||
@@ -83,3 +185,135 @@ def test_abcde_config_enthaelt_zielverzeichnis():
|
|||||||
assert "INTERACTIVE=n" in inhalt
|
assert "INTERACTIVE=n" in inhalt
|
||||||
finally:
|
finally:
|
||||||
os.unlink(pfad)
|
os.unlink(pfad)
|
||||||
|
|
||||||
|
|
||||||
|
def test_preset_fuer_nimmt_das_preset_des_disc_typs():
|
||||||
|
# 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 wäre
|
||||||
|
# die 4K-Aufloesung still verlorengegangen.
|
||||||
|
from ripping import preset_fuer
|
||||||
|
|
||||||
|
einstellungen = {
|
||||||
|
"transcodePreset": "HQ 1080p30 Surround",
|
||||||
|
"transcodePresetDvd": "H.265 MKV 576p25",
|
||||||
|
"transcodePresetBluray": "H.265 MKV 1080p30",
|
||||||
|
"transcodePresetUhd": "H.265 MKV 2160p60 4K",
|
||||||
|
}
|
||||||
|
assert preset_fuer("uhd", einstellungen) == "H.265 MKV 2160p60 4K"
|
||||||
|
assert preset_fuer("bluray", einstellungen) == "H.265 MKV 1080p30"
|
||||||
|
assert preset_fuer("dvd", einstellungen) == "H.265 MKV 576p25"
|
||||||
|
|
||||||
|
|
||||||
|
def test_preset_fuer_faellt_auf_das_allgemeine_preset_zurueck():
|
||||||
|
# Bestandsinstallationen kennen die drei neuen Felder nicht. Solange der
|
||||||
|
# Nutzer sie nicht speichert, MUSS sich sein Verhalten nicht ändern.
|
||||||
|
from ripping import preset_fuer
|
||||||
|
|
||||||
|
alt = {"transcodePreset": "HQ 1080p30 Surround"}
|
||||||
|
assert preset_fuer("uhd", alt) == "HQ 1080p30 Surround"
|
||||||
|
assert preset_fuer("dvd", alt) == "HQ 1080p30 Surround"
|
||||||
|
# Leere Zeichenkette zählt als "nicht gesetzt" (leeres Select-Feld im UI)
|
||||||
|
assert preset_fuer("uhd", {"transcodePresetUhd": " ", "transcodePreset": "X"}) == "X"
|
||||||
|
|
||||||
|
|
||||||
|
def test_preset_fuer_ohne_einstellungen_nimmt_den_eingebauten_standard():
|
||||||
|
# Unbekannter Disc-Typ, leere oder fehlende Einstellungen: nie None, nie
|
||||||
|
# Absturz — sonst stirbt die Kompression an einem leeren --preset-Argument.
|
||||||
|
from ripping import DEFAULT_HB_PRESET, preset_fuer
|
||||||
|
|
||||||
|
assert preset_fuer("uhd", {}) == 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
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"""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"]
|
||||||
+28
-1
@@ -25,7 +25,34 @@ except ImportError:
|
|||||||
BASIS = os.path.dirname(os.path.abspath(__file__))
|
BASIS = os.path.dirname(os.path.abspath(__file__))
|
||||||
RIPPY_HOST = os.getenv("RIPPY_TRAY_HOST", "")
|
RIPPY_HOST = os.getenv("RIPPY_TRAY_HOST", "")
|
||||||
WORKER_NAME = os.getenv("WORKER_NAME", "windows-worker")
|
WORKER_NAME = os.getenv("WORKER_NAME", "windows-worker")
|
||||||
LOG_PFAD = os.path.join(BASIS, "worker.log")
|
|
||||||
|
|
||||||
|
def _log_pfad() -> str:
|
||||||
|
"""Wohin das Worker-Log geschrieben wird.
|
||||||
|
|
||||||
|
NICHT ins Programmverzeichnis (Befund 25.07.2026, Commander-Wunsch): Der
|
||||||
|
Worker wird jetzt standardmäßig nach `C:\\Program Files\\Rippy Worker`
|
||||||
|
installiert, und dort darf ein normaler Benutzer nicht schreiben. Ein Log
|
||||||
|
neben dem Programm hätte den Worker beim Starten scheitern lassen — genau
|
||||||
|
die Art Fehler, die man erst merkt, wenn nichts passiert.
|
||||||
|
|
||||||
|
Deshalb ins Benutzerprofil (`%LOCALAPPDATA%\\Rippy Worker`), wie es sich für
|
||||||
|
veränderliche Daten gehört. Nur falls das nicht klappt, bleibt das
|
||||||
|
Programmverzeichnis der Rückfall — bei einer Installation ins Benutzerprofil
|
||||||
|
(frei wählbar) ist es ohnehin beschreibbar.
|
||||||
|
"""
|
||||||
|
basis_daten = os.getenv("LOCALAPPDATA") or os.getenv("APPDATA") or ""
|
||||||
|
if basis_daten:
|
||||||
|
ordner = os.path.join(basis_daten, "Rippy Worker")
|
||||||
|
try:
|
||||||
|
os.makedirs(ordner, exist_ok=True)
|
||||||
|
return os.path.join(ordner, "worker.log")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return os.path.join(BASIS, "worker.log")
|
||||||
|
|
||||||
|
|
||||||
|
LOG_PFAD = _log_pfad()
|
||||||
|
|
||||||
prozess = None
|
prozess = None
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"""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.
|
||||||
|
ARBEITS_STATI = ("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]
|
||||||
|
|
||||||
|
|
||||||
|
def fehlertext(job) -> str:
|
||||||
|
"""Ehrlicher Klartext für die Job-Karte — was war, und was jetzt hilft."""
|
||||||
|
zuletzt = job.get("status") or "unbekannt"
|
||||||
|
return (
|
||||||
|
f"Abgebrochen: Der Vorgang lief nicht mehr (zuletzt gemeldet: {zuletzt}). "
|
||||||
|
"Beim Start des Workers war dazu weder ein Prozess noch eine Aufgabe in "
|
||||||
|
"der Warteschlange zu finden — meistens ein Absturz oder ein Neustart "
|
||||||
|
"mitten im Lauf. Die Rohdateien wurden NICHT gelöscht: mit "
|
||||||
|
"'Neu komprimieren' läuft die Kompression erneut, ohne die Disc noch "
|
||||||
|
"einmal zu rippen."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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
+398
@@ -0,0 +1,398 @@
|
|||||||
|
#!/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"; }
|
||||||
|
|
||||||
|
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."
|
||||||
|
rot " Installation (Debian/Ubuntu): sudo apt install docker-compose-plugin"
|
||||||
|
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.
|
||||||
|
UNIT="/etc/systemd/system/$(systemd-escape -p --suffix=mount "$MEDIA_DIR" 2>/dev/null)"
|
||||||
|
if [ -n "${UNIT:-}" ] && [ ! -e "$UNIT" ] && command -v systemctl >/dev/null 2>&1; then
|
||||||
|
cat > "$UNIT" <<UNITEOF
|
||||||
|
[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 " ! 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 ""
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
# Rippy udev-Setup
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
Rippy findet kein Laufwerk, obwohl eines am Host angeschlossen ist.
|
|
||||||
|
|
||||||
## Ursache
|
|
||||||
Die `docker-compose.yml` nutzte anonyme Volumes für `/dev/disc`, `/dev/cdrom`, `/dev/dvd`:
|
|
||||||
```yaml
|
|
||||||
volumes:
|
|
||||||
- disc:/dev/disc # ❌ Anonymes Volume, leer
|
|
||||||
```
|
|
||||||
|
|
||||||
## Lösung
|
|
||||||
Direkte Mounts vom Host in den Worker-Container:
|
|
||||||
|
|
||||||
### 1. docker-compose.yml anpassen
|
|
||||||
```yaml
|
|
||||||
volumes:
|
|
||||||
- /dev/cdrom:/dev/cdrom:ro
|
|
||||||
- /dev/dvd:/dev/dvd:ro
|
|
||||||
- /dev/sr0:/dev/sr0:ro
|
|
||||||
- disc:/dev/disc # ✅ Shared-Mount für udev-Symlinks
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Worker-Dockerfile erweitern
|
|
||||||
- `isoinfo` für Disc-Typ-Erkennung
|
|
||||||
- `udev` für Geräteverwaltung
|
|
||||||
|
|
||||||
### 3. udev-Regel installieren
|
|
||||||
Kopiere `99-disc-ripper.rules` nach `/etc/udev/rules.d/`
|
|
||||||
|
|
||||||
### 4. udev-Daemon ausführen
|
|
||||||
Der Daemon (`udev_daemon.py`) läuft im Worker-Container und erstellt Symlinks mit UUID/Serial.
|
|
||||||
|
|
||||||
## Installation auf Arcane VM
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /path/to/rippy/udev
|
|
||||||
bash install-arcane.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testen
|
|
||||||
1. Leg eine CD/DVD ein
|
|
||||||
2. Logs prüfen: `docker logs -f rippy-worker`
|
|
||||||
3. Symlinks: `docker exec rippy-worker ls -la /dev/disc`
|
|
||||||
4. Devices: `docker exec rippy-worker ls -la /dev/cdrom /dev/dvd /dev/sr0`
|
|
||||||
|
|
||||||
## Dateien
|
|
||||||
- `99-disc-ripper.rules` — udev-Regel für Einwurf/Auswurf
|
|
||||||
- `udev_daemon.py` — Daemon für Disc-Erkennung
|
|
||||||
- `rippy-udev.service` — systemd-Service (optional)
|
|
||||||
- `install-arcane.sh` — Installations-Skript für Arcane VM
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Installations-Skript für Arcane VM (Debian 12)
|
|
||||||
# Auf der Arcane VM ausführen
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "=== Rippy udev-Setup für Arcane VM ==="
|
|
||||||
|
|
||||||
# 1. udev-Regel installieren
|
|
||||||
echo "Installiere udev-Regel..."
|
|
||||||
cp 99-disc-ripper.rules /etc/udev/rules.d/
|
|
||||||
echo "✅ /etc/udev/rules.d/99-disc-ripper.rules installiert"
|
|
||||||
|
|
||||||
# 2. udev-Daemon in Worker-Container kopieren
|
|
||||||
echo "Kopiere udev-Daemon in Worker-Container..."
|
|
||||||
docker cp udev_daemon.py rippy-worker:/app/udev/
|
|
||||||
docker cp 99-disc-ripper.rules rippy-worker:/etc/udev/rules.d/
|
|
||||||
docker cp rippy-udev.service rippy-worker:/etc/systemd/system/
|
|
||||||
echo "✅ udev-Daemon und Regeln in Container kopiert"
|
|
||||||
|
|
||||||
# 3. udev-Regel neu laden (im Host)
|
|
||||||
echo "Lade udev-Regeln im Host neu..."
|
|
||||||
sudo udevadm control --reload-rules
|
|
||||||
sudo udevadm trigger --subsystem-match=block
|
|
||||||
echo "✅ udev-Regeln im Host neu geladen"
|
|
||||||
|
|
||||||
# 4. udev-Regel in Container neu laden
|
|
||||||
echo "Lade udev-Regeln im Container neu..."
|
|
||||||
docker exec -i rippy-worker udevadm control --reload-rules
|
|
||||||
docker exec -i rippy-worker udevadm trigger --subsystem-match=block
|
|
||||||
echo "✅ udev-Regeln im Container neu geladen"
|
|
||||||
|
|
||||||
# 5. Service aktivieren (optional - wenn im Container als systemd läuft)
|
|
||||||
if docker exec -i rippy-worker which systemctl > /dev/null 2>&1; then
|
|
||||||
echo "Aktiviere systemd-Service..."
|
|
||||||
docker exec -i rippy-worker systemctl daemon-reload
|
|
||||||
docker exec -i rippy-worker systemctl enable rippy-udev
|
|
||||||
echo "✅ systemd-Service aktiviert"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "=== Setup abgeschlossen ==="
|
|
||||||
echo ""
|
|
||||||
echo "Testen:"
|
|
||||||
echo " 1. Leg eine CD/DVD ein"
|
|
||||||
echo " 2. Logs prüfen: docker logs -f rippy-worker"
|
|
||||||
echo " 3. Symlinks: docker exec rippy-worker ls -la /dev/disc"
|
|
||||||
echo " 4. Devices: docker exec rippy-worker ls -la /dev/cdrom /dev/dvd /dev/sr0"
|
|
||||||
echo ""
|
|
||||||
echo "Hinweis: /dev/disc ist ein Shared-Mount, der udev-Daemon erstellt Symlinks"
|
|
||||||
echo " mit UUID/Serial für jedes erkannte Laufwerk."
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Installations-Skript für Rippy udev-Regel + Service
|
|
||||||
# Auf dem Host (Arcane VM / Proxmox LXC) ausführen
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "=== Rippy udev-Setup ==="
|
|
||||||
|
|
||||||
# Prüfen ob wir root sind
|
|
||||||
if [ "$EUID" -ne 0 ]; then
|
|
||||||
echo "❌ Bitte als root ausführen (sudo bash install-udev.sh)"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 1. udev-Regel installieren
|
|
||||||
echo "Installiere udev-Regel..."
|
|
||||||
cp 99-disc-ripper.rules /etc/udev/rules.d/
|
|
||||||
echo "✅ /etc/udev/rules.d/99-disc-ripper.rules installiert"
|
|
||||||
|
|
||||||
# 2. udev-Daemon in den Container kopieren
|
|
||||||
echo "Kopiere udev-Daemon in Worker-Container..."
|
|
||||||
docker cp udev_daemon.py rippy-worker:/app/udev/
|
|
||||||
docker cp 99-disc-ripper.rules rippy-worker:/etc/udev/rules.d/
|
|
||||||
docker cp rippy-udev.service rippy-worker:/etc/systemd/system/
|
|
||||||
echo "✅ udev-Daemon und Regeln in Container kopiert"
|
|
||||||
|
|
||||||
# 3. udev-Regel neu laden
|
|
||||||
echo "Lade udev-Regeln neu..."
|
|
||||||
sudo udevadm control --reload-rules
|
|
||||||
sudo udevadm trigger --subsystem-match=block
|
|
||||||
echo "✅ udev-Regeln neu geladen"
|
|
||||||
|
|
||||||
# 4. Service aktivieren (optional - wenn im Container als systemd läuft)
|
|
||||||
if docker exec -i rippy-worker which systemctl > /dev/null 2>&1; then
|
|
||||||
echo "Aktiviere systemd-Service..."
|
|
||||||
docker exec -i rippy-worker systemctl daemon-reload
|
|
||||||
docker exec -i rippy-worker systemctl enable rippy-udev
|
|
||||||
echo "✅ systemd-Service aktiviert"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "=== Setup abgeschlossen ==="
|
|
||||||
echo "Teste Disc-Einwurf: Leg eine CD/DVD ein"
|
|
||||||
echo "Prüfe Logs: docker logs -f rippy-worker"
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# systemd-Service für Rippy udev-Daemon
|
|
||||||
# Diese Datei muss nach /etc/systemd/system/rippy-udev.service kopiert werden
|
|
||||||
|
|
||||||
[Unit]
|
|
||||||
Description=Rippy Disc Detection Daemon
|
|
||||||
After=dev-disc.device
|
|
||||||
Requires=dev-disc.device
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
ExecStart=/usr/bin/python3 /app/udev_daemon.py
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
WorkingDirectory=/app
|
|
||||||
User=root
|
|
||||||
Group=root
|
|
||||||
|
|
||||||
# Environment
|
|
||||||
Environment=REDIS_URL=redis://redis:6379/0
|
|
||||||
Environment=PATH=/usr/local/bin:/usr/bin:/bin
|
|
||||||
|
|
||||||
# Security Hardening
|
|
||||||
NoNewPrivileges=yes
|
|
||||||
ProtectSystem=strict
|
|
||||||
ProtectHome=read-only
|
|
||||||
PrivateTmp=yes
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
udev-Daemon für Rippy
|
|
||||||
Erkennt Disc-Einwurf/-auswurf und erstellt Celery-Jobs.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import subprocess
|
|
||||||
import redis
|
|
||||||
import json
|
|
||||||
|
|
||||||
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
|
||||||
DISC_DEVICE_PATH = "/dev/disc"
|
|
||||||
|
|
||||||
# Redis verbinden
|
|
||||||
def get_redis_client():
|
|
||||||
try:
|
|
||||||
return redis.from_url(REDIS_URL)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Fehler beim Verbinden mit Redis: {e}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
def get_device_info(device_name: str) -> dict:
|
|
||||||
"""Ermittle Device-Info via udevadm."""
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["udevadm", "info", "-q", "property", "-n", f"/dev/{device_name}"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
|
|
||||||
info = {}
|
|
||||||
for line in result.stdout.strip().split('\n'):
|
|
||||||
if '=' in line:
|
|
||||||
key, value = line.split('=', 1)
|
|
||||||
info[key] = value
|
|
||||||
|
|
||||||
return info
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Fehler beim Auslesen von Device {device_name}: {e}")
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def create_device_symlink(device_name: str, info: dict) -> str:
|
|
||||||
"""Erstelle Symlink mit UUID/Serial-Nummer."""
|
|
||||||
serial = info.get("ID_SERIAL", "unknown")
|
|
||||||
model = info.get("ID_MODEL", "unknown")
|
|
||||||
|
|
||||||
symlink_name = f"{model}_{serial}".replace(" ", "_")
|
|
||||||
symlink_path = f"{DISC_DEVICE_PATH}/{symlink_name}"
|
|
||||||
|
|
||||||
os.makedirs(DISC_DEVICE_PATH, exist_ok=True)
|
|
||||||
|
|
||||||
# Alten Symlink entfernen falls vorhanden
|
|
||||||
if os.path.exists(symlink_path):
|
|
||||||
os.unlink(symlink_path)
|
|
||||||
|
|
||||||
# Neuen Symlink erstellen
|
|
||||||
os.symlink(f"/dev/{device_name}", symlink_path)
|
|
||||||
|
|
||||||
return symlink_path
|
|
||||||
|
|
||||||
|
|
||||||
def delete_device_symlink(device_name: str):
|
|
||||||
"""Lösche Symlink beim Auswurf."""
|
|
||||||
symlink_path = f"{DISC_DEVICE_PATH}/{device_name}"
|
|
||||||
if os.path.exists(symlink_path):
|
|
||||||
os.unlink(symlink_path)
|
|
||||||
|
|
||||||
|
|
||||||
def detect_disc_type(device_path: str) -> str:
|
|
||||||
"""Erkenne Disc-Typ (CD/DVD/Blu-ray)."""
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["isoinfo", "-d", "-i", device_path],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
|
|
||||||
output = result.stdout.lower()
|
|
||||||
|
|
||||||
if "rock ridge" in output or "joliet" in output:
|
|
||||||
if "blu-ray" in output:
|
|
||||||
return "bluray"
|
|
||||||
return "dvd"
|
|
||||||
elif "cda" in output:
|
|
||||||
return "cd"
|
|
||||||
|
|
||||||
return "unknown"
|
|
||||||
except Exception:
|
|
||||||
return "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
def create_job(device_path: str, disc_type: str):
|
|
||||||
"""Erstelle Celery-Job für Disc-Erkennung."""
|
|
||||||
try:
|
|
||||||
client = get_redis_client()
|
|
||||||
|
|
||||||
job_data = {
|
|
||||||
"device_path": device_path,
|
|
||||||
"disc_type": disc_type,
|
|
||||||
"timestamp": str(int(subprocess.check_output(["date", "+%s"]).decode().strip())),
|
|
||||||
"status": "pending"
|
|
||||||
}
|
|
||||||
|
|
||||||
client.lpush("rippy:jobs", json.dumps(job_data))
|
|
||||||
print(f"Job erstellt: {disc_type} -> {device_path}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Fehler beim Erstellen des Jobs: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def handle_add(device_name: str):
|
|
||||||
"""Handle Disc-Einwurf."""
|
|
||||||
print(f"Disc-Einwurf erkannt: {device_name}")
|
|
||||||
|
|
||||||
info = get_device_info(device_name)
|
|
||||||
if not info:
|
|
||||||
print("Konnte Device-Info nicht auslesen")
|
|
||||||
return
|
|
||||||
|
|
||||||
symlink_path = create_device_symlink(device_name, info)
|
|
||||||
print(f"Symlink erstellt: {symlink_path}")
|
|
||||||
|
|
||||||
disc_type = detect_disc_type(symlink_path)
|
|
||||||
print(f"Disc-Typ erkannt: {disc_type}")
|
|
||||||
|
|
||||||
create_job(symlink_path, disc_type)
|
|
||||||
|
|
||||||
|
|
||||||
def handle_remove(device_name: str):
|
|
||||||
"""Handle Disc-Auswurf."""
|
|
||||||
print(f"Disc-Auswurf erkannt: {device_name}")
|
|
||||||
delete_device_symlink(device_name)
|
|
||||||
print(f"Symlink gelöscht: /dev/disc/{device_name}")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Hauptfunktion."""
|
|
||||||
if len(sys.argv) < 3:
|
|
||||||
print("Usage: udev_daemon.py <action> <device>")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
action = sys.argv[1]
|
|
||||||
device = sys.argv[2]
|
|
||||||
|
|
||||||
if action == "add":
|
|
||||||
handle_add(device)
|
|
||||||
elif action == "remove":
|
|
||||||
handle_remove(device)
|
|
||||||
else:
|
|
||||||
print(f"Unbekannte Aktion: {action}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
Reference in New Issue
Block a user