package main import ( "context" "fmt" "math/rand" "net/http" "os" "os/exec" "path/filepath" "strings" "time" ) func serveTeaserFile(w http.ResponseWriter, r *http.Request, path string) { f, err := openForReadShareDelete(path) if err != nil { http.Error(w, "datei öffnen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } defer f.Close() fi, err := f.Stat() if err != nil || fi.IsDir() || fi.Size() == 0 { http.Error(w, "datei nicht gefunden", http.StatusNotFound) return } w.Header().Set("Cache-Control", "public, max-age=31536000") w.Header().Set("Content-Type", "video/mp4") http.ServeContent(w, r, filepath.Base(path), fi.ModTime(), f) } // tolerante Input-Flags für kaputte/abgeschnittene H264/TS Streams var ffmpegInputTol = []string{ "-fflags", "+discardcorrupt+genpts", "-err_detect", "ignore_err", "-max_error_rate", "1.0", } var coverModelStore *ModelStore func setCoverModelStore(s *ModelStore) { coverModelStore = s // random seed (einmalig) rand.Seed(time.Now().UnixNano()) } func generateTeaserMP4(ctx context.Context, srcPath, outPath string, startSec, durSec float64) error { if durSec <= 0 { durSec = 8 } if startSec < 0 { startSec = 0 } // temp schreiben -> rename tmp := outPath + ".tmp.mp4" args := []string{ "-y", "-hide_banner", "-loglevel", "error", } args = append(args, ffmpegInputTol...) args = append(args, "-ss", fmt.Sprintf("%.3f", startSec), "-i", srcPath, "-t", fmt.Sprintf("%.3f", durSec), // Video "-vf", "scale=720:-2", "-map", "0:v:0", // Audio (optional: falls kein Audio vorhanden ist, bricht ffmpeg NICHT ab) "-map", "0:a:0", "-c:a", "aac", "-b:a", "128k", "-ac", "2", "-c:v", "libx264", "-preset", "veryfast", "-crf", "28", "-pix_fmt", "yuv420p", // Wenn Audio minimal kürzer/länger ist, sauber beenden "-shortest", "-movflags", "+faststart", "-f", "mp4", tmp, ) cmd := exec.CommandContext(ctx, ffmpegPath, args...) if out, err := cmd.CombinedOutput(); err != nil { _ = os.Remove(tmp) return fmt.Errorf("ffmpeg teaser failed: %v (%s)", err, strings.TrimSpace(string(out))) } _ = os.Remove(outPath) return os.Rename(tmp, outPath) } func generatedTeaser(w http.ResponseWriter, r *http.Request) { id := strings.TrimSpace(r.URL.Query().Get("id")) if id == "" { http.Error(w, "id fehlt", http.StatusBadRequest) return } var err error id, err = sanitizeID(id) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } outPath, err := findFinishedFileByID(id) if err != nil { http.Error(w, "preview nicht verfügbar", http.StatusNotFound) return } if err := ensureGeneratedDirs(); err != nil { http.Error(w, "generated-dir nicht verfügbar: "+err.Error(), http.StatusInternalServerError) return } assetID := stripHotPrefix(id) if assetID == "" { assetID = id } assetDir, err := ensureGeneratedDir(assetID) if err != nil { http.Error(w, "generated-dir nicht verfügbar: "+err.Error(), http.StatusInternalServerError) return } previewPath := filepath.Join(assetDir, "preview.mp4") // ✅ NEU: noGenerate=1 -> niemals on-the-fly erzeugen, nur liefern wenn vorhanden qNoGen := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("noGenerate"))) noGen := qNoGen == "1" || qNoGen == "true" || qNoGen == "yes" // Cache hit (neu) if fi, err := os.Stat(previewPath); err == nil && !fi.IsDir() && fi.Size() > 0 { serveTeaserFile(w, r, previewPath) return } // Legacy: generated/teaser/_teaser.mp4 oder .mp4 if teaserLegacy, _ := generatedTeaserRoot(); strings.TrimSpace(teaserLegacy) != "" { cids := []string{assetID, id} for _, cid := range cids { candidates := []string{ filepath.Join(teaserLegacy, cid+"_teaser.mp4"), filepath.Join(teaserLegacy, cid+".mp4"), } for _, c := range candidates { if fi, err := os.Stat(c); err == nil && !fi.IsDir() && fi.Size() > 0 { if _, err2 := os.Stat(previewPath); os.IsNotExist(err2) { _ = os.MkdirAll(filepath.Dir(previewPath), 0o755) _ = os.Rename(c, previewPath) } if fi2, err2 := os.Stat(previewPath); err2 == nil && !fi2.IsDir() && fi2.Size() > 0 { serveTeaserFile(w, r, previewPath) return } serveTeaserFile(w, r, c) return } } } } // ✅ NEU: wenn noGenerate aktiv und bisher kein Teaser gefunden -> 404 if noGen { http.Error(w, "preview nicht verfügbar", http.StatusNotFound) return } // Neu erzeugen if err := genSem.Acquire(r.Context()); err != nil { http.Error(w, "abgebrochen: "+err.Error(), http.StatusRequestTimeout) return } defer genSem.Release() genCtx, cancel := context.WithTimeout(r.Context(), 3*time.Minute) defer cancel() if err := generateTeaserClipsMP4(genCtx, outPath, previewPath, 1.0, 18); err != nil { // Fallback: einzelner kurzer Teaser ab Anfang (trifft seltener kaputte Stellen) if err2 := generateTeaserMP4(genCtx, outPath, previewPath, 0, 8); err2 != nil { http.Error(w, "konnte preview nicht erzeugen: "+err.Error()+" (fallback ebenfalls fehlgeschlagen: "+err2.Error()+")", http.StatusInternalServerError) return } } serveTeaserFile(w, r, previewPath) }