// backend/myfreecams_online.go package main import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" "sync" "time" ) const myfreecamsOnlineLookupURLFmt = "https://api-edge.myfreecams.com/usernameLookup/%s" const ( mfcOnlinePollInterval = 5 * time.Minute mfcOnlineMaxConcurrent = 1 mfcOnlinePerModelTimeout = 8 * time.Second ) type mfcMaybeString string func (s *mfcMaybeString) UnmarshalJSON(b []byte) error { raw := strings.TrimSpace(string(b)) switch raw { case "", "null", "false", "true": *s = "" return nil } var text string if err := json.Unmarshal(b, &text); err == nil { *s = mfcMaybeString(strings.TrimSpace(text)) return nil } // MFC liefert gelegentlich unerwartete Typen. // Nicht den ganzen usernameLookup scheitern lassen. *s = "" return nil } func (s mfcMaybeString) String() string { return strings.TrimSpace(string(s)) } type MyFreeCamsOnlineRoomLite struct { Username string `json:"username"` CurrentShow string `json:"current_show"` // public/private/offline/unknown ChatRoomURL string `json:"chat_room_url"` ImageURL string `json:"image_url"` Gender string `json:"gender,omitempty"` Country string `json:"country,omitempty"` RoomTopic string `json:"room_topic,omitempty"` RoomCount int `json:"room_count,omitempty"` Tags []string `json:"tags,omitempty"` } type myfreecamsOnlineCache struct { LiteByUser map[string]MyFreeCamsOnlineRoomLite FetchedAt time.Time LastAttempt time.Time LastErr string } var ( mfcOnlineHTTP = &http.Client{Timeout: mfcOnlinePerModelTimeout + 2*time.Second} mfcOnlineMu sync.RWMutex mfcOnline myfreecamsOnlineCache mfcOnlineModelStore *ModelStore ) var ( mfcOnlineRefreshMu sync.Mutex mfcOnlineRefreshInFlight bool ) func setMyFreeCamsOnlineModelStore(store *ModelStore) { mfcOnlineModelStore = store } type mfcOnlineLookupResp struct { Result struct { Success int `json:"success"` Message string `json:"message"` User struct { ID int `json:"id"` UserID int `json:"user_id"` Username string `json:"username"` Avatar mfcMaybeString `json:"avatar"` Tags []string `json:"tags"` Sessions []struct { SessionID int `json:"session_id"` VState int `json:"vstate"` RoomCount int `json:"room_count"` RoomTopic string `json:"room_topic"` ServerName string `json:"server_name"` Phase string `json:"phase"` SnapURL string `json:"snap_url"` } `json:"sessions"` Profile struct { Gender string `json:"gender"` Country string `json:"country"` } `json:"profile"` } `json:"user"` } `json:"result"` Err int `json:"err"` } func mfcOnlineDigitsOnly(s string) string { var b strings.Builder for _, r := range strings.TrimSpace(s) { if r >= '0' && r <= '9' { b.WriteRune(r) } } return b.String() } func normalizeMFCOnlineImageURL(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { return "" } if strings.HasPrefix(raw, "//") { raw = "https:" + raw } else if strings.HasPrefix(raw, "/") { raw = "https://snap.mfcimg.com" + raw } else if !strings.HasPrefix(raw, "http://") && !strings.HasPrefix(raw, "https://") { raw = "https://snap.mfcimg.com/" + strings.TrimLeft(raw, "/") } u, err := url.Parse(raw) if err != nil || u.Scheme == "" || u.Host == "" { return raw } q := u.Query() q.Set("no-cache", strconv.FormatInt(time.Now().UnixNano(), 10)) u.RawQuery = q.Encode() return u.String() } func buildMFCOnlineSnapimgURL(serverName string, rawUserID int) string { serverID := mfcOnlineDigitsOnly(serverName) if serverID == "" || rawUserID <= 0 { return "" } return normalizeMFCOnlineImageURL(fmt.Sprintf( "https://snap.mfcimg.com/snapimg/%s/853x480/mfc_%d", serverID, rawUserID, )) } func normalizeMFCOnlineRoomStatus(hasSession bool, vstate int) string { if !hasSession { return "offline" } // Deine Beispiele: // vstate=0 => public // vstate=14 => private show if vstate == 0 { return "public" } return "private" } func selectMFCOnlineImageURL(userID int, avatarURL string, sessServerName string, sessSnapURL string) string { // Bevorzugt die stabile Live-Snap-URL: // https://snap.mfcimg.com/snapimg//853x480/mfc_ if img := buildMFCOnlineSnapimgURL(sessServerName, userID); img != "" { return img } if img := normalizeMFCOnlineImageURL(sessSnapURL); img != "" { return img } return normalizeMFCOnlineImageURL(avatarURL) } func fetchMyFreeCamsOnlineModel(ctx context.Context, model string) (MyFreeCamsOnlineRoomLite, []byte, error) { model = strings.TrimSpace(model) if model == "" { return MyFreeCamsOnlineRoomLite{}, nil, appErrorf("mfc model leer") } if err := mfcLookupPausedError(); err != nil { return MyFreeCamsOnlineRoomLite{}, nil, err } upstreamURL := fmt.Sprintf(myfreecamsOnlineLookupURLFmt, url.PathEscape(model)) req, err := http.NewRequestWithContext(ctx, http.MethodGet, upstreamURL, nil) if err != nil { return MyFreeCamsOnlineRoomLite{}, nil, err } req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)") req.Header.Set("Accept", "application/json, text/plain, */*") req.Header.Set("Referer", "https://www.myfreecams.com/#"+model) req.Header.Set("Origin", "https://www.myfreecams.com") resp, err := mfcOnlineHTTP.Do(req) if err != nil { return MyFreeCamsOnlineRoomLite{}, nil, err } defer resp.Body.Close() raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if resp.StatusCode != http.StatusOK { return MyFreeCamsOnlineRoomLite{}, raw, appErrorf( "mfc usernameLookup %s HTTP %d: %s", model, resp.StatusCode, strings.TrimSpace(string(raw)), ) } var data mfcOnlineLookupResp if err := json.Unmarshal(raw, &data); err != nil { return MyFreeCamsOnlineRoomLite{}, raw, appErrorf("mfc usernameLookup json %s: %w", model, err) } user := data.Result.User avatarURL := user.Avatar.String() username := strings.TrimSpace(user.Username) if username == "" { username = model } if data.Result.Success == 0 { msg := strings.TrimSpace(data.Result.Message) if msg == "" { msg = "lookup failed" } if isMFCServersBusyMessage(msg) { pauseMFCLookups(msg, mfcLookupBusyCooldown) } return MyFreeCamsOnlineRoomLite{}, raw, appErrorf( "mfc usernameLookup %s: %s", model, msg, ) } if user.ID <= 0 { return MyFreeCamsOnlineRoomLite{}, raw, appErrorf( "mfc usernameLookup %s: missing user.id", model, ) } hasSession := len(user.Sessions) > 0 var ( vstate int roomCount int roomTopic string serverName string snapURL string ) if hasSession { sess := user.Sessions[0] vstate = sess.VState roomCount = sess.RoomCount roomTopic = strings.TrimSpace(sess.RoomTopic) serverName = strings.TrimSpace(sess.ServerName) snapURL = strings.TrimSpace(sess.SnapURL) } status := normalizeMFCOnlineRoomStatus(hasSession, vstate) imageURL := selectMFCOnlineImageURL(user.ID, avatarURL, serverName, snapURL) return MyFreeCamsOnlineRoomLite{ Username: username, CurrentShow: status, ChatRoomURL: "https://www.myfreecams.com/#" + username, ImageURL: imageURL, Gender: strings.TrimSpace(user.Profile.Gender), Country: strings.TrimSpace(user.Profile.Country), RoomTopic: roomTopic, RoomCount: roomCount, Tags: user.Tags, }, raw, nil } func listMyFreeCamsStoreModels(store *ModelStore) []StoredModel { if store == nil { return nil } all := store.List() out := make([]StoredModel, 0, len(all)) seen := map[string]bool{} for _, m := range all { host := canonicalHost(m.Host) if host != "myfreecams.com" { continue } modelKey := strings.TrimSpace(m.ModelKey) if modelKey == "" { continue } k := strings.ToLower(modelKey) if seen[k] { continue } seen[k] = true out = append(out, m) } return out } func refreshMyFreeCamsSnapshotNow(ctx context.Context, store *ModelStore) (time.Time, int, error) { if store == nil { return time.Time{}, 0, appErrorf("mfc online: model store nil") } mfcOnlineMu.Lock() mfcOnline.LastAttempt = time.Now() mfcOnlineMu.Unlock() models := listMyFreeCamsStoreModels(store) fetchedAt := time.Now().UTC() if err := mfcLookupPausedError(); err != nil { mfcOnlineMu.Lock() mfcOnline.LastAttempt = time.Now() mfcOnline.LastErr = err.Error() mfcOnlineMu.Unlock() return fetchedAt, 0, err } liteByUser := make(map[string]MyFreeCamsOnlineRoomLite, len(models)) // Vorherigen Snapshot behalten, damit einzelne Timeout-Fehler nicht sofort // alle bekannten MFC-Online-Daten aus dem Cache löschen. mfcOnlineMu.RLock() for k, v := range mfcOnline.LiteByUser { liteByUser[k] = v } mfcOnlineMu.RUnlock() if len(models) == 0 { mfcOnlineMu.Lock() mfcOnline.LiteByUser = liteByUser mfcOnline.FetchedAt = fetchedAt mfcOnline.LastErr = "" mfcOnlineMu.Unlock() return fetchedAt, 0, nil } type result struct { model StoredModel room MyFreeCamsOnlineRoomLite raw []byte err error } sem := make(chan struct{}, mfcOnlineMaxConcurrent) resCh := make(chan result, len(models)) var wg sync.WaitGroup for _, m := range models { m := m wg.Add(1) go func() { defer wg.Done() select { case <-ctx.Done(): resCh <- result{model: m, err: ctx.Err()} return case sem <- struct{}{}: } defer func() { <-sem }() if err := mfcLookupPausedError(); err != nil { resCh <- result{model: m, err: err} return } reqCtx, reqCancel := context.WithTimeout(ctx, mfcOnlinePerModelTimeout) room, raw, err := fetchMyFreeCamsOnlineModel(reqCtx, m.ModelKey) reqCancel() resCh <- result{ model: m, room: room, raw: raw, err: err, } }() } wg.Wait() close(resCh) var ( firstErr error okCount int failedCount int timeoutCount int ) for res := range resCh { modelKey := strings.TrimSpace(res.model.ModelKey) if modelKey == "" { continue } if res.err != nil { failedCount++ if errors.Is(res.err, context.DeadlineExceeded) { timeoutCount++ } if firstErr == nil { firstErr = res.err } // Kein Log pro Model mehr, sonst spammt es bei vielen Timeouts. continue } room := res.room if strings.TrimSpace(room.Username) == "" { room.Username = modelKey } key := strings.ToLower(strings.TrimSpace(room.Username)) if key == "" { key = strings.ToLower(modelKey) } liteByUser[key] = room okCount++ isOnline := room.CurrentShow != "offline" && room.CurrentShow != "unknown" _ = store.SetChaturbateRoomState( "myfreecams.com", modelKey, room.CurrentShow, isOnline, room.ChatRoomURL, room.ImageURL, fetchedAt, ) _ = store.SetLastSeenOnline( "myfreecams.com", modelKey, isOnline, fetchedAt.Format(time.RFC3339Nano), ) if len(res.raw) > 0 { _ = store.SetBioContext( "myfreecams.com", modelKey, string(res.raw), fetchedAt.Format(time.RFC3339Nano), ) } if strings.TrimSpace(room.ImageURL) != "" { _ = store.SetProfileImageURLOnly( "myfreecams.com", modelKey, room.ImageURL, fetchedAt.Format(time.RFC3339Nano), ) } } lastErr := "" if firstErr != nil { lastErr = fmt.Sprintf( "%d/%d lookups failed (%d timeouts); first error: %v", failedCount, len(models), timeoutCount, firstErr, ) appLogln("⚠️ [myfreecams] online refresh partial:", lastErr) } mfcOnlineMu.Lock() mfcOnline.LiteByUser = liteByUser mfcOnline.FetchedAt = fetchedAt mfcOnline.LastErr = lastErr mfcOnlineMu.Unlock() // Nur hart fehlschlagen, wenn wirklich gar kein Model erfolgreich geprüft wurde. if okCount == 0 && firstErr != nil { return fetchedAt, okCount, firstErr } return fetchedAt, okCount, nil } 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 } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(mfcOnlineResponse{ Enabled: false, FetchedAt: time.Time{}, LastAttempt: time.Time{}, Count: 0, Total: 0, LastError: "MyFreeCams Online-Prüfung deaktiviert; usernameLookup nur über biocontext bei Bedarf.", Rooms: []MyFreeCamsOnlineRoomLite{}, }) }