diff --git a/docker/ui/src/components/FirstRunWizard.tsx b/docker/ui/src/components/FirstRunWizard.tsx index 6cb2be5..8d81bdb 100644 --- a/docker/ui/src/components/FirstRunWizard.tsx +++ b/docker/ui/src/components/FirstRunWizard.tsx @@ -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 = { @@ -19,40 +33,122 @@ const ENCODER_LABELS: Record = { '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([]) + const [geraete, setGeraete] = useState([]) + const [plaetze, setPlaetze] = useState([]) + const [geladen, setGeladen] = useState(false) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) + const [keyWarnung, setKeyWarnung] = useState(null) + // Wiederholt laden, bis Worker UND Laufwerk da sind: beim allerersten Start + // 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 }) {

Willkommen bei Rippy

-

Einmalige Einrichtung — dauert keine zwei Minuten. Alles ist später unter Einstellungen änderbar.

+

+ Einmalige Einrichtung — dauert keine zwei Minuten. Du kannst nichts kaputt + machen: alles ist später unter Einstellungen änderbar. +

- {/* Metadaten-APIs */} + {/* ---- Was Rippy gerade sieht ---------------------------------- */} +
+

+ Was Rippy gerade sieht +

+
+ 0} + gutText={geraete.map(g => g.name).join(', ')} + schlechtText="Kein optisches Laufwerk gefunden. Rippy kann dann nur komprimieren, nicht rippen. Prüfe, ob das Laufwerk angeschlossen und (bei einer VM) durchgereicht ist." + /> + 0} + gutText={encoderWorker.map(w => { + const kerne = w.info?.cpu_kerne + const simd = w.info?.cpu_simd + const zusatz = [kerne ? `${kerne} Kerne` : '', simd && simd !== 'unbekannt' ? simd : ''] + .filter(Boolean).join(', ') + return zusatz ? `${w.name} (${zusatz})` : w.name + }).join(' · ')} + schlechtText="Noch kein Worker gemeldet. Beim ersten Start dauert das bis zu einer Minute — diese Anzeige aktualisiert sich selbst. Bleibt es dabei, läuft der Worker-Container nicht." + /> + = PLATZ_WARNUNG_GB} + gutText={platz === null ? '—' : `${platz.toFixed(0)} GB`} + schlechtText={`Nur ${platz?.toFixed(0)} GB frei. Eine Blu-ray braucht roh rund 40 GB, eine 4K-UHD bis 100 GB. Lege die Ablage besser auf eine NAS-Freigabe (Einstellungen → Speicherziele).`} + /> +
+
+ + {/* ---- Metadaten ---------------------------------------------- */}

Metadaten-Erkennung

+ {/* Bewusst SICHTBAR statt type=password: Das sind kopierte Keys, + keine Passwoerter — und einen Tippfehler sieht man in Punkten + nicht. Geprueft wird direkt nach dem Speichern. */} setTmdbKey(e.target.value)} - placeholder="Kostenlos auf themoviedb.org" + placeholder="Kostenlos auf themoviedb.org — hier einfügen" /> setOmdbKey(e.target.value)} @@ -91,11 +227,12 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) { />

Ohne Keys rippt Rippy trotzdem — Discs heißen dann nur wie ihr Volume-Label. + Beide Keys werden direkt nach dem Speichern geprüft.

- {/* Media-Server */} + {/* ---- Media-Server ------------------------------------------- */}

Dein Media-Server @@ -126,48 +263,104 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {

- {/* Verarbeitung + erkannte Hardware */} + {/* ---- Verarbeitung ------------------------------------------- */}

Verarbeitung

-
-

Erkannte Encoder in deinem System:

- {workers.length === 0 ? ( -

Noch kein Worker gemeldet — startet gerade?

- ) : ( - workers.map(w => ( -
- {w.name}: - {w.encoders.map(e => ( - - {ENCODER_LABELS[e] || e} - - ))} -
- )) - )} -
- -
- {error &&

{error}

} + {keyWarnung && ( +
+

{keyWarnung}

+
+ + +
+
+ )} + + {error && ( +

+ {error} +

+ )}
) } + +/** Eine Zeile im „Was Rippy gerade sieht"-Kasten: gruen, wenn es passt, sonst + * bernstein MIT Handlungsanweisung. Nie nur ein Kreuz ohne Erklaerung. */ +function Zeile({ titel, geladen, gut, gutText, schlechtText }: { + titel: string + geladen: boolean + gut: boolean + gutText: string + schlechtText: string +}) { + if (!geladen) { + return ( +
+ + {titel} — wird geprüft… +
+ ) + } + return ( +
+ +
+

+ {titel}:{' '} + {gut ? gutText : nicht bereit} +

+ {!gut &&

{schlechtText}

} +
+
+ ) +} diff --git a/docker/ui/src/lib/encoder.ts b/docker/ui/src/lib/encoder.ts index 4aebd19..4e301ca 100644 --- a/docker/ui/src/lib/encoder.ts +++ b/docker/ui/src/lib/encoder.ts @@ -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)) +} diff --git a/docker/ui/src/pages/Settings.tsx b/docker/ui/src/pages/Settings.tsx index 6e74f22..db848be 100644 --- a/docker/ui/src/pages/Settings.tsx +++ b/docker/ui/src/pages/Settings.tsx @@ -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)} > - + + + {/* 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) && ( +

+ ⚠️ Keiner deiner Worker kann AVX2 — 4K in H.265 dauert damit + ein bis zwei Tage pro Film (gemessen, nicht geschätzt). + Besser „Nicht komprimieren" wählen und die verlustfreie Datei behalten, + oder einen Worker mit Hardware-Encoder anschließen. +

+ )} + +

+ „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. +

diff --git a/docker/worker/ripping.py b/docker/worker/ripping.py index 95dfd43..68e8257 100644 --- a/docker/worker/ripping.py +++ b/docker/worker/ripping.py @@ -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 diff --git a/docker/worker/tasks.py b/docker/worker/tasks.py index cf32b2b..d1362c8 100644 --- a/docker/worker/tasks.py +++ b/docker/worker/tasks.py @@ -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 diff --git a/docker/worker/test_ripping_helpers.py b/docker/worker/test_ripping_helpers.py index 64722c7..727b0cf 100644 --- a/docker/worker/test_ripping_helpers.py +++ b/docker/worker/test_ripping_helpers.py @@ -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