nsfwapp/backend/chaturbate_online.go
2026-03-10 18:27:17 +01:00

1211 lines
29 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. 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"
"path/filepath"
"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
)
// --- HLS refresh throttling (damit /online nicht zu teuer wird) ---
var cbHlsRefreshMu sync.Mutex
var cbHlsRefreshAt = map[string]time.Time{} // key=userLower -> last refresh time
func shouldRefreshHLS(userLower string, minInterval time.Duration) bool {
if userLower == "" {
return false
}
cbHlsRefreshMu.Lock()
defer cbHlsRefreshMu.Unlock()
last := cbHlsRefreshAt[userLower]
if !last.IsZero() && time.Since(last) < minInterval {
return false
}
cbHlsRefreshAt[userLower] = time.Now()
return true
}
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
}
func syncChaturbateRoomStateIntoModelStore(store *ModelStore, rooms []ChaturbateRoom, fetchedAt time.Time) {
if store == nil {
return
}
seenOnline := make(map[string]bool, len(rooms))
for _, rm := range rooms {
modelKey := strings.ToLower(strings.TrimSpace(rm.Username))
if modelKey == "" {
continue
}
seenOnline[modelKey] = true
img := strings.TrimSpace(rm.ImageURL360)
if img == "" {
img = strings.TrimSpace(rm.ImageURL)
}
_ = store.SetChaturbateRoomState(
"chaturbate.com",
modelKey,
rm.CurrentShow,
true,
rm.ChatRoomURL,
img,
fetchedAt,
)
publishJobUpsertsForModelKey(modelKey)
}
// bekannte Chaturbate-Models, die NICHT im Online-Snapshot sind => offline setzen
models := store.List()
for _, m := range models {
if strings.ToLower(strings.TrimSpace(m.Host)) != "chaturbate.com" {
continue
}
modelKey := strings.ToLower(strings.TrimSpace(m.ModelKey))
if modelKey == "" {
continue
}
if seenOnline[modelKey] {
continue
}
_ = store.SetChaturbateRoomState(
"chaturbate.com",
modelKey,
"offline",
false,
"",
"",
fetchedAt,
)
publishJobUpsertsForModelKey(modelKey)
}
}
func publishJobUpsertsForModelKey(modelKey string) {
modelKey = strings.ToLower(strings.TrimSpace(modelKey))
if modelKey == "" {
return
}
jobsMu.Lock()
list := make([]*RecordJob, 0, len(jobs))
for _, j := range jobs {
if j == nil || j.Hidden {
continue
}
if sseModelEventNameForJob(j) != modelKey {
continue
}
c := *j
list = append(list, &c)
}
jobsMu.Unlock()
for _, j := range list {
publishJobUpsert(j)
}
}
// --- 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
}
func refreshChaturbateSnapshotNow(ctx context.Context) (time.Time, error) {
rooms, err := fetchChaturbateOnlineRooms(ctx)
if err != nil {
cbMu.Lock()
cb.LastErr = err.Error()
cb.Rooms = nil
cb.RoomsByUser = nil
cb.LiteByUser = nil
cbMu.Unlock()
return time.Time{}, err
}
fetchedAtNow := cbApplySnapshot(rooms)
if cbModelStore != nil {
// ✅ bekannten Store sofort auf aktuellen Snapshot ziehen
syncChaturbateRoomStateIntoModelStore(
cbModelStore,
append([]ChaturbateRoom(nil), rooms...),
fetchedAtNow,
)
// optional / best effort
if len(rooms) > 0 {
go cbModelStore.FillMissingTagsFromChaturbateOnline(rooms)
}
}
return fetchedAtNow, nil
}
// 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
}
fetchedAtNow := cbApplySnapshot(rooms)
// ✅ Alle bekannten Chaturbate-Models in der DB mit aktuellem Online-Snapshot synchronisieren
if cbModelStore != nil {
go syncChaturbateRoomStateIntoModelStore(
cbModelStore,
append([]ChaturbateRoom(nil), rooms...),
fetchedAtNow,
)
if len(rooms) > 0 {
cbModelStore.FillMissingTagsFromChaturbateOnline(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))
}
// jobMatchesUser prüft, ob ein laufender Job zu diesem Username gehört.
// (wir matchen über SourceURL und Output-Pfad robust genug ohne modelNameFromFilename Abhängigkeit)
func jobMatchesUser(j *RecordJob, userLower string) bool {
if j == nil {
return false
}
u := strings.ToLower(strings.TrimSpace(userLower))
if u == "" {
return false
}
// 1) SourceURL enthält meist /<username>
if s := strings.ToLower(strings.TrimSpace(j.SourceURL)); s != "" {
if strings.Contains(s, "/"+u) || strings.HasSuffix(s, "/"+u) || strings.HasSuffix(s, u) {
return true
}
}
// 2) Output-Pfad enthält bei dir häufig den modelKey im Dateinamen/Ordner
if out := strings.ToLower(strings.TrimSpace(j.Output)); out != "" {
base := strings.ToLower(strings.TrimSpace(filepath.Base(out)))
if strings.Contains(base, u) {
return true
}
dir := strings.ToLower(strings.TrimSpace(filepath.Base(filepath.Dir(out))))
if dir == u {
return true
}
}
return false
}
// fetchCurrentBestHLS lädt die Room-Seite, parsed hls_source und wählt die beste Variant-Playlist.
func fetchCurrentBestHLS(ctx context.Context, username string, cookie string, userAgent string) (string, error) {
u := strings.TrimSpace(username)
if u == "" {
return "", fmt.Errorf("empty username")
}
hc := NewHTTPClient(userAgent)
pageURL := "https://chaturbate.com/" + strings.Trim(u, "/")
body, err := hc.FetchPage(ctx, pageURL, cookie)
if err != nil {
return "", err
}
master, err := ParseStream(body) // -> hls_source
if err != nil {
return "", err
}
pl, err := FetchPlaylist(ctx, hc, master, cookie) // -> beste Variant
if err != nil {
return "", err
}
return strings.TrimSpace(pl.PlaylistURL), nil
}
// refreshRunningJobsHLS aktualisiert PreviewM3U8 (+Cookie/UA) für passende laufende Jobs.
// Wenn die URL rotiert hat: stopPreview(job) damit ffmpeg neu startet.
func refreshRunningJobsHLS(userLower string, newHls string, cookie string, ua string) {
if strings.TrimSpace(userLower) == "" || strings.TrimSpace(newHls) == "" {
return
}
toStop := make([]*RecordJob, 0, 4)
changedJobs := make([]*RecordJob, 0, 4)
jobsMu.Lock()
for _, j := range jobs {
if j == nil || j.Status != JobRunning {
continue
}
if !jobMatchesUser(j, userLower) {
continue
}
old := strings.TrimSpace(j.PreviewM3U8)
j.PreviewM3U8 = newHls
j.PreviewCookie = cookie
j.PreviewUA = ua
if old != "" && old != newHls {
j.PreviewState = ""
j.PreviewStateAt = ""
j.PreviewStateMsg = ""
toStop = append(toStop, j)
}
if !j.Hidden {
changedJobs = append(changedJobs, j)
}
}
jobsMu.Unlock()
for _, j := range toStop {
stopPreview(j)
}
for _, j := range changedJobs {
publishJobUpsert(j)
}
}
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
// Optional: Cookie vom Frontend (für Cloudflare/session best effort)
cookieHeader := strings.TrimSpace(r.Header.Get("X-Chaturbate-Cookie"))
// UA vom Client (oder fallback)
reqUA := strings.TrimSpace(r.Header.Get("User-Agent"))
if reqUA == "" {
reqUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
}
// ---------------------------
// 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
}
// ---------------------------
// Refresh/Bootstrap-Strategie
// ---------------------------
const bootstrapCooldown = 8 * time.Second
// ersten Snapshot lesen
cbMu.RLock()
fetchedAt := cb.FetchedAt
lastErr := cb.LastErr
lastAttempt := cb.LastAttempt
cbMu.RUnlock()
needBootstrap := fetchedAt.IsZero()
// ✅ Bei explizitem refresh synchron aktualisieren,
// damit die Response garantiert den neuesten Stand enthält.
if wantRefresh {
cbRefreshMu.Lock()
if cbRefreshInFlight {
cbRefreshMu.Unlock()
} else {
cbRefreshInFlight = true
cbRefreshMu.Unlock()
cbMu.Lock()
cb.LastAttempt = time.Now()
cbMu.Unlock()
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
_, err := refreshChaturbateSnapshotNow(ctx)
cancel()
cbRefreshMu.Lock()
cbRefreshInFlight = false
cbRefreshMu.Unlock()
if err != nil {
// Fehler nur im Cache halten; Antwort wird unten aus aktuellem Snapshot gebaut
}
}
} else if needBootstrap && time.Since(lastAttempt) >= bootstrapCooldown {
// ✅ Bootstrap darf weiterhin asynchron bleiben
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)
defer cancel()
_, _ = refreshChaturbateSnapshotNow(ctx)
}()
}
}
// ✅ JETZT erst den finalen Snapshot lesen
cbMu.RLock()
fetchedAt = cb.FetchedAt
lastErr = cb.LastErr
lastAttempt = cb.LastAttempt
liteByUser := cb.LiteByUser
cbMu.RUnlock()
needBootstrap = fetchedAt.IsZero()
// ---------------------------
// ✅ HLS URL Refresh für laufende Jobs (best effort)
// Trigger nur, wenn explizite Users angefragt werden (dein Frontend macht das so)
// und nur wenn User gerade online ist.
// ---------------------------
const hlsMinInterval = 12 * time.Second
if onlySpecificUsers && liteByUser != nil {
for _, u := range users {
rm, ok := liteByUser[u]
if !ok {
continue
}
show := strings.ToLower(strings.TrimSpace(rm.CurrentShow))
if show == "offline" || show == "" {
continue
}
if !shouldRefreshHLS(u, hlsMinInterval) {
continue
}
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
newHls, err := fetchCurrentBestHLS(ctx, rm.Username, cookieHeader, reqUA)
cancel()
if err != nil || strings.TrimSpace(newHls) == "" {
continue
}
refreshRunningJobsHLS(u, newHls, cookieHeader, reqUA)
}
}
// ---------------------------
// 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)
}
}
// ---------------------------
// 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)
}