feat(transcode,ui): Kompression je Disc-Typ abwaehlbar + Wizard rechnet mit der CPU
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:
Hitonabi
2026-07-25 21:48:31 +02:00
parent 11597eb00c
commit 6a17af2118
6 changed files with 411 additions and 51 deletions
+265 -41
View File
@@ -1,14 +1,28 @@
import { useState, useEffect } from 'react' 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 { api } from '../lib/api'
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer' import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
import { PRESET_KEINE, schwacheEncoderCpu } from '../lib/encoder'
import { Button } from './ui/Button' import { Button } from './ui/Button'
import { Input, Select } from './ui/Input' import { Input, Select } from './ui/Input'
interface WorkerInfo { interface WorkerInfo {
name: string name: string
encoders: 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> = { const ENCODER_LABELS: Record<string, string> = {
@@ -19,40 +33,122 @@ const ENCODER_LABELS: Record<string, string> = {
'nvenc': 'Hardware · NVENC (NVIDIA)', '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 }) { export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
const [tmdbKey, setTmdbKey] = useState('') const [tmdbKey, setTmdbKey] = useState('')
const [omdbKey, setOmdbKey] = useState('') const [omdbKey, setOmdbKey] = useState('')
const [mediaServer, setMediaServer] = useState('jellyfin') const [mediaServer, setMediaServer] = useState('jellyfin')
const [transcodeEnabled, setTranscodeEnabled] = useState(true) const [transcodeEnabled, setTranscodeEnabled] = useState(true)
const [preset, setPreset] = useState('H.265 MKV 1080p30')
const [workers, setWorkers] = useState<WorkerInfo[]>([]) 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 [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null) 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(() => { useEffect(() => {
api.get('/capabilities') let laeuft = true
.then(r => setWorkers(r.data.workers || [])) const laden = async () => {
.catch(() => setWorkers([])) 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 () => { const abschliessen = async () => {
setSaving(true) setSaving(true)
setError(null) setError(null)
setKeyWarnung(null)
try { try {
const bestehende = (await api.get('/settings')).data || {} const bestehende = (await api.get('/settings')).data || {}
await api.post('/settings', { await api.post('/settings', {
...bestehende, ...bestehende,
...(tmdbKey ? { tmdbApiKey: tmdbKey } : {}), ...(tmdbKey.trim() ? { tmdbApiKey: tmdbKey.trim() } : {}),
...(omdbKey ? { omdbApiKey: omdbKey } : {}), ...(omdbKey.trim() ? { omdbApiKey: omdbKey.trim() } : {}),
mediaServer, mediaServer,
transcodeEnabled, 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') await api.post('/setup/complete')
onDone() onDone()
} catch { } catch {
setError('Speichern fehlgeschlagen — läuft die API?') setError('Speichern fehlgeschlagen — läuft die API?')
} finally {
setSaving(false) setSaving(false)
} }
} }
@@ -65,25 +161,65 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
<Disc size={32} className="animate-disc-spin" /> <Disc size={32} className="animate-disc-spin" />
<h1 className="text-2xl font-bold">Willkommen bei Rippy</h1> <h1 className="text-2xl font-bold">Willkommen bei Rippy</h1>
</div> </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>
<div className="p-8 space-y-8"> <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> <section>
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100"> <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 <Globe size={18} className="text-amber-500" /> Metadaten-Erkennung
</h2> </h2>
<div className="space-y-3"> <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 <Input
type="password"
label="TMDB API-Key (empfohlen)" label="TMDB API-Key (empfohlen)"
value={tmdbKey} value={tmdbKey}
onChange={e => setTmdbKey(e.target.value)} onChange={e => setTmdbKey(e.target.value)}
placeholder="Kostenlos auf themoviedb.org" placeholder="Kostenlos auf themoviedb.org — hier einfügen"
/> />
<Input <Input
type="password"
label="OMDb API-Key (optional, zweite Quelle)" label="OMDb API-Key (optional, zweite Quelle)"
value={omdbKey} value={omdbKey}
onChange={e => setOmdbKey(e.target.value)} 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"> <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. Ohne Keys rippt Rippy trotzdem Discs heißen dann nur wie ihr Volume-Label.
Beide Keys werden direkt nach dem Speichern geprüft.
</p> </p>
</div> </div>
</section> </section>
{/* Media-Server */} {/* ---- Media-Server ------------------------------------------- */}
<section> <section>
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100"> <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 <Tv size={18} className="text-amber-500" /> Dein Media-Server
@@ -126,22 +263,74 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
</p> </p>
</section> </section>
{/* Verarbeitung + erkannte Hardware */} {/* ---- Verarbeitung ------------------------------------------- */}
<section> <section>
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100"> <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 <Cpu size={18} className="text-amber-500" /> Verarbeitung
</h2> </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"> <label className="flex items-start gap-3 mb-3 text-sm text-slate-700 dark:text-slate-300">
<p className="text-sm font-medium mb-2 text-slate-700 dark:text-slate-300">Erkannte Encoder in deinem System:</p> <input
{workers.length === 0 ? ( type="checkbox"
<p className="text-sm text-slate-500 dark:text-slate-400">Noch kein Worker gemeldet — startet gerade?</p> 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">
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>
</>
) : ( ) : (
workers.map(w => ( <p className="text-sm font-medium text-slate-700 dark:text-slate-300">
<div key={w.name} className="mb-1"> Deine Worker sind schnell genug für H.265 — Rippy stellt ein:
<span className="text-xs font-mono text-slate-500 dark:text-slate-400">{w.name}: </span> </p>
{w.encoders.map(e => ( )}
<span key={e} className={`inline-block text-xs px-2 py-0.5 rounded font-medium mr-1 ${ <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}
</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') e.startsWith('cpu')
? 'bg-slate-200 dark:bg-slate-800 text-slate-600 dark:text-slate-300' ? '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' : 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'
@@ -150,24 +339,28 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
</span> </span>
))} ))}
</div> </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>
{transcodeEnabled && (
<Select value={preset} onChange={e => setPreset(e.target.value)}>
<option value="H.265 MKV 1080p30">H.265 — klein &amp; modern (Standard)</option>
<option value="HQ 1080p30 Surround">H.264 — schneller auf schwacher CPU, etwas größer</option>
</Select>
)} )}
</section> </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 <Button
variant="amber" variant="amber"
@@ -187,3 +380,34 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
</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>
)
}
+31
View File
@@ -22,3 +22,34 @@ export function simdWarnung(simd?: string): string | null {
+ 'CPU-Typ auf "host" zu stellen; sonst besser einen Worker mit ' + 'CPU-Typ auf "host" zu stellen; sonst besser einen Worker mit '
+ 'Hardware-Encoder wählen.' + '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))
}
+24 -2
View File
@@ -10,7 +10,7 @@ import { Button } from '../components/ui/Button'
import { Input, Select, Toggle } from '../components/ui/Input' import { Input, Select, Toggle } from '../components/ui/Input'
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer' import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
import { ENCODER_BADGES } from '../lib/design' import { ENCODER_BADGES } from '../lib/design'
import { simdWarnung } from '../lib/encoder' import { PRESET_KEINE, schwacheEncoderCpu, simdWarnung } from '../lib/encoder'
interface SettingsState { interface SettingsState {
tmdbApiKey: string tmdbApiKey: string
@@ -617,17 +617,32 @@ export default function SettingsPage() {
value={settings.transcodePresetUhd} value={settings.transcodePresetUhd}
onChange={(e) => handleChange('transcodePresetUhd', e.target.value)} 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="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="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> <option value="H.265 MKV 1080p30">H.265 MKV 1080p30 bewusst auf 1080p verkleinern</option>
</Select> </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 <Select
label="Preset für Blu-ray" label="Preset für Blu-ray"
value={settings.transcodePresetBluray} value={settings.transcodePresetBluray}
onChange={(e) => handleChange('transcodePresetBluray', e.target.value)} 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 &amp; modern (empfohlen)</option> <option value="H.265 MKV 1080p30">H.265 MKV 1080p30 — klein &amp; modern (empfohlen)</option>
<option value="HQ 1080p30 Surround">HQ 1080p30 Surround — H.264, maximale Kompatibilität</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> <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} value={settings.transcodePresetDvd}
onChange={(e) => handleChange('transcodePresetDvd', e.target.value)} 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 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="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="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> <option value="H.265 MKV 1080p30">H.265 MKV 1080p30 — hochskalieren (bringt nichts)</option>
</Select> </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 20100 GB je Film. Sinnvoll für
4K, wenn die Ablage groß genug ist.
</p>
</div> </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"> <div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
+36 -1
View File
@@ -266,6 +266,34 @@ PRESET_SCHLUESSEL = {
} }
# Reservierter Wert im Preset-Feld eines Disc-Typs: „diesen Typ NICHT
# komprimieren". Kein HandBrake-Preset heißt so (gegengeprüft gegen alle 90 aus
# --preset-list), deshalb kann er nicht mit einem echten Namen kollidieren.
#
# Warum es das gibt (Befund 25.07.2026): Ein 4K-HEVC-Encode brauchte auf der
# Rippy-VM gemessene 28-55 Stunden, weil die CPU kein AVX2 hat. Bis hierher gab
# es nur einen globalen Schalter — entweder ALLES komprimieren oder nichts. Wer
# 4K verlustfrei behalten und DVDs trotzdem schrumpfen wollte, hatte keine
# Möglichkeit. Genau die ist das hier.
PRESET_KEINE = "keine"
def komprimieren_fuer(disc_type: str, einstellungen: dict) -> bool:
"""Soll dieser Disc-Typ nach dem Rip komprimiert werden? (pure, testbar)
Der globale Schalter `transcodeEnabled` schlägt alles. Ist er an, kann ein
einzelner Disc-Typ abgewählt werden, indem sein Preset auf PRESET_KEINE
steht — dann bleibt die verlustfreie Datei aus dem Rip stehen.
"""
einstellungen = einstellungen or {}
if not einstellungen.get("transcodeEnabled", True):
return False
schluessel = PRESET_SCHLUESSEL.get(disc_type or "")
if schluessel and (einstellungen.get(schluessel) or "").strip() == PRESET_KEINE:
return False
return True
def preset_fuer(disc_type: str, einstellungen: dict) -> str: def preset_fuer(disc_type: str, einstellungen: dict) -> str:
"""Welches HandBrake-Preset gilt für diesen Disc-Typ? (pure, testbar) """Welches HandBrake-Preset gilt für diesen Disc-Typ? (pure, testbar)
@@ -273,12 +301,19 @@ def preset_fuer(disc_type: str, einstellungen: dict) -> str:
DEFAULT_HB_PRESET. Diese Kette hält Bestandsinstallationen am Leben, in DEFAULT_HB_PRESET. Diese Kette hält Bestandsinstallationen am Leben, in
denen nur `transcodePreset` gesetzt ist: solange der Nutzer die neuen denen nur `transcodePreset` gesetzt ist: solange der Nutzer die neuen
Felder nicht speichert, ändert sich sein Verhalten nicht. Felder nicht speichert, ändert sich sein Verhalten nicht.
PRESET_KEINE gilt hier absichtlich als „nicht gesetzt" und wird
übersprungen: Diese Funktion darf NIE den Reservewert als Preset-Namen
zurückgeben, sonst bekäme HandBrake `--preset keine` und scheiterte. Ob
überhaupt komprimiert wird, entscheidet komprimieren_fuer(). Wer über
„Neu komprimieren" ausdrücklich doch komprimieren will, bekommt so ein
brauchbares Preset statt eines Fehlers.
""" """
einstellungen = einstellungen or {} einstellungen = einstellungen or {}
schluessel = PRESET_SCHLUESSEL.get(disc_type or "") schluessel = PRESET_SCHLUESSEL.get(disc_type or "")
if schluessel: if schluessel:
eigen = (einstellungen.get(schluessel) or "").strip() eigen = (einstellungen.get(schluessel) or "").strip()
if eigen: if eigen and eigen != PRESET_KEINE:
return eigen return eigen
return (einstellungen.get("transcodePreset") or "").strip() or DEFAULT_HB_PRESET return (einstellungen.get("transcodePreset") or "").strip() or DEFAULT_HB_PRESET
+4 -1
View File
@@ -40,6 +40,7 @@ except ImportError: # Windows: kein fcntl
from ripping import ( from ripping import (
RIP_OUTPUT_DIR, RIP_OUTPUT_DIR,
RipAbbruch, RipAbbruch,
komprimieren_fuer,
lies_datei_dauer, lies_datei_dauer,
lies_titel_info, lies_titel_info,
preset_fuer, preset_fuer,
@@ -505,7 +506,9 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
einstellungen = db.get_settings() einstellungen = db.get_settings()
ist_video = disc_type in ("dvd", "bluray", "uhd") ist_video = disc_type in ("dvd", "bluray", "uhd")
transcode_an = ist_video and einstellungen.get("transcodeEnabled", True) # Je Disc-Typ abwählbar (siehe komprimieren_fuer): 4K verlustfrei behalten,
# DVDs trotzdem schrumpfen — vorher gab es nur alles oder nichts.
transcode_an = ist_video and komprimieren_fuer(disc_type, einstellungen)
if ist_video: if ist_video:
# UI-Key schlägt Env-Key — Monats-Key ohne Rebuild aktualisierbar # UI-Key schlägt Env-Key — Monats-Key ohne Rebuild aktualisierbar
+45
View File
@@ -272,3 +272,48 @@ def test_handbrake_prueft_abbruch_bei_jeder_zeile_nicht_nur_bei_fortschritt():
# Zwei Scan-Zeilen genuegten — es musste NICHT auf eine Encode-Zeile gewartet # Zwei Scan-Zeilen genuegten — es musste NICHT auf eine Encode-Zeile gewartet
# werden. Genau das war der Fehler. # werden. Genau das war der Fehler.
assert len(gesehen) == 2 assert len(gesehen) == 2
def test_komprimieren_fuer_globaler_schalter_schlaegt_alles():
from ripping import komprimieren_fuer
assert komprimieren_fuer("uhd", {"transcodeEnabled": False}) is False
assert komprimieren_fuer("dvd", {"transcodeEnabled": False}) is False
# Standard ist AN - Bestandsinstallationen aendern ihr Verhalten nicht
assert komprimieren_fuer("bluray", {}) is True
assert komprimieren_fuer("bluray", None) is True
def test_komprimieren_fuer_einzelner_disc_typ_abwaehlbar():
"""Der Grund dafuer (Befund 25.07.2026): 4K-HEVC brauchte auf der Rippy-VM
gemessene 28-55 Stunden, weil die CPU kein AVX2 hat. Vorher gab es nur
einen globalen Schalter - wer 4K verlustfrei behalten und DVDs trotzdem
schrumpfen wollte, hatte keine Moeglichkeit."""
from ripping import PRESET_KEINE, komprimieren_fuer
nur_uhd_aus = {"transcodePresetUhd": PRESET_KEINE,
"transcodePresetDvd": "H.265 MKV 576p25"}
assert komprimieren_fuer("uhd", nur_uhd_aus) is False
assert komprimieren_fuer("dvd", nur_uhd_aus) is True
assert komprimieren_fuer("bluray", nur_uhd_aus) is True
def test_preset_fuer_gibt_den_reservewert_nie_als_preset_zurueck():
"""Sonst bekaeme HandBrake `--preset keine` und wuerde scheitern. Wer ueber
"Neu komprimieren" ausdruecklich doch komprimieren will, soll ein
brauchbares Preset bekommen statt eines Fehlers."""
from ripping import DEFAULT_HB_PRESET, PRESET_KEINE, preset_fuer
assert preset_fuer("uhd", {"transcodePresetUhd": PRESET_KEINE,
"transcodePreset": "HQ 1080p30 Surround"}) == "HQ 1080p30 Surround"
# Auch ohne allgemeines Preset kommt ein gueltiger Name heraus
assert preset_fuer("uhd", {"transcodePresetUhd": PRESET_KEINE}) == DEFAULT_HB_PRESET
def test_reservewert_kollidiert_mit_keinem_echten_preset():
"""Gegengeprueft gegen alle 90 Presets aus `HandBrakeCLI --preset-list`
im Worker-Image (25.07.2026) - keines heisst so."""
from ripping import PRESET_KEINE
assert PRESET_KEINE == "keine"
assert " " not in PRESET_KEINE # echte Preset-Namen haben durchweg Leerzeichen