feat(transcode,ui): Kompression je Disc-Typ abwaehlbar + Wizard rechnet mit der CPU
Ampel / ampel (push) Successful in 28s
Ampel / ampel (push) Successful in 28s
Loest die offene Frage aus dem Savepoint ("UHD gar nicht komprimieren?") und
nimmt dem Wizard die Falle, in die er bisher fuehrte.
## Kompression je Disc-Typ abwaehlbar
Bisher gab es nur den globalen Schalter transcodeEnabled: alles komprimieren
oder nichts. Wer 4K verlustfrei behalten und DVDs trotzdem schrumpfen wollte,
hatte keine Moeglichkeit - obwohl genau das die vernuenftige Einstellung fuer
diese Maschine ist (4K-HEVC = gemessene 28-55 h je Film ohne AVX2).
Jetzt kann das Preset eines Disc-Typs auf den Reservewert "keine" stehen, dann
bleibt die verlustfreie Datei aus dem Rip stehen. Neue reine Funktion
komprimieren_fuer(disc_type, einstellungen); der globale Schalter schlaegt
weiter alles. preset_fuer() ueberspringt den Reservewert bewusst und gibt ihn
NIE als Preset-Namen zurueck - sonst bekaeme HandBrake `--preset keine` und
wuerde scheitern. Wer ueber "Neu komprimieren" ausdruecklich doch komprimieren
will, bekommt so ein brauchbares Preset statt eines Fehlers.
Der Reservewert kollidiert mit keinem echten Namen: gegengeprueft gegen alle
90 Presets aus `HandBrakeCLI --preset-list` im Worker-Image. Bei der
Gelegenheit auch die fuenf im UI angebotenen Namen geprueft - alle echt.
## Der Wizard empfiehlt nach GEMESSENER Rechenleistung
Vorher stand H.265 als Standard drin. Auf einer CPU ohne AVX2 sind das ein bis
zwei Tage pro 4K-Film - genau der Lauf, der am 25.07.2026 abgebrochen werden
musste. Der Wizard hatte die Zahlen sogar schon vorliegen (/capabilities
meldet cpu_simd und cpu_kerne), nur benutzt hat er sie nicht.
Jetzt: schwache CPU -> 4K wird nicht komprimiert, Blu-ray/DVD gehen auf H.264
(schneller als H.265). Starke CPU oder Hardware-Encoder -> H.265 durchgehend.
Der Wizard schreibt dabei ALLE vier Preset-Felder, nicht nur das allgemeine -
vorher fiel 4K auf ein 1080p-Preset zurueck und die Aufloesung war weg.
Gewarnt wird nur, wenn es belegt ist: schwacheEncoderCpu() verlangt mindestens
einen Worker mit BEKANNTER SIMD-Stufe und keinen mit Hardware-Encoder. Ein
Windows-Worker meldet "unbekannt" (dort gibt es kein /proc/cpuinfo) - dann wird
geschwiegen statt falsch gewarnt. Auf Windows gegengeprueft: 16 Kerne und
CPU-Modell kommen korrekt durch, encoders ist ohne HandBrake leer.
## Weitere Wizard-Haerten
- Kasten "Was Rippy gerade sieht": Laufwerk, Worker (mit Kernen/SIMD), freier
Platz. Jede Zeile hat bei Problemen eine HANDLUNGSANWEISUNG statt nur eines
Kreuzes - kein Laufwerk, kein Worker und wenig Platz sind die drei Faelle, in
denen man vorher ratlos dastand.
- Laedt alle drei Quellen parallel (Promise.allSettled) und wiederholt im
5-s-Takt: beim ersten Start laeuft der Worker noch hoch, vorher stand dort
dauerhaft "Noch kein Worker gemeldet" ohne Aussicht.
- API-Keys sind SICHTBAR statt als Punkte: das sind kopierte Keys, keine
Passwoerter, und einen Tippfehler sieht man in Punkten nicht.
- Keys werden direkt nach dem Speichern geprueft (/metadata/status). Ein
falsch kopierter Key faellt sofort auf, statt erst beim ersten Rip als
"Unknown Disc" - mit der Wahl "Key korrigieren" oder "Trotzdem fertigstellen".
## Einstellungen -> Verarbeitung
Alle drei Preset-Auswahlen bekommen "Nicht komprimieren", und beim 4K-Feld
erscheint die AVX2-Warnung mit der gemessenen Zahl - aber nur, wenn die
Maschine sie wirklich braucht.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,28 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Disc, Cpu, Globe, Tv, CheckCircle, ArrowRight } from 'lucide-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[]
|
||||
last_seen?: 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> = {
|
||||
@@ -19,40 +33,122 @@ const ENCODER_LABELS: Record<string, string> = {
|
||||
'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 — abhaengig 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 fuer den Rest H.264 nehmen (deutlich schneller
|
||||
// als H.265, dafuer etwas groesser).
|
||||
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 [preset, setPreset] = useState('H.265 MKV 1080p30')
|
||||
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||||
const [geraete, setGeraete] = useState<GeraetInfo[]>([])
|
||||
const [plaetze, setPlaetze] = useState<PlatzInfo[]>([])
|
||||
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
|
||||
// laeuft 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(() => {
|
||||
api.get('/capabilities')
|
||||
.then(r => setWorkers(r.data.workers || []))
|
||||
.catch(() => setWorkers([]))
|
||||
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 ? { tmdbApiKey: tmdbKey } : {}),
|
||||
...(omdbKey ? { omdbApiKey: omdbKey } : {}),
|
||||
...(tmdbKey.trim() ? { tmdbApiKey: tmdbKey.trim() } : {}),
|
||||
...(omdbKey.trim() ? { omdbApiKey: omdbKey.trim() } : {}),
|
||||
mediaServer,
|
||||
transcodeEnabled,
|
||||
transcodePreset: preset,
|
||||
...presets,
|
||||
})
|
||||
// Erst SPEICHERN, dann pruefen: /metadata/status liest die Keys aus der
|
||||
// Datenbank, nicht aus dem Formular. Ein falsch kopierter Key faellt 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 {
|
||||
// Pruefung 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?')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
@@ -65,25 +161,65 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
||||
<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. Alles ist später unter Einstellungen änderbar.</p>
|
||||
<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">
|
||||
{/* Metadaten-APIs */}
|
||||
{/* ---- 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
|
||||
titel="Encoding-Worker"
|
||||
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="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."
|
||||
/>
|
||||
<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
|
||||
type="password"
|
||||
label="TMDB API-Key (empfohlen)"
|
||||
value={tmdbKey}
|
||||
onChange={e => setTmdbKey(e.target.value)}
|
||||
placeholder="Kostenlos auf themoviedb.org"
|
||||
placeholder="Kostenlos auf themoviedb.org — hier einfügen"
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
label="OMDb API-Key (optional, zweite Quelle)"
|
||||
value={omdbKey}
|
||||
onChange={e => setOmdbKey(e.target.value)}
|
||||
@@ -91,11 +227,12 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
||||
/>
|
||||
<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>
|
||||
|
||||
{/* Media-Server */}
|
||||
{/* ---- 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
|
||||
@@ -126,48 +263,104 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Verarbeitung + erkannte Hardware */}
|
||||
{/* ---- 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>
|
||||
|
||||
<div className="rounded-xl p-4 mb-3 bg-slate-50 dark:bg-slate-950/80 border border-slate-200 dark:border-slate-800">
|
||||
<p className="text-sm font-medium mb-2 text-slate-700 dark:text-slate-300">Erkannte Encoder in deinem System:</p>
|
||||
{workers.length === 0 ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">Noch kein Worker gemeldet — startet gerade?</p>
|
||||
) : (
|
||||
workers.map(w => (
|
||||
<div key={w.name} className="mb-1">
|
||||
<span className="text-xs font-mono text-slate-500 dark:text-slate-400">{w.name}: </span>
|
||||
{w.encoders.map(e => (
|
||||
<span key={e} className={`inline-block text-xs px-2 py-0.5 rounded font-medium mr-1 ${
|
||||
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>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="flex items-center 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 accent-amber-500 rounded" />
|
||||
Nach dem Rip auf Arbeitsgröße komprimieren (empfohlen — sonst ~40 GB pro Blu-ray)
|
||||
<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 && (
|
||||
<Select value={preset} onChange={e => setPreset(e.target.value)}>
|
||||
<option value="H.265 MKV 1080p30">H.265 — klein & modern (Standard)</option>
|
||||
<option value="HQ 1080p30 Surround">H.264 — schneller auf schwacher CPU, etwas größer</option>
|
||||
</Select>
|
||||
<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">
|
||||
Keiner deiner Worker kann 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>
|
||||
</>
|
||||
) : (
|
||||
<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 (20–100 GB je Film)'
|
||||
: presets.transcodePresetUhd}
|
||||
</li>
|
||||
<li><strong>Blu-ray:</strong> {presets.transcodePresetBluray}</li>
|
||||
<li><strong>DVD:</strong> {presets.transcodePresetDvd}</li>
|
||||
</ul>
|
||||
<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>
|
||||
|
||||
{error && <p className="text-sm text-rose-500">{error}</p>}
|
||||
{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"
|
||||
@@ -187,3 +380,34 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,3 +22,34 @@ export function simdWarnung(simd?: string): string | null {
|
||||
+ 'CPU-Typ auf "host" zu stellen; sonst besser einen Worker mit '
|
||||
+ 'Hardware-Encoder wählen.'
|
||||
}
|
||||
|
||||
// Reservierter Wert im Preset-Feld: „diesen Disc-Typ NICHT komprimieren".
|
||||
// Muss mit ripping.PRESET_KEINE im Worker übereinstimmen — es gibt kein
|
||||
// geteiltes Paket zwischen UI und Worker, deshalb steht der Wert hier nochmal.
|
||||
export const PRESET_KEINE = 'keine'
|
||||
|
||||
interface WorkerFuerBewertung {
|
||||
encoders?: string[]
|
||||
info?: { cpu_simd?: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* Kann von den gemeldeten Workern KEINER 4K in Software brauchbar schnell
|
||||
* encodieren? Wahr nur, wenn das auch belegt ist: mindestens ein Worker hat
|
||||
* eine bekannte SIMD-Stufe, und keiner erreicht AVX2 — und keiner hat einen
|
||||
* Hardware-Encoder, der die Frage sowieso erledigt.
|
||||
*
|
||||
* Bei unbekannter Stufe (z. B. Windows-Worker, dort gibt es kein
|
||||
* /proc/cpuinfo) wird NICHT gewarnt. Lieber schweigen als falsch warnen.
|
||||
*/
|
||||
export function schwacheEncoderCpu(workers?: WorkerFuerBewertung[]): boolean {
|
||||
const liste = workers || []
|
||||
if (liste.length === 0) return false
|
||||
const hardware = liste.some(w => (w.encoders || []).some(e => !e.startsWith('cpu')))
|
||||
if (hardware) return false
|
||||
const bekannte = liste
|
||||
.map(w => w.info?.cpu_simd)
|
||||
.filter((s): s is string => !!s && s !== 'unbekannt')
|
||||
if (bekannte.length === 0) return false
|
||||
return !bekannte.some(s => SIMD_SCHNELL.includes(s))
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Button } from '../components/ui/Button'
|
||||
import { Input, Select, Toggle } from '../components/ui/Input'
|
||||
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
|
||||
import { ENCODER_BADGES } from '../lib/design'
|
||||
import { simdWarnung } from '../lib/encoder'
|
||||
import { PRESET_KEINE, schwacheEncoderCpu, simdWarnung } from '../lib/encoder'
|
||||
|
||||
interface SettingsState {
|
||||
tmdbApiKey: string
|
||||
@@ -617,17 +617,32 @@ export default function SettingsPage() {
|
||||
value={settings.transcodePresetUhd}
|
||||
onChange={(e) => handleChange('transcodePresetUhd', e.target.value)}
|
||||
>
|
||||
<option value="H.265 MKV 2160p60 4K">H.265 MKV 2160p60 4K — 4K erhalten (empfohlen)</option>
|
||||
<option value={PRESET_KEINE}>Nicht komprimieren — 4K verlustfrei behalten</option>
|
||||
<option value="H.265 MKV 2160p60 4K">H.265 MKV 2160p60 4K — 4K erhalten</option>
|
||||
<option value="HQ 2160p60 4K HEVC Surround">HQ 2160p60 4K HEVC Surround — 4K, mehr Qualität</option>
|
||||
<option value="Super HQ 2160p60 4K HEVC Surround">Super HQ 2160p60 4K HEVC Surround — 4K, größer</option>
|
||||
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 — bewusst auf 1080p verkleinern</option>
|
||||
</Select>
|
||||
{/* Die Warnung, die den 50-Stunden-Lauf verhindert haette. Sie
|
||||
steht nur da, wenn die Maschine sie WIRKLICH braucht -
|
||||
gemessen, nicht vermutet (worker/caps.py meldet cpu_simd). */}
|
||||
{settings.transcodePresetUhd !== PRESET_KEINE
|
||||
&& settings.transcodePresetUhd.includes('2160')
|
||||
&& schwacheEncoderCpu(workers) && (
|
||||
<p className="text-xs -mt-1 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-700 dark:text-amber-300">
|
||||
⚠️ Keiner deiner Worker kann AVX2 — 4K in H.265 dauert damit
|
||||
<strong> ein bis zwei Tage pro Film</strong> (gemessen, nicht geschätzt).
|
||||
Besser „Nicht komprimieren" wählen und die verlustfreie Datei behalten,
|
||||
oder einen Worker mit Hardware-Encoder anschließen.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Select
|
||||
label="Preset für Blu-ray"
|
||||
value={settings.transcodePresetBluray}
|
||||
onChange={(e) => handleChange('transcodePresetBluray', e.target.value)}
|
||||
>
|
||||
<option value={PRESET_KEINE}>Nicht komprimieren — verlustfrei behalten</option>
|
||||
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 — klein & modern (empfohlen)</option>
|
||||
<option value="HQ 1080p30 Surround">HQ 1080p30 Surround — H.264, maximale Kompatibilität</option>
|
||||
<option value="Super HQ 1080p30 Surround">Super HQ 1080p30 Surround — größer, beste Qualität</option>
|
||||
@@ -638,11 +653,18 @@ export default function SettingsPage() {
|
||||
value={settings.transcodePresetDvd}
|
||||
onChange={(e) => handleChange('transcodePresetDvd', e.target.value)}
|
||||
>
|
||||
<option value={PRESET_KEINE}>Nicht komprimieren — verlustfrei behalten</option>
|
||||
<option value="H.265 MKV 576p25">H.265 MKV 576p25 — PAL-DVD, native Auflösung (empfohlen)</option>
|
||||
<option value="H.265 MKV 480p30">H.265 MKV 480p30 — NTSC-DVD, native Auflösung</option>
|
||||
<option value="HQ 576p25 Surround">HQ 576p25 Surround — H.264, maximale Kompatibilität</option>
|
||||
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 — hochskalieren (bringt nichts)</option>
|
||||
</Select>
|
||||
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
„Nicht komprimieren" heißt: Der Rip bleibt so, wie MakeMKV ihn
|
||||
gesichert hat — beste Qualität, aber 20–100 GB je Film. Sinnvoll für
|
||||
4K, wenn die Ablage groß genug ist.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||||
|
||||
Reference in New Issue
Block a user