feat(v5): MakeMKV-Selbst-Update (§ 9) und Kino-Look fürs Fenster
Ampel / ampel (push) Successful in 1m34s
Ampel / ampel (push) Successful in 1m34s
Update-Strecke: Quellenkette (Hersteller 525 → Forum → Archiv, höchste gewinnt, 31.08. nachgemessen), MZ- + Versions-Ressourcen-Prüfung (GuinpinSoft inc, gemessen), Download in .neu, Start übers Haupt (shell/UAC — Installer hängt bewusst NICHT an der Leine), Warte-Poll bis die EXE-Version wechselt, dann Schlüssel neu prüfen. Ehrlicher Zweig: installierte == neueste Fassung → kein sinnloser Download. Fenster: Laufwerks-Kachel als Bühne (TMDb-Backdrop, Poster, Beschreibung, Phasen-Label, Restzeit, aufklappbares Protokoll), Bibliothek als Poster-Regal (DB-Schema 3: poster_pfad, Migration), Schlüssel-Karte mit Update-Knopf und Beschaffungs-Balken, MakeMKV-Version als Badge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
5622d8e47d
commit
d5ea48cd1e
@@ -329,6 +329,7 @@ export class Pipeline {
|
||||
ablageort: fertigOrdner,
|
||||
datum: startZeit.toISOString(),
|
||||
dauerS: Math.round(((this.umgebung.jetzt?.() ?? new Date()).getTime() - startZeit.getTime()) / 1000),
|
||||
posterPfad: zuordnung?.meta.posterPfad ?? '',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -450,6 +451,7 @@ export class Pipeline {
|
||||
ablageort: ziel,
|
||||
datum: startZeit.toISOString(),
|
||||
dauerS: Math.round(((this.umgebung.jetzt?.() ?? new Date()).getTime() - startZeit.getTime()) / 1000),
|
||||
posterPfad: '',
|
||||
})
|
||||
}
|
||||
fertigMachen({
|
||||
@@ -490,6 +492,7 @@ export class Pipeline {
|
||||
ablageort: zielDatei,
|
||||
datum: startZeit.toISOString(),
|
||||
dauerS: Math.round(((this.umgebung.jetzt?.() ?? new Date()).getTime() - startZeit.getTime()) / 1000),
|
||||
posterPfad: '',
|
||||
})
|
||||
}
|
||||
this.melden({ id, phase: 'fertig', prozent: 100, text: `Fertig — 1:1-Abbild unter ${zielDatei}.`, dateien: [zielDatei], lesefehler: false })
|
||||
|
||||
@@ -113,6 +113,8 @@ async function discErmitteln(id: string, geraetepfad: string, groesseBytes: numb
|
||||
confidence: zuordnung.confidence,
|
||||
quelle: zuordnung.meta.quelle,
|
||||
posterPfad: zuordnung.meta.posterPfad,
|
||||
backdropPfad: zuordnung.meta.backdropPfad,
|
||||
beschreibung: zuordnung.meta.beschreibung,
|
||||
// § 6.6: Rippy erkennt beim Einlegen, wenn eine Disc schon einmal
|
||||
// durchgelaufen ist — und fragt nach, statt stumm zu doppeln.
|
||||
schonGerippt: db?.bibliothekSchonGerippt(fingerprint) ?? null,
|
||||
@@ -182,6 +184,7 @@ async function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): Promise<vo
|
||||
jahr: jahrAus(vorschlag.typ === 'movie' ? details?.release_date : details?.first_air_date) ?? vorschlag.jahr,
|
||||
beschreibung: details?.overview ?? '',
|
||||
posterPfad: details?.poster_path ?? vorschlag.posterPfad,
|
||||
backdropPfad: details?.backdrop_path ?? '',
|
||||
laufzeitMinuten: details?.runtime ?? 0,
|
||||
genres: (details?.genres ?? []).map((g) => g.name),
|
||||
quelle: 'tmdb',
|
||||
@@ -197,6 +200,8 @@ async function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): Promise<vo
|
||||
confidence: 1,
|
||||
quelle: 'deine Wahl',
|
||||
posterPfad: zuordnung.meta.posterPfad,
|
||||
backdropPfad: zuordnung.meta.backdropPfad,
|
||||
beschreibung: zuordnung.meta.beschreibung,
|
||||
schonGerippt: db?.bibliothekSchonGerippt(discFingerprints.get(id) ?? '') ?? null,
|
||||
},
|
||||
})
|
||||
@@ -231,6 +236,8 @@ const laufwerkeBereit: Promise<void> = (async () => {
|
||||
confidence: 0,
|
||||
quelle: '',
|
||||
posterPfad: '',
|
||||
backdropPfad: '',
|
||||
beschreibung: '',
|
||||
schonGerippt: null,
|
||||
},
|
||||
})
|
||||
@@ -385,6 +392,113 @@ async function schluesselLauf(): Promise<void> {
|
||||
// Täglich neu prüfen (§ 9.1) — ohne den Prozess-Exit aufzuhalten.
|
||||
setInterval(() => void schluesselLauf(), 24 * 60 * 60 * 1000).unref()
|
||||
|
||||
// ── MakeMKV-Update (§ 9): holen, prüfen, installieren lassen ────────────
|
||||
// Der Kern lädt und PRÜFT die Datei; STARTEN kann sie nur das Haupt
|
||||
// (shell → UAC-Abfrage; der erhöhte Installer hängt dadurch nicht an der
|
||||
// Prozess-Leine und überlebt ein geschlossenes Rippy).
|
||||
let beschaffungLaeuft = false
|
||||
let installerAntwort: ((fehler: string | null) => void) | null = null
|
||||
|
||||
function beschaffungMelden(text: string, anteil: number | null, laeuft: boolean, fehler = false): void {
|
||||
anFenster({ art: 'beschaffung-status', stand: { werkzeug: 'makemkv', laeuft, text, anteil, fehler } })
|
||||
}
|
||||
|
||||
async function makemkvUpdateLauf(): Promise<void> {
|
||||
if (beschaffungLaeuft) {
|
||||
beschaffungMelden('Ein MakeMKV-Update läuft bereits.', null, true)
|
||||
return
|
||||
}
|
||||
beschaffungLaeuft = true
|
||||
try {
|
||||
const { istNeuer, makemkvNeuesteVersion, makemkvInstallerHolen, versionsangaben } = await import(
|
||||
'./werkzeuge/beschaffen'
|
||||
)
|
||||
beschaffungMelden('Nach der neuesten MakeMKV-Fassung wird gesucht …', null, true)
|
||||
const neueste = await makemkvNeuesteVersion()
|
||||
if (neueste.length === 0) {
|
||||
beschaffungMelden(
|
||||
'Keine der Quellen konnte sagen, welche Fassung aktuell ist (Hersteller-Seite, Forum, Archiv). ' +
|
||||
'Ausweg: MakeMKV von Hand von makemkv.com laden und installieren; Rippy findet es danach von allein.',
|
||||
null,
|
||||
false,
|
||||
true,
|
||||
)
|
||||
beschaffungLaeuft = false
|
||||
return
|
||||
}
|
||||
const installiert = schluesselStand?.version ?? ''
|
||||
if (installiert.length > 0 && !istNeuer(neueste, installiert)) {
|
||||
// Ehrlich bleiben (31.08.2026 gemessen: 1.18.4 ist installiert UND
|
||||
// die neueste angekündigte — die Beta ist nur abgelaufen): Ein
|
||||
// Download derselben Fassung würde nichts lösen.
|
||||
beschaffungMelden(
|
||||
`MakeMKV ${installiert} ist bereits die neueste angekündigte Fassung. ` +
|
||||
'Sobald der Hersteller eine neuere veröffentlicht, holt dieser Knopf sie — ' +
|
||||
'bis dahin rippt der Beta-Modus weiter.',
|
||||
null,
|
||||
false,
|
||||
)
|
||||
beschaffungLaeuft = false
|
||||
return
|
||||
}
|
||||
// Merken, woran eine gelungene Installation zu erkennen ist: Die
|
||||
// Versions-Ressource der gefundenen EXE ändert sich (oder es gibt
|
||||
// erstmals eine).
|
||||
const alteExe = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '')
|
||||
const alteVersion = alteExe.length > 0 ? versionsangaben(alteExe).version : ''
|
||||
|
||||
const ergebnis = await makemkvInstallerHolen(neueste, (text, anteil) =>
|
||||
beschaffungMelden(text, anteil, true),
|
||||
)
|
||||
beschaffungMelden(`Geprüft (Quelle: ${ergebnis.quelle}) — der Installer wird gestartet …`, null, true)
|
||||
|
||||
const startFehler = await new Promise<string | null>((fertig) => {
|
||||
installerAntwort = fertig
|
||||
process.parentPort.postMessage({ art: 'installer-starten', pfad: ergebnis.pfad })
|
||||
// Falls das Haupt nie antwortet, bleibt der Lauf nicht ewig hängen.
|
||||
setTimeout(() => fertig('Keine Antwort vom Haupt-Prozess'), 30_000).unref()
|
||||
})
|
||||
installerAntwort = null
|
||||
if (startFehler !== null && startFehler.length > 0) {
|
||||
beschaffungMelden(`Der Installer wurde nicht gestartet: ${startFehler}`, null, false, true)
|
||||
beschaffungLaeuft = false
|
||||
return
|
||||
}
|
||||
beschaffungMelden(
|
||||
`Der MakeMKV-Installer ${ergebnis.version} läuft — bitte die Windows-Nachfrage bestätigen und durchklicken. ` +
|
||||
'Rippy merkt die fertige Installation von allein und prüft den Schlüssel neu.',
|
||||
null,
|
||||
true,
|
||||
)
|
||||
// Warten, bis die Installation durch ist: Die Versions-Ressource der
|
||||
// EXE ändert sich. Alle 15 s nachsehen, höchstens 20 Minuten.
|
||||
let runden = 0
|
||||
const zeiger = setInterval(() => {
|
||||
runden += 1
|
||||
const exe = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '')
|
||||
const jetztVersion = exe.length > 0 ? versionsangaben(exe).version : ''
|
||||
if (jetztVersion.length > 0 && jetztVersion !== alteVersion) {
|
||||
clearInterval(zeiger)
|
||||
beschaffungLaeuft = false
|
||||
beschaffungMelden(`MakeMKV ${jetztVersion} ist installiert — der Schlüssel wird neu geprüft.`, null, false)
|
||||
void schluesselLauf().then(() => werkzeugeSenden())
|
||||
} else if (runden >= 80) {
|
||||
clearInterval(zeiger)
|
||||
beschaffungLaeuft = false
|
||||
beschaffungMelden(
|
||||
'Die fertige Installation wurde nicht bemerkt — nach dem Durchklicken hilft „Schlüssel neu prüfen".',
|
||||
null,
|
||||
false,
|
||||
)
|
||||
}
|
||||
}, 15_000)
|
||||
zeiger.unref()
|
||||
} catch (fehler) {
|
||||
beschaffungMelden(fehler instanceof Error ? fehler.message : String(fehler), null, false, true)
|
||||
beschaffungLaeuft = false
|
||||
}
|
||||
}
|
||||
|
||||
// Die gemessene Auskunft bleibt liegen — die Pipeline braucht sie für die
|
||||
// Preset-Empfehlung, auch wenn gerade kein Fenster offen ist.
|
||||
let werkzeugAuskunft: { presets: string[]; backends: string[] } = { presets: [], backends: [] }
|
||||
@@ -432,6 +546,11 @@ async function auswerfen(id: string): Promise<void> {
|
||||
|
||||
process.parentPort.on('message', (ereignis) => {
|
||||
const nachricht: unknown = ereignis.data
|
||||
if (istHauptNachricht(nachricht) && nachricht.art === 'installer-ergebnis') {
|
||||
installerAntwort?.(nachricht.fehler ?? '')
|
||||
installerAntwort = null
|
||||
return
|
||||
}
|
||||
if (istHauptNachricht(nachricht) && nachricht.art === 'fenster-port') {
|
||||
// Das Haupt reicht einen frischen Port zum Fenster durch. Ein neues
|
||||
// Fenster bringt einen neuen Port — der alte wird geschlossen.
|
||||
@@ -468,6 +587,10 @@ process.parentPort.on('message', (ereignis) => {
|
||||
void werkzeugeSenden()
|
||||
} else if (frage.art === 'bibliothek-laden') {
|
||||
anFenster({ art: 'bibliothek', eintraege: db?.bibliothekListe() ?? [] })
|
||||
} else if (frage.art === 'makemkv-update') {
|
||||
void makemkvUpdateLauf()
|
||||
} else if (frage.art === 'schluessel-pruefen') {
|
||||
void schluesselLauf().then(() => werkzeugeSenden())
|
||||
}
|
||||
})
|
||||
fensterPort.start()
|
||||
|
||||
@@ -25,6 +25,8 @@ export interface MetaTreffer {
|
||||
jahr: number | null
|
||||
beschreibung: string
|
||||
posterPfad: string
|
||||
/** TMDb-Hintergrundbild (Querformat) — fürs Kachel-Panorama; '' wenn keins. */
|
||||
backdropPfad: string
|
||||
laufzeitMinuten: number
|
||||
genres: string[]
|
||||
quelle: 'tmdb' | 'omdb' | ''
|
||||
@@ -51,6 +53,7 @@ function ausDetails(typ: 'movie' | 'tv', id: number, details: FilmDetails): Meta
|
||||
jahr: jahrAus(typ === 'movie' ? details.release_date : details.first_air_date),
|
||||
beschreibung: details.overview ?? '',
|
||||
posterPfad: details.poster_path ?? '',
|
||||
backdropPfad: details.backdrop_path ?? '',
|
||||
laufzeitMinuten: details.runtime ?? 0,
|
||||
genres: (details.genres ?? []).map((g) => g.name),
|
||||
quelle: 'tmdb',
|
||||
@@ -65,6 +68,8 @@ function ausOmdb(treffer: OmdbTreffer): MetaTreffer {
|
||||
jahr: treffer.year,
|
||||
beschreibung: treffer.overview,
|
||||
posterPfad: treffer.poster_path,
|
||||
// OMDb kennt keine Querformat-Bilder — die Kachel fällt aufs Poster zurück.
|
||||
backdropPfad: '',
|
||||
laufzeitMinuten: treffer.runtime,
|
||||
genres: treffer.genres,
|
||||
quelle: 'omdb',
|
||||
@@ -82,6 +87,7 @@ const UNBEKANNT = (titel: string): Zuordnung => ({
|
||||
jahr: null,
|
||||
beschreibung: '',
|
||||
posterPfad: '',
|
||||
backdropPfad: '',
|
||||
laufzeitMinuten: 0,
|
||||
genres: [],
|
||||
quelle: '',
|
||||
|
||||
@@ -12,7 +12,7 @@ import { mkdirSync } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
import type { BibliothekEintrag } from '../../gemeinsam/nachrichten'
|
||||
|
||||
export const SCHEMA_VERSION = 2
|
||||
export const SCHEMA_VERSION = 3
|
||||
|
||||
export class Datenbank {
|
||||
readonly pfad: string
|
||||
@@ -46,9 +46,16 @@ export class Datenbank {
|
||||
groesse_bytes INTEGER NOT NULL,
|
||||
ablageort TEXT NOT NULL,
|
||||
datum TEXT NOT NULL,
|
||||
dauer_s INTEGER NOT NULL
|
||||
dauer_s INTEGER NOT NULL,
|
||||
poster_pfad TEXT NOT NULL DEFAULT ''
|
||||
);`,
|
||||
)
|
||||
// Schema 2 → 3: Bestands-Datenbanken haben die Bibliothek noch ohne
|
||||
// Poster-Spalte — nachrüsten, ohne Einträge anzufassen.
|
||||
const spalten = this.db.prepare(`PRAGMA table_info(bibliothek)`).all() as Array<{ name: string }>
|
||||
if (!spalten.some((s) => s.name === 'poster_pfad')) {
|
||||
this.db.exec(`ALTER TABLE bibliothek ADD COLUMN poster_pfad TEXT NOT NULL DEFAULT ''`)
|
||||
}
|
||||
this.db
|
||||
.prepare(`INSERT OR REPLACE INTO meta (schluessel, wert) VALUES ('schema_version', ?)`)
|
||||
.run(String(SCHEMA_VERSION))
|
||||
@@ -81,8 +88,8 @@ export class Datenbank {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT OR REPLACE INTO bibliothek
|
||||
(fingerprint, titel, jahr, typ, groesse_bytes, ablageort, datum, dauer_s)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
(fingerprint, titel, jahr, typ, groesse_bytes, ablageort, datum, dauer_s, poster_pfad)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
eintrag.fingerprint,
|
||||
@@ -93,6 +100,7 @@ export class Datenbank {
|
||||
eintrag.ablageort,
|
||||
eintrag.datum,
|
||||
eintrag.dauerS,
|
||||
eintrag.posterPfad,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -109,6 +117,7 @@ export class Datenbank {
|
||||
ablageort: String(z.ablageort),
|
||||
datum: String(z.datum),
|
||||
dauerS: Number(z.dauer_s),
|
||||
posterPfad: String(z.poster_pfad ?? ''),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
// 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 { createWriteStream, openSync, 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<string> {
|
||||
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<string> {
|
||||
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<string, unknown>
|
||||
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<void> {
|
||||
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<void>((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<void>((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
|
||||
}
|
||||
|
||||
/** 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,
|
||||
): Promise<BeschaffungErgebnis> {
|
||||
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)) {
|
||||
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 }
|
||||
} 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.',
|
||||
)
|
||||
}
|
||||
@@ -161,6 +161,8 @@ export interface SchluesselStand {
|
||||
gueltigBis: string
|
||||
geholtAm: string
|
||||
text: string
|
||||
/** Die installierte MakeMKV-Fassung (aus MSG 1005) — fürs Update-Urteil. */
|
||||
version: string
|
||||
}
|
||||
|
||||
/** Die ganze Kette: Dauerlizenz → Forum-Key → ablegen → PRÜFEN →
|
||||
@@ -185,6 +187,7 @@ export async function schluesselKette(
|
||||
text: urteil.ok
|
||||
? `Eigener Schlüssel hinterlegt und von MakeMKV${urteil.version.length > 0 ? ` ${urteil.version}` : ''} angenommen.`
|
||||
: `Eigener Schlüssel abgelehnt: ${urteil.grund}`,
|
||||
version: urteil.version,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +205,7 @@ export async function schluesselKette(
|
||||
text: urteil.ok
|
||||
? 'Forum nicht erreichbar — der hinterlegte Schlüssel funktioniert weiter.'
|
||||
: `Forum nicht erreichbar und der hinterlegte Schlüssel taugt nicht: ${urteil.grund}`,
|
||||
version: urteil.version,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,5 +233,6 @@ export async function schluesselKette(
|
||||
'Rippy hat ihn wieder entfernt, damit MakeMKV im Beta-Modus weiterläuft (so klappen Rips trotzdem). ' +
|
||||
'Abhilfe: MakeMKV von makemkv.com aktualisieren — danach hinterlegt Rippy den Key automatisch neu.'
|
||||
: `Beta-Key abgelehnt (${urteil.grund}) — wieder entfernt, damit MakeMKV im Beta-Modus weiterläuft.`,
|
||||
version: urteil.version,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user