nsfwapp/backend/tasks_assets.go
2026-04-27 14:40:16 +02:00

835 lines
21 KiB
Go

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