feat(v5): Einstellungen als erklärende Karten-Seite — Version 5.1.0
Ampel / ampel (push) Successful in 1m35s
Ampel / ampel (push) Successful in 1m35s
Der Einstellungs-Tab im Kino-Mix, statt magerer Feldliste: links eine Sektions-Navigation, rechts Karten mit je einem Satz Einordnung — Ablage (mit Struktur-Vorschau Filme/Musik/ISO/roh), Kompression (Schalter, gemessene Encoder als Badges, Empfehlungs-Erklärung), Metadaten (Key-Quellen im Klartext, hinterlegt/fehlt-Punkte), MakeMKV (eigene Karte: Live-Schlüsselstand, Update- und Prüf-Knopf, Dauerlizenz), Medienserver, Programm (Schalter, Update-Stand, Versionszeile) und Werkzeuge — NEU mit den drei „Eigener Pfad'-Feldern (werkzeug.*, vom Katalog seit je unterstützt, bisher ohne UI). Dazu: #einstellungen/#bibliothek als Startanker (RIPPY_START_BEREICH), RIPPY_SMOKE_WARTE_MS für Bild-Beweise, die auf späte Kern-Antworten warten (Einstellungen+Encoder-Messung kommen nach den 750 ms). Version 5.1.0 — erste Fassung ohne Vorab-Suffix, der Bau erzeugt damit erstmals latest.yml (der Update-Kanal aus § 6.8). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
c12b1be448
commit
b64ddf5f06
Binary file not shown.
|
After Width: | Height: | Size: 282 KiB |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "rippy",
|
"name": "rippy",
|
||||||
"productName": "Rippy",
|
"productName": "Rippy",
|
||||||
"version": "5.0.0-w0",
|
"version": "5.1.0",
|
||||||
"description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)",
|
"description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)",
|
||||||
"main": "out/haupt/index.js",
|
"main": "out/haupt/index.js",
|
||||||
"author": "KrBrZ",
|
"author": "KrBrZ",
|
||||||
|
|||||||
+378
-129
@@ -609,6 +609,59 @@ function Kicker({ text }: { text: string }) {
|
|||||||
return <h2 className="font-mono text-[10px] font-semibold tracking-[0.22em] text-gold/80">{text}</h2>
|
return <h2 className="font-mono text-[10px] font-semibold tracking-[0.22em] text-gold/80">{text}</h2>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ein richtiger Schalter statt einer nackten Checkbox. */
|
||||||
|
function Schalter({ an, aufWechsel, label }: { an: boolean; aufWechsel: (an: boolean) => void; label: string }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => aufWechsel(!an)}
|
||||||
|
className="flex items-center gap-3 text-left"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={an}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`relative inline-block h-5 w-9 flex-none rounded-full transition-colors ${
|
||||||
|
an ? 'bg-gradient-to-b from-gold-hell to-gold-tief' : 'bg-white/12'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-0.5 h-4 w-4 rounded-full bg-schnee shadow transition-all ${an ? 'left-[18px]' : 'left-0.5'}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="text-xs leading-5 text-dunst">{label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Einstellungs-Karte: Kicker, ein Satz Einordnung, dann die Felder. */
|
||||||
|
function SektionsKarte({
|
||||||
|
id,
|
||||||
|
titel,
|
||||||
|
intro,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
id: string
|
||||||
|
titel: string
|
||||||
|
intro: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section id={id} className="scroll-mt-4 rounded-2xl border border-white/10 bg-karte/50 p-5">
|
||||||
|
<Kicker text={titel} />
|
||||||
|
<p className="mt-1.5 max-w-xl text-xs leading-5 text-nebel">{intro}</p>
|
||||||
|
<div className="mt-4 space-y-3">{children}</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kleiner Status-Punkt für Schlüssel-Felder: hinterlegt oder leer. */
|
||||||
|
function HinterlegtPunkt({ wert }: { wert: string }) {
|
||||||
|
return (
|
||||||
|
<span className={`ml-2 font-mono text-[10px] ${wert.length > 0 ? 'text-gruen' : 'text-nebel'}`}>
|
||||||
|
{wert.length > 0 ? '● hinterlegt' : '○ fehlt'}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function Feld({
|
function Feld({
|
||||||
name,
|
name,
|
||||||
wert,
|
wert,
|
||||||
@@ -672,12 +725,64 @@ function PresetWahl({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Eine Werkzeug-Zeile: Fundort in Klartext plus der eigene Pfad, der
|
||||||
|
* die automatische Suche übersteuert (Katalog-Schritt 1). */
|
||||||
|
function WerkzeugZeile({
|
||||||
|
name,
|
||||||
|
pfad,
|
||||||
|
fehlt,
|
||||||
|
pflicht,
|
||||||
|
schluessel,
|
||||||
|
wert,
|
||||||
|
aufAendern,
|
||||||
|
}: {
|
||||||
|
name: string
|
||||||
|
pfad: string | null
|
||||||
|
fehlt: string
|
||||||
|
pflicht: boolean
|
||||||
|
schluessel: string
|
||||||
|
wert: string
|
||||||
|
aufAendern: (schluessel: string, wert: string) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-white/8 bg-samt/50 p-3">
|
||||||
|
<p className={`break-all font-mono text-xs ${pfad === null ? 'text-nebel' : pfad.length > 0 ? 'text-gruen' : pflicht ? 'text-rot' : 'text-warn'}`}>
|
||||||
|
{name}: {pfad === null ? 'wird gesucht …' : pfad.length > 0 ? pfad : fehlt}
|
||||||
|
</p>
|
||||||
|
<Feld
|
||||||
|
name="Eigener Pfad"
|
||||||
|
hinweis="leer = automatische Suche; ein eingetragener Pfad gilt exakt (Tippfehler werden als FEHLT sichtbar)"
|
||||||
|
wert={wert}
|
||||||
|
aufAendern={(w) => aufAendern(schluessel, w)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function Einstellungen({ verbindung, haupt }: { verbindung: Verbindung; haupt: HauptStatus | null }) {
|
function Einstellungen({ verbindung, haupt }: { verbindung: Verbindung; haupt: HauptStatus | null }) {
|
||||||
const werte = verbindung.einstellungen
|
const werte = verbindung.einstellungen
|
||||||
const presets = verbindung.werkzeuge?.presets ?? []
|
const presets = verbindung.werkzeuge?.presets ?? []
|
||||||
|
const backends = verbindung.werkzeuge?.backends ?? []
|
||||||
const setzen = einstellungSetzen
|
const setzen = einstellungSetzen
|
||||||
const empfehlungFuer = (typ: string): string =>
|
const s = verbindung.schluessel
|
||||||
presetEmpfehlung(typ, verbindung.werkzeuge?.backends ?? [], presets)
|
const b = verbindung.beschaffung
|
||||||
|
const empfehlungFuer = (typ: string): string => presetEmpfehlung(typ, backends, presets)
|
||||||
|
const [aktiv, setAktiv] = useState('ablage')
|
||||||
|
|
||||||
|
const SEKTIONEN: Array<[string, string]> = [
|
||||||
|
['ablage', 'Ablage'],
|
||||||
|
['kompression', 'Kompression'],
|
||||||
|
['metadaten', 'Metadaten'],
|
||||||
|
['makemkv', 'MakeMKV'],
|
||||||
|
['medienserver', 'Medienserver'],
|
||||||
|
['programm', 'Programm'],
|
||||||
|
['werkzeuge', 'Werkzeuge'],
|
||||||
|
]
|
||||||
|
|
||||||
|
const springe = (id: string): void => {
|
||||||
|
setAktiv(id)
|
||||||
|
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
}
|
||||||
|
|
||||||
const ordnerWaehlen = async (): Promise<void> => {
|
const ordnerWaehlen = async (): Promise<void> => {
|
||||||
const ordner = await window.rippy.ordnerWaehlen('Ablage-Ordner wählen')
|
const ordner = await window.rippy.ordnerWaehlen('Ablage-Ordner wählen')
|
||||||
@@ -685,138 +790,277 @@ function Einstellungen({ verbindung, haupt }: { verbindung: Verbindung; haupt: H
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl space-y-7">
|
<div className="flex gap-8">
|
||||||
<section className="space-y-3">
|
<nav className="sticky top-4 flex h-fit w-44 flex-none flex-col gap-1">
|
||||||
<Kicker text="ABLAGE" />
|
{SEKTIONEN.map(([id, name]) => (
|
||||||
<div className="flex items-end gap-2">
|
|
||||||
<div className="flex-1">
|
|
||||||
<Feld name="Ablage-Ordner" wert={werte['ablage'] ?? ''} aufAendern={(w) => setzen('ablage', w)} />
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => void ordnerWaehlen()}
|
key={id}
|
||||||
className="rounded-lg border border-white/15 bg-samt/60 px-3 py-2 text-xs text-dunst hover:text-schnee"
|
onClick={() => springe(id)}
|
||||||
|
className={`rounded-lg px-3 py-1.5 text-left text-[13px] ${
|
||||||
|
aktiv === id
|
||||||
|
? 'border border-gold/40 bg-gold/12 font-semibold text-gold-hell'
|
||||||
|
: 'border border-transparent text-dunst hover:bg-white/5 hover:text-schnee'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
Durchsuchen …
|
{name}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
))}
|
||||||
</section>
|
</nav>
|
||||||
|
|
||||||
<section className="space-y-3">
|
<div className="min-w-0 max-w-2xl flex-1 space-y-5">
|
||||||
<Kicker
|
<SektionsKarte
|
||||||
text={`KOMPRESSION${verbindung.werkzeuge !== null && verbindung.werkzeuge.handbrake.length === 0 ? ' — HANDBRAKECLI FEHLT' : ''}`}
|
id="ablage"
|
||||||
/>
|
titel="ABLAGE"
|
||||||
<label className="flex items-center gap-2 text-xs text-dunst">
|
intro="Ein Ordner genügt — auch ein Netzlaufwerk. Rippy baut darin die Struktur, die Jellyfin, Emby und Kodi ohne Zutun verstehen."
|
||||||
<input
|
>
|
||||||
type="checkbox"
|
<div className="flex items-end gap-2">
|
||||||
checked={werte['transcodeEnabled'] !== 'false'}
|
<div className="flex-1">
|
||||||
onChange={(e) => setzen('transcodeEnabled', e.target.checked ? 'true' : 'false')}
|
<Feld name="Ablage-Ordner" wert={werte['ablage'] ?? ''} aufAendern={(w) => setzen('ablage', w)} />
|
||||||
className="accent-[oklch(0.83_0.11_75)]"
|
</div>
|
||||||
/>
|
<button
|
||||||
Nach dem Rip komprimieren (der verlustfreie Rip bleibt bis zum Erfolg erhalten)
|
onClick={() => void ordnerWaehlen()}
|
||||||
</label>
|
className="rounded-lg border border-white/15 bg-samt/60 px-3 py-2 text-xs text-dunst hover:text-schnee"
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
>
|
||||||
<PresetWahl name="DVD" schluessel="transcodePresetDvd" wert={werte['transcodePresetDvd'] ?? ''} presets={presets} empfehlung={empfehlungFuer('dvd')} aufAendern={setzen} />
|
Durchsuchen …
|
||||||
<PresetWahl name="Blu-ray" schluessel="transcodePresetBluray" wert={werte['transcodePresetBluray'] ?? ''} presets={presets} empfehlung={empfehlungFuer('bluray')} aufAendern={setzen} />
|
</button>
|
||||||
<PresetWahl name="4K-UHD" schluessel="transcodePresetUhd" wert={werte['transcodePresetUhd'] ?? ''} presets={presets} empfehlung={empfehlungFuer('uhd')} aufAendern={setzen} />
|
</div>
|
||||||
</div>
|
<div className="rounded-xl border border-white/8 bg-buehne/60 p-3 font-mono text-[11px] leading-5 text-nebel">
|
||||||
<Feld
|
<p className="text-dunst">So legt Rippy ab:</p>
|
||||||
name="Allgemeines Preset"
|
<p>├─ Filme\Akira (1988)\ <span className="text-nebel/70">— Film, Poster und Infodatei beieinander</span></p>
|
||||||
hinweis="gilt, wo oben nichts gewählt ist"
|
<p>├─ Musik\ <span className="text-nebel/70">— Audio-CDs als FLAC, benannt nach MusicBrainz</span></p>
|
||||||
wert={werte['transcodePreset'] ?? ''}
|
<p>├─ ISO\ <span className="text-nebel/70">— unerkannte Discs als 1:1-Abbild</span></p>
|
||||||
aufAendern={(w) => setzen('transcodePreset', w)}
|
<p>└─ roh\ <span className="text-nebel/70">— Arbeitsstand während des Rips, räumt sich selbst auf</span></p>
|
||||||
/>
|
</div>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
</SektionsKarte>
|
||||||
<Feld
|
|
||||||
name="Tonspuren (Sprachen)"
|
|
||||||
hinweis="ISO-639-2, z. B. deu,eng — leer = alle"
|
|
||||||
wert={werte['audioSprachen'] ?? ''}
|
|
||||||
aufAendern={(w) => setzen('audioSprachen', w)}
|
|
||||||
/>
|
|
||||||
<Feld
|
|
||||||
name="Untertitel (Sprachen)"
|
|
||||||
hinweis="leer = alle"
|
|
||||||
wert={werte['untertitelSprachen'] ?? ''}
|
|
||||||
aufAendern={(w) => setzen('untertitelSprachen', w)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="space-y-3">
|
<SektionsKarte
|
||||||
<Kicker text="METADATEN" />
|
id="kompression"
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
titel="KOMPRESSION"
|
||||||
<Feld name="TMDb-API-Key" geheim wert={werte['tmdbApiKey'] ?? ''} aufAendern={(w) => setzen('tmdbApiKey', w)} />
|
intro="Nach dem verlustfreien Rip rechnet HandBrake die Filme auf Arbeitsgröße — der Rohling bleibt bis zum Erfolg liegen, es geht nie etwas verloren."
|
||||||
<Feld name="OMDb-API-Key" geheim wert={werte['omdbApiKey'] ?? ''} aufAendern={(w) => setzen('omdbApiKey', w)} />
|
>
|
||||||
</div>
|
{verbindung.werkzeuge !== null && verbindung.werkzeuge.handbrake.length === 0 && (
|
||||||
<Feld
|
<p className="font-mono text-xs text-warn">⚠ HandBrakeCLI fehlt — Rippen geht, Komprimieren nicht (siehe Werkzeuge).</p>
|
||||||
name="MakeMKV-Schlüssel (Dauerlizenz)"
|
|
||||||
hinweis="schlägt den automatischen Beta-Key — leer lassen, wenn keiner gekauft ist"
|
|
||||||
geheim
|
|
||||||
wert={werte['makemkvAppKey'] ?? ''}
|
|
||||||
aufAendern={(w) => setzen('makemkvAppKey', w)}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="space-y-3">
|
|
||||||
<Kicker text="MEDIENSERVER" />
|
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-semibold text-dunst">System</span>
|
|
||||||
<select value={werte['mediaServer'] ?? 'none'} onChange={(e) => setzen('mediaServer', e.target.value)} className={FELD_KLASSE}>
|
|
||||||
<option value="none">keiner</option>
|
|
||||||
<option value="jellyfin">Jellyfin</option>
|
|
||||||
<option value="emby">Emby</option>
|
|
||||||
<option value="kodi">Kodi</option>
|
|
||||||
<option value="plex">Plex (nur Benennung)</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<Feld name="Adresse" hinweis="http(s)://…" wert={werte['mediaServerUrl'] ?? ''} aufAendern={(w) => setzen('mediaServerUrl', w)} />
|
|
||||||
<Feld name="API-Key" geheim hinweis="Kodi: benutzer:passwort" wert={werte['mediaServerApiKey'] ?? ''} aufAendern={(w) => setzen('mediaServerApiKey', w)} />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="space-y-3">
|
|
||||||
<Kicker text="PROGRAMM" />
|
|
||||||
<label className="flex items-center gap-2 text-xs text-dunst">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={haupt?.autostart === true}
|
|
||||||
onChange={(e) => void window.rippy.autostartSetzen(e.target.checked)}
|
|
||||||
className="accent-[oklch(0.83_0.11_75)]"
|
|
||||||
/>
|
|
||||||
Rippy mit Windows starten (HKCU\…\Run — kein Dienst, keine Adminrechte)
|
|
||||||
</label>
|
|
||||||
<Feld
|
|
||||||
name="Update-Adresse"
|
|
||||||
hinweis="leer = Gitea des Commanders; NUR https:// wird angenommen (§ 6.8)"
|
|
||||||
wert={werte['updateUrl'] ?? ''}
|
|
||||||
aufAendern={(w) => setzen('updateUrl', w)}
|
|
||||||
/>
|
|
||||||
{haupt !== null && (
|
|
||||||
<p className="font-mono text-xs text-nebel">
|
|
||||||
Selbst-Update: {haupt.update.text}
|
|
||||||
{haupt.update.letzteErfolgreichePruefung.length > 0 &&
|
|
||||||
` · letzte erfolgreiche Prüfung: ${haupt.update.letzteErfolgreichePruefung.slice(0, 16).replace('T', ' ')}`}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="space-y-3">
|
|
||||||
<Kicker text="WERKZEUGE" />
|
|
||||||
<div className="space-y-1.5 rounded-2xl border border-white/10 bg-karte/50 p-4 font-mono text-xs">
|
|
||||||
<p className={verbindung.werkzeuge === null ? 'text-nebel' : verbindung.werkzeuge.makemkvcon.length > 0 ? 'text-gruen' : 'text-rot'}>
|
|
||||||
makemkvcon: {verbindung.werkzeuge === null ? 'wird gesucht …' : verbindung.werkzeuge.makemkvcon || 'NICHT GEFUNDEN — MakeMKV installieren'}
|
|
||||||
</p>
|
|
||||||
<p className={verbindung.werkzeuge === null ? 'text-nebel' : verbindung.werkzeuge.handbrake.length > 0 ? 'text-gruen' : 'text-warn'}>
|
|
||||||
HandBrakeCLI: {verbindung.werkzeuge === null ? 'wird gesucht …' : verbindung.werkzeuge.handbrake || 'nicht gefunden — Rippen geht, Kompression nicht'}
|
|
||||||
</p>
|
|
||||||
<p className={verbindung.werkzeuge === null ? 'text-nebel' : verbindung.werkzeuge.flac.length > 0 ? 'text-gruen' : 'text-warn'}>
|
|
||||||
flac: {verbindung.werkzeuge === null ? 'wird gesucht …' : verbindung.werkzeuge.flac || 'nicht gefunden — nur für Audio-CDs nötig'}
|
|
||||||
</p>
|
|
||||||
{verbindung.werkzeuge !== null && verbindung.werkzeuge.backends.length > 0 && (
|
|
||||||
<p className="text-nebel">Encoder: {verbindung.werkzeuge.backends.join(', ')}</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
<Schalter
|
||||||
</section>
|
an={werte['transcodeEnabled'] !== 'false'}
|
||||||
|
aufWechsel={(an) => setzen('transcodeEnabled', an ? 'true' : 'false')}
|
||||||
|
label="Nach dem Rip automatisch komprimieren"
|
||||||
|
/>
|
||||||
|
{backends.length > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className="font-mono text-[10px] tracking-wider text-nebel">GEMESSENE ENCODER:</span>
|
||||||
|
{backends.map((backend) => (
|
||||||
|
<Badge key={backend} text={backend} ton={backend.startsWith('cpu') ? 'grau' : 'blau'} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<PresetWahl name="DVD" schluessel="transcodePresetDvd" wert={werte['transcodePresetDvd'] ?? ''} presets={presets} empfehlung={empfehlungFuer('dvd')} aufAendern={setzen} />
|
||||||
|
<PresetWahl name="Blu-ray" schluessel="transcodePresetBluray" wert={werte['transcodePresetBluray'] ?? ''} presets={presets} empfehlung={empfehlungFuer('bluray')} aufAendern={setzen} />
|
||||||
|
<PresetWahl name="4K-UHD" schluessel="transcodePresetUhd" wert={werte['transcodePresetUhd'] ?? ''} presets={presets} empfehlung={empfehlungFuer('uhd')} aufAendern={setzen} />
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] leading-4 text-nebel">
|
||||||
|
„automatisch" nimmt die Empfehlung aus der gemessenen Liste deines HandBrake — auf dieser Maschine
|
||||||
|
die Grafikkarte, wo es sie gibt. Eine eigene Wahl schlägt die Empfehlung immer.
|
||||||
|
</p>
|
||||||
|
<Feld
|
||||||
|
name="Allgemeines Preset"
|
||||||
|
hinweis="gilt, wo oben nichts gewählt ist"
|
||||||
|
wert={werte['transcodePreset'] ?? ''}
|
||||||
|
aufAendern={(w) => setzen('transcodePreset', w)}
|
||||||
|
/>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Feld
|
||||||
|
name="Tonspuren (Sprachen)"
|
||||||
|
hinweis="ISO-639-2, z. B. deu,eng — leer = alle"
|
||||||
|
wert={werte['audioSprachen'] ?? ''}
|
||||||
|
aufAendern={(w) => setzen('audioSprachen', w)}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
name="Untertitel (Sprachen)"
|
||||||
|
hinweis="leer = alle"
|
||||||
|
wert={werte['untertitelSprachen'] ?? ''}
|
||||||
|
aufAendern={(w) => setzen('untertitelSprachen', w)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SektionsKarte>
|
||||||
|
|
||||||
|
<SektionsKarte
|
||||||
|
id="metadaten"
|
||||||
|
titel="METADATEN"
|
||||||
|
intro="TMDb erkennt Titel, Jahr, Poster und Beschreibung; OMDb hilft bei Ausreißern nach. Beide Schlüssel sind kostenlos und in zwei Minuten geholt."
|
||||||
|
>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Feld name="TMDb-API-Key" geheim wert={werte['tmdbApiKey'] ?? ''} aufAendern={(w) => setzen('tmdbApiKey', w)} />
|
||||||
|
<p className="mt-1 font-mono text-[10px] text-nebel">
|
||||||
|
www.themoviedb.org → Einstellungen → API
|
||||||
|
<HinterlegtPunkt wert={werte['tmdbApiKey'] ?? ''} />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Feld name="OMDb-API-Key" geheim wert={werte['omdbApiKey'] ?? ''} aufAendern={(w) => setzen('omdbApiKey', w)} />
|
||||||
|
<p className="mt-1 font-mono text-[10px] text-nebel">
|
||||||
|
www.omdbapi.com/apikey.aspx
|
||||||
|
<HinterlegtPunkt wert={werte['omdbApiKey'] ?? ''} />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] leading-4 text-nebel">
|
||||||
|
Ohne Schlüssel rippt Rippy trotzdem — die Filme heißen dann wie das Disc-Label, und „Ändern …"
|
||||||
|
an der Laufwerks-Kachel bleibt ohne Suche.
|
||||||
|
</p>
|
||||||
|
</SektionsKarte>
|
||||||
|
|
||||||
|
<SektionsKarte
|
||||||
|
id="makemkv"
|
||||||
|
titel="MAKEMKV"
|
||||||
|
intro="MakeMKV liest die Discs. Rippy holt den frei veröffentlichten Beta-Key automatisch aus dem Hersteller-Forum, hinterlegt ihn und fragt MakeMKV selbst, ob er angenommen wird — täglich."
|
||||||
|
>
|
||||||
|
{s !== null && (
|
||||||
|
<div className="rounded-xl border border-white/8 bg-buehne/60 p-3">
|
||||||
|
<p className="flex items-center gap-2 font-mono text-[10.5px] tracking-wider">
|
||||||
|
<span className={`inline-block h-2 w-2 rounded-full ${s.angenommen === true ? 'bg-gruen' : s.weiterBetrieb ? 'bg-warn' : 'bg-rot'}`} />
|
||||||
|
<span className={s.angenommen === true ? 'text-gruen' : s.weiterBetrieb ? 'text-warn' : 'text-rot'}>
|
||||||
|
{s.angenommen === true ? 'SCHLÜSSEL ANGENOMMEN' : s.weiterBetrieb ? 'BETA-MODUS — RIPS LAUFEN' : 'AUSFALL'}
|
||||||
|
</span>
|
||||||
|
{s.version.length > 0 && <span className="ml-auto text-nebel">MakeMKV {s.version}</span>}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1.5 text-xs leading-5 text-dunst">{s.text}</p>
|
||||||
|
<p className="mt-1 font-mono text-[10px] text-nebel">
|
||||||
|
zuletzt geprüft: {s.geholtAm.slice(0, 16).replace('T', ' ')}
|
||||||
|
{s.gueltigBis.length > 0 && ` · Key gültig bis ${s.gueltigBis}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{s !== null && s.angenommen === false && (
|
||||||
|
<button
|
||||||
|
onClick={() => makemkvUpdate()}
|
||||||
|
disabled={b !== null && b.laeuft}
|
||||||
|
className="rounded-lg bg-gradient-to-b from-warn to-[oklch(0.72_0.12_78)] px-3 py-1.5 text-xs font-bold text-buehne shadow-[inset_0_1px_0_oklch(1_0_0/0.35)] hover:brightness-105 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
MakeMKV aktualisieren …
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => schluesselPruefen()}
|
||||||
|
disabled={b !== null && b.laeuft}
|
||||||
|
className="rounded-lg border border-white/15 px-3 py-1.5 text-xs text-dunst hover:text-schnee disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Schlüssel neu prüfen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{b !== null && (
|
||||||
|
<p className={`font-mono text-[11px] leading-5 ${b.fehler ? 'text-rot' : b.laeuft ? 'text-gold-hell' : 'text-gruen'}`}>
|
||||||
|
{b.text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Feld
|
||||||
|
name="Eigener Schlüssel (Dauerlizenz)"
|
||||||
|
hinweis="schlägt den automatischen Beta-Key — leer lassen, wenn keiner gekauft ist"
|
||||||
|
geheim
|
||||||
|
wert={werte['makemkvAppKey'] ?? ''}
|
||||||
|
aufAendern={(w) => setzen('makemkvAppKey', w)}
|
||||||
|
/>
|
||||||
|
</SektionsKarte>
|
||||||
|
|
||||||
|
<SektionsKarte
|
||||||
|
id="medienserver"
|
||||||
|
titel="MEDIENSERVER"
|
||||||
|
intro="Nach jedem fertigen Rip sagt Rippy deinem Server Bescheid, damit der Film sofort auftaucht — ohne Warten auf den nächsten Bibliotheks-Scan."
|
||||||
|
>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-semibold text-dunst">System</span>
|
||||||
|
<select value={werte['mediaServer'] ?? 'none'} onChange={(e) => setzen('mediaServer', e.target.value)} className={FELD_KLASSE}>
|
||||||
|
<option value="none">keiner</option>
|
||||||
|
<option value="jellyfin">Jellyfin</option>
|
||||||
|
<option value="emby">Emby</option>
|
||||||
|
<option value="kodi">Kodi</option>
|
||||||
|
<option value="plex">Plex (nur Benennung)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<Feld name="Adresse" hinweis="http(s)://…" wert={werte['mediaServerUrl'] ?? ''} aufAendern={(w) => setzen('mediaServerUrl', w)} />
|
||||||
|
<Feld name="API-Key" geheim hinweis="Kodi: benutzer:passwort" wert={werte['mediaServerApiKey'] ?? ''} aufAendern={(w) => setzen('mediaServerApiKey', w)} />
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] leading-4 text-nebel">
|
||||||
|
Bei Plex benennt Rippy nur nach dessen Regeln — angestoßen wird dort nichts. Die Ordnerstruktur
|
||||||
|
aus „Ablage" passt für alle vier.
|
||||||
|
</p>
|
||||||
|
</SektionsKarte>
|
||||||
|
|
||||||
|
<SektionsKarte
|
||||||
|
id="programm"
|
||||||
|
titel="PROGRAMM"
|
||||||
|
intro="Rippy lebt im Infobereich neben der Uhr: Fenster zu heißt weiterlaufen, beendet wird über das Tray-Menü."
|
||||||
|
>
|
||||||
|
<Schalter
|
||||||
|
an={haupt?.autostart === true}
|
||||||
|
aufWechsel={(an) => void window.rippy.autostartSetzen(an)}
|
||||||
|
label="Rippy mit Windows starten (Autostart im Benutzerkonto — kein Dienst, keine Adminrechte)"
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
name="Update-Adresse"
|
||||||
|
hinweis="leer = Standard; NUR https:// wird angenommen"
|
||||||
|
wert={werte['updateUrl'] ?? ''}
|
||||||
|
aufAendern={(w) => setzen('updateUrl', w)}
|
||||||
|
/>
|
||||||
|
{haupt !== null && (
|
||||||
|
<div className="rounded-xl border border-white/8 bg-buehne/60 p-3 font-mono text-[11px] leading-5 text-nebel">
|
||||||
|
<p>Selbst-Update: {haupt.update.text}</p>
|
||||||
|
{haupt.update.letzteErfolgreichePruefung.length > 0 && (
|
||||||
|
<p>letzte erfolgreiche Prüfung: {haupt.update.letzteErfolgreichePruefung.slice(0, 16).replace('T', ' ')}</p>
|
||||||
|
)}
|
||||||
|
<p>
|
||||||
|
Rippy v{haupt.version}
|
||||||
|
{verbindung.kern !== null && ` · Kern: Node ${verbindung.kern.nodeVersion} (PID ${verbindung.kern.pid})`}
|
||||||
|
</p>
|
||||||
|
<p className="text-nebel/70">Updates warten, solange ein Rip läuft — nie mitten im Vorgang.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SektionsKarte>
|
||||||
|
|
||||||
|
<SektionsKarte
|
||||||
|
id="werkzeuge"
|
||||||
|
titel="WERKZEUGE"
|
||||||
|
intro="Rippy findet seine Werkzeuge von allein: mitgeliefert, selbst geholt, installiert oder im PATH — in dieser Reihenfolge. Wer einen eigenen Pfad einträgt, bekommt genau den."
|
||||||
|
>
|
||||||
|
<WerkzeugZeile
|
||||||
|
name="makemkvcon"
|
||||||
|
pfad={verbindung.werkzeuge === null ? null : verbindung.werkzeuge.makemkvcon}
|
||||||
|
fehlt="NICHT GEFUNDEN — MakeMKV von makemkv.com installieren, Rippy findet es danach von allein"
|
||||||
|
pflicht={true}
|
||||||
|
schluessel="werkzeug.makemkv"
|
||||||
|
wert={werte['werkzeug.makemkv'] ?? ''}
|
||||||
|
aufAendern={setzen}
|
||||||
|
/>
|
||||||
|
<WerkzeugZeile
|
||||||
|
name="HandBrakeCLI"
|
||||||
|
pfad={verbindung.werkzeuge === null ? null : verbindung.werkzeuge.handbrake}
|
||||||
|
fehlt="nicht gefunden — Rippen geht, Kompression nicht"
|
||||||
|
pflicht={false}
|
||||||
|
schluessel="werkzeug.handbrake"
|
||||||
|
wert={werte['werkzeug.handbrake'] ?? ''}
|
||||||
|
aufAendern={setzen}
|
||||||
|
/>
|
||||||
|
<WerkzeugZeile
|
||||||
|
name="flac"
|
||||||
|
pfad={verbindung.werkzeuge === null ? null : verbindung.werkzeuge.flac}
|
||||||
|
fehlt="nicht gefunden — nur für Audio-CDs nötig"
|
||||||
|
pflicht={false}
|
||||||
|
schluessel="werkzeug.flac"
|
||||||
|
wert={werte['werkzeug.flac'] ?? ''}
|
||||||
|
aufAendern={setzen}
|
||||||
|
/>
|
||||||
|
{verbindung.werkzeuge !== null && (
|
||||||
|
<p className="font-mono text-[10.5px] text-nebel">
|
||||||
|
HandBrake gemessen: {verbindung.werkzeuge.presets.length} Presets · Encoder:{' '}
|
||||||
|
{backends.length > 0 ? backends.join(', ') : '—'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</SektionsKarte>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1037,7 +1281,12 @@ type Bereich = 'uebersicht' | 'einstellungen' | 'bibliothek'
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [haupt, setHaupt] = useState<HauptStatus | null>(null)
|
const [haupt, setHaupt] = useState<HauptStatus | null>(null)
|
||||||
const [bereich, setBereich] = useState<Bereich>('uebersicht')
|
// #einstellungen / #bibliothek als Startanker — der Smoke-Beweis
|
||||||
|
// fotografiert damit auch die anderen Bereiche.
|
||||||
|
const [bereich, setBereich] = useState<Bereich>(() => {
|
||||||
|
const anker = window.location.hash.replace('#', '')
|
||||||
|
return anker === 'einstellungen' || anker === 'bibliothek' ? anker : 'uebersicht'
|
||||||
|
})
|
||||||
const [verbindung, setVerbindung] = useState<Verbindung>({
|
const [verbindung, setVerbindung] = useState<Verbindung>({
|
||||||
verbunden: false,
|
verbunden: false,
|
||||||
kern: null,
|
kern: null,
|
||||||
|
|||||||
@@ -28,11 +28,17 @@ export function fensterErstellen(): BrowserWindow {
|
|||||||
|
|
||||||
// electron-vite: Im dev-Modus bedient ein Vite-Server das Fenster
|
// electron-vite: Im dev-Modus bedient ein Vite-Server das Fenster
|
||||||
// (ELECTRON_RENDERER_URL), gebaut wird aus out/fenster geladen.
|
// (ELECTRON_RENDERER_URL), gebaut wird aus out/fenster geladen.
|
||||||
|
// RIPPY_START_BEREICH öffnet einen bestimmten Bereich (#einstellungen /
|
||||||
|
// #bibliothek) — der Smoke-Beweis fotografiert damit auch diese Seiten.
|
||||||
const devUrl = process.env['ELECTRON_RENDERER_URL']
|
const devUrl = process.env['ELECTRON_RENDERER_URL']
|
||||||
|
const startBereich = process.env['RIPPY_START_BEREICH'] ?? ''
|
||||||
if (devUrl !== undefined && devUrl.length > 0) {
|
if (devUrl !== undefined && devUrl.length > 0) {
|
||||||
void fenster.loadURL(devUrl)
|
void fenster.loadURL(startBereich.length > 0 ? `${devUrl}#${startBereich}` : devUrl)
|
||||||
} else {
|
} else {
|
||||||
void fenster.loadFile(join(__dirname, '../fenster/index.html'))
|
void fenster.loadFile(
|
||||||
|
join(__dirname, '../fenster/index.html'),
|
||||||
|
startBereich.length > 0 ? { hash: startBereich } : undefined,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return fenster
|
return fenster
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -279,8 +279,11 @@ function start(): void {
|
|||||||
try {
|
try {
|
||||||
// Der Pong kommt schneller, als der Compositor das erste Bild malt —
|
// Der Pong kommt schneller, als der Compositor das erste Bild malt —
|
||||||
// ein sofortiges capturePage lieferte hier »UnknownVizError«
|
// ein sofortiges capturePage lieferte hier »UnknownVizError«
|
||||||
// (gemessen 30.08.2026). Kurz warten, bis wirklich gemalt ist.
|
// (gemessen 30.08.2026). Kurz warten, bis wirklich gemalt ist;
|
||||||
await new Promise((fertig) => setTimeout(fertig, 750))
|
// RIPPY_SMOKE_WARTE_MS verlängert das für Bild-Beweise, die auf
|
||||||
|
// späte Kern-Antworten warten.
|
||||||
|
const warteMs = Number(process.env['RIPPY_SMOKE_WARTE_MS'] ?? '750')
|
||||||
|
await new Promise((fertig) => setTimeout(fertig, Number.isFinite(warteMs) ? warteMs : 750))
|
||||||
const bild = await fenster.webContents.capturePage()
|
const bild = await fenster.webContents.capturePage()
|
||||||
await writeFile(bildPfad, bild.toPNG())
|
await writeFile(bildPfad, bild.toPNG())
|
||||||
console.log(`SMOKE: Bild abgelegt unter ${bildPfad}`)
|
console.log(`SMOKE: Bild abgelegt unter ${bildPfad}`)
|
||||||
|
|||||||
Reference in New Issue
Block a user