// MakeMKV beschaffen — die Quellenkette aus KONZEPT § 9, Vorlage // tools/beschaffen.py (alle Regeln dort am 28.08.2026 gemessen, hier am // 31.08.2026 nachgemessen): // // Hersteller-Seite HTTP 525 (Cloudflare erreicht den Ursprung nicht) // Hersteller-Forum HTTP 200, 0,2 s → 1.18.4 // Internet Archive HTTP 200, 7,8 s → höchste dort: 1.18.2 // // Daraus folgen die drei Regeln dieser Datei: // 1. QUELLENKETTE: Der Hersteller zuerst, immer — er ist maßgeblich. // Schweigt er (seit Tagen 525), zählen Forum und Archiv, und die // HÖCHSTE gefundene Nummer gewinnt: Fällt das wacklige Forum aus, // würde sonst der ältere Archiv-Schnappschuss (1.18.2) als „neueste // Fassung" verkauft. // 2. GEPRÜFT WIRD, WAS ANKOMMT: MakeMKV signiert seinen Installer nicht // (Get-AuthenticodeSignature → NotSigned, an der echten Datei // gemessen). Deshalb Versions-Ressource: CompanyName „GuinpinSoft // inc", ProductName „MakeMKV", FileVersion „v1.18.4" (am 31.08.2026 // an makemkvcon64.exe des Commander-PCs gemessen — mit v-Präfix!). // Das ersetzt keine Signatur, fängt aber ab, was wirklich droht: // Fehlerseite, halber Download, falsche Version. // 3. NIE eine gute Datei durch eine halbe ersetzen: Geladen wird in // `.neu`, geprüft, erst dann getauscht. import { spawnSync } from 'node:child_process' import { createHash } from 'node:crypto' import { createWriteStream, openSync, readFileSync, readSync, closeSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs' import { win32 as pfad } from 'node:path' import { werkzeugOrdner } from './katalog' export const MAKEMKV_SEITE = 'https://www.makemkv.com/download/' export const MAKEMKV_FORUM_NEWS = 'https://forum.makemkv.com/forum/viewforum.php?f=3' // Ein Zeitstempel in der VERGANGENHEIT ist Pflicht: Ohne ihn holt das // Archiv live nach — und die Quelle ist ja gerade tot (beschaffen.py). const ARCHIV_STAND = '2025' const MAKEMKV_DATEI = 'https://www.makemkv.com/download/Setup_MakeMKV_v%s.exe' const VERSION_RE = /MakeMKV\s+v?(\d+\.\d+\.\d+)/g // Eine Seite zu lesen dauert Millisekunden; 8 s sind reichlich und // begrenzen den schlimmsten Fall (3 Quellen × 2 Versuche) auf unter eine // Minute. Das Archiv brauchte bei der Messung 7,8 s — knapp, aber drin. const VERSIONS_TIMEOUT_MS = 8_000 const DOWNLOAD_TIMEOUT_MS = 600_000 const VERSUCHE = 2 const WARTEN_ZWISCHEN_VERSUCHEN_MS = 2_000 // Der Installer hat 16,4 MB. Alles unter einem MB ist keiner — sondern // eine Fehlerseite, die mit HTTP 200 ausgeliefert wurde. const MINDESTGROESSE_BYTES = 1024 * 1024 const MAKEMKV_FIRMA = 'GuinpinSoft' const MAKEMKV_PRODUKT = 'MakeMKV' export class BeschaffungsFehler extends Error {} /** Die höchste Fassung, die in einem Text vorkommt (pur). NUMERISCH * verglichen — als Zeichenkette wäre „1.9.2" größer als „1.18.4". */ export function hoechsteVersion(text: string): string { const funde = [...(text ?? '').matchAll(VERSION_RE)].map((t) => t[1]) if (funde.length === 0) return '' return funde.reduce((a, b) => (istNeuer(b, a) ? b : a)) } /** Ist `neu` wirklich neuer als `alt`? (pur, numerisch je Stelle) */ export function istNeuer(neu: string, alt: string): boolean { const teile = (v: string): number[] => (v ?? '') .split('.') .filter((t) => /^\d+$/.test(t)) .map(Number) const a = teile(neu) const b = teile(alt) if (a.length === 0 || b.length === 0) return a.length > 0 && b.length === 0 for (let i = 0; i < Math.max(a.length, b.length); i++) { const x = a[i] ?? 0 const y = b[i] ?? 0 if (x !== y) return x > y } return false } export interface VersionsQuelle { name: string url: string /** Diese Quelle IST die Wahrheit — wer sie erreicht, sucht nicht weiter. * Gilt nur für den Hersteller selbst. */ massgeblich: boolean } export function versionsquellen(): VersionsQuelle[] { return [ { name: 'Hersteller-Seite', url: MAKEMKV_SEITE, massgeblich: true }, { name: 'Hersteller-Forum', url: MAKEMKV_FORUM_NEWS, massgeblich: false }, { name: 'Archiv der Hersteller-Seite', url: `https://web.archive.org/web/${ARCHIV_STAND}/${MAKEMKV_SEITE}`, massgeblich: false, }, ] } async function seiteLaden(url: string, holen: typeof fetch): Promise { let letzter: unknown = null for (let versuch = 0; versuch < VERSUCHE; versuch++) { try { const antwort = await holen(url, { signal: AbortSignal.timeout(VERSIONS_TIMEOUT_MS), headers: { 'User-Agent': 'Rippy/5.0 (https://github.com/Hitonabi/rippy)', Accept: '*/*' }, }) if (antwort.status >= 300) throw new Error(`HTTP ${antwort.status}`) return await antwort.text() } catch (fehler) { letzter = fehler if (versuch + 1 < VERSUCHE) { await new Promise((weiter) => setTimeout(weiter, WARTEN_ZWISCHEN_VERSUCHEN_MS)) } } } throw letzter } /** Die neueste angekündigte Fassung — '' wenn KEINE Quelle antwortet. */ export async function makemkvNeuesteVersion( holen: typeof fetch = fetch, quellen: VersionsQuelle[] = versionsquellen(), ): Promise { const gefunden: string[] = [] for (const quelle of quellen) { let text: string try { text = await seiteLaden(quelle.url, holen) } catch { continue } const version = hoechsteVersion(text) if (version.length === 0) continue if (quelle.massgeblich) return version gefunden.push(version) } if (gefunden.length === 0) return '' return gefunden.reduce((a, b) => (istNeuer(b, a) ? b : a)) } /** Alle Bezugsquellen für den Installer, in Reihenfolge: eigene Basis * (wer sie einträgt, hat sich etwas gedacht) → Hersteller → Archiv. */ export function makemkvQuellen(version: string, basis = ''): Array<[string, string]> { const herstellerUrl = MAKEMKV_DATEI.replace('%s', version) const quellen: Array<[string, string]> = [] if (basis.trim().length > 0) { quellen.push(['eigene Quelle', `${basis.trim().replace(/\/+$/, '')}/Setup_MakeMKV_v${version}.exe`]) } quellen.push(['Hersteller', herstellerUrl]) // `id_` liefert die ROHE Datei; ohne das Kürzel kommt HTML (gemessen). quellen.push(['Internet Archive', `https://web.archive.org/web/${ARCHIV_STAND}id_/${herstellerUrl}`]) return quellen } export interface Versionsangaben { firma: string produkt: string version: string } /** Die Versions-Ressource einer EXE — über PowerShells FileVersionInfo, * damit kein zweiter koffi-Ort entsteht (R1). Nur unter Windows lesbar. */ export function versionsangaben(dateiPfad: string): Versionsangaben { const leer = { firma: '', produkt: '', version: '' } if (process.platform !== 'win32') return leer // ' im Pfad wird PowerShell-üblich verdoppelt — mehr Quoting braucht ein // wörtliches '…'-Literal nicht. const skript = `$v = [System.Diagnostics.FileVersionInfo]::GetVersionInfo('${dateiPfad.replace(/'/g, "''")}'); ` + '@{firma=$v.CompanyName; produkt=$v.ProductName; version=$v.FileVersion} | ConvertTo-Json -Compress' const lauf = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', skript], { windowsHide: true, timeout: 20_000, encoding: 'utf8', }) if (lauf.status !== 0) return leer try { const roh: unknown = JSON.parse(lauf.stdout ?? '') if (typeof roh !== 'object' || roh === null) return leer const angaben = roh as Record return { firma: typeof angaben['firma'] === 'string' ? angaben['firma'] : '', produkt: typeof angaben['produkt'] === 'string' ? angaben['produkt'] : '', version: typeof angaben['version'] === 'string' ? angaben['version'] : '', } } catch { return leer } } /** Ist das WIRKLICH der MakeMKV-Installer? (pur bis auf den Datei-Kopf) */ export function dateiPruefen( dateiPfad: string, version: string, angaben: (p: string) => Versionsangaben = versionsangaben, plattform: NodeJS.Platform = process.platform, ): { ok: boolean; grund: string } { // Windows-Programme beginnen mit „MZ" — eine mit HTTP 200 ausgelieferte // Fehlerseite tut das nicht. const kopf = Buffer.alloc(2) try { const datei = openSync(dateiPfad, 'r') try { readSync(datei, kopf, 0, 2, 0) } finally { closeSync(datei) } } catch (fehler) { return { ok: false, grund: `Datei nicht lesbar: ${String(fehler)}` } } if (kopf.toString('latin1') !== 'MZ') { return { ok: false, grund: 'Die geladene Datei ist kein Windows-Programm (kein „MZ"-Kopf) — vermutlich eine Fehlerseite.' } } if (plattform !== 'win32') { // Ohne Windows keine Versions-Ressourcen — der MZ-Test hat gegriffen, // mehr ist dort nicht zu holen (CI läuft auf Linux). return { ok: true, grund: 'Windows-Programm (Versionsangaben nur unter Windows lesbar)' } } const info = angaben(dateiPfad) if (!info.firma.includes(MAKEMKV_FIRMA)) { return { ok: false, grund: `Die Datei stammt laut Versions-Ressource nicht von ${MAKEMKV_FIRMA} (CompanyName: „${info.firma}").` } } if (!info.produkt.includes(MAKEMKV_PRODUKT)) { return { ok: false, grund: `Die Datei ist laut Versions-Ressource kein ${MAKEMKV_PRODUKT} (ProductName: „${info.produkt}").` } } if (version.length > 0 && !info.version.includes(version)) { return { ok: false, grund: `Falsche Fassung: erwartet ${version}, die Datei sagt „${info.version}".` } } return { ok: true, grund: `Versions-Ressource geprüft: ${info.firma}, ${info.produkt} ${info.version}` } } async function dateiLaden( url: string, ziel: string, melden: (text: string, anteil: number | null) => void, holen: typeof fetch, ): Promise { const antwort = await holen(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS), headers: { 'User-Agent': 'Rippy/5.0 (https://github.com/Hitonabi/rippy)', Accept: '*/*' }, }) if (antwort.status >= 300) throw new BeschaffungsFehler(`HTTP ${antwort.status}`) if (antwort.body === null) throw new BeschaffungsFehler('Antwort ohne Inhalt') const erwartet = Number(antwort.headers.get('content-length') ?? '0') const schreiber = createWriteStream(ziel) const leser = antwort.body.getReader() let geladen = 0 let letzteStufe = -1 try { for (;;) { const { done, value } = await leser.read() if (done) break geladen += value.byteLength await new Promise((weiter, kaputt) => { schreiber.write(Buffer.from(value), (fehler) => (fehler ? kaputt(fehler) : weiter())) }) if (erwartet > 0) { const anteil = Math.min(1, geladen / erwartet) // Nur bei jedem zehnten Prozent melden — 16 MB in kleinen Häppchen // wären sonst hundert Zeilen. const stufe = Math.floor(anteil * 10) if (stufe !== letzteStufe) { letzteStufe = stufe melden( `${Math.floor(anteil * 100)} % geladen (${(geladen / 1024 ** 2).toFixed(1)} von ${(erwartet / 1024 ** 2).toFixed(1)} MB)`, anteil * 0.85, ) } } } } finally { await new Promise((weiter) => schreiber.end(weiter)) } const groesse = statSync(ziel).size if (groesse < MINDESTGROESSE_BYTES) { throw new BeschaffungsFehler( `Die geladene Datei ist nur ${groesse} Bytes groß — das ist keine Programmdatei, sondern vermutlich eine Fehlerseite.`, ) } } export interface BeschaffungErgebnis { pfad: string quelle: string version: string /** 5.7.0 (F16): Größe und SHA-256 der geladenen Datei — damit man sie * gegen die Herstellerangabe halten kann, sobald die Seite wieder da ist. */ bytes: number sha256: string } /** SHA-256 einer Datei als Hex — für die Anzeige neben dem Installer. */ export function pruefsumme(dateiPfad: string): string { return createHash('sha256').update(readFileSync(dateiPfad)).digest('hex') } /** Welche Quelle ein Rückfrage braucht: alles außer Hersteller und eigener * Basis. Das Internet Archive liefert eine unsignierte EXE, die mit * Adminrechten läuft — das entscheidet der Nutzer, nicht Rippy (F16). */ export function brauchtFreigabe(quellenName: string): boolean { return quellenName === 'Internet Archive' } /** Lädt den MakeMKV-Installer über die Quellenkette in Rippys * Downloads-Ordner. **Rippy liefert MakeMKV nicht mit** — es lädt die * offizielle Datei; gestartet wird sie vom Haupt (UAC, § 9). */ export async function makemkvInstallerHolen( version: string, melden: (text: string, anteil: number | null) => void, basis = '', holen: typeof fetch = fetch, ordner: string = pfad.join(werkzeugOrdner(), 'downloads'), pruefen: typeof dateiPruefen = dateiPruefen, /** 5.7.0 (F16): Darf eine Quelle mit Rückfrage benutzt werden? Ohne * diese Funktion wird sie übersprungen — nie still geladen. */ freigabe?: (quellenName: string, url: string) => Promise, ): Promise { mkdirSync(ordner, { recursive: true }) const ziel = pfad.join(ordner, `Setup_MakeMKV_v${version}.exe`) const vorlaeufig = `${ziel}.neu` const fehlschlaege: string[] = [] for (const [name, url] of makemkvQuellen(version, basis)) { if (brauchtFreigabe(name)) { const erlaubt = freigabe !== undefined && (await freigabe(name, url)) if (!erlaubt) { fehlschlaege.push(`${name}: nicht freigegeben`) melden(`${name} wurde nicht freigegeben.`, null) continue } } melden(`MakeMKV ${version} wird geladen (${name}) …`, 0) try { await dateiLaden(url, vorlaeufig, melden, holen) const pruefung = pruefen(vorlaeufig, version) if (!pruefung.ok) throw new BeschaffungsFehler(pruefung.grund) // Erst tauschen, wenn geprüft — ein halber Download darf eine gute // Datei nicht ersetzen. renameSync(vorlaeufig, ziel) return { pfad: ziel, quelle: name, version, bytes: statSync(ziel).size, sha256: pruefsumme(ziel) } } catch (fehler) { fehlschlaege.push(`${name}: ${fehler instanceof Error ? fehler.message : String(fehler)}`) melden(`${name} hat nicht geliefert.`, null) try { rmSync(vorlaeufig, { force: true }) } catch { // Aufräumen ist Kür — der nächste Versuch überschreibt ohnehin. } } } throw new BeschaffungsFehler( `Keine Quelle hat den MakeMKV-Installer geliefert:\n ${fehlschlaege.join('\n ')}\n` + 'Ausweg: MakeMKV von Hand von makemkv.com laden und installieren; Rippy findet es danach von allein.', ) }