updated sse

This commit is contained in:
Linrador 2026-03-26 16:39:53 +01:00
parent ff06d0d18b
commit 80b9f69597
20 changed files with 466 additions and 497 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -418,10 +418,6 @@ func startPostWorkStatusRefresher() {
}
}
jobsMu.Unlock()
for _, job := range changedJobs {
publishJobUpsert(job)
}
}
}()
}

View File

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

View File

@ -97,7 +97,6 @@ func RecordStream(
jobsMu.Lock()
job.SizeBytes = written
jobsMu.Unlock()
publishJobUpsert(job)
lastPush = now
lastBytes = written

View File

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

View File

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

View File

@ -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="
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",

View File

@ -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<Record<string, StoredModel>>({})
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<StoredModel[]>('/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<AutostartState>('/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' ? (
<RecorderSettings
onAssetsGenerated={bumpAssets}
onTaskRunningChange={setSettingsTaskRunning}
/>
) : null}
</main>

View File

@ -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<RecorderSettings>(DEFAULTS)
const [saving, setSaving] = useState(false)
const [saveSuccessUntilMs, setSaveSuccessUntilMs] = useState<number>(0)
const saveSuccessTimerRef = useRef<number | null>(null)
const cleanupDismissUntilRef = useRef(0)
const [cleaning, setCleaning] = useState(false)
const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | null>(null)
const [msg, setMsg] = useState<string | null>(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,
}))

View File

@ -105,15 +105,15 @@ export default function TaskList({ tasks, onCancel }: Props) {
{/* Status badge (running bewusst ohne Badge) */}
{t.status === 'done' ? (
<span className="shrink-0 inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-[11px] font-semibold text-green-800 ring-1 ring-inset ring-green-200 dark:bg-green-500/20 dark:text-green-200 dark:ring-green-400/30">
fertig
Fertig
</span>
) : t.status === 'cancelled' ? (
<span className="shrink-0 inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-semibold text-gray-700 ring-1 ring-inset ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10">
abgebrochen
Abgebrochen
</span>
) : t.status === 'error' ? (
<span className="shrink-0 inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-[11px] font-semibold text-red-800 ring-1 ring-inset ring-red-200 dark:bg-red-500/20 dark:text-red-200 dark:ring-red-400/30">
fehler
Fehler
</span>
) : null}