890 lines
21 KiB
Go
890 lines
21 KiB
Go
// backend\chaturbate_online.go
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha1"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"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
|
|
)
|
|
|
|
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))
|
|
}
|
|
|
|
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
|
|
|
|
// ---------------------------
|
|
// 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()
|
|
|
|
// ---------------------------
|
|
// 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)
|
|
}
|