nsfwapp/backend/preview_hls.go
2026-02-20 18:18:59 +01:00

394 lines
9.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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/<jobId>/thumbs.webp regelmäßig neu)
startLiveThumbWebPLoop(ctx, job)
return nil
}