Dashboard-Korrektur-Popup, Worker-Tab mit Live-Ping, Herzschlag, Anzeige-Fixes
Ampel / ampel (push) Failing after 29s

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 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-24 08:18:44 +02:00
parent 6b7d391737
commit 6517644d1f
8 changed files with 358 additions and 19 deletions
+19 -3
View File
@@ -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):
+26 -7
View File
@@ -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<string | null>(null)
const [actionBusy, setActionBusy] = useState(false)
const [modalDevice, setModalDevice] = useState<Device | null>(null)
const [korrekturDevice, setKorrekturDevice] = useState<Device | null>(null)
const { theme } = useDarkMode()
const refreshDevices = async () => {
@@ -176,13 +178,21 @@ export default function DeviceDiscovery() {
{device.disc!.metadata.overview}
</p>
)}
<button
onClick={() => 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
</button>
<div className="flex items-center gap-3 mt-3">
<button
onClick={() => 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
</button>
<button
onClick={() => 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?
</button>
</div>
</div>
</div>
))}
@@ -313,6 +323,15 @@ export default function DeviceDiscovery() {
}
}}
/>
{/* „Nicht korrekt?"-Popup: Korrektur ohne Seitenwechsel */}
<MetadataKorrektur
devicePath={korrekturDevice?.path || ''}
vorschlag={korrekturDevice?.disc?.title}
isOpen={korrekturDevice !== null}
onClose={() => setKorrekturDevice(null)}
onUebernommen={refreshDevices}
/>
</div>
)
}
@@ -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<SuchKandidat[]>([])
const [laeuft, setLaeuft] = useState(false)
const [fehler, setFehler] = useState<string | null>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className={`w-full max-w-2xl max-h-[85vh] flex flex-col rounded-xl shadow-2xl ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'}`}>
<div className={`px-6 py-4 border-b flex items-center justify-between ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
<div>
<h2 className={`text-lg font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Richtigen Titel wählen</h2>
<p className={`text-xs mt-0.5 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
Deine Wahl gilt für diese Disc auch beim nächsten Einlegen.
</p>
</div>
<button onClick={onClose} className={`p-1.5 rounded ${theme === 'dark' ? 'hover:bg-slate-700 text-slate-400' : 'hover:bg-slate-100 text-slate-500'}`}>
<X size={18} />
</button>
</div>
<div className="p-6 overflow-y-auto">
<div className="flex gap-3">
<input
value={suchbegriff}
onChange={e => 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'}`}
/>
<button
onClick={suchen}
disabled={!suchbegriff || laeuft}
className="flex items-center gap-2 px-5 py-2 rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 transition-colors disabled:opacity-40"
>
{laeuft ? <Loader2 className="animate-spin" size={18} /> : <Search size={18} />}
Suchen
</button>
</div>
{fehler && <p className="text-sm text-rose-500 mt-3">{fehler}</p>}
{kandidaten.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mt-4">
{kandidaten.map((k, i) => (
<button
key={i}
onClick={() => 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 ? (
<img src={k.poster} alt={k.title} className="w-full h-40 object-cover" />
) : (
<div className={`w-full h-40 flex items-center justify-center ${theme === 'dark' ? 'bg-slate-800 text-slate-600' : 'bg-slate-100 text-slate-400'}`}>
<Film size={32} />
</div>
)}
<div className="p-2">
<p className={`text-xs font-semibold leading-tight line-clamp-2 ${theme === 'dark' ? 'text-slate-200' : 'text-slate-800'}`}>{k.title}</p>
<p className={`text-xs mt-0.5 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
{k.year || '—'} · {k.type === 'tv' ? 'Serie' : 'Film'} · {k.source}
</p>
</div>
</button>
))}
</div>
)}
</div>
</div>
</div>
)
}
@@ -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<string, string> = {
'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<WorkerInfo[]>([])
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-repo-url> 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 (
<div className="space-y-6">
{/* Verbundene Worker */}
<div>
<div className="flex items-center justify-between mb-3">
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Verbundene Worker</h3>
<button onClick={laden} className={`p-1.5 rounded ${theme === 'dark' ? 'hover:bg-slate-700 text-slate-400' : 'hover:bg-slate-100 text-slate-500'}`}>
<RefreshCw size={16} />
</button>
</div>
{workers.length === 0 ? (
<p className={`text-sm ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Noch kein Worker gemeldet.</p>
) : (
<div className="space-y-2">
{workers.map(w => (
<div key={w.name} className={`flex items-center justify-between p-3 rounded-lg ${theme === 'dark' ? 'bg-slate-800' : 'bg-slate-50'}`}>
<div className="flex items-center gap-3 min-w-0">
<span
className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${w.online ? 'bg-emerald-500' : 'bg-rose-500'}`}
title={w.online ? 'erreichbar (Ping OK)' : 'nicht erreichbar'}
/>
<Server size={18} className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'} />
<div className="min-w-0">
<p className={`text-sm font-mono truncate ${theme === 'dark' ? 'text-slate-200' : 'text-slate-800'}`}>{w.name}</p>
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
{w.online ? 'online' : 'offline'} · Lebenszeichen {relativeZeit(w.last_seen)}
</p>
</div>
</div>
<div className="flex flex-wrap gap-1 justify-end">
{w.encoders.map(e => (
<span key={e} className={`text-xs px-2 py-0.5 rounded ${e.startsWith('cpu') ? (theme === 'dark' ? 'bg-slate-700 text-slate-300' : 'bg-slate-200 text-slate-600') : 'bg-emerald-600/20 text-emerald-500 font-medium'}`}>
{ENCODER_LABELS[e] || e}
</span>
))}
</div>
</div>
))}
</div>
)}
</div>
{/* Neue Maschine anbinden */}
<div className={`rounded-lg p-4 border ${theme === 'dark' ? 'border-slate-700 bg-slate-800/50' : 'border-slate-200 bg-slate-50'}`}>
<h3 className={`font-medium mb-1 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Maschine als Encoding-Worker anbinden</h3>
<p className={`text-xs mb-3 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
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.
</p>
<div className={`relative rounded-lg p-3 font-mono text-xs whitespace-pre overflow-x-auto ${theme === 'dark' ? 'bg-slate-950 text-slate-300' : 'bg-slate-900 text-slate-200'}`}>
{installBefehl}
<button
onClick={kopieren}
className="absolute top-2 right-2 p-1.5 rounded bg-slate-700 text-slate-300 hover:bg-slate-600"
title="Kopieren"
>
{kopiert ? <CheckCircle size={14} className="text-emerald-400" /> : <Copy size={14} />}
</button>
</div>
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
Eine geführte Ein-Klick-Installation (SSH) ist als Ausbaustufe geplant.
</p>
</div>
</div>
)
}
+4 -2
View File
@@ -337,7 +337,9 @@ export default function MetadataPreview() {
</div>
)}
{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 && (
<div>
<h3 className={`text-sm font-medium uppercase mb-2 ${theme === 'dark' ? 'text-slate-400' : 'text-gray-500'}`}>Genre</h3>
<div className="flex flex-wrap gap-2 transition-colors duration-300">
@@ -353,7 +355,7 @@ export default function MetadataPreview() {
</div>
)}
{result.metadata.runtime && (
{(result.metadata.runtime ?? 0) > 0 && (
<div>
<h3 className={`text-sm font-medium uppercase mb-2 ${theme === 'dark' ? 'text-slate-400' : 'text-gray-500'}`}>Laufzeit</h3>
<p className={`transition-colors duration-300 ${theme === 'dark' ? 'text-slate-300' : 'text-gray-700'}`}>{Math.floor(result.metadata.runtime / 60)}h {result.metadata.runtime % 60}m</p>
+12 -1
View File
@@ -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() {
<div className={`flex transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} border-b`}>
<TabButton icon={Disc} label="Ripping" isActive={activeTab === 'ripping'} onClick={() => setActiveTab('ripping')} darkMode={theme === 'dark'} />
<TabButton icon={Cpu} label="Verarbeitung" isActive={activeTab === 'verarbeitung'} onClick={() => setActiveTab('verarbeitung')} darkMode={theme === 'dark'} />
<TabButton icon={Cpu} label="Worker" isActive={activeTab === 'worker'} onClick={() => setActiveTab('worker')} darkMode={theme === 'dark'} />
<TabButton icon={HardDrive} label="Speicherziele" isActive={activeTab === 'speicherziele'} onClick={() => setActiveTab('speicherziele')} darkMode={theme === 'dark'} />
<TabButton icon={Globe} label="APIs" isActive={activeTab === 'apis'} onClick={() => setActiveTab('apis')} darkMode={theme === 'dark'} />
<TabButton icon={AlertCircle} label="Benachrichtigungen" isActive={activeTab === 'benachrichtigungen'} onClick={() => setActiveTab('benachrichtigungen')} darkMode={theme === 'dark'} />
@@ -258,6 +260,15 @@ export default function SettingsPage() {
</div>
</section>}
{/* Worker: Encoding-Maschinen im Netz */}
{activeTab === 'worker' && <section>
<h2 className={`text-lg font-semibold mb-4 flex items-center gap-2 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
<Cpu size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
Encoding-Worker
</h2>
<WorkerVerwaltung />
</section>}
{/* Speicherziele: Netzwerk-Mounts aus dem UI */}
{activeTab === 'speicherziele' && <section>
<h2 className={`text-lg font-semibold mb-4 flex items-center gap-2 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
+18 -6
View File
@@ -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()
+6
View File
@@ -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"]