From 548919cde216877221d21420b647786bf8060af0 Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 28 Mar 2026 13:31:19 +0100 Subject: [PATCH] updated --- backend/record.go | 22 ++++ backend/recorder.go | 107 ++++++++++++++++-- backend/recorder_settings.json | 6 +- backend/routes.go | 49 ++++++++ backend/settings.go | 86 ++++++++++---- frontend/src/App.tsx | 6 + frontend/src/components/ui/Downloads.tsx | 38 ++++++- .../src/components/ui/RecorderSettings.tsx | 63 +++++++++++ 8 files changed, 346 insertions(+), 31 deletions(-) diff --git a/backend/record.go b/backend/record.go index efb2218..c5b4c7c 100644 --- a/backend/record.go +++ b/backend/record.go @@ -96,6 +96,28 @@ type bulkFilesRequest struct { 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) { b, err := json.Marshal(t) if err != nil { diff --git a/backend/recorder.go b/backend/recorder.go index 34c44ce..0aa2466 100644 --- a/backend/recorder.go +++ b/backend/recorder.go @@ -254,6 +254,48 @@ func recordPreviewSprite(w http.ResponseWriter, r *http.Request) { // ---------------- 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) { url := strings.TrimSpace(req.URL) if url == "" { @@ -261,19 +303,39 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) { } jobsMu.Lock() + defer jobsMu.Unlock() + + // Duplicate-Check for _, j := range jobs { if j != nil && j.Status == JobRunning && j.EndedAt == nil && strings.TrimSpace(j.SourceURL) == url { if j.Hidden && !req.Hidden { j.Hidden = false - jobsMu.Unlock() return j, nil } - - jobsMu.Unlock() 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() 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")) - s := getSettings() recordDirAbs, _ := resolvePathRelativeToApp(s.RecordDir) recordDir := strings.TrimSpace(recordDirAbs) if recordDir == "" { @@ -313,7 +374,6 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) { } jobs[jobID] = job - jobsMu.Unlock() go runJob(ctx, job, req) return job, nil @@ -608,15 +668,46 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { }) } + // 1) Remux // 1) Remux if strings.EqualFold(filepath.Ext(out), ".ts") { 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() - 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() + + 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 diff --git a/backend/recorder_settings.json b/backend/recorder_settings.json index b42d528..e1bdca9 100644 --- a/backend/recorder_settings.json +++ b/backend/recorder_settings.json @@ -1,11 +1,13 @@ { "databaseUrl": "postgres://postgres@127.0.0.1:5432/nsfwapp?sslmode=disable", "encryptedDbPassword": "vkNvHM+/AgEkRPW0X+rDDys4bPb5Q7ZPGqPSADCluIJ6GCDGNGlDy3s=", - "recordDir": "C:\\Users\\Rother\\Desktop\\Privat\\records", - "doneDir": "C:\\Users\\Rother\\Desktop\\Privat\\records\\done", + "recordDir": "C:\\Users\\Chris\\Desktop\\privat\\records", + "doneDir": "C:\\Users\\Chris\\Desktop\\privat\\records\\done", "ffmpegPath": "", "autoAddToDownloadList": true, "autoStartAddedDownloads": true, + "enableConcurrentDownloadsLimit": true, + "maxConcurrentDownloads": 40, "useChaturbateApi": true, "useMyFreeCamsWatcher": true, "autoDeleteSmallDownloads": false, diff --git a/backend/routes.go b/backend/routes.go index 5d918d4..e8d6abc 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -66,6 +66,7 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore { api.HandleFunc("/api/record/regenerate-assets", handleRegenerateAssets) api.HandleFunc("/api/record/delete-many", handleDeleteMany) api.HandleFunc("/api/record/keep-many", handleKeepMany) + api.HandleFunc("/api/record/concurrency", recordConcurrentDownloadsStatus) api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler) api.HandleFunc("/api/chaturbate/biocontext", chaturbateBioContextHandler) @@ -182,6 +183,54 @@ func buildPostgresDSNFromSettings() (string, error) { 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. func sanitizeDSNForLog(dsn string) string { dsn = strings.TrimSpace(dsn) diff --git a/backend/settings.go b/backend/settings.go index 54cb0f8..8d61cb4 100644 --- a/backend/settings.go +++ b/backend/settings.go @@ -22,8 +22,10 @@ type RecorderSettings struct { DoneDir string `json:"doneDir"` FFmpegPath string `json:"ffmpegPath"` - AutoAddToDownloadList bool `json:"autoAddToDownloadList"` - AutoStartAddedDownloads bool `json:"autoStartAddedDownloads"` + AutoAddToDownloadList bool `json:"autoAddToDownloadList"` + AutoStartAddedDownloads bool `json:"autoStartAddedDownloads"` + EnableConcurrentDownloadsLimit bool `json:"enableConcurrentDownloadsLimit"` + MaxConcurrentDownloads int `json:"maxConcurrentDownloads"` UseChaturbateAPI bool `json:"useChaturbateApi"` UseMyFreeCamsWatcher bool `json:"useMyFreeCamsWatcher"` @@ -52,8 +54,10 @@ var ( DoneDir: "/records/done", FFmpegPath: "", - AutoAddToDownloadList: false, - AutoStartAddedDownloads: false, + AutoAddToDownloadList: false, + AutoStartAddedDownloads: false, + EnableConcurrentDownloadsLimit: false, + MaxConcurrentDownloads: 20, UseChaturbateAPI: false, UseMyFreeCamsWatcher: false, @@ -205,8 +209,10 @@ type RecorderSettingsPublic struct { DatabaseURL string `json:"databaseUrl"` HasDBPassword bool `json:"hasDbPassword"` - AutoAddToDownloadList bool `json:"autoAddToDownloadList"` - AutoStartAddedDownloads bool `json:"autoStartAddedDownloads"` + AutoAddToDownloadList bool `json:"autoAddToDownloadList"` + AutoStartAddedDownloads bool `json:"autoStartAddedDownloads"` + EnableConcurrentDownloadsLimit bool `json:"enableConcurrentDownloadsLimit"` + MaxConcurrentDownloads int `json:"maxConcurrentDownloads"` UseChaturbateAPI bool `json:"useChaturbateApi"` UseMyFreeCamsWatcher bool `json:"useMyFreeCamsWatcher"` @@ -231,8 +237,10 @@ func toPublicSettings(s RecorderSettings) RecorderSettingsPublic { DatabaseURL: strings.TrimSpace(s.DatabaseURL), HasDBPassword: strings.TrimSpace(s.EncryptedDBPassword) != "", - AutoAddToDownloadList: s.AutoAddToDownloadList, - AutoStartAddedDownloads: s.AutoStartAddedDownloads, + AutoAddToDownloadList: s.AutoAddToDownloadList, + AutoStartAddedDownloads: s.AutoStartAddedDownloads, + EnableConcurrentDownloadsLimit: s.EnableConcurrentDownloadsLimit, + MaxConcurrentDownloads: s.MaxConcurrentDownloads, UseChaturbateAPI: s.UseChaturbateAPI, UseMyFreeCamsWatcher: s.UseMyFreeCamsWatcher, @@ -310,6 +318,12 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) { if 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) --- recAbs, err := resolvePathRelativeToApp(in.RecordDir) @@ -366,32 +380,64 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) { 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 := - strings.TrimSpace(in.DatabaseURL) != strings.TrimSpace(current.DatabaseURL) || - strings.TrimSpace(in.EncryptedDBPassword) != strings.TrimSpace(current.EncryptedDBPassword) + strings.TrimSpace(next.DatabaseURL) != strings.TrimSpace(current.DatabaseURL) || + strings.TrimSpace(next.EncryptedDBPassword) != strings.TrimSpace(current.EncryptedDBPassword) - // ✅ Settings im RAM aktualisieren - settingsMu.Lock() - settings = in.RecorderSettings - settingsMu.Unlock() + var newStore *ModelStore - // ✅ Settings auf Disk persistieren - saveSettingsToDisk() - - // ✅ Wenn DB geändert wurde: ModelStore sofort auf neue DB umstellen + // DB erst prüfen, BEVOR gespeichert wird if dbChanged { - dsn, err := buildPostgresDSNFromSettings() + dsn, err := buildPostgresDSNFromRecorderSettings(next) if err != nil { http.Error(w, "ungültige Datenbank-Konfiguration: "+err.Error(), http.StatusBadRequest) return } - newStore := NewModelStore(dsn) + newStore = NewModelStore(dsn) if err := newStore.Load(); err != nil { http.Error(w, "Datenbank-Verbindung fehlgeschlagen: "+err.Error(), http.StatusBadRequest) return } + } + // Erst jetzt wirklich übernehmen + settingsMu.Lock() + settings = next + settingsMu.Unlock() + + saveSettingsToDisk() + + if newStore != nil { setModelStore(newStore) setChaturbateOnlineModelStore(newStore) } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4e1da91..49e26d8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -57,6 +57,8 @@ type RecorderSettingsState = { ffmpegPath?: string autoAddToDownloadList?: boolean autoStartAddedDownloads?: boolean + enableConcurrentDownloadsLimit?: boolean + maxConcurrentDownloads?: number useChaturbateApi?: boolean useMyFreeCamsWatcher?: boolean autoDeleteSmallDownloads?: boolean @@ -126,6 +128,8 @@ const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = { ffmpegPath: '', autoAddToDownloadList: false, autoStartAddedDownloads: false, + enableConcurrentDownloadsLimit: false, + maxConcurrentDownloads: 20, useChaturbateApi: false, useMyFreeCamsWatcher: false, autoDeleteSmallDownloads: false, @@ -3562,6 +3566,8 @@ export default function App() { onAddToDownloads={handleAddToDownloads} onRemovePending={handleRemovePendingWatchedRoom} blurPreviews={Boolean(recSettings.blurPreviews)} + enableConcurrentDownloadsLimit={Boolean(recSettings.enableConcurrentDownloadsLimit)} + maxConcurrentDownloads={Number(recSettings.maxConcurrentDownloads ?? 20)} /> ) : null} diff --git a/frontend/src/components/ui/Downloads.tsx b/frontend/src/components/ui/Downloads.tsx index 64fd64d..f84b339 100644 --- a/frontend/src/components/ui/Downloads.tsx +++ b/frontend/src/components/ui/Downloads.tsx @@ -48,6 +48,9 @@ type Props = { onToggleWatch?: (job: RecordJob) => void | Promise onAddToDownloads?: (job: RecordJob) => boolean | void | Promise onRemovePending?: (pending: PendingWatchedRoom) => void | Promise + + enableConcurrentDownloadsLimit?: boolean + maxConcurrentDownloads?: number } const pendingModelName = (p: PendingWatchedRoom) => { @@ -565,6 +568,8 @@ export default function Downloads({ modelsByKey = {}, roomStatusByModelKey = {}, blurPreviews, + enableConcurrentDownloadsLimit = false, + maxConcurrentDownloads = 20, }: Props) { const isDesktop = useMediaQuery('(min-width: 640px)', true) @@ -764,6 +769,18 @@ export default function Downloads({ return jobs.some((j) => !j.endedAt && j.status === 'running') }, [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(() => { setGrowingByJobId((prev) => { const next: Record = { ...prev } @@ -1381,7 +1398,26 @@ export default function Downloads({ -
+
+ {concurrentLimitHintVisible ? ( +
+ Downloads: {activeDownloadCount}/{maxConcurrentDownloads} + {concurrentLimitReached ? ' · Limit erreicht' : ''} +
+ ) : null} +
+
+ + 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." + /> + +
+
+
Max. parallele Downloads
+
+ Mehr gleichzeitige Starts werden nicht zugelassen. +
+
+ +
+
+ + 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" + /> +
+
+
+
+