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
+149
View File
@@ -0,0 +1,149 @@
// Rip-Verwaltung (W-2) — die schlanke Vorstufe der Pipeline aus § 4.2:
// EIN Rip je Laufwerk, Status-Meldungen nach draußen, Abbruch über
// AbortController. makemkvcon läuft als Kind dieses Kerns und hängt damit
// automatisch an der Prozess-Leine (R6): Der Main-Prozess hat die
// Arbeitsgruppe VOR dem Kern-Start angelegt, Kinder erben die
// Mitgliedschaft.
import { join } from 'node:path'
import type { GeraeteInfo, RipStatus } from '../../gemeinsam/nachrichten'
import { rippen } from '../rip/makemkv'
interface LaufenderRip {
abbruch: AbortController
status: RipStatus
}
export interface RipUmgebung {
/** Pfad zu makemkvcon — oder '' wenn nicht gefunden. */
makemkvcon: () => string
/** Aktueller Stand des Laufwerks (für Typ und Vorab-Blick). */
geraet: (id: string) => GeraeteInfo | null
/** Wohin gerippt wird (Wurzel der Ablage). */
ablage: () => string
melden: (status: RipStatus) => void
/** Zeitstempel für Ordnernamen — injizierbar für Tests. */
jetzt?: () => Date
}
export class RipVerwaltung {
private laufend = new Map<string, LaufenderRip>()
constructor(private readonly umgebung: RipUmgebung) {}
laeuft(id: string): boolean {
return this.laufend.has(id)
}
status(id: string): RipStatus | null {
return this.laufend.get(id)?.status ?? null
}
private melden(status: RipStatus): void {
const eintrag = this.laufend.get(status.id)
if (eintrag !== undefined) eintrag.status = status
this.umgebung.melden(status)
}
/** Startet einen verlustfreien Rip der ganzen Disc ('all'). */
async starten(id: string): Promise<void> {
if (this.laufend.has(id)) {
this.umgebung.melden(this.fehler(id, `Auf Laufwerk ${id}: läuft schon ein Rip.`))
return
}
const programm = this.umgebung.makemkvcon()
if (programm.length === 0) {
this.umgebung.melden(
this.fehler(id, 'makemkvcon wurde nicht gefunden — ist MakeMKV installiert? (Suchwege: Einstellung, Rippy-Werkzeuge, PATH, Programme, Registry)'),
)
return
}
const geraet = this.umgebung.geraet(id)
if (geraet === null) {
this.umgebung.melden(this.fehler(id, `Laufwerk ${id}: gibt es nicht.`))
return
}
// Erst nachsehen, dann rippen (§ 6.2): Ein leeres Laufwerk bekommt
// einen Satz statt zwei Minuten makemkvcon mit Code 11. Aber: Ist der
// Zustand UNBEKANNT, wird trotzdem gerippt — „ich weiß es nicht" darf
// nie zu „es geht nicht" werden.
if (geraet.status === 'empty') {
this.umgebung.melden(
this.fehler(id, `Kein Datenträger in Laufwerk ${id}. Nach einem fertigen Rip wirft Rippy die Disc aus — für einen neuen Rip muss sie wieder hinein.`),
)
return
}
const abbruch = new AbortController()
const zeit = (this.umgebung.jetzt?.() ?? new Date())
.toISOString()
.replaceAll(/[:.]/g, '-')
.slice(0, 19)
const zielOrdner = join(this.umgebung.ablage(), 'roh', `${geraet.typ}-${zeit}`)
const start: RipStatus = {
id,
phase: 'rippt',
prozent: -1,
text: `Rippe verlustfrei nach ${zielOrdner}`,
dateien: [],
lesefehler: false,
}
this.laufend.set(id, { abbruch, status: start })
this.melden(start)
const ergebnis = await rippen({
programm,
geraetepfad: geraet.pfad,
zielOrdner,
titel: 'all',
abbruch: abbruch.signal,
aufFortschritt: (prozent) => {
const alt = this.laufend.get(id)?.status
if (alt === undefined) return
this.melden({ ...alt, prozent })
},
aufMeldung: (_code, text) => {
const alt = this.laufend.get(id)?.status
if (alt === undefined) return
this.melden({ ...alt, text })
},
})
this.laufend.delete(id)
if (ergebnis.status === 'success') {
this.melden({
id,
phase: 'fertig',
prozent: 100,
// Lesefehler AUCH bei Erfolg nennen — genau dann gehen sie sonst
// unter (Rückgabewert 0, ein Titel fehlt trotzdem).
text: ergebnis.lesefehler
? '⚠ Fertig, aber die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).'
: 'Fertig — verlustfrei gesichert.',
dateien: ergebnis.dateien,
lesefehler: ergebnis.lesefehler,
})
} else if (ergebnis.status === 'cancelled') {
this.melden({ id, phase: 'abgebrochen', prozent: -1, text: 'Abgebrochen.', dateien: [], lesefehler: false })
} else {
this.melden({
id,
phase: 'fehler',
prozent: -1,
text: ergebnis.fehler,
dateien: [],
lesefehler: ergebnis.lesefehler,
})
}
}
abbrechen(id: string): void {
this.laufend.get(id)?.abbruch.abort()
}
private fehler(id: string, text: string): RipStatus {
return { id, phase: 'fehler', prozent: -1, text, dateien: [], lesefehler: false }
}
}
+36
View File
@@ -7,16 +7,19 @@
// und »MessagePorts in Electron« — dieselbe Verdrahtung wie im gemessenen
// Beweis beweise/sqlite-main.js.
import type { MessagePortMain } from 'electron'
import { join } from 'node:path'
import {
istFensterNachricht,
istHauptNachricht,
type DbStatus,
type KernNachricht,
} from '../gemeinsam/nachrichten'
import { RipVerwaltung } from './ablauf/rippen'
import type { LaufwerkApi } from './laufwerk/codes'
import { auswerfenMitGrund, geraeteInfo, kennung, listOptischeLaufwerke } from './laufwerk/disc'
import { DiscWache } from './laufwerk/wache'
import { Datenbank } from './speicher/db'
import { finden } from './werkzeuge/katalog'
// --datenbank=<pfad> kommt vom Haupt (der Kern kennt Electrons app.getPath
// nicht — er ist ein reiner Node-Prozess).
@@ -101,8 +104,37 @@ const laufwerkeBereit: Promise<void> = (async () => {
}
})()
// ── Rippen (W-2) ────────────────────────────────────────────────────────
// Die Ablage kommt aus der EINEN Einstellungsquelle (§ 6.7); solange dort
// nichts steht, gilt Videos\Rippy im Benutzerprofil.
function ablageOrdner(): string {
const eingestellt = db?.einstellungLesen('ablage')
if (eingestellt !== null && eingestellt !== undefined && eingestellt.length > 0) return eingestellt
return join(process.env['USERPROFILE'] ?? '.', 'Videos', 'Rippy')
}
const ripVerwaltung = new RipVerwaltung({
makemkvcon: () => finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? ''),
geraet: (id) => {
if (laufwerkApi === null) return null
const geraet = listOptischeLaufwerke(laufwerkApi).find((g) => kennung(g) === id)
return geraet === undefined ? null : geraeteInfo(laufwerkApi, geraet)
},
ablage: ablageOrdner,
melden: (status) => anHauptUndFenster({ art: 'rip-status', status }),
})
async function auswerfen(id: string): Promise<void> {
await laufwerkeBereit
if (ripVerwaltung.laeuft(id)) {
anFenster({
art: 'auswurf-ergebnis',
id,
grund: 'abgelehnt',
fehler: `Auf Laufwerk ${id} läuft ein Rip — erst abbrechen oder abwarten.`,
})
return
}
if (laufwerkApi === null) {
anFenster({ art: 'auswurf-ergebnis', id, grund: 'kein-zugriff', fehler: laufwerkFehler })
return
@@ -135,6 +167,10 @@ process.parentPort.on('message', (ereignis) => {
anFenster({ art: 'pong', zeit: frage.zeit })
} else if (frage.art === 'auswerfen') {
void auswerfen(frage.id)
} else if (frage.art === 'rip-start') {
void laufwerkeBereit.then(() => ripVerwaltung.starten(frage.id))
} else if (frage.art === 'rip-abbruch') {
ripVerwaltung.abbrechen(frage.id)
}
})
fensterPort.start()
+225
View File
@@ -0,0 +1,225 @@
// 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 { spawn } from 'node:child_process'
import { mkdirSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import {
KRITISCHE_CODES,
KRITISCHE_TEXTE,
LESEFEHLER_MARKE,
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]
}
/** Auswertung der Rip-Ausgabe — pur, eine Zeile nach der anderen. */
export class RipAuswertung {
prozent = -1
letzteMeldung = ''
lesefehler = false
kritischeMeldungen: string[] = []
/** 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 {}
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
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 {
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,
): Promise<DiscInfo> {
return new Promise((fertig, scheitert) => {
const kind = spawn(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)
kind.on('error', (fehler) => {
clearTimeout(zeiger)
scheitert(fehler)
})
kind.on('close', () => {
clearTimeout(zeiger)
const ausgabe = textVon(Buffer.concat(stuecke))
const titel = parseTitelInfo(ausgabe)
const streams = parseStreamInfo(ausgabe)
fertig({ 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
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 = spawn(
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 })
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)
}
}
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,
})
})
})
}
+279
View File
@@ -0,0 +1,279 @@
// makemkvcon-Ausgabe lesen — reine Textverarbeitung, vollständig testbar
// ohne Laufwerk (KONZEPT § 6.2). Format laut
// https://www.makemkv.com/developers/usage.txt (Robot-Mode -r); jede
// Eigenheit hier ist im Docker-Zweig an echten Discs GEMESSEN worden
// (ripping.py / makemkv_aufruf.py) und wandert mit ihren Testfällen mit.
// ── Quelle: Wie MakeMKV DIESES Laufwerk nennt ───────────────────────────
// Der 29.08.-Befund: `dev:\\.\G:` → MSG 2024 „Unknown device";
// `dev:G:` → 68 TINFO-Zeilen. CreateFileW braucht den Geräte-Namensraum,
// MakeMKV will den Laufwerksbuchstaben. Entschieden wird nach der FORM des
// Pfades, nicht nach dem Rechner.
export function quelle(geraetepfad: string): string {
let pfad = (geraetepfad ?? '').trim()
if (pfad.length === 0) return ''
for (const vorsatz of ['\\\\.\\', '\\\\?\\']) {
if (pfad.startsWith(vorsatz)) {
pfad = pfad.slice(vorsatz.length)
break
}
}
// „G:\" → „G:" — ohne Trenner ist es das, was MakeMKV selbst ausgibt.
if (pfad.length === 3 && pfad[1] === ':' && (pfad[2] === '\\' || pfad[2] === '/')) {
pfad = pfad.slice(0, 2)
}
return `dev:${pfad}`
}
// ── Kodierung: BINÄR lesen, dann bestimmen statt behaupten ──────────────
// makemkvcon mischt Kodierungen je nach Konsole (UTF-8 oder cp1252): Beim
// Commander wurde aus „Öffnen" ein „Öffnen". UTF-8 STRENG versuchen,
// sonst cp1252 — mit errors=replace ginge jeder Umlaut still unter.
export function textVon(roh: Buffer | string): string {
if (typeof roh === 'string') return roh
if (roh.length === 0) return ''
try {
return new TextDecoder('utf-8', { fatal: true }).decode(roh)
} catch {
// cp1252 kann jedes Byte abbilden — hier kann nichts mehr scheitern.
return new TextDecoder('windows-1252').decode(roh)
}
}
// ── MSG-Zeilen ──────────────────────────────────────────────────────────
// MSG:code,flags,count,"message",… — Feld 4 ist der fertige Klartext.
// Ein naives split(",") schnitt jede Meldung mit Komma ab (25.07.2026) —
// und MakeMKV schreibt solche laufend.
const MSG_ZEILE = /^MSG:(\d+),\d+,\d+,"((?:[^"\\]|\\.)*)"/
export function parseMsg(zeile: string): { code: number; text: string } | null {
const treffer = MSG_ZEILE.exec(zeile.trim())
if (treffer === null) return null
return { code: Number(treffer[1]), text: treffer[2].replaceAll('\\"', '"') }
}
// ── PRGV: Gesamtfortschritt ─────────────────────────────────────────────
// PRGV:current,total,max — total/max ist der Gesamtfortschritt.
// -1 heißt „keine Angabe": Ein echtes 0 % muss von „nichts" unterscheidbar
// bleiben, sonst friert der Anfang jedes Rips optisch ein.
export function progressAusPrgv(zeile: string): number {
const treffer = /^PRGV:(\d+),(\d+),(\d+)/.exec(zeile.trim())
if (treffer === null) return -1
const gesamt = Number(treffer[2])
const maximum = Number(treffer[3])
if (maximum <= 0) return -1
return Math.min(100, Math.floor((gesamt * 100) / maximum))
}
// ── TINFO: Titel-Liste ──────────────────────────────────────────────────
// Attribut-IDs laut MakeMKV apdefs.h: 8 = ChapterCount, 9 = Duration
// ("H:MM:SS"), 11 = DiskSizeBytes.
export interface TitelInfo {
nr: number
dauerS: number
groesseBytes: number
kapitel: number
}
const TINFO_ZEILE = /^TINFO:(\d+),(\d+),\d+,"(.*)"\s*$/
export function parseTitelInfo(ausgabe: string): TitelInfo[] {
const titel = new Map<number, TitelInfo>()
for (const roh of ausgabe.split(/\r?\n/)) {
const treffer = TINFO_ZEILE.exec(roh.trim())
if (treffer === null) continue
const nr = Number(treffer[1])
const attr = Number(treffer[2])
const wert = treffer[3]
let eintrag = titel.get(nr)
if (eintrag === undefined) {
eintrag = { nr, dauerS: 0, groesseBytes: 0, kapitel: 0 }
titel.set(nr, eintrag)
}
if (attr === 9) {
const teile = wert.split(':')
if (teile.length === 3 && teile.every((t) => /^\d+$/.test(t))) {
eintrag.dauerS = Number(teile[0]) * 3600 + Number(teile[1]) * 60 + Number(teile[2])
}
} else if (attr === 11 && /^\d+$/.test(wert)) {
eintrag.groesseBytes = Number(wert)
} else if (attr === 8 && /^\d+$/.test(wert)) {
eintrag.kapitel = Number(wert)
}
}
return [...titel.keys()].sort((a, b) => a - b).map((nr) => titel.get(nr)!)
}
// ── SINFO: Tonspuren und Untertitel ─────────────────────────────────────
// Attribut-IDs am 26.07.2026 an der Akira-Blu-ray abgenommen:
// 1 = Typ („Audio"/„Subtitles"), 3 = Sprachcode, 4 = Sprachname,
// 6 = Codec, 14 = Kanäle, 30 = Beschreibung.
// ⚠️ Die Sprache steht in 3/4 — NICHT in 28/29: Die tragen auf JEDEM
// Stream „eng"/„English" (MakeMKVs Anzeigesprache). Wer 28 nimmt, hält
// jede Disc für englisch.
export interface StreamInfo {
nr: number
typ: string
lang: string
sprache: string
codec: string
kanaele: string
beschreibung: string
}
export interface TitelStreams {
audio: StreamInfo[]
untertitel: StreamInfo[]
}
const SINFO_ZEILE = /^SINFO:(\d+),(\d+),(\d+),\d+,"(.*)"\s*$/
export function parseStreamInfo(ausgabe: string): Map<number, TitelStreams> {
const titel = new Map<number, Map<number, StreamInfo>>()
for (const roh of (ausgabe ?? '').split(/\r?\n/)) {
const treffer = SINFO_ZEILE.exec(roh.trim())
if (treffer === null) continue
const tNr = Number(treffer[1])
const sNr = Number(treffer[2])
const attr = Number(treffer[3])
const wert = treffer[4]
let streams = titel.get(tNr)
if (streams === undefined) {
streams = new Map()
titel.set(tNr, streams)
}
let eintrag = streams.get(sNr)
if (eintrag === undefined) {
eintrag = { nr: sNr, typ: '', lang: '', sprache: '', codec: '', kanaele: '', beschreibung: '' }
streams.set(sNr, eintrag)
}
if (attr === 1) eintrag.typ = wert
else if (attr === 3) eintrag.lang = wert
else if (attr === 4) eintrag.sprache = wert
else if (attr === 6) eintrag.codec = wert
else if (attr === 14) eintrag.kanaele = wert
else if (attr === 30) eintrag.beschreibung = wert
}
const ergebnis = new Map<number, TitelStreams>()
for (const [tNr, streams] of titel) {
const audio: StreamInfo[] = []
const untertitel: StreamInfo[] = []
for (const sNr of [...streams.keys()].sort((a, b) => a - b)) {
const s = streams.get(sNr)!
// MakeMKV schreibt „Subtitles" (englisch, Plural) — gemessen.
if (s.typ === 'Audio') audio.push(s)
else if (s.typ.startsWith('Subtitle')) untertitel.push(s)
}
ergebnis.set(tNr, { audio, untertitel })
}
return ergebnis
}
export interface SprachEintrag {
lang: string
sprache: string
spuren: number
}
/** Welche Sprachen hat die DISC insgesamt? Über alle Titel vereinigt,
* Reihenfolge stabil (wie auf der Disc). */
export function sprachenZusammenfassen(streamsJeTitel: Map<number, TitelStreams>): {
audio: SprachEintrag[]
untertitel: SprachEintrag[]
} {
const sammeln = (schluessel: 'audio' | 'untertitel'): SprachEintrag[] => {
const gefunden = new Map<string, SprachEintrag>()
for (const streams of streamsJeTitel.values()) {
for (const s of streams[schluessel]) {
if (s.lang.length === 0) continue
let eintrag = gefunden.get(s.lang)
if (eintrag === undefined) {
eintrag = { lang: s.lang, sprache: s.sprache.length > 0 ? s.sprache : s.lang, spuren: 0 }
gefunden.set(s.lang, eintrag)
}
eintrag.spuren += 1
}
}
return [...gefunden.values()]
}
return { audio: sammeln('audio'), untertitel: sammeln('untertitel') }
}
// ── Titelwahl-Heuristiken ───────────────────────────────────────────────
/** Titel-Nummer für den Hauptfilm. Mit Soll-Laufzeit (Minuten, z. B. aus
* TMDb) gewinnt der Titel, der ihr am nächsten kommt (±20 min) — das
* vermeidet „Play All"-Titel, die länger als der Film sind. */
export function laengsterTitel(dauern: Map<number, number>, sollMinuten = 0): number | null {
if (dauern.size === 0) return null
const zielS = sollMinuten * 60
if (zielS > 0) {
let bester: number | null = null
let abstand = Number.POSITIVE_INFINITY
for (const [nr, dauer] of dauern) {
const d = Math.abs(dauer - zielS)
if (d < abstand) {
abstand = d
bester = nr
}
}
if (bester !== null && abstand <= 20 * 60) return bester
}
let laengster: number | null = null
let max = -1
for (const [nr, dauer] of dauern) {
if (dauer > max) {
max = dauer
laengster = nr
}
}
return laengster
}
/** Episoden-Titel: alle Titel, deren Laufzeit ±10 min am Durchschnitt der
* bekannten Episoden-Laufzeiten (Minuten) liegt. */
export function episodenTitel(dauern: Map<number, number>, episodenMinuten: number[]): number[] {
if (dauern.size === 0 || episodenMinuten.length === 0) return []
const laufzeitenS = episodenMinuten.map((m) => m * 60)
const schnitt = laufzeitenS.reduce((a, b) => a + b, 0) / laufzeitenS.length
const gefunden: number[] = []
for (const [nr, dauer] of dauern) {
if (Math.abs(dauer - schnitt) <= 10 * 60) gefunden.push(nr)
}
return gefunden.sort((a, b) => a - b)
}
// ── Fehler-Erkennung ────────────────────────────────────────────────────
// Sprachunabhängige Marke für „die Disc ließ sich stellenweise nicht
// lesen": Die URL steht auch in der deutschen MakeMKV-Fassung englisch da
// (30.08.2026, Spartacus Disc 2 — 1 von 2 Titeln kam an, Rückgabewert 0).
export const LESEFEHLER_MARKE = 'makemkv.com/errors/read'
// Meldungs-Nummern, die die URSACHE eines Fehlschlags benennen — sie sind
// sprachunabhängig, anders als die Texte. Jede Nummer wurde am Laufwerk
// des Commanders GESEHEN (makemkv_aufruf.py, im Wortlaut dokumentiert).
export const KRITISCHE_CODES: Record<number, string> = {
2024: 'MakeMKV kennt dieses Laufwerk nicht',
5010: 'Das Laufwerk ließ sich nicht öffnen — liegt die Disc noch drin? Nach einem fertigen Rip wirft Rippy sie aus',
5020: 'Der hinterlegte MakeMKV-Schlüssel wird nicht angenommen',
5021: 'MakeMKV ist zu alt für den aktuellen Beta-Schlüssel — bitte MakeMKV aktualisieren',
// Am 30.08.2026 am BU40N gemessen: Nach einem abgebrochenen Rip samt
// gescheitertem Auswurf sah MakeMKV GAR KEIN Laufwerk mehr (leere
// DRV-Liste), während die Windows-Storage-Abfragen (CHECK_VERIFY2,
// GET_LENGTH) weiter sauber antworteten — MakeMKV spricht SCSI direkt,
// und diese Ebene blieb hängen.
5042: 'MakeMKV findet gerade kein Laufwerk (das passiert nach abgebrochenen Rips). Abhilfe: Disc auswerfen und neu einlegen oder das Laufwerk kurz ab- und anstecken; hilft das nicht, den Rechner neu starten',
5051: 'MakeMKV ist nicht freigeschaltet (Testzeitraum abgelaufen)',
}
/** Meldungen, die sagen: DIESER Schlüssel taugt für DIESE Fassung nicht. */
export const KEY_ABGELEHNT = [5020, 5021]
// Text-Rückfall für englische Installationen — die NUMMERN sind die
// verlässliche Quelle (beim Commander meldet MakeMKV deutsch).
export const KRITISCHE_TEXTE = [
'volume key is unknown',
'evaluation period has expired',
'registration key',
'Too old version',
]
+190
View File
@@ -0,0 +1,190 @@
// Wo Rippys Werkzeuge liegen — und wie man sie findet (KONZEPT § 9).
// Die Suchreihenfolge und ihre Fallen stammen aus tools/katalog.py, dort am
// Commander-PC gemessen:
//
// 1. Ausdrücklich eingestellt — wer einen Pfad einträgt, will genau den.
// 2. Rippys eigener Werkzeug-Ordner (mitgeliefert bzw. selbst geholt) —
// VOR dem System, damit eine gepflegte Fassung eine alte Installation
// schlägt.
// 3. PATH — der „selbst eingetragen"-Fall.
// 4. Bekannte Installationsorte (%ProgramFiles%…) — der Normalfall.
// 5. Uninstall-Zweig der Registry: Bei MakeMKV ist `InstallLocation` LEER
// (28.08.2026 nachgesehen), aber `DisplayIcon` zeigt in den
// Programmordner.
//
// ⚠ %VAR%-Entfaltung MUSS Groß/Klein ignorieren: os-Umgebungen liefern
// PROGRAMFILES(X86), das Muster sagt %ProgramFiles(x86)% — der strikte
// Vergleich ließ die bekannten Orte auf einer echten Maschine NIE greifen
// (katalog.py, 28.08.2026).
import { spawnSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import { delimiter, join } from 'node:path'
export interface Werkzeug {
titel: string
zweck: string
programme: readonly string[]
orte: readonly string[]
/** Teilstring des DisplayName im Uninstall-Zweig der Registry. */
anzeige: string
pflicht: boolean
}
export const WERKZEUGE: Record<string, Werkzeug> = {
makemkv: {
titel: 'MakeMKV',
zweck: 'Liest DVDs, Blu-rays und 4K-UHDs — der einzige Weg durch AACS.',
programme: ['makemkvcon64.exe', 'makemkvcon.exe'],
orte: ['%ProgramFiles(x86)%\\MakeMKV', '%ProgramFiles%\\MakeMKV'],
anzeige: 'MakeMKV',
pflicht: true,
},
handbrake: {
titel: 'HandBrakeCLI',
zweck: 'Komprimiert den verlustfreien Rip auf Arbeitsgröße.',
programme: ['HandBrakeCLI.exe'],
orte: ['%ProgramFiles%\\HandBrake', '%ProgramFiles(x86)%\\HandBrake'],
anzeige: 'HandBrake',
pflicht: false, // ohne HandBrake geht Rippen, nur ohne Kompression
},
flac: {
titel: 'FLAC',
zweck: 'Wandelt die von der Audio-CD gelesenen Rohdaten in FLAC.',
programme: ['flac.exe'],
orte: ['%ProgramFiles%\\FLAC', '%ProgramFiles(x86)%\\FLAC'],
anzeige: 'FLAC',
pflicht: false, // ohne FLAC läuft alles außer Audio-CDs
},
}
/** Rippys eigener Werkzeug-Ordner — dort landet Mitgeliefertes/Geholtes. */
export function werkzeugOrdner(umgebung: NodeJS.ProcessEnv = process.env): string {
const wurzel = umgebung['LOCALAPPDATA'] ?? umgebung['USERPROFILE'] ?? '.'
return join(wurzel, 'Rippy', 'tools')
}
/** %ProgramFiles%\X → C:\Program Files\X — Groß/Klein wird IGNORIERT.
* Bleibt ein % stehen, ist der Kandidat unbrauchbar → ''. */
export function entfalten(pfad: string, umgebung: NodeJS.ProcessEnv): string {
let ergebnis = pfad
for (const [name, wert] of Object.entries(umgebung)) {
if (wert === undefined) continue
const marke = `%${name}%`.toUpperCase()
for (;;) {
const stelle = ergebnis.toUpperCase().indexOf(marke)
if (stelle < 0) break
ergebnis = ergebnis.slice(0, stelle) + wert + ergebnis.slice(stelle + marke.length)
}
}
return ergebnis.includes('%') ? '' : ergebnis
}
/** Alle Kandidaten-Pfade in Suchreihenfolge — reine Funktion, prüft nichts. */
export function kandidaten(
name: string,
eigenerOrdner: string,
umgebung: NodeJS.ProcessEnv,
): string[] {
const werkzeug = WERKZEUGE[name]
const pfade: string[] = []
for (const programm of werkzeug.programme) {
pfade.push(join(eigenerOrdner, name, programm))
pfade.push(join(eigenerOrdner, programm))
}
for (const ort of werkzeug.orte) {
const entfaltet = entfalten(ort, umgebung)
if (entfaltet.length === 0) continue
for (const programm of werkzeug.programme) {
pfade.push(join(entfaltet, programm))
}
}
return pfade
}
function imPfadSuchen(programme: readonly string[], umgebung: NodeJS.ProcessEnv): string {
const pfadVar = umgebung['PATH'] ?? umgebung['Path'] ?? ''
for (const ordner of pfadVar.split(delimiter)) {
if (ordner.length === 0) continue
for (const programm of programme) {
const kandidat = join(ordner, programm)
if (existsSync(kandidat)) return kandidat
}
}
return ''
}
/** Registry-Rückfall: DisplayIcon aus dem Uninstall-Zweig. Bei MakeMKV ist
* InstallLocation leer — DisplayIcon zeigt auf eine Datei im
* Programmordner (28.08.2026 nachgesehen). Über reg.exe gelesen, damit
* kein zweiter koffi-Ort entsteht (R1). */
export function ausUninstallRegistry(
anzeige: string,
programme: readonly string[],
ausfuehren: (wurzel: string) => string = regQuery,
): string {
const wurzeln = [
'HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
'HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
'HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
]
for (const wurzel of wurzeln) {
const ausgabe = ausfuehren(wurzel)
// reg.exe gruppiert je Unterschlüssel: Kopfzeile, dann NAME TYP WERT.
let passt = false
for (const zeile of ausgabe.split(/\r?\n/)) {
if (zeile.startsWith('HK')) {
passt = false
continue
}
const wert = /^\s*(\S+)\s+REG_SZ\s+(.+)$/.exec(zeile)
if (wert === null) continue
if (wert[1] === 'DisplayName') {
passt = wert[2].includes(anzeige)
} else if (passt && (wert[1] === 'DisplayIcon' || wert[1] === 'UninstallString')) {
const ordner = wert[2].replace(/^"|"$/g, '').replace(/[^\\]*$/, '')
for (const programm of programme) {
const kandidat = join(ordner, programm)
if (existsSync(kandidat)) return kandidat
}
}
}
}
return ''
}
function regQuery(wurzel: string): string {
const ergebnis = spawnSync('reg', ['query', wurzel, '/s'], {
encoding: 'utf8',
windowsHide: true,
timeout: 15_000,
maxBuffer: 32 * 1024 * 1024,
})
return ergebnis.stdout ?? ''
}
/** Der Pfad zum Werkzeug — oder '' wenn es nicht da ist. Prüf-Funktionen
* einspritzbar, damit die Suchreihenfolge ohne Installationen testbar ist. */
export function finden(
name: string,
eingestellt = '',
eigenerOrdner: string = werkzeugOrdner(),
umgebung: NodeJS.ProcessEnv = process.env,
existiert: (pfad: string) => boolean = existsSync,
registry: typeof ausUninstallRegistry = ausUninstallRegistry,
): string {
// 1. Ausdrücklich eingestellt — auch wenn der Pfad (noch) fehlt, wird
// NICHT still etwas anderes genommen: Wer einen Pfad einträgt, will
// genau den, und ein Tippfehler soll als Fehler sichtbar werden.
if (eingestellt.trim().length > 0) {
return existiert(eingestellt.trim()) ? eingestellt.trim() : ''
}
// 2 + 4. Eigener Ordner, dann bekannte Orte.
for (const kandidat of kandidaten(name, eigenerOrdner, umgebung)) {
if (existiert(kandidat)) return kandidat
}
// 3. PATH.
const imPfad = imPfadSuchen(WERKZEUGE[name].programme, umgebung)
if (imPfad.length > 0) return imPfad
// 5. Registry-Rückfall.
return registry(WERKZEUGE[name].anzeige, WERKZEUGE[name].programme)
}