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