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 {
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"})

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())
}
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

View File

@ -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…",
)

View File

@ -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<number | null>(null)
const overviewAbortRef = useRef<AbortController | 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 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<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((
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 (

View File

@ -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)

View File

@ -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<void>
}) {
const [activeTab, setActiveTab] = useState<TrainingStatsTabKey>('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: {
</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) => (
<div
key={model.key}
@ -3037,7 +3051,7 @@ function TrainingStatsModal(props: {
<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">
{modelSummaryLabel} - {availableModelCount}/2
{modelSummaryLabel} - {availableModelCount}/{totalModelCount}
</div>
{modelCards.map((model) => (
@ -3295,6 +3309,39 @@ function TrainingStatsModal(props: {
total={activeTabItem.total}
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>
@ -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
<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>
</div>
@ -7608,22 +7667,6 @@ export default function TrainingTab(props: {
</div>
) : 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>
)
}
@ -9569,7 +9612,10 @@ export default function TrainingTab(props: {
disabled={!canConfirmTrainingStart || trainingRunning}
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>
</>
}
@ -9741,6 +9787,9 @@ export default function TrainingTab(props: {
error={trainingStatsError}
feedbackCount={feedbackCount}
requiredCount={requiredCount}
deletingTrainingData={deletingTrainingData}
deleteTrainingDataDisabled={uiLocked}
onDeleteTrainingData={deleteAllTrainingData}
/>
<TrainingFeedbackHistoryModal