import { useState, useEffect } from 'react' import { Disc, Cpu, Globe, Tv, CheckCircle, ArrowRight, HardDrive, AlertTriangle, KeyRound } from 'lucide-react' import { api } from '../lib/api' import { useBetrieb } from '../lib/useBetrieb' import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer' import { PRESET_KEINE, schwacheEncoderCpu } from '../lib/encoder' import { Button } from './ui/Button' import { Input, Select } from './ui/Input' interface WorkerInfo { name: string encoders: string[] online?: boolean info?: { cpu_kerne?: string; cpu_simd?: string; cpu_modell?: string } } interface GeraetInfo { id: string name: string status: string } interface PlatzInfo { name: string frei_gb: number gesamt_gb: number } const ENCODER_LABELS: Record = { 'cpu-x264': 'CPU · H.264 (x264)', 'cpu-x265': 'CPU · H.265 (x265)', 'cpu-av1': 'CPU · AV1 (SVT-AV1)', 'vaapi': 'Hardware · VAAPI (AMD/Intel)', 'nvenc': 'Hardware · NVENC (NVIDIA)', } // Unter diesem Wert lohnt der Hinweis auf die Ablage: eine einzelne Blu-ray // braucht roh rund 40 GB, eine 4K-UHD bis 100 GB. const PLATZ_WARNUNG_GB = 60 // Antwort von GET /presets — dieselbe Quelle, die auch die Einstellungen-Seite // benutzt. Bis 26.07.2026 entschied der Wizard hier selbst und mit fest // verdrahteten Namen; jetzt kommt beides aus api/presets.py, wo es Tests hat. interface PresetUebersicht { quelle: 'worker' | 'rueckfall' hardware: string[] typen: Record<'uhd' | 'bluray' | 'dvd', { empfehlung: { preset: string, grund: string, gefunden: boolean } }> } /** Was beim Abschliessen gespeichert wird — die Empfehlung der API. */ function presetsFuer(uebersicht: PresetUebersicht | null) { const nimm = (typ: 'uhd' | 'bluray' | 'dvd') => { const e = uebersicht?.typen?.[typ]?.empfehlung return e?.gefunden ? e.preset : '' } const uhd = nimm('uhd') const bluray = nimm('bluray') const dvd = nimm('dvd') // Leere Werte gar nicht schreiben: dann bleibt der Bestandswert stehen, // statt dass HandBrake später ein leeres --preset bekommt. return { ...(bluray ? { transcodePreset: bluray, transcodePresetBluray: bluray } : {}), ...(dvd ? { transcodePresetDvd: dvd } : {}), ...(uhd ? { transcodePresetUhd: uhd } : {}), } } export default function FirstRunWizard({ onDone }: { onDone: () => void }) { const [tmdbKey, setTmdbKey] = useState('') const [omdbKey, setOmdbKey] = useState('') // MakeMKV-Beta-Key: Lizenz fuer die SOFTWARE (Blu-ray), nicht der // Disc-Schluessel einer einzelnen 4K-Disc. const [makemkvKey, setMakemkvKey] = useState('') const [mediaServer, setMediaServer] = useState('jellyfin') const [transcodeEnabled, setTranscodeEnabled] = useState(true) const betrieb = useBetrieb() const [workers, setWorkers] = useState([]) const [geraete, setGeraete] = useState([]) const [plaetze, setPlaetze] = useState([]) const [presetInfo, setPresetInfo] = useState(null) const [geladen, setGeladen] = useState(false) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) const [keyWarnung, setKeyWarnung] = useState(null) // Wiederholt laden, bis Worker UND Laufwerk da sind: beim allerersten Start // läuft der Worker-Container oft noch hoch. Ohne das stand hier dauerhaft // „Noch kein Worker gemeldet" und der Nutzer wusste nicht, ob er warten soll. useEffect(() => { let laeuft = true const laden = async () => { const [caps, devs, info, pres] = await Promise.allSettled([ api.get('/capabilities'), api.get('/devices'), api.get('/system/info'), api.get('/presets'), ]) if (!laeuft) return if (caps.status === 'fulfilled') setWorkers(caps.value.data?.workers || []) if (devs.status === 'fulfilled') setGeraete(devs.value.data || []) if (info.status === 'fulfilled') setPlaetze(info.value.data?.plaetze || []) if (pres.status === 'fulfilled') setPresetInfo(pres.value.data || null) setGeladen(true) } laden() const takt = setInterval(laden, 5000) return () => { laeuft = false; clearInterval(takt) } }, []) const schwach = schwacheEncoderCpu(workers) const presets = presetsFuer(presetInfo) const encoderWorker = workers.filter(w => w.encoders.length > 0) const platz = plaetze.length > 0 ? Math.min(...plaetze.map(p => p.frei_gb)) : null const abschliessen = async () => { setSaving(true) setError(null) setKeyWarnung(null) try { const bestehende = (await api.get('/settings')).data || {} await api.post('/settings', { ...bestehende, ...(tmdbKey.trim() ? { tmdbApiKey: tmdbKey.trim() } : {}), ...(omdbKey.trim() ? { omdbApiKey: omdbKey.trim() } : {}), ...(makemkvKey.trim() ? { makemkvAppKey: makemkvKey.trim() } : {}), mediaServer, transcodeEnabled, ...presets, }) // Erst SPEICHERN, dann prüfen: /metadata/status liest die Keys aus der // Datenbank, nicht aus dem Formular. Ein falsch kopierter Key fällt so // sofort auf, statt erst beim ersten Rip als „Unknown Disc". if (tmdbKey.trim() || omdbKey.trim()) { try { const s = (await api.get('/metadata/status')).data || {} const kaputt = Object.entries(s) .filter(([, v]) => v === 'fehler') .map(([k]) => k.toUpperCase()) if (kaputt.length > 0) { setKeyWarnung( `${kaputt.join(' und ')} hat den Key nicht angenommen — vermutlich ein ` + 'Tippfehler beim Kopieren. Rippy laeuft trotzdem, die Titel heissen dann ' + 'nur wie das Disc-Label. Nachbessern: Einstellungen → APIs.' ) setSaving(false) return // nicht wegklicken, damit die Warnung gelesen wird } } catch { // Prüfung selbst kaputt → kein Grund, die Einrichtung zu blockieren } } await api.post('/setup/complete') onDone() } catch { setError('Speichern fehlgeschlagen — läuft die API? (Seite neu laden hilft meist.)') setSaving(false) } } const trotzdemWeiter = async () => { setSaving(true) try { await api.post('/setup/complete') onDone() } catch { setError('Speichern fehlgeschlagen — läuft die API?') setSaving(false) } } return (

Willkommen bei Rippy

Einmalige Einrichtung — dauert keine zwei Minuten. Du kannst nichts kaputt machen: alles ist später unter Einstellungen änderbar.

{/* ---- Was Rippy gerade sieht ---------------------------------- */}

Was Rippy gerade sieht

0} gutText={geraete.map(g => g.name).join(', ')} schlechtText="Kein optisches Laufwerk gefunden. Rippy kann dann nur komprimieren, nicht rippen. Prüfe, ob das Laufwerk angeschlossen und (bei einer VM) durchgereicht ist." /> 0} gutText={encoderWorker.map(w => { const kerne = w.info?.cpu_kerne const simd = w.info?.cpu_simd const zusatz = [kerne ? `${kerne} Kerne` : '', simd && simd !== 'unbekannt' ? simd : ''] .filter(Boolean).join(', ') return zusatz ? `${w.name} (${zusatz})` : w.name }).join(' · ')} schlechtText={betrieb.kann.externe_worker ? 'Noch kein Worker gemeldet. Beim ersten Start dauert das bis zu einer Minute — diese Anzeige aktualisiert sich selbst. Bleibt es dabei, läuft der Worker-Container nicht.' : 'Rippy meldet noch keine Encoder. Beim ersten Start dauert das bis zu einer Minute — diese Anzeige aktualisiert sich selbst.'} /> = PLATZ_WARNUNG_GB} gutText={platz === null ? '—' : `${platz.toFixed(0)} GB`} schlechtText={`Nur ${platz?.toFixed(0)} GB frei. Eine Blu-ray braucht roh rund 40 GB, eine 4K-UHD bis 100 GB. Lege die Ablage besser auf eine NAS-Freigabe (Einstellungen → Speicherziele).`} />
{/* ---- Metadaten ---------------------------------------------- */}

Metadaten-Erkennung

{/* Bewusst SICHTBAR statt type=password: Das sind kopierte Keys, keine Passwoerter — und einen Tippfehler sieht man in Punkten nicht. Geprueft wird direkt nach dem Speichern. */} setTmdbKey(e.target.value)} placeholder="Kostenlos auf themoviedb.org — hier einfügen" /> setOmdbKey(e.target.value)} placeholder="Kostenlos auf omdbapi.com" />

Ohne Keys rippt Rippy trotzdem — Discs heißen dann nur wie ihr Volume-Label. Beide Keys werden direkt nach dem Speichern geprüft.

{/* ---- MakeMKV-Beta-Key --------------------------------------- */} {/* Neu 26.07.2026, bei der Weitergabe-Prüfung aufgefallen: Der Key war in der README, in der .env und in den Einstellungen zu finden — nur NICHT hier. Ein Fremder installiert also, legt eine Blu-ray ein und bekommt irgendwann einen Fehlschlag, ohne dass ihn beim Einrichten jemand darauf hingewiesen hätte. Das ist die wahrscheinlichste Stolperstelle einer frischen Installation. */}

MakeMKV-Beta-Key

setMakemkvKey(e.target.value)} placeholder="T-… — kostenlos im MakeMKV-Forum, Thread t=1053" />

MakeMKV ist für DVDs dauerhaft kostenlos. Für Blu-ray und 4K braucht es den Beta-Key; ohne ihn läuft eine 30-Tage-Probezeit, danach bricht jeder Blu-ray-Rip ab. Der Key ist kostenlos, wechselt aber etwa monatlich — nachpflegen unter Einstellungen → System, das wirkt sofort ab dem nächsten Rip, ohne Neubau.

Nicht verwechseln mit dem Disc-Schlüssel einer einzelnen 4K-Disc — das ist eine andere Sache und steht unter Einstellungen → System.

{/* ---- Media-Server ------------------------------------------- */}

Dein Media-Server

Rippy benennt fertige Rips als „Titel (Jahr)" und legt für Jellyfin/Emby/Kodi zusätzlich movie.nfo + poster.jpg dazu.

{MEDIA_SERVER_OPTIONEN.map(o => ( ))}

{MEDIA_SERVER_OPTIONEN.find(o => o.wert === mediaServer)?.hinweis}

{/* ---- Verarbeitung ------------------------------------------- */}

Verarbeitung

{transcodeEnabled && (
{/* Der Kern dieser Seite: Die Empfehlung folgt der GEMESSENEN Rechenleistung. Vorher stand hier H.265 als Standard - auf einer CPU ohne AVX2 sind das 28-55 Stunden je 4K-Film, und genau das ist am 25.07.2026 passiert. */} {schwach ? ( <>

Rippy hat deine Rechenleistung gemessen und passt die Wahl an.

{betrieb.kann.externe_worker ? 'Keiner deiner Worker' : 'Dieser Rechner'} kann kein AVX2 — die Vektorbefehle, von denen H.265 lebt. 4K in H.265 würde hier ein bis zwei Tage pro Film {' '}dauern. Deshalb wird eingestellt:

) : presetInfo && presetInfo.hardware.length > 0 ? (

Hardware-Encoder gemeldet ({presetInfo.hardware.join(', ')}) — Rippy nimmt ihn:

) : (

Deine Worker sind schnell genug für H.265 — Rippy stellt ein:

)}
  • 4K-UHD:{' '} {presets.transcodePresetUhd === PRESET_KEINE ? 'nicht komprimieren, verlustfrei behalten (20–100 GB je Film)' : presets.transcodePresetUhd || 'unverändert (kein bekanntes Preset gemeldet)'}
  • Blu-ray:{' '} {presets.transcodePresetBluray || 'unverändert (kein bekanntes Preset gemeldet)'}
  • DVD:{' '} {presets.transcodePresetDvd || 'unverändert (kein bekanntes Preset gemeldet)'}
{presetInfo?.typen?.uhd?.empfehlung?.grund && (

Warum: {presetInfo.typen.uhd.empfehlung.grund}

)}

Alles einzeln änderbar unter Einstellungen → Verarbeitung.

)} {encoderWorker.length > 0 && (
{encoderWorker.flatMap(w => w.encoders).filter((e, i, a) => a.indexOf(e) === i).map(e => ( {ENCODER_LABELS[e] || e} ))}
)}
{keyWarnung && (

{keyWarnung}

)} {error && (

{error}

)}

Läuft komplett lokal — keine Cloud, keine Telemetrie.

) } /** Eine Zeile im „Was Rippy gerade sieht"-Kasten: gruen, wenn es passt, sonst * bernstein MIT Handlungsanweisung. Nie nur ein Kreuz ohne Erklaerung. */ function Zeile({ titel, geladen, gut, gutText, schlechtText }: { titel: string geladen: boolean gut: boolean gutText: string schlechtText: string }) { if (!geladen) { return (
{titel} — wird geprüft…
) } return (

{titel}:{' '} {gut ? gutText : nicht bereit}

{!gut &&

{schlechtText}

}
) }