30 Commits

Author SHA1 Message Date
Hitonabi a468e4b6a4 docs(savepoint): Commander-Feedback als Landkarte - neun Punkte, neun Staende
Ampel / ampel (push) Successful in 27s
Rueckfrage des Commanders: "Was ist mit diesen ganzen Sachen?" - er wollte
wissen, ob seine Liste aus der ersten Selbst-Einrichtung vollstaendig erfasst
ist. Nachgeprueft: alle neun Punkte kommen im v3.16-Block vor.

Einer war aber nur IMPLIZIT zu finden: dass seine RX 9070 XT wirklich
encodieren soll, musste man aus zwei anderen Punkten erschliessen (Pfad-Mapping
und Preset-Namen). Genau so faellt etwas durch.

Jetzt steht seine Liste woertlich als Tabelle im Savepoint, jede Zeile mit
Stand und Verweis auf die Prioritaetenliste. Drei erledigt, eine Frage
beantwortet, fuenf offen.

Aufgeteilt wo noetig: Punkt 3 war eigentlich drei Forderungen - Vektorbefehle
auslesen (erledigt), das Beste empfehlen (offen), und Warnung von orange auf
gruen (erledigt, wirkt fuer seinen PC ab dem Deploy).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 12:10:59 +02:00
Hitonabi 4c78914529 docs(savepoint): v3.16 Uebergabe - externes Encoden konnte nie funktionieren
Ampel / ampel (push) Successful in 27s
Diese Sitzung endete wegen vollem Kontext. Der Savepoint ist so geschrieben,
dass die naechste ohne Nachfragen weitermachen kann.

ZUSTAND, den man kennen MUSS: Repo fd1feaa mit gruener Ampel, aber die VM laeuft
noch 61c38a0 - die letzten zwei Commits sind NICHT deployt, und der
Windows-Worker des Commanders laeuft mit altem Code. Der 80-GB-Rohschnitt liegt
intakt auf der NAS, "Neu komprimieren" genuegt also, sobald der Blocker faellt.

DER KERNBEFUND aus dem fehlgeschlagenen Test-Rip, mit Messung statt Vermutung:
Das gezielte Routing funktionierte einwandfrei - sein PC nahm die Aufgabe an und
lehnte sie 182 ms spaeter ab. Drei Ursachen griffen ineinander, zwei sind
behoben, eine bleibt der Blocker (RIPPY_PATH_MAP wird von niemandem gesetzt).

Die offenen Punkte stehen nach Prioritaet, jeweils MIT dem Kontext, der zum
Weiterarbeiten fehlt: Format von pfad_lokal fuer Punkt 1, warum Punkt 2 nicht
ohne die Preset-Liste vom Worker geht (AGENTS Regel D - Namen erfinden hat das
Projekt zweimal teuer bezahlt), welcher Messpunkt sich fuer die ETA bewaehrt hat
(/proc/<pid>/fdinfo statt Fortschrittsanzeige), und welche Datei jeweils dran ist.

Dazu ein eigener Abschnitt "Fallen, die diese Sitzung gekostet haben" - deutsche
Anfuehrungszeichen in doppelt gequoteten Python-Strings (dreimal), Code in
f-String-Einsetzungen, docker exec ohne sh -c verschluckt Globs, /proc-Suche
trifft die eigene Shell, und pydantic-core springt lokal wiederholt zurueck.
Alles auch in den Memory-Notizen, damit es nicht wieder entdeckt werden muss.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 12:06:32 +02:00
Hitonabi fd1feaaee3 fix(ui): die Ablage war im UI ueberhaupt nicht einstellbar
Ampel / ampel (push) Successful in 28s
Der Grund, warum der erste Rip mit externem Encoder scheitern MUSSTE - und
Commander-Vorgabe: "Wenn ein externes Ziel eingehaengt ist, soll Rippy das
ausgewaehlte als Arbeitsziel verwenden. Das stellt man ja sowieso in den
Einstellungen ein. Das muss auch fuer den Automatik-Modus so sein."

Genau das ging nicht. outputDir stand in den Einstellungen, wurde von der
Vollautomatik (main.py) und der Schnellwahl (RipTargetModal) gelesen - aber es
gab NIRGENDS ein Eingabefeld. Die Ablage klebte auf /app/media, der
Container-Platte. Das Arbeitsverzeichnis hatte eine Auswahlliste der
eingehaengten Ziele, das Ziel nicht.

Folge im Praxistest: Rohdaten auf der NAS (die der PC erreicht), Ziel auf der
VM-Platte (die er nicht erreicht, kein Samba dort). Der externe Encoder konnte
lesen, aber nicht schreiben - und das haette man mit den vorhandenen
Bedienelementen gar nicht anders einstellen koennen.

JETZT: Ablage-Auswahl in Einstellungen -> Ripping, mit derselben Liste
eingehaengter Ziele wie das Arbeitsverzeichnis. Damit wirkt sie automatisch
auch in der Vollautomatik und in der Schnellwahl, weil beide outputDir lesen -
also genau so, wie der Commander es beschrieben hat.

DAZU die Pruefung, die den Vorfall verhindert haette: Sobald ein EXTERNER
Worker gemeldet ist, warnt das UI, wenn Ablage und Arbeitsverzeichnis nicht
beide auf einer erreichbaren Freigabe liegen. Drei Faelle, jeweils mit der
Konsequenz im Klartext:
  - Arbeitsverzeichnis auf der Container-Platte -> externer Encoder kann nicht lesen
  - Arbeit auf Freigabe, Ablage lokal -> kann lesen, nicht schreiben (der Vorfall)
  - beide auf VERSCHIEDENEN Freigaben -> laeuft, kostet aber eine Vollkopie

"Extern" ist dabei keine Heuristik ueber IPs oder Namen: caps.py meldet es
selbst (`extern`), weil /app im Rippy-Image immer existiert und ausserhalb nie.
Zusaetzlich meldet der Worker jetzt seine `pfad_map` - damit kann das UI im
naechsten Schritt sagen, ob die Uebersetzung ueberhaupt gesetzt ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 11:58:58 +02:00
Hitonabi fe12401e34 fix(worker): Hardware-Encoder und CPU-Merkmale auf Windows - plus ehrliche Transcode-Fehler
Aus dem gescheiterten Test-Rip des Commanders (Job 95afdc89) und seiner
UI-Durchsicht. Der Reihe nach.

## 1. Der externe Encoder wurde SEHR WOHL gesehen (Diagnose-Korrektur)

Der Commander schloss aus der Meldung, das System sehe seinen PC nicht. Live
gemessen sagt das Gegenteil:

    meta.transcode_node = tobisnicerpc@TobisNicerPC
    VM-Worker-Log        = nur rip_disc, KEIN transcode_files
    22:19:41.271  Kompression eingereiht
    22:19:41.453  "Keine Roh-MKVs gefunden"   <- 182 ms spaeter

Das Routing hat funktioniert. Sein PC nahm die Aufgabe an und lehnte sie sofort
ab, weil er /app/media/rippy/<job> nicht finden kann - ein Pfad, der nur INNEN
im Container existiert. Der Rip war vollstaendig (79,6 GB auf der NAS).

## 2. Der Hardware-Encoder-Bug war meiner

leite_backends_ab() verlangte zusaetzlich ein Geraet: /dev/dri fuer
VAAPI/QSV/VCE bzw. nvidia-smi fuer NVENC. Unter WINDOWS gibt es beides nicht -
der PC des Commanders (RX 9070 XT) meldete deshalb nur CPU-Encoder, obwohl
HandBrakes Windows-Build vce_*/nvenc_*/qsv_* beherrscht. Der Bug traf genau den
Anwendungsfall, fuer den externe Worker gedacht sind.

Die Pruefung war ausserdem ueberfluessig: HandBrake probiert Hardware beim
Start selbst an und listet nur Nutzbares - auf der VM belegt durch
"qsv: not available on this system" bei gleichzeitig fehlendem qsv_* in --help.
Jetzt ist HandBrakes Liste die einzige Auskunft.

Dazu nach FAMILIE unterschieden (nvenc/qsv/vce/vaapi) statt alles in "vaapi" zu
werfen - eine AMD-Karte lief unter dem Intel-Namen. Hardware-AV1 bekommt eine
eigene Kennung, weil es die beste Kombination aus Tempo und Groesse ist und
sonst unsichtbar bliebe.

## 3. Vektorbefehle unter Windows - gemessen, nicht "unbekannt"

Es gibt dort kein /proc/cpuinfo, also meldete der Windows-Worker
"Vektorbefehle unbekannt" - gerade auf der Maschine, die encodieren soll.
Jetzt ueber IsProcessorFeaturePresent (kernel32, winnt.h dokumentiert) plus
CPU-Name aus der Registry. Auf dem Commander-PC gegengeprueft:

    vorher: AMD64 Family 26 Model 68 Stepping 0  /  Vektorbefehle unbekannt
    jetzt:  AMD Ryzen 7 9700X 8-Core Processor   /  avx512f  /  16 Kerne

Damit verschwindet die AVX2-Warnung fuer diesen Worker von selbst - genau das
hatte der Commander gefordert.

## 4. Ehrliche Fehlermeldung statt "Keine Roh-MKVs gefunden"

Neues _erreichbarkeit_pruefen() unterscheidet jetzt drei Faelle statt einem:
Pfad nicht erreichbar UND kein Mapping gesetzt (nennt RIPPY_PATH_MAP samt
Beispiel), Mapping gesetzt aber greift nicht, oder Pfad einfach nicht da. Und
es prueft BEIDE Pfade - im Fall des Commanders lag die Quelle auf der
erreichbaren NAS, das ZIEL aber auf der VM-Platte ohne Samba; das waere erst
beim Schreiben nach Stunden Rechenzeit aufgefallen.

Jede Meldung sagt jetzt ausdruecklich, dass die Rohdaten NICHT verloren sind
und "Neu komprimieren" genuegt.

## 5. Persoenliche Werte raus

Vier Platzhalter trugen die Umgebung des Commanders (192.168.178.20,
TOBIS-PC, seine Jellyfin- und Rippy-IP). In einem Repo, das weitergegeben
wird, hat das nichts verloren.

Nebenbei dreimal in die eigene Falle getappt: deutsche Anfuehrungszeichen in
DOPPELT gequoteten Python-Strings beenden den String vorzeitig. Der Bestand
nutzt dafuer einfach gequotete f-Strings - jetzt auch meine.

NOCH OFFEN und wichtig: RIPPY_PATH_MAP wird von niemandem GESETZT (Installer,
UI, Compose). Ein Windows-Worker kann damit grundsaetzlich nicht transcodieren.
Das ist der naechste Schritt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 11:51:47 +02:00
Hitonabi 61c38a0d14 docs(readme): eigener Abschnitt fuer Dockge, Portainer, Arcane und Shell
Ampel / ampel (push) Successful in 28s
Commander-Plan fuer die Uebergabe: "Repo auf den Dockge-Server laden, Compose
reinhauen, Profit?!" - und genau da sitzt die Falle, in die jeder zuerst tappt.

DER KERN, der jetzt oben im Abschnitt steht: Rippy hat KEINE Registry-Images.
api, worker und ui werden aus dem Repo gebaut (build: context: .). Die
Compose-Datei allein ist deshalb wertlos - in ein leeres Verzeichnis kopiert
gibt es sofort "failed to read dockerfile". Daraus folgt fuer JEDE Oberflaeche
dieselbe Regel: das Repo muss dort liegen, wo die Oberflaeche den Stack baut.

Je Werkzeug der konkrete Weg, weil sie sich genau darin unterscheiden:

- Dockge kann das Repo NICHT selbst holen -> hinein in den Stacks-Ordner
  klonen (Standard /opt/stacks). Dann ist die Compose des Repos die des Stacks,
  und man darf den Stack gerade NICHT neu in Dockge anlegen - sonst liegt eine
  leere Compose in einem anderen Ordner.
- Portainer KANN es selbst holen -> Stacks > Add stack > Repository mit
  Git-URL. Web-Editor und Upload funktionieren nicht (kein Build-Kontext).
  Geraeteknoten dort als Stack-Umgebungsvariablen statt .env.
- Arcane: Repo auf dem Host, Deploy per Shell - der Git-Sync zieht nicht
  selbststaendig (auf dieser Installation seit Wochen die geuebte Praxis).
- Shell: unveraendert git clone + sudo ./install.sh.

Dazu die Vorab-Pruefung als gemeinsamer Einstieg: ./install.sh --nur-pruefen
braucht kein root, aendert nichts und nennt vor allem die ECHTEN
Geraeteknoten - der einzige Punkt, der zuverlaessig zuschlaegt.

RICHTIGSTELLUNG an mir selbst: Ich hatte die Mount-Propagation als Huerde
dargestellt. Auf den meisten systemd-Hosts ist / schon rshared und /srv/rippy/
media erbt das - da ist nichts zu tun. Nur wenn Docker ueber "not a shared
mount" klagt, braucht es die Shell. Steht jetzt so drin.

Stoerungstabelle von vier auf sechs Faelle: "failed to read dockerfile" und
"Worker startet nicht, /dev/sgN fehlt" ergaenzt - die beiden, die ein
Oberflaechen-Nutzer als erste sieht. Interne Verweise gegengeprueft, alle vier
loesen auf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:02:46 +02:00
Hitonabi 29444805a8 fix(build): der MakeMKV-Fallback der VM war seit Monaten tot - Kette jetzt dreistufig
Ampel / ampel (push) Successful in 28s
Beim Aufraeumen der VM-.env aufgefallen und nachgemessen (25.07.2026):

    https://www.makemkv.com/download        HTTP 200   <- Repo-Standard
    https://www.makemkv.com/download/old    HTTP 525   (Cloudflare)
    web.archive.org-Schnappschuss           HTTP 404   <- stand in der VM-.env

Die VM zeigte also auf eine KAPUTTE Adresse. Aufgefallen ist es nie, weil dort
die vendor/-Tarballs liegen und der Download-Zweig gar nicht erreicht wird.
Nimm die Tarballs weg, und jeder Bau scheitert mit 404 - waehrend die
Konfiguration gesund aussieht. Genau diese Sorte Fehler ist bei einer Uebergabe
teuer, weil sie erst beim Fremden zuschlaegt.

## Bereinigt (auf Commander-Wunsch, Sicherung liegt auf der VM)

.env.sicherung-vor-aufraeumen-20260725 angelegt, dann:
- MAKEMKV_URL_BASE raus -> es gilt der Compose-Standard, der liefert.
  Von Docker gegengeprueft: MAKEMKV_URL_BASE: https://www.makemkv.com/download
- JWT_SECRET_KEY raus -> Ueberrest der in v3.4 ausgebauten Anmeldung, wirkungslos.

## Fallbacks BEHALTEN, aber echt gemacht (Commander-Vorgabe)

Vorher war der "Fallback" eine Zeile, die man von Hand eintragen musste - und
die hier ins Leere zeigte. Jetzt eine automatische Kette:

  1. vendor/-Tarballs        (braucht kein Netz, zuverlaessigster Weg)
  2. MAKEMKV_URL_BASE
  3. MAKEMKV_URL_FALLBACK    (NEU, wird automatisch versucht wenn 2 versagt)

Stufe 3 ist ABSICHTLICH leer vorbelegt. Es gibt derzeit keine belegbare zweite
Quelle (siehe Messung oben), und eine einzutragen, die nicht liefert, waere
schlimmer als keine - genau das lag auf der VM vor. Wer eine eigene Quelle hat
(Spiegel im LAN), traegt sie ein und sie wird automatisch genutzt.

Scheitert alles, nennt die Fehlermeldung jetzt die beiden Wege, die
FUNKTIONIEREN, statt nur einen curl-Rueckgabewert zu hinterlassen.

## Geprueft, beide Zweige

- vendor-Pfad:   Bau durch, 828 MB
- Download-Pfad: im FREMDEN Klon ohne Tarballs gebaut - Bau durch, 777 MB,
  makemkvcon liegt unter /usr/local/bin/. Die leere Fallback-Stufe wird
  korrekt uebersprungen, "Erste Quelle lieferte nicht" erschien nicht.

docker compose config validiert. README und .env.example nennen die gemessenen
HTTP-Antworten, damit niemand wieder eine 404-Adresse als Fallback eintraegt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 22:50:54 +02:00
Hitonabi b0205c42c9 refactor(install): erst ALLES pruefen, dann erst aendern
Ampel / ampel (push) Successful in 28s
Commander-Einwand: "Warum macht das install script den pre-check nicht selbst
vor der Installation?" Zu Recht - es tat es nur halb.

VORHER war Pruefen und Aendern verschraenkt:
  1 Voraussetzungen  pruefen (bricht ab)
  2 Laufwerk         pruefen (warnt)
  3 Verzeichnisse    AENDERT
  4 Mount            AENDERT
  5 .env             AENDERT
  6 Bauen            AENDERT

Nur die harten Voraussetzungen liefen vorab. Verzeichnisse wurden angelegt,
bevor klar war, ob der Rest durchlaeuft - scheiterte etwas in der Mitte, blieb
ein halb eingerichteter Rechner zurueck. Und --nur-pruefen war als if-Zweig
durch FUENF Schritte gefaedelt: so etwas laeuft zwangslaeufig aus dem Ruder,
weil jede neue Aktion daran denken muesste.

JETZT zwei klar getrennte Phasen:

  Phase 1  PRUEFEN     - fuenf Pruefungen, aendert NICHTS
  Weiche               - Probleme -> Abbruch mit Liste, nichts angefasst
  Phase 2  EINRICHTEN  - Verzeichnisse, Mount, .env, bauen, starten

Es gibt damit GENAU EINEN Pruef-Pfad, den beide Modi benutzen: --nur-pruefen
heisst schlicht "nach Phase 1 aufhoeren". Die Pruefung kann nicht mehr etwas
anderes behaupten als die Installation tut.

Die Weiche unterscheidet PROBLEME (verhindern die Installation) von HINWEISEN
(halten nicht auf) und fasst am Ende beides als Liste zusammen - vorher musste
man die Ausgabe rueckwaerts lesen, um zu wissen, ob es gereicht hat.

Neu mitgeprueft, weil es jetzt an einer Stelle passt:
- Ist der Elternpfad der Verzeichnisse ueberhaupt beschreibbar? (Vorher waere
  das erst beim mkdir aufgefallen - mitten in der Aenderungsphase.)
- Freier Plattenplatz mit Schwelle 60 GB, samt Hinweis auf die Groessen
  (Blu-ray roh ~40 GB, 4K-UHD bis 100 GB).
- Ist ueberhaupt .env.example da, also stehen wir im Rippy-Repo?
- Was in die .env geschrieben WUERDE, steht jetzt in Phase 1 als Ansage.

Auf der VM gegengeprueft: aus /tmp gestartet meldet es korrekt "kein
Rippy-Repo" und bricht ab, ohne etwas anzufassen; aus dem Repo heraus laeuft
die Pruefung sauber durch ("Alles in Ordnung, keine Einschraenkungen").

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 22:42:31 +02:00
Hitonabi 35cfcbcb07 style: echte Umlaute im ganzen Projekt + Installer im Rippy-Look
Ampel / ampel (push) Successful in 27s
Commander-Rueckmeldung: Umlaute fehlen. Ausloeser war sichtbar der Installer -
"Diese Maschine uebernimmt die Video-Kompression fuer Rippy" stand woertlich im
Screenshot.

## Die .ps1-Falle war loesbar, nicht unumgehbar

Bisher galt: ausgelieferte .ps1 MUESSEN ASCII sein, weil PowerShell 5.1 sie
ohne BOM als ANSI liest. Das ist nur die halbe Wahrheit - gemessen mit echtem
powershell.exe 5.1:

    ohne BOM:  $s = "Größe: äöü"   + Unerwartetes Token -> Skript kaputt
    mit  BOM:  Groesse: aeoeue         + laeuft, Length 16 korrekt

Alle drei Skripte sind jetzt UTF-8 MIT BOM und tragen echte Umlaute; unter 5.1
gegengeprueft (BOM vorhanden, Parser fehlerfrei, Text korrekt gelesen). Der
Kopfkommentar sagt das jetzt richtig statt "ASCII-only".

## Umstellung: Text ja, Bezeichner nein

Umlaute gehoeren in Kommentare und Anzeigetexte, nicht in Funktionsnamen oder
Datenschluessel. Deshalb je Sprache das passende Werkzeug:

- Python: ueber den TOKENIZER - angefasst wurden ausschliesslich COMMENT- und
  STRING-Tokens. 156 Stellen. Code ist damit garantiert unberuehrt.
- TypeScript: nur // und /* */ Kommentare sowie JSX-Text (kann per Definition
  kein Bezeichner sein). 24 Stellen. `const waehlen`, `let laeuft`, `plaetze`,
  `GeraetInfo` sind nachweislich unversehrt.
- install.sh: Anzeigetext, aber die Shell-Funktionen (gruen/rot/gelb/titel) und
  der Schalter --nur-pruefen bleiben ASCII - das sind Schnittstellen.
- Markdown: 0 Aenderungen, die Doku hatte schon Umlaute.

ZWEI FEHLER MEINES KONVERTERS, beide von Werkzeugen gefangen:

1. In f-Strings steht in {...} CODE, kein Text. Aus f"{groesse}" wurde
   f"{größe}", waehrend die Variable groesse hiess - Ruff meldete F821
   "Undefined name". Der Konverter lagert Einsetzungen jetzt aus.
2. Ein Dict-Schluessel wurde umbenannt: die Wortliste enthaelt das PRAEFIX
   "uebersprung", der Tabu-Schutz prueft aber ganze Woerter. bericht[...] ist
   wieder ASCII - Umlaute in Datenschluesseln brechen JSON-Runden und DB-Felder.

## Installer im Rippy-Look

Statt hellgrau jetzt dieselben Toene wie das Web-UI (aus lib/design.ts
uebernommen): slate-900 Flaeche, dunkle Eingabefelder, Amber-Hauptknopf wie
"Los geht's" im Wizard. Oben ein Kopfbereich mit dem Farbverlauf
amber -> indigo -> purple und dem Disc-Symbol - in WinForms per Paint-Ereignis
gezeichnet, weil es dort keine Verlaeufe von der Stange gibt.

Geprueft, nicht gehofft: Das Fenster wurde headless in ein PNG gerendert
(DrawToBitmap) und angesehen - Verlauf, Symbol, Umlaute und Farben sitzen.
RippyWorkerSetup.exe neu gebaut (57344 -> 60416 Bytes); Umlaute und das
requireAdministrator-Manifest sind in der .exe verifiziert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 22:37:23 +02:00
Hitonabi 65edf8369e feat(worker-windows): Installation nach "Programme", Zielordner frei waehlbar
Ampel / ampel (push) Successful in 28s
Commander-Wunsch: nicht ins Benutzerprofil, sondern wie jedes andere Programm
nach C:\Program Files - und selbst waehlbar, mit diesem Standard.

## Was neu ist

- Feld "Installieren nach" mit Knopf "Aendern ..." (Ordner-Dialog). Standard
  ist [Environment]::GetFolderPath("ProgramFiles") + "Rippy Worker" - der
  ECHTE Pfad; deutsche Windows-Versionen zeigen im Explorer "Programme", der
  Pfad heisst trotzdem "Program Files".
- Der Ordner-Dialog liefert den ELTERN-Ordner, "Rippy Worker" haengt der
  Installer selbst an. Sonst koennte die Deinstallation einen fremden Ordner
  mitloeschen - sie macht Remove-Item -Recurse auf das Zielverzeichnis.
- install.ps1 bekommt -InstallDir mit demselben Standard.

## Drei Folgen, die mitbehandelt werden mussten

1. RECHTE. In C:\Program Files darf nur ein Administrator schreiben. Die .exe
   traegt jetzt ein requireAdministrator-Manifest (-requireAdmin in
   build-exe.ps1), fragt also beim Doppelklick EINMAL per UAC - danach stimmen
   die Rechte fuer alles Weitere. Verifiziert: "requireAdministrator" steckt im
   Manifest, "asInvoker" nicht mehr. Zusaetzlich prueft der Installer VOR dem
   Entpacken, ob er dort schreiben darf, und nennt bei Nein die zwei Wege
   (als Administrator starten ODER Ziel ins eigene Profil legen) - statt
   irgendwo tief "Zugriff verweigert" zu produzieren.

2. DAS LOG. tray.py schrieb worker.log NEBEN das Programm. Unter Program Files
   darf ein normaler Benutzer das nicht - der Worker waere beim Starten
   gescheitert, und zwar still. Das Log liegt jetzt in
   %LOCALAPPDATA%\Rippy Worker\worker.log, also da, wo veraenderliche Daten
   hingehoeren. Rueckfall auf das Programmverzeichnis bleibt fuer
   Installationen ins eigene Profil.

3. DER AUTOSTART. Bei einer Installation unter Programme ist es eine
   Installation FUER DIE MASCHINE - der Autostart geht deshalb in den
   Autostart-Ordner aller Benutzer (CommonStartup). Das loest zugleich ein
   Rechte-Problem: erhoeht man ueber ein FREMDES Administratorkonto, waere
   GetFolderPath("Startup") der Ordner dieses Admins, also der falsche. Bei
   einem Ziel im eigenen Profil bleibt es persoenlich. uninstall.ps1 raeumt
   beide Orte ab.

## Geprueft

- Leerzeichen im Pfad: die erzeugte .bat mit cd /d "%~dp0", PATH-Erweiterung
  und relativem Aufruf laeuft aus "C:\...\Rippy Test Ordner" korrekt durch.
- Alle drei .ps1 ASCII-rein und fehlerfrei geparst.
- RippyWorkerSetup.exe neu gebaut (52736 -> 57344 Bytes, Version 1.0.1.0).

UI: Der Copy-Paste-Befehl fuer den CLI-Weg sagt jetzt, dass PowerShell als
Administrator laufen muss, und nennt -InstallDir als Ausweg ohne Adminrechte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 22:20:50 +02:00
Hitonabi 8f208ed0da fix(worker-windows): Autostart brach die ganze Installation ab
Ampel / ampel (push) Successful in 28s
Beim Commander live aufgetreten: Der Installer meldete
"FEHLER: FEHLER: Zugriff verweigert" samt dem Tipp, die IP koenne falsch sein -
und das, NACHDEM Worker-Code, Abhaengigkeiten und HandBrake schon fertig waren.
Die IP war also nie das Problem.

URSACHE: schtasks /create mit /tn "RippyWorker" legt die Aufgabe im
WURZELORDNER der Aufgabenplanung an, und das verlangt Administratorrechte. Der
Installer laeuft normal ohne. schtasks schrieb "FEHLER: Zugriff verweigert."
nach stderr, und weil im Skript $ErrorActionPreference = "Stop" steht, machte
PowerShell daraus einen TERMINIERENDEN Fehler - der catch-Block riss damit die
komplette Installation ab, obwohl nur der Autostart fehlte. Das doppelte
"FEHLER: FEHLER:" war der Hinweis: der aeussere Handler setzt sein Praefix vor
eine Meldung, die selbst schon mit "FEHLER:" begann, also aus schtasks kam.

FIX, zwei Teile:

1. Autostart ueber den Autostart-ORDNER statt schtasks. Eine Verknuepfung in
   [Environment]::GetFolderPath("Startup") braucht NIE Adminrechte - und der
   Nutzer kann sie dort selbst sehen und loeschen. Auf diesem Windows-PC
   gegengeprueft: Verknuepfung angelegt (1047 Bytes) mit Admin=False.
2. Ein Fehlschlag beim Autostart ist jetzt eine WARNUNG in eigenem try/catch,
   kein Abbruch. Der Worker ist zu dem Zeitpunkt fertig und startbar, und der
   Text sagt das auch - plus den Handweg (shell:startup).

In BEIDEN Installern (install-gui.ps1 und install.ps1, dort mit -Autostart) -
der CLI-Weg hatte denselben Fehler. uninstall.ps1 raeumt jetzt die
Verknuepfung weg UND versucht weiter schtasks /delete, damit aeltere
Installationen sauber verschwinden.

RippyWorkerSetup.exe neu gebaut (50688 -> 52736 Bytes): die GUI ist in der .exe
eingebettet, ohne Rebuild wirkt der Fix beim Nutzer nicht.

Nebenbei: build-exe.ps1 hatte zwei Gedankenstriche. Ausgelieferte .ps1 muessen
reines ASCII sein (PowerShell 5.1 liest sie als ANSI) - jetzt sind alle drei
Skripte ASCII-rein und parsen fehlerfrei.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 22:11:39 +02:00
Hitonabi b76ca5d9be docs(savepoint): install.sh und README-Umbau nachgetragen
Ampel / ampel (push) Successful in 28s
Der Block dokumentiert jetzt auch die letzte Runde: ein Befehl statt
Checkliste, README neu aufgebaut, VM-CPU-Anleitung drin.

Ehrlich als UNGETESTET markiert (neuer Punkt 6): install.sh ist nie als root
durchgelaufen. Auf dieser VM sind alle root-Schritte No-Ops - Verzeichnisse
liegen da, die Propagation ist schon shared - und sudo verlangt hier ein
Passwort. Bewiesen sind Laufwerkserkennung (SCSI-Abgleich gegen die echte
Hardware), die .env-Logik in drei Faellen und die Syntax. Das Anlegen der
Verzeichnisse, mount --make-rshared und die systemd-Unit laufen erst bei einer
echten Neuinstallation - das gehoert in die Uebergabe statt in eine
Erfolgsmeldung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 22:01:06 +02:00
Hitonabi ca91e5b693 feat(install): ein Befehl statt Checkliste - install.sh nimmt die Handarbeit ab
Ampel / ampel (push) Successful in 28s
Commander-Rueckmeldung: "Das Docker Deployment ist mir zu kompliziert." Zu
Recht - die Installation war eine Checkliste aus sechs Schritten, von denen
zwei nur mit Fachwissen zu schaffen waren.

## Neu: sudo ./install.sh

Nimmt genau die Schritte ab, an denen man scheitern konnte:

1. Prueft Voraussetzungen (Linux, Docker, Compose) und nennt bei jedem Mangel
   den Installationsbefehl.
2. FINDET DAS LAUFWERK SELBST. Das war der schlimmste Punkt: MakeMKV braucht
   ZWEI Geraeteknoten, und die sg-Nummer ist je Host anders. Das Skript gleicht
   sie ueber die SCSI-Adresse in /sys ab statt zu raten. Auf der VM
   gegengeprueft: sr0 -> 3:0:0:0, sg1 -> 3:0:0:0, also dasselbe Geraet -
   korrekt als /dev/sr0 + /dev/sg1 erkannt.
3. Legt Ablage- und MakeMKV-Verzeichnis an.
4. Richtet die Mount-Propagation ein UND macht sie per systemd-.mount-Unit
   neustart-fest. Vorher stand in der README nur "reboot-fest persistieren" -
   ohne zu sagen wie; nach einem Reboot scheiterte das Einhaengen von
   NAS-Freigaben aus dem UI stillschweigend.
5. Schreibt die .env, ueberschreibt aber NIE einen bestehenden Wert.
6. Baut und startet, und nennt bei Fehlschlag die drei haeufigsten Ursachen
   mit Diagnosebefehl.

Wiederholbar (mehrfach ausfuehren aendert nichts kaputt) und damit auch der
Update-Weg: git pull && sudo ./install.sh

## Zwei Fallgruben, die beim Testen auffielen - beide meine eigenen

- --nur-pruefen verlangte root und brach ab. Ein Pruef-Modus, der nichts
  aendert, darf daran nicht scheitern - sonst kann man vor der Installation
  nicht nachsehen, ob alles passt. Behoben.
- .env.example hatte OPTICAL_SG=/dev/sg1 UNKOMMENTIERT vorbelegt. Damit haette
  der Installer den erkannten Wert nicht eingetragen ("steht schon drin") und
  auf jedem fremden Host still eine kaputte Konfiguration hinterlassen - genau
  das, was er verhindern soll. Beide Geraetezeilen sind jetzt auskommentiert;
  Compose hat ohnehin Vorgaben. Dazu eine Gegenprobe im Skript: zeigt ein
  wirksamer Wert auf ein Geraet, das es hier nicht gibt (".env von einem
  anderen Rechner"), wird das benannt statt kryptisch von Docker gemeldet.

## README neu aufgebaut

Vorher 293 Zeilen, in denen der Schnellstart zwischen lsscsi, sg-Knoten,
USB-Passthrough und mount --make-rshared begraben war. Jetzt: Installation in
zwei Zeilen oben, dann eine Tabelle "Wenn etwas nicht geht" mit den vier
Faellen, die praktisch alles abdecken. Alles Technische steht darunter in
aufklappbaren Abschnitten - inklusive der Handarbeits-Variante fuer die, die
sie wollen.

NEU und ausdruecklich gewuenscht: Abschnitt "Rippy schneller machen" mit dem
VM-CPU-Typ. Erklaert, warum Virtualisierer eine generische CPU ohne AVX2
geben, was das kostet (gemessene 28-55 h je 4K-Film), die genauen Schritte in
Proxmox (herunterfahren - Hardware/Processors/Type auf 'host' - starten, bzw.
qm set <vmid> --cpu host), warum ein Neustart von innen NICHT genuegt, und wie
man nachprueft: Rippy zeigt die Vektorbefehle seit v3.14 selbst an. Dazu der
Nachteil (keine Live-Migration auf andere CPUs) und die Alternative x86-64-v3
fuer Cluster.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:59:23 +02:00
Hitonabi 1eb1c91dd8 docs(savepoint): v3.15 - Aufraeum-Runde mit gemessenen Zahlen
Ampel / ampel (push) Successful in 28s
Fuenf Vorgaben, was daraus wurde:

Codebase sauber   -> vier tote Routen + zwei Module weg (main.py 1726 -> 1682
                     Zeilen), Tests halten sie draussen
UI schnell        -> /capabilities 1,010 s -> 0,003 s; Dashboard-Aufbau
                     1,03 s -> 0,028 s. Alle 15 Ladeendpunkte unter 10 ms
UI selbsterklaerend -> Wizard-Kasten "Was Rippy gerade sieht" mit
                     Handlungsanweisung je Problem, sichtbare Keys mit Pruefung
Idiotensicher     -> Wizard empfiehlt nach GEMESSENER CPU statt H.265 blind;
                     4K-Falle ist damit zu
Externe Worker    -> auf Windows gegengeprueft (Kerne/Modell korrekt, encoders
                     ohne HandBrake korrekt leer, SIMD ehrlich "unbekannt")

Dazu die offene 4K-Frage aus v3.14 beantwortet: Kompression ist je Disc-Typ
abwaehlbar, 4K kann verlustfrei bleiben waehrend DVD/Blu-ray weiter schrumpfen.

ARM-Vergleich drin: fast alles, was ARM automatisch macht, macht Rippy schon -
und meist gruendlicher. Zwei echte Luecken benannt und NICHT gebaut
(ISO-Sicherung fuer Datentraeger, mehrere Laufwerke gleichzeitig), weil das
Funktionen sind und keine Reparatur. Ehrlich dabei: ob Rippy parallel rippen
kann, ist unbewiesen - mit einem Laufwerk nicht testbar.

Offene Punkte stehen unter "NOCH OFFEN", darunter der VM-CPU-Typ (qemu64 statt
host - AVX2 waere ein VM-Neustart und 2-4x schneller).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:51:23 +02:00
Hitonabi 6a17af2118 feat(transcode,ui): Kompression je Disc-Typ abwaehlbar + Wizard rechnet mit der CPU
Ampel / ampel (push) Successful in 28s
Loest die offene Frage aus dem Savepoint ("UHD gar nicht komprimieren?") und
nimmt dem Wizard die Falle, in die er bisher fuehrte.

## Kompression je Disc-Typ abwaehlbar

Bisher gab es nur den globalen Schalter transcodeEnabled: alles komprimieren
oder nichts. Wer 4K verlustfrei behalten und DVDs trotzdem schrumpfen wollte,
hatte keine Moeglichkeit - obwohl genau das die vernuenftige Einstellung fuer
diese Maschine ist (4K-HEVC = gemessene 28-55 h je Film ohne AVX2).

Jetzt kann das Preset eines Disc-Typs auf den Reservewert "keine" stehen, dann
bleibt die verlustfreie Datei aus dem Rip stehen. Neue reine Funktion
komprimieren_fuer(disc_type, einstellungen); der globale Schalter schlaegt
weiter alles. preset_fuer() ueberspringt den Reservewert bewusst und gibt ihn
NIE als Preset-Namen zurueck - sonst bekaeme HandBrake `--preset keine` und
wuerde scheitern. Wer ueber "Neu komprimieren" ausdruecklich doch komprimieren
will, bekommt so ein brauchbares Preset statt eines Fehlers.

Der Reservewert kollidiert mit keinem echten Namen: gegengeprueft gegen alle
90 Presets aus `HandBrakeCLI --preset-list` im Worker-Image. Bei der
Gelegenheit auch die fuenf im UI angebotenen Namen geprueft - alle echt.

## Der Wizard empfiehlt nach GEMESSENER Rechenleistung

Vorher stand H.265 als Standard drin. Auf einer CPU ohne AVX2 sind das ein bis
zwei Tage pro 4K-Film - genau der Lauf, der am 25.07.2026 abgebrochen werden
musste. Der Wizard hatte die Zahlen sogar schon vorliegen (/capabilities
meldet cpu_simd und cpu_kerne), nur benutzt hat er sie nicht.

Jetzt: schwache CPU -> 4K wird nicht komprimiert, Blu-ray/DVD gehen auf H.264
(schneller als H.265). Starke CPU oder Hardware-Encoder -> H.265 durchgehend.
Der Wizard schreibt dabei ALLE vier Preset-Felder, nicht nur das allgemeine -
vorher fiel 4K auf ein 1080p-Preset zurueck und die Aufloesung war weg.

Gewarnt wird nur, wenn es belegt ist: schwacheEncoderCpu() verlangt mindestens
einen Worker mit BEKANNTER SIMD-Stufe und keinen mit Hardware-Encoder. Ein
Windows-Worker meldet "unbekannt" (dort gibt es kein /proc/cpuinfo) - dann wird
geschwiegen statt falsch gewarnt. Auf Windows gegengeprueft: 16 Kerne und
CPU-Modell kommen korrekt durch, encoders ist ohne HandBrake leer.

## Weitere Wizard-Haerten

- Kasten "Was Rippy gerade sieht": Laufwerk, Worker (mit Kernen/SIMD), freier
  Platz. Jede Zeile hat bei Problemen eine HANDLUNGSANWEISUNG statt nur eines
  Kreuzes - kein Laufwerk, kein Worker und wenig Platz sind die drei Faelle, in
  denen man vorher ratlos dastand.
- Laedt alle drei Quellen parallel (Promise.allSettled) und wiederholt im
  5-s-Takt: beim ersten Start laeuft der Worker noch hoch, vorher stand dort
  dauerhaft "Noch kein Worker gemeldet" ohne Aussicht.
- API-Keys sind SICHTBAR statt als Punkte: das sind kopierte Keys, keine
  Passwoerter, und einen Tippfehler sieht man in Punkten nicht.
- Keys werden direkt nach dem Speichern geprueft (/metadata/status). Ein
  falsch kopierter Key faellt sofort auf, statt erst beim ersten Rip als
  "Unknown Disc" - mit der Wahl "Key korrigieren" oder "Trotzdem fertigstellen".

## Einstellungen -> Verarbeitung

Alle drei Preset-Auswahlen bekommen "Nicht komprimieren", und beim 4K-Feld
erscheint die AVX2-Warnung mit der gemessenen Zahl - aber nur, wenn die
Maschine sie wirklich braucht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:48:31 +02:00
Hitonabi 11597eb00c perf+cleanup(api): /capabilities war 1,0 s; vier tote Endpunkte entfernt
Ampel / ampel (push) Successful in 28s
Beides in main.py, deshalb ein Commit.

## 1. Das "Laggen" hatte genau eine Ursache

Gemessen ueber alle 15 Endpunkte, die das UI beim Laden braucht:

    /capabilities      1,010 s
    /system/updates    0,491 s   (haengt am Knopf, nicht am Seitenaufbau)
    /metadata/status   0,412 s   (dito)
    die anderen 12   < 0,025 s

/capabilities ist der einzige langsame, der beim SEITENAUFBAU zuschlaegt - und
fuenf Stellen holen ihn (Dashboard, Einstellungen, Worker-Tab, Wizard,
Rip-Dialog). Jede Seite zahlte eine Sekunde.

Die Ursache ist kein Fehler, sondern das Wesen des Celery-Pings: er sammelt
Antworten bis zum Timeout und kann nicht frueher aufhoeren, weil er nicht
weiss, wie viele Worker noch antworten wollen. Den Timeout zu kuerzen wuerde
Antworten langsamer Remote-Worker verschlucken - also genau die Maschinen, um
die es beim externen Encoding geht.

Jetzt pingt eine Hintergrund-Schleife im 5-s-Takt (neben Disc-Watcher und
Key-Refresh, die es dort schon gibt), der Endpunkt liest nur ab. Vorrat aelter
als 30 s - Schleife noch nicht angelaufen oder gestorben - dann EINMAL synchron
pingen: lieber langsam als falsch ("alles offline", obwohl alles laeuft).

## 2. Vier tote Endpunkte raus

Jeder ein Ueberrest eines ersetzten Entwurfs, keiner mit Aufrufer (mechanisch
gegengeprueft: alle api.*-Aufrufe des UI gegen alle Routen):

  POST /prescan                  Metadaten-Vorschau-Seite ist seit v3.4 weg.
                                 Die PreScan-Klasse bleibt - sie hat 5 echte
                                 Fundstellen, der Watcher ruft sie im Prozess.
  POST /jellyfin/format          Macht seit v3.2 der Worker (medien.py), und
                                 zwar an der richtigen Stelle: er kennt den
                                 Ausgabeordner und ist nach dem Rip am Zug.
                                 Mit ihm fallen nfo_generator.py und
                                 image_downloader.py weg (sonst unbenutzt).
  GET  /stream/jobs              Der unangenehmste: erst Placebo, am 23.07.
                                 "repariert" statt entfernt - aber ein
                                 EventSource im UI gab es nie (das Dashboard
                                 nutzt setInterval(..., 4000)). Also keine
                                 harmlose Leiche, sondern eine Endlosschleife
                                 je Verbindung, die jeder aufmachen konnte.
  GET  /worker-setup/windows-gui Ohne Aufrufer seit die .exe den .bat-Umweg
                                 ersetzt hat (v3.9). install-gui.ps1 selbst
                                 lebt weiter, sie steckt in der .exe.

main.py: 1726 -> 1682 Zeilen, dazu 279 Zeilen in zwei geloeschten Modulen.

Tests halten beide Seiten fest: die vier Routen muessen WEG bleiben, und die
drei, an denen die Worker-Installation haengt (/worker-setup/paket, /windows,
/windows-exe), muessen DA sein. Ausserdem eine Doppelung entfernt - mein
eigener _sicherer_dateiname-Test aus dem Vorcommit pruefte dasselbe wie der
bestehende test_dateiname_validierung_blockt_pfad_tricks, und der war die
ganze Zeit korrekt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:41:48 +02:00
Hitonabi b546c79b9e docs(savepoint): deployt und aufgeraeumt - Stand 19:30 mit Belegen
Ampel / ampel (push) Successful in 28s
Der Block behauptete "nichts ist deployt" und fuehrte Fragment wie
Rohschnitt als offene Punkte. Beides ueberholt - genau solche stehen
gebliebenen Zusagen waren das Thema dieser Sitzung.

Nachgezogen mit Belegen statt Behauptungen:
- a1aabd5 laeuft, alle 5 Container up
- GET /capabilities antwortet cpu-x264/cpu-x265/cpu-av1 - KEIN Phantom-vaapi
  mehr, dazu QEMU Virtual CPU 2.5+, 4 Kerne, sse4_2 (AVX2-Warnung greift)
- Zombie-Erkennung 19:26:09, exakt 120 s nach Start, geprueft=0
- 604 Disc-Schluessel haben den Rebuild ueberlebt
- Fragment (67.633.152 B) und der verwaiste 75-GB-Rohschnitt geloescht,
  Platte 37 GB -> 111 GB frei

Neuer Befund dabei, nicht gebaut: "Job aus der Liste entfernen" loescht
bewusst keine Dateien - richtig so, macht die Rohdaten aber unerreichbar,
weil beides nur ueber die Job-ID verbunden ist. Der Rohschnitt war danach im
UI unsichtbar und "Neu komprimieren" unmoeglich. Das UI sollte beim Loeschen
sagen, wie viel daneben liegen bleibt.

Bei den vier toten Endpunkten steht jetzt, WARUM sie tot sind (jeder ein
Ueberrest eines ersetzten Entwurfs) und dass /stream/jobs der unangenehmste
ist: keine harmlose Leiche, sondern eine Endlosschleife je Verbindung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:29:56 +02:00
Hitonabi a1aabd591d fix(test): Ampel rot - der Backslash-Test prueft nichts
Ampel / ampel (push) Successful in 28s
Meine eigene Zeile aus dem vorigen Commit. Im Quelltext stand "a\b.mkv" mit
EINEM Backslash - Python liest \b als Backspace-Zeichen, der String enthaelt
also gar keinen Backslash, und _sicherer_dateiname gab korrekt True zurueck.
Der Test behauptete, den Backslash-Pfad zu pruefen, und tat es nicht.

Jetzt ein Raw-String r"a\b.mkv".

Warum das lokal nicht auffiel: test_api_smoke.py ueberspringt sich unter
Windows selbst (main.py -> detection.py -> fcntl). Genau die Luecke, die im
Savepoint als offener Punkt steht - hier hat sie sofort zugeschlagen. Lehre:
Tests, die nur in der Ampel laufen, sind erst nach dem Push bewiesen, und
Backslashes gehoeren in Raw-Strings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:12:39 +02:00
Hitonabi 8394de6926 fix(transcode): "Abbrechen" wirkt sofort statt erst beim naechsten Prozent
Ampel / ampel (push) Failing after 28s
Fund des Commanders am laufenden Akira-Job (Nachtrag im vorigen Savepoint):
Job stand auf 'canceling', HandBrake lief weiter. Gemessen 3,4 Minuten
zwischen Anforderung (18:30:17) und Bestaetigung (18:33:38) - bei langsamerem
Fortschritt entsprechend mehr.

Ursache genau wie dort beschrieben: Der Abbruch wurde nur in
datei_fortschritt geprueft, und diese Closure stieg oben sofort wieder aus,
wenn sich die Prozentzahl nicht geaendert hatte ("if gesamt == letzter[0]:
return"). Bei einem Prozent je halber Stunde hing der Abbruch also an einem
Ereignis, das eine halbe Stunde lang nicht eintrat.

run_handbrake bekommt jetzt einen eigenen abbruch_cb neben progress_cb -
dasselbe Muster, das run_makemkv schon fuer log_cb benutzt, und aus
demselben Grund: der Fortschritts-Kanal verwirft Aufrufe. Er wird bei JEDER
Ausgabezeile aufgerufen und im Worker auf 5 s gedrosselt.

Nebeneffekt, der genauso wichtig ist: Er greift auch waehrend des
Scan-Durchlaufs, der ueberhaupt keine Encode-Prozente liefert. Dort war ein
Abbruch vorher grundsaetzlich unmoeglich - und seit die Prozent-Regex den
Scan korrekt ignoriert, waere das sonst sogar schlimmer geworden.

Die Leseschleife ist als _handbrake_schleife() herausgezogen, damit die
Reihenfolge (Abbruch VOR Fortschritt) ohne echtes HandBrake pruefbar ist.
Der Test belegt: zwei Scan-Zeilen genuegen fuer den Abbruch, es muss NICHT
auf eine Encode-Zeile gewartet werden.

Savepoint nachgezogen: Der Encode laeuft nicht mehr, ein Deploy ist
gefahrlos moeglich, und die alte "SOFORT ENTSCHEIDEN"-Passage (Platte
laeuft voll, wenn der Job fertig wird) ist damit gegenstandslos. Neu darin:
die drei Wege fuer UHD, und die Warnung, dass eine /proc-Suche nach
"HandBrake" die eigene Shell mittrifft - zwei Fehlalarme in dieser Sitzung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:10:03 +02:00
Hitonabi 574354131c docs(savepoint): v3.14 - Durchsicht, mit den zwei eigenen Fehlschluessen
SAVEPOINT bekommt den Stand v3.14: der unwirksame Platten-Schutz zuerst
(wichtigster Fund), dann die gemessenen Werte, das Gebaute, die vier toten
Endpunkte als Entscheidungsvorlage - und ein Abschnitt "SOFORT ENTSCHEIDEN".

Der laufende Akira-Encode wird beim Fertigwerden mit dem DEPLOYTEN, alten
Code versuchen, 75 GB in 37 GB zu kopieren. keepOriginal steht auf true, und
den Wert hat die Aufgabe beim Start gelesen - jetzt umstellen aendert daran
nichts mehr. Der Savepoint nennt die drei Optionen mit Empfehlung.

Zwei Richtigstellungen an v3.13, beide durch Messung:
- "progress=99 ist ein Altwert aus dem Absturz" war falsch. Beide Startpfade
  setzen auf 0; der Wert kam frisch vom Scan-Durchlauf. Ein Bug, kein Ueberrest.
- Die Erwartung, _original_aufheben werde nur warnen, war falsch - siehe
  EXDEV-Fund im vorigen Commit.

Ehrlich als OFFEN markiert: Was die Platte am 25.07. mittags fuellte, ist
NICHT belegt. Es gibt keine Kopier-Reste, kein original-Verzeichnis und
keinen einzigen Log-Eintrag von _original_aufheben, das in beiden Zweigen
loggt. Der Mechanismus ist jetzt bewiesen, sein Zuschlagen an jenem Tag
nicht. Lieber offen lassen als eine dritte Vermutung aufstellen.

AGENTS bekommt Etappe 19 und einen neuen Abschnitt, der das wiederkehrende
Muster benennt: nicht aus einem Zustandswert auf einen Mechanismus
schliessen - vier belegte Faelle. Plus die Umkehrung, die diese Sitzung
gekostet hat: gleiches st_dev heisst NICHT gleicher Mount. Was ausprobierbar
ist, wird ausprobiert statt vorhergesagt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:05:38 +02:00
Hitonabi ef0a574a70 fix(worker,api): Platten-Schutz griff nicht, Fortschritt log, Auswurf tat nichts
Vier Funde aus der Durchsicht, alle auf der VM gemessen.

1. DER PLATTEN-SCHUTZ AUS c065967 WAR WIRKUNGSLOS

_original_aufheben() entschied per os.stat().st_dev, ob umgehaengt oder
kopiert werden muss. Im Worker-Container gemessen - beides gleichzeitig wahr:

    st_dev /app/temp  = 2050
    st_dev /app/media = 2050        → identisch
    os.rename(...)    → EXDEV, "Invalid cross-device link"

Der Kernel vergleicht bei rename() den MOUNT, nicht das Geraet. /app/temp
(Docker-Volume) und /app/media (Bind-Mount) sind zwei Mounts DERSELBEN
ext4-Partition. Die Pruefung sah "gleiches Dateisystem", uebersprang die
Platzpruefung, und shutil.move kopierte doch - 75 GB bei 37 GB frei. Der
Schutz haette genau den Schaden zugelassen, gegen den er gebaut wurde.

Jetzt wird os.rename VERSUCHT statt vorhergesagt: klappt es, ist es
umgehaengt und fertig; kommt EXDEV, steht die Kopie fest und ERST DANN wird
der Platz geprueft. Das ist keine Vermutung mehr, sondern die Antwort des
Kernels. Vier Tests in test_original_aufheben.py, darunter genau der Fall,
der die Platte fuellte. Die zwei alten Tests in test_medien.py sind dorthin
gewandert - sie taeuschten per gefaelschtem os.stat "verschiedene
Dateisysteme" vor, also genau die Annahme, an der der Schutz scheiterte.

2. DIE FORTSCHRITTSANZEIGE ZEIGTE DEN SCAN, NICHT DEN ENCODE

get_progress_from_line matchte jede Zahl vor einem Prozentzeichen. HandBrake
gibt Prozente aber in drei Phasen aus (Formatstrings aus dem Binary gelesen):

    Scanning title %d of %d, preview %d, %.2f %%          → laeuft VOR dem
                                                            Encode bis 100 %
    Encoding: task %d of %d, %.2f %%       (%.2f fps, avg  → der echte Wert
    Encoding: task %d of %d, Searching for start time, ... → Vorlauf

Dazu warf `if progress > 0` im Aufrufer jeden Wert unter 1,00 % weg. Live
beobachtet: Anzeige stand auf 99 %, der Encode bei 1,06 %; sie fiel erst auf
1, als der Encode die 1-%-Marke ueberschritt. Jetzt wird nur die
Encoding-Zeile gelesen, `task N of M` mitgerechnet (sonst springt die
Anzeige bei Zwei-Pass-Presets mitten in der Datei zurueck), und -1 heisst
"keine Angabe" - dasselbe Muster wie bei get_progress_from_prgv.

3. "AUTOMATISCHER AUSWURF" WURDE VON NIEMANDEM GELESEN

Die Einstellung (Standard: ein, "Disc nach erfolgreichem Ripping automatisch
auswerfen") kam in keiner Zeile Backend-Code vor. DVD/Blu-ray warfen deshalb
NIE aus, Audio-CDs IMMER, weil abcde `-x` fest verdrahtet bekam. Jetzt
entscheidet die Einstellung beides: wirf_disc_aus() per CDROMEJECT-ioctl
(fcntl-guarded, der native Windows-Worker laedt das Modul auch) und `-x` nur
noch, wenn gewuenscht.

4. PFAD-PRUEFUNG FIEL AUF PRAEFIX-NAMEN HEREIN

Elf Stellen prueften mit nacktem startswith(MEDIA_ROOT). "/app/media-boese/x"
beginnt mit "/app/media", liegt aber ausserhalb - betroffen waren auch
/browse und /browse/mkdir, wo der Pfad vom Nutzer kommt. Neuer
Zwillings-Helfer unter_wurzel() in api/main.py und worker/tasks.py, alle elf
Stellen umgestellt, Tests in beiden.

Nebenbefund: _zielbasis() benutzte os.path.normpath - unter Windows werden
daraus Backslashes, die MEDIA_ROOT-Pruefung greift nicht mehr, und das
gewaehlte Ziel faellt still auf den Standard zurueck. Genau die Falle, die
_arbeitsverzeichnis() drei Zeilen weiter dokumentiert und mit posixpath
vermeidet. Live war es nie (nur aus rip_disc, das auf Windows verriegelt
ist), jetzt konsistent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:05:11 +02:00
Hitonabi 883c1c290b fix(caps,ui): Encoder werden gemessen statt behauptet
Der Modulkopf von caps.py verspricht "ehrlich erkannt, nicht behauptet" -
erkenne_encoder() tat aber drei Mal das Gegenteil:

1. cpu-x264 und cpu-x265 standen fest verdrahtet in der Liste ("immer
   dabei"). Ein Rip-Worker OHNE HandBrake behauptete damit, komprimieren zu
   koennen; jeder Transcode dort endete sofort mit "nicht installiert".
2. 'vaapi' wurde allein wegen /dev/dri gemeldet, ohne zu pruefen, ob
   HandBrake das ueberhaupt kann. Auf der VM gemessen: das Worker-Image
   kennt svt_av1/x264/x265/mpeg4/mpeg2/VP8/VP9/theora und KEINEN einzigen
   Hardware-Encoder. Rippy haette VAAPI versprochen und dann versagt.
3. Ueber die Rechenleistung sagte es gar nichts. Genau deshalb war nicht zu
   sehen, dass ein 4K-Encode auf dieser Maschine Tage braucht: CPU-Modell
   ist das generische "QEMU Virtual CPU version 2.5+", grep -c avx2
   /proc/cpuinfo = 0, nur bis sse4_2. x265 lebt von AVX2. Gemessen an der
   Leseposition des Quellstroms: 28-55 h fuer einen Film, 3,84 von 4 Kernen
   gesaettigt. Abhilfe waere der CPU-Typ 'host' in Proxmox.

Jetzt wird die Encoder-Liste aus `HandBrakeCLI --help` geparst (Abschnitt
-e/--encoder, Formatstrings im Worker-Image gemessen - AGENTS Regel D), und
Hardware zaehlt nur, wenn Geraet UND HandBrake-Unterstuetzung da sind. Neu
gemeldet: cpu-av1 (svt_av1 ist wirklich verfuegbar) sowie CPU-Modell,
Kernzahl und Vektorbefehlsstufe je Worker.

UI: Kerne/Vektorbefehle stehen bei jedem Worker (Worker-Tab und
Einstellungen -> System), dazu die ungefilterte HandBrake-Auskunft und eine
sichtbare Warnung, wenn AVX2 fehlt - inklusive des Hinweises auf den
VM-CPU-Typ. Der Warntext liegt in lib/encoder.ts, damit er nicht doppelt
im Code steht.

Mit im gleichen Zug (dieselbe Datei): der Schalter "Alle Tracks rippen" ist
entfallen. Er war ein Placebo - abcde bekommt keine Track-Auswahl und es gab
auch keine Oberflaeche, um eine Teilmenge zu waehlen. Eine Audio-CD wurde
also immer vollstaendig gerippt, egal wie der Schalter stand. An seiner
Stelle steht jetzt die Wahrheit.

7 Tests, Testdaten sind echte HandBrake-Ausgaben von der VM. Der Parser ist
zusaetzlich gegen die vollen 697 Zeilen der echten --help gegengeprueft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:04:42 +02:00
Hitonabi e0cb7b3ddc feat(worker): Zombie-Erkennung - Jobs, an denen niemand arbeitet
Nach einem Absturz oder Rebuild blieb ein Job auf 'transcoding' stehen,
obwohl weder ein Prozess lief noch etwas in den Queues stand. Folge: keine
Anzeige, kein Download - und der Knopf "Neu komprimieren" fehlte, weil
_kann_neu_komprimieren (api/main.py) status=='failed' verlangt. Der Job war
unerreichbar, obwohl die Rohdateien vollstaendig dalagen.

zombies.py haelt beim Worker-Start die Jobs in 'ripping'/'transcoding'/
'canceling' gegen Celerys active/reserved/scheduled und setzt sie ehrlich
auf 'failed', wenn niemand daran arbeitet.

Drei Sicherungen, weil ein falsch getoeteter Job teurer ist als eine
stehengebliebene Leiche:
- nur beim Start (da ist "es lief nichts" eindeutig; ein periodischer Lauf
  koennte einen Job erwischen, der legitim in der Warteschlange wartet)
- 120 s Gnadenfrist (Celery stellt unbestaetigte Aufgaben erneut zu)
- Vollzaehligkeit: antworten weniger Knoten als laut Herzschlag online sind,
  wird NICHTS gewertet - sonst waere der laufende Job eines beschaeftigten
  Remote-Workers eine falsche Leiche

Die Job-ID wird per Textsuche ueber die Inspektions-Antwort gefunden, nicht
per Position: sie steht bei rip_disc an zweiter, bei transcode_files an
erster Stelle, und Celery liefert args je nach Version als Liste oder Text.

13 Tests ohne Postgres/Redis, darunter "laufender Job wird nicht angetastet"
und "schweigender Worker verhindert jedes Urteil".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:04:19 +02:00
Hitonabi 5cd1876c90 docs(savepoint): Nachtrag - 4K-Transcode abgebrochen, zwei neue Befunde
Ampel / ampel (push) Successful in 48s
Der im Uebergabestand als "laeuft" beschriebene 4K-Lauf wurde um 18:31 vom
Commander abgebrochen. Damit war die Aussage im Block darueber ueberholt -
nachgezogen, statt sie stehen zu lassen.

Zwei Befunde, beide gemessen:

1. 4K-HEVC ist auf dieser CPU nicht machbar: 18:02 bis 18:31 ergab 1 Prozent,
   hochgerechnet rund 50 Stunden fuer den Film. Zu entscheiden: UHD gar nicht
   komprimieren (Roh-MKV behalten), Hardware-Encoder (Remote-Worker mit GPU),
   oder bewusst bei 1080p bleiben. Preset-je-Disc-Typ ist richtig, reicht
   allein aber nicht.

2. Abbruch wirkt verzoegert (FEHLER, nicht behoben): Job steht seit 18:31 auf
   "canceling", HandBrake lief um 18:35 immer noch. Der Abbruch wird nur in
   datei_fortschritt geprueft, und die Closure laeuft nur bei geaenderter
   Prozentzahl ("if gesamt == letzter[0]: return"). Bei 1 Prozent alle 30
   Minuten sieht "Abbrechen" bis zu eine halbe Stunde lang wirkungslos aus.
   Der Abbruch gehoert unabhaengig vom Fortschritt geprueft.

Ausserdem festgehalten: die 1080p-Fassung wurde von HandBrake beim Start auf
0 Bytes gekuerzt, im Akira-Ordner liegt derzeit ein unbrauchbares Fragment.
Der 75-GB-Rohschnitt ist unversehrt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 20:32:34 +02:00
Hitonabi b526a0a033 docs(savepoint): Uebergabestand v3.13 - gemessen vs. vermutet getrennt
Ampel / ampel (push) Successful in 46s
Uebergabe an eine neue Sitzung. Der Block trennt bewusst, was per Befehl auf
der VM geprueft wurde, von dem was offen bzw. nur erwartet ist - inklusive
zweier Fehlschluesse dieser Sitzung, damit die naechste Sitzung weiss, welchen
Aussagen sie nicht blind glauben soll.

Kern:
- Ein 4K-HandBrake-Lauf laeuft GERADE (Akira, Preset "H.265 MKV 2160p60 4K").
  Die vorherige 1080p-Datei wurde dabei auf 0 Bytes gekuerzt; der 75-GB-
  Rohschnitt liegt unversehrt in /app/temp/raw.
- progress=99 am Job ist ein Altwert aus dem Absturz, NICHT der echte Stand.
- Noch nicht bewiesen: dass _original_aufheben() im Ernstfall nur warnt statt
  die Platte vollzuschreiben. Genau dieser Pfad ist neu.
- Gefunden, nicht gebaut: Zombie-Erkennung. Nach dem Absturz stand der Job auf
  "transcoding", obwohl kein Prozess lief und beide Celery-Queues leer waren.
  _kann_neu_komprimieren verlangt status == "failed", deshalb fehlt dem Nutzer
  auch der "Neu komprimieren"-Knopf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 20:08:51 +02:00
Hitonabi 8bb075c656 feat(rip): Arbeitsverzeichnis je Rip waehlbar statt global vorgegeben
Ampel / ampel (push) Successful in 28s
Commander-Vorgabe 25.07.2026: "Du sollst es nicht automatisch setzen, es soll
auswaehlbar sein - z. B. beim Rippen starten, oder VORHER wenn Vollautomatik
eingeschaltet ist."

Bisher gab es nur die globale Einstellung workDir, und die war ein freies
Textfeld - man musste den Container-Pfad (/app/media/...) kennen. Beim
Akira-Rip landeten deshalb 74 GB Rohdaten auf der 148-GB-VM-Platte, obwohl
eine NAS-Freigabe mit 2,3 TB eingehaengt war.

- RipTargetModal: neue Auswahl "Arbeitsverzeichnis fuer die Rohdaten",
  gespeist aus /storage-targets (mit Kennzeichnung als Netzwerk-Freigabe und
  freiem Platz je Ziel). Leer = "Standard aus den Einstellungen", der Wert
  wird zur Orientierung mit angezeigt. Bei Musik ausgeblendet - CD-Rips gehen
  direkt als FLAC ins Ziel, ohne Roh-Zwischenstufe.
- POST /jobs nimmt work_dir entgegen, mit derselben Pfad-Haerte wie das Ziel
  (_validiere_ziel: muss unter /app/media liegen), und legt es in die
  Job-Metadaten.
- _arbeitsverzeichnis(einstellungen, job_wahl) im Worker: Wahl dieses Rips ->
  Setting -> Container-Default. Damit bleibt die Einstellung genau das, was
  bei Vollautomatik-Rips greift, weil dort niemand gefragt wird. Der Text im
  Einstellungen-Tab sagt das jetzt auch so.
- posixpath statt os.path in _arbeitsverzeichnis: das sind immer
  Container-Pfade, auch wenn ein nativer Windows-Worker das Modul laedt (der
  uebersetzt erst spaeter per pfad_lokal). os.path.normpath machte unter
  Windows Backslashes daraus, wodurch die MEDIA_ROOT-Pruefung nicht mehr
  griff - lokal als Testfehler aufgefallen.
- Test deckt die Reihenfolge und die Ausbruchsversuche ab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 19:59:37 +02:00
Hitonabi c065967f4d fix(ui,transcode): Dialog-Falle, Arbeitsverzeichnis waehlbar, Original-Aufheben entschaerft
Ampel / ampel (push) Successful in 28s
Drei Befunde aus dem ersten echten UHD-Durchlauf (Akira). Der Rip und die
Kompression liefen sauber durch - danach hat "Original behalten" die Platte
vollgeschrieben und den fertigen Job als fehlgeschlagen markiert.

1. ORIGINAL-AUFHEBEN DARF DEN JOB NICHT MEHR TOETEN (tasks.py)
   Vorher stand dort ein nacktes shutil.move(raw_dir, ziel). Arbeits-
   verzeichnis (/app/temp, Docker-Volume) und Ziel (/app/media, Bind-Mount)
   sind VERSCHIEDENE Dateisysteme - os.rename scheitert mit EXDEV, shutil.move
   faellt auf Kopieren zurueck. Ergebnis am 25.07.: 74-GB-Vollkopie auf
   dieselbe Platte, Abbruch bei 41 GB mit ENOSPC, Platte 100 % voll, Worker-
   Container startete nicht mehr ("failed to mount: no space left on device"),
   und der Job galt als FEHLGESCHLAGEN - obwohl die komprimierte Datei
   (4,8 GB) fertig und in Ordnung war. Der Nutzer sah nur eine leere Queue.
   Jetzt: _original_aufheben() prueft erst, ob ueberhaupt kopiert werden muss
   (gleiches Dateisystem -> reines Umhaengen), prueft sonst den freien Platz
   VORHER, faengt jeden OSError ab, raeumt eine halbe Kopie weg und meldet das
   als WARNUNG. Der Job bleibt erfolgreich, die Roh-Datei bleibt liegen.
   Zwei Tests decken beide Wege ab.

2. ARBEITSVERZEICHNIS IST JETZT WAEHLBAR (Settings.tsx)
   Es war ein freies Textfeld - man musste den Container-Pfad (/app/media/...)
   KENNEN, um eine Netzwerk-Freigabe zu treffen. Genau daran ist es
   gescheitert, weshalb der 74-GB-Rohschnitt ueberhaupt erst auf der VM-Platte
   landete. Jetzt eine Auswahl aus /storage-targets (dieselbe Liste wie bei
   den Speicherzielen), inklusive Kennzeichnung als Netzwerk-Freigabe und
   freiem Platz je Ziel.

3. DIALOGE KLEBTEN IM PANEL (ui/Modal.tsx)
   "Rippen starten" im Laufwerke-Tab oeffnete den Dialog INNERHALB des
   Bereichs, teils abgeschnitten. Ursache: .glass-panel in index.css setzt
   backdrop-filter: blur(16px), und ein Element mit backdrop-filter wird zum
   Bezugsrahmen fuer position: fixed seiner Nachfahren - das Modal war damit
   an der Card ausgerichtet statt am Fenster. Modal rendert jetzt per
   createPortal an document.body. Behebt es fuer ALLE Dialoge auf einmal.

Aufgeraeumt: die abgebrochene 41-GB-Teilkopie unter
"/app/media/movies/Akira (1988)/original/" geloescht (nachweislich
unvollstaendig - 41 GB gegen 79,6 GB Quelle, Quelle intakt). Platte wieder
bei 78 %, Container laufen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 19:48:10 +02:00
Hitonabi e84afc1718 feat(transcode): ein HandBrake-Preset je Disc-Typ statt eines fuer alles
Ampel / ampel (push) Successful in 29s
Rueckfrage des Commanders beim ersten echten UHD-Rip: "merkt Rippy, dass es
eine UHD ist, und nimmt direkt das 4K-Preset?" Antwort war nein. Die
Kompression fragte den Disc-Typ ueberhaupt nicht:

    preset = einstellungen.get("transcodePreset") or DEFAULT_HB_PRESET

Live eingestellt war "HQ 1080p30 Surround". Der gerade laufende Akira-Rip
waere also verlustfrei in 4K gerippt und danach auf 1080p heruntergerechnet
worden - und mit keepOriginal=False waere der 4K-Rohschnitt danach geloescht
worden. Umgekehrt wurde eine DVD auf 1080p hochskaliert, was nichts bringt.

- preset_fuer(disc_type, einstellungen) in ripping.py, pure und getestet.
  Reihenfolge: Preset des Disc-Typs -> allgemeines transcodePreset ->
  DEFAULT_HB_PRESET. Bestandsinstallationen aendern ihr Verhalten NICHT,
  solange die neuen Felder nicht gespeichert sind.
- Drei Einstellungen: transcodePresetDvd / transcodePresetBluray /
  transcodePresetUhd. transcodePreset bleibt als Rueckfall bestehen.
- transcode_files holt den Disc-Typ aus dem Job-Datensatz und schreibt ihn
  mit ins Log ("Disc-Typ 'uhd', Preset '...'").
- UI: drei Auswahlfelder statt einem, mit Klartext dazu, warum eine 4K-UHD
  auf ein 2160p-Preset gehoert.

Preset-Namen stammen aus "HandBrakeCLI --preset-list" im Worker-Image
(HandBrake 1.6.1) - nicht aus dem Kopf (AGENTS Regel D):
H.265 MKV 2160p60 4K, HQ 2160p60 4K HEVC Surround,
Super HQ 2160p60 4K HEVC Surround, H.265 MKV 1080p30, HQ 1080p30 Surround,
Super HQ 1080p30 Surround, H.265 MKV 576p25, H.265 MKV 480p30,
HQ 576p25 Surround.

Sofortmassnahme am laufenden Job (auf Ansage des Commanders): keepOriginal
auf True gesetzt - nur dieses eine Feld, gegengeprueft dass kein anderer
Schluessel veraendert wurde. Damit ueberlebt der 4K-Rohschnitt die
Kompression.

ACHTUNG - Deploy bewusst NICHT ausgefuehrt: docker compose up -d --build
wuerde den Worker-Container neu erstellen und den laufenden Akira-Rip
abbrechen. Erst nach Abschluss des Jobs deployen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 11:38:52 +02:00
Hitonabi f449c4ee34 fix(uhd): 4K-UHD geloest - makemkvcon holt Schluessel unter Linux nie
Ampel / ampel (push) Successful in 28s
Richtigstellung des Vortags-Befunds. Dort stand, 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 zwar wirklich nicht mehr aufloesen, von MakeMKV
aber laengst nicht mehr benutzt werden. Aufgedeckt durch den Einwand des
Commanders, unter Windows ginge es sofort.

Gegenprobe mit demselben Laufwerk und derselben Disc (Akira UHD, MKB v76):

                        Linux (Worker)      Windows
  Verbindungen          KEINE EINZIGE       185.84.108.20:443
  Meldung 3338          nie                 "Downloading latest HK"
  _private_data.tar     2048 B, 0 Keys      6,4 MB, 604 Keys
  Disc                  volume key unknown  TCOUNT:5, geht auf

Gegengeprueft mit leerem UND gefuelltem Speicher, mit und ohne --noscan,
mit dev:/dev/sr0 und disc:0, mit geloeschter update.conf. Linux fragt nie.
Die Meldungsvorlage "Downloading latest %1 to %2 ..." steckt sehr wohl im
Linux-Binary - sie loest nur nicht aus. Gleiches Symptom im MakeMKV-Forum,
seit Jahren offen (t=25782, t=34022). Der Dienst lebt; der Worker erreicht
185.84.108.20:443 sogar problemlos.

BEWIESEN: Nach Uebernahme des Windows-Schluesselspeichers oeffnet
makemkvcon auf der VM die Akira-UHD - "Operation successfully completed",
TCOUNT:5, fuenf Titel, identisch zum Windows-Ergebnis. Erster belegter
UHD-Disc-Zugriff auf der Rippy-Maschine.

- makemkv_daten.py (beide Zwillinge): zaehle_schluessel,
  private_data_pruefen, schluesselspeicher_status, private_data_schreiben.
  Die Pruefung lehnt einen Speicher OHNE hkd_*.bin ab - sonst laedt jemand
  den leeren Vorrat einer frischen Installation hoch, nichts aendert sich,
  und niemand versteht warum. Modulkopf komplett neu, inkl. der
  Fehldiagnose als Warnung fuer spaeter.
- API: GET/POST /system/keystore. Der Rohkoerper der Anfrage IST die Datei
  (binaer - JSON/Base64 waere Ballast, Multipart kann die API nicht).
  Groessengrenze 64 MB = client_max_body_size in nginx.conf.
- UI: neuer Block "Disc-Schluessel fuer 4K-UHD" UEBER dem KEYDB-Block, mit
  Schluessel-Anzahl, Upload und Anleitung fuer den Windows-Weg. KEYDB.cfg
  ist jetzt als Notnagel beschriftet. Worker-Plakette zeigt die Anzahl;
  0 heisst sichtbar "4K-UHD scheitert".
- tasks.py: UHD-Fehlertext sagt den Windows-Weg an und nennt die Anzahl
  bekannter Schluessel dieses Workers.
- caps.py meldet schluessel je Worker.
- Alle Falschaussagen korrigiert: UI (3), Anleitung (2), README (3),
  KONZEPT §8 + §10, Worker-Dockerfile, makemkv_key.py (dort stand "Den
  AACS-Schluessel zieht MakeMKV via LibreDrive ohnehin selbst aus dem
  Laufwerk" - gilt fuer Blu-ray, NICHT fuer UHD).
- SAVEPOINT v3.11, ROADMAP Etappe 18 (Etappe 17 mit Nachtrag), AGENTS.

Offen: voller UHD-Rip inkl. Transcode-E2E; und ob sich der Abruf unter
Linux doch anstossen laesst.

Quellen (AGENTS Regel D):
- Linux laedt keine Hashed Keys, gleiches Symptom:
  https://forum.makemkv.com/forum/viewtopic.php?t=25782
  https://forum.makemkv.com/forum/viewtopic.php?t=34022
- Schluessel als hkd_*.bin in _private_data.tar:
  https://forum.makemkv.com/forum/viewtopic.php?t=32675
- Meldungsformat: https://www.makemkv.com/developers/usage.txt

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 11:18:09 +02:00
Hitonabi 0935766f61 feat(uhd): KEYDB.cfg-Unterstuetzung - MakeMKVs Schluessel-Kanal liefert nichts mehr
Ampel / ampel (push) Successful in 28s
4K-UHD scheiterte an "The volume key is unknown for this disc". Am 25.07.2026
im Worker-Container nachgemessen: Laufwerk und MakeMKV sind in Ordnung
(LibreDrive v06.3 / Meldung 1011, Disc wird gelesen, AACS-Dump geschrieben) -
MakeMKV versucht gar nicht erst, online einen Schluessel zu holen. Belege:
_private_data.tar enthaelt nur die Index-Datei und KEINE hkd_*.bin; auch mit
geloeschter update.conf (Meldung 5074 belegt den Web-Kontakt) und mit
app_UpdateEnable="1" kam keiner; hkdata.fairuse.org und hkdata.crabdance.com
loesen weltweit nicht mehr auf (NXDOMAIN gegen Fritz!Box, 8.8.8.8, 1.1.1.1).
Betroffen war Akira UHD (MKB v76, Pressung Dez. 2020) - also gerade KEINE
Neuerscheinung. Der bisherige Fehlertext ("Disc neuer als die
Schluessel-Datenbank, mit einem der naechsten Updates rippbar") war falsch.

Einziger heute funktionierender Weg ist eine vom Nutzer selbst mitgebrachte
KEYDB.cfg. Rippy liefert KEINE Schluessel mit, laedt keine herunter und
verteilt keine - es stellt nur den Platz bereit und zeigt an, was dort liegt.

- Datenverzeichnis persistent gemountet (MAKEMKV_DATA_HOST, Default
  /srv/rippy/makemkv): KEYDB.cfg und AACS-Dumps ueberleben jeden Rebuild.
  Vorher loeschte jeder "up -d --build" beides - inklusive des Dumps, auf den
  die Fehlermeldung selbst verwies.
- entrypoint.sh und tasks.py schreiben settings.conf ergaenzend statt
  zerstoerend. Der entrypoint bricht bei nicht beschreibbarem Verzeichnis
  nicht mehr ab - mit "restart: unless-stopped" waere das ein Crashloop
  gewesen, in dem auch reines DVD-Rippen tot ist.
- Neues Zwillings-Modul makemkv_daten.py (docker/api + docker/worker,
  byteweise identisch; test_zwillinge_sind_byteweise_identisch wacht darueber
  und wurde durch absichtliches Verstellen als wirksam nachgewiesen).
- API: GET/POST/DELETE /system/keydb, GET /system/aacs-dumps(/{dateiname}).
  JSON-Body statt Multipart - python-multipart ist bewusst nicht installiert
  und wuerde die API beim Import toeten. nginx client_max_body_size 64m,
  sonst scheitert der Upload mit 413, bevor die API ihn sieht.
- UI (Einstellungen -> System): Status, Hochladen per Datei-Dialog, Entfernen,
  Dump-Download, KEYDB-Plakette je Worker (nur wo das Verzeichnis wirklich
  gemountet ist - ein Remote-Transcode-Worker truege sonst eine Warnung,
  die ihn nichts angeht).
- parse_msg() + log_cb: MakeMKV-Meldungen landen im Rippy-Log (gedrosselt:
  Code 1003 raus, keine Wiederholungen, max. 40 je Rip). Nebenbei behoben:
  der alte Parser (split(",", 4)[3]) schnitt jede Meldung am ersten Komma ab.
- Fuenf Stellen richtiggestellt, die behaupteten, MakeMKV-Updates braechten
  die neueste Disc-Schluessel-Datenbank mit (UI, Anleitung, README,
  Worker-Dockerfile, makemkv_key.py).

NICHT bewiesen: ein erfolgreicher UHD-Rip - es lag keine KEYDB.cfg mit dem
Akira-Schluessel vor. Belegt sind der Befund und die neue Mechanik. So steht
es auch im SAVEPOINT und in der ROADMAP.

Quellen (AGENTS Regel D):
- Datenverzeichnis + Dateiname GROSS/case-sensitiv:
  https://forum.makemkv.com/forum/viewtopic.php?t=30636
- hkd_*.bin in _private_data.tar:
  https://forum.makemkv.com/forum/viewtopic.php?t=32675
- headless settings.conf / app_UpdateEnable:
  https://forum.makemkv.com/forum/viewtopic.php?t=20364
- KEYDB.cfg-Zeilenformat (libaacs):
  https://github.com/ShiftMediaProject/libaacs/blob/master/KEYDB.cfg
- MSG-/PRGV-Format: https://www.makemkv.com/developers/usage.txt

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 01:26:08 +02:00
Hitonabi f74f4e54f6 fix(api): Job-Meta (poster_path) an die Jobliste durchreichen
Ampel / ampel (push) Successful in 32s
GET /jobs nutzt response_model=List[Job]; das Job-Model hatte kein meta-Feld,
also schnitt FastAPI die Disc-Metadaten (poster_path) weg -> Dashboard.tsx
bekam job.meta = undefined -> Filmstreifen-Platzhalter statt TMDB-Poster,
sowohl in der Jobliste als auch im aktiven Rip-Header (beide aus /jobs).

Additiv: meta: Optional[Dict] ins Job-Model + in _job_row_to_model parsen
(json.loads wie im Detail-Endpunkt, defensiv gegen kaputtes JSON). Keine
UI-Aenderung noetig -- posterUrl() rendert dann die vorhandenen Poster.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 00:03:41 +02:00
48 changed files with 7331 additions and 1372 deletions
+38 -5
View File
@@ -7,11 +7,17 @@ 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
# generischen sg-Knoten des Laufwerks.
#
# BEWUSST AUSKOMMENTIERT: Die sg-Nummer ist JE HOST ANDERS. Ein vorbelegter Wert
# wäre auf den meisten Rechnern falsch — und zwar unbemerkt, weil er dann „schon
# gesetzt" aussieht. `install.sh` findet beide Knoten selbst (Abgleich über die
# SCSI-Adresse in /sys) und trägt sie hier ein.
#
# Von Hand ermitteln, falls gewünscht: lsscsi -g
# Ohne Eintrag gelten die Compose-Vorgaben /dev/sr0 und /dev/sg1.
#OPTICAL_SR=/dev/sr0
#OPTICAL_SG=/dev/sg1
# (JWT/Auth wurde am 24.07.2026 komplett entfernt — Rippy ist Heimnetz-only,
# Commander-Entscheid, siehe KONZEPT.md Abschnitt 10. Härtung für exponierte
@@ -33,8 +39,35 @@ 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
# (bei Cloudflare-Zicken vorher Tarballs nach docker/worker/vendor/ legen)
#MAKEMKV_VERSION=1.18.4
# ---------------------------------------------------------------------------
# MakeMKV-Bezug beim Image-Bau (nur nötig, wenn der Download klemmt)
# ---------------------------------------------------------------------------
# Am 25.07.2026 nachgemessen, welche Quellen wirklich liefern:
# https://www.makemkv.com/download HTTP 200 <- der Standard, funktioniert
# https://www.makemkv.com/download/old HTTP 525 (Cloudflare)
# web.archive.org-Schnappschuss HTTP 404
# Normalerweise ist hier NICHTS einzutragen.
#MAKEMKV_URL_BASE=https://www.makemkv.com/download
#
# Zweite Quelle, die bei Fehlschlag der ersten automatisch probiert wird.
# Trage hier nur etwas ein, das du selbst geprüft hast — eine Adresse, die nicht
# liefert, lässt die Konfiguration gesund aussehen und den Bau später scheitern.
# Genau so lag es auf der Rippy-VM: dort stand eine 404-Adresse, und nur die
# vendor-Tarballs retteten jeden Bau, ohne dass es jemandem auffiel.
#MAKEMKV_URL_FALLBACK=http://192.168.178.10:8099
#
# Der zuverlässigste Weg bleibt ohne Netz: Tarballs von makemkv.com/download
# laden und nach docker/worker/vendor/ legen — der Bau nimmt sie dann von dort.
+26 -1
View File
@@ -55,10 +55,35 @@ 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)
-**Etappe 19 (v3.14):** Durchsicht Frontend/Backend — vier Placebos weg
(Fortschritt log, Auswurf tat nichts, „Alle Tracks" konnte nichts, Encoder
wurden behauptet statt gemessen), Zombie-Erkennung gebaut, Pfad-Prüfung
gehärtet, und der Platten-Schutz aus `c065967` als **unwirksam** entlarvt
-**Etappe 20 (v3.15):** Aufräum-Runde — `/capabilities` 1,010 s → 0,003 s
(Ping im Hintergrund), Kompression je Disc-Typ abwählbar (4K verlustfrei),
Wizard empfiehlt nach gemessener CPU, vier tote Routen entfernt
- 📝 **Details immer in SAVEPOINT.md** — diese Sektion nennt nur die Etappe
## Was diese Sitzungen wiederholt gekostet hat
**Nicht aus einem Zustandswert auf einen Mechanismus schließen.** Vorgefallen:
aus „kein Schlüssel da" → „Server abgeschaltet" (falsch), aus Status
`transcoding` → „Celery hat neu zugestellt" (falsch), aus `progress=99`
„Altwert aus dem Absturz" (falsch — ein Bug), aus gleichem `st_dev`
`os.rename` funktioniert" (falsch — der Kernel vergleicht den Mount).
Jedes Mal hätte eine Messung von unter einer Minute gereicht.
**Und die Umkehrung gilt genauso:** gleiches `st_dev` heißt NICHT gleicher
Mount. Wo eine Eigenschaft ausprobierbar ist, probiere sie aus, statt sie
vorherzusagen.
+18
View File
@@ -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
+444 -193
View File
@@ -2,217 +2,468 @@
Disc rein → automatisch erkannt (Titel, Poster, Metadaten) → verlustfrei
gerippt (MakeMKV) → auf Arbeitsgröße komprimiert (HandBrake) → fertig
abgelegt, wo DU willst (lokal, NAS, jede Freigabe). Modernes Web-UI,
Echtzeit-Fortschritt, komplett in Docker, komplett lokal.
abgelegt, wo du willst (lokal, NAS, jede Freigabe). Modernes Web-UI, komplett
in Docker, komplett lokal — keine Cloud, keine Telemetrie.
## Schnellstart
---
Voraussetzungen: Docker + Docker Compose, ein optisches Laufwerk am Host.
## Installation
Du brauchst: einen **Linux-Rechner mit Docker** und ein **optisches Laufwerk**.
```bash
git clone <repo-url> rippy && cd rippy
cp .env.example .env # optional: API-Keys — geht auch später im UI
mkdir -p /srv/rippy/media # Ablage-Basis (anpassbar in docker-compose.yml)
sudo ./install.sh
```
Das war es. Am Ende steht die Adresse im Terminal — im Browser öffnen, der
**Einrichtungs-Assistent** übernimmt den Rest.
Zuerst nur nachsehen, ob alles passt, ohne etwas zu ändern:
```bash
./install.sh --nur-pruefen
```
<details>
<summary><b>Was <code>install.sh</code> für dich macht</b> (aufklappen)</summary>
Es nimmt genau die Schritte ab, an denen man vorher scheitern konnte:
1. **Prüft die Voraussetzungen** — Linux, Docker, Compose. Fehlt etwas, steht
der Installationsbefehl dabei.
2. **Findet dein Laufwerk selbst.** MakeMKV braucht **zwei** Geräteknoten:
`/dev/srN` und den passenden `/dev/sgM`. Welche sg-Nummer dazugehört, ist
**je Rechner anders** — das Skript gleicht sie über die SCSI-Adresse ab
statt zu raten. (Genau daran ging vorher die Handarbeit schief.)
3. **Legt die Verzeichnisse an** (`/srv/rippy/media` für die Filme,
`/srv/rippy/makemkv` für MakeMKVs Daten).
4. **Richtet die Mount-Propagation ein** und macht sie neustart-fest — sonst
funktioniert das Einhängen von NAS-Freigaben aus dem UI nach jedem Reboot
nicht mehr.
5. **Schreibt die `.env`** mit den gefundenen Werten. Bestehende Werte werden
**nie** überschrieben.
6. **Baut und startet** alles.
Das Skript ist **wiederholbar**: mehrmals ausführen ändert nichts kaputt. Es
ist auch der Update-Weg — siehe unten.
</details>
### Aktualisieren
```bash
git pull && sudo ./install.sh
```
---
## Mit einer Docker-Oberfläche (Dockge, Portainer, Arcane)
**Die eine Sache, die du wissen musst:** Rippy hat **keine fertigen Images in
einer Registry.** Die drei Dienste `api`, `worker` und `ui` werden aus dem Repo
gebaut (`build: context: .`). Die Compose-Datei allein ist deshalb wertlos —
wer sie in ein leeres Verzeichnis kopiert, bekommt sofort
*„failed to read dockerfile"*.
Daraus folgt für **jede** Oberfläche: **das Repo muss dort liegen, wo die
Oberfläche den Stack baut.** Alles andere ist Kleinarbeit.
| | kann das Repo selbst holen? | Weg |
|---|---|---|
| **Shell** | — | `git clone` + `sudo ./install.sh` |
| **Dockge** | nein | Repo **in** den Stacks-Ordner klonen |
| **Portainer** | **ja** | Stack aus *Repository* (Git-URL) |
| **Arcane** | nein | Repo auf dem Host, Deploy per Shell |
### Vorab-Prüfung — braucht kein root, ändert nichts
Egal welche Oberfläche: Führe nach dem Klonen einmal das hier aus. Es sagt dir
in fünf Sekunden, ob die Maschine passt — vor allem, **welche Geräteknoten dein
Laufwerk wirklich hat**:
```bash
./install.sh --nur-pruefen
```
### Dockge
Dockge verwaltet Stacks als Verzeichnisse. Klone das Repo **in** den
Stacks-Ordner (Standard `/opt/stacks`, bei dir ggf. anders):
```bash
cd /opt/stacks
git clone <repo-url> rippy
cd rippy && ./install.sh --nur-pruefen
```
Dockge zeigt `rippy` danach als Stack an, und „Start" baut die Images. Du musst
**keine Compose-Datei einfügen** — die des Repos ist die des Stacks. Lege den
Stack also **nicht** neu in Dockge an, sonst landet eine leere Compose-Datei in
einem anderen Ordner und der Bau scheitert.
### Portainer
Portainer kann das Repo selbst klonen, das ist hier der bequemste Weg:
**Stacks → Add stack → Repository**, Git-URL eintragen, Compose-Pfad
`docker-compose.yml`. Der Web-Editor und „Upload" funktionieren **nicht**
beide liefern keinen Build-Kontext.
Die Geräteknoten trägst du als Stack-Umgebungsvariablen ein (statt in eine
`.env`), falls sie von `/dev/sr0` und `/dev/sg1` abweichen:
```
OPTICAL_SR=/dev/sr0
OPTICAL_SG=/dev/sg0
```
Welche es sind, sagt dir auf dem Host `lsscsi -g` — oder die Vorab-Prüfung oben.
### Arcane
Das Repo liegt auf dem Host (z. B. `~/projects/rippy`), Arcane verwaltet die
Container. Deployt wird per Shell — Arcanes Git-Sync zieht **nicht**
selbstständig:
```bash
cd ~/projects/rippy && git pull --ff-only && docker compose up -d --build
```
### Was keine Oberfläche für dich tun kann
Drei Dinge passieren auf dem **Host**, nicht im Container — deshalb gibt es
`install.sh` überhaupt:
1. **Geräteknoten.** MakeMKV braucht `/dev/srN` **und** den passenden
`/dev/sgM`; die sg-Nummer ist je Rechner anders. Stimmt sie nicht, **startet
der Worker-Container gar nicht.** Das ist der Fehler, der praktisch immer als
erster kommt.
2. **Ablage-Ordner** `/srv/rippy/media` und `/srv/rippy/makemkv`.
3. **Mount-Propagation** (`rshared`) für das Einhängen von NAS-Freigaben aus dem
UI. Auf den meisten systemd-Hosts ist `/` schon `rshared` und es ist nichts
zu tun — nur wenn `docker compose up` über *„not a shared mount"* klagt,
braucht es die Shell.
Punkt 1 ist der einzige, der zuverlässig zuschlägt. `sudo ./install.sh` erledigt
alle drei; danach kannst du den Stack dauerhaft über die Oberfläche fahren.
---
## Wenn etwas nicht geht
Die sechs Fälle, die praktisch alles abdecken:
| Symptom | Ursache & Lösung |
|---|---|
| **`failed to read dockerfile`** | Die Compose-Datei liegt ohne das Repo da. Rippy hat **keine Registry-Images**, die drei Dienste werden gebaut. Siehe **[Mit einer Docker-Oberfläche](#mit-einer-docker-oberfläche-dockge-portainer-arcane)**. |
| **Worker-Container startet nicht, `/dev/sgN` nicht gefunden** | Die sg-Nummer ist **je Rechner anders**; die Vorgaben `sr0`/`sg1` passen nur zufällig. `./install.sh --nur-pruefen` sagt dir die richtigen (oder `lsscsi -g`), dann in die `.env` bzw. als Stack-Variablen eintragen. **Der Fehler, der praktisch immer als erster kommt.** |
| **„Kein optisches Laufwerk gefunden"** | In einer **VM**? Das Laufwerk muss per **USB-Passthrough** durchgereicht werden, nicht als emuliertes CD-ROM (`media=cdrom`) — das kann keine SCSI-Kommandos, MakeMKV sieht es nie. Proxmox: `qm set <vmid> -usb0 host=<hersteller>:<produkt>,usb3=1`. Auf echter Hardware: `ls /dev/sr*` prüfen. |
| **Build bricht beim MakeMKV-Download ab** | Cloudflare drosselt manchmal. Der zuverlässige Weg: Tarballs von makemkv.com/download händisch nach `docker/worker/vendor/` legen, dann `sudo ./install.sh` erneut — der Build nimmt sie von dort und braucht kein Netz. Hast du eine eigene Quelle (Spiegel im LAN), trage sie als `MAKEMKV_URL_FALLBACK` in die `.env` ein; sie wird automatisch versucht, wenn makemkv.com nicht liefert. |
| **Kompression läuft ewig** | Deine CPU kann kein AVX2. Rippy zeigt das jetzt selbst an (Einstellungen → System, „Vektorbefehle"). Siehe **[Rippy schneller machen](#rippy-schneller-machen)**. |
| **4K-UHD: „The volume key is unknown"** | Erwartbar und **kein Fehler in Rippy**. Siehe **[4K-UHD](#4k-uhd)**. |
Logs ansehen: `docker compose logs -f` · Status: `docker compose ps`
---
## Rippy schneller machen
**Läuft Rippy in einer virtuellen Maschine, ist das hier der wirksamste
Handgriff überhaupt** — und er kostet fünf Minuten.
Virtualisierer geben der VM standardmäßig eine **generische CPU** (bei
Proxmox/KVM heißt sie `kvm64` bzw. `qemu64`). Die kann absichtlich nur alte
Befehlssätze, damit sich eine VM zwischen verschiedenen Wirten verschieben
lässt. Der Preis: **kein AVX2** — und genau davon lebt der Video-Encoder x265.
Was das ausmacht, ist auf der Rippy-Maschine gemessen worden: ein 4K-Film in
H.265 brauchte **28 bis 55 Stunden**, bei voll ausgelasteten Kernen. Mit AVX2
sind typisch **2- bis 4-mal schneller** drin.
**So stellst du es um (Proxmox):**
1. VM **herunterfahren.** Ein Neustart von innen genügt nicht — Proxmox
übernimmt Hardware-Änderungen nur bei einem echten Stopp.
2. Im Web-UI: VM auswählen → **Hardware****Processors** → Doppelklick →
**Type** auf **`host`** stellen → OK.
Oder auf der Proxmox-Konsole:
```bash
qm set <vmid> --cpu host
```
3. VM **starten.**
**Nachprüfen — Rippy sagt es dir selbst:** Einstellungen → System, Zeile
„Vektorbefehle" beim Worker. Steht dort jetzt `avx2` oder `avx512f` statt
`sse4_2`, hat es geklappt und die amberfarbene Warnung verschwindet.
Auf der Kommandozeile: `grep -o avx2 /proc/cpuinfo | head -1`.
<details>
<summary>Nachteile von <code>host</code> — der Vollständigkeit halber</summary>
Die VM sieht dann die echte CPU. Dadurch lässt sie sich nicht mehr auf einen
Wirt mit anderem Prozessor **live** verschieben. In einem Heim-Cluster mit
einem einzigen Wirt ist das ohne Bedeutung. Wer mehrere Wirte hat und
Live-Migration nutzt, wählt statt `host` das neueste Modell, das **alle**
Wirte beherrschen (z. B. `x86-64-v3` — das enthält AVX2).
</details>
**Kein AVX2 möglich?** Dann zwei Auswege, beide im UI:
- **4K nicht komprimieren** (Einstellungen → Verarbeitung → Preset für 4K-UHD →
„Nicht komprimieren"). Die verlustfreie Datei bleibt stehen: beste Qualität,
20100 GB je Film. Der Einrichtungs-Assistent wählt das bei schwacher CPU
von selbst.
- **Andere Maschine komprimieren lassen** — siehe
[Weitere Maschinen als Worker](#weitere-maschinen-als-worker).
---
## Der Alltag
### Rippen
Disc einlegen. Rippy erkennt sie, zeigt Titel und Poster, du wählst das Ziel —
oder du stellst die **Vollautomatik** an (Einstellungen → Ripping) und es
läuft ohne Nachfrage los.
- **Alle Tonspuren und Untertitel bleiben erhalten** (wichtig für Anime/O-Ton).
- **Nur Hauptfilm** auf Wunsch — der längste Titel, Extras bleiben weg.
- **Serien:** Serienname + Staffel angeben → Ablage `<Serie>/Season NN`, und
die Episoden werden per Laufzeitabgleich (TMDB) zu „Serie S01E02.mkv"
benannt. Nur bei eindeutiger Zuordnung, sonst bleiben die Namen — mit Log.
- **Audio-CDs** laufen über abcde zu FLAC mit MusicBrainz-Tags.
- Schon gerippte Discs erkennt Rippy am Fingerabdruck und warnt.
- Vor dem Start wird der freie Platz gegen die Disc-Größe geprüft.
### Kompression
Ein **eigenes Preset je Disc-Typ** (Einstellungen → Verarbeitung) — Rippy
erkennt den Typ selbst. Das ist wichtig, weil ein 1080p-Preset eine 4K-UHD
stillschweigend herunterrechnet und eine DVD sinnlos hochskaliert.
Jeder Typ kann auch auf **„Nicht komprimieren"** stehen: dann bleibt der
verlustfreie Rip liegen. Sinnvoll für 4K, wenn die Ablage groß genug ist.
Die Rohdatei wird erst nach erfolgreicher Kompression gelöscht („Original
behalten" als Option). Fehlgeschlagene Kompressionen lassen sich ohne
Neu-Rip erneut anstoßen.
### Ablage & Media-Server
Einstellungen → Ripping: dein System wählen (Jellyfin, Emby, Kodi, Plex oder
keins). Fertige Rips heißen dann „Titel (Jahr)"; für Jellyfin/Emby/Kodi legt
Rippy zusätzlich `movie.nfo` + `poster.jpg` dazu. Bei **Jellyfin/Emby** mit
Server-URL + API-Key stößt Rippy nach jedem Rip sofort einen
Bibliotheks-Scan an — Disc rein, Film erscheint im Server.
**NAS-Freigaben** hängst du unter Einstellungen → Speicherziele direkt aus dem
UI ein (NFS/SMB). Sie erscheinen sofort in der Ziel-Auswahl und werden beim
Start automatisch wieder verbunden.
⚠️ Zugangsdaten liegen unverschlüsselt in der lokalen Datenbank — bewusster
Heimnetz-Kompromiss. Lege fürs NAS einen eigenen, eingeschränkten Benutzer an.
### Benachrichtigungen
Einstellungen → Benachrichtigungen: eine Webhook-URL eintragen, „Test senden"
drücken, speichern. Rippy erkennt den Dienst an der URL selbst:
| Dienst | URL-Beispiel |
|---|---|
| Discord | `https://discord.com/api/webhooks/…` |
| Slack | `https://hooks.slack.com/services/…` |
| ntfy (Handy-Push) | `https://ntfy.sh/mein-geheimes-thema` |
| Eigenes (Home Assistant, n8n, …) | beliebige HTTPS-URL |
---
## 4K-UHD
4K braucht **zwei** Dinge, die normale BD/DVD nicht brauchen: ein
**LibreDrive-fähiges Laufwerk** (MakeMKV-Forum: „Ultimate UHD Drives Flashing
Guide") **und** den **Schlüssel dieser Pressung**.
**Der Schlüssel ist die eigentliche Hürde — aus einem überraschenden Grund.**
Am 25.07.2026 auf beiden Maschinen gemessen (Akira UHD, MKB v76, Pressung
Dezember 2020): Laufwerk und MakeMKV waren in Ordnung — „Using LibreDrive mode
(v06.3)", die Disc wurde gelesen — und trotzdem kam „The volume key is unknown
for this disc".
> **`makemkvcon` unter Linux holt Disc-Schlüssel nie aus dem Netz.
> Die Windows-Version tut es.**
Gemessen: Linux baute in keinem einzigen Lauf eine Verbindung nach draußen auf
— geprüft mit leerem und gefülltem Schlüsselspeicher, mit und ohne `--noscan`,
mit `dev:` und `disc:`, und mit erzwungener frischer Prüfung. Dieselbe Disc am
selben Laufwerk unter Windows: „Lade aktuelle HK …", Verbindung zum
Schlüssel-Server, Disc geht auf. **Ein MakeMKV-Update ändert daran nichts, und
es ist kein Fehler in Rippy.**
**Der Weg drumherum** (Einstellungen → System):
1. MakeMKV auf einem **Windows-PC** installieren (gleicher Beta-Key).
2. Laufwerk anstecken, Disc **einmal öffnen** — dabei lädt MakeMKV die
Schlüssel nach.
3. In MakeMKV unter *Preferences → General* das „MakeMKV data directory"
nachschlagen und die Datei **`_private_data.tar`** daraus bei Rippy
hochladen.
Rippy zeigt an, **wie viele Disc-Schlüssel** dieser Worker kennt. Steht dort 0,
scheitert jede unbekannte UHD. Für neue Discs den Schritt gelegentlich
wiederholen. Rippy prüft die Datei und lehnt sie ab, wenn kein einziger
Schlüssel drinsteckt — sonst ändert sich nichts und niemand versteht, warum.
**Kennt MakeMKV die Pressung selbst nicht**, bleibt die **`KEYDB.cfg`** als
Notnagel (gleicher Tab). Und: **AACS-Dumps herunterladen** — den Dump legt
MakeMKV bei jeder unbekannten Disc selbst ab, und genau den braucht man, wenn
man im MakeMKV-Forum um den Schlüssel für eine neue Pressung bittet.
> ⚠️ **Rippy liefert keine Disc-Schlüssel mit, lädt keine herunter und
> verteilt keine.** Rippy hält nur den Platz für eine Datei bereit, die du
> selbst mitbringst, und zeigt ehrlich an, was dort liegt. Der
> MakeMKV-**Beta-Key** ist die Lizenz für die *Software* — zwei völlig
> verschiedene Dinge.
⚠️ UHD-Rohdaten sind bis 100 GB groß. Ist die Platte zu klein, lege das
**Arbeitsverzeichnis** (Einstellungen → Verarbeitung) auf eine eingehängte
Freigabe. Rippy bricht sonst **vor** dem Rip mit Klartext ab statt nach 40 GB
mit voller Platte.
---
## Weitere Maschinen als Worker
Die Kompression läuft als eigener Task auf der Queue `transcode` — **jede**
Maschine im Netz kann sie übernehmen. Einstellungen → Worker zeigt für beide
Varianten einen Copy-Paste-Befehl:
- **Windows (nativ, ohne Docker)** — braucht nur Python. Der Installer kommt
als fertige `.exe` von Rippy selbst; Worker-Code und HandBrake holt er zur
Laufzeit.
- **Linux (Docker)** — `deploy/remote-transcode-worker.yml`.
Rippy zeigt für jeden Worker an, **was er wirklich kann**: Encoder, Kernzahl,
Vektorbefehle. Für einen **GPU-Worker** wichtig: das Bild braucht ein
HandBrake mit `nvenc_*` bzw. `qsv_*` — die Anzeige nennt HandBrakes
ungefilterte Auskunft, damit du das nachprüfen kannst. Das mitgelieferte
Linux-Worker-Bild hat **keinen** Hardware-Encoder.
Ohne Zusatz-Worker macht der eingebaute CPU-Worker alles selbst — Rippy bleibt
All-in-one.
---
## Für Fortgeschrittene
<details>
<summary><b>Umgebungsvariablen (.env)</b></summary>
`install.sh` füllt die hostabhängigen Werte selbst. Alles hier ist optional —
UI-Einstellungen überstimmen die Env-Variablen.
| Variable | Zweck |
|---|---|
| `TMDB_API_KEY` | Metadaten (deutsche Texte). Bequemer im Assistenten — beide Key-Arten gehen (v3-Schlüssel und v4-Token) |
| `OMDB_API_KEY` | zweite Metadaten-Quelle (Fallback) |
| `THETVDB_API_KEY` | Serien-Fallback |
| `WORKER_NAME` | Anzeigename des eingebauten Workers (Standard `rippy-hauptworker`) |
| `MAKEMKV_APP_KEY` | MakeMKV-Beta-Key. Bequemer im UI unter Einstellungen → System — gilt ab dem nächsten Rip, ohne Rebuild |
| `MAKEMKV_VERSION` | MakeMKV-Version für den Image-Build |
| `MAKEMKV_URL_BASE` | Download-Quelle für den Build. Normalerweise **nichts eintragen** — der Standard `makemkv.com/download` liefert (am 25.07.2026 mit HTTP 200 geprüft; `/download/old` gibt 525, ein web.archive.org-Schnappschuss 404) |
| `MAKEMKV_URL_FALLBACK` | zweite Quelle, die bei Fehlschlag der ersten **automatisch** versucht wird. Leer = keine. Trage nur ein, was du selbst geprüft hast: eine Adresse, die nicht liefert, lässt die Konfiguration gesund aussehen und den Build später scheitern |
| `MAKEMKV_DATA_HOST` | Host-Verzeichnis für MakeMKVs Daten (Standard `/srv/rippy/makemkv`) — Schlüsselspeicher, `KEYDB.cfg`, AACS-Dumps. Persistent, überlebt jeden Rebuild |
| `OPTICAL_SR` / `OPTICAL_SG` | Geräteknoten des Laufwerks. **Findet `install.sh` selbst** |
| `POSTGRES_PASSWORD` | DB-Passwort (Standard `rippy`) |
</details>
<details>
<summary><b>Installation ohne install.sh (von Hand)</b></summary>
Falls du jeden Schritt selbst machen willst:
```bash
cp .env.example .env
mkdir -p /srv/rippy/media /srv/rippy/makemkv
# Laufwerksknoten ermitteln — die sg-Nummer ist je Host anders!
lsscsi -g # zeigt Modell + zugehoerigen /dev/sgN
# in die .env: OPTICAL_SR=/dev/sr0 und OPTICAL_SG=/dev/sgN
# Nur falls 'docker compose up' meldet:
# "path ... is mounted on / but it is not a shared mount"
mount --bind /srv/rippy/media /srv/rippy/media
mount --make-rshared /srv/rippy/media # neustart-fest als systemd-.mount-Unit
docker compose up -d --build
```
Dann `http://<host>` öffnen — der **Einrichtungs-Assistent** startet beim
ersten Mal automatisch (API-Keys, Verarbeitung, erkannte Hardware).
Braucht klassisches Linux-Docker mit `SYS_ADMIN` — nicht Docker Desktop,
rootless oder Podman. Die Mount-Propagation ist nur für das Einhängen von
NAS-Freigaben **aus dem UI** nötig; Freigaben auf dem Host einhängen geht immer.
### Laufwerk anpassen
</details>
Der Worker braucht **zwei** Host-Geräteknoten: den CD-ROM-Knoten (meist
`/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`).
<details>
<summary><b>Wie es intern funktioniert</b></summary>
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/
```
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.)
1. **Disc-Wache** — ioctl-Polling, kein udev. Im Container läuft kein udevd,
die udev-Datenbank ist leer; `udevadm info` liefert dort prinzipbedingt
nichts.
2. **Rip** — MakeMKV, verlustfrei. Der einzige Weg durch AACS: HandBrake kann
verschlüsselte Discs nicht lesen, deshalb sind es zwingend zwei Stufen.
3. **Kompression** — HandBrake als eigener Celery-Task auf eigener Queue,
deshalb an andere Maschinen routbar.
4. **Metadaten** — Volume-Label → TMDB → OMDb → MyAnimeList, mit
Ähnlichkeits-Bewertung und Korrektur-Popup an der Disc-Karte.
**Laufwerk in einer VM?** Per USB-Passthrough anhand der Vendor-ID
durchreichen (Proxmox: `qm set <vmid> -usb0 host=xxxx:yyyy,usb3=1`) —
NICHT als emuliertes CD-ROM (`media=cdrom`), das kann keine SCSI-Kommandos.
</details>
## Wie es funktioniert
<details>
<summary><b>Härtung für exponierte Netze</b></summary>
1. **Disc-Wache** (ioctl-Polling, kein udev-Gefrickel) erkennt Einlegen,
identifiziert die Disc (Volume-Label → TMDB → OMDb-Fallback) und zeigt
sie mit Poster auf dem Dashboard. Klick auf einen Job-Titel öffnet die
Detail-Ansicht (Poster, Jahr, Beschreibung, Ablagepfad).
2. **Rip** (MakeMKV, verlustfrei — der einzige Weg durch AACS): Ziel wählst
du beim Start (Filme/Serien/Musik/eigener Pfad, inkl. Netzwerk-Ziele) —
oder du stellst die **Vollautomatik** an (Einstellungen → Ripping) und
der Rip startet ohne Nachfrage in den passenden Schnellwahl-Ordner.
Optional **nur Hauptfilm** (längster Titel, Extras bleiben weg). Bereits
gerippte Discs erkennt Rippy am Fingerabdruck und warnt. Vor dem Start
prüft Rippy den freien Platz gegen die Disc-Größe. Alle Tonspuren und
Untertitel bleiben erhalten (wichtig für Anime/O-Ton). Audio-CDs laufen
über abcde → FLAC + MusicBrainz.
**Serien**: Im Dialog „Serien" wählen → Serienname + Staffel angeben —
Ablage wird `<Serie>/Season NN`, und die Episoden werden nach dem Rip
per Laufzeitabgleich (TMDB) automatisch zu „Serie S01E02.mkv" benannt
(nur bei eindeutiger Zuordnung — sonst bleiben die Namen, mit Log).
3. **Kompression** (HandBrake, eigener Job auf eigener Queue): x265/x264,
Preset im UI wählbar; Rohdatei wird erst nach Erfolg gelöscht
(„Original behalten" als Option). Fehlgeschlagene Kompressionen lassen
sich ohne Neu-Rip neu anstoßen.
4. **Media-Server-Ablage**: Unter Einstellungen → Ripping (oder im
Einrichtungs-Assistenten) wählst du dein System — Jellyfin, Emby, Kodi,
Plex oder keins. Fertige Rips heißen dann „Titel (Jahr)" statt
Job-UUID; für Jellyfin/Emby/Kodi legt Rippy zusätzlich movie.nfo +
poster.jpg dazu (Kodi-NFO-Schema, lesen alle drei nativ). Plex nutzt
nur die Benennung. **Jellyfin/Emby**: Server-URL + API-Key eintragen,
dann stößt Rippy nach jedem fertigen Rip sofort einen Bibliotheks-Scan
an — Disc rein, Film erscheint im Server.
5. **4K-UHD**: braucht ein LibreDrive-fähiges Laufwerk (MakeMKV-Forum:
„Ultimate UHD Drives Flashing Guide"). Normale BD/DVD gehen mit jedem
Laufwerk. ⚠️ UHD-Rohdaten sind bis 100 GB groß — wenn die Platte der
Rippy-Maschine dafür zu klein ist, lege das **Arbeitsverzeichnis**
(Einstellungen → Verarbeitung) auf eine eingehängte Freigabe, z. B.
`/app/media/nas-arbeit`. Rippy bricht sonst VOR dem Rip mit einer
Klartext-Meldung ab statt nach 40 GB mit voller Platte.
Rippy ist bewusst **Heimnetz-only**: keine Authentifizierung, und Redis +
PostgreSQL veröffentlichen Ports für Remote-Worker. Im vertrauten LAN ein
akzeptierter Kompromiss — **exponiere Rippy niemals ungeschützt an ein
öffentliches Netz.**
## Speicherziele (NAS, Freigaben)
Unter **Einstellungen → Speicherziele** hängst du NFS- oder SMB-Freigaben
direkt aus dem UI ein — sie erscheinen sofort in der Ziel-Auswahl beim
Rippen und werden beim Start automatisch wieder verbunden.
Technik: der api-Container läuft mit `CAP_SYS_ADMIN` und einem
rshared-Bind auf `/srv/rippy/media`, Mounts propagieren zu allen
Containern. ⚠️ Zugangsdaten liegen unverschlüsselt in der lokalen
Postgres-DB — bewusster Heimnetz-Kompromiss; lege fürs NAS einen eigenen,
eingeschränkten Benutzer an.
## Benachrichtigungen
**Einstellungen → Benachrichtigungen**: eine Webhook-URL eintragen, „Test
senden" drücken, speichern — fertig. Rippy meldet Job-Ende (fertig,
fehlgeschlagen, abgebrochen) und erkennt den Dienst an der URL selbst:
| Dienst | URL-Beispiel | Format |
|---|---|---|
| Discord | `https://discord.com/api/webhooks/…` | `{"content": …}` |
| Slack | `https://hooks.slack.com/services/…` | `{"text": …}` |
| ntfy (Handy-Push) | `https://ntfy.sh/mein-geheimes-thema` | Roh-Text + Titel |
| Eigenes (HA, n8n, …) | beliebige HTTPS-URL | `{"title","message","level"}` |
## System, MakeMKV-Beta-Key & Updates
**Einstellungen → System** zeigt die Werkzeug-Versionen jedes Workers
(MakeMKV, HandBrake), den Key-Status und den freien Speicherplatz. Der
MakeMKV-Beta-Key (wechselt ~monatlich, Forum-Thread t=1053) wird hier im
UI eingetragen und gilt ab dem **nächsten Rip** — ohne Rebuild, ohne
Neustart; er schlägt den Key aus der `.env`.
**Updates:** „Auf Updates prüfen" vergleicht mit makemkv.com und den
offiziellen HandBrake-Releases (12 h gecacht). MakeMKV-Update ohne
Code-Änderung: `MAKEMKV_VERSION=x.y.z` in die `.env`, dann
`docker compose build worker && docker compose up -d worker` — neue
Versionen bringen auch die neueste Disc-Schlüssel-Datenbank mit.
HandBrake kommt im Docker-Worker bewusst aus Debian (stabil, hinkt der
offiziellen Version hinterher); der native Windows-Worker nutzt die
aktuelle Version direkt.
## Verarbeitung & Hardware
**Einstellungen → Verarbeitung** zeigt ehrlich an, welche Encoder deine
Worker WIRKLICH haben (CPU x264/x265, VAAPI bei AMD/Intel-GPU, NVENC bei
NVIDIA) — jeder Worker meldet seine Fähigkeiten selbst beim Start.
### Optional: weitere Maschinen als Transcode-Worker
Die Kompression läuft als eigener Celery-Task auf der Queue `transcode`
JEDE Maschine im Netz kann sie übernehmen. **Einstellungen → Worker** zeigt
für beide Varianten einen Copy-Paste-Befehl:
- **Linux (Docker)**: `deploy/remote-transcode-worker.yml`
- **Windows (nativ, ohne Docker)**: braucht nur Python 3.10+ — der
Installer (`install.ps1`) kommt von der Rippy-Instanz selbst
(`/api/worker-setup/windows`), lädt Worker-Code und HandBrakeCLI
automatisch und erzeugt eine `start-worker.bat` (optional `-Autostart`).
Ohne Zusatz-Worker macht der eingebaute CPU-Worker alles selbst — Rippy
bleibt All-in-one.
## Umgebungsvariablen (.env)
| Variable | Pflicht | Zweck |
|---|---|---|
| `TMDB_API_KEY` | empfohlen | Metadaten (deutsche Texte) — alternativ im UI/Wizard; beide Key-Arten gehen (v3-Schlüssel und v4-Token) |
| `WORKER_NAME` | optional | Anzeigename des eingebauten Workers (Standard: rippy-hauptworker) |
| `OMDB_API_KEY` | optional | zweite Metadaten-Quelle (Fallback) |
| `THETVDB_API_KEY` | optional | Serien-Fallback |
| `MAKEMKV_APP_KEY` | optional | MakeMKV-Beta-Key (Forum); DVDs gehen ohne — bequemer: im UI unter Einstellungen → System pflegen |
| `MAKEMKV_URL_BASE` | optional | alternative Download-Quelle für den Image-Build |
| `OPTICAL_SR` | je Host | Host-Pfad des CD-ROM-Knotens (Default `/dev/sr0`) — siehe „Laufwerk anpassen" |
| `OPTICAL_SG` | je Host | Host-Pfad des sg-Knotens des Laufwerks (Default `/dev/sg1`; oft `/dev/sg0`) |
| `POSTGRES_PASSWORD` | optional | DB-Passwort (Default `rippy`) — für exponierte Umgebungen ein starkes Passwort setzen |
UI-Einstellungen (Wizard/Settings) überstimmen die Env-Variablen.
## Rippy woanders bereitstellen
Rippy ist reines Docker Compose — es läuft auf **jedem Linux-Host mit
Docker**, nicht nur auf der Original-VM. Es gibt (noch) keine fertigen
Registry-Images; gebaut wird beim ersten `up` direkt aus dem Repo.
Voraussetzungen auf dem Ziel-Host:
1. Linux (x86_64) mit Docker + Compose-Plugin.
2. Ein optisches Laufwerk, das der Host sieht (`ls /dev/sr* /dev/sg*`).
In einer VM: per **USB-Passthrough** (Vendor-ID) durchreichen, NICHT
als emuliertes CD-ROM — siehe „Laufwerk anpassen" oben.
3. Ablage-Basis anlegen: `mkdir -p /srv/rippy/media` (oder Pfad in
`docker-compose.yml` anpassen). Der Bind nutzt **shared/slave-Propagation**
(fürs In-UI-Einhängen von NAS-Freigaben). Auf den meisten systemd-Linux-Hosts
ist `/` schon `rshared`, dann ist nichts zu tun. Bricht `docker compose up`
mit *„path … is mounted on / but it is not a shared mount"* ab, einmalig:
```bash
sudo mount --bind /srv/rippy/media /srv/rippy/media
sudo mount --make-rshared /srv/rippy/media
```
(reboot-fest als systemd-`.mount`-Unit persistieren.) Braucht einen klassischen
Linux-Docker-Host mit `SYS_ADMIN` — nicht Docker-Desktop/rootless/Podman.
Dann wie im Schnellstart: klonen, `.env` füllen, `docker compose up -d
--build`, `http://<host>` öffnen — der Einrichtungs-Assistent führt durch
den Rest (API-Keys, Media-Server, Verarbeitung). Updates: `git pull &&
docker compose up -d --build`.
Nicht mitnehmen musst du: Gitea, Arcane, den CI-Runner — das ist die
Entwicklungs-Infrastruktur DIESER Installation, nicht Teil von Rippy.
### Härtung für fremde/exponierte Netze
Rippy ist bewusst **Heimnetz-only**: Es hat **keine Authentifizierung**, und
Redis + PostgreSQL veröffentlichen Ports auf allen Interfaces (für Remote-Worker).
Im vertrauten LAN ist das ein akzeptierter Kompromiss — **exponiere Rippy niemals
ungeschützt an ein unsicheres oder öffentliches Netz.** Wer es weiter absichern will:
- **Zugriff kapseln:** API (`:8000`) und UI hinter einen Reverse-Proxy mit Auth
(Basic-/Forward-Auth) legen oder nur über VPN erreichbar machen; Port `8000`
nicht auf `0.0.0.0` binden.
- **DB/Broker abschotten:** `POSTGRES_PASSWORD` in der `.env` auf ein starkes
Passwort setzen; die Ports `5432`/`6379` nur an ein internes/VPN-Interface binden
(z. B. `"10.0.0.5:5432:5432"`) statt an `0.0.0.0`; Redis mit `--requirepass
<geheim>` starten und das Passwort in `REDIS_URL` ergänzen.
- **CORS:** `main.py` erlaubt aktuell `*` (nötig, weil UI und API getrennte Ports
- **Zugriff kapseln:** UI und API (`:8000`) hinter einen Reverse-Proxy mit Auth
oder nur über VPN erreichbar machen.
- **DB/Broker abschotten:** starkes `POSTGRES_PASSWORD`; die Ports
`5432`/`6379` nur an ein internes/VPN-Interface binden statt an `0.0.0.0`;
Redis mit `--requirepass` starten und das Passwort in `REDIS_URL` ergänzen.
- **CORS:** `main.py` erlaubt `*` (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.
- **NAS-Zugangsdaten** liegen im Klartext in der DB — ein weiterer Grund, den
DB-Port nie offen ins unsichere Netz zu hängen.
</details>
<details>
<summary><b>Updates der Werkzeuge</b></summary>
„Auf Updates prüfen" (Einstellungen → System) vergleicht mit makemkv.com und
den offiziellen HandBrake-Releases, 12 h gecacht.
**MakeMKV** aktualisieren: `MAKEMKV_VERSION=x.y.z` in die `.env`, dann
`sudo ./install.sh`. Der Beta-Key wechselt etwa monatlich (Forum-Thread
t=1053) und wird im UI gepflegt — ohne Rebuild.
**HandBrake** kommt im Docker-Worker bewusst aus Debian (stabil, hinkt der
offiziellen Version hinterher); der native Windows-Worker nutzt die aktuelle.
⚠️ Ein MakeMKV-Update hilft **nicht** gegen „The volume key is unknown" —
MakeMKV liefert überhaupt keine Disc-Schlüssel mit, es holt sie zur Laufzeit,
und die Linux-Version holt sie nie. Siehe [4K-UHD](#4k-uhd).
</details>
---
## Entwicklung
CI („Ampel") läuft bei jedem Push: Ruff, pytest, Vite-Build. Es gibt genau
einen Branch, `main` (Single Source of Truth) — die Ampel prüft nur,
deployt wird direkt aus `main`. Rot heißt: nicht deployen.
einen Branch, `main` — die Ampel prüft nur, deployt wird direkt aus `main`.
Rot heißt: nicht deployen.
Regeln für Beiträge: [AGENTS.md](AGENTS.md) · Konzept: [KONZEPT.md](KONZEPT.md) ·
Fahrplan: [ROADMAP.md](ROADMAP.md)
Fahrplan: [ROADMAP.md](ROADMAP.md) · Stand: [SAVEPOINT.md](SAVEPOINT.md)
+156
View File
@@ -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
+869 -6
View File
@@ -1,6 +1,865 @@
# SAVEPOINT — Rippy
## Aktueller Stand: v3.9Windows-Installer als echte .exe (Icon, kein Konsolenfenster) (24.07.2026)
## Aktueller Stand: v3.16ÜBERGABE: externes Encoden konnte nie funktionieren (26.07.2026)
> **Zuerst lesen.** Diese Sitzung endete wegen vollem Kontext. Der Auftrag war
> Commander-Feedback aus der ersten Selbst-Einrichtung plus die Diagnose eines
> fehlgeschlagenen Test-Rips. Die Diagnose ist abgeschlossen, zwei Commits sind
> gebaut — **aber NICHT deployt**, und der Kern-Blocker steht noch offen.
### ZUSTAND, gemessen
| | |
|---|---|
| Repo | `fd1feaa`, Ampel **grün** |
| **VM** | **`61c38a0` — die letzten 2 Commits sind NICHT aktiv** |
| Windows-Worker des Commanders | läuft mit **altem** Code (vor der Windows-Erkennung) |
| Job `95afdc89` | `failed` beim Encoden |
| **Rohschnitt** | **79,6 GB intakt** auf `/app/media/rippy/95afdc89-…/title_t00.mkv` (NAS) → „Neu komprimieren" genügt, **kein Neu-Rip** |
| Commander-PC | AMD Ryzen 7 9700X · **avx512f** · 16 Kerne · RX 9070 XT |
| Rippy-VM-CPU | QEMU-Generikum · nur `sse4_2` · 4 Kerne |
### DER KERNBEFUND: drei Ursachen, die ineinandergriffen
Der Commander vermutete „das System sieht den externen Encoder nicht". **Falsch —
das Routing funktionierte einwandfrei.** Gemessen:
```
meta.transcode_node = tobisnicerpc@TobisNicerPC
VM-Worker-Log = nur rip_disc, KEIN transcode_files
22:19:41.271 Kompression eingereiht
22:19:41.453 "Keine Roh-MKVs gefunden" ← 182 ms später
```
Sein PC nahm die Aufgabe an und lehnte sie sofort ab. Warum:
1. **`RIPPY_PATH_MAP` wird von NIEMANDEM gesetzt** — nicht vom Installer, nicht
vom UI, nicht von Compose. `pfad_lokal()` in `tasks.py` hat sogar Tests, aber
keinen Anschluss. `/app/media/...` sind Container-Pfade; ein externer Worker
sieht sie nur übersetzt. **Ein Windows-Worker konnte also nie transcodieren.**
⚠️ **DAS IST DER OFFENE BLOCKER.**
2. **Die Ablage war im UI überhaupt nicht einstellbar.** `outputDir` wurde von
Vollautomatik und Schnellwahl gelesen, hatte aber **kein Eingabefeld**. Das
Arbeitsverzeichnis hatte eine Auswahl eingehängter Ziele, das Ziel nicht.
Folge: Rohdaten auf der NAS (erreichbar), Ziel auf der VM-Platte (nicht
erreichbar — dort läuft **kein Samba**). Die richtige Einstellung war mit den
vorhandenen Bedienelementen **nicht erreichbar.** → behoben, siehe unten.
3. **Die Fehlermeldung log.** „Keine Roh-MKVs gefunden" klang nach kaputtem Rip,
obwohl der Rip vollständig war. → behoben.
### GEBAUT in dieser Sitzung (2 Commits, gepusht, Ampel grün)
**`fe12401`** — Hardware-Encoder + CPU-Merkmale auf Windows, ehrliche Fehler:
- **Hardware-Erkennung war MEIN Bug:** `leite_backends_ab()` verlangte
`/dev/dri` bzw. `nvidia-smi` — beides gibt es unter Windows nicht. Der PC des
Commanders meldete deshalb nur CPU, obwohl HandBrakes Windows-Build `vce_*`
kennt. Die Prüfung war zudem überflüssig: **HandBrake probiert Hardware selbst
an und listet nur Nutzbares** (auf der VM belegt: „qsv: not available", und
`qsv_*` fehlt dann in `--help`). Jetzt ist HandBrakes Liste die einzige
Auskunft, unterschieden nach Familie (`nvenc`/`qsv`/`vce`/`vaapi`) plus eigener
Kennung für Hardware-AV1.
- **Vektorbefehle unter Windows** über `IsProcessorFeaturePresent` (kernel32,
winnt.h) + CPU-Name aus der Registry. Auf dem Commander-PC gegengeprüft:
vorher „AMD64 Family 26 Model 68 / unbekannt", jetzt „AMD Ryzen 7 9700X /
avx512f". Damit verschwindet die AVX2-Warnung für diesen Worker von selbst.
- **`_erreichbarkeit_pruefen()`** unterscheidet jetzt „Pfad nicht erreichbar +
kein Mapping" (nennt `RIPPY_PATH_MAP` mit Beispiel), „Mapping greift nicht"
und „Ordner leer" — und prüft **beide** Pfade, nicht nur die Quelle. Jede
Meldung sagt ausdrücklich, dass die Rohdaten nicht verloren sind.
- Vier Platzhalter mit der Umgebung des Commanders (`192.168.178.20`,
`TOBIS-PC`, seine Jellyfin- und Rippy-IP) verallgemeinert.
**`fd1feaa`** — Ablage einstellbar + Kohärenz-Prüfung:
- Auswahlliste **„Ablage"** in Einstellungen → Ripping, gleiche Liste wie beim
Arbeitsverzeichnis. Wirkt automatisch in Vollautomatik und Schnellwahl, weil
beide `outputDir` lesen — genau wie vom Commander gefordert.
- **Warnung, sobald ein EXTERNER Worker gemeldet ist** und Ablage +
Arbeitsverzeichnis nicht beide auf einer erreichbaren Freigabe liegen. Drei
Fälle mit Konsequenz im Klartext (kann nicht lesen / kann nicht schreiben /
läuft, kostet aber eine Vollkopie).
- „Extern" ist keine Heuristik: `caps.py` meldet `extern` selbst (`/app` gibt es
im Rippy-Image immer, außerhalb nie) und zusätzlich `pfad_map`.
### COMMANDER-FEEDBACK aus der ersten Selbst-Einrichtung — wo steht was
Seine neun Punkte, damit nichts durchfällt. „→ Punkt N" verweist auf die
Prioritätenliste darunter.
| Sein Punkt | Stand |
|---|---|
| 1. „Bei den Presets soll IMMER das Beste ausgewählt werden" | **offen → Punkt 2** (hängt an der Preset-Liste vom Worker) |
| 2. „Hier steht noch meine Settings drin, muss verallgemeinert werden" | **erledigt** (`fe12401`, vier Platzhalter) |
| 3. „Vektorbefehle müssen ausgelesen werden" | **erledigt** (`fe12401`, avx512f auf seinem PC gemessen) |
| 3b. „…wenn der Worker AV1 oder besseres kann, immer diesem empfehlen" | **offen → Punkt 2** |
| 3c. „…Warnung verschwindet / orange zu grün" | **erledigt**`schwacheEncoderCpu()` schaltet ab, sobald SIMD schnell ODER Hardware da ist. Wirkt für seinen PC ab dem Deploy |
| 4. „JIKAN ist drin — wird das genutzt?" | **beantwortet: ja.** Kette TMDB → Jikan → OMDb, Gate 0,55. Nebenbefund → Punkt 7 |
| 5. „Was wenn der Unterbau nicht Debian ist?" | **offen → Punkt 5** |
| 6. „Mein PC hat ne dicke Grafikkarte (RX 9070 XT), NVIDIA auch möglich" | **Erkennung erledigt** (`fe12401``vce`/`nvenc`/`qsv` getrennt, Hardware-AV1 eigene Kennung). **Benutzen** hängt an **Punkt 1** (Pfad-Mapping) **und Punkt 2** (Preset-Namen) — beides offen |
| 7. „Warum wird der Datei-Browser noch angezeigt" | **offen → Punkt 4** |
| 8. „Server Status muss dringend überarbeitet werden" | **offen → Punkt 3** |
| 9. „ETA im externen Worker und im Dashboard" | **offen → Punkt 3** |
Drei erledigt, eine Frage beantwortet, fünf offen — und die fünf sind unten
priorisiert.
### OFFENE PUNKTE — nach Priorität, mit Kontext
**1. `RIPPY_PATH_MAP` setzen (DER BLOCKER).** Der Installer muss fragen „wie
erreicht dieser PC die Freigabe?" und den Wert in `start-tray.bat` /
`start-worker.bat` schreiben. Format laut `pfad_lokal()`:
`/app/media=\\NAS\rippy;/app/temp=…` (Paare per `;`, Präfix-Ersetzung, wandelt
`/` zu `\`, wenn das Ziel Backslashes enthält). Commander-Vorgabe dazu: *„Wenn
ein externes Ziel für den Speicher eingehängt ist, soll Rippy das ausgewählte als
Arbeitsziel verwenden … das muss auch für den Automatik-Modus so sein."* Punkt 2
oben erfüllt die Einstellungs-Seite davon; es fehlt die Worker-Seite.
Danach: Commander kann seinen 80-GB-Rohschnitt per „Neu komprimieren" auf dem
Ryzen laufen lassen.
**2. Preset-Liste vom Worker holen** — Voraussetzung für „immer das Beste".
Commander: *„Bei den Presets soll IMMER das Beste ausgewählt werden"* und *„wenn
der Worker AV1 oder noch besseres kann, immer diesem empfehlen, die Warnung
verschwindet oder wird von orange zu grün"*. Blocker: Die Namen der
HandBrake-**Hardware**-Presets sind auf der Rippy-VM **nicht ermittelbar** (deren
HandBrake hat keinen Hardware-Encoder), und Erfinden verstößt gegen AGENTS
Regel D — das Projekt hat das zweimal teuer bezahlt. Richtige Lösung: `caps.py`
meldet `HandBrakeCLI --preset-list` des jeweiligen Workers, das UI bietet genau
diese an. Beendet das Raten dauerhaft.
**3. Dashboard + ETA.** Commander: *„Der Server Status muss dringend überarbeitet
werden, das Dashboard soll ja quasi alles auf einen Blick zeigen"* — „Auslastung:
0 % (Aktiv)" ist nichtssagend. Dazu eine **ETA** im Dashboard und beim externen
Worker. Restzeit ist aus dem Fortschrittsverlauf schätzbar; ehrlich bleiben,
solange die Datenlage dünn ist. Verlässlichster Messpunkt (in dieser Sitzung
bewährt): Leseposition im Quellstrom über `/proc/<pid>/fdinfo/`.
**4. Datei-Browser aus dem Rip-Dialog.** Commander: *„Warum wird hier der Datei
Browser noch angezeigt — das ist doch quatsch"*. Prüfen, ob wirklich redundant
(darüber gibt es Schnellwahl + Arbeitsverzeichnis-Auswahl), dann raus oder
einklappen. Datei: `docker/ui/src/components/RipTargetModal.tsx`.
**5. Nicht-Debian-Hosts.** `install.sh` nennt bei fehlendem Compose nur
`sudo apt install docker-compose-plugin`; auf Arch/Fedora/openSUSE falsch.
Paketmanager erkennen. Klarstellen: der Worker-**Container** ist unabhängig vom
Host-System.
**6. Warnung im Rip-Dialog** (nicht nur in den Einstellungen), wenn der gewählte
externe Encoder die Pfade nicht erreicht — vor dem Start, nicht nach einer Stunde.
Bausteine liegen: `caps.py` meldet `extern` und `pfad_map`.
**7. Jikan-Reihenfolge prüfen** (Nebenbefund). Kette ist TMDB → **Jikan** → OMDb
mit Gate `MINDEST_AEHNLICHKEIT = 0.55`. Jikan wird also wirklich benutzt (Antwort
auf die Commander-Frage), läuft aber **vor** OMDb: bei einem Nicht-Anime, den
TMDB verpasst, antwortet zuerst eine Anime-Datenbank. 0,55 ist großzügig.
**8. Aus dem ARM-Vergleich, nicht gebaut:** ISO-Sicherung für Datenträger, die
weder Film noch Audio-CD sind (daran scheitert Rippy heute), und **mehrere
Laufwerke gleichzeitig** — Rippy sieht sie (`/dev/sr[0-9]*`), ob parallel gerippt
wird, ist **unbewiesen**.
**9. Ältere offene Punkte, unverändert gültig:**
- „Job aus der Liste entfernen" sagt nicht, wie viel daneben liegen bleibt — der
Rohschnitt verwaist unsichtbar (v3.14).
- Discord-Webhook und MakeMKV-Key liegen im Klartext in der settings-Tabelle.
- **VM-CPU-Typ steht auf `qemu64`** → `host` würde AVX2 freischalten, 24×
schneller. Anleitung steht in der README („Rippy schneller machen").
- **`install.sh` ist nie als root durchgelaufen** — Verzeichnisse anlegen,
`mount --make-rshared` und die systemd-Unit sind ungetestet.
- Zwei API-Tests laufen nur in der Ampel (`test_api_smoke.py` überspringt sich
unter Windows selbst).
### FALLEN, die diese Sitzung gekostet haben
- **Deutsche Anführungszeichen in DOPPELT gequoteten Python-Strings** beenden den
String vorzeitig. Dreimal hineingetappt. Der Bestand nutzt dafür **einfach**
gequotete f-Strings (`f'…„{x}"…'`). In dreifach gequoteten Docstrings ist es
unproblematisch.
- **In f-Strings steht in `{...}` CODE, kein Text.** Ein Umlaut-Konverter machte
aus `f"{groesse}"` ein `f"{größe}"`, während die Variable `groesse` hieß —
Ruff fand es als F821.
- **`docker exec` ohne `sh -c` expandiert kein `*`** — ein `du -sh /pfad/*`
liefert dann still nichts und sieht wie „leer" aus.
- **Eine `/proc`-Suche nach „HandBrake" trifft die eigene Shell mit**, weil deren
Kommandozeile das Wort enthält. Zwei Fehlalarme.
- **`pydantic-core` springt lokal wiederholt auf 2.47.0** und bricht damit das
Einsammeln ALLER API-Tests (`SystemError`). Fix:
`python -m pip install "pydantic-core==2.46.4"`. Zweimal nötig gewesen.
---
## Vorheriger Stand: v3.15 — Aufräum-Runde: Tempo, tote Routen, 4K-Entscheidung (25.07.2026, 20:00)
> **Deployt und live gegengeprüft.** Auftrag war „bau alles so um, dass es
> sauber ist" mit fünf Vorgaben: saubere Codebase, selbsterklärendes UI,
> externe Worker, kein Laggen, idiotensicher. Was noch offen ist, steht unten.
### Das „Laggen" hatte genau eine Ursache — gemessen und behoben
Über alle 15 Endpunkte gemessen, die das UI beim Laden braucht:
| Endpunkt | vorher | nachher |
|---|---|---|
| `/capabilities` | **1,010 s** | **0,003 s** |
| `/system/updates` | 0,491 s | unverändert (hängt am Knopf) |
| `/metadata/status` | 0,412 s | unverändert (hängt am Knopf) |
| die anderen 12 | < 0,025 s | < 0,010 s |
Nur `/capabilities` schlug beim **Seitenaufbau** zu — und fünf Stellen holen
ihn (Dashboard, Einstellungen, Worker-Tab, Wizard, Rip-Dialog). Jede Seite
zahlte eine Sekunde. Ursache ist kein Fehler, sondern das Wesen des
Celery-Pings: er sammelt Antworten bis zum Timeout und kann nicht früher
aufhören. Den Timeout zu kürzen würde Antworten langsamer Remote-Worker
verschlucken — also genau die Maschinen, um die es beim externen Encoding geht.
Jetzt pingt eine Hintergrund-Schleife im 5-s-Takt, der Endpunkt liest ab. Ist
der Vorrat älter als 30 s, wird einmal synchron gepingt: lieber langsam als
falsch („alles offline", obwohl alles läuft). Kompletter Dashboard-Aufbau:
**1,03 s → 0,028 s.**
### 4K ist entschieden — Kompression je Disc-Typ abwählbar
Die offene Frage aus v3.14 ist gebaut. Bisher gab es nur `transcodeEnabled`:
alles oder nichts. Jetzt kann das Preset eines Disc-Typs auf den Reservewert
`keine` stehen → die verlustfreie Datei bleibt stehen. Damit ist die sinnvolle
Einstellung für diese Maschine erstmals möglich: **4K verlustfrei behalten,
DVD und Blu-ray weiter schrumpfen.**
`komprimieren_fuer()` ist die neue reine Funktion; `preset_fuer()` überspringt
den Reservewert bewusst und gibt ihn NIE als Preset-Namen zurück — sonst bekäme
HandBrake `--preset keine`. Gegengeprüft: keiner der 90 echten Presets aus
`--preset-list` heißt so, und die fünf im UI angebotenen Namen existieren alle.
### Der Wizard läuft nicht mehr in die 4K-Falle
Er hatte die Zahlen längst vorliegen (`/capabilities` meldet `cpu_simd` und
`cpu_kerne`) — benutzt hat er sie nicht und H.265 als Standard vorgeschlagen.
Auf einer CPU ohne AVX2 sind das ein bis zwei Tage pro 4K-Film.
Jetzt entscheidet die **gemessene** Leistung: schwache CPU → 4K nicht
komprimieren, Blu-ray/DVD auf H.264. Stark oder Hardware-Encoder → H.265
durchgehend. Und er schreibt **alle vier** Preset-Felder statt nur des
allgemeinen; vorher fiel 4K auf ein 1080p-Preset zurück.
Dazu gehärtet: Kasten „Was Rippy gerade sieht" (Laufwerk, Worker mit
Kernen/SIMD, freier Platz) — jede Zeile mit **Handlungsanweisung** statt nur
einem Kreuz. Lädt parallel und wiederholt im 5-s-Takt, weil der Worker beim
ersten Start noch hochläuft. API-Keys sind sichtbar statt Punkte (kopierte
Keys, keine Passwörter — Tippfehler sieht man in Punkten nicht) und werden
direkt nach dem Speichern geprüft, mit der Wahl „Key korrigieren" oder
„Trotzdem fertigstellen".
**Gewarnt wird nur, wenn es belegt ist:** `schwacheEncoderCpu()` verlangt
mindestens einen Worker mit BEKANNTER SIMD-Stufe und keinen mit
Hardware-Encoder. Ein Windows-Worker meldet `unbekannt` (kein `/proc/cpuinfo`)
→ dann wird geschwiegen statt falsch gewarnt.
### Vier tote Routen raus
`POST /prescan`, `POST /jellyfin/format` (+ `nfo_generator.py` und
`image_downloader.py`, die sonst nichts nutzte), `GET /stream/jobs`,
`GET /worker-setup/windows-gui`. Jede ein Überrest eines ersetzten Entwurfs,
keine mit Aufrufer. `main.py`: 1726 → 1682 Zeilen, dazu 279 Zeilen in zwei
gelöschten Modulen. Tests halten beide Seiten fest: die vier müssen WEG
bleiben, die drei für die Worker-Installation (`/worker-setup/paket`,
`/windows`, `/windows-exe`) müssen DA sein.
### Externe Worker — vorbereitet, vom Commander zu testen
Auf **Windows gegengeprüft** (dieser PC): `cpu_kerne` 16 und CPU-Modell kommen
korrekt durch, `encoders` ist ohne installiertes HandBrake korrekt **leer**,
`cpu_simd` ehrlich `unbekannt` → keine falsche Warnung. Ein GPU-Worker braucht
ein HandBrake-Build mit `nvenc_*`/`qsv_*`; die neue Anzeige nennt die
ungefilterte HandBrake-Auskunft, damit das nachprüfbar ist.
### ARM-Vergleich (Vorbild-Projekt)
Fast alles, was ARM automatisch macht, macht Rippy schon — und meist
gründlicher: Metadaten aus drei Quellen statt nur OMDb, Episoden-Erkennung per
Laufzeitabgleich, Kompression auf eine eigene Queue routbar. Bezeichnend:
ARMs Auto-Auswurf war bei Rippy nur **behauptet** und ist erst in v3.14 echt
geworden. Zwei Lücken bleiben, beide **nicht gebaut, als Vorschlag**:
- **ISO-Sicherung** für Datenträger, die weder Film noch Audio-CD sind — daran
scheitert Rippy heute.
- **Mehrere Laufwerke gleichzeitig.** Rippy sieht sie (`/dev/sr[0-9]*`), ob
parallel gerippt wird, ist **unbewiesen** — mit einem Laufwerk nicht testbar.
### Installation: ein Befehl statt Checkliste (`install.sh`)
Commander-Rückmeldung: „Das Docker Deployment ist mir zu kompliziert." Zu
Recht — es war eine Sechs-Schritte-Checkliste, von der zwei Punkte Fachwissen
verlangten. Jetzt:
```bash
git clone <repo-url> rippy && cd rippy
sudo ./install.sh
```
Der schlimmste Punkt war das **Laufwerk**: MakeMKV braucht ZWEI Geräteknoten,
und die sg-Nummer ist je Host anders. Das Skript gleicht sie über die
SCSI-Adresse in `/sys` ab statt zu raten — auf der VM gegengeprüft
(`sr0 → 3:0:0:0`, `sg1 → 3:0:0:0` = dasselbe Gerät, korrekt erkannt). Dazu:
Verzeichnisse, Mount-Propagation **inklusive neustart-fester systemd-Unit**
(vorher stand in der README nur „reboot-fest persistieren", ohne zu sagen wie —
nach einem Reboot scheiterte das NAS-Einhängen aus dem UI stillschweigend),
`.env` schreiben ohne Bestehendes zu überschreiben, bauen, starten.
`./install.sh --nur-pruefen` sieht nur nach. Wiederholbar, damit auch der
Update-Weg: `git pull && sudo ./install.sh`.
**Zwei Fallgruben fielen beim Testen auf, beide meine eigenen:**
- `--nur-pruefen` verlangte root und brach ab — ein Prüf-Modus, der nichts
ändert, darf daran nicht scheitern.
- `.env.example` hatte `OPTICAL_SG=/dev/sg1` **unkommentiert** vorbelegt. Der
Installer hätte den erkannten Wert deshalb nicht eingetragen („steht schon
drin") und auf jedem fremden Host still eine kaputte Konfiguration
hinterlassen — genau das, was er verhindern soll. Beide Gerätezeilen sind
jetzt auskommentiert (Compose hat ohnehin Vorgaben), plus eine Gegenprobe:
zeigt ein wirksamer Wert auf ein Gerät, das es hier nicht gibt („`.env` von
einem anderen Rechner"), wird das benannt statt kryptisch von Docker gemeldet.
Die `.env`-Logik ist in drei Fällen auf der VM geprüft: frische Datei bekommt
den erkannten Wert, ein selbst gesetzter Wert bleibt unangetastet, zweimal
ausführen erzeugt genau eine Zeile.
### README neu aufgebaut
Vorher 293 Zeilen, in denen der Schnellstart zwischen `lsscsi`, sg-Knoten,
USB-Passthrough und `mount --make-rshared` begraben war. Jetzt: Installation in
zwei Zeilen oben, dann die Tabelle „Wenn etwas nicht geht" mit den vier Fällen,
die praktisch alles abdecken. Alles Technische darunter in aufklappbaren
Abschnitten, inklusive der Handarbeits-Variante.
**Neu und ausdrücklich gewünscht: „Rippy schneller machen"** — der VM-CPU-Typ.
Warum Virtualisierer eine generische CPU ohne AVX2 geben, was das kostet
(gemessene 2855 h je 4K-Film), die genauen Proxmox-Schritte (herunterfahren →
Hardware → Processors → Type auf `host` → starten, bzw. `qm set <vmid> --cpu
host`), **warum ein Neustart von innen nicht genügt**, und wie man nachprüft:
Rippy zeigt die Vektorbefehle selbst an. Dazu der Nachteil (keine
Live-Migration auf andere CPUs) und die Alternative `x86-64-v3` für Cluster.
### Der MakeMKV-„Fallback" auf der VM war seit Monaten tot
Beim Aufräumen der VM-`.env` aufgefallen und nachgemessen (25.07.2026):
| Quelle | Antwort |
|---|---|
| `https://www.makemkv.com/download` (Repo-Standard) | **HTTP 200** |
| `https://www.makemkv.com/download/old` | HTTP 525 (Cloudflare) |
| web.archive.org-Schnappschuss **aus der VM-`.env`** | **HTTP 404** |
Die VM zeigte also auf eine **kaputte** Adresse. Aufgefallen ist es nie, weil
dort die `vendor/`-Tarballs liegen und der Download-Zweig gar nicht erreicht
wird. Nimm die Tarballs weg, und jeder Bau scheitert mit 404 — während die
Konfiguration gesund aussieht. Genau diese Sorte Fehler ist bei einer Übergabe
teuer.
**Bereinigt** (Sicherung liegt als `.env.sicherung-vor-aufraeumen-20260725`):
`MAKEMKV_URL_BASE` raus → es gilt der Standard, der liefert. `JWT_SECRET_KEY`
raus → Überrest der in v3.4 ausgebauten Anmeldung, wirkungslos.
**Gebaut — Rückfall dreistufig und ehrlich:** `vendor/`-Tarballs →
`MAKEMKV_URL_BASE``MAKEMKV_URL_FALLBACK` (neu, wird automatisch versucht).
Der Fallback ist **absichtlich leer vorbelegt**: Es gibt derzeit keine belegbare
zweite Quelle, und eine einzutragen, die nicht liefert, wäre schlimmer als
keine — siehe oben. Scheitert alles, nennt die Fehlermeldung jetzt die beiden
Wege, die funktionieren (Tarballs nach `vendor/`, oder eigene Quelle als
`MAKEMKV_URL_FALLBACK`), statt nur einen curl-Rückgabewert zu hinterlassen.
### NOCH OFFEN
1. **Externe Worker im Praxistest** (Commander).
2. **ISO-Sicherung und Mehr-Laufwerk-Betrieb** — siehe ARM-Vergleich.
3. **„Job aus der Liste entfernen" sagt nicht, wie viel daneben liegen bleibt**
(v3.14). Der Rohschnitt verwaist dabei unsichtbar.
4. **Discord-Webhook und MakeMKV-Key liegen im Klartext** in der
settings-Tabelle. Für Heimnetz erwartbar, aber der Webhook ist eine echte
Zugangsberechtigung.
5. **Der VM-CPU-Typ steht auf `qemu64`.** `host` würde AVX2 freischalten und
jeden Software-Encode 24× beschleunigen. Ein VM-Neustart, nicht gemacht —
**die Anleitung steht jetzt in der README** („Rippy schneller machen").
6. **`install.sh` ist nie als root durchgelaufen.** Auf dieser VM sind alle
root-Schritte No-Ops (Verzeichnisse da, Propagation schon `shared`), und
`sudo` verlangt hier ein Passwort. Bewiesen sind Laufwerkserkennung,
`.env`-Logik (drei Fälle) und Syntax; **ungetestet bleiben das Anlegen der
Verzeichnisse, `mount --make-rshared` und die systemd-Unit** — die laufen
erst bei einer echten Neuinstallation.
---
## Vorheriger Stand: v3.14 — Durchsicht: vier Placebos und ein unwirksamer Schutz (25.07.2026, 18:40)
> **Stand 19:30: alles committet, Ampel grün (`a1aabd5`), DEPLOYT und live
> gegengeprüft.** Der Akira-Encode wurde um 18:31 abgebrochen (Nachtrag im
> Block darunter), danach der Job aus der Liste gelöscht und die Rohdaten
> entsorgt. Es läuft nichts, die Platte hat **111 GB frei**.
> Was noch zu entscheiden ist, steht unten unter „NOCH OFFEN".
### DER WICHTIGSTE FUND: der Platten-Schutz aus `c065967` greift nicht
`_original_aufheben()` entschied per `os.stat().st_dev`, ob umgehängt oder
kopiert werden muss. Auf der VM gemessen — **beides gleichzeitig wahr**:
```
st_dev /app/temp = 2050
st_dev /app/media = 2050 → identisch
os.rename(...) → EXDEV, "Invalid cross-device link"
```
Der Kernel vergleicht bei `rename()` den **Mount**, nicht das Gerät.
`/app/temp` (Docker-Volume) und `/app/media` (Bind-Mount) sind zwei Mounts
DERSELBEN ext4-Partition. Die Prüfung sah deshalb „gleiches Dateisystem",
**übersprang die Platzprüfung**, und `shutil.move` kopierte doch — 75 GB bei
37 GB frei. Der Schutz hätte genau den Schaden zugelassen, gegen den er
gebaut wurde.
**Behoben:** `os.rename` wird jetzt VERSUCHT statt vorhergesagt. Klappt es,
ist es umgehängt. Kommt EXDEV, steht die Kopie fest — und erst dann wird der
Platz geprüft. Vier Tests dazu (`test_original_aufheben.py`), inklusive des
Falls, der die Platte füllte.
### GEMESSEN, Stand 18:30 (alles per Befehl auf der VM geprüft)
- **Container:** alle 5 `Up`, api/postgres/redis `healthy`. api/ui/worker seit
18:00:39 (Deploy von `8bb075c`), postgres/redis älter.
- **Platte:** 148 G, 105 G belegt, 37 G frei = 75 G Rohschnitt + 4,9 G Medien
(Evangelion) + ~25 G System. **Keine Kopier-Reste**, kein `original`-Ordner.
- **Akira-Job:** `failed`, „Abgebrochen durch Nutzer" (Abbruch 18:30:17
angefordert, Worker bestätigt 18:33:38 → **3,4 Minuten Verzug**, siehe unten).
Es läuft **kein** HandBrake mehr (per `/proc` geprüft, Stand 19:06).
- **Der Encode war bei 1,44 %**, gemessen an der Leseposition im Quellstrom
(`/proc/<pid>/fdinfo/3`: 1.145.940.149 von 79.604.951.639 Bytes) — exakter
als jede Fortschrittsanzeige. Zwei Tempo-Fenster ergaben 395 und 784 KB/s
**2855 h** für den Film, bei 3,84 von 4 gesättigten Kernen.
- **Achtung bei Prozess-Suchen per `/proc`:** Ein `case "$c" in *HandBrake*)`
trifft die eigene Shell mit, weil deren Kommandozeile das Wort enthält. Zwei
Fehlalarme in dieser Sitzung. Immer die PID gegenprüfen.
- **Die Ursache dafür ist neu und behebbar:** Die VM läuft auf dem generischen
QEMU-CPU-Modell (`QEMU Virtual CPU version 2.5+`), `grep -c avx2
/proc/cpuinfo` = **0**, nur bis `sse4_2`. x265 lebt von AVX2. Abhilfe:
CPU-Typ von VM 106 in Proxmox auf `host` stellen (braucht VM-Neustart).
- **HandBrake im Worker-Image kann:** `svt_av1`, `x264`, `x265` (je 10/12-bit),
`mpeg4/2`, `VP8/9`, `theora` — und **keinen einzigen Hardware-Encoder**.
- **Ampel grün** für `b526a0a` und `8bb075c` (Gitea-API abgefragt).
### GEBAUT — vier Placebos entfernt bzw. echt gemacht
1. **Fortschritt log statt Wahrheit.** `get_progress_from_line` matchte jede
Zahl vor einem `%` — also auch HandBrakes **Scan-Durchlauf**, der VOR dem
Encodieren bis 100 % hochläuft. Dazu warf `if progress > 0` alle echten
Werte unter 1,00 % weg. Ergebnis: Anzeige klebte stundenlang auf 99 %.
Jetzt wird nur die `Encoding:`-Zeile gelesen, `task N of M` mitgerechnet,
und **-1 heißt „keine Angabe"** (Muster von `get_progress_from_prgv`).
Formatstrings aus dem Binary gelesen, nicht geraten.
**⚠️ Richtigstellung zu v3.13:** Dort steht, `progress=99` sei ein „Altwert
aus dem Absturz". Falsch — beide Startpfade setzen auf 0, der Wert war
frisch vom Scan-Durchlauf geschrieben. Es war ein Bug, kein Überrest.
2. **„Automatischer Auswurf" tat nichts.** Die Einstellung (Standard: ein)
wurde von niemandem gelesen: DVD/Blu-ray warfen **nie** aus, Audio-CDs
**immer**, weil abcde `-x` fest verdrahtet bekam. Jetzt entscheidet die
Einstellung beides (`wirf_disc_aus()` per CDROMEJECT-ioctl, Linux-guarded).
3. **„Alle Tracks rippen" konnte nichts bewirken** — abcde bekommt keine
Track-Auswahl und es gibt keine Oberfläche dafür. Schalter entfernt, an
seiner Stelle steht jetzt die Wahrheit („wird immer vollständig gerippt").
4. **Encoder-Auslese behauptete statt zu messen.** `cpu-x264`/`cpu-x265`
standen fest verdrahtet drin („immer dabei") — ein Rip-Worker **ohne**
HandBrake behauptete damit, komprimieren zu können. Und `vaapi` wurde
allein wegen `/dev/dri` gemeldet, ohne zu prüfen, ob HandBrake das kann
(dieses Image kann es nicht). Jetzt aus `HandBrakeCLI --help` geparst, plus
**CPU-Modell, Kernzahl und Vektorbefehlsstufe** je Worker — mit sichtbarer
Warnung im UI, wenn AVX2 fehlt. Genau die Angabe, deren Fehlen den
55-Stunden-Encode unsichtbar machte.
### GEBAUT — Zombie-Erkennung (v3.13 Punkt 2 abgearbeitet)
`zombies.py`: Beim Worker-Start werden Jobs, die auf `ripping`/`transcoding`/
`canceling` stehen, gegen Celerys `active`/`reserved`/`scheduled` gehalten und
ehrlich auf `failed` gesetzt, wenn niemand daran arbeitet. Drei Sicherungen,
weil ein falsch getöteter Job teurer ist als eine stehende Leiche:
- **nur beim Start** (da ist „es lief nichts" eindeutig),
- **120 s Gnadenfrist** (Celery stellt unbestätigte Aufgaben erneut zu),
- **Vollzähligkeit**: antworten weniger Knoten als laut Herzschlag online
sind, wird NICHTS gewertet — sonst wäre der laufende Job eines
beschäftigten Remote-Workers eine falsche Leiche.
13 Tests, unter anderem: „laufender Job wird nicht angetastet" und
„schweigender Worker verhindert jedes Urteil".
### GEBAUT — Pfad-Prüfung gehärtet
Elf Stellen prüften mit nacktem `startswith(MEDIA_ROOT)`. `/app/media-boese/x`
beginnt mit `/app/media`, liegt aber außerhalb — betroffen waren auch `/browse`
und `/browse/mkdir`, wo der Pfad vom Nutzer kommt. Neuer Zwillings-Helfer
`unter_wurzel()` in `api/main.py` und `worker/tasks.py`, alle elf Stellen
umgestellt, Tests in beiden.
Nebenbefund dabei: `_zielbasis()` benutzte `os.path.normpath` — unter Windows
werden daraus Backslashes und die Prüfung greift nicht mehr; das gewählte Ziel
wäre still auf den Standard zurückgefallen. Genau die Falle, die
`_arbeitsverzeichnis()` drei Zeilen weiter dokumentiert und mit `posixpath`
vermeidet. Live war es nie (nur aus `rip_disc`, das auf Windows verriegelt
ist), jetzt konsistent.
### GEFUNDEN, NICHT ANGETASTET — vier tote Endpunkte
Nirgends im UI referenziert (mechanisch gegengeprüft: alle `api.*`-Aufrufe
gegen alle Routen):
| Endpunkt | Lage |
|---|---|
| `POST /jellyfin/format` | ersetzt durch `medien.py` im Worker; zieht `nfo_generator.py` + `image_downloader.py` in der API mit, die sonst niemand nutzt |
| `POST /prescan` | ohne Aufrufer (die `PreScan`-Klasse selbst wird woanders sehr wohl gebraucht) |
| `GET /stream/jobs` | niemand konsumiert ihn; der Kommentar behauptet einen „Fix 23.07.", der ihn funktionsfähig machte — gebraucht wird er trotzdem nicht |
| `GET /worker-setup/windows-gui` | seit der `.exe` (v3.9) unreferenziert |
Bewusst nicht entfernt: `test_api_smoke.py` prüft `/prescan` als verdrahtete
Route, und Entfernen ist eine Entscheidung, keine Reparatur. **Der Commander
entscheidet.**
### NOCH OFFEN / EHRLICH UNGEKLÄRT
- **Was die Platte am 25.07. mittags füllte, ist nicht belegt.** Es gibt keine
Kopier-Reste, kein `original`-Verzeichnis und **keinen einzigen Log-Eintrag
von `_original_aufheben`** — das loggt in beiden Zweigen. Zwischen 10:21:50
und 17:49 steht überhaupt nichts im Log. Der EXDEV-Mechanismus ist jetzt
belegt (siehe oben), dass er DAMALS zuschlug, ist es nicht.
- **`tracks:/dev/sr0` meldet `{"status":"done","tracks":[]}`** — null Titel für
eine Disc, die MakeMKV mit `TCOUNT:5` öffnet. Nicht weiter verfolgt.
- **Der Discord-Webhook und der MakeMKV-Key liegen im Klartext** in der
settings-Tabelle. Für Heimnetz erwartbar, aber der Webhook ist eine echte
Zugangsberechtigung.
- **Zwei neue API-Tests laufen nur in der Ampel** — `test_api_smoke.py`
überspringt sich unter Windows selbst (`main.py` braucht `fcntl`).
### GEBAUT — „Abbrechen" wirkt jetzt sofort
Der Fund aus dem Nachtrag (Commander, am laufenden Job beobachtet) ist behoben.
Ursache war genau wie dort beschrieben: Der Abbruch wurde nur in
`datei_fortschritt` geprüft, und diese Closure stieg oben sofort wieder aus,
wenn sich die Prozentzahl nicht geändert hatte. Bei einem Prozent je halber
Stunde sah „Abbrechen" entsprechend lange wirkungslos aus.
Jetzt gibt es in `run_handbrake` einen **eigenen Abbruch-Kanal** neben dem
Fortschritts-Callback — dasselbe Muster, das `run_makemkv` schon für `log_cb`
benutzt, und aus demselben Grund. Er wird bei JEDER Ausgabezeile aufgerufen
und ist im Worker auf 5 Sekunden gedrosselt (`ABBRUCH_INTERVALL_SEKUNDEN`).
Nebeneffekt: Er greift auch während des Scan-Durchlaufs, der überhaupt keine
Encode-Prozente liefert — dort war ein Abbruch vorher grundsätzlich unmöglich.
Die Leseschleife ist als `_handbrake_schleife()` herausgezogen, damit die
Reihenfolge (Abbruch VOR Fortschritt) ohne echtes HandBrake testbar ist.
### ERLEDIGT UM 19:30 — deployt und live gegengeprüft
`a1aabd5` läuft auf der VM. Belege, nicht Behauptungen:
- **Container:** alle 5 up, api/postgres/redis `healthy`.
- **Die neue Auslese antwortet ehrlich** (`GET /capabilities`):
`cpu-x264, cpu-x265, cpu-av1`**kein Phantom-`vaapi`** mehr, obwohl die
alte Fassung es bei vorhandenem `/dev/dri` gemeldet hätte. Dazu
`cpu_modell: QEMU Virtual CPU version 2.5+`, `cpu_kerne: 4`,
`cpu_simd: sse4_2` → die AVX2-Warnung im UI greift.
- **Zombie-Erkennung:** 19:26:09, exakt 120 s nach Worker-Start,
`{'geprueft': 0, 'aufgeraeumt': []}` — nachgesehen, nichts in Arbeit
gefunden, korrekt nichts angetastet.
- **604 Disc-Schlüssel** haben den Rebuild überlebt.
- **Aufgeräumt:** das unbrauchbare 4K-Fragment (67.633.152 B) gelöscht; der
Akira-Job wurde um 19:07 aus der Liste entfernt, wodurch der 75-GB-Rohschnitt
verwaiste (kein Job-Datensatz zeigte mehr darauf, im UI unsichtbar,
„Neu komprimieren" unmöglich) — auf Entscheid des Commanders gelöscht, samt
vier leerer Alt-Ordner. **Platte: 37 GB → 111 GB frei.**
⚠️ **Merken für die Job-Verwaltung:** „Job aus der Liste entfernen" löscht
bewusst keine Dateien — das ist richtig, macht die Rohdaten aber unerreichbar,
weil beides nur über die Job-ID verbunden ist. Ein Rohschnitt ohne Job-Zeile
taucht nirgends mehr auf. Das UI sollte beim Löschen sagen, wie viel daneben
liegen bleibt. Nicht gebaut.
### NOCH OFFEN
**1. Die UHD-Strategie** (aus dem Nachtrag, unverändert gültig).
Preset-je-Disc-Typ war richtig, reicht aber nicht: Software-HEVC in 4K ist auf
dieser CPU keine Option. Drei Wege, keiner davon gebaut:
- **UHD gar nicht komprimieren** — Roh-MKV behalten. Ehrlichste Variante,
kostet Platz (75100 GB je Film, gehört dann auf die NAS).
- **Hardware-Encoder** — Remote-Worker mit GPU (`nvenc`/`vaapi`). Rippy kann
das schon routen (bewiesen v3.7); es fehlt die Maschine. Achtung: Das
Worker-Image kann selbst keinen Hardware-Encoder, ein GPU-Worker braucht ein
HandBrake-Build mit `nvenc_*`/`qsv_*` — die neue Anzeige sagt das jetzt.
- **CPU-Typ der VM auf `host`** — schaltet AVX2 frei, bringt bei x265 typisch
Faktor 24. Aus 2855 h werden damit aber immer noch Stunden bis Tage; das
allein löst 4K nicht, hilft aber jedem 1080p-Encode.
**2. Vier tote Endpunkte** — entfernen oder behalten (Tabelle oben). Alle vier
sind Überreste eines ersetzten Entwurfs: `/jellyfin/format` (die Arbeit macht
seit v3.2 `medien.py` im Worker), `/prescan` (Metadaten-Seite ist seit v3.4
weg; die `PreScan`-Klasse selbst wird sehr wohl gebraucht), `/stream/jobs`
(kein `EventSource` im UI — das Dashboard pollt `setInterval(…, 4000)`) und
`/worker-setup/windows-gui` (seit der `.exe` in v3.9 ohne Aufrufer; die Datei
steckt weiter in der `.exe`). **`/stream/jobs` ist der unangenehmste:** keine
harmlose Leiche, sondern eine Endlosschleife je Verbindung, die jeder erreichen
kann. Empfehlung: alle vier raus, plus `nfo_generator.py` und
`image_downloader.py` in der API (nutzt sonst nichts) — und `test_api_smoke.py`
prüft `/prescan` als verdrahtete Route, der Test muss also mit.
**3. Akira liegt jetzt gar nicht mehr vor.** Fragment und Rohschnitt sind
gelöscht (siehe oben). Wer den Film will, legt die Disc neu ein — der
Schlüsselspeicher steht (604 Schlüssel), der Rip dauert rund eine Stunde. Vor
dem nächsten UHD-Versuch aber Punkt 1 entscheiden, sonst läuft dasselbe
50-Stunden-Rennen wieder an.
---
## Vorheriger Stand: v3.13 — ÜBERGABE (25.07.2026, 18:05)
> Dieser Block ist eine Übergabe an die nächste Sitzung. Er trennt bewusst
> **gemessen** von **vermutet** — in dieser Sitzung wurden zwei Behauptungen
> aufgestellt, die sich als falsch erwiesen (siehe „Vertrauensnotiz" unten).
> Im Zweifel: selbst nachmessen, nicht diesem Dokument glauben.
### GEMESSEN, Stand 18:05 (alles per Befehl auf der VM geprüft)
- **Container:** api/postgres/redis/ui/worker alle `Up`, api + postgres +
redis `healthy`.
- **Platte:** `/dev/sda2` 148 G, 105 G belegt, **37 G frei** (75 %).
- **Läuft gerade:** `HandBrakeCLI --input
/app/temp/raw/73b89777-…/title_t00.mkv --output
"/app/media/movies/Akira (1988)/title_t00.mkv" --preset
"H.265 MKV 2160p60 4K" --all-audio --all-subtitles`
- **Job `73b89777-a808-426f-8159-d406d27d0ec8`:** `status='transcoding'`,
`progress=99` (⚠️ **Altwert aus dem Absturz, NICHT der echte Fortschritt** —
der Lauf hat um 18:02 begonnen), `disc_type='uhd'`,
`output_path='/app/media/movies/Akira (1988)'`.
- **Dateien:** `…/Akira (1988)/title_t00.mkv` ist **0 Bytes** (HandBrake hat
die vorherige 1080p-Fassung beim Start gekürzt und schreibt gerade neu).
Roh-Rip `/app/temp/raw/73b89777-…/` = **75 GB, unversehrt.**
- **Git:** `8bb075c` ist HEAD und deployt. Davor `c065967`, `e84afc1`,
`f449c4e`, `0935766`. Alle Ampeln waren grün.
- **Schlüsselspeicher:** 604 Disc-Schlüssel, `/system/keystore` antwortet.
- **NAS:** `//192.168.178.62/rippy` auf `/srv/rippy/media/rippy` gemountet,
erreichbar, **2311 GB frei**. Wird als Ablageziel gelistet.
### NACHTRAG 18:35 — der 4K-Lauf wurde abgebrochen, zwei neue Befunde
- **4K-HEVC ist auf dieser CPU nicht machbar.** Gemessen: von 18:02 bis 18:31
kam der Lauf von 0 auf **1 %** → hochgerechnet **~50 Stunden** für den Film.
Der Commander hat um 18:31 abgebrochen. **Konsequenz, die noch zu
entscheiden ist:** entweder UHD gar nicht komprimieren (Roh-MKV behalten,
`transcodeEnabled` für UHD aus), oder ein Hardware-Encoder (Remote-Worker
mit GPU, `nvenc`/`vaapi`), oder bewusst bei 1080p bleiben. Das eben
gebaute Preset-je-Disc-Typ ist damit richtig, aber allein nicht genug.
- **Abbruch wirkt verzögert (FEHLER, nicht gebaut).** Job steht seit 18:31 auf
`canceling`, **HandBrake lief um 18:35 immer noch**. Ursache: Der Abbruch
wird nur in `datei_fortschritt` geprüft, und diese Closure läuft nur, wenn
sich die PROZENTZAHL ändert (`tasks.py`, `if gesamt == letzter[0]: return`).
Bei 1 % alle 30 Minuten heißt das: bis zu eine halbe Stunde, in der
„Abbrechen" für den Nutzer wirkungslos aussieht. Der Abbruch muss unabhängig
vom Fortschritt geprüft werden (z. B. zeitgesteuert beim Lesen der
HandBrake-Ausgabe).
- **Die 1080p-Fassung ist weg.** HandBrake hat sie beim Start auf 0 Bytes
gekürzt; aktuell liegen dort ~64 MB 4K-Fragment. Der **75-GB-Rohschnitt ist
unversehrt** — es ist also nichts unwiederbringlich verloren, aber im
Akira-Ordner liegt gerade eine unbrauchbare Datei.
### WAS ALS NÄCHSTES ANSTEHT
1. **Ausgang des 4K-Laufs prüfen.** Erwartung, die noch NICHT bewiesen ist:
Der Job endet **erfolgreich**, und „Original behalten" (Setting steht auf
`true`) meldet nur eine **Warnung**, weil 75 GB nicht neben 37 GB freien
Platz passen. Genau dieser Pfad ist neu (`_original_aufheben` in
`tasks.py`) und in der Praxis noch nie gelaufen. Wenn er hält, ist der
Ausfall von heute Mittag strukturell behoben.
2. **Zombie-Erkennung fehlt (gefunden, NICHT gebaut).** Nach dem Absturz stand
der Job auf `transcoding 96 %`, obwohl weder ein Prozess lief noch etwas in
den Celery-Queues stand (beide `llen` = 0). Folge: keine Anzeige, kein
Download, und der „Neu komprimieren"-Knopf fehlt, weil
`_kann_neu_komprimieren` (main.py) `status == "failed"` verlangt. Der
Worker sollte beim Start solche Leichen erkennen und ehrlich auf `failed`
setzen.
3. **`keepOriginal` steht auf `true`** — von dieser Sitzung gesetzt, um den
4K-Rohschnitt zu retten. Bewusst entscheiden, ob das so bleibt: mit
Arbeitsverzeichnis auf der NAS-Freigabe wäre es ein reines Umhängen statt
einer Vollkopie.
4. **75 GB Rohschnitt** liegen weiter in `/app/temp/raw/73b89777-…`. Nach
erfolgreichem 4K-Lauf entscheiden, ob er weg kann.
### VERTRAUENSNOTIZ — zwei Fehlschlüsse dieser Sitzung
- **„MakeMKVs Schlüssel-Kanal ist abgeschaltet"** — falsch. Stützte sich auf
zwei tote Hostnamen aus alten Forumsbeiträgen, die MakeMKV längst nicht mehr
benutzt. Richtig: `makemkvcon` unter **Linux** fragt nie, die
**Windows**-Version schon. Korrigiert in v3.11.
- **„Celery hat die Aufgabe nach dem Deploy erneut zugestellt"** — falsch.
Aus dem Status `transcoding` geraten, ohne Prozesse oder Queues zu prüfen.
Es war ein Zombie-Eintrag (Punkt 2 oben).
Beide Male war das Muster dasselbe: **aus einem Zustandswert auf einen
Mechanismus geschlossen, statt den Mechanismus zu messen.**
---
## Vorheriger Stand: v3.12 — Preset je Disc-Typ (25.07.2026)
- **Der Fund:** Beim ersten echten UHD-Rip aufgefallen — die Kompression
fragte den Disc-Typ **gar nicht**: `preset = einstellungen.get(
"transcodePreset")`, ein globales Preset für alles. Live eingestellt war
`HQ 1080p30 Surround`. Der laufende Akira-Rip wäre also verlustfrei in 4K
gerippt und danach **auf 1080p heruntergerechnet** worden — und mit
`keepOriginal: False` wäre der 4K-Rohschnitt anschließend gelöscht worden.
Aufgefallen ist es nur, weil der Commander gefragt hat, ob Rippy das
UHD-Preset automatisch nimmt.
- **Sofortmaßnahme am laufenden Job:** `keepOriginal` auf `True` gesetzt
(nur dieses eine Feld, gegengeprüft: kein anderer Schlüssel verändert).
Damit überlebt der 4K-Rohschnitt die Kompression auf jeden Fall.
- **Gebaut:** `preset_fuer(disc_type, einstellungen)` in `ripping.py` (pure,
getestet) plus drei Einstellungen `transcodePresetDvd` / `…Bluray` /
`…Uhd`. Reihenfolge: Preset des Disc-Typs → allgemeines
`transcodePreset` → `DEFAULT_HB_PRESET`. **Bestandsinstallationen ändern
ihr Verhalten nicht**, solange die neuen Felder nicht gespeichert sind.
`transcode_files` holt den Disc-Typ aus dem Job-Datensatz und schreibt ihn
mit ins Log.
- **UI (Einstellungen → Verarbeitung):** drei Auswahlfelder statt einem, mit
Klartext dazu, warum 4K auf ein 2160p-Preset gehört. Alle Preset-Namen
stammen aus `HandBrakeCLI --preset-list` im Worker-Image (1.6.1) — nicht
geraten (AGENTS Regel D).
- **⚠️ Deploy bewusst zurückgehalten:** `docker compose up -d --build`
würde den Worker-Container neu erstellen und den **laufenden Akira-Rip
abbrechen**. Erst deployen, wenn der Job durch ist. Der 4K-Rohschnitt ist
durch `keepOriginal` geschützt; danach reicht „Neu komprimieren" im UI,
um mit dem richtigen Preset in 4K zu komprimieren.
- **Nebenbefund:** `ps` gibt es im Worker-Image nicht (python-slim). Frühere
Prüfungen auf laufende Rips per `ps | grep` lieferten deshalb still
„nichts aktiv" — richtig geht es über `/proc`.
---
## Vorheriger Stand: v3.11 — 4K-UHD GELÖST: Akira geht auf (25.07.2026)
- **🎉 Der Durchbruch:** Nach Übernahme des Schlüsselspeichers öffnet
`makemkvcon` auf der VM die Akira-UHD: „Operation successfully
completed", **`TCOUNT:5`**, fünf Titel — identisch zum Windows-Ergebnis.
**Erster belegter UHD-Disc-Zugriff auf der Rippy-Maschine.**
- **Die echte Ursache — und sie ist eine andere als in v3.10:**
`makemkvcon` unter **Linux** ruft Disc-Schlüssel **nie** ab. Die
**Windows**-Version tut es. Gegenprobe mit demselben Laufwerk und
derselben Disc:
| | Linux (Worker) | Windows |
|---|---|---|
| Verbindungen beim Disc-Öffnen | **keine einzige** | 185.84.108.20:443 |
| Meldung 3338 „Downloading latest HK" | nie | ja |
| `_private_data.tar` | 2048 B, **0** Schlüssel | 6,4 MB, **604** Schlüssel |
| Disc | „volume key is unknown" | **geht auf** |
Gegengeprüft mit leerem UND gefülltem Speicher, mit und ohne `--noscan`,
mit `dev:/dev/sr0` und `disc:0`, mit gelöschter `update.conf`. Immer:
keine Verbindung. Die Meldungsvorlage „Downloading latest %1 to %2 ..."
steckt sehr wohl im Linux-Binary — sie löst nur nie aus. Gleiches
Symptom im MakeMKV-Forum, seit Jahren offen (t=25782, t=34022).
- **⚠️ Richtigstellung zu v3.10 (direkt darunter):** Dort steht, MakeMKVs
Schlüssel-Kanal sei abgeschaltet. **Das war falsch.** Die Herleitung
stützte sich auf zwei Hostnamen aus alten Forumsbeiträgen
(`hkdata.fairuse.org`, `hkdata.crabdance.com`), die tatsächlich nicht
mehr auflösen — MakeMKV benutzt sie aber längst nicht mehr. **Der Dienst
lebt, der Worker erreicht ihn sogar** (Verbindungstest auf
185.84.108.20:443 erfolgreich); er wird unter Linux nur nie gefragt.
Aufgedeckt hat das der Commander mit dem Einwand, unter Windows ginge es
sofort. Alles in v3.10 Gebaute bleibt richtig und nötig — nur die
`KEYDB.cfg` ist nicht der Haupt-, sondern der Ersatzweg.
- **Gebaut — Schlüsselspeicher übernehmbar:** `GET`/`POST
/system/keystore` plus die Helfer in `makemkv_daten.py` (beide
Zwillinge). Der Rohkörper der Anfrage IST die Datei — binär, deshalb
kein JSON und kein Base64. Die Prüfung lehnt einen Speicher **ohne**
`hkd_*.bin` ab, sonst lädt jemand den leeren Vorrat einer frischen
Installation hoch und wundert sich, dass nichts passiert.
- **Gebaut — UI:** neuer Block „Disc-Schlüssel für 4K-UHD" **über** dem
KEYDB-Block, mit Schlüssel-Anzahl, Upload und der Schritt-für-Schritt-
Anleitung für den Windows-Umweg. KEYDB.cfg ist jetzt als **Notnagel**
beschriftet. Die Worker-Plakette zeigt die Anzahl bekannter Schlüssel;
`0` heißt sichtbar „4K-UHD scheitert".
- **Gebaut — ehrliche Texte:** Der UHD-Fehlertext nennt jetzt den
Windows-Weg und die Anzahl der Schlüssel dieses Workers. Alle
Falschaussagen korrigiert: UI (3 Stellen), Anleitung (2), README (3),
KONZEPT §8 + §10, Modulkopf von `makemkv_daten.py`, Worker-Dockerfile
und `makemkv_key.py` (dort stand: „Den AACS-Schlüssel zieht MakeMKV via
LibreDrive ohnehin selbst aus dem Laufwerk" — gilt für Blu-ray, NICHT
für UHD).
- **So hältst du den Vorrat aktuell:** Laufwerk an den Windows-PC, Disc in
MakeMKV öffnen, dann `_private_data.tar` aus dem MakeMKV-Datenverzeichnis
(*Preferences → General*) unter Einstellungen → System hochladen. Der
Speicher der Windows-Installation liegt bereits auf der VM unter
`/srv/rippy/makemkv/`.
- **Offen:** Der volle UHD-Rip inklusive Transcode-E2E ist noch nicht
durch — belegt ist der Disc-Zugriff, nicht die komplette Kette bis zur
fertigen Datei. Und ob sich der Abruf unter Linux doch anstoßen lässt,
ist ungeklärt; der Code dafür ist im Binary vorhanden.
---
## Vorheriger Stand: v3.10 — 4K-UHD: KEYDB.cfg statt Warten auf MakeMKV (25.07.2026)
- **Der Befund (am 25.07. live auf der VM im Worker-Container
nachgemessen — kein Verdacht, alles belegt):** Eine 4K-UHD (Akira,
MKB v76, Pressung Dezember 2020) scheitert mit „The volume key is
unknown for this disc". **Laufwerk und MakeMKV sind dabei in Ordnung:**
makemkvcon meldet „Using LibreDrive mode (v06.3)" und „Using direct
disc access mode", liest die Disc und legt den AACS-Dump ab
(Meldung 3332). Der Fehler liegt also NICHT an der Hardware.
- **Die echte Ursache — MakeMKV fragt gar nicht erst:** Das Debug-Log
geht ohne einen einzigen Netz-Versuch von „Loaded content hash table"
direkt auf „The volume key is unknown". **Beweise:**
`/root/.MakeMKV/_private_data.tar` enthielt nur die Index-Datei und
KEINE einzige `hkd_*.bin` — es wurde also nie ein Schlüssel geladen.
Auch mit erzwungener frischer Prüfung (`update.conf` gelöscht,
Meldung 5074 belegt den Web-Kontakt) und mit `app_UpdateEnable = "1"`
kam keiner. Und die im MakeMKV-Forum dokumentierten Schlüssel-Server
`hkdata.fairuse.org` und `hkdata.crabdance.com` lösen weltweit nicht
mehr auf (NXDOMAIN gegen Fritz!Box, 8.8.8.8 und 1.1.1.1).
- **Richtigstellung zu v3.3 (weiter unten korrigiert):** Dort stand, die
Ursache sei „Disc neuer als MakeMKVs Schlüssel-DB" und ein
MakeMKV-Update werde das lösen. Das ist widerlegt — es gibt keine
nachladbare Schlüssel-Datenbank mehr. **Warten auf ein MakeMKV-Update
hilft bei diesem Fehler nicht.**
- **Der einzige Weg, der heute funktioniert: `KEYDB.cfg`** — GROSS
geschrieben (unter Linux case-sensitiv) im MakeMKV-Datenverzeichnis.
Rippy macht diesen Weg jetzt begehbar, statt auf MakeMKV zu warten.
- **Gebaut — persistentes Datenverzeichnis:** `docker-compose.yml` mountet
`${MAKEMKV_DATA_HOST:-/srv/rippy/makemkv}` vom Host — im Worker auf
`/root/.MakeMKV`, in der API auf `/app/makemkv-data`; beide Container
bekommen `MAKEMKV_DATA_DIR`. Damit überleben `KEYDB.cfg` und die
AACS-Dumps jeden Rebuild. `entrypoint.sh` schreibt `settings.conf`
jetzt ergänzend statt zerstörend (sonst hätte der Beta-Key den Rest
überbügelt) und setzt `app_UpdateEnable = "1"`.
- **Gebaut — eine einzige Wahrheit über das Verzeichnis:** neues
Zwillings-Modul `makemkv_daten.py` (identisch in `docker/api/` und
`docker/worker/`) mit reinen Helfern — Status lesen, Inhalt prüfen,
atomar schreiben, löschen, AACS-Dumps auflisten. Nur reine Funktionen,
damit die Ampel sie ohne Postgres/Redis testen kann.
- **Gebaut — Bedienung im UI:** Einstellungen → System zeigt den
`KEYDB.cfg`-Status (Pfad, Größe, Anzahl Disc-Einträge, Datum), nimmt die
Datei über einen ganz normalen Datei-Dialog entgegen (der Browser liest
sie und schickt den Text als JSON — serverseitig bewusst KEIN
Multipart-Upload, es gibt kein `python-multipart`, das würde die API
beim Import töten), lehnt
unplausiblen Inhalt mit deutschem Klartext ab, kann die Datei wieder
entfernen und die AACS-Dumps zum Download anbieten.
- **Gebaut — man sieht endlich, was MakeMKV sagt:** `parse_msg()` in
`ripping.py` plus Log-Callback in `tasks.py` schreiben MakeMKV-Meldungen
ins Rippy-Log (gedrosselt: Code 1003 raus, keine Wiederholungen, max. 40
je Rip). Der UHD-Fehlertext ist ehrlich neu geschrieben. `caps.py`
meldet zusätzlich `keydb: ja | nein | unbekannt` je Worker.
- **Abgrenzung, die überall durchscheint:** **Rippy liefert KEINE
Schlüssel mit, lädt keine herunter und verteilt keine.** Rippy stellt
nur den Platz für eine Datei bereit, die der Nutzer selbst mitbringt,
und zeigt ehrlich an, was dort liegt. Genau die Grenze zieht schon
`docker/api/makemkv_key.py` (Zeile 14) für den Beta-Key: das ist die
Software-LIZENZ, nicht das Entschlüsseln oder Verteilen von
Disc-Schlüsseln.
- **⚠️ NOCH NICHT end-to-end bewiesen — ehrlich gesagt:** Zum Zeitpunkt
dieser Änderung lag KEINE `KEYDB.cfg` vor, die den Akira-Schlüssel
enthält. Belegt sind der Befund oben und die neue Mechanik (Mount,
Modul, Endpunkte, UI) — **nicht** ein erfolgreicher UHD-Rip. Der
Nachweis steht aus und braucht eine echte Schlüssel-Datei.
---
## Vorheriger Stand: v3.9 — Windows-Installer als echte .exe (Icon, kein Konsolenfenster) (24.07.2026)
- **RippyWorkerSetup.exe** ersetzt den .vbs/.bat-Weg (Commander-Einwand:
.vbs ist abgekündigt + wird als gefährlich geflaggt). install-gui.ps1 via
@@ -183,16 +1042,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
Binary file not shown.
+15 -6
View File
@@ -1,7 +1,7 @@
# Baut rippy.ico + RippyWorkerSetup.exe aus install-gui.ps1 (ps2exe).
# Baut rippy.ico + RippyWorkerSetup.exe aus install-gui.ps1 (ps2exe).
#
# WANN NEU BAUEN: nur wenn sich install-gui.ps1 (die Installer-Oberflaeche)
# aendert. NICHT bei MakeMKV-/HandBrake-Updates die .exe holt HandBrake zur
# WANN NEU BAUEN: nur wenn sich install-gui.ps1 (die Installer-Oberfläche)
# ändert. NICHT bei MakeMKV-/HandBrake-Updates - die .exe holt HandBrake zur
# Laufzeit (GitHub latest) und den Worker-Code live von Rippy, friert also
# keine Versionen ein.
# AUSFUEHREN AUF WINDOWS (eine Windows-.exe kann nicht von Linux gebaut werden):
@@ -35,7 +35,7 @@ $bw.Write($png); $bw.Flush()
[System.IO.File]::WriteAllBytes("$PSScriptRoot\rippy.ico", $icoStream.ToArray())
Write-Host "rippy.ico erzeugt ($($png.Length) Bytes PNG)"
# 2. ps2exe sicherstellen TLS 1.2 ist der Fix fuer den NuGet-Bootstrap-Fehler
# 2. ps2exe sicherstellen - TLS 1.2 ist der Fix für den NuGet-Bootstrap-Fehler
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor 3072
if (-not (Get-Module -ListAvailable ps2exe)) {
Write-Host "Bootstrappe NuGet + PSGallery ..."
@@ -48,9 +48,18 @@ if (-not (Get-Module -ListAvailable ps2exe)) {
Import-Module ps2exe
# 3. Kompilieren (WinForms -> noConsole + STA, Icon eingebettet)
#
# -requireAdmin (seit 25.07.2026): Der Worker wird standardmäßig nach
# "C:\Program Files\Rippy Worker" installiert, und dorthin darf nur ein
# Administrator schreiben. Mit dem Schalter tragt die .exe ein Manifest, das
# Windows beim Doppelklick EINMAL per UAC fragen lässt - danach stimmen die
# Rechte für alles Weitere. Ohne ihn liefe der Installer unerhöht und
# scheiterte beim Anlegen des Ordners; wer das Ziel ins eigene Profil legt,
# braucht die Erhöhung nicht, wird aber trotzdem gefragt (normal für einen
# Installer und besser als ein Fehlschlag mitten im Lauf).
Invoke-ps2exe -inputFile $guiSrc -outputFile "$PSScriptRoot\RippyWorkerSetup.exe" `
-iconFile "$PSScriptRoot\rippy.ico" -noConsole -STA `
-title "Rippy Worker Setup" -product "Rippy" -company "Rippy" -version "1.0.0.0"
-iconFile "$PSScriptRoot\rippy.ico" -noConsole -STA -requireAdmin `
-title "Rippy Worker Setup" -product "Rippy" -company "Rippy" -version "1.0.1.0"
if (Test-Path "$PSScriptRoot\RippyWorkerSetup.exe") {
Write-Host "OK: RippyWorkerSetup.exe = $((Get-Item "$PSScriptRoot\RippyWorkerSetup.exe").Length) Bytes"
+234 -45
View File
@@ -1,9 +1,14 @@
# Rippy Windows-Worker - GRAFISCHER Installer (WinForms, keine externe Runtime).
# Wird ueber rippy-worker-setup.bat gestartet (Doppelklick). Braucht nur
# Rippy Windows-Worker - GRAFISCHER Installer (WinForms, keine externe Runtime).
# Wird über rippy-worker-setup.bat gestartet (Doppelklick). Braucht nur
# Python 3.10+ auf der Maschine - kein Docker, kein git.
#
# ASCII-only (PowerShell 5.1 liest .ps1 als ANSI - Sonderzeichen zerschiessen
# das Skript).
# WICHTIG - KODIERUNG: Diese Datei MUSS als UTF-8 MIT BOM gespeichert werden.
# PowerShell 5.1 liest .ps1 ohne BOM als ANSI; Umlaute werden dann zu Kauderwelsch
# UND das Skript wirft einen Parser-Fehler, weil in "ö" ein Anführungszeichen
# steckt. Mit BOM liest 5.1 korrekt UTF-8 - am 25.07.2026 beidseitig gemessen:
# ohne BOM: "Größe" + Unerwartetes Token
# mit BOM: "Größe" + laeuft
# Nach jeder Änderung pruefen: powershell.exe -Command "Parser::ParseFile(...)"
param(
[string]$RippyHost = "",
@@ -14,20 +19,44 @@ $ErrorActionPreference = "Stop"
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$InstallDir = Join-Path $env:USERPROFILE "rippy-worker"
# Standard-Zielverzeichnis: wie jedes andere Programm unter "Programme"
# (Commander-Wunsch 25.07.2026 - vorher lag der Worker im Benutzerprofil, was
# für ein Programm unüblich ist). GetFolderPath liefert den ECHTEN Pfad;
# deutsche Windows-Versionen zeigen im Explorer "Programme" an, der Pfad heißt
# trotzdem "C:\Program Files".
#
# Schreiben dorthin braucht Administratorrechte - deshalb ist die .exe mit
# -requireAdmin gebaut (build-exe.ps1) und fragt beim Start einmal per UAC.
# Ohne das wäre hier der nächste "Zugriff verweigert" programmiert.
$StandardZiel = Join-Path ([Environment]::GetFolderPath("ProgramFiles")) "Rippy Worker"
$InstallDir = $StandardZiel
# --- Farben: dieselben Töne wie das Rippy-Web-UI ---------------------------
# Übernommen aus docker/ui/src/lib/design.ts bzw. den Tailwind-Klassen des UI,
# damit der Installer nicht wie ein Fremdkörper wirkt.
$cBg = [System.Drawing.Color]::FromArgb(15, 23, 42) # slate-900, Fensterfläche
$cPanel = [System.Drawing.Color]::FromArgb(2, 6, 23) # slate-950, Log + Eingaben
$cFeld = [System.Drawing.Color]::FromArgb(30, 41, 59) # slate-800, Eingabefelder
$cText = [System.Drawing.Color]::FromArgb(226, 232, 240) # slate-200
$cGedaempft= [System.Drawing.Color]::FromArgb(148, 163, 184) # slate-400, Beschriftungen
$cIndigo = [System.Drawing.Color]::FromArgb(99, 102, 241)
$cAmber = [System.Drawing.Color]::FromArgb(245, 158, 11)
$cEmerald = [System.Drawing.Color]::FromArgb(16, 185, 129)
$cPurple = [System.Drawing.Color]::FromArgb(126, 34, 206)
# --- Fenster ---------------------------------------------------------------
$form = New-Object System.Windows.Forms.Form
$form.Text = "Rippy Encoding-Worker - Installation"
$form.Size = New-Object System.Drawing.Size(560, 520)
$form.ClientSize = New-Object System.Drawing.Size(560, 560)
$form.StartPosition = "CenterScreen"
$form.FormBorderStyle = "FixedDialog"
$form.MaximizeBox = $false
$form.BackColor = [System.Drawing.Color]::FromArgb(248, 250, 252)
$form.BackColor = $cBg
$form.ForeColor = $cText
$form.Font = New-Object System.Drawing.Font("Segoe UI", 9)
# Fenster-Icon: Rippy-Disc zur Laufzeit zeichnen (Indigo-Kreis + weisses Loch)
# - kein externes .ico noetig. Erscheint in Titelleiste und Taskleiste.
# Fenster-Icon: Rippy-Disc zur Laufzeit zeichnen (Indigo-Kreis + weißes Loch)
# - kein externes .ico nötig. Erscheint in Titelleiste und Taskleiste.
try {
$ibmp = New-Object System.Drawing.Bitmap 32, 32
$ig = [System.Drawing.Graphics]::FromImage($ibmp)
@@ -37,84 +66,158 @@ try {
$form.Icon = [System.Drawing.Icon]::FromHandle($ibmp.GetHicon())
} catch {}
$titel = New-Object System.Windows.Forms.Label
$titel.Text = "Rippy Encoding-Worker einrichten"
$titel.Font = New-Object System.Drawing.Font("Segoe UI", 14, [System.Drawing.FontStyle]::Bold)
$titel.ForeColor = [System.Drawing.Color]::FromArgb(79, 70, 229)
$titel.Location = New-Object System.Drawing.Point(24, 20)
$titel.Size = New-Object System.Drawing.Size(500, 30)
$form.Controls.Add($titel)
# --- Kopfbereich mit Farbverlauf --------------------------------------------
# Das Rippy-UI hat oben einen Verlauf amber -> indigo -> purple (siehe
# FirstRunWizard.tsx). WinForms kann das nicht von sich aus, also wird der
# Verlauf im Paint-Ereignis gezeichnet - inklusive Disc-Symbol.
$kopf = New-Object System.Windows.Forms.Panel
$kopf.Dock = "Top"
$kopf.Height = 104
$form.Controls.Add($kopf)
$kopf.Add_Paint({
param($absender, $e)
$flaeche = New-Object System.Drawing.Rectangle 0, 0, $absender.Width, $absender.Height
$pinsel = New-Object System.Drawing.Drawing2D.LinearGradientBrush(
$flaeche, $cAmber, $cPurple, [System.Drawing.Drawing2D.LinearGradientMode]::Horizontal)
# Dreifarbig wie im UI: der Indigo-Ton in der Mitte
$mischung = New-Object System.Drawing.Drawing2D.ColorBlend 3
$mischung.Colors = @($cAmber, $cIndigo, $cPurple)
$mischung.Positions = @(0.0, 0.45, 1.0)
$pinsel.InterpolationColors = $mischung
$e.Graphics.FillRectangle($pinsel, $flaeche)
$pinsel.Dispose()
$info = New-Object System.Windows.Forms.Label
$info.Text = "Diese Maschine uebernimmt die Video-Kompression fuer Rippy. Gerippt wird weiter auf der Rippy-Hauptmaschine."
$info.Location = New-Object System.Drawing.Point(24, 52)
$info.Size = New-Object System.Drawing.Size(500, 34)
$info.ForeColor = [System.Drawing.Color]::FromArgb(100, 116, 139)
$form.Controls.Add($info)
# Disc-Symbol: Ring aussen, weisses Loch in der Mitte
$e.Graphics.SmoothingMode = "AntiAlias"
$weiss = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(235, 255, 255, 255))
$e.Graphics.FillEllipse($weiss, 24, 28, 48, 48)
$e.Graphics.FillEllipse((New-Object System.Drawing.SolidBrush $cIndigo), 36, 40, 24, 24)
$e.Graphics.FillEllipse($weiss, 45, 49, 6, 6)
$weiss.Dispose()
# Texte direkt zeichnen - so bleibt der Verlauf ohne Kästchen sichtbar
$fTitel = New-Object System.Drawing.Font("Segoe UI", 15, [System.Drawing.FontStyle]::Bold)
$fUnter = New-Object System.Drawing.Font("Segoe UI", 9)
$e.Graphics.DrawString("Rippy Encoding-Worker", $fTitel,
[System.Drawing.Brushes]::White, 88, 26)
$e.Graphics.DrawString(
"Diese Maschine übernimmt die Video-Kompression für Rippy." + [char]10 +
"Gerippt wird weiter auf der Rippy-Hauptmaschine.", $fUnter,
(New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(225, 255, 255, 255))), 90, 56)
$fTitel.Dispose(); $fUnter.Dispose()
})
# Rippy-Adresse
$lblHost = New-Object System.Windows.Forms.Label
$lblHost.Text = "LAN-IP der Rippy-Maschine (Docker-Host) - NICHT dieser PC:"
$lblHost.Location = New-Object System.Drawing.Point(24, 96)
$lblHost.Location = New-Object System.Drawing.Point(24, 120)
$lblHost.Size = New-Object System.Drawing.Size(500, 18)
$lblHost.ForeColor = $cGedaempft
$form.Controls.Add($lblHost)
$txtHost = New-Object System.Windows.Forms.TextBox
$txtHost.Location = New-Object System.Drawing.Point(24, 116)
$txtHost.Location = New-Object System.Drawing.Point(24, 140)
$txtHost.Size = New-Object System.Drawing.Size(300, 26)
$txtHost.Text = $RippyHost
$txtHost.Font = New-Object System.Drawing.Font("Consolas", 10)
$txtHost.BackColor = $cFeld
$txtHost.ForeColor = $cText
$txtHost.BorderStyle = "FixedSingle"
$form.Controls.Add($txtHost)
# Worker-Name
$lblName = New-Object System.Windows.Forms.Label
$lblName.Text = "Name dieses Workers (frei waehlbar):"
$lblName.Location = New-Object System.Drawing.Point(24, 152)
$lblName.Text = "Name dieses Workers (frei wählbar):"
$lblName.Location = New-Object System.Drawing.Point(24, 176)
$lblName.Size = New-Object System.Drawing.Size(500, 18)
$lblName.ForeColor = $cGedaempft
$form.Controls.Add($lblName)
$txtName = New-Object System.Windows.Forms.TextBox
$txtName.Location = New-Object System.Drawing.Point(24, 172)
$txtName.Location = New-Object System.Drawing.Point(24, 196)
$txtName.Size = New-Object System.Drawing.Size(300, 26)
$txtName.Text = $WorkerName
$txtName.BackColor = $cFeld
$txtName.ForeColor = $cText
$txtName.BorderStyle = "FixedSingle"
$form.Controls.Add($txtName)
# Zielverzeichnis - frei wählbar, Standard "Programme"
$lblPfad = New-Object System.Windows.Forms.Label
$lblPfad.Text = "Installieren nach:"
$lblPfad.Location = New-Object System.Drawing.Point(24, 232)
$lblPfad.Size = New-Object System.Drawing.Size(500, 18)
$lblPfad.ForeColor = $cGedaempft
$form.Controls.Add($lblPfad)
$txtPfad = New-Object System.Windows.Forms.TextBox
$txtPfad.Location = New-Object System.Drawing.Point(24, 252)
$txtPfad.Size = New-Object System.Drawing.Size(390, 26)
$txtPfad.Text = $StandardZiel
$txtPfad.Font = New-Object System.Drawing.Font("Consolas", 9)
$txtPfad.BackColor = $cFeld
$txtPfad.ForeColor = $cText
$txtPfad.BorderStyle = "FixedSingle"
$form.Controls.Add($txtPfad)
$btnPfad = New-Object System.Windows.Forms.Button
$btnPfad.Text = "Ändern ..."
$btnPfad.Location = New-Object System.Drawing.Point(424, 251)
$btnPfad.Size = New-Object System.Drawing.Size(100, 28)
$btnPfad.FlatStyle = "Flat"
$btnPfad.BackColor = $cFeld
$btnPfad.ForeColor = $cText
$btnPfad.FlatAppearance.BorderColor = $cGedaempft
$form.Controls.Add($btnPfad)
$btnPfad.Add_Click({
$dlg = New-Object System.Windows.Forms.FolderBrowserDialog
$dlg.Description = "Ordner wählen - darin wird 'Rippy Worker' angelegt"
$dlg.ShowNewFolderButton = $true
if ($dlg.ShowDialog() -eq "OK") {
# Der Dialog liefert den ELTERN-Ordner; der Unterordner kommt von uns,
# damit die Deinstallation nie einen fremden Ordner mitlöscht.
$txtPfad.Text = (Join-Path $dlg.SelectedPath "Rippy Worker")
}
})
# Autostart
$chkAuto = New-Object System.Windows.Forms.CheckBox
$chkAuto.Text = "Beim Anmelden automatisch starten (Tray-Symbol)"
$chkAuto.Location = New-Object System.Drawing.Point(24, 206)
$chkAuto.Location = New-Object System.Drawing.Point(24, 290)
$chkAuto.Size = New-Object System.Drawing.Size(400, 22)
$chkAuto.Checked = $true
$chkAuto.ForeColor = $cText
$form.Controls.Add($chkAuto)
# Log-Bereich
$log = New-Object System.Windows.Forms.TextBox
$log.Location = New-Object System.Drawing.Point(24, 240)
$log.Size = New-Object System.Drawing.Size(500, 170)
$log.Location = New-Object System.Drawing.Point(24, 322)
$log.Size = New-Object System.Drawing.Size(500, 150)
$log.Multiline = $true
$log.ReadOnly = $true
$log.ScrollBars = "Vertical"
$log.BackColor = [System.Drawing.Color]::FromArgb(15, 23, 42)
$log.ForeColor = [System.Drawing.Color]::FromArgb(226, 232, 240)
$log.BackColor = $cPanel
$log.ForeColor = $cText
$log.BorderStyle = "FixedSingle"
$log.Font = New-Object System.Drawing.Font("Consolas", 8.5)
$form.Controls.Add($log)
# Knoepfe
# Knöpfe
$btnInstall = New-Object System.Windows.Forms.Button
$btnInstall.Text = "Installieren"
$btnInstall.Location = New-Object System.Drawing.Point(24, 424)
$btnInstall.Location = New-Object System.Drawing.Point(24, 486)
$btnInstall.Size = New-Object System.Drawing.Size(150, 40)
$btnInstall.BackColor = [System.Drawing.Color]::FromArgb(79, 70, 229)
$btnInstall.ForeColor = [System.Drawing.Color]::White
$btnInstall.BackColor = $cAmber
$btnInstall.ForeColor = $cBg
$btnInstall.FlatStyle = "Flat"
$btnInstall.Font = New-Object System.Drawing.Font("Segoe UI", 10, [System.Drawing.FontStyle]::Bold)
$form.Controls.Add($btnInstall)
$btnStart = New-Object System.Windows.Forms.Button
$btnStart.Text = "Worker starten"
$btnStart.Location = New-Object System.Drawing.Point(190, 424)
$btnStart.Location = New-Object System.Drawing.Point(190, 486)
$btnStart.Size = New-Object System.Drawing.Size(150, 40)
$btnStart.BackColor = [System.Drawing.Color]::FromArgb(16, 185, 129)
$btnStart.BackColor = $cEmerald
$btnStart.ForeColor = [System.Drawing.Color]::White
$btnStart.FlatStyle = "Flat"
$btnStart.Font = New-Object System.Drawing.Font("Segoe UI", 10, [System.Drawing.FontStyle]::Bold)
@@ -133,8 +236,21 @@ function Do-Install {
if (-not $rHost) { [System.Windows.Forms.MessageBox]::Show("Bitte die LAN-IP der Rippy-Maschine eintragen.", "Fehlt"); return }
if (-not $wName) { $wName = $env:COMPUTERNAME.ToLower() }
# Zielverzeichnis aus dem Feld. script:-Bereich, weil "Worker starten"
# und die Abschlussmeldung denselben Pfad brauchen.
$zielEingabe = $txtPfad.Text.Trim()
if (-not $zielEingabe) { $zielEingabe = $StandardZiel }
if (-not [System.IO.Path]::IsPathRooted($zielEingabe)) {
[System.Windows.Forms.MessageBox]::Show(
"Bitte einen vollständigen Pfad angeben, z. B. C:\Program Files\Rippy Worker.",
"Pfad unvollständig")
return
}
$script:InstallDir = $zielEingabe
$btnInstall.Enabled = $false
$log.Clear()
Log "Ziel: $InstallDir"
try {
# 1. Python finden
@@ -155,7 +271,27 @@ function Do-Install {
Log "Python gefunden: $python"
# 2. Ordner + Worker-Code
New-Item -ItemType Directory -Force $InstallDir | Out-Null
# Erst PRUEFEN, ob wir dort überhaupt schreiben dürfen. Die .exe ist mit
# -requireAdmin gebaut und fragt per UAC, aber wer install-gui.ps1 direkt
# startet, hat die Rechte nicht - dann soll hier ein brauchbarer Satz
# stehen und nicht "Zugriff verweigert" aus irgendeiner Tiefe.
try {
New-Item -ItemType Directory -Force $InstallDir -ErrorAction Stop | Out-Null
$probe = Join-Path $InstallDir ".schreibtest"
Set-Content -Path $probe -Value "x" -ErrorAction Stop
Remove-Item -Force $probe -ErrorAction SilentlyContinue
} catch {
Log "FEHLER: In '$InstallDir' darf nicht geschrieben werden."
Log ""
Log "Das ist normal bei Ordnern unter 'Programme' - dafür braucht es"
Log "Administratorrechte. Zwei Wege:"
Log " 1. Diesen Installer mit Rechtsklick > 'Als Administrator"
Log " ausführen' starten (der empfohlene Weg), ODER"
Log " 2. oben über 'Ändern ...' einen Ordner im eigenen"
Log " Benutzerprofil wählen - dort geht es ohne Adminrechte."
$btnInstall.Enabled = $true
return
}
Set-Location $InstallDir
$HandBrakeVersion = "1.11.2"
try {
@@ -168,13 +304,13 @@ function Do-Install {
Expand-Archive worker.zip -DestinationPath . -Force
Remove-Item worker.zip
# 3. venv + Abhaengigkeiten
# 3. venv + Abhängigkeiten
if (-not (Test-Path "venv")) { Log "Lege Python-Umgebung an ..."; Invoke-Expression "$python -m venv venv" }
Log "Installiere Python-Abhaengigkeiten (kann 1-2 min dauern) ..."
Log "Installiere Python-Abhängigkeiten (kann 1-2 min dauern) ..."
& .\venv\Scripts\python.exe -m pip install --quiet --upgrade pip
& .\venv\Scripts\pip.exe install --quiet -r requirements.txt
& .\venv\Scripts\pip.exe install --quiet pystray pillow
Log "Abhaengigkeiten installiert."
Log "Abhängigkeiten installiert."
# 4. HandBrakeCLI
if (-not (Test-Path ".\HandBrakeCLI.exe") -and -not (Get-Command HandBrakeCLI.exe -ErrorAction SilentlyContinue)) {
@@ -199,6 +335,12 @@ if (-not `$Force) { if ([System.Windows.Forms.MessageBox]::Show("Rippy-Worker '$
Add-Type -AssemblyName System.Windows.Forms
Get-CimInstance Win32_Process | Where-Object { `$_.ExecutablePath -like "`$PSScriptRoot*" } | ForEach-Object { Stop-Process -Id `$_.ProcessId -Force -ErrorAction SilentlyContinue }
Start-Sleep 2
# Autostart-Verknüpfung entfernen - BEIDE möglichen Orte, weil die
# Installation je nach Zielverzeichnis persönlich oder maschinenweit war.
foreach (`$ordner in @("Startup", "CommonStartup")) {
try { Remove-Item -Force (Join-Path ([Environment]::GetFolderPath(`$ordner)) "RippyWorker.lnk") -ErrorAction Stop } catch {}
}
# ... und die geplante Aufgabe aus älteren Installationen, falls vorhanden.
schtasks /delete /tn "RippyWorker" /f 2>`$null | Out-Null
try { Invoke-RestMethod -Method Delete "http://$rHost/api/workers/$wName" -TimeoutSec 5 | Out-Null } catch {}
Set-Location (Split-Path `$PSScriptRoot -Parent)
@@ -207,10 +349,57 @@ Remove-Item -Recurse -Force `$PSScriptRoot
"@
Set-Content -Path "uninstall.ps1" -Value $uninstall -Encoding ASCII
# 6. Autostart
# 6. Autostart über den Autostart-ORDNER, nicht über schtasks.
#
# Befund 25.07.2026 (auf dem Commander-PC aufgetreten): schtasks /create
# mit /tn "RippyWorker" legt die Aufgabe im WURZELORDNER der
# Aufgabenplanung an, und das verlangt Administratorrechte. Der
# Installer läuft normal ohne. schtasks schrieb deshalb
# "FEHLER: Zugriff verweigert." nach stderr, und weil oben
# $ErrorActionPreference = "Stop" steht, wurde daraus ein Abbruch der
# GESAMTEN Installation - obwohl alles außer dem Autostart fertig war.
# Der Nutzer sah nur "FEHLER: FEHLER: Zugriff verweigert" samt dem
# irreführenden Tipp, die IP könne falsch sein.
#
# Der Autostart-Ordner braucht NIE Adminrechte, und der Nutzer kann die
# Verknüpfung dort selbst sehen und löschen. Zwei Fliegen.
if ($chkAuto.Checked) {
schtasks /create /f /tn "RippyWorker" /tr "`"$InstallDir\start-tray.bat`"" /sc onlogon | Out-Null
Log "Autostart eingerichtet (Start bei Anmeldung)."
try {
# Liegt das Programm unter "Programme", ist es eine Installation
# für die ganze Maschine - dann gehört der Autostart in den
# Autostart-Ordner ALLER Benutzer. Das passt auch zur Rechtelage:
# der Installer läuft dafür ohnehin erhöht, und bei einer
# Erhöhung über ein FREMDES Administratorkonto wäre der
# benutzereigene Ordner der des Admins - also der falsche.
# Bei einer Installation ins eigene Profil bleibt es persönlich.
$programme = [Environment]::GetFolderPath("ProgramFiles")
$programmeX86 = [Environment]::GetFolderPath("ProgramFilesX86")
$maschinenweit = $InstallDir.StartsWith($programme, "OrdinalIgnoreCase") `
-or ($programmeX86 -and $InstallDir.StartsWith($programmeX86, "OrdinalIgnoreCase"))
$autostartDir = if ($maschinenweit) {
[Environment]::GetFolderPath("CommonStartup")
} else {
[Environment]::GetFolderPath("Startup")
}
$lnkPfad = Join-Path $autostartDir "RippyWorker.lnk"
$wsh = New-Object -ComObject WScript.Shell
$lnk = $wsh.CreateShortcut($lnkPfad)
$lnk.TargetPath = (Join-Path $InstallDir "start-tray.bat")
$lnk.WorkingDirectory = $InstallDir
$lnk.Description = "Rippy Encoding-Worker"
$lnk.Save()
if ($maschinenweit) {
Log "Autostart eingerichtet (für alle Benutzer dieser Maschine)."
} else {
Log "Autostart eingerichtet (für dich, startet bei der Anmeldung)."
}
} catch {
# Nur eine Warnung: der Worker selbst ist fertig und startbar.
Log "HINWEIS: Autostart konnte nicht eingerichtet werden ($($_.Exception.Message))."
Log " Der Worker ist trotzdem fertig installiert und läuft."
Log " Von Hand: start-tray.bat in den Autostart-Ordner verknüpfen"
Log " (Windows-Taste + R, dann shell:startup eingeben)."
}
}
Log ""
+76 -23
View File
@@ -1,14 +1,14 @@
# Rippy: Nativer Windows-Transcode-Worker - Installation OHNE Docker.
# Rippy: Nativer Windows-Transcode-Worker - Installation OHNE Docker.
#
# Was das Skript tut (alles in einen Unterordner "rippy-worker"):
# 1. Prueft Python (3.10+), legt ein venv an, installiert die Abhaengigkeiten
# 2. Laedt den Worker-Code direkt von deiner Rippy-Instanz (/api/worker-setup/paket)
# 3. Laedt HandBrakeCLI (gepinnte Version, offizielles GitHub-Release),
# Was das Skript tut (alles nach -InstallDir, Standard "C:\Program Files\Rippy Worker"):
# 1. Prüft Python (3.10+), legt ein venv an, installiert die Abhängigkeiten
# 2. Lädt den Worker-Code direkt von deiner Rippy-Instanz (/api/worker-setup/paket)
# 3. Lädt HandBrakeCLI (gepinnte Version, offizielles GitHub-Release),
# falls nicht schon im PATH oder im Ordner vorhanden
# 4. Erzeugt start-worker.bat - Doppelklick startet den Worker
#
# Der Worker bedient NUR die Kompressions-Queue (transcode) - gerippt wird
# weiterhin auf der Rippy-Hauptmaschine (dort haengt das Laufwerk).
# weiterhin auf der Rippy-Hauptmaschine (dort hängt das Laufwerk).
#
# Aufruf (PowerShell):
# .\install.ps1 -RippyHost 192.168.178.162 [-WorkerName mein-pc] [-Autostart]
@@ -18,14 +18,19 @@
param(
[Parameter(Mandatory = $true)] [string]$RippyHost,
[string]$WorkerName = $env:COMPUTERNAME.ToLower(),
[switch]$Autostart
[switch]$Autostart,
# Zielverzeichnis. Standard ist "Programme", wie bei jedem anderen Programm
# (Commander-Wunsch 25.07.2026). Dorthin schreiben braucht Adminrechte -
# PowerShell also "Als Administrator" starten, oder hier einen Ordner im
# eigenen Profil angeben.
[string]$InstallDir = (Join-Path ([Environment]::GetFolderPath("ProgramFiles")) "Rippy Worker")
)
$ErrorActionPreference = "Stop"
# HandBrake-Version: die NEUESTE offizielle (GitHub-Release) - so passt der
# Windows-Worker zu dem, was der Update-Check im UI meldet (frueher war hier
# eine feste 1.9.2 hardcodiert, was verwirrte). Faellt auf eine bekannte
# Version zurueck, falls die GitHub-API gerade nicht erreichbar ist.
# Windows-Worker zu dem, was der Update-Check im UI meldet (früher war hier
# eine feste 1.9.2 hardcodiert, was verwirrte). Fällt auf eine bekannte
# Version zurück, falls die GitHub-API gerade nicht erreichbar ist.
$HandBrakeVersion = "1.11.2"
try {
$rel = Invoke-RestMethod "https://api.github.com/repos/HandBrake/HandBrake/releases/latest" `
@@ -49,14 +54,28 @@ foreach ($kandidat in @("py -3", "python")) {
}
if (-not $python) {
Write-Host "FEHLER: Python 3.10+ nicht gefunden." -ForegroundColor Red
Write-Host "Installieren mit: winget install Python.Python.3.12 (dann Skript erneut ausfuehren)"
Write-Host "Installieren mit: winget install Python.Python.3.12 (dann Skript erneut ausführen)"
exit 1
}
Write-Host "Python gefunden: $python"
# 2. Arbeitsordner + Worker-Code von der Rippy-Instanz laden
$ziel = Join-Path (Get-Location) "rippy-worker"
New-Item -ItemType Directory -Force $ziel | Out-Null
# 2. Zielordner + Worker-Code von der Rippy-Instanz laden
$ziel = $InstallDir
try {
New-Item -ItemType Directory -Force $ziel -ErrorAction Stop | Out-Null
$probe = Join-Path $ziel ".schreibtest"
Set-Content -Path $probe -Value "x" -ErrorAction Stop
Remove-Item -Force $probe -ErrorAction SilentlyContinue
} catch {
Write-Host ""
Write-Host "FEHLER: In '$ziel' darf nicht geschrieben werden." -ForegroundColor Red
Write-Host "Das ist normal bei Ordnern unter 'Programme'. Zwei Wege:" -ForegroundColor Red
Write-Host " 1. PowerShell als Administrator starten und erneut aufrufen, ODER" -ForegroundColor Red
Write-Host " 2. ein eigenes Ziel angeben, z. B.:" -ForegroundColor Red
Write-Host " -InstallDir `"`$env:LOCALAPPDATA\Rippy Worker`"" -ForegroundColor Red
exit 1
}
Write-Host "Ziel: $ziel"
Set-Location $ziel
Write-Host "Lade Worker-Code von http://$RippyHost/api/worker-setup/paket ..."
@@ -64,18 +83,18 @@ Invoke-WebRequest "http://$RippyHost/api/worker-setup/paket" -OutFile worker.zip
Expand-Archive worker.zip -DestinationPath . -Force
Remove-Item worker.zip
# 3. venv + Abhaengigkeiten
# 3. venv + Abhängigkeiten
if (-not (Test-Path "venv")) {
Write-Host "Lege Python-Umgebung an ..."
Invoke-Expression "$python -m venv venv"
}
& .\venv\Scripts\python.exe -m pip install --quiet --upgrade pip
& .\venv\Scripts\pip.exe install --quiet -r requirements.txt
# Nur fuer den Windows-Worker: Tray-Symbol (Status, Start/Stopp, Beenden)
# Nur für den Windows-Worker: Tray-Symbol (Status, Start/Stopp, Beenden)
& .\venv\Scripts\pip.exe install --quiet pystray pillow
Write-Host "Python-Abhaengigkeiten installiert."
Write-Host "Python-Abhängigkeiten installiert."
# 4. HandBrakeCLI besorgen (falls noetig)
# 4. HandBrakeCLI besorgen (falls nötig)
$hb = Get-Command HandBrakeCLI.exe -ErrorAction SilentlyContinue
if (-not $hb -and -not (Test-Path ".\HandBrakeCLI.exe")) {
Write-Host "Lade HandBrakeCLI $HandBrakeVersion (offizielles GitHub-Release) ..."
@@ -119,10 +138,10 @@ start "" venv\Scripts\pythonw.exe tray.py
Set-Content -Path "start-tray.bat" -Value $trayBat -Encoding ASCII
Write-Host "start-worker.bat + start-tray.bat erzeugt."
# 7. Deinstaller - MUSS-Kriterium: rueckstandsfrei entfernbar
# 7. Deinstaller - MUSS-Kriterium: rückstandsfrei entfernbar
$uninstall = @"
# Rippy-Worker DEINSTALLIEREN: stoppt Prozesse, entfernt Autostart,
# meldet den Worker in Rippy ab und loescht diesen Ordner komplett.
# meldet den Worker in Rippy ab und löscht diesen Ordner komplett.
param([switch]`$Force)
if (-not `$Force) {
`$antwort = Read-Host "Rippy-Worker '$WorkerName' wirklich deinstallieren? (j/n)"
@@ -132,11 +151,17 @@ Get-CimInstance Win32_Process | Where-Object {
`$_.ExecutablePath -like "`$PSScriptRoot*"
} | ForEach-Object { Stop-Process -Id `$_.ProcessId -Force -ErrorAction SilentlyContinue }
Start-Sleep 2
# Autostart-Verknüpfung entfernen - BEIDE möglichen Orte, weil die
# Installation je nach Zielverzeichnis persönlich oder maschinenweit war.
foreach (`$ordner in @("Startup", "CommonStartup")) {
try { Remove-Item -Force (Join-Path ([Environment]::GetFolderPath(`$ordner)) "RippyWorker.lnk") -ErrorAction Stop } catch {}
}
# ... und die geplante Aufgabe aus älteren Installationen, falls vorhanden.
schtasks /delete /tn "RippyWorker" /f 2>`$null | Out-Null
try {
Invoke-RestMethod -Method Delete "http://$RippyHost/api/workers/$WorkerName" -TimeoutSec 5 | Out-Null
Write-Host "Worker-Eintrag in Rippy entfernt."
} catch { Write-Host "Hinweis: Eintrag in Rippy ggf. von Hand loeschen (Einstellungen -> Worker)." }
} catch { Write-Host "Hinweis: Eintrag in Rippy ggf. von Hand löschen (Einstellungen -> Worker)." }
Set-Location (Split-Path `$PSScriptRoot -Parent)
Remove-Item -Recurse -Force `$PSScriptRoot
Write-Host "Rippy-Worker deinstalliert." -ForegroundColor Green
@@ -145,9 +170,37 @@ Set-Content -Path "uninstall.ps1" -Value $uninstall -Encoding ASCII
Write-Host "uninstall.ps1 erzeugt."
# 8. Optional: Autostart bei Anmeldung (mit Tray)
#
# Über den Autostart-ORDNER, nicht über schtasks: Eine Aufgabe im
# Wurzelordner der Aufgabenplanung anzulegen verlangt Administratorrechte, und
# der Installer läuft normal ohne. Auf dem Commander-PC scheiterte das am
# 25.07.2026 mit "FEHLER: Zugriff verweigert." - und weil oben
# $ErrorActionPreference = "Stop" steht, riss es die ganze Installation mit,
# obwohl nur der Autostart fehlte. Der Autostart-Ordner braucht nie Adminrechte.
if ($Autostart) {
schtasks /create /f /tn "RippyWorker" /tr "`"$ziel\start-tray.bat`"" /sc onlogon | Out-Null
Write-Host "Autostart-Aufgabe 'RippyWorker' registriert (Tray bei Anmeldung)."
try {
# Unter "Programme" = Installation für die Maschine, also Autostart für
# alle Benutzer. Sonst persönlich. Siehe install-gui.ps1 für die
# ausführliche Begründung (Rechtelage bei fremdem Admin-Konto).
$programme = [Environment]::GetFolderPath("ProgramFiles")
$maschinenweit = $ziel.StartsWith($programme, "OrdinalIgnoreCase")
$autostartDir = if ($maschinenweit) {
[Environment]::GetFolderPath("CommonStartup")
} else {
[Environment]::GetFolderPath("Startup")
}
$wsh = New-Object -ComObject WScript.Shell
$lnk = $wsh.CreateShortcut((Join-Path $autostartDir "RippyWorker.lnk"))
$lnk.TargetPath = (Join-Path $ziel "start-tray.bat")
$lnk.WorkingDirectory = $ziel
$lnk.Description = "Rippy Encoding-Worker"
$lnk.Save()
Write-Host "Autostart eingerichtet (Verknüpfung im Autostart-Ordner)."
} catch {
Write-Host "HINWEIS: Autostart konnte nicht eingerichtet werden ($($_.Exception.Message))." -ForegroundColor Yellow
Write-Host " Der Worker ist trotzdem fertig installiert." -ForegroundColor Yellow
Write-Host " Von Hand: start-tray.bat in shell:startup verknüpfen." -ForegroundColor Yellow
}
}
Write-Host ""
+27 -3
View File
@@ -16,6 +16,10 @@ services:
- 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,6 +51,8 @@ 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:
- ${OPTICAL_SR:-/dev/sr0}:/dev/sr0
networks:
@@ -64,10 +70,17 @@ services:
context: .
dockerfile: docker/worker/Dockerfile
args:
# Übersteuerbar via .env — makemkv.com ist zeitweise down (Cloudflare
# 525); dann Wayback-Snapshot als MAKEMKV_URL_BASE eintragen.
# /download = aktuelle Version, /download/old = ältere Versionen.
# Übersteuerbar via .env. /download = aktuelle Version,
# /download/old = ältere. Am 25.07.2026 nachgemessen: /download
# antwortet mit 200, /download/old mit 525 (Cloudflare), und ein
# web.archive.org-Schnappschuss mit 404 — deshalb steht hier der
# Standard, der wirklich liefert.
MAKEMKV_URL_BASE: ${MAKEMKV_URL_BASE:-https://www.makemkv.com/download}
# Zweite Quelle, die bei Fehlschlag der ersten automatisch probiert wird
# (leer = keine). Bewusst ohne Vorbelegung: eine Adresse, die nicht
# liefert, lässt die Konfiguration gesund aussehen und den Build später
# scheitern. Der zuverlässige Weg bleibt docker/worker/vendor/.
MAKEMKV_URL_FALLBACK: ${MAKEMKV_URL_FALLBACK:-}
# MakeMKV-Update OHNE Code-Änderung: neue Version in die .env,
# Tarballs nach docker/worker/vendor/ legen (oder Download klappt),
# dann docker compose build worker && docker compose up -d worker.
@@ -78,6 +91,10 @@ services:
- 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,6 +109,13 @@ 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:
# Host-Geräteknoten via .env (OPTICAL_SR/OPTICAL_SG) — je Rechner ANDERS!
# MakeMKV spricht Laufwerke über die SCSI-Generic-Schicht an; ohne den
-110
View File
@@ -1,110 +0,0 @@
"""Jellyfin Image Downloader."""
import requests
from pathlib import Path
from typing import Dict, Optional
from clients.tmdb import TMDBClient
from clients.thetvdb import TheTVDBClient
class ImageDownloader:
def __init__(self):
self.tmdb = TMDBClient()
self.thetvdb = TheTVDBClient()
self.base_url = "https://image.tmdb.org/t/p"
def download_image(self, url: str, output_path: Path, width: int = 500) -> bool:
"""Lade Image herunter."""
try:
# TMDB URL anpassen
if url.startswith("https://image.tmdb.org"):
# Konvertiere zu gewünschter Größe
path = url.replace(f"{self.base_url}/", "")
url = f"{self.base_url}/w{width}/{path}"
response = requests.get(url, timeout=30)
response.raise_for_status()
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, 'wb') as f:
f.write(response.content)
return True
except Exception as e:
print(f"Image download error: {e}")
return False
def download_poster(self, title: str, output_dir: Path, width: int = 500) -> Optional[Path]:
"""Lade Poster herunter."""
# TMDB Search
movies = self.tmdb.search_movie(title)
if movies:
movie = movies[0]
images = self.tmdb.get_movie_images(movie["id"])
if images.get("poster"):
output_path = output_dir / "poster.jpg"
if self.download_image(images["poster"], output_path, width):
return output_path
return None
def download_fanart(self, title: str, output_dir: Path, width: int = 1920) -> Optional[Path]:
"""Lade Fanart herunter."""
# TMDB Search
movies = self.tmdb.search_movie(title)
if movies:
movie = movies[0]
images = self.tmdb.get_movie_images(movie["id"])
if images.get("fanart"):
output_path = output_dir / "fanart.jpg"
if self.download_image(images["fanart"], output_path, width):
return output_path
return None
def download_series_images(self, title: str, output_dir: Path) -> Dict[str, Optional[Path]]:
"""Lade Serien-Poster und Fanart herunter."""
result = {
"poster": None,
"fanart": None
}
tv_shows = self.tmdb.search_tv(title)
if tv_shows:
tv = tv_shows[0]
images = self.tmdb.get_tv_images(tv["id"])
if images.get("poster"):
output_path = output_dir / "poster.jpg"
if self.download_image(images["poster"], output_path, 500):
result["poster"] = output_path
if images.get("fanart"):
output_path = output_dir / "fanart.jpg"
if self.download_image(images["fanart"], output_path, 1920):
result["fanart"] = output_path
return result
def download_music_images(self, artist: str, album: str, output_dir: Path) -> Dict[str, Optional[Path]]:
"""Lade Musik-Album-Cover herunter (via TMDB als Fallback)."""
result = {
"album": None
}
# TMDB Search für Soundtracks
query = f"{album} soundtrack"
movies = self.tmdb.search_movie(query)
if movies:
movie = movies[0]
images = self.tmdb.get_movie_images(movie["id"])
if images.get("poster"):
output_path = output_dir / "album.jpg"
if self.download_image(images["poster"], output_path, 500):
result["album"] = output_path
return result
+322 -135
View File
@@ -1,17 +1,18 @@
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.responses import FileResponse
from pydantic import BaseModel
from typing import List, Optional, Dict
from pathlib import Path
import asyncio
import json
import os
import shutil
import time
import uuid
import db
import devices as device_discovery
import makemkv_daten
import makemkv_key
import mounts as mount_verwaltung
import notify
@@ -22,8 +23,6 @@ from config_validation import validate_config, ConfigValidationError
from cache import init_cache
from ratelimit import check_rate_limit, get_rate_limit_remaining
from prescan import PreScan
from nfo_generator import NFOGenerator
from image_downloader import ImageDownloader
# Auth (JWT/Login/API-Keys) KOMPLETT entfernt — Commander-Entscheid 24.07.2026:
# Rippy läuft ausschließlich im Heimnetz, die Endpoints schützten ohnehin
@@ -59,10 +58,13 @@ async def startup_event():
asyncio.create_task(asyncio.to_thread(remount))
asyncio.create_task(disc_watcher())
# MakeMKV-Beta-Key automatisch aktuell halten (wechselt ~monatlich, laeuft zum
# MakeMKV-Beta-Key automatisch aktuell halten (wechselt ~monatlich, läuft zum
# Monatsende ab) — sonst blockt Blu-ray-Ripping irgendwann still. Taeglicher
# Forum-Abgleich; wirkt ohne Rebuild ab dem naechsten Rip.
# Forum-Abgleich; wirkt ohne Rebuild ab dem nächsten Rip.
asyncio.create_task(makemkv_key.refresh_loop())
# Worker-Erreichbarkeit im Hintergrund pingen (siehe _ping_knoten) — sonst
# kostet JEDER Aufruf von /capabilities eine ganze Sekunde.
asyncio.create_task(_ping_schleife())
# Auto-Pre-Scan-Ergebnisse je Laufwerk: das Dashboard zeigt damit sofort,
@@ -154,7 +156,7 @@ async def _auto_rip_wenn_aktiviert(pfad: str):
else:
unterordner = einstellungen.get("movieDir") or "movies"
ziel = os.path.normpath(os.path.join(basis, unterordner))
if not ziel.startswith(MEDIA_ROOT):
if not unter_wurzel(ziel, MEDIA_ROOT):
ziel = None
job_id = str(uuid.uuid4())
@@ -250,6 +252,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 +268,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 +285,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")
@@ -298,7 +309,7 @@ def _kann_neu_komprimieren(job: dict, work_dir: str) -> bool:
return False
if os.path.isdir(os.path.join("/app/temp/raw", job["id"])):
return True
return work_dir.startswith(MEDIA_ROOT) and os.path.isdir(os.path.join(work_dir, job["id"]))
return unter_wurzel(work_dir, MEDIA_ROOT) and os.path.isdir(os.path.join(work_dir, job["id"]))
@app.get("/jobs", response_model=List[Job])
@@ -349,6 +360,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"
@@ -359,7 +374,7 @@ def _validiere_ziel(target_dir: Optional[str]) -> Optional[str]:
if not target_dir:
return None
normalisiert = os.path.normpath(target_dir)
if not normalisiert.startswith(MEDIA_ROOT):
if not unter_wurzel(normalisiert, MEDIA_ROOT):
raise HTTPException(
status_code=422,
detail=f"Ziel muss unter {MEDIA_ROOT} liegen (Shares dort einhängen)",
@@ -408,6 +423,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())
@@ -442,10 +463,24 @@ def _sicherer_dateiname(name: str) -> bool:
return bool(name) and "/" not in name and "\\" not in name and not name.startswith(".")
def unter_wurzel(pfad: str, wurzel: str) -> bool:
"""Liegt `pfad` wirklich unterhalb von `wurzel` (oder IST es die Wurzel)?
Pure Funktion, testbar. Ein nacktes `startswith()` genügt hier nicht:
/app/media-boese/x" beginnt mit „/app/media", liegt aber außerhalb
(Befund 25.07.2026 bei der Durchsicht). Deshalb Gleichheit ODER Wurzel
samt Trennzeichen. Erwartet werden normalisierte Container-Pfade mit /".
"""
if not pfad or not wurzel:
return False
sauber = wurzel.rstrip("/") or "/"
return pfad == sauber or pfad.startswith(sauber + "/")
def _job_ausgabeordner(job: dict) -> str:
"""Validierter Ausgabeordner eines Jobs — strikt unter /app/media."""
ausgabe = os.path.normpath(job.get("output_path") or "")
if not ausgabe.startswith(MEDIA_ROOT):
if not unter_wurzel(ausgabe, MEDIA_ROOT):
raise HTTPException(status_code=404, detail="Job hat keinen Ausgabeordner unter /app/media")
return ausgabe
@@ -502,7 +537,7 @@ async def download_job_file(job_id: str, dateiname: str):
pfad = os.path.join(ausgabe, dateiname)
def pruefe():
return os.path.isfile(pfad) and os.path.realpath(pfad).startswith(MEDIA_ROOT)
return os.path.isfile(pfad) and unter_wurzel(os.path.realpath(pfad), MEDIA_ROOT)
if not await asyncio.to_thread(pruefe):
raise HTTPException(status_code=404, detail="Datei nicht gefunden")
@@ -613,7 +648,7 @@ async def retry_transcode(job_id: str):
# (Einstellungen → Verarbeitung), sonst Container-Default /app/temp/raw.
einstellungen = await asyncio.to_thread(db.get_settings)
work_dir = os.path.normpath((einstellungen.get("workDir") or "").strip() or "/")
raw_basis = work_dir if work_dir.startswith(MEDIA_ROOT) else "/app/temp/raw"
raw_basis = work_dir if unter_wurzel(work_dir, MEDIA_ROOT) else "/app/temp/raw"
raw_dir = f"{raw_basis}/{job_id}"
# Zielordner: der Worker schreibt das geplante Ziel beim Rip-Start nach
# output_path (sprechender Name statt UUID) — alter Fallback bleibt.
@@ -654,6 +689,55 @@ async def cancel_job(job_id: str):
return {"id": job_id, "status": "canceling"}
# --- Worker-Erreichbarkeit: gepingt wird im Hintergrund, nicht im Request ---
#
# Befund 25.07.2026 (gemessen): /capabilities brauchte **1,010 s** — und zwar
# jedes Mal. Ursache ist kein Fehler, sondern das Wesen des Celery-Pings: er
# sammelt Antworten bis zum Timeout und kann nicht früher aufhören, weil er
# nicht weiß, wie viele Worker noch antworten wollen. Fünf UI-Stellen holen
# /capabilities (Dashboard, Einstellungen, Worker-Tab, Wizard, Rip-Dialog) —
# jede Seite zahlte also eine Sekunde, obwohl alle anderen Endpunkte unter
# 25 ms liegen. Genau das war das „Laggen".
#
# Jetzt pingt ein Hintergrund-Lauf im festen Takt, und der Endpunkt liest nur
# ab. Ist der Vorrat älter als PING_ALTER_MAX (Lauf noch nicht angelaufen oder
# gestorben), wird EINMAL synchron gepingt und der Vorrat wieder gefüllt —
# lieber eine langsame Antwort als eine falsche.
_PING = {"knoten": [], "stand": -1e9}
PING_INTERVALL_SEKUNDEN = 5
PING_ALTER_MAX_SEKUNDEN = 30
def _ping_jetzt() -> list:
"""Pingt sofort (blockiert ~1 s) und füllt den Vorrat."""
try:
antworten = celery_client.control.ping(timeout=1.0) or []
knoten = [k for antwort in antworten for k in antwort.keys()]
except Exception:
knoten = []
_PING["knoten"] = knoten
_PING["stand"] = time.monotonic()
return knoten
def _ping_knoten() -> list:
"""Erreichbare Celery-Knoten aus dem Vorrat — ohne zu warten."""
if time.monotonic() - _PING["stand"] <= PING_ALTER_MAX_SEKUNDEN:
return _PING["knoten"]
return _ping_jetzt()
async def _ping_schleife():
"""Hält den Ping-Vorrat frisch. Darf nie sterben, sonst wird jeder
/capabilities-Aufruf wieder langsam."""
while True:
try:
await asyncio.to_thread(_ping_jetzt)
except Exception: # Broker weg → beim nächsten Durchlauf erneut
pass
await asyncio.sleep(PING_INTERVALL_SEKUNDEN)
@app.get("/capabilities")
async def capabilities():
"""Welche Encoder sind auf welchen Workern WIRKLICH verfügbar — inkl.
@@ -664,11 +748,7 @@ async def capabilities():
"""
def sammle():
zeilen = db.list_workers()
try:
antworten = celery_client.control.ping(timeout=1.0) or []
ping_knoten = [k for antwort in antworten for k in antwort.keys()]
except Exception:
ping_knoten = []
ping_knoten = _ping_knoten()
for zeile in zeilen:
# Celery-Knotenname = <name>@<hostname>. Der Ping liefert ihn voll;
# gematcht wird über den Hostname (der Docker-Worker heißt celery@…,
@@ -968,7 +1048,7 @@ async def delete_storage_mount(name: str):
async def browse(path: str = MEDIA_ROOT):
"""Server-seitiger Ordner-Browser für die Ziel-Auswahl (nur unter /app/media)."""
normalisiert = os.path.normpath(path)
if not normalisiert.startswith(MEDIA_ROOT):
if not unter_wurzel(normalisiert, MEDIA_ROOT):
raise HTTPException(status_code=422, detail=f"Nur Pfade unter {MEDIA_ROOT}")
def liste():
@@ -1009,7 +1089,7 @@ class MkdirRequest(BaseModel):
async def browse_mkdir(request: MkdirRequest):
"""Neuen Ordner unter /app/media anlegen (Speicherziele-Verwaltung)."""
basis = os.path.normpath(request.path)
if not basis.startswith(MEDIA_ROOT):
if not unter_wurzel(basis, MEDIA_ROOT):
raise HTTPException(status_code=422, detail=f"Nur Pfade unter {MEDIA_ROOT}")
name = request.name.strip()
if not name or "/" in name or "\\" in name or name.startswith("."):
@@ -1099,14 +1179,10 @@ async def worker_setup_windows():
return FileResponse(pfad, media_type="text/plain", filename="install-rippy-worker.ps1")
@app.get("/worker-setup/windows-gui")
async def worker_setup_windows_gui():
"""Grafischer Windows-Installer (WinForms, PowerShell-Quelle) — Rückfall
für Fortgeschrittene; der Normalweg ist die .exe unten."""
pfad = "worker_dist/install-gui.ps1"
if not os.path.isfile(pfad):
raise HTTPException(status_code=404, detail="GUI-Installer nicht im Image — API neu bauen")
return FileResponse(pfad, media_type="text/plain", filename="rippy-worker-gui.ps1")
# GET /worker-setup/windows-gui entfernt am 25.07.2026: ohne Aufrufer, seit die
# .exe den .bat-Umweg ersetzt hat (v3.9 — .vbs/.bat wird als gefährlich
# geflaggt, Commander-Einwand). Die Datei install-gui.ps1 selbst lebt weiter,
# sie steckt in der .exe; nur diese Route war verwaist.
@app.get("/worker-setup/windows-exe")
@@ -1181,6 +1257,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 großes tar — gehört deshalb mit in
# den Thread und nicht in die Ereignisschleife.
fehler = makemkv_daten.private_data_pruefen(rohdaten)
if fehler:
return fehler, None
return "", makemkv_daten.private_data_schreiben(rohdaten)
try:
fehler, status = await asyncio.to_thread(pruefe_und_schreibe)
except OSError as e:
raise HTTPException(
status_code=500,
detail=(
f"Schlüsselspeicher konnte nicht geschrieben werden: {e}. "
"Prüfe, ob das MakeMKV-Datenverzeichnis auf der VM existiert "
"und beschreibbar ist (Standard: /srv/rippy/makemkv)."
),
)
if fehler:
raise HTTPException(status_code=422, detail=fehler)
await asyncio.to_thread(
db.add_log, "success", "makemkv-keydb",
f"Schlüsselspeicher übernommen: {status['schluessel']} Disc-Schlüssel, "
f"{status['groesse_bytes']} Bytes. Wirkt ab dem NÄCHSTEN Rip.",
)
return status
@app.get("/system/aacs-dumps")
async def get_aacs_dumps():
"""AACS-Dumps, die MakeMKV selbst abgelegt hat (neueste zuerst).
MakeMKV schreibt sie beim gescheiterten UHD-Versuch ins Datenverzeichnis
(Meldung 3332 "Saved AACS dump file as file:///root/.MakeMKV/<name>.tgz",
am 25.07.2026 so beobachtet). Rippy wertet sie nicht aus und schickt sie
nirgendwohin es zeigt nur, dass sie da sind, damit der Nutzer selbst
entscheiden kann, was er damit tut.
"""
def liste():
return {"dumps": makemkv_daten.dumps_auflisten()}
return await asyncio.to_thread(liste)
@app.get("/system/aacs-dumps/{dateiname}")
async def download_aacs_dump(dateiname: str):
"""Lädt EINEN AACS-Dump herunter.
Pfad-Validierung genauso streng wie beim Job-Datei-Download: nackter Name
ohne Pfadtrenner und ohne führenden Punkt (ist_aacs_dump) PLUS realpath,
der das MakeMKV-Datenverzeichnis nicht verlassen darf (kein ..-Ausbruch,
kein Symlink nach draußen).
"""
if not makemkv_daten.ist_aacs_dump(dateiname):
raise HTTPException(
status_code=404,
detail="Kein gültiger Dump-Name — erwartet wird eine .tgz-Datei ohne Pfadangabe.",
)
pfad = os.path.join(MAKEMKV_DATA_ROOT, dateiname)
def pruefe():
return os.path.isfile(pfad) and unter_wurzel(os.path.realpath(pfad), MAKEMKV_DATA_ROOT)
if not await asyncio.to_thread(pruefe):
raise HTTPException(
status_code=404,
detail="Dump nicht gefunden — MakeMKV legt ihn erst beim gescheiterten UHD-Versuch an.",
)
return FileResponse(pfad, filename=dateiname, media_type="application/gzip")
class NotificationTestRequest(BaseModel):
url: str
@@ -1269,21 +1542,13 @@ async def get_devices():
return geraete
# SSE-Stream für Echtzeit-Updates
@app.get("/stream/jobs")
async def job_stream():
"""SSE-Stream für Job-Updates.
Fix 23.07.: Der alte Generator sendete nur, wenn `sse_connections` gefüllt
war aber NICHTS hat diese Liste je befüllt. Der Stream war ein Placebo.
"""
async def event_generator():
while True:
jobs = await get_jobs()
yield f"data: {json.dumps([j.dict() for j in jobs])}\n\n"
await asyncio.sleep(2)
return StreamingResponse(event_generator(), media_type="text/event-stream")
# GET /stream/jobs (SSE) entfernt am 25.07.2026. Der Stream war zweimal falsch:
# erst ein Placebo (er sendete nur, wenn eine Liste `sse_connections` gefüllt
# war, und nichts füllte sie je), dann am 23.07. funktionsfähig gemacht — aber
# einen Verbraucher hat er nie bekommen. Im UI gibt es kein `EventSource`; das
# Dashboard holt die Jobs mit `setInterval(loadData, 4000)`. Damit war er keine
# harmlose Leiche, sondern eine Endlosschleife je Verbindung, die jeder im
# Heimnetz aufmachen konnte. Wer echtes Push will, braucht BEIDE Seiten.
# /metadata/lookup + /metadata/confirm entfernt (24.07., mit der
@@ -1396,99 +1661,21 @@ async def metadata_override(request: MetadataOverride):
return ergebnis
# Pre-Scan Endpoint
class PreScanRequest(BaseModel):
device_path: str
@app.post("/prescan")
async def run_prescan(request: PreScanRequest):
"""Führe Pre-Scan durch."""
try:
prescan = PreScan()
result = prescan.scan(request.device_path)
return result.to_dict()
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# Jellyfin-Formatierung Endpoints
class JellyfinFormatRequest(BaseModel):
title: str
year: Optional[int]
metadata: Dict
disc_type: str
output_dir: str
@app.post("/jellyfin/format")
async def jellyfin_format(request: JellyfinFormatRequest):
"""Formatiere für Jellyfin (NFO + Images)."""
try:
nfo_gen = NFOGenerator()
img_downloader = ImageDownloader()
# Ordnerstruktur erstellen
output_path = Path(request.output_dir)
if request.disc_type in ["dvd", "bluray"]:
# Film-Formatierung
title = request.metadata.get("title", request.title)
year = request.year or request.metadata.get("year")
# movie.nfo
movie_nfo = nfo_gen.generate_movie_nfo(
title=title,
year=year or 2000,
overview=request.metadata.get("overview", ""),
rating=request.metadata.get("rating", 0),
runtime=request.metadata.get("runtime", 0),
genres=request.metadata.get("genres", []),
director=request.metadata.get("director", ""),
actors=request.metadata.get("actors", [])
)
nfo_path = output_path / "movie.nfo"
nfo_gen.save_nfo(movie_nfo, nfo_path)
# Poster und Fanart
img_downloader.download_poster(title, output_path, 500)
img_downloader.download_fanart(title, output_path, 1920)
return {
"status": "formatted",
"nfo_path": str(nfo_path),
"poster_path": str(output_path / "poster.jpg"),
"fanart_path": str(output_path / "fanart.jpg")
}
else:
# Audio-Formatierung
artist = request.metadata.get("artist", "Unknown Artist")
album = request.metadata.get("title", request.title)
# Review-Fix 22.07.: `year` war hier undefiniert (existierte nur im Film-Zweig)
year = request.year or request.metadata.get("year")
# album.nfo
album_nfo = nfo_gen.generate_album_nfo(
title=album,
artist=artist,
year=year or 2000,
genres=request.metadata.get("genres", [])
)
nfo_path = output_path / "album.nfo"
nfo_gen.save_nfo(album_nfo, nfo_path)
# Album-Cover
img_downloader.download_music_images(artist, album, output_path)
return {
"status": "formatted",
"nfo_path": str(nfo_path),
"album_cover_path": str(output_path / "album.jpg")
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# POST /prescan und POST /jellyfin/format entfernt am 25.07.2026 — beide waren
# Überreste eines ersetzten Entwurfs, ohne einen einzigen Aufrufer:
#
# /prescan war der Endpunkt hinter der Metadaten-Vorschau-Seite. Die
# Seite ist seit v3.4 weg (Korrektur-Popup ist der einzige
# Weg), der Endpunkt blieb liegen. Der Pre-Scan selbst lebt:
# der Disc-Watcher ruft PreScan direkt im Prozess auf, das
# Ergebnis landet auf der Disc-Karte. Nur der HTTP-Weg
# dorthin hatte keinen Nutzer.
#
# /jellyfin/format schrieb NFO-Dateien und lud Poster — in der API. Seit v3.2
# macht das der Worker (medien.py), und das ist die richtige
# Stelle: er kennt den Ausgabeordner und ist direkt nach dem
# Rip am Zug. Mit dem Endpunkt fallen nfo_generator.py und
# image_downloader.py in der API weg; sonst nutzte sie nichts.
# Auth-Endpoints (/token, /api-keys) entfernt — Commander-Entscheid 24.07.:
+375
View File
@@ -0,0 +1,375 @@
"""MakeMKV-Datenverzeichnis: Schlüsselspeicher, KEYDB.cfg, AACS-Dumps.
WARUM ES DIESE DATEI GIBT (Befund 25.07.2026, auf BEIDEN Maschinen gemessen):
4K-UHD-Discs scheiterten mit "The volume key is unknown for this disc". Die
Ursache ist weder die Disc noch das Laufwerk LibreDrive v06.3 läuft und
MakeMKV liest die Disc sondern:
makemkvcon unter LINUX ruft die Disc-Schluessel nie ab.
Die Windows-Version tut es.
Gemessen, nicht vermutet:
* Linux: in KEINEM Lauf auch nur eine Verbindung nach draussen. Geprueft mit
leerem UND mit gefülltem Schlüsselspeicher, mit und ohne --noscan, mit
dev:/dev/sr0 und mit disc:0, und mit erzwungener frischer Prüfung
(update.conf gelöscht, Meldung 5074 belegt den Web-Kontakt). Immer:
keine Verbindung, kein Schluessel.
* Windows, dieselbe Disc, dasselbe Laufwerk: Meldung 3338 "Downloading
latest HK to ...", Verbindung nach 185.84.108.20:443
(web33.majordomo.ru), _private_data.tar wächst und die Disc geht auf
(TCOUNT:5, "Operation successfully completed").
* Der Code dafür steckt auch im Linux-Binary: die Meldungsvorlage
"Downloading latest %1 to %2 ..." steht in makemkvcon. Sie löst nur nie
aus. Gleiches Symptom im Forum, seit Jahren offen und unbeantwortet.
FRUEHERE FEHLDIAGNOSE, bewusst festgehalten: An dieser Stelle stand zuerst,
MakeMKVs Schluessel-Kanal sei abgeschaltet. Das war FALSCH. Die Herleitung
stützte sich auf zwei Hostnamen aus alten Forumsbeitraegen
(hkdata.fairuse.org, hkdata.crabdance.com), die tatsaechlich nicht mehr
aufloesen MakeMKV benutzt sie aber laengst nicht mehr. Der Dienst lebt, der
Worker erreicht ihn sogar; er wird unter Linux nur nie gefragt.
WAS DARAUS FOLGT: Der Schlüsselspeicher (_private_data.tar) muss von einer
MakeMKV-Installation kommen, die ihn wirklich abruft praktisch von Windows.
Rippy nimmt ihn unter Einstellungen -> System entgegen. Die KEYDB.cfg bleibt
der Notnagel für Pressungen, die auch MakeMKV selbst nicht kennt.
Rippy liefert KEINE Schluessel mit, lädt keine herunter und verteilt keine.
Es verwaltet nur, was der Nutzer selbst mitbringt.
QUELLEN (AGENTS Regel D externe Schnittstellen nie aus dem Kopf):
* Linux lädt keine Hashed Keys dasselbe Symptom, mehrfach berichtet:
https://forum.makemkv.com/forum/viewtopic.php?t=25782
https://forum.makemkv.com/forum/viewtopic.php?t=34022
* Schluessel liegen als hkd_*.bin in _private_data.tar:
https://forum.makemkv.com/forum/viewtopic.php?t=32675
* Datenverzeichnis und Dateiname KEYDB.cfg GROSS geschrieben (unter Linux
case-sensitiv), dort geloest mit "cp KEYDB.cfg ~/.MakeMKV/":
https://forum.makemkv.com/forum/viewtopic.php?t=30636
* Zeilenformat der KEYDB.cfg (libaacs): Disc-Kennung = 40 Hex-Zeichen,
optional mit 0x-Praefix, dann "= Titel", danach optionale Felder wie
"| V | <32 Hex>"; Zeilen ab ";" sind Kommentare:
https://github.com/ShiftMediaProject/libaacs/blob/master/KEYDB.cfg
* Den AACS-Dump legt MakeMKV selbst ab, Meldung 3332 "Saved AACS dump file
as file:///root/.MakeMKV/<name>.tgz" (am 25.07.2026 so beobachtet).
ZWILLINGSDATEI: liegt identisch unter docker/api/ und docker/worker/ beide
Images brauchen sie, ein gemeinsames Paket gibt es in diesem Projekt nicht
(gleiche Lage wie bei db.py). Änderungen IMMER in BEIDEN Dateien nachziehen.
"""
import io
import os
import re
import tarfile
from datetime import datetime, timezone
# Container-Pfad aus der Umgebung: /root/.MakeMKV im Worker (nur dort sucht
# makemkvcon), /app/makemkv-data in der API. Beide zeigen laut
# docker-compose.yml auf DASSELBE Host-Verzeichnis.
DATEN_DIR = os.getenv("MAKEMKV_DATA_DIR", "/root/.MakeMKV")
# GROSS geschrieben — unter Linux case-sensitiv, "keydb.cfg" wird ignoriert.
KEYDB_NAME = "KEYDB.cfg"
# Obergrenze für den Upload. Eine vollständige oeffentliche KEYDB.cfg liegt
# im einstelligen MB-Bereich; 64 MB sind reichlich Luft und verhindern, dass
# eine versehentlich hochgeladene Riesendatei den Speicher vollschreibt.
MAX_KEYDB_BYTES = 64 * 1024 * 1024
# Eine Disc-Zeile beginnt mit der 40 Zeichen langen Hex-Kennung (optional mit
# 0x davor), danach folgt das Gleichheitszeichen. Alles andere (Kommentare ab
# ";", Leerzeilen, Fortsetzungsfelder) zählt nicht als Eintrag.
_DISC_ZEILE = re.compile(r"^\s*(?:0x)?[0-9a-fA-F]{40}\s*=")
def _iso(zeitstempel: float) -> str:
"""Unix-Zeit -> ISO-8601 in UTC (sekundengenau), wie db.utcnow() es tut."""
return datetime.fromtimestamp(zeitstempel, timezone.utc).isoformat(timespec="seconds")
def keydb_pfad(daten_dir: str = None) -> str:
"""Voller Pfad zur KEYDB.cfg im Datenverzeichnis."""
return os.path.join(daten_dir or DATEN_DIR, KEYDB_NAME)
def zaehle_disc_eintraege(inhalt: str) -> int:
"""Zeilen mit Disc-Kennung zählen (pure Funktion, testbar).
Bewusst eine Heuristik und keine vollständige Auswertung: MakeMKV liest
die Datei mit seinem eigenen Parser, und wie viele Eintraege es daraus
macht, ist von aussen nicht sichtbar. Die Zahl dient nur dazu, im UI
"da liegt wirklich etwas drin" von "leere oder falsche Datei" zu
unterscheiden sie wird deshalb auch genau so beschriftet.
"""
return sum(1 for zeile in inhalt.splitlines() if _DISC_ZEILE.match(zeile))
def keydb_pruefen(inhalt: str) -> str:
"""Prüft hochgeladenen Inhalt; gibt deutschen Fehlertext oder "" zurück.
Verhindert den häufigsten Bedienfehler: statt der KEYDB.cfg landet die
HTML-Fehlerseite eines Downloads oder eine leere Datei im Verzeichnis
MakeMKV würde dann still weiter "volume key is unknown" melden.
"""
if not inhalt.strip():
return "Die Datei ist leer."
if len(inhalt.encode("utf-8")) > MAX_KEYDB_BYTES:
return (
"Die Datei ist größer als "
f"{MAX_KEYDB_BYTES // (1024 * 1024)} MB — das ist keine KEYDB.cfg."
)
if inhalt.lstrip()[:1] == "<":
return (
"Das sieht nach HTML aus, nicht nach einer KEYDB.cfg — "
"vermutlich wurde eine Fehlerseite statt der Datei geladen."
)
if zaehle_disc_eintraege(inhalt) == 0:
return (
"Keine einzige Zeile mit Disc-Kennung gefunden (40 Hex-Zeichen, "
"dann ein Gleichheitszeichen). Das ist keine KEYDB.cfg."
)
return ""
def keydb_status(daten_dir: str = None) -> dict:
"""Zustand der KEYDB.cfg. Fehlt sie, ist das der Normalfall, kein Fehler."""
pfad = keydb_pfad(daten_dir)
try:
angaben = os.stat(pfad)
except OSError:
return {
"vorhanden": False,
"pfad": pfad,
"groesse_bytes": 0,
"eintraege": 0,
"geaendert": "",
}
eintraege = 0
try:
with open(pfad, encoding="utf-8", errors="replace") as datei:
eintraege = zaehle_disc_eintraege(datei.read())
except OSError:
pass # Datei da, aber unlesbar: Größe/Datum stimmen trotzdem
return {
"vorhanden": True,
"pfad": pfad,
"groesse_bytes": angaben.st_size,
"eintraege": eintraege,
"geaendert": _iso(angaben.st_mtime),
}
def keydb_schreiben(inhalt: str, daten_dir: str = None) -> dict:
"""Schreibt die KEYDB.cfg atomar und meldet den neuen Zustand.
Erst in eine Nebendatei, dann os.replace: während ein Rip läuft, darf
makemkvcon niemals eine halb geschriebene Datei zu sehen bekommen.
"""
pfad = keydb_pfad(daten_dir)
os.makedirs(os.path.dirname(pfad), exist_ok=True)
neben = pfad + ".neu"
try:
with open(neben, "w", encoding="utf-8", newline="\n") as datei:
datei.write(inhalt)
os.replace(neben, pfad)
except OSError:
# Die Nebendatei nie liegen lassen: eine halb geschriebene
# KEYDB.cfg.neu verwirrt jeden, der ins Verzeichnis schaut, und
# belegt im Extremfall 64 MB, die niemand mehr aufräumt.
try:
os.remove(neben)
except OSError:
pass
raise
return keydb_status(daten_dir)
def keydb_loeschen(daten_dir: str = None) -> dict:
"""Entfernt die KEYDB.cfg (z. B. nach einem Fehlgriff beim Hochladen).
Nur "Datei war schon weg" wird geschluckt das ist das gewünschte
Ergebnis. Jeder andere Fehler (schreibgeschützter Mount, fremder
Eigentümer) MUSS nach oben durch: sonst meldete die API einen Erfolg,
den es nicht gab, und die Datei wirkte beim nächsten Rip weiter.
"""
try:
os.remove(keydb_pfad(daten_dir))
except FileNotFoundError:
pass
return keydb_status(daten_dir)
def ist_aacs_dump(name: str) -> bool:
"""Dateiname eines AACS-Dumps? (pure Funktion, testbar)
MakeMKV legt ihn als <MKB..._NAME_....>.tgz direkt im Datenverzeichnis ab
(Meldung 3332). Pfadtrenner und fuehrende Punkte werden hier schon
ausgeschlossen, damit der Download-Endpunkt keine Pfad-Tricks erlaubt.
"""
return (
name.endswith(".tgz")
and "/" not in name
and "\\" not in name
and not name.startswith(".")
)
def dumps_auflisten(daten_dir: str = None) -> list:
"""Alle AACS-Dumps im Datenverzeichnis, neueste zuerst."""
ordner = daten_dir or DATEN_DIR
try:
namen = os.listdir(ordner)
except OSError:
return []
liste = []
for name in namen:
if not ist_aacs_dump(name):
continue
try:
angaben = os.stat(os.path.join(ordner, name))
except OSError:
continue
liste.append(
{
"name": name,
"groesse_bytes": angaben.st_size,
"geaendert": _iso(angaben.st_mtime),
"_sort": angaben.st_mtime,
}
)
liste.sort(key=lambda eintrag: eintrag["_sort"], reverse=True)
for eintrag in liste:
del eintrag["_sort"]
return liste
# --- Schlüsselspeicher (_private_data.tar) -------------------------------
#
# Das ist MakeMKVs eigener Speicher für die "Hashed Keys": ein tar-Archiv mit
# hkd_*.bin-Eintraegen. Unter Windows füllt MakeMKV es selbst (Meldung 3338),
# unter Linux nie — siehe Modul-Kopf. Rippy nimmt die Datei deshalb entgegen
# und legt sie ins Datenverzeichnis; MakeMKV liest sie beim nächsten Start.
PRIVATE_DATA_NAME = "_private_data.tar"
# Der Speicher lag am 25.07.2026 bei rund 6 MB und wächst mit jeder neuen
# Pressung. 64 MB sind reichlich Luft — und derselbe Wert wie
# client_max_body_size in docker/ui/nginx.conf: wäre die Grenze hier höher,
# würde nginx den Upload abweisen, bevor die API ihn überhaupt sieht.
MAX_PRIVATE_DATA_BYTES = 64 * 1024 * 1024
def private_data_pfad(daten_dir: str = None) -> str:
"""Voller Pfad zum Schlüsselspeicher im Datenverzeichnis."""
return os.path.join(daten_dir or DATEN_DIR, PRIVATE_DATA_NAME)
def zaehle_schluessel(rohdaten: bytes) -> int:
"""Anzahl der hkd_*.bin-Eintraege im Archiv (pure Funktion, testbar).
Das ist die ehrliche Kennzahl für "wie viele Disc-Schluessel kennt diese
Installation". Ein frischer, leerer Speicher enthält nur eine
Index-Datei und kommt hier auf 0 genau der Zustand, in dem jede
unbekannte UHD-Disc scheitert.
"""
try:
with tarfile.open(fileobj=io.BytesIO(rohdaten)) as archiv:
return sum(1 for name in archiv.getnames() if name.startswith("hkd_"))
except (tarfile.TarError, OSError, EOFError):
return 0
def private_data_pruefen(rohdaten: bytes) -> str:
"""Prüft hochgeladene Rohdaten; deutscher Fehlertext oder "".
Faengt die beiden Bedienfehler ab, die sonst still danebengehen: eine
voellig andere Datei hochladen, oder den Speicher einer Installation, die
selbst noch keine Schluessel geholt hat (dann ändert sich nichts, und
niemand versteht warum).
"""
if not rohdaten:
return "Die Datei ist leer."
if len(rohdaten) > MAX_PRIVATE_DATA_BYTES:
return (
"Die Datei ist größer als "
f"{MAX_PRIVATE_DATA_BYTES // (1024 * 1024)} MB — das ist kein "
"MakeMKV-Schlüsselspeicher."
)
try:
with tarfile.open(fileobj=io.BytesIO(rohdaten)) as archiv:
namen = archiv.getnames()
except (tarfile.TarError, OSError, EOFError):
return (
"Das ist kein tar-Archiv. Erwartet wird die Datei "
f"{PRIVATE_DATA_NAME} aus dem MakeMKV-Datenverzeichnis."
)
if not any(name.startswith("hkd_") for name in namen):
return (
"In dieser Datei steckt kein einziger Schluessel (kein hkd_*.bin). "
"Sie stammt vermutlich von einer MakeMKV-Installation, die selbst "
"noch keine geholt hat — öffne dort erst einmal eine Disc."
)
return ""
def schluesselspeicher_status(daten_dir: str = None) -> dict:
"""Zustand des Schlüsselspeichers. Fehlt er, ist das kein Fehler."""
pfad = private_data_pfad(daten_dir)
try:
angaben = os.stat(pfad)
with open(pfad, "rb") as datei:
schluessel = zaehle_schluessel(datei.read())
except OSError:
return {
"vorhanden": False,
"pfad": pfad,
"groesse_bytes": 0,
"schluessel": 0,
"geaendert": "",
}
return {
"vorhanden": True,
"pfad": pfad,
"groesse_bytes": angaben.st_size,
"schluessel": schluessel,
"geaendert": _iso(angaben.st_mtime),
}
def private_data_schreiben(rohdaten: bytes, daten_dir: str = None) -> dict:
"""Legt den Schlüsselspeicher atomar ab und meldet den neuen Zustand.
Atomar aus demselben Grund wie bei der KEYDB.cfg: während ein Rip läuft,
darf makemkvcon nie ein halb geschriebenes Archiv sehen.
"""
pfad = private_data_pfad(daten_dir)
os.makedirs(os.path.dirname(pfad), exist_ok=True)
neben = pfad + ".neu"
try:
with open(neben, "wb") as datei:
datei.write(rohdaten)
os.replace(neben, pfad)
except OSError:
try:
os.remove(neben)
except OSError:
pass
raise
return schluesselspeicher_status(daten_dir)
def settings_conf_zusammenfuehren(inhalt: str, key: str) -> str:
"""app_Key setzen, ohne den Rest der settings.conf zu verlieren (pure).
Bis zum 25.07.2026 haben entrypoint.sh UND tasks.py die Datei komplett
überschrieben. Mit dem jetzt persistenten Datenverzeichnis wäre damit
bei jedem Containerstart und vor jedem Rip alles andere weg z. B.
app_UpdateEnable. Leerer Key lässt die vorhandene Zeile ebenfalls fallen,
damit ein bewusst geleerter Key nicht heimlich weiterwirkt.
"""
zeilen = [z for z in inhalt.splitlines() if not z.lstrip().startswith("app_Key")]
if key:
zeilen.append('app_Key = "{}"'.format(key))
text = "\n".join(zeilen).strip("\n")
return text + "\n" if text else ""
+14 -8
View File
@@ -1,18 +1,24 @@
"""Automatische Erneuerung des MakeMKV-Beta-Keys.
Der kostenlose MakeMKV-Beta-Key (Forum-Thread "MakeMKV is free while in beta",
t=1053) wechselt etwa monatlich und laeuft zum Monatsende ab. Ohne gueltigen Key
faellt Blu-ray/UHD-Ripping in den 30-Tage-Testmodus; DVD-Ripping bleibt frei.
t=1053) wechselt etwa monatlich und läuft zum Monatsende ab. Ohne gültigen Key
fällt Blu-ray/UHD-Ripping in den 30-Tage-Testmodus; DVD-Ripping bleibt frei.
Bisher musste der Key von Hand nachgetragen werden (UI: Einstellungen -> System).
Dieses Modul holt den aktuellen Key vom Forum und schreibt ihn in die Settings
(makemkvAppKey). Der Worker liest den Key VOR jedem Rip aus den Settings
(docker/worker/tasks.py) und schreibt settings.conf -> die Erneuerung wirkt ohne
Rebuild/Neustart, ab dem naechsten Rip.
Rebuild/Neustart, ab dem nächsten Rip.
Wichtig: Das ist die kostenlose, oeffentliche Beta-LIZENZ der Software selbst
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 früher, MakeMKV ziehe den AACS-Schluessel
via LibreDrive ohnehin selbst aus dem Laufwerk. Das stimmt für Blu-ray, aber
NICHT für 4K-UHD dort braucht MakeMKV den Volume-Key der jeweiligen Pressung,
und den bekommt es weder aus dem Laufwerk noch (heute) aus dem Netz. Belege und
Messungen stehen im Modul-Kopf von makemkv_daten.py.
Quelle/Format dokumentiert (AGENTS Regel D nicht geraten):
- Forum-Thread: https://forum.makemkv.com/forum/viewtopic.php?t=1053
@@ -35,14 +41,14 @@ def extract_key(html: str) -> str | None:
def fetch_current_key(timeout: int = 15) -> str | None:
"""Holt den aktuellen Beta-Key vom MakeMKV-Forum. None bei Fehler/kein Treffer."""
import requests # lazy: haelt extract_key + den Test dependency-frei
import requests # lazy: hält extract_key + den Test dependency-frei
antwort = requests.get(FORUM_URL, headers=_HEADERS, timeout=timeout)
antwort.raise_for_status()
return extract_key(antwort.text)
def _apply_key(key: str) -> bool:
"""Merge den Key in die Settings. save_settings ueberschreibt das GANZE JSON,
"""Merge den Key in die Settings. save_settings überschreibt das GANZE JSON,
darum erst lesen, dann setzen. Rueckgabe True, wenn sich der Key geaendert hat."""
import db
einstellungen = db.get_settings()
@@ -70,7 +76,7 @@ async def refresh_once() -> bool:
async def refresh_loop(intervall_stunden: int = 24, start_verzoegerung_s: int = 60) -> None:
"""Periodische Erneuerung (Default: taeglich, damit ein Monatswechsel nie verpasst
wird). Fehler werden geloggt, aber nie geworfen -> die API laeuft weiter."""
wird). Fehler werden geloggt, aber nie geworfen -> die API läuft weiter."""
import db
await asyncio.sleep(start_verzoegerung_s)
while True:
+3 -3
View File
@@ -76,11 +76,11 @@ def schreibtest(pfad: str) -> bool:
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)
Warum: mounten() fällt bei einem TOTEN Mount (os.path.ismount wirft OSError)
auf den echten `mount` durch der stapelt dann auf die Leiche. Vorfall 24.07.:
ueber viele Neustarts 12 Schichten, die tote oberste blockierte jeden Zugriff
über viele Neustarts 12 Schichten, die tote oberste blockierte jeden Zugriff
(ls-Timeout, obwohl SMB-445 offen). Erst alle Schichten loesen macht das
Re-Mounten idempotent. `umount -l` (lazy) haengt nicht an einem toten CIFS.
Re-Mounten idempotent. `umount -l` (lazy) hängt nicht an einem toten CIFS.
Rueckgabe: Zahl der geloesten Schichten.
"""
geloest = 0
-169
View File
@@ -1,169 +0,0 @@
"""NFO-Generator für Jellyfin (Kodi/NFO-Schema)."""
from pathlib import Path
from typing import List
from xml.dom.minidom import getDOMImplementation
class NFOGenerator:
def __init__(self):
self.dom_impl = getDOMImplementation()
def _create_element(self, doc, name: str, text: str = None) -> None:
"""Hilfsfunktion für Element-Erstellung."""
element = doc.createElement(name)
if text:
element.appendChild(doc.createTextNode(str(text)))
return element
def generate_movie_nfo(self, title: str, year: int,
overview: str = "", rating: float = 0.0,
runtime: int = 0, genres: List[str] = None,
director: str = "", writer: str = "",
actors: List[str] = None, studio: str = "",
premiered: str = "", mpaa: str = "") -> str:
"""Generiere movie.nfo für Filme."""
doc = self.dom_impl.createDocument(None, "movie", None)
root = doc.documentElement
root.appendChild(self._create_element(doc, "title", title))
root.appendChild(self._create_element(doc, "year", year))
root.appendChild(self._create_element(doc, "plot", overview))
root.appendChild(self._create_element(doc, "rating", rating))
root.appendChild(self._create_element(doc, "runtime", runtime))
if genres:
for genre in genres:
root.appendChild(self._create_element(doc, "genre", genre))
if director:
root.appendChild(self._create_element(doc, "director", director))
if writer:
root.appendChild(self._create_element(doc, "writer", writer))
if actors:
for actor in actors:
actor_node = doc.createElement("actor")
actor_node.appendChild(self._create_element(doc, "name", actor))
root.appendChild(actor_node)
if studio:
root.appendChild(self._create_element(doc, "studio", studio))
if premiered:
root.appendChild(self._create_element(doc, "premiered", premiered))
if mpaa:
root.appendChild(self._create_element(doc, "mpaa", mpaa))
# Attribution
root.appendChild(self._create_element(doc, "details", "Source: TMDB"))
return doc.toprettyxml(indent=" ")
def generate_series_nfo(self, title: str, year: int,
overview: str = "", rating: float = 0.0,
genres: List[str] = None, studio: str = "",
premiered: str = "") -> str:
"""Generiere series.nfo für Serien."""
doc = self.dom_impl.createDocument(None, "tvshow", None)
root = doc.documentElement
root.appendChild(self._create_element(doc, "title", title))
root.appendChild(self._create_element(doc, "year", year))
root.appendChild(self._create_element(doc, "plot", overview))
root.appendChild(self._create_element(doc, "rating", rating))
if genres:
for genre in genres:
root.appendChild(self._create_element(doc, "genre", genre))
if studio:
root.appendChild(self._create_element(doc, "studio", studio))
if premiered:
root.appendChild(self._create_element(doc, "premiered", premiered))
# Attribution
root.appendChild(self._create_element(doc, "details", "Source: TMDB"))
return doc.toprettyxml(indent=" ")
def generate_episode_nfo(self, title: str, season: int, episode: int,
overview: str = "", rating: float = 0.0,
director: str = "", premiered: str = "",
writers: List[str] = None) -> str:
"""Generiere episode.nfo für Episoden."""
doc = self.dom_impl.createDocument(None, "episodedetails", None)
root = doc.documentElement
root.appendChild(self._create_element(doc, "title", title))
root.appendChild(self._create_element(doc, "season", season))
root.appendChild(self._create_element(doc, "episode", episode))
root.appendChild(self._create_element(doc, "plot", overview))
root.appendChild(self._create_element(doc, "rating", rating))
if director:
root.appendChild(self._create_element(doc, "director", director))
if premiered:
root.appendChild(self._create_element(doc, "premiered", premiered))
if writers:
for writer in writers:
root.appendChild(self._create_element(doc, "credits", writer))
return doc.toprettyxml(indent=" ")
def generate_album_nfo(self, title: str, artist: str, year: int,
genres: List[str] = None, rating: float = 0.0,
review: str = "") -> str:
"""Generiere album.nfo für Musikalben."""
doc = self.dom_impl.createDocument(None, "musicalbum", None)
root = doc.documentElement
root.appendChild(self._create_element(doc, "title", title))
root.appendChild(self._create_element(doc, "artist", artist))
root.appendChild(self._create_element(doc, "year", year))
root.appendChild(self._create_element(doc, "rating", rating))
root.appendChild(self._create_element(doc, "review", review))
if genres:
for genre in genres:
root.appendChild(self._create_element(doc, "genre", genre))
# Attribution
root.appendChild(self._create_element(doc, "details", "Source: MusicBrainz"))
return doc.toprettyxml(indent=" ")
def save_nfo(self, content: str, output_path: Path) -> bool:
"""Speichere NFO-Datei."""
try:
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, 'w', encoding='utf-8') as f:
f.write(content)
return True
except Exception as e:
print(f"NFO save error: {e}")
return False
# Beispieldaten
if __name__ == "__main__":
nfo_gen = NFOGenerator()
# movie.nfo
movie_nfo = nfo_gen.generate_movie_nfo(
title="Inception",
year=2010,
overview="A thief who steals corporate secrets through the use of dream-sharing technology is given the inverse task of planting an idea into the mind of a C.E.O.",
rating=8.8,
runtime=148,
genres=["Action", "Sci-Fi", "Thriller"],
director="Christopher Nolan",
actors=["Leonardo DiCaprio", "Joseph Gordon-Levitt", "Ellen Page"]
)
print(movie_nfo)
+101 -1
View File
@@ -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",
# KEYDB.cfg + AACS-Dumps: der Platz für die selbst mitgebrachte
# Schlüsseldatei (Befund 25.07.2026 — MakeMKV holt UHD-Schlüssel nicht
# mehr online nach). Ohne diese Routen ist die Seite im UI tot.
"/system/keydb", "/system/aacs-dumps", "/system/aacs-dumps/{dateiname}",
# Der Hauptweg für 4K-UHD (25.07.2026): makemkvcon holt Disc-Schluessel
# unter Linux nie selbst, sie kommen von Hand über diesen Endpunkt.
"/system/keystore",
):
assert pfad in routen, f"Route {pfad} fehlt"
@@ -57,3 +66,94 @@ def test_remount_blockiert_den_api_start_nicht():
"remount muss als Hintergrund-Task laufen (nicht blockierend)"
assert "await asyncio.to_thread(remount)" not in quelle, \
"remount darf nicht mehr direkt awaited werden (blockiert sonst den Start)"
def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein():
"""Befund 25.07.2026: In main.py prüften neun Stellen mit nacktem
startswith(MEDIA_ROOT) darunter /browse und /browse/mkdir, wo der Pfad
vom Nutzer kommt. /app/media-boese/x" beginnt mit „/app/media", liegt
aber außerhalb. Zwilling von tasks.unter_wurzel im Worker."""
from main import unter_wurzel
assert unter_wurzel("/app/media", "/app/media") is True
assert unter_wurzel("/app/media/movies", "/app/media") is True
assert unter_wurzel("/app/media-boese/x", "/app/media") is False
assert unter_wurzel("/app/mediaX", "/app/media") is False
assert unter_wurzel("/etc/passwd", "/app/media") is False
assert unter_wurzel("", "/app/media") is False
assert unter_wurzel("/app/media", "") is False
assert unter_wurzel("/app/media/movies", "/app/media/") is True
def test_ping_vorrat_verhindert_die_wartesekunde(monkeypatch):
"""Gemessen 25.07.2026: /capabilities brauchte 1,010 s - jedes Mal. Der
Celery-Ping sammelt Antworten bis zum Timeout und kann nicht früher
aufhoeren. Fuenf UI-Stellen holen /capabilities, also zahlte jede Seite
eine Sekunde, während alle anderen Endpunkte unter 25 ms lagen.
Der Vorrat muss deshalb abgelesen und NICHT neu gepingt werden, solange er
frisch ist - und bei altem Vorrat lieber einmal langsam als falsch.
"""
import time as _t
import main
pings = []
monkeypatch.setattr(main, "_ping_jetzt", lambda: pings.append(1) or ["celery@neu"])
# Frischer Vorrat -> ablesen, kein Ping
monkeypatch.setitem(main._PING, "knoten", ["celery@alt"])
monkeypatch.setitem(main._PING, "stand", _t.monotonic())
assert main._ping_knoten() == ["celery@alt"]
assert pings == [], "bei frischem Vorrat darf NICHT gepingt werden"
# Zu alter Vorrat -> einmal synchron pingen
monkeypatch.setitem(main._PING, "stand", _t.monotonic() - main.PING_ALTER_MAX_SEKUNDEN - 1)
assert main._ping_knoten() == ["celery@neu"]
assert len(pings) == 1
# Kalter Start (nie gepingt) -> ebenfalls pingen, nicht "alles offline" melden
monkeypatch.setitem(main._PING, "stand", -1e9)
main._ping_knoten()
assert len(pings) == 2
def test_ping_takt_ist_kuerzer_als_die_haltbarkeit():
"""Sonst läuft der Vorrat zwischen zwei Hintergrund-Laeufen ab und der
Endpunkt pingt doch wieder synchron."""
import main
assert main.PING_INTERVALL_SEKUNDEN < main.PING_ALTER_MAX_SEKUNDEN
def test_tote_routen_sind_und_bleiben_weg():
"""Entfernt am 25.07.2026, jede ein Überrest eines ersetzten Entwurfs und
ohne einen einzigen Aufrufer (mechanisch gegengeprueft: alle api.*-Aufrufe
des UI gegen alle Routen).
Der Test hält sie draussen. /stream/jobs ist der Grund für diese
Absicherung: er war schon einmal ein Placebo, wurde dann "repariert" statt
entfernt - und war danach eine Endlosschleife je Verbindung ohne jeden
Verbraucher. Wer echtes Push will, braucht BEIDE Seiten (Server UND ein
EventSource im UI).
"""
from main import app
routen = {route.path for route in app.routes}
for pfad in ("/prescan", "/jellyfin/format", "/stream/jobs",
"/worker-setup/windows-gui"):
assert pfad not in routen, (
f"{pfad} ist wieder da — entweder mit Verbraucher (dann diesen Test "
"anpassen) oder versehentlich (dann wieder raus)"
)
def test_worker_setup_routen_die_gebraucht_werden_sind_da():
"""Die Installer holen sich Code und .exe hierueber — /worker-setup/paket
ruft install.ps1 UND install-gui.ps1 auf, /windows-exe der UI-Knopf."""
from main import app
routen = {route.path for route in app.routes}
for pfad in ("/worker-setup/paket", "/worker-setup/windows",
"/worker-setup/windows-exe"):
assert pfad in routen, f"Route {pfad} fehlt — Worker-Installation kaputt"
+118
View File
@@ -0,0 +1,118 @@
"""Tests für die puren Helfer aus makemkv_daten.
Bewusst OHNE Dateisystem, DB und fcntl deshalb laufen sie auch auf Windows
und nicht nur in der Ampel. Geprueft wird genau das, was ohne Container und
ohne echte Disc entscheidbar ist: das Zeilenformat der KEYDB.cfg, die
Plausibilitaetspruefung beim Hochladen, die Namenshaerte der AACS-Dumps und
das Zusammenfuehren der settings.conf.
"""
from makemkv_daten import (
ist_aacs_dump,
keydb_pruefen,
settings_conf_zusammenfuehren,
zaehle_disc_eintraege,
)
# Echte Beispielzeilen im libaacs-Format: 40 Hex-Zeichen Disc-Kennung, dann
# "= Titel". Zweite Zeile mit 0x-Praefix, weil die oeffentlichen Dateien beide
# Schreibweisen mischen (Fundstelle steht im Modul-Docstring von makemkv_daten).
_GUELTIG = """; KEYDB.cfg — Beispiel
0123456789ABCDEF0123456789ABCDEF01234567 = Akira
0xFEDCBA9876543210FEDCBA9876543210FEDCBA98 = Blade Runner | V | 00112233445566778899AABBCCDDEEFF
"""
def test_zaehle_disc_eintraege_zaehlt_nur_echte_disc_zeilen():
# Kommentar- und Leerzeilen dürfen NICHT mitgezaehlt werden, sonst meldet
# das UI "da liegt was drin", obwohl die Datei keinen Schluessel enthält.
assert zaehle_disc_eintraege(_GUELTIG) == 2
def test_zaehle_disc_eintraege_ohne_disc_zeile_ist_null():
nur_kommentare = "; nur ein Kommentar\n\n;noch einer\n"
assert zaehle_disc_eintraege(nur_kommentare) == 0
assert zaehle_disc_eintraege("") == 0
def test_zaehle_disc_eintraege_akzeptiert_0x_praefix_einzeln():
assert zaehle_disc_eintraege("0x0123456789abcdef0123456789abcdef01234567 = Tenet") == 1
def test_zaehle_disc_eintraege_lehnt_zu_kurze_kennung_ab():
# 39 statt 40 Hex-Zeichen: das ist keine Disc-Kennung, sondern Tippfehler
# oder eine abgeschnittene Datei — darf nicht als Eintrag durchgehen.
assert zaehle_disc_eintraege("0123456789ABCDEF0123456789ABCDEF0123456 = Kurz") == 0
def test_keydb_pruefen_meldet_leere_datei():
assert keydb_pruefen("") != ""
assert keydb_pruefen(" \n\n ") != ""
def test_keydb_pruefen_erkennt_html():
# Häufigster Bedienfehler: statt der Datei landet die HTML-Fehlerseite
# eines Downloads im Feld. MakeMKV würde dann still weiter meckern.
fehler = keydb_pruefen("<!DOCTYPE html>\n<html><body>404 Not Found</body></html>\n")
assert "HTML" in fehler
def test_keydb_pruefen_meldet_datei_ohne_disc_zeile():
# Text ist da, aber keine einzige Disc-Kennung — z. B. eine Liesmich-Datei.
assert keydb_pruefen("Das hier ist irgendein Text ohne Schluessel.\n") != ""
def test_keydb_pruefen_laesst_gueltige_datei_durch():
# "" heißt laut Vertrag: alles in Ordnung, darf geschrieben werden.
assert keydb_pruefen(_GUELTIG) == ""
def test_ist_aacs_dump_erkennt_echten_namen():
# So heißt der Dump, den MakeMKV am 25.07.2026 für Akira UHD abgelegt hat
# (Meldung 3332) — dieser Name MUSS zum Download durchkommen.
assert ist_aacs_dump("MKB20_v76_UHD_AKIRA_C02B.tgz") is True
def test_ist_aacs_dump_blockt_pfad_tricks():
# Der Download-Endpunkt hängt den Namen an das Datenverzeichnis — ein
# durchgelassenes ".." oder ein Pfadtrenner wäre ein Ausbruch.
assert ist_aacs_dump("../x.tgz") is False
assert ist_aacs_dump("../../etc/passwd.tgz") is False
assert ist_aacs_dump("unter/ordner.tgz") is False
assert ist_aacs_dump("unter\\ordner.tgz") is False
assert ist_aacs_dump(".versteckt.tgz") is False
def test_ist_aacs_dump_lehnt_andere_endungen_ab():
# Nur die Dumps sollen abholbar sein — nicht settings.conf, nicht
# _private_data.tar und schon gar nicht die KEYDB.cfg selbst.
assert ist_aacs_dump("KEYDB.cfg") is False
assert ist_aacs_dump("settings.conf") is False
assert ist_aacs_dump("_private_data.tar") is False
assert ist_aacs_dump("") is False
def test_settings_conf_ersetzt_alten_key_und_behaelt_den_rest():
# Regression: bis 25.07.2026 wurde die Datei komplett überschrieben. Mit
# dem jetzt persistenten Datenverzeichnis wäre app_UpdateEnable vor jedem
# Rip weg gewesen.
alt = 'app_UpdateEnable = "1"\napp_Key = "T-alt"\napp_DestinationDir = "/tmp"\n'
neu = settings_conf_zusammenfuehren(alt, "T-neu")
assert 'app_Key = "T-neu"' in neu
assert "T-alt" not in neu
assert 'app_UpdateEnable = "1"' in neu
assert 'app_DestinationDir = "/tmp"' in neu
def test_settings_conf_leerer_key_entfernt_die_zeile():
# Ein bewusst geleerter Key darf nicht heimlich weiterwirken.
neu = settings_conf_zusammenfuehren('app_Key = "T-alt"\napp_UpdateEnable = "1"\n', "")
assert "app_Key" not in neu
assert 'app_UpdateEnable = "1"' in neu
def test_settings_conf_aus_dem_nichts_endet_mit_zeilenumbruch():
# Erster Start: es gibt noch keine settings.conf. MakeMKV erwartet eine
# Datei mit abschliessendem Zeilenumbruch.
assert settings_conf_zusammenfuehren("", "T-neu") == 'app_Key = "T-neu"\n'
assert settings_conf_zusammenfuehren("", "") == ""
+3 -3
View File
@@ -1,4 +1,4 @@
"""Tests fuer den MakeMKV-Beta-Key-Parser (dependency-frei, nur extract_key)."""
"""Tests für den MakeMKV-Beta-Key-Parser (dependency-frei, nur extract_key)."""
from makemkv_key import extract_key
# Beispiel-Key im echten Format (T- + 64 Zeichen [A-Za-z0-9_]).
@@ -16,12 +16,12 @@ def test_extract_key_ohne_key_gibt_none():
def test_extract_key_zu_kurz_kein_treffer():
# Format ist streng T- + genau 64 Zeichen — zu kurz faellt raus.
# Format ist streng T- + genau 64 Zeichen — zu kurz fällt raus.
assert extract_key("T-zukurz123") is None
def test_extract_key_liefert_ganzen_key():
# Regression: die Regex darf den Key NICHT abschneiden (Beta-Keys sind laenger
# Regression: die Regex darf den Key NICHT abschneiden (Beta-Keys sind länger
# als die im Forum genannten 64 Zeichen — hier der volle Key zwischen Text).
treffer = extract_key(f"Vorher-Text {_KEY} Nachher-Text")
assert treffer == _KEY
+6
View File
@@ -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 / {
@@ -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"')
+266 -41
View File
@@ -1,57 +1,154 @@
import { useState, useEffect } from 'react'
import { Disc, Cpu, Globe, Tv, CheckCircle, ArrowRight } from 'lucide-react'
import { Disc, Cpu, Globe, Tv, CheckCircle, ArrowRight, HardDrive, AlertTriangle } from 'lucide-react'
import { api } from '../lib/api'
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
import { PRESET_KEINE, schwacheEncoderCpu } from '../lib/encoder'
import { Button } from './ui/Button'
import { Input, Select } from './ui/Input'
interface WorkerInfo {
name: string
encoders: string[]
last_seen?: string
online?: boolean
info?: { cpu_kerne?: string; cpu_simd?: string; cpu_modell?: string }
}
interface GeraetInfo {
id: string
name: string
status: string
}
interface PlatzInfo {
name: string
frei_gb: number
gesamt_gb: number
}
const ENCODER_LABELS: Record<string, string> = {
'cpu-x264': 'CPU · H.264 (x264)',
'cpu-x265': 'CPU · H.265 (x265)',
'cpu-av1': 'CPU · AV1 (SVT-AV1)',
'vaapi': 'Hardware · VAAPI (AMD/Intel)',
'nvenc': 'Hardware · NVENC (NVIDIA)',
}
// Unter diesem Wert lohnt der Hinweis auf die Ablage: eine einzelne Blu-ray
// braucht roh rund 40 GB, eine 4K-UHD bis 100 GB.
const PLATZ_WARNUNG_GB = 60
/** Was beim Abschliessen gespeichert wird — abhängig davon, was die Maschine kann. */
function presetsFuer(schwach: boolean) {
if (schwach) {
// Gemessen auf einer CPU ohne AVX2: 4K-HEVC = 28-55 h pro Film. Also 4K
// verlustfrei behalten und für den Rest H.264 nehmen (deutlich schneller
// als H.265, dafür etwas größer).
return {
transcodePreset: 'HQ 1080p30 Surround',
transcodePresetBluray: 'HQ 1080p30 Surround',
transcodePresetDvd: 'HQ 576p25 Surround',
transcodePresetUhd: PRESET_KEINE,
}
}
return {
transcodePreset: 'H.265 MKV 1080p30',
transcodePresetBluray: 'H.265 MKV 1080p30',
transcodePresetDvd: 'H.265 MKV 576p25',
transcodePresetUhd: 'H.265 MKV 2160p60 4K',
}
}
export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
const [tmdbKey, setTmdbKey] = useState('')
const [omdbKey, setOmdbKey] = useState('')
const [mediaServer, setMediaServer] = useState('jellyfin')
const [transcodeEnabled, setTranscodeEnabled] = useState(true)
const [preset, setPreset] = useState('H.265 MKV 1080p30')
const [workers, setWorkers] = useState<WorkerInfo[]>([])
const [geraete, setGeraete] = useState<GeraetInfo[]>([])
const [plaetze, setPlaetze] = useState<PlatzInfo[]>([])
const [geladen, setGeladen] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [keyWarnung, setKeyWarnung] = useState<string | null>(null)
// Wiederholt laden, bis Worker UND Laufwerk da sind: beim allerersten Start
// läuft der Worker-Container oft noch hoch. Ohne das stand hier dauerhaft
// „Noch kein Worker gemeldet" und der Nutzer wusste nicht, ob er warten soll.
useEffect(() => {
api.get('/capabilities')
.then(r => setWorkers(r.data.workers || []))
.catch(() => setWorkers([]))
let laeuft = true
const laden = async () => {
const [caps, devs, info] = await Promise.allSettled([
api.get('/capabilities'),
api.get('/devices'),
api.get('/system/info'),
])
if (!laeuft) return
if (caps.status === 'fulfilled') setWorkers(caps.value.data?.workers || [])
if (devs.status === 'fulfilled') setGeraete(devs.value.data || [])
if (info.status === 'fulfilled') setPlaetze(info.value.data?.plaetze || [])
setGeladen(true)
}
laden()
const takt = setInterval(laden, 5000)
return () => { laeuft = false; clearInterval(takt) }
}, [])
const schwach = schwacheEncoderCpu(workers)
const presets = presetsFuer(schwach)
const encoderWorker = workers.filter(w => w.encoders.length > 0)
const platz = plaetze.length > 0 ? Math.min(...plaetze.map(p => p.frei_gb)) : null
const abschliessen = async () => {
setSaving(true)
setError(null)
setKeyWarnung(null)
try {
const bestehende = (await api.get('/settings')).data || {}
await api.post('/settings', {
...bestehende,
...(tmdbKey ? { tmdbApiKey: tmdbKey } : {}),
...(omdbKey ? { omdbApiKey: omdbKey } : {}),
...(tmdbKey.trim() ? { tmdbApiKey: tmdbKey.trim() } : {}),
...(omdbKey.trim() ? { omdbApiKey: omdbKey.trim() } : {}),
mediaServer,
transcodeEnabled,
transcodePreset: preset,
...presets,
})
// Erst SPEICHERN, dann prüfen: /metadata/status liest die Keys aus der
// Datenbank, nicht aus dem Formular. Ein falsch kopierter Key fällt so
// sofort auf, statt erst beim ersten Rip als „Unknown Disc".
if (tmdbKey.trim() || omdbKey.trim()) {
try {
const s = (await api.get('/metadata/status')).data || {}
const kaputt = Object.entries(s)
.filter(([, v]) => v === 'fehler')
.map(([k]) => k.toUpperCase())
if (kaputt.length > 0) {
setKeyWarnung(
`${kaputt.join(' und ')} hat den Key nicht angenommen — vermutlich ein `
+ 'Tippfehler beim Kopieren. Rippy laeuft trotzdem, die Titel heissen dann '
+ 'nur wie das Disc-Label. Nachbessern: Einstellungen → APIs.'
)
setSaving(false)
return // nicht wegklicken, damit die Warnung gelesen wird
}
} catch {
// Prüfung selbst kaputt → kein Grund, die Einrichtung zu blockieren
}
}
await api.post('/setup/complete')
onDone()
} catch {
setError('Speichern fehlgeschlagen — läuft die API? (Seite neu laden hilft meist.)')
setSaving(false)
}
}
const trotzdemWeiter = async () => {
setSaving(true)
try {
await api.post('/setup/complete')
onDone()
} catch {
setError('Speichern fehlgeschlagen — läuft die API?')
} finally {
setSaving(false)
}
}
@@ -64,25 +161,65 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
<Disc size={32} className="animate-disc-spin" />
<h1 className="text-2xl font-bold">Willkommen bei Rippy</h1>
</div>
<p className="opacity-90">Einmalige Einrichtung dauert keine zwei Minuten. Alles ist später unter Einstellungen änderbar.</p>
<p className="opacity-90">
Einmalige Einrichtung dauert keine zwei Minuten. Du kannst nichts kaputt
machen: alles ist später unter Einstellungen änderbar.
</p>
</div>
<div className="p-8 space-y-8">
{/* Metadaten-APIs */}
{/* ---- Was Rippy gerade sieht ---------------------------------- */}
<section>
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
<HardDrive size={18} className="text-amber-500" /> Was Rippy gerade sieht
</h2>
<div className="rounded-xl border border-slate-200 dark:border-slate-800 divide-y divide-slate-200 dark:divide-slate-800 overflow-hidden">
<Zeile
titel="Laufwerk"
geladen={geladen}
gut={geraete.length > 0}
gutText={geraete.map(g => g.name).join(', ')}
schlechtText="Kein optisches Laufwerk gefunden. Rippy kann dann nur komprimieren, nicht rippen. Prüfe, ob das Laufwerk angeschlossen und (bei einer VM) durchgereicht ist."
/>
<Zeile
titel="Encoding-Worker"
geladen={geladen}
gut={encoderWorker.length > 0}
gutText={encoderWorker.map(w => {
const kerne = w.info?.cpu_kerne
const simd = w.info?.cpu_simd
const zusatz = [kerne ? `${kerne} Kerne` : '', simd && simd !== 'unbekannt' ? simd : '']
.filter(Boolean).join(', ')
return zusatz ? `${w.name} (${zusatz})` : w.name
}).join(' · ')}
schlechtText="Noch kein Worker gemeldet. Beim ersten Start dauert das bis zu einer Minute — diese Anzeige aktualisiert sich selbst. Bleibt es dabei, läuft der Worker-Container nicht."
/>
<Zeile
titel="Freier Platz"
geladen={geladen}
gut={platz === null || platz >= PLATZ_WARNUNG_GB}
gutText={platz === null ? '—' : `${platz.toFixed(0)} GB`}
schlechtText={`Nur ${platz?.toFixed(0)} GB frei. Eine Blu-ray braucht roh rund 40 GB, eine 4K-UHD bis 100 GB. Lege die Ablage besser auf eine NAS-Freigabe (Einstellungen → Speicherziele).`}
/>
</div>
</section>
{/* ---- Metadaten ---------------------------------------------- */}
<section>
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
<Globe size={18} className="text-amber-500" /> Metadaten-Erkennung
</h2>
<div className="space-y-3">
{/* Bewusst SICHTBAR statt type=password: Das sind kopierte Keys,
keine Passwoerter und einen Tippfehler sieht man in Punkten
nicht. Geprueft wird direkt nach dem Speichern. */}
<Input
type="password"
label="TMDB API-Key (empfohlen)"
value={tmdbKey}
onChange={e => setTmdbKey(e.target.value)}
placeholder="Kostenlos auf themoviedb.org"
placeholder="Kostenlos auf themoviedb.org — hier einfügen"
/>
<Input
type="password"
label="OMDb API-Key (optional, zweite Quelle)"
value={omdbKey}
onChange={e => setOmdbKey(e.target.value)}
@@ -90,11 +227,12 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
/>
<p className="text-xs text-slate-500 dark:text-slate-400">
Ohne Keys rippt Rippy trotzdem Discs heißen dann nur wie ihr Volume-Label.
Beide Keys werden direkt nach dem Speichern geprüft.
</p>
</div>
</section>
{/* Media-Server */}
{/* ---- Media-Server ------------------------------------------- */}
<section>
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
<Tv size={18} className="text-amber-500" /> Dein Media-Server
@@ -125,22 +263,74 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
</p>
</section>
{/* Verarbeitung + erkannte Hardware */}
{/* ---- Verarbeitung ------------------------------------------- */}
<section>
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
<Cpu size={18} className="text-amber-500" /> Verarbeitung
</h2>
<div className="rounded-xl p-4 mb-3 bg-slate-50 dark:bg-slate-950/80 border border-slate-200 dark:border-slate-800">
<p className="text-sm font-medium mb-2 text-slate-700 dark:text-slate-300">Erkannte Encoder in deinem System:</p>
{workers.length === 0 ? (
<p className="text-sm text-slate-500 dark:text-slate-400">Noch kein Worker gemeldet startet gerade?</p>
<label className="flex items-start gap-3 mb-3 text-sm text-slate-700 dark:text-slate-300">
<input
type="checkbox"
checked={transcodeEnabled}
onChange={e => setTranscodeEnabled(e.target.checked)}
className="w-4 h-4 mt-0.5 accent-amber-500 rounded"
/>
<span>
Nach dem Rip auf Arbeitsgröße komprimieren
<span className="block text-xs text-slate-500 dark:text-slate-400">
Empfohlen ohne Kompression braucht jede Blu-ray rund 40 GB.
</span>
</span>
</label>
{transcodeEnabled && (
<div className={`rounded-xl p-4 border ${
schwach
? 'bg-amber-500/10 border-amber-500/30'
: 'bg-slate-50 dark:bg-slate-950/80 border-slate-200 dark:border-slate-800'
}`}>
{/* Der Kern dieser Seite: Die Empfehlung folgt der GEMESSENEN
Rechenleistung. Vorher stand hier H.265 als Standard - auf
einer CPU ohne AVX2 sind das 28-55 Stunden je 4K-Film, und
genau das ist am 25.07.2026 passiert. */}
{schwach ? (
<>
<p className="text-sm font-medium flex items-start gap-2 text-amber-700 dark:text-amber-300">
<AlertTriangle size={16} className="mt-0.5 flex-shrink-0" />
<span>Rippy hat deine Rechenleistung gemessen und passt die Wahl an.</span>
</p>
<p className="text-xs mt-2 text-amber-700/90 dark:text-amber-300/90">
Keiner deiner Worker kann AVX2 die Vektorbefehle, von denen H.265
lebt. 4K in H.265 würde hier <strong>ein bis zwei Tage pro Film</strong>
{' '}dauern. Deshalb wird eingestellt:
</p>
</>
) : (
workers.map(w => (
<div key={w.name} className="mb-1">
<span className="text-xs font-mono text-slate-500 dark:text-slate-400">{w.name}: </span>
{w.encoders.map(e => (
<span key={e} className={`inline-block text-xs px-2 py-0.5 rounded font-medium mr-1 ${
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
Deine Worker sind schnell genug für H.265 Rippy stellt ein:
</p>
)}
<ul className="mt-2 space-y-1 text-xs text-slate-600 dark:text-slate-400">
<li>
<strong>4K-UHD:</strong>{' '}
{presets.transcodePresetUhd === PRESET_KEINE
? 'nicht komprimieren, verlustfrei behalten (20100 GB je Film)'
: presets.transcodePresetUhd}
</li>
<li><strong>Blu-ray:</strong> {presets.transcodePresetBluray}</li>
<li><strong>DVD:</strong> {presets.transcodePresetDvd}</li>
</ul>
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
Alles einzeln änderbar unter Einstellungen Verarbeitung.
</p>
</div>
)}
{encoderWorker.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1">
{encoderWorker.flatMap(w => w.encoders).filter((e, i, a) => a.indexOf(e) === i).map(e => (
<span key={e} className={`text-xs px-2 py-0.5 rounded font-medium ${
e.startsWith('cpu')
? 'bg-slate-200 dark:bg-slate-800 text-slate-600 dark:text-slate-300'
: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'
@@ -149,24 +339,28 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
</span>
))}
</div>
))
)}
</div>
<label className="flex items-center gap-3 mb-3 text-sm text-slate-700 dark:text-slate-300">
<input type="checkbox" checked={transcodeEnabled} onChange={e => setTranscodeEnabled(e.target.checked)} className="w-4 h-4 accent-amber-500 rounded" />
Nach dem Rip auf Arbeitsgröße komprimieren (empfohlen sonst ~40 GB pro Blu-ray)
</label>
{transcodeEnabled && (
<Select value={preset} onChange={e => setPreset(e.target.value)}>
<option value="H.265 MKV 1080p30">H.265 klein &amp; modern (Standard)</option>
<option value="HQ 1080p30 Surround">H.264 schneller auf schwacher CPU, etwas größer</option>
</Select>
)}
</section>
{error && <p className="text-sm text-rose-500">{error}</p>}
{keyWarnung && (
<div className="p-4 rounded-xl bg-amber-500/10 border border-amber-500/30">
<p className="text-sm text-amber-700 dark:text-amber-300">{keyWarnung}</p>
<div className="flex gap-2 mt-3">
<Button variant="secondary" size="sm" onClick={() => setKeyWarnung(null)}>
Key korrigieren
</Button>
<Button variant="secondary" size="sm" onClick={trotzdemWeiter} disabled={saving}>
Trotzdem fertigstellen
</Button>
</div>
</div>
)}
{error && (
<p className="text-sm p-3 rounded-lg bg-rose-500/10 border border-rose-500/30 text-rose-600 dark:text-rose-400">
{error}
</p>
)}
<Button
variant="amber"
@@ -186,3 +380,34 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
</div>
)
}
/** Eine Zeile im Was Rippy gerade sieht"-Kasten: gruen, wenn es passt, sonst
* bernstein MIT Handlungsanweisung. Nie nur ein Kreuz ohne Erklaerung. */
function Zeile({ titel, geladen, gut, gutText, schlechtText }: {
titel: string
geladen: boolean
gut: boolean
gutText: string
schlechtText: string
}) {
if (!geladen) {
return (
<div className="flex items-center gap-3 p-3">
<span className="w-2 h-2 rounded-full bg-slate-300 dark:bg-slate-600 animate-pulse flex-shrink-0" />
<span className="text-sm text-slate-500 dark:text-slate-400">{titel} wird geprüft</span>
</div>
)
}
return (
<div className="flex items-start gap-3 p-3">
<span className={`w-2 h-2 mt-1.5 rounded-full flex-shrink-0 ${gut ? 'bg-emerald-500' : 'bg-amber-500'}`} />
<div className="min-w-0">
<p className="text-sm text-slate-700 dark:text-slate-300">
<span className="font-medium">{titel}:</span>{' '}
{gut ? gutText : <span className="text-amber-600 dark:text-amber-400">nicht bereit</span>}
</p>
{!gut && <p className="text-xs mt-0.5 text-amber-700/90 dark:text-amber-300/90">{schlechtText}</p>}
</div>
</div>
)
}
+60 -2
View File
@@ -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 {
@@ -29,7 +38,8 @@ interface WorkerWahl {
}
const ENCODER_KURZ: Record<string, string> = {
'cpu-x264': 'H.264', 'cpu-x265': 'H.265', 'vaapi': 'VAAPI⚡', 'nvenc': 'NVENC⚡',
'cpu-x264': 'H.264', 'cpu-x265': 'H.265', 'cpu-av1': 'AV1',
'vaapi': 'VAAPI⚡', 'nvenc': 'NVENC⚡',
}
interface TitelInfo {
@@ -112,6 +122,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 +140,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 +151,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 +185,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 +267,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 -1
View File
@@ -378,7 +378,7 @@ export default function StorageMounts() {
label="Rechner (dein PC oder NAS — Name oder IP)"
value={host}
onChange={e => setHost(e.target.value)}
placeholder="192.168.178.20 oder TOBIS-PC"
placeholder="z. B. 192.168.1.20 oder MEIN-NAS"
/>
</div>
<Input
+27 -2
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'
import { Server, RefreshCw, Copy, CheckCircle, Trash2, Download } from 'lucide-react'
import { api } from '../lib/api'
import { simdWarnung } from '../lib/encoder'
import { useToast } from '../context/ToastContext'
import { Card, CardHeader, CardTitle, CardContent } from './ui/Card'
import { Button } from './ui/Button'
@@ -16,16 +17,22 @@ interface WorkerInfo {
ip?: string
makemkv?: string
handbrake?: string
cpu_modell?: string
cpu_kerne?: string
cpu_simd?: string
handbrake_encoder?: string
}
}
const ENCODER_LABELS: Record<string, string> = {
'cpu-x264': 'CPU · H.264',
'cpu-x265': 'CPU · H.265',
'cpu-av1': 'CPU · AV1 (SVT)',
'vaapi': 'GPU · VAAPI (AMD/Intel)',
'nvenc': 'GPU · NVENC (NVIDIA)',
}
function relativeZeit(iso?: string): string {
if (!iso) return 'nie'
const sekunden = Math.floor((Date.now() - new Date(iso).getTime()) / 1000)
@@ -79,9 +86,14 @@ export default function WorkerVerwaltung() {
` docker compose -f deploy/remote-transcode-worker.yml up -d --build`,
].join('\n')
: [
// Standard-Ziel ist "C:\Program Files\Rippy Worker" - dorthin darf nur
// ein Administrator schreiben, deshalb der Hinweis direkt im Befehl.
// Die .exe fragt selbst per UAC; hier muss die Konsole erhöht sein.
`# PowerShell als Administrator starten (Standard-Ziel: Programme)`,
`iwr http://${hostFuerBefehl}/api/worker-setup/windows -OutFile install-rippy-worker.ps1`,
`powershell -ExecutionPolicy Bypass -File .\\install-rippy-worker.ps1 \``,
` -RippyHost ${hostFuerBefehl} -WorkerName mein-pc`,
`# Ohne Adminrechte: -InstallDir "$env:LOCALAPPDATA\\Rippy Worker" anhaengen`,
].join('\n')
const kopieren = () => {
@@ -134,6 +146,18 @@ export default function WorkerVerwaltung() {
{w.info?.ip ? ` · IP ${w.info.ip}` : ''}
{w.info?.hostname && w.info.hostname !== w.name ? ` · ID ${w.info.hostname}` : ''}
</p>
{(w.info?.cpu_kerne || w.info?.cpu_simd) && (
<p className="text-xs text-slate-500 dark:text-slate-400" title={w.info?.cpu_modell || ''}>
{w.info?.cpu_kerne ? `${w.info.cpu_kerne} Kerne` : ''}
{w.info?.cpu_kerne && w.info?.cpu_simd ? ' · ' : ''}
{w.info?.cpu_simd ? `Vektorbefehle ${w.info.cpu_simd}` : ''}
</p>
)}
{simdWarnung(w.info?.cpu_simd) && (
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400 max-w-xl">
{simdWarnung(w.info?.cpu_simd)}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
@@ -180,7 +204,7 @@ export default function WorkerVerwaltung() {
label="LAN-IP der Rippy-Maschine (Docker-Host)"
value={rippyHost}
onChange={e => setRippyHost(e.target.value.trim())}
placeholder="z. B. 192.168.178.162"
placeholder="z. B. 192.168.1.10"
/>
<p className="text-xs mt-2 text-slate-500 dark:text-slate-400">
Die IP der Maschine, auf der <strong>Rippy selbst</strong> (Docker) läuft meist deine
@@ -192,7 +216,8 @@ export default function WorkerVerwaltung() {
{siehtOeffentlichAus && (
<p className="text-xs mt-2 p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-700 dark:text-amber-300">
Das sieht nach einer externen Domain aus (Reverse-Proxy) die geht nicht. Bitte die
<strong> LAN-IP</strong> der Rippy-Maschine eintragen, z. B. 192.168.178.162.
<strong> LAN-IP</strong> der Rippy-Maschine eintragen die beginnt im Heimnetz
fast immer mit 192.168., 10. oder 172.16.172.31.
</p>
)}
</div>
+12 -2
View File
@@ -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,
)
}
+12 -1
View File
@@ -77,9 +77,20 @@ export const LOG_LEVEL_STYLES = {
},
} as const
// Encoder-Backends, wie worker/caps.py sie meldet. Hardware wird seit
// 25.07.2026 nach FAMILIE unterschieden (nvenc/qsv/vce/vaapi) — vorher landete
// eine AMD-Karte unter dem Intel-Namen „vaapi", und AV1 in Hardware war gar
// nicht sichtbar, obwohl es die beste Kombination aus Tempo und Größe ist.
export const ENCODER_BADGES: Record<string, { label: string; highlight: boolean }> = {
'cpu-x264': { label: 'H.264 (CPU)', highlight: false },
'cpu-x265': { label: 'H.265 (CPU)', highlight: false },
'cpu-av1': { label: 'AV1 (CPU)', highlight: false },
'nvenc': { label: 'NVENC ⚡ (NVIDIA)', highlight: true },
'nvenc-av1': { label: 'AV1 · NVENC ⚡', highlight: true },
'qsv': { label: 'QuickSync ⚡ (Intel)', highlight: true },
'qsv-av1': { label: 'AV1 · QuickSync ⚡', highlight: true },
'vce': { label: 'VCE ⚡ (AMD)', highlight: true },
'vce-av1': { label: 'AV1 · VCE ⚡', highlight: true },
'vaapi': { label: 'VAAPI ⚡', highlight: true },
'nvenc': { label: 'NVENC ⚡', highlight: true },
'vaapi-av1': { label: 'AV1 · VAAPI ⚡', highlight: true },
}
+55
View File
@@ -0,0 +1,55 @@
// Encoder-Fähigkeiten eines Workers in Klartext übersetzen.
//
// Befund 25.07.2026: Die Rippy-VM lief auf dem generischen QEMU-CPU-Modell
// („QEMU Virtual CPU version 2.5+") und hatte deshalb kein AVX2, nur sse4_2.
// x265 lebt von diesen Vektorbefehlen — ein 4K-Encode brauchte dort gemessene
// 28-55 Stunden, und nirgends im UI war das zu sehen. Der Worker meldet die
// Angaben seit dieser Runde selbst (worker/caps.py), hier werden sie gedeutet.
// Stufen, mit denen Software-Encoding brauchbar schnell ist.
export const SIMD_SCHNELL = ['avx512f', 'avx2']
/**
* Warnt, wenn die CPU dieses Workers zu schwach für Software-Encoding ist.
* Gibt null zurück, wenn alles in Ordnung ist ODER die Stufe unbekannt ist
* lieber nichts sagen als etwas Falsches behaupten.
*/
export function simdWarnung(simd?: string): string | null {
if (!simd || simd === 'unbekannt') return null
if (SIMD_SCHNELL.includes(simd)) return null
return `Diese CPU kann nur ${simd} (kein AVX2) — Software-Encoding ist hier `
+ 'sehr langsam. Bei einer virtuellen Maschine hilft es meist, den '
+ 'CPU-Typ auf "host" zu stellen; sonst besser einen Worker mit '
+ 'Hardware-Encoder wählen.'
}
// Reservierter Wert im Preset-Feld: „diesen Disc-Typ NICHT komprimieren".
// Muss mit ripping.PRESET_KEINE im Worker übereinstimmen — es gibt kein
// geteiltes Paket zwischen UI und Worker, deshalb steht der Wert hier nochmal.
export const PRESET_KEINE = 'keine'
interface WorkerFuerBewertung {
encoders?: string[]
info?: { cpu_simd?: string }
}
/**
* Kann von den gemeldeten Workern KEINER 4K in Software brauchbar schnell
* encodieren? Wahr nur, wenn das auch belegt ist: mindestens ein Worker hat
* eine bekannte SIMD-Stufe, und keiner erreicht AVX2 und keiner hat einen
* Hardware-Encoder, der die Frage sowieso erledigt.
*
* Bei unbekannter Stufe (z. B. Windows-Worker, dort gibt es kein
* /proc/cpuinfo) wird NICHT gewarnt. Lieber schweigen als falsch warnen.
*/
export function schwacheEncoderCpu(workers?: WorkerFuerBewertung[]): boolean {
const liste = workers || []
if (liste.length === 0) return false
const hardware = liste.some(w => (w.encoders || []).some(e => !e.startsWith('cpu')))
if (hardware) return false
const bekannte = liste
.map(w => w.info?.cpu_simd)
.filter((s): s is string => !!s && s !== 'unbekannt')
if (bekannte.length === 0) return false
return !bekannte.some(s => SIMD_SCHNELL.includes(s))
}
+32 -5
View File
@@ -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
+598 -28
View File
@@ -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'
@@ -10,6 +10,7 @@ import { Button } from '../components/ui/Button'
import { Input, Select, Toggle } from '../components/ui/Input'
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
import { ENCODER_BADGES } from '../lib/design'
import { PRESET_KEINE, schwacheEncoderCpu, simdWarnung } from '../lib/encoder'
interface SettingsState {
tmdbApiKey: string
@@ -19,13 +20,18 @@ interface SettingsState {
movieDir: string
seriesDir: string
musicDir: string
ripAllTracks: boolean
mainFeatureOnly: boolean
autoEject: boolean
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
@@ -42,13 +48,15 @@ const defaultSettings: SettingsState = {
movieDir: 'movies',
seriesDir: 'series',
musicDir: 'music',
ripAllTracks: true,
mainFeatureOnly: false,
autoEject: true,
autoRipStart: false,
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 +70,53 @@ 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, zählt, deshalb steht die Angabe pro Worker und nicht global.
// cpu_kerne/cpu_simd: Rechenleistung fürs Software-Encodieren. Ohne diese
// Angaben war am 25.07.2026 nicht zu sehen, dass ein 4K-Encode auf der
// Rippy-VM Tage statt Stunden braucht (kein AVX2, 4 Kerne).
// handbrake_encoder: die ungefilterte Encoder-Auskunft von HandBrake selbst.
// extern: "ja" = läuft AUSSERHALB des Rippy-Containers, sieht /app/... also
// nur über eine Freigabe plus pfad_map. Der Worker meldet das selbst.
info?: {
makemkv?: string, handbrake?: string, makemkv_key?: string, keydb?: string,
cpu_modell?: string, cpu_kerne?: string, cpu_simd?: string,
handbrake_encoder?: string, extern?: string, pfad_map?: string,
}
}
// Antwort von GET/POST/DELETE /system/keydb (Feldnamen exakt wie die API sie liefert).
interface KeydbStatus {
vorhanden: boolean
pfad: string
groesse_bytes: number
eintraege: number
geaendert: string
}
// Ein Ablageziel aus GET /storage-targets (Feldnamen exakt wie die API liefert).
interface StorageZiel {
name: string
path: string
is_mount: boolean
free_gb: number | null
}
// Antwort von GET/POST /system/keystore — MakeMKVs eigener Schlüsselvorrat.
interface KeystoreStatus {
vorhanden: boolean
pfad: string
groesse_bytes: number
schluessel: number
geaendert: string
}
// Ein AACS-Dump aus GET /system/aacs-dumps.
interface AacsDump {
name: string
groesse_bytes: number
geaendert: string
}
interface SystemInfo {
@@ -73,6 +127,24 @@ interface SystemInfo {
webhook_gesetzt: boolean
}
// Byte-Zahl menschenlesbar — eine echte KEYDB.cfg ist mehrere MB groß.
function bytesLesbar(bytes: number): string {
if (!bytes) return '0 B'
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`
return `${bytes} B`
}
// Die API liefert ISO-8601 in UTC (oder einen leeren String). Ohne Zonen-Kennung
// würde der Browser den Zeitstempel als Ortszeit lesen und die Uhrzeit um den
// Zonen-Versatz verschieben — deshalb notfalls ein Z anhaengen.
function zeitLesbar(iso: string): string {
if (!iso) return 'unbekannt'
const mitZone = /[Zz]$|[+-]\d{2}:?\d{2}$/.test(iso) ? iso : `${iso}Z`
const d = new Date(mitZone)
return isNaN(d.getTime()) ? 'unbekannt' : d.toLocaleString('de-DE')
}
export default function SettingsPage() {
const [settings, setSettings] = useState<SettingsState>(defaultSettings)
const [activeTab, setActiveTab] = useState<SettingsTab>('ripping')
@@ -86,6 +158,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 würde sie mitschleifen.
const [keydb, setKeydb] = useState<KeydbStatus | null>(null)
const [keydbBusy, setKeydbBusy] = useState(false)
const [dumps, setDumps] = useState<AacsDump[]>([])
const keydbInput = useRef<HTMLInputElement>(null)
// Ablageziele unter /app/media inkl. eingehängter Freigaben — speist die
// Auswahl des Arbeitsverzeichnisses (vorher musste man den Pfad tippen).
const [ziele, setZiele] = useState<StorageZiel[]>([])
const [keystore, setKeystore] = useState<KeystoreStatus | null>(null)
const [keystoreBusy, setKeystoreBusy] = useState(false)
const keystoreInput = useRef<HTMLInputElement>(null)
const { toast } = useToast()
@@ -106,6 +191,69 @@ export default function SettingsPage() {
}
}
// Status der KEYDB.cfg + Liste der AACS-Dumps frisch holen. Wird beim Laden
// der Seite und nach jeder Aktion aufgerufen — die API ist die Wahrheit,
// nicht das, was wir gerade hochgeschickt haben.
const keydbLaden = () => {
api.get('/system/keydb').then(r => setKeydb(r.data)).catch(() => setKeydb(null))
api.get('/system/keystore').then(r => setKeystore(r.data)).catch(() => setKeystore(null))
api.get('/system/aacs-dumps').then(r => setDumps(r.data?.dumps || [])).catch(() => setDumps([]))
// Die Worker-Plaketten oben kommen aus /capabilities und stammen aus dem
// letzten Herzschlag des Workers (minuetlich) — ohne dieses Nachladen
// widerspraeche die Kachel nach einem Upload sichtbar der Erfolgsmeldung.
// Ganz sofort ist sie trotzdem nicht: der Worker meldet erst beim
// nächsten Herzschlag neu. Genau so steht es auch im Erklaertext.
api.get('/capabilities').then(r => setWorkers(r.data.workers || [])).catch(() => setWorkers([]))
}
const keydbHochladen = async (datei: File) => {
setKeydbBusy(true)
try {
// Bewusst als JSON-Text und nicht als Multipart-Upload: der API fehlt
// python-multipart, ein File()-Endpunkt würde sie beim Import killen.
const inhalt = await datei.text()
await api.post('/system/keydb', { inhalt })
keydbLaden()
toast('success', 'KEYDB.cfg gespeichert — sie wirkt ab dem nächsten Rip.')
} catch (e: any) {
toast('error', e?.response?.data?.detail || 'Hochladen fehlgeschlagen — ist das wirklich eine KEYDB.cfg, und läuft die API?')
} finally {
setKeydbBusy(false)
}
}
const keystoreHochladen = async (datei: File) => {
setKeystoreBusy(true)
try {
// Die Datei wandert als roher Anfrage-Körper zur API — _private_data.tar
// ist binär, JSON oder Base64 wäre nur unnötiger Ballast, und Multipart
// kann die API nicht (kein python-multipart).
await api.post('/system/keystore', datei, {
headers: { 'Content-Type': 'application/octet-stream' },
})
keydbLaden()
toast('success', 'Schlüsselspeicher übernommen — wirkt ab dem nächsten Rip.')
} catch (e: any) {
toast('error', e?.response?.data?.detail
|| 'Übernahme fehlgeschlagen — ist das wirklich die Datei _private_data.tar, und läuft die API?')
} finally {
setKeystoreBusy(false)
}
}
const keydbEntfernen = async () => {
setKeydbBusy(true)
try {
await api.delete('/system/keydb')
keydbLaden()
toast('success', 'KEYDB.cfg entfernt — Rippy nutzt jetzt keine Schlüsseldatei mehr.')
} catch (e: any) {
toast('error', e?.response?.data?.detail || 'Entfernen fehlgeschlagen — läuft die API?')
} finally {
setKeydbBusy(false)
}
}
const quellenPruefen = async () => {
setQuellenBusy(true)
try {
@@ -131,6 +279,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 () => {
@@ -187,6 +347,40 @@ export default function SettingsPage() {
)
}
// Erreichbarkeit für EXTERNE Encoder. Ein Worker auf einem anderen Rechner
// sieht die Container-Platte nicht — nur eingehängte Netzwerk-Freigaben, die
// er selbst auch erreicht. Liegen Ablage und Arbeitsverzeichnis nicht BEIDE
// auf einer solchen Freigabe, scheitert die Kompression dort zwangsläufig.
//
// Genau das ist am 25.07.2026 passiert: Rohdaten auf der NAS (erreichbar),
// Ziel auf der VM-Platte (nicht erreichbar) — der Fehler kam erst nach einer
// Stunde Rippen. Diese Prüfung sagt es vorher.
// Der Worker sagt selbst, ob er außerhalb des Containers läuft (caps.py
// meldet `extern`) — keine Heuristik über IPs oder Namen nötig.
const externerWorkerVorhanden = workers.some(w => w.info?.extern === 'ja')
const istFreigabe = (pfad: string) =>
ziele.some(z => z.path === pfad && z.is_mount)
const ablageWarnung = (() => {
const ablage = settings.outputDir || '/app/media'
const arbeit = settings.workDir
if (!arbeit) {
return 'Das Arbeitsverzeichnis steht auf der Container-Platte. Ein Encoder auf '
+ 'einem anderen Rechner kann die Rohdaten dort nicht lesen — lege beides auf '
+ 'dieselbe Netzwerk-Freigabe (Einstellungen → Verarbeitung).'
}
if (!istFreigabe(ablage) && istFreigabe(arbeit)) {
return 'Arbeitsverzeichnis liegt auf einer Freigabe, die Ablage aber auf der '
+ 'Container-Platte. Ein externer Encoder kann dann lesen, aber nicht schreiben — '
+ 'genau daran scheiterte der erste Versuch. Stelle die Ablage auf dieselbe Freigabe.'
}
if (istFreigabe(ablage) && istFreigabe(arbeit) && ablage !== arbeit) {
return 'Ablage und Arbeitsverzeichnis liegen auf VERSCHIEDENEN Freigaben. Das '
+ 'funktioniert, kostet am Ende aber eine Vollkopie statt eines Umhängens. '
+ 'Dieselbe Freigabe für beides ist deutlich schneller.'
}
return ''
})()
const tabs: { id: SettingsTab; label: string; icon: any }[] = [
{ id: 'ripping', label: 'Ripping', icon: Disc },
{ id: 'verarbeitung', label: 'Verarbeitung', icon: Cpu },
@@ -276,14 +470,16 @@ export default function SettingsPage() {
🎵 Audio-CD
</p>
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
<Toggle
label="Alle Tracks rippen"
description="Rippe alle Tracks einer CD (FLAC + MusicBrainz-Tags)."
checked={settings.ripAllTracks}
onChange={(v) => handleChange('ripAllTracks', v)}
/>
</div>
{/* Der Schalter Alle Tracks rippen" ist am 25.07.2026 entfallen.
Er war ein Placebo: abcde bekommt keine Track-Auswahl (siehe
build_abcde_cmd) und es gibt auch keine Oberfläche, um eine
Teilmenge zu wählen eine Audio-CD wurde also immer
vollständig gerippt, egal wie der Schalter stand. Statt einer
Wahl, die nichts bewirkt, steht hier jetzt die Wahrheit. */}
<p className="text-xs text-slate-500 dark:text-slate-400 px-1">
💿 Eine Audio-CD wird immer vollständig gerippt alle Tracks als
FLAC, mit MusicBrainz-Tags.
</p>
<p className="text-xs font-bold uppercase tracking-wider pt-2 text-slate-400 dark:text-slate-500">
Allgemein
@@ -338,7 +534,7 @@ export default function SettingsPage() {
label="Server-URL"
value={settings.jellyfinUrl}
onChange={(e) => handleChange('jellyfinUrl', e.target.value)}
placeholder="http://192.168.178.50:8096"
placeholder="z. B. http://192.168.1.50:8096"
/>
<div className="flex gap-2 items-end">
<div className="flex-1">
@@ -404,6 +600,24 @@ export default function SettingsPage() {
{ENCODER_BADGES[e]?.label || e}
</span>
))}
{(w.info?.cpu_kerne || w.info?.cpu_simd) && (
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400" title={w.info?.cpu_modell || ''}>
{w.info?.cpu_modell ? `${w.info.cpu_modell} · ` : ''}
{w.info?.cpu_kerne ? `${w.info.cpu_kerne} Kerne` : ''}
{w.info?.cpu_kerne && w.info?.cpu_simd ? ' · ' : ''}
{w.info?.cpu_simd ? `Vektorbefehle ${w.info.cpu_simd}` : ''}
</p>
)}
{simdWarnung(w.info?.cpu_simd) && (
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400">
{simdWarnung(w.info?.cpu_simd)}
</p>
)}
{w.info?.handbrake_encoder && (
<p className="mt-1 text-xs text-slate-400 dark:text-slate-500 font-mono break-all">
HandBrake meldet: {w.info.handbrake_encoder}
</p>
)}
</div>
))
)}
@@ -419,17 +633,76 @@ export default function SettingsPage() {
/>
</div>
{/*
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="HandBrake-Preset"
value={settings.transcodePreset}
onChange={(e) => handleChange('transcodePreset', e.target.value)}
label="Preset für 4K-UHD"
value={settings.transcodePresetUhd}
onChange={(e) => handleChange('transcodePresetUhd', e.target.value)}
>
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 klein &amp; modern (Standard)</option>
<option value="H.265 MKV 2160p60 4K">H.265 MKV 2160p60 4K für UHD-Quellen</option>
<option value={PRESET_KEINE}>Nicht komprimieren 4K verlustfrei behalten</option>
<option value="H.265 MKV 2160p60 4K">H.265 MKV 2160p60 4K 4K erhalten</option>
<option value="HQ 2160p60 4K HEVC Surround">HQ 2160p60 4K HEVC Surround 4K, mehr Qualität</option>
<option value="Super HQ 2160p60 4K HEVC Surround">Super HQ 2160p60 4K HEVC Surround 4K, größer</option>
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 bewusst auf 1080p verkleinern</option>
</Select>
{/* Die Warnung, die den 50-Stunden-Lauf verhindert haette. Sie
steht nur da, wenn die Maschine sie WIRKLICH braucht -
gemessen, nicht vermutet (worker/caps.py meldet cpu_simd). */}
{settings.transcodePresetUhd !== PRESET_KEINE
&& settings.transcodePresetUhd.includes('2160')
&& schwacheEncoderCpu(workers) && (
<p className="text-xs -mt-1 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-700 dark:text-amber-300">
Keiner deiner Worker kann AVX2 4K in H.265 dauert damit
<strong> ein bis zwei Tage pro Film</strong> (gemessen, nicht geschätzt).
Besser Nicht komprimieren" wählen und die verlustfreie Datei behalten,
oder einen Worker mit Hardware-Encoder anschließen.
</p>
)}
<Select
label="Preset für Blu-ray"
value={settings.transcodePresetBluray}
onChange={(e) => handleChange('transcodePresetBluray', e.target.value)}
>
<option value={PRESET_KEINE}>Nicht komprimieren verlustfrei behalten</option>
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 klein &amp; modern (empfohlen)</option>
<option value="HQ 1080p30 Surround">HQ 1080p30 Surround H.264, maximale Kompatibilität</option>
<option value="Super HQ 1080p30 Surround">Super HQ 1080p30 Surround größer, beste Qualität</option>
</Select>
<Select
label="Preset für DVD"
value={settings.transcodePresetDvd}
onChange={(e) => handleChange('transcodePresetDvd', e.target.value)}
>
<option value={PRESET_KEINE}>Nicht komprimieren verlustfrei behalten</option>
<option value="H.265 MKV 576p25">H.265 MKV 576p25 PAL-DVD, native Auflösung (empfohlen)</option>
<option value="H.265 MKV 480p30">H.265 MKV 480p30 NTSC-DVD, native Auflösung</option>
<option value="HQ 576p25 Surround">HQ 576p25 Surround H.264, maximale Kompatibilität</option>
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 hochskalieren (bringt nichts)</option>
</Select>
<p className="text-xs text-slate-500 dark:text-slate-400">
Nicht komprimieren" heißt: Der Rip bleibt so, wie MakeMKV ihn
gesichert hat beste Qualität, aber 20100 GB je Film. Sinnvoll für
4K, wenn die Ablage groß genug ist.
</p>
</div>
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
<Toggle
label="Original behalten"
@@ -439,14 +712,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}
@@ -474,12 +768,50 @@ export default function SettingsPage() {
Speicherziele
</h2>
{/* ABLAGE bis 26.07.2026 gab es dieses Feld im UI GAR NICHT.
outputDir stand in den Einstellungen und wurde von der
Vollautomatik und der Schnellwahl gelesen, ließ sich aber
nirgends ändern: die Ablage klebte auf /app/media, also der
Container-Platte. Deshalb konnte man das Arbeitsverzeichnis auf
eine NAS legen (dafür gab es eine Auswahl), das ZIEL aber nicht
und genau daran scheiterte der erste Rip mit externem
Encoder: Rohdaten auf der erreichbaren NAS, Ziel auf der
VM-Platte, die der andere Rechner nicht sieht. */}
<div className="p-5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3">
<h3 className="font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<HardDrive size={16} className="text-amber-500" /> Ablage wohin die fertigen Filme kommen
</h3>
<Select
label="Ablage"
value={settings.outputDir}
onChange={(e) => handleChange('outputDir', e.target.value)}
>
<option value="/app/media">Container-Platte (Standard)</option>
{ziele.filter(z => z.path !== '/app/media').map(z => (
<option key={z.path} value={z.path}>
{z.name}{z.is_mount ? ' (Netzwerk-Freigabe)' : ''}
{z.free_gb != null ? `${z.free_gb} GB frei` : ''}
</option>
))}
</Select>
<p className="text-xs text-slate-500 dark:text-slate-400">
Gilt für die Schnellwahl im Rip-Dialog <strong className="text-slate-700 dark:text-slate-300">und
für die Vollautomatik</strong> dort fragt niemand nach, also entscheidet dieser Wert.
Die Unterordner darunter kommen aus der Schnellwahl.
</p>
{externerWorkerVorhanden && ablageWarnung && (
<p className="text-xs p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-700 dark:text-amber-300">
{ablageWarnung}
</p>
)}
</div>
<div className="p-5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3">
<h3 className="font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<Database size={16} className="text-amber-500" /> Schnellwahl beim Rippen
</h3>
<p className="text-xs text-slate-500 dark:text-slate-400">
Name der Ordner im Rippen starten"-Dialog (🎥/📺/🎵).
Name der Ordner im Rippen starten"-Dialog (🎥/📺/🎵) jeweils unterhalb der Ablage.
</p>
<div className="grid grid-cols-3 gap-3">
{([['movieDir', '🎥 Filme'], ['seriesDir', '📺 Serien'], ['musicDir', '🎵 Musik']] as const).map(([feld, label]) => (
@@ -633,13 +965,42 @@ export default function SettingsPage() {
HandBrake {w.info.handbrake}
</span>
)}
{/*
Nur das Verzeichnis des rippenden Workers zählt deshalb je Worker.
Die Zahl der Disc-Schluessel ist die entscheidende Angabe für 4K-UHD:
0 heißt, dass JEDE unbekannte UHD-Disc scheitert.
*/}
{w.info?.schluessel && w.info.schluessel !== '0' && w.info.schluessel !== 'unbekannt' && (
<span className="text-xs px-2.5 py-0.5 rounded-md font-medium bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30">
{w.info.schluessel} Disc-Schlüssel
</span>
)}
{w.info?.schluessel === '0' && (
<span className="text-xs px-2.5 py-0.5 rounded-md font-medium bg-amber-500/15 text-amber-600 dark:text-amber-400 border border-amber-500/30">
keine Disc-Schlüssel 4K-UHD scheitert
</span>
)}
{w.info?.keydb === 'ja' && (
<span className="text-xs px-2.5 py-0.5 rounded-md font-medium bg-slate-200 dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-300 dark:border-slate-700">
+ KEYDB.cfg
</span>
)}
</div>
))
)}
{/*
Bis 25.07.2026 stand hier, MakeMKV-Updates braechten die neueste
Disc-Schluessel-Datenbank mit. Nachgemessen: falsch MakeMKV liefert
gar keine Schluessel mit, es holt sie zur Laufzeit. Und die
Linux-Version holt sie nie (kein einziger Verbindungsversuch, auf
beiden Maschinen verglichen). Deshalb der Schlüsselspeicher unten.
*/}
<p className="text-xs mt-3 pt-3 border-t border-slate-200/60 dark:border-slate-800 text-slate-500 dark:text-slate-400">
<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 +1016,213 @@ export default function SettingsPage() {
placeholder="T-… (Forum-Thread „MakeMKV is free while in beta”)"
/>
{/*
Schlüsselspeicher der Hauptweg für 4K-UHD (Befund 25.07.2026,
auf beiden Maschinen gemessen): makemkvcon holt Disc-Schluessel
unter Linux NIE selbst, die Windows-Version tut es (Meldung 3338,
Verbindung nach 185.84.108.20:443). Deshalb wird der Vorrat hier
von Hand hereingereicht. Frueher stand an dieser Stelle die These,
MakeMKVs Schluessel-Kanal sei abgeschaltet das war falsch.
*/}
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-slate-900 dark:text-slate-200 flex items-center gap-2">
<Database size={16} className="text-amber-500" />
Disc-Schlüssel für 4K-UHD
</p>
<div className="flex items-center gap-2">
<input
type="file"
className="hidden"
ref={keystoreInput}
accept=".tar"
onChange={(e) => {
const datei = e.target.files?.[0]
e.target.value = ''
if (datei) keystoreHochladen(datei)
}}
/>
<Button
variant="amber"
size="sm"
onClick={() => keystoreInput.current?.click()}
disabled={keystoreBusy}
>
<Upload size={14} />
{keystoreBusy ? 'Übernehme…' : '_private_data.tar übernehmen'}
</Button>
</div>
</div>
{keystore?.vorhanden && keystore.schluessel > 0 ? (
<div className="p-3 rounded-lg text-sm bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30">
<p className="font-semibold">{keystore.schluessel} Disc-Schlüssel vorhanden.</p>
<p className="text-xs mt-1">
{bytesLesbar(keystore.groesse_bytes)} · zuletzt geändert {zeitLesbar(keystore.geaendert)}
</p>
</div>
) : (
<div className="p-3 rounded-lg text-sm bg-amber-500/15 text-amber-600 dark:text-amber-400 border border-amber-500/30">
<p className="font-semibold">Kein einziger Disc-Schlüssel vorhanden.</p>
<p className="text-xs mt-1">
DVDs und normale Blu-rays laufen trotzdem. 4K-UHD-Discs scheitern dagegen mit
The volume key is unknown for this disc" solange hier nichts liegt, jede einzelne.
</p>
</div>
)}
<p className="text-xs text-slate-500 dark:text-slate-400">
<strong className="text-slate-700 dark:text-slate-300">Warum das nötig ist:</strong> MakeMKV
holt sich diese Schlüssel eigentlich selbst aus dem Netz. Die <strong>Linux-Version tut das
nicht</strong> am 25.07.2026 nachgemessen: sie baut dabei nicht eine einzige Verbindung auf.
Die Windows-Version schon. Ein MakeMKV-Update ändert daran nichts, es ist kein Fehler in Rippy.
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
<strong className="text-slate-700 dark:text-slate-300">So füllst du den Vorrat:</strong> MakeMKV
auf einem Windows-PC installieren (gleicher Beta-Key), das Laufwerk dort anstecken und die Disc
einmal öffnen. MakeMKV lädt die Schlüssel dabei nach. Danach unter <em>Preferences General</em>
das MakeMKV data directory" nachschlagen, die Datei <code>_private_data.tar</code> daraus hier
hochladen fertig. Für neue Discs von Zeit zu Zeit wiederholen.
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
Das ist der Zwischenspeicher <strong className="text-slate-700 dark:text-slate-300">deiner
eigenen</strong> MakeMKV-Installation, über MakeMKVs offiziellen Weg mit deiner eigenen Lizenz
geholt. Rippy liefert keine Schlüssel mit und lädt keine herunter.
</p>
</div>
{/*
KEYDB.cfg der NOTNAGEL, nicht der Hauptweg. Sie hilft bei Pressungen,
die auch MakeMKV selbst nicht kennt. Der Regelfall läuft über den
Schlüsselspeicher im Block darüber. Dateiname GROSS geschrieben, unter
Linux case-sensitiv. Rippy stellt nur den Platz bereit.
*/}
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-slate-900 dark:text-slate-200 flex items-center gap-2">
<KeyRound size={16} className="text-amber-500" />
KEYDB.cfg (Notnagel)
</p>
<div className="flex items-center gap-2">
{/*
Versteckter Datei-Dialog: der Inhalt wird im Browser gelesen und als
JSON gepostet. Kein Multipart der API fehlt python-multipart.
*/}
<input
type="file"
className="hidden"
ref={keydbInput}
accept=".cfg,text/plain"
onChange={(e) => {
const datei = e.target.files?.[0]
// Wert leeren, damit dieselbe Datei erneut gewählt werden kann.
e.target.value = ''
if (datei) keydbHochladen(datei)
}}
/>
<Button
variant="amber"
size="sm"
onClick={() => keydbInput.current?.click()}
disabled={keydbBusy}
>
<Upload size={14} />
{keydbBusy ? 'Arbeite…' : 'KEYDB.cfg hochladen'}
</Button>
{keydb?.vorhanden && (
<Button variant="danger" size="sm" onClick={keydbEntfernen} disabled={keydbBusy}>
<Trash2 size={14} />
Entfernen
</Button>
)}
</div>
</div>
{keydb?.vorhanden ? (
<div className="p-3 rounded-lg text-sm bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30">
<p className="font-semibold">Eine KEYDB.cfg liegt bereit.</p>
<p className="text-xs mt-1">
{bytesLesbar(keydb.groesse_bytes)} · {keydb.eintraege} Zeilen mit Disc-Kennung ·
zuletzt geändert {zeitLesbar(keydb.geaendert)}
</p>
<p className="text-xs mt-1 font-mono opacity-80">{keydb.pfad}</p>
</div>
) : (
<div className="p-3 rounded-lg text-sm bg-slate-200 dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-300 dark:border-slate-700">
<p className="font-semibold">Keine KEYDB.cfg hinterlegt.</p>
<p className="text-xs mt-1">
Das ist normal und meistens auch nicht nötig der Regelfall läuft über den
Schlüsselspeicher oben.
</p>
</div>
)}
<p className="text-xs text-slate-500 dark:text-slate-400">
Die <strong className="text-slate-700 dark:text-slate-300">KEYDB.cfg</strong> ist eine reine
Textdatei mit Disc-Schlüsseln. Sie ist der <strong className="text-slate-700 dark:text-slate-300">
Notnagel</strong> für den Fall, dass eine Pressung selbst über den Schlüsselspeicher nicht
aufgeht also auch MakeMKV sie nicht kennt. Zuerst immer den Weg darüber versuchen.
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
<strong className="text-slate-700 dark:text-slate-300">Wichtig:</strong> Rippy liefert keine
Schlüssel mit, lädt keine herunter und verteilt keine. Rippy stellt nur den Platz für eine Datei
bereit, die du selbst mitbringst, und zeigt dir ehrlich an, was dort liegt. Die Zahl oben ist
genau das: die gezählten Zeilen mit einer Disc-Kennung in deiner Datei keine von MakeMKV
bestätigte Anzahl brauchbarer Schlüssel.
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
Eine hochgeladene Datei ersetzt die bisherige und wirkt
<strong className="text-slate-700 dark:text-slate-300"> ab dem nächsten Rip</strong> laufende
Jobs bleiben unberührt, ein Neustart ist nicht nötig. Die Plakette am Worker weiter oben
folgt erst mit dessen nächstem Herzschlag (etwa eine Minute) dieser Kasten hier ist sofort
aktuell.
</p>
</div>
{/*
AACS-Dumps: MakeMKV legt sie bei einer unbekannten UHD-Disc ab (Meldung 3332).
Seit das Datenverzeichnis persistent gemountet ist, überleben sie den
Container-Neustart vorher waren sie nach jedem Rip weg.
*/}
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-slate-900 dark:text-slate-200">AACS-Dumps</p>
<Button variant="secondary" size="sm" onClick={keydbLaden}>
Liste aktualisieren
</Button>
</div>
{dumps.length === 0 ? (
<p className="text-xs text-slate-500 dark:text-slate-400">
Noch kein Dump vorhanden. MakeMKV legt hier automatisch einen ab, sobald eine 4K-UHD-Disc
an einem unbekannten Schlüssel scheitert.
</p>
) : (
<div className="space-y-1.5">
{dumps.map(d => (
<a
key={d.name}
href={`/api/system/aacs-dumps/${encodeURIComponent(d.name)}`}
download
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors text-amber-600 dark:text-amber-300 hover:bg-slate-100 dark:hover:bg-slate-800"
>
<Download size={14} className="flex-shrink-0" />
<span className="truncate min-w-0 font-mono">{d.name}</span>
<span className="ml-auto text-xs flex-shrink-0 text-slate-500 dark:text-slate-400 font-mono">
{bytesLesbar(d.groesse_bytes)} · {zeitLesbar(d.geaendert)}
</span>
</a>
))}
</div>
)}
<p className="text-xs text-slate-500 dark:text-slate-400">
Ein AACS-Dump ist das, was das Laufwerk von der Disc gelesen hat, bevor der Schlüssel fehlte.
Er enthält keinen Schlüssel und nützt dir allein nichts aber du kannst ihn herunterladen und
im MakeMKV-Forum im Bereich <strong className="text-slate-700 dark:text-slate-300">Ultra HD
Blu-ray</strong> einreichen. Dort können Leute daraus den Schlüssel für deine Pressung ermitteln;
den trägst du dann in deine KEYDB.cfg ein.
</p>
</div>
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-slate-900 dark:text-slate-200">Update-Check</p>
@@ -672,7 +1240,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 &amp;&amp; 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>}
+51 -13
View File
@@ -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
@@ -28,6 +32,19 @@ FROM python:3.12-slim-bookworm AS makemkv-build
# docker compose build --build-arg MAKEMKV_URL_BASE=http://<host-ip>:8099 worker
ARG MAKEMKV_VERSION=1.18.4
ARG MAKEMKV_URL_BASE=https://www.makemkv.com/download
# Zweite Quelle, die bei einem Fehlschlag der ersten AUTOMATISCH probiert wird.
# Absichtlich LEER: Am 25.07.2026 nachgemessen, welche Quellen wirklich liefern —
# https://www.makemkv.com/download HTTP 200 (der Standard oben)
# https://www.makemkv.com/download/old HTTP 525 (Cloudflare)
# web.archive.org-Schnappschuss HTTP 404
# Es gibt derzeit also keine belegbare zweite Quelle. Eine hier einzutragen, die
# nicht liefert, wäre schlimmer als keine: der Build scheitert dann später und
# die Konfiguration sieht trotzdem gesund aus — genau das war auf der Rippy-VM
# der Fall (dort stand die 404-Adresse, und nur die vendor-Tarballs retteten
# jeden Build, ohne dass es auffiel).
# Wer eine eigene Quelle hat (Spiegel im LAN, eigener Webserver), traegt sie ein:
# docker compose build --build-arg MAKEMKV_URL_FALLBACK=http://<host>:8099 worker
ARG MAKEMKV_URL_FALLBACK=
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
@@ -42,26 +59,47 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
WORKDIR /build
# Download mit doppeltem Netz (Cloudflare drosselt BuildKit-Downloads
# Download mit DREIFACHEM Netz (Cloudflare drosselt BuildKit-Downloads
# hartnäckig — 24.07. viermal, auch MIT --retry-all-errors):
# 1. Liegen die Tarballs lokal in docker/worker/vendor/ (untracked), werden
# SIE genutzt — einmal von Hand hinlegen, nie wieder Download-Roulette.
# 2. Sonst curl mit Retries gegen MAKEMKV_URL_BASE.
# 3. Scheitert das, wird MAKEMKV_URL_FALLBACK versucht, falls gesetzt.
# Und wenn alles scheitert, sagt die Meldung, welche Wege es gibt — statt nur
# einen curl-Rückgabewert zu hinterlassen.
COPY docker/worker/vendor/ ./vendor/
RUN if [ -s "vendor/makemkv-oss-${MAKEMKV_VERSION}.tar.gz" ] \
RUN set -e; \
hole() { \
ziel="$1"; datei="$2"; \
if curl -fsSL --retry 5 --retry-delay 15 --retry-all-errors \
-o "$ziel" "${MAKEMKV_URL_BASE}/${datei}"; then \
return 0; \
fi; \
if [ -n "${MAKEMKV_URL_FALLBACK}" ]; then \
echo "Erste Quelle lieferte nicht — versuche MAKEMKV_URL_FALLBACK"; \
curl -fsSL --retry 3 --retry-delay 10 --retry-all-errors \
-o "$ziel" "${MAKEMKV_URL_FALLBACK}/${datei}" && return 0; \
fi; \
echo "FEHLER: ${datei} war von keiner Quelle zu holen." >&2; \
echo " Geprueft: ${MAKEMKV_URL_BASE}${MAKEMKV_URL_FALLBACK:+ und ${MAKEMKV_URL_FALLBACK}}" >&2; \
echo " Sicherster Weg: Tarballs von makemkv.com/download herunterladen" >&2; \
echo " und nach docker/worker/vendor/ legen, dann erneut bauen." >&2; \
echo " Alternativ eine eigene Quelle angeben:" >&2; \
echo " docker compose build --build-arg MAKEMKV_URL_FALLBACK=http://<host>:8099 worker" >&2; \
return 1; \
}; \
if [ -s "vendor/makemkv-oss-${MAKEMKV_VERSION}.tar.gz" ] \
&& [ -s "vendor/makemkv-bin-${MAKEMKV_VERSION}.tar.gz" ]; then \
echo "Nutze lokale Tarballs aus vendor/"; \
cp "vendor/makemkv-oss-${MAKEMKV_VERSION}.tar.gz" oss.tar.gz; \
cp "vendor/makemkv-bin-${MAKEMKV_VERSION}.tar.gz" bin.tar.gz; \
else \
curl -fsSL --retry 5 --retry-delay 15 --retry-all-errors \
-o oss.tar.gz "${MAKEMKV_URL_BASE}/makemkv-oss-${MAKEMKV_VERSION}.tar.gz" \
&& curl -fsSL --retry 5 --retry-delay 15 --retry-all-errors \
-o bin.tar.gz "${MAKEMKV_URL_BASE}/makemkv-bin-${MAKEMKV_VERSION}.tar.gz"; \
fi \
&& sha256sum oss.tar.gz bin.tar.gz \
&& tar xzf oss.tar.gz \
&& tar xzf bin.tar.gz
hole oss.tar.gz "makemkv-oss-${MAKEMKV_VERSION}.tar.gz"; \
hole bin.tar.gz "makemkv-bin-${MAKEMKV_VERSION}.tar.gz"; \
fi; \
sha256sum oss.tar.gz bin.tar.gz; \
tar xzf oss.tar.gz; \
tar xzf bin.tar.gz
RUN cd "makemkv-oss-${MAKEMKV_VERSION}" \
&& ./configure --disable-gui --prefix=/usr/local \
+260 -9
View File
@@ -6,26 +6,228 @@ Remote-GPU-Worker meldet sich hier genauso wie der eingebaute CPU-Worker.
"""
import os
import platform
import re
import shutil
import subprocess
def erkenne_encoder() -> list:
"""Liste der verfügbaren Encoder-Backends auf dieser Maschine."""
gefunden = ["cpu-x264", "cpu-x265"] # HandBrake-Software-Encoder, immer dabei
HB_ENCODER_KOPF = re.compile(r"^-e,\s*--encoder\b")
# VAAPI: AMD (VCN) und Intel (QuickSync) melden sich über /dev/dri
if os.path.exists("/dev/dri/renderD128"):
gefunden.append("vaapi")
# Reihenfolge = Anzeige-Reihenfolge im UI.
SIMD_STUFEN = ("avx512f", "avx2", "avx", "sse4_2")
# NVENC: NVIDIA-Treiber im Container sichtbar
if shutil.which("nvidia-smi") or os.path.exists("/usr/lib/x86_64-linux-gnu/libnvidia-encode.so.1"):
gefunden.append("nvenc")
def parse_encoder_liste(hilfe_text: str) -> list:
"""Video-Encoder, die DIESES HandBrake wirklich anbietet.
Quelle: `HandBrakeCLI --help`, Abschnitt `-e, --encoder <string>`
(AGENTS Regel D im Worker-Image am 25.07.2026 gemessen, nicht geraten):
-e, --encoder <string> Select video encoder:
svt_av1
x264
x265
...
--encoder-preset <string>
Der Block endet an der nächsten Option also an der ersten Zeile, deren
erstes nicht-leeres Zeichen ein Bindestrich ist.
"""
encoder = []
im_block = False
for zeile in (hilfe_text or "").splitlines():
inhalt = zeile.strip()
if not im_block:
if HB_ENCODER_KOPF.match(inhalt):
im_block = True
continue
if not inhalt:
continue
if inhalt.startswith("-"):
break
encoder.append(inhalt.split()[0])
return encoder
# Hardware-Encoder-Familien, wie HandBrake sie benennt → Backend-Kennung.
# Reihenfolge = Anzeige-Reihenfolge; AV1 zuerst, weil es das modernste ist.
HW_FAMILIEN = (
("nvenc", "nvenc"), # NVIDIA
("qsv", "qsv"), # Intel QuickSync
("vce", "vce"), # AMD VCE/VCN
("vaapi", "vaapi"), # generisch (Linux)
)
def leite_backends_ab(hb_encoder) -> list:
"""Übersetzt die HandBrake-Encoder-Namen in die Backends, die das UI zeigt.
## HandBrakes Liste IST die Auskunft — keine zweite Meinung nötig
Frühere Fassung (25.07.2026) verlangte zusätzlich ein Gerät: `/dev/dri`
für VAAPI/QSV/VCE bzw. `nvidia-smi` für NVENC. Das war doppelt falsch:
1. **Unter Windows gibt es beides nicht.** Der Commander-PC (RX 9070 XT)
meldete deshalb nur CPU-Encoder, obwohl HandBrakes Windows-Build
`vce_*`, `nvenc_*` und `qsv_*` beherrscht. Ein Bug, der genau den
Anwendungsfall traf, für den externe Worker gedacht sind.
2. **Die Prüfung war überflüssig.** HandBrake probiert Hardware-Encoder
beim Start selbst an und listet nur, was es wirklich benutzen kann. Auf
der Rippy-VM belegt: `--version` meldet qsv: not available on this
system" und „Cannot load libnvidia-encode.so.1" und genau diese
Encoder fehlen dann in `--help`. Wer die Liste hat, hat die Antwort.
Bleibt die eine Regel, die weiter gilt: **ohne HandBrake gibt es keine
Encoder.** Vorher standen `cpu-x264`/`cpu-x265` fest verdrahtet drin, und
ein reiner Rip-Worker behauptete damit, komprimieren zu können.
Hardware wird jetzt nach Familie unterschieden (nvenc/qsv/vce/vaapi) statt
alles in vaapi" zu werfen — sonst kann das UI nicht sagen, welches Preset
passt, und AMD-Karten liefen unter einem Intel-Namen.
"""
namen = {e.lower() for e in (hb_encoder or [])}
gefunden = []
if any(n.startswith("x264") for n in namen):
gefunden.append("cpu-x264")
if any(n.startswith("x265") for n in namen):
gefunden.append("cpu-x265")
if any(n.startswith("svt_av1") for n in namen):
gefunden.append("cpu-av1")
for kennzeichen, backend in HW_FAMILIEN:
treffer = [n for n in namen if kennzeichen in n]
if not treffer:
continue
gefunden.append(backend)
# AV1 in Hardware ist die derzeit beste Kombination aus Tempo und
# Dateigröße — das muss sichtbar sein, nicht in „nvenc" verschwinden.
if any("av1" in n for n in treffer):
gefunden.append(f"{backend}-av1")
return gefunden
def beste_simd(flags_text: str) -> str:
"""Höchste Vektor-Befehlsstufe der CPU — entscheidend fürs Software-Encodieren.
Befund 25.07.2026: Die Rippy-VM läuft auf dem generischen QEMU-CPU-Modell
(QEMU Virtual CPU version 2.5+") und hat deshalb KEIN AVX2, nur sse4_2.
x265 lebt von diesen Befehlen; ein 4K-Encode brauchte dort gemessene
28-55 Stunden. Sichtbar war das nirgends genau diese Lücke schließt die
Anzeige. Abhilfe bei Proxmox/KVM: CPU-Typ der VM auf `host` stellen.
"""
vorhanden = set((flags_text or "").split())
for stufe in SIMD_STUFEN:
if stufe in vorhanden:
return stufe
return "unbekannt"
def _lies_cpuinfo() -> tuple:
"""(Modellname, Flags-Text, Kernzahl) aus /proc/cpuinfo — leer auf Windows."""
modell, flags, kerne = "", "", 0
try:
with open("/proc/cpuinfo", encoding="utf-8", errors="replace") as f:
for zeile in f:
if zeile.startswith("processor"):
kerne += 1
elif not modell and zeile.startswith("model name"):
modell = zeile.split(":", 1)[-1].strip()
elif not flags and zeile.startswith("flags"):
flags = zeile.split(":", 1)[-1].strip()
except OSError:
pass
return modell, flags, kerne
# Merkmals-Nummern für IsProcessorFeaturePresent (Win32, winnt.h).
# Absteigend nach Stärke — der erste Treffer ist die höchste Stufe.
WIN_MERKMALE = (
("avx512f", 41), # PF_AVX512F_INSTRUCTIONS_AVAILABLE
("avx2", 40), # PF_AVX2_INSTRUCTIONS_AVAILABLE
("avx", 39), # PF_AVX_INSTRUCTIONS_AVAILABLE
("sse4_2", 38), # PF_SSE4_2_INSTRUCTIONS_AVAILABLE
)
def _simd_windows() -> str:
"""Höchste Vektor-Befehlsstufe unter Windows.
Es gibt dort kein /proc/cpuinfo, deshalb meldete der Windows-Worker bis
25.07.2026 Vektorbefehle unbekannt" — gerade auf der Maschine, die das
Encodieren übernehmen soll, also genau da, wo die Angabe zählt.
Gefragt wird `IsProcessorFeaturePresent` aus kernel32 (Win32-API, in
winnt.h dokumentiert). Kein Zusatzpaket, kein Registry-Gefummel, keine
Fremdprozesse.
"""
try:
import ctypes
kernel32 = ctypes.windll.kernel32
except (ImportError, AttributeError, OSError):
return "unbekannt"
for name, nummer in WIN_MERKMALE:
try:
if kernel32.IsProcessorFeaturePresent(nummer):
return name
except OSError:
return "unbekannt"
return "unbekannt"
def _modell_windows() -> str:
"""Lesbarer CPU-Name unter Windows.
platform.processor() liefert dort nur AMD64 Family 26 Model 68 " — die
Registry kennt den Namen, den auch der Aufkleber trägt.
"""
try:
import winreg
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE,
r"HARDWARE\DESCRIPTION\System\CentralProcessor\0",
) as schluessel:
name, _ = winreg.QueryValueEx(schluessel, "ProcessorNameString")
return " ".join(str(name).split())
except (ImportError, OSError):
return ""
def cpu_merkmale() -> tuple:
"""(Modell, SIMD-Stufe, Kernzahl) — plattformübergreifend, nie leer."""
modell, flags, kerne = _lies_cpuinfo()
simd = beste_simd(flags)
if simd == "unbekannt" and platform.system() == "Windows":
simd = _simd_windows()
if not modell:
modell = _modell_windows() or platform.processor() or "unbekannt"
return modell, simd, (kerne or os.cpu_count() or 0)
def hole_handbrake_hilfe() -> str:
"""`HandBrakeCLI --help` einmal abrufen (leer, wenn nicht installiert)."""
if not shutil.which("HandBrakeCLI"):
return ""
try:
aus = subprocess.run(
["HandBrakeCLI", "--help"], capture_output=True, text=True, timeout=30
)
return (aus.stdout or "") + (aus.stderr or "")
except (OSError, subprocess.TimeoutExpired):
return ""
def erkenne_encoder() -> list:
"""Encoder-Backends, die auf DIESER Maschine wirklich nutzbar sind.
Gefragt wird ausschließlich HandBrake selbst es probiert Hardware beim
Start an und listet nur Nutzbares. Eine zusätzliche Geräteprüfung wäre
plattformabhängig (und war unter Windows falsch), siehe leite_backends_ab.
"""
return leite_backends_ab(parse_encoder_liste(hole_handbrake_hilfe()))
def erkenne_ip() -> str:
"""Beste erratbare eigene IP (UDP-Route-Trick, KEIN echter Traffic).
@@ -56,11 +258,33 @@ def werkzeug_versionen() -> dict:
"""
import socket
modell, simd, kerne = cpu_merkmale()
info = {
# Zuordnung im UI: hostname (für den Celery-Online-Abgleich) + IP
"hostname": socket.gethostname(),
"ip": erkenne_ip(),
# Rechenleistung fürs Software-Encodieren. Ohne diese drei Angaben war
# am 25.07.2026 nicht zu sehen, dass ein 4K-Encode auf dieser Maschine
# Tage statt Stunden braucht (kein AVX2, 4 Kerne).
"cpu_modell": modell,
"cpu_kerne": str(kerne),
"cpu_simd": simd,
# Läuft dieser Worker INNERHALB des Rippy-Containers oder auf einer
# fremden Maschine? Entscheidend, weil ein externer Worker die
# Container-Pfade (/app/media, /app/temp) nur über eine Freigabe plus
# RIPPY_PATH_MAP erreicht. Das UI kann damit VOR dem Rip warnen, statt
# den Nutzer eine Stunde rippen zu lassen (Vorfall 25.07.2026).
# /app ist im Rippy-Image immer vorhanden — kein Ratespiel.
"extern": "nein" if os.path.isdir("/app") else "ja",
# Ist die Pfad-Übersetzung gesetzt? Ohne sie kann ein externer Worker
# grundsätzlich nicht komprimieren.
"pfad_map": os.getenv("RIPPY_PATH_MAP", ""),
}
hb_encoder = parse_encoder_liste(hole_handbrake_hilfe())
if hb_encoder:
# Die ungefilterte Auskunft von HandBrake — damit im UI nachprüfbar ist,
# woher die Backend-Liste kommt.
info["handbrake_encoder"] = ",".join(hb_encoder)
if shutil.which("makemkvcon"):
info["makemkv"] = os.getenv("MAKEMKV_VERSION") or "installiert"
if shutil.which("HandBrakeCLI"):
@@ -85,4 +309,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
+24
View File
@@ -33,6 +33,30 @@ celery_app.conf.update(
# zuverlässig im sys.path (ModuleNotFoundError 'caps', Deploy 23.07.).
import caps # noqa: E402
import db # noqa: E402
import zombies # noqa: E402
@worker_ready.connect
def raeume_job_leichen_auf(**kwargs):
"""Nach der Gnadenfrist: Jobs, an denen niemand arbeitet, ehrlich auf
'failed' setzen (Details und Sicherungen in zombies.py).
Läuft im Hintergrund-Thread der Worker soll sofort Aufgaben annehmen und
nicht zwei Minuten auf die Aufräumung warten.
"""
import threading
import time
def spaeter():
time.sleep(zombies.GNADENFRIST_SEKUNDEN)
try:
db.init_db()
bericht = zombies.raeume_zombies_auf(celery_app, db)
print(f"Zombie-Erkennung: {bericht}")
except Exception as e: # darf den Worker nie mitnehmen
print(f"Zombie-Erkennung fehlgeschlagen: {e}")
threading.Thread(target=spaeter, daemon=True, name="zombie-erkennung").start()
@worker_ready.connect
+36
View File
@@ -181,6 +181,42 @@ def get_job_status(job_id: str) -> str:
return zeile[0] if zeile else ""
def list_jobs_mit_status(stati) -> list:
"""Alle Jobs in einem der genannten Zustände (id/status/title/created_at).
Basis der Zombie-Erkennung: Jobs, die behaupten, es arbeite gerade jemand
an ihnen. Bewusst NUR diese schmale Auswahl statt der ganzen Zeile die
Erkennung braucht nichts weiter.
"""
from sqlalchemy import select
with engine.connect() as conn:
zeilen = conn.execute(
select(jobs.c.id, jobs.c.status, jobs.c.title, jobs.c.created_at)
.where(jobs.c.status.in_(list(stati)))
).mappings().all()
return [dict(z) for z in zeilen]
def zaehle_online_worker(sekunden: int = 120) -> int:
"""Wie viele Worker gelten laut Herzschlag gerade als online?
Die Zombie-Erkennung vergleicht das mit der Zahl der Celery-Antworten:
melden sich weniger Worker als bekannt sind, ist die Auskunft
unvollständig dann wird NICHTS als Leiche gewertet.
"""
from datetime import timedelta
from sqlalchemy import func, select
grenze = utcnow() - timedelta(seconds=sekunden)
with engine.connect() as conn:
anzahl = conn.execute(
select(func.count()).select_from(workers).where(workers.c.last_seen >= grenze)
).scalar()
return int(anzahl or 0)
def update_job(job_id: str, **fields) -> None:
with engine.begin() as conn:
conn.execute(jobs.update().where(jobs.c.id == job_id).values(**fields))
+44 -4
View File
@@ -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
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
mkdir -p /root/.MakeMKV
printf 'app_Key = "%s"\n' "${MAKEMKV_APP_KEY}" > /root/.MakeMKV/settings.conf
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 "$@"
+375
View File
@@ -0,0 +1,375 @@
"""MakeMKV-Datenverzeichnis: Schlüsselspeicher, KEYDB.cfg, AACS-Dumps.
WARUM ES DIESE DATEI GIBT (Befund 25.07.2026, auf BEIDEN Maschinen gemessen):
4K-UHD-Discs scheiterten mit "The volume key is unknown for this disc". Die
Ursache ist weder die Disc noch das Laufwerk LibreDrive v06.3 läuft und
MakeMKV liest die Disc sondern:
makemkvcon unter LINUX ruft die Disc-Schluessel nie ab.
Die Windows-Version tut es.
Gemessen, nicht vermutet:
* Linux: in KEINEM Lauf auch nur eine Verbindung nach draussen. Geprueft mit
leerem UND mit gefülltem Schlüsselspeicher, mit und ohne --noscan, mit
dev:/dev/sr0 und mit disc:0, und mit erzwungener frischer Prüfung
(update.conf gelöscht, Meldung 5074 belegt den Web-Kontakt). Immer:
keine Verbindung, kein Schluessel.
* Windows, dieselbe Disc, dasselbe Laufwerk: Meldung 3338 "Downloading
latest HK to ...", Verbindung nach 185.84.108.20:443
(web33.majordomo.ru), _private_data.tar wächst und die Disc geht auf
(TCOUNT:5, "Operation successfully completed").
* Der Code dafür steckt auch im Linux-Binary: die Meldungsvorlage
"Downloading latest %1 to %2 ..." steht in makemkvcon. Sie löst nur nie
aus. Gleiches Symptom im Forum, seit Jahren offen und unbeantwortet.
FRUEHERE FEHLDIAGNOSE, bewusst festgehalten: An dieser Stelle stand zuerst,
MakeMKVs Schluessel-Kanal sei abgeschaltet. Das war FALSCH. Die Herleitung
stützte sich auf zwei Hostnamen aus alten Forumsbeitraegen
(hkdata.fairuse.org, hkdata.crabdance.com), die tatsaechlich nicht mehr
aufloesen MakeMKV benutzt sie aber laengst nicht mehr. Der Dienst lebt, der
Worker erreicht ihn sogar; er wird unter Linux nur nie gefragt.
WAS DARAUS FOLGT: Der Schlüsselspeicher (_private_data.tar) muss von einer
MakeMKV-Installation kommen, die ihn wirklich abruft praktisch von Windows.
Rippy nimmt ihn unter Einstellungen -> System entgegen. Die KEYDB.cfg bleibt
der Notnagel für Pressungen, die auch MakeMKV selbst nicht kennt.
Rippy liefert KEINE Schluessel mit, lädt keine herunter und verteilt keine.
Es verwaltet nur, was der Nutzer selbst mitbringt.
QUELLEN (AGENTS Regel D externe Schnittstellen nie aus dem Kopf):
* Linux lädt keine Hashed Keys dasselbe Symptom, mehrfach berichtet:
https://forum.makemkv.com/forum/viewtopic.php?t=25782
https://forum.makemkv.com/forum/viewtopic.php?t=34022
* Schluessel liegen als hkd_*.bin in _private_data.tar:
https://forum.makemkv.com/forum/viewtopic.php?t=32675
* Datenverzeichnis und Dateiname KEYDB.cfg GROSS geschrieben (unter Linux
case-sensitiv), dort geloest mit "cp KEYDB.cfg ~/.MakeMKV/":
https://forum.makemkv.com/forum/viewtopic.php?t=30636
* Zeilenformat der KEYDB.cfg (libaacs): Disc-Kennung = 40 Hex-Zeichen,
optional mit 0x-Praefix, dann "= Titel", danach optionale Felder wie
"| V | <32 Hex>"; Zeilen ab ";" sind Kommentare:
https://github.com/ShiftMediaProject/libaacs/blob/master/KEYDB.cfg
* Den AACS-Dump legt MakeMKV selbst ab, Meldung 3332 "Saved AACS dump file
as file:///root/.MakeMKV/<name>.tgz" (am 25.07.2026 so beobachtet).
ZWILLINGSDATEI: liegt identisch unter docker/api/ und docker/worker/ beide
Images brauchen sie, ein gemeinsames Paket gibt es in diesem Projekt nicht
(gleiche Lage wie bei db.py). Änderungen IMMER in BEIDEN Dateien nachziehen.
"""
import io
import os
import re
import tarfile
from datetime import datetime, timezone
# Container-Pfad aus der Umgebung: /root/.MakeMKV im Worker (nur dort sucht
# makemkvcon), /app/makemkv-data in der API. Beide zeigen laut
# docker-compose.yml auf DASSELBE Host-Verzeichnis.
DATEN_DIR = os.getenv("MAKEMKV_DATA_DIR", "/root/.MakeMKV")
# GROSS geschrieben — unter Linux case-sensitiv, "keydb.cfg" wird ignoriert.
KEYDB_NAME = "KEYDB.cfg"
# Obergrenze für den Upload. Eine vollständige oeffentliche KEYDB.cfg liegt
# im einstelligen MB-Bereich; 64 MB sind reichlich Luft und verhindern, dass
# eine versehentlich hochgeladene Riesendatei den Speicher vollschreibt.
MAX_KEYDB_BYTES = 64 * 1024 * 1024
# Eine Disc-Zeile beginnt mit der 40 Zeichen langen Hex-Kennung (optional mit
# 0x davor), danach folgt das Gleichheitszeichen. Alles andere (Kommentare ab
# ";", Leerzeilen, Fortsetzungsfelder) zählt nicht als Eintrag.
_DISC_ZEILE = re.compile(r"^\s*(?:0x)?[0-9a-fA-F]{40}\s*=")
def _iso(zeitstempel: float) -> str:
"""Unix-Zeit -> ISO-8601 in UTC (sekundengenau), wie db.utcnow() es tut."""
return datetime.fromtimestamp(zeitstempel, timezone.utc).isoformat(timespec="seconds")
def keydb_pfad(daten_dir: str = None) -> str:
"""Voller Pfad zur KEYDB.cfg im Datenverzeichnis."""
return os.path.join(daten_dir or DATEN_DIR, KEYDB_NAME)
def zaehle_disc_eintraege(inhalt: str) -> int:
"""Zeilen mit Disc-Kennung zählen (pure Funktion, testbar).
Bewusst eine Heuristik und keine vollständige Auswertung: MakeMKV liest
die Datei mit seinem eigenen Parser, und wie viele Eintraege es daraus
macht, ist von aussen nicht sichtbar. Die Zahl dient nur dazu, im UI
"da liegt wirklich etwas drin" von "leere oder falsche Datei" zu
unterscheiden sie wird deshalb auch genau so beschriftet.
"""
return sum(1 for zeile in inhalt.splitlines() if _DISC_ZEILE.match(zeile))
def keydb_pruefen(inhalt: str) -> str:
"""Prüft hochgeladenen Inhalt; gibt deutschen Fehlertext oder "" zurück.
Verhindert den häufigsten Bedienfehler: statt der KEYDB.cfg landet die
HTML-Fehlerseite eines Downloads oder eine leere Datei im Verzeichnis
MakeMKV würde dann still weiter "volume key is unknown" melden.
"""
if not inhalt.strip():
return "Die Datei ist leer."
if len(inhalt.encode("utf-8")) > MAX_KEYDB_BYTES:
return (
"Die Datei ist größer als "
f"{MAX_KEYDB_BYTES // (1024 * 1024)} MB — das ist keine KEYDB.cfg."
)
if inhalt.lstrip()[:1] == "<":
return (
"Das sieht nach HTML aus, nicht nach einer KEYDB.cfg — "
"vermutlich wurde eine Fehlerseite statt der Datei geladen."
)
if zaehle_disc_eintraege(inhalt) == 0:
return (
"Keine einzige Zeile mit Disc-Kennung gefunden (40 Hex-Zeichen, "
"dann ein Gleichheitszeichen). Das ist keine KEYDB.cfg."
)
return ""
def keydb_status(daten_dir: str = None) -> dict:
"""Zustand der KEYDB.cfg. Fehlt sie, ist das der Normalfall, kein Fehler."""
pfad = keydb_pfad(daten_dir)
try:
angaben = os.stat(pfad)
except OSError:
return {
"vorhanden": False,
"pfad": pfad,
"groesse_bytes": 0,
"eintraege": 0,
"geaendert": "",
}
eintraege = 0
try:
with open(pfad, encoding="utf-8", errors="replace") as datei:
eintraege = zaehle_disc_eintraege(datei.read())
except OSError:
pass # Datei da, aber unlesbar: Größe/Datum stimmen trotzdem
return {
"vorhanden": True,
"pfad": pfad,
"groesse_bytes": angaben.st_size,
"eintraege": eintraege,
"geaendert": _iso(angaben.st_mtime),
}
def keydb_schreiben(inhalt: str, daten_dir: str = None) -> dict:
"""Schreibt die KEYDB.cfg atomar und meldet den neuen Zustand.
Erst in eine Nebendatei, dann os.replace: während ein Rip läuft, darf
makemkvcon niemals eine halb geschriebene Datei zu sehen bekommen.
"""
pfad = keydb_pfad(daten_dir)
os.makedirs(os.path.dirname(pfad), exist_ok=True)
neben = pfad + ".neu"
try:
with open(neben, "w", encoding="utf-8", newline="\n") as datei:
datei.write(inhalt)
os.replace(neben, pfad)
except OSError:
# Die Nebendatei nie liegen lassen: eine halb geschriebene
# KEYDB.cfg.neu verwirrt jeden, der ins Verzeichnis schaut, und
# belegt im Extremfall 64 MB, die niemand mehr aufräumt.
try:
os.remove(neben)
except OSError:
pass
raise
return keydb_status(daten_dir)
def keydb_loeschen(daten_dir: str = None) -> dict:
"""Entfernt die KEYDB.cfg (z. B. nach einem Fehlgriff beim Hochladen).
Nur "Datei war schon weg" wird geschluckt das ist das gewünschte
Ergebnis. Jeder andere Fehler (schreibgeschützter Mount, fremder
Eigentümer) MUSS nach oben durch: sonst meldete die API einen Erfolg,
den es nicht gab, und die Datei wirkte beim nächsten Rip weiter.
"""
try:
os.remove(keydb_pfad(daten_dir))
except FileNotFoundError:
pass
return keydb_status(daten_dir)
def ist_aacs_dump(name: str) -> bool:
"""Dateiname eines AACS-Dumps? (pure Funktion, testbar)
MakeMKV legt ihn als <MKB..._NAME_....>.tgz direkt im Datenverzeichnis ab
(Meldung 3332). Pfadtrenner und fuehrende Punkte werden hier schon
ausgeschlossen, damit der Download-Endpunkt keine Pfad-Tricks erlaubt.
"""
return (
name.endswith(".tgz")
and "/" not in name
and "\\" not in name
and not name.startswith(".")
)
def dumps_auflisten(daten_dir: str = None) -> list:
"""Alle AACS-Dumps im Datenverzeichnis, neueste zuerst."""
ordner = daten_dir or DATEN_DIR
try:
namen = os.listdir(ordner)
except OSError:
return []
liste = []
for name in namen:
if not ist_aacs_dump(name):
continue
try:
angaben = os.stat(os.path.join(ordner, name))
except OSError:
continue
liste.append(
{
"name": name,
"groesse_bytes": angaben.st_size,
"geaendert": _iso(angaben.st_mtime),
"_sort": angaben.st_mtime,
}
)
liste.sort(key=lambda eintrag: eintrag["_sort"], reverse=True)
for eintrag in liste:
del eintrag["_sort"]
return liste
# --- Schlüsselspeicher (_private_data.tar) -------------------------------
#
# Das ist MakeMKVs eigener Speicher für die "Hashed Keys": ein tar-Archiv mit
# hkd_*.bin-Eintraegen. Unter Windows füllt MakeMKV es selbst (Meldung 3338),
# unter Linux nie — siehe Modul-Kopf. Rippy nimmt die Datei deshalb entgegen
# und legt sie ins Datenverzeichnis; MakeMKV liest sie beim nächsten Start.
PRIVATE_DATA_NAME = "_private_data.tar"
# Der Speicher lag am 25.07.2026 bei rund 6 MB und wächst mit jeder neuen
# Pressung. 64 MB sind reichlich Luft — und derselbe Wert wie
# client_max_body_size in docker/ui/nginx.conf: wäre die Grenze hier höher,
# würde nginx den Upload abweisen, bevor die API ihn überhaupt sieht.
MAX_PRIVATE_DATA_BYTES = 64 * 1024 * 1024
def private_data_pfad(daten_dir: str = None) -> str:
"""Voller Pfad zum Schlüsselspeicher im Datenverzeichnis."""
return os.path.join(daten_dir or DATEN_DIR, PRIVATE_DATA_NAME)
def zaehle_schluessel(rohdaten: bytes) -> int:
"""Anzahl der hkd_*.bin-Eintraege im Archiv (pure Funktion, testbar).
Das ist die ehrliche Kennzahl für "wie viele Disc-Schluessel kennt diese
Installation". Ein frischer, leerer Speicher enthält nur eine
Index-Datei und kommt hier auf 0 genau der Zustand, in dem jede
unbekannte UHD-Disc scheitert.
"""
try:
with tarfile.open(fileobj=io.BytesIO(rohdaten)) as archiv:
return sum(1 for name in archiv.getnames() if name.startswith("hkd_"))
except (tarfile.TarError, OSError, EOFError):
return 0
def private_data_pruefen(rohdaten: bytes) -> str:
"""Prüft hochgeladene Rohdaten; deutscher Fehlertext oder "".
Faengt die beiden Bedienfehler ab, die sonst still danebengehen: eine
voellig andere Datei hochladen, oder den Speicher einer Installation, die
selbst noch keine Schluessel geholt hat (dann ändert sich nichts, und
niemand versteht warum).
"""
if not rohdaten:
return "Die Datei ist leer."
if len(rohdaten) > MAX_PRIVATE_DATA_BYTES:
return (
"Die Datei ist größer als "
f"{MAX_PRIVATE_DATA_BYTES // (1024 * 1024)} MB — das ist kein "
"MakeMKV-Schlüsselspeicher."
)
try:
with tarfile.open(fileobj=io.BytesIO(rohdaten)) as archiv:
namen = archiv.getnames()
except (tarfile.TarError, OSError, EOFError):
return (
"Das ist kein tar-Archiv. Erwartet wird die Datei "
f"{PRIVATE_DATA_NAME} aus dem MakeMKV-Datenverzeichnis."
)
if not any(name.startswith("hkd_") for name in namen):
return (
"In dieser Datei steckt kein einziger Schluessel (kein hkd_*.bin). "
"Sie stammt vermutlich von einer MakeMKV-Installation, die selbst "
"noch keine geholt hat — öffne dort erst einmal eine Disc."
)
return ""
def schluesselspeicher_status(daten_dir: str = None) -> dict:
"""Zustand des Schlüsselspeichers. Fehlt er, ist das kein Fehler."""
pfad = private_data_pfad(daten_dir)
try:
angaben = os.stat(pfad)
with open(pfad, "rb") as datei:
schluessel = zaehle_schluessel(datei.read())
except OSError:
return {
"vorhanden": False,
"pfad": pfad,
"groesse_bytes": 0,
"schluessel": 0,
"geaendert": "",
}
return {
"vorhanden": True,
"pfad": pfad,
"groesse_bytes": angaben.st_size,
"schluessel": schluessel,
"geaendert": _iso(angaben.st_mtime),
}
def private_data_schreiben(rohdaten: bytes, daten_dir: str = None) -> dict:
"""Legt den Schlüsselspeicher atomar ab und meldet den neuen Zustand.
Atomar aus demselben Grund wie bei der KEYDB.cfg: während ein Rip läuft,
darf makemkvcon nie ein halb geschriebenes Archiv sehen.
"""
pfad = private_data_pfad(daten_dir)
os.makedirs(os.path.dirname(pfad), exist_ok=True)
neben = pfad + ".neu"
try:
with open(neben, "wb") as datei:
datei.write(rohdaten)
os.replace(neben, pfad)
except OSError:
try:
os.remove(neben)
except OSError:
pass
raise
return schluesselspeicher_status(daten_dir)
def settings_conf_zusammenfuehren(inhalt: str, key: str) -> str:
"""app_Key setzen, ohne den Rest der settings.conf zu verlieren (pure).
Bis zum 25.07.2026 haben entrypoint.sh UND tasks.py die Datei komplett
überschrieben. Mit dem jetzt persistenten Datenverzeichnis wäre damit
bei jedem Containerstart und vor jedem Rip alles andere weg z. B.
app_UpdateEnable. Leerer Key lässt die vorhandene Zeile ebenfalls fallen,
damit ein bewusst geleerter Key nicht heimlich weiterwirkt.
"""
zeilen = [z for z in inhalt.splitlines() if not z.lstrip().startswith("app_Key")]
if key:
zeilen.append('app_Key = "{}"'.format(key))
text = "\n".join(zeilen).strip("\n")
return text + "\n" if text else ""
+223 -32
View File
@@ -25,6 +25,36 @@ class RipAbbruch(Exception):
Nutzer den Job abgebrochen hat (Status 'canceling' in der DB)."""
# include/uapi/linux/cdrom.h — dasselbe ioctl wie in api/devices.py
CDROMEJECT = 0x5309
def wirf_disc_aus(device_path: str) -> bool:
"""Wirft die Disc aus; gibt zurück, ob es geklappt hat. Wirft NIE.
Befund 25.07.2026: Die Einstellung Automatischer Auswurf" (Standard: ein,
Beschreibung Disc nach erfolgreichem Ripping automatisch auswerfen") wurde
von niemandem gelesen bei DVD/Blu-ray warf Rippy nie aus.
Bewusst hier und nicht in detection.py: das Modul ist ein byteweiser
Zwilling der API-Kopie. `fcntl` gibt es nur unter Linux der native
Windows-Worker lädt ripping.py ebenfalls, rippt dort aber nie.
"""
try:
from fcntl import ioctl
except ImportError: # Windows
return False
try:
fd = os.open(device_path, os.O_RDONLY | os.O_NONBLOCK)
try:
ioctl(fd, CDROMEJECT, 0)
finally:
os.close(fd)
return True
except OSError:
return False
def check_makemkv_installed() -> bool:
"""Prüft, ob makemkvcon installiert ist."""
return shutil.which("makemkvcon") is not None
@@ -114,7 +144,7 @@ def lies_titel_info(device_path: str, timeout: int = 300) -> list:
def rip_titel_auswahl(device_path: str, output_dir: str, titel_liste: list,
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 +154,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 +212,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 enthält und MakeMKV schreibt solche
laufend ("Title #1 has length of 12 seconds, which is less than ...").
Deshalb eine Regex, die die Anführungszeichen respektiert.
"""
treffer = _MSG_RE.match(zeile.strip())
if not treffer:
return None
return int(treffer.group(1)), treffer.group(2).replace('\\"', '"')
def get_progress_from_prgv(line: str) -> int:
"""Extrahiert Gesamt-Fortschritt (0-100) aus einer PRGV-Zeile.
@@ -203,6 +255,68 @@ def check_handbrake_installed() -> bool:
DEFAULT_HB_PRESET = "H.265 MKV 1080p30"
# Ein Preset je Disc-Typ (Befund 25.07.2026). Vorher galt EIN Preset für
# alles: eine 4K-UHD wurde damit auf 1080p heruntergerechnet und eine DVD auf
# 1080p hochskaliert — beides Unsinn, und beim ersten UHD-Rip wäre die 4K-
# Auflösung still verlorengegangen. Den Disc-Typ kennt Rippy ohnehin.
PRESET_SCHLUESSEL = {
"dvd": "transcodePresetDvd",
"bluray": "transcodePresetBluray",
"uhd": "transcodePresetUhd",
}
# Reservierter Wert im Preset-Feld eines Disc-Typs: „diesen Typ NICHT
# komprimieren". Kein HandBrake-Preset heißt so (gegengeprüft gegen alle 90 aus
# --preset-list), deshalb kann er nicht mit einem echten Namen kollidieren.
#
# Warum es das gibt (Befund 25.07.2026): Ein 4K-HEVC-Encode brauchte auf der
# Rippy-VM gemessene 28-55 Stunden, weil die CPU kein AVX2 hat. Bis hierher gab
# es nur einen globalen Schalter — entweder ALLES komprimieren oder nichts. Wer
# 4K verlustfrei behalten und DVDs trotzdem schrumpfen wollte, hatte keine
# Möglichkeit. Genau die ist das hier.
PRESET_KEINE = "keine"
def komprimieren_fuer(disc_type: str, einstellungen: dict) -> bool:
"""Soll dieser Disc-Typ nach dem Rip komprimiert werden? (pure, testbar)
Der globale Schalter `transcodeEnabled` schlägt alles. Ist er an, kann ein
einzelner Disc-Typ abgewählt werden, indem sein Preset auf PRESET_KEINE
steht dann bleibt die verlustfreie Datei aus dem Rip stehen.
"""
einstellungen = einstellungen or {}
if not einstellungen.get("transcodeEnabled", True):
return False
schluessel = PRESET_SCHLUESSEL.get(disc_type or "")
if schluessel and (einstellungen.get(schluessel) or "").strip() == PRESET_KEINE:
return False
return True
def preset_fuer(disc_type: str, einstellungen: dict) -> str:
"""Welches HandBrake-Preset gilt für diesen Disc-Typ? (pure, testbar)
Reihenfolge: Preset des Disc-Typs allgemeines transcodePreset
DEFAULT_HB_PRESET. Diese Kette hält Bestandsinstallationen am Leben, in
denen nur `transcodePreset` gesetzt ist: solange der Nutzer die neuen
Felder nicht speichert, ändert sich sein Verhalten nicht.
PRESET_KEINE gilt hier absichtlich als nicht gesetzt" und wird
übersprungen: Diese Funktion darf NIE den Reservewert als Preset-Namen
zurückgeben, sonst bekäme HandBrake `--preset keine` und scheiterte. Ob
überhaupt komprimiert wird, entscheidet komprimieren_fuer(). Wer über
Neu komprimieren" ausdrücklich doch komprimieren will, bekommt so ein
brauchbares Preset statt eines Fehlers.
"""
einstellungen = einstellungen or {}
schluessel = PRESET_SCHLUESSEL.get(disc_type or "")
if schluessel:
eigen = (einstellungen.get(schluessel) or "").strip()
if eigen and eigen != PRESET_KEINE:
return eigen
return (einstellungen.get("transcodePreset") or "").strip() or DEFAULT_HB_PRESET
def build_handbrake_cmd(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET) -> list:
"""Baut das HandBrake-Kommando für die Kompressions-Stufe (pure Funktion).
@@ -222,20 +336,57 @@ def build_handbrake_cmd(input_path: str, output_path: str, preset: str = DEFAULT
]
HB_ENCODE_RE = re.compile(
r'^Encoding:\s*task\s+(\d+)\s+of\s+(\d+),\s*(\d+(?:\.\d+)?)\s*%'
)
def get_progress_from_line(line: str) -> int:
"""Extrahiert Fortschritt in Prozent aus HandBrake-Ausgabe.
"""Extrahiert den ENCODE-Fortschritt in Prozent aus HandBrake-Ausgabe.
Testfund 22.07.: echtes HandBrake schreibt 45.50 %" MIT Leerzeichen vor
dem Prozentzeichen eine Regex ohne \\s* parst NIE echte Ausgabe.
Gibt **-1** zurück, wenn die Zeile kein Encode-Fortschritt ist genau wie
get_progress_from_prgv. Sonst wäre ein echtes 0 %" nicht von „keine
Angabe" zu unterscheiden, und der Anfang des Encodes bliebe unsichtbar.
Formatstrings aus dem HandBrakeCLI-Binary im Worker-Image gelesen
(AGENTS Regel D nicht aus dem Kopf):
Scanning title %d of %d, preview %d, %.2f %% Scan, NICHT Encode
Encoding: task %d of %d, %.2f %% (%.2f fps, avg Encode
Encoding: task %d of %d, Searching for start time, %.2f Vorlauf, NICHT Encode
Befund 25.07.2026 (Akira-UHD, live gemessen): Die alte Regex nahm JEDE
Zahl vor einem Prozentzeichen also auch den Scan-Durchlauf, der VOR dem
Encodieren einmal bis 100 % hochläuft. Die Anzeige stand deshalb auf 99 %,
während der eigentliche Encode bei 1 % war. Zusammen mit dem alten Filter
`progress > 0` im Aufrufer blieb sie dort stundenlang kleben, weil
int(0.87) == 0 verworfen wurde.
`task N of M` wird mitgerechnet: Presets mit zwei Durchläufen zählen die
Prozente je Durchlauf neu, sonst sprang die Anzeige mitten in der Datei
zurück auf 0.
"""
match = re.search(r'(\d+\.\d+)\s*%', line)
if match:
return int(float(match.group(1)))
return 0
match = HB_ENCODE_RE.match(line.strip())
if not match:
return -1
aktuell, gesamt, prozent = int(match.group(1)), int(match.group(2)), float(match.group(3))
if gesamt < 1 or aktuell < 1:
return -1
return int(((aktuell - 1) * 100 + prozent) / gesamt)
def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET, progress_cb=None) -> dict:
"""Komprimiert eine MKV-Datei mit HandBrakeCLI; meldet Fortschritt."""
def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET,
progress_cb=None, abbruch_cb=None) -> dict:
"""Komprimiert eine MKV-Datei mit HandBrakeCLI; meldet Fortschritt.
`abbruch_cb` wird bei JEDER Ausgabezeile aufgerufen und soll RipAbbruch
werfen, wenn der Nutzer abgebrochen hat. Bewusst ein eigener Kanal neben
`progress_cb` aus demselben Grund wie `log_cb` bei run_makemkv: der
Fortschritts-Callback verwirft Aufrufe mit unveränderter Prozentzahl, und
genau daran hing der Abbruch früher. Bei einem 4K-Encode mit einem Prozent
je halber Stunde sah Abbrechen" deshalb minutenlang wirkungslos aus
(Befund 25.07.2026). Zusätzlich greift es damit auch während des
Scan-Durchlaufs, der gar keine Encode-Prozente ausgibt.
"""
if not check_handbrake_installed():
return {"status": "error", "error": "HandBrakeCLI ist nicht installiert"}
@@ -247,11 +398,26 @@ def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PR
text=True,
bufsize=1
)
return _handbrake_schleife(process, output_path, abbruch_cb, progress_cb)
except Exception as e:
return {"status": "error", "error": str(e)}
def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=None) -> dict:
"""Liest HandBrakes Ausgabe und wertet sie aus. Eigene Funktion, damit die
Reihenfolge (Abbruch VOR Fortschritt) ohne echtes HandBrake testbar ist."""
try:
for line in process.stdout:
# Zuerst der Abbruch — unabhängig davon, ob die Zeile überhaupt
# einen Fortschritt enthält. Sonst hängt „Abbrechen" daran, dass
# sich die Prozentzahl bewegt (Befund 25.07.2026).
if abbruch_cb:
abbruch_cb()
progress = get_progress_from_line(line)
if progress > 0 and progress_cb:
# >= 0: ein echtes 0 % ist eine Angabe und muss durch. Der alte
# Filter `> 0` verwarf den gesamten ersten Prozentpunkt — bei
# einem 4K-Encode sind das Stunden ohne sichtbare Bewegung.
if progress >= 0 and progress_cb:
progress_cb(progress)
except RipAbbruch:
process.kill()
@@ -267,24 +433,29 @@ def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PR
"error": f"HandBrake endete mit Code {process.returncode}",
"return_code": process.returncode,
}
except Exception as e:
return {"status": "error", "error": str(e)}
def build_abcde_cmd(device_path: str, config_path: str) -> list:
def build_abcde_cmd(device_path: str, config_path: str, auswerfen: bool = True) -> list:
"""Baut das abcde-Kommando (pure Funktion, testbar).
-o = Ausgabeformat (flac), -N = nicht-interaktiv, -x = Eject am Ende,
-c = Config-Datei (enthält OUTPUTDIR). NIE ein Verzeichnis an -o geben.
Befund 25.07.2026: `-x` stand fest verdrahtet drin. Damit warf eine
Audio-CD IMMER aus, während eine DVD/Blu-ray NIE auswarf und die
Einstellung Automatischer Auswurf" regelte keines von beidem, weil sie
nirgends gelesen wurde. Jetzt entscheidet sie hier mit.
"""
return [
cmd = [
"abcde",
"-d", device_path,
"-o", "flac",
"-N",
"-x",
"-c", config_path
]
if auswerfen:
cmd.append("-x")
cmd += ["-c", config_path]
return cmd
def write_abcde_config(output_dir: str) -> str:
@@ -297,8 +468,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
wären dort also großteils verschwunden. Ohne diesen Kanal war am
25.07.2026 nicht von aussen erkennbar, dass MakeMKV bei der UHD-Disc
nicht einmal versucht, einen Schluessel zu holen (siehe makemkv_daten).
"""
if not check_makemkv_installed():
return {"status": "error", "error": "makemkvcon ist nicht installiert"}
@@ -328,15 +508,23 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
try:
for line in process.stdout:
progress = get_progress_from_prgv(line)
if progress >= 0 and progress_cb:
if progress >= 0:
if 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('"')
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 +561,7 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_cb=None,
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 +576,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,11 +585,14 @@ 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:
"""Rippt eine CD mit abcde (FLAC)."""
def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = None,
auswerfen: bool = True) -> dict:
"""Rippt eine CD mit abcde (FLAC). `auswerfen` = Einstellung „Automatischer
Auswurf" (abcde macht das selbst per -x)."""
if not check_abcde_installed():
return {
"status": "error",
@@ -425,7 +616,7 @@ def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = N
try:
process = subprocess.Popen(
build_abcde_cmd(device_path, config_path),
build_abcde_cmd(device_path, config_path, auswerfen=auswerfen),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
+363 -42
View File
@@ -12,14 +12,18 @@ komprimiert danach auf Arbeitsgröße. Die Rohdatei liegt nur temporär in
/app/temp und wird nach Erfolg gelöscht (Setting keepOriginal behält sie).
"""
import errno
import glob
import json
import os
import posixpath
import shutil
import time
import requests
import db
import makemkv_daten
import medien
import notify
from celery_app import celery_app
@@ -34,14 +38,16 @@ except ImportError: # Windows: kein fcntl
disc_size_bytes = None
from ripping import (
DEFAULT_HB_PRESET,
RIP_OUTPUT_DIR,
RipAbbruch,
komprimieren_fuer,
lies_datei_dauer,
lies_titel_info,
preset_fuer,
rip_cd,
rip_video,
run_handbrake,
wirf_disc_aus,
)
API_URL = os.getenv("API_URL", "http://api:8000")
@@ -88,28 +94,68 @@ def pfad_lokal(pfad: str, mapping: str = None) -> str:
RAW_DIR = os.getenv("RAW_DIR", "/app/temp/raw")
MEDIA_ROOT = "/app/media"
# Wie oft während einer Kompression nachgesehen wird, ob der Nutzer abgebrochen
# hat. Eine DB-Abfrage alle paar Sekunden ist nichts gegen einen Encode, der
# Stunden läuft — und „Abbrechen" fühlt sich damit sofort an.
ABBRUCH_INTERVALL_SEKUNDEN = 5
def unter_wurzel(pfad: str, wurzel: str) -> bool:
"""Liegt `pfad` wirklich unterhalb von `wurzel` (oder IST es die Wurzel)?
Pure Funktion, testbar. Ein nacktes `startswith()` genügt nicht:
/app/media-boese/x" beginnt mit „/app/media", liegt aber außerhalb
(Befund 25.07.2026 bei der Durchsicht). Immer /" als Trenner — das sind
Container-Pfade, auch wenn ein nativer Windows-Worker das Modul lädt.
Gleichlautend in docker/api/main.py; es gibt kein geteiltes Paket zwischen
den Containern.
"""
if not pfad or not wurzel:
return False
sauber = wurzel.rstrip("/") or "/"
return pfad == sauber or pfad.startswith(sauber + "/")
def _zielbasis(target_dir, disc_type: str) -> str:
"""Ablagebasis: vom Nutzer gewähltes Ziel (validiert) oder Standard."""
"""Ablagebasis: vom Nutzer gewähltes Ziel (validiert) oder Standard.
posixpath statt os.path aus demselben Grund wie in _arbeitsverzeichnis:
das sind IMMER Container-Pfade. os.path.normpath macht unter Windows
Backslashes daraus, und dann greift die MEDIA_ROOT-Prüfung nicht mehr, das
gewählte Ziel fiele still auf den Standard zurück. Aufgefallen 25.07.2026,
als der Test dafür erstmals unter Windows lief. Live war es nie: aufgerufen
wird nur aus rip_disc, und das ist auf Windows-Workern verriegelt.
"""
if target_dir:
normalisiert = os.path.normpath(target_dir)
if normalisiert.startswith(MEDIA_ROOT):
normalisiert = posixpath.normpath(target_dir)
if unter_wurzel(normalisiert, MEDIA_ROOT):
return normalisiert
return os.path.join(RIP_OUTPUT_DIR, disc_type)
return posixpath.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):
# 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 unter_wurzel(normalisiert, MEDIA_ROOT):
return normalisiert
return RAW_DIR
@@ -147,19 +193,117 @@ 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(). Zwischen Arbeitsverzeichnis (/app/temp) und Ziel
(/app/media) scheitert os.rename mit EXDEV, shutil.move fällt auf Kopieren
zurück eine 74-GB-Vollkopie auf dieselbe Platte, bis sie mit ENOSPC voll
war. Ergebnis: Platte 100 % voll, Worker-Container startete nicht mehr, und
der Job galt als FEHLGESCHLAGEN, obwohl die komprimierte Datei längst
fertig war. Der Nutzer sah nur nichts da".
## Warum hier NICHT vorhergesagt, sondern versucht wird
Die erste Fassung dieses Schutzes verglich `os.stat(...).st_dev` und
schloss aus gleichen Werten auf reines Umhängen, kein Platz nötig". Am
25.07.2026 im Worker-Container nachgemessen beides zugleich wahr:
st_dev /app/temp = 2050
st_dev /app/media = 2050 also identisch
os.rename(...) EXDEV, Invalid cross-device link"
Der Kernel vergleicht bei rename() den **Mount**, nicht das Gerät. /app/temp
(Docker-Volume) und /app/media (Bind-Mount) sind zwei Mounts DERSELBEN
ext4-Partition. Die st_dev-Prüfung war deshalb wirkungslos: sie sah
gleiches Dateisystem", übersprang die Platzprüfung, und shutil.move kopierte
doch. Der Schutz hätte genau den Schaden zugelassen, gegen den er gebaut war.
Also: erst rename VERSUCHEN. Klappt es, ist es umgehängt und fertig.
Kommt EXDEV, steht fest, dass kopiert werden müsste und erst dann wird
der Platz geprüft. Das ist keine Vermutung mehr, sondern die Antwort des
Kernels.
"""
ziel_original = os.path.join(final_dir, "original")
try:
# Der billige Weg zuerst — und er ist gleichzeitig der einzige
# verlässliche Test, ob überhaupt umgehängt werden kann.
try:
os.rename(raw_dir, ziel_original)
db.add_log(
"info", "worker",
f"Job {job_id}: Original behalten unter {ziel_original} (umgehängt, "
"kein zusätzlicher Platz nötig)",
)
return
except OSError as e:
if e.errno != errno.EXDEV:
raise # etwas anderes ist schiefgelaufen → unten ehrlich melden
# Ab hier ist eine echte Kopie unvermeidlich. Jetzt lohnt die Platzfrage.
benoetigt = _ordner_groesse(raw_dir)
frei = _frei_bytes(final_dir)
if frei < benoetigt * 1.05:
db.add_log(
"warning", "worker",
f"Job {job_id}: Original NICHT aufgehoben — Ziel liegt auf einem "
f"anderen Mount, es müsste kopiert werden. Dafür wären "
f"{benoetigt / 1024**3:.1f} GB nötig, frei sind nur "
f"{frei / 1024**3:.1f} GB. Die Roh-Datei bleibt unter "
f"{raw_dir} liegen. Abhilfe: Arbeitsverzeichnis "
"(Einstellungen → Verarbeitung) auf dieselbe Freigabe legen "
"wie das Ziel — dann wird nur umgehängt statt kopiert."
)
return
shutil.move(raw_dir, ziel_original)
db.add_log("info", "worker", f"Job {job_id}: Original behalten unter {ziel_original}")
except OSError as e:
# Halb geschriebene Kopie wegräumen, sonst belegt sie für immer Platz.
shutil.rmtree(ziel_original, ignore_errors=True)
db.add_log(
"warning", "worker",
f"Job {job_id}: Original konnte nicht aufgehoben werden ({e}). "
f"Die komprimierte Datei ist fertig; die Roh-Datei bleibt unter {raw_dir}."
)
def _abbruch_angefordert(job_id: str) -> bool:
"""Kooperativer Abbruch: hat der Nutzer über die API abgebrochen?"""
return db.get_job_status(job_id) == "canceling"
@@ -344,9 +488,27 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
)
db.update_job(job_id, progress=progress)
# 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)
# Je Disc-Typ abwählbar (siehe komprimieren_fuer): 4K verlustfrei behalten,
# DVDs trotzdem schrumpfen — vorher gab es nur alles oder nichts.
transcode_an = ist_video and komprimieren_fuer(disc_type, einstellungen)
if ist_video:
# UI-Key schlägt Env-Key — Monats-Key ohne Rebuild aktualisierbar
@@ -382,7 +544,8 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
# Geplantes Ziel sofort sichtbar machen (UI-Detail + retry-transcode)
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).
@@ -402,7 +565,10 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
return ergebnis
if disc_type == "cd":
ergebnis = rip_cd(device_path, job_id, progress_cb=fortschritt, output_dir=final_dir)
ergebnis = rip_cd(
device_path, job_id, progress_cb=fortschritt, output_dir=final_dir,
auswerfen=bool(einstellungen.get("autoEject", True)),
)
elif transcode_an:
# Roh-Rip ins Arbeitsverzeichnis (wird nach erfolgreicher Kompression gelöscht)
ergebnis = rip_video(
@@ -411,6 +577,7 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
output_dir=raw_dir,
nur_hauptfilm=nur_hauptfilm,
titel_liste=titel_liste,
log_cb=melde_makemkv,
)
else:
ergebnis = rip_video(
@@ -418,26 +585,44 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
progress_cb=fortschritt, output_dir=final_dir,
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. "
)
elif "Failed to open disc" in fehler_text:
+ "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 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 "
@@ -445,6 +630,23 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
"Normale BD/DVD gehen weiterhin."
)
# Automatischer Auswurf. Die Disc ist nach dem Rip nicht mehr nötig — die
# Kompression arbeitet auf der Datei, nicht am Laufwerk.
#
# Befund 25.07.2026: Die Einstellung („Disc nach erfolgreichem Ripping
# automatisch auswerfen", Standard ein) wurde von NIEMANDEM gelesen. Bei
# DVD/Blu-ray warf Rippy deshalb nie aus, bei Audio-CD dagegen immer, weil
# abcde `-x` fest verdrahtet bekam. Jetzt entscheidet die Einstellung beides.
if ergebnis.get("status") == "success" and ist_video and einstellungen.get("autoEject", True):
if wirf_disc_aus(device_path):
db.add_log("info", "worker", f"Job {job_id}: Disc ausgeworfen")
else:
db.add_log(
"warning", "worker",
f"Job {job_id}: Disc konnte nicht ausgeworfen werden ({device_path}) — "
"der Rip ist davon unberührt.",
)
if ergebnis.get("status") == "success" and transcode_an:
# Kompression als eigener Task — an den im Rip-Dialog GEWÄHLTEN Worker
# (worker_direct), sonst an die geteilte transcode-Queue (irgendein
@@ -498,6 +700,80 @@ def scan_tracks(device_path: str):
return {"status": "error", "error": str(e)}
def _ist_uebersetzt(container_pfad: str, lokal_pfad: str) -> bool:
"""Hat RIPPY_PATH_MAP diesen Pfad wirklich angefasst? (pure, testbar)"""
return container_pfad != lokal_pfad
def _erreichbarkeit_pruefen(raw_container: str, raw_lokal: str,
final_container: str, final_lokal: str) -> str:
"""Kann DIESER Worker Quelle und Ziel überhaupt sehen? Klartext oder "".
Befund 25.07.2026 am Job 95afdc89, live: Das gezielte Routing an den
Windows-PC des Commanders funktionierte einwandfrei der Worker nahm die
Aufgabe an und lehnte sie 182 ms später ab mit Keine Roh-MKVs in
/app/media/rippy/ gefunden". Diese Meldung klang nach einem kaputten Rip,
obwohl der Rip vollständig war (79,6 GB lagen auf der NAS). Der Commander
schloss daraus, das System sähe den externen Encoder nicht verständlich
und falsch.
Die eigentliche Ursache: `/app/media/...` sind CONTAINER-Pfade. Ein nativer
Worker sieht sie nur, wenn RIPPY_PATH_MAP sie auf eine Freigabe übersetzt
und dieses Mapping wurde von NIEMANDEM gesetzt. Es war also nicht möglich,
dass diese Kombination je funktioniert.
Zweiter Stolperstein im selben Fall: Die Rohdaten lagen auf der NAS (für den
PC erreichbar), das ZIEL aber auf der VM-Platte (nicht erreichbar, dort
läuft kein Samba). Deshalb werden hier BEIDE Pfade geprüft, nicht nur die
Quelle sonst scheitert es erst beim Schreiben, nach Stunden Rechenzeit.
"""
mapping = os.getenv("RIPPY_PATH_MAP", "")
fremder_worker = not os.path.isdir("/app")
for zweck, container, lokal in (
("Quelle (Rohdaten)", raw_container, raw_lokal),
("Ziel (fertige Datei)", final_container, final_lokal),
):
if os.path.isdir(lokal):
continue
# Ziel darf fehlen, solange sein Elternordner erreichbar ist — es wird
# gleich angelegt. Nur ein unerreichbarer ELTERN-Pfad ist ein Problem.
if zweck.startswith("Ziel") and os.path.isdir(os.path.dirname(lokal) or "."):
continue
text = [f"Dieser Worker erreicht die {zweck} nicht: {lokal}"]
if fremder_worker and not mapping:
text.append(
"Ursache: RIPPY_PATH_MAP ist auf dieser Maschine nicht gesetzt. "
f'{container}" ist ein Pfad INNERHALB des Rippy-Containers — ein '
'externer Worker sieht ihn nur, wenn er auf eine Netzwerk-Freigabe '
'übersetzt wird.'
)
text.append(
"Abhilfe: Freigabe auf diesem Rechner erreichbar machen und in "
"start-tray.bat setzen, z. B. "
"set RIPPY_PATH_MAP=/app/media=\\\\NAS\\rippy-media"
)
elif fremder_worker and mapping:
text.append(f"RIPPY_PATH_MAP ist gesetzt ({mapping}), deckt diesen Pfad aber nicht ab.")
if not _ist_uebersetzt(container, lokal):
text.append(
f'{container}" wurde von keinem Eintrag übersetzt — fehlt ein '
'Präfix, oder ist es ein anderes Verzeichnis als erwartet?'
)
else:
text.append(
"Der Pfad existiert nicht. Liegt das Arbeitsverzeichnis auf einer "
"Freigabe, die gerade nicht eingehängt ist?"
)
text.append(
'WICHTIG: Die Rohdaten sind NICHT verloren — nach der Korrektur '
'genügt „Neu komprimieren", ohne die Disc erneut zu rippen.'
)
return " ".join(text)
return ""
@celery_app.task(bind=True, name="worker.tasks.transcode_files")
def transcode_files(self, job_id: str, raw_dir: str, final_dir: str):
"""Stufe 2: HandBrake komprimiert die Roh-MKVs auf Arbeitsgröße.
@@ -509,27 +785,72 @@ def transcode_files(self, job_id: str, raw_dir: str, final_dir: str):
"""
db.init_db()
# Native Worker (Windows) übersetzen Container-Pfade aufs Netzlaufwerk
raw_container, final_container = raw_dir, final_dir
raw_dir = pfad_lokal(raw_dir)
final_dir = pfad_lokal(final_dir)
fehler = _erreichbarkeit_pruefen(raw_container, raw_dir, final_container, final_dir)
if fehler:
ergebnis = {"status": "error", "error": fehler}
_job_abschliessen(job_id, ergebnis)
return ergebnis
quellen = sorted(glob.glob(os.path.join(raw_dir, "*.mkv")))
if not quellen:
ergebnis = {"status": "error", "error": f"Keine Roh-MKVs in {raw_dir} gefunden"}
ergebnis = {
"status": "error",
"error": (
f"Das Verzeichnis {raw_dir} ist erreichbar, enthält aber keine "
"MKV-Datei. Der Rip hat also nichts abgelegt (oder jemand hat die "
"Datei entfernt). Die Kompression lässt sich nach einem neuen Rip "
"erneut anstoßen."
),
}
_job_abschliessen(job_id, ergebnis)
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)
letzter = [-1]
letzte_abbruchpruefung = [0.0]
def abbruch_pruefen():
"""Zeitgesteuert prüfen, ob der Nutzer abgebrochen hat. Wirft RipAbbruch.
Befund 25.07.2026 (Commander, am laufenden Akira-Job beobachtet): Der
Abbruch wurde NUR in `datei_fortschritt` geprüft und diese Closure
stieg oben sofort wieder aus, wenn sich die Prozentzahl nicht geändert
hatte. Bei einem 4K-Encode, der pro Prozent eine halbe Stunde braucht,
sah Abbrechen" entsprechend lange wirkungslos aus (gemessen: 3,4 min
zwischen Anforderung 18:30:17 und Bestätigung 18:33:38 bei noch
langsamerem Fortschritt entsprechend mehr).
Deshalb hängt die Prüfung jetzt an der Zeit statt am Fortschritt und
läuft bei JEDER Ausgabezeile von HandBrake auch während des
Scan-Durchlaufs, der überhaupt keine Encode-Prozente liefert.
"""
jetzt = time.monotonic()
if jetzt - letzte_abbruchpruefung[0] < ABBRUCH_INTERVALL_SEKUNDEN:
return
letzte_abbruchpruefung[0] = jetzt
if _abbruch_angefordert(job_id):
raise RipAbbruch()
for index, quelle in enumerate(quellen):
ziel = os.path.join(final_dir, os.path.basename(quelle))
@@ -538,11 +859,13 @@ def transcode_files(self, job_id: str, raw_dir: str, final_dir: str):
if gesamt == letzter[0]:
return
letzter[0] = gesamt
if _abbruch_angefordert(job_id):
raise RipAbbruch()
db.update_job(job_id, progress=min(99, gesamt))
hb = run_handbrake(quelle, ziel, preset=preset, progress_cb=datei_fortschritt)
hb = run_handbrake(
quelle, ziel, preset=preset,
progress_cb=datei_fortschritt,
abbruch_cb=abbruch_pruefen,
)
if hb.get("status") == "cancelled":
_job_abschliessen(job_id, hb)
return hb
@@ -558,9 +881,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)
+140
View File
@@ -0,0 +1,140 @@
"""Tests der Fähigkeiten-Erkennung — reine Funktionen, keine Infrastruktur.
Die Testdaten sind ECHTE Ausgaben, am 25.07.2026 im Worker-Image der Rippy-VM
abgenommen (AGENTS Regel D). Erfundene Formate hätten hier keinen Wert: genau
daran ist die Erkennung vorher gescheitert.
"""
import caps
# Wörtlich aus `docker exec rippy-worker-1 HandBrakeCLI --help` (HandBrake im
# Worker-Image, 25.07.2026). Bemerkenswert: KEIN Hardware-Encoder dabei.
HB_HILFE_ECHT = """
-Z, --preset <string> Select preset by name
-e, --encoder <string> Select video encoder:
svt_av1
svt_av1_10bit
x264
x264_10bit
x265
x265_10bit
x265_12bit
mpeg4
mpeg2
VP8
VP9
VP9_10bit
theora
--encoder-preset <string>
Adjust video encoding settings
"""
# Wie dieselbe Stelle auf einer Maschine MIT Hardware-Encodern aussieht
# (HandBrake-Windows-Build; Struktur identisch, nur mehr Namen).
HB_HILFE_MIT_HARDWARE = """
-e, --encoder <string> Select video encoder:
x264
x265
nvenc_h264
nvenc_h265
qsv_h264
qsv_h265
--encoder-preset <string>
"""
def test_encoder_liste_wird_aus_echter_hilfe_gelesen():
encoder = caps.parse_encoder_liste(HB_HILFE_ECHT)
assert encoder == [
"svt_av1", "svt_av1_10bit",
"x264", "x264_10bit",
"x265", "x265_10bit", "x265_12bit",
"mpeg4", "mpeg2", "VP8", "VP9", "VP9_10bit", "theora",
]
# Der Block endet an der nächsten Option — sie darf nicht mitgelesen werden
assert not any("encoder-preset" in e for e in encoder)
assert not any(e.startswith("-") for e in encoder)
def test_encoder_liste_ohne_handbrake_ist_leer():
assert caps.parse_encoder_liste("") == []
assert caps.parse_encoder_liste(None) == []
assert caps.parse_encoder_liste("irgendein Text ohne Encoder-Abschnitt") == []
def test_ohne_handbrake_keine_encoder():
"""Der wichtigste Fall: vorher standen cpu-x264/cpu-x265 fest verdrahtet in
der Liste. Ein Rip-Worker ohne HandBrake behauptete damit, komprimieren zu
können und jeder Transcode dort endete sofort mit 'nicht installiert'."""
assert caps.leite_backends_ab([]) == []
assert caps.leite_backends_ab(None) == []
def test_backends_aus_echter_liste():
"""Das Worker-Image der VM kann laut eigener Auskunft keine Hardware —
entsprechend steht hier nur CPU."""
encoder = caps.parse_encoder_liste(HB_HILFE_ECHT)
assert caps.leite_backends_ab(encoder) == ["cpu-x264", "cpu-x265", "cpu-av1"]
def test_handbrakes_liste_ist_die_auskunft_ohne_geraetepruefung():
"""Regel-Wechsel 25.07.2026, weil die alte Fassung einen Bug hatte.
Vorher wurde zusätzlich ein Gerät verlangt: /dev/dri für VAAPI/QSV/VCE bzw.
nvidia-smi für NVENC. Unter WINDOWS gibt es beides nicht der PC des
Commanders (RX 9070 XT) meldete deshalb nur CPU-Encoder, obwohl HandBrakes
Windows-Build vce_*/nvenc_*/qsv_* beherrscht. Der Bug traf genau den
Anwendungsfall, für den externe Worker gedacht sind.
Die Prüfung war zudem überflüssig: HandBrake probiert Hardware beim Start
selbst an und listet nur Nutzbares (auf der VM belegt 'qsv: not available
on this system', und qsv_* fehlt dann in --help). Wer die Liste hat, hat die
Antwort; eine zweite Meinung braucht es nicht.
"""
mit_hw = caps.parse_encoder_liste(HB_HILFE_MIT_HARDWARE)
backends = caps.leite_backends_ab(mit_hw)
assert "nvenc" in backends
assert "qsv" in backends
# Ohne Geräteprüfung — es gibt keine Parameter mehr, die das verhindern
assert caps.leite_backends_ab(mit_hw) == backends
def test_hardware_wird_nach_familie_unterschieden():
"""Vorher landete alles in „vaapi" — eine AMD-Karte lief damit unter dem
Intel-Namen, und das UI konnte nicht sagen, welches Preset passt."""
amd = caps.leite_backends_ab(["x265", "vce_h264", "vce_h265"])
assert "vce" in amd
assert "qsv" not in amd and "nvenc" not in amd
intel = caps.leite_backends_ab(["x265", "qsv_h265"])
assert "qsv" in intel and "vce" not in intel
def test_hardware_av1_bekommt_eigene_kennung():
"""AV1 in Hardware ist derzeit die beste Kombination aus Tempo und
Dateigröße das darf nicht in nvenc" verschwinden, sonst kann das UI es
nicht empfehlen."""
backends = caps.leite_backends_ab(["x265", "nvenc_h265", "nvenc_av1"])
assert "nvenc" in backends
assert "nvenc-av1" in backends
# Ohne AV1 in der Liste gibt es die Kennung auch nicht
assert "nvenc-av1" not in caps.leite_backends_ab(["nvenc_h265"])
def test_simd_stufe_erkennt_die_hoechste():
assert caps.beste_simd("fpu vme de sse4_2 avx avx2") == "avx2"
assert caps.beste_simd("sse4_2 avx avx2 avx512f") == "avx512f"
assert caps.beste_simd("fpu vme de pse tsc sse4_2") == "sse4_2"
assert caps.beste_simd("") == "unbekannt"
assert caps.beste_simd(None) == "unbekannt"
def test_simd_stufe_der_rippy_vm_ist_sse4_2():
"""Echt gemessen auf der Rippy-VM (25.07.2026): generisches QEMU-CPU-Modell,
`grep -c avx2 /proc/cpuinfo` = 0. Genau das macht Software-4K dort so lahm."""
flags_vm = (
"fpu de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 "
"clflush mmx fxsr sse sse2 syscall nx lm rep_good nopl cpuid "
"pni cx16 sse4_1 sse4_2 x2apic popcnt aes hypervisor lahf_lm"
)
assert caps.beste_simd(flags_vm) == "sse4_2"
+244
View File
@@ -0,0 +1,244 @@
"""Tests für makemkv_daten.py: die reinen Helfer rund um das MakeMKV-Datenverzeichnis.
WARUM ES DIESE TESTS GIBT (Befund 25.07.2026, live im Worker nachgemessen):
Eine 4K-UHD-Disc (Akira UHD, MKB v76) scheiterte mit "The volume key is unknown
for this disc", obwohl Laufwerk und MakeMKV in Ordnung waren. Der einzige heute
noch funktionierende Weg ist eine selbst mitgebrachte KEYDB.cfg im
Datenverzeichnis. Damit hängt einiges an diesen kleinen Funktionen: erkennen wir
die Datei falsch, meldet das UI "alles gut", während MakeMKV weiter scheitert.
Getestet wird nur, was ohne Postgres, Redis und ohne Laufwerk läuft also die
puren Funktionen mit echten Beispieldaten. Zeilenformat der KEYDB.cfg laut
libaacs (AGENTS Regel D, externe Schnittstellen nie aus dem Kopf):
https://github.com/ShiftMediaProject/libaacs/blob/master/KEYDB.cfg
WARUM DER DATEINAME "_worker" HINTEN DRANHAENGT (25.07.2026): makemkv_daten.py
ist eine Zwillingsdatei, es gibt sie unter docker/api/ UND docker/worker/, und
beide Seiten haben Tests. Da im Projekt keine __init__.py liegen, importiert
pytest Testdateien unter ihrem blossen Dateinamen zwei Dateien namens
test_makemkv_daten.py brechen deshalb die Sammelphase ab ("import file
mismatch") und faerben die ganze Ampel rot. Nicht zurückbenennen.
"""
import hashlib
import importlib.util
import os
# WICHTIG (Prüfbefund 25.07.2026): Ein schlichtes "from makemkv_daten import ..."
# lädt bei "pytest -q" vom Repo-Wurzelverzeichnis NICHT diese Datei, sondern die
# API-Kopie — docker/api wird zuerst gesammelt, und jeder weitere Import trifft
# nur noch den sys.modules-Cache. Die Tests hier hätten den Worker-Zwilling also
# nie angefasst und eine Abweichung wäre grün durchgelaufen. Deshalb wird er
# ausdrücklich über seinen Pfad geladen.
_HIER = os.path.dirname(os.path.abspath(__file__))
_WORKER_MODUL = os.path.join(_HIER, "makemkv_daten.py")
_API_MODUL = os.path.abspath(os.path.join(_HIER, "..", "api", "makemkv_daten.py"))
_spec = importlib.util.spec_from_file_location("makemkv_daten_worker_kopie", _WORKER_MODUL)
_modul = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_modul)
ist_aacs_dump = _modul.ist_aacs_dump
keydb_pruefen = _modul.keydb_pruefen
settings_conf_zusammenfuehren = _modul.settings_conf_zusammenfuehren
zaehle_disc_eintraege = _modul.zaehle_disc_eintraege
private_data_pruefen = _modul.private_data_pruefen
zaehle_schluessel = _modul.zaehle_schluessel
def _tar_mit(namen):
"""Baut ein tar-Archiv im Speicher — so sieht MakeMKVs Schlüsselspeicher aus.
Echte Eintragsnamen aus dem Speicher der Windows-Installation vom
25.07.2026: hkd_<8 Hex>.bin für die Schluessel, dazu Index-Dateien.
"""
import io
import tarfile
puffer = io.BytesIO()
with tarfile.open(fileobj=puffer, mode="w") as archiv:
for name in namen:
eintrag = tarfile.TarInfo(name)
eintrag.size = 3
archiv.addfile(eintrag, io.BytesIO(b"abc"))
return puffer.getvalue()
def test_zaehle_schluessel_zaehlt_nur_hkd_eintraege():
# Nur hkd_*.bin sind Disc-Schluessel. Index- und sdf-Dateien gehoeren zum
# Speicher dazu, sind aber keine Schluessel — sonst meldete das UI
# "1 Schluessel vorhanden" für einen komplett leeren Vorrat.
voll = _tar_mit([
"hkd_00000059.bin",
"hkd_0000005a.bin",
"sdf_000000a6.bin",
"--index-A2E950B3C3FC57DA9CB856DCAFBA5275F40423DB.bin",
])
assert zaehle_schluessel(voll) == 2
def test_zaehle_schluessel_leerer_speicher_ist_null():
# Genau dieser Zustand lag am 25.07.2026 auf der VM vor: ein Archiv mit
# ausschliesslich der Index-Datei. Jede unbekannte UHD-Disc scheitert dann.
leer = _tar_mit(["--index-4EF73C3560D489497ACE763962A7F07A4A1545C4.bin"])
assert zaehle_schluessel(leer) == 0
def test_zaehle_schluessel_bei_muell_kein_absturz():
# Darf niemals werfen — die Zahl landet im Worker-Herzschlag.
assert zaehle_schluessel(b"") == 0
assert zaehle_schluessel(b"das ist kein tar") == 0
def test_private_data_pruefen_nimmt_echten_speicher_an():
assert private_data_pruefen(_tar_mit(["hkd_00000059.bin"])) == ""
def test_private_data_pruefen_lehnt_leere_und_falsche_dateien_ab():
assert "leer" in private_data_pruefen(b"")
assert "tar-Archiv" in private_data_pruefen(b"<html>Fehlerseite</html>")
def test_private_data_pruefen_lehnt_speicher_ohne_schluessel_ab():
# Der teuerste Bedienfehler: den Speicher einer Installation hochladen,
# die selbst noch nie Schluessel geholt hat. Ohne diese Prüfung ändert
# sich nichts und niemand versteht, warum.
leer = _tar_mit(["--index-4EF73C3560D489497ACE763962A7F07A4A1545C4.bin"])
assert "kein einziger Schluessel" in private_data_pruefen(leer)
def test_zwillinge_sind_byteweise_identisch():
"""docker/api/makemkv_daten.py MUSS dieselbe Datei sein wie diese hier.
Das Modul existiert bewusst doppelt es gibt in diesem Projekt kein
gemeinsames Paket für API und Worker (gleiche Lage wie bei db.py). Genau
deshalb braucht es einen Wächter: laufen die beiden auseinander, zeigt das
UI etwas anderes an, als der rippende Worker tatsächlich sieht, und es
fällt niemandem auf. Dieser Test ist die einzige Stelle, die das
mechanisch prüft.
"""
with open(_WORKER_MODUL, "rb") as datei:
worker = hashlib.sha256(datei.read()).hexdigest()
with open(_API_MODUL, "rb") as datei:
api = hashlib.sha256(datei.read()).hexdigest()
assert worker == api, (
"docker/worker/makemkv_daten.py und docker/api/makemkv_daten.py sind "
"auseinandergelaufen - Änderungen immer in BEIDE Dateien übernehmen."
)
# Eine kleine, aber echte KEYDB.cfg im libaacs-Format: Kommentarkopf, eine
# Disc-Zeile MIT 0x-Praefix, eine OHNE, dazu ein Fortsetzungsfeld und eine
# Leerzeile. Erwartete Zahl der Eintraege: 2.
BEISPIEL_KEYDB = """; KEYDB.cfg
; Kommentarzeilen beginnen mit einem Semikolon
0x8F4E2C1A9B7D3E5F0A6C8B2D4E1F3A5C7B9D0E2F = AKIRA
| V | 0123456789ABCDEF0123456789ABCDEF
A1B2C3D4E5F60718293A4B5C6D7E8F90A1B2C3D4 = BLADE RUNNER 2049
"""
def test_zaehle_disc_eintraege_zaehlt_nur_echte_disc_zeilen():
"""Nur Zeilen mit 40 Hex-Zeichen und Gleichheitszeichen sind Eintraege.
Kommentare, Leerzeilen und Fortsetzungsfelder dürfen nicht mitzaehlen
sonst meldet das UI bei einer reinen Kommentardatei stolz "42 Eintraege".
"""
assert zaehle_disc_eintraege(BEISPIEL_KEYDB) == 2
def test_zaehle_disc_eintraege_ignoriert_kommentare_und_leerzeilen():
# Eine Datei ganz ohne Disc-Zeile hat null Eintraege, nicht drei.
nur_beiwerk = "; nur ein Kommentar\n\n| V | 0123456789ABCDEF0123456789ABCDEF\n"
assert zaehle_disc_eintraege(nur_beiwerk) == 0
def test_zaehle_disc_eintraege_ignoriert_zu_kurze_kennung():
"""39 Hex-Zeichen sind keine Disc-Kennung.
Genau so sieht eine beim Kopieren verstuemmelte Datei aus die darf nicht
als gültig durchgehen, sonst sucht der Commander den Fehler beim Laufwerk.
"""
zu_kurz = "A1B2C3D4E5F60718293A4B5C6D7E8F90A1B2C3D = KAPUTT\n"
assert zaehle_disc_eintraege(zu_kurz) == 0
def test_keydb_pruefen_meldet_leere_datei():
# Häufigster Fehlgriff: das Textfeld war leer, es wird trotzdem gespeichert.
assert keydb_pruefen("") != ""
assert keydb_pruefen(" \n\n ") != ""
def test_keydb_pruefen_erkennt_html_fehlerseite():
"""Der zweithaeufigste Fehlgriff: der Download lieferte eine HTML-Seite.
MakeMKV würde die Datei still ignorieren und weiter "volume key is unknown"
melden deshalb muss der Fehler schon beim Hochladen sichtbar werden.
"""
html = "<!DOCTYPE html>\n<html><body><h1>404 Not Found</h1></body></html>\n"
meldung = keydb_pruefen(html)
assert meldung != ""
assert "HTML" in meldung
def test_keydb_pruefen_meldet_text_ohne_disc_zeile():
# Irgendein Text (hier: eine README) ist keine KEYDB.cfg.
meldung = keydb_pruefen("Diese Datei enthält keine Schluessel, nur Prosa.\n")
assert meldung != ""
def test_keydb_pruefen_akzeptiert_gueltigen_inhalt():
# Leerer Rueckgabewert heißt laut Vertrag: alles in Ordnung.
assert keydb_pruefen(BEISPIEL_KEYDB) == ""
def test_ist_aacs_dump_akzeptiert_echten_namen():
"""Name aus der Praxis: so legt MakeMKV den Dump laut Meldung 3332 ab
(am 25.07.2026 im Worker so beobachtet)."""
assert ist_aacs_dump("MKB20_v76_UHD_AKIRA_C02B.tgz") is True
def test_ist_aacs_dump_lehnt_pfad_tricks_und_fremde_dateien_ab():
"""Der Download-Endpunkt hängt den Namen an das Datenverzeichnis an —
ohne diese Prüfung könnte man sich damit aus dem Verzeichnis heraus
lesen. Versteckte Dateien und Nicht-Dumps sind ebenfalls nichts für die
Liste."""
assert ist_aacs_dump("../ausbruch.tgz") is False
assert ist_aacs_dump(".versteckt.tgz") is False
assert ist_aacs_dump("irgendwas.txt") is False
assert ist_aacs_dump("..\\windows\\ausbruch.tgz") is False
def test_settings_conf_ersetzt_key_und_behaelt_den_rest():
"""DIE Regression, um die es geht: bis zum 25.07.2026 haben entrypoint.sh
und tasks.py die settings.conf komplett überschrieben. Mit dem jetzt
persistenten Datenverzeichnis wäre damit bei jedem Containerstart und vor
jedem Rip alles andere weg allen voran app_UpdateEnable."""
alt = 'app_Key = "T-alterSchluessel"\napp_UpdateEnable = "1"\napp_DefaultSelectionString = "+sel:all"\n'
neu = settings_conf_zusammenfuehren(alt, "T-neuerSchluessel")
assert 'app_Key = "T-neuerSchluessel"' in neu
assert 'app_Key = "T-alterSchluessel"' not in neu
assert 'app_UpdateEnable = "1"' in neu
assert 'app_DefaultSelectionString = "+sel:all"' in neu
# Genau EINE app_Key-Zeile, sonst gewinnt am Ende die falsche.
assert neu.count("app_Key") == 1
def test_settings_conf_leerer_key_entfernt_die_zeile():
# Ein bewusst geleerter Key darf nicht heimlich weiterwirken.
alt = 'app_Key = "T-alterSchluessel"\napp_UpdateEnable = "1"\n'
neu = settings_conf_zusammenfuehren(alt, "")
assert "app_Key" not in neu
assert 'app_UpdateEnable = "1"' in neu
def test_settings_conf_aus_dem_nichts_ergibt_saubere_datei():
"""Erststart: die Datei gibt es noch gar nicht. Der abschliessende
Zeilenumbruch ist Absicht MakeMKV liest die Datei zeilenweise."""
assert settings_conf_zusammenfuehren("", "T-neuerSchluessel") == 'app_Key = "T-neuerSchluessel"\n'
def test_settings_conf_ohne_key_und_ohne_inhalt_bleibt_leer():
# Kein Inhalt, kein Key: keine Datei mit einer einsamen Leerzeile erzeugen.
assert settings_conf_zusammenfuehren("", "") == ""
+61
View File
@@ -112,3 +112,64 @@ def test_pfad_lokal_uebersetzt_fuer_windows_worker():
assert pfad_lokal("/app/media/movies/Film (2020)", mapping) == "Z:\\media\\movies\\Film (2020)"
assert pfad_lokal("/woanders/x", mapping) == "/woanders/x"
assert pfad_lokal("/app/temp/raw/abc", "") == "/app/temp/raw/abc"
# Die beiden früheren Tests zu _original_aufheben sind nach
# test_original_aufheben.py gewandert und dort erweitert. Sie prüfen jetzt das
# richtige Kriterium: Vorher taeuschten sie per gefaelschtem os.stat
# „verschiedene Dateisysteme" vor — genau die Annahme, an der der Schutz in der
# Praxis scheiterte (st_dev war identisch, os.rename trotzdem EXDEV).
def test_arbeitsverzeichnis_wahl_des_rips_schlaegt_die_einstellung():
"""Pro Rip wählbar (Commander 25.07.2026), Einstellung bleibt Standard.
Reihenfolge: Wahl dieses Rips -> Setting -> Container-Default. Der
Setting-Wert ist genau der, der bei Vollautomatik-Rips greift, weil dort
niemand gefragt wird.
"""
import tasks
einst = {"workDir": "/app/media/movies"}
assert tasks._arbeitsverzeichnis(einst, "/app/media/rippy") == "/app/media/rippy"
assert tasks._arbeitsverzeichnis(einst) == "/app/media/movies"
assert tasks._arbeitsverzeichnis({}) == tasks.RAW_DIR
# Ausbruchsversuche und Pfade außerhalb /app/media fallen durch
assert tasks._arbeitsverzeichnis({}, "/etc") == tasks.RAW_DIR
assert tasks._arbeitsverzeichnis({}, "/app/media/../etc") == tasks.RAW_DIR
# Leere Wahl fällt sauber auf die Einstellung zurück
assert tasks._arbeitsverzeichnis(einst, " ") == "/app/media/movies"
def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein():
"""Befund 25.07.2026: Elf Stellen prüften mit nacktem startswith().
/app/media-boese/x" beginnt mit „/app/media", liegt aber außerhalb."""
import tasks
assert tasks.unter_wurzel("/app/media", "/app/media") is True
assert tasks.unter_wurzel("/app/media/movies", "/app/media") is True
assert tasks.unter_wurzel("/app/media/movies/Film (2020)", "/app/media") is True
# Der Fall, den startswith() durchgelassen haette
assert tasks.unter_wurzel("/app/media-boese/x", "/app/media") is False
assert tasks.unter_wurzel("/app/mediaX", "/app/media") is False
assert tasks.unter_wurzel("/etc/passwd", "/app/media") is False
assert tasks.unter_wurzel("", "/app/media") is False
assert tasks.unter_wurzel("/app/media", "") is False
# Wurzel mit Schrägstrich am Ende darf nichts ändern
assert tasks.unter_wurzel("/app/media/movies", "/app/media/") is True
def test_zielbasis_lehnt_praefix_ausbruch_ab():
import tasks
assert tasks._zielbasis("/app/media/movies", "bluray") == "/app/media/movies"
# Ausbruch per Praefix-Namen fällt auf den Standard zurück
assert tasks._zielbasis("/app/media-boese", "bluray") != "/app/media-boese"
assert tasks._zielbasis("/etc", "bluray") != "/etc"
def test_arbeitsverzeichnis_lehnt_praefix_ausbruch_ab():
import tasks
assert tasks._arbeitsverzeichnis({}, "/app/media-boese") == tasks.RAW_DIR
assert tasks._arbeitsverzeichnis({"workDir": "/app/mediaX"}) == tasks.RAW_DIR
+124
View File
@@ -0,0 +1,124 @@
"""Tests für _original_aufheben — der Pfad, der am 25.07.2026 die Platte füllte.
Der Kern: Es wird NICHT vorhergesagt, ob umgehaengt werden kann, sondern
os.rename versucht. Die alte Fassung verglich st_dev und lag damit falsch
auf der Rippy-VM sind st_dev von /app/temp und /app/media identisch (2050),
os.rename scheitert zwischen ihnen trotzdem mit EXDEV, weil der Kernel den
Mount vergleicht und nicht das Gerät. Die Platzpruefung wurde deshalb
übersprungen und shutil.move kopierte 75 GB bei 37 GB frei.
"""
import errno
import os
import pytest
import tasks
class FakeDb:
"""Faengt nur die Log-Zeilen ab — mehr braucht _original_aufheben nicht."""
def __init__(self):
self.logs = []
def add_log(self, level, source, message):
self.logs.append((level, message))
def meldungen(self):
return " ".join(m for _, m in self.logs)
@pytest.fixture
def fake_db(monkeypatch):
ersatz = FakeDb()
monkeypatch.setattr(tasks, "db", ersatz)
return ersatz
def _lege_rohdaten_an(tmp_path, groesse=2048):
raw = tmp_path / "raw" / "job-1"
raw.mkdir(parents=True)
(raw / "title_t00.mkv").write_bytes(b"x" * groesse)
final = tmp_path / "media" / "Film (2020)"
final.mkdir(parents=True)
return str(raw), str(final)
def test_umhaengen_wenn_derselbe_mount(tmp_path, fake_db):
"""Der gute Fall: rename klappt, nichts wird kopiert, kein Platz nötig."""
raw, final = _lege_rohdaten_an(tmp_path)
tasks._original_aufheben("job-1", raw, final)
assert os.path.isdir(os.path.join(final, "original"))
assert os.path.isfile(os.path.join(final, "original", "title_t00.mkv"))
assert not os.path.exists(raw)
assert "umgehängt" in fake_db.meldungen()
def test_bei_exdev_und_zu_wenig_platz_wird_nur_gewarnt(tmp_path, fake_db, monkeypatch):
"""Der Fall, der die Platte füllte: rename geht nicht, Platz reicht nicht.
Vorher lief hier eine Vollkopie an, weil die st_dev-Prüfung gleiches
Dateisystem" meldete und die Platzpruefung deshalb ausblieb.
"""
raw, final = _lege_rohdaten_an(tmp_path)
def kein_rename(*_a, **_k):
raise OSError(errno.EXDEV, "Invalid cross-device link")
monkeypatch.setattr(tasks.os, "rename", kein_rename)
monkeypatch.setattr(tasks, "_frei_bytes", lambda _p: 1024) # weniger als die Rohdaten
tasks._original_aufheben("job-1", raw, final)
# Rohdaten bleiben unangetastet liegen, es wurde NICHTS kopiert
assert os.path.isfile(os.path.join(raw, "title_t00.mkv"))
assert not os.path.exists(os.path.join(final, "original"))
meldungen = fake_db.meldungen()
assert "NICHT aufgehoben" in meldungen
assert "anderen Mount" in meldungen
assert "Arbeitsverzeichnis" in meldungen # nennt die Abhilfe
def test_bei_exdev_und_genug_platz_wird_kopiert(tmp_path, fake_db, monkeypatch):
raw, final = _lege_rohdaten_an(tmp_path)
echtes_rename = os.rename
aufrufe = {"n": 0}
def rename_erst_exdev(*args, **kwargs):
# Nur der Versuch von _original_aufheben scheitert; shutil.move darf
# intern weiter umbenennen (es kopiert selbst und benennt Teile um).
aufrufe["n"] += 1
if aufrufe["n"] == 1:
raise OSError(errno.EXDEV, "Invalid cross-device link")
return echtes_rename(*args, **kwargs)
monkeypatch.setattr(tasks.os, "rename", rename_erst_exdev)
monkeypatch.setattr(tasks, "_frei_bytes", lambda _p: 10 * 1024**3)
tasks._original_aufheben("job-1", raw, final)
assert os.path.isfile(os.path.join(final, "original", "title_t00.mkv"))
assert "Original behalten" in fake_db.meldungen()
def test_anderer_fehler_wird_ehrlich_gemeldet_und_reisst_job_nicht_mit(
tmp_path, fake_db, monkeypatch
):
"""Ein Fehler beim Aufheben darf den Job NIE scheitern lassen — die
komprimierte Datei ist zu diesem Zeitpunkt fertig und in Ordnung."""
raw, final = _lege_rohdaten_an(tmp_path)
def zugriff_verweigert(*_a, **_k):
raise OSError(errno.EACCES, "Permission denied")
monkeypatch.setattr(tasks.os, "rename", zugriff_verweigert)
tasks._original_aufheben("job-1", raw, final) # darf nicht werfen
meldungen = fake_db.meldungen()
assert "konnte nicht aufgehoben werden" in meldungen
assert raw in meldungen # sagt, WO die Rohdatei liegt
+236 -2
View File
@@ -13,6 +13,7 @@ from ripping import (
build_makemkv_cmd,
get_progress_from_line,
get_progress_from_prgv,
parse_msg,
write_abcde_config,
)
@@ -42,10 +43,43 @@ def test_handbrake_cmd_arbeitet_auf_datei_nicht_geraet():
def test_handbrake_progress_parsing():
# Testfund 22.07.: echtes HandBrake schreibt 45.50 %" MIT Leerzeichen
# Testfund 22.07.: echtes HandBrake schreibt 45.50 % MIT Leerzeichen
assert get_progress_from_line("Encoding: task 1 of 1, 45.50 %") == 45
assert get_progress_from_line("Encoding: task 1 of 1, 100.00 %") == 100
assert get_progress_from_line("kein Fortschritt hier") == 0
# Echte Zeile mit fps-Anhang, wie sie im Binary steht
assert get_progress_from_line(
"Encoding: task 1 of 1, 12.34 % (5.67 fps, avg 4.32 fps, ETA 00h12m34s)"
) == 12
# Ein echtes 0 % ist eine ANGABE, keine Leermeldung
assert get_progress_from_line("Encoding: task 1 of 1, 0.00 %") == 0
def test_handbrake_progress_ignoriert_scan_durchlauf():
"""Befund 25.07.2026 (Akira-UHD, live gemessen): HandBrake läuft VOR dem
Encodieren einen Scan-Durchlauf, der ebenfalls Prozente ausgibt und dabei
bis 100 % steigt. Die alte Regex nahm jede Zahl vor einem Prozentzeichen
und meldete deshalb 99 %, während der Encode bei 1 % stand.
-1 heißt keine Encode-Fortschrittszeile" — dasselbe Muster wie bei
get_progress_from_prgv. Fremde Zeilen dürfen NIE als 0 % gelten.
"""
assert get_progress_from_line("Scanning title 1 of 1, preview 3, 30.00 %") == -1
assert get_progress_from_line("Scanning title 1 of 1, preview 10, 100.00 %") == -1
# Vorlauf-Phase: Prozente beziehen sich auf die Suche, nicht auf den Encode
assert get_progress_from_line(
"Encoding: task 1 of 1, Searching for start time, 42.00 %"
) == -1
assert get_progress_from_line("kein Fortschritt hier") == -1
assert get_progress_from_line("Muxing: this may take awhile...") == -1
assert get_progress_from_line("") == -1
def test_handbrake_progress_rechnet_zwei_durchlaeufe_zusammen():
"""Presets mit zwei Durchläufen zählen die Prozente je Durchlauf neu.
Ohne Verrechnung sprang die Anzeige mitten in der Datei zurück auf 0."""
assert get_progress_from_line("Encoding: task 1 of 2, 50.00 %") == 25
assert get_progress_from_line("Encoding: task 2 of 2, 0.00 %") == 50
assert get_progress_from_line("Encoding: task 2 of 2, 100.00 %") == 100
def test_prgv_parsing():
@@ -63,6 +97,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 läuft im LibreDrive-Modus, MakeMKV kennt nur den Schluessel
DIESER Pressung nicht. Ohne diese Zeile im Log raet der Commander."""
assert parse_msg(
'MSG:3303,16777216,0,"The volume key is unknown for this disc - video can\'t be decrypted","The volume key is unknown for this disc - video can\'t be decrypted"'
) == (3303, "The volume key is unknown for this disc - video can't be decrypted")
assert parse_msg('MSG:5010,0,0,"Failed to open disc","Failed to open disc"') == (
5010,
"Failed to open disc",
)
def test_parse_msg_schneidet_meldungen_mit_komma_nicht_ab():
"""DER Grund für die Regex (Stand 25.07.2026): vorher stand hier
line.split(",", 4)[3]. Das schnitt jede Meldung ab, die selbst ein Komma
enthält und MakeMKV schreibt solche laufend. Im Log stand dann nur noch
ein Satzfragment, das mehr verwirrt als hilft."""
zeile = (
'MSG:3025,0,3,"Title #1 has length of 12 seconds, which is less than '
'minimum title length of 120 seconds and was therefore skipped",'
'"Title #%1 has length of %2 seconds which is less than minimum title '
'length of %3 seconds and was therefore skipped","1","12","120"'
)
code, text = parse_msg(zeile)
assert code == 3025
assert text.endswith("and was therefore skipped")
assert "which is less than" in text
def test_parse_msg_ignoriert_fremde_zeilen():
# Alles außer MSG muss None liefern, sonst landet Fortschritts-Rauschen
# (PRGV kommt mehrmals pro Sekunde) als Log-Eintrag in der Datenbank.
assert parse_msg("PRGV:100,32768,65536") is None
assert parse_msg('DRV:0,2,999,12,"BD-RE ASUS BW-16D1HT","AKIRA","/dev/sr0"') is None
assert parse_msg("TCOUNT:5") is None
assert parse_msg("") is None
assert parse_msg("irgendwelcher Muell ohne Struktur") is None
def test_abcde_cmd_hat_genau_ein_ausgabeformat():
"""Review-Fund 22.07.: '-o' stand doppelt (Format UND Verzeichnis) — abcde
parste das Verzeichnis als Format, CD-Ripping war nie funktionsfähig."""
@@ -74,6 +162,20 @@ def test_abcde_cmd_hat_genau_ein_ausgabeformat():
assert "-N" in cmd # nicht-interaktiv, sonst hängt der Worker
def test_abcde_auswurf_folgt_der_einstellung():
"""Befund 25.07.2026: `-x` (Auswurf) stand fest verdrahtet drin. Eine
Audio-CD warf damit IMMER aus, eine DVD/Blu-ray NIE und die Einstellung
Automatischer Auswurf" regelte keines von beidem, weil sie nirgends
gelesen wurde."""
assert "-x" in build_abcde_cmd("/dev/sr0", "/tmp/c.conf", auswerfen=True)
assert "-x" not in build_abcde_cmd("/dev/sr0", "/tmp/c.conf", auswerfen=False)
# Standard bleibt „auswerfen" — so war das Verhalten bisher
assert "-x" in build_abcde_cmd("/dev/sr0", "/tmp/c.conf")
# Die Config darf durch das weggefallene -x nicht verrutschen
ohne = build_abcde_cmd("/dev/sr0", "/tmp/c.conf", auswerfen=False)
assert ohne[ohne.index("-c") + 1] == "/tmp/c.conf"
def test_abcde_config_enthaelt_zielverzeichnis():
pfad = write_abcde_config("/app/media/cd/test123")
try:
@@ -83,3 +185,135 @@ 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 für alles — eine 4K-UHD wurde
# damit auf 1080p heruntergerechnet, und beim ersten echten UHD-Rip wäre
# die 4K-Aufloesung still verlorengegangen.
from ripping import preset_fuer
einstellungen = {
"transcodePreset": "HQ 1080p30 Surround",
"transcodePresetDvd": "H.265 MKV 576p25",
"transcodePresetBluray": "H.265 MKV 1080p30",
"transcodePresetUhd": "H.265 MKV 2160p60 4K",
}
assert preset_fuer("uhd", einstellungen) == "H.265 MKV 2160p60 4K"
assert preset_fuer("bluray", einstellungen) == "H.265 MKV 1080p30"
assert preset_fuer("dvd", einstellungen) == "H.265 MKV 576p25"
def test_preset_fuer_faellt_auf_das_allgemeine_preset_zurueck():
# Bestandsinstallationen kennen die drei neuen Felder nicht. Solange der
# Nutzer sie nicht speichert, MUSS sich sein Verhalten nicht ändern.
from ripping import preset_fuer
alt = {"transcodePreset": "HQ 1080p30 Surround"}
assert preset_fuer("uhd", alt) == "HQ 1080p30 Surround"
assert preset_fuer("dvd", alt) == "HQ 1080p30 Surround"
# Leere Zeichenkette zählt als "nicht gesetzt" (leeres Select-Feld im UI)
assert preset_fuer("uhd", {"transcodePresetUhd": " ", "transcodePreset": "X"}) == "X"
def test_preset_fuer_ohne_einstellungen_nimmt_den_eingebauten_standard():
# Unbekannter Disc-Typ, leere oder fehlende Einstellungen: nie None, nie
# Absturz — sonst stirbt die Kompression an einem leeren --preset-Argument.
from ripping import DEFAULT_HB_PRESET, preset_fuer
assert preset_fuer("uhd", {}) == DEFAULT_HB_PRESET
assert preset_fuer("", None) == DEFAULT_HB_PRESET
assert preset_fuer(None, {}) == DEFAULT_HB_PRESET
assert preset_fuer("cd", {"transcodePresetUhd": "egal"}) == DEFAULT_HB_PRESET
def test_handbrake_prueft_abbruch_bei_jeder_zeile_nicht_nur_bei_fortschritt():
"""Befund 25.07.2026 (am laufenden Akira-Job beobachtet): Der Abbruch hing
am Fortschritts-Callback, und der stieg bei unveraenderter Prozentzahl
sofort aus. Bei einem 4K-Encode mit einem Prozent je halber Stunde sah
Abbrechen" minutenlang wirkungslos aus (gemessen: 3,4 min).
Der Abbruch-Kanal muss deshalb JEDE Ausgabezeile sehen auch die des
Scan-Durchlaufs, der gar keine Encode-Prozente liefert.
"""
import ripping
zeilen = [
"Scanning title 1 of 1, preview 1, 10.00 %\n",
"Scanning title 1 of 1, preview 2, 20.00 %\n",
"Encoding: task 1 of 1, 0.00 %\n",
"Encoding: task 1 of 1, 0.00 %\n",
]
gesehen = []
class FakeProcess:
def __init__(self):
self.stdout = iter(zeilen)
self.returncode = 0
self.getoetet = False
def kill(self):
self.getoetet = True
def wait(self):
return 0
prozess = FakeProcess()
def abbruch_cb():
gesehen.append(1)
if len(gesehen) == 2: # beim zweiten Mal abbrechen
raise ripping.RipAbbruch()
ergebnis = ripping._handbrake_schleife(prozess, "/x.mkv", abbruch_cb, None)
assert ergebnis["status"] == "cancelled"
assert prozess.getoetet is True
# Zwei Scan-Zeilen genügten — es musste NICHT auf eine Encode-Zeile gewartet
# werden. Genau das war der Fehler.
assert len(gesehen) == 2
def test_komprimieren_fuer_globaler_schalter_schlaegt_alles():
from ripping import komprimieren_fuer
assert komprimieren_fuer("uhd", {"transcodeEnabled": False}) is False
assert komprimieren_fuer("dvd", {"transcodeEnabled": False}) is False
# Standard ist AN - Bestandsinstallationen ändern ihr Verhalten nicht
assert komprimieren_fuer("bluray", {}) is True
assert komprimieren_fuer("bluray", None) is True
def test_komprimieren_fuer_einzelner_disc_typ_abwaehlbar():
"""Der Grund dafür (Befund 25.07.2026): 4K-HEVC brauchte auf der Rippy-VM
gemessene 28-55 Stunden, weil die CPU kein AVX2 hat. Vorher gab es nur
einen globalen Schalter - wer 4K verlustfrei behalten und DVDs trotzdem
schrumpfen wollte, hatte keine Möglichkeit."""
from ripping import PRESET_KEINE, komprimieren_fuer
nur_uhd_aus = {"transcodePresetUhd": PRESET_KEINE,
"transcodePresetDvd": "H.265 MKV 576p25"}
assert komprimieren_fuer("uhd", nur_uhd_aus) is False
assert komprimieren_fuer("dvd", nur_uhd_aus) is True
assert komprimieren_fuer("bluray", nur_uhd_aus) is True
def test_preset_fuer_gibt_den_reservewert_nie_als_preset_zurueck():
"""Sonst bekaeme HandBrake `--preset keine` und würde scheitern. Wer über
"Neu komprimieren" ausdruecklich doch komprimieren will, soll ein
brauchbares Preset bekommen statt eines Fehlers."""
from ripping import DEFAULT_HB_PRESET, PRESET_KEINE, preset_fuer
assert preset_fuer("uhd", {"transcodePresetUhd": PRESET_KEINE,
"transcodePreset": "HQ 1080p30 Surround"}) == "HQ 1080p30 Surround"
# Auch ohne allgemeines Preset kommt ein gültiger Name heraus
assert preset_fuer("uhd", {"transcodePresetUhd": PRESET_KEINE}) == DEFAULT_HB_PRESET
def test_reservewert_kollidiert_mit_keinem_echten_preset():
"""Gegengeprueft gegen alle 90 Presets aus `HandBrakeCLI --preset-list`
im Worker-Image (25.07.2026) - keines heißt so."""
from ripping import PRESET_KEINE
assert PRESET_KEINE == "keine"
assert " " not in PRESET_KEINE # echte Preset-Namen haben durchweg Leerzeichen
+190
View File
@@ -0,0 +1,190 @@
"""Tests der Zombie-Erkennung — ohne Postgres und ohne Redis.
Der Schwerpunkt liegt bewusst auf dem, was WEHTUT: ein laufender Job darf
niemals als Leiche gelten. Genau das wäre am 25.07.2026 passiert, wenn die
Erkennung nach dem Alter des Jobs geurteilt hätte der Akira-Job war seit
neun Stunden offen und lief trotzdem.
"""
import zombies
# --- reine Funktionen -------------------------------------------------------
def test_belegte_job_ids_findet_id_an_beliebiger_stelle():
"""job_id steht bei rip_disc an zweiter, bei transcode_files an erster
Stelle die Erkennung darf sich auf keine Position verlassen."""
aktiv = {
"celery@node1": [
{"name": "worker.tasks.transcode_files", "args": ["job-eins", "/raw", "/final"]},
{"name": "worker.tasks.rip_disc", "args": ["/dev/sr0", "job-zwei"]},
]
}
belegt = zombies.belegte_job_ids([aktiv], ["job-eins", "job-zwei", "job-drei"])
assert belegt == {"job-eins", "job-zwei"}
def test_belegte_job_ids_versteht_args_als_text():
"""Celery liefert args je nach Version als Liste ODER als Text-Repräsentation."""
aktiv = {"celery@node1": [{"name": "x", "args": "('job-eins', '/raw', '/final')"}]}
assert zombies.belegte_job_ids([aktiv], ["job-eins", "job-zwei"]) == {"job-eins"}
def test_belegte_job_ids_ohne_auskunft_ist_leer():
assert zombies.belegte_job_ids(None, ["a"]) == set()
assert zombies.belegte_job_ids([None, None, None], ["a"]) == set()
assert zombies.belegte_job_ids([{}, None], ["a"]) == set()
def test_antwortende_knoten_sammelt_ueber_alle_abfragen():
aktiv = {"celery@a": []}
vorgemerkt = {"celery@b": []}
assert zombies.antwortende_knoten([aktiv, None, vorgemerkt]) == {"celery@a", "celery@b"}
assert zombies.antwortende_knoten([None, None]) == set()
def test_auskunft_nur_vollstaendig_wenn_alle_bekannten_antworten():
assert zombies.auskunft_vollstaendig(1, 1) is True
assert zombies.auskunft_vollstaendig(2, 1) is True
# Ein bekannter Worker schweigt → NICHT urteilen
assert zombies.auskunft_vollstaendig(1, 2) is False
# Niemand antwortet → wir wissen nichts
assert zombies.auskunft_vollstaendig(0, 0) is False
def test_finde_zombies_verschont_belegte_jobs():
offene = [{"id": "a", "status": "transcoding"}, {"id": "b", "status": "ripping"}]
assert zombies.finde_zombies(offene, {"a"}) == [{"id": "b", "status": "ripping"}]
assert zombies.finde_zombies(offene, {"a", "b"}) == []
def test_fehlertext_nennt_zustand_und_den_weg_zurueck():
text = zombies.fehlertext({"status": "transcoding"})
assert "transcoding" in text
assert "Neu komprimieren" in text
assert "NICHT gelöscht" in text
# --- Attrappen für den Gesamtdurchlauf --------------------------------------
class FakeInspektor:
def __init__(self, aktiv=None, vorgemerkt=None, geplant=None):
self._aktiv, self._vorgemerkt, self._geplant = aktiv, vorgemerkt, geplant
def active(self):
return self._aktiv
def reserved(self):
return self._vorgemerkt
def scheduled(self):
return self._geplant
class FakeCelery:
def __init__(self, inspektor):
self.control = self
self._inspektor = inspektor
def inspect(self, timeout=None):
return self._inspektor
class FakeDb:
def __init__(self, offene, online=1):
self._offene, self._online = offene, online
self.aktualisierungen = []
self.logs = []
def list_jobs_mit_status(self, stati):
return [j for j in self._offene if j["status"] in stati]
def zaehle_online_worker(self, sekunden=120):
return self._online
def update_job(self, job_id, **felder):
self.aktualisierungen.append((job_id, felder))
def add_log(self, level, source, message):
self.logs.append((level, message))
def utcnow(self):
return "jetzt"
# --- Gesamtdurchlauf --------------------------------------------------------
def test_laufender_job_wird_nicht_angetastet():
"""Der Fall, der wehtut: Job läuft seit Stunden und IST aktiv."""
db = FakeDb([{"id": "lebt", "status": "transcoding", "title": "Akira"}])
aktiv = {"celery@a": [{"name": "worker.tasks.transcode_files", "args": ["lebt"]}]}
celery = FakeCelery(FakeInspektor(aktiv=aktiv, vorgemerkt={}, geplant={}))
bericht = zombies.raeume_zombies_auf(celery, db)
assert bericht["aufgeraeumt"] == []
assert db.aktualisierungen == []
def test_echte_leiche_wird_auf_failed_gesetzt():
db = FakeDb([{"id": "leiche", "status": "transcoding", "title": "Akira"}])
# Der Knoten antwortet — er arbeitet nur an nichts.
celery = FakeCelery(FakeInspektor(aktiv={"celery@a": []}, vorgemerkt={}, geplant={}))
bericht = zombies.raeume_zombies_auf(celery, db)
assert bericht["aufgeraeumt"] == ["leiche"]
job_id, felder = db.aktualisierungen[0]
assert job_id == "leiche"
assert felder["status"] == "failed"
assert "Neu komprimieren" in felder["error"]
assert felder["finished_at"] == "jetzt"
def test_schweigender_worker_verhindert_jedes_urteil():
"""Zwei Worker gelten als online, nur einer antwortet — der andere könnte
genau diesen Job bearbeiten. Also: Finger weg."""
db = FakeDb([{"id": "unklar", "status": "transcoding", "title": "X"}], online=2)
celery = FakeCelery(FakeInspektor(aktiv={"celery@a": []}, vorgemerkt={}, geplant={}))
bericht = zombies.raeume_zombies_auf(celery, db)
assert bericht["aufgeraeumt"] == []
assert db.aktualisierungen == []
assert "unvollständige Auskunft" in bericht["uebersprungen"]
def test_gar_keine_antwort_fuehrt_zu_keinem_urteil():
db = FakeDb([{"id": "unklar", "status": "ripping", "title": "X"}], online=0)
celery = FakeCelery(FakeInspektor(aktiv=None, vorgemerkt=None, geplant=None))
bericht = zombies.raeume_zombies_auf(celery, db)
assert bericht["aufgeraeumt"] == []
assert db.aktualisierungen == []
def test_fertige_jobs_werden_gar_nicht_betrachtet():
db = FakeDb([
{"id": "fertig", "status": "completed", "title": "A"},
{"id": "kaputt", "status": "failed", "title": "B"},
{"id": "wartet", "status": "pending", "title": "C"},
])
celery = FakeCelery(FakeInspektor(aktiv={"celery@a": []}, vorgemerkt={}, geplant={}))
bericht = zombies.raeume_zombies_auf(celery, db)
# pending bleibt bewusst unberührt: die Aufgabe kann noch in der
# Warteschlange liegen und wird von selbst abgeholt.
assert bericht["geprueft"] == 0
assert db.aktualisierungen == []
def test_fehler_reisst_den_worker_start_nicht_mit():
class KaputteDb(FakeDb):
def list_jobs_mit_status(self, stati):
raise RuntimeError("Postgres weg")
db = KaputteDb([])
bericht = zombies.raeume_zombies_auf(FakeCelery(FakeInspektor()), db)
assert "Postgres weg" in bericht["uebersprungen"]
+28 -1
View File
@@ -25,7 +25,34 @@ except ImportError:
BASIS = os.path.dirname(os.path.abspath(__file__))
RIPPY_HOST = os.getenv("RIPPY_TRAY_HOST", "")
WORKER_NAME = os.getenv("WORKER_NAME", "windows-worker")
LOG_PFAD = os.path.join(BASIS, "worker.log")
def _log_pfad() -> str:
"""Wohin das Worker-Log geschrieben wird.
NICHT ins Programmverzeichnis (Befund 25.07.2026, Commander-Wunsch): Der
Worker wird jetzt standardmäßig nach `C:\\Program Files\\Rippy Worker`
installiert, und dort darf ein normaler Benutzer nicht schreiben. Ein Log
neben dem Programm hätte den Worker beim Starten scheitern lassen genau
die Art Fehler, die man erst merkt, wenn nichts passiert.
Deshalb ins Benutzerprofil (`%LOCALAPPDATA%\\Rippy Worker`), wie es sich für
veränderliche Daten gehört. Nur falls das nicht klappt, bleibt das
Programmverzeichnis der Rückfall bei einer Installation ins Benutzerprofil
(frei wählbar) ist es ohnehin beschreibbar.
"""
basis_daten = os.getenv("LOCALAPPDATA") or os.getenv("APPDATA") or ""
if basis_daten:
ordner = os.path.join(basis_daten, "Rippy Worker")
try:
os.makedirs(ordner, exist_ok=True)
return os.path.join(ordner, "worker.log")
except OSError:
pass
return os.path.join(BASIS, "worker.log")
LOG_PFAD = _log_pfad()
prozess = None
+149
View File
@@ -0,0 +1,149 @@
"""Erkennt Job-Leichen: Jobs, die behaupten zu laufen, an denen aber niemand arbeitet.
Befund 25.07.2026 (Akira-UHD): Nach einem Absturz stand ein Job auf
`transcoding` bei 96 %, obwohl weder ein Prozess lief noch etwas in den
Celery-Queues stand. Folge für den Nutzer: kein Fortschritt, kein Download
und der Knopf Neu komprimieren" fehlte, weil `_kann_neu_komprimieren`
(api/main.py) `status == "failed"` verlangt. Der Job war damit unerreichbar,
obwohl die Rohdateien vollständig dalagen.
## Die Leitregel: ohne vollständige Auskunft wird NICHTS angetastet
Ein falsch als Leiche markierter Job ist teurer als eine stehengebliebene
Leiche. Deshalb drei Sicherungen:
1. **Nur beim Worker-Start.** Da ist die Aussage eindeutig: als ich hochkam,
lief nichts". Ein periodischer Lauf könnte einen Job erwischen, der legitim
in der Warteschlange wartet, weil alle Arbeitsplätze belegt sind der ist
für `active()`/`reserved()` unsichtbar. Bewusst nicht gebaut.
2. **Gnadenfrist.** Nach einem Neustart stellt Celery unbestätigte Aufgaben
erneut zu. Erst abwarten, dann urteilen.
3. **Vollzähligkeit.** Es wird nur geurteilt, wenn mindestens so viele
Celery-Knoten antworten, wie laut Herzschlag online sind. Antwortet ein
beschäftigter Remote-Worker nicht rechtzeitig, wäre sein laufender Job
sonst eine falsche Leiche.
Die Kernfunktionen sind bewusst rein (kein Postgres, kein Redis), damit die
Ampel sie ohne Infrastruktur prüfen kann.
"""
# Zustände, die behaupten: hier arbeitet gerade jemand.
ARBEITS_STATI = ("ripping", "transcoding", "canceling")
# Wartezeit nach dem Worker-Start, bevor geurteilt wird. Deckt die
# Wiederzustellung unbestätigter Aufgaben durch Celery ab.
GNADENFRIST_SEKUNDEN = 120
# Wie lange auf Antworten der Celery-Knoten gewartet wird. Großzügig, weil ein
# Worker mitten in einem 4K-Encode träge antwortet.
INSPEKT_TIMEOUT_SEKUNDEN = 10
def belegte_job_ids(inspektionen, kandidaten) -> set:
"""Welche der `kandidaten`-IDs kommen in irgendeiner Celery-Aufgabe vor?
Bewusst als Textsuche über die ganze Inspektions-Antwort: `job_id` steht
bei `rip_disc` an ZWEITER, bei `transcode_files` an ERSTER Stelle, und
Celery liefert `args` je nach Version als Liste oder als Text. Eine
Positions-Auswertung wäre an beidem zerbrechlich. Eine Job-ID ist eine
36-stellige UUID Fehltreffer sind praktisch ausgeschlossen.
`inspektionen` ist die Liste der Antworten (active/reserved/scheduled);
`None`-Einträge werden übersprungen.
"""
vorhandene = [i for i in (inspektionen or []) if i]
if not vorhandene:
return set()
text = repr(vorhandene)
return {jid for jid in kandidaten if jid and jid in text}
def antwortende_knoten(inspektionen) -> set:
"""Namen aller Celery-Knoten, die auf die Inspektion geantwortet haben."""
knoten = set()
for antwort in (inspektionen or []):
if antwort:
knoten.update(antwort.keys())
return knoten
def auskunft_vollstaendig(anzahl_antworten: int, anzahl_bekannt: int) -> bool:
"""Darf aus dieser Auskunft überhaupt ein Urteil folgen?
Nein, wenn niemand geantwortet hat (dann wissen wir nichts), und nein, wenn
weniger Knoten antworten als laut Herzschlag online sind (dann fehlt uns
genau der Knoten, der den Job vielleicht gerade bearbeitet).
"""
if anzahl_antworten < 1:
return False
return anzahl_antworten >= anzahl_bekannt
def finde_zombies(offene_jobs, belegte) -> list:
"""Jobs aus `offene_jobs`, zu denen keine Celery-Aufgabe existiert."""
return [job for job in offene_jobs if job.get("id") not in belegte]
def fehlertext(job) -> str:
"""Ehrlicher Klartext für die Job-Karte — was war, und was jetzt hilft."""
zuletzt = job.get("status") or "unbekannt"
return (
f"Abgebrochen: Der Vorgang lief nicht mehr (zuletzt gemeldet: {zuletzt}). "
"Beim Start des Workers war dazu weder ein Prozess noch eine Aufgabe in "
"der Warteschlange zu finden — meistens ein Absturz oder ein Neustart "
"mitten im Lauf. Die Rohdateien wurden NICHT gelöscht: mit "
"'Neu komprimieren' läuft die Kompression erneut, ohne die Disc noch "
"einmal zu rippen."
)
def raeume_zombies_auf(celery_app, db) -> dict:
"""Sucht Leichen und setzt sie ehrlich auf `failed`. Wirft nie.
Rückgabe (auch für den Log): was geprüft und was getan wurde.
"""
bericht = {"geprueft": 0, "aufgeraeumt": [], "uebersprungen": ""}
try:
offene = db.list_jobs_mit_status(ARBEITS_STATI)
bericht["geprueft"] = len(offene)
if not offene:
return bericht
inspektor = celery_app.control.inspect(timeout=INSPEKT_TIMEOUT_SEKUNDEN)
inspektionen = [inspektor.active(), inspektor.reserved(), inspektor.scheduled()]
knoten = antwortende_knoten(inspektionen)
bekannt = db.zaehle_online_worker()
if not auskunft_vollstaendig(len(knoten), bekannt):
bericht["uebersprungen"] = (
f"unvollständige Auskunft ({len(knoten)} von {bekannt} Worker "
"haben geantwortet) — es wird nichts als Leiche gewertet"
)
db.add_log(
"info", "worker",
f"Zombie-Erkennung übersprungen: {bericht['uebersprungen']}",
)
return bericht
belegte = belegte_job_ids(inspektionen, [j.get("id") for j in offene])
for job in finde_zombies(offene, belegte):
db.update_job(
job["id"],
status="failed",
error=fehlertext(job),
finished_at=db.utcnow(),
)
db.add_log(
"warning", "worker",
f"Job {job['id']} ({job.get('title') or 'ohne Titel'}) stand auf "
f"'{job.get('status')}', es arbeitet aber niemand daran — "
"ehrlich auf 'failed' gesetzt. Rohdateien bleiben liegen.",
)
bericht["aufgeraeumt"].append(job["id"])
except Exception as e: # Erkennung darf den Worker-Start NIEMALS verhindern
bericht["uebersprungen"] = f"Fehler: {e}"
try:
db.add_log("warning", "worker", f"Zombie-Erkennung fehlgeschlagen: {e}")
except Exception:
pass
return bericht
Executable
+398
View File
@@ -0,0 +1,398 @@
#!/usr/bin/env bash
#
# Rippy einrichten — ein Befehl, keine Handarbeit.
#
# ## Aufbau: erst ALLES prüfen, dann erst ändern
#
# Das Skript läuft in zwei klar getrennten Phasen:
#
# Phase 1 PRÜFEN — sieht sich alles an und ändert NICHTS.
# (Weiche) — harte Mängel → Abbruch mit Liste, nichts angefasst.
# Phase 2 EINRICHTEN — Verzeichnisse, Mount, .env, bauen, starten.
#
# Warum das wichtig ist (Commander-Einwand 25.07.2026): Die erste Fassung
# prüfte und änderte abwechselnd — Verzeichnisse wurden angelegt, bevor
# überhaupt klar war, ob der Rest durchläuft. Scheiterte etwas in der Mitte,
# blieb ein halb eingerichteter Rechner zurück. Und der Prüf-Modus war als
# `if`-Zweig durch fünf Schritte gefädelt; so etwas läuft mit der Zeit
# zwangsläufig aus dem Ruder, weil jede neue Aktion daran denken müsste.
#
# Jetzt gibt es GENAU EINEN Prüf-Pfad, den beide Modi benutzen. `--nur-pruefen`
# heißt schlicht: nach Phase 1 aufhören. Damit kann die Prüfung nicht mehr etwas
# anderes behaupten als die Installation tut.
#
# Es ist absichtlich WIEDERHOLBAR: mehrfach ausführen ändert nichts kaputt, und
# es ändert NIE einen Wert, der schon in der .env steht.
#
# Aufruf: sudo ./install.sh
# ./install.sh --nur-pruefen (nichts anfassen, nur berichten)
set -u
NUR_PRUEFEN=0
[ "${1:-}" = "--nur-pruefen" ] && NUR_PRUEFEN=1
MEDIA_DIR="${RIPPY_MEDIA_DIR:-/srv/rippy/media}"
MAKEMKV_DIR="${RIPPY_MAKEMKV_DIR:-/srv/rippy/makemkv}"
rot() { printf '\033[31m%s\033[0m\n' "$*"; }
gruen() { printf '\033[32m%s\033[0m\n' "$*"; }
gelb() { printf '\033[33m%s\033[0m\n' "$*"; }
titel() { printf '\n\033[1m%s\033[0m\n' "$*"; }
# Befunde der Prüfphase. PROBLEME verhindern die Installation, HINWEISE nicht.
PROBLEME=""
HINWEISE=""
problem() { PROBLEME="${PROBLEME}$1"$'\n'; rot " X $1"; }
hinweis() { HINWEISE="${HINWEISE}$1"$'\n'; gelb " ! $1"; }
ok() { gruen " OK $1"; }
cd "$(dirname "$0")" || exit 1
echo "======================================================================"
echo " Rippy — Einrichtung"
echo "======================================================================"
echo " Phase 1 von 2: prüfen. Es wird noch NICHTS geändert."
# ===========================================================================
# PHASE 1 — PRÜFEN. Ab hier bis zur Weiche wird nichts verändert.
# ===========================================================================
# --- 1. Betriebssystem und Docker ------------------------------------------
titel "Prüfung 1/5 Betriebssystem und Docker"
if [ "$(uname -s)" != "Linux" ]; then
problem "Kein Linux. Rippy braucht einen Linux-Host mit Docker."
rot " (Docker Desktop unter Windows/macOS kann keine optischen Laufwerke"
rot " durchreichen — deshalb geht es dort grundsätzlich nicht.)"
else
ok "Linux erkannt ($(uname -r))"
fi
if ! command -v docker >/dev/null 2>&1; then
problem "Docker ist nicht installiert."
rot " Installation: curl -fsSL https://get.docker.com | sudo sh"
else
ok "Docker gefunden ($(docker --version 2>/dev/null | head -1))"
fi
COMPOSE=""
if docker compose version >/dev/null 2>&1; then
COMPOSE="docker compose"
ok "Compose-Plugin gefunden"
elif command -v docker-compose >/dev/null 2>&1; then
COMPOSE="docker-compose"
hinweis "Nur das alte docker-compose gefunden — funktioniert, ist aber veraltet."
else
problem "Docker Compose fehlt."
rot " Installation (Debian/Ubuntu): sudo apt install docker-compose-plugin"
fi
if [ "$(id -u)" = "0" ]; then
ok "Als root gestartet"
elif [ "$NUR_PRUEFEN" = 1 ]; then
# Die Prüfung ändert nichts und darf deshalb NICHT an root scheitern —
# sonst kann man vor der Installation nicht nachsehen, ob alles passt.
ok "Ohne root — für die Prüfung völlig in Ordnung"
else
problem "Bitte mit sudo starten: sudo ./install.sh"
rot " (Verzeichnisse unter /srv und die Mount-Propagation brauchen root.)"
rot " Nur nachsehen, ohne etwas zu ändern: ./install.sh --nur-pruefen"
fi
# --- 2. Optisches Laufwerk -------------------------------------------------
titel "Prüfung 2/5 Optisches Laufwerk"
# MakeMKV spricht Laufwerke über die SCSI-Generic-Schicht an und braucht
# deshalb ZWEI Knoten: /dev/srN und den passenden /dev/sgM. Welche sg-Nummer
# dazugehört, ist je Host anders — deshalb wird sie hier über die SCSI-Adresse
# ermittelt statt geraten. Beide Knoten zeigen in /sys auf dasselbe
# Geräteverzeichnis (z. B. 3:0:0:0), das ist der Abgleich.
SR=""
SG=""
LAUFWERK=""
for srpfad in /sys/block/sr*; do
[ -e "$srpfad" ] || continue
srname=$(basename "$srpfad")
adresse=$(basename "$(readlink -f "$srpfad/device" 2>/dev/null)" 2>/dev/null)
[ -n "$adresse" ] || continue
for sgpfad in /sys/class/scsi_generic/sg*; do
[ -e "$sgpfad" ] || continue
if [ "$(basename "$(readlink -f "$sgpfad/device" 2>/dev/null)" 2>/dev/null)" = "$adresse" ]; then
SR="/dev/$srname"
SG="/dev/$(basename "$sgpfad")"
LAUFWERK="$(cat "$srpfad/device/vendor" 2>/dev/null) $(cat "$srpfad/device/model" 2>/dev/null)"
break 2
fi
done
done
if [ -n "$SR" ]; then
ok "Laufwerk gefunden: $(echo "$LAUFWERK" | tr -s ' ')"
gruen " $SR + $SG (SCSI-Adresse abgeglichen, nicht geraten)"
else
hinweis "Kein optisches Laufwerk gefunden — Rippy kann dann nur komprimieren."
gelb " Die Installation läuft trotzdem durch; diese Maschine dient dann"
gelb " als reine KOMPRIMIER-Maschine für andere Rippy-Instanzen."
gelb " Zum Rippen fehlt das Laufwerk. Häufigste Ursachen:"
gelb " - In einer VM: Laufwerk per USB-Passthrough durchreichen"
gelb " (Proxmox: qm set <vmid> -usb0 host=<hersteller>:<produkt>,usb3=1)"
gelb " NICHT als emuliertes CD-ROM (media=cdrom) — das kann keine"
gelb " SCSI-Kommandos und MakeMKV sieht es nie."
gelb " - Auf echter Hardware: 'ls /dev/sr*' prüfen, Kabel/Strom."
fi
# --- 3. Verzeichnisse und Platz -------------------------------------------
titel "Prüfung 3/5 Verzeichnisse und Plattenplatz"
for d in "$MEDIA_DIR" "$MAKEMKV_DIR"; do
if [ -d "$d" ]; then
ok "$d liegt schon da"
else
# Anlegen können wir nur mit root — und ob der Elternpfad beschreibbar ist,
# entscheidet, ob es später klappt. Beides hier schon sagen.
eltern="$(dirname "$d")"
while [ -n "$eltern" ] && [ ! -d "$eltern" ]; do eltern="$(dirname "$eltern")"; done
if [ "$(id -u)" = "0" ] || [ -w "$eltern" ]; then
ok "$d fehlt noch — wird in Phase 2 angelegt"
else
problem "$d fehlt und $eltern ist nicht beschreibbar."
fi
fi
done
FREI_GB=$(df -BG --output=avail "$(dirname "$MEDIA_DIR")" 2>/dev/null | tail -1 | tr -dc '0-9')
if [ -n "${FREI_GB:-}" ]; then
if [ "$FREI_GB" -lt 60 ]; then
hinweis "Nur ${FREI_GB} GB frei. Eine Blu-ray braucht roh rund 40 GB, eine 4K-UHD bis 100 GB."
gelb " Rippy läuft trotzdem — lege die Ablage später besser auf eine"
gelb " NAS-Freigabe (Einstellungen → Speicherziele)."
else
ok "${FREI_GB} GB frei"
fi
fi
# --- 4. Mount-Propagation --------------------------------------------------
titel "Prüfung 4/5 Mount-Propagation (für NAS-Freigaben aus dem UI)"
# Rippy hängt NAS-Freigaben aus dem UI ein. Damit die auch in den anderen
# Containern sichtbar werden, muss der Ablage-Pfad ein 'shared' Mount sein.
# Auf den meisten systemd-Hosts ist / schon rshared und es ist nichts zu tun.
propagation_ok() {
findmnt -no PROPAGATION --target "$MEDIA_DIR" 2>/dev/null | grep -q shared
}
PROPAGATION_NOETIG=0
if propagation_ok; then
ok "$MEDIA_DIR ist bereits 'shared' — nichts zu tun"
elif [ ! -d "$MEDIA_DIR" ]; then
ok "wird nach dem Anlegen des Verzeichnisses geprüft und bei Bedarf gesetzt"
PROPAGATION_NOETIG=1
else
ok "Propagation fehlt — wird in Phase 2 eingerichtet"
PROPAGATION_NOETIG=1
fi
# --- 5. Konfiguration ------------------------------------------------------
titel "Prüfung 5/5 Konfiguration (.env)"
if [ ! -f .env ]; then
if [ -f .env.example ]; then
ok ".env fehlt — wird in Phase 2 aus .env.example erzeugt"
else
problem ".env.example fehlt. Ist das wirklich das Rippy-Repo?"
fi
else
ok ".env liegt schon da — bestehende Werte bleiben unangetastet"
fi
# Was in Phase 2 in die .env geschrieben würde — hier nur ANSAGEN.
wirksamer_wert() {
schluessel="$1"; standard="$2"
wert=$(grep -E "^${schluessel}=" .env 2>/dev/null | tail -1 | cut -d= -f2-)
[ -n "${wert:-}" ] && printf '%s' "$wert" || printf '%s' "$standard"
}
for paar in "OPTICAL_SR:$SR" "OPTICAL_SG:$SG" "MAKEMKV_DATA_HOST:$MAKEMKV_DIR"; do
schluessel="${paar%%:*}"; erkannt="${paar#*:}"
if grep -qE "^${schluessel}=.+" .env 2>/dev/null; then
echo " $schluessel steht schon in der .env — bleibt unverändert"
elif [ -n "$erkannt" ]; then
echo " $schluessel wird auf $erkannt gesetzt"
fi
done
# Gegenprobe: Zeigen die WIRKSAMEN Gerätepfade auf etwas, das es hier gibt?
# Fängt den Fall "'.env' von einem anderen Rechner kopiert" und Tippfehler ab —
# sonst startet Docker mit einem Pfad, den es nicht gibt, und die Meldung ist
# kryptisch.
for paar in "OPTICAL_SR:/dev/sr0" "OPTICAL_SG:/dev/sg1"; do
schluessel="${paar%%:*}"; standard="${paar##*:}"
# Nur prüfen, was auch wirklich gelten wird
if grep -qE "^${schluessel}=.+" .env 2>/dev/null; then
wirksam=$(wirksamer_wert "$schluessel" "$standard")
elif [ -n "$SR" ]; then
continue # wird gleich korrekt gesetzt
else
wirksam="$standard"
fi
if [ ! -e "$wirksam" ]; then
hinweis "$schluessel zeigt auf $wirksam — dieses Gerät gibt es hier NICHT."
if [ -n "$SR" ]; then
gelb " Gefunden wurde stattdessen: $SR + $SG"
gelb " Trage das in der .env ein (oder lösche die Zeile $schluessel=)."
fi
fi
done
echo " API-Keys (TMDB/OMDb) brauchst du hier NICHT einzutragen —"
echo " der Einrichtungs-Assistent im Browser fragt sie ab und prüft sie."
# ===========================================================================
# WEICHE — hier wird entschieden. Bis hierher wurde NICHTS verändert.
# ===========================================================================
titel "Ergebnis der Prüfung"
ANZ_PROBLEME=$(printf '%s' "$PROBLEME" | grep -c . || true)
ANZ_HINWEISE=$(printf '%s' "$HINWEISE" | grep -c . || true)
if [ "$ANZ_PROBLEME" -gt 0 ]; then
rot " $ANZ_PROBLEME Punkt(e) verhindern die Installation:"
printf '%s' "$PROBLEME" | grep . | while IFS= read -r z; do rot " - $z"; done
rot ""
rot " Es wurde NICHTS verändert. Bitte das Obige nachholen und erneut starten."
exit 1
fi
if [ "$ANZ_HINWEISE" -gt 0 ]; then
gelb " Alles Wichtige ist da. $ANZ_HINWEISE Hinweis(e), die dich nicht aufhalten:"
printf '%s' "$HINWEISE" | grep . | while IFS= read -r z; do gelb " - $z"; done
else
gruen " Alles in Ordnung, keine Einschränkungen."
fi
if [ "$NUR_PRUEFEN" = 1 ]; then
echo ""
gruen "Prüfung fertig — es wurde nichts verändert."
echo "Zum Installieren: sudo ./install.sh"
exit 0
fi
# ===========================================================================
# PHASE 2 — EINRICHTEN. Erst ab hier wird etwas verändert.
# ===========================================================================
echo ""
echo "======================================================================"
echo " Phase 2 von 2: einrichten"
echo "======================================================================"
titel "Schritt 1/4 Verzeichnisse anlegen"
for d in "$MEDIA_DIR" "$MAKEMKV_DIR"; do
if [ -d "$d" ]; then
ok "$d war schon da"
else
mkdir -p "$d" && ok "$d angelegt"
fi
done
echo " Ablage: $MEDIA_DIR (hier landen die fertigen Filme)"
echo " MakeMKV-Daten: $MAKEMKV_DIR (Schlüsselspeicher, rebuild-fest)"
titel "Schritt 2/4 Mount-Propagation einrichten"
if propagation_ok; then
ok "war schon 'shared' — nichts zu tun"
elif [ "$PROPAGATION_NOETIG" = 1 ]; then
mount --bind "$MEDIA_DIR" "$MEDIA_DIR" 2>/dev/null
mount --make-rshared "$MEDIA_DIR" 2>/dev/null
if propagation_ok; then
ok "Propagation eingerichtet"
# Neustart-fest machen, sonst ist es nach dem nächsten Reboot wieder weg
# und das Einhängen aus dem UI scheitert stillschweigend.
UNIT="/etc/systemd/system/$(systemd-escape -p --suffix=mount "$MEDIA_DIR" 2>/dev/null)"
if [ -n "${UNIT:-}" ] && [ ! -e "$UNIT" ] && command -v systemctl >/dev/null 2>&1; then
cat > "$UNIT" <<UNITEOF
[Unit]
Description=Rippy-Ablage als shared mount (für NAS-Freigaben aus dem UI)
After=local-fs.target
[Mount]
What=$MEDIA_DIR
Where=$MEDIA_DIR
Type=none
Options=bind,rshared
[Install]
WantedBy=multi-user.target
UNITEOF
systemctl daemon-reload 2>/dev/null
if systemctl enable "$(basename "$UNIT")" >/dev/null 2>&1; then
ok "neustart-fest gemacht ($(basename "$UNIT"))"
else
gelb " ! Konnte nicht neustart-fest gemacht werden — nach einem Reboot"
gelb " dieses Skript einfach erneut ausführen."
fi
fi
else
gelb " ! Propagation ließ sich nicht einrichten."
gelb " Rippy läuft trotzdem — nur das Einhängen von NAS-Freigaben AUS DEM UI"
gelb " funktioniert dann nicht. Freigaben stattdessen auf dem Host einhängen,"
gelb " z. B. unter $MEDIA_DIR/nas. Betrifft nur klassisches Docker mit"
gelb " SYS_ADMIN — nicht rootless/Podman."
fi
fi
titel "Schritt 3/4 Konfiguration schreiben"
if [ ! -f .env ]; then
cp .env.example .env && ok ".env aus .env.example erzeugt"
fi
# Setzt einen Wert NUR, wenn er noch nicht (unkommentiert) gesetzt ist.
setze_wenn_leer() {
schluessel="$1"; wert="$2"
[ -f .env ] || return
if grep -qE "^${schluessel}=.+" .env; then
echo " $schluessel steht schon in der .env — unverändert gelassen"
else
sed -i "/^#*${schluessel}=/d" .env
printf '%s=%s\n' "$schluessel" "$wert" >> .env
ok "$schluessel=$wert eingetragen"
fi
}
if [ -n "$SR" ]; then
setze_wenn_leer OPTICAL_SR "$SR"
setze_wenn_leer OPTICAL_SG "$SG"
fi
setze_wenn_leer MAKEMKV_DATA_HOST "$MAKEMKV_DIR"
titel "Schritt 4/4 Bauen und starten"
echo " Der erste Bau dauert je nach Internet 3-10 Minuten. Nur beim ersten Mal."
echo ""
if ! $COMPOSE up -d --build; then
rot ""
rot "Der Start ist gescheitert. Die häufigsten Ursachen:"
rot " - Kein Platz auf der Platte: df -h /"
rot " - Port 80 schon belegt: ss -tlnp | grep ':80 '"
rot " - Download von makemkv.com gedrosselt (Cloudflare): Tarballs von"
rot " makemkv.com/download nach docker/worker/vendor/ legen, dann erneut."
rot "Die volle Meldung steht oben. Logs: $COMPOSE logs --tail=50"
exit 1
fi
IP=$(ip -4 route get 1.1.1.1 2>/dev/null | grep -oE 'src [0-9.]+' | awk '{print $2}')
[ -n "${IP:-}" ] || IP="<ip-dieses-rechners>"
echo ""
echo "======================================================================"
gruen " Fertig. Rippy läuft."
echo "======================================================================"
echo ""
echo " Jetzt im Browser öffnen: http://$IP"
echo ""
echo " Dort startet der Einrichtungs-Assistent. Er zeigt dir, ob Laufwerk,"
echo " Worker und Platz in Ordnung sind, und stellt die Kompression passend"
echo " zu deiner CPU ein. Mehr ist nicht zu tun."
echo ""
echo " Status ansehen: $COMPOSE ps"
echo " Logs ansehen: $COMPOSE logs -f"
echo " Aktualisieren: git pull && sudo ./install.sh"
echo ""