581 lines
15 KiB
Go
581 lines
15 KiB
Go
// 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)
|
||
}
|