1228 lines
31 KiB
Go
1228 lines
31 KiB
Go
// 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
|
||
}
|
||
|
||
// --- 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 buildSnapshotFromRoom(rm ChaturbateRoom) *ChaturbateOnlineSnapshot {
|
||
return &ChaturbateOnlineSnapshot{
|
||
Username: strings.TrimSpace(rm.Username),
|
||
DisplayName: strings.TrimSpace(rm.DisplayName),
|
||
CurrentShow: strings.TrimSpace(rm.CurrentShow),
|
||
RoomSubject: strings.TrimSpace(rm.RoomSubject),
|
||
Location: strings.TrimSpace(rm.Location),
|
||
Country: strings.TrimSpace(rm.Country),
|
||
SpokenLanguages: strings.TrimSpace(rm.SpokenLanguages),
|
||
Gender: strings.TrimSpace(rm.Gender),
|
||
|
||
NumUsers: rm.NumUsers,
|
||
NumFollowers: rm.NumFollowers,
|
||
IsHD: rm.IsHD,
|
||
IsNew: rm.IsNew,
|
||
Age: rm.Age,
|
||
SecondsOnline: rm.SecondsOnline,
|
||
|
||
ImageURL: strings.TrimSpace(rm.ImageURL),
|
||
ImageURL360: strings.TrimSpace(rm.ImageURL360),
|
||
ChatRoomURL: strings.TrimSpace(rm.ChatRoomURL),
|
||
ChatRoomURLRS: strings.TrimSpace(rm.ChatRoomURLRS),
|
||
|
||
Tags: rm.Tags,
|
||
}
|
||
}
|
||
|
||
func buildLiteFromStoredSnapshot(userLower string, snap *ChaturbateOnlineSnapshot) ChaturbateOnlineRoomLite {
|
||
if snap == nil {
|
||
return ChaturbateOnlineRoomLite{
|
||
Username: userLower,
|
||
CurrentShow: "offline",
|
||
ChatRoomURL: "https://chaturbate.com/" + userLower + "/",
|
||
ImageURL: "",
|
||
}
|
||
}
|
||
|
||
username := strings.TrimSpace(snap.Username)
|
||
if username == "" {
|
||
username = userLower
|
||
}
|
||
|
||
show := strings.ToLower(strings.TrimSpace(snap.CurrentShow))
|
||
if show == "" {
|
||
show = "offline"
|
||
}
|
||
|
||
chatURL := strings.TrimSpace(snap.ChatRoomURL)
|
||
if chatURL == "" {
|
||
chatURL = "https://chaturbate.com/" + username + "/"
|
||
}
|
||
|
||
img := strings.TrimSpace(snap.ImageURL360)
|
||
if img == "" {
|
||
img = strings.TrimSpace(snap.ImageURL)
|
||
}
|
||
|
||
return ChaturbateOnlineRoomLite{
|
||
Username: username,
|
||
CurrentShow: show,
|
||
ChatRoomURL: chatURL,
|
||
ImageURL: img,
|
||
|
||
Gender: strings.TrimSpace(snap.Gender),
|
||
Country: strings.TrimSpace(snap.Country),
|
||
NumUsers: snap.NumUsers,
|
||
IsHD: snap.IsHD,
|
||
Tags: snap.Tags,
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
|
||
fetchedAt := time.Now().UTC().Format(time.RFC3339Nano)
|
||
|
||
// ✅ DB-Infos aktualisieren (nur für bereits vorhandene Models)
|
||
if cbModelStore != nil {
|
||
// map online rooms by username
|
||
roomsByUser := indexRoomsByUser(rooms)
|
||
|
||
// alle modelKeys für chaturbate.com holen
|
||
keys, err := cbModelStore.ListModelKeysByHost("chaturbate.com")
|
||
if err == nil && len(keys) > 0 {
|
||
for _, key := range keys {
|
||
// key ist lowercased
|
||
rm, ok := roomsByUser[key]
|
||
if ok {
|
||
// online -> status + snapshot
|
||
_ = cbModelStore.SetLastSeenOnline("chaturbate.com", key, true, fetchedAt)
|
||
_ = cbModelStore.SetChaturbateOnlineSnapshot("chaturbate.com", key, buildSnapshotFromRoom(rm), fetchedAt, "")
|
||
|
||
// optional: tags füllen (aber nur wenn leer hast du eh schon)
|
||
// optional: profile_image_url updaten (URL-only) wenn du willst:
|
||
imgURL := selectBestRoomImageURL(rm)
|
||
if imgURL != "" {
|
||
_ = cbModelStore.SetProfileImageURLOnly("chaturbate.com", key, imgURL, fetchedAt)
|
||
}
|
||
} else {
|
||
// offline -> status + snapshot löschen (oder behalten)
|
||
_ = cbModelStore.SetLastSeenOnline("chaturbate.com", key, false, fetchedAt)
|
||
|
||
// Ich empfehle: Snapshot behalten, aber CurrentShow/offline kannst du so ausdrücken:
|
||
// -> entweder NULL setzen oder offline-snapshot speichern
|
||
_ = cbModelStore.SetChaturbateOnlineSnapshot("chaturbate.com", key, &ChaturbateOnlineSnapshot{
|
||
Username: key,
|
||
CurrentShow: "offline",
|
||
}, fetchedAt, "")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
changedIDs := make([]string, 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
|
||
|
||
// Wenn ffmpeg schon läuft und sich Quelle geändert hat -> hart stoppen
|
||
if old != "" && old != newHls {
|
||
stopPreview(j)
|
||
j.PreviewState = ""
|
||
j.PreviewStateAt = ""
|
||
j.PreviewStateMsg = ""
|
||
}
|
||
|
||
if strings.TrimSpace(j.ID) != "" {
|
||
changedIDs = append(changedIDs, j.ID)
|
||
}
|
||
}
|
||
jobsMu.Unlock()
|
||
|
||
for _, id := range changedIDs {
|
||
notifyJobPatched(id)
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// ---------------------------
|
||
// Snapshot Cache lesen (nur Lite)
|
||
// ---------------------------
|
||
cbMu.RLock()
|
||
fetchedAt := cb.FetchedAt
|
||
lastErr := cb.LastErr
|
||
lastAttempt := cb.LastAttempt
|
||
liteByUser := cb.LiteByUser
|
||
cbMu.RUnlock()
|
||
|
||
// DB-Fallback für bereits bekannte Models
|
||
dbLiteByUser := map[string]ChaturbateOnlineRoomLite{}
|
||
if cbModelStore != nil && onlySpecificUsers {
|
||
for _, u := range users {
|
||
snap, _, ok, err := cbModelStore.GetChaturbateOnlineSnapshot("chaturbate.com", u)
|
||
if err != nil || !ok || snap == nil {
|
||
continue
|
||
}
|
||
dbLiteByUser[u] = buildLiteFromStoredSnapshot(u, snap)
|
||
}
|
||
}
|
||
|
||
// ---------------------------
|
||
// ✅ 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.
|
||
// ---------------------------
|
||
if onlySpecificUsers && liteByUser != nil {
|
||
const hlsMinInterval = 12 * time.Second // throttle pro user
|
||
|
||
for _, u := range users {
|
||
rm, ok := liteByUser[u]
|
||
if !ok {
|
||
continue // offline -> nichts
|
||
}
|
||
|
||
// Optional: nur wenn wirklich "public" (reduziert unnötige fetches)
|
||
// Wenn du auch in "private" previewen willst, entferne diesen Block.
|
||
show := strings.ToLower(strings.TrimSpace(rm.CurrentShow))
|
||
if show == "offline" || show == "" {
|
||
continue
|
||
}
|
||
|
||
// throttle
|
||
if !shouldRefreshHLS(u, hlsMinInterval) {
|
||
continue
|
||
}
|
||
|
||
// HLS holen (kurzer Timeout – soll /online nicht blockieren)
|
||
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
|
||
}
|
||
|
||
// Jobs aktualisieren + ggf. Preview stoppen
|
||
refreshRunningJobsHLS(u, newHls, cookieHeader, reqUA)
|
||
}
|
||
}
|
||
|
||
// ---------------------------
|
||
// Persist "last seen online/offline" für explizit angefragte User
|
||
// ---------------------------
|
||
if cbModelStore != nil && onlySpecificUsers && !fetchedAt.IsZero() {
|
||
seenAt := fetchedAt.UTC().Format(time.RFC3339Nano)
|
||
for _, u := range users {
|
||
isOnline := false
|
||
if liteByUser != nil {
|
||
_, 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 {
|
||
for _, u := range users {
|
||
var (
|
||
rm ChaturbateOnlineRoomLite
|
||
ok bool
|
||
)
|
||
|
||
// 1) bevorzugt echter API-Live-Snapshot
|
||
if liteByUser != nil {
|
||
rm, ok = liteByUser[u]
|
||
}
|
||
|
||
// 2) fallback auf DB-persistierten Snapshot
|
||
if !ok {
|
||
if dbRm, dbOK := dbLiteByUser[u]; dbOK {
|
||
rm = dbRm
|
||
ok = true
|
||
}
|
||
}
|
||
|
||
// 3) wenn gar nichts da ist: offline-Minimalobjekt
|
||
if !ok {
|
||
rm = ChaturbateOnlineRoomLite{
|
||
Username: u,
|
||
CurrentShow: "offline",
|
||
ChatRoomURL: "https://chaturbate.com/" + u + "/",
|
||
ImageURL: "",
|
||
}
|
||
ok = true
|
||
}
|
||
|
||
if !matches(rm) {
|
||
continue
|
||
}
|
||
|
||
username := strings.TrimSpace(rm.Username)
|
||
if username == "" {
|
||
username = u
|
||
}
|
||
|
||
currentShow := strings.ToLower(strings.TrimSpace(rm.CurrentShow))
|
||
if currentShow == "" {
|
||
currentShow = "offline"
|
||
}
|
||
|
||
chatRoomURL := strings.TrimSpace(rm.ChatRoomURL)
|
||
if chatRoomURL == "" {
|
||
chatRoomURL = "https://chaturbate.com/" + username + "/"
|
||
}
|
||
|
||
outRooms = append(outRooms, outRoom{
|
||
Username: username,
|
||
CurrentShow: currentShow,
|
||
ChatRoomURL: chatRoomURL,
|
||
ImageURL: strings.TrimSpace(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)
|
||
}
|