Drei Praxis-Bugs: tote NAS-Mounts reparierbar, HandBrake-Versionen konsistent, Encoder-Wahl beim Rip
Ampel / ampel (push) Successful in 28s

1) Speicher-Mounts robust (Befund: toter CIFS-Mount nach NAS-Ausfall/Rebuild —
   mounted:false, verschwand aus 'Verfuegbare Ziele', Neu-Anlegen -> 409, man
   sass fest):
   - mounts.py: ist_erreichbar() (listdir, soft-Mount bricht schnell ab),
     ist_gemountet() faengt OSError toter Mounts, aushaengen() mit
     umount -l Fallback, reparieren() (lazy abhaengen + frisch mounten).
   - /storage-mounts liefert 'reachable'; POST bei existierendem Namen:
     aktiv -> 409, tot -> automatische Reparatur mit neuen Angaben;
     neuer POST /storage-mounts/{name}/repair (gespeicherte Zugangsdaten).
   - /storage-targets crasht nicht mehr an totem Mount (os.path.ismount
     OSError abgefangen).
   - UI: eigene 'Netzwerk-Mounts'-Liste mit Status (aktiv/nicht erreichbar/
     getrennt) + Reparieren- und Entfernen-Knopf — tote Mounts sind sichtbar
     und wiederherstellbar statt zu verschwinden.

2) HandBrake-Versionen konsistent (Befund: Docker 1.6.1, Windows-Skript
   fest 1.9.2, Update-Check meldet 1.11.2 — verwirrend):
   - Windows-Installer zieht jetzt DYNAMISCH die neueste Version (GitHub
     latest, Fallback 1.11.2) — passt zum Update-Check.
   - Update-UI erklaert klar: Docker = stabiles Debian-Paket (bewusst aelter,
     kein Fehler), Windows = neueste. MakeMKV-Update zeigt den Befehl.

3) Encoder-/Worker-Auswahl beim Rip (Feature):
   - Celery worker_direct=True: jeder Worker konsumiert zusaetzlich seine
     Direkt-Queue. API-Helper transcode_queue(node) routet gezielt an den
     gewaehlten Worker, faellt aber sicher auf die geteilte transcode-Queue
     zurueck, wenn er offline ist (kein Haengenbleiben).
   - /capabilities liefert den Celery-Node je Worker; POST /jobs nimmt
     transcode_node (-> Job-meta); rip_disc + retry-transcode routen danach.
   - Rip-Dialog: Encoder-/Worker-Dropdown, sichtbar ab 2 Online-Workern.
   - ping_worker-Task zum Verifizieren des gezielten Routings.
   - Nebenfund gefixt: DeviceDiscovery leitete die Titel-Auswahl (titles)
     gar nicht an die API weiter — jetzt titles + transcode_node.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-24 18:19:57 +02:00
parent fe1387b1ec
commit 4ba02047db
10 changed files with 360 additions and 47 deletions
+102 -30
View File
@@ -338,6 +338,7 @@ class JobCreateRequest(BaseModel):
season: Optional[int] = None
main_feature_only: Optional[bool] = None # pro Rip; None = Setting gilt
titles: Optional[List[int]] = None # exakte Titel-Auswahl (Track-Tabelle)
transcode_node: Optional[str] = None # gewählter Encoder-Worker (Celery-Node)
MEDIA_ROOT = "/app/media"
@@ -395,6 +396,8 @@ async def create_job(request: JobCreateRequest):
titel_liste = sorted({int(t) for t in request.titles if int(t) >= 0})[:200]
if titel_liste:
meta_dict["titles"] = titel_liste
if request.transcode_node:
meta_dict["transcode_node"] = request.transcode_node
meta_json = json.dumps(meta_dict) if meta_dict else None
job_id = str(uuid.uuid4())
@@ -511,7 +514,15 @@ async def storage_targets():
return ziele
for name in eintraege:
pfad = os.path.join(MEDIA_ROOT, name)
if not os.path.isdir(pfad):
try:
ist_mount = os.path.ismount(pfad)
except OSError:
# Toter CIFS-Mount (NAS weg) — als Ziel unbrauchbar, aber NICHT
# den ganzen Endpoint sprengen (Befund 24.07.: Eintrag flog raus,
# ließ sich aber nicht mehr neu anlegen). Überspringen ist ok,
# die Mount-Verwaltung (/storage-mounts) zeigt ihn zum Reparieren.
continue
if not ist_mount and not os.path.isdir(pfad):
continue
try:
nutzung = shutil.disk_usage(pfad)
@@ -521,7 +532,7 @@ async def storage_targets():
ziele.append({
"name": name,
"path": pfad,
"is_mount": os.path.ismount(pfad),
"is_mount": ist_mount,
"free_gb": frei_gb,
})
return ziele
@@ -599,10 +610,16 @@ async def retry_transcode(job_id: str):
basis = job.get("target_dir") or f"{MEDIA_ROOT}/{job.get('disc_type') or 'bluray'}"
final_dir = job.get("output_path") or f"{basis}/{job_id}"
# An den (beim Rip gewählten) Encoder-Worker routen, sonst geteilte Queue
try:
meta = json.loads(job.get("meta") or "{}")
except ValueError:
meta = {}
from celery_client import transcode_queue
celery_client.send_task(
"worker.tasks.transcode_files",
args=[job_id, raw_dir, final_dir],
queue="transcode",
queue=transcode_queue(meta.get("transcode_node")),
)
await asyncio.to_thread(db.update_job, job_id, status="transcoding", progress=0, error=None)
await asyncio.to_thread(db.add_log, "info", "api", f"Job {job_id}: Kompression neu eingereiht")
@@ -639,18 +656,18 @@ async def capabilities():
zeilen = db.list_workers()
try:
antworten = celery_client.control.ping(timeout=1.0) or []
online_namen = {
knoten.split("@", 1)[-1]
for antwort in antworten
for knoten in antwort.keys()
}
ping_knoten = [k for antwort in antworten for k in antwort.keys()]
except Exception:
online_namen = set()
ping_knoten = []
for zeile in zeilen:
# Celery-Ping meldet den HOSTNAME des Knotens — bei gesetztem
# WORKER_NAME (Anzeigename) steckt der echte Hostname in info.
# Celery-Knotenname = <name>@<hostname>. Der Ping liefert ihn voll;
# gematcht wird über den Hostname (der Docker-Worker heißt celery@…,
# der WORKER_NAME steckt nur in info). `node` ist der Routing-Ziel-
# Knoten für die gezielte Encoder-Wahl.
hostname = (zeile.get("info") or {}).get("hostname") or zeile["name"]
zeile["online"] = hostname in online_namen
node = next((k for k in ping_knoten if k.split("@", 1)[-1] == hostname), None)
zeile["online"] = node is not None
zeile["node"] = node
return zeilen
return {"workers": await asyncio.to_thread(sammle)}
@@ -806,39 +823,67 @@ class MountRequest(BaseModel):
@app.get("/storage-mounts")
async def get_storage_mounts():
"""Konfigurierte Netzwerk-Speicherziele inkl. Live-Mount-Status."""
eintraege = await asyncio.to_thread(db.list_mounts)
return [
{
"name": e["name"],
"type": e["typ"],
"source": e["quelle"],
"mounted": mount_verwaltung.ist_gemountet(e["name"]),
"has_credentials": bool(e.get("username")),
}
for e in eintraege
]
"""Konfigurierte Netzwerk-Speicherziele inkl. Live-Status.
`mounted` = liegt ein Mount an; `reachable` = ist er auch WIRKLICH nutzbar
(toter CIFS-Mount nach NAS-Ausfall: mounted=true, reachable=false → das UI
bietet dann „Reparieren" statt den Eintrag verschwinden zu lassen)."""
def sammle():
eintraege = db.list_mounts()
ergebnis = []
for e in eintraege:
gemountet = mount_verwaltung.ist_gemountet(e["name"])
ergebnis.append({
"name": e["name"],
"type": e["typ"],
"source": e["quelle"],
"mounted": gemountet,
"reachable": mount_verwaltung.ist_erreichbar(e["name"]) if gemountet else False,
"has_credentials": bool(e.get("username")),
})
return ergebnis
return await asyncio.to_thread(sammle)
@app.post("/storage-mounts", status_code=201)
async def create_storage_mount(request: MountRequest):
"""Hängt ein NFS/SMB-Ziel ein und speichert es für den nächsten Start."""
"""Hängt ein NFS/SMB-Ziel ein und speichert es für den nächsten Start.
Existiert der Name schon UND ist erreichbar → 409. Existiert er, ist aber
TOT (NAS war weg) → wird mit den neuen Angaben frisch repariert statt
stur „Name bereits vergeben" zu melden (Befund 24.07.: man saß sonst fest)."""
if not mount_verwaltung.validiere_name(request.name):
raise HTTPException(status_code=422, detail="Name: nur a-z, 0-9, Bindestrich (2-31 Zeichen)")
if request.type not in ("nfs", "cifs"):
raise HTTPException(status_code=422, detail="Typ muss nfs oder cifs sein")
if any(e["name"] == request.name for e in await asyncio.to_thread(db.list_mounts)):
raise HTTPException(status_code=409, detail="Name bereits vergeben")
vorhanden = any(e["name"] == request.name for e in await asyncio.to_thread(db.list_mounts))
if vorhanden:
gemountet = await asyncio.to_thread(mount_verwaltung.ist_gemountet, request.name)
erreichbar = gemountet and await asyncio.to_thread(mount_verwaltung.ist_erreichbar, request.name)
if erreichbar:
raise HTTPException(
status_code=409,
detail="Name bereits vergeben und aktiv — erst entfernen, dann neu anlegen.",
)
# Toter/veralteter Eintrag → reparieren (lazy abhängen + frisch mounten)
aktion = mount_verwaltung.reparieren
else:
aktion = mount_verwaltung.mounten
try:
schreibbar = await asyncio.to_thread(
mount_verwaltung.mounten,
aktion,
request.name, request.type, request.source,
request.options or "", request.username or "", request.password or "",
)
except RuntimeError as e:
raise HTTPException(status_code=400, detail=str(e))
# Bei Reparatur die (evtl. neuen) Zugangsdaten in der DB aktualisieren
if vorhanden:
await asyncio.to_thread(db.delete_mount, request.name)
await asyncio.to_thread(
db.save_mount,
request.name, request.type, request.source,
@@ -847,10 +892,37 @@ async def create_storage_mount(request: MountRequest):
await asyncio.to_thread(
db.add_log,
"success" if schreibbar else "warning", "mounts",
f"Speicherziel '{request.name}' ({request.type}) eingehängt: {request.source}"
f"Speicherziel '{request.name}' ({request.type}) "
+ ("repariert" if vorhanden else "eingehängt") + f": {request.source}"
+ ("" if schreibbar else " — ACHTUNG: NUR LESBAR (Schreibtest fehlgeschlagen)"),
)
return {"name": request.name, "mounted": True, "writable": schreibbar}
return {"name": request.name, "mounted": True, "writable": schreibbar, "repaired": vorhanden}
@app.post("/storage-mounts/{name}/repair")
async def repair_storage_mount(name: str):
"""Toten/veralteten Mount mit den GESPEICHERTEN Zugangsdaten neu verbinden
(Reparieren-Knopf im UI) — ohne dass der Nutzer alles neu eintippt."""
eintrag = next(
(e for e in await asyncio.to_thread(db.list_mounts) if e["name"] == name), None
)
if not eintrag:
raise HTTPException(status_code=404, detail="Speicherziel nicht gefunden")
try:
schreibbar = await asyncio.to_thread(
mount_verwaltung.reparieren,
eintrag["name"], eintrag["typ"], eintrag["quelle"],
eintrag.get("optionen") or "", eintrag.get("username") or "",
eintrag.get("passwort") or "",
)
except RuntimeError as e:
raise HTTPException(status_code=400, detail=str(e))
await asyncio.to_thread(
db.add_log, "success" if schreibbar else "warning", "mounts",
f"Speicherziel '{name}' neu verbunden"
+ ("" if schreibbar else " — NUR LESBAR"),
)
return {"name": name, "mounted": True, "writable": schreibbar}
@app.get("/storage-mounts/shares")