nsfwapp/frontend/src/components/ui/RecorderSettings.tsx
2026-03-26 07:38:24 +01:00

1144 lines
41 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 } 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, XMarkIcon } from '@heroicons/react/24/solid'
type RecorderSettings = {
databaseUrl?: string
hasDbPassword?: boolean
recordDir: string
doneDir: string
ffmpegPath?: string
autoAddToDownloadList?: boolean
autoStartAddedDownloads?: boolean
useChaturbateApi?: boolean
useMyFreeCamsWatcher?: boolean
autoDeleteSmallDownloads?: boolean
autoDeleteSmallDownloadsBelowMB?: number
blurPreviews?: boolean
teaserPlayback?: 'still' | 'hover' | 'all'
teaserAudio?: boolean
lowDiskPauseBelowGB?: number
enableNotifications?: boolean
}
type DiskStatus = {
emergency: boolean
pauseGB: number
resumeGB: number
freeBytes: number
freeBytesHuman: string
recordPath?: string
}
const DEFAULTS: RecorderSettings = {
databaseUrl: '',
hasDbPassword: false,
recordDir: 'records',
doneDir: 'records/done',
ffmpegPath: '',
autoAddToDownloadList: true,
useChaturbateApi: false,
useMyFreeCamsWatcher: false,
autoDeleteSmallDownloads: true,
autoDeleteSmallDownloadsBelowMB: 200,
blurPreviews: false,
teaserPlayback: 'hover',
teaserAudio: false,
lowDiskPauseBelowGB: 5,
enableNotifications: true,
}
type Props = {
onAssetsGenerated?: () => void
onTaskRunningChange?: (running: boolean) => void
}
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))
}
export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChange }: 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 cleanupDismissUntilRef = useRef(0)
const [cleaning, setCleaning] = useState(false)
const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | null>(null)
const [msg, setMsg] = useState<string | null>(null)
const [err, setErr] = useState<string | null>(null)
const [diskStatus, setDiskStatus] = useState<DiskStatus | null>(null)
const assetsAbortRef = useRef<AbortController | null>(null)
const [dbModalOpen, setDbModalOpen] = useState(false)
const [loadedDatabaseUrl, setLoadedDatabaseUrl] = useState('')
const [pendingDbPassword, setPendingDbPassword] = useState('') // wird nur beim Speichern gesendet
const now = Date.now()
const saveSucceeded = saveSuccessUntilMs > now
const saveFailed = Boolean(err) && !saving && !saveSucceeded
type SaveUiState = 'idle' | 'saving' | 'success' | 'error'
const saveUiState: SaveUiState = saving ? 'saving' : saveSucceeded ? 'success' : saveFailed ? 'error' : 'idle'
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 [assetsTask, setAssetsTask] = useState<TaskItem>({
id: 'generate-assets',
status: 'idle',
title: 'Assets generieren', // oder '' wenn du es komplett weg willst
text: '',
cancellable: true,
fading: false,
})
const [cleanupTask, setCleanupTask] = useState<TaskItem>({
id: 'cleanup',
status: 'idle',
title: 'Aufräumen',
text: '',
cancellable: true,
fading: false,
})
const [regenerateAssetsTask, setRegenerateAssetsTask] = useState<TaskItem>({
id: 'regenerate-assets',
status: 'idle',
title: 'Assets neu generieren',
text: '',
cancellable: true,
fading: false,
})
useEffect(() => {
const running =
assetsTask.status === 'running' ||
regenerateAssetsTask.status === 'running' ||
cleanupTask.status === 'running' ||
cleaning
onTaskRunningChange?.(running)
}, [assetsTask.status, regenerateAssetsTask.status, cleanupTask.status, cleaning, onTaskRunningChange])
const pauseGB = Number(value.lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB ?? 5)
const uiPauseGB = diskStatus?.pauseGB ?? pauseGB
const uiResumeGB = diskStatus?.resumeGB ?? (pauseGB + 3)
useEffect(() => {
return () => {
if (saveSuccessTimerRef.current != null) {
window.clearTimeout(saveSuccessTimerRef.current)
saveSuccessTimerRef.current = null
}
}
}, [])
useEffect(() => {
let alive = true
fetch('/api/settings', { cache: 'no-store' })
.then(async (r) => {
if (!r.ok) throw new Error(await r.text())
return r.json()
})
.then((data: RecorderSettings) => {
if (!alive) return
setValue({
databaseUrl: String((data as any).databaseUrl ?? ''),
hasDbPassword: Boolean((data as any).hasDbPassword ?? false),
recordDir: (data.recordDir || DEFAULTS.recordDir).toString(),
doneDir: (data.doneDir || DEFAULTS.doneDir).toString(),
ffmpegPath: String(data.ffmpegPath ?? DEFAULTS.ffmpegPath ?? ''),
// ✅ falls backend die Felder noch nicht hat -> defaults nutzen
autoAddToDownloadList: data.autoAddToDownloadList ?? DEFAULTS.autoAddToDownloadList,
autoStartAddedDownloads: data.autoStartAddedDownloads ?? DEFAULTS.autoStartAddedDownloads,
useChaturbateApi: data.useChaturbateApi ?? DEFAULTS.useChaturbateApi,
useMyFreeCamsWatcher: data.useMyFreeCamsWatcher ?? DEFAULTS.useMyFreeCamsWatcher,
autoDeleteSmallDownloads: data.autoDeleteSmallDownloads ?? DEFAULTS.autoDeleteSmallDownloads,
autoDeleteSmallDownloadsBelowMB: data.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB,
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,
})
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 regen = data.regenerateAssets
if (regen?.running) {
setRegenerateAssetsTask((t) => ({
...t,
status: 'running',
title: 'Assets neu generieren',
text: String(regen.text || regen.file || ''),
err: undefined,
cancellable: true,
fading: false,
}))
} else if (regen?.error) {
setRegenerateAssetsTask((t) => ({
...t,
status: 'error',
title: 'Assets neu generieren',
text: String(regen.file || ''),
err: String(regen.error),
cancellable: false,
fading: false,
}))
} else if (regen?.text || regen?.file) {
setRegenerateAssetsTask((t) => ({
...t,
status: 'done',
title: 'Assets neu generieren',
text: String(regen.text || regen.file || ''),
err: undefined,
cancellable: false,
fading: false,
}))
} else {
setRegenerateAssetsTask((t) => ({
...t,
status: 'idle',
title: 'Assets neu generieren',
text: '',
err: undefined,
cancellable: true,
fading: false,
}))
}
const cleanup = data.cleanup
const cleanupSuppressed = Date.now() < cleanupDismissUntilRef.current
if (cleanup?.running) {
setCleaning(true)
setCleanupTask((t) => ({
...t,
status: 'running',
title: 'Aufräumen',
text: String(cleanup.currentFile || cleanup.text || 'Räume auf…'),
done: Number(cleanup.done ?? 0),
total: Number(cleanup.total ?? 0),
err: undefined,
cancellable: false,
fading: false,
}))
} else if (!cleanupSuppressed && cleanup?.error) {
setCleaning(false)
setCleanupTask((t) => ({
...t,
status: 'error',
title: 'Aufräumen',
text: String(cleanup.text || 'Fehler beim Aufräumen.'),
done: Number(cleanup.done ?? 0),
total: Number(cleanup.total ?? 0),
err: String(cleanup.error),
cancellable: false,
fading: false,
}))
} else if (!cleanupSuppressed && (cleanup?.finishedAt || cleanup?.text)) {
setCleaning(false)
setCleanupTask((t) => ({
...t,
status: 'done',
title: 'Aufräumen',
text: String(cleanup.text || 'Aufräumen abgeschlossen.'),
done: Number(cleanup.done ?? 0),
total: Number(cleanup.total ?? 0),
err: undefined,
cancellable: false,
fading: false,
}))
} else {
setCleaning(false)
setCleanupTask((t) => ({
...t,
status: 'idle',
title: 'Aufräumen',
text: '',
done: 0,
total: 0,
err: undefined,
cancellable: true,
fading: false,
}))
}
} catch {
// ignore
}
}
void loadTasksStatus()
const timer = window.setInterval(loadTasksStatus, 1500)
return () => {
alive = false
window.clearInterval(timer)
}
}, [])
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, 5000) // alle 5s aktualisieren
return () => {
alive = false
window.clearInterval(t)
}
}, [])
useEffect(() => {
let alive = true
const loadTaskStatus = async () => {
try {
const res = await fetch('/api/tasks/generate-assets', { cache: 'no-store' })
if (!res.ok) return
const data = await res.json().catch(() => null)
if (!alive || !data) return
const running = Boolean(data.running)
const done = Number(data.done ?? 0)
const total = Number(data.total ?? 0)
const currentFile = shortTaskFilename(data.currentFile)
if (running) {
setAssetsTask((t) => ({
...t,
status: 'running',
title: 'Assets generieren',
text: currentFile || '',
done,
total,
err: undefined,
fading: false,
}))
} else {
setAssetsTask((t) => ({
...t,
status: 'idle',
title: 'Assets generieren',
text: '',
done: 0,
total: 0,
err: undefined,
fading: false,
}))
}
} catch {
// ignorieren
}
}
void loadTaskStatus()
return () => {
alive = false
}
}, [])
async function browse(target: 'record' | 'done' | 'ffmpeg') {
setErr(null)
setMsg(null)
setBrowsing(target)
try {
window.focus()
const res = await fetch(`/api/settings/browse?target=${target}`, { cache: 'no-store' })
if (res.status === 204) return // user cancelled
if (!res.ok) {
const t = await res.text().catch(() => '')
throw new Error(t || `HTTP ${res.status}`)
}
const data = (await res.json()) as { path?: string }
const p = (data.path ?? '').trim()
if (!p) return
setValue((v) => {
if (target === 'record') return { ...v, recordDir: p }
if (target === 'done') return { ...v, doneDir: p }
return { ...v, ffmpegPath: p }
})
} catch (e: any) {
setErr(e?.message ?? String(e))
} finally {
setBrowsing(null)
}
}
async function save() {
setErr(null)
setMsg(null)
const recordDir = value.recordDir.trim()
const doneDir = value.doneDir.trim()
const ffmpegPath = (value.ffmpegPath ?? '').trim()
const databaseUrl = String((value as any).databaseUrl ?? '').trim()
const hadPendingDbPassword = Boolean((pendingDbPassword || '').trim())
if (!recordDir || !doneDir) {
setErr('Bitte Aufnahme-Ordner und Ziel-Ordner angeben.')
return
}
// ✅ Switch-Logik: Autostart nur sinnvoll, wenn Auto-Add aktiv ist
const autoAddToDownloadList = !!value.autoAddToDownloadList
const autoStartAddedDownloads = autoAddToDownloadList ? !!value.autoStartAddedDownloads : false
const useChaturbateApi = !!value.useChaturbateApi
const useMyFreeCamsWatcher = !!value.useMyFreeCamsWatcher
const autoDeleteSmallDownloads = !!value.autoDeleteSmallDownloads
const autoDeleteSmallDownloadsBelowMB = Math.max(
0,
Math.min(100_000, Math.floor(Number(value.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB)))
)
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
setSaving(true)
try {
const res = await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
databaseUrl,
dbPassword: pendingDbPassword || undefined,
recordDir,
doneDir,
ffmpegPath,
autoAddToDownloadList,
autoStartAddedDownloads,
useChaturbateApi,
useMyFreeCamsWatcher,
autoDeleteSmallDownloads,
autoDeleteSmallDownloadsBelowMB,
blurPreviews,
teaserPlayback,
teaserAudio,
lowDiskPauseBelowGB,
enableNotifications,
}),
})
if (!res.ok) {
const t = await res.text().catch(() => '')
throw new Error(t || `HTTP ${res.status}`)
}
setMsg('✅ Gespeichert.')
// Button-Label: "Gespeichert" für 2.5s
const until = Date.now() + 2500
setSaveSuccessUntilMs(until)
if (saveSuccessTimerRef.current != null) {
window.clearTimeout(saveSuccessTimerRef.current)
}
saveSuccessTimerRef.current = window.setTimeout(() => {
setSaveSuccessUntilMs(0)
saveSuccessTimerRef.current = null
}, 2500)
window.dispatchEvent(new CustomEvent('recorder-settings-updated'))
const databaseUrlChanged =
databaseUrl !== loadedDatabaseUrl || hadPendingDbPassword
if (databaseUrlChanged) {
window.dispatchEvent(
new CustomEvent('models-db-changed', {
detail: { databaseUrl },
})
)
setLoadedDatabaseUrl(databaseUrl)
setPendingDbPassword('')
}
} catch (e: any) {
setSaveSuccessUntilMs(0)
if (saveSuccessTimerRef.current != null) {
window.clearTimeout(saveSuccessTimerRef.current)
saveSuccessTimerRef.current = null
}
setErr(e?.message ?? String(e))
} finally {
setSaving(false)
}
}
function fadeOutTask(setter: React.Dispatch<React.SetStateAction<TaskItem>>, delayMs = 3500, fadeMs = 500) {
window.setTimeout(() => {
setter((t) => ({ ...t, fading: true }))
window.setTimeout(() => {
setter((t) => ({ ...t, status: 'idle', text: '', err: undefined, done: 0, total: 0, fading: false }))
}, fadeMs)
}, delayMs)
}
async function cleanupSmallDone() {
setErr(null)
setMsg(null)
const mb = Number(value.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB ?? 0)
const doneDir = (value.doneDir || DEFAULTS.doneDir).trim()
if (!doneDir) {
setErr('doneDir ist leer.')
return
}
if (!mb || mb <= 0) {
setErr('Mindestgröße ist 0 es würde nichts gelöscht.')
return
}
const ok = window.confirm(
`Aufräumen:\n` +
`• Löscht Dateien in "${doneDir}" < ${mb} MB (Ordner "keep" wird übersprungen)\n` +
`• Entfernt verwaiste Previews/Thumbs/Generated-Assets ohne passende Datei\n\n` +
`Fortfahren?`
)
if (!ok) return
// ✅ Task starten (als letzter Eintrag in TaskList)
setCleaning(true)
setCleanupTask((t) => ({
...t,
status: 'running',
title: 'Aufräumen', // ✅ HINZUFÜGEN (reset)
text: 'Räume auf…',
err: undefined,
done: 0,
total: 0,
fading: false,
}))
try {
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}`)
}
const data = await res.json()
const scannedFiles = Number(data.scannedFiles ?? 0)
const orphanRemoved = Number(data.orphanIdsRemoved ?? 0)
const genRemoved = Number(data.generatedOrphansRemoved ?? 0)
const orphansTotalRemoved = orphanRemoved + genRemoved
setCleanupTask((t) => ({
...t,
status: 'done',
done: scannedFiles,
total: scannedFiles,
title: 'Aufräumen',
text: `geprüft: ${scannedFiles} · Orphans: ${orphansTotalRemoved}`,
}))
cleanupDismissUntilRef.current = Date.now() + 4500
fadeOutTask(setCleanupTask) // ✅ nach und nach ausfaden
} catch (e: any) {
const msg = e?.message ?? String(e)
setErr(msg)
setCleanupTask((t) => ({
...t,
status: 'error',
text: 'Fehler beim Aufräumen.',
err: msg,
}))
fadeOutTask(setCleanupTask)
} finally {
setCleaning(false)
}
}
async function cancelAssetsTask() {
const ac = assetsAbortRef.current
assetsAbortRef.current = null
// UI sofort
setAssetsTask((t: TaskItem) => ({ ...t, status: 'cancelled', text: 'Abgebrochen.' }))
if (ac) {
ac.abort()
return
}
// Fallback (z.B. nach Reload, wenn kein Controller existiert):
try {
await fetch('/api/tasks/generate-assets', { method: 'DELETE', cache: 'no-store' as any })
} catch {
// ignore
}
}
async function cancelRegenerateAssetsTask() {
// UI sofort aktualisieren
setRegenerateAssetsTask((t) => ({
...t,
status: 'cancelled',
title: 'Assets neu generieren',
text: 'Abgebrochen.',
err: undefined,
fading: false,
}))
try {
// Vorschlag: gleicher Endpoint wie Start, aber DELETE zum Abbrechen
await fetch('/api/record/regenerate-assets', {
method: 'DELETE',
cache: 'no-store' as any,
})
} catch {
// ignore
}
fadeOutTask(setRegenerateAssetsTask)
}
return (
<Card
header={
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="text-base font-semibold text-gray-900 dark:text-white">Einstellungen</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={[assetsTask, regenerateAssetsTask, cleanupTask]}
onCancel={(id: string) => {
if (id === 'generate-assets') {
void cancelAssetsTask()
return
}
if (id === 'regenerate-assets') {
void cancelRegenerateAssetsTask()
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 */}
<div className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-950/40">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Aufgaben</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
Hintergrundaufgaben wie z.B. Asset/Preview-Generierung.
</div>
</div>
</div>
<div className="mt-3 space-y-3">
<Task
title="Assets-Generator"
description="Erzeugt fehlende Assets (thumb/preview/meta). Fortschritt & Abbrechen oben in der Taskliste."
startLabel="Start"
startingLabel="Starte…"
startUrl="/api/tasks/generate-assets"
stopUrl="/api/tasks/generate-assets"
sseUrl="/api/events/stream"
onFinished={onAssetsGenerated}
onStart={(ac) => {
assetsAbortRef.current = ac
setAssetsTask((t: TaskItem) => ({
...t,
status: 'running',
title: 'Assets generieren',
text: '',
done: 0,
total: 0,
err: undefined,
fading: false,
}))
}}
onProgress={(p) => {
const fn = shortTaskFilename(p.currentFile)
setAssetsTask((t: TaskItem) => ({
...t,
status: 'running',
title: 'Assets generieren',
text: fn || '',
done: p.done,
total: p.total,
}))
}}
onDone={() => {
assetsAbortRef.current = null
setAssetsTask((t: TaskItem) => ({ ...t, status: 'done', title: 'Assets generieren' }))
fadeOutTask(setAssetsTask)
}}
onCancelled={() => {
assetsAbortRef.current = null
setAssetsTask((t: TaskItem) => ({
...t,
status: 'cancelled',
title: 'Assets generieren',
text: 'Abgebrochen.',
}))
fadeOutTask(setAssetsTask)
}}
onError={(message) => {
assetsAbortRef.current = null
setAssetsTask((t: TaskItem) => ({
...t,
status: 'error',
title: 'Assets generieren',
text: 'Fehler beim Generieren.',
err: message,
}))
fadeOutTask(setAssetsTask)
}}
/>
<Task
title="Aufräumen"
description='Löscht Dateien im doneDir kleiner als die Mindestgröße (Ordner "keep" wird übersprungen) und entfernt verwaiste Assets.'
startLabel="Aufräumen"
startingLabel="Läuft…"
onTrigger={cleanupSmallDone}
busy={cleaning}
disabled={saving || !value.autoDeleteSmallDownloads}
onError={(message) => {
// Optional: zusätzlicher Fallback für Startfehler-Anzeige direkt im Task
setErr(message)
}}
/>
</div>
</div>
{/* Paths */}
<div className="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">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">
Postgres Verbindung. Passwort wird verschlüsselt gespeichert.
</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">
Database URL
</label>
<div className="sm:col-span-9 flex gap-2">
<input
value={String((value as any).databaseUrl ?? '')}
onChange={(e) => setValue((v) => ({ ...v, databaseUrl: e.target.value }))}
placeholder="postgres://user@host:5432/db?sslmode=disable"
className="min-w-0 flex-1 rounded-lg px-3 py-2 text-sm bg-white text-gray-900 ring-1 ring-gray-200
focus:outline-none focus:ring-2 focus:ring-indigo-500
dark:bg-white/10 dark:text-white dark:ring-white/10"
/>
<Button variant="secondary" onClick={() => setDbModalOpen(true)} disabled={saving}>
URL erstellen
</Button>
</div>
</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, 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>
</div>
{/* Automatisierung */}
<div className="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">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="space-y-3">
<LabeledSwitch
checked={!!value.autoAddToDownloadList}
onChange={(checked) =>
setValue((v) => ({
...v,
autoAddToDownloadList: checked,
autoStartAddedDownloads: checked ? v.autoStartAddedDownloads : false,
}))
}
label="Automatisch zur Downloadliste hinzufügen"
description="Neue Links/Modelle werden automatisch in die Downloadliste übernommen."
/>
<LabeledSwitch
checked={!!value.autoStartAddedDownloads}
onChange={(checked) => setValue((v) => ({ ...v, autoStartAddedDownloads: checked }))}
disabled={!value.autoAddToDownloadList}
label="Hinzugefügte Downloads automatisch starten"
description="Wenn ein Download hinzugefügt wurde, startet er direkt (sofern möglich)."
/>
<LabeledSwitch
checked={!!value.useChaturbateApi}
onChange={(checked) => setValue((v) => ({ ...v, useChaturbateApi: checked }))}
label="Chaturbate API"
description="Wenn aktiv, pollt das Backend alle paar Sekunden die Online-Rooms API und cached die aktuell online Models."
/>
<LabeledSwitch
checked={!!value.useMyFreeCamsWatcher}
onChange={(checked) => setValue((v) => ({ ...v, useMyFreeCamsWatcher: checked }))}
label="MyFreeCams Auto-Check (watched)"
description="Geht watched MyFreeCams-Models einzeln durch und startet einen Download. Wenn keine Output-Datei entsteht, ist der Stream nicht öffentlich (offline/away/private) und der Job wird wieder entfernt."
/>
{/* ✅ 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>
<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-4">
<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-8">
<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
dark:border-white/10 dark:bg-gray-900 dark:text-gray-100 dark:[color-scheme:dark]"
>
<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>
</div>
</div>
</Card>
)
}