This commit is contained in:
Linrador 2026-06-26 21:25:45 +02:00
parent ab9f1f79b5
commit 5e6c841b26
9 changed files with 582 additions and 119 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -140,6 +140,8 @@ type importResult struct {
type modelsOverviewRequest struct { type modelsOverviewRequest struct {
Keys []string `json:"keys"` Keys []string `json:"keys"`
IncludeWatched bool `json:"includeWatched"` IncludeWatched bool `json:"includeWatched"`
IncludeStats *bool `json:"includeStats,omitempty"`
IncludeModels *bool `json:"includeModels,omitempty"`
} }
type modelsOverviewModel struct { type modelsOverviewModel struct {
@ -187,6 +189,13 @@ func normalizeModelKeySet(in []string) map[string]bool {
return out return out
} }
func overviewBool(v *bool, fallback bool) bool {
if v == nil {
return fallback
}
return *v
}
func isOnlineRoomStatus(status string, isOnline bool) bool { func isOnlineRoomStatus(status string, isOnline bool) bool {
s := strings.ToLower(strings.TrimSpace(status)) s := strings.ToLower(strings.TrimSpace(status))
if s == "offline" { if s == "offline" {
@ -213,9 +222,15 @@ func buildModelsOverview(rawList any, req modelsOverviewRequest) (modelsOverview
keySet := normalizeModelKeySet(req.Keys) keySet := normalizeModelKeySet(req.Keys)
includeStats := overviewBool(req.IncludeStats, true)
includeModels := overviewBool(req.IncludeModels, true)
resp := modelsOverviewResponse{ 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 { for _, m := range list {
@ -224,27 +239,30 @@ func buildModelsOverview(rawList any, req modelsOverviewRequest) (modelsOverview
continue continue
} }
online := isOnlineRoomStatus(m.RoomStatus, m.IsOnline) if includeStats {
online := isOnlineRoomStatus(m.RoomStatus, m.IsOnline)
if strings.Contains(strings.ToLower(m.Host), "chaturbate") { if strings.Contains(strings.ToLower(m.Host), "chaturbate") {
if online { if online {
resp.OnlineModelsCount++ resp.OnlineModelsCount++
}
if m.Watching && online {
resp.OnlineWatchedModelsCount++
}
} }
if m.Watching && online {
resp.OnlineWatchedModelsCount++
}
}
if m.Favorite && online { if m.Favorite && online {
resp.OnlineFavCount++ resp.OnlineFavCount++
} }
if m.Liked != nil && *m.Liked && online { if m.Liked != nil && *m.Liked && online {
resp.OnlineLikedCount++ resp.OnlineLikedCount++
}
} }
needModel := needModel :=
keySet[key] || includeModels &&
(req.IncludeWatched && m.Watching) (keySet[key] ||
(req.IncludeWatched && m.Watching))
if needModel { if needModel {
resp.Models = append(resp.Models, m) resp.Models = append(resp.Models, m)
@ -259,10 +277,17 @@ func loadModelsOverview(store *ModelStore, req modelsOverviewRequest) (modelsOve
return modelsOverviewResponse{}, err 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 var resp modelsOverviewResponse
err := store.db.QueryRow(` 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 SELECT
COUNT(*), COUNT(*),
COALESCE(SUM(CASE WHEN lower(COALESCE(host,'')) LIKE '%chaturbate%' AND `+onlineExpr+` THEN 1 ELSE 0 END), 0), 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) COALESCE(SUM(CASE WHEN COALESCE(liked,false) = true AND `+onlineExpr+` THEN 1 ELSE 0 END), 0)
FROM models; FROM models;
`).Scan(&totalCount, &onlineCount, &onlineWatchedCount, &onlineFavCount, &onlineLikedCount) `).Scan(&totalCount, &onlineCount, &onlineWatchedCount, &onlineFavCount, &onlineLikedCount)
if err != nil { if err != nil {
return modelsOverviewResponse{}, err 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{ if !includeModels {
TotalCount: int(totalCount), return resp, nil
OnlineModelsCount: int(onlineCount),
OnlineWatchedModelsCount: int(onlineWatchedCount),
OnlineFavCount: int(onlineFavCount),
OnlineLikedCount: int(onlineLikedCount),
Models: make([]modelsOverviewModel, 0, len(req.Keys)+32),
} }
keySet := normalizeModelKeySet(req.Keys) keySet := normalizeModelKeySet(req.Keys)
@ -578,6 +605,23 @@ func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) {
modelsWriteJSON(w, http.StatusOK, resp) 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) { mux.HandleFunc("/api/models", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})

View File

@ -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()) 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 { func (s *ModelStore) emptyTextExpr() string {
return "''::text" return "''::text"
} }
@ -1577,6 +1581,144 @@ ORDER BY updated_at DESC;
return out 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 { func (s *ModelStore) Meta() ModelsMeta {
if err := s.ensureInit(); err != nil { if err := s.ensureInit(); err != nil {
return ModelsMeta{Count: 0, UpdatedAt: ""} return ModelsMeta{Count: 0, UpdatedAt: ""}
@ -2207,7 +2349,7 @@ SELECT id FROM updated;
return StoredModel{}, err return StoredModel{}, err
} }
return s.getByID(updatedID) return s.getModelTabByID(updatedID)
} }
func (s *ModelStore) Delete(id string) error { func (s *ModelStore) Delete(id string) error {
@ -2299,6 +2441,127 @@ ON CONFLICT(id) DO UPDATE SET
return m, inserted, err 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) { func (s *ModelStore) getByID(id string) (StoredModel, error) {
var ( var (
input, host, path, modelKey, tags string input, host, path, modelKey, tags string

View File

@ -966,7 +966,12 @@ func trainingAnalysisValueTruthy(value any) bool {
func trainingPublishAnalysisPayload(payload map[string]any) { func trainingPublishAnalysisPayload(payload map[string]any) {
trainingRememberAnalysisPayload(payload) trainingRememberAnalysisPayload(payload)
trainingPublishAnalysisPayload(payload) b, err := json.Marshal(payload)
if err != nil {
return
}
publishSSE("analysisProgress", b)
} }
func trainingPublishAnalysisStep( func trainingPublishAnalysisStep(
@ -1021,12 +1026,7 @@ func trainingPublishAnalysisStepWithPreview(
payload["previewUrl"] = strings.TrimSpace(previewURL) payload["previewUrl"] = strings.TrimSpace(previewURL)
} }
b, err := json.Marshal(payload) trainingPublishAnalysisPayload(payload)
if err != nil {
return
}
publishSSE("analysisProgress", b)
} }
func trainingPublishAnalysisStarted( func trainingPublishAnalysisStarted(
@ -2882,23 +2882,25 @@ func trainingLatestOpenSample(
sourceFile = filepath.Base(sample.SourcePath) sourceFile = filepath.Base(sample.SourcePath)
} }
trainingPublishAnalysisStep( trainingPublishAnalysisStepWithPreview(
requestID, requestID,
startedAtMs, startedAtMs,
1, 1,
2, 2,
sourceFile, sourceFile,
sample.FrameURL,
"Aktuelles Bild wird analysiert…", "Aktuelles Bild wird analysiert…",
) )
sample.Prediction = trainingPredictFrame(framePath) sample.Prediction = trainingPredictFrame(framePath)
trainingPublishAnalysisStep( trainingPublishAnalysisStepWithPreview(
requestID, requestID,
startedAtMs, startedAtMs,
2, 2,
2, 2,
sourceFile, sourceFile,
sample.FrameURL,
"Analyse-Ergebnis wird gespeichert…", "Analyse-Ergebnis wird gespeichert…",
) )

View File

@ -1826,12 +1826,12 @@ export default function App() {
const doneSortRef = useRef(doneSort) const doneSortRef = useRef(doneSort)
type AppModelsSnapshot = { type AppModelsSnapshot = {
totalCount: number totalCount?: number
onlineModelsCount: number onlineModelsCount?: number
onlineWatchedModelsCount: number onlineWatchedModelsCount?: number
onlineFavCount: number onlineFavCount?: number
onlineLikedCount: number onlineLikedCount?: number
models: StoredModel[] models?: StoredModel[]
} }
const [headerStats, setHeaderStats] = useState({ const [headerStats, setHeaderStats] = useState({
@ -1873,6 +1873,63 @@ export default function App() {
const overviewTimerRef = useRef<number | null>(null) const overviewTimerRef = useRef<number | null>(null)
const overviewAbortRef = useRef<AbortController | null>(null) const overviewAbortRef = useRef<AbortController | null>(null)
const overviewPendingKeysRef = useRef<string[] | null>(null) const overviewPendingKeysRef = useRef<string[] | null>(null)
const overviewStatsInFlightRef = useRef(false)
const overviewStatsLastAtRef = useRef(0)
const overviewStatsAbortRef = useRef<AbortController | null>(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 loadAppModelsSnapshot = useCallback(async (keys: string[]) => {
const uniqKeys = Array.from( const uniqKeys = Array.from(
@ -1894,7 +1951,6 @@ export default function App() {
if (overviewLastSigRef.current === sig && now - overviewLastAtRef.current < MODELS_OVERVIEW_COOLDOWN_MS) return if (overviewLastSigRef.current === sig && now - overviewLastAtRef.current < MODELS_OVERVIEW_COOLDOWN_MS) return
overviewInFlightRef.current = true overviewInFlightRef.current = true
overviewAbortRef.current?.abort()
const controller = new AbortController() const controller = new AbortController()
overviewAbortRef.current = controller overviewAbortRef.current = controller
@ -1907,50 +1963,16 @@ export default function App() {
signal: controller.signal, signal: controller.signal,
body: JSON.stringify({ body: JSON.stringify({
keys: uniqKeys, 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 overviewLastSigRef.current = sig
overviewLastAtRef.current = now overviewLastAtRef.current = now
const safeList = Array.isArray(data?.models) ? data.models : [] mergeModelsSnapshot(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
})
} catch (error: any) { } catch (error: any) {
if (error?.name === 'AbortError') return if (error?.name === 'AbortError') return
// ignore // ignore
@ -1974,7 +1996,46 @@ export default function App() {
}, MODELS_OVERVIEW_DEBOUNCE_MS) }, 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<AppModelsSnapshot>('/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(( const maybeNotifyWatchedModelStatusChange = useCallback((
keyLower: string, keyLower: string,
@ -2349,6 +2410,7 @@ export default function App() {
if (lastOverviewSigRef.current !== requiredModelKeysSig) { if (lastOverviewSigRef.current !== requiredModelKeysSig) {
lastOverviewSigRef.current = requiredModelKeysSig lastOverviewSigRef.current = requiredModelKeysSig
scheduleOverviewLoad() scheduleOverviewLoad()
void loadAppModelsStatsAndWatched(false)
} }
const onChanged = (ev: Event) => { const onChanged = (ev: Event) => {
@ -2379,6 +2441,7 @@ export default function App() {
) )
scheduleOverviewLoad() scheduleOverviewLoad()
void loadAppModelsStatsAndWatched(true)
return return
} }
@ -2398,10 +2461,12 @@ export default function App() {
} }
scheduleOverviewLoad() scheduleOverviewLoad()
void loadAppModelsStatsAndWatched(true)
return return
} }
scheduleOverviewLoad() scheduleOverviewLoad()
void loadAppModelsStatsAndWatched(false)
} }
window.addEventListener('models-changed', onChanged as any) window.addEventListener('models-changed', onChanged as any)
@ -2413,13 +2478,44 @@ export default function App() {
window.clearTimeout(overviewTimerRef.current) window.clearTimeout(overviewTimerRef.current)
overviewTimerRef.current = null 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 overviewPendingKeysRef.current = null
overviewAbortRef.current?.abort() overviewAbortRef.current?.abort()
overviewAbortRef.current = null overviewAbortRef.current = null
overviewInFlightRef.current = false overviewInFlightRef.current = false
overviewStatsAbortRef.current?.abort()
overviewStatsAbortRef.current = null
overviewStatsInFlightRef.current = false
overviewStatsLastAtRef.current = 0
} }
}, [authed, loadAppModelsSnapshot, requiredModelKeysSig, requiredModelKeys, upsertModelCache]) }, [])
useEffect(() => { useEffect(() => {
donePageRef.current = donePage donePageRef.current = donePage
@ -2592,6 +2688,7 @@ export default function App() {
// der watched/fav/like Counter + modelsByKey // der watched/fav/like Counter + modelsByKey
if (safeFetchedAt && safeFetchedAt !== lastSeenFetchedAt) { if (safeFetchedAt && safeFetchedAt !== lastSeenFetchedAt) {
lastSeenFetchedAt = safeFetchedAt lastSeenFetchedAt = safeFetchedAt
void loadAppModelsStatsAndWatched(true)
void loadAppModelsSnapshot(requiredModelKeysRef.current) void loadAppModelsSnapshot(requiredModelKeysRef.current)
} }
} catch { } catch {
@ -2609,7 +2706,7 @@ export default function App() {
cancelled = true cancelled = true
if (timer != null) window.clearInterval(timer) 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) // ✅ StartURL (hier habe ich den alten Online-Fetch entfernt und nur Snapshot genutzt)
const startUrl = useCallback(async ( const startUrl = useCallback(async (

View File

@ -83,6 +83,14 @@ export type StoredModel = {
gender?: string | null gender?: string | null
country?: string | null country?: string | null
roomStatus?: string
isOnline?: boolean
chatRoomUrl?: string
imageUrl?: string
lastOnlineAt?: string
lastOfflineAt?: string
lastRoomSyncAt?: string
watching: boolean watching: boolean
favorite: boolean favorite: boolean
hot: boolean hot: boolean
@ -497,7 +505,7 @@ export default function ModelsTab({ onAddToDownloads, onModelsCountChange }: Mod
setLoading(true) setLoading(true)
setErr(null) setErr(null)
try { 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}`)) if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`))
const data = await res.json().catch(() => null) const data = await res.json().catch(() => null)

View File

@ -275,6 +275,8 @@ type TrainingStats = {
detectorModelInfo?: TrainingModelInfo detectorModelInfo?: TrainingModelInfo
poseModelAvailable?: boolean poseModelAvailable?: boolean
poseModelInfo?: TrainingModelInfo poseModelInfo?: TrainingModelInfo
videoMAEModelAvailable?: boolean
videoMAEModelInfo?: TrainingModelInfo
confidence?: TrainingConfidence confidence?: TrainingConfidence
labels: { labels: {
people: TrainingLabelStat[] people: TrainingLabelStat[]
@ -2649,6 +2651,9 @@ function TrainingStatsModal(props: {
error: string | null error: string | null
feedbackCount: number feedbackCount: number
requiredCount: number requiredCount: number
deletingTrainingData?: boolean
deleteTrainingDataDisabled?: boolean
onDeleteTrainingData?: () => void | Promise<void>
}) { }) {
const [activeTab, setActiveTab] = useState<TrainingStatsTabKey>('people') const [activeTab, setActiveTab] = useState<TrainingStatsTabKey>('people')
const stats = props.stats const stats = props.stats
@ -2667,6 +2672,8 @@ function TrainingStatsModal(props: {
const detectorModelInfo = stats?.detectorModelInfo ?? stats?.modelInfo const detectorModelInfo = stats?.detectorModelInfo ?? stats?.modelInfo
const poseModelAvailable = Boolean(stats?.poseModelAvailable) const poseModelAvailable = Boolean(stats?.poseModelAvailable)
const poseModelInfo = stats?.poseModelInfo const poseModelInfo = stats?.poseModelInfo
const videoMAEModelAvailable = Boolean(stats?.videoMAEModelAvailable)
const videoMAEModelInfo = stats?.videoMAEModelInfo
const modelTrainedAtLabel = formatModelTrainedAt(detectorModelInfo) const modelTrainedAtLabel = formatModelTrainedAt(detectorModelInfo)
const modelMap50Label = formatMapPercent(detectorModelInfo?.map50) const modelMap50Label = formatMapPercent(detectorModelInfo?.map50)
const modelMap5095Label = formatMapPercent(detectorModelInfo?.map5095) const modelMap5095Label = formatMapPercent(detectorModelInfo?.map5095)
@ -2683,16 +2690,6 @@ function TrainingStatsModal(props: {
return parts.join(' · ') 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 = [ const modelCards = [
{ {
key: 'detector', key: 'detector',
@ -2706,6 +2703,12 @@ function TrainingStatsModal(props: {
available: poseModelAvailable, available: poseModelAvailable,
info: poseModelInfo, info: poseModelInfo,
}, },
{
key: 'videomae',
title: 'VideoMAE',
available: videoMAEModelAvailable,
info: videoMAEModelInfo,
},
].map((model) => ({ ].map((model) => ({
...model, ...model,
trainedAtLabel: formatModelTrainedAt(model.info), trainedAtLabel: formatModelTrainedAt(model.info),
@ -2716,6 +2719,17 @@ function TrainingStatsModal(props: {
? modelInfoDetails ? modelInfoDetails
: formatTrainingModelDetails(model.info), : 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 ?? [] const history = props.history ?? []
@ -2893,7 +2907,7 @@ function TrainingStatsModal(props: {
</div> </div>
</div> </div>
<div className="mt-3 grid grid-cols-2 gap-1.5"> <div className="mt-3 grid grid-cols-3 gap-1.5">
{modelCards.map((model) => ( {modelCards.map((model) => (
<div <div
key={model.key} key={model.key}
@ -3037,7 +3051,7 @@ function TrainingStatsModal(props: {
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
<div className="text-[11px] font-semibold uppercase tracking-wide text-indigo-700/80 dark:text-indigo-200/80"> <div className="text-[11px] font-semibold uppercase tracking-wide text-indigo-700/80 dark:text-indigo-200/80">
{modelSummaryLabel} - {availableModelCount}/2 {modelSummaryLabel} - {availableModelCount}/{totalModelCount}
</div> </div>
{modelCards.map((model) => ( {modelCards.map((model) => (
@ -3295,6 +3309,39 @@ function TrainingStatsModal(props: {
total={activeTabItem.total} total={activeTabItem.total}
confidence={activeTabItem.confidence} confidence={activeTabItem.confidence}
/> />
<div className="rounded-xl border border-red-100 bg-red-50 p-3 dark:border-red-400/20 dark:bg-red-500/10 sm:p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="text-sm font-semibold text-red-950 dark:text-red-100">
Trainingsdaten löschen
</div>
<div className="mt-0.5 text-xs leading-relaxed text-red-800/80 dark:text-red-100/70">
Entfernt Feedback, Frames, Samples und Trainingsdaten. Diese Aktion kann nicht rückgängig gemacht werden.
</div>
</div>
<Button
size="md"
variant="primary"
color="red"
className="shrink-0 justify-center px-3 text-xs sm:w-auto"
disabled={
Boolean(props.deletingTrainingData) ||
Boolean(props.deleteTrainingDataDisabled) ||
!props.onDeleteTrainingData
}
onClick={() => void props.onDeleteTrainingData?.()}
title="Löscht Feedback, Frames, Samples und Trainingsdaten."
>
<span className="inline-flex items-center gap-1.5">
<TrashIcon className="h-3.5 w-3.5" aria-hidden="true" />
{props.deletingTrainingData ? 'Lösche…' : 'Trainingsdaten löschen'}
</span>
</Button>
</div>
</div>
</div> </div>
)} )}
</div> </div>
@ -5262,6 +5309,12 @@ export default function TrainingTab(props: {
), ),
poseModelAvailable: Boolean(data?.poseModelAvailable), poseModelAvailable: Boolean(data?.poseModelAvailable),
poseModelInfo: parseTrainingModelInfo(data?.poseModelInfo), poseModelInfo: parseTrainingModelInfo(data?.poseModelInfo),
videoMAEModelAvailable: Boolean(
data?.videoMAEModelAvailable ?? data?.videomaeModelAvailable
),
videoMAEModelInfo: parseTrainingModelInfo(
data?.videoMAEModelInfo ?? data?.videomaeModelInfo
),
confidence: data?.confidence, confidence: data?.confidence,
labels: { labels: {
people: Array.isArray(data?.labels?.people) ? data.labels.people : [], people: Array.isArray(data?.labels?.people) ? data.labels.people : [],
@ -6355,12 +6408,15 @@ export default function TrainingTab(props: {
await loadTrainingStatus() await loadTrainingStatus()
await loadNext({ forceNew: true, preserveNotice: true }) await loadNext({ forceNew: true, preserveNotice: true })
if (statsModalOpen) {
await loadTrainingStats()
}
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : String(e)) setError(e instanceof Error ? e.message : String(e))
} finally { } finally {
setDeletingTrainingData(false) setDeletingTrainingData(false)
} }
}, [loadNext, loadTrainingStatus, requiredCount]) }, [loadNext, loadTrainingStats, loadTrainingStatus, requiredCount, statsModalOpen])
const getPointerPosFromRect = useCallback(( const getPointerPosFromRect = useCallback((
rect: ImageContentRect, rect: ImageContentRect,
@ -7562,7 +7618,10 @@ export default function TrainingTab(props: {
onClick={() => setFeedbackModalOpen(true)} onClick={() => setFeedbackModalOpen(true)}
title="Bisher abgegebenes Feedback ansehen." title="Bisher abgegebenes Feedback ansehen."
> >
Abgegebenes Feedback ansehen <span className="inline-flex items-center gap-1.5">
<InboxArrowDownIcon className="h-3.5 w-3.5" aria-hidden="true" />
Abgegebenes Feedback ansehen
</span>
</Button> </Button>
</div> </div>
@ -7608,22 +7667,6 @@ export default function TrainingTab(props: {
</div> </div>
) : null} ) : null}
<div className="mt-3 border-t border-gray-100 pt-3 dark:border-white/10">
<Button
size="md"
variant="primary"
color="red"
className="w-full justify-center px-2 text-[11px]"
disabled={uiLocked}
onClick={() => void deleteAllTrainingData()}
title="Löscht Feedback, Frames, Samples und Detector-Daten."
>
<span className="inline-flex items-center gap-1.5">
<TrashIcon className="h-3.5 w-3.5" aria-hidden="true" />
{deletingTrainingData ? 'Lösche…' : 'Trainingsdaten löschen'}
</span>
</Button>
</div>
</div> </div>
) )
} }
@ -9569,7 +9612,10 @@ export default function TrainingTab(props: {
disabled={!canConfirmTrainingStart || trainingRunning} disabled={!canConfirmTrainingStart || trainingRunning}
onClick={startTrainingFromModal} onClick={startTrainingFromModal}
> >
Training starten <span className="inline-flex items-center gap-1.5">
<BoltIcon className="h-3.5 w-3.5" aria-hidden="true" />
Training starten
</span>
</Button> </Button>
</> </>
} }
@ -9741,6 +9787,9 @@ export default function TrainingTab(props: {
error={trainingStatsError} error={trainingStatsError}
feedbackCount={feedbackCount} feedbackCount={feedbackCount}
requiredCount={requiredCount} requiredCount={requiredCount}
deletingTrainingData={deletingTrainingData}
deleteTrainingDataDisabled={uiLocked}
onDeleteTrainingData={deleteAllTrainingData}
/> />
<TrainingFeedbackHistoryModal <TrainingFeedbackHistoryModal