nsfwapp/frontend/src/components/ui/RecorderSettings.tsx
2026-06-24 10:00:03 +02:00

3432 lines
125 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 = {
databaseType?: 'postgres'
postgresMode?: 'url' | 'local'
postgresDataDir?: string
postgresPort?: number
postgresDatabase?: string
postgresUser?: string
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<T = any>(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 (
<span
className={[
'shrink-0 rounded-full px-2 py-1 text-[11px] font-bold ring-1',
active
? 'bg-emerald-50 text-emerald-800 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-400/30'
: 'bg-gray-100 text-gray-700 ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10',
].join(' ')}
>
{active ? activeText : inactiveText}
</span>
)
}
function SecurityDeviceRow(props: {
icon: string
title: string
subtitle: string
meta?: string
badge?: string
actions?: ReactNode
}) {
return (
<div className="flex items-start gap-3 rounded-xl border border-gray-200 bg-white p-3 dark:border-white/10 dark:bg-gray-950/40">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gray-100 text-lg shadow-sm ring-1 ring-gray-200 dark:bg-white/10 dark:ring-white/10">
{props.icon}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
{props.title}
</div>
{props.badge ? (
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-700 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10">
{props.badge}
</span>
) : null}
</div>
<div className="mt-0.5 text-xs text-gray-600 dark:text-gray-300">
{props.subtitle}
</div>
{props.meta ? (
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
{props.meta}
</div>
) : null}
</div>
{props.actions ? (
<div className="shrink-0">
{props.actions}
</div>
) : null}
</div>
)
}
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 (
<section className="overflow-hidden rounded-2xl bg-white shadow-md shadow-gray-200/70 ring-1 ring-gray-300/80 transition-shadow hover:shadow-lg dark:bg-gray-950/70 dark:shadow-black/30 dark:ring-white/15">
<button
type="button"
onClick={toggleOpen}
className={[
'flex w-full items-start justify-between gap-4 p-4 text-left transition',
'focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500',
'dark:focus-visible:ring-indigo-400',
open
? 'rounded-t-2xl rounded-b-none bg-gray-50 hover:bg-gray-100/80 dark:bg-white/5 dark:hover:bg-white/10'
: 'rounded-2xl bg-white hover:bg-gray-50 dark:bg-gray-950/70 dark:hover:bg-white/5',
].join(' ')}
aria-expanded={open}
>
<div className="min-w-0">
<div className="flex items-start gap-3">
{props.icon ? (
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gray-100 text-lg dark:bg-white/10">
{props.icon}
</div>
) : null}
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-900 dark:text-white">
{props.title}
</div>
{props.description ? (
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
{props.description}
</div>
) : null}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{props.actions}
<ChevronDownIcon
className={[
'h-5 w-5 text-gray-400 transition-transform duration-200 dark:text-gray-500',
open ? 'rotate-180' : '',
].join(' ')}
/>
</div>
</button>
{open ? (
<div className="border-t border-gray-200 p-4 pt-3 dark:border-white/10">
{props.children}
</div>
) : null}
</section>
)
}
const POSTGRES_DATA_DIR_NAME = 'nsfwapp-postgres-data'
const LEGACY_POSTGRES_DATA_DIR_NAME = 'postgres-data'
const DEFAULTS: RecorderSettings = {
databaseType: 'postgres',
postgresMode: 'local',
postgresDataDir: POSTGRES_DATA_DIR_NAME,
postgresPort: 55432,
postgresDatabase: 'nsfwapp',
postgresUser: 'nsfwapp',
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<SetStateAction<TaskItem[]>>,
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 normalizeDatabaseType(_value: unknown): 'postgres' {
return 'postgres'
}
function normalizePostgresMode(value: unknown, databaseUrl?: unknown): 'url' | 'local' {
const mode = String(value ?? '').trim().toLowerCase()
if (mode === 'url') return 'url'
if (mode === 'local') return 'local'
return String(databaseUrl ?? '').trim() ? 'url' : 'local'
}
function normalizePostgresDataDir(value: unknown) {
let s = String(value ?? '').trim()
if (!s) return POSTGRES_DATA_DIR_NAME
s = s.replace(/[\\/]+$/g, '')
if (!s) return POSTGRES_DATA_DIR_NAME
const sep = s.includes('\\') && !s.includes('/') ? '\\' : '/'
const parts = s.split(/[\\/]+/)
const base = parts[parts.length - 1] || ''
if (base.toLowerCase() === POSTGRES_DATA_DIR_NAME.toLowerCase()) return s
if (base.toLowerCase() === LEGACY_POSTGRES_DATA_DIR_NAME.toLowerCase()) {
const parent = parts.slice(0, -1).join(sep)
return parent ? `${parent}${sep}${POSTGRES_DATA_DIR_NAME}` : POSTGRES_DATA_DIR_NAME
}
return `${s}${sep}${POSTGRES_DATA_DIR_NAME}`
}
function normalizePostgresPort(value: unknown) {
const n = Math.floor(Number(value))
return Number.isFinite(n) && n >= 1 && n <= 65535 ? n : 55432
}
function normalizePostgresName(value: unknown, fallback: string) {
const s = String(value ?? '').trim()
return s || fallback
}
function postgresSourceKey(v: any) {
const mode = normalizePostgresMode(v?.postgresMode, v?.databaseUrl)
if (mode === 'local') {
return [
mode,
normalizePostgresDataDir(v?.postgresDataDir),
normalizePostgresPort(v?.postgresPort),
normalizePostgresName(v?.postgresDatabase, 'nsfwapp'),
normalizePostgresName(v?.postgresUser, 'nsfwapp'),
].join('\u0000')
}
return [mode, String(v?.databaseUrl ?? '').trim()].join('\u0000')
}
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 (
<span className="relative inline-grid h-7 w-7 shrink-0 place-items-center leading-none">
<svg
viewBox="0 0 20 20"
aria-hidden="true"
className={[
'h-7 w-7 shrink-0 transition duration-150',
filled
? `fill-current ${settingsRatingTone(stars)}`
: 'fill-transparent text-gray-300 dark:text-white/25',
].join(' ')}
style={{
filter: filled
? 'drop-shadow(0 1px 2px rgba(0,0,0,0.75)) drop-shadow(0 0 3px rgba(0,0,0,0.35))'
: 'drop-shadow(0 1px 1px rgba(0,0,0,0.22))',
}}
>
<path
d={SETTINGS_RATING_STAR_PATH}
stroke={filled ? 'rgba(0,0,0,0.75)' : 'currentColor'}
strokeWidth="1.1"
paintOrder="stroke fill"
/>
<text
x="10"
y="11.35"
textAnchor="middle"
dominantBaseline="middle"
fontSize="6.4"
fontWeight="900"
className={filled ? 'fill-white' : 'fill-gray-500 dark:fill-white/55'}
style={{
paintOrder: 'stroke fill',
stroke: filled ? 'rgba(0,0,0,0.9)' : 'transparent',
strokeWidth: filled ? 0.75 : 0,
}}
>
{stars}
</text>
</svg>
</span>
)
}
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 (
<div className="text-right">
<div
role="radiogroup"
aria-label="Sterne-Grenze"
className={[
'flex flex-wrap items-center justify-end gap-1',
disabled ? 'pointer-events-none opacity-50' : '',
].join(' ')}
>
{[1, 2, 3, 4, 5].map((stars) => {
const filled = stars <= selected
const checked = stars === selected
return (
<button
key={stars}
type="button"
role="radio"
aria-checked={checked}
disabled={disabled}
title={`Bis einschließlich ${stars} Stern${stars === 1 ? '' : 'e'} löschen`}
onClick={() => onChange(stars)}
className={[
'rounded-md p-0.5 transition',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-950',
disabled
? 'cursor-not-allowed'
: 'hover:scale-110 active:scale-95',
].join(' ')}
>
<RatingThresholdStar stars={stars} filled={filled} />
</button>
)
})}
</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
Löscht bis einschließlich {selected} Stern{selected === 1 ? '' : 'e'}.
</div>
</div>
)
}
export default function RecorderSettings({ onAssetsGenerated }: Props) {
const [value, setValue] = useState<RecorderSettings>(DEFAULTS)
const [saving, setSaving] = useState(false)
const [saveSuccessUntilMs, setSaveSuccessUntilMs] = useState<number>(0)
const saveSuccessTimerRef = useRef<number | null>(null)
const appLogPreRef = useRef<HTMLPreElement | null>(null)
const dbRestoreInputRef = useRef<HTMLInputElement | null>(null)
const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | 'postgresData' | null>(null)
const [msg, setMsg] = useState<string | null>(null)
const [err, setErr] = useState<string | null>(null)
const [diskStatus, setDiskStatus] = useState<DiskStatus | null>(null)
const [aiServerStatus, setAiServerStatus] = useState<AIServerStatus | null>(null)
const [appLog, setAppLog] = useState<AppLog | null>(null)
const [appLogLoading, setAppLogLoading] = useState(false)
const [appLogOpen, setAppLogOpen] = useState(false)
const [dbModalOpen, setDbModalOpen] = useState(false)
const [loadedDatabaseType, setLoadedDatabaseType] = useState<'postgres'>('postgres')
const [loadedPostgresMode, setLoadedPostgresMode] = useState<'url' | 'local'>('local')
const [loadedPostgresDataDir, setLoadedPostgresDataDir] = useState(POSTGRES_DATA_DIR_NAME)
const [loadedPostgresPort, setLoadedPostgresPort] = useState(55432)
const [loadedPostgresDatabase, setLoadedPostgresDatabase] = useState('nsfwapp')
const [loadedPostgresUser, setLoadedPostgresUser] = useState('nsfwapp')
const [loadedDatabaseUrl, setLoadedDatabaseUrl] = useState('')
const [pendingDbPassword, setPendingDbPassword] = useState('') // wird nur beim Speichern gesendet
const [dbBackupBusy, setDbBackupBusy] = useState(false)
const [dbRestoreBusy, setDbRestoreBusy] = useState(false)
const [dbMigrationBusy, setDbMigrationBusy] = useState(false)
const [dbMaintenanceMsg, setDbMaintenanceMsg] = useState<string | null>(null)
const [dbMaintenanceErr, setDbMaintenanceErr] = useState<string | null>(null)
const now = Date.now()
const saveSucceeded = saveSuccessUntilMs > now
const saveFailed = Boolean(err) && !saving && !saveSucceeded
const currentPostgresMode = normalizePostgresMode((value as any).postgresMode, (value as any).databaseUrl)
const currentDatabaseUrl = String((value as any).databaseUrl ?? '').trim()
const loadedPostgresSourceKey = postgresSourceKey({
databaseType: loadedDatabaseType,
databaseUrl: loadedDatabaseUrl,
postgresMode: loadedPostgresMode,
postgresDataDir: loadedPostgresDataDir,
postgresPort: loadedPostgresPort,
postgresDatabase: loadedPostgresDatabase,
postgresUser: loadedPostgresUser,
})
const currentPostgresSourceKey = postgresSourceKey(value)
const databaseLocationDirty = currentPostgresSourceKey !== loadedPostgresSourceKey
const databaseSettingsDirty =
databaseLocationDirty || Boolean((pendingDbPassword || '').trim())
const savedDatabaseConfigured =
loadedPostgresMode === 'local' ? Boolean(loadedPostgresDataDir) : Boolean(loadedDatabaseUrl)
const targetDatabaseConfigured =
currentPostgresMode === 'local'
? Boolean(normalizePostgresDataDir((value as any).postgresDataDir))
: Boolean(currentDatabaseUrl)
const databaseConfigured = savedDatabaseConfigured
const canMigrateDatabase = savedDatabaseConfigured && targetDatabaseConfigured && databaseLocationDirty
const dbMaintenanceBusy = dbBackupBusy || dbRestoreBusy || dbMigrationBusy
const [authStatus, setAuthStatus] = useState<AuthSecurityStatus | null>(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: <CheckIcon className="size-4" />,
isLoading: false,
}
}
if (saveUiState === 'error') {
return {
text: 'Fehler',
color: 'red' as const,
icon: <XMarkIcon className="size-4" />,
isLoading: false,
}
}
return {
text: 'Speichern',
color: 'indigo' as const,
icon: null,
isLoading: false,
}
})()
const [assetTasks, setAssetTasks] = useState<TaskItem[]>([])
const [checkVideoTasks, setCheckVideoTasks] = useState<TaskItem[]>([])
const [cleanupTasks, setCleanupTasks] = useState<TaskItem[]>([])
const [regenerateAssetsTasks, setRegenerateAssetsTasks] = useState<TaskItem[]>([])
const reportedFinishedAssetTaskIdsRef = useRef<Set<string>>(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
const databaseType = normalizeDatabaseType((data as any).databaseType)
const postgresMode = normalizePostgresMode((data as any).postgresMode, (data as any).databaseUrl)
const postgresDataDir = normalizePostgresDataDir((data as any).postgresDataDir)
const postgresPort = normalizePostgresPort((data as any).postgresPort)
const postgresDatabase = normalizePostgresName((data as any).postgresDatabase, 'nsfwapp')
const postgresUser = normalizePostgresName((data as any).postgresUser, 'nsfwapp')
setValue({
databaseType,
postgresMode,
postgresDataDir,
postgresPort,
postgresDatabase,
postgresUser,
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,
})
setLoadedDatabaseType(databaseType)
setLoadedPostgresMode(postgresMode)
setLoadedPostgresDataDir(postgresDataDir)
setLoadedPostgresPort(postgresPort)
setLoadedPostgresDatabase(postgresDatabase)
setLoadedPostgresUser(postgresUser)
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' | 'postgresData') {
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 }
if (target === 'postgresData') return { ...v, postgresMode: 'local', postgresDataDir: normalizePostgresDataDir(p) }
return { ...v, ffmpegPath: p }
})
} catch (e: any) {
setErr(e?.message ?? String(e))
} finally {
setBrowsing(null)
}
}
async function downloadDatabaseBackup() {
setDbMaintenanceMsg(null)
setDbMaintenanceErr(null)
if (!databaseConfigured) {
setDbMaintenanceErr('Bitte zuerst eine Datenbank-Konfiguration 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 (!databaseConfigured) {
setDbMaintenanceErr('Bitte zuerst eine Datenbank-Konfiguration speichern.')
return
}
if (databaseSettingsDirty) {
setDbMaintenanceErr('Bitte die Datenbank-Einstellungen zuerst speichern.')
return
}
const ok = window.confirm(
`Datenbank-Backup "${file.name}" wiederherstellen?\n\n` +
'Das überschreibt die aktuelle Datenbank. Dieser Schritt kann nicht automatisch rückgängig 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: {
databaseType: loadedDatabaseType,
postgresMode: loadedPostgresMode,
postgresDataDir: loadedPostgresDataDir,
postgresPort: loadedPostgresPort,
postgresDatabase: loadedPostgresDatabase,
postgresUser: loadedPostgresUser,
databaseUrl: loadedDatabaseUrl,
restored: true,
},
})
)
} catch (e: any) {
setDbMaintenanceErr(e?.message ?? String(e))
} finally {
setDbRestoreBusy(false)
}
}
async function migrateDatabase() {
setDbMaintenanceMsg(null)
setDbMaintenanceErr(null)
const postgresMode = currentPostgresMode
const postgresDataDir = normalizePostgresDataDir((value as any).postgresDataDir)
const postgresPort = normalizePostgresPort((value as any).postgresPort)
const postgresDatabase = normalizePostgresName((value as any).postgresDatabase, 'nsfwapp')
const postgresUser = normalizePostgresName((value as any).postgresUser, 'nsfwapp')
const databaseUrl = currentDatabaseUrl
if (!savedDatabaseConfigured) {
setDbMaintenanceErr('Bitte zuerst eine Quell-Datenbank speichern.')
return
}
if (!targetDatabaseConfigured) {
setDbMaintenanceErr('Bitte zuerst die Ziel-Datenbank angeben.')
return
}
if (!databaseLocationDirty) {
setDbMaintenanceErr('Quelle und Ziel sind identisch.')
return
}
const targetLabel =
postgresMode === 'local'
? `lokalen Datenordner "${postgresDataDir}"`
: `Datenbank "${databaseUrl}"`
const ok = window.confirm(
`Aktive PostgreSQL-Datenbank nach ${targetLabel} migrieren?\n\n` +
'Die Ziel-Datenbank wird mit den aktuellen Model-Daten überschrieben. Danach nutzt die App das Ziel.'
)
if (!ok) return
setDbMigrationBusy(true)
try {
const res = await fetch('/api/settings/database/migrate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
body: JSON.stringify({
databaseType: 'postgres',
postgresMode,
postgresDataDir,
postgresPort,
postgresDatabase,
postgresUser,
databaseUrl,
dbPassword: pendingDbPassword || undefined,
}),
})
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 ?? 0)
setDbMaintenanceMsg(size ? `Migration abgeschlossen (${size}).` : 'Migration abgeschlossen.')
setLoadedDatabaseType('postgres')
setLoadedPostgresMode(postgresMode)
setLoadedPostgresDataDir(postgresDataDir)
setLoadedPostgresPort(postgresPort)
setLoadedPostgresDatabase(postgresDatabase)
setLoadedPostgresUser(postgresUser)
setLoadedDatabaseUrl(databaseUrl)
setPendingDbPassword('')
setValue((v) => ({
...v,
databaseType: 'postgres',
postgresMode,
postgresDataDir,
postgresPort,
postgresDatabase,
postgresUser,
databaseUrl,
}))
window.dispatchEvent(new CustomEvent('recorder-settings-updated'))
window.dispatchEvent(new Event('models-changed'))
window.dispatchEvent(
new CustomEvent('models-db-changed', {
detail: {
databaseType: 'postgres',
postgresMode,
postgresDataDir,
postgresPort,
postgresDatabase,
postgresUser,
databaseUrl,
migrated: true,
},
})
)
} catch (e: any) {
setDbMaintenanceErr(e?.message ?? String(e))
} finally {
setDbMigrationBusy(false)
}
}
async function save() {
setErr(null)
setMsg(null)
const recordDir = value.recordDir.trim()
const doneDir = value.doneDir.trim()
const ffmpegPath = (value.ffmpegPath ?? '').trim()
const databaseType = normalizeDatabaseType((value as any).databaseType)
const postgresMode = normalizePostgresMode((value as any).postgresMode, (value as any).databaseUrl)
const postgresDataDir = normalizePostgresDataDir((value as any).postgresDataDir)
const postgresPort = normalizePostgresPort((value as any).postgresPort)
const postgresDatabase = normalizePostgresName((value as any).postgresDatabase, 'nsfwapp')
const postgresUser = normalizePostgresName((value as any).postgresUser, 'nsfwapp')
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
}
if (postgresMode === 'url' && !databaseUrl) {
setErr('Bitte Database URL angeben.')
return
}
if (postgresMode === 'local' && !postgresDataDir) {
setErr('Bitte PostgreSQL-Datenordner 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({
databaseType,
postgresMode,
postgresDataDir,
postgresPort,
postgresDatabase,
postgresUser,
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'))
setValue((v) => ({
...v,
databaseType,
postgresMode,
postgresDataDir,
postgresPort,
postgresDatabase,
postgresUser,
databaseUrl,
}))
const databaseChanged =
postgresSourceKey({
databaseType,
postgresMode,
postgresDataDir,
postgresPort,
postgresDatabase,
postgresUser,
databaseUrl,
}) !== loadedPostgresSourceKey ||
hadPendingDbPassword
if (databaseChanged) {
window.dispatchEvent(
new CustomEvent('models-db-changed', {
detail: {
databaseType,
postgresMode,
postgresDataDir,
postgresPort,
postgresDatabase,
postgresUser,
databaseUrl,
},
})
)
setLoadedDatabaseType(databaseType)
setLoadedPostgresMode(postgresMode)
setLoadedPostgresDataDir(postgresDataDir)
setLoadedPostgresPort(postgresPort)
setLoadedPostgresDatabase(postgresDatabase)
setLoadedPostgresUser(postgresUser)
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\nPruefe, ob du den Passkey unter genau dieser Browser-URL registriert hast. Passkeys sind an Hostname und RP-ID gebunden.'
)
}
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. Pruefe, ob Hostname, HTTPS und die Passkey-Registrierung zusammenpassen.'
)
}
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 (
<Card
header={
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<div className="text-base font-semibold text-gray-900 dark:text-white">
Einstellungen
</div>
<span
className={[
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-bold ring-1',
aiServerStatus === null
? 'bg-gray-100 text-gray-700 ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10'
: aiServerStatus.running
? 'bg-emerald-50 text-emerald-800 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-400/30'
: 'bg-red-50 text-red-800 ring-red-200 dark:bg-red-500/15 dark:text-red-100 dark:ring-red-400/30',
].join(' ')}
title={
aiServerStatus === null
? 'AI Server Status wird geladen…'
: aiServerStatus.running
? `AI Server läuft${aiServerStatus.url ? `: ${aiServerStatus.url}` : ''}`
: `AI Server offline${aiServerStatus.error ? `: ${aiServerStatus.error}` : ''}`
}
>
<span
className={[
'h-1.5 w-1.5 rounded-full',
aiServerStatus === null
? 'bg-gray-400'
: aiServerStatus.running
? 'bg-emerald-500'
: 'bg-red-500',
].join(' ')}
aria-hidden="true"
/>
{aiServerStatus === null
? 'AI prüft…'
: aiServerStatus.running
? 'AI online'
: 'AI offline'}
</span>
</div>
<div className="mt-0.5 text-xs text-gray-600 dark:text-gray-300">
Recorder-Konfiguration, Automatisierung und Tasks.
</div>
</div>
{/* Rechts: Alerts + Button */}
<div className="flex items-start gap-2">
<Button
variant="primary"
color={saveButton.color}
onClick={save}
disabled={saving}
className="shrink-0"
size="md"
isLoading={saveButton.isLoading}
leadingIcon={saveButton.icon}
>
{saveButton.text}
</Button>
</div>
</div>
}
grayBody
>
<div className="space-y-4">
<TaskList
tasks={visibleTasks}
onCancel={(id: string) => {
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' ? (
<div className="sm:hidden">
{err && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200">
{err}
</div>
)}
{msg && (
<div className="rounded-lg border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700 dark:border-green-500/30 dark:bg-green-500/10 dark:text-green-200">
{msg}
</div>
)}
</div>
) : null}
{/* Aufgaben */}
<SettingsSection
id="tasks"
title="Aufgaben"
description="Hintergrundaufgaben wie z.B. Asset/Preview-Generierung."
icon="⚙️"
defaultOpen={true}
>
<div className="space-y-3">
<Task
title="Assets-Generator"
description="Erzeugt nur fehlende oder unvollständige Assets der aktivierten Phasen. Nicht ausgewählte Phasen werden komplett übersprungen."
startLabel="Start"
startingLabel="Starte…"
onTrigger={startAssetsTaskFromCurrentSwitches}
onCreated={(job) => {
if (!job?.id) return
upsertTask(setAssetTasks, mapAssetJobToTask(job))
}}
onError={(message) => {
setErr(message)
}}
/>
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
<div className="space-y-2">
<LabeledSwitch
checked={!!value.generateAssetsMeta}
onChange={(checked) => setValue((v) => ({ ...v, generateAssetsMeta: checked }))}
label="Meta"
description="Erzeugt oder ergänzt die meta.json mit Dauer, Auflösung, FPS und weiteren Videoinformationen."
/>
<LabeledSwitch
checked={!!value.generateAssetsThumb}
onChange={(checked) => setValue((v) => ({ ...v, generateAssetsThumb: checked }))}
label="Vorschaubild"
description="Erzeugt das statische Vorschaubild preview.jpg für Listen und Kartenansicht."
/>
<LabeledSwitch
checked={!!value.generateAssetsTeaser}
onChange={(checked) => setValue((v) => ({ ...v, generateAssetsTeaser: checked }))}
label="Teaser"
description="Erzeugt die kurze Video-Vorschau preview.mp4 für Hover- und Preview-Wiedergabe."
/>
<LabeledSwitch
checked={!!value.generateAssetsSprites}
onChange={(checked) => setValue((v) => ({ ...v, generateAssetsSprites: checked }))}
label="Sprites"
description="Erzeugt das Sprite-Bild preview-sprite.jpg für die Scrubber- und Timeline-Vorschau."
/>
<LabeledSwitch
checked={!!value.generateAssetsAnalyze}
onChange={(checked) => setValue((v) => ({ ...v, generateAssetsAnalyze: checked }))}
label="Analyse"
description="Führt die Analyse aus und ergänzt Treffer, Segmente und weitere Auswertungsdaten."
/>
</div>
</div>
<Task
title="Downloads prüfen"
description="Prüft fertige Videos anhand der preview-sprite.jpg auf schwarze Endzellen und schreibt das Ergebnis in die meta.json."
startLabel="Prüfen"
startingLabel="Starte…"
startUrl="/api/tasks/check-videos"
onCreated={(job) => {
if (!job?.id) return
upsertTask(setCheckVideoTasks, mapCheckVideosJobToTask(job))
}}
onError={(message) => {
setErr(message)
}}
/>
<Task
title="Aufräumen"
description='Löscht Dateien im doneDir kleiner als die Mindestgröße, optional Downloads bis zur gewählten Sterne-Bewertung, und entfernt verwaiste Assets.'
startLabel="Aufräumen"
startingLabel="Läuft…"
onTrigger={cleanupSmallDone}
disabled={saving}
onCreated={(job) => {
if (!job?.id) return
upsertTask(setCleanupTasks, mapCleanupJobToTask(job))
}}
onError={(message) => {
setErr(message)
}}
/>
</div>
</SettingsSection>
{/* Paths */}
<SettingsSection
id="paths"
title="Pfad-Einstellungen"
description="Aufnahme- und Zielverzeichnisse sowie optionaler ffmpeg-Pfad."
icon="📁"
defaultOpen={false}
>
<div className="mb-3">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Pfad-Einstellungen</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
Aufnahme- und Zielverzeichnisse sowie optionaler ffmpeg-Pfad.
</div>
</div>
{/* Datenbank */}
<div className="mb-3 rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-950/40">
<div className="mb-3">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Datenbank</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
PostgreSQL als externe Datenbank oder lokal verwalteter Datenordner.
</div>
</div>
<div className="space-y-3">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center">
<label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3">
Quelle
</label>
<div className="sm:col-span-9">
<div className="inline-flex rounded-lg bg-gray-100 p-1 ring-1 ring-gray-200 dark:bg-white/10 dark:ring-white/10">
{([
['url', 'Datenbank'],
['local', 'Datenordner'],
] as const).map(([mode, label]) => {
const active = currentPostgresMode === mode
return (
<button
key={mode}
type="button"
onClick={() => setValue((v) => ({ ...v, postgresMode: mode, databaseType: 'postgres' }))}
className={[
'rounded-md px-3 py-1.5 text-sm font-semibold transition',
active
? 'bg-white text-gray-950 shadow-sm dark:bg-gray-950 dark:text-white'
: 'text-gray-600 hover:text-gray-950 dark:text-gray-300 dark:hover:text-white',
].join(' ')}
>
{label}
</button>
)
})}
</div>
</div>
</div>
{currentPostgresMode === 'url' ? (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center">
<label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3">
Database URL
</label>
<div className="sm:col-span-9 flex gap-2">
<input
value={String((value as any).databaseUrl ?? '')}
onChange={(e) => setValue((v) => ({ ...v, databaseType: 'postgres', 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"
/>
<Button variant="secondary" onClick={() => setDbModalOpen(true)} disabled={saving}>
URL erstellen...
</Button>
</div>
</div>
) : (
<div className="space-y-3">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center">
<label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3">
Datenordner
</label>
<div className="sm:col-span-9 flex gap-2">
<input
value={String((value as any).postgresDataDir ?? DEFAULTS.postgresDataDir ?? POSTGRES_DATA_DIR_NAME)}
onChange={(e) => setValue((v) => ({ ...v, postgresMode: 'local', postgresDataDir: e.target.value }))}
placeholder={POSTGRES_DATA_DIR_NAME}
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"
/>
<Button variant="secondary" onClick={() => browse('postgresData')} disabled={saving || browsing !== null}>
Speicherort...
</Button>
</div>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center">
<label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3">
Verbindung
</label>
<div className="sm:col-span-9 grid grid-cols-1 gap-2 sm:grid-cols-3">
<input
type="number"
min={1}
max={65535}
value={Number((value as any).postgresPort ?? DEFAULTS.postgresPort ?? 55432)}
onChange={(e) => setValue((v) => ({ ...v, postgresMode: 'local', postgresPort: Number(e.target.value || 55432) }))}
placeholder="Port"
className="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"
/>
<input
value={String((value as any).postgresDatabase ?? DEFAULTS.postgresDatabase ?? 'nsfwapp')}
onChange={(e) => setValue((v) => ({ ...v, postgresMode: 'local', postgresDatabase: e.target.value }))}
placeholder="Datenbank"
className="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"
/>
<input
value={String((value as any).postgresUser ?? DEFAULTS.postgresUser ?? 'nsfwapp')}
onChange={(e) => setValue((v) => ({ ...v, postgresMode: 'local', postgresUser: e.target.value }))}
placeholder="User"
className="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"
/>
</div>
</div>
</div>
)}
<div className="border-t border-gray-200 pt-3 dark:border-white/10">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-900 dark:text-white">
Backup / Restore
</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
Exportiert die gespeicherte PostgreSQL-Datenbank als pg_dump-Datei oder stellt sie aus einem Backup wieder her.
</div>
{databaseSettingsDirty ? (
<div className="mt-2 text-xs text-amber-700 dark:text-amber-200">
Die Datenbank-Einstellungen wurden geändert. Bitte erst speichern.
</div>
) : null}
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button
variant="secondary"
color="blue"
isLoading={dbMigrationBusy}
disabled={saving || dbMaintenanceBusy || !canMigrateDatabase}
onClick={() => void migrateDatabase()}
>
Migrieren und wechseln
</Button>
<Button
variant="secondary"
color="emerald"
isLoading={dbBackupBusy}
disabled={saving || dbMaintenanceBusy || !databaseConfigured || databaseSettingsDirty}
onClick={() => void downloadDatabaseBackup()}
>
Backup herunterladen
</Button>
<Button
variant="secondary"
color="amber"
isLoading={dbRestoreBusy}
disabled={saving || dbMaintenanceBusy || !databaseConfigured || databaseSettingsDirty}
onClick={chooseDatabaseRestoreFile}
>
Restore
</Button>
</div>
</div>
<input
ref={dbRestoreInputRef}
type="file"
accept=".dump,.backup,.bin,application/octet-stream"
className="hidden"
onChange={(e) => {
const file = e.currentTarget.files?.[0] ?? null
e.currentTarget.value = ''
void restoreDatabaseBackup(file)
}}
/>
{dbMaintenanceErr ? (
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200">
{dbMaintenanceErr}
</div>
) : dbMaintenanceMsg ? (
<div className="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs text-emerald-800 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-200">
{dbMaintenanceMsg}
</div>
) : null}
</div>
</div>
</div>
<PostgresUrlModal
open={dbModalOpen}
onClose={() => setDbModalOpen(false)}
initialUrl={String((value as any).databaseUrl ?? '')}
initialHasPassword={Boolean((value as any).hasDbPassword)}
onApply={({ databaseUrl, dbPassword }) => {
setValue((v) => ({ ...v, postgresMode: 'url', databaseUrl }))
setPendingDbPassword(dbPassword || '')
}}
/>
<div className="space-y-3">
{/* Aufnahme-Ordner */}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center">
<label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3">
Aufnahme-Ordner
</label>
<div className="sm:col-span-9 flex gap-2">
<input
value={value.recordDir}
onChange={(e) => 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"
/>
<Button variant="secondary" onClick={() => browse('record')} disabled={saving || browsing !== null}>
Durchsuchen...
</Button>
</div>
</div>
{/* Fertige Downloads */}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center">
<label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3">
Fertige Downloads nach
</label>
<div className="sm:col-span-9 flex gap-2">
<input
value={value.doneDir}
onChange={(e) => 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"
/>
<Button variant="secondary" onClick={() => browse('done')} disabled={saving || browsing !== null}>
Durchsuchen...
</Button>
</div>
</div>
{/* ffmpeg.exe */}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center">
<label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3">
ffmpeg.exe
</label>
<div className="sm:col-span-9 flex gap-2">
<input
value={value.ffmpegPath ?? ''}
onChange={(e) => 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"
/>
<Button variant="secondary" onClick={() => browse('ffmpeg')} disabled={saving || browsing !== null}>
Durchsuchen...
</Button>
</div>
</div>
</div>
</SettingsSection>
{/* Automatisierung */}
<SettingsSection
id="automation"
title="Automatisierung & Anzeige"
description="Verhalten beim Hinzufügen/Starten sowie Anzeigeoptionen."
icon="🤖"
defaultOpen={false}
>
<div className="mb-3">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Automatisierung & Anzeige</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
Verhalten beim Hinzufügen/Starten sowie Anzeigeoptionen.
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5 mb-2">
<LabeledSwitch
checked={!!value.enableConcurrentDownloadsLimit}
onChange={(checked) =>
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."
/>
<div
className={
'mt-2 grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center ' +
(!value.enableConcurrentDownloadsLimit ? 'opacity-50 pointer-events-none' : '')
}
>
<div className="sm:col-span-4">
<div className="text-sm font-medium text-gray-900 dark:text-gray-200">Max. parallele Downloads</div>
<div className="text-xs text-gray-600 dark:text-gray-300">
Mehr gleichzeitige Starts werden nicht zugelassen.
</div>
</div>
<div className="sm:col-span-8">
<div className="flex items-center justify-end gap-2">
<input
type="number"
min={1}
step={1}
value={value.maxConcurrentDownloads ?? 20}
onChange={(e) =>
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"
/>
</div>
</div>
</div>
</div>
<div className="space-y-3">
<LabeledSwitch
checked={!!value.enrichPostworkEnabled}
onChange={(checked) =>
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."
/>
<LabeledSwitch
checked={!!value.autoAddToDownloadList}
onChange={(checked) =>
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."
/>
<LabeledSwitch
checked={!!value.autoStartAddedDownloads}
onChange={(checked) =>
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."
/>
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
<LabeledSwitch
checked={!!value.useProviderApis}
onChange={(checked) =>
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."
/>
<div
className={
'mt-3 space-y-3 border-t border-gray-200 pt-3 dark:border-white/10 ' +
(!value.useProviderApis ? 'opacity-50 pointer-events-none' : '')
}
>
<LabeledSwitch
checked={!!value.useChaturbateApi}
onChange={(checked) =>
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."
/>
<LabeledSwitch
checked={!!value.useMyFreeCamsApi}
onChange={(checked) =>
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."
/>
</div>
</div>
{/* ✅ NEU: Auto-Delete kleine Downloads */}
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
<LabeledSwitch
checked={!!value.autoDeleteSmallDownloads}
onChange={(checked) =>
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."
/>
<div
className={
'mt-2 grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center ' +
(!value.autoDeleteSmallDownloads ? 'opacity-50 pointer-events-none' : '')
}
>
<div className="sm:col-span-4">
<div className="text-sm font-medium text-gray-900 dark:text-gray-200">Mindestgröße</div>
<div className="text-xs text-gray-600 dark:text-gray-300">Alles darunter wird gelöscht.</div>
</div>
<div className="sm:col-span-8">
<div className="flex items-center justify-end gap-2">
<input
type="number"
min={0}
step={1}
value={value.autoDeleteSmallDownloadsBelowMB ?? 50}
onChange={(e) =>
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"
/>
<span className="shrink-0 text-xs text-gray-600 dark:text-gray-300">MB</span>
</div>
</div>
</div>
<div
className={
'mt-3 border-t border-gray-200 pt-3 dark:border-white/10 ' +
(!value.autoDeleteSmallDownloads ? 'opacity-50 pointer-events-none' : '')
}
>
<LabeledSwitch
checked={!!value.autoDeleteSmallDownloadsKeepFavorites}
onChange={(checked) =>
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."
/>
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
<LabeledSwitch
checked={!!value.autoDeleteLowRatedDownloads}
onChange={(checked) =>
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."
/>
<div
className={
'mt-2 grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center ' +
(!value.autoDeleteLowRatedDownloads ? 'opacity-50 pointer-events-none' : '')
}
>
<div className="sm:col-span-5">
<div className="text-sm font-medium text-gray-900 dark:text-gray-200">
Sterne-Grenze
</div>
<div className="text-xs text-gray-600 dark:text-gray-300">
Bis einschließlich dieser Bewertung wird gelöscht.
</div>
</div>
<div className="sm:col-span-7">
<RatingThresholdStars
value={value.autoDeleteLowRatedDownloadsMaxStars}
disabled={!value.autoDeleteLowRatedDownloads}
onChange={(stars) =>
setValue((v) => ({
...v,
autoDeleteLowRatedDownloadsMaxStars: stars,
}))
}
/>
</div>
</div>
</div>
<div className="mt-5 rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
<LabeledSwitch
checked={!!value.trainingRecognitionEnabled}
onChange={(checked) =>
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."
/>
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center">
<div className="sm:col-span-9">
<div className="text-sm font-medium text-gray-900 dark:text-gray-200">
Object Detection Epochs
</div>
<div className="max-w-4xl text-xs text-gray-600 dark:text-gray-300">
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.
</div>
</div>
<div className="sm:col-span-3">
<div className="flex items-center justify-end gap-2">
<input
type="number"
min={1}
max={300}
step={1}
value={value.trainingDetectorEpochs ?? 60}
onChange={(e) =>
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"
/>
<span className="shrink-0 text-xs text-gray-600 dark:text-gray-300">
Epochs
</span>
</div>
</div>
</div>
</div>
<LabeledSwitch
checked={!!value.blurPreviews}
onChange={(checked) => setValue((v) => ({ ...v, blurPreviews: checked }))}
label="Vorschaubilder blurren"
description="Weichzeichnet Vorschaubilder/Teaser (praktisch auf mobilen Geräten oder im öffentlichen Umfeld)."
/>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center">
<div className="sm:col-span-8">
<div className="text-sm font-medium text-gray-900 dark:text-gray-200">
Teaser abspielen
</div>
<div className="text-xs text-gray-600 dark:text-gray-300">
Standbild spart Leistung. Bei Hover (Standard): Desktop spielt bei Hover ab, Mobile im Viewport. Alle kann viel CPU ziehen.
</div>
</div>
<div className="sm:col-span-4 sm:flex sm:justify-end">
<label className="sr-only" htmlFor="teaserPlayback">
Teaser abspielen
</label>
<select
id="teaserPlayback"
value={value.teaserPlayback ?? 'hover'}
onChange={(e) =>
setValue((v) => ({ ...v, teaserPlayback: e.target.value as any }))
}
className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm
focus:outline-none focus:ring-2 focus:ring-indigo-500
dark:border-white/10 dark:bg-gray-900 dark:text-gray-100 dark:[color-scheme:dark]
sm:max-w-56"
>
<option value="still">Standbild</option>
<option value="hover">Bei Hover (Standard)</option>
<option value="all">Alle</option>
</select>
</div>
</div>
<LabeledSwitch
checked={!!value.teaserAudio}
onChange={(checked) => setValue((v) => ({ ...v, teaserAudio: checked }))}
label="Teaser mit Ton"
description="Wenn aktiv, werden Vorschau/Teaser nicht stumm geschaltet."
/>
<LabeledSwitch
checked={!!value.enableNotifications}
onChange={(checked) => 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)."
/>
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-gray-900 dark:text-white">Speicherplatz-Notbremse</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
Aktiviert automatisch Stop + Autostart-Block bei wenig freiem Speicher (Resume bei +3 GB).
</div>
</div>
<span
className={
'inline-flex items-center rounded-full px-2 py-1 text-[11px] font-medium ' +
(diskStatus?.emergency
? 'bg-red-100 text-red-800 dark:bg-red-500/20 dark:text-red-200'
: 'bg-green-100 text-green-800 dark:bg-green-500/20 dark:text-green-200')
}
title={diskStatus?.emergency ? 'Notfallbremse greift gerade' : 'OK'}
>
{diskStatus?.emergency ? 'AKTIV' : 'OK'}
</span>
</div>
<div className="mt-3 text-sm text-gray-900 dark:text-gray-200">
<div>
<span className="font-medium">Schwelle:</span>{' '}
Pause unter <span className="tabular-nums">{uiPauseGB}</span> GB
{' · '}Resume ab{' '}
<span className="tabular-nums">
{uiResumeGB}
</span>{' '}
GB
</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
{diskStatus
? `Frei: ${diskStatus.freeBytesHuman}${diskStatus.recordPath ? ` (Pfad: ${diskStatus.recordPath})` : ''}`
: 'Status wird geladen…'}
</div>
{diskStatus?.emergency && (
<div className="mt-2 text-xs text-red-700 dark:text-red-200">
Notfallbremse greift: laufende Downloads werden gestoppt und Autostart bleibt gesperrt, bis wieder genug frei ist.
</div>
)}
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
<button
type="button"
onClick={() => setAppLogOpen((v) => !v)}
className="flex w-full items-start justify-between gap-3 text-left"
>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm font-semibold text-gray-900 dark:text-white">
Log
</div>
{appLog?.sizeBytes ? (
<span className="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-700 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10">
{Math.ceil(appLog.sizeBytes / 1024)} KB
</span>
) : null}
</div>
<div className="mt-1 truncate text-xs text-gray-600 dark:text-gray-300">
Zeigt die aktuelle recorder.log aus dem App-Verzeichnis.
</div>
</div>
<span
className={[
'shrink-0 text-sm text-gray-500 transition-transform dark:text-gray-400',
appLogOpen ? 'rotate-180' : '',
].join(' ')}
aria-hidden="true"
>
</span>
</button>
{appLogOpen ? (
<div className="mt-3 border-t border-gray-200 pt-3 dark:border-white/10">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0 text-xs text-gray-600 dark:text-gray-300">
{appLog?.path ? (
<span className="break-all">
Logdatei: {appLog.path}
</span>
) : appLogLoading ? (
'Logdatei wird geladen…'
) : (
'Logdatei noch nicht geladen.'
)}
</div>
<Button
variant="secondary"
size="sm"
disabled={appLogLoading}
onClick={() => void loadAppLog()}
>
{appLogLoading ? 'Lade…' : 'Aktualisieren'}
</Button>
</div>
{appLog?.error ? (
<div className="mb-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-400/30 dark:bg-amber-500/10 dark:text-amber-100">
{appLog.error}
</div>
) : null}
<pre
ref={appLogPreRef}
className="max-h-96 overflow-auto whitespace-pre-wrap rounded-xl bg-gray-950 p-3 font-mono text-[11px] leading-relaxed text-gray-100 ring-1 ring-black/10 dark:ring-white/10"
>
{appLogLoading
? 'Log wird geladen…'
: appLog?.log?.trim()
? appLog.log
: 'Noch kein Log vorhanden.'}
</pre>
</div>
) : null}
</div>
</div>
</SettingsSection>
{/* Sicherheit */}
<SettingsSection
id="security"
title="Konto-Sicherheit"
description="Passwort, Zwei-Faktor-Authentifizierung und Passkeys getrennt von den Recorder-Einstellungen verwalten."
icon="🛡️"
defaultOpen={false}
actions={
<>
{statusPill(!!authStatus?.totpEnabled, '2FA aktiv', '2FA aus')}
{statusPill(!!authStatus?.passkeyConfigured, 'Passkey aktiv', 'Kein Passkey')}
</>
}
>
<div className="space-y-4">
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-gray-900 dark:text-white">
Passwort ändern
</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
Ändert das Login-Passwort. Andere Sessions werden abgemeldet.
</div>
</div>
</div>
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-3">
<input
type="password"
value={currentPassword}
onChange={(e) => 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"
/>
<input
type="password"
value={newPassword}
onChange={(e) => 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"
/>
<input
type="password"
value={newPasswordRepeat}
onChange={(e) => 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"
/>
</div>
<div className="mt-3 flex justify-end">
<Button
variant="primary"
disabled={securityBusy}
onClick={() => void changePassword()}
>
Passwort speichern
</Button>
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-gray-900 dark:text-white">
Zwei-Faktor-Authentifizierung
</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
Authenticator-App als zweiter Faktor. Einzelne Geräte können bei TOTP nicht getrennt erkannt werden.
</div>
</div>
{statusPill(!!authStatus?.totpEnabled, 'Aktiv', 'Aus')}
</div>
<div className="mt-3 space-y-2">
{authStatus?.totpDevice ? (
<SecurityDeviceRow
icon="🔐"
title={authStatus.totpDevice.label || 'Authenticator-App'}
subtitle={
authStatus?.totpEnabled
? '2FA ist aktiv und wird beim Passwort-Login abgefragt.'
: '2FA ist eingerichtet, aber aktuell nicht aktiv.'
}
meta={`Eingerichtet: ${formatSecurityDate(authStatus.totpDevice.configuredAt)}`}
badge={authStatus?.totpEnabled ? 'TOTP' : 'Inaktiv'}
/>
) : (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
2FA ist noch nicht eingerichtet. Die Einrichtung erfolgt aktuell beim nächsten Login-Setup.
</div>
)}
</div>
{authStatus?.totpConfigured || authStatus?.totpEnabled ? (
<>
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
<input
type="password"
value={disable2FAPassword}
onChange={(e) => {
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(' ')}
/>
<input
value={disable2FACode}
onChange={(e) => {
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(' ')}
/>
</div>
{disable2FAFieldError?.password || disable2FAFieldError?.code ? (
<div className="mt-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200">
{disable2FAFieldError.password || disable2FAFieldError.code}
</div>
) : (
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Zum Deaktivieren brauchst du dein aktuelles Passwort
{authStatus?.totpEnabled ? ' und einen gültigen 2FA-Code.' : '.'}
</div>
)}
<div className="mt-3 flex justify-end">
<Button
variant="primary"
color="red"
disabled={securityBusy}
onClick={() => void disable2FA()}
>
2FA deaktivieren
</Button>
</div>
</>
) : null}
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-gray-900 dark:text-white">
Passkeys
</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
Anmeldung ohne Passwort über Face ID, Fingerabdruck, Windows Hello oder Sicherheitsschlüssel.
</div>
</div>
{statusPill(!!authStatus?.passkeyConfigured, 'Aktiv', 'Keine')}
</div>
<div className="mt-3 space-y-2">
{(authStatus?.passkeys ?? []).length > 0 ? (
(authStatus?.passkeys ?? []).map((pk, index) => (
<SecurityDeviceRow
key={pk.id || index}
icon="🔑"
title={pk.label || `Passkey ${index + 1}`}
subtitle={
pk.backupEligible
? pk.backupState
? 'Synchronisierter Passkey'
: 'Passkey kann synchronisiert werden'
: 'Gerätegebundener Passkey oder Sicherheitsschlüssel'
}
meta={[
`Erstellt: ${formatSecurityDate(pk.createdAt)}`,
`Zuletzt genutzt: ${formatSecurityDate(pk.lastUsedAt)}`,
typeof pk.signCount === 'number' ? `SignCount: ${pk.signCount}` : '',
].filter(Boolean).join(' · ')}
badge="Passkey"
actions={
<Button
variant="secondary"
color="red"
size="sm"
disabled={securityBusy}
onClick={() => void deletePasskey(pk)}
>
Löschen
</Button>
}
/>
))
) : (
<div className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs text-gray-600 dark:border-white/10 dark:bg-gray-950/40 dark:text-gray-300">
Noch kein Passkey gespeichert.
</div>
)}
</div>
{!passkeySupported ? (
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
Dein Browser oder diese Verbindung unterstützt Passkeys aktuell nicht.
</div>
) : (
<div className="mt-3 space-y-2">
{passkeyFeedback ? (
<div
className={[
'whitespace-pre-wrap rounded-lg border px-3 py-2 text-xs',
passkeyFeedback.kind === 'success'
? 'border-green-200 bg-green-50 text-green-800 dark:border-green-500/30 dark:bg-green-500/10 dark:text-green-200'
: passkeyFeedback.kind === 'error'
? 'border-red-200 bg-red-50 text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200'
: 'border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-500/30 dark:bg-blue-500/10 dark:text-blue-200',
].join(' ')}
>
{passkeyFeedback.kind === 'loading' ? '⏳ ' : null}
{passkeyFeedback.kind === 'success' ? '✅ ' : null}
{passkeyFeedback.kind === 'error' ? '⚠️ ' : null}
{passkeyFeedback.text}
</div>
) : null}
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button
variant="secondary"
disabled={securityBusy}
onClick={() => void registerSettingsPasskey()}
>
{securityBusy ? 'Passkey wird eingerichtet…' : 'Passkey hinzufügen'}
</Button>
</div>
</div>
)}
</div>
</div>
</SettingsSection>
</div>
</Card>
)
}