fix(v5): Die drei Funde aus dem ersten Commander-Test
Ampel / ampel (push) Successful in 1m25s

FUND 1 — 'Key korrekt, aber als ungueltig angezeigt': Stimmt beides.
Live gemessen: Der aktuelle Forum-Key ist in Ordnung, aber MakeMKV
1.18.4 ist ZU ALT fuer ihn — makemkvcon meldet 5020 UND 5021 zusammen,
und die alte Pruefung brach beim ersten Code ab und beschuldigte den
Schluessel. urteilAusAusgabe sammelt jetzt ALLE Codes (5021 schlaegt
5020, MakeMKV-Version aus MSG 1005 wird mitgelesen) und die Kachel
sagt den wahren Satz: 'Der Key ist in Ordnung — MakeMKV 1.18.4 ist zu
alt. Rippy hat ihn entfernt, damit der Beta-Modus weiterlaeuft (Rips
klappen trotzdem). Abhilfe: MakeMKV aktualisieren.' Neues Feld
weiterBetrieb: Beta-Modus ist GELB (Warnung), nicht rot. Test mit der
echten Ausgabe als Fixture.

FUND 2 — 'Komprimieren auf CPU statt GPU': Der Default 'H.265 MKV
1080p30' ist ein CPU-Preset. Neue GEMESSENE Empfehlung
(gemeinsam/preset-empfehlung.ts): aus den echten Backends und der
echten Preset-Liste des eigenen HandBrake — VCN vor NVENC vor QSV vor
CPU, nur Namen die WIRKLICH in der Liste stehen, HandBrake-Presets
deckeln die Aufloesung nur (DVD bleibt SD). Kette: eigene Wahl >
allgemeines Preset > Empfehlung > CPU-Default. Auf dem Commander-PC:
bluray/dvd -> 'H.265 VCN 1080p', uhd -> 'H.265 VCN 2160p 4K'. Die
Preset-Auswahl zeigt 'automatisch: <Empfehlung>' als Leer-Option.

FUND 3 — 'keine Alternative waehlbar': 'Aendern …'-Dialog an der
Laufwerks-Kachel (§ 5 Schritt 4): TMDb-Suche (Film + Serie, mit
Postern), Klick uebernimmt — Nutzer-Wahl gilt als 100 % und steuert
Ablage-Ordner, NFO und Poster. Neue Nachrichten metadaten-suchen /
zuordnung-setzen / metadaten-vorschlaege.

Dazu: Poster in der Laufwerks-Kachel (CSP um image.tmdb.org
erweitert), Restzeit-Schaetzung am Fortschritt.

192 Tests gruen, Smoke gruen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-30 20:20:10 +02:00
co-authored by Claude Fable 5
parent 0e1c927e7b
commit c5dd90cc18
11 changed files with 542 additions and 56 deletions
+3 -1
View File
@@ -24,7 +24,9 @@ function cspNurImBau(): Plugin {
transformIndexHtml(html) {
return html.replace(
'<head>',
`<head>\n <meta http-equiv="Content-Security-Policy" content="default-src 'self'" />`,
// img-src erlaubt TMDb-Poster (Kachel + Ändern-Dialog) — sonst
// bleibt alles bei 'self'.
`<head>\n <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data: https://image.tmdb.org" />`,
)
},
}
+173 -30
View File
@@ -3,15 +3,33 @@
// Bibliothek (§ 6.6). Design-Sprache wie das heutige Rippy: dunkel, Kacheln.
import { useEffect, useState } from 'react'
import type { DiscInfoStand, GeraeteInfo, HauptStatus, RipStatus } from '../gemeinsam/nachrichten'
import { presetEmpfehlung } from '../gemeinsam/preset-empfehlung'
import {
aufVerbindung,
auswerfen,
einstellungSetzen,
metadatenSuchen,
ripAbbrechen,
ripStarten,
verbindungStarten,
zuordnungSetzen,
type Verbindung,
} from './kernverbindung'
import type { MetaVorschlag } from '../gemeinsam/nachrichten'
function posterUrlKlein(pfad: string): string {
if (pfad.length === 0) return ''
if (pfad.startsWith('http')) return pfad
return `https://image.tmdb.org/t/p/w154${pfad}`
}
function restzeitText(prozent: number, startMs: number | undefined): string {
if (startMs === undefined || prozent <= 2 || prozent >= 100) return ''
const verstrichen = (Date.now() - startMs) / 1000
const restS = Math.round((verstrichen * (100 - prozent)) / prozent)
if (restS < 90) return `· ~${restS} s`
return `· ~${Math.round(restS / 60)} min`
}
function istHauptStatus(wert: unknown): wert is HauptStatus {
return (
@@ -68,15 +86,101 @@ function Kachel({ titel, zustand, zeilen }: KachelProps) {
)
}
// § 5 Schritt 4: „nein → fragen" — Suche + Auswahl der richtigen Metadaten.
function AendernDialog({
id,
startText,
vorschlaege,
schliessen,
}: {
id: string
startText: string
vorschlaege: MetaVorschlag[]
schliessen: () => void
}) {
const [text, setText] = useState(startText)
useEffect(() => {
metadatenSuchen(id, startText)
// Nur beim Öffnen — weitere Suchen löst der Knopf aus.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6" onClick={schliessen}>
<div
className="max-h-[80vh] w-full max-w-lg overflow-y-auto rounded-xl border border-slate-600 bg-slate-900 p-4 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<h3 className="mb-3 text-sm font-semibold text-slate-200">Was ist das wirklich?</h3>
<div className="mb-3 flex gap-2">
<input
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') metadatenSuchen(id, text)
}}
placeholder="Titel suchen …"
className="flex-1 rounded-lg border border-slate-600 bg-slate-950 px-3 py-1.5 font-mono text-xs text-slate-200 focus:border-sky-400 focus:outline-none"
/>
<button
onClick={() => metadatenSuchen(id, text)}
className="rounded-lg border border-sky-500/60 px-3 py-1.5 text-xs text-sky-300 hover:bg-sky-500/10"
>
Suchen
</button>
</div>
{vorschlaege.length === 0 ? (
<p className="font-mono text-xs text-slate-500">Keine Treffer anderen Suchtext probieren.</p>
) : (
<div className="space-y-1.5">
{vorschlaege.map((v) => (
<button
key={`${v.typ}-${v.tmdbId}`}
onClick={() => {
zuordnungSetzen(id, v)
schliessen()
}}
className="flex w-full items-center gap-3 rounded-lg border border-slate-700/60 bg-slate-800/40 p-2 text-left hover:border-sky-400/60 hover:bg-slate-800"
>
{v.posterPfad.length > 0 ? (
<img src={posterUrlKlein(v.posterPfad)} alt="" className="h-16 w-11 rounded object-cover" />
) : (
<div className="flex h-16 w-11 items-center justify-center rounded bg-slate-700 text-lg">🎬</div>
)}
<div>
<p className="text-sm text-slate-200">
{v.titel}
{v.jahr !== null ? ` (${v.jahr})` : ''}
</p>
<p className="text-xs text-slate-500">{v.typ === 'tv' ? 'Serie' : 'Film'}</p>
</div>
</button>
))}
</div>
)}
<div className="mt-3 text-right">
<button onClick={schliessen} className="rounded-lg border border-slate-600 px-3 py-1 text-xs text-slate-300 hover:bg-slate-700">
Schließen
</button>
</div>
</div>
</div>
)
}
function LaufwerkKachel({
geraet,
rip,
info,
vorschlaege,
ripStartMs,
}: {
geraet: GeraeteInfo
rip: RipStatus | undefined
info: DiscInfoStand | undefined
vorschlaege: MetaVorschlag[]
ripStartMs: number | undefined
}) {
const [aendernOffen, setAendernOffen] = useState(false)
const ripLaeuft = rip !== undefined && LAUFENDE_PHASEN.includes(rip.phase)
const zustand = geraet.status === 'ready' ? 'ok' : geraet.status === 'empty' ? 'wartet' : 'fehler'
const punktFarbe = ripLaeuft
@@ -121,32 +225,59 @@ function LaufwerkKachel({
</button>
</div>
</div>
<div className="mt-2 space-y-0.5 font-mono text-xs text-slate-400">
<p>{geraet.modell.length > 0 ? geraet.modell : 'Modell unbekannt'}</p>
{geraet.status === 'ready' && (
<p className="text-slate-200">
{TYP_NAMEN[geraet.typ]}
{geraet.groesseBytes > 0 ? ` · ${gb(geraet.groesseBytes)}` : ''}
</p>
)}
{geraet.status === 'ready' && info !== undefined && info.titel.length > 0 && (
<p className={info.confidence >= 0.8 ? 'text-emerald-300' : 'text-amber-300'}>
{sicherheitsWort(info.confidence)}: {info.titel}
{info.jahr !== null ? ` (${info.jahr})` : ''} · {Math.round(info.confidence * 100)} %
{info.quelle.length > 0 ? ` · ${info.quelle}` : ''}
</p>
)}
{geraet.status === 'ready' && info !== undefined && info.schonGerippt !== null && (
<p className="text-amber-300">
Diese Disc wurde schon einmal gerippt ({info.schonGerippt.slice(0, 10)}) siehe Bibliothek.
</p>
)}
{geraet.status === 'empty' && <p>keine Disc eingelegt</p>}
{geraet.status === 'unknown' && <p className="text-red-300">{geraet.grund}</p>}
{geraet.status !== 'unknown' && geraet.grund.length > 0 && (
<p className="text-amber-300">{geraet.grund}</p>
<div className="mt-2 flex gap-3">
{geraet.status === 'ready' && info !== undefined && info.posterPfad.length > 0 && (
<img
src={posterUrlKlein(info.posterPfad)}
alt=""
className="h-24 w-16 flex-none rounded-lg object-cover shadow-lg shadow-black/40"
/>
)}
<div className="min-w-0 flex-1 space-y-0.5 font-mono text-xs text-slate-400">
<p>{geraet.modell.length > 0 ? geraet.modell : 'Modell unbekannt'}</p>
{geraet.status === 'ready' && (
<p className="text-slate-200">
{TYP_NAMEN[geraet.typ]}
{geraet.groesseBytes > 0 ? ` · ${gb(geraet.groesseBytes)}` : ''}
</p>
)}
{geraet.status === 'ready' && info !== undefined && info.titel.length > 0 && (
<p className={info.confidence >= 0.8 ? 'text-emerald-300' : 'text-amber-300'}>
{sicherheitsWort(info.confidence)}: {info.titel}
{info.jahr !== null ? ` (${info.jahr})` : ''} · {Math.round(info.confidence * 100)} %
{info.quelle.length > 0 ? ` · ${info.quelle}` : ''}
</p>
)}
{geraet.status === 'ready' && (
<button
onClick={() => setAendernOffen(true)}
disabled={ripLaeuft}
className="mt-1 rounded border border-slate-600 px-2 py-0.5 text-xs text-slate-300 hover:bg-slate-700 disabled:opacity-40"
title="Falsch erkannt? Selbst suchen und wählen — die Ablage folgt deiner Wahl."
>
Ändern
</button>
)}
{geraet.status === 'ready' && info !== undefined && info.schonGerippt !== null && (
<p className="text-amber-300">
Diese Disc wurde schon einmal gerippt ({info.schonGerippt.slice(0, 10)}) siehe Bibliothek.
</p>
)}
{geraet.status === 'empty' && <p>keine Disc eingelegt</p>}
{geraet.status === 'unknown' && <p className="text-red-300">{geraet.grund}</p>}
{geraet.status !== 'unknown' && geraet.grund.length > 0 && (
<p className="text-amber-300">{geraet.grund}</p>
)}
</div>
</div>
{aendernOffen && (
<AendernDialog
id={geraet.id}
startText={info !== undefined && info.titel.length > 0 ? info.titel : ''}
vorschlaege={vorschlaege}
schliessen={() => setAendernOffen(false)}
/>
)}
{rip !== undefined && (
<div className="mt-3 space-y-1.5 border-t border-slate-700/60 pt-3">
{ripLaeuft && (
@@ -168,7 +299,9 @@ function LaufwerkKachel({
: 'text-slate-300'
}`}
>
{ripLaeuft && rip.prozent >= 0 ? `${rip.prozent} % — ` : ''}
{ripLaeuft && rip.prozent >= 0
? `${rip.prozent} % ${restzeitText(rip.prozent, ripStartMs)}`
: ''}
{rip.text}
</p>
{rip.dateien.map((datei) => (
@@ -220,12 +353,14 @@ function PresetWahl({
schluessel,
wert,
presets,
empfehlung,
aufAendern,
}: {
name: string
schluessel: string
wert: string
presets: string[]
empfehlung: string
aufAendern: (schluessel: string, wert: string) => void
}) {
return (
@@ -236,7 +371,7 @@ function PresetWahl({
onChange={(e) => aufAendern(schluessel, e.target.value)}
className="mt-1 w-full rounded-lg border border-slate-600 bg-slate-900 px-3 py-1.5 font-mono text-xs text-slate-200 focus:border-sky-400 focus:outline-none"
>
<option value="">(allgemeines Preset)</option>
<option value="">{empfehlung.length > 0 ? `automatisch: ${empfehlung}` : '(allgemeines Preset)'}</option>
<option value="keine">NICHT komprimieren (verlustfrei lassen)</option>
{presets.map((preset) => (
<option key={preset} value={preset}>
@@ -252,6 +387,8 @@ function Einstellungen({ verbindung, haupt }: { verbindung: Verbindung; haupt: H
const werte = verbindung.einstellungen
const presets = verbindung.werkzeuge?.presets ?? []
const setzen = einstellungSetzen
const empfehlungFuer = (typ: string): string =>
presetEmpfehlung(typ, verbindung.werkzeuge?.backends ?? [], presets)
const ordnerWaehlen = async (): Promise<void> => {
const ordner = await window.rippy.ordnerWaehlen('Ablage-Ordner wählen')
@@ -290,9 +427,9 @@ function Einstellungen({ verbindung, haupt }: { verbindung: Verbindung; haupt: H
Nach dem Rip komprimieren (der verlustfreie Rip bleibt bis zum Erfolg erhalten)
</label>
<div className="grid gap-3 sm:grid-cols-3">
<PresetWahl name="DVD" schluessel="transcodePresetDvd" wert={werte['transcodePresetDvd'] ?? ''} presets={presets} aufAendern={setzen} />
<PresetWahl name="Blu-ray" schluessel="transcodePresetBluray" wert={werte['transcodePresetBluray'] ?? ''} presets={presets} aufAendern={setzen} />
<PresetWahl name="4K-UHD" schluessel="transcodePresetUhd" wert={werte['transcodePresetUhd'] ?? ''} presets={presets} aufAendern={setzen} />
<PresetWahl name="DVD" schluessel="transcodePresetDvd" wert={werte['transcodePresetDvd'] ?? ''} presets={presets} empfehlung={empfehlungFuer('dvd')} aufAendern={setzen} />
<PresetWahl name="Blu-ray" schluessel="transcodePresetBluray" wert={werte['transcodePresetBluray'] ?? ''} presets={presets} empfehlung={empfehlungFuer('bluray')} aufAendern={setzen} />
<PresetWahl name="4K-UHD" schluessel="transcodePresetUhd" wert={werte['transcodePresetUhd'] ?? ''} presets={presets} empfehlung={empfehlungFuer('uhd')} aufAendern={setzen} />
</div>
<Feld
name="Allgemeines Preset"
@@ -618,6 +755,8 @@ export default function App() {
laufwerke: [],
rips: {},
discInfos: {},
vorschlaege: {},
ripStart: {},
einstellungen: {},
werkzeuge: null,
bibliothek: [],
@@ -689,6 +828,8 @@ export default function App() {
geraet={geraet}
rip={verbindung.rips[geraet.id]}
info={verbindung.discInfos[geraet.id]}
vorschlaege={verbindung.vorschlaege[geraet.id] ?? []}
ripStartMs={verbindung.ripStart[geraet.id]}
/>
))}
</div>
@@ -744,7 +885,9 @@ export default function App() {
? 'wartet'
: verbindung.schluessel.angenommen === true
? 'ok'
: 'fehler'
: verbindung.schluessel.weiterBetrieb
? 'wartet' // gelb: Rips laufen, aber mit Handlungsempfehlung
: 'fehler'
}
zeilen={
verbindung.schluessel === null
+37 -1
View File
@@ -12,6 +12,7 @@ import {
type DiscInfoStand,
type GeraeteInfo,
type KernNachricht,
type MetaVorschlag,
type RipStatus,
type WerkzeugAuskunft,
} from '../gemeinsam/nachrichten'
@@ -26,10 +27,21 @@ export interface Verbindung {
rips: Record<string, RipStatus>
/** Was Rippy über die eingelegte Disc weiß (§ 5 Schritt 3). */
discInfos: Record<string, DiscInfoStand>
/** Suchergebnisse des Ändern-Dialogs je Laufwerk (§ 5 Schritt 4). */
vorschlaege: Record<string, MetaVorschlag[]>
/** Wann der laufende Vorgang je Laufwerk begann (für die Restzeit). */
ripStart: Record<string, number>
einstellungen: Record<string, string>
werkzeuge: WerkzeugAuskunft | null
bibliothek: BibliothekEintrag[]
schluessel: { quelle: string; angenommen: boolean | null; gueltigBis: string; geholtAm: string; text: string } | null
schluessel: {
quelle: string
angenommen: boolean | null
weiterBetrieb: boolean
gueltigBis: string
geholtAm: string
text: string
} | null
/** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */
meldungen: string[]
}
@@ -45,6 +57,8 @@ const stand: Verbindung = {
laufwerke: [],
rips: {},
discInfos: {},
vorschlaege: {},
ripStart: {},
einstellungen: {},
werkzeuge: null,
bibliothek: [],
@@ -62,6 +76,7 @@ function melden(): void {
laufwerke: [...stand.laufwerke],
rips: { ...stand.rips },
discInfos: { ...stand.discInfos },
vorschlaege: { ...stand.vorschlaege },
einstellungen: { ...stand.einstellungen },
bibliothek: [...stand.bibliothek],
meldungen: [...stand.meldungen],
@@ -115,6 +130,9 @@ function kernNachricht(nachricht: KernNachricht): void {
}
break
}
case 'metadaten-vorschlaege':
stand.vorschlaege = { ...stand.vorschlaege, [nachricht.id]: nachricht.vorschlaege }
break
case 'einstellungen':
stand.einstellungen = nachricht.werte
break
@@ -130,6 +148,13 @@ function kernNachricht(nachricht: KernNachricht): void {
break
case 'rip-status': {
stand.rips = { ...stand.rips, [nachricht.status.id]: nachricht.status }
const laeuft = ['liest-info', 'rippt', 'komprimiert', 'legt-ab'].includes(nachricht.status.phase)
if (laeuft && stand.ripStart[nachricht.status.id] === undefined) {
stand.ripStart = { ...stand.ripStart, [nachricht.status.id]: Date.now() }
} else if (!laeuft) {
const { [nachricht.status.id]: _weg, ...rest } = stand.ripStart
stand.ripStart = rest
}
if (nachricht.status.phase === 'fertig') {
meldung(
`Laufwerk ${nachricht.status.id}: Rip fertig — ${nachricht.status.dateien.length} Datei(en)` +
@@ -187,6 +212,7 @@ export function aufVerbindung(h: Horcher): () => void {
laufwerke: [...stand.laufwerke],
rips: { ...stand.rips },
discInfos: { ...stand.discInfos },
vorschlaege: { ...stand.vorschlaege },
einstellungen: { ...stand.einstellungen },
bibliothek: [...stand.bibliothek],
meldungen: [...stand.meldungen],
@@ -215,6 +241,16 @@ export function ripAbbrechen(id: string): void {
anKern({ art: 'rip-abbruch', id }, `Rip-Abbruch ${id}`)
}
export function metadatenSuchen(id: string, text: string): void {
if (port === null) return
port.postMessage({ art: 'metadaten-suchen', id, text })
}
export function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): void {
if (port === null) return
port.postMessage({ art: 'zuordnung-setzen', id, vorschlag })
}
export function einstellungSetzen(schluessel: string, wert: string): void {
if (port === null) {
meldung(`⚠ Einstellung ${schluessel}: keine Verbindung zum Kern`)
+40 -1
View File
@@ -85,6 +85,15 @@ export interface BibliothekEintrag {
dauerS: number
}
/** Ein wählbarer Metadaten-Vorschlag (§ 5 Schritt 4: „nein → fragen"). */
export interface MetaVorschlag {
typ: 'movie' | 'tv'
tmdbId: number
titel: string
jahr: number | null
posterPfad: string
}
/** Auskunft über die Werkzeuge (Katalog + gemessene HandBrake-Listen). */
export interface WerkzeugAuskunft {
makemkvcon: string
@@ -103,12 +112,21 @@ export type KernNachricht =
| { art: 'auswurf-ergebnis'; id: string; grund: AuswurfGrund; fehler: string | null }
| { art: 'rip-status'; status: RipStatus }
| { art: 'disc-info'; info: DiscInfoStand }
| { art: 'metadaten-vorschlaege'; id: string; vorschlaege: MetaVorschlag[] }
| { art: 'einstellungen'; werte: Record<string, string> }
| { art: 'werkzeuge'; auskunft: WerkzeugAuskunft }
| { art: 'bibliothek'; eintraege: BibliothekEintrag[] }
| {
art: 'schluessel-status'
stand: { quelle: string; angenommen: boolean | null; gueltigBis: string; geholtAm: string; text: string }
stand: {
quelle: string
angenommen: boolean | null
/** Rips laufen trotzdem (Beta-Modus) — Warnung, kein Ausfall. */
weiterBetrieb: boolean
gueltigBis: string
geholtAm: string
text: string
}
}
/** Nachrichten, die das Fenster über den MessagePort an den Kern schickt. */
@@ -118,6 +136,8 @@ export type FensterNachricht =
| { art: 'rip-start'; id: string }
| { art: 'rip-abbruch'; id: string }
| { art: 'einstellung-setzen'; schluessel: string; wert: string }
| { art: 'metadaten-suchen'; id: string; text: string }
| { art: 'zuordnung-setzen'; id: string; vorschlag: MetaVorschlag }
| { art: 'einstellungen-laden' }
| { art: 'werkzeuge-laden' }
| { art: 'bibliothek-laden' }
@@ -178,11 +198,24 @@ export function istKernNachricht(wert: unknown): wert is KernNachricht {
return Array.isArray(wert.eintraege)
case 'schluessel-status':
return istObjekt(wert.stand) && typeof (wert.stand as Record<string, unknown>).text === 'string'
case 'metadaten-vorschlaege':
return typeof wert.id === 'string' && Array.isArray(wert.vorschlaege)
default:
return false
}
}
export function istMetaVorschlag(wert: unknown): wert is MetaVorschlag {
if (!istObjekt(wert)) return false
return (
(wert.typ === 'movie' || wert.typ === 'tv') &&
typeof wert.tmdbId === 'number' &&
typeof wert.titel === 'string' &&
(wert.jahr === null || typeof wert.jahr === 'number') &&
typeof wert.posterPfad === 'string'
)
}
export function istRipStatus(wert: unknown): wert is RipStatus {
if (!istObjekt(wert)) return false
return (
@@ -211,6 +244,12 @@ export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
if (wert.art === 'einstellung-setzen') {
return typeof wert.schluessel === 'string' && wert.schluessel.length > 0 && typeof wert.wert === 'string'
}
if (wert.art === 'metadaten-suchen') {
return typeof wert.id === 'string' && wert.id.length > 0 && typeof wert.text === 'string'
}
if (wert.art === 'zuordnung-setzen') {
return typeof wert.id === 'string' && wert.id.length > 0 && istMetaVorschlag(wert.vorschlag)
}
return wert.art === 'einstellungen-laden' || wert.art === 'werkzeuge-laden' || wert.art === 'bibliothek-laden'
}
@@ -0,0 +1,44 @@
// Die Preset-Empfehlung — GEMESSEN, nicht behauptet (Commander-Fund
// 30.08.2026: „Das Komprimieren passiert auf der CPU, nicht auf der
// deutlich schnelleren GPU"). Der alte Default „H.265 MKV 1080p30" ist
// ein CPU-x265-Preset; auf dem Commander-PC kodiert die RX 9070 XT
// (VCN) denselben Film in 2 statt 60+ Minuten.
//
// Regeln:
// * Empfohlen wird NUR ein Preset, das in der vom eigenen HandBrake
// gemessenen Liste WIRKLICH steht (encoder.ts) — nie ein geratener
// Name (die „Invalid preset"-Falle).
// * Familienreihenfolge nach den gemessenen Backends: VCN (AMD) →
// NVENC (NVIDIA) → QSV (Intel) → CPU-Default.
// * HandBrake-Presets DECKELN die Auflösung nur — eine DVD bleibt SD,
// auch unter „…1080p" (deshalb ist die 1080p-Empfehlung auch für
// DVDs richtig; hochskaliert wird nichts).
// Liegt hier in gemeinsam/, weil Kern (Pipeline-Fallback) UND Fenster
// (Anzeige „automatisch: …") dieselbe Rechnung brauchen.
export const DEFAULT_HB_PRESET = 'H.265 MKV 1080p30'
const FAMILIEN: ReadonlyArray<readonly [backend: string, presetKennung: string]> = [
['vce', 'VCN'],
['nvenc', 'NVENC'],
['qsv', 'QSV'],
]
/** Das empfohlene Preset für diesen Disc-Typ — aus den GEMESSENEN
* Backends und Preset-Namen. Fällt auf den CPU-Default zurück. */
export function presetEmpfehlung(
discTyp: string,
backends: readonly string[],
presets: readonly string[],
): string {
const gewuenscht = discTyp === 'uhd' ? '2160p 4K' : '1080p'
for (const [backend, kennung] of FAMILIEN) {
if (!backends.includes(backend)) continue
const treffer = presets.find((p) => p.startsWith('H.265 ') && p.includes(kennung) && p.includes(gewuenscht))
if (treffer !== undefined) return treffer
// 4K-Familie ohne 4K-Preset: das 1080p-Pendant ist besser als CPU.
const kleiner = presets.find((p) => p.startsWith('H.265 ') && p.includes(kennung))
if (kleiner !== undefined) return kleiner
}
return DEFAULT_HB_PRESET
}
+4 -1
View File
@@ -53,6 +53,9 @@ export interface PipelineUmgebung {
/** Wurzel der Ablage (Roh-Ordner und fertige Filme liegen darunter). */
ablage: () => string
einstellungen: () => Einstellungen
/** Gemessene Preset-Empfehlung (Hardware zuerst) für diesen Disc-Typ —
* greift nur, wenn der Nutzer nichts eingestellt hat. */
presetEmpfehlung?: (discTyp: string) => string
melden: (status: RipStatus) => void
/** Die letzte Metadaten-Zuordnung dieses Laufwerks (W-4) — oder null. */
zuordnung?: (id: string) => Zuordnung | null
@@ -204,7 +207,7 @@ export class Pipeline {
return
}
const preset = presetFuer(geraet.typ, einstellungen)
const preset = presetFuer(geraet.typ, einstellungen, this.umgebung.presetEmpfehlung?.(geraet.typ) ?? '')
// Mit Metadaten (W-4) heißt der Ordner „<Titel> (Jahr)" — daran
// erkennen Jellyfin & Co. den Film; ohne bleibt der Vorgangsname.
const zuordnung = this.umgebung.zuordnung?.(id) ?? null
+94
View File
@@ -13,6 +13,7 @@ import {
istHauptNachricht,
type DbStatus,
type KernNachricht,
type MetaVorschlag,
} from '../gemeinsam/nachrichten'
import { Pipeline } from './ablauf/pipeline'
import type { LaufwerkApi } from './laufwerk/codes'
@@ -29,6 +30,7 @@ import { discFingerprint, discTitel } from './metadaten/discmerkmale'
import { OmdbClient } from './metadaten/omdb'
import { TmdbClient } from './metadaten/tmdb'
import { zuordnen, type Zuordnung } from './metadaten/zuordnung'
import { presetEmpfehlung } from '../gemeinsam/preset-empfehlung'
import { Datenbank } from './speicher/db'
import { finden } from './werkzeuge/katalog'
@@ -124,6 +126,85 @@ async function discErmitteln(id: string, geraetepfad: string, groesseBytes: numb
}
}
// § 5 Schritt 4: „nein → fragen" — der Nutzer sucht und WÄHLT selbst.
function jahrAus(datum: string | undefined): number | null {
if (datum === undefined || datum.length < 4) return null
const jahr = Number(datum.slice(0, 4))
return Number.isFinite(jahr) && jahr > 0 ? jahr : null
}
async function metadatenSuchen(id: string, text: string): Promise<void> {
try {
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
if (!tmdb.verfuegbar) {
anFenster({ art: 'kern-fehler', text: 'Metadaten-Suche braucht einen TMDb-Key (Einstellungen → Metadaten).' })
anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege: [] })
return
}
const [filme, serien] = await Promise.all([tmdb.searchMovie(text), tmdb.searchTv(text)])
const vorschlaege: MetaVorschlag[] = [
...(filme ?? []).slice(0, 6).map((f) => ({
typ: 'movie' as const,
tmdbId: f.id,
titel: f.title ?? '',
jahr: jahrAus(f.release_date),
posterPfad: f.poster_path ?? '',
})),
...(serien ?? []).slice(0, 6).map((s) => ({
typ: 'tv' as const,
tmdbId: s.id,
titel: s.name ?? '',
jahr: jahrAus(s.first_air_date),
posterPfad: s.poster_path ?? '',
})),
].filter((v) => v.titel.length > 0)
anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege })
} catch (fehler) {
anFenster({ art: 'kern-fehler', text: `Metadaten-Suche: ${String(fehler)}` })
anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege: [] })
}
}
async function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): Promise<void> {
try {
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
const details =
vorschlag.typ === 'movie' ? await tmdb.movieDetails(vorschlag.tmdbId) : await tmdb.tvDetails(vorschlag.tmdbId)
const zuordnung: Zuordnung = {
titel: (vorschlag.typ === 'movie' ? details?.title : details?.name) ?? vorschlag.titel,
jahr: jahrAus(vorschlag.typ === 'movie' ? details?.release_date : details?.first_air_date) ?? vorschlag.jahr,
// Vom Nutzer gewählt = sicher. Genau dafür ist die Frage da (§ 5).
confidence: 1,
meta: {
typ: vorschlag.typ,
id: vorschlag.tmdbId,
titel: (vorschlag.typ === 'movie' ? details?.title : details?.name) ?? vorschlag.titel,
jahr: jahrAus(vorschlag.typ === 'movie' ? details?.release_date : details?.first_air_date) ?? vorschlag.jahr,
beschreibung: details?.overview ?? '',
posterPfad: details?.poster_path ?? vorschlag.posterPfad,
laufzeitMinuten: details?.runtime ?? 0,
genres: (details?.genres ?? []).map((g) => g.name),
quelle: 'tmdb',
},
}
discZuordnungen.set(id, zuordnung)
anHauptUndFenster({
art: 'disc-info',
info: {
id,
titel: zuordnung.titel,
jahr: zuordnung.jahr,
confidence: 1,
quelle: 'deine Wahl',
posterPfad: zuordnung.meta.posterPfad,
schonGerippt: db?.bibliothekSchonGerippt(discFingerprints.get(id) ?? '') ?? null,
},
})
} catch (fehler) {
anFenster({ art: 'kern-fehler', text: `Zuordnung übernehmen: ${String(fehler)}` })
}
}
const laufwerkeBereit: Promise<void> = (async () => {
try {
if (process.platform !== 'win32') {
@@ -229,6 +310,7 @@ const ripVerwaltung = new Pipeline({
},
ablage: ablageOrdner,
einstellungen: einstellungenLaden,
presetEmpfehlung: (typ) => presetEmpfehlung(typ, werkzeugAuskunft.backends, werkzeugAuskunft.presets),
melden: (status) => anHauptUndFenster({ art: 'rip-status', status }),
zuordnung: (id) => discZuordnungen.get(id) ?? null,
fingerprint: (id) => discFingerprints.get(id) ?? null,
@@ -303,12 +385,17 @@ async function schluesselLauf(): Promise<void> {
// Täglich neu prüfen (§ 9.1) — ohne den Prozess-Exit aufzuhalten.
setInterval(() => void schluesselLauf(), 24 * 60 * 60 * 1000).unref()
// Die gemessene Auskunft bleibt liegen — die Pipeline braucht sie für die
// Preset-Empfehlung, auch wenn gerade kein Fenster offen ist.
let werkzeugAuskunft: { presets: string[]; backends: string[] } = { presets: [], backends: [] }
async function werkzeugeSenden(): Promise<void> {
const makemkvcon = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '')
const handbrake = finden('handbrake', db?.einstellungLesen('werkzeug.handbrake') ?? '')
const flac = finden('flac', db?.einstellungLesen('werkzeug.flac') ?? '')
const { encoderMessen } = await import('./komprimieren/encoder')
const gemessen = await encoderMessen(handbrake)
werkzeugAuskunft = { presets: gemessen.presets, backends: gemessen.backends }
anFenster({
art: 'werkzeuge',
auskunft: { makemkvcon, handbrake, flac, presets: gemessen.presets, backends: gemessen.backends },
@@ -362,6 +449,10 @@ process.parentPort.on('message', (ereignis) => {
void laufwerkeBereit.then(() => ripVerwaltung.starten(frage.id))
} else if (frage.art === 'rip-abbruch') {
ripVerwaltung.abbrechen(frage.id)
} else if (frage.art === 'metadaten-suchen') {
void metadatenSuchen(frage.id, frage.text)
} else if (frage.art === 'zuordnung-setzen') {
void zuordnungSetzen(frage.id, frage.vorschlag)
} else if (frage.art === 'einstellung-setzen') {
if (!ERLAUBTE_EINSTELLUNGEN.has(frage.schluessel)) {
anFenster({ art: 'kern-fehler', text: `Unbekannter Einstellungs-Schlüssel: ${frage.schluessel}` })
@@ -410,4 +501,7 @@ einstellungenSenden()
// der Gerüst-Beweis soll ohne Netz und ohne MakeMKV grün sein.
if (!process.argv.includes('--smoke-kern') && process.env['RIPPY_OHNE_SCHLUESSEL'] !== '1') {
void schluesselLauf()
// Werkzeuge einmal beim Start messen — die Preset-Empfehlung der
// Pipeline braucht Backends und Preset-Liste auch ohne offenes Fenster.
void werkzeugeSenden()
}
+10 -6
View File
@@ -2,7 +2,9 @@
// mit den Befunden vom 25.07.2026 begründet: EIN Preset für alles rechnete
// 4K auf 1080p herunter und DVDs hinauf.
export const DEFAULT_HB_PRESET = 'H.265 MKV 1080p30'
import { DEFAULT_HB_PRESET, presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
export { DEFAULT_HB_PRESET, presetEmpfehlung }
/** Einstellungs-Schlüssel je Disc-Typ. */
export const PRESET_SCHLUESSEL: Record<string, string> = {
@@ -32,17 +34,19 @@ export function komprimierenFuer(discTyp: string, einstellungen: Einstellungen):
}
/** Welches HandBrake-Preset gilt für diesen Disc-Typ? (pur)
* Kette: Typ-Preset → allgemeines transcodePreset → DEFAULT. PRESET_KEINE
* wird ÜBERSPRUNGEN — diese Funktion darf nie „keine" als Preset-Namen
* liefern, sonst bekäme HandBrake `--preset keine` und scheiterte. */
export function presetFuer(discTyp: string, einstellungen: Einstellungen): string {
* Kette: Typ-Preset → allgemeines transcodePreset → EMPFEHLUNG (gemessen,
* Hardware zuerst) → CPU-DEFAULT. PRESET_KEINE wird ÜBERSPRUNGEN — diese
* Funktion darf nie „keine" als Preset-Namen liefern, sonst bekäme
* HandBrake `--preset keine` und scheiterte. */
export function presetFuer(discTyp: string, einstellungen: Einstellungen, empfehlung = ''): string {
const schluessel = PRESET_SCHLUESSEL[discTyp] ?? ''
if (schluessel.length > 0) {
const eigen = String(einstellungen[schluessel] ?? '').trim()
if (eigen.length > 0 && eigen !== PRESET_KEINE) return eigen
}
const allgemein = String(einstellungen['transcodePreset'] ?? '').trim()
return allgemein.length > 0 ? allgemein : DEFAULT_HB_PRESET
if (allgemein.length > 0) return allgemein
return empfehlung.length > 0 ? empfehlung : DEFAULT_HB_PRESET
}
/** „deu, eng , ,DEU" → ["deu","eng"] — HandBrake will ISO-639-2 klein;
+3
View File
@@ -7,6 +7,9 @@ export interface FilmTreffer {
id: number
title?: string
name?: string
poster_path?: string
release_date?: string
first_air_date?: string
}
export interface FilmDetails {
+54 -16
View File
@@ -91,15 +91,24 @@ export interface KeyUrteil {
ok: boolean
code: number | null
grund: string
/** Die MakeMKV-Version aus MSG 1005 („MakeMKV v1.18.4 …") — '' wenn
* nicht erkennbar. */
version: string
}
/** Nimmt MakeMKV den hinterlegten Schlüssel an? — es selbst gefragt:
* `info disc:9999` braucht keinen Datenträger, die Freischaltungs-Meldung
* kommt trotzdem (makemkv_aufruf.py, 29.08.2026). */
* kommt trotzdem (makemkv_aufruf.py, 29.08.2026).
*
* ⚠ ALLE Codes einsammeln, dann urteilen: Beim Commander kamen 5020 UND
* 5021 zusammen (30.08.2026 live gemessen) — 5020 allein klingt nach
* „Schlüssel kaputt", die URSACHE steht aber in 5021: MakeMKV ist zu alt
* für den aktuellen Beta-Key. Wer beim ersten Code abbricht, beschuldigt
* den falschen. */
export function keyPruefen(programm: string, timeoutMs = 120_000): Promise<KeyUrteil> {
return new Promise((fertig) => {
if (programm.length === 0) {
fertig({ ok: false, code: null, grund: 'MakeMKV nicht gefunden' })
fertig({ ok: false, code: null, grund: 'MakeMKV nicht gefunden', version: '' })
return
}
const kind = spawn(programm, ['-r', '--noscan', 'info', 'disc:9999'], {
@@ -112,28 +121,43 @@ export function keyPruefen(programm: string, timeoutMs = 120_000): Promise<KeyUr
const zeiger = setTimeout(() => kind.kill(), timeoutMs)
kind.on('error', (fehler) => {
clearTimeout(zeiger)
fertig({ ok: false, code: null, grund: `MakeMKV nicht befragbar (${String(fehler)})` })
fertig({ ok: false, code: null, grund: `MakeMKV nicht befragbar (${String(fehler)})`, version: '' })
})
kind.on('close', () => {
clearTimeout(zeiger)
for (const zeile of textVon(Buffer.concat(stuecke)).split(/\r?\n/)) {
if (!zeile.startsWith('MSG:')) continue
const code = Number(zeile.slice(4).split(',', 1)[0])
if (!Number.isInteger(code)) continue
if (KEY_ABGELEHNT.includes(code) || code === 5051) {
fertig({ ok: false, code, grund: KRITISCHE_CODES[code] ?? `MSG ${code}` })
return
}
}
fertig({ ok: true, code: null, grund: '' })
fertig(urteilAusAusgabe(textVon(Buffer.concat(stuecke))))
})
})
}
/** Die Ausgabe eines info-Laufs → Urteil (pur, testbar). */
export function urteilAusAusgabe(ausgabe: string): KeyUrteil {
const codes: number[] = []
let version = ''
for (const zeile of ausgabe.split(/\r?\n/)) {
if (!zeile.startsWith('MSG:')) continue
const code = Number(zeile.slice(4).split(',', 1)[0])
if (!Number.isInteger(code)) continue
if (code === 1005) {
const treffer = /MakeMKV v([0-9][0-9.]*)/.exec(zeile)
if (treffer !== null) version = treffer[1]
}
if (KEY_ABGELEHNT.includes(code) || code === 5051) codes.push(code)
}
if (codes.length === 0) return { ok: true, code: null, grund: '', version }
// 5021 schlägt alles: Es benennt die URSACHE (Version zu alt) — 5020
// ist dann nur die Folge und würde den Schlüssel zu Unrecht beschuldigen.
const code = codes.includes(5021) ? 5021 : codes[0]
return { ok: false, code, grund: KRITISCHE_CODES[code] ?? `MSG ${code}`, version }
}
export interface SchluesselStand {
/** 'dauerlizenz' | 'forum' | 'keiner' */
quelle: string
angenommen: boolean | null
/** Laufen Rips trotzdem? (Beta-Modus ohne Key = ja — das ist eine
* WARNUNG mit Handlungsempfehlung, kein Ausfall.) */
weiterBetrieb: boolean
gueltigBis: string
geholtAm: string
text: string
@@ -155,9 +179,12 @@ export async function schluesselKette(
return {
quelle: 'dauerlizenz',
angenommen: urteil.ok,
weiterBetrieb: urteil.ok,
gueltigBis: '',
geholtAm: jetztIso,
text: urteil.ok ? 'Eigener Schlüssel hinterlegt und angenommen.' : `Eigener Schlüssel abgelehnt: ${urteil.grund}`,
text: urteil.ok
? `Eigener Schlüssel hinterlegt und von MakeMKV${urteil.version.length > 0 ? ` ${urteil.version}` : ''} angenommen.`
: `Eigener Schlüssel abgelehnt: ${urteil.grund}`,
}
}
@@ -169,6 +196,7 @@ export async function schluesselKette(
return {
quelle: 'keiner',
angenommen: urteil.ok,
weiterBetrieb: true,
gueltigBis: '',
geholtAm: jetztIso,
text: urteil.ok
@@ -181,15 +209,25 @@ export async function schluesselKette(
const urteil = await keyPruefen(makemkvcon)
if (!urteil.ok) {
// Ein abgelehnter Schlüssel ist schlimmer als keiner — zurücknehmen.
// (Live gemessen 30.08.2026: MIT abgelehntem Key verweigert MakeMKV
// alles; OHNE läuft es im Beta-Modus weiter und rippt.)
keyZuruecknehmen()
}
const versionsText = urteil.version.length > 0 ? ` ${urteil.version}` : ''
return {
quelle: 'forum',
angenommen: urteil.ok,
// Der Zurücknahme-Weg lässt MakeMKV im Beta-Modus weiterlaufen —
// Rips gehen, nur eben ohne Freischaltungs-Komfort.
weiterBetrieb: true,
gueltigBis: forum.gueltigBis,
geholtAm: jetztIso,
text: urteil.ok
? `Beta-Key aus dem Forum hinterlegt${forum.gueltigBis.length > 0 ? ` — gültig bis ${forum.gueltigBis}` : ''}.`
: `Beta-Key abgelehnt (${urteil.grund}) — wieder entfernt. Vermutlich ist MakeMKV zu alt.`,
? `Beta-Key aus dem Forum hinterlegt und von MakeMKV${versionsText} angenommen${forum.gueltigBis.length > 0 ? ` — gültig bis ${forum.gueltigBis}` : ''}.`
: urteil.code === 5021
? `Der aktuelle Beta-Key ist in Ordnung — aber MakeMKV${versionsText} ist ZU ALT für ihn. ` +
'Rippy hat ihn wieder entfernt, damit MakeMKV im Beta-Modus weiterläuft (so klappen Rips trotzdem). ' +
'Abhilfe: MakeMKV von makemkv.com aktualisieren — danach hinterlegt Rippy den Key automatisch neu.'
: `Beta-Key abgelehnt (${urteil.grund}) — wieder entfernt, damit MakeMKV im Beta-Modus weiterläuft.`,
}
}
@@ -0,0 +1,80 @@
// Die drei Funde aus dem ersten Commander-Test (30.08.2026, abends) —
// jeder als Test festgehalten, mit den LIVE gemessenen Fixtures.
import { describe, expect, it } from 'vitest'
import { urteilAusAusgabe } from '../src/kern/werkzeuge/schluessel'
import { presetEmpfehlung, DEFAULT_HB_PRESET } from '../src/gemeinsam/preset-empfehlung'
import { presetFuer } from '../src/kern/komprimieren/presets'
describe('Fund 1 — „Key korrekt, aber als ungültig angezeigt"', () => {
// Die ECHTE makemkvcon-Ausgabe vom Commander-PC (30.08.2026): Der
// aktuelle Forum-Key ist in Ordnung, MakeMKV 1.18.4 ist ZU ALT für ihn
// — es meldet 5020 UND 5021 zusammen.
const ECHTE_AUSGABE = [
'MSG:1005,0,1,"MakeMKV v1.18.4 win(x64-release) gestartet","%1 gestartet","MakeMKV v1.18.4 win(x64-release)"',
'MSG:5020,516,0,"Der hinterlegte Aktivierungsschlüssel ist ungültig. …","…"',
'MSG:5021,131332,1,"Diese Programmversion ist zu alt. Bitte laden Sie die aktuelle Version von http://www.makemkv.com/ herunter.","…","http://www.makemkv.com/"',
].join('\n')
it('5021 schlägt 5020: Die Ursache ist die Version, nicht der Schlüssel', () => {
const urteil = urteilAusAusgabe(ECHTE_AUSGABE)
expect(urteil.ok).toBe(false)
expect(urteil.code).toBe(5021)
expect(urteil.grund).toContain('zu alt')
expect(urteil.version).toBe('1.18.4')
})
it('ohne Key-Meldungen ist alles gut — und die Version kommt trotzdem mit', () => {
const urteil = urteilAusAusgabe(
'MSG:1005,0,1,"MakeMKV v1.18.4 win(x64-release) gestartet","x"\nMSG:5010,0,0,"Das Öffnen der Disk schlug fehl","x"',
)
expect(urteil.ok).toBe(true)
expect(urteil.version).toBe('1.18.4')
})
it('5020 allein bleibt „Schlüssel wird nicht angenommen"', () => {
const urteil = urteilAusAusgabe('MSG:5020,516,0,"…","…"')
expect(urteil.code).toBe(5020)
})
})
describe('Fund 2 — „Komprimieren auf der CPU statt der GPU"', () => {
// Die ECHTE Hardware-Preset-Liste des Commander-HandBrake (30.08.2026)
// plus die gemessenen Backends der RX 9070 XT.
const PRESETS = [
'Very Fast 1080p30',
'H.265 MKV 1080p30',
'AV1 QSV 2160p 4K',
'AV1 VCN 2160p 4K',
'H.265 NVENC 2160p 4K',
'H.265 NVENC 1080p',
'H.265 QSV 2160p 4K',
'H.265 QSV 1080p',
'H.265 VCN 2160p 4K',
'H.265 VCN 1080p',
]
const BACKENDS = ['cpu-x264', 'cpu-x265', 'cpu-av1', 'vce', 'vce-av1']
it('empfiehlt auf dem Commander-PC VCN, nicht CPU', () => {
expect(presetEmpfehlung('bluray', BACKENDS, PRESETS)).toBe('H.265 VCN 1080p')
expect(presetEmpfehlung('dvd', BACKENDS, PRESETS)).toBe('H.265 VCN 1080p')
expect(presetEmpfehlung('uhd', BACKENDS, PRESETS)).toBe('H.265 VCN 2160p 4K')
})
it('NVIDIA-Maschine bekäme NVENC, Intel QSV, reine CPU den Default', () => {
expect(presetEmpfehlung('bluray', ['cpu-x265', 'nvenc'], PRESETS)).toBe('H.265 NVENC 1080p')
expect(presetEmpfehlung('bluray', ['cpu-x265', 'qsv'], PRESETS)).toBe('H.265 QSV 1080p')
expect(presetEmpfehlung('bluray', ['cpu-x264', 'cpu-x265'], PRESETS)).toBe(DEFAULT_HB_PRESET)
})
it('empfohlen wird NUR, was die gemessene Liste hergibt', () => {
// vce da, aber keine VCN-Presets in der Liste → CPU-Default statt
// eines geratenen Namens (die „Invalid preset"-Falle).
expect(presetEmpfehlung('bluray', ['vce'], ['Very Fast 1080p30'])).toBe(DEFAULT_HB_PRESET)
})
it('die Kette bleibt: eigene Wahl schlägt die Empfehlung', () => {
expect(presetFuer('bluray', { transcodePresetBluray: 'Fast 480p30' }, 'H.265 VCN 1080p')).toBe('Fast 480p30')
expect(presetFuer('bluray', {}, 'H.265 VCN 1080p')).toBe('H.265 VCN 1080p')
expect(presetFuer('bluray', {}, '')).toBe(DEFAULT_HB_PRESET)
})
})