nsfwapp/backend/chaturbate_online.go
2026-01-13 14:00:05 +01:00

581 lines
15 KiB
Go
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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)
}