package main import ( "context" "encoding/json" "fmt" "io" "net/http" "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"` } type chaturbateCache struct { Rooms []ChaturbateRoom FetchedAt time.Time LastErr string } var ( cbHTTP = &http.Client{Timeout: 12 * time.Second} cbMu sync.RWMutex cb chaturbateCache ) func fetchChaturbateOnlineRooms(ctx context.Context) ([]ChaturbateRoom, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, chaturbateOnlineRoomsURL, nil) if err != nil { return nil, err } // ein "normaler" UA reduziert manchmal Block/Rate-Limit Probleme 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))) } data, err := io.ReadAll(resp.Body) if err != nil { return nil, err } var rooms []ChaturbateRoom if err := json.Unmarshal(data, &rooms); err != nil { return nil, err } return rooms, nil } // startChaturbateOnlinePoller pollt die API alle paar Sekunden, // aber nur, wenn der Settings-Switch "useChaturbateApi" aktiviert ist. func startChaturbateOnlinePoller() { const interval = 10 * time.Second // nur loggen, wenn sich etwas ändert (sonst spammt es alle 5s) lastLoggedCount := -1 lastLoggedErr := "" // 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 } ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) rooms, err := fetchChaturbateOnlineRooms(ctx) cancel() cbMu.Lock() if err != nil { // ❗️WICHTIG: 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: rooms leeren cb.Rooms = nil cbMu.Unlock() if cb.LastErr != lastLoggedErr { fmt.Println("❌ [chaturbate] online rooms fetch failed:", cb.LastErr) lastLoggedErr = cb.LastErr } continue } // ✅ Erfolg: komplette Liste ersetzen + fetchedAt setzen cb.LastErr = "" cb.Rooms = rooms cb.FetchedAt = time.Now() cbMu.Unlock() // 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) } } } func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed) return } enabled := getSettings().UseChaturbateAPI if !enabled { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(map[string]any{ "enabled": false, "fetchedAt": time.Time{}, "count": 0, "lastError": "", "rooms": []ChaturbateRoom{}, }) return } // optional: ?refresh=1 triggert einen direkten Fetch (falls aktiviert) q := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("refresh"))) wantRefresh := q == "1" || q == "true" || q == "yes" // Snapshot des Caches cbMu.RLock() rooms := cb.Rooms fetchedAt := cb.FetchedAt lastErr := cb.LastErr cbMu.RUnlock() // Wenn aktiviert aber Cache noch nie gefüllt wurde, einmalig automatisch fetchen. // (Das verhindert das "count=0 / fetchedAt=0001" Verhalten direkt nach Neustart.) const staleAfter = 20 * time.Second isStale := fetchedAt.IsZero() || time.Since(fetchedAt) > staleAfter if enabled && (wantRefresh || isStale) { ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second) freshRooms, err := fetchChaturbateOnlineRooms(ctx) cancel() cbMu.Lock() if err != nil { cb.LastErr = err.Error() // ❗️WICHTIG: keine alten rooms weitergeben cb.Rooms = nil // ❗️FetchedAt NICHT aktualisieren (bleibt letzte erfolgreiche Zeit) } else { cb.LastErr = "" cb.Rooms = freshRooms cb.FetchedAt = time.Now() } rooms = cb.Rooms fetchedAt = cb.FetchedAt lastErr = cb.LastErr cbMu.Unlock() } // nil-slice vermeiden -> Frontend bekommt [] statt null if rooms == nil { rooms = []ChaturbateRoom{} } // optional: ?show=public,private,hidden,away showFilter := strings.TrimSpace(r.URL.Query().Get("show")) if showFilter != "" { allowed := map[string]bool{} for _, s := range strings.Split(showFilter, ",") { s = strings.ToLower(strings.TrimSpace(s)) if s != "" { allowed[s] = true } } if len(allowed) > 0 { filtered := make([]ChaturbateRoom, 0, len(rooms)) for _, rm := range rooms { if allowed[strings.ToLower(strings.TrimSpace(rm.CurrentShow))] { filtered = append(filtered, rm) } } rooms = filtered } } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") // Wir liefern ein kleines Meta-Objekt, damit du im UI sofort siehst, ob der Cache aktuell ist. out := map[string]any{ "enabled": enabled, "fetchedAt": fetchedAt, "count": len(rooms), "lastError": lastErr, "rooms": rooms, } _ = json.NewEncoder(w).Encode(out) }