nsfwapp/backend/serve_video.go
2026-02-12 11:33:21 +01:00

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
}