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>
This commit is contained in:
Hitonabi
2026-07-25 22:37:23 +02:00
parent 65edf8369e
commit 35cfcbcb07
24 changed files with 702 additions and 640 deletions
+89 -89
View File
@@ -1,89 +1,89 @@
# AGENTS.md — Arbeits-Konventionen für Rippy # AGENTS.md — Arbeits-Konventionen für Rippy
## Grundregeln ## Grundregeln
**1. Plan vor Code.** Vor jeder Etappe: Klare Akzeptanzkriterien schreiben, dann bauen. **1. Plan vor Code.** Vor jeder Etappe: Klare Akzeptanzkriterien schreiben, dann bauen.
**2. Kleine Schritte.** Max. eine Feature pro Commit. Commit-Nachrichten beschreiben WAS und WARUM. **2. Kleine Schritte.** Max. eine Feature pro Commit. Commit-Nachrichten beschreiben WAS und WARUM.
**3. Beweisen statt behaupten.** Testen, was gebaut wurde. Keine "es sollte funktionieren"-Commits. **3. Beweisen statt behaupten.** Testen, was gebaut wurde. Keine "es sollte funktionieren"-Commits.
**4. Deutsch-Nicht-Entwickler.** Der Commander liest alles — Variablennamen auf Englisch, aber Kommentare und Docs auf Deutsch. **4. Deutsch-Nicht-Entwickler.** Der Commander liest alles — Variablennamen auf Englisch, aber Kommentare und Docs auf Deutsch.
## ⛔ HARTE Regeln (mechanisch geprüft — seit 22.07.2026) ## ⛔ HARTE Regeln (mechanisch geprüft — seit 22.07.2026)
**A. Die CI-Ampel muss GRÜN sein, bevor irgendetwas „fertig" heißt.** **A. Die CI-Ampel muss GRÜN sein, bevor irgendetwas „fertig" heißt.**
`.gitea/workflows/ci.yml` läuft bei jedem Push auf dem Gitea-Runner (NICHT löschen, NICHT `.gitea/workflows/ci.yml` läuft bei jedem Push auf dem Gitea-Runner (NICHT löschen, NICHT
abschwächen). Keine Tests = rot = nicht fertig. Der Commander liest die Ampel, nicht den Code. abschwächen). Keine Tests = rot = nicht fertig. Der Commander liest die Ampel, nicht den Code.
**B. Abweichung vom KONZEPT = STOPP + fragen.** Anderes Werkzeug, andere Bibliothek, **B. Abweichung vom KONZEPT = STOPP + fragen.** Anderes Werkzeug, andere Bibliothek,
gestrichenes Muss-Feature → erst den Commander fragen, NIE still ersetzen. gestrichenes Muss-Feature → erst den Commander fragen, NIE still ersetzen.
(Vorgefallen: Muss-Feature „MakeMKV lossless" wurde still durch lossy HandBrake ersetzt.) (Vorgefallen: Muss-Feature „MakeMKV lossless" wurde still durch lossy HandBrake ersetzt.)
**C. Single Source of Truth ist `main` — es gibt nur diesen einen Branch** **C. Single Source of Truth ist `main` — es gibt nur diesen einen Branch**
(seit 24.07.2026; der `stable`-Zwischenbranch ist abgeschafft). Die CI-Ampel (seit 24.07.2026; der `stable`-Zwischenbranch ist abgeschafft). Die CI-Ampel
läuft bei jedem Push und PRÜFT nur (Ruff/pytest/Vite-Build) — sie befördert läuft bei jedem Push und PRÜFT nur (Ruff/pytest/Vite-Build) — sie befördert
nichts mehr. Deployt wird direkt aus `main`: auf der VM `git pull` bzw. nichts mehr. Deployt wird direkt aus `main`: auf der VM `git pull` bzw.
`./deploy.sh` (verifiziere vorher, dass die Ampel für den Commit GRÜN ist — `./deploy.sh` (verifiziere vorher, dass die Ampel für den Commit GRÜN ist —
rot heißt: nicht deployen). Nie freihändig per SSH auf der VM bauen. rot heißt: nicht deployen). Nie freihändig per SSH auf der VM bauen.
(Vorgefallen: Doppel-Anlage `Rippy` + `rippy` auf der VM durch Freihand-Deploys.) (Vorgefallen: Doppel-Anlage `Rippy` + `rippy` auf der VM durch Freihand-Deploys.)
**D. Externe Schnittstellen NIE aus dem Kopf.** Vor Nutzung fremder CLI-Flags oder **D. Externe Schnittstellen NIE aus dem Kopf.** Vor Nutzung fremder CLI-Flags oder
Bibliotheks-APIs: `--help`/Doku prüfen und die Fundstelle im Commit nennen. Bibliotheks-APIs: `--help`/Doku prüfen und die Fundstelle im Commit nennen.
(Vorgefallen: erfundene Celery-Methode `self.send_task`, erfundene abcde-Flags.) (Vorgefallen: erfundene Celery-Methode `self.send_task`, erfundene abcde-Flags.)
## Workflow ## Workflow
1. **Read:** KONZEPT.md + ROADMAP.md lesen. Verstehen, welche Etappe dran ist. 1. **Read:** KONZEPT.md + ROADMAP.md lesen. Verstehen, welche Etappe dran ist.
2. **Plan:** Was genau soll diese Sitzung bauen? Eine Zeile. 2. **Plan:** Was genau soll diese Sitzung bauen? Eine Zeile.
3. **Build:** Code schreiben, testen, committen. 3. **Build:** Code schreiben, testen, committen.
4. **Verify:** `docker compose up` — funktioniert das Ganze? 4. **Verify:** `docker compose up` — funktioniert das Ganze?
5. **Savepoint:** SAVEPOINT.md aktualisieren. Nächster Chat beginnt nicht von Null. 5. **Savepoint:** SAVEPOINT.md aktualisieren. Nächster Chat beginnt nicht von Null.
## Modelle je Aufgabe ## Modelle je Aufgabe
- **Planung:** Heavy-Modell (Reasoning) für Architektur-Entscheidungen. - **Planung:** Heavy-Modell (Reasoning) für Architektur-Entscheidungen.
- **Bauen:** schnelles Coding-Modell. - **Bauen:** schnelles Coding-Modell.
- **Review:** Heavy nochmal für Code-Review vor Merge. - **Review:** Heavy nochmal für Code-Review vor Merge.
## Docker-Praxis ## Docker-Praxis
- Alles läuft in Containern: `docker compose up -d` - Alles läuft in Containern: `docker compose up -d`
- Keine System-Pakete auf dem Host — alles im Container. - Keine System-Pakete auf dem Host — alles im Container.
- Dockerfile immer multi-stage, kleinste Images. - Dockerfile immer multi-stage, kleinste Images.
## Was NICHT gebaut wird ## Was NICHT gebaut wird
- Kein Code direkt im Host-OS. - Kein Code direkt im Host-OS.
- Kein Proxmox-LXC-Nesting-Workaround — das Projekt lebt bewusst in normalem Docker. - Kein Proxmox-LXC-Nesting-Workaround — das Projekt lebt bewusst in normalem Docker.
- Kein ARM-Fork — Rippy ist ein Eigenbau von Grund auf. - Kein ARM-Fork — Rippy ist ein Eigenbau von Grund auf.
## Aktueller Stand (25.07.2026) ## Aktueller Stand (25.07.2026)
-**E2E bewiesen (v3.1):** BD-50 komplett durch die Kette (43 GB → 4,8 GB) -**E2E bewiesen (v3.1):** BD-50 komplett durch die Kette (43 GB → 4,8 GB)
-**Etappe 13 (v3.2):** Universal-Komfort-Runde — Media-Server-Integration, -**Etappe 13 (v3.2):** Universal-Komfort-Runde — Media-Server-Integration,
echte Benachrichtigungen, SMB-Klartext-Fehler, UHD-Arbeitsverzeichnis, echte Benachrichtigungen, SMB-Klartext-Fehler, UHD-Arbeitsverzeichnis,
MakeMKV-Key via UI, Job-Detail-Popup, Toast-Feedback, Ampel-Blocker behoben MakeMKV-Key via UI, Job-Detail-Popup, Toast-Feedback, Ampel-Blocker behoben
-**Etappe 17 (v3.10):** 4K-UHD-Disc-Schlüssel — persistentes -**Etappe 17 (v3.10):** 4K-UHD-Disc-Schlüssel — persistentes
MakeMKV-Datenverzeichnis + `KEYDB.cfg` im UI MakeMKV-Datenverzeichnis + `KEYDB.cfg` im UI
-**Etappe 18 (v3.11):** 4K-UHD gelöst — `makemkvcon` holt Schlüssel -**Etappe 18 (v3.11):** 4K-UHD gelöst — `makemkvcon` holt Schlüssel
unter Linux nie, unter Windows schon; Schlüsselspeicher übernehmbar. unter Linux nie, unter Windows schon; Schlüsselspeicher übernehmbar.
Akira-UHD geht auf der VM auf (`TCOUNT:5`, bewiesen) Akira-UHD geht auf der VM auf (`TCOUNT:5`, bewiesen)
-**Etappe 19 (v3.14):** Durchsicht Frontend/Backend — vier Placebos weg -**Etappe 19 (v3.14):** Durchsicht Frontend/Backend — vier Placebos weg
(Fortschritt log, Auswurf tat nichts, „Alle Tracks" konnte nichts, Encoder (Fortschritt log, Auswurf tat nichts, „Alle Tracks" konnte nichts, Encoder
wurden behauptet statt gemessen), Zombie-Erkennung gebaut, Pfad-Prüfung wurden behauptet statt gemessen), Zombie-Erkennung gebaut, Pfad-Prüfung
gehärtet, und der Platten-Schutz aus `c065967` als **unwirksam** entlarvt 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 -**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), (Ping im Hintergrund), Kompression je Disc-Typ abwählbar (4K verlustfrei),
Wizard empfiehlt nach gemessener CPU, vier tote Routen entfernt Wizard empfiehlt nach gemessener CPU, vier tote Routen entfernt
- 📝 **Details immer in SAVEPOINT.md** — diese Sektion nennt nur die Etappe - 📝 **Details immer in SAVEPOINT.md** — diese Sektion nennt nur die Etappe
## Was diese Sitzungen wiederholt gekostet hat ## Was diese Sitzungen wiederholt gekostet hat
**Nicht aus einem Zustandswert auf einen Mechanismus schließen.** Vorgefallen: **Nicht aus einem Zustandswert auf einen Mechanismus schließen.** Vorgefallen:
aus „kein Schlüssel da" → „Server abgeschaltet" (falsch), aus Status aus „kein Schlüssel da" → „Server abgeschaltet" (falsch), aus Status
`transcoding` → „Celery hat neu zugestellt" (falsch), aus `progress=99` `transcoding` → „Celery hat neu zugestellt" (falsch), aus `progress=99`
„Altwert aus dem Absturz" (falsch — ein Bug), aus gleichem `st_dev` „Altwert aus dem Absturz" (falsch — ein Bug), aus gleichem `st_dev`
`os.rename` funktioniert" (falsch — der Kernel vergleicht den Mount). `os.rename` funktioniert" (falsch — der Kernel vergleicht den Mount).
Jedes Mal hätte eine Messung von unter einer Minute gereicht. Jedes Mal hätte eine Messung von unter einer Minute gereicht.
**Und die Umkehrung gilt genauso:** gleiches `st_dev` heißt NICHT gleicher **Und die Umkehrung gilt genauso:** gleiches `st_dev` heißt NICHT gleicher
Mount. Wo eine Eigenschaft ausprobierbar ist, probiere sie aus, statt sie Mount. Wo eine Eigenschaft ausprobierbar ist, probiere sie aus, statt sie
vorherzusagen. vorherzusagen.
Binary file not shown.
+8 -8
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) # WANN NEU BAUEN: nur wenn sich install-gui.ps1 (die Installer-Oberfläche)
# aendert. NICHT bei MakeMKV-/HandBrake-Updates - die .exe holt HandBrake zur # ändert. NICHT bei MakeMKV-/HandBrake-Updates - die .exe holt HandBrake zur
# Laufzeit (GitHub latest) und den Worker-Code live von Rippy, friert also # Laufzeit (GitHub latest) und den Worker-Code live von Rippy, friert also
# keine Versionen ein. # keine Versionen ein.
# AUSFUEHREN AUF WINDOWS (eine Windows-.exe kann nicht von Linux gebaut werden): # AUSFUEHREN AUF WINDOWS (eine Windows-.exe kann nicht von Linux gebaut werden):
@@ -35,7 +35,7 @@ $bw.Write($png); $bw.Flush()
[System.IO.File]::WriteAllBytes("$PSScriptRoot\rippy.ico", $icoStream.ToArray()) [System.IO.File]::WriteAllBytes("$PSScriptRoot\rippy.ico", $icoStream.ToArray())
Write-Host "rippy.ico erzeugt ($($png.Length) Bytes PNG)" Write-Host "rippy.ico erzeugt ($($png.Length) Bytes PNG)"
# 2. ps2exe sicherstellen - TLS 1.2 ist der Fix fuer den NuGet-Bootstrap-Fehler # 2. ps2exe sicherstellen - TLS 1.2 ist der Fix für den NuGet-Bootstrap-Fehler
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor 3072 [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor 3072
if (-not (Get-Module -ListAvailable ps2exe)) { if (-not (Get-Module -ListAvailable ps2exe)) {
Write-Host "Bootstrappe NuGet + PSGallery ..." Write-Host "Bootstrappe NuGet + PSGallery ..."
@@ -49,13 +49,13 @@ Import-Module ps2exe
# 3. Kompilieren (WinForms -> noConsole + STA, Icon eingebettet) # 3. Kompilieren (WinForms -> noConsole + STA, Icon eingebettet)
# #
# -requireAdmin (seit 25.07.2026): Der Worker wird standardmaessig nach # -requireAdmin (seit 25.07.2026): Der Worker wird standardmäßig nach
# "C:\Program Files\Rippy Worker" installiert, und dorthin darf nur ein # "C:\Program Files\Rippy Worker" installiert, und dorthin darf nur ein
# Administrator schreiben. Mit dem Schalter tragt die .exe ein Manifest, das # Administrator schreiben. Mit dem Schalter tragt die .exe ein Manifest, das
# Windows beim Doppelklick EINMAL per UAC fragen laesst - danach stimmen die # Windows beim Doppelklick EINMAL per UAC fragen lässt - danach stimmen die
# Rechte fuer alles Weitere. Ohne ihn liefe der Installer unerhoeht und # 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, # scheiterte beim Anlegen des Ordners; wer das Ziel ins eigene Profil legt,
# braucht die Erhoehung nicht, wird aber trotzdem gefragt (normal fuer einen # braucht die Erhöhung nicht, wird aber trotzdem gefragt (normal für einen
# Installer und besser als ein Fehlschlag mitten im Lauf). # Installer und besser als ein Fehlschlag mitten im Lauf).
Invoke-ps2exe -inputFile $guiSrc -outputFile "$PSScriptRoot\RippyWorkerSetup.exe" ` Invoke-ps2exe -inputFile $guiSrc -outputFile "$PSScriptRoot\RippyWorkerSetup.exe" `
-iconFile "$PSScriptRoot\rippy.ico" -noConsole -STA -requireAdmin ` -iconFile "$PSScriptRoot\rippy.ico" -noConsole -STA -requireAdmin `
+144 -82
View File
@@ -1,9 +1,14 @@
# Rippy Windows-Worker - GRAFISCHER Installer (WinForms, keine externe Runtime). # Rippy Windows-Worker - GRAFISCHER Installer (WinForms, keine externe Runtime).
# Wird ueber rippy-worker-setup.bat gestartet (Doppelklick). Braucht nur # Wird über rippy-worker-setup.bat gestartet (Doppelklick). Braucht nur
# Python 3.10+ auf der Maschine - kein Docker, kein git. # Python 3.10+ auf der Maschine - kein Docker, kein git.
# #
# ASCII-only (PowerShell 5.1 liest .ps1 als ANSI - Sonderzeichen zerschiessen # WICHTIG - KODIERUNG: Diese Datei MUSS als UTF-8 MIT BOM gespeichert werden.
# das Skript). # PowerShell 5.1 liest .ps1 ohne BOM als ANSI; Umlaute werden dann zu Kauderwelsch
# UND das Skript wirft einen Parser-Fehler, weil in "ö" ein Anführungszeichen
# 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( param(
[string]$RippyHost = "", [string]$RippyHost = "",
@@ -16,28 +21,42 @@ Add-Type -AssemblyName System.Drawing
# Standard-Zielverzeichnis: wie jedes andere Programm unter "Programme" # Standard-Zielverzeichnis: wie jedes andere Programm unter "Programme"
# (Commander-Wunsch 25.07.2026 - vorher lag der Worker im Benutzerprofil, was # (Commander-Wunsch 25.07.2026 - vorher lag der Worker im Benutzerprofil, was
# fuer ein Programm unueblich ist). GetFolderPath liefert den ECHTEN Pfad; # für ein Programm unüblich ist). GetFolderPath liefert den ECHTEN Pfad;
# deutsche Windows-Versionen zeigen im Explorer "Programme" an, der Pfad heisst # deutsche Windows-Versionen zeigen im Explorer "Programme" an, der Pfad heißt
# trotzdem "C:\Program Files". # trotzdem "C:\Program Files".
# #
# Schreiben dorthin braucht Administratorrechte - deshalb ist die .exe mit # Schreiben dorthin braucht Administratorrechte - deshalb ist die .exe mit
# -requireAdmin gebaut (build-exe.ps1) und fragt beim Start einmal per UAC. # -requireAdmin gebaut (build-exe.ps1) und fragt beim Start einmal per UAC.
# Ohne das waere hier der naechste "Zugriff verweigert" programmiert. # Ohne das wäre hier der nächste "Zugriff verweigert" programmiert.
$StandardZiel = Join-Path ([Environment]::GetFolderPath("ProgramFiles")) "Rippy Worker" $StandardZiel = Join-Path ([Environment]::GetFolderPath("ProgramFiles")) "Rippy Worker"
$InstallDir = $StandardZiel $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 --------------------------------------------------------------- # --- Fenster ---------------------------------------------------------------
$form = New-Object System.Windows.Forms.Form $form = New-Object System.Windows.Forms.Form
$form.Text = "Rippy Encoding-Worker - Installation" $form.Text = "Rippy Encoding-Worker - Installation"
$form.Size = New-Object System.Drawing.Size(560, 575) $form.ClientSize = New-Object System.Drawing.Size(560, 560)
$form.StartPosition = "CenterScreen" $form.StartPosition = "CenterScreen"
$form.FormBorderStyle = "FixedDialog" $form.FormBorderStyle = "FixedDialog"
$form.MaximizeBox = $false $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) $form.Font = New-Object System.Drawing.Font("Segoe UI", 9)
# Fenster-Icon: Rippy-Disc zur Laufzeit zeichnen (Indigo-Kreis + weisses Loch) # Fenster-Icon: Rippy-Disc zur Laufzeit zeichnen (Indigo-Kreis + weißes Loch)
# - kein externes .ico noetig. Erscheint in Titelleiste und Taskleiste. # - kein externes .ico nötig. Erscheint in Titelleiste und Taskleiste.
try { try {
$ibmp = New-Object System.Drawing.Bitmap 32, 32 $ibmp = New-Object System.Drawing.Bitmap 32, 32
$ig = [System.Drawing.Graphics]::FromImage($ibmp) $ig = [System.Drawing.Graphics]::FromImage($ibmp)
@@ -47,75 +66,116 @@ try {
$form.Icon = [System.Drawing.Icon]::FromHandle($ibmp.GetHicon()) $form.Icon = [System.Drawing.Icon]::FromHandle($ibmp.GetHicon())
} catch {} } catch {}
$titel = New-Object System.Windows.Forms.Label # --- Kopfbereich mit Farbverlauf --------------------------------------------
$titel.Text = "Rippy Encoding-Worker einrichten" # Das Rippy-UI hat oben einen Verlauf amber -> indigo -> purple (siehe
$titel.Font = New-Object System.Drawing.Font("Segoe UI", 14, [System.Drawing.FontStyle]::Bold) # FirstRunWizard.tsx). WinForms kann das nicht von sich aus, also wird der
$titel.ForeColor = [System.Drawing.Color]::FromArgb(79, 70, 229) # Verlauf im Paint-Ereignis gezeichnet - inklusive Disc-Symbol.
$titel.Location = New-Object System.Drawing.Point(24, 20) $kopf = New-Object System.Windows.Forms.Panel
$titel.Size = New-Object System.Drawing.Size(500, 30) $kopf.Dock = "Top"
$form.Controls.Add($titel) $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 # Disc-Symbol: Ring aussen, weisses Loch in der Mitte
$info.Text = "Diese Maschine uebernimmt die Video-Kompression fuer Rippy. Gerippt wird weiter auf der Rippy-Hauptmaschine." $e.Graphics.SmoothingMode = "AntiAlias"
$info.Location = New-Object System.Drawing.Point(24, 52) $weiss = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(235, 255, 255, 255))
$info.Size = New-Object System.Drawing.Size(500, 34) $e.Graphics.FillEllipse($weiss, 24, 28, 48, 48)
$info.ForeColor = [System.Drawing.Color]::FromArgb(100, 116, 139) $e.Graphics.FillEllipse((New-Object System.Drawing.SolidBrush $cIndigo), 36, 40, 24, 24)
$form.Controls.Add($info) $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 # Rippy-Adresse
$lblHost = New-Object System.Windows.Forms.Label $lblHost = New-Object System.Windows.Forms.Label
$lblHost.Text = "LAN-IP der Rippy-Maschine (Docker-Host) - NICHT dieser PC:" $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.Size = New-Object System.Drawing.Size(500, 18)
$lblHost.ForeColor = $cGedaempft
$form.Controls.Add($lblHost) $form.Controls.Add($lblHost)
$txtHost = New-Object System.Windows.Forms.TextBox $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.Size = New-Object System.Drawing.Size(300, 26)
$txtHost.Text = $RippyHost $txtHost.Text = $RippyHost
$txtHost.Font = New-Object System.Drawing.Font("Consolas", 10) $txtHost.Font = New-Object System.Drawing.Font("Consolas", 10)
$txtHost.BackColor = $cFeld
$txtHost.ForeColor = $cText
$txtHost.BorderStyle = "FixedSingle"
$form.Controls.Add($txtHost) $form.Controls.Add($txtHost)
# Worker-Name # Worker-Name
$lblName = New-Object System.Windows.Forms.Label $lblName = New-Object System.Windows.Forms.Label
$lblName.Text = "Name dieses Workers (frei waehlbar):" $lblName.Text = "Name dieses Workers (frei wählbar):"
$lblName.Location = New-Object System.Drawing.Point(24, 152) $lblName.Location = New-Object System.Drawing.Point(24, 176)
$lblName.Size = New-Object System.Drawing.Size(500, 18) $lblName.Size = New-Object System.Drawing.Size(500, 18)
$lblName.ForeColor = $cGedaempft
$form.Controls.Add($lblName) $form.Controls.Add($lblName)
$txtName = New-Object System.Windows.Forms.TextBox $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.Size = New-Object System.Drawing.Size(300, 26)
$txtName.Text = $WorkerName $txtName.Text = $WorkerName
$txtName.BackColor = $cFeld
$txtName.ForeColor = $cText
$txtName.BorderStyle = "FixedSingle"
$form.Controls.Add($txtName) $form.Controls.Add($txtName)
# Zielverzeichnis - frei waehlbar, Standard "Programme" # Zielverzeichnis - frei wählbar, Standard "Programme"
$lblPfad = New-Object System.Windows.Forms.Label $lblPfad = New-Object System.Windows.Forms.Label
$lblPfad.Text = "Installieren nach:" $lblPfad.Text = "Installieren nach:"
$lblPfad.Location = New-Object System.Drawing.Point(24, 206) $lblPfad.Location = New-Object System.Drawing.Point(24, 232)
$lblPfad.Size = New-Object System.Drawing.Size(500, 18) $lblPfad.Size = New-Object System.Drawing.Size(500, 18)
$lblPfad.ForeColor = $cGedaempft
$form.Controls.Add($lblPfad) $form.Controls.Add($lblPfad)
$txtPfad = New-Object System.Windows.Forms.TextBox $txtPfad = New-Object System.Windows.Forms.TextBox
$txtPfad.Location = New-Object System.Drawing.Point(24, 226) $txtPfad.Location = New-Object System.Drawing.Point(24, 252)
$txtPfad.Size = New-Object System.Drawing.Size(390, 26) $txtPfad.Size = New-Object System.Drawing.Size(390, 26)
$txtPfad.Text = $StandardZiel $txtPfad.Text = $StandardZiel
$txtPfad.Font = New-Object System.Drawing.Font("Consolas", 9) $txtPfad.Font = New-Object System.Drawing.Font("Consolas", 9)
$txtPfad.BackColor = $cFeld
$txtPfad.ForeColor = $cText
$txtPfad.BorderStyle = "FixedSingle"
$form.Controls.Add($txtPfad) $form.Controls.Add($txtPfad)
$btnPfad = New-Object System.Windows.Forms.Button $btnPfad = New-Object System.Windows.Forms.Button
$btnPfad.Text = "Aendern ..." $btnPfad.Text = "Ändern ..."
$btnPfad.Location = New-Object System.Drawing.Point(424, 225) $btnPfad.Location = New-Object System.Drawing.Point(424, 251)
$btnPfad.Size = New-Object System.Drawing.Size(100, 28) $btnPfad.Size = New-Object System.Drawing.Size(100, 28)
$btnPfad.FlatStyle = "Flat" $btnPfad.FlatStyle = "Flat"
$btnPfad.BackColor = $cFeld
$btnPfad.ForeColor = $cText
$btnPfad.FlatAppearance.BorderColor = $cGedaempft
$form.Controls.Add($btnPfad) $form.Controls.Add($btnPfad)
$btnPfad.Add_Click({ $btnPfad.Add_Click({
$dlg = New-Object System.Windows.Forms.FolderBrowserDialog $dlg = New-Object System.Windows.Forms.FolderBrowserDialog
$dlg.Description = "Ordner waehlen - darin wird 'Rippy Worker' angelegt" $dlg.Description = "Ordner wählen - darin wird 'Rippy Worker' angelegt"
$dlg.ShowNewFolderButton = $true $dlg.ShowNewFolderButton = $true
if ($dlg.ShowDialog() -eq "OK") { if ($dlg.ShowDialog() -eq "OK") {
# Der Dialog liefert den ELTERN-Ordner; der Unterordner kommt von uns, # Der Dialog liefert den ELTERN-Ordner; der Unterordner kommt von uns,
# damit die Deinstallation nie einen fremden Ordner mitloescht. # damit die Deinstallation nie einen fremden Ordner mitlöscht.
$txtPfad.Text = (Join-Path $dlg.SelectedPath "Rippy Worker") $txtPfad.Text = (Join-Path $dlg.SelectedPath "Rippy Worker")
} }
}) })
@@ -123,39 +183,41 @@ $btnPfad.Add_Click({
# Autostart # Autostart
$chkAuto = New-Object System.Windows.Forms.CheckBox $chkAuto = New-Object System.Windows.Forms.CheckBox
$chkAuto.Text = "Beim Anmelden automatisch starten (Tray-Symbol)" $chkAuto.Text = "Beim Anmelden automatisch starten (Tray-Symbol)"
$chkAuto.Location = New-Object System.Drawing.Point(24, 264) $chkAuto.Location = New-Object System.Drawing.Point(24, 290)
$chkAuto.Size = New-Object System.Drawing.Size(400, 22) $chkAuto.Size = New-Object System.Drawing.Size(400, 22)
$chkAuto.Checked = $true $chkAuto.Checked = $true
$chkAuto.ForeColor = $cText
$form.Controls.Add($chkAuto) $form.Controls.Add($chkAuto)
# Log-Bereich # Log-Bereich
$log = New-Object System.Windows.Forms.TextBox $log = New-Object System.Windows.Forms.TextBox
$log.Location = New-Object System.Drawing.Point(24, 296) $log.Location = New-Object System.Drawing.Point(24, 322)
$log.Size = New-Object System.Drawing.Size(500, 160) $log.Size = New-Object System.Drawing.Size(500, 150)
$log.Multiline = $true $log.Multiline = $true
$log.ReadOnly = $true $log.ReadOnly = $true
$log.ScrollBars = "Vertical" $log.ScrollBars = "Vertical"
$log.BackColor = [System.Drawing.Color]::FromArgb(15, 23, 42) $log.BackColor = $cPanel
$log.ForeColor = [System.Drawing.Color]::FromArgb(226, 232, 240) $log.ForeColor = $cText
$log.BorderStyle = "FixedSingle"
$log.Font = New-Object System.Drawing.Font("Consolas", 8.5) $log.Font = New-Object System.Drawing.Font("Consolas", 8.5)
$form.Controls.Add($log) $form.Controls.Add($log)
# Knoepfe # Knöpfe
$btnInstall = New-Object System.Windows.Forms.Button $btnInstall = New-Object System.Windows.Forms.Button
$btnInstall.Text = "Installieren" $btnInstall.Text = "Installieren"
$btnInstall.Location = New-Object System.Drawing.Point(24, 470) $btnInstall.Location = New-Object System.Drawing.Point(24, 486)
$btnInstall.Size = New-Object System.Drawing.Size(150, 40) $btnInstall.Size = New-Object System.Drawing.Size(150, 40)
$btnInstall.BackColor = [System.Drawing.Color]::FromArgb(79, 70, 229) $btnInstall.BackColor = $cAmber
$btnInstall.ForeColor = [System.Drawing.Color]::White $btnInstall.ForeColor = $cBg
$btnInstall.FlatStyle = "Flat" $btnInstall.FlatStyle = "Flat"
$btnInstall.Font = New-Object System.Drawing.Font("Segoe UI", 10, [System.Drawing.FontStyle]::Bold) $btnInstall.Font = New-Object System.Drawing.Font("Segoe UI", 10, [System.Drawing.FontStyle]::Bold)
$form.Controls.Add($btnInstall) $form.Controls.Add($btnInstall)
$btnStart = New-Object System.Windows.Forms.Button $btnStart = New-Object System.Windows.Forms.Button
$btnStart.Text = "Worker starten" $btnStart.Text = "Worker starten"
$btnStart.Location = New-Object System.Drawing.Point(190, 470) $btnStart.Location = New-Object System.Drawing.Point(190, 486)
$btnStart.Size = New-Object System.Drawing.Size(150, 40) $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.ForeColor = [System.Drawing.Color]::White
$btnStart.FlatStyle = "Flat" $btnStart.FlatStyle = "Flat"
$btnStart.Font = New-Object System.Drawing.Font("Segoe UI", 10, [System.Drawing.FontStyle]::Bold) $btnStart.Font = New-Object System.Drawing.Font("Segoe UI", 10, [System.Drawing.FontStyle]::Bold)
@@ -176,15 +238,15 @@ function Do-Install {
# Zielverzeichnis aus dem Feld. script:-Bereich, weil "Worker starten" # Zielverzeichnis aus dem Feld. script:-Bereich, weil "Worker starten"
# und die Abschlussmeldung denselben Pfad brauchen. # und die Abschlussmeldung denselben Pfad brauchen.
$gewaehlt = $txtPfad.Text.Trim() $zielEingabe = $txtPfad.Text.Trim()
if (-not $gewaehlt) { $gewaehlt = $StandardZiel } if (-not $zielEingabe) { $zielEingabe = $StandardZiel }
if (-not [System.IO.Path]::IsPathRooted($gewaehlt)) { if (-not [System.IO.Path]::IsPathRooted($zielEingabe)) {
[System.Windows.Forms.MessageBox]::Show( [System.Windows.Forms.MessageBox]::Show(
"Bitte einen vollstaendigen Pfad angeben, z. B. C:\Program Files\Rippy Worker.", "Bitte einen vollständigen Pfad angeben, z. B. C:\Program Files\Rippy Worker.",
"Pfad unvollstaendig") "Pfad unvollständig")
return return
} }
$script:InstallDir = $gewaehlt $script:InstallDir = $zielEingabe
$btnInstall.Enabled = $false $btnInstall.Enabled = $false
$log.Clear() $log.Clear()
@@ -209,7 +271,7 @@ function Do-Install {
Log "Python gefunden: $python" Log "Python gefunden: $python"
# 2. Ordner + Worker-Code # 2. Ordner + Worker-Code
# Erst PRUEFEN, ob wir dort ueberhaupt schreiben duerfen. Die .exe ist mit # 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 # -requireAdmin gebaut und fragt per UAC, aber wer install-gui.ps1 direkt
# startet, hat die Rechte nicht - dann soll hier ein brauchbarer Satz # startet, hat die Rechte nicht - dann soll hier ein brauchbarer Satz
# stehen und nicht "Zugriff verweigert" aus irgendeiner Tiefe. # stehen und nicht "Zugriff verweigert" aus irgendeiner Tiefe.
@@ -221,12 +283,12 @@ function Do-Install {
} catch { } catch {
Log "FEHLER: In '$InstallDir' darf nicht geschrieben werden." Log "FEHLER: In '$InstallDir' darf nicht geschrieben werden."
Log "" Log ""
Log "Das ist normal bei Ordnern unter 'Programme' - dafuer braucht es" Log "Das ist normal bei Ordnern unter 'Programme' - dafür braucht es"
Log "Administratorrechte. Zwei Wege:" Log "Administratorrechte. Zwei Wege:"
Log " 1. Diesen Installer mit Rechtsklick > 'Als Administrator" Log " 1. Diesen Installer mit Rechtsklick > 'Als Administrator"
Log " ausfuehren' starten (der empfohlene Weg), ODER" Log " ausführen' starten (der empfohlene Weg), ODER"
Log " 2. oben ueber 'Aendern ...' einen Ordner im eigenen" Log " 2. oben über 'Ändern ...' einen Ordner im eigenen"
Log " Benutzerprofil waehlen - dort geht es ohne Adminrechte." Log " Benutzerprofil wählen - dort geht es ohne Adminrechte."
$btnInstall.Enabled = $true $btnInstall.Enabled = $true
return return
} }
@@ -242,13 +304,13 @@ function Do-Install {
Expand-Archive worker.zip -DestinationPath . -Force Expand-Archive worker.zip -DestinationPath . -Force
Remove-Item worker.zip 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" } 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\python.exe -m pip install --quiet --upgrade pip
& .\venv\Scripts\pip.exe install --quiet -r requirements.txt & .\venv\Scripts\pip.exe install --quiet -r requirements.txt
& .\venv\Scripts\pip.exe install --quiet pystray pillow & .\venv\Scripts\pip.exe install --quiet pystray pillow
Log "Abhaengigkeiten installiert." Log "Abhängigkeiten installiert."
# 4. HandBrakeCLI # 4. HandBrakeCLI
if (-not (Test-Path ".\HandBrakeCLI.exe") -and -not (Get-Command HandBrakeCLI.exe -ErrorAction SilentlyContinue)) { if (-not (Test-Path ".\HandBrakeCLI.exe") -and -not (Get-Command HandBrakeCLI.exe -ErrorAction SilentlyContinue)) {
@@ -273,12 +335,12 @@ if (-not `$Force) { if ([System.Windows.Forms.MessageBox]::Show("Rippy-Worker '$
Add-Type -AssemblyName System.Windows.Forms Add-Type -AssemblyName System.Windows.Forms
Get-CimInstance Win32_Process | Where-Object { `$_.ExecutablePath -like "`$PSScriptRoot*" } | ForEach-Object { Stop-Process -Id `$_.ProcessId -Force -ErrorAction SilentlyContinue } Get-CimInstance Win32_Process | Where-Object { `$_.ExecutablePath -like "`$PSScriptRoot*" } | ForEach-Object { Stop-Process -Id `$_.ProcessId -Force -ErrorAction SilentlyContinue }
Start-Sleep 2 Start-Sleep 2
# Autostart-Verknuepfung entfernen - BEIDE moeglichen Orte, weil die # Autostart-Verknüpfung entfernen - BEIDE möglichen Orte, weil die
# Installation je nach Zielverzeichnis persoenlich oder maschinenweit war. # Installation je nach Zielverzeichnis persönlich oder maschinenweit war.
foreach (`$ordner in @("Startup", "CommonStartup")) { foreach (`$ordner in @("Startup", "CommonStartup")) {
try { Remove-Item -Force (Join-Path ([Environment]::GetFolderPath(`$ordner)) "RippyWorker.lnk") -ErrorAction Stop } catch {} try { Remove-Item -Force (Join-Path ([Environment]::GetFolderPath(`$ordner)) "RippyWorker.lnk") -ErrorAction Stop } catch {}
} }
# ... und die geplante Aufgabe aus aelteren Installationen, falls vorhanden. # ... und die geplante Aufgabe aus älteren Installationen, falls vorhanden.
schtasks /delete /tn "RippyWorker" /f 2>`$null | Out-Null schtasks /delete /tn "RippyWorker" /f 2>`$null | Out-Null
try { Invoke-RestMethod -Method Delete "http://$rHost/api/workers/$wName" -TimeoutSec 5 | Out-Null } catch {} try { Invoke-RestMethod -Method Delete "http://$rHost/api/workers/$wName" -TimeoutSec 5 | Out-Null } catch {}
Set-Location (Split-Path `$PSScriptRoot -Parent) Set-Location (Split-Path `$PSScriptRoot -Parent)
@@ -287,55 +349,55 @@ Remove-Item -Recurse -Force `$PSScriptRoot
"@ "@
Set-Content -Path "uninstall.ps1" -Value $uninstall -Encoding ASCII Set-Content -Path "uninstall.ps1" -Value $uninstall -Encoding ASCII
# 6. Autostart ueber den Autostart-ORDNER, nicht ueber schtasks. # 6. Autostart über den Autostart-ORDNER, nicht über schtasks.
# #
# Befund 25.07.2026 (auf dem Commander-PC aufgetreten): schtasks /create # Befund 25.07.2026 (auf dem Commander-PC aufgetreten): schtasks /create
# mit /tn "RippyWorker" legt die Aufgabe im WURZELORDNER der # mit /tn "RippyWorker" legt die Aufgabe im WURZELORDNER der
# Aufgabenplanung an, und das verlangt Administratorrechte. Der # Aufgabenplanung an, und das verlangt Administratorrechte. Der
# Installer laeuft normal ohne. schtasks schrieb deshalb # Installer läuft normal ohne. schtasks schrieb deshalb
# "FEHLER: Zugriff verweigert." nach stderr, und weil oben # "FEHLER: Zugriff verweigert." nach stderr, und weil oben
# $ErrorActionPreference = "Stop" steht, wurde daraus ein Abbruch der # $ErrorActionPreference = "Stop" steht, wurde daraus ein Abbruch der
# GESAMTEN Installation - obwohl alles ausser dem Autostart fertig war. # GESAMTEN Installation - obwohl alles außer dem Autostart fertig war.
# Der Nutzer sah nur "FEHLER: FEHLER: Zugriff verweigert" samt dem # Der Nutzer sah nur "FEHLER: FEHLER: Zugriff verweigert" samt dem
# irrefuehrenden Tipp, die IP koenne falsch sein. # irreführenden Tipp, die IP könne falsch sein.
# #
# Der Autostart-Ordner braucht NIE Adminrechte, und der Nutzer kann die # Der Autostart-Ordner braucht NIE Adminrechte, und der Nutzer kann die
# Verknuepfung dort selbst sehen und loeschen. Zwei Fliegen. # Verknüpfung dort selbst sehen und löschen. Zwei Fliegen.
if ($chkAuto.Checked) { if ($chkAuto.Checked) {
try { try {
# Liegt das Programm unter "Programme", ist es eine Installation # Liegt das Programm unter "Programme", ist es eine Installation
# fuer die ganze Maschine - dann gehoert der Autostart in den # für die ganze Maschine - dann gehört der Autostart in den
# Autostart-Ordner ALLER Benutzer. Das passt auch zur Rechtelage: # Autostart-Ordner ALLER Benutzer. Das passt auch zur Rechtelage:
# der Installer laeuft dafuer ohnehin erhoeht, und bei einer # der Installer läuft dafür ohnehin erhöht, und bei einer
# Erhoehung ueber ein FREMDES Administratorkonto waere der # Erhöhung über ein FREMDES Administratorkonto wäre der
# benutzereigene Ordner der des Admins - also der falsche. # benutzereigene Ordner der des Admins - also der falsche.
# Bei einer Installation ins eigene Profil bleibt es persoenlich. # Bei einer Installation ins eigene Profil bleibt es persönlich.
$programme = [Environment]::GetFolderPath("ProgramFiles") $programme = [Environment]::GetFolderPath("ProgramFiles")
$programmeX86 = [Environment]::GetFolderPath("ProgramFilesX86") $programmeX86 = [Environment]::GetFolderPath("ProgramFilesX86")
$fuerAlle = $InstallDir.StartsWith($programme, "OrdinalIgnoreCase") ` $maschinenweit = $InstallDir.StartsWith($programme, "OrdinalIgnoreCase") `
-or ($programmeX86 -and $InstallDir.StartsWith($programmeX86, "OrdinalIgnoreCase")) -or ($programmeX86 -and $InstallDir.StartsWith($programmeX86, "OrdinalIgnoreCase"))
$autostartOrdner = if ($fuerAlle) { $autostartDir = if ($maschinenweit) {
[Environment]::GetFolderPath("CommonStartup") [Environment]::GetFolderPath("CommonStartup")
} else { } else {
[Environment]::GetFolderPath("Startup") [Environment]::GetFolderPath("Startup")
} }
$verknuepfung = Join-Path $autostartOrdner "RippyWorker.lnk" $lnkPfad = Join-Path $autostartDir "RippyWorker.lnk"
$wsh = New-Object -ComObject WScript.Shell $wsh = New-Object -ComObject WScript.Shell
$lnk = $wsh.CreateShortcut($verknuepfung) $lnk = $wsh.CreateShortcut($lnkPfad)
$lnk.TargetPath = (Join-Path $InstallDir "start-tray.bat") $lnk.TargetPath = (Join-Path $InstallDir "start-tray.bat")
$lnk.WorkingDirectory = $InstallDir $lnk.WorkingDirectory = $InstallDir
$lnk.Description = "Rippy Encoding-Worker" $lnk.Description = "Rippy Encoding-Worker"
$lnk.Save() $lnk.Save()
if ($fuerAlle) { if ($maschinenweit) {
Log "Autostart eingerichtet (fuer alle Benutzer dieser Maschine)." Log "Autostart eingerichtet (für alle Benutzer dieser Maschine)."
} else { } else {
Log "Autostart eingerichtet (fuer dich, startet bei der Anmeldung)." Log "Autostart eingerichtet (für dich, startet bei der Anmeldung)."
} }
} catch { } catch {
# Nur eine Warnung: der Worker selbst ist fertig und startbar. # Nur eine Warnung: der Worker selbst ist fertig und startbar.
Log "HINWEIS: Autostart konnte nicht eingerichtet werden ($($_.Exception.Message))." Log "HINWEIS: Autostart konnte nicht eingerichtet werden ($($_.Exception.Message))."
Log " Der Worker ist trotzdem fertig installiert und laeuft." Log " Der Worker ist trotzdem fertig installiert und läuft."
Log " Von Hand: start-tray.bat in den Autostart-Ordner verknuepfen" Log " Von Hand: start-tray.bat in den Autostart-Ordner verknüpfen"
Log " (Windows-Taste + R, dann shell:startup eingeben)." Log " (Windows-Taste + R, dann shell:startup eingeben)."
} }
} }
+211 -211
View File
@@ -1,211 +1,211 @@
# Rippy: Nativer Windows-Transcode-Worker - Installation OHNE Docker. # Rippy: Nativer Windows-Transcode-Worker - Installation OHNE Docker.
# #
# Was das Skript tut (alles nach -InstallDir, Standard "C:\Program Files\Rippy Worker"): # Was das Skript tut (alles nach -InstallDir, Standard "C:\Program Files\Rippy Worker"):
# 1. Prueft Python (3.10+), legt ein venv an, installiert die Abhaengigkeiten # 1. Prüft Python (3.10+), legt ein venv an, installiert die Abhängigkeiten
# 2. Laedt den Worker-Code direkt von deiner Rippy-Instanz (/api/worker-setup/paket) # 2. Lädt den Worker-Code direkt von deiner Rippy-Instanz (/api/worker-setup/paket)
# 3. Laedt HandBrakeCLI (gepinnte Version, offizielles GitHub-Release), # 3. Lädt HandBrakeCLI (gepinnte Version, offizielles GitHub-Release),
# falls nicht schon im PATH oder im Ordner vorhanden # falls nicht schon im PATH oder im Ordner vorhanden
# 4. Erzeugt start-worker.bat - Doppelklick startet den Worker # 4. Erzeugt start-worker.bat - Doppelklick startet den Worker
# #
# Der Worker bedient NUR die Kompressions-Queue (transcode) - gerippt wird # Der Worker bedient NUR die Kompressions-Queue (transcode) - gerippt wird
# weiterhin auf der Rippy-Hauptmaschine (dort haengt das Laufwerk). # weiterhin auf der Rippy-Hauptmaschine (dort hängt das Laufwerk).
# #
# Aufruf (PowerShell): # Aufruf (PowerShell):
# .\install.ps1 -RippyHost 192.168.178.162 [-WorkerName mein-pc] [-Autostart] # .\install.ps1 -RippyHost 192.168.178.162 [-WorkerName mein-pc] [-Autostart]
# #
# -Autostart registriert eine Aufgabe "RippyWorker" (Start bei Anmeldung). # -Autostart registriert eine Aufgabe "RippyWorker" (Start bei Anmeldung).
param( param(
[Parameter(Mandatory = $true)] [string]$RippyHost, [Parameter(Mandatory = $true)] [string]$RippyHost,
[string]$WorkerName = $env:COMPUTERNAME.ToLower(), [string]$WorkerName = $env:COMPUTERNAME.ToLower(),
[switch]$Autostart, [switch]$Autostart,
# Zielverzeichnis. Standard ist "Programme", wie bei jedem anderen Programm # Zielverzeichnis. Standard ist "Programme", wie bei jedem anderen Programm
# (Commander-Wunsch 25.07.2026). Dorthin schreiben braucht Adminrechte - # (Commander-Wunsch 25.07.2026). Dorthin schreiben braucht Adminrechte -
# PowerShell also "Als Administrator" starten, oder hier einen Ordner im # PowerShell also "Als Administrator" starten, oder hier einen Ordner im
# eigenen Profil angeben. # eigenen Profil angeben.
[string]$InstallDir = (Join-Path ([Environment]::GetFolderPath("ProgramFiles")) "Rippy Worker") [string]$InstallDir = (Join-Path ([Environment]::GetFolderPath("ProgramFiles")) "Rippy Worker")
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
# HandBrake-Version: die NEUESTE offizielle (GitHub-Release) - so passt der # HandBrake-Version: die NEUESTE offizielle (GitHub-Release) - so passt der
# Windows-Worker zu dem, was der Update-Check im UI meldet (frueher war hier # Windows-Worker zu dem, was der Update-Check im UI meldet (früher war hier
# eine feste 1.9.2 hardcodiert, was verwirrte). Faellt auf eine bekannte # eine feste 1.9.2 hardcodiert, was verwirrte). Fällt auf eine bekannte
# Version zurueck, falls die GitHub-API gerade nicht erreichbar ist. # Version zurück, falls die GitHub-API gerade nicht erreichbar ist.
$HandBrakeVersion = "1.11.2" $HandBrakeVersion = "1.11.2"
try { try {
$rel = Invoke-RestMethod "https://api.github.com/repos/HandBrake/HandBrake/releases/latest" ` $rel = Invoke-RestMethod "https://api.github.com/repos/HandBrake/HandBrake/releases/latest" `
-TimeoutSec 15 -Headers @{ "User-Agent" = "rippy-installer" } -TimeoutSec 15 -Headers @{ "User-Agent" = "rippy-installer" }
if ($rel.tag_name) { $HandBrakeVersion = ([string]$rel.tag_name).TrimStart("v") } if ($rel.tag_name) { $HandBrakeVersion = ([string]$rel.tag_name).TrimStart("v") }
Write-Host "Neueste HandBrake-Version: $HandBrakeVersion" Write-Host "Neueste HandBrake-Version: $HandBrakeVersion"
} catch { Write-Host "GitHub-API nicht erreichbar - nutze HandBrake $HandBrakeVersion" } } catch { Write-Host "GitHub-API nicht erreichbar - nutze HandBrake $HandBrakeVersion" }
$HandBrakeUrl = "https://github.com/HandBrake/HandBrake/releases/download/$HandBrakeVersion/HandBrakeCLI-$HandBrakeVersion-win-x86_64.zip" $HandBrakeUrl = "https://github.com/HandBrake/HandBrake/releases/download/$HandBrakeVersion/HandBrakeCLI-$HandBrakeVersion-win-x86_64.zip"
Write-Host "== Rippy Windows-Worker Installation ==" -ForegroundColor Cyan Write-Host "== Rippy Windows-Worker Installation ==" -ForegroundColor Cyan
# 1. Python finden (py-Launcher oder python im PATH) # 1. Python finden (py-Launcher oder python im PATH)
$python = $null $python = $null
foreach ($kandidat in @("py -3", "python")) { foreach ($kandidat in @("py -3", "python")) {
try { try {
$version = Invoke-Expression "$kandidat --version" 2>&1 $version = Invoke-Expression "$kandidat --version" 2>&1
if ("$version" -match "Python 3\.(\d+)") { if ("$version" -match "Python 3\.(\d+)") {
if ([int]$Matches[1] -ge 10) { $python = $kandidat; break } if ([int]$Matches[1] -ge 10) { $python = $kandidat; break }
} }
} catch {} } catch {}
} }
if (-not $python) { if (-not $python) {
Write-Host "FEHLER: Python 3.10+ nicht gefunden." -ForegroundColor Red 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 exit 1
} }
Write-Host "Python gefunden: $python" Write-Host "Python gefunden: $python"
# 2. Zielordner + Worker-Code von der Rippy-Instanz laden # 2. Zielordner + Worker-Code von der Rippy-Instanz laden
$ziel = $InstallDir $ziel = $InstallDir
try { try {
New-Item -ItemType Directory -Force $ziel -ErrorAction Stop | Out-Null New-Item -ItemType Directory -Force $ziel -ErrorAction Stop | Out-Null
$probe = Join-Path $ziel ".schreibtest" $probe = Join-Path $ziel ".schreibtest"
Set-Content -Path $probe -Value "x" -ErrorAction Stop Set-Content -Path $probe -Value "x" -ErrorAction Stop
Remove-Item -Force $probe -ErrorAction SilentlyContinue Remove-Item -Force $probe -ErrorAction SilentlyContinue
} catch { } catch {
Write-Host "" Write-Host ""
Write-Host "FEHLER: In '$ziel' darf nicht geschrieben werden." -ForegroundColor Red 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 "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 " 1. PowerShell als Administrator starten und erneut aufrufen, ODER" -ForegroundColor Red
Write-Host " 2. ein eigenes Ziel angeben, z. B.:" -ForegroundColor Red Write-Host " 2. ein eigenes Ziel angeben, z. B.:" -ForegroundColor Red
Write-Host " -InstallDir `"`$env:LOCALAPPDATA\Rippy Worker`"" -ForegroundColor Red Write-Host " -InstallDir `"`$env:LOCALAPPDATA\Rippy Worker`"" -ForegroundColor Red
exit 1 exit 1
} }
Write-Host "Ziel: $ziel" Write-Host "Ziel: $ziel"
Set-Location $ziel Set-Location $ziel
Write-Host "Lade Worker-Code von http://$RippyHost/api/worker-setup/paket ..." Write-Host "Lade Worker-Code von http://$RippyHost/api/worker-setup/paket ..."
Invoke-WebRequest "http://$RippyHost/api/worker-setup/paket" -OutFile worker.zip -UseBasicParsing Invoke-WebRequest "http://$RippyHost/api/worker-setup/paket" -OutFile worker.zip -UseBasicParsing
Expand-Archive worker.zip -DestinationPath . -Force Expand-Archive worker.zip -DestinationPath . -Force
Remove-Item worker.zip Remove-Item worker.zip
# 3. venv + Abhaengigkeiten # 3. venv + Abhängigkeiten
if (-not (Test-Path "venv")) { if (-not (Test-Path "venv")) {
Write-Host "Lege Python-Umgebung an ..." Write-Host "Lege Python-Umgebung an ..."
Invoke-Expression "$python -m venv venv" Invoke-Expression "$python -m venv venv"
} }
& .\venv\Scripts\python.exe -m pip install --quiet --upgrade pip & .\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 -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 & .\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 $hb = Get-Command HandBrakeCLI.exe -ErrorAction SilentlyContinue
if (-not $hb -and -not (Test-Path ".\HandBrakeCLI.exe")) { if (-not $hb -and -not (Test-Path ".\HandBrakeCLI.exe")) {
Write-Host "Lade HandBrakeCLI $HandBrakeVersion (offizielles GitHub-Release) ..." Write-Host "Lade HandBrakeCLI $HandBrakeVersion (offizielles GitHub-Release) ..."
Invoke-WebRequest $HandBrakeUrl -OutFile hb.zip -UseBasicParsing Invoke-WebRequest $HandBrakeUrl -OutFile hb.zip -UseBasicParsing
Expand-Archive hb.zip -DestinationPath . -Force Expand-Archive hb.zip -DestinationPath . -Force
Remove-Item hb.zip Remove-Item hb.zip
} }
if (-not (Test-Path ".\HandBrakeCLI.exe") -and -not $hb) { if (-not (Test-Path ".\HandBrakeCLI.exe") -and -not $hb) {
Write-Host "FEHLER: HandBrakeCLI.exe fehlt." -ForegroundColor Red Write-Host "FEHLER: HandBrakeCLI.exe fehlt." -ForegroundColor Red
exit 1 exit 1
} }
Write-Host "HandBrakeCLI bereit." Write-Host "HandBrakeCLI bereit."
# 5. Start-Skript erzeugen # 5. Start-Skript erzeugen
$bat = @" $bat = @"
@echo off @echo off
rem Rippy Windows-Transcode-Worker - verbindet sich mit $RippyHost rem Rippy Windows-Transcode-Worker - verbindet sich mit $RippyHost
cd /d "%~dp0" cd /d "%~dp0"
set REDIS_URL=redis://${RippyHost}:6379/0 set REDIS_URL=redis://${RippyHost}:6379/0
set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy
set API_URL=http://${RippyHost}:8000 set API_URL=http://${RippyHost}:8000
set WORKER_NAME=$WorkerName set WORKER_NAME=$WorkerName
set PATH=%~dp0;%PATH% set PATH=%~dp0;%PATH%
venv\Scripts\celery.exe -A celery_app worker --loglevel=info -Q transcode --pool=solo -n ${WorkerName}@%%h venv\Scripts\celery.exe -A celery_app worker --loglevel=info -Q transcode --pool=solo -n ${WorkerName}@%%h
"@ "@
Set-Content -Path "start-worker.bat" -Value $bat -Encoding ASCII Set-Content -Path "start-worker.bat" -Value $bat -Encoding ASCII
# 6. Tray-Start (empfohlen): Symbol neben der Uhr, kein Konsolenfenster # 6. Tray-Start (empfohlen): Symbol neben der Uhr, kein Konsolenfenster
$trayBat = @" $trayBat = @"
@echo off @echo off
rem Rippy-Worker mit Tray-Symbol - verbindet sich mit $RippyHost rem Rippy-Worker mit Tray-Symbol - verbindet sich mit $RippyHost
cd /d "%~dp0" cd /d "%~dp0"
set REDIS_URL=redis://${RippyHost}:6379/0 set REDIS_URL=redis://${RippyHost}:6379/0
set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy
set API_URL=http://${RippyHost}:8000 set API_URL=http://${RippyHost}:8000
set WORKER_NAME=$WorkerName set WORKER_NAME=$WorkerName
set RIPPY_TRAY_HOST=$RippyHost set RIPPY_TRAY_HOST=$RippyHost
set PATH=%~dp0;%PATH% set PATH=%~dp0;%PATH%
start "" venv\Scripts\pythonw.exe tray.py start "" venv\Scripts\pythonw.exe tray.py
"@ "@
Set-Content -Path "start-tray.bat" -Value $trayBat -Encoding ASCII Set-Content -Path "start-tray.bat" -Value $trayBat -Encoding ASCII
Write-Host "start-worker.bat + start-tray.bat erzeugt." Write-Host "start-worker.bat + start-tray.bat erzeugt."
# 7. Deinstaller - MUSS-Kriterium: rueckstandsfrei entfernbar # 7. Deinstaller - MUSS-Kriterium: rückstandsfrei entfernbar
$uninstall = @" $uninstall = @"
# Rippy-Worker DEINSTALLIEREN: stoppt Prozesse, entfernt Autostart, # 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) param([switch]`$Force)
if (-not `$Force) { if (-not `$Force) {
`$antwort = Read-Host "Rippy-Worker '$WorkerName' wirklich deinstallieren? (j/n)" `$antwort = Read-Host "Rippy-Worker '$WorkerName' wirklich deinstallieren? (j/n)"
if (`$antwort -ne "j") { Write-Host "Abgebrochen."; exit } if (`$antwort -ne "j") { Write-Host "Abgebrochen."; exit }
} }
Get-CimInstance Win32_Process | Where-Object { Get-CimInstance Win32_Process | Where-Object {
`$_.ExecutablePath -like "`$PSScriptRoot*" `$_.ExecutablePath -like "`$PSScriptRoot*"
} | ForEach-Object { Stop-Process -Id `$_.ProcessId -Force -ErrorAction SilentlyContinue } } | ForEach-Object { Stop-Process -Id `$_.ProcessId -Force -ErrorAction SilentlyContinue }
Start-Sleep 2 Start-Sleep 2
# Autostart-Verknuepfung entfernen - BEIDE moeglichen Orte, weil die # Autostart-Verknüpfung entfernen - BEIDE möglichen Orte, weil die
# Installation je nach Zielverzeichnis persoenlich oder maschinenweit war. # Installation je nach Zielverzeichnis persönlich oder maschinenweit war.
foreach (`$ordner in @("Startup", "CommonStartup")) { foreach (`$ordner in @("Startup", "CommonStartup")) {
try { Remove-Item -Force (Join-Path ([Environment]::GetFolderPath(`$ordner)) "RippyWorker.lnk") -ErrorAction Stop } catch {} try { Remove-Item -Force (Join-Path ([Environment]::GetFolderPath(`$ordner)) "RippyWorker.lnk") -ErrorAction Stop } catch {}
} }
# ... und die geplante Aufgabe aus aelteren Installationen, falls vorhanden. # ... und die geplante Aufgabe aus älteren Installationen, falls vorhanden.
schtasks /delete /tn "RippyWorker" /f 2>`$null | Out-Null schtasks /delete /tn "RippyWorker" /f 2>`$null | Out-Null
try { try {
Invoke-RestMethod -Method Delete "http://$RippyHost/api/workers/$WorkerName" -TimeoutSec 5 | Out-Null Invoke-RestMethod -Method Delete "http://$RippyHost/api/workers/$WorkerName" -TimeoutSec 5 | Out-Null
Write-Host "Worker-Eintrag in Rippy entfernt." 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) Set-Location (Split-Path `$PSScriptRoot -Parent)
Remove-Item -Recurse -Force `$PSScriptRoot Remove-Item -Recurse -Force `$PSScriptRoot
Write-Host "Rippy-Worker deinstalliert." -ForegroundColor Green Write-Host "Rippy-Worker deinstalliert." -ForegroundColor Green
"@ "@
Set-Content -Path "uninstall.ps1" -Value $uninstall -Encoding ASCII Set-Content -Path "uninstall.ps1" -Value $uninstall -Encoding ASCII
Write-Host "uninstall.ps1 erzeugt." Write-Host "uninstall.ps1 erzeugt."
# 8. Optional: Autostart bei Anmeldung (mit Tray) # 8. Optional: Autostart bei Anmeldung (mit Tray)
# #
# Ueber den Autostart-ORDNER, nicht ueber schtasks: Eine Aufgabe im # Über den Autostart-ORDNER, nicht über schtasks: Eine Aufgabe im
# Wurzelordner der Aufgabenplanung anzulegen verlangt Administratorrechte, und # Wurzelordner der Aufgabenplanung anzulegen verlangt Administratorrechte, und
# der Installer laeuft normal ohne. Auf dem Commander-PC scheiterte das am # der Installer läuft normal ohne. Auf dem Commander-PC scheiterte das am
# 25.07.2026 mit "FEHLER: Zugriff verweigert." - und weil oben # 25.07.2026 mit "FEHLER: Zugriff verweigert." - und weil oben
# $ErrorActionPreference = "Stop" steht, riss es die ganze Installation mit, # $ErrorActionPreference = "Stop" steht, riss es die ganze Installation mit,
# obwohl nur der Autostart fehlte. Der Autostart-Ordner braucht nie Adminrechte. # obwohl nur der Autostart fehlte. Der Autostart-Ordner braucht nie Adminrechte.
if ($Autostart) { if ($Autostart) {
try { try {
# Unter "Programme" = Installation fuer die Maschine, also Autostart fuer # Unter "Programme" = Installation für die Maschine, also Autostart für
# alle Benutzer. Sonst persoenlich. Siehe install-gui.ps1 fuer die # alle Benutzer. Sonst persönlich. Siehe install-gui.ps1 für die
# ausfuehrliche Begruendung (Rechtelage bei fremdem Admin-Konto). # ausführliche Begründung (Rechtelage bei fremdem Admin-Konto).
$programme = [Environment]::GetFolderPath("ProgramFiles") $programme = [Environment]::GetFolderPath("ProgramFiles")
$fuerAlle = $ziel.StartsWith($programme, "OrdinalIgnoreCase") $maschinenweit = $ziel.StartsWith($programme, "OrdinalIgnoreCase")
$autostartOrdner = if ($fuerAlle) { $autostartDir = if ($maschinenweit) {
[Environment]::GetFolderPath("CommonStartup") [Environment]::GetFolderPath("CommonStartup")
} else { } else {
[Environment]::GetFolderPath("Startup") [Environment]::GetFolderPath("Startup")
} }
$wsh = New-Object -ComObject WScript.Shell $wsh = New-Object -ComObject WScript.Shell
$lnk = $wsh.CreateShortcut((Join-Path $autostartOrdner "RippyWorker.lnk")) $lnk = $wsh.CreateShortcut((Join-Path $autostartDir "RippyWorker.lnk"))
$lnk.TargetPath = (Join-Path $ziel "start-tray.bat") $lnk.TargetPath = (Join-Path $ziel "start-tray.bat")
$lnk.WorkingDirectory = $ziel $lnk.WorkingDirectory = $ziel
$lnk.Description = "Rippy Encoding-Worker" $lnk.Description = "Rippy Encoding-Worker"
$lnk.Save() $lnk.Save()
Write-Host "Autostart eingerichtet (Verknuepfung im Autostart-Ordner)." Write-Host "Autostart eingerichtet (Verknüpfung im Autostart-Ordner)."
} catch { } catch {
Write-Host "HINWEIS: Autostart konnte nicht eingerichtet werden ($($_.Exception.Message))." -ForegroundColor Yellow Write-Host "HINWEIS: Autostart konnte nicht eingerichtet werden ($($_.Exception.Message))." -ForegroundColor Yellow
Write-Host " Der Worker ist trotzdem fertig installiert." -ForegroundColor Yellow Write-Host " Der Worker ist trotzdem fertig installiert." -ForegroundColor Yellow
Write-Host " Von Hand: start-tray.bat in shell:startup verknuepfen." -ForegroundColor Yellow Write-Host " Von Hand: start-tray.bat in shell:startup verknüpfen." -ForegroundColor Yellow
} }
} }
Write-Host "" Write-Host ""
Write-Host "FERTIG." -ForegroundColor Green Write-Host "FERTIG." -ForegroundColor Green
Write-Host " Starten (mit Tray-Symbol): $ziel\start-tray.bat" Write-Host " Starten (mit Tray-Symbol): $ziel\start-tray.bat"
Write-Host " Starten (Konsole/Debug): $ziel\start-worker.bat" Write-Host " Starten (Konsole/Debug): $ziel\start-worker.bat"
Write-Host " Deinstallieren: $ziel\uninstall.ps1" Write-Host " Deinstallieren: $ziel\uninstall.ps1"
Write-Host "Der Worker erscheint in Rippy unter Einstellungen -> Worker als '$WorkerName'." Write-Host "Der Worker erscheint in Rippy unter Einstellungen -> Worker als '$WorkerName'."
+4 -4
View File
@@ -58,9 +58,9 @@ async def startup_event():
asyncio.create_task(asyncio.to_thread(remount)) asyncio.create_task(asyncio.to_thread(remount))
asyncio.create_task(disc_watcher()) asyncio.create_task(disc_watcher())
# MakeMKV-Beta-Key automatisch aktuell halten (wechselt ~monatlich, laeuft zum # MakeMKV-Beta-Key automatisch aktuell halten (wechselt ~monatlich, läuft zum
# Monatsende ab) — sonst blockt Blu-ray-Ripping irgendwann still. Taeglicher # Monatsende ab) — sonst blockt Blu-ray-Ripping irgendwann still. Taeglicher
# Forum-Abgleich; wirkt ohne Rebuild ab dem naechsten Rip. # Forum-Abgleich; wirkt ohne Rebuild ab dem nächsten Rip.
asyncio.create_task(makemkv_key.refresh_loop()) asyncio.create_task(makemkv_key.refresh_loop())
# Worker-Erreichbarkeit im Hintergrund pingen (siehe _ping_knoten) — sonst # Worker-Erreichbarkeit im Hintergrund pingen (siehe _ping_knoten) — sonst
# kostet JEDER Aufruf von /capabilities eine ganze Sekunde. # kostet JEDER Aufruf von /capabilities eine ganze Sekunde.
@@ -467,7 +467,7 @@ def unter_wurzel(pfad: str, wurzel: str) -> bool:
"""Liegt `pfad` wirklich unterhalb von `wurzel` (oder IST es die Wurzel)? """Liegt `pfad` wirklich unterhalb von `wurzel` (oder IST es die Wurzel)?
Pure Funktion, testbar. Ein nacktes `startswith()` genügt hier nicht: Pure Funktion, testbar. Ein nacktes `startswith()` genügt hier nicht:
„/app/media-boese/x" beginnt mit „/app/media", liegt aber ausserhalb „/app/media-boese/x" beginnt mit „/app/media", liegt aber außerhalb
(Befund 25.07.2026 bei der Durchsicht). Deshalb Gleichheit ODER Wurzel (Befund 25.07.2026 bei der Durchsicht). Deshalb Gleichheit ODER Wurzel
samt Trennzeichen. Erwartet werden normalisierte Container-Pfade mit „/". samt Trennzeichen. Erwartet werden normalisierte Container-Pfade mit „/".
""" """
@@ -1383,7 +1383,7 @@ async def set_keystore(request: Request):
rohdaten = await request.body() rohdaten = await request.body()
def pruefe_und_schreibe(): def pruefe_und_schreibe():
# Prüfung liest ein mehrere MB grosses tar — gehört deshalb mit in # Prüfung liest ein mehrere MB großes tar — gehört deshalb mit in
# den Thread und nicht in die Ereignisschleife. # den Thread und nicht in die Ereignisschleife.
fehler = makemkv_daten.private_data_pruefen(rohdaten) fehler = makemkv_daten.private_data_pruefen(rohdaten)
if fehler: if fehler:
+44 -44
View File
@@ -1,8 +1,8 @@
"""MakeMKV-Datenverzeichnis: Schluesselspeicher, KEYDB.cfg, AACS-Dumps. """MakeMKV-Datenverzeichnis: Schlüsselspeicher, KEYDB.cfg, AACS-Dumps.
WARUM ES DIESE DATEI GIBT (Befund 25.07.2026, auf BEIDEN Maschinen gemessen): WARUM ES DIESE DATEI GIBT (Befund 25.07.2026, auf BEIDEN Maschinen gemessen):
4K-UHD-Discs scheiterten mit "The volume key is unknown for this disc". Die 4K-UHD-Discs scheiterten mit "The volume key is unknown for this disc". Die
Ursache ist weder die Disc noch das Laufwerk — LibreDrive v06.3 laeuft und Ursache ist weder die Disc noch das Laufwerk — LibreDrive v06.3 läuft und
MakeMKV liest die Disc — sondern: MakeMKV liest die Disc — sondern:
makemkvcon unter LINUX ruft die Disc-Schluessel nie ab. makemkvcon unter LINUX ruft die Disc-Schluessel nie ab.
@@ -10,35 +10,35 @@ MakeMKV liest die Disc — sondern:
Gemessen, nicht vermutet: Gemessen, nicht vermutet:
* Linux: in KEINEM Lauf auch nur eine Verbindung nach draussen. Geprueft mit * Linux: in KEINEM Lauf auch nur eine Verbindung nach draussen. Geprueft mit
leerem UND mit gefuelltem Schluesselspeicher, mit und ohne --noscan, mit leerem UND mit gefülltem Schlüsselspeicher, mit und ohne --noscan, mit
dev:/dev/sr0 und mit disc:0, und mit erzwungener frischer Pruefung dev:/dev/sr0 und mit disc:0, und mit erzwungener frischer Prüfung
(update.conf geloescht, Meldung 5074 belegt den Web-Kontakt). Immer: (update.conf gelöscht, Meldung 5074 belegt den Web-Kontakt). Immer:
keine Verbindung, kein Schluessel. keine Verbindung, kein Schluessel.
* Windows, dieselbe Disc, dasselbe Laufwerk: Meldung 3338 "Downloading * Windows, dieselbe Disc, dasselbe Laufwerk: Meldung 3338 "Downloading
latest HK to ...", Verbindung nach 185.84.108.20:443 latest HK to ...", Verbindung nach 185.84.108.20:443
(web33.majordomo.ru), _private_data.tar waechst — und die Disc geht auf (web33.majordomo.ru), _private_data.tar wächst — und die Disc geht auf
(TCOUNT:5, "Operation successfully completed"). (TCOUNT:5, "Operation successfully completed").
* Der Code dafuer steckt auch im Linux-Binary: die Meldungsvorlage * Der Code dafür steckt auch im Linux-Binary: die Meldungsvorlage
"Downloading latest %1 to %2 ..." steht in makemkvcon. Sie loest nur nie "Downloading latest %1 to %2 ..." steht in makemkvcon. Sie löst nur nie
aus. Gleiches Symptom im Forum, seit Jahren offen und unbeantwortet. aus. Gleiches Symptom im Forum, seit Jahren offen und unbeantwortet.
FRUEHERE FEHLDIAGNOSE, bewusst festgehalten: An dieser Stelle stand zuerst, FRUEHERE FEHLDIAGNOSE, bewusst festgehalten: An dieser Stelle stand zuerst,
MakeMKVs Schluessel-Kanal sei abgeschaltet. Das war FALSCH. Die Herleitung MakeMKVs Schluessel-Kanal sei abgeschaltet. Das war FALSCH. Die Herleitung
stuetzte sich auf zwei Hostnamen aus alten Forumsbeitraegen stützte sich auf zwei Hostnamen aus alten Forumsbeitraegen
(hkdata.fairuse.org, hkdata.crabdance.com), die tatsaechlich nicht mehr (hkdata.fairuse.org, hkdata.crabdance.com), die tatsaechlich nicht mehr
aufloesen — MakeMKV benutzt sie aber laengst nicht mehr. Der Dienst lebt, der aufloesen — MakeMKV benutzt sie aber laengst nicht mehr. Der Dienst lebt, der
Worker erreicht ihn sogar; er wird unter Linux nur nie gefragt. Worker erreicht ihn sogar; er wird unter Linux nur nie gefragt.
WAS DARAUS FOLGT: Der Schluesselspeicher (_private_data.tar) muss von einer WAS DARAUS FOLGT: Der Schlüsselspeicher (_private_data.tar) muss von einer
MakeMKV-Installation kommen, die ihn wirklich abruft — praktisch von Windows. MakeMKV-Installation kommen, die ihn wirklich abruft — praktisch von Windows.
Rippy nimmt ihn unter Einstellungen -> System entgegen. Die KEYDB.cfg bleibt Rippy nimmt ihn unter Einstellungen -> System entgegen. Die KEYDB.cfg bleibt
der Notnagel fuer Pressungen, die auch MakeMKV selbst nicht kennt. der Notnagel für Pressungen, die auch MakeMKV selbst nicht kennt.
Rippy liefert KEINE Schluessel mit, laedt keine herunter und verteilt keine. Rippy liefert KEINE Schluessel mit, lädt keine herunter und verteilt keine.
Es verwaltet nur, was der Nutzer selbst mitbringt. Es verwaltet nur, was der Nutzer selbst mitbringt.
QUELLEN (AGENTS Regel D — externe Schnittstellen nie aus dem Kopf): QUELLEN (AGENTS Regel D — externe Schnittstellen nie aus dem Kopf):
* Linux laedt keine Hashed Keys — dasselbe Symptom, mehrfach berichtet: * Linux lädt keine Hashed Keys — dasselbe Symptom, mehrfach berichtet:
https://forum.makemkv.com/forum/viewtopic.php?t=25782 https://forum.makemkv.com/forum/viewtopic.php?t=25782
https://forum.makemkv.com/forum/viewtopic.php?t=34022 https://forum.makemkv.com/forum/viewtopic.php?t=34022
* Schluessel liegen als hkd_*.bin in _private_data.tar: * Schluessel liegen als hkd_*.bin in _private_data.tar:
@@ -55,7 +55,7 @@ QUELLEN (AGENTS Regel D — externe Schnittstellen nie aus dem Kopf):
ZWILLINGSDATEI: liegt identisch unter docker/api/ und docker/worker/ — beide ZWILLINGSDATEI: liegt identisch unter docker/api/ und docker/worker/ — beide
Images brauchen sie, ein gemeinsames Paket gibt es in diesem Projekt nicht Images brauchen sie, ein gemeinsames Paket gibt es in diesem Projekt nicht
(gleiche Lage wie bei db.py). Aenderungen IMMER in BEIDEN Dateien nachziehen. (gleiche Lage wie bei db.py). Änderungen IMMER in BEIDEN Dateien nachziehen.
""" """
import io import io
@@ -72,14 +72,14 @@ DATEN_DIR = os.getenv("MAKEMKV_DATA_DIR", "/root/.MakeMKV")
# GROSS geschrieben — unter Linux case-sensitiv, "keydb.cfg" wird ignoriert. # GROSS geschrieben — unter Linux case-sensitiv, "keydb.cfg" wird ignoriert.
KEYDB_NAME = "KEYDB.cfg" KEYDB_NAME = "KEYDB.cfg"
# Obergrenze fuer den Upload. Eine vollstaendige oeffentliche KEYDB.cfg liegt # Obergrenze für den Upload. Eine vollständige oeffentliche KEYDB.cfg liegt
# im einstelligen MB-Bereich; 64 MB sind reichlich Luft und verhindern, dass # im einstelligen MB-Bereich; 64 MB sind reichlich Luft und verhindern, dass
# eine versehentlich hochgeladene Riesendatei den Speicher vollschreibt. # eine versehentlich hochgeladene Riesendatei den Speicher vollschreibt.
MAX_KEYDB_BYTES = 64 * 1024 * 1024 MAX_KEYDB_BYTES = 64 * 1024 * 1024
# Eine Disc-Zeile beginnt mit der 40 Zeichen langen Hex-Kennung (optional mit # Eine Disc-Zeile beginnt mit der 40 Zeichen langen Hex-Kennung (optional mit
# 0x davor), danach folgt das Gleichheitszeichen. Alles andere (Kommentare ab # 0x davor), danach folgt das Gleichheitszeichen. Alles andere (Kommentare ab
# ";", Leerzeilen, Fortsetzungsfelder) zaehlt nicht als Eintrag. # ";", Leerzeilen, Fortsetzungsfelder) zählt nicht als Eintrag.
_DISC_ZEILE = re.compile(r"^\s*(?:0x)?[0-9a-fA-F]{40}\s*=") _DISC_ZEILE = re.compile(r"^\s*(?:0x)?[0-9a-fA-F]{40}\s*=")
@@ -94,9 +94,9 @@ def keydb_pfad(daten_dir: str = None) -> str:
def zaehle_disc_eintraege(inhalt: str) -> int: def zaehle_disc_eintraege(inhalt: str) -> int:
"""Zeilen mit Disc-Kennung zaehlen (pure Funktion, testbar). """Zeilen mit Disc-Kennung zählen (pure Funktion, testbar).
Bewusst eine Heuristik und keine vollstaendige Auswertung: MakeMKV liest Bewusst eine Heuristik und keine vollständige Auswertung: MakeMKV liest
die Datei mit seinem eigenen Parser, und wie viele Eintraege es daraus die Datei mit seinem eigenen Parser, und wie viele Eintraege es daraus
macht, ist von aussen nicht sichtbar. Die Zahl dient nur dazu, im UI macht, ist von aussen nicht sichtbar. Die Zahl dient nur dazu, im UI
"da liegt wirklich etwas drin" von "leere oder falsche Datei" zu "da liegt wirklich etwas drin" von "leere oder falsche Datei" zu
@@ -106,17 +106,17 @@ def zaehle_disc_eintraege(inhalt: str) -> int:
def keydb_pruefen(inhalt: str) -> str: def keydb_pruefen(inhalt: str) -> str:
"""Prueft hochgeladenen Inhalt; gibt deutschen Fehlertext oder "" zurueck. """Prüft hochgeladenen Inhalt; gibt deutschen Fehlertext oder "" zurück.
Verhindert den haeufigsten Bedienfehler: statt der KEYDB.cfg landet die Verhindert den häufigsten Bedienfehler: statt der KEYDB.cfg landet die
HTML-Fehlerseite eines Downloads oder eine leere Datei im Verzeichnis — HTML-Fehlerseite eines Downloads oder eine leere Datei im Verzeichnis —
MakeMKV wuerde dann still weiter "volume key is unknown" melden. MakeMKV würde dann still weiter "volume key is unknown" melden.
""" """
if not inhalt.strip(): if not inhalt.strip():
return "Die Datei ist leer." return "Die Datei ist leer."
if len(inhalt.encode("utf-8")) > MAX_KEYDB_BYTES: if len(inhalt.encode("utf-8")) > MAX_KEYDB_BYTES:
return ( return (
"Die Datei ist groesser als " "Die Datei ist größer als "
f"{MAX_KEYDB_BYTES // (1024 * 1024)} MB — das ist keine KEYDB.cfg." f"{MAX_KEYDB_BYTES // (1024 * 1024)} MB — das ist keine KEYDB.cfg."
) )
if inhalt.lstrip()[:1] == "<": if inhalt.lstrip()[:1] == "<":
@@ -150,7 +150,7 @@ def keydb_status(daten_dir: str = None) -> dict:
with open(pfad, encoding="utf-8", errors="replace") as datei: with open(pfad, encoding="utf-8", errors="replace") as datei:
eintraege = zaehle_disc_eintraege(datei.read()) eintraege = zaehle_disc_eintraege(datei.read())
except OSError: except OSError:
pass # Datei da, aber unlesbar: Groesse/Datum stimmen trotzdem pass # Datei da, aber unlesbar: Größe/Datum stimmen trotzdem
return { return {
"vorhanden": True, "vorhanden": True,
"pfad": pfad, "pfad": pfad,
@@ -163,7 +163,7 @@ def keydb_status(daten_dir: str = None) -> dict:
def keydb_schreiben(inhalt: str, daten_dir: str = None) -> dict: def keydb_schreiben(inhalt: str, daten_dir: str = None) -> dict:
"""Schreibt die KEYDB.cfg atomar und meldet den neuen Zustand. """Schreibt die KEYDB.cfg atomar und meldet den neuen Zustand.
Erst in eine Nebendatei, dann os.replace: waehrend ein Rip laeuft, darf Erst in eine Nebendatei, dann os.replace: während ein Rip läuft, darf
makemkvcon niemals eine halb geschriebene Datei zu sehen bekommen. makemkvcon niemals eine halb geschriebene Datei zu sehen bekommen.
""" """
pfad = keydb_pfad(daten_dir) pfad = keydb_pfad(daten_dir)
@@ -244,32 +244,32 @@ def dumps_auflisten(daten_dir: str = None) -> list:
return liste return liste
# --- Schluesselspeicher (_private_data.tar) ------------------------------- # --- Schlüsselspeicher (_private_data.tar) -------------------------------
# #
# Das ist MakeMKVs eigener Speicher fuer die "Hashed Keys": ein tar-Archiv mit # Das ist MakeMKVs eigener Speicher für die "Hashed Keys": ein tar-Archiv mit
# hkd_*.bin-Eintraegen. Unter Windows fuellt MakeMKV es selbst (Meldung 3338), # hkd_*.bin-Eintraegen. Unter Windows füllt MakeMKV es selbst (Meldung 3338),
# unter Linux nie — siehe Modul-Kopf. Rippy nimmt die Datei deshalb entgegen # unter Linux nie — siehe Modul-Kopf. Rippy nimmt die Datei deshalb entgegen
# und legt sie ins Datenverzeichnis; MakeMKV liest sie beim naechsten Start. # und legt sie ins Datenverzeichnis; MakeMKV liest sie beim nächsten Start.
PRIVATE_DATA_NAME = "_private_data.tar" PRIVATE_DATA_NAME = "_private_data.tar"
# Der Speicher lag am 25.07.2026 bei rund 6 MB und waechst mit jeder neuen # Der Speicher lag am 25.07.2026 bei rund 6 MB und wächst mit jeder neuen
# Pressung. 64 MB sind reichlich Luft — und derselbe Wert wie # Pressung. 64 MB sind reichlich Luft — und derselbe Wert wie
# client_max_body_size in docker/ui/nginx.conf: waere die Grenze hier hoeher, # client_max_body_size in docker/ui/nginx.conf: wäre die Grenze hier höher,
# wuerde nginx den Upload abweisen, bevor die API ihn ueberhaupt sieht. # würde nginx den Upload abweisen, bevor die API ihn überhaupt sieht.
MAX_PRIVATE_DATA_BYTES = 64 * 1024 * 1024 MAX_PRIVATE_DATA_BYTES = 64 * 1024 * 1024
def private_data_pfad(daten_dir: str = None) -> str: def private_data_pfad(daten_dir: str = None) -> str:
"""Voller Pfad zum Schluesselspeicher im Datenverzeichnis.""" """Voller Pfad zum Schlüsselspeicher im Datenverzeichnis."""
return os.path.join(daten_dir or DATEN_DIR, PRIVATE_DATA_NAME) return os.path.join(daten_dir or DATEN_DIR, PRIVATE_DATA_NAME)
def zaehle_schluessel(rohdaten: bytes) -> int: def zaehle_schluessel(rohdaten: bytes) -> int:
"""Anzahl der hkd_*.bin-Eintraege im Archiv (pure Funktion, testbar). """Anzahl der hkd_*.bin-Eintraege im Archiv (pure Funktion, testbar).
Das ist die ehrliche Kennzahl fuer "wie viele Disc-Schluessel kennt diese Das ist die ehrliche Kennzahl für "wie viele Disc-Schluessel kennt diese
Installation". Ein frischer, leerer Speicher enthaelt nur eine Installation". Ein frischer, leerer Speicher enthält nur eine
Index-Datei und kommt hier auf 0 — genau der Zustand, in dem jede Index-Datei und kommt hier auf 0 — genau der Zustand, in dem jede
unbekannte UHD-Disc scheitert. unbekannte UHD-Disc scheitert.
""" """
@@ -281,20 +281,20 @@ def zaehle_schluessel(rohdaten: bytes) -> int:
def private_data_pruefen(rohdaten: bytes) -> str: def private_data_pruefen(rohdaten: bytes) -> str:
"""Prueft hochgeladene Rohdaten; deutscher Fehlertext oder "". """Prüft hochgeladene Rohdaten; deutscher Fehlertext oder "".
Faengt die beiden Bedienfehler ab, die sonst still danebengehen: eine Faengt die beiden Bedienfehler ab, die sonst still danebengehen: eine
voellig andere Datei hochladen, oder den Speicher einer Installation, die voellig andere Datei hochladen, oder den Speicher einer Installation, die
selbst noch keine Schluessel geholt hat (dann aendert sich nichts, und selbst noch keine Schluessel geholt hat (dann ändert sich nichts, und
niemand versteht warum). niemand versteht warum).
""" """
if not rohdaten: if not rohdaten:
return "Die Datei ist leer." return "Die Datei ist leer."
if len(rohdaten) > MAX_PRIVATE_DATA_BYTES: if len(rohdaten) > MAX_PRIVATE_DATA_BYTES:
return ( return (
"Die Datei ist groesser als " "Die Datei ist größer als "
f"{MAX_PRIVATE_DATA_BYTES // (1024 * 1024)} MB — das ist kein " f"{MAX_PRIVATE_DATA_BYTES // (1024 * 1024)} MB — das ist kein "
"MakeMKV-Schluesselspeicher." "MakeMKV-Schlüsselspeicher."
) )
try: try:
with tarfile.open(fileobj=io.BytesIO(rohdaten)) as archiv: with tarfile.open(fileobj=io.BytesIO(rohdaten)) as archiv:
@@ -308,13 +308,13 @@ def private_data_pruefen(rohdaten: bytes) -> str:
return ( return (
"In dieser Datei steckt kein einziger Schluessel (kein hkd_*.bin). " "In dieser Datei steckt kein einziger Schluessel (kein hkd_*.bin). "
"Sie stammt vermutlich von einer MakeMKV-Installation, die selbst " "Sie stammt vermutlich von einer MakeMKV-Installation, die selbst "
"noch keine geholt hat — oeffne dort erst einmal eine Disc." "noch keine geholt hat — öffne dort erst einmal eine Disc."
) )
return "" return ""
def schluesselspeicher_status(daten_dir: str = None) -> dict: def schluesselspeicher_status(daten_dir: str = None) -> dict:
"""Zustand des Schluesselspeichers. Fehlt er, ist das kein Fehler.""" """Zustand des Schlüsselspeichers. Fehlt er, ist das kein Fehler."""
pfad = private_data_pfad(daten_dir) pfad = private_data_pfad(daten_dir)
try: try:
angaben = os.stat(pfad) angaben = os.stat(pfad)
@@ -338,9 +338,9 @@ def schluesselspeicher_status(daten_dir: str = None) -> dict:
def private_data_schreiben(rohdaten: bytes, daten_dir: str = None) -> dict: def private_data_schreiben(rohdaten: bytes, daten_dir: str = None) -> dict:
"""Legt den Schluesselspeicher atomar ab und meldet den neuen Zustand. """Legt den Schlüsselspeicher atomar ab und meldet den neuen Zustand.
Atomar aus demselben Grund wie bei der KEYDB.cfg: waehrend ein Rip laeuft, Atomar aus demselben Grund wie bei der KEYDB.cfg: während ein Rip läuft,
darf makemkvcon nie ein halb geschriebenes Archiv sehen. darf makemkvcon nie ein halb geschriebenes Archiv sehen.
""" """
pfad = private_data_pfad(daten_dir) pfad = private_data_pfad(daten_dir)
@@ -363,9 +363,9 @@ def settings_conf_zusammenfuehren(inhalt: str, key: str) -> str:
"""app_Key setzen, ohne den Rest der settings.conf zu verlieren (pure). """app_Key setzen, ohne den Rest der settings.conf zu verlieren (pure).
Bis zum 25.07.2026 haben entrypoint.sh UND tasks.py die Datei komplett Bis zum 25.07.2026 haben entrypoint.sh UND tasks.py die Datei komplett
ueberschrieben. Mit dem jetzt persistenten Datenverzeichnis waere damit überschrieben. Mit dem jetzt persistenten Datenverzeichnis wäre damit
bei jedem Containerstart und vor jedem Rip alles andere weg — z. B. bei jedem Containerstart und vor jedem Rip alles andere weg — z. B.
app_UpdateEnable. Leerer Key laesst die vorhandene Zeile ebenfalls fallen, app_UpdateEnable. Leerer Key lässt die vorhandene Zeile ebenfalls fallen,
damit ein bewusst geleerter Key nicht heimlich weiterwirkt. damit ein bewusst geleerter Key nicht heimlich weiterwirkt.
""" """
zeilen = [z for z in inhalt.splitlines() if not z.lstrip().startswith("app_Key")] zeilen = [z for z in inhalt.splitlines() if not z.lstrip().startswith("app_Key")]
+9 -9
View File
@@ -1,22 +1,22 @@
"""Automatische Erneuerung des MakeMKV-Beta-Keys. """Automatische Erneuerung des MakeMKV-Beta-Keys.
Der kostenlose MakeMKV-Beta-Key (Forum-Thread "MakeMKV is free while in beta", Der kostenlose MakeMKV-Beta-Key (Forum-Thread "MakeMKV is free while in beta",
t=1053) wechselt etwa monatlich und laeuft zum Monatsende ab. Ohne gueltigen Key t=1053) wechselt etwa monatlich und läuft zum Monatsende ab. Ohne gültigen Key
faellt Blu-ray/UHD-Ripping in den 30-Tage-Testmodus; DVD-Ripping bleibt frei. fällt Blu-ray/UHD-Ripping in den 30-Tage-Testmodus; DVD-Ripping bleibt frei.
Bisher musste der Key von Hand nachgetragen werden (UI: Einstellungen -> System). Bisher musste der Key von Hand nachgetragen werden (UI: Einstellungen -> System).
Dieses Modul holt den aktuellen Key vom Forum und schreibt ihn in die Settings Dieses Modul holt den aktuellen Key vom Forum und schreibt ihn in die Settings
(makemkvAppKey). Der Worker liest den Key VOR jedem Rip aus den Settings (makemkvAppKey). Der Worker liest den Key VOR jedem Rip aus den Settings
(docker/worker/tasks.py) und schreibt settings.conf -> die Erneuerung wirkt ohne (docker/worker/tasks.py) und schreibt settings.conf -> die Erneuerung wirkt ohne
Rebuild/Neustart, ab dem naechsten Rip. Rebuild/Neustart, ab dem nächsten Rip.
Wichtig: Das ist die kostenlose, oeffentliche Beta-LIZENZ der Software selbst — Wichtig: Das ist die kostenlose, oeffentliche Beta-LIZENZ der Software selbst —
NICHT das Entschluesseln oder Verteilen von Disc-Schluesseln. Diese Grenze gilt NICHT das Entschluesseln oder Verteilen von Disc-Schluesseln. Diese Grenze gilt
unveraendert; Rippy liefert keine Disc-Schluessel mit und verteilt keine. unveraendert; Rippy liefert keine Disc-Schluessel mit und verteilt keine.
Richtigstellung 25.07.2026: Hier stand frueher, MakeMKV ziehe den AACS-Schluessel Richtigstellung 25.07.2026: Hier stand früher, MakeMKV ziehe den AACS-Schluessel
via LibreDrive ohnehin selbst aus dem Laufwerk. Das stimmt fuer Blu-ray, aber via LibreDrive ohnehin selbst aus dem Laufwerk. Das stimmt für Blu-ray, aber
NICHT fuer 4K-UHD — dort braucht MakeMKV den Volume-Key der jeweiligen Pressung, NICHT für 4K-UHD — dort braucht MakeMKV den Volume-Key der jeweiligen Pressung,
und den bekommt es weder aus dem Laufwerk noch (heute) aus dem Netz. Belege und und den bekommt es weder aus dem Laufwerk noch (heute) aus dem Netz. Belege und
Messungen stehen im Modul-Kopf von makemkv_daten.py. Messungen stehen im Modul-Kopf von makemkv_daten.py.
@@ -41,14 +41,14 @@ def extract_key(html: str) -> str | None:
def fetch_current_key(timeout: int = 15) -> str | None: def fetch_current_key(timeout: int = 15) -> str | None:
"""Holt den aktuellen Beta-Key vom MakeMKV-Forum. None bei Fehler/kein Treffer.""" """Holt den aktuellen Beta-Key vom MakeMKV-Forum. None bei Fehler/kein Treffer."""
import requests # lazy: haelt extract_key + den Test dependency-frei import requests # lazy: hält extract_key + den Test dependency-frei
antwort = requests.get(FORUM_URL, headers=_HEADERS, timeout=timeout) antwort = requests.get(FORUM_URL, headers=_HEADERS, timeout=timeout)
antwort.raise_for_status() antwort.raise_for_status()
return extract_key(antwort.text) return extract_key(antwort.text)
def _apply_key(key: str) -> bool: def _apply_key(key: str) -> bool:
"""Merge den Key in die Settings. save_settings ueberschreibt das GANZE JSON, """Merge den Key in die Settings. save_settings überschreibt das GANZE JSON,
darum erst lesen, dann setzen. Rueckgabe True, wenn sich der Key geaendert hat.""" darum erst lesen, dann setzen. Rueckgabe True, wenn sich der Key geaendert hat."""
import db import db
einstellungen = db.get_settings() einstellungen = db.get_settings()
@@ -76,7 +76,7 @@ async def refresh_once() -> bool:
async def refresh_loop(intervall_stunden: int = 24, start_verzoegerung_s: int = 60) -> None: async def refresh_loop(intervall_stunden: int = 24, start_verzoegerung_s: int = 60) -> None:
"""Periodische Erneuerung (Default: taeglich, damit ein Monatswechsel nie verpasst """Periodische Erneuerung (Default: taeglich, damit ein Monatswechsel nie verpasst
wird). Fehler werden geloggt, aber nie geworfen -> die API laeuft weiter.""" wird). Fehler werden geloggt, aber nie geworfen -> die API läuft weiter."""
import db import db
await asyncio.sleep(start_verzoegerung_s) await asyncio.sleep(start_verzoegerung_s)
while True: while True:
+3 -3
View File
@@ -76,11 +76,11 @@ def schreibtest(pfad: str) -> bool:
def _stale_mounts_loesen(ziel: str) -> int: def _stale_mounts_loesen(ziel: str) -> int:
"""Loest ALLE (evtl. gestapelten/toten) Mounts an `ziel` per lazy umount. """Loest ALLE (evtl. gestapelten/toten) Mounts an `ziel` per lazy umount.
Warum: mounten() faellt bei einem TOTEN Mount (os.path.ismount wirft OSError) Warum: mounten() fällt bei einem TOTEN Mount (os.path.ismount wirft OSError)
auf den echten `mount` durch — der stapelt dann auf die Leiche. Vorfall 24.07.: auf den echten `mount` durch — der stapelt dann auf die Leiche. Vorfall 24.07.:
ueber viele Neustarts 12 Schichten, die tote oberste blockierte jeden Zugriff über viele Neustarts 12 Schichten, die tote oberste blockierte jeden Zugriff
(ls-Timeout, obwohl SMB-445 offen). Erst alle Schichten loesen macht das (ls-Timeout, obwohl SMB-445 offen). Erst alle Schichten loesen macht das
Re-Mounten idempotent. `umount -l` (lazy) haengt nicht an einem toten CIFS. Re-Mounten idempotent. `umount -l` (lazy) hängt nicht an einem toten CIFS.
Rueckgabe: Zahl der geloesten Schichten. Rueckgabe: Zahl der geloesten Schichten.
""" """
geloest = 0 geloest = 0
+9 -9
View File
@@ -24,8 +24,8 @@ def test_main_importierbar_und_routen_verdrahtet():
# Schlüsseldatei (Befund 25.07.2026 — MakeMKV holt UHD-Schlüssel nicht # Schlüsseldatei (Befund 25.07.2026 — MakeMKV holt UHD-Schlüssel nicht
# mehr online nach). Ohne diese Routen ist die Seite im UI tot. # mehr online nach). Ohne diese Routen ist die Seite im UI tot.
"/system/keydb", "/system/aacs-dumps", "/system/aacs-dumps/{dateiname}", "/system/keydb", "/system/aacs-dumps", "/system/aacs-dumps/{dateiname}",
# Der Hauptweg fuer 4K-UHD (25.07.2026): makemkvcon holt Disc-Schluessel # Der Hauptweg für 4K-UHD (25.07.2026): makemkvcon holt Disc-Schluessel
# unter Linux nie selbst, sie kommen von Hand ueber diesen Endpunkt. # unter Linux nie selbst, sie kommen von Hand über diesen Endpunkt.
"/system/keystore", "/system/keystore",
): ):
assert pfad in routen, f"Route {pfad} fehlt" assert pfad in routen, f"Route {pfad} fehlt"
@@ -69,10 +69,10 @@ def test_remount_blockiert_den_api_start_nicht():
def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein(): def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein():
"""Befund 25.07.2026: In main.py prueften neun Stellen mit nacktem """Befund 25.07.2026: In main.py prüften neun Stellen mit nacktem
startswith(MEDIA_ROOT) — darunter /browse und /browse/mkdir, wo der Pfad startswith(MEDIA_ROOT) — darunter /browse und /browse/mkdir, wo der Pfad
vom Nutzer kommt. „/app/media-boese/x" beginnt mit „/app/media", liegt vom Nutzer kommt. „/app/media-boese/x" beginnt mit „/app/media", liegt
aber ausserhalb. Zwilling von tasks.unter_wurzel im Worker.""" aber außerhalb. Zwilling von tasks.unter_wurzel im Worker."""
from main import unter_wurzel from main import unter_wurzel
assert unter_wurzel("/app/media", "/app/media") is True assert unter_wurzel("/app/media", "/app/media") is True
@@ -87,9 +87,9 @@ def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein():
def test_ping_vorrat_verhindert_die_wartesekunde(monkeypatch): def test_ping_vorrat_verhindert_die_wartesekunde(monkeypatch):
"""Gemessen 25.07.2026: /capabilities brauchte 1,010 s - jedes Mal. Der """Gemessen 25.07.2026: /capabilities brauchte 1,010 s - jedes Mal. Der
Celery-Ping sammelt Antworten bis zum Timeout und kann nicht frueher Celery-Ping sammelt Antworten bis zum Timeout und kann nicht früher
aufhoeren. Fuenf UI-Stellen holen /capabilities, also zahlte jede Seite aufhoeren. Fuenf UI-Stellen holen /capabilities, also zahlte jede Seite
eine Sekunde, waehrend alle anderen Endpunkte unter 25 ms lagen. eine Sekunde, während alle anderen Endpunkte unter 25 ms lagen.
Der Vorrat muss deshalb abgelesen und NICHT neu gepingt werden, solange er Der Vorrat muss deshalb abgelesen und NICHT neu gepingt werden, solange er
frisch ist - und bei altem Vorrat lieber einmal langsam als falsch. frisch ist - und bei altem Vorrat lieber einmal langsam als falsch.
@@ -119,7 +119,7 @@ def test_ping_vorrat_verhindert_die_wartesekunde(monkeypatch):
def test_ping_takt_ist_kuerzer_als_die_haltbarkeit(): def test_ping_takt_ist_kuerzer_als_die_haltbarkeit():
"""Sonst laeuft der Vorrat zwischen zwei Hintergrund-Laeufen ab und der """Sonst läuft der Vorrat zwischen zwei Hintergrund-Laeufen ab und der
Endpunkt pingt doch wieder synchron.""" Endpunkt pingt doch wieder synchron."""
import main import main
@@ -127,11 +127,11 @@ def test_ping_takt_ist_kuerzer_als_die_haltbarkeit():
def test_tote_routen_sind_und_bleiben_weg(): def test_tote_routen_sind_und_bleiben_weg():
"""Entfernt am 25.07.2026, jede ein Ueberrest eines ersetzten Entwurfs und """Entfernt am 25.07.2026, jede ein Überrest eines ersetzten Entwurfs und
ohne einen einzigen Aufrufer (mechanisch gegengeprueft: alle api.*-Aufrufe ohne einen einzigen Aufrufer (mechanisch gegengeprueft: alle api.*-Aufrufe
des UI gegen alle Routen). des UI gegen alle Routen).
Der Test haelt sie draussen. /stream/jobs ist der Grund fuer diese 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 Absicherung: er war schon einmal ein Placebo, wurde dann "repariert" statt
entfernt - und war danach eine Endlosschleife je Verbindung ohne jeden entfernt - und war danach eine Endlosschleife je Verbindung ohne jeden
Verbraucher. Wer echtes Push will, braucht BEIDE Seiten (Server UND ein Verbraucher. Wer echtes Push will, braucht BEIDE Seiten (Server UND ein
+11 -11
View File
@@ -1,4 +1,4 @@
"""Tests fuer die puren Helfer aus makemkv_daten. """Tests für die puren Helfer aus makemkv_daten.
Bewusst OHNE Dateisystem, DB und fcntl — deshalb laufen sie auch auf Windows Bewusst OHNE Dateisystem, DB und fcntl — deshalb laufen sie auch auf Windows
und nicht nur in der Ampel. Geprueft wird genau das, was ohne Container und und nicht nur in der Ampel. Geprueft wird genau das, was ohne Container und
@@ -24,8 +24,8 @@ _GUELTIG = """; KEYDB.cfg — Beispiel
def test_zaehle_disc_eintraege_zaehlt_nur_echte_disc_zeilen(): def test_zaehle_disc_eintraege_zaehlt_nur_echte_disc_zeilen():
# Kommentar- und Leerzeilen duerfen NICHT mitgezaehlt werden, sonst meldet # Kommentar- und Leerzeilen dürfen NICHT mitgezaehlt werden, sonst meldet
# das UI "da liegt was drin", obwohl die Datei keinen Schluessel enthaelt. # das UI "da liegt was drin", obwohl die Datei keinen Schluessel enthält.
assert zaehle_disc_eintraege(_GUELTIG) == 2 assert zaehle_disc_eintraege(_GUELTIG) == 2
@@ -51,8 +51,8 @@ def test_keydb_pruefen_meldet_leere_datei():
def test_keydb_pruefen_erkennt_html(): def test_keydb_pruefen_erkennt_html():
# Haeufigster Bedienfehler: statt der Datei landet die HTML-Fehlerseite # Häufigster Bedienfehler: statt der Datei landet die HTML-Fehlerseite
# eines Downloads im Feld. MakeMKV wuerde dann still weiter meckern. # eines Downloads im Feld. MakeMKV würde dann still weiter meckern.
fehler = keydb_pruefen("<!DOCTYPE html>\n<html><body>404 Not Found</body></html>\n") fehler = keydb_pruefen("<!DOCTYPE html>\n<html><body>404 Not Found</body></html>\n")
assert "HTML" in fehler assert "HTML" in fehler
@@ -63,19 +63,19 @@ def test_keydb_pruefen_meldet_datei_ohne_disc_zeile():
def test_keydb_pruefen_laesst_gueltige_datei_durch(): def test_keydb_pruefen_laesst_gueltige_datei_durch():
# "" heisst laut Vertrag: alles in Ordnung, darf geschrieben werden. # "" heißt laut Vertrag: alles in Ordnung, darf geschrieben werden.
assert keydb_pruefen(_GUELTIG) == "" assert keydb_pruefen(_GUELTIG) == ""
def test_ist_aacs_dump_erkennt_echten_namen(): def test_ist_aacs_dump_erkennt_echten_namen():
# So heisst der Dump, den MakeMKV am 25.07.2026 fuer Akira UHD abgelegt hat # So heißt der Dump, den MakeMKV am 25.07.2026 für Akira UHD abgelegt hat
# (Meldung 3332) — dieser Name MUSS zum Download durchkommen. # (Meldung 3332) — dieser Name MUSS zum Download durchkommen.
assert ist_aacs_dump("MKB20_v76_UHD_AKIRA_C02B.tgz") is True assert ist_aacs_dump("MKB20_v76_UHD_AKIRA_C02B.tgz") is True
def test_ist_aacs_dump_blockt_pfad_tricks(): def test_ist_aacs_dump_blockt_pfad_tricks():
# Der Download-Endpunkt haengt den Namen an das Datenverzeichnis — ein # Der Download-Endpunkt hängt den Namen an das Datenverzeichnis — ein
# durchgelassenes ".." oder ein Pfadtrenner waere ein Ausbruch. # durchgelassenes ".." oder ein Pfadtrenner wäre ein Ausbruch.
assert ist_aacs_dump("../x.tgz") is False assert ist_aacs_dump("../x.tgz") is False
assert ist_aacs_dump("../../etc/passwd.tgz") is False assert ist_aacs_dump("../../etc/passwd.tgz") is False
assert ist_aacs_dump("unter/ordner.tgz") is False assert ist_aacs_dump("unter/ordner.tgz") is False
@@ -93,8 +93,8 @@ def test_ist_aacs_dump_lehnt_andere_endungen_ab():
def test_settings_conf_ersetzt_alten_key_und_behaelt_den_rest(): def test_settings_conf_ersetzt_alten_key_und_behaelt_den_rest():
# Regression: bis 25.07.2026 wurde die Datei komplett ueberschrieben. Mit # Regression: bis 25.07.2026 wurde die Datei komplett überschrieben. Mit
# dem jetzt persistenten Datenverzeichnis waere app_UpdateEnable vor jedem # dem jetzt persistenten Datenverzeichnis wäre app_UpdateEnable vor jedem
# Rip weg gewesen. # Rip weg gewesen.
alt = 'app_UpdateEnable = "1"\napp_Key = "T-alt"\napp_DestinationDir = "/tmp"\n' alt = 'app_UpdateEnable = "1"\napp_Key = "T-alt"\napp_DestinationDir = "/tmp"\n'
neu = settings_conf_zusammenfuehren(alt, "T-neu") neu = settings_conf_zusammenfuehren(alt, "T-neu")
+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 from makemkv_key import extract_key
# Beispiel-Key im echten Format (T- + 64 Zeichen [A-Za-z0-9_]). # Beispiel-Key im echten Format (T- + 64 Zeichen [A-Za-z0-9_]).
@@ -16,12 +16,12 @@ def test_extract_key_ohne_key_gibt_none():
def test_extract_key_zu_kurz_kein_treffer(): def test_extract_key_zu_kurz_kein_treffer():
# Format ist streng T- + genau 64 Zeichen — zu kurz faellt raus. # Format ist streng T- + genau 64 Zeichen — zu kurz fällt raus.
assert extract_key("T-zukurz123") is None assert extract_key("T-zukurz123") is None
def test_extract_key_liefert_ganzen_key(): def test_extract_key_liefert_ganzen_key():
# Regression: die Regex darf den Key NICHT abschneiden (Beta-Keys sind laenger # Regression: die Regex darf den Key NICHT abschneiden (Beta-Keys sind länger
# als die im Forum genannten 64 Zeichen — hier der volle Key zwischen Text). # als die im Forum genannten 64 Zeichen — hier der volle Key zwischen Text).
treffer = extract_key(f"Vorher-Text {_KEY} Nachher-Text") treffer = extract_key(f"Vorher-Text {_KEY} Nachher-Text")
assert treffer == _KEY assert treffer == _KEY
+7 -7
View File
@@ -37,12 +37,12 @@ const ENCODER_LABELS: Record<string, string> = {
// braucht roh rund 40 GB, eine 4K-UHD bis 100 GB. // braucht roh rund 40 GB, eine 4K-UHD bis 100 GB.
const PLATZ_WARNUNG_GB = 60 const PLATZ_WARNUNG_GB = 60
/** Was beim Abschliessen gespeichert wird — abhaengig davon, was die Maschine kann. */ /** Was beim Abschliessen gespeichert wird — abhängig davon, was die Maschine kann. */
function presetsFuer(schwach: boolean) { function presetsFuer(schwach: boolean) {
if (schwach) { if (schwach) {
// Gemessen auf einer CPU ohne AVX2: 4K-HEVC = 28-55 h pro Film. Also 4K // Gemessen auf einer CPU ohne AVX2: 4K-HEVC = 28-55 h pro Film. Also 4K
// verlustfrei behalten und fuer den Rest H.264 nehmen (deutlich schneller // verlustfrei behalten und für den Rest H.264 nehmen (deutlich schneller
// als H.265, dafuer etwas groesser). // als H.265, dafür etwas größer).
return { return {
transcodePreset: 'HQ 1080p30 Surround', transcodePreset: 'HQ 1080p30 Surround',
transcodePresetBluray: 'HQ 1080p30 Surround', transcodePresetBluray: 'HQ 1080p30 Surround',
@@ -72,7 +72,7 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
const [keyWarnung, setKeyWarnung] = useState<string | null>(null) const [keyWarnung, setKeyWarnung] = useState<string | null>(null)
// Wiederholt laden, bis Worker UND Laufwerk da sind: beim allerersten Start // Wiederholt laden, bis Worker UND Laufwerk da sind: beim allerersten Start
// laeuft der Worker-Container oft noch hoch. Ohne das stand hier dauerhaft // 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. // „Noch kein Worker gemeldet" und der Nutzer wusste nicht, ob er warten soll.
useEffect(() => { useEffect(() => {
let laeuft = true let laeuft = true
@@ -112,8 +112,8 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
transcodeEnabled, transcodeEnabled,
...presets, ...presets,
}) })
// Erst SPEICHERN, dann pruefen: /metadata/status liest die Keys aus der // Erst SPEICHERN, dann prüfen: /metadata/status liest die Keys aus der
// Datenbank, nicht aus dem Formular. Ein falsch kopierter Key faellt so // Datenbank, nicht aus dem Formular. Ein falsch kopierter Key fällt so
// sofort auf, statt erst beim ersten Rip als „Unknown Disc". // sofort auf, statt erst beim ersten Rip als „Unknown Disc".
if (tmdbKey.trim() || omdbKey.trim()) { if (tmdbKey.trim() || omdbKey.trim()) {
try { try {
@@ -131,7 +131,7 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
return // nicht wegklicken, damit die Warnung gelesen wird return // nicht wegklicken, damit die Warnung gelesen wird
} }
} catch { } catch {
// Pruefung selbst kaputt → kein Grund, die Einrichtung zu blockieren // Prüfung selbst kaputt → kein Grund, die Einrichtung zu blockieren
} }
} }
await api.post('/setup/complete') await api.post('/setup/complete')
@@ -88,7 +88,7 @@ export default function WorkerVerwaltung() {
: [ : [
// Standard-Ziel ist "C:\Program Files\Rippy Worker" - dorthin darf nur // Standard-Ziel ist "C:\Program Files\Rippy Worker" - dorthin darf nur
// ein Administrator schreiben, deshalb der Hinweis direkt im Befehl. // ein Administrator schreiben, deshalb der Hinweis direkt im Befehl.
// Die .exe fragt selbst per UAC; hier muss die Konsole erhoeht sein. // Die .exe fragt selbst per UAC; hier muss die Konsole erhöht sein.
`# PowerShell als Administrator starten (Standard-Ziel: Programme)`, `# PowerShell als Administrator starten (Standard-Ziel: Programme)`,
`iwr http://${hostFuerBefehl}/api/worker-setup/windows -OutFile install-rippy-worker.ps1`, `iwr http://${hostFuerBefehl}/api/worker-setup/windows -OutFile install-rippy-worker.ps1`,
`powershell -ExecutionPolicy Bypass -File .\\install-rippy-worker.ps1 \``, `powershell -ExecutionPolicy Bypass -File .\\install-rippy-worker.ps1 \``,
+19 -19
View File
@@ -72,8 +72,8 @@ interface WorkerInfo {
encoders: string[] encoders: string[]
// keydb: "ja" | "nein" | "unbekannt" — sagt, ob DIESER Worker eine KEYDB.cfg // keydb: "ja" | "nein" | "unbekannt" — sagt, ob DIESER Worker eine KEYDB.cfg
// in seinem MakeMKV-Datenverzeichnis sieht. Nur der Worker, der wirklich // in seinem MakeMKV-Datenverzeichnis sieht. Nur der Worker, der wirklich
// rippt, zaehlt, deshalb steht die Angabe pro Worker und nicht global. // rippt, zählt, deshalb steht die Angabe pro Worker und nicht global.
// cpu_kerne/cpu_simd: Rechenleistung fuers Software-Encodieren. Ohne diese // 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 // 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). // Rippy-VM Tage statt Stunden braucht (kein AVX2, 4 Kerne).
// handbrake_encoder: die ungefilterte Encoder-Auskunft von HandBrake selbst. // handbrake_encoder: die ungefilterte Encoder-Auskunft von HandBrake selbst.
@@ -101,7 +101,7 @@ interface StorageZiel {
free_gb: number | null free_gb: number | null
} }
// Antwort von GET/POST /system/keystore — MakeMKVs eigener Schluesselvorrat. // Antwort von GET/POST /system/keystore — MakeMKVs eigener Schlüsselvorrat.
interface KeystoreStatus { interface KeystoreStatus {
vorhanden: boolean vorhanden: boolean
pfad: string pfad: string
@@ -125,7 +125,7 @@ interface SystemInfo {
webhook_gesetzt: boolean webhook_gesetzt: boolean
} }
// Byte-Zahl menschenlesbar — eine echte KEYDB.cfg ist mehrere MB gross. // Byte-Zahl menschenlesbar — eine echte KEYDB.cfg ist mehrere MB groß.
function bytesLesbar(bytes: number): string { function bytesLesbar(bytes: number): string {
if (!bytes) return '0 B' if (!bytes) return '0 B'
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB` if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
@@ -134,7 +134,7 @@ function bytesLesbar(bytes: number): string {
} }
// Die API liefert ISO-8601 in UTC (oder einen leeren String). Ohne Zonen-Kennung // Die API liefert ISO-8601 in UTC (oder einen leeren String). Ohne Zonen-Kennung
// wuerde der Browser den Zeitstempel als Ortszeit lesen und die Uhrzeit um den // würde der Browser den Zeitstempel als Ortszeit lesen und die Uhrzeit um den
// Zonen-Versatz verschieben — deshalb notfalls ein Z anhaengen. // Zonen-Versatz verschieben — deshalb notfalls ein Z anhaengen.
function zeitLesbar(iso: string): string { function zeitLesbar(iso: string): string {
if (!iso) return 'unbekannt' if (!iso) return 'unbekannt'
@@ -158,7 +158,7 @@ export default function SettingsPage() {
const [updatesBusy, setUpdatesBusy] = useState(false) const [updatesBusy, setUpdatesBusy] = useState(false)
// KEYDB.cfg und AACS-Dumps sind DATEIEN im MakeMKV-Datenverzeichnis, keine // KEYDB.cfg und AACS-Dumps sind DATEIEN im MakeMKV-Datenverzeichnis, keine
// Einstellungen — deshalb bewusst NICHT in SettingsState: der globale // Einstellungen — deshalb bewusst NICHT in SettingsState: der globale
// Speichern-Knopf postet dieses Objekt komplett und wuerde sie mitschleifen. // Speichern-Knopf postet dieses Objekt komplett und würde sie mitschleifen.
const [keydb, setKeydb] = useState<KeydbStatus | null>(null) const [keydb, setKeydb] = useState<KeydbStatus | null>(null)
const [keydbBusy, setKeydbBusy] = useState(false) const [keydbBusy, setKeydbBusy] = useState(false)
const [dumps, setDumps] = useState<AacsDump[]>([]) const [dumps, setDumps] = useState<AacsDump[]>([])
@@ -200,7 +200,7 @@ export default function SettingsPage() {
// letzten Herzschlag des Workers (minuetlich) — ohne dieses Nachladen // letzten Herzschlag des Workers (minuetlich) — ohne dieses Nachladen
// widerspraeche die Kachel nach einem Upload sichtbar der Erfolgsmeldung. // widerspraeche die Kachel nach einem Upload sichtbar der Erfolgsmeldung.
// Ganz sofort ist sie trotzdem nicht: der Worker meldet erst beim // Ganz sofort ist sie trotzdem nicht: der Worker meldet erst beim
// naechsten Herzschlag neu. Genau so steht es auch im Erklaertext. // nächsten Herzschlag neu. Genau so steht es auch im Erklaertext.
api.get('/capabilities').then(r => setWorkers(r.data.workers || [])).catch(() => setWorkers([])) api.get('/capabilities').then(r => setWorkers(r.data.workers || [])).catch(() => setWorkers([]))
} }
@@ -208,7 +208,7 @@ export default function SettingsPage() {
setKeydbBusy(true) setKeydbBusy(true)
try { try {
// Bewusst als JSON-Text und nicht als Multipart-Upload: der API fehlt // Bewusst als JSON-Text und nicht als Multipart-Upload: der API fehlt
// python-multipart, ein File()-Endpunkt wuerde sie beim Import killen. // python-multipart, ein File()-Endpunkt würde sie beim Import killen.
const inhalt = await datei.text() const inhalt = await datei.text()
await api.post('/system/keydb', { inhalt }) await api.post('/system/keydb', { inhalt })
keydbLaden() keydbLaden()
@@ -436,9 +436,9 @@ export default function SettingsPage() {
{/* Der Schalter „Alle Tracks rippen" ist am 25.07.2026 entfallen. {/* Der Schalter „Alle Tracks rippen" ist am 25.07.2026 entfallen.
Er war ein Placebo: abcde bekommt keine Track-Auswahl (siehe Er war ein Placebo: abcde bekommt keine Track-Auswahl (siehe
build_abcde_cmd) und es gibt auch keine Oberflaeche, um eine build_abcde_cmd) und es gibt auch keine Oberfläche, um eine
Teilmenge zu waehlen — eine Audio-CD wurde also immer Teilmenge zu wählen — eine Audio-CD wurde also immer
vollstaendig gerippt, egal wie der Schalter stand. Statt einer vollständig gerippt, egal wie der Schalter stand. Statt einer
Wahl, die nichts bewirkt, steht hier jetzt die Wahrheit. */} Wahl, die nichts bewirkt, steht hier jetzt die Wahrheit. */}
<p className="text-xs text-slate-500 dark:text-slate-400 px-1"> <p className="text-xs text-slate-500 dark:text-slate-400 px-1">
💿 Eine Audio-CD wird immer vollständig gerippt alle Tracks als 💿 Eine Audio-CD wird immer vollständig gerippt alle Tracks als
@@ -892,9 +892,9 @@ export default function SettingsPage() {
</span> </span>
)} )}
{/* {/*
Nur das Verzeichnis des rippenden Workers zaehlt — deshalb je Worker. Nur das Verzeichnis des rippenden Workers zählt — deshalb je Worker.
Die Zahl der Disc-Schluessel ist die entscheidende Angabe fuer 4K-UHD: Die Zahl der Disc-Schluessel ist die entscheidende Angabe für 4K-UHD:
0 heisst, dass JEDE unbekannte UHD-Disc scheitert. 0 heißt, dass JEDE unbekannte UHD-Disc scheitert.
*/} */}
{w.info?.schluessel && w.info.schluessel !== '0' && w.info.schluessel !== 'unbekannt' && ( {w.info?.schluessel && w.info.schluessel !== '0' && w.info.schluessel !== 'unbekannt' && (
<span className="text-xs px-2.5 py-0.5 rounded-md font-medium bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30"> <span className="text-xs px-2.5 py-0.5 rounded-md font-medium bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30">
@@ -919,7 +919,7 @@ export default function SettingsPage() {
Disc-Schluessel-Datenbank mit. Nachgemessen: falsch — MakeMKV liefert Disc-Schluessel-Datenbank mit. Nachgemessen: falsch — MakeMKV liefert
gar keine Schluessel mit, es holt sie zur Laufzeit. Und die gar keine Schluessel mit, es holt sie zur Laufzeit. Und die
Linux-Version holt sie nie (kein einziger Verbindungsversuch, auf Linux-Version holt sie nie (kein einziger Verbindungsversuch, auf
beiden Maschinen verglichen). Deshalb der Schluesselspeicher unten. beiden Maschinen verglichen). Deshalb der Schlüsselspeicher unten.
*/} */}
<p className="text-xs mt-3 pt-3 border-t border-slate-200/60 dark:border-slate-800 text-slate-500 dark:text-slate-400"> <p className="text-xs mt-3 pt-3 border-t border-slate-200/60 dark:border-slate-800 text-slate-500 dark:text-slate-400">
<strong className="text-slate-700 dark:text-slate-300">MakeMKV</strong> lässt sich aktuell halten <strong className="text-slate-700 dark:text-slate-300">MakeMKV</strong> lässt sich aktuell halten
@@ -943,7 +943,7 @@ export default function SettingsPage() {
/> />
{/* {/*
Schluesselspeicher — der Hauptweg fuer 4K-UHD (Befund 25.07.2026, Schlüsselspeicher — der Hauptweg für 4K-UHD (Befund 25.07.2026,
auf beiden Maschinen gemessen): makemkvcon holt Disc-Schluessel auf beiden Maschinen gemessen): makemkvcon holt Disc-Schluessel
unter Linux NIE selbst, die Windows-Version tut es (Meldung 3338, unter Linux NIE selbst, die Windows-Version tut es (Meldung 3338,
Verbindung nach 185.84.108.20:443). Deshalb wird der Vorrat hier Verbindung nach 185.84.108.20:443). Deshalb wird der Vorrat hier
@@ -1019,8 +1019,8 @@ export default function SettingsPage() {
{/* {/*
KEYDB.cfg — der NOTNAGEL, nicht der Hauptweg. Sie hilft bei Pressungen, KEYDB.cfg — der NOTNAGEL, nicht der Hauptweg. Sie hilft bei Pressungen,
die auch MakeMKV selbst nicht kennt. Der Regelfall laeuft ueber den die auch MakeMKV selbst nicht kennt. Der Regelfall läuft über den
Schluesselspeicher im Block darueber. Dateiname GROSS geschrieben, unter Schlüsselspeicher im Block darüber. Dateiname GROSS geschrieben, unter
Linux case-sensitiv. Rippy stellt nur den Platz bereit. Linux case-sensitiv. Rippy stellt nur den Platz bereit.
*/} */}
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3"> <div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3">
@@ -1107,7 +1107,7 @@ export default function SettingsPage() {
{/* {/*
AACS-Dumps: MakeMKV legt sie bei einer unbekannten UHD-Disc ab (Meldung 3332). AACS-Dumps: MakeMKV legt sie bei einer unbekannten UHD-Disc ab (Meldung 3332).
Seit das Datenverzeichnis persistent gemountet ist, ueberleben sie den Seit das Datenverzeichnis persistent gemountet ist, überleben sie den
Container-Neustart — vorher waren sie nach jedem Rip weg. Container-Neustart — vorher waren sie nach jedem Rip weg.
*/} */}
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3"> <div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3">
+1 -1
View File
@@ -203,7 +203,7 @@ def zaehle_online_worker(sekunden: int = 120) -> int:
Die Zombie-Erkennung vergleicht das mit der Zahl der Celery-Antworten: Die Zombie-Erkennung vergleicht das mit der Zahl der Celery-Antworten:
melden sich weniger Worker als bekannt sind, ist die Auskunft melden sich weniger Worker als bekannt sind, ist die Auskunft
unvollstaendig — dann wird NICHTS als Leiche gewertet. unvollständig — dann wird NICHTS als Leiche gewertet.
""" """
from datetime import timedelta from datetime import timedelta
+44 -44
View File
@@ -1,8 +1,8 @@
"""MakeMKV-Datenverzeichnis: Schluesselspeicher, KEYDB.cfg, AACS-Dumps. """MakeMKV-Datenverzeichnis: Schlüsselspeicher, KEYDB.cfg, AACS-Dumps.
WARUM ES DIESE DATEI GIBT (Befund 25.07.2026, auf BEIDEN Maschinen gemessen): WARUM ES DIESE DATEI GIBT (Befund 25.07.2026, auf BEIDEN Maschinen gemessen):
4K-UHD-Discs scheiterten mit "The volume key is unknown for this disc". Die 4K-UHD-Discs scheiterten mit "The volume key is unknown for this disc". Die
Ursache ist weder die Disc noch das Laufwerk — LibreDrive v06.3 laeuft und Ursache ist weder die Disc noch das Laufwerk — LibreDrive v06.3 läuft und
MakeMKV liest die Disc — sondern: MakeMKV liest die Disc — sondern:
makemkvcon unter LINUX ruft die Disc-Schluessel nie ab. makemkvcon unter LINUX ruft die Disc-Schluessel nie ab.
@@ -10,35 +10,35 @@ MakeMKV liest die Disc — sondern:
Gemessen, nicht vermutet: Gemessen, nicht vermutet:
* Linux: in KEINEM Lauf auch nur eine Verbindung nach draussen. Geprueft mit * Linux: in KEINEM Lauf auch nur eine Verbindung nach draussen. Geprueft mit
leerem UND mit gefuelltem Schluesselspeicher, mit und ohne --noscan, mit leerem UND mit gefülltem Schlüsselspeicher, mit und ohne --noscan, mit
dev:/dev/sr0 und mit disc:0, und mit erzwungener frischer Pruefung dev:/dev/sr0 und mit disc:0, und mit erzwungener frischer Prüfung
(update.conf geloescht, Meldung 5074 belegt den Web-Kontakt). Immer: (update.conf gelöscht, Meldung 5074 belegt den Web-Kontakt). Immer:
keine Verbindung, kein Schluessel. keine Verbindung, kein Schluessel.
* Windows, dieselbe Disc, dasselbe Laufwerk: Meldung 3338 "Downloading * Windows, dieselbe Disc, dasselbe Laufwerk: Meldung 3338 "Downloading
latest HK to ...", Verbindung nach 185.84.108.20:443 latest HK to ...", Verbindung nach 185.84.108.20:443
(web33.majordomo.ru), _private_data.tar waechst — und die Disc geht auf (web33.majordomo.ru), _private_data.tar wächst — und die Disc geht auf
(TCOUNT:5, "Operation successfully completed"). (TCOUNT:5, "Operation successfully completed").
* Der Code dafuer steckt auch im Linux-Binary: die Meldungsvorlage * Der Code dafür steckt auch im Linux-Binary: die Meldungsvorlage
"Downloading latest %1 to %2 ..." steht in makemkvcon. Sie loest nur nie "Downloading latest %1 to %2 ..." steht in makemkvcon. Sie löst nur nie
aus. Gleiches Symptom im Forum, seit Jahren offen und unbeantwortet. aus. Gleiches Symptom im Forum, seit Jahren offen und unbeantwortet.
FRUEHERE FEHLDIAGNOSE, bewusst festgehalten: An dieser Stelle stand zuerst, FRUEHERE FEHLDIAGNOSE, bewusst festgehalten: An dieser Stelle stand zuerst,
MakeMKVs Schluessel-Kanal sei abgeschaltet. Das war FALSCH. Die Herleitung MakeMKVs Schluessel-Kanal sei abgeschaltet. Das war FALSCH. Die Herleitung
stuetzte sich auf zwei Hostnamen aus alten Forumsbeitraegen stützte sich auf zwei Hostnamen aus alten Forumsbeitraegen
(hkdata.fairuse.org, hkdata.crabdance.com), die tatsaechlich nicht mehr (hkdata.fairuse.org, hkdata.crabdance.com), die tatsaechlich nicht mehr
aufloesen — MakeMKV benutzt sie aber laengst nicht mehr. Der Dienst lebt, der aufloesen — MakeMKV benutzt sie aber laengst nicht mehr. Der Dienst lebt, der
Worker erreicht ihn sogar; er wird unter Linux nur nie gefragt. Worker erreicht ihn sogar; er wird unter Linux nur nie gefragt.
WAS DARAUS FOLGT: Der Schluesselspeicher (_private_data.tar) muss von einer WAS DARAUS FOLGT: Der Schlüsselspeicher (_private_data.tar) muss von einer
MakeMKV-Installation kommen, die ihn wirklich abruft — praktisch von Windows. MakeMKV-Installation kommen, die ihn wirklich abruft — praktisch von Windows.
Rippy nimmt ihn unter Einstellungen -> System entgegen. Die KEYDB.cfg bleibt Rippy nimmt ihn unter Einstellungen -> System entgegen. Die KEYDB.cfg bleibt
der Notnagel fuer Pressungen, die auch MakeMKV selbst nicht kennt. der Notnagel für Pressungen, die auch MakeMKV selbst nicht kennt.
Rippy liefert KEINE Schluessel mit, laedt keine herunter und verteilt keine. Rippy liefert KEINE Schluessel mit, lädt keine herunter und verteilt keine.
Es verwaltet nur, was der Nutzer selbst mitbringt. Es verwaltet nur, was der Nutzer selbst mitbringt.
QUELLEN (AGENTS Regel D — externe Schnittstellen nie aus dem Kopf): QUELLEN (AGENTS Regel D — externe Schnittstellen nie aus dem Kopf):
* Linux laedt keine Hashed Keys — dasselbe Symptom, mehrfach berichtet: * Linux lädt keine Hashed Keys — dasselbe Symptom, mehrfach berichtet:
https://forum.makemkv.com/forum/viewtopic.php?t=25782 https://forum.makemkv.com/forum/viewtopic.php?t=25782
https://forum.makemkv.com/forum/viewtopic.php?t=34022 https://forum.makemkv.com/forum/viewtopic.php?t=34022
* Schluessel liegen als hkd_*.bin in _private_data.tar: * Schluessel liegen als hkd_*.bin in _private_data.tar:
@@ -55,7 +55,7 @@ QUELLEN (AGENTS Regel D — externe Schnittstellen nie aus dem Kopf):
ZWILLINGSDATEI: liegt identisch unter docker/api/ und docker/worker/ — beide ZWILLINGSDATEI: liegt identisch unter docker/api/ und docker/worker/ — beide
Images brauchen sie, ein gemeinsames Paket gibt es in diesem Projekt nicht Images brauchen sie, ein gemeinsames Paket gibt es in diesem Projekt nicht
(gleiche Lage wie bei db.py). Aenderungen IMMER in BEIDEN Dateien nachziehen. (gleiche Lage wie bei db.py). Änderungen IMMER in BEIDEN Dateien nachziehen.
""" """
import io import io
@@ -72,14 +72,14 @@ DATEN_DIR = os.getenv("MAKEMKV_DATA_DIR", "/root/.MakeMKV")
# GROSS geschrieben — unter Linux case-sensitiv, "keydb.cfg" wird ignoriert. # GROSS geschrieben — unter Linux case-sensitiv, "keydb.cfg" wird ignoriert.
KEYDB_NAME = "KEYDB.cfg" KEYDB_NAME = "KEYDB.cfg"
# Obergrenze fuer den Upload. Eine vollstaendige oeffentliche KEYDB.cfg liegt # Obergrenze für den Upload. Eine vollständige oeffentliche KEYDB.cfg liegt
# im einstelligen MB-Bereich; 64 MB sind reichlich Luft und verhindern, dass # im einstelligen MB-Bereich; 64 MB sind reichlich Luft und verhindern, dass
# eine versehentlich hochgeladene Riesendatei den Speicher vollschreibt. # eine versehentlich hochgeladene Riesendatei den Speicher vollschreibt.
MAX_KEYDB_BYTES = 64 * 1024 * 1024 MAX_KEYDB_BYTES = 64 * 1024 * 1024
# Eine Disc-Zeile beginnt mit der 40 Zeichen langen Hex-Kennung (optional mit # Eine Disc-Zeile beginnt mit der 40 Zeichen langen Hex-Kennung (optional mit
# 0x davor), danach folgt das Gleichheitszeichen. Alles andere (Kommentare ab # 0x davor), danach folgt das Gleichheitszeichen. Alles andere (Kommentare ab
# ";", Leerzeilen, Fortsetzungsfelder) zaehlt nicht als Eintrag. # ";", Leerzeilen, Fortsetzungsfelder) zählt nicht als Eintrag.
_DISC_ZEILE = re.compile(r"^\s*(?:0x)?[0-9a-fA-F]{40}\s*=") _DISC_ZEILE = re.compile(r"^\s*(?:0x)?[0-9a-fA-F]{40}\s*=")
@@ -94,9 +94,9 @@ def keydb_pfad(daten_dir: str = None) -> str:
def zaehle_disc_eintraege(inhalt: str) -> int: def zaehle_disc_eintraege(inhalt: str) -> int:
"""Zeilen mit Disc-Kennung zaehlen (pure Funktion, testbar). """Zeilen mit Disc-Kennung zählen (pure Funktion, testbar).
Bewusst eine Heuristik und keine vollstaendige Auswertung: MakeMKV liest Bewusst eine Heuristik und keine vollständige Auswertung: MakeMKV liest
die Datei mit seinem eigenen Parser, und wie viele Eintraege es daraus die Datei mit seinem eigenen Parser, und wie viele Eintraege es daraus
macht, ist von aussen nicht sichtbar. Die Zahl dient nur dazu, im UI macht, ist von aussen nicht sichtbar. Die Zahl dient nur dazu, im UI
"da liegt wirklich etwas drin" von "leere oder falsche Datei" zu "da liegt wirklich etwas drin" von "leere oder falsche Datei" zu
@@ -106,17 +106,17 @@ def zaehle_disc_eintraege(inhalt: str) -> int:
def keydb_pruefen(inhalt: str) -> str: def keydb_pruefen(inhalt: str) -> str:
"""Prueft hochgeladenen Inhalt; gibt deutschen Fehlertext oder "" zurueck. """Prüft hochgeladenen Inhalt; gibt deutschen Fehlertext oder "" zurück.
Verhindert den haeufigsten Bedienfehler: statt der KEYDB.cfg landet die Verhindert den häufigsten Bedienfehler: statt der KEYDB.cfg landet die
HTML-Fehlerseite eines Downloads oder eine leere Datei im Verzeichnis — HTML-Fehlerseite eines Downloads oder eine leere Datei im Verzeichnis —
MakeMKV wuerde dann still weiter "volume key is unknown" melden. MakeMKV würde dann still weiter "volume key is unknown" melden.
""" """
if not inhalt.strip(): if not inhalt.strip():
return "Die Datei ist leer." return "Die Datei ist leer."
if len(inhalt.encode("utf-8")) > MAX_KEYDB_BYTES: if len(inhalt.encode("utf-8")) > MAX_KEYDB_BYTES:
return ( return (
"Die Datei ist groesser als " "Die Datei ist größer als "
f"{MAX_KEYDB_BYTES // (1024 * 1024)} MB — das ist keine KEYDB.cfg." f"{MAX_KEYDB_BYTES // (1024 * 1024)} MB — das ist keine KEYDB.cfg."
) )
if inhalt.lstrip()[:1] == "<": if inhalt.lstrip()[:1] == "<":
@@ -150,7 +150,7 @@ def keydb_status(daten_dir: str = None) -> dict:
with open(pfad, encoding="utf-8", errors="replace") as datei: with open(pfad, encoding="utf-8", errors="replace") as datei:
eintraege = zaehle_disc_eintraege(datei.read()) eintraege = zaehle_disc_eintraege(datei.read())
except OSError: except OSError:
pass # Datei da, aber unlesbar: Groesse/Datum stimmen trotzdem pass # Datei da, aber unlesbar: Größe/Datum stimmen trotzdem
return { return {
"vorhanden": True, "vorhanden": True,
"pfad": pfad, "pfad": pfad,
@@ -163,7 +163,7 @@ def keydb_status(daten_dir: str = None) -> dict:
def keydb_schreiben(inhalt: str, daten_dir: str = None) -> dict: def keydb_schreiben(inhalt: str, daten_dir: str = None) -> dict:
"""Schreibt die KEYDB.cfg atomar und meldet den neuen Zustand. """Schreibt die KEYDB.cfg atomar und meldet den neuen Zustand.
Erst in eine Nebendatei, dann os.replace: waehrend ein Rip laeuft, darf Erst in eine Nebendatei, dann os.replace: während ein Rip läuft, darf
makemkvcon niemals eine halb geschriebene Datei zu sehen bekommen. makemkvcon niemals eine halb geschriebene Datei zu sehen bekommen.
""" """
pfad = keydb_pfad(daten_dir) pfad = keydb_pfad(daten_dir)
@@ -244,32 +244,32 @@ def dumps_auflisten(daten_dir: str = None) -> list:
return liste return liste
# --- Schluesselspeicher (_private_data.tar) ------------------------------- # --- Schlüsselspeicher (_private_data.tar) -------------------------------
# #
# Das ist MakeMKVs eigener Speicher fuer die "Hashed Keys": ein tar-Archiv mit # Das ist MakeMKVs eigener Speicher für die "Hashed Keys": ein tar-Archiv mit
# hkd_*.bin-Eintraegen. Unter Windows fuellt MakeMKV es selbst (Meldung 3338), # hkd_*.bin-Eintraegen. Unter Windows füllt MakeMKV es selbst (Meldung 3338),
# unter Linux nie — siehe Modul-Kopf. Rippy nimmt die Datei deshalb entgegen # unter Linux nie — siehe Modul-Kopf. Rippy nimmt die Datei deshalb entgegen
# und legt sie ins Datenverzeichnis; MakeMKV liest sie beim naechsten Start. # und legt sie ins Datenverzeichnis; MakeMKV liest sie beim nächsten Start.
PRIVATE_DATA_NAME = "_private_data.tar" PRIVATE_DATA_NAME = "_private_data.tar"
# Der Speicher lag am 25.07.2026 bei rund 6 MB und waechst mit jeder neuen # Der Speicher lag am 25.07.2026 bei rund 6 MB und wächst mit jeder neuen
# Pressung. 64 MB sind reichlich Luft — und derselbe Wert wie # Pressung. 64 MB sind reichlich Luft — und derselbe Wert wie
# client_max_body_size in docker/ui/nginx.conf: waere die Grenze hier hoeher, # client_max_body_size in docker/ui/nginx.conf: wäre die Grenze hier höher,
# wuerde nginx den Upload abweisen, bevor die API ihn ueberhaupt sieht. # würde nginx den Upload abweisen, bevor die API ihn überhaupt sieht.
MAX_PRIVATE_DATA_BYTES = 64 * 1024 * 1024 MAX_PRIVATE_DATA_BYTES = 64 * 1024 * 1024
def private_data_pfad(daten_dir: str = None) -> str: def private_data_pfad(daten_dir: str = None) -> str:
"""Voller Pfad zum Schluesselspeicher im Datenverzeichnis.""" """Voller Pfad zum Schlüsselspeicher im Datenverzeichnis."""
return os.path.join(daten_dir or DATEN_DIR, PRIVATE_DATA_NAME) return os.path.join(daten_dir or DATEN_DIR, PRIVATE_DATA_NAME)
def zaehle_schluessel(rohdaten: bytes) -> int: def zaehle_schluessel(rohdaten: bytes) -> int:
"""Anzahl der hkd_*.bin-Eintraege im Archiv (pure Funktion, testbar). """Anzahl der hkd_*.bin-Eintraege im Archiv (pure Funktion, testbar).
Das ist die ehrliche Kennzahl fuer "wie viele Disc-Schluessel kennt diese Das ist die ehrliche Kennzahl für "wie viele Disc-Schluessel kennt diese
Installation". Ein frischer, leerer Speicher enthaelt nur eine Installation". Ein frischer, leerer Speicher enthält nur eine
Index-Datei und kommt hier auf 0 — genau der Zustand, in dem jede Index-Datei und kommt hier auf 0 — genau der Zustand, in dem jede
unbekannte UHD-Disc scheitert. unbekannte UHD-Disc scheitert.
""" """
@@ -281,20 +281,20 @@ def zaehle_schluessel(rohdaten: bytes) -> int:
def private_data_pruefen(rohdaten: bytes) -> str: def private_data_pruefen(rohdaten: bytes) -> str:
"""Prueft hochgeladene Rohdaten; deutscher Fehlertext oder "". """Prüft hochgeladene Rohdaten; deutscher Fehlertext oder "".
Faengt die beiden Bedienfehler ab, die sonst still danebengehen: eine Faengt die beiden Bedienfehler ab, die sonst still danebengehen: eine
voellig andere Datei hochladen, oder den Speicher einer Installation, die voellig andere Datei hochladen, oder den Speicher einer Installation, die
selbst noch keine Schluessel geholt hat (dann aendert sich nichts, und selbst noch keine Schluessel geholt hat (dann ändert sich nichts, und
niemand versteht warum). niemand versteht warum).
""" """
if not rohdaten: if not rohdaten:
return "Die Datei ist leer." return "Die Datei ist leer."
if len(rohdaten) > MAX_PRIVATE_DATA_BYTES: if len(rohdaten) > MAX_PRIVATE_DATA_BYTES:
return ( return (
"Die Datei ist groesser als " "Die Datei ist größer als "
f"{MAX_PRIVATE_DATA_BYTES // (1024 * 1024)} MB — das ist kein " f"{MAX_PRIVATE_DATA_BYTES // (1024 * 1024)} MB — das ist kein "
"MakeMKV-Schluesselspeicher." "MakeMKV-Schlüsselspeicher."
) )
try: try:
with tarfile.open(fileobj=io.BytesIO(rohdaten)) as archiv: with tarfile.open(fileobj=io.BytesIO(rohdaten)) as archiv:
@@ -308,13 +308,13 @@ def private_data_pruefen(rohdaten: bytes) -> str:
return ( return (
"In dieser Datei steckt kein einziger Schluessel (kein hkd_*.bin). " "In dieser Datei steckt kein einziger Schluessel (kein hkd_*.bin). "
"Sie stammt vermutlich von einer MakeMKV-Installation, die selbst " "Sie stammt vermutlich von einer MakeMKV-Installation, die selbst "
"noch keine geholt hat — oeffne dort erst einmal eine Disc." "noch keine geholt hat — öffne dort erst einmal eine Disc."
) )
return "" return ""
def schluesselspeicher_status(daten_dir: str = None) -> dict: def schluesselspeicher_status(daten_dir: str = None) -> dict:
"""Zustand des Schluesselspeichers. Fehlt er, ist das kein Fehler.""" """Zustand des Schlüsselspeichers. Fehlt er, ist das kein Fehler."""
pfad = private_data_pfad(daten_dir) pfad = private_data_pfad(daten_dir)
try: try:
angaben = os.stat(pfad) angaben = os.stat(pfad)
@@ -338,9 +338,9 @@ def schluesselspeicher_status(daten_dir: str = None) -> dict:
def private_data_schreiben(rohdaten: bytes, daten_dir: str = None) -> dict: def private_data_schreiben(rohdaten: bytes, daten_dir: str = None) -> dict:
"""Legt den Schluesselspeicher atomar ab und meldet den neuen Zustand. """Legt den Schlüsselspeicher atomar ab und meldet den neuen Zustand.
Atomar aus demselben Grund wie bei der KEYDB.cfg: waehrend ein Rip laeuft, Atomar aus demselben Grund wie bei der KEYDB.cfg: während ein Rip läuft,
darf makemkvcon nie ein halb geschriebenes Archiv sehen. darf makemkvcon nie ein halb geschriebenes Archiv sehen.
""" """
pfad = private_data_pfad(daten_dir) pfad = private_data_pfad(daten_dir)
@@ -363,9 +363,9 @@ def settings_conf_zusammenfuehren(inhalt: str, key: str) -> str:
"""app_Key setzen, ohne den Rest der settings.conf zu verlieren (pure). """app_Key setzen, ohne den Rest der settings.conf zu verlieren (pure).
Bis zum 25.07.2026 haben entrypoint.sh UND tasks.py die Datei komplett Bis zum 25.07.2026 haben entrypoint.sh UND tasks.py die Datei komplett
ueberschrieben. Mit dem jetzt persistenten Datenverzeichnis waere damit überschrieben. Mit dem jetzt persistenten Datenverzeichnis wäre damit
bei jedem Containerstart und vor jedem Rip alles andere weg — z. B. bei jedem Containerstart und vor jedem Rip alles andere weg — z. B.
app_UpdateEnable. Leerer Key laesst die vorhandene Zeile ebenfalls fallen, app_UpdateEnable. Leerer Key lässt die vorhandene Zeile ebenfalls fallen,
damit ein bewusst geleerter Key nicht heimlich weiterwirkt. damit ein bewusst geleerter Key nicht heimlich weiterwirkt.
""" """
zeilen = [z for z in inhalt.splitlines() if not z.lstrip().startswith("app_Key")] zeilen = [z for z in inhalt.splitlines() if not z.lstrip().startswith("app_Key")]
+4 -4
View File
@@ -223,9 +223,9 @@ def parse_msg(zeile: str):
fertig zusammengesetzte Klartext. fertig zusammengesetzte Klartext.
Bis zum 25.07.2026 stand hier line.split(",", 4)[3]: das schnitt JEDE Bis zum 25.07.2026 stand hier line.split(",", 4)[3]: das schnitt JEDE
Meldung ab, die selbst ein Komma enthaelt — und MakeMKV schreibt solche Meldung ab, die selbst ein Komma enthält — und MakeMKV schreibt solche
laufend ("Title #1 has length of 12 seconds, which is less than ..."). laufend ("Title #1 has length of 12 seconds, which is less than ...").
Deshalb eine Regex, die die Anfuehrungszeichen respektiert. Deshalb eine Regex, die die Anführungszeichen respektiert.
""" """
treffer = _MSG_RE.match(zeile.strip()) treffer = _MSG_RE.match(zeile.strip())
if not treffer: if not treffer:
@@ -409,7 +409,7 @@ def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=
try: try:
for line in process.stdout: for line in process.stdout:
# Zuerst der Abbruch — unabhängig davon, ob die Zeile überhaupt # Zuerst der Abbruch — unabhängig davon, ob die Zeile überhaupt
# einen Fortschritt enthält. Sonst haengt „Abbrechen" daran, dass # einen Fortschritt enthält. Sonst hängt „Abbrechen" daran, dass
# sich die Prozentzahl bewegt (Befund 25.07.2026). # sich die Prozentzahl bewegt (Befund 25.07.2026).
if abbruch_cb: if abbruch_cb:
abbruch_cb() abbruch_cb()
@@ -475,7 +475,7 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
log_cb(code, text) bekommt JEDE MakeMKV-Meldung. Bewusst ein eigener log_cb(code, text) bekommt JEDE MakeMKV-Meldung. Bewusst ein eigener
Kanal statt progress_cb: der Fortschritts-Callback in tasks.py verwirft Kanal statt progress_cb: der Fortschritts-Callback in tasks.py verwirft
Aufrufe, bei denen sich die Prozentzahl nicht geaendert hat — Meldungen Aufrufe, bei denen sich die Prozentzahl nicht geaendert hat — Meldungen
waeren dort also grossteils verschwunden. Ohne diesen Kanal war am wären dort also großteils verschwunden. Ohne diesen Kanal war am
25.07.2026 nicht von aussen erkennbar, dass MakeMKV bei der UHD-Disc 25.07.2026 nicht von aussen erkennbar, dass MakeMKV bei der UHD-Disc
nicht einmal versucht, einen Schluessel zu holen (siehe makemkv_daten). nicht einmal versucht, einen Schluessel zu holen (siehe makemkv_daten).
""" """
+1 -1
View File
@@ -104,7 +104,7 @@ def unter_wurzel(pfad: str, wurzel: str) -> bool:
"""Liegt `pfad` wirklich unterhalb von `wurzel` (oder IST es die Wurzel)? """Liegt `pfad` wirklich unterhalb von `wurzel` (oder IST es die Wurzel)?
Pure Funktion, testbar. Ein nacktes `startswith()` genügt nicht: Pure Funktion, testbar. Ein nacktes `startswith()` genügt nicht:
„/app/media-boese/x" beginnt mit „/app/media", liegt aber ausserhalb „/app/media-boese/x" beginnt mit „/app/media", liegt aber außerhalb
(Befund 25.07.2026 bei der Durchsicht). Immer „/" als Trenner — das sind (Befund 25.07.2026 bei der Durchsicht). Immer „/" als Trenner — das sind
Container-Pfade, auch wenn ein nativer Windows-Worker das Modul lädt. Container-Pfade, auch wenn ein nativer Windows-Worker das Modul lädt.
+21 -21
View File
@@ -1,13 +1,13 @@
"""Tests fuer makemkv_daten.py: die reinen Helfer rund um das MakeMKV-Datenverzeichnis. """Tests für makemkv_daten.py: die reinen Helfer rund um das MakeMKV-Datenverzeichnis.
WARUM ES DIESE TESTS GIBT (Befund 25.07.2026, live im Worker nachgemessen): WARUM ES DIESE TESTS GIBT (Befund 25.07.2026, live im Worker nachgemessen):
Eine 4K-UHD-Disc (Akira UHD, MKB v76) scheiterte mit "The volume key is unknown Eine 4K-UHD-Disc (Akira UHD, MKB v76) scheiterte mit "The volume key is unknown
for this disc", obwohl Laufwerk und MakeMKV in Ordnung waren. Der einzige heute for this disc", obwohl Laufwerk und MakeMKV in Ordnung waren. Der einzige heute
noch funktionierende Weg ist eine selbst mitgebrachte KEYDB.cfg im noch funktionierende Weg ist eine selbst mitgebrachte KEYDB.cfg im
Datenverzeichnis. Damit haengt einiges an diesen kleinen Funktionen: erkennen wir Datenverzeichnis. Damit hängt einiges an diesen kleinen Funktionen: erkennen wir
die Datei falsch, meldet das UI "alles gut", waehrend MakeMKV weiter scheitert. die Datei falsch, meldet das UI "alles gut", während MakeMKV weiter scheitert.
Getestet wird nur, was ohne Postgres, Redis und ohne Laufwerk laeuft also die Getestet wird nur, was ohne Postgres, Redis und ohne Laufwerk läuft also die
puren Funktionen mit echten Beispieldaten. Zeilenformat der KEYDB.cfg laut puren Funktionen mit echten Beispieldaten. Zeilenformat der KEYDB.cfg laut
libaacs (AGENTS Regel D, externe Schnittstellen nie aus dem Kopf): libaacs (AGENTS Regel D, externe Schnittstellen nie aus dem Kopf):
https://github.com/ShiftMediaProject/libaacs/blob/master/KEYDB.cfg https://github.com/ShiftMediaProject/libaacs/blob/master/KEYDB.cfg
@@ -17,7 +17,7 @@ ist eine Zwillingsdatei, es gibt sie unter docker/api/ UND docker/worker/, und
beide Seiten haben Tests. Da im Projekt keine __init__.py liegen, importiert beide Seiten haben Tests. Da im Projekt keine __init__.py liegen, importiert
pytest Testdateien unter ihrem blossen Dateinamen zwei Dateien namens pytest Testdateien unter ihrem blossen Dateinamen zwei Dateien namens
test_makemkv_daten.py brechen deshalb die Sammelphase ab ("import file test_makemkv_daten.py brechen deshalb die Sammelphase ab ("import file
mismatch") und faerben die ganze Ampel rot. Nicht zurueckbenennen. mismatch") und faerben die ganze Ampel rot. Nicht zurückbenennen.
""" """
import hashlib import hashlib
@@ -47,10 +47,10 @@ zaehle_schluessel = _modul.zaehle_schluessel
def _tar_mit(namen): def _tar_mit(namen):
"""Baut ein tar-Archiv im Speicher — so sieht MakeMKVs Schluesselspeicher aus. """Baut ein tar-Archiv im Speicher — so sieht MakeMKVs Schlüsselspeicher aus.
Echte Eintragsnamen aus dem Speicher der Windows-Installation vom Echte Eintragsnamen aus dem Speicher der Windows-Installation vom
25.07.2026: hkd_<8 Hex>.bin fuer die Schluessel, dazu Index-Dateien. 25.07.2026: hkd_<8 Hex>.bin für die Schluessel, dazu Index-Dateien.
""" """
import io import io
import tarfile import tarfile
@@ -67,7 +67,7 @@ def _tar_mit(namen):
def test_zaehle_schluessel_zaehlt_nur_hkd_eintraege(): def test_zaehle_schluessel_zaehlt_nur_hkd_eintraege():
# Nur hkd_*.bin sind Disc-Schluessel. Index- und sdf-Dateien gehoeren zum # Nur hkd_*.bin sind Disc-Schluessel. Index- und sdf-Dateien gehoeren zum
# Speicher dazu, sind aber keine Schluessel — sonst meldete das UI # Speicher dazu, sind aber keine Schluessel — sonst meldete das UI
# "1 Schluessel vorhanden" fuer einen komplett leeren Vorrat. # "1 Schluessel vorhanden" für einen komplett leeren Vorrat.
voll = _tar_mit([ voll = _tar_mit([
"hkd_00000059.bin", "hkd_00000059.bin",
"hkd_0000005a.bin", "hkd_0000005a.bin",
@@ -101,7 +101,7 @@ def test_private_data_pruefen_lehnt_leere_und_falsche_dateien_ab():
def test_private_data_pruefen_lehnt_speicher_ohne_schluessel_ab(): def test_private_data_pruefen_lehnt_speicher_ohne_schluessel_ab():
# Der teuerste Bedienfehler: den Speicher einer Installation hochladen, # Der teuerste Bedienfehler: den Speicher einer Installation hochladen,
# die selbst noch nie Schluessel geholt hat. Ohne diese Pruefung aendert # die selbst noch nie Schluessel geholt hat. Ohne diese Prüfung ändert
# sich nichts und niemand versteht, warum. # sich nichts und niemand versteht, warum.
leer = _tar_mit(["--index-4EF73C3560D489497ACE763962A7F07A4A1545C4.bin"]) leer = _tar_mit(["--index-4EF73C3560D489497ACE763962A7F07A4A1545C4.bin"])
assert "kein einziger Schluessel" in private_data_pruefen(leer) assert "kein einziger Schluessel" in private_data_pruefen(leer)
@@ -123,7 +123,7 @@ def test_zwillinge_sind_byteweise_identisch():
api = hashlib.sha256(datei.read()).hexdigest() api = hashlib.sha256(datei.read()).hexdigest()
assert worker == api, ( assert worker == api, (
"docker/worker/makemkv_daten.py und docker/api/makemkv_daten.py sind " "docker/worker/makemkv_daten.py und docker/api/makemkv_daten.py sind "
"auseinandergelaufen - Aenderungen immer in BEIDE Dateien uebernehmen." "auseinandergelaufen - Änderungen immer in BEIDE Dateien übernehmen."
) )
# Eine kleine, aber echte KEYDB.cfg im libaacs-Format: Kommentarkopf, eine # Eine kleine, aber echte KEYDB.cfg im libaacs-Format: Kommentarkopf, eine
@@ -142,7 +142,7 @@ A1B2C3D4E5F60718293A4B5C6D7E8F90A1B2C3D4 = BLADE RUNNER 2049
def test_zaehle_disc_eintraege_zaehlt_nur_echte_disc_zeilen(): def test_zaehle_disc_eintraege_zaehlt_nur_echte_disc_zeilen():
"""Nur Zeilen mit 40 Hex-Zeichen und Gleichheitszeichen sind Eintraege. """Nur Zeilen mit 40 Hex-Zeichen und Gleichheitszeichen sind Eintraege.
Kommentare, Leerzeilen und Fortsetzungsfelder duerfen nicht mitzaehlen Kommentare, Leerzeilen und Fortsetzungsfelder dürfen nicht mitzaehlen
sonst meldet das UI bei einer reinen Kommentardatei stolz "42 Eintraege". sonst meldet das UI bei einer reinen Kommentardatei stolz "42 Eintraege".
""" """
assert zaehle_disc_eintraege(BEISPIEL_KEYDB) == 2 assert zaehle_disc_eintraege(BEISPIEL_KEYDB) == 2
@@ -158,14 +158,14 @@ def test_zaehle_disc_eintraege_ignoriert_zu_kurze_kennung():
"""39 Hex-Zeichen sind keine Disc-Kennung. """39 Hex-Zeichen sind keine Disc-Kennung.
Genau so sieht eine beim Kopieren verstuemmelte Datei aus die darf nicht Genau so sieht eine beim Kopieren verstuemmelte Datei aus die darf nicht
als gueltig durchgehen, sonst sucht der Commander den Fehler beim Laufwerk. als gültig durchgehen, sonst sucht der Commander den Fehler beim Laufwerk.
""" """
zu_kurz = "A1B2C3D4E5F60718293A4B5C6D7E8F90A1B2C3D = KAPUTT\n" zu_kurz = "A1B2C3D4E5F60718293A4B5C6D7E8F90A1B2C3D = KAPUTT\n"
assert zaehle_disc_eintraege(zu_kurz) == 0 assert zaehle_disc_eintraege(zu_kurz) == 0
def test_keydb_pruefen_meldet_leere_datei(): def test_keydb_pruefen_meldet_leere_datei():
# Haeufigster Fehlgriff: das Textfeld war leer, es wird trotzdem gespeichert. # Häufigster Fehlgriff: das Textfeld war leer, es wird trotzdem gespeichert.
assert keydb_pruefen("") != "" assert keydb_pruefen("") != ""
assert keydb_pruefen(" \n\n ") != "" assert keydb_pruefen(" \n\n ") != ""
@@ -173,7 +173,7 @@ def test_keydb_pruefen_meldet_leere_datei():
def test_keydb_pruefen_erkennt_html_fehlerseite(): def test_keydb_pruefen_erkennt_html_fehlerseite():
"""Der zweithaeufigste Fehlgriff: der Download lieferte eine HTML-Seite. """Der zweithaeufigste Fehlgriff: der Download lieferte eine HTML-Seite.
MakeMKV wuerde die Datei still ignorieren und weiter "volume key is unknown" MakeMKV würde die Datei still ignorieren und weiter "volume key is unknown"
melden deshalb muss der Fehler schon beim Hochladen sichtbar werden. melden deshalb muss der Fehler schon beim Hochladen sichtbar werden.
""" """
html = "<!DOCTYPE html>\n<html><body><h1>404 Not Found</h1></body></html>\n" html = "<!DOCTYPE html>\n<html><body><h1>404 Not Found</h1></body></html>\n"
@@ -184,12 +184,12 @@ def test_keydb_pruefen_erkennt_html_fehlerseite():
def test_keydb_pruefen_meldet_text_ohne_disc_zeile(): def test_keydb_pruefen_meldet_text_ohne_disc_zeile():
# Irgendein Text (hier: eine README) ist keine KEYDB.cfg. # Irgendein Text (hier: eine README) ist keine KEYDB.cfg.
meldung = keydb_pruefen("Diese Datei enthaelt keine Schluessel, nur Prosa.\n") meldung = keydb_pruefen("Diese Datei enthält keine Schluessel, nur Prosa.\n")
assert meldung != "" assert meldung != ""
def test_keydb_pruefen_akzeptiert_gueltigen_inhalt(): def test_keydb_pruefen_akzeptiert_gueltigen_inhalt():
# Leerer Rueckgabewert heisst laut Vertrag: alles in Ordnung. # Leerer Rueckgabewert heißt laut Vertrag: alles in Ordnung.
assert keydb_pruefen(BEISPIEL_KEYDB) == "" assert keydb_pruefen(BEISPIEL_KEYDB) == ""
@@ -200,9 +200,9 @@ def test_ist_aacs_dump_akzeptiert_echten_namen():
def test_ist_aacs_dump_lehnt_pfad_tricks_und_fremde_dateien_ab(): def test_ist_aacs_dump_lehnt_pfad_tricks_und_fremde_dateien_ab():
"""Der Download-Endpunkt haengt den Namen an das Datenverzeichnis an — """Der Download-Endpunkt hängt den Namen an das Datenverzeichnis an —
ohne diese Pruefung koennte man sich damit aus dem Verzeichnis heraus ohne diese Prüfung könnte man sich damit aus dem Verzeichnis heraus
lesen. Versteckte Dateien und Nicht-Dumps sind ebenfalls nichts fuer die lesen. Versteckte Dateien und Nicht-Dumps sind ebenfalls nichts für die
Liste.""" Liste."""
assert ist_aacs_dump("../ausbruch.tgz") is False assert ist_aacs_dump("../ausbruch.tgz") is False
assert ist_aacs_dump(".versteckt.tgz") is False assert ist_aacs_dump(".versteckt.tgz") is False
@@ -212,8 +212,8 @@ def test_ist_aacs_dump_lehnt_pfad_tricks_und_fremde_dateien_ab():
def test_settings_conf_ersetzt_key_und_behaelt_den_rest(): def test_settings_conf_ersetzt_key_und_behaelt_den_rest():
"""DIE Regression, um die es geht: bis zum 25.07.2026 haben entrypoint.sh """DIE Regression, um die es geht: bis zum 25.07.2026 haben entrypoint.sh
und tasks.py die settings.conf komplett ueberschrieben. Mit dem jetzt und tasks.py die settings.conf komplett überschrieben. Mit dem jetzt
persistenten Datenverzeichnis waere damit bei jedem Containerstart und vor persistenten Datenverzeichnis wäre damit bei jedem Containerstart und vor
jedem Rip alles andere weg allen voran app_UpdateEnable.""" jedem Rip alles andere weg allen voran app_UpdateEnable."""
alt = 'app_Key = "T-alterSchluessel"\napp_UpdateEnable = "1"\napp_DefaultSelectionString = "+sel:all"\n' alt = 'app_Key = "T-alterSchluessel"\napp_UpdateEnable = "1"\napp_DefaultSelectionString = "+sel:all"\n'
neu = settings_conf_zusammenfuehren(alt, "T-neuerSchluessel") neu = settings_conf_zusammenfuehren(alt, "T-neuerSchluessel")
+9 -9
View File
@@ -114,15 +114,15 @@ def test_pfad_lokal_uebersetzt_fuer_windows_worker():
assert pfad_lokal("/app/temp/raw/abc", "") == "/app/temp/raw/abc" assert pfad_lokal("/app/temp/raw/abc", "") == "/app/temp/raw/abc"
# Die beiden frueheren Tests zu _original_aufheben sind nach # Die beiden früheren Tests zu _original_aufheben sind nach
# test_original_aufheben.py gewandert und dort erweitert. Sie pruefen jetzt das # test_original_aufheben.py gewandert und dort erweitert. Sie prüfen jetzt das
# richtige Kriterium: Vorher taeuschten sie per gefaelschtem os.stat # richtige Kriterium: Vorher taeuschten sie per gefaelschtem os.stat
# „verschiedene Dateisysteme" vor — genau die Annahme, an der der Schutz in der # „verschiedene Dateisysteme" vor — genau die Annahme, an der der Schutz in der
# Praxis scheiterte (st_dev war identisch, os.rename trotzdem EXDEV). # Praxis scheiterte (st_dev war identisch, os.rename trotzdem EXDEV).
def test_arbeitsverzeichnis_wahl_des_rips_schlaegt_die_einstellung(): def test_arbeitsverzeichnis_wahl_des_rips_schlaegt_die_einstellung():
"""Pro Rip waehlbar (Commander 25.07.2026), Einstellung bleibt Standard. """Pro Rip wählbar (Commander 25.07.2026), Einstellung bleibt Standard.
Reihenfolge: Wahl dieses Rips -> Setting -> Container-Default. Der Reihenfolge: Wahl dieses Rips -> Setting -> Container-Default. Der
Setting-Wert ist genau der, der bei Vollautomatik-Rips greift, weil dort Setting-Wert ist genau der, der bei Vollautomatik-Rips greift, weil dort
@@ -134,16 +134,16 @@ def test_arbeitsverzeichnis_wahl_des_rips_schlaegt_die_einstellung():
assert tasks._arbeitsverzeichnis(einst, "/app/media/rippy") == "/app/media/rippy" assert tasks._arbeitsverzeichnis(einst, "/app/media/rippy") == "/app/media/rippy"
assert tasks._arbeitsverzeichnis(einst) == "/app/media/movies" assert tasks._arbeitsverzeichnis(einst) == "/app/media/movies"
assert tasks._arbeitsverzeichnis({}) == tasks.RAW_DIR assert tasks._arbeitsverzeichnis({}) == tasks.RAW_DIR
# Ausbruchsversuche und Pfade ausserhalb /app/media fallen durch # Ausbruchsversuche und Pfade außerhalb /app/media fallen durch
assert tasks._arbeitsverzeichnis({}, "/etc") == tasks.RAW_DIR assert tasks._arbeitsverzeichnis({}, "/etc") == tasks.RAW_DIR
assert tasks._arbeitsverzeichnis({}, "/app/media/../etc") == tasks.RAW_DIR assert tasks._arbeitsverzeichnis({}, "/app/media/../etc") == tasks.RAW_DIR
# Leere Wahl faellt sauber auf die Einstellung zurueck # Leere Wahl fällt sauber auf die Einstellung zurück
assert tasks._arbeitsverzeichnis(einst, " ") == "/app/media/movies" assert tasks._arbeitsverzeichnis(einst, " ") == "/app/media/movies"
def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein(): def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein():
"""Befund 25.07.2026: Elf Stellen prueften mit nacktem startswith(). """Befund 25.07.2026: Elf Stellen prüften mit nacktem startswith().
/app/media-boese/x" beginnt mit „/app/media", liegt aber ausserhalb.""" /app/media-boese/x" beginnt mit „/app/media", liegt aber außerhalb."""
import tasks import tasks
assert tasks.unter_wurzel("/app/media", "/app/media") is True assert tasks.unter_wurzel("/app/media", "/app/media") is True
@@ -155,7 +155,7 @@ def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein():
assert tasks.unter_wurzel("/etc/passwd", "/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
assert tasks.unter_wurzel("/app/media", "") is False assert tasks.unter_wurzel("/app/media", "") is False
# Wurzel mit Schrägstrich am Ende darf nichts aendern # Wurzel mit Schrägstrich am Ende darf nichts ändern
assert tasks.unter_wurzel("/app/media/movies", "/app/media/") is True assert tasks.unter_wurzel("/app/media/movies", "/app/media/") is True
@@ -163,7 +163,7 @@ def test_zielbasis_lehnt_praefix_ausbruch_ab():
import tasks import tasks
assert tasks._zielbasis("/app/media/movies", "bluray") == "/app/media/movies" assert tasks._zielbasis("/app/media/movies", "bluray") == "/app/media/movies"
# Ausbruch per Praefix-Namen faellt auf den Standard zurueck # Ausbruch per Praefix-Namen fällt auf den Standard zurück
assert tasks._zielbasis("/app/media-boese", "bluray") != "/app/media-boese" assert tasks._zielbasis("/app/media-boese", "bluray") != "/app/media-boese"
assert tasks._zielbasis("/etc", "bluray") != "/etc" assert tasks._zielbasis("/etc", "bluray") != "/etc"
+6 -6
View File
@@ -1,11 +1,11 @@
"""Tests fuer _original_aufheben — der Pfad, der am 25.07.2026 die Platte fuellte. """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 Der Kern: Es wird NICHT vorhergesagt, ob umgehaengt werden kann, sondern
os.rename versucht. Die alte Fassung verglich st_dev und lag damit falsch 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), 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 os.rename scheitert zwischen ihnen trotzdem mit EXDEV, weil der Kernel den
Mount vergleicht und nicht das Geraet. Die Platzpruefung wurde deshalb Mount vergleicht und nicht das Gerät. Die Platzpruefung wurde deshalb
uebersprungen und shutil.move kopierte 75 GB bei 37 GB frei. übersprungen und shutil.move kopierte 75 GB bei 37 GB frei.
""" """
import errno import errno
@@ -46,7 +46,7 @@ def _lege_rohdaten_an(tmp_path, groesse=2048):
def test_umhaengen_wenn_derselbe_mount(tmp_path, fake_db): def test_umhaengen_wenn_derselbe_mount(tmp_path, fake_db):
"""Der gute Fall: rename klappt, nichts wird kopiert, kein Platz noetig.""" """Der gute Fall: rename klappt, nichts wird kopiert, kein Platz nötig."""
raw, final = _lege_rohdaten_an(tmp_path) raw, final = _lege_rohdaten_an(tmp_path)
tasks._original_aufheben("job-1", raw, final) tasks._original_aufheben("job-1", raw, final)
@@ -58,9 +58,9 @@ def test_umhaengen_wenn_derselbe_mount(tmp_path, fake_db):
def test_bei_exdev_und_zu_wenig_platz_wird_nur_gewarnt(tmp_path, fake_db, monkeypatch): def test_bei_exdev_und_zu_wenig_platz_wird_nur_gewarnt(tmp_path, fake_db, monkeypatch):
"""Der Fall, der die Platte fuellte: rename geht nicht, Platz reicht nicht. """Der Fall, der die Platte füllte: rename geht nicht, Platz reicht nicht.
Vorher lief hier eine Vollkopie an, weil die st_dev-Pruefung gleiches Vorher lief hier eine Vollkopie an, weil die st_dev-Prüfung gleiches
Dateisystem" meldete und die Platzpruefung deshalb ausblieb. Dateisystem" meldete und die Platzpruefung deshalb ausblieb.
""" """
raw, final = _lege_rohdaten_an(tmp_path) raw, final = _lege_rohdaten_an(tmp_path)
+15 -15
View File
@@ -113,7 +113,7 @@ def test_parse_msg_trennt_code_und_klartext():
def test_parse_msg_liest_die_uhd_fehlermeldung(): def test_parse_msg_liest_die_uhd_fehlermeldung():
"""3303 ist der Befund, um den es beim ganzen KEYDB-Thema geht: das """3303 ist der Befund, um den es beim ganzen KEYDB-Thema geht: das
Laufwerk laeuft im LibreDrive-Modus, MakeMKV kennt nur den Schluessel Laufwerk läuft im LibreDrive-Modus, MakeMKV kennt nur den Schluessel
DIESER Pressung nicht. Ohne diese Zeile im Log raet der Commander.""" DIESER Pressung nicht. Ohne diese Zeile im Log raet der Commander."""
assert parse_msg( assert parse_msg(
'MSG:3303,16777216,0,"The volume key is unknown for this disc - video can\'t be decrypted","The volume key is unknown for this disc - video can\'t be decrypted"' 'MSG:3303,16777216,0,"The volume key is unknown for this disc - video can\'t be decrypted","The volume key is unknown for this disc - video can\'t be decrypted"'
@@ -125,9 +125,9 @@ def test_parse_msg_liest_die_uhd_fehlermeldung():
def test_parse_msg_schneidet_meldungen_mit_komma_nicht_ab(): def test_parse_msg_schneidet_meldungen_mit_komma_nicht_ab():
"""DER Grund fuer die Regex (Stand 25.07.2026): vorher stand hier """DER Grund für die Regex (Stand 25.07.2026): vorher stand hier
line.split(",", 4)[3]. Das schnitt jede Meldung ab, die selbst ein Komma line.split(",", 4)[3]. Das schnitt jede Meldung ab, die selbst ein Komma
enthaelt und MakeMKV schreibt solche laufend. Im Log stand dann nur noch enthält und MakeMKV schreibt solche laufend. Im Log stand dann nur noch
ein Satzfragment, das mehr verwirrt als hilft.""" ein Satzfragment, das mehr verwirrt als hilft."""
zeile = ( zeile = (
'MSG:3025,0,3,"Title #1 has length of 12 seconds, which is less than ' 'MSG:3025,0,3,"Title #1 has length of 12 seconds, which is less than '
@@ -142,7 +142,7 @@ def test_parse_msg_schneidet_meldungen_mit_komma_nicht_ab():
def test_parse_msg_ignoriert_fremde_zeilen(): def test_parse_msg_ignoriert_fremde_zeilen():
# Alles ausser MSG muss None liefern, sonst landet Fortschritts-Rauschen # Alles außer MSG muss None liefern, sonst landet Fortschritts-Rauschen
# (PRGV kommt mehrmals pro Sekunde) als Log-Eintrag in der Datenbank. # (PRGV kommt mehrmals pro Sekunde) als Log-Eintrag in der Datenbank.
assert parse_msg("PRGV:100,32768,65536") is None assert parse_msg("PRGV:100,32768,65536") is None
assert parse_msg('DRV:0,2,999,12,"BD-RE ASUS BW-16D1HT","AKIRA","/dev/sr0"') is None assert parse_msg('DRV:0,2,999,12,"BD-RE ASUS BW-16D1HT","AKIRA","/dev/sr0"') is None
@@ -188,8 +188,8 @@ def test_abcde_config_enthaelt_zielverzeichnis():
def test_preset_fuer_nimmt_das_preset_des_disc_typs(): def test_preset_fuer_nimmt_das_preset_des_disc_typs():
# Befund 25.07.2026: vorher galt EIN Preset fuer alles — eine 4K-UHD wurde # Befund 25.07.2026: vorher galt EIN Preset für alles — eine 4K-UHD wurde
# damit auf 1080p heruntergerechnet, und beim ersten echten UHD-Rip waere # damit auf 1080p heruntergerechnet, und beim ersten echten UHD-Rip wäre
# die 4K-Aufloesung still verlorengegangen. # die 4K-Aufloesung still verlorengegangen.
from ripping import preset_fuer from ripping import preset_fuer
@@ -206,13 +206,13 @@ def test_preset_fuer_nimmt_das_preset_des_disc_typs():
def test_preset_fuer_faellt_auf_das_allgemeine_preset_zurueck(): def test_preset_fuer_faellt_auf_das_allgemeine_preset_zurueck():
# Bestandsinstallationen kennen die drei neuen Felder nicht. Solange der # Bestandsinstallationen kennen die drei neuen Felder nicht. Solange der
# Nutzer sie nicht speichert, MUSS sich sein Verhalten nicht aendern. # Nutzer sie nicht speichert, MUSS sich sein Verhalten nicht ändern.
from ripping import preset_fuer from ripping import preset_fuer
alt = {"transcodePreset": "HQ 1080p30 Surround"} alt = {"transcodePreset": "HQ 1080p30 Surround"}
assert preset_fuer("uhd", alt) == "HQ 1080p30 Surround" assert preset_fuer("uhd", alt) == "HQ 1080p30 Surround"
assert preset_fuer("dvd", alt) == "HQ 1080p30 Surround" assert preset_fuer("dvd", alt) == "HQ 1080p30 Surround"
# Leere Zeichenkette zaehlt als "nicht gesetzt" (leeres Select-Feld im UI) # Leere Zeichenkette zählt als "nicht gesetzt" (leeres Select-Feld im UI)
assert preset_fuer("uhd", {"transcodePresetUhd": " ", "transcodePreset": "X"}) == "X" assert preset_fuer("uhd", {"transcodePresetUhd": " ", "transcodePreset": "X"}) == "X"
@@ -269,7 +269,7 @@ def test_handbrake_prueft_abbruch_bei_jeder_zeile_nicht_nur_bei_fortschritt():
assert ergebnis["status"] == "cancelled" assert ergebnis["status"] == "cancelled"
assert prozess.getoetet is True assert prozess.getoetet is True
# Zwei Scan-Zeilen genuegten — es musste NICHT auf eine Encode-Zeile gewartet # Zwei Scan-Zeilen genügten — es musste NICHT auf eine Encode-Zeile gewartet
# werden. Genau das war der Fehler. # werden. Genau das war der Fehler.
assert len(gesehen) == 2 assert len(gesehen) == 2
@@ -279,16 +279,16 @@ def test_komprimieren_fuer_globaler_schalter_schlaegt_alles():
assert komprimieren_fuer("uhd", {"transcodeEnabled": False}) is False assert komprimieren_fuer("uhd", {"transcodeEnabled": False}) is False
assert komprimieren_fuer("dvd", {"transcodeEnabled": False}) is False assert komprimieren_fuer("dvd", {"transcodeEnabled": False}) is False
# Standard ist AN - Bestandsinstallationen aendern ihr Verhalten nicht # Standard ist AN - Bestandsinstallationen ändern ihr Verhalten nicht
assert komprimieren_fuer("bluray", {}) is True assert komprimieren_fuer("bluray", {}) is True
assert komprimieren_fuer("bluray", None) is True assert komprimieren_fuer("bluray", None) is True
def test_komprimieren_fuer_einzelner_disc_typ_abwaehlbar(): def test_komprimieren_fuer_einzelner_disc_typ_abwaehlbar():
"""Der Grund dafuer (Befund 25.07.2026): 4K-HEVC brauchte auf der Rippy-VM """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 gemessene 28-55 Stunden, weil die CPU kein AVX2 hat. Vorher gab es nur
einen globalen Schalter - wer 4K verlustfrei behalten und DVDs trotzdem einen globalen Schalter - wer 4K verlustfrei behalten und DVDs trotzdem
schrumpfen wollte, hatte keine Moeglichkeit.""" schrumpfen wollte, hatte keine Möglichkeit."""
from ripping import PRESET_KEINE, komprimieren_fuer from ripping import PRESET_KEINE, komprimieren_fuer
nur_uhd_aus = {"transcodePresetUhd": PRESET_KEINE, nur_uhd_aus = {"transcodePresetUhd": PRESET_KEINE,
@@ -299,20 +299,20 @@ def test_komprimieren_fuer_einzelner_disc_typ_abwaehlbar():
def test_preset_fuer_gibt_den_reservewert_nie_als_preset_zurueck(): def test_preset_fuer_gibt_den_reservewert_nie_als_preset_zurueck():
"""Sonst bekaeme HandBrake `--preset keine` und wuerde scheitern. Wer ueber """Sonst bekaeme HandBrake `--preset keine` und würde scheitern. Wer über
"Neu komprimieren" ausdruecklich doch komprimieren will, soll ein "Neu komprimieren" ausdruecklich doch komprimieren will, soll ein
brauchbares Preset bekommen statt eines Fehlers.""" brauchbares Preset bekommen statt eines Fehlers."""
from ripping import DEFAULT_HB_PRESET, PRESET_KEINE, preset_fuer from ripping import DEFAULT_HB_PRESET, PRESET_KEINE, preset_fuer
assert preset_fuer("uhd", {"transcodePresetUhd": PRESET_KEINE, assert preset_fuer("uhd", {"transcodePresetUhd": PRESET_KEINE,
"transcodePreset": "HQ 1080p30 Surround"}) == "HQ 1080p30 Surround" "transcodePreset": "HQ 1080p30 Surround"}) == "HQ 1080p30 Surround"
# Auch ohne allgemeines Preset kommt ein gueltiger Name heraus # Auch ohne allgemeines Preset kommt ein gültiger Name heraus
assert preset_fuer("uhd", {"transcodePresetUhd": PRESET_KEINE}) == DEFAULT_HB_PRESET assert preset_fuer("uhd", {"transcodePresetUhd": PRESET_KEINE}) == DEFAULT_HB_PRESET
def test_reservewert_kollidiert_mit_keinem_echten_preset(): def test_reservewert_kollidiert_mit_keinem_echten_preset():
"""Gegengeprueft gegen alle 90 Presets aus `HandBrakeCLI --preset-list` """Gegengeprueft gegen alle 90 Presets aus `HandBrakeCLI --preset-list`
im Worker-Image (25.07.2026) - keines heisst so.""" im Worker-Image (25.07.2026) - keines heißt so."""
from ripping import PRESET_KEINE from ripping import PRESET_KEINE
assert PRESET_KEINE == "keine" assert PRESET_KEINE == "keine"
+39 -39
View File
@@ -3,15 +3,15 @@
# Rippy einrichten — ein Befehl, keine Handarbeit. # Rippy einrichten — ein Befehl, keine Handarbeit.
# #
# Das Skript nimmt genau die Schritte ab, an denen man vorher scheitern konnte: # Das Skript nimmt genau die Schritte ab, an denen man vorher scheitern konnte:
# * Laufwerk finden (BEIDE Geraeteknoten — der sg-Knoten ist je Host anders, # * Laufwerk finden (BEIDE Geräteknoten — der sg-Knoten ist je Host anders,
# und ein fest eingetragener Standard ist damit auf fremden Hosts falsch) # und ein fest eingetragener Standard ist damit auf fremden Hosts falsch)
# * Ablage- und MakeMKV-Verzeichnis anlegen # * Ablage- und MakeMKV-Verzeichnis anlegen
# * shared-Mount-Propagation herstellen, falls Docker sie verlangt # * shared-Mount-Propagation herstellen, falls Docker sie verlangt
# * .env anlegen bzw. fehlende Werte ergaenzen, ohne Bestehendes zu ueberschreiben # * .env anlegen bzw. fehlende Werte ergänzen, ohne Bestehendes zu überschreiben
# * bauen und starten # * bauen und starten
# #
# Es ist absichtlich WIEDERHOLBAR: mehrfach ausfuehren aendert nichts kaputt. # Es ist absichtlich WIEDERHOLBAR: mehrfach ausführen ändert nichts kaputt.
# Es aendert NIE einen Wert, der schon in der .env steht. # Es ändert NIE einen Wert, der schon in der .env steht.
# #
# Aufruf: sudo ./install.sh # Aufruf: sudo ./install.sh
# sudo ./install.sh --nur-pruefen (nichts anfassen, nur berichten) # sudo ./install.sh --nur-pruefen (nichts anfassen, nur berichten)
@@ -33,7 +33,7 @@ titel() { printf '\n\033[1m%s\033[0m\n' "$*"; }
echo "======================================================================" echo "======================================================================"
echo " Rippy — Einrichtung" echo " Rippy — Einrichtung"
echo "======================================================================" echo "======================================================================"
[ "$NUR_PRUEFEN" = 1 ] && gelb "Nur-Pruefen-Modus: es wird nichts geaendert." [ "$NUR_PRUEFEN" = 1 ] && gelb "Nur-Prüfen-Modus: es wird nichts geändert."
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 1. Voraussetzungen # 1. Voraussetzungen
@@ -43,7 +43,7 @@ titel "1/6 Voraussetzungen"
if [ "$(uname -s)" != "Linux" ]; then if [ "$(uname -s)" != "Linux" ]; then
rot " X Das hier ist kein Linux. Rippy braucht einen Linux-Host mit Docker." rot " X Das hier ist kein Linux. Rippy braucht einen Linux-Host mit Docker."
rot " (Docker Desktop unter Windows/macOS kann keine optischen Laufwerke" rot " (Docker Desktop unter Windows/macOS kann keine optischen Laufwerke"
rot " durchreichen — deshalb geht es dort grundsaetzlich nicht.)" rot " durchreichen — deshalb geht es dort grundsätzlich nicht.)"
exit 1 exit 1
fi fi
gruen " OK Linux erkannt ($(uname -r))" gruen " OK Linux erkannt ($(uname -r))"
@@ -71,14 +71,14 @@ fi
if [ "$(id -u)" = "0" ]; then if [ "$(id -u)" = "0" ]; then
gruen " OK Als root gestartet" gruen " OK Als root gestartet"
elif [ "$NUR_PRUEFEN" = 1 ]; then elif [ "$NUR_PRUEFEN" = 1 ]; then
# Der Pruef-Modus aendert nichts und darf deshalb NICHT an root scheitern — # Der Prüf-Modus ändert nichts und darf deshalb NICHT an root scheitern —
# sonst kann man vor der Installation nicht nachsehen, ob alles passt. # sonst kann man vor der Installation nicht nachsehen, ob alles passt.
gelb " ! Ohne root — fuer die Pruefung in Ordnung. Die echte Installation" gelb " ! Ohne root — für die Prüfung in Ordnung. Die echte Installation"
gelb " braucht sudo (Verzeichnisse unter /srv, Mount-Propagation)." gelb " braucht sudo (Verzeichnisse unter /srv, Mount-Propagation)."
else else
rot " X Bitte mit sudo starten: sudo ./install.sh" rot " X Bitte mit sudo starten: sudo ./install.sh"
rot " (Verzeichnisse unter /srv und die Mount-Propagation brauchen root.)" rot " (Verzeichnisse unter /srv und die Mount-Propagation brauchen root.)"
rot " Nur nachsehen, ohne etwas zu aendern: ./install.sh --nur-pruefen" rot " Nur nachsehen, ohne etwas zu ändern: ./install.sh --nur-pruefen"
FEHLER=1 FEHLER=1
fi fi
@@ -89,11 +89,11 @@ fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
titel "2/6 Optisches Laufwerk" titel "2/6 Optisches Laufwerk"
# MakeMKV spricht Laufwerke ueber die SCSI-Generic-Schicht an und braucht # MakeMKV spricht Laufwerke über die SCSI-Generic-Schicht an und braucht
# deshalb ZWEI Knoten: /dev/srN und den passenden /dev/sgM. Welche sg-Nummer # deshalb ZWEI Knoten: /dev/srN und den passenden /dev/sgM. Welche sg-Nummer
# dazugehoert, ist je Host anders — deshalb wird sie hier ueber die # dazugehört, ist je Host anders — deshalb wird sie hier über die
# SCSI-Adresse ermittelt statt geraten. Beide Knoten zeigen in /sys auf # SCSI-Adresse ermittelt statt geraten. Beide Knoten zeigen in /sys auf
# dasselbe Geraeteverzeichnis (z. B. 3:0:0:0), das ist der Abgleich. # dasselbe Geräteverzeichnis (z. B. 3:0:0:0), das ist der Abgleich.
SR="" SR=""
SG="" SG=""
LAUFWERK="" LAUFWERK=""
@@ -118,14 +118,14 @@ if [ -n "$SR" ]; then
gruen " $SR + $SG (SCSI-Adresse abgeglichen, nicht geraten)" gruen " $SR + $SG (SCSI-Adresse abgeglichen, nicht geraten)"
else else
gelb " ! Kein optisches Laufwerk gefunden." gelb " ! Kein optisches Laufwerk gefunden."
gelb " Rippy laesst sich trotzdem installieren und kann dann als reine" gelb " Rippy lässt sich trotzdem installieren und kann dann als reine"
gelb " KOMPRIMIER-Maschine fuer andere Rippy-Instanzen dienen." gelb " KOMPRIMIER-Maschine für andere Rippy-Instanzen dienen."
gelb " Zum Rippen fehlt das Laufwerk. Haeufigste Ursachen:" gelb " Zum Rippen fehlt das Laufwerk. Häufigste Ursachen:"
gelb " - In einer VM: Laufwerk per USB-Passthrough durchreichen" gelb " - In einer VM: Laufwerk per USB-Passthrough durchreichen"
gelb " (Proxmox: qm set <vmid> -usb0 host=<hersteller>:<produkt>,usb3=1)" gelb " (Proxmox: qm set <vmid> -usb0 host=<hersteller>:<produkt>,usb3=1)"
gelb " NICHT als emuliertes CD-ROM (media=cdrom) — das kann keine" gelb " NICHT als emuliertes CD-ROM (media=cdrom) — das kann keine"
gelb " SCSI-Kommandos und MakeMKV sieht es nie." gelb " SCSI-Kommandos und MakeMKV sieht es nie."
gelb " - Auf echter Hardware: 'ls /dev/sr*' pruefen, Kabel/Strom." gelb " - Auf echter Hardware: 'ls /dev/sr*' prüfen, Kabel/Strom."
fi fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -136,20 +136,20 @@ for d in "$MEDIA_DIR" "$MAKEMKV_DIR"; do
if [ -d "$d" ]; then if [ -d "$d" ]; then
gruen " OK $d liegt schon da" gruen " OK $d liegt schon da"
elif [ "$NUR_PRUEFEN" = 1 ]; then elif [ "$NUR_PRUEFEN" = 1 ]; then
gelb " ! $d fehlt (wuerde angelegt)" gelb " ! $d fehlt (würde angelegt)"
else else
mkdir -p "$d" && gruen " OK $d angelegt" mkdir -p "$d" && gruen " OK $d angelegt"
fi fi
done done
echo " Ablage: $MEDIA_DIR (hier landen die fertigen Filme)" echo " Ablage: $MEDIA_DIR (hier landen die fertigen Filme)"
echo " MakeMKV-Daten: $MAKEMKV_DIR (Schluesselspeicher, rebuild-fest)" echo " MakeMKV-Daten: $MAKEMKV_DIR (Schlüsselspeicher, rebuild-fest)"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 4. Mount-Propagation # 4. Mount-Propagation
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
titel "4/6 Mount-Propagation (fuer NAS-Freigaben aus dem UI)" titel "4/6 Mount-Propagation (für NAS-Freigaben aus dem UI)"
# Rippy haengt NAS-Freigaben aus dem UI ein. Damit die auch in den anderen # 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. # 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. # Auf den meisten systemd-Hosts ist / schon rshared und es ist nichts zu tun.
propagation_ok() { propagation_ok() {
@@ -159,19 +159,19 @@ propagation_ok() {
if propagation_ok; then if propagation_ok; then
gruen " OK $MEDIA_DIR ist bereits 'shared' — nichts zu tun" gruen " OK $MEDIA_DIR ist bereits 'shared' — nichts zu tun"
elif [ "$NUR_PRUEFEN" = 1 ]; then elif [ "$NUR_PRUEFEN" = 1 ]; then
gelb " ! Propagation fehlt (wuerde eingerichtet)" gelb " ! Propagation fehlt (würde eingerichtet)"
else else
mount --bind "$MEDIA_DIR" "$MEDIA_DIR" 2>/dev/null mount --bind "$MEDIA_DIR" "$MEDIA_DIR" 2>/dev/null
mount --make-rshared "$MEDIA_DIR" 2>/dev/null mount --make-rshared "$MEDIA_DIR" 2>/dev/null
if propagation_ok; then if propagation_ok; then
gruen " OK Propagation eingerichtet" gruen " OK Propagation eingerichtet"
# Neustart-fest machen, sonst ist es nach dem naechsten Reboot wieder weg # Neustart-fest machen, sonst ist es nach dem nächsten Reboot wieder weg
# und das Einhaengen aus dem UI scheitert stillschweigend. # und das Einhängen aus dem UI scheitert stillschweigend.
UNIT="/etc/systemd/system/$(systemd-escape -p --suffix=mount "$MEDIA_DIR" 2>/dev/null)" 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 if [ -n "${UNIT:-}" ] && [ ! -e "$UNIT" ] && command -v systemctl >/dev/null 2>&1; then
cat > "$UNIT" <<UNITEOF cat > "$UNIT" <<UNITEOF
[Unit] [Unit]
Description=Rippy-Ablage als shared mount (fuer NAS-Freigaben aus dem UI) Description=Rippy-Ablage als shared mount (für NAS-Freigaben aus dem UI)
After=local-fs.target After=local-fs.target
[Mount] [Mount]
@@ -187,12 +187,12 @@ UNITEOF
systemctl enable "$(basename "$UNIT")" >/dev/null 2>&1 \ systemctl enable "$(basename "$UNIT")" >/dev/null 2>&1 \
&& gruen " OK Neustart-fest gemacht ($(basename "$UNIT"))" \ && gruen " OK Neustart-fest gemacht ($(basename "$UNIT"))" \
|| gelb " ! Konnte nicht neustart-fest gemacht werden — nach einem Reboot" || gelb " ! Konnte nicht neustart-fest gemacht werden — nach einem Reboot"
gelb " ggf. dieses Skript erneut ausfuehren." gelb " ggf. dieses Skript erneut ausführen."
fi fi
else else
gelb " ! Propagation liess sich nicht einrichten." gelb " ! Propagation liess sich nicht einrichten."
gelb " Rippy laeuft trotzdem — nur das Einhaengen von NAS-Freigaben AUS DEM UI" gelb " Rippy läuft trotzdem — nur das Einhängen von NAS-Freigaben AUS DEM UI"
gelb " funktioniert dann nicht. Freigaben stattdessen auf dem Host einhaengen," gelb " funktioniert dann nicht. Freigaben stattdessen auf dem Host einhängen,"
gelb " z. B. unter $MEDIA_DIR/nas. Betrifft nur klassisches Docker mit" gelb " z. B. unter $MEDIA_DIR/nas. Betrifft nur klassisches Docker mit"
gelb " SYS_ADMIN — nicht rootless/Podman." gelb " SYS_ADMIN — nicht rootless/Podman."
fi fi
@@ -207,7 +207,7 @@ cd "$(dirname "$0")" || exit 1
if [ ! -f .env ]; then if [ ! -f .env ]; then
if [ "$NUR_PRUEFEN" = 1 ]; then if [ "$NUR_PRUEFEN" = 1 ]; then
gelb " ! .env fehlt (wuerde aus .env.example erzeugt)" gelb " ! .env fehlt (würde aus .env.example erzeugt)"
else else
cp .env.example .env && gruen " OK .env aus .env.example erzeugt" cp .env.example .env && gruen " OK .env aus .env.example erzeugt"
fi fi
@@ -218,10 +218,10 @@ fi
# Setzt einen Wert NUR, wenn er noch nicht (unkommentiert) gesetzt ist. # Setzt einen Wert NUR, wenn er noch nicht (unkommentiert) gesetzt ist.
setze_wenn_leer() { setze_wenn_leer() {
schluessel="$1"; wert="$2" schluessel="$1"; wert="$2"
[ "$NUR_PRUEFEN" = 1 ] && { echo " wuerde setzen: $schluessel=$wert"; return; } [ "$NUR_PRUEFEN" = 1 ] && { echo " würde setzen: $schluessel=$wert"; return; }
[ -f .env ] || return [ -f .env ] || return
if grep -qE "^${schluessel}=.+" .env; then if grep -qE "^${schluessel}=.+" .env; then
echo " $schluessel steht schon in der .env — unveraendert gelassen" echo " $schluessel steht schon in der .env — unverändert gelassen"
else else
sed -i "/^#*${schluessel}=/d" .env sed -i "/^#*${schluessel}=/d" .env
printf '%s=%s\n' "$schluessel" "$wert" >> .env printf '%s=%s\n' "$schluessel" "$wert" >> .env
@@ -236,9 +236,9 @@ fi
setze_wenn_leer MAKEMKV_DATA_HOST "$MAKEMKV_DIR" setze_wenn_leer MAKEMKV_DATA_HOST "$MAKEMKV_DIR"
echo " API-Keys (TMDB/OMDb) brauchst du hier NICHT einzutragen —" echo " API-Keys (TMDB/OMDb) brauchst du hier NICHT einzutragen —"
echo " der Einrichtungs-Assistent im Browser fragt sie ab und prueft sie." echo " der Einrichtungs-Assistent im Browser fragt sie ab und prüft sie."
# Gegenprobe: Zeigen die WIRKSAMEN Werte auf Geraete, die es hier gibt? Faengt # Gegenprobe: Zeigen die WIRKSAMEN Werte auf Geräte, die es hier gibt? Fängt
# den Fall "'.env' von einem anderen Rechner kopiert" und Tippfehler ab — sonst # 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. # startet Docker mit einem Pfad, den es nicht gibt, und die Meldung ist kryptisch.
if [ -f .env ]; then if [ -f .env ]; then
@@ -247,10 +247,10 @@ if [ -f .env ]; then
wirksam=$(grep -E "^${schluessel}=" .env 2>/dev/null | tail -1 | cut -d= -f2-) wirksam=$(grep -E "^${schluessel}=" .env 2>/dev/null | tail -1 | cut -d= -f2-)
[ -n "${wirksam:-}" ] || wirksam="$standard" [ -n "${wirksam:-}" ] || wirksam="$standard"
if [ ! -e "$wirksam" ]; then if [ ! -e "$wirksam" ]; then
gelb " ! $schluessel zeigt auf $wirksam — dieses Geraet gibt es hier NICHT." gelb " ! $schluessel zeigt auf $wirksam — dieses Gerät gibt es hier NICHT."
if [ -n "$SR" ]; then if [ -n "$SR" ]; then
gelb " Gefunden wurde stattdessen: $SR + $SG" gelb " Gefunden wurde stattdessen: $SR + $SG"
gelb " Trage das in der .env ein (oder loesche die Zeile $schluessel=)." gelb " Trage das in der .env ein (oder lösche die Zeile $schluessel=)."
else else
gelb " Es wurde auch kein anderes Laufwerk gefunden — siehe Schritt 2." gelb " Es wurde auch kein anderes Laufwerk gefunden — siehe Schritt 2."
fi fi
@@ -264,9 +264,9 @@ fi
titel "6/6 Bauen und starten" titel "6/6 Bauen und starten"
if [ "$NUR_PRUEFEN" = 1 ]; then if [ "$NUR_PRUEFEN" = 1 ]; then
gelb " ! Uebersprungen (Nur-Pruefen-Modus)." gelb " ! Übersprungen (Nur-Prüfen-Modus)."
echo "" echo ""
gruen "Pruefung fertig. Ohne --nur-pruefen wuerde jetzt gebaut und gestartet." gruen "Prüfung fertig. Ohne --nur-pruefen würde jetzt gebaut und gestartet."
exit 0 exit 0
fi fi
@@ -274,7 +274,7 @@ echo " Der erste Bau dauert je nach Internet 3-10 Minuten. Nur beim ersten
echo "" echo ""
if ! $COMPOSE up -d --build; then if ! $COMPOSE up -d --build; then
rot "" rot ""
rot "Der Start ist gescheitert. Die haeufigsten Ursachen:" rot "Der Start ist gescheitert. Die häufigsten Ursachen:"
rot " - Kein Platz auf der Platte: df -h /" rot " - Kein Platz auf der Platte: df -h /"
rot " - Port 80 schon belegt: ss -tlnp | grep ':80 '" rot " - Port 80 schon belegt: ss -tlnp | grep ':80 '"
rot " - Download von makemkv.com gedrosselt (Cloudflare): Tarballs von" rot " - Download von makemkv.com gedrosselt (Cloudflare): Tarballs von"
@@ -288,10 +288,10 @@ IP=$(ip -4 route get 1.1.1.1 2>/dev/null | grep -oE 'src [0-9.]+' | awk '{print
echo "" echo ""
echo "======================================================================" echo "======================================================================"
gruen " Fertig. Rippy laeuft." gruen " Fertig. Rippy läuft."
echo "======================================================================" echo "======================================================================"
echo "" echo ""
echo " Jetzt im Browser oeffnen: http://$IP" echo " Jetzt im Browser öffnen: http://$IP"
echo "" echo ""
echo " Dort startet der Einrichtungs-Assistent. Er zeigt dir, ob Laufwerk," echo " Dort startet der Einrichtungs-Assistent. Er zeigt dir, ob Laufwerk,"
echo " Worker und Platz in Ordnung sind, und stellt die Kompression passend" echo " Worker und Platz in Ordnung sind, und stellt die Kompression passend"