From c5dd90cc18b24b592da5a27828ae54091d39d6c2 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Sun, 30 Aug 2026 20:20:10 +0200 Subject: [PATCH] fix(v5): Die drei Funde aus dem ersten Commander-Test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: ' 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 --- rippy-windows/electron.vite.config.ts | 4 +- rippy-windows/src/fenster/App.tsx | 203 +++++++++++++++--- rippy-windows/src/fenster/kernverbindung.ts | 38 +++- rippy-windows/src/gemeinsam/nachrichten.ts | 41 +++- .../src/gemeinsam/preset-empfehlung.ts | 44 ++++ rippy-windows/src/kern/ablauf/pipeline.ts | 5 +- rippy-windows/src/kern/index.ts | 94 ++++++++ .../src/kern/komprimieren/presets.ts | 16 +- rippy-windows/src/kern/metadaten/tmdb.ts | 3 + .../src/kern/werkzeuge/schluessel.ts | 70 ++++-- rippy-windows/test/commander-funde.test.ts | 80 +++++++ 11 files changed, 542 insertions(+), 56 deletions(-) create mode 100644 rippy-windows/src/gemeinsam/preset-empfehlung.ts create mode 100644 rippy-windows/test/commander-funde.test.ts diff --git a/rippy-windows/electron.vite.config.ts b/rippy-windows/electron.vite.config.ts index 7b7a2cb..7873cab 100644 --- a/rippy-windows/electron.vite.config.ts +++ b/rippy-windows/electron.vite.config.ts @@ -24,7 +24,9 @@ function cspNurImBau(): Plugin { transformIndexHtml(html) { return html.replace( '', - `\n `, + // img-src erlaubt TMDb-Poster (Kachel + Ändern-Dialog) — sonst + // bleibt alles bei 'self'. + `\n `, ) }, } diff --git a/rippy-windows/src/fenster/App.tsx b/rippy-windows/src/fenster/App.tsx index 149ffba..d5cd2b1 100644 --- a/rippy-windows/src/fenster/App.tsx +++ b/rippy-windows/src/fenster/App.tsx @@ -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 ( +
+
e.stopPropagation()} + > +

Was ist das wirklich?

+
+ 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" + /> + +
+ {vorschlaege.length === 0 ? ( +

Keine Treffer — anderen Suchtext probieren.

+ ) : ( +
+ {vorschlaege.map((v) => ( + + ))} +
+ )} +
+ +
+
+
+ ) +} + 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({ -
-

{geraet.modell.length > 0 ? geraet.modell : 'Modell unbekannt'}

- {geraet.status === 'ready' && ( -

- {TYP_NAMEN[geraet.typ]} - {geraet.groesseBytes > 0 ? ` · ${gb(geraet.groesseBytes)}` : ''} -

- )} - {geraet.status === 'ready' && info !== undefined && info.titel.length > 0 && ( -

= 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}` : ''} -

- )} - {geraet.status === 'ready' && info !== undefined && info.schonGerippt !== null && ( -

- ⚠ Diese Disc wurde schon einmal gerippt ({info.schonGerippt.slice(0, 10)}) — siehe Bibliothek. -

- )} - {geraet.status === 'empty' &&

keine Disc eingelegt

} - {geraet.status === 'unknown' &&

{geraet.grund}

} - {geraet.status !== 'unknown' && geraet.grund.length > 0 && ( -

{geraet.grund}

+
+ {geraet.status === 'ready' && info !== undefined && info.posterPfad.length > 0 && ( + )} +
+

{geraet.modell.length > 0 ? geraet.modell : 'Modell unbekannt'}

+ {geraet.status === 'ready' && ( +

+ {TYP_NAMEN[geraet.typ]} + {geraet.groesseBytes > 0 ? ` · ${gb(geraet.groesseBytes)}` : ''} +

+ )} + {geraet.status === 'ready' && info !== undefined && info.titel.length > 0 && ( +

= 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}` : ''} +

+ )} + {geraet.status === 'ready' && ( + + )} + {geraet.status === 'ready' && info !== undefined && info.schonGerippt !== null && ( +

+ ⚠ Diese Disc wurde schon einmal gerippt ({info.schonGerippt.slice(0, 10)}) — siehe Bibliothek. +

+ )} + {geraet.status === 'empty' &&

keine Disc eingelegt

} + {geraet.status === 'unknown' &&

{geraet.grund}

} + {geraet.status !== 'unknown' && geraet.grund.length > 0 && ( +

{geraet.grund}

+ )} +
+ {aendernOffen && ( + 0 ? info.titel : ''} + vorschlaege={vorschlaege} + schliessen={() => setAendernOffen(false)} + /> + )} {rip !== undefined && (
{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}

{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" > - + {presets.map((preset) => (
@@ -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 diff --git a/rippy-windows/src/fenster/kernverbindung.ts b/rippy-windows/src/fenster/kernverbindung.ts index fb29775..4b4e208 100644 --- a/rippy-windows/src/fenster/kernverbindung.ts +++ b/rippy-windows/src/fenster/kernverbindung.ts @@ -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 /** Was Rippy über die eingelegte Disc weiß (§ 5 Schritt 3). */ discInfos: Record + /** Suchergebnisse des Ändern-Dialogs je Laufwerk (§ 5 Schritt 4). */ + vorschlaege: Record + /** Wann der laufende Vorgang je Laufwerk begann (für die Restzeit). */ + ripStart: Record einstellungen: Record 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`) diff --git a/rippy-windows/src/gemeinsam/nachrichten.ts b/rippy-windows/src/gemeinsam/nachrichten.ts index 19b0f45..278330a 100644 --- a/rippy-windows/src/gemeinsam/nachrichten.ts +++ b/rippy-windows/src/gemeinsam/nachrichten.ts @@ -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 } | { 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).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' } diff --git a/rippy-windows/src/gemeinsam/preset-empfehlung.ts b/rippy-windows/src/gemeinsam/preset-empfehlung.ts new file mode 100644 index 0000000..90ba9a3 --- /dev/null +++ b/rippy-windows/src/gemeinsam/preset-empfehlung.ts @@ -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 = [ + ['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 +} diff --git a/rippy-windows/src/kern/ablauf/pipeline.ts b/rippy-windows/src/kern/ablauf/pipeline.ts index bcd1c18..302693f 100644 --- a/rippy-windows/src/kern/ablauf/pipeline.ts +++ b/rippy-windows/src/kern/ablauf/pipeline.ts @@ -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 „ (Jahr)" — daran // erkennen Jellyfin & Co. den Film; ohne bleibt der Vorgangsname. const zuordnung = this.umgebung.zuordnung?.(id) ?? null diff --git a/rippy-windows/src/kern/index.ts b/rippy-windows/src/kern/index.ts index e05f23c..5358302 100644 --- a/rippy-windows/src/kern/index.ts +++ b/rippy-windows/src/kern/index.ts @@ -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 { + 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 { + 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 = (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 { // 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 { 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() } diff --git a/rippy-windows/src/kern/komprimieren/presets.ts b/rippy-windows/src/kern/komprimieren/presets.ts index 099fa53..fa304c3 100644 --- a/rippy-windows/src/kern/komprimieren/presets.ts +++ b/rippy-windows/src/kern/komprimieren/presets.ts @@ -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 = { @@ -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; diff --git a/rippy-windows/src/kern/metadaten/tmdb.ts b/rippy-windows/src/kern/metadaten/tmdb.ts index f3e18d1..44c3841 100644 --- a/rippy-windows/src/kern/metadaten/tmdb.ts +++ b/rippy-windows/src/kern/metadaten/tmdb.ts @@ -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 { diff --git a/rippy-windows/src/kern/werkzeuge/schluessel.ts b/rippy-windows/src/kern/werkzeuge/schluessel.ts index 4c32425..728298c 100644 --- a/rippy-windows/src/kern/werkzeuge/schluessel.ts +++ b/rippy-windows/src/kern/werkzeuge/schluessel.ts @@ -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 { 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 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.`, } } diff --git a/rippy-windows/test/commander-funde.test.ts b/rippy-windows/test/commander-funde.test.ts new file mode 100644 index 0000000..d79ea60 --- /dev/null +++ b/rippy-windows/test/commander-funde.test.ts @@ -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) + }) +})