// backend\serve_video.go package main import ( "bytes" "context" "fmt" "math" "net/http" "os" "path/filepath" "strings" "time" ) func serveVideoFile(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", "no-store") w.Header().Set("Accept-Ranges", "bytes") w.Header().Set("X-Content-Type-Options", "nosniff") ext := strings.ToLower(filepath.Ext(path)) switch ext { case ".ts": w.Header().Set("Content-Type", "video/mp2t") default: w.Header().Set("Content-Type", "video/mp4") } // ServeContent unterstützt Range Requests (wichtig für Video) http.ServeContent(w, r, filepath.Base(path), fi.ModTime(), f) } func sniffVideoKind(path string) (string, error) { f, err := openForReadShareDelete(path) if err != nil { return "", err } defer f.Close() buf := make([]byte, 64) n, _ := f.Read(buf) buf = buf[:n] // HTML? trim := bytes.TrimSpace(buf) if len(trim) >= 1 && trim[0] == '<' { return "html", nil } // MPEG-TS: 0x47 sync byte if len(buf) >= 1 && buf[0] == 0x47 { return "ts", nil } // MP4: "ftyp" typischerweise bei Offset 4 if len(buf) >= 8 && string(buf[4:8]) == "ftyp" { return "mp4", nil } return "unknown", nil } func maybeRemuxTS(path string) (string, error) { path = strings.TrimSpace(path) if path == "" { return "", nil } if !strings.EqualFold(filepath.Ext(path), ".ts") { return "", nil } mp4 := strings.TrimSuffix(path, filepath.Ext(path)) + ".mp4" // remux (ohne neu encoden) if err := remuxTSToMP4(path, mp4); err != nil { return "", err } _ = os.Remove(path) // TS entfernen, wenn MP4 ok return mp4, nil } func maybeRemuxTSForJob(job *RecordJob, path string) (string, error) { path = strings.TrimSpace(path) if path == "" { return "", nil } if !strings.EqualFold(filepath.Ext(path), ".ts") { return "", nil } mp4 := strings.TrimSuffix(path, filepath.Ext(path)) + ".mp4" // input size für fallback (optional für progress/ffmpeg) var inSize int64 if fi, err := os.Stat(path); err == nil && fi != nil && !fi.IsDir() { inSize = fi.Size() } // duration (für sauberen progress) var durSec float64 { durCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) durSec, _ = durationSecondsCached(durCtx, path) cancel() } // Throttle + monoton (lokal), globale Monotonie macht setJobProgress lastProgress := -1 lastTick := time.Now().Add(-time.Second) onRatio := func(r float64) { if r < 0 { r = 0 } if r > 1 { r = 1 } p := int(math.Round(r * 100)) if p < 0 { p = 0 } if p > 100 { p = 100 } // nur steigen lassen if p <= lastProgress { return } // leicht throttlen (außer kurz vor Schluss) if time.Since(lastTick) < 150*time.Millisecond && p < 99 { return } lastProgress = p lastTick = time.Now() // ✅ wichtig: 0..100 übergeben (Mapping macht setJobProgress) setJobProgress(job, "remuxing", p) } remuxCtx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) defer cancel() if err := remuxTSToMP4WithProgress(remuxCtx, path, mp4, durSec, inSize, onRatio); err != nil { return "", err } _ = os.Remove(path) // TS entfernen, wenn MP4 ok // ✅ Remux finished setJobProgress(job, "remuxing", 100) return mp4, nil } func moveToDoneDir(src string) (string, error) { src = strings.TrimSpace(src) if src == "" { return "", fmt.Errorf("src empty") } s := getSettings() doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil || strings.TrimSpace(doneAbs) == "" { // fallback doneAbs = strings.TrimSpace(s.DoneDir) } if strings.TrimSpace(doneAbs) == "" { return "", fmt.Errorf("doneDir empty") } // Quelle normalisieren/abs machen (best effort) srcAbs := filepath.Clean(src) if !filepath.IsAbs(srcAbs) { if abs, rerr := resolvePathRelativeToApp(srcAbs); rerr == nil && strings.TrimSpace(abs) != "" { srcAbs = abs } } fi, err := os.Stat(srcAbs) if err != nil || fi.IsDir() { return "", fmt.Errorf("src not found: %v", err) } file := filepath.Base(srcAbs) // Zielordner: immer done/ (keine model-subdirs) dstDir := doneAbs if err := os.MkdirAll(dstDir, 0o755); err != nil { return "", err } // Bei Kollisionen eindeutigen Namen wählen dst, err := uniqueDestPath(dstDir, file) if err != nil { return "", err } // Robust verschieben (Windows / Locks / Cross-device) if err := renameWithRetry(srcAbs, dst); err != nil { return "", err } // Duration-Cache invalidieren (du nutzt das ja) purgeDurationCacheForPath(srcAbs) return dst, nil }