diff --git a/backend/assets_generate.go b/backend/assets_generate.go index 4efe039..428ca6d 100644 --- a/backend/assets_generate.go +++ b/backend/assets_generate.go @@ -833,88 +833,80 @@ func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string) return nil } -func enqueueDeferredAssetsAndAI(job *RecordJob, videoPath, sourceURL string) bool { - videoPath = strings.TrimSpace(videoPath) - if videoPath == "" { +func enqueueDeferredAssetsAndAI(job *RecordJob, outPath string, sourceURL string) bool { + outPath = strings.TrimSpace(outPath) + if outPath == "" { return false } - id := assetIDFromVideoPath(videoPath) - if id == "" { + fileName := filepath.Base(outPath) + assetID := assetIDFromVideoPath(outPath) + if assetID == "" { return false } - key := "enrich:" + id + truth := finishedPhaseTruthForID(assetID) + + needMeta := !truth.MetaReady + needThumb := !truth.ThumbReady + needTeaser := !truth.TeaserReady + needSprites := !truth.SpritesReady + needAnalyze := !truth.AnalyzeReady + + // Nichts zu tun -> erfolgreich + if !needMeta && !needThumb && !needTeaser && !needSprites && !needAnalyze { + return true + } + + // Nur fehlende Phasen als queued publishen + if needMeta { + publishDeferredPhase(fileName, assetID, "postwork", "meta", "queued", "Meta wartet", nil) + } + if needThumb { + publishDeferredPhase(fileName, assetID, "postwork", "thumb", "queued", "Vorschaubild wartet", nil) + } + if needTeaser { + publishDeferredPhase(fileName, assetID, "postwork", "teaser", "queued", "Teaser wartet", nil) + } + if needSprites { + publishDeferredPhase(fileName, assetID, "postwork", "sprites", "queued", "Sprites warten", nil) + } + if needAnalyze { + publishDeferredPhase(fileName, assetID, "enrich", "analyze", "queued", "KI-Analyse wartet", nil) + } ok := enrichQ.Enqueue(PostWorkTask{ - Key: key, + Key: "enrich:" + assetID, Added: time.Now(), Run: func(ctx context.Context) error { - if job != nil { - st := enrichQ.StatusForKey(key) - publishFinishedPostworkStateForJob( - job, - "enrich", - st.State, - "assets", - "Wird verarbeitet…", - st.Position, - st.Waiting, - st.Running, - st.MaxParallel, - ) - } - - err := ensureDeferredAssetsAndAI(ctx, videoPath, sourceURL) - - if job != nil { - if err != nil { - publishFinishedPostworkStateForJob( - job, - "enrich", - "error", - "assets", - "Ergänzung fehlgeschlagen", - 0, - 0, - 0, - 0, - ) - } else { - publishFinishedPostworkStateForJob( - job, - "enrich", - "done", - "", - "Fertig", - 0, - 0, - 0, - 0, - ) - } - } - - return err + return runDeferredEnrichPipeline(ctx, outPath, sourceURL) }, }) - if ok && job != nil { - st := enrichQ.StatusForKey(key) - publishFinishedPostworkStateForJob( - job, - "enrich", - st.State, - "assets", - "Wartet", - st.Position, - st.Waiting, - st.Running, - st.MaxParallel, - ) + if ok { + return true } - return ok + enqueueErr := fmt.Errorf("enqueue failed") + + // queued-Zustände auf error drehen, aber nur für wirklich fehlende Phasen + if needMeta { + publishDeferredPhase(fileName, assetID, "postwork", "meta", "error", "Meta konnte nicht eingeplant werden", enqueueErr) + } + if needThumb { + publishDeferredPhase(fileName, assetID, "postwork", "thumb", "error", "Vorschaubild konnte nicht eingeplant werden", enqueueErr) + } + if needTeaser { + publishDeferredPhase(fileName, assetID, "postwork", "teaser", "error", "Teaser konnte nicht eingeplant werden", enqueueErr) + } + if needSprites { + publishDeferredPhase(fileName, assetID, "postwork", "sprites", "error", "Sprites konnten nicht eingeplant werden", enqueueErr) + } + if needAnalyze { + publishDeferredPhase(fileName, assetID, "enrich", "analyze", "error", "KI-Analyse konnte nicht eingeplant werden", enqueueErr) + } + + return false } // Core: generiert thumbs/preview/sprite/meta und sagt zurück was passiert ist. diff --git a/backend/data/pending-autostart/__global__.json b/backend/data/pending-autostart/__global__.json index b157675..3be17b8 100644 --- a/backend/data/pending-autostart/__global__.json +++ b/backend/data/pending-autostart/__global__.json @@ -2,66 +2,74 @@ "items": [ { "modelKey": "jam_cream", - "url": "https://www.myfreecams.com/#jam_cream", + "url": "https://www.myfreecams.com/#Jam_cream", "mode": "probe_retry", - "nextProbeAtMs": 1775738855792, + "nextProbeAtMs": 1775899166952, "currentShow": "offline", "source": "watched" }, { - "modelKey": "tashinadoncaster", - "url": "https://chaturbate.com/tashinadoncaster/", + "modelKey": "milk_bunny_", + "url": "https://chaturbate.com/milk_bunny_/", "mode": "wait_public", - "currentShow": "private", - "imageUrl": "https://thumb.live.mmcdn.com/ri/tashinadoncaster.jpg", + "currentShow": "hidden", + "imageUrl": "https://thumb.live.mmcdn.com/ri/milk_bunny_.jpg", "source": "watched" }, { - "modelKey": "_jesslei_", - "url": "https://chaturbate.com/_jesslei_/", + "modelKey": "kali_hiill", + "url": "https://chaturbate.com/kali_hiill/", "mode": "wait_public", "currentShow": "private", - "imageUrl": "https://thumb.live.mmcdn.com/ri/_jesslei_.jpg", + "imageUrl": "https://thumb.live.mmcdn.com/ri/kali_hiill.jpg", "source": "watched" }, { - "modelKey": "mary_meys", - "url": "https://chaturbate.com/mary_meys/", - "mode": "wait_public", - "currentShow": "private", - "imageUrl": "https://thumb.live.mmcdn.com/ri/mary_meys.jpg", - "source": "watched" - }, - { - "modelKey": "jodeesolarz", - "url": "https://chaturbate.com/jodeesolarz/", + "modelKey": "passiondesire_", + "url": "https://chaturbate.com/passiondesire_/", "mode": "wait_public", "currentShow": "away", - "imageUrl": "https://thumb.live.mmcdn.com/ri/jodeesolarz.jpg", + "imageUrl": "https://thumb.live.mmcdn.com/ri/passiondesire_.jpg", "source": "watched" }, { - "modelKey": "di_n_alex", - "url": "https://chaturbate.com/di_n_alex/", + "modelKey": "tsukuyami_mi", + "url": "https://chaturbate.com/tsukuyami_mi/", + "mode": "wait_public", + "currentShow": "hidden", + "imageUrl": "https://thumb.live.mmcdn.com/ri/tsukuyami_mi.jpg", + "source": "watched" + }, + { + "modelKey": "swtty_meivi", + "url": "https://chaturbate.com/swtty_meivi/", "mode": "wait_public", "currentShow": "private", - "imageUrl": "https://thumb.live.mmcdn.com/ri/di_n_alex.jpg", + "imageUrl": "https://thumb.live.mmcdn.com/ri/swtty_meivi.jpg", "source": "watched" }, { - "modelKey": "lora_lifelover", - "url": "https://chaturbate.com/lora_lifelover/", + "modelKey": "amandatalk", + "url": "https://chaturbate.com/amandatalk/", "mode": "wait_public", - "currentShow": "hidden", - "imageUrl": "https://thumb.live.mmcdn.com/ri/lora_lifelover.jpg", + "currentShow": "private", + "imageUrl": "https://thumb.live.mmcdn.com/ri/amandatalk.jpg", "source": "watched" }, { - "modelKey": "chaesoffel", - "url": "https://chaturbate.com/chaesoffel/", + "modelKey": "oliver_and_megan", + "url": "https://chaturbate.com/oliver_and_megan/", "mode": "wait_public", - "currentShow": "hidden", - "imageUrl": "https://thumb.live.mmcdn.com/ri/chaesoffel.jpg", + "currentShow": "private", + "imageUrl": "https://thumb.live.mmcdn.com/ri/oliver_and_megan.jpg", + "source": "watched" + }, + { + "modelKey": "aurora_asti_", + "url": "https://chaturbate.com/aurora_asti_/", + "mode": "wait_public", + "currentShow": "private", + "imageUrl": "https://thumb.live.mmcdn.com/ri/aurora_asti_.jpg", "source": "watched" }, { @@ -73,26 +81,34 @@ "source": "watched" }, { - "modelKey": "shirleendurrah", - "url": "https://chaturbate.com/shirleendurrah/", + "modelKey": "gimbobar", + "url": "https://chaturbate.com/gimbobar/", "mode": "wait_public", "currentShow": "private", - "imageUrl": "https://thumb.live.mmcdn.com/ri/shirleendurrah.jpg", + "imageUrl": "https://thumb.live.mmcdn.com/ri/gimbobar.jpg", "source": "watched" }, { - "modelKey": "palmiratownzen", - "url": "https://chaturbate.com/palmiratownzen/", + "modelKey": "lizbethbiers", + "url": "https://chaturbate.com/lizbethbiers/", "mode": "wait_public", - "currentShow": "away", - "imageUrl": "https://thumb.live.mmcdn.com/ri/palmiratownzen.jpg", + "currentShow": "private", + "imageUrl": "https://thumb.live.mmcdn.com/ri/lizbethbiers.jpg", "source": "watched" }, { - "modelKey": "rafaelaloghry", - "url": "https://chaturbate.com/rafaelaloghry/", + "modelKey": "bonnettbonett", + "url": "https://chaturbate.com/bonnettbonett/", + "mode": "wait_public", + "currentShow": "private", + "imageUrl": "https://thumb.live.mmcdn.com/ri/bonnettbonett.jpg", + "source": "watched" + }, + { + "modelKey": "pazrapacki", + "url": "https://chaturbate.com/pazrapacki/", "mode": "probe_retry", - "nextProbeAtMs": 1775738868799, + "nextProbeAtMs": 1775899181131, "currentShow": "unknown", "source": "watched" } diff --git a/backend/models_api.go b/backend/models_api.go index 8824341..7565651 100644 --- a/backend/models_api.go +++ b/backend/models_api.go @@ -141,10 +141,15 @@ type modelsOverviewRequest struct { } type modelsOverviewModel struct { - ID string `json:"id"` - Input string `json:"input"` - Host string `json:"host,omitempty"` - ModelKey string `json:"modelKey"` + ID string `json:"id"` + Input string `json:"input"` + Host string `json:"host,omitempty"` + ModelKey string `json:"modelKey"` + + Gender string `json:"gender,omitempty"` + Sex string `json:"sex,omitempty"` + Subgender string `json:"subgender,omitempty"` + Watching bool `json:"watching"` Favorite bool `json:"favorite,omitempty"` Liked *bool `json:"liked,omitempty"` diff --git a/backend/models_store.go b/backend/models_store.go index 1d5cf93..95febf0 100644 --- a/backend/models_store.go +++ b/backend/models_store.go @@ -17,12 +17,17 @@ import ( ) type StoredModel struct { - ID string `json:"id"` // unique (wir verwenden host:modelKey) - Input string `json:"input"` // Original-URL/Eingabe - IsURL bool `json:"isUrl"` // vom Parser - Host string `json:"host,omitempty"` - Path string `json:"path,omitempty"` - ModelKey string `json:"modelKey"` // Display/Key + ID string `json:"id"` // unique (wir verwenden host:modelKey) + Input string `json:"input"` // Original-URL/Eingabe + IsURL bool `json:"isUrl"` // vom Parser + Host string `json:"host,omitempty"` + Path string `json:"path,omitempty"` + ModelKey string `json:"modelKey"` // Display/Key + + Gender string `json:"gender,omitempty"` + Sex string `json:"sex,omitempty"` + Subgender string `json:"subgender,omitempty"` + Tags string `json:"tags,omitempty"` LastStream string `json:"lastStream,omitempty"` // RFC3339Nano LastSeenOnline *bool `json:"lastSeenOnline,omitempty"` // nil = unbekannt @@ -1048,13 +1053,27 @@ ORDER BY updated_at DESC; continue } + var gender, sex, subgender string + + if strings.TrimSpace(cbOnlineJSON) != "" { + var snap ChaturbateOnlineSnapshot + if err := json.Unmarshal([]byte(cbOnlineJSON), &snap); err == nil { + gender = strings.TrimSpace(snap.Gender) + } + } + m := StoredModel{ - ID: id, - Input: input, - IsURL: isURL, - Host: host, - Path: path, - ModelKey: modelKey, + ID: id, + Input: input, + IsURL: isURL, + Host: host, + Path: path, + ModelKey: modelKey, + + Gender: gender, + Sex: sex, + Subgender: subgender, + Tags: tags, LastStream: fmtNullTime(lastStream), LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline), @@ -1870,13 +1889,26 @@ WHERE id=$1; } } + var gender, sex, subgender string + + if strings.TrimSpace(cbOnlineJSON) != "" { + var snap ChaturbateOnlineSnapshot + if err := json.Unmarshal([]byte(cbOnlineJSON), &snap); err == nil { + gender = strings.TrimSpace(snap.Gender) + } + } + m := StoredModel{ - ID: id, - Input: input, - IsURL: isURL, - Host: host, - Path: path, - ModelKey: modelKey, + ID: id, + Input: input, + IsURL: isURL, + Host: host, + Path: path, + ModelKey: modelKey, + + Gender: gender, + Sex: sex, + Subgender: subgender, Tags: tags, LastStream: fmtNullTime(lastStream), LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline), diff --git a/backend/myfreecams_autostart.go b/backend/myfreecams_autostart.go index b72a012..273f6d9 100644 --- a/backend/myfreecams_autostart.go +++ b/backend/myfreecams_autostart.go @@ -10,6 +10,10 @@ import ( "time" ) +func normalizeMFCUserKey(s string) string { + return strings.ToLower(strings.TrimSpace(s)) +} + func mfcUserFromURL(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { @@ -42,7 +46,7 @@ func mfcUserFromURL(raw string) string { for i := len(parts) - 1; i >= 0; i-- { p := strings.TrimSpace(parts[i]) if p != "" { - return strings.ToLower(p) + return p } } } @@ -51,42 +55,40 @@ func mfcUserFromURL(raw string) string { for i := len(parts) - 1; i >= 0; i-- { p := strings.TrimSpace(parts[i]) if p != "" { - return strings.ToLower(p) + return p } } return "" } -func watchedMyFreeCamsUserKey(m WatchedModelLite) string { - key := strings.ToLower(strings.TrimSpace(m.ModelKey)) - if key != "" { - return key - } +func mfcUserKeyFromURL(raw string) string { + return normalizeMFCUserKey(mfcUserFromURL(raw)) +} - in := strings.TrimSpace(m.Input) - if in == "" { - return "" - } - - if u := mfcUserFromURL(in); u != "" { +func watchedMyFreeCamsUsername(m WatchedModelLite) string { + if u := mfcUserFromURL(m.Input); u != "" { return u } - return strings.ToLower(strings.Trim(in, "/")) + if key := strings.TrimSpace(m.ModelKey); key != "" { + return key + } + + return strings.TrimSpace(strings.Trim(m.Input, "/")) +} + +func watchedMyFreeCamsUserKey(m WatchedModelLite) string { + return normalizeMFCUserKey(watchedMyFreeCamsUsername(m)) } func resolveMyFreeCamsURL(m WatchedModelLite) string { - if u := mfcUserFromURL(m.Input); u != "" { - return fmt.Sprintf("https://www.myfreecams.com/#%s", u) - } - - key := watchedMyFreeCamsUserKey(m) - if key == "" { + user := watchedMyFreeCamsUsername(m) + if user == "" { return "" } - return fmt.Sprintf("https://www.myfreecams.com/#%s", key) + return fmt.Sprintf("https://www.myfreecams.com/#%s", user) } // Startet watched MyFreeCams Models (ohne API) "best-effort". @@ -156,9 +158,9 @@ func startMyFreeCamsAutoStartWorker(store *ModelStore) { manualOrder := make([]string, 0, len(manualItems)) for _, it := range manualItems { - key := strings.ToLower(strings.TrimSpace(it.ModelKey)) + key := normalizeMFCUserKey(strings.TrimSpace(it.ModelKey)) if key == "" { - key = mfcUserFromURL(it.URL) + key = mfcUserKeyFromURL(it.URL) } if key == "" { continue @@ -211,7 +213,7 @@ func startMyFreeCamsAutoStartWorker(store *ModelStore) { continue } - u := mfcUserFromURL(j.SourceURL) + u := mfcUserKeyFromURL(j.SourceURL) if u == "" { continue } diff --git a/backend/postwork.go b/backend/postwork.go index a869cd9..ce39485 100644 --- a/backend/postwork.go +++ b/backend/postwork.go @@ -354,7 +354,6 @@ func startEnrichStatusRefresher() { key := "enrich:" + id st := enrichQ.StatusForKey(key) - // Nur senden, wenn der Job tatsächlich gerade queued/running ist if st.State != "queued" && st.State != "running" { clearEnrichStatusSnapshot(key) continue @@ -364,22 +363,10 @@ func startEnrichStatusRefresher() { continue } - label := "Wartet" - if st.State == "running" { - label = "Wird verarbeitet…" - } - - publishFinishedPostworkStateForJob( - job, - "enrich", - st.State, - "assets", - label, - st.Position, - st.Waiting, - st.Running, - st.MaxParallel, - ) + // Wichtig: + // Hier NICHT mehr pauschal "analyze" publishen. + // Die echten Phasen kommen direkt aus dem Enrich-Worker selbst: + // meta/thumb/teaser (nur falls fehlend), danach sprites, danach analyze. } } }() diff --git a/backend/preview.go b/backend/preview.go index e349501..5432b19 100644 --- a/backend/preview.go +++ b/backend/preview.go @@ -26,6 +26,7 @@ import ( "os/exec" "path" "path/filepath" + "reflect" "regexp" "sort" "strconv" @@ -1532,36 +1533,117 @@ func serveProviderFallbackOrStatus(w http.ResponseWriter, r *http.Request, job * servePreviewStatusSVG(w, label, status) } +func serveRunningJobPreview(w http.ResponseWriter, r *http.Request, job *RecordJob) bool { + if job == nil { + return false + } + + provider := detectPreviewProvider(job, job.Output) + previewState := previewStateForJob(job) + imageURL := strings.TrimSpace(previewImageURLForJob(job)) + previewM3U8 := strings.TrimSpace(job.PreviewM3U8) + previewCookie := strings.TrimSpace(job.PreviewCookie) + previewUA := strings.TrimSpace(job.PreviewUA) + outPath := strings.TrimSpace(job.Output) + + // Chaturbate: immer CDN bevorzugen, nichts auf Platte schreiben + if provider == "chaturbate" && imageURL != "" { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + if img, err := downloadPreviewImageJPG(ctx, imageURL, previewUA); err == nil && len(img) > 0 { + job.previewMu.Lock() + job.previewJPG = append([]byte(nil), img...) + job.previewJPGAt = time.Now() + job.previewMu.Unlock() + + serveLivePreviewJPGBytes(w, img) + return true + } + } + + job.previewMu.Lock() + cached := append([]byte(nil), job.previewJPG...) + cachedAt := job.previewJPGAt + fresh := len(cached) > 0 && !cachedAt.IsZero() && time.Since(cachedAt) < 18*time.Second + canSpawn := !fresh && !job.previewGen && provider != "chaturbate" + if canSpawn { + job.previewGen = true + } + job.previewMu.Unlock() + + if fresh || (provider == "chaturbate" && len(cached) > 0) { + serveLivePreviewJPGBytes(w, cached) + return true + } + + // Für Nicht-Chaturbate darf weiterhin in-memory erzeugt werden, aber nicht als preview.jpg speichern + if canSpawn { + go func(j *RecordJob, previewState, imageURL, previewM3U8, previewCookie, previewUA, outPath string) { + defer func() { + j.previewMu.Lock() + j.previewGen = false + j.previewMu.Unlock() + }() + + var img []byte + + if previewState == "public" && imageURL != "" { + if b, err := downloadPreviewImageJPG(context.Background(), imageURL, previewUA); err == nil && len(b) > 0 { + img = b + } + } + + if len(img) == 0 && previewM3U8 != "" { + if b, err := extractLiveFrameFromM3U8ThumbJPG(context.Background(), previewM3U8, previewCookie, previewUA); err == nil && len(b) > 0 { + img = b + } + } + + if len(img) == 0 && outPath != "" { + if b, err := extractLastFrameJPGScaled(outPath, 720, 75); err == nil && len(b) > 0 { + img = b + } + } + + if len(img) == 0 { + return + } + + j.previewMu.Lock() + j.previewJPG = append([]byte(nil), img...) + j.previewJPGAt = time.Now() + j.previewMu.Unlock() + }(job, previewState, imageURL, previewM3U8, previewCookie, previewUA, outPath) + } + + return false +} + func servePreviewJPGAlias(w http.ResponseWriter, r *http.Request, id string) { jobsMu.RLock() job := jobs[id] jobsMu.RUnlock() if job != nil { + if job.Status == JobRunning { + if serveRunningJobPreview(w, r, job) { + return + } + serveProviderFallbackOrStatus(w, r, job, job.Output, "Preview", http.StatusOK) + return + } + assetID := assetIDForJob(job) if assetID != "" { if jpgPath, err := generatedThumbJPGFile(assetID); err == nil { if st, err := os.Stat(jpgPath); err == nil && !st.IsDir() && st.Size() > 0 { - if job.Status == JobRunning { - serveLivePreviewJPGFile(w, r, jpgPath) - } else { - servePreviewJPGFile(w, r, jpgPath) - } + servePreviewJPGFile(w, r, jpgPath) return } } } - if job.Status == JobRunning { - job.previewMu.Lock() - cached := job.previewJPG - job.previewMu.Unlock() - if len(cached) > 0 { - serveLivePreviewJPGBytes(w, cached) - return - } - } - serveProviderFallbackOrStatus(w, r, job, job.Output, "Preview", http.StatusOK) return } @@ -1634,6 +1716,14 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri low := strings.ToLower(strings.TrimSpace(file)) if low == "preview.jpg" { + jobsMu.RLock() + job := jobs[id] + jobsMu.RUnlock() + + if job != nil && job.Status == JobRunning { + startLiveThumbJPGLoop(context.Background(), job) + } + servePreviewJPGAlias(w, r, id) return } @@ -1649,82 +1739,34 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri if ok { if job.Status == JobRunning { - assetID := assetIDForJob(job) - if assetID != "" { - if jpgPath, err := generatedThumbJPGFile(assetID); err == nil { - if st, err := os.Stat(jpgPath); err == nil && !st.IsDir() && st.Size() > 0 { - serveLivePreviewJPGFile(w, r, jpgPath) - return - } + if serveRunningJobPreview(w, r, job) { + return + } + + state := strings.TrimSpace(previewStateForJob(job)) + if state == "private" { + serveProviderFallbackOrStatus(w, r, job, job.Output, "Private", http.StatusOK) + return + } + if state == "offline" { + serveProviderFallbackOrStatus(w, r, job, job.Output, "Offline", http.StatusOK) + return + } + + serveProviderFallbackOrStatus(w, r, job, job.Output, "Preview", http.StatusOK) + return + } + + assetID := assetIDForJob(job) + if assetID != "" { + if jpgPath, err := generatedThumbJPGFile(assetID); err == nil { + if st, err := os.Stat(jpgPath); err == nil && !st.IsDir() && st.Size() > 0 { + servePreviewJPGFile(w, r, jpgPath) + return } } } - job.previewMu.Lock() - cached := job.previewJPG - cachedAt := job.previewJPGAt - fresh := len(cached) > 0 && !cachedAt.IsZero() && time.Since(cachedAt) < 8*time.Second - - if !fresh && !job.previewGen { - job.previewGen = true - go func(j *RecordJob) { - defer func() { - j.previewMu.Lock() - j.previewGen = false - j.previewMu.Unlock() - }() - - var img []byte - var genErr error - - previewM3U8 := strings.TrimSpace(j.PreviewM3U8) - previewCookie := strings.TrimSpace(j.PreviewCookie) - previewUA := strings.TrimSpace(j.PreviewUA) - - if previewM3U8 != "" { - img, genErr = extractLiveFrameFromM3U8ThumbJPG(context.Background(), previewM3U8, previewCookie, previewUA) - if genErr != nil { - // optional loggen - } - } - - if len(img) == 0 { - outPath := strings.TrimSpace(j.Output) - if outPath != "" { - img, _ = extractLastFrameJPGScaled(outPath, 720, 75) - } - } - - if len(img) > 0 { - j.previewMu.Lock() - j.previewJPG = img - j.previewJPGAt = time.Now() - j.previewMu.Unlock() - } - }(job) - } - - out := cached - job.previewMu.Unlock() - - if len(out) > 0 { - serveLivePreviewJPGBytes(w, out) - return - } - - jobsMu.RLock() - state := strings.TrimSpace(job.PreviewState) - jobsMu.RUnlock() - - if state == "private" { - serveProviderFallbackOrStatus(w, r, job, job.Output, "Private", http.StatusOK) - return - } - if state == "offline" { - serveProviderFallbackOrStatus(w, r, job, job.Output, "Offline", http.StatusOK) - return - } - serveProviderFallbackOrStatus(w, r, job, job.Output, "Preview", http.StatusOK) return } @@ -1733,6 +1775,210 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri servePreviewForFinishedFile(w, r, id) } +func reflectStringField(v any, names ...string) string { + rv := reflect.ValueOf(v) + if !rv.IsValid() { + return "" + } + if rv.Kind() == reflect.Pointer { + if rv.IsNil() { + return "" + } + rv = rv.Elem() + } + if !rv.IsValid() || rv.Kind() != reflect.Struct { + return "" + } + + for _, name := range names { + f := rv.FieldByName(name) + if !f.IsValid() || f.Kind() != reflect.String { + continue + } + s := strings.TrimSpace(f.String()) + if s != "" { + return s + } + } + return "" +} + +func inferModelFromSourceURL(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + + u, err := url.Parse(raw) + if err != nil || u == nil { + return "" + } + + host := strings.ToLower(strings.TrimPrefix(u.Hostname(), "www.")) + switch { + case strings.Contains(host, "chaturbate.com"): + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + if len(parts) > 0 { + return strings.ToLower(strings.TrimSpace(parts[0])) + } + case strings.Contains(host, "myfreecams.com"): + hash := strings.TrimPrefix(strings.TrimSpace(u.Fragment), "/") + if hash != "" { + parts := strings.Split(hash, "/") + last := strings.TrimSpace(parts[len(parts)-1]) + if last != "" { + return strings.ToLower(last) + } + } + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + if len(parts) > 0 { + last := strings.TrimSpace(parts[len(parts)-1]) + if last != "" { + return strings.ToLower(last) + } + } + } + + return "" +} + +func previewModelKeyForJob(job *RecordJob) string { + if job == nil { + return "" + } + + if s := reflectStringField(job, "Model", "ModelKey"); s != "" { + return strings.ToLower(strings.TrimSpace(s)) + } + + if s := reflectStringField(job, "SourceURL", "SourceUrl"); s != "" { + if mk := inferModelFromSourceURL(s); mk != "" { + return mk + } + } + + if s := reflectStringField(job, "Output"); s != "" { + if mk := inferModelFromThumbLike(s); mk != "" { + return strings.ToLower(strings.TrimSpace(mk)) + } + } + + return "" +} + +func previewStateForJob(job *RecordJob) string { + if job == nil { + return "" + } + + if s := reflectStringField(job, + "PreviewState", + "RoomStatus", + "ModelStatus", + "CurrentShow", + "Show", + "StatusText", + ); s != "" { + return strings.ToLower(strings.TrimSpace(s)) + } + + modelKey := previewModelKeyForJob(job) + if modelKey == "" || coverModelStore == nil { + return "" + } + + models := coverModelStore.List() + for _, m := range models { + mk := strings.ToLower(strings.TrimSpace(reflectStringField(m, "ModelKey"))) + if mk == "" || mk != modelKey { + continue + } + + if s := reflectStringField(m, + "RoomStatus", + "CurrentShow", + "Status", + "ModelStatus", + ); s != "" { + return strings.ToLower(strings.TrimSpace(s)) + } + } + + return "" +} + +func previewImageURLForJob(job *RecordJob) string { + if job == nil { + return "" + } + + if s := reflectStringField(job, + "ModelImageURL", + "ModelImageUrl", + "ImageURL", + "ImageUrl", + "PreviewImageURL", + "PreviewImageUrl", + ); s != "" { + return s + } + + modelKey := previewModelKeyForJob(job) + if modelKey == "" || coverModelStore == nil { + return "" + } + + models := coverModelStore.List() + for _, m := range models { + mk := strings.ToLower(strings.TrimSpace(reflectStringField(m, "ModelKey"))) + if mk == "" || mk != modelKey { + continue + } + + if s := reflectStringField(m, + "ImageURL", + "ImageUrl", + "ModelImageURL", + "ModelImageUrl", + ); s != "" { + return s + } + } + + return "" +} + +func imageBytesToJPG(raw []byte, quality int) ([]byte, error) { + if len(raw) == 0 { + return nil, fmt.Errorf("empty image") + } + if quality <= 0 || quality > 100 { + quality = 80 + } + + img, _, err := image.Decode(bytes.NewReader(raw)) + if err != nil { + return nil, err + } + + var buf bytes.Buffer + if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality}); err != nil { + return nil, err + } + if buf.Len() == 0 { + return nil, fmt.Errorf("jpeg encode returned empty data") + } + return buf.Bytes(), nil +} + +func downloadPreviewImageJPG(ctx context.Context, rawURL, ua string) ([]byte, error) { + raw, _, err := downloadBytes(ctx, rawURL, ua) + if err != nil { + return nil, err + } + return imageBytesToJPG(raw, 82) +} + func updateLiveThumbJPGOnce(ctx context.Context, job *RecordJob) { jobsMu.RLock() status := job.Status @@ -1746,21 +1992,16 @@ func updateLiveThumbJPGOnce(ctx context.Context, job *RecordJob) { return } - assetID := assetIDForJob(job) - thumbPath, err := generatedThumbJPGFile(assetID) - if err != nil { + job.previewMu.Lock() + lastAt := job.previewJPGAt + job.previewMu.Unlock() + + if !lastAt.IsZero() && time.Since(lastAt) < 18*time.Second { return } - if st, err := os.Stat(thumbPath); err == nil && st.Size() > 0 { - // etwas Puffer, damit die 10s-Loop nicht an exakten Grenzwerten hängen bleibt - if time.Since(st.ModTime()) < 9*time.Second { - return - } - } - if thumbSem != nil { - thumbCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + thumbCtx, cancel := context.WithTimeout(ctx, 8*time.Second) defer cancel() if err := thumbSem.Acquire(thumbCtx); err != nil { return @@ -1770,15 +2011,33 @@ func updateLiveThumbJPGOnce(ctx context.Context, job *RecordJob) { var img []byte - if previewM3U8 != "" { - if b, err := extractLiveFrameFromM3U8ThumbJPG(ctx, previewM3U8, previewCookie, previewUA); err == nil && len(b) > 0 { - img = b - } - } + provider := detectPreviewProvider(job, out) + previewState := previewStateForJob(job) + imageURL := strings.TrimSpace(previewImageURLForJob(job)) - if len(img) == 0 && out != "" { - if b, err := extractLastFrameJPGScaled(out, 320, 70); err == nil && len(b) > 0 { - img = b + if provider == "chaturbate" { + if imageURL != "" { + if b, err := downloadPreviewImageJPG(ctx, imageURL, previewUA); err == nil && len(b) > 0 { + img = b + } + } + } else { + if previewState == "public" && imageURL != "" { + if b, err := downloadPreviewImageJPG(ctx, imageURL, previewUA); err == nil && len(b) > 0 { + img = b + } + } + + if len(img) == 0 && previewM3U8 != "" { + if b, err := extractLiveFrameFromM3U8ThumbJPG(ctx, previewM3U8, previewCookie, previewUA); err == nil && len(b) > 0 { + img = b + } + } + + if len(img) == 0 && out != "" { + if b, err := extractLastFrameJPGScaled(out, 320, 70); err == nil && len(b) > 0 { + img = b + } } } @@ -1786,7 +2045,10 @@ func updateLiveThumbJPGOnce(ctx context.Context, job *RecordJob) { return } - _ = atomicWriteFile(thumbPath, img) + job.previewMu.Lock() + job.previewJPG = append([]byte(nil), img...) + job.previewJPGAt = time.Now() + job.previewMu.Unlock() } func startLiveThumbJPGLoop(ctx context.Context, job *RecordJob) { @@ -1804,7 +2066,7 @@ func startLiveThumbJPGLoop(ctx context.Context, job *RecordJob) { select { case <-ctx.Done(): return - case <-time.After(10 * time.Second): + case <-time.After(20 * time.Second): jobsMu.RLock() st := job.Status jobsMu.RUnlock() diff --git a/backend/record_stream_cb.go b/backend/record_stream_cb.go index 993aecd..4826a10 100644 --- a/backend/record_stream_cb.go +++ b/backend/record_stream_cb.go @@ -91,11 +91,6 @@ func RecordStream( job.PreviewCookie = httpCookie job.PreviewUA = hc.userAgent jobsMu.Unlock() - - // Live-Thumbs sind teuer: bei vielen parallelen Downloads nicht für alle starten. - if assetID != "" && activeRecordingJobsCount() <= 2 { - startLiveThumbJPGLoop(ctx, job) - } } const maxPlaylistRefreshes = 12 diff --git a/backend/recorder.go b/backend/recorder.go index 8dde46e..f0e9a0a 100644 --- a/backend/recorder.go +++ b/backend/recorder.go @@ -1159,6 +1159,167 @@ func buildPostworkPhaseUpdater(job *RecordJob, postKey string, out *string) func } } +func publishDeferredPhase(fileName, assetID, queue, phase, state, label string, err error) { + errText := "" + if err != nil { + errText = strings.TrimSpace(err.Error()) + } + + publishFinishedPostworkPhase( + fileName, + assetID, + queue, + phase, + state, + label, + errText, + ) +} + +func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL string) error { + outPath = strings.TrimSpace(outPath) + if outPath == "" { + return fmt.Errorf("output path leer") + } + + fileName := filepath.Base(outPath) + assetID := assetIDFromVideoPath(outPath) + if assetID == "" { + return fmt.Errorf("asset id fehlt") + } + + // best effort sourceURL aus meta.json nachziehen, falls leer + if strings.TrimSpace(sourceURL) == "" { + if _, _, _, _, metaPath, err := assetPathsForID(assetID); err == nil { + if fi, serr := os.Stat(outPath); serr == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 { + if u, ok := readVideoMetaSourceURL(metaPath, fi); ok { + sourceURL = u + } + } + } + } + + abortIfCanceled := func(stepErr error, queue string, phase string) error { + if errors.Is(stepErr, context.Canceled) || + errors.Is(stepErr, context.DeadlineExceeded) || + ctx.Err() != nil { + publishDeferredPhase(fileName, assetID, queue, phase, "missing", "", nil) + return context.Canceled + } + return nil + } + + truth := finishedPhaseTruthForID(assetID) + + // ------------------------------------------------- + // Fehlende Vorstufen nur nachziehen, NICHT alles neu + // ------------------------------------------------- + + if !truth.MetaReady { + publishDeferredPhase(fileName, assetID, "postwork", "meta", "running", "Meta", nil) + + okMeta, err := ensureMetaForVideoCtx(ctx, outPath, sourceURL) + if cerr := abortIfCanceled(err, "postwork", "meta"); cerr != nil { + return cerr + } + if err != nil || !okMeta || !finishedPhaseTruthForID(assetID).MetaReady { + if err == nil { + err = fmt.Errorf("meta konnte nicht erzeugt werden") + } + publishDeferredPhase(fileName, assetID, "postwork", "meta", "error", "Meta fehlgeschlagen", err) + return err + } + + publishDeferredPhase(fileName, assetID, "postwork", "meta", "done", "Meta", nil) + truth = finishedPhaseTruthForID(assetID) + } + + if !truth.ThumbReady { + publishDeferredPhase(fileName, assetID, "postwork", "thumb", "running", "Vorschaubild", nil) + + _, err := ensurePrimaryAssetsForVideoWithProgressCtx(ctx, outPath, sourceURL, nil) + if cerr := abortIfCanceled(err, "postwork", "thumb"); cerr != nil { + return cerr + } + if err != nil || !finishedPhaseTruthForID(assetID).ThumbReady { + if err == nil { + err = fmt.Errorf("vorschaubild konnte nicht erzeugt werden") + } + publishDeferredPhase(fileName, assetID, "postwork", "thumb", "error", "Vorschaubild fehlgeschlagen", err) + return err + } + + publishDeferredPhase(fileName, assetID, "postwork", "thumb", "done", "Vorschaubild", nil) + truth = finishedPhaseTruthForID(assetID) + } + + if !truth.TeaserReady { + publishDeferredPhase(fileName, assetID, "postwork", "teaser", "running", "Teaser", nil) + + _, err := ensureTeaserForVideoCtx(ctx, outPath, sourceURL) + if cerr := abortIfCanceled(err, "postwork", "teaser"); cerr != nil { + return cerr + } + if err != nil || !finishedPhaseTruthForID(assetID).TeaserReady { + if err == nil { + err = fmt.Errorf("teaser konnte nicht erzeugt werden") + } + publishDeferredPhase(fileName, assetID, "postwork", "teaser", "error", "Teaser fehlgeschlagen", err) + return err + } + + publishDeferredPhase(fileName, assetID, "postwork", "teaser", "done", "Teaser", nil) + truth = finishedPhaseTruthForID(assetID) + } + + // Ab hier erst die langsamen Hintergrundphasen + var firstErr error + + if !truth.SpritesReady { + publishDeferredPhase(fileName, assetID, "postwork", "sprites", "running", "Sprites", nil) + + _, err := ensureSpritesForVideoCtx(ctx, outPath, sourceURL) + if cerr := abortIfCanceled(err, "postwork", "sprites"); cerr != nil { + return cerr + } + if err != nil || !finishedPhaseTruthForID(assetID).SpritesReady { + if err == nil { + err = fmt.Errorf("sprites konnten nicht erzeugt werden") + } + publishDeferredPhase(fileName, assetID, "postwork", "sprites", "error", "Sprites fehlgeschlagen", err) + if firstErr == nil { + firstErr = err + } + } else { + publishDeferredPhase(fileName, assetID, "postwork", "sprites", "done", "Sprites", nil) + } + } + + truth = finishedPhaseTruthForID(assetID) + + if !truth.AnalyzeReady { + publishDeferredPhase(fileName, assetID, "enrich", "analyze", "running", "KI-Analyse", nil) + + _, err := ensureAnalyzeForVideoCtx(ctx, outPath, sourceURL, "nsfw") + if cerr := abortIfCanceled(err, "enrich", "analyze"); cerr != nil { + return cerr + } + if err != nil || !finishedPhaseTruthForID(assetID).AnalyzeReady { + if err == nil { + err = fmt.Errorf("ki-analyse konnte nicht erzeugt werden") + } + publishDeferredPhase(fileName, assetID, "enrich", "analyze", "error", "KI-Analyse fehlgeschlagen", err) + if firstErr == nil { + firstErr = err + } + } else { + publishDeferredPhase(fileName, assetID, "enrich", "analyze", "done", "KI-Analyse", nil) + } + } + + return firstErr +} + func handlePrimaryPostworkFailure(job *RecordJob, out string, postTarget JobStatus, err error) { finalStatus := JobFailed if postTarget == JobStopped { @@ -1223,15 +1384,6 @@ func handlePrimaryPostworkSuccess(job *RecordJob, out string, postTarget JobStat if ok := enqueueDeferredAssetsAndAI(job, out, job.SourceURL); !ok { fmt.Println("⚠️ deferred enrichment enqueue failed:", out) - - publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ - File: finalFile, - AssetID: finalAssetID, - Queue: "enrich", - State: "error", - Phase: "assets", - Label: "Späte Nachbearbeitung konnte nicht gestartet werden", - }) } if postTarget == JobStopped { diff --git a/backend/recorder_settings.json b/backend/recorder_settings.json index 8e74ac6..9639e26 100644 --- a/backend/recorder_settings.json +++ b/backend/recorder_settings.json @@ -9,8 +9,8 @@ "maxConcurrentDownloads": 80, "useChaturbateApi": true, "useMyFreeCamsWatcher": true, - "autoDeleteSmallDownloads": false, - "autoDeleteSmallDownloadsBelowMB": 100, + "autoDeleteSmallDownloads": true, + "autoDeleteSmallDownloadsBelowMB": 300, "lowDiskPauseBelowGB": 5, "blurPreviews": false, "teaserPlayback": "all", diff --git a/backend/tasks_assets.go b/backend/tasks_assets.go index f6ed9a5..dbdb6d5 100644 --- a/backend/tasks_assets.go +++ b/backend/tasks_assets.go @@ -18,6 +18,7 @@ import ( // --------------------------- type AssetsTaskState struct { + Queued bool `json:"queued"` Running bool `json:"running"` Total int `json:"total"` Done int `json:"done"` @@ -31,6 +32,7 @@ type AssetsTaskState struct { CurrentQueue string `json:"currentQueue,omitempty"` CurrentPhase string `json:"currentPhase,omitempty"` CurrentLabel string `json:"currentLabel,omitempty"` + Text string `json:"text,omitempty"` } var assetsTaskMu sync.Mutex @@ -71,32 +73,31 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) { case http.MethodPost: assetsTaskMu.Lock() - if assetsTaskState.Running { + if assetsTaskState.Running || assetsTaskState.Queued { st := assetsTaskState assetsTaskMu.Unlock() writeJSON(w, http.StatusOK, st) return } - // cancelbarer Context (pro Run) ctx, cancel := context.WithCancel(context.Background()) assetsTaskCancel = cancel - - now := time.Now() assetsTaskState = AssetsTaskState{ - Running: true, + Queued: true, + Running: false, Total: 0, Done: 0, GeneratedThumbs: 0, GeneratedPreviews: 0, Skipped: 0, - StartedAt: now, + StartedAt: time.Time{}, FinishedAt: nil, Error: "", CurrentFile: "", CurrentQueue: "", CurrentPhase: "", CurrentLabel: "", + Text: "Wartet…", } st := assetsTaskState assetsTaskMu.Unlock() @@ -104,7 +105,32 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) { notifyAssetsChanged() publishTaskState() - go runGenerateMissingAssets(ctx) + go func() { + if err := acquireExclusiveTask(ctx); err != nil { + now := time.Now() + updateAssetsState(func(st *AssetsTaskState) { + st.Queued = false + st.Running = false + st.FinishedAt = &now + if errors.Is(err, context.Canceled) { + st.Error = "Abgebrochen" + } else { + st.Error = err.Error() + } + }) + return + } + defer releaseExclusiveTask() + + updateAssetsState(func(st *AssetsTaskState) { + st.Queued = false + st.Running = true + st.StartedAt = time.Now() + st.Text = "" + }) + + runGenerateMissingAssets(ctx) + }() writeJSON(w, http.StatusOK, st) return @@ -113,9 +139,10 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) { assetsTaskMu.Lock() cancel := assetsTaskCancel running := assetsTaskState.Running + queued := assetsTaskState.Queued assetsTaskMu.Unlock() - if !running || cancel == nil { + if (!running && !queued) || cancel == nil { w.WriteHeader(http.StatusNoContent) return } @@ -126,7 +153,9 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) { // UI sofort informieren (ohne Running künstlich auf false zu setzen — // das macht der Worker zuverlässig im finishWithErr(context.Canceled)) st := updateAssetsState(func(st *AssetsTaskState) { - if st.Running { + if st.Running || st.Queued { + st.Queued = false + st.Running = false st.Error = "Abgebrochen" } }) @@ -189,6 +218,7 @@ func runGenerateMissingAssets(ctx context.Context) { now := time.Now() updateAssetsState(func(st *AssetsTaskState) { + st.Queued = false st.Running = false st.FinishedAt = &now st.CurrentFile = "" diff --git a/backend/tasks_cleanup.go b/backend/tasks_cleanup.go index dbaf763..1fb88db 100644 --- a/backend/tasks_cleanup.go +++ b/backend/tasks_cleanup.go @@ -90,6 +90,13 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) { mb = 0 } + // ✅ exklusiv warten: Cleanup startet erst, wenn andere exklusive Tasks fertig sind + if err := acquireExclusiveTask(r.Context()); err != nil { + http.Error(w, err.Error(), http.StatusRequestTimeout) + return + } + defer releaseExclusiveTask() + setCleanupTaskRunning("Räume auf…") resp := cleanupResp{} diff --git a/backend/tasks_exclusive.go b/backend/tasks_exclusive.go new file mode 100644 index 0000000..c532563 --- /dev/null +++ b/backend/tasks_exclusive.go @@ -0,0 +1,33 @@ +// backend\tasks_exclusive.go + +package main + +import ( + "context" +) + +var exclusiveTaskSem = make(chan struct{}, 1) + +func init() { + exclusiveTaskSem <- struct{}{} +} + +func acquireExclusiveTask(ctx context.Context) error { + if ctx == nil { + ctx = context.Background() + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-exclusiveTaskSem: + return nil + } +} + +func releaseExclusiveTask() { + select { + case exclusiveTaskSem <- struct{}{}: + default: + } +} diff --git a/backend/tasks_regenerate_assets.go b/backend/tasks_regenerate_assets.go index 7a94929..a6e9772 100644 --- a/backend/tasks_regenerate_assets.go +++ b/backend/tasks_regenerate_assets.go @@ -196,14 +196,29 @@ func nextQueuedRegenerateAssetsJob() *regenerateAssetsJob { continue } - job.State = "running" - job.StartedAt = time.Now() return job } return nil } +func markRegenerateAssetsJobRunning(file string) *regenerateAssetsJob { + regenerateAssetsMu.Lock() + defer regenerateAssetsMu.Unlock() + + job := regenerateAssetsJobs[file] + if job == nil { + return nil + } + if job.State != "queued" { + return nil + } + + job.State = "running" + job.StartedAt = time.Now() + return job +} + func finishRegenerateAssetsJob(file string, state string, errMsg string) { regenerateAssetsMu.Lock() defer regenerateAssetsMu.Unlock() @@ -305,7 +320,24 @@ func regenerateAssetsWorker() { continue } - runRegenerateAssetsJob(job) + // ✅ wartet global, bis kein anderer exklusiver Task läuft + if err := acquireExclusiveTask(job.Ctx); err != nil { + finishRegenerateAssetsJob(job.File, "cancelled", "Abgebrochen") + setRegenerateAssetsTaskError(job.File, "Abgebrochen") + removeRegenerateAssetsJobLater(job.File, 2*time.Second) + continue + } + + job = markRegenerateAssetsJobRunning(job.File) + if job == nil { + releaseExclusiveTask() + continue + } + + func() { + defer releaseExclusiveTask() + runRegenerateAssetsJob(job) + }() } } diff --git a/backend/tasks_status.go b/backend/tasks_status.go index 633026e..80d0336 100644 --- a/backend/tasks_status.go +++ b/backend/tasks_status.go @@ -206,7 +206,7 @@ func tasksStatusHandler(w http.ResponseWriter, r *http.Request) { RegenerateAssets: regenerate, RegenerateAssetsJobs: regenerateJobs, Cleanup: cleanup, - AnyRunning: assets.Running || anyRegenerateRunning || cleanup.Running, + AnyRunning: assets.Queued || assets.Running || anyRegenerateRunning || cleanup.Running, } w.Header().Set("Content-Type", "application/json") diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5c2c67b..0c6e638 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,7 +16,7 @@ import ModelDetails from './components/ui/ModelDetails' import { SignalIcon, HeartIcon, - HandThumbUpIcon, + StarIcon, EyeIcon, ArrowDownTrayIcon, ArchiveBoxIcon, @@ -149,6 +149,7 @@ type StoredModel = { input: string host?: string modelKey: string + watching: boolean favorite?: boolean liked?: boolean | null @@ -159,6 +160,11 @@ type StoredModel = { isOnline?: boolean chatRoomUrl?: string imageUrl?: string + + gender?: string | null + sex?: string | null + subgender?: string | null + lastOnlineAt?: string lastOfflineAt?: string lastRoomSyncAt?: string @@ -2318,12 +2324,16 @@ export default function App() { }, [authed, recSettings.useChaturbateApi, loadAppModelsSnapshot]) // ✅ StartURL (hier habe ich den alten Online-Fetch entfernt und nur Snapshot genutzt) - const startUrl = useCallback(async (rawUrl: string, opts?: { silent?: boolean }): Promise => { + const startUrl = useCallback(async ( + rawUrl: string, + opts?: { silent?: boolean; immediate?: boolean } + ): Promise => { const norm0 = normalizeHttpUrl(rawUrl) if (!norm0) return false const norm = canonicalizeProviderUrl(norm0) const silent = Boolean(opts?.silent) + const immediate = Boolean(opts?.immediate) if (!silent) setError(null) @@ -2360,20 +2370,24 @@ export default function App() { const keyLower = providerKeyLowerFromUrl(norm) - if (provider === 'chaturbate' && recSettingsRef.current.useChaturbateApi && keyLower) { - queuePendingAutoStart(keyLower, norm, 'unknown', undefined, { - mode: 'probe_retry', - source: 'manual', - }) - return true - } + // Nur automatische Starts in pending-autostart schieben. + // Manuelle UI-Starts müssen sofort direkt starten. + if (!immediate) { + if (provider === 'chaturbate' && recSettingsRef.current.useChaturbateApi && keyLower) { + queuePendingAutoStart(keyLower, norm, 'unknown', undefined, { + mode: 'probe_retry', + source: 'manual', + }) + return true + } - if (provider === 'mfc' && recSettingsRef.current.useMyFreeCamsWatcher && keyLower) { - queuePendingAutoStart(keyLower, norm, 'offline', undefined, { - mode: 'probe_retry', - source: 'manual', - }) - return true + if (provider === 'mfc' && recSettingsRef.current.useMyFreeCamsWatcher && keyLower) { + queuePendingAutoStart(keyLower, norm, 'offline', undefined, { + mode: 'probe_retry', + source: 'manual', + }) + return true + } } try { @@ -2926,7 +2940,7 @@ export default function App() { }, [playerJob, modelsByKey]) async function onStart() { - const ok = await startUrl(sourceUrl, { silent: false }) + const ok = await startUrl(sourceUrl, { silent: false, immediate: true }) if (ok) { setSourceUrl('') @@ -2958,7 +2972,7 @@ export default function App() { } const url = canonicalizeProviderUrl(norm0) - const ok = await startUrl(url, { silent: true }) + const ok = await startUrl(url, { silent: true, immediate: true }) if (!ok) { notify.error('Konnte URL nicht hinzufügen', 'Start fehlgeschlagen oder URL ungültig.') @@ -3618,12 +3632,12 @@ export default function App() { - + {onlineFavCount} - + {onlineLikedCount} @@ -3661,12 +3675,12 @@ export default function App() { - + {onlineFavCount} - + {onlineLikedCount} diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx index 5d5459e..f149d4f 100644 --- a/frontend/src/components/ui/Button.tsx +++ b/frontend/src/components/ui/Button.tsx @@ -26,7 +26,7 @@ function cn(...parts: Array) { } const base = - 'inline-flex items-center justify-center font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 disabled:opacity-50 disabled:cursor-not-allowed' + 'inline-flex items-center justify-center font-semibold transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 disabled:opacity-50 disabled:cursor-not-allowed' const roundedMap = { sm: 'rounded-sm', @@ -47,11 +47,11 @@ const colorMap: Record> = { '!bg-indigo-600 !text-white shadow-sm hover:!bg-indigo-700 focus-visible:outline-indigo-600 ' + 'dark:!bg-indigo-500 dark:hover:!bg-indigo-400 dark:focus-visible:outline-indigo-500', secondary: - 'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' + - 'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20', + 'bg-white text-gray-900 hover:bg-gray-100 ' + + 'dark:bg-white/10 dark:text-white dark:shadow-none dark:hover:bg-white/20', soft: - 'bg-indigo-50 text-indigo-600 shadow-xs hover:bg-indigo-100 ' + - 'dark:bg-indigo-500/20 dark:text-indigo-400 dark:shadow-none dark:hover:bg-indigo-500/30', + 'bg-indigo-500/14 text-indigo-700 shadow-xs inset-ring inset-ring-indigo-500/20 hover:bg-indigo-500/24 hover:inset-ring-indigo-500/30 ' + + 'dark:bg-indigo-500/20 dark:text-indigo-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-indigo-500/30', }, blue: { @@ -59,11 +59,11 @@ const colorMap: Record> = { '!bg-blue-600 !text-white shadow-sm hover:!bg-blue-700 focus-visible:outline-blue-600 ' + 'dark:!bg-blue-500 dark:hover:!bg-blue-400 dark:focus-visible:outline-blue-500', secondary: - 'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' + - 'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20', + 'bg-white text-gray-900 hover:bg-gray-100 ' + + 'dark:bg-white/10 dark:text-white dark:shadow-none dark:hover:bg-white/20', soft: - 'bg-blue-50 text-blue-600 shadow-xs hover:bg-blue-100 ' + - 'dark:bg-blue-500/20 dark:text-blue-400 dark:shadow-none dark:hover:bg-blue-500/30', + 'bg-blue-500/14 text-blue-700 shadow-xs inset-ring inset-ring-blue-500/20 hover:bg-blue-500/24 hover:inset-ring-blue-500/30 ' + + 'dark:bg-blue-500/20 dark:text-blue-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-blue-500/30', }, emerald: { @@ -71,11 +71,11 @@ const colorMap: Record> = { '!bg-emerald-600 !text-white shadow-sm hover:!bg-emerald-700 focus-visible:outline-emerald-600 ' + 'dark:!bg-emerald-500 dark:hover:!bg-emerald-400 dark:focus-visible:outline-emerald-500', secondary: - 'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' + - 'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20', + 'bg-white text-gray-900 hover:bg-gray-100 ' + + 'dark:bg-white/10 dark:text-white dark:shadow-none dark:hover:bg-white/20', soft: - 'bg-emerald-50 text-emerald-700 shadow-xs hover:bg-emerald-100 ' + - 'dark:bg-emerald-500/20 dark:text-emerald-400 dark:shadow-none dark:hover:bg-emerald-500/30', + 'bg-emerald-500/14 text-emerald-700 shadow-xs inset-ring inset-ring-emerald-500/20 hover:bg-emerald-500/24 hover:inset-ring-emerald-500/30 ' + + 'dark:bg-emerald-500/20 dark:text-emerald-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-emerald-500/30', }, red: { @@ -83,11 +83,11 @@ const colorMap: Record> = { '!bg-red-600 !text-white shadow-sm hover:!bg-red-700 focus-visible:outline-red-600 ' + 'dark:!bg-red-500 dark:hover:!bg-red-400 dark:focus-visible:outline-red-500', secondary: - 'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' + - 'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20', + 'bg-white text-gray-900 hover:bg-gray-100 ' + + 'dark:bg-white/10 dark:text-white dark:shadow-none dark:hover:bg-white/20', soft: - 'bg-red-50 text-red-700 shadow-xs hover:bg-red-100 ' + - 'dark:bg-red-500/20 dark:text-red-400 dark:shadow-none dark:hover:bg-red-500/30', + 'bg-red-500/14 text-red-700 shadow-xs inset-ring inset-ring-red-500/20 hover:bg-red-500/24 hover:inset-ring-red-500/30 ' + + 'dark:bg-red-500/20 dark:text-red-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-red-500/30', }, amber: { @@ -95,11 +95,11 @@ const colorMap: Record> = { '!bg-amber-500 !text-white shadow-sm hover:!bg-amber-600 focus-visible:outline-amber-500 ' + 'dark:!bg-amber-500 dark:hover:!bg-amber-400 dark:focus-visible:outline-amber-500', secondary: - 'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' + - 'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20', + 'bg-white text-gray-900 hover:bg-gray-100 ' + + 'dark:bg-white/10 dark:text-white dark:shadow-none dark:hover:bg-white/20', soft: - 'bg-amber-50 text-amber-800 shadow-xs hover:bg-amber-100 ' + - 'dark:bg-amber-500/20 dark:text-amber-300 dark:shadow-none dark:hover:bg-amber-500/30', + 'bg-amber-500/12 text-amber-800 shadow-xs inset-ring inset-ring-amber-500/20 hover:bg-amber-500/22 hover:inset-ring-amber-500/30 ' + + 'dark:bg-amber-500/20 dark:text-amber-300 dark:shadow-none dark:inset-ring-0 dark:hover:bg-amber-500/30', }, } diff --git a/frontend/src/components/ui/ButtonGroup.tsx b/frontend/src/components/ui/ButtonGroup.tsx index 713c551..6cce81c 100644 --- a/frontend/src/components/ui/ButtonGroup.tsx +++ b/frontend/src/components/ui/ButtonGroup.tsx @@ -7,9 +7,9 @@ type Size = 'sm' | 'md' | 'lg' export type ButtonGroupItem = { id: string - label?: React.ReactNode // optional (für icon-only) + label?: React.ReactNode icon?: React.ReactNode - srLabel?: string // für icon-only (Screenreader) + srLabel?: string disabled?: boolean } @@ -45,7 +45,7 @@ export default function ButtonGroup({ return ( onChange(it.id)} aria-pressed={active} className={cn( - 'relative inline-flex items-center justify-center font-semibold leading-none focus:z-10 transition-colors', - !isFirst && 'before:absolute before:left-0 before:top-0 before:bottom-0 before:w-px before:bg-gray-300 dark:before:bg-gray-700', + 'relative inline-flex items-center justify-center font-semibold leading-none', + 'transition-[background-color,color,box-shadow] duration-150', + 'focus:z-20 hover:z-10', + !isFirst && 'before:absolute before:bottom-0 before:left-0 before:top-0 before:w-px before:bg-gray-300 dark:before:bg-gray-700', isFirst && 'rounded-l-md', isLast && 'rounded-r-md', - // Base vs Active: gegenseitig ausschließen (wichtig, sonst gewinnt oft bg-white) active - ? 'bg-indigo-100 text-indigo-800 hover:bg-indigo-200 dark:bg-indigo-500/40 dark:text-indigo-100 dark:hover:bg-indigo-500/50' - : 'bg-white text-gray-900 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:hover:bg-white/20', + ? [ + 'bg-indigo-100 text-indigo-800', + 'hover:bg-indigo-200', + 'hover:shadow-[inset_0_0_0_1px_rgba(99,102,241,0.28),inset_0_0_0_9999px_rgba(99,102,241,0.10)]', + 'dark:bg-indigo-500/40 dark:text-indigo-100 dark:hover:bg-indigo-500/50 dark:hover:shadow-none', + ].join(' ') + : [ + 'bg-white text-gray-900', + 'hover:bg-gray-50', + 'hover:shadow-[inset_0_0_0_1px_rgba(148,163,184,0.35),inset_0_0_0_9999px_rgba(15,23,42,0.05)]', + 'dark:bg-white/10 dark:text-white dark:hover:bg-white/20 dark:hover:shadow-none', + ].join(' '), - // Disabled - 'disabled:opacity-50 disabled:cursor-not-allowed', - - // Padding / Größe + 'disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:shadow-none', iconOnly ? `p-0 ${s.iconOnly}` : s.btn )} title={typeof it.label === 'string' ? it.label : it.srLabel} @@ -88,9 +96,11 @@ export default function ButtonGroup({ {it.icon ? ( {it.icon} @@ -103,4 +113,4 @@ export default function ButtonGroup({ })} ) -} +} \ No newline at end of file diff --git a/frontend/src/components/ui/Downloads.tsx b/frontend/src/components/ui/Downloads.tsx index dfb33eb..7640401 100644 --- a/frontend/src/components/ui/Downloads.tsx +++ b/frontend/src/components/ui/Downloads.tsx @@ -7,11 +7,13 @@ import Table, { type Column } from './Table' import Card from './Card' import Button from './Button' import ModelPreview from './ModelPreview' +import ModelGenderIcon from './ModelGenderIcon' import type { RecordJob } from '../../types' import ProgressBar from './ProgressBar' import { PauseIcon, PlayIcon } from '@heroicons/react/24/solid' import { useMediaQuery } from '../../lib/useMediaQuery' import DownloadsCardRow, { + PendingPreviewImage, type DownloadRow, type PendingWatchedRoom, } from './DownloadsCardRow' @@ -38,6 +40,9 @@ type Props = { watching?: boolean roomStatus?: string imageUrl?: string + gender?: string | null + sex?: string | null + subgender?: string | null } > roomStatusByModelKey?: Record @@ -56,6 +61,31 @@ type Props = { maxConcurrentDownloads?: number } +const isChaturbateJob = ( + job: RecordJob, + modelsByKey?: Record +): boolean => { + const anyJ = job as any + const src = String(anyJ?.sourceUrl ?? anyJ?.SourceURL ?? '').toLowerCase() + const chat = String(anyJ?.modelChatRoomUrl ?? '').toLowerCase() + const img = onlineImageUrlOfJob(job, modelsByKey).toLowerCase() + + return ( + src.includes('chaturbate.com') || + chat.includes('chaturbate.com') || + img.includes('highwebmedia') || + img.includes('chaturbate') + ) +} + +const previewInitialSrcOfJob = ( + job: RecordJob, + modelsByKey?: Record +): string => { + if (!isChaturbateJob(job, modelsByKey)) return '' + return onlineImageUrlOfJob(job, modelsByKey) +} + const pendingModelName = (p: PendingWatchedRoom) => { const anyP = p as any return ( @@ -68,13 +98,6 @@ const pendingModelName = (p: PendingWatchedRoom) => { ) } -const shouldRefreshPreview = (j: RecordJob) => { - const phase = String((j as any)?.phase ?? '').trim().toLowerCase() - const status = String(j.status ?? '').trim().toLowerCase() - - return !j.endedAt && status === 'running' && (phase === '' || phase === 'recording') -} - const pendingUrl = (p: PendingWatchedRoom) => { const anyP = p as any return anyP.sourceUrl ?? anyP.url ?? anyP.roomUrl ?? '' @@ -588,8 +611,6 @@ export default function Downloads({ const [watchedBusy, setWatchedBusy] = useState(false) - const [thumbTick, setThumbTick] = useState(0) - const refreshWatchedState = useCallback(async () => { try { await onRefreshAutostartState?.() @@ -598,33 +619,6 @@ export default function Downloads({ } }, [onRefreshAutostartState]) - const [pageVisible, setPageVisible] = useState(!document.hidden) - - useEffect(() => { - const onVis = () => setPageVisible(!document.hidden) - document.addEventListener('visibilitychange', onVis) - return () => document.removeEventListener('visibilitychange', onVis) - }, []) - - useEffect(() => { - const activePreviewJobs = jobs.filter((j) => { - const phase = String((j as any)?.phase ?? '').trim().toLowerCase() - const status = String(j.status ?? '').trim().toLowerCase() - - return !j.endedAt && status === 'running' && (phase === '' || phase === 'recording') - }).length - - if (activePreviewJobs === 0 || !pageVisible) return - - const intervalMs = activePreviewJobs > 2 ? 30000 : 10000 - - const id = window.setInterval(() => { - setThumbTick((t) => t + 1) - }, intervalMs) - - return () => window.clearInterval(id) - }, [jobs, pageVisible]) - useEffect(() => { const nextPaused = Boolean(autostartState?.paused) const nextPausedByUser = Boolean(autostartState?.pausedByUser) @@ -971,8 +965,7 @@ export default function Downloads({ cell: (r) => { if (r.kind === 'job') { const j = r.job - const providerFallbackSrc = `/api/preview?id=${encodeURIComponent(j.id)}&fallbackOnly=1` - const onlineImageUrl = onlineImageUrlOfJob(j, modelsByKey) + const directPreviewSrc = previewInitialSrcOfJob(j, modelsByKey) return (
@@ -982,8 +975,7 @@ export default function Downloads({ > @@ -1007,18 +999,11 @@ export default function Downloads({ if (img) { return (
- {name} { - ;(e.currentTarget as HTMLImageElement).style.display = 'none' - }} + blur={blurPreviews} + className="h-full w-full" />
) @@ -1040,6 +1025,8 @@ export default function Downloads({ const j = r.job const f = baseName(j.output || '') const name = modelNameFromOutput(j.output) + const modelKey = modelKeyFromJob(j) + const modelMeta = modelKey ? modelsByKey[modelKey] : undefined const roomStatus = effectiveRoomStatusOfJob( j, @@ -1054,6 +1041,14 @@ export default function Downloads({ {name} + + + - - {name} - +
+ + {name} + + + +
+ {url ? ( { if (r.kind !== 'job') { - const pendingKey = pendingRowKey(r.pending) - const isRemovingPending = Boolean(removePendingRequestedKeys[pendingKey]) + const pendingKey = String( + (r.pending as any)?.modelKey ?? + pendingModelName(r.pending) + ).trim().toLowerCase() + + const pendingFlags = pendingKey ? modelsByKey[pendingKey] : undefined + + const pendingActionJob = { + id: `pending:${pendingKey || pendingRowKey(r.pending)}`, + output: `${pendingModelName(r.pending)}_pending.mp4`, + sourceUrl: pendingUrl(r.pending), + status: 'pending', + endedAt: null, + } as unknown as RecordJob + + const isFav = Boolean(pendingFlags?.favorite) + const isLiked = pendingFlags?.liked === true + const isWatching = Boolean(pendingFlags?.watching) + + const removeKey = pendingRowKey(r.pending) + const isRemovingPending = Boolean(removePendingRequestedKeys[removeKey]) return ( -
+
e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + > + + + + +
+ +
@@ -654,18 +797,12 @@ export default function DownloadsCardRow({ Boolean(phase) && (!Number.isFinite(progress) || progress <= 0 || progress >= 100) - const key = name && name !== '—' ? name.toLowerCase() : '' - const flags = key ? modelsByKey[key] : undefined + const modelKey = modelKeyFromJob(j) + const flags = modelKey ? modelsByKey[modelKey] : undefined const isFav = Boolean(flags?.favorite) const isLiked = flags?.liked === true const isWatching = Boolean(flags?.watching) - - const shouldRefreshPreview = (j: RecordJob) => { - const phase = String((j as any)?.phase ?? '').trim().toLowerCase() - const status = String(j.status ?? '').trim().toLowerCase() - - return !j.endedAt && status === 'running' && (phase === '' || phase === 'recording') - } + const directPreviewSrc = previewInitialSrcOfJob(j, modelsByKey) return (
-
-
-
-
+
+
+
+
{name}
- - {roomStatus} - + +
-
+ +
{file || '—'}
-
- - {runtimeOf(j, nowMs)} - - - {formatBytes(sizeBytesOf(j))} +
+ + {roomStatus}
@@ -790,7 +931,7 @@ export default function DownloadsCardRow({
) : null} -
+
e.stopPropagation()} @@ -811,28 +952,43 @@ export default function DownloadsCardRow({ />
-
- + if (showRemoveQueuedButton) { + await onRemoveQueuedPostworkJob?.(j.id) + return + } + + if (isStopping) return + markStopRequested(j.id) + await onStopJob(j.id) + }} + > + {showRemoveQueuedButton ? 'Entfernen' : (isStopping ? 'Stoppe…' : 'Stop')} + +
diff --git a/frontend/src/components/ui/FinishedDownloads.tsx b/frontend/src/components/ui/FinishedDownloads.tsx index d193182..31d71bf 100644 --- a/frontend/src/components/ui/FinishedDownloads.tsx +++ b/frontend/src/components/ui/FinishedDownloads.tsx @@ -185,9 +185,11 @@ function enrichJobForDetails(job: RecordJob, modelsByKey: Record() - let aggregateAssetsBadge: FinishedPostworkBadge | null = null - for (const badge of realBadges) { const queue = badge.queue const rawPhase = String(badge.phase ?? '').trim().toLowerCase() - - // ✅ Sammelstatus "enrich:assets" NICHT als eigene Extra-Zeile anzeigen - // sondern später auf die bekannten Schritte verteilen. - if (isAggregateAssetsPhase(queue, rawPhase)) { - if (!aggregateAssetsBadge || (aggregateAssetsBadge.ts ?? 0) <= (badge.ts ?? 0)) { - aggregateAssetsBadge = badge - } - continue - } - const phase = normalizeFinishedPhase(queue, rawPhase) const key = `${queue}:${phase}` @@ -1012,27 +1001,6 @@ function summarizePostworkSteps( }) } - // ✅ Dann ggf. den Sammelstatus "assets" auf alle noch nicht fertigen Schritte abbilden - if (aggregateAssetsBadge) { - for (const stepDef of expectedSteps) { - const cur = byKey.get(stepDef.key) - if (!cur) continue - - // Bereits fertige Schritte beibehalten - if (cur.state === 'done') continue - - byKey.set(stepDef.key, { - ...cur, - state: aggregateAssetsBadge.state, - ts: Math.max(cur.ts || 0, aggregateAssetsBadge.ts || 0), - position: aggregateAssetsBadge.position, - waiting: aggregateAssetsBadge.waiting, - running: aggregateAssetsBadge.running, - maxParallel: aggregateAssetsBadge.maxParallel, - }) - } - } - const order = expectedSteps.map((s) => s.key) const steps = Array.from(byKey.values()).sort((a, b) => { @@ -1207,6 +1175,39 @@ function mergePostworkSummaries( } } +function samePostworkBadge(a: FinishedPostworkBadge, b: FinishedPostworkBadge): boolean { + return ( + a.file === b.file && + a.assetId === b.assetId && + a.queue === b.queue && + a.phase === b.phase && + a.state === b.state && + a.label === b.label && + a.error === b.error && + a.position === b.position && + a.waiting === b.waiting && + a.running === b.running && + a.maxParallel === b.maxParallel && + (a.ts ?? 0) === (b.ts ?? 0) + ) +} + +function samePostworkBadgeList( + a?: FinishedPostworkBadge[], + b?: FinishedPostworkBadge[] +): boolean { + const aa = Array.isArray(a) ? a : [] + const bb = Array.isArray(b) ? b : [] + + if (aa.length !== bb.length) return false + + for (let i = 0; i < aa.length; i++) { + if (!samePostworkBadge(aa[i], bb[i])) return false + } + + return true +} + export function renderFinishedPostworkBadge( summary?: FinishedPostworkSummary, opts?: { @@ -1505,6 +1506,8 @@ export default function FinishedDownloads({ const removeTimersRef = React.useRef>(new Map()) + const regenerateTaskFilesRef = React.useRef>(new Set()) + // ----------------------------------------------------------------------------- // memoized derived data // ----------------------------------------------------------------------------- @@ -1777,6 +1780,9 @@ export default function FinishedDownloads({ pageRows.length > 0 && pageRows.every((j) => selectedKeys.has(keyFor(j))) + const hasSearchQuery = (searchQuery || '').trim() !== '' + const selectionActionsVisible = selectedCount > 0 && selectionActionsOpen + // ----------------------------------------------------------------------------- // callbacks // ----------------------------------------------------------------------------- @@ -2689,53 +2695,22 @@ export default function FinishedDownloads({ const cleanFile = String(file || '').trim() if (!cleanFile) return false + const assetId = fileStemFromOutput(cleanFile) + const now = Date.now() + + // Nur den ersten Schritt optimistisch als queued setzen. + // Die weiteren Phasen sollen erst durch echte SSE-/Backend-Events auftauchen. setPostworkByFile((prev) => ({ ...prev, [cleanFile]: [ { file: cleanFile, - assetId: fileStemFromOutput(cleanFile), + assetId, queue: 'postwork', state: 'queued', phase: 'meta', label: 'Meta', - ts: Date.now(), - }, - { - file: cleanFile, - assetId: fileStemFromOutput(cleanFile), - queue: 'postwork', - state: 'queued', - phase: 'thumb', - label: 'Vorschaubild', - ts: Date.now(), - }, - { - file: cleanFile, - assetId: fileStemFromOutput(cleanFile), - queue: 'postwork', - state: 'queued', - phase: 'teaser', - label: 'Teaser', - ts: Date.now(), - }, - { - file: cleanFile, - assetId: fileStemFromOutput(cleanFile), - queue: 'postwork', - state: 'queued', - phase: 'sprites', - label: 'Sprites', - ts: Date.now(), - }, - { - file: cleanFile, - assetId: fileStemFromOutput(cleanFile), - queue: 'enrich', - state: 'queued', - phase: 'analyze', - label: 'KI-Analyse', - ts: Date.now(), + ts: now, }, ], })) @@ -2759,16 +2734,19 @@ export default function FinishedDownloads({ error: e?.message ? String(e.message) : String(e), }) + // Request/Enqueue ist fehlgeschlagen -> KEIN Sammelstatus, KEIN enrich:assets. + // Wir markieren nur den ersten Schritt als Fehler. setPostworkByFile((prev) => ({ ...prev, [cleanFile]: [ { file: cleanFile, - assetId: fileStemFromOutput(cleanFile), - queue: 'enrich', + assetId, + queue: 'postwork', state: 'error', - phase: 'assets', + phase: 'meta', label: 'Fehler', + error: e?.message ? String(e.message) : String(e), ts: Date.now(), }, ], @@ -2800,10 +2778,19 @@ export default function FinishedDownloads({ return '—' }, [durations]) - const registerTeaserHost = useCallback( - (key: string) => (el: HTMLDivElement | null) => { + const teaserHostCallbackMapRef = React.useRef( + new Map void>() + ) + + const registerTeaserHost = useCallback((key: string) => { + const existing = teaserHostCallbackMapRef.current.get(key) + if (existing) return existing + + const cb = (el: HTMLDivElement | null) => { const prev = teaserHostsRef.current.get(key) - if (prev && teaserIORef.current) teaserIORef.current.unobserve(prev) + if (prev && teaserIORef.current && prev !== el) { + teaserIORef.current.unobserve(prev) + } if (el) { teaserHostsRef.current.set(key, el) @@ -2813,9 +2800,11 @@ export default function FinishedDownloads({ if (prev && teaserIORef.current) teaserIORef.current.unobserve(prev) teaserHostsRef.current.delete(key) } - }, - [] - ) + } + + teaserHostCallbackMapRef.current.set(key, cb) + return cb + }, []) const handleDuration = useCallback((job: RecordJob, seconds: number) => { if (!Number.isFinite(seconds) || seconds <= 0) return @@ -3150,111 +3139,101 @@ export default function FinishedDownloads({ setHoverTeaserKey(null) setInlinePlay(null) }, [pauseTeasers]) + + const refreshPostworkState = useCallback(async () => { + try { + const res = await fetch('/api/tasks/status', { cache: 'no-store' }) + if (!res.ok) return - useEffect(() => { - let alive = true + const data = await res.json().catch(() => null) + if (!data) return - const loadInitialPostworkState = async () => { - try { - const res = await fetch('/api/tasks/status', { cache: 'no-store' }) - if (!res.ok) return + const jobs = Array.isArray(data?.regenerateAssetsJobs) + ? data.regenerateAssetsJobs + : [] - const data = await res.json().catch(() => null) - if (!alive || !data) return + const syncedFiles = new Set() - const jobs = Array.isArray(data?.regenerateAssetsJobs) - ? data.regenerateAssetsJobs - : [] + setPostworkByFile((prev) => { + let changed = false + const next = { ...prev } - if (jobs.length > 0) { - setPostworkByFile((prev) => { - const next = { ...prev } + for (const oldFile of regenerateTaskFilesRef.current) { + if (!syncedFiles.has(oldFile) && oldFile in next) { + delete next[oldFile] + changed = true + } + } - for (const item of jobs) { - const file = String(item?.file ?? '').trim() - if (!file) continue + for (const item of jobs) { + const file = String(item?.file ?? '').trim() + if (!file) continue - const assetId = - String(item?.assetId ?? '').trim() || fileStemFromOutput(file) + let nextBadges: FinishedPostworkBadge[] | undefined - const phases = Array.isArray(item?.phases) ? item.phases : [] + const assetId = + String(item?.assetId ?? '').trim() || fileStemFromOutput(file) - if (phases.length > 0) { - const restored = phases - .map((p: any): FinishedPostworkBadge | null => { - const queueRaw = String(p?.queue ?? '').trim().toLowerCase() - const phaseRaw = String(p?.phase ?? '').trim().toLowerCase() - const rawState = String(p?.state ?? '').trim().toLowerCase() + const phases = Array.isArray(item?.phases) ? item.phases : [] - const state: FinishedPostworkState = - rawState === 'queued' || - rawState === 'running' || - rawState === 'done' || - rawState === 'error' || - rawState === 'missing' - ? rawState - : rawState === 'cancelled' - ? 'missing' - : 'missing' + if (phases.length > 0) { + nextBadges = phases + .map((p: any): FinishedPostworkBadge | null => { + const queueRaw = String(p?.queue ?? '').trim().toLowerCase() + const phaseRaw = String(p?.phase ?? '').trim().toLowerCase() + const rawState = String(p?.state ?? '').trim().toLowerCase() - if ( - (queueRaw !== 'postwork' && queueRaw !== 'enrich') || - !phaseRaw - ) { - return null - } + const state: FinishedPostworkState = + rawState === 'queued' || + rawState === 'running' || + rawState === 'done' || + rawState === 'error' || + rawState === 'missing' + ? rawState + : rawState === 'cancelled' + ? 'missing' + : 'missing' - return { - file, - assetId, - queue: queueRaw as 'postwork' | 'enrich', - phase: phaseRaw, - state, - label: - String(p?.label ?? '').trim() || - labelForStep(queueRaw as 'postwork' | 'enrich', phaseRaw), - position: - typeof p?.position === 'number' ? p.position : undefined, - waiting: - typeof p?.waiting === 'number' ? p.waiting : undefined, - running: - typeof p?.running === 'number' ? p.running : undefined, - maxParallel: - typeof p?.maxParallel === 'number' - ? p.maxParallel - : undefined, - ts: - typeof p?.ts === 'number' && Number.isFinite(p.ts) - ? p.ts - : Date.now(), - } - }) - .filter(Boolean) as FinishedPostworkBadge[] - - if (restored.length > 0) { - next[file] = restored - continue + if ( + (queueRaw !== 'postwork' && queueRaw !== 'enrich') || + !phaseRaw + ) { + return null } - } - const rawState = String(item?.state ?? '').trim().toLowerCase() + return { + file, + assetId, + queue: queueRaw as 'postwork' | 'enrich', + phase: phaseRaw, + state, + label: + String(p?.label ?? '').trim() || + labelForStep(queueRaw as 'postwork' | 'enrich', phaseRaw), + position: typeof p?.position === 'number' ? p.position : undefined, + waiting: typeof p?.waiting === 'number' ? p.waiting : undefined, + running: typeof p?.running === 'number' ? p.running : undefined, + maxParallel: typeof p?.maxParallel === 'number' ? p.maxParallel : undefined, + ts: typeof p?.ts === 'number' && Number.isFinite(p.ts) ? p.ts : 0, + } + }) + .filter(Boolean) as FinishedPostworkBadge[] + } else { + const rawState = String(item?.state ?? '').trim().toLowerCase() - const state: FinishedPostworkState = - rawState === 'queued' || - rawState === 'running' || - rawState === 'done' || - rawState === 'error' || - rawState === 'missing' - ? rawState - : rawState === 'cancelled' - ? 'missing' - : 'missing' + const state: FinishedPostworkState = + rawState === 'queued' || + rawState === 'running' || + rawState === 'done' || + rawState === 'error' || + rawState === 'missing' + ? rawState + : rawState === 'cancelled' + ? 'missing' + : 'missing' - if (state !== 'queued' && state !== 'running' && state !== 'error') { - continue - } - - next[file] = [ + if (state === 'queued' || state === 'running' || state === 'error') { + nextBadges = [ { file, assetId, @@ -3262,60 +3241,64 @@ export default function FinishedDownloads({ state, phase: 'meta', label: 'Meta', - ts: Date.now(), - }, - { - file, - assetId, - queue: 'postwork', - state, - phase: 'thumb', - label: 'Vorschaubild', - ts: Date.now(), - }, - { - file, - assetId, - queue: 'postwork', - state, - phase: 'teaser', - label: 'Teaser', - ts: Date.now(), - }, - { - file, - assetId, - queue: 'postwork', - state, - phase: 'sprites', - label: 'Sprites', - ts: Date.now(), - }, - { - file, - assetId, - queue: 'enrich', - state, - phase: 'analyze', - label: 'KI-Analyse', - ts: Date.now(), + ts: 0, }, ] } + } - return next - }) + const prevBadges = prev[file] + + if (nextBadges) { + if (!samePostworkBadgeList(prevBadges, nextBadges)) { + next[file] = nextBadges + changed = true + } + } else if (file in next) { + delete next[file] + changed = true + } } - } catch {} - } - void loadInitialPostworkState() + return changed ? next : prev + }) - return () => { - alive = false + regenerateTaskFilesRef.current = syncedFiles + } catch { + // ignore } }, []) + useEffect(() => { + void refreshPostworkState() + }, [refreshPostworkState]) + + useEffect(() => { + const onFocus = () => { + void refreshPostworkState() + } + + const onVisibilityChange = () => { + if (document.visibilityState === 'visible') { + void refreshPostworkState() + } + } + + const onPageShow = () => { + void refreshPostworkState() + } + + window.addEventListener('focus', onFocus) + window.addEventListener('pageshow', onPageShow) + document.addEventListener('visibilitychange', onVisibilityChange) + + return () => { + window.removeEventListener('focus', onFocus) + window.removeEventListener('pageshow', onPageShow) + document.removeEventListener('visibilitychange', onVisibilityChange) + } + }, [refreshPostworkState]) + useEffect(() => { const onPointerMove = (e: PointerEvent) => { pointerPosRef.current = { x: e.clientX, y: e.clientY } @@ -3580,6 +3563,39 @@ export default function FinishedDownloads({ } }, [selectedCount, selectionActionsOpen]) + const desktopToolbarControlsClass = [ + 'flex items-center gap-3', + 'flex-nowrap', + 'md:flex-wrap', + 'xl:flex-nowrap', + ].join(' ') + + const desktopToolbarLeftClass = [ + 'flex min-w-0 shrink-0 items-center gap-2', + 'flex-nowrap', + ].join(' ') + + const desktopToolbarRightClass = [ + 'flex min-w-0 items-center gap-2', + 'flex-nowrap', + 'md:ml-0 md:basis-full md:flex-wrap', + 'xl:ml-auto xl:basis-auto xl:flex-nowrap xl:justify-end', + ].join(' ') + + const renderClickablePostworkBadge = useCallback( + (badge?: FinishedPostworkSummary) => + renderFinishedPostworkBadge(badge, { + clickable: true, + disablePopover: isSmall, + onClick: () => { + if (!badge?.file) return + if (badge.state === 'running' || badge.state === 'queued') return + void regenerateAssetsForFile(badge.file) + }, + }), + [isSmall, regenerateAssetsForFile] + ) + // ----------------------------------------------------------------------------- // render // ----------------------------------------------------------------------------- @@ -3596,89 +3612,110 @@ export default function FinishedDownloads({ > {/* Desktop */}
-
-
-
- Abgeschlossene Downloads -
- - {totalItemsForPagination} - -
- -
- -
-
- -
-
- setSearchQuery(e.target.value)} - placeholder="Suchen…" - /> - {(searchQuery || '').trim() !== '' ? ( - - ) : null} -
- - {selectedCount > 0 ? ( - - ) : null} - -
-
- { - if (page !== 1) onPageChange(1) - onIncludeKeepChange(checked) - }} - /> +
+
+
+
+ Abgeschlossene Downloads +
+ + {totalItemsForPagination} +
- {view !== 'table' ? ( -
- {view === 'gallery' ? ( -
- -
- ) : null} +
+
+ +
-
+
+ setSearchQuery(e.target.value)} + placeholder="Suchen…" + className={hasSearchQuery ? 'w-full pr-9' : 'w-full'} + /> + + +
+
+
+ +
+ {/* links */} +
+
+ { + if (page !== 1) onPageChange(1) + onIncludeKeepChange(checked) + }} + /> +
+ + {selectedCount > 0 ? ( +
+ +
+ ) : null} +
+ + {/* rechts */} +
+ {view === 'gallery' ? ( +
+ +
+ ) : null} + + {view !== 'table' ? ( +
@@ -3691,7 +3728,7 @@ export default function FinishedDownloads({ if (page !== 1) onPageChange(1) }} className=" - h-9 rounded-md border border-gray-200 bg-white px-2 text-sm text-gray-900 shadow-sm + h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm dark:border-white/10 dark:bg-gray-900 dark:text-gray-100 dark:[color-scheme:dark] " > @@ -3705,51 +3742,54 @@ export default function FinishedDownloads({
+ ) : null} + + + +
+ setView(id as ViewMode)} + size="md" + ariaLabel="Ansicht" + items={[ + { + id: 'table', + icon: , + label: 'Tabelle', + srLabel: 'Tabelle', + }, + { + id: 'cards', + icon: , + label: 'Cards', + srLabel: 'Cards', + }, + { + id: 'gallery', + icon: , + label: 'Galerie', + srLabel: 'Galerie', + }, + ]} + />
- ) : null} - - - - setView(id as ViewMode)} - size="md" - ariaLabel="Ansicht" - items={[ - { - id: 'table', - icon: , - label: 'Tabelle', - srLabel: 'Tabelle', - }, - { - id: 'cards', - icon: , - label: 'Cards', - srLabel: 'Cards', - }, - { - id: 'gallery', - icon: , - label: 'Galerie', - srLabel: 'Galerie', - }, - ]} - /> +
@@ -3859,93 +3899,32 @@ export default function FinishedDownloads({
- {selectedCount > 0 ? ( +
-
-
- {bulkBusy ? ( -
- - Wird verarbeitet… -
- ) : null} - -
- - - - - - - +
+ {bulkBusy ? ( +
+ + Wird verarbeitet…
-
- -
- {bulkBusy ? ( -
- - Wird verarbeitet… -
- ) : null} + ) : null} +
@@ -3970,12 +3950,11 @@ export default function FinishedDownloads({ leadingIcon={} disabled={bulkBusy} onClick={clearSelection} + className="w-full justify-center" > Auswahl aufheben -
- @@ -3994,13 +3974,75 @@ export default function FinishedDownloads({ leadingIcon={} disabled={bulkBusy} onClick={() => void bulkDeleteSelected()} + className="w-full justify-center" > Löschen
+ +
+ {bulkBusy ? ( +
+ + Wird verarbeitet… +
+ ) : null} + + + + + +
+ + + + +
- ) : null} +
{tagFilter.length > 0 ? (
@@ -4261,17 +4303,7 @@ export default function FinishedDownloads({ forcePreviewMuted={forcePreviewMuted} mobileTeaserAudioUnlocked={mobileTeaserAudioUnlocked} onUnlockMobileTeaserAudio={() => setMobileTeaserAudioUnlocked(true)} - renderPostworkBadge={(badge) => - renderFinishedPostworkBadge(badge, { - clickable: true, - disablePopover: isSmall, - onClick: () => { - if (!badge?.file) return - if (badge.state === 'running' || badge.state === 'queued') return - void regenerateAssetsForFile(badge.file) - }, - }) - } + renderPostworkBadge={renderClickablePostworkBadge} />
)} @@ -4326,17 +4358,7 @@ export default function FinishedDownloads({ postworkByFile={effectivePostworkSummaryByFile} getPostworkSummaryForOutput={getPostworkSummaryForOutput} forcePreviewMuted={forcePreviewMuted} - renderPostworkBadge={(badge) => - renderFinishedPostworkBadge(badge, { - clickable: true, - disablePopover: isSmall, - onClick: () => { - if (!badge?.file) return - if (badge.state === 'running' || badge.state === 'queued') return - void regenerateAssetsForFile(badge.file) - }, - }) - } + renderPostworkBadge={renderClickablePostworkBadge} /> )} @@ -4386,17 +4408,7 @@ export default function FinishedDownloads({ getPostworkSummaryForOutput={getPostworkSummaryForOutput} forcePreviewMuted={forcePreviewMuted} cardScale={galleryCardScaleLive} - renderPostworkBadge={(badge) => - renderFinishedPostworkBadge(badge, { - clickable: true, - disablePopover: isSmall, - onClick: () => { - if (!badge?.file) return - if (badge.state === 'running' || badge.state === 'queued') return - void regenerateAssetsForFile(badge.file) - }, - }) - } + renderPostworkBadge={renderClickablePostworkBadge} /> )}
diff --git a/frontend/src/components/ui/FinishedDownloadsCardsView.tsx b/frontend/src/components/ui/FinishedDownloadsCardsView.tsx index 378fb74..69c788a 100644 --- a/frontend/src/components/ui/FinishedDownloadsCardsView.tsx +++ b/frontend/src/components/ui/FinishedDownloadsCardsView.tsx @@ -29,6 +29,7 @@ import { readPreviewSpriteInfo, scrubProgressRatioFromIndex, } from './previewSprite' +import ModelGenderIcon from './ModelGenderIcon' type Props = { rows: RecordJob[] @@ -536,13 +537,6 @@ export default function FinishedDownloadsCardsView({ const previewRenderRole = opts?.renderRole ?? (isSmall ? 'top' : 'desktop') - const previewInstanceKey = [ - k, - previewRenderRole, - inlineActive ? 'inline' : 'teaser', - allowTeaserAnimation ? 'anim' : 'still', - ].join('::') - const showMobileAudioButton = Boolean(onUnlockMobileTeaserAudio) && isMobileTopCard && @@ -700,7 +694,6 @@ export default function FinishedDownloadsCardsView({ ) : null} stripHotPrefix(baseName(p))} className="h-full w-full" @@ -859,8 +852,17 @@ export default function FinishedDownloadsCardsView({
-
- {model} +
+
+ {model} +
+ +
{renderPostworkBadge && livePostwork ? ( diff --git a/frontend/src/components/ui/FinishedDownloadsGalleryCard.tsx b/frontend/src/components/ui/FinishedDownloadsGalleryCard.tsx index 05512fe..6d76166 100644 --- a/frontend/src/components/ui/FinishedDownloadsGalleryCard.tsx +++ b/frontend/src/components/ui/FinishedDownloadsGalleryCard.tsx @@ -27,6 +27,7 @@ import { readPreviewSpriteInfo, scrubProgressRatioFromIndex, } from './previewSprite' +import ModelGenderIcon from './ModelGenderIcon' function normalizeDurationSeconds(value: unknown): number | undefined { if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined @@ -48,7 +49,7 @@ type Props = { ) => FinishedPostworkSummary | undefined renderPostworkBadge?: (badge?: FinishedPostworkSummary) => React.ReactNode - durations: Record + durationSeconds?: number teaserState: FinishedDownloadsTeaserState keyFor: (j: RecordJob) => string @@ -100,7 +101,7 @@ function FinishedDownloadsGalleryCardInner({ postworkByFile, getPostworkSummaryForOutput, renderPostworkBadge, - durations, + durationSeconds, teaserState, keyFor, baseName, @@ -261,7 +262,7 @@ function FinishedDownloadsGalleryCardInner({ const hideTeaserUnderOverlay = showModelPreviewInThumb || showScrubberSpriteInThumb const previewDurationSeconds = - durations[k] ?? + durationSeconds ?? normalizeDurationSeconds(meta?.media?.durationSeconds) ?? normalizeDurationSeconds(meta?.file?.durationSeconds) @@ -336,7 +337,6 @@ function FinishedDownloadsGalleryCardInner({
stripHotPrefix(baseName(p))} durationSeconds={previewDurationSeconds} @@ -442,8 +442,17 @@ function FinishedDownloadsGalleryCardInner({ >
-
- {model} +
+
+ {model} +
+ +
{showPostworkBadge && renderPostworkBadge ? ( diff --git a/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx b/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx index d46c786..26eb9cf 100644 --- a/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx +++ b/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx @@ -219,6 +219,7 @@ export default function FinishedDownloadsGalleryView({ > {rows.map((j) => { const k = keyFor(j) + const durationForRow = durations[k] return (
-
- {model} +
+
+ {model} +
+ +
{showPostworkBadge && renderPostworkBadge ? ( diff --git a/frontend/src/components/ui/FinishedVideoPreview.tsx b/frontend/src/components/ui/FinishedVideoPreview.tsx index 2b6ea3a..81b4e81 100644 --- a/frontend/src/components/ui/FinishedVideoPreview.tsx +++ b/frontend/src/components/ui/FinishedVideoPreview.tsx @@ -685,8 +685,8 @@ export default function FinishedVideoPreview({ const teaserSrc = useMemo(() => { if (!previewId) return '' - return `/api/generated/teaser?id=${encodeURIComponent(previewId)}&noGenerate=1&v=${v}` - }, [previewId, v]) + return `/api/generated/teaser?id=${encodeURIComponent(previewId)}&noGenerate=1` + }, [previewId]) // --- Inline Video sichtbar? const showingInlineVideo = diff --git a/frontend/src/components/ui/ModelDetails.tsx b/frontend/src/components/ui/ModelDetails.tsx index 5c1d085..50c2247 100644 --- a/frontend/src/components/ui/ModelDetails.tsx +++ b/frontend/src/components/ui/ModelDetails.tsx @@ -377,6 +377,34 @@ function pill(cls: string) { ) } +function heroOverlayPill(cls: string) { + return cn( + 'inline-flex items-center rounded-full px-2.5 py-1 text-[11px] font-semibold text-white', + 'ring-1 backdrop-blur-md shadow-sm', + '[text-shadow:0_1px_1px_rgba(0,0,0,0.45)]', + cls + ) +} + +function heroStatusPill(show: string) { + switch (show) { + case 'public': + return heroOverlayPill('bg-emerald-500/85 ring-emerald-300/25') + case 'private': + return heroOverlayPill('bg-fuchsia-500/85 ring-fuchsia-300/25') + case 'hidden': + return heroOverlayPill('bg-slate-900/70 ring-white/15') + case 'away': + return heroOverlayPill('bg-amber-500/85 ring-amber-300/25') + default: + return heroOverlayPill('bg-black/55 ring-white/15') + } +} + +function heroNewPill() { + return heroOverlayPill('bg-amber-500/90 ring-amber-300/25') +} + const previewBlurCls = (blur?: boolean) => blur ? 'blur-md scale-[1.03] brightness-90' : '' @@ -1400,30 +1428,6 @@ export default function ModelDetails({ />
)} - -
- {showPill ? ( - - {showPill} - - ) : null} - - {effectiveRoom?.is_new ? ( - - NEW - - ) : null} -
} width="max-w-6xl" @@ -1481,25 +1485,15 @@ export default function ModelDetails({ /> {/* Pills */} -
+
{showPill ? ( - + {showPill} ) : null} {effectiveRoom?.is_new ? ( - + NEW ) : null} @@ -1870,9 +1864,33 @@ export default function ModelDetails({ {/* Top row: name/username links + action icons rechts */}
-
-
- {effectiveRoom?.display_name || effectiveRoom?.username || model?.modelKey || key || '—'} +
+
+ + {effectiveRoom?.display_name || effectiveRoom?.username || model?.modelKey || key || '—'} + + + {showPill ? ( + + {showPill} + + ) : null} + + {effectiveRoom?.is_new ? ( + + NEW + + ) : null}
diff --git a/frontend/src/components/ui/ModelGenderIcon.tsx b/frontend/src/components/ui/ModelGenderIcon.tsx index 98c989a..4f7aa81 100644 --- a/frontend/src/components/ui/ModelGenderIcon.tsx +++ b/frontend/src/components/ui/ModelGenderIcon.tsx @@ -19,15 +19,14 @@ function normalizeGender( if (!raw) return null - if (raw.includes('couple') || raw.includes('pair')) { + if (raw.includes('couple') || raw.includes('pair') || raw === 'c') { return 'couple' } if ( raw.includes('trans') || raw.includes('transgender') || - raw.includes('ts') || - raw.includes('shemale') + raw === 't' ) { return 'trans' } @@ -35,7 +34,9 @@ function normalizeGender( if ( raw.includes('female') || raw.includes('girl') || - raw.includes('woman') + raw.includes('woman') || + raw === 'f' || + raw === 'w' ) { return 'female' } @@ -43,7 +44,8 @@ function normalizeGender( if ( raw.includes('male') || raw.includes('boy') || - raw.includes('man') + raw.includes('man') || + raw === 'm' ) { return 'male' } @@ -153,13 +155,22 @@ export default function ModelGenderIcon({ const { label, Icon } = map[kind] + const toneClass = + kind === 'female' + ? 'bg-pink-100 text-pink-700 ring-pink-200 dark:bg-pink-500/15 dark:text-pink-200 dark:ring-pink-400/25' + : kind === 'male' + ? 'bg-sky-100 text-sky-700 ring-sky-200 dark:bg-sky-500/15 dark:text-sky-200 dark:ring-sky-400/25' + : kind === 'trans' + ? 'bg-violet-100 text-violet-700 ring-violet-200 dark:bg-violet-500/15 dark:text-violet-200 dark:ring-violet-400/25' + : 'bg-gradient-to-r from-sky-100 to-pink-100 text-fuchsia-700 ring-fuchsia-200 dark:from-sky-500/15 dark:to-pink-500/15 dark:text-pink-200 dark:ring-pink-400/25' + return ( { const explicit = String(initialSrc ?? '').trim() - if (explicit) { - return explicit - } + const base = explicit || `/api/preview?id=${encodeURIComponent(jobId)}&file=preview.jpg` - const base = `/api/preview?id=${encodeURIComponent(jobId)}&file=preview.jpg` + const version = + typeof refreshKey === 'number' && Number.isFinite(refreshKey) + ? `${refreshKey}-${localRefreshNonce}` + : localRefreshNonce > 0 + ? String(localRefreshNonce) + : '' - if (typeof refreshKey === 'number') { - return `${base}&v=${refreshKey}` - } + if (!version) return base - return base - }, [initialSrc, jobId, refreshKey]) + return base.includes('?') + ? `${base}&v=${encodeURIComponent(version)}` + : `${base}?v=${encodeURIComponent(version)}` + }, [initialSrc, jobId, refreshKey, localRefreshNonce]) const fallbackImgSrc = useMemo(() => { const s = String(fallbackSrc ?? '').trim() - if (s) return s - return `/api/preview?id=${encodeURIComponent(jobId)}&fallbackOnly=1` - }, [fallbackSrc, jobId]) + const base = s || `/api/preview?id=${encodeURIComponent(jobId)}&fallbackOnly=1` + + const version = + typeof refreshKey === 'number' && Number.isFinite(refreshKey) + ? `${refreshKey}-${localRefreshNonce}` + : localRefreshNonce > 0 + ? String(localRefreshNonce) + : '' + + if (!version) return base + + return base.includes('?') + ? `${base}&v=${encodeURIComponent(version)}` + : `${base}?v=${encodeURIComponent(version)}` + }, [fallbackSrc, jobId, refreshKey, localRefreshNonce]) + + useEffect(() => { + if (!inView) return + + const id = window.setInterval(() => { + setLocalRefreshNonce((n) => n + 1) + }, 20000) + + return () => window.clearInterval(id) + }, [inView, jobId]) useEffect(() => { const ac = new AbortController() @@ -170,12 +198,21 @@ export default function ModelPreview({ useEffect(() => { setImgError(false) + setUsingFallback(false) setImgSrc(previewSrc) setLiveSrc('') setLivePreparing(false) setLiveReady(false) }, [jobId, previewSrc]) + useEffect(() => { + if (!usingFallback) return + if (!fallbackImgSrc) return + + setImgError(false) + setImgSrc(fallbackImgSrc) + }, [usingFallback, fallbackImgSrc]) + return ( { @@ -287,21 +324,26 @@ export default function ModelPreview({ loading="lazy" decoding="async" alt="" + referrerPolicy="no-referrer" className={['block w-full h-full', objectFitCls, 'object-center', blurCls].filter(Boolean).join(' ')} onLoad={() => { setImgError(false) + + const current = String(imgSrc || '').trim() + const fallback = String(fallbackImgSrc || '').trim() + setUsingFallback(Boolean(fallback && current === fallback)) }} onError={() => { const current = String(imgSrc || '').trim() const fallback = String(fallbackImgSrc || '').trim() - // Erst von preview.jpg auf Provider-Fallback wechseln if (fallback && current !== fallback) { + setUsingFallback(true) + setImgError(false) setImgSrc(fallback) return } - // Wenn selbst das Fallback nicht lädt: endgültig Fehler setImgError(true) }} /> diff --git a/frontend/src/components/ui/ModelsTab.tsx b/frontend/src/components/ui/ModelsTab.tsx index 869e6f7..5f2a8ca 100644 --- a/frontend/src/components/ui/ModelsTab.tsx +++ b/frontend/src/components/ui/ModelsTab.tsx @@ -14,6 +14,7 @@ import TagBadge from './TagBadge' import RecordJobActions from './RecordJobActions' import type { RecordJob } from '../../types' import TextInput from './TextInput' +import ModelGenderIcon from './ModelGenderIcon' type ParsedModel = { input: string @@ -51,6 +52,10 @@ export type StoredModel = { profileImageCached?: string profileImageUpdatedAt?: string + gender?: string | null + sex?: string | null + subgender?: string | null + watching: boolean favorite: boolean hot: boolean @@ -741,7 +746,16 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
-
{m.modelKey}
+
+
{m.modelKey}
+ +
+ {href ? ( -
- {m.modelKey} +
+
+ {m.modelKey} +
+ +
{m.host ? (
diff --git a/frontend/src/components/ui/Pagination.tsx b/frontend/src/components/ui/Pagination.tsx index 2e6aecc..6ba85d3 100644 --- a/frontend/src/components/ui/Pagination.tsx +++ b/frontend/src/components/ui/Pagination.tsx @@ -57,9 +57,9 @@ function PageButton({ className={clsx( 'relative inline-flex h-10 min-w-10 items-center justify-center px-3 text-sm font-semibold focus:z-20 focus:outline-offset-0', roundedCls, - disabled ? 'opacity-100 cursor-not-allowed' : 'cursor-pointer', - 'bg-white text-gray-900 inset-ring inset-ring-gray-300 hover:bg-gray-50', - 'dark:bg-slate-800 dark:text-gray-200 dark:inset-ring-slate-700 dark:hover:bg-slate-700' + disabled + ? 'opacity-100 cursor-not-allowed bg-white text-gray-400 inset-ring inset-ring-gray-200 dark:bg-slate-900 dark:text-gray-500 dark:inset-ring-slate-800' + : 'cursor-pointer bg-white text-gray-900 inset-ring inset-ring-gray-300 hover:bg-gray-50 dark:bg-slate-800 dark:text-gray-200 dark:inset-ring-slate-700 dark:hover:bg-slate-700' )} > {children} diff --git a/frontend/src/components/ui/RecorderSettings.tsx b/frontend/src/components/ui/RecorderSettings.tsx index f2f8cfe..8997213 100644 --- a/frontend/src/components/ui/RecorderSettings.tsx +++ b/frontend/src/components/ui/RecorderSettings.tsx @@ -120,6 +120,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { const [err, setErr] = useState(null) const [diskStatus, setDiskStatus] = useState(null) const assetsAbortRef = useRef(null) + const assetsCancelRequestedRef = useRef(false) const [dbModalOpen, setDbModalOpen] = useState(false) const [loadedDatabaseUrl, setLoadedDatabaseUrl] = useState('') const [pendingDbPassword, setPendingDbPassword] = useState('') // wird nur beim Speichern gesendet @@ -258,7 +259,19 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { const assetsLabel = String(assets?.currentLabel || '').trim() const assetsPhase = String(assets?.currentPhase || '').trim() - if (assets?.running) { + if (assets?.queued) { + setAssetsTask((t) => ({ + ...t, + status: 'running', + title: 'Assets generieren', + text: String(assets.text || 'Wartet…'), + done: Number(assets.done ?? 0), + total: Number(assets.total ?? 0), + err: undefined, + cancellable: false, + fading: false, + })) + } else if (assets?.running) { setAssetsTask((t) => ({ ...t, status: 'running', @@ -278,12 +291,16 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { assetsError.toLowerCase() === 'cancelled' || assetsError.toLowerCase() === 'canceled' + if (cancelled && assetsCancelRequestedRef.current) { + return + } + setAssetsTask((t) => { if (cancelled && (t.status === 'idle' || t.fading)) { return t } - const next: TaskItem = { + return { ...t, status: cancelled ? 'cancelled' : 'error', title: 'Assets generieren', @@ -294,10 +311,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { cancellable: false, fading: t.fading ?? false, } - - return next }) - } else if (assets?.text || assets?.finishedAt) { + } else if (assets?.finishedAt) { setAssetsTask((t) => ({ ...t, status: 'done', @@ -654,7 +669,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { } } - function fadeOutTask(setter: React.Dispatch>, delayMs = 3500, fadeMs = 500) { + function fadeOutTask(setter: React.Dispatch>, delayMs = 3000, fadeMs = 500) { window.setTimeout(() => { setter((t) => ({ ...t, fading: true })) window.setTimeout(() => { @@ -672,7 +687,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { }, delayMs) } - function fadeOutRegenerateTask(file: string, delayMs = 3500, fadeMs = 500) { + function fadeOutRegenerateTask(file: string, delayMs = 3000, fadeMs = 500) { const id = regenerateTaskId(file) window.setTimeout(() => { @@ -769,35 +784,48 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { } async function cancelAssetsTask() { - const ac = assetsAbortRef.current - assetsAbortRef.current = null + assetsCancelRequestedRef.current = true - // UI sofort - setAssetsTask((t: TaskItem) => ({ - ...t, - status: 'cancelled', - title: 'Assets generieren', - text: '', - err: undefined, - cancellable: false, - fading: false, - })) + const ac = assetsAbortRef.current + assetsAbortRef.current = null - fadeOutTask(setAssetsTask) + // UI ruhig ausblenden statt "Abgebrochen" sichtbar zu zeigen + setAssetsTask((t: TaskItem) => ({ + ...t, + cancellable: false, + fading: true, + })) - if (ac) { - ac.abort() - return + window.setTimeout(() => { + setAssetsTask((t: TaskItem) => ({ + ...t, + status: 'idle', + title: 'Assets generieren', + text: '', + err: undefined, + done: 0, + total: 0, + cancellable: true, + fading: false, + startedAtMs: 0, + })) + }, 500) + + if (ac) { + ac.abort() + return + } + + try { + await fetch('/api/tasks/generate-assets', { + method: 'DELETE', + cache: 'no-store' as any, + }) + } catch { + // ignore + } } - // Fallback (z.B. nach Reload, wenn kein Controller existiert): - try { - await fetch('/api/tasks/generate-assets', { method: 'DELETE', cache: 'no-store' as any }) - } catch { - // ignore - } -} - async function cancelRegenerateAssetsTask(file: string) { const cleanFile = String(file || '').trim() if (!cleanFile) return @@ -920,7 +948,23 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { stopUrl="/api/tasks/generate-assets" sseUrl="/api/events/stream" onFinished={onAssetsGenerated} + onQueued={() => { + assetsCancelRequestedRef.current = false + setAssetsTask((t: TaskItem) => ({ + ...t, + status: 'queued', + title: 'Assets generieren', + text: 'Wartet…', + done: 0, + total: 0, + err: undefined, + cancellable: true, + fading: false, + startedAtMs: t.startedAtMs || Date.now(), + })) + }} onStart={(ac) => { + assetsCancelRequestedRef.current = false assetsAbortRef.current = ac setAssetsTask((t: TaskItem) => ({ ...t, @@ -930,6 +974,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { done: 0, total: 0, err: undefined, + cancellable: true, fading: false, startedAtMs: t.startedAtMs || Date.now(), })) @@ -955,6 +1000,12 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { }} onCancelled={() => { assetsAbortRef.current = null + + if (assetsCancelRequestedRef.current) { + assetsCancelRequestedRef.current = false + return + } + setAssetsTask((t: TaskItem) => ({ ...t, status: 'cancelled', @@ -983,7 +1034,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { startingLabel="Läuft…" onTrigger={cleanupSmallDone} busy={cleaning} - disabled={saving || !value.autoDeleteSmallDownloads} + disabled={saving} onError={(message) => { // Optional: zusätzlicher Fallback für Startfehler-Anzeige direkt im Task setErr(message) @@ -1117,7 +1168,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
-
+
diff --git a/frontend/src/components/ui/Task.tsx b/frontend/src/components/ui/Task.tsx index 310e50f..3b09d56 100644 --- a/frontend/src/components/ui/Task.tsx +++ b/frontend/src/components/ui/Task.tsx @@ -41,6 +41,7 @@ type Props = { /** Callback-Hooks (nur für API/SSE-Variante relevant) */ onFinished?: () => void onStart?: (ac: AbortController) => void + onQueued?: () => void onProgress?: (p: Progress) => void onDone?: () => void onCancelled?: () => void @@ -85,6 +86,7 @@ export default function Task({ busy = false, onFinished, onStart, + onQueued, onProgress, onDone, onCancelled, @@ -155,12 +157,13 @@ export default function Task({ if (prev && !cur) { const errText = String(state?.error ?? '').trim() + const errTextNorm = errText.toLowerCase() // Reset pro Run abortRef.current = null armedRef.current = false - if (cancelledRef.current || errText === 'abgebrochen') { + if (cancelledRef.current || errTextNorm === 'abgebrochen') { cancelledRef.current = false onCancelled?.() } else if (errText) { @@ -191,9 +194,10 @@ export default function Task({ } const errText = String(st?.error ?? '').trim() + const errTextNorm = errText.toLowerCase() // Abbruch ist kein "Fehler"-Event für die UI - if (errText && errText !== 'abgebrochen' && errText !== lastErrorRef.current) { + if (errText && errTextNorm !== 'abgebrochen' && errText !== lastErrorRef.current) { lastErrorRef.current = errText onErrorRef.current?.(errText) } @@ -205,13 +209,16 @@ export default function Task({ async function start() { if (busy) return if (state?.running) return + if (starting) return setStartError(null) setStarting(true) cancelledRef.current = false lastErrorRef.current = '' - // 1) Lokaler Task-Modus (z.B. Cleanup) + onQueued?.() + + // 1) Lokaler Task-Modus if (onTrigger) { try { await onTrigger() @@ -225,7 +232,7 @@ export default function Task({ return } - // 2) API/SSE-Task-Modus (wie Assets) + // 2) API/SSE-Task-Modus if (!startUrl) { const msg = 'Task ist nicht konfiguriert (startUrl fehlt).' setStartError(msg) @@ -234,17 +241,16 @@ export default function Task({ return } - // Controller vorbereiten, aber TaskList erst nach erfolgreichem Start armieren const ac = ensureControllerCreated() try { - const st = await fetchJSON(startUrl, { method: 'POST' }) + const st = await fetchJSON(startUrl, { method: 'POST' }) setState(st) - // TaskList jetzt aktivieren - armTaskList(ac) - + // Nur wenn der Task wirklich schon läuft if (st?.running) { + armTaskList(ac) + onProgress?.({ done: st?.done ?? 0, total: st?.total ?? 0, @@ -252,7 +258,6 @@ export default function Task({ }) } } catch (e: any) { - // Start fehlgeschlagen -> Controller/Flags zurücksetzen abortRef.current = null armedRef.current = false diff --git a/frontend/src/components/ui/TaskList.tsx b/frontend/src/components/ui/TaskList.tsx index dbaf7b1..2cc9f0e 100644 --- a/frontend/src/components/ui/TaskList.tsx +++ b/frontend/src/components/ui/TaskList.tsx @@ -4,7 +4,7 @@ export type TaskItem = { id: string - status: 'idle' | 'running' | 'done' | 'error' | 'cancelled' + status: 'idle' | 'queued' | 'running' | 'done' | 'error' | 'cancelled' title: string text?: string done?: number @@ -60,26 +60,30 @@ export default function TaskList({ tasks, onCancel }: Props) { const hasProgress = isRunning && (t.total ?? 0) > 0 const title = (t.title ?? '').trim() const suffix = (t.text ?? '').trim() + const isQueued = t.status === 'queued' + const canCancel = (isRunning || isQueued) && t.cancellable && onCancel return (
-
- {/* Left icon: Cancel X (running+cancellable) OR green check (done) */} -
- {isRunning && t.cancellable && onCancel ? ( +
+ {/* Left action / icon column */} +
+ {canCancel ? ( ) : t.status === 'done' ? ( ) : ( - + )}
- {/* One-line row: title (optional) + status + progress */} -
-
- {title || 'Aufgabe'} - {suffix ? ( - - {' · '}{suffix} + {/* Right content */} +
+
+
+ {/* Mobile: Title oben */} +
+ {title || 'Aufgabe'} +
+ + {/* Desktop: Title + suffix in einer Zeile */} +
+ {title || 'Aufgabe'} + {suffix ? ( + + · {suffix} + + ) : null} +
+
+ + {t.status === 'done' ? ( + + Fertig + ) : t.status === 'cancelled' ? ( + + Abgebrochen + + ) : t.status === 'error' ? ( + + Fehler + + ) : null} + + {hasProgress ? ( +
+ {t.done ?? 0}/{t.total ?? 0} + {p}% + +
+
+
+
) : null}
- {/* Status badge (running bewusst ohne Badge) */} - {t.status === 'done' ? ( - - Fertig - - ) : t.status === 'cancelled' ? ( - - Abgebrochen - - ) : t.status === 'error' ? ( - - Fehler - + {/* Mobile: suffix unter dem Title */} + {suffix ? ( +
+ {suffix} +
) : null} - {/* Spacer */} -
- - {/* Progress compact on same line */} - {hasProgress ? ( -
- {t.done ?? 0}/{t.total ?? 0} - {p}% - - {/* Progressbar: auf Mobile ausblenden, ab sm anzeigen */} -
-
-
+ {t.status === 'error' && t.err ? ( +
+ {t.err}
) : null}
- - {/* Error text optional (unter der Zeile) */} - {t.status === 'error' && t.err ? ( -
- {t.err} -
- ) : null}
) })} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 4da6f82..bf2ca96 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -228,6 +228,9 @@ export type StoredModelFlags = { liked?: boolean | null watching?: boolean | null tags?: string + gender?: string | null + sex?: string | null + subgender?: string | null } export type GalleryModelFlags = StoredModelFlags & {