diff --git a/backend/dist/nsfwapp-linux-amd64 b/backend/dist/nsfwapp-linux-amd64 index dd91f16..01fb1c2 100644 Binary files a/backend/dist/nsfwapp-linux-amd64 and b/backend/dist/nsfwapp-linux-amd64 differ diff --git a/backend/dist/nsfwapp.exe b/backend/dist/nsfwapp.exe index 523625d..b81984a 100644 Binary files a/backend/dist/nsfwapp.exe and b/backend/dist/nsfwapp.exe differ diff --git a/backend/dist/nsfwapp_amd64.deb b/backend/dist/nsfwapp_amd64.deb index ef1c1ad..9d700ab 100644 Binary files a/backend/dist/nsfwapp_amd64.deb and b/backend/dist/nsfwapp_amd64.deb differ diff --git a/backend/models_api.go b/backend/models_api.go index b14b316..db97a62 100644 --- a/backend/models_api.go +++ b/backend/models_api.go @@ -140,6 +140,8 @@ type importResult struct { type modelsOverviewRequest struct { Keys []string `json:"keys"` IncludeWatched bool `json:"includeWatched"` + IncludeStats *bool `json:"includeStats,omitempty"` + IncludeModels *bool `json:"includeModels,omitempty"` } type modelsOverviewModel struct { @@ -187,6 +189,13 @@ func normalizeModelKeySet(in []string) map[string]bool { return out } +func overviewBool(v *bool, fallback bool) bool { + if v == nil { + return fallback + } + return *v +} + func isOnlineRoomStatus(status string, isOnline bool) bool { s := strings.ToLower(strings.TrimSpace(status)) if s == "offline" { @@ -213,9 +222,15 @@ func buildModelsOverview(rawList any, req modelsOverviewRequest) (modelsOverview keySet := normalizeModelKeySet(req.Keys) + includeStats := overviewBool(req.IncludeStats, true) + includeModels := overviewBool(req.IncludeModels, true) + resp := modelsOverviewResponse{ - TotalCount: len(list), - Models: make([]modelsOverviewModel, 0, len(keySet)+32), + Models: make([]modelsOverviewModel, 0, len(keySet)+32), + } + + if includeStats { + resp.TotalCount = len(list) } for _, m := range list { @@ -224,27 +239,30 @@ func buildModelsOverview(rawList any, req modelsOverviewRequest) (modelsOverview continue } - online := isOnlineRoomStatus(m.RoomStatus, m.IsOnline) + if includeStats { + online := isOnlineRoomStatus(m.RoomStatus, m.IsOnline) - if strings.Contains(strings.ToLower(m.Host), "chaturbate") { - if online { - resp.OnlineModelsCount++ + if strings.Contains(strings.ToLower(m.Host), "chaturbate") { + if online { + resp.OnlineModelsCount++ + } + if m.Watching && online { + resp.OnlineWatchedModelsCount++ + } } - if m.Watching && online { - resp.OnlineWatchedModelsCount++ - } - } - if m.Favorite && online { - resp.OnlineFavCount++ - } - if m.Liked != nil && *m.Liked && online { - resp.OnlineLikedCount++ + if m.Favorite && online { + resp.OnlineFavCount++ + } + if m.Liked != nil && *m.Liked && online { + resp.OnlineLikedCount++ + } } needModel := - keySet[key] || - (req.IncludeWatched && m.Watching) + includeModels && + (keySet[key] || + (req.IncludeWatched && m.Watching)) if needModel { resp.Models = append(resp.Models, m) @@ -259,10 +277,17 @@ func loadModelsOverview(store *ModelStore, req modelsOverviewRequest) (modelsOve return modelsOverviewResponse{}, err } - onlineExpr := `(lower(trim(COALESCE(room_status,''))) <> 'offline' AND (COALESCE(is_online,false) = true OR trim(COALESCE(room_status,'')) <> ''))` + includeStats := overviewBool(req.IncludeStats, true) + includeModels := overviewBool(req.IncludeModels, true) - var totalCount, onlineCount, onlineWatchedCount, onlineFavCount, onlineLikedCount int64 - err := store.db.QueryRow(` + var resp modelsOverviewResponse + resp.Models = make([]modelsOverviewModel, 0, len(req.Keys)+32) + + if includeStats { + onlineExpr := `(lower(trim(COALESCE(room_status,''))) <> 'offline' AND (COALESCE(is_online,false) = true OR trim(COALESCE(room_status,'')) <> ''))` + + var totalCount, onlineCount, onlineWatchedCount, onlineFavCount, onlineLikedCount int64 + err := store.db.QueryRow(` SELECT COUNT(*), COALESCE(SUM(CASE WHEN lower(COALESCE(host,'')) LIKE '%chaturbate%' AND `+onlineExpr+` THEN 1 ELSE 0 END), 0), @@ -271,17 +296,19 @@ SELECT COALESCE(SUM(CASE WHEN COALESCE(liked,false) = true AND `+onlineExpr+` THEN 1 ELSE 0 END), 0) FROM models; `).Scan(&totalCount, &onlineCount, &onlineWatchedCount, &onlineFavCount, &onlineLikedCount) - if err != nil { - return modelsOverviewResponse{}, err + if err != nil { + return modelsOverviewResponse{}, err + } + + resp.TotalCount = int(totalCount) + resp.OnlineModelsCount = int(onlineCount) + resp.OnlineWatchedModelsCount = int(onlineWatchedCount) + resp.OnlineFavCount = int(onlineFavCount) + resp.OnlineLikedCount = int(onlineLikedCount) } - resp := modelsOverviewResponse{ - TotalCount: int(totalCount), - OnlineModelsCount: int(onlineCount), - OnlineWatchedModelsCount: int(onlineWatchedCount), - OnlineFavCount: int(onlineFavCount), - OnlineLikedCount: int(onlineLikedCount), - Models: make([]modelsOverviewModel, 0, len(req.Keys)+32), + if !includeModels { + return resp, nil } keySet := normalizeModelKeySet(req.Keys) @@ -578,6 +605,23 @@ func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) { modelsWriteJSON(w, http.StatusOK, resp) }) + mux.HandleFunc("/api/models/list", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + + store := getModelStore() + if store == nil { + modelsWriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "model store nicht verfügbar"}) + return + } + + modelsWriteJSON(w, http.StatusOK, map[string]any{ + "items": store.ListForModelTab(), + }) + }) + mux.HandleFunc("/api/models", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) diff --git a/backend/models_store.go b/backend/models_store.go index b3b07fd..aa29931 100644 --- a/backend/models_store.go +++ b/backend/models_store.go @@ -341,6 +341,10 @@ func (s *ModelStore) profileImagePresenceExpr() string { return fmt.Sprintf("CASE WHEN trim(COALESCE(profile_image_path,'')) <> '' OR (profile_image_blob IS NOT NULL AND %s > 0) THEN 1 ELSE 0 END", s.blobLengthExpr()) } +func (s *ModelStore) profileImagePathPresenceExpr() string { + return "CASE WHEN trim(COALESCE(profile_image_path,'')) <> '' THEN 1 ELSE 0 END" +} + func (s *ModelStore) emptyTextExpr() string { return "''::text" } @@ -1577,6 +1581,144 @@ ORDER BY updated_at DESC; return out } +func (s *ModelStore) ListForModelTab() []StoredModel { + if err := s.ensureInit(); err != nil { + return []StoredModel{} + } + + rows, err := s.db.Query(fmt.Sprintf(` +SELECT + id, + COALESCE(input,'') as input, + is_url, + COALESCE(host,'') as host, + COALESCE(path,'') as path, + COALESCE(model_key,'') as model_key, + COALESCE(tags,'') as tags, + COALESCE(cb_online_json,'') as cb_online_json, + last_stream, + COALESCE(profile_image_url,''), + profile_image_updated_at, + %s as has_profile_image, + COALESCE(room_status,'') as room_status, + COALESCE(is_online,false) as is_online, + COALESCE(chat_room_url,'') as chat_room_url, + COALESCE(image_url,'') as image_url, + last_online_at, + last_offline_at, + last_room_sync_at, + watching,favorite,hot,keep,liked, + created_at, updated_at +FROM models +ORDER BY updated_at DESC; +`, s.profileImagePathPresenceExpr())) + if err != nil { + appLogln("models ListForModelTab query err:", err) + return []StoredModel{} + } + defer rows.Close() + + out := make([]StoredModel, 0, 256) + scanErrCount := 0 + + for rows.Next() { + var ( + id, input, host, path, modelKey, tags string + cbOnlineJSON string + isURL bool + + lastStream dbNullTime + + profileImageURL string + profileImageUpdatedAt dbNullTime + hasProfileImage int64 + + roomStatus string + isOnline bool + chatRoomURL string + imageURL string + lastOnlineAt dbNullTime + lastOfflineAt dbNullTime + lastRoomSyncAt dbNullTime + + watching, favorite, hot, keep bool + liked sql.NullBool + + createdAt, updatedAt dbNullTime + ) + + if err := rows.Scan( + &id, &input, &isURL, &host, &path, &modelKey, + &tags, &cbOnlineJSON, &lastStream, + &profileImageURL, &profileImageUpdatedAt, &hasProfileImage, + &roomStatus, &isOnline, &chatRoomURL, &imageURL, + &lastOnlineAt, &lastOfflineAt, &lastRoomSyncAt, + &watching, &favorite, &hot, &keep, &liked, + &createdAt, &updatedAt, + ); err != nil { + scanErrCount++ + continue + } + + var gender, country string + if strings.TrimSpace(cbOnlineJSON) != "" { + var snap ChaturbateOnlineSnapshot + if err := json.Unmarshal([]byte(cbOnlineJSON), &snap); err == nil { + gender = strings.TrimSpace(snap.Gender) + country = strings.TrimSpace(snap.Country) + } + } + + m := StoredModel{ + ID: id, + Input: input, + IsURL: isURL, + Host: host, + Path: path, + ModelKey: modelKey, + + Tags: tags, + LastStream: fmtNullTime(lastStream.NullTime), + Gender: gender, + Country: country, + + ProfileImageURL: profileImageURL, + ProfileImageUpdatedAt: fmtNullTime(profileImageUpdatedAt.NullTime), + + RoomStatus: roomStatus, + IsOnline: isOnline, + ChatRoomURL: chatRoomURL, + ImageURL: imageURL, + LastOnlineAt: fmtNullTime(lastOnlineAt.NullTime), + LastOfflineAt: fmtNullTime(lastOfflineAt.NullTime), + LastRoomSyncAt: fmtNullTime(lastRoomSyncAt.NullTime), + + Watching: watching, + Favorite: favorite, + Hot: hot, + Keep: keep, + Liked: ptrLikedFromNullBool(liked), + + CreatedAt: fmtNullTime(createdAt.NullTime), + UpdatedAt: fmtNullTime(updatedAt.NullTime), + } + + if hasProfileImage != 0 { + m.ProfileImageCached = "/api/models/image?id=" + url.QueryEscape(id) + } + + out = append(out, m) + } + if err := rows.Err(); err != nil { + appLogln("models ListForModelTab rows err:", err) + } + if scanErrCount > 0 { + appLogln("models ListForModelTab skipped rows after scan errors:", scanErrCount) + } + + return out +} + func (s *ModelStore) Meta() ModelsMeta { if err := s.ensureInit(); err != nil { return ModelsMeta{Count: 0, UpdatedAt: ""} @@ -2207,7 +2349,7 @@ SELECT id FROM updated; return StoredModel{}, err } - return s.getByID(updatedID) + return s.getModelTabByID(updatedID) } func (s *ModelStore) Delete(id string) error { @@ -2299,6 +2441,127 @@ ON CONFLICT(id) DO UPDATE SET return m, inserted, err } +func (s *ModelStore) getModelTabByID(id string) (StoredModel, error) { + id = strings.TrimSpace(id) + if id == "" { + return StoredModel{}, errors.New("id fehlt") + } + + var ( + input, host, path, modelKey, tags string + cbOnlineJSON string + isURL bool + + lastStream dbNullTime + + profileImageURL string + profileImageUpdatedAt dbNullTime + hasProfileImage int64 + + roomStatus string + isOnline bool + chatRoomURL string + imageURL string + lastOnlineAt dbNullTime + lastOfflineAt dbNullTime + lastRoomSyncAt dbNullTime + + watching, favorite, hot, keep bool + liked sql.NullBool + + createdAt, updatedAt dbNullTime + ) + + err := s.db.QueryRow(fmt.Sprintf(` +SELECT + COALESCE(input,'') as input, + is_url, + COALESCE(host,'') as host, + COALESCE(path,'') as path, + COALESCE(model_key,'') as model_key, + COALESCE(tags,'') as tags, + COALESCE(cb_online_json,'') as cb_online_json, + last_stream, + COALESCE(profile_image_url,''), + profile_image_updated_at, + %s as has_profile_image, + COALESCE(room_status,'') as room_status, + COALESCE(is_online,false) as is_online, + COALESCE(chat_room_url,'') as chat_room_url, + COALESCE(image_url,'') as image_url, + last_online_at, + last_offline_at, + last_room_sync_at, + watching,favorite,hot,keep,liked, + created_at, updated_at +FROM models +WHERE id=$1; + `, s.profileImagePathPresenceExpr()), id).Scan( + &input, &isURL, &host, &path, &modelKey, + &tags, &cbOnlineJSON, &lastStream, + &profileImageURL, &profileImageUpdatedAt, &hasProfileImage, + &roomStatus, &isOnline, &chatRoomURL, &imageURL, + &lastOnlineAt, &lastOfflineAt, &lastRoomSyncAt, + &watching, &favorite, &hot, &keep, &liked, + &createdAt, &updatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return StoredModel{}, errors.New("model nicht gefunden") + } + if err != nil { + return StoredModel{}, err + } + + var gender, country string + if strings.TrimSpace(cbOnlineJSON) != "" { + var snap ChaturbateOnlineSnapshot + if err := json.Unmarshal([]byte(cbOnlineJSON), &snap); err == nil { + gender = strings.TrimSpace(snap.Gender) + country = strings.TrimSpace(snap.Country) + } + } + + m := StoredModel{ + ID: id, + Input: input, + IsURL: isURL, + Host: host, + Path: path, + ModelKey: modelKey, + + Tags: tags, + LastStream: fmtNullTime(lastStream.NullTime), + Gender: gender, + Country: country, + + ProfileImageURL: profileImageURL, + ProfileImageUpdatedAt: fmtNullTime(profileImageUpdatedAt.NullTime), + + RoomStatus: roomStatus, + IsOnline: isOnline, + ChatRoomURL: chatRoomURL, + ImageURL: imageURL, + LastOnlineAt: fmtNullTime(lastOnlineAt.NullTime), + LastOfflineAt: fmtNullTime(lastOfflineAt.NullTime), + LastRoomSyncAt: fmtNullTime(lastRoomSyncAt.NullTime), + + Watching: watching, + Favorite: favorite, + Hot: hot, + Keep: keep, + Liked: ptrLikedFromNullBool(liked), + + CreatedAt: fmtNullTime(createdAt.NullTime), + UpdatedAt: fmtNullTime(updatedAt.NullTime), + } + + if hasProfileImage != 0 { + m.ProfileImageCached = "/api/models/image?id=" + url.QueryEscape(id) + } + + return m, nil +} + func (s *ModelStore) getByID(id string) (StoredModel, error) { var ( input, host, path, modelKey, tags string diff --git a/backend/training.go b/backend/training.go index 0275429..33e2872 100644 --- a/backend/training.go +++ b/backend/training.go @@ -966,7 +966,12 @@ func trainingAnalysisValueTruthy(value any) bool { func trainingPublishAnalysisPayload(payload map[string]any) { trainingRememberAnalysisPayload(payload) - trainingPublishAnalysisPayload(payload) + b, err := json.Marshal(payload) + if err != nil { + return + } + + publishSSE("analysisProgress", b) } func trainingPublishAnalysisStep( @@ -1021,12 +1026,7 @@ func trainingPublishAnalysisStepWithPreview( payload["previewUrl"] = strings.TrimSpace(previewURL) } - b, err := json.Marshal(payload) - if err != nil { - return - } - - publishSSE("analysisProgress", b) + trainingPublishAnalysisPayload(payload) } func trainingPublishAnalysisStarted( @@ -2882,23 +2882,25 @@ func trainingLatestOpenSample( sourceFile = filepath.Base(sample.SourcePath) } - trainingPublishAnalysisStep( + trainingPublishAnalysisStepWithPreview( requestID, startedAtMs, 1, 2, sourceFile, + sample.FrameURL, "Aktuelles Bild wird analysiert…", ) sample.Prediction = trainingPredictFrame(framePath) - trainingPublishAnalysisStep( + trainingPublishAnalysisStepWithPreview( requestID, startedAtMs, 2, 2, sourceFile, + sample.FrameURL, "Analyse-Ergebnis wird gespeichert…", ) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2e8a35f..4b4a478 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1826,12 +1826,12 @@ export default function App() { const doneSortRef = useRef(doneSort) type AppModelsSnapshot = { - totalCount: number - onlineModelsCount: number - onlineWatchedModelsCount: number - onlineFavCount: number - onlineLikedCount: number - models: StoredModel[] + totalCount?: number + onlineModelsCount?: number + onlineWatchedModelsCount?: number + onlineFavCount?: number + onlineLikedCount?: number + models?: StoredModel[] } const [headerStats, setHeaderStats] = useState({ @@ -1873,6 +1873,63 @@ export default function App() { const overviewTimerRef = useRef(null) const overviewAbortRef = useRef(null) const overviewPendingKeysRef = useRef(null) + const overviewStatsInFlightRef = useRef(false) + const overviewStatsLastAtRef = useRef(0) + const overviewStatsAbortRef = useRef(null) + + const applyHeaderStatsFromSnapshot = useCallback((data: AppModelsSnapshot | null | undefined) => { + if (!data) return + + setHeaderStats((prev) => { + const totalCount = Number(data.totalCount) + const onlineModelsCount = Number(data.onlineModelsCount) + const onlineWatchedModelsCount = Number(data.onlineWatchedModelsCount) + const onlineFavCount = Number(data.onlineFavCount) + const onlineLikedCount = Number(data.onlineLikedCount) + + const next = { + totalCount: Number.isFinite(totalCount) ? totalCount : prev.totalCount, + onlineModelsCount: Number.isFinite(onlineModelsCount) ? onlineModelsCount : prev.onlineModelsCount, + onlineWatchedModelsCount: Number.isFinite(onlineWatchedModelsCount) + ? onlineWatchedModelsCount + : prev.onlineWatchedModelsCount, + onlineFavCount: Number.isFinite(onlineFavCount) ? onlineFavCount : prev.onlineFavCount, + onlineLikedCount: Number.isFinite(onlineLikedCount) ? onlineLikedCount : prev.onlineLikedCount, + } + + return ( + prev.totalCount === next.totalCount && + prev.onlineModelsCount === next.onlineModelsCount && + prev.onlineWatchedModelsCount === next.onlineWatchedModelsCount && + prev.onlineFavCount === next.onlineFavCount && + prev.onlineLikedCount === next.onlineLikedCount + ) + ? prev + : next + }) + }, []) + + const mergeModelsSnapshot = useCallback((models: StoredModel[] | undefined | null) => { + const safeList = Array.isArray(models) ? models : [] + if (safeList.length === 0) return + + const built = buildModelsByKey(safeList) + + setModelsByKey((prev) => { + const next = { ...prev } + let changed = false + + for (const [k, incoming] of Object.entries(built)) { + const merged = mergeStoredModel(prev[k], incoming) + if (!storedModelShallowEqual(prev[k], merged)) { + next[k] = merged + changed = true + } + } + + return changed ? next : prev + }) + }, [buildModelsByKey]) const loadAppModelsSnapshot = useCallback(async (keys: string[]) => { const uniqKeys = Array.from( @@ -1894,7 +1951,6 @@ export default function App() { if (overviewLastSigRef.current === sig && now - overviewLastAtRef.current < MODELS_OVERVIEW_COOLDOWN_MS) return overviewInFlightRef.current = true - overviewAbortRef.current?.abort() const controller = new AbortController() overviewAbortRef.current = controller @@ -1907,50 +1963,16 @@ export default function App() { signal: controller.signal, body: JSON.stringify({ keys: uniqKeys, - includeWatched: true, + includeWatched: false, + includeStats: false, + includeModels: true, }), }) - setHeaderStats((prev) => { - const next = { - totalCount: Number(data?.totalCount ?? 0), - onlineModelsCount: Number(data?.onlineModelsCount ?? 0), - onlineWatchedModelsCount: Number(data?.onlineWatchedModelsCount ?? 0), - onlineFavCount: Number(data?.onlineFavCount ?? 0), - onlineLikedCount: Number(data?.onlineLikedCount ?? 0), - } - - return ( - prev.totalCount === next.totalCount && - prev.onlineModelsCount === next.onlineModelsCount && - prev.onlineWatchedModelsCount === next.onlineWatchedModelsCount && - prev.onlineFavCount === next.onlineFavCount && - prev.onlineLikedCount === next.onlineLikedCount - ) - ? prev - : next - }) - overviewLastSigRef.current = sig overviewLastAtRef.current = now - const safeList = Array.isArray(data?.models) ? data.models : [] - const built = buildModelsByKey(safeList) - - setModelsByKey((prev) => { - const next = { ...prev } - let changed = false - - for (const [k, incoming] of Object.entries(built)) { - const merged = mergeStoredModel(prev[k], incoming) - if (!storedModelShallowEqual(prev[k], merged)) { - next[k] = merged - changed = true - } - } - - return changed ? next : prev - }) + mergeModelsSnapshot(data?.models) } catch (error: any) { if (error?.name === 'AbortError') return // ignore @@ -1974,7 +1996,46 @@ export default function App() { }, MODELS_OVERVIEW_DEBOUNCE_MS) } } - }, [buildModelsByKey]) + }, [mergeModelsSnapshot]) + + const loadAppModelsStatsAndWatched = useCallback(async (force = false) => { + const now = Date.now() + + if (overviewStatsInFlightRef.current) return + if (!force && now - overviewStatsLastAtRef.current < MODELS_OVERVIEW_COOLDOWN_MS) return + + overviewStatsInFlightRef.current = true + + const controller = new AbortController() + overviewStatsAbortRef.current = controller + + try { + const data = await apiJSON('/api/models/overview', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + cache: 'no-store' as any, + signal: controller.signal, + body: JSON.stringify({ + keys: [], + includeWatched: true, + includeStats: true, + includeModels: true, + }), + }) + + overviewStatsLastAtRef.current = Date.now() + applyHeaderStatsFromSnapshot(data) + mergeModelsSnapshot(data?.models) + } catch (error: any) { + if (error?.name === 'AbortError') return + // ignore + } finally { + overviewStatsInFlightRef.current = false + if (overviewStatsAbortRef.current === controller) { + overviewStatsAbortRef.current = null + } + } + }, [applyHeaderStatsFromSnapshot, mergeModelsSnapshot]) const maybeNotifyWatchedModelStatusChange = useCallback(( keyLower: string, @@ -2349,6 +2410,7 @@ export default function App() { if (lastOverviewSigRef.current !== requiredModelKeysSig) { lastOverviewSigRef.current = requiredModelKeysSig scheduleOverviewLoad() + void loadAppModelsStatsAndWatched(false) } const onChanged = (ev: Event) => { @@ -2379,6 +2441,7 @@ export default function App() { ) scheduleOverviewLoad() + void loadAppModelsStatsAndWatched(true) return } @@ -2398,10 +2461,12 @@ export default function App() { } scheduleOverviewLoad() + void loadAppModelsStatsAndWatched(true) return } scheduleOverviewLoad() + void loadAppModelsStatsAndWatched(false) } window.addEventListener('models-changed', onChanged as any) @@ -2413,13 +2478,44 @@ export default function App() { window.clearTimeout(overviewTimerRef.current) overviewTimerRef.current = null } + } + }, [authed, loadAppModelsSnapshot, loadAppModelsStatsAndWatched, requiredModelKeysSig, requiredModelKeys, upsertModelCache]) + + useEffect(() => { + if (authed) return + + if (overviewTimerRef.current != null) { + window.clearTimeout(overviewTimerRef.current) + overviewTimerRef.current = null + } + + overviewPendingKeysRef.current = null + overviewAbortRef.current?.abort() + overviewAbortRef.current = null + overviewInFlightRef.current = false + overviewStatsAbortRef.current?.abort() + overviewStatsAbortRef.current = null + overviewStatsInFlightRef.current = false + overviewStatsLastAtRef.current = 0 + }, [authed]) + + useEffect(() => { + return () => { + if (overviewTimerRef.current != null) { + window.clearTimeout(overviewTimerRef.current) + overviewTimerRef.current = null + } overviewPendingKeysRef.current = null overviewAbortRef.current?.abort() overviewAbortRef.current = null overviewInFlightRef.current = false + overviewStatsAbortRef.current?.abort() + overviewStatsAbortRef.current = null + overviewStatsInFlightRef.current = false + overviewStatsLastAtRef.current = 0 } - }, [authed, loadAppModelsSnapshot, requiredModelKeysSig, requiredModelKeys, upsertModelCache]) + }, []) useEffect(() => { donePageRef.current = donePage @@ -2592,6 +2688,7 @@ export default function App() { // der watched/fav/like Counter + modelsByKey if (safeFetchedAt && safeFetchedAt !== lastSeenFetchedAt) { lastSeenFetchedAt = safeFetchedAt + void loadAppModelsStatsAndWatched(true) void loadAppModelsSnapshot(requiredModelKeysRef.current) } } catch { @@ -2609,7 +2706,7 @@ export default function App() { cancelled = true if (timer != null) window.clearInterval(timer) } - }, [authed, recSettings.useChaturbateApi, loadAppModelsSnapshot]) + }, [authed, recSettings.useChaturbateApi, loadAppModelsSnapshot, loadAppModelsStatsAndWatched]) // ✅ StartURL (hier habe ich den alten Online-Fetch entfernt und nur Snapshot genutzt) const startUrl = useCallback(async ( diff --git a/frontend/src/components/ui/ModelsTab.tsx b/frontend/src/components/ui/ModelsTab.tsx index 1778342..914891e 100644 --- a/frontend/src/components/ui/ModelsTab.tsx +++ b/frontend/src/components/ui/ModelsTab.tsx @@ -83,6 +83,14 @@ export type StoredModel = { gender?: string | null country?: string | null + roomStatus?: string + isOnline?: boolean + chatRoomUrl?: string + imageUrl?: string + lastOnlineAt?: string + lastOfflineAt?: string + lastRoomSyncAt?: string + watching: boolean favorite: boolean hot: boolean @@ -497,7 +505,7 @@ export default function ModelsTab({ onAddToDownloads, onModelsCountChange }: Mod setLoading(true) setErr(null) try { - const res = await fetch('/api/models', { cache: 'no-store' as any }) + const res = await fetch('/api/models/list', { cache: 'no-store' as any }) if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`)) const data = await res.json().catch(() => null) diff --git a/frontend/src/components/ui/TrainingTab.tsx b/frontend/src/components/ui/TrainingTab.tsx index 80fb11d..1e347dd 100644 --- a/frontend/src/components/ui/TrainingTab.tsx +++ b/frontend/src/components/ui/TrainingTab.tsx @@ -275,6 +275,8 @@ type TrainingStats = { detectorModelInfo?: TrainingModelInfo poseModelAvailable?: boolean poseModelInfo?: TrainingModelInfo + videoMAEModelAvailable?: boolean + videoMAEModelInfo?: TrainingModelInfo confidence?: TrainingConfidence labels: { people: TrainingLabelStat[] @@ -2649,6 +2651,9 @@ function TrainingStatsModal(props: { error: string | null feedbackCount: number requiredCount: number + deletingTrainingData?: boolean + deleteTrainingDataDisabled?: boolean + onDeleteTrainingData?: () => void | Promise }) { const [activeTab, setActiveTab] = useState('people') const stats = props.stats @@ -2667,6 +2672,8 @@ function TrainingStatsModal(props: { const detectorModelInfo = stats?.detectorModelInfo ?? stats?.modelInfo const poseModelAvailable = Boolean(stats?.poseModelAvailable) const poseModelInfo = stats?.poseModelInfo + const videoMAEModelAvailable = Boolean(stats?.videoMAEModelAvailable) + const videoMAEModelInfo = stats?.videoMAEModelInfo const modelTrainedAtLabel = formatModelTrainedAt(detectorModelInfo) const modelMap50Label = formatMapPercent(detectorModelInfo?.map50) const modelMap5095Label = formatMapPercent(detectorModelInfo?.map5095) @@ -2683,16 +2690,6 @@ function TrainingStatsModal(props: { return parts.join(' · ') })() - const availableModelCount = - (detectorModelAvailable ? 1 : 0) + (poseModelAvailable ? 1 : 0) - const modelSummaryLabel = - availableModelCount === 2 - ? 'Detector & Pose verfuegbar' - : detectorModelAvailable - ? 'Detector verfuegbar' - : poseModelAvailable - ? 'Pose verfuegbar' - : 'Noch kein trainiertes Modell verfuegbar' const modelCards = [ { key: 'detector', @@ -2706,6 +2703,12 @@ function TrainingStatsModal(props: { available: poseModelAvailable, info: poseModelInfo, }, + { + key: 'videomae', + title: 'VideoMAE', + available: videoMAEModelAvailable, + info: videoMAEModelInfo, + }, ].map((model) => ({ ...model, trainedAtLabel: formatModelTrainedAt(model.info), @@ -2716,6 +2719,17 @@ function TrainingStatsModal(props: { ? modelInfoDetails : formatTrainingModelDetails(model.info), })) + const availableModelCount = modelCards.filter((model) => model.available).length + const totalModelCount = modelCards.length + const availableModelNames = modelCards + .filter((model) => model.available) + .map((model) => model.title) + const modelSummaryLabel = + availableModelCount === totalModelCount + ? 'Alle Modelle verfuegbar' + : availableModelCount > 0 + ? `${availableModelNames.join(' + ')} verfuegbar` + : 'Noch kein trainiertes Modell verfuegbar' const history = props.history ?? [] @@ -2893,7 +2907,7 @@ function TrainingStatsModal(props: { -
+
{modelCards.map((model) => (
- {modelSummaryLabel} - {availableModelCount}/2 + {modelSummaryLabel} - {availableModelCount}/{totalModelCount}
{modelCards.map((model) => ( @@ -3295,6 +3309,39 @@ function TrainingStatsModal(props: { total={activeTabItem.total} confidence={activeTabItem.confidence} /> + +
+
+
+
+ Trainingsdaten löschen +
+ +
+ Entfernt Feedback, Frames, Samples und Trainingsdaten. Diese Aktion kann nicht rückgängig gemacht werden. +
+
+ + +
+
)}
@@ -5262,6 +5309,12 @@ export default function TrainingTab(props: { ), poseModelAvailable: Boolean(data?.poseModelAvailable), poseModelInfo: parseTrainingModelInfo(data?.poseModelInfo), + videoMAEModelAvailable: Boolean( + data?.videoMAEModelAvailable ?? data?.videomaeModelAvailable + ), + videoMAEModelInfo: parseTrainingModelInfo( + data?.videoMAEModelInfo ?? data?.videomaeModelInfo + ), confidence: data?.confidence, labels: { people: Array.isArray(data?.labels?.people) ? data.labels.people : [], @@ -6355,12 +6408,15 @@ export default function TrainingTab(props: { await loadTrainingStatus() await loadNext({ forceNew: true, preserveNotice: true }) + if (statsModalOpen) { + await loadTrainingStats() + } } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setDeletingTrainingData(false) } - }, [loadNext, loadTrainingStatus, requiredCount]) + }, [loadNext, loadTrainingStats, loadTrainingStatus, requiredCount, statsModalOpen]) const getPointerPosFromRect = useCallback(( rect: ImageContentRect, @@ -7562,7 +7618,10 @@ export default function TrainingTab(props: { onClick={() => setFeedbackModalOpen(true)} title="Bisher abgegebenes Feedback ansehen." > - Abgegebenes Feedback ansehen + +
@@ -7608,22 +7667,6 @@ export default function TrainingTab(props: { ) : null} -
- -
) } @@ -9569,7 +9612,10 @@ export default function TrainingTab(props: { disabled={!canConfirmTrainingStart || trainingRunning} onClick={startTrainingFromModal} > - Training starten + + } @@ -9741,6 +9787,9 @@ export default function TrainingTab(props: { error={trainingStatsError} feedbackCount={feedbackCount} requiredCount={requiredCount} + deletingTrainingData={deletingTrainingData} + deleteTrainingDataDisabled={uiLocked} + onDeleteTrainingData={deleteAllTrainingData} />