feat(v5): 5.5.0 — Kern und Oberfläche aufgeteilt, Übersicht und Einstellungen neu, Erst-Einrichtung mit Poster-Kulisse

Commander-Wünsche vom 02.09.2026 vor der Freigabe von 5.4.0 (die deshalb nie
veröffentlicht wurde):

Kern: kern/index.ts ist nur noch Bootstrap und Router; Dienste in
kern/dienste/ (Einstellungen, Werkzeuge, Laufwerke, Erkennung, Vorgänge,
Speicher, Benachrichtigung, Automatik), Kontext in kern/kontext.ts,
Protokoll-Datei in gemeinsam/protokoll.ts, Platz-Rechnung in gemeinsam/platz.ts.

Oberfläche: fenster/App.tsx ist nur noch der Rahmen; bausteine.tsx, dialoge/,
uebersicht/ (LaufwerkKachel, KompressionsKarte, SpeicherKarte, VerlaufKarte),
einstellungen/ (Karten je Funktion, SprachAuswahl), einrichtung/
(ErstEinrichtung, PosterHintergrund).

Neu: Bibliothek weg, dafür Verlauf; Speicher-und-Bilanz-Karte (statfs);
LED-Fußleiste; MakeMKV-Kopf in die Einstellungen; Sprach-Chips mit Dropdown
statt Allgemeinem Preset; Karten Automatik/Benachrichtigung/Allgemein/Update;
Durchsuchen bei Werkzeugen; Arbeitsordner lokal (Encode dort, dann in die
Ablage); Platz-Prüfung vor dem Rip (Dialog und Kern); Automatik mit Countdown;
Serien-Merker über Discs; Rettungs-Abbild mit Nullen nur nach gesehenen
Lesefehlern (kern/rip/rettung.ts, Phase rettet, iso:-Quelle); Warteschlange
pausieren; Discord/Telegram-Benachrichtigung; Protokoll-Datei je Tag;
Fensterlage 80 % mittig, gemerkt; Erst-Einrichtung in acht Schritten vor dem
Poster-Grid.

Beweise: 413 Tests (neu: speicher, benachrichtigung, automatik, rettung,
fensterlage, protokoll, kleinkram-550), drei Typprüfungen, Klick-Beweis mit
29 Schritten inklusive Erst-Einrichtung (zweiter Start mit
RIPPY_START_BEREICH=einrichtung), Bilder beweise/klick/01…09.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-02 17:51:15 +02:00
co-authored by Claude Fable 5.1
parent 6678c81592
commit 3dc6798c79
67 changed files with 6961 additions and 3950 deletions
@@ -0,0 +1,262 @@
// ── Erst-Einrichtung (§ 6.9): nach der Installation, im Programm ────────
// Zwei Regeln aus dem alten Setup: NUR ein Fehler blockiert (kein
// Laufwerk ist eine WARNUNG — eine reine Komprimier-Maschine ist ein
// vorgesehener Betriebsfall), und Erfolg wird am BESTAND gemessen.
// 5.5.0: dieselben Bausteine wie die Einstellungen (Commander: „All diese
// Dinge müssen auch in den FirstRun mit rein") — Ablage und Arbeitsordner,
// Werkzeuge mit Durchsuchen, Kompression mit Sprach-Auswahl, TMDb,
// Benachrichtigung, Automatik und Roh-Regel. Dazu der Poster-Hintergrund.
import { useEffect, useState } from 'react'
import { DEFAULT_HB_PRESET, presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
import { Badge, FELD_KLASSE, Feld, KNOPF_GOLD, KNOPF_LEISE, PfadFeld, Schalter, gbKurz } from '../bausteine'
import { SprachAuswahl } from '../einstellungen/SprachAuswahl'
import { benachrichtigungTesten, einstellungSetzen, posterLaden, speicherLaden, type Verbindung } from '../kernverbindung'
import { PosterHintergrund } from './PosterHintergrund'
const SCHRITTE = ['Willkommen', 'Prüfung', 'Ordner', 'Qualität', 'Titel', 'Meldungen', 'Automatik', 'Fertig']
export function ErstEinrichtung({ verbindung }: { verbindung: Verbindung }) {
const [schritt, setSchritt] = useState(0)
const werte = verbindung.einstellungen
const werkzeuge = verbindung.werkzeuge
const speicher = verbindung.speicher
const setzen = einstellungSetzen
const makemkvFehlt = werkzeuge !== null && werkzeuge.makemkvcon.length === 0
const tmdbDa = (werte['tmdbApiKey'] ?? '').length > 0
// Poster, sobald ein TMDb-Schlüssel da ist — auch, wenn er gerade erst
// eingetragen wurde.
useEffect(() => {
if (tmdbDa) posterLaden()
}, [tmdbDa])
useEffect(() => {
speicherLaden()
}, [schritt])
const pruefzeilen: Array<{ ok: boolean | null; pflicht: boolean; text: string }> = [
{
ok: werkzeuge === null ? null : werkzeuge.makemkvcon.length > 0,
pflicht: true,
text:
werkzeuge === null
? 'MakeMKV wird gesucht …'
: werkzeuge.makemkvcon.length > 0
? `MakeMKV gefunden: ${werkzeuge.makemkvcon}`
: 'MakeMKV FEHLT — ohne makemkvcon kann Rippy keine Video-Disc lesen. Von makemkv.com/download installieren, dann findet Rippy es von selbst — oder unten den Pfad wählen.',
},
{
ok: werkzeuge === null ? null : werkzeuge.handbrake.length > 0,
pflicht: false,
text: werkzeuge === null ? 'HandBrakeCLI wird gesucht …' : werkzeuge.handbrake.length > 0 ? `HandBrakeCLI dabei: ${werkzeuge.handbrake}` : 'HandBrakeCLI fehlt — Rippen geht, Komprimieren nicht.',
},
{
ok: werkzeuge === null ? null : werkzeuge.flac.length > 0,
pflicht: false,
text: werkzeuge === null ? 'FLAC wird gesucht …' : werkzeuge.flac.length > 0 ? `FLAC dabei: ${werkzeuge.flac}` : 'FLAC fehlt — nur für Audio-CDs nötig.',
},
{
ok: verbindung.laufwerke.length > 0 ? true : null,
pflicht: false,
text:
verbindung.laufwerke.length > 0
? `${verbindung.laufwerke.length} optisches Laufwerk gefunden (${verbindung.laufwerke.map((l) => l.id + ':').join(', ')})`
: 'Kein optisches Laufwerk — nur eine Warnung: Eine reine Komprimier-Maschine ist ein vorgesehener Betriebsfall.',
},
]
// Was Rippy an DIESER Maschine gemessen hat — die Grundlage der
// Preset-Empfehlung (Commander-Fund 01.09.2026: sie war unsichtbar).
const backends = werkzeuge?.backends ?? []
const presets = werkzeuge?.presets ?? []
const nurCpu = backends.length > 0 && backends.every((b) => b.startsWith('cpu'))
const EMPFEHLUNGEN: ReadonlyArray<readonly [string, string]> = [
['DVD', 'dvd'],
['Blu-ray', 'bluray'],
['4K-UHD', 'uhd'],
]
return (
<>
<PosterHintergrund pfade={verbindung.poster} />
<div className="relative mx-auto max-w-2xl space-y-7 rounded-2xl border border-white/10 bg-buehne/80 p-6 pt-5 shadow-2xl shadow-black/60 backdrop-blur">
<div className="flex flex-wrap gap-2">
{SCHRITTE.map((name, i) => (
<span
key={name}
className={`rounded-full px-3 py-1 text-xs ${i === schritt ? 'border border-gold/40 bg-gold/15 text-gold-hell' : i < schritt ? 'text-gruen' : 'text-nebel'}`}
>
{i + 1} {name}
</span>
))}
</div>
{schritt === 0 && (
<div className="space-y-3 text-sm text-dunst">
<h2 className="font-display text-3xl font-bold text-schnee">Willkommen bei Rippy</h2>
<p>Rippy sichert deine DVDs, Blu-rays und Audio-CDs erst verlustfrei, dann komprimiert.</p>
<p>Disc rein genügt: Rippy erkennt, was drauf ist, und startet bei sicherer Erkennung von selbst.</p>
<p>Fertige Filme landen so abgelegt, dass Jellyfin, Emby, Kodi und Plex sie sofort erkennen.</p>
<p>Rippy prüft gleich seine Werkzeuge und fragt dich danach ein paar Dinge: wohin, wie schnell, woher die Titel kommen, wie es sich meldet. Dauert zwei Minuten ändern kannst du alles später.</p>
</div>
)}
{schritt === 1 && (
<div className="space-y-3">
<div className="space-y-2">
{pruefzeilen.map((zeile, i) => (
<p key={i} className={`font-mono text-xs ${zeile.ok === true ? 'text-gruen' : zeile.ok === null ? 'text-nebel' : zeile.pflicht ? 'text-rot' : 'text-warn'}`}>
{zeile.ok === true ? '✓' : zeile.ok === null ? '…' : zeile.pflicht ? '✗' : '⚠'} {zeile.text}
</p>
))}
</div>
{makemkvFehlt && (
<PfadFeld
name="makemkvcon.exe von Hand wählen"
hinweis="falls MakeMKV an einem ungewöhnlichen Ort liegt"
wert={werte['werkzeug.makemkv'] ?? ''}
art="programm"
dialogTitel="makemkvcon.exe wählen"
aufAendern={(w) => setzen('werkzeug.makemkv', w)}
/>
)}
</div>
)}
{schritt === 2 && (
<div className="space-y-4">
<p className="text-sm text-dunst">Wohin sollen fertige Filme, Musik und Abbilder? Ein Netzlaufwerk geht Rippy baut darin selbst die Struktur.</p>
<PfadFeld name="Ablage-Ordner" wert={werte['ablage'] ?? ''} art="ordner" dialogTitel="Ablage-Ordner wählen" aufAendern={(w) => setzen('ablage', w)} />
{speicher !== null && speicher.ablage.ok && <p className="font-mono text-[10.5px] text-nebel">{gbKurz(speicher.ablage.freiBytes)} frei von {gbKurz(speicher.ablage.gesamtBytes)}</p>}
<p className="text-sm text-dunst">
Liegt die Ablage auf einem Server, wähle einen <span className="text-schnee">Arbeitsordner auf einer lokalen Platte</span>: Dort liegen die Roh-Dateien
(30 GB und mehr je Blu-ray) und dort rechnet HandBrake. Nur das fertige Ergebnis wandert übers Netz.
</p>
<PfadFeld
name="Arbeitsordner (lokal, optional)"
hinweis="leer = unter der Ablage"
wert={werte['arbeitsordner'] ?? ''}
art="ordner"
dialogTitel="Arbeitsordner wählen (lokale Platte)"
aufAendern={(w) => setzen('arbeitsordner', w)}
/>
{speicher !== null && speicher.getrennt && speicher.arbeit.ok && (
<p className="font-mono text-[10.5px] text-nebel">{gbKurz(speicher.arbeit.freiBytes)} frei von {gbKurz(speicher.arbeit.gesamtBytes)} eine Blu-ray braucht rund 50 GB.</p>
)}
</div>
)}
{schritt === 3 && (
<div className="space-y-4">
<p className="text-sm text-dunst">Rippy sichert eine Disc immer erst verlustfrei. Danach kann es eine kleinere Fassung berechnen die kostet Zeit, spart aber viel Platz.</p>
<Schalter an={werte['transcodeEnabled'] !== 'false'} aufWechsel={(an) => setzen('transcodeEnabled', an ? 'true' : 'false')} label="Nach dem Rip automatisch komprimieren" />
<div className="rounded-xl border border-white/8 bg-samt/50 p-3">
{werkzeuge === null ? (
<p className="font-mono text-xs text-nebel">Encoder werden gemessen </p>
) : backends.length === 0 ? (
<p className="font-mono text-xs text-warn">Keine Encoder gemessen ohne HandBrakeCLI bleibt es beim verlustfreien Rip.</p>
) : (
<>
<div className="flex flex-wrap items-center gap-1.5">
<span className="font-mono text-[10px] tracking-wider text-nebel">AUF DIESER MASCHINE GEMESSEN:</span>
{backends.map((backend) => (
<Badge key={backend} text={backend} ton={backend.startsWith('cpu') ? 'grau' : 'blau'} />
))}
</div>
<p className="mt-2 text-xs text-dunst">Danach wählt Rippy von selbst Grafikkarte vor CPU, wo es eine gibt:</p>
<div className="mt-2 grid gap-2 sm:grid-cols-3">
{EMPFEHLUNGEN.map(([name, typ]) => (
<div key={typ} className="rounded-lg border border-white/8 bg-buehne/60 p-2">
<p className="font-mono text-[10px] tracking-wider text-nebel">{name}</p>
<p className="mt-0.5 font-mono text-xs text-gold-hell">{presetEmpfehlung(typ, backends, presets) || DEFAULT_HB_PRESET}</p>
</div>
))}
</div>
{nurCpu && <p className="mt-2 text-xs text-warn">Nur CPU-Encoder gefunden das Komprimieren dauert dann deutlich länger. Der verlustfreie Rip ist davon nicht betroffen.</p>}
</>
)}
</div>
<div className="grid gap-3 sm:grid-cols-2">
<SprachAuswahl name="Tonspuren" hinweis="die erste ist deine Sprache" wert={werte['audioSprachen'] ?? ''} aufAendern={(w) => setzen('audioSprachen', w)} />
<SprachAuswahl name="Untertitel" hinweis="vorgewählt bei fremdem Ton" wert={werte['untertitelSprachen'] ?? ''} aufAendern={(w) => setzen('untertitelSprachen', w)} />
</div>
<p className="text-xs text-nebel">Je Disc-Typ ein eigenes Preset geht später unter Einstellungen Kompression" — deine Wahl schlägt die Empfehlung immer.</p>
</div>
)}
{schritt === 4 && (
<div className="space-y-3">
<p className="text-sm text-dunst">Woher sollen Titel, Jahr, Poster und Beschreibung kommen? Dafür braucht Rippy einen kostenlosen TMDb-Schlüssel.</p>
<Feld name="TMDb-API-Key" hinweis="kostenlos auf themoviedb.org Einstellungen API; in zwei Minuten geholt" wert={werte['tmdbApiKey'] ?? ''} aufAendern={(w) => setzen('tmdbApiKey', w)} />
{tmdbDa && verbindung.poster.length > 0 && <p className="font-mono text-xs text-gruen">✓ Der Schlüssel funktioniert — die Poster im Hintergrund kommen von TMDb.</p>}
<p className="text-xs text-nebel">Ohne Schlüssel rippt Rippy trotzdem — die Filme heißen dann so, wie die Disc sich selbst nennt, und es gibt keine Poster. Nachtragen geht jederzeit unter „Einstellungen → Metadaten".</p>
</div>
)}
{schritt === 5 && (
<div className="space-y-3">
<p className="text-sm text-dunst">Soll Rippy sich aufs Handy melden, wenn ein Rip fertig ist oder etwas schiefgeht? Windows-Meldungen kommen immer das hier ist freiwillig.</p>
<Feld name="Discord-Webhook" hinweis="Servereinstellungen → Integrationen → Webhooks" geheim wert={werte['discordWebhook'] ?? ''} aufAendern={(w) => setzen('discordWebhook', w)} />
<div className="grid gap-3 sm:grid-cols-2">
<Feld name="Telegram-Bot-Token" hinweis="von @BotFather" geheim wert={werte['telegramToken'] ?? ''} aufAendern={(w) => setzen('telegramToken', w)} />
<Feld name="Telegram-Chat-ID" wert={werte['telegramChatId'] ?? ''} aufAendern={(w) => setzen('telegramChatId', w)} />
</div>
<button onClick={() => benachrichtigungTesten()} className={KNOPF_LEISE}>
Testnachricht schicken
</button>
</div>
)}
{schritt === 6 && (
<div className="space-y-4">
<p className="text-sm text-dunst">Wie selbstständig soll Rippy sein?</p>
<Schalter an={werte['automatik'] !== 'aus'} aufWechsel={(an) => setzen('automatik', an ? 'an' : 'aus')} label="Automatik: bei sicherer Erkennung (ab 90 %) nach einem Countdown von selbst rippen — mit Stopp-Knopf" />
<label className="block max-w-xs">
<span className="text-xs font-semibold text-dunst">Countdown</span>
<select value={werte['automatikSekunden'] ?? '30'} onChange={(e) => setzen('automatikSekunden', e.target.value)} className={FELD_KLASSE}>
<option value="10">10 Sekunden</option>
<option value="30">30 Sekunden (Vorgabe)</option>
<option value="60">1 Minute</option>
<option value="120">2 Minuten</option>
</select>
</label>
<Schalter an={werte['autoAuswurf'] !== 'false'} aufWechsel={(an) => setzen('autoAuswurf', an ? 'true' : 'false')} label="Disc nach dem Rip auswerfen — die nächste kann rein, während die Kompression läuft" />
<label className="block max-w-sm">
<span className="text-xs font-semibold text-dunst">Roh-Dateien nach gelungener Kompression</span>
<select value={werte['rohAufbewahrung'] ?? '7'} onChange={(e) => setzen('rohAufbewahrung', e.target.value)} className={FELD_KLASSE}>
<option value="sofort">sofort löschen</option>
<option value="7">nach 7 Tagen löschen (Vorgabe)</option>
<option value="14">nach 14 Tagen löschen</option>
<option value="30">nach 30 Tagen löschen</option>
<option value="behalten">behalten nur von Hand löschen</option>
</select>
</label>
<p className="text-xs text-nebel">Die Roh-Dateien sind der Grund, warum ein Preset-Wechsel kein Neulesen kostet und gelöscht wird nur, wenn das fertige Ergebnis nachweislich da ist.</p>
</div>
)}
{schritt === 7 && (
<div className="space-y-3 text-sm text-dunst">
<p>Das war's. Disc einlegen — Rippy meldet sich.</p>
<p className="text-xs text-nebel">Rippy lebt im Infobereich neben der Uhr: Fenster zu heißt weiterlaufen, beendet wird über das Tray-Menü.</p>
{makemkvFehlt && <p className="text-xs text-rot">Denk an MakeMKV: Ohne makemkvcon bleibt der Video-Weg zu (Einstellungen → Werkzeuge zeigt den Stand, sobald es installiert ist).</p>}
</div>
)}
<div className="flex justify-between">
<button onClick={() => setSchritt((s) => Math.max(0, s - 1))} disabled={schritt === 0} className="rounded-lg border border-white/15 px-4 py-1.5 text-xs text-dunst hover:text-schnee disabled:opacity-35">
Zurück
</button>
{schritt < SCHRITTE.length - 1 ? (
<button onClick={() => setSchritt((s) => s + 1)} className={KNOPF_GOLD}>
Weiter
</button>
) : (
<button onClick={() => setzen('setup.done', 'true')} className={KNOPF_GOLD}>
Los geht's
</button>
)}
</div>
</div>
</>
)
}
@@ -0,0 +1,51 @@
// Der Poster-Hintergrund der Erst-Einrichtung (5.5.0, Commander: „diverse
// Movie Cover die … im Hintergrund in nem Grid langsam von oben nach unten
// laufen"). Vor dem TMDb-Schlüssel ein ruhiges Raster leerer Film-Karten,
// danach echte Poster beliebter Filme (kern: erkennung.posterLaden). Die
// Spalten laufen langsam, jede in eigenem Tempo, die Liste ist verdoppelt,
// damit der Lauf nahtlos ist (Keyframes in stil.css: poster-lauf).
import { IconDisc, posterUrlGross } from '../bausteine'
const SPALTEN = 7
const JE_SPALTE = 6
export function PosterHintergrund({ pfade }: { pfade: string[] }) {
const spalten: string[][] = Array.from({ length: SPALTEN }, () => [])
pfade.forEach((p, i) => spalten[i % SPALTEN].push(p))
return (
<div className="pointer-events-none fixed inset-0 overflow-hidden" aria-hidden="true">
<div className="absolute inset-0 flex gap-4 px-4 opacity-[0.5]">
{spalten.map((spalte, i) => {
// Auffüllen mit leeren Karten, damit jede Spalte voll ist.
const karten: Array<string | null> = [...spalte]
while (karten.length < JE_SPALTE) karten.push(null)
const doppelt = [...karten, ...karten]
return (
<div
key={i}
className="poster-spalte flex flex-1 flex-col gap-4"
style={{
animationDuration: `${70 + (i % 3) * 25}s`,
animationDelay: `${-i * 9}s`,
marginTop: `${(i % 2) * -120}px`,
}}
>
{doppelt.map((p, j) =>
p !== null ? (
<img key={j} src={posterUrlGross(p)} alt="" className="aspect-[2/3] w-full rounded-xl object-cover ring-1 ring-gold/25" loading="lazy" />
) : (
<div key={j} className="flex aspect-[2/3] w-full items-center justify-center rounded-xl bg-gradient-to-br from-[oklch(0.27_0.03_75)] to-[oklch(0.19_0.02_75)] ring-1 ring-gold/30">
<IconDisc groesse={28} farbe="oklch(0.6 0.08 75)" />
</div>
),
)}
</div>
)
})}
</div>
{/* Der Verlauf ins Bühnen-Schwarz — die Poster bleiben Kulisse, der
Assistent bleibt lesbar. */}
<div className="absolute inset-0 bg-gradient-to-b from-buehne/35 via-buehne/55 to-buehne/90" />
</div>
)
}