fix(v5): 5.7.1 — Titel lesen wartet, solange MakeMKV arbeitet; Grenze gilt für Stille, Dialog zeigt Schritt, Prozent und Meldung
Ampel / ampel (push) Successful in 1m20s
WAS: Der Info-Lauf verlangt --progress=-same und wertet die Ausgabe zeilenweise aus (PRGC/PRGT/PRGV/MSG). titelInfoLesen beendet makemkvcon nur noch, wenn er so lange STILL ist, wie die Einstellung erlaubt (Vorgabe 15 min) — jede Fortschritts- oder Meldungszeile setzt die Uhr zurück. Kündigt MakeMKV den VOB-Scan an („IFO-Datei … beschädigt, die VOB-Datei muss gescannt werden"), gilt nur noch eine Obergrenze von 4 h. Der Dialog zeigt während des Lesens Schritt, Prozent und letzte Meldung (titelLaufZwischenstand), beim VOB-Scan mit Erklärung (20–60 min, im MakeMKV-Programm genauso). Bricht Rippy ab, stehen die letzten acht Zeilen von makemkvcon im Fehler und im Protokoll. Einstellungs-Text „Titel lesen — ohne Lebenszeichen höchstens". Tests mit nachgebautem makemkvcon (redselig über die Stille-Grenze hinaus, stumm, VOB-Scan mit Obergrenze). Version 5.7.1, Änderungsnotizen. WARUM: Commander 12.09.2026 spät: „Der Patriot" (DVD, IFO für VTS #1 beschädigt) scheiterte in 5.7.0 erneut nach 15 Minuten, das MakeMKV- Programm las die Disc fertig — MakeMKV liest bei so einer Disc die ganze VOB durch, das dauert so lange wie die Disc braucht. Eine feste Gesamtgrenze ist dafür immer zu kurz; Stille ist das richtige Maß. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Before Width: | Height: | Size: 288 KiB After Width: | Height: | Size: 288 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 335 KiB After Width: | Height: | Size: 337 KiB |
|
Before Width: | Height: | Size: 752 KiB After Width: | Height: | Size: 759 KiB |
|
Before Width: | Height: | Size: 753 KiB After Width: | Height: | Size: 760 KiB |
|
Before Width: | Height: | Size: 784 KiB After Width: | Height: | Size: 791 KiB |
|
Before Width: | Height: | Size: 337 KiB After Width: | Height: | Size: 339 KiB |
|
Before Width: | Height: | Size: 468 KiB After Width: | Height: | Size: 468 KiB |
|
Before Width: | Height: | Size: 457 KiB After Width: | Height: | Size: 457 KiB |
|
Before Width: | Height: | Size: 445 KiB After Width: | Height: | Size: 445 KiB |
@@ -308,8 +308,8 @@ await schritt('zurück in den Kinosaal (dunkel)', async () => {
|
||||
await seite.locator('html[data-design="dunkel"]').waitFor()
|
||||
})
|
||||
|
||||
await schritt('5.7.0: „Titel lesen — höchstens" steht auf 15 Minuten, 30 lässt sich wählen', async () => {
|
||||
const regler = seite.locator('#allgemein').getByLabel('Titel lesen — höchstens')
|
||||
await schritt('5.7.0: „Titel lesen — ohne Lebenszeichen höchstens" steht auf 15 Minuten, 30 lässt sich wählen', async () => {
|
||||
const regler = seite.locator('#allgemein').getByLabel('Titel lesen — ohne Lebenszeichen höchstens')
|
||||
if ((await regler.inputValue()) !== '15') throw new Error(`Vorgabe ist ${await regler.inputValue()}, nicht 15`)
|
||||
await regler.selectOption('30')
|
||||
// Der Wert geht zum Kern und kommt als Einstellung zurück — erst dann
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
Version 5.7.1
|
||||
|
||||
Titel lesen: Rippy wartet, solange MakeMKV arbeitet.
|
||||
"Der Patriot" (DVD mit beschaedigter IFO-Datei) scheiterte in 5.7.0 nach
|
||||
15 Minuten, waehrend das MakeMKV-Programm die Disc fertig las: MakeMKV
|
||||
liest bei so einer Disc die ganze VOB-Datei durch, und das dauert so lange
|
||||
wie das Lesen der Disc. Die Grenze aus den Einstellungen gilt jetzt fuer
|
||||
Stille - solange MakeMKV Fortschritt oder Meldungen schickt, wartet Rippy
|
||||
(kuendigt MakeMKV den VOB-Scan an, bis zu vier Stunden). Der Dialog zeigt
|
||||
dabei, was MakeMKV gerade tut: Schritt, Prozent und letzte Meldung.
|
||||
"Abbrechen" beendet den Lauf jederzeit. Bricht Rippy doch ab, stehen die
|
||||
letzten Zeilen von MakeMKV im Protokoll.
|
||||
|
||||
Version 5.7.0
|
||||
|
||||
Der Beta-Schluessel kommt ganz an.
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "rippy",
|
||||
"version": "5.7.0",
|
||||
"version": "5.7.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "rippy",
|
||||
"version": "5.7.0",
|
||||
"version": "5.7.1",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.8.9",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "rippy",
|
||||
"productName": "Rippy",
|
||||
"version": "5.7.0",
|
||||
"version": "5.7.1",
|
||||
"description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)",
|
||||
"main": "out/haupt/index.js",
|
||||
"author": "KrBrZ",
|
||||
|
||||
@@ -579,7 +579,7 @@ export function AllgemeinKarte({ verbindung, haupt }: { verbindung: Verbindung;
|
||||
<SektionsKarte id="allgemein" titel="ALLGEMEIN" intro="Rippy lebt im Infobereich neben der Uhr: Fenster zu heißt weiterlaufen, beendet wird über das Tray-Menü.">
|
||||
<Schalter an={haupt?.autostart === true} aufWechsel={(an) => void window.rippy.autostartSetzen(an)} label="Rippy mit Windows starten (Autostart im Benutzerkonto — kein Dienst, keine Adminrechte)" />
|
||||
<label className="block max-w-sm">
|
||||
<span className="text-xs font-semibold text-dunst">Titel lesen — höchstens</span>
|
||||
<span className="text-xs font-semibold text-dunst">Titel lesen — ohne Lebenszeichen höchstens</span>
|
||||
<select value={werte['titelLesenMinuten'] ?? '15'} onChange={(e) => setzen('titelLesenMinuten', e.target.value)} className={FELD_KLASSE}>
|
||||
<option value="5">5 Minuten</option>
|
||||
<option value="10">10 Minuten</option>
|
||||
@@ -587,7 +587,7 @@ export function AllgemeinKarte({ verbindung, haupt }: { verbindung: Verbindung;
|
||||
<option value="30">30 Minuten</option>
|
||||
<option value="60">60 Minuten</option>
|
||||
</select>
|
||||
<span className="mt-1 block text-[11px] leading-4 text-nebel">So lange darf MakeMKV die Titel einer Disc lesen. Kopiergeschützte DVDs und Discs mit vielen Playlisten brauchen im MakeMKV-Programm oft länger als fünf Minuten — braucht eine Disc dort länger als hier eingestellt, erhöhe den Wert.</span>
|
||||
<span className="mt-1 block text-[11px] leading-4 text-nebel">So lange darf MakeMKV still sein (kein Fortschritt, keine Meldung), bevor Rippy das Titel-Lesen beendet. Solange MakeMKV arbeitet und Fortschritt meldet — etwa beim Durchlesen einer DVD mit beschädigter IFO-Datei, was 20–60 Minuten dauern kann — wartet Rippy; „Abbrechen" im Dialog geht jederzeit.</span>
|
||||
</label>
|
||||
<label className="block max-w-sm">
|
||||
<span className="text-xs font-semibold text-dunst">Erscheinungsbild</span>
|
||||
|
||||
@@ -16,7 +16,7 @@ import { discFingerprint, discTitel, type DiscTitel } from '../metadaten/discmer
|
||||
import { OmdbClient } from '../metadaten/omdb'
|
||||
import { TmdbClient } from '../metadaten/tmdb'
|
||||
import { LEERER_TREFFER, ausDetails, nachpruefen, zuordnen, type Zuordnung } from '../metadaten/zuordnung'
|
||||
import { ABGELOEST, titelInfoLesen } from '../rip/makemkv'
|
||||
import { ABGELOEST, titelInfoLesen, type Lebenszeichen } from '../rip/makemkv'
|
||||
import { spurenFuerTitel, sprachenFuerTitel, type TitelInfo } from '../rip/parser'
|
||||
import { strukturHinweis, titelVorauswahl } from '../rip/titelwahl'
|
||||
|
||||
@@ -316,19 +316,32 @@ export class ErkennungDienst {
|
||||
// 5.7.0 (Kandidat 2): Die Grenze kommt aus den Einstellungen (Vorgabe
|
||||
// 15 min statt fest fünf), und ab der zweiten Minute sagt der Dialog,
|
||||
// dass es länger dauert — mit Abbrechen-Knopf, statt stumm zu drehen.
|
||||
// 5.7.1 (Der Patriot): Die Grenze gilt für STILLE — solange MakeMKV
|
||||
// Fortschritt oder Meldungen schickt, wartet Rippy, und der Dialog zeigt,
|
||||
// was MakeMKV gerade tut.
|
||||
const minuten = this.abh.titelLesenMinuten?.() ?? 15
|
||||
const zwischenstand = setInterval(() => {
|
||||
let lebens: Lebenszeichen = { prozent: -1, schritt: '', meldung: '', vobScan: false }
|
||||
let letztesLebenszeichen = start
|
||||
let letzteSendung = start
|
||||
const zwischenstandSenden = (): void => {
|
||||
if (!nochAktuell()) return
|
||||
const vergangen = Math.round((Date.now() - start) / 60_000)
|
||||
senden({
|
||||
...leer,
|
||||
laeuft: true,
|
||||
einordnung: `MakeMKV liest seit ${vergangen} min. Kopiergeschützte DVDs und Discs mit vielen Playlisten brauchen länger — Rippy wartet bis ${minuten} min (Einstellungen → Allgemein), „Abbrechen" beendet den Lauf.`,
|
||||
})
|
||||
}, 60_000)
|
||||
letzteSendung = Date.now()
|
||||
senden({ ...leer, laeuft: true, einordnung: titelLaufZwischenstand(Date.now() - start, Date.now() - letztesLebenszeichen, minuten, lebens) })
|
||||
}
|
||||
const zwischenstand = setInterval(zwischenstandSenden, 60_000)
|
||||
zwischenstand.unref()
|
||||
try {
|
||||
const info = await titelInfoLesen(programm, geraet.pfad, minuten * 60_000, abbruch.signal)
|
||||
const info = await titelInfoLesen(programm, geraet.pfad, {
|
||||
stillMs: minuten * 60_000,
|
||||
abbruch: abbruch.signal,
|
||||
aufLebenszeichen: (stand) => {
|
||||
const neueMeldung = stand.meldung !== lebens.meldung || stand.schritt !== lebens.schritt
|
||||
lebens = stand
|
||||
letztesLebenszeichen = Date.now()
|
||||
// Neue Meldung oder neuer Schritt sofort, Prozente höchstens alle 5 s.
|
||||
if (Date.now() - start >= 30_000 && (neueMeldung || Date.now() - letzteSendung >= 5_000)) zwischenstandSenden()
|
||||
},
|
||||
})
|
||||
if (!nochAktuell()) return { ...leer, fehler: ABGELOEST }
|
||||
const merkmale = this.merkmale.get(id)
|
||||
const wahl = titelVorauswahl(info.titel, {
|
||||
@@ -369,10 +382,14 @@ export class ErkennungDienst {
|
||||
if (String(fehler).includes(ABGELOEST) || !nochAktuell()) return { ...leer, fehler: ABGELOEST }
|
||||
// R4: sonst laut scheitern. „Konnte nicht nachsehen" ist etwas anderes
|
||||
// als „die Disc hat keine Titel" — beides zu vermischen erzeugt Unsinn.
|
||||
const grund = String(fehler).includes('keine Antwort nach')
|
||||
? `MakeMKV hat die Titel nach ${minuten} Minuten nicht gelesen — Rippy hat den Versuch beendet und das Laufwerk freigegeben. Das kann an einer beschädigten Disc liegen, aber auch an Kopierschutz oder vielen Playlisten, die einfach länger brauchen: Die Grenze steht unter Einstellungen → Allgemein. Zur Probe die Disc im MakeMKV-Programm öffnen und die Zeit stoppen.`
|
||||
: `Titel lesen: ${String(fehler).slice(0, 200)}`
|
||||
this.kontext.protokoll.schreiben(`Titel-Lauf ${id} gescheitert: ${grund}`)
|
||||
const grund = String(fehler).includes('kein Lebenszeichen seit')
|
||||
? `MakeMKV hat ${minuten} Minuten lang kein Lebenszeichen gegeben (kein Fortschritt, keine Meldung) — Rippy hat den Versuch nach ${Math.round((Date.now() - start) / 60_000)} min beendet und das Laufwerk freigegeben.${
|
||||
lebens.meldung.length > 0 ? ` Letzte Meldung: „${lebens.meldung}".` : ''
|
||||
} Das kann an einer beschädigten Disc oder am Laufwerk liegen. Die Geduld steht unter Einstellungen → Allgemein; zur Probe die Disc im MakeMKV-Programm öffnen.`
|
||||
: String(fehler).includes('VOB-Scan nach')
|
||||
? `MakeMKV liest die beschädigte DVD-Struktur seit ${Math.round((Date.now() - start) / 60_000)} min und ist nicht fertig geworden — Rippy hat den Versuch beendet und das Laufwerk freigegeben. Zur Probe die Disc im MakeMKV-Programm öffnen.`
|
||||
: `Titel lesen: ${String(fehler).slice(0, 200)}`
|
||||
this.kontext.protokoll.schreiben(`Titel-Lauf ${id} gescheitert: ${grund} [${String(fehler).slice(0, 900)}]`)
|
||||
return senden({ ...leer, fehler: grund })
|
||||
} finally {
|
||||
clearInterval(zwischenstand)
|
||||
@@ -448,3 +465,22 @@ export class ErkennungDienst {
|
||||
this.kontext.anFenster({ art: 'poster-vorschau', pfade })
|
||||
}
|
||||
}
|
||||
|
||||
/** Was der Dialog während des Titel-Lesens zeigt (pur, 5.7.1): wie lange
|
||||
* MakeMKV schon liest, was es gerade tut, wann Rippy aufgeben würde. */
|
||||
export function titelLaufZwischenstand(vergangenMs: number, stillMs: number, geduldMinuten: number, lebens: Lebenszeichen): string {
|
||||
const min = Math.max(1, Math.round(vergangenMs / 60_000))
|
||||
const teile: string[] = [`MakeMKV liest seit ${min} min`]
|
||||
if (lebens.schritt.length > 0 || lebens.prozent >= 0) {
|
||||
teile.push(`${lebens.schritt.length > 0 ? lebens.schritt : 'Fortschritt'}${lebens.prozent >= 0 ? ` ${lebens.prozent} %` : ''}`)
|
||||
}
|
||||
if (lebens.meldung.length > 0) teile.push(`letzte Meldung: „${lebens.meldung.trim().slice(0, 160)}"`)
|
||||
let text = teile.join(' · ') + '.'
|
||||
if (lebens.vobScan) {
|
||||
text += ` Die DVD-Struktur ist beschädigt — MakeMKV liest die ganze VOB-Datei durch; das dauert so lange wie das Lesen der Disc (oft 20–60 min), im MakeMKV-Programm genauso. Rippy wartet, solange makemkvcon läuft.`
|
||||
} else {
|
||||
const stillMin = Math.floor(stillMs / 60_000)
|
||||
text += ` Solange MakeMKV Fortschritt meldet, wartet Rippy; ohne Lebenszeichen bricht es nach ${geduldMinuten} min ab (Einstellungen → Allgemein)${stillMin >= 1 ? ` — still seit ${stillMin} min` : ''}.`
|
||||
}
|
||||
return text + ' „Abbrechen" beendet den Lauf.'
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import { werkzeugSpawn } from '../werkzeuge/aufruf'
|
||||
import { mkdirSync, readdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
aufgabeAusPrg,
|
||||
istVobScanMeldung,
|
||||
KRITISCHE_CODES,
|
||||
KRITISCHE_TEXTE,
|
||||
LESEFEHLER_MARKE,
|
||||
@@ -31,8 +33,11 @@ import {
|
||||
type TitelStreams,
|
||||
} from './parser'
|
||||
|
||||
/** 5.7.1: auch der Info-Lauf meldet Fortschritt (--progress=-same) — daran
|
||||
* erkennt Rippy, dass MakeMKV noch arbeitet, statt nach fester Zeit
|
||||
* abzubrechen. */
|
||||
export function buildInfoArgs(geraetepfad: string): string[] {
|
||||
return ['-r', '--noscan', 'info', quelle(geraetepfad)]
|
||||
return ['-r', '--noscan', '--progress=-same', 'info', quelle(geraetepfad)]
|
||||
}
|
||||
|
||||
export function buildRipArgs(geraetepfad: string, zielOrdner: string, titel: string): string[] {
|
||||
@@ -151,26 +156,106 @@ export interface DiscInfo {
|
||||
sprachen: { audio: SprachEintrag[]; untertitel: SprachEintrag[] }
|
||||
}
|
||||
|
||||
/** Was der Info-Lauf gerade tut — für den Dialog (5.7.1). */
|
||||
export interface Lebenszeichen {
|
||||
/** Gesamtfortschritt 0–100, -1 ohne Angabe. */
|
||||
prozent: number
|
||||
/** Der aktuelle Schritt laut PRGC, '' wenn unbekannt. */
|
||||
schritt: string
|
||||
/** Die letzte Meldung (MSG-Klartext), '' wenn keine. */
|
||||
meldung: string
|
||||
/** MakeMKV hat den VOB-Scan einer beschädigten DVD angekündigt. */
|
||||
vobScan: boolean
|
||||
}
|
||||
|
||||
export interface TitelInfoOptionen {
|
||||
/** So lange darf makemkvcon STILL sein (kein Byte Ausgabe), bevor Rippy
|
||||
* ihn beendet. Solange Fortschritt oder Meldungen kommen, wartet Rippy —
|
||||
* eine feste Gesamtgrenze warf den Patriot-Lauf weg, während das
|
||||
* MakeMKV-Programm dieselbe Disc fertig las (Commander, 12.09.2026). */
|
||||
stillMs?: number
|
||||
/** Hat MakeMKV den VOB-Scan angekündigt, gilt statt der Stille-Grenze
|
||||
* nur diese Obergrenze — der Scan liest die ganze Disc, und ob er dabei
|
||||
* Fortschritt meldet, ist nicht gemessen. */
|
||||
vobScanHoechstensMs?: number
|
||||
abbruch?: AbortSignal
|
||||
aufLebenszeichen?: (stand: Lebenszeichen) => void
|
||||
}
|
||||
|
||||
export const VOB_SCAN_HOECHSTENS_MS = 4 * 60 * 60_000
|
||||
|
||||
/** 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,
|
||||
abbruch?: AbortSignal,
|
||||
): Promise<DiscInfo> {
|
||||
* zweimal holen wäre eine Minute Wartezeit für nichts (ripping.py).
|
||||
* 5.7.1: Die Grenze ist eine STILLE-Grenze — makemkvcon wird nur beendet,
|
||||
* wenn er so lange nichts mehr sagt; wer Fortschritt meldet, darf weiter. */
|
||||
export function titelInfoLesen(programm: string, geraetepfad: string, optionen: TitelInfoOptionen = {}): Promise<DiscInfo> {
|
||||
const stillMs = optionen.stillMs ?? 300_000
|
||||
const vobHoechstensMs = optionen.vobScanHoechstensMs ?? VOB_SCAN_HOECHSTENS_MS
|
||||
const abbruch = optionen.abbruch
|
||||
return new Promise((fertig, scheitert) => {
|
||||
const kind = werkzeugSpawn(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(() => {
|
||||
const stand: Lebenszeichen = { prozent: -1, schritt: '', meldung: '', vobScan: false }
|
||||
const start = Date.now()
|
||||
let zeiger: NodeJS.Timeout
|
||||
// Die letzten Zeilen reisen im Fehler mit — fürs Protokoll, damit ein
|
||||
// fremder PC sagen kann, WO makemkvcon stehen blieb.
|
||||
const letzteZeilen: string[] = []
|
||||
const stumm = (): void => {
|
||||
kind.kill()
|
||||
scheitert(new Error(`makemkvcon info: keine Antwort nach ${Math.round(timeoutMs / 1000)} s`))
|
||||
}, timeoutMs)
|
||||
const grund = stand.vobScan
|
||||
? `makemkvcon info: VOB-Scan nach ${Math.round(vobHoechstensMs / 60_000)} min nicht fertig`
|
||||
: `makemkvcon info: kein Lebenszeichen seit ${Math.round(stillMs / 1000)} s`
|
||||
scheitert(new Error(letzteZeilen.length > 0 ? `${grund} — letzte Zeilen: ${letzteZeilen.join(' | ')}` : grund))
|
||||
}
|
||||
const neuWarten = (): void => {
|
||||
clearTimeout(zeiger)
|
||||
// Beim VOB-Scan zählt nur die Obergrenze ab Start des Laufs.
|
||||
const frist = stand.vobScan ? Math.max(0, vobHoechstensMs - (Date.now() - start)) : stillMs
|
||||
zeiger = setTimeout(stumm, frist)
|
||||
}
|
||||
neuWarten()
|
||||
const leserAus = new ZeilenLeser()
|
||||
const leserFehler = new ZeilenLeser()
|
||||
const aufStueck = (leser: ZeilenLeser, stueck: Buffer): void => {
|
||||
stuecke.push(stueck)
|
||||
let neu = false
|
||||
for (const zeile of leser.hinzu(stueck)) {
|
||||
if (zeile.trim().length > 0) {
|
||||
letzteZeilen.push(zeile.trim().slice(0, 200))
|
||||
if (letzteZeilen.length > 8) letzteZeilen.shift()
|
||||
}
|
||||
const p = progressAusPrgv(zeile)
|
||||
if (p >= 0) {
|
||||
if (p !== stand.prozent) {
|
||||
stand.prozent = p
|
||||
neu = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
const aufgabe = aufgabeAusPrg(zeile)
|
||||
if (aufgabe !== null) {
|
||||
if (aufgabe.art === 'schritt' && aufgabe.name !== stand.schritt) {
|
||||
stand.schritt = aufgabe.name
|
||||
neu = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
const meldung = parseMsg(zeile)
|
||||
if (meldung !== null && meldung.text !== stand.meldung) {
|
||||
stand.meldung = meldung.text
|
||||
if (istVobScanMeldung(meldung.text)) stand.vobScan = true
|
||||
neu = true
|
||||
}
|
||||
}
|
||||
neuWarten()
|
||||
if (neu) optionen.aufLebenszeichen?.({ ...stand })
|
||||
}
|
||||
kind.stdout.on('data', (stueck: Buffer) => aufStueck(leserAus, stueck))
|
||||
kind.stderr.on('data', (stueck: Buffer) => aufStueck(leserFehler, stueck))
|
||||
// Ein abgelöster Lauf lässt das Laufwerk sofort los — zwei makemkvcon
|
||||
// auf derselben Disc machen beide langsamer (Commander-Fund 01.09.2026:
|
||||
// Ein alter Lauf meldete 300 s später seinen Timeout und legte ihn über
|
||||
|
||||
@@ -68,6 +68,27 @@ export function progressAusPrgv(zeile: string): number {
|
||||
return Math.min(100, Math.floor((gesamt * 100) / maximum))
|
||||
}
|
||||
|
||||
// ── PRGC/PRGT: Was MakeMKV gerade tut ───────────────────────────────────
|
||||
// PRGC:code,id,"name" ist der aktuelle Schritt („Analysieren von seamless
|
||||
// segments"), PRGT:code,id,"name" die Gesamtaufgabe („Öffnen der DVD-Disc").
|
||||
// Für die Lebenszeichen im Titel-Dialog (5.7.1, Der Patriot).
|
||||
const PRG_ZEILE = /^PRG([CT]):(\d+),(\d+),"((?:[^"\\]|\\.)*)"/
|
||||
|
||||
export function aufgabeAusPrg(zeile: string): { art: 'schritt' | 'aufgabe'; name: string } | null {
|
||||
const treffer = PRG_ZEILE.exec(zeile.trim())
|
||||
if (treffer === null) return null
|
||||
return { art: treffer[1] === 'C' ? 'schritt' : 'aufgabe', name: treffer[4].replaceAll('\\"', '"') }
|
||||
}
|
||||
|
||||
/** MakeMKV kündigt den langen Weg an: „IFO file for VTS #1 is corrupt, VOB
|
||||
* file needs to be scanned. This may take a long time" — deutsch „IFO-Datei
|
||||
* für VTS #1 ist beschädigt, die VOB-Datei muss gescannt werden". Dann liest
|
||||
* MakeMKV die ganze VOB durch, so lange wie die Disc braucht (Der Patriot,
|
||||
* Commander 12.09.2026: 15 Minuten reichten nicht, das GUI schaffte es). */
|
||||
export function istVobScanMeldung(text: string): boolean {
|
||||
return /\bIFO\b/i.test(text) && /\bVOB\b/i.test(text)
|
||||
}
|
||||
|
||||
// ── TINFO: Titel-Liste ──────────────────────────────────────────────────
|
||||
// Attribut-IDs laut MakeMKV apdefs.h: 8 = ChapterCount, 9 = Duration
|
||||
// ("H:MM:SS"), 11 = DiskSizeBytes.
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// 5.7.1 — Commander-Fund 12.09.2026 spät: „Der Patriot" (DVD, IFO-Datei für
|
||||
// VTS #1 beschädigt, MakeMKV scannt die VOB-Datei). Rippy 5.7.0 brach nach
|
||||
// 15 Minuten ab, das MakeMKV-Programm las die Disc fertig. Jetzt gilt die
|
||||
// Grenze für STILLE: Solange makemkvcon Fortschritt oder Meldungen schickt,
|
||||
// wartet Rippy — und der Dialog zeigt, was MakeMKV gerade tut.
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { titelLaufZwischenstand } from '../src/kern/dienste/erkennung'
|
||||
import { buildInfoArgs, titelInfoLesen, VOB_SCAN_HOECHSTENS_MS, type Lebenszeichen } from '../src/kern/rip/makemkv'
|
||||
import { aufgabeAusPrg, istVobScanMeldung } from '../src/kern/rip/parser'
|
||||
|
||||
let wegwerf: string[] = []
|
||||
afterEach(() => {
|
||||
for (const o of wegwerf.splice(0)) rmSync(o, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('Fortschritts-Zeilen des Info-Laufs', () => {
|
||||
it('der Info-Lauf verlangt --progress=-same', () => {
|
||||
expect(buildInfoArgs('\\\\.\\G:')).toContain('--progress=-same')
|
||||
})
|
||||
it('PRGC ist der Schritt, PRGT die Aufgabe — mit Komma und Anführungszeichen im Namen', () => {
|
||||
expect(aufgabeAusPrg('PRGC:5018,0,"Analysieren von seamless segments"')).toEqual({ art: 'schritt', name: 'Analysieren von seamless segments' })
|
||||
expect(aufgabeAusPrg('PRGT:5017,0,"Öffnen der DVD-Disc"')).toEqual({ art: 'aufgabe', name: 'Öffnen der DVD-Disc' })
|
||||
expect(aufgabeAusPrg('PRGC:1,2,"Lesen, Teil \\"A\\""')).toEqual({ art: 'schritt', name: 'Lesen, Teil "A"' })
|
||||
expect(aufgabeAusPrg('PRGV:10,20,65536')).toBeNull()
|
||||
expect(aufgabeAusPrg('MSG:1005,0,1,"x","x"')).toBeNull()
|
||||
})
|
||||
it('erkennt die VOB-Scan-Ankündigung auf Deutsch und Englisch', () => {
|
||||
expect(istVobScanMeldung('IFO-Datei für VTS #1 ist beschädigt, die VOB-Datei muss gescannt werden. Das kann viel Zeit in Anspruch nehmen, bitte haben Sie Geduld.')).toBe(true)
|
||||
expect(istVobScanMeldung('IFO file for VTS #1 is corrupt, VOB file needs to be scanned. This may take a long time, please be patient.')).toBe(true)
|
||||
expect(istVobScanMeldung('Using LibreDrive mode (v02.1 id=F39BF9BC17BE)')).toBe(false)
|
||||
expect(istVobScanMeldung('Titel #1 wurde hinzugefügt (31 cell(s), 2:47:40)')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Zwischenstand im Dialog', () => {
|
||||
const still: Lebenszeichen = { prozent: -1, schritt: '', meldung: '', vobScan: false }
|
||||
it('nennt Dauer, Schritt, Prozent, letzte Meldung und die Geduld', () => {
|
||||
const text = titelLaufZwischenstand(17 * 60_000, 20_000, 15, { prozent: 43, schritt: 'Öffnen der DVD-Disc', meldung: 'Using LibreDrive mode', vobScan: false })
|
||||
expect(text).toContain('MakeMKV liest seit 17 min')
|
||||
expect(text).toContain('Öffnen der DVD-Disc 43 %')
|
||||
expect(text).toContain('letzte Meldung: „Using LibreDrive mode"')
|
||||
expect(text).toContain('ohne Lebenszeichen bricht es nach 15 min ab')
|
||||
expect(text).toContain('„Abbrechen" beendet den Lauf')
|
||||
expect(text).not.toContain('still seit')
|
||||
})
|
||||
it('sagt, wie lange MakeMKV schon still ist', () => {
|
||||
expect(titelLaufZwischenstand(9 * 60_000, 3 * 60_000 + 5000, 15, still)).toContain('still seit 3 min')
|
||||
})
|
||||
it('beim VOB-Scan: die Erklärung statt der Stille-Grenze', () => {
|
||||
const text = titelLaufZwischenstand(23 * 60_000, 0, 15, { prozent: 12, schritt: '', meldung: 'IFO-Datei für VTS #1 ist beschädigt, die VOB-Datei muss gescannt werden.', vobScan: true })
|
||||
expect(text).toContain('Fortschritt 12 %')
|
||||
expect(text).toContain('DVD-Struktur ist beschädigt')
|
||||
expect(text).toContain('oft 20–60 min')
|
||||
expect(text).not.toContain('bricht es nach')
|
||||
})
|
||||
})
|
||||
|
||||
// Ein nachgebautes makemkvcon (Node-Skript, aufruf.ts startet es über die
|
||||
// eigene Laufzeit): meldet erst die VOB-Ankündigung, dann alle 300 ms
|
||||
// Fortschritt — länger als die Stille-Grenze von 1 s — und liefert am Ende
|
||||
// einen Titel. Ein zweites schweigt nach der ersten Zeile.
|
||||
function fakeMakemkv(inhalt: string): string {
|
||||
const ordner = mkdtempSync(join(tmpdir(), 'rippy-lebens-'))
|
||||
wegwerf.push(ordner)
|
||||
const pfad = join(ordner, 'fake-makemkvcon.cjs')
|
||||
writeFileSync(pfad, inhalt)
|
||||
return pfad
|
||||
}
|
||||
|
||||
const REDSELIG = `
|
||||
const out = (z) => process.stdout.write(z + '\\n')
|
||||
out('MSG:1005,0,1,"MakeMKV v1.18.4 win(x64-release) gestartet","%1 gestartet","MakeMKV v1.18.4 win(x64-release)"')
|
||||
out('PRGT:5017,0,"Öffnen der DVD-Disc"')
|
||||
out('MSG:3300,0,1,"IFO-Datei für VTS #1 ist beschädigt, die VOB-Datei muss gescannt werden. Das kann viel Zeit in Anspruch nehmen, bitte haben Sie Geduld.","x"')
|
||||
let i = 0
|
||||
const tick = () => {
|
||||
i += 1
|
||||
out('PRGV:' + i * 100 + ',' + i * 100 + ',1000')
|
||||
if (i < 8) return setTimeout(tick, 300)
|
||||
out('TCOUNT:1')
|
||||
out('TINFO:0,8,0,"28"')
|
||||
out('TINFO:0,9,0,"2:47:40"')
|
||||
out('TINFO:0,11,0,"5800000000"')
|
||||
out('SINFO:0,0,1,6201,"Video"')
|
||||
process.exit(0)
|
||||
}
|
||||
setTimeout(tick, 300)
|
||||
`
|
||||
|
||||
const STUMM = `
|
||||
process.stdout.write('MSG:1005,0,1,"MakeMKV gestartet","x"\\n')
|
||||
setTimeout(() => process.exit(0), 60_000)
|
||||
`
|
||||
|
||||
describe('titelInfoLesen — Geduld nach Lebenszeichen', () => {
|
||||
it('wartet über die Stille-Grenze hinaus, solange Fortschritt kommt; meldet Lebenszeichen', { timeout: 20_000 }, async () => {
|
||||
const zeichen: Lebenszeichen[] = []
|
||||
const start = Date.now()
|
||||
const info = await titelInfoLesen(fakeMakemkv(REDSELIG), '\\\\.\\G:', { stillMs: 1000, aufLebenszeichen: (s) => zeichen.push(s) })
|
||||
expect(Date.now() - start).toBeGreaterThan(2000)
|
||||
expect(info.titel).toHaveLength(1)
|
||||
expect(info.titel[0].dauerS).toBe(2 * 3600 + 47 * 60 + 40)
|
||||
const letztes = zeichen.at(-1)!
|
||||
expect(letztes.vobScan).toBe(true)
|
||||
expect(letztes.meldung).toContain('VOB-Datei muss gescannt werden')
|
||||
expect(zeichen.some((z) => z.prozent === 50)).toBe(true)
|
||||
expect(zeichen.some((z) => z.schritt === '' && z.prozent === -1 && z.meldung.includes('gestartet'))).toBe(true)
|
||||
})
|
||||
|
||||
it('beendet einen stummen makemkvcon nach der Stille-Grenze — mit klarem Grund', { timeout: 20_000 }, async () => {
|
||||
const start = Date.now()
|
||||
await expect(titelInfoLesen(fakeMakemkv(STUMM), '\\\\.\\G:', { stillMs: 1000 })).rejects.toThrow('kein Lebenszeichen seit 1 s')
|
||||
expect(Date.now() - start).toBeLessThan(10_000)
|
||||
// Die letzten Zeilen reisen im Fehler mit — fürs Protokoll.
|
||||
await expect(titelInfoLesen(fakeMakemkv(STUMM), '\\\\.\\G:', { stillMs: 1000 })).rejects.toThrow('letzte Zeilen: MSG:1005')
|
||||
})
|
||||
|
||||
it('beim VOB-Scan gilt nur die Obergrenze (4 h Vorgabe)', { timeout: 20_000 }, async () => {
|
||||
expect(VOB_SCAN_HOECHSTENS_MS).toBe(4 * 60 * 60_000)
|
||||
// Stille-Grenze 200 ms, aber nach der VOB-Ankündigung kommt 300 ms lang
|
||||
// nichts — der Lauf darf trotzdem weiterleben und liefert den Titel.
|
||||
const info = await titelInfoLesen(fakeMakemkv(REDSELIG), '\\\\.\\G:', { stillMs: 200 })
|
||||
expect(info.titel).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('beim VOB-Scan beendet die Obergrenze den Lauf', { timeout: 20_000 }, async () => {
|
||||
await expect(titelInfoLesen(fakeMakemkv(REDSELIG), '\\\\.\\G:', { stillMs: 60_000, vobScanHoechstensMs: 1200 })).rejects.toThrow('VOB-Scan nach 0 min nicht fertig')
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,7 @@ import { buildInfoArgs, buildRipArgs, RipAuswertung, ZeilenLeser } from '../src/
|
||||
|
||||
describe('Kommandobau', () => {
|
||||
it('info: Robot-Mode ohne Geräte-Scan', () => {
|
||||
expect(buildInfoArgs('\\\\.\\G:')).toEqual(['-r', '--noscan', 'info', 'dev:G:'])
|
||||
expect(buildInfoArgs('\\\\.\\G:')).toEqual(['-r', '--noscan', '--progress=-same', 'info', 'dev:G:'])
|
||||
})
|
||||
it('mkv: Fortschritt in denselben Strom', () => {
|
||||
expect(buildRipArgs('\\\\.\\G:', 'C:\\ziel', 'all')).toEqual([
|
||||
|
||||