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,22 +263,74 @@ 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>
|
||||
<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">
|
||||
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 => (
|
||||
<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 ${
|
||||
<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'
|
||||
@@ -150,24 +339,28 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
||||
</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>
|
||||
|
||||
{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>
|
||||
)}
|
||||
</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">
|
||||
|
||||
@@ -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:
|
||||
"""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
|
||||
denen nur `transcodePreset` gesetzt ist: solange der Nutzer die neuen
|
||||
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 {}
|
||||
schluessel = PRESET_SCHLUESSEL.get(disc_type or "")
|
||||
if schluessel:
|
||||
eigen = (einstellungen.get(schluessel) or "").strip()
|
||||
if eigen:
|
||||
if eigen and eigen != PRESET_KEINE:
|
||||
return eigen
|
||||
return (einstellungen.get("transcodePreset") or "").strip() or DEFAULT_HB_PRESET
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ except ImportError: # Windows: kein fcntl
|
||||
from ripping import (
|
||||
RIP_OUTPUT_DIR,
|
||||
RipAbbruch,
|
||||
komprimieren_fuer,
|
||||
lies_datei_dauer,
|
||||
lies_titel_info,
|
||||
preset_fuer,
|
||||
@@ -505,7 +506,9 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
||||
|
||||
einstellungen = db.get_settings()
|
||||
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:
|
||||
# UI-Key schlägt Env-Key — Monats-Key ohne Rebuild aktualisierbar
|
||||
|
||||
@@ -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
|
||||
# werden. Genau das war der Fehler.
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user