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>
304 lines
12 KiB
TypeScript
304 lines
12 KiB
TypeScript
// 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 20–120 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,
|
||
})
|
||
})
|
||
})
|
||
}
|