// frontend\src\components\ui\RecorderSettings.tsx 'use client' import { useEffect, useState } from 'react' import Button from './Button' import Card from './Card' import LabeledSwitch from './LabeledSwitch' import GenerateAssetsTask from './GenerateAssetsTask' 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 } export default function RecorderSettings({ onAssetsGenerated }: Props) { const [value, setValue] = useState(DEFAULTS) const [saving, setSaving] = useState(false) const [cleaning, setCleaning] = useState(false) const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | null>(null) const [msg, setMsg] = useState(null) const [err, setErr] = useState(null) const [diskStatus, setDiskStatus] = useState(null) const 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) } } 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 setCleaning(true) 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() setMsg( `🧹 Aufräumen fertig:\n` + `• Gelöscht: ${data.deletedFiles} Datei(en) (${data.deletedBytesHuman})\n` + `• Geprüft: ${data.scannedFiles} · Übersprungen: ${data.skippedFiles} · Fehler: ${data.errorCount}\n` + `• Orphans: ${data.orphanIdsRemoved}/${data.orphanIdsScanned} entfernt (Previews/Thumbs/Generated)` ) } catch (e: any) { setErr(e?.message ?? String(e)) } finally { setCleaning(false) } } return (
Einstellungen
Recorder-Konfiguration, Automatisierung und Tasks.
} grayBody >
{/* Alerts */} {err && (
{err}
)} {msg && (
{msg}
)} {/* ✅ Tasks (als erstes) */}
Tasks
Generiere fehlende Vorschauen/Metadaten für schnelle Listenansichten.
Utilities
{/* Paths */}
Pfad-Einstellungen
Aufnahme- und Zielverzeichnisse sowie optionaler ffmpeg-Pfad.
{/* Aufnahme-Ordner */}
setValue((v) => ({ ...v, recordDir: e.target.value }))} placeholder="records (oder absolut: C:\records / /mnt/data/records)" className="min-w-0 flex-1 rounded-lg px-3 py-2 text-sm bg-white text-gray-900 ring-1 ring-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10" />
{/* Fertige Downloads */}
setValue((v) => ({ ...v, doneDir: e.target.value }))} placeholder="records/done" className="min-w-0 flex-1 rounded-lg px-3 py-2 text-sm bg-white text-gray-900 ring-1 ring-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10" />
{/* ffmpeg.exe */}
setValue((v) => ({ ...v, ffmpegPath: e.target.value }))} placeholder="Leer = automatisch (FFMPEG_PATH / ffmpeg im PATH)" className="min-w-0 flex-1 rounded-lg px-3 py-2 text-sm bg-white text-gray-900 ring-1 ring-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10" />
{/* Automatisierung */}
Automatisierung & Anzeige
Verhalten beim Hinzufügen/Starten sowie Anzeigeoptionen.
setValue((v) => ({ ...v, autoAddToDownloadList: checked, autoStartAddedDownloads: checked ? v.autoStartAddedDownloads : false, })) } label="Automatisch zur Downloadliste hinzufügen" description="Neue Links/Modelle werden automatisch in die Downloadliste übernommen." /> 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)." /> 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." /> 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 */}
setValue((v) => ({ ...v, autoDeleteSmallDownloads: checked, autoDeleteSmallDownloadsBelowMB: v.autoDeleteSmallDownloadsBelowMB ?? 50, })) } label="Kleine Downloads automatisch löschen" description="Löscht fertige Downloads automatisch, wenn die Datei kleiner als die eingestellte Mindestgröße ist." />
Mindestgröße
Alles darunter wird gelöscht.
setValue((v) => ({ ...v, autoDeleteSmallDownloadsBelowMB: Number(e.target.value || 0), })) } className="h-9 w-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" /> MB
setValue((v) => ({ ...v, blurPreviews: checked }))} label="Vorschaubilder blurren" description="Weichzeichnet Vorschaubilder/Teaser (praktisch auf mobilen Geräten oder im öffentlichen Umfeld)." />
Teaser abspielen
Standbild spart Leistung. „Bei Hover (Standard)“: Desktop spielt bei Hover ab, Mobile im Viewport. „Alle“ kann viel CPU ziehen.
setValue((v) => ({ ...v, teaserAudio: checked }))} label="Teaser mit Ton" description="Wenn aktiv, werden Vorschau/Teaser nicht stumm geschaltet." /> setValue((v) => ({ ...v, enableNotifications: checked }))} label="Benachrichtigungen" description="Wenn aktiv, zeigt das Frontend Toasts (z.B. wenn watched Models online/live gehen oder wenn ein queued Model wieder public wird)." />
Speicherplatz-Notbremse
Aktiviert automatisch Stop + Autostart-Block bei wenig freiem Speicher (Resume bei +3 GB).
{diskStatus?.emergency ? 'AKTIV' : 'OK'}
Schwelle:{' '} Pause unter {uiPauseGB} GB {' · '}Resume ab{' '} {uiResumeGB} {' '} GB
{diskStatus ? `Frei: ${diskStatus.freeBytesHuman}${diskStatus.recordPath ? ` (Pfad: ${diskStatus.recordPath})` : ''}` : 'Status wird geladen…'}
{diskStatus?.emergency && (
Notfallbremse greift: laufende Downloads werden gestoppt und Autostart bleibt gesperrt, bis wieder genug frei ist.
)}
) }