// backend\chaturbate_online.go package main import ( "context" "crypto/sha1" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "path/filepath" "sort" "strconv" "strings" "sync" "time" ) // Chaturbate Affiliates API (Online Rooms) // https://chaturbate.com/affiliates/api/onlinerooms/?format=json&wm=827SM const chaturbateOnlineRoomsURL = "https://chaturbate.com/affiliates/api/onlinerooms/?format=json&wm=827SM" // ChaturbateRoom bildet die Felder ab, die die Online-Rooms API liefert. type ChaturbateRoom struct { Gender string `json:"gender"` Location string `json:"location"` CurrentShow string `json:"current_show"` // public / private / hidden / away Username string `json:"username"` RoomSubject string `json:"room_subject"` Tags []string `json:"tags"` IsNew bool `json:"is_new"` NumUsers int `json:"num_users"` NumFollowers int `json:"num_followers"` Country string `json:"country"` SpokenLanguages string `json:"spoken_languages"` DisplayName string `json:"display_name"` Birthday string `json:"birthday"` IsHD bool `json:"is_hd"` Age int `json:"age"` SecondsOnline int `json:"seconds_online"` ImageURL string `json:"image_url"` ImageURL360 string `json:"image_url_360x270"` ChatRoomURL string `json:"chat_room_url"` ChatRoomURLRS string `json:"chat_room_url_revshare"` IframeEmbed string `json:"iframe_embed"` IframeEmbedRS string `json:"iframe_embed_revshare"` BlockCountries string `json:"block_from_countries"` BlockStates string `json:"block_from_states"` Recorded string `json:"recorded"` // kommt in der API als String "true"/"false" Slug string `json:"slug"` } // ✅ Was das Frontend wirklich braucht (viel kleiner & schneller zu marshalen) type ChaturbateOnlineRoomLite struct { Username string `json:"username"` CurrentShow string `json:"current_show"` ChatRoomURL string `json:"chat_room_url"` ImageURL string `json:"image_url"` // fürs Filtern Gender string `json:"gender"` Country string `json:"country"` NumUsers int `json:"num_users"` IsHD bool `json:"is_hd"` Tags []string `json:"tags"` } type chaturbateCache struct { Rooms []ChaturbateRoom RoomsByUser map[string]ChaturbateRoom // ✅ Lite-Index für die Online-API Response LiteByUser map[string]ChaturbateOnlineRoomLite FetchedAt time.Time LastAttempt time.Time LastErr string } var ( cbHTTP = &http.Client{Timeout: 30 * time.Second} cbMu sync.RWMutex cb chaturbateCache // ✅ Optional: ModelStore, um Tags/Bilder/Status aus der Online-API zu übernehmen cbModelStore *ModelStore ) var ( cbRefreshMu sync.Mutex cbRefreshInFlight bool ) // --- HLS refresh throttling (damit /online nicht zu teuer wird) --- var cbHlsRefreshMu sync.Mutex var cbHlsRefreshAt = map[string]time.Time{} // key=userLower -> last refresh time func shouldRefreshHLS(userLower string, minInterval time.Duration) bool { if userLower == "" { return false } cbHlsRefreshMu.Lock() defer cbHlsRefreshMu.Unlock() last := cbHlsRefreshAt[userLower] if !last.IsZero() && time.Since(last) < minInterval { return false } cbHlsRefreshAt[userLower] = time.Now() return true } func normalizeList(in []string) []string { seen := map[string]bool{} out := make([]string, 0, len(in)) for _, s := range in { s = strings.ToLower(strings.TrimSpace(s)) if s == "" || seen[s] { continue } seen[s] = true out = append(out, s) } sort.Strings(out) return out } func keysOfSet(m map[string]bool) []string { if len(m) == 0 { return nil } out := make([]string, 0, len(m)) for k := range m { out = append(out, k) } sort.Strings(out) return out } func toSet(list []string) map[string]bool { if len(list) == 0 { return nil } m := make(map[string]bool, len(list)) for _, s := range list { m[s] = true } return m } func tagsAnyMatch(tags []string, allowed map[string]bool) bool { if len(allowed) == 0 { return true } for _, t := range tags { t = strings.ToLower(strings.TrimSpace(t)) if allowed[t] { return true } } return false } func derefInt(p *int) string { if p == nil { return "any" } return strconv.Itoa(*p) } func derefBool(p *bool) string { if p == nil { return "any" } if *p { return "true" } return "false" } // setChaturbateOnlineModelStore wird einmal beim Startup aufgerufen. func setChaturbateOnlineModelStore(store *ModelStore) { cbModelStore = store } func fetchChaturbateOnlineRooms(ctx context.Context) ([]ChaturbateRoom, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, chaturbateOnlineRoomsURL, nil) if err != nil { return nil, err } req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)") req.Header.Set("Accept", "application/json") resp, err := cbHTTP.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != 200 { b, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10)) return nil, fmt.Errorf("chaturbate online rooms: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) } dec := json.NewDecoder(resp.Body) // Erwartet: JSON Array tok, err := dec.Token() if err != nil { return nil, err } if d, ok := tok.(json.Delim); !ok || d != '[' { return nil, fmt.Errorf("chaturbate online rooms: expected JSON array") } rooms := make([]ChaturbateRoom, 0, 4096) for dec.More() { var rm ChaturbateRoom if err := dec.Decode(&rm); err != nil { return nil, err } rooms = append(rooms, rm) } // schließende ']' lesen if _, err := dec.Token(); err != nil { return nil, err } return rooms, nil } func indexRoomsByUser(rooms []ChaturbateRoom) map[string]ChaturbateRoom { m := make(map[string]ChaturbateRoom, len(rooms)) for _, rm := range rooms { u := strings.ToLower(strings.TrimSpace(rm.Username)) if u == "" { continue } m[u] = rm } return m } func indexLiteByUser(rooms []ChaturbateRoom) map[string]ChaturbateOnlineRoomLite { m := make(map[string]ChaturbateOnlineRoomLite, len(rooms)) for _, rm := range rooms { u := strings.ToLower(strings.TrimSpace(rm.Username)) if u == "" { continue } img := strings.TrimSpace(rm.ImageURL360) if img == "" { img = strings.TrimSpace(rm.ImageURL) } m[u] = ChaturbateOnlineRoomLite{ Username: rm.Username, CurrentShow: rm.CurrentShow, ChatRoomURL: rm.ChatRoomURL, ImageURL: img, Gender: rm.Gender, Country: rm.Country, NumUsers: rm.NumUsers, IsHD: rm.IsHD, Tags: rm.Tags, } } return m } // --- Profilbild Download + Persist (online -> offline) --- func selectBestRoomImageURL(rm ChaturbateRoom) string { if v := strings.TrimSpace(rm.ImageURL360); v != "" { return v } if v := strings.TrimSpace(rm.ImageURL); v != "" { return v } return "" } func fetchProfileImageBytes(ctx context.Context, rawURL string) (mime string, data []byte, err error) { u := strings.TrimSpace(rawURL) if u == "" { return "", nil, fmt.Errorf("empty image url") } req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return "", nil, err } req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)") req.Header.Set("Accept", "image/*,*/*;q=0.8") resp, err := cbHTTP.Do(req) if err != nil { return "", nil, err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) return "", nil, fmt.Errorf("image fetch HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) } // Sicherheitslimit (Profilbilder sind klein) const maxImageBytes = 4 << 20 // 4 MiB b, err := io.ReadAll(io.LimitReader(resp.Body, maxImageBytes+1)) if err != nil { return "", nil, err } if len(b) == 0 { return "", nil, fmt.Errorf("empty image body") } if len(b) > maxImageBytes { return "", nil, fmt.Errorf("image too large") } ct := strings.TrimSpace(strings.ToLower(resp.Header.Get("Content-Type"))) if i := strings.Index(ct, ";"); i >= 0 { ct = strings.TrimSpace(ct[:i]) } return ct, b, nil } func persistOfflineTransitions(prevRoomsByUser, newRoomsByUser map[string]ChaturbateRoom, fetchedAt time.Time) { if cbModelStore == nil || prevRoomsByUser == nil { return } seenAt := fetchedAt.UTC().Format(time.RFC3339Nano) for userLower, prevRm := range prevRoomsByUser { // war vorher online und ist jetzt noch online => kein Offline-Transition if _, stillOnline := newRoomsByUser[userLower]; stillOnline { continue } username := strings.TrimSpace(prevRm.Username) if username == "" { username = strings.TrimSpace(userLower) } if username == "" { continue } // 1) Offline Status persistieren _ = cbModelStore.SetLastSeenOnline("chaturbate.com", username, false, seenAt) // 2) Letztes bekanntes Profilbild persistieren imgURL := selectBestRoomImageURL(prevRm) if imgURL == "" { continue } // URL immer merken (Fallback / Diagnose) _ = cbModelStore.SetProfileImageURLOnly("chaturbate.com", username, imgURL, seenAt) // Blob speichern (best effort) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) mime, data, err := fetchProfileImageBytes(ctx, imgURL) cancel() if err != nil || len(data) == 0 { continue } _ = cbModelStore.SetProfileImage("chaturbate.com", username, imgURL, mime, data, seenAt) } } // cbApplySnapshot ersetzt atomar den Cache-Snapshot und triggert anschließend // offline-transition persist (best effort, außerhalb des Locks). func cbApplySnapshot(rooms []ChaturbateRoom) time.Time { var prevRoomsByUser map[string]ChaturbateRoom newRoomsByUser := indexRoomsByUser(rooms) newLiteByUser := indexLiteByUser(rooms) fetchedAtNow := time.Now() cbMu.Lock() if cb.RoomsByUser != nil { prevRoomsByUser = cb.RoomsByUser } cb.LastErr = "" cb.Rooms = rooms cb.RoomsByUser = newRoomsByUser cb.LiteByUser = newLiteByUser cb.FetchedAt = fetchedAtNow cbMu.Unlock() // Offline-Transitions bewusst außerhalb des Locks if cbModelStore != nil && prevRoomsByUser != nil { go persistOfflineTransitions(prevRoomsByUser, newRoomsByUser, fetchedAtNow) } return fetchedAtNow } // startChaturbateOnlinePoller pollt die API alle paar Sekunden, // aber nur, wenn der Settings-Switch "useChaturbateApi" aktiviert ist. func startChaturbateOnlinePoller(store *ModelStore) { const interval = 10 * time.Second const tagsFillEvery = 10 * time.Minute lastLoggedCount := -1 lastLoggedErr := "" var tagsMu sync.Mutex var tagsLast time.Time first := time.NewTimer(0) defer first.Stop() ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-first.C: case <-ticker.C: } if !getSettings().UseChaturbateAPI { continue } // immer merken: wir haben es versucht cbMu.Lock() cb.LastAttempt = time.Now() cbMu.Unlock() ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) rooms, err := fetchChaturbateOnlineRooms(ctx) cancel() if err != nil { cbMu.Lock() cb.LastErr = err.Error() // Fehler => Cache leeren (damit offline nicht hängen bleibt) cb.Rooms = nil cb.RoomsByUser = nil cb.LiteByUser = nil cbMu.Unlock() if cb.LastErr != lastLoggedErr { fmt.Println("❌ [chaturbate] online rooms fetch failed:", cb.LastErr) lastLoggedErr = cb.LastErr } continue } _ = cbApplySnapshot(rooms) // Tags übernehmen ist teuer -> nur selten + im Hintergrund if cbModelStore != nil && len(rooms) > 0 { shouldFill := false tagsMu.Lock() if tagsLast.IsZero() || time.Since(tagsLast) >= tagsFillEvery { tagsLast = time.Now() shouldFill = true } tagsMu.Unlock() if shouldFill { go cbModelStore.FillMissingTagsFromChaturbateOnline(rooms) } } // success logging only on changes if lastLoggedErr != "" { fmt.Println("✅ [chaturbate] online rooms fetch recovered") lastLoggedErr = "" } if verboseLogs() && len(rooms) != lastLoggedCount { fmt.Println("✅ [chaturbate] online rooms:", len(rooms)) lastLoggedCount = len(rooms) } } } var onlineCacheMu sync.Mutex var onlineCache = map[string]struct { at time.Time body []byte }{} func cachedOnline(key string) ([]byte, bool) { onlineCacheMu.Lock() defer onlineCacheMu.Unlock() e, ok := onlineCache[key] if !ok { return nil, false } if time.Since(e.at) > 2*time.Second { // TTL delete(onlineCache, key) return nil, false } return e.body, true } func setCachedOnline(key string, body []byte) { onlineCacheMu.Lock() onlineCache[key] = struct { at time.Time body []byte }{at: time.Now(), body: body} onlineCacheMu.Unlock() } type cbOnlineReq struct { Q []string `json:"q"` // usernames Show []string `json:"show"` // public/private/hidden/away // neue Filter Gender []string `json:"gender"` // m/f/c/t/s ... (was die API liefert) Country []string `json:"country"` // country codes/names (wie in API) MinUsers *int `json:"minUsers"` // Mindestviewer IsHD *bool `json:"isHD"` // true/false TagsAny []string `json:"tagsAny"` // mind. ein Tag matcht Refresh bool `json:"refresh"` } func hashKey(parts ...string) string { h := sha1.New() for _, p := range parts { _, _ = h.Write([]byte(p)) _, _ = h.Write([]byte{0}) } return hex.EncodeToString(h.Sum(nil)) } // jobMatchesUser prüft, ob ein laufender Job zu diesem Username gehört. // (wir matchen über SourceURL und Output-Pfad – robust genug ohne modelNameFromFilename Abhängigkeit) func jobMatchesUser(j *RecordJob, userLower string) bool { if j == nil { return false } u := strings.ToLower(strings.TrimSpace(userLower)) if u == "" { return false } // 1) SourceURL enthält meist / if s := strings.ToLower(strings.TrimSpace(j.SourceURL)); s != "" { if strings.Contains(s, "/"+u) || strings.HasSuffix(s, "/"+u) || strings.HasSuffix(s, u) { return true } } // 2) Output-Pfad enthält bei dir häufig den modelKey im Dateinamen/Ordner if out := strings.ToLower(strings.TrimSpace(j.Output)); out != "" { base := strings.ToLower(strings.TrimSpace(filepath.Base(out))) if strings.Contains(base, u) { return true } dir := strings.ToLower(strings.TrimSpace(filepath.Base(filepath.Dir(out)))) if dir == u { return true } } return false } // fetchCurrentBestHLS lädt die Room-Seite, parsed hls_source und wählt die beste Variant-Playlist. func fetchCurrentBestHLS(ctx context.Context, username string, cookie string, userAgent string) (string, error) { u := strings.TrimSpace(username) if u == "" { return "", fmt.Errorf("empty username") } hc := NewHTTPClient(userAgent) pageURL := "https://chaturbate.com/" + strings.Trim(u, "/") body, err := hc.FetchPage(ctx, pageURL, cookie) if err != nil { return "", err } master, err := ParseStream(body) // -> hls_source if err != nil { return "", err } pl, err := FetchPlaylist(ctx, hc, master, cookie) // -> beste Variant if err != nil { return "", err } return strings.TrimSpace(pl.PlaylistURL), nil } // refreshRunningJobsHLS aktualisiert PreviewM3U8 (+Cookie/UA) für passende laufende Jobs. // Wenn die URL rotiert hat: stopPreview(job) damit ffmpeg neu startet. func refreshRunningJobsHLS(userLower string, newHls string, cookie string, ua string) { if strings.TrimSpace(userLower) == "" || strings.TrimSpace(newHls) == "" { return } changedAny := false jobsMu.Lock() for _, j := range jobs { if j == nil || j.Status != JobRunning { continue } if !jobMatchesUser(j, userLower) { continue } old := strings.TrimSpace(j.PreviewM3U8) j.PreviewM3U8 = newHls j.PreviewCookie = cookie j.PreviewUA = ua // Wenn ffmpeg schon läuft und sich Quelle geändert hat -> hart stoppen if old != "" && old != newHls { stopPreview(j) // PreviewState zurücksetzen (damit "private/offline" nicht hängen bleibt) j.PreviewState = "" j.PreviewStateAt = "" j.PreviewStateMsg = "" } changedAny = true } jobsMu.Unlock() if changedAny { notifyJobsChanged() } } func chaturbateOnlineHandler(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 } enabled := getSettings().UseChaturbateAPI // Optional: Cookie vom Frontend (für Cloudflare/session – best effort) cookieHeader := strings.TrimSpace(r.Header.Get("X-Chaturbate-Cookie")) // UA vom Client (oder fallback) reqUA := strings.TrimSpace(r.Header.Get("User-Agent")) if reqUA == "" { reqUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" } // --------------------------- // Request params (GET/POST) // --------------------------- wantRefresh := false var users []string var shows []string // --------------------------- // Filter state // --------------------------- var ( allowedShow map[string]bool allowedGender map[string]bool allowedCountry map[string]bool allowedTagsAny map[string]bool minUsers *int isHD *bool ) if r.Method == http.MethodPost { r.Body = http.MaxBytesReader(w, r.Body, 8<<20) raw, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "Body read failed", http.StatusBadRequest) return } var req cbOnlineReq if len(raw) > 0 { if err := json.Unmarshal(raw, &req); err != nil { http.Error(w, "Invalid JSON body", http.StatusBadRequest) return } } wantRefresh = req.Refresh genders := normalizeList(req.Gender) countries := normalizeList(req.Country) tagsAny := normalizeList(req.TagsAny) minUsers = req.MinUsers isHD = req.IsHD allowedGender = toSet(genders) allowedCountry = toSet(countries) allowedTagsAny = toSet(tagsAny) seenU := map[string]bool{} for _, u := range req.Q { u = strings.ToLower(strings.TrimSpace(u)) if u == "" || seenU[u] { continue } seenU[u] = true users = append(users, u) } sort.Strings(users) seenS := map[string]bool{} for _, s := range req.Show { s = strings.ToLower(strings.TrimSpace(s)) if s == "" || seenS[s] { continue } seenS[s] = true shows = append(shows, s) } sort.Strings(shows) allowedShow = toSet(shows) } else { // GET (legacy) 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 != "" { seenU := map[string]bool{} for _, s := range strings.Split(qUsers, ",") { u := strings.ToLower(strings.TrimSpace(s)) if u == "" || seenU[u] { continue } seenU[u] = true users = append(users, u) } sort.Strings(users) } showFilter := strings.TrimSpace(r.URL.Query().Get("show")) if showFilter != "" { seenS := map[string]bool{} for _, s := range strings.Split(showFilter, ",") { s = strings.ToLower(strings.TrimSpace(s)) if s == "" || seenS[s] { continue } seenS[s] = true shows = append(shows, s) } sort.Strings(shows) } qGender := strings.TrimSpace(r.URL.Query().Get("gender")) if qGender != "" { genders := normalizeList(strings.Split(qGender, ",")) allowedGender = toSet(genders) } qCountry := strings.TrimSpace(r.URL.Query().Get("country")) if qCountry != "" { countries := normalizeList(strings.Split(qCountry, ",")) allowedCountry = toSet(countries) } qTagsAny := strings.TrimSpace(r.URL.Query().Get("tagsAny")) if qTagsAny != "" { tagsAny := normalizeList(strings.Split(qTagsAny, ",")) allowedTagsAny = toSet(tagsAny) } qMinUsers := strings.TrimSpace(r.URL.Query().Get("minUsers")) if qMinUsers != "" { if n, err := strconv.Atoi(qMinUsers); err == nil { minUsers = &n } } qIsHD := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("isHD"))) if qIsHD != "" { b := (qIsHD == "1" || qIsHD == "true" || qIsHD == "yes") isHD = &b } allowedShow = toSet(shows) } onlySpecificUsers := len(users) > 0 // --------------------------- // Response Cache (2s) // --------------------------- cacheKey := "cb_online:" + hashKey( fmt.Sprintf("enabled=%v", enabled), "users="+strings.Join(users, ","), "show="+strings.Join(keysOfSet(allowedShow), ","), "gender="+strings.Join(keysOfSet(allowedGender), ","), "country="+strings.Join(keysOfSet(allowedCountry), ","), "tagsAny="+strings.Join(keysOfSet(allowedTagsAny), ","), "minUsers="+derefInt(minUsers), "isHD="+derefBool(isHD), fmt.Sprintf("refresh=%v", wantRefresh), "lite=1", ) if body, ok := cachedOnline(cacheKey); ok { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _, _ = w.Write(body) return } // --------------------------- // Disabled -> immer schnell // --------------------------- if !enabled { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") out := map[string]any{ "enabled": false, "fetchedAt": time.Time{}, "count": 0, "total": 0, "lastError": "", "rooms": []any{}, } body, _ := json.Marshal(out) setCachedOnline(cacheKey, body) _, _ = w.Write(body) return } // --------------------------- // Snapshot Cache lesen (nur Lite) // --------------------------- cbMu.RLock() fetchedAt := cb.FetchedAt lastErr := cb.LastErr lastAttempt := cb.LastAttempt liteByUser := cb.LiteByUser cbMu.RUnlock() // --------------------------- // ✅ HLS URL Refresh für laufende Jobs (best effort) // Trigger nur, wenn explizite Users angefragt werden (dein Frontend macht das so) // und nur wenn User gerade online ist. // --------------------------- if onlySpecificUsers && liteByUser != nil { const hlsMinInterval = 12 * time.Second // throttle pro user for _, u := range users { rm, ok := liteByUser[u] if !ok { continue // offline -> nichts } // Optional: nur wenn wirklich "public" (reduziert unnötige fetches) // Wenn du auch in "private" previewen willst, entferne diesen Block. show := strings.ToLower(strings.TrimSpace(rm.CurrentShow)) if show == "offline" || show == "" { continue } // throttle if !shouldRefreshHLS(u, hlsMinInterval) { continue } // HLS holen (kurzer Timeout – soll /online nicht blockieren) ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second) newHls, err := fetchCurrentBestHLS(ctx, rm.Username, cookieHeader, reqUA) cancel() if err != nil || strings.TrimSpace(newHls) == "" { continue } // Jobs aktualisieren + ggf. Preview stoppen refreshRunningJobsHLS(u, newHls, cookieHeader, reqUA) } } // --------------------------- // Persist "last seen online/offline" für explizit angefragte User // --------------------------- if cbModelStore != nil && onlySpecificUsers && liteByUser != nil && !fetchedAt.IsZero() { seenAt := fetchedAt.UTC().Format(time.RFC3339Nano) for _, u := range users { _, isOnline := liteByUser[u] _ = cbModelStore.SetLastSeenOnline("chaturbate.com", u, isOnline, seenAt) } } // --------------------------- // Refresh/Bootstrap-Strategie // --------------------------- const bootstrapCooldown = 8 * time.Second needBootstrap := fetchedAt.IsZero() shouldTriggerFetch := wantRefresh || (needBootstrap && time.Since(lastAttempt) >= bootstrapCooldown) if shouldTriggerFetch { cbRefreshMu.Lock() if cbRefreshInFlight { cbRefreshMu.Unlock() } else { cbRefreshInFlight = true cbRefreshMu.Unlock() cbMu.Lock() cb.LastAttempt = time.Now() cbMu.Unlock() go func() { defer func() { cbRefreshMu.Lock() cbRefreshInFlight = false cbRefreshMu.Unlock() }() ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) rooms, err := fetchChaturbateOnlineRooms(ctx) cancel() if err != nil { cbMu.Lock() cb.LastErr = err.Error() cb.Rooms = nil cb.RoomsByUser = nil cb.LiteByUser = nil // fetchedAt NICHT ändern (bleibt letzte erfolgreiche Zeit) cbMu.Unlock() return } _ = cbApplySnapshot(rooms) if cbModelStore != nil && len(rooms) > 0 { cbModelStore.FillMissingTagsFromChaturbateOnline(rooms) } }() } } // --------------------------- // Rooms bauen (LITE, O(Anzahl requested Users)) // --------------------------- type outRoom struct { Username string `json:"username"` CurrentShow string `json:"current_show"` ChatRoomURL string `json:"chat_room_url"` ImageURL string `json:"image_url"` } matches := func(rm ChaturbateOnlineRoomLite) bool { if len(allowedShow) > 0 { s := strings.ToLower(strings.TrimSpace(rm.CurrentShow)) if !allowedShow[s] { return false } } if len(allowedGender) > 0 { g := strings.ToLower(strings.TrimSpace(rm.Gender)) if !allowedGender[g] { return false } } if len(allowedCountry) > 0 { c := strings.ToLower(strings.TrimSpace(rm.Country)) if !allowedCountry[c] { return false } } if minUsers != nil && rm.NumUsers < *minUsers { return false } if isHD != nil && rm.IsHD != *isHD { return false } if len(allowedTagsAny) > 0 && !tagsAnyMatch(rm.Tags, allowedTagsAny) { return false } return true } total := 0 if liteByUser != nil { noExtraFilters := len(allowedShow) == 0 && len(allowedGender) == 0 && len(allowedCountry) == 0 && len(allowedTagsAny) == 0 && minUsers == nil && isHD == nil if noExtraFilters { total = len(liteByUser) } else { for _, rm := range liteByUser { if matches(rm) { total++ } } } } outRooms := make([]outRoom, 0, len(users)) if onlySpecificUsers && liteByUser != nil { for _, u := range users { rm, ok := liteByUser[u] if !ok { continue } if !matches(rm) { continue } outRooms = append(outRooms, outRoom{ Username: rm.Username, CurrentShow: rm.CurrentShow, ChatRoomURL: rm.ChatRoomURL, ImageURL: rm.ImageURL, }) } } if needBootstrap && lastErr == "" { lastErr = "warming up" } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") out := map[string]any{ "enabled": true, "fetchedAt": fetchedAt, "count": len(outRooms), "total": total, "lastError": lastErr, "rooms": outRooms, } body, _ := json.Marshal(out) setCachedOnline(cacheKey, body) _, _ = w.Write(body) }