diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..b0e010c --- /dev/null +++ b/conftest.py @@ -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) diff --git a/docker/api/Dockerfile b/docker/api/Dockerfile index 19136cb..84ae436 100644 --- a/docker/api/Dockerfile +++ b/docker/api/Dockerfile @@ -17,10 +17,19 @@ RUN pip install --no-cache-dir -r requirements.txt 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 # native Worker aus (GET /worker-setup/windows bzw. /worker-setup/paket) — # die Zielmaschine braucht weder git noch Docker. 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/ # 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 diff --git a/docker/api/devices.py b/docker/api/devices.py index f3cc848..a783981 100644 --- a/docker/api/devices.py +++ b/docker/api/devices.py @@ -10,7 +10,7 @@ import glob import os from fcntl import ioctl -from detection import ( +from rippy.drives.detection import ( CDS_DISC_OK, classify, disc_size_bytes, diff --git a/docker/api/main.py b/docker/api/main.py index 560d51f..8b419b0 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -14,15 +14,20 @@ import uuid import db import devices as device_discovery import eta -import makemkv_daten +from rippy.rip import makemkv_daten import makemkv_key import mounts as mount_verwaltung -import notify +from rippy.core import notify import phasen import presets as preset_auswahl import rohdaten 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 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 elif name in ("requirements.txt", "rippy.ico"): 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 version_str = os.getenv("RIPPY_VERSION", "dev") z.writestr("version.txt", version_str) diff --git a/docker/api/notify.py b/docker/api/notify.py deleted file mode 100644 index 87e9d36..0000000 --- a/docker/api/notify.py +++ /dev/null @@ -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/, 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]}" - ) diff --git a/docker/api/prescan/prescan.py b/docker/api/prescan/prescan.py index 99635fc..e79a933 100644 --- a/docker/api/prescan/prescan.py +++ b/docker/api/prescan/prescan.py @@ -10,7 +10,7 @@ import shutil import subprocess from typing import Dict, List, Optional -import detection +from rippy.drives import detection from clients.tmdb import TMDBClient from clients.jikan import JikanClient from clients.musicbrainz import MusicBrainzClient diff --git a/docker/api/test_makemkv_daten.py b/docker/api/test_makemkv_daten.py deleted file mode 100644 index 451e6d5..0000000 --- a/docker/api/test_makemkv_daten.py +++ /dev/null @@ -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("\n404 Not Found\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("", "") == "" diff --git a/docker/worker/Dockerfile b/docker/worker/Dockerfile index 01319fd..c554c36 100644 --- a/docker/worker/Dockerfile +++ b/docker/worker/Dockerfile @@ -147,6 +147,12 @@ COPY docker/worker/requirements.txt . RUN pip install --no-cache-dir -r requirements.txt 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 ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/docker/worker/caps.py b/docker/worker/caps.py index acd0881..129dadf 100644 --- a/docker/worker/caps.py +++ b/docker/worker/caps.py @@ -385,7 +385,7 @@ def werkzeug_versionen() -> dict: # Ohne diese Prüfung trüge er im UI dauerhaft die Warnung "keine # KEYDB.cfg", obwohl ihn das gar nichts angeht. try: - import makemkv_daten + from rippy.rip import makemkv_daten daten_dir = makemkv_daten.DATEN_DIR except Exception: daten_dir = "" diff --git a/docker/worker/detection.py b/docker/worker/detection.py deleted file mode 100644 index 687bde5..0000000 --- a/docker/worker/detection.py +++ /dev/null @@ -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" diff --git a/docker/worker/makemkv_daten.py b/docker/worker/makemkv_daten.py deleted file mode 100644 index bcf02c2..0000000 --- a/docker/worker/makemkv_daten.py +++ /dev/null @@ -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/.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 .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 "" diff --git a/docker/worker/tasks.py b/docker/worker/tasks.py index 4243630..cde8488 100644 --- a/docker/worker/tasks.py +++ b/docker/worker/tasks.py @@ -23,16 +23,23 @@ import time import requests import db -import makemkv_daten +from rippy.rip import makemkv_daten import medien -import notify +from rippy.core import notify from celery_app import celery_app # fcntl gibt es nur unter Linux — der NATIVE Windows-Transcode-Worker # (deploy/worker-windows) lädt dieses Modul auch, bedient aber nur die # 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: - 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 detect_disc_type = None disc_size_bytes = None diff --git a/src/rippy/__init__.py b/src/rippy/__init__.py new file mode 100644 index 0000000..86166e7 --- /dev/null +++ b/src/rippy/__init__.py @@ -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 \\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). +""" diff --git a/src/rippy/core/__init__.py b/src/rippy/core/__init__.py new file mode 100644 index 0000000..de0bc4b --- /dev/null +++ b/src/rippy/core/__init__.py @@ -0,0 +1 @@ +"""Kern-Bausteine: Zustand, Phasen, Benachrichtigungen (KONZEPT-V2.md § 2.1).""" diff --git a/docker/worker/notify.py b/src/rippy/core/notify.py similarity index 91% rename from docker/worker/notify.py rename to src/rippy/core/notify.py index 87e9d36..4e3cce1 100644 --- a/docker/worker/notify.py +++ b/src/rippy/core/notify.py @@ -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 (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. +Bis Etappe V2-0 (28.08.2026) lag das Modul zweimal im Repo (docker/api und +docker/worker), weil es kein geteiltes Paket gab. Seitdem gibt es `rippy` — +die Datei existiert genau einmal, API und Worker importieren dieselbe. Payload-Formate (dokumentiert, nicht geraten — AGENTS Regel D): - Discord: POST JSON {"content": "..."} — discord.com/developers/docs/resources/webhook diff --git a/docker/worker/test_notify.py b/src/rippy/core/test_notify.py similarity index 95% rename from docker/worker/test_notify.py rename to src/rippy/core/test_notify.py index 8b29ff0..8dfd2c3 100644 --- a/docker/worker/test_notify.py +++ b/src/rippy/core/test_notify.py @@ -1,6 +1,6 @@ """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(): diff --git a/src/rippy/drives/__init__.py b/src/rippy/drives/__init__.py new file mode 100644 index 0000000..07b4b60 --- /dev/null +++ b/src/rippy/drives/__init__.py @@ -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). +""" diff --git a/docker/api/detection.py b/src/rippy/drives/detection.py similarity index 100% rename from docker/api/detection.py rename to src/rippy/drives/detection.py diff --git a/docker/worker/test_detection.py b/src/rippy/drives/test_detection.py similarity index 97% rename from docker/worker/test_detection.py rename to src/rippy/drives/test_detection.py index 0a634ab..b9a5591 100644 --- a/docker/worker/test_detection.py +++ b/src/rippy/drives/test_detection.py @@ -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. """ -from detection import ( +from rippy.drives.detection import ( BLURAY_MIN_BYTES, CDS_AUDIO, CDS_DATA_1, diff --git a/src/rippy/rip/__init__.py b/src/rippy/rip/__init__.py new file mode 100644 index 0000000..34e2a07 --- /dev/null +++ b/src/rippy/rip/__init__.py @@ -0,0 +1 @@ +"""Ripping-Schicht: MakeMKV und abcde — Kommandobau, Datenverzeichnis, Parser.""" diff --git a/docker/api/makemkv_daten.py b/src/rippy/rip/makemkv_daten.py similarity index 97% rename from docker/api/makemkv_daten.py rename to src/rippy/rip/makemkv_daten.py index bcf02c2..c91f3e2 100644 --- a/docker/api/makemkv_daten.py +++ b/src/rippy/rip/makemkv_daten.py @@ -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 as file:///root/.MakeMKV/.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. +Bis Etappe V2-0 (28.08.2026) lag diese Datei ZWEIMAL im Repo — unter +docker/api/ und docker/worker/, byte-identisch, bewacht von einem eigenen Test. +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 diff --git a/docker/worker/test_makemkv_daten_worker.py b/src/rippy/rip/test_makemkv_daten.py similarity index 73% rename from docker/worker/test_makemkv_daten_worker.py rename to src/rippy/rip/test_makemkv_daten.py index 7f00dd0..26cf809 100644 --- a/docker/worker/test_makemkv_daten_worker.py +++ b/src/rippy/rip/test_makemkv_daten.py @@ -2,48 +2,37 @@ WARUM ES DIESE TESTS GIBT (Befund 25.07.2026, live im Worker nachgemessen): Eine 4K-UHD-Disc (Akira UHD, MKB v76) scheiterte mit "The volume key is unknown -for this disc", obwohl Laufwerk und MakeMKV in Ordnung waren. Der einzige heute -noch funktionierende Weg ist eine selbst mitgebrachte KEYDB.cfg im -Datenverzeichnis. Damit hängt einiges an diesen kleinen Funktionen: erkennen wir -die Datei falsch, meldet das UI "alles gut", während MakeMKV weiter scheitert. +for this disc", obwohl Laufwerk und MakeMKV in Ordnung waren. Damit hängt +einiges an diesen kleinen Funktionen: erkennen wir die Datei falsch, meldet das +UI "alles gut", während MakeMKV weiter scheitert. Getestet wird nur, was ohne Postgres, Redis und ohne Laufwerk läuft — also die puren Funktionen mit echten Beispieldaten. Zeilenformat der KEYDB.cfg laut libaacs (AGENTS Regel D, externe Schnittstellen nie aus dem Kopf): https://github.com/ShiftMediaProject/libaacs/blob/master/KEYDB.cfg -WARUM DER DATEINAME "_worker" HINTEN DRANHAENGT (25.07.2026): makemkv_daten.py -ist eine Zwillingsdatei, es gibt sie unter docker/api/ UND docker/worker/, und -beide Seiten haben Tests. Da im Projekt keine __init__.py liegen, importiert -pytest Testdateien unter ihrem blossen Dateinamen — zwei Dateien namens -test_makemkv_daten.py brechen deshalb die Sammelphase ab ("import file -mismatch") und faerben die ganze Ampel rot. Nicht zurückbenennen. +## Diese Datei war einmal zwei (Etappe V2-0, 28.08.2026) + +Bis hierher gab es sie doppelt — docker/api/test_makemkv_daten.py und +docker/worker/test_makemkv_daten_worker.py —, weil auch das Modul doppelt +existierte. Der Worker-Test musste sein Modul sogar ausdrücklich über den PFAD +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 -import importlib.util -import os - -# WICHTIG (Prüfbefund 25.07.2026): Ein schlichtes "from makemkv_daten import ..." -# lädt bei "pytest -q" vom Repo-Wurzelverzeichnis NICHT diese Datei, sondern die -# API-Kopie — docker/api wird zuerst gesammelt, und jeder weitere Import trifft -# nur noch den sys.modules-Cache. Die Tests hier hätten den Worker-Zwilling also -# nie angefasst und eine Abweichung wäre grün durchgelaufen. Deshalb wird er -# ausdrücklich über seinen Pfad geladen. -_HIER = os.path.dirname(os.path.abspath(__file__)) -_WORKER_MODUL = os.path.join(_HIER, "makemkv_daten.py") -_API_MODUL = os.path.abspath(os.path.join(_HIER, "..", "api", "makemkv_daten.py")) - -_spec = importlib.util.spec_from_file_location("makemkv_daten_worker_kopie", _WORKER_MODUL) -_modul = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_modul) - -ist_aacs_dump = _modul.ist_aacs_dump -keydb_pruefen = _modul.keydb_pruefen -settings_conf_zusammenfuehren = _modul.settings_conf_zusammenfuehren -zaehle_disc_eintraege = _modul.zaehle_disc_eintraege -private_data_pruefen = _modul.private_data_pruefen -zaehle_schluessel = _modul.zaehle_schluessel +from rippy.rip.makemkv_daten import ( + ist_aacs_dump, + keydb_pruefen, + private_data_pruefen, + settings_conf_zusammenfuehren, + zaehle_disc_eintraege, + zaehle_schluessel, +) 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) -def test_zwillinge_sind_byteweise_identisch(): - """docker/api/makemkv_daten.py MUSS dieselbe Datei sein wie diese hier. - - Das Modul existiert bewusst doppelt — es gibt in diesem Projekt kein - gemeinsames Paket für API und Worker (gleiche Lage wie bei db.py). Genau - deshalb braucht es einen Wächter: laufen die beiden auseinander, zeigt das - UI etwas anderes an, als der rippende Worker tatsächlich sieht, und es - fällt niemandem auf. Dieser Test ist die einzige Stelle, die das - mechanisch prüft. - """ - with open(_WORKER_MODUL, "rb") as datei: - worker = hashlib.sha256(datei.read()).hexdigest() - with open(_API_MODUL, "rb") as datei: - api = hashlib.sha256(datei.read()).hexdigest() - assert worker == api, ( - "docker/worker/makemkv_daten.py und docker/api/makemkv_daten.py sind " - "auseinandergelaufen - Änderungen immer in BEIDE Dateien übernehmen." - ) - # Eine kleine, aber echte KEYDB.cfg im libaacs-Format: Kommentarkopf, eine # Disc-Zeile MIT 0x-Praefix, eine OHNE, dazu ein Fortsetzungsfeld und eine # Leerzeile. Erwartete Zahl der Eintraege: 2. @@ -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(): # Kein Inhalt, kein Key: keine Datei mit einer einsamen Leerzeile erzeugen. 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 diff --git a/src/rippy/test_paket.py b/src/rippy/test_paket.py new file mode 100644 index 0000000..7f51514 --- /dev/null +++ b/src/rippy/test_paket.py @@ -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