import { useState, useEffect } from 'react' import { Disc, Cpu, Globe, Tv, CheckCircle, ArrowRight, HardDrive, AlertTriangle } from 'lucide-react' import { api } from '../lib/api' 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 /** Was beim Abschliessen gespeichert wird — abhängig davon, was die Maschine kann. */ function presetsFuer(schwach: boolean) { if (schwach) { // Gemessen auf einer CPU ohne AVX2: 4K-HEVC = 28-55 h pro Film. Also 4K // verlustfrei behalten und für den Rest H.264 nehmen (deutlich schneller // als H.265, dafür etwas größer). return { transcodePreset: 'HQ 1080p30 Surround', transcodePresetBluray: 'HQ 1080p30 Surround', transcodePresetDvd: 'HQ 576p25 Surround', transcodePresetUhd: PRESET_KEINE, } } return { transcodePreset: 'H.265 MKV 1080p30', transcodePresetBluray: 'H.265 MKV 1080p30', transcodePresetDvd: 'H.265 MKV 576p25', transcodePresetUhd: 'H.265 MKV 2160p60 4K', } } export default function FirstRunWizard({ onDone }: { onDone: () => void }) { const [tmdbKey, setTmdbKey] = useState('') const [omdbKey, setOmdbKey] = useState('') const [mediaServer, setMediaServer] = useState('jellyfin') const [transcodeEnabled, setTranscodeEnabled] = useState(true) const [workers, setWorkers] = useState([]) const [geraete, setGeraete] = useState([]) const [plaetze, setPlaetze] = useState([]) 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] = await Promise.allSettled([ api.get('/capabilities'), api.get('/devices'), api.get('/system/info'), ]) 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 || []) setGeladen(true) } laden() const takt = setInterval(laden, 5000) return () => { laeuft = false; clearInterval(takt) } }, []) const schwach = schwacheEncoderCpu(workers) const presets = presetsFuer(schwach) 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() } : {}), 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="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." /> = 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.

{/* ---- 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.

Keiner deiner Worker kann 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:

) : (

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}
  • Blu-ray: {presets.transcodePresetBluray}
  • DVD: {presets.transcodePresetDvd}

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}

}
) }