WAS (je Fund der Durchsicht vom 12.09.2026): - F15 Tray „Beenden" fragt nach, wenn ein Rip oder eine Kompression läuft (app.quit reißt makemkvcon/HandBrake über die Leine mit). - F17 „Roh-Dateien löschen" und „Ordner löschen" fragen im Fenster nach (Größe und Pfad stehen dabei) — ein Klick war vorher 30 GB. - F18 Eingabefelder speichern auch bei Enter und beim Verschwinden des Feldes (Bereichswechsel) — Tipparbeit ging vorher verloren. - F19 Dialoge: Escape schließt, role=dialog/aria-modal, Fokus im Dialog. - F20 Toast „Disc erkannt" einmal je Disc und Titel, nicht dreimal. - F21 Was mit dem Poster passiert, steht in den Hinweisen (R4). - F22 Werkzeug-Funde gelten 60 s; ein neuer Pfad und „werkzeuge-laden" suchen frisch — vorher bei fehlendem MakeMKV je Aufruf dreimal reg query /s (bis 45 s, synchron). - F23 movie.nfo/tvshow.nfo tragen <uniqueid type=tmdb|imdb>. - F24 Doppelter Zielordner heißt „Akira (1988) (2)" statt „[bluray-2]". - F25 Umlaute in drei Knopftexten. - F26 Phase „rettet" zählt im Haupt als laufender Vorgang (Update-Sperre, Taskleiste). - F27 „Neu komprimieren" nicht mehr für verschobene, unkomprimierte Vorgänge. - F28 Die Automatik sagt bei Audio-CD und unbekannter Disc, warum sie nichts tut. - F30 Audio-CD-Spuren werden asynchron gelesen (alle acht Blöcke Luft), „Abbrechen" wirkt mitten in der Spur. - A5 Der tote Schlüssel transcodePreset ist aus presetFuer raus. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
709 lines
34 KiB
TypeScript
709 lines
34 KiB
TypeScript
// Die Einstellungs-Karten (5.5.0, je Karte eine Funktion): Ablage,
|
|
// Kompression, Roh-Dateien, Automatik, Metadaten, MakeMKV, Medienserver,
|
|
// Benachrichtigung, Allgemein, Update, Werkzeuge. Alle schreiben über
|
|
// einstellungSetzen in die EINE Quelle (§ 6.7).
|
|
import { useState } from 'react'
|
|
import type { GeraeteInfo, HauptStatus, VorgangEintrag } from '../../gemeinsam/nachrichten'
|
|
import { presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
|
|
import {
|
|
Badge,
|
|
FELD_KLASSE,
|
|
Feld,
|
|
HinterlegtPunkt,
|
|
KNOPF_LEISE,
|
|
LAUFENDE_PHASEN,
|
|
PfadFeld,
|
|
Schalter,
|
|
SektionsKarte,
|
|
TYP_NAMEN,
|
|
datumKurz,
|
|
gb,
|
|
gbKurz,
|
|
} from '../bausteine'
|
|
import {
|
|
benachrichtigungTesten,
|
|
einstellungSetzen,
|
|
fremdenOrdnerLoeschen,
|
|
kompressionAbbrechen,
|
|
makemkvUpdate,
|
|
rohAufraeumen,
|
|
schluesselPruefen,
|
|
vorgaengeLaden,
|
|
vorgangNeuKomprimieren,
|
|
vorgangRohLoeschen,
|
|
type Verbindung,
|
|
} from '../kernverbindung'
|
|
import { phasenTon } from '../uebersicht/VerlaufKarte'
|
|
import { SprachAuswahl } from './SprachAuswahl'
|
|
|
|
const setzen = einstellungSetzen
|
|
|
|
export function AblageKarte({ verbindung }: { verbindung: Verbindung }) {
|
|
const werte = verbindung.einstellungen
|
|
const speicher = verbindung.speicher
|
|
return (
|
|
<SektionsKarte
|
|
id="ablage"
|
|
titel="ABLAGE"
|
|
intro="Die Ablage ist, wo Fertiges landet — auch ein Netzlaufwerk. Der Arbeitsordner ist, wo Rippy rechnet: Roh-Dateien und Kompression. Liegt die Ablage auf dem Server, gehört der Arbeitsordner auf eine lokale Platte."
|
|
>
|
|
<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>
|
|
)}
|
|
<PfadFeld
|
|
name="Arbeitsordner (lokal, optional)"
|
|
hinweis="leer = unter der Ablage; Roh-Dateien und Kompression liegen dann dort"
|
|
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 className="rounded-xl border border-tinte/8 bg-buehne/60 p-3 font-mono text-[11px] leading-5 text-nebel">
|
|
<p className="text-dunst">So legt Rippy ab:</p>
|
|
<p>
|
|
├─ Filme\Akira (1988)\ <span className="text-nebel/70">— Film, Poster und Infodatei beieinander</span>
|
|
</p>
|
|
<p>
|
|
├─ Serien\Spartacus\Season 01\ <span className="text-nebel/70">— Folgen, wie Jellyfin sie erwartet</span>
|
|
</p>
|
|
<p>
|
|
├─ Musik\ <span className="text-nebel/70">— Audio-CDs als FLAC, benannt nach MusicBrainz</span>
|
|
</p>
|
|
<p>
|
|
├─ ISO\ <span className="text-nebel/70">— unerkannte Discs als 1:1-Abbild</span>
|
|
</p>
|
|
<p>
|
|
└─ roh\ <span className="text-nebel/70">— im Arbeitsordner: verlustfreies Roh-Material, aufgeräumt nach der Regel unter „Roh-Dateien"</span>
|
|
</p>
|
|
</div>
|
|
<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"
|
|
/>
|
|
</SektionsKarte>
|
|
)
|
|
}
|
|
|
|
function PresetWahl({
|
|
name,
|
|
schluessel,
|
|
wert,
|
|
presets,
|
|
empfehlung,
|
|
}: {
|
|
name: string
|
|
schluessel: string
|
|
wert: string
|
|
presets: string[]
|
|
empfehlung: string
|
|
}) {
|
|
return (
|
|
<label className="block">
|
|
<span className="text-xs font-semibold text-dunst">{name}</span>
|
|
<select value={wert} onChange={(e) => setzen(schluessel, e.target.value)} className={FELD_KLASSE}>
|
|
<option value="">{empfehlung.length > 0 ? `automatisch: ${empfehlung}` : 'automatisch'}</option>
|
|
<option value="keine">NICHT komprimieren (verlustfrei lassen)</option>
|
|
{presets.map((preset) => (
|
|
<option key={preset} value={preset}>
|
|
{preset}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
)
|
|
}
|
|
|
|
export function KompressionKarte({ verbindung }: { verbindung: Verbindung }) {
|
|
const werte = verbindung.einstellungen
|
|
const presets = verbindung.werkzeuge?.presets ?? []
|
|
const backends = verbindung.werkzeuge?.backends ?? []
|
|
const empfehlungFuer = (typ: string): string => presetEmpfehlung(typ, backends, presets)
|
|
return (
|
|
<SektionsKarte
|
|
id="kompression"
|
|
titel="KOMPRESSION"
|
|
intro="Nach dem verlustfreien Rip rechnet HandBrake die Filme auf Arbeitsgröße — der Rohling bleibt bis zum Erfolg liegen, es geht nie etwas verloren."
|
|
>
|
|
{verbindung.werkzeuge !== null && verbindung.werkzeuge.handbrake.length === 0 && (
|
|
<p className="font-mono text-xs text-warn">⚠ HandBrakeCLI fehlt — Rippen geht, Komprimieren nicht (siehe Werkzeuge).</p>
|
|
)}
|
|
<Schalter 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')} />
|
|
<PresetWahl name="Blu-ray" schluessel="transcodePresetBluray" wert={werte['transcodePresetBluray'] ?? ''} presets={presets} empfehlung={empfehlungFuer('bluray')} />
|
|
<PresetWahl name="4K-UHD" schluessel="transcodePresetUhd" wert={werte['transcodePresetUhd'] ?? ''} presets={presets} empfehlung={empfehlungFuer('uhd')} />
|
|
</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>
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<SprachAuswahl
|
|
name="Tonspuren"
|
|
hinweis="Reihenfolge = Wunschreihenfolge; die erste ist deine Sprache"
|
|
wert={werte['audioSprachen'] ?? ''}
|
|
aufAendern={(w) => setzen('audioSprachen', w)}
|
|
/>
|
|
<SprachAuswahl name="Untertitel" hinweis="werden vorgewählt, wenn der Ton nicht deine Sprache ist" wert={werte['untertitelSprachen'] ?? ''} aufAendern={(w) => setzen('untertitelSprachen', w)} />
|
|
</div>
|
|
</SektionsKarte>
|
|
)
|
|
}
|
|
|
|
export function AutomatikKarte({ verbindung }: { verbindung: Verbindung }) {
|
|
const werte = verbindung.einstellungen
|
|
const an = werte['automatik'] !== 'aus'
|
|
return (
|
|
<SektionsKarte
|
|
id="automatik"
|
|
titel="AUTOMATIK"
|
|
intro="Disc rein genügt: Ist Rippy sicher, was das ist, liest es die Titel und startet nach einem Countdown von selbst — mit seinem Vorschlag. Wo es unsicher ist, wartet es auf dich."
|
|
>
|
|
<Schalter an={an} aufWechsel={(a) => setzen('automatik', a ? 'an' : 'aus')} label="Automatik: bei sicherer Erkennung (ab 90 %) von selbst rippen" />
|
|
<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)} disabled={!an} 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>
|
|
<p className="text-[11px] leading-4 text-nebel">
|
|
Der Countdown steht in der Laufwerks-Kachel, mit „Stopp" und „Jetzt". Eine Disc, die schon einmal gerippt wurde, startet nie von selbst.
|
|
</p>
|
|
</SektionsKarte>
|
|
)
|
|
}
|
|
|
|
export function RohDateienKarte({ verbindung, aktivFuer }: { verbindung: Verbindung; aktivFuer?: boolean }) {
|
|
const werte = verbindung.einstellungen
|
|
const v = verbindung.vorgaenge
|
|
const [presetWahl, setPresetWahl] = useState<Record<string, string>>({})
|
|
const [alleZeigen, setAlleZeigen] = useState(aktivFuer === true)
|
|
// 5.7.0 (Durchsicht F17): Löschen fragt nach — ein Klick war vorher 30 GB.
|
|
// Der erste Klick macht aus dem Knopf die Rückfrage, der zweite löscht.
|
|
const [loeschfrage, setLoeschfrage] = useState<string | null>(null)
|
|
const presets = verbindung.werkzeuge?.presets ?? []
|
|
const regelWert = werte['rohAufbewahrung'] ?? '7'
|
|
const phasenText: Record<VorgangEintrag['phase'], string> = {
|
|
rippt: 'rippt',
|
|
wartet: 'wartet auf Kompression',
|
|
komprimiert: 'komprimiert',
|
|
'legt-ab': 'legt ab',
|
|
fertig: 'fertig',
|
|
fehler: 'Fehler',
|
|
abgebrochen: 'abgebrochen',
|
|
}
|
|
const liste = v?.vorgaenge ?? []
|
|
const sichtbar = alleZeigen ? liste : liste.slice(0, 12)
|
|
|
|
return (
|
|
<SektionsKarte
|
|
id="roh"
|
|
titel="ROH-DATEIEN"
|
|
intro="Jede Blu-ray lässt 30 GB und mehr verlustfreies Roh-Material liegen — genau dafür, dass ein Preset-Wechsel kein Neulesen kostet. Aufgeräumt wird nach Regel, und nur, wenn das fertige Ergebnis nachweislich da ist."
|
|
>
|
|
<div className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-end">
|
|
<label className="block">
|
|
<span className="text-xs font-semibold text-dunst">Nach gelungener Kompression</span>
|
|
<select value={regelWert} 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>
|
|
<div className="flex gap-2">
|
|
<button onClick={() => rohAufraeumen()} className={`${KNOPF_LEISE} py-2`} title="Die Regel jetzt anwenden, statt auf den täglichen Takt zu warten">
|
|
Jetzt aufräumen
|
|
</button>
|
|
<button onClick={() => vorgaengeLaden()} className={`${KNOPF_LEISE} py-2`} title="Belegung neu messen">
|
|
Aktualisieren
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="rounded-xl border border-tinte/8 bg-buehne/60 p-3 font-mono text-[11px] leading-5 text-nebel">
|
|
{v === null ? (
|
|
<p>Belegung wird gemessen …</p>
|
|
) : (
|
|
<>
|
|
<p>
|
|
<span className="text-dunst">Belegung jetzt:</span> <span className="text-gold-hell">{gb(v.rohGesamtBytes)}</span> in{' '}
|
|
{liste.filter((e) => !e.rohGeloescht).length + v.fremde.length} Roh-Ordnern
|
|
</p>
|
|
<p>{v.regel}</p>
|
|
<p className="text-nebel/70">Unkomprimierte Rips (z. B. 4K verlustfrei) liegen im Zielordner, nicht hier — sie sind nie betroffen.</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{liste.length === 0 && v !== null && <p className="font-mono text-xs text-nebel">Noch kein Vorgang — der erste Rip legt hier einen Eintrag an.</p>}
|
|
<div className="space-y-2">
|
|
{sichtbar.map((e) => {
|
|
const ruhig = e.phase === 'fertig' || e.phase === 'fehler' || e.phase === 'abgebrochen'
|
|
return (
|
|
<div key={e.id} className="rounded-xl border border-tinte/8 bg-buehne/60 p-3">
|
|
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
|
<span className="font-display text-[14px] font-bold text-schnee">
|
|
{e.titel}
|
|
{e.jahr !== null && <span className="ml-1 font-medium text-dunst">({e.jahr})</span>}
|
|
</span>
|
|
<Badge text={phasenText[e.phase]} ton={phasenTon(e.phase)} />
|
|
<span className="font-mono text-[10.5px] text-nebel">
|
|
{(TYP_NAMEN[e.typ as GeraeteInfo['typ']] ?? e.typ).toUpperCase()} · {datumKurz(e.begonnen)}
|
|
{e.titelZahl > 0 && ` · ${e.titelZahl} Titel`}
|
|
{e.preset.length > 0 && ` · ${e.preset}`}
|
|
</span>
|
|
</div>
|
|
<p className="mt-1 font-mono text-[11px] leading-5 text-nebel">
|
|
<span className="text-dunst">Roh:</span> {e.rohGeloescht ? 'gelöscht' : gb(e.rohBytes)}
|
|
{e.faelligAm.length > 0 && ` · fällig ${datumKurz(e.faelligAm)}`}
|
|
{' · '}
|
|
<span className="text-dunst">Fertig:</span>{' '}
|
|
{e.phase === 'fertig' ? (
|
|
<span className="break-all">
|
|
{e.fertigBytes > 0 ? `${gb(e.fertigBytes)} · ` : ''}
|
|
{e.zielOrdner}
|
|
{!e.komprimiert && ' (verlustfrei, ohne Kompression)'}
|
|
</span>
|
|
) : (
|
|
'—'
|
|
)}
|
|
</p>
|
|
{e.fehler.length > 0 && <p className="mt-1 break-all font-mono text-[11px] text-warn">⚠ {e.fehler}</p>}
|
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
|
{e.neuKomprimierbar && (
|
|
<>
|
|
<select
|
|
value={presetWahl[e.id] ?? ''}
|
|
onChange={(ev) => setPresetWahl({ ...presetWahl, [e.id]: ev.target.value })}
|
|
className="rounded-lg border border-tinte/15 bg-samt/70 px-2 py-1 text-[11px] text-dunst"
|
|
title="Preset für die Wiederholung — leer: wie eingestellt"
|
|
>
|
|
<option value="">Preset wie eingestellt</option>
|
|
{presets.map((p) => (
|
|
<option key={p} value={p}>
|
|
{p}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
onClick={() => vorgangNeuKomprimieren(e.id, presetWahl[e.id] ?? '')}
|
|
className="rounded-lg border border-gold/40 bg-gold/12 px-3 py-1 text-[11px] font-semibold text-gold-hell hover:bg-gold/20"
|
|
title="Die Roh-Dateien liegen genau dafür da — kein Neulesen der Disc"
|
|
>
|
|
Neu komprimieren
|
|
</button>
|
|
</>
|
|
)}
|
|
{!e.rohGeloescht && ruhig && loeschfrage !== e.id && (
|
|
<button onClick={() => setLoeschfrage(e.id)} className="rounded-lg border border-rot/40 bg-rot/10 px-3 py-1 text-[11px] text-rot hover:bg-rot/20" title="Den Roh-Ordner dieses Vorgangs löschen — fragt erst nach">
|
|
Roh-Dateien löschen …
|
|
</button>
|
|
)}
|
|
{!e.rohGeloescht && ruhig && loeschfrage === e.id && (
|
|
<span className="inline-flex flex-wrap items-center gap-2 rounded-lg border border-rot/50 bg-rot/10 px-2 py-1 text-[11px] text-rot">
|
|
{gb(e.rohBytes)} unter {e.rohOrdner} unwiderruflich löschen?
|
|
<button
|
|
onClick={() => {
|
|
setLoeschfrage(null)
|
|
vorgangRohLoeschen(e.id)
|
|
}}
|
|
className="rounded-md bg-rot px-2 py-0.5 font-semibold text-buehne hover:brightness-110"
|
|
>
|
|
Ja, löschen
|
|
</button>
|
|
<button onClick={() => setLoeschfrage(null)} className="rounded-md border border-tinte/20 px-2 py-0.5 text-dunst hover:text-schnee">
|
|
Nein
|
|
</button>
|
|
</span>
|
|
)}
|
|
{e.phase === 'fertig' && e.zielOrdner.length > 0 && (
|
|
<button onClick={() => void window.rippy.ordnerOeffnen(e.zielOrdner)} className="rounded-lg border border-tinte/15 px-3 py-1 text-[11px] text-dunst hover:text-schnee">
|
|
Ordner öffnen
|
|
</button>
|
|
)}
|
|
{(e.phase === 'wartet' || e.phase === 'komprimiert' || e.phase === 'legt-ab') && (
|
|
<button onClick={() => kompressionAbbrechen(e.id)} className="rounded-lg border border-tinte/15 px-3 py-1 text-[11px] text-dunst hover:text-rot">
|
|
Kompression abbrechen
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
{liste.length > sichtbar.length && (
|
|
<button onClick={() => setAlleZeigen(true)} className="font-mono text-[10.5px] text-nebel hover:text-dunst">
|
|
▸ alle {liste.length} Vorgänge zeigen
|
|
</button>
|
|
)}
|
|
|
|
{v !== null && v.fremde.length > 0 && (
|
|
<div className="rounded-xl border border-warn/30 bg-warn/5 p-3">
|
|
<p className="font-mono text-[10.5px] tracking-wider text-warn">ORDNER OHNE VORGANG</p>
|
|
<p className="mt-1 text-[11px] leading-4 text-nebel">Aus der Zeit vor 5.4.0 oder von Hand hineingelegt — Rippy kennt sie nicht und löscht sie deshalb NIE von selbst.</p>
|
|
<div className="mt-2 space-y-1">
|
|
{v.fremde.map((f) => (
|
|
<div key={f.ordner} className="flex items-center justify-between gap-3">
|
|
<span className="min-w-0 break-all font-mono text-[11px] text-dunst">
|
|
{f.ordner} · {gb(f.bytes)}
|
|
</span>
|
|
{loeschfrage !== f.ordner ? (
|
|
<button onClick={() => setLoeschfrage(f.ordner)} className="flex-none rounded-lg border border-rot/40 bg-rot/10 px-2.5 py-0.5 text-[10.5px] text-rot hover:bg-rot/20">
|
|
Löschen …
|
|
</button>
|
|
) : (
|
|
<span className="flex flex-none items-center gap-1.5 text-[10.5px] text-rot">
|
|
wirklich?
|
|
<button
|
|
onClick={() => {
|
|
setLoeschfrage(null)
|
|
fremdenOrdnerLoeschen(f.ordner)
|
|
}}
|
|
className="rounded-md bg-rot px-2 py-0.5 font-semibold text-buehne hover:brightness-110"
|
|
>
|
|
Ja
|
|
</button>
|
|
<button onClick={() => setLoeschfrage(null)} className="rounded-md border border-tinte/20 px-2 py-0.5 text-dunst hover:text-schnee">
|
|
Nein
|
|
</button>
|
|
</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</SektionsKarte>
|
|
)
|
|
}
|
|
|
|
export function MetadatenKarte({ verbindung }: { verbindung: Verbindung }) {
|
|
const werte = verbindung.einstellungen
|
|
return (
|
|
<SektionsKarte
|
|
id="metadaten"
|
|
titel="METADATEN"
|
|
intro="TMDb erkennt Titel, Jahr, Poster, Logo 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>
|
|
)
|
|
}
|
|
|
|
export function MakemkvKarte({ verbindung }: { verbindung: Verbindung }) {
|
|
const werte = verbindung.einstellungen
|
|
const s = verbindung.schluessel
|
|
const b = verbindung.beschaffung
|
|
return (
|
|
<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."
|
|
>
|
|
<div className="rounded-xl border border-tinte/8 bg-buehne/60 p-3">
|
|
{s === null ? (
|
|
<p className="font-mono text-xs text-nebel">Schlüssel wird geprüft …</p>
|
|
) : (
|
|
<>
|
|
{/* 5.7.0 (F1/F5): Ein abgelehnter oder fehlender Schlüssel ist
|
|
ROT — Blu-rays laufen dann nur noch in MakeMKVs Gnadenfrist.
|
|
„Nicht prüfbar" (kein Laufwerk) ist grau, kein Erfolg. */}
|
|
<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.angenommen === null ? 'bg-tinte/25' : 'bg-rot'}`} />
|
|
<span className={s.angenommen === true ? 'text-gruen' : s.angenommen === null ? 'text-nebel' : 'text-rot'}>
|
|
{s.angenommen === true ? 'SCHLÜSSEL ANGENOMMEN' : s.angenommen === null ? 'NICHT PRÜFBAR — KEIN LAUFWERK' : 'KEIN GÜLTIGER SCHLÜSSEL — BLU-RAY-RIPS LAUFEN NICHT'}
|
|
</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: {datumKurz(s.geholtAm)}
|
|
{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"
|
|
title="Holt den offiziellen Installer über die Quellenkette, prüft ihn und startet ihn — Windows fragt einmal nach Adminrechten."
|
|
>
|
|
MakeMKV aktualisieren …
|
|
</button>
|
|
)}
|
|
<button onClick={() => schluesselPruefen()} disabled={b !== null && b.laeuft} className={KNOPF_LEISE} title="Forum-Key holen, hinterlegen und MakeMKV selbst fragen, ob er angenommen wird">
|
|
Schlüssel neu prüfen
|
|
</button>
|
|
</div>
|
|
{b !== null && (
|
|
<div className="rounded-lg border border-tinte/10 bg-buehne/70 p-2.5">
|
|
<p className={`font-mono text-[11px] leading-5 ${b.fehler ? 'text-rot' : b.laeuft ? 'text-gold-hell' : 'text-gruen'}`}>{b.text}</p>
|
|
{b.laeuft && b.anteil !== null && (
|
|
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-tinte/8">
|
|
<div className="h-full rounded-full bg-gradient-to-r from-gold-tief to-gold-hell transition-all" style={{ width: `${Math.round(b.anteil * 100)}%` }} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
<Feld
|
|
name="Eigener Schlüssel (Dauerlizenz)"
|
|
hinweis="schlägt den automatischen Beta-Key — leer lassen, wenn keiner gekauft ist; nach dem Eintragen prüft Rippy sofort"
|
|
geheim
|
|
wert={werte['makemkvAppKey'] ?? ''}
|
|
aufAendern={(w) => setzen('makemkvAppKey', w)}
|
|
/>
|
|
<p className="text-[11px] leading-4 text-nebel">
|
|
Rippy schreibt in MakeMKVs eigene Einstellungen: den Schlüssel, die Auswahlregel „alle Spuren behalten" und den Kanal für 4K-Disc-Schlüssel. Deshalb nimmt auch das MakeMKV-Programm seitdem alle Tonspuren und Untertitel mit — die Dateien dort werden größer, verloren geht nichts.
|
|
</p>
|
|
</SektionsKarte>
|
|
)
|
|
}
|
|
|
|
export function MedienserverKarte({ verbindung }: { verbindung: Verbindung }) {
|
|
const werte = verbindung.einstellungen
|
|
return (
|
|
<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>
|
|
)
|
|
}
|
|
|
|
export function BenachrichtigungKarte({ verbindung }: { verbindung: Verbindung }) {
|
|
const werte = verbindung.einstellungen
|
|
return (
|
|
<SektionsKarte
|
|
id="benachrichtigung"
|
|
titel="BENACHRICHTIGUNG"
|
|
intro="Rippy meldet sich aufs Handy, wenn ein Rip oder eine Kompression fertig ist oder etwas schiefgeht — über einen Discord-Webhook oder einen Telegram-Bot. Windows-Meldungen kommen immer."
|
|
>
|
|
<Schalter an={werte['benachrichtigungen'] !== 'aus'} aufWechsel={(an) => setzen('benachrichtigungen', an ? 'an' : 'aus')} label="Benachrichtigungen verschicken" />
|
|
<Feld
|
|
name="Discord-Webhook"
|
|
hinweis="Discord → Servereinstellungen → Integrationen → Webhooks → URL kopieren"
|
|
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" hinweis="deine ID, z. B. von @userinfobot" wert={werte['telegramChatId'] ?? ''} aufAendern={(w) => setzen('telegramChatId', w)} />
|
|
</div>
|
|
<button onClick={() => benachrichtigungTesten()} className={KNOPF_LEISE}>
|
|
Testnachricht schicken
|
|
</button>
|
|
</SektionsKarte>
|
|
)
|
|
}
|
|
|
|
export function AllgemeinKarte({ verbindung, haupt }: { verbindung: Verbindung; haupt: HauptStatus | null }) {
|
|
const werte = verbindung.einstellungen
|
|
const kern = verbindung.kern
|
|
const db = kern?.datenbank ?? null
|
|
const dbText = db === null ? '—' : db.ok ? db.pfad : `FEHLER — ${db.fehler}`
|
|
return (
|
|
<SektionsKarte id="allgemein" titel="ALLGEMEIN" 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)" />
|
|
<label className="block max-w-sm">
|
|
<span className="text-xs font-semibold text-dunst">Erscheinungsbild</span>
|
|
<select value={werte['design'] ?? 'dunkel'} onChange={(e) => setzen('design', e.target.value)} className={FELD_KLASSE}>
|
|
<option value="dunkel">Dunkel — der Kinosaal (Vorgabe)</option>
|
|
<option value="hell">Hell — Papier und Gold</option>
|
|
<option value="system">Wie Windows eingestellt</option>
|
|
</select>
|
|
</label>
|
|
<div className="flex flex-wrap gap-2">
|
|
<button onClick={() => void window.rippy.protokollOeffnen()} className={KNOPF_LEISE} title="Eine Datei je Tag, 14 Tage aufgehoben — Haupt und Kern schreiben hinein">
|
|
Protokoll öffnen
|
|
</button>
|
|
<button onClick={() => void window.rippy.ordnerOeffnen(werte['ablage'] ?? '')} className={KNOPF_LEISE}>
|
|
Ablage im Explorer
|
|
</button>
|
|
{(werte['arbeitsordner'] ?? '').length > 0 && (
|
|
<button onClick={() => void window.rippy.ordnerOeffnen(werte['arbeitsordner'] ?? '')} className={KNOPF_LEISE}>
|
|
Arbeitsordner im Explorer
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="rounded-xl border border-tinte/8 bg-buehne/60 p-3 font-mono text-[11px] leading-5 text-nebel">
|
|
<p>
|
|
Rippy v{haupt?.version ?? '…'}
|
|
{kern !== null && ` · Kern: Node ${kern.nodeVersion} (PID ${kern.pid})`}
|
|
{haupt !== null && ` · Leine ${haupt.leine.ok ? 'gesetzt' : 'NICHT gesetzt'}`}
|
|
{verbindung.pongLatenzMs !== null && ` · Antwortzeit ${verbindung.pongLatenzMs} ms`}
|
|
</p>
|
|
<p className="break-all">Datenbank: {dbText}</p>
|
|
<p className="break-all">Protokoll: {haupt?.protokollOrdner ?? '—'}</p>
|
|
</div>
|
|
</SektionsKarte>
|
|
)
|
|
}
|
|
|
|
export function UpdateKarte({ verbindung, haupt }: { verbindung: Verbindung; haupt: HauptStatus | null }) {
|
|
// § 6.8: Der Prüf-Knopf ist während eines Rips aus — nicht, weil er
|
|
// nicht könnte, sondern weil Rippy dann bewusst nicht sucht.
|
|
const ripLaeuftGerade = Object.values(verbindung.rips).some((r) => LAUFENDE_PHASEN.includes(r.phase))
|
|
return (
|
|
<SektionsKarte id="update" titel="UPDATE" intro="Rippy holt neue Fassungen selbst aus dem Gitea — geprüft, geladen, beim nächsten Beenden installiert. Nie mitten in einem Rip oder einer Kompression.">
|
|
{/* Das Feld „Update-Adresse" ist am 01.09.2026 auf Zuruf des
|
|
Commanders verschwunden: Updates kommen immer vom Gitea, ein
|
|
Eingabefeld dafür war nur Ballast. Der Einstellungs-Schlüssel
|
|
`updateUrl` gilt weiterhin — wer ihn wirklich braucht, setzt
|
|
ihn in der Datenbank, und § 6.8 prüft ihn wie zuvor (nur
|
|
https://). */}
|
|
{haupt !== null && (
|
|
<div className="rounded-xl border border-tinte/8 bg-buehne/60 p-3 font-mono text-[11px] leading-5 text-nebel">
|
|
<p>
|
|
<span className="text-dunst">Stand:</span> {haupt.update.text}
|
|
</p>
|
|
{haupt.update.letzteErfolgreichePruefung.length > 0 && <p>letzte erfolgreiche Prüfung: {datumKurz(haupt.update.letzteErfolgreichePruefung)}</p>}
|
|
{haupt.update.neueVersion.length > 0 && <p className="text-gold-hell">Version {haupt.update.neueVersion} gefunden — die Karte in der Übersicht zeigt, was neu ist.</p>}
|
|
</div>
|
|
)}
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<button
|
|
onClick={() => void window.rippy.updatePruefen()}
|
|
disabled={ripLaeuftGerade || haupt?.update.laeuft === true}
|
|
className={KNOPF_LEISE}
|
|
title={ripLaeuftGerade ? 'Während eines Rips wird nicht gesucht — das ist Absicht (§ 6.8).' : 'Fragt sofort beim Update-Server nach.'}
|
|
>
|
|
{haupt?.update.laeuft === true ? 'sucht …' : 'Jetzt auf Updates prüfen'}
|
|
</button>
|
|
</div>
|
|
</SektionsKarte>
|
|
)
|
|
}
|
|
|
|
function WerkzeugZeile({
|
|
name,
|
|
pfad,
|
|
fehlt,
|
|
pflicht,
|
|
schluessel,
|
|
wert,
|
|
}: {
|
|
name: string
|
|
pfad: string | null
|
|
fehlt: string
|
|
pflicht: boolean
|
|
schluessel: string
|
|
wert: string
|
|
}) {
|
|
return (
|
|
<div className="rounded-xl border border-tinte/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>
|
|
<PfadFeld
|
|
name="Eigener Pfad"
|
|
hinweis="leer = automatische Suche; ein eingetragener Pfad gilt exakt"
|
|
wert={wert}
|
|
art="programm"
|
|
dialogTitel={`${name} wählen`}
|
|
aufAendern={(w) => setzen(schluessel, w)}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function WerkzeugeKarte({ verbindung }: { verbindung: Verbindung }) {
|
|
const werte = verbindung.einstellungen
|
|
const backends = verbindung.werkzeuge?.backends ?? []
|
|
return (
|
|
<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'] ?? ''}
|
|
/>
|
|
<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'] ?? ''}
|
|
/>
|
|
<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'] ?? ''}
|
|
/>
|
|
{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>
|
|
)
|
|
}
|