diff --git a/rippy-windows/src/fenster/App.tsx b/rippy-windows/src/fenster/App.tsx index f736079..eb8e2dc 100644 --- a/rippy-windows/src/fenster/App.tsx +++ b/rippy-windows/src/fenster/App.tsx @@ -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 (
@@ -62,14 +75,33 @@ function LaufwerkKachel({ geraet }: { geraet: GeraeteInfo }) {

{geraet.name}

- +
+ {ripLaeuft ? ( + + ) : ( + + )} + +

{geraet.modell.length > 0 ? geraet.modell : 'Modell unbekannt'}

@@ -85,6 +117,37 @@ function LaufwerkKachel({ geraet }: { geraet: GeraeteInfo }) {

{geraet.grund}

)}
+ {rip !== undefined && ( +
+ {ripLaeuft && ( +
+
+
+ )} +

+ {ripLaeuft && rip.prozent >= 0 ? `${rip.prozent} % — ` : ''} + {rip.text} +

+ {rip.dateien.map((datei) => ( +

+ {datei} +

+ ))} +
+ )}
) } @@ -96,6 +159,7 @@ export default function App() { kern: null, pongLatenzMs: null, laufwerke: [], + rips: {}, meldungen: [], }) @@ -135,7 +199,7 @@ export default function App() { ) : (
{verbindung.laufwerke.map((geraet) => ( - + ))}
)} diff --git a/rippy-windows/src/fenster/kernverbindung.ts b/rippy-windows/src/fenster/kernverbindung.ts index ee23cc0..1e7af7c 100644 --- a/rippy-windows/src/fenster/kernverbindung.ts +++ b/rippy-windows/src/fenster/kernverbindung.ts @@ -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 | 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 /** 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() 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}`) } diff --git a/rippy-windows/src/gemeinsam/nachrichten.ts b/rippy-windows/src/gemeinsam/nachrichten.ts index 1a119b7..1371253 100644 --- a/rippy-windows/src/gemeinsam/nachrichten.ts +++ b/rippy-windows/src/gemeinsam/nachrichten.ts @@ -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 } diff --git a/rippy-windows/src/kern/ablauf/rippen.ts b/rippy-windows/src/kern/ablauf/rippen.ts new file mode 100644 index 0000000..542e13e --- /dev/null +++ b/rippy-windows/src/kern/ablauf/rippen.ts @@ -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() + + 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 { + 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 } + } +} diff --git a/rippy-windows/src/kern/index.ts b/rippy-windows/src/kern/index.ts index 3d888f6..bb30d34 100644 --- a/rippy-windows/src/kern/index.ts +++ b/rippy-windows/src/kern/index.ts @@ -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= kommt vom Haupt (der Kern kennt Electrons app.getPath // nicht — er ist ein reiner Node-Prozess). @@ -101,8 +104,37 @@ const laufwerkeBereit: Promise = (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 { 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() diff --git a/rippy-windows/src/kern/rip/makemkv.ts b/rippy-windows/src/kern/rip/makemkv.ts new file mode 100644 index 0000000..59aa9c5 --- /dev/null +++ b/rippy-windows/src/kern/rip/makemkv.ts @@ -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 + 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 { + 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 { + 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, + }) + }) + }) +} diff --git a/rippy-windows/src/kern/rip/parser.ts b/rippy-windows/src/kern/rip/parser.ts new file mode 100644 index 0000000..4acdd3a --- /dev/null +++ b/rippy-windows/src/kern/rip/parser.ts @@ -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() + for (const roh of ausgabe.split(/\r?\n/)) { + const treffer = TINFO_ZEILE.exec(roh.trim()) + if (treffer === null) continue + const nr = Number(treffer[1]) + const attr = Number(treffer[2]) + const wert = treffer[3] + let eintrag = titel.get(nr) + if (eintrag === undefined) { + eintrag = { nr, dauerS: 0, groesseBytes: 0, kapitel: 0 } + titel.set(nr, eintrag) + } + if (attr === 9) { + const teile = wert.split(':') + if (teile.length === 3 && teile.every((t) => /^\d+$/.test(t))) { + eintrag.dauerS = Number(teile[0]) * 3600 + Number(teile[1]) * 60 + Number(teile[2]) + } + } else if (attr === 11 && /^\d+$/.test(wert)) { + eintrag.groesseBytes = Number(wert) + } else if (attr === 8 && /^\d+$/.test(wert)) { + eintrag.kapitel = Number(wert) + } + } + return [...titel.keys()].sort((a, b) => a - b).map((nr) => titel.get(nr)!) +} + +// ── SINFO: Tonspuren und Untertitel ───────────────────────────────────── +// Attribut-IDs am 26.07.2026 an der Akira-Blu-ray abgenommen: +// 1 = Typ („Audio"/„Subtitles"), 3 = Sprachcode, 4 = Sprachname, +// 6 = Codec, 14 = Kanäle, 30 = Beschreibung. +// ⚠️ Die Sprache steht in 3/4 — NICHT in 28/29: Die tragen auf JEDEM +// Stream „eng"/„English" (MakeMKVs Anzeigesprache). Wer 28 nimmt, hält +// jede Disc für englisch. +export interface StreamInfo { + nr: number + typ: string + lang: string + sprache: string + codec: string + kanaele: string + beschreibung: string +} + +export interface TitelStreams { + audio: StreamInfo[] + untertitel: StreamInfo[] +} + +const SINFO_ZEILE = /^SINFO:(\d+),(\d+),(\d+),\d+,"(.*)"\s*$/ + +export function parseStreamInfo(ausgabe: string): Map { + const titel = new Map>() + for (const roh of (ausgabe ?? '').split(/\r?\n/)) { + const treffer = SINFO_ZEILE.exec(roh.trim()) + if (treffer === null) continue + const tNr = Number(treffer[1]) + const sNr = Number(treffer[2]) + const attr = Number(treffer[3]) + const wert = treffer[4] + let streams = titel.get(tNr) + if (streams === undefined) { + streams = new Map() + titel.set(tNr, streams) + } + let eintrag = streams.get(sNr) + if (eintrag === undefined) { + eintrag = { nr: sNr, typ: '', lang: '', sprache: '', codec: '', kanaele: '', beschreibung: '' } + streams.set(sNr, eintrag) + } + if (attr === 1) eintrag.typ = wert + else if (attr === 3) eintrag.lang = wert + else if (attr === 4) eintrag.sprache = wert + else if (attr === 6) eintrag.codec = wert + else if (attr === 14) eintrag.kanaele = wert + else if (attr === 30) eintrag.beschreibung = wert + } + + const ergebnis = new Map() + for (const [tNr, streams] of titel) { + const audio: StreamInfo[] = [] + const untertitel: StreamInfo[] = [] + for (const sNr of [...streams.keys()].sort((a, b) => a - b)) { + const s = streams.get(sNr)! + // MakeMKV schreibt „Subtitles" (englisch, Plural) — gemessen. + if (s.typ === 'Audio') audio.push(s) + else if (s.typ.startsWith('Subtitle')) untertitel.push(s) + } + ergebnis.set(tNr, { audio, untertitel }) + } + return ergebnis +} + +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): { + audio: SprachEintrag[] + untertitel: SprachEintrag[] +} { + const sammeln = (schluessel: 'audio' | 'untertitel'): SprachEintrag[] => { + const gefunden = new Map() + for (const streams of streamsJeTitel.values()) { + for (const s of streams[schluessel]) { + if (s.lang.length === 0) continue + let eintrag = gefunden.get(s.lang) + if (eintrag === undefined) { + eintrag = { lang: s.lang, sprache: s.sprache.length > 0 ? s.sprache : s.lang, spuren: 0 } + gefunden.set(s.lang, eintrag) + } + eintrag.spuren += 1 + } + } + return [...gefunden.values()] + } + return { audio: sammeln('audio'), untertitel: sammeln('untertitel') } +} + +// ── Titelwahl-Heuristiken ─────────────────────────────────────────────── +/** Titel-Nummer für den Hauptfilm. Mit Soll-Laufzeit (Minuten, z. B. aus + * TMDb) gewinnt der Titel, der ihr am nächsten kommt (±20 min) — das + * vermeidet „Play All"-Titel, die länger als der Film sind. */ +export function laengsterTitel(dauern: Map, sollMinuten = 0): number | null { + if (dauern.size === 0) return null + const zielS = sollMinuten * 60 + if (zielS > 0) { + let bester: number | null = null + let abstand = Number.POSITIVE_INFINITY + for (const [nr, dauer] of dauern) { + const d = Math.abs(dauer - zielS) + if (d < abstand) { + abstand = d + bester = nr + } + } + if (bester !== null && abstand <= 20 * 60) return bester + } + let laengster: number | null = null + let max = -1 + for (const [nr, dauer] of dauern) { + if (dauer > max) { + max = dauer + laengster = nr + } + } + return laengster +} + +/** Episoden-Titel: alle Titel, deren Laufzeit ±10 min am Durchschnitt der + * bekannten Episoden-Laufzeiten (Minuten) liegt. */ +export function episodenTitel(dauern: Map, episodenMinuten: number[]): number[] { + if (dauern.size === 0 || episodenMinuten.length === 0) return [] + const laufzeitenS = episodenMinuten.map((m) => m * 60) + const schnitt = laufzeitenS.reduce((a, b) => a + b, 0) / laufzeitenS.length + const gefunden: number[] = [] + for (const [nr, dauer] of dauern) { + if (Math.abs(dauer - schnitt) <= 10 * 60) gefunden.push(nr) + } + return gefunden.sort((a, b) => a - b) +} + +// ── Fehler-Erkennung ──────────────────────────────────────────────────── +// Sprachunabhängige Marke für „die Disc ließ sich stellenweise nicht +// lesen": Die URL steht auch in der deutschen MakeMKV-Fassung englisch da +// (30.08.2026, Spartacus Disc 2 — 1 von 2 Titeln kam an, Rückgabewert 0). +export const LESEFEHLER_MARKE = 'makemkv.com/errors/read' + +// Meldungs-Nummern, die die URSACHE eines Fehlschlags benennen — sie sind +// sprachunabhängig, anders als die Texte. Jede Nummer wurde am Laufwerk +// des Commanders GESEHEN (makemkv_aufruf.py, im Wortlaut dokumentiert). +export const KRITISCHE_CODES: Record = { + 2024: 'MakeMKV kennt dieses Laufwerk nicht', + 5010: 'Das Laufwerk ließ sich nicht öffnen — liegt die Disc noch drin? Nach einem fertigen Rip wirft Rippy sie aus', + 5020: 'Der hinterlegte MakeMKV-Schlüssel wird nicht angenommen', + 5021: 'MakeMKV ist zu alt für den aktuellen Beta-Schlüssel — bitte MakeMKV aktualisieren', + // Am 30.08.2026 am BU40N gemessen: Nach einem abgebrochenen Rip samt + // gescheitertem Auswurf sah MakeMKV GAR KEIN Laufwerk mehr (leere + // DRV-Liste), während die Windows-Storage-Abfragen (CHECK_VERIFY2, + // GET_LENGTH) weiter sauber antworteten — MakeMKV spricht SCSI direkt, + // und diese Ebene blieb hängen. + 5042: 'MakeMKV findet gerade kein Laufwerk (das passiert nach abgebrochenen Rips). Abhilfe: Disc auswerfen und neu einlegen oder das Laufwerk kurz ab- und anstecken; hilft das nicht, den Rechner neu starten', + 5051: 'MakeMKV ist nicht freigeschaltet (Testzeitraum abgelaufen)', +} + +/** Meldungen, die sagen: DIESER Schlüssel taugt für DIESE Fassung nicht. */ +export const KEY_ABGELEHNT = [5020, 5021] + +// Text-Rückfall für englische Installationen — die NUMMERN sind die +// verlässliche Quelle (beim Commander meldet MakeMKV deutsch). +export const KRITISCHE_TEXTE = [ + 'volume key is unknown', + 'evaluation period has expired', + 'registration key', + 'Too old version', +] diff --git a/rippy-windows/src/kern/werkzeuge/katalog.ts b/rippy-windows/src/kern/werkzeuge/katalog.ts new file mode 100644 index 0000000..0ea101a --- /dev/null +++ b/rippy-windows/src/kern/werkzeuge/katalog.ts @@ -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 = { + 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) +} diff --git a/rippy-windows/test/katalog.test.ts b/rippy-windows/test/katalog.test.ts new file mode 100644 index 0000000..cbae7a9 --- /dev/null +++ b/rippy-windows/test/katalog.test.ts @@ -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) + }) +}) diff --git a/rippy-windows/test/messung.makemkv.test.ts b/rippy-windows/test/messung.makemkv.test.ts new file mode 100644 index 0000000..f8df83c --- /dev/null +++ b/rippy-windows/test/messung.makemkv.test.ts @@ -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) + }) +}) diff --git a/rippy-windows/test/messung.rip.test.ts b/rippy-windows/test/messung.rip.test.ts new file mode 100644 index 0000000..8aab36f --- /dev/null +++ b/rippy-windows/test/messung.rip.test.ts @@ -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= \ +// 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) + }) +}) diff --git a/rippy-windows/test/rip-lauf.test.ts b/rippy-windows/test/rip-lauf.test.ts new file mode 100644 index 0000000..cbc8529 --- /dev/null +++ b/rippy-windows/test/rip-lauf.test.ts @@ -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) + }) +}) diff --git a/rippy-windows/test/rip-parser.test.ts b/rippy-windows/test/rip-parser.test.ts new file mode 100644 index 0000000..09be9c5 --- /dev/null +++ b/rippy-windows/test/rip-parser.test.ts @@ -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) + } + }) +})