refactor(core): V2-0 — gemeinsames Paket rippy statt drei Zwillingsdateien
Ampel / ampel (push) Successful in 41s

WAS: Neues Paket src/rippy (core/drives/rip). detection.py,
makemkv_daten.py und notify.py lagen je ZWEIMAL im Repo — unter
docker/api UND docker/worker, byte-identisch. Jetzt gibt es sie einmal;
beide Container importieren dieselbe Datei. Verhalten unveraendert.

WARUM: Es gab kein geteiltes Paket zwischen den Containern, deshalb die
Kopien. docker/api/db.py sagt es im Kopfkommentar selbst: "Wer die
Struktur aendert, aendert BEIDE Dateien." Ein Waechter-Test
(test_zwillinge_sind_byteweise_identisch) hat das mechanisch
abgesichert — er war noetig, weil die Konstruktion falsch war. Erste
Etappe des v2-Plans (KONZEPT-V2.md §8.3).

IM EINZELNEN:
- src/rippy/{core/notify, drives/detection, rip/makemkv_daten}.py
- conftest.py in der Wurzel legt src/ auf den sys.path (die Ampel ruft
  pytest dort auf).
- Beide Dockerfiles kopieren src/rippy nach /app/rippy — /app ist
  Arbeitsverzeichnis und uvicorn-App-Dir, also ohne PYTHONPATH findbar.
- worker_setup_paket packt das Paket ausdruecklich mit ins Zip: die
  Schleife sah nur die oberste Ebene, ein Unterordner waere nie
  mitgekommen und der Windows-Worker beim Start gestorben.
- Die zwei Test-Dateien fuer makemkv_daten sind zu einer verschmolzen
  (die Faelle aus beiden). Damit entfaellt auch der importlib-Umweg im
  Worker-Test: der war noetig, weil bei "pytest -q" aus der Wurzel
  docker/api zuerst eingesammelt wird und jeder weitere Import nur noch
  den sys.modules-Cache trifft — die Worker-Kopie wurde also nie
  angefasst. Netto -13 doppelte Tests, +2 neue (siehe unten).

EIN FEHLER, DEN DER UMBAU FAST AUSGELIEFERT HAETTE: In tasks.py steht
der detection-Import in einem "try/except ImportError" — absichtlich,
denn der native Windows-Worker hat kein fcntl und soll trotzdem
starten. Das except verschluckt aber JEDEN ImportError, auch einen
falschen Modulpfad. Auf Linux haette der Worker ab sofort still
detect_disc_type=None gesetzt und jeden Rip verweigert, ohne dass
irgendwo ein Fehler stuende — genau die Klasse "still scheiternder
Hintergrund-Prozess" aus AGENTS.md. Gefunden ueber eine zweite Suche
mit eingerueckten Treffern.

Waechter dagegen: src/rippy/test_paket.py prueft mit
importlib.util.find_spec (fuehrt nichts aus, laeuft also auch auf
Windows ohne fcntl), dass es die drei Modulpfade wirklich gibt. Beim
Wegnehmen von detection.py gegengeprueft: Test wird rot, nach
Wiederherstellen gruen.

GEMESSEN: ruff sauber, 290 Tests gruen + 1 uebersprungen (lokal, ohne
die zwei Linux-only-Module). Kein Modul liegt noch doppelt im Repo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-28 08:38:29 +02:00
co-authored by Claude Opus 5
parent 20675a98fa
commit b98dc5eef5
23 changed files with 235 additions and 727 deletions
+22
View File
@@ -0,0 +1,22 @@
"""Pytest-Setup für das ganze Repo: das gemeinsame Paket `rippy` findbar machen.
Die Ampel ruft `pytest -q` in der Repo-Wurzel auf (.gitea/workflows/ci.yml).
Ohne diesen Eintrag scheitert JEDER Import von `rippy.*` — das Paket liegt
unter `src/`, und `src/` steht in keinem Standardpfad.
Warum `src/` und nicht `rippy/` direkt in der Wurzel: Sonst würde `pytest`
beim Einsammeln in dasselbe Verzeichnis schauen, in dem auch `docker/` liegt,
und ein zufällig gleichnamiges Modul könnte gewinnen. Ein eigenes Quellen-
Verzeichnis macht die Grenze eindeutig.
Die beiden conftest.py unter docker/api und docker/worker bleiben bestehen —
sie machen die dortigen FLACHEN Modul-Importe möglich (`import ripping`,
`import db`). Beide Mechanismen greifen nebeneinander.
"""
import os
import sys
_QUELLEN = os.path.join(os.path.dirname(os.path.abspath(__file__)), "src")
if _QUELLEN not in sys.path:
sys.path.insert(0, _QUELLEN)
+9
View File
@@ -17,10 +17,19 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY docker/api/ . COPY docker/api/ .
# Gemeinsamer Kern (Etappe V2-0): liegt im Repo unter src/rippy, im Image
# neben den API-Modulen. /app ist Arbeitsverzeichnis und uvicorn-App-Dir,
# damit findet "import rippy" das Paket ohne PYTHONPATH.
COPY src/rippy ./rippy
# Worker-Selbstversorgung: die API liefert Installer + Worker-Code an # Worker-Selbstversorgung: die API liefert Installer + Worker-Code an
# native Worker aus (GET /worker-setup/windows bzw. /worker-setup/paket) — # native Worker aus (GET /worker-setup/windows bzw. /worker-setup/paket) —
# die Zielmaschine braucht weder git noch Docker. # die Zielmaschine braucht weder git noch Docker.
COPY docker/worker/*.py docker/worker/requirements.txt worker_dist/ COPY docker/worker/*.py docker/worker/requirements.txt worker_dist/
# Der Worker importiert seit V2-0 aus dem gemeinsamen Paket (tasks.py, caps.py).
# Ohne diese Zeile fehlte es im Zip und der Windows-Worker stürbe beim Start
# mit ModuleNotFoundError — worker_setup_paket packt genau diesen Ordner mit ein.
COPY src/rippy worker_dist/rippy
COPY deploy/worker-windows/installer.py worker_dist/ COPY deploy/worker-windows/installer.py worker_dist/
# Rippy-Icon: Der Installer legt damit eine Verknüpfung auf den Desktop # Rippy-Icon: Der Installer legt damit eine Verknüpfung auf den Desktop
# (Commander-Wunsch 26.07.2026). Ohne die Datei auf der Zielmaschine trüge die # (Commander-Wunsch 26.07.2026). Ohne die Datei auf der Zielmaschine trüge die
+1 -1
View File
@@ -10,7 +10,7 @@ import glob
import os import os
from fcntl import ioctl from fcntl import ioctl
from detection import ( from rippy.drives.detection import (
CDS_DISC_OK, CDS_DISC_OK,
classify, classify,
disc_size_bytes, disc_size_bytes,
+22 -4
View File
@@ -14,15 +14,20 @@ import uuid
import db import db
import devices as device_discovery import devices as device_discovery
import eta import eta
import makemkv_daten from rippy.rip import makemkv_daten
import makemkv_key import makemkv_key
import mounts as mount_verwaltung import mounts as mount_verwaltung
import notify from rippy.core import notify
import phasen import phasen
import presets as preset_auswahl import presets as preset_auswahl
import rohdaten import rohdaten
from celery_client import celery_client, start_rip from celery_client import celery_client, start_rip
from detection import CDS_DISC_OK, CDS_NO_DISC, CDS_TRAY_OPEN, drive_status from rippy.drives.detection import (
CDS_DISC_OK,
CDS_NO_DISC,
CDS_TRAY_OPEN,
drive_status,
)
from config_validation import validate_config, ConfigValidationError from config_validation import validate_config, ConfigValidationError
from cache import get as cache_get, init_cache, set as cache_set from cache import get as cache_get, init_cache, set as cache_set
@@ -1783,7 +1788,20 @@ async def worker_setup_paket():
# requirements.txt für die venv, rippy.ico für die Verknüpfung # requirements.txt für die venv, rippy.ico für die Verknüpfung
elif name in ("requirements.txt", "rippy.ico"): elif name in ("requirements.txt", "rippy.ico"):
z.write(os.path.join("worker_dist", name), name) z.write(os.path.join("worker_dist", name), name)
# Das gemeinsame Paket muss MIT (Etappe V2-0): tasks.py und caps.py
# importieren seitdem "from rippy.rip import makemkv_daten". Die
# Schleife oben sieht nur die oberste Ebene — ein Unterordner käme
# nie mit, und der Windows-Worker stürbe beim Start mit
# ModuleNotFoundError. Deshalb hier ausdrücklich durchlaufen.
paket = os.path.join("worker_dist", "rippy")
for wurzel, _, dateien in os.walk(paket):
for datei in sorted(dateien):
if not datei.endswith(".py") or datei.startswith("test_"):
continue
voll = os.path.join(wurzel, datei)
z.write(voll, os.path.relpath(voll, "worker_dist"))
# Write RIPPY_VERSION to version.txt # Write RIPPY_VERSION to version.txt
version_str = os.getenv("RIPPY_VERSION", "dev") version_str = os.getenv("RIPPY_VERSION", "dev")
z.writestr("version.txt", version_str) z.writestr("version.txt", version_str)
-66
View File
@@ -1,66 +0,0 @@
"""Webhook-Benachrichtigungen bei Job-Ende (Discord, Slack, ntfy, generisch).
Bis 24.07. war das notificationWebhook-Setting ein Placebo: das UI speicherte
die URL, aber NICHTS hat je gesendet. Jetzt meldet der Worker Job-Ende
(fertig/fehlgeschlagen/abgebrochen) und die API bietet einen Test-Endpoint.
Das Modul existiert bewusst identisch in API und Worker
(docker/api/notify.py) es gibt kein geteiltes Paket zwischen den Containern.
Wer es ändert, ändert BEIDE Dateien.
Payload-Formate (dokumentiert, nicht geraten AGENTS Regel D):
- Discord: POST JSON {"content": "..."} discord.com/developers/docs/resources/webhook
- Slack: POST JSON {"text": "..."} api.slack.com/messaging/webhooks
- ntfy: POST Roh-Text an https://ntfy.sh/<topic>, Titel via ?title=
docs.ntfy.sh/publish
- Generisch: POST JSON {"title", "message", "level"} für eigene Empfänger
(Home Assistant, n8n, eigene Skripte).
"""
import requests
TIMEOUT_SEKUNDEN = 10
def erkenne_webhook_typ(url: str) -> str:
"""Erkennt den Dienst an der URL — der Nutzer muss nichts konfigurieren."""
u = url.lower()
if "discord.com/api/webhooks" in u or "discordapp.com/api/webhooks" in u:
return "discord"
if "hooks.slack.com" in u:
return "slack"
if "ntfy" in u:
return "ntfy"
return "generisch"
def baue_payload(url: str, titel: str, text: str, level: str = "info"):
"""Pure Funktion (testbar): (typ, json_payload) — ntfy sendet Roh-Text."""
typ = erkenne_webhook_typ(url)
if typ == "discord":
return typ, {"content": f"**{titel}**\n{text}"}
if typ == "slack":
return typ, {"text": f"*{titel}*\n{text}"}
if typ == "ntfy":
return typ, None
return typ, {"title": titel, "message": text, "level": level}
def sende(url: str, titel: str, text: str, level: str = "info") -> None:
"""Schickt die Nachricht; wirft RuntimeError mit Klartext bei Fehlern."""
typ, payload = baue_payload(url, titel, text, level)
try:
if typ == "ntfy":
antwort = requests.post(
url, data=text.encode("utf-8"),
params={"title": titel}, timeout=TIMEOUT_SEKUNDEN,
)
else:
antwort = requests.post(url, json=payload, timeout=TIMEOUT_SEKUNDEN)
except requests.RequestException as e:
raise RuntimeError(f"Webhook nicht erreichbar: {e}") from e
if antwort.status_code >= 300:
raise RuntimeError(
f"Webhook antwortete mit HTTP {antwort.status_code}: "
f"{(antwort.text or '')[:200]}"
)
+1 -1
View File
@@ -10,7 +10,7 @@ import shutil
import subprocess import subprocess
from typing import Dict, List, Optional from typing import Dict, List, Optional
import detection from rippy.drives import detection
from clients.tmdb import TMDBClient from clients.tmdb import TMDBClient
from clients.jikan import JikanClient from clients.jikan import JikanClient
from clients.musicbrainz import MusicBrainzClient from clients.musicbrainz import MusicBrainzClient
-118
View File
@@ -1,118 +0,0 @@
"""Tests für die puren Helfer aus makemkv_daten.
Bewusst OHNE Dateisystem, DB und fcntl deshalb laufen sie auch auf Windows
und nicht nur in der Ampel. Geprueft wird genau das, was ohne Container und
ohne echte Disc entscheidbar ist: das Zeilenformat der KEYDB.cfg, die
Plausibilitaetspruefung beim Hochladen, die Namenshaerte der AACS-Dumps und
das Zusammenfuehren der settings.conf.
"""
from makemkv_daten import (
ist_aacs_dump,
keydb_pruefen,
settings_conf_zusammenfuehren,
zaehle_disc_eintraege,
)
# Echte Beispielzeilen im libaacs-Format: 40 Hex-Zeichen Disc-Kennung, dann
# "= Titel". Zweite Zeile mit 0x-Praefix, weil die oeffentlichen Dateien beide
# Schreibweisen mischen (Fundstelle steht im Modul-Docstring von makemkv_daten).
_GUELTIG = """; KEYDB.cfg — Beispiel
0123456789ABCDEF0123456789ABCDEF01234567 = Akira
0xFEDCBA9876543210FEDCBA9876543210FEDCBA98 = Blade Runner | V | 00112233445566778899AABBCCDDEEFF
"""
def test_zaehle_disc_eintraege_zaehlt_nur_echte_disc_zeilen():
# Kommentar- und Leerzeilen dürfen NICHT mitgezaehlt werden, sonst meldet
# das UI "da liegt was drin", obwohl die Datei keinen Schluessel enthält.
assert zaehle_disc_eintraege(_GUELTIG) == 2
def test_zaehle_disc_eintraege_ohne_disc_zeile_ist_null():
nur_kommentare = "; nur ein Kommentar\n\n;noch einer\n"
assert zaehle_disc_eintraege(nur_kommentare) == 0
assert zaehle_disc_eintraege("") == 0
def test_zaehle_disc_eintraege_akzeptiert_0x_praefix_einzeln():
assert zaehle_disc_eintraege("0x0123456789abcdef0123456789abcdef01234567 = Tenet") == 1
def test_zaehle_disc_eintraege_lehnt_zu_kurze_kennung_ab():
# 39 statt 40 Hex-Zeichen: das ist keine Disc-Kennung, sondern Tippfehler
# oder eine abgeschnittene Datei — darf nicht als Eintrag durchgehen.
assert zaehle_disc_eintraege("0123456789ABCDEF0123456789ABCDEF0123456 = Kurz") == 0
def test_keydb_pruefen_meldet_leere_datei():
assert keydb_pruefen("") != ""
assert keydb_pruefen(" \n\n ") != ""
def test_keydb_pruefen_erkennt_html():
# Häufigster Bedienfehler: statt der Datei landet die HTML-Fehlerseite
# eines Downloads im Feld. MakeMKV würde dann still weiter meckern.
fehler = keydb_pruefen("<!DOCTYPE html>\n<html><body>404 Not Found</body></html>\n")
assert "HTML" in fehler
def test_keydb_pruefen_meldet_datei_ohne_disc_zeile():
# Text ist da, aber keine einzige Disc-Kennung — z. B. eine Liesmich-Datei.
assert keydb_pruefen("Das hier ist irgendein Text ohne Schluessel.\n") != ""
def test_keydb_pruefen_laesst_gueltige_datei_durch():
# "" heißt laut Vertrag: alles in Ordnung, darf geschrieben werden.
assert keydb_pruefen(_GUELTIG) == ""
def test_ist_aacs_dump_erkennt_echten_namen():
# So heißt der Dump, den MakeMKV am 25.07.2026 für Akira UHD abgelegt hat
# (Meldung 3332) — dieser Name MUSS zum Download durchkommen.
assert ist_aacs_dump("MKB20_v76_UHD_AKIRA_C02B.tgz") is True
def test_ist_aacs_dump_blockt_pfad_tricks():
# Der Download-Endpunkt hängt den Namen an das Datenverzeichnis — ein
# durchgelassenes ".." oder ein Pfadtrenner wäre ein Ausbruch.
assert ist_aacs_dump("../x.tgz") is False
assert ist_aacs_dump("../../etc/passwd.tgz") is False
assert ist_aacs_dump("unter/ordner.tgz") is False
assert ist_aacs_dump("unter\\ordner.tgz") is False
assert ist_aacs_dump(".versteckt.tgz") is False
def test_ist_aacs_dump_lehnt_andere_endungen_ab():
# Nur die Dumps sollen abholbar sein — nicht settings.conf, nicht
# _private_data.tar und schon gar nicht die KEYDB.cfg selbst.
assert ist_aacs_dump("KEYDB.cfg") is False
assert ist_aacs_dump("settings.conf") is False
assert ist_aacs_dump("_private_data.tar") is False
assert ist_aacs_dump("") is False
def test_settings_conf_ersetzt_alten_key_und_behaelt_den_rest():
# Regression: bis 25.07.2026 wurde die Datei komplett überschrieben. Mit
# dem jetzt persistenten Datenverzeichnis wäre app_UpdateEnable vor jedem
# Rip weg gewesen.
alt = 'app_UpdateEnable = "1"\napp_Key = "T-alt"\napp_DestinationDir = "/tmp"\n'
neu = settings_conf_zusammenfuehren(alt, "T-neu")
assert 'app_Key = "T-neu"' in neu
assert "T-alt" not in neu
assert 'app_UpdateEnable = "1"' in neu
assert 'app_DestinationDir = "/tmp"' in neu
def test_settings_conf_leerer_key_entfernt_die_zeile():
# Ein bewusst geleerter Key darf nicht heimlich weiterwirken.
neu = settings_conf_zusammenfuehren('app_Key = "T-alt"\napp_UpdateEnable = "1"\n', "")
assert "app_Key" not in neu
assert 'app_UpdateEnable = "1"' in neu
def test_settings_conf_aus_dem_nichts_endet_mit_zeilenumbruch():
# Erster Start: es gibt noch keine settings.conf. MakeMKV erwartet eine
# Datei mit abschliessendem Zeilenumbruch.
assert settings_conf_zusammenfuehren("", "T-neu") == 'app_Key = "T-neu"\n'
assert settings_conf_zusammenfuehren("", "") == ""
+6
View File
@@ -147,6 +147,12 @@ COPY docker/worker/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY docker/worker/ . COPY docker/worker/ .
# Gemeinsamer Kern (Etappe V2-0): liegt im Repo unter src/rippy, im Image
# neben den Worker-Modulen. Celery startet mit /app als Arbeitsverzeichnis,
# damit findet "import rippy" das Paket ohne PYTHONPATH.
COPY src/rippy ./rippy
RUN chmod +x /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh
ENTRYPOINT ["/app/entrypoint.sh"] ENTRYPOINT ["/app/entrypoint.sh"]
+1 -1
View File
@@ -385,7 +385,7 @@ def werkzeug_versionen() -> dict:
# Ohne diese Prüfung trüge er im UI dauerhaft die Warnung "keine # Ohne diese Prüfung trüge er im UI dauerhaft die Warnung "keine
# KEYDB.cfg", obwohl ihn das gar nichts angeht. # KEYDB.cfg", obwohl ihn das gar nichts angeht.
try: try:
import makemkv_daten from rippy.rip import makemkv_daten
daten_dir = makemkv_daten.DATEN_DIR daten_dir = makemkv_daten.DATEN_DIR
except Exception: except Exception:
daten_dir = "" daten_dir = ""
-97
View File
@@ -1,97 +0,0 @@
"""Disc-Erkennung über Kernel-ioctls — ohne `file`, ohne udev.
Warum neu (Review 23.07.): Die alte Erkennung rief `file -L /dev/sr0` auf.
`file` liest ohne `-s` aber NIE den Inhalt eines Block-Devices die Ausgabe ist
immer nur "block special", die Erkennung lieferte also strukturell IMMER
"unknown". Der alte Test hatte sich die `file`-Ausgabe passend gemockt und
bewies damit nichts.
Die ioctl-Konstanten stammen aus der Kernel-UAPI (include/uapi/linux/cdrom.h
bzw. linux/fs.h für BLKGETSIZE64) und sind seit Jahrzehnten stabil.
"""
import os
import struct
from fcntl import ioctl
# include/uapi/linux/cdrom.h
CDROM_DRIVE_STATUS = 0x5326
CDROM_DISC_STATUS = 0x5327
CDS_NO_DISC = 1
CDS_TRAY_OPEN = 2
CDS_DRIVE_NOT_READY = 3
CDS_DISC_OK = 4
CDS_AUDIO = 100
CDS_DATA_1 = 101
CDS_DATA_2 = 102
CDS_XA_2_1 = 103
CDS_XA_2_2 = 104
CDS_MIXED = 105
# include/uapi/linux/fs.h: BLKGETSIZE64 = _IOR(0x12, 114, size_t) auf 64-bit
BLKGETSIZE64 = 0x80081272
# Eine DVD9 fasst ~8,5 GB; Blu-ray beginnt bei 25 GB (Single Layer).
# Alles ab 10 GB ist also sicher eine Blu-ray.
BLURAY_MIN_BYTES = 10 * 1024**3
# 4K-UHD-Discs sind BD-66 (66 GB) oder BD-100 — eine normale BD-50 bleibt
# unter ~47 GiB. Ab 55 GiB ist es also sicher eine UHD. (Seltene 50-GB-UHDs
# laufen als "bluray" — der Rip-Weg ist ohnehin identisch.)
UHD_MIN_BYTES = 55 * 1024**3
def _open_nonblock(device_path: str) -> int:
"""O_NONBLOCK ist Pflicht: ohne blockiert open() bis eine Disc eingelegt ist."""
return os.open(device_path, os.O_RDONLY | os.O_NONBLOCK)
def drive_status(device_path: str) -> int:
"""CDROM_DRIVE_STATUS: 1=keine Disc, 2=Schublade offen, 3=nicht bereit, 4=Disc ok."""
fd = _open_nonblock(device_path)
try:
return ioctl(fd, CDROM_DRIVE_STATUS, 0)
finally:
os.close(fd)
def disc_status(device_path: str) -> int:
"""CDROM_DISC_STATUS: 100=Audio, 101-104=Daten, 105=Mixed."""
fd = _open_nonblock(device_path)
try:
return ioctl(fd, CDROM_DISC_STATUS, 0)
finally:
os.close(fd)
def disc_size_bytes(device_path: str) -> int:
"""Größe des eingelegten Mediums in Bytes (BLKGETSIZE64)."""
fd = _open_nonblock(device_path)
try:
buf = bytearray(8)
ioctl(fd, BLKGETSIZE64, buf)
return struct.unpack("Q", bytes(buf))[0]
finally:
os.close(fd)
def classify(disc_status_code: int, size_bytes: int) -> str:
"""Pure Zuordnung (testbar): Disc-Status + Größe → cd | dvd | bluray | uhd | unknown."""
if disc_status_code in (CDS_AUDIO, CDS_MIXED):
return "cd"
if disc_status_code in (CDS_DATA_1, CDS_DATA_2, CDS_XA_2_1, CDS_XA_2_2):
if size_bytes >= UHD_MIN_BYTES:
return "uhd"
return "bluray" if size_bytes >= BLURAY_MIN_BYTES else "dvd"
return "unknown"
def detect_disc_type(device_path: str) -> str:
"""Erkennt den Typ der eingelegten Disc; 'no_disc' wenn keine drin ist."""
try:
if drive_status(device_path) != CDS_DISC_OK:
return "no_disc"
return classify(disc_status(device_path), disc_size_bytes(device_path))
except OSError:
return "unknown"
-375
View File
@@ -1,375 +0,0 @@
"""MakeMKV-Datenverzeichnis: Schlüsselspeicher, KEYDB.cfg, AACS-Dumps.
WARUM ES DIESE DATEI GIBT (Befund 25.07.2026, auf BEIDEN Maschinen gemessen):
4K-UHD-Discs scheiterten mit "The volume key is unknown for this disc". Die
Ursache ist weder die Disc noch das Laufwerk LibreDrive v06.3 läuft und
MakeMKV liest die Disc sondern:
makemkvcon unter LINUX ruft die Disc-Schluessel nie ab.
Die Windows-Version tut es.
Gemessen, nicht vermutet:
* Linux: in KEINEM Lauf auch nur eine Verbindung nach draussen. Geprueft mit
leerem UND mit gefülltem Schlüsselspeicher, mit und ohne --noscan, mit
dev:/dev/sr0 und mit disc:0, und mit erzwungener frischer Prüfung
(update.conf gelöscht, Meldung 5074 belegt den Web-Kontakt). Immer:
keine Verbindung, kein Schluessel.
* Windows, dieselbe Disc, dasselbe Laufwerk: Meldung 3338 "Downloading
latest HK to ...", Verbindung nach 185.84.108.20:443
(web33.majordomo.ru), _private_data.tar wächst und die Disc geht auf
(TCOUNT:5, "Operation successfully completed").
* Der Code dafür steckt auch im Linux-Binary: die Meldungsvorlage
"Downloading latest %1 to %2 ..." steht in makemkvcon. Sie löst nur nie
aus. Gleiches Symptom im Forum, seit Jahren offen und unbeantwortet.
FRUEHERE FEHLDIAGNOSE, bewusst festgehalten: An dieser Stelle stand zuerst,
MakeMKVs Schluessel-Kanal sei abgeschaltet. Das war FALSCH. Die Herleitung
stützte sich auf zwei Hostnamen aus alten Forumsbeitraegen
(hkdata.fairuse.org, hkdata.crabdance.com), die tatsaechlich nicht mehr
aufloesen MakeMKV benutzt sie aber laengst nicht mehr. Der Dienst lebt, der
Worker erreicht ihn sogar; er wird unter Linux nur nie gefragt.
WAS DARAUS FOLGT: Der Schlüsselspeicher (_private_data.tar) muss von einer
MakeMKV-Installation kommen, die ihn wirklich abruft praktisch von Windows.
Rippy nimmt ihn unter Einstellungen -> System entgegen. Die KEYDB.cfg bleibt
der Notnagel für Pressungen, die auch MakeMKV selbst nicht kennt.
Rippy liefert KEINE Schluessel mit, lädt keine herunter und verteilt keine.
Es verwaltet nur, was der Nutzer selbst mitbringt.
QUELLEN (AGENTS Regel D externe Schnittstellen nie aus dem Kopf):
* Linux lädt keine Hashed Keys dasselbe Symptom, mehrfach berichtet:
https://forum.makemkv.com/forum/viewtopic.php?t=25782
https://forum.makemkv.com/forum/viewtopic.php?t=34022
* Schluessel liegen als hkd_*.bin in _private_data.tar:
https://forum.makemkv.com/forum/viewtopic.php?t=32675
* Datenverzeichnis und Dateiname KEYDB.cfg GROSS geschrieben (unter Linux
case-sensitiv), dort geloest mit "cp KEYDB.cfg ~/.MakeMKV/":
https://forum.makemkv.com/forum/viewtopic.php?t=30636
* Zeilenformat der KEYDB.cfg (libaacs): Disc-Kennung = 40 Hex-Zeichen,
optional mit 0x-Praefix, dann "= Titel", danach optionale Felder wie
"| V | <32 Hex>"; Zeilen ab ";" sind Kommentare:
https://github.com/ShiftMediaProject/libaacs/blob/master/KEYDB.cfg
* Den AACS-Dump legt MakeMKV selbst ab, Meldung 3332 "Saved AACS dump file
as file:///root/.MakeMKV/<name>.tgz" (am 25.07.2026 so beobachtet).
ZWILLINGSDATEI: liegt identisch unter docker/api/ und docker/worker/ beide
Images brauchen sie, ein gemeinsames Paket gibt es in diesem Projekt nicht
(gleiche Lage wie bei db.py). Änderungen IMMER in BEIDEN Dateien nachziehen.
"""
import io
import os
import re
import tarfile
from datetime import datetime, timezone
# Container-Pfad aus der Umgebung: /root/.MakeMKV im Worker (nur dort sucht
# makemkvcon), /app/makemkv-data in der API. Beide zeigen laut
# docker-compose.yml auf DASSELBE Host-Verzeichnis.
DATEN_DIR = os.getenv("MAKEMKV_DATA_DIR", "/root/.MakeMKV")
# GROSS geschrieben — unter Linux case-sensitiv, "keydb.cfg" wird ignoriert.
KEYDB_NAME = "KEYDB.cfg"
# Obergrenze für den Upload. Eine vollständige oeffentliche KEYDB.cfg liegt
# im einstelligen MB-Bereich; 64 MB sind reichlich Luft und verhindern, dass
# eine versehentlich hochgeladene Riesendatei den Speicher vollschreibt.
MAX_KEYDB_BYTES = 64 * 1024 * 1024
# Eine Disc-Zeile beginnt mit der 40 Zeichen langen Hex-Kennung (optional mit
# 0x davor), danach folgt das Gleichheitszeichen. Alles andere (Kommentare ab
# ";", Leerzeilen, Fortsetzungsfelder) zählt nicht als Eintrag.
_DISC_ZEILE = re.compile(r"^\s*(?:0x)?[0-9a-fA-F]{40}\s*=")
def _iso(zeitstempel: float) -> str:
"""Unix-Zeit -> ISO-8601 in UTC (sekundengenau), wie db.utcnow() es tut."""
return datetime.fromtimestamp(zeitstempel, timezone.utc).isoformat(timespec="seconds")
def keydb_pfad(daten_dir: str = None) -> str:
"""Voller Pfad zur KEYDB.cfg im Datenverzeichnis."""
return os.path.join(daten_dir or DATEN_DIR, KEYDB_NAME)
def zaehle_disc_eintraege(inhalt: str) -> int:
"""Zeilen mit Disc-Kennung zählen (pure Funktion, testbar).
Bewusst eine Heuristik und keine vollständige Auswertung: MakeMKV liest
die Datei mit seinem eigenen Parser, und wie viele Eintraege es daraus
macht, ist von aussen nicht sichtbar. Die Zahl dient nur dazu, im UI
"da liegt wirklich etwas drin" von "leere oder falsche Datei" zu
unterscheiden sie wird deshalb auch genau so beschriftet.
"""
return sum(1 for zeile in inhalt.splitlines() if _DISC_ZEILE.match(zeile))
def keydb_pruefen(inhalt: str) -> str:
"""Prüft hochgeladenen Inhalt; gibt deutschen Fehlertext oder "" zurück.
Verhindert den häufigsten Bedienfehler: statt der KEYDB.cfg landet die
HTML-Fehlerseite eines Downloads oder eine leere Datei im Verzeichnis
MakeMKV würde dann still weiter "volume key is unknown" melden.
"""
if not inhalt.strip():
return "Die Datei ist leer."
if len(inhalt.encode("utf-8")) > MAX_KEYDB_BYTES:
return (
"Die Datei ist größer als "
f"{MAX_KEYDB_BYTES // (1024 * 1024)} MB — das ist keine KEYDB.cfg."
)
if inhalt.lstrip()[:1] == "<":
return (
"Das sieht nach HTML aus, nicht nach einer KEYDB.cfg — "
"vermutlich wurde eine Fehlerseite statt der Datei geladen."
)
if zaehle_disc_eintraege(inhalt) == 0:
return (
"Keine einzige Zeile mit Disc-Kennung gefunden (40 Hex-Zeichen, "
"dann ein Gleichheitszeichen). Das ist keine KEYDB.cfg."
)
return ""
def keydb_status(daten_dir: str = None) -> dict:
"""Zustand der KEYDB.cfg. Fehlt sie, ist das der Normalfall, kein Fehler."""
pfad = keydb_pfad(daten_dir)
try:
angaben = os.stat(pfad)
except OSError:
return {
"vorhanden": False,
"pfad": pfad,
"groesse_bytes": 0,
"eintraege": 0,
"geaendert": "",
}
eintraege = 0
try:
with open(pfad, encoding="utf-8", errors="replace") as datei:
eintraege = zaehle_disc_eintraege(datei.read())
except OSError:
pass # Datei da, aber unlesbar: Größe/Datum stimmen trotzdem
return {
"vorhanden": True,
"pfad": pfad,
"groesse_bytes": angaben.st_size,
"eintraege": eintraege,
"geaendert": _iso(angaben.st_mtime),
}
def keydb_schreiben(inhalt: str, daten_dir: str = None) -> dict:
"""Schreibt die KEYDB.cfg atomar und meldet den neuen Zustand.
Erst in eine Nebendatei, dann os.replace: während ein Rip läuft, darf
makemkvcon niemals eine halb geschriebene Datei zu sehen bekommen.
"""
pfad = keydb_pfad(daten_dir)
os.makedirs(os.path.dirname(pfad), exist_ok=True)
neben = pfad + ".neu"
try:
with open(neben, "w", encoding="utf-8", newline="\n") as datei:
datei.write(inhalt)
os.replace(neben, pfad)
except OSError:
# Die Nebendatei nie liegen lassen: eine halb geschriebene
# KEYDB.cfg.neu verwirrt jeden, der ins Verzeichnis schaut, und
# belegt im Extremfall 64 MB, die niemand mehr aufräumt.
try:
os.remove(neben)
except OSError:
pass
raise
return keydb_status(daten_dir)
def keydb_loeschen(daten_dir: str = None) -> dict:
"""Entfernt die KEYDB.cfg (z. B. nach einem Fehlgriff beim Hochladen).
Nur "Datei war schon weg" wird geschluckt das ist das gewünschte
Ergebnis. Jeder andere Fehler (schreibgeschützter Mount, fremder
Eigentümer) MUSS nach oben durch: sonst meldete die API einen Erfolg,
den es nicht gab, und die Datei wirkte beim nächsten Rip weiter.
"""
try:
os.remove(keydb_pfad(daten_dir))
except FileNotFoundError:
pass
return keydb_status(daten_dir)
def ist_aacs_dump(name: str) -> bool:
"""Dateiname eines AACS-Dumps? (pure Funktion, testbar)
MakeMKV legt ihn als <MKB..._NAME_....>.tgz direkt im Datenverzeichnis ab
(Meldung 3332). Pfadtrenner und fuehrende Punkte werden hier schon
ausgeschlossen, damit der Download-Endpunkt keine Pfad-Tricks erlaubt.
"""
return (
name.endswith(".tgz")
and "/" not in name
and "\\" not in name
and not name.startswith(".")
)
def dumps_auflisten(daten_dir: str = None) -> list:
"""Alle AACS-Dumps im Datenverzeichnis, neueste zuerst."""
ordner = daten_dir or DATEN_DIR
try:
namen = os.listdir(ordner)
except OSError:
return []
liste = []
for name in namen:
if not ist_aacs_dump(name):
continue
try:
angaben = os.stat(os.path.join(ordner, name))
except OSError:
continue
liste.append(
{
"name": name,
"groesse_bytes": angaben.st_size,
"geaendert": _iso(angaben.st_mtime),
"_sort": angaben.st_mtime,
}
)
liste.sort(key=lambda eintrag: eintrag["_sort"], reverse=True)
for eintrag in liste:
del eintrag["_sort"]
return liste
# --- Schlüsselspeicher (_private_data.tar) -------------------------------
#
# Das ist MakeMKVs eigener Speicher für die "Hashed Keys": ein tar-Archiv mit
# hkd_*.bin-Eintraegen. Unter Windows füllt MakeMKV es selbst (Meldung 3338),
# unter Linux nie — siehe Modul-Kopf. Rippy nimmt die Datei deshalb entgegen
# und legt sie ins Datenverzeichnis; MakeMKV liest sie beim nächsten Start.
PRIVATE_DATA_NAME = "_private_data.tar"
# Der Speicher lag am 25.07.2026 bei rund 6 MB und wächst mit jeder neuen
# Pressung. 64 MB sind reichlich Luft — und derselbe Wert wie
# client_max_body_size in docker/ui/nginx.conf: wäre die Grenze hier höher,
# würde nginx den Upload abweisen, bevor die API ihn überhaupt sieht.
MAX_PRIVATE_DATA_BYTES = 64 * 1024 * 1024
def private_data_pfad(daten_dir: str = None) -> str:
"""Voller Pfad zum Schlüsselspeicher im Datenverzeichnis."""
return os.path.join(daten_dir or DATEN_DIR, PRIVATE_DATA_NAME)
def zaehle_schluessel(rohdaten: bytes) -> int:
"""Anzahl der hkd_*.bin-Eintraege im Archiv (pure Funktion, testbar).
Das ist die ehrliche Kennzahl für "wie viele Disc-Schluessel kennt diese
Installation". Ein frischer, leerer Speicher enthält nur eine
Index-Datei und kommt hier auf 0 genau der Zustand, in dem jede
unbekannte UHD-Disc scheitert.
"""
try:
with tarfile.open(fileobj=io.BytesIO(rohdaten)) as archiv:
return sum(1 for name in archiv.getnames() if name.startswith("hkd_"))
except (tarfile.TarError, OSError, EOFError):
return 0
def private_data_pruefen(rohdaten: bytes) -> str:
"""Prüft hochgeladene Rohdaten; deutscher Fehlertext oder "".
Faengt die beiden Bedienfehler ab, die sonst still danebengehen: eine
voellig andere Datei hochladen, oder den Speicher einer Installation, die
selbst noch keine Schluessel geholt hat (dann ändert sich nichts, und
niemand versteht warum).
"""
if not rohdaten:
return "Die Datei ist leer."
if len(rohdaten) > MAX_PRIVATE_DATA_BYTES:
return (
"Die Datei ist größer als "
f"{MAX_PRIVATE_DATA_BYTES // (1024 * 1024)} MB — das ist kein "
"MakeMKV-Schlüsselspeicher."
)
try:
with tarfile.open(fileobj=io.BytesIO(rohdaten)) as archiv:
namen = archiv.getnames()
except (tarfile.TarError, OSError, EOFError):
return (
"Das ist kein tar-Archiv. Erwartet wird die Datei "
f"{PRIVATE_DATA_NAME} aus dem MakeMKV-Datenverzeichnis."
)
if not any(name.startswith("hkd_") for name in namen):
return (
"In dieser Datei steckt kein einziger Schluessel (kein hkd_*.bin). "
"Sie stammt vermutlich von einer MakeMKV-Installation, die selbst "
"noch keine geholt hat — öffne dort erst einmal eine Disc."
)
return ""
def schluesselspeicher_status(daten_dir: str = None) -> dict:
"""Zustand des Schlüsselspeichers. Fehlt er, ist das kein Fehler."""
pfad = private_data_pfad(daten_dir)
try:
angaben = os.stat(pfad)
with open(pfad, "rb") as datei:
schluessel = zaehle_schluessel(datei.read())
except OSError:
return {
"vorhanden": False,
"pfad": pfad,
"groesse_bytes": 0,
"schluessel": 0,
"geaendert": "",
}
return {
"vorhanden": True,
"pfad": pfad,
"groesse_bytes": angaben.st_size,
"schluessel": schluessel,
"geaendert": _iso(angaben.st_mtime),
}
def private_data_schreiben(rohdaten: bytes, daten_dir: str = None) -> dict:
"""Legt den Schlüsselspeicher atomar ab und meldet den neuen Zustand.
Atomar aus demselben Grund wie bei der KEYDB.cfg: während ein Rip läuft,
darf makemkvcon nie ein halb geschriebenes Archiv sehen.
"""
pfad = private_data_pfad(daten_dir)
os.makedirs(os.path.dirname(pfad), exist_ok=True)
neben = pfad + ".neu"
try:
with open(neben, "wb") as datei:
datei.write(rohdaten)
os.replace(neben, pfad)
except OSError:
try:
os.remove(neben)
except OSError:
pass
raise
return schluesselspeicher_status(daten_dir)
def settings_conf_zusammenfuehren(inhalt: str, key: str) -> str:
"""app_Key setzen, ohne den Rest der settings.conf zu verlieren (pure).
Bis zum 25.07.2026 haben entrypoint.sh UND tasks.py die Datei komplett
überschrieben. Mit dem jetzt persistenten Datenverzeichnis wäre damit
bei jedem Containerstart und vor jedem Rip alles andere weg z. B.
app_UpdateEnable. Leerer Key lässt die vorhandene Zeile ebenfalls fallen,
damit ein bewusst geleerter Key nicht heimlich weiterwirkt.
"""
zeilen = [z for z in inhalt.splitlines() if not z.lstrip().startswith("app_Key")]
if key:
zeilen.append('app_Key = "{}"'.format(key))
text = "\n".join(zeilen).strip("\n")
return text + "\n" if text else ""
+10 -3
View File
@@ -23,16 +23,23 @@ import time
import requests import requests
import db import db
import makemkv_daten from rippy.rip import makemkv_daten
import medien import medien
import notify from rippy.core import notify
from celery_app import celery_app from celery_app import celery_app
# fcntl gibt es nur unter Linux — der NATIVE Windows-Transcode-Worker # fcntl gibt es nur unter Linux — der NATIVE Windows-Transcode-Worker
# (deploy/worker-windows) lädt dieses Modul auch, bedient aber nur die # (deploy/worker-windows) lädt dieses Modul auch, bedient aber nur die
# transcode-Queue. Rippen ohne detection ist unten hart verriegelt. # transcode-Queue. Rippen ohne detection ist unten hart verriegelt.
#
# ⚠️ Dieses except verschluckt JEDEN ImportError, nicht nur den fehlenden
# fcntl — auch einen falschen Modulpfad. Beim Umzug nach rippy.drives
# (V2-0, 28.08.2026) wäre genau das passiert: auf Linux hätte der Worker
# still detect_disc_type=None gesetzt und jeden Rip verweigert, ohne dass
# irgendwo ein Fehler stünde. Wächter dagegen: src/rippy/test_paket.py
# prüft plattformunabhängig, dass es den Modulpfad wirklich gibt.
try: try:
from detection import detect_disc_type, disc_size_bytes from rippy.drives.detection import detect_disc_type, disc_size_bytes
except ImportError: # Windows: kein fcntl except ImportError: # Windows: kein fcntl
detect_disc_type = None detect_disc_type = None
disc_size_bytes = None disc_size_bytes = None
+35
View File
@@ -0,0 +1,35 @@
"""rippy — der gemeinsame Kern von API und Worker.
## Warum es dieses Paket gibt (Etappe V2-0, 28.08.2026)
Bis hierher gab es KEIN gemeinsames Paket zwischen den Containern. Drei Module
lagen deshalb zweimal im Repo, byte-identisch, und `db.py` sagte es sogar im
Kopfkommentar: Wer die Struktur ändert, ändert BEIDE Dateien."
Ein Wächter-Test (`test_zwillinge_sind_byteweise_identisch`) hat das mechanisch
abgesichert er war nötig, weil die Konstruktion selbst falsch war. Mit diesem
Paket entfällt beides: es gibt jede Datei genau einmal.
## Aufbau
Die Unterpakete folgen den Fachschichten aus KONZEPT-V2.md § 2.1:
core/ Job-Modell, Zustandsmaschine, Benachrichtigungen
drives/ Laufwerke: Erkennung, Disc-Status, Auswurf (ioctl / Win32)
rip/ MakeMKV, abcde Kommandobau und Datenverzeichnis
Weitere Schichten (metadata, transcode, library, storage) kommen mit den
Etappen V2-1 ff. dazu. Bis dahin leben sie unverändert in docker/api bzw.
docker/worker weiter V2-0 ändert bewusst KEIN Verhalten.
## Wo das Paket zur Laufzeit liegt
Repo src/rippy/ (auf sys.path über conftest.py in der Wurzel)
API-Image /app/rippy/ (COPY im Dockerfile, /app ist Arbeitsverzeichnis)
Worker-Image /app/rippy/ (dito)
Windows <Installationsordner>\\rippy\\ (im Zip von /worker-setup/paket)
Wer hier eine Datei ergänzt, prüft alle vier Wege der Windows-Weg ist der,
den man am leichtesten vergisst (das Zip nimmt nicht automatisch alles mit,
siehe `worker_setup_paket` in docker/api/main.py).
"""
+1
View File
@@ -0,0 +1 @@
"""Kern-Bausteine: Zustand, Phasen, Benachrichtigungen (KONZEPT-V2.md § 2.1)."""
@@ -4,9 +4,9 @@ Bis 24.07. war das notificationWebhook-Setting ein Placebo: das UI speicherte
die URL, aber NICHTS hat je gesendet. Jetzt meldet der Worker Job-Ende die URL, aber NICHTS hat je gesendet. Jetzt meldet der Worker Job-Ende
(fertig/fehlgeschlagen/abgebrochen) und die API bietet einen Test-Endpoint. (fertig/fehlgeschlagen/abgebrochen) und die API bietet einen Test-Endpoint.
Das Modul existiert bewusst identisch in API und Worker Bis Etappe V2-0 (28.08.2026) lag das Modul zweimal im Repo (docker/api und
(docker/api/notify.py) es gibt kein geteiltes Paket zwischen den Containern. docker/worker), weil es kein geteiltes Paket gab. Seitdem gibt es `rippy`
Wer es ändert, ändert BEIDE Dateien. die Datei existiert genau einmal, API und Worker importieren dieselbe.
Payload-Formate (dokumentiert, nicht geraten AGENTS Regel D): Payload-Formate (dokumentiert, nicht geraten AGENTS Regel D):
- Discord: POST JSON {"content": "..."} discord.com/developers/docs/resources/webhook - Discord: POST JSON {"content": "..."} discord.com/developers/docs/resources/webhook
@@ -1,6 +1,6 @@
"""Tests für die Webhook-Benachrichtigungen (Typ-Erkennung + Payload-Bau).""" """Tests für die Webhook-Benachrichtigungen (Typ-Erkennung + Payload-Bau)."""
from notify import baue_payload, erkenne_webhook_typ from rippy.core.notify import baue_payload, erkenne_webhook_typ
def test_discord_wird_an_url_erkannt(): def test_discord_wird_an_url_erkannt():
+4
View File
@@ -0,0 +1,4 @@
"""Laufwerks-Schicht: Erkennung, Disc-Status, Verriegeln, Auswurf.
Der EINZIGE Ort im Projekt mit ioctl- bzw. Win32-Aufrufen (KONZEPT-V2.md § 5).
"""
@@ -6,7 +6,7 @@ nur echte, deterministische Logik getestet — die ioctl-Aufrufe selbst sind
dünne Kernel-Durchreichen und werden im E2E-Test mit echter Disc bewiesen. dünne Kernel-Durchreichen und werden im E2E-Test mit echter Disc bewiesen.
""" """
from detection import ( from rippy.drives.detection import (
BLURAY_MIN_BYTES, BLURAY_MIN_BYTES,
CDS_AUDIO, CDS_AUDIO,
CDS_DATA_1, CDS_DATA_1,
+1
View File
@@ -0,0 +1 @@
"""Ripping-Schicht: MakeMKV und abcde — Kommandobau, Datenverzeichnis, Parser."""
@@ -53,9 +53,10 @@ QUELLEN (AGENTS Regel D — externe Schnittstellen nie aus dem Kopf):
* Den AACS-Dump legt MakeMKV selbst ab, Meldung 3332 "Saved AACS dump file * Den AACS-Dump legt MakeMKV selbst ab, Meldung 3332 "Saved AACS dump file
as file:///root/.MakeMKV/<name>.tgz" (am 25.07.2026 so beobachtet). as file:///root/.MakeMKV/<name>.tgz" (am 25.07.2026 so beobachtet).
ZWILLINGSDATEI: liegt identisch unter docker/api/ und docker/worker/ beide Bis Etappe V2-0 (28.08.2026) lag diese Datei ZWEIMAL im Repo unter
Images brauchen sie, ein gemeinsames Paket gibt es in diesem Projekt nicht docker/api/ und docker/worker/, byte-identisch, bewacht von einem eigenen Test.
(gleiche Lage wie bei db.py). Änderungen IMMER in BEIDEN Dateien nachziehen. Beide Images brauchen sie, und ein gemeinsames Paket gab es nicht. Jetzt gibt
es eins: `rippy`. Die Kopie ist weg, der Wächter-Test damit gegenstandslos.
""" """
import io import io
@@ -2,48 +2,37 @@
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. Damit hängt
noch funktionierende Weg ist eine selbst mitgebrachte KEYDB.cfg im einiges an diesen kleinen Funktionen: erkennen wir die Datei falsch, meldet das
Datenverzeichnis. Damit hängt einiges an diesen kleinen Funktionen: erkennen wir UI "alles gut", während 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 läuft 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
WARUM DER DATEINAME "_worker" HINTEN DRANHAENGT (25.07.2026): makemkv_daten.py ## Diese Datei war einmal zwei (Etappe V2-0, 28.08.2026)
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 Bis hierher gab es sie doppelt docker/api/test_makemkv_daten.py und
pytest Testdateien unter ihrem blossen Dateinamen zwei Dateien namens docker/worker/test_makemkv_daten_worker.py , weil auch das Modul doppelt
test_makemkv_daten.py brechen deshalb die Sammelphase ab ("import file existierte. Der Worker-Test musste sein Modul sogar ausdrücklich über den PFAD
mismatch") und faerben die ganze Ampel rot. Nicht zurückbenennen. laden (importlib): Bei "pytest -q" aus der Repo-Wurzel wird docker/api zuerst
eingesammelt, und jeder weitere Import trifft nur noch den sys.modules-Cache
die Worker-Kopie wäre also nie angefasst worden. Dazu kam ein Wächter-Test,
der beide Dateien byteweise verglich.
Mit dem gemeinsamen Paket `rippy` ist all das weg: ein Modul, ein Test, ein
normaler Import. Die Fälle aus beiden alten Dateien stehen hier zusammen.
""" """
import hashlib from rippy.rip.makemkv_daten import (
import importlib.util ist_aacs_dump,
import os keydb_pruefen,
private_data_pruefen,
# WICHTIG (Prüfbefund 25.07.2026): Ein schlichtes "from makemkv_daten import ..." settings_conf_zusammenfuehren,
# lädt bei "pytest -q" vom Repo-Wurzelverzeichnis NICHT diese Datei, sondern die zaehle_disc_eintraege,
# API-Kopie — docker/api wird zuerst gesammelt, und jeder weitere Import trifft zaehle_schluessel,
# nur noch den sys.modules-Cache. Die Tests hier hätten den Worker-Zwilling also )
# nie angefasst und eine Abweichung wäre grün durchgelaufen. Deshalb wird er
# ausdrücklich über seinen Pfad geladen.
_HIER = os.path.dirname(os.path.abspath(__file__))
_WORKER_MODUL = os.path.join(_HIER, "makemkv_daten.py")
_API_MODUL = os.path.abspath(os.path.join(_HIER, "..", "api", "makemkv_daten.py"))
_spec = importlib.util.spec_from_file_location("makemkv_daten_worker_kopie", _WORKER_MODUL)
_modul = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_modul)
ist_aacs_dump = _modul.ist_aacs_dump
keydb_pruefen = _modul.keydb_pruefen
settings_conf_zusammenfuehren = _modul.settings_conf_zusammenfuehren
zaehle_disc_eintraege = _modul.zaehle_disc_eintraege
private_data_pruefen = _modul.private_data_pruefen
zaehle_schluessel = _modul.zaehle_schluessel
def _tar_mit(namen): def _tar_mit(namen):
@@ -107,25 +96,6 @@ def test_private_data_pruefen_lehnt_speicher_ohne_schluessel_ab():
assert "kein einziger Schluessel" in private_data_pruefen(leer) assert "kein einziger Schluessel" in private_data_pruefen(leer)
def test_zwillinge_sind_byteweise_identisch():
"""docker/api/makemkv_daten.py MUSS dieselbe Datei sein wie diese hier.
Das Modul existiert bewusst doppelt es gibt in diesem Projekt kein
gemeinsames Paket für API und Worker (gleiche Lage wie bei db.py). Genau
deshalb braucht es einen Wächter: laufen die beiden auseinander, zeigt das
UI etwas anderes an, als der rippende Worker tatsächlich sieht, und es
fällt niemandem auf. Dieser Test ist die einzige Stelle, die das
mechanisch prüft.
"""
with open(_WORKER_MODUL, "rb") as datei:
worker = hashlib.sha256(datei.read()).hexdigest()
with open(_API_MODUL, "rb") as datei:
api = hashlib.sha256(datei.read()).hexdigest()
assert worker == api, (
"docker/worker/makemkv_daten.py und docker/api/makemkv_daten.py sind "
"auseinandergelaufen - Änderungen immer in BEIDE Dateien übernehmen."
)
# Eine kleine, aber echte KEYDB.cfg im libaacs-Format: Kommentarkopf, eine # Eine kleine, aber echte KEYDB.cfg im libaacs-Format: Kommentarkopf, eine
# Disc-Zeile MIT 0x-Praefix, eine OHNE, dazu ein Fortsetzungsfeld und eine # Disc-Zeile MIT 0x-Praefix, eine OHNE, dazu ein Fortsetzungsfeld und eine
# Leerzeile. Erwartete Zahl der Eintraege: 2. # Leerzeile. Erwartete Zahl der Eintraege: 2.
@@ -242,3 +212,18 @@ def test_settings_conf_aus_dem_nichts_ergibt_saubere_datei():
def test_settings_conf_ohne_key_und_ohne_inhalt_bleibt_leer(): def test_settings_conf_ohne_key_und_ohne_inhalt_bleibt_leer():
# Kein Inhalt, kein Key: keine Datei mit einer einsamen Leerzeile erzeugen. # Kein Inhalt, kein Key: keine Datei mit einer einsamen Leerzeile erzeugen.
assert settings_conf_zusammenfuehren("", "") == "" assert settings_conf_zusammenfuehren("", "") == ""
def test_zaehle_disc_eintraege_akzeptiert_0x_praefix_einzeln():
# Die oeffentlichen Dateien mischen beide Schreibweisen. Eine einzelne
# Zeile MIT Praefix muss für sich allein schon zaehlen.
assert zaehle_disc_eintraege("0x0123456789abcdef0123456789abcdef01234567 = Tenet") == 1
def test_ist_aacs_dump_lehnt_andere_endungen_ab():
# Nur die Dumps sollen abholbar sein — nicht settings.conf, nicht
# _private_data.tar und schon gar nicht die KEYDB.cfg selbst.
assert ist_aacs_dump("KEYDB.cfg") is False
assert ist_aacs_dump("settings.conf") is False
assert ist_aacs_dump("_private_data.tar") is False
assert ist_aacs_dump("") is False
+75
View File
@@ -0,0 +1,75 @@
"""Wächter: Gibt es die Modulpfade wirklich, die anderswo importiert werden?
## Der Fehler, gegen den das hier steht (V2-0, 28.08.2026)
Beim Umzug der drei Zwillingsmodule nach `rippy` blieb in `tasks.py` eine
Import-Zeile auf dem alten, flachen Namen stehen und zwar diese hier:
try:
from detection import detect_disc_type, disc_size_bytes
except ImportError: # Windows: kein fcntl
detect_disc_type = None
Das `except` ist beabsichtigt: Der native Windows-Worker hat kein `fcntl` und
soll trotzdem starten (er komprimiert nur). Es verschluckt aber **jeden**
ImportError auch einen falschen Modulpfad. Auf Linux hätte der Worker
also ab sofort still `detect_disc_type = None` gesetzt und jeden Rip
verweigert, ohne dass irgendwo ein Fehler aufgetaucht wäre.
Genau das Muster, das `AGENTS.md` als teuerste Fehlerklasse führt: *Ein
Hintergrund-Prozess, der still scheitert, ist schlimmer als einer, der laut
scheitert."*
## Warum find_spec und kein Import
`import rippy.drives.detection` würde das Modul AUSFÜHREN und das braucht
`fcntl`, das es unter Windows nicht gibt. Der Test liefe dann nur in der Ampel
und wäre auf dem Entwicklungsrechner blind. `find_spec` sucht nur den Pfad und
führt nichts aus: Es läuft überall und schlägt genau dann fehl, wenn eine
Datei verschoben oder umbenannt wurde.
Wer ein Modul aus `rippy` woanders hin verschiebt, kommt hier vorbei.
"""
import importlib.util
# Jeder Eintrag: (Modulpfad, wer ihn importiert und was passiert, wenn er fehlt)
ERWARTETE_MODULE = [
(
"rippy.drives.detection",
"docker/worker/tasks.py (in einem try/except ImportError — schluckt "
"einen Tippfehler still!) und docker/api/{devices,main}.py, "
"docker/api/prescan/prescan.py",
),
(
"rippy.rip.makemkv_daten",
"docker/worker/{tasks,caps}.py und docker/api/main.py — ohne das Modul "
"meldet das UI den Schlüsselstand falsch",
),
(
"rippy.core.notify",
"docker/worker/tasks.py und docker/api/main.py — ohne das Modul kommt "
"keine Benachrichtigung mehr an",
),
]
def test_alle_erwarteten_module_sind_auffindbar():
fehlend = []
for pfad, wer in ERWARTETE_MODULE:
if importlib.util.find_spec(pfad) is None:
fehlend.append(f" {pfad} — gebraucht von: {wer}")
assert not fehlend, (
"Diese Module aus dem Paket `rippy` gibt es nicht (mehr):\n"
+ "\n".join(fehlend)
+ "\n\nWurde etwas verschoben oder umbenannt? Dann fehlen jetzt die "
"Importe an den genannten Stellen. Achtung: Mindestens einer davon "
"steckt in einem try/except ImportError und scheitert deshalb STILL."
)
def test_paket_selbst_ist_auffindbar():
# Wenn das hier bricht, stimmt der sys.path nicht — siehe conftest.py in
# der Repo-Wurzel (fügt src/ hinzu) bzw. die COPY-Zeilen in beiden
# Dockerfiles (legen das Paket nach /app/rippy).
assert importlib.util.find_spec("rippy") is not None