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
+11 -1
View File
@@ -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
+21
View File
@@ -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(
+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")
+51 -5
View File
@@ -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/<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 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 = []
@@ -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"')
+44 -2
View File
@@ -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<string, string> = {
'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<TitelInfo[]>([])
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set())
const [workers, setWorkers] = useState<WorkerWahl[]>([])
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
</label>
)}
{/* 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 && (
<div>
<Select
label="Encoder / Worker für die Kompression"
value={encoderNode}
onChange={e => setEncoderNode(e.target.value)}
>
<option value="">Automatisch (erster freier Worker)</option>
{workers.map(w => (
<option key={w.node!} value={w.node!}>
{w.name} {w.encoders.map(e => ENCODER_KURZ[e] || e).join(', ')}
</option>
))}
</Select>
<p className="text-xs mt-1.5 text-slate-500 dark:text-slate-400 flex items-center gap-1.5">
<Cpu size={13} /> Wähle gezielt eine Maschine (z. B. die mit GPU) sonst nimmt der erste freie Worker.
</p>
</div>
)}
{/* Titel-Auswahl (Track-Tabelle) */}
{deviceId && selectedType !== 'music' && (
<div className="rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-50/50 dark:bg-slate-950/50">
+66 -1
View File
@@ -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 (
<div className="space-y-6">
{/* Vorhandene Ziele */}
@@ -190,6 +205,56 @@ export default function StorageMounts() {
</CardContent>
</Card>
{/* 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 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<HardDrive size={20} className="text-amber-500" />
<span>Netzwerk-Mounts</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2.5">
{mounts.map(m => {
const aktiv = m.mounted && m.reachable !== false
const tot = m.mounted && m.reachable === false
return (
<div key={m.name} className="flex items-center justify-between gap-3 p-3.5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50 dark:bg-slate-950/80">
<div className="flex items-center gap-3 min-w-0">
{aktiv
? <CheckCircle size={18} className="text-emerald-500 flex-shrink-0" />
: tot
? <AlertTriangle size={18} className="text-amber-500 flex-shrink-0" />
: <AlertCircle size={18} className="text-slate-400 flex-shrink-0" />}
<div className="min-w-0">
<span className="text-sm font-medium text-slate-900 dark:text-slate-200">{m.name}</span>
<span className="text-xs ml-2 font-mono text-slate-500 dark:text-slate-400">{m.source}</span>
<p className={`text-xs ${aktiv ? 'text-emerald-600 dark:text-emerald-400' : tot ? 'text-amber-600 dark:text-amber-400' : 'text-slate-500 dark:text-slate-400'}`}>
{aktiv ? 'aktiv & erreichbar' : tot ? 'verbunden, aber NICHT erreichbar (NAS aus/weg?)' : 'getrennt'}
</p>
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{!aktiv && (
<Button variant="secondary" size="sm" onClick={() => reparieren(m.name)} disabled={busy} title="Neu verbinden (gespeicherte Zugangsdaten)">
<Wrench size={14} /> Reparieren
</Button>
)}
<button onClick={() => entfernen(m.name)} disabled={busy} className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors" title="Entfernen">
<Trash2 size={16} />
</button>
</div>
</div>
)
})}
</div>
</CardContent>
</Card>
)}
{/* Ordner-Verwaltung */}
<Card>
<button
+21 -4
View File
@@ -653,15 +653,32 @@ export default function SettingsPage() {
{updatesBusy ? 'Prüfe…' : 'Auf Updates prüfen'}
</Button>
</div>
{updates && (
<div className="space-y-2 text-sm text-slate-700 dark:text-slate-300">
<div>
{updates ? (
<div className="space-y-3 text-sm text-slate-700 dark:text-slate-300">
<div className={updates.makemkv?.update ? 'text-amber-600 dark:text-amber-400' : ''}>
<strong>MakeMKV:</strong> installiert {updates.makemkv?.installiert || '—'}, aktuell {updates.makemkv?.verfuegbar || 'unbekannt'}
{updates.makemkv?.update && (
<p className="text-xs mt-1">
Update verfügbar in der .env <code>MAKEMKV_VERSION={updates.makemkv.verfuegbar}</code> setzen,
dann auf der Rippy-Maschine <code>docker compose build worker &amp;&amp; docker compose up -d worker</code>.
Neue Versionen bringen auch die neueste Disc-Schlüssel-Datenbank mit.
</p>
)}
</div>
<div>
<strong>HandBrake:</strong> installiert {updates.handbrake?.installiert || '—'}, verfügbar {updates.handbrake?.verfuegbar || 'unbekannt'}
<strong>HandBrake:</strong> Docker-Worker {updates.handbrake?.installiert || '—'}, neueste offizielle {updates.handbrake?.verfuegbar || 'unbekannt'}
<p className="text-xs mt-1 text-slate-500 dark:text-slate-400">
Das ist <strong>kein Fehler</strong>: Im Docker-Worker kommt HandBrake aus dem stabilen
Debian-Paket und hinkt der neuesten Version bewusst hinterher (funktioniert einwandfrei,
keine Eigenbauten). Der native <strong>Windows-Worker holt automatisch die neueste</strong>
Version deshalb die unterschiedlichen Nummern.
</p>
</div>
</div>
) : (
<p className="text-xs text-slate-500 dark:text-slate-400">
Prüft gegen makemkv.com und die HandBrake-GitHub-Releases (Ergebnis wird 12 h gemerkt).
</p>
)}
</div>
</section>
+6
View File
@@ -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 (<nodename>.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,
)
+36 -4
View File
@@ -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).