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
+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`)