From 4ba02047db22c84b91899ec8cd430872e5c557d8 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Fri, 24 Jul 2026 18:19:57 +0200 Subject: [PATCH] Drei Praxis-Bugs: tote NAS-Mounts reparierbar, HandBrake-Versionen konsistent, Encoder-Wahl beim Rip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- deploy/worker-windows/install.ps1 | 12 +- docker/api/celery_client.py | 21 +++ docker/api/main.py | 132 ++++++++++++++----- docker/api/mounts.py | 56 +++++++- docker/ui/src/components/DeviceDiscovery.tsx | 2 + docker/ui/src/components/RipTargetModal.tsx | 46 ++++++- docker/ui/src/components/StorageMounts.tsx | 67 +++++++++- docker/ui/src/pages/Settings.tsx | 25 +++- docker/worker/celery_app.py | 6 + docker/worker/tasks.py | 40 +++++- 10 files changed, 360 insertions(+), 47 deletions(-) diff --git a/deploy/worker-windows/install.ps1 b/deploy/worker-windows/install.ps1 index c56cdad..df37699 100644 --- a/deploy/worker-windows/install.ps1 +++ b/deploy/worker-windows/install.ps1 @@ -22,7 +22,17 @@ param( ) $ErrorActionPreference = "Stop" -$HandBrakeVersion = "1.9.2" +# HandBrake-Version: die NEUESTE offizielle (GitHub-Release) — so passt der +# Windows-Worker zu dem, was der Update-Check im UI meldet (frueher war hier +# eine feste 1.9.2 hardcodiert, was verwirrte). Faellt auf eine bekannte +# Version zurueck, falls die GitHub-API gerade nicht erreichbar ist. +$HandBrakeVersion = "1.11.2" +try { + $rel = Invoke-RestMethod "https://api.github.com/repos/HandBrake/HandBrake/releases/latest" ` + -TimeoutSec 15 -Headers @{ "User-Agent" = "rippy-installer" } + if ($rel.tag_name) { $HandBrakeVersion = ([string]$rel.tag_name).TrimStart("v") } + Write-Host "Neueste HandBrake-Version: $HandBrakeVersion" +} catch { Write-Host "GitHub-API nicht erreichbar — nutze HandBrake $HandBrakeVersion" } $HandBrakeUrl = "https://github.com/HandBrake/HandBrake/releases/download/$HandBrakeVersion/HandBrakeCLI-$HandBrakeVersion-win-x86_64.zip" Write-Host "== Rippy Windows-Worker Installation ==" -ForegroundColor Cyan diff --git a/docker/api/celery_client.py b/docker/api/celery_client.py index 1da42e4..601eb77 100644 --- a/docker/api/celery_client.py +++ b/docker/api/celery_client.py @@ -7,12 +7,33 @@ kein POST /jobs, kein udev-Daemon. Dieser Client schließt die Lücke. import os from celery import Celery +from celery.utils import worker_direct REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0") celery_client = Celery("rippy_api", broker=REDIS_URL, backend=REDIS_URL) +def transcode_queue(node: str = None): + """Ziel-Queue für die Kompression: der GEWÄHLTE Worker (worker_direct) + wenn er gerade online ist, sonst die geteilte transcode-Queue. + + So kann ein Job gezielt einen Encoder ansprechen — fällt der Worker aber + weg, bleibt die Kompression nicht in einer toten Queue hängen, sondern + landet bei irgendeinem freien Worker. + """ + if not node: + return "transcode" + try: + antworten = celery_client.control.ping(timeout=1.0) or [] + online = {k for antwort in antworten for k in antwort.keys()} + if node in online: + return worker_direct(node) + except Exception: + pass + return "transcode" + + def start_rip(device_path: str, job_id: str, target_dir: str = None): """Schickt den Rip-Task an den Worker (Task-Name aus worker/tasks.py).""" return celery_client.send_task( diff --git a/docker/api/main.py b/docker/api/main.py index c3cfbcc..3c2bf26 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -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 = @. 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") diff --git a/docker/api/mounts.py b/docker/api/mounts.py index b8c5c8b..8b7a4fa 100644 --- a/docker/api/mounts.py +++ b/docker/api/mounts.py @@ -30,7 +30,27 @@ def validiere_name(name: str) -> bool: def ist_gemountet(name: str) -> bool: - return os.path.ismount(_mountpoint(name)) + """Ist unter /app/media/ 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 mit „Host is down" (Befund 24.07.). CIFS läuft mit + `soft`, d. h. der Zugriff bricht schnell ab statt zu hängen. + """ + try: + os.listdir(_mountpoint(name)) + return True + except OSError: + return False def schreibtest(pfad: str) -> bool: @@ -169,22 +189,48 @@ def mounten(name: str, typ: str, quelle: str, optionen: str = "", 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) - if os.path.ismount(ziel): + 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: - raise RuntimeError( - f"umount schlug fehl: {(ergebnis.stderr or '').strip()[:300]}" - ) + # 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 = [] diff --git a/docker/ui/src/components/DeviceDiscovery.tsx b/docker/ui/src/components/DeviceDiscovery.tsx index 5189432..0514a33 100644 --- a/docker/ui/src/components/DeviceDiscovery.tsx +++ b/docker/ui/src/components/DeviceDiscovery.tsx @@ -88,6 +88,8 @@ export default function DeviceDiscovery() { ...(targetDir ? { target_dir: targetDir } : {}), ...(optionen?.series ? { series: optionen.series, season: optionen.season } : {}), ...(optionen?.mainFeatureOnly !== undefined ? { main_feature_only: optionen.mainFeatureOnly } : {}), + ...(optionen?.titles && optionen.titles.length ? { titles: optionen.titles } : {}), + ...(optionen?.transcodeNode ? { transcode_node: optionen.transcodeNode } : {}), }) setActionFeedback(`✓ Job angelegt (${response.data.id.slice(0, 8)}…) — Fortschritt im Dashboard`) toast('success', 'Rip gestartet — Fortschritt unten bei „Neueste Jobs"') diff --git a/docker/ui/src/components/RipTargetModal.tsx b/docker/ui/src/components/RipTargetModal.tsx index 6e78db6..21e6b64 100644 --- a/docker/ui/src/components/RipTargetModal.tsx +++ b/docker/ui/src/components/RipTargetModal.tsx @@ -1,9 +1,9 @@ import { useState, useEffect } from 'react' -import { Folder, FolderOpen, File, ArrowUp, CheckCircle, Film, Tv, Music } from 'lucide-react' +import { Folder, FolderOpen, File, ArrowUp, CheckCircle, Film, Tv, Music, Cpu } from 'lucide-react' import { api } from '../lib/api' import { Modal } from './ui/Modal' import { Button } from './ui/Button' -import { Input } from './ui/Input' +import { Input, Select } from './ui/Input' interface TargetConfig { id: string @@ -18,6 +18,18 @@ export interface RipOptionen { season?: number mainFeatureOnly?: boolean titles?: number[] + transcodeNode?: string // gewählter Encoder-Worker (Celery-Node) oder leer = auto +} + +interface WorkerWahl { + name: string + node: string | null + online?: boolean + encoders: string[] +} + +const ENCODER_KURZ: Record = { + 'cpu-x264': 'H.264', 'cpu-x265': 'H.265', 'vaapi': 'VAAPI⚡', 'nvenc': 'NVENC⚡', } interface TitelInfo { @@ -56,6 +68,8 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI const [scanFehler, setScanFehler] = useState('') const [titelListe, setTitelListe] = useState([]) const [gewaehlt, setGewaehlt] = useState>(new Set()) + const [workers, setWorkers] = useState([]) + const [encoderNode, setEncoderNode] = useState('') // '' = automatisch const scanStarten = async () => { if (!deviceId) return @@ -109,6 +123,11 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI setScanFehler('') setTitelListe([]) setGewaehlt(new Set()) + setEncoderNode('') + // Online-Worker für die Encoder-Wahl (nur relevant, wenn ≥2 verfügbar) + api.get('/capabilities').then(r => { + setWorkers((r.data.workers || []).filter((w: WorkerWahl) => w.online && w.node)) + }).catch(() => setWorkers([])) api.get('/settings').then(r => { const s = r.data || {} setNurHauptfilm(!!s.mainFeatureOnly) @@ -144,6 +163,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI if (scanStatus === 'done' && gewaehlt.size > 0 && gewaehlt.size < titelListe.length) { optionen.titles = [...gewaehlt].sort((a, b) => a - b) } + if (encoderNode) optionen.transcodeNode = encoderNode const target = targets.find(t => t.type === selectedType) if (customPath) { onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true }, optionen) @@ -225,6 +245,28 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI )} + {/* Encoder-/Worker-Wahl — nur wenn mehrere Worker online sind + (sonst gibt es nichts zu wählen). Musik wird nicht komprimiert. */} + {selectedType !== 'music' && workers.length >= 2 && ( +
+ +

+ Wähle gezielt eine Maschine (z. B. die mit GPU) — sonst nimmt der erste freie Worker. +

+
+ )} + {/* Titel-Auswahl (Track-Tabelle) */} {deviceId && selectedType !== 'music' && (
diff --git a/docker/ui/src/components/StorageMounts.tsx b/docker/ui/src/components/StorageMounts.tsx index 58ec63c..31693dd 100644 --- a/docker/ui/src/components/StorageMounts.tsx +++ b/docker/ui/src/components/StorageMounts.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { HardDrive, Plus, Trash2, CheckCircle, AlertCircle, RefreshCw, Folder, File, ArrowUp, FolderPlus } from 'lucide-react' +import { HardDrive, Plus, Trash2, CheckCircle, AlertCircle, AlertTriangle, RefreshCw, Wrench, Folder, File, ArrowUp, FolderPlus } from 'lucide-react' import { api } from '../lib/api' import { useToast } from '../context/ToastContext' import { Card, CardHeader, CardTitle, CardContent } from './ui/Card' @@ -11,6 +11,7 @@ interface Mount { type: string source: string mounted: boolean + reachable?: boolean has_credentials: boolean } @@ -149,6 +150,20 @@ export default function StorageMounts() { } } + const reparieren = async (mountName: string) => { + setBusy(true) + try { + const r = await api.post(`/storage-mounts/${mountName}/repair`) + toast(r.data.writable ? 'success' : 'error', + r.data.writable ? `„${mountName}" neu verbunden` : `„${mountName}" verbunden, aber nur lesbar`) + laden() + } catch (e: any) { + toast('error', e?.response?.data?.detail || 'Reparatur fehlgeschlagen — NAS erreichbar?') + } finally { + setBusy(false) + } + } + return (
{/* Vorhandene Ziele */} @@ -190,6 +205,56 @@ export default function StorageMounts() { + {/* Netzwerk-Mounts mit Status — zeigt AUCH tote/getrennte Mounts, damit + man sie reparieren oder entfernen kann (Bug 24.07.: ein toter NAS-Mount + verschwand aus „Verfügbare Ziele" und ließ sich nicht neu anlegen). */} + {mounts.length > 0 && ( + + + + + Netzwerk-Mounts + + + +
+ {mounts.map(m => { + const aktiv = m.mounted && m.reachable !== false + const tot = m.mounted && m.reachable === false + return ( +
+
+ {aktiv + ? + : tot + ? + : } +
+ {m.name} + {m.source} +

+ {aktiv ? 'aktiv & erreichbar' : tot ? 'verbunden, aber NICHT erreichbar (NAS aus/weg?)' : 'getrennt'} +

+
+
+
+ {!aktiv && ( + + )} + +
+
+ ) + })} +
+
+
+ )} + {/* Ordner-Verwaltung */}
- {updates && ( -
-
+ {updates ? ( +
+
MakeMKV: installiert {updates.makemkv?.installiert || '—'}, aktuell {updates.makemkv?.verfuegbar || 'unbekannt'} + {updates.makemkv?.update && ( +

+ Update verfügbar — in der .env MAKEMKV_VERSION={updates.makemkv.verfuegbar} setzen, + dann auf der Rippy-Maschine docker compose build worker && docker compose up -d worker. + Neue Versionen bringen auch die neueste Disc-Schlüssel-Datenbank mit. +

+ )}
- HandBrake: installiert {updates.handbrake?.installiert || '—'}, verfügbar {updates.handbrake?.verfuegbar || 'unbekannt'} + HandBrake: Docker-Worker {updates.handbrake?.installiert || '—'}, neueste offizielle {updates.handbrake?.verfuegbar || 'unbekannt'} +

+ Das ist kein Fehler: Im Docker-Worker kommt HandBrake aus dem stabilen + Debian-Paket und hinkt der neuesten Version bewusst hinterher (funktioniert einwandfrei, + keine Eigenbauten). Der native Windows-Worker holt automatisch die neueste + Version — deshalb die unterschiedlichen Nummern. +

+ ) : ( +

+ Prüft gegen makemkv.com und die HandBrake-GitHub-Releases (Ergebnis wird 12 h gemerkt). +

)}
diff --git a/docker/worker/celery_app.py b/docker/worker/celery_app.py index 27f274f..955534f 100644 --- a/docker/worker/celery_app.py +++ b/docker/worker/celery_app.py @@ -20,6 +20,12 @@ celery_app.conf.update( timezone="UTC", enable_utc=True, broker_connection_retry_on_startup=True, + # worker_direct: jeder Worker konsumiert zusätzlich eine eigene + # Direkt-Queue (.dq). Damit kann die API die Kompression + # gezielt an EINEN gewählten Worker schicken (Encoder-Auswahl im + # Rip-Dialog) — ohne Wahl läuft sie weiter über die geteilte + # transcode-Queue (irgendein freier Worker). + worker_direct=True, ) diff --git a/docker/worker/tasks.py b/docker/worker/tasks.py index 8842de7..3ba9124 100644 --- a/docker/worker/tasks.py +++ b/docker/worker/tasks.py @@ -47,6 +47,22 @@ from ripping import ( API_URL = os.getenv("API_URL", "http://api:8000") +def _transcode_queue(node: str): + """Ziel-Queue für die Kompression (siehe celery_client.transcode_queue): + gewählter Worker via worker_direct, wenn online — sonst geteilte Queue.""" + if not node: + return "transcode" + try: + from celery.utils import worker_direct + antworten = celery_app.control.ping(timeout=1.0) or [] + online = {k for antwort in antworten for k in antwort.keys()} + if node in online: + return worker_direct(node) + except Exception: + pass + return "transcode" + + def pfad_lokal(pfad: str, mapping: str = None) -> str: """Übersetzt Rippy-Container-Pfade für native Worker (pure Funktion). @@ -430,13 +446,18 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None): ) if ergebnis.get("status") == "success" and transcode_an: - # Kompression als eigener Task auf der transcode-Queue — kann vom - # lokalen Worker ODER einem Remote-GPU-Worker übernommen werden. + # Kompression als eigener Task — an den im Rip-Dialog GEWÄHLTEN Worker + # (worker_direct), sonst an die geteilte transcode-Queue (irgendein + # freier Worker, inkl. Remote-GPU). + ziel_queue = _transcode_queue(meta.get("transcode_node")) db.update_job(job_id, status="transcoding", progress=0) - db.add_log("info", "worker", f"Job {job_id}: Rip fertig, Kompression eingereiht") + gezielt = meta.get("transcode_node") and ziel_queue != "transcode" + db.add_log("info", "worker", + f"Job {job_id}: Rip fertig, Kompression eingereiht" + + (f" → gezielt an {meta.get('transcode_node')}" if gezielt else "")) transcode_files.apply_async( args=[job_id, raw_dir, final_dir], - queue="transcode", + queue=ziel_queue, ) return {"status": "ripped", "raw_dir": raw_dir} @@ -444,6 +465,17 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None): return ergebnis +@celery_app.task(name="worker.tasks.ping_worker") +def ping_worker(): + """Winziger Task zum Beweisen des gezielten Routings: gibt Hostname + + Encoder zurück. Wird über worker_direct an EINEN Worker geschickt — landet + er beim richtigen, stimmt das Encoder-Routing (Encoder-Auswahl im UI).""" + import socket + + import caps + return {"hostname": socket.gethostname(), "encoders": caps.erkenne_encoder()} + + @celery_app.task(name="worker.tasks.scan_tracks") def scan_tracks(device_path: str): """Titel-Liste der eingelegten Disc erfassen (für die Auswahl-Tabelle).