bugfixes
This commit is contained in:
parent
5a46814039
commit
1625952638
Binary file not shown.
@ -456,4 +456,33 @@ def health():
|
|||||||
"classes": list(names.values())[:80] if isinstance(names, dict) else names,
|
"classes": list(names.values())[:80] if isinstance(names, dict) else names,
|
||||||
"labelConfig": str(DETECTION_LABELS_PATH) if DETECTION_LABELS_PATH else "",
|
"labelConfig": str(DETECTION_LABELS_PATH) if DETECTION_LABELS_PATH else "",
|
||||||
"labelError": _LABEL_ERROR,
|
"labelError": _LABEL_ERROR,
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.post("/reload", dependencies=[Depends(require_ai_server_auth)])
|
||||||
|
def reload_model():
|
||||||
|
global model
|
||||||
|
global _MODEL_PATH
|
||||||
|
global _MODEL_ERROR
|
||||||
|
global DETECTION_LABELS_PATH
|
||||||
|
|
||||||
|
model = None
|
||||||
|
_MODEL_PATH = ""
|
||||||
|
_MODEL_ERROR = ""
|
||||||
|
DETECTION_LABELS_PATH = None
|
||||||
|
|
||||||
|
current_model = get_model()
|
||||||
|
names = getattr(current_model, "names", {}) or {} if current_model is not None else {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": current_model is not None,
|
||||||
|
"ready": current_model is not None,
|
||||||
|
"modelAvailable": current_model is not None,
|
||||||
|
"model": _MODEL_PATH,
|
||||||
|
"modelError": _MODEL_ERROR,
|
||||||
|
"expectedModel": str(DEFAULT_MODEL_PATH),
|
||||||
|
"trainingRoot": str(TRAINING_ROOT),
|
||||||
|
"classCount": len(names),
|
||||||
|
"classes": list(names.values())[:80] if isinstance(names, dict) else names,
|
||||||
|
"labelConfig": str(DETECTION_LABELS_PATH) if DETECTION_LABELS_PATH else "",
|
||||||
|
"labelError": _LABEL_ERROR,
|
||||||
}
|
}
|
||||||
@ -31,7 +31,15 @@ var autostartSubs = map[chan autostartStatePayload]struct{}{}
|
|||||||
func isAutostartFeatureEnabled() bool {
|
func isAutostartFeatureEnabled() bool {
|
||||||
s := getSettings()
|
s := getSettings()
|
||||||
|
|
||||||
return s.UseChaturbateAPI || s.UseMyFreeCamsWatcher
|
if !s.AutoStartAddedDownloads {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.UseProviderAPIs {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.UseChaturbateAPI || s.UseMyFreeCamsAPI
|
||||||
}
|
}
|
||||||
|
|
||||||
func broadcastAutostartPaused() {
|
func broadcastAutostartPaused() {
|
||||||
@ -239,9 +247,12 @@ func autostartStateStreamHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
send := func(state autostartStatePayload) {
|
send := func(state autostartStatePayload) {
|
||||||
payload := map[string]any{
|
payload := map[string]any{
|
||||||
|
"enabled": state.Enabled,
|
||||||
|
"active": state.Active,
|
||||||
"paused": state.Paused,
|
"paused": state.Paused,
|
||||||
"pausedByUser": state.PausedByUser,
|
"pausedByUser": state.PausedByUser,
|
||||||
"pausedByDisk": state.PausedByDisk,
|
"pausedByDisk": state.PausedByDisk,
|
||||||
|
"reason": state.Reason,
|
||||||
"ts": time.Now().UTC().Format(time.RFC3339Nano),
|
"ts": time.Now().UTC().Format(time.RFC3339Nano),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -342,10 +342,10 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-scanTicker.C:
|
case <-scanTicker.C:
|
||||||
autostartPaused := isAutostartPaused()
|
|
||||||
|
|
||||||
s := getSettings()
|
s := getSettings()
|
||||||
if !s.UseChaturbateAPI {
|
autostartPaused := isAutostartPaused() || !s.AutoStartAddedDownloads
|
||||||
|
|
||||||
|
if !s.UseProviderAPIs || !s.UseChaturbateAPI {
|
||||||
queue = queue[:0]
|
queue = queue[:0]
|
||||||
queued = map[string]bool{}
|
queued = map[string]bool{}
|
||||||
lastCookieHdr = ""
|
lastCookieHdr = ""
|
||||||
@ -853,7 +853,7 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
|
|
||||||
case <-startTicker.C:
|
case <-startTicker.C:
|
||||||
s := getSettings()
|
s := getSettings()
|
||||||
if !s.UseChaturbateAPI {
|
if !s.UseProviderAPIs || !s.UseChaturbateAPI || !s.AutoStartAddedDownloads || isAutostartPaused() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -564,7 +564,7 @@ func startChaturbateOnlinePoller(store *ModelStore) {
|
|||||||
var tagsLast time.Time
|
var tagsLast time.Time
|
||||||
|
|
||||||
refreshOnce := func(trigger string) {
|
refreshOnce := func(trigger string) {
|
||||||
if !getSettings().UseChaturbateAPI {
|
if !chaturbateProviderAPIEnabled() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -32,6 +32,7 @@ func setModelStore(store *ModelStore) {
|
|||||||
modelStoreRef = store
|
modelStoreRef = store
|
||||||
setCoverModelStore(store)
|
setCoverModelStore(store)
|
||||||
setChaturbateOnlineModelStore(store)
|
setChaturbateOnlineModelStore(store)
|
||||||
|
setMyFreeCamsOnlineModelStore(store)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ umbenannt, damit es nicht mit models.go kollidiert
|
// ✅ umbenannt, damit es nicht mit models.go kollidiert
|
||||||
@ -359,7 +360,6 @@ func importModelsCSV(store *ModelStore, r io.Reader, kind string) (importResult,
|
|||||||
func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) {
|
func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) {
|
||||||
setModelStore(store)
|
setModelStore(store)
|
||||||
|
|
||||||
// ✅ NEU: Parse-Endpoint (nur URL erlaubt)
|
|
||||||
mux.HandleFunc("/api/models/parse", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/models/parse", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||||
|
|||||||
@ -116,12 +116,8 @@ func startMyFreeCamsAutoStartWorker(store *ModelStore) {
|
|||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-scanTicker.C:
|
case <-scanTicker.C:
|
||||||
if isAutostartPaused() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
s := getSettings()
|
s := getSettings()
|
||||||
if !s.UseMyFreeCamsWatcher {
|
if !s.UseProviderAPIs || !s.UseMyFreeCamsAPI || !s.AutoStartAddedDownloads || isAutostartPaused() {
|
||||||
queue = queue[:0]
|
queue = queue[:0]
|
||||||
queued = map[string]bool{}
|
queued = map[string]bool{}
|
||||||
|
|
||||||
@ -394,10 +390,8 @@ func startMyFreeCamsAutoStartWorker(store *ModelStore) {
|
|||||||
pendingAutoStartMu.Unlock()
|
pendingAutoStartMu.Unlock()
|
||||||
|
|
||||||
case <-startTicker.C:
|
case <-startTicker.C:
|
||||||
if isAutostartPaused() {
|
s := getSettings()
|
||||||
continue
|
if !s.UseProviderAPIs || !s.UseMyFreeCamsAPI || !s.AutoStartAddedDownloads || isAutostartPaused() {
|
||||||
}
|
|
||||||
if !getSettings().UseMyFreeCamsWatcher {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if len(queue) == 0 {
|
if len(queue) == 0 {
|
||||||
|
|||||||
680
backend/myfreecams_biocontext.go
Normal file
680
backend/myfreecams_biocontext.go
Normal file
@ -0,0 +1,680 @@
|
|||||||
|
// backend\myfreecams_biocontext.go
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const myfreecamsBioContextURLFmt = "https://api-edge.myfreecams.com/usernameLookup/%s"
|
||||||
|
|
||||||
|
// getrennt von Chaturbate, damit die Datei unabhängig bleibt
|
||||||
|
const myfreecamsBioCacheMaxAge = 10 * time.Minute
|
||||||
|
|
||||||
|
type MyFreeCamsBioContextResp struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
FetchedAt time.Time `json:"fetchedAt"`
|
||||||
|
LastError string `json:"lastError,omitempty"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Bio any `json:"bio,omitempty"`
|
||||||
|
|
||||||
|
RoomStatus string `json:"roomStatus,omitempty"` // public/private/offline/unknown
|
||||||
|
IsOnline bool `json:"isOnline,omitempty"`
|
||||||
|
ChatRoomURL string `json:"chatRoomUrl,omitempty"`
|
||||||
|
ImageURL string `json:"imageUrl,omitempty"`
|
||||||
|
|
||||||
|
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||||
|
SnapURL string `json:"snapUrl,omitempty"`
|
||||||
|
RoomTopic string `json:"roomTopic,omitempty"`
|
||||||
|
Country string `json:"country,omitempty"`
|
||||||
|
Gender string `json:"gender,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type mfcBioLookupResp 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"`
|
||||||
|
VS any `json:"vs"`
|
||||||
|
|
||||||
|
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"`
|
||||||
|
VideoServerType string `json:"video_server_type"`
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
SnapURL string `json:"snap_url"`
|
||||||
|
} `json:"sessions"`
|
||||||
|
|
||||||
|
Profile struct {
|
||||||
|
Age string `json:"age"`
|
||||||
|
Gender string `json:"gender"`
|
||||||
|
City string `json:"city"`
|
||||||
|
Country string `json:"country"`
|
||||||
|
Birthdate string `json:"birthdate"`
|
||||||
|
SexualPreference string `json:"sexual_preference"`
|
||||||
|
MaritalStatus string `json:"marital_status"`
|
||||||
|
Ethnicity string `json:"ethnicity"`
|
||||||
|
Hair string `json:"hair"`
|
||||||
|
Eyes string `json:"eyes"`
|
||||||
|
Weight string `json:"weight"`
|
||||||
|
WeightUnits string `json:"weight_units"`
|
||||||
|
Height string `json:"height"`
|
||||||
|
HeightUnits string `json:"height_units"`
|
||||||
|
BodyType string `json:"body_type"`
|
||||||
|
Smoke string `json:"smoke"`
|
||||||
|
Drink string `json:"drink"`
|
||||||
|
Drugs string `json:"drugs"`
|
||||||
|
Blurb string `json:"blurb"`
|
||||||
|
} `json:"profile"`
|
||||||
|
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
} `json:"user"`
|
||||||
|
} `json:"result"`
|
||||||
|
|
||||||
|
Err int `json:"err"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMFCBioRoomStatus(vstate int, hasSession bool) string {
|
||||||
|
if !hasSession {
|
||||||
|
return "offline"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deine Beispiele:
|
||||||
|
// vstate=0 => public
|
||||||
|
// vstate=14 => private show
|
||||||
|
if vstate == 0 {
|
||||||
|
return "public"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "private"
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMFCBioImageURL(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 mfcBioDigitsOnly(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 buildMFCBioSnapimgURL(serverName string, rawUserID int) string {
|
||||||
|
serverID := mfcBioDigitsOnly(serverName)
|
||||||
|
if serverID == "" || rawUserID <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeMFCBioImageURL(fmt.Sprintf(
|
||||||
|
"https://snap.mfcimg.com/snapimg/%s/853x480/mfc_%d",
|
||||||
|
serverID,
|
||||||
|
rawUserID,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectMFCBioImageURL(data mfcBioLookupResp) (imageURL string, avatarURL string, snapURL string) {
|
||||||
|
user := data.Result.User
|
||||||
|
|
||||||
|
avatarURL = normalizeMFCBioImageURL(user.Avatar.String())
|
||||||
|
|
||||||
|
if len(user.Sessions) > 0 {
|
||||||
|
sess := user.Sessions[0]
|
||||||
|
|
||||||
|
// bevorzugt die stabile snapimg-URL mit echter User-ID
|
||||||
|
snapURL = buildMFCBioSnapimgURL(sess.ServerName, user.ID)
|
||||||
|
|
||||||
|
// fallback: API snap_url
|
||||||
|
if strings.TrimSpace(snapURL) == "" {
|
||||||
|
snapURL = normalizeMFCBioImageURL(sess.SnapURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
imageURL = strings.TrimSpace(snapURL)
|
||||||
|
if imageURL == "" {
|
||||||
|
imageURL = strings.TrimSpace(avatarURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
return imageURL, avatarURL, snapURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func inferRoomStatusFromMFCBio(data mfcBioLookupResp) (
|
||||||
|
roomStatus string,
|
||||||
|
isOnline bool,
|
||||||
|
chatRoomURL string,
|
||||||
|
imageURL string,
|
||||||
|
avatarURL string,
|
||||||
|
snapURL string,
|
||||||
|
roomTopic string,
|
||||||
|
country string,
|
||||||
|
gender string,
|
||||||
|
) {
|
||||||
|
user := data.Result.User
|
||||||
|
|
||||||
|
hasSession := len(user.Sessions) > 0
|
||||||
|
vstate := 0
|
||||||
|
if hasSession {
|
||||||
|
vstate = user.Sessions[0].VState
|
||||||
|
roomTopic = strings.TrimSpace(user.Sessions[0].RoomTopic)
|
||||||
|
}
|
||||||
|
|
||||||
|
roomStatus = normalizeMFCBioRoomStatus(vstate, hasSession)
|
||||||
|
isOnline = hasSession
|
||||||
|
chatRoomURL = "https://www.myfreecams.com/#" + strings.TrimSpace(user.Username)
|
||||||
|
|
||||||
|
imageURL, avatarURL, snapURL = selectMFCBioImageURL(data)
|
||||||
|
|
||||||
|
country = strings.TrimSpace(user.Profile.Country)
|
||||||
|
gender = strings.TrimSpace(user.Profile.Gender)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func myfreecamsBioContextHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
enabled := true
|
||||||
|
|
||||||
|
model := sanitizeModelKey(r.URL.Query().Get("model"))
|
||||||
|
if model == "" {
|
||||||
|
http.Error(w, "model fehlt", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh := strings.TrimSpace(r.URL.Query().Get("refresh"))
|
||||||
|
forceRefresh := refresh == "1" || strings.EqualFold(refresh, "true")
|
||||||
|
|
||||||
|
const host = "myfreecams.com"
|
||||||
|
|
||||||
|
var (
|
||||||
|
cachedBio any
|
||||||
|
cachedAt time.Time
|
||||||
|
hasCache bool
|
||||||
|
)
|
||||||
|
|
||||||
|
store := getModelStore()
|
||||||
|
if store != nil {
|
||||||
|
raw, ts, ok, err := store.GetBioContext(host, model)
|
||||||
|
if err == nil && ok {
|
||||||
|
var tmp any
|
||||||
|
if e := json.Unmarshal([]byte(raw), &tmp); e == nil {
|
||||||
|
cachedBio = tmp
|
||||||
|
hasCache = true
|
||||||
|
if t, e2 := time.Parse(time.RFC3339Nano, strings.TrimSpace(ts)); e2 == nil {
|
||||||
|
cachedAt = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasCache && !forceRefresh && !cachedAt.IsZero() && time.Since(cachedAt) < myfreecamsBioCacheMaxAge {
|
||||||
|
roomStatus := "unknown"
|
||||||
|
isOnline := false
|
||||||
|
chatRoomURL := "https://www.myfreecams.com/#" + model
|
||||||
|
imageURL := ""
|
||||||
|
|
||||||
|
// Cache ist generisches JSON; erneut typisieren, damit Status/Bild sauber extrahiert werden.
|
||||||
|
b, _ := json.Marshal(cachedBio)
|
||||||
|
var parsed mfcBioLookupResp
|
||||||
|
if json.Unmarshal(b, &parsed) == nil {
|
||||||
|
var avatarURL, snapURL, roomTopic, country, gender string
|
||||||
|
roomStatus, isOnline, chatRoomURL, imageURL, avatarURL, snapURL, roomTopic, country, gender =
|
||||||
|
inferRoomStatusFromMFCBio(parsed)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_ = json.NewEncoder(w).Encode(MyFreeCamsBioContextResp{
|
||||||
|
Enabled: enabled,
|
||||||
|
FetchedAt: cachedAt,
|
||||||
|
Model: model,
|
||||||
|
Bio: cachedBio,
|
||||||
|
RoomStatus: roomStatus,
|
||||||
|
IsOnline: isOnline,
|
||||||
|
ChatRoomURL: chatRoomURL,
|
||||||
|
ImageURL: imageURL,
|
||||||
|
AvatarURL: avatarURL,
|
||||||
|
SnapURL: snapURL,
|
||||||
|
RoomTopic: roomTopic,
|
||||||
|
Country: country,
|
||||||
|
Gender: gender,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_ = json.NewEncoder(w).Encode(MyFreeCamsBioContextResp{
|
||||||
|
Enabled: enabled,
|
||||||
|
FetchedAt: cachedAt,
|
||||||
|
Model: model,
|
||||||
|
Bio: cachedBio,
|
||||||
|
RoomStatus: roomStatus,
|
||||||
|
IsOnline: isOnline,
|
||||||
|
ChatRoomURL: chatRoomURL,
|
||||||
|
ImageURL: imageURL,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !myFreeCamsProviderAPIEnabled() {
|
||||||
|
errMsg := "MyFreeCams API ist in den Einstellungen deaktiviert"
|
||||||
|
|
||||||
|
if hasCache {
|
||||||
|
roomStatus := "unknown"
|
||||||
|
isOnline := false
|
||||||
|
chatRoomURL := "https://www.myfreecams.com/#" + model
|
||||||
|
imageURL := ""
|
||||||
|
avatarURL := ""
|
||||||
|
snapURL := ""
|
||||||
|
roomTopic := ""
|
||||||
|
country := ""
|
||||||
|
gender := ""
|
||||||
|
|
||||||
|
b, _ := json.Marshal(cachedBio)
|
||||||
|
var cachedParsed mfcBioLookupResp
|
||||||
|
if json.Unmarshal(b, &cachedParsed) == nil {
|
||||||
|
roomStatus, isOnline, chatRoomURL, imageURL, avatarURL, snapURL, roomTopic, country, gender =
|
||||||
|
inferRoomStatusFromMFCBio(cachedParsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_ = json.NewEncoder(w).Encode(MyFreeCamsBioContextResp{
|
||||||
|
Enabled: false,
|
||||||
|
FetchedAt: cachedAt,
|
||||||
|
LastError: errMsg,
|
||||||
|
Model: model,
|
||||||
|
Bio: cachedBio,
|
||||||
|
RoomStatus: roomStatus,
|
||||||
|
IsOnline: isOnline,
|
||||||
|
ChatRoomURL: chatRoomURL,
|
||||||
|
ImageURL: imageURL,
|
||||||
|
AvatarURL: avatarURL,
|
||||||
|
SnapURL: snapURL,
|
||||||
|
RoomTopic: roomTopic,
|
||||||
|
Country: country,
|
||||||
|
Gender: gender,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_ = json.NewEncoder(w).Encode(MyFreeCamsBioContextResp{
|
||||||
|
Enabled: false,
|
||||||
|
FetchedAt: time.Now().UTC(),
|
||||||
|
LastError: errMsg,
|
||||||
|
Model: model,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manueller Refresh darf einmal testen, ob MFC wieder frei ist.
|
||||||
|
if forceRefresh {
|
||||||
|
clearMFCLookupPause("manual biocontext refresh for " + model)
|
||||||
|
} else if err := mfcLookupPausedError(); err != nil {
|
||||||
|
errMsg := err.Error()
|
||||||
|
|
||||||
|
if hasCache {
|
||||||
|
roomStatus := "unknown"
|
||||||
|
isOnline := false
|
||||||
|
chatRoomURL := "https://www.myfreecams.com/#" + model
|
||||||
|
imageURL := ""
|
||||||
|
avatarURL := ""
|
||||||
|
snapURL := ""
|
||||||
|
roomTopic := ""
|
||||||
|
country := ""
|
||||||
|
gender := ""
|
||||||
|
|
||||||
|
b, _ := json.Marshal(cachedBio)
|
||||||
|
var cachedParsed mfcBioLookupResp
|
||||||
|
if json.Unmarshal(b, &cachedParsed) == nil {
|
||||||
|
roomStatus, isOnline, chatRoomURL, imageURL, avatarURL, snapURL, roomTopic, country, gender =
|
||||||
|
inferRoomStatusFromMFCBio(cachedParsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_ = json.NewEncoder(w).Encode(MyFreeCamsBioContextResp{
|
||||||
|
Enabled: enabled,
|
||||||
|
FetchedAt: cachedAt,
|
||||||
|
LastError: errMsg,
|
||||||
|
Model: model,
|
||||||
|
Bio: cachedBio,
|
||||||
|
RoomStatus: roomStatus,
|
||||||
|
IsOnline: isOnline,
|
||||||
|
ChatRoomURL: chatRoomURL,
|
||||||
|
ImageURL: imageURL,
|
||||||
|
AvatarURL: avatarURL,
|
||||||
|
SnapURL: snapURL,
|
||||||
|
RoomTopic: roomTopic,
|
||||||
|
Country: country,
|
||||||
|
Gender: gender,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_ = json.NewEncoder(w).Encode(MyFreeCamsBioContextResp{
|
||||||
|
Enabled: enabled,
|
||||||
|
FetchedAt: time.Now().UTC(),
|
||||||
|
LastError: errMsg,
|
||||||
|
Model: model,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !myFreeCamsProviderAPIEnabled() {
|
||||||
|
errMsg := "MyFreeCams API ist in den Einstellungen deaktiviert"
|
||||||
|
|
||||||
|
if hasCache {
|
||||||
|
roomStatus := "unknown"
|
||||||
|
isOnline := false
|
||||||
|
chatRoomURL := "https://www.myfreecams.com/#" + model
|
||||||
|
imageURL := ""
|
||||||
|
avatarURL := ""
|
||||||
|
snapURL := ""
|
||||||
|
roomTopic := ""
|
||||||
|
country := ""
|
||||||
|
gender := ""
|
||||||
|
|
||||||
|
b, _ := json.Marshal(cachedBio)
|
||||||
|
var cachedParsed mfcBioLookupResp
|
||||||
|
if json.Unmarshal(b, &cachedParsed) == nil {
|
||||||
|
roomStatus, isOnline, chatRoomURL, imageURL, avatarURL, snapURL, roomTopic, country, gender =
|
||||||
|
inferRoomStatusFromMFCBio(cachedParsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_ = json.NewEncoder(w).Encode(MyFreeCamsBioContextResp{
|
||||||
|
Enabled: false,
|
||||||
|
FetchedAt: cachedAt,
|
||||||
|
LastError: errMsg,
|
||||||
|
Model: model,
|
||||||
|
Bio: cachedBio,
|
||||||
|
RoomStatus: roomStatus,
|
||||||
|
IsOnline: isOnline,
|
||||||
|
ChatRoomURL: chatRoomURL,
|
||||||
|
ImageURL: imageURL,
|
||||||
|
AvatarURL: avatarURL,
|
||||||
|
SnapURL: snapURL,
|
||||||
|
RoomTopic: roomTopic,
|
||||||
|
Country: country,
|
||||||
|
Gender: gender,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_ = json.NewEncoder(w).Encode(MyFreeCamsBioContextResp{
|
||||||
|
Enabled: false,
|
||||||
|
FetchedAt: time.Now().UTC(),
|
||||||
|
LastError: errMsg,
|
||||||
|
Model: model,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
upstreamURL := fmt.Sprintf(myfreecamsBioContextURLFmt, url.PathEscape(model))
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, upstreamURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "request build failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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 := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
if hasCache {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_ = json.NewEncoder(w).Encode(MyFreeCamsBioContextResp{
|
||||||
|
Enabled: enabled,
|
||||||
|
FetchedAt: cachedAt,
|
||||||
|
LastError: err.Error(),
|
||||||
|
Model: model,
|
||||||
|
Bio: cachedBio,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_ = json.NewEncoder(w).Encode(MyFreeCamsBioContextResp{
|
||||||
|
Enabled: enabled,
|
||||||
|
FetchedAt: time.Now().UTC(),
|
||||||
|
LastError: err.Error(),
|
||||||
|
Model: model,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
|
||||||
|
snip := strings.TrimSpace(string(raw))
|
||||||
|
if len(snip) > 400 {
|
||||||
|
snip = snip[:400] + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
errMsg := fmt.Sprintf("HTTP %d (ct=%s): %s", resp.StatusCode, resp.Header.Get("Content-Type"), snip)
|
||||||
|
|
||||||
|
if hasCache {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_ = json.NewEncoder(w).Encode(MyFreeCamsBioContextResp{
|
||||||
|
Enabled: enabled,
|
||||||
|
FetchedAt: cachedAt,
|
||||||
|
LastError: errMsg,
|
||||||
|
Model: model,
|
||||||
|
Bio: cachedBio,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_ = json.NewEncoder(w).Encode(MyFreeCamsBioContextResp{
|
||||||
|
Enabled: enabled,
|
||||||
|
FetchedAt: time.Now().UTC(),
|
||||||
|
LastError: errMsg,
|
||||||
|
Model: model,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed mfcBioLookupResp
|
||||||
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||||
|
errMsg := fmt.Sprintf("invalid json from MFC usernameLookup (ct=%s): %v; body: %s", resp.Header.Get("Content-Type"), err, snip)
|
||||||
|
|
||||||
|
if hasCache {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_ = json.NewEncoder(w).Encode(MyFreeCamsBioContextResp{
|
||||||
|
Enabled: enabled,
|
||||||
|
FetchedAt: cachedAt,
|
||||||
|
LastError: errMsg,
|
||||||
|
Model: model,
|
||||||
|
Bio: cachedBio,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_ = json.NewEncoder(w).Encode(MyFreeCamsBioContextResp{
|
||||||
|
Enabled: enabled,
|
||||||
|
FetchedAt: time.Now().UTC(),
|
||||||
|
LastError: errMsg,
|
||||||
|
Model: model,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.Result.Success == 0 || parsed.Result.User.ID <= 0 {
|
||||||
|
msg := strings.TrimSpace(parsed.Result.Message)
|
||||||
|
if msg == "" {
|
||||||
|
msg = "usernameLookup failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
errMsg := fmt.Sprintf("mfc usernameLookup: %s", msg)
|
||||||
|
|
||||||
|
if isMFCServersBusyMessage(msg) {
|
||||||
|
pauseMFCLookups(msg, mfcLookupBusyCooldown)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wichtig: Busy-Antwort NICHT in den Store schreiben.
|
||||||
|
if hasCache {
|
||||||
|
roomStatus := "unknown"
|
||||||
|
isOnline := false
|
||||||
|
chatRoomURL := "https://www.myfreecams.com/#" + model
|
||||||
|
imageURL := ""
|
||||||
|
avatarURL := ""
|
||||||
|
snapURL := ""
|
||||||
|
roomTopic := ""
|
||||||
|
country := ""
|
||||||
|
gender := ""
|
||||||
|
|
||||||
|
b, _ := json.Marshal(cachedBio)
|
||||||
|
var cachedParsed mfcBioLookupResp
|
||||||
|
if json.Unmarshal(b, &cachedParsed) == nil {
|
||||||
|
roomStatus, isOnline, chatRoomURL, imageURL, avatarURL, snapURL, roomTopic, country, gender =
|
||||||
|
inferRoomStatusFromMFCBio(cachedParsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_ = json.NewEncoder(w).Encode(MyFreeCamsBioContextResp{
|
||||||
|
Enabled: enabled,
|
||||||
|
FetchedAt: cachedAt,
|
||||||
|
LastError: errMsg,
|
||||||
|
Model: model,
|
||||||
|
Bio: cachedBio,
|
||||||
|
RoomStatus: roomStatus,
|
||||||
|
IsOnline: isOnline,
|
||||||
|
ChatRoomURL: chatRoomURL,
|
||||||
|
ImageURL: imageURL,
|
||||||
|
AvatarURL: avatarURL,
|
||||||
|
SnapURL: snapURL,
|
||||||
|
RoomTopic: roomTopic,
|
||||||
|
Country: country,
|
||||||
|
Gender: gender,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_ = json.NewEncoder(w).Encode(MyFreeCamsBioContextResp{
|
||||||
|
Enabled: enabled,
|
||||||
|
FetchedAt: time.Now().UTC(),
|
||||||
|
LastError: errMsg,
|
||||||
|
Model: model,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var bio any
|
||||||
|
if err := json.Unmarshal(raw, &bio); err != nil {
|
||||||
|
bio = parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchedAt := time.Now().UTC()
|
||||||
|
|
||||||
|
roomStatus, isOnline, chatRoomURL, imageURL, avatarURL, snapURL, roomTopic, country, gender :=
|
||||||
|
inferRoomStatusFromMFCBio(parsed)
|
||||||
|
|
||||||
|
if store != nil {
|
||||||
|
_ = store.SetBioContext(host, model, string(raw), fetchedAt.Format(time.RFC3339Nano))
|
||||||
|
|
||||||
|
// Der Methodenname ist historisch, funktioniert aber mit beliebigem host.
|
||||||
|
_ = store.SetChaturbateRoomState(
|
||||||
|
host,
|
||||||
|
model,
|
||||||
|
roomStatus,
|
||||||
|
isOnline,
|
||||||
|
chatRoomURL,
|
||||||
|
imageURL,
|
||||||
|
fetchedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if imageURL != "" {
|
||||||
|
_ = store.SetProfileImageURLOnly(host, model, imageURL, fetchedAt.Format(time.RFC3339Nano))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_ = json.NewEncoder(w).Encode(MyFreeCamsBioContextResp{
|
||||||
|
Enabled: enabled,
|
||||||
|
FetchedAt: fetchedAt,
|
||||||
|
LastError: "",
|
||||||
|
Model: model,
|
||||||
|
Bio: bio,
|
||||||
|
RoomStatus: roomStatus,
|
||||||
|
IsOnline: isOnline,
|
||||||
|
ChatRoomURL: chatRoomURL,
|
||||||
|
ImageURL: imageURL,
|
||||||
|
AvatarURL: avatarURL,
|
||||||
|
SnapURL: snapURL,
|
||||||
|
RoomTopic: roomTopic,
|
||||||
|
Country: country,
|
||||||
|
Gender: gender,
|
||||||
|
})
|
||||||
|
}
|
||||||
100
backend/myfreecams_lookup_pause.go
Normal file
100
backend/myfreecams_lookup_pause.go
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
// backend/myfreecams_lookup_pause.go
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const mfcLookupBusyCooldown = 10 * time.Minute
|
||||||
|
|
||||||
|
var (
|
||||||
|
mfcLookupPauseMu sync.Mutex
|
||||||
|
mfcLookupPausedUntil time.Time
|
||||||
|
mfcLookupPauseReason string
|
||||||
|
)
|
||||||
|
|
||||||
|
func isMFCServersBusyMessage(msg string) bool {
|
||||||
|
msg = strings.ToLower(strings.TrimSpace(msg))
|
||||||
|
return strings.Contains(msg, "servers busy")
|
||||||
|
}
|
||||||
|
|
||||||
|
func pauseMFCLookups(reason string, d time.Duration) {
|
||||||
|
if d <= 0 {
|
||||||
|
d = mfcLookupBusyCooldown
|
||||||
|
}
|
||||||
|
|
||||||
|
reason = strings.TrimSpace(reason)
|
||||||
|
if reason == "" {
|
||||||
|
reason = "servers busy"
|
||||||
|
}
|
||||||
|
|
||||||
|
until := time.Now().Add(d)
|
||||||
|
|
||||||
|
mfcLookupPauseMu.Lock()
|
||||||
|
changed := until.After(mfcLookupPausedUntil)
|
||||||
|
if changed {
|
||||||
|
mfcLookupPausedUntil = until
|
||||||
|
mfcLookupPauseReason = reason
|
||||||
|
}
|
||||||
|
mfcLookupPauseMu.Unlock()
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
appLogln(
|
||||||
|
"⏸️ [myfreecams] usernameLookup pausiert bis",
|
||||||
|
until.Format("15:04:05"),
|
||||||
|
"Grund:",
|
||||||
|
reason,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mfcLookupsPaused() (until time.Time, reason string, paused bool) {
|
||||||
|
mfcLookupPauseMu.Lock()
|
||||||
|
defer mfcLookupPauseMu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
if mfcLookupPausedUntil.IsZero() || now.After(mfcLookupPausedUntil) {
|
||||||
|
mfcLookupPausedUntil = time.Time{}
|
||||||
|
mfcLookupPauseReason = ""
|
||||||
|
return time.Time{}, "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
return mfcLookupPausedUntil, mfcLookupPauseReason, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func mfcLookupPausedError() error {
|
||||||
|
until, reason, paused := mfcLookupsPaused()
|
||||||
|
if !paused {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if reason == "" {
|
||||||
|
reason = "servers busy"
|
||||||
|
}
|
||||||
|
|
||||||
|
return appErrorf(
|
||||||
|
"mfc usernameLookup pausiert bis %s: %s",
|
||||||
|
until.Format("15:04:05"),
|
||||||
|
reason,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearMFCLookupPause(reason string) {
|
||||||
|
reason = strings.TrimSpace(reason)
|
||||||
|
if reason == "" {
|
||||||
|
reason = "manual refresh"
|
||||||
|
}
|
||||||
|
|
||||||
|
mfcLookupPauseMu.Lock()
|
||||||
|
wasPaused := !mfcLookupPausedUntil.IsZero()
|
||||||
|
mfcLookupPausedUntil = time.Time{}
|
||||||
|
mfcLookupPauseReason = ""
|
||||||
|
mfcLookupPauseMu.Unlock()
|
||||||
|
|
||||||
|
if wasPaused {
|
||||||
|
appLogln("▶️ [myfreecams] usernameLookup Pause aufgehoben. Grund:", reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
736
backend/myfreecams_online.go
Normal file
736
backend/myfreecams_online.go
Normal file
@ -0,0 +1,736 @@
|
|||||||
|
// 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
17
backend/provider_api_settings.go
Normal file
17
backend/provider_api_settings.go
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
// backend/provider_api_settings.go
|
||||||
|
package main
|
||||||
|
|
||||||
|
func providerAPIsEnabled() bool {
|
||||||
|
s := getSettings()
|
||||||
|
return s.UseProviderAPIs
|
||||||
|
}
|
||||||
|
|
||||||
|
func chaturbateProviderAPIEnabled() bool {
|
||||||
|
s := getSettings()
|
||||||
|
return s.UseProviderAPIs && s.UseChaturbateAPI
|
||||||
|
}
|
||||||
|
|
||||||
|
func myFreeCamsProviderAPIEnabled() bool {
|
||||||
|
s := getSettings()
|
||||||
|
return s.UseProviderAPIs && s.UseMyFreeCamsAPI
|
||||||
|
}
|
||||||
@ -313,9 +313,6 @@ func ParseStream(html string) (string, error) {
|
|||||||
if err := json.Unmarshal([]byte(decoded), &rd); err != nil {
|
if err := json.Unmarshal([]byte(decoded), &rd); err != nil {
|
||||||
return "", appErrorf("JSON-parse failed: %w", err)
|
return "", appErrorf("JSON-parse failed: %w", err)
|
||||||
}
|
}
|
||||||
if rd.HLSSource == "" {
|
|
||||||
return "", errors.New("kein HLS-Quell-URL im JSON")
|
|
||||||
}
|
|
||||||
return rd.HLSSource, nil
|
return rd.HLSSource, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
@ -31,6 +32,10 @@ func RecordStreamMFC(
|
|||||||
const waitPublicMax = 90 * time.Second
|
const waitPublicMax = 90 * time.Second
|
||||||
deadline := time.Now().Add(waitPublicMax)
|
deadline := time.Now().Add(waitPublicMax)
|
||||||
|
|
||||||
|
var lastStatus Status = StatusUnknown
|
||||||
|
var lastErr error
|
||||||
|
var lastStatusLog time.Time
|
||||||
|
|
||||||
for {
|
for {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return err
|
return err
|
||||||
@ -41,11 +46,21 @@ func RecordStreamMFC(
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if time.Now().After(deadline) {
|
lastStatus = st
|
||||||
|
lastErr = err
|
||||||
|
|
||||||
|
if lastStatusLog.IsZero() || time.Since(lastStatusLog) >= 15*time.Second {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return appErrorf("mfc status: %w", err)
|
appLogln("⚠️ mfc status failed:", username, err)
|
||||||
}
|
}
|
||||||
return context.DeadlineExceeded
|
lastStatusLog = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
if lastErr != nil {
|
||||||
|
return appErrorf("mfc status: %w", lastErr)
|
||||||
|
}
|
||||||
|
return appErrorf("mfc status blieb %s für %s", lastStatus, username)
|
||||||
}
|
}
|
||||||
|
|
||||||
time.Sleep(5 * time.Second)
|
time.Sleep(5 * time.Second)
|
||||||
@ -120,9 +135,9 @@ type MyFreeCams struct {
|
|||||||
type mfcUsernameLookupResp struct {
|
type mfcUsernameLookupResp struct {
|
||||||
Result struct {
|
Result struct {
|
||||||
User struct {
|
User struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Avatar string `json:"avatar"`
|
Avatar mfcMaybeString `json:"avatar"`
|
||||||
|
|
||||||
Sessions []struct {
|
Sessions []struct {
|
||||||
ServerName string `json:"server_name"`
|
ServerName string `json:"server_name"`
|
||||||
@ -364,17 +379,27 @@ func (m *MyFreeCams) GetStatus(ctx context.Context, userAgent string) (Status, e
|
|||||||
return StatusUnknown, appErrorf("mfc usernameLookup HTTP %d: %s", resp.StatusCode, lookupURL)
|
return StatusUnknown, appErrorf("mfc usernameLookup HTTP %d: %s", resp.StatusCode, lookupURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
return StatusUnknown, err
|
||||||
|
}
|
||||||
|
|
||||||
|
snip := strings.TrimSpace(string(raw))
|
||||||
|
if len(snip) > 500 {
|
||||||
|
snip = snip[:500] + "…"
|
||||||
|
}
|
||||||
|
|
||||||
var data mfcUsernameLookupResp
|
var data mfcUsernameLookupResp
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
if err := json.Unmarshal(raw, &data); err != nil {
|
||||||
return StatusUnknown, appErrorf("mfc usernameLookup json: %w", err)
|
return StatusUnknown, appErrorf("mfc usernameLookup json for %s: %w; body=%s", username, err, snip)
|
||||||
}
|
}
|
||||||
|
|
||||||
if data.Result.User.ID <= 0 {
|
if data.Result.User.ID <= 0 {
|
||||||
return StatusNotExist, nil
|
return StatusUnknown, appErrorf("mfc usernameLookup ohne user.id für %s; body=%s", username, snip)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.RawUserID = data.Result.User.ID
|
m.RawUserID = data.Result.User.ID
|
||||||
m.AvatarURL = strings.TrimSpace(data.Result.User.Avatar)
|
m.AvatarURL = data.Result.User.Avatar.String()
|
||||||
|
|
||||||
if len(data.Result.User.Sessions) == 0 {
|
if len(data.Result.User.Sessions) == 0 {
|
||||||
return StatusOffline, nil
|
return StatusOffline, nil
|
||||||
@ -507,21 +532,86 @@ func extractMFCUsername(input string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) URL mit Fragment (#username)
|
if u, err := url.Parse(s); err == nil {
|
||||||
if u, err := url.Parse(s); err == nil && u.Fragment != "" {
|
if frag := strings.TrimSpace(u.Fragment); frag != "" {
|
||||||
return strings.Trim(strings.TrimSpace(u.Fragment), "/")
|
if name := extractMFCUsernameFromPathish(frag); name != "" {
|
||||||
}
|
return name
|
||||||
|
}
|
||||||
// 2) URL Pfad: letztes Segment nehmen
|
}
|
||||||
if u, err := url.Parse(s); err == nil && u.Host != "" {
|
|
||||||
p := strings.Trim(u.Path, "/")
|
if u.Host != "" {
|
||||||
if p == "" {
|
if name := extractMFCUsernameFromPathish(u.Path); name != "" {
|
||||||
return ""
|
return name
|
||||||
|
}
|
||||||
}
|
}
|
||||||
parts := strings.Split(p, "/")
|
|
||||||
return strings.TrimSpace(parts[len(parts)-1])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) Fallback: raw
|
return sanitizeMFCUsernamePart(s)
|
||||||
return s
|
}
|
||||||
|
|
||||||
|
func extractMFCUsernameFromPathish(raw string) string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
raw = strings.TrimPrefix(raw, "#")
|
||||||
|
raw = strings.Trim(raw, "/")
|
||||||
|
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
skip := map[string]bool{
|
||||||
|
"models": true,
|
||||||
|
"model": true,
|
||||||
|
"profile": true,
|
||||||
|
"users": true,
|
||||||
|
"user": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(raw, "/")
|
||||||
|
|
||||||
|
for i := len(parts) - 1; i >= 0; i-- {
|
||||||
|
p := sanitizeMFCUsernamePart(parts[i])
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if skip[strings.ToLower(p)] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeMFCUsernamePart(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
s = strings.Trim(s, "/")
|
||||||
|
s = strings.TrimPrefix(s, "@")
|
||||||
|
|
||||||
|
if dec, err := url.PathUnescape(s); err == nil {
|
||||||
|
s = dec
|
||||||
|
}
|
||||||
|
if dec, err := url.QueryUnescape(s); err == nil {
|
||||||
|
s = dec
|
||||||
|
}
|
||||||
|
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
s = strings.Trim(s, "/")
|
||||||
|
s = strings.TrimPrefix(s, "@")
|
||||||
|
|
||||||
|
s = strings.Map(func(r rune) rune {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z':
|
||||||
|
return r
|
||||||
|
case r >= 'A' && r <= 'Z':
|
||||||
|
return r
|
||||||
|
case r >= '0' && r <= '9':
|
||||||
|
return r
|
||||||
|
case r == '_' || r == '-' || r == '.':
|
||||||
|
return r
|
||||||
|
default:
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
}, s)
|
||||||
|
|
||||||
|
return strings.TrimSpace(s)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1151,6 +1151,11 @@ func runMFCRecording(
|
|||||||
_ = os.MkdirAll(recordDirAbs, 0o755)
|
_ = os.MkdirAll(recordDirAbs, 0o755)
|
||||||
|
|
||||||
username := extractMFCUsername(req.URL)
|
username := extractMFCUsername(req.URL)
|
||||||
|
|
||||||
|
if strings.TrimSpace(username) == "" {
|
||||||
|
return appErrorf("mfc: username konnte nicht aus URL gelesen werden: %s", req.URL)
|
||||||
|
}
|
||||||
|
|
||||||
filename := fmt.Sprintf("%s_%s.ts", username, now.Format("01_02_2006__15-04-05"))
|
filename := fmt.Sprintf("%s_%s.ts", username, now.Format("01_02_2006__15-04-05"))
|
||||||
outPath := filepath.Join(recordDirAbs, filename)
|
outPath := filepath.Join(recordDirAbs, filename)
|
||||||
|
|
||||||
|
|||||||
@ -1,17 +1,19 @@
|
|||||||
{
|
{
|
||||||
"databaseUrl": "postgres://postgres@127.0.0.1:5432/nsfwapp?sslmode=disable",
|
"databaseUrl": "postgres://postgres@127.0.0.1:5432/nsfwapp?sslmode=disable",
|
||||||
"encryptedDbPassword": "vkNvHM+/AgEkRPW0X+rDDys4bPb5Q7ZPGqPSADCluIJ6GCDGNGlDy3s=",
|
"encryptedDbPassword": "vkNvHM+/AgEkRPW0X+rDDys4bPb5Q7ZPGqPSADCluIJ6GCDGNGlDy3s=",
|
||||||
"recordDir": "C:\\Users\\Chris\\Desktop\\privat\\records",
|
"recordDir": "C:\\Users\\Rother\\Desktop\\Privat\\records",
|
||||||
"doneDir": "C:\\Users\\Chris\\Desktop\\privat\\records\\done",
|
"doneDir": "C:\\Users\\Rother\\Desktop\\Privat\\records\\done",
|
||||||
"ffmpegPath": "",
|
"ffmpegPath": "",
|
||||||
"autoAddToDownloadList": true,
|
"autoAddToDownloadList": true,
|
||||||
|
"autoStartAddedDownloads": true,
|
||||||
"enableConcurrentDownloadsLimit": true,
|
"enableConcurrentDownloadsLimit": true,
|
||||||
"maxConcurrentDownloads": 80,
|
"maxConcurrentDownloads": 80,
|
||||||
|
"useProviderApis": true,
|
||||||
"useChaturbateApi": true,
|
"useChaturbateApi": true,
|
||||||
"useMyFreeCamsWatcher": true,
|
"useMyFreeCamsApi": true,
|
||||||
"autoDeleteSmallDownloads": true,
|
"autoDeleteSmallDownloads": true,
|
||||||
"autoDeleteSmallDownloadsBelowMB": 300,
|
"autoDeleteSmallDownloadsBelowMB": 300,
|
||||||
"autoDeleteSmallDownloadsKeepFavorites": false,
|
"autoDeleteSmallDownloadsKeepFavorites": true,
|
||||||
"lowDiskPauseBelowGB": 5,
|
"lowDiskPauseBelowGB": 5,
|
||||||
"blurPreviews": false,
|
"blurPreviews": false,
|
||||||
"teaserPlayback": "all",
|
"teaserPlayback": "all",
|
||||||
|
|||||||
@ -104,6 +104,9 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
|
|||||||
api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler)
|
api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler)
|
||||||
api.HandleFunc("/api/chaturbate/biocontext", chaturbateBioContextHandler)
|
api.HandleFunc("/api/chaturbate/biocontext", chaturbateBioContextHandler)
|
||||||
|
|
||||||
|
api.HandleFunc("/api/myfreecams/online", myfreecamsOnlineHandler)
|
||||||
|
api.HandleFunc("/api/myfreecams/biocontext", myfreecamsBioContextHandler)
|
||||||
|
|
||||||
api.HandleFunc("/api/generated/teaser", generatedTeaser)
|
api.HandleFunc("/api/generated/teaser", generatedTeaser)
|
||||||
api.HandleFunc("/api/generated/cover", generatedCover)
|
api.HandleFunc("/api/generated/cover", generatedCover)
|
||||||
api.HandleFunc("/api/generated/coverinfo/list", generatedCoverInfoList)
|
api.HandleFunc("/api/generated/coverinfo/list", generatedCoverInfoList)
|
||||||
@ -132,6 +135,7 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
|
|||||||
setCoverModelStore(store)
|
setCoverModelStore(store)
|
||||||
RegisterModelAPI(api, store)
|
RegisterModelAPI(api, store)
|
||||||
setChaturbateOnlineModelStore(store)
|
setChaturbateOnlineModelStore(store)
|
||||||
|
setMyFreeCamsOnlineModelStore(store)
|
||||||
|
|
||||||
// --------------------------
|
// --------------------------
|
||||||
// 4) Mount Protected API
|
// 4) Mount Protected API
|
||||||
|
|||||||
@ -715,6 +715,7 @@ func main() {
|
|||||||
// Hintergrund-Worker
|
// Hintergrund-Worker
|
||||||
go startDiskSpaceGuard()
|
go startDiskSpaceGuard()
|
||||||
go startChaturbateOnlinePoller(store)
|
go startChaturbateOnlinePoller(store)
|
||||||
|
go startMyFreeCamsOnlinePoller(store)
|
||||||
go startChaturbateAutoStartWorker(store)
|
go startChaturbateAutoStartWorker(store)
|
||||||
go startMyFreeCamsAutoStartWorker(store)
|
go startMyFreeCamsAutoStartWorker(store)
|
||||||
|
|
||||||
|
|||||||
@ -22,11 +22,14 @@ type RecorderSettings struct {
|
|||||||
FFmpegPath string `json:"ffmpegPath"`
|
FFmpegPath string `json:"ffmpegPath"`
|
||||||
|
|
||||||
AutoAddToDownloadList bool `json:"autoAddToDownloadList"`
|
AutoAddToDownloadList bool `json:"autoAddToDownloadList"`
|
||||||
|
AutoStartAddedDownloads bool `json:"autoStartAddedDownloads"`
|
||||||
EnableConcurrentDownloadsLimit bool `json:"enableConcurrentDownloadsLimit"`
|
EnableConcurrentDownloadsLimit bool `json:"enableConcurrentDownloadsLimit"`
|
||||||
MaxConcurrentDownloads int `json:"maxConcurrentDownloads"`
|
MaxConcurrentDownloads int `json:"maxConcurrentDownloads"`
|
||||||
|
|
||||||
UseChaturbateAPI bool `json:"useChaturbateApi"`
|
UseProviderAPIs bool `json:"useProviderApis"`
|
||||||
UseMyFreeCamsWatcher bool `json:"useMyFreeCamsWatcher"`
|
UseChaturbateAPI bool `json:"useChaturbateApi"`
|
||||||
|
UseMyFreeCamsAPI bool `json:"useMyFreeCamsApi"`
|
||||||
|
|
||||||
// Wenn aktiv, werden fertige Downloads automatisch gelöscht, wenn sie kleiner als der Grenzwert sind.
|
// Wenn aktiv, werden fertige Downloads automatisch gelöscht, wenn sie kleiner als der Grenzwert sind.
|
||||||
AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"`
|
AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"`
|
||||||
AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"`
|
AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"`
|
||||||
@ -64,11 +67,14 @@ var (
|
|||||||
FFmpegPath: "",
|
FFmpegPath: "",
|
||||||
|
|
||||||
AutoAddToDownloadList: false,
|
AutoAddToDownloadList: false,
|
||||||
|
AutoStartAddedDownloads: true,
|
||||||
EnableConcurrentDownloadsLimit: false,
|
EnableConcurrentDownloadsLimit: false,
|
||||||
MaxConcurrentDownloads: 20,
|
MaxConcurrentDownloads: 20,
|
||||||
|
|
||||||
UseChaturbateAPI: false,
|
UseProviderAPIs: false,
|
||||||
UseMyFreeCamsWatcher: false,
|
UseChaturbateAPI: false,
|
||||||
|
UseMyFreeCamsAPI: false,
|
||||||
|
|
||||||
AutoDeleteSmallDownloads: false,
|
AutoDeleteSmallDownloads: false,
|
||||||
AutoDeleteSmallDownloadsBelowMB: 50,
|
AutoDeleteSmallDownloadsBelowMB: 50,
|
||||||
AutoDeleteSmallDownloadsKeepFavorites: true,
|
AutoDeleteSmallDownloadsKeepFavorites: true,
|
||||||
@ -236,11 +242,13 @@ type RecorderSettingsPublic struct {
|
|||||||
HasDBPassword bool `json:"hasDbPassword"`
|
HasDBPassword bool `json:"hasDbPassword"`
|
||||||
|
|
||||||
AutoAddToDownloadList bool `json:"autoAddToDownloadList"`
|
AutoAddToDownloadList bool `json:"autoAddToDownloadList"`
|
||||||
|
AutoStartAddedDownloads bool `json:"autoStartAddedDownloads"`
|
||||||
EnableConcurrentDownloadsLimit bool `json:"enableConcurrentDownloadsLimit"`
|
EnableConcurrentDownloadsLimit bool `json:"enableConcurrentDownloadsLimit"`
|
||||||
MaxConcurrentDownloads int `json:"maxConcurrentDownloads"`
|
MaxConcurrentDownloads int `json:"maxConcurrentDownloads"`
|
||||||
|
|
||||||
UseChaturbateAPI bool `json:"useChaturbateApi"`
|
UseProviderAPIs bool `json:"useProviderApis"`
|
||||||
UseMyFreeCamsWatcher bool `json:"useMyFreeCamsWatcher"`
|
UseChaturbateAPI bool `json:"useChaturbateApi"`
|
||||||
|
UseMyFreeCamsAPI bool `json:"useMyFreeCamsApi"`
|
||||||
|
|
||||||
AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"`
|
AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"`
|
||||||
AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"`
|
AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"`
|
||||||
@ -274,11 +282,13 @@ func toPublicSettings(s RecorderSettings) RecorderSettingsPublic {
|
|||||||
HasDBPassword: strings.TrimSpace(s.EncryptedDBPassword) != "",
|
HasDBPassword: strings.TrimSpace(s.EncryptedDBPassword) != "",
|
||||||
|
|
||||||
AutoAddToDownloadList: s.AutoAddToDownloadList,
|
AutoAddToDownloadList: s.AutoAddToDownloadList,
|
||||||
|
AutoStartAddedDownloads: s.AutoStartAddedDownloads,
|
||||||
EnableConcurrentDownloadsLimit: s.EnableConcurrentDownloadsLimit,
|
EnableConcurrentDownloadsLimit: s.EnableConcurrentDownloadsLimit,
|
||||||
MaxConcurrentDownloads: s.MaxConcurrentDownloads,
|
MaxConcurrentDownloads: s.MaxConcurrentDownloads,
|
||||||
|
|
||||||
UseChaturbateAPI: s.UseChaturbateAPI,
|
UseProviderAPIs: s.UseProviderAPIs,
|
||||||
UseMyFreeCamsWatcher: s.UseMyFreeCamsWatcher,
|
UseChaturbateAPI: s.UseChaturbateAPI,
|
||||||
|
UseMyFreeCamsAPI: s.UseMyFreeCamsAPI,
|
||||||
|
|
||||||
AutoDeleteSmallDownloads: s.AutoDeleteSmallDownloads,
|
AutoDeleteSmallDownloads: s.AutoDeleteSmallDownloads,
|
||||||
AutoDeleteSmallDownloadsBelowMB: s.AutoDeleteSmallDownloadsBelowMB,
|
AutoDeleteSmallDownloadsBelowMB: s.AutoDeleteSmallDownloadsBelowMB,
|
||||||
@ -442,11 +452,13 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
next.EncryptedDBPassword = in.EncryptedDBPassword
|
next.EncryptedDBPassword = in.EncryptedDBPassword
|
||||||
|
|
||||||
next.AutoAddToDownloadList = in.AutoAddToDownloadList
|
next.AutoAddToDownloadList = in.AutoAddToDownloadList
|
||||||
|
next.AutoStartAddedDownloads = in.AutoStartAddedDownloads
|
||||||
next.EnableConcurrentDownloadsLimit = in.EnableConcurrentDownloadsLimit
|
next.EnableConcurrentDownloadsLimit = in.EnableConcurrentDownloadsLimit
|
||||||
next.MaxConcurrentDownloads = in.MaxConcurrentDownloads
|
next.MaxConcurrentDownloads = in.MaxConcurrentDownloads
|
||||||
|
|
||||||
|
next.UseProviderAPIs = in.UseProviderAPIs
|
||||||
next.UseChaturbateAPI = in.UseChaturbateAPI
|
next.UseChaturbateAPI = in.UseChaturbateAPI
|
||||||
next.UseMyFreeCamsWatcher = in.UseMyFreeCamsWatcher
|
next.UseMyFreeCamsAPI = in.UseMyFreeCamsAPI
|
||||||
|
|
||||||
next.AutoDeleteSmallDownloads = in.AutoDeleteSmallDownloads
|
next.AutoDeleteSmallDownloads = in.AutoDeleteSmallDownloads
|
||||||
next.AutoDeleteSmallDownloadsBelowMB = in.AutoDeleteSmallDownloadsBelowMB
|
next.AutoDeleteSmallDownloadsBelowMB = in.AutoDeleteSmallDownloadsBelowMB
|
||||||
@ -514,6 +526,7 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
if newStore != nil {
|
if newStore != nil {
|
||||||
setModelStore(newStore)
|
setModelStore(newStore)
|
||||||
setChaturbateOnlineModelStore(newStore)
|
setChaturbateOnlineModelStore(newStore)
|
||||||
|
setMyFreeCamsOnlineModelStore(newStore)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ ffmpeg/ffprobe nach Änderungen neu bestimmen
|
// ✅ ffmpeg/ffprobe nach Änderungen neu bestimmen
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"math"
|
"math"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
@ -2436,6 +2437,10 @@ func trainingRunJob(ctx context.Context, root string, count int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if detectorStatus == "trained" {
|
||||||
|
reloadAIServerModelAfterTraining()
|
||||||
|
}
|
||||||
|
|
||||||
trainingSetJobStatus(func(s *TrainingJobStatus) {
|
trainingSetJobStatus(func(s *TrainingJobStatus) {
|
||||||
finishedAt := time.Now().UTC()
|
finishedAt := time.Now().UTC()
|
||||||
|
|
||||||
@ -3329,6 +3334,39 @@ func trainingCreateUncertainNextSampleWithProgress(startedAtMs int64, requestID
|
|||||||
return best.sample, nil
|
return best.sample, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func reloadAIServerModelAfterTraining() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, aiServerURL()+"/reload", nil)
|
||||||
|
if err != nil {
|
||||||
|
appLogln("⚠️ AI Server Reload Request konnte nicht gebaut werden:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
addAIServerAuth(req)
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
appLogln("⚠️ AI Server Reload fehlgeschlagen:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
appLogln("⚠️ AI Server Reload HTTP", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
appLogln("✅ AI Server Modell nach Training neu geladen:", strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
|
||||||
func trainingDeleteSampleFiles(root string, sampleID string) {
|
func trainingDeleteSampleFiles(root string, sampleID string) {
|
||||||
sampleID = strings.TrimSpace(sampleID)
|
sampleID = strings.TrimSpace(sampleID)
|
||||||
if sampleID == "" || strings.Contains(sampleID, "/") || strings.Contains(sampleID, "\\") {
|
if sampleID == "" || strings.Contains(sampleID, "/") || strings.Contains(sampleID, "\\") {
|
||||||
|
|||||||
@ -65,8 +65,9 @@ const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = {
|
|||||||
autoStartAddedDownloads: false,
|
autoStartAddedDownloads: false,
|
||||||
enableConcurrentDownloadsLimit: false,
|
enableConcurrentDownloadsLimit: false,
|
||||||
maxConcurrentDownloads: 20,
|
maxConcurrentDownloads: 20,
|
||||||
|
useProviderApis: false,
|
||||||
useChaturbateApi: false,
|
useChaturbateApi: false,
|
||||||
useMyFreeCamsWatcher: false,
|
useMyFreeCamsApi: false,
|
||||||
autoDeleteSmallDownloads: false,
|
autoDeleteSmallDownloads: false,
|
||||||
autoDeleteSmallDownloadsBelowMB: 200,
|
autoDeleteSmallDownloadsBelowMB: 200,
|
||||||
autoDeleteSmallDownloadsKeepFavorites: true,
|
autoDeleteSmallDownloadsKeepFavorites: true,
|
||||||
@ -2503,7 +2504,12 @@ export default function App() {
|
|||||||
// Nur automatische Starts in pending-autostart schieben.
|
// Nur automatische Starts in pending-autostart schieben.
|
||||||
// Manuelle UI-Starts müssen sofort direkt starten.
|
// Manuelle UI-Starts müssen sofort direkt starten.
|
||||||
if (!immediate) {
|
if (!immediate) {
|
||||||
if (provider === 'chaturbate' && recSettingsRef.current.useChaturbateApi && keyLower) {
|
if (
|
||||||
|
provider === 'chaturbate' &&
|
||||||
|
recSettingsRef.current.useProviderApis &&
|
||||||
|
recSettingsRef.current.useChaturbateApi &&
|
||||||
|
keyLower
|
||||||
|
) {
|
||||||
queuePendingAutoStart(keyLower, norm, 'unknown', undefined, {
|
queuePendingAutoStart(keyLower, norm, 'unknown', undefined, {
|
||||||
mode: 'probe_retry',
|
mode: 'probe_retry',
|
||||||
source: 'manual',
|
source: 'manual',
|
||||||
@ -2511,7 +2517,12 @@ export default function App() {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider === 'mfc' && recSettingsRef.current.useMyFreeCamsWatcher && keyLower) {
|
if (
|
||||||
|
provider === 'mfc' &&
|
||||||
|
recSettingsRef.current.useProviderApis &&
|
||||||
|
recSettingsRef.current.useMyFreeCamsApi &&
|
||||||
|
keyLower
|
||||||
|
) {
|
||||||
queuePendingAutoStart(keyLower, norm, 'offline', undefined, {
|
queuePendingAutoStart(keyLower, norm, 'offline', undefined, {
|
||||||
mode: 'probe_retry',
|
mode: 'probe_retry',
|
||||||
source: 'manual',
|
source: 'manual',
|
||||||
@ -3837,7 +3848,7 @@ export default function App() {
|
|||||||
if (autoAddEnabled) setSourceUrl(url)
|
if (autoAddEnabled) setSourceUrl(url)
|
||||||
|
|
||||||
if (autoStartEnabled) {
|
if (autoStartEnabled) {
|
||||||
void startUrl(url, { silent: false })
|
void startUrl(url, { silent: false, immediate: true })
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
|
|||||||
@ -97,11 +97,29 @@ function ssSet(key: string, value: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ssKeyOnline(modelKey: string) {
|
type ProviderKey = 'chaturbate' | 'myfreecams'
|
||||||
return `md:cb:online:${modelKey}`
|
|
||||||
|
function providerOfModel(model?: StoredModel | null): ProviderKey {
|
||||||
|
const host = String(model?.host ?? '').toLowerCase()
|
||||||
|
const chatUrl = String(model?.chatRoomUrl ?? '').toLowerCase()
|
||||||
|
|
||||||
|
if (host.includes('myfreecams') || chatUrl.includes('myfreecams.com')) {
|
||||||
|
return 'myfreecams'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'chaturbate'
|
||||||
}
|
}
|
||||||
function ssKeyBio(modelKey: string) {
|
|
||||||
return `md:cb:bio:${modelKey}`
|
function mdCacheKey(provider: ProviderKey, modelKey: string) {
|
||||||
|
return `${provider}:${modelKey}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function ssKeyOnline(provider: ProviderKey, modelKey: string) {
|
||||||
|
return `md:${provider}:online:${modelKey}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function ssKeyBio(provider: ProviderKey, modelKey: string) {
|
||||||
|
return `md:${provider}:bio:${modelKey}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const nf = new Intl.NumberFormat('de-DE')
|
const nf = new Intl.NumberFormat('de-DE')
|
||||||
@ -403,11 +421,17 @@ type BioResp = {
|
|||||||
fetchedAt?: string
|
fetchedAt?: string
|
||||||
lastError?: string
|
lastError?: string
|
||||||
model?: string
|
model?: string
|
||||||
bio?: BioContext | null
|
bio?: BioContext | any | null
|
||||||
roomStatus?: string
|
roomStatus?: string
|
||||||
isOnline?: boolean
|
isOnline?: boolean
|
||||||
chatRoomUrl?: string
|
chatRoomUrl?: string
|
||||||
imageUrl?: string
|
imageUrl?: string
|
||||||
|
|
||||||
|
avatarUrl?: string
|
||||||
|
snapUrl?: string
|
||||||
|
roomTopic?: string
|
||||||
|
country?: string
|
||||||
|
gender?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------ props ------
|
// ------ props ------
|
||||||
@ -416,8 +440,13 @@ type BioResp = {
|
|||||||
// /api/models liefert StoredModel aus dem models_store
|
// /api/models liefert StoredModel aus dem models_store
|
||||||
type StoredModel = {
|
type StoredModel = {
|
||||||
id: string
|
id: string
|
||||||
|
host?: string | null
|
||||||
modelKey: string
|
modelKey: string
|
||||||
tags?: string | null
|
tags?: string | null
|
||||||
|
|
||||||
|
gender?: string | null
|
||||||
|
country?: string | null
|
||||||
|
|
||||||
lastSeenOnline?: boolean | null
|
lastSeenOnline?: boolean | null
|
||||||
lastSeenOnlineAt?: string
|
lastSeenOnlineAt?: string
|
||||||
|
|
||||||
@ -511,6 +540,116 @@ function buildChaturbateCookieHeader(cookies?: Record<string, string>): string {
|
|||||||
return parts.join('; ')
|
return parts.join('; ')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function asMFCUser(data: BioResp | null | undefined): any | null {
|
||||||
|
return (data?.bio as any)?.result?.user ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNum(v: any): number | undefined {
|
||||||
|
const n = Number(v)
|
||||||
|
return Number.isFinite(n) ? n : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonZeroDate(v: any): string {
|
||||||
|
const s = String(v ?? '').trim()
|
||||||
|
if (!s || s === '0000-00-00') return ''
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
function mfcLocation(profile: any): string {
|
||||||
|
const city = String(profile?.city ?? '').trim()
|
||||||
|
const country = String(profile?.country ?? '').trim()
|
||||||
|
return [city, country].filter(Boolean).join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function mfcDisplayNameFromUser(user: any, fallbackKey?: string): string {
|
||||||
|
const profile = user?.profile ?? {}
|
||||||
|
|
||||||
|
const blurbName = stripHtmlToText(profile?.blurb)
|
||||||
|
if (blurbName) return blurbName
|
||||||
|
|
||||||
|
const username = String(user?.username ?? fallbackKey ?? '').trim()
|
||||||
|
return username
|
||||||
|
}
|
||||||
|
|
||||||
|
function mfcSocialsToBioSocials(social: any): BioSocial[] {
|
||||||
|
const out: BioSocial[] = []
|
||||||
|
|
||||||
|
const twitter = String(social?.twitter_username ?? '').trim()
|
||||||
|
if (twitter) {
|
||||||
|
out.push({
|
||||||
|
id: 1,
|
||||||
|
title_name: 'Twitter/X',
|
||||||
|
link: `https://x.com/${twitter.replace(/^@/, '')}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const instagram = String(social?.instagram_username ?? '').trim()
|
||||||
|
if (instagram) {
|
||||||
|
out.push({
|
||||||
|
id: 2,
|
||||||
|
title_name: 'Instagram',
|
||||||
|
link: `https://instagram.com/${instagram.replace(/^@/, '')}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function mfcRoomFromBioResp(data: BioResp | null | undefined, fallbackKey: string): ChaturbateRoom | null {
|
||||||
|
const user = asMFCUser(data)
|
||||||
|
if (!user) return null
|
||||||
|
|
||||||
|
const profile = user.profile ?? {}
|
||||||
|
const session = Array.isArray(user.sessions) && user.sessions.length > 0 ? user.sessions[0] : null
|
||||||
|
|
||||||
|
const username = String(user.username ?? fallbackKey ?? '').trim()
|
||||||
|
const displayName = mfcDisplayNameFromUser(user, username || fallbackKey)
|
||||||
|
const roomStatus = String(data?.roomStatus ?? '').trim().toLowerCase()
|
||||||
|
const image = String(data?.imageUrl ?? user.avatar ?? '').trim()
|
||||||
|
|
||||||
|
return {
|
||||||
|
username,
|
||||||
|
display_name: displayName || username,
|
||||||
|
current_show: roomStatus || (session ? 'public' : 'offline'),
|
||||||
|
room_subject: String(session?.room_topic ?? data?.roomTopic ?? '').trim(),
|
||||||
|
tags: Array.isArray(user.tags) ? user.tags : [],
|
||||||
|
num_users: toNum(session?.room_count),
|
||||||
|
num_followers: toNum(user.share?.follows),
|
||||||
|
gender: String(profile.gender ?? data?.gender ?? '').trim(),
|
||||||
|
country: String(profile.country ?? data?.country ?? '').trim(),
|
||||||
|
location: mfcLocation(profile),
|
||||||
|
age: toNum(profile.age),
|
||||||
|
birthday: nonZeroDate(profile.birthdate),
|
||||||
|
image_url: image,
|
||||||
|
image_url_360x270: image,
|
||||||
|
chat_room_url: String(data?.chatRoomUrl ?? `https://www.myfreecams.com/#${username}`).trim(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mfcBioFromBioResp(data: BioResp | null | undefined): BioContext | null {
|
||||||
|
const user = asMFCUser(data)
|
||||||
|
if (!user) return null
|
||||||
|
|
||||||
|
const profile = user.profile ?? {}
|
||||||
|
const social = user.social ?? {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
follower_count: toNum(user.share?.follows),
|
||||||
|
location: mfcLocation(profile),
|
||||||
|
body_type: String(profile.body_type ?? '').trim(),
|
||||||
|
display_birthday: nonZeroDate(profile.birthdate),
|
||||||
|
display_age: toNum(profile.age),
|
||||||
|
sex: String(profile.gender ?? data?.gender ?? '').trim(),
|
||||||
|
room_status: String(data?.roomStatus ?? '').trim(),
|
||||||
|
|
||||||
|
// MFC liefert hier relative Strings wie "2 days ago".
|
||||||
|
last_broadcast: String(user.last_login ?? '').trim(),
|
||||||
|
|
||||||
|
about_me: stripHtmlToText(profile.blurb),
|
||||||
|
smoke_drink: [profile.smoke, profile.drink].map((x) => String(x ?? '').trim()).filter(Boolean).join(' / '),
|
||||||
|
social_medias: mfcSocialsToBioSocials(social),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function ModelDetails({
|
export default function ModelDetails({
|
||||||
open,
|
open,
|
||||||
@ -587,6 +726,13 @@ export default function ModelDetails({
|
|||||||
const removingKeys = useMemo(() => new Set<string>(), [])
|
const removingKeys = useMemo(() => new Set<string>(), [])
|
||||||
const deletedKeys = useMemo(() => new Set<string>(), [])
|
const deletedKeys = useMemo(() => new Set<string>(), [])
|
||||||
|
|
||||||
|
const model = useMemo(() => {
|
||||||
|
if (!key) return null
|
||||||
|
return models.find((m) => (m.modelKey || '').toLowerCase() === key) ?? null
|
||||||
|
}, [models, key])
|
||||||
|
|
||||||
|
const provider = useMemo<ProviderKey>(() => providerOfModel(model), [model])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
bioReqRef.current?.abort()
|
bioReqRef.current?.abort()
|
||||||
@ -622,8 +768,10 @@ export default function ModelDetails({
|
|||||||
if (!open || !key) return
|
if (!open || !key) return
|
||||||
|
|
||||||
// Online
|
// Online
|
||||||
const memOnline = mdOnlineMem.get(key)
|
const ck = mdCacheKey(provider, key)
|
||||||
const ssOnline = ssGet<OnlineCacheEntry>(ssKeyOnline(key))
|
|
||||||
|
const memOnline = mdOnlineMem.get(ck)
|
||||||
|
const ssOnline = ssGet<OnlineCacheEntry>(ssKeyOnline(provider, key))
|
||||||
|
|
||||||
const onlineHit =
|
const onlineHit =
|
||||||
(memOnline && isFresh(memOnline.at) ? memOnline : null) ||
|
(memOnline && isFresh(memOnline.at) ? memOnline : null) ||
|
||||||
@ -635,8 +783,8 @@ export default function ModelDetails({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Bio
|
// Bio
|
||||||
const memBio = mdBioMem.get(key)
|
const memBio = mdBioMem.get(ck)
|
||||||
const ssBio = ssGet<BioCacheEntry>(ssKeyBio(key))
|
const ssBio = ssGet<BioCacheEntry>(ssKeyBio(provider, key))
|
||||||
|
|
||||||
const bioHit =
|
const bioHit =
|
||||||
(memBio && isFresh(memBio.at) ? memBio : null) ||
|
(memBio && isFresh(memBio.at) ? memBio : null) ||
|
||||||
@ -647,7 +795,7 @@ export default function ModelDetails({
|
|||||||
setBioMeta(bioHit.meta ?? null)
|
setBioMeta(bioHit.meta ?? null)
|
||||||
// bioLoading NICHT anfassen – Fetch kann trotzdem laufen
|
// bioLoading NICHT anfassen – Fetch kann trotzdem laufen
|
||||||
}
|
}
|
||||||
}, [open, key])
|
}, [open, key, provider])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
@ -657,15 +805,20 @@ export default function ModelDetails({
|
|||||||
const refreshBio = useCallback(async () => {
|
const refreshBio = useCallback(async () => {
|
||||||
if (!key) return
|
if (!key) return
|
||||||
|
|
||||||
// vorherigen abbrechen
|
|
||||||
bioReqRef.current?.abort()
|
bioReqRef.current?.abort()
|
||||||
const ac = new AbortController()
|
const ac = new AbortController()
|
||||||
bioReqRef.current = ac
|
bioReqRef.current = ac
|
||||||
|
|
||||||
setBioLoading(true)
|
setBioLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cookieHeader = buildChaturbateCookieHeader(cookies)
|
const isMFC = provider === 'myfreecams'
|
||||||
const url = `/api/chaturbate/biocontext?model=${encodeURIComponent(key)}&refresh=1`
|
|
||||||
|
const url = isMFC
|
||||||
|
? `/api/myfreecams/biocontext?model=${encodeURIComponent(key)}&refresh=1`
|
||||||
|
: `/api/chaturbate/biocontext?model=${encodeURIComponent(key)}&refresh=1`
|
||||||
|
|
||||||
|
const cookieHeader = !isMFC ? buildChaturbateCookieHeader(cookies) : ''
|
||||||
|
|
||||||
const r = await fetch(url, {
|
const r = await fetch(url, {
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
@ -679,22 +832,68 @@ export default function ModelDetails({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = (await r.json().catch(() => null)) as BioResp
|
const data = (await r.json().catch(() => null)) as BioResp
|
||||||
const meta = { enabled: data?.enabled, fetchedAt: data?.fetchedAt, lastError: data?.lastError }
|
|
||||||
|
const meta = {
|
||||||
|
enabled: data?.enabled,
|
||||||
|
fetchedAt: data?.fetchedAt,
|
||||||
|
lastError: data?.lastError,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMFC) {
|
||||||
|
const nextRoom = mfcRoomFromBioResp(data, key)
|
||||||
|
const nextBio = mfcBioFromBioResp(data)
|
||||||
|
|
||||||
|
setRoomMeta(meta)
|
||||||
|
setRoom(nextRoom)
|
||||||
|
|
||||||
|
setBioMeta(meta)
|
||||||
|
setBio(nextBio)
|
||||||
|
|
||||||
|
const ck = mdCacheKey(provider, key)
|
||||||
|
|
||||||
|
const onlineEntry: OnlineCacheEntry = {
|
||||||
|
at: Date.now(),
|
||||||
|
room: nextRoom,
|
||||||
|
meta,
|
||||||
|
}
|
||||||
|
|
||||||
|
const bioEntry: BioCacheEntry = {
|
||||||
|
at: Date.now(),
|
||||||
|
bio: nextBio,
|
||||||
|
meta,
|
||||||
|
}
|
||||||
|
|
||||||
|
mdOnlineMem.set(ck, onlineEntry)
|
||||||
|
ssSet(ssKeyOnline(provider, key), onlineEntry)
|
||||||
|
|
||||||
|
mdBioMem.set(ck, bioEntry)
|
||||||
|
ssSet(ssKeyBio(provider, key), bioEntry)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const nextBio = (data?.bio as BioContext) ?? null
|
const nextBio = (data?.bio as BioContext) ?? null
|
||||||
|
|
||||||
setBioMeta(meta)
|
setBioMeta(meta)
|
||||||
setBio(nextBio)
|
setBio(nextBio)
|
||||||
|
|
||||||
|
const ck = mdCacheKey(provider, key)
|
||||||
const entry: BioCacheEntry = { at: Date.now(), bio: nextBio, meta }
|
const entry: BioCacheEntry = { at: Date.now(), bio: nextBio, meta }
|
||||||
mdBioMem.set(key, entry)
|
|
||||||
ssSet(ssKeyBio(key), entry)
|
mdBioMem.set(ck, entry)
|
||||||
|
ssSet(ssKeyBio(provider, key), entry)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e?.name === 'AbortError') return
|
if (e?.name === 'AbortError') return
|
||||||
setBioMeta({ enabled: undefined, fetchedAt: undefined, lastError: e?.message || 'Fetch fehlgeschlagen' })
|
|
||||||
|
setBioMeta({
|
||||||
|
enabled: undefined,
|
||||||
|
fetchedAt: undefined,
|
||||||
|
lastError: e?.message || 'Fetch fehlgeschlagen',
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setBioLoading(false)
|
setBioLoading(false)
|
||||||
}
|
}
|
||||||
}, [key, cookies])
|
}, [key, provider, cookies])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) return
|
if (open) return
|
||||||
@ -839,18 +1038,35 @@ export default function ModelDetails({
|
|||||||
}
|
}
|
||||||
}, [open, runningJobs])
|
}, [open, runningJobs])
|
||||||
|
|
||||||
const model = useMemo(() => {
|
|
||||||
if (!key) return null
|
|
||||||
return models.find((m) => (m.modelKey || '').toLowerCase() === key) ?? null
|
|
||||||
}, [models, key])
|
|
||||||
|
|
||||||
const storedRoomFromSnap = useMemo<ChaturbateRoom | null>(() => {
|
const storedRoomFromSnap = useMemo<ChaturbateRoom | null>(() => {
|
||||||
const raw = (model as any)?.cbOnlineJson
|
const raw = (model as any)?.cbOnlineJson
|
||||||
if (!raw || typeof raw !== 'string') return null
|
|
||||||
try {
|
if (raw && typeof raw === 'string') {
|
||||||
return JSON.parse(raw) as ChaturbateRoom
|
try {
|
||||||
} catch {
|
return JSON.parse(raw) as ChaturbateRoom
|
||||||
return null
|
} catch {
|
||||||
|
// fallback unten
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!model) return null
|
||||||
|
|
||||||
|
const image = String((model as any)?.imageUrl ?? '').trim()
|
||||||
|
const status = String((model as any)?.roomStatus ?? '').trim()
|
||||||
|
const chatRoomUrl = String((model as any)?.chatRoomUrl ?? '').trim()
|
||||||
|
|
||||||
|
if (!image && !status && !chatRoomUrl) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
username: model.modelKey,
|
||||||
|
display_name: model.modelKey,
|
||||||
|
current_show: status,
|
||||||
|
image_url: image,
|
||||||
|
image_url_360x270: image,
|
||||||
|
chat_room_url: chatRoomUrl,
|
||||||
|
gender: String((model as any)?.gender ?? '').trim(),
|
||||||
|
country: String((model as any)?.country ?? '').trim(),
|
||||||
|
tags: splitTags(model.tags),
|
||||||
}
|
}
|
||||||
}, [model])
|
}, [model])
|
||||||
|
|
||||||
@ -908,8 +1124,13 @@ export default function ModelDetails({
|
|||||||
|
|
||||||
const profileHref = useMemo(() => {
|
const profileHref = useMemo(() => {
|
||||||
if (!profileUsername) return ''
|
if (!profileUsername) return ''
|
||||||
|
|
||||||
|
if (provider === 'myfreecams') {
|
||||||
|
return `https://www.myfreecams.com/#${encodeURIComponent(profileUsername)}`
|
||||||
|
}
|
||||||
|
|
||||||
return `https://chaturbate.com/${encodeURIComponent(profileUsername)}`
|
return `https://chaturbate.com/${encodeURIComponent(profileUsername)}`
|
||||||
}, [profileUsername])
|
}, [profileUsername, provider])
|
||||||
|
|
||||||
const currentOnlineState = useMemo(() => {
|
const currentOnlineState = useMemo(() => {
|
||||||
const roomStatus = String(model?.roomStatus ?? '').trim().toLowerCase()
|
const roomStatus = String(model?.roomStatus ?? '').trim().toLowerCase()
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import Task from './Task'
|
|||||||
import TaskList from './TaskList'
|
import TaskList from './TaskList'
|
||||||
import type { TaskItem } from './TaskList'
|
import type { TaskItem } from './TaskList'
|
||||||
import PostgresUrlModal from './PostgresUrlModal'
|
import PostgresUrlModal from './PostgresUrlModal'
|
||||||
import { CheckIcon, XMarkIcon } from '@heroicons/react/24/solid'
|
import { CheckIcon, ChevronDownIcon, XMarkIcon } from '@heroicons/react/24/solid'
|
||||||
import { startRegistration } from '@simplewebauthn/browser'
|
import { startRegistration } from '@simplewebauthn/browser'
|
||||||
|
|
||||||
type RecorderSettings = {
|
type RecorderSettings = {
|
||||||
@ -22,8 +22,9 @@ type RecorderSettings = {
|
|||||||
autoStartAddedDownloads?: boolean
|
autoStartAddedDownloads?: boolean
|
||||||
enableConcurrentDownloadsLimit?: boolean
|
enableConcurrentDownloadsLimit?: boolean
|
||||||
maxConcurrentDownloads?: number
|
maxConcurrentDownloads?: number
|
||||||
|
useProviderApis?: boolean
|
||||||
useChaturbateApi?: boolean
|
useChaturbateApi?: boolean
|
||||||
useMyFreeCamsWatcher?: boolean
|
useMyFreeCamsApi?: boolean
|
||||||
autoDeleteSmallDownloads?: boolean
|
autoDeleteSmallDownloads?: boolean
|
||||||
autoDeleteSmallDownloadsBelowMB?: number
|
autoDeleteSmallDownloadsBelowMB?: number
|
||||||
autoDeleteSmallDownloadsKeepFavorites?: boolean
|
autoDeleteSmallDownloadsKeepFavorites?: boolean
|
||||||
@ -141,7 +142,7 @@ function SecurityDeviceRow(props: {
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-3 rounded-xl border border-gray-200 bg-white p-3 dark:border-white/10 dark:bg-gray-950/40">
|
<div className="flex items-start gap-3 rounded-xl border border-gray-200 bg-white p-3 dark:border-white/10 dark:bg-gray-950/40">
|
||||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gray-100 text-lg dark:bg-white/10">
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gray-100 text-lg shadow-sm ring-1 ring-gray-200 dark:bg-white/10 dark:ring-white/10">
|
||||||
{props.icon}
|
{props.icon}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -178,6 +179,96 @@ function SecurityDeviceRow(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SettingsSection(props: {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
icon?: ReactNode
|
||||||
|
actions?: ReactNode
|
||||||
|
defaultOpen?: boolean
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
const storageKey = `recorder-settings:section:${props.id}:open`
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(() => {
|
||||||
|
if (typeof window === 'undefined') return props.defaultOpen ?? true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(storageKey)
|
||||||
|
if (raw === '1') return true
|
||||||
|
if (raw === '0') return false
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.defaultOpen ?? true
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleOpen() {
|
||||||
|
setOpen((cur) => {
|
||||||
|
const next = !cur
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(storageKey, next ? '1' : '0')
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="overflow-hidden rounded-2xl bg-white shadow-md shadow-gray-200/70 ring-1 ring-gray-300/80 transition-shadow hover:shadow-lg dark:bg-gray-950/70 dark:shadow-black/30 dark:ring-white/15">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleOpen}
|
||||||
|
className="flex w-full items-start justify-between gap-4 rounded-2xl bg-white/95 p-4 text-left transition hover:bg-gray-50 focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 dark:bg-gray-950/70 dark:hover:bg-white/5 dark:focus-visible:ring-indigo-400"
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{props.icon ? (
|
||||||
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gray-100 text-lg dark:bg-white/10">
|
||||||
|
{props.icon}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
{props.title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{props.description ? (
|
||||||
|
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||||||
|
{props.description}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
{props.actions}
|
||||||
|
|
||||||
|
<ChevronDownIcon
|
||||||
|
className={[
|
||||||
|
'h-5 w-5 text-gray-400 transition-transform duration-200 dark:text-gray-500',
|
||||||
|
open ? 'rotate-180' : '',
|
||||||
|
].join(' ')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open ? (
|
||||||
|
<div className="border-t border-gray-200 p-4 pt-3 dark:border-white/10">
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULTS: RecorderSettings = {
|
const DEFAULTS: RecorderSettings = {
|
||||||
databaseUrl: '',
|
databaseUrl: '',
|
||||||
hasDbPassword: false,
|
hasDbPassword: false,
|
||||||
@ -188,8 +279,9 @@ const DEFAULTS: RecorderSettings = {
|
|||||||
autoStartAddedDownloads: true,
|
autoStartAddedDownloads: true,
|
||||||
enableConcurrentDownloadsLimit: false,
|
enableConcurrentDownloadsLimit: false,
|
||||||
maxConcurrentDownloads: 20,
|
maxConcurrentDownloads: 20,
|
||||||
|
useProviderApis: false,
|
||||||
useChaturbateApi: false,
|
useChaturbateApi: false,
|
||||||
useMyFreeCamsWatcher: false,
|
useMyFreeCamsApi: false,
|
||||||
autoDeleteSmallDownloads: true,
|
autoDeleteSmallDownloads: true,
|
||||||
autoDeleteSmallDownloadsBelowMB: 200,
|
autoDeleteSmallDownloadsBelowMB: 200,
|
||||||
autoDeleteSmallDownloadsKeepFavorites: true,
|
autoDeleteSmallDownloadsKeepFavorites: true,
|
||||||
@ -578,8 +670,15 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
maxConcurrentDownloads:
|
maxConcurrentDownloads:
|
||||||
(data as any).maxConcurrentDownloads ?? DEFAULTS.maxConcurrentDownloads,
|
(data as any).maxConcurrentDownloads ?? DEFAULTS.maxConcurrentDownloads,
|
||||||
|
|
||||||
useChaturbateApi: data.useChaturbateApi ?? DEFAULTS.useChaturbateApi,
|
useProviderApis:
|
||||||
useMyFreeCamsWatcher: data.useMyFreeCamsWatcher ?? DEFAULTS.useMyFreeCamsWatcher,
|
(data as any).useProviderApis ??
|
||||||
|
Boolean((data as any).useChaturbateApi || (data as any).useMyFreeCamsApi),
|
||||||
|
|
||||||
|
useChaturbateApi:
|
||||||
|
(data as any).useChaturbateApi ?? DEFAULTS.useChaturbateApi,
|
||||||
|
|
||||||
|
useMyFreeCamsApi:
|
||||||
|
(data as any).useMyFreeCamsApi ?? DEFAULTS.useMyFreeCamsApi,
|
||||||
autoDeleteSmallDownloads: data.autoDeleteSmallDownloads ?? DEFAULTS.autoDeleteSmallDownloads,
|
autoDeleteSmallDownloads: data.autoDeleteSmallDownloads ?? DEFAULTS.autoDeleteSmallDownloads,
|
||||||
autoDeleteSmallDownloadsBelowMB: data.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB,
|
autoDeleteSmallDownloadsBelowMB: data.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB,
|
||||||
autoDeleteSmallDownloadsKeepFavorites:
|
autoDeleteSmallDownloadsKeepFavorites:
|
||||||
@ -971,8 +1070,9 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
Math.min(100, Math.floor(Number((value as any).maxConcurrentDownloads ?? DEFAULTS.maxConcurrentDownloads ?? 20)))
|
Math.min(100, Math.floor(Number((value as any).maxConcurrentDownloads ?? DEFAULTS.maxConcurrentDownloads ?? 20)))
|
||||||
)
|
)
|
||||||
|
|
||||||
const useChaturbateApi = !!value.useChaturbateApi
|
const useProviderApis = !!value.useProviderApis
|
||||||
const useMyFreeCamsWatcher = !!value.useMyFreeCamsWatcher
|
const useChaturbateApi = useProviderApis && !!value.useChaturbateApi
|
||||||
|
const useMyFreeCamsApi = useProviderApis && !!value.useMyFreeCamsApi
|
||||||
const autoDeleteSmallDownloads = !!value.autoDeleteSmallDownloads
|
const autoDeleteSmallDownloads = !!value.autoDeleteSmallDownloads
|
||||||
const autoDeleteSmallDownloadsBelowMB = Math.max(
|
const autoDeleteSmallDownloadsBelowMB = Math.max(
|
||||||
0,
|
0,
|
||||||
@ -1015,8 +1115,9 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
autoStartAddedDownloads,
|
autoStartAddedDownloads,
|
||||||
enableConcurrentDownloadsLimit,
|
enableConcurrentDownloadsLimit,
|
||||||
maxConcurrentDownloads,
|
maxConcurrentDownloads,
|
||||||
|
useProviderApis,
|
||||||
useChaturbateApi,
|
useChaturbateApi,
|
||||||
useMyFreeCamsWatcher,
|
useMyFreeCamsApi,
|
||||||
autoDeleteSmallDownloads,
|
autoDeleteSmallDownloads,
|
||||||
autoDeleteSmallDownloadsBelowMB,
|
autoDeleteSmallDownloadsBelowMB,
|
||||||
autoDeleteSmallDownloadsKeepFavorites,
|
autoDeleteSmallDownloadsKeepFavorites,
|
||||||
@ -1605,17 +1706,14 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Aufgaben */}
|
{/* Aufgaben */}
|
||||||
<div className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-950/40">
|
<SettingsSection
|
||||||
<div className="flex items-start justify-between gap-4">
|
id="tasks"
|
||||||
<div className="min-w-0">
|
title="Aufgaben"
|
||||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Aufgaben</div>
|
description="Hintergrundaufgaben wie z.B. Asset/Preview-Generierung."
|
||||||
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
icon="⚙️"
|
||||||
Hintergrundaufgaben wie z.B. Asset/Preview-Generierung.
|
defaultOpen={true}
|
||||||
</div>
|
>
|
||||||
</div>
|
<div className="space-y-3">
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-3 space-y-3">
|
|
||||||
<Task
|
<Task
|
||||||
title="Assets-Generator"
|
title="Assets-Generator"
|
||||||
description="Erzeugt nur fehlende oder unvollständige Assets der aktivierten Phasen. Nicht ausgewählte Phasen werden komplett übersprungen."
|
description="Erzeugt nur fehlende oder unvollständige Assets der aktivierten Phasen. Nicht ausgewählte Phasen werden komplett übersprungen."
|
||||||
@ -1701,10 +1799,16 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</SettingsSection>
|
||||||
|
|
||||||
{/* Paths */}
|
{/* Paths */}
|
||||||
<div className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-950/40">
|
<SettingsSection
|
||||||
|
id="paths"
|
||||||
|
title="Pfad-Einstellungen"
|
||||||
|
description="Aufnahme- und Zielverzeichnisse sowie optionaler ffmpeg-Pfad."
|
||||||
|
icon="📁"
|
||||||
|
defaultOpen={false}
|
||||||
|
>
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Pfad-Einstellungen</div>
|
<div className="text-sm font-semibold text-gray-900 dark:text-white">Pfad-Einstellungen</div>
|
||||||
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||||||
@ -1817,10 +1921,16 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</SettingsSection>
|
||||||
|
|
||||||
{/* Automatisierung */}
|
{/* Automatisierung */}
|
||||||
<div className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-950/40">
|
<SettingsSection
|
||||||
|
id="automation"
|
||||||
|
title="Automatisierung & Anzeige"
|
||||||
|
description="Verhalten beim Hinzufügen/Starten sowie Anzeigeoptionen."
|
||||||
|
icon="🤖"
|
||||||
|
defaultOpen={false}
|
||||||
|
>
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Automatisierung & Anzeige</div>
|
<div className="text-sm font-semibold text-gray-900 dark:text-white">Automatisierung & Anzeige</div>
|
||||||
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||||||
@ -1895,12 +2005,13 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
setValue((v) => ({
|
setValue((v) => ({
|
||||||
...v,
|
...v,
|
||||||
autoAddToDownloadList: checked,
|
autoAddToDownloadList: checked,
|
||||||
|
autoStartAddedDownloads: checked ? v.autoStartAddedDownloads : false,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
label="Automatisch zur Downloadliste hinzufügen"
|
label="Clipboard-Links in Start-URL übernehmen"
|
||||||
description="Neue Links/Modelle werden automatisch in die Downloadliste übernommen."
|
description="Übernimmt erkannte Chaturbate- und MyFreeCams-Links aus der Zwischenablage automatisch in das Start-URL-Feld. Wenn deaktiviert, werden Clipboard-Links ignoriert."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<LabeledSwitch
|
<LabeledSwitch
|
||||||
checked={!!value.autoStartAddedDownloads}
|
checked={!!value.autoStartAddedDownloads}
|
||||||
onChange={(checked) =>
|
onChange={(checked) =>
|
||||||
@ -1910,23 +2021,58 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
autoAddToDownloadList: checked ? true : v.autoAddToDownloadList,
|
autoAddToDownloadList: checked ? true : v.autoAddToDownloadList,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
label="Automatisch starten"
|
label="Übernommene Links automatisch starten"
|
||||||
description="Neue Links aus der Zwischenablage werden automatisch gestartet. Aktiviert automatisch auch das Hinzufügen zur Downloadliste."
|
description="Startet automatisch, sobald ein erkannter Clipboard-Link übernommen wurde oder ein aktivierter Provider ein watched Model als öffentlich online erkennt. Aktiviert automatisch auch die Clipboard-Übernahme."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<LabeledSwitch
|
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
|
||||||
checked={!!value.useChaturbateApi}
|
<LabeledSwitch
|
||||||
onChange={(checked) => setValue((v) => ({ ...v, useChaturbateApi: checked }))}
|
checked={!!value.useProviderApis}
|
||||||
label="Chaturbate API"
|
onChange={(checked) =>
|
||||||
description="Wenn aktiv, pollt das Backend alle paar Sekunden die Online-Rooms API und cached die aktuell online Models."
|
setValue((v) => ({
|
||||||
/>
|
...v,
|
||||||
|
useProviderApis: checked,
|
||||||
|
useChaturbateApi: checked ? v.useChaturbateApi : false,
|
||||||
|
useMyFreeCamsApi: checked ? v.useMyFreeCamsApi : false,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
label="Provider-APIs verwenden"
|
||||||
|
description="Master-Schalter für externe Provider-APIs. Wenn deaktiviert, werden keine automatischen API-Abfragen für Online-Status, Profilbilder oder Model-Daten ausgeführt."
|
||||||
|
/>
|
||||||
|
|
||||||
<LabeledSwitch
|
<div
|
||||||
checked={!!value.useMyFreeCamsWatcher}
|
className={
|
||||||
onChange={(checked) => setValue((v) => ({ ...v, useMyFreeCamsWatcher: checked }))}
|
'mt-3 space-y-3 border-t border-gray-200 pt-3 dark:border-white/10 ' +
|
||||||
label="MyFreeCams Auto-Check (watched)"
|
(!value.useProviderApis ? 'opacity-50 pointer-events-none' : '')
|
||||||
description="Geht watched MyFreeCams-Models einzeln durch und startet einen Download. Wenn keine Output-Datei entsteht, ist der Stream nicht öffentlich (offline/away/private) und der Job wird wieder entfernt."
|
}
|
||||||
/>
|
>
|
||||||
|
<LabeledSwitch
|
||||||
|
checked={!!value.useChaturbateApi}
|
||||||
|
onChange={(checked) =>
|
||||||
|
setValue((v) => ({
|
||||||
|
...v,
|
||||||
|
useProviderApis: checked ? true : v.useProviderApis,
|
||||||
|
useChaturbateApi: checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
label="Chaturbate API"
|
||||||
|
description="Aktualisiert Online-Status, Vorschaubilder und Model-Daten über die Chaturbate-API. Wenn Automatisch starten aktiv ist, werden öffentliche watched Models gestartet."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LabeledSwitch
|
||||||
|
checked={!!value.useMyFreeCamsApi}
|
||||||
|
onChange={(checked) =>
|
||||||
|
setValue((v) => ({
|
||||||
|
...v,
|
||||||
|
useProviderApis: checked ? true : v.useProviderApis,
|
||||||
|
useMyFreeCamsApi: checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
label="MyFreeCams API"
|
||||||
|
description="Aktualisiert Online-Status, Vorschaubilder und Model-Daten über usernameLookup. Wenn Automatisch starten aktiv ist, werden öffentliche watched Models gestartet. Bei 'servers busy' pausiert die App MyFreeCams-Anfragen automatisch."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ✅ NEU: Auto-Delete kleine Downloads */}
|
{/* ✅ NEU: Auto-Delete kleine Downloads */}
|
||||||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
|
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
|
||||||
@ -2226,283 +2372,269 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</SettingsSection>
|
||||||
|
|
||||||
{/* Sicherheit */}
|
{/* Sicherheit */}
|
||||||
<section className="mt-8 rounded-3xl border-2 border-indigo-200 bg-indigo-50/70 p-1 shadow-sm dark:border-indigo-400/20 dark:bg-indigo-500/5">
|
<SettingsSection
|
||||||
<div className="rounded-[1.35rem] bg-white p-4 dark:bg-gray-950/70">
|
id="security"
|
||||||
<div className="mb-4 flex flex-col gap-3 border-b border-indigo-100 pb-4 dark:border-indigo-400/10 sm:flex-row sm:items-start sm:justify-between">
|
title="Konto-Sicherheit"
|
||||||
<div className="min-w-0">
|
description="Passwort, Zwei-Faktor-Authentifizierung und Passkeys getrennt von den Recorder-Einstellungen verwalten."
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
icon="🛡️"
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-2xl bg-indigo-100 text-lg dark:bg-indigo-500/15">
|
defaultOpen={false}
|
||||||
🛡️
|
actions={
|
||||||
|
<>
|
||||||
|
{statusPill(!!authStatus?.totpEnabled, '2FA aktiv', '2FA aus')}
|
||||||
|
{statusPill(!!authStatus?.passkeyConfigured, 'Passkey aktiv', 'Kein Passkey')}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
Passwort ändern
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||||||
<div>
|
Ändert das Login-Passwort. Andere Sessions werden abgemeldet.
|
||||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
|
||||||
Konto-Sicherheit
|
|
||||||
</div>
|
|
||||||
<div className="mt-0.5 text-xs text-gray-600 dark:text-gray-300">
|
|
||||||
Passwort, Zwei-Faktor-Authentifizierung und Passkeys getrennt von den Recorder-Einstellungen verwalten.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
|
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||||
{statusPill(!!authStatus?.totpEnabled, '2FA aktiv', '2FA aus')}
|
<input
|
||||||
{statusPill(!!authStatus?.passkeyConfigured, 'Passkey aktiv', 'Kein Passkey')}
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
placeholder="Aktuelles Passwort"
|
||||||
|
autoComplete="current-password"
|
||||||
|
disabled={securityBusy}
|
||||||
|
className="rounded-lg bg-white px-3 py-2 text-sm text-gray-900 ring-1 ring-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
placeholder="Neues Passwort"
|
||||||
|
autoComplete="new-password"
|
||||||
|
disabled={securityBusy}
|
||||||
|
className="rounded-lg bg-white px-3 py-2 text-sm text-gray-900 ring-1 ring-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPasswordRepeat}
|
||||||
|
onChange={(e) => setNewPasswordRepeat(e.target.value)}
|
||||||
|
placeholder="Neues Passwort wiederholen"
|
||||||
|
autoComplete="new-password"
|
||||||
|
disabled={securityBusy}
|
||||||
|
className="rounded-lg bg-white px-3 py-2 text-sm text-gray-900 ring-1 ring-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
disabled={securityBusy}
|
||||||
|
onClick={() => void changePassword()}
|
||||||
|
>
|
||||||
|
Passwort speichern
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
|
||||||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div>
|
||||||
<div>
|
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
Zwei-Faktor-Authentifizierung
|
||||||
Passwort ändern
|
</div>
|
||||||
</div>
|
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||||||
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
Authenticator-App als zweiter Faktor. Einzelne Geräte können bei TOTP nicht getrennt erkannt werden.
|
||||||
Ändert das Login-Passwort. Andere Sessions werden abgemeldet.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-3">
|
{statusPill(!!authStatus?.totpEnabled, 'Aktiv', 'Aus')}
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={currentPassword}
|
|
||||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
|
||||||
placeholder="Aktuelles Passwort"
|
|
||||||
autoComplete="current-password"
|
|
||||||
disabled={securityBusy}
|
|
||||||
className="rounded-lg bg-white px-3 py-2 text-sm text-gray-900 ring-1 ring-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={newPassword}
|
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
|
||||||
placeholder="Neues Passwort"
|
|
||||||
autoComplete="new-password"
|
|
||||||
disabled={securityBusy}
|
|
||||||
className="rounded-lg bg-white px-3 py-2 text-sm text-gray-900 ring-1 ring-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={newPasswordRepeat}
|
|
||||||
onChange={(e) => setNewPasswordRepeat(e.target.value)}
|
|
||||||
placeholder="Neues Passwort wiederholen"
|
|
||||||
autoComplete="new-password"
|
|
||||||
disabled={securityBusy}
|
|
||||||
className="rounded-lg bg-white px-3 py-2 text-sm text-gray-900 ring-1 ring-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-3 flex justify-end">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
disabled={securityBusy}
|
|
||||||
onClick={() => void changePassword()}
|
|
||||||
>
|
|
||||||
Passwort speichern
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
|
<div className="mt-3 space-y-2">
|
||||||
<div className="flex items-start justify-between gap-3">
|
{authStatus?.totpDevice ? (
|
||||||
<div>
|
<SecurityDeviceRow
|
||||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
icon="🔐"
|
||||||
Zwei-Faktor-Authentifizierung
|
title={authStatus.totpDevice.label || 'Authenticator-App'}
|
||||||
</div>
|
subtitle={
|
||||||
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
authStatus?.totpEnabled
|
||||||
Authenticator-App als zweiter Faktor. Einzelne Geräte können bei TOTP nicht getrennt erkannt werden.
|
? '2FA ist aktiv und wird beim Passwort-Login abgefragt.'
|
||||||
</div>
|
: '2FA ist eingerichtet, aber aktuell nicht aktiv.'
|
||||||
</div>
|
}
|
||||||
|
meta={`Eingerichtet: ${formatSecurityDate(authStatus.totpDevice.configuredAt)}`}
|
||||||
{statusPill(!!authStatus?.totpEnabled, 'Aktiv', 'Aus')}
|
badge={authStatus?.totpEnabled ? 'TOTP' : 'Inaktiv'}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<div className="mt-3 space-y-2">
|
|
||||||
{authStatus?.totpDevice ? (
|
|
||||||
<SecurityDeviceRow
|
|
||||||
icon="🔐"
|
|
||||||
title={authStatus.totpDevice.label || 'Authenticator-App'}
|
|
||||||
subtitle={
|
|
||||||
authStatus?.totpEnabled
|
|
||||||
? '2FA ist aktiv und wird beim Passwort-Login abgefragt.'
|
|
||||||
: '2FA ist eingerichtet, aber aktuell nicht aktiv.'
|
|
||||||
}
|
|
||||||
meta={`Eingerichtet: ${formatSecurityDate(authStatus.totpDevice.configuredAt)}`}
|
|
||||||
badge={authStatus?.totpEnabled ? 'TOTP' : 'Inaktiv'}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
|
|
||||||
2FA ist noch nicht eingerichtet. Die Einrichtung erfolgt aktuell beim nächsten Login-Setup.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{authStatus?.totpConfigured || authStatus?.totpEnabled ? (
|
|
||||||
<>
|
|
||||||
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={disable2FAPassword}
|
|
||||||
onChange={(e) => {
|
|
||||||
setDisable2FAPassword(e.target.value)
|
|
||||||
setDisable2FAFieldError((cur) => ({ ...cur, password: undefined }))
|
|
||||||
}}
|
|
||||||
placeholder="Aktuelles Passwort"
|
|
||||||
autoComplete="current-password"
|
|
||||||
disabled={securityBusy}
|
|
||||||
className={[
|
|
||||||
'rounded-lg bg-white px-3 py-2 text-sm text-gray-900 ring-1 focus:outline-none focus:ring-2 dark:bg-white/10 dark:text-white',
|
|
||||||
disable2FAFieldError?.password
|
|
||||||
? 'ring-red-300 focus:ring-red-500 dark:ring-red-400/50'
|
|
||||||
: 'ring-gray-200 focus:ring-indigo-500 dark:ring-white/10',
|
|
||||||
].join(' ')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<input
|
|
||||||
value={disable2FACode}
|
|
||||||
onChange={(e) => {
|
|
||||||
setDisable2FACode(e.target.value.replace(/\D/g, '').slice(0, 6))
|
|
||||||
setDisable2FAFieldError((cur) => ({ ...cur, code: undefined }))
|
|
||||||
}}
|
|
||||||
placeholder="2FA-Code"
|
|
||||||
inputMode="numeric"
|
|
||||||
autoComplete="one-time-code"
|
|
||||||
disabled={securityBusy}
|
|
||||||
className={[
|
|
||||||
'rounded-lg bg-white px-3 py-2 text-sm text-gray-900 ring-1 focus:outline-none focus:ring-2 dark:bg-white/10 dark:text-white',
|
|
||||||
disable2FAFieldError?.code
|
|
||||||
? 'ring-red-300 focus:ring-red-500 dark:ring-red-400/50'
|
|
||||||
: 'ring-gray-200 focus:ring-indigo-500 dark:ring-white/10',
|
|
||||||
].join(' ')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{disable2FAFieldError?.password || disable2FAFieldError?.code ? (
|
|
||||||
<div className="mt-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200">
|
|
||||||
{disable2FAFieldError.password || disable2FAFieldError.code}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
Zum Deaktivieren brauchst du dein aktuelles Passwort
|
|
||||||
{authStatus?.totpEnabled ? ' und einen gültigen 2FA-Code.' : '.'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-3 flex justify-end">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
color="red"
|
|
||||||
disabled={securityBusy}
|
|
||||||
onClick={() => void disable2FA()}
|
|
||||||
>
|
|
||||||
2FA deaktivieren
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
|
||||||
Passkeys
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
|
||||||
Anmeldung ohne Passwort über Face ID, Fingerabdruck, Windows Hello oder Sicherheitsschlüssel.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{statusPill(!!authStatus?.passkeyConfigured, 'Aktiv', 'Keine')}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-3 space-y-2">
|
|
||||||
{(authStatus?.passkeys ?? []).length > 0 ? (
|
|
||||||
(authStatus?.passkeys ?? []).map((pk, index) => (
|
|
||||||
<SecurityDeviceRow
|
|
||||||
key={pk.id || index}
|
|
||||||
icon="🔑"
|
|
||||||
title={pk.label || `Passkey ${index + 1}`}
|
|
||||||
subtitle={
|
|
||||||
pk.backupEligible
|
|
||||||
? pk.backupState
|
|
||||||
? 'Synchronisierter Passkey'
|
|
||||||
: 'Passkey kann synchronisiert werden'
|
|
||||||
: 'Gerätegebundener Passkey oder Sicherheitsschlüssel'
|
|
||||||
}
|
|
||||||
meta={[
|
|
||||||
`Erstellt: ${formatSecurityDate(pk.createdAt)}`,
|
|
||||||
`Zuletzt genutzt: ${formatSecurityDate(pk.lastUsedAt)}`,
|
|
||||||
typeof pk.signCount === 'number' ? `SignCount: ${pk.signCount}` : '',
|
|
||||||
].filter(Boolean).join(' · ')}
|
|
||||||
badge="Passkey"
|
|
||||||
actions={
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
color="red"
|
|
||||||
size="sm"
|
|
||||||
disabled={securityBusy}
|
|
||||||
onClick={() => void deletePasskey(pk)}
|
|
||||||
>
|
|
||||||
Löschen
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs text-gray-600 dark:border-white/10 dark:bg-gray-950/40 dark:text-gray-300">
|
|
||||||
Noch kein Passkey gespeichert.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!passkeySupported ? (
|
|
||||||
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
|
|
||||||
Dein Browser oder diese Verbindung unterstützt Passkeys aktuell nicht.
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-3 space-y-2">
|
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
|
||||||
{passkeyFeedback ? (
|
2FA ist noch nicht eingerichtet. Die Einrichtung erfolgt aktuell beim nächsten Login-Setup.
|
||||||
<div
|
|
||||||
className={[
|
|
||||||
'whitespace-pre-wrap rounded-lg border px-3 py-2 text-xs',
|
|
||||||
passkeyFeedback.kind === 'success'
|
|
||||||
? 'border-green-200 bg-green-50 text-green-800 dark:border-green-500/30 dark:bg-green-500/10 dark:text-green-200'
|
|
||||||
: passkeyFeedback.kind === 'error'
|
|
||||||
? 'border-red-200 bg-red-50 text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200'
|
|
||||||
: 'border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-500/30 dark:bg-blue-500/10 dark:text-blue-200',
|
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
{passkeyFeedback.kind === 'loading' ? '⏳ ' : null}
|
|
||||||
{passkeyFeedback.kind === 'success' ? '✅ ' : null}
|
|
||||||
{passkeyFeedback.kind === 'error' ? '⚠️ ' : null}
|
|
||||||
{passkeyFeedback.text}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
disabled={securityBusy}
|
|
||||||
onClick={() => void registerSettingsPasskey()}
|
|
||||||
>
|
|
||||||
{securityBusy ? 'Passkey wird eingerichtet…' : 'Passkey hinzufügen'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{authStatus?.totpConfigured || authStatus?.totpEnabled ? (
|
||||||
|
<>
|
||||||
|
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={disable2FAPassword}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDisable2FAPassword(e.target.value)
|
||||||
|
setDisable2FAFieldError((cur) => ({ ...cur, password: undefined }))
|
||||||
|
}}
|
||||||
|
placeholder="Aktuelles Passwort"
|
||||||
|
autoComplete="current-password"
|
||||||
|
disabled={securityBusy}
|
||||||
|
className={[
|
||||||
|
'rounded-lg bg-white px-3 py-2 text-sm text-gray-900 ring-1 focus:outline-none focus:ring-2 dark:bg-white/10 dark:text-white',
|
||||||
|
disable2FAFieldError?.password
|
||||||
|
? 'ring-red-300 focus:ring-red-500 dark:ring-red-400/50'
|
||||||
|
: 'ring-gray-200 focus:ring-indigo-500 dark:ring-white/10',
|
||||||
|
].join(' ')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
value={disable2FACode}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDisable2FACode(e.target.value.replace(/\D/g, '').slice(0, 6))
|
||||||
|
setDisable2FAFieldError((cur) => ({ ...cur, code: undefined }))
|
||||||
|
}}
|
||||||
|
placeholder="2FA-Code"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
disabled={securityBusy}
|
||||||
|
className={[
|
||||||
|
'rounded-lg bg-white px-3 py-2 text-sm text-gray-900 ring-1 focus:outline-none focus:ring-2 dark:bg-white/10 dark:text-white',
|
||||||
|
disable2FAFieldError?.code
|
||||||
|
? 'ring-red-300 focus:ring-red-500 dark:ring-red-400/50'
|
||||||
|
: 'ring-gray-200 focus:ring-indigo-500 dark:ring-white/10',
|
||||||
|
].join(' ')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{disable2FAFieldError?.password || disable2FAFieldError?.code ? (
|
||||||
|
<div className="mt-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200">
|
||||||
|
{disable2FAFieldError.password || disable2FAFieldError.code}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Zum Deaktivieren brauchst du dein aktuelles Passwort
|
||||||
|
{authStatus?.totpEnabled ? ' und einen gültigen 2FA-Code.' : '.'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-3 flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
color="red"
|
||||||
|
disabled={securityBusy}
|
||||||
|
onClick={() => void disable2FA()}
|
||||||
|
>
|
||||||
|
2FA deaktivieren
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
Passkeys
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||||||
|
Anmeldung ohne Passwort über Face ID, Fingerabdruck, Windows Hello oder Sicherheitsschlüssel.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{statusPill(!!authStatus?.passkeyConfigured, 'Aktiv', 'Keine')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{(authStatus?.passkeys ?? []).length > 0 ? (
|
||||||
|
(authStatus?.passkeys ?? []).map((pk, index) => (
|
||||||
|
<SecurityDeviceRow
|
||||||
|
key={pk.id || index}
|
||||||
|
icon="🔑"
|
||||||
|
title={pk.label || `Passkey ${index + 1}`}
|
||||||
|
subtitle={
|
||||||
|
pk.backupEligible
|
||||||
|
? pk.backupState
|
||||||
|
? 'Synchronisierter Passkey'
|
||||||
|
: 'Passkey kann synchronisiert werden'
|
||||||
|
: 'Gerätegebundener Passkey oder Sicherheitsschlüssel'
|
||||||
|
}
|
||||||
|
meta={[
|
||||||
|
`Erstellt: ${formatSecurityDate(pk.createdAt)}`,
|
||||||
|
`Zuletzt genutzt: ${formatSecurityDate(pk.lastUsedAt)}`,
|
||||||
|
typeof pk.signCount === 'number' ? `SignCount: ${pk.signCount}` : '',
|
||||||
|
].filter(Boolean).join(' · ')}
|
||||||
|
badge="Passkey"
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
color="red"
|
||||||
|
size="sm"
|
||||||
|
disabled={securityBusy}
|
||||||
|
onClick={() => void deletePasskey(pk)}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs text-gray-600 dark:border-white/10 dark:bg-gray-950/40 dark:text-gray-300">
|
||||||
|
Noch kein Passkey gespeichert.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!passkeySupported ? (
|
||||||
|
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
|
||||||
|
Dein Browser oder diese Verbindung unterstützt Passkeys aktuell nicht.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{passkeyFeedback ? (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'whitespace-pre-wrap rounded-lg border px-3 py-2 text-xs',
|
||||||
|
passkeyFeedback.kind === 'success'
|
||||||
|
? 'border-green-200 bg-green-50 text-green-800 dark:border-green-500/30 dark:bg-green-500/10 dark:text-green-200'
|
||||||
|
: passkeyFeedback.kind === 'error'
|
||||||
|
? 'border-red-200 bg-red-50 text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200'
|
||||||
|
: 'border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-500/30 dark:bg-blue-500/10 dark:text-blue-200',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{passkeyFeedback.kind === 'loading' ? '⏳ ' : null}
|
||||||
|
{passkeyFeedback.kind === 'success' ? '✅ ' : null}
|
||||||
|
{passkeyFeedback.kind === 'error' ? '⚠️ ' : null}
|
||||||
|
{passkeyFeedback.text}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
disabled={securityBusy}
|
||||||
|
onClick={() => void registerSettingsPasskey()}
|
||||||
|
>
|
||||||
|
{securityBusy ? 'Passkey wird eingerichtet…' : 'Passkey hinzufügen'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</SettingsSection>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -258,8 +258,9 @@ export type RecorderSettingsState = {
|
|||||||
autoStartAddedDownloads?: boolean
|
autoStartAddedDownloads?: boolean
|
||||||
enableConcurrentDownloadsLimit?: boolean
|
enableConcurrentDownloadsLimit?: boolean
|
||||||
maxConcurrentDownloads?: number
|
maxConcurrentDownloads?: number
|
||||||
|
useProviderApis?: boolean
|
||||||
useChaturbateApi?: boolean
|
useChaturbateApi?: boolean
|
||||||
useMyFreeCamsWatcher?: boolean
|
useMyFreeCamsApi?: boolean
|
||||||
autoDeleteSmallDownloads?: boolean
|
autoDeleteSmallDownloads?: boolean
|
||||||
autoDeleteSmallDownloadsBelowMB?: number
|
autoDeleteSmallDownloadsBelowMB?: number
|
||||||
autoDeleteSmallDownloadsKeepFavorites?: boolean
|
autoDeleteSmallDownloadsKeepFavorites?: boolean
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user