From e5f506ec45b6550681c93a71c8cd4a32c6ecb264 Mon Sep 17 00:00:00 2001 From: Linrador <68631622+Linrador@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:04:01 +0200 Subject: [PATCH] fixed autostart --- backend/assets_generate.go | 7 +- backend/assets_sprite.go | 2 +- backend/chaturbate_autostart.go | 144 +- backend/chaturbate_online.go | 57 +- backend/pending_autostart.go | 97 +- backend/preview.go | 1 + backend/record.go | 7 + backend/recorder.go | 1184 +++++++++-------- backend/tasks_assets.go | 95 +- frontend/src/App.tsx | 246 +++- .../src/components/ui/LastUpdatedText.tsx | 2 +- .../src/components/ui/RecorderSettings.tsx | 59 +- 12 files changed, 1150 insertions(+), 751 deletions(-) diff --git a/backend/assets_generate.go b/backend/assets_generate.go index 8e0fbde..4efe039 100644 --- a/backend/assets_generate.go +++ b/backend/assets_generate.go @@ -808,10 +808,8 @@ func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string) return nil } - if _, err := ensureTeaserForVideoCtx(ctx, videoPath, sourceURL); err != nil { - return fmt.Errorf("deferred teaser failed for %s: %w", videoPath, err) - } - + // NUR noch langsame Background-Schritte: + // 1) Sprite if _, err := ensureSpritesForVideoCtx(ctx, videoPath, sourceURL); err != nil { return fmt.Errorf("deferred sprite failed for %s: %w", videoPath, err) } @@ -821,6 +819,7 @@ func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string) return fmt.Errorf("preview-sprite.jpg fehlt nach deferred generation: %s", id) } + // 2) KI-Analyse analyzeReady, err := ensureAnalyzeForVideoCtx(ctx, videoPath, sourceURL, "nsfw") if err != nil { return fmt.Errorf("deferred analyze failed for %s: %w", videoPath, err) diff --git a/backend/assets_sprite.go b/backend/assets_sprite.go index 66505dd..d0cddc3 100644 --- a/backend/assets_sprite.go +++ b/backend/assets_sprite.go @@ -117,7 +117,7 @@ func generatePreviewSpriteJPG( "-i", videoPath, "-an", "-sn", - "-threads", "0", + "-threads", "2", "-vf", vf, "-frames:v", "1", "-update", "1", diff --git a/backend/chaturbate_autostart.go b/backend/chaturbate_autostart.go index 1b09b62..fed2075 100644 --- a/backend/chaturbate_autostart.go +++ b/backend/chaturbate_autostart.go @@ -275,6 +275,7 @@ func startChaturbateAutoStartWorker(store *ModelStore) { } showByUser := map[string]string{} + imageByUser := map[string]string{} seenInAPI := map[string]bool{} for _, r := range rooms { @@ -283,19 +284,26 @@ func startChaturbateAutoStartWorker(store *ModelStore) { continue } seenInAPI[u] = true - showByUser[u] = strings.ToLower(strings.TrimSpace(r.CurrentShow)) + showByUser[u] = normalizePendingShowServer(r.CurrentShow) + imageByUser[u] = selectBestRoomImageURL(r) } // laufende Jobs sammeln - running := map[string]bool{} + runningByUser := map[string]*RecordJob{} jobsMu.RLock() for _, j := range jobs { + if j == nil { + continue + } if !isActiveRecordingJob(j) { continue } + if detectProvider(strings.TrimSpace(j.SourceURL)) != "chaturbate" { + continue + } u := chaturbateUserFromURL(j.SourceURL) if u != "" { - running[u] = true + runningByUser[u] = j } } jobsMu.RUnlock() @@ -348,7 +356,7 @@ func startChaturbateAutoStartWorker(store *ModelStore) { if !ok { continue } - if running[it.userKey] { + if runningByUser[it.userKey] != nil { continue } @@ -364,68 +372,86 @@ func startChaturbateAutoStartWorker(store *ModelStore) { queued = nextQueued now := time.Now() + nextPending := make([]PendingAutoStartItem, 0, len(watchedByUser)) - // ------------------------------------------------------------ - // 1) watched + API sagt public => normal enqueue - // ------------------------------------------------------------ for user, m := range watchedByUser { - show := strings.ToLower(strings.TrimSpace(showByUser[user])) - if show == "" { - continue - } - if !strings.Contains(show, "public") { - continue - } - if running[user] { - continue - } - if queued[user] { - continue - } - if t, ok := lastTry[user]; ok && now.Sub(t) < retryCooldown { - continue - } - u := resolveChaturbateURL(m) if u == "" { continue } - queue = append(queue, autoStartItem{ - userKey: user, - url: u, - }) - queued[user] = true + show := normalizePendingShowServer(showByUser[user]) + img := strings.TrimSpace(imageByUser[user]) + + switch show { + case "public": + // public => kein Pending-Eintrag + if runningByUser[user] != nil { + continue + } + if queued[user] { + continue + } + if t, ok := lastTry[user]; ok && now.Sub(t) < retryCooldown { + continue + } + + queue = append(queue, autoStartItem{ + userKey: user, + url: u, + }) + queued[user] = true + + case "private", "hidden", "away": + // laufenden watched-Download beenden und auf public warten + nextPending = append(nextPending, PendingAutoStartItem{ + ModelKey: user, + URL: u, + Mode: "wait_public", + CurrentShow: show, + ImageURL: img, + Source: "watched", + }) + + if runningJob := runningByUser[user]; runningJob != nil { + stopJobsInternal([]*RecordJob{runningJob}) + } + + default: + // offline / unknown / nicht in API + nextProbeAtMs := now.Add(blindRetryCooldown).UnixMilli() + + nextPending = append(nextPending, PendingAutoStartItem{ + ModelKey: user, + URL: u, + Mode: "probe_retry", + NextProbeAtMs: nextProbeAtMs, + CurrentShow: show, + ImageURL: img, + Source: "watched", + }) + + if runningByUser[user] != nil { + continue + } + if queued[user] { + continue + } + if t, ok := lastBlindTry[user]; ok && now.Sub(t) < blindRetryCooldown { + continue + } + + queue = append(queue, autoStartItem{ + userKey: user, + url: u, + }) + queued[user] = true + } } - // ------------------------------------------------------------ - // 2) watched + NICHT in API => blind best-effort try vormerken - // ------------------------------------------------------------ - for user, m := range watchedByUser { - if seenInAPI[user] { - continue - } - if running[user] { - continue - } - if queued[user] { - continue - } - if t, ok := lastBlindTry[user]; ok && now.Sub(t) < blindRetryCooldown { - continue - } - - u := resolveChaturbateURL(m) - if u == "" { - continue - } - - queue = append(queue, autoStartItem{ - userKey: user, - url: u, - }) - queued[user] = true - } + pendingAutoStartMu.Lock() + _ = saveWatchedPendingAutoStartItems(nextPending) + pendingAutoStartMu.Unlock() case <-startTicker.C: if isAutostartPaused() { @@ -485,6 +511,10 @@ func startChaturbateAutoStartWorker(store *ModelStore) { continue } + pendingAutoStartMu.Lock() + _ = removeWatchedPendingAutoStartItem(it.userKey) + pendingAutoStartMu.Unlock() + if verboseLogs() { fmt.Println("▶️ [autostart] started:", it.url) } diff --git a/backend/chaturbate_online.go b/backend/chaturbate_online.go index cdecd97..ae4a895 100644 --- a/backend/chaturbate_online.go +++ b/backend/chaturbate_online.go @@ -671,12 +671,13 @@ type cbOnlineOutRoom struct { } type cbOnlineResponse struct { - Enabled bool `json:"enabled"` - FetchedAt time.Time `json:"fetchedAt"` - Count int `json:"count"` - Total int `json:"total"` - LastError string `json:"lastError"` - Rooms []cbOnlineOutRoom `json:"rooms"` + Enabled bool `json:"enabled"` + FetchedAt time.Time `json:"fetchedAt"` // letzter erfolgreicher Snapshot + LastAttempt time.Time `json:"lastAttempt"` // letzter Pull-Versuch + Count int `json:"count"` + Total int `json:"total"` + LastError string `json:"lastError"` + Rooms []cbOnlineOutRoom `json:"rooms"` } func hashKey(parts ...string) string { @@ -696,12 +697,6 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) { enabled := getSettings().UseChaturbateAPI - // UA vom Client (oder fallback) - reqUA := strings.TrimSpace(r.Header.Get("User-Agent")) - if reqUA == "" { - reqUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" - } - // --------------------------- // Request params (GET/POST) // --------------------------- @@ -837,25 +832,24 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) { b := (qIsHD == "1" || qIsHD == "true" || qIsHD == "yes") isHD = &b } + allowedShow = toSet(shows) } onlySpecificUsers := len(users) > 0 // --------------------------- - // Response Cache (2s) + // Response Cache // --------------------------- cacheKey := "cb_online:" + hashKey( fmt.Sprintf("enabled=%v", enabled), "users="+strings.Join(users, ","), "show="+strings.Join(keysOfSet(allowedShow), ","), - "gender="+strings.Join(keysOfSet(allowedGender), ","), "country="+strings.Join(keysOfSet(allowedCountry), ","), "tagsAny="+strings.Join(keysOfSet(allowedTagsAny), ","), "minUsers="+derefInt(minUsers), "isHD="+derefBool(isHD), - fmt.Sprintf("refresh=%v", wantRefresh), "lite=1", ) @@ -874,12 +868,13 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") out := cbOnlineResponse{ - Enabled: false, - FetchedAt: time.Time{}, - Count: 0, - Total: 0, - LastError: "", - Rooms: []cbOnlineOutRoom{}, + Enabled: false, + FetchedAt: time.Time{}, + LastAttempt: time.Time{}, + Count: 0, + Total: 0, + LastError: "", + Rooms: []cbOnlineOutRoom{}, } body, _ := json.Marshal(out) setCachedOnline(cacheKey, body) @@ -927,7 +922,7 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) { } cbRefreshMu.Unlock() } else if needBootstrap && time.Since(lastAttempt) >= bootstrapCooldown { - // ✅ Bootstrap darf weiterhin asynchron bleiben + // Bootstrap weiterhin asynchron cbRefreshMu.Lock() if cbRefreshInFlight { cbRefreshMu.Unlock() @@ -954,7 +949,7 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) { } } - // ✅ JETZT erst den finalen Snapshot lesen + // finalen Snapshot lesen cbMu.RLock() fetchedAt = cb.FetchedAt lastErr = cb.LastErr @@ -976,9 +971,8 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) { } // --------------------------- - // Rooms bauen (LITE, O(Anzahl requested Users)) + // Rooms bauen (LITE) // --------------------------- - matches := func(rm ChaturbateOnlineRoomLite) bool { if len(allowedShow) > 0 { s := strings.ToLower(strings.TrimSpace(rm.CurrentShow)) @@ -1065,12 +1059,13 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") out := cbOnlineResponse{ - Enabled: true, - FetchedAt: fetchedAt, - Count: len(outRooms), - Total: total, - LastError: lastErr, - Rooms: outRooms, + Enabled: true, + FetchedAt: fetchedAt, + LastAttempt: lastAttempt, + Count: len(outRooms), + Total: total, + LastError: lastErr, + Rooms: outRooms, } body, _ := json.Marshal(out) diff --git a/backend/pending_autostart.go b/backend/pending_autostart.go index 1dfd3e9..6147944 100644 --- a/backend/pending_autostart.go +++ b/backend/pending_autostart.go @@ -21,6 +21,7 @@ type PendingAutoStartItem struct { NextProbeAtMs int64 `json:"nextProbeAtMs,omitempty"` // nur bei probe_retry CurrentShow string `json:"currentShow,omitempty"` // public/private/hidden/away/offline/unknown ImageURL string `json:"imageUrl,omitempty"` + Source string `json:"source,omitempty"` // manual | watched } type PendingAutoStartResponse struct { @@ -33,6 +34,17 @@ type pendingAutoStartFile struct { var pendingAutoStartMu sync.Mutex +const pendingAutoStartGlobalUserKey = "__global__" + +func normalizePendingSourceServer(v string) string { + switch strings.TrimSpace(strings.ToLower(v)) { + case "watched": + return "watched" + default: + return "manual" + } +} + func normalizePendingModeServer(v string) string { if strings.TrimSpace(strings.ToLower(v)) == "probe_retry" { return "probe_retry" @@ -103,12 +115,94 @@ func loadPendingAutoStartItems(userKey string) ([]PendingAutoStartItem, error) { NextProbeAtMs: it.NextProbeAtMs, CurrentShow: normalizePendingShowServer(it.CurrentShow), ImageURL: strings.TrimSpace(it.ImageURL), + Source: normalizePendingSourceServer(it.Source), }) } return out, nil } +func loadMergedPendingAutoStartItems(userKey string) ([]PendingAutoStartItem, error) { + globalItems, err := loadPendingAutoStartItems(pendingAutoStartGlobalUserKey) + if err != nil { + return nil, err + } + + userItems, err := loadPendingAutoStartItems(userKey) + if err != nil { + return nil, err + } + + byKey := make(map[string]PendingAutoStartItem) + + for _, it := range globalItems { + k := strings.ToLower(strings.TrimSpace(it.ModelKey)) + if k == "" { + continue + } + byKey[k] = it + } + + // user-spezifisch überschreibt global + for _, it := range userItems { + k := strings.ToLower(strings.TrimSpace(it.ModelKey)) + if k == "" { + continue + } + byKey[k] = it + } + + out := make([]PendingAutoStartItem, 0, len(byKey)) + for _, it := range byKey { + out = append(out, it) + } + + return out, nil +} + +func saveWatchedPendingAutoStartItems(items []PendingAutoStartItem) error { + next := make([]PendingAutoStartItem, 0, len(items)) + + for _, it := range items { + it.ModelKey = strings.ToLower(strings.TrimSpace(it.ModelKey)) + it.URL = strings.TrimSpace(it.URL) + it.Mode = normalizePendingModeServer(it.Mode) + it.CurrentShow = normalizePendingShowServer(it.CurrentShow) + it.ImageURL = strings.TrimSpace(it.ImageURL) + it.Source = "watched" + + if it.ModelKey == "" || it.URL == "" { + continue + } + + next = append(next, it) + } + + return savePendingAutoStartItems(pendingAutoStartGlobalUserKey, next) +} + +func removeWatchedPendingAutoStartItem(modelKey string) error { + modelKey = strings.ToLower(strings.TrimSpace(modelKey)) + if modelKey == "" { + return nil + } + + items, err := loadPendingAutoStartItems(pendingAutoStartGlobalUserKey) + if err != nil { + return err + } + + next := make([]PendingAutoStartItem, 0, len(items)) + for _, it := range items { + if strings.ToLower(strings.TrimSpace(it.ModelKey)) == modelKey { + continue + } + next = append(next, it) + } + + return savePendingAutoStartItems(pendingAutoStartGlobalUserKey, next) +} + func savePendingAutoStartItems(userKey string, items []PendingAutoStartItem) error { path := pendingAutoStartFilePath(userKey) @@ -148,7 +242,7 @@ func handlePendingAutoStart(auth *AuthManager) http.HandlerFunc { switch r.Method { case http.MethodGet: - items, err := loadPendingAutoStartItems(userKey) + items, err := loadMergedPendingAutoStartItems(userKey) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -172,6 +266,7 @@ func handlePendingAutoStart(auth *AuthManager) http.HandlerFunc { in.Mode = normalizePendingModeServer(in.Mode) in.CurrentShow = normalizePendingShowServer(in.CurrentShow) in.ImageURL = strings.TrimSpace(in.ImageURL) + in.Source = normalizePendingSourceServer(in.Source) if in.ModelKey == "" { http.Error(w, "missing modelKey", http.StatusBadRequest) diff --git a/backend/preview.go b/backend/preview.go index b229df9..e349501 100644 --- a/backend/preview.go +++ b/backend/preview.go @@ -2310,6 +2310,7 @@ func generateTeaserMP4(ctx context.Context, srcPath, outPath string, startSec, d "-movflags", "+faststart", "-f", "mp4", + "-threads", "2", tmp, ) diff --git a/backend/record.go b/backend/record.go index 7db9175..be4ff24 100644 --- a/backend/record.go +++ b/backend/record.go @@ -1473,6 +1473,7 @@ func recordStatus(w http.ResponseWriter, r *http.Request) { jobsMu.RUnlock() applyPreviewSpriteTruthToRecordJobMeta(&c) + applyFinishedPhaseTruthToRecordJobMeta(&c) respondJSON(w, &c) } @@ -2300,7 +2301,13 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) { } c := *base + + // Sprite-Infos aus Dateisystem/meta ergänzen applyPreviewSpriteTruthToRecordJobMeta(&c) + + // WICHTIG: completed-Flags für Meta/Thumb/Teaser/Sprites/Analyze setzen + applyFinishedPhaseTruthToRecordJobMeta(&c) + out = append(out, &c) } diff --git a/backend/recorder.go b/backend/recorder.go index 068906d..8dde46e 100644 --- a/backend/recorder.go +++ b/backend/recorder.go @@ -513,12 +513,231 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) { return job, nil } +func requireNonEmptyRegularFile(path string, label string) error { + path = strings.TrimSpace(path) + if path == "" { + return fmt.Errorf("%s: path empty", label) + } + + fi, err := os.Stat(path) + if err != nil { + return fmt.Errorf("%s: %w", label, err) + } + if fi == nil || fi.IsDir() { + return fmt.Errorf("%s: not a file", label) + } + if fi.Size() <= 0 { + return fmt.Errorf("%s: empty file", label) + } + return nil +} + +func runPrimaryPostworkPipeline( + ctx context.Context, + job *RecordJob, + out string, + setPhase func(string, int), +) (string, error) { + out = strings.TrimSpace(out) + if err := requireNonEmptyRegularFile(out, "record output"); err != nil { + return out, err + } + + // 1) TS -> MP4 zuerst + if strings.EqualFold(filepath.Ext(out), ".ts") { + tsPath := out + + if setPhase != nil { + setPhase("remuxing", 0) + } + + fi, err := os.Stat(tsPath) + if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { + return out, fmt.Errorf("ts invalid before remux") + } + + inSize := fi.Size() + mp4Path := strings.TrimSuffix(tsPath, filepath.Ext(tsPath)) + ".mp4" + _ = os.Remove(mp4Path) + + dur := 0.0 + dctx, cancel := context.WithTimeout(ctx, 6*time.Second) + if d, derr := durationSecondsCached(dctx, tsPath); derr == nil && d > 0 { + dur = d + } + cancel() + + err = remuxTSToMP4WithProgress( + ctx, + tsPath, + mp4Path, + dur, + inSize, + func(r float64) { + if setPhase == nil { + return + } + if r < 0 { + r = 0 + } + if r > 1 { + r = 1 + } + setPhase("remuxing", int(math.Round(r*100))) + }, + ) + if err != nil { + return tsPath, fmt.Errorf("ts remux failed: %w", err) + } + + if err := requireNonEmptyRegularFile(mp4Path, "remux result"); err != nil { + _ = removeWithRetry(mp4Path) + return tsPath, err + } + + // best effort cleanup der alten TS + if err := removeWithRetry(tsPath); err != nil && !os.IsNotExist(err) { + fmt.Println("⚠️ ts cleanup after remux:", err) + } + purgeDurationCacheForPath(tsPath) + + out = mp4Path + + if fi2, err := os.Stat(out); err == nil && fi2 != nil && !fi2.IsDir() { + jobsMu.Lock() + job.Output = out + job.SizeBytes = fi2.Size() + jobsMu.Unlock() + } + } + + // 2) Danach nach done verschieben + if setPhase != nil { + setPhase("moving", 0) + } + + moved, err := moveToDoneDir(out) + if err != nil || strings.TrimSpace(moved) == "" { + if err != nil { + return out, fmt.Errorf("moveToDoneDir failed: %w", err) + } + return out, fmt.Errorf("moveToDoneDir returned empty path") + } + + out = strings.TrimSpace(moved) + + if err := requireNonEmptyRegularFile(out, "moved output"); err != nil { + return out, err + } + + jobsMu.Lock() + job.Output = out + if fi, err := os.Stat(out); err == nil && fi != nil && !fi.IsDir() { + job.SizeBytes = fi.Size() + } + jobsMu.Unlock() + + notifyDoneChanged() + + // 3) Danach meta.json sicher erzeugen + if setPhase != nil { + setPhase("probe", 15) + } + + if _, err := ensureMetaForVideoCtx(ctx, out, job.SourceURL); err != nil { + return out, fmt.Errorf("meta.json generation failed: %w", err) + } + + attachMetaToJobBestEffort(ctx, job, out) + + assetID := assetIDFromVideoPath(out) + if assetID == "" { + return out, fmt.Errorf("asset id missing after move") + } + + _, thumbPath, previewPath, _, metaPath, err := assetPathsForID(assetID) + if err != nil { + return out, err + } + + if err := requireNonEmptyRegularFile(metaPath, "meta.json"); err != nil { + return out, err + } + + // 4) Danach preview.jpg und 5) preview.mp4 + if setPhase != nil { + setPhase("assets", 0) + } + + lastPct := -1 + lastTick := time.Time{} + + update := func(r float64) { + if setPhase == nil { + return + } + if r < 0 { + r = 0 + } + if r > 1 { + r = 1 + } + + pct := int(math.Round(r * 100)) + if pct == lastPct { + return + } + if !lastTick.IsZero() && time.Since(lastTick) < 150*time.Millisecond { + return + } + + lastPct = pct + lastTick = time.Now() + setPhase("assets", pct) + } + + if _, err := ensurePrimaryAssetsForVideoWithProgressCtx(ctx, out, job.SourceURL, update); err != nil { + return out, fmt.Errorf("primary assets failed: %w", err) + } + + if err := requireNonEmptyRegularFile(thumbPath, "preview.jpg"); err != nil { + return out, err + } + if err := requireNonEmptyRegularFile(previewPath, "preview.mp4"); err != nil { + return out, err + } + + attachMetaToJobBestEffort(ctx, job, out) + + if setPhase != nil { + setPhase("assets", 100) + } + + return out, nil +} + func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { + now := ensureJobStartInitialized(job) + hc := NewHTTPClient(req.UserAgent) - provider := detectProvider(req.URL) + err := runProviderRecording(ctx, job, req, hc, now) - var err error + target, out := finalizeRecordingAttempt(job, err) + if !prepareOutputForPostwork(job, target, out) { + return + } + + out = strings.TrimSpace(job.Output) + if out == "" { + removeJobAndPublish(job) + return + } + + enqueuePostworkOrFail(job, out, target) +} + +func ensureJobStartInitialized(job *RecordJob) time.Time { now := job.StartedAt if now.IsZero() { now = time.Now() @@ -528,86 +747,118 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { base := job.StartedAt if base.IsZero() { base = time.Now() + jobsMu.Lock() job.StartedAt = base jobsMu.Unlock() } + jobsMu.Lock() job.StartedAtMs = base.UnixMilli() jobsMu.Unlock() } setJobProgress(job, "recording", 0) + return now +} - switch provider { +func runProviderRecording( + ctx context.Context, + job *RecordJob, + req RecordRequest, + hc *HTTPClient, + now time.Time, +) error { + switch detectProvider(req.URL) { case "chaturbate": - if !hasChaturbateCookies(req.Cookie) { - err = errors.New("cf_clearance und session_id (oder sessionid) Cookies sind für Chaturbate erforderlich") - break - } - - s := getSettings() - recordDirAbs, rerr := resolvePathRelativeToApp(s.RecordDir) - if rerr != nil || strings.TrimSpace(recordDirAbs) == "" { - err = fmt.Errorf("recordDir auflösung fehlgeschlagen: %v", rerr) - break - } - _ = os.MkdirAll(recordDirAbs, 0o755) - - username := extractUsername(req.URL) - filename := fmt.Sprintf("%s_%s.ts", username, now.Format("01_02_2006__15-04-05")) - - jobsMu.Lock() - existingOut := strings.TrimSpace(job.Output) - jobsMu.Unlock() - - outPath := existingOut - if outPath == "" || !filepath.IsAbs(outPath) { - outPath = filepath.Join(recordDirAbs, filename) - } - - if strings.TrimSpace(existingOut) != strings.TrimSpace(outPath) { - jobsMu.Lock() - job.Output = outPath - jobsMu.Unlock() - } - - markModelPublicWhileRecording(job) - err = RecordStream(ctx, hc, "https://chaturbate.com/", username, outPath, req.Cookie, job) - + return runChaturbateRecording(ctx, job, req, hc, now) case "mfc": - s := getSettings() - recordDirAbs, rerr := resolvePathRelativeToApp(s.RecordDir) - if rerr != nil || strings.TrimSpace(recordDirAbs) == "" { - err = fmt.Errorf("recordDir auflösung fehlgeschlagen: %v", rerr) - break - } - _ = os.MkdirAll(recordDirAbs, 0o755) + return runMFCRecording(ctx, job, req, hc, now) + default: + return errors.New("unsupported provider") + } +} - username := extractMFCUsername(req.URL) - filename := fmt.Sprintf("%s_%s.ts", username, now.Format("01_02_2006__15-04-05")) - outPath := filepath.Join(recordDirAbs, filename) +func runChaturbateRecording( + ctx context.Context, + job *RecordJob, + req RecordRequest, + hc *HTTPClient, + now time.Time, +) error { + if !hasChaturbateCookies(req.Cookie) { + return errors.New("cf_clearance und session_id (oder sessionid) Cookies sind für Chaturbate erforderlich") + } + s := getSettings() + recordDirAbs, err := resolvePathRelativeToApp(s.RecordDir) + if err != nil || strings.TrimSpace(recordDirAbs) == "" { + return fmt.Errorf("recordDir auflösung fehlgeschlagen: %v", err) + } + + _ = os.MkdirAll(recordDirAbs, 0o755) + + username := extractUsername(req.URL) + filename := fmt.Sprintf("%s_%s.ts", username, now.Format("01_02_2006__15-04-05")) + + jobsMu.Lock() + existingOut := strings.TrimSpace(job.Output) + jobsMu.Unlock() + + outPath := existingOut + if outPath == "" || !filepath.IsAbs(outPath) { + outPath = filepath.Join(recordDirAbs, filename) + } + + if strings.TrimSpace(existingOut) != strings.TrimSpace(outPath) { jobsMu.Lock() job.Output = outPath jobsMu.Unlock() - - err = RecordStreamMFC(ctx, hc, username, outPath, job) - - default: - err = errors.New("unsupported provider") } + markModelPublicWhileRecording(job) + + return RecordStream(ctx, hc, "https://chaturbate.com/", username, outPath, req.Cookie, job) +} + +func runMFCRecording( + ctx context.Context, + job *RecordJob, + req RecordRequest, + hc *HTTPClient, + now time.Time, +) error { + s := getSettings() + recordDirAbs, err := resolvePathRelativeToApp(s.RecordDir) + if err != nil || strings.TrimSpace(recordDirAbs) == "" { + return fmt.Errorf("recordDir auflösung fehlgeschlagen: %v", err) + } + + _ = os.MkdirAll(recordDirAbs, 0o755) + + username := extractMFCUsername(req.URL) + filename := fmt.Sprintf("%s_%s.ts", username, now.Format("01_02_2006__15-04-05")) + outPath := filepath.Join(recordDirAbs, filename) + + jobsMu.Lock() + job.Output = outPath + jobsMu.Unlock() + + return RecordStreamMFC(ctx, hc, username, outPath, job) +} + +func finalizeRecordingAttempt(job *RecordJob, runErr error) (JobStatus, string) { end := time.Now() target := JobFinished var errText string - if err != nil { - if errors.Is(err, context.Canceled) { + + if runErr != nil { + if errors.Is(runErr, context.Canceled) { target = JobStopped } else { target = JobFailed - errText = err.Error() + errText = runErr.Error() } } @@ -624,106 +875,108 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { out := strings.TrimSpace(job.Output) jobsMu.Unlock() - // ------------------------------------------------------------ - // HARTE SCHRANKE 1: gar kein Output-Pfad - // ------------------------------------------------------------ - if out == "" { - jobsMu.Lock() - job.Phase = "" - job.Progress = 100 - job.PostWorkKey = "" - job.PostWork = nil - jobsMu.Unlock() + return target, out +} - jobsMu.Lock() - delete(jobs, job.ID) - jobsMu.Unlock() - publishJobRemove(job) - return +func prepareOutputForPostwork(job *RecordJob, target JobStatus, out string) bool { + if strings.TrimSpace(out) == "" { + clearJobPostworkState(job) + removeJobAndPublish(job) + return false } - // ------------------------------------------------------------ - // HARTE SCHRANKE 2: Output prüfen, aber gestoppte Downloads - // nicht aggressiv wegwerfen - // ------------------------------------------------------------ - { - var ( - fi os.FileInfo - serr error - ) - - if target == JobStopped { - fi, serr = waitForUsableOutput(out, 3*time.Second) - } else { - fi, serr = os.Stat(out) - } - - if serr != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { - // Bei STOP nicht sofort wegwerfen – nur wenn wirklich nichts da ist - if target == JobStopped && shouldKeepStoppedJobForInspection(target, out) { - jobsMu.Lock() - job.Phase = "" - job.Progress = 100 - jobsMu.Unlock() - return - } - - _ = removeWithRetry(out) - purgeDurationCacheForPath(out) - - jobsMu.Lock() - delete(jobs, job.ID) - jobsMu.Unlock() - - publishJobRemove(job) - return - } + if !ensureUsableRecordedOutput(job, target, out) { + return false } - // ------------------------------------------------------------ - // AUTO DELETE SMALL DOWNLOADS - // ------------------------------------------------------------ - { - fi, serr := os.Stat(out) - if serr == nil && fi != nil && !fi.IsDir() { - jobsMu.Lock() - job.SizeBytes = fi.Size() - jobsMu.Unlock() - - s := getSettings() - minMB := s.AutoDeleteSmallDownloadsBelowMB - - if s.AutoDeleteSmallDownloads && minMB > 0 { - threshold := int64(minMB) * 1024 * 1024 - - if fi.Size() > 0 && fi.Size() < threshold { - base := filepath.Base(out) - id := stripHotPrefix(strings.TrimSuffix(base, filepath.Ext(base))) - - derr := removeWithRetry(out) - if derr == nil || os.IsNotExist(derr) { - removeGeneratedForID(id) - purgeDurationCacheForPath(out) - - jobsMu.Lock() - delete(jobs, job.ID) - jobsMu.Unlock() - - publishJobRemove(job) - return - } - - fmt.Println("⚠️ auto-delete before enqueue failed:", derr) - } - } - } + if maybeDeleteSmallRecordedOutput(job, out) { + return false } - // ------------------------------------------------------------ - // POSTWORK QUEUE - // ------------------------------------------------------------ - postOut := out - postTarget := target + return true +} + +func ensureUsableRecordedOutput(job *RecordJob, target JobStatus, out string) bool { + var ( + fi os.FileInfo + err error + ) + + if target == JobStopped { + fi, err = waitForUsableOutput(out, 3*time.Second) + } else { + fi, err = os.Stat(out) + } + + if err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 { + return true + } + + if target == JobStopped && shouldKeepStoppedJobForInspection(target, out) { + clearJobPostworkState(job) + return false + } + + _ = removeWithRetry(out) + purgeDurationCacheForPath(out) + removeJobAndPublish(job) + return false +} + +func maybeDeleteSmallRecordedOutput(job *RecordJob, out string) bool { + fi, err := os.Stat(out) + if err != nil || fi == nil || fi.IsDir() { + return false + } + + jobsMu.Lock() + job.SizeBytes = fi.Size() + jobsMu.Unlock() + + s := getSettings() + minMB := s.AutoDeleteSmallDownloadsBelowMB + if !s.AutoDeleteSmallDownloads || minMB <= 0 { + return false + } + + threshold := int64(minMB) * 1024 * 1024 + if fi.Size() <= 0 || fi.Size() >= threshold { + return false + } + + base := filepath.Base(out) + id := stripHotPrefix(strings.TrimSuffix(base, filepath.Ext(base))) + + if derr := removeWithRetry(out); derr == nil || os.IsNotExist(derr) { + removeGeneratedForID(id) + purgeDurationCacheForPath(out) + removeJobAndPublish(job) + return true + } else { + fmt.Println("⚠️ auto-delete before enqueue failed:", derr) + } + + return false +} + +func clearJobPostworkState(job *RecordJob) { + jobsMu.Lock() + job.Phase = "" + job.Progress = 100 + job.PostWorkKey = "" + job.PostWork = nil + jobsMu.Unlock() +} + +func removeJobAndPublish(job *RecordJob) { + jobsMu.Lock() + delete(jobs, job.ID) + jobsMu.Unlock() + + publishJobRemove(job) +} + +func enqueuePostworkOrFail(job *RecordJob, out string, postTarget JobStatus) { postKey := "postwork:" + job.ID postFile := filepath.Base(out) postAssetID := assetIDFromVideoPath(out) @@ -741,423 +994,51 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { Key: postKey, Added: time.Now(), Run: func(ctx context.Context) error { - { - st := postWorkQ.StatusForKey(postKey) - jobsMu.Lock() - job.PostWork = &st - jobsMu.Unlock() - - setJobProgress(job, "postwork", 0) - - publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ - File: postFile, - AssetID: postAssetID, - Queue: "postwork", - State: "running", - Phase: "postwork", - Label: "Nachbearbeitung läuft…", - Position: st.Position, - Waiting: st.Waiting, - Running: st.Running, - MaxParallel: st.MaxParallel, - }) - } - - out := strings.TrimSpace(postOut) - - if out == "" { - jobsMu.Lock() - job.Status = postTarget - job.Phase = "" - job.Progress = 100 - job.PostWorkKey = "" - job.PostWork = nil - jobsMu.Unlock() - - jobsMu.Lock() - delete(jobs, job.ID) - jobsMu.Unlock() - publishJobRemove(job) - return nil - } - - setPhase := func(phase string, pct int) { - setJobProgress(job, phase, pct) - st := postWorkQ.StatusForKey(postKey) - - jobsMu.Lock() - job.PostWork = &st - jobsMu.Unlock() - - label := "Nachbearbeitung läuft…" - switch strings.ToLower(strings.TrimSpace(phase)) { - case "remuxing": - label = "Remux läuft…" - case "moving": - label = "Verschiebe Datei…" - case "probe": - label = "Analysiere Datei…" - case "assets": - label = "Erstelle Preview…" - case "analyze": - label = "Analysiere Inhalt…" - } - - publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ - File: filepath.Base(out), - AssetID: assetIDFromVideoPath(out), - Queue: "postwork", - State: "running", - Phase: phase, - Label: label, - Position: st.Position, - Waiting: st.Waiting, - Running: st.Running, - MaxParallel: st.MaxParallel, - }) - } - - // 1) Erst nach done verschieben - setPhase("moving", 10) - - moved, err2 := moveToDoneDir(out) - if err2 != nil || strings.TrimSpace(moved) == "" { - jobsMu.Lock() - job.Status = JobFailed - if err2 != nil { - job.Error = "moveToDoneDir failed: " + err2.Error() - } else { - job.Error = "moveToDoneDir returned empty path" - } - job.Output = out - job.Phase = "" - job.Progress = 100 - job.PostWorkKey = "" - job.PostWork = nil - jobsMu.Unlock() - - if shouldKeepStoppedJobForInspection(postTarget, out) { - _ = publishJob(job.ID) - - publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ - File: filepath.Base(out), - AssetID: assetIDFromVideoPath(out), - Queue: "postwork", - State: "error", - Phase: "moving", - Label: "Verschieben nach done fehlgeschlagen", - }) - return nil - } - - jobsMu.Lock() - delete(jobs, job.ID) - jobsMu.Unlock() - publishJobRemove(job) - - publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ - File: filepath.Base(out), - AssetID: assetIDFromVideoPath(out), - Queue: "postwork", - State: "error", - Phase: "moving", - Label: "Verschieben nach done fehlgeschlagen", - }) - return nil - } - - out = strings.TrimSpace(moved) - jobsMu.Lock() - job.Output = out - jobsMu.Unlock() - notifyDoneChanged() - - // 2) Nach move prüfen - { - fi, serr := os.Stat(out) - if serr != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { - _ = removeWithRetry(out) - purgeDurationCacheForPath(out) - - jobsMu.Lock() - delete(jobs, job.ID) - jobsMu.Unlock() - - publishJobRemove(job) - - publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ - File: filepath.Base(out), - AssetID: assetIDFromVideoPath(out), - Queue: "postwork", - State: "error", - Phase: "moving", - Label: "Nachbearbeitung fehlgeschlagen", - }) - return nil - } - } - - // 3) ✅ TS -> MP4 Remux in den Nacharbeiten - if strings.EqualFold(filepath.Ext(out), ".ts") { - setPhase("remuxing", 35) - - remuxed, rerr := maybeRemuxTS(out) - if rerr != nil || strings.TrimSpace(remuxed) == "" { - jobsMu.Lock() - job.Status = JobFailed - if rerr != nil { - job.Error = "TS remux failed: " + rerr.Error() - } else { - job.Error = "TS remux returned empty path" - } - job.Output = out // originale .ts behalten - job.Phase = "" - job.Progress = 100 - job.PostWorkKey = "" - job.PostWork = nil - jobsMu.Unlock() - - if shouldKeepStoppedJobForInspection(postTarget, out) { - _ = publishJob(job.ID) - - publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ - File: filepath.Base(out), - AssetID: assetIDFromVideoPath(out), - Queue: "postwork", - State: "error", - Phase: "remuxing", - Label: "TS Remux fehlgeschlagen", - }) - return nil - } - - jobsMu.Lock() - delete(jobs, job.ID) - jobsMu.Unlock() - publishJobRemove(job) - - publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ - File: filepath.Base(out), - AssetID: assetIDFromVideoPath(out), - Queue: "postwork", - State: "error", - Phase: "remuxing", - Label: "TS Remux fehlgeschlagen", - }) - return nil - } - - out = strings.TrimSpace(remuxed) - - fi, serr := os.Stat(out) - if serr != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 || !strings.EqualFold(filepath.Ext(out), ".mp4") { - jobsMu.Lock() - job.Status = JobFailed - job.Error = "TS remux result invalid" - job.Phase = "" - job.Progress = 100 - job.PostWorkKey = "" - job.PostWork = nil - jobsMu.Unlock() - - if shouldKeepStoppedJobForInspection(postTarget, out) { - _ = publishJob(job.ID) - - publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ - File: filepath.Base(out), - AssetID: assetIDFromVideoPath(out), - Queue: "postwork", - State: "error", - Phase: "remuxing", - Label: "Remux-Ergebnis ungültig", - }) - return nil - } - - jobsMu.Lock() - delete(jobs, job.ID) - jobsMu.Unlock() - publishJobRemove(job) - - publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ - File: filepath.Base(out), - AssetID: assetIDFromVideoPath(out), - Queue: "postwork", - State: "error", - Phase: "remuxing", - Label: "Remux-Ergebnis ungültig", - }) - return nil - } - - jobsMu.Lock() - job.Output = out - job.SizeBytes = fi.Size() - jobsMu.Unlock() - notifyDoneChanged() - } - - // 4) DURATION - setPhase("probe", 60) - { - dctx, cancel := context.WithTimeout(ctx, 6*time.Second) - if sec, derr := durationSecondsCached(dctx, out); derr == nil && sec > 0 { - jobsMu.Lock() - job.DurationSeconds = sec - jobsMu.Unlock() - } - cancel() - } - - // 5) VIDEO PROPS - setPhase("probe", 85) - { - pctx, cancel := context.WithTimeout(ctx, 6*time.Second) - w, h, fps, perr := probeVideoProps(pctx, out) - cancel() - if perr == nil { - jobsMu.Lock() - job.VideoWidth = w - job.VideoHeight = h - job.FPS = fps - jobsMu.Unlock() - } - } - - // 6) PRIMARY ASSETS - setPhase("assets", 0) - - lastPct := -1 - lastTick := time.Time{} - - update := func(r float64) { - if r < 0 { - r = 0 - } - if r > 1 { - r = 1 - } - - pct := int(math.Round(r * 100)) - if pct < 0 { - pct = 0 - } - if pct > 100 { - pct = 100 - } - - if pct == lastPct { - return - } - if !lastTick.IsZero() && time.Since(lastTick) < 150*time.Millisecond { - return - } - - lastPct = pct - lastTick = time.Now() - setPhase("assets", pct) - } - - if _, err := ensurePrimaryAssetsForVideoWithProgressCtx(ctx, out, job.SourceURL, update); err != nil { - fmt.Println("⚠️ ensurePrimaryAssetsForVideo:", err) - } - setPhase("assets", 100) - - // FINALIZE PRIMARY POSTWORK - finalFile := filepath.Base(out) - finalAssetID := assetIDFromVideoPath(out) - - jobsMu.Lock() - job.Status = postTarget - job.Phase = "" - job.Progress = 100 - job.PostWorkKey = "" - job.PostWork = nil - jobsMu.Unlock() - - if postTarget == JobStopped { - _ = publishJob(job.ID) - } else { - jobsMu.Lock() - delete(jobs, job.ID) - jobsMu.Unlock() - - publishJobRemove(job) - } - - publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ - File: finalFile, - AssetID: finalAssetID, - Queue: "postwork", - State: "done", - Label: "Primäre Nachbearbeitung fertig", - }) - - 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", - }) - } - - return nil + return runQueuedPostwork(ctx, job, out, postTarget, postKey) }, }) if okQueued { - st := postWorkQ.StatusForKey(postKey) - jobsMu.Lock() - job.PostWork = &st - jobsMu.Unlock() + publishQueuedPostworkState(job, postKey, postFile, postAssetID) + return + } - publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ - File: postFile, - AssetID: postAssetID, - Queue: "postwork", - State: "queued", - Phase: "postwork", - Label: "Warte auf Nachbearbeitung…", - Position: st.Position, - Waiting: st.Waiting, - Running: st.Running, - MaxParallel: st.MaxParallel, - }) - } else { - jobsMu.Lock() - job.Status = postTarget - job.Phase = "" - job.Progress = 100 - job.PostWorkKey = "" - job.PostWork = nil - job.Error = "Nachbearbeitung konnte nicht eingeplant werden" - jobsMu.Unlock() + handlePostworkEnqueueFailure(job, out, postTarget, postFile, postAssetID) +} - // Gestoppte Downloads mit existierender Datei nicht sofort aus der UI werfen - if shouldKeepStoppedJobForInspection(postTarget, out) { - _ = publishJob(job.ID) +func publishQueuedPostworkState(job *RecordJob, postKey, postFile, postAssetID string) { + st := postWorkQ.StatusForKey(postKey) - publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ - File: postFile, - AssetID: postAssetID, - Queue: "postwork", - State: "error", - Phase: "postwork", - Label: "Nachbearbeitung konnte nicht eingeplant werden", - }) - return - } + jobsMu.Lock() + job.PostWork = &st + jobsMu.Unlock() - jobsMu.Lock() - delete(jobs, job.ID) - jobsMu.Unlock() + publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ + File: postFile, + AssetID: postAssetID, + Queue: "postwork", + State: "queued", + Phase: "postwork", + Label: "Warte auf Nachbearbeitung…", + Position: st.Position, + Waiting: st.Waiting, + Running: st.Running, + MaxParallel: st.MaxParallel, + }) +} - publishJobRemove(job) +func handlePostworkEnqueueFailure(job *RecordJob, out string, postTarget JobStatus, postFile, postAssetID string) { + jobsMu.Lock() + job.Status = postTarget + job.Phase = "" + job.Progress = 100 + job.PostWorkKey = "" + job.PostWork = nil + job.Error = "Nachbearbeitung konnte nicht eingeplant werden" + jobsMu.Unlock() + + if shouldKeepStoppedJobForInspection(postTarget, out) { + _ = publishJob(job.ID) publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ File: postFile, @@ -1167,5 +1048,196 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { Phase: "postwork", Label: "Nachbearbeitung konnte nicht eingeplant werden", }) + return + } + + removeJobAndPublish(job) + + publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ + File: postFile, + AssetID: postAssetID, + Queue: "postwork", + State: "error", + Phase: "postwork", + Label: "Nachbearbeitung konnte nicht eingeplant werden", + }) +} + +func runQueuedPostwork( + ctx context.Context, + job *RecordJob, + initialOut string, + postTarget JobStatus, + postKey string, +) error { + out := strings.TrimSpace(initialOut) + if out == "" { + clearJobPostworkState(job) + removeJobAndPublish(job) + return nil + } + + publishRunningPostworkStart(job, postKey, out) + + setPhase := buildPostworkPhaseUpdater(job, postKey, &out) + + var err error + out, err = runPrimaryPostworkPipeline(ctx, job, out, setPhase) + if err != nil { + handlePrimaryPostworkFailure(job, out, postTarget, err) + return nil + } + + handlePrimaryPostworkSuccess(job, out, postTarget) + return nil +} + +func publishRunningPostworkStart(job *RecordJob, postKey, out string) { + st := postWorkQ.StatusForKey(postKey) + + jobsMu.Lock() + job.PostWork = &st + jobsMu.Unlock() + + setJobProgress(job, "postwork", 0) + + publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ + File: filepath.Base(out), + AssetID: assetIDFromVideoPath(out), + Queue: "postwork", + State: "running", + Phase: "postwork", + Label: "Nachbearbeitung läuft…", + Position: st.Position, + Waiting: st.Waiting, + Running: st.Running, + MaxParallel: st.MaxParallel, + }) +} + +func buildPostworkPhaseUpdater(job *RecordJob, postKey string, out *string) func(string, int) { + return func(phase string, pct int) { + setJobProgress(job, phase, pct) + + st := postWorkQ.StatusForKey(postKey) + + jobsMu.Lock() + job.PostWork = &st + jobsMu.Unlock() + + label := "Nachbearbeitung läuft…" + switch strings.ToLower(strings.TrimSpace(phase)) { + case "remuxing": + label = "Remux läuft…" + case "moving": + label = "Verschiebe Datei…" + case "probe": + label = "Analysiere Datei…" + case "assets": + label = "Erstelle Preview…" + case "analyze": + label = "Analysiere Inhalt…" + } + + currentOut := "" + if out != nil { + currentOut = strings.TrimSpace(*out) + } + + publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ + File: filepath.Base(currentOut), + AssetID: assetIDFromVideoPath(currentOut), + Queue: "postwork", + State: "running", + Phase: phase, + Label: label, + Position: st.Position, + Waiting: st.Waiting, + Running: st.Running, + MaxParallel: st.MaxParallel, + }) } } + +func handlePrimaryPostworkFailure(job *RecordJob, out string, postTarget JobStatus, err error) { + finalStatus := JobFailed + if postTarget == JobStopped { + finalStatus = JobStopped + } + + jobsMu.Lock() + job.Status = finalStatus + job.Error = err.Error() + job.Output = out + job.Phase = "" + job.Progress = 100 + job.PostWorkKey = "" + job.PostWork = nil + jobsMu.Unlock() + + if shouldKeepStoppedJobForInspection(postTarget, out) { + _ = publishJob(job.ID) + + publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ + File: filepath.Base(out), + AssetID: assetIDFromVideoPath(out), + Queue: "postwork", + State: "error", + Phase: "postwork", + Label: "Primäre Nachbearbeitung fehlgeschlagen", + }) + return + } + + publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ + File: filepath.Base(out), + AssetID: assetIDFromVideoPath(out), + Queue: "postwork", + State: "error", + Phase: "postwork", + Label: "Primäre Nachbearbeitung fehlgeschlagen", + }) + + removeJobAndPublish(job) +} + +func handlePrimaryPostworkSuccess(job *RecordJob, out string, postTarget JobStatus) { + finalFile := filepath.Base(out) + finalAssetID := assetIDFromVideoPath(out) + + jobsMu.Lock() + job.Status = postTarget + job.Phase = "" + job.Progress = 100 + job.PostWorkKey = "" + job.PostWork = nil + jobsMu.Unlock() + + publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ + File: finalFile, + AssetID: finalAssetID, + Queue: "postwork", + State: "done", + Label: "Primäre Nachbearbeitung fertig", + }) + + 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 { + _ = publishJob(job.ID) + return + } + + removeJobAndPublish(job) +} diff --git a/backend/tasks_assets.go b/backend/tasks_assets.go index 80fe74d..3539ed6 100644 --- a/backend/tasks_assets.go +++ b/backend/tasks_assets.go @@ -257,50 +257,37 @@ func runGenerateMissingAssets(ctx context.Context) { } // Initialisierung: Total etc. + SSE Push - updateAssetsState(func(st *AssetsTaskState) { - st.Total = len(items) - st.Done = 0 - st.GeneratedThumbs = 0 - st.GeneratedPreviews = 0 - st.Skipped = 0 - st.Error = "" - }) + type assetCandidate struct { + name string + path string + id string + beforeTruth finishedPhaseTruth + } - for i, it := range items { + candidates := make([]assetCandidate, 0, len(items)) + skippedCount := 0 + + for _, it := range items { if err := ctx.Err(); err != nil { finishWithErr(err) return } - // aktuellen Dateinamen für UI setzen - updateAssetsState(func(st *AssetsTaskState) { - st.CurrentFile = it.name - }) - - // ID aus Dateiname base := strings.TrimSuffix(it.name, filepath.Ext(it.name)) id := stripHotPrefix(base) if strings.TrimSpace(id) == "" { clearAssetsTaskFinishedPostworkStates(it.name) - - updateAssetsState(func(st *AssetsTaskState) { - st.Done = i + 1 - }) + skippedCount++ continue } - // Datei-Info (validieren) vfi, verr := os.Stat(it.path) - if verr != nil || vfi.IsDir() || vfi.Size() <= 0 { + if verr != nil || vfi == nil || vfi.IsDir() || vfi.Size() <= 0 { clearAssetsTaskFinishedPostworkStates(it.name) - - updateAssetsState(func(st *AssetsTaskState) { - st.Done = i + 1 - }) + skippedCount++ continue } - // Vorher-Wahrheit ermitteln: was fehlt wirklich? beforeTruth := finishedPhaseTruthForID(id) needMeta := !beforeTruth.MetaReady @@ -309,17 +296,69 @@ func runGenerateMissingAssets(ctx context.Context) { needSprites := !beforeTruth.SpritesReady needAnalyze := !beforeTruth.AnalyzeReady - // Wenn gar nichts fehlt -> alte evtl. Live-States wegräumen und skippen + // Bereits vollständig -> gar nicht erst in die Arbeitsliste aufnehmen if !needMeta && !needThumb && !needTeaser && !needSprites && !needAnalyze { clearAssetsTaskFinishedPostworkStates(it.name) + skippedCount++ + continue + } + + candidates = append(candidates, assetCandidate{ + name: it.name, + path: it.path, + id: id, + beforeTruth: beforeTruth, + }) + } + + updateAssetsState(func(st *AssetsTaskState) { + st.Total = len(candidates) + st.Done = 0 + st.GeneratedThumbs = 0 + st.GeneratedPreviews = 0 + st.Skipped = skippedCount + st.Error = "" + st.CurrentFile = "" + st.CurrentQueue = "" + st.CurrentPhase = "" + st.CurrentLabel = "" + }) + + if len(candidates) == 0 { + finishWithErr(nil) + return + } + + for i, it := range candidates { + if err := ctx.Err(); err != nil { + finishWithErr(err) + return + } + + updateAssetsState(func(st *AssetsTaskState) { + st.CurrentFile = it.name + }) + + id := it.id + + vfi, verr := os.Stat(it.path) + if verr != nil || vfi == nil || vfi.IsDir() || vfi.Size() <= 0 { + clearAssetsTaskFinishedPostworkStates(it.name) updateAssetsState(func(st *AssetsTaskState) { - st.Skipped++ st.Done = i + 1 }) continue } + beforeTruth := it.beforeTruth + + needMeta := !beforeTruth.MetaReady + needThumb := !beforeTruth.ThumbReady + needTeaser := !beforeTruth.TeaserReady + needSprites := !beforeTruth.SpritesReady + needAnalyze := !beforeTruth.AnalyzeReady + // Pfade einmalig über zentralen Helper _, _, _, _, metaPath, perr := assetPathsForID(id) if perr != nil { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index add413c..7a7ff08 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -190,6 +190,7 @@ type ChaturbateOnlineRoomItem = { type ChaturbateOnlineResponse = { enabled?: boolean fetchedAt?: string + lastAttempt?: string count?: number total?: number lastError?: string @@ -204,6 +205,8 @@ type AutostartState = { type PendingAutoStartMode = 'wait_public' | 'probe_retry' +type PendingAutoStartSource = 'manual' | 'watched' + type PendingAutoStartItem = { modelKey: string url: string @@ -211,6 +214,7 @@ type PendingAutoStartItem = { nextProbeAtMs?: number currentShow?: 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' imageUrl?: string + source?: PendingAutoStartSource } type PendingAutoStartResponse = { @@ -393,6 +397,11 @@ function stripHotPrefix(name: string) { const reModel = /^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}/ function chaturbateModelKeyFromJob(job: RecordJob): string { + const direct = String((job as any)?.model ?? (job as any)?.modelKey ?? '') + .trim() + .toLowerCase() + if (direct) return direct + const raw = String((job as any)?.sourceUrl ?? '').trim() const norm0 = normalizeHttpUrl(raw) const norm = norm0 ? canonicalizeProviderUrl(norm0) : '' @@ -400,7 +409,9 @@ function chaturbateModelKeyFromJob(job: RecordJob): string { const keyFromUrl = providerKeyLowerFromUrl(norm) if (keyFromUrl) return keyFromUrl - const keyFromFile = (modelKeyFromFilename((job as any)?.output || '') || '').trim().toLowerCase() + const keyFromFile = (modelKeyFromFilename((job as any)?.output || '') || '') + .trim() + .toLowerCase() return keyFromFile } @@ -418,6 +429,11 @@ function modelKeyFromFilename(fileOrPath: string): string | null { } function modelEventKeyFromJob(job: RecordJob): string { + const direct = String((job as any)?.model ?? (job as any)?.modelKey ?? '') + .trim() + .toLowerCase() + if (direct) return direct + const raw = String((job as any)?.sourceUrl ?? '').trim() const norm0 = normalizeHttpUrl(raw) const norm = norm0 ? canonicalizeProviderUrl(norm0) : '' @@ -425,7 +441,9 @@ function modelEventKeyFromJob(job: RecordJob): string { const keyFromUrl = providerKeyLowerFromUrl(norm) if (keyFromUrl) return keyFromUrl - const keyFromFile = (modelKeyFromFilename((job as any)?.output || '') || '').trim().toLowerCase() + const keyFromFile = (modelKeyFromFilename((job as any)?.output || '') || '') + .trim() + .toLowerCase() return keyFromFile } @@ -435,7 +453,9 @@ function modelEventKeyFromDoneJob(job: RecordJob): string { } function modelKeyAndHostFromJob(job: RecordJob): { modelKey: string; host: string } { - const keyFromFile = (modelKeyFromFilename((job as any)?.output || '') || '').trim().toLowerCase() + const direct = String((job as any)?.model ?? (job as any)?.modelKey ?? '') + .trim() + .toLowerCase() let host = '' try { @@ -449,6 +469,14 @@ function modelKeyAndHostFromJob(job: RecordJob): { modelKey: string; host: strin // ignore } + if (direct) { + return { modelKey: direct, host } + } + + const keyFromFile = (modelKeyFromFilename((job as any)?.output || '') || '') + .trim() + .toLowerCase() + if (keyFromFile) { return { modelKey: keyFromFile, host } } @@ -583,6 +611,13 @@ export default function App() { const [pendingAutoStartModeByKey, setPendingAutoStartModeByKey] = useState>({}) const [pendingBlindRetryAtByKey, setPendingBlindRetryAtByKey] = useState>({}) + const [pendingAutoStartSourceByKey, setPendingAutoStartSourceByKey] = useState>({}) + + const pendingAutoStartSourceByKeyRef = useRef(pendingAutoStartSourceByKey) + useEffect(() => { + pendingAutoStartSourceByKeyRef.current = pendingAutoStartSourceByKey + }, [pendingAutoStartSourceByKey]) + const pendingAutoStartModeByKeyRef = useRef(pendingAutoStartModeByKey) useEffect(() => { pendingAutoStartModeByKeyRef.current = pendingAutoStartModeByKey @@ -687,10 +722,12 @@ export default function App() { setPendingAutoStartByKey({}) setPendingAutoStartModeByKey({}) setPendingBlindRetryAtByKey({}) + setPendingAutoStartSourceByKey({}) pendingAutoStartByKeyRef.current = {} pendingAutoStartModeByKeyRef.current = {} pendingBlindRetryAtByKeyRef.current = {} + pendingAutoStartSourceByKeyRef.current = {} // optional: URL-Feld leeren setSourceUrl('') @@ -924,12 +961,15 @@ export default function App() { const nextByKey: Record = {} const nextModeByKey: Record = {} const nextBlindRetryAtByKey: Record = {} + const nextSourceByKey: Record = {} for (const item of items) { const keyLower = String(item?.modelKey ?? '').trim().toLowerCase() const norm0 = normalizeHttpUrl(String(item?.url ?? '')) const mode: PendingAutoStartMode = String(item?.mode ?? '').trim() === 'probe_retry' ? 'probe_retry' : 'wait_public' + const source: PendingAutoStartSource = + String(item?.source ?? '').trim() === 'watched' ? 'watched' : 'manual' if (!keyLower || !norm0) continue @@ -937,6 +977,7 @@ export default function App() { nextByKey[keyLower] = norm nextModeByKey[keyLower] = mode + nextSourceByKey[keyLower] = source if (mode === 'probe_retry') { const ts = Number(item?.nextProbeAtMs ?? 0) @@ -953,6 +994,9 @@ export default function App() { setPendingBlindRetryAtByKey(nextBlindRetryAtByKey) pendingBlindRetryAtByKeyRef.current = nextBlindRetryAtByKey + + setPendingAutoStartSourceByKey(nextSourceByKey) + pendingAutoStartSourceByKeyRef.current = nextSourceByKey } catch { // ignore } finally { @@ -1072,6 +1116,8 @@ export default function App() { return { ...(prevJob ?? ({} as RecordJob)), id: jobId, + model: msg.model ?? (prevJob as any)?.model, + status: (msg.status as any) ?? (prevJob?.status ?? 'running'), sourceUrl: msg.sourceUrl ?? ((prevJob as any)?.sourceUrl ?? ''), output: msg.output ?? (prevJob?.output ?? ''), @@ -1681,6 +1727,7 @@ export default function App() { const alreadyRunning = jobsRef.current.some((j) => { if (String(j.status || '').toLowerCase() !== 'running') return false if ((j as any).endedAt) return false + const jNorm0 = normalizeHttpUrl(String((j as any).sourceUrl || '')) const jNorm = jNorm0 ? canonicalizeProviderUrl(jNorm0) : '' return jNorm === normUrl @@ -1697,7 +1744,9 @@ export default function App() { } if (provider === 'chaturbate' && !hasRequiredChaturbateCookies(currentCookies)) { - if (!silent) setError('Für Chaturbate müssen die Cookies "cf_clearance" und "sessionId" gesetzt sein.') + if (!silent) { + setError('Für Chaturbate müssen die Cookies "cf_clearance" und "sessionId" gesetzt sein.') + } return false } @@ -1715,13 +1764,20 @@ export default function App() { }), }) - if (created?.id && !opts?.hidden) { - startedToastByJobIdRef.current[String(created.id)] = true + // Wichtig: model lokal sofort mitschleppen, damit requiredModelKeys / + // overview / watched/fav/liked schon vor SSE korrekt funktionieren. + const localModelKey = providerKeyLowerFromUrl(normUrl) + const createdWithModel = localModelKey + ? ({ ...created, model: localModelKey } as RecordJob) + : created + + if ((createdWithModel as any)?.id && !opts?.hidden) { + startedToastByJobIdRef.current[String((createdWithModel as any).id)] = true } - if (!(opts?.hidden || Boolean((created as any)?.hidden))) { + if (!(opts?.hidden || Boolean((createdWithModel as any)?.hidden))) { setJobs((prev) => { - const next = upsertJobById(prev, created) + const next = upsertJobById(prev, createdWithModel) jobsRef.current = next return next }) @@ -1833,11 +1889,17 @@ export default function App() { [requiredModelKeys] ) - - - const lastOverviewSigRef = useRef('') + const requiredModelKeysRef = useRef(requiredModelKeys) useEffect(() => { + requiredModelKeysRef.current = requiredModelKeys + }, [requiredModelKeys]) + + const lastOverviewSigRef = useRef(null) + + useEffect(() => { + if (!authed) return + const scheduleOverviewLoad = () => { if (overviewTimerRef.current != null) { window.clearTimeout(overviewTimerRef.current) @@ -1870,8 +1932,6 @@ export default function App() { } catch {} setPlayerModel((prev) => (prev?.id === updated.id ? updated : prev)) - - // counts/headerStats trotzdem später sauber nachziehen scheduleOverviewLoad() return } @@ -1908,7 +1968,7 @@ export default function App() { overviewTimerRef.current = null } } - }, [loadAppModelsSnapshot, requiredModelKeysSig, requiredModelKeys, upsertModelCache]) + }, [authed, loadAppModelsSnapshot, requiredModelKeysSig, requiredModelKeys, upsertModelCache]) const queuePendingAutoStart = useCallback(( keyLower: string, @@ -1918,6 +1978,7 @@ export default function App() { opts?: { mode?: PendingAutoStartMode nextProbeAtMs?: number + source?: PendingAutoStartSource } ) => { const key = String(keyLower ?? '').trim().toLowerCase() @@ -1926,11 +1987,31 @@ export default function App() { const norm = canonicalizeProviderUrl(norm0) const mode: PendingAutoStartMode = opts?.mode ?? 'wait_public' + const source: PendingAutoStartSource = opts?.source === 'watched' ? 'watched' : 'manual' + const nextProbeAtMs = mode === 'probe_retry' ? Number(opts?.nextProbeAtMs ?? (Date.now() + 60_000)) : undefined + const nextShow = normalizePendingShow(show) + const nextRetryAt = + mode === 'probe_retry' && Number.isFinite(nextProbeAtMs) + ? Number(nextProbeAtMs) + : 0 + + // ✅ ALTE Werte VOR dem State-Update lesen + const prevUrl = String(pendingAutoStartByKeyRef.current[key] ?? '').trim() + const prevMode = (pendingAutoStartModeByKeyRef.current[key] ?? 'wait_public') as PendingAutoStartMode + const prevSource = (pendingAutoStartSourceByKeyRef.current[key] ?? 'manual') as PendingAutoStartSource + const prevRetryAt = Number(pendingBlindRetryAtByKeyRef.current[key] ?? 0) + + const sameUrl = prevUrl === norm + const sameMode = prevMode === mode + const sameSource = prevSource === source + const sameRetryAt = + mode !== 'probe_retry' || Math.abs(prevRetryAt - nextRetryAt) < 1000 + setPendingAutoStartByKey((prev) => { const next = { ...(prev || {}), [key]: norm } pendingAutoStartByKeyRef.current = next @@ -1943,11 +2024,17 @@ export default function App() { return next }) + setPendingAutoStartSourceByKey((prev) => { + const next = { ...(prev || {}), [key]: source } + pendingAutoStartSourceByKeyRef.current = next + return next + }) + setPendingBlindRetryAtByKey((prev) => { const next = { ...(prev || {}) } if (mode === 'probe_retry') { - next[key] = Number.isFinite(nextProbeAtMs) ? Number(nextProbeAtMs) : Date.now() + 60_000 + next[key] = nextRetryAt || (Date.now() + 60_000) } else { delete next[key] } @@ -1961,7 +2048,7 @@ export default function App() { id: key, modelKey: key, url: norm, - currentShow: normalizePendingShow(show), + currentShow: nextShow, imageUrl: imageUrl || undefined, } @@ -1978,20 +2065,8 @@ export default function App() { return [nextItem, ...prev] }) - const prevUrl = String(pendingAutoStartByKeyRef.current[key] ?? '').trim() - const prevMode = String(pendingAutoStartModeByKeyRef.current[key] ?? 'wait_public').trim() - const prevRetryAt = Number(pendingBlindRetryAtByKeyRef.current[key] ?? 0) - const nextShow = normalizePendingShow(show) - const nextRetryAt = mode === 'probe_retry' - ? (Number.isFinite(nextProbeAtMs) ? Number(nextProbeAtMs) : 0) - : 0 - - const sameUrl = prevUrl === norm - const sameMode = prevMode === mode - const sameRetryAt = - mode !== 'probe_retry' || Math.abs(prevRetryAt - nextRetryAt) < 1000 - - if (sameUrl && sameMode && sameRetryAt) { + // ✅ Nur persistieren, wenn sich wirklich etwas geändert hat + if (sameUrl && sameMode && sameSource && sameRetryAt) { return } @@ -2002,9 +2077,10 @@ export default function App() { modelKey: key, url: norm, mode, - nextProbeAtMs: mode === 'probe_retry' ? nextProbeAtMs : undefined, + nextProbeAtMs: mode === 'probe_retry' ? nextRetryAt : undefined, currentShow: nextShow, imageUrl: imageUrl || undefined, + source, }), }).catch(() => {}) }, []) @@ -2016,7 +2092,8 @@ export default function App() { const hadPending = Boolean(pendingAutoStartByKeyRef.current[key]) || Boolean(pendingAutoStartModeByKeyRef.current[key]) || - Boolean(pendingBlindRetryAtByKeyRef.current[key]) + Boolean(pendingBlindRetryAtByKeyRef.current[key]) || + Boolean(pendingAutoStartSourceByKeyRef.current[key]) if (!hadPending) return @@ -2041,6 +2118,13 @@ export default function App() { return next }) + setPendingAutoStartSourceByKey((prev) => { + const next = { ...(prev || {}) } + delete next[key] + pendingAutoStartSourceByKeyRef.current = next + return next + }) + setPendingWatchedRooms((prev) => prev.filter((x) => String(x.modelKey ?? '').trim().toLowerCase() !== key) ) @@ -2170,7 +2254,6 @@ export default function App() { useEffect(() => { if (!recSettings.useChaturbateApi) { - setCbOnlineFetchedAt('') return } @@ -2253,6 +2336,9 @@ export default function App() { const mode = (modeMap[key] ?? 'wait_public') as PendingAutoStartMode + const source = + (pendingAutoStartSourceByKeyRef.current[key] ?? 'manual') as PendingAutoStartSource + if (snap.show === 'public') { enqueueStart({ url, @@ -2272,6 +2358,7 @@ export default function App() { ) { queuePendingAutoStart(key, url, snap.show, snap.imageUrl, { mode: 'wait_public', + source, }) continue } @@ -2287,6 +2374,7 @@ export default function App() { queuePendingAutoStart(key, url, snap.show, snap.imageUrl, { mode: 'probe_retry', nextProbeAtMs: Date.now() + 60_000, + source, }) } @@ -2373,18 +2461,28 @@ export default function App() { useEffect(() => { if (!authed || !recSettings.useChaturbateApi) { setCbOnlineFetchedAt('') + + setHeaderStats((prev) => ({ + ...prev, + onlineModelsCount: 0, + onlineWatchedModelsCount: 0, + onlineFavCount: 0, + onlineLikedCount: 0, + })) + return } let cancelled = false let timer: number | null = null + let lastSeenFetchedAt = '' - const tick = async () => { + const tick = async (refresh = false) => { try { - const onlineRes = await fetch('/api/chaturbate/online', { - method: 'GET', - cache: 'no-store', - }) + const onlineRes = await fetch( + refresh ? '/api/chaturbate/online?refresh=1' : '/api/chaturbate/online', + { method: 'GET', cache: 'no-store' } + ) if (!onlineRes.ok) return @@ -2392,33 +2490,54 @@ export default function App() { if (cancelled || !data) return const fetchedAt = String(data.fetchedAt ?? '').trim() - if (fetchedAt && fetchedAt !== '0001-01-01T00:00:00Z') { - setCbOnlineFetchedAt(fetchedAt) + const safeFetchedAt = + fetchedAt && fetchedAt !== '0001-01-01T00:00:00Z' ? fetchedAt : '' + + const totalRaw = Number(data.total ?? 0) + const total = Number.isFinite(totalRaw) && totalRaw >= 0 ? totalRaw : 0 + + // Online-Gesamtcounter sofort aus der Online-API aktualisieren + setHeaderStats((prev) => { + if (prev.onlineModelsCount === total) return prev + return { + ...prev, + onlineModelsCount: total, + } + }) + + setCbOnlineFetchedAt(safeFetchedAt) + + // Derselbe erfolgreiche Snapshot triggert den Refresh + // der watched/fav/like Counter + modelsByKey + if (safeFetchedAt && safeFetchedAt !== lastSeenFetchedAt) { + lastSeenFetchedAt = safeFetchedAt + void loadAppModelsSnapshot(requiredModelKeysRef.current) } } catch { // ignore } } - void tick() + void tick(true) timer = window.setInterval(() => { - void tick() + void tick(false) }, 10_000) return () => { cancelled = true if (timer != null) window.clearInterval(timer) } - }, [authed, recSettings.useChaturbateApi]) + }, [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 norm0 = normalizeHttpUrl(rawUrl) if (!norm0) return false - const norm = canonicalizeProviderUrl(norm0) + const norm = canonicalizeProviderUrl(norm0) const silent = Boolean(opts?.silent) + if (!silent) setError(null) const provider = getProviderFromNormalizedUrl(norm) @@ -2430,7 +2549,9 @@ export default function App() { const currentCookies = cookiesRef.current if (provider === 'chaturbate' && !hasRequiredChaturbateCookies(currentCookies)) { - if (!silent) setError('Für Chaturbate müssen die Cookies "cf_clearance" und "sessionId" gesetzt sein.') + if (!silent) { + setError('Für Chaturbate müssen die Cookies "cf_clearance" und "sessionId" gesetzt sein.') + } return false } @@ -2438,7 +2559,7 @@ export default function App() { const alreadyRunning = jobsRef.current.some((j) => { if (String(j.status || '').toLowerCase() !== 'running') return false - // ✅ Wenn endedAt existiert: Aufnahme ist fertig -> Postwork/Queue -> NICHT blocken + // Wenn endedAt existiert: Aufnahme ist fertig -> Postwork/Queue -> NICHT blocken if ((j as any).endedAt) return false const jNorm0 = normalizeHttpUrl(String((j as any).sourceUrl || '')) @@ -2447,7 +2568,7 @@ export default function App() { }) if (alreadyRunning) return true - // ✅ Chaturbate-Startlogik mit Online-API: + // Chaturbate-Startlogik mit Online-API: // public -> direkt starten // private/hidden/away -> wait_public // offline/unknown -> probe_retry mit hidden start @@ -2472,7 +2593,10 @@ export default function App() { method: 'POST', cache: 'no-store', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ q: [mkLower] }), + body: JSON.stringify({ + q: [mkLower], + refresh: true, + }), }) if (onlineRes.ok) { @@ -2509,6 +2633,7 @@ export default function App() { // 2) in API + nicht public => warten bis public queuePendingAutoStart(mkLower, norm, resolvedShow, resolvedImageUrl, { mode: 'wait_public', + source: 'manual', }) return true } else { @@ -2516,6 +2641,7 @@ export default function App() { queuePendingAutoStart(mkLower, norm, resolvedShow, resolvedImageUrl, { mode: 'probe_retry', nextProbeAtMs: Date.now() + 60_000, + source: 'manual', }) const ok = await doStartNow(norm, silent, { hidden: true }) @@ -2532,6 +2658,7 @@ export default function App() { queuePendingAutoStart(mkLower, norm, 'unknown', undefined, { mode: 'probe_retry', nextProbeAtMs: Date.now() + 60_000, + source: 'manual', }) const ok = await doStartNow(norm, silent, { hidden: true }) @@ -2552,23 +2679,36 @@ export default function App() { const created = await apiJSON('/api/record', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url: norm, cookie: cookieString }), + body: JSON.stringify({ + url: norm, + cookie: cookieString, + }), }) - // ✅ verhindert Doppel-Toast: StartUrl toastet ggf. schon selbst, + // Wichtig: model lokal sofort mitschleppen, damit overview / modelsByKey + // nicht erst nach SSE oder Dateinamen-Erkennung korrekt werden. + const localModelKey = providerKeyLowerFromUrl(norm) + const createdWithModel = localModelKey + ? ({ ...created, model: localModelKey } as RecordJob) + : created + + // verhindert Doppel-Toast: StartUrl toastet ggf. schon selbst, // und kurz danach kommt der Job nochmal über SSE/polling rein. - if (created?.id) startedToastByJobIdRef.current[String(created.id)] = true + if ((createdWithModel as any)?.id) { + startedToastByJobIdRef.current[String((createdWithModel as any).id)] = true + } setJobs((prev) => { - const next = upsertJobById(prev, created) + const next = upsertJobById(prev, createdWithModel) jobsRef.current = next return next }) + return true } catch (e: any) { const msg = e?.message ?? String(e) - // ✅ Spezialfall: Age-Gate / Cloudflare / kein Room-HTML => Cookies Hinweis + // Spezialfall: Age-Gate / Cloudflare / kein Room-HTML => Cookies Hinweis if (isCookieGateError(msg)) { showMissingCookiesMessage({ silent }) return false diff --git a/frontend/src/components/ui/LastUpdatedText.tsx b/frontend/src/components/ui/LastUpdatedText.tsx index 1c101fd..19736d8 100644 --- a/frontend/src/components/ui/LastUpdatedText.tsx +++ b/frontend/src/components/ui/LastUpdatedText.tsx @@ -31,7 +31,7 @@ export default function LastUpdatedText({ fetchedAt, enabled = true }: Props) { useEffect(() => { if (!enabled) return - const t = window.setInterval(() => setNow(Date.now()), 10_000) + const t = window.setInterval(() => setNow(Date.now()), 1_000) return () => window.clearInterval(t) }, [enabled]) diff --git a/frontend/src/components/ui/RecorderSettings.tsx b/frontend/src/components/ui/RecorderSettings.tsx index 77cfc89..419b059 100644 --- a/frontend/src/components/ui/RecorderSettings.tsx +++ b/frontend/src/components/ui/RecorderSettings.tsx @@ -81,6 +81,35 @@ function regenerateTaskTitle(file: string) { return short ? `Assets neu generieren · ${short}` : 'Assets neu generieren' } +function assetsPhaseLabel(label?: string, phase?: string) { + const direct = String(label ?? '').trim() + if (direct) return direct + + switch (String(phase ?? '').trim().toLowerCase()) { + case 'meta': + return 'Meta' + case 'thumb': + return 'Vorschaubild' + case 'teaser': + return 'Teaser' + case 'sprites': + return 'Sprites' + case 'analyze': + return 'KI-Analyse' + default: + return '' + } +} + +function assetsTaskText(file?: string, label?: string, phase?: string) { + const fn = shortTaskFilename(file, 38) + const phaseText = assetsPhaseLabel(label, phase) + + if (fn && phaseText) return `${fn} · ${phaseText}` + if (fn) return fn + return phaseText +} + export default function RecorderSettings({ onAssetsGenerated }: Props) { const [value, setValue] = useState(DEFAULTS) const [saving, setSaving] = useState(false) @@ -224,13 +253,16 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { if (!alive || !data) return const assets = data.generateAssets + const assetsFile = String(assets?.currentFile || assets?.text || '').trim() + const assetsLabel = String(assets?.currentLabel || '').trim() + const assetsPhase = String(assets?.currentPhase || '').trim() if (assets?.running) { setAssetsTask((t) => ({ ...t, status: 'running', title: 'Assets generieren', - text: shortTaskFilename(String(assets.currentFile || assets.text || '')), + text: assetsTaskText(assetsFile, assetsLabel, assetsPhase), done: Number(assets.done ?? 0), total: Number(assets.total ?? 0), err: undefined, @@ -246,7 +278,6 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { assetsError.toLowerCase() === 'canceled' setAssetsTask((t) => { - // Wenn schon idle oder bereits am Ausfaden: nicht wieder "sichtbar reaktivieren" if (cancelled && (t.status === 'idle' || t.fading)) { return t } @@ -255,7 +286,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { ...t, status: cancelled ? 'cancelled' : 'error', title: 'Assets generieren', - text: shortTaskFilename(String(assets.currentFile || assets.text || '')), + text: assetsTaskText(assetsFile, assetsLabel, assetsPhase), done: Number(assets.done ?? 0), total: Number(assets.total ?? 0), err: cancelled ? undefined : assetsError, @@ -270,25 +301,13 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { ...t, status: 'done', title: 'Assets generieren', - text: shortTaskFilename(String(assets.currentFile || assets.text || '')), + text: assetsTaskText(assetsFile, assetsLabel, assetsPhase), done: Number(assets.done ?? 0), total: Number(assets.total ?? 0), err: undefined, cancellable: false, fading: false, })) - } else { - setAssetsTask((t) => ({ - ...t, - status: 'idle', - title: 'Assets generieren', - text: '', - done: 0, - total: 0, - err: undefined, - cancellable: true, - fading: false, - })) } const regenJobs = Array.isArray(data?.regenerateAssetsJobs) @@ -870,7 +889,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
{ - const fn = shortTaskFilename(p.currentFile) + const currentFile = String((p as any).currentFile ?? '').trim() + const currentLabel = String((p as any).currentLabel ?? '').trim() + const currentPhase = String((p as any).currentPhase ?? '').trim() setAssetsTask((t: TaskItem) => ({ ...t, status: 'running', title: 'Assets generieren', - text: fn || '', + text: assetsTaskText(currentFile, currentLabel, currentPhase), done: p.done, total: p.total, }))