nsfwapp/backend/myfreecams_online.go
2026-06-05 13:17:04 +02:00

737 lines
16 KiB
Go
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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/myfreecams_online.go
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
const myfreecamsOnlineLookupURLFmt = "https://api-edge.myfreecams.com/usernameLookup/%s"
const (
mfcOnlinePollInterval = 5 * time.Minute
mfcOnlineMaxConcurrent = 1
mfcOnlinePerModelTimeout = 8 * time.Second
)
type mfcMaybeString string
func (s *mfcMaybeString) UnmarshalJSON(b []byte) error {
raw := strings.TrimSpace(string(b))
switch raw {
case "", "null", "false", "true":
*s = ""
return nil
}
var text string
if err := json.Unmarshal(b, &text); err == nil {
*s = mfcMaybeString(strings.TrimSpace(text))
return nil
}
// MFC liefert gelegentlich unerwartete Typen.
// Nicht den ganzen usernameLookup scheitern lassen.
*s = ""
return nil
}
func (s mfcMaybeString) String() string {
return strings.TrimSpace(string(s))
}
type MyFreeCamsOnlineRoomLite struct {
Username string `json:"username"`
CurrentShow string `json:"current_show"` // public/private/offline/unknown
ChatRoomURL string `json:"chat_room_url"`
ImageURL string `json:"image_url"`
Gender string `json:"gender,omitempty"`
Country string `json:"country,omitempty"`
RoomTopic string `json:"room_topic,omitempty"`
RoomCount int `json:"room_count,omitempty"`
Tags []string `json:"tags,omitempty"`
}
type myfreecamsOnlineCache struct {
LiteByUser map[string]MyFreeCamsOnlineRoomLite
FetchedAt time.Time
LastAttempt time.Time
LastErr string
}
var (
mfcOnlineHTTP = &http.Client{Timeout: mfcOnlinePerModelTimeout + 2*time.Second}
mfcOnlineMu sync.RWMutex
mfcOnline myfreecamsOnlineCache
mfcOnlineModelStore *ModelStore
)
var (
mfcOnlineRefreshMu sync.Mutex
mfcOnlineRefreshInFlight bool
)
func setMyFreeCamsOnlineModelStore(store *ModelStore) {
mfcOnlineModelStore = store
}
type mfcOnlineLookupResp struct {
Result struct {
Success int `json:"success"`
Message string `json:"message"`
User struct {
ID int `json:"id"`
UserID int `json:"user_id"`
Username string `json:"username"`
Avatar mfcMaybeString `json:"avatar"`
Tags []string `json:"tags"`
Sessions []struct {
SessionID int `json:"session_id"`
VState int `json:"vstate"`
RoomCount int `json:"room_count"`
RoomTopic string `json:"room_topic"`
ServerName string `json:"server_name"`
Phase string `json:"phase"`
SnapURL string `json:"snap_url"`
} `json:"sessions"`
Profile struct {
Gender string `json:"gender"`
Country string `json:"country"`
} `json:"profile"`
} `json:"user"`
} `json:"result"`
Err int `json:"err"`
}
func mfcOnlineDigitsOnly(s string) string {
var b strings.Builder
for _, r := range strings.TrimSpace(s) {
if r >= '0' && r <= '9' {
b.WriteRune(r)
}
}
return b.String()
}
func normalizeMFCOnlineImageURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if strings.HasPrefix(raw, "//") {
raw = "https:" + raw
} else if strings.HasPrefix(raw, "/") {
raw = "https://snap.mfcimg.com" + raw
} else if !strings.HasPrefix(raw, "http://") && !strings.HasPrefix(raw, "https://") {
raw = "https://snap.mfcimg.com/" + strings.TrimLeft(raw, "/")
}
u, err := url.Parse(raw)
if err != nil || u.Scheme == "" || u.Host == "" {
return raw
}
q := u.Query()
q.Set("no-cache", strconv.FormatInt(time.Now().UnixNano(), 10))
u.RawQuery = q.Encode()
return u.String()
}
func buildMFCOnlineSnapimgURL(serverName string, rawUserID int) string {
serverID := mfcOnlineDigitsOnly(serverName)
if serverID == "" || rawUserID <= 0 {
return ""
}
return normalizeMFCOnlineImageURL(fmt.Sprintf(
"https://snap.mfcimg.com/snapimg/%s/853x480/mfc_%d",
serverID,
rawUserID,
))
}
func normalizeMFCOnlineRoomStatus(hasSession bool, vstate int) string {
if !hasSession {
return "offline"
}
// Deine Beispiele:
// vstate=0 => public
// vstate=14 => private show
if vstate == 0 {
return "public"
}
return "private"
}
func selectMFCOnlineImageURL(userID int, avatarURL string, sessServerName string, sessSnapURL string) string {
// Bevorzugt die stabile Live-Snap-URL:
// https://snap.mfcimg.com/snapimg/<server>/853x480/mfc_<userID>
if img := buildMFCOnlineSnapimgURL(sessServerName, userID); img != "" {
return img
}
if img := normalizeMFCOnlineImageURL(sessSnapURL); img != "" {
return img
}
return normalizeMFCOnlineImageURL(avatarURL)
}
func fetchMyFreeCamsOnlineModel(ctx context.Context, model string) (MyFreeCamsOnlineRoomLite, []byte, error) {
model = strings.TrimSpace(model)
if model == "" {
return MyFreeCamsOnlineRoomLite{}, nil, appErrorf("mfc model leer")
}
if err := mfcLookupPausedError(); err != nil {
return MyFreeCamsOnlineRoomLite{}, nil, err
}
upstreamURL := fmt.Sprintf(myfreecamsOnlineLookupURLFmt, url.PathEscape(model))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, upstreamURL, nil)
if err != nil {
return MyFreeCamsOnlineRoomLite{}, nil, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("Referer", "https://www.myfreecams.com/#"+model)
req.Header.Set("Origin", "https://www.myfreecams.com")
resp, err := mfcOnlineHTTP.Do(req)
if err != nil {
return MyFreeCamsOnlineRoomLite{}, nil, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return MyFreeCamsOnlineRoomLite{}, raw, appErrorf(
"mfc usernameLookup %s HTTP %d: %s",
model,
resp.StatusCode,
strings.TrimSpace(string(raw)),
)
}
var data mfcOnlineLookupResp
if err := json.Unmarshal(raw, &data); err != nil {
return MyFreeCamsOnlineRoomLite{}, raw, appErrorf("mfc usernameLookup json %s: %w", model, err)
}
user := data.Result.User
avatarURL := user.Avatar.String()
username := strings.TrimSpace(user.Username)
if username == "" {
username = model
}
if data.Result.Success == 0 {
msg := strings.TrimSpace(data.Result.Message)
if msg == "" {
msg = "lookup failed"
}
if isMFCServersBusyMessage(msg) {
pauseMFCLookups(msg, mfcLookupBusyCooldown)
}
return MyFreeCamsOnlineRoomLite{}, raw, appErrorf(
"mfc usernameLookup %s: %s",
model,
msg,
)
}
if user.ID <= 0 {
return MyFreeCamsOnlineRoomLite{}, raw, appErrorf(
"mfc usernameLookup %s: missing user.id",
model,
)
}
hasSession := len(user.Sessions) > 0
var (
vstate int
roomCount int
roomTopic string
serverName string
snapURL string
)
if hasSession {
sess := user.Sessions[0]
vstate = sess.VState
roomCount = sess.RoomCount
roomTopic = strings.TrimSpace(sess.RoomTopic)
serverName = strings.TrimSpace(sess.ServerName)
snapURL = strings.TrimSpace(sess.SnapURL)
}
status := normalizeMFCOnlineRoomStatus(hasSession, vstate)
imageURL := selectMFCOnlineImageURL(user.ID, avatarURL, serverName, snapURL)
return MyFreeCamsOnlineRoomLite{
Username: username,
CurrentShow: status,
ChatRoomURL: "https://www.myfreecams.com/#" + username,
ImageURL: imageURL,
Gender: strings.TrimSpace(user.Profile.Gender),
Country: strings.TrimSpace(user.Profile.Country),
RoomTopic: roomTopic,
RoomCount: roomCount,
Tags: user.Tags,
}, raw, nil
}
func listMyFreeCamsStoreModels(store *ModelStore) []StoredModel {
if store == nil {
return nil
}
all := store.List()
out := make([]StoredModel, 0, len(all))
seen := map[string]bool{}
for _, m := range all {
host := canonicalHost(m.Host)
if host != "myfreecams.com" {
continue
}
modelKey := strings.TrimSpace(m.ModelKey)
if modelKey == "" {
continue
}
k := strings.ToLower(modelKey)
if seen[k] {
continue
}
seen[k] = true
out = append(out, m)
}
return out
}
func refreshMyFreeCamsSnapshotNow(ctx context.Context, store *ModelStore) (time.Time, int, error) {
if store == nil {
return time.Time{}, 0, appErrorf("mfc online: model store nil")
}
mfcOnlineMu.Lock()
mfcOnline.LastAttempt = time.Now()
mfcOnlineMu.Unlock()
models := listMyFreeCamsStoreModels(store)
fetchedAt := time.Now().UTC()
if err := mfcLookupPausedError(); err != nil {
mfcOnlineMu.Lock()
mfcOnline.LastAttempt = time.Now()
mfcOnline.LastErr = err.Error()
mfcOnlineMu.Unlock()
return fetchedAt, 0, err
}
liteByUser := make(map[string]MyFreeCamsOnlineRoomLite, len(models))
// Vorherigen Snapshot behalten, damit einzelne Timeout-Fehler nicht sofort
// alle bekannten MFC-Online-Daten aus dem Cache löschen.
mfcOnlineMu.RLock()
for k, v := range mfcOnline.LiteByUser {
liteByUser[k] = v
}
mfcOnlineMu.RUnlock()
if len(models) == 0 {
mfcOnlineMu.Lock()
mfcOnline.LiteByUser = liteByUser
mfcOnline.FetchedAt = fetchedAt
mfcOnline.LastErr = ""
mfcOnlineMu.Unlock()
return fetchedAt, 0, nil
}
type result struct {
model StoredModel
room MyFreeCamsOnlineRoomLite
raw []byte
err error
}
sem := make(chan struct{}, mfcOnlineMaxConcurrent)
resCh := make(chan result, len(models))
var wg sync.WaitGroup
for _, m := range models {
m := m
wg.Add(1)
go func() {
defer wg.Done()
select {
case <-ctx.Done():
resCh <- result{model: m, err: ctx.Err()}
return
case sem <- struct{}{}:
}
defer func() { <-sem }()
if err := mfcLookupPausedError(); err != nil {
resCh <- result{model: m, err: err}
return
}
reqCtx, reqCancel := context.WithTimeout(ctx, mfcOnlinePerModelTimeout)
room, raw, err := fetchMyFreeCamsOnlineModel(reqCtx, m.ModelKey)
reqCancel()
resCh <- result{
model: m,
room: room,
raw: raw,
err: err,
}
}()
}
wg.Wait()
close(resCh)
var (
firstErr error
okCount int
failedCount int
timeoutCount int
)
for res := range resCh {
modelKey := strings.TrimSpace(res.model.ModelKey)
if modelKey == "" {
continue
}
if res.err != nil {
failedCount++
if errors.Is(res.err, context.DeadlineExceeded) {
timeoutCount++
}
if firstErr == nil {
firstErr = res.err
}
// Kein Log pro Model mehr, sonst spammt es bei vielen Timeouts.
continue
}
room := res.room
if strings.TrimSpace(room.Username) == "" {
room.Username = modelKey
}
key := strings.ToLower(strings.TrimSpace(room.Username))
if key == "" {
key = strings.ToLower(modelKey)
}
liteByUser[key] = room
okCount++
isOnline := room.CurrentShow != "offline" && room.CurrentShow != "unknown"
_ = store.SetChaturbateRoomState(
"myfreecams.com",
modelKey,
room.CurrentShow,
isOnline,
room.ChatRoomURL,
room.ImageURL,
fetchedAt,
)
_ = store.SetLastSeenOnline(
"myfreecams.com",
modelKey,
isOnline,
fetchedAt.Format(time.RFC3339Nano),
)
if len(res.raw) > 0 {
_ = store.SetBioContext(
"myfreecams.com",
modelKey,
string(res.raw),
fetchedAt.Format(time.RFC3339Nano),
)
}
if strings.TrimSpace(room.ImageURL) != "" {
_ = store.SetProfileImageURLOnly(
"myfreecams.com",
modelKey,
room.ImageURL,
fetchedAt.Format(time.RFC3339Nano),
)
}
}
lastErr := ""
if firstErr != nil {
lastErr = fmt.Sprintf(
"%d/%d lookups failed (%d timeouts); first error: %v",
failedCount,
len(models),
timeoutCount,
firstErr,
)
appLogln("⚠️ [myfreecams] online refresh partial:", lastErr)
}
mfcOnlineMu.Lock()
mfcOnline.LiteByUser = liteByUser
mfcOnline.FetchedAt = fetchedAt
mfcOnline.LastErr = lastErr
mfcOnlineMu.Unlock()
// Nur hart fehlschlagen, wenn wirklich gar kein Model erfolgreich geprüft wurde.
if okCount == 0 && firstErr != nil {
return fetchedAt, okCount, firstErr
}
return fetchedAt, okCount, nil
}
func startMyFreeCamsOnlinePoller(store *ModelStore) {
const interval = mfcOnlinePollInterval
if store != nil {
setMyFreeCamsOnlineModelStore(store)
}
lastLoggedErr := ""
refreshOnce := func(trigger string) {
if !myFreeCamsProviderAPIEnabled() {
if verboseLogs() {
appLogln(" [myfreecams] online refresh skipped: provider API disabled")
}
return
}
mfcOnlineRefreshMu.Lock()
if mfcOnlineRefreshInFlight {
mfcOnlineRefreshMu.Unlock()
return
}
mfcOnlineRefreshInFlight = true
mfcOnlineRefreshMu.Unlock()
go func() {
defer func() {
mfcOnlineRefreshMu.Lock()
mfcOnlineRefreshInFlight = false
mfcOnlineRefreshMu.Unlock()
}()
fetchedAt, count, err := refreshMyFreeCamsSnapshotNow(context.Background(), store)
if err != nil {
msg := err.Error()
if strings.Contains(msg, "usernameLookup pausiert") {
if verboseLogs() && msg != lastLoggedErr {
appLogln("⏸️ [myfreecams] online refresh skipped:", msg)
lastLoggedErr = msg
}
return
}
if msg != lastLoggedErr {
appLogln("⚠️ [myfreecams] online refresh failed:", msg)
lastLoggedErr = msg
}
return
}
if lastLoggedErr != "" {
appLogln("✅ [myfreecams] online refresh recovered")
lastLoggedErr = ""
}
if verboseLogs() {
appLogln(
"✅ [myfreecams] checked models:",
count,
"snapshot",
fetchedAt.Format(time.RFC3339),
"trigger="+trigger,
)
}
}()
}
refreshOnce("startup")
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
refreshOnce("ticker")
}
}
type mfcOnlineResponse struct {
Enabled bool `json:"enabled"`
FetchedAt time.Time `json:"fetchedAt"`
LastAttempt time.Time `json:"lastAttempt"`
Count int `json:"count"`
Total int `json:"total"`
LastError string `json:"lastError"`
Rooms []MyFreeCamsOnlineRoomLite `json:"rooms"`
}
func myfreecamsOnlineHandler(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
}
wantRefresh := false
var users []string
if r.Method == http.MethodPost {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
var req struct {
Q []string `json:"q"`
Refresh bool `json:"refresh"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
wantRefresh = req.Refresh
for _, u := range req.Q {
u = strings.ToLower(strings.TrimSpace(u))
if u != "" {
users = append(users, u)
}
}
} else {
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 != "" {
for _, u := range strings.Split(qUsers, ",") {
u = strings.ToLower(strings.TrimSpace(u))
if u != "" {
users = append(users, u)
}
}
}
}
store := mfcOnlineModelStore
if store == nil {
store = getModelStore()
}
if wantRefresh && store != nil {
if !myFreeCamsProviderAPIEnabled() {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(mfcOnlineResponse{
Enabled: false,
LastError: "MyFreeCams API ist in den Einstellungen deaktiviert",
Rooms: []MyFreeCamsOnlineRoomLite{},
})
return
}
mfcOnlineRefreshMu.Lock()
if !mfcOnlineRefreshInFlight {
mfcOnlineRefreshInFlight = true
go func() {
defer func() {
mfcOnlineRefreshMu.Lock()
mfcOnlineRefreshInFlight = false
mfcOnlineRefreshMu.Unlock()
}()
_, _, _ = refreshMyFreeCamsSnapshotNow(context.Background(), store)
}()
}
mfcOnlineRefreshMu.Unlock()
}
mfcOnlineMu.RLock()
fetchedAt := mfcOnline.FetchedAt
lastAttempt := mfcOnline.LastAttempt
lastErr := mfcOnline.LastErr
liteByUser := mfcOnline.LiteByUser
mfcOnlineMu.RUnlock()
total := 0
if liteByUser != nil {
total = len(liteByUser)
}
rooms := make([]MyFreeCamsOnlineRoomLite, 0)
if len(users) > 0 {
for _, u := range users {
if rm, ok := liteByUser[u]; ok {
rooms = append(rooms, rm)
}
}
}
if fetchedAt.IsZero() && lastErr == "" {
lastErr = "warming up"
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(mfcOnlineResponse{
Enabled: true,
FetchedAt: fetchedAt,
LastAttempt: lastAttempt,
Count: len(rooms),
Total: total,
LastError: lastErr,
Rooms: rooms,
})
}