This commit is contained in:
Chris 2026-03-28 13:31:19 +01:00
parent c5739f7d44
commit 548919cde2
8 changed files with 346 additions and 31 deletions

View File

@ -96,6 +96,28 @@ type bulkFilesRequest struct {
Files []string `json:"files"` Files []string `json:"files"`
} }
type concurrentDownloadsStatusResp struct {
Enabled bool `json:"enabled"`
Max int `json:"max"`
Active int `json:"active"`
Reached bool `json:"reached"`
}
func recordConcurrentDownloadsStatus(w http.ResponseWriter, r *http.Request) {
if !mustMethod(w, r, http.MethodGet) {
return
}
enabled, max, active, reached := concurrentDownloadsLimitState()
respondJSON(w, concurrentDownloadsStatusResp{
Enabled: enabled,
Max: max,
Active: active,
Reached: reached,
})
}
func encodeUndoDeleteToken(t undoDeleteToken) (string, error) { func encodeUndoDeleteToken(t undoDeleteToken) (string, error) {
b, err := json.Marshal(t) b, err := json.Marshal(t)
if err != nil { if err != nil {

View File

@ -254,6 +254,48 @@ func recordPreviewSprite(w http.ResponseWriter, r *http.Request) {
// ---------------- Start + run job ---------------- // ---------------- Start + run job ----------------
func isActiveRecordingJob(job *RecordJob) bool {
if job == nil {
return false
}
if job.EndedAt != nil {
return false
}
if job.Status != JobRunning {
return false
}
phase := strings.ToLower(strings.TrimSpace(job.Phase))
return phase == "" || phase == "recording"
}
func activeRecordingJobsCount() int {
jobsMu.Lock()
defer jobsMu.Unlock()
n := 0
for _, j := range jobs {
if isActiveRecordingJob(j) {
n++
}
}
return n
}
func concurrentDownloadsLimitState() (enabled bool, max int, active int, reached bool) {
s := getSettings()
enabled = s.EnableConcurrentDownloadsLimit
max = s.MaxConcurrentDownloads
if max < 1 {
max = 1
}
active = activeRecordingJobsCount()
reached = enabled && active >= max
return
}
func startRecordingInternal(req RecordRequest) (*RecordJob, error) { func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
url := strings.TrimSpace(req.URL) url := strings.TrimSpace(req.URL)
if url == "" { if url == "" {
@ -261,19 +303,39 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
} }
jobsMu.Lock() jobsMu.Lock()
defer jobsMu.Unlock()
// Duplicate-Check
for _, j := range jobs { for _, j := range jobs {
if j != nil && j.Status == JobRunning && j.EndedAt == nil && strings.TrimSpace(j.SourceURL) == url { if j != nil && j.Status == JobRunning && j.EndedAt == nil && strings.TrimSpace(j.SourceURL) == url {
if j.Hidden && !req.Hidden { if j.Hidden && !req.Hidden {
j.Hidden = false j.Hidden = false
jobsMu.Unlock()
return j, nil return j, nil
} }
jobsMu.Unlock()
return j, nil return j, nil
} }
} }
// Limit-Check ATOMAR unter jobsMu
s := getSettings()
if s.EnableConcurrentDownloadsLimit {
max := s.MaxConcurrentDownloads
if max < 1 {
max = 1
}
active := 0
for _, j := range jobs {
if isActiveRecordingJob(j) {
active++
}
}
if active >= max {
return nil, fmt.Errorf("Download-Limit erreicht (%d/%d aktiv)", active, max)
}
}
startedAt := time.Now() startedAt := time.Now()
provider := detectProvider(url) provider := detectProvider(url)
@ -290,7 +352,6 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
filename := fmt.Sprintf("%s_%s.ts", username, startedAt.Format("01_02_2006__15-04-05")) filename := fmt.Sprintf("%s_%s.ts", username, startedAt.Format("01_02_2006__15-04-05"))
s := getSettings()
recordDirAbs, _ := resolvePathRelativeToApp(s.RecordDir) recordDirAbs, _ := resolvePathRelativeToApp(s.RecordDir)
recordDir := strings.TrimSpace(recordDirAbs) recordDir := strings.TrimSpace(recordDirAbs)
if recordDir == "" { if recordDir == "" {
@ -313,7 +374,6 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
} }
jobs[jobID] = job jobs[jobID] = job
jobsMu.Unlock()
go runJob(ctx, job, req) go runJob(ctx, job, req)
return job, nil return job, nil
@ -608,15 +668,46 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
}) })
} }
// 1) Remux
// 1) Remux // 1) Remux
if strings.EqualFold(filepath.Ext(out), ".ts") { if strings.EqualFold(filepath.Ext(out), ".ts") {
setPhase("remuxing", 10) setPhase("remuxing", 10)
if newOut, err2 := maybeRemuxTSForJob(job, out); err2 == nil && strings.TrimSpace(newOut) != "" {
out = strings.TrimSpace(newOut) newOut, err2 := maybeRemuxTSForJob(job, out)
if err2 != nil || strings.TrimSpace(newOut) == "" {
finalFile := filepath.Base(out)
finalAssetID := assetIDFromVideoPath(out)
jobsMu.Lock() jobsMu.Lock()
job.Output = out job.Status = JobFailed
job.Error = "TS->MP4 Remux fehlgeschlagen"
if err2 != nil {
job.Error = "TS->MP4 Remux fehlgeschlagen: " + err2.Error()
}
job.Phase = ""
job.Progress = 100
job.PostWorkKey = ""
job.PostWork = nil
jobsMu.Unlock() jobsMu.Unlock()
publishJobRemove(job)
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
File: finalFile,
AssetID: finalAssetID,
Queue: "postwork",
State: "error",
Phase: "remuxing",
Label: "Remux fehlgeschlagen",
})
return nil
} }
out = strings.TrimSpace(newOut)
jobsMu.Lock()
job.Output = out
jobsMu.Unlock()
} }
// 2) Move to done // 2) Move to done

View File

@ -1,11 +1,13 @@
{ {
"databaseUrl": "postgres://postgres@127.0.0.1:5432/nsfwapp?sslmode=disable", "databaseUrl": "postgres://postgres@127.0.0.1:5432/nsfwapp?sslmode=disable",
"encryptedDbPassword": "vkNvHM+/AgEkRPW0X+rDDys4bPb5Q7ZPGqPSADCluIJ6GCDGNGlDy3s=", "encryptedDbPassword": "vkNvHM+/AgEkRPW0X+rDDys4bPb5Q7ZPGqPSADCluIJ6GCDGNGlDy3s=",
"recordDir": "C:\\Users\\Rother\\Desktop\\Privat\\records", "recordDir": "C:\\Users\\Chris\\Desktop\\privat\\records",
"doneDir": "C:\\Users\\Rother\\Desktop\\Privat\\records\\done", "doneDir": "C:\\Users\\Chris\\Desktop\\privat\\records\\done",
"ffmpegPath": "", "ffmpegPath": "",
"autoAddToDownloadList": true, "autoAddToDownloadList": true,
"autoStartAddedDownloads": true, "autoStartAddedDownloads": true,
"enableConcurrentDownloadsLimit": true,
"maxConcurrentDownloads": 40,
"useChaturbateApi": true, "useChaturbateApi": true,
"useMyFreeCamsWatcher": true, "useMyFreeCamsWatcher": true,
"autoDeleteSmallDownloads": false, "autoDeleteSmallDownloads": false,

View File

@ -66,6 +66,7 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
api.HandleFunc("/api/record/regenerate-assets", handleRegenerateAssets) api.HandleFunc("/api/record/regenerate-assets", handleRegenerateAssets)
api.HandleFunc("/api/record/delete-many", handleDeleteMany) api.HandleFunc("/api/record/delete-many", handleDeleteMany)
api.HandleFunc("/api/record/keep-many", handleKeepMany) api.HandleFunc("/api/record/keep-many", handleKeepMany)
api.HandleFunc("/api/record/concurrency", recordConcurrentDownloadsStatus)
api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler) api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler)
api.HandleFunc("/api/chaturbate/biocontext", chaturbateBioContextHandler) api.HandleFunc("/api/chaturbate/biocontext", chaturbateBioContextHandler)
@ -182,6 +183,54 @@ func buildPostgresDSNFromSettings() (string, error) {
return u.String(), nil return u.String(), nil
} }
func buildPostgresDSNFromRecorderSettings(s RecorderSettings) (string, error) {
dbURL := strings.TrimSpace(s.DatabaseURL)
if dbURL == "" {
return "", fmt.Errorf("databaseUrl ist leer")
}
u, err := url.Parse(dbURL)
if err != nil {
return "", fmt.Errorf("databaseUrl ungültig: %w", err)
}
// Wenn URL bereits ein echtes Passwort enthält, direkt verwenden
if u.User != nil {
if pw, hasPw := u.User.Password(); hasPw {
pw = strings.TrimSpace(pw)
if pw != "" && pw != "****" {
return u.String(), nil
}
}
}
enc := strings.TrimSpace(s.EncryptedDBPassword)
if enc == "" {
// URL ohne Passwort ist ok
return u.String(), nil
}
plainPw, err := decryptSettingString(enc)
if err != nil {
return "", fmt.Errorf("db password decrypt failed: %w", err)
}
plainPw = strings.TrimSpace(plainPw)
if plainPw == "" {
return u.String(), nil
}
user := ""
if u.User != nil {
user = u.User.Username()
}
if strings.TrimSpace(user) == "" {
return "", fmt.Errorf("databaseUrl enthält keinen Username, kann Passwort nicht einsetzen")
}
u.User = url.UserPassword(user, plainPw)
return u.String(), nil
}
// sanitizeDSNForLog entfernt Passwort aus DSN, damit du es gefahrlos loggen kannst. // sanitizeDSNForLog entfernt Passwort aus DSN, damit du es gefahrlos loggen kannst.
func sanitizeDSNForLog(dsn string) string { func sanitizeDSNForLog(dsn string) string {
dsn = strings.TrimSpace(dsn) dsn = strings.TrimSpace(dsn)

View File

@ -22,8 +22,10 @@ type RecorderSettings struct {
DoneDir string `json:"doneDir"` DoneDir string `json:"doneDir"`
FFmpegPath string `json:"ffmpegPath"` FFmpegPath string `json:"ffmpegPath"`
AutoAddToDownloadList bool `json:"autoAddToDownloadList"` AutoAddToDownloadList bool `json:"autoAddToDownloadList"`
AutoStartAddedDownloads bool `json:"autoStartAddedDownloads"` AutoStartAddedDownloads bool `json:"autoStartAddedDownloads"`
EnableConcurrentDownloadsLimit bool `json:"enableConcurrentDownloadsLimit"`
MaxConcurrentDownloads int `json:"maxConcurrentDownloads"`
UseChaturbateAPI bool `json:"useChaturbateApi"` UseChaturbateAPI bool `json:"useChaturbateApi"`
UseMyFreeCamsWatcher bool `json:"useMyFreeCamsWatcher"` UseMyFreeCamsWatcher bool `json:"useMyFreeCamsWatcher"`
@ -52,8 +54,10 @@ var (
DoneDir: "/records/done", DoneDir: "/records/done",
FFmpegPath: "", FFmpegPath: "",
AutoAddToDownloadList: false, AutoAddToDownloadList: false,
AutoStartAddedDownloads: false, AutoStartAddedDownloads: false,
EnableConcurrentDownloadsLimit: false,
MaxConcurrentDownloads: 20,
UseChaturbateAPI: false, UseChaturbateAPI: false,
UseMyFreeCamsWatcher: false, UseMyFreeCamsWatcher: false,
@ -205,8 +209,10 @@ type RecorderSettingsPublic struct {
DatabaseURL string `json:"databaseUrl"` DatabaseURL string `json:"databaseUrl"`
HasDBPassword bool `json:"hasDbPassword"` HasDBPassword bool `json:"hasDbPassword"`
AutoAddToDownloadList bool `json:"autoAddToDownloadList"` AutoAddToDownloadList bool `json:"autoAddToDownloadList"`
AutoStartAddedDownloads bool `json:"autoStartAddedDownloads"` AutoStartAddedDownloads bool `json:"autoStartAddedDownloads"`
EnableConcurrentDownloadsLimit bool `json:"enableConcurrentDownloadsLimit"`
MaxConcurrentDownloads int `json:"maxConcurrentDownloads"`
UseChaturbateAPI bool `json:"useChaturbateApi"` UseChaturbateAPI bool `json:"useChaturbateApi"`
UseMyFreeCamsWatcher bool `json:"useMyFreeCamsWatcher"` UseMyFreeCamsWatcher bool `json:"useMyFreeCamsWatcher"`
@ -231,8 +237,10 @@ func toPublicSettings(s RecorderSettings) RecorderSettingsPublic {
DatabaseURL: strings.TrimSpace(s.DatabaseURL), DatabaseURL: strings.TrimSpace(s.DatabaseURL),
HasDBPassword: strings.TrimSpace(s.EncryptedDBPassword) != "", HasDBPassword: strings.TrimSpace(s.EncryptedDBPassword) != "",
AutoAddToDownloadList: s.AutoAddToDownloadList, AutoAddToDownloadList: s.AutoAddToDownloadList,
AutoStartAddedDownloads: s.AutoStartAddedDownloads, AutoStartAddedDownloads: s.AutoStartAddedDownloads,
EnableConcurrentDownloadsLimit: s.EnableConcurrentDownloadsLimit,
MaxConcurrentDownloads: s.MaxConcurrentDownloads,
UseChaturbateAPI: s.UseChaturbateAPI, UseChaturbateAPI: s.UseChaturbateAPI,
UseMyFreeCamsWatcher: s.UseMyFreeCamsWatcher, UseMyFreeCamsWatcher: s.UseMyFreeCamsWatcher,
@ -310,6 +318,12 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
if in.LowDiskPauseBelowGB > 10_000 { if in.LowDiskPauseBelowGB > 10_000 {
in.LowDiskPauseBelowGB = 10_000 in.LowDiskPauseBelowGB = 10_000
} }
if in.MaxConcurrentDownloads < 1 {
in.MaxConcurrentDownloads = 1
}
if in.MaxConcurrentDownloads > 100 {
in.MaxConcurrentDownloads = 100
}
// --- ensure folders (Fehler zurückgeben, falls z.B. keine Rechte) --- // --- ensure folders (Fehler zurückgeben, falls z.B. keine Rechte) ---
recAbs, err := resolvePathRelativeToApp(in.RecordDir) recAbs, err := resolvePathRelativeToApp(in.RecordDir)
@ -366,32 +380,64 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
in.EncryptedDBPassword = current.EncryptedDBPassword in.EncryptedDBPassword = current.EncryptedDBPassword
} }
// Nicht komplette Settings stumpf überschreiben,
// sondern bestehende Settings nehmen und gezielt aktualisieren.
next := current
next.RecordDir = in.RecordDir
next.DoneDir = in.DoneDir
next.FFmpegPath = in.FFmpegPath
next.DatabaseURL = in.DatabaseURL
next.EncryptedDBPassword = in.EncryptedDBPassword
next.AutoAddToDownloadList = in.AutoAddToDownloadList
next.AutoStartAddedDownloads = in.AutoStartAddedDownloads
next.EnableConcurrentDownloadsLimit = in.EnableConcurrentDownloadsLimit
next.MaxConcurrentDownloads = in.MaxConcurrentDownloads
next.UseChaturbateAPI = in.UseChaturbateAPI
next.UseMyFreeCamsWatcher = in.UseMyFreeCamsWatcher
next.AutoDeleteSmallDownloads = in.AutoDeleteSmallDownloads
next.AutoDeleteSmallDownloadsBelowMB = in.AutoDeleteSmallDownloadsBelowMB
next.LowDiskPauseBelowGB = in.LowDiskPauseBelowGB
next.BlurPreviews = in.BlurPreviews
next.TeaserPlayback = in.TeaserPlayback
next.TeaserAudio = in.TeaserAudio
next.EnableNotifications = in.EnableNotifications
dbChanged := dbChanged :=
strings.TrimSpace(in.DatabaseURL) != strings.TrimSpace(current.DatabaseURL) || strings.TrimSpace(next.DatabaseURL) != strings.TrimSpace(current.DatabaseURL) ||
strings.TrimSpace(in.EncryptedDBPassword) != strings.TrimSpace(current.EncryptedDBPassword) strings.TrimSpace(next.EncryptedDBPassword) != strings.TrimSpace(current.EncryptedDBPassword)
// ✅ Settings im RAM aktualisieren var newStore *ModelStore
settingsMu.Lock()
settings = in.RecorderSettings
settingsMu.Unlock()
// ✅ Settings auf Disk persistieren // DB erst prüfen, BEVOR gespeichert wird
saveSettingsToDisk()
// ✅ Wenn DB geändert wurde: ModelStore sofort auf neue DB umstellen
if dbChanged { if dbChanged {
dsn, err := buildPostgresDSNFromSettings() dsn, err := buildPostgresDSNFromRecorderSettings(next)
if err != nil { if err != nil {
http.Error(w, "ungültige Datenbank-Konfiguration: "+err.Error(), http.StatusBadRequest) http.Error(w, "ungültige Datenbank-Konfiguration: "+err.Error(), http.StatusBadRequest)
return return
} }
newStore := NewModelStore(dsn) newStore = NewModelStore(dsn)
if err := newStore.Load(); err != nil { if err := newStore.Load(); err != nil {
http.Error(w, "Datenbank-Verbindung fehlgeschlagen: "+err.Error(), http.StatusBadRequest) http.Error(w, "Datenbank-Verbindung fehlgeschlagen: "+err.Error(), http.StatusBadRequest)
return return
} }
}
// Erst jetzt wirklich übernehmen
settingsMu.Lock()
settings = next
settingsMu.Unlock()
saveSettingsToDisk()
if newStore != nil {
setModelStore(newStore) setModelStore(newStore)
setChaturbateOnlineModelStore(newStore) setChaturbateOnlineModelStore(newStore)
} }

View File

@ -57,6 +57,8 @@ type RecorderSettingsState = {
ffmpegPath?: string ffmpegPath?: string
autoAddToDownloadList?: boolean autoAddToDownloadList?: boolean
autoStartAddedDownloads?: boolean autoStartAddedDownloads?: boolean
enableConcurrentDownloadsLimit?: boolean
maxConcurrentDownloads?: number
useChaturbateApi?: boolean useChaturbateApi?: boolean
useMyFreeCamsWatcher?: boolean useMyFreeCamsWatcher?: boolean
autoDeleteSmallDownloads?: boolean autoDeleteSmallDownloads?: boolean
@ -126,6 +128,8 @@ const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = {
ffmpegPath: '', ffmpegPath: '',
autoAddToDownloadList: false, autoAddToDownloadList: false,
autoStartAddedDownloads: false, autoStartAddedDownloads: false,
enableConcurrentDownloadsLimit: false,
maxConcurrentDownloads: 20,
useChaturbateApi: false, useChaturbateApi: false,
useMyFreeCamsWatcher: false, useMyFreeCamsWatcher: false,
autoDeleteSmallDownloads: false, autoDeleteSmallDownloads: false,
@ -3562,6 +3566,8 @@ export default function App() {
onAddToDownloads={handleAddToDownloads} onAddToDownloads={handleAddToDownloads}
onRemovePending={handleRemovePendingWatchedRoom} onRemovePending={handleRemovePendingWatchedRoom}
blurPreviews={Boolean(recSettings.blurPreviews)} blurPreviews={Boolean(recSettings.blurPreviews)}
enableConcurrentDownloadsLimit={Boolean(recSettings.enableConcurrentDownloadsLimit)}
maxConcurrentDownloads={Number(recSettings.maxConcurrentDownloads ?? 20)}
/> />
) : null} ) : null}

View File

@ -48,6 +48,9 @@ type Props = {
onToggleWatch?: (job: RecordJob) => void | Promise<void> onToggleWatch?: (job: RecordJob) => void | Promise<void>
onAddToDownloads?: (job: RecordJob) => boolean | void | Promise<boolean | void> onAddToDownloads?: (job: RecordJob) => boolean | void | Promise<boolean | void>
onRemovePending?: (pending: PendingWatchedRoom) => void | Promise<void> onRemovePending?: (pending: PendingWatchedRoom) => void | Promise<void>
enableConcurrentDownloadsLimit?: boolean
maxConcurrentDownloads?: number
} }
const pendingModelName = (p: PendingWatchedRoom) => { const pendingModelName = (p: PendingWatchedRoom) => {
@ -565,6 +568,8 @@ export default function Downloads({
modelsByKey = {}, modelsByKey = {},
roomStatusByModelKey = {}, roomStatusByModelKey = {},
blurPreviews, blurPreviews,
enableConcurrentDownloadsLimit = false,
maxConcurrentDownloads = 20,
}: Props) { }: Props) {
const isDesktop = useMediaQuery('(min-width: 640px)', true) const isDesktop = useMediaQuery('(min-width: 640px)', true)
@ -764,6 +769,18 @@ export default function Downloads({
return jobs.some((j) => !j.endedAt && j.status === 'running') return jobs.some((j) => !j.endedAt && j.status === 'running')
}, [jobs]) }, [jobs])
const activeDownloadCount = useMemo(() => {
return jobs.filter((j) => {
if (isPostworkJob(j)) return false
if ((j as any).endedAt) return false
return String(j.status ?? '').trim().toLowerCase() === 'running'
}).length
}, [jobs])
const concurrentLimitHintVisible = enableConcurrentDownloadsLimit
const concurrentLimitReached =
enableConcurrentDownloadsLimit && activeDownloadCount >= maxConcurrentDownloads
useEffect(() => { useEffect(() => {
setGrowingByJobId((prev) => { setGrowingByJobId((prev) => {
const next: Record<string, boolean> = { ...prev } const next: Record<string, boolean> = { ...prev }
@ -1381,7 +1398,26 @@ export default function Downloads({
</span> </span>
</div> </div>
<div className="shrink-0 flex items-center gap-2"> <div className="shrink-0 flex items-center gap-2 min-w-0">
{concurrentLimitHintVisible ? (
<div
className={[
'flex items-center rounded-full px-2.5 py-1 text-xs font-medium ring-1 whitespace-nowrap',
concurrentLimitReached
? 'bg-amber-50 text-amber-900 ring-amber-200 dark:bg-amber-400/10 dark:text-amber-100 dark:ring-amber-400/20'
: 'bg-blue-50 text-blue-900 ring-blue-200 dark:bg-blue-400/10 dark:text-blue-100 dark:ring-blue-400/20',
].join(' ')}
title={
concurrentLimitReached
? 'Maximale Anzahl gleichzeitiger Downloads erreicht'
: 'Begrenzung für gleichzeitige Downloads ist aktiv'
}
>
Downloads: {activeDownloadCount}/{maxConcurrentDownloads}
{concurrentLimitReached ? ' · Limit erreicht' : ''}
</div>
) : null}
<Button <Button
size="sm" size="sm"
variant={watchedPaused ? 'secondary' : 'primary'} variant={watchedPaused ? 'secondary' : 'primary'}

View File

@ -19,6 +19,8 @@ type RecorderSettings = {
ffmpegPath?: string ffmpegPath?: string
autoAddToDownloadList?: boolean autoAddToDownloadList?: boolean
autoStartAddedDownloads?: boolean autoStartAddedDownloads?: boolean
enableConcurrentDownloadsLimit?: boolean
maxConcurrentDownloads?: number
useChaturbateApi?: boolean useChaturbateApi?: boolean
useMyFreeCamsWatcher?: boolean useMyFreeCamsWatcher?: boolean
autoDeleteSmallDownloads?: boolean autoDeleteSmallDownloads?: boolean
@ -47,6 +49,8 @@ const DEFAULTS: RecorderSettings = {
doneDir: 'records/done', doneDir: 'records/done',
ffmpegPath: '', ffmpegPath: '',
autoAddToDownloadList: true, autoAddToDownloadList: true,
enableConcurrentDownloadsLimit: false,
maxConcurrentDownloads: 20,
useChaturbateApi: false, useChaturbateApi: false,
useMyFreeCamsWatcher: false, useMyFreeCamsWatcher: false,
autoDeleteSmallDownloads: true, autoDeleteSmallDownloads: true,
@ -185,6 +189,10 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
// ✅ falls backend die Felder noch nicht hat -> defaults nutzen // ✅ falls backend die Felder noch nicht hat -> defaults nutzen
autoAddToDownloadList: data.autoAddToDownloadList ?? DEFAULTS.autoAddToDownloadList, autoAddToDownloadList: data.autoAddToDownloadList ?? DEFAULTS.autoAddToDownloadList,
autoStartAddedDownloads: data.autoStartAddedDownloads ?? DEFAULTS.autoStartAddedDownloads, autoStartAddedDownloads: data.autoStartAddedDownloads ?? DEFAULTS.autoStartAddedDownloads,
enableConcurrentDownloadsLimit:
(data as any).enableConcurrentDownloadsLimit ?? DEFAULTS.enableConcurrentDownloadsLimit,
maxConcurrentDownloads:
(data as any).maxConcurrentDownloads ?? DEFAULTS.maxConcurrentDownloads,
useChaturbateApi: data.useChaturbateApi ?? DEFAULTS.useChaturbateApi, useChaturbateApi: data.useChaturbateApi ?? DEFAULTS.useChaturbateApi,
useMyFreeCamsWatcher: data.useMyFreeCamsWatcher ?? DEFAULTS.useMyFreeCamsWatcher, useMyFreeCamsWatcher: data.useMyFreeCamsWatcher ?? DEFAULTS.useMyFreeCamsWatcher,
@ -515,6 +523,11 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
// ✅ Switch-Logik: Autostart nur sinnvoll, wenn Auto-Add aktiv ist // ✅ Switch-Logik: Autostart nur sinnvoll, wenn Auto-Add aktiv ist
const autoAddToDownloadList = !!value.autoAddToDownloadList const autoAddToDownloadList = !!value.autoAddToDownloadList
const autoStartAddedDownloads = autoAddToDownloadList ? !!value.autoStartAddedDownloads : false const autoStartAddedDownloads = autoAddToDownloadList ? !!value.autoStartAddedDownloads : false
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 useChaturbateApi = !!value.useChaturbateApi const useChaturbateApi = !!value.useChaturbateApi
const useMyFreeCamsWatcher = !!value.useMyFreeCamsWatcher const useMyFreeCamsWatcher = !!value.useMyFreeCamsWatcher
@ -545,6 +558,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
ffmpegPath, ffmpegPath,
autoAddToDownloadList, autoAddToDownloadList,
autoStartAddedDownloads, autoStartAddedDownloads,
enableConcurrentDownloadsLimit,
maxConcurrentDownloads,
useChaturbateApi, useChaturbateApi,
useMyFreeCamsWatcher, useMyFreeCamsWatcher,
autoDeleteSmallDownloads, autoDeleteSmallDownloads,
@ -1041,6 +1056,54 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
</div> </div>
</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.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"> <div className="space-y-3">
<LabeledSwitch <LabeledSwitch
checked={!!value.autoAddToDownloadList} checked={!!value.autoAddToDownloadList}