203 lines
5.1 KiB
Go
203 lines
5.1 KiB
Go
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/<id>_teaser.mp4 oder <id>.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)
|
|
}
|