Files
rippy/rippy-windows/src/kern/rip/makemkv.ts
T
HitonabiandClaude Fable 5.1 6dd1b0bc7a feat(v5): Ein Smoke-Beweis, der KLICKT — Playwright durch das echte Programm
WAS: `npm run klick` startet das ECHTE Rippy (Electron, Haupt, Kern,
Fenster, Datenbank) mit eigenem Profil (--profil, --klick-smoke: ohne
Tray, Updater, Toasts), einem nachgebauten Laufwerk (RIPPY_FAKE_LAUFWERK)
und nachgebauten Werkzeugen (bau/klick/fake-*.cjs) — und klickt sich
durch: Rippen → Dialog → Sprachen je Titel (jpn statt deu) → Rollen auf
Folge → Staffel 1, ab Folge 3 → Vorschau → Rip → Warteschlange →
Kompression → Ablage als S01E03/S01E04 unter Serien\…\Season 01 →
Einstellungen/Roh-Dateien → Roh loeschen → Ereignisse. Bilder liegen in
beweise/klick/. Die Naht dafuer (kern/werkzeuge/aufruf.ts): Zeigt ein
Werkzeug-Pfad auf ein Node-Skript, laeuft es ueber die eigene Laufzeit
(ELECTRON_RUN_AS_NODE) — fuer EXE-Pfade aendert sich nichts.

WARUM: Beide Fehler vom 01.09.2026 waren Verkabelung; 261 Unit-Tests
sahen keinen. Der Klick-Beweis haette beide gefunden.

NEBENBEFUND: Die CSP blockte eingebettete Schrift-Teile (data:-Fonts der
fontsource-Pakete) — Konsolenfehler im gebauten Fenster, jetzt font-src.
Dazu test/messung.spuren.test.ts: HandBrake-Scan einer echten Roh-Datei
auf Verlangen (RIPPY_MESSUNG_MKV).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 22:22:04 +02:00

304 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// makemkvcon ansprechen — Kommandobau und Lauf (KONZEPT § 6.2).
// Die Auswertung der Ausgabe steckt in RipAuswertung (pur, testbar);
// hier bleiben nur spawn, Zeilenzerlegung und Aufräumen.
//
// Was aus dem Docker-Zweig zwingend mitkommt (ripping.py, gemessen):
// - Robot-Mode: -r --noscan --progress=-same (--noscan: der Geräte-Scan
// hing bzw. stürzte — Befund 23.07.)
// - BINÄR lesen, je Zeile dekodieren (textVon): makemkvcon mischt
// Kodierungen; „Das Öffnen" war die Folge von text=True.
// - Lesefehler heißen Lesefehler: MakeMKV kann 1 von 2 Titeln sichern und
// sich trotzdem mit 0 beenden (30.08.2026, Spartacus Disc 2).
// - Kritische MSG-NUMMERN einsammeln — die letzte Zeile ist fast immer
// nur „Failed to open disc", die Ursache steht Zeilen davor.
import { werkzeugSpawn } from '../werkzeuge/aufruf'
import { mkdirSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import {
KRITISCHE_CODES,
KRITISCHE_TEXTE,
LESEFEHLER_MARKE,
offsetAusMeldung,
parseMsg,
parseStreamInfo,
parseTitelInfo,
progressAusPrgv,
quelle,
sprachenZusammenfassen,
textVon,
type SprachEintrag,
type TitelInfo,
type TitelStreams,
} from './parser'
export function buildInfoArgs(geraetepfad: string): string[] {
return ['-r', '--noscan', 'info', quelle(geraetepfad)]
}
export function buildRipArgs(geraetepfad: string, zielOrdner: string, titel: string): string[] {
return ['-r', '--noscan', '--progress=-same', 'mkv', quelle(geraetepfad), titel, zielOrdner]
}
/** Ab so vielen Meldungen an DERSELBEN Stelle gilt der Lauf als hängend.
* Jeder Leseversuch wirft zwei Zeilen (SCSI-Fehler + Betriebssystemfehler),
* zwölf sind also rund sechs Versuche — genug, um einen kurzen Aussetzer
* von einem echten Defekt zu unterscheiden. */
export const HAENGT_AB = 12
/** Kennzeichnet einen Titel-Lauf, den ein NEUERER abgelöst hat. Der
* Aufrufer schweigt dazu — abgelöst ist kein Fehler, sondern Absicht. */
export const ABGELOEST = 'titel-lauf-abgeloest'
/** Auswertung der Rip-Ausgabe — pur, eine Zeile nach der anderen. */
export class RipAuswertung {
prozent = -1
letzteMeldung = ''
lesefehler = false
kritischeMeldungen: string[] = []
/** Die Stelle, an der es zuletzt klemmte — '' wenn keine. */
haengtBei = ''
/** Wie oft dieselbe Stelle schon gemeldet wurde. */
haengtWiederholungen = 0
/** Klemmt der Lauf an einer festen Stelle? Dann ist das ein Defekt auf
* der Disc und kein Fortschrittsproblem — Weiterversuchen hilft nicht. */
haengt(): boolean {
return this.haengtBei.length > 0 && this.haengtWiederholungen >= HAENGT_AB
}
/** Verarbeitet eine Zeile; gibt einen neuen Prozentwert zurück oder null. */
verarbeite(zeile: string): { prozent?: number; meldung?: { code: number; text: string } } {
const p = progressAusPrgv(zeile)
if (p >= 0) {
if (p === this.prozent) return {}
// Es geht wieder voran — was vorher klemmte, klemmt nicht mehr.
this.haengtBei = ''
this.haengtWiederholungen = 0
this.prozent = p
return { prozent: p }
}
const meldung = parseMsg(zeile)
if (meldung === null) return {}
this.letzteMeldung = meldung.text
if (meldung.text.includes(LESEFEHLER_MARKE)) this.lesefehler = true
// Dieselbe Stelle wieder und wieder heißt: physischer Defekt.
const stelle = offsetAusMeldung(meldung.text)
if (stelle.length > 0) {
if (stelle === this.haengtBei) {
this.haengtWiederholungen += 1
} else {
this.haengtBei = stelle
this.haengtWiederholungen = 1
}
}
if (meldung.code in KRITISCHE_CODES) {
this.kritischeMeldungen.push(`${KRITISCHE_CODES[meldung.code]} (${meldung.text.trim()})`)
} else if (KRITISCHE_TEXTE.some((muster) => meldung.text.includes(muster))) {
this.kritischeMeldungen.push(meldung.text)
}
return { meldung }
}
fehlerText(returncode: number | null, dateienDa: boolean): string {
return (
`makemkvcon endete mit Code ${returncode ?? '?'}` +
(this.kritischeMeldungen.length > 0 ? ` — Ursache: ${this.kritischeMeldungen.join('; ')}` : '') +
(this.letzteMeldung.length > 0 ? ` — letzte Meldung: ${this.letzteMeldung}` : '') +
(dateienDa ? '' : ' — keine MKV-Datei entstanden')
)
}
}
/** Buffer-Ströme in Zeilen zerlegen — binär, die letzte halbe Zeile bleibt
* liegen, bis ihr Rest kommt. Je Zeile wird einzeln dekodiert (textVon). */
export class ZeilenLeser {
private rest: Buffer = Buffer.alloc(0)
hinzu(stueck: Buffer): string[] {
this.rest = this.rest.length === 0 ? stueck : Buffer.concat([this.rest, stueck])
const zeilen: string[] = []
for (;;) {
const umbruch = this.rest.indexOf(0x0a)
if (umbruch < 0) break
let ende = umbruch
if (ende > 0 && this.rest[ende - 1] === 0x0d) ende -= 1
zeilen.push(textVon(this.rest.subarray(0, ende)))
this.rest = this.rest.subarray(umbruch + 1)
}
return zeilen
}
schluss(): string[] {
if (this.rest.length === 0) return []
const letzte = textVon(this.rest)
this.rest = Buffer.alloc(0)
return letzte.trim().length > 0 ? [letzte] : []
}
}
export interface DiscInfo {
/** Was MakeMKV als URSACHE genannt hat, wenn nichts herauskam — die
* kritischen MSG-Nummern im Klartext. Leer, wenn alles glatt lief.
*
* Warum (01.09.2026 am Laufwerk gemessen): Beim Zustand 5042 („Das
* Programm konnte keine verwendbaren optischen Laufwerke finden")
* antwortet der Info-Lauf nach 0,7 s mit TCOUNT:0. Ohne diese Zeile
* stünde im Fenster nur „keine Titel gefunden" — und niemand wüsste,
* dass das Laufwerk gemeint ist und nicht die Disc. */
ursachen: string[]
titel: TitelInfo[]
streams: Map<number, TitelStreams>
sprachen: { audio: SprachEintrag[]; untertitel: SprachEintrag[] }
}
/** EIN Info-Lauf liefert Titel UND Sprachen — er dauert je Disc 20120 s,
* zweimal holen wäre eine Minute Wartezeit für nichts (ripping.py). */
export function titelInfoLesen(
programm: string,
geraetepfad: string,
timeoutMs = 300_000,
abbruch?: AbortSignal,
): Promise<DiscInfo> {
return new Promise((fertig, scheitert) => {
const kind = werkzeugSpawn(programm, buildInfoArgs(geraetepfad), {
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe'],
})
const stuecke: Buffer[] = []
kind.stdout.on('data', (stueck: Buffer) => stuecke.push(stueck))
kind.stderr.on('data', (stueck: Buffer) => stuecke.push(stueck))
const zeiger = setTimeout(() => {
kind.kill()
scheitert(new Error(`makemkvcon info: keine Antwort nach ${Math.round(timeoutMs / 1000)} s`))
}, timeoutMs)
// Ein abgelöster Lauf lässt das Laufwerk sofort los — zwei makemkvcon
// auf derselben Disc machen beide langsamer (Commander-Fund 01.09.2026:
// Ein alter Lauf meldete 300 s später seinen Timeout und legte ihn über
// die längst gelieferte Titelliste).
const aufgeben = (): void => {
clearTimeout(zeiger)
kind.kill()
scheitert(new Error(ABGELOEST))
}
abbruch?.addEventListener('abort', aufgeben, { once: true })
kind.on('error', (fehler) => {
clearTimeout(zeiger)
abbruch?.removeEventListener('abort', aufgeben)
scheitert(fehler)
})
kind.on('close', () => {
clearTimeout(zeiger)
abbruch?.removeEventListener('abort', aufgeben)
const ausgabe = textVon(Buffer.concat(stuecke))
const titel = parseTitelInfo(ausgabe)
const streams = parseStreamInfo(ausgabe)
// Die Ursache steht in den MSG-Nummern, nicht in der letzten Zeile.
const ursachen: string[] = []
for (const zeile of ausgabe.split('\n')) {
const meldung = parseMsg(zeile)
if (meldung === null) continue
if (meldung.code in KRITISCHE_CODES && !ursachen.includes(KRITISCHE_CODES[meldung.code])) {
ursachen.push(KRITISCHE_CODES[meldung.code])
}
}
fertig({ ursachen, titel, streams, sprachen: sprachenZusammenfassen(streams) })
})
})
}
export type RipErgebnis =
| { status: 'success'; dateien: string[]; lesefehler: boolean }
| { status: 'cancelled' }
| { status: 'error'; fehler: string; lesefehler: boolean }
export interface RipLaufOptionen {
programm: string
geraetepfad: string
zielOrdner: string
/** 'all' oder eine Titel-Nummer als Text. */
titel: string
aufFortschritt?: (prozent: number) => void
aufMeldung?: (code: number, text: string) => void
/** Der Lauf klemmt an einer festen Stelle (oder klemmt nicht mehr) —
* das ist ein Disc-Defekt, kein Fortschrittsproblem. */
aufHaengt?: (klemmt: { offset: string; wiederholungen: number } | null) => void
abbruch?: AbortSignal
}
/** Rippt verlustfrei. Löst NIE mit einer Ausnahme auf — das Ergebnis trägt
* den Fehler als Wert (der Aufrufer meldet ihn weiter, R4). */
export function rippen(optionen: RipLaufOptionen): Promise<RipErgebnis> {
return new Promise((fertig) => {
mkdirSync(optionen.zielOrdner, { recursive: true })
const kind = werkzeugSpawn(
optionen.programm,
buildRipArgs(optionen.geraetepfad, optionen.zielOrdner, optionen.titel),
{ windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] },
)
const auswertung = new RipAuswertung()
const leser = new ZeilenLeser()
let abgebrochen = false
const abbrechen = (): void => {
abgebrochen = true
kind.kill()
}
optionen.abbruch?.addEventListener('abort', abbrechen, { once: true })
// Ob es klemmt, wird gemeldet, sobald es sich ÄNDERT — nicht bei jeder
// Zeile, sonst rauscht dieselbe Nachricht hundertfach durchs Fenster.
let klemmteVorher = false
const zeile = (z: string): void => {
const ergebnis = auswertung.verarbeite(z)
if (ergebnis.prozent !== undefined) optionen.aufFortschritt?.(ergebnis.prozent)
if (ergebnis.meldung !== undefined) {
optionen.aufMeldung?.(ergebnis.meldung.code, ergebnis.meldung.text)
}
const klemmt = auswertung.haengt()
if (klemmt !== klemmteVorher || (klemmt && auswertung.haengtWiederholungen % 10 === 0)) {
klemmteVorher = klemmt
optionen.aufHaengt?.(
klemmt ? { offset: auswertung.haengtBei, wiederholungen: auswertung.haengtWiederholungen } : null,
)
}
}
kind.stdout.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
kind.stderr.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
kind.on('error', (fehler) => {
optionen.abbruch?.removeEventListener('abort', abbrechen)
fertig({ status: 'error', fehler: String(fehler), lesefehler: false })
})
kind.on('close', (code) => {
optionen.abbruch?.removeEventListener('abort', abbrechen)
leser.schluss().forEach(zeile)
if (abgebrochen) {
fertig({ status: 'cancelled' })
return
}
let dateien: string[] = []
try {
dateien = readdirSync(optionen.zielOrdner)
.filter((name) => name.endsWith('.mkv'))
.sort()
.map((name) => join(optionen.zielOrdner, name))
} catch (fehler) {
fertig({ status: 'error', fehler: `Zielordner nicht lesbar: ${String(fehler)}`, lesefehler: auswertung.lesefehler })
return
}
if (code === 0 && dateien.length > 0) {
fertig({ status: 'success', dateien, lesefehler: auswertung.lesefehler })
return
}
fertig({
status: 'error',
fehler: auswertung.fehlerText(code, dateien.length > 0),
lesefehler: auswertung.lesefehler,
})
})
})
}