refactor(core): V2-0 — gemeinsames Paket rippy statt drei Zwillingsdateien
Ampel / ampel (push) Successful in 41s
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:
co-authored by
Claude Opus 5
parent
20675a98fa
commit
b98dc5eef5
+22
@@ -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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+21
-3
@@ -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
|
||||||
@@ -1784,6 +1789,19 @@ async def worker_setup_paket():
|
|||||||
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)
|
||||||
|
|||||||
@@ -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]}"
|
|
||||||
)
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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("", "") == ""
|
|
||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
|
|||||||
@@ -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"
|
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
"""
|
||||||
@@ -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():
|
||||||
@@ -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,
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user