feat(v5): Etappe W-2 — Rippen: Parser, makemkvcon-Lauf, Werkzeug-Katalog
Ampel / ampel (push) Failing after 1m22s

rip/parser.ts: die reine Textverarbeitung mit allen bezahlten Fallen aus
dem Docker-Zweig — quelle() (dev:G: statt dev:\.\G:, der 404-Verwandte),
textVon() (UTF-8 streng, sonst cp1252 — nie wieder 'Öffnen'), parseMsg
(Regex statt split — Meldungen mit Komma), PRGV (-1 vs. echtes 0 %),
TINFO/SINFO (Sprache aus Attr 3/4, NICHT 28/29), laengsterTitel mit
Play-All-Schutz, episodenTitel, KRITISCHE_CODES (sprachfeste NUMMERN).

rip/makemkv.ts: Kommandobau + Lauf — ZeilenLeser (binaer, halbe Zeilen,
je Zeile dekodiert), RipAuswertung (pur), rippen() mit AbortSignal,
Lesefehler-Flag AUCH bei Rueckgabewert 0. werkzeuge/katalog.ts: die
Suchreihenfolge samt %VAR%-Gross/Klein-Falle und Registry-Rueckfall.
ablauf/rippen.ts: ein Rip je Laufwerk, Status-Push, 'erst nachsehen,
dann rippen' (unbekannt heisst trotzdem rippen). Fenster: Rippen-Knopf,
Fortschrittsbalken, Abbrechen; Auswurf waehrend des Rips abgelehnt.

Gemessen am echten System (30.08.2026): Katalog findet
makemkvcon64 (Program Files x86), HandBrakeCLI und flac (beide im
rc11-Werkzeugordner LOCALAPPDATA\Rippy\tools). NEUER BEFUND: Nach dem
abgebrochenen rc11-Rip vom Mittag sieht MakeMKV GAR KEIN Laufwerk mehr
(MSG 5042, leere DRV-Liste, mit und ohne --noscan), waehrend die
Storage-IOCTLs sauber antworten — MakeMKV spricht SCSI direkt. 5042 ist
jetzt ein KRITISCHER_CODE mit Klartext-Abhilfe. Der volle Rip-Beweis
(messung.rip.test.ts liegt bereit) braucht deshalb erst eine Hand am
Geraet: Disc neu einlegen oder USB ab-/anstecken.

96 Tests gruen, Smoke gruen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-30 18:12:44 +02:00
co-authored by Claude Fable 5
parent a45069af5e
commit 844bca37ff
13 changed files with 1495 additions and 23 deletions
+78 -14
View File
@@ -2,8 +2,15 @@
// Disc-Wache. Die große Oberfläche (Dashboard, Bibliothek, Einstellungen)
// ist Etappe W-5 — hier wird das Gerüst SICHTBAR bewiesen.
import { useEffect, useState } from 'react'
import type { GeraeteInfo, HauptStatus } from '../gemeinsam/nachrichten'
import { aufVerbindung, auswerfen, verbindungStarten, type Verbindung } from './kernverbindung'
import type { GeraeteInfo, HauptStatus, RipStatus } from '../gemeinsam/nachrichten'
import {
aufVerbindung,
auswerfen,
ripAbbrechen,
ripStarten,
verbindungStarten,
type Verbindung,
} from './kernverbindung'
function istHauptStatus(wert: unknown): wert is HauptStatus {
return (
@@ -51,10 +58,16 @@ function Kachel({ titel, zustand, zeilen }: KachelProps) {
)
}
function LaufwerkKachel({ geraet }: { geraet: GeraeteInfo }) {
function LaufwerkKachel({ geraet, rip }: { geraet: GeraeteInfo; rip: RipStatus | undefined }) {
const ripLaeuft = rip !== undefined && rip.phase === 'rippt'
const zustand = geraet.status === 'ready' ? 'ok' : geraet.status === 'empty' ? 'wartet' : 'fehler'
const punktFarbe =
zustand === 'ok' ? 'bg-emerald-400' : zustand === 'fehler' ? 'bg-red-400' : 'bg-slate-500'
const punktFarbe = ripLaeuft
? 'animate-pulse bg-sky-400'
: zustand === 'ok'
? 'bg-emerald-400'
: zustand === 'fehler'
? 'bg-red-400'
: 'bg-slate-500'
return (
<div className="rounded-xl border border-slate-700/60 bg-slate-800/40 p-4">
<div className="flex items-center justify-between gap-2">
@@ -62,14 +75,33 @@ function LaufwerkKachel({ geraet }: { geraet: GeraeteInfo }) {
<span className={`inline-block h-2.5 w-2.5 rounded-full ${punktFarbe}`} />
<h2 className="text-sm font-semibold tracking-wide text-slate-200">{geraet.name}</h2>
</div>
<button
onClick={() => auswerfen(geraet.id)}
disabled={geraet.status !== 'ready'}
className="rounded-lg border border-slate-600 px-3 py-1 text-xs text-slate-300 hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-40"
title="Entriegeln, auswerfen, nachsehen — in dieser Reihenfolge"
>
Auswerfen
</button>
<div className="flex gap-2">
{ripLaeuft ? (
<button
onClick={() => ripAbbrechen(geraet.id)}
className="rounded-lg border border-red-500/60 px-3 py-1 text-xs text-red-300 hover:bg-red-500/10"
>
Abbrechen
</button>
) : (
<button
onClick={() => ripStarten(geraet.id)}
disabled={geraet.status !== 'ready'}
className="rounded-lg border border-emerald-500/60 px-3 py-1 text-xs text-emerald-300 hover:bg-emerald-500/10 disabled:cursor-not-allowed disabled:opacity-40"
title="Alle Titel, verlustfrei (MakeMKV)"
>
Rippen
</button>
)}
<button
onClick={() => auswerfen(geraet.id)}
disabled={geraet.status !== 'ready' || ripLaeuft}
className="rounded-lg border border-slate-600 px-3 py-1 text-xs text-slate-300 hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-40"
title="Entriegeln, auswerfen, nachsehen — in dieser Reihenfolge"
>
Auswerfen
</button>
</div>
</div>
<div className="mt-2 space-y-0.5 font-mono text-xs text-slate-400">
<p>{geraet.modell.length > 0 ? geraet.modell : 'Modell unbekannt'}</p>
@@ -85,6 +117,37 @@ function LaufwerkKachel({ geraet }: { geraet: GeraeteInfo }) {
<p className="text-amber-300">{geraet.grund}</p>
)}
</div>
{rip !== undefined && (
<div className="mt-3 space-y-1.5 border-t border-slate-700/60 pt-3">
{ripLaeuft && (
<div className="h-2 overflow-hidden rounded-full bg-slate-700">
<div
className="h-full rounded-full bg-sky-400 transition-all"
style={{ width: `${Math.max(0, rip.prozent)}%` }}
/>
</div>
)}
<p
className={`break-all font-mono text-xs ${
rip.phase === 'fehler'
? 'text-red-300'
: rip.phase === 'fertig'
? rip.lesefehler
? 'text-amber-300'
: 'text-emerald-300'
: 'text-slate-300'
}`}
>
{ripLaeuft && rip.prozent >= 0 ? `${rip.prozent} % — ` : ''}
{rip.text}
</p>
{rip.dateien.map((datei) => (
<p key={datei} className="break-all font-mono text-xs text-slate-500">
{datei}
</p>
))}
</div>
)}
</div>
)
}
@@ -96,6 +159,7 @@ export default function App() {
kern: null,
pongLatenzMs: null,
laufwerke: [],
rips: {},
meldungen: [],
})
@@ -135,7 +199,7 @@ export default function App() {
) : (
<div className="grid gap-4 sm:grid-cols-2">
{verbindung.laufwerke.map((geraet) => (
<LaufwerkKachel key={geraet.id} geraet={geraet} />
<LaufwerkKachel key={geraet.id} geraet={geraet} rip={verbindung.rips[geraet.id]} />
))}
</div>
)}
+45 -7
View File
@@ -6,13 +6,16 @@
// 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 GeraeteInfo, type KernNachricht } from '../gemeinsam/nachrichten'
import { istKernNachricht, type GeraeteInfo, type KernNachricht, type RipStatus } 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>
/** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */
meldungen: string[]
}
@@ -21,13 +24,22 @@ type Horcher = (stand: Verbindung) => void
const MAX_MELDUNGEN = 8
const stand: Verbindung = { verbunden: false, kern: null, pongLatenzMs: null, laufwerke: [], meldungen: [] }
const stand: Verbindung = {
verbunden: false,
kern: null,
pongLatenzMs: null,
laufwerke: [],
rips: {},
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], meldungen: [...stand.meldungen] })
for (const h of horcher) {
h({ ...stand, laufwerke: [...stand.laufwerke], rips: { ...stand.rips }, meldungen: [...stand.meldungen] })
}
}
function meldung(text: string): void {
@@ -67,6 +79,20 @@ function kernNachricht(nachricht: KernNachricht): void {
meldung(nachricht.grund === 'ok' ? text : `${text}`)
break
}
case 'rip-status': {
stand.rips = { ...stand.rips, [nachricht.status.id]: nachricht.status }
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()
}
@@ -104,15 +130,27 @@ export function verbindungStarten(): void {
export function aufVerbindung(h: Horcher): () => void {
horcher.add(h)
h({ ...stand, laufwerke: [...stand.laufwerke], meldungen: [...stand.meldungen] })
h({ ...stand, laufwerke: [...stand.laufwerke], rips: { ...stand.rips }, meldungen: [...stand.meldungen] })
return () => horcher.delete(h)
}
export function auswerfen(id: string): void {
function anKern(nachricht: { art: string; id: string }, wasWar: string): void {
if (port === null) {
meldung(` Auswerfen ${id}: keine Verbindung zum Kern`)
meldung(`${wasWar}: keine Verbindung zum Kern`)
melden()
return
}
port.postMessage({ art: 'auswerfen', id })
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}`)
}