From 6517644d1f06ca75efcc1c19218edaae698cece2 Mon Sep 17 00:00:00 2001
From: Hitonabi
Date: Fri, 24 Jul 2026 08:18:44 +0200
Subject: [PATCH] Dashboard-Korrektur-Popup, Worker-Tab mit Live-Ping,
Herzschlag, Anzeige-Fixes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Commander-Wuensche (23.07. Nacht):
- "Nicht korrekt?!"-Knopf direkt an der Disc-Karte: Korrektur-Suche als
Popup (MetadataKorrektur) — kein Seitenwechsel mehr noetig; Wahl gilt
per Disc-Fingerabdruck weiter 30 Tage
- Einstellungen -> Worker: verbundene Encoding-Maschinen mit ECHTER
Erreichbarkeit (Celery-Ping + minuetlicher Herzschlag/last_seen),
Encoder-Badges, Copy-Paste-Anbindung fuer neue Maschinen
(Linux/Docker + Docker Desktop; SSH-Ein-Klick als Ausbaustufe)
- JSX-0-Falle gefixt: {0 && ...} renderte nackte "0" unter GENRE/LAUFZEIT
- ruff.toml: Regelsatz gepinnt — lokal aktualisierte Ruff-Version zog
ploetzlich Stilregeln und faerbte den Baum rot; Ampel und lokal
urteilen jetzt identisch
Co-Authored-By: Claude Fable 5
---
docker/api/main.py | 22 ++-
docker/ui/src/components/DeviceDiscovery.tsx | 33 +++-
.../ui/src/components/MetadataKorrektur.tsx | 145 ++++++++++++++++++
docker/ui/src/components/WorkerVerwaltung.tsx | 128 ++++++++++++++++
docker/ui/src/pages/MetadataPreview.tsx | 6 +-
docker/ui/src/pages/Settings.tsx | 13 +-
docker/worker/celery_app.py | 24 ++-
ruff.toml | 6 +
8 files changed, 358 insertions(+), 19 deletions(-)
create mode 100644 docker/ui/src/components/MetadataKorrektur.tsx
create mode 100644 docker/ui/src/components/WorkerVerwaltung.tsx
create mode 100644 ruff.toml
diff --git a/docker/api/main.py b/docker/api/main.py
index 3f1c017..54166e5 100644
--- a/docker/api/main.py
+++ b/docker/api/main.py
@@ -377,12 +377,28 @@ async def cancel_job(job_id: str):
@app.get("/capabilities")
async def capabilities():
- """Welche Encoder sind auf welchen Workern WIRKLICH verfügbar?
+ """Welche Encoder sind auf welchen Workern WIRKLICH verfügbar — inkl.
+ Live-Erreichbarkeit (Celery-Ping + Herzschlag-Alter).
- Jeder Worker meldet sich beim Start selbst (caps.py) — auch optionale
+ Jeder Worker meldet sich selbst (caps.py, minütlich) — auch optionale
Remote-GPU-Worker tauchen hier automatisch auf.
"""
- return {"workers": await asyncio.to_thread(db.list_workers)}
+ def sammle():
+ 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()
+ }
+ except Exception:
+ online_namen = set()
+ for zeile in zeilen:
+ zeile["online"] = zeile["name"] in online_namen
+ return zeilen
+
+ return {"workers": await asyncio.to_thread(sammle)}
class MountRequest(BaseModel):
diff --git a/docker/ui/src/components/DeviceDiscovery.tsx b/docker/ui/src/components/DeviceDiscovery.tsx
index 8454e83..5871426 100644
--- a/docker/ui/src/components/DeviceDiscovery.tsx
+++ b/docker/ui/src/components/DeviceDiscovery.tsx
@@ -3,6 +3,7 @@ import { HardDrive, Disc, AlertCircle, CheckCircle, RefreshCw } from 'lucide-rea
import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext'
import RipTargetModal from './RipTargetModal'
+import MetadataKorrektur from './MetadataKorrektur'
interface DiscInfo {
title: string
@@ -41,6 +42,7 @@ export default function DeviceDiscovery() {
const [actionFeedback, setActionFeedback] = useState(null)
const [actionBusy, setActionBusy] = useState(false)
const [modalDevice, setModalDevice] = useState(null)
+ const [korrekturDevice, setKorrekturDevice] = useState(null)
const { theme } = useDarkMode()
const refreshDevices = async () => {
@@ -176,13 +178,21 @@ export default function DeviceDiscovery() {
{device.disc!.metadata.overview}
)}
- setModalDevice(device)}
- disabled={actionBusy}
- className="mt-3 px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors disabled:opacity-40"
- >
- Rippen starten
-
+
+ setModalDevice(device)}
+ disabled={actionBusy}
+ className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors disabled:opacity-40"
+ >
+ Rippen starten
+
+ setKorrekturDevice(device)}
+ className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${theme === 'dark' ? 'bg-slate-800 text-amber-300 hover:bg-slate-700' : 'bg-slate-100 text-amber-700 hover:bg-slate-200'}`}
+ >
+ Nicht korrekt?
+
+
))}
@@ -313,6 +323,15 @@ export default function DeviceDiscovery() {
}
}}
/>
+
+ {/* „Nicht korrekt?"-Popup: Korrektur ohne Seitenwechsel */}
+ setKorrekturDevice(null)}
+ onUebernommen={refreshDevices}
+ />
)
}
diff --git a/docker/ui/src/components/MetadataKorrektur.tsx b/docker/ui/src/components/MetadataKorrektur.tsx
new file mode 100644
index 0000000..246a5f1
--- /dev/null
+++ b/docker/ui/src/components/MetadataKorrektur.tsx
@@ -0,0 +1,145 @@
+import { useState, useEffect } from 'react'
+import { Search, Loader2, X, Film } from 'lucide-react'
+import { api } from '../lib/api'
+import { useDarkMode } from '../context/ThemeContext'
+
+// „Nicht korrekt?!"-Popup (Commander-Wunsch 23.07.): Korrektur direkt am
+// Dashboard — suchen, richtigen Treffer anklicken, Rippy merkt sich die Wahl
+// für diese Disc (30 Tage, per Disc-Fingerabdruck).
+
+interface SuchKandidat {
+ title: string
+ year?: number
+ poster?: string
+ overview?: string
+ type: string
+ source: string
+ id?: string
+}
+
+interface Props {
+ devicePath: string
+ vorschlag?: string
+ isOpen: boolean
+ onClose: () => void
+ onUebernommen: () => void
+}
+
+export default function MetadataKorrektur({ devicePath, vorschlag, isOpen, onClose, onUebernommen }: Props) {
+ const [suchbegriff, setSuchbegriff] = useState('')
+ const [kandidaten, setKandidaten] = useState([])
+ const [laeuft, setLaeuft] = useState(false)
+ const [fehler, setFehler] = useState(null)
+ const { theme } = useDarkMode()
+
+ useEffect(() => {
+ if (isOpen) {
+ setSuchbegriff(vorschlag || '')
+ setKandidaten([])
+ setFehler(null)
+ }
+ }, [isOpen, vorschlag])
+
+ const suchen = async () => {
+ if (!suchbegriff) return
+ setLaeuft(true)
+ setFehler(null)
+ try {
+ const r = await api.get('/metadata/search', { params: { q: suchbegriff } })
+ setKandidaten(r.data)
+ if (r.data.length === 0) setFehler('Nichts gefunden — anderen Suchbegriff probieren.')
+ } catch {
+ setFehler('Suche fehlgeschlagen.')
+ } finally {
+ setLaeuft(false)
+ }
+ }
+
+ const waehlen = async (k: SuchKandidat) => {
+ try {
+ await api.post('/metadata/override', {
+ device_path: devicePath,
+ title: k.title,
+ year: k.year,
+ poster: k.poster,
+ overview: k.overview,
+ type: k.type,
+ source: k.source,
+ id: k.id,
+ })
+ onUebernommen()
+ onClose()
+ } catch {
+ setFehler('Übernehmen fehlgeschlagen.')
+ }
+ }
+
+ if (!isOpen) return null
+
+ return (
+
+
+
+
+
Richtigen Titel wählen
+
+ Deine Wahl gilt für diese Disc — auch beim nächsten Einlegen.
+
+
+
+
+
+
+
+
+
+ setSuchbegriff(e.target.value)}
+ onKeyDown={e => e.key === 'Enter' && suchen()}
+ autoFocus
+ placeholder="Titel eingeben…"
+ className={`flex-1 px-4 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-700 border-slate-600 text-slate-100' : 'bg-white border-slate-300 text-slate-900'}`}
+ />
+
+ {laeuft ? : }
+ Suchen
+
+
+
+ {fehler &&
{fehler}
}
+
+ {kandidaten.length > 0 && (
+
+ {kandidaten.map((k, i) => (
+
waehlen(k)}
+ className={`text-left rounded-lg border overflow-hidden transition-all hover:border-indigo-500 hover:shadow-lg ${theme === 'dark' ? 'bg-slate-900 border-slate-700' : 'bg-slate-50 border-slate-200'}`}
+ >
+ {k.poster ? (
+
+ ) : (
+
+
+
+ )}
+
+
{k.title}
+
+ {k.year || '—'} · {k.type === 'tv' ? 'Serie' : 'Film'} · {k.source}
+
+
+
+ ))}
+
+ )}
+
+
+
+ )
+}
diff --git a/docker/ui/src/components/WorkerVerwaltung.tsx b/docker/ui/src/components/WorkerVerwaltung.tsx
new file mode 100644
index 0000000..f5e0576
--- /dev/null
+++ b/docker/ui/src/components/WorkerVerwaltung.tsx
@@ -0,0 +1,128 @@
+import { useState, useEffect } from 'react'
+import { Server, RefreshCw, Copy, CheckCircle } from 'lucide-react'
+import { api } from '../lib/api'
+import { useDarkMode } from '../context/ThemeContext'
+
+// Worker-Verwaltung (Commander-Anforderung 23.07.): Welche Maschinen encoden,
+// sind sie erreichbar, und wie bindet man eine neue an? Ein Worker "installiert
+// sich" über einen Copy-Paste-Befehl auf der Zielmaschine — danach taucht er
+// hier automatisch auf und zieht sich Kompressions-Jobs aus der Warteschlange.
+
+interface WorkerInfo {
+ name: string
+ encoders: string[]
+ last_seen?: string
+ online?: boolean
+}
+
+const ENCODER_LABELS: Record = {
+ 'cpu-x264': 'CPU · H.264',
+ 'cpu-x265': 'CPU · H.265',
+ 'vaapi': 'GPU · VAAPI (AMD/Intel)',
+ 'nvenc': 'GPU · NVENC (NVIDIA)',
+}
+
+function relativeZeit(iso?: string): string {
+ if (!iso) return 'nie'
+ const sekunden = Math.floor((Date.now() - new Date(iso).getTime()) / 1000)
+ if (sekunden < 90) return 'gerade eben'
+ if (sekunden < 3600) return `vor ${Math.floor(sekunden / 60)} min`
+ return `vor ${Math.floor(sekunden / 3600)} h`
+}
+
+export default function WorkerVerwaltung() {
+ const [workers, setWorkers] = useState([])
+ const [kopiert, setKopiert] = useState(false)
+ const { theme } = useDarkMode()
+
+ const laden = () => {
+ api.get('/capabilities').then(r => setWorkers(r.data.workers || [])).catch(() => {})
+ }
+
+ useEffect(() => {
+ laden()
+ const interval = setInterval(laden, 15000)
+ return () => clearInterval(interval)
+ }, [])
+
+ const rippyHost = window.location.hostname
+ const installBefehl = [
+ `git clone rippy && cd rippy`,
+ `RIPPY_HOST=${rippyHost} docker compose -f deploy/remote-transcode-worker.yml up -d --build`,
+ ].join('\n')
+
+ const kopieren = () => {
+ navigator.clipboard.writeText(installBefehl).then(() => {
+ setKopiert(true)
+ setTimeout(() => setKopiert(false), 2000)
+ })
+ }
+
+ return (
+
+ {/* Verbundene Worker */}
+
+
+
Verbundene Worker
+
+
+
+
+ {workers.length === 0 ? (
+
Noch kein Worker gemeldet.
+ ) : (
+
+ {workers.map(w => (
+
+
+
+
+
+
{w.name}
+
+ {w.online ? 'online' : 'offline'} · Lebenszeichen {relativeZeit(w.last_seen)}
+
+
+
+
+ {w.encoders.map(e => (
+
+ {ENCODER_LABELS[e] || e}
+
+ ))}
+
+
+ ))}
+
+ )}
+
+
+ {/* Neue Maschine anbinden */}
+
+
Maschine als Encoding-Worker anbinden
+
+ Auf der Zielmaschine (Linux mit Docker, oder Windows mit Docker Desktop) diesen
+ Befehl ausführen — der Worker verbindet sich, taucht oben in der Liste auf und
+ übernimmt ab dann automatisch Kompressions-Jobs. GPU vorhanden? Wird erkannt
+ und angezeigt. Voraussetzung: die Maschine erreicht diesen Rippy-Host im Netz.
+
+
+ {installBefehl}
+
+ {kopiert ? : }
+
+
+
+ Eine geführte Ein-Klick-Installation (SSH) ist als Ausbaustufe geplant.
+
+
+
+ )
+}
diff --git a/docker/ui/src/pages/MetadataPreview.tsx b/docker/ui/src/pages/MetadataPreview.tsx
index 6d10165..5ce1480 100644
--- a/docker/ui/src/pages/MetadataPreview.tsx
+++ b/docker/ui/src/pages/MetadataPreview.tsx
@@ -337,7 +337,9 @@ export default function MetadataPreview() {
)}
- {result.metadata.genres && (
+ {/* .length-Guards: {[] && …} rendert die leere Sektion,
+ {0 && …} sogar eine nackte "0" — klassische JSX-Falle */}
+ {result.metadata.genres && result.metadata.genres.length > 0 && (
Genre
@@ -353,7 +355,7 @@ export default function MetadataPreview() {
)}
- {result.metadata.runtime && (
+ {(result.metadata.runtime ?? 0) > 0 && (
Laufzeit
{Math.floor(result.metadata.runtime / 60)}h {result.metadata.runtime % 60}m
diff --git a/docker/ui/src/pages/Settings.tsx b/docker/ui/src/pages/Settings.tsx
index 0cf0f4f..86589e3 100644
--- a/docker/ui/src/pages/Settings.tsx
+++ b/docker/ui/src/pages/Settings.tsx
@@ -3,6 +3,7 @@ import { Save, Disc, Cpu, Database, Globe, AlertCircle, CheckCircle, HardDrive }
import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext'
import StorageMounts from '../components/StorageMounts'
+import WorkerVerwaltung from '../components/WorkerVerwaltung'
interface SettingsState {
tmdbApiKey: string
@@ -38,7 +39,7 @@ const defaultSettings: SettingsState = {
keepOriginal: false,
}
-type SettingsTab = 'ripping' | 'verarbeitung' | 'speicherziele' | 'apis' | 'benachrichtigungen'
+type SettingsTab = 'ripping' | 'verarbeitung' | 'worker' | 'speicherziele' | 'apis' | 'benachrichtigungen'
interface WorkerInfo {
name: string
@@ -125,6 +126,7 @@ export default function SettingsPage() {
setActiveTab('ripping')} darkMode={theme === 'dark'} />
setActiveTab('verarbeitung')} darkMode={theme === 'dark'} />
+ setActiveTab('worker')} darkMode={theme === 'dark'} />
setActiveTab('speicherziele')} darkMode={theme === 'dark'} />
setActiveTab('apis')} darkMode={theme === 'dark'} />
setActiveTab('benachrichtigungen')} darkMode={theme === 'dark'} />
@@ -258,6 +260,15 @@ export default function SettingsPage() {
}
+ {/* Worker: Encoding-Maschinen im Netz */}
+ {activeTab === 'worker' &&
+
+
+ Encoding-Worker
+
+
+ }
+
{/* Speicherziele: Netzwerk-Mounts aus dem UI */}
{activeTab === 'speicherziele' &&
diff --git a/docker/worker/celery_app.py b/docker/worker/celery_app.py
index d309d92..20cddab 100644
--- a/docker/worker/celery_app.py
+++ b/docker/worker/celery_app.py
@@ -31,9 +31,21 @@ import db # noqa: E402
@worker_ready.connect
def melde_faehigkeiten(**kwargs):
- """Beim Start: eigene Encoder-Fähigkeiten in die DB melden (UI-Anzeige)."""
- try:
- db.init_db()
- db.save_worker(socket.gethostname(), caps.erkenne_encoder())
- except Exception as e: # DB noch nicht da → nicht den Start verhindern
- print(f"Fähigkeiten-Meldung fehlgeschlagen: {e}")
+ """Beim Start + minütlich: Encoder-Fähigkeiten und Lebenszeichen melden.
+
+ last_seen ist die Basis der Erreichbarkeits-Anzeige im UI — ein Worker,
+ der sich 2 Minuten nicht meldet, gilt als offline.
+ """
+ import threading
+ import time
+
+ def herzschlag():
+ while True:
+ try:
+ db.init_db()
+ db.save_worker(socket.gethostname(), caps.erkenne_encoder())
+ except Exception as e: # DB weg → weiterversuchen, nicht sterben
+ print(f"Fähigkeiten-Meldung fehlgeschlagen: {e}")
+ time.sleep(60)
+
+ threading.Thread(target=herzschlag, daemon=True, name="worker-herzschlag").start()
diff --git a/ruff.toml b/ruff.toml
new file mode 100644
index 0000000..6d6e199
--- /dev/null
+++ b/ruff.toml
@@ -0,0 +1,6 @@
+# Ruff-Regelsatz festgepinnt (23.07.2026): identisches Urteil auf jedem
+# Rechner und in der Ampel. Ohne Pin zog eine lokal aktualisierte Ruff-Version
+# plötzlich Stilregeln (UP/I/BLE/FURB …) und färbte den ganzen Baum rot —
+# der Regelsatz hier ist der bewährte Fehler-Fokus (Syntax, Imports, Bugs).
+[lint]
+select = ["E4", "E7", "E9", "F"]