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