Files
rippy/docker/api/test_mounts_helpers.py
T
Hitonabi 4195854bf8 feat(bausteine): Preset-Liste, Pfad-Vorschlag und Restzeit - alles gemessen
Vier neue reine Bausteine, jeder mit Tests. Sie beantworten Fragen, die Rippy
bisher geraten oder gar nicht gestellt hat.

1. caps.parse_preset_liste - die Preset-NAMEN, die das HandBrake DIESES Workers
   wirklich kennt (`--preset-list`, Format im Worker-Image gemessen). Damit
   endet das Raten: die Namen unterscheiden sich je HandBrake-Version, und ein
   erfundener Name laesst die Kompression scheitern.
   Richtigstellung zum SAVEPOINT v3.16: Dort galt es als unmoeglich, die
   Hardware-Preset-Namen auf der Rippy-VM zu ermitteln, weil dort kein
   Hardware-Encoder laeuft. Gemessen ist das falsch - die Kategorie `Hardware/`
   steht vollstaendig in der Liste (VCN, NVENC, QSV, MF). HandBrake trennt
   zwei Fragen: --preset-list nennt alle Presets, --help nur die nutzbaren
   Encoder. Zwei Fragen, zwei Quellen.

2. mounts.pfad_map_vorschlag/pfad_map_zeile - der fehlende Anschluss fuer
   RIPPY_PATH_MAP. Geraten werden muss dafuer nichts: Rippy hat die Freigabe
   selbst eingehaengt und kennt ihre Quelle (//host/share). Mountpunkt plus
   Quelle IST das Mapping. NFS gibt bewusst "" - Windows-Schreibweise ist nicht
   ableitbar (AGENTS Regel D).
   Der Test fand dabei sofort die dokumentierte Windows-Falle: os.path.join
   baute `/app/media\rippy` in einen Container-Pfad, das Mapping waere still
   wirkungslos geblieben. _mountpoint nutzt jetzt posixpath.

3. presets.empfehlung - "immer das Beste" (Commander-Anforderung) ohne
   Punktesystem: fuer jede Lage eine feste Reihenfolge echter Namen, genommen
   wird der erste, den der Worker kennt. Hardware nur, wenn die Familie
   wirklich gemeldet ist (vce -> Preset heisst VCN, sonst liefe eine AMD-Karte
   unter dem Intel-Namen). vaapi und MF werden nie empfohlen: fuer vaapi gibt
   es kein Preset, bei MF ist die Nutzbarkeit nicht ablesbar.

4. eta - Restzeit aus dem gemessenen Fortschritt. Messreihe je Phase (Rip und
   Kompression haben nichts miteinander zu tun), Stillstand verlaengert die
   Schaetzung, und unter zwei Messwerten oder 60 Sekunden Spanne gibt es
   ehrlich keine Aussage. Test mit den echten 25.07.-Werten: 1,44 % in 29 min
   landet bei "noch ca. 1 Tag 23 h" - genau die Angabe, deren Fehlen den
   50-Stunden-Lauf unsichtbar machte.

Dazu: HandBrakes "Invalid preset <Name>" wird uebersetzt. Vorher stand im UI
nur "HandBrake endete mit Code 3" - dass der Preset-NAME das Problem ist, war
daraus nicht zu erraten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 12:57:51 +02:00

166 lines
6.1 KiB
Python

"""Tests für die SMB-Fehlerübersetzung (Speicherziele → Freigaben auflisten)."""
from mounts import (
pfad_map_vorschlag,
pfad_map_zeile,
uebersetze_smb_fehler,
unc_aus_quelle,
validiere_name,
)
def test_access_denied_ohne_credentials_erklaert_gastproblem():
meldung = uebersetze_smb_fehler(
"session setup failed: NT_STATUS_ACCESS_DENIED", mit_credentials=False
)
assert "Gast" in meldung
assert "Benutzername + Passwort" in meldung
def test_access_denied_mit_credentials_verweist_auf_konto():
meldung = uebersetze_smb_fehler(
"session setup failed: NT_STATUS_ACCESS_DENIED", mit_credentials=True
)
assert "stimmen nicht" in meldung
def test_logon_failure_wird_uebersetzt():
meldung = uebersetze_smb_fehler("NT_STATUS_LOGON_FAILURE", mit_credentials=True)
assert "Passwort falsch" in meldung
def test_unerreichbar_wird_uebersetzt():
meldung = uebersetze_smb_fehler(
"do_connect: Connection to 10.0.0.9 failed (Error NT_STATUS_IO_TIMEOUT)",
mit_credentials=False,
)
assert "nicht erreichbar" in meldung
def test_unbekannter_fehler_bleibt_erhalten_und_gekappt():
meldung = uebersetze_smb_fehler("X" * 500, mit_credentials=False)
assert meldung == "X" * 200
def test_validiere_name_bleibt_streng():
assert validiere_name("nas-filme")
assert not validiere_name("NAS Filme")
def test_stale_mounts_loesen_loest_bis_nichts_mehr_geht(monkeypatch):
"""Löst gestapelte Schichten per lazy umount, bis umount nichts mehr findet
(returncode != 0), und meldet die Zahl der gelösten Schichten."""
import types
import mounts
aufrufe = []
def fake_run(cmd, **kwargs):
aufrufe.append(cmd)
rc = 0 if len(aufrufe) <= 3 else 1 # 3 Schichten lösen, dann leer
return types.SimpleNamespace(returncode=rc, stdout=b"", stderr=b"")
monkeypatch.setattr(mounts.subprocess, "run", fake_run)
assert mounts._stale_mounts_loesen("/app/media/x") == 3
assert all(cmd[:2] == ["umount", "-l"] for cmd in aufrufe)
def test_mounten_loest_stale_vor_dem_mount():
"""Regression (Vorfall 24.07.): mounten() muss Alt-Mounts LÖSEN, bevor es neu
mountet — sonst stapelt es auf eine Mount-Leiche (12 Schichten, ls-Timeout)."""
import inspect
import mounts
quelle = inspect.getsource(mounts.mounten)
assert "_stale_mounts_loesen(ziel)" in quelle
# --- RIPPY_PATH_MAP: der Anschluss für externe Worker (Befund 26.07.2026) ----
def test_unc_aus_quelle_uebersetzt_cifs():
assert unc_aus_quelle("cifs", "//192.168.178.62/rippy") == "\\\\192.168.178.62\\rippy"
assert unc_aus_quelle("cifs", "//nas/medien/filme") == "\\\\nas\\medien\\filme"
def test_unc_aus_quelle_raet_bei_nfs_nicht():
"""NFS gibt "" — Windows-Schreibweise ist nicht ableitbar (AGENTS Regel D)."""
assert unc_aus_quelle("nfs", "192.168.178.62:/volume1/rippy") == ""
assert unc_aus_quelle("cifs", "") == ""
assert unc_aus_quelle("cifs", "kein-unc-pfad") == ""
def test_pfad_map_zeile_baut_was_pfad_lokal_liest(monkeypatch):
"""Der erzeugte Wert muss vom Worker gelesen werden können — genau dieses
Format erwartet worker/tasks.pfad_lokal(): Paare, getrennt durch ';'."""
import mounts
monkeypatch.setattr(mounts, "ist_gemountet", lambda name: True)
vorschlaege = pfad_map_vorschlag([
{"name": "rippy", "typ": "cifs", "quelle": "//192.168.178.62/rippy"},
])
assert pfad_map_zeile(vorschlaege) == "/app/media/rippy=\\\\192.168.178.62\\rippy"
def test_pfad_map_zeile_laesst_nfs_weg(monkeypatch):
"""Ein halbes Mapping wäre schlimmer als keines: pfad_lokal() hört beim
ersten passenden Präfix auf, ein NFS-Eintrag ohne Ziel würde also einen
Pfad 'übersetzen', den der Worker danach trotzdem nicht sieht."""
import mounts
monkeypatch.setattr(mounts, "ist_gemountet", lambda name: True)
vorschlaege = pfad_map_vorschlag([
{"name": "nfs-ziel", "typ": "nfs", "quelle": "10.0.0.9:/export"},
{"name": "rippy", "typ": "cifs", "quelle": "//nas/rippy"},
])
assert pfad_map_zeile(vorschlaege) == "/app/media/rippy=\\\\nas\\rippy"
def test_pfad_map_zeile_ohne_mounts_ist_leer():
assert pfad_map_zeile([]) == ""
assert pfad_map_zeile(None) == ""
def test_erzeugtes_mapping_uebersetzt_den_echten_fehlerfall(monkeypatch):
"""Gegenprobe mit dem Pfad, an dem es am 26.07.2026 live scheiterte.
Der Rohschnitt lag auf `/app/media/rippy/95afdc89-…/title_t00.mkv`; der
Windows-Worker sah dort nichts. Mit dem hier erzeugten Mapping muss
genau dieser Pfad auf die Freigabe zeigen. `pfad_lokal` ist eine reine
Funktion im Worker — hier nachgebaut aufzurufen wäre wertlos, deshalb
wird sie über den Pfad importiert.
"""
import importlib.util
import os
import mounts
monkeypatch.setattr(mounts, "ist_gemountet", lambda name: True)
mapping = pfad_map_zeile(pfad_map_vorschlag([
{"name": "rippy", "typ": "cifs", "quelle": "//192.168.178.62/rippy"},
]))
# Der Worker liegt neben der API im Repo; kein geteiltes Paket zwischen
# den Containern, deshalb per Pfad laden statt importieren.
worker_tasks = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "worker", "tasks.py"
)
spec = importlib.util.spec_from_file_location("_worker_tasks_pfad", worker_tasks)
quelltext = open(worker_tasks, encoding="utf-8").read()
assert "def pfad_lokal" in quelltext and spec is not None
# pfad_lokal ist bewusst rein und ohne Modul-Zustand — die Funktion aus dem
# Quelltext zu holen, ohne tasks.py komplett zu laden (das braucht celery,
# db, requests …), geht am ehrlichsten über exec des Funktionsblocks.
anfang = quelltext.index("def pfad_lokal")
ende = quelltext.index("\nRAW_DIR", anfang)
umgebung = {"os": os}
exec(compile(quelltext[anfang:ende], worker_tasks, "exec"), umgebung) # noqa: S102
pfad_lokal = umgebung["pfad_lokal"]
assert pfad_lokal("/app/media/rippy/95afdc89/title_t00.mkv", mapping) == (
"\\\\192.168.178.62\\rippy\\95afdc89\\title_t00.mkv"
)