FUND 1 — 'Key korrekt, aber als ungueltig angezeigt': Stimmt beides. Live gemessen: Der aktuelle Forum-Key ist in Ordnung, aber MakeMKV 1.18.4 ist ZU ALT fuer ihn — makemkvcon meldet 5020 UND 5021 zusammen, und die alte Pruefung brach beim ersten Code ab und beschuldigte den Schluessel. urteilAusAusgabe sammelt jetzt ALLE Codes (5021 schlaegt 5020, MakeMKV-Version aus MSG 1005 wird mitgelesen) und die Kachel sagt den wahren Satz: 'Der Key ist in Ordnung — MakeMKV 1.18.4 ist zu alt. Rippy hat ihn entfernt, damit der Beta-Modus weiterlaeuft (Rips klappen trotzdem). Abhilfe: MakeMKV aktualisieren.' Neues Feld weiterBetrieb: Beta-Modus ist GELB (Warnung), nicht rot. Test mit der echten Ausgabe als Fixture. FUND 2 — 'Komprimieren auf CPU statt GPU': Der Default 'H.265 MKV 1080p30' ist ein CPU-Preset. Neue GEMESSENE Empfehlung (gemeinsam/preset-empfehlung.ts): aus den echten Backends und der echten Preset-Liste des eigenen HandBrake — VCN vor NVENC vor QSV vor CPU, nur Namen die WIRKLICH in der Liste stehen, HandBrake-Presets deckeln die Aufloesung nur (DVD bleibt SD). Kette: eigene Wahl > allgemeines Preset > Empfehlung > CPU-Default. Auf dem Commander-PC: bluray/dvd -> 'H.265 VCN 1080p', uhd -> 'H.265 VCN 2160p 4K'. Die Preset-Auswahl zeigt 'automatisch: <Empfehlung>' als Leer-Option. FUND 3 — 'keine Alternative waehlbar': 'Aendern …'-Dialog an der Laufwerks-Kachel (§ 5 Schritt 4): TMDb-Suche (Film + Serie, mit Postern), Klick uebernimmt — Nutzer-Wahl gilt als 100 % und steuert Ablage-Ordner, NFO und Poster. Neue Nachrichten metadaten-suchen / zuordnung-setzen / metadaten-vorschlaege. Dazu: Poster in der Laufwerks-Kachel (CSP um image.tmdb.org erweitert), Restzeit-Schaetzung am Fortschritt. 192 Tests gruen, Smoke gruen. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
0e1c927e7b
commit
c5dd90cc18
@@ -3,15 +3,33 @@
|
||||
// Bibliothek (§ 6.6). Design-Sprache wie das heutige Rippy: dunkel, Kacheln.
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { DiscInfoStand, GeraeteInfo, HauptStatus, RipStatus } from '../gemeinsam/nachrichten'
|
||||
import { presetEmpfehlung } from '../gemeinsam/preset-empfehlung'
|
||||
import {
|
||||
aufVerbindung,
|
||||
auswerfen,
|
||||
einstellungSetzen,
|
||||
metadatenSuchen,
|
||||
ripAbbrechen,
|
||||
ripStarten,
|
||||
verbindungStarten,
|
||||
zuordnungSetzen,
|
||||
type Verbindung,
|
||||
} from './kernverbindung'
|
||||
import type { MetaVorschlag } from '../gemeinsam/nachrichten'
|
||||
|
||||
function posterUrlKlein(pfad: string): string {
|
||||
if (pfad.length === 0) return ''
|
||||
if (pfad.startsWith('http')) return pfad
|
||||
return `https://image.tmdb.org/t/p/w154${pfad}`
|
||||
}
|
||||
|
||||
function restzeitText(prozent: number, startMs: number | undefined): string {
|
||||
if (startMs === undefined || prozent <= 2 || prozent >= 100) return ''
|
||||
const verstrichen = (Date.now() - startMs) / 1000
|
||||
const restS = Math.round((verstrichen * (100 - prozent)) / prozent)
|
||||
if (restS < 90) return `· ~${restS} s`
|
||||
return `· ~${Math.round(restS / 60)} min`
|
||||
}
|
||||
|
||||
function istHauptStatus(wert: unknown): wert is HauptStatus {
|
||||
return (
|
||||
@@ -68,15 +86,101 @@ function Kachel({ titel, zustand, zeilen }: KachelProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// § 5 Schritt 4: „nein → fragen" — Suche + Auswahl der richtigen Metadaten.
|
||||
function AendernDialog({
|
||||
id,
|
||||
startText,
|
||||
vorschlaege,
|
||||
schliessen,
|
||||
}: {
|
||||
id: string
|
||||
startText: string
|
||||
vorschlaege: MetaVorschlag[]
|
||||
schliessen: () => void
|
||||
}) {
|
||||
const [text, setText] = useState(startText)
|
||||
useEffect(() => {
|
||||
metadatenSuchen(id, startText)
|
||||
// Nur beim Öffnen — weitere Suchen löst der Knopf aus.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6" onClick={schliessen}>
|
||||
<div
|
||||
className="max-h-[80vh] w-full max-w-lg overflow-y-auto rounded-xl border border-slate-600 bg-slate-900 p-4 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="mb-3 text-sm font-semibold text-slate-200">Was ist das wirklich?</h3>
|
||||
<div className="mb-3 flex gap-2">
|
||||
<input
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') metadatenSuchen(id, text)
|
||||
}}
|
||||
placeholder="Titel suchen …"
|
||||
className="flex-1 rounded-lg border border-slate-600 bg-slate-950 px-3 py-1.5 font-mono text-xs text-slate-200 focus:border-sky-400 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={() => metadatenSuchen(id, text)}
|
||||
className="rounded-lg border border-sky-500/60 px-3 py-1.5 text-xs text-sky-300 hover:bg-sky-500/10"
|
||||
>
|
||||
Suchen
|
||||
</button>
|
||||
</div>
|
||||
{vorschlaege.length === 0 ? (
|
||||
<p className="font-mono text-xs text-slate-500">Keine Treffer — anderen Suchtext probieren.</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{vorschlaege.map((v) => (
|
||||
<button
|
||||
key={`${v.typ}-${v.tmdbId}`}
|
||||
onClick={() => {
|
||||
zuordnungSetzen(id, v)
|
||||
schliessen()
|
||||
}}
|
||||
className="flex w-full items-center gap-3 rounded-lg border border-slate-700/60 bg-slate-800/40 p-2 text-left hover:border-sky-400/60 hover:bg-slate-800"
|
||||
>
|
||||
{v.posterPfad.length > 0 ? (
|
||||
<img src={posterUrlKlein(v.posterPfad)} alt="" className="h-16 w-11 rounded object-cover" />
|
||||
) : (
|
||||
<div className="flex h-16 w-11 items-center justify-center rounded bg-slate-700 text-lg">🎬</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm text-slate-200">
|
||||
{v.titel}
|
||||
{v.jahr !== null ? ` (${v.jahr})` : ''}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">{v.typ === 'tv' ? 'Serie' : 'Film'}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 text-right">
|
||||
<button onClick={schliessen} className="rounded-lg border border-slate-600 px-3 py-1 text-xs text-slate-300 hover:bg-slate-700">
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LaufwerkKachel({
|
||||
geraet,
|
||||
rip,
|
||||
info,
|
||||
vorschlaege,
|
||||
ripStartMs,
|
||||
}: {
|
||||
geraet: GeraeteInfo
|
||||
rip: RipStatus | undefined
|
||||
info: DiscInfoStand | undefined
|
||||
vorschlaege: MetaVorschlag[]
|
||||
ripStartMs: number | undefined
|
||||
}) {
|
||||
const [aendernOffen, setAendernOffen] = useState(false)
|
||||
const ripLaeuft = rip !== undefined && LAUFENDE_PHASEN.includes(rip.phase)
|
||||
const zustand = geraet.status === 'ready' ? 'ok' : geraet.status === 'empty' ? 'wartet' : 'fehler'
|
||||
const punktFarbe = ripLaeuft
|
||||
@@ -121,32 +225,59 @@ function LaufwerkKachel({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-0.5 font-mono text-xs text-slate-400">
|
||||
<p>{geraet.modell.length > 0 ? geraet.modell : 'Modell unbekannt'}</p>
|
||||
{geraet.status === 'ready' && (
|
||||
<p className="text-slate-200">
|
||||
{TYP_NAMEN[geraet.typ]}
|
||||
{geraet.groesseBytes > 0 ? ` · ${gb(geraet.groesseBytes)}` : ''}
|
||||
</p>
|
||||
)}
|
||||
{geraet.status === 'ready' && info !== undefined && info.titel.length > 0 && (
|
||||
<p className={info.confidence >= 0.8 ? 'text-emerald-300' : 'text-amber-300'}>
|
||||
{sicherheitsWort(info.confidence)}: {info.titel}
|
||||
{info.jahr !== null ? ` (${info.jahr})` : ''} · {Math.round(info.confidence * 100)} %
|
||||
{info.quelle.length > 0 ? ` · ${info.quelle}` : ''}
|
||||
</p>
|
||||
)}
|
||||
{geraet.status === 'ready' && info !== undefined && info.schonGerippt !== null && (
|
||||
<p className="text-amber-300">
|
||||
⚠ Diese Disc wurde schon einmal gerippt ({info.schonGerippt.slice(0, 10)}) — siehe Bibliothek.
|
||||
</p>
|
||||
)}
|
||||
{geraet.status === 'empty' && <p>keine Disc eingelegt</p>}
|
||||
{geraet.status === 'unknown' && <p className="text-red-300">{geraet.grund}</p>}
|
||||
{geraet.status !== 'unknown' && geraet.grund.length > 0 && (
|
||||
<p className="text-amber-300">{geraet.grund}</p>
|
||||
<div className="mt-2 flex gap-3">
|
||||
{geraet.status === 'ready' && info !== undefined && info.posterPfad.length > 0 && (
|
||||
<img
|
||||
src={posterUrlKlein(info.posterPfad)}
|
||||
alt=""
|
||||
className="h-24 w-16 flex-none rounded-lg object-cover shadow-lg shadow-black/40"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0 flex-1 space-y-0.5 font-mono text-xs text-slate-400">
|
||||
<p>{geraet.modell.length > 0 ? geraet.modell : 'Modell unbekannt'}</p>
|
||||
{geraet.status === 'ready' && (
|
||||
<p className="text-slate-200">
|
||||
{TYP_NAMEN[geraet.typ]}
|
||||
{geraet.groesseBytes > 0 ? ` · ${gb(geraet.groesseBytes)}` : ''}
|
||||
</p>
|
||||
)}
|
||||
{geraet.status === 'ready' && info !== undefined && info.titel.length > 0 && (
|
||||
<p className={info.confidence >= 0.8 ? 'text-emerald-300' : 'text-amber-300'}>
|
||||
{sicherheitsWort(info.confidence)}: {info.titel}
|
||||
{info.jahr !== null ? ` (${info.jahr})` : ''} · {Math.round(info.confidence * 100)} %
|
||||
{info.quelle.length > 0 ? ` · ${info.quelle}` : ''}
|
||||
</p>
|
||||
)}
|
||||
{geraet.status === 'ready' && (
|
||||
<button
|
||||
onClick={() => setAendernOffen(true)}
|
||||
disabled={ripLaeuft}
|
||||
className="mt-1 rounded border border-slate-600 px-2 py-0.5 text-xs text-slate-300 hover:bg-slate-700 disabled:opacity-40"
|
||||
title="Falsch erkannt? Selbst suchen und wählen — die Ablage folgt deiner Wahl."
|
||||
>
|
||||
Ändern …
|
||||
</button>
|
||||
)}
|
||||
{geraet.status === 'ready' && info !== undefined && info.schonGerippt !== null && (
|
||||
<p className="text-amber-300">
|
||||
⚠ Diese Disc wurde schon einmal gerippt ({info.schonGerippt.slice(0, 10)}) — siehe Bibliothek.
|
||||
</p>
|
||||
)}
|
||||
{geraet.status === 'empty' && <p>keine Disc eingelegt</p>}
|
||||
{geraet.status === 'unknown' && <p className="text-red-300">{geraet.grund}</p>}
|
||||
{geraet.status !== 'unknown' && geraet.grund.length > 0 && (
|
||||
<p className="text-amber-300">{geraet.grund}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{aendernOffen && (
|
||||
<AendernDialog
|
||||
id={geraet.id}
|
||||
startText={info !== undefined && info.titel.length > 0 ? info.titel : ''}
|
||||
vorschlaege={vorschlaege}
|
||||
schliessen={() => setAendernOffen(false)}
|
||||
/>
|
||||
)}
|
||||
{rip !== undefined && (
|
||||
<div className="mt-3 space-y-1.5 border-t border-slate-700/60 pt-3">
|
||||
{ripLaeuft && (
|
||||
@@ -168,7 +299,9 @@ function LaufwerkKachel({
|
||||
: 'text-slate-300'
|
||||
}`}
|
||||
>
|
||||
{ripLaeuft && rip.prozent >= 0 ? `${rip.prozent} % — ` : ''}
|
||||
{ripLaeuft && rip.prozent >= 0
|
||||
? `${rip.prozent} % ${restzeitText(rip.prozent, ripStartMs)} — `
|
||||
: ''}
|
||||
{rip.text}
|
||||
</p>
|
||||
{rip.dateien.map((datei) => (
|
||||
@@ -220,12 +353,14 @@ function PresetWahl({
|
||||
schluessel,
|
||||
wert,
|
||||
presets,
|
||||
empfehlung,
|
||||
aufAendern,
|
||||
}: {
|
||||
name: string
|
||||
schluessel: string
|
||||
wert: string
|
||||
presets: string[]
|
||||
empfehlung: string
|
||||
aufAendern: (schluessel: string, wert: string) => void
|
||||
}) {
|
||||
return (
|
||||
@@ -236,7 +371,7 @@ function PresetWahl({
|
||||
onChange={(e) => aufAendern(schluessel, e.target.value)}
|
||||
className="mt-1 w-full rounded-lg border border-slate-600 bg-slate-900 px-3 py-1.5 font-mono text-xs text-slate-200 focus:border-sky-400 focus:outline-none"
|
||||
>
|
||||
<option value="">(allgemeines Preset)</option>
|
||||
<option value="">{empfehlung.length > 0 ? `automatisch: ${empfehlung}` : '(allgemeines Preset)'}</option>
|
||||
<option value="keine">NICHT komprimieren (verlustfrei lassen)</option>
|
||||
{presets.map((preset) => (
|
||||
<option key={preset} value={preset}>
|
||||
@@ -252,6 +387,8 @@ function Einstellungen({ verbindung, haupt }: { verbindung: Verbindung; haupt: H
|
||||
const werte = verbindung.einstellungen
|
||||
const presets = verbindung.werkzeuge?.presets ?? []
|
||||
const setzen = einstellungSetzen
|
||||
const empfehlungFuer = (typ: string): string =>
|
||||
presetEmpfehlung(typ, verbindung.werkzeuge?.backends ?? [], presets)
|
||||
|
||||
const ordnerWaehlen = async (): Promise<void> => {
|
||||
const ordner = await window.rippy.ordnerWaehlen('Ablage-Ordner wählen')
|
||||
@@ -290,9 +427,9 @@ function Einstellungen({ verbindung, haupt }: { verbindung: Verbindung; haupt: H
|
||||
Nach dem Rip komprimieren (der verlustfreie Rip bleibt bis zum Erfolg erhalten)
|
||||
</label>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<PresetWahl name="DVD" schluessel="transcodePresetDvd" wert={werte['transcodePresetDvd'] ?? ''} presets={presets} aufAendern={setzen} />
|
||||
<PresetWahl name="Blu-ray" schluessel="transcodePresetBluray" wert={werte['transcodePresetBluray'] ?? ''} presets={presets} aufAendern={setzen} />
|
||||
<PresetWahl name="4K-UHD" schluessel="transcodePresetUhd" wert={werte['transcodePresetUhd'] ?? ''} presets={presets} aufAendern={setzen} />
|
||||
<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>
|
||||
<Feld
|
||||
name="Allgemeines Preset"
|
||||
@@ -618,6 +755,8 @@ export default function App() {
|
||||
laufwerke: [],
|
||||
rips: {},
|
||||
discInfos: {},
|
||||
vorschlaege: {},
|
||||
ripStart: {},
|
||||
einstellungen: {},
|
||||
werkzeuge: null,
|
||||
bibliothek: [],
|
||||
@@ -689,6 +828,8 @@ export default function App() {
|
||||
geraet={geraet}
|
||||
rip={verbindung.rips[geraet.id]}
|
||||
info={verbindung.discInfos[geraet.id]}
|
||||
vorschlaege={verbindung.vorschlaege[geraet.id] ?? []}
|
||||
ripStartMs={verbindung.ripStart[geraet.id]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -744,7 +885,9 @@ export default function App() {
|
||||
? 'wartet'
|
||||
: verbindung.schluessel.angenommen === true
|
||||
? 'ok'
|
||||
: 'fehler'
|
||||
: verbindung.schluessel.weiterBetrieb
|
||||
? 'wartet' // gelb: Rips laufen, aber mit Handlungsempfehlung
|
||||
: 'fehler'
|
||||
}
|
||||
zeilen={
|
||||
verbindung.schluessel === null
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type DiscInfoStand,
|
||||
type GeraeteInfo,
|
||||
type KernNachricht,
|
||||
type MetaVorschlag,
|
||||
type RipStatus,
|
||||
type WerkzeugAuskunft,
|
||||
} from '../gemeinsam/nachrichten'
|
||||
@@ -26,10 +27,21 @@ export interface Verbindung {
|
||||
rips: Record<string, RipStatus>
|
||||
/** Was Rippy über die eingelegte Disc weiß (§ 5 Schritt 3). */
|
||||
discInfos: Record<string, DiscInfoStand>
|
||||
/** Suchergebnisse des Ändern-Dialogs je Laufwerk (§ 5 Schritt 4). */
|
||||
vorschlaege: Record<string, MetaVorschlag[]>
|
||||
/** Wann der laufende Vorgang je Laufwerk begann (für die Restzeit). */
|
||||
ripStart: Record<string, number>
|
||||
einstellungen: Record<string, string>
|
||||
werkzeuge: WerkzeugAuskunft | null
|
||||
bibliothek: BibliothekEintrag[]
|
||||
schluessel: { quelle: string; angenommen: boolean | null; gueltigBis: string; geholtAm: string; text: string } | null
|
||||
schluessel: {
|
||||
quelle: string
|
||||
angenommen: boolean | null
|
||||
weiterBetrieb: boolean
|
||||
gueltigBis: string
|
||||
geholtAm: string
|
||||
text: string
|
||||
} | null
|
||||
/** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */
|
||||
meldungen: string[]
|
||||
}
|
||||
@@ -45,6 +57,8 @@ const stand: Verbindung = {
|
||||
laufwerke: [],
|
||||
rips: {},
|
||||
discInfos: {},
|
||||
vorschlaege: {},
|
||||
ripStart: {},
|
||||
einstellungen: {},
|
||||
werkzeuge: null,
|
||||
bibliothek: [],
|
||||
@@ -62,6 +76,7 @@ function melden(): void {
|
||||
laufwerke: [...stand.laufwerke],
|
||||
rips: { ...stand.rips },
|
||||
discInfos: { ...stand.discInfos },
|
||||
vorschlaege: { ...stand.vorschlaege },
|
||||
einstellungen: { ...stand.einstellungen },
|
||||
bibliothek: [...stand.bibliothek],
|
||||
meldungen: [...stand.meldungen],
|
||||
@@ -115,6 +130,9 @@ function kernNachricht(nachricht: KernNachricht): void {
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'metadaten-vorschlaege':
|
||||
stand.vorschlaege = { ...stand.vorschlaege, [nachricht.id]: nachricht.vorschlaege }
|
||||
break
|
||||
case 'einstellungen':
|
||||
stand.einstellungen = nachricht.werte
|
||||
break
|
||||
@@ -130,6 +148,13 @@ function kernNachricht(nachricht: KernNachricht): void {
|
||||
break
|
||||
case 'rip-status': {
|
||||
stand.rips = { ...stand.rips, [nachricht.status.id]: nachricht.status }
|
||||
const laeuft = ['liest-info', 'rippt', 'komprimiert', 'legt-ab'].includes(nachricht.status.phase)
|
||||
if (laeuft && stand.ripStart[nachricht.status.id] === undefined) {
|
||||
stand.ripStart = { ...stand.ripStart, [nachricht.status.id]: Date.now() }
|
||||
} else if (!laeuft) {
|
||||
const { [nachricht.status.id]: _weg, ...rest } = stand.ripStart
|
||||
stand.ripStart = rest
|
||||
}
|
||||
if (nachricht.status.phase === 'fertig') {
|
||||
meldung(
|
||||
`Laufwerk ${nachricht.status.id}: Rip fertig — ${nachricht.status.dateien.length} Datei(en)` +
|
||||
@@ -187,6 +212,7 @@ export function aufVerbindung(h: Horcher): () => void {
|
||||
laufwerke: [...stand.laufwerke],
|
||||
rips: { ...stand.rips },
|
||||
discInfos: { ...stand.discInfos },
|
||||
vorschlaege: { ...stand.vorschlaege },
|
||||
einstellungen: { ...stand.einstellungen },
|
||||
bibliothek: [...stand.bibliothek],
|
||||
meldungen: [...stand.meldungen],
|
||||
@@ -215,6 +241,16 @@ export function ripAbbrechen(id: string): void {
|
||||
anKern({ art: 'rip-abbruch', id }, `Rip-Abbruch ${id}`)
|
||||
}
|
||||
|
||||
export function metadatenSuchen(id: string, text: string): void {
|
||||
if (port === null) return
|
||||
port.postMessage({ art: 'metadaten-suchen', id, text })
|
||||
}
|
||||
|
||||
export function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): void {
|
||||
if (port === null) return
|
||||
port.postMessage({ art: 'zuordnung-setzen', id, vorschlag })
|
||||
}
|
||||
|
||||
export function einstellungSetzen(schluessel: string, wert: string): void {
|
||||
if (port === null) {
|
||||
meldung(`⚠ Einstellung ${schluessel}: keine Verbindung zum Kern`)
|
||||
|
||||
Reference in New Issue
Block a user