diff --git a/docker/ui/src/components/FirstRunWizard.tsx b/docker/ui/src/components/FirstRunWizard.tsx index ac157b0..31e364d 100644 --- a/docker/ui/src/components/FirstRunWizard.tsx +++ b/docker/ui/src/components/FirstRunWizard.tsx @@ -37,24 +37,32 @@ const ENCODER_LABELS: Record = { // braucht roh rund 40 GB, eine 4K-UHD bis 100 GB. const PLATZ_WARNUNG_GB = 60 -/** Was beim Abschliessen gespeichert wird — abhängig 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 für den Rest H.264 nehmen (deutlich schneller - // als H.265, dafür etwas größer). - return { - transcodePreset: 'HQ 1080p30 Surround', - transcodePresetBluray: 'HQ 1080p30 Surround', - transcodePresetDvd: 'HQ 576p25 Surround', - transcodePresetUhd: PRESET_KEINE, - } +// Antwort von GET /presets — dieselbe Quelle, die auch die Einstellungen-Seite +// benutzt. Bis 26.07.2026 entschied der Wizard hier selbst und mit fest +// verdrahteten Namen; jetzt kommt beides aus api/presets.py, wo es Tests hat. +interface PresetUebersicht { + quelle: 'worker' | 'rueckfall' + hardware: string[] + typen: Record<'uhd' | 'bluray' | 'dvd', { + empfehlung: { preset: string, grund: string, gefunden: boolean } + }> +} + +/** Was beim Abschliessen gespeichert wird — die Empfehlung der API. */ +function presetsFuer(uebersicht: PresetUebersicht | null) { + const nimm = (typ: 'uhd' | 'bluray' | 'dvd') => { + const e = uebersicht?.typen?.[typ]?.empfehlung + return e?.gefunden ? e.preset : '' } + const uhd = nimm('uhd') + const bluray = nimm('bluray') + const dvd = nimm('dvd') + // Leere Werte gar nicht schreiben: dann bleibt der Bestandswert stehen, + // statt dass HandBrake später ein leeres --preset bekommt. return { - transcodePreset: 'H.265 MKV 1080p30', - transcodePresetBluray: 'H.265 MKV 1080p30', - transcodePresetDvd: 'H.265 MKV 576p25', - transcodePresetUhd: 'H.265 MKV 2160p60 4K', + ...(bluray ? { transcodePreset: bluray, transcodePresetBluray: bluray } : {}), + ...(dvd ? { transcodePresetDvd: dvd } : {}), + ...(uhd ? { transcodePresetUhd: uhd } : {}), } } @@ -66,6 +74,7 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) { const [workers, setWorkers] = useState([]) const [geraete, setGeraete] = useState([]) const [plaetze, setPlaetze] = useState([]) + const [presetInfo, setPresetInfo] = useState(null) const [geladen, setGeladen] = useState(false) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) @@ -77,15 +86,17 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) { useEffect(() => { let laeuft = true const laden = async () => { - const [caps, devs, info] = await Promise.allSettled([ + const [caps, devs, info, pres] = await Promise.allSettled([ api.get('/capabilities'), api.get('/devices'), api.get('/system/info'), + api.get('/presets'), ]) if (!laeuft) return if (caps.status === 'fulfilled') setWorkers(caps.value.data?.workers || []) if (devs.status === 'fulfilled') setGeraete(devs.value.data || []) if (info.status === 'fulfilled') setPlaetze(info.value.data?.plaetze || []) + if (pres.status === 'fulfilled') setPresetInfo(pres.value.data || null) setGeladen(true) } laden() @@ -94,7 +105,7 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) { }, []) const schwach = schwacheEncoderCpu(workers) - const presets = presetsFuer(schwach) + const presets = presetsFuer(presetInfo) const encoderWorker = workers.filter(w => w.encoders.length > 0) const platz = plaetze.length > 0 ? Math.min(...plaetze.map(p => p.frei_gb)) : null @@ -306,6 +317,10 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) { {' '}dauern. Deshalb wird eingestellt:

+ ) : presetInfo && presetInfo.hardware.length > 0 ? ( +

+ Hardware-Encoder gemeldet ({presetInfo.hardware.join(', ')}) — Rippy nimmt ihn: +

) : (

Deine Worker sind schnell genug für H.265 — Rippy stellt ein: @@ -316,11 +331,22 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) { 4K-UHD:{' '} {presets.transcodePresetUhd === PRESET_KEINE ? 'nicht komprimieren, verlustfrei behalten (20–100 GB je Film)' - : presets.transcodePresetUhd} + : presets.transcodePresetUhd || 'unverändert (kein bekanntes Preset gemeldet)'} + +

  • + Blu-ray:{' '} + {presets.transcodePresetBluray || 'unverändert (kein bekanntes Preset gemeldet)'} +
  • +
  • + DVD:{' '} + {presets.transcodePresetDvd || 'unverändert (kein bekanntes Preset gemeldet)'}
  • -
  • Blu-ray: {presets.transcodePresetBluray}
  • -
  • DVD: {presets.transcodePresetDvd}
  • + {presetInfo?.typen?.uhd?.empfehlung?.grund && ( +

    + Warum: {presetInfo.typen.uhd.empfehlung.grund} +

    + )}

    Alles einzeln änderbar unter Einstellungen → Verarbeitung.

    diff --git a/docker/ui/src/components/RipTargetModal.tsx b/docker/ui/src/components/RipTargetModal.tsx index be8a591..9bee217 100644 --- a/docker/ui/src/components/RipTargetModal.tsx +++ b/docker/ui/src/components/RipTargetModal.tsx @@ -1,9 +1,10 @@ import { useState, useEffect } from 'react' -import { Folder, FolderOpen, File, ArrowUp, CheckCircle, Film, Tv, Music, Cpu, HardDrive } from 'lucide-react' +import { Folder, FolderOpen, ArrowUp, CheckCircle, Film, Tv, Music, Cpu, HardDrive, ChevronRight, ChevronDown } from 'lucide-react' import { api } from '../lib/api' import { Modal } from './ui/Modal' import { Button } from './ui/Button' import { Input, Select } from './ui/Input' +import { automatikWarnung, externWarnung } from '../lib/encoder' interface TargetConfig { id: string @@ -35,6 +36,10 @@ interface WorkerWahl { node: string | null online?: boolean encoders: string[] + // extern: 'ja' = läuft AUSSERHALB des Rippy-Containers und erreicht die + // Container-Pfade nur über eine Freigabe plus pfad_map. Der Worker meldet + // beides selbst (worker/caps.py). + info?: { extern?: string, pfad_map?: string } } const ENCODER_KURZ: Record = { @@ -120,8 +125,16 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI const [browsePath, setBrowsePath] = useState('/app/media') const [browseParent, setBrowseParent] = useState(null) const [browseDirs, setBrowseDirs] = useState([]) - const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([]) const [customPath, setCustomPath] = useState('') + // Der Ordner-Browser ist EINGEKLAPPT (Commander 26.07.2026: „Warum wird hier + // der Datei Browser noch angezeigt — das ist doch quatsch"). Er ist nicht + // wirklich redundant: nur über ihn lässt sich für DIESEN einen Rip ein + // beliebiger Zielordner wählen. Aber der Normalfall ist die Schnellwahl + // darüber, und der Browser überschrieb sie stillschweigend, sobald man auf + // „Diesen Ordner nutzen" klickte. Jetzt muss man ihn aufklappen — dann ist + // die Wahl bewusst. Nebenbefund beim Aufräumen: `browseFiles` wurde geladen + // und NIE angezeigt (samt ungenutztem File-Icon) — beides entfernt. + const [browserOffen, setBrowserOffen] = useState(false) // Arbeitsverzeichnis dieses Rips (Commander-Wunsch 25.07.2026: hier wählbar, // nicht global vorgegeben). '' = der Wert aus den Einstellungen, der auch // bei Vollautomatik-Rips gilt, weil dort niemand gefragt wird. @@ -141,6 +154,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI setGewaehlt(new Set()) setEncoderNode('') setArbeitsDir('') + setBrowserOffen(false) api.get('/storage-targets') .then(r => setArbeitsZiele(Array.isArray(r.data) ? r.data : [])) .catch(() => setArbeitsZiele([])) @@ -159,7 +173,8 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI { id: '3', name: 'Musik', path: `${basis}/${s.musicDir || 'music'}`, type: 'music', isActive: true }, ]) }).catch(() => {}) - laden('/app/media') + // /browse wird erst beim Aufklappen geholt — der Dialog braucht es im + // Normalfall gar nicht. }, [isOpen]) const laden = async (pfad: string) => { @@ -168,13 +183,28 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI setBrowsePath(r.data.path) setBrowseParent(r.data.parent) setBrowseDirs(r.data.dirs) - setBrowseFiles(r.data.files || []) } catch { setBrowseDirs([]) - setBrowseFiles([]) } } + // Der effektive Zielpfad dieses Rips — Schnellwahl oder eigener Ordner. + const zielPfad = customPath || targets.find(t => t.type === selectedType)?.path || '' + // Das effektive Arbeitsverzeichnis: Wahl für diesen Rip → Einstellung → + // leer (= Container-Platte /app/temp, für externe Worker unerreichbar). + const arbeitsPfadEffektiv = arbeitsDir || standardArbeitsDir + // Warnung VOR dem Start, wenn der gewählte externe Encoder die Pfade nicht + // erreicht (Punkt 6 des Savepoints v3.16). Am 26.07.2026 fiel genau das erst + // NACH dem Rip auf, weil nur der Worker selbst prüfte. + const gewaehlterWorker = workers.find(w => w.node === encoderNode) + const pfadWarnung = selectedType === 'music' + ? null + : encoderNode + ? externWarnung(gewaehlterWorker, zielPfad, arbeitsPfadEffektiv) + // „Automatisch": die geteilte Queue nimmt den ersten freien Worker — auch + // einen, der die Pfade nicht erreicht. + : automatikWarnung(workers, zielPfad, arbeitsPfadEffektiv) + const handleSave = () => { const optionen: RipOptionen = { mainFeatureOnly: nurHauptfilm } if (selectedType === 'series' && serienName.trim()) { @@ -325,6 +355,16 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI )} + {/* Die Warnung, die am 26.07.2026 gefehlt hat: Der gewählte externe + Encoder nahm die Aufgabe an und lehnte sie 182 ms später ab, weil er + die Container-Pfade nicht sieht. Sichtbar war das erst NACH dem Rip. */} + {pfadWarnung && ( +
    + ⚠️ Dieser Encoder kann so nicht arbeiten. +

    {pfadWarnung}

    +
    + )} + {/* Titel-Auswahl (Track-Tabelle) */} {deviceId && selectedType !== 'music' && (
    @@ -390,45 +430,64 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
    )} - {/* Ordner-Browser */} -
    -
    - - {browsePath} - -
    -
    - {browseDirs.map(d => ( - - ))} -
    + {/* Ordner-Browser — eingeklappt, siehe Begründung bei browserOffen */} +
    + + + {browserOffen && ( +
    +
    + + {browsePath} + +
    +
    + {browseDirs.map(d => ( + + ))} + {browseDirs.length === 0 && ( +

    Keine Unterordner.

    + )} +
    +
    + )}
    {/* Gewähltes Ziel Display */}
    {customPath ? : } - {customPath || targets.find(t => t.type === selectedType)?.path} + {zielPfad}
    diff --git a/docker/ui/src/lib/encoder.ts b/docker/ui/src/lib/encoder.ts index 4e301ca..ee59a52 100644 --- a/docker/ui/src/lib/encoder.ts +++ b/docker/ui/src/lib/encoder.ts @@ -33,6 +33,91 @@ interface WorkerFuerBewertung { info?: { cpu_simd?: string } } +// --- Erreicht ein EXTERNER Worker die Pfade? --------------------------------- +// +// Befund 26.07.2026, live am Job 95afdc89: Das Routing an den Windows-PC des +// Commanders funktionierte einwandfrei — der Worker nahm die Aufgabe an und +// lehnte sie 182 ms später ab, weil `/app/media/...` Pfade INNERHALB des +// Rippy-Containers sind. Ohne RIPPY_PATH_MAP sieht ein externer Worker sie nie. +// Auffallen durfte das nicht erst nach einer Stunde Rip, sondern hier. + +interface WorkerFuerPfadPruefung { + name: string + node?: string | null + info?: { extern?: string, pfad_map?: string } +} + +/** + * Deckt `RIPPY_PATH_MAP` diesen Container-Pfad ab? Gleiche Regel wie + * `pfad_lokal()` im Worker (docker/worker/tasks.py): Paare `quelle=ziel`, + * getrennt durch `;`, Treffer per Präfix. + */ +export function pfadAbgedeckt(pfad: string, mapping: string): boolean { + if (!pfad || !mapping) return false + return mapping.split(';').some(paar => { + const quelle = paar.split('=', 1)[0] + return !!quelle && pfad.startsWith(quelle) + }) +} + +/** + * Warnt, BEVOR gerippt wird, wenn der gewählte Encoder die Pfade nicht + * erreichen kann. null = alles in Ordnung (oder der Worker läuft im Container, + * dann gibt es nichts zu übersetzen). + * + * `arbeitsPfad` leer heißt: Container-Standard /app/temp/raw — ein Docker-Volume, + * an das ein externer Worker grundsätzlich nicht herankommt. + */ +export function externWarnung( + worker: WorkerFuerPfadPruefung | undefined, + zielPfad: string, + arbeitsPfad: string, +): string | null { + if (!worker || worker.info?.extern !== 'ja') return null + const mapping = (worker.info?.pfad_map || '').trim() + if (!mapping) { + return `„${worker.name}" läuft auf einer anderen Maschine und hat keine ` + + 'Pfad-Übersetzung (RIPPY_PATH_MAP). Er kann die Rohdaten deshalb nicht ' + + 'finden und würde sofort abbrechen. Abhilfe: den Windows-Installer erneut ' + + 'ausführen — er holt den Wert jetzt selbst von Rippy. Bis dahin besser ' + + '„Automatisch" wählen.' + } + if (!arbeitsPfad) { + return `„${worker.name}" arbeitet auf einer anderen Maschine. Das ` + + 'Arbeitsverzeichnis steht auf der Container-Platte (/app/temp) — die ist ' + + 'von außen nicht erreichbar. Oben eine Netzwerk-Freigabe als ' + + 'Arbeitsverzeichnis wählen.' + } + const fehlt: string[] = [] + if (!pfadAbgedeckt(arbeitsPfad, mapping)) fehlt.push(`Rohdaten (${arbeitsPfad})`) + if (!pfadAbgedeckt(zielPfad, mapping)) fehlt.push(`Ziel (${zielPfad})`) + if (fehlt.length === 0) return null + return `„${worker.name}" erreicht ${fehlt.join(' und ')} nicht — seine ` + + `Übersetzung deckt nur „${mapping}" ab. Beides muss auf einer Freigabe ` + + 'liegen, die diese Maschine sieht, sonst scheitert die Kompression ' + + '(die Rohdaten bleiben dabei erhalten).' +} + +/** + * Dasselbe Risiko bei „Automatisch": dort nimmt die geteilte Queue den ersten + * freien Worker — auch einen externen, der die Pfade nicht erreicht. Nennt die + * betroffenen Namen, damit man gezielt einen anderen wählt. + */ +export function automatikWarnung( + workers: WorkerFuerPfadPruefung[] | undefined, + zielPfad: string, + arbeitsPfad: string, +): string | null { + const betroffen = (workers || []) + .filter(w => externWarnung(w, zielPfad, arbeitsPfad) !== null) + .map(w => w.name) + if (betroffen.length === 0) return null + return `Bei „Automatisch" nimmt die Aufgabe der erste freie Worker — das kann ` + + `${betroffen.join(', ')} sein, und dort scheitert sie an den Pfaden. ` + + 'Wähle oben gezielt eine Maschine, die die Freigabe erreicht — oder rüste ' + + 'die Pfad-Übersetzung nach (Windows-Installer erneut ausführen).' +} + /** * Kann von den gemeldeten Workern KEINER 4K in Software brauchbar schnell * encodieren? Wahr nur, wenn das auch belegt ist: mindestens ein Worker hat diff --git a/docker/ui/src/pages/Settings.tsx b/docker/ui/src/pages/Settings.tsx index 78bd373..2963c2b 100644 --- a/docker/ui/src/pages/Settings.tsx +++ b/docker/ui/src/pages/Settings.tsx @@ -127,6 +127,37 @@ interface SystemInfo { webhook_gesetzt: boolean } +// Antwort von GET /presets — die Preset-Namen kommen aus +// `HandBrakeCLI --preset-list` des jeweiligen Workers, die Empfehlung samt +// Begründung aus api/presets.py (dort getestet). Bis 26.07.2026 standen die +// Namen fest verdrahtet in dieser Datei; sie unterscheiden sich aber zwischen +// HandBrake-Versionen, und ein Name, den das HandBrake des Workers nicht kennt, +// lässt die Kompression scheitern. +interface PresetEmpfehlung { + preset: string + grund: string + gefunden: boolean +} + +interface PresetTyp { + empfehlung: PresetEmpfehlung + auswahl: { name: string, verkleinert: boolean }[] +} + +interface PresetUebersicht { + quelle: 'worker' | 'rueckfall' + hardware: string[] + simd_schnell: boolean + typen: Record<'uhd' | 'bluray' | 'dvd', PresetTyp> +} + +// Welches Einstellungs-Feld gehört zu welchem Disc-Typ. +const PRESET_FELD: Record<'uhd' | 'bluray' | 'dvd', keyof SettingsState> = { + uhd: 'transcodePresetUhd', + bluray: 'transcodePresetBluray', + dvd: 'transcodePresetDvd', +} + // Byte-Zahl menschenlesbar — eine echte KEYDB.cfg ist mehrere MB groß. function bytesLesbar(bytes: number): string { if (!bytes) return '0 B' @@ -171,6 +202,8 @@ export default function SettingsPage() { const [keystore, setKeystore] = useState(null) const [keystoreBusy, setKeystoreBusy] = useState(false) const keystoreInput = useRef(null) + // Preset-Namen + Empfehlung, beides von der API (siehe PresetUebersicht). + const [presetInfo, setPresetInfo] = useState(null) const { toast } = useToast() @@ -291,8 +324,90 @@ export default function SettingsPage() { api.get('/system/aacs-dumps') .then(r => setDumps(r.data?.dumps || [])) .catch(() => setDumps([])) + api.get('/presets') + .then(r => setPresetInfo(r.data)) + .catch(() => setPresetInfo(null)) }, []) + // „Bestes automatisch wählen" (Commander-Anforderung 26.07.2026). Setzt alle + // drei Disc-Typen auf die Empfehlung der API — und nur solche, die wirklich + // gefunden wurden, damit nie ein Name landet, den HandBrake nicht kennt. + const bestePresetsUebernehmen = () => { + if (!presetInfo) return + const neu: Partial = {} + const uebernommen: string[] = [] + for (const typ of ['uhd', 'bluray', 'dvd'] as const) { + const e = presetInfo.typen[typ]?.empfehlung + if (!e?.gefunden || !e.preset) continue + neu[PRESET_FELD[typ]] = e.preset as any + uebernommen.push(`${typ.toUpperCase()}: ${e.preset === PRESET_KEINE ? 'nicht komprimieren' : e.preset}`) + } + if (uebernommen.length === 0) { + toast('error', 'Kein Preset übernommen — die Worker melden keine bekannten Namen.') + return + } + setSettings(prev => ({ ...prev, ...neu })) + setSaved(false) + toast('success', `Übernommen — ${uebernommen.join(' · ')}. Noch speichern!`) + } + + // Eine Preset-Auswahl je Disc-Typ. Die Optionen kommen vom WORKER (siehe + // PresetUebersicht), nicht aus dieser Datei. Der aktuell gespeicherte Wert + // steht immer mit drin — sonst würde eine Bestandseinstellung beim Öffnen der + // Seite still auf etwas anderes springen. + const presetAuswahl = ( + typ: 'uhd' | 'bluray' | 'dvd', label: string, keineText: string, + ) => { + const feld = PRESET_FELD[typ] + const wert = (settings[feld] as string) || '' + const daten = presetInfo?.typen?.[typ] + const empfehlung = daten?.empfehlung + const passend = (daten?.auswahl || []).filter(o => !o.verkleinert) + const kleiner = (daten?.auswahl || []).filter(o => o.verkleinert) + const bekannt = wert === PRESET_KEINE + || (daten?.auswahl || []).some(o => o.name === wert) + return ( +
    + + {empfehlung && empfehlung.preset !== wert && ( +

    + {empfehlung.gefunden ? ( + <> + Empfohlen wäre{' '} + + {empfehlung.preset === PRESET_KEINE ? 'nicht komprimieren' : empfehlung.preset} + + {' — '}{empfehlung.grund} + + ) : empfehlung.grund} +

    + )} +
    + ) + } + const webhookTesten = async () => { if (!settings.notificationWebhook) return setTestBusy(true) @@ -637,28 +752,54 @@ export default function SettingsPage() { Ein Preset JE DISC-TYP (25.07.2026). Vorher lief alles durch dasselbe Preset — eine 4K-UHD wurde damit auf 1080p heruntergerechnet und eine DVD auf 1080p hochskaliert. - Preset-Namen stammen aus `HandBrakeCLI --preset-list` im - Worker-Image (HandBrake 1.6.1), nicht aus dem Kopf. + + Seit 26.07.2026 kommen die NAMEN vom Worker selbst + (`HandBrakeCLI --preset-list`, siehe worker/caps.py) und die + Empfehlung von GET /presets. Vorher standen sie hier fest + verdrahtet — sie unterscheiden sich aber je HandBrake-Version, + und ein Name, den das HandBrake des Workers nicht kennt, lässt + die Kompression scheitern. */}
    -

    - Ein Preset je Quelle — Rippy erkennt den Disc-Typ selbst und wählt passend aus. - Eine 4K-UHD gehört auf ein - 2160p-Preset, sonst rechnet HandBrake sie auf 1080p herunter und die - 4K-Auflösung ist weg. -

    +
    +

    + Ein Preset je Quelle — Rippy erkennt den Disc-Typ selbst und wählt passend aus. + Eine 4K-UHD gehört auf ein + 2160p-Preset, sonst rechnet HandBrake sie auf 1080p herunter und die + 4K-Auflösung ist weg. +

    + +
    - + {presetInfo && ( +

    + {presetInfo.quelle === 'worker' ? ( + <> + Die Liste kommt von deinen Workern selbst. + {presetInfo.hardware.length > 0 + ? ` Hardware-Encoder gemeldet: ${presetInfo.hardware.join(', ')} — damit geht 4K in Minuten statt Tagen.` + : presetInfo.simd_schnell + ? ' Kein Hardware-Encoder, aber ein Worker mit AVX2 — Software-H.265 ist damit schnell genug.' + : ' Kein Hardware-Encoder und kein AVX2 — hier ist Software-4K keine Option (gemessen: 28–55 h je Film).'} + + ) : ( + <> + Kein Worker meldet gerade eine Preset-Liste (alter Worker-Stand oder + keiner online) — angezeigt wird deshalb eine geprüfte Standardauswahl. + + )} +

    + )} + + {presetAuswahl('uhd', 'Preset für 4K-UHD', + 'Nicht komprimieren — 4K verlustfrei behalten')} {/* 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). */} @@ -673,28 +814,10 @@ export default function SettingsPage() {

    )} - - - + {presetAuswahl('bluray', 'Preset für Blu-ray', + 'Nicht komprimieren — verlustfrei behalten')} + {presetAuswahl('dvd', 'Preset für DVD', + 'Nicht komprimieren — verlustfrei behalten')}

    „Nicht komprimieren" heißt: Der Rip bleibt so, wie MakeMKV ihn