// backend\tasks_assets.go package main import ( "context" "errors" "fmt" "net/http" "os" "path/filepath" "sort" "strings" "sync" "time" ) // --------------------------- // Tasks: Missing Assets erzeugen // --------------------------- type AssetsTaskState struct { Queued bool `json:"queued"` Running bool `json:"running"` QueuedAt time.Time `json:"queuedAt"` StartedAt time.Time `json:"startedAt"` FinishedAt *time.Time `json:"finishedAt,omitempty"` Total int `json:"total"` Done int `json:"done"` GeneratedThumbs int `json:"generatedThumbs"` GeneratedPreviews int `json:"generatedPreviews"` Skipped int `json:"skipped"` Error string `json:"error,omitempty"` CurrentFile string `json:"currentFile,omitempty"` CurrentQueue string `json:"currentQueue,omitempty"` CurrentPhase string `json:"currentPhase,omitempty"` CurrentLabel string `json:"currentLabel,omitempty"` Text string `json:"text,omitempty"` } type assetsPhaseSelection struct { Meta bool Thumb bool Teaser bool Sprites bool Analyze bool } var assetsTaskMu sync.Mutex var assetsTaskState AssetsTaskState var assetsTaskCancel context.CancelFunc var assetsTaskFileCancels = map[string]context.CancelFunc{} var assetsTaskSkipFiles = map[string]struct{}{} func selectedAssetsPhasesFromSettings() assetsPhaseSelection { s := getSettings() return assetsPhaseSelection{ Meta: s.GenerateAssetsMeta, Thumb: s.GenerateAssetsThumb, Teaser: s.GenerateAssetsTeaser, Sprites: s.GenerateAssetsSprites, Analyze: s.GenerateAssetsAnalyze, } } func assetsTaskFileKey(file string) string { return strings.ToLower(strings.TrimSpace(filepath.Base(file))) } func resetAssetsTaskFileControlLocked() { assetsTaskFileCancels = map[string]context.CancelFunc{} assetsTaskSkipFiles = map[string]struct{}{} } func registerAssetsTaskFileCancel(file string, cancel context.CancelFunc) { key := assetsTaskFileKey(file) if key == "" { return } assetsTaskMu.Lock() assetsTaskFileCancels[key] = cancel assetsTaskMu.Unlock() } func clearAssetsTaskFileControl(file string) { key := assetsTaskFileKey(file) if key == "" { return } assetsTaskMu.Lock() delete(assetsTaskFileCancels, key) delete(assetsTaskSkipFiles, key) assetsTaskMu.Unlock() } func requestAssetsTaskSkip(file string) bool { key := assetsTaskFileKey(file) if key == "" { return false } var cancel context.CancelFunc assetsTaskMu.Lock() assetsTaskSkipFiles[key] = struct{}{} cancel = assetsTaskFileCancels[key] assetsTaskMu.Unlock() if cancel != nil { cancel() return true } return false } func isAssetsTaskSkipRequested(file string) bool { key := assetsTaskFileKey(file) if key == "" { return false } assetsTaskMu.Lock() _, ok := assetsTaskSkipFiles[key] assetsTaskMu.Unlock() return ok } func (p assetsPhaseSelection) Any() bool { return p.Meta || p.Thumb || p.Teaser || p.Sprites || p.Analyze } // updateAssetsState mutiert den State atomar und triggert danach SSE notify. // notifyAssetsChanged() muss außerhalb des Locks passieren. func updateAssetsState(fn func(st *AssetsTaskState)) AssetsTaskState { assetsTaskMu.Lock() fn(&assetsTaskState) st := assetsTaskState assetsTaskMu.Unlock() notifyAssetsChanged() publishTaskState() return st } func snapshotAssetsState() AssetsTaskState { assetsTaskMu.Lock() st := assetsTaskState assetsTaskMu.Unlock() return st } func getGenerateAssetsTaskStatus() AssetsTaskState { return snapshotAssetsState() } func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: // GET bleibt als Fallback/Debug möglich (UI nutzt SSE) st := snapshotAssetsState() writeJSON(w, http.StatusOK, st) return case http.MethodPost: assetsTaskMu.Lock() if assetsTaskState.Running || assetsTaskState.Queued { st := assetsTaskState assetsTaskMu.Unlock() writeJSON(w, http.StatusOK, st) return } ctx, cancel := context.WithCancel(context.Background()) assetsTaskCancel = cancel assetsTaskState = AssetsTaskState{ Queued: true, Running: false, QueuedAt: time.Now(), StartedAt: time.Time{}, FinishedAt: nil, Total: 0, Done: 0, GeneratedThumbs: 0, GeneratedPreviews: 0, Skipped: 0, Error: "", CurrentFile: "", CurrentQueue: "", CurrentPhase: "", CurrentLabel: "", Text: "Wartet…", } resetAssetsTaskFileControlLocked() st := assetsTaskState assetsTaskMu.Unlock() notifyAssetsChanged() publishTaskState() go func() { if err := acquireExclusiveTask(ctx); err != nil { now := time.Now() updateAssetsState(func(st *AssetsTaskState) { st.Queued = false st.Running = false st.FinishedAt = &now if errors.Is(err, context.Canceled) { st.Error = "Abgebrochen" } else { st.Error = err.Error() } }) return } defer releaseExclusiveTask() updateAssetsState(func(st *AssetsTaskState) { st.Queued = false st.Running = true st.StartedAt = time.Now() st.Text = "" }) runGenerateMissingAssets(ctx) }() writeJSON(w, http.StatusOK, st) return case http.MethodDelete: assetsTaskMu.Lock() cancel := assetsTaskCancel running := assetsTaskState.Running queued := assetsTaskState.Queued assetsTaskMu.Unlock() if (!running && !queued) || cancel == nil { w.WriteHeader(http.StatusNoContent) return } // canceln: Worker merkt das beim nächsten ctx.Err() und beendet sauber cancel() // UI sofort informieren (ohne Running künstlich auf false zu setzen — // das macht der Worker zuverlässig im finishWithErr(context.Canceled)) st := updateAssetsState(func(st *AssetsTaskState) { if st.Running || st.Queued { st.Queued = false st.Running = false st.Error = "Abgebrochen" } }) writeJSON(w, http.StatusOK, st) return default: http.Error(w, "Nur GET/POST/DELETE", http.StatusMethodNotAllowed) return } } func publishAssetsTaskPhase(fileName string, queue string, phase string, state string, label string) { fileName = strings.TrimSpace(fileName) if fileName == "" { return } base := strings.TrimSuffix(fileName, filepath.Ext(fileName)) assetID := stripHotPrefix(base) publishFinishedPostworkPhase( fileName, assetID, queue, phase, state, label, "", ) } func clearAssetsTaskFinishedPostworkStates(fileName string, sel assetsPhaseSelection) { if sel.Meta { publishAssetsTaskPhase(fileName, "postwork", "meta", "missing", "") } if sel.Thumb { publishAssetsTaskPhase(fileName, "postwork", "thumb", "missing", "") } if sel.Teaser { publishAssetsTaskPhase(fileName, "postwork", "teaser", "missing", "") } if sel.Sprites { publishAssetsTaskPhase(fileName, "postwork", "sprites", "missing", "") } if sel.Analyze { publishAssetsTaskPhase(fileName, "enrich", "analyze", "missing", "") } } func fileExistsNonEmpty(path string) bool { path = strings.TrimSpace(path) if path == "" { return false } fi, err := os.Stat(path) return err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 } type orderedAssetItem struct { name string path string } func listAssetsDoneThenKeepByFilenameAsc(doneAbs string) []orderedAssetItem { indexedItems, _ := buildDoneIndex(doneAbs) doneItems := make([]orderedAssetItem, 0, len(indexedItems)) keepItems := make([]orderedAssetItem, 0, len(indexedItems)) seen := make(map[string]struct{}, len(indexedItems)) for _, entry := range indexedItems { if entry.job == nil { continue } full := strings.TrimSpace(entry.job.Output) if full == "" { continue } cleanKey := strings.ToLower(filepath.Clean(full)) if _, ok := seen[cleanKey]; ok { continue } seen[cleanKey] = struct{}{} it := orderedAssetItem{ name: filepath.Base(full), path: full, } if entry.fromKeep { keepItems = append(keepItems, it) } else { doneItems = append(doneItems, it) } } sort.Slice(doneItems, func(i, j int) bool { ai := strings.ToLower(doneItems[i].name) aj := strings.ToLower(doneItems[j].name) if ai != aj { return ai < aj } return strings.ToLower(filepath.Clean(doneItems[i].path)) < strings.ToLower(filepath.Clean(doneItems[j].path)) }) sort.Slice(keepItems, func(i, j int) bool { ai := strings.ToLower(keepItems[i].name) aj := strings.ToLower(keepItems[j].name) if ai != aj { return ai < aj } return strings.ToLower(filepath.Clean(keepItems[i].path)) < strings.ToLower(filepath.Clean(keepItems[j].path)) }) return append(doneItems, keepItems...) } func assetsTaskTruthForVideo(id string, videoPath string) finishedPhaseTruth { truth := finishedPhaseTruthForID(id) _, thumbPath, previewPath, spritePath, metaPath, err := assetPathsForID(id) if err != nil { return truth } // meta muss nicht nur logisch "ready" sein, sondern die Datei muss auch existieren truth.MetaReady = truth.MetaReady && fileExistsNonEmpty(metaPath) // dateibasierte Assets IMMER an echter Datei festmachen truth.ThumbReady = fileExistsNonEmpty(thumbPath) truth.TeaserReady = fileExistsNonEmpty(previewPath) truth.SpritesReady = fileExistsNonEmpty(spritePath) // Analyse bleibt inhaltlich/meta-basiert if strings.TrimSpace(videoPath) != "" { truth.AnalyzeReady = hasAIResultsForOutput(videoPath) } return truth } func runGenerateMissingAssets(ctx context.Context) { // Worker-Ende: CancelFunc zurücksetzen (pro Run) defer func() { assetsTaskMu.Lock() assetsTaskCancel = nil assetsTaskMu.Unlock() }() finishWithErr := func(err error) { now := time.Now() updateAssetsState(func(st *AssetsTaskState) { st.Queued = false st.Running = false st.FinishedAt = &now st.CurrentFile = "" st.CurrentQueue = "" st.CurrentPhase = "" st.CurrentLabel = "" if err == nil { st.Error = "" return } if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { st.Error = "Abgebrochen" } else { st.Error = err.Error() } }) assetsTaskMu.Lock() resetAssetsTaskFileControlLocked() assetsTaskMu.Unlock() } s := getSettings() sel := selectedAssetsPhasesFromSettings() if !sel.Any() { finishWithErr(nil) return } doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil || strings.TrimSpace(doneAbs) == "" { finishWithErr(fmt.Errorf("doneDir auflösung fehlgeschlagen: %v", err)) return } items := listAssetsDoneThenKeepByFilenameAsc(doneAbs) type assetCandidate struct { name string path string id string beforeTruth finishedPhaseTruth } candidates := make([]assetCandidate, 0, len(items)) skippedCount := 0 initialDone := 0 for _, it := range items { if err := ctx.Err(); err != nil { finishWithErr(err) return } base := strings.TrimSuffix(it.name, filepath.Ext(it.name)) id := stripHotPrefix(base) if strings.TrimSpace(id) == "" { clearAssetsTaskFinishedPostworkStates(it.name, sel) skippedCount++ initialDone++ continue } vfi, verr := os.Stat(it.path) if verr != nil || vfi == nil || vfi.IsDir() || vfi.Size() <= 0 { clearAssetsTaskFinishedPostworkStates(it.name, sel) skippedCount++ initialDone++ continue } beforeTruth := assetsTaskTruthForVideo(id, it.path) needMeta := sel.Meta && !beforeTruth.MetaReady needThumb := sel.Thumb && !beforeTruth.ThumbReady needTeaser := sel.Teaser && !beforeTruth.TeaserReady needSprites := sel.Sprites && !beforeTruth.SpritesReady needAnalyze := sel.Analyze && !beforeTruth.AnalyzeReady // Bereits vollständig bzgl. ausgewählter Phasen -> als erledigt/skipped mitzählen if !needMeta && !needThumb && !needTeaser && !needSprites && !needAnalyze { clearAssetsTaskFinishedPostworkStates(it.name, sel) skippedCount++ initialDone++ continue } candidates = append(candidates, assetCandidate{ name: it.name, path: it.path, id: id, beforeTruth: beforeTruth, }) } updateAssetsState(func(st *AssetsTaskState) { st.Total = len(items) // alle Dateien, inkl. keep + skips st.Done = initialDone // vorhandene/ungültige direkt als erledigt zählen st.GeneratedThumbs = 0 st.GeneratedPreviews = 0 st.Skipped = skippedCount st.Error = "" st.CurrentFile = "" st.CurrentQueue = "" st.CurrentPhase = "" st.CurrentLabel = "" st.Text = "" }) if len(items) == 0 { finishWithErr(nil) return } if len(candidates) == 0 { finishWithErr(nil) return } for _, it := range candidates { if err := ctx.Err(); err != nil { finishWithErr(err) return } fileCtx, fileCancel := context.WithCancel(ctx) registerAssetsTaskFileCancel(it.name, fileCancel) finishFileControl := func() { fileCancel() clearAssetsTaskFileControl(it.name) } skipCurrentFile := func() { clearAssetsTaskFinishedPostworkStates(it.name, sel) updateAssetsState(func(st *AssetsTaskState) { st.Done++ st.Skipped++ st.CurrentFile = "" st.CurrentQueue = "" st.CurrentPhase = "" st.CurrentLabel = "" }) finishFileControl() } handleFileAbort := func(stepErr error) (skipFile bool, stopAll bool) { if stepErr == nil { if isAssetsTaskSkipRequested(it.name) || !fileExistsNonEmpty(it.path) { return true, false } return false, false } if errors.Is(stepErr, context.Canceled) || errors.Is(stepErr, context.DeadlineExceeded) { // globaler Task wurde beendet if ctx.Err() != nil && !isAssetsTaskSkipRequested(it.name) { finishFileControl() finishWithErr(context.Canceled) return false, true } // nur diese eine Datei wurde freigegeben/übersprungen return true, false } // Datei wurde evtl. während des Verarbeitungsschritts gelöscht/verschoben if isAssetsTaskSkipRequested(it.name) || !fileExistsNonEmpty(it.path) { return true, false } return false, false } updateAssetsState(func(st *AssetsTaskState) { st.CurrentFile = it.name }) if isAssetsTaskSkipRequested(it.name) || !fileExistsNonEmpty(it.path) { skipCurrentFile() continue } id := it.id vfi, verr := os.Stat(it.path) if verr != nil || vfi == nil || vfi.IsDir() || vfi.Size() <= 0 { clearAssetsTaskFinishedPostworkStates(it.name, sel) updateAssetsState(func(st *AssetsTaskState) { st.Done++ st.Skipped++ st.CurrentFile = "" st.CurrentQueue = "" st.CurrentPhase = "" st.CurrentLabel = "" }) finishFileControl() continue } beforeTruth := it.beforeTruth needMeta := sel.Meta && !beforeTruth.MetaReady needThumb := sel.Thumb && !beforeTruth.ThumbReady needTeaser := sel.Teaser && !beforeTruth.TeaserReady needSprites := sel.Sprites && !beforeTruth.SpritesReady needAnalyze := sel.Analyze && !beforeTruth.AnalyzeReady // Pfade einmalig über zentralen Helper _, _, _, _, metaPath, perr := assetPathsForID(id) if perr != nil { if needMeta { publishAssetsTaskPhase(it.name, "postwork", "meta", "error", "Meta fehlgeschlagen") } if needThumb { publishAssetsTaskPhase(it.name, "postwork", "thumb", "error", "Vorschaubild fehlgeschlagen") } if needTeaser { publishAssetsTaskPhase(it.name, "postwork", "teaser", "error", "Teaser fehlgeschlagen") } if needSprites { publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen") } if needAnalyze { publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "Analyse fehlgeschlagen") } updateAssetsState(func(st *AssetsTaskState) { st.Error = "mindestens ein Eintrag konnte nicht verarbeitet werden (siehe Logs)" st.Done++ st.CurrentFile = "" st.CurrentQueue = "" st.CurrentPhase = "" st.CurrentLabel = "" }) finishFileControl() fmt.Println("⚠️ assetPathsForID:", perr) continue } // SourceURL best-effort: aus bestehender meta.json sourceURL := "" if u, ok := readVideoMetaSourceURL(metaPath, vfi); ok { sourceURL = u } thumbGenerated := false previewGenerated := false // ---------------- // META // ---------------- if needMeta { updateAssetsState(func(st *AssetsTaskState) { st.CurrentFile = it.name st.CurrentQueue = "postwork" st.CurrentPhase = "meta" st.CurrentLabel = "Meta" }) publishAssetsTaskPhase(it.name, "postwork", "meta", "running", "Meta") okMeta, merr := ensureMetaForVideoCtx(fileCtx, it.path, sourceURL) if skipFile, stopAll := handleFileAbort(merr); stopAll { return } else if skipFile { skipCurrentFile() continue } if merr != nil || !okMeta { publishAssetsTaskPhase(it.name, "postwork", "meta", "error", "Meta fehlgeschlagen") } else { publishAssetsTaskPhase(it.name, "postwork", "meta", "done", "Meta") } } // ---------------- // THUMB // ---------------- if needThumb { updateAssetsState(func(st *AssetsTaskState) { st.CurrentFile = it.name st.CurrentQueue = "postwork" st.CurrentPhase = "thumb" st.CurrentLabel = "Vorschaubild" }) publishAssetsTaskPhase(it.name, "postwork", "thumb", "running", "Vorschaubild") thumbGeneratedNow, terr := ensureThumbForVideoCtx(fileCtx, it.path, sourceURL) if skipFile, stopAll := handleFileAbort(terr); stopAll { return } else if skipFile { skipCurrentFile() continue } if terr != nil { publishAssetsTaskPhase(it.name, "postwork", "thumb", "error", "Vorschaubild fehlgeschlagen") } else if assetsTaskTruthForVideo(id, it.path).ThumbReady { if thumbGeneratedNow { thumbGenerated = true } publishAssetsTaskPhase(it.name, "postwork", "thumb", "done", "Vorschaubild") } else { publishAssetsTaskPhase(it.name, "postwork", "thumb", "error", "Vorschaubild fehlgeschlagen") } } // ---------------- // TEASER // ---------------- if needTeaser { updateAssetsState(func(st *AssetsTaskState) { st.CurrentFile = it.name st.CurrentQueue = "postwork" st.CurrentPhase = "teaser" st.CurrentLabel = "Teaser" }) publishAssetsTaskPhase(it.name, "postwork", "teaser", "running", "Teaser") genTeaser, terr := ensureTeaserForVideoCtx(fileCtx, it.path, sourceURL) if skipFile, stopAll := handleFileAbort(terr); stopAll { return } else if skipFile { skipCurrentFile() continue } if terr != nil { publishAssetsTaskPhase(it.name, "postwork", "teaser", "error", "Teaser fehlgeschlagen") } else if assetsTaskTruthForVideo(id, it.path).TeaserReady { if genTeaser { previewGenerated = true } publishAssetsTaskPhase(it.name, "postwork", "teaser", "done", "Teaser") } else { publishAssetsTaskPhase(it.name, "postwork", "teaser", "error", "Teaser fehlgeschlagen") } } // ---------------- // SPRITES // ---------------- if needSprites { updateAssetsState(func(st *AssetsTaskState) { st.CurrentFile = it.name st.CurrentQueue = "postwork" st.CurrentPhase = "sprites" st.CurrentLabel = "Sprites" }) publishAssetsTaskPhase(it.name, "postwork", "sprites", "running", "Sprites") _, serr := ensureSpritesForVideoCtx(fileCtx, it.path, sourceURL) if skipFile, stopAll := handleFileAbort(serr); stopAll { return } else if skipFile { skipCurrentFile() continue } if serr != nil { publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen") } else if assetsTaskTruthForVideo(id, it.path).SpritesReady { publishAssetsTaskPhase(it.name, "postwork", "sprites", "done", "Sprites") } else { publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen") } } // ---------------- // ANALYZE // ---------------- if needAnalyze { updateAssetsState(func(st *AssetsTaskState) { st.CurrentFile = it.name st.CurrentQueue = "enrich" st.CurrentPhase = "analyze" st.CurrentLabel = "Analyse" }) publishAssetsTaskPhase(it.name, "enrich", "analyze", "running", "Analyse") _, aerr := ensureAnalyzeForVideoCtx(fileCtx, it.path, sourceURL, "nsfw") if skipFile, stopAll := handleFileAbort(aerr); stopAll { return } else if skipFile { skipCurrentFile() continue } if aerr != nil { publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "Analyse fehlgeschlagen") } else if assetsTaskTruthForVideo(id, it.path).AnalyzeReady { publishAssetsTaskPhase(it.name, "enrich", "analyze", "done", "Analyse") } else { publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "Analyse fehlgeschlagen") } } updateAssetsState(func(st *AssetsTaskState) { if thumbGenerated { st.GeneratedThumbs++ } if previewGenerated { st.GeneratedPreviews++ } st.Done++ st.CurrentFile = "" st.CurrentQueue = "" st.CurrentPhase = "" st.CurrentLabel = "" }) finishFileControl() } finishWithErr(nil) }