nsfwapp/backend/myfreecams_online.go
2026-06-06 20:50:41 +02:00

567 lines
13 KiB
Go

// 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
}
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
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(mfcOnlineResponse{
Enabled: false,
FetchedAt: time.Time{},
LastAttempt: time.Time{},
Count: 0,
Total: 0,
LastError: "MyFreeCams Online-Prüfung deaktiviert; usernameLookup nur über biocontext bei Bedarf.",
Rooms: []MyFreeCamsOnlineRoomLite{},
})
}