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}

)} - +
+ + +
))} @@ -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'}`} + /> + +
+ + {fehler &&

{fehler}

} + + {kandidaten.length > 0 && ( +
+ {kandidaten.map((k, i) => ( + + ))} +
+ )} +
+
+
+ ) +} 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} + +
+

+ 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"]