Drei Praxis-Bugs: tote NAS-Mounts reparierbar, HandBrake-Versionen konsistent, Encoder-Wahl beim Rip
Ampel / ampel (push) Successful in 28s
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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"')
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 && 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>
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user