nsfwapp/backend/chaturbate_online.go
2026-02-24 18:30:30 +01:00

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