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,
|
||||
"labelConfig": str(DETECTION_LABELS_PATH) if DETECTION_LABELS_PATH else "",
|
||||
"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 {
|
||||
s := getSettings()
|
||||
|
||||
return s.UseChaturbateAPI || s.UseMyFreeCamsWatcher
|
||||
if !s.AutoStartAddedDownloads {
|
||||
return false
|
||||
}
|
||||
|
||||
if !s.UseProviderAPIs {
|
||||
return false
|
||||
}
|
||||
|
||||
return s.UseChaturbateAPI || s.UseMyFreeCamsAPI
|
||||
}
|
||||
|
||||
func broadcastAutostartPaused() {
|
||||
@ -239,9 +247,12 @@ func autostartStateStreamHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
send := func(state autostartStatePayload) {
|
||||
payload := map[string]any{
|
||||
"enabled": state.Enabled,
|
||||
"active": state.Active,
|
||||
"paused": state.Paused,
|
||||
"pausedByUser": state.PausedByUser,
|
||||
"pausedByDisk": state.PausedByDisk,
|
||||
"reason": state.Reason,
|
||||
"ts": time.Now().UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
|
||||
|
||||
@ -342,10 +342,10 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
||||
for {
|
||||
select {
|
||||
case <-scanTicker.C:
|
||||
autostartPaused := isAutostartPaused()
|
||||
|
||||
s := getSettings()
|
||||
if !s.UseChaturbateAPI {
|
||||
autostartPaused := isAutostartPaused() || !s.AutoStartAddedDownloads
|
||||
|
||||
if !s.UseProviderAPIs || !s.UseChaturbateAPI {
|
||||
queue = queue[:0]
|
||||
queued = map[string]bool{}
|
||||
lastCookieHdr = ""
|
||||
@ -853,7 +853,7 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
||||
|
||||
case <-startTicker.C:
|
||||
s := getSettings()
|
||||
if !s.UseChaturbateAPI {
|
||||
if !s.UseProviderAPIs || !s.UseChaturbateAPI || !s.AutoStartAddedDownloads || isAutostartPaused() {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@ -564,7 +564,7 @@ func startChaturbateOnlinePoller(store *ModelStore) {
|
||||
var tagsLast time.Time
|
||||
|
||||
refreshOnce := func(trigger string) {
|
||||
if !getSettings().UseChaturbateAPI {
|
||||
if !chaturbateProviderAPIEnabled() {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -32,6 +32,7 @@ func setModelStore(store *ModelStore) {
|
||||
modelStoreRef = store
|
||||
setCoverModelStore(store)
|
||||
setChaturbateOnlineModelStore(store)
|
||||
setMyFreeCamsOnlineModelStore(store)
|
||||
}
|
||||
|
||||
// ✅ 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) {
|
||||
setModelStore(store)
|
||||
|
||||
// ✅ NEU: Parse-Endpoint (nur URL erlaubt)
|
||||
mux.HandleFunc("/api/models/parse", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
|
||||
@ -116,12 +116,8 @@ func startMyFreeCamsAutoStartWorker(store *ModelStore) {
|
||||
for {
|
||||
select {
|
||||
case <-scanTicker.C:
|
||||
if isAutostartPaused() {
|
||||
continue
|
||||
}
|
||||
|
||||
s := getSettings()
|
||||
if !s.UseMyFreeCamsWatcher {
|
||||
if !s.UseProviderAPIs || !s.UseMyFreeCamsAPI || !s.AutoStartAddedDownloads || isAutostartPaused() {
|
||||
queue = queue[:0]
|
||||
queued = map[string]bool{}
|
||||
|
||||
@ -394,10 +390,8 @@ func startMyFreeCamsAutoStartWorker(store *ModelStore) {
|
||||
pendingAutoStartMu.Unlock()
|
||||
|
||||
case <-startTicker.C:
|
||||
if isAutostartPaused() {
|
||||
continue
|
||||
}
|
||||
if !getSettings().UseMyFreeCamsWatcher {
|
||||
s := getSettings()
|
||||
if !s.UseProviderAPIs || !s.UseMyFreeCamsAPI || !s.AutoStartAddedDownloads || isAutostartPaused() {
|
||||
continue
|
||||
}
|
||||
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 {
|
||||
return "", appErrorf("JSON-parse failed: %w", err)
|
||||
}
|
||||
if rd.HLSSource == "" {
|
||||
return "", errors.New("kein HLS-Quell-URL im JSON")
|
||||
}
|
||||
return rd.HLSSource, nil
|
||||
}
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@ -31,6 +32,10 @@ func RecordStreamMFC(
|
||||
const waitPublicMax = 90 * time.Second
|
||||
deadline := time.Now().Add(waitPublicMax)
|
||||
|
||||
var lastStatus Status = StatusUnknown
|
||||
var lastErr error
|
||||
var lastStatusLog time.Time
|
||||
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
@ -41,11 +46,21 @@ func RecordStreamMFC(
|
||||
break
|
||||
}
|
||||
|
||||
if time.Now().After(deadline) {
|
||||
lastStatus = st
|
||||
lastErr = err
|
||||
|
||||
if lastStatusLog.IsZero() || time.Since(lastStatusLog) >= 15*time.Second {
|
||||
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)
|
||||
@ -120,9 +135,9 @@ type MyFreeCams struct {
|
||||
type mfcUsernameLookupResp struct {
|
||||
Result struct {
|
||||
User struct {
|
||||
ID int `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar"`
|
||||
ID int `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Avatar mfcMaybeString `json:"avatar"`
|
||||
|
||||
Sessions []struct {
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return StatusUnknown, appErrorf("mfc usernameLookup json: %w", err)
|
||||
if err := json.Unmarshal(raw, &data); err != nil {
|
||||
return StatusUnknown, appErrorf("mfc usernameLookup json for %s: %w; body=%s", username, err, snip)
|
||||
}
|
||||
|
||||
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.AvatarURL = strings.TrimSpace(data.Result.User.Avatar)
|
||||
m.AvatarURL = data.Result.User.Avatar.String()
|
||||
|
||||
if len(data.Result.User.Sessions) == 0 {
|
||||
return StatusOffline, nil
|
||||
@ -507,21 +532,86 @@ func extractMFCUsername(input string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 1) URL mit Fragment (#username)
|
||||
if u, err := url.Parse(s); err == nil && u.Fragment != "" {
|
||||
return strings.Trim(strings.TrimSpace(u.Fragment), "/")
|
||||
}
|
||||
|
||||
// 2) URL Pfad: letztes Segment nehmen
|
||||
if u, err := url.Parse(s); err == nil && u.Host != "" {
|
||||
p := strings.Trim(u.Path, "/")
|
||||
if p == "" {
|
||||
return ""
|
||||
if u, err := url.Parse(s); err == nil {
|
||||
if frag := strings.TrimSpace(u.Fragment); frag != "" {
|
||||
if name := extractMFCUsernameFromPathish(frag); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
if u.Host != "" {
|
||||
if name := extractMFCUsernameFromPathish(u.Path); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
parts := strings.Split(p, "/")
|
||||
return strings.TrimSpace(parts[len(parts)-1])
|
||||
}
|
||||
|
||||
// 3) Fallback: raw
|
||||
return s
|
||||
return sanitizeMFCUsernamePart(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)
|
||||
|
||||
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"))
|
||||
outPath := filepath.Join(recordDirAbs, filename)
|
||||
|
||||
|
||||
@ -1,17 +1,19 @@
|
||||
{
|
||||
"databaseUrl": "postgres://postgres@127.0.0.1:5432/nsfwapp?sslmode=disable",
|
||||
"encryptedDbPassword": "vkNvHM+/AgEkRPW0X+rDDys4bPb5Q7ZPGqPSADCluIJ6GCDGNGlDy3s=",
|
||||
"recordDir": "C:\\Users\\Chris\\Desktop\\privat\\records",
|
||||
"doneDir": "C:\\Users\\Chris\\Desktop\\privat\\records\\done",
|
||||
"recordDir": "C:\\Users\\Rother\\Desktop\\Privat\\records",
|
||||
"doneDir": "C:\\Users\\Rother\\Desktop\\Privat\\records\\done",
|
||||
"ffmpegPath": "",
|
||||
"autoAddToDownloadList": true,
|
||||
"autoStartAddedDownloads": true,
|
||||
"enableConcurrentDownloadsLimit": true,
|
||||
"maxConcurrentDownloads": 80,
|
||||
"useProviderApis": true,
|
||||
"useChaturbateApi": true,
|
||||
"useMyFreeCamsWatcher": true,
|
||||
"useMyFreeCamsApi": true,
|
||||
"autoDeleteSmallDownloads": true,
|
||||
"autoDeleteSmallDownloadsBelowMB": 300,
|
||||
"autoDeleteSmallDownloadsKeepFavorites": false,
|
||||
"autoDeleteSmallDownloadsKeepFavorites": true,
|
||||
"lowDiskPauseBelowGB": 5,
|
||||
"blurPreviews": false,
|
||||
"teaserPlayback": "all",
|
||||
|
||||
@ -104,6 +104,9 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
|
||||
api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler)
|
||||
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/cover", generatedCover)
|
||||
api.HandleFunc("/api/generated/coverinfo/list", generatedCoverInfoList)
|
||||
@ -132,6 +135,7 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
|
||||
setCoverModelStore(store)
|
||||
RegisterModelAPI(api, store)
|
||||
setChaturbateOnlineModelStore(store)
|
||||
setMyFreeCamsOnlineModelStore(store)
|
||||
|
||||
// --------------------------
|
||||
// 4) Mount Protected API
|
||||
|
||||
@ -715,6 +715,7 @@ func main() {
|
||||
// Hintergrund-Worker
|
||||
go startDiskSpaceGuard()
|
||||
go startChaturbateOnlinePoller(store)
|
||||
go startMyFreeCamsOnlinePoller(store)
|
||||
go startChaturbateAutoStartWorker(store)
|
||||
go startMyFreeCamsAutoStartWorker(store)
|
||||
|
||||
|
||||
@ -22,11 +22,14 @@ type RecorderSettings struct {
|
||||
FFmpegPath string `json:"ffmpegPath"`
|
||||
|
||||
AutoAddToDownloadList bool `json:"autoAddToDownloadList"`
|
||||
AutoStartAddedDownloads bool `json:"autoStartAddedDownloads"`
|
||||
EnableConcurrentDownloadsLimit bool `json:"enableConcurrentDownloadsLimit"`
|
||||
MaxConcurrentDownloads int `json:"maxConcurrentDownloads"`
|
||||
|
||||
UseChaturbateAPI bool `json:"useChaturbateApi"`
|
||||
UseMyFreeCamsWatcher bool `json:"useMyFreeCamsWatcher"`
|
||||
UseProviderAPIs bool `json:"useProviderApis"`
|
||||
UseChaturbateAPI bool `json:"useChaturbateApi"`
|
||||
UseMyFreeCamsAPI bool `json:"useMyFreeCamsApi"`
|
||||
|
||||
// Wenn aktiv, werden fertige Downloads automatisch gelöscht, wenn sie kleiner als der Grenzwert sind.
|
||||
AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"`
|
||||
AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"`
|
||||
@ -64,11 +67,14 @@ var (
|
||||
FFmpegPath: "",
|
||||
|
||||
AutoAddToDownloadList: false,
|
||||
AutoStartAddedDownloads: true,
|
||||
EnableConcurrentDownloadsLimit: false,
|
||||
MaxConcurrentDownloads: 20,
|
||||
|
||||
UseChaturbateAPI: false,
|
||||
UseMyFreeCamsWatcher: false,
|
||||
UseProviderAPIs: false,
|
||||
UseChaturbateAPI: false,
|
||||
UseMyFreeCamsAPI: false,
|
||||
|
||||
AutoDeleteSmallDownloads: false,
|
||||
AutoDeleteSmallDownloadsBelowMB: 50,
|
||||
AutoDeleteSmallDownloadsKeepFavorites: true,
|
||||
@ -236,11 +242,13 @@ type RecorderSettingsPublic struct {
|
||||
HasDBPassword bool `json:"hasDbPassword"`
|
||||
|
||||
AutoAddToDownloadList bool `json:"autoAddToDownloadList"`
|
||||
AutoStartAddedDownloads bool `json:"autoStartAddedDownloads"`
|
||||
EnableConcurrentDownloadsLimit bool `json:"enableConcurrentDownloadsLimit"`
|
||||
MaxConcurrentDownloads int `json:"maxConcurrentDownloads"`
|
||||
|
||||
UseChaturbateAPI bool `json:"useChaturbateApi"`
|
||||
UseMyFreeCamsWatcher bool `json:"useMyFreeCamsWatcher"`
|
||||
UseProviderAPIs bool `json:"useProviderApis"`
|
||||
UseChaturbateAPI bool `json:"useChaturbateApi"`
|
||||
UseMyFreeCamsAPI bool `json:"useMyFreeCamsApi"`
|
||||
|
||||
AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"`
|
||||
AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"`
|
||||
@ -274,11 +282,13 @@ func toPublicSettings(s RecorderSettings) RecorderSettingsPublic {
|
||||
HasDBPassword: strings.TrimSpace(s.EncryptedDBPassword) != "",
|
||||
|
||||
AutoAddToDownloadList: s.AutoAddToDownloadList,
|
||||
AutoStartAddedDownloads: s.AutoStartAddedDownloads,
|
||||
EnableConcurrentDownloadsLimit: s.EnableConcurrentDownloadsLimit,
|
||||
MaxConcurrentDownloads: s.MaxConcurrentDownloads,
|
||||
|
||||
UseChaturbateAPI: s.UseChaturbateAPI,
|
||||
UseMyFreeCamsWatcher: s.UseMyFreeCamsWatcher,
|
||||
UseProviderAPIs: s.UseProviderAPIs,
|
||||
UseChaturbateAPI: s.UseChaturbateAPI,
|
||||
UseMyFreeCamsAPI: s.UseMyFreeCamsAPI,
|
||||
|
||||
AutoDeleteSmallDownloads: s.AutoDeleteSmallDownloads,
|
||||
AutoDeleteSmallDownloadsBelowMB: s.AutoDeleteSmallDownloadsBelowMB,
|
||||
@ -442,11 +452,13 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
next.EncryptedDBPassword = in.EncryptedDBPassword
|
||||
|
||||
next.AutoAddToDownloadList = in.AutoAddToDownloadList
|
||||
next.AutoStartAddedDownloads = in.AutoStartAddedDownloads
|
||||
next.EnableConcurrentDownloadsLimit = in.EnableConcurrentDownloadsLimit
|
||||
next.MaxConcurrentDownloads = in.MaxConcurrentDownloads
|
||||
|
||||
next.UseProviderAPIs = in.UseProviderAPIs
|
||||
next.UseChaturbateAPI = in.UseChaturbateAPI
|
||||
next.UseMyFreeCamsWatcher = in.UseMyFreeCamsWatcher
|
||||
next.UseMyFreeCamsAPI = in.UseMyFreeCamsAPI
|
||||
|
||||
next.AutoDeleteSmallDownloads = in.AutoDeleteSmallDownloads
|
||||
next.AutoDeleteSmallDownloadsBelowMB = in.AutoDeleteSmallDownloadsBelowMB
|
||||
@ -514,6 +526,7 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if newStore != nil {
|
||||
setModelStore(newStore)
|
||||
setChaturbateOnlineModelStore(newStore)
|
||||
setMyFreeCamsOnlineModelStore(newStore)
|
||||
}
|
||||
|
||||
// ✅ ffmpeg/ffprobe nach Änderungen neu bestimmen
|
||||
|
||||
@ -10,6 +10,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
@ -2436,6 +2437,10 @@ func trainingRunJob(ctx context.Context, root string, count int) {
|
||||
}
|
||||
}
|
||||
|
||||
if detectorStatus == "trained" {
|
||||
reloadAIServerModelAfterTraining()
|
||||
}
|
||||
|
||||
trainingSetJobStatus(func(s *TrainingJobStatus) {
|
||||
finishedAt := time.Now().UTC()
|
||||
|
||||
@ -3329,6 +3334,39 @@ func trainingCreateUncertainNextSampleWithProgress(startedAtMs int64, requestID
|
||||
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) {
|
||||
sampleID = strings.TrimSpace(sampleID)
|
||||
if sampleID == "" || strings.Contains(sampleID, "/") || strings.Contains(sampleID, "\\") {
|
||||
|
||||
@ -65,8 +65,9 @@ const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = {
|
||||
autoStartAddedDownloads: false,
|
||||
enableConcurrentDownloadsLimit: false,
|
||||
maxConcurrentDownloads: 20,
|
||||
useProviderApis: false,
|
||||
useChaturbateApi: false,
|
||||
useMyFreeCamsWatcher: false,
|
||||
useMyFreeCamsApi: false,
|
||||
autoDeleteSmallDownloads: false,
|
||||
autoDeleteSmallDownloadsBelowMB: 200,
|
||||
autoDeleteSmallDownloadsKeepFavorites: true,
|
||||
@ -2503,7 +2504,12 @@ export default function App() {
|
||||
// Nur automatische Starts in pending-autostart schieben.
|
||||
// Manuelle UI-Starts müssen sofort direkt starten.
|
||||
if (!immediate) {
|
||||
if (provider === 'chaturbate' && recSettingsRef.current.useChaturbateApi && keyLower) {
|
||||
if (
|
||||
provider === 'chaturbate' &&
|
||||
recSettingsRef.current.useProviderApis &&
|
||||
recSettingsRef.current.useChaturbateApi &&
|
||||
keyLower
|
||||
) {
|
||||
queuePendingAutoStart(keyLower, norm, 'unknown', undefined, {
|
||||
mode: 'probe_retry',
|
||||
source: 'manual',
|
||||
@ -2511,7 +2517,12 @@ export default function App() {
|
||||
return true
|
||||
}
|
||||
|
||||
if (provider === 'mfc' && recSettingsRef.current.useMyFreeCamsWatcher && keyLower) {
|
||||
if (
|
||||
provider === 'mfc' &&
|
||||
recSettingsRef.current.useProviderApis &&
|
||||
recSettingsRef.current.useMyFreeCamsApi &&
|
||||
keyLower
|
||||
) {
|
||||
queuePendingAutoStart(keyLower, norm, 'offline', undefined, {
|
||||
mode: 'probe_retry',
|
||||
source: 'manual',
|
||||
@ -3837,7 +3848,7 @@ export default function App() {
|
||||
if (autoAddEnabled) setSourceUrl(url)
|
||||
|
||||
if (autoStartEnabled) {
|
||||
void startUrl(url, { silent: false })
|
||||
void startUrl(url, { silent: false, immediate: true })
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
|
||||
@ -97,11 +97,29 @@ function ssSet(key: string, value: any) {
|
||||
}
|
||||
}
|
||||
|
||||
function ssKeyOnline(modelKey: string) {
|
||||
return `md:cb:online:${modelKey}`
|
||||
type ProviderKey = 'chaturbate' | 'myfreecams'
|
||||
|
||||
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')
|
||||
@ -403,11 +421,17 @@ type BioResp = {
|
||||
fetchedAt?: string
|
||||
lastError?: string
|
||||
model?: string
|
||||
bio?: BioContext | null
|
||||
bio?: BioContext | any | null
|
||||
roomStatus?: string
|
||||
isOnline?: boolean
|
||||
chatRoomUrl?: string
|
||||
imageUrl?: string
|
||||
|
||||
avatarUrl?: string
|
||||
snapUrl?: string
|
||||
roomTopic?: string
|
||||
country?: string
|
||||
gender?: string
|
||||
}
|
||||
|
||||
// ------ props ------
|
||||
@ -416,8 +440,13 @@ type BioResp = {
|
||||
// /api/models liefert StoredModel aus dem models_store
|
||||
type StoredModel = {
|
||||
id: string
|
||||
host?: string | null
|
||||
modelKey: string
|
||||
tags?: string | null
|
||||
|
||||
gender?: string | null
|
||||
country?: string | null
|
||||
|
||||
lastSeenOnline?: boolean | null
|
||||
lastSeenOnlineAt?: string
|
||||
|
||||
@ -511,6 +540,116 @@ function buildChaturbateCookieHeader(cookies?: Record<string, string>): string {
|
||||
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({
|
||||
open,
|
||||
@ -587,6 +726,13 @@ export default function ModelDetails({
|
||||
const removingKeys = 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(() => {
|
||||
if (!open) {
|
||||
bioReqRef.current?.abort()
|
||||
@ -622,8 +768,10 @@ export default function ModelDetails({
|
||||
if (!open || !key) return
|
||||
|
||||
// Online
|
||||
const memOnline = mdOnlineMem.get(key)
|
||||
const ssOnline = ssGet<OnlineCacheEntry>(ssKeyOnline(key))
|
||||
const ck = mdCacheKey(provider, key)
|
||||
|
||||
const memOnline = mdOnlineMem.get(ck)
|
||||
const ssOnline = ssGet<OnlineCacheEntry>(ssKeyOnline(provider, key))
|
||||
|
||||
const onlineHit =
|
||||
(memOnline && isFresh(memOnline.at) ? memOnline : null) ||
|
||||
@ -635,8 +783,8 @@ export default function ModelDetails({
|
||||
}
|
||||
|
||||
// Bio
|
||||
const memBio = mdBioMem.get(key)
|
||||
const ssBio = ssGet<BioCacheEntry>(ssKeyBio(key))
|
||||
const memBio = mdBioMem.get(ck)
|
||||
const ssBio = ssGet<BioCacheEntry>(ssKeyBio(provider, key))
|
||||
|
||||
const bioHit =
|
||||
(memBio && isFresh(memBio.at) ? memBio : null) ||
|
||||
@ -647,7 +795,7 @@ export default function ModelDetails({
|
||||
setBioMeta(bioHit.meta ?? null)
|
||||
// bioLoading NICHT anfassen – Fetch kann trotzdem laufen
|
||||
}
|
||||
}, [open, key])
|
||||
}, [open, key, provider])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
@ -657,15 +805,20 @@ export default function ModelDetails({
|
||||
const refreshBio = useCallback(async () => {
|
||||
if (!key) return
|
||||
|
||||
// vorherigen abbrechen
|
||||
bioReqRef.current?.abort()
|
||||
const ac = new AbortController()
|
||||
bioReqRef.current = ac
|
||||
|
||||
setBioLoading(true)
|
||||
|
||||
try {
|
||||
const cookieHeader = buildChaturbateCookieHeader(cookies)
|
||||
const url = `/api/chaturbate/biocontext?model=${encodeURIComponent(key)}&refresh=1`
|
||||
const isMFC = provider === 'myfreecams'
|
||||
|
||||
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, {
|
||||
cache: 'no-store',
|
||||
@ -679,22 +832,68 @@ export default function ModelDetails({
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
setBioMeta(meta)
|
||||
setBio(nextBio)
|
||||
|
||||
const ck = mdCacheKey(provider, key)
|
||||
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) {
|
||||
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 {
|
||||
setBioLoading(false)
|
||||
}
|
||||
}, [key, cookies])
|
||||
}, [key, provider, cookies])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) return
|
||||
@ -839,18 +1038,35 @@ export default function ModelDetails({
|
||||
}
|
||||
}, [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 raw = (model as any)?.cbOnlineJson
|
||||
if (!raw || typeof raw !== 'string') return null
|
||||
try {
|
||||
return JSON.parse(raw) as ChaturbateRoom
|
||||
} catch {
|
||||
return null
|
||||
|
||||
if (raw && typeof raw === 'string') {
|
||||
try {
|
||||
return JSON.parse(raw) as ChaturbateRoom
|
||||
} 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])
|
||||
|
||||
@ -908,8 +1124,13 @@ export default function ModelDetails({
|
||||
|
||||
const profileHref = useMemo(() => {
|
||||
if (!profileUsername) return ''
|
||||
|
||||
if (provider === 'myfreecams') {
|
||||
return `https://www.myfreecams.com/#${encodeURIComponent(profileUsername)}`
|
||||
}
|
||||
|
||||
return `https://chaturbate.com/${encodeURIComponent(profileUsername)}`
|
||||
}, [profileUsername])
|
||||
}, [profileUsername, provider])
|
||||
|
||||
const currentOnlineState = useMemo(() => {
|
||||
const roomStatus = String(model?.roomStatus ?? '').trim().toLowerCase()
|
||||
|
||||
@ -9,7 +9,7 @@ import Task from './Task'
|
||||
import TaskList from './TaskList'
|
||||
import type { TaskItem } from './TaskList'
|
||||
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'
|
||||
|
||||
type RecorderSettings = {
|
||||
@ -22,8 +22,9 @@ type RecorderSettings = {
|
||||
autoStartAddedDownloads?: boolean
|
||||
enableConcurrentDownloadsLimit?: boolean
|
||||
maxConcurrentDownloads?: number
|
||||
useProviderApis?: boolean
|
||||
useChaturbateApi?: boolean
|
||||
useMyFreeCamsWatcher?: boolean
|
||||
useMyFreeCamsApi?: boolean
|
||||
autoDeleteSmallDownloads?: boolean
|
||||
autoDeleteSmallDownloadsBelowMB?: number
|
||||
autoDeleteSmallDownloadsKeepFavorites?: boolean
|
||||
@ -141,7 +142,7 @@ function SecurityDeviceRow(props: {
|
||||
}) {
|
||||
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 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}
|
||||
</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 = {
|
||||
databaseUrl: '',
|
||||
hasDbPassword: false,
|
||||
@ -188,8 +279,9 @@ const DEFAULTS: RecorderSettings = {
|
||||
autoStartAddedDownloads: true,
|
||||
enableConcurrentDownloadsLimit: false,
|
||||
maxConcurrentDownloads: 20,
|
||||
useProviderApis: false,
|
||||
useChaturbateApi: false,
|
||||
useMyFreeCamsWatcher: false,
|
||||
useMyFreeCamsApi: false,
|
||||
autoDeleteSmallDownloads: true,
|
||||
autoDeleteSmallDownloadsBelowMB: 200,
|
||||
autoDeleteSmallDownloadsKeepFavorites: true,
|
||||
@ -578,8 +670,15 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
maxConcurrentDownloads:
|
||||
(data as any).maxConcurrentDownloads ?? DEFAULTS.maxConcurrentDownloads,
|
||||
|
||||
useChaturbateApi: data.useChaturbateApi ?? DEFAULTS.useChaturbateApi,
|
||||
useMyFreeCamsWatcher: data.useMyFreeCamsWatcher ?? DEFAULTS.useMyFreeCamsWatcher,
|
||||
useProviderApis:
|
||||
(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,
|
||||
autoDeleteSmallDownloadsBelowMB: data.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB,
|
||||
autoDeleteSmallDownloadsKeepFavorites:
|
||||
@ -971,8 +1070,9 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
Math.min(100, Math.floor(Number((value as any).maxConcurrentDownloads ?? DEFAULTS.maxConcurrentDownloads ?? 20)))
|
||||
)
|
||||
|
||||
const useChaturbateApi = !!value.useChaturbateApi
|
||||
const useMyFreeCamsWatcher = !!value.useMyFreeCamsWatcher
|
||||
const useProviderApis = !!value.useProviderApis
|
||||
const useChaturbateApi = useProviderApis && !!value.useChaturbateApi
|
||||
const useMyFreeCamsApi = useProviderApis && !!value.useMyFreeCamsApi
|
||||
const autoDeleteSmallDownloads = !!value.autoDeleteSmallDownloads
|
||||
const autoDeleteSmallDownloadsBelowMB = Math.max(
|
||||
0,
|
||||
@ -1015,8 +1115,9 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
autoStartAddedDownloads,
|
||||
enableConcurrentDownloadsLimit,
|
||||
maxConcurrentDownloads,
|
||||
useProviderApis,
|
||||
useChaturbateApi,
|
||||
useMyFreeCamsWatcher,
|
||||
useMyFreeCamsApi,
|
||||
autoDeleteSmallDownloads,
|
||||
autoDeleteSmallDownloadsBelowMB,
|
||||
autoDeleteSmallDownloadsKeepFavorites,
|
||||
@ -1605,17 +1706,14 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
) : null}
|
||||
|
||||
{/* Aufgaben */}
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-950/40">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Aufgaben</div>
|
||||
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||||
Hintergrundaufgaben wie z.B. Asset/Preview-Generierung.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-3">
|
||||
<SettingsSection
|
||||
id="tasks"
|
||||
title="Aufgaben"
|
||||
description="Hintergrundaufgaben wie z.B. Asset/Preview-Generierung."
|
||||
icon="⚙️"
|
||||
defaultOpen={true}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Task
|
||||
title="Assets-Generator"
|
||||
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>
|
||||
</SettingsSection>
|
||||
|
||||
{/* 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="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">
|
||||
@ -1817,10 +1921,16 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{/* 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="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">
|
||||
@ -1895,12 +2005,13 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
setValue((v) => ({
|
||||
...v,
|
||||
autoAddToDownloadList: checked,
|
||||
autoStartAddedDownloads: checked ? v.autoStartAddedDownloads : false,
|
||||
}))
|
||||
}
|
||||
label="Automatisch zur Downloadliste hinzufügen"
|
||||
description="Neue Links/Modelle werden automatisch in die Downloadliste übernommen."
|
||||
label="Clipboard-Links in Start-URL übernehmen"
|
||||
description="Übernimmt erkannte Chaturbate- und MyFreeCams-Links aus der Zwischenablage automatisch in das Start-URL-Feld. Wenn deaktiviert, werden Clipboard-Links ignoriert."
|
||||
/>
|
||||
|
||||
|
||||
<LabeledSwitch
|
||||
checked={!!value.autoStartAddedDownloads}
|
||||
onChange={(checked) =>
|
||||
@ -1910,23 +2021,58 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
autoAddToDownloadList: checked ? true : v.autoAddToDownloadList,
|
||||
}))
|
||||
}
|
||||
label="Automatisch starten"
|
||||
description="Neue Links aus der Zwischenablage werden automatisch gestartet. Aktiviert automatisch auch das Hinzufügen zur Downloadliste."
|
||||
label="Übernommene Links automatisch starten"
|
||||
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
|
||||
checked={!!value.useChaturbateApi}
|
||||
onChange={(checked) => setValue((v) => ({ ...v, useChaturbateApi: checked }))}
|
||||
label="Chaturbate API"
|
||||
description="Wenn aktiv, pollt das Backend alle paar Sekunden die Online-Rooms API und cached die aktuell online Models."
|
||||
/>
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
|
||||
<LabeledSwitch
|
||||
checked={!!value.useProviderApis}
|
||||
onChange={(checked) =>
|
||||
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
|
||||
checked={!!value.useMyFreeCamsWatcher}
|
||||
onChange={(checked) => setValue((v) => ({ ...v, useMyFreeCamsWatcher: checked }))}
|
||||
label="MyFreeCams Auto-Check (watched)"
|
||||
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."
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
'mt-3 space-y-3 border-t border-gray-200 pt-3 dark:border-white/10 ' +
|
||||
(!value.useProviderApis ? 'opacity-50 pointer-events-none' : '')
|
||||
}
|
||||
>
|
||||
<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 */}
|
||||
<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}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{/* 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">
|
||||
<div className="rounded-[1.35rem] bg-white p-4 dark:bg-gray-950/70">
|
||||
<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">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-2xl bg-indigo-100 text-lg dark:bg-indigo-500/15">
|
||||
🛡️
|
||||
<SettingsSection
|
||||
id="security"
|
||||
title="Konto-Sicherheit"
|
||||
description="Passwort, Zwei-Faktor-Authentifizierung und Passkeys getrennt von den Recorder-Einstellungen verwalten."
|
||||
icon="🛡️"
|
||||
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="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 className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||||
Ändert das Login-Passwort. Andere Sessions werden abgemeldet.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
|
||||
{statusPill(!!authStatus?.totpEnabled, '2FA aktiv', '2FA aus')}
|
||||
{statusPill(!!authStatus?.passkeyConfigured, 'Passkey aktiv', 'Kein Passkey')}
|
||||
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
<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 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 className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||||
Ändert das Login-Passwort. Andere Sessions werden abgemeldet.
|
||||
</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">
|
||||
Zwei-Faktor-Authentifizierung
|
||||
</div>
|
||||
<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.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
<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>
|
||||
{statusPill(!!authStatus?.totpEnabled, 'Aktiv', 'Aus')}
|
||||
</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">
|
||||
Zwei-Faktor-Authentifizierung
|
||||
</div>
|
||||
<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.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{statusPill(!!authStatus?.totpEnabled, 'Aktiv', 'Aus')}
|
||||
</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">
|
||||
{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="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 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">
|
||||
{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>
|
||||
</section>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@ -258,8 +258,9 @@ export type RecorderSettingsState = {
|
||||
autoStartAddedDownloads?: boolean
|
||||
enableConcurrentDownloadsLimit?: boolean
|
||||
maxConcurrentDownloads?: number
|
||||
useProviderApis?: boolean
|
||||
useChaturbateApi?: boolean
|
||||
useMyFreeCamsWatcher?: boolean
|
||||
useMyFreeCamsApi?: boolean
|
||||
autoDeleteSmallDownloads?: boolean
|
||||
autoDeleteSmallDownloadsBelowMB?: number
|
||||
autoDeleteSmallDownloadsKeepFavorites?: boolean
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user