diff --git a/backend/__pycache__/ai_server.cpython-314.pyc b/backend/__pycache__/ai_server.cpython-314.pyc index 02cc6c3..228399b 100644 Binary files a/backend/__pycache__/ai_server.cpython-314.pyc and b/backend/__pycache__/ai_server.cpython-314.pyc differ diff --git a/backend/analyze.go b/backend/analyze.go index 6dc7d60..7d1843d 100644 --- a/backend/analyze.go +++ b/backend/analyze.go @@ -159,9 +159,33 @@ func shouldAutoSelectAnalyzeHit(label string) bool { return false } + if isIgnoredNSFWLabel(label) || isPersonSegmentLabel(label) { + return false + } + labels := autoSelectedAILabelSet() - _, ok := labels[label] - return ok + if _, ok := labels[label]; ok { + return true + } + + // Robuster Fallback: + // Auch wenn trainingGroupedLabels()/detection_labels.json gerade leer, + // veraltet oder nicht ladbar ist, sollen bekannte Rating-/Analyze-Labels + // trotzdem durchkommen. + if bodyPartSeverityWeight(label) > 0 { + return true + } + if objectSeverityWeight(label) > 0 { + return true + } + if clothingSeverityWeight(label) > 0 { + return true + } + if isKnownPositionLabel(label) && positionSeverityWeight(label) > 0 { + return true + } + + return false } var nsfwIgnoredLabels = map[string]struct{}{ @@ -986,6 +1010,7 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) { ai := &aiAnalysisMeta{ Goal: "highlights", Mode: "video", + Completed: true, Hits: hits, Segments: segments, Rating: rating, @@ -1271,8 +1296,41 @@ func highlightComboPartOrder(label string) int { } } +func predictionHasAnyAnalyzeSignal(pred TrainingPrediction) bool { + sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition)) + if sexPosition != "" && sexPosition != "unknown" { + return true + } + + if len(pred.BodyPartsPresent) > 0 { + return true + } + if len(pred.ObjectsPresent) > 0 { + return true + } + if len(pred.ClothingPresent) > 0 { + return true + } + if len(pred.Boxes) > 0 { + return true + } + + return false +} + +func predictionUsableForAnalyze(pred TrainingPrediction) bool { + if pred.ModelAvailable { + return true + } + + // Fallback: + // Einige AI-Server/Antworten liefern verwertbare Labels/Boxes, + // setzen modelAvailable aber nicht sauber. + return predictionHasAnyAnalyzeSignal(pred) +} + func buildCombinedHighlightHitFromPrediction(pred TrainingPrediction, t float64) (analyzeHit, bool) { - if !pred.ModelAvailable { + if !predictionUsableForAnalyze(pred) { return analyzeHit{}, false } @@ -1471,7 +1529,7 @@ func buildCombinedHighlightHitFromPrediction(pred TrainingPrediction, t float64) } func buildHighlightHitsFromPrediction(pred TrainingPrediction, t float64) []analyzeHit { - if !pred.ModelAvailable { + if !predictionUsableForAnalyze(pred) { return nil } @@ -1938,6 +1996,19 @@ func analyzeVideoFromFramesForGoal( sample := samples[startIdx+i] pred := predictions[i] + if !pred.ModelAvailable && predictionHasAnyAnalyzeSignal(pred) { + appLogf( + "⚠️ [analyze] prediction has signals but modelAvailable=false file=%q t=%.3f sex=%q body=%d objects=%d clothing=%d boxes=%d", + file, + sample.Time, + strings.TrimSpace(pred.SexPosition), + len(pred.BodyPartsPresent), + len(pred.ObjectsPresent), + len(pred.ClothingPresent), + len(pred.Boxes), + ) + } + highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, sample.Time) } diff --git a/backend/assets_generate.go b/backend/assets_generate.go index 9e594e5..02f3494 100644 --- a/backend/assets_generate.go +++ b/backend/assets_generate.go @@ -185,6 +185,7 @@ func ensureAnalyzeAllGoalsForVideoCtxForce( ai := &aiAnalysisMeta{ Goal: "highlights", Mode: "video", + Completed: true, Hits: hits, Segments: segments, Rating: rating, @@ -1412,24 +1413,46 @@ func hasAIAnalysisForOutputGoal(outPath string, goal string) bool { return false } - // Eine Analyse ist erst dann fertig, wenn wirklich ein Rating-Objekt - // mit typischen Rating-Feldern gespeichert wurde. + // Neuer sauberer Marker: + // Analyse wurde wirklich ausgeführt und abgeschlossen. + // Das gilt auch dann, wenn es keine Hits/Segmente gab. + if completed, ok := aiMap["completed"].(bool); ok && completed { + return true + } + + // Treffer/Segmente sind ebenfalls ein sicherer Hinweis. + rawHits, hasHits := aiMap["hits"].([]any) + if hasHits && len(rawHits) > 0 { + return true + } + + rawSegs, hasSegs := aiMap["segments"].([]any) + if hasSegs && len(rawSegs) > 0 { + return true + } + + // Legacy-Fallback: + // Positive Rating-Werte zählen, aber stars alleine NICHT. + // stars=1 kann nur der Default sein. rating, ok := aiMap["rating"].(map[string]any) if !ok || rating == nil { return false } - if jsonFieldExists(rating, "score") || - jsonFieldExists(rating, "stars") || - jsonFieldExists(rating, "segments") { + if isPositiveJSONNumber(rating["segments"]) { + return true + } + if isPositiveJSONNumber(rating["score"]) { + return true + } + if isPositiveJSONNumber(rating["flaggedSeconds"]) { + return true + } + if isPositiveJSONNumber(rating["weightedFlaggedSeconds"]) { return true } - // Legacy-Fallback: alte Dateien ohne Rating, aber mit Hits/Segments. - rawHits, hasHits := aiMap["hits"].([]any) - rawSegs, hasSegs := aiMap["segments"].([]any) - - return (hasHits && len(rawHits) > 0) || (hasSegs && len(rawSegs) > 0) + return false } func jsonFieldExists(m map[string]any, key string) bool { diff --git a/backend/data/pending-autostart/admin.json b/backend/data/pending-autostart/admin.json new file mode 100644 index 0000000..fc69ce2 --- /dev/null +++ b/backend/data/pending-autostart/admin.json @@ -0,0 +1,3 @@ +{ + "items": [] +} \ No newline at end of file diff --git a/backend/meta.go b/backend/meta.go index 72f8158..819d4b0 100644 --- a/backend/meta.go +++ b/backend/meta.go @@ -84,6 +84,11 @@ type postworkCompletedMeta struct { Analyze bool `json:"analyze,omitempty"` } +type postworkMeta struct { + Completed postworkCompletedMeta `json:"completed,omitempty"` + History []postworkHistoryEntry `json:"history,omitempty"` +} + type videoMeta struct { Version int `json:"version"` UpdatedAtUnix int64 `json:"updatedAtUnix"` @@ -92,6 +97,7 @@ type videoMeta struct { Media videoMediaMeta `json:"media"` Preview previewMeta `json:"preview,omitempty"` Analysis analysisMeta `json:"analysis,omitempty"` + Postwork *postworkMeta `json:"postwork,omitempty"` Validation map[string]any `json:"validation,omitempty"` } @@ -111,12 +117,29 @@ type aiSegmentMeta struct { type aiAnalysisMeta struct { Goal string `json:"goal,omitempty"` Mode string `json:"mode,omitempty"` + Completed bool `json:"completed,omitempty"` Hits []analyzeHit `json:"hits,omitempty"` Segments []aiSegmentMeta `json:"segments,omitempty"` Rating *aiRatingMeta `json:"rating,omitempty"` AnalyzedAtUnix int64 `json:"analyzedAtUnix,omitempty"` } +func isZeroPostworkCompletedMeta(v postworkCompletedMeta) bool { + return !v.Meta && + !v.Teaser && + !v.Thumb && + !v.Sprites && + !v.Analyze +} + +func isZeroPostworkMeta(v *postworkMeta) bool { + if v == nil { + return true + } + + return isZeroPostworkCompletedMeta(v.Completed) && len(v.History) == 0 +} + func readVideoMeta(metaPath string, fi os.FileInfo) (dur float64, w int, h int, fps float64, ok bool) { m, ok := readVideoMetaIfValid(metaPath, fi) if !ok || m == nil { @@ -455,6 +478,7 @@ func writeVideoMeta( m.Preview = existing.Preview m.Analysis = existing.Analysis m.Validation = existing.Validation + m.Postwork = existing.Postwork if m.File.SourceURL == "" { m.File.SourceURL = existing.File.SourceURL @@ -473,6 +497,10 @@ func writeVideoMeta( } } + if isZeroPostworkMeta(m.Postwork) { + m.Postwork = nil + } + buf, err := json.MarshalIndent(m, "", " ") if err != nil { return err @@ -522,6 +550,7 @@ func writeVideoMetaAI( if existing != nil { m.Preview = existing.Preview m.Validation = existing.Validation + m.Postwork = existing.Postwork if m.Media.Video.Width <= 0 { m.Media.Video.Width = existing.Media.Video.Width @@ -552,6 +581,10 @@ func writeVideoMetaAI( m.Analysis.Highlights = ai } + if isZeroPostworkMeta(m.Postwork) { + m.Postwork = nil + } + buf, err := json.MarshalIndent(m, "", " ") if err != nil { return err @@ -596,6 +629,7 @@ func writeVideoMetaWithPreviewClips(metaPath string, fi os.FileInfo, dur float64 if existing != nil { m.Analysis = existing.Analysis + m.Postwork = existing.Postwork if existing.Preview.Sprite != nil && m.Preview.Sprite == nil { m.Preview.Sprite = existing.Preview.Sprite @@ -620,6 +654,10 @@ func writeVideoMetaWithPreviewClips(metaPath string, fi os.FileInfo, dur float64 } } + if isZeroPostworkMeta(m.Postwork) { + m.Postwork = nil + } + buf, err := json.Marshal(m) if err != nil { return err @@ -675,6 +713,7 @@ func writeVideoMetaWithPreviewClipsAndSprite( if existing != nil { m.Analysis = existing.Analysis + m.Postwork = existing.Postwork m.Preview.Sprite = mergePreviewSpriteMeta(existing.Preview.Sprite, m.Preview.Sprite) @@ -704,6 +743,10 @@ func writeVideoMetaWithPreviewClipsAndSprite( } } + if isZeroPostworkMeta(m.Postwork) { + m.Postwork = nil + } + buf, err := json.Marshal(m) if err != nil { return err diff --git a/backend/models_api.go b/backend/models_api.go index 9fc0982..682de12 100644 --- a/backend/models_api.go +++ b/backend/models_api.go @@ -32,7 +32,6 @@ func setModelStore(store *ModelStore) { modelStoreRef = store setCoverModelStore(store) setChaturbateOnlineModelStore(store) - setMyFreeCamsOnlineModelStore(store) } // ✅ umbenannt, damit es nicht mit models.go kollidiert diff --git a/backend/myfreecams_biocontext.go b/backend/myfreecams_biocontext.go index 8c9d50f..0015741 100644 --- a/backend/myfreecams_biocontext.go +++ b/backend/myfreecams_biocontext.go @@ -11,6 +11,7 @@ import ( "net/url" "strconv" "strings" + "sync" "time" ) @@ -19,6 +20,97 @@ const myfreecamsBioContextURLFmt = "https://api-edge.myfreecams.com/usernameLook // getrennt von Chaturbate, damit die Datei unabhängig bleibt const myfreecamsBioCacheMaxAge = 10 * time.Minute +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) + } +} + type MyFreeCamsBioContextResp struct { Enabled bool `json:"enabled"` FetchedAt time.Time `json:"fetchedAt"` @@ -38,6 +130,8 @@ type MyFreeCamsBioContextResp struct { Gender string `json:"gender,omitempty"` } +type mfcMaybeString string + type mfcBioLookupResp struct { Result struct { Success int `json:"success"` @@ -90,6 +184,31 @@ type mfcBioLookupResp struct { Err int `json:"err"` } +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)) +} + func normalizeMFCBioRoomStatus(vstate int, hasSession bool) string { if !hasSession { return "offline" diff --git a/backend/myfreecams_lookup_pause.go b/backend/myfreecams_lookup_pause.go deleted file mode 100644 index 2b3476a..0000000 --- a/backend/myfreecams_lookup_pause.go +++ /dev/null @@ -1,100 +0,0 @@ -// 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) - } -} diff --git a/backend/myfreecams_online.go b/backend/myfreecams_online.go deleted file mode 100644 index e72831b..0000000 --- a/backend/myfreecams_online.go +++ /dev/null @@ -1,736 +0,0 @@ -// 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//853x480/mfc_ - 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, - }) -} diff --git a/backend/record.go b/backend/record.go index 7ae660c..5b1dfc5 100644 --- a/backend/record.go +++ b/backend/record.go @@ -1030,60 +1030,28 @@ func applyFinishedPhaseTruthToRecordJobMeta(j *RecordJob) { return } - id := canonicalAssetIDFromName(outPath) - id = strings.TrimSpace(id) + id := strings.TrimSpace(canonicalAssetIDFromName(outPath)) if id == "" { return } truth := finishedPhaseTruthForID(id) - rv := reflect.ValueOf(j) - if rv.Kind() != reflect.Pointer || rv.IsNil() { - return - } - sv := rv.Elem() - if !sv.IsValid() || sv.Kind() != reflect.Struct { - return + if j.Meta == nil { + j.Meta = &videoMeta{} } - fv := sv.FieldByName("Meta") - if !fv.IsValid() || !fv.CanSet() { - return + if j.Meta.Postwork == nil { + j.Meta.Postwork = &postworkMeta{} } - var raw any - switch fv.Kind() { - case reflect.Interface: - if fv.IsNil() { - raw = nil - } else { - raw = fv.Interface() - } - default: - raw = fv.Interface() + j.Meta.Postwork.Completed = postworkCompletedMeta{ + Meta: truth.MetaReady, + Teaser: truth.TeaserReady, + Thumb: truth.ThumbReady, + Sprites: truth.SpritesReady, + Analyze: truth.AnalyzeReady, } - - meta := metaMapFromAny(raw) - if meta == nil { - meta = map[string]any{} - } - - postworkMap, _ := meta["postwork"].(map[string]any) - if postworkMap == nil { - postworkMap = map[string]any{} - } - - postworkMap["completed"] = map[string]any{ - "meta": truth.MetaReady, - "teaser": truth.TeaserReady, - "thumb": truth.ThumbReady, - "sprites": truth.SpritesReady, - "analyze": truth.AnalyzeReady, - } - - meta["postwork"] = postworkMap - setStructFieldJSONMap(fv, meta) } // ---------------- Handlers ---------------- diff --git a/backend/routes.go b/backend/routes.go index d150c1b..b81b336 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -104,7 +104,6 @@ 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) @@ -135,7 +134,6 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore { setCoverModelStore(store) RegisterModelAPI(api, store) setChaturbateOnlineModelStore(store) - setMyFreeCamsOnlineModelStore(store) // -------------------------- // 4) Mount Protected API diff --git a/backend/server.go b/backend/server.go index f803a14..79100da 100644 --- a/backend/server.go +++ b/backend/server.go @@ -844,7 +844,6 @@ func main() { // Hintergrund-Worker go startDiskSpaceGuard() go startChaturbateOnlinePoller(store) - go startMyFreeCamsOnlinePoller(store) go startChaturbateAutoStartWorker(store) go startMyFreeCamsAutoStartWorker(store) diff --git a/backend/settings.go b/backend/settings.go index e7fb8a4..653b293 100644 --- a/backend/settings.go +++ b/backend/settings.go @@ -560,7 +560,6 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) { if newStore != nil { setModelStore(newStore) setChaturbateOnlineModelStore(newStore) - setMyFreeCamsOnlineModelStore(newStore) } // ✅ ffmpeg/ffprobe nach Änderungen neu bestimmen diff --git a/frontend/src/components/ui/FinishedVideoPreview.tsx b/frontend/src/components/ui/FinishedVideoPreview.tsx index 9d4820d..c50deca 100644 --- a/frontend/src/components/ui/FinishedVideoPreview.tsx +++ b/frontend/src/components/ui/FinishedVideoPreview.tsx @@ -1304,7 +1304,7 @@ export default function FinishedVideoPreview({