WAS: - pipeline.starten fängt jede Ausnahme und meldet sie als Fehler des Laufwerks; vor dem Start eine Schreibprobe am Zielordner mit Klartext („nicht erreichbar … NAS verbunden?"). Vorher warf mkdirSync bei toter Ablage aus starten() heraus, der Aufrufer schluckte es: kein Fehler im Fenster, keine Protokollzeile, der Dialog ging zu (Sonde 10). - kern/index.ts: unbehandelte Promise-Ablehnungen landen im Protokoll und im Fenster (gemessen: ein Electron-utilityProcess stirbt daran nicht, er schweigt); echte Ausnahmen werden protokolliert, der Kern endet kontrolliert, der Haupt startet ihn neu. Die Einlege-Kette hat ein Fangnetz. - kernstart.ts spannt nach einem Kern-Neustart den Fenster-Port neu auf; das Fenster schließt den toten Port und meldet den Neustart als Zeile. Vorher blieb es taub, bis Rippy komplett neu gestartet wurde. - wache.runde() löscht den geplanten Timer, bevor es einen neuen setzt — jeder Auswurf legte sonst eine weitere 3-s-Kette an (Sonde 3). - beenden() räumt den leeren Roh-Ordner, verschont aber rettung.iso und den Bericht (RETTUNG_DATEIEN). - Typ „unbekannt" heißt nur ohne Titel-Auftrag ISO; mit Auftrag rippt makemkvcon, der Typ folgt der Größe (Blu-ray/DVD) für Preset und Ordner. WARUM: Durchsicht 12.09.2026, Funde F2, F4, F6, F8, F10 — jeder mit Test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
437 lines
16 KiB
TypeScript
437 lines
16 KiB
TypeScript
// 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 AutomatikStand,
|
||
type BeschaffungStand,
|
||
type DiscInfoStand,
|
||
type FensterNachricht,
|
||
type GeraeteInfo,
|
||
type KernNachricht,
|
||
type LaufwerkBericht,
|
||
type MetaVorschlag,
|
||
type RipAuftragOptionen,
|
||
type RipAuftragTitel,
|
||
type RipStatus,
|
||
type SpeicherStand,
|
||
type TitelStand,
|
||
type VorgaengeStand,
|
||
type WerkzeugAuskunft,
|
||
} from '../gemeinsam/nachrichten'
|
||
|
||
export interface Verbindung {
|
||
verbunden: boolean
|
||
kern: Extract<KernNachricht, { art: 'kern-bereit' }> | 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<string, RipStatus>
|
||
/** Was Rippy über die eingelegte Disc weiß (§ 5 Schritt 3). */
|
||
discInfos: Record<string, DiscInfoStand>
|
||
/** Suchergebnisse des Ändern-Dialogs je Laufwerk (§ 5 Schritt 4). */
|
||
vorschlaege: Record<string, MetaVorschlag[]>
|
||
/** Die Titel der Disc je Laufwerk (§ 6.2) — Ergebnis des Info-Laufs. */
|
||
discTitel: Record<string, TitelStand>
|
||
/** Wann der laufende Vorgang je Laufwerk begann (für die Restzeit). */
|
||
ripStart: Record<string, number>
|
||
/** Statuszeilen des (letzten) Vorgangs je Laufwerk — das aufklappbare
|
||
* Protokoll der Kachel. Bleibt nach dem Ende stehen (R2), bis ein neuer
|
||
* Vorgang beginnt. */
|
||
ripProtokoll: Record<string, string[]>
|
||
einstellungen: Record<string, string>
|
||
werkzeuge: WerkzeugAuskunft | null
|
||
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
|
||
/** Vorgänge, Roh-Belegung und Kompressions-Warteschlange (5.4.0) — null,
|
||
* solange der Kern noch nichts geschickt hat. */
|
||
vorgaenge: VorgaengeStand | null
|
||
/** Was das Windows-Protokoll je Laufwerk sagt (5.4.0) — bleibt stehen. */
|
||
gesundheit: Record<string, LaufwerkBericht>
|
||
/** Freier Platz an Arbeitsordner und Ablage (5.5.0). */
|
||
speicher: SpeicherStand | null
|
||
/** Automatik-Countdown je Laufwerk (5.5.0). */
|
||
automatik: Record<string, AutomatikStand>
|
||
/** Poster für den Hintergrund der Erst-Einrichtung (5.5.0). */
|
||
poster: string[]
|
||
/** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */
|
||
meldungen: string[]
|
||
}
|
||
|
||
type Horcher = (stand: Verbindung) => void
|
||
|
||
const MAX_MELDUNGEN = 30
|
||
|
||
export const LEERE_VERBINDUNG: Verbindung = {
|
||
verbunden: false,
|
||
kern: null,
|
||
pongLatenzMs: null,
|
||
laufwerke: [],
|
||
rips: {},
|
||
discInfos: {},
|
||
discTitel: {},
|
||
vorschlaege: {},
|
||
ripStart: {},
|
||
ripProtokoll: {},
|
||
einstellungen: {},
|
||
werkzeuge: null,
|
||
schluessel: null,
|
||
beschaffung: null,
|
||
vorgaenge: null,
|
||
gesundheit: {},
|
||
speicher: null,
|
||
automatik: {},
|
||
poster: [],
|
||
meldungen: [],
|
||
}
|
||
|
||
const stand: Verbindung = { ...LEERE_VERBINDUNG }
|
||
const horcher = new Set<Horcher>()
|
||
let gestartet = false
|
||
let port: MessagePort | null = null
|
||
|
||
function kopie(): Verbindung {
|
||
return {
|
||
...stand,
|
||
laufwerke: [...stand.laufwerke],
|
||
rips: { ...stand.rips },
|
||
discInfos: { ...stand.discInfos },
|
||
vorschlaege: { ...stand.vorschlaege },
|
||
einstellungen: { ...stand.einstellungen },
|
||
gesundheit: { ...stand.gesundheit },
|
||
automatik: { ...stand.automatik },
|
||
poster: [...stand.poster],
|
||
meldungen: [...stand.meldungen],
|
||
}
|
||
}
|
||
|
||
function melden(): void {
|
||
for (const h of horcher) h(kopie())
|
||
}
|
||
|
||
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':
|
||
// 5.7.0 (F4): Eine neue PID heißt, der Kern wurde neu gestartet — das
|
||
// steht jetzt im Fenster, statt dass die Laufwerke stumm einfrieren.
|
||
if (stand.kern !== null && stand.kern.pid !== nachricht.pid) {
|
||
meldung(`⚠ Der Kern wurde neu gestartet (PID ${nachricht.pid}) — Rippy hat das Fenster wieder verbunden. Ein laufender Rip ist damit abgebrochen; der Grund steht im Protokoll (Einstellungen → Allgemein).`)
|
||
}
|
||
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 'kern-hinweis':
|
||
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}`)
|
||
const { [ereignis.geraet.id]: _weg, ...rest } = stand.automatik
|
||
stand.automatik = rest
|
||
} 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': {
|
||
const vorher = stand.discInfos[nachricht.info.id]
|
||
stand.discInfos = { ...stand.discInfos, [nachricht.info.id]: nachricht.info }
|
||
if (nachricht.info.titel.length > 0) {
|
||
// Dieselbe Erkennung nicht zweimal melden — die Nachprüfung nach
|
||
// dem Info-Lauf schickt den Stand erneut, mit Hinweisen.
|
||
const neuErkannt = vorher === undefined || vorher.titel !== nachricht.info.titel
|
||
if (neuErkannt) {
|
||
meldung(
|
||
`Laufwerk ${nachricht.info.id}: erkannt als „${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''}“ · ${Math.round(nachricht.info.confidence * 100)} %`,
|
||
)
|
||
}
|
||
for (const h of nachricht.info.hinweise) {
|
||
if (vorher === undefined || !vorher.hinweise.includes(h)) meldung(`Laufwerk ${nachricht.info.id}: ${h}`)
|
||
}
|
||
}
|
||
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 '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 'vorgaenge':
|
||
stand.vorgaenge = nachricht.stand
|
||
break
|
||
case 'kompression-ergebnis':
|
||
meldung(`${nachricht.ok ? '' : '⚠ '}Kompression „${nachricht.titel}": ${nachricht.text}`)
|
||
break
|
||
case 'laufwerk-gesundheit':
|
||
stand.gesundheit = { ...stand.gesundheit, [nachricht.id]: nachricht.bericht }
|
||
if (nachricht.bericht.urteil === 'hardware' || nachricht.bericht.urteil === 'disc') {
|
||
meldung(`Laufwerk ${nachricht.id}: ${nachricht.bericht.text}`)
|
||
}
|
||
break
|
||
case 'speicher':
|
||
stand.speicher = nachricht.stand
|
||
break
|
||
case 'automatik':
|
||
stand.automatik = { ...stand.automatik, [nachricht.stand.id]: nachricht.stand }
|
||
break
|
||
case 'poster-vorschau':
|
||
stand.poster = nachricht.pfade
|
||
break
|
||
case 'rip-status': {
|
||
stand.rips = { ...stand.rips, [nachricht.status.id]: nachricht.status }
|
||
const laeuft = ['liest-info', 'rettet', '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
|
||
}
|
||
case 'installer-starten':
|
||
// Nur fürs Haupt gedacht — im Fenster nichts zu tun.
|
||
break
|
||
}
|
||
melden()
|
||
}
|
||
|
||
function portAnnehmen(neuerPort: MessagePort): void {
|
||
// Ein zweiter Port kommt nach einem Kern-Neustart (5.7.0, F4) — der alte
|
||
// ist tot und wird geschlossen, damit nichts mehr ins Leere geht.
|
||
if (port !== null && port !== neuerPort) {
|
||
try {
|
||
port.close()
|
||
} catch {
|
||
// Ein toter Port lässt sich nicht immer schließen — er ist ohnehin weg.
|
||
}
|
||
}
|
||
port = neuerPort
|
||
stand.verbunden = true
|
||
neuerPort.onmessage = (ereignis: MessageEvent) => {
|
||
const nachricht: unknown = ereignis.data
|
||
if (istKernNachricht(nachricht)) kernNachricht(nachricht)
|
||
}
|
||
neuerPort.start()
|
||
senden({ art: 'ping', zeit: Date.now() })
|
||
senden({ art: 'einstellungen-laden' })
|
||
senden({ art: 'werkzeuge-laden' })
|
||
senden({ art: 'vorgaenge-laden' })
|
||
senden({ art: 'speicher-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<string, unknown>).art === 'kern-port' &&
|
||
ereignis.ports.length > 0
|
||
) {
|
||
portAnnehmen(ereignis.ports[0])
|
||
}
|
||
})
|
||
window.rippy.bereit()
|
||
}
|
||
|
||
export function aufVerbindung(h: Horcher): () => void {
|
||
horcher.add(h)
|
||
h(kopie())
|
||
return () => horcher.delete(h)
|
||
}
|
||
|
||
function senden(nachricht: FensterNachricht): void {
|
||
port?.postMessage(nachricht)
|
||
}
|
||
|
||
/** Schickt eine Nachricht — und sagt es, wenn kein Kern da ist (R4). */
|
||
function anKern(nachricht: FensterNachricht, 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}`)
|
||
}
|
||
|
||
/** `auftrag` leer heißt: alles rippen (wie vor 5.2). */
|
||
export function ripStarten(id: string, auftrag: RipAuftragTitel[] = [], optionen: RipAuftragOptionen = {}): void {
|
||
anKern({ art: 'rip-start', id, auftrag, optionen }, `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 rettungStarten(id: string): void {
|
||
anKern({ art: 'rettung-start', id }, `Rettungs-Abbild ${id}`)
|
||
}
|
||
|
||
export function metadatenSuchen(id: string, text: string): void {
|
||
senden({ art: 'metadaten-suchen', id, text })
|
||
}
|
||
|
||
export function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): void {
|
||
senden({ art: 'zuordnung-setzen', id, vorschlag })
|
||
}
|
||
|
||
export function makemkvUpdate(): void {
|
||
anKern({ art: 'makemkv-update' }, 'MakeMKV-Update')
|
||
}
|
||
|
||
export function schluesselPruefen(): void {
|
||
senden({ art: 'schluessel-pruefen' })
|
||
}
|
||
|
||
export function einstellungSetzen(schluessel: string, wert: string): void {
|
||
anKern({ art: 'einstellung-setzen', schluessel, wert }, `Einstellung ${schluessel}`)
|
||
}
|
||
|
||
// ── 5.4.0: Vorgänge, Roh-Dateien, Warteschlange, Laufwerks-Gesundheit ──
|
||
export function vorgaengeLaden(): void {
|
||
senden({ art: 'vorgaenge-laden' })
|
||
}
|
||
|
||
export function vorgangRohLoeschen(vorgangId: string): void {
|
||
anKern({ art: 'vorgang-roh-loeschen', vorgangId }, 'Roh-Dateien löschen')
|
||
}
|
||
|
||
export function vorgangNeuKomprimieren(vorgangId: string, preset: string): void {
|
||
anKern({ art: 'vorgang-neu-komprimieren', vorgangId, preset }, 'Neu komprimieren')
|
||
}
|
||
|
||
export function kompressionAbbrechen(vorgangId: string): void {
|
||
anKern({ art: 'kompression-abbrechen', vorgangId }, 'Kompression abbrechen')
|
||
}
|
||
|
||
export function rohAufraeumen(): void {
|
||
anKern({ art: 'roh-aufraeumen' }, 'Roh-Dateien aufräumen')
|
||
}
|
||
|
||
export function fremdenOrdnerLoeschen(ordner: string): void {
|
||
anKern({ art: 'fremden-ordner-loeschen', ordner }, 'Ordner löschen')
|
||
}
|
||
|
||
export function laufwerkPruefen(id: string): void {
|
||
anKern({ art: 'laufwerk-pruefen', id }, `Laufwerk ${id} prüfen`)
|
||
}
|
||
|
||
// ── 5.5.0 ───────────────────────────────────────────────────────────────
|
||
export function kompressionPause(an: boolean): void {
|
||
anKern({ art: 'kompression-pause', an }, an ? 'Kompression anhalten' : 'Kompression weiter')
|
||
}
|
||
|
||
export function automatikStopp(id: string): void {
|
||
senden({ art: 'automatik-stopp', id })
|
||
}
|
||
|
||
export function automatikJetzt(id: string): void {
|
||
anKern({ art: 'automatik-jetzt', id }, 'Automatik jetzt')
|
||
}
|
||
|
||
export function benachrichtigungTesten(): void {
|
||
anKern({ art: 'benachrichtigung-testen' }, 'Testnachricht')
|
||
}
|
||
|
||
export function posterLaden(): void {
|
||
senden({ art: 'poster-laden' })
|
||
}
|
||
|
||
export function speicherLaden(): void {
|
||
senden({ art: 'speicher-laden' })
|
||
}
|