Ampel / ampel (push) Successful in 1m34s
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>
301 lines
10 KiB
TypeScript
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 })
|
|
}
|