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
+71
View File
@@ -0,0 +1,71 @@
// Tests des Werkzeug-Katalogs — Suchreihenfolge und die %VAR%-Falle
// (Groß/Klein), beides ohne installierte Programme (Prüf-Funktionen
// werden eingespritzt, Muster aus test_katalog.py).
import { describe, expect, it } from 'vitest'
import { entfalten, finden, kandidaten, WERKZEUGE } from '../src/kern/werkzeuge/katalog'
describe('entfalten — %VAR% ohne Rücksicht auf Schreibweise', () => {
it('die gemessene Falle: PROGRAMFILES(X86) trifft %ProgramFiles(x86)%', () => {
// Windows liefert Umgebungsnamen GROSS; das Muster schreibt sie
// gemischt. Der strikte Vergleich ließ die bekannten Orte auf einer
// echten Maschine NIE greifen (katalog.py, 28.08.2026).
expect(entfalten('%ProgramFiles(x86)%\\MakeMKV', { 'PROGRAMFILES(X86)': 'C:\\Program Files (x86)' })).toBe(
'C:\\Program Files (x86)\\MakeMKV',
)
})
it('bleibt ein % stehen, ist der Kandidat unbrauchbar', () => {
expect(entfalten('%GibtEsNicht%\\X', {})).toBe('')
})
})
describe('kandidaten — Reihenfolge: eigener Ordner vor System', () => {
it('eigener Ordner zuerst, dann bekannte Orte, 64 Bit zuerst', () => {
const liste = kandidaten('makemkv', 'C:\\R\\tools', {
'PROGRAMFILES(X86)': 'C:\\PF86',
PROGRAMFILES: 'C:\\PF',
})
expect(liste[0]).toBe('C:\\R\\tools\\makemkv\\makemkvcon64.exe')
expect(liste).toContain('C:\\PF86\\MakeMKV\\makemkvcon64.exe')
expect(liste.indexOf('C:\\R\\tools\\makemkvcon64.exe')).toBeLessThan(
liste.indexOf('C:\\PF86\\MakeMKV\\makemkvcon64.exe'),
)
})
})
describe('finden', () => {
const umgebung = { 'PROGRAMFILES(X86)': 'C:\\PF86', PATH: '' }
it('ein eingestellter Pfad gewinnt — und ein falscher wird NICHT ersetzt', () => {
const existiert = (p: string): boolean => p === 'D:\\eigen\\makemkvcon64.exe'
expect(finden('makemkv', 'D:\\eigen\\makemkvcon64.exe', 'C:\\R', umgebung, existiert, () => '')).toBe(
'D:\\eigen\\makemkvcon64.exe',
)
// Tippfehler soll als Fehler sichtbar werden, nicht still anderswo landen.
expect(finden('makemkv', 'D:\\tippfehler.exe', 'C:\\R', umgebung, existiert, () => '')).toBe('')
})
it('findet über die bekannten Orte', () => {
const existiert = (p: string): boolean => p === 'C:\\PF86\\MakeMKV\\makemkvcon64.exe'
expect(finden('makemkv', '', 'C:\\R', umgebung, existiert, () => '')).toBe(
'C:\\PF86\\MakeMKV\\makemkvcon64.exe',
)
})
it('fällt zuletzt auf die Registry zurück', () => {
expect(finden('makemkv', '', 'C:\\R', umgebung, () => false, () => 'C:\\Reg\\makemkvcon64.exe')).toBe(
'C:\\Reg\\makemkvcon64.exe',
)
})
it('nichts gefunden ist "", kein Raten', () => {
expect(finden('makemkv', '', 'C:\\R', umgebung, () => false, () => '')).toBe('')
})
})
describe('WERKZEUGE — die Pflicht-Markierungen', () => {
it('nur MakeMKV ist Pflicht; ohne HandBrake/FLAC läuft Rippy eingeschränkt', () => {
expect(WERKZEUGE.makemkv.pflicht).toBe(true)
expect(WERKZEUGE.handbrake.pflicht).toBe(false)
expect(WERKZEUGE.flac.pflicht).toBe(false)
})
})
@@ -0,0 +1,57 @@
// MESSUNG an echter Hardware: makemkvcon finden und die eingelegte Disc
// befragen (Titel + Sprachen). Läuft nur auf Verlangen:
//
// RIPPY_MESSUNG=1 npx vitest run test/messung.makemkv.test.ts
//
// Der VOLLE Rip ist ein eigener Lauf (messung.rip.test.ts) — er dauert
// je Disc 3090 Minuten.
import { describe, expect, it } from 'vitest'
const messen = process.env.RIPPY_MESSUNG === '1' && process.platform === 'win32'
describe.skipIf(!messen)('makemkvcon am echten System', () => {
it('wird über den Katalog gefunden', async () => {
const { finden } = await import('../src/kern/werkzeuge/katalog')
const pfad = finden('makemkv')
console.log(`MESSUNG makemkvcon: ${pfad.length > 0 ? pfad : 'NICHT GEFUNDEN'}`)
const handbrake = finden('handbrake')
console.log(`MESSUNG HandBrakeCLI: ${handbrake.length > 0 ? handbrake : 'NICHT GEFUNDEN'}`)
const flac = finden('flac')
console.log(`MESSUNG flac: ${flac.length > 0 ? flac : 'NICHT GEFUNDEN'}`)
expect(pfad.length).toBeGreaterThan(0)
})
it('liest Titel und Sprachen der eingelegten Disc', { timeout: 360_000 }, async () => {
const { finden } = await import('../src/kern/werkzeuge/katalog')
const { titelInfoLesen } = await import('../src/kern/rip/makemkv')
const { win32Api } = await import('../src/kern/laufwerk/win32')
const { listOptischeLaufwerke } = await import('../src/kern/laufwerk/disc')
const programm = finden('makemkv')
expect(programm.length).toBeGreaterThan(0)
const geraete = listOptischeLaufwerke(win32Api())
expect(geraete.length).toBeGreaterThan(0)
const info = await titelInfoLesen(programm, geraete[0])
console.log(`MESSUNG Titel: ${info.titel.length}`)
for (const t of info.titel.slice(0, 12)) {
console.log(
`MESSUNG #${t.nr}: ${Math.floor(t.dauerS / 60)}min ${t.dauerS % 60}s · ` +
`${(t.groesseBytes / 1e9).toFixed(2)} GB · ${t.kapitel} Kapitel`,
)
}
console.log(
`MESSUNG Sprachen Ton: ${info.sprachen.audio.map((s) => `${s.lang}(${s.spuren})`).join(', ') || '—'}`,
)
console.log(
`MESSUNG Sprachen Untertitel: ${info.sprachen.untertitel.map((s) => `${s.lang}(${s.spuren})`).join(', ') || '—'}`,
)
expect(info.titel.length).toBeGreaterThan(0)
})
})
describe.skipIf(messen)('Messung übersprungen', () => {
it('läuft nur mit RIPPY_MESSUNG=1 unter Windows', () => {
expect(true).toBe(true)
})
})
+68
View File
@@ -0,0 +1,68 @@
// DER W-2-BEWEIS: ein voller, verlustfreier Rip der eingelegten Disc am
// echten Laufwerk — dauert 3090 Minuten. Läuft nur auf Verlangen:
//
// RIPPY_MESSUNG_RIP=1 RIPPY_MESSUNG_ZIEL=<ordner> \
// npx vitest run test/messung.rip.test.ts --reporter=verbose
//
// Fortschritt wird in 5-%-Schritten geloggt; das Ergebnis nennt Dateien
// und Größen. Das Protokoll wandert in den SAVEPOINT (beweisen statt
// behaupten).
import { statSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const messen = process.env.RIPPY_MESSUNG_RIP === '1' && process.platform === 'win32'
const zielWurzel = process.env.RIPPY_MESSUNG_ZIEL ?? ''
describe.skipIf(!messen)('Voller Rip am echten Laufwerk', () => {
it('rippt die eingelegte Disc verlustfrei', { timeout: 3 * 60 * 60 * 1000 }, async () => {
expect(zielWurzel.length, 'RIPPY_MESSUNG_ZIEL fehlt').toBeGreaterThan(0)
const { finden } = await import('../src/kern/werkzeuge/katalog')
const { rippen } = await import('../src/kern/rip/makemkv')
const { win32Api } = await import('../src/kern/laufwerk/win32')
const { listOptischeLaufwerke } = await import('../src/kern/laufwerk/disc')
const programm = finden('makemkv')
expect(programm.length).toBeGreaterThan(0)
const geraete = listOptischeLaufwerke(win32Api())
expect(geraete.length).toBeGreaterThan(0)
const start = Date.now()
let letzteStufe = -5
const ergebnis = await rippen({
programm,
geraetepfad: geraete[0],
zielOrdner: zielWurzel,
titel: 'all',
aufFortschritt: (prozent) => {
if (prozent >= letzteStufe + 5) {
letzteStufe = prozent
const minuten = ((Date.now() - start) / 60000).toFixed(1)
console.log(`RIP-FORTSCHRITT ${prozent}% nach ${minuten}min`)
}
},
aufMeldung: (code, text) => {
// Nur die tragenden Meldungen — sonst wird das Protokoll unlesbar.
if (code >= 5000 || text.includes('errors') || text.includes('gesichert') || text.includes('saved')) {
console.log(`RIP-MSG ${code}: ${text}`)
}
},
})
const minuten = ((Date.now() - start) / 60000).toFixed(1)
if (ergebnis.status === 'success') {
console.log(`RIP-ERGEBNIS success nach ${minuten}min · lesefehler=${ergebnis.lesefehler}`)
for (const datei of ergebnis.dateien) {
console.log(`RIP-DATEI ${datei} · ${(statSync(datei).size / 1e9).toFixed(2)} GB`)
}
} else {
console.log(`RIP-ERGEBNIS ${ergebnis.status} nach ${minuten}min: ${'fehler' in ergebnis ? ergebnis.fehler : ''}`)
}
expect(ergebnis.status).toBe('success')
})
})
describe.skipIf(messen)('Rip-Messung übersprungen', () => {
it('läuft nur mit RIPPY_MESSUNG_RIP=1 unter Windows', () => {
expect(true).toBe(true)
})
})
+74
View File
@@ -0,0 +1,74 @@
// Tests der Rip-Lauf-Bausteine: Zeilenzerlegung (binär, halbe Zeilen)
// und die Auswertung (Fortschritt, Lesefehler, kritische Ursachen) —
// alles ohne echtes makemkvcon.
import { describe, expect, it } from 'vitest'
import { buildInfoArgs, buildRipArgs, RipAuswertung, ZeilenLeser } from '../src/kern/rip/makemkv'
describe('Kommandobau', () => {
it('info: Robot-Mode ohne Geräte-Scan', () => {
expect(buildInfoArgs('\\\\.\\G:')).toEqual(['-r', '--noscan', 'info', 'dev:G:'])
})
it('mkv: Fortschritt in denselben Strom', () => {
expect(buildRipArgs('\\\\.\\G:', 'C:\\ziel', 'all')).toEqual([
'-r',
'--noscan',
'--progress=-same',
'mkv',
'dev:G:',
'all',
'C:\\ziel',
])
})
})
describe('ZeilenLeser — binär, halbe Zeilen bleiben liegen', () => {
it('setzt Zeilen über Stück-Grenzen zusammen', () => {
const leser = new ZeilenLeser()
expect(leser.hinzu(Buffer.from('PRGV:1,2,'))).toEqual([])
expect(leser.hinzu(Buffer.from('65536\r\nMSG:1,0,0,"a"\nRest'))).toEqual([
'PRGV:1,2,65536',
'MSG:1,0,0,"a"',
])
expect(leser.schluss()).toEqual(['Rest'])
})
it('dekodiert jede Zeile einzeln — auch cp1252 mitten im Strom', () => {
const leser = new ZeilenLeser()
const zeilen = leser.hinzu(
Buffer.concat([Buffer.from('MSG:5010,0,0,"Das '), Buffer.from('Öffnen', 'latin1'), Buffer.from('"\n')]),
)
expect(zeilen).toEqual(['MSG:5010,0,0,"Das Öffnen"'])
})
})
describe('RipAuswertung', () => {
it('meldet Fortschritt nur bei Änderung', () => {
const a = new RipAuswertung()
expect(a.verarbeite('PRGV:0,32768,65536')).toEqual({ prozent: 50 })
expect(a.verarbeite('PRGV:0,32769,65536')).toEqual({})
expect(a.verarbeite('PRGV:0,45876,65536')).toEqual({ prozent: 70 })
})
it('erkennt Lesefehler an der sprachfesten URL — auch auf Deutsch', () => {
const a = new RipAuswertung()
a.verarbeite(
'MSG:2003,0,3,"Encountered 29 errors of type \'Read Error\' - see http://www.makemkv.com/errors/read/","f"',
)
expect(a.lesefehler).toBe(true)
})
it('sammelt kritische URSACHEN über die Nummer, nicht den Text', () => {
const a = new RipAuswertung()
a.verarbeite('MSG:5051,0,0,"Der Testzeitraum ist abgelaufen. Erwerben Sie bitte einen Schlüssel"')
a.verarbeite('MSG:5010,0,0,"Das Öffnen der Disk schlug fehl"')
const fehler = a.fehlerText(11, false)
expect(fehler).toContain('Code 11')
expect(fehler).toContain('nicht freigeschaltet')
expect(fehler).toContain('keine MKV-Datei entstanden')
})
it('englische Textmuster bleiben als Rückfall', () => {
const a = new RipAuswertung()
a.verarbeite('MSG:9999,0,0,"AACS volume key is unknown for this disc"')
expect(a.kritischeMeldungen).toHaveLength(1)
})
})
+186
View File
@@ -0,0 +1,186 @@
// Tests des makemkvcon-Parsers — die Testfälle wandern sinngemäß aus dem
// Docker-Zweig mit (test_makemkv_aufruf.py, ripping.py-Doku): Ein neu
// geschriebener Parser, der dieselben Fälle besteht, kennt dieselben
// Fallen (KONZEPT § 10).
import { describe, expect, it } from 'vitest'
import {
episodenTitel,
KRITISCHE_CODES,
laengsterTitel,
parseMsg,
parseStreamInfo,
parseTitelInfo,
progressAusPrgv,
quelle,
sprachenZusammenfassen,
textVon,
} from '../src/kern/rip/parser'
describe('quelle — wie MakeMKV das Laufwerk nennt', () => {
it.each([
['\\\\.\\G:', 'dev:G:'],
['G:', 'dev:G:'],
['G:\\', 'dev:G:'],
['\\\\?\\E:', 'dev:E:'],
['/dev/sr0', 'dev:/dev/sr0'],
])('%s → %s', (eingabe, erwartet) => {
expect(quelle(eingabe)).toBe(erwartet)
})
it('ohne Gerät kommt nichts', () => {
expect(quelle('')).toBe('')
expect(quelle(' ')).toBe('')
})
it('ein langer Windows-Pfad wird nicht verstümmelt', () => {
expect(quelle('\\\\.\\D:\\Ordner')).toBe('dev:D:\\Ordner')
})
})
describe('textVon — Kodierung bestimmen statt behaupten', () => {
it('liest UTF-8', () => {
expect(textVon(Buffer.from('Das Öffnen', 'utf8'))).toBe('Das Öffnen')
})
it('liest AUCH cp1252', () => {
expect(textVon(Buffer.from('Das Öffnen', 'latin1'))).toBe('Das Öffnen')
})
it('der Fehler des Commanders kommt NICHT zurück (kein Ã)', () => {
for (const kodierung of ['utf8', 'latin1'] as const) {
const gelesen = textVon(Buffer.from('Das Öffnen der Disk schlug fehl', kodierung))
expect(gelesen).not.toContain('Ã')
expect(gelesen).toBe('Das Öffnen der Disk schlug fehl')
}
})
it('Text bleibt Text, leer bleibt leer, kaputte Bytes werfen nicht', () => {
expect(textVon('schon Text')).toBe('schon Text')
expect(textVon(Buffer.alloc(0))).toBe('')
expect(textVon(Buffer.from([0xff, 0xfe, 0x20]))).toBeTypeOf('string')
})
})
describe('parseMsg', () => {
it('liest Code und Klartext', () => {
expect(parseMsg('MSG:5010,0,1,"Das Öffnen der Disk schlug fehl","%1","x"')).toEqual({
code: 5010,
text: 'Das Öffnen der Disk schlug fehl',
})
})
it('Meldungen MIT Komma werden nicht abgeschnitten (25.07.-Falle)', () => {
const m = parseMsg('MSG:3025,0,1,"Title #1 has length of 12 seconds, which is less than minimum","f"')
expect(m?.text).toBe('Title #1 has length of 12 seconds, which is less than minimum')
})
it('maskierte Anführungszeichen werden entpackt', () => {
expect(parseMsg('MSG:1,0,0,"sagt \\"hallo\\""')?.text).toBe('sagt "hallo"')
})
it('Nicht-MSG-Zeilen sind null', () => {
expect(parseMsg('PRGV:0,1,65536')).toBeNull()
expect(parseMsg('irgendwas')).toBeNull()
})
})
describe('progressAusPrgv', () => {
it('total/max ist der Gesamtfortschritt', () => {
expect(progressAusPrgv('PRGV:100,32768,65536')).toBe(50)
expect(progressAusPrgv('PRGV:0,65536,65536')).toBe(100)
})
it('ein echtes 0 % ist eine Angabe, keine Zeile ist -1', () => {
expect(progressAusPrgv('PRGV:0,0,65536')).toBe(0)
expect(progressAusPrgv('MSG:1,0,0,"x"')).toBe(-1)
expect(progressAusPrgv('PRGV:1,1,0')).toBe(-1)
})
})
// Fixture nach dem dokumentierten Robot-Format; die SINFO-Zeilen sind das
// am 26.07.2026 an der Akira-Blu-ray abgenommene Muster.
const INFO_AUSGABE = [
'TINFO:0,8,0,"12"',
'TINFO:0,9,0,"1:57:07"',
'TINFO:0,11,0,"31474929664"',
'TINFO:1,9,0,"0:02:01"',
'TINFO:1,8,0,"2"',
'SINFO:0,0,1,6201,"Video"',
'SINFO:0,1,1,6202,"Audio"',
'SINFO:0,1,3,0,"deu"',
'SINFO:0,1,4,0,"German"',
'SINFO:0,1,6,0,"DTS-HD MA"',
'SINFO:0,1,14,0,"2"',
'SINFO:0,1,30,0,"DTS-HD MA Stereo German"',
'SINFO:0,2,1,6202,"Audio"',
'SINFO:0,2,3,0,"jpn"',
'SINFO:0,2,4,0,"Japanese"',
'SINFO:0,3,1,6203,"Subtitles"',
'SINFO:0,3,3,0,"deu"',
'SINFO:0,3,4,0,"German"',
].join('\n')
describe('parseTitelInfo', () => {
it('liest Dauer, Größe und Kapitel je Titel', () => {
expect(parseTitelInfo(INFO_AUSGABE)).toEqual([
{ nr: 0, dauerS: 7027, groesseBytes: 31_474_929_664, kapitel: 12 },
{ nr: 1, dauerS: 121, groesseBytes: 0, kapitel: 2 },
])
})
it('leere Ausgabe ist eine leere Liste', () => {
expect(parseTitelInfo('')).toEqual([])
})
})
describe('parseStreamInfo + sprachenZusammenfassen', () => {
it('trennt Audio und Untertitel, Sprache aus Attribut 3/4', () => {
const streams = parseStreamInfo(INFO_AUSGABE)
const titel0 = streams.get(0)!
expect(titel0.audio.map((s) => s.lang)).toEqual(['deu', 'jpn'])
expect(titel0.audio[0]).toMatchObject({ codec: 'DTS-HD MA', kanaele: '2', sprache: 'German' })
expect(titel0.untertitel.map((s) => s.lang)).toEqual(['deu'])
})
it('fasst die Sprachen der ganzen Disc zusammen', () => {
const sprachen = sprachenZusammenfassen(parseStreamInfo(INFO_AUSGABE))
expect(sprachen.audio).toEqual([
{ lang: 'deu', sprache: 'German', spuren: 1 },
{ lang: 'jpn', sprache: 'Japanese', spuren: 1 },
])
expect(sprachen.untertitel).toEqual([{ lang: 'deu', sprache: 'German', spuren: 1 }])
})
})
describe('laengsterTitel', () => {
const dauern = new Map([
[0, 7027], // Hauptfilm ~117 min
[1, 7300], // „Play All" wäre länger
[2, 121],
])
it('ohne Soll-Laufzeit gewinnt der längste', () => {
expect(laengsterTitel(dauern)).toBe(1)
})
it('mit Soll-Laufzeit gewinnt der nächstliegende (Play-All-Falle)', () => {
expect(laengsterTitel(dauern, 117)).toBe(0)
})
it('Soll-Laufzeit weit weg von allem → wieder der längste', () => {
expect(laengsterTitel(new Map([[0, 100]]), 117)).toBe(0)
expect(laengsterTitel(new Map(), 117)).toBeNull()
})
})
describe('episodenTitel', () => {
it('findet Titel nahe der mittleren Episoden-Laufzeit (±10 min)', () => {
const dauern = new Map([
[1, 42 * 60],
[2, 44 * 60],
[3, 43 * 60],
[9, 170 * 60], // Play All
[10, 2 * 60], // Trailer
])
expect(episodenTitel(dauern, [43, 43, 42])).toEqual([1, 2, 3])
})
it('ohne Angaben keine Treffer', () => {
expect(episodenTitel(new Map(), [43])).toEqual([])
expect(episodenTitel(new Map([[1, 60]]), [])).toEqual([])
})
})
describe('KRITISCHE_CODES — die gemessenen Ursachen', () => {
it('kennt die vier am Commander-Laufwerk gesehenen Nummern + 5010', () => {
for (const code of [2024, 5010, 5020, 5021, 5051]) {
expect(KRITISCHE_CODES[code], `Code ${code}`).toBeDefined()
expect(KRITISCHE_CODES[code].length).toBeGreaterThan(20)
}
})
})