Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5cd1876c90 | |||
| b526a0a033 | |||
| 8bb075c656 | |||
| c065967f4d | |||
| e84afc1718 | |||
| f449c4ee34 | |||
| 0935766f61 | |||
| f74f4e54f6 | |||
| 0832ef777c | |||
| 6a082cf77c |
+21
-3
@@ -1,14 +1,25 @@
|
||||
# Rippy-Umgebung — nach .env kopieren und Werte eintragen.
|
||||
# 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
|
||||
|
||||
# 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. Die sg-Nummer variiert je Host — auf DIESER
|
||||
# Maschine ermitteln: lsscsi -g (oder: ls -l /dev/sg*). Hier den HOST-Pfad des
|
||||
# Blu-ray/DVD-Laufwerks eintragen; der Default passt für den Ursprungs-Host.
|
||||
OPTICAL_SR=/dev/sr0
|
||||
OPTICAL_SG=/dev/sg1
|
||||
|
||||
# (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
|
||||
# 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=
|
||||
# TVDb (optional, Serien-Fallback)
|
||||
THETVDB_API_KEY=
|
||||
@@ -22,6 +33,13 @@ OMDB_API_KEY=
|
||||
# nächsten Rip, ohne Neustart, und schlägt diesen Env-Wert).
|
||||
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).
|
||||
# Update: Version hier anheben, dann auf der Rippy-Maschine
|
||||
# docker compose build worker && docker compose up -d worker
|
||||
|
||||
@@ -55,10 +55,15 @@ Bibliotheks-APIs: `--help`/Doku prüfen und die Fundstelle im Commit nennen.
|
||||
- Kein Proxmox-LXC-Nesting-Workaround — das Projekt lebt bewusst in normalem Docker.
|
||||
- Kein ARM-Fork — Rippy ist ein Eigenbau von Grund auf.
|
||||
|
||||
## Aktueller Stand (24.07.2026)
|
||||
## Aktueller Stand (25.07.2026)
|
||||
|
||||
- ✅ **E2E bewiesen (v3.1):** BD-50 komplett durch die Kette (43 GB → 4,8 GB)
|
||||
- ✅ **Etappe 13 (v3.2):** Universal-Komfort-Runde — Media-Server-Integration,
|
||||
echte Benachrichtigungen, SMB-Klartext-Fehler, UHD-Arbeitsverzeichnis,
|
||||
MakeMKV-Key via UI, Job-Detail-Popup, Toast-Feedback, Ampel-Blocker behoben
|
||||
- ✅ **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)
|
||||
- 📝 **Details immer in SAVEPOINT.md** — diese Sektion nennt nur die Etappe
|
||||
|
||||
+18
@@ -127,6 +127,7 @@ Ein modular aufgebautes System, das bei Disc-Einwurf automatisch den Typ erkennt
|
||||
| Risiko | Status | Behandlung |
|
||||
|--------|--------|------------|
|
||||
| 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) |
|
||||
| Pending-Queue / State-Manager | Gelöst | Redis mit AOF-Persistence |
|
||||
| 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
|
||||
(pro IP) bleibt. Wer Rippy je nach außen öffnet, stellt einen
|
||||
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
|
||||
Episoden-Zuordnung per Laufzeitabgleich (TMDB) — erfüllt Etappe-12-Ziel
|
||||
„Serien-Episoden-Erkennung" in der ersten Ausbaustufe (nur bei
|
||||
|
||||
@@ -21,20 +21,23 @@ ersten Mal automatisch (API-Keys, Verarbeitung, erkannte Hardware).
|
||||
|
||||
### Laufwerk anpassen
|
||||
|
||||
Standard ist `/dev/sr0` (+ `/dev/sg1` für den Worker — MakeMKV spricht
|
||||
Laufwerke über die SCSI-Generic-Schicht an). Andere Geräte? Lege eine
|
||||
`docker-compose.override.yml` an:
|
||||
Der Worker braucht **zwei** Host-Geräteknoten: den CD-ROM-Knoten (meist
|
||||
`/dev/sr0`) und den passenden **SCSI-Generic-Knoten** (`/dev/sgN`) — MakeMKV
|
||||
spricht Laufwerke über die sg-Schicht an. Die sg-Nummer ist **je Host anders**
|
||||
(auf frischen Systemen oft `/dev/sg0`).
|
||||
|
||||
```yaml
|
||||
services:
|
||||
api:
|
||||
devices: ["/dev/sr1:/dev/sr0"]
|
||||
worker:
|
||||
devices: ["/dev/sr1:/dev/sr0", "/dev/sg2:/dev/sg1"]
|
||||
Ermitteln, welche Knoten dein Blu-ray/DVD-Laufwerk hat:
|
||||
```bash
|
||||
lsscsi -g # zeigt Modell + zugehörigen /dev/sgN
|
||||
# oder: ls -la /sys/class/scsi_generic/
|
||||
```
|
||||
|
||||
Welche sg-Nummer dein Laufwerk hat, verrät `lsscsi -g` oder
|
||||
`ls -la /sys/class/scsi_generic/`.
|
||||
Dann in der `.env` eintragen (Defaults passen für den Ursprungs-Host):
|
||||
```bash
|
||||
OPTICAL_SR=/dev/sr0 # CD-ROM-Knoten deines Laufwerks
|
||||
OPTICAL_SG=/dev/sg0 # sg-Knoten deines Laufwerks
|
||||
```
|
||||
`docker compose up -d` nutzt diese Werte. (Für Feineinstellungen geht weiterhin
|
||||
eine `docker-compose.override.yml` mit eigenen `devices:`-Einträgen.)
|
||||
|
||||
**Laufwerk in einer VM?** Per USB-Passthrough anhand der Vendor-ID
|
||||
durchreichen (Proxmox: `qm set <vmid> -usb0 host=xxxx:yyyy,usb3=1`) —
|
||||
@@ -60,7 +63,13 @@ NICHT als emuliertes CD-ROM (`media=cdrom`), das kann keine SCSI-Kommandos.
|
||||
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
|
||||
**je Disc-Typ ein eigenes Preset** (Einstellungen → Verarbeitung) —
|
||||
Rippy kennt den Typ und wählt selbst. Wichtig, weil ein 1080p-Preset
|
||||
eine 4K-UHD stillschweigend herunterrechnet und ein 1080p-Preset eine
|
||||
DVD sinnlos hochskaliert. Sind die drei Felder nie gespeichert worden,
|
||||
gilt weiter das alte allgemeine Preset — Bestandsinstallationen ändern
|
||||
ihr Verhalten also nicht von selbst.
|
||||
Rohdatei wird erst nach Erfolg gelöscht
|
||||
(„Original behalten" als Option). Fehlgeschlagene Kompressionen lassen
|
||||
sich ohne Neu-Rip neu anstoßen.
|
||||
4. **Media-Server-Ablage**: Unter Einstellungen → Ripping (oder im
|
||||
@@ -71,9 +80,34 @@ NICHT als emuliertes CD-ROM (`media=cdrom`), das kann keine SCSI-Kommandos.
|
||||
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
|
||||
5. **4K-UHD**: braucht **zwei** Dinge — ein LibreDrive-fähiges Laufwerk
|
||||
(MakeMKV-Forum: „Ultimate UHD Drives Flashing Guide") **und** einen
|
||||
passenden Disc-Schlüssel. Normale BD/DVD gehen mit jedem Laufwerk und
|
||||
ohne Schlüssel-Datei.
|
||||
**Der Schlüssel ist heute die eigentliche Hürde — und zwar aus einem
|
||||
überraschenden Grund.** Am 25.07.2026 auf beiden Maschinen gemessen
|
||||
(Akira UHD, MKB v76, Pressung Dezember 2020): Laufwerk und MakeMKV
|
||||
waren in Ordnung — „Using LibreDrive mode (v06.3)", die Disc wurde
|
||||
gelesen — und trotzdem kam „The volume key is unknown for this disc".
|
||||
Die Ursache:
|
||||
**`makemkvcon` unter Linux holt Disc-Schlüssel nie aus dem Netz. Die
|
||||
Windows-Version tut es.**
|
||||
Gemessen: Linux baute in keinem einzigen Lauf eine Verbindung nach
|
||||
draußen auf — geprüft mit leerem und mit gefülltem Schlüsselspeicher,
|
||||
mit und ohne `--noscan`, mit `dev:` und `disc:`, und mit erzwungener
|
||||
frischer Prüfung. Dieselbe Disc am selben Laufwerk unter Windows: „Lade
|
||||
aktuelle HK …", Verbindung zum Schlüssel-Server, Disc geht auf.
|
||||
Ein MakeMKV-Update ändert daran nichts, und es ist kein Fehler in
|
||||
Rippy.
|
||||
**Der Weg drumherum:** MakeMKV einmal auf einem Windows-PC die Disc
|
||||
öffnen lassen und die dabei gefüllte Datei `_private_data.tar` bei
|
||||
Rippy unter **Einstellungen → System** hochladen (siehe unten). Für
|
||||
Pressungen, die auch MakeMKV nicht kennt, bleibt die **`KEYDB.cfg`**
|
||||
als Notnagel.
|
||||
⚠️ **Rippy liefert keine Schlüssel mit, lädt keine herunter und
|
||||
verteilt keine.** Rippy stellt nur den Platz für eine Datei bereit, die
|
||||
du selbst mitbringst, und zeigt dir ehrlich an, was dort liegt.
|
||||
⚠️ UHD-Rohdaten sind bis 100 GB groß — wenn die Platte der
|
||||
Rippy-Maschine dafür zu klein ist, lege das **Arbeitsverzeichnis**
|
||||
(Einstellungen → Verarbeitung) auf eine eingehängte Freigabe, z. B.
|
||||
`/app/media/nas-arbeit`. Rippy bricht sonst VOR dem Rip mit einer
|
||||
@@ -103,7 +137,7 @@ fehlgeschlagen, abgebrochen) und erkennt den Dienst an der URL selbst:
|
||||
| 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
|
||||
## System, MakeMKV-Beta-Key, Disc-Schlüssel & Updates
|
||||
|
||||
**Einstellungen → System** zeigt die Werkzeug-Versionen jedes Workers
|
||||
(MakeMKV, HandBrake), den Key-Status und den freien Speicherplatz. Der
|
||||
@@ -111,11 +145,47 @@ MakeMKV-Beta-Key (wechselt ~monatlich, Forum-Thread t=1053) wird hier im
|
||||
UI eingetragen und gilt ab dem **nächsten Rip** — ohne Rebuild, ohne
|
||||
Neustart; er schlägt den Key aus der `.env`.
|
||||
|
||||
**Disc-Schlüssel für 4K-UHD** — im selben Tab, nur für UHD nötig. Der
|
||||
Hauptweg zuerst:
|
||||
|
||||
- **Schlüsselspeicher (`_private_data.tar`)**: Rippy zeigt, **wie viele
|
||||
Disc-Schlüssel** dieser Worker kennt. Steht dort 0, scheitert jede
|
||||
unbekannte UHD-Disc.
|
||||
**So füllst du ihn:** MakeMKV auf einem Windows-PC installieren
|
||||
(gleicher Beta-Key), Laufwerk anstecken, Disc einmal öffnen — dabei lädt
|
||||
MakeMKV die Schlüssel nach. Dann in MakeMKV unter *Preferences →
|
||||
General* das „MakeMKV data directory" nachschlagen und die Datei
|
||||
`_private_data.tar` daraus hier hochladen. Wirkt ab dem nächsten Rip;
|
||||
für neue Discs gelegentlich wiederholen.
|
||||
Rippy prüft die Datei und lehnt sie ab, wenn kein einziger Schlüssel
|
||||
drinsteckt — sonst ändert sich nichts und niemand versteht, warum.
|
||||
- **`KEYDB.cfg`** (Notnagel): nur nötig, wenn eine Pressung auch mit
|
||||
gefülltem Speicher nicht aufgeht, MakeMKV sie also selbst nicht kennt.
|
||||
Hochladen, Status und Entfernen genau wie oben.
|
||||
- **AACS-Dumps herunterladen**: MakeMKV legt bei jeder unbekannten Disc
|
||||
selbst einen Dump ab. Genau den braucht man, wenn man im MakeMKV-Forum
|
||||
um den Schlüssel für eine neue Pressung bittet — hier holst du ihn dir
|
||||
aus dem Container, ohne SSH.
|
||||
|
||||
Der Beta-Key ist die **Lizenz für die Software**, der Schlüsselspeicher
|
||||
und die `KEYDB.cfg` sind **Disc-Schlüssel** — zwei völlig verschiedene
|
||||
Dinge. **Rippy liefert und lädt keine Disc-Schlüssel**, es hält nur ein
|
||||
persistentes Verzeichnis dafür bereit (`MAKEMKV_DATA_HOST`, siehe Tabelle
|
||||
unten) — rebuild-fest, damit deine Dateien einen
|
||||
`docker compose build` überleben. Der Schlüsselspeicher ist der
|
||||
Zwischenspeicher **deiner eigenen** MakeMKV-Installation.
|
||||
|
||||
**Updates:** „Auf Updates prüfen" vergleicht mit makemkv.com und den
|
||||
offiziellen HandBrake-Releases (12 h gecacht). MakeMKV-Update ohne
|
||||
Code-Änderung: `MAKEMKV_VERSION=x.y.z` in die `.env`, dann
|
||||
`docker compose build worker && docker compose up -d worker` — neue
|
||||
Versionen bringen auch die neueste Disc-Schlüssel-Datenbank mit.
|
||||
`docker compose build worker && docker compose up -d worker`.
|
||||
⚠️ **Richtigstellung (25.07.2026):** Hier stand früher, neue MakeMKV-
|
||||
Versionen brächten „auch die neueste Disc-Schlüssel-Datenbank mit". Das
|
||||
ist widerlegt — MakeMKV liefert überhaupt keine Disc-Schlüssel mit, es
|
||||
holt sie zur Laufzeit. Und die Linux-Version holt sie nie. Ein Update
|
||||
lohnt für Programmfehler und neue Laufwerke, hilft aber NICHT gegen „The
|
||||
volume key is unknown". Dafür brauchst du den Schlüsselspeicher (siehe
|
||||
oben).
|
||||
HandBrake kommt im Docker-Worker bewusst aus Debian (stabil, hinkt der
|
||||
offiziellen Version hinterher); der native Windows-Worker nutzt die
|
||||
aktuelle Version direkt.
|
||||
@@ -150,6 +220,10 @@ bleibt All-in-one.
|
||||
| `THETVDB_API_KEY` | optional | Serien-Fallback |
|
||||
| `MAKEMKV_APP_KEY` | optional | MakeMKV-Beta-Key (Forum); DVDs gehen ohne — bequemer: im UI unter Einstellungen → System pflegen |
|
||||
| `MAKEMKV_URL_BASE` | optional | alternative Download-Quelle für den Image-Build |
|
||||
| `MAKEMKV_DATA_HOST` | optional | Host-Verzeichnis für MakeMKVs Daten (Default `/srv/rippy/makemkv`) — hier liegen dein Schlüsselspeicher `_private_data.tar`, eine etwaige `KEYDB.cfg` und die AACS-Dumps; persistent, überlebt jeden Rebuild |
|
||||
| `OPTICAL_SR` | je Host | Host-Pfad des CD-ROM-Knotens (Default `/dev/sr0`) — siehe „Laufwerk anpassen" |
|
||||
| `OPTICAL_SG` | je Host | Host-Pfad des sg-Knotens des Laufwerks (Default `/dev/sg1`; oft `/dev/sg0`) |
|
||||
| `POSTGRES_PASSWORD` | optional | DB-Passwort (Default `rippy`) — für exponierte Umgebungen ein starkes Passwort setzen |
|
||||
|
||||
UI-Einstellungen (Wizard/Settings) überstimmen die Env-Variablen.
|
||||
|
||||
@@ -165,7 +239,22 @@ Voraussetzungen auf dem Ziel-Host:
|
||||
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).
|
||||
`docker-compose.yml` anpassen). Der Bind nutzt **shared/slave-Propagation**
|
||||
(fürs In-UI-Einhängen von NAS-Freigaben). Auf den meisten systemd-Linux-Hosts
|
||||
ist `/` schon `rshared`, dann ist nichts zu tun. Bricht `docker compose up`
|
||||
mit *„path … is mounted on / but it is not a shared mount"* ab, einmalig:
|
||||
```bash
|
||||
sudo mount --bind /srv/rippy/media /srv/rippy/media
|
||||
sudo mount --make-rshared /srv/rippy/media
|
||||
```
|
||||
(reboot-fest als systemd-`.mount`-Unit persistieren.) Braucht einen klassischen
|
||||
Linux-Docker-Host mit `SYS_ADMIN` — nicht Docker-Desktop/rootless/Podman.
|
||||
4. MakeMKV-Datenverzeichnis anlegen: `mkdir -p /srv/rippy/makemkv` (oder
|
||||
eigenen Pfad über `MAKEMKV_DATA_HOST` in der `.env`). Dort liegen
|
||||
MakeMKVs Programmzustand, die AACS-Dumps und — falls du 4K-UHD rippst —
|
||||
dein Schlüsselspeicher (`_private_data.tar`) sowie eine etwaige
|
||||
`KEYDB.cfg`. Das Verzeichnis gehört dem Host, damit ein
|
||||
`docker compose build` deine Dateien nicht wegwirft.
|
||||
|
||||
Dann wie im Schnellstart: klonen, `.env` füllen, `docker compose up -d
|
||||
--build`, `http://<host>` öffnen — der Einrichtungs-Assistent führt durch
|
||||
@@ -175,6 +264,25 @@ docker compose up -d --build`.
|
||||
Nicht mitnehmen musst du: Gitea, Arcane, den CI-Runner — das ist die
|
||||
Entwicklungs-Infrastruktur DIESER Installation, nicht Teil von Rippy.
|
||||
|
||||
### Härtung für fremde/exponierte Netze
|
||||
|
||||
Rippy ist bewusst **Heimnetz-only**: Es hat **keine Authentifizierung**, und
|
||||
Redis + PostgreSQL veröffentlichen Ports auf allen Interfaces (für Remote-Worker).
|
||||
Im vertrauten LAN ist das ein akzeptierter Kompromiss — **exponiere Rippy niemals
|
||||
ungeschützt an ein unsicheres oder öffentliches Netz.** Wer es weiter absichern will:
|
||||
|
||||
- **Zugriff kapseln:** API (`:8000`) und UI hinter einen Reverse-Proxy mit Auth
|
||||
(Basic-/Forward-Auth) legen oder nur über VPN erreichbar machen; Port `8000`
|
||||
nicht auf `0.0.0.0` binden.
|
||||
- **DB/Broker abschotten:** `POSTGRES_PASSWORD` in der `.env` auf ein starkes
|
||||
Passwort setzen; die Ports `5432`/`6379` nur an ein internes/VPN-Interface binden
|
||||
(z. B. `"10.0.0.5:5432:5432"`) statt an `0.0.0.0`; Redis mit `--requirepass
|
||||
<geheim>` starten und das Passwort in `REDIS_URL` ergänzen.
|
||||
- **CORS:** `main.py` erlaubt aktuell `*` (nötig, weil UI und API getrennte Ports
|
||||
sind) — hinter einem Proxy auf die echte UI-Herkunft einschränken.
|
||||
- **NAS-Zugangsdaten** liegen bewusst im Klartext in der DB — ein weiterer Grund,
|
||||
den DB-Port nie offen ins unsichere Netz zu hängen.
|
||||
|
||||
## Entwicklung
|
||||
|
||||
CI („Ampel") läuft bei jedem Push: Ruff, pytest, Vite-Build. Es gibt genau
|
||||
|
||||
+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
|
||||
|
||||
1. **Design 2.0** — an Gemini übergeben (24.07.2026). Vollständiges
|
||||
|
||||
+263
-6
@@ -1,6 +1,259 @@
|
||||
# SAVEPOINT — Rippy
|
||||
|
||||
## Aktueller Stand: v3.9 — Windows-Installer als echte .exe (Icon, kein Konsolenfenster) (24.07.2026)
|
||||
## Aktueller 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:
|
||||
.vbs ist abgekündigt + wird als gefährlich geflaggt). install-gui.ps1 via
|
||||
@@ -183,16 +436,20 @@ Konvertierung (Infrastruktur steht), AI-Box-NFS-Export, Kodi-Refresh.
|
||||
- **UHD-Kette diagnostiziert (Nachtrag, gleicher Tag): Der BU40N-Flash IST
|
||||
erledigt** — makemkvcon meldet „Using LibreDrive mode (v06.3)". Der
|
||||
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 —
|
||||
auf der VM mit 1.17.7 UND der aktuellsten 1.18.4 bewiesen. Konsequenzen:
|
||||
is unknown" — auf der VM mit 1.17.7 UND der aktuellsten 1.18.4
|
||||
reproduziert. Konsequenzen:
|
||||
MakeMKV auf **1.18.4** gehoben (Scan-Hänger durch überall genutztes
|
||||
--noscan umgangen, auf der BU40N sauber gelaufen; URL-Base → /download),
|
||||
run_makemkv reicht kritische Meldungen (volume key/Key abgelaufen) in
|
||||
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/).
|
||||
Summer Wars bleibt bis zu einem MakeMKV-Key-Update nicht entschlüsselbar
|
||||
— das ist Stand der Technik, kein Rippy-Bug.
|
||||
**⚠️ Korrigiert am 25.07.2026 (siehe v3.10 ganz oben):** Die hier
|
||||
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
|
||||
ohne Popup in den passenden Schnellwahl-Ordner (Serie/Film/Musik).
|
||||
- **Job-Verwaltung**: „Neu komprimieren" nur noch, wenn Rohdaten wirklich
|
||||
|
||||
@@ -1,27 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
# Deploy für Rippy — deployt den aktuellen 'main'-Stand auf die VM.
|
||||
# Single Source of Truth ist seit 24.07.2026 'main' (kein stable-Branch mehr);
|
||||
# die Ampel prüft nur noch, befördert aber nichts. Vor dem Deploy also selbst
|
||||
# sicherstellen, dass die Ampel für den zu deployenden Commit GRÜN ist.
|
||||
# Deploy fuer Rippy — deployt den aktuellen 'main'-Stand auf die Ziel-VM.
|
||||
# Single Source of Truth ist 'main' (kein stable-Branch); die Ampel prueft nur,
|
||||
# befoerdert nichts. Vor dem Deploy selbst sicherstellen, dass die Ampel fuer den
|
||||
# zu deployenden Commit GRUEN ist.
|
||||
#
|
||||
# Nutzt einen EIGENEN Klon (~/notfall-rippy) und fasst Arcanes
|
||||
# Projektverzeichnis (~/projects/rippy) NIE an — 22.07. gelernt: ein git-Klon
|
||||
# im Arcane-Verzeichnis kollidiert mit dessen Sync (read-only .git-Objekte).
|
||||
# Die .env gehört Arcane und wird nur GELESEN (für compose-Interpolation).
|
||||
# Generisch/portabel: Ziel-Host, Repo-URL und .env-Pfad kommen aus der Umgebung —
|
||||
# KEINE festen Adressen im Repo. Fuer eine Erst-Installation auf einem eigenen Host
|
||||
# reicht ohnehin der einfache Weg (siehe README):
|
||||
# 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
|
||||
|
||||
VM="arcane@192.168.178.162"
|
||||
DIENST="${1:-}" # optional: nur einen Dienst neu bauen, z.B. ./deploy.sh api
|
||||
VM="${RIPPY_VM:?RIPPY_VM setzen, z.B. RIPPY_VM=user@10.0.0.5}"
|
||||
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
|
||||
if [ ! -d ~/notfall-rippy/.git ]; then
|
||||
git clone --branch main https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/rippy.git ~/notfall-rippy
|
||||
REPO_URL="$1"
|
||||
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
|
||||
cd ~/notfall-rippy
|
||||
cd "$CLONE"
|
||||
git fetch 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 ps --format 'table {{.Name}}\t{{.Status}}'
|
||||
REMOTE
|
||||
|
||||
+29
-11
@@ -11,11 +11,15 @@ services:
|
||||
context: .
|
||||
dockerfile: docker/api/Dockerfile
|
||||
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
|
||||
- TMDB_API_KEY=${TMDB_API_KEY:-}
|
||||
- THETVDB_API_KEY=${THETVDB_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
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c 'import urllib.request; urllib.request.urlopen(\"http://localhost:8000/health\")'"]
|
||||
@@ -47,8 +51,10 @@ services:
|
||||
bind:
|
||||
propagation: rshared
|
||||
- temp:/app/temp
|
||||
# MakeMKV-Datenverzeichnis (dasselbe wie im Worker, siehe dort).
|
||||
- ${MAKEMKV_DATA_HOST:-/srv/rippy/makemkv}:/app/makemkv-data
|
||||
devices:
|
||||
- /dev/sr0:/dev/sr0
|
||||
- ${OPTICAL_SR:-/dev/sr0}:/dev/sr0
|
||||
networks:
|
||||
- rippy-net
|
||||
depends_on:
|
||||
@@ -74,10 +80,14 @@ services:
|
||||
# Einstellungen → System meldet, wenn es eine neuere Version gibt.
|
||||
MAKEMKV_VERSION: ${MAKEMKV_VERSION:-1.18.4}
|
||||
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
|
||||
- RIP_OUTPUT_DIR=/app/media
|
||||
- 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;
|
||||
# via .env übersteuerbar, z. B. WORKER_NAME=wohnzimmer-vm)
|
||||
- WORKER_NAME=${WORKER_NAME:-rippy-hauptworker}
|
||||
@@ -92,14 +102,22 @@ services:
|
||||
bind:
|
||||
propagation: rslave
|
||||
- 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:
|
||||
- /dev/sr0:/dev/sr0
|
||||
# MakeMKV spricht Laufwerke über die SCSI-Generic-Schicht an — ohne den
|
||||
# sg-Knoten findet es „keine usable optical drives" (Deploy-Befund 23.07.).
|
||||
# In dieser VM: sg0 = Systemplatte, sg1 = das BD-Laufwerk. Achtung: nach
|
||||
# einem USB-Reconnect zur Laufzeit kann die sg-Nummer wandern → Container
|
||||
# neu starten.
|
||||
- /dev/sg1:/dev/sg1
|
||||
# Host-Geräteknoten via .env (OPTICAL_SR/OPTICAL_SG) — je Rechner ANDERS!
|
||||
# MakeMKV spricht Laufwerke über die SCSI-Generic-Schicht an; ohne den
|
||||
# passenden sg-Knoten findet es „keine usable optical drives". Den sg-Knoten
|
||||
# des Laufwerks auf DIESEM Host ermitteln (lsscsi -g) und in die .env eintragen.
|
||||
# Achtung: nach USB-Reconnect zur Laufzeit kann die sg-Nummer wandern →
|
||||
# Container neu starten. Defaults (sr0/sg1) passen für den Ursprungs-Host.
|
||||
- ${OPTICAL_SR:-/dev/sr0}:/dev/sr0
|
||||
- ${OPTICAL_SG:-/dev/sg1}:/dev/sg1
|
||||
networks:
|
||||
- rippy-net
|
||||
depends_on:
|
||||
@@ -132,7 +150,7 @@ services:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
- POSTGRES_USER=rippy
|
||||
- POSTGRES_PASSWORD=rippy
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-rippy}
|
||||
- POSTGRES_DB=rippy
|
||||
# Veröffentlicht (Befund 24.07.): Remote-Worker (GPU-Maschine, künftiger
|
||||
# Windows-Worker) verbinden sich über <rippy-host>:5432/6379 — ohne
|
||||
|
||||
@@ -14,12 +14,14 @@ def validate_config() -> Settings:
|
||||
try:
|
||||
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:
|
||||
raise ConfigValidationError(
|
||||
"TMDB_API_KEY ist erforderlich für Metadaten-Lookup.\n"
|
||||
"Hole dir einen免费en Key auf https://www.themoviedb.org/\n"
|
||||
"Setze ihn als Umgebungsvariable: TMDB_API_KEY=dein_key"
|
||||
"TMDB_API_KEY ist nicht gesetzt (empfohlen für Metadaten-Lookup).\n"
|
||||
"Kostenlosen Key auf https://www.themoviedb.org/ holen und als\n"
|
||||
"Umgebungsvariable TMDB_API_KEY setzen — oder später im UI eintragen."
|
||||
)
|
||||
|
||||
return settings
|
||||
|
||||
@@ -12,6 +12,7 @@ import uuid
|
||||
|
||||
import db
|
||||
import devices as device_discovery
|
||||
import makemkv_daten
|
||||
import makemkv_key
|
||||
import mounts as mount_verwaltung
|
||||
import notify
|
||||
@@ -250,6 +251,7 @@ class Job(BaseModel):
|
||||
title: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
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):
|
||||
id: str
|
||||
@@ -265,6 +267,13 @@ class Device(BaseModel):
|
||||
def _job_row_to_model(zeile: dict) -> Job:
|
||||
"""DB-Zeile → UI-Form (Worker-Status 'running' heißt im UI '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(
|
||||
id=zeile["id"],
|
||||
type=zeile.get("disc_type") or "unknown",
|
||||
@@ -275,6 +284,7 @@ def _job_row_to_model(zeile: dict) -> Job:
|
||||
progress=zeile.get("progress") or 0,
|
||||
title=zeile.get("title"),
|
||||
error=zeile.get("error"),
|
||||
meta=meta,
|
||||
)
|
||||
|
||||
@app.get("/health")
|
||||
@@ -349,6 +359,10 @@ class JobCreateRequest(BaseModel):
|
||||
main_feature_only: Optional[bool] = None # pro Rip; None = Setting gilt
|
||||
titles: Optional[List[int]] = None # exakte Titel-Auswahl (Track-Tabelle)
|
||||
transcode_node: Optional[str] = None # gewählter Encoder-Worker (Celery-Node)
|
||||
# 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"
|
||||
@@ -408,6 +422,12 @@ async def create_job(request: JobCreateRequest):
|
||||
meta_dict["titles"] = titel_liste
|
||||
if 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
|
||||
|
||||
job_id = str(uuid.uuid4())
|
||||
@@ -1181,6 +1201,203 @@ async def system_info():
|
||||
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 grosses 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 os.path.realpath(pfad).startswith(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):
|
||||
url: str
|
||||
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
"""MakeMKV-Datenverzeichnis: Schluesselspeicher, 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 laeuft 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 gefuelltem Schluesselspeicher, mit und ohne --noscan, mit
|
||||
dev:/dev/sr0 und mit disc:0, und mit erzwungener frischer Pruefung
|
||||
(update.conf geloescht, 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 waechst — und die Disc geht auf
|
||||
(TCOUNT:5, "Operation successfully completed").
|
||||
* Der Code dafuer steckt auch im Linux-Binary: die Meldungsvorlage
|
||||
"Downloading latest %1 to %2 ..." steht in makemkvcon. Sie loest 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
|
||||
stuetzte 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 Schluesselspeicher (_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 fuer Pressungen, die auch MakeMKV selbst nicht kennt.
|
||||
|
||||
Rippy liefert KEINE Schluessel mit, laedt keine herunter und verteilt keine.
|
||||
Es verwaltet nur, was der Nutzer selbst mitbringt.
|
||||
|
||||
QUELLEN (AGENTS Regel D — externe Schnittstellen nie aus dem Kopf):
|
||||
* Linux laedt 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). Aenderungen 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 fuer den Upload. Eine vollstaendige 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) zaehlt 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 zaehlen (pure Funktion, testbar).
|
||||
|
||||
Bewusst eine Heuristik und keine vollstaendige 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:
|
||||
"""Prueft hochgeladenen Inhalt; gibt deutschen Fehlertext oder "" zurueck.
|
||||
|
||||
Verhindert den haeufigsten Bedienfehler: statt der KEYDB.cfg landet die
|
||||
HTML-Fehlerseite eines Downloads oder eine leere Datei im Verzeichnis —
|
||||
MakeMKV wuerde 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 groesser 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: Groesse/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: waehrend ein Rip laeuft, 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
|
||||
|
||||
|
||||
# --- Schluesselspeicher (_private_data.tar) -------------------------------
|
||||
#
|
||||
# Das ist MakeMKVs eigener Speicher fuer die "Hashed Keys": ein tar-Archiv mit
|
||||
# hkd_*.bin-Eintraegen. Unter Windows fuellt 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 naechsten Start.
|
||||
|
||||
PRIVATE_DATA_NAME = "_private_data.tar"
|
||||
|
||||
# Der Speicher lag am 25.07.2026 bei rund 6 MB und waechst mit jeder neuen
|
||||
# Pressung. 64 MB sind reichlich Luft — und derselbe Wert wie
|
||||
# client_max_body_size in docker/ui/nginx.conf: waere die Grenze hier hoeher,
|
||||
# wuerde nginx den Upload abweisen, bevor die API ihn ueberhaupt sieht.
|
||||
MAX_PRIVATE_DATA_BYTES = 64 * 1024 * 1024
|
||||
|
||||
|
||||
def private_data_pfad(daten_dir: str = None) -> str:
|
||||
"""Voller Pfad zum Schluesselspeicher 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 fuer "wie viele Disc-Schluessel kennt diese
|
||||
Installation". Ein frischer, leerer Speicher enthaelt 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:
|
||||
"""Prueft 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 aendert 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 groesser als "
|
||||
f"{MAX_PRIVATE_DATA_BYTES // (1024 * 1024)} MB — das ist kein "
|
||||
"MakeMKV-Schluesselspeicher."
|
||||
)
|
||||
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 — oeffne dort erst einmal eine Disc."
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
def schluesselspeicher_status(daten_dir: str = None) -> dict:
|
||||
"""Zustand des Schluesselspeichers. 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 Schluesselspeicher atomar ab und meldet den neuen Zustand.
|
||||
|
||||
Atomar aus demselben Grund wie bei der KEYDB.cfg: waehrend ein Rip laeuft,
|
||||
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
|
||||
ueberschrieben. Mit dem jetzt persistenten Datenverzeichnis waere damit
|
||||
bei jedem Containerstart und vor jedem Rip alles andere weg — z. B.
|
||||
app_UpdateEnable. Leerer Key laesst 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 ""
|
||||
@@ -11,8 +11,14 @@ Dieses Modul holt den aktuellen Key vom Forum und schreibt ihn in die Settings
|
||||
Rebuild/Neustart, ab dem naechsten Rip.
|
||||
|
||||
Wichtig: Das ist die kostenlose, oeffentliche Beta-LIZENZ der Software selbst —
|
||||
NICHT das Entschluesseln oder Verteilen von Disc-Schluesseln. Den AACS-Schluessel
|
||||
zieht MakeMKV via LibreDrive ohnehin selbst aus dem Laufwerk.
|
||||
NICHT das Entschluesseln oder Verteilen von Disc-Schluesseln. Diese Grenze gilt
|
||||
unveraendert; Rippy liefert keine Disc-Schluessel mit und verteilt keine.
|
||||
|
||||
Richtigstellung 25.07.2026: Hier stand frueher, MakeMKV ziehe den AACS-Schluessel
|
||||
via LibreDrive ohnehin selbst aus dem Laufwerk. Das stimmt fuer Blu-ray, aber
|
||||
NICHT fuer 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):
|
||||
- Forum-Thread: https://forum.makemkv.com/forum/viewtopic.php?t=1053
|
||||
|
||||
+29
-1
@@ -73,6 +73,30 @@ def schreibtest(pfad: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _stale_mounts_loesen(ziel: str) -> int:
|
||||
"""Loest ALLE (evtl. gestapelten/toten) Mounts an `ziel` per lazy umount.
|
||||
|
||||
Warum: mounten() faellt bei einem TOTEN Mount (os.path.ismount wirft OSError)
|
||||
auf den echten `mount` durch — der stapelt dann auf die Leiche. Vorfall 24.07.:
|
||||
ueber viele Neustarts 12 Schichten, die tote oberste blockierte jeden Zugriff
|
||||
(ls-Timeout, obwohl SMB-445 offen). Erst alle Schichten loesen macht das
|
||||
Re-Mounten idempotent. `umount -l` (lazy) haengt 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:
|
||||
"""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):
|
||||
return schreibtest(ziel)
|
||||
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
|
||||
try:
|
||||
|
||||
@@ -18,7 +18,16 @@ def test_main_importierbar_und_routen_verdrahtet():
|
||||
from main import app
|
||||
|
||||
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", "/prescan",
|
||||
# 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 fuer 4K-UHD (25.07.2026): makemkvcon holt Disc-Schluessel
|
||||
# unter Linux nie selbst, sie kommen von Hand ueber diesen Endpunkt.
|
||||
"/system/keystore",
|
||||
):
|
||||
assert pfad in routen, f"Route {pfad} fehlt"
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Tests fuer 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 duerfen NICHT mitgezaehlt werden, sonst meldet
|
||||
# das UI "da liegt was drin", obwohl die Datei keinen Schluessel enthaelt.
|
||||
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():
|
||||
# Haeufigster Bedienfehler: statt der Datei landet die HTML-Fehlerseite
|
||||
# eines Downloads im Feld. MakeMKV wuerde 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():
|
||||
# "" heisst laut Vertrag: alles in Ordnung, darf geschrieben werden.
|
||||
assert keydb_pruefen(_GUELTIG) == ""
|
||||
|
||||
|
||||
def test_ist_aacs_dump_erkennt_echten_namen():
|
||||
# So heisst der Dump, den MakeMKV am 25.07.2026 fuer 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 haengt den Namen an das Datenverzeichnis — ein
|
||||
# durchgelassenes ".." oder ein Pfadtrenner waere 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 ueberschrieben. Mit
|
||||
# dem jetzt persistenten Datenverzeichnis waere 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("", "") == ""
|
||||
@@ -39,3 +39,33 @@ def test_unbekannter_fehler_bleibt_erhalten_und_gekappt():
|
||||
def test_validiere_name_bleibt_streng():
|
||||
assert 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:
|
||||
proxy_buffering off;
|
||||
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 / {
|
||||
|
||||
+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 Dashboard from './pages/Dashboard'
|
||||
import SettingsPage from './pages/Settings'
|
||||
import LogsPage from './pages/Logs'
|
||||
import AnleitungPage from './pages/Anleitung'
|
||||
import FirstRunWizard from './components/FirstRunWizard'
|
||||
import { api } from './lib/api'
|
||||
|
||||
type Page = 'dashboard' | 'anleitung' | 'logs' | 'settings'
|
||||
|
||||
export default function App() {
|
||||
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 }[] = [
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
@@ -17,6 +28,9 @@ export default function App() {
|
||||
{ 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 (
|
||||
<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 */}
|
||||
|
||||
@@ -90,6 +90,7 @@ export default function DeviceDiscovery() {
|
||||
...(optionen?.mainFeatureOnly !== undefined ? { main_feature_only: optionen.mainFeatureOnly } : {}),
|
||||
...(optionen?.titles && optionen.titles.length ? { titles: optionen.titles } : {}),
|
||||
...(optionen?.transcodeNode ? { transcode_node: optionen.transcodeNode } : {}),
|
||||
...(optionen?.workDir ? { work_dir: optionen.workDir } : {}),
|
||||
})
|
||||
setActionFeedback(`✓ Job angelegt (${response.data.id.slice(0, 8)}…) — Fortschritt im Dashboard`)
|
||||
toast('success', 'Rip gestartet — Fortschritt unten bei „Neueste Jobs"')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { 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 { Modal } from './ui/Modal'
|
||||
import { Button } from './ui/Button'
|
||||
@@ -19,6 +19,15 @@ export interface RipOptionen {
|
||||
mainFeatureOnly?: boolean
|
||||
titles?: number[]
|
||||
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 {
|
||||
@@ -112,6 +121,12 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([])
|
||||
const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([])
|
||||
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(() => {
|
||||
if (!isOpen) return
|
||||
@@ -124,6 +139,10 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
setTitelListe([])
|
||||
setGewaehlt(new Set())
|
||||
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)
|
||||
api.get('/capabilities').then(r => {
|
||||
setWorkers((r.data.workers || []).filter((w: WorkerWahl) => w.online && w.node))
|
||||
@@ -131,6 +150,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
api.get('/settings').then(r => {
|
||||
const s = r.data || {}
|
||||
setNurHauptfilm(!!s.mainFeatureOnly)
|
||||
setStandardArbeitsDir((s.workDir || '').trim())
|
||||
const basis = s.outputDir || '/app/media'
|
||||
setTargets([
|
||||
{ id: '1', name: 'Filme', path: `${basis}/${s.movieDir || 'movies'}`, type: 'movies', isActive: true },
|
||||
@@ -164,6 +184,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
optionen.titles = [...gewaehlt].sort((a, b) => a - b)
|
||||
}
|
||||
if (encoderNode) optionen.transcodeNode = encoderNode
|
||||
if (arbeitsDir) optionen.workDir = arbeitsDir
|
||||
const target = targets.find(t => t.type === selectedType)
|
||||
if (customPath) {
|
||||
onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true }, optionen)
|
||||
@@ -245,6 +266,42 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
</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
|
||||
(sonst gibt es nichts zu wählen). Musik wird nicht komprimiert. */}
|
||||
{selectedType !== 'music' && workers.length >= 2 && (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ReactNode, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
interface ModalProps {
|
||||
@@ -35,7 +36,15 @@ export function Modal({ isOpen, onClose, title, children, maxWidth = 'lg' }: Mod
|
||||
'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">
|
||||
{/* Glass Backdrop */}
|
||||
<div
|
||||
@@ -61,6 +70,7 @@ export function Modal({ isOpen, onClose, title, children, maxWidth = 'lg' }: Mod
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -134,12 +134,15 @@ export default function AnleitungPage() {
|
||||
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.
|
||||
</p>
|
||||
{/* 25.07.2026 richtiggestellt: Updates bringen keine Disc-Schluessel mit — siehe UHD-Absatz unten. */}
|
||||
<p>
|
||||
<span className={fett}>Updates:</span> „Auf Updates prüfen" (ebenfalls Einstellungen →
|
||||
System) vergleicht die installierten Versionen mit makemkv.com und den offiziellen
|
||||
HandBrake-Releases. Gibt es ein MakeMKV-Update, zeigt Rippy den fertigen
|
||||
Update-Befehl an — wichtig, weil neue Versionen auch die neueste
|
||||
Disc-Schlüssel-Datenbank mitbringen.
|
||||
Update-Befehl an — neue Versionen bringen bessere Laufwerks-Unterstützung und
|
||||
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>
|
||||
</Abschnitt>
|
||||
|
||||
@@ -157,11 +160,35 @@ export default function AnleitungPage() {
|
||||
<span className={fett}>„Diese Disc wurde bereits gerippt"?</span> Rippy erkennt Discs am
|
||||
Fingerabdruck. Nochmal rippen geht trotzdem — der Hinweis verhindert nur Versehen.
|
||||
</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>
|
||||
<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
|
||||
noch nicht. Den automatisch gespeicherten AACS-Dump im MakeMKV-Forum einreichen — mit einem
|
||||
der nächsten Updates ist die Disc rippbar.
|
||||
liest die Disc einwandfrei (LibreDrive) — MakeMKV fehlt nur der Schlüssel dieser Pressung.
|
||||
Der Grund liegt nicht bei dir und nicht bei Rippy: <span className={fett}>die Linux-Version
|
||||
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>
|
||||
<span className={fett}>„Neu komprimieren" fehlt bei einem Fehl-Job?</span> Der Knopf
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Save, Disc, Cpu, Database, Globe, AlertCircle, CheckCircle, HardDrive, Wrench, Send, Tv } from 'lucide-react'
|
||||
import { useState, useEffect, useRef } from '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 { useToast } from '../context/ToastContext'
|
||||
import StorageMounts from '../components/StorageMounts'
|
||||
@@ -25,7 +25,13 @@ interface SettingsState {
|
||||
autoRipStart: boolean
|
||||
notificationWebhook: string
|
||||
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
|
||||
transcodePresetDvd: string
|
||||
transcodePresetBluray: string
|
||||
transcodePresetUhd: string
|
||||
keepOriginal: boolean
|
||||
mediaServer: string
|
||||
jellyfinUrl: string
|
||||
@@ -49,6 +55,9 @@ const defaultSettings: SettingsState = {
|
||||
notificationWebhook: '',
|
||||
transcodeEnabled: true,
|
||||
transcodePreset: 'H.265 MKV 1080p30',
|
||||
transcodePresetDvd: 'H.265 MKV 576p25',
|
||||
transcodePresetBluray: 'H.265 MKV 1080p30',
|
||||
transcodePresetUhd: 'H.265 MKV 2160p60 4K',
|
||||
keepOriginal: false,
|
||||
mediaServer: 'none',
|
||||
jellyfinUrl: '',
|
||||
@@ -62,7 +71,43 @@ type SettingsTab = 'ripping' | 'verarbeitung' | 'worker' | 'speicherziele' | 'ap
|
||||
interface WorkerInfo {
|
||||
name: 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, zaehlt, deshalb steht die Angabe pro Worker und nicht global.
|
||||
info?: { makemkv?: string, handbrake?: string, makemkv_key?: string, keydb?: 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 Schluesselvorrat.
|
||||
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 {
|
||||
@@ -73,6 +118,24 @@ interface SystemInfo {
|
||||
webhook_gesetzt: boolean
|
||||
}
|
||||
|
||||
// Byte-Zahl menschenlesbar — eine echte KEYDB.cfg ist mehrere MB gross.
|
||||
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
|
||||
// wuerde 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() {
|
||||
const [settings, setSettings] = useState<SettingsState>(defaultSettings)
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('ripping')
|
||||
@@ -86,6 +149,19 @@ export default function SettingsPage() {
|
||||
const [quellenBusy, setQuellenBusy] = useState(false)
|
||||
const [updates, setUpdates] = useState<Record<string, { installiert?: string, verfuegbar?: string, update?: boolean }> | null>(null)
|
||||
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 wuerde 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()
|
||||
|
||||
@@ -106,6 +182,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
|
||||
// naechsten 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 wuerde 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 () => {
|
||||
setQuellenBusy(true)
|
||||
try {
|
||||
@@ -131,6 +270,18 @@ export default function SettingsPage() {
|
||||
api.get('/system/info')
|
||||
.then(r => setSystemInfo(r.data))
|
||||
.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 () => {
|
||||
@@ -419,16 +570,53 @@ export default function SettingsPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
label="HandBrake-Preset"
|
||||
value={settings.transcodePreset}
|
||||
onChange={(e) => handleChange('transcodePreset', e.target.value)}
|
||||
>
|
||||
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 — klein & modern (Standard)</option>
|
||||
<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>
|
||||
<option value="Super HQ 1080p30 Surround">Super HQ 1080p30 Surround — größer, beste Qualität</option>
|
||||
</Select>
|
||||
{/*
|
||||
Ein Preset JE DISC-TYP (25.07.2026). Vorher lief alles durch
|
||||
dasselbe Preset — eine 4K-UHD wurde damit auf 1080p
|
||||
heruntergerechnet und eine DVD auf 1080p hochskaliert.
|
||||
Preset-Namen stammen aus `HandBrakeCLI --preset-list` im
|
||||
Worker-Image (HandBrake 1.6.1), nicht aus dem Kopf.
|
||||
*/}
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Ein Preset je Quelle — Rippy erkennt den Disc-Typ selbst und wählt passend aus.
|
||||
<strong className="text-slate-700 dark:text-slate-300"> Eine 4K-UHD gehört auf ein
|
||||
2160p-Preset</strong>, sonst rechnet HandBrake sie auf 1080p herunter und die
|
||||
4K-Auflösung ist weg.
|
||||
</p>
|
||||
|
||||
<Select
|
||||
label="Preset für 4K-UHD"
|
||||
value={settings.transcodePresetUhd}
|
||||
onChange={(e) => handleChange('transcodePresetUhd', e.target.value)}
|
||||
>
|
||||
<option value="H.265 MKV 2160p60 4K">H.265 MKV 2160p60 4K — 4K erhalten (empfohlen)</option>
|
||||
<option value="HQ 2160p60 4K HEVC Surround">HQ 2160p60 4K HEVC Surround — 4K, mehr Qualität</option>
|
||||
<option value="Super HQ 2160p60 4K HEVC Surround">Super HQ 2160p60 4K HEVC Surround — 4K, größer</option>
|
||||
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 — bewusst auf 1080p verkleinern</option>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
label="Preset für Blu-ray"
|
||||
value={settings.transcodePresetBluray}
|
||||
onChange={(e) => handleChange('transcodePresetBluray', e.target.value)}
|
||||
>
|
||||
<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="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>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||||
<Toggle
|
||||
@@ -439,14 +627,35 @@ export default function SettingsPage() {
|
||||
/>
|
||||
</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}
|
||||
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">
|
||||
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 ? (
|
||||
<> Aktuell frei: {systemInfo.plaetze.map(p => `${p.name}: ${p.frei_gb} GB`).join(' · ')}</>
|
||||
) : null}
|
||||
@@ -633,13 +842,42 @@ export default function SettingsPage() {
|
||||
HandBrake {w.info.handbrake}
|
||||
</span>
|
||||
)}
|
||||
{/*
|
||||
Nur das Verzeichnis des rippenden Workers zaehlt — deshalb je Worker.
|
||||
Die Zahl der Disc-Schluessel ist die entscheidende Angabe fuer 4K-UHD:
|
||||
0 heisst, 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>
|
||||
))
|
||||
)}
|
||||
{/*
|
||||
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 Schluesselspeicher 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">
|
||||
<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
|
||||
Disc-Schlüssel-Datenbank.
|
||||
(Update-Check unten; Version über <code>MAKEMKV_VERSION</code> + Rebuild) — neue Versionen bringen
|
||||
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
|
||||
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
|
||||
@@ -655,6 +893,213 @@ export default function SettingsPage() {
|
||||
placeholder="T-… (Forum-Thread „MakeMKV is free while in beta”)"
|
||||
/>
|
||||
|
||||
{/*
|
||||
Schluesselspeicher — der Hauptweg fuer 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 laeuft ueber den
|
||||
Schluesselspeicher im Block darueber. 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, ueberleben 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="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-slate-900 dark:text-slate-200">Update-Check</p>
|
||||
@@ -672,7 +1117,9 @@ export default function SettingsPage() {
|
||||
<p className="text-xs mt-1">
|
||||
⚠️ 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>.
|
||||
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>
|
||||
)
|
||||
: <span className="text-xs ml-1 text-emerald-600 dark:text-emerald-400">✓ aktuell</span>}
|
||||
|
||||
@@ -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
|
||||
# gepinnt. Beides erledigt: der Scan wird überall mit --noscan umgangen
|
||||
# (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
|
||||
# Version zählt, weil sie die neueste AACS-Schlüssel-Datenbank mitbringt —
|
||||
# 1.17.7 kannte z. B. den Key der Summer-Wars-UHD (MKB v82) nicht.
|
||||
# das Laufwerk ist geflasht (LibreDrive v06.3 bestätigt).
|
||||
# Richtigstellung 25.07.2026: Hier stand, die aktuelle Version zähle, weil sie
|
||||
# "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
|
||||
# aktuelle Version, /download/old die älteren. Wenn Cloudflare BuildKit-
|
||||
# Downloads drosselt (24.07.: außerhalb des Builds ging alles, im Build
|
||||
|
||||
@@ -85,4 +85,31 @@ def werkzeug_versionen() -> dict:
|
||||
info["makemkv_key"] = "env"
|
||||
else:
|
||||
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
|
||||
|
||||
@@ -1,11 +1,51 @@
|
||||
#!/bin/sh
|
||||
# Schreibt den MakeMKV-Beta-Key aus der Umgebung in die Settings (falls gesetzt).
|
||||
# Ohne Key: DVD-Ripping geht immer, Blu-ray läuft im 30-Tage-Testmodus.
|
||||
# Bereitet MakeMKVs Datenverzeichnis vor, BEVOR der Worker startet.
|
||||
#
|
||||
# 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
|
||||
|
||||
if [ -n "${MAKEMKV_APP_KEY}" ]; then
|
||||
mkdir -p /root/.MakeMKV
|
||||
printf 'app_Key = "%s"\n' "${MAKEMKV_APP_KEY}" > /root/.MakeMKV/settings.conf
|
||||
DATEN_DIR="${MAKEMKV_DATA_DIR:-/root/.MakeMKV}"
|
||||
CONF="${DATEN_DIR}/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
|
||||
|
||||
exec "$@"
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
"""MakeMKV-Datenverzeichnis: Schluesselspeicher, 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 laeuft 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 gefuelltem Schluesselspeicher, mit und ohne --noscan, mit
|
||||
dev:/dev/sr0 und mit disc:0, und mit erzwungener frischer Pruefung
|
||||
(update.conf geloescht, 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 waechst — und die Disc geht auf
|
||||
(TCOUNT:5, "Operation successfully completed").
|
||||
* Der Code dafuer steckt auch im Linux-Binary: die Meldungsvorlage
|
||||
"Downloading latest %1 to %2 ..." steht in makemkvcon. Sie loest 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
|
||||
stuetzte 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 Schluesselspeicher (_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 fuer Pressungen, die auch MakeMKV selbst nicht kennt.
|
||||
|
||||
Rippy liefert KEINE Schluessel mit, laedt keine herunter und verteilt keine.
|
||||
Es verwaltet nur, was der Nutzer selbst mitbringt.
|
||||
|
||||
QUELLEN (AGENTS Regel D — externe Schnittstellen nie aus dem Kopf):
|
||||
* Linux laedt 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). Aenderungen 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 fuer den Upload. Eine vollstaendige 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) zaehlt 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 zaehlen (pure Funktion, testbar).
|
||||
|
||||
Bewusst eine Heuristik und keine vollstaendige 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:
|
||||
"""Prueft hochgeladenen Inhalt; gibt deutschen Fehlertext oder "" zurueck.
|
||||
|
||||
Verhindert den haeufigsten Bedienfehler: statt der KEYDB.cfg landet die
|
||||
HTML-Fehlerseite eines Downloads oder eine leere Datei im Verzeichnis —
|
||||
MakeMKV wuerde 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 groesser 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: Groesse/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: waehrend ein Rip laeuft, 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
|
||||
|
||||
|
||||
# --- Schluesselspeicher (_private_data.tar) -------------------------------
|
||||
#
|
||||
# Das ist MakeMKVs eigener Speicher fuer die "Hashed Keys": ein tar-Archiv mit
|
||||
# hkd_*.bin-Eintraegen. Unter Windows fuellt 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 naechsten Start.
|
||||
|
||||
PRIVATE_DATA_NAME = "_private_data.tar"
|
||||
|
||||
# Der Speicher lag am 25.07.2026 bei rund 6 MB und waechst mit jeder neuen
|
||||
# Pressung. 64 MB sind reichlich Luft — und derselbe Wert wie
|
||||
# client_max_body_size in docker/ui/nginx.conf: waere die Grenze hier hoeher,
|
||||
# wuerde nginx den Upload abweisen, bevor die API ihn ueberhaupt sieht.
|
||||
MAX_PRIVATE_DATA_BYTES = 64 * 1024 * 1024
|
||||
|
||||
|
||||
def private_data_pfad(daten_dir: str = None) -> str:
|
||||
"""Voller Pfad zum Schluesselspeicher 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 fuer "wie viele Disc-Schluessel kennt diese
|
||||
Installation". Ein frischer, leerer Speicher enthaelt 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:
|
||||
"""Prueft 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 aendert 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 groesser als "
|
||||
f"{MAX_PRIVATE_DATA_BYTES // (1024 * 1024)} MB — das ist kein "
|
||||
"MakeMKV-Schluesselspeicher."
|
||||
)
|
||||
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 — oeffne dort erst einmal eine Disc."
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
def schluesselspeicher_status(daten_dir: str = None) -> dict:
|
||||
"""Zustand des Schluesselspeichers. 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 Schluesselspeicher atomar ab und meldet den neuen Zustand.
|
||||
|
||||
Atomar aus demselben Grund wie bei der KEYDB.cfg: waehrend ein Rip laeuft,
|
||||
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
|
||||
ueberschrieben. Mit dem jetzt persistenten Datenverzeichnis waere damit
|
||||
bei jedem Containerstart und vor jedem Rip alles andere weg — z. B.
|
||||
app_UpdateEnable. Leerer Key laesst 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 ""
|
||||
+83
-16
@@ -114,7 +114,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,
|
||||
progress_cb=None) -> dict:
|
||||
progress_cb=None, log_cb=None) -> dict:
|
||||
"""Rippt GENAU die gewählten Titel (makemkvcon kann pro Aufruf nur einen
|
||||
Titel oder 'all' — also ein Aufruf je Titel, Fortschritt anteilig)."""
|
||||
gesamt = len(titel_liste)
|
||||
@@ -124,7 +124,8 @@ def rip_titel_auswahl(device_path: str, output_dir: str, titel_liste: list,
|
||||
if progress_cb:
|
||||
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":
|
||||
return ergebnis
|
||||
if ergebnis.get("status") != "success":
|
||||
@@ -181,6 +182,27 @@ def lies_datei_dauer(pfad: str, timeout: int = 120) -> int:
|
||||
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 enthaelt — und MakeMKV schreibt solche
|
||||
laufend ("Title #1 has length of 12 seconds, which is less than ...").
|
||||
Deshalb eine Regex, die die Anfuehrungszeichen 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:
|
||||
"""Extrahiert Gesamt-Fortschritt (0-100) aus einer PRGV-Zeile.
|
||||
|
||||
@@ -203,6 +225,33 @@ def check_handbrake_installed() -> bool:
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
einstellungen = einstellungen or {}
|
||||
schluessel = PRESET_SCHLUESSEL.get(disc_type or "")
|
||||
if schluessel:
|
||||
eigen = (einstellungen.get(schluessel) or "").strip()
|
||||
if eigen:
|
||||
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:
|
||||
"""Baut das HandBrake-Kommando für die Kompressions-Stufe (pure Funktion).
|
||||
@@ -297,8 +346,17 @@ def write_abcde_config(output_dir: str) -> str:
|
||||
return tmp.name
|
||||
|
||||
|
||||
def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str = "all") -> dict:
|
||||
"""Rippt eine DVD/Blu-ray verlustfrei mit makemkvcon; meldet Fortschritt."""
|
||||
def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str = "all",
|
||||
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
|
||||
waeren dort also grossteils 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():
|
||||
return {"status": "error", "error": "makemkvcon ist nicht installiert"}
|
||||
|
||||
@@ -328,15 +386,23 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
|
||||
try:
|
||||
for line in process.stdout:
|
||||
progress = get_progress_from_prgv(line)
|
||||
if progress >= 0 and progress_cb:
|
||||
progress_cb(progress)
|
||||
elif line.startswith("MSG:"):
|
||||
# MSG:code,flags,count,"message",... — Klartext ist Feld 4
|
||||
teile = line.split(",", 4)
|
||||
if len(teile) >= 4:
|
||||
letzte_meldung = teile[3].strip('"')
|
||||
if any(muster in letzte_meldung for muster in KRITISCH):
|
||||
kritische_meldungen.append(letzte_meldung)
|
||||
if progress >= 0:
|
||||
if progress_cb:
|
||||
progress_cb(progress)
|
||||
continue
|
||||
meldung = parse_msg(line)
|
||||
if meldung is None:
|
||||
continue
|
||||
code, letzte_meldung = 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:
|
||||
process.kill()
|
||||
process.wait()
|
||||
@@ -373,7 +439,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,
|
||||
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.
|
||||
|
||||
Bewusst KEIN eigener Celery-Task: der einzige Task ist worker.tasks.rip_disc,
|
||||
@@ -388,7 +454,7 @@ def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_c
|
||||
|
||||
if titel_liste:
|
||||
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"
|
||||
if nur_hauptfilm:
|
||||
@@ -397,7 +463,8 @@ def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_c
|
||||
if haupt is not None:
|
||||
titel = str(haupt)
|
||||
# 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:
|
||||
|
||||
+153
-33
@@ -15,11 +15,13 @@ komprimiert danach auf Arbeitsgröße. Die Rohdatei liegt nur temporär in
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import posixpath
|
||||
import shutil
|
||||
|
||||
import requests
|
||||
|
||||
import db
|
||||
import makemkv_daten
|
||||
import medien
|
||||
import notify
|
||||
from celery_app import celery_app
|
||||
@@ -34,11 +36,11 @@ except ImportError: # Windows: kein fcntl
|
||||
disc_size_bytes = None
|
||||
|
||||
from ripping import (
|
||||
DEFAULT_HB_PRESET,
|
||||
RIP_OUTPUT_DIR,
|
||||
RipAbbruch,
|
||||
lies_datei_dauer,
|
||||
lies_titel_info,
|
||||
preset_fuer,
|
||||
rip_cd,
|
||||
rip_video,
|
||||
run_handbrake,
|
||||
@@ -98,19 +100,29 @@ def _zielbasis(target_dir, disc_type: str) -> str:
|
||||
return os.path.join(RIP_OUTPUT_DIR, disc_type)
|
||||
|
||||
|
||||
def _arbeitsverzeichnis(einstellungen: dict) -> str:
|
||||
"""Basis für Roh-Rips: UI-Setting `workDir` (unter /app/media, z. B. eine
|
||||
NAS-Freigabe) schlägt den Container-Default /app/temp/raw.
|
||||
def _arbeitsverzeichnis(einstellungen: dict, job_wahl: str = "") -> str:
|
||||
"""Basis für Roh-Rips. Reihenfolge: Wahl DIESES Rips → UI-Setting
|
||||
`workDir` → Container-Default /app/temp/raw.
|
||||
|
||||
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 —
|
||||
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()
|
||||
if work_dir:
|
||||
normalisiert = os.path.normpath(work_dir)
|
||||
if normalisiert.startswith(MEDIA_ROOT):
|
||||
return normalisiert
|
||||
# posixpath statt os.path: Das sind IMMER Container-Pfade (/app/media/...),
|
||||
# auch wenn ein nativer Windows-Worker dieses Modul lädt — der übersetzt
|
||||
# sie erst später mit pfad_lokal(). os.path.normpath macht unter Windows
|
||||
# Backslashes daraus, und dann greift die MEDIA_ROOT-Prüfung nicht mehr.
|
||||
for kandidat in (job_wahl, einstellungen.get("workDir")):
|
||||
wert = (kandidat or "").strip()
|
||||
if wert:
|
||||
normalisiert = posixpath.normpath(wert)
|
||||
if normalisiert.startswith(MEDIA_ROOT):
|
||||
return normalisiert
|
||||
return RAW_DIR
|
||||
|
||||
|
||||
@@ -147,19 +159,88 @@ def _makemkv_key_anwenden(einstellungen: dict) -> None:
|
||||
|
||||
Damit ist der Monats-Key ohne Rebuild/Neustart aktualisierbar
|
||||
(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()
|
||||
if not key:
|
||||
return
|
||||
ordner = os.path.expanduser("~/.MakeMKV")
|
||||
pfad = os.path.join(makemkv_daten.DATEN_DIR, "settings.conf")
|
||||
try:
|
||||
os.makedirs(ordner, exist_ok=True)
|
||||
with open(os.path.join(ordner, "settings.conf"), "w") as f:
|
||||
f.write(f'app_Key = "{key}"\n')
|
||||
os.makedirs(makemkv_daten.DATEN_DIR, exist_ok=True)
|
||||
try:
|
||||
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:
|
||||
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(). Arbeitsverzeichnis (/app/temp, Docker-Volume) und Ziel
|
||||
(/app/media, Bind-Mount) sind VERSCHIEDENE Dateisysteme — os.rename
|
||||
scheitert dort mit EXDEV, und shutil.move fällt auf Kopieren zurück. Damit
|
||||
lief eine 74-GB-Vollkopie auf dieselbe Platte an, bis sie mit ENOSPC voll
|
||||
war. Ergebnis: Platte 100 % voll, Worker-Container startete nicht mehr —
|
||||
und der Job wurde als FEHLGESCHLAGEN markiert, obwohl die komprimierte
|
||||
Datei längst fertig und in Ordnung war. Der Nutzer sah nur „nichts da".
|
||||
|
||||
Deshalb dreifach abgesichert: Platz vorher prüfen, Fehler abfangen, und im
|
||||
Zweifel die Rohdatei einfach liegen lassen, wo sie ist.
|
||||
"""
|
||||
ziel_original = os.path.join(final_dir, "original")
|
||||
try:
|
||||
# Liegt beides auf demselben Dateisystem, ist es ein reines Umhängen —
|
||||
# sofort fertig, kein zusätzlicher Platz nötig.
|
||||
gleiches_dateisystem = os.stat(raw_dir).st_dev == os.stat(final_dir).st_dev
|
||||
if not gleiches_dateisystem:
|
||||
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 — 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:
|
||||
"""Kooperativer Abbruch: hat der Nutzer über die API abgebrochen?"""
|
||||
return db.get_job_status(job_id) == "canceling"
|
||||
@@ -344,6 +425,22 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
||||
)
|
||||
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()
|
||||
ist_video = disc_type in ("dvd", "bluray", "uhd")
|
||||
transcode_an = ist_video and einstellungen.get("transcodeEnabled", True)
|
||||
@@ -382,7 +479,8 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
||||
# Geplantes Ziel sofort sichtbar machen (UI-Detail + retry-transcode)
|
||||
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
|
||||
# Platte nach 40 GB wäre der teuerste Fehlschlag (4K-UHD: bis 100 GB roh).
|
||||
@@ -411,6 +509,7 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
||||
output_dir=raw_dir,
|
||||
nur_hauptfilm=nur_hauptfilm,
|
||||
titel_liste=titel_liste,
|
||||
log_cb=melde_makemkv,
|
||||
)
|
||||
else:
|
||||
ergebnis = rip_video(
|
||||
@@ -418,26 +517,44 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
||||
progress_cb=fortschritt, output_dir=final_dir,
|
||||
nur_hauptfilm=nur_hauptfilm,
|
||||
titel_liste=titel_liste,
|
||||
log_cb=melde_makemkv,
|
||||
)
|
||||
|
||||
# UHD-Fehler in Klartext übersetzen — "Failed to open disc" allein hilft
|
||||
# niemandem. Zwei bekannte Ursachen (Befunde 24.07., Summer-Wars-UHD):
|
||||
if ergebnis.get("status") == "error" and disc_type == "uhd":
|
||||
# MakeMKV-Fehler in Klartext übersetzen — "Failed to open disc" allein
|
||||
# hilft niemandem.
|
||||
if ergebnis.get("status") == "error":
|
||||
fehler_text = ergebnis.get("error") or ""
|
||||
if "volume key is unknown" in fehler_text:
|
||||
# LibreDrive lief bereits — MakeMKV kennt nur den Disc-Schlüssel
|
||||
# nicht: Version zu alt ODER Disc neuer als die Key-Datenbank.
|
||||
# Befund 25.07.2026, auf BEIDEN Maschinen gemessen: Laufwerk und
|
||||
# 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"] += (
|
||||
" — Klartext: Das Laufwerk liest die Disc (LibreDrive OK), "
|
||||
"aber MakeMKV kennt den Schlüssel dieser Disc nicht. Erst "
|
||||
"prüfen: MakeMKV aktuell? (Einstellungen → System; Update = "
|
||||
"Image-Rebuild). Ist es aktuell, ist die Disc neuer als die "
|
||||
"Schlüssel-Datenbank — MakeMKV hat einen AACS-Dump unter "
|
||||
"/root/.MakeMKV/ im Worker gespeichert; im MakeMKV-Forum "
|
||||
"(Bereich 'Ultra HD Blu-ray') einreichen, mit einem der "
|
||||
"nächsten Updates ist die Disc dann rippbar."
|
||||
" — Klartext: Laufwerk und Rippy sind in Ordnung, MakeMKV "
|
||||
"liest die Disc. Es fehlt nur der Schlüssel dieser Pressung. "
|
||||
"Der Grund: makemkvcon holt Schlüssel unter Linux nie selbst "
|
||||
"nach — die Windows-Version schon. "
|
||||
+ (
|
||||
"Dieser Worker kennt aktuell GAR KEINEN Disc-Schlüssel. "
|
||||
if not anzahl
|
||||
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"] += (
|
||||
" — 4K-UHD erkannt: Das Laufwerk kann UHD-Discs vermutlich "
|
||||
"nicht entschlüsseln. Dafür ist eine LibreDrive-Firmware nötig "
|
||||
@@ -518,14 +635,19 @@ def transcode_files(self, job_id: str, raw_dir: str, final_dir: str):
|
||||
return ergebnis
|
||||
|
||||
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)
|
||||
|
||||
os.makedirs(final_dir, exist_ok=True)
|
||||
db.update_job(job_id, status="transcoding", progress=0, error=None)
|
||||
db.add_log(
|
||||
"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)
|
||||
@@ -558,9 +680,7 @@ def transcode_files(self, job_id: str, raw_dir: str, final_dir: str):
|
||||
return ergebnis
|
||||
|
||||
if original_behalten:
|
||||
ziel_original = os.path.join(final_dir, "original")
|
||||
shutil.move(raw_dir, ziel_original)
|
||||
db.add_log("info", "worker", f"Job {job_id}: Original behalten unter {ziel_original}")
|
||||
_original_aufheben(job_id, raw_dir, final_dir)
|
||||
else:
|
||||
shutil.rmtree(raw_dir, ignore_errors=True)
|
||||
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Tests fuer 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 haengt einiges an diesen kleinen Funktionen: erkennen wir
|
||||
die Datei falsch, meldet das UI "alles gut", waehrend MakeMKV weiter scheitert.
|
||||
|
||||
Getestet wird nur, was ohne Postgres, Redis und ohne Laufwerk laeuft — 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 zurueckbenennen.
|
||||
"""
|
||||
|
||||
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 Schluesselspeicher aus.
|
||||
|
||||
Echte Eintragsnamen aus dem Speicher der Windows-Installation vom
|
||||
25.07.2026: hkd_<8 Hex>.bin fuer 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" fuer 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 Pruefung aendert
|
||||
# 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 - Aenderungen immer in BEIDE Dateien uebernehmen."
|
||||
)
|
||||
|
||||
# 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 duerfen 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 gueltig 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():
|
||||
# Haeufigster 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 wuerde 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 enthaelt keine Schluessel, nur Prosa.\n")
|
||||
assert meldung != ""
|
||||
|
||||
|
||||
def test_keydb_pruefen_akzeptiert_gueltigen_inhalt():
|
||||
# Leerer Rueckgabewert heisst 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 haengt den Namen an das Datenverzeichnis an —
|
||||
ohne diese Pruefung koennte man sich damit aus dem Verzeichnis heraus
|
||||
lesen. Versteckte Dateien und Nicht-Dumps sind ebenfalls nichts fuer 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 ueberschrieben. Mit dem jetzt
|
||||
persistenten Datenverzeichnis waere 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,82 @@ 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("/woanders/x", mapping) == "/woanders/x"
|
||||
assert pfad_lokal("/app/temp/raw/abc", "") == "/app/temp/raw/abc"
|
||||
|
||||
|
||||
def test_original_aufheben_laesst_job_nie_scheitern(tmp_path, monkeypatch):
|
||||
"""Der Schritt "Original behalten" darf einen fertigen Job NICHT killen.
|
||||
|
||||
Schaden vom 25.07.2026 (Akira-UHD): shutil.move() ueber Dateisystemgrenzen
|
||||
kopierte 74 GB, lief in ENOSPC, und der Job galt als fehlgeschlagen —
|
||||
obwohl die komprimierte Datei laengst fertig war. Der Nutzer sah nur eine
|
||||
leere Queue.
|
||||
"""
|
||||
import tasks
|
||||
|
||||
meldungen = []
|
||||
monkeypatch.setattr(tasks.db, "add_log", lambda *a: meldungen.append(a))
|
||||
|
||||
raw = tmp_path / "raw"
|
||||
raw.mkdir()
|
||||
(raw / "title_t00.mkv").write_bytes(b"x" * 1024)
|
||||
final = tmp_path / "final"
|
||||
final.mkdir()
|
||||
|
||||
def platzt(*_a, **_k):
|
||||
raise OSError(28, "No space left on device")
|
||||
|
||||
monkeypatch.setattr(tasks.shutil, "move", platzt)
|
||||
# Darf NICHT werfen
|
||||
tasks._original_aufheben("job-1", str(raw), str(final))
|
||||
assert meldungen and meldungen[-1][0] == "warning"
|
||||
assert "Roh-Datei bleibt" in meldungen[-1][2]
|
||||
|
||||
|
||||
def test_original_aufheben_warnt_statt_platte_vollzuschreiben(tmp_path, monkeypatch):
|
||||
"""Zu wenig Platz auf einem ANDEREN Dateisystem -> gar nicht erst anfangen."""
|
||||
import tasks
|
||||
|
||||
meldungen = []
|
||||
monkeypatch.setattr(tasks.db, "add_log", lambda *a: meldungen.append(a))
|
||||
bewegt = []
|
||||
monkeypatch.setattr(tasks.shutil, "move", lambda *a: bewegt.append(a))
|
||||
|
||||
raw = tmp_path / "raw"
|
||||
raw.mkdir()
|
||||
(raw / "gross.mkv").write_bytes(b"x" * 4096)
|
||||
final = tmp_path / "final"
|
||||
final.mkdir()
|
||||
|
||||
# Verschiedene Dateisysteme vortaeuschen und den freien Platz kleinrechnen.
|
||||
# _ordner_groesse wird mitgefaelscht, weil ein selbstgebautes os.stat sonst
|
||||
# os.path.getsize mitreisst (das liest st_size an derselben Stelle).
|
||||
class Stat:
|
||||
def __init__(self, dev): self.st_dev = dev
|
||||
monkeypatch.setattr(tasks.os, "stat", lambda p: Stat(1 if "raw" in str(p) else 2))
|
||||
monkeypatch.setattr(tasks, "_ordner_groesse", lambda _p: 4096)
|
||||
monkeypatch.setattr(tasks, "_frei_bytes", lambda _p: 100)
|
||||
|
||||
tasks._original_aufheben("job-2", str(raw), str(final))
|
||||
assert bewegt == [], "es darf NICHT kopiert werden, wenn der Platz nicht reicht"
|
||||
assert meldungen[-1][0] == "warning"
|
||||
assert "NICHT aufgehoben" in meldungen[-1][2]
|
||||
|
||||
|
||||
def test_arbeitsverzeichnis_wahl_des_rips_schlaegt_die_einstellung():
|
||||
"""Pro Rip waehlbar (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 ausserhalb /app/media fallen durch
|
||||
assert tasks._arbeitsverzeichnis({}, "/etc") == tasks.RAW_DIR
|
||||
assert tasks._arbeitsverzeichnis({}, "/app/media/../etc") == tasks.RAW_DIR
|
||||
# Leere Wahl faellt sauber auf die Einstellung zurueck
|
||||
assert tasks._arbeitsverzeichnis(einst, " ") == "/app/media/movies"
|
||||
|
||||
@@ -13,6 +13,7 @@ from ripping import (
|
||||
build_makemkv_cmd,
|
||||
get_progress_from_line,
|
||||
get_progress_from_prgv,
|
||||
parse_msg,
|
||||
write_abcde_config,
|
||||
)
|
||||
|
||||
@@ -63,6 +64,60 @@ def test_prgv_parsing_ignoriert_fremde_zeilen():
|
||||
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 laeuft 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 fuer die Regex (Stand 25.07.2026): vorher stand hier
|
||||
line.split(",", 4)[3]. Das schnitt jede Meldung ab, die selbst ein Komma
|
||||
enthaelt — und MakeMKV schreibt solche laufend. Im Log stand dann nur noch
|
||||
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 ausser 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():
|
||||
"""Review-Fund 22.07.: '-o' stand doppelt (Format UND Verzeichnis) — abcde
|
||||
parste das Verzeichnis als Format, CD-Ripping war nie funktionsfähig."""
|
||||
@@ -83,3 +138,43 @@ def test_abcde_config_enthaelt_zielverzeichnis():
|
||||
assert "INTERACTIVE=n" in inhalt
|
||||
finally:
|
||||
os.unlink(pfad)
|
||||
|
||||
|
||||
def test_preset_fuer_nimmt_das_preset_des_disc_typs():
|
||||
# Befund 25.07.2026: vorher galt EIN Preset fuer alles — eine 4K-UHD wurde
|
||||
# damit auf 1080p heruntergerechnet, und beim ersten echten UHD-Rip waere
|
||||
# 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 aendern.
|
||||
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 zaehlt 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
|
||||
|
||||
@@ -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