394 lines
9.9 KiB
Go
394 lines
9.9 KiB
Go
// backend\preview_hls.go
|
||
|
||
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"fmt"
|
||
"net/http"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
var previewFileRe = regexp.MustCompile(`^(index(_hq)?\.m3u8|seg_(low|hq)_\d+\.ts|seg_\d+\.ts)$`)
|
||
|
||
func serveEmptyLiveM3U8(w http.ResponseWriter, r *http.Request) {
|
||
// Für Player: gültige Playlist statt 204 liefern
|
||
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl; charset=utf-8")
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||
// Optional: Player/Proxy darf schnell retryen
|
||
w.Header().Set("Retry-After", "1")
|
||
|
||
// Bei HEAD nur Header schicken
|
||
if r.Method == http.MethodHead {
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
|
||
// Minimal gültige LIVE-Playlist (keine Segmente, kein ENDLIST)
|
||
// Viele Player bleiben damit im "loading", statt hart zu failen.
|
||
body := "#EXTM3U\n" +
|
||
"#EXT-X-VERSION:3\n" +
|
||
"#EXT-X-TARGETDURATION:2\n" +
|
||
"#EXT-X-MEDIA-SEQUENCE:0\n"
|
||
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write([]byte(body))
|
||
}
|
||
|
||
func stopPreview(job *RecordJob) {
|
||
jobsMu.Lock()
|
||
cmd := job.previewCmd
|
||
cancel := job.previewCancel
|
||
job.previewCmd = nil
|
||
job.previewCancel = nil
|
||
job.LiveThumbStarted = false
|
||
job.PreviewDir = ""
|
||
jobsMu.Unlock()
|
||
|
||
if cancel != nil {
|
||
cancel()
|
||
}
|
||
if cmd != nil && cmd.Process != nil {
|
||
_ = cmd.Process.Kill()
|
||
}
|
||
}
|
||
|
||
func servePreviewHLSFile(w http.ResponseWriter, r *http.Request, id, file string) {
|
||
file = strings.TrimSpace(file)
|
||
if file == "" || filepath.Base(file) != file || !previewFileRe.MatchString(file) {
|
||
http.Error(w, "ungültige file", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
isIndex := file == "index.m3u8" || file == "index_hq.m3u8"
|
||
|
||
jobsMu.Lock()
|
||
job, ok := jobs[id]
|
||
state := ""
|
||
if ok && job != nil {
|
||
state = strings.TrimSpace(job.PreviewState)
|
||
}
|
||
jobsMu.Unlock()
|
||
|
||
// =========================
|
||
// ✅ HEAD = nur Existenzcheck (kein hover nötig, kein Preview-Start)
|
||
// =========================
|
||
if r.Method == http.MethodHead {
|
||
if !ok || job == nil {
|
||
w.WriteHeader(http.StatusNotFound)
|
||
return
|
||
}
|
||
if state == "private" {
|
||
w.WriteHeader(http.StatusForbidden)
|
||
return
|
||
}
|
||
if state == "offline" {
|
||
w.WriteHeader(http.StatusNotFound)
|
||
return
|
||
}
|
||
previewDir := strings.TrimSpace(job.PreviewDir)
|
||
if previewDir == "" {
|
||
w.WriteHeader(http.StatusNotFound)
|
||
return
|
||
}
|
||
p := filepath.Join(previewDir, file)
|
||
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
// =========================
|
||
// ✅ NEU: Player darf Preview auch ohne Hover starten
|
||
// - Frontend hängt &play=1 an (empfohlen)
|
||
// - Wir akzeptieren zusätzlich: play=1 => treat as active
|
||
// =========================
|
||
active := isHover(r) || strings.TrimSpace(r.URL.Query().Get("play")) == "1"
|
||
|
||
if !active {
|
||
// Kein Hover/Play => niemals Live-HLS abgreifen
|
||
if isIndex {
|
||
serveEmptyLiveM3U8(w, r)
|
||
return
|
||
}
|
||
http.Error(w, "preview not active", http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
// active => wenn Job unbekannt, sauber raus
|
||
if !ok || job == nil {
|
||
if isIndex {
|
||
serveEmptyLiveM3U8(w, r)
|
||
return
|
||
}
|
||
http.Error(w, "job nicht gefunden", http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
// active => Preview starten/keepalive
|
||
ensurePreviewStarted(r, job)
|
||
touchPreview(job)
|
||
|
||
// state ggf. nach Start nochmal lesen
|
||
jobsMu.Lock()
|
||
state = strings.TrimSpace(job.PreviewState)
|
||
jobsMu.Unlock()
|
||
|
||
if state == "private" {
|
||
http.Error(w, "model private", http.StatusForbidden)
|
||
return
|
||
}
|
||
if state == "offline" {
|
||
http.Error(w, "model offline", http.StatusNotFound)
|
||
return
|
||
}
|
||
if state == "error" {
|
||
http.Error(w, "preview error", http.StatusServiceUnavailable)
|
||
return
|
||
}
|
||
|
||
previewDir := strings.TrimSpace(job.PreviewDir)
|
||
if previewDir == "" {
|
||
if isIndex {
|
||
serveEmptyLiveM3U8(w, r)
|
||
return
|
||
}
|
||
http.Error(w, "preview nicht verfügbar", http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
p := filepath.Join(previewDir, file)
|
||
|
||
st, err := os.Stat(p)
|
||
if err != nil || st.IsDir() {
|
||
if isIndex {
|
||
serveEmptyLiveM3U8(w, r)
|
||
return
|
||
}
|
||
http.Error(w, "datei nicht gefunden", http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
ext := strings.ToLower(filepath.Ext(p))
|
||
|
||
// ✅ common: always no-store
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
// ✅ avoids some proxy buffering surprises (harmless if ignored)
|
||
w.Header().Set("X-Accel-Buffering", "no")
|
||
|
||
// =========================
|
||
// ✅ .m3u8: rewrite (klein, ReadFile ok)
|
||
// =========================
|
||
if ext == ".m3u8" {
|
||
raw, err := os.ReadFile(p)
|
||
if err != nil {
|
||
http.Error(w, "m3u8 read failed", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
rewritten := rewriteM3U8(raw, id)
|
||
|
||
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl; charset=utf-8")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write(rewritten)
|
||
return
|
||
}
|
||
|
||
// =========================
|
||
// ✅ Segmente: robust streamen + Range-support
|
||
// =========================
|
||
switch ext {
|
||
case ".ts":
|
||
w.Header().Set("Content-Type", "video/mp2t")
|
||
case ".m4s":
|
||
w.Header().Set("Content-Type", "video/iso.segment")
|
||
default:
|
||
w.Header().Set("Content-Type", "application/octet-stream")
|
||
}
|
||
|
||
// ✅ Optional aber sehr hilfreich:
|
||
// liefere ein Segment erst aus, wenn es nicht mehr wächst (verhindert "hängende" große .ts)
|
||
if ext == ".ts" || ext == ".m4s" {
|
||
if !waitForStableFile(p, 2, 120*time.Millisecond) {
|
||
// Segment ist vermutlich noch im Schreiben -> lieber 404, Player retryt
|
||
http.Error(w, "segment not ready", http.StatusNotFound)
|
||
return
|
||
}
|
||
}
|
||
|
||
f, err := os.Open(p)
|
||
if err != nil {
|
||
http.Error(w, "open failed", http.StatusNotFound)
|
||
return
|
||
}
|
||
defer f.Close()
|
||
|
||
// ✅ ServeContent macht Range korrekt und streamt ohne ReadAll.
|
||
// name ist nur für logs/cache; modTime für If-Modified-Since etc.
|
||
http.ServeContent(w, r, file, st.ModTime(), f)
|
||
|
||
}
|
||
|
||
func waitForStableFile(path string, checks int, interval time.Duration) bool {
|
||
// returns true if size is stable across N checks
|
||
var last int64 = -1
|
||
for i := 0; i < checks; i++ {
|
||
st, err := os.Stat(path)
|
||
if err != nil || st.IsDir() {
|
||
return false
|
||
}
|
||
sz := st.Size()
|
||
if last >= 0 && sz == last {
|
||
return true
|
||
}
|
||
last = sz
|
||
time.Sleep(interval)
|
||
}
|
||
// if we never saw stability, assume not ready
|
||
return false
|
||
}
|
||
|
||
func classifyPreviewFFmpegStderr(stderr string) (state string, httpStatus int) {
|
||
s := strings.ToLower(stderr)
|
||
|
||
// ffmpeg schreibt typischerweise:
|
||
// "HTTP error 403 Forbidden" oder "Server returned 403 Forbidden"
|
||
if strings.Contains(s, "403 forbidden") || strings.Contains(s, "http error 403") || strings.Contains(s, "server returned 403") {
|
||
return "private", http.StatusForbidden
|
||
}
|
||
|
||
// "HTTP error 404 Not Found" oder "Server returned 404 Not Found"
|
||
if strings.Contains(s, "404 not found") || strings.Contains(s, "http error 404") || strings.Contains(s, "server returned 404") {
|
||
return "offline", http.StatusNotFound
|
||
}
|
||
|
||
return "", 0
|
||
}
|
||
|
||
func startPreviewHLS(ctx context.Context, job *RecordJob, m3u8URL, previewDir, httpCookie, userAgent string) error {
|
||
if strings.TrimSpace(ffmpegPath) == "" {
|
||
return fmt.Errorf("kein ffmpeg gefunden – setze FFMPEG_PATH oder lege ffmpeg(.exe) neben das Backend")
|
||
}
|
||
|
||
if err := os.MkdirAll(previewDir, 0755); err != nil {
|
||
return err
|
||
}
|
||
|
||
// ✅ PreviewState reset (neuer Start)
|
||
jobsMu.Lock()
|
||
job.PreviewState = ""
|
||
job.PreviewStateAt = ""
|
||
job.PreviewStateMsg = ""
|
||
jobsMu.Unlock()
|
||
notifyJobsChanged()
|
||
|
||
commonIn := []string{"-y"}
|
||
if strings.TrimSpace(userAgent) != "" {
|
||
commonIn = append(commonIn, "-user_agent", userAgent)
|
||
}
|
||
if strings.TrimSpace(httpCookie) != "" {
|
||
commonIn = append(commonIn, "-headers", fmt.Sprintf("Cookie: %s\r\n", httpCookie))
|
||
}
|
||
commonIn = append(commonIn, "-i", m3u8URL)
|
||
|
||
hqArgs := append(commonIn,
|
||
"-vf", "scale=480:-2",
|
||
"-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency",
|
||
"-pix_fmt", "yuv420p",
|
||
"-profile:v", "main",
|
||
"-level", "3.1",
|
||
"-threads", "4",
|
||
|
||
// GOP ~ 2s (bei 24fps). Optional force_key_frames zusätzlich.
|
||
"-g", "48", "-keyint_min", "48", "-sc_threshold", "0",
|
||
// optional, wenn du noch große Segmente bekommst:
|
||
// "-force_key_frames", "expr:gte(t,n_forced*2)",
|
||
|
||
"-map", "0:v:0",
|
||
"-map", "0:a:0?",
|
||
"-c:a", "aac", "-b:a", "128k", "-ac", "2",
|
||
|
||
"-f", "hls",
|
||
"-hls_time", "2",
|
||
"-hls_list_size", "6",
|
||
"-hls_allow_cache", "0",
|
||
|
||
// ✅ wichtig: temp_file
|
||
"-hls_flags", "delete_segments+append_list+independent_segments+temp_file",
|
||
|
||
"-hls_segment_filename", filepath.Join(previewDir, "seg_hq_%05d.ts"),
|
||
|
||
// ✅ Empfehlung: weglassen (du rewritest ohnehin)
|
||
// "-hls_base_url", baseURL,
|
||
|
||
filepath.Join(previewDir, "index_hq.m3u8"),
|
||
)
|
||
|
||
cmd := exec.CommandContext(ctx, ffmpegPath, hqArgs...)
|
||
var stderr bytes.Buffer
|
||
cmd.Stderr = &stderr
|
||
|
||
jobsMu.Lock()
|
||
job.previewCmd = cmd
|
||
jobsMu.Unlock()
|
||
|
||
go func() {
|
||
if err := previewSem.Acquire(ctx); err != nil {
|
||
jobsMu.Lock()
|
||
if job.previewCmd == cmd {
|
||
job.previewCmd = nil
|
||
}
|
||
jobsMu.Unlock()
|
||
return
|
||
}
|
||
defer previewSem.Release()
|
||
|
||
if err := cmd.Run(); err != nil && ctx.Err() == nil {
|
||
st := strings.TrimSpace(stderr.String())
|
||
|
||
// ✅ 403/404 erkennen -> Private/Offline setzen
|
||
state, code := classifyPreviewFFmpegStderr(st)
|
||
|
||
jobsMu.Lock()
|
||
if state != "" {
|
||
job.PreviewState = state
|
||
job.PreviewStateAt = time.Now().UTC().Format(time.RFC3339Nano)
|
||
job.PreviewStateMsg = fmt.Sprintf("ffmpeg input returned HTTP %d", code)
|
||
} else {
|
||
job.PreviewState = "error"
|
||
job.PreviewStateAt = time.Now().UTC().Format(time.RFC3339Nano)
|
||
if len(st) > 280 {
|
||
job.PreviewStateMsg = st[:280] + "…"
|
||
} else {
|
||
job.PreviewStateMsg = st
|
||
}
|
||
}
|
||
jobsMu.Unlock()
|
||
notifyJobsChanged()
|
||
|
||
fmt.Printf("⚠️ preview hq ffmpeg failed: %v (%s)\n", err, st)
|
||
}
|
||
|
||
jobsMu.Lock()
|
||
if job.previewCmd == cmd {
|
||
job.previewCmd = nil
|
||
}
|
||
jobsMu.Unlock()
|
||
}()
|
||
|
||
// ✅ Live thumb writer starten (schreibt generated/<assetID>/preview.webp regelmäßig neu)
|
||
startLiveThumbWebPLoop(ctx, job)
|
||
|
||
return nil
|
||
}
|