// frontend\src\components\ui\RecorderSettings.tsx 'use client' import { useEffect, useRef, useState, type Dispatch, type ReactNode, type SetStateAction } from 'react' import Button from './Button' import Card from './Card' import LabeledSwitch from './LabeledSwitch' import Task from './Task' import TaskList from './TaskList' import type { TaskItem } from './TaskList' import PostgresUrlModal from './PostgresUrlModal' import { CheckIcon, ChevronDownIcon, XMarkIcon } from '@heroicons/react/24/solid' import { startRegistration } from '@simplewebauthn/browser' type RecorderSettings = { databaseUrl?: string hasDbPassword?: boolean recordDir: string doneDir: string ffmpegPath?: string autoAddToDownloadList?: boolean autoStartAddedDownloads?: boolean enableConcurrentDownloadsLimit?: boolean maxConcurrentDownloads?: number useProviderApis?: boolean useChaturbateApi?: boolean useMyFreeCamsApi?: boolean autoDeleteSmallDownloads?: boolean autoDeleteSmallDownloadsBelowMB?: number autoDeleteSmallDownloadsKeepFavorites?: boolean autoDeleteLowRatedDownloads?: boolean autoDeleteLowRatedDownloadsMaxStars?: number blurPreviews?: boolean teaserPlayback?: 'still' | 'hover' | 'all' teaserAudio?: boolean lowDiskPauseBelowGB?: number enableNotifications?: boolean generateAssetsMeta?: boolean generateAssetsThumb?: boolean generateAssetsTeaser?: boolean generateAssetsSprites?: boolean generateAssetsAnalyze?: boolean enrichPostworkEnabled?: boolean trainingRecognitionEnabled?: boolean trainingDetectorEpochs?: number } type AuthDevice = { id: string label: string type?: string createdAt?: string configuredAt?: string lastUsedAt?: string active?: boolean signCount?: number backupState?: boolean backupEligible?: boolean } type AuthSecurityStatus = { authenticated?: boolean totpEnabled?: boolean totpConfigured?: boolean passkeyConfigured?: boolean passkeyCount?: number totpDevice?: AuthDevice | null passkeys?: AuthDevice[] } type AIServerStatus = { ok: boolean running: boolean url?: string statusCode?: number error?: string } type AppLog = { ok: boolean exists?: boolean path?: string sizeBytes?: number log?: string error?: string } type DiskStatus = { emergency: boolean pauseGB: number resumeGB: number freeBytes: number freeBytesHuman: string recordPath?: string } function unwrapWebAuthnOptions(options: any): T { const unwrapped = options?.publicKey ?? options if (!unwrapped?.challenge) { throw new Error('Passkey Setup fehlgeschlagen: challenge fehlt in den WebAuthn-Options.') } return unwrapped as T } function formatSecurityDate(value?: string) { const raw = String(value ?? '').trim() if (!raw) return 'Unbekannt' const d = new Date(raw) if (!Number.isFinite(d.getTime())) return 'Unbekannt' return d.toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short', }) } function statusPill(active: boolean, activeText = 'Aktiv', inactiveText = 'Aus') { return ( {active ? activeText : inactiveText} ) } function SecurityDeviceRow(props: { icon: string title: string subtitle: string meta?: string badge?: string actions?: ReactNode }) { return (
{props.icon}
{props.title}
{props.badge ? ( {props.badge} ) : null}
{props.subtitle}
{props.meta ? (
{props.meta}
) : null}
{props.actions ? (
{props.actions}
) : null}
) } function SettingsSection(props: { id: string title: string description?: string icon?: ReactNode actions?: ReactNode defaultOpen?: boolean children: ReactNode }) { const storageKey = `recorder-settings:section:${props.id}:open` const [open, setOpen] = useState(() => { if (typeof window === 'undefined') return props.defaultOpen ?? true try { const raw = window.localStorage.getItem(storageKey) if (raw === '1') return true if (raw === '0') return false } catch { // ignore } return props.defaultOpen ?? true }) function toggleOpen() { setOpen((cur) => { const next = !cur try { window.localStorage.setItem(storageKey, next ? '1' : '0') } catch { // ignore } return next }) } return (
{open ? (
{props.children}
) : null}
) } const DEFAULTS: RecorderSettings = { databaseUrl: '', hasDbPassword: false, recordDir: 'record', doneDir: 'record/done', ffmpegPath: '', autoAddToDownloadList: true, autoStartAddedDownloads: true, enableConcurrentDownloadsLimit: false, maxConcurrentDownloads: 20, useProviderApis: false, useChaturbateApi: false, useMyFreeCamsApi: false, autoDeleteSmallDownloads: true, autoDeleteSmallDownloadsBelowMB: 200, autoDeleteSmallDownloadsKeepFavorites: true, autoDeleteLowRatedDownloads: false, autoDeleteLowRatedDownloadsMaxStars: 3, blurPreviews: false, teaserPlayback: 'hover', teaserAudio: false, lowDiskPauseBelowGB: 5, enableNotifications: true, generateAssetsMeta: true, generateAssetsThumb: true, generateAssetsTeaser: true, generateAssetsSprites: true, generateAssetsAnalyze: true, enrichPostworkEnabled: true, trainingRecognitionEnabled: true, trainingDetectorEpochs: 60, } type Props = { onAssetsGenerated?: () => void } function regeneratePhaseOrder(queue?: string, phase?: string) { const q = String(queue || '').trim().toLowerCase() const p = String(phase || '').trim().toLowerCase() const key = `${q}:${p}` switch (key) { case 'postwork:meta': return 0 case 'postwork:thumb': return 1 case 'postwork:teaser': return 2 case 'postwork:sprites': return 3 case 'enrich:analyze': return 4 default: return 999 } } function shortTaskFilename(name?: string, max = 52) { const s = String(name ?? '').trim() if (!s) return '' if (s.length <= max) return s return '…' + s.slice(-(max - 1)) } function regenerateTaskId(file: string) { return `regenerate-assets:${file}` } function regenerateTaskTitle(_file: string) { return 'Assets neu generieren' } function assetsPhaseLabel(label?: string, phase?: string) { const direct = String(label ?? '').trim() if (direct) return direct switch (String(phase ?? '').trim().toLowerCase()) { case 'meta': return 'Meta' case 'thumb': return 'Vorschaubild' case 'teaser': return 'Teaser' case 'sprites': return 'Sprites' case 'analyze': return 'Analyse' default: return '' } } function assetsTaskText(file?: string, label?: string, phase?: string) { const fn = shortTaskFilename(file, 38) const phaseText = assetsPhaseLabel(label, phase) if (fn && phaseText) return `${fn} · ${phaseText}` if (fn) return fn return phaseText } function parseTaskTimeMs(v: unknown) { if (typeof v === 'number' && Number.isFinite(v)) return v const s = String(v ?? '').trim() if (!s) return 0 const ms = Date.parse(s) return Number.isFinite(ms) ? ms : 0 } function assetTaskDomId(id: string) { return `generate-assets:${id}` } function checkVideosTaskDomId(id: string) { return `check-videos:${id}` } function cleanupTaskDomId(id: string) { return `cleanup:${id}` } function isCancelledError(err?: string) { const s = String(err ?? '').trim().toLowerCase() return s === 'abgebrochen' || s === 'abbruch' || s === 'cancelled' || s === 'canceled' } function upsertTask( setter: Dispatch>, next: TaskItem ) { setter((prev) => { const idx = prev.findIndex((t) => t.id === next.id) if (idx === -1) return [...prev, next] const copy = [...prev] copy[idx] = { ...copy[idx], ...next } return copy }) } function mapAssetJobToTask(job: any): TaskItem { const err = String(job?.error || '').trim() let status: TaskItem['status'] = 'idle' if (job?.queued) status = 'queued' else if (job?.running) status = 'running' else if (err) status = isCancelledError(err) ? 'cancelled' : 'error' else if (job?.finishedAt) status = 'done' return { id: assetTaskDomId(String(job?.id || '')), status, title: 'Assets generieren', text: status === 'running' ? assetsTaskText( String(job?.currentFile || '').trim(), String(job?.currentLabel || '').trim(), String(job?.currentPhase || '').trim() ) : String(job?.text || ''), done: Number(job?.done ?? 0), total: Number(job?.total ?? 0), err: status === 'error' ? err : undefined, cancellable: status === 'queued' || status === 'running', fading: false, queuedAtMs: parseTaskTimeMs(job?.queuedAt), startedAtMs: parseTaskTimeMs(job?.startedAt), } } function mapCheckVideosJobToTask(job: any): TaskItem { const err = String(job?.error || '').trim() let status: TaskItem['status'] = 'idle' if (job?.queued) status = 'queued' else if (job?.running) status = 'running' else if (err) status = isCancelledError(err) ? 'cancelled' : 'error' else if (job?.finishedAt) status = 'done' return { id: checkVideosTaskDomId(String(job?.id || '')), status, title: 'Downloads prüfen', text: status === 'running' ? (String(job?.currentFile || '').trim() ? shortTaskFilename(String(job?.currentFile || '').trim(), 52) : 'Prüfe Videos…') : String(job?.text || ''), done: Number(job?.done ?? 0), total: Number(job?.total ?? 0), err: status === 'error' ? err : undefined, cancellable: status === 'queued' || status === 'running', fading: false, queuedAtMs: parseTaskTimeMs(job?.queuedAt), startedAtMs: parseTaskTimeMs(job?.startedAt), } } function mapCleanupJobToTask(job: any): TaskItem { const rawId = String(job?.id || '') const err = String(job?.error || '').trim() let status: TaskItem['status'] = 'idle' if (job?.queued) status = 'queued' else if (job?.running) status = 'running' else if (err) status = isCancelledError(err) ? 'cancelled' : 'error' else if (job?.finishedAt) status = 'done' return { id: cleanupTaskDomId(rawId), status, title: cleanupTaskTitle(rawId), text: String(job?.text || job?.currentFile || ''), done: Number(job?.done ?? 0), total: Number(job?.total ?? 0), err: status === 'error' ? err : undefined, cancellable: job?.cancellable !== false && (status === 'queued' || status === 'running'), fading: false, queuedAtMs: parseTaskTimeMs(job?.queuedAt), startedAtMs: parseTaskTimeMs(job?.startedAt), } } function cleanupTaskTitle(rawId: string) { const id = String(rawId || '').trim().toLowerCase() if (id.startsWith('cleanup-leftovers-startup')) return 'Startup-Cleanup' if (id.startsWith('cleanup-leftovers-hourly')) return 'Stündlicher Cleanup' if (id.startsWith('cleanup-leftovers')) return 'Record-Reste prüfen' return 'Aufräumen' } function databaseBackupFilename(contentDisposition: string | null) { const header = String(contentDisposition ?? '').trim() if (!header) return 'nsfwapp-db-backup.dump' const encoded = /filename\*=UTF-8''([^;]+)/i.exec(header) if (encoded?.[1]) { try { return decodeURIComponent(encoded[1].trim().replace(/^"|"$/g, '')) } catch { return encoded[1].trim().replace(/^"|"$/g, '') } } const plain = /filename="?([^";]+)"?/i.exec(header) return plain?.[1]?.trim() || 'nsfwapp-db-backup.dump' } function formatDatabaseBackupBytes(bytes: unknown) { const n = Number(bytes) if (!Number.isFinite(n) || n <= 0) return '' if (n < 1024) return `${Math.round(n)} B` if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB` if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB` return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB` } const SETTINGS_RATING_STAR_PATH = 'M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.176 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 9.72c-.783-.57-.38-1.81.588-1.81H7.03a1 1 0 00.951-.69l1.07-3.292z' function settingsRatingTone(stars: number) { switch (stars) { case 1: return 'text-yellow-300 opacity-55' case 2: return 'text-yellow-300 opacity-65' case 3: return 'text-yellow-300 opacity-80' case 4: return 'text-yellow-300 opacity-95' default: return 'text-yellow-200 opacity-100' } } function RatingThresholdStar({ stars, filled, }: { stars: number filled: boolean }) { return ( ) } function RatingThresholdStars({ value, disabled, onChange, }: { value: number | undefined disabled?: boolean onChange: (stars: number) => void }) { const selected = Math.max(1, Math.min(5, Math.round(Number(value ?? 3)))) return (
{[1, 2, 3, 4, 5].map((stars) => { const filled = stars <= selected const checked = stars === selected return ( ) })}
Löscht bis einschließlich {selected} Stern{selected === 1 ? '' : 'e'}.
) } export default function RecorderSettings({ onAssetsGenerated }: Props) { const [value, setValue] = useState(DEFAULTS) const [saving, setSaving] = useState(false) const [saveSuccessUntilMs, setSaveSuccessUntilMs] = useState(0) const saveSuccessTimerRef = useRef(null) const appLogPreRef = useRef(null) const dbRestoreInputRef = useRef(null) const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | null>(null) const [msg, setMsg] = useState(null) const [err, setErr] = useState(null) const [diskStatus, setDiskStatus] = useState(null) const [aiServerStatus, setAiServerStatus] = useState(null) const [appLog, setAppLog] = useState(null) const [appLogLoading, setAppLogLoading] = useState(false) const [appLogOpen, setAppLogOpen] = useState(false) const [dbModalOpen, setDbModalOpen] = useState(false) const [loadedDatabaseUrl, setLoadedDatabaseUrl] = useState('') const [pendingDbPassword, setPendingDbPassword] = useState('') // wird nur beim Speichern gesendet const [dbBackupBusy, setDbBackupBusy] = useState(false) const [dbRestoreBusy, setDbRestoreBusy] = useState(false) const [dbMaintenanceMsg, setDbMaintenanceMsg] = useState(null) const [dbMaintenanceErr, setDbMaintenanceErr] = useState(null) const now = Date.now() const saveSucceeded = saveSuccessUntilMs > now const saveFailed = Boolean(err) && !saving && !saveSucceeded const currentDatabaseUrl = String((value as any).databaseUrl ?? '').trim() const databaseSettingsDirty = currentDatabaseUrl !== loadedDatabaseUrl || Boolean((pendingDbPassword || '').trim()) const dbMaintenanceBusy = dbBackupBusy || dbRestoreBusy const [authStatus, setAuthStatus] = useState(null) const [securityBusy, setSecurityBusy] = useState(false) const [currentPassword, setCurrentPassword] = useState('') const [newPassword, setNewPassword] = useState('') const [newPasswordRepeat, setNewPasswordRepeat] = useState('') const [disable2FAPassword, setDisable2FAPassword] = useState('') const [disable2FACode, setDisable2FACode] = useState('') const [disable2FAFieldError, setDisable2FAFieldError] = useState<{ password?: string code?: string } | null>(null) const [passkeySupported, setPasskeySupported] = useState(false) const [passkeyFeedback, setPasskeyFeedback] = useState<{ kind: 'loading' | 'success' | 'error' text: string } | null>(null) type SaveUiState = 'idle' | 'saving' | 'success' | 'error' const saveUiState: SaveUiState = saving ? 'saving' : saveSucceeded ? 'success' : saveFailed ? 'error' : 'idle' function scrollAppLogToBottom() { window.requestAnimationFrame(() => { const el = appLogPreRef.current if (!el) return el.scrollTop = el.scrollHeight }) } const saveButton = (() => { if (saveUiState === 'saving') { return { text: 'Speichern…', color: 'blue' as const, icon: null, // Spinner kommt über isLoading isLoading: true, } } if (saveUiState === 'success') { return { text: 'Gespeichert', color: 'emerald' as const, icon: , isLoading: false, } } if (saveUiState === 'error') { return { text: 'Fehler', color: 'red' as const, icon: , isLoading: false, } } return { text: 'Speichern', color: 'indigo' as const, icon: null, isLoading: false, } })() const [assetTasks, setAssetTasks] = useState([]) const [checkVideoTasks, setCheckVideoTasks] = useState([]) const [cleanupTasks, setCleanupTasks] = useState([]) const [regenerateAssetsTasks, setRegenerateAssetsTasks] = useState([]) const reportedFinishedAssetTaskIdsRef = useRef>(new Set()) const pauseGB = Number(value.lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB ?? 5) const uiPauseGB = diskStatus?.pauseGB ?? pauseGB const uiResumeGB = diskStatus?.resumeGB ?? (pauseGB + 3) async function startAssetsTaskFromCurrentSwitches() { setErr(null) const payload = { meta: !!value.generateAssetsMeta, thumb: !!value.generateAssetsThumb, teaser: !!value.generateAssetsTeaser, sprites: !!value.generateAssetsSprites, analyze: !!value.generateAssetsAnalyze, } if (!Object.values(payload).some(Boolean)) { setErr('Mindestens eine Asset-Phase aktivieren.') return null } const res = await fetch('/api/tasks/generate-assets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, cache: 'no-store', body: JSON.stringify(payload), }) if (!res.ok) { const t = await res.text().catch(() => '') throw new Error(t || `HTTP ${res.status}`) } return await res.json().catch(() => null) } useEffect(() => { return () => { if (saveSuccessTimerRef.current != null) { window.clearTimeout(saveSuccessTimerRef.current) saveSuccessTimerRef.current = null } } }, []) useEffect(() => { setPasskeySupported( typeof window !== 'undefined' && typeof window.PublicKeyCredential !== 'undefined' ) }, []) useEffect(() => { void loadAuthStatus() }, []) useEffect(() => { let alive = true fetch('/api/settings', { cache: 'no-store' }) .then(async (r) => { if (!r.ok) throw new Error(await r.text()) return r.json() }) .then((data: RecorderSettings) => { if (!alive) return setValue({ databaseUrl: String((data as any).databaseUrl ?? ''), hasDbPassword: Boolean((data as any).hasDbPassword ?? false), recordDir: (data.recordDir || DEFAULTS.recordDir).toString(), doneDir: (data.doneDir || DEFAULTS.doneDir).toString(), ffmpegPath: String(data.ffmpegPath ?? DEFAULTS.ffmpegPath ?? ''), // ✅ falls backend die Felder noch nicht hat -> defaults nutzen autoAddToDownloadList: data.autoAddToDownloadList ?? DEFAULTS.autoAddToDownloadList, autoStartAddedDownloads: (data as any).autoStartAddedDownloads ?? DEFAULTS.autoStartAddedDownloads, enableConcurrentDownloadsLimit: (data as any).enableConcurrentDownloadsLimit ?? DEFAULTS.enableConcurrentDownloadsLimit, maxConcurrentDownloads: (data as any).maxConcurrentDownloads ?? DEFAULTS.maxConcurrentDownloads, useProviderApis: (data as any).useProviderApis ?? Boolean((data as any).useChaturbateApi || (data as any).useMyFreeCamsApi), useChaturbateApi: (data as any).useChaturbateApi ?? DEFAULTS.useChaturbateApi, useMyFreeCamsApi: (data as any).useMyFreeCamsApi ?? DEFAULTS.useMyFreeCamsApi, autoDeleteSmallDownloads: data.autoDeleteSmallDownloads ?? DEFAULTS.autoDeleteSmallDownloads, autoDeleteSmallDownloadsBelowMB: data.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB, autoDeleteSmallDownloadsKeepFavorites: (data as any).autoDeleteSmallDownloadsKeepFavorites ?? DEFAULTS.autoDeleteSmallDownloadsKeepFavorites, autoDeleteLowRatedDownloads: (data as any).autoDeleteLowRatedDownloads ?? DEFAULTS.autoDeleteLowRatedDownloads, autoDeleteLowRatedDownloadsMaxStars: (data as any).autoDeleteLowRatedDownloadsMaxStars ?? DEFAULTS.autoDeleteLowRatedDownloadsMaxStars, blurPreviews: data.blurPreviews ?? DEFAULTS.blurPreviews, teaserPlayback: (data as any).teaserPlayback ?? DEFAULTS.teaserPlayback, teaserAudio: (data as any).teaserAudio ?? DEFAULTS.teaserAudio, lowDiskPauseBelowGB: (data as any).lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB, enableNotifications: (data as any).enableNotifications ?? DEFAULTS.enableNotifications, generateAssetsMeta: (data as any).generateAssetsMeta ?? DEFAULTS.generateAssetsMeta, generateAssetsThumb: (data as any).generateAssetsThumb ?? DEFAULTS.generateAssetsThumb, generateAssetsTeaser: (data as any).generateAssetsTeaser ?? DEFAULTS.generateAssetsTeaser, generateAssetsSprites: (data as any).generateAssetsSprites ?? DEFAULTS.generateAssetsSprites, generateAssetsAnalyze: (data as any).generateAssetsAnalyze ?? DEFAULTS.generateAssetsAnalyze, enrichPostworkEnabled: (data as any).enrichPostworkEnabled ?? DEFAULTS.enrichPostworkEnabled, trainingRecognitionEnabled: (data as any).trainingRecognitionEnabled ?? DEFAULTS.trainingRecognitionEnabled, trainingDetectorEpochs: (data as any).trainingDetectorEpochs ?? DEFAULTS.trainingDetectorEpochs, }) setLoadedDatabaseUrl(String((data as any).databaseUrl ?? '').trim()) }) .catch(() => { // backend evtl. noch alt -> defaults lassen }) return () => { alive = false } }, []) useEffect(() => { let alive = true const loadTasksStatus = async () => { try { const res = await fetch('/api/tasks/status', { cache: 'no-store' }) if (!res.ok) return const data = await res.json().catch(() => null) if (!alive || !data) return const assetJobs = Array.isArray(data?.generateAssetsJobs) ? data.generateAssetsJobs : [] setAssetTasks( assetJobs .map(mapAssetJobToTask) .filter((t: TaskItem) => t.status !== 'idle') ) const regenJobs = Array.isArray(data?.regenerateAssetsJobs) ? data.regenerateAssetsJobs : [] setRegenerateAssetsTasks((prev) => { const prevById = new Map(prev.map((t: TaskItem) => [t.id, t] as const)) const nextTasks: TaskItem[] = regenJobs .map((job: any): TaskItem | null => { const file = String(job?.file || '').trim() if (!file) return null const id = regenerateTaskId(file) const prevTask = prevById.get(id) const state = String(job?.state || '').trim().toLowerCase() const error = String(job?.error || '').trim() const cancelled = state === 'cancelled' || error.toLowerCase() === 'abgebrochen' || error.toLowerCase() === 'abbruch' || error.toLowerCase() === 'cancelled' || error.toLowerCase() === 'canceled' let status: TaskItem['status'] = 'idle' if (state === 'queued') status = 'queued' else if (state === 'running') status = 'running' else if (state === 'done') status = 'done' else if (cancelled) status = 'cancelled' else if (state === 'error') status = 'error' const phases = Array.isArray(job?.phases) ? job.phases : [] const relevantPhases = phases .filter((p: any) => { const q = String(p?.queue || '').toLowerCase() const ph = String(p?.phase || '').toLowerCase() return ( (q === 'postwork' && ['meta', 'thumb', 'teaser', 'sprites'].includes(ph)) || (q === 'enrich' && ph === 'analyze') ) }) .sort((a: any, b: any) => { return regeneratePhaseOrder(a?.queue, a?.phase) - regeneratePhaseOrder(b?.queue, b?.phase) }) const runningPhase = relevantPhases.find( (p: any) => String(p?.state || '').toLowerCase() === 'running' ) const queuedPhase = relevantPhases.find( (p: any) => String(p?.state || '').toLowerCase() === 'queued' ) const activePhase = runningPhase ?? queuedPhase const phaseLabel = assetsPhaseLabel( String(activePhase?.label || '').trim(), String(activePhase?.phase || '').trim() ) const total = relevantPhases.length || 5 const done = relevantPhases.filter( (p: any) => String(p?.state || '').toLowerCase() === 'done' ).length const stableQueuedAtMs = prevTask?.queuedAtMs || Number(job?.enqueuedAt ?? 0) || Number(job?.startedAt ?? 0) || Date.now() const stableStartedAtMs = Number(job?.startedAt ?? 0) || prevTask?.startedAtMs || 0 const text = status === 'running' || status === 'queued' ? phaseLabel ? `${shortTaskFilename(file, 38)} · ${phaseLabel}` : shortTaskFilename(file, 52) : shortTaskFilename(file, 52) return { id, status, title: regenerateTaskTitle(file), text, done: status === 'done' ? total : done, total, err: status === 'error' ? error || 'Fehler beim Regenerieren.' : undefined, cancellable: status === 'queued' || status === 'running', fading: prevTask?.fading ?? false, queuedAtMs: stableQueuedAtMs, startedAtMs: stableStartedAtMs, } }) .filter(Boolean) as TaskItem[] const nextById = new Map(nextTasks.map((t: TaskItem) => [t.id, t] as const)) const merged: TaskItem[] = [] for (const next of nextTasks) { const prevTask = prevById.get(next.id) merged.push(prevTask?.fading ? { ...next, fading: true } : next) } for (const prevTask of prev) { if (nextById.has(prevTask.id)) continue if (prevTask.status === 'running' || prevTask.status === 'queued') continue if (prevTask.fading) { merged.push(prevTask) continue } merged.push({ ...prevTask, fading: true }) window.setTimeout(() => { setRegenerateAssetsTasks((cur: TaskItem[]) => cur.filter((t: TaskItem) => t.id !== prevTask.id) ) }, 500) } return merged }) const checkVideoJobs = Array.isArray(data?.checkVideosJobs) ? data.checkVideosJobs : [] setCheckVideoTasks( checkVideoJobs .map(mapCheckVideosJobToTask) .filter((t: TaskItem) => t.status !== 'idle') ) const cleanupJobs = Array.isArray(data?.cleanupJobs) ? data.cleanupJobs : [] setCleanupTasks( cleanupJobs .map(mapCleanupJobToTask) .filter((t: TaskItem) => t.status !== 'idle') ) } catch { // ignore } } void loadTasksStatus() const timer = window.setInterval(loadTasksStatus, 1500) return () => { alive = false window.clearInterval(timer) } }, []) useEffect(() => { for (const task of assetTasks) { if (task.status === 'done') { if (!reportedFinishedAssetTaskIdsRef.current.has(task.id)) { reportedFinishedAssetTaskIdsRef.current.add(task.id) onAssetsGenerated?.() } } else if (task.status === 'queued' || task.status === 'running') { reportedFinishedAssetTaskIdsRef.current.delete(task.id) } } }, [assetTasks, onAssetsGenerated]) useEffect(() => { let alive = true const load = async () => { try { const r = await fetch('/api/status/disk', { cache: 'no-store' }) if (!r.ok) return const data = (await r.json()) as DiskStatus if (alive) setDiskStatus(data) } catch { // ignorieren } } load() const t = window.setInterval(load, 1000) // jede Sekunde aktualisieren return () => { alive = false window.clearInterval(t) } }, []) useEffect(() => { let alive = true const load = async () => { try { const res = await fetch('/api/status/ai-server', { cache: 'no-store' }) const data = await res.json().catch(() => null) if (!alive) return if (!res.ok || !data) { setAiServerStatus({ ok: false, running: false, error: `HTTP ${res.status}`, }) return } setAiServerStatus({ ok: Boolean(data.ok), running: Boolean(data.running), url: String(data.url || ''), statusCode: Number(data.statusCode || 0), error: String(data.error || ''), }) } catch (e) { if (!alive) return setAiServerStatus({ ok: false, running: false, error: e instanceof Error ? e.message : String(e), }) } } void load() const timer = window.setInterval(load, 5000) return () => { alive = false window.clearInterval(timer) } }, []) async function loadAppLog() { setAppLogLoading(true) try { const res = await fetch('/api/status/log?maxBytes=131072', { cache: 'no-store', }) const data = await res.json().catch(() => null) if (!res.ok || !data) { setAppLog({ ok: false, log: '', error: `HTTP ${res.status}`, }) return } setAppLog({ ok: Boolean(data.ok), exists: Boolean(data.exists), path: String(data.path || ''), sizeBytes: Number(data.sizeBytes || 0), log: String(data.log || ''), error: String(data.error || ''), }) } catch (e) { setAppLog({ ok: false, log: '', error: e instanceof Error ? e.message : String(e), }) } finally { setAppLogLoading(false) scrollAppLogToBottom() } } useEffect(() => { if (!appLogOpen) return void loadAppLog() }, [appLogOpen]) useEffect(() => { if (!appLogOpen) return scrollAppLogToBottom() }, [appLogOpen, appLog?.log, appLogLoading]) async function browse(target: 'record' | 'done' | 'ffmpeg') { setErr(null) setMsg(null) setBrowsing(target) try { window.focus() const res = await fetch(`/api/settings/browse?target=${target}`, { cache: 'no-store' }) if (res.status === 204) return // user cancelled if (!res.ok) { const t = await res.text().catch(() => '') throw new Error(t || `HTTP ${res.status}`) } const data = (await res.json()) as { path?: string } const p = (data.path ?? '').trim() if (!p) return setValue((v) => { if (target === 'record') return { ...v, recordDir: p } if (target === 'done') return { ...v, doneDir: p } return { ...v, ffmpegPath: p } }) } catch (e: any) { setErr(e?.message ?? String(e)) } finally { setBrowsing(null) } } async function downloadDatabaseBackup() { setDbMaintenanceMsg(null) setDbMaintenanceErr(null) if (!loadedDatabaseUrl) { setDbMaintenanceErr('Bitte zuerst eine Datenbank-Verbindung speichern.') return } if (databaseSettingsDirty) { setDbMaintenanceErr('Bitte die Datenbank-Einstellungen zuerst speichern.') return } setDbBackupBusy(true) try { const res = await fetch('/api/settings/database/backup', { cache: 'no-store' }) if (!res.ok) { const t = await res.text().catch(() => '') throw new Error(t || `HTTP ${res.status}`) } const blob = await res.blob() const filename = databaseBackupFilename(res.headers.get('Content-Disposition')) const href = window.URL.createObjectURL(blob) const a = document.createElement('a') a.href = href a.download = filename document.body.appendChild(a) a.click() a.remove() window.URL.revokeObjectURL(href) const size = formatDatabaseBackupBytes(blob.size) setDbMaintenanceMsg(size ? `Backup heruntergeladen (${size}).` : 'Backup heruntergeladen.') } catch (e: any) { setDbMaintenanceErr(e?.message ?? String(e)) } finally { setDbBackupBusy(false) } } function chooseDatabaseRestoreFile() { setDbMaintenanceMsg(null) setDbMaintenanceErr(null) dbRestoreInputRef.current?.click() } async function restoreDatabaseBackup(file?: File | null) { if (!file) return setDbMaintenanceMsg(null) setDbMaintenanceErr(null) if (!loadedDatabaseUrl) { setDbMaintenanceErr('Bitte zuerst eine Datenbank-Verbindung speichern.') return } if (databaseSettingsDirty) { setDbMaintenanceErr('Bitte die Datenbank-Einstellungen zuerst speichern.') return } const ok = window.confirm( `Datenbank-Backup "${file.name}" wiederherstellen?\n\n` + 'Das ueberschreibt die aktuelle Datenbank. Dieser Schritt kann nicht automatisch rueckgaengig gemacht werden.' ) if (!ok) return setDbRestoreBusy(true) try { const form = new FormData() form.append('backup', file) const res = await fetch('/api/settings/database/restore', { method: 'POST', body: form, }) if (!res.ok) { const t = await res.text().catch(() => '') throw new Error(t || `HTTP ${res.status}`) } const data = await res.json().catch(() => null) const size = formatDatabaseBackupBytes(data?.bytes ?? file.size) setDbMaintenanceMsg(size ? `Restore abgeschlossen (${size}).` : 'Restore abgeschlossen.') window.dispatchEvent(new Event('models-changed')) window.dispatchEvent( new CustomEvent('models-db-changed', { detail: { databaseUrl: loadedDatabaseUrl, restored: true }, }) ) } catch (e: any) { setDbMaintenanceErr(e?.message ?? String(e)) } finally { setDbRestoreBusy(false) } } async function save() { setErr(null) setMsg(null) const recordDir = value.recordDir.trim() const doneDir = value.doneDir.trim() const ffmpegPath = (value.ffmpegPath ?? '').trim() const databaseUrl = String((value as any).databaseUrl ?? '').trim() const hadPendingDbPassword = Boolean((pendingDbPassword || '').trim()) if (!recordDir || !doneDir) { setErr('Bitte Aufnahme-Ordner und Ziel-Ordner angeben.') return } // ✅ Switch-Logik: Autostart nur sinnvoll, wenn Auto-Add aktiv ist const autoAddToDownloadList = !!value.autoAddToDownloadList const autoStartAddedDownloads = !!value.autoStartAddedDownloads const enableConcurrentDownloadsLimit = !!(value as any).enableConcurrentDownloadsLimit const maxConcurrentDownloads = Math.max( 1, Math.min(100, Math.floor(Number((value as any).maxConcurrentDownloads ?? DEFAULTS.maxConcurrentDownloads ?? 20))) ) const useProviderApis = !!value.useProviderApis const useChaturbateApi = useProviderApis && !!value.useChaturbateApi const useMyFreeCamsApi = useProviderApis && !!value.useMyFreeCamsApi const autoDeleteSmallDownloads = !!value.autoDeleteSmallDownloads const autoDeleteSmallDownloadsBelowMB = Math.max( 0, Math.min(100_000, Math.floor(Number(value.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB))) ) const autoDeleteSmallDownloadsKeepFavorites = !!value.autoDeleteSmallDownloadsKeepFavorites const autoDeleteLowRatedDownloads = !!value.autoDeleteLowRatedDownloads const autoDeleteLowRatedDownloadsMaxStars = Math.max( 1, Math.min(5, Math.floor(Number(value.autoDeleteLowRatedDownloadsMaxStars ?? DEFAULTS.autoDeleteLowRatedDownloadsMaxStars ?? 3))) ) const blurPreviews = !!value.blurPreviews const teaserPlayback = value.teaserPlayback === 'still' || value.teaserPlayback === 'all' || value.teaserPlayback === 'hover' ? value.teaserPlayback : DEFAULTS.teaserPlayback const teaserAudio = !!value.teaserAudio const lowDiskPauseBelowGB = Math.max(1, Math.floor(Number(value.lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB))) const enableNotifications = !!value.enableNotifications const generateAssetsMeta = !!value.generateAssetsMeta const generateAssetsThumb = !!value.generateAssetsThumb const generateAssetsTeaser = !!value.generateAssetsTeaser const generateAssetsSprites = !!value.generateAssetsSprites const generateAssetsAnalyze = !!value.generateAssetsAnalyze const enrichPostworkEnabled = !!value.enrichPostworkEnabled const trainingRecognitionEnabled = !!value.trainingRecognitionEnabled const trainingDetectorEpochs = Math.max( 1, Math.min(300, Math.floor(Number(value.trainingDetectorEpochs ?? DEFAULTS.trainingDetectorEpochs ?? 60))) ) setSaving(true) try { const res = await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ databaseUrl, dbPassword: pendingDbPassword || undefined, recordDir, doneDir, ffmpegPath, autoAddToDownloadList, autoStartAddedDownloads, enableConcurrentDownloadsLimit, maxConcurrentDownloads, useProviderApis, useChaturbateApi, useMyFreeCamsApi, autoDeleteSmallDownloads, autoDeleteSmallDownloadsBelowMB, autoDeleteSmallDownloadsKeepFavorites, autoDeleteLowRatedDownloads, autoDeleteLowRatedDownloadsMaxStars, blurPreviews, teaserPlayback, teaserAudio, lowDiskPauseBelowGB, enableNotifications, generateAssetsMeta, generateAssetsThumb, generateAssetsTeaser, generateAssetsSprites, generateAssetsAnalyze, enrichPostworkEnabled, trainingRecognitionEnabled, trainingDetectorEpochs, }), }) if (!res.ok) { const t = await res.text().catch(() => '') throw new Error(t || `HTTP ${res.status}`) } // Button-Label: "Gespeichert" für 2.5s const until = Date.now() + 2500 setSaveSuccessUntilMs(until) if (saveSuccessTimerRef.current != null) { window.clearTimeout(saveSuccessTimerRef.current) } saveSuccessTimerRef.current = window.setTimeout(() => { setSaveSuccessUntilMs(0) saveSuccessTimerRef.current = null }, 2500) window.dispatchEvent(new CustomEvent('recorder-settings-updated')) const databaseUrlChanged = databaseUrl !== loadedDatabaseUrl || hadPendingDbPassword if (databaseUrlChanged) { window.dispatchEvent( new CustomEvent('models-db-changed', { detail: { databaseUrl }, }) ) setLoadedDatabaseUrl(databaseUrl) setPendingDbPassword('') } } catch (e: any) { setSaveSuccessUntilMs(0) if (saveSuccessTimerRef.current != null) { window.clearTimeout(saveSuccessTimerRef.current) saveSuccessTimerRef.current = null } setErr(e?.message ?? String(e)) } finally { setSaving(false) } } function fadeOutRegenerateTask(file: string, delayMs = 3000, fadeMs = 500) { const id = regenerateTaskId(file) window.setTimeout(() => { setRegenerateAssetsTasks((prev) => prev.map((t) => (t.id === id ? { ...t, fading: true } : t)) ) window.setTimeout(() => { setRegenerateAssetsTasks((prev) => prev.filter((t) => t.id !== id)) }, fadeMs) }, delayMs) } async function cleanupSmallDone() { setErr(null) const mb = Number(value.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB ?? 0) const doneDir = (value.doneDir || DEFAULTS.doneDir).trim() const deleteLowRated = !!value.autoDeleteLowRatedDownloads const maxStars = Math.max( 1, Math.min(5, Math.floor(Number(value.autoDeleteLowRatedDownloadsMaxStars ?? DEFAULTS.autoDeleteLowRatedDownloadsMaxStars ?? 3))) ) if (!doneDir) { setErr('doneDir ist leer.') return null } if ((!mb || mb <= 0) && !deleteLowRated) { setErr('Mindestgröße ist 0 und Rating-Löschen ist deaktiviert – es würde nichts gelöscht.') return null } const ratingLine = deleteLowRated ? `• Rating ≤ ${maxStars} Stern${maxStars === 1 ? '' : 'e'}\n` : '' const sizeLine = mb > 0 ? `• Dateien < ${mb} MB in "${doneDir}"\n` : '' const ok = window.confirm( `Aufräumen:\n` + sizeLine + ratingLine + `• Entfernt defekte Video-Dateien\n` + `• Löscht Record-Reste und verwaiste Assets\n\n` + `Fortfahren?` ) if (!ok) return null const res = await fetch('/api/settings/cleanup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, cache: 'no-store', }) if (!res.ok) { const t = await res.text().catch(() => '') throw new Error(t || `HTTP ${res.status}`) } return await res.json().catch(() => null) } async function cancelAssetsTask(jobId: string) { try { await fetch(`/api/tasks/generate-assets?id=${encodeURIComponent(jobId)}`, { method: 'DELETE', cache: 'no-store' as any, }) } catch { // ignore } } async function cancelRegenerateAssetsTask(file: string) { const cleanFile = String(file || '').trim() if (!cleanFile) return setRegenerateAssetsTasks((prev) => prev.map((t) => t.id === regenerateTaskId(cleanFile) ? { ...t, status: 'cancelled', title: regenerateTaskTitle(cleanFile), text: shortTaskFilename(cleanFile, 52), err: undefined, cancellable: false, fading: false, } : t ) ) try { await fetch('/api/record/regenerate-assets', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, cache: 'no-store' as any, body: JSON.stringify({ file: cleanFile }), }) } catch { // ignore } fadeOutRegenerateTask(cleanFile) } async function cancelCheckVideosTask(jobId: string) { try { await fetch(`/api/tasks/check-videos?id=${encodeURIComponent(jobId)}`, { method: 'DELETE', cache: 'no-store' as any, }) } catch { // ignore } } async function cancelCleanupTask(jobId: string) { try { await fetch(`/api/settings/cleanup?id=${encodeURIComponent(jobId)}`, { method: 'DELETE', cache: 'no-store' as any, }) } catch { // ignore } } const visibleTasks: TaskItem[] = [ ...assetTasks, ...regenerateAssetsTasks, ...checkVideoTasks, ...cleanupTasks, ].filter((t: TaskItem) => t.status !== 'idle') async function loadAuthStatus() { try { const res = await fetch('/api/auth/me', { cache: 'no-store' }) if (!res.ok) return const data = await res.json().catch(() => null) if (!data) return setAuthStatus({ authenticated: Boolean(data.authenticated), totpEnabled: Boolean(data.totpEnabled), totpConfigured: Boolean(data.totpConfigured), passkeyConfigured: Boolean(data.passkeyConfigured), passkeyCount: Number(data.passkeyCount ?? 0), totpDevice: data.totpDevice ?? null, passkeys: Array.isArray(data.passkeys) ? data.passkeys : [], }) } catch { // ignore } } async function changePassword() { setErr(null) setMsg(null) if (!currentPassword.trim()) { setErr('Bitte aktuelles Passwort eingeben.') return } if (newPassword.length < 8) { setErr('Das neue Passwort muss mindestens 8 Zeichen lang sein.') return } if (newPassword !== newPasswordRepeat) { setErr('Die neuen Passwörter stimmen nicht überein.') return } setSecurityBusy(true) try { const res = await fetch('/api/auth/password/change', { method: 'POST', headers: { 'Content-Type': 'application/json' }, cache: 'no-store', body: JSON.stringify({ currentPassword, newPassword, }), }) if (!res.ok) { const text = await res.text().catch(() => '') throw new Error(text || `HTTP ${res.status}`) } setCurrentPassword('') setNewPassword('') setNewPasswordRepeat('') setMsg('Passwort wurde geändert.') } catch (e: any) { setErr(e?.message ?? String(e)) } finally { setSecurityBusy(false) } } async function disable2FA() { setErr(null) setMsg(null) setDisable2FAFieldError(null) const password = disable2FAPassword.trim() const code = disable2FACode.trim() const nextFieldError: { password?: string code?: string } = {} if (!password) { nextFieldError.password = 'Gib dein aktuelles Passwort ein, um 2FA zu deaktivieren.' } if (authStatus?.totpEnabled && !/^\d{6}$/.test(code)) { nextFieldError.code = 'Gib den 6-stelligen Code aus deiner Authenticator-App ein.' } if (nextFieldError.password || nextFieldError.code) { setDisable2FAFieldError(nextFieldError) return } const ok = window.confirm('2FA wirklich deaktivieren? Dein Login ist danach weniger geschützt.') if (!ok) return setSecurityBusy(true) try { const res = await fetch('/api/auth/2fa/disable', { method: 'POST', headers: { 'Content-Type': 'application/json' }, cache: 'no-store', body: JSON.stringify({ currentPassword: password, code, }), }) if (!res.ok) { const text = await res.text().catch(() => '') throw new Error(text || `HTTP ${res.status}`) } setDisable2FAPassword('') setDisable2FACode('') setDisable2FAFieldError(null) setMsg('2FA wurde deaktiviert.') await loadAuthStatus() } catch (e: any) { setErr(e?.message ?? String(e)) } finally { setSecurityBusy(false) } } function passkeyErrorMessage(e: any) { const raw = String(e?.message ?? e ?? '').trim() if (!raw) return 'Passkey konnte nicht hinzugefügt werden.' if (raw.includes('passkey registration verify failed')) { return ( raw + '\n\nPrüfe AUTH_RP_ORIGIN und AUTH_RP_ID. Die Werte müssen exakt zur Browser-URL passen.' ) } if ( raw.includes('NotAllowedError') || raw.toLowerCase().includes('not allowed') || raw.toLowerCase().includes('aborted') ) { return 'Passkey-Einrichtung wurde abgebrochen oder vom Browser/Authenticator nicht erlaubt.' } if ( raw.includes('SecurityError') || raw.toLowerCase().includes('relying party') || raw.toLowerCase().includes('rp id') || raw.toLowerCase().includes('origin') ) { return ( raw + '\n\nDas sieht nach einem Origin/RP-ID Problem aus. Prüfe AUTH_RP_ORIGIN und AUTH_RP_ID.' ) } if (raw.includes('challenge expired')) { return 'Die Passkey-Challenge ist abgelaufen. Bitte erneut versuchen.' } return raw } async function registerSettingsPasskey() { if (securityBusy) return setErr(null) setMsg(null) setPasskeyFeedback({ kind: 'loading', text: 'Starte Passkey-Einrichtung…', }) setSecurityBusy(true) try { setPasskeyFeedback({ kind: 'loading', text: 'Hole WebAuthn-Challenge vom Backend…', }) const optionsRes = await fetch('/api/auth/passkey/register/options', { method: 'POST', cache: 'no-store', }) if (!optionsRes.ok) { const text = await optionsRes.text().catch(() => '') throw new Error(text || `HTTP ${optionsRes.status}`) } const options = await optionsRes.json() setPasskeyFeedback({ kind: 'loading', text: 'Browser-Passkey-Dialog geöffnet. Bitte Face ID, Fingerabdruck, Windows Hello oder Sicherheitsschlüssel bestätigen.', }) const credential = await startRegistration({ optionsJSON: unwrapWebAuthnOptions(options), }) setPasskeyFeedback({ kind: 'loading', text: 'Passkey wurde vom Browser erstellt. Verifiziere beim Backend…', }) const verifyRes = await fetch('/api/auth/passkey/register/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, cache: 'no-store', body: JSON.stringify(credential), }) if (!verifyRes.ok) { const text = await verifyRes.text().catch(() => '') throw new Error(text || `HTTP ${verifyRes.status}`) } setMsg('Passkey wurde hinzugefügt.') setPasskeyFeedback({ kind: 'success', text: 'Passkey wurde erfolgreich hinzugefügt.', }) await loadAuthStatus() } catch (e: any) { const message = passkeyErrorMessage(e) setErr(message) setPasskeyFeedback({ kind: 'error', text: message, }) } finally { setSecurityBusy(false) } } async function deletePasskey(passkey: AuthDevice) { setErr(null) setMsg(null) const credentialId = String(passkey?.id ?? '').trim() if (!credentialId) { setErr('Passkey kann nicht gelöscht werden: Credential-ID fehlt.') return } const label = String(passkey?.label || 'diesen Passkey').trim() const ok = window.confirm( `"${label}" wirklich löschen? Anmeldung mit diesem Passkey ist danach nicht mehr möglich.` ) if (!ok) return setSecurityBusy(true) try { const res = await fetch('/api/auth/passkey/delete', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, cache: 'no-store', body: JSON.stringify({ credentialId, }), }) if (!res.ok) { const text = await res.text().catch(() => '') throw new Error(text || `HTTP ${res.status}`) } setMsg('Passkey wurde gelöscht.') await loadAuthStatus() } catch (e: any) { setErr(e?.message ?? String(e)) } finally { setSecurityBusy(false) } } return (
Einstellungen
Recorder-Konfiguration, Automatisierung und Tasks.
{/* Rechts: Alerts + Button */}
} grayBody >
{ if (id.startsWith('generate-assets:')) { const jobId = id.slice('generate-assets:'.length) void cancelAssetsTask(jobId) return } if (id.startsWith('regenerate-assets:')) { const file = id.slice('regenerate-assets:'.length) void cancelRegenerateAssetsTask(file) return } if (id.startsWith('check-videos:')) { const jobId = id.slice('check-videos:'.length) void cancelCheckVideosTask(jobId) return } if (id.startsWith('cleanup:')) { const jobId = id.slice('cleanup:'.length) void cancelCleanupTask(jobId) return } }} /> {/* Alerts (mobile) */} {saveUiState !== 'success' ? (
{err && (
{err}
)} {msg && (
{msg}
)}
) : null} {/* Aufgaben */}
{ if (!job?.id) return upsertTask(setAssetTasks, mapAssetJobToTask(job)) }} onError={(message) => { setErr(message) }} />
setValue((v) => ({ ...v, generateAssetsMeta: checked }))} label="Meta" description="Erzeugt oder ergänzt die meta.json mit Dauer, Auflösung, FPS und weiteren Videoinformationen." /> setValue((v) => ({ ...v, generateAssetsThumb: checked }))} label="Vorschaubild" description="Erzeugt das statische Vorschaubild preview.jpg für Listen und Kartenansicht." /> setValue((v) => ({ ...v, generateAssetsTeaser: checked }))} label="Teaser" description="Erzeugt die kurze Video-Vorschau preview.mp4 für Hover- und Preview-Wiedergabe." /> setValue((v) => ({ ...v, generateAssetsSprites: checked }))} label="Sprites" description="Erzeugt das Sprite-Bild preview-sprite.jpg für die Scrubber- und Timeline-Vorschau." /> setValue((v) => ({ ...v, generateAssetsAnalyze: checked }))} label="Analyse" description="Führt die Analyse aus und ergänzt Treffer, Segmente und weitere Auswertungsdaten." />
{ if (!job?.id) return upsertTask(setCheckVideoTasks, mapCheckVideosJobToTask(job)) }} onError={(message) => { setErr(message) }} /> { if (!job?.id) return upsertTask(setCleanupTasks, mapCleanupJobToTask(job)) }} onError={(message) => { setErr(message) }} />
{/* Paths */}
Pfad-Einstellungen
Aufnahme- und Zielverzeichnisse sowie optionaler ffmpeg-Pfad.
{/* Datenbank */}
Datenbank
Postgres Verbindung. Passwort wird verschlüsselt gespeichert.
setValue((v) => ({ ...v, databaseUrl: e.target.value }))} placeholder="postgres://user@host:5432/db?sslmode=disable" className="min-w-0 flex-1 rounded-lg px-3 py-2 text-sm bg-white text-gray-900 ring-1 ring-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10" />
Backup / Restore
Exportiert die gespeicherte PostgreSQL-Datenbank als pg_dump-Datei oder stellt sie aus einem Backup wieder her.
{databaseSettingsDirty ? (
Die Datenbank-Einstellungen wurden geaendert. Bitte erst speichern.
) : null}
{ const file = e.currentTarget.files?.[0] ?? null e.currentTarget.value = '' void restoreDatabaseBackup(file) }} /> {dbMaintenanceErr ? (
{dbMaintenanceErr}
) : dbMaintenanceMsg ? (
{dbMaintenanceMsg}
) : null}
setDbModalOpen(false)} initialUrl={String((value as any).databaseUrl ?? '')} initialHasPassword={Boolean((value as any).hasDbPassword)} onApply={({ databaseUrl, dbPassword }) => { setValue((v) => ({ ...v, databaseUrl })) setPendingDbPassword(dbPassword || '') }} />
{/* Aufnahme-Ordner */}
setValue((v) => ({ ...v, recordDir: e.target.value }))} placeholder="records (oder absolut: C:\records / /mnt/data/records)" className="min-w-0 flex-1 rounded-lg px-3 py-2 text-sm bg-white text-gray-900 ring-1 ring-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10" />
{/* Fertige Downloads */}
setValue((v) => ({ ...v, doneDir: e.target.value }))} placeholder="records/done" className="min-w-0 flex-1 rounded-lg px-3 py-2 text-sm bg-white text-gray-900 ring-1 ring-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10" />
{/* ffmpeg.exe */}
setValue((v) => ({ ...v, ffmpegPath: e.target.value }))} placeholder="Leer = automatisch (FFMPEG_PATH / ffmpeg im PATH)" className="min-w-0 flex-1 rounded-lg px-3 py-2 text-sm bg-white text-gray-900 ring-1 ring-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10" />
{/* Automatisierung */}
Automatisierung & Anzeige
Verhalten beim Hinzufügen/Starten sowie Anzeigeoptionen.
setValue((v) => ({ ...v, enableConcurrentDownloadsLimit: checked, maxConcurrentDownloads: v.maxConcurrentDownloads ?? 20, })) } label="Gleichzeitige Downloads begrenzen" description="Begrenzt die Anzahl parallel laufender Downloads. Weitere Starts werden vom Backend blockiert." />
Max. parallele Downloads
Mehr gleichzeitige Starts werden nicht zugelassen.
setValue((v) => ({ ...v, maxConcurrentDownloads: Number(e.target.value || 1), })) } className="h-9 w-32 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm dark:border-white/10 dark:bg-gray-900 dark:text-gray-100" />
setValue((v) => ({ ...v, enrichPostworkEnabled: checked, })) } label="EnrichQ-Postwork im Hintergrund" description="Wenn aktiv, werden fehlende Analyse-/Enrich-Assets automatisch im Hintergrund nachgeholt. Wenn deaktiviert, werden keine neuen EnrichQ-Hintergrundjobs gestartet; manuelle Asset-Generierung bleibt möglich." /> setValue((v) => ({ ...v, autoAddToDownloadList: checked, autoStartAddedDownloads: checked ? v.autoStartAddedDownloads : false, })) } label="Clipboard-Links in Start-URL übernehmen" description="Übernimmt erkannte Chaturbate- und MyFreeCams-Links aus der Zwischenablage automatisch in das Start-URL-Feld. Wenn deaktiviert, werden Clipboard-Links ignoriert." /> setValue((v) => ({ ...v, autoStartAddedDownloads: checked, autoAddToDownloadList: checked ? true : v.autoAddToDownloadList, })) } label="Übernommene Links automatisch starten" description="Startet automatisch, sobald ein erkannter Clipboard-Link übernommen wurde oder ein aktivierter Provider ein watched Model als öffentlich online erkennt. Aktiviert automatisch auch die Clipboard-Übernahme." />
setValue((v) => ({ ...v, useProviderApis: checked, useChaturbateApi: checked ? v.useChaturbateApi : false, useMyFreeCamsApi: checked ? v.useMyFreeCamsApi : false, })) } label="Provider-APIs verwenden" description="Master-Schalter für externe Provider-APIs. Wenn deaktiviert, werden keine automatischen API-Abfragen für Online-Status, Profilbilder oder Model-Daten ausgeführt." />
setValue((v) => ({ ...v, useProviderApis: checked ? true : v.useProviderApis, useChaturbateApi: checked, })) } label="Chaturbate API" description="Aktualisiert Online-Status, Vorschaubilder und Model-Daten über die Chaturbate-API. Wenn Automatisch starten aktiv ist, werden öffentliche watched Models gestartet." /> setValue((v) => ({ ...v, useProviderApis: checked ? true : v.useProviderApis, useMyFreeCamsApi: checked, })) } label="MyFreeCams API" description="Aktualisiert Online-Status, Vorschaubilder und Model-Daten über usernameLookup. Wenn Automatisch starten aktiv ist, werden öffentliche watched Models gestartet. Bei 'servers busy' pausiert die App MyFreeCams-Anfragen automatisch." />
{/* ✅ NEU: Auto-Delete kleine Downloads */}
setValue((v) => ({ ...v, autoDeleteSmallDownloads: checked, autoDeleteSmallDownloadsBelowMB: v.autoDeleteSmallDownloadsBelowMB ?? 50, })) } label="Kleine Downloads automatisch löschen" description="Löscht fertige Downloads automatisch, wenn die Datei kleiner als die eingestellte Mindestgröße ist." />
Mindestgröße
Alles darunter wird gelöscht.
setValue((v) => ({ ...v, autoDeleteSmallDownloadsBelowMB: Number(e.target.value || 0), })) } className="h-9 w-32 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm dark:border-white/10 dark:bg-gray-900 dark:text-gray-100" /> MB
setValue((v) => ({ ...v, autoDeleteSmallDownloadsKeepFavorites: checked, })) } label="Favoriten nicht automatisch löschen" description="Schützt Downloads von favorisierten Models vor dem automatischen Löschen, auch wenn die Datei kleiner als die Mindestgröße ist." />
setValue((v) => ({ ...v, autoDeleteLowRatedDownloads: checked, autoDeleteLowRatedDownloadsMaxStars: v.autoDeleteLowRatedDownloadsMaxStars ?? 3, })) } label="Downloads nach Analyse nach Rating löschen" description="Löscht fertige Downloads automatisch nach der Analyse, wenn das Rating höchstens der gewählten Sternezahl entspricht." />
Sterne-Grenze
Bis einschließlich dieser Bewertung wird gelöscht.
setValue((v) => ({ ...v, autoDeleteLowRatedDownloadsMaxStars: stars, })) } />
setValue((v) => ({ ...v, trainingRecognitionEnabled: checked, })) } label="Training-Erkennung aktivieren" description="Wenn aktiv, werden neue Trainingsbilder automatisch mit YOLO und Scene-KNN analysiert. Wenn deaktiviert, kannst du weiterhin manuell korrigieren und Boxen zeichnen." />
Object Detection Epochs
Anzahl der Trainingsdurchläufe für die Box-Erkennung. Mehr Epochs können die Erkennung verbessern, dauern aber länger. 60 ist ein guter Standard.
setValue((v) => ({ ...v, trainingDetectorEpochs: Number(e.target.value || 60), })) } className="h-9 w-32 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm dark:border-white/10 dark:bg-gray-900 dark:text-gray-100" /> Epochs
setValue((v) => ({ ...v, blurPreviews: checked }))} label="Vorschaubilder blurren" description="Weichzeichnet Vorschaubilder/Teaser (praktisch auf mobilen Geräten oder im öffentlichen Umfeld)." />
Teaser abspielen
Standbild spart Leistung. „Bei Hover (Standard)“: Desktop spielt bei Hover ab, Mobile im Viewport. „Alle“ kann viel CPU ziehen.
setValue((v) => ({ ...v, teaserAudio: checked }))} label="Teaser mit Ton" description="Wenn aktiv, werden Vorschau/Teaser nicht stumm geschaltet." /> setValue((v) => ({ ...v, enableNotifications: checked }))} label="Benachrichtigungen" description="Wenn aktiv, zeigt das Frontend Toasts (z.B. wenn watched Models online/live gehen oder wenn ein queued Model wieder public wird)." />
Speicherplatz-Notbremse
Aktiviert automatisch Stop + Autostart-Block bei wenig freiem Speicher (Resume bei +3 GB).
{diskStatus?.emergency ? 'AKTIV' : 'OK'}
Schwelle:{' '} Pause unter {uiPauseGB} GB {' · '}Resume ab{' '} {uiResumeGB} {' '} GB
{diskStatus ? `Frei: ${diskStatus.freeBytesHuman}${diskStatus.recordPath ? ` (Pfad: ${diskStatus.recordPath})` : ''}` : 'Status wird geladen…'}
{diskStatus?.emergency && (
Notfallbremse greift: laufende Downloads werden gestoppt und Autostart bleibt gesperrt, bis wieder genug frei ist.
)}
{appLogOpen ? (
{appLog?.path ? ( Logdatei: {appLog.path} ) : appLogLoading ? ( 'Logdatei wird geladen…' ) : ( 'Logdatei noch nicht geladen.' )}
{appLog?.error ? (
{appLog.error}
) : null}
                    {appLogLoading
                      ? 'Log wird geladen…'
                      : appLog?.log?.trim()
                        ? appLog.log
                        : 'Noch kein Log vorhanden.'}
                  
) : null}
{/* Sicherheit */} {statusPill(!!authStatus?.totpEnabled, '2FA aktiv', '2FA aus')} {statusPill(!!authStatus?.passkeyConfigured, 'Passkey aktiv', 'Kein Passkey')} } >
Passwort ändern
Ändert das Login-Passwort. Andere Sessions werden abgemeldet.
setCurrentPassword(e.target.value)} placeholder="Aktuelles Passwort" autoComplete="current-password" disabled={securityBusy} className="rounded-lg bg-white px-3 py-2 text-sm text-gray-900 ring-1 ring-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10" /> setNewPassword(e.target.value)} placeholder="Neues Passwort" autoComplete="new-password" disabled={securityBusy} className="rounded-lg bg-white px-3 py-2 text-sm text-gray-900 ring-1 ring-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10" /> setNewPasswordRepeat(e.target.value)} placeholder="Neues Passwort wiederholen" autoComplete="new-password" disabled={securityBusy} className="rounded-lg bg-white px-3 py-2 text-sm text-gray-900 ring-1 ring-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10" />
Zwei-Faktor-Authentifizierung
Authenticator-App als zweiter Faktor. Einzelne Geräte können bei TOTP nicht getrennt erkannt werden.
{statusPill(!!authStatus?.totpEnabled, 'Aktiv', 'Aus')}
{authStatus?.totpDevice ? ( ) : (
2FA ist noch nicht eingerichtet. Die Einrichtung erfolgt aktuell beim nächsten Login-Setup.
)}
{authStatus?.totpConfigured || authStatus?.totpEnabled ? ( <>
{ setDisable2FAPassword(e.target.value) setDisable2FAFieldError((cur) => ({ ...cur, password: undefined })) }} placeholder="Aktuelles Passwort" autoComplete="current-password" disabled={securityBusy} className={[ 'rounded-lg bg-white px-3 py-2 text-sm text-gray-900 ring-1 focus:outline-none focus:ring-2 dark:bg-white/10 dark:text-white', disable2FAFieldError?.password ? 'ring-red-300 focus:ring-red-500 dark:ring-red-400/50' : 'ring-gray-200 focus:ring-indigo-500 dark:ring-white/10', ].join(' ')} /> { setDisable2FACode(e.target.value.replace(/\D/g, '').slice(0, 6)) setDisable2FAFieldError((cur) => ({ ...cur, code: undefined })) }} placeholder="2FA-Code" inputMode="numeric" autoComplete="one-time-code" disabled={securityBusy} className={[ 'rounded-lg bg-white px-3 py-2 text-sm text-gray-900 ring-1 focus:outline-none focus:ring-2 dark:bg-white/10 dark:text-white', disable2FAFieldError?.code ? 'ring-red-300 focus:ring-red-500 dark:ring-red-400/50' : 'ring-gray-200 focus:ring-indigo-500 dark:ring-white/10', ].join(' ')} />
{disable2FAFieldError?.password || disable2FAFieldError?.code ? (
{disable2FAFieldError.password || disable2FAFieldError.code}
) : (
Zum Deaktivieren brauchst du dein aktuelles Passwort {authStatus?.totpEnabled ? ' und einen gültigen 2FA-Code.' : '.'}
)}
) : null}
Passkeys
Anmeldung ohne Passwort über Face ID, Fingerabdruck, Windows Hello oder Sicherheitsschlüssel.
{statusPill(!!authStatus?.passkeyConfigured, 'Aktiv', 'Keine')}
{(authStatus?.passkeys ?? []).length > 0 ? ( (authStatus?.passkeys ?? []).map((pk, index) => ( void deletePasskey(pk)} > Löschen } /> )) ) : (
Noch kein Passkey gespeichert.
)}
{!passkeySupported ? (
Dein Browser oder diese Verbindung unterstützt Passkeys aktuell nicht.
) : (
{passkeyFeedback ? (
{passkeyFeedback.kind === 'loading' ? '⏳ ' : null} {passkeyFeedback.kind === 'success' ? '✅ ' : null} {passkeyFeedback.kind === 'error' ? '⚠️ ' : null} {passkeyFeedback.text}
) : null}
)}
) }