// 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 { // 5.5.0: Das Rettungs-Abbild ist eine ISO-Datei — makemkvcon liest sie // als `iso:` (Manpage makemkvcon: „iso:"). if (/\.iso$/i.test(geraetepfad)) return `iso:${geraetepfad}` 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() 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 { const titel = new Map>() 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() 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 } // EINE Definition für Kern und Fenster (5.4.0): Der Auswahl-Dialog zeigt // die Sprachen je Titel — deshalb lebt der Typ in gemeinsam/. import type { SprachEintrag } from '../../gemeinsam/nachrichten' export type { SprachEintrag } /** Welche Sprachen hat die DISC insgesamt? Über alle Titel vereinigt, * Reihenfolge stabil (wie auf der Disc). */ export function sprachenZusammenfassen(streamsJeTitel: Map): { audio: SprachEintrag[] untertitel: SprachEintrag[] } { const sammeln = (schluessel: 'audio' | 'untertitel'): SprachEintrag[] => { const gefunden = new Map() 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') } } /** Die Sprachen EINES Titels (5.4.0, für den Auswahl-Dialog) — dieselbe * Zusammenfassung wie oben, nur auf einen Titel beschränkt. Ohne Streams * (Titel unbekannt) leer, nicht erfunden. */ export function sprachenFuerTitel(streams: TitelStreams | undefined): { audio: SprachEintrag[] untertitel: SprachEintrag[] } { if (streams === undefined) return { audio: [], untertitel: [] } return sprachenZusammenfassen(new Map([[0, streams]])) } // ── 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, 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, 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' /** Die STELLE, an der ein Lesefehler auftrat. MakeMKV hängt sie ans Ende * der Meldung, in jeder Sprache als letzte Zahl in Anführungszeichen: * * … während des Auslesens von '/BDMV/STREAM/00047.m2ts' bei Offset '56881152' * … while reading '/BDMV/STREAM/00047.m2ts' at offset '56881152' * * Deshalb wird die letzte gequotete Zahl gelesen und nicht das Wort davor * — das wäre von der MakeMKV-Sprache abhängig. '' wenn keine dasteht. * * Wozu (01.09.2026, an der Spartacus-Disc gesehen): Wiederholt sich * DIESELBE Stelle, ist das kein Fortschritt, sondern ein physischer * Defekt — MakeMKV versucht es dann stundenlang, das Laufwerk hämmert, * und Windows protokolliert einen fehlerhaften Block nach dem anderen. */ export function offsetAusMeldung(text: string): string { // Mindestens zwei Ziffern: Titel- und Kapitelnummern stehen ebenfalls in // Anführungszeichen, ein Offset ist immer groß. const treffer = /'(\d{2,})'\s*$/.exec(text.trim()) return treffer === null ? '' : treffer[1] } // 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 = { 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', ]