diff --git a/backend/__pycache__/ai_server.cpython-314.pyc b/backend/__pycache__/ai_server.cpython-314.pyc index ab1fdea..7369f49 100644 Binary files a/backend/__pycache__/ai_server.cpython-314.pyc and b/backend/__pycache__/ai_server.cpython-314.pyc differ diff --git a/backend/ai_server.py b/backend/ai_server.py index 2399a0c..1e27733 100644 --- a/backend/ai_server.py +++ b/backend/ai_server.py @@ -456,4 +456,33 @@ def health(): "classes": list(names.values())[:80] if isinstance(names, dict) else names, "labelConfig": str(DETECTION_LABELS_PATH) if DETECTION_LABELS_PATH else "", "labelError": _LABEL_ERROR, + } + +@app.post("/reload", dependencies=[Depends(require_ai_server_auth)]) +def reload_model(): + global model + global _MODEL_PATH + global _MODEL_ERROR + global DETECTION_LABELS_PATH + + model = None + _MODEL_PATH = "" + _MODEL_ERROR = "" + DETECTION_LABELS_PATH = None + + current_model = get_model() + names = getattr(current_model, "names", {}) or {} if current_model is not None else {} + + return { + "ok": current_model is not None, + "ready": current_model is not None, + "modelAvailable": current_model is not None, + "model": _MODEL_PATH, + "modelError": _MODEL_ERROR, + "expectedModel": str(DEFAULT_MODEL_PATH), + "trainingRoot": str(TRAINING_ROOT), + "classCount": len(names), + "classes": list(names.values())[:80] if isinstance(names, dict) else names, + "labelConfig": str(DETECTION_LABELS_PATH) if DETECTION_LABELS_PATH else "", + "labelError": _LABEL_ERROR, } \ No newline at end of file diff --git a/backend/autostart_pause.go b/backend/autostart_pause.go index 1edea5f..dfe830f 100644 --- a/backend/autostart_pause.go +++ b/backend/autostart_pause.go @@ -31,7 +31,15 @@ var autostartSubs = map[chan autostartStatePayload]struct{}{} func isAutostartFeatureEnabled() bool { s := getSettings() - return s.UseChaturbateAPI || s.UseMyFreeCamsWatcher + if !s.AutoStartAddedDownloads { + return false + } + + if !s.UseProviderAPIs { + return false + } + + return s.UseChaturbateAPI || s.UseMyFreeCamsAPI } func broadcastAutostartPaused() { @@ -239,9 +247,12 @@ func autostartStateStreamHandler(w http.ResponseWriter, r *http.Request) { send := func(state autostartStatePayload) { payload := map[string]any{ + "enabled": state.Enabled, + "active": state.Active, "paused": state.Paused, "pausedByUser": state.PausedByUser, "pausedByDisk": state.PausedByDisk, + "reason": state.Reason, "ts": time.Now().UTC().Format(time.RFC3339Nano), } diff --git a/backend/chaturbate_autostart.go b/backend/chaturbate_autostart.go index ecdf1e7..d345348 100644 --- a/backend/chaturbate_autostart.go +++ b/backend/chaturbate_autostart.go @@ -342,10 +342,10 @@ func startChaturbateAutoStartWorker(store *ModelStore) { for { select { case <-scanTicker.C: - autostartPaused := isAutostartPaused() - s := getSettings() - if !s.UseChaturbateAPI { + autostartPaused := isAutostartPaused() || !s.AutoStartAddedDownloads + + if !s.UseProviderAPIs || !s.UseChaturbateAPI { queue = queue[:0] queued = map[string]bool{} lastCookieHdr = "" @@ -853,7 +853,7 @@ func startChaturbateAutoStartWorker(store *ModelStore) { case <-startTicker.C: s := getSettings() - if !s.UseChaturbateAPI { + if !s.UseProviderAPIs || !s.UseChaturbateAPI || !s.AutoStartAddedDownloads || isAutostartPaused() { continue } diff --git a/backend/chaturbate_online.go b/backend/chaturbate_online.go index a0543df..c1a0db3 100644 --- a/backend/chaturbate_online.go +++ b/backend/chaturbate_online.go @@ -564,7 +564,7 @@ func startChaturbateOnlinePoller(store *ModelStore) { var tagsLast time.Time refreshOnce := func(trigger string) { - if !getSettings().UseChaturbateAPI { + if !chaturbateProviderAPIEnabled() { return } diff --git a/backend/models_api.go b/backend/models_api.go index 68b2205..9fc0982 100644 --- a/backend/models_api.go +++ b/backend/models_api.go @@ -32,6 +32,7 @@ func setModelStore(store *ModelStore) { modelStoreRef = store setCoverModelStore(store) setChaturbateOnlineModelStore(store) + setMyFreeCamsOnlineModelStore(store) } // ✅ umbenannt, damit es nicht mit models.go kollidiert @@ -359,7 +360,6 @@ func importModelsCSV(store *ModelStore, r io.Reader, kind string) (importResult, func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) { setModelStore(store) - // ✅ NEU: Parse-Endpoint (nur URL erlaubt) mux.HandleFunc("/api/models/parse", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) diff --git a/backend/myfreecams_autostart.go b/backend/myfreecams_autostart.go index e6edd17..604da0c 100644 --- a/backend/myfreecams_autostart.go +++ b/backend/myfreecams_autostart.go @@ -116,12 +116,8 @@ func startMyFreeCamsAutoStartWorker(store *ModelStore) { for { select { case <-scanTicker.C: - if isAutostartPaused() { - continue - } - s := getSettings() - if !s.UseMyFreeCamsWatcher { + if !s.UseProviderAPIs || !s.UseMyFreeCamsAPI || !s.AutoStartAddedDownloads || isAutostartPaused() { queue = queue[:0] queued = map[string]bool{} @@ -394,10 +390,8 @@ func startMyFreeCamsAutoStartWorker(store *ModelStore) { pendingAutoStartMu.Unlock() case <-startTicker.C: - if isAutostartPaused() { - continue - } - if !getSettings().UseMyFreeCamsWatcher { + s := getSettings() + if !s.UseProviderAPIs || !s.UseMyFreeCamsAPI || !s.AutoStartAddedDownloads || isAutostartPaused() { continue } if len(queue) == 0 { diff --git a/backend/myfreecams_biocontext.go b/backend/myfreecams_biocontext.go new file mode 100644 index 0000000..8c9d50f --- /dev/null +++ b/backend/myfreecams_biocontext.go @@ -0,0 +1,680 @@ +// backend\myfreecams_biocontext.go + +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const myfreecamsBioContextURLFmt = "https://api-edge.myfreecams.com/usernameLookup/%s" + +// getrennt von Chaturbate, damit die Datei unabhängig bleibt +const myfreecamsBioCacheMaxAge = 10 * time.Minute + +type MyFreeCamsBioContextResp struct { + Enabled bool `json:"enabled"` + FetchedAt time.Time `json:"fetchedAt"` + LastError string `json:"lastError,omitempty"` + Model string `json:"model"` + Bio any `json:"bio,omitempty"` + + RoomStatus string `json:"roomStatus,omitempty"` // public/private/offline/unknown + IsOnline bool `json:"isOnline,omitempty"` + ChatRoomURL string `json:"chatRoomUrl,omitempty"` + ImageURL string `json:"imageUrl,omitempty"` + + AvatarURL string `json:"avatarUrl,omitempty"` + SnapURL string `json:"snapUrl,omitempty"` + RoomTopic string `json:"roomTopic,omitempty"` + Country string `json:"country,omitempty"` + Gender string `json:"gender,omitempty"` +} + +type mfcBioLookupResp struct { + Result struct { + Success int `json:"success"` + Message string `json:"message"` + + User struct { + ID int `json:"id"` + UserID int `json:"user_id"` + Username string `json:"username"` + Avatar mfcMaybeString `json:"avatar"` + VS any `json:"vs"` + + Sessions []struct { + SessionID int `json:"session_id"` + VState int `json:"vstate"` + RoomCount int `json:"room_count"` + RoomTopic string `json:"room_topic"` + ServerName string `json:"server_name"` + VideoServerType string `json:"video_server_type"` + Phase string `json:"phase"` + SnapURL string `json:"snap_url"` + } `json:"sessions"` + + Profile struct { + Age string `json:"age"` + Gender string `json:"gender"` + City string `json:"city"` + Country string `json:"country"` + Birthdate string `json:"birthdate"` + SexualPreference string `json:"sexual_preference"` + MaritalStatus string `json:"marital_status"` + Ethnicity string `json:"ethnicity"` + Hair string `json:"hair"` + Eyes string `json:"eyes"` + Weight string `json:"weight"` + WeightUnits string `json:"weight_units"` + Height string `json:"height"` + HeightUnits string `json:"height_units"` + BodyType string `json:"body_type"` + Smoke string `json:"smoke"` + Drink string `json:"drink"` + Drugs string `json:"drugs"` + Blurb string `json:"blurb"` + } `json:"profile"` + + Tags []string `json:"tags"` + } `json:"user"` + } `json:"result"` + + Err int `json:"err"` +} + +func normalizeMFCBioRoomStatus(vstate int, hasSession bool) string { + if !hasSession { + return "offline" + } + + // Deine Beispiele: + // vstate=0 => public + // vstate=14 => private show + if vstate == 0 { + return "public" + } + + return "private" +} + +func normalizeMFCBioImageURL(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + + if strings.HasPrefix(raw, "//") { + raw = "https:" + raw + } else if strings.HasPrefix(raw, "/") { + raw = "https://snap.mfcimg.com" + raw + } else if !strings.HasPrefix(raw, "http://") && !strings.HasPrefix(raw, "https://") { + raw = "https://snap.mfcimg.com/" + strings.TrimLeft(raw, "/") + } + + u, err := url.Parse(raw) + if err != nil || u.Scheme == "" || u.Host == "" { + return raw + } + + q := u.Query() + q.Set("no-cache", strconv.FormatInt(time.Now().UnixNano(), 10)) + u.RawQuery = q.Encode() + + return u.String() +} + +func mfcBioDigitsOnly(s string) string { + var b strings.Builder + for _, r := range strings.TrimSpace(s) { + if r >= '0' && r <= '9' { + b.WriteRune(r) + } + } + return b.String() +} + +func buildMFCBioSnapimgURL(serverName string, rawUserID int) string { + serverID := mfcBioDigitsOnly(serverName) + if serverID == "" || rawUserID <= 0 { + return "" + } + + return normalizeMFCBioImageURL(fmt.Sprintf( + "https://snap.mfcimg.com/snapimg/%s/853x480/mfc_%d", + serverID, + rawUserID, + )) +} + +func selectMFCBioImageURL(data mfcBioLookupResp) (imageURL string, avatarURL string, snapURL string) { + user := data.Result.User + + avatarURL = normalizeMFCBioImageURL(user.Avatar.String()) + + if len(user.Sessions) > 0 { + sess := user.Sessions[0] + + // bevorzugt die stabile snapimg-URL mit echter User-ID + snapURL = buildMFCBioSnapimgURL(sess.ServerName, user.ID) + + // fallback: API snap_url + if strings.TrimSpace(snapURL) == "" { + snapURL = normalizeMFCBioImageURL(sess.SnapURL) + } + } + + imageURL = strings.TrimSpace(snapURL) + if imageURL == "" { + imageURL = strings.TrimSpace(avatarURL) + } + + return imageURL, avatarURL, snapURL +} + +func inferRoomStatusFromMFCBio(data mfcBioLookupResp) ( + roomStatus string, + isOnline bool, + chatRoomURL string, + imageURL string, + avatarURL string, + snapURL string, + roomTopic string, + country string, + gender string, +) { + user := data.Result.User + + hasSession := len(user.Sessions) > 0 + vstate := 0 + if hasSession { + vstate = user.Sessions[0].VState + roomTopic = strings.TrimSpace(user.Sessions[0].RoomTopic) + } + + roomStatus = normalizeMFCBioRoomStatus(vstate, hasSession) + isOnline = hasSession + chatRoomURL = "https://www.myfreecams.com/#" + strings.TrimSpace(user.Username) + + imageURL, avatarURL, snapURL = selectMFCBioImageURL(data) + + country = strings.TrimSpace(user.Profile.Country) + gender = strings.TrimSpace(user.Profile.Gender) + + return +} + +func myfreecamsBioContextHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed) + return + } + + enabled := true + + model := sanitizeModelKey(r.URL.Query().Get("model")) + if model == "" { + http.Error(w, "model fehlt", http.StatusBadRequest) + return + } + + refresh := strings.TrimSpace(r.URL.Query().Get("refresh")) + forceRefresh := refresh == "1" || strings.EqualFold(refresh, "true") + + const host = "myfreecams.com" + + var ( + cachedBio any + cachedAt time.Time + hasCache bool + ) + + store := getModelStore() + if store != nil { + raw, ts, ok, err := store.GetBioContext(host, model) + if err == nil && ok { + var tmp any + if e := json.Unmarshal([]byte(raw), &tmp); e == nil { + cachedBio = tmp + hasCache = true + if t, e2 := time.Parse(time.RFC3339Nano, strings.TrimSpace(ts)); e2 == nil { + cachedAt = t + } + } + } + } + + if hasCache && !forceRefresh && !cachedAt.IsZero() && time.Since(cachedAt) < myfreecamsBioCacheMaxAge { + roomStatus := "unknown" + isOnline := false + chatRoomURL := "https://www.myfreecams.com/#" + model + imageURL := "" + + // Cache ist generisches JSON; erneut typisieren, damit Status/Bild sauber extrahiert werden. + b, _ := json.Marshal(cachedBio) + var parsed mfcBioLookupResp + if json.Unmarshal(b, &parsed) == nil { + var avatarURL, snapURL, roomTopic, country, gender string + roomStatus, isOnline, chatRoomURL, imageURL, avatarURL, snapURL, roomTopic, country, gender = + inferRoomStatusFromMFCBio(parsed) + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(MyFreeCamsBioContextResp{ + Enabled: enabled, + FetchedAt: cachedAt, + Model: model, + Bio: cachedBio, + RoomStatus: roomStatus, + IsOnline: isOnline, + ChatRoomURL: chatRoomURL, + ImageURL: imageURL, + AvatarURL: avatarURL, + SnapURL: snapURL, + RoomTopic: roomTopic, + Country: country, + Gender: gender, + }) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(MyFreeCamsBioContextResp{ + Enabled: enabled, + FetchedAt: cachedAt, + Model: model, + Bio: cachedBio, + RoomStatus: roomStatus, + IsOnline: isOnline, + ChatRoomURL: chatRoomURL, + ImageURL: imageURL, + }) + return + } + + if !myFreeCamsProviderAPIEnabled() { + errMsg := "MyFreeCams API ist in den Einstellungen deaktiviert" + + if hasCache { + roomStatus := "unknown" + isOnline := false + chatRoomURL := "https://www.myfreecams.com/#" + model + imageURL := "" + avatarURL := "" + snapURL := "" + roomTopic := "" + country := "" + gender := "" + + b, _ := json.Marshal(cachedBio) + var cachedParsed mfcBioLookupResp + if json.Unmarshal(b, &cachedParsed) == nil { + roomStatus, isOnline, chatRoomURL, imageURL, avatarURL, snapURL, roomTopic, country, gender = + inferRoomStatusFromMFCBio(cachedParsed) + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(MyFreeCamsBioContextResp{ + Enabled: false, + FetchedAt: cachedAt, + LastError: errMsg, + Model: model, + Bio: cachedBio, + RoomStatus: roomStatus, + IsOnline: isOnline, + ChatRoomURL: chatRoomURL, + ImageURL: imageURL, + AvatarURL: avatarURL, + SnapURL: snapURL, + RoomTopic: roomTopic, + Country: country, + Gender: gender, + }) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(MyFreeCamsBioContextResp{ + Enabled: false, + FetchedAt: time.Now().UTC(), + LastError: errMsg, + Model: model, + }) + return + } + + // Manueller Refresh darf einmal testen, ob MFC wieder frei ist. + if forceRefresh { + clearMFCLookupPause("manual biocontext refresh for " + model) + } else if err := mfcLookupPausedError(); err != nil { + errMsg := err.Error() + + if hasCache { + roomStatus := "unknown" + isOnline := false + chatRoomURL := "https://www.myfreecams.com/#" + model + imageURL := "" + avatarURL := "" + snapURL := "" + roomTopic := "" + country := "" + gender := "" + + b, _ := json.Marshal(cachedBio) + var cachedParsed mfcBioLookupResp + if json.Unmarshal(b, &cachedParsed) == nil { + roomStatus, isOnline, chatRoomURL, imageURL, avatarURL, snapURL, roomTopic, country, gender = + inferRoomStatusFromMFCBio(cachedParsed) + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(MyFreeCamsBioContextResp{ + Enabled: enabled, + FetchedAt: cachedAt, + LastError: errMsg, + Model: model, + Bio: cachedBio, + RoomStatus: roomStatus, + IsOnline: isOnline, + ChatRoomURL: chatRoomURL, + ImageURL: imageURL, + AvatarURL: avatarURL, + SnapURL: snapURL, + RoomTopic: roomTopic, + Country: country, + Gender: gender, + }) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(MyFreeCamsBioContextResp{ + Enabled: enabled, + FetchedAt: time.Now().UTC(), + LastError: errMsg, + Model: model, + }) + return + } + + if !myFreeCamsProviderAPIEnabled() { + errMsg := "MyFreeCams API ist in den Einstellungen deaktiviert" + + if hasCache { + roomStatus := "unknown" + isOnline := false + chatRoomURL := "https://www.myfreecams.com/#" + model + imageURL := "" + avatarURL := "" + snapURL := "" + roomTopic := "" + country := "" + gender := "" + + b, _ := json.Marshal(cachedBio) + var cachedParsed mfcBioLookupResp + if json.Unmarshal(b, &cachedParsed) == nil { + roomStatus, isOnline, chatRoomURL, imageURL, avatarURL, snapURL, roomTopic, country, gender = + inferRoomStatusFromMFCBio(cachedParsed) + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(MyFreeCamsBioContextResp{ + Enabled: false, + FetchedAt: cachedAt, + LastError: errMsg, + Model: model, + Bio: cachedBio, + RoomStatus: roomStatus, + IsOnline: isOnline, + ChatRoomURL: chatRoomURL, + ImageURL: imageURL, + AvatarURL: avatarURL, + SnapURL: snapURL, + RoomTopic: roomTopic, + Country: country, + Gender: gender, + }) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(MyFreeCamsBioContextResp{ + Enabled: false, + FetchedAt: time.Now().UTC(), + LastError: errMsg, + Model: model, + }) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second) + defer cancel() + + upstreamURL := fmt.Sprintf(myfreecamsBioContextURLFmt, url.PathEscape(model)) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, upstreamURL, nil) + if err != nil { + http.Error(w, "request build failed: "+err.Error(), http.StatusInternalServerError) + return + } + + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)") + req.Header.Set("Accept", "application/json, text/plain, */*") + req.Header.Set("Referer", "https://www.myfreecams.com/#"+model) + req.Header.Set("Origin", "https://www.myfreecams.com") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + if hasCache { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(MyFreeCamsBioContextResp{ + Enabled: enabled, + FetchedAt: cachedAt, + LastError: err.Error(), + Model: model, + Bio: cachedBio, + }) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(MyFreeCamsBioContextResp{ + Enabled: enabled, + FetchedAt: time.Now().UTC(), + LastError: err.Error(), + Model: model, + }) + return + } + defer resp.Body.Close() + + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + + snip := strings.TrimSpace(string(raw)) + if len(snip) > 400 { + snip = snip[:400] + "…" + } + + if resp.StatusCode != http.StatusOK { + errMsg := fmt.Sprintf("HTTP %d (ct=%s): %s", resp.StatusCode, resp.Header.Get("Content-Type"), snip) + + if hasCache { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(MyFreeCamsBioContextResp{ + Enabled: enabled, + FetchedAt: cachedAt, + LastError: errMsg, + Model: model, + Bio: cachedBio, + }) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(MyFreeCamsBioContextResp{ + Enabled: enabled, + FetchedAt: time.Now().UTC(), + LastError: errMsg, + Model: model, + }) + return + } + + var parsed mfcBioLookupResp + if err := json.Unmarshal(raw, &parsed); err != nil { + errMsg := fmt.Sprintf("invalid json from MFC usernameLookup (ct=%s): %v; body: %s", resp.Header.Get("Content-Type"), err, snip) + + if hasCache { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(MyFreeCamsBioContextResp{ + Enabled: enabled, + FetchedAt: cachedAt, + LastError: errMsg, + Model: model, + Bio: cachedBio, + }) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(MyFreeCamsBioContextResp{ + Enabled: enabled, + FetchedAt: time.Now().UTC(), + LastError: errMsg, + Model: model, + }) + return + } + + if parsed.Result.Success == 0 || parsed.Result.User.ID <= 0 { + msg := strings.TrimSpace(parsed.Result.Message) + if msg == "" { + msg = "usernameLookup failed" + } + + errMsg := fmt.Sprintf("mfc usernameLookup: %s", msg) + + if isMFCServersBusyMessage(msg) { + pauseMFCLookups(msg, mfcLookupBusyCooldown) + } + + // Wichtig: Busy-Antwort NICHT in den Store schreiben. + if hasCache { + roomStatus := "unknown" + isOnline := false + chatRoomURL := "https://www.myfreecams.com/#" + model + imageURL := "" + avatarURL := "" + snapURL := "" + roomTopic := "" + country := "" + gender := "" + + b, _ := json.Marshal(cachedBio) + var cachedParsed mfcBioLookupResp + if json.Unmarshal(b, &cachedParsed) == nil { + roomStatus, isOnline, chatRoomURL, imageURL, avatarURL, snapURL, roomTopic, country, gender = + inferRoomStatusFromMFCBio(cachedParsed) + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(MyFreeCamsBioContextResp{ + Enabled: enabled, + FetchedAt: cachedAt, + LastError: errMsg, + Model: model, + Bio: cachedBio, + RoomStatus: roomStatus, + IsOnline: isOnline, + ChatRoomURL: chatRoomURL, + ImageURL: imageURL, + AvatarURL: avatarURL, + SnapURL: snapURL, + RoomTopic: roomTopic, + Country: country, + Gender: gender, + }) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(MyFreeCamsBioContextResp{ + Enabled: enabled, + FetchedAt: time.Now().UTC(), + LastError: errMsg, + Model: model, + }) + return + } + + var bio any + if err := json.Unmarshal(raw, &bio); err != nil { + bio = parsed + } + + fetchedAt := time.Now().UTC() + + roomStatus, isOnline, chatRoomURL, imageURL, avatarURL, snapURL, roomTopic, country, gender := + inferRoomStatusFromMFCBio(parsed) + + if store != nil { + _ = store.SetBioContext(host, model, string(raw), fetchedAt.Format(time.RFC3339Nano)) + + // Der Methodenname ist historisch, funktioniert aber mit beliebigem host. + _ = store.SetChaturbateRoomState( + host, + model, + roomStatus, + isOnline, + chatRoomURL, + imageURL, + fetchedAt, + ) + + if imageURL != "" { + _ = store.SetProfileImageURLOnly(host, model, imageURL, fetchedAt.Format(time.RFC3339Nano)) + } + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(MyFreeCamsBioContextResp{ + Enabled: enabled, + FetchedAt: fetchedAt, + LastError: "", + Model: model, + Bio: bio, + RoomStatus: roomStatus, + IsOnline: isOnline, + ChatRoomURL: chatRoomURL, + ImageURL: imageURL, + AvatarURL: avatarURL, + SnapURL: snapURL, + RoomTopic: roomTopic, + Country: country, + Gender: gender, + }) +} diff --git a/backend/myfreecams_lookup_pause.go b/backend/myfreecams_lookup_pause.go new file mode 100644 index 0000000..2b3476a --- /dev/null +++ b/backend/myfreecams_lookup_pause.go @@ -0,0 +1,100 @@ +// backend/myfreecams_lookup_pause.go + +package main + +import ( + "strings" + "sync" + "time" +) + +const mfcLookupBusyCooldown = 10 * time.Minute + +var ( + mfcLookupPauseMu sync.Mutex + mfcLookupPausedUntil time.Time + mfcLookupPauseReason string +) + +func isMFCServersBusyMessage(msg string) bool { + msg = strings.ToLower(strings.TrimSpace(msg)) + return strings.Contains(msg, "servers busy") +} + +func pauseMFCLookups(reason string, d time.Duration) { + if d <= 0 { + d = mfcLookupBusyCooldown + } + + reason = strings.TrimSpace(reason) + if reason == "" { + reason = "servers busy" + } + + until := time.Now().Add(d) + + mfcLookupPauseMu.Lock() + changed := until.After(mfcLookupPausedUntil) + if changed { + mfcLookupPausedUntil = until + mfcLookupPauseReason = reason + } + mfcLookupPauseMu.Unlock() + + if changed { + appLogln( + "⏸️ [myfreecams] usernameLookup pausiert bis", + until.Format("15:04:05"), + "Grund:", + reason, + ) + } +} + +func mfcLookupsPaused() (until time.Time, reason string, paused bool) { + mfcLookupPauseMu.Lock() + defer mfcLookupPauseMu.Unlock() + + now := time.Now() + if mfcLookupPausedUntil.IsZero() || now.After(mfcLookupPausedUntil) { + mfcLookupPausedUntil = time.Time{} + mfcLookupPauseReason = "" + return time.Time{}, "", false + } + + return mfcLookupPausedUntil, mfcLookupPauseReason, true +} + +func mfcLookupPausedError() error { + until, reason, paused := mfcLookupsPaused() + if !paused { + return nil + } + + if reason == "" { + reason = "servers busy" + } + + return appErrorf( + "mfc usernameLookup pausiert bis %s: %s", + until.Format("15:04:05"), + reason, + ) +} + +func clearMFCLookupPause(reason string) { + reason = strings.TrimSpace(reason) + if reason == "" { + reason = "manual refresh" + } + + mfcLookupPauseMu.Lock() + wasPaused := !mfcLookupPausedUntil.IsZero() + mfcLookupPausedUntil = time.Time{} + mfcLookupPauseReason = "" + mfcLookupPauseMu.Unlock() + + if wasPaused { + appLogln("▶️ [myfreecams] usernameLookup Pause aufgehoben. Grund:", reason) + } +} diff --git a/backend/myfreecams_online.go b/backend/myfreecams_online.go new file mode 100644 index 0000000..e72831b --- /dev/null +++ b/backend/myfreecams_online.go @@ -0,0 +1,736 @@ +// backend/myfreecams_online.go +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" +) + +const myfreecamsOnlineLookupURLFmt = "https://api-edge.myfreecams.com/usernameLookup/%s" + +const ( + mfcOnlinePollInterval = 5 * time.Minute + mfcOnlineMaxConcurrent = 1 + mfcOnlinePerModelTimeout = 8 * time.Second +) + +type mfcMaybeString string + +func (s *mfcMaybeString) UnmarshalJSON(b []byte) error { + raw := strings.TrimSpace(string(b)) + + switch raw { + case "", "null", "false", "true": + *s = "" + return nil + } + + var text string + if err := json.Unmarshal(b, &text); err == nil { + *s = mfcMaybeString(strings.TrimSpace(text)) + return nil + } + + // MFC liefert gelegentlich unerwartete Typen. + // Nicht den ganzen usernameLookup scheitern lassen. + *s = "" + return nil +} + +func (s mfcMaybeString) String() string { + return strings.TrimSpace(string(s)) +} + +type MyFreeCamsOnlineRoomLite struct { + Username string `json:"username"` + CurrentShow string `json:"current_show"` // public/private/offline/unknown + ChatRoomURL string `json:"chat_room_url"` + ImageURL string `json:"image_url"` + + Gender string `json:"gender,omitempty"` + Country string `json:"country,omitempty"` + RoomTopic string `json:"room_topic,omitempty"` + RoomCount int `json:"room_count,omitempty"` + Tags []string `json:"tags,omitempty"` +} + +type myfreecamsOnlineCache struct { + LiteByUser map[string]MyFreeCamsOnlineRoomLite + + FetchedAt time.Time + LastAttempt time.Time + LastErr string +} + +var ( + mfcOnlineHTTP = &http.Client{Timeout: mfcOnlinePerModelTimeout + 2*time.Second} + + mfcOnlineMu sync.RWMutex + mfcOnline myfreecamsOnlineCache + + mfcOnlineModelStore *ModelStore +) + +var ( + mfcOnlineRefreshMu sync.Mutex + mfcOnlineRefreshInFlight bool +) + +func setMyFreeCamsOnlineModelStore(store *ModelStore) { + mfcOnlineModelStore = store +} + +type mfcOnlineLookupResp struct { + Result struct { + Success int `json:"success"` + Message string `json:"message"` + + User struct { + ID int `json:"id"` + UserID int `json:"user_id"` + Username string `json:"username"` + Avatar mfcMaybeString `json:"avatar"` + Tags []string `json:"tags"` + + Sessions []struct { + SessionID int `json:"session_id"` + VState int `json:"vstate"` + RoomCount int `json:"room_count"` + RoomTopic string `json:"room_topic"` + ServerName string `json:"server_name"` + Phase string `json:"phase"` + SnapURL string `json:"snap_url"` + } `json:"sessions"` + + Profile struct { + Gender string `json:"gender"` + Country string `json:"country"` + } `json:"profile"` + } `json:"user"` + } `json:"result"` + + Err int `json:"err"` +} + +func mfcOnlineDigitsOnly(s string) string { + var b strings.Builder + for _, r := range strings.TrimSpace(s) { + if r >= '0' && r <= '9' { + b.WriteRune(r) + } + } + return b.String() +} + +func normalizeMFCOnlineImageURL(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + + if strings.HasPrefix(raw, "//") { + raw = "https:" + raw + } else if strings.HasPrefix(raw, "/") { + raw = "https://snap.mfcimg.com" + raw + } else if !strings.HasPrefix(raw, "http://") && !strings.HasPrefix(raw, "https://") { + raw = "https://snap.mfcimg.com/" + strings.TrimLeft(raw, "/") + } + + u, err := url.Parse(raw) + if err != nil || u.Scheme == "" || u.Host == "" { + return raw + } + + q := u.Query() + q.Set("no-cache", strconv.FormatInt(time.Now().UnixNano(), 10)) + u.RawQuery = q.Encode() + + return u.String() +} + +func buildMFCOnlineSnapimgURL(serverName string, rawUserID int) string { + serverID := mfcOnlineDigitsOnly(serverName) + if serverID == "" || rawUserID <= 0 { + return "" + } + + return normalizeMFCOnlineImageURL(fmt.Sprintf( + "https://snap.mfcimg.com/snapimg/%s/853x480/mfc_%d", + serverID, + rawUserID, + )) +} + +func normalizeMFCOnlineRoomStatus(hasSession bool, vstate int) string { + if !hasSession { + return "offline" + } + + // Deine Beispiele: + // vstate=0 => public + // vstate=14 => private show + if vstate == 0 { + return "public" + } + + return "private" +} + +func selectMFCOnlineImageURL(userID int, avatarURL string, sessServerName string, sessSnapURL string) string { + // Bevorzugt die stabile Live-Snap-URL: + // https://snap.mfcimg.com/snapimg//853x480/mfc_ + if img := buildMFCOnlineSnapimgURL(sessServerName, userID); img != "" { + return img + } + + if img := normalizeMFCOnlineImageURL(sessSnapURL); img != "" { + return img + } + + return normalizeMFCOnlineImageURL(avatarURL) +} + +func fetchMyFreeCamsOnlineModel(ctx context.Context, model string) (MyFreeCamsOnlineRoomLite, []byte, error) { + model = strings.TrimSpace(model) + if model == "" { + return MyFreeCamsOnlineRoomLite{}, nil, appErrorf("mfc model leer") + } + + if err := mfcLookupPausedError(); err != nil { + return MyFreeCamsOnlineRoomLite{}, nil, err + } + + upstreamURL := fmt.Sprintf(myfreecamsOnlineLookupURLFmt, url.PathEscape(model)) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, upstreamURL, nil) + if err != nil { + return MyFreeCamsOnlineRoomLite{}, nil, err + } + + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)") + req.Header.Set("Accept", "application/json, text/plain, */*") + req.Header.Set("Referer", "https://www.myfreecams.com/#"+model) + req.Header.Set("Origin", "https://www.myfreecams.com") + + resp, err := mfcOnlineHTTP.Do(req) + if err != nil { + return MyFreeCamsOnlineRoomLite{}, nil, err + } + defer resp.Body.Close() + + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + + if resp.StatusCode != http.StatusOK { + return MyFreeCamsOnlineRoomLite{}, raw, appErrorf( + "mfc usernameLookup %s HTTP %d: %s", + model, + resp.StatusCode, + strings.TrimSpace(string(raw)), + ) + } + + var data mfcOnlineLookupResp + if err := json.Unmarshal(raw, &data); err != nil { + return MyFreeCamsOnlineRoomLite{}, raw, appErrorf("mfc usernameLookup json %s: %w", model, err) + } + + user := data.Result.User + avatarURL := user.Avatar.String() + username := strings.TrimSpace(user.Username) + if username == "" { + username = model + } + + if data.Result.Success == 0 { + msg := strings.TrimSpace(data.Result.Message) + if msg == "" { + msg = "lookup failed" + } + + if isMFCServersBusyMessage(msg) { + pauseMFCLookups(msg, mfcLookupBusyCooldown) + } + + return MyFreeCamsOnlineRoomLite{}, raw, appErrorf( + "mfc usernameLookup %s: %s", + model, + msg, + ) + } + + if user.ID <= 0 { + return MyFreeCamsOnlineRoomLite{}, raw, appErrorf( + "mfc usernameLookup %s: missing user.id", + model, + ) + } + + hasSession := len(user.Sessions) > 0 + + var ( + vstate int + roomCount int + roomTopic string + serverName string + snapURL string + ) + + if hasSession { + sess := user.Sessions[0] + vstate = sess.VState + roomCount = sess.RoomCount + roomTopic = strings.TrimSpace(sess.RoomTopic) + serverName = strings.TrimSpace(sess.ServerName) + snapURL = strings.TrimSpace(sess.SnapURL) + } + + status := normalizeMFCOnlineRoomStatus(hasSession, vstate) + imageURL := selectMFCOnlineImageURL(user.ID, avatarURL, serverName, snapURL) + + return MyFreeCamsOnlineRoomLite{ + Username: username, + CurrentShow: status, + ChatRoomURL: "https://www.myfreecams.com/#" + username, + ImageURL: imageURL, + Gender: strings.TrimSpace(user.Profile.Gender), + Country: strings.TrimSpace(user.Profile.Country), + RoomTopic: roomTopic, + RoomCount: roomCount, + Tags: user.Tags, + }, raw, nil +} + +func listMyFreeCamsStoreModels(store *ModelStore) []StoredModel { + if store == nil { + return nil + } + + all := store.List() + out := make([]StoredModel, 0, len(all)) + seen := map[string]bool{} + + for _, m := range all { + host := canonicalHost(m.Host) + if host != "myfreecams.com" { + continue + } + + modelKey := strings.TrimSpace(m.ModelKey) + if modelKey == "" { + continue + } + + k := strings.ToLower(modelKey) + if seen[k] { + continue + } + seen[k] = true + + out = append(out, m) + } + + return out +} + +func refreshMyFreeCamsSnapshotNow(ctx context.Context, store *ModelStore) (time.Time, int, error) { + if store == nil { + return time.Time{}, 0, appErrorf("mfc online: model store nil") + } + + mfcOnlineMu.Lock() + mfcOnline.LastAttempt = time.Now() + mfcOnlineMu.Unlock() + + models := listMyFreeCamsStoreModels(store) + fetchedAt := time.Now().UTC() + + if err := mfcLookupPausedError(); err != nil { + mfcOnlineMu.Lock() + mfcOnline.LastAttempt = time.Now() + mfcOnline.LastErr = err.Error() + mfcOnlineMu.Unlock() + + return fetchedAt, 0, err + } + + liteByUser := make(map[string]MyFreeCamsOnlineRoomLite, len(models)) + + // Vorherigen Snapshot behalten, damit einzelne Timeout-Fehler nicht sofort + // alle bekannten MFC-Online-Daten aus dem Cache löschen. + mfcOnlineMu.RLock() + for k, v := range mfcOnline.LiteByUser { + liteByUser[k] = v + } + mfcOnlineMu.RUnlock() + + if len(models) == 0 { + mfcOnlineMu.Lock() + mfcOnline.LiteByUser = liteByUser + mfcOnline.FetchedAt = fetchedAt + mfcOnline.LastErr = "" + mfcOnlineMu.Unlock() + + return fetchedAt, 0, nil + } + + type result struct { + model StoredModel + room MyFreeCamsOnlineRoomLite + raw []byte + err error + } + + sem := make(chan struct{}, mfcOnlineMaxConcurrent) + resCh := make(chan result, len(models)) + + var wg sync.WaitGroup + + for _, m := range models { + m := m + wg.Add(1) + + go func() { + defer wg.Done() + + select { + case <-ctx.Done(): + resCh <- result{model: m, err: ctx.Err()} + return + case sem <- struct{}{}: + } + defer func() { <-sem }() + + if err := mfcLookupPausedError(); err != nil { + resCh <- result{model: m, err: err} + return + } + + reqCtx, reqCancel := context.WithTimeout(ctx, mfcOnlinePerModelTimeout) + room, raw, err := fetchMyFreeCamsOnlineModel(reqCtx, m.ModelKey) + reqCancel() + resCh <- result{ + model: m, + room: room, + raw: raw, + err: err, + } + }() + } + + wg.Wait() + close(resCh) + + var ( + firstErr error + okCount int + failedCount int + timeoutCount int + ) + + for res := range resCh { + modelKey := strings.TrimSpace(res.model.ModelKey) + if modelKey == "" { + continue + } + + if res.err != nil { + failedCount++ + + if errors.Is(res.err, context.DeadlineExceeded) { + timeoutCount++ + } + + if firstErr == nil { + firstErr = res.err + } + + // Kein Log pro Model mehr, sonst spammt es bei vielen Timeouts. + continue + } + + room := res.room + if strings.TrimSpace(room.Username) == "" { + room.Username = modelKey + } + + key := strings.ToLower(strings.TrimSpace(room.Username)) + if key == "" { + key = strings.ToLower(modelKey) + } + + liteByUser[key] = room + okCount++ + + isOnline := room.CurrentShow != "offline" && room.CurrentShow != "unknown" + + _ = store.SetChaturbateRoomState( + "myfreecams.com", + modelKey, + room.CurrentShow, + isOnline, + room.ChatRoomURL, + room.ImageURL, + fetchedAt, + ) + + _ = store.SetLastSeenOnline( + "myfreecams.com", + modelKey, + isOnline, + fetchedAt.Format(time.RFC3339Nano), + ) + + if len(res.raw) > 0 { + _ = store.SetBioContext( + "myfreecams.com", + modelKey, + string(res.raw), + fetchedAt.Format(time.RFC3339Nano), + ) + } + + if strings.TrimSpace(room.ImageURL) != "" { + _ = store.SetProfileImageURLOnly( + "myfreecams.com", + modelKey, + room.ImageURL, + fetchedAt.Format(time.RFC3339Nano), + ) + } + } + + lastErr := "" + if firstErr != nil { + lastErr = fmt.Sprintf( + "%d/%d lookups failed (%d timeouts); first error: %v", + failedCount, + len(models), + timeoutCount, + firstErr, + ) + + appLogln("⚠️ [myfreecams] online refresh partial:", lastErr) + } + + mfcOnlineMu.Lock() + mfcOnline.LiteByUser = liteByUser + mfcOnline.FetchedAt = fetchedAt + mfcOnline.LastErr = lastErr + mfcOnlineMu.Unlock() + + // Nur hart fehlschlagen, wenn wirklich gar kein Model erfolgreich geprüft wurde. + if okCount == 0 && firstErr != nil { + return fetchedAt, okCount, firstErr + } + + return fetchedAt, okCount, nil +} + +func startMyFreeCamsOnlinePoller(store *ModelStore) { + const interval = mfcOnlinePollInterval + + if store != nil { + setMyFreeCamsOnlineModelStore(store) + } + + lastLoggedErr := "" + + refreshOnce := func(trigger string) { + if !myFreeCamsProviderAPIEnabled() { + if verboseLogs() { + appLogln("ℹ️ [myfreecams] online refresh skipped: provider API disabled") + } + return + } + + mfcOnlineRefreshMu.Lock() + if mfcOnlineRefreshInFlight { + mfcOnlineRefreshMu.Unlock() + return + } + mfcOnlineRefreshInFlight = true + mfcOnlineRefreshMu.Unlock() + + go func() { + defer func() { + mfcOnlineRefreshMu.Lock() + mfcOnlineRefreshInFlight = false + mfcOnlineRefreshMu.Unlock() + }() + + fetchedAt, count, err := refreshMyFreeCamsSnapshotNow(context.Background(), store) + + if err != nil { + msg := err.Error() + + if strings.Contains(msg, "usernameLookup pausiert") { + if verboseLogs() && msg != lastLoggedErr { + appLogln("⏸️ [myfreecams] online refresh skipped:", msg) + lastLoggedErr = msg + } + return + } + + if msg != lastLoggedErr { + appLogln("⚠️ [myfreecams] online refresh failed:", msg) + lastLoggedErr = msg + } + return + } + + if lastLoggedErr != "" { + appLogln("✅ [myfreecams] online refresh recovered") + lastLoggedErr = "" + } + + if verboseLogs() { + appLogln( + "✅ [myfreecams] checked models:", + count, + "snapshot", + fetchedAt.Format(time.RFC3339), + "trigger="+trigger, + ) + } + }() + } + + refreshOnce("startup") + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for range ticker.C { + refreshOnce("ticker") + } +} + +type mfcOnlineResponse struct { + Enabled bool `json:"enabled"` + FetchedAt time.Time `json:"fetchedAt"` + LastAttempt time.Time `json:"lastAttempt"` + Count int `json:"count"` + Total int `json:"total"` + LastError string `json:"lastError"` + Rooms []MyFreeCamsOnlineRoomLite `json:"rooms"` +} + +func myfreecamsOnlineHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodPost { + http.Error(w, "Nur GET/POST erlaubt", http.StatusMethodNotAllowed) + return + } + + wantRefresh := false + var users []string + + if r.Method == http.MethodPost { + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + var req struct { + Q []string `json:"q"` + Refresh bool `json:"refresh"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + wantRefresh = req.Refresh + for _, u := range req.Q { + u = strings.ToLower(strings.TrimSpace(u)) + if u != "" { + users = append(users, u) + } + } + } else { + qRefresh := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("refresh"))) + wantRefresh = qRefresh == "1" || qRefresh == "true" || qRefresh == "yes" + + qUsers := strings.TrimSpace(r.URL.Query().Get("q")) + if qUsers != "" { + for _, u := range strings.Split(qUsers, ",") { + u = strings.ToLower(strings.TrimSpace(u)) + if u != "" { + users = append(users, u) + } + } + } + } + + store := mfcOnlineModelStore + if store == nil { + store = getModelStore() + } + + if wantRefresh && store != nil { + if !myFreeCamsProviderAPIEnabled() { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(mfcOnlineResponse{ + Enabled: false, + LastError: "MyFreeCams API ist in den Einstellungen deaktiviert", + Rooms: []MyFreeCamsOnlineRoomLite{}, + }) + return + } + + mfcOnlineRefreshMu.Lock() + if !mfcOnlineRefreshInFlight { + mfcOnlineRefreshInFlight = true + go func() { + defer func() { + mfcOnlineRefreshMu.Lock() + mfcOnlineRefreshInFlight = false + mfcOnlineRefreshMu.Unlock() + }() + + _, _, _ = refreshMyFreeCamsSnapshotNow(context.Background(), store) + }() + } + mfcOnlineRefreshMu.Unlock() + } + + mfcOnlineMu.RLock() + fetchedAt := mfcOnline.FetchedAt + lastAttempt := mfcOnline.LastAttempt + lastErr := mfcOnline.LastErr + liteByUser := mfcOnline.LiteByUser + mfcOnlineMu.RUnlock() + + total := 0 + if liteByUser != nil { + total = len(liteByUser) + } + + rooms := make([]MyFreeCamsOnlineRoomLite, 0) + + if len(users) > 0 { + for _, u := range users { + if rm, ok := liteByUser[u]; ok { + rooms = append(rooms, rm) + } + } + } + + if fetchedAt.IsZero() && lastErr == "" { + lastErr = "warming up" + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + + _ = json.NewEncoder(w).Encode(mfcOnlineResponse{ + Enabled: true, + FetchedAt: fetchedAt, + LastAttempt: lastAttempt, + Count: len(rooms), + Total: total, + LastError: lastErr, + Rooms: rooms, + }) +} diff --git a/backend/provider_api_settings.go b/backend/provider_api_settings.go new file mode 100644 index 0000000..815dd35 --- /dev/null +++ b/backend/provider_api_settings.go @@ -0,0 +1,17 @@ +// backend/provider_api_settings.go +package main + +func providerAPIsEnabled() bool { + s := getSettings() + return s.UseProviderAPIs +} + +func chaturbateProviderAPIEnabled() bool { + s := getSettings() + return s.UseProviderAPIs && s.UseChaturbateAPI +} + +func myFreeCamsProviderAPIEnabled() bool { + s := getSettings() + return s.UseProviderAPIs && s.UseMyFreeCamsAPI +} diff --git a/backend/record_stream_cb.go b/backend/record_stream_cb.go index 826012e..eb3fc65 100644 --- a/backend/record_stream_cb.go +++ b/backend/record_stream_cb.go @@ -313,9 +313,6 @@ func ParseStream(html string) (string, error) { if err := json.Unmarshal([]byte(decoded), &rd); err != nil { return "", appErrorf("JSON-parse failed: %w", err) } - if rd.HLSSource == "" { - return "", errors.New("kein HLS-Quell-URL im JSON") - } return rd.HLSSource, nil } diff --git a/backend/record_stream_mfc.go b/backend/record_stream_mfc.go index 1dcd72d..a21be96 100644 --- a/backend/record_stream_mfc.go +++ b/backend/record_stream_mfc.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/http" "net/url" "strconv" @@ -31,6 +32,10 @@ func RecordStreamMFC( const waitPublicMax = 90 * time.Second deadline := time.Now().Add(waitPublicMax) + var lastStatus Status = StatusUnknown + var lastErr error + var lastStatusLog time.Time + for { if err := ctx.Err(); err != nil { return err @@ -41,11 +46,21 @@ func RecordStreamMFC( break } - if time.Now().After(deadline) { + lastStatus = st + lastErr = err + + if lastStatusLog.IsZero() || time.Since(lastStatusLog) >= 15*time.Second { if err != nil { - return appErrorf("mfc status: %w", err) + appLogln("⚠️ mfc status failed:", username, err) } - return context.DeadlineExceeded + lastStatusLog = time.Now() + } + + if time.Now().After(deadline) { + if lastErr != nil { + return appErrorf("mfc status: %w", lastErr) + } + return appErrorf("mfc status blieb %s für %s", lastStatus, username) } time.Sleep(5 * time.Second) @@ -120,9 +135,9 @@ type MyFreeCams struct { type mfcUsernameLookupResp struct { Result struct { User struct { - ID int `json:"id"` - Username string `json:"username"` - Avatar string `json:"avatar"` + ID int `json:"id"` + Username string `json:"username"` + Avatar mfcMaybeString `json:"avatar"` Sessions []struct { ServerName string `json:"server_name"` @@ -364,17 +379,27 @@ func (m *MyFreeCams) GetStatus(ctx context.Context, userAgent string) (Status, e return StatusUnknown, appErrorf("mfc usernameLookup HTTP %d: %s", resp.StatusCode, lookupURL) } + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return StatusUnknown, err + } + + snip := strings.TrimSpace(string(raw)) + if len(snip) > 500 { + snip = snip[:500] + "…" + } + var data mfcUsernameLookupResp - if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { - return StatusUnknown, appErrorf("mfc usernameLookup json: %w", err) + if err := json.Unmarshal(raw, &data); err != nil { + return StatusUnknown, appErrorf("mfc usernameLookup json for %s: %w; body=%s", username, err, snip) } if data.Result.User.ID <= 0 { - return StatusNotExist, nil + return StatusUnknown, appErrorf("mfc usernameLookup ohne user.id für %s; body=%s", username, snip) } m.RawUserID = data.Result.User.ID - m.AvatarURL = strings.TrimSpace(data.Result.User.Avatar) + m.AvatarURL = data.Result.User.Avatar.String() if len(data.Result.User.Sessions) == 0 { return StatusOffline, nil @@ -507,21 +532,86 @@ func extractMFCUsername(input string) string { return "" } - // 1) URL mit Fragment (#username) - if u, err := url.Parse(s); err == nil && u.Fragment != "" { - return strings.Trim(strings.TrimSpace(u.Fragment), "/") - } - - // 2) URL Pfad: letztes Segment nehmen - if u, err := url.Parse(s); err == nil && u.Host != "" { - p := strings.Trim(u.Path, "/") - if p == "" { - return "" + if u, err := url.Parse(s); err == nil { + if frag := strings.TrimSpace(u.Fragment); frag != "" { + if name := extractMFCUsernameFromPathish(frag); name != "" { + return name + } + } + + if u.Host != "" { + if name := extractMFCUsernameFromPathish(u.Path); name != "" { + return name + } } - parts := strings.Split(p, "/") - return strings.TrimSpace(parts[len(parts)-1]) } - // 3) Fallback: raw - return s + return sanitizeMFCUsernamePart(s) +} + +func extractMFCUsernameFromPathish(raw string) string { + raw = strings.TrimSpace(raw) + raw = strings.TrimPrefix(raw, "#") + raw = strings.Trim(raw, "/") + + if raw == "" { + return "" + } + + skip := map[string]bool{ + "models": true, + "model": true, + "profile": true, + "users": true, + "user": true, + } + + parts := strings.Split(raw, "/") + + for i := len(parts) - 1; i >= 0; i-- { + p := sanitizeMFCUsernamePart(parts[i]) + if p == "" { + continue + } + if skip[strings.ToLower(p)] { + continue + } + return p + } + + return "" +} + +func sanitizeMFCUsernamePart(s string) string { + s = strings.TrimSpace(s) + s = strings.Trim(s, "/") + s = strings.TrimPrefix(s, "@") + + if dec, err := url.PathUnescape(s); err == nil { + s = dec + } + if dec, err := url.QueryUnescape(s); err == nil { + s = dec + } + + s = strings.TrimSpace(s) + s = strings.Trim(s, "/") + s = strings.TrimPrefix(s, "@") + + s = strings.Map(func(r rune) rune { + switch { + case r >= 'a' && r <= 'z': + return r + case r >= 'A' && r <= 'Z': + return r + case r >= '0' && r <= '9': + return r + case r == '_' || r == '-' || r == '.': + return r + default: + return -1 + } + }, s) + + return strings.TrimSpace(s) } diff --git a/backend/recorder.go b/backend/recorder.go index b19a1b2..7ba0375 100644 --- a/backend/recorder.go +++ b/backend/recorder.go @@ -1151,6 +1151,11 @@ func runMFCRecording( _ = os.MkdirAll(recordDirAbs, 0o755) username := extractMFCUsername(req.URL) + + if strings.TrimSpace(username) == "" { + return appErrorf("mfc: username konnte nicht aus URL gelesen werden: %s", req.URL) + } + filename := fmt.Sprintf("%s_%s.ts", username, now.Format("01_02_2006__15-04-05")) outPath := filepath.Join(recordDirAbs, filename) diff --git a/backend/recorder_settings.json b/backend/recorder_settings.json index 6525a4c..5e26e59 100644 --- a/backend/recorder_settings.json +++ b/backend/recorder_settings.json @@ -1,17 +1,19 @@ { "databaseUrl": "postgres://postgres@127.0.0.1:5432/nsfwapp?sslmode=disable", "encryptedDbPassword": "vkNvHM+/AgEkRPW0X+rDDys4bPb5Q7ZPGqPSADCluIJ6GCDGNGlDy3s=", - "recordDir": "C:\\Users\\Chris\\Desktop\\privat\\records", - "doneDir": "C:\\Users\\Chris\\Desktop\\privat\\records\\done", + "recordDir": "C:\\Users\\Rother\\Desktop\\Privat\\records", + "doneDir": "C:\\Users\\Rother\\Desktop\\Privat\\records\\done", "ffmpegPath": "", "autoAddToDownloadList": true, + "autoStartAddedDownloads": true, "enableConcurrentDownloadsLimit": true, "maxConcurrentDownloads": 80, + "useProviderApis": true, "useChaturbateApi": true, - "useMyFreeCamsWatcher": true, + "useMyFreeCamsApi": true, "autoDeleteSmallDownloads": true, "autoDeleteSmallDownloadsBelowMB": 300, - "autoDeleteSmallDownloadsKeepFavorites": false, + "autoDeleteSmallDownloadsKeepFavorites": true, "lowDiskPauseBelowGB": 5, "blurPreviews": false, "teaserPlayback": "all", diff --git a/backend/routes.go b/backend/routes.go index f0ecf3b..d150c1b 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -104,6 +104,9 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore { api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler) api.HandleFunc("/api/chaturbate/biocontext", chaturbateBioContextHandler) + api.HandleFunc("/api/myfreecams/online", myfreecamsOnlineHandler) + api.HandleFunc("/api/myfreecams/biocontext", myfreecamsBioContextHandler) + api.HandleFunc("/api/generated/teaser", generatedTeaser) api.HandleFunc("/api/generated/cover", generatedCover) api.HandleFunc("/api/generated/coverinfo/list", generatedCoverInfoList) @@ -132,6 +135,7 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore { setCoverModelStore(store) RegisterModelAPI(api, store) setChaturbateOnlineModelStore(store) + setMyFreeCamsOnlineModelStore(store) // -------------------------- // 4) Mount Protected API diff --git a/backend/server.go b/backend/server.go index c649442..b8457fd 100644 --- a/backend/server.go +++ b/backend/server.go @@ -715,6 +715,7 @@ func main() { // Hintergrund-Worker go startDiskSpaceGuard() go startChaturbateOnlinePoller(store) + go startMyFreeCamsOnlinePoller(store) go startChaturbateAutoStartWorker(store) go startMyFreeCamsAutoStartWorker(store) diff --git a/backend/settings.go b/backend/settings.go index 870797b..f7c2aec 100644 --- a/backend/settings.go +++ b/backend/settings.go @@ -22,11 +22,14 @@ type RecorderSettings struct { FFmpegPath string `json:"ffmpegPath"` AutoAddToDownloadList bool `json:"autoAddToDownloadList"` + AutoStartAddedDownloads bool `json:"autoStartAddedDownloads"` EnableConcurrentDownloadsLimit bool `json:"enableConcurrentDownloadsLimit"` MaxConcurrentDownloads int `json:"maxConcurrentDownloads"` - UseChaturbateAPI bool `json:"useChaturbateApi"` - UseMyFreeCamsWatcher bool `json:"useMyFreeCamsWatcher"` + UseProviderAPIs bool `json:"useProviderApis"` + UseChaturbateAPI bool `json:"useChaturbateApi"` + UseMyFreeCamsAPI bool `json:"useMyFreeCamsApi"` + // Wenn aktiv, werden fertige Downloads automatisch gelöscht, wenn sie kleiner als der Grenzwert sind. AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"` AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"` @@ -64,11 +67,14 @@ var ( FFmpegPath: "", AutoAddToDownloadList: false, + AutoStartAddedDownloads: true, EnableConcurrentDownloadsLimit: false, MaxConcurrentDownloads: 20, - UseChaturbateAPI: false, - UseMyFreeCamsWatcher: false, + UseProviderAPIs: false, + UseChaturbateAPI: false, + UseMyFreeCamsAPI: false, + AutoDeleteSmallDownloads: false, AutoDeleteSmallDownloadsBelowMB: 50, AutoDeleteSmallDownloadsKeepFavorites: true, @@ -236,11 +242,13 @@ type RecorderSettingsPublic struct { HasDBPassword bool `json:"hasDbPassword"` AutoAddToDownloadList bool `json:"autoAddToDownloadList"` + AutoStartAddedDownloads bool `json:"autoStartAddedDownloads"` EnableConcurrentDownloadsLimit bool `json:"enableConcurrentDownloadsLimit"` MaxConcurrentDownloads int `json:"maxConcurrentDownloads"` - UseChaturbateAPI bool `json:"useChaturbateApi"` - UseMyFreeCamsWatcher bool `json:"useMyFreeCamsWatcher"` + UseProviderAPIs bool `json:"useProviderApis"` + UseChaturbateAPI bool `json:"useChaturbateApi"` + UseMyFreeCamsAPI bool `json:"useMyFreeCamsApi"` AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"` AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"` @@ -274,11 +282,13 @@ func toPublicSettings(s RecorderSettings) RecorderSettingsPublic { HasDBPassword: strings.TrimSpace(s.EncryptedDBPassword) != "", AutoAddToDownloadList: s.AutoAddToDownloadList, + AutoStartAddedDownloads: s.AutoStartAddedDownloads, EnableConcurrentDownloadsLimit: s.EnableConcurrentDownloadsLimit, MaxConcurrentDownloads: s.MaxConcurrentDownloads, - UseChaturbateAPI: s.UseChaturbateAPI, - UseMyFreeCamsWatcher: s.UseMyFreeCamsWatcher, + UseProviderAPIs: s.UseProviderAPIs, + UseChaturbateAPI: s.UseChaturbateAPI, + UseMyFreeCamsAPI: s.UseMyFreeCamsAPI, AutoDeleteSmallDownloads: s.AutoDeleteSmallDownloads, AutoDeleteSmallDownloadsBelowMB: s.AutoDeleteSmallDownloadsBelowMB, @@ -442,11 +452,13 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) { next.EncryptedDBPassword = in.EncryptedDBPassword next.AutoAddToDownloadList = in.AutoAddToDownloadList + next.AutoStartAddedDownloads = in.AutoStartAddedDownloads next.EnableConcurrentDownloadsLimit = in.EnableConcurrentDownloadsLimit next.MaxConcurrentDownloads = in.MaxConcurrentDownloads + next.UseProviderAPIs = in.UseProviderAPIs next.UseChaturbateAPI = in.UseChaturbateAPI - next.UseMyFreeCamsWatcher = in.UseMyFreeCamsWatcher + next.UseMyFreeCamsAPI = in.UseMyFreeCamsAPI next.AutoDeleteSmallDownloads = in.AutoDeleteSmallDownloads next.AutoDeleteSmallDownloadsBelowMB = in.AutoDeleteSmallDownloadsBelowMB @@ -514,6 +526,7 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) { if newStore != nil { setModelStore(newStore) setChaturbateOnlineModelStore(newStore) + setMyFreeCamsOnlineModelStore(newStore) } // ✅ ffmpeg/ffprobe nach Änderungen neu bestimmen diff --git a/backend/training.go b/backend/training.go index 88a46bf..e1cd15b 100644 --- a/backend/training.go +++ b/backend/training.go @@ -10,6 +10,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "math" "math/rand" "net/http" @@ -2436,6 +2437,10 @@ func trainingRunJob(ctx context.Context, root string, count int) { } } + if detectorStatus == "trained" { + reloadAIServerModelAfterTraining() + } + trainingSetJobStatus(func(s *TrainingJobStatus) { finishedAt := time.Now().UTC() @@ -3329,6 +3334,39 @@ func trainingCreateUncertainNextSampleWithProgress(startedAtMs int64, requestID return best.sample, nil } +func reloadAIServerModelAfterTraining() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, aiServerURL()+"/reload", nil) + if err != nil { + appLogln("⚠️ AI Server Reload Request konnte nicht gebaut werden:", err) + return + } + + addAIServerAuth(req) + + client := &http.Client{ + Timeout: 30 * time.Second, + } + + resp, err := client.Do(req) + if err != nil { + appLogln("⚠️ AI Server Reload fehlgeschlagen:", err) + return + } + defer resp.Body.Close() + + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + appLogln("⚠️ AI Server Reload HTTP", resp.StatusCode, strings.TrimSpace(string(body))) + return + } + + appLogln("✅ AI Server Modell nach Training neu geladen:", strings.TrimSpace(string(body))) +} + func trainingDeleteSampleFiles(root string, sampleID string) { sampleID = strings.TrimSpace(sampleID) if sampleID == "" || strings.Contains(sampleID, "/") || strings.Contains(sampleID, "\\") { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 71a4842..90561db 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -65,8 +65,9 @@ const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = { autoStartAddedDownloads: false, enableConcurrentDownloadsLimit: false, maxConcurrentDownloads: 20, + useProviderApis: false, useChaturbateApi: false, - useMyFreeCamsWatcher: false, + useMyFreeCamsApi: false, autoDeleteSmallDownloads: false, autoDeleteSmallDownloadsBelowMB: 200, autoDeleteSmallDownloadsKeepFavorites: true, @@ -2503,7 +2504,12 @@ export default function App() { // Nur automatische Starts in pending-autostart schieben. // Manuelle UI-Starts müssen sofort direkt starten. if (!immediate) { - if (provider === 'chaturbate' && recSettingsRef.current.useChaturbateApi && keyLower) { + if ( + provider === 'chaturbate' && + recSettingsRef.current.useProviderApis && + recSettingsRef.current.useChaturbateApi && + keyLower + ) { queuePendingAutoStart(keyLower, norm, 'unknown', undefined, { mode: 'probe_retry', source: 'manual', @@ -2511,7 +2517,12 @@ export default function App() { return true } - if (provider === 'mfc' && recSettingsRef.current.useMyFreeCamsWatcher && keyLower) { + if ( + provider === 'mfc' && + recSettingsRef.current.useProviderApis && + recSettingsRef.current.useMyFreeCamsApi && + keyLower + ) { queuePendingAutoStart(keyLower, norm, 'offline', undefined, { mode: 'probe_retry', source: 'manual', @@ -3837,7 +3848,7 @@ export default function App() { if (autoAddEnabled) setSourceUrl(url) if (autoStartEnabled) { - void startUrl(url, { silent: false }) + void startUrl(url, { silent: false, immediate: true }) } } catch { // ignore diff --git a/frontend/src/components/ui/ModelDetails.tsx b/frontend/src/components/ui/ModelDetails.tsx index 95086fc..e5b4197 100644 --- a/frontend/src/components/ui/ModelDetails.tsx +++ b/frontend/src/components/ui/ModelDetails.tsx @@ -97,11 +97,29 @@ function ssSet(key: string, value: any) { } } -function ssKeyOnline(modelKey: string) { - return `md:cb:online:${modelKey}` +type ProviderKey = 'chaturbate' | 'myfreecams' + +function providerOfModel(model?: StoredModel | null): ProviderKey { + const host = String(model?.host ?? '').toLowerCase() + const chatUrl = String(model?.chatRoomUrl ?? '').toLowerCase() + + if (host.includes('myfreecams') || chatUrl.includes('myfreecams.com')) { + return 'myfreecams' + } + + return 'chaturbate' } -function ssKeyBio(modelKey: string) { - return `md:cb:bio:${modelKey}` + +function mdCacheKey(provider: ProviderKey, modelKey: string) { + return `${provider}:${modelKey}` +} + +function ssKeyOnline(provider: ProviderKey, modelKey: string) { + return `md:${provider}:online:${modelKey}` +} + +function ssKeyBio(provider: ProviderKey, modelKey: string) { + return `md:${provider}:bio:${modelKey}` } const nf = new Intl.NumberFormat('de-DE') @@ -403,11 +421,17 @@ type BioResp = { fetchedAt?: string lastError?: string model?: string - bio?: BioContext | null + bio?: BioContext | any | null roomStatus?: string isOnline?: boolean chatRoomUrl?: string imageUrl?: string + + avatarUrl?: string + snapUrl?: string + roomTopic?: string + country?: string + gender?: string } // ------ props ------ @@ -416,8 +440,13 @@ type BioResp = { // /api/models liefert StoredModel aus dem models_store type StoredModel = { id: string + host?: string | null modelKey: string tags?: string | null + + gender?: string | null + country?: string | null + lastSeenOnline?: boolean | null lastSeenOnlineAt?: string @@ -511,6 +540,116 @@ function buildChaturbateCookieHeader(cookies?: Record): string { return parts.join('; ') } +function asMFCUser(data: BioResp | null | undefined): any | null { + return (data?.bio as any)?.result?.user ?? null +} + +function toNum(v: any): number | undefined { + const n = Number(v) + return Number.isFinite(n) ? n : undefined +} + +function nonZeroDate(v: any): string { + const s = String(v ?? '').trim() + if (!s || s === '0000-00-00') return '' + return s +} + +function mfcLocation(profile: any): string { + const city = String(profile?.city ?? '').trim() + const country = String(profile?.country ?? '').trim() + return [city, country].filter(Boolean).join(', ') +} + +function mfcDisplayNameFromUser(user: any, fallbackKey?: string): string { + const profile = user?.profile ?? {} + + const blurbName = stripHtmlToText(profile?.blurb) + if (blurbName) return blurbName + + const username = String(user?.username ?? fallbackKey ?? '').trim() + return username +} + +function mfcSocialsToBioSocials(social: any): BioSocial[] { + const out: BioSocial[] = [] + + const twitter = String(social?.twitter_username ?? '').trim() + if (twitter) { + out.push({ + id: 1, + title_name: 'Twitter/X', + link: `https://x.com/${twitter.replace(/^@/, '')}`, + }) + } + + const instagram = String(social?.instagram_username ?? '').trim() + if (instagram) { + out.push({ + id: 2, + title_name: 'Instagram', + link: `https://instagram.com/${instagram.replace(/^@/, '')}`, + }) + } + + return out +} + +function mfcRoomFromBioResp(data: BioResp | null | undefined, fallbackKey: string): ChaturbateRoom | null { + const user = asMFCUser(data) + if (!user) return null + + const profile = user.profile ?? {} + const session = Array.isArray(user.sessions) && user.sessions.length > 0 ? user.sessions[0] : null + + const username = String(user.username ?? fallbackKey ?? '').trim() + const displayName = mfcDisplayNameFromUser(user, username || fallbackKey) + const roomStatus = String(data?.roomStatus ?? '').trim().toLowerCase() + const image = String(data?.imageUrl ?? user.avatar ?? '').trim() + + return { + username, + display_name: displayName || username, + current_show: roomStatus || (session ? 'public' : 'offline'), + room_subject: String(session?.room_topic ?? data?.roomTopic ?? '').trim(), + tags: Array.isArray(user.tags) ? user.tags : [], + num_users: toNum(session?.room_count), + num_followers: toNum(user.share?.follows), + gender: String(profile.gender ?? data?.gender ?? '').trim(), + country: String(profile.country ?? data?.country ?? '').trim(), + location: mfcLocation(profile), + age: toNum(profile.age), + birthday: nonZeroDate(profile.birthdate), + image_url: image, + image_url_360x270: image, + chat_room_url: String(data?.chatRoomUrl ?? `https://www.myfreecams.com/#${username}`).trim(), + } +} + +function mfcBioFromBioResp(data: BioResp | null | undefined): BioContext | null { + const user = asMFCUser(data) + if (!user) return null + + const profile = user.profile ?? {} + const social = user.social ?? {} + + return { + follower_count: toNum(user.share?.follows), + location: mfcLocation(profile), + body_type: String(profile.body_type ?? '').trim(), + display_birthday: nonZeroDate(profile.birthdate), + display_age: toNum(profile.age), + sex: String(profile.gender ?? data?.gender ?? '').trim(), + room_status: String(data?.roomStatus ?? '').trim(), + + // MFC liefert hier relative Strings wie "2 days ago". + last_broadcast: String(user.last_login ?? '').trim(), + + about_me: stripHtmlToText(profile.blurb), + smoke_drink: [profile.smoke, profile.drink].map((x) => String(x ?? '').trim()).filter(Boolean).join(' / '), + social_medias: mfcSocialsToBioSocials(social), + } +} export default function ModelDetails({ open, @@ -587,6 +726,13 @@ export default function ModelDetails({ const removingKeys = useMemo(() => new Set(), []) const deletedKeys = useMemo(() => new Set(), []) + const model = useMemo(() => { + if (!key) return null + return models.find((m) => (m.modelKey || '').toLowerCase() === key) ?? null + }, [models, key]) + + const provider = useMemo(() => providerOfModel(model), [model]) + useEffect(() => { if (!open) { bioReqRef.current?.abort() @@ -622,8 +768,10 @@ export default function ModelDetails({ if (!open || !key) return // Online - const memOnline = mdOnlineMem.get(key) - const ssOnline = ssGet(ssKeyOnline(key)) + const ck = mdCacheKey(provider, key) + + const memOnline = mdOnlineMem.get(ck) + const ssOnline = ssGet(ssKeyOnline(provider, key)) const onlineHit = (memOnline && isFresh(memOnline.at) ? memOnline : null) || @@ -635,8 +783,8 @@ export default function ModelDetails({ } // Bio - const memBio = mdBioMem.get(key) - const ssBio = ssGet(ssKeyBio(key)) + const memBio = mdBioMem.get(ck) + const ssBio = ssGet(ssKeyBio(provider, key)) const bioHit = (memBio && isFresh(memBio.at) ? memBio : null) || @@ -647,7 +795,7 @@ export default function ModelDetails({ setBioMeta(bioHit.meta ?? null) // bioLoading NICHT anfassen – Fetch kann trotzdem laufen } - }, [open, key]) + }, [open, key, provider]) useEffect(() => { if (!open) return @@ -657,15 +805,20 @@ export default function ModelDetails({ const refreshBio = useCallback(async () => { if (!key) return - // vorherigen abbrechen bioReqRef.current?.abort() const ac = new AbortController() bioReqRef.current = ac setBioLoading(true) + try { - const cookieHeader = buildChaturbateCookieHeader(cookies) - const url = `/api/chaturbate/biocontext?model=${encodeURIComponent(key)}&refresh=1` + const isMFC = provider === 'myfreecams' + + const url = isMFC + ? `/api/myfreecams/biocontext?model=${encodeURIComponent(key)}&refresh=1` + : `/api/chaturbate/biocontext?model=${encodeURIComponent(key)}&refresh=1` + + const cookieHeader = !isMFC ? buildChaturbateCookieHeader(cookies) : '' const r = await fetch(url, { cache: 'no-store', @@ -679,22 +832,68 @@ export default function ModelDetails({ } const data = (await r.json().catch(() => null)) as BioResp - const meta = { enabled: data?.enabled, fetchedAt: data?.fetchedAt, lastError: data?.lastError } + + const meta = { + enabled: data?.enabled, + fetchedAt: data?.fetchedAt, + lastError: data?.lastError, + } + + if (isMFC) { + const nextRoom = mfcRoomFromBioResp(data, key) + const nextBio = mfcBioFromBioResp(data) + + setRoomMeta(meta) + setRoom(nextRoom) + + setBioMeta(meta) + setBio(nextBio) + + const ck = mdCacheKey(provider, key) + + const onlineEntry: OnlineCacheEntry = { + at: Date.now(), + room: nextRoom, + meta, + } + + const bioEntry: BioCacheEntry = { + at: Date.now(), + bio: nextBio, + meta, + } + + mdOnlineMem.set(ck, onlineEntry) + ssSet(ssKeyOnline(provider, key), onlineEntry) + + mdBioMem.set(ck, bioEntry) + ssSet(ssKeyBio(provider, key), bioEntry) + + return + } + const nextBio = (data?.bio as BioContext) ?? null setBioMeta(meta) setBio(nextBio) + const ck = mdCacheKey(provider, key) const entry: BioCacheEntry = { at: Date.now(), bio: nextBio, meta } - mdBioMem.set(key, entry) - ssSet(ssKeyBio(key), entry) + + mdBioMem.set(ck, entry) + ssSet(ssKeyBio(provider, key), entry) } catch (e: any) { if (e?.name === 'AbortError') return - setBioMeta({ enabled: undefined, fetchedAt: undefined, lastError: e?.message || 'Fetch fehlgeschlagen' }) + + setBioMeta({ + enabled: undefined, + fetchedAt: undefined, + lastError: e?.message || 'Fetch fehlgeschlagen', + }) } finally { setBioLoading(false) } - }, [key, cookies]) + }, [key, provider, cookies]) useEffect(() => { if (open) return @@ -839,18 +1038,35 @@ export default function ModelDetails({ } }, [open, runningJobs]) - const model = useMemo(() => { - if (!key) return null - return models.find((m) => (m.modelKey || '').toLowerCase() === key) ?? null - }, [models, key]) - const storedRoomFromSnap = useMemo(() => { const raw = (model as any)?.cbOnlineJson - if (!raw || typeof raw !== 'string') return null - try { - return JSON.parse(raw) as ChaturbateRoom - } catch { - return null + + if (raw && typeof raw === 'string') { + try { + return JSON.parse(raw) as ChaturbateRoom + } catch { + // fallback unten + } + } + + if (!model) return null + + const image = String((model as any)?.imageUrl ?? '').trim() + const status = String((model as any)?.roomStatus ?? '').trim() + const chatRoomUrl = String((model as any)?.chatRoomUrl ?? '').trim() + + if (!image && !status && !chatRoomUrl) return null + + return { + username: model.modelKey, + display_name: model.modelKey, + current_show: status, + image_url: image, + image_url_360x270: image, + chat_room_url: chatRoomUrl, + gender: String((model as any)?.gender ?? '').trim(), + country: String((model as any)?.country ?? '').trim(), + tags: splitTags(model.tags), } }, [model]) @@ -908,8 +1124,13 @@ export default function ModelDetails({ const profileHref = useMemo(() => { if (!profileUsername) return '' + + if (provider === 'myfreecams') { + return `https://www.myfreecams.com/#${encodeURIComponent(profileUsername)}` + } + return `https://chaturbate.com/${encodeURIComponent(profileUsername)}` - }, [profileUsername]) + }, [profileUsername, provider]) const currentOnlineState = useMemo(() => { const roomStatus = String(model?.roomStatus ?? '').trim().toLowerCase() diff --git a/frontend/src/components/ui/RecorderSettings.tsx b/frontend/src/components/ui/RecorderSettings.tsx index 36701e0..0813b24 100644 --- a/frontend/src/components/ui/RecorderSettings.tsx +++ b/frontend/src/components/ui/RecorderSettings.tsx @@ -9,7 +9,7 @@ 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' +import { CheckIcon, ChevronDownIcon, XMarkIcon } from '@heroicons/react/24/solid' import { startRegistration } from '@simplewebauthn/browser' type RecorderSettings = { @@ -22,8 +22,9 @@ type RecorderSettings = { autoStartAddedDownloads?: boolean enableConcurrentDownloadsLimit?: boolean maxConcurrentDownloads?: number + useProviderApis?: boolean useChaturbateApi?: boolean - useMyFreeCamsWatcher?: boolean + useMyFreeCamsApi?: boolean autoDeleteSmallDownloads?: boolean autoDeleteSmallDownloadsBelowMB?: number autoDeleteSmallDownloadsKeepFavorites?: boolean @@ -141,7 +142,7 @@ function SecurityDeviceRow(props: { }) { return (
-
+
{props.icon}
@@ -178,6 +179,96 @@ function SecurityDeviceRow(props: { ) } +function SettingsSection(props: { + id: string + title: string + description?: string + icon?: ReactNode + actions?: ReactNode + defaultOpen?: boolean + children: ReactNode +}) { + const storageKey = `recorder-settings:section:${props.id}:open` + + const [open, setOpen] = useState(() => { + if (typeof window === 'undefined') return props.defaultOpen ?? true + + try { + const raw = window.localStorage.getItem(storageKey) + if (raw === '1') return true + if (raw === '0') return false + } catch { + // ignore + } + + return props.defaultOpen ?? true + }) + + function toggleOpen() { + setOpen((cur) => { + const next = !cur + + try { + window.localStorage.setItem(storageKey, next ? '1' : '0') + } catch { + // ignore + } + + return next + }) + } + + return ( +
+ + + {open ? ( +
+ {props.children} +
+ ) : null} +
+ ) +} + const DEFAULTS: RecorderSettings = { databaseUrl: '', hasDbPassword: false, @@ -188,8 +279,9 @@ const DEFAULTS: RecorderSettings = { autoStartAddedDownloads: true, enableConcurrentDownloadsLimit: false, maxConcurrentDownloads: 20, + useProviderApis: false, useChaturbateApi: false, - useMyFreeCamsWatcher: false, + useMyFreeCamsApi: false, autoDeleteSmallDownloads: true, autoDeleteSmallDownloadsBelowMB: 200, autoDeleteSmallDownloadsKeepFavorites: true, @@ -578,8 +670,15 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { maxConcurrentDownloads: (data as any).maxConcurrentDownloads ?? DEFAULTS.maxConcurrentDownloads, - useChaturbateApi: data.useChaturbateApi ?? DEFAULTS.useChaturbateApi, - useMyFreeCamsWatcher: data.useMyFreeCamsWatcher ?? DEFAULTS.useMyFreeCamsWatcher, + useProviderApis: + (data as any).useProviderApis ?? + Boolean((data as any).useChaturbateApi || (data as any).useMyFreeCamsApi), + + useChaturbateApi: + (data as any).useChaturbateApi ?? DEFAULTS.useChaturbateApi, + + useMyFreeCamsApi: + (data as any).useMyFreeCamsApi ?? DEFAULTS.useMyFreeCamsApi, autoDeleteSmallDownloads: data.autoDeleteSmallDownloads ?? DEFAULTS.autoDeleteSmallDownloads, autoDeleteSmallDownloadsBelowMB: data.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB, autoDeleteSmallDownloadsKeepFavorites: @@ -971,8 +1070,9 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { Math.min(100, Math.floor(Number((value as any).maxConcurrentDownloads ?? DEFAULTS.maxConcurrentDownloads ?? 20))) ) - const useChaturbateApi = !!value.useChaturbateApi - const useMyFreeCamsWatcher = !!value.useMyFreeCamsWatcher + const useProviderApis = !!value.useProviderApis + const useChaturbateApi = useProviderApis && !!value.useChaturbateApi + const useMyFreeCamsApi = useProviderApis && !!value.useMyFreeCamsApi const autoDeleteSmallDownloads = !!value.autoDeleteSmallDownloads const autoDeleteSmallDownloadsBelowMB = Math.max( 0, @@ -1015,8 +1115,9 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { autoStartAddedDownloads, enableConcurrentDownloadsLimit, maxConcurrentDownloads, + useProviderApis, useChaturbateApi, - useMyFreeCamsWatcher, + useMyFreeCamsApi, autoDeleteSmallDownloads, autoDeleteSmallDownloadsBelowMB, autoDeleteSmallDownloadsKeepFavorites, @@ -1605,17 +1706,14 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { ) : null} {/* Aufgaben */} -
-
-
-
Aufgaben
-
- Hintergrundaufgaben wie z.B. Asset/Preview-Generierung. -
-
-
- -
+ +
-
+ {/* Paths */} -
+
Pfad-Einstellungen
@@ -1817,10 +1921,16 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
-
+ {/* Automatisierung */} -
+
Automatisierung & Anzeige
@@ -1895,12 +2005,13 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { 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." + label="Clipboard-Links in Start-URL übernehmen" + description="Übernimmt erkannte Chaturbate- und MyFreeCams-Links aus der Zwischenablage automatisch in das Start-URL-Feld. Wenn deaktiviert, werden Clipboard-Links ignoriert." /> - + @@ -1910,23 +2021,58 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { autoAddToDownloadList: checked ? true : v.autoAddToDownloadList, })) } - label="Automatisch starten" - description="Neue Links aus der Zwischenablage werden automatisch gestartet. Aktiviert automatisch auch das Hinzufügen zur Downloadliste." + label="Übernommene Links automatisch starten" + description="Startet automatisch, sobald ein erkannter Clipboard-Link übernommen wurde oder ein aktivierter Provider ein watched Model als öffentlich online erkennt. Aktiviert automatisch auch die Clipboard-Übernahme." /> - 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, + useProviderApis: checked, + useChaturbateApi: checked ? v.useChaturbateApi : false, + useMyFreeCamsApi: checked ? v.useMyFreeCamsApi : false, + })) + } + label="Provider-APIs verwenden" + description="Master-Schalter für externe Provider-APIs. Wenn deaktiviert, werden keine automatischen API-Abfragen für Online-Status, Profilbilder oder Model-Daten ausgeführt." + /> - 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." - /> +
+ + setValue((v) => ({ + ...v, + useProviderApis: checked ? true : v.useProviderApis, + useChaturbateApi: checked, + })) + } + label="Chaturbate API" + description="Aktualisiert Online-Status, Vorschaubilder und Model-Daten über die Chaturbate-API. Wenn Automatisch starten aktiv ist, werden öffentliche watched Models gestartet." + /> + + + setValue((v) => ({ + ...v, + useProviderApis: checked ? true : v.useProviderApis, + useMyFreeCamsApi: checked, + })) + } + label="MyFreeCams API" + description="Aktualisiert Online-Status, Vorschaubilder und Model-Daten über usernameLookup. Wenn Automatisch starten aktiv ist, werden öffentliche watched Models gestartet. Bei 'servers busy' pausiert die App MyFreeCams-Anfragen automatisch." + /> +
+
{/* ✅ NEU: Auto-Delete kleine Downloads */}
@@ -2226,283 +2372,269 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { ) : null}
-
+
{/* Sicherheit */} -
-
-
-
-
-
- 🛡️ + + {statusPill(!!authStatus?.totpEnabled, '2FA aktiv', '2FA aus')} + {statusPill(!!authStatus?.passkeyConfigured, 'Passkey aktiv', 'Kein Passkey')} + + } + > +
+
+
+
+
+ Passwort ändern
- -
-
- Konto-Sicherheit -
-
- Passwort, Zwei-Faktor-Authentifizierung und Passkeys getrennt von den Recorder-Einstellungen verwalten. -
+
+ Ändert das Login-Passwort. Andere Sessions werden abgemeldet.
-
- {statusPill(!!authStatus?.totpEnabled, '2FA aktiv', '2FA aus')} - {statusPill(!!authStatus?.passkeyConfigured, 'Passkey aktiv', 'Kein Passkey')} +
+ setCurrentPassword(e.target.value)} + placeholder="Aktuelles Passwort" + autoComplete="current-password" + disabled={securityBusy} + className="rounded-lg bg-white px-3 py-2 text-sm 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" + /> + + setNewPassword(e.target.value)} + placeholder="Neues Passwort" + autoComplete="new-password" + disabled={securityBusy} + className="rounded-lg bg-white px-3 py-2 text-sm 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" + /> + + setNewPasswordRepeat(e.target.value)} + placeholder="Neues Passwort wiederholen" + autoComplete="new-password" + disabled={securityBusy} + className="rounded-lg bg-white px-3 py-2 text-sm 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" + /> +
+ +
+
-
-
-
-
-
- Passwort ändern -
-
- Ändert das Login-Passwort. Andere Sessions werden abgemeldet. -
+
+
+
+
+ Zwei-Faktor-Authentifizierung +
+
+ Authenticator-App als zweiter Faktor. Einzelne Geräte können bei TOTP nicht getrennt erkannt werden.
-
- setCurrentPassword(e.target.value)} - placeholder="Aktuelles Passwort" - autoComplete="current-password" - disabled={securityBusy} - className="rounded-lg bg-white px-3 py-2 text-sm 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" - /> - - setNewPassword(e.target.value)} - placeholder="Neues Passwort" - autoComplete="new-password" - disabled={securityBusy} - className="rounded-lg bg-white px-3 py-2 text-sm 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" - /> - - setNewPasswordRepeat(e.target.value)} - placeholder="Neues Passwort wiederholen" - autoComplete="new-password" - disabled={securityBusy} - className="rounded-lg bg-white px-3 py-2 text-sm 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" - /> -
- -
- -
+ {statusPill(!!authStatus?.totpEnabled, 'Aktiv', 'Aus')}
-
-
-
-
- Zwei-Faktor-Authentifizierung -
-
- Authenticator-App als zweiter Faktor. Einzelne Geräte können bei TOTP nicht getrennt erkannt werden. -
-
- - {statusPill(!!authStatus?.totpEnabled, 'Aktiv', 'Aus')} -
- -
- {authStatus?.totpDevice ? ( - - ) : ( -
- 2FA ist noch nicht eingerichtet. Die Einrichtung erfolgt aktuell beim nächsten Login-Setup. -
- )} -
- - {authStatus?.totpConfigured || authStatus?.totpEnabled ? ( - <> -
- { - setDisable2FAPassword(e.target.value) - setDisable2FAFieldError((cur) => ({ ...cur, password: undefined })) - }} - placeholder="Aktuelles Passwort" - autoComplete="current-password" - disabled={securityBusy} - className={[ - 'rounded-lg bg-white px-3 py-2 text-sm text-gray-900 ring-1 focus:outline-none focus:ring-2 dark:bg-white/10 dark:text-white', - disable2FAFieldError?.password - ? 'ring-red-300 focus:ring-red-500 dark:ring-red-400/50' - : 'ring-gray-200 focus:ring-indigo-500 dark:ring-white/10', - ].join(' ')} - /> - - { - setDisable2FACode(e.target.value.replace(/\D/g, '').slice(0, 6)) - setDisable2FAFieldError((cur) => ({ ...cur, code: undefined })) - }} - placeholder="2FA-Code" - inputMode="numeric" - autoComplete="one-time-code" - disabled={securityBusy} - className={[ - 'rounded-lg bg-white px-3 py-2 text-sm text-gray-900 ring-1 focus:outline-none focus:ring-2 dark:bg-white/10 dark:text-white', - disable2FAFieldError?.code - ? 'ring-red-300 focus:ring-red-500 dark:ring-red-400/50' - : 'ring-gray-200 focus:ring-indigo-500 dark:ring-white/10', - ].join(' ')} - /> -
- - {disable2FAFieldError?.password || disable2FAFieldError?.code ? ( -
- {disable2FAFieldError.password || disable2FAFieldError.code} -
- ) : ( -
- Zum Deaktivieren brauchst du dein aktuelles Passwort - {authStatus?.totpEnabled ? ' und einen gültigen 2FA-Code.' : '.'} -
- )} - -
- -
- - ) : null} -
- -
-
-
-
- Passkeys -
-
- Anmeldung ohne Passwort über Face ID, Fingerabdruck, Windows Hello oder Sicherheitsschlüssel. -
-
- - {statusPill(!!authStatus?.passkeyConfigured, 'Aktiv', 'Keine')} -
- -
- {(authStatus?.passkeys ?? []).length > 0 ? ( - (authStatus?.passkeys ?? []).map((pk, index) => ( - void deletePasskey(pk)} - > - Löschen - - } - /> - )) - ) : ( -
- Noch kein Passkey gespeichert. -
- )} -
- - {!passkeySupported ? ( -
- Dein Browser oder diese Verbindung unterstützt Passkeys aktuell nicht. -
+
+ {authStatus?.totpDevice ? ( + ) : ( -
- {passkeyFeedback ? ( -
- {passkeyFeedback.kind === 'loading' ? '⏳ ' : null} - {passkeyFeedback.kind === 'success' ? '✅ ' : null} - {passkeyFeedback.kind === 'error' ? '⚠️ ' : null} - {passkeyFeedback.text} -
- ) : null} - -
- -
+
+ 2FA ist noch nicht eingerichtet. Die Einrichtung erfolgt aktuell beim nächsten Login-Setup.
)}
+ + {authStatus?.totpConfigured || authStatus?.totpEnabled ? ( + <> +
+ { + setDisable2FAPassword(e.target.value) + setDisable2FAFieldError((cur) => ({ ...cur, password: undefined })) + }} + placeholder="Aktuelles Passwort" + autoComplete="current-password" + disabled={securityBusy} + className={[ + 'rounded-lg bg-white px-3 py-2 text-sm text-gray-900 ring-1 focus:outline-none focus:ring-2 dark:bg-white/10 dark:text-white', + disable2FAFieldError?.password + ? 'ring-red-300 focus:ring-red-500 dark:ring-red-400/50' + : 'ring-gray-200 focus:ring-indigo-500 dark:ring-white/10', + ].join(' ')} + /> + + { + setDisable2FACode(e.target.value.replace(/\D/g, '').slice(0, 6)) + setDisable2FAFieldError((cur) => ({ ...cur, code: undefined })) + }} + placeholder="2FA-Code" + inputMode="numeric" + autoComplete="one-time-code" + disabled={securityBusy} + className={[ + 'rounded-lg bg-white px-3 py-2 text-sm text-gray-900 ring-1 focus:outline-none focus:ring-2 dark:bg-white/10 dark:text-white', + disable2FAFieldError?.code + ? 'ring-red-300 focus:ring-red-500 dark:ring-red-400/50' + : 'ring-gray-200 focus:ring-indigo-500 dark:ring-white/10', + ].join(' ')} + /> +
+ + {disable2FAFieldError?.password || disable2FAFieldError?.code ? ( +
+ {disable2FAFieldError.password || disable2FAFieldError.code} +
+ ) : ( +
+ Zum Deaktivieren brauchst du dein aktuelles Passwort + {authStatus?.totpEnabled ? ' und einen gültigen 2FA-Code.' : '.'} +
+ )} + +
+ +
+ + ) : null} +
+ +
+
+
+
+ Passkeys +
+
+ Anmeldung ohne Passwort über Face ID, Fingerabdruck, Windows Hello oder Sicherheitsschlüssel. +
+
+ + {statusPill(!!authStatus?.passkeyConfigured, 'Aktiv', 'Keine')} +
+ +
+ {(authStatus?.passkeys ?? []).length > 0 ? ( + (authStatus?.passkeys ?? []).map((pk, index) => ( + void deletePasskey(pk)} + > + Löschen + + } + /> + )) + ) : ( +
+ Noch kein Passkey gespeichert. +
+ )} +
+ + {!passkeySupported ? ( +
+ Dein Browser oder diese Verbindung unterstützt Passkeys aktuell nicht. +
+ ) : ( +
+ {passkeyFeedback ? ( +
+ {passkeyFeedback.kind === 'loading' ? '⏳ ' : null} + {passkeyFeedback.kind === 'success' ? '✅ ' : null} + {passkeyFeedback.kind === 'error' ? '⚠️ ' : null} + {passkeyFeedback.text} +
+ ) : null} + +
+ +
+
+ )}
-
+
) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index e05ecd7..f1489e8 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -258,8 +258,9 @@ export type RecorderSettingsState = { autoStartAddedDownloads?: boolean enableConcurrentDownloadsLimit?: boolean maxConcurrentDownloads?: number + useProviderApis?: boolean useChaturbateApi?: boolean - useMyFreeCamsWatcher?: boolean + useMyFreeCamsApi?: boolean autoDeleteSmallDownloads?: boolean autoDeleteSmallDownloadsBelowMB?: number autoDeleteSmallDownloadsKeepFavorites?: boolean