Files
rippy/docker/ui/src/components/FirstRunWizard.tsx
T
HitonabiandClaude Opus 5 8e65411189
Ampel / ampel (push) Failing after 54s
fix(windows): die Datenbank lag AUSSERHALB der Installation — daher fehlte
der Ersteinrichtungs-Assistent

Der Befund des Commanders: "Ausserdem fehlt der 1st run wizzard wenn man es
installiert." Meine erste Reparatur (App.tsx las einen gescheiterten Abruf
als "erledigt") war richtig, aber nicht die Ursache. Nachgemessen:

    Installation      %LOCALAPPDATA%\Rippy      wird deinstalliert
    Datenbank         %ProgramData%\Rippy       BLEIBT LIEGEN

Eine "frische" Installation erbte damit die alte Datenbank samt
`setup.done = true` -- der Assistent erschien nie wieder. Gegengeprueft:
nach dem Aufraeumen meldet /api/setup wieder {"done": false}, und das
Fenster zeigt "Willkommen bei Rippy".

Der Grund fuer den alten Ort stand im Docstring: "Ein Programmordner ist
unter Windows fuer einen DIENST nicht zuverlaessig beschreibbar." Das stimmte
-- fuer den Windows-Dienst, den es nie gab. Rippy laeuft als der angemeldete
Benutzer (Autostart unter HKCU). Jetzt liegt alles, was Rippy gehoert, in
EINEM Ordner: Programm, UI, Werkzeuge, Protokoll, WebView2-Zwischenspeicher
und die Datenbank.

Dazu:

* `datenbank_umziehen()` holt eine vorhandene Datenbank vom alten Ort ab --
  wer Rippy schon benutzt hat, behaelt Jobs und Einstellungen. Gibt es am
  neuen Ort schon eine, bleibt sie unangetastet: Die BENUTZTE gewinnt.
* `deinstallieren()` raeumt den alten Ort mit weg, und `alte_orte()` fasst
  nur einen Ordner an, der wirklich "Rippy" heisst.
* Der Ersteinrichtungs-Assistent spricht nicht mehr von "Encoding-Worker",
  wenn es keine gibt -- dort steht jetzt "Kompression". Gegengeprueft: kein
  "docker", kein "Container", kein "Worker" mehr im Text.

Ampel lokal: 709 gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:46:02 +02:00

487 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, string> = {
'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<WorkerInfo[]>([])
const [geraete, setGeraete] = useState<GeraetInfo[]>([])
const [plaetze, setPlaetze] = useState<PlatzInfo[]>([])
const [presetInfo, setPresetInfo] = useState<PresetUebersicht | null>(null)
const [geladen, setGeladen] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [keyWarnung, setKeyWarnung] = useState<string | null>(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 (
<div className="min-h-screen flex items-center justify-center p-6 bg-slate-100 dark:bg-slate-950">
<div className="w-full max-w-2xl rounded-2xl shadow-2xl overflow-hidden bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800">
<div className="bg-gradient-to-r from-amber-500 via-indigo-600 to-purple-700 p-8 text-white">
<div className="flex items-center gap-3 mb-2">
<Disc size={32} className="animate-disc-spin" />
<h1 className="text-2xl font-bold">Willkommen bei Rippy</h1>
</div>
<p className="opacity-90">
Einmalige Einrichtung dauert keine zwei Minuten. Du kannst nichts kaputt
machen: alles ist später unter Einstellungen änderbar.
</p>
</div>
<div className="p-8 space-y-8">
{/* ---- Was Rippy gerade sieht ---------------------------------- */}
<section>
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
<HardDrive size={18} className="text-amber-500" /> Was Rippy gerade sieht
</h2>
<div className="rounded-xl border border-slate-200 dark:border-slate-800 divide-y divide-slate-200 dark:divide-slate-800 overflow-hidden">
<Zeile
titel="Laufwerk"
geladen={geladen}
gut={geraete.length > 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."
/>
<Zeile
/* Auf einem Windows-PC gibt es keinen "Encoding-Worker" und
keinen Worker-Container -- Rippy komprimiert selbst. Der
Commander am 28.08.2026: "Das gilt fuer die ganze
standalone version fuer Windows." */
titel={betrieb.kann.externe_worker ? 'Encoding-Worker' : 'Kompression'}
geladen={geladen}
gut={encoderWorker.length > 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.'}
/>
<Zeile
titel="Freier Platz"
geladen={geladen}
gut={platz === null || platz >= 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).`}
/>
</div>
</section>
{/* ---- Metadaten ---------------------------------------------- */}
<section>
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
<Globe size={18} className="text-amber-500" /> Metadaten-Erkennung
</h2>
<div className="space-y-3">
{/* 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. */}
<Input
label="TMDB API-Key (empfohlen)"
value={tmdbKey}
onChange={e => setTmdbKey(e.target.value)}
placeholder="Kostenlos auf themoviedb.org — hier einfügen"
/>
<Input
label="OMDb API-Key (optional, zweite Quelle)"
value={omdbKey}
onChange={e => setOmdbKey(e.target.value)}
placeholder="Kostenlos auf omdbapi.com"
/>
<p className="text-xs text-slate-500 dark:text-slate-400">
Ohne Keys rippt Rippy trotzdem Discs heißen dann nur wie ihr Volume-Label.
Beide Keys werden direkt nach dem Speichern geprüft.
</p>
</div>
</section>
{/* ---- 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.
*/}
<section>
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
<KeyRound size={18} className="text-amber-500" /> MakeMKV-Beta-Key
</h2>
<div className="space-y-3">
<Input
label="Beta-Key (für Blu-ray; DVDs gehen ohne)"
value={makemkvKey}
onChange={e => setMakemkvKey(e.target.value)}
placeholder="T-… — kostenlos im MakeMKV-Forum, Thread t=1053"
/>
<p className="text-xs text-slate-500 dark:text-slate-400">
MakeMKV ist für <strong>DVDs dauerhaft kostenlos</strong>. Für
<strong> Blu-ray und 4K</strong> 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.
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
Nicht verwechseln mit dem <strong>Disc-Schlüssel</strong> einer einzelnen
4K-Disc das ist eine andere Sache und steht unter Einstellungen System.
</p>
</div>
</section>
{/* ---- Media-Server ------------------------------------------- */}
<section>
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
<Tv size={18} className="text-amber-500" /> Dein Media-Server
</h2>
<p className="text-sm mb-3 text-slate-500 dark:text-slate-400">
Rippy benennt fertige Rips als Titel (Jahr)" und legt für Jellyfin/Emby/Kodi
zusätzlich movie.nfo + poster.jpg dazu.
</p>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
{MEDIA_SERVER_OPTIONEN.map(o => (
<button
key={o.wert}
type="button"
onClick={() => setMediaServer(o.wert)}
title={o.hinweis}
className={`px-3 py-2 rounded-xl text-sm font-medium border transition-all ${
mediaServer === o.wert
? 'bg-amber-500/20 border-amber-500 text-amber-600 dark:text-amber-300 font-semibold'
: 'bg-slate-50 dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 hover:border-slate-300 dark:hover:border-slate-600'
}`}
>
{o.label}
</button>
))}
</div>
<p className="text-xs mt-2 text-slate-500 dark:text-slate-400">
{MEDIA_SERVER_OPTIONEN.find(o => o.wert === mediaServer)?.hinweis}
</p>
</section>
{/* ---- Verarbeitung ------------------------------------------- */}
<section>
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
<Cpu size={18} className="text-amber-500" /> Verarbeitung
</h2>
<label className="flex items-start gap-3 mb-3 text-sm text-slate-700 dark:text-slate-300">
<input
type="checkbox"
checked={transcodeEnabled}
onChange={e => setTranscodeEnabled(e.target.checked)}
className="w-4 h-4 mt-0.5 accent-amber-500 rounded"
/>
<span>
Nach dem Rip auf Arbeitsgröße komprimieren
<span className="block text-xs text-slate-500 dark:text-slate-400">
Empfohlen — ohne Kompression braucht jede Blu-ray rund 40 GB.
</span>
</span>
</label>
{transcodeEnabled && (
<div className={`rounded-xl p-4 border ${
schwach
? 'bg-amber-500/10 border-amber-500/30'
: 'bg-slate-50 dark:bg-slate-950/80 border-slate-200 dark:border-slate-800'
}`}>
{/* 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 ? (
<>
<p className="text-sm font-medium flex items-start gap-2 text-amber-700 dark:text-amber-300">
<AlertTriangle size={16} className="mt-0.5 flex-shrink-0" />
<span>Rippy hat deine Rechenleistung gemessen und passt die Wahl an.</span>
</p>
<p className="text-xs mt-2 text-amber-700/90 dark:text-amber-300/90">
{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 <strong>ein bis zwei Tage pro Film</strong>
{' '}dauern. Deshalb wird eingestellt:
</p>
</>
) : presetInfo && presetInfo.hardware.length > 0 ? (
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
Hardware-Encoder gemeldet ({presetInfo.hardware.join(', ')}) — Rippy nimmt ihn:
</p>
) : (
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
Deine Worker sind schnell genug für H.265 — Rippy stellt ein:
</p>
)}
<ul className="mt-2 space-y-1 text-xs text-slate-600 dark:text-slate-400">
<li>
<strong>4K-UHD:</strong>{' '}
{presets.transcodePresetUhd === PRESET_KEINE
? 'nicht komprimieren, verlustfrei behalten (20100 GB je Film)'
: presets.transcodePresetUhd || 'unverändert (kein bekanntes Preset gemeldet)'}
</li>
<li>
<strong>Blu-ray:</strong>{' '}
{presets.transcodePresetBluray || 'unverändert (kein bekanntes Preset gemeldet)'}
</li>
<li>
<strong>DVD:</strong>{' '}
{presets.transcodePresetDvd || 'unverändert (kein bekanntes Preset gemeldet)'}
</li>
</ul>
{presetInfo?.typen?.uhd?.empfehlung?.grund && (
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
Warum: {presetInfo.typen.uhd.empfehlung.grund}
</p>
)}
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
Alles einzeln änderbar unter Einstellungen → Verarbeitung.
</p>
</div>
)}
{encoderWorker.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1">
{encoderWorker.flatMap(w => w.encoders).filter((e, i, a) => a.indexOf(e) === i).map(e => (
<span key={e} className={`text-xs px-2 py-0.5 rounded font-medium ${
e.startsWith('cpu')
? 'bg-slate-200 dark:bg-slate-800 text-slate-600 dark:text-slate-300'
: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'
}`}>
{ENCODER_LABELS[e] || e}
</span>
))}
</div>
)}
</section>
{keyWarnung && (
<div className="p-4 rounded-xl bg-amber-500/10 border border-amber-500/30">
<p className="text-sm text-amber-700 dark:text-amber-300">{keyWarnung}</p>
<div className="flex gap-2 mt-3">
<Button variant="secondary" size="sm" onClick={() => setKeyWarnung(null)}>
Key korrigieren
</Button>
<Button variant="secondary" size="sm" onClick={trotzdemWeiter} disabled={saving}>
Trotzdem fertigstellen
</Button>
</div>
</div>
)}
{error && (
<p className="text-sm p-3 rounded-lg bg-rose-500/10 border border-rose-500/30 text-rose-600 dark:text-rose-400">
{error}
</p>
)}
<Button
variant="amber"
size="lg"
onClick={abschliessen}
disabled={saving}
className="w-full"
>
{saving ? 'Speichern…' : <>Los geht's <ArrowRight size={18} /></>}
</Button>
<p className="text-xs text-center flex items-center justify-center gap-1 text-slate-500 dark:text-slate-400">
<CheckCircle size={12} /> Läuft komplett lokal — keine Cloud, keine Telemetrie.
</p>
</div>
</div>
</div>
)
}
/** 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 (
<div className="flex items-center gap-3 p-3">
<span className="w-2 h-2 rounded-full bg-slate-300 dark:bg-slate-600 animate-pulse flex-shrink-0" />
<span className="text-sm text-slate-500 dark:text-slate-400">{titel} wird geprüft</span>
</div>
)
}
return (
<div className="flex items-start gap-3 p-3">
<span className={`w-2 h-2 mt-1.5 rounded-full flex-shrink-0 ${gut ? 'bg-emerald-500' : 'bg-amber-500'}`} />
<div className="min-w-0">
<p className="text-sm text-slate-700 dark:text-slate-300">
<span className="font-medium">{titel}:</span>{' '}
{gut ? gutText : <span className="text-amber-600 dark:text-amber-400">nicht bereit</span>}
</p>
{!gut && <p className="text-xs mt-0.5 text-amber-700/90 dark:text-amber-300/90">{schlechtText}</p>}
</div>
</div>
)
}