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
@@ -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"]
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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 ""
|
||||
@@ -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
-3
@@ -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
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
"""Tests für detection.py — die pure Zuordnung classify().
|
||||
|
||||
Der Vorgänger (`file -L` auf ein Block-Device) konnte strukturell nie etwas
|
||||
erkennen; sein Test mockte sich die file-Ausgabe passend zurecht. Hier wird
|
||||
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 (
|
||||
BLURAY_MIN_BYTES,
|
||||
CDS_AUDIO,
|
||||
CDS_DATA_1,
|
||||
CDS_MIXED,
|
||||
UHD_MIN_BYTES,
|
||||
classify,
|
||||
)
|
||||
|
||||
|
||||
def test_audio_cd():
|
||||
assert classify(CDS_AUDIO, 700 * 1024**2) == "cd"
|
||||
|
||||
|
||||
def test_mixed_mode_zaehlt_als_cd():
|
||||
assert classify(CDS_MIXED, 700 * 1024**2) == "cd"
|
||||
|
||||
|
||||
def test_dvd_unter_schwelle():
|
||||
# DVD9 = ~8,5 GB — liegt unter der 10-GB-Blu-ray-Schwelle
|
||||
assert classify(CDS_DATA_1, 8 * 1024**3) == "dvd"
|
||||
|
||||
|
||||
def test_bluray_ab_schwelle():
|
||||
# BD-SL = 25 GB
|
||||
assert classify(CDS_DATA_1, 25 * 1024**3) == "bluray"
|
||||
assert classify(CDS_DATA_1, BLURAY_MIN_BYTES) == "bluray"
|
||||
|
||||
|
||||
def test_bd50_bleibt_bluray():
|
||||
# BD-50 = 50 * 10^9 Bytes ≈ 46,6 GiB — knapp unter der UHD-Schwelle
|
||||
assert classify(CDS_DATA_1, 50 * 1000**3) == "bluray"
|
||||
|
||||
|
||||
def test_uhd_ab_schwelle():
|
||||
# BD-66 (66 GB) und BD-100 sind 4K-UHD-Medien
|
||||
assert classify(CDS_DATA_1, 66 * 1000**3) == "uhd"
|
||||
assert classify(CDS_DATA_1, 100 * 1000**3) == "uhd"
|
||||
assert classify(CDS_DATA_1, UHD_MIN_BYTES) == "uhd"
|
||||
|
||||
|
||||
def test_unbekannter_status():
|
||||
assert classify(999, 5 * 1024**3) == "unknown"
|
||||
assert classify(0, 0) == "unknown"
|
||||
@@ -1,244 +0,0 @@
|
||||
"""Tests für makemkv_daten.py: die reinen Helfer rund um das MakeMKV-Datenverzeichnis.
|
||||
|
||||
WARUM ES DIESE TESTS GIBT (Befund 25.07.2026, live im Worker nachgemessen):
|
||||
Eine 4K-UHD-Disc (Akira UHD, MKB v76) scheiterte mit "The volume key is unknown
|
||||
for this disc", obwohl Laufwerk und MakeMKV in Ordnung waren. Der einzige heute
|
||||
noch funktionierende Weg ist eine selbst mitgebrachte KEYDB.cfg im
|
||||
Datenverzeichnis. Damit hängt einiges an diesen kleinen Funktionen: erkennen wir
|
||||
die Datei falsch, meldet das UI "alles gut", während MakeMKV weiter scheitert.
|
||||
|
||||
Getestet wird nur, was ohne Postgres, Redis und ohne Laufwerk läuft — also die
|
||||
puren Funktionen mit echten Beispieldaten. Zeilenformat der KEYDB.cfg laut
|
||||
libaacs (AGENTS Regel D, externe Schnittstellen nie aus dem Kopf):
|
||||
https://github.com/ShiftMediaProject/libaacs/blob/master/KEYDB.cfg
|
||||
|
||||
WARUM DER DATEINAME "_worker" HINTEN DRANHAENGT (25.07.2026): makemkv_daten.py
|
||||
ist eine Zwillingsdatei, es gibt sie unter docker/api/ UND docker/worker/, und
|
||||
beide Seiten haben Tests. Da im Projekt keine __init__.py liegen, importiert
|
||||
pytest Testdateien unter ihrem blossen Dateinamen — zwei Dateien namens
|
||||
test_makemkv_daten.py brechen deshalb die Sammelphase ab ("import file
|
||||
mismatch") und faerben die ganze Ampel rot. Nicht zurückbenennen.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import os
|
||||
|
||||
# WICHTIG (Prüfbefund 25.07.2026): Ein schlichtes "from makemkv_daten import ..."
|
||||
# lädt bei "pytest -q" vom Repo-Wurzelverzeichnis NICHT diese Datei, sondern die
|
||||
# API-Kopie — docker/api wird zuerst gesammelt, und jeder weitere Import trifft
|
||||
# nur noch den sys.modules-Cache. Die Tests hier hätten den Worker-Zwilling also
|
||||
# nie angefasst und eine Abweichung wäre grün durchgelaufen. Deshalb wird er
|
||||
# ausdrücklich über seinen Pfad geladen.
|
||||
_HIER = os.path.dirname(os.path.abspath(__file__))
|
||||
_WORKER_MODUL = os.path.join(_HIER, "makemkv_daten.py")
|
||||
_API_MODUL = os.path.abspath(os.path.join(_HIER, "..", "api", "makemkv_daten.py"))
|
||||
|
||||
_spec = importlib.util.spec_from_file_location("makemkv_daten_worker_kopie", _WORKER_MODUL)
|
||||
_modul = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_modul)
|
||||
|
||||
ist_aacs_dump = _modul.ist_aacs_dump
|
||||
keydb_pruefen = _modul.keydb_pruefen
|
||||
settings_conf_zusammenfuehren = _modul.settings_conf_zusammenfuehren
|
||||
zaehle_disc_eintraege = _modul.zaehle_disc_eintraege
|
||||
private_data_pruefen = _modul.private_data_pruefen
|
||||
zaehle_schluessel = _modul.zaehle_schluessel
|
||||
|
||||
|
||||
def _tar_mit(namen):
|
||||
"""Baut ein tar-Archiv im Speicher — so sieht MakeMKVs Schlüsselspeicher aus.
|
||||
|
||||
Echte Eintragsnamen aus dem Speicher der Windows-Installation vom
|
||||
25.07.2026: hkd_<8 Hex>.bin für die Schluessel, dazu Index-Dateien.
|
||||
"""
|
||||
import io
|
||||
import tarfile
|
||||
|
||||
puffer = io.BytesIO()
|
||||
with tarfile.open(fileobj=puffer, mode="w") as archiv:
|
||||
for name in namen:
|
||||
eintrag = tarfile.TarInfo(name)
|
||||
eintrag.size = 3
|
||||
archiv.addfile(eintrag, io.BytesIO(b"abc"))
|
||||
return puffer.getvalue()
|
||||
|
||||
|
||||
def test_zaehle_schluessel_zaehlt_nur_hkd_eintraege():
|
||||
# Nur hkd_*.bin sind Disc-Schluessel. Index- und sdf-Dateien gehoeren zum
|
||||
# Speicher dazu, sind aber keine Schluessel — sonst meldete das UI
|
||||
# "1 Schluessel vorhanden" für einen komplett leeren Vorrat.
|
||||
voll = _tar_mit([
|
||||
"hkd_00000059.bin",
|
||||
"hkd_0000005a.bin",
|
||||
"sdf_000000a6.bin",
|
||||
"--index-A2E950B3C3FC57DA9CB856DCAFBA5275F40423DB.bin",
|
||||
])
|
||||
assert zaehle_schluessel(voll) == 2
|
||||
|
||||
|
||||
def test_zaehle_schluessel_leerer_speicher_ist_null():
|
||||
# Genau dieser Zustand lag am 25.07.2026 auf der VM vor: ein Archiv mit
|
||||
# ausschliesslich der Index-Datei. Jede unbekannte UHD-Disc scheitert dann.
|
||||
leer = _tar_mit(["--index-4EF73C3560D489497ACE763962A7F07A4A1545C4.bin"])
|
||||
assert zaehle_schluessel(leer) == 0
|
||||
|
||||
|
||||
def test_zaehle_schluessel_bei_muell_kein_absturz():
|
||||
# Darf niemals werfen — die Zahl landet im Worker-Herzschlag.
|
||||
assert zaehle_schluessel(b"") == 0
|
||||
assert zaehle_schluessel(b"das ist kein tar") == 0
|
||||
|
||||
|
||||
def test_private_data_pruefen_nimmt_echten_speicher_an():
|
||||
assert private_data_pruefen(_tar_mit(["hkd_00000059.bin"])) == ""
|
||||
|
||||
|
||||
def test_private_data_pruefen_lehnt_leere_und_falsche_dateien_ab():
|
||||
assert "leer" in private_data_pruefen(b"")
|
||||
assert "tar-Archiv" in private_data_pruefen(b"<html>Fehlerseite</html>")
|
||||
|
||||
|
||||
def test_private_data_pruefen_lehnt_speicher_ohne_schluessel_ab():
|
||||
# Der teuerste Bedienfehler: den Speicher einer Installation hochladen,
|
||||
# die selbst noch nie Schluessel geholt hat. Ohne diese Prüfung ändert
|
||||
# sich nichts und niemand versteht, warum.
|
||||
leer = _tar_mit(["--index-4EF73C3560D489497ACE763962A7F07A4A1545C4.bin"])
|
||||
assert "kein einziger Schluessel" in private_data_pruefen(leer)
|
||||
|
||||
|
||||
def test_zwillinge_sind_byteweise_identisch():
|
||||
"""docker/api/makemkv_daten.py MUSS dieselbe Datei sein wie diese hier.
|
||||
|
||||
Das Modul existiert bewusst doppelt — es gibt in diesem Projekt kein
|
||||
gemeinsames Paket für API und Worker (gleiche Lage wie bei db.py). Genau
|
||||
deshalb braucht es einen Wächter: laufen die beiden auseinander, zeigt das
|
||||
UI etwas anderes an, als der rippende Worker tatsächlich sieht, und es
|
||||
fällt niemandem auf. Dieser Test ist die einzige Stelle, die das
|
||||
mechanisch prüft.
|
||||
"""
|
||||
with open(_WORKER_MODUL, "rb") as datei:
|
||||
worker = hashlib.sha256(datei.read()).hexdigest()
|
||||
with open(_API_MODUL, "rb") as datei:
|
||||
api = hashlib.sha256(datei.read()).hexdigest()
|
||||
assert worker == api, (
|
||||
"docker/worker/makemkv_daten.py und docker/api/makemkv_daten.py sind "
|
||||
"auseinandergelaufen - Änderungen immer in BEIDE Dateien übernehmen."
|
||||
)
|
||||
|
||||
# Eine kleine, aber echte KEYDB.cfg im libaacs-Format: Kommentarkopf, eine
|
||||
# Disc-Zeile MIT 0x-Praefix, eine OHNE, dazu ein Fortsetzungsfeld und eine
|
||||
# Leerzeile. Erwartete Zahl der Eintraege: 2.
|
||||
BEISPIEL_KEYDB = """; KEYDB.cfg
|
||||
; Kommentarzeilen beginnen mit einem Semikolon
|
||||
|
||||
0x8F4E2C1A9B7D3E5F0A6C8B2D4E1F3A5C7B9D0E2F = AKIRA
|
||||
| V | 0123456789ABCDEF0123456789ABCDEF
|
||||
|
||||
A1B2C3D4E5F60718293A4B5C6D7E8F90A1B2C3D4 = BLADE RUNNER 2049
|
||||
"""
|
||||
|
||||
|
||||
def test_zaehle_disc_eintraege_zaehlt_nur_echte_disc_zeilen():
|
||||
"""Nur Zeilen mit 40 Hex-Zeichen und Gleichheitszeichen sind Eintraege.
|
||||
|
||||
Kommentare, Leerzeilen und Fortsetzungsfelder dürfen nicht mitzaehlen —
|
||||
sonst meldet das UI bei einer reinen Kommentardatei stolz "42 Eintraege".
|
||||
"""
|
||||
assert zaehle_disc_eintraege(BEISPIEL_KEYDB) == 2
|
||||
|
||||
|
||||
def test_zaehle_disc_eintraege_ignoriert_kommentare_und_leerzeilen():
|
||||
# Eine Datei ganz ohne Disc-Zeile hat null Eintraege, nicht drei.
|
||||
nur_beiwerk = "; nur ein Kommentar\n\n| V | 0123456789ABCDEF0123456789ABCDEF\n"
|
||||
assert zaehle_disc_eintraege(nur_beiwerk) == 0
|
||||
|
||||
|
||||
def test_zaehle_disc_eintraege_ignoriert_zu_kurze_kennung():
|
||||
"""39 Hex-Zeichen sind keine Disc-Kennung.
|
||||
|
||||
Genau so sieht eine beim Kopieren verstuemmelte Datei aus — die darf nicht
|
||||
als gültig durchgehen, sonst sucht der Commander den Fehler beim Laufwerk.
|
||||
"""
|
||||
zu_kurz = "A1B2C3D4E5F60718293A4B5C6D7E8F90A1B2C3D = KAPUTT\n"
|
||||
assert zaehle_disc_eintraege(zu_kurz) == 0
|
||||
|
||||
|
||||
def test_keydb_pruefen_meldet_leere_datei():
|
||||
# Häufigster Fehlgriff: das Textfeld war leer, es wird trotzdem gespeichert.
|
||||
assert keydb_pruefen("") != ""
|
||||
assert keydb_pruefen(" \n\n ") != ""
|
||||
|
||||
|
||||
def test_keydb_pruefen_erkennt_html_fehlerseite():
|
||||
"""Der zweithaeufigste Fehlgriff: der Download lieferte eine HTML-Seite.
|
||||
|
||||
MakeMKV würde die Datei still ignorieren und weiter "volume key is unknown"
|
||||
melden — deshalb muss der Fehler schon beim Hochladen sichtbar werden.
|
||||
"""
|
||||
html = "<!DOCTYPE html>\n<html><body><h1>404 Not Found</h1></body></html>\n"
|
||||
meldung = keydb_pruefen(html)
|
||||
assert meldung != ""
|
||||
assert "HTML" in meldung
|
||||
|
||||
|
||||
def test_keydb_pruefen_meldet_text_ohne_disc_zeile():
|
||||
# Irgendein Text (hier: eine README) ist keine KEYDB.cfg.
|
||||
meldung = keydb_pruefen("Diese Datei enthält keine Schluessel, nur Prosa.\n")
|
||||
assert meldung != ""
|
||||
|
||||
|
||||
def test_keydb_pruefen_akzeptiert_gueltigen_inhalt():
|
||||
# Leerer Rueckgabewert heißt laut Vertrag: alles in Ordnung.
|
||||
assert keydb_pruefen(BEISPIEL_KEYDB) == ""
|
||||
|
||||
|
||||
def test_ist_aacs_dump_akzeptiert_echten_namen():
|
||||
"""Name aus der Praxis: so legt MakeMKV den Dump laut Meldung 3332 ab
|
||||
(am 25.07.2026 im Worker so beobachtet)."""
|
||||
assert ist_aacs_dump("MKB20_v76_UHD_AKIRA_C02B.tgz") is True
|
||||
|
||||
|
||||
def test_ist_aacs_dump_lehnt_pfad_tricks_und_fremde_dateien_ab():
|
||||
"""Der Download-Endpunkt hängt den Namen an das Datenverzeichnis an —
|
||||
ohne diese Prüfung könnte man sich damit aus dem Verzeichnis heraus
|
||||
lesen. Versteckte Dateien und Nicht-Dumps sind ebenfalls nichts für die
|
||||
Liste."""
|
||||
assert ist_aacs_dump("../ausbruch.tgz") is False
|
||||
assert ist_aacs_dump(".versteckt.tgz") is False
|
||||
assert ist_aacs_dump("irgendwas.txt") is False
|
||||
assert ist_aacs_dump("..\\windows\\ausbruch.tgz") is False
|
||||
|
||||
|
||||
def test_settings_conf_ersetzt_key_und_behaelt_den_rest():
|
||||
"""DIE Regression, um die es geht: bis zum 25.07.2026 haben entrypoint.sh
|
||||
und tasks.py die settings.conf komplett überschrieben. Mit dem jetzt
|
||||
persistenten Datenverzeichnis wäre damit bei jedem Containerstart und vor
|
||||
jedem Rip alles andere weg — allen voran app_UpdateEnable."""
|
||||
alt = 'app_Key = "T-alterSchluessel"\napp_UpdateEnable = "1"\napp_DefaultSelectionString = "+sel:all"\n'
|
||||
neu = settings_conf_zusammenfuehren(alt, "T-neuerSchluessel")
|
||||
assert 'app_Key = "T-neuerSchluessel"' in neu
|
||||
assert 'app_Key = "T-alterSchluessel"' not in neu
|
||||
assert 'app_UpdateEnable = "1"' in neu
|
||||
assert 'app_DefaultSelectionString = "+sel:all"' in neu
|
||||
# Genau EINE app_Key-Zeile, sonst gewinnt am Ende die falsche.
|
||||
assert neu.count("app_Key") == 1
|
||||
|
||||
|
||||
def test_settings_conf_leerer_key_entfernt_die_zeile():
|
||||
# Ein bewusst geleerter Key darf nicht heimlich weiterwirken.
|
||||
alt = 'app_Key = "T-alterSchluessel"\napp_UpdateEnable = "1"\n'
|
||||
neu = settings_conf_zusammenfuehren(alt, "")
|
||||
assert "app_Key" not in neu
|
||||
assert 'app_UpdateEnable = "1"' in neu
|
||||
|
||||
|
||||
def test_settings_conf_aus_dem_nichts_ergibt_saubere_datei():
|
||||
"""Erststart: die Datei gibt es noch gar nicht. Der abschliessende
|
||||
Zeilenumbruch ist Absicht — MakeMKV liest die Datei zeilenweise."""
|
||||
assert settings_conf_zusammenfuehren("", "T-neuerSchluessel") == 'app_Key = "T-neuerSchluessel"\n'
|
||||
|
||||
|
||||
def test_settings_conf_ohne_key_und_ohne_inhalt_bleibt_leer():
|
||||
# Kein Inhalt, kein Key: keine Datei mit einer einsamen Leerzeile erzeugen.
|
||||
assert settings_conf_zusammenfuehren("", "") == ""
|
||||
@@ -1,38 +0,0 @@
|
||||
"""Tests für die Webhook-Benachrichtigungen (Typ-Erkennung + Payload-Bau)."""
|
||||
|
||||
from notify import baue_payload, erkenne_webhook_typ
|
||||
|
||||
|
||||
def test_discord_wird_an_url_erkannt():
|
||||
assert erkenne_webhook_typ("https://discord.com/api/webhooks/1/abc") == "discord"
|
||||
assert erkenne_webhook_typ("https://discordapp.com/api/webhooks/1/abc") == "discord"
|
||||
|
||||
|
||||
def test_slack_und_ntfy_und_generisch():
|
||||
assert erkenne_webhook_typ("https://hooks.slack.com/services/T/B/x") == "slack"
|
||||
assert erkenne_webhook_typ("https://ntfy.sh/mein-thema") == "ntfy"
|
||||
assert erkenne_webhook_typ("https://ha.local/api/webhook/rippy") == "generisch"
|
||||
|
||||
|
||||
def test_discord_payload_nutzt_content():
|
||||
typ, payload = baue_payload("https://discord.com/api/webhooks/1/a", "Titel", "Text")
|
||||
assert typ == "discord"
|
||||
assert payload == {"content": "**Titel**\nText"}
|
||||
|
||||
|
||||
def test_slack_payload_nutzt_text():
|
||||
typ, payload = baue_payload("https://hooks.slack.com/services/x", "Titel", "Text")
|
||||
assert typ == "slack"
|
||||
assert payload == {"text": "*Titel*\nText"}
|
||||
|
||||
|
||||
def test_ntfy_sendet_rohtext():
|
||||
typ, payload = baue_payload("https://ntfy.sh/thema", "Titel", "Text")
|
||||
assert typ == "ntfy"
|
||||
assert payload is None # Roh-Text-Body, kein JSON
|
||||
|
||||
|
||||
def test_generischer_payload_traegt_level():
|
||||
typ, payload = baue_payload("https://example.org/hook", "Titel", "Text", "error")
|
||||
assert typ == "generisch"
|
||||
assert payload == {"title": "Titel", "message": "Text", "level": "error"}
|
||||
Reference in New Issue
Block a user