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
@@ -2,8 +2,15 @@
|
||||
// Disc-Wache. Die große Oberfläche (Dashboard, Bibliothek, Einstellungen)
|
||||
// ist Etappe W-5 — hier wird das Gerüst SICHTBAR bewiesen.
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { GeraeteInfo, HauptStatus } from '../gemeinsam/nachrichten'
|
||||
import { aufVerbindung, auswerfen, verbindungStarten, type Verbindung } from './kernverbindung'
|
||||
import type { GeraeteInfo, HauptStatus, RipStatus } from '../gemeinsam/nachrichten'
|
||||
import {
|
||||
aufVerbindung,
|
||||
auswerfen,
|
||||
ripAbbrechen,
|
||||
ripStarten,
|
||||
verbindungStarten,
|
||||
type Verbindung,
|
||||
} from './kernverbindung'
|
||||
|
||||
function istHauptStatus(wert: unknown): wert is HauptStatus {
|
||||
return (
|
||||
@@ -51,10 +58,16 @@ function Kachel({ titel, zustand, zeilen }: KachelProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function LaufwerkKachel({ geraet }: { geraet: GeraeteInfo }) {
|
||||
function LaufwerkKachel({ geraet, rip }: { geraet: GeraeteInfo; rip: RipStatus | undefined }) {
|
||||
const ripLaeuft = rip !== undefined && rip.phase === 'rippt'
|
||||
const zustand = geraet.status === 'ready' ? 'ok' : geraet.status === 'empty' ? 'wartet' : 'fehler'
|
||||
const punktFarbe =
|
||||
zustand === 'ok' ? 'bg-emerald-400' : zustand === 'fehler' ? 'bg-red-400' : 'bg-slate-500'
|
||||
const punktFarbe = ripLaeuft
|
||||
? 'animate-pulse bg-sky-400'
|
||||
: zustand === 'ok'
|
||||
? 'bg-emerald-400'
|
||||
: zustand === 'fehler'
|
||||
? 'bg-red-400'
|
||||
: 'bg-slate-500'
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-700/60 bg-slate-800/40 p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
@@ -62,15 +75,34 @@ function LaufwerkKachel({ geraet }: { geraet: GeraeteInfo }) {
|
||||
<span className={`inline-block h-2.5 w-2.5 rounded-full ${punktFarbe}`} />
|
||||
<h2 className="text-sm font-semibold tracking-wide text-slate-200">{geraet.name}</h2>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{ripLaeuft ? (
|
||||
<button
|
||||
onClick={() => ripAbbrechen(geraet.id)}
|
||||
className="rounded-lg border border-red-500/60 px-3 py-1 text-xs text-red-300 hover:bg-red-500/10"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => ripStarten(geraet.id)}
|
||||
disabled={geraet.status !== 'ready'}
|
||||
className="rounded-lg border border-emerald-500/60 px-3 py-1 text-xs text-emerald-300 hover:bg-emerald-500/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title="Alle Titel, verlustfrei (MakeMKV)"
|
||||
>
|
||||
Rippen
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => auswerfen(geraet.id)}
|
||||
disabled={geraet.status !== 'ready'}
|
||||
disabled={geraet.status !== 'ready' || ripLaeuft}
|
||||
className="rounded-lg border border-slate-600 px-3 py-1 text-xs text-slate-300 hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title="Entriegeln, auswerfen, nachsehen — in dieser Reihenfolge"
|
||||
>
|
||||
Auswerfen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-0.5 font-mono text-xs text-slate-400">
|
||||
<p>{geraet.modell.length > 0 ? geraet.modell : 'Modell unbekannt'}</p>
|
||||
{geraet.status === 'ready' && (
|
||||
@@ -85,6 +117,37 @@ function LaufwerkKachel({ geraet }: { geraet: GeraeteInfo }) {
|
||||
<p className="text-amber-300">{geraet.grund}</p>
|
||||
)}
|
||||
</div>
|
||||
{rip !== undefined && (
|
||||
<div className="mt-3 space-y-1.5 border-t border-slate-700/60 pt-3">
|
||||
{ripLaeuft && (
|
||||
<div className="h-2 overflow-hidden rounded-full bg-slate-700">
|
||||
<div
|
||||
className="h-full rounded-full bg-sky-400 transition-all"
|
||||
style={{ width: `${Math.max(0, rip.prozent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p
|
||||
className={`break-all font-mono text-xs ${
|
||||
rip.phase === 'fehler'
|
||||
? 'text-red-300'
|
||||
: rip.phase === 'fertig'
|
||||
? rip.lesefehler
|
||||
? 'text-amber-300'
|
||||
: 'text-emerald-300'
|
||||
: 'text-slate-300'
|
||||
}`}
|
||||
>
|
||||
{ripLaeuft && rip.prozent >= 0 ? `${rip.prozent} % — ` : ''}
|
||||
{rip.text}
|
||||
</p>
|
||||
{rip.dateien.map((datei) => (
|
||||
<p key={datei} className="break-all font-mono text-xs text-slate-500">
|
||||
{datei}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -96,6 +159,7 @@ export default function App() {
|
||||
kern: null,
|
||||
pongLatenzMs: null,
|
||||
laufwerke: [],
|
||||
rips: {},
|
||||
meldungen: [],
|
||||
})
|
||||
|
||||
@@ -135,7 +199,7 @@ export default function App() {
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{verbindung.laufwerke.map((geraet) => (
|
||||
<LaufwerkKachel key={geraet.id} geraet={geraet} />
|
||||
<LaufwerkKachel key={geraet.id} geraet={geraet} rip={verbindung.rips[geraet.id]} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6,13 +6,16 @@
|
||||
// jedem neuen Abonnenten sofort wiedergegeben — er wird nie auf »leer«
|
||||
// zurückgesetzt, nur weil gerade keine Nachricht kam. Insbesondere die
|
||||
// Laufwerksliste: Sie ändert sich NUR durch eine neue Liste vom Kern.
|
||||
import { istKernNachricht, type GeraeteInfo, type KernNachricht } from '../gemeinsam/nachrichten'
|
||||
import { istKernNachricht, type GeraeteInfo, type KernNachricht, type RipStatus } from '../gemeinsam/nachrichten'
|
||||
|
||||
export interface Verbindung {
|
||||
verbunden: boolean
|
||||
kern: Extract<KernNachricht, { art: 'kern-bereit' }> | null
|
||||
pongLatenzMs: number | null
|
||||
laufwerke: GeraeteInfo[]
|
||||
/** Letzter Rip-Stand je Laufwerks-Kennung. Bleibt nach dem Ende stehen,
|
||||
* bis ein neuer Rip startet (R2: kein Verschwinden ohne neue Auskunft). */
|
||||
rips: Record<string, RipStatus>
|
||||
/** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */
|
||||
meldungen: string[]
|
||||
}
|
||||
@@ -21,13 +24,22 @@ type Horcher = (stand: Verbindung) => void
|
||||
|
||||
const MAX_MELDUNGEN = 8
|
||||
|
||||
const stand: Verbindung = { verbunden: false, kern: null, pongLatenzMs: null, laufwerke: [], meldungen: [] }
|
||||
const stand: Verbindung = {
|
||||
verbunden: false,
|
||||
kern: null,
|
||||
pongLatenzMs: null,
|
||||
laufwerke: [],
|
||||
rips: {},
|
||||
meldungen: [],
|
||||
}
|
||||
const horcher = new Set<Horcher>()
|
||||
let gestartet = false
|
||||
let port: MessagePort | null = null
|
||||
|
||||
function melden(): void {
|
||||
for (const h of horcher) h({ ...stand, laufwerke: [...stand.laufwerke], meldungen: [...stand.meldungen] })
|
||||
for (const h of horcher) {
|
||||
h({ ...stand, laufwerke: [...stand.laufwerke], rips: { ...stand.rips }, meldungen: [...stand.meldungen] })
|
||||
}
|
||||
}
|
||||
|
||||
function meldung(text: string): void {
|
||||
@@ -67,6 +79,20 @@ function kernNachricht(nachricht: KernNachricht): void {
|
||||
meldung(nachricht.grund === 'ok' ? text : `⚠ ${text}`)
|
||||
break
|
||||
}
|
||||
case 'rip-status': {
|
||||
stand.rips = { ...stand.rips, [nachricht.status.id]: nachricht.status }
|
||||
if (nachricht.status.phase === 'fertig') {
|
||||
meldung(
|
||||
`Laufwerk ${nachricht.status.id}: Rip fertig — ${nachricht.status.dateien.length} Datei(en)` +
|
||||
(nachricht.status.lesefehler ? ' ⚠ mit Lesefehlern' : ''),
|
||||
)
|
||||
} else if (nachricht.status.phase === 'fehler') {
|
||||
meldung(`⚠ Laufwerk ${nachricht.status.id}: ${nachricht.status.text}`)
|
||||
} else if (nachricht.status.phase === 'abgebrochen') {
|
||||
meldung(`Laufwerk ${nachricht.status.id}: Rip abgebrochen`)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
melden()
|
||||
}
|
||||
@@ -104,15 +130,27 @@ export function verbindungStarten(): void {
|
||||
|
||||
export function aufVerbindung(h: Horcher): () => void {
|
||||
horcher.add(h)
|
||||
h({ ...stand, laufwerke: [...stand.laufwerke], meldungen: [...stand.meldungen] })
|
||||
h({ ...stand, laufwerke: [...stand.laufwerke], rips: { ...stand.rips }, meldungen: [...stand.meldungen] })
|
||||
return () => horcher.delete(h)
|
||||
}
|
||||
|
||||
export function auswerfen(id: string): void {
|
||||
function anKern(nachricht: { art: string; id: string }, wasWar: string): void {
|
||||
if (port === null) {
|
||||
meldung(`⚠ Auswerfen ${id}: keine Verbindung zum Kern`)
|
||||
meldung(`⚠ ${wasWar}: keine Verbindung zum Kern`)
|
||||
melden()
|
||||
return
|
||||
}
|
||||
port.postMessage({ art: 'auswerfen', id })
|
||||
port.postMessage(nachricht)
|
||||
}
|
||||
|
||||
export function auswerfen(id: string): void {
|
||||
anKern({ art: 'auswerfen', id }, `Auswerfen ${id}`)
|
||||
}
|
||||
|
||||
export function ripStarten(id: string): void {
|
||||
anKern({ art: 'rip-start', id }, `Rip ${id}`)
|
||||
}
|
||||
|
||||
export function ripAbbrechen(id: string): void {
|
||||
anKern({ art: 'rip-abbruch', id }, `Rip-Abbruch ${id}`)
|
||||
}
|
||||
|
||||
@@ -38,6 +38,20 @@ export type WacheEreignis =
|
||||
|
||||
export type AuswurfGrund = 'ok' | 'kein-zugriff' | 'abgelehnt' | 'bleibt-drin'
|
||||
|
||||
// ── Rippen (Etappe W-2) ─────────────────────────────────────────────────
|
||||
export type RipPhase = 'liest-info' | 'rippt' | 'fertig' | 'fehler' | 'abgebrochen'
|
||||
|
||||
export interface RipStatus {
|
||||
/** Laufwerks-Kennung, auf der der Rip läuft. */
|
||||
id: string
|
||||
phase: RipPhase
|
||||
/** 0–100; -1 heißt „noch keine Angabe" (ein echtes 0 % bleibt sichtbar). */
|
||||
prozent: number
|
||||
text: string
|
||||
dateien: string[]
|
||||
lesefehler: boolean
|
||||
}
|
||||
|
||||
/** Nachrichten, die der Kern verschickt. */
|
||||
export type KernNachricht =
|
||||
| { art: 'kern-bereit'; pid: number; nodeVersion: string; datenbank: DbStatus }
|
||||
@@ -45,9 +59,14 @@ export type KernNachricht =
|
||||
| { art: 'kern-fehler'; text: string }
|
||||
| { art: 'laufwerke'; liste: GeraeteInfo[]; ereignisse: WacheEreignis[] }
|
||||
| { art: 'auswurf-ergebnis'; id: string; grund: AuswurfGrund; fehler: string | null }
|
||||
| { art: 'rip-status'; status: RipStatus }
|
||||
|
||||
/** Nachrichten, die das Fenster über den MessagePort an den Kern schickt. */
|
||||
export type FensterNachricht = { art: 'ping'; zeit: number } | { art: 'auswerfen'; id: string }
|
||||
export type FensterNachricht =
|
||||
| { art: 'ping'; zeit: number }
|
||||
| { art: 'auswerfen'; id: string }
|
||||
| { art: 'rip-start'; id: string }
|
||||
| { art: 'rip-abbruch'; id: string }
|
||||
|
||||
/** Nachrichten, die das Haupt an den Kern schickt (Port-Übergabe). */
|
||||
export type HauptNachricht = { art: 'fenster-port' }
|
||||
@@ -85,11 +104,25 @@ export function istKernNachricht(wert: unknown): wert is KernNachricht {
|
||||
typeof wert.grund === 'string' &&
|
||||
(wert.fehler === null || typeof wert.fehler === 'string')
|
||||
)
|
||||
case 'rip-status':
|
||||
return istRipStatus(wert.status)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function istRipStatus(wert: unknown): wert is RipStatus {
|
||||
if (!istObjekt(wert)) return false
|
||||
return (
|
||||
typeof wert.id === 'string' &&
|
||||
typeof wert.phase === 'string' &&
|
||||
typeof wert.prozent === 'number' &&
|
||||
typeof wert.text === 'string' &&
|
||||
Array.isArray(wert.dateien) &&
|
||||
typeof wert.lesefehler === 'boolean'
|
||||
)
|
||||
}
|
||||
|
||||
export function istDbStatus(wert: unknown): wert is DbStatus {
|
||||
if (!istObjekt(wert)) return false
|
||||
if (wert.ok === true) return typeof wert.pfad === 'string'
|
||||
@@ -100,7 +133,9 @@ export function istDbStatus(wert: unknown): wert is DbStatus {
|
||||
export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
|
||||
if (!istObjekt(wert)) return false
|
||||
if (wert.art === 'ping') return typeof wert.zeit === 'number'
|
||||
if (wert.art === 'auswerfen') return typeof wert.id === 'string' && wert.id.length > 0
|
||||
if (wert.art === 'auswerfen' || wert.art === 'rip-start' || wert.art === 'rip-abbruch') {
|
||||
return typeof wert.id === 'string' && wert.id.length > 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Tests des Werkzeug-Katalogs — Suchreihenfolge und die %VAR%-Falle
|
||||
// (Groß/Klein), beides ohne installierte Programme (Prüf-Funktionen
|
||||
// werden eingespritzt, Muster aus test_katalog.py).
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { entfalten, finden, kandidaten, WERKZEUGE } from '../src/kern/werkzeuge/katalog'
|
||||
|
||||
describe('entfalten — %VAR% ohne Rücksicht auf Schreibweise', () => {
|
||||
it('die gemessene Falle: PROGRAMFILES(X86) trifft %ProgramFiles(x86)%', () => {
|
||||
// Windows liefert Umgebungsnamen GROSS; das Muster schreibt sie
|
||||
// gemischt. Der strikte Vergleich ließ die bekannten Orte auf einer
|
||||
// echten Maschine NIE greifen (katalog.py, 28.08.2026).
|
||||
expect(entfalten('%ProgramFiles(x86)%\\MakeMKV', { 'PROGRAMFILES(X86)': 'C:\\Program Files (x86)' })).toBe(
|
||||
'C:\\Program Files (x86)\\MakeMKV',
|
||||
)
|
||||
})
|
||||
it('bleibt ein % stehen, ist der Kandidat unbrauchbar', () => {
|
||||
expect(entfalten('%GibtEsNicht%\\X', {})).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('kandidaten — Reihenfolge: eigener Ordner vor System', () => {
|
||||
it('eigener Ordner zuerst, dann bekannte Orte, 64 Bit zuerst', () => {
|
||||
const liste = kandidaten('makemkv', 'C:\\R\\tools', {
|
||||
'PROGRAMFILES(X86)': 'C:\\PF86',
|
||||
PROGRAMFILES: 'C:\\PF',
|
||||
})
|
||||
expect(liste[0]).toBe('C:\\R\\tools\\makemkv\\makemkvcon64.exe')
|
||||
expect(liste).toContain('C:\\PF86\\MakeMKV\\makemkvcon64.exe')
|
||||
expect(liste.indexOf('C:\\R\\tools\\makemkvcon64.exe')).toBeLessThan(
|
||||
liste.indexOf('C:\\PF86\\MakeMKV\\makemkvcon64.exe'),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('finden', () => {
|
||||
const umgebung = { 'PROGRAMFILES(X86)': 'C:\\PF86', PATH: '' }
|
||||
|
||||
it('ein eingestellter Pfad gewinnt — und ein falscher wird NICHT ersetzt', () => {
|
||||
const existiert = (p: string): boolean => p === 'D:\\eigen\\makemkvcon64.exe'
|
||||
expect(finden('makemkv', 'D:\\eigen\\makemkvcon64.exe', 'C:\\R', umgebung, existiert, () => '')).toBe(
|
||||
'D:\\eigen\\makemkvcon64.exe',
|
||||
)
|
||||
// Tippfehler soll als Fehler sichtbar werden, nicht still anderswo landen.
|
||||
expect(finden('makemkv', 'D:\\tippfehler.exe', 'C:\\R', umgebung, existiert, () => '')).toBe('')
|
||||
})
|
||||
|
||||
it('findet über die bekannten Orte', () => {
|
||||
const existiert = (p: string): boolean => p === 'C:\\PF86\\MakeMKV\\makemkvcon64.exe'
|
||||
expect(finden('makemkv', '', 'C:\\R', umgebung, existiert, () => '')).toBe(
|
||||
'C:\\PF86\\MakeMKV\\makemkvcon64.exe',
|
||||
)
|
||||
})
|
||||
|
||||
it('fällt zuletzt auf die Registry zurück', () => {
|
||||
expect(finden('makemkv', '', 'C:\\R', umgebung, () => false, () => 'C:\\Reg\\makemkvcon64.exe')).toBe(
|
||||
'C:\\Reg\\makemkvcon64.exe',
|
||||
)
|
||||
})
|
||||
|
||||
it('nichts gefunden ist "", kein Raten', () => {
|
||||
expect(finden('makemkv', '', 'C:\\R', umgebung, () => false, () => '')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('WERKZEUGE — die Pflicht-Markierungen', () => {
|
||||
it('nur MakeMKV ist Pflicht; ohne HandBrake/FLAC läuft Rippy eingeschränkt', () => {
|
||||
expect(WERKZEUGE.makemkv.pflicht).toBe(true)
|
||||
expect(WERKZEUGE.handbrake.pflicht).toBe(false)
|
||||
expect(WERKZEUGE.flac.pflicht).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
// MESSUNG an echter Hardware: makemkvcon finden und die eingelegte Disc
|
||||
// befragen (Titel + Sprachen). Läuft nur auf Verlangen:
|
||||
//
|
||||
// RIPPY_MESSUNG=1 npx vitest run test/messung.makemkv.test.ts
|
||||
//
|
||||
// Der VOLLE Rip ist ein eigener Lauf (messung.rip.test.ts) — er dauert
|
||||
// je Disc 30–90 Minuten.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const messen = process.env.RIPPY_MESSUNG === '1' && process.platform === 'win32'
|
||||
|
||||
describe.skipIf(!messen)('makemkvcon am echten System', () => {
|
||||
it('wird über den Katalog gefunden', async () => {
|
||||
const { finden } = await import('../src/kern/werkzeuge/katalog')
|
||||
const pfad = finden('makemkv')
|
||||
console.log(`MESSUNG makemkvcon: ${pfad.length > 0 ? pfad : 'NICHT GEFUNDEN'}`)
|
||||
const handbrake = finden('handbrake')
|
||||
console.log(`MESSUNG HandBrakeCLI: ${handbrake.length > 0 ? handbrake : 'NICHT GEFUNDEN'}`)
|
||||
const flac = finden('flac')
|
||||
console.log(`MESSUNG flac: ${flac.length > 0 ? flac : 'NICHT GEFUNDEN'}`)
|
||||
expect(pfad.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('liest Titel und Sprachen der eingelegten Disc', { timeout: 360_000 }, async () => {
|
||||
const { finden } = await import('../src/kern/werkzeuge/katalog')
|
||||
const { titelInfoLesen } = await import('../src/kern/rip/makemkv')
|
||||
const { win32Api } = await import('../src/kern/laufwerk/win32')
|
||||
const { listOptischeLaufwerke } = await import('../src/kern/laufwerk/disc')
|
||||
|
||||
const programm = finden('makemkv')
|
||||
expect(programm.length).toBeGreaterThan(0)
|
||||
const geraete = listOptischeLaufwerke(win32Api())
|
||||
expect(geraete.length).toBeGreaterThan(0)
|
||||
|
||||
const info = await titelInfoLesen(programm, geraete[0])
|
||||
console.log(`MESSUNG Titel: ${info.titel.length}`)
|
||||
for (const t of info.titel.slice(0, 12)) {
|
||||
console.log(
|
||||
`MESSUNG #${t.nr}: ${Math.floor(t.dauerS / 60)}min ${t.dauerS % 60}s · ` +
|
||||
`${(t.groesseBytes / 1e9).toFixed(2)} GB · ${t.kapitel} Kapitel`,
|
||||
)
|
||||
}
|
||||
console.log(
|
||||
`MESSUNG Sprachen Ton: ${info.sprachen.audio.map((s) => `${s.lang}(${s.spuren})`).join(', ') || '—'}`,
|
||||
)
|
||||
console.log(
|
||||
`MESSUNG Sprachen Untertitel: ${info.sprachen.untertitel.map((s) => `${s.lang}(${s.spuren})`).join(', ') || '—'}`,
|
||||
)
|
||||
expect(info.titel.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(messen)('Messung übersprungen', () => {
|
||||
it('läuft nur mit RIPPY_MESSUNG=1 unter Windows', () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
// DER W-2-BEWEIS: ein voller, verlustfreier Rip der eingelegten Disc am
|
||||
// echten Laufwerk — dauert 30–90 Minuten. Läuft nur auf Verlangen:
|
||||
//
|
||||
// RIPPY_MESSUNG_RIP=1 RIPPY_MESSUNG_ZIEL=<ordner> \
|
||||
// npx vitest run test/messung.rip.test.ts --reporter=verbose
|
||||
//
|
||||
// Fortschritt wird in 5-%-Schritten geloggt; das Ergebnis nennt Dateien
|
||||
// und Größen. Das Protokoll wandert in den SAVEPOINT (beweisen statt
|
||||
// behaupten).
|
||||
import { statSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const messen = process.env.RIPPY_MESSUNG_RIP === '1' && process.platform === 'win32'
|
||||
const zielWurzel = process.env.RIPPY_MESSUNG_ZIEL ?? ''
|
||||
|
||||
describe.skipIf(!messen)('Voller Rip am echten Laufwerk', () => {
|
||||
it('rippt die eingelegte Disc verlustfrei', { timeout: 3 * 60 * 60 * 1000 }, async () => {
|
||||
expect(zielWurzel.length, 'RIPPY_MESSUNG_ZIEL fehlt').toBeGreaterThan(0)
|
||||
const { finden } = await import('../src/kern/werkzeuge/katalog')
|
||||
const { rippen } = await import('../src/kern/rip/makemkv')
|
||||
const { win32Api } = await import('../src/kern/laufwerk/win32')
|
||||
const { listOptischeLaufwerke } = await import('../src/kern/laufwerk/disc')
|
||||
|
||||
const programm = finden('makemkv')
|
||||
expect(programm.length).toBeGreaterThan(0)
|
||||
const geraete = listOptischeLaufwerke(win32Api())
|
||||
expect(geraete.length).toBeGreaterThan(0)
|
||||
|
||||
const start = Date.now()
|
||||
let letzteStufe = -5
|
||||
const ergebnis = await rippen({
|
||||
programm,
|
||||
geraetepfad: geraete[0],
|
||||
zielOrdner: zielWurzel,
|
||||
titel: 'all',
|
||||
aufFortschritt: (prozent) => {
|
||||
if (prozent >= letzteStufe + 5) {
|
||||
letzteStufe = prozent
|
||||
const minuten = ((Date.now() - start) / 60000).toFixed(1)
|
||||
console.log(`RIP-FORTSCHRITT ${prozent}% nach ${minuten}min`)
|
||||
}
|
||||
},
|
||||
aufMeldung: (code, text) => {
|
||||
// Nur die tragenden Meldungen — sonst wird das Protokoll unlesbar.
|
||||
if (code >= 5000 || text.includes('errors') || text.includes('gesichert') || text.includes('saved')) {
|
||||
console.log(`RIP-MSG ${code}: ${text}`)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const minuten = ((Date.now() - start) / 60000).toFixed(1)
|
||||
if (ergebnis.status === 'success') {
|
||||
console.log(`RIP-ERGEBNIS success nach ${minuten}min · lesefehler=${ergebnis.lesefehler}`)
|
||||
for (const datei of ergebnis.dateien) {
|
||||
console.log(`RIP-DATEI ${datei} · ${(statSync(datei).size / 1e9).toFixed(2)} GB`)
|
||||
}
|
||||
} else {
|
||||
console.log(`RIP-ERGEBNIS ${ergebnis.status} nach ${minuten}min: ${'fehler' in ergebnis ? ergebnis.fehler : ''}`)
|
||||
}
|
||||
expect(ergebnis.status).toBe('success')
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(messen)('Rip-Messung übersprungen', () => {
|
||||
it('läuft nur mit RIPPY_MESSUNG_RIP=1 unter Windows', () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
// Tests der Rip-Lauf-Bausteine: Zeilenzerlegung (binär, halbe Zeilen)
|
||||
// und die Auswertung (Fortschritt, Lesefehler, kritische Ursachen) —
|
||||
// alles ohne echtes makemkvcon.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildInfoArgs, buildRipArgs, RipAuswertung, ZeilenLeser } from '../src/kern/rip/makemkv'
|
||||
|
||||
describe('Kommandobau', () => {
|
||||
it('info: Robot-Mode ohne Geräte-Scan', () => {
|
||||
expect(buildInfoArgs('\\\\.\\G:')).toEqual(['-r', '--noscan', 'info', 'dev:G:'])
|
||||
})
|
||||
it('mkv: Fortschritt in denselben Strom', () => {
|
||||
expect(buildRipArgs('\\\\.\\G:', 'C:\\ziel', 'all')).toEqual([
|
||||
'-r',
|
||||
'--noscan',
|
||||
'--progress=-same',
|
||||
'mkv',
|
||||
'dev:G:',
|
||||
'all',
|
||||
'C:\\ziel',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ZeilenLeser — binär, halbe Zeilen bleiben liegen', () => {
|
||||
it('setzt Zeilen über Stück-Grenzen zusammen', () => {
|
||||
const leser = new ZeilenLeser()
|
||||
expect(leser.hinzu(Buffer.from('PRGV:1,2,'))).toEqual([])
|
||||
expect(leser.hinzu(Buffer.from('65536\r\nMSG:1,0,0,"a"\nRest'))).toEqual([
|
||||
'PRGV:1,2,65536',
|
||||
'MSG:1,0,0,"a"',
|
||||
])
|
||||
expect(leser.schluss()).toEqual(['Rest'])
|
||||
})
|
||||
it('dekodiert jede Zeile einzeln — auch cp1252 mitten im Strom', () => {
|
||||
const leser = new ZeilenLeser()
|
||||
const zeilen = leser.hinzu(
|
||||
Buffer.concat([Buffer.from('MSG:5010,0,0,"Das '), Buffer.from('Öffnen', 'latin1'), Buffer.from('"\n')]),
|
||||
)
|
||||
expect(zeilen).toEqual(['MSG:5010,0,0,"Das Öffnen"'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('RipAuswertung', () => {
|
||||
it('meldet Fortschritt nur bei Änderung', () => {
|
||||
const a = new RipAuswertung()
|
||||
expect(a.verarbeite('PRGV:0,32768,65536')).toEqual({ prozent: 50 })
|
||||
expect(a.verarbeite('PRGV:0,32769,65536')).toEqual({})
|
||||
expect(a.verarbeite('PRGV:0,45876,65536')).toEqual({ prozent: 70 })
|
||||
})
|
||||
|
||||
it('erkennt Lesefehler an der sprachfesten URL — auch auf Deutsch', () => {
|
||||
const a = new RipAuswertung()
|
||||
a.verarbeite(
|
||||
'MSG:2003,0,3,"Encountered 29 errors of type \'Read Error\' - see http://www.makemkv.com/errors/read/","f"',
|
||||
)
|
||||
expect(a.lesefehler).toBe(true)
|
||||
})
|
||||
|
||||
it('sammelt kritische URSACHEN über die Nummer, nicht den Text', () => {
|
||||
const a = new RipAuswertung()
|
||||
a.verarbeite('MSG:5051,0,0,"Der Testzeitraum ist abgelaufen. Erwerben Sie bitte einen Schlüssel"')
|
||||
a.verarbeite('MSG:5010,0,0,"Das Öffnen der Disk schlug fehl"')
|
||||
const fehler = a.fehlerText(11, false)
|
||||
expect(fehler).toContain('Code 11')
|
||||
expect(fehler).toContain('nicht freigeschaltet')
|
||||
expect(fehler).toContain('keine MKV-Datei entstanden')
|
||||
})
|
||||
|
||||
it('englische Textmuster bleiben als Rückfall', () => {
|
||||
const a = new RipAuswertung()
|
||||
a.verarbeite('MSG:9999,0,0,"AACS volume key is unknown for this disc"')
|
||||
expect(a.kritischeMeldungen).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,186 @@
|
||||
// Tests des makemkvcon-Parsers — die Testfälle wandern sinngemäß aus dem
|
||||
// Docker-Zweig mit (test_makemkv_aufruf.py, ripping.py-Doku): Ein neu
|
||||
// geschriebener Parser, der dieselben Fälle besteht, kennt dieselben
|
||||
// Fallen (KONZEPT § 10).
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
episodenTitel,
|
||||
KRITISCHE_CODES,
|
||||
laengsterTitel,
|
||||
parseMsg,
|
||||
parseStreamInfo,
|
||||
parseTitelInfo,
|
||||
progressAusPrgv,
|
||||
quelle,
|
||||
sprachenZusammenfassen,
|
||||
textVon,
|
||||
} from '../src/kern/rip/parser'
|
||||
|
||||
describe('quelle — wie MakeMKV das Laufwerk nennt', () => {
|
||||
it.each([
|
||||
['\\\\.\\G:', 'dev:G:'],
|
||||
['G:', 'dev:G:'],
|
||||
['G:\\', 'dev:G:'],
|
||||
['\\\\?\\E:', 'dev:E:'],
|
||||
['/dev/sr0', 'dev:/dev/sr0'],
|
||||
])('%s → %s', (eingabe, erwartet) => {
|
||||
expect(quelle(eingabe)).toBe(erwartet)
|
||||
})
|
||||
it('ohne Gerät kommt nichts', () => {
|
||||
expect(quelle('')).toBe('')
|
||||
expect(quelle(' ')).toBe('')
|
||||
})
|
||||
it('ein langer Windows-Pfad wird nicht verstümmelt', () => {
|
||||
expect(quelle('\\\\.\\D:\\Ordner')).toBe('dev:D:\\Ordner')
|
||||
})
|
||||
})
|
||||
|
||||
describe('textVon — Kodierung bestimmen statt behaupten', () => {
|
||||
it('liest UTF-8', () => {
|
||||
expect(textVon(Buffer.from('Das Öffnen', 'utf8'))).toBe('Das Öffnen')
|
||||
})
|
||||
it('liest AUCH cp1252', () => {
|
||||
expect(textVon(Buffer.from('Das Öffnen', 'latin1'))).toBe('Das Öffnen')
|
||||
})
|
||||
it('der Fehler des Commanders kommt NICHT zurück (kein Ã)', () => {
|
||||
for (const kodierung of ['utf8', 'latin1'] as const) {
|
||||
const gelesen = textVon(Buffer.from('Das Öffnen der Disk schlug fehl', kodierung))
|
||||
expect(gelesen).not.toContain('Ã')
|
||||
expect(gelesen).toBe('Das Öffnen der Disk schlug fehl')
|
||||
}
|
||||
})
|
||||
it('Text bleibt Text, leer bleibt leer, kaputte Bytes werfen nicht', () => {
|
||||
expect(textVon('schon Text')).toBe('schon Text')
|
||||
expect(textVon(Buffer.alloc(0))).toBe('')
|
||||
expect(textVon(Buffer.from([0xff, 0xfe, 0x20]))).toBeTypeOf('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseMsg', () => {
|
||||
it('liest Code und Klartext', () => {
|
||||
expect(parseMsg('MSG:5010,0,1,"Das Öffnen der Disk schlug fehl","%1","x"')).toEqual({
|
||||
code: 5010,
|
||||
text: 'Das Öffnen der Disk schlug fehl',
|
||||
})
|
||||
})
|
||||
it('Meldungen MIT Komma werden nicht abgeschnitten (25.07.-Falle)', () => {
|
||||
const m = parseMsg('MSG:3025,0,1,"Title #1 has length of 12 seconds, which is less than minimum","f"')
|
||||
expect(m?.text).toBe('Title #1 has length of 12 seconds, which is less than minimum')
|
||||
})
|
||||
it('maskierte Anführungszeichen werden entpackt', () => {
|
||||
expect(parseMsg('MSG:1,0,0,"sagt \\"hallo\\""')?.text).toBe('sagt "hallo"')
|
||||
})
|
||||
it('Nicht-MSG-Zeilen sind null', () => {
|
||||
expect(parseMsg('PRGV:0,1,65536')).toBeNull()
|
||||
expect(parseMsg('irgendwas')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('progressAusPrgv', () => {
|
||||
it('total/max ist der Gesamtfortschritt', () => {
|
||||
expect(progressAusPrgv('PRGV:100,32768,65536')).toBe(50)
|
||||
expect(progressAusPrgv('PRGV:0,65536,65536')).toBe(100)
|
||||
})
|
||||
it('ein echtes 0 % ist eine Angabe, keine Zeile ist -1', () => {
|
||||
expect(progressAusPrgv('PRGV:0,0,65536')).toBe(0)
|
||||
expect(progressAusPrgv('MSG:1,0,0,"x"')).toBe(-1)
|
||||
expect(progressAusPrgv('PRGV:1,1,0')).toBe(-1)
|
||||
})
|
||||
})
|
||||
|
||||
// Fixture nach dem dokumentierten Robot-Format; die SINFO-Zeilen sind das
|
||||
// am 26.07.2026 an der Akira-Blu-ray abgenommene Muster.
|
||||
const INFO_AUSGABE = [
|
||||
'TINFO:0,8,0,"12"',
|
||||
'TINFO:0,9,0,"1:57:07"',
|
||||
'TINFO:0,11,0,"31474929664"',
|
||||
'TINFO:1,9,0,"0:02:01"',
|
||||
'TINFO:1,8,0,"2"',
|
||||
'SINFO:0,0,1,6201,"Video"',
|
||||
'SINFO:0,1,1,6202,"Audio"',
|
||||
'SINFO:0,1,3,0,"deu"',
|
||||
'SINFO:0,1,4,0,"German"',
|
||||
'SINFO:0,1,6,0,"DTS-HD MA"',
|
||||
'SINFO:0,1,14,0,"2"',
|
||||
'SINFO:0,1,30,0,"DTS-HD MA Stereo German"',
|
||||
'SINFO:0,2,1,6202,"Audio"',
|
||||
'SINFO:0,2,3,0,"jpn"',
|
||||
'SINFO:0,2,4,0,"Japanese"',
|
||||
'SINFO:0,3,1,6203,"Subtitles"',
|
||||
'SINFO:0,3,3,0,"deu"',
|
||||
'SINFO:0,3,4,0,"German"',
|
||||
].join('\n')
|
||||
|
||||
describe('parseTitelInfo', () => {
|
||||
it('liest Dauer, Größe und Kapitel je Titel', () => {
|
||||
expect(parseTitelInfo(INFO_AUSGABE)).toEqual([
|
||||
{ nr: 0, dauerS: 7027, groesseBytes: 31_474_929_664, kapitel: 12 },
|
||||
{ nr: 1, dauerS: 121, groesseBytes: 0, kapitel: 2 },
|
||||
])
|
||||
})
|
||||
it('leere Ausgabe ist eine leere Liste', () => {
|
||||
expect(parseTitelInfo('')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseStreamInfo + sprachenZusammenfassen', () => {
|
||||
it('trennt Audio und Untertitel, Sprache aus Attribut 3/4', () => {
|
||||
const streams = parseStreamInfo(INFO_AUSGABE)
|
||||
const titel0 = streams.get(0)!
|
||||
expect(titel0.audio.map((s) => s.lang)).toEqual(['deu', 'jpn'])
|
||||
expect(titel0.audio[0]).toMatchObject({ codec: 'DTS-HD MA', kanaele: '2', sprache: 'German' })
|
||||
expect(titel0.untertitel.map((s) => s.lang)).toEqual(['deu'])
|
||||
})
|
||||
it('fasst die Sprachen der ganzen Disc zusammen', () => {
|
||||
const sprachen = sprachenZusammenfassen(parseStreamInfo(INFO_AUSGABE))
|
||||
expect(sprachen.audio).toEqual([
|
||||
{ lang: 'deu', sprache: 'German', spuren: 1 },
|
||||
{ lang: 'jpn', sprache: 'Japanese', spuren: 1 },
|
||||
])
|
||||
expect(sprachen.untertitel).toEqual([{ lang: 'deu', sprache: 'German', spuren: 1 }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('laengsterTitel', () => {
|
||||
const dauern = new Map([
|
||||
[0, 7027], // Hauptfilm ~117 min
|
||||
[1, 7300], // „Play All" wäre länger
|
||||
[2, 121],
|
||||
])
|
||||
it('ohne Soll-Laufzeit gewinnt der längste', () => {
|
||||
expect(laengsterTitel(dauern)).toBe(1)
|
||||
})
|
||||
it('mit Soll-Laufzeit gewinnt der nächstliegende (Play-All-Falle)', () => {
|
||||
expect(laengsterTitel(dauern, 117)).toBe(0)
|
||||
})
|
||||
it('Soll-Laufzeit weit weg von allem → wieder der längste', () => {
|
||||
expect(laengsterTitel(new Map([[0, 100]]), 117)).toBe(0)
|
||||
expect(laengsterTitel(new Map(), 117)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('episodenTitel', () => {
|
||||
it('findet Titel nahe der mittleren Episoden-Laufzeit (±10 min)', () => {
|
||||
const dauern = new Map([
|
||||
[1, 42 * 60],
|
||||
[2, 44 * 60],
|
||||
[3, 43 * 60],
|
||||
[9, 170 * 60], // Play All
|
||||
[10, 2 * 60], // Trailer
|
||||
])
|
||||
expect(episodenTitel(dauern, [43, 43, 42])).toEqual([1, 2, 3])
|
||||
})
|
||||
it('ohne Angaben keine Treffer', () => {
|
||||
expect(episodenTitel(new Map(), [43])).toEqual([])
|
||||
expect(episodenTitel(new Map([[1, 60]]), [])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('KRITISCHE_CODES — die gemessenen Ursachen', () => {
|
||||
it('kennt die vier am Commander-Laufwerk gesehenen Nummern + 5010', () => {
|
||||
for (const code of [2024, 5010, 5020, 5021, 5051]) {
|
||||
expect(KRITISCHE_CODES[code], `Code ${code}`).toBeDefined()
|
||||
expect(KRITISCHE_CODES[code].length).toBeGreaterThan(20)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user