1074 lines
27 KiB
Go
1074 lines
27 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
|
||
}
|
||
|
||
// 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 func(roomsCopy []ChaturbateRoom, fetchedAt time.Time) {
|
||
if err := cbModelStore.SyncChaturbateOnlineForKnownModels(roomsCopy, fetchedAt); err != nil && verboseLogs() {
|
||
fmt.Println("⚠️ [chaturbate] sync known models failed:", err)
|
||
}
|
||
}(append([]ChaturbateRoom(nil), rooms...), fetchedAtNow)
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
changedAny := false
|
||
|
||
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)
|
||
// PreviewState zurücksetzen (damit "private/offline" nicht hängen bleibt)
|
||
j.PreviewState = ""
|
||
j.PreviewStateAt = ""
|
||
j.PreviewStateMsg = ""
|
||
}
|
||
|
||
changedAny = true
|
||
}
|
||
jobsMu.Unlock()
|
||
|
||
if changedAny {
|
||
notifyJobsChanged()
|
||
}
|
||
}
|
||
|
||
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()
|
||
|
||
// ---------------------------
|
||
// ✅ 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 && 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
|
||
}
|
||
|
||
fetchedAtNow := cbApplySnapshot(rooms)
|
||
|
||
if cbModelStore != nil {
|
||
_ = cbModelStore.SyncChaturbateOnlineForKnownModels(rooms, fetchedAtNow)
|
||
|
||
if 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)
|
||
}
|