// backend\main.go package main import ( "bufio" "bytes" "context" "crypto/sha1" "encoding/binary" "encoding/hex" "encoding/json" "errors" "fmt" "image" "image/color" "image/draw" "image/jpeg" "image/png" "io" "log" "math" "math/rand" "net/http" "net/url" "os" "os/exec" "path" "path/filepath" "regexp" "runtime" "sort" "strconv" "strings" "sync" "sync/atomic" "time" "github.com/grafov/m3u8" gocpu "github.com/shirou/gopsutil/v3/cpu" godisk "github.com/shirou/gopsutil/v3/disk" "golang.org/x/image/font" "golang.org/x/image/font/basicfont" "golang.org/x/image/math/fixed" ) var roomDossierRegexp = regexp.MustCompile(`window\.initialRoomDossier = "(.*?)"`) type JobStatus string const ( JobRunning JobStatus = "running" JobPostwork JobStatus = "postwork" // ✅ NEU: Aufnahme vorbei, Nacharbeiten laufen noch JobFinished JobStatus = "finished" JobStopped JobStatus = "stopped" JobFailed JobStatus = "failed" ) type RecordJob struct { ID string `json:"id"` SourceURL string `json:"sourceUrl"` Output string `json:"output"` Status JobStatus `json:"status"` StartedAt time.Time `json:"startedAt"` EndedAt *time.Time `json:"endedAt,omitempty"` DurationSeconds float64 `json:"durationSeconds,omitempty"` SizeBytes int64 `json:"sizeBytes,omitempty"` VideoWidth int `json:"videoWidth,omitempty"` VideoHeight int `json:"videoHeight,omitempty"` FPS float64 `json:"fps,omitempty"` Meta *videoMeta `json:"meta,omitempty"` Hidden bool `json:"-"` Error string `json:"error,omitempty"` PreviewDir string `json:"-"` PreviewImage string `json:"-"` previewCmd *exec.Cmd `json:"-"` LiveThumbStarted bool `json:"-"` // ✅ Preview-Status (z.B. private/offline anhand ffmpeg HTTP Fehler) PreviewState string `json:"previewState,omitempty"` // "", "private", "offline", "error" PreviewStateAt string `json:"previewStateAt,omitempty"` // RFC3339Nano PreviewStateMsg string `json:"previewStateMsg,omitempty"` // kurze Info // Thumbnail cache (verhindert, dass pro HTTP-Request ffmpeg läuft) previewMu sync.Mutex `json:"-"` previewJpeg []byte `json:"-"` previewJpegAt time.Time `json:"-"` previewGen bool `json:"-"` PreviewM3U8 string `json:"-"` // HLS url, die ffmpeg inputt PreviewCookie string `json:"-"` // Cookie header (falls nötig) PreviewUA string `json:"-"` // user-agent previewCancel context.CancelFunc `json:"-"` previewLastHit time.Time `json:"-"` previewStartMu sync.Mutex `json:"-"` // ✅ Frontend Progress beim Stop/Finalize Phase string `json:"phase,omitempty"` // stopping | remuxing | moving Progress int `json:"progress,omitempty"` // 0..100 PostWorkKey string `json:"postWorkKey,omitempty"` PostWork *PostWorkKeyStatus `json:"postWork,omitempty"` cancel context.CancelFunc `json:"-"` } type dummyResponseWriter struct { h http.Header } type ffprobeStreamInfo struct { Width int `json:"width"` Height int `json:"height"` AvgFrameRate string `json:"avg_frame_rate"` RFrameRate string `json:"r_frame_rate"` } type ffprobeInfo struct { Streams []ffprobeStreamInfo `json:"streams"` } func parseFFRate(s string) float64 { s = strings.TrimSpace(s) if s == "" || s == "0/0" { return 0 } // "30000/1001" if a, b, ok := strings.Cut(s, "/"); ok { num, err1 := strconv.ParseFloat(strings.TrimSpace(a), 64) den, err2 := strconv.ParseFloat(strings.TrimSpace(b), 64) if err1 == nil && err2 == nil && den != 0 { return num / den } return 0 } // "25" f, err := strconv.ParseFloat(s, 64) if err != nil { return 0 } return f } func probeVideoProps(ctx context.Context, filePath string) (w int, h int, fps float64, err error) { filePath = strings.TrimSpace(filePath) if filePath == "" { return 0, 0, 0, fmt.Errorf("empty path") } cmd := exec.CommandContext(ctx, ffprobePath, "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height,avg_frame_rate,r_frame_rate", "-of", "json", filePath, ) out, err := cmd.Output() if err != nil { return 0, 0, 0, err } var info ffprobeInfo if err := json.Unmarshal(out, &info); err != nil { return 0, 0, 0, err } if len(info.Streams) == 0 { return 0, 0, 0, fmt.Errorf("no video stream") } s := info.Streams[0] w, h = s.Width, s.Height // bevorzugt avg_frame_rate, fallback r_frame_rate fps = parseFFRate(s.AvgFrameRate) if fps <= 0 { fps = parseFFRate(s.RFrameRate) } return w, h, fps, nil } func metaJSONPathForAssetID(assetID string) (string, error) { root, err := generatedMetaRoot() if err != nil { return "", err } if strings.TrimSpace(root) == "" { return "", fmt.Errorf("generated/meta root leer") } return filepath.Join(root, assetID, "meta.json"), nil } func readVideoMetaIfValid(metaPath string, fi os.FileInfo) (*videoMeta, bool) { b, err := os.ReadFile(metaPath) if err != nil || len(b) == 0 { return nil, false } var m videoMeta if err := json.Unmarshal(b, &m); err != nil { return nil, false } // nur akzeptieren wenn Datei identisch (damit wir nicht stale Werte zeigen) if m.FileSize != fi.Size() || m.FileModUnix != fi.ModTime().Unix() { return nil, false } // Mindestvalidierung if m.DurationSeconds <= 0 { return nil, false } return &m, true } func ensureVideoMetaForFile(ctx context.Context, fullPath string, fi os.FileInfo, sourceURL string) (*videoMeta, bool) { // assetID aus Dateiname stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath)) assetID := stripHotPrefix(strings.TrimSpace(stem)) if assetID == "" { return nil, false } // sanitize wie bei deinen generated Ordnern var err error assetID, err = sanitizeID(assetID) if err != nil || assetID == "" { return nil, false } metaPath, err := metaJSONPathForAssetID(assetID) if err != nil { return nil, false } // 1) valid meta vorhanden? if m, ok := readVideoMetaIfValid(metaPath, fi); ok { return m, true } // 2) sonst neu erzeugen (mit Concurrency-Limit) if ctx == nil { ctx = context.Background() } cctx, cancel := context.WithTimeout(ctx, 8*time.Second) defer cancel() if durSem != nil { if err := durSem.Acquire(cctx); err != nil { return nil, false } defer durSem.Release() } // Dauer dur, derr := durationSecondsCached(cctx, fullPath) if derr != nil || dur <= 0 { return nil, false } // Video props w, h, fps, perr := probeVideoProps(cctx, fullPath) if perr != nil { // width/height/fps dürfen 0 bleiben, duration ist aber trotzdem nützlich w, h, fps = 0, 0, 0 } // meta dir anlegen _ = os.MkdirAll(filepath.Dir(metaPath), 0o755) m := &videoMeta{ Version: 2, DurationSeconds: dur, FileSize: fi.Size(), FileModUnix: fi.ModTime().Unix(), VideoWidth: w, VideoHeight: h, FPS: fps, Resolution: formatResolution(w, h), SourceURL: strings.TrimSpace(sourceURL), UpdatedAtUnix: time.Now().Unix(), } b, _ := json.MarshalIndent(m, "", " ") b = append(b, '\n') _ = atomicWriteFile(metaPath, b) // best effort return m, true } // ensureVideoMetaForFileBestEffort: // - versucht zuerst echtes Generieren (ffprobe/ffmpeg) via ensureVideoMetaForFile // - wenn das fehlschlägt, aber durationSecondsCacheOnly schon was weiß: // schreibt eine Duration-only meta.json, damit wir künftig "aus meta.json" lesen können. func ensureVideoMetaForFileBestEffort(ctx context.Context, fullPath string, sourceURL string) (*videoMeta, bool) { fullPath = strings.TrimSpace(fullPath) if fullPath == "" { return nil, false } fi, err := os.Stat(fullPath) if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { return nil, false } // 1) Normaler Weg: meta erzeugen/lesen (ffprobe/ffmpeg) if m, ok := ensureVideoMetaForFile(ctx, fullPath, fi, sourceURL); ok && m != nil { return m, true } // 2) Fallback: wenn wir Duration schon im RAM-Cache haben -> meta.json (Duration-only) persistieren dur := durationSecondsCacheOnly(fullPath, fi) if dur <= 0 { return nil, false } stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath)) assetID := stripHotPrefix(strings.TrimSpace(stem)) if assetID == "" { return nil, false } metaPath, err := metaJSONPathForAssetID(assetID) if err != nil || strings.TrimSpace(metaPath) == "" { return nil, false } _ = os.MkdirAll(filepath.Dir(metaPath), 0o755) _ = writeVideoMetaDuration(metaPath, fi, dur, sourceURL) // nochmal lesen/validieren if m, ok := readVideoMetaIfValid(metaPath, fi); ok && m != nil { return m, true } return nil, false } func (d *dummyResponseWriter) Header() http.Header { if d.h == nil { d.h = make(http.Header) } return d.h } func (d *dummyResponseWriter) Write(b []byte) (int, error) { return len(b), nil } func (d *dummyResponseWriter) WriteHeader(statusCode int) {} var ( jobs = map[string]*RecordJob{} jobsMu = sync.Mutex{} ) var serverStartedAt = time.Now() var lastCPUUsageBits uint64 // atomic float64 bits func setLastCPUUsage(v float64) { atomic.StoreUint64(&lastCPUUsageBits, math.Float64bits(v)) } func getLastCPUUsage() float64 { return math.Float64frombits(atomic.LoadUint64(&lastCPUUsageBits)) } // -------------------- SSE: /api/record/stream -------------------- type sseHub struct { mu sync.Mutex clients map[chan []byte]struct{} } func newSSEHub() *sseHub { return &sseHub{clients: map[chan []byte]struct{}{}} } func (h *sseHub) add(ch chan []byte) { h.mu.Lock() h.clients[ch] = struct{}{} h.mu.Unlock() } func (h *sseHub) remove(ch chan []byte) { h.mu.Lock() delete(h.clients, ch) h.mu.Unlock() close(ch) } func (h *sseHub) broadcast(b []byte) { h.mu.Lock() defer h.mu.Unlock() for ch := range h.clients { // Non-blocking: langsame Clients droppen Updates (holen sich beim nächsten Update wieder ein) select { case ch <- b: default: } } } var doneHub = newSSEHub() var doneNotify = make(chan struct{}, 1) // optional: monotoner Zähler, damit Clients auch bei "gleichen" payloads triggern können var doneSeq uint64 func notifyDoneChanged() { select { case doneNotify <- struct{}{}: default: } } var recordJobsHub = newSSEHub() var recordJobsNotify = make(chan struct{}, 1) func startPreviewIdleKiller() { t := time.NewTicker(5 * time.Second) go func() { defer t.Stop() for range t.C { jobsMu.Lock() list := make([]*RecordJob, 0, len(jobs)) for _, j := range jobs { if j != nil { list = append(list, j) } } jobsMu.Unlock() for _, j := range list { jobsMu.Lock() cmdRunning := j.previewCmd != nil last := j.previewLastHit st := j.Status jobsMu.Unlock() if !cmdRunning { continue } // wenn Job nicht mehr läuft oder Hover weg if st != JobRunning || (!last.IsZero() && time.Since(last) > 10*time.Minute) { stopPreview(j) } } } }() } func init() { initFFmpegSemaphores() startAdaptiveSemController(context.Background()) startPreviewIdleKiller() // Debounced broadcaster (jobs) go func() { for range recordJobsNotify { time.Sleep(40 * time.Millisecond) for { select { case <-recordJobsNotify: default: goto SEND } } SEND: recordJobsHub.broadcast(jobsSnapshotJSON()) } }() // Debounced broadcaster (done changed) go func() { for range doneNotify { time.Sleep(40 * time.Millisecond) for { select { case <-doneNotify: default: goto SEND } } SEND: seq := atomic.AddUint64(&doneSeq, 1) // Payload bewusst klein halten: Client soll nur "refetch done" machen b := []byte(fmt.Sprintf(`{"type":"doneChanged","seq":%d,"ts":%d}`, seq, time.Now().UnixMilli())) doneHub.broadcast(b) } }() } func publishJob(jobID string) bool { jobsMu.Lock() j := jobs[jobID] if j == nil || !j.Hidden { jobsMu.Unlock() return false } j.Hidden = false jobsMu.Unlock() notifyJobsChanged() return true } func notifyJobsChanged() { select { case recordJobsNotify <- struct{}{}: default: } } func jobsSnapshotJSON() []byte { jobsMu.Lock() list := make([]*RecordJob, 0, len(jobs)) for _, j := range jobs { // ✅ Hidden-Jobs niemals an die UI senden (verhindert „UI springt“) if j == nil || j.Hidden { continue } c := *j c.cancel = nil // nicht serialisieren list = append(list, &c) } jobsMu.Unlock() // optional: neueste zuerst sort.Slice(list, func(i, j int) bool { return list[i].StartedAt.After(list[j].StartedAt) }) b, _ := json.Marshal(list) return b } func recordStream(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed) return } flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "Streaming nicht unterstützt", http.StatusInternalServerError) return } // SSE-Header h := w.Header() h.Set("Content-Type", "text/event-stream; charset=utf-8") h.Set("Cache-Control", "no-cache, no-transform") h.Set("Connection", "keep-alive") h.Set("X-Accel-Buffering", "no") // hilfreich bei Reverse-Proxies // sofort starten w.WriteHeader(http.StatusOK) writeEvent := func(event string, data []byte) bool { // returns false => client weg / write error if event != "" { if _, err := fmt.Fprintf(w, "event: %s\n", event); err != nil { return false } } if len(data) > 0 { if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil { return false } } else { // empty payload ok (nur terminator) if _, err := io.WriteString(w, "\n"); err != nil { return false } } flusher.Flush() return true } writeComment := func(msg string) bool { if _, err := fmt.Fprintf(w, ": %s\n\n", msg); err != nil { return false } flusher.Flush() return true } // Reconnect-Hinweis if _, err := fmt.Fprintf(w, "retry: 3000\n\n"); err != nil { return } flusher.Flush() // Channel + Hub ch := make(chan []byte, 32) recordJobsHub.add(ch) defer recordJobsHub.remove(ch) // Initialer Snapshot sofort if b := jobsSnapshotJSON(); len(b) > 0 { if !writeEvent("jobs", b) { return } } ctx := r.Context() // Ping/Keepalive ping := time.NewTicker(15 * time.Second) defer ping.Stop() for { select { case <-ctx.Done(): return case b, ok := <-ch: if !ok { return } if len(b) == 0 { continue } // ✅ Burst-Coalescing: wenn viele Updates schnell kommen, nur das neueste senden last := b drain: for i := 0; i < 64; i++ { select { case nb, ok := <-ch: if !ok { return } if len(nb) > 0 { last = nb } default: break drain } } if !writeEvent("jobs", last) { return } case <-ping.C: // Keepalive als Kommentar (stört nicht, hält Verbindungen offen) if !writeComment(fmt.Sprintf("ping %d", time.Now().Unix())) { return } } } } // ffmpeg-Binary suchen (env, neben EXE, oder PATH) var ffmpegPath = detectFFmpegPath() var ffprobePath = detectFFprobePath() func detectFFprobePath() string { // 1) Env-Override if p := strings.TrimSpace(os.Getenv("FFPROBE_PATH")); p != "" { if abs, err := filepath.Abs(p); err == nil { return abs } return p } // 2) Neben ffmpeg.exe (gleicher Ordner) fp := strings.TrimSpace(ffmpegPath) if fp != "" && fp != "ffmpeg" { dir := filepath.Dir(fp) ext := "" if strings.HasSuffix(strings.ToLower(fp), ".exe") { ext = ".exe" } c := filepath.Join(dir, "ffprobe"+ext) if fi, err := os.Stat(c); err == nil && !fi.IsDir() { return c } } // 3) Im EXE-Ordner if exe, err := os.Executable(); err == nil { exeDir := filepath.Dir(exe) candidates := []string{ filepath.Join(exeDir, "ffprobe"), filepath.Join(exeDir, "ffprobe.exe"), } for _, c := range candidates { if fi, err := os.Stat(c); err == nil && !fi.IsDir() { return c } } } // 4) PATH if lp, err := exec.LookPath("ffprobe"); err == nil { if abs, err2 := filepath.Abs(lp); err2 == nil { return abs } return lp } return "ffprobe" } // ---------- Dynamic Semaphore (resizeable by load controller) ---------- type DynSem struct { mu sync.Mutex in int max int cap int } func NewDynSem(initial, cap int) *DynSem { if cap < 1 { cap = 1 } if initial < 1 { initial = 1 } if initial > cap { initial = cap } return &DynSem{max: initial, cap: cap} } func (s *DynSem) Acquire(ctx context.Context) error { for { if ctx != nil && ctx.Err() != nil { return ctx.Err() } s.mu.Lock() if s.in < s.max { s.in++ s.mu.Unlock() return nil } s.mu.Unlock() time.Sleep(25 * time.Millisecond) } } func (s *DynSem) Release() { s.mu.Lock() if s.in > 0 { s.in-- } s.mu.Unlock() } func (s *DynSem) SetMax(n int) { if n < 1 { n = 1 } if n > s.cap { n = s.cap } s.mu.Lock() s.max = n s.mu.Unlock() } func (s *DynSem) Max() int { s.mu.Lock() defer s.mu.Unlock() return s.max } func (s *DynSem) Cap() int { s.mu.Lock() defer s.mu.Unlock() return s.cap } func (s *DynSem) InUse() int { s.mu.Lock() defer s.mu.Unlock() return s.in } var ( genSem *DynSem previewSem *DynSem thumbSem *DynSem durSem *DynSem ) func clamp(n, lo, hi int) int { if n < lo { return lo } if n > hi { return hi } return n } func envInt(name string) (int, bool) { v := strings.TrimSpace(os.Getenv(name)) if v == "" { return 0, false } n, err := strconv.Atoi(v) if err != nil { return 0, false } return n, true } func initFFmpegSemaphores() { cpu := runtime.NumCPU() if cpu <= 0 { cpu = 2 } // Defaults (heuristisch) previewN := clamp((cpu+1)/2, 1, 6) // x264 live -> konservativ thumbN := clamp(cpu, 2, 12) // Frames -> darf höher genN := clamp((cpu+3)/4, 1, 4) // preview.mp4 clips -> eher klein durN := clamp(cpu, 2, 16) // ffprobe: darf höher, aber nicht unbegrenzt // ENV Overrides (optional) if n, ok := envInt("PREVIEW_WORKERS"); ok { previewN = clamp(n, 1, 32) } if n, ok := envInt("THUMB_WORKERS"); ok { thumbN = clamp(n, 1, 64) } if n, ok := envInt("GEN_WORKERS"); ok { genN = clamp(n, 1, 16) } if n, ok := envInt("DUR_WORKERS"); ok { durN = clamp(n, 1, 64) } // Caps (Obergrenzen) – können via ENV überschrieben werden previewCap := clamp(cpu, 2, 12) thumbCap := clamp(cpu*2, 4, 32) genCap := clamp((cpu+1)/2, 2, 12) durCap := clamp(cpu*2, 4, 32) if n, ok := envInt("PREVIEW_CAP"); ok { previewCap = clamp(n, 1, 64) } if n, ok := envInt("THUMB_CAP"); ok { thumbCap = clamp(n, 1, 128) } if n, ok := envInt("GEN_CAP"); ok { genCap = clamp(n, 1, 64) } if n, ok := envInt("DUR_CAP"); ok { durCap = clamp(n, 1, 128) } // Initial max (Startwerte) previewSem = NewDynSem(previewN, previewCap) thumbSem = NewDynSem(thumbN, thumbCap) genSem = NewDynSem(genN, genCap) durSem = NewDynSem(durN, durCap) fmt.Printf( "🔧 semaphores(init): preview=%d/%d thumb=%d/%d gen=%d/%d dur=%d/%d (cpu=%d)\n", previewSem.Max(), previewSem.Cap(), thumbSem.Max(), thumbSem.Cap(), genSem.Max(), genSem.Cap(), durSem.Max(), durSem.Cap(), cpu, ) fmt.Printf( "🔧 semaphores: preview=%d thumb=%d gen=%d dur=%d (cpu=%d)\n", previewN, thumbN, genN, durN, cpu, ) } func startAdaptiveSemController(ctx context.Context) { targetHi := 85.0 targetLo := 65.0 if v := strings.TrimSpace(os.Getenv("CPU_TARGET_HI")); v != "" { if f, err := strconv.ParseFloat(v, 64); err == nil { targetHi = f } } if v := strings.TrimSpace(os.Getenv("CPU_TARGET_LO")); v != "" { if f, err := strconv.ParseFloat(v, 64); err == nil { targetLo = f } } // Warmup (erste Messung kann 0 sein) _, _ = gocpu.Percent(200*time.Millisecond, false) t := time.NewTicker(2 * time.Second) go func() { defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: p, err := gocpu.Percent(0, false) if err != nil || len(p) == 0 { continue } usage := p[0] setLastCPUUsage(usage) // Preview ist am teuersten → konservativ if usage > targetHi { previewSem.SetMax(previewSem.Max() - 1) genSem.SetMax(genSem.Max() - 1) thumbSem.SetMax(thumbSem.Max() - 1) } else if usage < targetLo { previewSem.SetMax(previewSem.Max() + 1) genSem.SetMax(genSem.Max() + 1) thumbSem.SetMax(thumbSem.Max() + 1) } // optional Debug: // fmt.Printf("CPU %.1f%% -> preview=%d thumb=%d gen=%d\n", usage, previewSem.Max(), thumbSem.Max(), genSem.Max()) } } }() } type durEntry struct { size int64 mod time.Time sec float64 } var durCache = struct { mu sync.Mutex m map[string]durEntry }{m: map[string]durEntry{}} var startedAtFromFilenameRe = regexp.MustCompile( `^(.+)_([0-9]{1,2})_([0-9]{1,2})_([0-9]{4})__([0-9]{1,2})-([0-9]{2})-([0-9]{2})$`, ) func buildPerfSnapshot() map[string]any { var ms runtime.MemStats runtime.ReadMemStats(&ms) s := getSettings() recordDir, _ := resolvePathRelativeToApp(s.RecordDir) var diskFreeBytes uint64 var diskTotalBytes uint64 var diskUsedPercent float64 diskPath := recordDir if recordDir != "" { if u, err := godisk.Usage(recordDir); err == nil && u != nil { diskFreeBytes = u.Free diskTotalBytes = u.Total diskUsedPercent = u.UsedPercent } } // ✅ Dynamische Disk-Schwellen (2× inFlight, Resume = +3GB) pauseGB, resumeGB, inFlight, pauseNeed, resumeNeed := computeDiskThresholds() resp := map[string]any{ "ts": time.Now().UTC().Format(time.RFC3339Nano), "serverMs": time.Now().UTC().UnixMilli(), // ✅ für "Ping" im Frontend (Approx) "uptimeSec": time.Since(serverStartedAt).Seconds(), "cpuPercent": func() float64 { v := getLastCPUUsage() if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 { return 0 } return v }(), "diskPath": diskPath, "diskFreeBytes": diskFreeBytes, "diskTotalBytes": diskTotalBytes, "diskUsedPercent": diskUsedPercent, "diskEmergency": atomic.LoadInt32(&diskEmergency) == 1, // ✅ statt LowDiskPauseBelowGB aus Settings "diskPauseBelowGB": pauseGB, "diskResumeAboveGB": resumeGB, // ✅ optional, aber sehr hilfreich (Debug/UI) "diskInFlightBytes": inFlight, "diskInFlightHuman": formatBytesSI(u64ToI64(inFlight)), "diskPauseNeedBytes": pauseNeed, "diskPauseNeedHuman": formatBytesSI(u64ToI64(pauseNeed)), "diskResumeNeedBytes": resumeNeed, "diskResumeNeedHuman": formatBytesSI(u64ToI64(resumeNeed)), "goroutines": runtime.NumGoroutine(), "mem": map[string]any{ "alloc": ms.Alloc, "heapAlloc": ms.HeapAlloc, "heapInuse": ms.HeapInuse, "sys": ms.Sys, "numGC": ms.NumGC, }, } sem := map[string]any{} if genSem != nil { sem["gen"] = map[string]any{"inUse": genSem.InUse(), "cap": genSem.Cap(), "max": genSem.Max()} } if previewSem != nil { sem["preview"] = map[string]any{"inUse": previewSem.InUse(), "cap": previewSem.Cap(), "max": previewSem.Max()} } if thumbSem != nil { sem["thumb"] = map[string]any{"inUse": thumbSem.InUse(), "cap": thumbSem.Cap(), "max": thumbSem.Max()} } if durSem != nil { sem["dur"] = map[string]any{"inUse": durSem.InUse(), "cap": durSem.Cap(), "max": durSem.Max()} } if len(sem) > 0 { resp["sem"] = sem } return resp } func pingHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { http.Error(w, "Nur GET/HEAD erlaubt", http.StatusMethodNotAllowed) return } w.Header().Set("Cache-Control", "no-store") w.WriteHeader(http.StatusNoContent) } func perfHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed) return } resp := buildPerfSnapshot() w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(resp) } func perfStreamHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed) return } fl, ok := w.(http.Flusher) if !ok { http.Error(w, "Streaming nicht unterstützt", http.StatusInternalServerError) return } // Optional: client kann Intervall mitgeben: /api/perf/stream?ms=5000 ms := 5000 if q := r.URL.Query().Get("ms"); q != "" { if v, err := strconv.Atoi(q); err == nil { // clamp: 1000..30000 if v < 1000 { v = 1000 } if v > 30000 { v = 30000 } ms = v } } w.Header().Set("Content-Type", "text/event-stream; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.Header().Set("Connection", "keep-alive") // hilfreich hinter nginx/proxies: w.Header().Set("X-Accel-Buffering", "no") ctx := r.Context() // sofort erstes Event schicken send := func() error { payload := buildPerfSnapshot() var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(payload); err != nil { return err } // event: perf _, _ = io.WriteString(w, "event: perf\n") _, _ = io.WriteString(w, "data: ") _, _ = w.Write(buf.Bytes()) _, _ = io.WriteString(w, "\n") fl.Flush() return nil } // initial _ = send() t := time.NewTicker(time.Duration(ms) * time.Millisecond) hb := time.NewTicker(15 * time.Second) // heartbeat gegen Proxy timeouts defer t.Stop() defer hb.Stop() for { select { case <-ctx.Done(): return case <-t.C: _ = send() case <-hb.C: // SSE Kommentar als Heartbeat _, _ = io.WriteString(w, ": keep-alive\n\n") fl.Flush() } } } // ------------------------- // Low disk space guard // - pausiert Autostart // - stoppt laufende Downloads // ------------------------- const ( diskGuardInterval = 5 * time.Second ) var diskEmergency int32 // 0=false, 1=true type diskStatusResp struct { Emergency bool `json:"emergency"` PauseGB int `json:"pauseGB"` ResumeGB int `json:"resumeGB"` FreeBytes uint64 `json:"freeBytes"` FreeBytesHuman string `json:"freeBytesHuman"` RecordPath string `json:"recordPath"` } func diskStatusHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { http.Error(w, "Nur GET/HEAD erlaubt", http.StatusMethodNotAllowed) return } s := getSettings() pauseGB, resumeGB, _, _, _ := computeDiskThresholds() recordDirAbs, _ := resolvePathRelativeToApp(s.RecordDir) dir := strings.TrimSpace(recordDirAbs) if dir == "" { dir = strings.TrimSpace(s.RecordDir) } free := uint64(0) if dir != "" { if u, err := godisk.Usage(dir); err == nil && u != nil { free = u.Free } } resp := diskStatusResp{ Emergency: atomic.LoadInt32(&diskEmergency) == 1, PauseGB: pauseGB, ResumeGB: resumeGB, FreeBytes: free, FreeBytesHuman: formatBytesSI(int64(free)), RecordPath: dir, } w.Header().Set("Cache-Control", "no-store") writeJSON(w, http.StatusOK, resp) } // stopJobsInternal markiert Jobs als "stopping" und cancelt sie (inkl. Preview-FFmpeg Kill). // Nutzt 2 notify-Pushes, damit die UI Phase/Progress sofort sieht. func stopJobsInternal(list []*RecordJob) { if len(list) == 0 { return } type payload struct { cmd *exec.Cmd cancel context.CancelFunc } pl := make([]payload, 0, len(list)) jobsMu.Lock() for _, job := range list { if job == nil { continue } job.Phase = "stopping" job.Progress = 10 pl = append(pl, payload{cmd: job.previewCmd, cancel: job.cancel}) job.previewCmd = nil } jobsMu.Unlock() notifyJobsChanged() // 1) UI sofort updaten (Phase/Progress) for _, p := range pl { if p.cmd != nil && p.cmd.Process != nil { _ = p.cmd.Process.Kill() } if p.cancel != nil { p.cancel() } } notifyJobsChanged() // 2) optional: nach Cancel/Kill nochmal pushen } func stopAllStoppableJobs() int { stoppable := make([]*RecordJob, 0, 16) jobsMu.Lock() for _, j := range jobs { if j == nil { continue } if j.Status != JobRunning { continue } phase := strings.ToLower(strings.TrimSpace(j.Phase)) // ✅ Im Disk-Notfall ALLES stoppen, was noch schreibt. // Wir skippen nur Jobs, die sowieso schon im "stopping" sind. if phase == "stopping" { continue } stoppable = append(stoppable, j) } jobsMu.Unlock() stopJobsInternal(stoppable) return len(stoppable) } func shouldAutoDeleteSmallDownload(filePath string) (bool, int64, int64) { // returns: (shouldDelete, sizeBytes, thresholdBytes) s := getSettings() if !s.AutoDeleteSmallDownloads { return false, 0, 0 } mb := s.AutoDeleteSmallDownloadsBelowMB if mb <= 0 { return false, 0, 0 } p := strings.TrimSpace(filePath) if p == "" { return false, 0, 0 } // relativ -> absolut versuchen (best effort) if !filepath.IsAbs(p) { if abs, err := resolvePathRelativeToApp(p); err == nil && strings.TrimSpace(abs) != "" { p = abs } } fi, err := os.Stat(p) if err != nil || fi.IsDir() { return false, 0, int64(mb) * 1024 * 1024 } size := fi.Size() thr := int64(mb) * 1024 * 1024 if size > 0 && size < thr { return true, size, thr } return false, size, thr } func sizeOfPathBestEffort(p string) uint64 { p = strings.TrimSpace(p) if p == "" { return 0 } // relativ -> absolut versuchen if !filepath.IsAbs(p) { if abs, err := resolvePathRelativeToApp(p); err == nil && strings.TrimSpace(abs) != "" { p = abs } } fi, err := os.Stat(p) if err != nil || fi.IsDir() || fi.Size() <= 0 { return 0 } return uint64(fi.Size()) } func inFlightBytesForJob(j *RecordJob) uint64 { if j == nil { return 0 } // Prefer live-tracked bytes if available (accurate & cheap). if j.SizeBytes > 0 { return uint64(j.SizeBytes) } return sizeOfPathBestEffort(j.Output) } const giB = uint64(1024 * 1024 * 1024) // computeDiskThresholds: // Pause = ceil( (2 * inFlightBytes) / GiB ) // Resume = Pause + 3 GB (Hysterese) // Wenn inFlight==0 => Pause/Resume = 0 func computeDiskThresholds() (pauseGB int, resumeGB int, inFlight uint64, pauseNeed uint64, resumeNeed uint64) { inFlight = sumInFlightBytes() if inFlight == 0 { return 0, 0, 0, 0, 0 } need := inFlight * 2 pauseGB = int((need + giB - 1) / giB) // ceil // Safety cap (nur zur Sicherheit, falls irgendwas eskaliert) if pauseGB > 10_000 { pauseGB = 10_000 } resumeGB = pauseGB + 3 if resumeGB > 10_000 { resumeGB = 10_000 } pauseNeed = uint64(pauseGB) * giB resumeNeed = uint64(resumeGB) * giB return } // ✅ Summe der "wachsenden" Daten (running + remuxing etc.) // Idee: Für TS->MP4 Peak brauchst du grob nochmal die Größe der aktuellen Datei als Reserve. func sumInFlightBytes() uint64 { var sum uint64 jobsMu.Lock() defer jobsMu.Unlock() for _, j := range jobs { if j == nil { continue } if j.Status != JobRunning { continue } // Nimm die Datei, die gerade wächst. // In deinem System ist das typischerweise j.Output (TS oder temporäres Ziel). // Falls du ein separates Feld für "TempTS" o.ä. hast: hier ergänzen. sum += inFlightBytesForJob(j) } return sum } // startDiskSpaceGuard läuft im Backend und reagiert auch ohne offenen Browser. // Bei wenig freiem Platz: // - Autostart pausieren // - laufende Jobs stoppen (nur Status=running und Phase leer) func startDiskSpaceGuard() { t := time.NewTicker(diskGuardInterval) defer t.Stop() for range t.C { s := getSettings() // Pfad bestimmen, auf dem wir freien Speicher prüfen recordDirAbs, _ := resolvePathRelativeToApp(s.RecordDir) dir := strings.TrimSpace(recordDirAbs) if dir == "" { dir = strings.TrimSpace(s.RecordDir) } if dir == "" { continue } u, err := godisk.Usage(dir) if err != nil || u == nil { continue } free := u.Free // ✅ Dynamische Schwellen: // Pause = ceil((2 * inFlight) / GiB) // Resume = Pause + 3 GB // pauseNeed/resumeNeed sind die benötigten freien Bytes pauseGB, resumeGB, inFlight, pauseNeed, resumeNeed := computeDiskThresholds() // Wenn nichts läuft, gibt es nichts zu reservieren. // (Optional: Emergency zurücksetzen, damit Autostart wieder frei wird.) if inFlight == 0 { if atomic.LoadInt32(&diskEmergency) == 1 { atomic.StoreInt32(&diskEmergency, 0) fmt.Printf( "✅ [disk] No active jobs: emergency cleared (free=%s, path=%s)\n", formatBytesSI(u64ToI64(free)), dir, ) } continue } // ✅ Hysterese: erst ab resumeNeed wieder "bereit" if atomic.LoadInt32(&diskEmergency) == 1 { if free >= resumeNeed { atomic.StoreInt32(&diskEmergency, 0) fmt.Printf( "✅ [disk] Recovered: free=%s (%dB) (>= %s, %dB) emergency cleared (pause=%dGB resume=%dGB inFlight=%s, %dB)\n", formatBytesSI(u64ToI64(free)), free, formatBytesSI(u64ToI64(resumeNeed)), resumeNeed, pauseGB, resumeGB, formatBytesSI(u64ToI64(inFlight)), inFlight, ) } continue } // ✅ Normalzustand: solange free >= pauseNeed, nichts tun if free >= pauseNeed { continue } // ✅ Trigger: Notbremse aktivieren, Jobs stoppen atomic.StoreInt32(&diskEmergency, 1) fmt.Printf( "🛑 [disk] Low space: free=%s (%dB) (< %s, %dB, pause=%dGB resume=%dGB, inFlight=%s, %dB) -> stop jobs + block autostart via diskEmergency (path=%s)\n", formatBytesSI(u64ToI64(free)), free, formatBytesSI(u64ToI64(pauseNeed)), pauseNeed, pauseGB, resumeGB, formatBytesSI(u64ToI64(inFlight)), inFlight, dir, ) stopped := stopAllStoppableJobs() if stopped > 0 { fmt.Printf("🛑 [disk] Stop requested for %d job(s)\n", stopped) } } } func setJobPhase(job *RecordJob, phase string, progress int) { if progress < 0 { progress = 0 } if progress > 100 { progress = 100 } jobsMu.Lock() job.Phase = phase job.Progress = progress jobsMu.Unlock() notifyJobsChanged() } func durationSecondsCached(ctx context.Context, path string) (float64, error) { fi, err := os.Stat(path) if err != nil { return 0, err } durCache.mu.Lock() if e, ok := durCache.m[path]; ok && e.size == fi.Size() && e.mod.Equal(fi.ModTime()) && e.sec > 0 { durCache.mu.Unlock() return e.sec, nil } durCache.mu.Unlock() // 1) ffprobe (bevorzugt) cmd := exec.CommandContext(ctx, ffprobePath, "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", path, ) out, err := cmd.Output() if err == nil { s := strings.TrimSpace(string(out)) sec, err2 := strconv.ParseFloat(s, 64) if err2 == nil && sec > 0 { durCache.mu.Lock() durCache.m[path] = durEntry{size: fi.Size(), mod: fi.ModTime(), sec: sec} durCache.mu.Unlock() return sec, nil } } // 2) Fallback: ffmpeg -i "Duration: HH:MM:SS.xx" parsen cmd2 := exec.CommandContext(ctx, ffmpegPath, "-i", path) b, _ := cmd2.CombinedOutput() // ffmpeg liefert hier oft ExitCode!=0, Output ist trotzdem da text := string(b) re := regexp.MustCompile(`Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)`) m := re.FindStringSubmatch(text) if len(m) != 4 { return 0, fmt.Errorf("duration not found") } hh, _ := strconv.ParseFloat(m[1], 64) mm, _ := strconv.ParseFloat(m[2], 64) ss, _ := strconv.ParseFloat(m[3], 64) sec := hh*3600 + mm*60 + ss if sec <= 0 { return 0, fmt.Errorf("invalid duration") } durCache.mu.Lock() durCache.m[path] = durEntry{size: fi.Size(), mod: fi.ModTime(), sec: sec} durCache.mu.Unlock() return sec, nil } func detectFFmpegPath() string { // 0. Settings-Override (ffmpegPath in recorder_settings.json / UI) s := getSettings() if p := strings.TrimSpace(s.FFmpegPath); p != "" { // Relativ zur EXE auflösen, falls nötig if !filepath.IsAbs(p) { if abs, err := resolvePathRelativeToApp(p); err == nil { p = abs } } return p } // 1. Umgebungsvariable FFMPEG_PATH erlaubt Override if p := strings.TrimSpace(os.Getenv("FFMPEG_PATH")); p != "" { if abs, err := filepath.Abs(p); err == nil { return abs } return p } // 2. ffmpeg / ffmpeg.exe im selben Ordner wie dein Go-Programm if exe, err := os.Executable(); err == nil { exeDir := filepath.Dir(exe) candidates := []string{ filepath.Join(exeDir, "ffmpeg"), filepath.Join(exeDir, "ffmpeg.exe"), } for _, c := range candidates { if fi, err := os.Stat(c); err == nil && !fi.IsDir() { return c } } } // 3. ffmpeg über PATH suchen und absolut machen if lp, err := exec.LookPath("ffmpeg"); err == nil { if abs, err2 := filepath.Abs(lp); err2 == nil { return abs } return lp } // 4. Fallback: plain "ffmpeg" – kann dann immer noch fehlschlagen return "ffmpeg" } func removeGeneratedForID(id string) { // ✅ canonical id: wie beim Erzeugen der generated Ordner id = strings.TrimSpace(id) if id == "" { return } // falls jemand "file.mp4" übergibt id = strings.TrimSuffix(id, filepath.Ext(id)) // HOT Prefix weg id = stripHotPrefix(id) // wichtig: exakt gleiche Normalisierung wie überall sonst (Ordnernamen!) var err error id, err = sanitizeID(id) if err != nil || id == "" { return } // 1) NEU: generated/meta// ... if root, _ := generatedMetaRoot(); strings.TrimSpace(root) != "" { _ = os.RemoveAll(filepath.Join(root, id)) } // (optional aber sinnvoll) 1b) Legacy: generated// (falls noch alte Assets existieren) if root, _ := generatedRoot(); strings.TrimSpace(root) != "" { _ = os.RemoveAll(filepath.Join(root, id)) } // 2) Temp Preview Segmente (HLS) wegräumen // (%TEMP%/rec_preview/) _ = os.RemoveAll(filepath.Join(os.TempDir(), "rec_preview", id)) // 3) Legacy Cleanup (best effort) thumbsLegacy, _ := generatedThumbsRoot() teaserLegacy, _ := generatedTeaserRoot() if strings.TrimSpace(thumbsLegacy) != "" { _ = os.RemoveAll(filepath.Join(thumbsLegacy, id)) _ = os.Remove(filepath.Join(thumbsLegacy, id+".jpg")) } if strings.TrimSpace(teaserLegacy) != "" { _ = os.Remove(filepath.Join(teaserLegacy, id+".mp4")) _ = os.Remove(filepath.Join(teaserLegacy, id+"_teaser.mp4")) } } func purgeDurationCacheForPath(p string) { p = strings.TrimSpace(p) if p == "" { return } durCache.mu.Lock() delete(durCache.m, p) durCache.mu.Unlock() } func renameGenerated(oldID, newID string) { thumbsRoot, _ := generatedThumbsRoot() teaserRoot, _ := generatedTeaserRoot() oldThumb := filepath.Join(thumbsRoot, oldID) newThumb := filepath.Join(thumbsRoot, newID) if _, err := os.Stat(oldThumb); err == nil { if _, err2 := os.Stat(newThumb); os.IsNotExist(err2) { _ = os.Rename(oldThumb, newThumb) } else { _ = os.RemoveAll(oldThumb) } } oldTeaser := filepath.Join(teaserRoot, oldID+".mp4") newTeaser := filepath.Join(teaserRoot, newID+".mp4") if _, err := os.Stat(oldTeaser); err == nil { if _, err2 := os.Stat(newTeaser); os.IsNotExist(err2) { _ = os.Rename(oldTeaser, newTeaser) } else { _ = os.Remove(oldTeaser) } } } // --- Gemeinsame Status-Werte für MFC --- type Status int const ( StatusUnknown Status = iota StatusPublic StatusPrivate StatusOffline StatusNotExist ) func (s Status) String() string { switch s { case StatusPublic: return "PUBLIC" case StatusPrivate: return "PRIVATE" case StatusOffline: return "OFFLINE" case StatusNotExist: return "NOTEXIST" default: return "UNKNOWN" } } // HTTPClient kapselt http.Client + Header/Cookies (wie internal.Req im DVR) type HTTPClient struct { client *http.Client userAgent string } // gemeinsamen HTTP-Client erzeugen func NewHTTPClient(userAgent string) *HTTPClient { if userAgent == "" { // Default, falls kein UA übergeben wird userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" } return &HTTPClient{ client: &http.Client{ Timeout: 10 * time.Second, }, userAgent: userAgent, } } // Request-Erstellung mit User-Agent + Cookies func (h *HTTPClient) NewRequest(ctx context.Context, method, url, cookieStr string) (*http.Request, error) { req, err := http.NewRequestWithContext(ctx, method, url, nil) if err != nil { return nil, err } // Basis-Header, die immer gesetzt werden if h.userAgent != "" { req.Header.Set("User-Agent", h.userAgent) } else { req.Header.Set("User-Agent", "Mozilla/5.0") } req.Header.Set("Accept", "*/*") // Cookie-String wie "name=value; foo=bar" addCookiesFromString(req, cookieStr) return req, nil } // Seite laden + einfache Erkennung von Schutzseiten (Cloudflare / Age-Gate) func (h *HTTPClient) FetchPage(ctx context.Context, url, cookieStr string) (string, error) { req, err := h.NewRequest(ctx, http.MethodGet, url, cookieStr) if err != nil { return "", err } resp, err := h.client.Do(req) if err != nil { return "", err } defer resp.Body.Close() data, err := io.ReadAll(resp.Body) if err != nil { return "", err } body := string(data) // Etwas aussagekräftigere Fehler als nur "room dossier nicht gefunden" if strings.Contains(body, "Just a moment...") { return "", errors.New("Schutzseite von Cloudflare erhalten (\"Just a moment...\") – kein Room-HTML") } if strings.Contains(body, "Verify your age") { return "", errors.New("Altersverifikationsseite erhalten – kein Room-HTML") } if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("HTTP %d beim Laden von %s", resp.StatusCode, url) } return body, nil } func remuxTSToMP4(tsPath, mp4Path string) error { // ffmpeg -y -i in.ts -c copy -movflags +faststart out.mp4 cmd := exec.Command(ffmpegPath, "-y", "-i", tsPath, "-c", "copy", "-movflags", "+faststart", mp4Path, ) var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return fmt.Errorf("ffmpeg remux failed: %v (%s)", err, stderr.String()) } return nil } func parseFFmpegOutTime(v string) float64 { v = strings.TrimSpace(v) if v == "" { return 0 } parts := strings.Split(v, ":") if len(parts) != 3 { return 0 } h, err1 := strconv.Atoi(parts[0]) m, err2 := strconv.Atoi(parts[1]) s, err3 := strconv.ParseFloat(parts[2], 64) // Sekunden können Dezimalstellen haben if err1 != nil || err2 != nil || err3 != nil { return 0 } return float64(h*3600+m*60) + s } func remuxTSToMP4WithProgress( ctx context.Context, tsPath, mp4Path string, durationSec float64, inSize int64, onRatio func(r float64), ) error { // ffmpeg progress kommt auf stdout als key=value cmd := exec.CommandContext(ctx, ffmpegPath, "-y", "-nostats", "-progress", "pipe:1", "-i", tsPath, "-c", "copy", "-movflags", "+faststart", mp4Path, ) stdout, err := cmd.StdoutPipe() if err != nil { return err } var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Start(); err != nil { return err } sc := bufio.NewScanner(stdout) sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) var ( lastOutSec float64 lastTotalSz int64 ) send := func(outSec float64, totalSize int64, force bool) { // bevorzugt: Zeit/Dauer if durationSec > 0 && outSec > 0 { r := outSec / durationSec if r < 0 { r = 0 } if r > 1 { r = 1 } if onRatio != nil { onRatio(r) } return } // fallback: Bytes (bei remux meist okay-ish) if inSize > 0 && totalSize > 0 { r := float64(totalSize) / float64(inSize) if r < 0 { r = 0 } if r > 1 { r = 1 } if onRatio != nil { onRatio(r) } return } // force (z.B. end) if force && onRatio != nil { onRatio(1) } } for sc.Scan() { line := strings.TrimSpace(sc.Text()) if line == "" { continue } k, v, ok := strings.Cut(line, "=") if !ok { continue } switch k { case "out_time_us": if n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil && n > 0 { lastOutSec = float64(n) / 1_000_000.0 send(lastOutSec, lastTotalSz, false) } case "out_time_ms": if n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil && n > 0 { // out_time_ms ist i.d.R. Millisekunden lastOutSec = float64(n) / 1_000.0 send(lastOutSec, lastTotalSz, false) } case "out_time": if s := parseFFmpegOutTime(v); s > 0 { lastOutSec = s send(lastOutSec, lastTotalSz, false) } case "total_size": if n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil && n > 0 { lastTotalSz = n send(lastOutSec, lastTotalSz, false) } case "progress": if strings.TrimSpace(v) == "end" { send(lastOutSec, lastTotalSz, true) } } } if err := cmd.Wait(); err != nil { return fmt.Errorf("ffmpeg remux failed: %v (%s)", err, strings.TrimSpace(stderr.String())) } return nil } // --- MP4 Streaming Optimierung (Fast Start) --- // "Fast Start" bedeutet: moov vor mdat (Browser kann sofort Metadaten lesen) func isFastStartMP4(path string) (bool, error) { f, err := os.Open(path) if err != nil { return false, err } defer f.Close() for i := 0; i < 256; i++ { var hdr [8]byte if _, err := io.ReadFull(f, hdr[:]); err != nil { // unklar/kurz -> nicht anfassen return true, nil } sz32 := binary.BigEndian.Uint32(hdr[0:4]) typ := string(hdr[4:8]) var boxSize int64 headerSize := int64(8) if sz32 == 0 { return true, nil } if sz32 == 1 { var ext [8]byte if _, err := io.ReadFull(f, ext[:]); err != nil { return true, nil } boxSize = int64(binary.BigEndian.Uint64(ext[:])) headerSize = 16 } else { boxSize = int64(sz32) } if boxSize < headerSize { return true, nil } switch typ { case "moov": return true, nil case "mdat": return false, nil } if _, err := f.Seek(boxSize-headerSize, io.SeekCurrent); err != nil { return true, nil } } return true, nil } func ensureFastStartMP4(path string) error { path = strings.TrimSpace(path) if path == "" || !strings.EqualFold(filepath.Ext(path), ".mp4") { return nil } if strings.TrimSpace(ffmpegPath) == "" { return nil } ok, err := isFastStartMP4(path) if err == nil && ok { return nil } dir := filepath.Dir(path) base := filepath.Base(path) tmp := filepath.Join(dir, ".__faststart__"+base+".tmp") bak := filepath.Join(dir, ".__faststart__"+base+".bak") _ = os.Remove(tmp) _ = os.Remove(bak) cmd := exec.Command(ffmpegPath, "-y", "-i", path, "-c", "copy", "-movflags", "+faststart", tmp, ) var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { _ = os.Remove(tmp) return fmt.Errorf("ffmpeg faststart failed: %v (%s)", err, strings.TrimSpace(stderr.String())) } // atomar austauschen if err := os.Rename(path, bak); err != nil { _ = os.Remove(tmp) return fmt.Errorf("rename original to bak failed: %w", err) } if err := os.Rename(tmp, path); err != nil { _ = os.Rename(bak, path) _ = os.Remove(tmp) return fmt.Errorf("rename tmp to original failed: %w", err) } _ = os.Remove(bak) return nil } func extractLastFrameJPEG(path string) ([]byte, error) { cmd := exec.Command( ffmpegPath, "-hide_banner", "-loglevel", "error", "-sseof", "-0.1", "-i", path, "-frames:v", "1", "-vf", "scale=720:-2", "-q:v", "10", "-f", "image2pipe", "-vcodec", "mjpeg", "pipe:1", ) var out bytes.Buffer var stderr bytes.Buffer cmd.Stdout = &out cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return nil, fmt.Errorf("ffmpeg last-frame: %w (%s)", err, strings.TrimSpace(stderr.String())) } return out.Bytes(), nil } func extractFrameAtTimeJPEG(path string, seconds float64) ([]byte, error) { if seconds < 0 { seconds = 0 } seek := fmt.Sprintf("%.3f", seconds) cmd := exec.Command( ffmpegPath, "-hide_banner", "-loglevel", "error", "-ss", seek, "-i", path, "-frames:v", "1", "-vf", "scale=720:-2", "-q:v", "10", "-f", "image2pipe", "-vcodec", "mjpeg", "pipe:1", ) var out bytes.Buffer var stderr bytes.Buffer cmd.Stdout = &out cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return nil, fmt.Errorf("ffmpeg frame-at-time: %w (%s)", err, strings.TrimSpace(stderr.String())) } return out.Bytes(), nil } func extractLastFrameJPEGScaled(path string, width int, q int) ([]byte, error) { if width <= 0 { width = 320 } if q <= 0 { q = 14 } // ffmpeg: letztes Frame, low-res cmd := exec.Command( ffmpegPath, "-hide_banner", "-loglevel", "error", "-sseof", "-0.25", "-i", path, "-frames:v", "1", "-vf", fmt.Sprintf("scale=%d:-2", width), "-q:v", strconv.Itoa(q), "-f", "image2pipe", "pipe:1", ) var out bytes.Buffer var stderr bytes.Buffer cmd.Stdout = &out cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return nil, fmt.Errorf("ffmpeg last-frame scaled: %w (%s)", err, strings.TrimSpace(stderr.String())) } b := out.Bytes() if len(b) == 0 { return nil, fmt.Errorf("ffmpeg last-frame scaled: empty output") } return b, nil } func extractFirstFrameJPEGScaled(path string, width int, q int) ([]byte, error) { if width <= 0 { width = 320 } if q <= 0 { q = 14 } cmd := exec.Command( ffmpegPath, "-hide_banner", "-loglevel", "error", "-ss", "0", "-i", path, "-frames:v", "1", "-vf", fmt.Sprintf("scale=%d:-2", width), "-q:v", strconv.Itoa(q), "-f", "image2pipe", "pipe:1", ) var out bytes.Buffer var stderr bytes.Buffer cmd.Stdout = &out cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return nil, fmt.Errorf("ffmpeg first-frame scaled: %w (%s)", err, strings.TrimSpace(stderr.String())) } b := out.Bytes() if len(b) == 0 { return nil, fmt.Errorf("ffmpeg first-frame scaled: empty output") } return b, nil } func extractLastFrameFromPreviewDirThumb(previewDir string) ([]byte, error) { seg, err := latestPreviewSegment(previewDir) if err != nil { return nil, err } // low-res, notfalls fallback auf erstes Frame img, err := extractLastFrameJPEGScaled(seg, 320, 14) if err == nil && len(img) > 0 { return img, nil } return extractFirstFrameJPEGScaled(seg, 320, 14) } // sucht das "neueste" Preview-Segment (seg_low_XXXXX.ts / seg_hq_XXXXX.ts) func latestPreviewSegment(previewDir string) (string, error) { entries, err := os.ReadDir(previewDir) if err != nil { return "", err } var best string for _, e := range entries { if e.IsDir() { continue } name := e.Name() if !strings.HasPrefix(name, "seg_low_") && !strings.HasPrefix(name, "seg_hq_") { continue } if best == "" || name > best { best = name } } if best == "" { return "", fmt.Errorf("kein Preview-Segment in %s", previewDir) } return filepath.Join(previewDir, best), nil } // erzeugt ein JPEG aus dem letzten Preview-Segment func extractLastFrameFromPreviewDir(previewDir string) ([]byte, error) { seg, err := latestPreviewSegment(previewDir) if err != nil { return nil, err } // Segment ist klein und "fertig" – hier reicht ein Last-Frame-Versuch, // mit Fallback auf First-Frame. img, err := extractLastFrameJPEG(seg) if err != nil { return extractFirstFrameJPEG(seg) } return img, nil } func stripHotPrefix(s string) string { s = strings.TrimSpace(s) // akzeptiere "HOT " auch case-insensitive if len(s) >= 4 && strings.EqualFold(s[:4], "HOT ") { return strings.TrimSpace(s[4:]) } return s } // -------------------------- // Covers: generated/covers/. // -------------------------- type coverInfo struct { Category string `json:"category"` Model string `json:"model,omitempty"` Src string `json:"src,omitempty"` GeneratedAt string `json:"generatedAt"` } func normalizeCoverSrc(s string) string { s = strings.TrimSpace(s) if s == "" { return "" } // Windows -> URL-artig s2 := strings.ReplaceAll(s, "\\", "/") // Wenn es schon wie ein Web-Pfad aussieht, so lassen if strings.HasPrefix(s2, "/generated/") || strings.HasPrefix(s2, "http://") || strings.HasPrefix(s2, "https://") { return s2 } // Wenn es ein lokaler Pfad ist, versuche den /generated/ Teil zu extrahieren // z.B. ".../generated/meta//thumbs.jpg" -> "/generated/meta//thumbs.jpg" if i := strings.Index(s2, "/generated/"); i >= 0 { return s2[i:] } return s2 } func coverInfoPathForKey(key string) (string, error) { root, err := coversRoot() if err != nil { return "", err } return filepath.Join(root, key+".info.json"), nil } func writeCoverInfoBestEffort(key string, info coverInfo) { p, err := coverInfoPathForKey(key) if err != nil { return } b, err := json.MarshalIndent(info, "", " ") if err != nil { return } _ = os.MkdirAll(filepath.Dir(p), 0o755) _ = os.WriteFile(p, b, 0o644) } func readCoverInfoBestEffort(key string) (coverInfo, bool) { p, err := coverInfoPathForKey(key) if err != nil { return coverInfo{}, false } b, err := os.ReadFile(p) if err != nil || len(b) == 0 { return coverInfo{}, false } var ci coverInfo if json.Unmarshal(b, &ci) != nil { return coverInfo{}, false } return ci, true } func drawLabel(img draw.Image, text string) { text = strings.TrimSpace(text) if text == "" { return } face := basicfont.Face7x13 // Layout const margin = 10 const padX = 10 const padY = 8 b := img.Bounds() // Max. verfügbare Breite für Text (ohne Padding/Margins) maxTextW := (b.Dx() - 2*margin) - 2*padX if maxTextW <= 0 { return } // Text ggf. kürzen, damit er ins Badge passt measure := func(s string) int { d := &font.Drawer{Face: face} return d.MeasureString(s).Ceil() } label := text if w := measure(label); w > maxTextW { ellipsis := "…" rs := []rune(text) // harte Schranke gegen Extremfälle if len(rs) == 0 { return } lo, hi := 0, len(rs) best := "" for lo <= hi { mid := (lo + hi) / 2 cand := string(rs[:mid]) + ellipsis if measure(cand) <= maxTextW { best = cand lo = mid + 1 } else { hi = mid - 1 } } if best == "" { // notfalls nur Ellipsis label = ellipsis } else { label = best } } // Textmetriken d := &font.Drawer{Face: face} textW := d.MeasureString(label).Ceil() textH := face.Metrics().Height.Ceil() ascent := face.Metrics().Ascent.Ceil() // Badge-Box (unten links) x0 := b.Min.X + margin y1 := b.Max.Y - margin y0 := y1 - (textH + 2*padY) x1 := x0 + (textW + 2*padX) // Clamp nach rechts (falls Bild sehr schmal) maxX1 := b.Max.X - margin if x1 > maxX1 { shift := x1 - maxX1 x0 -= shift x1 -= shift if x0 < b.Min.X+margin { x0 = b.Min.X + margin x1 = maxX1 } } // Clamp nach oben (falls Bild sehr niedrig) minY0 := b.Min.Y + margin if y0 < minY0 { y0 = minY0 y1 = y0 + (textH + 2*padY) if y1 > b.Max.Y-margin { // zu wenig Platz insgesamt return } } rect := image.Rect(x0, y0, x1, y1) // Background bg := image.NewUniform(color.RGBA{0, 0, 0, 170}) draw.Draw(img, rect, bg, image.Point{}, draw.Over) // Optional: dünner Rand für mehr Kontrast border := image.NewUniform(color.RGBA{255, 255, 255, 35}) // top draw.Draw(img, image.Rect(rect.Min.X, rect.Min.Y, rect.Max.X, rect.Min.Y+1), border, image.Point{}, draw.Over) // bottom draw.Draw(img, image.Rect(rect.Min.X, rect.Max.Y-1, rect.Max.X, rect.Max.Y), border, image.Point{}, draw.Over) // left draw.Draw(img, image.Rect(rect.Min.X, rect.Min.Y, rect.Min.X+1, rect.Max.Y), border, image.Point{}, draw.Over) // right draw.Draw(img, image.Rect(rect.Max.X-1, rect.Min.Y, rect.Max.X, rect.Max.Y), border, image.Point{}, draw.Over) // Text baseline tx := x0 + padX ty := y0 + padY + ascent // Mini-Schatten (Lesbarkeit) shadow := &font.Drawer{ Dst: img, Src: image.NewUniform(color.RGBA{0, 0, 0, 200}), Face: face, Dot: fixed.P(tx+1, ty+1), } shadow.DrawString(label) // Text fg := &font.Drawer{ Dst: img, Src: image.NewUniform(color.RGBA{255, 255, 255, 235}), Face: face, Dot: fixed.P(tx, ty), } fg.DrawString(label) } func splitTagsLoose(raw string) []string { raw = strings.TrimSpace(raw) if raw == "" { return nil } parts := strings.FieldsFunc(raw, func(r rune) bool { switch r { case '\n', ',', ';', '|': return true } return false }) out := make([]string, 0, len(parts)) seen := map[string]struct{}{} for _, p := range parts { t := strings.TrimSpace(p) if t == "" { continue } low := strings.ToLower(t) if _, ok := seen[low]; ok { continue } seen[low] = struct{}{} out = append(out, t) } return out } func hasTag(tagsRaw string, want string) bool { want = strings.ToLower(strings.TrimSpace(want)) if want == "" { return false } for _, t := range splitTagsLoose(tagsRaw) { if strings.ToLower(strings.TrimSpace(t)) == want { return true } } return false } // ✅ Passe diese Struct/Methoden an dein echtes ModelStore-API an. type coverModel struct { Key string // z.B. model key/name Tags string // raw tags (csv/newline/…) } func listModelsForCovers() ([]coverModel, error) { if coverModelStore == nil { return nil, fmt.Errorf("model store not set") } ms := coverModelStore.List() // ✅ existiert bei dir out := make([]coverModel, 0, len(ms)) for _, m := range ms { key := strings.TrimSpace(m.ModelKey) if key == "" { continue } out = append(out, coverModel{ Key: key, Tags: m.Tags, }) } return out, nil } func pickRandomThumbForCategory(ctx context.Context, category string) (thumbPath string, err error) { category = strings.TrimSpace(category) if category == "" { return "", fmt.Errorf("category empty") } // Optional: früh abbrechen, wenn Request schon tot ist select { case <-ctx.Done(): return "", ctx.Err() default: } models, err := listModelsForCovers() if err != nil { return "", err } // 1) Kandidaten-Models nach Tag filtern cands := make([]coverModel, 0, 64) for _, m := range models { key := strings.TrimSpace(m.Key) if key == "" { continue } if hasTag(m.Tags, category) { cands = append(cands, coverModel{Key: key, Tags: m.Tags}) } } if len(cands) == 0 { return "", fmt.Errorf("no model with tag") } // 2) Kandidaten mischen und nacheinander probieren (robuster als 1 random pick) rand.Shuffle(len(cands), func(i, j int) { cands[i], cands[j] = cands[j], cands[i] }) // 3) done dirs (einmalig auflösen) s := getSettings() doneAbs, derr := resolvePathRelativeToApp(s.DoneDir) if derr != nil || strings.TrimSpace(doneAbs) == "" { return "", fmt.Errorf("doneDir resolve failed: %v", derr) } type candFile struct { videoPath string id string } isVideo := func(name string) bool { low := strings.ToLower(name) if strings.Contains(low, ".part") || strings.Contains(low, ".tmp") { return false } ext := strings.ToLower(filepath.Ext(name)) return ext == ".mp4" || ext == ".ts" } // 4) Für jedes passende Model: Dateien sammeln, random wählen, Thumb prüfen for _, m := range cands { // Context check pro Iteration select { case <-ctx.Done(): return "", ctx.Err() default: } modelKey := strings.TrimSpace(m.Key) if modelKey == "" { continue } // Kandidaten: done// und done/keep// dirs := []string{ filepath.Join(doneAbs, modelKey), filepath.Join(doneAbs, "keep", modelKey), } files := make([]candFile, 0, 128) for _, d := range dirs { ents, err := os.ReadDir(d) if err != nil { continue } for _, e := range ents { if e.IsDir() { continue } name := e.Name() if !isVideo(name) { continue } full := filepath.Join(d, name) stem := strings.TrimSuffix(name, filepath.Ext(name)) id := stripHotPrefix(strings.TrimSpace(stem)) if id == "" { continue } files = append(files, candFile{videoPath: full, id: id}) } } if len(files) == 0 { // ✅ dieses Model hat (noch) keine Downloads -> nächstes Model probieren continue } // random file innerhalb des Models cf := files[rand.Intn(len(files))] // thumbs sicherstellen (best effort) _ = ensureAssetsForVideo(cf.videoPath) tp, terr := generatedThumbFile(cf.id) if terr != nil { // nächstes Model probieren continue } if fi, serr := os.Stat(tp); serr == nil && !fi.IsDir() && fi.Size() > 0 { return tp, nil } // ✅ Thumb fehlt -> nächstes Model probieren } return "", fmt.Errorf("no downloads/thumbs for category") } func coversRoot() (string, error) { return resolvePathRelativeToApp(filepath.Join("generated", "covers")) } func ensureCoversDir() (string, error) { root, err := coversRoot() if err != nil { return "", err } if strings.TrimSpace(root) == "" { return "", fmt.Errorf("covers root ist leer") } if err := os.MkdirAll(root, 0o755); err != nil { return "", err } return root, nil } var coverKeyRe = regexp.MustCompile(`[^a-z0-9._-]+`) func sanitizeCoverKey(category string) (string, error) { c := strings.ToLower(strings.TrimSpace(category)) if c == "" { sum := sha1.Sum([]byte(category)) c = "tag_" + hex.EncodeToString(sum[:8]) // 16 hex chars reichen } if c == "" { return "", fmt.Errorf("category fehlt") } // Windows & FS safe c = strings.ReplaceAll(c, " ", "_") c = coverKeyRe.ReplaceAllString(c, "_") c = strings.Trim(c, "._-") if c == "" { return "", fmt.Errorf("category ungültig") } if len(c) > 120 { c = c[:120] } return c, nil } func detectImageExt(contentType string, b []byte) (ext string, ct string) { ct = strings.ToLower(strings.TrimSpace(contentType)) // wenn server CT liefert switch { case strings.Contains(ct, "image/jpeg") || strings.Contains(ct, "image/jpg"): return ".jpg", "image/jpeg" case strings.Contains(ct, "image/png"): return ".png", "image/png" case strings.Contains(ct, "image/webp"): return ".webp", "image/webp" case strings.Contains(ct, "image/gif"): return ".gif", "image/gif" } // Magic bytes fallback if len(b) >= 3 && b[0] == 0xFF && b[1] == 0xD8 && b[2] == 0xFF { return ".jpg", "image/jpeg" } if len(b) >= 8 && bytes.Equal(b[:8], []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}) { return ".png", "image/png" } if len(b) >= 12 && string(b[:4]) == "RIFF" && string(b[8:12]) == "WEBP" { return ".webp", "image/webp" } if len(b) >= 6 && (string(b[:6]) == "GIF87a" || string(b[:6]) == "GIF89a") { return ".gif", "image/gif" } // default: jpg return ".jpg", "image/jpeg" } func coverPathForCategory(key string, ext string) (string, error) { root, err := coversRoot() if err != nil { return "", err } if strings.TrimSpace(root) == "" { return "", fmt.Errorf("covers root ist leer") } if ext == "" { ext = ".jpg" } return filepath.Join(root, key+ext), nil } func findExistingCoverFile(key string) (string, os.FileInfo, bool) { root, err := coversRoot() if err != nil || strings.TrimSpace(root) == "" { return "", nil, false } // probiere gängige Endungen exts := []string{".jpg", ".png", ".webp", ".gif"} for _, ext := range exts { p := filepath.Join(root, key+ext) if fi, err := os.Stat(p); err == nil && !fi.IsDir() && fi.Size() > 0 { return p, fi, true } } return "", nil, false } func downloadBytes(ctx context.Context, rawURL string, ua string) ([]byte, string, error) { rawURL = strings.TrimSpace(rawURL) if rawURL == "" { return nil, "", fmt.Errorf("src fehlt") } // ✅ 1) Lokaler Pfad: nur /generated/... erlauben if strings.HasPrefix(rawURL, "/") { // URL-Pfad normalisieren und Traversal verhindern clean := path.Clean(rawURL) // URL-style cleaning if !strings.HasPrefix(clean, "/generated/") { return nil, "", fmt.Errorf("src ungültig") } if strings.Contains(clean, "..") { return nil, "", fmt.Errorf("src ungültig") } // "/generated/..." -> "generated/..." (relativ zur App) rel := strings.TrimPrefix(clean, "/") abs, err := resolvePathRelativeToApp(rel) if err != nil || strings.TrimSpace(abs) == "" { return nil, "", fmt.Errorf("src ungültig") } f, err := os.Open(abs) if err != nil { return nil, "", fmt.Errorf("download failed: %v", err) } defer f.Close() // max 10MB lesen b, err := io.ReadAll(io.LimitReader(f, 10*1024*1024)) if err != nil { return nil, "", fmt.Errorf("download failed: %v", err) } if len(b) == 0 { return nil, "", fmt.Errorf("download empty") } // Content-Type grob nach Extension (Magic-bytes macht detectImageExt später sowieso) ext := strings.ToLower(filepath.Ext(abs)) ct := "application/octet-stream" switch ext { case ".jpg", ".jpeg": ct = "image/jpeg" case ".png": ct = "image/png" case ".webp": ct = "image/webp" case ".gif": ct = "image/gif" } return b, ct, nil } // ✅ 2) Remote URL: wie bisher nur http/https u, err := url.Parse(rawURL) if err != nil || u.Scheme == "" || u.Host == "" { return nil, "", fmt.Errorf("src ungültig") } if u.Scheme != "http" && u.Scheme != "https" { return nil, "", fmt.Errorf("src schema nicht erlaubt") } req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { return nil, "", err } if strings.TrimSpace(ua) == "" { ua = "Mozilla/5.0" } req.Header.Set("User-Agent", ua) req.Header.Set("Accept", "image/*,*/*;q=0.8") client := &http.Client{Timeout: 12 * time.Second} resp, err := client.Do(req) if err != nil { return nil, "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, "", fmt.Errorf("download failed: HTTP %d", resp.StatusCode) } b, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) // max 10MB if err != nil { return nil, "", err } if len(b) == 0 { return nil, "", fmt.Errorf("download empty") } return b, resp.Header.Get("Content-Type"), nil } func generatedRoot() (string, error) { return resolvePathRelativeToApp("generated") } func generatedMetaRoot() (string, error) { return resolvePathRelativeToApp(filepath.Join("generated", "meta")) } // Legacy (falls noch alte Assets liegen): func generatedThumbsRoot() (string, error) { return resolvePathRelativeToApp(filepath.Join("generated", "thumbs")) } func generatedTeaserRoot() (string, error) { return resolvePathRelativeToApp(filepath.Join("generated", "teaser")) } // irgendwo auf Package-Level (z.B. in derselben Datei über generatedCover) var coverBatchMu sync.Mutex var ( coverBatchInflight int coverBatchStarted time.Time coverBatchTotal int coverBatchForced int coverBatchMiss int coverBatchErrors int coverBatchNoThumb int coverBatchDecodeErr int ) func coverBatchEnter(force bool) { coverBatchMu.Lock() defer coverBatchMu.Unlock() if coverBatchInflight == 0 { coverBatchStarted = time.Now() coverBatchTotal = 0 coverBatchForced = 0 coverBatchMiss = 0 coverBatchErrors = 0 coverBatchNoThumb = 0 coverBatchDecodeErr = 0 log.Printf("[cover] BATCH START") } coverBatchInflight++ coverBatchTotal++ if force { coverBatchForced++ } else { coverBatchMiss++ } } func coverBatchLeave(outcome string, status int) { coverBatchMu.Lock() defer coverBatchMu.Unlock() // Outcome-Stats (grob, aber hilfreich) if status >= 400 { coverBatchErrors++ } switch outcome { case "no-thumb": coverBatchNoThumb++ case "decode-failed-no-overlay": coverBatchDecodeErr++ } coverBatchInflight-- if coverBatchInflight <= 0 { dur := time.Since(coverBatchStarted).Round(time.Millisecond) log.Printf( "[cover] BATCH END total=%d miss=%d forced=%d errors=%d noThumb=%d decodeFail=%d took=%s", coverBatchTotal, coverBatchMiss, coverBatchForced, coverBatchErrors, coverBatchNoThumb, coverBatchDecodeErr, dur, ) coverBatchInflight = 0 } } // ---------------------------------------------------------------------- // deine Handler-Funktion (vollständig) var ( reModelFromStem = regexp.MustCompile(`^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}`) ) // stem ist z.B. "sigmasian_01_21_2026__07-28-13" oder ein Parent-Dir-Name func inferModelFromStem(stem string) string { stem = stripHotPrefix(strings.TrimSpace(stem)) if stem == "" { return "" } m := reModelFromStem.FindStringSubmatch(stem) if len(m) >= 2 { return strings.TrimSpace(m[1]) } return "" } // akzeptiert: // - "/generated/meta//thumbs.jpg" // - "C:\...\generated\meta\\thumbs.jpg" // - "http(s)://host/generated/meta//thumbs.jpg" // - (fallback) irgendeinen Dateinamen-Stem, der wie "_MM_DD_YYYY__HH-MM-ss" aussieht func inferModelFromThumbLike(srcOrPath string) string { s := strings.TrimSpace(srcOrPath) if s == "" { return "" } // Windows -> slash s = strings.ReplaceAll(s, `\`, `/`) // Wenn URL: nimm nur den Path if u, err := url.Parse(s); err == nil && u != nil && u.Scheme != "" && u.Host != "" { s = u.Path } // Wenn es wie "...//thumbs.jpg" aussieht: parent dir ist base := path.Base(s) lb := strings.ToLower(base) if strings.HasPrefix(lb, "thumbs.") { id := path.Base(path.Dir(s)) return inferModelFromStem(id) } // Fallback: versuch Stem aus basename stem := strings.TrimSuffix(base, path.Ext(base)) return inferModelFromStem(stem) } // ---------------------------------------------------------------------- // deine Handler-Funktion (vollständig) type coverInfoListItem struct { Category string `json:"category"` Model string `json:"model,omitempty"` GeneratedAt string `json:"generatedAt,omitempty"` HasCover bool `json:"hasCover"` } func generatedCoverInfoList(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { http.Error(w, "Nur GET/HEAD erlaubt", http.StatusMethodNotAllowed) return } root, err := coversRoot() if err != nil { http.Error(w, "covers root: "+err.Error(), http.StatusInternalServerError) return } entries, err := os.ReadDir(root) if err != nil { http.Error(w, "covers dir: "+err.Error(), http.StatusInternalServerError) return } byKey := map[string]*coverInfoListItem{} ensure := func(key string) *coverInfoListItem { if v, ok := byKey[key]; ok { return v } v := &coverInfoListItem{Category: key} byKey[key] = v return v } isCoverExt := func(ext string) bool { switch strings.ToLower(ext) { case ".jpg", ".jpeg", ".png", ".webp", ".gif": return true default: return false } } for _, e := range entries { name := e.Name() lower := strings.ToLower(name) // info.json if strings.HasSuffix(lower, ".info.json") { key := strings.TrimSuffix(name, ".info.json") if ci, ok := readCoverInfoBestEffort(key); ok { v := ensure(key) if strings.TrimSpace(ci.Category) != "" { v.Category = strings.TrimSpace(ci.Category) } if strings.TrimSpace(ci.Model) != "" { v.Model = strings.TrimSpace(ci.Model) } if strings.TrimSpace(ci.GeneratedAt) != "" { v.GeneratedAt = strings.TrimSpace(ci.GeneratedAt) } } continue } // cover image ext := filepath.Ext(name) if isCoverExt(ext) { key := strings.TrimSuffix(name, ext) v := ensure(key) v.HasCover = true } } // ✅ WICHTIG: Model nur ausgeben, wenn wirklich ein Cover-Bild existiert for _, v := range byKey { if !v.HasCover { v.Model = "" v.GeneratedAt = "" } if strings.TrimSpace(v.Category) == "" { v.Category = "" } } // sortiert ausgeben (stabil) keys := make([]string, 0, len(byKey)) for k := range byKey { keys = append(keys, k) } sort.Strings(keys) out := make([]coverInfoListItem, 0, len(keys)) for _, k := range keys { out = append(out, *byKey[k]) } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Cache-Control", "no-store") if r.Method == http.MethodHead { w.WriteHeader(http.StatusOK) return } _ = json.NewEncoder(w).Encode(out) } func generatedCover(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { http.Error(w, "Nur GET/HEAD erlaubt", http.StatusMethodNotAllowed) return } category := r.URL.Query().Get("category") key, err := sanitizeCoverKey(category) if err != nil { http.Error(w, "category ungültig: "+err.Error(), http.StatusBadRequest) return } refresh := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("refresh"))) force := refresh == "1" || refresh == "true" || refresh == "yes" // Optional: model overlay modelQ := strings.TrimSpace(r.URL.Query().Get("model")) modelExplicit := modelQ != "" model := modelQ // Optional: src (Frontend kann ein konkretes Thumb vorgeben) src := strings.TrimSpace(r.URL.Query().Get("src")) fallbackModel := "" if ci, ok := readCoverInfoBestEffort(key); ok { if m := strings.TrimSpace(ci.Model); m != "" { fallbackModel = m } } // Wenn kein explizites model im Request: erstmal fallback übernehmen if model == "" { model = fallbackModel } // Wenn src gesetzt ist und model NICHT explizit: schon hier aus src ableiten (ok) if !modelExplicit && src != "" { if m := inferModelFromThumbLike(src); m != "" { model = m } } // kleine Request-ID (nur als Header/Debug; kein per-request log) reqID := strconv.FormatInt(time.Now().UnixNano(), 36) setDebugHeaders := func(cache string) { w.Header().Set("X-Cover-Key", key) w.Header().Set("X-Cover-Category", category) if model != "" { w.Header().Set("X-Cover-Model", model) } w.Header().Set("X-Cover-Cache", cache) // HIT | MISS | FORCED w.Header().Set("X-Request-Id", reqID) } // 1) Cache hit: direkt von Disk (nur wenn nicht force) if !force { // Wenn model im Request/abgeleitet da ist: info.json muss existieren & gleich sein, sonst neu erzeugen if model != "" { if ci, ok := readCoverInfoBestEffort(key); ok { if strings.TrimSpace(ci.Model) != model { // auch wenn ci.Model leer ist force = true } } else { // kein info.json -> force, damit wir model persistent bekommen force = true } } if !force { if p, fi, ok := findExistingCoverFile(key); ok { setDebugHeaders("HIT") // ✅ Cache-Hit: Cover existiert wirklich -> info.json best-effort updaten, // aber NUR wenn wir ein Model kennen (damit "kein Cover" auch kein Model zeigt) if model != "" { ci, ok := readCoverInfoBestEffort(key) if !ok { ci = coverInfo{Category: category} } ci.Category = category ci.Model = strings.TrimSpace(model) // Src NICHT überschreiben, wenn wir es bei HIT nicht kennen // ci.Src bleibt wie gehabt ci.GeneratedAt = time.Now().UTC().Format(time.RFC3339Nano) writeCoverInfoBestEffort(key, ci) } w.Header().Set("Cache-Control", "public, max-age=31536000") w.Header().Set("X-Content-Type-Options", "nosniff") ext := strings.ToLower(filepath.Ext(p)) switch ext { case ".png": w.Header().Set("Content-Type", "image/png") case ".webp": w.Header().Set("Content-Type", "image/webp") case ".gif": w.Header().Set("Content-Type", "image/gif") default: w.Header().Set("Content-Type", "image/jpeg") } if r.Method == http.MethodHead { w.WriteHeader(http.StatusOK) return } f, err := os.Open(p) if err != nil { http.NotFound(w, r) return } defer f.Close() http.ServeContent(w, r, filepath.Base(p), fi.ModTime(), f) return } } } // Ab hier: entweder MISS oder FORCED => wir erzeugen (oder versuchen es) cacheStatus := "MISS" if force { cacheStatus = "FORCED" } setDebugHeaders(cacheStatus) // ✅ Globales Batch-Logging: nur beim echten Generate-Path coverBatchEnter(force) start := time.Now() status := http.StatusOK outcome := "ok" defer func() { w.Header().Set("X-Cover-Gen-Ms", strconv.FormatInt(time.Since(start).Milliseconds(), 10)) coverBatchLeave(outcome, status) }() // 2) Kein Cache (oder force): Cover erzeugen und persistieren if _, err := ensureCoversDir(); err != nil { status = http.StatusInternalServerError outcome = "covers-dir" http.Error(w, "covers-dir nicht verfügbar: "+err.Error(), status) return } ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) defer cancel() var ( raw []byte mimeType string ext string ) thumbPath := "" // nur fürs Ableiten von model, wenn src leer ist usedSrc := "" // ✅ das speichern wir später in coverInfo // 2a) Wenn src gegeben: daraus bauen (lokal /generated/... oder http(s)) if src != "" { var derr error raw, mimeType, derr = downloadBytes(ctx, src, r.Header.Get("User-Agent")) usedSrc = normalizeCoverSrc(src) if derr != nil { status = http.StatusBadRequest outcome = "src-download" http.Error(w, "src download failed: "+derr.Error(), status) return } ext, mimeType = detectImageExt(mimeType, raw) if len(raw) == 0 { status = http.StatusBadRequest outcome = "src-empty" http.Error(w, "src leer", status) return } // ✅ falls model immer noch leer ist, versuch JETZT aus src abzuleiten if model == "" { if m := inferModelFromThumbLike(src); m != "" { model = m // Header aktualisieren (weil model jetzt da ist) w.Header().Set("X-Cover-Model", model) } } } else { // 2b) Sonst: Backend wählt random Thumb passend zur Category var perr error thumbPath, perr = pickRandomThumbForCategory(ctx, category) if perr != nil { // Wenn es schon ein Cover gibt, lieber das liefern (auch wenn force) if p, fi, ok := findExistingCoverFile(key); ok { outcome = "fallback-existing-cover" status = http.StatusOK w.Header().Set("Cache-Control", "public, max-age=600") w.Header().Set("X-Content-Type-Options", "nosniff") ext2 := strings.ToLower(filepath.Ext(p)) switch ext2 { case ".png": w.Header().Set("Content-Type", "image/png") case ".webp": w.Header().Set("Content-Type", "image/webp") case ".gif": w.Header().Set("Content-Type", "image/gif") default: w.Header().Set("Content-Type", "image/jpeg") } if r.Method == http.MethodHead { w.WriteHeader(http.StatusOK) return } f, err := os.Open(p) if err != nil { servePreviewStatusSVG(w, "No Cover", status) return } defer f.Close() http.ServeContent(w, r, filepath.Base(p), fi.ModTime(), f) return } outcome = "no-thumb" status = http.StatusNotFound if r.Method == http.MethodHead { w.WriteHeader(status) return } servePreviewStatusSVG(w, "No Cover", status) return } usedSrc = normalizeCoverSrc(thumbPath) raw, err = os.ReadFile(thumbPath) if err != nil || len(raw) == 0 { status = http.StatusInternalServerError outcome = "thumb-read" http.Error(w, "cover read fehlgeschlagen", status) return } // thumbs bei dir sind JPEG ext = ".jpg" mimeType = "image/jpeg" // ✅ falls model leer ist: aus thumbPath ableiten // thumbPath ist i.d.R. ".../generated/meta//thumbs.jpg" if model == "" { if m := inferModelFromThumbLike(thumbPath); m != "" { model = m w.Header().Set("X-Cover-Model", model) } } } // ✅ Final: wenn model nicht explizit gesetzt wurde, binde es an usedSrc if !modelExplicit { if m := inferModelFromThumbLike(usedSrc); m != "" { model = m w.Header().Set("X-Cover-Model", model) } } // ✅ 3) Overlay + Re-Encode img, _, derr := image.Decode(bytes.NewReader(raw)) if derr == nil && img != nil { rgba := image.NewRGBA(img.Bounds()) draw.Draw(rgba, rgba.Bounds(), img, img.Bounds().Min, draw.Src) var buf bytes.Buffer switch strings.ToLower(ext) { case ".png": _ = png.Encode(&buf, rgba) raw = buf.Bytes() ext = ".png" mimeType = "image/png" default: _ = jpeg.Encode(&buf, rgba, &jpeg.Options{Quality: 85}) raw = buf.Bytes() ext = ".jpg" mimeType = "image/jpeg" } } else { outcome = "decode-failed-no-overlay" } // 4) Vorherige Cover-Dateien entfernen root, _ := coversRoot() for _, e := range []string{".jpg", ".png", ".webp", ".gif"} { _ = os.Remove(filepath.Join(root, key+e)) } _ = os.Remove(filepath.Join(root, key+".info.json")) // 5) Persistieren dst, err := coverPathForCategory(key, ext) if err != nil { status = http.StatusInternalServerError outcome = "cover-path" http.Error(w, "cover path: "+err.Error(), status) return } if err := atomicWriteFile(dst, raw); err != nil { status = http.StatusInternalServerError outcome = "cover-write" http.Error(w, "cover write: "+err.Error(), status) return } // ✅ 6) info.json schreiben (best-effort) // model ist jetzt: Query-Param ODER aus info.json ODER aus src/thumbPath abgeleitet writeCoverInfoBestEffort(key, coverInfo{ Category: category, Model: strings.TrimSpace(model), Src: strings.TrimSpace(usedSrc), GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano), }) // 7) Ausliefern w.Header().Set("Cache-Control", "public, max-age=600") w.Header().Set("Content-Type", mimeType) w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Cover-Bytes", strconv.Itoa(len(raw))) if r.Method == http.MethodHead { w.WriteHeader(http.StatusOK) return } w.WriteHeader(http.StatusOK) _, _ = w.Write(raw) } func sanitizeModelKey(k string) string { k = stripHotPrefix(strings.TrimSpace(k)) if k == "" || k == "—" || strings.ContainsAny(k, `/\`) { return "" } return k } func modelKeyFromFilenameOrPath(file string, srcPath string, doneAbs string) string { // 1) bevorzugt aus Dateiname (OHNE Extension!) stem := strings.TrimSuffix(filepath.Base(strings.TrimSpace(file)), filepath.Ext(file)) k := sanitizeModelKey(strings.TrimSpace(modelNameFromFilename(stem))) if k != "" { return k } // 2) Fallback: aus Quellpfad ableiten, wenn Datei in done//... lag if strings.TrimSpace(srcPath) != "" && strings.TrimSpace(doneAbs) != "" { srcDir := filepath.Clean(filepath.Dir(srcPath)) doneAbs = filepath.Clean(doneAbs) // srcDir != doneAbs => wir sind in einem Unterordner, dessen Name i.d.R. das Model ist if !strings.EqualFold(srcDir, doneAbs) { k2 := sanitizeModelKey(filepath.Base(srcDir)) if k2 != "" && !strings.EqualFold(k2, "keep") { return k2 } } } return "" } func uniqueDestPath(dstDir, file string) (string, error) { dst := filepath.Join(dstDir, file) if _, err := os.Stat(dst); err == nil { ext := filepath.Ext(file) base := strings.TrimSuffix(file, ext) for i := 2; i <= 200; i++ { alt := fmt.Sprintf("%s__dup%d%s", base, i, ext) cand := filepath.Join(dstDir, alt) if _, err := os.Stat(cand); os.IsNotExist(err) { return cand, nil } } return "", fmt.Errorf("too many duplicates for %s", file) } else if err != nil && !os.IsNotExist(err) { return "", err } return dst, nil } func idFromVideoPath(videoPath string) string { name := filepath.Base(strings.TrimSpace(videoPath)) return strings.TrimSuffix(name, filepath.Ext(name)) } func assetIDForJob(job *RecordJob) string { if job == nil { return "" } // Prefer: Dateiname ohne Endung (und ohne HOT Prefix) out := strings.TrimSpace(job.Output) if out != "" { id := stripHotPrefix(idFromVideoPath(out)) if strings.TrimSpace(id) != "" { return id } } // Fallback: JobID (sollte praktisch nie nötig sein) return strings.TrimSpace(job.ID) } func atomicWriteFile(dst string, data []byte) error { dir := filepath.Dir(dst) if err := os.MkdirAll(dir, 0o755); err != nil { return err } tmp, err := os.CreateTemp(dir, ".tmp-*") if err != nil { return err } tmpName := tmp.Name() _ = tmp.Chmod(0o644) _, werr := tmp.Write(data) cerr := tmp.Close() if werr != nil { _ = os.Remove(tmpName) return werr } if cerr != nil { _ = os.Remove(tmpName) return cerr } return os.Rename(tmpName, dst) } // Beim Start: loose Dateien in /done/keep (Root) in /done/keep// einsortieren. // Best-effort: wenn Model nicht ableitbar oder Ziel kollidiert, wird geskippt bzw. umbenannt. func fixKeepRootFilesIntoModelSubdirs() { s := getSettings() doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil || strings.TrimSpace(doneAbs) == "" { return } keepRoot := filepath.Join(doneAbs, "keep") ents, err := os.ReadDir(keepRoot) if err != nil { // keep existiert evtl. noch nicht -> nichts zu tun if os.IsNotExist(err) { return } fmt.Println("⚠️ keep scan failed:", err) return } moved := 0 skipped := 0 isVideo := func(name string) bool { low := strings.ToLower(name) if strings.Contains(low, ".part") || strings.Contains(low, ".tmp") { return false } ext := strings.ToLower(filepath.Ext(name)) return ext == ".mp4" || ext == ".ts" } for _, e := range ents { if e.IsDir() { continue } name := e.Name() if !isVideo(name) { continue } // Quelle: /done/keep/ src := filepath.Join(keepRoot, name) // Model aus Dateiname ableiten (wie im keep-handler) stem := strings.TrimSuffix(name, filepath.Ext(name)) // ✅ ohne .mp4/.ts modelKey := sanitizeModelKey(strings.TrimSpace(modelNameFromFilename(stem))) // wenn nicht ableitbar -> skip if modelKey == "" || modelKey == "—" || strings.ContainsAny(modelKey, `/\`) { skipped++ continue } dstDir := filepath.Join(keepRoot, modelKey) if err := os.MkdirAll(dstDir, 0o755); err != nil { fmt.Println("⚠️ keep mkdir failed:", err) skipped++ continue } dst := filepath.Join(dstDir, name) // Wenn Ziel schon existiert -> unique Name finden if _, err := os.Stat(dst); err == nil { ext := filepath.Ext(name) base := strings.TrimSuffix(name, ext) found := false for i := 2; i <= 200; i++ { alt := fmt.Sprintf("%s__dup%d%s", base, i, ext) cand := filepath.Join(dstDir, alt) if _, err := os.Stat(cand); os.IsNotExist(err) { dst = cand found = true break } } if !found { fmt.Println("⚠️ keep fix: too many duplicates, skip:", name) skipped++ continue } } else if err != nil && !os.IsNotExist(err) { fmt.Println("⚠️ keep stat dst failed:", err) skipped++ continue } // Verschieben (Windows-lock robust) if err := renameWithRetry(src, dst); err != nil { fmt.Println("⚠️ keep fix rename failed:", err) skipped++ continue } moved++ } if moved > 0 || skipped > 0 { fmt.Printf("🧹 keep fix: moved=%d skipped=%d (root=%s)\n", moved, skipped, keepRoot) } } func findFinishedFileByID(id string) (string, error) { s := getSettings() recordAbs, _ := resolvePathRelativeToApp(s.RecordDir) doneAbs, _ := resolvePathRelativeToApp(s.DoneDir) base := stripHotPrefix(strings.TrimSpace(id)) if base == "" { return "", fmt.Errorf("not found") } names := []string{ base + ".mp4", "HOT " + base + ".mp4", base + ".ts", "HOT " + base + ".ts", } // done (root + /done//) + keep (root + /done/keep//) for _, name := range names { if p, _, ok := findFileInDirOrOneLevelSubdirs(doneAbs, name, "keep"); ok { return p, nil } if p, _, ok := findFileInDirOrOneLevelSubdirs(filepath.Join(doneAbs, "keep"), name, ""); ok { return p, nil } if p, _, ok := findFileInDirOrOneLevelSubdirs(recordAbs, name, ""); ok { return p, nil } } return "", fmt.Errorf("not found") } func servePreviewThumbAlias(w http.ResponseWriter, r *http.Request, id, file string) { // 1) Wenn Job bekannt (id = job.ID): assetID aus Output ableiten jobsMu.Lock() job := jobs[id] jobsMu.Unlock() if job != nil { assetID := assetIDForJob(job) if assetID != "" { if thumbPath, err := generatedThumbFile(assetID); err == nil { if st, err := os.Stat(thumbPath); err == nil && !st.IsDir() && st.Size() > 0 { // running => no-store, finished => cache ok (du willst live eher no-store) if job.Status == JobRunning { serveLivePreviewJPEGFile(w, r, thumbPath) } else { servePreviewJPEGFile(w, r, thumbPath) } return } } } // Optional: wenn du bei running noch in-memory fallback willst: if job.Status == JobRunning { job.previewMu.Lock() cached := job.previewJpeg job.previewMu.Unlock() if len(cached) > 0 { serveLivePreviewJPEGBytes(w, cached) return } } // Placeholder statt hartem 404 servePreviewStatusSVG(w, "Preview", http.StatusOK) return } // 2) Kein Job im RAM: id als assetID behandeln (finished files nach Neustart) // "preview.jpg" als Alias auf thumbs.jpg assetID := stripHotPrefix(strings.TrimSpace(id)) if assetID == "" { http.NotFound(w, r) return } if thumbPath, err := generatedThumbFile(assetID); err == nil { if st, err := os.Stat(thumbPath); err == nil && !st.IsDir() && st.Size() > 0 { servePreviewJPEGFile(w, r, thumbPath) return } } http.NotFound(w, r) } func isHover(r *http.Request) bool { v := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("hover"))) return v == "1" || v == "true" || v == "yes" } func touchPreview(job *RecordJob) { if job == nil { return } jobsMu.Lock() job.previewLastHit = time.Now() jobsMu.Unlock() } func ensurePreviewStarted(r *http.Request, job *RecordJob) { if job == nil { return } job.previewStartMu.Lock() defer job.previewStartMu.Unlock() jobsMu.Lock() // läuft schon? if job.previewCmd != nil && job.PreviewDir != "" { job.previewLastHit = time.Now() jobsMu.Unlock() return } // brauchen M3U8 URL m3u8 := strings.TrimSpace(job.PreviewM3U8) cookie := strings.TrimSpace(job.PreviewCookie) ua := strings.TrimSpace(job.PreviewUA) jobsMu.Unlock() if m3u8 == "" { return } // eigener Context für Preview (WICHTIG: nicht der Recording ctx) pctx, cancel := context.WithCancel(context.Background()) // PreviewDir temp assetID := assetIDForJob(job) pdir := filepath.Join(os.TempDir(), "rec_preview", assetID) jobsMu.Lock() job.PreviewDir = pdir job.previewCancel = cancel job.previewLastHit = time.Now() jobsMu.Unlock() _ = startPreviewHLS(pctx, job, m3u8, pdir, cookie, ua) } func recordPreview(w http.ResponseWriter, r *http.Request) { // optional aber sinnvoll: nur GET/HEAD erlauben if r.Method != http.MethodGet && r.Method != http.MethodHead { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } id := strings.TrimSpace(r.URL.Query().Get("id")) if id == "" { // ✅ Alias: Frontend schickt "name" id = strings.TrimSpace(r.URL.Query().Get("name")) } if id == "" { http.Error(w, "id fehlt", http.StatusBadRequest) return } // ✅ NEW: JPEG requests abfangen if file := strings.TrimSpace(r.URL.Query().Get("file")); file != "" { if file == "thumbs.jpg" || file == "preview.jpg" { servePreviewThumbAlias(w, r, id, file) return } // HLS wie gehabt servePreviewHLSFile(w, r, id, file) return } // Schauen, ob wir einen Job mit dieser ID kennen (laufend oder gerade fertig) jobsMu.Lock() job, ok := jobs[id] jobsMu.Unlock() if ok { // ✅ 0) Running: wenn generated//thumbs.jpg existiert -> sofort ausliefern // (kein ffmpeg pro HTTP-Request) if job.Status == "running" { assetID := assetIDForJob(job) if assetID != "" { if thumbPath, err := generatedThumbFile(assetID); err == nil { if st, err := os.Stat(thumbPath); err == nil && !st.IsDir() && st.Size() > 0 { serveLivePreviewJPEGFile(w, r, thumbPath) return } } } } // ✅ Fallback: alter In-Memory-Cache (falls thumbs.jpg noch nicht da ist) job.previewMu.Lock() cached := job.previewJpeg cachedAt := job.previewJpegAt freshWindow := 8 * time.Second fresh := len(cached) > 0 && !cachedAt.IsZero() && time.Since(cachedAt) < freshWindow // Wenn nicht frisch, ggf. im Hintergrund aktualisieren (einmal gleichzeitig) if !fresh && !job.previewGen { job.previewGen = true go func(j *RecordJob, jobID string) { defer func() { j.previewMu.Lock() j.previewGen = false j.previewMu.Unlock() }() var img []byte var genErr error // 1) aus Preview-Segmenten previewDir := strings.TrimSpace(j.PreviewDir) if previewDir != "" { img, genErr = extractLastFrameFromPreviewDir(previewDir) } // 2) Fallback: aus der Ausgabedatei if genErr != nil || len(img) == 0 { outPath := strings.TrimSpace(j.Output) if outPath != "" { outPath = filepath.Clean(outPath) if !filepath.IsAbs(outPath) { if abs, err := resolvePathRelativeToApp(outPath); err == nil { outPath = abs } } if fi, err := os.Stat(outPath); err == nil && !fi.IsDir() && fi.Size() > 0 { img, genErr = extractLastFrameJPEG(outPath) if genErr != nil { img, _ = extractFirstFrameJPEG(outPath) } } } } if len(img) > 0 { j.previewMu.Lock() j.previewJpeg = img j.previewJpegAt = time.Now() j.previewMu.Unlock() } }(job, id) } // Wir liefern entweder ein frisches Bild, oder das zuletzt gecachte. out := cached job.previewMu.Unlock() if len(out) > 0 { serveLivePreviewJPEGBytes(w, out) // ✅ no-store für laufende Jobs return } // ✅ Wenn Preview definitiv nicht geht -> Placeholder statt 204 jobsMu.Lock() state := strings.TrimSpace(job.PreviewState) jobsMu.Unlock() if state == "private" { servePreviewStatusSVG(w, "Private", http.StatusOK) return } if state == "offline" { servePreviewStatusSVG(w, "Offline", http.StatusOK) return } // noch kein Bild verfügbar -> 204 (Frontend zeigt Placeholder und retry) w.Header().Set("Cache-Control", "no-store") w.WriteHeader(http.StatusNoContent) return } // 3️⃣ Kein Job im RAM → id als Dateistamm für fertige Downloads behandeln servePreviewForFinishedFile(w, r, id) } func serveLivePreviewJPEGFile(w http.ResponseWriter, r *http.Request, path string) { f, err := os.Open(path) if err != nil { http.NotFound(w, r) return } defer f.Close() st, err := f.Stat() if err != nil || st.IsDir() || st.Size() == 0 { http.NotFound(w, r) return } w.Header().Set("Content-Type", "image/jpeg") w.Header().Set("Cache-Control", "no-store") http.ServeContent(w, r, "thumbs.jpg", st.ModTime(), f) } func updateLiveThumbOnce(ctx context.Context, job *RecordJob) { // Snapshot unter Lock holen jobsMu.Lock() status := job.Status previewDir := job.PreviewDir out := job.Output jobsMu.Unlock() if status != "running" { return } // Zielpfad: generated//thumbs.jpg assetID := assetIDForJob(job) thumbPath, err := generatedThumbFile(assetID) if err != nil { return } // Wenn frisch genug: skip if st, err := os.Stat(thumbPath); err == nil && st.Size() > 0 { if time.Since(st.ModTime()) < 10*time.Second { return } } // Concurrency limit über thumbSem if thumbSem != nil { thumbCtx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() if err := thumbSem.Acquire(thumbCtx); err != nil { return } defer thumbSem.Release() } var img []byte // 1) bevorzugt aus Preview-Segmenten if previewDir != "" { if b, err := extractLastFrameFromPreviewDirThumb(previewDir); err == nil && len(b) > 0 { img = b } } // 2) fallback aus Output-Datei (kann bei partial files manchmal langsamer sein) if len(img) == 0 && out != "" { if b, err := extractLastFrameJPEGScaled(out, 320, 14); err == nil && len(b) > 0 { img = b } } if len(img) == 0 { return } _ = atomicWriteFile(thumbPath, img) } func startLiveThumbLoop(ctx context.Context, job *RecordJob) { // einmalig starten jobsMu.Lock() if job.LiveThumbStarted { jobsMu.Unlock() return } job.LiveThumbStarted = true jobsMu.Unlock() go func() { // sofort einmal versuchen updateLiveThumbOnce(ctx, job) for { // dynamische Frequenz: je mehr aktive Jobs, desto langsamer (weniger Last) jobsMu.Lock() nRunning := 0 for _, j := range jobs { if j != nil && j.Status == "running" { nRunning++ } } jobsMu.Unlock() delay := 12 * time.Second if nRunning >= 6 { delay = 18 * time.Second } if nRunning >= 12 { delay = 25 * time.Second } select { case <-ctx.Done(): return case <-time.After(delay): // ✅ Stoppen, sobald Job nicht mehr läuft jobsMu.Lock() st := job.Status jobsMu.Unlock() if st != "running" { return } updateLiveThumbOnce(ctx, job) } } }() } // Fallback: Preview für fertige Dateien nur anhand des Dateistamms (id) func servePreviewForFinishedFile(w http.ResponseWriter, r *http.Request, id string) { var err error id, err = sanitizeID(id) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } outPath, err := findFinishedFileByID(id) if err != nil { http.Error(w, "preview nicht verfügbar", http.StatusNotFound) return } if err := ensureGeneratedDirs(); err != nil { http.Error(w, "generated-dir nicht verfügbar: "+err.Error(), http.StatusInternalServerError) return } // ✅ Assets immer auf "basename ohne HOT" ablegen assetID := stripHotPrefix(id) if assetID == "" { assetID = id } assetDir, err := ensureGeneratedDir(assetID) if err != nil { http.Error(w, "generated-dir nicht verfügbar: "+err.Error(), http.StatusInternalServerError) return } // ✅ Frame-Caching für t=... (optional) if tStr := strings.TrimSpace(r.URL.Query().Get("t")); tStr != "" { if sec, err := strconv.ParseFloat(tStr, 64); err == nil && sec >= 0 { secI := int64(sec + 0.5) if secI < 0 { secI = 0 } framePath := filepath.Join(assetDir, fmt.Sprintf("t_%d.jpg", secI)) if fi, err := os.Stat(framePath); err == nil && !fi.IsDir() && fi.Size() > 0 { servePreviewJPEGFile(w, r, framePath) return } img, err := extractFrameAtTimeJPEG(outPath, float64(secI)) if err == nil && len(img) > 0 { _ = atomicWriteFile(framePath, img) servePreviewJPEGBytes(w, img) return } } } thumbPath := filepath.Join(assetDir, "thumbs.jpg") // 1) Cache hit (neu) if fi, err := os.Stat(thumbPath); err == nil && !fi.IsDir() && fi.Size() > 0 { servePreviewJPEGFile(w, r, thumbPath) return } // 2) Legacy-Migration (best effort) if thumbsLegacy, _ := generatedThumbsRoot(); strings.TrimSpace(thumbsLegacy) != "" { candidates := []string{ filepath.Join(thumbsLegacy, assetID, "preview.jpg"), filepath.Join(thumbsLegacy, id, "preview.jpg"), filepath.Join(thumbsLegacy, assetID+".jpg"), filepath.Join(thumbsLegacy, id+".jpg"), } for _, c := range candidates { if fi, err := os.Stat(c); err == nil && !fi.IsDir() && fi.Size() > 0 { if b, rerr := os.ReadFile(c); rerr == nil && len(b) > 0 { _ = atomicWriteFile(thumbPath, b) servePreviewJPEGBytes(w, b) return } } } } // 3) Neu erzeugen genCtx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() var t float64 = 0 if dur, derr := durationSecondsCached(genCtx, outPath); derr == nil && dur > 0 { t = dur * 0.5 } img, err := extractFrameAtTimeJPEG(outPath, t) if err != nil || len(img) == 0 { img, err = extractLastFrameJPEG(outPath) if err != nil || len(img) == 0 { img, err = extractFirstFrameJPEG(outPath) if err != nil || len(img) == 0 { http.Error(w, "konnte preview nicht erzeugen", http.StatusInternalServerError) return } } } _ = atomicWriteFile(thumbPath, img) servePreviewJPEGBytes(w, img) } const maxInt64 = int64(^uint64(0) >> 1) func removeJobsByOutputBasename(file string) { file = strings.TrimSpace(file) if file == "" { return } removed := false jobsMu.Lock() for id, j := range jobs { if j == nil { continue } out := strings.TrimSpace(j.Output) if out == "" { continue } if filepath.Base(out) == file { delete(jobs, id) removed = true } } jobsMu.Unlock() if removed { notifyJobsChanged() } } func renameJobsOutputBasename(oldFile, newFile string) { oldFile = strings.TrimSpace(oldFile) newFile = strings.TrimSpace(newFile) if oldFile == "" || newFile == "" { return } changed := false jobsMu.Lock() for _, j := range jobs { if j == nil { continue } out := strings.TrimSpace(j.Output) if out == "" { continue } if filepath.Base(out) == oldFile { j.Output = filepath.Join(filepath.Dir(out), newFile) changed = true } } jobsMu.Unlock() if changed { notifyJobsChanged() } } // nimmt jetzt *HTTPClient entgegen func FetchPlaylist(ctx context.Context, hc *HTTPClient, hlsSource, httpCookie string) (*Playlist, error) { if hlsSource == "" { return nil, errors.New("HLS-URL leer") } req, err := hc.NewRequest(ctx, http.MethodGet, hlsSource, httpCookie) if err != nil { return nil, fmt.Errorf("Fehler beim Erstellen der Playlist-Request: %w", err) } resp, err := hc.client.Do(req) if err != nil { return nil, fmt.Errorf("Fehler beim Laden der Playlist: %w", err) } defer resp.Body.Close() playlist, listType, err := m3u8.DecodeFrom(resp.Body, true) if err != nil || listType != m3u8.MASTER { return nil, errors.New("keine gültige Master-Playlist") } master := playlist.(*m3u8.MasterPlaylist) var bestURI string var bestWidth int var bestFramerate int for _, variant := range master.Variants { if variant == nil || variant.Resolution == "" { continue } parts := strings.Split(variant.Resolution, "x") if len(parts) != 2 { continue } width, err := strconv.Atoi(parts[0]) if err != nil { continue } fr := 30 if strings.Contains(variant.Name, "FPS:60.0") { fr = 60 } if width > bestWidth || (width == bestWidth && fr > bestFramerate) { bestWidth = width bestFramerate = fr bestURI = variant.URI } } if bestURI == "" { return nil, errors.New("keine gültige Auflösung gefunden") } root := hlsSource[:strings.LastIndex(hlsSource, "/")+1] return &Playlist{ PlaylistURL: root + bestURI, RootURL: root, Resolution: bestWidth, Framerate: bestFramerate, }, nil } func readLine() string { r := bufio.NewReader(os.Stdin) s, _ := r.ReadString('\n') return strings.TrimRight(s, "\r\n") } func fileExists(path string) bool { _, err := os.Stat(path) return err == nil }