This commit is contained in:
Linrador 2026-06-05 13:17:04 +02:00
parent 5a46814039
commit 1625952638
23 changed files with 2469 additions and 387 deletions

View File

@ -456,4 +456,33 @@ def health():
"classes": list(names.values())[:80] if isinstance(names, dict) else names, "classes": list(names.values())[:80] if isinstance(names, dict) else names,
"labelConfig": str(DETECTION_LABELS_PATH) if DETECTION_LABELS_PATH else "", "labelConfig": str(DETECTION_LABELS_PATH) if DETECTION_LABELS_PATH else "",
"labelError": _LABEL_ERROR, "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,
} }

View File

@ -31,7 +31,15 @@ var autostartSubs = map[chan autostartStatePayload]struct{}{}
func isAutostartFeatureEnabled() bool { func isAutostartFeatureEnabled() bool {
s := getSettings() s := getSettings()
return s.UseChaturbateAPI || s.UseMyFreeCamsWatcher if !s.AutoStartAddedDownloads {
return false
}
if !s.UseProviderAPIs {
return false
}
return s.UseChaturbateAPI || s.UseMyFreeCamsAPI
} }
func broadcastAutostartPaused() { func broadcastAutostartPaused() {
@ -239,9 +247,12 @@ func autostartStateStreamHandler(w http.ResponseWriter, r *http.Request) {
send := func(state autostartStatePayload) { send := func(state autostartStatePayload) {
payload := map[string]any{ payload := map[string]any{
"enabled": state.Enabled,
"active": state.Active,
"paused": state.Paused, "paused": state.Paused,
"pausedByUser": state.PausedByUser, "pausedByUser": state.PausedByUser,
"pausedByDisk": state.PausedByDisk, "pausedByDisk": state.PausedByDisk,
"reason": state.Reason,
"ts": time.Now().UTC().Format(time.RFC3339Nano), "ts": time.Now().UTC().Format(time.RFC3339Nano),
} }

View File

@ -342,10 +342,10 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
for { for {
select { select {
case <-scanTicker.C: case <-scanTicker.C:
autostartPaused := isAutostartPaused()
s := getSettings() s := getSettings()
if !s.UseChaturbateAPI { autostartPaused := isAutostartPaused() || !s.AutoStartAddedDownloads
if !s.UseProviderAPIs || !s.UseChaturbateAPI {
queue = queue[:0] queue = queue[:0]
queued = map[string]bool{} queued = map[string]bool{}
lastCookieHdr = "" lastCookieHdr = ""
@ -853,7 +853,7 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
case <-startTicker.C: case <-startTicker.C:
s := getSettings() s := getSettings()
if !s.UseChaturbateAPI { if !s.UseProviderAPIs || !s.UseChaturbateAPI || !s.AutoStartAddedDownloads || isAutostartPaused() {
continue continue
} }

View File

@ -564,7 +564,7 @@ func startChaturbateOnlinePoller(store *ModelStore) {
var tagsLast time.Time var tagsLast time.Time
refreshOnce := func(trigger string) { refreshOnce := func(trigger string) {
if !getSettings().UseChaturbateAPI { if !chaturbateProviderAPIEnabled() {
return return
} }

View File

@ -32,6 +32,7 @@ func setModelStore(store *ModelStore) {
modelStoreRef = store modelStoreRef = store
setCoverModelStore(store) setCoverModelStore(store)
setChaturbateOnlineModelStore(store) setChaturbateOnlineModelStore(store)
setMyFreeCamsOnlineModelStore(store)
} }
// ✅ umbenannt, damit es nicht mit models.go kollidiert // ✅ 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) { func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) {
setModelStore(store) setModelStore(store)
// ✅ NEU: Parse-Endpoint (nur URL erlaubt)
mux.HandleFunc("/api/models/parse", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/models/parse", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})

View File

@ -116,12 +116,8 @@ func startMyFreeCamsAutoStartWorker(store *ModelStore) {
for { for {
select { select {
case <-scanTicker.C: case <-scanTicker.C:
if isAutostartPaused() {
continue
}
s := getSettings() s := getSettings()
if !s.UseMyFreeCamsWatcher { if !s.UseProviderAPIs || !s.UseMyFreeCamsAPI || !s.AutoStartAddedDownloads || isAutostartPaused() {
queue = queue[:0] queue = queue[:0]
queued = map[string]bool{} queued = map[string]bool{}
@ -394,10 +390,8 @@ func startMyFreeCamsAutoStartWorker(store *ModelStore) {
pendingAutoStartMu.Unlock() pendingAutoStartMu.Unlock()
case <-startTicker.C: case <-startTicker.C:
if isAutostartPaused() { s := getSettings()
continue if !s.UseProviderAPIs || !s.UseMyFreeCamsAPI || !s.AutoStartAddedDownloads || isAutostartPaused() {
}
if !getSettings().UseMyFreeCamsWatcher {
continue continue
} }
if len(queue) == 0 { if len(queue) == 0 {

View File

@ -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,
})
}

View File

@ -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)
}
}

View File

@ -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/<server>/853x480/mfc_<userID>
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,
})
}

View File

@ -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
}

View File

@ -313,9 +313,6 @@ func ParseStream(html string) (string, error) {
if err := json.Unmarshal([]byte(decoded), &rd); err != nil { if err := json.Unmarshal([]byte(decoded), &rd); err != nil {
return "", appErrorf("JSON-parse failed: %w", err) return "", appErrorf("JSON-parse failed: %w", err)
} }
if rd.HLSSource == "" {
return "", errors.New("kein HLS-Quell-URL im JSON")
}
return rd.HLSSource, nil return rd.HLSSource, nil
} }

View File

@ -7,6 +7,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
@ -31,6 +32,10 @@ func RecordStreamMFC(
const waitPublicMax = 90 * time.Second const waitPublicMax = 90 * time.Second
deadline := time.Now().Add(waitPublicMax) deadline := time.Now().Add(waitPublicMax)
var lastStatus Status = StatusUnknown
var lastErr error
var lastStatusLog time.Time
for { for {
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return err return err
@ -41,11 +46,21 @@ func RecordStreamMFC(
break break
} }
if time.Now().After(deadline) { lastStatus = st
lastErr = err
if lastStatusLog.IsZero() || time.Since(lastStatusLog) >= 15*time.Second {
if err != nil { 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) time.Sleep(5 * time.Second)
@ -120,9 +135,9 @@ type MyFreeCams struct {
type mfcUsernameLookupResp struct { type mfcUsernameLookupResp struct {
Result struct { Result struct {
User struct { User struct {
ID int `json:"id"` ID int `json:"id"`
Username string `json:"username"` Username string `json:"username"`
Avatar string `json:"avatar"` Avatar mfcMaybeString `json:"avatar"`
Sessions []struct { Sessions []struct {
ServerName string `json:"server_name"` 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) 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 var data mfcUsernameLookupResp
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { if err := json.Unmarshal(raw, &data); err != nil {
return StatusUnknown, appErrorf("mfc usernameLookup json: %w", err) return StatusUnknown, appErrorf("mfc usernameLookup json for %s: %w; body=%s", username, err, snip)
} }
if data.Result.User.ID <= 0 { 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.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 { if len(data.Result.User.Sessions) == 0 {
return StatusOffline, nil return StatusOffline, nil
@ -507,21 +532,86 @@ func extractMFCUsername(input string) string {
return "" return ""
} }
// 1) URL mit Fragment (#username) if u, err := url.Parse(s); err == nil {
if u, err := url.Parse(s); err == nil && u.Fragment != "" { if frag := strings.TrimSpace(u.Fragment); frag != "" {
return strings.Trim(strings.TrimSpace(u.Fragment), "/") if name := extractMFCUsernameFromPathish(frag); name != "" {
} return name
}
// 2) URL Pfad: letztes Segment nehmen }
if u, err := url.Parse(s); err == nil && u.Host != "" {
p := strings.Trim(u.Path, "/") if u.Host != "" {
if p == "" { if name := extractMFCUsernameFromPathish(u.Path); name != "" {
return "" return name
}
} }
parts := strings.Split(p, "/")
return strings.TrimSpace(parts[len(parts)-1])
} }
// 3) Fallback: raw return sanitizeMFCUsernamePart(s)
return 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)
} }

View File

@ -1151,6 +1151,11 @@ func runMFCRecording(
_ = os.MkdirAll(recordDirAbs, 0o755) _ = os.MkdirAll(recordDirAbs, 0o755)
username := extractMFCUsername(req.URL) 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")) filename := fmt.Sprintf("%s_%s.ts", username, now.Format("01_02_2006__15-04-05"))
outPath := filepath.Join(recordDirAbs, filename) outPath := filepath.Join(recordDirAbs, filename)

View File

@ -1,17 +1,19 @@
{ {
"databaseUrl": "postgres://postgres@127.0.0.1:5432/nsfwapp?sslmode=disable", "databaseUrl": "postgres://postgres@127.0.0.1:5432/nsfwapp?sslmode=disable",
"encryptedDbPassword": "vkNvHM+/AgEkRPW0X+rDDys4bPb5Q7ZPGqPSADCluIJ6GCDGNGlDy3s=", "encryptedDbPassword": "vkNvHM+/AgEkRPW0X+rDDys4bPb5Q7ZPGqPSADCluIJ6GCDGNGlDy3s=",
"recordDir": "C:\\Users\\Chris\\Desktop\\privat\\records", "recordDir": "C:\\Users\\Rother\\Desktop\\Privat\\records",
"doneDir": "C:\\Users\\Chris\\Desktop\\privat\\records\\done", "doneDir": "C:\\Users\\Rother\\Desktop\\Privat\\records\\done",
"ffmpegPath": "", "ffmpegPath": "",
"autoAddToDownloadList": true, "autoAddToDownloadList": true,
"autoStartAddedDownloads": true,
"enableConcurrentDownloadsLimit": true, "enableConcurrentDownloadsLimit": true,
"maxConcurrentDownloads": 80, "maxConcurrentDownloads": 80,
"useProviderApis": true,
"useChaturbateApi": true, "useChaturbateApi": true,
"useMyFreeCamsWatcher": true, "useMyFreeCamsApi": true,
"autoDeleteSmallDownloads": true, "autoDeleteSmallDownloads": true,
"autoDeleteSmallDownloadsBelowMB": 300, "autoDeleteSmallDownloadsBelowMB": 300,
"autoDeleteSmallDownloadsKeepFavorites": false, "autoDeleteSmallDownloadsKeepFavorites": true,
"lowDiskPauseBelowGB": 5, "lowDiskPauseBelowGB": 5,
"blurPreviews": false, "blurPreviews": false,
"teaserPlayback": "all", "teaserPlayback": "all",

View File

@ -104,6 +104,9 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler) api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler)
api.HandleFunc("/api/chaturbate/biocontext", chaturbateBioContextHandler) 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/teaser", generatedTeaser)
api.HandleFunc("/api/generated/cover", generatedCover) api.HandleFunc("/api/generated/cover", generatedCover)
api.HandleFunc("/api/generated/coverinfo/list", generatedCoverInfoList) api.HandleFunc("/api/generated/coverinfo/list", generatedCoverInfoList)
@ -132,6 +135,7 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
setCoverModelStore(store) setCoverModelStore(store)
RegisterModelAPI(api, store) RegisterModelAPI(api, store)
setChaturbateOnlineModelStore(store) setChaturbateOnlineModelStore(store)
setMyFreeCamsOnlineModelStore(store)
// -------------------------- // --------------------------
// 4) Mount Protected API // 4) Mount Protected API

View File

@ -715,6 +715,7 @@ func main() {
// Hintergrund-Worker // Hintergrund-Worker
go startDiskSpaceGuard() go startDiskSpaceGuard()
go startChaturbateOnlinePoller(store) go startChaturbateOnlinePoller(store)
go startMyFreeCamsOnlinePoller(store)
go startChaturbateAutoStartWorker(store) go startChaturbateAutoStartWorker(store)
go startMyFreeCamsAutoStartWorker(store) go startMyFreeCamsAutoStartWorker(store)

View File

@ -22,11 +22,14 @@ type RecorderSettings struct {
FFmpegPath string `json:"ffmpegPath"` FFmpegPath string `json:"ffmpegPath"`
AutoAddToDownloadList bool `json:"autoAddToDownloadList"` AutoAddToDownloadList bool `json:"autoAddToDownloadList"`
AutoStartAddedDownloads bool `json:"autoStartAddedDownloads"`
EnableConcurrentDownloadsLimit bool `json:"enableConcurrentDownloadsLimit"` EnableConcurrentDownloadsLimit bool `json:"enableConcurrentDownloadsLimit"`
MaxConcurrentDownloads int `json:"maxConcurrentDownloads"` MaxConcurrentDownloads int `json:"maxConcurrentDownloads"`
UseChaturbateAPI bool `json:"useChaturbateApi"` UseProviderAPIs bool `json:"useProviderApis"`
UseMyFreeCamsWatcher bool `json:"useMyFreeCamsWatcher"` UseChaturbateAPI bool `json:"useChaturbateApi"`
UseMyFreeCamsAPI bool `json:"useMyFreeCamsApi"`
// Wenn aktiv, werden fertige Downloads automatisch gelöscht, wenn sie kleiner als der Grenzwert sind. // Wenn aktiv, werden fertige Downloads automatisch gelöscht, wenn sie kleiner als der Grenzwert sind.
AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"` AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"`
AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"` AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"`
@ -64,11 +67,14 @@ var (
FFmpegPath: "", FFmpegPath: "",
AutoAddToDownloadList: false, AutoAddToDownloadList: false,
AutoStartAddedDownloads: true,
EnableConcurrentDownloadsLimit: false, EnableConcurrentDownloadsLimit: false,
MaxConcurrentDownloads: 20, MaxConcurrentDownloads: 20,
UseChaturbateAPI: false, UseProviderAPIs: false,
UseMyFreeCamsWatcher: false, UseChaturbateAPI: false,
UseMyFreeCamsAPI: false,
AutoDeleteSmallDownloads: false, AutoDeleteSmallDownloads: false,
AutoDeleteSmallDownloadsBelowMB: 50, AutoDeleteSmallDownloadsBelowMB: 50,
AutoDeleteSmallDownloadsKeepFavorites: true, AutoDeleteSmallDownloadsKeepFavorites: true,
@ -236,11 +242,13 @@ type RecorderSettingsPublic struct {
HasDBPassword bool `json:"hasDbPassword"` HasDBPassword bool `json:"hasDbPassword"`
AutoAddToDownloadList bool `json:"autoAddToDownloadList"` AutoAddToDownloadList bool `json:"autoAddToDownloadList"`
AutoStartAddedDownloads bool `json:"autoStartAddedDownloads"`
EnableConcurrentDownloadsLimit bool `json:"enableConcurrentDownloadsLimit"` EnableConcurrentDownloadsLimit bool `json:"enableConcurrentDownloadsLimit"`
MaxConcurrentDownloads int `json:"maxConcurrentDownloads"` MaxConcurrentDownloads int `json:"maxConcurrentDownloads"`
UseChaturbateAPI bool `json:"useChaturbateApi"` UseProviderAPIs bool `json:"useProviderApis"`
UseMyFreeCamsWatcher bool `json:"useMyFreeCamsWatcher"` UseChaturbateAPI bool `json:"useChaturbateApi"`
UseMyFreeCamsAPI bool `json:"useMyFreeCamsApi"`
AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"` AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"`
AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"` AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"`
@ -274,11 +282,13 @@ func toPublicSettings(s RecorderSettings) RecorderSettingsPublic {
HasDBPassword: strings.TrimSpace(s.EncryptedDBPassword) != "", HasDBPassword: strings.TrimSpace(s.EncryptedDBPassword) != "",
AutoAddToDownloadList: s.AutoAddToDownloadList, AutoAddToDownloadList: s.AutoAddToDownloadList,
AutoStartAddedDownloads: s.AutoStartAddedDownloads,
EnableConcurrentDownloadsLimit: s.EnableConcurrentDownloadsLimit, EnableConcurrentDownloadsLimit: s.EnableConcurrentDownloadsLimit,
MaxConcurrentDownloads: s.MaxConcurrentDownloads, MaxConcurrentDownloads: s.MaxConcurrentDownloads,
UseChaturbateAPI: s.UseChaturbateAPI, UseProviderAPIs: s.UseProviderAPIs,
UseMyFreeCamsWatcher: s.UseMyFreeCamsWatcher, UseChaturbateAPI: s.UseChaturbateAPI,
UseMyFreeCamsAPI: s.UseMyFreeCamsAPI,
AutoDeleteSmallDownloads: s.AutoDeleteSmallDownloads, AutoDeleteSmallDownloads: s.AutoDeleteSmallDownloads,
AutoDeleteSmallDownloadsBelowMB: s.AutoDeleteSmallDownloadsBelowMB, AutoDeleteSmallDownloadsBelowMB: s.AutoDeleteSmallDownloadsBelowMB,
@ -442,11 +452,13 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
next.EncryptedDBPassword = in.EncryptedDBPassword next.EncryptedDBPassword = in.EncryptedDBPassword
next.AutoAddToDownloadList = in.AutoAddToDownloadList next.AutoAddToDownloadList = in.AutoAddToDownloadList
next.AutoStartAddedDownloads = in.AutoStartAddedDownloads
next.EnableConcurrentDownloadsLimit = in.EnableConcurrentDownloadsLimit next.EnableConcurrentDownloadsLimit = in.EnableConcurrentDownloadsLimit
next.MaxConcurrentDownloads = in.MaxConcurrentDownloads next.MaxConcurrentDownloads = in.MaxConcurrentDownloads
next.UseProviderAPIs = in.UseProviderAPIs
next.UseChaturbateAPI = in.UseChaturbateAPI next.UseChaturbateAPI = in.UseChaturbateAPI
next.UseMyFreeCamsWatcher = in.UseMyFreeCamsWatcher next.UseMyFreeCamsAPI = in.UseMyFreeCamsAPI
next.AutoDeleteSmallDownloads = in.AutoDeleteSmallDownloads next.AutoDeleteSmallDownloads = in.AutoDeleteSmallDownloads
next.AutoDeleteSmallDownloadsBelowMB = in.AutoDeleteSmallDownloadsBelowMB next.AutoDeleteSmallDownloadsBelowMB = in.AutoDeleteSmallDownloadsBelowMB
@ -514,6 +526,7 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
if newStore != nil { if newStore != nil {
setModelStore(newStore) setModelStore(newStore)
setChaturbateOnlineModelStore(newStore) setChaturbateOnlineModelStore(newStore)
setMyFreeCamsOnlineModelStore(newStore)
} }
// ✅ ffmpeg/ffprobe nach Änderungen neu bestimmen // ✅ ffmpeg/ffprobe nach Änderungen neu bestimmen

View File

@ -10,6 +10,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"math" "math"
"math/rand" "math/rand"
"net/http" "net/http"
@ -2436,6 +2437,10 @@ func trainingRunJob(ctx context.Context, root string, count int) {
} }
} }
if detectorStatus == "trained" {
reloadAIServerModelAfterTraining()
}
trainingSetJobStatus(func(s *TrainingJobStatus) { trainingSetJobStatus(func(s *TrainingJobStatus) {
finishedAt := time.Now().UTC() finishedAt := time.Now().UTC()
@ -3329,6 +3334,39 @@ func trainingCreateUncertainNextSampleWithProgress(startedAtMs int64, requestID
return best.sample, nil 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) { func trainingDeleteSampleFiles(root string, sampleID string) {
sampleID = strings.TrimSpace(sampleID) sampleID = strings.TrimSpace(sampleID)
if sampleID == "" || strings.Contains(sampleID, "/") || strings.Contains(sampleID, "\\") { if sampleID == "" || strings.Contains(sampleID, "/") || strings.Contains(sampleID, "\\") {

View File

@ -65,8 +65,9 @@ const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = {
autoStartAddedDownloads: false, autoStartAddedDownloads: false,
enableConcurrentDownloadsLimit: false, enableConcurrentDownloadsLimit: false,
maxConcurrentDownloads: 20, maxConcurrentDownloads: 20,
useProviderApis: false,
useChaturbateApi: false, useChaturbateApi: false,
useMyFreeCamsWatcher: false, useMyFreeCamsApi: false,
autoDeleteSmallDownloads: false, autoDeleteSmallDownloads: false,
autoDeleteSmallDownloadsBelowMB: 200, autoDeleteSmallDownloadsBelowMB: 200,
autoDeleteSmallDownloadsKeepFavorites: true, autoDeleteSmallDownloadsKeepFavorites: true,
@ -2503,7 +2504,12 @@ export default function App() {
// Nur automatische Starts in pending-autostart schieben. // Nur automatische Starts in pending-autostart schieben.
// Manuelle UI-Starts müssen sofort direkt starten. // Manuelle UI-Starts müssen sofort direkt starten.
if (!immediate) { if (!immediate) {
if (provider === 'chaturbate' && recSettingsRef.current.useChaturbateApi && keyLower) { if (
provider === 'chaturbate' &&
recSettingsRef.current.useProviderApis &&
recSettingsRef.current.useChaturbateApi &&
keyLower
) {
queuePendingAutoStart(keyLower, norm, 'unknown', undefined, { queuePendingAutoStart(keyLower, norm, 'unknown', undefined, {
mode: 'probe_retry', mode: 'probe_retry',
source: 'manual', source: 'manual',
@ -2511,7 +2517,12 @@ export default function App() {
return true return true
} }
if (provider === 'mfc' && recSettingsRef.current.useMyFreeCamsWatcher && keyLower) { if (
provider === 'mfc' &&
recSettingsRef.current.useProviderApis &&
recSettingsRef.current.useMyFreeCamsApi &&
keyLower
) {
queuePendingAutoStart(keyLower, norm, 'offline', undefined, { queuePendingAutoStart(keyLower, norm, 'offline', undefined, {
mode: 'probe_retry', mode: 'probe_retry',
source: 'manual', source: 'manual',
@ -3837,7 +3848,7 @@ export default function App() {
if (autoAddEnabled) setSourceUrl(url) if (autoAddEnabled) setSourceUrl(url)
if (autoStartEnabled) { if (autoStartEnabled) {
void startUrl(url, { silent: false }) void startUrl(url, { silent: false, immediate: true })
} }
} catch { } catch {
// ignore // ignore

View File

@ -97,11 +97,29 @@ function ssSet(key: string, value: any) {
} }
} }
function ssKeyOnline(modelKey: string) { type ProviderKey = 'chaturbate' | 'myfreecams'
return `md:cb:online:${modelKey}`
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') const nf = new Intl.NumberFormat('de-DE')
@ -403,11 +421,17 @@ type BioResp = {
fetchedAt?: string fetchedAt?: string
lastError?: string lastError?: string
model?: string model?: string
bio?: BioContext | null bio?: BioContext | any | null
roomStatus?: string roomStatus?: string
isOnline?: boolean isOnline?: boolean
chatRoomUrl?: string chatRoomUrl?: string
imageUrl?: string imageUrl?: string
avatarUrl?: string
snapUrl?: string
roomTopic?: string
country?: string
gender?: string
} }
// ------ props ------ // ------ props ------
@ -416,8 +440,13 @@ type BioResp = {
// /api/models liefert StoredModel aus dem models_store // /api/models liefert StoredModel aus dem models_store
type StoredModel = { type StoredModel = {
id: string id: string
host?: string | null
modelKey: string modelKey: string
tags?: string | null tags?: string | null
gender?: string | null
country?: string | null
lastSeenOnline?: boolean | null lastSeenOnline?: boolean | null
lastSeenOnlineAt?: string lastSeenOnlineAt?: string
@ -511,6 +540,116 @@ function buildChaturbateCookieHeader(cookies?: Record<string, string>): string {
return parts.join('; ') 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({ export default function ModelDetails({
open, open,
@ -587,6 +726,13 @@ export default function ModelDetails({
const removingKeys = useMemo(() => new Set<string>(), []) const removingKeys = useMemo(() => new Set<string>(), [])
const deletedKeys = useMemo(() => new Set<string>(), []) const deletedKeys = useMemo(() => new Set<string>(), [])
const model = useMemo(() => {
if (!key) return null
return models.find((m) => (m.modelKey || '').toLowerCase() === key) ?? null
}, [models, key])
const provider = useMemo<ProviderKey>(() => providerOfModel(model), [model])
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
bioReqRef.current?.abort() bioReqRef.current?.abort()
@ -622,8 +768,10 @@ export default function ModelDetails({
if (!open || !key) return if (!open || !key) return
// Online // Online
const memOnline = mdOnlineMem.get(key) const ck = mdCacheKey(provider, key)
const ssOnline = ssGet<OnlineCacheEntry>(ssKeyOnline(key))
const memOnline = mdOnlineMem.get(ck)
const ssOnline = ssGet<OnlineCacheEntry>(ssKeyOnline(provider, key))
const onlineHit = const onlineHit =
(memOnline && isFresh(memOnline.at) ? memOnline : null) || (memOnline && isFresh(memOnline.at) ? memOnline : null) ||
@ -635,8 +783,8 @@ export default function ModelDetails({
} }
// Bio // Bio
const memBio = mdBioMem.get(key) const memBio = mdBioMem.get(ck)
const ssBio = ssGet<BioCacheEntry>(ssKeyBio(key)) const ssBio = ssGet<BioCacheEntry>(ssKeyBio(provider, key))
const bioHit = const bioHit =
(memBio && isFresh(memBio.at) ? memBio : null) || (memBio && isFresh(memBio.at) ? memBio : null) ||
@ -647,7 +795,7 @@ export default function ModelDetails({
setBioMeta(bioHit.meta ?? null) setBioMeta(bioHit.meta ?? null)
// bioLoading NICHT anfassen Fetch kann trotzdem laufen // bioLoading NICHT anfassen Fetch kann trotzdem laufen
} }
}, [open, key]) }, [open, key, provider])
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
@ -657,15 +805,20 @@ export default function ModelDetails({
const refreshBio = useCallback(async () => { const refreshBio = useCallback(async () => {
if (!key) return if (!key) return
// vorherigen abbrechen
bioReqRef.current?.abort() bioReqRef.current?.abort()
const ac = new AbortController() const ac = new AbortController()
bioReqRef.current = ac bioReqRef.current = ac
setBioLoading(true) setBioLoading(true)
try { try {
const cookieHeader = buildChaturbateCookieHeader(cookies) const isMFC = provider === 'myfreecams'
const url = `/api/chaturbate/biocontext?model=${encodeURIComponent(key)}&refresh=1`
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, { const r = await fetch(url, {
cache: 'no-store', cache: 'no-store',
@ -679,22 +832,68 @@ export default function ModelDetails({
} }
const data = (await r.json().catch(() => null)) as BioResp 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 const nextBio = (data?.bio as BioContext) ?? null
setBioMeta(meta) setBioMeta(meta)
setBio(nextBio) setBio(nextBio)
const ck = mdCacheKey(provider, key)
const entry: BioCacheEntry = { at: Date.now(), bio: nextBio, meta } 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) { } catch (e: any) {
if (e?.name === 'AbortError') return 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 { } finally {
setBioLoading(false) setBioLoading(false)
} }
}, [key, cookies]) }, [key, provider, cookies])
useEffect(() => { useEffect(() => {
if (open) return if (open) return
@ -839,18 +1038,35 @@ export default function ModelDetails({
} }
}, [open, runningJobs]) }, [open, runningJobs])
const model = useMemo(() => {
if (!key) return null
return models.find((m) => (m.modelKey || '').toLowerCase() === key) ?? null
}, [models, key])
const storedRoomFromSnap = useMemo<ChaturbateRoom | null>(() => { const storedRoomFromSnap = useMemo<ChaturbateRoom | null>(() => {
const raw = (model as any)?.cbOnlineJson const raw = (model as any)?.cbOnlineJson
if (!raw || typeof raw !== 'string') return null
try { if (raw && typeof raw === 'string') {
return JSON.parse(raw) as ChaturbateRoom try {
} catch { return JSON.parse(raw) as ChaturbateRoom
return null } 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]) }, [model])
@ -908,8 +1124,13 @@ export default function ModelDetails({
const profileHref = useMemo(() => { const profileHref = useMemo(() => {
if (!profileUsername) return '' if (!profileUsername) return ''
if (provider === 'myfreecams') {
return `https://www.myfreecams.com/#${encodeURIComponent(profileUsername)}`
}
return `https://chaturbate.com/${encodeURIComponent(profileUsername)}` return `https://chaturbate.com/${encodeURIComponent(profileUsername)}`
}, [profileUsername]) }, [profileUsername, provider])
const currentOnlineState = useMemo(() => { const currentOnlineState = useMemo(() => {
const roomStatus = String(model?.roomStatus ?? '').trim().toLowerCase() const roomStatus = String(model?.roomStatus ?? '').trim().toLowerCase()

View File

@ -9,7 +9,7 @@ import Task from './Task'
import TaskList from './TaskList' import TaskList from './TaskList'
import type { TaskItem } from './TaskList' import type { TaskItem } from './TaskList'
import PostgresUrlModal from './PostgresUrlModal' 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' import { startRegistration } from '@simplewebauthn/browser'
type RecorderSettings = { type RecorderSettings = {
@ -22,8 +22,9 @@ type RecorderSettings = {
autoStartAddedDownloads?: boolean autoStartAddedDownloads?: boolean
enableConcurrentDownloadsLimit?: boolean enableConcurrentDownloadsLimit?: boolean
maxConcurrentDownloads?: number maxConcurrentDownloads?: number
useProviderApis?: boolean
useChaturbateApi?: boolean useChaturbateApi?: boolean
useMyFreeCamsWatcher?: boolean useMyFreeCamsApi?: boolean
autoDeleteSmallDownloads?: boolean autoDeleteSmallDownloads?: boolean
autoDeleteSmallDownloadsBelowMB?: number autoDeleteSmallDownloadsBelowMB?: number
autoDeleteSmallDownloadsKeepFavorites?: boolean autoDeleteSmallDownloadsKeepFavorites?: boolean
@ -141,7 +142,7 @@ function SecurityDeviceRow(props: {
}) { }) {
return ( return (
<div className="flex items-start gap-3 rounded-xl border border-gray-200 bg-white p-3 dark:border-white/10 dark:bg-gray-950/40"> <div className="flex items-start gap-3 rounded-xl border border-gray-200 bg-white p-3 dark:border-white/10 dark:bg-gray-950/40">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gray-100 text-lg dark:bg-white/10"> <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gray-100 text-lg shadow-sm ring-1 ring-gray-200 dark:bg-white/10 dark:ring-white/10">
{props.icon} {props.icon}
</div> </div>
@ -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 (
<section className="overflow-hidden rounded-2xl bg-white shadow-md shadow-gray-200/70 ring-1 ring-gray-300/80 transition-shadow hover:shadow-lg dark:bg-gray-950/70 dark:shadow-black/30 dark:ring-white/15">
<button
type="button"
onClick={toggleOpen}
className="flex w-full items-start justify-between gap-4 rounded-2xl bg-white/95 p-4 text-left transition hover:bg-gray-50 focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 dark:bg-gray-950/70 dark:hover:bg-white/5 dark:focus-visible:ring-indigo-400"
aria-expanded={open}
>
<div className="min-w-0">
<div className="flex items-start gap-3">
{props.icon ? (
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gray-100 text-lg dark:bg-white/10">
{props.icon}
</div>
) : null}
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-900 dark:text-white">
{props.title}
</div>
{props.description ? (
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
{props.description}
</div>
) : null}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{props.actions}
<ChevronDownIcon
className={[
'h-5 w-5 text-gray-400 transition-transform duration-200 dark:text-gray-500',
open ? 'rotate-180' : '',
].join(' ')}
/>
</div>
</button>
{open ? (
<div className="border-t border-gray-200 p-4 pt-3 dark:border-white/10">
{props.children}
</div>
) : null}
</section>
)
}
const DEFAULTS: RecorderSettings = { const DEFAULTS: RecorderSettings = {
databaseUrl: '', databaseUrl: '',
hasDbPassword: false, hasDbPassword: false,
@ -188,8 +279,9 @@ const DEFAULTS: RecorderSettings = {
autoStartAddedDownloads: true, autoStartAddedDownloads: true,
enableConcurrentDownloadsLimit: false, enableConcurrentDownloadsLimit: false,
maxConcurrentDownloads: 20, maxConcurrentDownloads: 20,
useProviderApis: false,
useChaturbateApi: false, useChaturbateApi: false,
useMyFreeCamsWatcher: false, useMyFreeCamsApi: false,
autoDeleteSmallDownloads: true, autoDeleteSmallDownloads: true,
autoDeleteSmallDownloadsBelowMB: 200, autoDeleteSmallDownloadsBelowMB: 200,
autoDeleteSmallDownloadsKeepFavorites: true, autoDeleteSmallDownloadsKeepFavorites: true,
@ -578,8 +670,15 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
maxConcurrentDownloads: maxConcurrentDownloads:
(data as any).maxConcurrentDownloads ?? DEFAULTS.maxConcurrentDownloads, (data as any).maxConcurrentDownloads ?? DEFAULTS.maxConcurrentDownloads,
useChaturbateApi: data.useChaturbateApi ?? DEFAULTS.useChaturbateApi, useProviderApis:
useMyFreeCamsWatcher: data.useMyFreeCamsWatcher ?? DEFAULTS.useMyFreeCamsWatcher, (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, autoDeleteSmallDownloads: data.autoDeleteSmallDownloads ?? DEFAULTS.autoDeleteSmallDownloads,
autoDeleteSmallDownloadsBelowMB: data.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB, autoDeleteSmallDownloadsBelowMB: data.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB,
autoDeleteSmallDownloadsKeepFavorites: autoDeleteSmallDownloadsKeepFavorites:
@ -971,8 +1070,9 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
Math.min(100, Math.floor(Number((value as any).maxConcurrentDownloads ?? DEFAULTS.maxConcurrentDownloads ?? 20))) Math.min(100, Math.floor(Number((value as any).maxConcurrentDownloads ?? DEFAULTS.maxConcurrentDownloads ?? 20)))
) )
const useChaturbateApi = !!value.useChaturbateApi const useProviderApis = !!value.useProviderApis
const useMyFreeCamsWatcher = !!value.useMyFreeCamsWatcher const useChaturbateApi = useProviderApis && !!value.useChaturbateApi
const useMyFreeCamsApi = useProviderApis && !!value.useMyFreeCamsApi
const autoDeleteSmallDownloads = !!value.autoDeleteSmallDownloads const autoDeleteSmallDownloads = !!value.autoDeleteSmallDownloads
const autoDeleteSmallDownloadsBelowMB = Math.max( const autoDeleteSmallDownloadsBelowMB = Math.max(
0, 0,
@ -1015,8 +1115,9 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
autoStartAddedDownloads, autoStartAddedDownloads,
enableConcurrentDownloadsLimit, enableConcurrentDownloadsLimit,
maxConcurrentDownloads, maxConcurrentDownloads,
useProviderApis,
useChaturbateApi, useChaturbateApi,
useMyFreeCamsWatcher, useMyFreeCamsApi,
autoDeleteSmallDownloads, autoDeleteSmallDownloads,
autoDeleteSmallDownloadsBelowMB, autoDeleteSmallDownloadsBelowMB,
autoDeleteSmallDownloadsKeepFavorites, autoDeleteSmallDownloadsKeepFavorites,
@ -1605,17 +1706,14 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
) : null} ) : null}
{/* Aufgaben */} {/* Aufgaben */}
<div className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-950/40"> <SettingsSection
<div className="flex items-start justify-between gap-4"> id="tasks"
<div className="min-w-0"> title="Aufgaben"
<div className="text-sm font-semibold text-gray-900 dark:text-white">Aufgaben</div> description="Hintergrundaufgaben wie z.B. Asset/Preview-Generierung."
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300"> icon="⚙️"
Hintergrundaufgaben wie z.B. Asset/Preview-Generierung. defaultOpen={true}
</div> >
</div> <div className="space-y-3">
</div>
<div className="mt-3 space-y-3">
<Task <Task
title="Assets-Generator" title="Assets-Generator"
description="Erzeugt nur fehlende oder unvollständige Assets der aktivierten Phasen. Nicht ausgewählte Phasen werden komplett übersprungen." description="Erzeugt nur fehlende oder unvollständige Assets der aktivierten Phasen. Nicht ausgewählte Phasen werden komplett übersprungen."
@ -1701,10 +1799,16 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
}} }}
/> />
</div> </div>
</div> </SettingsSection>
{/* Paths */} {/* Paths */}
<div className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-950/40"> <SettingsSection
id="paths"
title="Pfad-Einstellungen"
description="Aufnahme- und Zielverzeichnisse sowie optionaler ffmpeg-Pfad."
icon="📁"
defaultOpen={false}
>
<div className="mb-3"> <div className="mb-3">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Pfad-Einstellungen</div> <div className="text-sm font-semibold text-gray-900 dark:text-white">Pfad-Einstellungen</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300"> <div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
@ -1817,10 +1921,16 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
</div> </div>
</div> </div>
</div> </div>
</div> </SettingsSection>
{/* Automatisierung */} {/* Automatisierung */}
<div className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-950/40"> <SettingsSection
id="automation"
title="Automatisierung & Anzeige"
description="Verhalten beim Hinzufügen/Starten sowie Anzeigeoptionen."
icon="🤖"
defaultOpen={false}
>
<div className="mb-3"> <div className="mb-3">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Automatisierung & Anzeige</div> <div className="text-sm font-semibold text-gray-900 dark:text-white">Automatisierung & Anzeige</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300"> <div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
@ -1895,12 +2005,13 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
setValue((v) => ({ setValue((v) => ({
...v, ...v,
autoAddToDownloadList: checked, autoAddToDownloadList: checked,
autoStartAddedDownloads: checked ? v.autoStartAddedDownloads : false,
})) }))
} }
label="Automatisch zur Downloadliste hinzufügen" label="Clipboard-Links in Start-URL übernehmen"
description="Neue Links/Modelle werden automatisch in die Downloadliste übernommen." description="Übernimmt erkannte Chaturbate- und MyFreeCams-Links aus der Zwischenablage automatisch in das Start-URL-Feld. Wenn deaktiviert, werden Clipboard-Links ignoriert."
/> />
<LabeledSwitch <LabeledSwitch
checked={!!value.autoStartAddedDownloads} checked={!!value.autoStartAddedDownloads}
onChange={(checked) => onChange={(checked) =>
@ -1910,23 +2021,58 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
autoAddToDownloadList: checked ? true : v.autoAddToDownloadList, autoAddToDownloadList: checked ? true : v.autoAddToDownloadList,
})) }))
} }
label="Automatisch starten" label="Übernommene Links automatisch starten"
description="Neue Links aus der Zwischenablage werden automatisch gestartet. Aktiviert automatisch auch das Hinzufügen zur Downloadliste." 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."
/> />
<LabeledSwitch <div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
checked={!!value.useChaturbateApi} <LabeledSwitch
onChange={(checked) => setValue((v) => ({ ...v, useChaturbateApi: checked }))} checked={!!value.useProviderApis}
label="Chaturbate API" onChange={(checked) =>
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."
/>
<LabeledSwitch <div
checked={!!value.useMyFreeCamsWatcher} className={
onChange={(checked) => setValue((v) => ({ ...v, useMyFreeCamsWatcher: checked }))} 'mt-3 space-y-3 border-t border-gray-200 pt-3 dark:border-white/10 ' +
label="MyFreeCams Auto-Check (watched)" (!value.useProviderApis ? 'opacity-50 pointer-events-none' : '')
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." }
/> >
<LabeledSwitch
checked={!!value.useChaturbateApi}
onChange={(checked) =>
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."
/>
<LabeledSwitch
checked={!!value.useMyFreeCamsApi}
onChange={(checked) =>
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."
/>
</div>
</div>
{/* ✅ NEU: Auto-Delete kleine Downloads */} {/* ✅ NEU: Auto-Delete kleine Downloads */}
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5"> <div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
@ -2226,283 +2372,269 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
) : null} ) : null}
</div> </div>
</div> </div>
</div> </SettingsSection>
{/* Sicherheit */} {/* Sicherheit */}
<section className="mt-8 rounded-3xl border-2 border-indigo-200 bg-indigo-50/70 p-1 shadow-sm dark:border-indigo-400/20 dark:bg-indigo-500/5"> <SettingsSection
<div className="rounded-[1.35rem] bg-white p-4 dark:bg-gray-950/70"> id="security"
<div className="mb-4 flex flex-col gap-3 border-b border-indigo-100 pb-4 dark:border-indigo-400/10 sm:flex-row sm:items-start sm:justify-between"> title="Konto-Sicherheit"
<div className="min-w-0"> description="Passwort, Zwei-Faktor-Authentifizierung und Passkeys getrennt von den Recorder-Einstellungen verwalten."
<div className="flex flex-wrap items-center gap-2"> icon="🛡️"
<div className="flex h-9 w-9 items-center justify-center rounded-2xl bg-indigo-100 text-lg dark:bg-indigo-500/15"> defaultOpen={false}
🛡 actions={
<>
{statusPill(!!authStatus?.totpEnabled, '2FA aktiv', '2FA aus')}
{statusPill(!!authStatus?.passkeyConfigured, 'Passkey aktiv', 'Kein Passkey')}
</>
}
>
<div className="space-y-4">
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-gray-900 dark:text-white">
Passwort ändern
</div> </div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
<div> Ändert das Login-Passwort. Andere Sessions werden abgemeldet.
<div className="text-base font-semibold text-gray-900 dark:text-white">
Konto-Sicherheit
</div>
<div className="mt-0.5 text-xs text-gray-600 dark:text-gray-300">
Passwort, Zwei-Faktor-Authentifizierung und Passkeys getrennt von den Recorder-Einstellungen verwalten.
</div>
</div> </div>
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center gap-2 sm:justify-end"> <div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-3">
{statusPill(!!authStatus?.totpEnabled, '2FA aktiv', '2FA aus')} <input
{statusPill(!!authStatus?.passkeyConfigured, 'Passkey aktiv', 'Kein Passkey')} type="password"
value={currentPassword}
onChange={(e) => 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"
/>
<input
type="password"
value={newPassword}
onChange={(e) => 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"
/>
<input
type="password"
value={newPasswordRepeat}
onChange={(e) => 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"
/>
</div>
<div className="mt-3 flex justify-end">
<Button
variant="primary"
disabled={securityBusy}
onClick={() => void changePassword()}
>
Passwort speichern
</Button>
</div> </div>
</div> </div>
<div className="space-y-4"> <div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5"> <div className="flex items-start justify-between gap-3">
<div className="flex items-start justify-between gap-3"> <div>
<div> <div className="text-sm font-semibold text-gray-900 dark:text-white">
<div className="text-sm font-semibold text-gray-900 dark:text-white"> Zwei-Faktor-Authentifizierung
Passwort ändern </div>
</div> <div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300"> Authenticator-App als zweiter Faktor. Einzelne Geräte können bei TOTP nicht getrennt erkannt werden.
Ändert das Login-Passwort. Andere Sessions werden abgemeldet.
</div>
</div> </div>
</div> </div>
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-3"> {statusPill(!!authStatus?.totpEnabled, 'Aktiv', 'Aus')}
<input
type="password"
value={currentPassword}
onChange={(e) => 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"
/>
<input
type="password"
value={newPassword}
onChange={(e) => 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"
/>
<input
type="password"
value={newPasswordRepeat}
onChange={(e) => 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"
/>
</div>
<div className="mt-3 flex justify-end">
<Button
variant="primary"
disabled={securityBusy}
onClick={() => void changePassword()}
>
Passwort speichern
</Button>
</div>
</div> </div>
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5"> <div className="mt-3 space-y-2">
<div className="flex items-start justify-between gap-3"> {authStatus?.totpDevice ? (
<div> <SecurityDeviceRow
<div className="text-sm font-semibold text-gray-900 dark:text-white"> icon="🔐"
Zwei-Faktor-Authentifizierung title={authStatus.totpDevice.label || 'Authenticator-App'}
</div> subtitle={
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300"> authStatus?.totpEnabled
Authenticator-App als zweiter Faktor. Einzelne Geräte können bei TOTP nicht getrennt erkannt werden. ? '2FA ist aktiv und wird beim Passwort-Login abgefragt.'
</div> : '2FA ist eingerichtet, aber aktuell nicht aktiv.'
</div> }
meta={`Eingerichtet: ${formatSecurityDate(authStatus.totpDevice.configuredAt)}`}
{statusPill(!!authStatus?.totpEnabled, 'Aktiv', 'Aus')} badge={authStatus?.totpEnabled ? 'TOTP' : 'Inaktiv'}
</div> />
<div className="mt-3 space-y-2">
{authStatus?.totpDevice ? (
<SecurityDeviceRow
icon="🔐"
title={authStatus.totpDevice.label || 'Authenticator-App'}
subtitle={
authStatus?.totpEnabled
? '2FA ist aktiv und wird beim Passwort-Login abgefragt.'
: '2FA ist eingerichtet, aber aktuell nicht aktiv.'
}
meta={`Eingerichtet: ${formatSecurityDate(authStatus.totpDevice.configuredAt)}`}
badge={authStatus?.totpEnabled ? 'TOTP' : 'Inaktiv'}
/>
) : (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
2FA ist noch nicht eingerichtet. Die Einrichtung erfolgt aktuell beim nächsten Login-Setup.
</div>
)}
</div>
{authStatus?.totpConfigured || authStatus?.totpEnabled ? (
<>
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
<input
type="password"
value={disable2FAPassword}
onChange={(e) => {
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(' ')}
/>
<input
value={disable2FACode}
onChange={(e) => {
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(' ')}
/>
</div>
{disable2FAFieldError?.password || disable2FAFieldError?.code ? (
<div className="mt-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200">
{disable2FAFieldError.password || disable2FAFieldError.code}
</div>
) : (
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Zum Deaktivieren brauchst du dein aktuelles Passwort
{authStatus?.totpEnabled ? ' und einen gültigen 2FA-Code.' : '.'}
</div>
)}
<div className="mt-3 flex justify-end">
<Button
variant="primary"
color="red"
disabled={securityBusy}
onClick={() => void disable2FA()}
>
2FA deaktivieren
</Button>
</div>
</>
) : null}
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-gray-900 dark:text-white">
Passkeys
</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
Anmeldung ohne Passwort über Face ID, Fingerabdruck, Windows Hello oder Sicherheitsschlüssel.
</div>
</div>
{statusPill(!!authStatus?.passkeyConfigured, 'Aktiv', 'Keine')}
</div>
<div className="mt-3 space-y-2">
{(authStatus?.passkeys ?? []).length > 0 ? (
(authStatus?.passkeys ?? []).map((pk, index) => (
<SecurityDeviceRow
key={pk.id || index}
icon="🔑"
title={pk.label || `Passkey ${index + 1}`}
subtitle={
pk.backupEligible
? pk.backupState
? 'Synchronisierter Passkey'
: 'Passkey kann synchronisiert werden'
: 'Gerätegebundener Passkey oder Sicherheitsschlüssel'
}
meta={[
`Erstellt: ${formatSecurityDate(pk.createdAt)}`,
`Zuletzt genutzt: ${formatSecurityDate(pk.lastUsedAt)}`,
typeof pk.signCount === 'number' ? `SignCount: ${pk.signCount}` : '',
].filter(Boolean).join(' · ')}
badge="Passkey"
actions={
<Button
variant="secondary"
color="red"
size="sm"
disabled={securityBusy}
onClick={() => void deletePasskey(pk)}
>
Löschen
</Button>
}
/>
))
) : (
<div className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs text-gray-600 dark:border-white/10 dark:bg-gray-950/40 dark:text-gray-300">
Noch kein Passkey gespeichert.
</div>
)}
</div>
{!passkeySupported ? (
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
Dein Browser oder diese Verbindung unterstützt Passkeys aktuell nicht.
</div>
) : ( ) : (
<div className="mt-3 space-y-2"> <div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
{passkeyFeedback ? ( 2FA ist noch nicht eingerichtet. Die Einrichtung erfolgt aktuell beim nächsten Login-Setup.
<div
className={[
'whitespace-pre-wrap rounded-lg border px-3 py-2 text-xs',
passkeyFeedback.kind === 'success'
? 'border-green-200 bg-green-50 text-green-800 dark:border-green-500/30 dark:bg-green-500/10 dark:text-green-200'
: passkeyFeedback.kind === 'error'
? 'border-red-200 bg-red-50 text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200'
: 'border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-500/30 dark:bg-blue-500/10 dark:text-blue-200',
].join(' ')}
>
{passkeyFeedback.kind === 'loading' ? '⏳ ' : null}
{passkeyFeedback.kind === 'success' ? '✅ ' : null}
{passkeyFeedback.kind === 'error' ? '⚠️ ' : null}
{passkeyFeedback.text}
</div>
) : null}
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button
variant="secondary"
disabled={securityBusy}
onClick={() => void registerSettingsPasskey()}
>
{securityBusy ? 'Passkey wird eingerichtet…' : 'Passkey hinzufügen'}
</Button>
</div>
</div> </div>
)} )}
</div> </div>
{authStatus?.totpConfigured || authStatus?.totpEnabled ? (
<>
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
<input
type="password"
value={disable2FAPassword}
onChange={(e) => {
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(' ')}
/>
<input
value={disable2FACode}
onChange={(e) => {
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(' ')}
/>
</div>
{disable2FAFieldError?.password || disable2FAFieldError?.code ? (
<div className="mt-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200">
{disable2FAFieldError.password || disable2FAFieldError.code}
</div>
) : (
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Zum Deaktivieren brauchst du dein aktuelles Passwort
{authStatus?.totpEnabled ? ' und einen gültigen 2FA-Code.' : '.'}
</div>
)}
<div className="mt-3 flex justify-end">
<Button
variant="primary"
color="red"
disabled={securityBusy}
onClick={() => void disable2FA()}
>
2FA deaktivieren
</Button>
</div>
</>
) : null}
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-gray-900 dark:text-white">
Passkeys
</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
Anmeldung ohne Passwort über Face ID, Fingerabdruck, Windows Hello oder Sicherheitsschlüssel.
</div>
</div>
{statusPill(!!authStatus?.passkeyConfigured, 'Aktiv', 'Keine')}
</div>
<div className="mt-3 space-y-2">
{(authStatus?.passkeys ?? []).length > 0 ? (
(authStatus?.passkeys ?? []).map((pk, index) => (
<SecurityDeviceRow
key={pk.id || index}
icon="🔑"
title={pk.label || `Passkey ${index + 1}`}
subtitle={
pk.backupEligible
? pk.backupState
? 'Synchronisierter Passkey'
: 'Passkey kann synchronisiert werden'
: 'Gerätegebundener Passkey oder Sicherheitsschlüssel'
}
meta={[
`Erstellt: ${formatSecurityDate(pk.createdAt)}`,
`Zuletzt genutzt: ${formatSecurityDate(pk.lastUsedAt)}`,
typeof pk.signCount === 'number' ? `SignCount: ${pk.signCount}` : '',
].filter(Boolean).join(' · ')}
badge="Passkey"
actions={
<Button
variant="secondary"
color="red"
size="sm"
disabled={securityBusy}
onClick={() => void deletePasskey(pk)}
>
Löschen
</Button>
}
/>
))
) : (
<div className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs text-gray-600 dark:border-white/10 dark:bg-gray-950/40 dark:text-gray-300">
Noch kein Passkey gespeichert.
</div>
)}
</div>
{!passkeySupported ? (
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
Dein Browser oder diese Verbindung unterstützt Passkeys aktuell nicht.
</div>
) : (
<div className="mt-3 space-y-2">
{passkeyFeedback ? (
<div
className={[
'whitespace-pre-wrap rounded-lg border px-3 py-2 text-xs',
passkeyFeedback.kind === 'success'
? 'border-green-200 bg-green-50 text-green-800 dark:border-green-500/30 dark:bg-green-500/10 dark:text-green-200'
: passkeyFeedback.kind === 'error'
? 'border-red-200 bg-red-50 text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200'
: 'border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-500/30 dark:bg-blue-500/10 dark:text-blue-200',
].join(' ')}
>
{passkeyFeedback.kind === 'loading' ? '⏳ ' : null}
{passkeyFeedback.kind === 'success' ? '✅ ' : null}
{passkeyFeedback.kind === 'error' ? '⚠️ ' : null}
{passkeyFeedback.text}
</div>
) : null}
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button
variant="secondary"
disabled={securityBusy}
onClick={() => void registerSettingsPasskey()}
>
{securityBusy ? 'Passkey wird eingerichtet…' : 'Passkey hinzufügen'}
</Button>
</div>
</div>
)}
</div> </div>
</div> </div>
</section> </SettingsSection>
</div> </div>
</Card> </Card>
) )

View File

@ -258,8 +258,9 @@ export type RecorderSettingsState = {
autoStartAddedDownloads?: boolean autoStartAddedDownloads?: boolean
enableConcurrentDownloadsLimit?: boolean enableConcurrentDownloadsLimit?: boolean
maxConcurrentDownloads?: number maxConcurrentDownloads?: number
useProviderApis?: boolean
useChaturbateApi?: boolean useChaturbateApi?: boolean
useMyFreeCamsWatcher?: boolean useMyFreeCamsApi?: boolean
autoDeleteSmallDownloads?: boolean autoDeleteSmallDownloads?: boolean
autoDeleteSmallDownloadsBelowMB?: number autoDeleteSmallDownloadsBelowMB?: number
autoDeleteSmallDownloadsKeepFavorites?: boolean autoDeleteSmallDownloadsKeepFavorites?: boolean