diff --git a/beweise/ui-520-uebersicht.png b/beweise/ui-520-uebersicht.png new file mode 100644 index 0000000..b344020 Binary files /dev/null and b/beweise/ui-520-uebersicht.png differ diff --git a/rippy-windows/bau/release-notes.md b/rippy-windows/bau/release-notes.md index 248a8d8..0a768e6 100644 --- a/rippy-windows/bau/release-notes.md +++ b/rippy-windows/bau/release-notes.md @@ -1,28 +1,29 @@ -Version 5.1.5 +Version 5.2.0 -Der letzte Rand ist weg. -Windows 11 hat um das rahmenlose Fenster noch eine feine Linie gezeichnet -— die gehört nicht zum Programm, sondern zum Fenstermanager. Rippy sagt -Windows jetzt, dass es sie weglassen soll. +Du wählst jetzt aus, was gerippt wird. +Bisher hat Rippy immer ALLES von der Disc geholt — auch Trailer, Werbung +und Alterskennzeichen. Ein Klick auf "Rippen" liest jetzt erst die Titel +der Disc (das dauert bis zu zwei Minuten, solange dreht das Laufwerk) und +zeigt sie dir mit Dauer, Größe und Kapitelzahl. -Rippy zeigt beim Einrichten, was deine Maschine kann. -Rippy misst schon immer die Encoder deiner Grafikkarte und wählt danach -von selbst das beste Preset — nur sah man das nirgends. Die Erst- -Einrichtung zeigt jetzt die gemessenen Encoder und was Rippy für DVD, -Blu-ray und 4K daraus macht. +Rippy macht dabei einen Vorschlag. +Bei einer Serie hakt es so viele Titel an, wie die Disc Folgen nennt — +und sagt dazu, ob die Zahl passt. Bei einem Film den längsten Titel. Zwei +Fallen kennt es: einen Sammeltitel, der alle Folgen am Stück enthält, +wählt es NICHT vor; und wenn mehrere Titel fast gleich lang sind, sagt es +offen, dass es unsicher ist, statt sich stumm zu entscheiden. -Die Erst-Einrichtung wurde überarbeitet. -Sie sagte "vier Dinge" und stellte zwei ein. Jetzt sind es sechs -Schritte und drei Fragen: wohin, wie schnell, woher die Titel. Neu dabei: -der TMDb-Schlüssel gleich beim Einrichten statt versteckt, und der -Ablage-Ordner zeigt die Vorgabe an, statt leer zu bleiben. - -Die Update-Adresse ist aus den Einstellungen verschwunden. -Updates kommen ohnehin immer von derselben Stelle. +Ein Defekt auf der Disc kostet nicht mehr alles. +Rippy rippt jetzt Titel für Titel. Merkt es, dass MakeMKV immer wieder an +derselben Stelle hängt, sagt es das im Klartext und bietet an, diesen +einen Titel zu überspringen — der Rest der Disc läuft weiter. Vorher riss +ein kaputter Sektor den ganzen Durchlauf mit, und im Protokoll rauschte +stundenlang dieselbe Zeile durch. +Aus 5.1.5: Windows-Rand weg, Erst-Einrichtung überarbeitet. Aus 5.1.4: Das Update installiert sich wirklich. -Aus 5.1.3: Eigenes Fenster, Layout waechst mit. +Aus 5.1.3: Eigenes Fenster, Layout wächst mit. Aus 5.1.2: Rippy sagt Bescheid, wenn ein Update da ist. Aus 5.1.1: Serien-Discs werden richtig erkannt. -Unveraendert: Updates warten immer, bis kein Rip mehr laeuft. +Unverändert: Updates warten immer, bis kein Rip mehr läuft. diff --git a/rippy-windows/package-lock.json b/rippy-windows/package-lock.json index aeacb83..de0804d 100644 --- a/rippy-windows/package-lock.json +++ b/rippy-windows/package-lock.json @@ -1,12 +1,12 @@ { "name": "rippy", - "version": "5.1.5", + "version": "5.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rippy", - "version": "5.1.5", + "version": "5.2.0", "license": "UNLICENSED", "dependencies": { "electron-updater": "^6.8.9", diff --git a/rippy-windows/package.json b/rippy-windows/package.json index 8ac3e28..1eb5217 100644 --- a/rippy-windows/package.json +++ b/rippy-windows/package.json @@ -1,7 +1,7 @@ { "name": "rippy", "productName": "Rippy", - "version": "5.1.5", + "version": "5.2.0", "description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)", "main": "out/haupt/index.js", "author": "KrBrZ", diff --git a/rippy-windows/src/fenster/App.tsx b/rippy-windows/src/fenster/App.tsx index 0bae9d7..b9ea656 100644 --- a/rippy-windows/src/fenster/App.tsx +++ b/rippy-windows/src/fenster/App.tsx @@ -8,7 +8,7 @@ // über den Kern (§ 6.7), R2 (nichts verschwindet ohne Auskunft), R4 // (Fehler stehen im Fenster — bei Systemfehlern als rote Karte). import { useEffect, useState } from 'react' -import type { DiscInfoStand, GeraeteInfo, HauptStatus, RipStatus } from '../gemeinsam/nachrichten' +import type { DiscInfoStand, GeraeteInfo, HauptStatus, RipStatus, TitelStand } from '../gemeinsam/nachrichten' import { DEFAULT_HB_PRESET, presetEmpfehlung } from '../gemeinsam/preset-empfehlung' import { aufVerbindung, @@ -18,7 +18,9 @@ import { metadatenSuchen, ripAbbrechen, ripStarten, + ripUeberspringen, schluesselPruefen, + titelLesen, verbindungStarten, zuordnungSetzen, type Verbindung, @@ -352,6 +354,141 @@ function AendernDialog({ // Die Bühne: Backdrop als Panorama, Poster mit Goldkante, Plakat-Titel — // und beim Arbeiten die Aussteuerungsanzeige samt Mono-Protokoll. + +// ── Auswahl: WAS soll gerippt werden? (§ 6.2) ─────────────────────────── +// Bis zum 01.09.2026 rippte Rippy IMMER alles — Trailer, Werbung und +// Alterskennzeichen inklusive. Der Info-Lauf dauert 20–120 s, deshalb +// beginnt er erst hier und nicht schon beim Einlegen. +function dauerText(s: number): string { + const m = Math.floor(s / 60) + return m >= 60 + ? `${Math.floor(m / 60)}:${String(m % 60).padStart(2, '0')} h` + : `${m}:${String(Math.round(s % 60)).padStart(2, '0')} min` +} + +function TitelWahlDialog({ + id, + stand, + schliessen, +}: { + id: string + stand: TitelStand | undefined + schliessen: () => void +}) { + const [gewaehlt, setGewaehlt] = useState | null>(null) + // Rippys Vorauswahl gilt, bis der Nutzer sie anfasst. + const aktiv = gewaehlt ?? new Set((stand?.titel ?? []).filter((t) => t.gewaehlt).map((t) => t.nr)) + const umschalten = (nr: number): void => { + const neu = new Set(aktiv) + if (neu.has(nr)) neu.delete(nr) + else neu.add(nr) + setGewaehlt(neu) + } + const alle = stand?.titel ?? [] + const gesamtBytes = alle.filter((t) => aktiv.has(t.nr)).reduce((s, t) => s + t.groesseBytes, 0) + + return ( +
+
e.stopPropagation()} + > +

Was soll gerippt werden?

+ + {stand === undefined || stand.laeuft ? ( +

+ Rippy liest die Titel der Disc … das dauert bis zu zwei Minuten. Solange dreht das Laufwerk. +

+ ) : stand.fehler.length > 0 ? ( +

✗ {stand.fehler}

+ ) : ( + <> +

+ {stand.unsicher ? '⚠ ' : ''} + {stand.einordnung} +

+ +
+ + + +
+ +
+ {alle.map((t) => ( + + ))} + {alle.length === 0 && ( +

Die Disc gab keine Titel her.

+ )} +
+ +

+ {aktiv.size} von {alle.length} gewählt · zusammen {gb(gesamtBytes)} +

+ + )} + +
+ + +
+
+
+ ) +} + function LaufwerkKachel({ geraet, rip, @@ -360,6 +497,7 @@ function LaufwerkKachel({ ripStartMs, protokoll, verbindung, + titelStand, }: { geraet: GeraeteInfo rip: RipStatus | undefined @@ -368,8 +506,10 @@ function LaufwerkKachel({ ripStartMs: number | undefined protokoll: string[] verbindung: Verbindung + titelStand: TitelStand | undefined }) { const [aendernOffen, setAendernOffen] = useState(false) + const [titelOffen, setTitelOffen] = useState(false) const [protokollOffen, setProtokollOffen] = useState(false) const ripLaeuft = rip !== undefined && LAUFENDE_PHASEN.includes(rip.phase) const discDa = geraet.status === 'ready' @@ -416,10 +556,14 @@ function LaufwerkKachel({ ) : ( + + oder weiter versuchen — abbrechen geht oben. + + + + )} +

> {rip.text}

@@ -587,6 +766,10 @@ function LaufwerkKachel({ )} + {titelOffen && ( + setTitelOffen(false)} /> + )} + {aendernOffen && ( ))} diff --git a/rippy-windows/src/fenster/kernverbindung.ts b/rippy-windows/src/fenster/kernverbindung.ts index d7f5ba4..017adec 100644 --- a/rippy-windows/src/fenster/kernverbindung.ts +++ b/rippy-windows/src/fenster/kernverbindung.ts @@ -1,300 +1,319 @@ -// Hält den EINEN MessagePort zum Kern — bewusst außerhalb von React: -// Die Port-Übergabe vom Haupt passiert genau einmal je Fenster-Ladung; -// React-Re-Mounts (StrictMode im Dev, HMR) dürfen sie nicht verlieren. -// -// R2 gilt auch hier: Der letzte bekannte Stand bleibt stehen und wird -// jedem neuen Abonnenten sofort wiedergegeben — er wird nie auf »leer« -// zurückgesetzt, nur weil gerade keine Nachricht kam. Insbesondere die -// Laufwerksliste: Sie ändert sich NUR durch eine neue Liste vom Kern. -import { - istKernNachricht, - type BeschaffungStand, - type BibliothekEintrag, - type DiscInfoStand, - type GeraeteInfo, - type KernNachricht, - type MetaVorschlag, - type RipStatus, - type WerkzeugAuskunft, -} from '../gemeinsam/nachrichten' - -export interface Verbindung { - verbunden: boolean - kern: Extract | null - pongLatenzMs: number | null - laufwerke: GeraeteInfo[] - /** Letzter Rip-Stand je Laufwerks-Kennung. Bleibt nach dem Ende stehen, - * bis ein neuer Rip startet (R2: kein Verschwinden ohne neue Auskunft). */ - 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 - /** Statuszeilen des (letzten) Vorgangs je Laufwerk — das aufklappbare - * Protokoll der Kachel. Bleibt nach dem Ende stehen (R2), bis ein neuer - * Vorgang beginnt. */ - ripProtokoll: Record - einstellungen: Record - werkzeuge: WerkzeugAuskunft | null - bibliothek: BibliothekEintrag[] - schluessel: { - quelle: string - angenommen: boolean | null - weiterBetrieb: boolean - gueltigBis: string - geholtAm: string - text: string - version: string - } | null - /** Stand der MakeMKV-Beschaffung (§ 9) — null, solange nie eine lief. */ - beschaffung: BeschaffungStand | null - /** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */ - meldungen: string[] -} - -type Horcher = (stand: Verbindung) => void - -const MAX_MELDUNGEN = 20 - -const stand: Verbindung = { - verbunden: false, - kern: null, - pongLatenzMs: null, - laufwerke: [], - rips: {}, - discInfos: {}, - vorschlaege: {}, - ripStart: {}, - ripProtokoll: {}, - einstellungen: {}, - werkzeuge: null, - bibliothek: [], - schluessel: null, - beschaffung: null, - meldungen: [], -} -const horcher = new Set() -let gestartet = false -let port: MessagePort | null = null - -function melden(): void { - for (const h of horcher) { - h({ - ...stand, - laufwerke: [...stand.laufwerke], - rips: { ...stand.rips }, - discInfos: { ...stand.discInfos }, - vorschlaege: { ...stand.vorschlaege }, - einstellungen: { ...stand.einstellungen }, - bibliothek: [...stand.bibliothek], - meldungen: [...stand.meldungen], - }) - } -} - -function meldung(text: string): void { - stand.meldungen = [text, ...stand.meldungen].slice(0, MAX_MELDUNGEN) -} - -function kernNachricht(nachricht: KernNachricht): void { - switch (nachricht.art) { - case 'kern-bereit': - stand.kern = nachricht - break - case 'pong': - stand.pongLatenzMs = Math.max(0, Date.now() - nachricht.zeit) - // Der Beweis für den Smoke-Lauf: Ping ging hin, Pong kam zurück. - window.rippy.melden('kern-pong') - break - case 'kern-fehler': - meldung(`⚠ ${nachricht.text}`) - break - case 'laufwerke': - stand.laufwerke = nachricht.liste - for (const ereignis of nachricht.ereignisse) { - if (ereignis.art === 'disc-eingelegt') { - meldung(`Disc erkannt in ${ereignis.geraet.name} — ${ereignis.geraet.typ}`) - } else if (ereignis.art === 'disc-entnommen') { - meldung(`Disc entnommen aus ${ereignis.geraet.name}`) - } else if (ereignis.art === 'laufwerk-weg') { - meldung(`Laufwerk ${ereignis.id}: nicht mehr da`) - } - } - break - case 'auswurf-ergebnis': { - const text = - nachricht.grund === 'ok' - ? `Laufwerk ${nachricht.id}: ausgeworfen (nachgesehen)` - : `Laufwerk ${nachricht.id}: Auswurf ${nachricht.grund}${nachricht.fehler !== null ? ` — ${nachricht.fehler}` : ''}` - meldung(nachricht.grund === 'ok' ? text : `⚠ ${text}`) - break - } - case 'disc-info': { - stand.discInfos = { ...stand.discInfos, [nachricht.info.id]: nachricht.info } - if (nachricht.info.titel.length > 0) { - meldung( - `Laufwerk ${nachricht.info.id}: erkannt als „${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''}“ · ${Math.round(nachricht.info.confidence * 100)} %`, - ) - } - break - } - case 'metadaten-vorschlaege': - stand.vorschlaege = { ...stand.vorschlaege, [nachricht.id]: nachricht.vorschlaege } - break - case 'einstellungen': - stand.einstellungen = nachricht.werte - break - case 'werkzeuge': - stand.werkzeuge = nachricht.auskunft - break - case 'bibliothek': - stand.bibliothek = nachricht.eintraege - break - case 'schluessel-status': - stand.schluessel = nachricht.stand - if (nachricht.stand.angenommen === false) meldung(`⚠ MakeMKV-Schlüssel: ${nachricht.stand.text}`) - break - case 'beschaffung-status': - stand.beschaffung = nachricht.stand - if (nachricht.stand.fehler) meldung(`⚠ MakeMKV-Update: ${nachricht.stand.text}`) - 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() } - // Ein NEUER Vorgang beginnt — das Protokoll des alten weicht. - stand.ripProtokoll = { ...stand.ripProtokoll, [nachricht.status.id]: [] } - } else if (!laeuft) { - const { [nachricht.status.id]: _weg, ...rest } = stand.ripStart - stand.ripStart = rest - } - // Jede NEUE Statuszeile wandert ins Kachel-Protokoll (Prozent-Ticks - // wiederholen den Text — nur echte Änderungen zählen). - const bisher = stand.ripProtokoll[nachricht.status.id] ?? [] - if (nachricht.status.text.length > 0 && bisher[bisher.length - 1] !== nachricht.status.text) { - stand.ripProtokoll = { - ...stand.ripProtokoll, - [nachricht.status.id]: [...bisher, nachricht.status.text].slice(-80), - } - } - if (nachricht.status.phase === 'fertig') { - meldung( - `Laufwerk ${nachricht.status.id}: Rip fertig — ${nachricht.status.dateien.length} Datei(en)` + - (nachricht.status.lesefehler ? ' ⚠ mit Lesefehlern' : ''), - ) - } else if (nachricht.status.phase === 'fehler') { - meldung(`⚠ Laufwerk ${nachricht.status.id}: ${nachricht.status.text}`) - } else if (nachricht.status.phase === 'abgebrochen') { - meldung(`Laufwerk ${nachricht.status.id}: Rip abgebrochen`) - } - break - } - } - melden() -} - -function portAnnehmen(neuerPort: MessagePort): void { - port = neuerPort - stand.verbunden = true - neuerPort.onmessage = (ereignis: MessageEvent) => { - const nachricht: unknown = ereignis.data - if (istKernNachricht(nachricht)) kernNachricht(nachricht) - } - neuerPort.start() - neuerPort.postMessage({ art: 'ping', zeit: Date.now() }) - neuerPort.postMessage({ art: 'einstellungen-laden' }) - neuerPort.postMessage({ art: 'werkzeuge-laden' }) - neuerPort.postMessage({ art: 'bibliothek-laden' }) - melden() -} - -/** Idempotent — der erste Aufruf registriert den Horcher und meldet der - * Brücke, dass gepufferte Ports jetzt zugestellt werden können. */ -export function verbindungStarten(): void { - if (gestartet) return - gestartet = true - window.addEventListener('message', (ereignis: MessageEvent) => { - const daten: unknown = ereignis.data - if ( - typeof daten === 'object' && - daten !== null && - (daten as Record).art === 'kern-port' && - ereignis.ports.length > 0 - ) { - portAnnehmen(ereignis.ports[0]) - } - }) - window.rippy.bereit() -} - -export function aufVerbindung(h: Horcher): () => void { - horcher.add(h) - h({ - ...stand, - laufwerke: [...stand.laufwerke], - rips: { ...stand.rips }, - discInfos: { ...stand.discInfos }, - vorschlaege: { ...stand.vorschlaege }, - einstellungen: { ...stand.einstellungen }, - bibliothek: [...stand.bibliothek], - meldungen: [...stand.meldungen], - }) - return () => horcher.delete(h) -} - -function anKern(nachricht: { art: string; id: string }, wasWar: string): void { - if (port === null) { - meldung(`⚠ ${wasWar}: keine Verbindung zum Kern`) - melden() - return - } - port.postMessage(nachricht) -} - -export function auswerfen(id: string): void { - anKern({ art: 'auswerfen', id }, `Auswerfen ${id}`) -} - -export function ripStarten(id: string): void { - anKern({ art: 'rip-start', id }, `Rip ${id}`) -} - -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 makemkvUpdate(): void { - if (port === null) { - meldung('⚠ MakeMKV-Update: keine Verbindung zum Kern') - melden() - return - } - port.postMessage({ art: 'makemkv-update' }) -} - -export function schluesselPruefen(): void { - if (port === null) return - port.postMessage({ art: 'schluessel-pruefen' }) -} - -export function einstellungSetzen(schluessel: string, wert: string): void { - if (port === null) { - meldung(`⚠ Einstellung ${schluessel}: keine Verbindung zum Kern`) - melden() - return - } - port.postMessage({ art: 'einstellung-setzen', schluessel, wert }) -} +// Hält den EINEN MessagePort zum Kern — bewusst außerhalb von React: +// Die Port-Übergabe vom Haupt passiert genau einmal je Fenster-Ladung; +// React-Re-Mounts (StrictMode im Dev, HMR) dürfen sie nicht verlieren. +// +// R2 gilt auch hier: Der letzte bekannte Stand bleibt stehen und wird +// jedem neuen Abonnenten sofort wiedergegeben — er wird nie auf »leer« +// zurückgesetzt, nur weil gerade keine Nachricht kam. Insbesondere die +// Laufwerksliste: Sie ändert sich NUR durch eine neue Liste vom Kern. +import { + istKernNachricht, + type BeschaffungStand, + type BibliothekEintrag, + type DiscInfoStand, + type GeraeteInfo, + type KernNachricht, + type MetaVorschlag, + type TitelStand, + type RipStatus, + type WerkzeugAuskunft, +} from '../gemeinsam/nachrichten' + +export interface Verbindung { + verbunden: boolean + kern: Extract | null + pongLatenzMs: number | null + laufwerke: GeraeteInfo[] + /** Letzter Rip-Stand je Laufwerks-Kennung. Bleibt nach dem Ende stehen, + * bis ein neuer Rip startet (R2: kein Verschwinden ohne neue Auskunft). */ + 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 + /** Die Titel der Disc je Laufwerk (§ 6.2) — Ergebnis des Info-Laufs. */ + discTitel: Record + /** Wann der laufende Vorgang je Laufwerk begann (für die Restzeit). */ + ripStart: Record + /** Statuszeilen des (letzten) Vorgangs je Laufwerk — das aufklappbare + * Protokoll der Kachel. Bleibt nach dem Ende stehen (R2), bis ein neuer + * Vorgang beginnt. */ + ripProtokoll: Record + einstellungen: Record + werkzeuge: WerkzeugAuskunft | null + bibliothek: BibliothekEintrag[] + schluessel: { + quelle: string + angenommen: boolean | null + weiterBetrieb: boolean + gueltigBis: string + geholtAm: string + text: string + version: string + } | null + /** Stand der MakeMKV-Beschaffung (§ 9) — null, solange nie eine lief. */ + beschaffung: BeschaffungStand | null + /** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */ + meldungen: string[] +} + +type Horcher = (stand: Verbindung) => void + +const MAX_MELDUNGEN = 20 + +const stand: Verbindung = { + verbunden: false, + kern: null, + pongLatenzMs: null, + laufwerke: [], + rips: {}, + discInfos: {}, + discTitel: {}, + vorschlaege: {}, + ripStart: {}, + ripProtokoll: {}, + einstellungen: {}, + werkzeuge: null, + bibliothek: [], + schluessel: null, + beschaffung: null, + meldungen: [], +} +const horcher = new Set() +let gestartet = false +let port: MessagePort | null = null + +function melden(): void { + for (const h of horcher) { + h({ + ...stand, + laufwerke: [...stand.laufwerke], + rips: { ...stand.rips }, + discInfos: { ...stand.discInfos }, + vorschlaege: { ...stand.vorschlaege }, + einstellungen: { ...stand.einstellungen }, + bibliothek: [...stand.bibliothek], + meldungen: [...stand.meldungen], + }) + } +} + +function meldung(text: string): void { + stand.meldungen = [text, ...stand.meldungen].slice(0, MAX_MELDUNGEN) +} + +function kernNachricht(nachricht: KernNachricht): void { + switch (nachricht.art) { + case 'kern-bereit': + stand.kern = nachricht + break + case 'pong': + stand.pongLatenzMs = Math.max(0, Date.now() - nachricht.zeit) + // Der Beweis für den Smoke-Lauf: Ping ging hin, Pong kam zurück. + window.rippy.melden('kern-pong') + break + case 'kern-fehler': + meldung(`⚠ ${nachricht.text}`) + break + case 'laufwerke': + stand.laufwerke = nachricht.liste + for (const ereignis of nachricht.ereignisse) { + if (ereignis.art === 'disc-eingelegt') { + meldung(`Disc erkannt in ${ereignis.geraet.name} — ${ereignis.geraet.typ}`) + } else if (ereignis.art === 'disc-entnommen') { + meldung(`Disc entnommen aus ${ereignis.geraet.name}`) + } else if (ereignis.art === 'laufwerk-weg') { + meldung(`Laufwerk ${ereignis.id}: nicht mehr da`) + } + } + break + case 'auswurf-ergebnis': { + const text = + nachricht.grund === 'ok' + ? `Laufwerk ${nachricht.id}: ausgeworfen (nachgesehen)` + : `Laufwerk ${nachricht.id}: Auswurf ${nachricht.grund}${nachricht.fehler !== null ? ` — ${nachricht.fehler}` : ''}` + meldung(nachricht.grund === 'ok' ? text : `⚠ ${text}`) + break + } + case 'disc-info': { + stand.discInfos = { ...stand.discInfos, [nachricht.info.id]: nachricht.info } + if (nachricht.info.titel.length > 0) { + meldung( + `Laufwerk ${nachricht.info.id}: erkannt als „${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''}“ · ${Math.round(nachricht.info.confidence * 100)} %`, + ) + } + break + } + case 'disc-titel': + stand.discTitel = { ...stand.discTitel, [nachricht.stand.id]: nachricht.stand } + if (nachricht.stand.fehler.length > 0) meldung(`⚠ Titel lesen: ${nachricht.stand.fehler}`) + break + case 'metadaten-vorschlaege': + stand.vorschlaege = { ...stand.vorschlaege, [nachricht.id]: nachricht.vorschlaege } + break + case 'einstellungen': + stand.einstellungen = nachricht.werte + break + case 'werkzeuge': + stand.werkzeuge = nachricht.auskunft + break + case 'bibliothek': + stand.bibliothek = nachricht.eintraege + break + case 'schluessel-status': + stand.schluessel = nachricht.stand + if (nachricht.stand.angenommen === false) meldung(`⚠ MakeMKV-Schlüssel: ${nachricht.stand.text}`) + break + case 'beschaffung-status': + stand.beschaffung = nachricht.stand + if (nachricht.stand.fehler) meldung(`⚠ MakeMKV-Update: ${nachricht.stand.text}`) + 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() } + // Ein NEUER Vorgang beginnt — das Protokoll des alten weicht. + stand.ripProtokoll = { ...stand.ripProtokoll, [nachricht.status.id]: [] } + } else if (!laeuft) { + const { [nachricht.status.id]: _weg, ...rest } = stand.ripStart + stand.ripStart = rest + } + // Jede NEUE Statuszeile wandert ins Kachel-Protokoll (Prozent-Ticks + // wiederholen den Text — nur echte Änderungen zählen). + const bisher = stand.ripProtokoll[nachricht.status.id] ?? [] + if (nachricht.status.text.length > 0 && bisher[bisher.length - 1] !== nachricht.status.text) { + stand.ripProtokoll = { + ...stand.ripProtokoll, + [nachricht.status.id]: [...bisher, nachricht.status.text].slice(-80), + } + } + if (nachricht.status.phase === 'fertig') { + meldung( + `Laufwerk ${nachricht.status.id}: Rip fertig — ${nachricht.status.dateien.length} Datei(en)` + + (nachricht.status.lesefehler ? ' ⚠ mit Lesefehlern' : ''), + ) + } else if (nachricht.status.phase === 'fehler') { + meldung(`⚠ Laufwerk ${nachricht.status.id}: ${nachricht.status.text}`) + } else if (nachricht.status.phase === 'abgebrochen') { + meldung(`Laufwerk ${nachricht.status.id}: Rip abgebrochen`) + } + break + } + } + melden() +} + +function portAnnehmen(neuerPort: MessagePort): void { + port = neuerPort + stand.verbunden = true + neuerPort.onmessage = (ereignis: MessageEvent) => { + const nachricht: unknown = ereignis.data + if (istKernNachricht(nachricht)) kernNachricht(nachricht) + } + neuerPort.start() + neuerPort.postMessage({ art: 'ping', zeit: Date.now() }) + neuerPort.postMessage({ art: 'einstellungen-laden' }) + neuerPort.postMessage({ art: 'werkzeuge-laden' }) + neuerPort.postMessage({ art: 'bibliothek-laden' }) + melden() +} + +/** Idempotent — der erste Aufruf registriert den Horcher und meldet der + * Brücke, dass gepufferte Ports jetzt zugestellt werden können. */ +export function verbindungStarten(): void { + if (gestartet) return + gestartet = true + window.addEventListener('message', (ereignis: MessageEvent) => { + const daten: unknown = ereignis.data + if ( + typeof daten === 'object' && + daten !== null && + (daten as Record).art === 'kern-port' && + ereignis.ports.length > 0 + ) { + portAnnehmen(ereignis.ports[0]) + } + }) + window.rippy.bereit() +} + +export function aufVerbindung(h: Horcher): () => void { + horcher.add(h) + h({ + ...stand, + laufwerke: [...stand.laufwerke], + rips: { ...stand.rips }, + discInfos: { ...stand.discInfos }, + vorschlaege: { ...stand.vorschlaege }, + einstellungen: { ...stand.einstellungen }, + bibliothek: [...stand.bibliothek], + meldungen: [...stand.meldungen], + }) + return () => horcher.delete(h) +} + +function anKern(nachricht: { art: string; id: string; titel?: number[] }, wasWar: string): void { + if (port === null) { + meldung(`⚠ ${wasWar}: keine Verbindung zum Kern`) + melden() + return + } + port.postMessage(nachricht) +} + +export function auswerfen(id: string): void { + anKern({ art: 'auswerfen', id }, `Auswerfen ${id}`) +} + +/** Den Info-Lauf anstoßen (20–120 s) — erst auf Zuruf, nie beim Einlegen. */ +export function titelLesen(id: string): void { + anKern({ art: 'titel-lesen', id }, `Titel lesen ${id}`) +} + +/** `titel` leer heißt: alles rippen. */ +export function ripStarten(id: string, titel: number[] = []): void { + anKern({ art: 'rip-start', id, titel }, `Rip ${id}`) +} + +/** Den laufenden Titel aufgeben und mit dem nächsten weitermachen. */ +export function ripUeberspringen(id: string): void { + anKern({ art: 'rip-ueberspringen', id }, `Titel überspringen ${id}`) +} + +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 makemkvUpdate(): void { + if (port === null) { + meldung('⚠ MakeMKV-Update: keine Verbindung zum Kern') + melden() + return + } + port.postMessage({ art: 'makemkv-update' }) +} + +export function schluesselPruefen(): void { + if (port === null) return + port.postMessage({ art: 'schluessel-pruefen' }) +} + +export function einstellungSetzen(schluessel: string, wert: string): void { + if (port === null) { + meldung(`⚠ Einstellung ${schluessel}: keine Verbindung zum Kern`) + melden() + return + } + port.postMessage({ art: 'einstellung-setzen', schluessel, wert }) +} diff --git a/rippy-windows/src/gemeinsam/nachrichten.ts b/rippy-windows/src/gemeinsam/nachrichten.ts index 2ece012..4ee50ab 100644 --- a/rippy-windows/src/gemeinsam/nachrichten.ts +++ b/rippy-windows/src/gemeinsam/nachrichten.ts @@ -57,6 +57,40 @@ export interface RipStatus { text: string dateien: string[] lesefehler: boolean + /** Der wievielte gewählte Titel läuft gerade (1-basiert) und wie viele es + * insgesamt sind — Rippy rippt Titel für Titel. Fehlt bei Audio-CD, ISO + * und in Fehler-Meldungen; die kennen keine Titel. */ + titelNr?: number + titelGesamt?: number + /** Der Lauf klemmt an einer festen Stelle der Disc — ein physischer + * Defekt, kein Fortschrittsproblem. Fehlt/null heißt: alles in Ordnung. */ + haengt?: { offset: string; wiederholungen: number } | null +} + +/** Ein Titel der Disc, wie ihn der Auswahl-Dialog zeigt (§ 6.2). */ +export interface DiscTitelVorschlag { + nr: number + dauerS: number + groesseBytes: number + kapitel: number + /** Von Rippy vorausgewählt. */ + gewaehlt: boolean + /** Warum — Klartext („Folge", „Sammeltitel …", „zu kurz …"). */ + grund: string +} + +/** Was der Info-Lauf über die Titel der Disc ergab. Er dauert 20–120 s, + * deshalb trägt der Stand ein eigenes `laeuft` — das Fenster steht in der + * Zeit nicht stumm da (R2: nichts ohne Auskunft). */ +export interface TitelStand { + id: string + laeuft: boolean + titel: DiscTitelVorschlag[] + /** Einordnung („Serie: 2 Folgen erwartet, 2 gefunden — passt."). */ + einordnung: string + /** Rippy ist unsicher — das Fenster fragt dann deutlicher nach. */ + unsicher: boolean + fehler: string } /** Was Rippy über die eingelegte Disc herausgefunden hat (§ 5 Schritt 3). */ @@ -136,6 +170,7 @@ export type KernNachricht = | { art: 'auswurf-ergebnis'; id: string; grund: AuswurfGrund; fehler: string | null } | { art: 'rip-status'; status: RipStatus } | { art: 'disc-info'; info: DiscInfoStand } + | { art: 'disc-titel'; stand: TitelStand } | { art: 'metadaten-vorschlaege'; id: string; vorschlaege: MetaVorschlag[] } | { art: 'einstellungen'; werte: Record } | { art: 'werkzeuge'; auskunft: WerkzeugAuskunft } @@ -163,8 +198,16 @@ export type KernNachricht = export type FensterNachricht = | { art: 'ping'; zeit: number } | { art: 'auswerfen'; id: string } - | { art: 'rip-start'; id: string } + /** Titel-Lauf anstoßen (§ 6.2): dauert 20–120 s, deshalb erst auf Zuruf + * und nicht schon beim Einlegen (der 30.08.-Fund „das Erkennen dauert + * sehr sehr lange"). */ + | { art: 'titel-lesen'; id: string } + /** `titel` leer heißt: alles rippen — wie vor 5.2. */ + | { art: 'rip-start'; id: string; titel?: number[] } | { art: 'rip-abbruch'; id: string } + /** Den gerade laufenden Titel aufgeben und mit dem nächsten weitermachen + * — der Ausweg, wenn die Disc an einer Stelle einen Defekt hat. */ + | { art: 'rip-ueberspringen'; id: string } | { art: 'einstellung-setzen'; schluessel: string; wert: string } | { art: 'metadaten-suchen'; id: string; text: string } | { art: 'zuordnung-setzen'; id: string; vorschlag: MetaVorschlag } @@ -250,6 +293,13 @@ export function istKernNachricht(wert: unknown): wert is KernNachricht { typeof (wert.info as Record).titel === 'string' && typeof (wert.info as Record).confidence === 'number' ) + case 'disc-titel': + return ( + istObjekt(wert.stand) && + typeof (wert.stand as Record).id === 'string' && + typeof (wert.stand as Record).laeuft === 'boolean' && + Array.isArray((wert.stand as Record).titel) + ) case 'einstellungen': return istObjekt(wert.werte) case 'werkzeuge': diff --git a/rippy-windows/src/kern/ablauf/pipeline.ts b/rippy-windows/src/kern/ablauf/pipeline.ts index 2c02294..73178d5 100644 --- a/rippy-windows/src/kern/ablauf/pipeline.ts +++ b/rippy-windows/src/kern/ablauf/pipeline.ts @@ -27,7 +27,12 @@ import { mkdtempSync, rmSync, createWriteStream } from 'node:fs' import { tmpdir } from 'node:os' interface LaufenderVorgang { + /** Beendet den GANZEN Auftrag. */ abbruch: AbortController + /** Beendet nur den gerade laufenden Titel (überspringen). */ + titelAbbruch?: AbortController + /** Der laufende Titel wurde übersprungen, nicht der Auftrag abgebrochen. */ + uebersprungen?: boolean status: RipStatus } @@ -86,8 +91,19 @@ export class Pipeline { return { id, phase: 'fehler', prozent: -1, text, dateien: [], lesefehler: false } } - /** Startet die Kette für die ganze Disc ('all'). */ - async starten(id: string): Promise { + /** Bricht nur den GERADE LAUFENDEN Titel ab und macht mit dem nächsten + * weiter — der Ausweg bei einem Disc-Defekt (Commander 01.09.2026: + * MakeMKV hing eine halbe Stunde an derselben Stelle in Folge 1, + * während Folge 2 heil daneben lag). */ + ueberspringen(id: string): void { + const vorgang = this.laufend.get(id) + if (vorgang === undefined || vorgang.titelAbbruch === undefined) return + vorgang.uebersprungen = true + vorgang.titelAbbruch.abort() + } + + /** Startet die Kette. `titelWahl` leer heißt: alles rippen (wie früher). */ + async starten(id: string, titelWahl: readonly number[] = []): Promise { if (this.laufend.has(id)) { this.umgebung.melden(this.fehler(id, `Auf Laufwerk ${id}: läuft schon ein Vorgang.`)) return @@ -143,48 +159,134 @@ export class Pipeline { } const rohOrdner = join(this.umgebung.ablage(), 'roh', vorgangsName) + mkdirSync(rohOrdner, { recursive: true }) - const start: RipStatus = { - id, - phase: 'rippt', - prozent: -1, - text: `Rippe verlustfrei nach ${rohOrdner} …`, - dateien: [], - lesefehler: false, - } - this.laufend.set(id, { abbruch, status: start }) - this.melden(start) + // TITEL FÜR TITEL statt einem 'all'-Aufruf (Commander-Wunsch + // 01.09.2026). Zwei Gründe, und beide brauchen denselben Umbau: + // * Auswählen geht nur so — vorher rippte Rippy IMMER alles, samt + // Trailer, Werbung und Alterskennzeichen. + // * Ein Titel, der an einem Disc-Defekt hängen bleibt, reißt nicht + // mehr den ganzen Auftrag mit. Bei der Spartacus-Disc lag der + // Defekt in Folge 1 — Folge 2 war heil und ging trotzdem verloren. + const auftrag = titelWahl.length > 0 ? titelWahl.map(String) : ['all'] + const alleDateien = new Set() + let lesefehlerGesamt = false + const gescheitert: string[] = [] + const uebersprungen: string[] = [] - const rip = await rippen({ - programm, - geraetepfad: geraet.pfad, - zielOrdner: rohOrdner, - titel: 'all', - abbruch: abbruch.signal, - aufFortschritt: (prozent) => { - const alt = this.laufend.get(id)?.status - if (alt !== undefined) this.melden({ ...alt, prozent }) - }, - aufMeldung: (_code, text) => { - const alt = this.laufend.get(id)?.status - if (alt !== undefined) this.melden({ ...alt, text }) - }, + this.laufend.set(id, { + abbruch, + status: { id, phase: 'rippt', prozent: -1, text: 'Beginne …', dateien: [], lesefehler: false }, }) - if (rip.status === 'cancelled') { - this.laufend.delete(id) - this.melden({ id, phase: 'abgebrochen', prozent: -1, text: 'Abgebrochen.', dateien: [], lesefehler: false }) - return + for (let i = 0; i < auftrag.length; i++) { + const welcher = auftrag[i] + // Eigener Abbruch je Titel: „überspringen" beendet NUR diesen Lauf, + // „abbrechen" beendet den ganzen Auftrag — und reicht nach unten durch. + const titelAbbruch = new AbortController() + const weiterreichen = (): void => titelAbbruch.abort() + abbruch.signal.addEventListener('abort', weiterreichen, { once: true }) + const vorgang = this.laufend.get(id) + if (vorgang !== undefined) { + vorgang.titelAbbruch = titelAbbruch + vorgang.uebersprungen = false + } + + const beschriftung = + welcher === 'all' ? 'alle Titel' : `Titel ${welcher} (${i + 1} von ${auftrag.length})` + this.melden({ + id, + phase: 'rippt', + prozent: -1, + text: `Rippe ${beschriftung} verlustfrei nach ${rohOrdner} …`, + dateien: [...alleDateien], + lesefehler: lesefehlerGesamt, + titelNr: i + 1, + titelGesamt: auftrag.length, + haengt: null, + }) + + const rip = await rippen({ + programm, + geraetepfad: geraet.pfad, + zielOrdner: rohOrdner, + titel: welcher, + abbruch: titelAbbruch.signal, + aufFortschritt: (prozent) => { + const alt = this.laufend.get(id)?.status + if (alt !== undefined) this.melden({ ...alt, prozent }) + }, + aufMeldung: (_code, text) => { + const alt = this.laufend.get(id)?.status + if (alt !== undefined) this.melden({ ...alt, text }) + }, + aufHaengt: (klemmt) => { + const alt = this.laufend.get(id)?.status + if (alt !== undefined) this.melden({ ...alt, haengt: klemmt }) + }, + }) + abbruch.signal.removeEventListener('abort', weiterreichen) + + if (rip.status === 'cancelled') { + // Nur dieser Titel — oder der ganze Auftrag? + if (this.laufend.get(id)?.uebersprungen !== true) { + this.laufend.delete(id) + this.melden({ + id, + phase: 'abgebrochen', + prozent: -1, + text: 'Abgebrochen.', + dateien: [...alleDateien], + lesefehler: lesefehlerGesamt, + }) + return + } + uebersprungen.push(beschriftung) + continue + } + + lesefehlerGesamt = lesefehlerGesamt || rip.lesefehler + if (rip.status === 'error') { + // EIN kaputter Titel beendet den Auftrag nicht — die anderen sind + // vielleicht heil. Der Grund wird gesammelt und am Ende genannt. + gescheitert.push(`${beschriftung}: ${rip.fehler}`) + continue + } + // rippen() liefert den ganzen Zielordner zurück, also auch die + // Dateien der vorigen Titel — neu ist, was noch nicht bekannt war. + const neu = rip.dateien.filter((d) => !alleDateien.has(d)) + for (const d of rip.dateien) alleDateien.add(d) + if (neu.length === 0) { + // MakeMKV kann mit Code 0 enden und trotzdem nichts abgelegt haben + // (30.08.2026 gemessen) — dann ist dieser Titel NICHT gesichert. + gescheitert.push(`${beschriftung}: keine Datei entstanden`) + } } - if (rip.status === 'error') { + + const nachLauf = this.laufend.get(id) + if (nachLauf !== undefined) nachLauf.titelAbbruch = undefined + + if (alleDateien.size === 0) { this.laufend.delete(id) - this.melden({ id, phase: 'fehler', prozent: -1, text: rip.fehler, dateien: [], lesefehler: rip.lesefehler }) + this.melden({ + id, + phase: 'fehler', + prozent: -1, + text: gescheitert.length > 0 ? gescheitert.join(' · ') : 'Kein Titel konnte gesichert werden.', + dateien: [], + lesefehler: lesefehlerGesamt, + }) return } - const lesefehlerHinweis = rip.lesefehler - ? ' ⚠ Die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).' - : '' + const rip = { dateien: [...alleDateien].sort(), lesefehler: lesefehlerGesamt } + + const lesefehlerHinweis = + (rip.lesefehler + ? ' ⚠ Die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).' + : '') + + (uebersprungen.length > 0 ? ` ⚠ Übersprungen: ${uebersprungen.join(', ')}.` : '') + + (gescheitert.length > 0 ? ` ⚠ Nicht gesichert: ${gescheitert.join(' · ')}` : '') // ── Kompression (W-3) ──────────────────────────────────────────────── const einstellungen = this.umgebung.einstellungen() diff --git a/rippy-windows/src/kern/index.ts b/rippy-windows/src/kern/index.ts index 9c224bf..8bc37d8 100644 --- a/rippy-windows/src/kern/index.ts +++ b/rippy-windows/src/kern/index.ts @@ -32,6 +32,8 @@ import { TmdbClient } from './metadaten/tmdb' import { zuordnen, type Zuordnung } from './metadaten/zuordnung' import { presetEmpfehlung } from '../gemeinsam/preset-empfehlung' import { Datenbank } from './speicher/db' +import { titelInfoLesen } from './rip/makemkv' +import { titelVorauswahl } from './rip/titelwahl' import { finden } from './werkzeuge/katalog' // --datenbank= kommt vom Haupt (der Kern kennt Electrons app.getPath @@ -278,6 +280,53 @@ const laufwerkeBereit: Promise = (async () => { // ── Rippen (W-2) ──────────────────────────────────────────────────────── // Die Ablage kommt aus der EINEN Einstellungsquelle (§ 6.7); solange dort // nichts steht, gilt Videos\Rippy im Benutzerprofil. +// ── Titel der Disc lesen (§ 6.2) ──────────────────────────────────────── +// Der Info-Lauf dauert 20–120 s — deshalb geschieht er erst auf Zuruf und +// nicht schon beim Einlegen (der 30.08.-Fund: „das Erkennen dauert sehr +// sehr lange"). Das Fenster bekommt sofort `laeuft: true`, damit es in der +// Zeit nicht stumm dasteht (R2). +async function titelLesen(id: string): Promise { + const leer = { id, titel: [], einordnung: '', unsicher: false } + if (ripVerwaltung.laeuft(id)) { + anFenster({ art: 'disc-titel', stand: { ...leer, laeuft: false, fehler: 'Auf diesem Laufwerk läuft schon ein Vorgang.' } }) + return + } + const geraet = geraetVon(id) + if (geraet === null || laufwerkApi === null) { + anFenster({ art: 'disc-titel', stand: { ...leer, laeuft: false, fehler: `Laufwerk ${id}: gibt es nicht.` } }) + return + } + const geraetepfad = geraeteInfo(laufwerkApi, geraet).pfad + const programm = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '') + if (programm.length === 0) { + anFenster({ + art: 'disc-titel', + stand: { ...leer, laeuft: false, fehler: 'makemkvcon wurde nicht gefunden — ist MakeMKV installiert?' }, + }) + return + } + anFenster({ art: 'disc-titel', stand: { ...leer, laeuft: true, fehler: '' } }) + try { + const info = await titelInfoLesen(programm, geraetepfad) + const merkmale = discMerkmale.get(id) + const wahl = titelVorauswahl(info.titel, { + istSerie: merkmale?.istSerie ?? false, + folgen: merkmale?.folgen ?? 0, + }) + anFenster({ + art: 'disc-titel', + stand: { id, laeuft: false, titel: wahl.titel, einordnung: wahl.einordnung, unsicher: wahl.unsicher, fehler: '' }, + }) + } catch (fehler) { + // R4: laut scheitern. „Konnte nicht nachsehen" ist etwas anderes als + // „die Disc hat keine Titel" — beides zu vermischen erzeugt Unsinn. + anFenster({ + art: 'disc-titel', + stand: { ...leer, laeuft: false, fehler: `Titel lesen: ${String(fehler).slice(0, 200)}` }, + }) + } +} + function ablageOrdner(): string { const eingestellt = db?.einstellungLesen('ablage') if (eingestellt !== null && eingestellt !== undefined && eingestellt.length > 0) return eingestellt @@ -581,10 +630,14 @@ process.parentPort.on('message', (ereignis) => { anFenster({ art: 'pong', zeit: frage.zeit }) } else if (frage.art === 'auswerfen') { void auswerfen(frage.id) + } else if (frage.art === 'titel-lesen') { + void titelLesen(frage.id) } else if (frage.art === 'rip-start') { - void laufwerkeBereit.then(() => ripVerwaltung.starten(frage.id)) + void laufwerkeBereit.then(() => ripVerwaltung.starten(frage.id, frage.titel ?? [])) } else if (frage.art === 'rip-abbruch') { ripVerwaltung.abbrechen(frage.id) + } else if (frage.art === 'rip-ueberspringen') { + ripVerwaltung.ueberspringen(frage.id) } else if (frage.art === 'metadaten-suchen') { void metadatenSuchen(frage.id, frage.text) } else if (frage.art === 'zuordnung-setzen') { diff --git a/rippy-windows/src/kern/rip/makemkv.ts b/rippy-windows/src/kern/rip/makemkv.ts index 59aa9c5..73bf1f2 100644 --- a/rippy-windows/src/kern/rip/makemkv.ts +++ b/rippy-windows/src/kern/rip/makemkv.ts @@ -18,6 +18,7 @@ import { KRITISCHE_CODES, KRITISCHE_TEXTE, LESEFEHLER_MARKE, + offsetAusMeldung, parseMsg, parseStreamInfo, parseTitelInfo, @@ -38,18 +39,37 @@ export function buildRipArgs(geraetepfad: string, zielOrdner: string, titel: str return ['-r', '--noscan', '--progress=-same', 'mkv', quelle(geraetepfad), titel, zielOrdner] } +/** Ab so vielen Meldungen an DERSELBEN Stelle gilt der Lauf als hängend. + * Jeder Leseversuch wirft zwei Zeilen (SCSI-Fehler + Betriebssystemfehler), + * zwölf sind also rund sechs Versuche — genug, um einen kurzen Aussetzer + * von einem echten Defekt zu unterscheiden. */ +export const HAENGT_AB = 12 + /** Auswertung der Rip-Ausgabe — pur, eine Zeile nach der anderen. */ export class RipAuswertung { prozent = -1 letzteMeldung = '' lesefehler = false kritischeMeldungen: string[] = [] + /** Die Stelle, an der es zuletzt klemmte — '' wenn keine. */ + haengtBei = '' + /** Wie oft dieselbe Stelle schon gemeldet wurde. */ + haengtWiederholungen = 0 + + /** Klemmt der Lauf an einer festen Stelle? Dann ist das ein Defekt auf + * der Disc und kein Fortschrittsproblem — Weiterversuchen hilft nicht. */ + haengt(): boolean { + return this.haengtBei.length > 0 && this.haengtWiederholungen >= HAENGT_AB + } /** Verarbeitet eine Zeile; gibt einen neuen Prozentwert zurück oder null. */ verarbeite(zeile: string): { prozent?: number; meldung?: { code: number; text: string } } { const p = progressAusPrgv(zeile) if (p >= 0) { if (p === this.prozent) return {} + // Es geht wieder voran — was vorher klemmte, klemmt nicht mehr. + this.haengtBei = '' + this.haengtWiederholungen = 0 this.prozent = p return { prozent: p } } @@ -57,6 +77,16 @@ export class RipAuswertung { if (meldung === null) return {} this.letzteMeldung = meldung.text if (meldung.text.includes(LESEFEHLER_MARKE)) this.lesefehler = true + // Dieselbe Stelle wieder und wieder heißt: physischer Defekt. + const stelle = offsetAusMeldung(meldung.text) + if (stelle.length > 0) { + if (stelle === this.haengtBei) { + this.haengtWiederholungen += 1 + } else { + this.haengtBei = stelle + this.haengtWiederholungen = 1 + } + } if (meldung.code in KRITISCHE_CODES) { this.kritischeMeldungen.push(`${KRITISCHE_CODES[meldung.code]} (${meldung.text.trim()})`) } else if (KRITISCHE_TEXTE.some((muster) => meldung.text.includes(muster))) { @@ -154,6 +184,9 @@ export interface RipLaufOptionen { titel: string aufFortschritt?: (prozent: number) => void aufMeldung?: (code: number, text: string) => void + /** Der Lauf klemmt an einer festen Stelle (oder klemmt nicht mehr) — + * das ist ein Disc-Defekt, kein Fortschrittsproblem. */ + aufHaengt?: (klemmt: { offset: string; wiederholungen: number } | null) => void abbruch?: AbortSignal } @@ -179,12 +212,22 @@ export function rippen(optionen: RipLaufOptionen): Promise { } optionen.abbruch?.addEventListener('abort', abbrechen, { once: true }) + // Ob es klemmt, wird gemeldet, sobald es sich ÄNDERT — nicht bei jeder + // Zeile, sonst rauscht dieselbe Nachricht hundertfach durchs Fenster. + let klemmteVorher = false const zeile = (z: string): void => { const ergebnis = auswertung.verarbeite(z) if (ergebnis.prozent !== undefined) optionen.aufFortschritt?.(ergebnis.prozent) if (ergebnis.meldung !== undefined) { optionen.aufMeldung?.(ergebnis.meldung.code, ergebnis.meldung.text) } + const klemmt = auswertung.haengt() + if (klemmt !== klemmteVorher || (klemmt && auswertung.haengtWiederholungen % 10 === 0)) { + klemmteVorher = klemmt + optionen.aufHaengt?.( + klemmt ? { offset: auswertung.haengtBei, wiederholungen: auswertung.haengtWiederholungen } : null, + ) + } } kind.stdout.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile)) kind.stderr.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile)) diff --git a/rippy-windows/src/kern/rip/parser.ts b/rippy-windows/src/kern/rip/parser.ts index 4acdd3a..727471c 100644 --- a/rippy-windows/src/kern/rip/parser.ts +++ b/rippy-windows/src/kern/rip/parser.ts @@ -249,6 +249,26 @@ export function episodenTitel(dauern: Map, episodenMinuten: numb // (30.08.2026, Spartacus Disc 2 — 1 von 2 Titeln kam an, Rückgabewert 0). export const LESEFEHLER_MARKE = 'makemkv.com/errors/read' +/** Die STELLE, an der ein Lesefehler auftrat. MakeMKV hängt sie ans Ende + * der Meldung, in jeder Sprache als letzte Zahl in Anführungszeichen: + * + * … während des Auslesens von '/BDMV/STREAM/00047.m2ts' bei Offset '56881152' + * … while reading '/BDMV/STREAM/00047.m2ts' at offset '56881152' + * + * Deshalb wird die letzte gequotete Zahl gelesen und nicht das Wort davor + * — das wäre von der MakeMKV-Sprache abhängig. '' wenn keine dasteht. + * + * Wozu (01.09.2026, an der Spartacus-Disc gesehen): Wiederholt sich + * DIESELBE Stelle, ist das kein Fortschritt, sondern ein physischer + * Defekt — MakeMKV versucht es dann stundenlang, das Laufwerk hämmert, + * und Windows protokolliert einen fehlerhaften Block nach dem anderen. */ +export function offsetAusMeldung(text: string): string { + // Mindestens zwei Ziffern: Titel- und Kapitelnummern stehen ebenfalls in + // Anführungszeichen, ein Offset ist immer groß. + const treffer = /'(\d{2,})'\s*$/.exec(text.trim()) + return treffer === null ? '' : treffer[1] +} + // Meldungs-Nummern, die die URSACHE eines Fehlschlags benennen — sie sind // sprachunabhängig, anders als die Texte. Jede Nummer wurde am Laufwerk // des Commanders GESEHEN (makemkv_aufruf.py, im Wortlaut dokumentiert). diff --git a/rippy-windows/src/kern/rip/titelwahl.ts b/rippy-windows/src/kern/rip/titelwahl.ts new file mode 100644 index 0000000..26a5428 --- /dev/null +++ b/rippy-windows/src/kern/rip/titelwahl.ts @@ -0,0 +1,147 @@ +// Welche Titel einer Disc will man wirklich? — PUR, ohne Laufwerk, damit +// die Ampel das auf Linux prüfen kann. +// +// Anlass (01.09.2026, Commander): „es fehlt generell noch komplett die +// auswahl WAS man rippen möchte. Bei Serien eben genau das die folgen, +// bei filmen z. B. nur das hauptfeature oder auch die extras." +// +// Bis hierher übergab die Pipeline fest `titel: 'all'` — Rippy rippte +// IMMER alles, inklusive Trailer, Werbung und Alterskennzeichen. +// +// Diese Datei RÄT NICHT INS BLAUE. Sie rechnet mit dem, was messbar ist +// (Dauer, Größe, Kapitelzahl) und mit dem, was die Disc selbst bezeugt +// (Folgenzahl aus dem bdmt-Inhaltsverzeichnis, siehe discmerkmale.ts). +// Wo es nicht eindeutig ist, sagt sie das — statt sich zu entscheiden und +// zu schweigen. +import type { TitelInfo } from './parser' + +/** Kürzer als das ist kein Film und keine Folge, sondern ein Logo, eine + * Warnung oder ein Menü-Schnipsel. Dieselbe Grenze benutzt makemkvcon + * standardmäßig für seine eigene Mindestlänge. */ +export const KURZ_S = 120 + +/** Folgen einer Staffel sind ähnlich lang — 45 bis 55 Minuten sind eine + * Serie, 50 Minuten und 3 Minuten sind es nicht. */ +export const AEHNLICH = 0.15 + +/** Mehrere Titel dicht am längsten heißen fast immer „Seamless Branching" + * bzw. Kamerawinkel: dieselbe Fassung in Varianten. Dann ist die Wahl + * nicht eindeutig, und Rippy sagt das. */ +export const FILM_GLEICHAUF = 0.05 + +/** Ein Titel, der ungefähr so lang ist wie die anderen ZUSAMMEN, ist ein + * Sammeltitel („Alle Folgen am Stück"). Der ist der längste und würde + * eine Film-Auswahl sonst gewinnen — und dann rippt Rippy alles doppelt. */ +export const SAMMEL_TOLERANZ = 0.1 + +export interface TitelVorschlag extends TitelInfo { + /** Von Rippy vorausgewählt? */ + gewaehlt: boolean + /** Warum — im Klartext, das Fenster zeigt es an. */ + grund: string +} + +export interface TitelWahl { + titel: TitelVorschlag[] + /** Einordnung fürs Fenster, z. B. „Serie: 2 Folgen erwartet, 2 gefunden". */ + einordnung: string + /** Rippy ist sich NICHT sicher — das Fenster fragt dann deutlicher nach. */ + unsicher: boolean +} + +/** Was die Disc über sich selbst bezeugt (discmerkmale.discTitel()). */ +export interface DiscHinweis { + istSerie: boolean + /** Folgen laut bdmt-Inhaltsverzeichnis; 0 wenn es keines gibt. */ + folgen: number +} + +function dauerText(s: number): string { + const m = Math.round(s / 60) + return m >= 60 ? `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')} min` : `${m} min` +} + +/** Ist dieser Titel ungefähr so lang wie die kürzeren zusammen? */ +function istSammeltitel(kandidat: TitelInfo, alle: readonly TitelInfo[]): boolean { + const kuerzere = alle.filter((t) => t.nr !== kandidat.nr && t.dauerS >= KURZ_S && t.dauerS < kandidat.dauerS) + // Mit weniger als zwei Teilen gibt es nichts zu sammeln. + if (kuerzere.length < 2) return false + const summe = kuerzere.reduce((s, t) => s + t.dauerS, 0) + if (summe <= 0) return false + return Math.abs(kandidat.dauerS - summe) / kandidat.dauerS <= SAMMEL_TOLERANZ +} + +/** + * Die Vorauswahl. Sie wählt VOR — entschieden wird im Fenster. + * + * Drei Fälle: + * * Serie (von der Disc bezeugt): die längsten Titel, so viele wie das + * Inhaltsverzeichnis Folgen nennt. + * * Film: der längste Titel, der kein Sammeltitel ist. + * * Unklar: nichts vorausgewählt — lieber fragen als raten. + */ +export function titelVorauswahl(alle: readonly TitelInfo[], hinweis: DiscHinweis): TitelWahl { + if (alle.length === 0) { + return { titel: [], einordnung: 'Keine Titel gefunden — die Disc gab nichts her.', unsicher: true } + } + + const sammel = new Set(alle.filter((t) => istSammeltitel(t, alle)).map((t) => t.nr)) + const kandidaten = alle + .filter((t) => t.dauerS >= KURZ_S && !sammel.has(t.nr)) + .sort((a, b) => b.dauerS - a.dauerS) + + const gewaehlt = new Set() + let einordnung: string + let unsicher = false + + if (kandidaten.length === 0) { + einordnung = `Kein Titel über ${Math.round(KURZ_S / 60)} Minuten — hier ist nichts, was nach Hauptinhalt aussieht.` + unsicher = true + } else if (hinweis.istSerie && hinweis.folgen >= 2) { + const folgen = kandidaten.slice(0, hinweis.folgen) + for (const t of folgen) gewaehlt.add(t.nr) + const laengste = folgen[0].dauerS + const passen = folgen.every((t) => Math.abs(t.dauerS - laengste) / laengste <= AEHNLICH) + if (folgen.length < hinweis.folgen) { + einordnung = `Serie: ${hinweis.folgen} Folgen erwartet, nur ${folgen.length} gefunden — bitte nachsehen.` + unsicher = true + } else if (!passen) { + einordnung = `Serie: ${hinweis.folgen} Folgen erwartet, aber die gefundenen sind unterschiedlich lang — bitte nachsehen.` + unsicher = true + } else { + einordnung = `Serie: ${hinweis.folgen} Folgen erwartet, ${folgen.length} gefunden — passt.` + } + } else { + const laengster = kandidaten[0] + const gleichauf = kandidaten.filter( + (t) => Math.abs(t.dauerS - laengster.dauerS) / laengster.dauerS <= FILM_GLEICHAUF, + ) + if (gleichauf.length > 1) { + // Mehrere Fassungen derselben Länge: die mit den meisten Kapiteln ist + // erfahrungsgemäß die Hauptfassung — sicher ist das nicht. + const beste = [...gleichauf].sort((a, b) => b.kapitel - a.kapitel || a.nr - b.nr)[0] + gewaehlt.add(beste.nr) + einordnung = `${gleichauf.length} Titel sind fast gleich lang (${dauerText(laengster.dauerS)}) — Rippy hat den mit den meisten Kapiteln gewählt. Bitte nachsehen.` + unsicher = true + } else { + gewaehlt.add(laengster.nr) + einordnung = `Film: ein Hauptinhalt von ${dauerText(laengster.dauerS)} gefunden.` + } + } + + const titel: TitelVorschlag[] = alle.map((t) => ({ + ...t, + gewaehlt: gewaehlt.has(t.nr), + grund: gewaehlt.has(t.nr) + ? hinweis.istSerie && hinweis.folgen >= 2 + ? 'Folge' + : 'Hauptinhalt' + : sammel.has(t.nr) + ? 'Sammeltitel — enthält vermutlich alles am Stück' + : t.dauerS < KURZ_S + ? 'zu kurz — Logo, Warnung oder Menü' + : 'Zugabe', + })) + + return { titel, einordnung, unsicher } +} diff --git a/rippy-windows/test/titelwahl.test.ts b/rippy-windows/test/titelwahl.test.ts new file mode 100644 index 0000000..2e966b6 --- /dev/null +++ b/rippy-windows/test/titelwahl.test.ts @@ -0,0 +1,179 @@ +// Welche Titel schlägt Rippy vor? — und wann sagt es „ich bin unsicher"? +// +// Anlass (01.09.2026): Bis dahin übergab die Pipeline fest `titel: 'all'` +// — Rippy rippte IMMER alles, inklusive Trailer, Werbung und +// Alterskennzeichen. Der Commander: „es fehlt generell noch komplett die +// auswahl WAS man rippen möchte." +import { describe, expect, it } from 'vitest' +import { offsetAusMeldung } from '../src/kern/rip/parser' +import { HAENGT_AB, RipAuswertung } from '../src/kern/rip/makemkv' +import { AEHNLICH, KURZ_S, titelVorauswahl } from '../src/kern/rip/titelwahl' +import type { TitelInfo } from '../src/kern/rip/parser' + +function titel(nr: number, minuten: number, kapitel = 8, gb = 1): TitelInfo { + return { nr, dauerS: Math.round(minuten * 60), groesseBytes: Math.round(gb * 1e9), kapitel } +} + +const KEIN_HINWEIS = { istSerie: false, folgen: 0 } + +describe('titelVorauswahl — Serie', () => { + it('die echte Spartacus-Disc: zwei Folgen, sonst nichts', () => { + // Gemessen an der Disc: zwei große Ströme (15,76 GB / 14,89 GB) und + // Kleinkram. Das bdmt-Inhaltsverzeichnis nennt EP 1 und EP 2. + const wahl = titelVorauswahl( + [titel(0, 52.2, 8, 15.76), titel(1, 49.6, 8, 14.89), titel(2, 0.5, 1), titel(3, 0.2, 1)], + { istSerie: true, folgen: 2 }, + ) + expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.nr)).toEqual([0, 1]) + expect(wahl.unsicher).toBe(false) + expect(wahl.einordnung).toContain('2 Folgen erwartet, 2 gefunden') + expect(wahl.titel[0].grund).toBe('Folge') + expect(wahl.titel[3].grund).toContain('zu kurz') + }) + + it('weniger Folgen gefunden als erwartet — Rippy sagt es', () => { + const wahl = titelVorauswahl([titel(0, 50), titel(1, 0.4, 1)], { istSerie: true, folgen: 3 }) + expect(wahl.unsicher).toBe(true) + expect(wahl.einordnung).toContain('nur 1 gefunden') + }) + + it('gefundene Folgen sind unterschiedlich lang — auch das sagt Rippy', () => { + // 50 min und 20 min sind keine Staffel; die Auswahl bleibt, aber + // Rippy behauptet nicht, sie sei sicher. + const wahl = titelVorauswahl([titel(0, 50), titel(1, 20)], { istSerie: true, folgen: 2 }) + expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.nr)).toEqual([0, 1]) + expect(wahl.unsicher).toBe(true) + expect(wahl.einordnung).toContain('unterschiedlich lang') + }) + + it('ähnlich lange Folgen bleiben innerhalb der Toleranz sicher', () => { + // 45 und 50 Minuten liegen bei 10 % — unter AEHNLICH. + expect((50 - 45) / 50).toBeLessThan(AEHNLICH) + const wahl = titelVorauswahl([titel(0, 50), titel(1, 45)], { istSerie: true, folgen: 2 }) + expect(wahl.unsicher).toBe(false) + }) +}) + +describe('titelVorauswahl — Film', () => { + it('der längste Titel ist der Hauptinhalt', () => { + const wahl = titelVorauswahl([titel(0, 118), titel(1, 4), titel(2, 2)], KEIN_HINWEIS) + expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.nr)).toEqual([0]) + expect(wahl.unsicher).toBe(false) + expect(wahl.titel[0].grund).toBe('Hauptinhalt') + expect(wahl.titel[1].grund).toBe('Zugabe') + }) + + it('DIE ANGEL-FALLE: mehrere fast gleich lange Fassungen', () => { + // Seamless Branching / Kamerawinkel — dieselbe Fassung in Varianten. + // Rippy nimmt die mit den meisten Kapiteln UND sagt, dass es unsicher ist. + const wahl = titelVorauswahl( + [titel(0, 120, 12), titel(1, 119, 24), titel(2, 118, 6)], + KEIN_HINWEIS, + ) + expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.nr)).toEqual([1]) + expect(wahl.unsicher).toBe(true) + expect(wahl.einordnung).toContain('fast gleich lang') + }) +}) + +describe('titelVorauswahl — die Sammeltitel-Falle', () => { + it('„alle Folgen am Stück" gewinnt NICHT die Film-Auswahl', () => { + // 150 min = 50 + 50 + 50. Ohne diese Regel wäre der Sammeltitel der + // längste — und Rippy würde alles doppelt sichern. + const wahl = titelVorauswahl( + [titel(0, 150), titel(1, 50), titel(2, 50), titel(3, 50)], + KEIN_HINWEIS, + ) + expect(wahl.titel[0].gewaehlt).toBe(false) + expect(wahl.titel[0].grund).toContain('Sammeltitel') + expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.nr)).toEqual([1]) + }) + + it('auch bei einer Serie bleibt der Sammeltitel außen vor', () => { + const wahl = titelVorauswahl( + [titel(0, 100), titel(1, 50), titel(2, 50)], + { istSerie: true, folgen: 2 }, + ) + expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.nr)).toEqual([1, 2]) + }) + + it('ein einzelner langer Titel ist KEIN Sammeltitel', () => { + // Ein Film mit einem Trailer daneben — 118 min sind nicht die Summe + // von irgendetwas, sonst würde jeder Film als Sammeltitel gelten. + const wahl = titelVorauswahl([titel(0, 118), titel(1, 4)], KEIN_HINWEIS) + expect(wahl.titel[0].gewaehlt).toBe(true) + }) +}) + +describe('titelVorauswahl — wenn nichts passt, wird nichts behauptet', () => { + it('nur Schnipsel: keine Vorauswahl, aber eine Ansage', () => { + const wahl = titelVorauswahl([titel(0, 1, 1), titel(1, 0.5, 1)], KEIN_HINWEIS) + expect(wahl.titel.every((t) => !t.gewaehlt)).toBe(true) + expect(wahl.unsicher).toBe(true) + expect(wahl.einordnung).toContain(`${Math.round(KURZ_S / 60)} Minuten`) + }) + + it('gar keine Titel', () => { + const wahl = titelVorauswahl([], KEIN_HINWEIS) + expect(wahl.titel).toEqual([]) + expect(wahl.unsicher).toBe(true) + }) +}) + +// ── Der Defekt-Wächter ────────────────────────────────────────────────── +// Gemessen an der Spartacus-Disc (01.09.2026): MakeMKV meldete eine halbe +// Stunde lang denselben Offset, makemkvcon verbrauchte dabei 1,7 s CPU +// (es wartete also), und Windows protokollierte 41 fehlerhafte Blöcke in +// 10 Minuten. Dieselbe Stelle wieder und wieder ist kein Fortschritt. +const FEHLER_DE = + "Fehler 'SCSI-Fehler - MEDIUM ERROR:L-EC UNCORRECTABLE ERROR' trat auf während des Auslesens von '/BDMV/STREAM/00047.m2ts' bei Offset '56881152'" +const FEHLER_EN = + "Error 'SCSI error - MEDIUM ERROR:L-EC UNCORRECTABLE ERROR' occurred while reading '/BDMV/STREAM/00047.m2ts' at offset '56881152'" + +describe('offsetAusMeldung', () => { + it('liest die Stelle — unabhängig von MakeMKVs Sprache', () => { + expect(offsetAusMeldung(FEHLER_DE)).toBe('56881152') + expect(offsetAusMeldung(FEHLER_EN)).toBe('56881152') + }) + + it('ohne Stelle bleibt es leer', () => { + expect(offsetAusMeldung('Kopiere 2 Titel')).toBe('') + expect(offsetAusMeldung('')).toBe('') + // Eine einzelne Ziffer ist eine Titelnummer, kein Offset. + expect(offsetAusMeldung("Titel '3'")).toBe('') + }) +}) + +describe('RipAuswertung — hängt der Lauf an einer festen Stelle?', () => { + const zeile = (text: string): string => `MSG:2003,0,3,"${text}"` + + it('dieselbe Stelle immer wieder = Defekt', () => { + const a = new RipAuswertung() + for (let i = 0; i < HAENGT_AB; i++) a.verarbeite(zeile(FEHLER_DE)) + expect(a.haengtBei).toBe('56881152') + expect(a.haengt()).toBe(true) + }) + + it('ein kurzer Aussetzer ist noch kein Defekt', () => { + const a = new RipAuswertung() + for (let i = 0; i < HAENGT_AB - 1; i++) a.verarbeite(zeile(FEHLER_DE)) + expect(a.haengt()).toBe(false) + }) + + it('Fortschritt räumt den Verdacht ab — es ging ja weiter', () => { + const a = new RipAuswertung() + for (let i = 0; i < HAENGT_AB; i++) a.verarbeite(zeile(FEHLER_DE)) + expect(a.haengt()).toBe(true) + a.verarbeite('PRGV:16384,16384,65536') + expect(a.haengt()).toBe(false) + expect(a.haengtBei).toBe('') + }) + + it('wandernde Stellen sind normale Lesefehler, kein Hängen', () => { + const a = new RipAuswertung() + for (let i = 0; i < HAENGT_AB * 2; i++) { + a.verarbeite(zeile(FEHLER_DE.replace('56881152', String(56881152 + i * 2048)))) + } + expect(a.haengt()).toBe(false) + }) +})