feat(v5): Etappe W-2 — Rippen: Parser, makemkvcon-Lauf, Werkzeug-Katalog
Ampel / ampel (push) Failing after 1m22s
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:
co-authored by
Claude Fable 5
parent
a45069af5e
commit
844bca37ff
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user