package main import ( "context" "fmt" "net/http" "os" "path/filepath" "strings" "sync" "time" ) // --------------------------- // Tasks: Missing Assets erzeugen // --------------------------- type AssetsTaskState struct { Running bool `json:"running"` Total int `json:"total"` Done int `json:"done"` GeneratedThumbs int `json:"generatedThumbs"` GeneratedPreviews int `json:"generatedPreviews"` Skipped int `json:"skipped"` StartedAt time.Time `json:"startedAt"` FinishedAt *time.Time `json:"finishedAt,omitempty"` Error string `json:"error,omitempty"` } var assetsTaskMu sync.Mutex var assetsTaskState AssetsTaskState var assetsTaskCancel context.CancelFunc func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: assetsTaskMu.Lock() st := assetsTaskState assetsTaskMu.Unlock() writeJSON(w, http.StatusOK, st) return case http.MethodPost: assetsTaskMu.Lock() if assetsTaskState.Running { st := assetsTaskState assetsTaskMu.Unlock() writeJSON(w, http.StatusOK, st) return } // ✅ cancelbaren Context erzeugen ctx, cancel := context.WithCancel(context.Background()) assetsTaskCancel = cancel assetsTaskState = AssetsTaskState{ Running: true, StartedAt: time.Now(), } st := assetsTaskState assetsTaskMu.Unlock() go runGenerateMissingAssets(ctx) writeJSON(w, http.StatusOK, st) return case http.MethodDelete: assetsTaskMu.Lock() cancel := assetsTaskCancel running := assetsTaskState.Running assetsTaskMu.Unlock() if !running || cancel == nil { // nichts zu stoppen w.WriteHeader(http.StatusNoContent) return } cancel() // optional: sofortiges Feedback in state.error assetsTaskMu.Lock() if assetsTaskState.Running { assetsTaskState.Error = "abgebrochen" } st := assetsTaskState assetsTaskMu.Unlock() writeJSON(w, http.StatusOK, st) return default: http.Error(w, "Nur GET/POST", http.StatusMethodNotAllowed) return } } func runGenerateMissingAssets(ctx context.Context) { finishWithErr := func(err error) { now := time.Now() assetsTaskMu.Lock() assetsTaskState.Running = false assetsTaskState.FinishedAt = &now if err != nil { assetsTaskState.Error = err.Error() } assetsTaskMu.Unlock() } defer func() { assetsTaskMu.Lock() assetsTaskCancel = nil assetsTaskMu.Unlock() }() s := getSettings() doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil || strings.TrimSpace(doneAbs) == "" { finishWithErr(fmt.Errorf("doneDir auflösung fehlgeschlagen: %v", err)) return } type item struct { name string path string } // .trash niemals verarbeiten isTrashPath := func(full string) bool { p := strings.ToLower(strings.ReplaceAll(full, "\\", "/")) return strings.Contains(p, "/.trash/") || strings.HasSuffix(p, "/.trash") } seen := map[string]struct{}{} items := make([]item, 0, 512) addIfVideo := func(full string) { if isTrashPath(full) { return } name := filepath.Base(full) low := strings.ToLower(name) if strings.Contains(low, ".part") || strings.Contains(low, ".tmp") { return } ext := strings.ToLower(filepath.Ext(name)) if ext != ".mp4" && ext != ".ts" { return } // Dedupe if _, ok := seen[full]; ok { return } seen[full] = struct{}{} items = append(items, item{name: name, path: full}) } scanOneLevel := func(dir string) { ents, err := os.ReadDir(dir) if err != nil { return } for _, e := range ents { // .trash-Ordner nie scannen if e.IsDir() && strings.EqualFold(e.Name(), ".trash") { continue } full := filepath.Join(dir, e.Name()) if e.IsDir() { sub, err := os.ReadDir(full) if err != nil { continue } for _, se := range sub { if se.IsDir() { continue } addIfVideo(filepath.Join(full, se.Name())) } continue } addIfVideo(full) } } // ✅ done + done// + done/keep + done/keep// scanOneLevel(doneAbs) scanOneLevel(filepath.Join(doneAbs, "keep")) assetsTaskMu.Lock() assetsTaskState.Total = len(items) assetsTaskState.Done = 0 assetsTaskState.GeneratedThumbs = 0 assetsTaskState.GeneratedPreviews = 0 assetsTaskState.Skipped = 0 assetsTaskState.Error = "" assetsTaskMu.Unlock() for i, 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) == "" { assetsTaskMu.Lock() assetsTaskState.Done = i + 1 assetsTaskMu.Unlock() continue } assetDir, derr := ensureGeneratedDir(id) if derr != nil { assetsTaskMu.Lock() assetsTaskState.Error = "mindestens ein Eintrag konnte nicht verarbeitet werden (siehe Logs)" assetsTaskState.Done = i + 1 assetsTaskMu.Unlock() fmt.Println("⚠️ ensureGeneratedDir:", derr) continue } thumbPath := filepath.Join(assetDir, "thumbs.jpg") previewPath := filepath.Join(assetDir, "preview.mp4") metaPath := filepath.Join(assetDir, "meta.json") thumbOK := func() bool { fi, err := os.Stat(thumbPath) return err == nil && !fi.IsDir() && fi.Size() > 0 }() previewOK := func() bool { fi, err := os.Stat(previewPath) return err == nil && !fi.IsDir() && fi.Size() > 0 }() // Datei-Info (für Meta-Validierung) vfi, verr := os.Stat(it.path) if verr != nil || vfi.IsDir() || vfi.Size() <= 0 { assetsTaskMu.Lock() assetsTaskState.Done = i + 1 assetsTaskMu.Unlock() continue } // ✅ SourceURL best-effort: aus bestehender meta.json, wenn vorhanden/valide sourceURL := "" if u, ok := readVideoMetaSourceURL(metaPath, vfi); ok { sourceURL = u } // ✅ Dauer zuerst aus meta.json, sonst 1× ffprobe & meta.json schreiben durSec := 0.0 metaOK := false if d, ok := readVideoMetaDuration(metaPath, vfi); ok { durSec = d metaOK = true // meta ist valide (Duration ok), aber falls wir (irgendwoher) eine SourceURL hätten // und sie in meta noch fehlt -> meta anreichern ohne ffprobe. if strings.TrimSpace(sourceURL) != "" { if u, ok := readVideoMetaSourceURL(metaPath, vfi); !ok || strings.TrimSpace(u) == "" { _ = writeVideoMetaDuration(metaPath, vfi, durSec, sourceURL) } } } else { dctx, cancel := context.WithTimeout(ctx, 6*time.Second) d, derr := durationSecondsCached(dctx, it.path) cancel() if derr == nil && d > 0 { durSec = d // ✅ HIER: nicht writeVideoMeta(metaPath, fi, dur, sourceURL) !! // sondern Duration-only writer nutzen _ = writeVideoMetaDuration(metaPath, vfi, durSec, sourceURL) metaOK = true } } if thumbOK && previewOK && metaOK { assetsTaskMu.Lock() assetsTaskState.Skipped++ assetsTaskState.Done = i + 1 assetsTaskMu.Unlock() continue } // ---------------- // Thumbs // ---------------- if !thumbOK { genCtx, cancel := context.WithTimeout(ctx, 45*time.Second) if err := thumbSem.Acquire(genCtx); err != nil { cancel() finishWithErr(err) return } cancel() // Timeout-Context freigeben, Semaphore bleibt gehalten defer thumbSem.Release() t := 0.0 if durSec > 0 { t = durSec * 0.5 } img, e1 := extractFrameAtTimeJPEG(it.path, t) if e1 != nil || len(img) == 0 { img, e1 = extractLastFrameJPEG(it.path) if e1 != nil || len(img) == 0 { img, e1 = extractFirstFrameJPEG(it.path) } } // Release wurde defer’t, aber wir wollen pro Iteration releasen: thumbSem.Release() if e1 == nil && len(img) > 0 { if err := atomicWriteFile(thumbPath, img); err == nil { assetsTaskMu.Lock() assetsTaskState.GeneratedThumbs++ assetsTaskMu.Unlock() } else { fmt.Println("⚠️ thumb write:", err) } } } // ---------------- // Preview // ---------------- if !previewOK { genCtx, cancel := context.WithTimeout(ctx, 3*time.Minute) if err := genSem.Acquire(genCtx); err != nil { cancel() finishWithErr(err) return } err := generateTeaserClipsMP4(genCtx, it.path, previewPath, 1.0, 18) genSem.Release() cancel() if err == nil { assetsTaskMu.Lock() assetsTaskState.GeneratedPreviews++ assetsTaskMu.Unlock() } else { fmt.Println("⚠️ preview clips:", err) } } assetsTaskMu.Lock() assetsTaskState.Done = i + 1 assetsTaskMu.Unlock() } finishWithErr(nil) }