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 } // ✅ Meta: Duration + Props (w/h/fps) => damit Resolution in meta.json landet durSec := 0.0 vw, vh := 0, 0 fps := 0.0 // Wir wollen nicht nur "Duration ok", sondern auch Props ok. // Sonst wird später fälschlich "skipped" und Resolution bleibt für immer leer. metaOK := false // 1) Versuch: komplette Meta lesen (Duration + w/h/fps) if d, mw, mh, mfps, ok := readVideoMeta(metaPath, vfi); ok { durSec, vw, vh, fps = d, mw, mh, mfps } else { // 2) Fallback: Duration berechnen dctx, cancel := context.WithTimeout(ctx, 6*time.Second) d, derr := durationSecondsCached(dctx, it.path) cancel() if derr == nil && d > 0 { durSec = d } } // 3) Wenn wir Duration haben, aber Props fehlen: einmal ffprobe für Props if durSec > 0 && (vw <= 0 || vh <= 0 || fps <= 0) { pctx, cancel := context.WithTimeout(ctx, 8*time.Second) defer cancel() // optional: Semaphore verwenden (du hast durSem global) if durSem != nil { if err := durSem.Acquire(pctx); err == nil { vw, vh, fps, _ = probeVideoProps(pctx, it.path) durSem.Release() } } else { vw, vh, fps, _ = probeVideoProps(pctx, it.path) } } // 4) Jetzt voll schreiben (inkl. Resolution via formatResolution) if durSec > 0 { _ = writeVideoMeta(metaPath, vfi, durSec, vw, vh, fps, sourceURL) } // Meta gilt nur als "OK", wenn Duration + Auflösung vorhanden ist metaOK = durSec > 0 && vw > 0 && vh > 0 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) }