Files
rippy/rippy-windows/src/fenster/kernverbindung.ts
T
HitonabiandClaude Fable 5 d5ea48cd1e
Ampel / ampel (push) Successful in 1m34s
feat(v5): MakeMKV-Selbst-Update (§ 9) und Kino-Look fürs Fenster
Update-Strecke: Quellenkette (Hersteller 525 → Forum → Archiv, höchste
gewinnt, 31.08. nachgemessen), MZ- + Versions-Ressourcen-Prüfung
(GuinpinSoft inc, gemessen), Download in .neu, Start übers Haupt
(shell/UAC — Installer hängt bewusst NICHT an der Leine), Warte-Poll
bis die EXE-Version wechselt, dann Schlüssel neu prüfen. Ehrlicher
Zweig: installierte == neueste Fassung → kein sinnloser Download.

Fenster: Laufwerks-Kachel als Bühne (TMDb-Backdrop, Poster, Beschreibung,
Phasen-Label, Restzeit, aufklappbares Protokoll), Bibliothek als
Poster-Regal (DB-Schema 3: poster_pfad, Migration), Schlüssel-Karte mit
Update-Knopf und Beschaffungs-Balken, MakeMKV-Version als Badge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 20:18:08 +02:00

301 lines
10 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 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<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[]>
/** 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
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<Horcher>()
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<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({
...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 })
}