feat(v5): Etappe W-2 — Rippen: Parser, makemkvcon-Lauf, Werkzeug-Katalog
Ampel / ampel (push) Failing after 1m22s
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:
co-authored by
Claude Fable 5
parent
a45069af5e
commit
844bca37ff
@@ -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 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,
|
||||
): 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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',
|
||||
]
|
||||
Reference in New Issue
Block a user