Die sechs Punkte des Commanders vom 02.09.2026 abends und seine Entscheide:
* Restzeit der Kompression aus HandBrakes eigener ETA-Klammer (Formatstring
aus dem Binary 1.11.2 gelesen), KompressionStand.restS.
* Kachel zeigt nach dem Rip nur noch eine Zeile — Balken, Prozent, Ziel in
der Kompressions-Karte und im Verlauf ("nur in der Kompressions-Karte").
* Kopfzeile bleibt beim Scrollen stehen (sticky), Sektionswahl darunter.
* Untertitel-Vorwahl je Extra ("NUR für Extras, IMMER OPTIONAL"): welche
Untertitel-Spur beim Abspielen an ist — weiche Spur, nie eingebrannt;
RipAuftragTitel/RohDatei.untertitelVorwahl, sprachwahl.
* Mehrere Filme je Disc: "anderer Film …" je Titel mit TMDb-Suche;
filme.ts filmGruppen trennt am Rip-Ende in eigene Roh-Ordner, Vorgänge
und Kompressions-Aufträge mit voller Zuordnung (erkennung.zuordnungAus).
* Roh-Ordner und Dateien tragen den Filmnamen (filme.ts rohOrdnerName,
ablage/dateinamen.ts: Akira (1988).mkv, - Fassung n, - Extra nn).
* Hell-Modus: Einstellung design (dunkel/hell/system), data-design auf
<html>, Token --color-tinte statt white/-Alpha-Klassen.
* Vorgangs-Leiste (Commander: "Phase 1–4 plus Komprimierung — zu einem
machen"): gemeinsam/vorgangsleiste.ts + uebersicht/VorgangsLeiste.tsx,
vier Stationen (Rettung als Zwischenstation), Segment-Balken in der
aktiven Station; Kachel, Kompressions-Karte, Verlauf. PHASE-x/4-Texte weg.
* Automatik-Kasten leert sich, sobald ein Rip startet.
Beweise: 446 Tests (neu: filme, dateinamen, vorgangsleiste; erweitert:
auftrag, handbrake, automatik, ablegen), drei Typprüfungen, Klick-Beweis
33 Schritte inkl. Vorwahl, anderer Film, Vorgangs-Leiste, Restzeit,
Hell-Modus (Bilder beweise/klick/01…11).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
263 lines
15 KiB
TypeScript
263 lines
15 KiB
TypeScript
// ── 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-tinte/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-tinte/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-tinte/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-tinte/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>
|
|
</>
|
|
)
|
|
}
|