233 lines
4.9 KiB
Go
233 lines
4.9 KiB
Go
// 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, filePath string) {
|
|
f, err := os.Open(filePath)
|
|
if err != nil {
|
|
http.Error(w, "open failed: "+err.Error(), http.StatusNotFound)
|
|
return
|
|
}
|
|
defer f.Close()
|
|
|
|
fi, err := f.Stat()
|
|
if err != nil || fi.IsDir() || fi.Size() <= 0 {
|
|
http.Error(w, "file not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
ext := strings.ToLower(filepath.Ext(filePath))
|
|
switch ext {
|
|
case ".mp4":
|
|
w.Header().Set("Content-Type", "video/mp4")
|
|
case ".ts":
|
|
w.Header().Set("Content-Type", "video/mp2t")
|
|
default:
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
}
|
|
|
|
// Range-Support (http.ServeContent macht 206/Content-Range automatisch, wenn Range kommt)
|
|
w.Header().Set("Accept-Ranges", "bytes")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
|
|
// ServeContent setzt Content-Length/Last-Modified/ETag-Handling korrekt
|
|
http.ServeContent(w, r, filepath.Base(filePath), 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
|
|
}
|