761 lines
29 KiB
TypeScript
761 lines
29 KiB
TypeScript
// 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'
|
||
|
||
type RecorderSettings = {
|
||
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 = {
|
||
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
|
||
}
|
||
|
||
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 }: Props) {
|
||
const [value, setValue] = useState<RecorderSettings>(DEFAULTS)
|
||
const [saving, setSaving] = useState(false)
|
||
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)
|
||
// ✅ Tasklist (Assets generieren)
|
||
const assetsAbortRef = useRef<AbortController | null>(null)
|
||
|
||
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: false,
|
||
fading: false,
|
||
})
|
||
|
||
const pauseGB = Number(value.lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB ?? 5)
|
||
const uiPauseGB = diskStatus?.pauseGB ?? pauseGB
|
||
const uiResumeGB = diskStatus?.resumeGB ?? (pauseGB + 3)
|
||
|
||
|
||
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({
|
||
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,
|
||
})
|
||
})
|
||
.catch(() => {
|
||
// backend evtl. noch alt -> defaults lassen
|
||
})
|
||
return () => {
|
||
alive = false
|
||
}
|
||
}, [])
|
||
|
||
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)
|
||
}
|
||
}, [])
|
||
|
||
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()
|
||
|
||
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({
|
||
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.')
|
||
window.dispatchEvent(new CustomEvent('recorder-settings-updated'))
|
||
} catch (e: any) {
|
||
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: 1,
|
||
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: 1,
|
||
total: 1,
|
||
title: 'Aufräumen',
|
||
text: `geprüft: ${scannedFiles} · Orphans: ${orphansTotalRemoved}`,
|
||
}))
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Card
|
||
header={
|
||
<div className="flex items-center justify-between gap-4">
|
||
<div>
|
||
<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>
|
||
<Button variant="primary" onClick={save} disabled={saving}>
|
||
Speichern
|
||
</Button>
|
||
</div>
|
||
}
|
||
grayBody
|
||
>
|
||
<div className="space-y-4">
|
||
<TaskList
|
||
tasks={[assetsTask, cleanupTask]} // ✅ cleanupTask ist “am Ende”
|
||
onCancel={(id: string) => {
|
||
if (id === 'generate-assets') cancelAssetsTask()
|
||
}}
|
||
/>
|
||
|
||
{/* Alerts */}
|
||
{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>
|
||
)}
|
||
|
||
{/* 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/tasks/assets/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>
|
||
|
||
<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>
|
||
)
|
||
}
|