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