nsfwapp/backend/myfreecams_biocontext.go
2026-06-10 19:43:31 +02:00

800 lines
20 KiB
Go

// backend\myfreecams_biocontext.go
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
const myfreecamsBioContextURLFmt = "https://api-edge.myfreecams.com/usernameLookup/%s"
// getrennt von Chaturbate, damit die Datei unabhängig bleibt
const myfreecamsBioCacheMaxAge = 10 * time.Minute
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)
}
}
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 mfcMaybeString string
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 (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))
}
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,
})
}