4195854bf8
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>
357 lines
14 KiB
Python
357 lines
14 KiB
Python
"""Netzwerk-Speicherziele (NFS/SMB) direkt aus Rippy heraus einhängen.
|
|
|
|
Commander-Anforderung 23.07.: Data-Mounts über das UI, universell für jede
|
|
Installation. Der api-Container läuft dafür mit CAP_SYS_ADMIN und einem
|
|
rshared-Bind auf /app/media — ein Mount hier propagiert über den Host in
|
|
alle anderen Container (Worker sieht das Ziel sofort).
|
|
|
|
Zugangsdaten liegen in Postgres (Klartext — bewusster Heimnetz-Kompromiss,
|
|
im README dokumentiert; SMB-Credentials wandern NIE in die Kommandozeile,
|
|
sondern über eine temporäre credentials-Datei).
|
|
"""
|
|
|
|
import os
|
|
import posixpath
|
|
import re
|
|
import subprocess
|
|
import tempfile
|
|
|
|
import db
|
|
|
|
MEDIA_ROOT = "/app/media"
|
|
NAME_MUSTER = re.compile(r"^[a-z0-9][a-z0-9-]{1,30}$")
|
|
|
|
|
|
def _mountpoint(name: str) -> str:
|
|
"""Mountpunkt eines Speicherziels — IMMER ein Container-Pfad.
|
|
|
|
posixpath statt os.path (Befund 26.07.2026): Innerhalb des api-Containers
|
|
ist das dasselbe, aber `pfad_map_vorschlag()` liefert diesen Pfad an einen
|
|
WINDOWS-Worker aus. Mit os.path.join entstand beim Test unter Windows
|
|
`/app/media\\rippy`, und `pfad_lokal()` fand dann kein Präfix — das Mapping
|
|
wäre still wirkungslos geblieben. Dieselbe Falle wie bei `_zielbasis()` im
|
|
Worker (v3.14), diesmal vom Test gefunden statt live.
|
|
"""
|
|
return posixpath.join(MEDIA_ROOT, name)
|
|
|
|
|
|
def validiere_name(name: str) -> bool:
|
|
return bool(NAME_MUSTER.match(name))
|
|
|
|
|
|
def ist_gemountet(name: str) -> bool:
|
|
"""Ist unter /app/media/<name> etwas gemountet? Ein TOTER CIFS-Mount
|
|
(NAS weg) lässt os.path.ismount mit OSError fliegen — das zählt weiter
|
|
als 'gemountet' (nur eben kaputt), damit die Reparatur greift."""
|
|
try:
|
|
return os.path.ismount(_mountpoint(name))
|
|
except OSError:
|
|
return True
|
|
|
|
|
|
def ist_erreichbar(name: str) -> bool:
|
|
"""Kann auf das Ziel WIRKLICH zugegriffen werden?
|
|
|
|
Ein toter CIFS-Mount (Host weg, stale) ist zwar 'gemountet', aber jeder
|
|
Zugriff scheitert. Der Zugriff wird HART auf 3 s begrenzt (`timeout 3 ls`)
|
|
— ein direktes os.listdir blockierte sonst bis zum CIFS-Timeout (~10 s,
|
|
Befund 24.07.: die Speicherziele-Seite lud dadurch träge).
|
|
"""
|
|
ziel = _mountpoint(name)
|
|
try:
|
|
ergebnis = subprocess.run(
|
|
["timeout", "3", "ls", ziel], capture_output=True, timeout=5
|
|
)
|
|
return ergebnis.returncode == 0
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
return False
|
|
|
|
|
|
def schreibtest(pfad: str) -> bool:
|
|
"""Berechtigungs-Prüfung: können wir im Ziel wirklich schreiben?
|
|
|
|
Commander-Anforderung 23.07.: beim Hinzufügen muss geprüft werden, ob
|
|
die Rechte stimmen — ein nur-lesbares Ziel fällt sonst erst beim Rip auf.
|
|
"""
|
|
testdatei = os.path.join(pfad, ".rippy-schreibtest")
|
|
try:
|
|
with open(testdatei, "w") as f:
|
|
f.write("ok")
|
|
os.unlink(testdatei)
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def _stale_mounts_loesen(ziel: str) -> int:
|
|
"""Loest ALLE (evtl. gestapelten/toten) Mounts an `ziel` per lazy umount.
|
|
|
|
Warum: mounten() fällt bei einem TOTEN Mount (os.path.ismount wirft OSError)
|
|
auf den echten `mount` durch — der stapelt dann auf die Leiche. Vorfall 24.07.:
|
|
über viele Neustarts 12 Schichten, die tote oberste blockierte jeden Zugriff
|
|
(ls-Timeout, obwohl SMB-445 offen). Erst alle Schichten loesen macht das
|
|
Re-Mounten idempotent. `umount -l` (lazy) hängt nicht an einem toten CIFS.
|
|
Rueckgabe: Zahl der geloesten Schichten.
|
|
"""
|
|
geloest = 0
|
|
for _ in range(20): # harte Obergrenze gegen Endlosschleife
|
|
try:
|
|
ergebnis = subprocess.run(
|
|
["umount", "-l", ziel], capture_output=True, timeout=10
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
break
|
|
if ergebnis.returncode != 0:
|
|
break # nichts (mehr) gemountet
|
|
geloest += 1
|
|
return geloest
|
|
|
|
|
|
def uebersetze_smb_fehler(fehler: str, mit_credentials: bool) -> str:
|
|
"""Pure Funktion (testbar): NT_STATUS-Kauderwelsch → handelbarer Klartext.
|
|
|
|
Befund 24.07.: „session setup failed: NT_STATUS_ACCESS_DENIED" hieß in
|
|
Wahrheit nur „Windows/NAS erlauben keine Gast-Abfrage" — ohne Übersetzung
|
|
rät niemand, dass oben Benutzer/Passwort fehlen.
|
|
"""
|
|
if "NT_STATUS_ACCESS_DENIED" in fehler:
|
|
if not mit_credentials:
|
|
return (
|
|
"Zugriff verweigert — dieser Rechner erlaubt keine Gast-Abfrage. "
|
|
"Benutzername + Passwort eintragen (Windows: ein Konto mit "
|
|
"Zugriff auf die Freigabe), dann erneut auflisten."
|
|
)
|
|
return (
|
|
"Zugriff verweigert — Benutzername/Passwort stimmen nicht oder das "
|
|
"Konto darf die Freigaben nicht auflisten."
|
|
)
|
|
if "NT_STATUS_LOGON_FAILURE" in fehler:
|
|
return "Anmeldung fehlgeschlagen — Benutzername oder Passwort falsch."
|
|
if (
|
|
"NT_STATUS_HOST_UNREACHABLE" in fehler
|
|
or "NT_STATUS_IO_TIMEOUT" in fehler
|
|
or "NT_STATUS_UNSUCCESSFUL" in fehler
|
|
or "NT_STATUS_CONNECTION_REFUSED" in fehler
|
|
or "Connection to" in fehler
|
|
):
|
|
return (
|
|
"Rechner nicht erreichbar — Name/IP prüfen; läuft dort ein "
|
|
"SMB-Dienst (Port 445, Datei- und Druckerfreigabe aktiv)?"
|
|
)
|
|
return fehler[:200]
|
|
|
|
|
|
def liste_smb_freigaben(host: str, username: str = "", passwort: str = "") -> list:
|
|
"""Listet die SMB-Freigaben eines Rechners (smbclient -L) — damit man
|
|
seinen PC/NAS per Klick wählt statt //host/share zu raten."""
|
|
cmd = ["smbclient", "-L", f"//{host}", "-g"]
|
|
if username:
|
|
cmd += ["-U", f"{username}%{passwort or ''}"]
|
|
else:
|
|
cmd += ["-N"]
|
|
try:
|
|
ergebnis = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
|
|
except subprocess.TimeoutExpired:
|
|
raise RuntimeError(
|
|
f"Freigaben-Abfrage fehlgeschlagen: {host} antwortet nicht "
|
|
"(Timeout nach 20 s) — Name/IP und Netzwerk prüfen."
|
|
)
|
|
freigaben = []
|
|
for zeile in (ergebnis.stdout or "").splitlines():
|
|
# -g-Format: Disk|Freigabename|Kommentar
|
|
teile = zeile.split("|")
|
|
if len(teile) >= 2 and teile[0] == "Disk" and not teile[1].endswith("$"):
|
|
freigaben.append(teile[1])
|
|
if not freigaben and ergebnis.returncode != 0:
|
|
fehler = (ergebnis.stderr or ergebnis.stdout or "").strip()
|
|
raise RuntimeError(
|
|
"Freigaben-Abfrage fehlgeschlagen: "
|
|
+ uebersetze_smb_fehler(fehler, bool(username))
|
|
)
|
|
return freigaben
|
|
|
|
|
|
def unc_aus_quelle(typ: str, quelle: str) -> str:
|
|
"""`//host/freigabe` → `\\\\host\\freigabe` (pure Funktion, testbar).
|
|
|
|
Nur für CIFS/SMB. NFS gibt "" zurück: Windows kann NFS zwar einhängen
|
|
(optionale Funktion „Client für NFS"), die Schreibweise ist aber eine
|
|
andere und der Export-Pfad lässt sich nicht zuverlässig übersetzen —
|
|
raten verstößt gegen AGENTS Regel D.
|
|
"""
|
|
if typ != "cifs" or not quelle:
|
|
return ""
|
|
rest = quelle.strip()
|
|
if not rest.startswith("//"):
|
|
return ""
|
|
return "\\\\" + rest[2:].replace("/", "\\")
|
|
|
|
|
|
def pfad_map_vorschlag(eintraege: list) -> list:
|
|
"""Welcher Container-Pfad entspricht welcher Windows-Freigabe?
|
|
|
|
Das ist der fehlende Anschluss für `RIPPY_PATH_MAP` (Befund 26.07.2026):
|
|
Ein externer Worker bekommt von Rippy Pfade wie `/app/media/rippy/<job>`
|
|
— das sind Pfade INNERHALB des Containers. Er sieht sie nur, wenn sie auf
|
|
eine Netzwerk-Freigabe übersetzt werden, und dieses Mapping wurde bis
|
|
dahin von niemandem gesetzt. Externes Encoden konnte also nie laufen.
|
|
|
|
Geraten werden muss dafür nichts: Rippy hat die Freigabe selbst
|
|
eingehängt und kennt ihre Quelle (`//192.168.178.62/rippy`). Der
|
|
Mountpunkt ist `/app/media/<name>`, und beides zusammen IST das Mapping.
|
|
"""
|
|
vorschlaege = []
|
|
for eintrag in eintraege or []:
|
|
name = (eintrag.get("name") or "").strip()
|
|
if not name:
|
|
continue
|
|
typ = (eintrag.get("typ") or "").strip()
|
|
unc = unc_aus_quelle(typ, eintrag.get("quelle") or "")
|
|
vorschlaege.append({
|
|
"name": name,
|
|
"typ": typ,
|
|
"quelle": eintrag.get("quelle") or "",
|
|
"container": _mountpoint(name),
|
|
"unc": unc,
|
|
"gemountet": ist_gemountet(name),
|
|
})
|
|
return vorschlaege
|
|
|
|
|
|
def pfad_map_zeile(vorschlaege: list) -> str:
|
|
"""Die Vorschläge als fertiger RIPPY_PATH_MAP-Wert (pure Funktion).
|
|
|
|
Format wie `worker/tasks.pfad_lokal()` es liest: Paare `container=ziel`,
|
|
getrennt durch `;`. Einträge ohne übersetzbaren UNC-Pfad (NFS) fallen
|
|
weg — ein halbes Mapping wäre schlimmer als keines, weil `pfad_lokal`
|
|
beim ersten passenden Präfix aufhört.
|
|
"""
|
|
paare = [f"{v['container']}={v['unc']}" for v in vorschlaege or [] if v.get("unc")]
|
|
return ";".join(paare)
|
|
|
|
|
|
def mounten(name: str, typ: str, quelle: str, optionen: str = "",
|
|
username: str = "", passwort: str = "") -> bool:
|
|
"""Hängt ein NFS/CIFS-Ziel unter /app/media/<name> ein.
|
|
|
|
Rückgabe: True wenn das Ziel SCHREIBBAR ist. Wirft RuntimeError bei
|
|
Mount-Fehlern.
|
|
"""
|
|
ziel = _mountpoint(name)
|
|
# exist_ok fängt „Ordner da" NICHT, wenn ein toter Mount os.path.isdir
|
|
# täuschen lässt (FileExistsError, Befund 24.07.) — deshalb tolerant.
|
|
try:
|
|
os.makedirs(ziel, exist_ok=True)
|
|
except FileExistsError:
|
|
pass
|
|
try:
|
|
if os.path.ismount(ziel):
|
|
return schreibtest(ziel)
|
|
except OSError:
|
|
pass # toter Mount → unten erst lösen, dann frisch mounten (kein Stapeln)
|
|
|
|
# Idempotent: etwaige (auch gestapelte/tote) Alt-Mounts erst lösen, damit der
|
|
# folgende mount NICHT auf eine Leiche stapelt (Vorfall 24.07.: 12 Schichten).
|
|
_stale_mounts_loesen(ziel)
|
|
|
|
creds_datei = None
|
|
try:
|
|
if typ == "nfs":
|
|
opts = optionen or "vers=4,soft,timeo=100"
|
|
cmd = ["mount", "-t", "nfs", "-o", opts, quelle, ziel]
|
|
elif typ == "cifs":
|
|
teile = [optionen] if optionen else []
|
|
if username:
|
|
# Credentials über Datei statt Kommandozeile (ps-sichtbar!)
|
|
creds = tempfile.NamedTemporaryFile(
|
|
"w", delete=False, prefix="cifs-", suffix=".cred"
|
|
)
|
|
creds.write(f"username={username}\npassword={passwort or ''}\n")
|
|
creds.close()
|
|
os.chmod(creds.name, 0o600)
|
|
creds_datei = creds.name
|
|
teile.append(f"credentials={creds.name}")
|
|
else:
|
|
teile.append("guest")
|
|
teile.append("iocharset=utf8")
|
|
cmd = ["mount", "-t", "cifs", "-o", ",".join(teile), quelle, ziel]
|
|
else:
|
|
raise RuntimeError(f"Unbekannter Typ: {typ} (nfs oder cifs)")
|
|
|
|
ergebnis = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
if ergebnis.returncode != 0:
|
|
fehler = (ergebnis.stderr or ergebnis.stdout or "").strip()
|
|
hinweis = ""
|
|
if "error(13)" in fehler or "Permission denied" in fehler:
|
|
hinweis = (
|
|
" — Zugriff verweigert: Benutzer/Passwort prüfen; ohne "
|
|
"Angaben versucht Rippy einen Gast-Zugriff, den Windows/"
|
|
"NAS meist ablehnen."
|
|
)
|
|
raise RuntimeError(f"mount schlug fehl: {fehler[:300]}{hinweis}")
|
|
return schreibtest(ziel)
|
|
finally:
|
|
if creds_datei:
|
|
try:
|
|
os.unlink(creds_datei)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _lazy_umount(ziel: str) -> None:
|
|
"""`umount -l`: hängt auch einen TOTEN/beschäftigten Mount ab (detach now,
|
|
cleanup later). Ohne das ließ sich ein Mount zu einem weggefallenen NAS
|
|
gar nicht mehr entfernen (Befund 24.07.)."""
|
|
subprocess.run(["umount", "-l", ziel], capture_output=True, text=True, timeout=30)
|
|
|
|
|
|
def aushaengen(name: str) -> None:
|
|
ziel = _mountpoint(name)
|
|
try:
|
|
noch_mount = os.path.ismount(ziel)
|
|
except OSError:
|
|
noch_mount = True # stale
|
|
if noch_mount:
|
|
ergebnis = subprocess.run(
|
|
["umount", ziel], capture_output=True, text=True, timeout=30
|
|
)
|
|
if ergebnis.returncode != 0:
|
|
# Toter/beschäftigter Mount → lazy detach (klappt immer)
|
|
_lazy_umount(ziel)
|
|
try:
|
|
os.rmdir(ziel)
|
|
except OSError:
|
|
pass # nicht leer oder weg — egal
|
|
|
|
|
|
def reparieren(name: str, typ: str, quelle: str, optionen: str = "",
|
|
username: str = "", passwort: str = "") -> bool:
|
|
"""Toten/veralteten Mount frisch neu verbinden: lazy abhängen, neu mounten.
|
|
|
|
Nötig, wenn ein NAS-Mount stale geworden ist (Rebuild, NAS-Schlaf) — das
|
|
normale mounten() würde am „ist schon Mountpoint" hängenbleiben.
|
|
"""
|
|
ziel = _mountpoint(name)
|
|
try:
|
|
if os.path.ismount(ziel):
|
|
_lazy_umount(ziel)
|
|
except OSError:
|
|
_lazy_umount(ziel)
|
|
return mounten(name, typ, quelle, optionen, username, passwort)
|
|
|
|
|
|
def alle_remounten() -> list:
|
|
"""Beim API-Start: alle gespeicherten Mounts wiederherstellen."""
|
|
meldungen = []
|
|
for eintrag in db.list_mounts():
|
|
try:
|
|
mounten(
|
|
eintrag["name"], eintrag["typ"], eintrag["quelle"],
|
|
eintrag.get("optionen") or "", eintrag.get("username") or "",
|
|
eintrag.get("passwort") or "",
|
|
)
|
|
meldungen.append(f"{eintrag['name']}: eingehängt")
|
|
except Exception as e:
|
|
meldungen.append(f"{eintrag['name']}: FEHLER — {e}")
|
|
return meldungen
|