// backend\chaturbate_online.go package main import ( "context" "crypto/sha1" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "sort" "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. // (Du kannst das später problemlos erweitern, wenn du weitere Felder brauchst.) 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"` } 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 // ✅ wichtig für Bootstrap-Cooldown (siehe Punkt 2) LastErr string } var ( cbHTTP = &http.Client{Timeout: 30 * time.Second} cbMu sync.RWMutex cb chaturbateCache // ✅ Optional: ModelStore, um Tags aus der Online-API zu übernehmen cbModelStore *ModelStore ) var ( cbRefreshMu sync.Mutex cbRefreshInFlight bool ) // 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 } m[u] = ChaturbateOnlineRoomLite{ Username: rm.Username, CurrentShow: rm.CurrentShow, ChatRoomURL: rm.ChatRoomURL, ImageURL: rm.ImageURL, } } return m } // startChaturbateOnlinePoller pollt die API alle paar Sekunden, // aber nur, wenn der Settings-Switch "useChaturbateApi" aktiviert ist. // startChaturbateOnlinePoller pollt die API alle paar Sekunden, // aber nur, wenn der Settings-Switch "useChaturbateApi" aktiviert ist. func startChaturbateOnlinePoller(store *ModelStore) { // ✅ etwas langsamer pollen (weniger Last) const interval = 10 * time.Second // ✅ Tags-Fill ist teuer -> max alle 10 Minuten const tagsFillEvery = 10 * time.Minute // nur loggen, wenn sich etwas ändert (sonst spammt es) lastLoggedCount := -1 lastLoggedErr := "" // Tags-Fill Throttle (lokal in der Funktion) var tagsMu sync.Mutex var tagsLast time.Time // sofort ein initialer Tick 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 (hilft dem Handler beim Bootstrap-Cooldown) cbMu.Lock() cb.LastAttempt = time.Now() cbMu.Unlock() ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) rooms, err := fetchChaturbateOnlineRooms(ctx) cancel() cbMu.Lock() if err != nil { // ❗️bei Fehler NICHT fetchedAt aktualisieren, // sonst wirkt der Cache "frisch", obwohl rooms alt sind. cb.LastErr = err.Error() // ❗️damit offline Models nicht hängen bleiben: Cache leeren 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 } // ✅ Erfolg: komplette Liste ersetzen + indices + fetchedAt setzen cb.LastErr = "" cb.Rooms = rooms cb.RoomsByUser = indexRoomsByUser(rooms) cb.LiteByUser = indexLiteByUser(rooms) cb.FetchedAt = time.Now() cbMu.Unlock() // ✅ 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 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 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)) } 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 // --------------------------- // Request params (GET/POST) // --------------------------- wantRefresh := false var users []string var shows []string 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 // normalize users 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) // normalize shows 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) } 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) } } // --------------------------- // Ultra-wichtig: niemals die komplette Affiliate-Liste ausliefern. // Wenn keine Users angegeben sind -> leere Antwort (spart massiv CPU + JSON) // --------------------------- onlySpecificUsers := len(users) > 0 // show allow-set allowedShow := map[string]bool{} for _, s := range shows { allowedShow[s] = true } // --------------------------- // Response Cache (2s) // --------------------------- cacheKey := "cb_online:" + hashKey( fmt.Sprintf("enabled=%v", enabled), "users="+strings.Join(users, ","), "show="+strings.Join(shows, ","), 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, "lastError": "", "rooms": []any{}, } body, _ := json.Marshal(out) setCachedOnline(cacheKey, body) _, _ = w.Write(body) return } // --------------------------- // Snapshot Cache (nur Lite-Index nutzen) // --------------------------- cbMu.RLock() fetchedAt := cb.FetchedAt lastErr := cb.LastErr lastAttempt := cb.LastAttempt liteByUser := cb.LiteByUser // map[usernameLower]ChaturbateRoomLite cbMu.RUnlock() // --------------------------- // Refresh/Bootstrap-Strategie: // - Handler blockiert NICHT auf Remote-Fetch (Performance!) // - wenn refresh=true: triggert einen Fetch (best effort), aber liefert sofort Cache/leer zurück // - wenn Cache noch nie erfolgreich war: "warming up" + best-effort Bootstrap, mit Cooldown // --------------------------- 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() // attempt timestamp sofort setzen (damit 100 Requests nicht alle triggern) cbMu.Lock() cb.LastAttempt = time.Now() cbMu.Unlock() // ✅ background fetch (nicht blockieren) go func() { defer func() { cbRefreshMu.Lock() cbRefreshInFlight = false cbRefreshMu.Unlock() }() ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) rooms, err := fetchChaturbateOnlineRooms(ctx) cancel() cbMu.Lock() if err != nil { cb.LastErr = err.Error() cb.Rooms = nil cb.RoomsByUser = nil cb.LiteByUser = nil // fetchedAt NICHT ändern (bleibt letzte erfolgreiche Zeit) } else { cb.LastErr = "" cb.Rooms = rooms cb.RoomsByUser = indexRoomsByUser(rooms) cb.LiteByUser = indexLiteByUser(rooms) // ✅ kleiner Index für Handler cb.FetchedAt = time.Now() } cbMu.Unlock() // Tags optional übernehmen (nur bei Erfolg) if cbModelStore != nil && err == 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"` } outRooms := make([]outRoom, 0, len(users)) if onlySpecificUsers && liteByUser != nil { for _, u := range users { rm, ok := liteByUser[u] if !ok { continue } // show filter if len(allowedShow) > 0 { s := strings.ToLower(strings.TrimSpace(rm.CurrentShow)) if !allowedShow[s] { continue } } outRooms = append(outRooms, outRoom{ Username: rm.Username, CurrentShow: rm.CurrentShow, ChatRoomURL: rm.ChatRoomURL, ImageURL: rm.ImageURL, }) } } // wenn noch nie erfolgreich gefetched: nicer error 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), "lastError": lastErr, "rooms": outRooms, // ✅ klein & schnell } body, _ := json.Marshal(out) setCachedOnline(cacheKey, body) _, _ = w.Write(body) }