From 80b9f6959729fdea03c82855926db6f3824bee85 Mon Sep 17 00:00:00 2001 From: Linrador <68631622+Linrador@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:39:53 +0100 Subject: [PATCH] updated sse --- backend/assets_generate.go | 2 - backend/chaturbate_online.go | 38 --- backend/disk_guard.go | 13 - backend/live.go | 10 - backend/main.go | 15 - backend/postwork.go | 4 - backend/record.go | 207 ------------ backend/record_stream_cb.go | 1 - backend/record_stream_mfc.go | 1 - backend/recorder.go | 26 -- backend/recorder_settings.json | 4 +- backend/sse.go | 80 ++--- backend/tasks_assets.go | 7 +- backend/tasks_cleanup.go | 17 + backend/tasks_regenerate_assets.go | 314 ++++++++++++++++++ backend/tasks_status.go | 17 + frontend/package.json | 2 +- frontend/src/App.tsx | 154 ++++----- .../src/components/ui/RecorderSettings.tsx | 45 ++- frontend/src/components/ui/TaskList.tsx | 6 +- 20 files changed, 466 insertions(+), 497 deletions(-) create mode 100644 backend/tasks_regenerate_assets.go diff --git a/backend/assets_generate.go b/backend/assets_generate.go index 1202e6d..fdc912f 100644 --- a/backend/assets_generate.go +++ b/backend/assets_generate.go @@ -935,8 +935,6 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU fmt.Printf("⚠️ preview clips skipped (invalid/incomplete input): %s\n", videoPath) return } - - fmt.Println("⚠️ preview clips:", err) return } diff --git a/backend/chaturbate_online.go b/backend/chaturbate_online.go index 1dc52f6..8882d9c 100644 --- a/backend/chaturbate_online.go +++ b/backend/chaturbate_online.go @@ -300,8 +300,6 @@ func syncChaturbateRoomStateIntoModelStore(store *ModelStore, rooms []Chaturbate img, fetchedAt, ) - - publishJobUpsertsForModelKey(modelKey) } // bekannte Chaturbate-Models, die NICHT im Online-Snapshot sind => offline setzen @@ -330,33 +328,6 @@ func syncChaturbateRoomStateIntoModelStore(store *ModelStore, rooms []Chaturbate "", fetchedAt, ) - - publishJobUpsertsForModelKey(modelKey) - } -} - -func publishJobUpsertsForModelKey(modelKey string) { - modelKey = strings.ToLower(strings.TrimSpace(modelKey)) - if modelKey == "" { - return - } - - jobsMu.Lock() - list := make([]*RecordJob, 0, len(jobs)) - for _, j := range jobs { - if j == nil || j.Hidden { - continue - } - if sseModelEventNameForJob(j) != modelKey { - continue - } - c := *j - list = append(list, &c) - } - jobsMu.Unlock() - - for _, j := range list { - publishJobUpsert(j) } } @@ -736,7 +707,6 @@ func refreshRunningJobsHLS(userLower string, newHls string, cookie string, ua st } toStop := make([]*RecordJob, 0, 4) - changedJobs := make([]*RecordJob, 0, 4) jobsMu.Lock() for _, j := range jobs { @@ -759,20 +729,12 @@ func refreshRunningJobsHLS(userLower string, newHls string, cookie string, ua st j.PreviewStateMsg = "" toStop = append(toStop, j) } - - if !j.Hidden { - changedJobs = append(changedJobs, j) - } } jobsMu.Unlock() for _, j := range toStop { stopPreview(j) } - - for _, j := range changedJobs { - publishJobUpsert(j) - } } func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) { diff --git a/backend/disk_guard.go b/backend/disk_guard.go index 5d121ef..78460b7 100644 --- a/backend/disk_guard.go +++ b/backend/disk_guard.go @@ -86,7 +86,6 @@ func stopJobsInternal(list []*RecordJob) { } pl := make([]payload, 0, len(list)) - visibleJobs := make([]*RecordJob, 0, len(list)) jobsMu.Lock() for _, job := range list { @@ -97,17 +96,9 @@ func stopJobsInternal(list []*RecordJob) { job.Progress = 10 pl = append(pl, payload{cmd: job.previewCmd, cancel: job.cancel}) job.previewCmd = nil - - if !job.Hidden { - visibleJobs = append(visibleJobs, job) - } } jobsMu.Unlock() - for _, job := range visibleJobs { - publishJobUpsert(job) - } - for _, p := range pl { if p.cmd != nil && p.cmd.Process != nil { _ = p.cmd.Process.Kill() @@ -116,10 +107,6 @@ func stopJobsInternal(list []*RecordJob) { p.cancel() } } - - for _, job := range visibleJobs { - publishJobUpsert(job) - } } func stopAllStoppableJobs() int { diff --git a/backend/live.go b/backend/live.go index 7195c51..ecf891b 100644 --- a/backend/live.go +++ b/backend/live.go @@ -350,13 +350,8 @@ func startPreviewHLS(ctx context.Context, job *RecordJob, m3u8URL, previewDir, h job.PreviewState = "" job.PreviewStateAt = "" job.PreviewStateMsg = "" - hidden := job.Hidden jobsMu.Unlock() - if !hidden { - publishJobUpsert(job) - } - commonIn := []string{"-y"} if strings.TrimSpace(userAgent) != "" { commonIn = append(commonIn, "-user_agent", userAgent) @@ -423,13 +418,8 @@ func startPreviewHLS(ctx context.Context, job *RecordJob, m3u8URL, previewDir, h job.PreviewStateMsg = st } } - hidden := job.Hidden jobsMu.Unlock() - if !hidden { - publishJobUpsert(job) - } - //fmt.Printf("⚠️ preview hq ffmpeg failed: %v (%s)\n", err, st) } diff --git a/backend/main.go b/backend/main.go index 2ea6294..65525bc 100644 --- a/backend/main.go +++ b/backend/main.go @@ -259,7 +259,6 @@ func publishJob(jobID string) bool { j.Hidden = false jobsMu.Unlock() - publishJobUpsert(j) return true } @@ -383,12 +382,7 @@ func setJobPhase(job *RecordJob, phase string, progress int) { jobsMu.Lock() job.Phase = phase job.Progress = progress - hidden := job.Hidden jobsMu.Unlock() - - if !hidden { - publishJobUpsert(job) - } } func durationSecondsCached(ctx context.Context, path string) (float64, error) { @@ -1282,8 +1276,6 @@ func renameJobsOutputBasename(oldFile, newFile string) { return } - changedJobs := make([]*RecordJob, 0, 4) - jobsMu.Lock() for _, j := range jobs { if j == nil { @@ -1295,16 +1287,9 @@ func renameJobsOutputBasename(oldFile, newFile string) { } if filepath.Base(out) == oldFile { j.Output = filepath.Join(filepath.Dir(out), newFile) - if !j.Hidden { - changedJobs = append(changedJobs, j) - } } } jobsMu.Unlock() - - for _, j := range changedJobs { - publishJobUpsert(j) - } } // nimmt jetzt *HTTPClient entgegen diff --git a/backend/postwork.go b/backend/postwork.go index de3db20..20c68b2 100644 --- a/backend/postwork.go +++ b/backend/postwork.go @@ -418,10 +418,6 @@ func startPostWorkStatusRefresher() { } } jobsMu.Unlock() - - for _, job := range changedJobs { - publishJobUpsert(job) - } } }() } diff --git a/backend/record.go b/backend/record.go index d5dc801..efb2218 100644 --- a/backend/record.go +++ b/backend/record.go @@ -166,213 +166,6 @@ func recordPrepareSplit(w http.ResponseWriter, r *http.Request) { }) } -type regenerateAssetsRequest struct { - File string `json:"file"` -} - -func publishFinishedPostworkPhase( - file string, - assetID string, - queue string, - phase string, - state string, - label string, -) { - ev := finishedPostworkEvent{ - Type: "finished_postwork", - File: file, - AssetID: assetID, - Queue: queue, - State: state, - Phase: phase, - Label: label, - TS: time.Now().UnixMilli(), - } - if b, err := json.Marshal(ev); err == nil { - publishSSE("finishedPostwork", b) - } -} - -func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - var req regenerateAssetsRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "invalid json body", http.StatusBadRequest) - return - } - - file := strings.TrimSpace(req.File) - if file == "" { - http.Error(w, "missing file", http.StatusBadRequest) - return - } - if !isAllowedVideoExt(file) { - http.Error(w, "nicht erlaubt", http.StatusForbidden) - return - } - - s := getSettings() - doneAbs, err := resolvePathRelativeToApp(s.DoneDir) - if err != nil { - http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) - return - } - if strings.TrimSpace(doneAbs) == "" { - http.Error(w, "doneDir ist leer", http.StatusBadRequest) - return - } - - videoPath, _, fi, err := resolveDoneFileByName(doneAbs, file) - if err != nil { - http.Error(w, "datei nicht gefunden", http.StatusNotFound) - return - } - if fi == nil || fi.IsDir() || fi.Size() <= 0 { - http.Error(w, "invalid output path", http.StatusBadRequest) - return - } - - setRegenerateAssetsTaskRunning(file, file) - - go func(videoPath string, file string) { - id := assetIDFromVideoPath(videoPath) - - // 1) Vorher evtl. laufende enrich-Jobs abbrechen - cancelDeferredEnrichForFile(file) - - // ✅ alten Analyze-Status aktiv löschen - publishFinishedPostworkPhase(file, id, "enrich", "analyze", "missing", "") - - // 2) SOFORT running an UI senden - publishFinishedPostworkPhase(file, id, "postwork", "meta", "running", "Meta") - publishFinishedPostworkPhase(file, id, "postwork", "teaser", "running", "Teaser") - publishFinishedPostworkPhase(file, id, "postwork", "thumb", "running", "Vorschaubild") - publishFinishedPostworkPhase(file, id, "postwork", "sprites", "running", "Sprites") - - notifyDoneChanged() - - // 3) Alte Assets löschen - if id != "" { - if assetDir, _, _, _, metaPath, err := assetPathsForID(id); err == nil { - _ = os.Remove(filepath.Join(assetDir, "preview.jpg")) - _ = os.Remove(filepath.Join(assetDir, "preview.mp4")) - _ = os.Remove(filepath.Join(assetDir, "preview-sprite.jpg")) - _ = os.Remove(metaPath) - } - } - - assetWatchDone := make(chan struct{}) - - go func() { - ticker := time.NewTicker(300 * time.Millisecond) - defer ticker.Stop() - - sentMetaDone := false - sentTeaserDone := false - sentThumbDone := false - sentSpritesDone := false - - checkAndEmit := func() { - truth := finishedPhaseTruthForID(id) - - if truth.MetaReady && !sentMetaDone { - sentMetaDone = true - publishFinishedPostworkPhase(file, id, "postwork", "meta", "done", "Meta") - } - if truth.TeaserReady && !sentTeaserDone { - sentTeaserDone = true - publishFinishedPostworkPhase(file, id, "postwork", "teaser", "done", "Teaser") - } - if truth.ThumbReady && !sentThumbDone { - sentThumbDone = true - publishFinishedPostworkPhase(file, id, "postwork", "thumb", "done", "Vorschaubild") - } - if truth.SpritesReady && !sentSpritesDone { - sentSpritesDone = true - publishFinishedPostworkPhase(file, id, "postwork", "sprites", "done", "Sprites") - } - } - - for { - select { - case <-assetWatchDone: - checkAndEmit() - return - case <-ticker.C: - checkAndEmit() - } - } - }() - - // 4) Regeneration - _, errAssets := ensureAssetsForVideoWithProgressCtx(context.Background(), videoPath, "", nil) - close(assetWatchDone) - - if errAssets == nil { - publishFinishedPostworkPhase(file, id, "enrich", "analyze", "running", "KI-Analyse") - } - - errAI := ensureDeferredAssetsAndAI(context.Background(), videoPath, "") - - if errAssets == nil && errAI == nil { - publishFinishedPostworkPhase(file, id, "enrich", "analyze", "done", "KI-Analyse") - } - - // 5) Ergebnis zurück an UI - if errAssets != nil || errAI != nil { - errMsg := "" - if errAssets != nil { - errMsg = errAssets.Error() - } else { - errMsg = errAI.Error() - } - - setRegenerateAssetsTaskError(file, errMsg) - - errEv := finishedPostworkEvent{ - Type: "finished_postwork", - File: file, - AssetID: id, - Queue: "enrich", - State: "error", - Phase: "assets", - Label: "Fehler", - TS: time.Now().UnixMilli(), - } - if b, err := json.Marshal(errEv); err == nil { - publishSSE("finishedPostwork", b) - } - - notifyDoneChanged() - - time.AfterFunc(5*time.Second, func() { - clearRegenerateAssetsTaskState() - }) - - fmt.Println("[regenerate-assets] failed:", file, errMsg) - return - } - - setRegenerateAssetsTaskDone(file, "Fertig") - - notifyDoneChanged() - - time.AfterFunc(3*time.Second, func() { - clearRegenerateAssetsTaskState() - }) - }(videoPath, file) - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "ok": true, - "file": file, - }) -} - func handleDeleteMany(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) diff --git a/backend/record_stream_cb.go b/backend/record_stream_cb.go index 172645b..805c47f 100644 --- a/backend/record_stream_cb.go +++ b/backend/record_stream_cb.go @@ -97,7 +97,6 @@ func RecordStream( jobsMu.Lock() job.SizeBytes = written jobsMu.Unlock() - publishJobUpsert(job) lastPush = now lastBytes = written diff --git a/backend/record_stream_mfc.go b/backend/record_stream_mfc.go index 2ca87b3..0efc2fd 100644 --- a/backend/record_stream_mfc.go +++ b/backend/record_stream_mfc.go @@ -379,7 +379,6 @@ func handleM3U8Mode(ctx context.Context, m3u8URL, outFile string, job *RecordJob jobsMu.Lock() job.SizeBytes = sz jobsMu.Unlock() - publishJobUpsert(job) last = sz } } diff --git a/backend/recorder.go b/backend/recorder.go index ea73e02..34c44ce 100644 --- a/backend/recorder.go +++ b/backend/recorder.go @@ -266,8 +266,6 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) { if j.Hidden && !req.Hidden { j.Hidden = false jobsMu.Unlock() - - publishJobUpsert(j) return j, nil } @@ -317,10 +315,6 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) { jobs[jobID] = job jobsMu.Unlock() - if !job.Hidden { - publishJobUpsert(job) - } - go runJob(ctx, job, req) return job, nil } @@ -350,7 +344,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { } setJobProgress(job, "recording", 0) - publishJobUpsert(job) switch provider { case "chaturbate": @@ -383,7 +376,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { jobsMu.Lock() job.Output = outPath jobsMu.Unlock() - publishJobUpsert(job) } err = RecordStream(ctx, hc, "https://chaturbate.com/", username, outPath, req.Cookie, job) @@ -404,7 +396,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { jobsMu.Lock() job.Output = outPath jobsMu.Unlock() - publishJobUpsert(job) err = RecordStreamMFC(ctx, hc, username, outPath, job) @@ -436,7 +427,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { job.Phase = "postwork" out := strings.TrimSpace(job.Output) jobsMu.Unlock() - publishJobUpsert(job) if out == "" { jobsMu.Lock() @@ -448,7 +438,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { jobsMu.Unlock() publishJobRemove(job) - notifyDoneChanged() return } @@ -464,7 +453,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { jobsMu.Unlock() publishJobRemove(job) - notifyDoneChanged() if shouldLogRecordInfo(req) { if serr != nil { @@ -486,7 +474,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { jobsMu.Lock() job.SizeBytes = fi.Size() jobsMu.Unlock() - publishJobUpsert(job) s := getSettings() minMB := s.AutoDeleteSmallDownloadsBelowMB @@ -508,7 +495,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { jobsMu.Unlock() publishJobRemove(job) - notifyDoneChanged() if shouldLogRecordInfo(req) { fmt.Println("🧹 auto-deleted before enqueue:", base, "(size: "+formatBytesSI(fi.Size())+", threshold: "+formatBytesSI(threshold)+")") @@ -537,7 +523,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { job.PostWork = &s } jobsMu.Unlock() - publishJobUpsert(job) okQueued := postWorkQ.Enqueue(PostWorkTask{ Key: postKey, @@ -550,7 +535,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { jobsMu.Unlock() setJobProgress(job, "postwork", 0) - publishJobUpsert(job) publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ File: postFile, @@ -577,7 +561,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { jobsMu.Unlock() publishJobRemove(job) - notifyDoneChanged() publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ File: postFile, @@ -596,7 +579,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { jobsMu.Lock() job.PostWork = &st jobsMu.Unlock() - publishJobUpsert(job) label := "Nachbearbeitung läuft…" switch strings.ToLower(strings.TrimSpace(phase)) { @@ -634,7 +616,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { jobsMu.Lock() job.Output = out jobsMu.Unlock() - publishJobUpsert(job) } } @@ -653,7 +634,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { jobsMu.Unlock() publishJobRemove(job) - notifyDoneChanged() publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ File: filepath.Base(out), @@ -682,7 +662,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { jobsMu.Lock() job.Output = out jobsMu.Unlock() - publishJobUpsert(job) notifyDoneChanged() } @@ -694,7 +673,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { jobsMu.Lock() job.DurationSeconds = sec jobsMu.Unlock() - publishJobUpsert(job) } cancel() } @@ -711,7 +689,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { job.VideoHeight = h job.FPS = fps jobsMu.Unlock() - publishJobUpsert(job) } } @@ -767,7 +744,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { jobsMu.Unlock() publishJobRemove(job) - notifyDoneChanged() publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ File: finalFile, @@ -799,7 +775,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { jobsMu.Lock() job.PostWork = &st jobsMu.Unlock() - publishJobUpsert(job) publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ File: postFile, @@ -823,7 +798,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { jobsMu.Unlock() publishJobRemove(job) - notifyDoneChanged() publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ File: postFile, diff --git a/backend/recorder_settings.json b/backend/recorder_settings.json index 887c901..11f7e48 100644 --- a/backend/recorder_settings.json +++ b/backend/recorder_settings.json @@ -8,12 +8,12 @@ "autoStartAddedDownloads": false, "useChaturbateApi": true, "useMyFreeCamsWatcher": true, - "autoDeleteSmallDownloads": false, + "autoDeleteSmallDownloads": true, "autoDeleteSmallDownloadsBelowMB": 200, "lowDiskPauseBelowGB": 5, "blurPreviews": false, "teaserPlayback": "hover", "teaserAudio": true, "enableNotifications": true, - "encryptedCookies": "bs6T0hwuivNal4zH7v+yRK63wstZFvAnwGt1GEFDS2hMS4yWHXC/m2I0CnHtwgl7FS5ARSnY45tkMn9GJznNPUofuq2GDbHqZTwbRF3vNO/f/fnO9za4BQ+woU9303Act9U/UAQYi515LtRzTNdIczz1lkbahevMdyf0RLx7ajBgFmJ6UTbNtClwDRaLgdHxPWo8XMZRX9K0J1nWoJOAlvCWm60ggu+8UwnIEyPWDR15+kbFj1FjlwUOz8G/uPyJXnDYVA1fLpHSgUJHgarWMeTZNqDkONl6v6T7ksvp8PsWk/dmcrEz/vmEP8R78kLBpdgGPXvGfLo1sXfdt0MEvcfXm3RirzpsvpibJTlDUZv4eDth1kfppU3uEVH6TcQuwQ/0u3y1uecKRo9m9u40xAriM1ASFt4NUTLcCH5z6SgXxf0DT8jYZpIXWY/ma69aYW3laIuNx3+1Ybg1BUEJkdX+lFJieLrfWUwStO34bvRASapwXmSekvKpZe/53P91R4TKjbhBs0M=" + "encryptedCookies": "khmON0nSN9PsCC7Fb8btmhpmC9GmuQfWglOutI11SwrgM+EVs4SwkmcdOkPykPoWM+6oMqCYUCrCYF0ZHH18gVyZdiDC6GEpPhUQkjVYZJ8aOs4Gz0PSPQj89cMrQ9OBWVakaXAyFk1/m2xjez1ofA2uWlDREcSHPXhh24Gw9nICx23TkoKt5GJdy/+MH4qWI+qGI5zaYvr6dEO9iq4yDCpVgXeKPsylyRonDS0NuqYthjbay5P0A4Ir4Xr/66d4fp1+YLrEiWyASOsYvEmBhs9WO6/zNH/Uj4ZJqBlHaoCv1doaRBUy3XizWwh0Q71fGuGPrlqm8jjJBee3hbrgoC/22Ao6gC8+mLoG6ruqZNFjSecVSuDl/XTcIYgXqudUScLCemF40UT5rbs0aFH3awY4pm75K1AzL55lBHiSIb05UX/Rf1UUyP+ruFsGJBvHZkiOgAj8TpMoVNXpfTJQYDyZR6ISJHaFMfLZBUQ2U9Dim59Aon0nf6f84gsUhX2j3UsulkbtJxU=" } diff --git a/backend/sse.go b/backend/sse.go index ea2120d..e89b90a 100644 --- a/backend/sse.go +++ b/backend/sse.go @@ -70,6 +70,15 @@ type finishedPostworkEvent struct { TS int64 `json:"ts"` } +type taskStateEvent struct { + Type string `json:"type"` // "task_state" + GenerateAssetsRunning bool `json:"generateAssetsRunning"` + RegenerateAssetsRunning bool `json:"regenerateAssetsRunning"` + CleanupRunning bool `json:"cleanupRunning"` + AnyRunning bool `json:"anyRunning"` + TS int64 `json:"ts"` +} + func publishFinishedPostworkStateForJob( j *RecordJob, queue string, @@ -106,6 +115,24 @@ func publishFinishedPostworkStateForJob( }) } +func publishTaskState() { + assets := getGenerateAssetsTaskStatus() + regenerate := snapshotRegenerateAssetsTaskState() + cleanup := snapshotCleanupTaskState() + + ev := taskStateEvent{ + Type: "task_state", + GenerateAssetsRunning: assets.Running, + RegenerateAssetsRunning: regenerate.Running, + CleanupRunning: cleanup.Running, + AnyRunning: assets.Running || regenerate.Running || cleanup.Running, + TS: time.Now().UnixMilli(), + } + + b, _ := json.Marshal(ev) + publishSSE("taskState", b) +} + func publishFinishedPostworkStatusForJob(j *RecordJob, ev finishedPostworkEvent) { if j == nil { return @@ -125,10 +152,7 @@ func publishFinishedPostworkStatusForJob(j *RecordJob, ev finishedPostworkEvent) b, _ := json.Marshal(ev) - // 1) modellbezogen für Running/Downloads - publishSSE(eventName, b) - - // 2) global für FinishedDownloads + // nur global für FinishedDownloads publishSSE("finishedPostwork", b) } @@ -269,54 +293,6 @@ func visibleJobEventsJSON() []ssePublishItem { return out } -func publishJobUpsert(j *RecordJob) { - if j == nil || j.Hidden { - return - } - if !shouldPublishModelEventForJob(j) { - return - } - - eventName := sseModelEventNameForJob(j) - if eventName == "" { - return - } - - payload := jobEvent{ - Type: "job_upsert", - Model: eventName, - JobID: j.ID, - Status: j.Status, - Phase: j.Phase, - Progress: j.Progress, - SourceURL: j.SourceURL, - Output: j.Output, - StartedAt: j.StartedAt.Format(time.RFC3339Nano), - StartedAtMs: j.StartedAtMs, - EndedAtMs: j.EndedAtMs, - SizeBytes: j.SizeBytes, - DurationSeconds: j.DurationSeconds, - PreviewState: j.PreviewState, - PostWorkKey: strings.TrimSpace(j.PostWorkKey), - PostWork: j.PostWork, - TS: time.Now().UnixMilli(), - } - - if sm := sseStoredModelForJob(j); sm != nil { - payload.RoomStatus = strings.ToLower(strings.TrimSpace(sm.RoomStatus)) - payload.IsOnline = sm.IsOnline - payload.ModelImageURL = strings.TrimSpace(sm.ImageURL) - payload.ModelChatRoomURL = strings.TrimSpace(sm.ChatRoomURL) - } - - if j.EndedAt != nil { - payload.EndedAt = j.EndedAt.Format(time.RFC3339Nano) - } - - b, _ := json.Marshal(payload) - publishSSE(eventName, b) -} - func publishJobRemove(j *RecordJob) { if j == nil || j.Hidden { return diff --git a/backend/tasks_assets.go b/backend/tasks_assets.go index d22f655..20a9083 100644 --- a/backend/tasks_assets.go +++ b/backend/tasks_assets.go @@ -46,6 +46,7 @@ func updateAssetsState(fn func(st *AssetsTaskState)) AssetsTaskState { assetsTaskMu.Unlock() notifyAssetsChanged() + publishTaskState() return st } @@ -100,8 +101,8 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) { st := assetsTaskState assetsTaskMu.Unlock() - // SSE: Start pushen notifyAssetsChanged() + publishTaskState() go runGenerateMissingAssets(ctx) @@ -126,7 +127,7 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) { // das macht der Worker zuverlässig im finishWithErr(context.Canceled)) st := updateAssetsState(func(st *AssetsTaskState) { if st.Running { - st.Error = "abgebrochen" + st.Error = "Abgebrochen" } }) @@ -200,7 +201,7 @@ func runGenerateMissingAssets(ctx context.Context) { } if errors.Is(err, context.Canceled) { - st.Error = "abgebrochen" + st.Error = "Abgebrochen" } else { st.Error = err.Error() } diff --git a/backend/tasks_cleanup.go b/backend/tasks_cleanup.go index a5fd6b4..6d7b7eb 100644 --- a/backend/tasks_cleanup.go +++ b/backend/tasks_cleanup.go @@ -40,6 +40,14 @@ type cleanupReq struct { BelowMB *int `json:"belowMB,omitempty"` } +func clearCleanupTaskState() { + cleanupTaskMu.Lock() + cleanupTaskState = CleanupTaskState{} + cleanupTaskMu.Unlock() + + publishTaskState() +} + // /api/settings/cleanup (POST) // - löscht kleine Dateien < threshold MB (mp4/ts; skip .part/.tmp; skip keep-Ordner) // - räumt Orphans (preview/thumbs + generated) auf @@ -55,6 +63,11 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) { doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil || strings.TrimSpace(doneAbs) == "" { setCleanupTaskError("doneDir auflösung fehlgeschlagen") + + time.AfterFunc(7*time.Second, func() { + clearCleanupTaskState() + }) + http.Error(w, "doneDir auflösung fehlgeschlagen", http.StatusBadRequest) return } @@ -96,6 +109,10 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) { orphansTotalRemoved := resp.GeneratedOrphansRemoved setCleanupTaskDone(fmt.Sprintf("geprüft: %d · Orphans: %d", resp.ScannedFiles, orphansTotalRemoved)) + time.AfterFunc(7*time.Second, func() { + clearCleanupTaskState() + }) + writeJSON(w, http.StatusOK, resp) } diff --git a/backend/tasks_regenerate_assets.go b/backend/tasks_regenerate_assets.go new file mode 100644 index 0000000..1dc02df --- /dev/null +++ b/backend/tasks_regenerate_assets.go @@ -0,0 +1,314 @@ +// backend\tasks_regenerate_assets.go + +package main + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +type regenerateAssetsRequest struct { + File string `json:"file"` +} + +func publishFinishedPostworkPhase( + file string, + assetID string, + queue string, + phase string, + state string, + label string, +) { + ev := finishedPostworkEvent{ + Type: "finished_postwork", + File: file, + AssetID: assetID, + Queue: queue, + State: state, + Phase: phase, + Label: label, + TS: time.Now().UnixMilli(), + } + if b, err := json.Marshal(ev); err == nil { + publishSSE("finishedPostwork", b) + } +} + +var ( + regenerateAssetsRunMu sync.Mutex + regenerateAssetsCancel context.CancelFunc + regenerateAssetsFile string +) + +func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost && r.Method != http.MethodDelete { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + if r.Method == http.MethodDelete { + regenerateAssetsRunMu.Lock() + cancel := regenerateAssetsCancel + file := regenerateAssetsFile + regenerateAssetsCancel = nil + regenerateAssetsFile = "" + regenerateAssetsRunMu.Unlock() + + if cancel != nil { + cancel() + } + + if strings.TrimSpace(file) != "" { + setRegenerateAssetsTaskError(file, "Abgebrochen") + + id := strings.TrimSpace(assetIDFromVideoPath(file)) + if id == "" { + id = stripHotPrefix(strings.TrimSuffix(filepath.Base(file), filepath.Ext(file))) + } + + publishFinishedPostworkPhase(file, id, "postwork", "meta", "missing", "") + publishFinishedPostworkPhase(file, id, "postwork", "teaser", "missing", "") + publishFinishedPostworkPhase(file, id, "postwork", "thumb", "missing", "") + publishFinishedPostworkPhase(file, id, "postwork", "sprites", "missing", "") + publishFinishedPostworkPhase(file, id, "enrich", "analyze", "missing", "") + + time.AfterFunc(2*time.Second, func() { + clearRegenerateAssetsTaskState() + }) + } else { + clearRegenerateAssetsTaskState() + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "cancelled": true, + }) + return + } + + var req regenerateAssetsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid json body", http.StatusBadRequest) + return + } + + file := strings.TrimSpace(req.File) + if file == "" { + http.Error(w, "missing file", http.StatusBadRequest) + return + } + if !isAllowedVideoExt(file) { + http.Error(w, "nicht erlaubt", http.StatusForbidden) + return + } + + s := getSettings() + doneAbs, err := resolvePathRelativeToApp(s.DoneDir) + if err != nil { + http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) + return + } + if strings.TrimSpace(doneAbs) == "" { + http.Error(w, "doneDir ist leer", http.StatusBadRequest) + return + } + + videoPath, _, fi, err := resolveDoneFileByName(doneAbs, file) + if err != nil { + http.Error(w, "datei nicht gefunden", http.StatusNotFound) + return + } + if fi == nil || fi.IsDir() || fi.Size() <= 0 { + http.Error(w, "invalid output path", http.StatusBadRequest) + return + } + + runCtx, runCancel := context.WithCancel(context.Background()) + + regenerateAssetsRunMu.Lock() + if regenerateAssetsCancel != nil { + regenerateAssetsCancel() + } + regenerateAssetsCancel = runCancel + regenerateAssetsFile = file + regenerateAssetsRunMu.Unlock() + + setRegenerateAssetsTaskRunning(file, file) + + go func(ctx context.Context, videoPath string, file string) { + defer func() { + regenerateAssetsRunMu.Lock() + if regenerateAssetsFile == file { + regenerateAssetsCancel = nil + regenerateAssetsFile = "" + } + regenerateAssetsRunMu.Unlock() + }() + + id := assetIDFromVideoPath(videoPath) + if strings.TrimSpace(id) == "" { + id = stripHotPrefix(strings.TrimSuffix(filepath.Base(file), filepath.Ext(file))) + } + + cancelDeferredEnrichForFile(file) + + select { + case <-ctx.Done(): + setRegenerateAssetsTaskError(file, "Abgebrochen") + time.AfterFunc(2*time.Second, func() { + clearRegenerateAssetsTaskState() + }) + return + default: + } + + publishFinishedPostworkPhase(file, id, "enrich", "analyze", "missing", "") + + publishFinishedPostworkPhase(file, id, "postwork", "meta", "running", "Meta") + publishFinishedPostworkPhase(file, id, "postwork", "teaser", "running", "Teaser") + publishFinishedPostworkPhase(file, id, "postwork", "thumb", "running", "Vorschaubild") + publishFinishedPostworkPhase(file, id, "postwork", "sprites", "running", "Sprites") + + if id != "" { + if assetDir, _, _, _, metaPath, err := assetPathsForID(id); err == nil { + _ = os.Remove(filepath.Join(assetDir, "preview.jpg")) + _ = os.Remove(filepath.Join(assetDir, "preview.mp4")) + _ = os.Remove(filepath.Join(assetDir, "preview-sprite.jpg")) + _ = os.Remove(metaPath) + } + } + + assetWatchDone := make(chan struct{}) + + go func(ctx context.Context, file string, id string) { + ticker := time.NewTicker(300 * time.Millisecond) + defer ticker.Stop() + + sentMetaDone := false + sentTeaserDone := false + sentThumbDone := false + sentSpritesDone := false + + checkAndEmit := func() { + truth := finishedPhaseTruthForID(id) + + if truth.MetaReady && !sentMetaDone { + sentMetaDone = true + publishFinishedPostworkPhase(file, id, "postwork", "meta", "done", "Meta") + } + if truth.TeaserReady && !sentTeaserDone { + sentTeaserDone = true + publishFinishedPostworkPhase(file, id, "postwork", "teaser", "done", "Teaser") + } + if truth.ThumbReady && !sentThumbDone { + sentThumbDone = true + publishFinishedPostworkPhase(file, id, "postwork", "thumb", "done", "Vorschaubild") + } + if truth.SpritesReady && !sentSpritesDone { + sentSpritesDone = true + publishFinishedPostworkPhase(file, id, "postwork", "sprites", "done", "Sprites") + } + } + + for { + select { + case <-ctx.Done(): + return + case <-assetWatchDone: + checkAndEmit() + return + case <-ticker.C: + checkAndEmit() + } + } + }(ctx, file, id) + + _, errAssets := ensureAssetsForVideoWithProgressCtx(ctx, videoPath, "", nil) + close(assetWatchDone) + + if errors.Is(errAssets, context.Canceled) || ctx.Err() == context.Canceled { + setRegenerateAssetsTaskError(file, "Abgebrochen") + + publishFinishedPostworkPhase(file, id, "postwork", "meta", "missing", "") + publishFinishedPostworkPhase(file, id, "postwork", "teaser", "missing", "") + publishFinishedPostworkPhase(file, id, "postwork", "thumb", "missing", "") + publishFinishedPostworkPhase(file, id, "postwork", "sprites", "missing", "") + publishFinishedPostworkPhase(file, id, "enrich", "analyze", "missing", "") + + time.AfterFunc(2*time.Second, func() { + clearRegenerateAssetsTaskState() + }) + return + } + + if errAssets == nil { + publishFinishedPostworkPhase(file, id, "enrich", "analyze", "running", "KI-Analyse") + } + + errAI := ensureDeferredAssetsAndAI(ctx, videoPath, "") + + if errors.Is(errAI, context.Canceled) || ctx.Err() == context.Canceled { + setRegenerateAssetsTaskError(file, "Abgebrochen") + publishFinishedPostworkPhase(file, id, "enrich", "analyze", "missing", "") + + time.AfterFunc(2*time.Second, func() { + clearRegenerateAssetsTaskState() + }) + return + } + + if errAssets == nil && errAI == nil { + publishFinishedPostworkPhase(file, id, "enrich", "analyze", "done", "KI-Analyse") + } + + if errAssets != nil || errAI != nil { + errMsg := "" + if errAssets != nil { + errMsg = errAssets.Error() + } else { + errMsg = errAI.Error() + } + + setRegenerateAssetsTaskError(file, errMsg) + + errEv := finishedPostworkEvent{ + Type: "finished_postwork", + File: file, + AssetID: id, + Queue: "enrich", + State: "error", + Phase: "assets", + Label: "Fehler", + TS: time.Now().UnixMilli(), + } + if b, err := json.Marshal(errEv); err == nil { + publishSSE("finishedPostwork", b) + } + + time.AfterFunc(5*time.Second, func() { + clearRegenerateAssetsTaskState() + }) + return + } + + setRegenerateAssetsTaskDone(file, "Fertig") + + time.AfterFunc(3*time.Second, func() { + clearRegenerateAssetsTaskState() + }) + }(runCtx, videoPath, file) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "file": file, + }) +} diff --git a/backend/tasks_status.go b/backend/tasks_status.go index 1ecd716..1223c81 100644 --- a/backend/tasks_status.go +++ b/backend/tasks_status.go @@ -63,6 +63,8 @@ func setRegenerateAssetsTaskRunning(file, text string) { Error: "", } regenerateAssetsTaskMu.Unlock() + + publishTaskState() } func setRegenerateAssetsTaskDone(file, text string) { @@ -74,6 +76,8 @@ func setRegenerateAssetsTaskDone(file, text string) { Error: "", } regenerateAssetsTaskMu.Unlock() + + publishTaskState() } func setRegenerateAssetsTaskError(file, errText string) { @@ -85,12 +89,16 @@ func setRegenerateAssetsTaskError(file, errText string) { Error: strings.TrimSpace(errText), } regenerateAssetsTaskMu.Unlock() + + publishTaskState() } func clearRegenerateAssetsTaskState() { regenerateAssetsTaskMu.Lock() regenerateAssetsTaskState = RegenerateAssetsTaskState{} regenerateAssetsTaskMu.Unlock() + + publishTaskState() } func snapshotCleanupTaskState() CleanupTaskState { @@ -112,6 +120,8 @@ func setCleanupTaskRunning(text string) { FinishedAt: nil, } cleanupTaskMu.Unlock() + + publishTaskState() } func setCleanupTaskProgress(done, total int, currentFile, text string) { @@ -124,6 +134,8 @@ func setCleanupTaskProgress(done, total int, currentFile, text string) { cleanupTaskState.Error = "" cleanupTaskState.FinishedAt = nil cleanupTaskMu.Unlock() + + publishTaskState() } func setCleanupTaskDone(text string) { @@ -139,15 +151,20 @@ func setCleanupTaskDone(text string) { } cleanupTaskMu.Unlock() + + publishTaskState() } func setCleanupTaskError(errText string) { cleanupTaskMu.Lock() cleanupTaskState.Running = false + cleanupTaskState.Text = "" cleanupTaskState.Error = strings.TrimSpace(errText) cleanupTaskState.CurrentFile = "" cleanupTaskState.FinishedAt = cleanupNowISO() cleanupTaskMu.Unlock() + + publishTaskState() } func tasksStatusHandler(w http.ResponseWriter, r *http.Request) { diff --git a/frontend/package.json b/frontend/package.json index c6425d5..fb69e25 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.0.0", + "version": "1.0.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c2723ab..54add8f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -111,6 +111,15 @@ type JobEvent = { maxParallel?: number } +type TaskStateEvent = { + type?: 'task_state' + generateAssetsRunning?: boolean + regenerateAssetsRunning?: boolean + cleanupRunning?: boolean + anyRunning?: boolean + ts?: number +} + const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = { recordDir: 'records', doneDir: 'records/done', @@ -596,72 +605,6 @@ export default function App() { } }, []) - useEffect(() => { - if (!authed) return - - let cancelled = false - let timer: number | null = null - - const loadSettingsTaskState = async () => { - try { - const [genRes, statusRes] = await Promise.all([ - fetch('/api/tasks/generate-assets', { cache: 'no-store' as any }), - fetch('/api/tasks/status', { cache: 'no-store' as any }), - ]) - - let generateAssetsRunning = false - let regenerateAssetsRunning = false - let cleanupRunning = false - - if (genRes.ok) { - const genData = await genRes.json().catch(() => null) - generateAssetsRunning = Boolean(genData?.running) - } - - if (statusRes.ok) { - const statusData = await statusRes.json().catch(() => null) - regenerateAssetsRunning = Boolean(statusData?.regenerateAssets?.running) - cleanupRunning = Boolean(statusData?.cleanup?.running) - } - - if (cancelled) return - - setSettingsTaskRunning( - generateAssetsRunning || - regenerateAssetsRunning || - cleanupRunning - ) - } catch { - if (!cancelled) setSettingsTaskRunning(false) - } - } - - const schedule = (ms: number) => { - if (cancelled) return - timer = window.setTimeout(async () => { - await loadSettingsTaskState() - schedule(document.hidden ? 8000 : 3000) - }, ms) - } - - void loadSettingsTaskState() - schedule(3000) - - const onVis = () => { - if (!document.hidden) { - void loadSettingsTaskState() - } - } - - document.addEventListener('visibilitychange', onVis) - - return () => { - cancelled = true - if (timer != null) window.clearTimeout(timer) - document.removeEventListener('visibilitychange', onVis) - } - }, [authed]) - useEffect(() => { void checkAuth() }, [checkAuth]) @@ -910,6 +853,10 @@ export default function App() { const [modelsByKey, setModelsByKey] = useState>({}) + useEffect(() => { + setModelsCount(Object.keys(modelsByKey).length) + }, [modelsByKey]) + const isChaturbateStoreModel = useCallback((m?: StoredModel | null) => { const h = String(m?.host ?? '').toLowerCase() const input = String(m?.input ?? '').toLowerCase() @@ -1166,7 +1113,9 @@ export default function App() { const refreshModelsByKey = useCallback(async () => { try { const list = await apiJSON('/api/models', { cache: 'no-store' as any }) - setModelsByKey(buildModelsByKey(Array.isArray(list) ? list : [])) + const safeList = Array.isArray(list) ? list : [] + + setModelsByKey(buildModelsByKey(safeList)) setLastHeaderUpdateAtMs(Date.now()) } catch { // ignore @@ -1411,7 +1360,9 @@ export default function App() { // ✅ 1) Update-Event mit Model: direkt in State übernehmen (KEIN /api/models) if (updated && typeof updated === 'object') { const k = String(updated.modelKey ?? '').toLowerCase().trim() - if (k) setModelsByKey((prev) => ({ ...prev, [k]: updated })) + if (k) { + setModelsByKey((prev) => ({ ...prev, [k]: updated })) + } try { upsertModelCache(updated) @@ -2173,31 +2124,6 @@ export default function App() { } }, []) - // ✅ Models-Count (leicht) - useEffect(() => { - let cancelled = false - - const load = async () => { - try { - const meta = await apiJSON<{ count?: number }>('/api/models/meta', { cache: 'no-store' }) - const c = Number(meta?.count ?? 0) - if (!cancelled && Number.isFinite(c)) { - setModelsCount(c) - setLastHeaderUpdateAtMs(Date.now()) - } - } catch { - // ignore - } - } - - load() - const t = window.setInterval(load, document.hidden ? 60000 : 30000) - return () => { - cancelled = true - window.clearInterval(t) - } - }, []) - const initialCookies = useMemo(() => Object.entries(cookies).map(([name, value]) => ({ name, value })), [cookies]) const openPlayer = useCallback((job: RecordJob, startAtSec?: number) => { @@ -2412,6 +2338,7 @@ export default function App() { void loadDoneCount() void loadJobs() + void loadTaskStateOnce() if (selectedTabRef.current === 'finished') { requestFinishedReload('sse fallback poll') @@ -2419,6 +2346,26 @@ export default function App() { }, document.hidden ? 60000 : 5000) } + const loadTaskStateOnce = async () => { + try { + const res = await fetch('/api/tasks/status', { cache: 'no-store' as any }) + if (!res.ok) return + + const data = await res.json().catch(() => null) + + setSettingsTaskRunning( + Boolean( + data?.anyRunning || + data?.generateAssets?.running || + data?.regenerateAssets?.running || + data?.cleanup?.running + ) + ) + } catch { + // ignore + } + } + const applyAutostartState = (data: unknown) => { const d = (data ?? {}) as any setAutostartState({ @@ -2483,8 +2430,23 @@ export default function App() { } catch {} } + const onTaskState = (ev: MessageEvent) => { + try { + const msg = JSON.parse(String(ev.data ?? 'null')) as TaskStateEvent + setSettingsTaskRunning( + Boolean( + msg?.anyRunning || + msg?.generateAssetsRunning || + msg?.regenerateAssetsRunning || + msg?.cleanupRunning + ) + ) + } catch {} + } + void loadJobs() void loadDoneCount() + void loadTaskStateOnce() void apiJSON('/api/autostart/state', { cache: 'no-store' as any }) .then((s) => { @@ -2502,6 +2464,7 @@ export default function App() { es.onopen = () => { stopFallbackPoll() + void loadTaskStateOnce() } es.onerror = () => { @@ -2511,11 +2474,14 @@ export default function App() { es.addEventListener('doneChanged', onDoneChanged as any) es.addEventListener('autostart', onAutostart as any) es.addEventListener('finishedPostwork', onFinishedPostwork as any) + es.addEventListener('taskState', onTaskState as any) const onVis = () => { if (document.hidden) return void loadJobs() void loadDoneCount() + void loadTaskStateOnce() + if (selectedTabRef.current === 'finished') { requestFinishedReload('visibilitychange') } @@ -2531,6 +2497,7 @@ export default function App() { es.removeEventListener('doneChanged', onDoneChanged as any) es.removeEventListener('autostart', onAutostart as any) es.removeEventListener('finishedPostwork', onFinishedPostwork as any) + es.removeEventListener('taskState', onTaskState as any) const handler = onModelJobEventRef.current if (handler) { @@ -3550,7 +3517,6 @@ export default function App() { {selectedTab === 'settings' ? ( ) : null} diff --git a/frontend/src/components/ui/RecorderSettings.tsx b/frontend/src/components/ui/RecorderSettings.tsx index c5ba0ce..0ef9d5f 100644 --- a/frontend/src/components/ui/RecorderSettings.tsx +++ b/frontend/src/components/ui/RecorderSettings.tsx @@ -60,7 +60,6 @@ const DEFAULTS: RecorderSettings = { type Props = { onAssetsGenerated?: () => void - onTaskRunningChange?: (running: boolean) => void } function shortTaskFilename(name?: string, max = 52) { @@ -70,12 +69,11 @@ function shortTaskFilename(name?: string, max = 52) { return '…' + s.slice(-(max - 1)) } -export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChange }: Props) { +export default function RecorderSettings({ onAssetsGenerated }: Props) { const [value, setValue] = useState(DEFAULTS) const [saving, setSaving] = useState(false) const [saveSuccessUntilMs, setSaveSuccessUntilMs] = useState(0) const saveSuccessTimerRef = useRef(null) - const cleanupDismissUntilRef = useRef(0) const [cleaning, setCleaning] = useState(false) const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | null>(null) const [msg, setMsg] = useState(null) @@ -152,16 +150,6 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang fading: false, }) - useEffect(() => { - const running = - assetsTask.status === 'running' || - regenerateAssetsTask.status === 'running' || - cleanupTask.status === 'running' || - cleaning - - onTaskRunningChange?.(running) - }, [assetsTask.status, regenerateAssetsTask.status, cleanupTask.status, cleaning, onTaskRunningChange]) - const pauseGB = Number(value.lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB ?? 5) const uiPauseGB = diskStatus?.pauseGB ?? pauseGB const uiResumeGB = diskStatus?.resumeGB ?? (pauseGB + 3) @@ -239,12 +227,19 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang fading: false, })) } else if (regen?.error) { + const regenError = String(regen.error || '').trim() + const cancelled = + regenError.toLowerCase() === 'abgebrochen' || + regenError.toLowerCase() === 'abbruch' || + regenError.toLowerCase() === 'cancelled' || + regenError.toLowerCase() === 'canceled' + setRegenerateAssetsTask((t) => ({ ...t, - status: 'error', + status: cancelled ? 'cancelled' : 'error', title: 'Assets neu generieren', - text: String(regen.file || ''), - err: String(regen.error), + text: String(regen.file || ''), // nur Dateiname anzeigen, kein "Abgebrochen" darunter + err: cancelled ? undefined : regenError, cancellable: false, fading: false, })) @@ -270,8 +265,8 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang })) } + const cleanup = data.cleanup - const cleanupSuppressed = Date.now() < cleanupDismissUntilRef.current if (cleanup?.running) { setCleaning(true) @@ -286,7 +281,7 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang cancellable: false, fading: false, })) - } else if (!cleanupSuppressed && cleanup?.error) { + } else if (cleanup?.error) { setCleaning(false) setCleanupTask((t) => ({ ...t, @@ -299,7 +294,7 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang cancellable: false, fading: false, })) - } else if (!cleanupSuppressed && (cleanup?.finishedAt || cleanup?.text)) { + } else if (cleanup?.finishedAt || cleanup?.text) { setCleaning(false) setCleanupTask((t) => ({ ...t, @@ -617,9 +612,8 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang title: 'Aufräumen', text: `geprüft: ${scannedFiles} · Orphans: ${orphansTotalRemoved}`, })) - - cleanupDismissUntilRef.current = Date.now() + 4500 - fadeOutTask(setCleanupTask) // ✅ nach und nach ausfaden + + fadeOutTask(setCleanupTask, 7000, 500) } catch (e: any) { const msg = e?.message ?? String(e) setErr(msg) @@ -630,8 +624,8 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang text: 'Fehler beim Aufräumen.', err: msg, })) - - fadeOutTask(setCleanupTask) + + fadeOutTask(setCleanupTask, 7000, 500) } finally { setCleaning(false) } @@ -663,8 +657,9 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang ...t, status: 'cancelled', title: 'Assets neu generieren', - text: 'Abgebrochen.', + text: t.text, err: undefined, + cancellable: false, fading: false, })) diff --git a/frontend/src/components/ui/TaskList.tsx b/frontend/src/components/ui/TaskList.tsx index a187a37..bfb6608 100644 --- a/frontend/src/components/ui/TaskList.tsx +++ b/frontend/src/components/ui/TaskList.tsx @@ -105,15 +105,15 @@ export default function TaskList({ tasks, onCancel }: Props) { {/* Status badge (running bewusst ohne Badge) */} {t.status === 'done' ? ( - fertig + Fertig ) : t.status === 'cancelled' ? ( - abgebrochen + Abgebrochen ) : t.status === 'error' ? ( - fehler + Fehler ) : null}