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
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
@@ -5,6 +5,7 @@ out/
|
|||||||
# Setup-Bau (electron-builder) und die MITGELIEFERTEN Binaerdateien —
|
# Setup-Bau (electron-builder) und die MITGELIEFERTEN Binaerdateien —
|
||||||
# vendor/ wird beim Paketieren aus LOCALAPPDATA\Rippy\tools befuellt
|
# vendor/ wird beim Paketieren aus LOCALAPPDATA\Rippy\tools befuellt
|
||||||
# (BAUEN.md), 74 MB gehoeren nicht ins Git.
|
# (BAUEN.md), 74 MB gehoeren nicht ins Git.
|
||||||
dist-setup/
|
# Auch die Ausweich-Ordner der win-unpacked-Sperre (BAUEN.md)
|
||||||
|
dist-setup*/
|
||||||
vendor/
|
vendor/
|
||||||
.electron-cache/
|
.electron-cache/
|
||||||
|
|||||||
@@ -59,6 +59,18 @@ Adminrechte) plus `latest.yml` und `.blockmap` — **alle drei Dateien
|
|||||||
gehören zum Release** (§ 6.8: `latest.yml` ist die Update-Auskunft, die
|
gehören zum Release** (§ 6.8: `latest.yml` ist die Update-Auskunft, die
|
||||||
`.blockmap` macht aus dem Voll-Download einen Differenz-Download).
|
`.blockmap` macht aus dem Voll-Download einen Differenz-Download).
|
||||||
|
|
||||||
|
⚠ **Die `win-unpacked`-Sperre (30./31.08.2026 gemessen):** Nachdem der
|
||||||
|
Paket-Smoke (`win-unpacked\Rippy.exe --smoke`) gelaufen ist, hält ein
|
||||||
|
Filter-Treiber (Virenscanner/Indexer — KEIN Prozess mit sichtbarem Pfad)
|
||||||
|
`resources\app.asar` mitunter STUNDENLANG fest. Der nächste Bau in
|
||||||
|
denselben Ordner scheitert dann mit `EBUSY … unlink app.asar`, und zwar
|
||||||
|
mit **Exit-Code 0** (electron-builder!) — nicht vom Log-Ende täuschen
|
||||||
|
lassen, die ⨯-Zeile zählt. Ausweg: in ein frisches Verzeichnis bauen
|
||||||
|
(`npx electron-builder --win --config electron-builder.yml
|
||||||
|
-c.directories.output=dist-setup-neu`), die fertige Setup-EXE nach
|
||||||
|
`dist-setup/` KOPIEREN (eine Kopie ist eine neue Datei ohne Sperre) und
|
||||||
|
den gesperrten Ordner löschen, sobald er wieder frei ist.
|
||||||
|
|
||||||
Vorher einmalig `vendor/` befüllen (die MITGELIEFERTEN Werkzeuge, § 9 —
|
Vorher einmalig `vendor/` befüllen (die MITGELIEFERTEN Werkzeuge, § 9 —
|
||||||
liegen nicht im Git, 74 MB):
|
liegen nicht im Git, 74 MB):
|
||||||
|
|
||||||
|
|||||||
+350
-140
@@ -1,6 +1,8 @@
|
|||||||
// Die Oberfläche (W-5): Übersicht (Laufwerke + Gerüst-Status),
|
// Die Oberfläche — seit dieser Runde im Kino-Look: Die Laufwerks-Kachel
|
||||||
// Einstellungen (EINE Quelle — alles läuft über den Kern, § 6.7) und die
|
// wird zur Bühne (TMDb-Backdrop als Panorama, Poster, Beschreibung), die
|
||||||
// Bibliothek (§ 6.6). Design-Sprache wie das heutige Rippy: dunkel, Kacheln.
|
// Bibliothek zum Poster-Regal, der MakeMKV-Schlüssel bekommt seinen
|
||||||
|
// Update-Knopf (§ 9). Die Regeln bleiben: Übersicht/Einstellungen/
|
||||||
|
// Bibliothek, EINE Einstellungs-Quelle über den Kern (§ 6.7), R2/R4.
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import type { DiscInfoStand, GeraeteInfo, HauptStatus, RipStatus } from '../gemeinsam/nachrichten'
|
import type { DiscInfoStand, GeraeteInfo, HauptStatus, RipStatus } from '../gemeinsam/nachrichten'
|
||||||
import { presetEmpfehlung } from '../gemeinsam/preset-empfehlung'
|
import { presetEmpfehlung } from '../gemeinsam/preset-empfehlung'
|
||||||
@@ -8,9 +10,11 @@ import {
|
|||||||
aufVerbindung,
|
aufVerbindung,
|
||||||
auswerfen,
|
auswerfen,
|
||||||
einstellungSetzen,
|
einstellungSetzen,
|
||||||
|
makemkvUpdate,
|
||||||
metadatenSuchen,
|
metadatenSuchen,
|
||||||
ripAbbrechen,
|
ripAbbrechen,
|
||||||
ripStarten,
|
ripStarten,
|
||||||
|
schluesselPruefen,
|
||||||
verbindungStarten,
|
verbindungStarten,
|
||||||
zuordnungSetzen,
|
zuordnungSetzen,
|
||||||
type Verbindung,
|
type Verbindung,
|
||||||
@@ -23,12 +27,24 @@ function posterUrlKlein(pfad: string): string {
|
|||||||
return `https://image.tmdb.org/t/p/w154${pfad}`
|
return `https://image.tmdb.org/t/p/w154${pfad}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function posterUrlGross(pfad: string): string {
|
||||||
|
if (pfad.length === 0) return ''
|
||||||
|
if (pfad.startsWith('http')) return pfad
|
||||||
|
return `https://image.tmdb.org/t/p/w342${pfad}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function backdropUrl(pfad: string): string {
|
||||||
|
if (pfad.length === 0) return ''
|
||||||
|
if (pfad.startsWith('http')) return pfad
|
||||||
|
return `https://image.tmdb.org/t/p/w780${pfad}`
|
||||||
|
}
|
||||||
|
|
||||||
function restzeitText(prozent: number, startMs: number | undefined): string {
|
function restzeitText(prozent: number, startMs: number | undefined): string {
|
||||||
if (startMs === undefined || prozent <= 2 || prozent >= 100) return ''
|
if (startMs === undefined || prozent <= 2 || prozent >= 100) return ''
|
||||||
const verstrichen = (Date.now() - startMs) / 1000
|
const verstrichen = (Date.now() - startMs) / 1000
|
||||||
const restS = Math.round((verstrichen * (100 - prozent)) / prozent)
|
const restS = Math.round((verstrichen * (100 - prozent)) / prozent)
|
||||||
if (restS < 90) return `· ~${restS} s`
|
if (restS < 90) return `noch ~${restS} s`
|
||||||
return `· ~${Math.round(restS / 60)} min`
|
return `noch ~${Math.round(restS / 60)} min`
|
||||||
}
|
}
|
||||||
|
|
||||||
function istHauptStatus(wert: unknown): wert is HauptStatus {
|
function istHauptStatus(wert: unknown): wert is HauptStatus {
|
||||||
@@ -51,6 +67,24 @@ const TYP_NAMEN: Record<GeraeteInfo['typ'], string> = {
|
|||||||
unknown: 'unbekannt',
|
unknown: 'unbekannt',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TYP_SYMBOL: Record<string, string> = {
|
||||||
|
cd: '💿',
|
||||||
|
dvd: '📀',
|
||||||
|
bluray: '📀',
|
||||||
|
uhd: '📀',
|
||||||
|
iso: '💽',
|
||||||
|
}
|
||||||
|
|
||||||
|
const PHASEN_NAMEN: Record<RipStatus['phase'], string> = {
|
||||||
|
'liest-info': 'liest Titel-Info',
|
||||||
|
rippt: 'rippt (verlustfrei)',
|
||||||
|
komprimiert: 'komprimiert',
|
||||||
|
'legt-ab': 'legt ab',
|
||||||
|
fertig: 'fertig',
|
||||||
|
fehler: 'Fehler',
|
||||||
|
abgebrochen: 'abgebrochen',
|
||||||
|
}
|
||||||
|
|
||||||
const LAUFENDE_PHASEN = ['liest-info', 'rippt', 'komprimiert', 'legt-ab']
|
const LAUFENDE_PHASEN = ['liest-info', 'rippt', 'komprimiert', 'legt-ab']
|
||||||
|
|
||||||
function sicherheitsWort(confidence: number): string {
|
function sicherheitsWort(confidence: number): string {
|
||||||
@@ -60,6 +94,20 @@ function sicherheitsWort(confidence: number): string {
|
|||||||
return 'unsicher'
|
return 'unsicher'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Badge({ text, ton }: { text: string; ton: 'gruen' | 'gelb' | 'blau' | 'grau' }) {
|
||||||
|
const farben = {
|
||||||
|
gruen: 'border-emerald-500/40 bg-emerald-500/10 text-emerald-300',
|
||||||
|
gelb: 'border-amber-500/40 bg-amber-500/10 text-amber-300',
|
||||||
|
blau: 'border-sky-500/40 bg-sky-500/10 text-sky-300',
|
||||||
|
grau: 'border-slate-600/60 bg-slate-800/60 text-slate-300',
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className={`inline-block rounded-full border px-2 py-0.5 text-[11px] leading-4 ${farben[ton]}`}>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
interface KachelProps {
|
interface KachelProps {
|
||||||
titel: string
|
titel: string
|
||||||
zustand: 'ok' | 'fehler' | 'wartet'
|
zustand: 'ok' | 'fehler' | 'wartet'
|
||||||
@@ -70,7 +118,7 @@ function Kachel({ titel, zustand, zeilen }: KachelProps) {
|
|||||||
const punktFarbe =
|
const punktFarbe =
|
||||||
zustand === 'ok' ? 'bg-emerald-400' : zustand === 'fehler' ? 'bg-red-400' : 'bg-amber-400'
|
zustand === 'ok' ? 'bg-emerald-400' : zustand === 'fehler' ? 'bg-red-400' : 'bg-amber-400'
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-700/60 bg-slate-800/40 p-4">
|
<div className="rounded-xl border border-slate-800 bg-slate-900/70 p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={`inline-block h-2.5 w-2.5 rounded-full ${punktFarbe}`} />
|
<span className={`inline-block h-2.5 w-2.5 rounded-full ${punktFarbe}`} />
|
||||||
<h2 className="text-sm font-semibold tracking-wide text-slate-200">{titel}</h2>
|
<h2 className="text-sm font-semibold tracking-wide text-slate-200">{titel}</h2>
|
||||||
@@ -105,9 +153,12 @@ function AendernDialog({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [])
|
}, [])
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6" onClick={schliessen}>
|
|
||||||
<div
|
<div
|
||||||
className="max-h-[80vh] w-full max-w-lg overflow-y-auto rounded-xl border border-slate-600 bg-slate-900 p-4 shadow-2xl"
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6 backdrop-blur-sm"
|
||||||
|
onClick={schliessen}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="max-h-[80vh] w-full max-w-lg overflow-y-auto rounded-2xl border border-slate-700 bg-slate-900 p-4 shadow-2xl"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<h3 className="mb-3 text-sm font-semibold text-slate-200">Was ist das wirklich?</h3>
|
<h3 className="mb-3 text-sm font-semibold text-slate-200">Was ist das wirklich?</h3>
|
||||||
@@ -167,37 +218,61 @@ function AendernDialog({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Die Bühne: Backdrop als Panorama, Poster, Titel — und darunter der
|
||||||
|
// laufende Vorgang mit Phase, Restzeit und aufklappbarem Protokoll.
|
||||||
function LaufwerkKachel({
|
function LaufwerkKachel({
|
||||||
geraet,
|
geraet,
|
||||||
rip,
|
rip,
|
||||||
info,
|
info,
|
||||||
vorschlaege,
|
vorschlaege,
|
||||||
ripStartMs,
|
ripStartMs,
|
||||||
|
protokoll,
|
||||||
}: {
|
}: {
|
||||||
geraet: GeraeteInfo
|
geraet: GeraeteInfo
|
||||||
rip: RipStatus | undefined
|
rip: RipStatus | undefined
|
||||||
info: DiscInfoStand | undefined
|
info: DiscInfoStand | undefined
|
||||||
vorschlaege: MetaVorschlag[]
|
vorschlaege: MetaVorschlag[]
|
||||||
ripStartMs: number | undefined
|
ripStartMs: number | undefined
|
||||||
|
protokoll: string[]
|
||||||
}) {
|
}) {
|
||||||
const [aendernOffen, setAendernOffen] = useState(false)
|
const [aendernOffen, setAendernOffen] = useState(false)
|
||||||
|
const [protokollOffen, setProtokollOffen] = useState(false)
|
||||||
const ripLaeuft = rip !== undefined && LAUFENDE_PHASEN.includes(rip.phase)
|
const ripLaeuft = rip !== undefined && LAUFENDE_PHASEN.includes(rip.phase)
|
||||||
const zustand = geraet.status === 'ready' ? 'ok' : geraet.status === 'empty' ? 'wartet' : 'fehler'
|
const discDa = geraet.status === 'ready'
|
||||||
const punktFarbe = ripLaeuft
|
const backdrop = discDa && info !== undefined ? backdropUrl(info.backdropPfad) : ''
|
||||||
? 'animate-pulse bg-sky-400'
|
const poster = discDa && info !== undefined ? posterUrlGross(info.posterPfad) : ''
|
||||||
: zustand === 'ok'
|
const erkannt = discDa && info !== undefined && info.titel.length > 0
|
||||||
? 'bg-emerald-400'
|
|
||||||
: zustand === 'fehler'
|
|
||||||
? 'bg-red-400'
|
|
||||||
: 'bg-slate-500'
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-700/60 bg-slate-800/40 p-4">
|
<div className="relative overflow-hidden rounded-2xl border border-slate-800 bg-slate-900/70">
|
||||||
<div className="flex items-center justify-between gap-2">
|
{backdrop.length > 0 && (
|
||||||
|
<>
|
||||||
|
<img src={backdrop} alt="" className="absolute inset-0 h-full w-full object-cover" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-slate-950 via-slate-950/85 to-slate-950/50" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-transparent to-transparent" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative p-5">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={`inline-block h-2.5 w-2.5 rounded-full ${punktFarbe}`} />
|
<span
|
||||||
<h2 className="text-sm font-semibold tracking-wide text-slate-200">{geraet.name}</h2>
|
className={`inline-block h-2.5 w-2.5 rounded-full ${
|
||||||
|
ripLaeuft
|
||||||
|
? 'animate-pulse bg-sky-400'
|
||||||
|
: discDa
|
||||||
|
? 'bg-emerald-400'
|
||||||
|
: geraet.status === 'empty'
|
||||||
|
? 'bg-slate-600'
|
||||||
|
: 'bg-red-400'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<h2 className="text-sm font-semibold tracking-wide text-slate-200">
|
||||||
|
Laufwerk {geraet.id}:
|
||||||
|
<span className="ml-2 font-normal text-slate-500">{geraet.modell}</span>
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-none gap-2">
|
||||||
{ripLaeuft ? (
|
{ripLaeuft ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => ripAbbrechen(geraet.id)}
|
onClick={() => ripAbbrechen(geraet.id)}
|
||||||
@@ -208,68 +283,159 @@ function LaufwerkKachel({
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => ripStarten(geraet.id)}
|
onClick={() => ripStarten(geraet.id)}
|
||||||
disabled={geraet.status !== 'ready'}
|
disabled={!discDa}
|
||||||
className="rounded-lg border border-emerald-500/60 px-3 py-1 text-xs text-emerald-300 hover:bg-emerald-500/10 disabled:cursor-not-allowed disabled:opacity-40"
|
className="rounded-lg border border-emerald-500/70 bg-emerald-500/10 px-4 py-1 text-xs font-semibold text-emerald-300 hover:bg-emerald-500/20 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
title="Alle Titel, verlustfrei (MakeMKV) — danach Kompression laut Einstellungen"
|
title="Alle Titel, verlustfrei (MakeMKV) — danach Kompression laut Einstellungen"
|
||||||
>
|
>
|
||||||
Rippen
|
▶ Rippen
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => auswerfen(geraet.id)}
|
onClick={() => auswerfen(geraet.id)}
|
||||||
disabled={geraet.status !== 'ready' || ripLaeuft}
|
disabled={!discDa || ripLaeuft}
|
||||||
className="rounded-lg border border-slate-600 px-3 py-1 text-xs text-slate-300 hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-40"
|
className="rounded-lg border border-slate-600 px-3 py-1 text-xs text-slate-300 hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
title="Entriegeln, auswerfen, nachsehen — in dieser Reihenfolge"
|
title="Entriegeln, auswerfen, nachsehen — in dieser Reihenfolge"
|
||||||
>
|
>
|
||||||
Auswerfen
|
⏏ Auswerfen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex gap-3">
|
|
||||||
{geraet.status === 'ready' && info !== undefined && info.posterPfad.length > 0 && (
|
{!discDa && (
|
||||||
|
<div className="mt-4 flex items-center gap-3 py-4 text-slate-500">
|
||||||
|
<span className="text-3xl opacity-60">💿</span>
|
||||||
|
<p className="font-mono text-xs">
|
||||||
|
{geraet.status === 'empty' ? 'Keine Disc eingelegt — Disc rein genügt.' : geraet.grund}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{discDa && (
|
||||||
|
<div className="mt-4 flex gap-4">
|
||||||
|
{poster.length > 0 ? (
|
||||||
<img
|
<img
|
||||||
src={posterUrlKlein(info.posterPfad)}
|
src={poster}
|
||||||
alt=""
|
alt=""
|
||||||
className="h-24 w-16 flex-none rounded-lg object-cover shadow-lg shadow-black/40"
|
className="h-40 w-[107px] flex-none rounded-xl object-cover shadow-xl shadow-black/60 ring-1 ring-white/10"
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-40 w-[107px] flex-none items-center justify-center rounded-xl bg-slate-800/80 text-4xl ring-1 ring-white/5">
|
||||||
|
{TYP_SYMBOL[geraet.typ] ?? '💿'}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0 flex-1 space-y-0.5 font-mono text-xs text-slate-400">
|
<div className="min-w-0 flex-1">
|
||||||
<p>{geraet.modell.length > 0 ? geraet.modell : 'Modell unbekannt'}</p>
|
{erkannt ? (
|
||||||
{geraet.status === 'ready' && (
|
<>
|
||||||
<p className="text-slate-200">
|
<h3 className="truncate text-xl font-bold text-slate-50 drop-shadow">
|
||||||
|
{info.titel}
|
||||||
|
{info.jahr !== null && <span className="ml-2 font-normal text-slate-400">({info.jahr})</span>}
|
||||||
|
</h3>
|
||||||
|
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||||
|
<Badge text={TYP_NAMEN[geraet.typ]} ton="blau" />
|
||||||
|
{geraet.groesseBytes > 0 && <Badge text={gb(geraet.groesseBytes)} ton="grau" />}
|
||||||
|
<Badge
|
||||||
|
text={`${sicherheitsWort(info.confidence)} · ${Math.round(info.confidence * 100)} %${info.quelle.length > 0 ? ` · ${info.quelle}` : ''}`}
|
||||||
|
ton={info.confidence >= 0.8 ? 'gruen' : 'gelb'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{info.beschreibung.length > 0 && (
|
||||||
|
<p className="mt-2 line-clamp-2 max-w-xl text-xs leading-5 text-slate-300/90">
|
||||||
|
{info.beschreibung}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h3 className="text-xl font-bold text-slate-200">
|
||||||
{TYP_NAMEN[geraet.typ]}
|
{TYP_NAMEN[geraet.typ]}
|
||||||
{geraet.groesseBytes > 0 ? ` · ${gb(geraet.groesseBytes)}` : ''}
|
{geraet.groesseBytes > 0 && (
|
||||||
</p>
|
<span className="ml-2 text-sm font-normal text-slate-400">{gb(geraet.groesseBytes)}</span>
|
||||||
)}
|
)}
|
||||||
{geraet.status === 'ready' && info !== undefined && info.titel.length > 0 && (
|
</h3>
|
||||||
<p className={info.confidence >= 0.8 ? 'text-emerald-300' : 'text-amber-300'}>
|
<p className="mt-1 font-mono text-xs text-slate-500">
|
||||||
{sicherheitsWort(info.confidence)}: {info.titel}
|
{geraet.typ === 'cd'
|
||||||
{info.jahr !== null ? ` (${info.jahr})` : ''} · {Math.round(info.confidence * 100)} %
|
? 'Titel kommen beim Rippen von MusicBrainz.'
|
||||||
{info.quelle.length > 0 ? ` · ${info.quelle}` : ''}
|
: 'Noch keine Zuordnung — „Ändern …" hilft nach.'}
|
||||||
</p>
|
</p>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{geraet.status === 'ready' && (
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setAendernOffen(true)}
|
onClick={() => setAendernOffen(true)}
|
||||||
disabled={ripLaeuft}
|
disabled={ripLaeuft}
|
||||||
className="mt-1 rounded border border-slate-600 px-2 py-0.5 text-xs text-slate-300 hover:bg-slate-700 disabled:opacity-40"
|
className="rounded-lg border border-slate-600/80 bg-slate-900/60 px-2.5 py-1 text-xs text-slate-300 hover:bg-slate-700 disabled:opacity-40"
|
||||||
title="Falsch erkannt? Selbst suchen und wählen — die Ablage folgt deiner Wahl."
|
title="Falsch erkannt? Selbst suchen und wählen — die Ablage folgt deiner Wahl."
|
||||||
>
|
>
|
||||||
Ändern …
|
Ändern …
|
||||||
</button>
|
</button>
|
||||||
|
{info !== undefined && info.schonGerippt !== null && (
|
||||||
|
<Badge text={`schon gerippt am ${info.schonGerippt.slice(0, 10)}`} ton="gelb" />
|
||||||
)}
|
)}
|
||||||
{geraet.status === 'ready' && info !== undefined && info.schonGerippt !== null && (
|
</div>
|
||||||
<p className="text-amber-300">
|
{geraet.grund.length > 0 && <p className="mt-2 font-mono text-xs text-amber-300">{geraet.grund}</p>}
|
||||||
⚠ Diese Disc wurde schon einmal gerippt ({info.schonGerippt.slice(0, 10)}) — siehe Bibliothek.
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rip !== undefined && (
|
||||||
|
<div className="mt-4 rounded-xl border border-slate-800 bg-slate-950/70 p-3">
|
||||||
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
|
<p
|
||||||
|
className={`text-xs font-semibold ${
|
||||||
|
rip.phase === 'fehler'
|
||||||
|
? 'text-red-300'
|
||||||
|
: rip.phase === 'fertig'
|
||||||
|
? rip.lesefehler
|
||||||
|
? 'text-amber-300'
|
||||||
|
: 'text-emerald-300'
|
||||||
|
: 'text-sky-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{PHASEN_NAMEN[rip.phase]}
|
||||||
|
{rip.lesefehler && rip.phase === 'fertig' ? ' — mit Lesefehlern' : ''}
|
||||||
|
</p>
|
||||||
|
{ripLaeuft && rip.prozent >= 0 && (
|
||||||
|
<p className="font-mono text-xs text-slate-400">
|
||||||
|
<span className="text-base font-bold text-slate-100">{rip.prozent} %</span>
|
||||||
|
<span className="ml-2">{restzeitText(rip.prozent, ripStartMs)}</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{geraet.status === 'empty' && <p>keine Disc eingelegt</p>}
|
</div>
|
||||||
{geraet.status === 'unknown' && <p className="text-red-300">{geraet.grund}</p>}
|
{ripLaeuft && (
|
||||||
{geraet.status !== 'unknown' && geraet.grund.length > 0 && (
|
<div className="mt-2 h-2 overflow-hidden rounded-full bg-slate-800">
|
||||||
<p className="text-amber-300">{geraet.grund}</p>
|
<div
|
||||||
|
className="h-full rounded-full bg-gradient-to-r from-sky-500 to-emerald-400 transition-all"
|
||||||
|
style={{ width: `${Math.max(0, rip.prozent)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="mt-2 break-all font-mono text-xs text-slate-400">{rip.text}</p>
|
||||||
|
{rip.dateien.map((datei) => (
|
||||||
|
<p key={datei} className="break-all font-mono text-xs text-slate-500">
|
||||||
|
{datei}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
{protokoll.length > 1 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setProtokollOffen((o) => !o)}
|
||||||
|
className="mt-2 text-xs text-slate-500 hover:text-slate-300"
|
||||||
|
>
|
||||||
|
{protokollOffen ? '▾ Protokoll ausblenden' : `▸ Protokoll (${protokoll.length} Zeilen)`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{protokollOffen && (
|
||||||
|
<div className="mt-1 max-h-40 space-y-0.5 overflow-y-auto rounded-lg bg-slate-950 p-2">
|
||||||
|
{protokoll.map((zeile, i) => (
|
||||||
|
<p key={i} className="break-all font-mono text-[11px] leading-4 text-slate-500">
|
||||||
|
{zeile}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{aendernOffen && (
|
{aendernOffen && (
|
||||||
<AendernDialog
|
<AendernDialog
|
||||||
id={geraet.id}
|
id={geraet.id}
|
||||||
@@ -278,40 +444,78 @@ function LaufwerkKachel({
|
|||||||
schliessen={() => setAendernOffen(false)}
|
schliessen={() => setAendernOffen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{rip !== undefined && (
|
</div>
|
||||||
<div className="mt-3 space-y-1.5 border-t border-slate-700/60 pt-3">
|
)
|
||||||
{ripLaeuft && (
|
}
|
||||||
<div className="h-2 overflow-hidden rounded-full bg-slate-700">
|
|
||||||
|
// Die MakeMKV-Schlüssel-Karte (§ 9.1) — mit dem Update-Weg aus § 9:
|
||||||
|
// „MakeMKV aktualisieren" holt den Installer über die Quellenkette,
|
||||||
|
// prüft ihn und lässt Windows fragen (UAC). Danach prüft Rippy neu.
|
||||||
|
function SchluesselKarte({ verbindung }: { verbindung: Verbindung }) {
|
||||||
|
const s = verbindung.schluessel
|
||||||
|
const b = verbindung.beschaffung
|
||||||
|
const zustand: 'ok' | 'fehler' | 'wartet' =
|
||||||
|
s === null ? 'wartet' : s.angenommen === true ? 'ok' : s.weiterBetrieb ? 'wartet' : 'fehler'
|
||||||
|
const punktFarbe =
|
||||||
|
zustand === 'ok' ? 'bg-emerald-400' : zustand === 'fehler' ? 'bg-red-400' : 'bg-amber-400'
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-slate-800 bg-slate-900/70 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`inline-block h-2.5 w-2.5 rounded-full ${punktFarbe}`} />
|
||||||
|
<h2 className="text-sm font-semibold tracking-wide text-slate-200">MakeMKV-Schlüssel</h2>
|
||||||
|
{s !== null && s.version.length > 0 && <Badge text={`MakeMKV ${s.version}`} ton="grau" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{s !== null && s.angenommen === false && (
|
||||||
|
<button
|
||||||
|
onClick={() => makemkvUpdate()}
|
||||||
|
disabled={b !== null && b.laeuft}
|
||||||
|
className="rounded-lg border border-sky-500/60 bg-sky-500/10 px-3 py-1 text-xs font-semibold text-sky-300 hover:bg-sky-500/20 disabled:opacity-40"
|
||||||
|
title="Holt den offiziellen Installer über die Quellenkette (Hersteller → Archiv), prüft ihn und startet ihn — Windows fragt einmal nach Adminrechten."
|
||||||
|
>
|
||||||
|
MakeMKV aktualisieren …
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => schluesselPruefen()}
|
||||||
|
disabled={b !== null && b.laeuft}
|
||||||
|
className="rounded-lg border border-slate-600 px-3 py-1 text-xs text-slate-300 hover:bg-slate-700 disabled:opacity-40"
|
||||||
|
title="Forum-Key holen, hinterlegen und MakeMKV selbst fragen, ob er angenommen wird"
|
||||||
|
>
|
||||||
|
Neu prüfen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{s === null ? (
|
||||||
|
<p className="font-mono text-xs text-slate-400">wird geprüft …</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="font-mono text-xs leading-5 text-slate-300">{s.text}</p>
|
||||||
|
<p className="font-mono text-xs text-slate-500">
|
||||||
|
zuletzt geprüft: {s.geholtAm.slice(0, 16).replace('T', ' ')}
|
||||||
|
{s.gueltigBis.length > 0 && ` · Key gültig bis ${s.gueltigBis}`}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{b !== null && (
|
||||||
|
<div className="mt-2 rounded-lg border border-slate-800 bg-slate-950/70 p-2">
|
||||||
|
<p className={`font-mono text-xs leading-5 ${b.fehler ? 'text-red-300' : b.laeuft ? 'text-sky-300' : 'text-emerald-300'}`}>
|
||||||
|
{b.text}
|
||||||
|
</p>
|
||||||
|
{b.laeuft && b.anteil !== null && (
|
||||||
|
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-slate-800">
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-full bg-sky-400 transition-all"
|
className="h-full rounded-full bg-sky-400 transition-all"
|
||||||
style={{ width: `${Math.max(0, rip.prozent)}%` }}
|
style={{ width: `${Math.round(b.anteil * 100)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p
|
|
||||||
className={`break-all font-mono text-xs ${
|
|
||||||
rip.phase === 'fehler'
|
|
||||||
? 'text-red-300'
|
|
||||||
: rip.phase === 'fertig'
|
|
||||||
? rip.lesefehler
|
|
||||||
? 'text-amber-300'
|
|
||||||
: 'text-emerald-300'
|
|
||||||
: 'text-slate-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{ripLaeuft && rip.prozent >= 0
|
|
||||||
? `${rip.prozent} % ${restzeitText(rip.prozent, ripStartMs)} — `
|
|
||||||
: ''}
|
|
||||||
{rip.text}
|
|
||||||
</p>
|
|
||||||
{rip.dateien.map((datei) => (
|
|
||||||
<p key={datei} className="break-all font-mono text-xs text-slate-500">
|
|
||||||
{datei}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,7 +721,7 @@ function Einstellungen({ verbindung, haupt }: { verbindung: Verbindung; haupt: H
|
|||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500">Werkzeuge</h2>
|
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500">Werkzeuge</h2>
|
||||||
<div className="space-y-1 rounded-xl border border-slate-700/60 bg-slate-800/40 p-4 font-mono text-xs">
|
<div className="space-y-1 rounded-xl border border-slate-800 bg-slate-900/70 p-4 font-mono text-xs">
|
||||||
<p className={verbindung.werkzeuge === null ? 'text-slate-400' : verbindung.werkzeuge.makemkvcon.length > 0 ? 'text-emerald-300' : 'text-red-300'}>
|
<p className={verbindung.werkzeuge === null ? 'text-slate-400' : verbindung.werkzeuge.makemkvcon.length > 0 ? 'text-emerald-300' : 'text-red-300'}>
|
||||||
makemkvcon: {verbindung.werkzeuge === null ? 'wird gesucht …' : verbindung.werkzeuge.makemkvcon || 'NICHT GEFUNDEN — MakeMKV installieren'}
|
makemkvcon: {verbindung.werkzeuge === null ? 'wird gesucht …' : verbindung.werkzeuge.makemkvcon || 'NICHT GEFUNDEN — MakeMKV installieren'}
|
||||||
</p>
|
</p>
|
||||||
@@ -536,7 +740,7 @@ function Einstellungen({ verbindung, haupt }: { verbindung: Verbindung; haupt: H
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Bibliothek ──────────────────────────────────────────────────────────
|
// ── Bibliothek: das Poster-Regal (§ 6.6) ────────────────────────────────
|
||||||
function Bibliothek({ verbindung }: { verbindung: Verbindung }) {
|
function Bibliothek({ verbindung }: { verbindung: Verbindung }) {
|
||||||
if (verbindung.bibliothek.length === 0) {
|
if (verbindung.bibliothek.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -545,37 +749,41 @@ function Bibliothek({ verbindung }: { verbindung: Verbindung }) {
|
|||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
const gesamt = verbindung.bibliothek.reduce((summe, e) => summe + e.groesseBytes, 0)
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto rounded-xl border border-slate-700/60">
|
<div className="space-y-4">
|
||||||
<table className="w-full text-left font-mono text-xs">
|
<p className="font-mono text-xs text-slate-500">
|
||||||
<thead className="bg-slate-800/60 text-slate-400">
|
{verbindung.bibliothek.length} {verbindung.bibliothek.length === 1 ? 'Eintrag' : 'Einträge'} ·{' '}
|
||||||
<tr>
|
{gb(gesamt)} gesamt
|
||||||
<th className="px-3 py-2">Titel</th>
|
</p>
|
||||||
<th className="px-3 py-2">Typ</th>
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-4">
|
||||||
<th className="px-3 py-2">Größe</th>
|
|
||||||
<th className="px-3 py-2">Datum</th>
|
|
||||||
<th className="px-3 py-2">Dauer</th>
|
|
||||||
<th className="px-3 py-2">Ablageort</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="text-slate-300">
|
|
||||||
{verbindung.bibliothek.map((eintrag) => (
|
{verbindung.bibliothek.map((eintrag) => (
|
||||||
<tr key={eintrag.fingerprint} className="border-t border-slate-700/40">
|
<div
|
||||||
<td className="px-3 py-2">
|
key={eintrag.fingerprint}
|
||||||
|
className="group"
|
||||||
|
title={`${eintrag.ablageort}\ngerippt am ${eintrag.datum.slice(0, 16).replace('T', ' ')} · ${Math.round(eintrag.dauerS / 60)} min`}
|
||||||
|
>
|
||||||
|
{eintrag.posterPfad.length > 0 ? (
|
||||||
|
<img
|
||||||
|
src={posterUrlGross(eintrag.posterPfad)}
|
||||||
|
alt=""
|
||||||
|
className="aspect-[2/3] w-full rounded-xl object-cover shadow-lg shadow-black/40 ring-1 ring-white/10 transition-transform group-hover:scale-[1.03]"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex aspect-[2/3] w-full items-center justify-center rounded-xl bg-slate-800/80 text-5xl ring-1 ring-white/5 transition-transform group-hover:scale-[1.03]">
|
||||||
|
{TYP_SYMBOL[eintrag.typ] ?? '🎬'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="mt-2 truncate text-xs font-semibold text-slate-200">
|
||||||
{eintrag.titel}
|
{eintrag.titel}
|
||||||
{eintrag.jahr !== null ? ` (${eintrag.jahr})` : ''}
|
{eintrag.jahr !== null ? ` (${eintrag.jahr})` : ''}
|
||||||
</td>
|
</p>
|
||||||
<td className="px-3 py-2">{eintrag.typ}</td>
|
<p className="font-mono text-[11px] text-slate-500">
|
||||||
<td className="px-3 py-2">{gb(eintrag.groesseBytes)}</td>
|
{TYP_NAMEN[eintrag.typ as GeraeteInfo['typ']] ?? eintrag.typ} · {gb(eintrag.groesseBytes)}
|
||||||
<td className="px-3 py-2">{eintrag.datum.slice(0, 16).replace('T', ' ')}</td>
|
</p>
|
||||||
<td className="px-3 py-2">{Math.round(eintrag.dauerS / 60)} min</td>
|
</div>
|
||||||
<td className="max-w-64 truncate px-3 py-2 text-slate-500" title={eintrag.ablageort}>
|
|
||||||
{eintrag.ablageort}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -757,10 +965,12 @@ export default function App() {
|
|||||||
discInfos: {},
|
discInfos: {},
|
||||||
vorschlaege: {},
|
vorschlaege: {},
|
||||||
ripStart: {},
|
ripStart: {},
|
||||||
|
ripProtokoll: {},
|
||||||
einstellungen: {},
|
einstellungen: {},
|
||||||
werkzeuge: null,
|
werkzeuge: null,
|
||||||
bibliothek: [],
|
bibliothek: [],
|
||||||
schluessel: null,
|
schluessel: null,
|
||||||
|
beschaffung: null,
|
||||||
meldungen: [],
|
meldungen: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -773,6 +983,7 @@ export default function App() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const db = verbindung.kern?.datenbank ?? null
|
const db = verbindung.kern?.datenbank ?? null
|
||||||
|
const einRipLaeuft = Object.values(verbindung.rips).some((r) => LAUFENDE_PHASEN.includes(r.phase))
|
||||||
|
|
||||||
// § 6.9: Die Erst-Einrichtung läuft NACH der Installation, im Programm —
|
// § 6.9: Die Erst-Einrichtung läuft NACH der Installation, im Programm —
|
||||||
// solange sie nicht abgeschlossen ist, ist sie die ganze Ansicht.
|
// solange sie nicht abgeschlossen ist, ist sie die ganze Ansicht.
|
||||||
@@ -793,12 +1004,23 @@ export default function App() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-slate-900 text-slate-100">
|
<div className="flex min-h-screen flex-col bg-slate-950 text-slate-100">
|
||||||
<header className="border-b border-slate-700/60 px-6 py-4">
|
<header className="border-b border-slate-800 bg-slate-950/80 px-6 py-3.5">
|
||||||
<div className="flex items-baseline justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex items-baseline gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Rippy</h1>
|
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-sky-500/30 to-emerald-500/20 text-lg ring-1 ring-white/10">
|
||||||
<span className="font-mono text-xs text-slate-400">v{haupt?.version ?? '…'}</span>
|
💿
|
||||||
|
</span>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<h1 className="text-xl font-bold tracking-tight">Rippy</h1>
|
||||||
|
<span className="font-mono text-xs text-slate-500">v{haupt?.version ?? '…'}</span>
|
||||||
|
{einRipLaeuft && (
|
||||||
|
<span className="ml-1 inline-flex items-center gap-1.5 rounded-full border border-sky-500/40 bg-sky-500/10 px-2 py-0.5 text-[11px] text-sky-300">
|
||||||
|
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-sky-400" />
|
||||||
|
arbeitet
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex gap-1">
|
<nav className="flex gap-1">
|
||||||
{knopf('uebersicht', 'Übersicht')}
|
{knopf('uebersicht', 'Übersicht')}
|
||||||
@@ -821,7 +1043,7 @@ export default function App() {
|
|||||||
{verbindung.verbunden ? 'Kein optisches Laufwerk gefunden.' : 'Warte auf den Kern …'}
|
{verbindung.verbunden ? 'Kein optisches Laufwerk gefunden.' : 'Warte auf den Kern …'}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="space-y-4">
|
||||||
{verbindung.laufwerke.map((geraet) => (
|
{verbindung.laufwerke.map((geraet) => (
|
||||||
<LaufwerkKachel
|
<LaufwerkKachel
|
||||||
key={geraet.id}
|
key={geraet.id}
|
||||||
@@ -830,6 +1052,7 @@ export default function App() {
|
|||||||
info={verbindung.discInfos[geraet.id]}
|
info={verbindung.discInfos[geraet.id]}
|
||||||
vorschlaege={verbindung.vorschlaege[geraet.id] ?? []}
|
vorschlaege={verbindung.vorschlaege[geraet.id] ?? []}
|
||||||
ripStartMs={verbindung.ripStart[geraet.id]}
|
ripStartMs={verbindung.ripStart[geraet.id]}
|
||||||
|
protokoll={verbindung.ripProtokoll[geraet.id] ?? []}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -838,11 +1061,18 @@ export default function App() {
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="mb-3 text-xs font-semibold uppercase tracking-widest text-slate-500">
|
<h2 className="mb-3 text-xs font-semibold uppercase tracking-widest text-slate-500">
|
||||||
Status
|
MakeMKV
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid content-start gap-4 sm:grid-cols-2">
|
<SchluesselKarte verbindung={verbindung} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-3 text-xs font-semibold uppercase tracking-widest text-slate-500">
|
||||||
|
System
|
||||||
|
</h2>
|
||||||
|
<div className="grid content-start gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<Kachel
|
<Kachel
|
||||||
titel="Kern (utilityProcess)"
|
titel="Kern"
|
||||||
zustand={verbindung.kern !== null ? 'ok' : haupt?.kern.laeuft ? 'wartet' : 'fehler'}
|
zustand={verbindung.kern !== null ? 'ok' : haupt?.kern.laeuft ? 'wartet' : 'fehler'}
|
||||||
zeilen={
|
zeilen={
|
||||||
verbindung.kern !== null
|
verbindung.kern !== null
|
||||||
@@ -854,23 +1084,23 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Kachel
|
<Kachel
|
||||||
titel="Datenbank (node:sqlite)"
|
titel="Datenbank"
|
||||||
zustand={db === null ? 'wartet' : db.ok ? 'ok' : 'fehler'}
|
zustand={db === null ? 'wartet' : db.ok ? 'ok' : 'fehler'}
|
||||||
zeilen={db === null ? ['wartet auf Kern …'] : db.ok ? [db.pfad] : [db.fehler]}
|
zeilen={db === null ? ['wartet auf Kern …'] : db.ok ? [db.pfad] : [db.fehler]}
|
||||||
/>
|
/>
|
||||||
<Kachel
|
<Kachel
|
||||||
titel="Prozess-Leine (Job Object)"
|
titel="Prozess-Leine"
|
||||||
zustand={haupt === null ? 'wartet' : haupt.leine.ok ? 'ok' : 'fehler'}
|
zustand={haupt === null ? 'wartet' : haupt.leine.ok ? 'ok' : 'fehler'}
|
||||||
zeilen={
|
zeilen={
|
||||||
haupt === null
|
haupt === null
|
||||||
? ['wartet auf Haupt …']
|
? ['wartet auf Haupt …']
|
||||||
: haupt.leine.ok
|
: haupt.leine.ok
|
||||||
? ['gesetzt — Kern und Werkzeuge sterben mit Rippy']
|
? ['gesetzt — Werkzeuge sterben mit Rippy']
|
||||||
: [haupt.leine.fehler ?? 'nicht gesetzt']
|
: [haupt.leine.fehler ?? 'nicht gesetzt']
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Kachel
|
<Kachel
|
||||||
titel="Fenster ↔ Kern (MessagePort)"
|
titel="Fenster ↔ Kern"
|
||||||
zustand={verbindung.pongLatenzMs !== null ? 'ok' : 'wartet'}
|
zustand={verbindung.pongLatenzMs !== null ? 'ok' : 'wartet'}
|
||||||
zeilen={
|
zeilen={
|
||||||
verbindung.pongLatenzMs !== null
|
verbindung.pongLatenzMs !== null
|
||||||
@@ -878,26 +1108,6 @@ export default function App() {
|
|||||||
: ['Ping unterwegs …']
|
: ['Ping unterwegs …']
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Kachel
|
|
||||||
titel="MakeMKV-Schlüssel (§ 9.1)"
|
|
||||||
zustand={
|
|
||||||
verbindung.schluessel === null
|
|
||||||
? 'wartet'
|
|
||||||
: verbindung.schluessel.angenommen === true
|
|
||||||
? 'ok'
|
|
||||||
: verbindung.schluessel.weiterBetrieb
|
|
||||||
? 'wartet' // gelb: Rips laufen, aber mit Handlungsempfehlung
|
|
||||||
: 'fehler'
|
|
||||||
}
|
|
||||||
zeilen={
|
|
||||||
verbindung.schluessel === null
|
|
||||||
? ['wird geprüft …']
|
|
||||||
: [
|
|
||||||
verbindung.schluessel.text,
|
|
||||||
`zuletzt geprüft: ${verbindung.schluessel.geholtAm.slice(0, 16).replace('T', ' ')}`,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -906,7 +1116,7 @@ export default function App() {
|
|||||||
<h2 className="mb-3 text-xs font-semibold uppercase tracking-widest text-slate-500">
|
<h2 className="mb-3 text-xs font-semibold uppercase tracking-widest text-slate-500">
|
||||||
Ereignisse
|
Ereignisse
|
||||||
</h2>
|
</h2>
|
||||||
<div className="space-y-1 rounded-xl border border-slate-700/60 bg-slate-800/40 p-4">
|
<div className="max-h-56 space-y-1 overflow-y-auto rounded-xl border border-slate-800 bg-slate-900/70 p-4">
|
||||||
{verbindung.meldungen.map((zeile, i) => (
|
{verbindung.meldungen.map((zeile, i) => (
|
||||||
<p key={i} className="break-all font-mono text-xs text-slate-400">
|
<p key={i} className="break-all font-mono text-xs text-slate-400">
|
||||||
{zeile}
|
{zeile}
|
||||||
@@ -922,7 +1132,7 @@ export default function App() {
|
|||||||
{!setupOffen && bereich === 'bibliothek' && <Bibliothek verbindung={verbindung} />}
|
{!setupOffen && bereich === 'bibliothek' && <Bibliothek verbindung={verbindung} />}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="border-t border-slate-700/60 px-6 py-3 text-center text-xs text-slate-500">
|
<footer className="border-t border-slate-800 px-6 py-3 text-center text-xs text-slate-600">
|
||||||
Rippy v5 · LucyAI · Claude · KrBrZ
|
Rippy v5 · LucyAI · Claude · KrBrZ
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
// Laufwerksliste: Sie ändert sich NUR durch eine neue Liste vom Kern.
|
// Laufwerksliste: Sie ändert sich NUR durch eine neue Liste vom Kern.
|
||||||
import {
|
import {
|
||||||
istKernNachricht,
|
istKernNachricht,
|
||||||
|
type BeschaffungStand,
|
||||||
type BibliothekEintrag,
|
type BibliothekEintrag,
|
||||||
type DiscInfoStand,
|
type DiscInfoStand,
|
||||||
type GeraeteInfo,
|
type GeraeteInfo,
|
||||||
@@ -31,6 +32,10 @@ export interface Verbindung {
|
|||||||
vorschlaege: Record<string, MetaVorschlag[]>
|
vorschlaege: Record<string, MetaVorschlag[]>
|
||||||
/** Wann der laufende Vorgang je Laufwerk begann (für die Restzeit). */
|
/** Wann der laufende Vorgang je Laufwerk begann (für die Restzeit). */
|
||||||
ripStart: Record<string, number>
|
ripStart: Record<string, number>
|
||||||
|
/** Statuszeilen des (letzten) Vorgangs je Laufwerk — das aufklappbare
|
||||||
|
* Protokoll der Kachel. Bleibt nach dem Ende stehen (R2), bis ein neuer
|
||||||
|
* Vorgang beginnt. */
|
||||||
|
ripProtokoll: Record<string, string[]>
|
||||||
einstellungen: Record<string, string>
|
einstellungen: Record<string, string>
|
||||||
werkzeuge: WerkzeugAuskunft | null
|
werkzeuge: WerkzeugAuskunft | null
|
||||||
bibliothek: BibliothekEintrag[]
|
bibliothek: BibliothekEintrag[]
|
||||||
@@ -41,14 +46,17 @@ export interface Verbindung {
|
|||||||
gueltigBis: string
|
gueltigBis: string
|
||||||
geholtAm: string
|
geholtAm: string
|
||||||
text: string
|
text: string
|
||||||
|
version: string
|
||||||
} | null
|
} | null
|
||||||
|
/** Stand der MakeMKV-Beschaffung (§ 9) — null, solange nie eine lief. */
|
||||||
|
beschaffung: BeschaffungStand | null
|
||||||
/** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */
|
/** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */
|
||||||
meldungen: string[]
|
meldungen: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type Horcher = (stand: Verbindung) => void
|
type Horcher = (stand: Verbindung) => void
|
||||||
|
|
||||||
const MAX_MELDUNGEN = 8
|
const MAX_MELDUNGEN = 20
|
||||||
|
|
||||||
const stand: Verbindung = {
|
const stand: Verbindung = {
|
||||||
verbunden: false,
|
verbunden: false,
|
||||||
@@ -59,10 +67,12 @@ const stand: Verbindung = {
|
|||||||
discInfos: {},
|
discInfos: {},
|
||||||
vorschlaege: {},
|
vorschlaege: {},
|
||||||
ripStart: {},
|
ripStart: {},
|
||||||
|
ripProtokoll: {},
|
||||||
einstellungen: {},
|
einstellungen: {},
|
||||||
werkzeuge: null,
|
werkzeuge: null,
|
||||||
bibliothek: [],
|
bibliothek: [],
|
||||||
schluessel: null,
|
schluessel: null,
|
||||||
|
beschaffung: null,
|
||||||
meldungen: [],
|
meldungen: [],
|
||||||
}
|
}
|
||||||
const horcher = new Set<Horcher>()
|
const horcher = new Set<Horcher>()
|
||||||
@@ -146,15 +156,30 @@ function kernNachricht(nachricht: KernNachricht): void {
|
|||||||
stand.schluessel = nachricht.stand
|
stand.schluessel = nachricht.stand
|
||||||
if (nachricht.stand.angenommen === false) meldung(`⚠ MakeMKV-Schlüssel: ${nachricht.stand.text}`)
|
if (nachricht.stand.angenommen === false) meldung(`⚠ MakeMKV-Schlüssel: ${nachricht.stand.text}`)
|
||||||
break
|
break
|
||||||
|
case 'beschaffung-status':
|
||||||
|
stand.beschaffung = nachricht.stand
|
||||||
|
if (nachricht.stand.fehler) meldung(`⚠ MakeMKV-Update: ${nachricht.stand.text}`)
|
||||||
|
break
|
||||||
case 'rip-status': {
|
case 'rip-status': {
|
||||||
stand.rips = { ...stand.rips, [nachricht.status.id]: nachricht.status }
|
stand.rips = { ...stand.rips, [nachricht.status.id]: nachricht.status }
|
||||||
const laeuft = ['liest-info', 'rippt', 'komprimiert', 'legt-ab'].includes(nachricht.status.phase)
|
const laeuft = ['liest-info', 'rippt', 'komprimiert', 'legt-ab'].includes(nachricht.status.phase)
|
||||||
if (laeuft && stand.ripStart[nachricht.status.id] === undefined) {
|
if (laeuft && stand.ripStart[nachricht.status.id] === undefined) {
|
||||||
stand.ripStart = { ...stand.ripStart, [nachricht.status.id]: Date.now() }
|
stand.ripStart = { ...stand.ripStart, [nachricht.status.id]: Date.now() }
|
||||||
|
// Ein NEUER Vorgang beginnt — das Protokoll des alten weicht.
|
||||||
|
stand.ripProtokoll = { ...stand.ripProtokoll, [nachricht.status.id]: [] }
|
||||||
} else if (!laeuft) {
|
} else if (!laeuft) {
|
||||||
const { [nachricht.status.id]: _weg, ...rest } = stand.ripStart
|
const { [nachricht.status.id]: _weg, ...rest } = stand.ripStart
|
||||||
stand.ripStart = rest
|
stand.ripStart = rest
|
||||||
}
|
}
|
||||||
|
// Jede NEUE Statuszeile wandert ins Kachel-Protokoll (Prozent-Ticks
|
||||||
|
// wiederholen den Text — nur echte Änderungen zählen).
|
||||||
|
const bisher = stand.ripProtokoll[nachricht.status.id] ?? []
|
||||||
|
if (nachricht.status.text.length > 0 && bisher[bisher.length - 1] !== nachricht.status.text) {
|
||||||
|
stand.ripProtokoll = {
|
||||||
|
...stand.ripProtokoll,
|
||||||
|
[nachricht.status.id]: [...bisher, nachricht.status.text].slice(-80),
|
||||||
|
}
|
||||||
|
}
|
||||||
if (nachricht.status.phase === 'fertig') {
|
if (nachricht.status.phase === 'fertig') {
|
||||||
meldung(
|
meldung(
|
||||||
`Laufwerk ${nachricht.status.id}: Rip fertig — ${nachricht.status.dateien.length} Datei(en)` +
|
`Laufwerk ${nachricht.status.id}: Rip fertig — ${nachricht.status.dateien.length} Datei(en)` +
|
||||||
@@ -251,6 +276,20 @@ export function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): void {
|
|||||||
port.postMessage({ art: 'zuordnung-setzen', id, vorschlag })
|
port.postMessage({ art: 'zuordnung-setzen', id, vorschlag })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function makemkvUpdate(): void {
|
||||||
|
if (port === null) {
|
||||||
|
meldung('⚠ MakeMKV-Update: keine Verbindung zum Kern')
|
||||||
|
melden()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
port.postMessage({ art: 'makemkv-update' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function schluesselPruefen(): void {
|
||||||
|
if (port === null) return
|
||||||
|
port.postMessage({ art: 'schluessel-pruefen' })
|
||||||
|
}
|
||||||
|
|
||||||
export function einstellungSetzen(schluessel: string, wert: string): void {
|
export function einstellungSetzen(schluessel: string, wert: string): void {
|
||||||
if (port === null) {
|
if (port === null) {
|
||||||
meldung(`⚠ Einstellung ${schluessel}: keine Verbindung zum Kern`)
|
meldung(`⚠ Einstellung ${schluessel}: keine Verbindung zum Kern`)
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ export interface DiscInfoStand {
|
|||||||
confidence: number
|
confidence: number
|
||||||
quelle: string
|
quelle: string
|
||||||
posterPfad: string
|
posterPfad: string
|
||||||
|
/** TMDb-Hintergrundbild (Querformat) fürs Kachel-Panorama; '' wenn keins. */
|
||||||
|
backdropPfad: string
|
||||||
|
/** Kurzbeschreibung (TMDb-Overview) — '' wenn keine. */
|
||||||
|
beschreibung: string
|
||||||
/** Datum des früheren Rips dieser Disc — die Bibliothek erkennt sie am
|
/** Datum des früheren Rips dieser Disc — die Bibliothek erkennt sie am
|
||||||
* Fingerabdruck wieder (§ 6.6); null wenn neu. */
|
* Fingerabdruck wieder (§ 6.6); null wenn neu. */
|
||||||
schonGerippt: string | null
|
schonGerippt: string | null
|
||||||
@@ -83,6 +87,8 @@ export interface BibliothekEintrag {
|
|||||||
ablageort: string
|
ablageort: string
|
||||||
datum: string
|
datum: string
|
||||||
dauerS: number
|
dauerS: number
|
||||||
|
/** TMDb-Posterpfad fürs Bibliotheks-Regal — '' wenn keiner bekannt. */
|
||||||
|
posterPfad: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ein wählbarer Metadaten-Vorschlag (§ 5 Schritt 4: „nein → fragen"). */
|
/** Ein wählbarer Metadaten-Vorschlag (§ 5 Schritt 4: „nein → fragen"). */
|
||||||
@@ -103,6 +109,17 @@ export interface WerkzeugAuskunft {
|
|||||||
backends: string[]
|
backends: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stand einer laufenden Werkzeug-Beschaffung (§ 9: MakeMKV-Update). */
|
||||||
|
export interface BeschaffungStand {
|
||||||
|
/** Welches Werkzeug — bisher nur 'makemkv'. */
|
||||||
|
werkzeug: string
|
||||||
|
laeuft: boolean
|
||||||
|
text: string
|
||||||
|
/** 0–1 während des Downloads; null wenn gerade nicht bezifferbar. */
|
||||||
|
anteil: number | null
|
||||||
|
fehler: boolean
|
||||||
|
}
|
||||||
|
|
||||||
/** Nachrichten, die der Kern verschickt. */
|
/** Nachrichten, die der Kern verschickt. */
|
||||||
export type KernNachricht =
|
export type KernNachricht =
|
||||||
| { art: 'kern-bereit'; pid: number; nodeVersion: string; datenbank: DbStatus }
|
| { art: 'kern-bereit'; pid: number; nodeVersion: string; datenbank: DbStatus }
|
||||||
@@ -116,6 +133,10 @@ export type KernNachricht =
|
|||||||
| { art: 'einstellungen'; werte: Record<string, string> }
|
| { art: 'einstellungen'; werte: Record<string, string> }
|
||||||
| { art: 'werkzeuge'; auskunft: WerkzeugAuskunft }
|
| { art: 'werkzeuge'; auskunft: WerkzeugAuskunft }
|
||||||
| { art: 'bibliothek'; eintraege: BibliothekEintrag[] }
|
| { art: 'bibliothek'; eintraege: BibliothekEintrag[] }
|
||||||
|
| { art: 'beschaffung-status'; stand: BeschaffungStand }
|
||||||
|
/** Nur fürs Haupt: Der Kern hat einen geprüften Installer abgelegt —
|
||||||
|
* starten kann ihn nur das Haupt (shell/UAC, § 9). */
|
||||||
|
| { art: 'installer-starten'; pfad: string }
|
||||||
| {
|
| {
|
||||||
art: 'schluessel-status'
|
art: 'schluessel-status'
|
||||||
stand: {
|
stand: {
|
||||||
@@ -126,6 +147,8 @@ export type KernNachricht =
|
|||||||
gueltigBis: string
|
gueltigBis: string
|
||||||
geholtAm: string
|
geholtAm: string
|
||||||
text: string
|
text: string
|
||||||
|
/** Die installierte MakeMKV-Fassung (aus MSG 1005) — '' wenn unbekannt. */
|
||||||
|
version: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,9 +164,16 @@ export type FensterNachricht =
|
|||||||
| { art: 'einstellungen-laden' }
|
| { art: 'einstellungen-laden' }
|
||||||
| { art: 'werkzeuge-laden' }
|
| { art: 'werkzeuge-laden' }
|
||||||
| { art: 'bibliothek-laden' }
|
| { art: 'bibliothek-laden' }
|
||||||
|
/** § 9: MakeMKV über die Quellenkette holen und installieren lassen. */
|
||||||
|
| { art: 'makemkv-update' }
|
||||||
|
/** Schlüsselkette sofort neu laufen lassen (z. B. nach einer Installation). */
|
||||||
|
| { art: 'schluessel-pruefen' }
|
||||||
|
|
||||||
/** Nachrichten, die das Haupt an den Kern schickt (Port-Übergabe). */
|
/** Nachrichten, die das Haupt an den Kern schickt. */
|
||||||
export type HauptNachricht = { art: 'fenster-port' }
|
export type HauptNachricht =
|
||||||
|
| { art: 'fenster-port' }
|
||||||
|
/** Antwort auf 'installer-starten': '' bzw. null heißt gestartet. */
|
||||||
|
| { art: 'installer-ergebnis'; fehler: string | null }
|
||||||
|
|
||||||
/** Zustand des Haupt-Prozesses fürs Fenster (Kanal 'haupt-status'). */
|
/** Zustand des Haupt-Prozesses fürs Fenster (Kanal 'haupt-status'). */
|
||||||
export interface HauptStatus {
|
export interface HauptStatus {
|
||||||
@@ -198,6 +228,15 @@ export function istKernNachricht(wert: unknown): wert is KernNachricht {
|
|||||||
return Array.isArray(wert.eintraege)
|
return Array.isArray(wert.eintraege)
|
||||||
case 'schluessel-status':
|
case 'schluessel-status':
|
||||||
return istObjekt(wert.stand) && typeof (wert.stand as Record<string, unknown>).text === 'string'
|
return istObjekt(wert.stand) && typeof (wert.stand as Record<string, unknown>).text === 'string'
|
||||||
|
case 'beschaffung-status':
|
||||||
|
return (
|
||||||
|
istObjekt(wert.stand) &&
|
||||||
|
typeof (wert.stand as Record<string, unknown>).werkzeug === 'string' &&
|
||||||
|
typeof (wert.stand as Record<string, unknown>).text === 'string' &&
|
||||||
|
typeof (wert.stand as Record<string, unknown>).laeuft === 'boolean'
|
||||||
|
)
|
||||||
|
case 'installer-starten':
|
||||||
|
return typeof wert.pfad === 'string' && wert.pfad.length > 0
|
||||||
case 'metadaten-vorschlaege':
|
case 'metadaten-vorschlaege':
|
||||||
return typeof wert.id === 'string' && Array.isArray(wert.vorschlaege)
|
return typeof wert.id === 'string' && Array.isArray(wert.vorschlaege)
|
||||||
default:
|
default:
|
||||||
@@ -250,9 +289,18 @@ export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
|
|||||||
if (wert.art === 'zuordnung-setzen') {
|
if (wert.art === 'zuordnung-setzen') {
|
||||||
return typeof wert.id === 'string' && wert.id.length > 0 && istMetaVorschlag(wert.vorschlag)
|
return typeof wert.id === 'string' && wert.id.length > 0 && istMetaVorschlag(wert.vorschlag)
|
||||||
}
|
}
|
||||||
return wert.art === 'einstellungen-laden' || wert.art === 'werkzeuge-laden' || wert.art === 'bibliothek-laden'
|
return (
|
||||||
|
wert.art === 'einstellungen-laden' ||
|
||||||
|
wert.art === 'werkzeuge-laden' ||
|
||||||
|
wert.art === 'bibliothek-laden' ||
|
||||||
|
wert.art === 'makemkv-update' ||
|
||||||
|
wert.art === 'schluessel-pruefen'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function istHauptNachricht(wert: unknown): wert is HauptNachricht {
|
export function istHauptNachricht(wert: unknown): wert is HauptNachricht {
|
||||||
return istObjekt(wert) && wert.art === 'fenster-port'
|
if (!istObjekt(wert)) return false
|
||||||
|
if (wert.art === 'fenster-port') return true
|
||||||
|
if (wert.art === 'installer-ergebnis') return wert.fehler === null || typeof wert.fehler === 'string'
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,11 @@
|
|||||||
// („Ein Fenster geht auf, der Kern läuft") maschinell — alle drei Prozesse,
|
// („Ein Fenster geht auf, der Kern läuft") maschinell — alle drei Prozesse,
|
||||||
// beide IPC-Wege und die Datenbank — und endet mit Code 0 (grün) oder 2.
|
// beide IPC-Wege und die Datenbank — und endet mit Code 0 (grün) oder 2.
|
||||||
// RIPPY_SMOKE_BILD=<pfad.png> legt zusätzlich einen Screenshot ab.
|
// RIPPY_SMOKE_BILD=<pfad.png> legt zusätzlich einen Screenshot ab.
|
||||||
import { app, dialog, ipcMain, Notification, type BrowserWindow } from 'electron'
|
import { app, dialog, ipcMain, Notification, shell, type BrowserWindow } from 'electron'
|
||||||
import { writeFile } from 'node:fs/promises'
|
import { writeFile } from 'node:fs/promises'
|
||||||
import { join } from 'node:path'
|
import { join, win32 } from 'node:path'
|
||||||
import type { HauptStatus, KernNachricht } from '../gemeinsam/nachrichten'
|
import type { HauptStatus, KernNachricht } from '../gemeinsam/nachrichten'
|
||||||
|
import { werkzeugOrdner } from '../kern/werkzeuge/katalog'
|
||||||
import { autostartAktiv, autostartSetzen } from './autostart'
|
import { autostartAktiv, autostartSetzen } from './autostart'
|
||||||
import { fensterErstellen } from './fenster'
|
import { fensterErstellen } from './fenster'
|
||||||
import { KernVerwaltung } from './kernstart'
|
import { KernVerwaltung } from './kernstart'
|
||||||
@@ -84,6 +85,8 @@ function start(): void {
|
|||||||
(text) => toast('Rippy-Update', text),
|
(text) => toast('Rippy-Update', text),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
} else if (nachricht.art === 'installer-starten') {
|
||||||
|
void installerStarten(nachricht.pfad)
|
||||||
} else if (nachricht.art === 'disc-info' && nachricht.info.titel.length > 0 && !smokeModus) {
|
} else if (nachricht.art === 'disc-info' && nachricht.info.titel.length > 0 && !smokeModus) {
|
||||||
// Windows-Toast beim Erkennen (§ 7) — Klick holt das Fenster hoch.
|
// Windows-Toast beim Erkennen (§ 7) — Klick holt das Fenster hoch.
|
||||||
toast(
|
toast(
|
||||||
@@ -131,6 +134,25 @@ function start(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// § 9: Der Kern hat einen geprüften Installer abgelegt — gestartet wird
|
||||||
|
// er HIER über die Shell. Zwei Gründe: (1) MakeMKVs Installer fordert im
|
||||||
|
// Manifest Administratorrechte an — CreateProcess bricht dann mit
|
||||||
|
// ERROR_ELEVATION_REQUIRED ab, nur der Shell-Weg zeigt die UAC-Abfrage
|
||||||
|
// (beschaffen.py, am Commander-PC gemessen). (2) Die Erhöhung läuft über
|
||||||
|
// den AppInfo-Dienst, der Installer ist dadurch KEIN Kind von Rippy —
|
||||||
|
// er hängt nicht an der Prozess-Leine und überlebt ein Beenden.
|
||||||
|
async function installerStarten(pfad: string): Promise<void> {
|
||||||
|
// Nur, was der Kern selbst geladen und geprüft hat: eine .exe aus
|
||||||
|
// Rippys eigenem Downloads-Ordner.
|
||||||
|
const downloads = win32.join(werkzeugOrdner(), 'downloads') + '\\'
|
||||||
|
if (!pfad.startsWith(downloads) || !pfad.toLowerCase().endsWith('.exe')) {
|
||||||
|
kernVerwaltung.senden({ art: 'installer-ergebnis', fehler: `Pfad außerhalb des Downloads-Ordners: ${pfad}` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const fehler = await shell.openPath(pfad)
|
||||||
|
kernVerwaltung.senden({ art: 'installer-ergebnis', fehler: fehler.length > 0 ? fehler : null })
|
||||||
|
}
|
||||||
|
|
||||||
function toast(titel: string, text: string): void {
|
function toast(titel: string, text: string): void {
|
||||||
try {
|
try {
|
||||||
if (!Notification.isSupported()) return
|
if (!Notification.isSupported()) return
|
||||||
|
|||||||
@@ -87,6 +87,11 @@ export class KernVerwaltung {
|
|||||||
webContents.postMessage('kern-port', null, [port2])
|
webContents.postMessage('kern-port', null, [port2])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Eine Nachricht an den Kern (z. B. die Antwort auf 'installer-starten'). */
|
||||||
|
senden(nachricht: HauptNachricht): void {
|
||||||
|
this.kern?.postMessage(nachricht)
|
||||||
|
}
|
||||||
|
|
||||||
zustand(): KernZustand {
|
zustand(): KernZustand {
|
||||||
return {
|
return {
|
||||||
laeuft: this.kern !== null,
|
laeuft: this.kern !== null,
|
||||||
|
|||||||
@@ -329,6 +329,7 @@ export class Pipeline {
|
|||||||
ablageort: fertigOrdner,
|
ablageort: fertigOrdner,
|
||||||
datum: startZeit.toISOString(),
|
datum: startZeit.toISOString(),
|
||||||
dauerS: Math.round(((this.umgebung.jetzt?.() ?? new Date()).getTime() - startZeit.getTime()) / 1000),
|
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,
|
ablageort: ziel,
|
||||||
datum: startZeit.toISOString(),
|
datum: startZeit.toISOString(),
|
||||||
dauerS: Math.round(((this.umgebung.jetzt?.() ?? new Date()).getTime() - startZeit.getTime()) / 1000),
|
dauerS: Math.round(((this.umgebung.jetzt?.() ?? new Date()).getTime() - startZeit.getTime()) / 1000),
|
||||||
|
posterPfad: '',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
fertigMachen({
|
fertigMachen({
|
||||||
@@ -490,6 +492,7 @@ export class Pipeline {
|
|||||||
ablageort: zielDatei,
|
ablageort: zielDatei,
|
||||||
datum: startZeit.toISOString(),
|
datum: startZeit.toISOString(),
|
||||||
dauerS: Math.round(((this.umgebung.jetzt?.() ?? new Date()).getTime() - startZeit.getTime()) / 1000),
|
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 })
|
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,
|
confidence: zuordnung.confidence,
|
||||||
quelle: zuordnung.meta.quelle,
|
quelle: zuordnung.meta.quelle,
|
||||||
posterPfad: zuordnung.meta.posterPfad,
|
posterPfad: zuordnung.meta.posterPfad,
|
||||||
|
backdropPfad: zuordnung.meta.backdropPfad,
|
||||||
|
beschreibung: zuordnung.meta.beschreibung,
|
||||||
// § 6.6: Rippy erkennt beim Einlegen, wenn eine Disc schon einmal
|
// § 6.6: Rippy erkennt beim Einlegen, wenn eine Disc schon einmal
|
||||||
// durchgelaufen ist — und fragt nach, statt stumm zu doppeln.
|
// durchgelaufen ist — und fragt nach, statt stumm zu doppeln.
|
||||||
schonGerippt: db?.bibliothekSchonGerippt(fingerprint) ?? null,
|
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,
|
jahr: jahrAus(vorschlag.typ === 'movie' ? details?.release_date : details?.first_air_date) ?? vorschlag.jahr,
|
||||||
beschreibung: details?.overview ?? '',
|
beschreibung: details?.overview ?? '',
|
||||||
posterPfad: details?.poster_path ?? vorschlag.posterPfad,
|
posterPfad: details?.poster_path ?? vorschlag.posterPfad,
|
||||||
|
backdropPfad: details?.backdrop_path ?? '',
|
||||||
laufzeitMinuten: details?.runtime ?? 0,
|
laufzeitMinuten: details?.runtime ?? 0,
|
||||||
genres: (details?.genres ?? []).map((g) => g.name),
|
genres: (details?.genres ?? []).map((g) => g.name),
|
||||||
quelle: 'tmdb',
|
quelle: 'tmdb',
|
||||||
@@ -197,6 +200,8 @@ async function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): Promise<vo
|
|||||||
confidence: 1,
|
confidence: 1,
|
||||||
quelle: 'deine Wahl',
|
quelle: 'deine Wahl',
|
||||||
posterPfad: zuordnung.meta.posterPfad,
|
posterPfad: zuordnung.meta.posterPfad,
|
||||||
|
backdropPfad: zuordnung.meta.backdropPfad,
|
||||||
|
beschreibung: zuordnung.meta.beschreibung,
|
||||||
schonGerippt: db?.bibliothekSchonGerippt(discFingerprints.get(id) ?? '') ?? null,
|
schonGerippt: db?.bibliothekSchonGerippt(discFingerprints.get(id) ?? '') ?? null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -231,6 +236,8 @@ const laufwerkeBereit: Promise<void> = (async () => {
|
|||||||
confidence: 0,
|
confidence: 0,
|
||||||
quelle: '',
|
quelle: '',
|
||||||
posterPfad: '',
|
posterPfad: '',
|
||||||
|
backdropPfad: '',
|
||||||
|
beschreibung: '',
|
||||||
schonGerippt: null,
|
schonGerippt: null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -385,6 +392,113 @@ async function schluesselLauf(): Promise<void> {
|
|||||||
// Täglich neu prüfen (§ 9.1) — ohne den Prozess-Exit aufzuhalten.
|
// Täglich neu prüfen (§ 9.1) — ohne den Prozess-Exit aufzuhalten.
|
||||||
setInterval(() => void schluesselLauf(), 24 * 60 * 60 * 1000).unref()
|
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
|
// Die gemessene Auskunft bleibt liegen — die Pipeline braucht sie für die
|
||||||
// Preset-Empfehlung, auch wenn gerade kein Fenster offen ist.
|
// Preset-Empfehlung, auch wenn gerade kein Fenster offen ist.
|
||||||
let werkzeugAuskunft: { presets: string[]; backends: string[] } = { presets: [], backends: [] }
|
let werkzeugAuskunft: { presets: string[]; backends: string[] } = { presets: [], backends: [] }
|
||||||
@@ -432,6 +546,11 @@ async function auswerfen(id: string): Promise<void> {
|
|||||||
|
|
||||||
process.parentPort.on('message', (ereignis) => {
|
process.parentPort.on('message', (ereignis) => {
|
||||||
const nachricht: unknown = ereignis.data
|
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') {
|
if (istHauptNachricht(nachricht) && nachricht.art === 'fenster-port') {
|
||||||
// Das Haupt reicht einen frischen Port zum Fenster durch. Ein neues
|
// Das Haupt reicht einen frischen Port zum Fenster durch. Ein neues
|
||||||
// Fenster bringt einen neuen Port — der alte wird geschlossen.
|
// Fenster bringt einen neuen Port — der alte wird geschlossen.
|
||||||
@@ -468,6 +587,10 @@ process.parentPort.on('message', (ereignis) => {
|
|||||||
void werkzeugeSenden()
|
void werkzeugeSenden()
|
||||||
} else if (frage.art === 'bibliothek-laden') {
|
} else if (frage.art === 'bibliothek-laden') {
|
||||||
anFenster({ art: 'bibliothek', eintraege: db?.bibliothekListe() ?? [] })
|
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()
|
fensterPort.start()
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export interface MetaTreffer {
|
|||||||
jahr: number | null
|
jahr: number | null
|
||||||
beschreibung: string
|
beschreibung: string
|
||||||
posterPfad: string
|
posterPfad: string
|
||||||
|
/** TMDb-Hintergrundbild (Querformat) — fürs Kachel-Panorama; '' wenn keins. */
|
||||||
|
backdropPfad: string
|
||||||
laufzeitMinuten: number
|
laufzeitMinuten: number
|
||||||
genres: string[]
|
genres: string[]
|
||||||
quelle: 'tmdb' | 'omdb' | ''
|
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),
|
jahr: jahrAus(typ === 'movie' ? details.release_date : details.first_air_date),
|
||||||
beschreibung: details.overview ?? '',
|
beschreibung: details.overview ?? '',
|
||||||
posterPfad: details.poster_path ?? '',
|
posterPfad: details.poster_path ?? '',
|
||||||
|
backdropPfad: details.backdrop_path ?? '',
|
||||||
laufzeitMinuten: details.runtime ?? 0,
|
laufzeitMinuten: details.runtime ?? 0,
|
||||||
genres: (details.genres ?? []).map((g) => g.name),
|
genres: (details.genres ?? []).map((g) => g.name),
|
||||||
quelle: 'tmdb',
|
quelle: 'tmdb',
|
||||||
@@ -65,6 +68,8 @@ function ausOmdb(treffer: OmdbTreffer): MetaTreffer {
|
|||||||
jahr: treffer.year,
|
jahr: treffer.year,
|
||||||
beschreibung: treffer.overview,
|
beschreibung: treffer.overview,
|
||||||
posterPfad: treffer.poster_path,
|
posterPfad: treffer.poster_path,
|
||||||
|
// OMDb kennt keine Querformat-Bilder — die Kachel fällt aufs Poster zurück.
|
||||||
|
backdropPfad: '',
|
||||||
laufzeitMinuten: treffer.runtime,
|
laufzeitMinuten: treffer.runtime,
|
||||||
genres: treffer.genres,
|
genres: treffer.genres,
|
||||||
quelle: 'omdb',
|
quelle: 'omdb',
|
||||||
@@ -82,6 +87,7 @@ const UNBEKANNT = (titel: string): Zuordnung => ({
|
|||||||
jahr: null,
|
jahr: null,
|
||||||
beschreibung: '',
|
beschreibung: '',
|
||||||
posterPfad: '',
|
posterPfad: '',
|
||||||
|
backdropPfad: '',
|
||||||
laufzeitMinuten: 0,
|
laufzeitMinuten: 0,
|
||||||
genres: [],
|
genres: [],
|
||||||
quelle: '',
|
quelle: '',
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { mkdirSync } from 'node:fs'
|
|||||||
import { dirname } from 'node:path'
|
import { dirname } from 'node:path'
|
||||||
import type { BibliothekEintrag } from '../../gemeinsam/nachrichten'
|
import type { BibliothekEintrag } from '../../gemeinsam/nachrichten'
|
||||||
|
|
||||||
export const SCHEMA_VERSION = 2
|
export const SCHEMA_VERSION = 3
|
||||||
|
|
||||||
export class Datenbank {
|
export class Datenbank {
|
||||||
readonly pfad: string
|
readonly pfad: string
|
||||||
@@ -46,9 +46,16 @@ export class Datenbank {
|
|||||||
groesse_bytes INTEGER NOT NULL,
|
groesse_bytes INTEGER NOT NULL,
|
||||||
ablageort TEXT NOT NULL,
|
ablageort TEXT NOT NULL,
|
||||||
datum 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
|
this.db
|
||||||
.prepare(`INSERT OR REPLACE INTO meta (schluessel, wert) VALUES ('schema_version', ?)`)
|
.prepare(`INSERT OR REPLACE INTO meta (schluessel, wert) VALUES ('schema_version', ?)`)
|
||||||
.run(String(SCHEMA_VERSION))
|
.run(String(SCHEMA_VERSION))
|
||||||
@@ -81,8 +88,8 @@ export class Datenbank {
|
|||||||
this.db
|
this.db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT OR REPLACE INTO bibliothek
|
`INSERT OR REPLACE INTO bibliothek
|
||||||
(fingerprint, titel, jahr, typ, groesse_bytes, ablageort, datum, dauer_s)
|
(fingerprint, titel, jahr, typ, groesse_bytes, ablageort, datum, dauer_s, poster_pfad)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
)
|
)
|
||||||
.run(
|
.run(
|
||||||
eintrag.fingerprint,
|
eintrag.fingerprint,
|
||||||
@@ -93,6 +100,7 @@ export class Datenbank {
|
|||||||
eintrag.ablageort,
|
eintrag.ablageort,
|
||||||
eintrag.datum,
|
eintrag.datum,
|
||||||
eintrag.dauerS,
|
eintrag.dauerS,
|
||||||
|
eintrag.posterPfad,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +117,7 @@ export class Datenbank {
|
|||||||
ablageort: String(z.ablageort),
|
ablageort: String(z.ablageort),
|
||||||
datum: String(z.datum),
|
datum: String(z.datum),
|
||||||
dauerS: Number(z.dauer_s),
|
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
|
gueltigBis: string
|
||||||
geholtAm: string
|
geholtAm: string
|
||||||
text: 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 →
|
/** Die ganze Kette: Dauerlizenz → Forum-Key → ablegen → PRÜFEN →
|
||||||
@@ -185,6 +187,7 @@ export async function schluesselKette(
|
|||||||
text: urteil.ok
|
text: urteil.ok
|
||||||
? `Eigener Schlüssel hinterlegt und von MakeMKV${urteil.version.length > 0 ? ` ${urteil.version}` : ''} angenommen.`
|
? `Eigener Schlüssel hinterlegt und von MakeMKV${urteil.version.length > 0 ? ` ${urteil.version}` : ''} angenommen.`
|
||||||
: `Eigener Schlüssel abgelehnt: ${urteil.grund}`,
|
: `Eigener Schlüssel abgelehnt: ${urteil.grund}`,
|
||||||
|
version: urteil.version,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +205,7 @@ export async function schluesselKette(
|
|||||||
text: urteil.ok
|
text: urteil.ok
|
||||||
? 'Forum nicht erreichbar — der hinterlegte Schlüssel funktioniert weiter.'
|
? 'Forum nicht erreichbar — der hinterlegte Schlüssel funktioniert weiter.'
|
||||||
: `Forum nicht erreichbar und der hinterlegte Schlüssel taugt nicht: ${urteil.grund}`,
|
: `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). ' +
|
'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.'
|
'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.`,
|
: `Beta-Key abgelehnt (${urteil.grund}) — wieder entfernt, damit MakeMKV im Beta-Modus weiterläuft.`,
|
||||||
|
version: urteil.version,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// Die MakeMKV-Beschaffungskette (§ 9) — Fixtures aus den Live-Messungen
|
||||||
|
// vom 31.08.2026: Hersteller-Seite HTTP 525, Forum 1.18.4 in 0,2 s,
|
||||||
|
// Archiv 1.18.2 in 7,8 s.
|
||||||
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
dateiPruefen,
|
||||||
|
hoechsteVersion,
|
||||||
|
istNeuer,
|
||||||
|
makemkvInstallerHolen,
|
||||||
|
makemkvNeuesteVersion,
|
||||||
|
makemkvQuellen,
|
||||||
|
versionsquellen,
|
||||||
|
} from '../src/kern/werkzeuge/beschaffen'
|
||||||
|
import { istFensterNachricht, istHauptNachricht, istKernNachricht } from '../src/gemeinsam/nachrichten'
|
||||||
|
|
||||||
|
const aufraeumen: string[] = []
|
||||||
|
afterEach(() => {
|
||||||
|
for (const ordner of aufraeumen.splice(0).reverse()) {
|
||||||
|
rmSync(ordner, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function tempOrdner(): string {
|
||||||
|
const ordner = mkdtempSync(join(tmpdir(), 'rippy-beschaffung-'))
|
||||||
|
aufraeumen.push(ordner)
|
||||||
|
return ordner
|
||||||
|
}
|
||||||
|
|
||||||
|
function antwort(body: string | Buffer, status = 200): Response {
|
||||||
|
return new Response(typeof body === 'string' ? body : new Uint8Array(body), { status })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('hoechsteVersion', () => {
|
||||||
|
it('vergleicht numerisch — als Zeichenkette wäre 1.9.2 größer als 1.18.4', () => {
|
||||||
|
expect(hoechsteVersion('MakeMKV v1.9.2 … MakeMKV 1.18.4 … MakeMKV v1.18.2')).toBe('1.18.4')
|
||||||
|
})
|
||||||
|
it('leerer Text → leere Antwort', () => {
|
||||||
|
expect(hoechsteVersion('')).toBe('')
|
||||||
|
expect(hoechsteVersion('nichts hier')).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('istNeuer', () => {
|
||||||
|
it('numerisch je Stelle', () => {
|
||||||
|
expect(istNeuer('1.18.4', '1.18.2')).toBe(true)
|
||||||
|
expect(istNeuer('1.9.2', '1.18.4')).toBe(false)
|
||||||
|
expect(istNeuer('1.18.4', '1.18.4')).toBe(false)
|
||||||
|
expect(istNeuer('1.18.4.1', '1.18.4')).toBe(true)
|
||||||
|
})
|
||||||
|
it('Unbekanntes ist nie neuer — außer gegenüber gar nichts', () => {
|
||||||
|
expect(istNeuer('', '1.0.0')).toBe(false)
|
||||||
|
expect(istNeuer('1.0.0', '')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Quellenketten', () => {
|
||||||
|
it('Versionsquellen: der Hersteller zuerst, und nur er ist maßgeblich', () => {
|
||||||
|
const quellen = versionsquellen()
|
||||||
|
expect(quellen).toHaveLength(3)
|
||||||
|
expect(quellen[0].name).toBe('Hersteller-Seite')
|
||||||
|
expect(quellen[0].massgeblich).toBe(true)
|
||||||
|
expect(quellen.slice(1).every((q) => !q.massgeblich)).toBe(true)
|
||||||
|
// Zeitstempel in der Vergangenheit — sonst holt das Archiv live nach.
|
||||||
|
expect(quellen[2].url).toContain('web.archive.org/web/2025/')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Datei-Quellen: Hersteller → Archiv (mit id_ für die ROHE Datei)', () => {
|
||||||
|
const quellen = makemkvQuellen('1.18.4')
|
||||||
|
expect(quellen.map(([name]) => name)).toEqual(['Hersteller', 'Internet Archive'])
|
||||||
|
expect(quellen[0][1]).toBe('https://www.makemkv.com/download/Setup_MakeMKV_v1.18.4.exe')
|
||||||
|
expect(quellen[1][1]).toBe(
|
||||||
|
'https://web.archive.org/web/2025id_/https://www.makemkv.com/download/Setup_MakeMKV_v1.18.4.exe',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('eine eigene Basis steht davor — wer sie einträgt, will sie', () => {
|
||||||
|
const quellen = makemkvQuellen('1.18.4', 'https://mein-server.example/mkv/')
|
||||||
|
expect(quellen[0]).toEqual(['eigene Quelle', 'https://mein-server.example/mkv/Setup_MakeMKV_v1.18.4.exe'])
|
||||||
|
expect(quellen).toHaveLength(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('makemkvNeuesteVersion', () => {
|
||||||
|
it('der Hersteller ist maßgeblich — antwortet er, wird nicht weitergefragt', async () => {
|
||||||
|
const gefragt: string[] = []
|
||||||
|
const holen = (async (url: string | URL | Request) => {
|
||||||
|
gefragt.push(String(url))
|
||||||
|
return antwort('<html>MakeMKV 1.19.0 is out</html>')
|
||||||
|
}) as typeof fetch
|
||||||
|
expect(await makemkvNeuesteVersion(holen)).toBe('1.19.0')
|
||||||
|
expect(gefragt).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Hersteller tot (525, wie gemessen) → höchste aus Forum und Archiv', async () => {
|
||||||
|
const holen = (async (url: string | URL | Request) => {
|
||||||
|
const wo = String(url)
|
||||||
|
if (wo.includes('www.makemkv.com')) return antwort('cloudflare', 525)
|
||||||
|
if (wo.includes('forum')) return antwort('MakeMKV v1.18.4 released')
|
||||||
|
return antwort('MakeMKV v1.18.2 … MakeMKV v1.18.1')
|
||||||
|
}) as typeof fetch
|
||||||
|
expect(await makemkvNeuesteVersion(holen)).toBe('1.18.4')
|
||||||
|
}, 15_000)
|
||||||
|
|
||||||
|
it('keine Quelle → leere Antwort statt Wurf', async () => {
|
||||||
|
expect(await makemkvNeuesteVersion(undefined, [])).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('dateiPruefen', () => {
|
||||||
|
it('ohne MZ-Kopf ist es kein Windows-Programm', () => {
|
||||||
|
const ordner = tempOrdner()
|
||||||
|
const pfad = join(ordner, 'seite.exe')
|
||||||
|
writeFileSync(pfad, '<html>Fehlerseite</html>')
|
||||||
|
const urteil = dateiPruefen(pfad, '1.18.4')
|
||||||
|
expect(urteil.ok).toBe(false)
|
||||||
|
expect(urteil.grund).toContain('MZ')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unter Windows zählt die Versions-Ressource (gemessene Felder)', () => {
|
||||||
|
const ordner = tempOrdner()
|
||||||
|
const pfad = join(ordner, 'setup.exe')
|
||||||
|
writeFileSync(pfad, 'MZ\x90rest')
|
||||||
|
// Die am 31.08.2026 an der echten Datei gemessenen Werte:
|
||||||
|
const echt = { firma: 'GuinpinSoft inc', produkt: 'MakeMKV', version: 'v1.18.4' }
|
||||||
|
expect(dateiPruefen(pfad, '1.18.4', () => echt, 'win32').ok).toBe(true)
|
||||||
|
expect(dateiPruefen(pfad, '1.18.4', () => ({ ...echt, firma: 'Boese GmbH' }), 'win32').ok).toBe(false)
|
||||||
|
expect(dateiPruefen(pfad, '1.19.0', () => echt, 'win32').ok).toBe(false)
|
||||||
|
// Auf Linux (CI) gibt es keine Versions-Ressourcen — MZ-Test muss reichen.
|
||||||
|
expect(dateiPruefen(pfad, '1.18.4', () => echt, 'linux').ok).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('makemkvInstallerHolen', () => {
|
||||||
|
const GROSS = Buffer.concat([Buffer.from('MZ'), Buffer.alloc(1024 * 1024 + 100, 7)])
|
||||||
|
|
||||||
|
it('Fehlerseite mit HTTP 200 fällt durch die Mindestgröße — die nächste Quelle gewinnt', async () => {
|
||||||
|
const ordner = tempOrdner()
|
||||||
|
const holen = (async (url: string | URL | Request) =>
|
||||||
|
String(url).includes('web.archive.org') ? antwort(GROSS) : antwort('<html>kaputt</html>')) as typeof fetch
|
||||||
|
const ergebnis = await makemkvInstallerHolen(
|
||||||
|
'1.18.4',
|
||||||
|
() => {},
|
||||||
|
'',
|
||||||
|
holen,
|
||||||
|
ordner,
|
||||||
|
(p, v) => dateiPruefen(p, v, () => ({ firma: 'GuinpinSoft inc', produkt: 'MakeMKV', version: 'v1.18.4' }), 'win32'),
|
||||||
|
)
|
||||||
|
expect(ergebnis.quelle).toBe('Internet Archive')
|
||||||
|
expect(readFileSync(ergebnis.pfad).length).toBe(GROSS.length)
|
||||||
|
expect(existsSync(`${ergebnis.pfad}.neu`)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('liefert keine Quelle, steht im Wurf der Bericht ALLER Versuche', async () => {
|
||||||
|
const ordner = tempOrdner()
|
||||||
|
const holen = (async () => antwort('weg', 404)) as typeof fetch
|
||||||
|
await expect(makemkvInstallerHolen('1.18.4', () => {}, '', holen, ordner)).rejects.toThrow(
|
||||||
|
/Hersteller: HTTP 404[\s\S]*Internet Archive: HTTP 404[\s\S]*makemkv\.com/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Nachrichten-Schema der Update-Strecke', () => {
|
||||||
|
it('Fenster → Kern: makemkv-update und schluessel-pruefen', () => {
|
||||||
|
expect(istFensterNachricht({ art: 'makemkv-update' })).toBe(true)
|
||||||
|
expect(istFensterNachricht({ art: 'schluessel-pruefen' })).toBe(true)
|
||||||
|
})
|
||||||
|
it('Haupt → Kern: installer-ergebnis', () => {
|
||||||
|
expect(istHauptNachricht({ art: 'installer-ergebnis', fehler: null })).toBe(true)
|
||||||
|
expect(istHauptNachricht({ art: 'installer-ergebnis', fehler: 'UAC abgelehnt' })).toBe(true)
|
||||||
|
expect(istHauptNachricht({ art: 'installer-ergebnis', fehler: 5 })).toBe(false)
|
||||||
|
})
|
||||||
|
it('Kern → Haupt/Fenster: beschaffung-status und installer-starten', () => {
|
||||||
|
expect(
|
||||||
|
istKernNachricht({
|
||||||
|
art: 'beschaffung-status',
|
||||||
|
stand: { werkzeug: 'makemkv', laeuft: true, text: 'lädt', anteil: 0.5, fehler: false },
|
||||||
|
}),
|
||||||
|
).toBe(true)
|
||||||
|
expect(istKernNachricht({ art: 'installer-starten', pfad: 'C:\\x\\setup.exe' })).toBe(true)
|
||||||
|
expect(istKernNachricht({ art: 'installer-starten', pfad: '' })).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user