Commander-Wünsche vom 02.09.2026 vor der Freigabe von 5.4.0 (die deshalb nie veröffentlicht wurde): Kern: kern/index.ts ist nur noch Bootstrap und Router; Dienste in kern/dienste/ (Einstellungen, Werkzeuge, Laufwerke, Erkennung, Vorgänge, Speicher, Benachrichtigung, Automatik), Kontext in kern/kontext.ts, Protokoll-Datei in gemeinsam/protokoll.ts, Platz-Rechnung in gemeinsam/platz.ts. Oberfläche: fenster/App.tsx ist nur noch der Rahmen; bausteine.tsx, dialoge/, uebersicht/ (LaufwerkKachel, KompressionsKarte, SpeicherKarte, VerlaufKarte), einstellungen/ (Karten je Funktion, SprachAuswahl), einrichtung/ (ErstEinrichtung, PosterHintergrund). Neu: Bibliothek weg, dafür Verlauf; Speicher-und-Bilanz-Karte (statfs); LED-Fußleiste; MakeMKV-Kopf in die Einstellungen; Sprach-Chips mit Dropdown statt Allgemeinem Preset; Karten Automatik/Benachrichtigung/Allgemein/Update; Durchsuchen bei Werkzeugen; Arbeitsordner lokal (Encode dort, dann in die Ablage); Platz-Prüfung vor dem Rip (Dialog und Kern); Automatik mit Countdown; Serien-Merker über Discs; Rettungs-Abbild mit Nullen nur nach gesehenen Lesefehlern (kern/rip/rettung.ts, Phase rettet, iso:-Quelle); Warteschlange pausieren; Discord/Telegram-Benachrichtigung; Protokoll-Datei je Tag; Fensterlage 80 % mittig, gemerkt; Erst-Einrichtung in acht Schritten vor dem Poster-Grid. Beweise: 413 Tests (neu: speicher, benachrichtigung, automatik, rettung, fensterlage, protokoll, kleinkram-550), drei Typprüfungen, Klick-Beweis mit 29 Schritten inklusive Erst-Einrichtung (zweiter Start mit RIPPY_START_BEREICH=einrichtung), Bilder beweise/klick/01…09. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
313 lines
13 KiB
TypeScript
313 lines
13 KiB
TypeScript
// 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:<pfad>` (Manpage makemkvcon: „iso:<FileName>").
|
|
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<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
|
|
}
|
|
|
|
// 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<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') }
|
|
}
|
|
|
|
/** 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<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'
|
|
|
|
/** 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<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',
|
|
]
|