// backend\main.go package main import ( "bufio" "bytes" "context" "encoding/binary" "encoding/json" "errors" "fmt" "html" "io" "math" "net/http" "net/url" "os" "os/exec" "path" "path/filepath" "regexp" "runtime" "sort" "strconv" "strings" "sync" "sync/atomic" "syscall" "time" "github.com/PuerkitoBio/goquery" "github.com/google/uuid" "github.com/grafov/m3u8" gocpu "github.com/shirou/gopsutil/v3/cpu" godisk "github.com/shirou/gopsutil/v3/disk" "github.com/sqweek/dialog" ) var roomDossierRegexp = regexp.MustCompile(`window\.initialRoomDossier = "(.*?)"`) type JobStatus string const ( JobRunning JobStatus = "running" JobFinished JobStatus = "finished" JobFailed JobStatus = "failed" JobStopped JobStatus = "stopped" ) 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"` 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:"-"` // ✅ Frontend Progress beim Stop/Finalize Phase string `json:"phase,omitempty"` // stopping | remuxing | moving Progress int `json:"progress,omitempty"` // 0..100 cancel context.CancelFunc `json:"-"` } type dummyResponseWriter struct { h http.Header } 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 recordJobsHub = newSSEHub() var recordJobsNotify = make(chan struct{}, 1) func init() { initFFmpegSemaphores() startAdaptiveSemController(context.Background()) // Debounced broadcaster go func() { for range recordJobsNotify { // kleine Debounce-Phase, um Burst-Updates zusammenzufassen time.Sleep(40 * time.Millisecond) // Kanal drainen (falls mehrere Notifies in kurzer Zeit kamen) for { select { case <-recordJobsNotify: default: goto SEND } } SEND: recordJobsHub.broadcast(jobsSnapshotJSON()) } }() } 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 } } 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, "diskPauseBelowGB": getSettings().LowDiskPauseBelowGB, "diskResumeAboveGB": getSettings().LowDiskPauseBelowGB + 3, "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) 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 } 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 // 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 } phase := strings.TrimSpace(j.Phase) if j.Status == JobRunning && phase == "" { stoppable = append(stoppable, j) } } jobsMu.Unlock() stopJobsInternal(stoppable) return len(stoppable) } // 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() // ✅ Schwellen aus Settings (GB -> Bytes) pauseGB := s.LowDiskPauseBelowGB // Defaults / Safety if pauseGB <= 0 { pauseGB = 5 } if pauseGB < 1 { pauseGB = 1 } if pauseGB > 10_000 { pauseGB = 10_000 } // ✅ Resume automatisch: +3GB Hysterese (wie vorher 5 -> 8) resumeGB := pauseGB + 3 if resumeGB > 10_000 { resumeGB = 10_000 } pauseBytes := uint64(pauseGB) * 1024 * 1024 * 1024 resumeBytes := uint64(resumeGB) * 1024 * 1024 * 1024 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 // ✅ Hysterese: erst ab resumeBytes wieder "bereit" if atomic.LoadInt32(&diskEmergency) == 1 { if free >= resumeBytes { atomic.StoreInt32(&diskEmergency, 0) fmt.Printf("✅ [disk] Recovered: free=%dB (>= %dB) emergency cleared\n", free, resumeBytes) } continue } // ✅ Normalzustand: solange free >= pauseBytes, nichts tun if free >= pauseBytes { continue } atomic.StoreInt32(&diskEmergency, 1) fmt.Printf( "🛑 [disk] Low space: free=%dB (<%dB, pause=%dGB resume=%dGB) -> pause autostart + stop jobs\n", free, pauseBytes, pauseGB, resumeGB, ) // 1) Autostart pausieren setAutostartPaused(true) // 2) laufende Downloads stoppen 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 } // main.go type RecorderSettings struct { RecordDir string `json:"recordDir"` DoneDir string `json:"doneDir"` FFmpegPath string `json:"ffmpegPath"` AutoAddToDownloadList bool `json:"autoAddToDownloadList"` AutoStartAddedDownloads bool `json:"autoStartAddedDownloads"` UseChaturbateAPI bool `json:"useChaturbateApi"` UseMyFreeCamsWatcher bool `json:"useMyFreeCamsWatcher"` // Wenn aktiv, werden fertige Downloads automatisch gelöscht, wenn sie kleiner als der Grenzwert sind. AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"` AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"` BlurPreviews bool `json:"blurPreviews"` TeaserPlayback string `json:"teaserPlayback"` // still | hover | all TeaserAudio bool `json:"teaserAudio"` // ✅ Vorschau/Teaser mit Ton abspielen // Low disk guard (GB) LowDiskPauseBelowGB int `json:"lowDiskPauseBelowGB"` // z.B. 5 // EncryptedCookies contains base64(nonce+ciphertext) of a JSON cookie map. EncryptedCookies string `json:"encryptedCookies"` } var ( settingsMu sync.Mutex settings = RecorderSettings{ RecordDir: "/records", DoneDir: "/records/done", FFmpegPath: "", AutoAddToDownloadList: false, AutoStartAddedDownloads: false, UseChaturbateAPI: false, UseMyFreeCamsWatcher: false, AutoDeleteSmallDownloads: false, AutoDeleteSmallDownloadsBelowMB: 50, BlurPreviews: false, TeaserPlayback: "hover", TeaserAudio: false, LowDiskPauseBelowGB: 5, EncryptedCookies: "", } settingsFile = "recorder_settings.json" ) func settingsFilePath() string { // optionaler Override per ENV name := strings.TrimSpace(os.Getenv("RECORDER_SETTINGS_FILE")) if name == "" { name = settingsFile } // Standard: relativ zur EXE / App-Dir (oder fallback auf Working Dir bei go run) if p, err := resolvePathRelativeToApp(name); err == nil && strings.TrimSpace(p) != "" { return p } // Fallback: so zurückgeben wie es ist return name } func getSettings() RecorderSettings { settingsMu.Lock() defer settingsMu.Unlock() return settings } 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 (ohne HOT) id = stripHotPrefix(strings.TrimSpace(id)) if id == "" { return } // 1) NEU: generated// (enthält thumbs.jpg, preview.mp4, meta.json, t_*.jpg, ...) 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) } } } func loadSettings() { p := settingsFilePath() b, err := os.ReadFile(p) fmt.Println("🔧 settingsFile:", p) if err == nil { s := getSettings() // ✅ startet mit Defaults if json.Unmarshal(b, &s) == nil { if strings.TrimSpace(s.RecordDir) != "" { s.RecordDir = filepath.Clean(strings.TrimSpace(s.RecordDir)) } if strings.TrimSpace(s.DoneDir) != "" { s.DoneDir = filepath.Clean(strings.TrimSpace(s.DoneDir)) } if strings.TrimSpace(s.FFmpegPath) != "" { s.FFmpegPath = strings.TrimSpace(s.FFmpegPath) } s.TeaserPlayback = strings.ToLower(strings.TrimSpace(s.TeaserPlayback)) if s.TeaserPlayback == "" { s.TeaserPlayback = "hover" } if s.TeaserPlayback != "still" && s.TeaserPlayback != "hover" && s.TeaserPlayback != "all" { s.TeaserPlayback = "hover" } // Auto-Delete: clamp if s.AutoDeleteSmallDownloadsBelowMB < 0 { s.AutoDeleteSmallDownloadsBelowMB = 0 } if s.AutoDeleteSmallDownloadsBelowMB > 100_000 { s.AutoDeleteSmallDownloadsBelowMB = 100_000 } // Low disk guard: clamp + Hysterese erzwingen if s.LowDiskPauseBelowGB < 1 { s.LowDiskPauseBelowGB = 1 } if s.LowDiskPauseBelowGB > 10_000 { s.LowDiskPauseBelowGB = 10_000 } settingsMu.Lock() settings = s settingsMu.Unlock() } } // Ordner sicherstellen s := getSettings() recordAbs, _ := resolvePathRelativeToApp(s.RecordDir) doneAbs, _ := resolvePathRelativeToApp(s.DoneDir) if strings.TrimSpace(recordAbs) != "" { _ = os.MkdirAll(recordAbs, 0o755) } if strings.TrimSpace(doneAbs) != "" { _ = os.MkdirAll(doneAbs, 0o755) } // ffmpeg-Pfad anhand Settings/Env/PATH bestimmen ffmpegPath = detectFFmpegPath() fmt.Println("🔍 ffmpegPath:", ffmpegPath) ffprobePath = detectFFprobePath() fmt.Println("🔍 ffprobePath:", ffprobePath) } func saveSettingsToDisk() { s := getSettings() b, err := json.MarshalIndent(s, "", " ") if err != nil { fmt.Println("⚠️ settings marshal:", err) return } b = append(b, '\n') p := settingsFilePath() if err := atomicWriteFile(p, b); err != nil { fmt.Println("⚠️ settings write:", err) return } // optional // fmt.Println("✅ settings saved:", p) } func recordSettingsHandler(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(getSettings()) return case http.MethodPost: var in RecorderSettings if err := json.NewDecoder(r.Body).Decode(&in); err != nil { http.Error(w, "invalid json: "+err.Error(), http.StatusBadRequest) return } // --- normalize --- in.RecordDir = filepath.Clean(strings.TrimSpace(in.RecordDir)) in.DoneDir = filepath.Clean(strings.TrimSpace(in.DoneDir)) in.FFmpegPath = strings.TrimSpace(in.FFmpegPath) in.TeaserPlayback = strings.ToLower(strings.TrimSpace(in.TeaserPlayback)) if in.TeaserPlayback == "" { in.TeaserPlayback = "hover" } if in.TeaserPlayback != "still" && in.TeaserPlayback != "hover" && in.TeaserPlayback != "all" { in.TeaserPlayback = "hover" } if in.RecordDir == "" || in.DoneDir == "" { http.Error(w, "recordDir und doneDir dürfen nicht leer sein", http.StatusBadRequest) return } // Auto-Delete: clamp if in.AutoDeleteSmallDownloadsBelowMB < 0 { in.AutoDeleteSmallDownloadsBelowMB = 0 } if in.AutoDeleteSmallDownloadsBelowMB > 100_000 { in.AutoDeleteSmallDownloadsBelowMB = 100_000 } // Low disk guard: backward compat + clamp current := getSettings() if in.LowDiskPauseBelowGB <= 0 { in.LowDiskPauseBelowGB = current.LowDiskPauseBelowGB } if in.LowDiskPauseBelowGB < 1 { in.LowDiskPauseBelowGB = 1 } if in.LowDiskPauseBelowGB > 10_000 { in.LowDiskPauseBelowGB = 10_000 } // --- ensure folders (Fehler zurückgeben, falls z.B. keine Rechte) --- recAbs, _ := resolvePathRelativeToApp(in.RecordDir) doneAbs, _ := resolvePathRelativeToApp(in.DoneDir) if err := os.MkdirAll(recAbs, 0o755); err != nil { http.Error(w, "konnte recordDir nicht erstellen: "+err.Error(), http.StatusBadRequest) return } if err := os.MkdirAll(doneAbs, 0o755); err != nil { http.Error(w, "konnte doneDir nicht erstellen: "+err.Error(), http.StatusBadRequest) return } // ✅ WICHTIG: Settings im RAM aktualisieren settingsMu.Lock() settings = in settingsMu.Unlock() // ✅ WICHTIG: Settings auf Disk persistieren saveSettingsToDisk() // ✅ ffmpeg/ffprobe nach Änderungen neu bestimmen (nutzt jetzt die neuen Settings) ffmpegPath = detectFFmpegPath() fmt.Println("🔍 ffmpegPath:", ffmpegPath) ffprobePath = detectFFprobePath() fmt.Println("🔍 ffprobePath:", ffprobePath) w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(getSettings()) return default: http.Error(w, "Nur GET/POST erlaubt", http.StatusMethodNotAllowed) return } } func settingsBrowse(w http.ResponseWriter, r *http.Request) { target := r.URL.Query().Get("target") if target != "record" && target != "done" && target != "ffmpeg" { http.Error(w, "target muss record, done oder ffmpeg sein", http.StatusBadRequest) return } var ( p string err error ) if target == "ffmpeg" { // Dateiauswahl für ffmpeg.exe p, err = dialog.File(). Title("ffmpeg.exe auswählen"). Load() } else { // Ordnerauswahl für record/done p, err = dialog.Directory(). Title("Ordner auswählen"). Browse() } if err != nil { // User cancelled → 204 No Content ist praktisch fürs Frontend if strings.Contains(strings.ToLower(err.Error()), "cancel") { w.WriteHeader(http.StatusNoContent) return } http.Error(w, "auswahl fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } // optional: wenn innerhalb exe-dir, als RELATIV zurückgeben p = maybeMakeRelativeToExe(p) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{"path": p}) } func maybeMakeRelativeToExe(abs string) string { exe, err := os.Executable() if err != nil { return abs } base := filepath.Dir(exe) rel, err := filepath.Rel(base, abs) if err != nil { return abs } // wenn rel mit ".." beginnt -> nicht innerhalb base -> absoluten Pfad behalten if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { return abs } return filepath.ToSlash(rel) // frontend-freundlich } // --- 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 } func generatedRoot() (string, error) { return resolvePathRelativeToApp("generated") } // 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")) } // -------------------------- // generated//meta.json // -------------------------- type videoMetaV1 struct { Version int `json:"version"` DurationSeconds float64 `json:"durationSeconds"` FileSize int64 `json:"fileSize"` FileModUnix int64 `json:"fileModUnix"` UpdatedAtUnix int64 `json:"updatedAtUnix"` } func generatedMetaFile(id string) (string, error) { dir, err := generatedDirForID(id) // erzeugt KEIN Verzeichnis if err != nil { return "", err } return filepath.Join(dir, "meta.json"), nil } func readVideoMetaDuration(metaPath string, fi os.FileInfo) (sec float64, ok bool) { b, err := os.ReadFile(metaPath) if err != nil || len(b) == 0 { return 0, false } var m videoMetaV1 if err := json.Unmarshal(b, &m); err != nil { return 0, false } if m.Version != 1 { return 0, false } // Invalidation: wenn Datei geändert wurde -> Meta ignorieren if m.FileSize != fi.Size() || m.FileModUnix != fi.ModTime().Unix() { return 0, false } if m.DurationSeconds <= 0 { return 0, false } return m.DurationSeconds, true } func writeVideoMeta(metaPath string, fi os.FileInfo, dur float64) error { if strings.TrimSpace(metaPath) == "" || dur <= 0 { return nil } m := videoMetaV1{ Version: 1, DurationSeconds: dur, FileSize: fi.Size(), FileModUnix: fi.ModTime().Unix(), UpdatedAtUnix: time.Now().Unix(), } buf, err := json.Marshal(m) if err != nil { return err } buf = append(buf, '\n') return atomicWriteFile(metaPath, buf) } // ✅ Neu: /generated//thumbs.jpg + /generated//preview.mp4 func generatedDirForID(id string) (string, error) { id, err := sanitizeID(id) if err != nil { return "", err } root, err := generatedRoot() if err != nil { return "", err } if strings.TrimSpace(root) == "" { return "", fmt.Errorf("generated root ist leer") } return filepath.Join(root, id), nil } func ensureGeneratedDir(id string) (string, error) { dir, err := generatedDirForID(id) if err != nil { return "", err } if err := os.MkdirAll(dir, 0o755); err != nil { return "", err } return dir, nil } func generatedThumbFile(id string) (string, error) { dir, err := generatedDirForID(id) if err != nil { return "", err } return filepath.Join(dir, "thumbs.jpg"), nil } func generatedPreviewFile(id string) (string, error) { dir, err := generatedDirForID(id) if err != nil { return "", err } return filepath.Join(dir, "preview.mp4"), nil } func ensureGeneratedDirs() error { root, err := generatedRoot() if err != nil { return err } if strings.TrimSpace(root) == "" { return fmt.Errorf("generated root ist leer") } return os.MkdirAll(root, 0o755) } func sanitizeID(id string) (string, error) { id = strings.TrimSpace(id) if id == "" { return "", fmt.Errorf("id fehlt") } if strings.ContainsAny(id, `/\`) { return "", fmt.Errorf("ungültige id") } return id, 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) } 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 recordPreview(w http.ResponseWriter, r *http.Request) { id := r.URL.Query().Get("id") if id == "" { http.Error(w, "id fehlt", http.StatusBadRequest) return } // HLS-Dateien (index.m3u8, seg_*.ts) wie bisher if file := r.URL.Query().Get("file"); file != "" { 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") return } if state == "offline" { servePreviewStatusSVG(w, "Offline") 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) } func serveTeaserFile(w http.ResponseWriter, r *http.Request, path string) { f, err := openForReadShareDelete(path) if err != nil { http.Error(w, "datei öffnen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } defer f.Close() fi, err := f.Stat() if err != nil || fi.IsDir() || fi.Size() == 0 { http.Error(w, "datei nicht gefunden", http.StatusNotFound) return } w.Header().Set("Cache-Control", "public, max-age=31536000") w.Header().Set("Content-Type", "video/mp4") http.ServeContent(w, r, filepath.Base(path), fi.ModTime(), f) } // tolerante Input-Flags für kaputte/abgeschnittene H264/TS Streams var ffmpegInputTol = []string{ "-fflags", "+discardcorrupt+genpts", "-err_detect", "ignore_err", "-max_error_rate", "1.0", } func generateTeaserMP4(ctx context.Context, srcPath, outPath string, startSec, durSec float64) error { if durSec <= 0 { durSec = 8 } if startSec < 0 { startSec = 0 } // temp schreiben -> rename tmp := outPath + ".tmp.mp4" args := []string{ "-y", "-hide_banner", "-loglevel", "error", } args = append(args, ffmpegInputTol...) args = append(args, "-ss", fmt.Sprintf("%.3f", startSec), "-i", srcPath, "-t", fmt.Sprintf("%.3f", durSec), // Video "-vf", "scale=720:-2", "-map", "0:v:0", // Audio (optional: falls kein Audio vorhanden ist, bricht ffmpeg NICHT ab) "-map", "0:a:0?", "-c:a", "aac", "-b:a", "128k", "-ac", "2", "-c:v", "libx264", "-preset", "veryfast", "-crf", "28", "-pix_fmt", "yuv420p", // Wenn Audio minimal kürzer/länger ist, sauber beenden "-shortest", "-movflags", "+faststart", "-f", "mp4", tmp, ) cmd := exec.CommandContext(ctx, ffmpegPath, args...) if out, err := cmd.CombinedOutput(); err != nil { _ = os.Remove(tmp) return fmt.Errorf("ffmpeg teaser failed: %v (%s)", err, strings.TrimSpace(string(out))) } _ = os.Remove(outPath) return os.Rename(tmp, outPath) } func generatedTeaser(w http.ResponseWriter, r *http.Request) { id := strings.TrimSpace(r.URL.Query().Get("id")) if id == "" { http.Error(w, "id fehlt", http.StatusBadRequest) return } 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 } 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 } previewPath := filepath.Join(assetDir, "preview.mp4") // Cache hit (neu) if fi, err := os.Stat(previewPath); err == nil && !fi.IsDir() && fi.Size() > 0 { serveTeaserFile(w, r, previewPath) return } // Legacy: generated/teaser/_teaser.mp4 oder .mp4 if teaserLegacy, _ := generatedTeaserRoot(); strings.TrimSpace(teaserLegacy) != "" { cids := []string{assetID, id} for _, cid := range cids { candidates := []string{ filepath.Join(teaserLegacy, cid+"_teaser.mp4"), filepath.Join(teaserLegacy, cid+".mp4"), } for _, c := range candidates { if fi, err := os.Stat(c); err == nil && !fi.IsDir() && fi.Size() > 0 { if _, err2 := os.Stat(previewPath); os.IsNotExist(err2) { _ = os.MkdirAll(filepath.Dir(previewPath), 0o755) _ = os.Rename(c, previewPath) } if fi2, err2 := os.Stat(previewPath); err2 == nil && !fi2.IsDir() && fi2.Size() > 0 { serveTeaserFile(w, r, previewPath) return } serveTeaserFile(w, r, c) return } } } } // Neu erzeugen if err := genSem.Acquire(r.Context()); err != nil { http.Error(w, "abgebrochen: "+err.Error(), http.StatusRequestTimeout) return } defer genSem.Release() genCtx, cancel := context.WithTimeout(r.Context(), 3*time.Minute) defer cancel() if err := generateTeaserClipsMP4(genCtx, outPath, previewPath, 1.0, 18); err != nil { // Fallback: einzelner kurzer Teaser ab Anfang (trifft seltener kaputte Stellen) if err2 := generateTeaserMP4(genCtx, outPath, previewPath, 0, 8); err2 != nil { http.Error(w, "konnte preview nicht erzeugen: "+err.Error()+" (fallback ebenfalls fehlgeschlagen: "+err2.Error()+")", http.StatusInternalServerError) return } } serveTeaserFile(w, r, previewPath) } // --------------------------- // Tasks: Missing Assets erzeugen // --------------------------- type AssetsTaskState struct { Running bool `json:"running"` Total int `json:"total"` Done int `json:"done"` GeneratedThumbs int `json:"generatedThumbs"` GeneratedPreviews int `json:"generatedPreviews"` Skipped int `json:"skipped"` StartedAt time.Time `json:"startedAt"` FinishedAt *time.Time `json:"finishedAt,omitempty"` Error string `json:"error,omitempty"` } var assetsTaskMu sync.Mutex var assetsTaskState AssetsTaskState var assetsTaskCancel context.CancelFunc func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: assetsTaskMu.Lock() st := assetsTaskState assetsTaskMu.Unlock() writeJSON(w, http.StatusOK, st) return case http.MethodPost: assetsTaskMu.Lock() if assetsTaskState.Running { st := assetsTaskState assetsTaskMu.Unlock() writeJSON(w, http.StatusOK, st) return } // ✅ cancelbaren Context erzeugen ctx, cancel := context.WithCancel(context.Background()) assetsTaskCancel = cancel assetsTaskState = AssetsTaskState{ Running: true, StartedAt: time.Now(), } st := assetsTaskState assetsTaskMu.Unlock() go runGenerateMissingAssets(ctx) writeJSON(w, http.StatusOK, st) return case http.MethodDelete: assetsTaskMu.Lock() cancel := assetsTaskCancel running := assetsTaskState.Running assetsTaskMu.Unlock() if !running || cancel == nil { // nichts zu stoppen w.WriteHeader(http.StatusNoContent) return } cancel() // optional: sofortiges Feedback in state.error assetsTaskMu.Lock() if assetsTaskState.Running { assetsTaskState.Error = "abgebrochen" } st := assetsTaskState assetsTaskMu.Unlock() writeJSON(w, http.StatusOK, st) return default: http.Error(w, "Nur GET/POST", http.StatusMethodNotAllowed) return } } func runGenerateMissingAssets(ctx context.Context) { finishWithErr := func(err error) { now := time.Now() assetsTaskMu.Lock() assetsTaskState.Running = false assetsTaskState.FinishedAt = &now if err != nil { assetsTaskState.Error = err.Error() } assetsTaskMu.Unlock() } defer func() { assetsTaskMu.Lock() assetsTaskCancel = nil assetsTaskMu.Unlock() }() s := getSettings() doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil || strings.TrimSpace(doneAbs) == "" { finishWithErr(fmt.Errorf("doneDir auflösung fehlgeschlagen: %v", err)) return } type item struct { name string path string } seen := map[string]struct{}{} items := make([]item, 0, 512) addIfVideo := func(full string) { name := filepath.Base(full) low := strings.ToLower(name) if strings.Contains(low, ".part") || strings.Contains(low, ".tmp") { return } ext := strings.ToLower(filepath.Ext(name)) if ext != ".mp4" && ext != ".ts" { return } // Dedupe (falls du Files doppelt findest) if _, ok := seen[full]; ok { return } seen[full] = struct{}{} items = append(items, item{name: name, path: full}) } scanOneLevel := func(dir string) { ents, err := os.ReadDir(dir) if err != nil { return } for _, e := range ents { full := filepath.Join(dir, e.Name()) if e.IsDir() { sub, err := os.ReadDir(full) if err != nil { continue } for _, se := range sub { if se.IsDir() { continue } addIfVideo(filepath.Join(full, se.Name())) } continue } addIfVideo(full) } } // ✅ done + done// + done/keep + done/keep// scanOneLevel(doneAbs) scanOneLevel(filepath.Join(doneAbs, "keep")) assetsTaskMu.Lock() assetsTaskState.Total = len(items) assetsTaskState.Done = 0 assetsTaskState.GeneratedThumbs = 0 assetsTaskState.GeneratedPreviews = 0 assetsTaskState.Skipped = 0 assetsTaskState.Error = "" assetsTaskMu.Unlock() for i, it := range items { if err := ctx.Err(); err != nil { finishWithErr(err) // context.Canceled return } base := strings.TrimSuffix(it.name, filepath.Ext(it.name)) id := stripHotPrefix(base) if strings.TrimSpace(id) == "" { assetsTaskMu.Lock() assetsTaskState.Done = i + 1 assetsTaskMu.Unlock() continue } assetDir, derr := ensureGeneratedDir(id) if derr != nil { assetsTaskMu.Lock() assetsTaskState.Error = "mindestens ein Eintrag konnte nicht verarbeitet werden (siehe Logs)" assetsTaskState.Done = i + 1 assetsTaskMu.Unlock() fmt.Println("⚠️ ensureGeneratedDir:", derr) continue } thumbPath := filepath.Join(assetDir, "thumbs.jpg") previewPath := filepath.Join(assetDir, "preview.mp4") thumbOK := func() bool { fi, err := os.Stat(thumbPath) return err == nil && !fi.IsDir() && fi.Size() > 0 }() previewOK := func() bool { fi, err := os.Stat(previewPath) return err == nil && !fi.IsDir() && fi.Size() > 0 }() // Datei-Info (für Meta-Invaliderung) vfi, verr := os.Stat(it.path) if verr != nil || vfi.IsDir() || vfi.Size() <= 0 { assetsTaskMu.Lock() assetsTaskState.Done = i + 1 assetsTaskMu.Unlock() continue } metaPath := filepath.Join(assetDir, "meta.json") // ✅ Dauer zuerst aus meta.json, sonst 1× ffprobe & meta.json schreiben durSec := 0.0 metaOK := false if d, ok := readVideoMetaDuration(metaPath, vfi); ok { durSec = d metaOK = true } else { dctx, cancel := context.WithTimeout(ctx, 6*time.Second) if d, derr := durationSecondsCached(dctx, it.path); derr == nil && d > 0 { durSec = d _ = writeVideoMeta(metaPath, vfi, durSec) metaOK = true } cancel() } if thumbOK && previewOK && metaOK { assetsTaskMu.Lock() assetsTaskState.Skipped++ assetsTaskState.Done = i + 1 assetsTaskMu.Unlock() continue } if !thumbOK { genCtx, cancel := context.WithTimeout(ctx, 45*time.Second) if err := thumbSem.Acquire(genCtx); err != nil { cancel() finishWithErr(err) return } var t float64 = 0 if durSec > 0 { t = durSec * 0.5 } cancel() img, e1 := extractFrameAtTimeJPEG(it.path, t) if e1 != nil || len(img) == 0 { img, e1 = extractLastFrameJPEG(it.path) if e1 != nil || len(img) == 0 { img, e1 = extractFirstFrameJPEG(it.path) } } thumbSem.Release() if e1 == nil && len(img) > 0 { if err := atomicWriteFile(thumbPath, img); err == nil { assetsTaskMu.Lock() assetsTaskState.GeneratedThumbs++ assetsTaskMu.Unlock() } else { fmt.Println("⚠️ thumb write:", err) } } } if !previewOK { genCtx, cancel := context.WithTimeout(ctx, 3*time.Minute) if err := genSem.Acquire(genCtx); err != nil { cancel() finishWithErr(err) return } err := generateTeaserClipsMP4(genCtx, it.path, previewPath, 1.0, 18) genSem.Release() cancel() if err == nil { assetsTaskMu.Lock() assetsTaskState.GeneratedPreviews++ assetsTaskMu.Unlock() } else { fmt.Println("⚠️ preview clips:", err) } } assetsTaskMu.Lock() assetsTaskState.Done = i + 1 assetsTaskMu.Unlock() } finishWithErr(nil) } func generateTeaserClipsMP4(ctx context.Context, srcPath, outPath string, clipLenSec float64, maxClips int) error { return generateTeaserClipsMP4WithProgress(ctx, srcPath, outPath, clipLenSec, maxClips, nil) } func generateTeaserClipsMP4WithProgress( ctx context.Context, srcPath, outPath string, clipLenSec float64, maxClips int, onRatio func(r float64), ) error { if clipLenSec <= 0 { clipLenSec = 1 } if maxClips <= 0 { maxClips = 18 } // Dauer holen (einmalig; wird gecached) dur, _ := durationSecondsCached(ctx, srcPath) // Wenn Dauer unbekannt/zu klein: einfach ab 0 ein kurzes Stück if !(dur > 0) || dur <= clipLenSec+0.2 { // hier kein großer Vorteil – trotzdem wenigstens 0..1 melden if onRatio != nil { onRatio(0) } err := generateTeaserMP4(ctx, srcPath, outPath, 0, math.Min(8, math.Max(clipLenSec, dur))) if onRatio != nil { onRatio(1) } return err } // Anzahl Clips ähnlich wie deine Frontend-"clips"-Logik: count := int(math.Floor(dur)) if count < 8 { count = 8 } if count > maxClips { count = maxClips } span := math.Max(0.1, dur-clipLenSec) base := math.Min(0.25, span*0.02) starts := make([]float64, 0, count) for i := 0; i < count; i++ { t := (float64(i)/float64(count))*span + base if t < 0.05 { t = 0.05 } if t > dur-0.05-clipLenSec { t = math.Max(0, dur-0.05-clipLenSec) } starts = append(starts, t) } // erwartete Output-Dauer (Concat der Clips) expectedOutSec := float64(len(starts)) * clipLenSec // temp schreiben -> rename (WICHTIG: temp endet auf .mp4, sonst Muxer-Error) tmp := strings.TrimSuffix(outPath, ".mp4") + ".part.mp4" args := []string{ "-y", "-nostats", "-progress", "pipe:1", "-hide_banner", "-loglevel", "error", } // Mehrere Inputs: gleiche Datei, aber je Clip mit eigenem -ss/-t for _, t := range starts { args = append(args, ffmpegInputTol...) args = append(args, "-ss", fmt.Sprintf("%.3f", t), "-t", fmt.Sprintf("%.3f", clipLenSec), "-i", srcPath, ) } // pro Segment v+i und a+i erzeugen var fc strings.Builder for i := range starts { fmt.Fprintf(&fc, "[%d:v]scale=720:-2,setsar=1,setpts=PTS-STARTPTS[v%d];", i, i, ) fmt.Fprintf(&fc, "[%d:a]aresample=48000,aformat=channel_layouts=stereo,asetpts=PTS-STARTPTS[a%d];", i, i, ) } // WICHTIG: interleaved! [v0][a0][v1][a1]... for i := range starts { fmt.Fprintf(&fc, "[v%d][a%d]", i, i) } fmt.Fprintf(&fc, "concat=n=%d:v=1:a=1[v][a]", len(starts)) args = append(args, "-filter_complex", fc.String(), "-map", "[v]", "-map", "[a]", "-c:v", "libx264", "-preset", "veryfast", "-crf", "28", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "128k", "-ac", "2", "-shortest", "-movflags", "+faststart", tmp, ) cmd := exec.CommandContext(ctx, ffmpegPath, args...) 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 lastSent float64 var lastAt time.Time send := func(outSec float64, force bool) { if onRatio == nil { return } if expectedOutSec > 0 && outSec > 0 { r := outSec / expectedOutSec if r < 0 { r = 0 } if r > 1 { r = 1 } // throttle if r-lastSent < 0.01 && !force { return } if !lastAt.IsZero() && time.Since(lastAt) < 150*time.Millisecond && !force { return } lastSent = r lastAt = time.Now() onRatio(r) return } if force { onRatio(1) } } var outSec float64 for sc.Scan() { line := strings.TrimSpace(sc.Text()) if line == "" { continue } parts := strings.SplitN(line, "=", 2) if len(parts) != 2 { continue } k := parts[0] v := parts[1] switch k { case "out_time_ms": // ffmpeg liefert hier Mikrosekunden (trotz Name) if n, perr := strconv.ParseInt(strings.TrimSpace(v), 10, 64); perr == nil && n > 0 { outSec = float64(n) / 1_000_000.0 send(outSec, false) } case "out_time": if s := parseFFmpegOutTime(v); s > 0 { outSec = s send(outSec, false) } case "progress": if strings.TrimSpace(v) == "end" { send(outSec, true) } } } if err := cmd.Wait(); err != nil { _ = os.Remove(tmp) return fmt.Errorf("ffmpeg teaser clips failed: %v (%s)", err, strings.TrimSpace(stderr.String())) } _ = os.Remove(outPath) return os.Rename(tmp, outPath) } func prunePreviewCacheDir(previewDir string, maxFrames int, maxAge time.Duration) { entries, err := os.ReadDir(previewDir) if err != nil { return } type frame struct { path string mt time.Time } now := time.Now() var frames []frame for _, e := range entries { name := e.Name() path := filepath.Join(previewDir, name) // .part Dateien immer weg if strings.HasSuffix(name, ".part") { _ = os.Remove(path) continue } // optional: preview.jpg neu erzeugen lassen, wenn uralt if name == "preview.jpg" { if info, err := e.Info(); err == nil { if maxAge > 0 && now.Sub(info.ModTime()) > maxAge { _ = os.Remove(path) } } continue } // Nur t_*.jpg verwalten if strings.HasPrefix(name, "t_") && strings.HasSuffix(name, ".jpg") { info, err := e.Info() if err != nil { continue } // alte Frames löschen if maxAge > 0 && now.Sub(info.ModTime()) > maxAge { _ = os.Remove(path) continue } frames = append(frames, frame{path: path, mt: info.ModTime()}) } } // Anzahl begrenzen: älteste zuerst löschen if maxFrames > 0 && len(frames) > maxFrames { sort.Slice(frames, func(i, j int) bool { return frames[i].mt.Before(frames[j].mt) }) toDelete := len(frames) - maxFrames for i := 0; i < toDelete; i++ { _ = os.Remove(frames[i].path) } } } func servePreviewJPEGBytes(w http.ResponseWriter, img []byte) { w.Header().Set("Content-Type", "image/jpeg") w.Header().Set("Cache-Control", "public, max-age=31536000") w.Header().Set("X-Content-Type-Options", "nosniff") w.WriteHeader(http.StatusOK) _, _ = w.Write(img) } func servePreviewJPEGBytesNoStore(w http.ResponseWriter, img []byte) { w.Header().Set("Content-Type", "image/jpeg") w.Header().Set("Cache-Control", "no-store, max-age=0") w.Header().Set("Pragma", "no-cache") w.Header().Set("Expires", "0") w.Header().Set("X-Content-Type-Options", "nosniff") w.WriteHeader(http.StatusOK) _, _ = w.Write(img) } func serveLivePreviewJPEGBytes(w http.ResponseWriter, img []byte) { w.Header().Set("Content-Type", "image/jpeg") w.Header().Set("Cache-Control", "no-store, max-age=0, must-revalidate") w.Header().Set("Pragma", "no-cache") w.Header().Set("Expires", "0") w.Header().Set("X-Content-Type-Options", "nosniff") w.WriteHeader(http.StatusOK) _, _ = w.Write(img) } func servePreviewJPEGFile(w http.ResponseWriter, r *http.Request, path string) { w.Header().Set("Content-Type", "image/jpeg") w.Header().Set("Cache-Control", "public, max-age=31536000") w.Header().Set("X-Content-Type-Options", "nosniff") http.ServeFile(w, r, path) } func recordList(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed) return } jobsMu.Lock() list := make([]*RecordJob, 0, len(jobs)) for _, j := range jobs { // ✅ NEU: Hidden (und nil) nicht ausgeben -> UI sieht Probe-Jobs nicht if j == nil || j.Hidden { continue } list = append(list, j) } jobsMu.Unlock() // optional: neueste zuerst sort.Slice(list, func(i, j int) bool { return list[i].StartedAt.After(list[j].StartedAt) }) w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(list) } var previewFileRe = regexp.MustCompile(`^(index(_hq)?\.m3u8|seg_(low|hq)_\d+\.ts|seg_\d+\.ts)$`) func serveEmptyLiveM3U8(w http.ResponseWriter, r *http.Request) { // Für Player: gültige Playlist statt 204 liefern w.Header().Set("Content-Type", "application/vnd.apple.mpegurl; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.Header().Set("X-Content-Type-Options", "nosniff") // Optional: Player/Proxy darf schnell retryen w.Header().Set("Retry-After", "1") // Bei HEAD nur Header schicken if r.Method == http.MethodHead { w.WriteHeader(http.StatusOK) return } // Minimal gültige LIVE-Playlist (keine Segmente, kein ENDLIST) // Viele Player bleiben damit im "loading", statt hart zu failen. body := "#EXTM3U\n" + "#EXT-X-VERSION:3\n" + "#EXT-X-TARGETDURATION:2\n" + "#EXT-X-MEDIA-SEQUENCE:0\n" w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(body)) } func servePreviewHLSFile(w http.ResponseWriter, r *http.Request, id, file string) { file = strings.TrimSpace(file) if file == "" || filepath.Base(file) != file || !previewFileRe.MatchString(file) { http.Error(w, "ungültige file", http.StatusBadRequest) return } isIndex := file == "index.m3u8" || file == "index_hq.m3u8" jobsMu.Lock() job, ok := jobs[id] state := "" if ok { state = strings.TrimSpace(job.PreviewState) } jobsMu.Unlock() if ok { if state == "private" { http.Error(w, "model private", http.StatusForbidden) return } if state == "offline" { http.Error(w, "model offline", http.StatusNotFound) return } } if !ok { // Job wirklich unbekannt => 404 ist ok http.Error(w, "job nicht gefunden", http.StatusNotFound) return } // Preview noch nicht initialisiert? Für index => 204 (kein roter Fehler im Browser) if strings.TrimSpace(job.PreviewDir) == "" { if isIndex { serveEmptyLiveM3U8(w, r) return } http.Error(w, "preview nicht verfügbar", http.StatusNotFound) return } p := filepath.Join(job.PreviewDir, file) if _, err := os.Stat(p); err != nil { if isIndex { serveEmptyLiveM3U8(w, r) return } http.Error(w, "datei nicht gefunden", http.StatusNotFound) return } switch strings.ToLower(filepath.Ext(p)) { case ".m3u8": w.Header().Set("Content-Type", "application/vnd.apple.mpegurl; charset=utf-8") case ".ts": w.Header().Set("Content-Type", "video/mp2t") default: w.Header().Set("Content-Type", "application/octet-stream") } w.Header().Set("Cache-Control", "no-store") http.ServeFile(w, r, p) } func rewriteM3U8ToPreviewEndpoint(m3u8 string, id string) string { lines := strings.Split(m3u8, "\n") escapedID := url.QueryEscape(id) for i, line := range lines { l := strings.TrimSpace(line) if l == "" || strings.HasPrefix(l, "#") { continue } // Segment/URI-Zeilen umschreiben lines[i] = "/api/record/preview?id=" + escapedID + "&file=" + url.QueryEscape(l) } return strings.Join(lines, "\n") } func classifyPreviewFFmpegStderr(stderr string) (state string, httpStatus int) { s := strings.ToLower(stderr) // ffmpeg schreibt typischerweise: // "HTTP error 403 Forbidden" oder "Server returned 403 Forbidden" if strings.Contains(s, "403 forbidden") || strings.Contains(s, "http error 403") || strings.Contains(s, "server returned 403") { return "private", http.StatusForbidden } // "HTTP error 404 Not Found" oder "Server returned 404 Not Found" if strings.Contains(s, "404 not found") || strings.Contains(s, "http error 404") || strings.Contains(s, "server returned 404") { return "offline", http.StatusNotFound } return "", 0 } func servePreviewStatusSVG(w http.ResponseWriter, label string) { w.Header().Set("Content-Type", "image/svg+xml; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.Header().Set("X-Content-Type-Options", "nosniff") txt := html.EscapeString(label) svg := ` ` + txt + ` Preview nicht verfügbar ` w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(svg)) } func startPreviewHLS(ctx context.Context, job *RecordJob, m3u8URL, previewDir, httpCookie, userAgent string) error { if strings.TrimSpace(ffmpegPath) == "" { return fmt.Errorf("kein ffmpeg gefunden – setze FFMPEG_PATH oder lege ffmpeg(.exe) neben das Backend") } if err := os.MkdirAll(previewDir, 0755); err != nil { return err } // ✅ PreviewState reset (neuer Start) jobsMu.Lock() job.PreviewState = "" job.PreviewStateAt = "" job.PreviewStateMsg = "" jobsMu.Unlock() commonIn := []string{"-y"} if strings.TrimSpace(userAgent) != "" { commonIn = append(commonIn, "-user_agent", userAgent) } if strings.TrimSpace(httpCookie) != "" { commonIn = append(commonIn, "-headers", fmt.Sprintf("Cookie: %s\r\n", httpCookie)) } commonIn = append(commonIn, "-i", m3u8URL) baseURL := fmt.Sprintf("/api/record/preview?id=%s&file=", url.QueryEscape(job.ID)) hqArgs := append(commonIn, "-vf", "scale=480:-2", "-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency", "-pix_fmt", "yuv420p", "-profile:v", "main", "-level", "3.1", "-threads", "1", "-g", "48", "-keyint_min", "48", "-sc_threshold", "0", "-map", "0:v:0", "-map", "0:a?", "-c:a", "aac", "-b:a", "128k", "-ac", "2", "-f", "hls", "-hls_time", "2", "-hls_list_size", "10", "-hls_delete_threshold", "20", "-hls_allow_cache", "0", "-hls_flags", "delete_segments+append_list+independent_segments", "-hls_segment_filename", filepath.Join(previewDir, "seg_hq_%05d.ts"), "-hls_base_url", baseURL, filepath.Join(previewDir, "index_hq.m3u8"), ) cmd := exec.CommandContext(ctx, ffmpegPath, hqArgs...) var stderr bytes.Buffer cmd.Stderr = &stderr jobsMu.Lock() job.previewCmd = cmd jobsMu.Unlock() go func() { if err := previewSem.Acquire(ctx); err != nil { jobsMu.Lock() if job.previewCmd == cmd { job.previewCmd = nil } jobsMu.Unlock() return } defer previewSem.Release() if err := cmd.Run(); err != nil && ctx.Err() == nil { st := strings.TrimSpace(stderr.String()) // ✅ 403/404 erkennen -> Private/Offline setzen state, code := classifyPreviewFFmpegStderr(st) jobsMu.Lock() if state != "" { job.PreviewState = state job.PreviewStateAt = time.Now().UTC().Format(time.RFC3339Nano) job.PreviewStateMsg = fmt.Sprintf("ffmpeg input returned HTTP %d", code) } else { job.PreviewState = "error" job.PreviewStateAt = time.Now().UTC().Format(time.RFC3339Nano) if len(st) > 280 { job.PreviewStateMsg = st[:280] + "…" } else { job.PreviewStateMsg = st } } jobsMu.Unlock() fmt.Printf("⚠️ preview hq ffmpeg failed: %v (%s)\n", err, st) } jobsMu.Lock() if job.previewCmd == cmd { job.previewCmd = nil } jobsMu.Unlock() }() // ✅ Live thumb writer starten (schreibt generated//thumbs.jpg regelmäßig neu) startLiveThumbLoop(ctx, job) return nil } func extractFirstFrameJPEG(path string) ([]byte, error) { cmd := exec.Command( ffmpegPath, "-hide_banner", "-loglevel", "error", "-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 first-frame: %w (%s)", err, strings.TrimSpace(stderr.String())) } return out.Bytes(), nil } func resolvePathRelativeToApp(p string) (string, error) { p = strings.TrimSpace(p) if p == "" { return "", nil } p = filepath.Clean(filepath.FromSlash(p)) if filepath.IsAbs(p) { return p, nil } exe, err := os.Executable() if err == nil { exeDir := filepath.Dir(exe) low := strings.ToLower(exeDir) // Heuristik: go run / tests -> exe liegt in Temp/go-build isTemp := strings.Contains(low, `\appdata\local\temp`) || strings.Contains(low, `\temp\`) || strings.Contains(low, `\tmp\`) || strings.Contains(low, `\go-build`) || strings.Contains(low, `/tmp/`) || strings.Contains(low, `/go-build`) if !isTemp { return filepath.Join(exeDir, p), nil } } // Fallback: Working Directory (Dev) wd, err := os.Getwd() if err != nil { return "", err } return filepath.Join(wd, p), nil } // Frontend (Vite build) als SPA ausliefern: Dateien aus dist, sonst index.html func registerFrontend(mux *http.ServeMux) { // Kandidaten: zuerst ENV, dann typische Ordner candidates := []string{ strings.TrimSpace(os.Getenv("FRONTEND_DIST")), "web/dist", "dist", } var distAbs string for _, c := range candidates { if c == "" { continue } abs, err := resolvePathRelativeToApp(c) if err != nil { continue } if fi, err := os.Stat(filepath.Join(abs, "index.html")); err == nil && !fi.IsDir() { distAbs = abs break } } if distAbs == "" { fmt.Println("⚠️ Frontend dist nicht gefunden (tried: FRONTEND_DIST, frontend/dist, dist) – API läuft trotzdem.") return } fmt.Println("🖼️ Frontend dist:", distAbs) fileServer := http.FileServer(http.Dir(distAbs)) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { // /api bleibt bei deinen API-Routen (längeres Pattern gewinnt), // aber falls mal was durchrutscht: if strings.HasPrefix(r.URL.Path, "/api/") { http.NotFound(w, r) return } // 1) Wenn echte Datei existiert -> ausliefern reqPath := r.URL.Path if reqPath == "" || reqPath == "/" { // index.html w.Header().Set("Cache-Control", "no-store") http.ServeFile(w, r, filepath.Join(distAbs, "index.html")) return } // URL-Pfad in Dateisystem-Pfad umwandeln (ohne Traversal) clean := path.Clean("/" + reqPath) // path.Clean (für URL-Slashes) rel := strings.TrimPrefix(clean, "/") onDisk := filepath.Join(distAbs, filepath.FromSlash(rel)) if fi, err := os.Stat(onDisk); err == nil && !fi.IsDir() { // Statische Assets ruhig cachen (Vite hashed assets) ext := strings.ToLower(filepath.Ext(onDisk)) if ext != "" && ext != ".html" { w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") } else { w.Header().Set("Cache-Control", "no-store") } fileServer.ServeHTTP(w, r) return } // 2) SPA-Fallback: alle "Routen" ohne Datei -> index.html w.Header().Set("Cache-Control", "no-store") http.ServeFile(w, r, filepath.Join(distAbs, "index.html")) }) } // routes.go (package main) func registerRoutes(mux *http.ServeMux) *ModelStore { mux.HandleFunc("/api/cookies", cookiesHandler) mux.HandleFunc("/api/perf/stream", perfStreamHandler) mux.HandleFunc("/api/autostart/state", autostartStateHandler) mux.HandleFunc("/api/autostart/state/stream", autostartStateStreamHandler) mux.HandleFunc("/api/autostart/pause", autostartPauseQuickHandler) mux.HandleFunc("/api/autostart/resume", autostartResumeHandler) mux.HandleFunc("/api/settings", recordSettingsHandler) mux.HandleFunc("/api/settings/browse", settingsBrowse) mux.HandleFunc("/api/record", startRecordingFromRequest) mux.HandleFunc("/api/record/status", recordStatus) mux.HandleFunc("/api/record/stop", recordStop) mux.HandleFunc("/api/record/preview", recordPreview) mux.HandleFunc("/api/record/list", recordList) mux.HandleFunc("/api/record/stream", recordStream) mux.HandleFunc("/api/record/video", recordVideo) mux.HandleFunc("/api/record/done", recordDoneList) mux.HandleFunc("/api/record/done/meta", recordDoneMeta) mux.HandleFunc("/api/record/delete", recordDeleteVideo) mux.HandleFunc("/api/record/toggle-hot", recordToggleHot) mux.HandleFunc("/api/record/keep", recordKeepVideo) mux.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler) mux.HandleFunc("/api/chaturbate/biocontext", chaturbateBioContextHandler) mux.HandleFunc("/api/generated/teaser", generatedTeaser) // Tasks mux.HandleFunc("/api/tasks/generate-assets", tasksGenerateAssets) modelsPath, _ := resolvePathRelativeToApp("data/models_store.db") fmt.Println("📦 Models DB:", modelsPath) store := NewModelStore(modelsPath) if err := store.Load(); err != nil { fmt.Println("⚠️ models load:", err) } // ✅ registriert /api/models/list, /parse, /upsert, /flags, /delete RegisterModelAPI(mux, store) // ✅ ModelStore auch für Chaturbate-Online/biocontext nutzen (Tags/Bio persistieren) setChaturbateOnlineModelStore(store) // ✅ Frontend (SPA) ausliefern registerFrontend(mux) return store } // --- main --- func main() { loadSettings() mux := http.NewServeMux() store := registerRoutes(mux) go startChaturbateOnlinePoller(store) // ✅ hält Online-Liste aktuell go startChaturbateAutoStartWorker(store) // ✅ startet watched+public automatisch go startMyFreeCamsAutoStartWorker(store) go startDiskSpaceGuard() // ✅ reagiert auch ohne Frontend fmt.Println("🌐 HTTP-API aktiv: http://localhost:9999") if err := http.ListenAndServe(":9999", mux); err != nil { fmt.Println("❌ HTTP-Server Fehler:", err) os.Exit(1) } } type RecordRequest struct { URL string `json:"url"` Cookie string `json:"cookie,omitempty"` UserAgent string `json:"userAgent,omitempty"` Hidden bool `json:"hidden,omitempty"` } type videoMeta struct { SizeBytes int64 `json:"sizeBytes"` ModTimeUnix int64 `json:"modTimeUnix"` DurationSeconds float64 `json:"durationSeconds"` } func readVideoMeta(metaPath string) (videoMeta, bool) { b, err := os.ReadFile(metaPath) if err != nil || len(b) == 0 { return videoMeta{}, false } var m videoMeta if json.Unmarshal(b, &m) != nil { return videoMeta{}, false } if m.SizeBytes <= 0 || m.ModTimeUnix <= 0 || m.DurationSeconds <= 0 { return videoMeta{}, false } return m, true } func durationFromMetaIfFresh(videoPath, assetDir string, fi os.FileInfo) (float64, bool) { metaPath := filepath.Join(assetDir, "meta.json") m, ok := readVideoMeta(metaPath) if !ok { return 0, false } if m.SizeBytes != fi.Size() { return 0, false } if m.ModTimeUnix != fi.ModTime().Unix() { return 0, false } return m.DurationSeconds, true } // shared: wird vom HTTP-Handler UND vom Autostart-Worker genutzt func startRecordingInternal(req RecordRequest) (*RecordJob, error) { url := strings.TrimSpace(req.URL) if url == "" { return nil, errors.New("url fehlt") } // Duplicate-running guard (identische URL) jobsMu.Lock() for _, j := range jobs { if j != nil && j.Status == JobRunning && strings.TrimSpace(j.SourceURL) == url { // ✅ Wenn ein versteckter Auto-Check-Job läuft und der User manuell startet -> sofort sichtbar machen if j.Hidden && !req.Hidden { j.Hidden = false jobsMu.Unlock() notifyJobsChanged() return j, nil } jobsMu.Unlock() return j, nil } } // ✅ Timestamp + Output schon hier setzen, damit UI sofort Model/Filename/Details hat startedAt := time.Now() provider := detectProvider(url) // best-effort Username aus URL username := "" switch provider { case "chaturbate": username = extractUsername(url) case "mfc": username = extractMFCUsername(url) } if strings.TrimSpace(username) == "" { username = "unknown" } // Dateiname (konsistent zu runJob: gleicher Timestamp) filename := fmt.Sprintf("%s_%s.ts", username, startedAt.Format("01_02_2006__15-04-05")) // best-effort: absoluter RecordDir (fallback auf Settings-Wert) s := getSettings() recordDirAbs, _ := resolvePathRelativeToApp(s.RecordDir) recordDir := strings.TrimSpace(recordDirAbs) if recordDir == "" { recordDir = strings.TrimSpace(s.RecordDir) } outPath := filepath.Join(recordDir, filename) jobID := uuid.NewString() ctx, cancel := context.WithCancel(context.Background()) job := &RecordJob{ ID: jobID, SourceURL: url, Status: JobRunning, StartedAt: startedAt, Output: outPath, // ✅ sofort befüllt Hidden: req.Hidden, // ✅ NEU cancel: cancel, } jobs[jobID] = job jobsMu.Unlock() // ✅ NEU: Hidden-Jobs nicht sofort ins UI broadcasten if !job.Hidden { notifyJobsChanged() } go runJob(ctx, job, req) return job, nil } func startRecordingFromRequest(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed) return } var req RecordRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } job, err := startRecordingInternal(req) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(job) } func parseCookieString(cookieStr string) map[string]string { out := map[string]string{} for _, pair := range strings.Split(cookieStr, ";") { parts := strings.SplitN(strings.TrimSpace(pair), "=", 2) if len(parts) != 2 { continue } name := strings.TrimSpace(parts[0]) value := strings.TrimSpace(parts[1]) if name == "" { continue } out[strings.ToLower(name)] = value } return out } func hasChaturbateCookies(cookieStr string) bool { m := parseCookieString(cookieStr) _, hasCF := m["cf_clearance"] // akzeptiere session_id ODER sessionid ODER sessionid/sessionId Varianten (case-insensitive durch ToLower) _, hasSessID := m["session_id"] _, hasSessIdAlt := m["sessionid"] // falls es ohne underscore kommt return hasCF && (hasSessID || hasSessIdAlt) } func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { hc := NewHTTPClient(req.UserAgent) provider := detectProvider(req.URL) var err error // ✅ nutze den Timestamp vom Job (damit Start/Output konsistent sind) now := job.StartedAt if now.IsZero() { now = time.Now() } // ---- Aufnahme starten (Output-Pfad sauber relativ zur EXE auflösen) ---- switch provider { case "chaturbate": if !hasChaturbateCookies(req.Cookie) { err = errors.New("cf_clearance und session_id (oder sessionid) Cookies sind für Chaturbate erforderlich") break } s := getSettings() recordDirAbs, rerr := resolvePathRelativeToApp(s.RecordDir) if rerr != nil || strings.TrimSpace(recordDirAbs) == "" { err = fmt.Errorf("recordDir auflösung fehlgeschlagen: %v", rerr) break } _ = os.MkdirAll(recordDirAbs, 0o755) username := extractUsername(req.URL) filename := fmt.Sprintf("%s_%s.ts", username, now.Format("01_02_2006__15-04-05")) // ✅ wenn Output schon beim Start gesetzt wurde, nutze ihn (falls absolut) jobsMu.Lock() existingOut := strings.TrimSpace(job.Output) jobsMu.Unlock() outPath := existingOut if outPath == "" || !filepath.IsAbs(outPath) { outPath = filepath.Join(recordDirAbs, filename) } // Output nur aktualisieren, wenn es sich ändert if strings.TrimSpace(existingOut) != strings.TrimSpace(outPath) { jobsMu.Lock() job.Output = outPath jobsMu.Unlock() notifyJobsChanged() } err = RecordStream(ctx, hc, "https://chaturbate.com/", username, outPath, req.Cookie, job) case "mfc": s := getSettings() recordDirAbs, rerr := resolvePathRelativeToApp(s.RecordDir) if rerr != nil || strings.TrimSpace(recordDirAbs) == "" { err = fmt.Errorf("recordDir auflösung fehlgeschlagen: %v", rerr) break } _ = os.MkdirAll(recordDirAbs, 0o755) username := extractMFCUsername(req.URL) filename := fmt.Sprintf("%s_%s.ts", username, now.Format("01_02_2006__15-04-05")) outPath := filepath.Join(recordDirAbs, filename) jobsMu.Lock() job.Output = outPath jobsMu.Unlock() notifyJobsChanged() err = RecordStreamMFC(ctx, hc, username, outPath, job) default: err = errors.New("unsupported provider") } // ---- Finalisieren (EndedAt/Error setzen, dann remux/move OHNE global-lock) ---- end := time.Now() // Zielstatus bestimmen (Status erst am Ende setzen, damit Progress sichtbar bleibt) target := JobFinished var errText string if err != nil { if errors.Is(err, context.Canceled) { target = JobStopped } else { target = JobFailed errText = err.Error() } } // EndedAt + Error speichern (kurz locken) jobsMu.Lock() job.EndedAt = &end if errText != "" { job.Error = errText } // Output lokal kopieren, damit wir ohne lock weiterarbeiten können out := strings.TrimSpace(job.Output) jobsMu.Unlock() notifyJobsChanged() // Falls Output fehlt (z.B. provider error), direkt final status setzen if out == "" { jobsMu.Lock() job.Status = target job.Phase = "" job.Progress = 100 jobsMu.Unlock() notifyJobsChanged() return } // 1) Remux (nur wenn TS) if strings.EqualFold(filepath.Ext(out), ".ts") { setJobPhase(job, "remuxing", 10) if newOut, err2 := maybeRemuxTSForJob(job, out); err2 == nil && strings.TrimSpace(newOut) != "" { out = strings.TrimSpace(newOut) jobsMu.Lock() job.Output = out jobsMu.Unlock() notifyJobsChanged() } } // 2) Move to done (best-effort) setJobPhase(job, "moving", 70) if moved, err2 := moveToDoneDir(out); err2 == nil && strings.TrimSpace(moved) != "" { out = strings.TrimSpace(moved) jobsMu.Lock() job.Output = out jobsMu.Unlock() notifyJobsChanged() } setJobPhase(job, "moving", 80) // ✅ Optional: kleine Downloads automatisch löschen (nach move, vor ffprobe/assets) if target == JobFinished || target == JobStopped { if fi, serr := os.Stat(out); serr == nil && fi != nil && !fi.IsDir() { // SizeBytes am Job speichern (praktisch fürs UI) jobsMu.Lock() job.SizeBytes = fi.Size() jobsMu.Unlock() notifyJobsChanged() s := getSettings() minMB := s.AutoDeleteSmallDownloadsBelowMB if s.AutoDeleteSmallDownloads && minMB > 0 { threshold := int64(minMB) * 1024 * 1024 if fi.Size() > 0 && fi.Size() < threshold { base := filepath.Base(out) id := stripHotPrefix(strings.TrimSuffix(base, filepath.Ext(base))) // Datei löschen (mit Windows file-lock retry) if derr := removeWithRetry(out); derr == nil || os.IsNotExist(derr) { // generated/ + legacy cleanup (best-effort) removeGeneratedForID(id) if doneAbs, rerr := resolvePathRelativeToApp(getSettings().DoneDir); rerr == nil && strings.TrimSpace(doneAbs) != "" { _ = os.RemoveAll(filepath.Join(doneAbs, "preview", id)) _ = os.RemoveAll(filepath.Join(doneAbs, "thumbs", id)) } purgeDurationCacheForPath(out) // Job entfernen, damit er nicht im Finished landet jobsMu.Lock() delete(jobs, job.ID) jobsMu.Unlock() notifyJobsChanged() fmt.Println("🧹 auto-deleted:", base, "size:", formatBytesSI(fi.Size())) return } else { fmt.Println("⚠️ auto-delete failed:", derr) } } } } } // ✅ NEU: Dauer einmalig zuverlässig bestimmen (ffprobe) und am Job speichern if target == JobFinished || target == JobStopped { dctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) if sec, derr := durationSecondsCached(dctx, out); derr == nil && sec > 0 { jobsMu.Lock() job.DurationSeconds = sec jobsMu.Unlock() notifyJobsChanged() } cancel() } // 3) Assets (thumbs.jpg + preview.mp4) mit Live-Progress // Nur wenn Finished oder Stopped (bei Failed skip) if target == JobFinished || target == JobStopped { const ( assetsStart = 86 assetsEnd = 99 ) setJobPhase(job, "assets", assetsStart) // Throttle damit UI nicht bei jedem ffmpeg-tick spammt lastPct := -1 lastTick := time.Time{} update := func(r float64) { if r < 0 { r = 0 } if r > 1 { r = 1 } pct := assetsStart + int(math.Round(r*float64(assetsEnd-assetsStart))) if pct < assetsStart { pct = assetsStart } if pct > assetsEnd { pct = assetsEnd } if pct == lastPct { return } if !lastTick.IsZero() && time.Since(lastTick) < 150*time.Millisecond { return } lastPct = pct lastTick = time.Now() setJobPhase(job, "assets", pct) } // best-effort: Job NICHT failen, nur loggen if err := ensureAssetsForVideoWithProgress(out, update); err != nil { fmt.Println("⚠️ ensureAssetsForVideo:", err) } setJobPhase(job, "assets", assetsEnd) } // 4) Finalize (erst jetzt endgültigen Status setzen) jobsMu.Lock() job.Status = target job.Phase = "" job.Progress = 100 jobsMu.Unlock() notifyJobsChanged() } func formatBytesSI(b int64) string { if b < 0 { b = 0 } const unit = 1024 if b < unit { return fmt.Sprintf("%d B", b) } div, exp := int64(unit), 0 for n := b / unit; n >= unit; n /= unit { div *= unit exp++ } suffix := []string{"KB", "MB", "GB", "TB", "PB"} v := float64(b) / float64(div) // 1 Nachkommastelle, außer sehr große ganze Zahlen if v >= 10 { return fmt.Sprintf("%.0f %s", v, suffix[exp]) } return fmt.Sprintf("%.1f %s", v, suffix[exp]) } func ensureAssetsForVideo(videoPath string) error { return ensureAssetsForVideoWithProgress(videoPath, nil) } // onRatio: 0..1 (Assets-Gesamtfortschritt) func ensureAssetsForVideoWithProgress(videoPath string, onRatio func(r float64)) error { videoPath = strings.TrimSpace(videoPath) if videoPath == "" { return nil } fi, statErr := os.Stat(videoPath) if statErr != nil || fi.IsDir() || fi.Size() <= 0 { return nil } // ✅ ID = Dateiname ohne Endung (immer OHNE "HOT " Prefix) base := filepath.Base(videoPath) id := strings.TrimSuffix(base, filepath.Ext(base)) id = stripHotPrefix(id) if strings.TrimSpace(id) == "" { return nil } // ✅ /generated//thumbs.jpg + /generated//preview.mp4 assetDir, gerr := ensureGeneratedDir(id) if gerr != nil || strings.TrimSpace(assetDir) == "" { return fmt.Errorf("generated dir: %v", gerr) } metaPath := filepath.Join(assetDir, "meta.json") // Dauer bevorzugt aus meta.json (schnell & stabil) durSec := 0.0 if d, ok := readVideoMetaDuration(metaPath, fi); ok { durSec = d } else { // Fallback: 1× ffprobe über durationSecondsCached (und dann persistieren) dctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) if d, derr := durationSecondsCached(dctx, videoPath); derr == nil && d > 0 { durSec = d _ = writeVideoMeta(metaPath, fi, durSec) } cancel() } // Gewichte: thumbs klein, preview groß (preview dauert) const ( thumbsW = 0.25 previewW = 0.75 ) progress := func(r float64) { if onRatio == nil { return } if r < 0 { r = 0 } if r > 1 { r = 1 } onRatio(r) } progress(0) // ---------------- // Thumbs erzeugen // ---------------- thumbPath := filepath.Join(assetDir, "thumbs.jpg") if tfi, err := os.Stat(thumbPath); err == nil && !tfi.IsDir() && tfi.Size() > 0 { progress(thumbsW) } else { progress(0.05) genCtx, cancel := context.WithTimeout(context.Background(), 45*time.Second) defer cancel() if err := thumbSem.Acquire(genCtx); err != nil { // best-effort progress(thumbsW) goto PREVIEW } defer thumbSem.Release() progress(0.10) t := 0.0 if durSec > 0 { t = durSec * 0.5 } progress(0.15) img, e1 := extractFrameAtTimeJPEG(videoPath, t) if e1 != nil || len(img) == 0 { img, e1 = extractLastFrameJPEG(videoPath) if e1 != nil || len(img) == 0 { img, e1 = extractFirstFrameJPEG(videoPath) } } progress(0.20) if e1 == nil && len(img) > 0 { if err := atomicWriteFile(thumbPath, img); err != nil { fmt.Println("⚠️ thumb write:", err) } } progress(thumbsW) } PREVIEW: // ---------------- // Preview erzeugen // ---------------- previewPath := filepath.Join(assetDir, "preview.mp4") if pfi, err := os.Stat(previewPath); err == nil && !pfi.IsDir() && pfi.Size() > 0 { progress(1) return nil } // Preview ist der teure Part -> hier Live-Progress durchreichen genCtx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) defer cancel() progress(thumbsW + 0.02) if err := genSem.Acquire(genCtx); err != nil { // best-effort progress(1) return nil } defer genSem.Release() progress(thumbsW + 0.05) if err := generateTeaserClipsMP4WithProgress(genCtx, videoPath, previewPath, 1.0, 18, func(r float64) { // r 0..1 nur für preview -> mappe in Gesamtfortschritt if r < 0 { r = 0 } if r > 1 { r = 1 } progress(thumbsW + r*previewW) }); err != nil { fmt.Println("⚠️ preview clips:", err) } progress(1) return nil } func recordVideo(w http.ResponseWriter, r *http.Request) { // ✅ Wiedergabe über Dateiname (für doneDir / recordDir) if raw := strings.TrimSpace(r.URL.Query().Get("file")); raw != "" { // explizit decoden (zur Sicherheit) file, err := url.QueryUnescape(raw) if err != nil { http.Error(w, "ungültiger file", http.StatusBadRequest) return } file = strings.TrimSpace(file) // kein Pfad, keine Backslashes, kein Traversal if file == "" || strings.Contains(file, "/") || strings.Contains(file, "\\") || filepath.Base(file) != file { http.Error(w, "ungültiger file", http.StatusBadRequest) return } ext := strings.ToLower(filepath.Ext(file)) if ext != ".mp4" && ext != ".ts" { http.Error(w, "nicht erlaubt", http.StatusForbidden) return } s := getSettings() recordAbs, err := resolvePathRelativeToApp(s.RecordDir) if err != nil { http.Error(w, "recordDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil { http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } // Kandidaten: erst done (inkl. 1 Level Subdir, aber ohne "keep"), // dann keep (inkl. 1 Level Subdir), dann recordDir names := []string{file} // Falls UI noch ".ts" kennt, die Datei aber schon als ".mp4" existiert: if ext == ".ts" { mp4File := strings.TrimSuffix(file, ext) + ".mp4" names = append(names, mp4File) } var outPath string for _, name := range names { // done root + done// (skip "keep") if p, _, ok := findFileInDirOrOneLevelSubdirs(doneAbs, name, "keep"); ok { outPath = p break } // keep root + keep// if p, _, ok := findFileInDirOrOneLevelSubdirs(filepath.Join(doneAbs, "keep"), name, ""); ok { outPath = p break } // record root (+ optional 1 Level Subdir) if p, _, ok := findFileInDirOrOneLevelSubdirs(recordAbs, name, ""); ok { outPath = p break } } if outPath == "" { http.Error(w, "datei nicht gefunden", http.StatusNotFound) return } // TS kann der Browser nicht zuverlässig direkt -> on-demand remux nach MP4 if strings.ToLower(filepath.Ext(outPath)) == ".ts" { newOut, err := maybeRemuxTS(outPath) if err != nil { http.Error(w, "TS kann im Browser nicht abgespielt werden; Remux fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } if strings.TrimSpace(newOut) == "" { http.Error(w, "TS kann im Browser nicht abgespielt werden; Remux hat keine MP4 erzeugt", http.StatusInternalServerError) return } outPath = newOut // sicherstellen, dass wirklich eine MP4 existiert fi, err := os.Stat(outPath) if err != nil || fi.IsDir() || fi.Size() == 0 || strings.ToLower(filepath.Ext(outPath)) != ".mp4" { http.Error(w, "Remux-Ergebnis ungültig", http.StatusInternalServerError) return } } w.Header().Set("Cache-Control", "no-store") serveVideoFile(w, r, outPath) return } // ✅ ALT: Wiedergabe über Job-ID (funktioniert nur solange Job im RAM existiert) id := strings.TrimSpace(r.URL.Query().Get("id")) if id == "" { http.Error(w, "id fehlt", http.StatusBadRequest) return } jobsMu.Lock() job, ok := jobs[id] jobsMu.Unlock() if !ok { http.Error(w, "job nicht gefunden", http.StatusNotFound) return } outPath := filepath.Clean(strings.TrimSpace(job.Output)) if outPath == "" { http.Error(w, "output fehlt", http.StatusNotFound) return } if !filepath.IsAbs(outPath) { abs, err := resolvePathRelativeToApp(outPath) if err != nil { http.Error(w, "pfad auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } outPath = abs } fi, err := os.Stat(outPath) if err != nil || fi.IsDir() || fi.Size() == 0 { http.Error(w, "datei nicht gefunden", http.StatusNotFound) return } // TS kann der Browser nicht zuverlässig direkt -> on-demand remux nach MP4 if strings.ToLower(filepath.Ext(outPath)) == ".ts" { newOut, err := maybeRemuxTS(outPath) if err != nil { http.Error(w, "TS Remux fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } if strings.TrimSpace(newOut) == "" { http.Error(w, "TS kann im Browser nicht abgespielt werden; Remux hat keine MP4 erzeugt", http.StatusInternalServerError) return } outPath = newOut fi, err := os.Stat(outPath) if err != nil || fi.IsDir() || fi.Size() == 0 || strings.ToLower(filepath.Ext(outPath)) != ".mp4" { http.Error(w, "Remux-Ergebnis ungültig", http.StatusInternalServerError) return } } serveVideoFile(w, r, outPath) } func setNoStoreHeaders(w http.ResponseWriter) { // verhindert Browser/Proxy Caching (wichtig für Logs/Status) w.Header().Set("Cache-Control", "no-store, max-age=0") w.Header().Set("Pragma", "no-cache") w.Header().Set("Expires", "0") } func durationSecondsCacheOnly(path string, fi os.FileInfo) float64 { durCache.mu.Lock() e, ok := durCache.m[path] durCache.mu.Unlock() if ok && e.size == fi.Size() && e.mod.Equal(fi.ModTime()) && e.sec > 0 { return e.sec } return 0 } func findFileInDirOrOneLevelSubdirs(root string, file string, skipDirName string) (string, os.FileInfo, bool) { // direct p := filepath.Join(root, file) if fi, err := os.Stat(p); err == nil && !fi.IsDir() && fi.Size() > 0 { return p, fi, true } entries, err := os.ReadDir(root) if err != nil { return "", nil, false } for _, e := range entries { if !e.IsDir() { continue } if skipDirName != "" && e.Name() == skipDirName { continue } pp := filepath.Join(root, e.Name(), file) if fi, err := os.Stat(pp); err == nil && !fi.IsDir() && fi.Size() > 0 { return pp, fi, true } } return "", nil, false } func resolveDoneFileByName(doneAbs string, file string) (full string, from string, fi os.FileInfo, err error) { // 1) done (root + /done//) — "keep" wird übersprungen if p, fi, ok := findFileInDirOrOneLevelSubdirs(doneAbs, file, "keep"); ok { return p, "done", fi, nil } // 2) keep (root + /done/keep//) keepDir := filepath.Join(doneAbs, "keep") if p, fi, ok := findFileInDirOrOneLevelSubdirs(keepDir, file, ""); ok { return p, "keep", fi, nil } return "", "", nil, fmt.Errorf("not found") } func recordDoneList(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed) return } // ✅ optional: auch /done/keep/ einbeziehen (Standard: false) qKeep := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("includeKeep"))) includeKeep := qKeep == "1" || qKeep == "true" || qKeep == "yes" // optional: Pagination (1-based). Wenn page/pageSize fehlen -> wie vorher: komplette Liste page := 0 pageSize := 0 if v := strings.TrimSpace(r.URL.Query().Get("page")); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 { page = n } } if v := strings.TrimSpace(r.URL.Query().Get("pageSize")); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 { pageSize = n } } // optional: Sort // supported: completed_(asc|desc), model_(asc|desc), file_(asc|desc), duration_(asc|desc), size_(asc|desc) sortMode := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("sort"))) if sortMode == "" { sortMode = "completed_desc" } // ✅ NEU: all=1 -> immer komplette Liste zurückgeben (Pagination deaktivieren) qAll := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("all"))) fetchAll := qAll == "1" || qAll == "true" || qAll == "yes" if fetchAll { page = 0 pageSize = 0 } s := getSettings() doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil { http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } // Wenn kein DoneDir gesetzt ist → einfach leere Liste zurückgeben if strings.TrimSpace(doneAbs) == "" { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode([]*RecordJob{}) return } type scanDir struct { dir string skipKeep bool // nur für doneAbs: "keep" nicht doppelt scannen } dirs := []scanDir{{dir: doneAbs, skipKeep: true}} if includeKeep { dirs = append(dirs, scanDir{dir: filepath.Join(doneAbs, "keep"), skipKeep: false}) } list := make([]*RecordJob, 0, 256) addFile := func(full string, fi os.FileInfo) { name := filepath.Base(full) ext := strings.ToLower(filepath.Ext(name)) if ext != ".mp4" && ext != ".ts" { return } base := strings.TrimSuffix(name, filepath.Ext(name)) t := fi.ModTime() // StartedAt aus Dateiname (Fallback: ModTime) start := t stem := base if strings.HasPrefix(stem, "HOT ") { stem = strings.TrimPrefix(stem, "HOT ") } if m := startedAtFromFilenameRe.FindStringSubmatch(stem); m != nil { mm, _ := strconv.Atoi(m[2]) dd, _ := strconv.Atoi(m[3]) yy, _ := strconv.Atoi(m[4]) hh, _ := strconv.Atoi(m[5]) mi, _ := strconv.Atoi(m[6]) ss, _ := strconv.Atoi(m[7]) start = time.Date(yy, time.Month(mm), dd, hh, mi, ss, 0, time.Local) } dur := 0.0 // 1) meta.json aus generated//meta.json lesen (schnell) id := stripHotPrefix(strings.TrimSuffix(filepath.Base(full), filepath.Ext(full))) if strings.TrimSpace(id) != "" { if mp, err := generatedMetaFile(id); err == nil { if d, ok := readVideoMetaDuration(mp, fi); ok { dur = d } } } // 2) Fallback: RAM-Cache only (immer noch schnell, kein ffprobe) if dur <= 0 { dur = durationSecondsCacheOnly(full, fi) } // 3) KEIN ffprobe hier! (sonst wird die API wieder langsam) list = append(list, &RecordJob{ ID: base, // ✅ KEIN keep/ prefix (würde Assets/Preview killen) Output: full, Status: JobFinished, StartedAt: start, EndedAt: &t, DurationSeconds: dur, SizeBytes: fi.Size(), }) } for _, sd := range dirs { entries, err := os.ReadDir(sd.dir) if err != nil { if os.IsNotExist(err) { if sd.dir == doneAbs { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode([]*RecordJob{}) return } continue } if sd.dir == doneAbs { http.Error(w, "doneDir lesen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } continue } for _, e := range entries { // Subdir: 1 Level rein (z.B. /done// oder /done/keep//) if e.IsDir() { if sd.skipKeep && e.Name() == "keep" { continue } sub := filepath.Join(sd.dir, e.Name()) subEntries, err := os.ReadDir(sub) if err != nil { continue } for _, se := range subEntries { if se.IsDir() { continue } full := filepath.Join(sub, se.Name()) fi, err := os.Stat(full) if err != nil || fi.IsDir() || fi.Size() == 0 { continue } addFile(full, fi) } continue } full := filepath.Join(sd.dir, e.Name()) fi, err := os.Stat(full) if err != nil || fi.IsDir() || fi.Size() == 0 { continue } addFile(full, fi) } } // helpers (Sort) fileForSort := func(j *RecordJob) string { f := strings.ToLower(filepath.Base(j.Output)) // HOT Prefix aus Sortierung rausnehmen f = strings.TrimPrefix(f, "hot ") return f } stemForSort := func(j *RecordJob) string { // ohne ext und ohne HOT Prefix f := fileForSort(j) return strings.TrimSuffix(f, filepath.Ext(f)) } modelForSort := func(j *RecordJob) string { stem := stemForSort(j) if m := startedAtFromFilenameRe.FindStringSubmatch(stem); m != nil { return strings.ToLower(strings.TrimSpace(m[1])) } // fallback: alles vor letztem "_" (oder kompletter stem) if i := strings.LastIndex(stem, "_"); i > 0 { return strings.ToLower(strings.TrimSpace(stem[:i])) } return strings.ToLower(strings.TrimSpace(stem)) } durationForSort := func(j *RecordJob) (sec float64, ok bool) { if j.DurationSeconds > 0 { return j.DurationSeconds, true } return 0, false } // Sortierung sort.Slice(list, func(i, j int) bool { a, b := list[i], list[j] ta, tb := time.Time{}, time.Time{} if a.EndedAt != nil { ta = *a.EndedAt } if b.EndedAt != nil { tb = *b.EndedAt } switch sortMode { case "completed_asc": if !ta.Equal(tb) { return ta.Before(tb) } return fileForSort(a) < fileForSort(b) case "completed_desc": if !ta.Equal(tb) { return ta.After(tb) } return fileForSort(a) < fileForSort(b) case "model_asc": ma, mb := modelForSort(a), modelForSort(b) if ma != mb { return ma < mb } if !ta.Equal(tb) { return ta.After(tb) } return fileForSort(a) < fileForSort(b) case "model_desc": ma, mb := modelForSort(a), modelForSort(b) if ma != mb { return ma > mb } if !ta.Equal(tb) { return ta.After(tb) } return fileForSort(a) < fileForSort(b) case "file_asc": fa, fb := fileForSort(a), fileForSort(b) if fa != fb { return fa < fb } if !ta.Equal(tb) { return ta.After(tb) } return fileForSort(a) < fileForSort(b) case "file_desc": fa, fb := fileForSort(a), fileForSort(b) if fa != fb { return fa > fb } if !ta.Equal(tb) { return ta.After(tb) } return fileForSort(a) < fileForSort(b) case "duration_asc": da, okA := durationForSort(a) db, okB := durationForSort(b) if okA != okB { return okA // unbekannt nach hinten } if okA && okB && da != db { return da < db } if !ta.Equal(tb) { return ta.After(tb) } return fileForSort(a) < fileForSort(b) case "duration_desc": da, okA := durationForSort(a) db, okB := durationForSort(b) if okA != okB { return okA } if okA && okB && da != db { return da > db } if !ta.Equal(tb) { return ta.After(tb) } return fileForSort(a) < fileForSort(b) case "size_asc": if a.SizeBytes != b.SizeBytes { return a.SizeBytes < b.SizeBytes } if !ta.Equal(tb) { return ta.After(tb) } return fileForSort(a) < fileForSort(b) case "size_desc": if a.SizeBytes != b.SizeBytes { return a.SizeBytes > b.SizeBytes } if !ta.Equal(tb) { return ta.After(tb) } return fileForSort(a) < fileForSort(b) default: if !ta.Equal(tb) { return ta.After(tb) } return fileForSort(a) < fileForSort(b) } }) // Pagination (nach Sort!) – nur wenn pageSize > 0 und NICHT all=1 if pageSize > 0 && !fetchAll { if page <= 0 { page = 1 } startIdx := (page - 1) * pageSize if startIdx >= len(list) { list = []*RecordJob{} } else { endIdx := startIdx + pageSize if endIdx > len(list) { endIdx = len(list) } list = list[startIdx:endIdx] } } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(list) } type doneMetaResp struct { Count int `json:"count"` } func recordDoneMeta(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.Header().Set("Allow", "GET") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } // ✅ optional: auch /done/keep/ einbeziehen (Standard: false) qKeep := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("includeKeep"))) includeKeep := qKeep == "1" || qKeep == "true" || qKeep == "yes" s := getSettings() doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil { http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } if strings.TrimSpace(doneAbs) == "" { writeJSON(w, http.StatusOK, doneMetaResp{Count: 0}) return } dirs := []string{doneAbs} if includeKeep { dirs = append(dirs, filepath.Join(doneAbs, "keep")) } cnt := 0 countIn := func(dir string, skipKeep bool) { entries, err := os.ReadDir(dir) if err != nil { return } for _, e := range entries { if e.IsDir() { if skipKeep && e.Name() == "keep" { continue } sub := filepath.Join(dir, e.Name()) subEntries, err := os.ReadDir(sub) if err != nil { continue } for _, se := range subEntries { if se.IsDir() { continue } ext := strings.ToLower(filepath.Ext(se.Name())) if ext == ".mp4" || ext == ".ts" { cnt++ } } continue } ext := strings.ToLower(filepath.Ext(e.Name())) if ext == ".mp4" || ext == ".ts" { cnt++ } } } countIn(doneAbs, true) if includeKeep { countIn(filepath.Join(doneAbs, "keep"), false) } writeJSON(w, http.StatusOK, doneMetaResp{Count: cnt}) } type durationReq struct { Files []string `json:"files"` } type durationItem struct { File string `json:"file"` DurationSeconds float64 `json:"durationSeconds,omitempty"` Error string `json:"error,omitempty"` } 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() } } func recordDeleteVideo(w http.ResponseWriter, r *http.Request) { // Frontend nutzt aktuell POST (siehe FinishedDownloads), daher erlauben wir POST + DELETE if r.Method != http.MethodPost && r.Method != http.MethodDelete { http.Error(w, "Nur POST oder DELETE erlaubt", http.StatusMethodNotAllowed) return } raw := strings.TrimSpace(r.URL.Query().Get("file")) if raw == "" { http.Error(w, "file fehlt", http.StatusBadRequest) return } // sicher decoden file, err := url.QueryUnescape(raw) if err != nil { http.Error(w, "ungültiger file", http.StatusBadRequest) return } file = strings.TrimSpace(file) // ✅ nur Basename erlauben (keine Unterordner, kein Traversal) if file == "" || strings.Contains(file, "/") || strings.Contains(file, "\\") || filepath.Base(file) != file { http.Error(w, "ungültiger file", http.StatusBadRequest) return } ext := strings.ToLower(filepath.Ext(file)) if ext != ".mp4" && ext != ".ts" { http.Error(w, "nicht erlaubt", http.StatusForbidden) return } s := getSettings() doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil { http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } if strings.TrimSpace(doneAbs) == "" { http.Error(w, "doneDir ist leer", http.StatusBadRequest) return } // ✅ done + done/ sowie keep + keep/ target, from, fi, err := resolveDoneFileByName(doneAbs, file) if err != nil { http.Error(w, "datei nicht gefunden", http.StatusNotFound) return } if fi != nil && fi.IsDir() { http.Error(w, "ist ein verzeichnis", http.StatusBadRequest) return } // löschen mit retry (Windows file-lock) if err := removeWithRetry(target); err != nil { if runtime.GOOS == "windows" { if isSharingViolation(err) { http.Error(w, "datei wird gerade verwendet (Player offen). Bitte kurz stoppen und erneut versuchen.", http.StatusConflict) return } } http.Error(w, "löschen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } base := strings.TrimSuffix(file, filepath.Ext(file)) canonical := stripHotPrefix(base) // Alles weg inkl. meta/frames/temp preview removeGeneratedForID(canonical) // Safety: falls irgendwo Assets “mit HOT im Ordnernamen” entstanden sind (sollte nicht, aber best-effort) if base != canonical { removeGeneratedForID(base) } purgeDurationCacheForPath(target) // Legacy-Cleanup (optional) thumbsLegacy, _ := generatedThumbsRoot() teaserLegacy, _ := generatedTeaserRoot() if strings.TrimSpace(thumbsLegacy) != "" { _ = os.RemoveAll(filepath.Join(thumbsLegacy, canonical)) _ = os.RemoveAll(filepath.Join(thumbsLegacy, base)) _ = os.Remove(filepath.Join(thumbsLegacy, canonical+".jpg")) _ = os.Remove(filepath.Join(thumbsLegacy, base+".jpg")) } if strings.TrimSpace(teaserLegacy) != "" { _ = os.Remove(filepath.Join(teaserLegacy, canonical+"_teaser.mp4")) _ = os.Remove(filepath.Join(teaserLegacy, base+"_teaser.mp4")) _ = os.Remove(filepath.Join(teaserLegacy, canonical+".mp4")) _ = os.Remove(filepath.Join(teaserLegacy, base+".mp4")) } removeJobsByOutputBasename(file) w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(map[string]any{ "ok": true, "file": file, "from": from, // "done" | "keep" }) } func serveVideoFile(w http.ResponseWriter, r *http.Request, path string) { f, err := openForReadShareDelete(path) if err != nil { http.Error(w, "datei öffnen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } defer f.Close() fi, err := f.Stat() if err != nil || fi.IsDir() || fi.Size() == 0 { http.Error(w, "datei nicht gefunden", http.StatusNotFound) return } w.Header().Set("Cache-Control", "no-store") w.Header().Set("Content-Type", "video/mp4") // ServeContent unterstützt Range Requests (wichtig für Video) http.ServeContent(w, r, filepath.Base(path), fi.ModTime(), f) } func recordKeepVideo(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed) return } raw := strings.TrimSpace(r.URL.Query().Get("file")) if raw == "" { http.Error(w, "file fehlt", http.StatusBadRequest) return } file, err := url.QueryUnescape(raw) if err != nil { http.Error(w, "ungültiger file", http.StatusBadRequest) return } file = strings.TrimSpace(file) // ✅ nur Basename erlauben if file == "" || strings.Contains(file, "/") || strings.Contains(file, "\\") || filepath.Base(file) != file { http.Error(w, "ungültiger file", http.StatusBadRequest) return } ext := strings.ToLower(filepath.Ext(file)) if ext != ".mp4" && ext != ".ts" { http.Error(w, "nicht erlaubt", http.StatusForbidden) return } s := getSettings() doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil { http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } if strings.TrimSpace(doneAbs) == "" { http.Error(w, "doneDir ist leer", http.StatusBadRequest) return } keepRoot := filepath.Join(doneAbs, "keep") // keep root sicherstellen if err := os.MkdirAll(keepRoot, 0o755); err != nil { http.Error(w, "keep dir erstellen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } // ✅ Wenn schon irgendwo in keep (root oder keep/) => idempotent OK if p, _, ok := findFileInDirOrOneLevelSubdirs(keepRoot, file, ""); ok { _ = p // nur für Klarheit w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(map[string]any{ "ok": true, "file": file, "alreadyKept": true, }) return } // ✅ Quelle in done (root oder done/), aber NICHT aus keep src, fi, ok := findFileInDirOrOneLevelSubdirs(doneAbs, file, "keep") if !ok { http.Error(w, "datei nicht gefunden", http.StatusNotFound) return } if fi == nil || fi.IsDir() { http.Error(w, "ist ein verzeichnis", http.StatusBadRequest) return } // ✅ Ziel: /done/keep//file (wenn model aus Dateiname ableitbar) dstDir := keepRoot modelKey := strings.TrimSpace(modelNameFromFilename(file)) modelKey = stripHotPrefix(modelKey) if modelKey != "" && modelKey != "—" && !strings.ContainsAny(modelKey, `/\`) { dstDir = filepath.Join(dstDir, modelKey) } if err := os.MkdirAll(dstDir, 0o755); err != nil { http.Error(w, "keep subdir erstellen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } dst := filepath.Join(dstDir, file) // falls schon vorhanden => Konflikt (sollte durch already-check oben selten passieren) if _, err := os.Stat(dst); err == nil { http.Error(w, "ziel existiert bereits", http.StatusConflict) return } else if !os.IsNotExist(err) { http.Error(w, "stat ziel fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } // rename mit retry (Windows file-lock) if err := renameWithRetry(src, dst); err != nil { if runtime.GOOS == "windows" && isSharingViolation(err) { http.Error(w, "keep fehlgeschlagen (Datei wird gerade verwendet).", http.StatusConflict) return } http.Error(w, "keep fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } // ✅ generated Assets löschen (best effort) base := strings.TrimSuffix(file, filepath.Ext(file)) canonical := stripHotPrefix(base) // Neu: /generated// if genAbs, _ := generatedRoot(); strings.TrimSpace(genAbs) != "" { if strings.TrimSpace(canonical) != "" { _ = os.RemoveAll(filepath.Join(genAbs, canonical)) } // falls irgendwo alte Assets mit HOT im Ordnernamen liegen if strings.TrimSpace(base) != "" && base != canonical { _ = os.RemoveAll(filepath.Join(genAbs, base)) } } // Legacy-Cleanup (optional) thumbsLegacy, _ := generatedThumbsRoot() teaserLegacy, _ := generatedTeaserRoot() if strings.TrimSpace(thumbsLegacy) != "" { _ = os.RemoveAll(filepath.Join(thumbsLegacy, canonical)) _ = os.RemoveAll(filepath.Join(thumbsLegacy, base)) _ = os.Remove(filepath.Join(thumbsLegacy, canonical+".jpg")) _ = os.Remove(filepath.Join(thumbsLegacy, base+".jpg")) } if strings.TrimSpace(teaserLegacy) != "" { _ = os.Remove(filepath.Join(teaserLegacy, canonical+"_teaser.mp4")) _ = os.Remove(filepath.Join(teaserLegacy, base+"_teaser.mp4")) _ = os.Remove(filepath.Join(teaserLegacy, canonical+".mp4")) _ = os.Remove(filepath.Join(teaserLegacy, base+".mp4")) } removeJobsByOutputBasename(file) w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(map[string]any{ "ok": true, "file": file, "alreadyKept": false, }) } func recordToggleHot(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Nur POST", http.StatusMethodNotAllowed) return } raw := strings.TrimSpace(r.URL.Query().Get("file")) if raw == "" { http.Error(w, "file fehlt", http.StatusBadRequest) return } file, err := url.QueryUnescape(raw) if err != nil { http.Error(w, "ungültiger file", http.StatusBadRequest) return } file = strings.TrimSpace(file) // ✅ nur Basename erlauben if file == "" || strings.Contains(file, "/") || strings.Contains(file, "\\") || filepath.Base(file) != file { http.Error(w, "ungültiger file", http.StatusBadRequest) return } ext := strings.ToLower(filepath.Ext(file)) if ext != ".mp4" && ext != ".ts" { http.Error(w, "nicht erlaubt", http.StatusForbidden) return } s := getSettings() doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil { http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } if strings.TrimSpace(doneAbs) == "" { http.Error(w, "doneDir ist leer", http.StatusBadRequest) return } // ✅ Quelle kann in done/, done/, keep/, keep/ liegen src, from, fi, err := resolveDoneFileByName(doneAbs, file) if err != nil { http.Error(w, "datei nicht gefunden", http.StatusNotFound) return } if fi != nil && fi.IsDir() { http.Error(w, "ist ein verzeichnis", http.StatusBadRequest) return } srcDir := filepath.Dir(src) // ✅ wichtig: toggeln im tatsächlichen Ordner // toggle: HOT Prefix newFile := file if strings.HasPrefix(file, "HOT ") { newFile = strings.TrimPrefix(file, "HOT ") } else { newFile = "HOT " + file } dst := filepath.Join(srcDir, newFile) // ✅ im selben Ordner toggeln (done oder keep) if _, err := os.Stat(dst); err == nil { http.Error(w, "ziel existiert bereits", http.StatusConflict) return } else if !os.IsNotExist(err) { http.Error(w, "stat ziel fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } if err := renameWithRetry(src, dst); err != nil { if runtime.GOOS == "windows" && isSharingViolation(err) { http.Error(w, "rename fehlgeschlagen (Datei wird gerade abgespielt). Bitte erneut versuchen.", http.StatusConflict) return } http.Error(w, "rename fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } // ✅ KEIN generated-rename! // Assets bleiben canonical (ohne HOT) canonicalID := stripHotPrefix(strings.TrimSuffix(file, filepath.Ext(file))) renameJobsOutputBasename(file, newFile) w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(map[string]any{ "ok": true, "oldFile": file, "newFile": newFile, "canonicalID": canonicalID, "from": from, // "done" | "keep" }) } func maybeRemuxTS(path string) (string, error) { path = strings.TrimSpace(path) if path == "" { return "", nil } if !strings.EqualFold(filepath.Ext(path), ".ts") { return "", nil } mp4 := strings.TrimSuffix(path, filepath.Ext(path)) + ".mp4" // remux (ohne neu encoden) if err := remuxTSToMP4(path, mp4); err != nil { return "", err } _ = os.Remove(path) // TS entfernen, wenn MP4 ok return mp4, nil } func maybeRemuxTSForJob(job *RecordJob, path string) (string, error) { path = strings.TrimSpace(path) if path == "" { return "", nil } if !strings.EqualFold(filepath.Ext(path), ".ts") { return "", nil } mp4 := strings.TrimSuffix(path, filepath.Ext(path)) + ".mp4" // input size für fallback var inSize int64 if fi, err := os.Stat(path); err == nil && !fi.IsDir() { inSize = fi.Size() } // duration (für sauberen progress) var durSec float64 { durCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) durSec, _ = durationSecondsCached(durCtx, path) cancel() } const base = 10 const span = 60 // 10..69 (70 startet "moving") lastProgress := base lastTick := time.Now().Add(-time.Second) onRatio := func(r float64) { if r < 0 { r = 0 } if r > 1 { r = 1 } p := base + int(r*float64(span)) if p >= 70 { p = 69 } if p <= lastProgress { return } // leicht throttlen if time.Since(lastTick) < 150*time.Millisecond && p < 79 { return } lastProgress = p lastTick = time.Now() setJobPhase(job, "remuxing", p) } remuxCtx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) defer cancel() if err := remuxTSToMP4WithProgress(remuxCtx, path, mp4, durSec, inSize, onRatio); err != nil { return "", err } _ = os.Remove(path) // TS entfernen, wenn MP4 ok setJobPhase(job, "remuxing", 69) // ✅ Remux finished (nie rückwärts) return mp4, nil } func moveFile(src, dst string) error { // zuerst Rename (schnell) if err := os.Rename(src, dst); err == nil { return nil } else { // Fallback: Copy+Remove (z.B. bei EXDEV) in, err2 := os.Open(src) if err2 != nil { return err } defer in.Close() out, err2 := os.Create(dst) if err2 != nil { return err } if _, err2 := io.Copy(out, in); err2 != nil { out.Close() return err2 } if err2 := out.Close(); err2 != nil { return err2 } return os.Remove(src) } } const windowsSharingViolation syscall.Errno = 32 // ERROR_SHARING_VIOLATION func isSharingViolation(err error) bool { if runtime.GOOS != "windows" { return false } // Windows: ERROR_SHARING_VIOLATION = 32, ERROR_LOCK_VIOLATION = 33 var pe *os.PathError if errors.As(err, &pe) { if errno, ok := pe.Err.(syscall.Errno); ok { return errno == syscall.Errno(32) || errno == syscall.Errno(33) } } // Fallback über Text s := strings.ToLower(err.Error()) return strings.Contains(s, "sharing violation") || strings.Contains(s, "used by another process") || strings.Contains(s, "wird von einem anderen prozess verwendet") } func removeWithRetry(path string) error { var err error for i := 0; i < 40; i++ { // ~4s bei 100ms err = os.Remove(path) if err == nil { return nil } if isSharingViolation(err) { time.Sleep(100 * time.Millisecond) continue } return err } return err } func renameWithRetry(oldPath, newPath string) error { var err error for i := 0; i < 40; i++ { err = os.Rename(oldPath, newPath) if err == nil { return nil } if isSharingViolation(err) { time.Sleep(100 * time.Millisecond) continue } return err } return err } func moveToDoneDir(outputPath string) (string, error) { outputPath = strings.TrimSpace(outputPath) if outputPath == "" { return "", nil } s := getSettings() // ✅ doneDir relativ zur exe auflösen (funktion hast du schon) doneDirAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil { return "", err } if err := os.MkdirAll(doneDirAbs, 0o755); err != nil { return "", err } dst := filepath.Join(doneDirAbs, filepath.Base(outputPath)) if err := moveFile(outputPath, dst); err != nil { return "", err } // ✅ Streaming-Optimierung if strings.EqualFold(filepath.Ext(dst), ".mp4") { if err := ensureFastStartMP4(dst); err != nil { fmt.Println("⚠️ faststart:", err) } } return dst, nil } func recordStatus(w http.ResponseWriter, r *http.Request) { id := r.URL.Query().Get("id") if id == "" { http.Error(w, "id fehlt", http.StatusBadRequest) return } jobsMu.Lock() job, ok := jobs[id] jobsMu.Unlock() if !ok { http.Error(w, "job nicht gefunden", http.StatusNotFound) return } json.NewEncoder(w).Encode(job) } func recordStop(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Nur POST", http.StatusMethodNotAllowed) return } id := r.URL.Query().Get("id") jobsMu.Lock() job, ok := jobs[id] jobsMu.Unlock() if !ok { http.Error(w, "job nicht gefunden", http.StatusNotFound) return } stopJobsInternal([]*RecordJob{job}) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(job) } // --- DVR-ähnlicher Recorder-Ablauf --- // Entspricht grob dem RecordStream aus dem Channel-Snippet: func RecordStream( ctx context.Context, hc *HTTPClient, domain string, username string, outputPath string, httpCookie string, job *RecordJob, ) error { // 1) Seite laden // Domain sauber zusammenbauen (mit/ohne Slash) base := strings.TrimRight(domain, "/") pageURL := base + "/" + username body, err := hc.FetchPage(ctx, pageURL, httpCookie) if err != nil { return fmt.Errorf("seite laden: %w", err) } // 2) HLS-URL aus roomDossier extrahieren (wie DVR.ParseStream) hlsURL, err := ParseStream(body) if err != nil { return fmt.Errorf("stream-parsing: %w", err) } // 3) Playlist holen (wie stream.GetPlaylist im DVR) playlist, err := FetchPlaylist(ctx, hc, hlsURL, httpCookie) if err != nil { return fmt.Errorf("playlist abrufen: %w", err) } if job != nil && strings.TrimSpace(job.PreviewDir) == "" { assetID := assetIDForJob(job) if strings.TrimSpace(assetID) == "" { assetID = job.ID } previewDir := filepath.Join(os.TempDir(), "rec_preview", assetID) jobsMu.Lock() job.PreviewDir = previewDir jobsMu.Unlock() if err := startPreviewHLS(ctx, job, playlist.PlaylistURL, previewDir, httpCookie, hc.userAgent); err != nil { fmt.Println("⚠️ preview start fehlgeschlagen:", err) } } // 4) Datei öffnen file, err := os.Create(outputPath) if err != nil { return fmt.Errorf("datei erstellen: %w", err) } defer func() { _ = file.Close() }() // live size tracking (für UI) var written int64 var lastPush time.Time var lastBytes int64 // 5) Segmente „watchen“ – analog zu WatchSegments + HandleSegment im DVR err = playlist.WatchSegments(ctx, hc, httpCookie, func(b []byte, duration float64) error { // Hier wäre im DVR ch.HandleSegment – bei dir einfach in eine Datei schreiben if _, err := file.Write(b); err != nil { return fmt.Errorf("schreibe segment: %w", err) } // ✅ live size (UI) – throttled written += int64(len(b)) if job != nil { now := time.Now() if lastPush.IsZero() || now.Sub(lastPush) >= 750*time.Millisecond || (written-lastBytes) >= 2*1024*1024 { jobsMu.Lock() job.SizeBytes = written jobsMu.Unlock() notifyJobsChanged() lastPush = now lastBytes = written } } // Könntest hier z.B. auch Dauer/Größe loggen, wenn du möchtest _ = duration // aktuell unbenutzt return nil }) if err != nil { return fmt.Errorf("watch segments: %w", err) } return nil } // RecordStreamMFC nimmt vorerst die URL 1:1 und ruft ffmpeg direkt darauf auf. // In der Praxis musst du hier meist erst eine HLS-URL aus dem HTML extrahieren. // RecordStreamMFC ist jetzt nur noch ein Wrapper um den bewährten MFC-Flow (runMFC). func RecordStreamMFC( ctx context.Context, hc *HTTPClient, username string, outputPath string, job *RecordJob, ) error { mfc := NewMyFreeCams(username) // optional, aber sinnvoll: nur aufnehmen wenn Public st, err := mfc.GetStatus() if err != nil { return fmt.Errorf("mfc status: %w", err) } if st != StatusPublic { return fmt.Errorf("Stream ist nicht öffentlich (Status: %s)", st) } m3u8URL, err := mfc.GetVideoURL(false) if err != nil { return fmt.Errorf("mfc get video url: %w", err) } if strings.TrimSpace(m3u8URL) == "" { return fmt.Errorf("mfc: keine m3u8 URL gefunden") } // ✅ Preview starten if job != nil && job.PreviewDir == "" { assetID := assetIDForJob(job) if strings.TrimSpace(assetID) == "" { assetID = job.ID } previewDir := filepath.Join(os.TempDir(), "rec_preview", assetID) job.PreviewDir = previewDir if err := startPreviewHLS(ctx, job, m3u8URL, previewDir, "", hc.userAgent); err != nil { fmt.Println("⚠️ preview start fehlgeschlagen:", err) job.PreviewDir = "" // rollback } } // Aufnahme starten return handleM3U8Mode(ctx, m3u8URL, outputPath, job) } func detectProvider(raw string) string { s := strings.ToLower(raw) if strings.Contains(s, "chaturbate.com") { return "chaturbate" } if strings.Contains(s, "myfreecams.com") { return "mfc" } return "unknown" } // --- helper --- func extractUsername(input string) string { input = strings.TrimSpace(input) input = strings.TrimPrefix(input, "https://") input = strings.TrimPrefix(input, "http://") input = strings.TrimPrefix(input, "www.") if strings.HasPrefix(input, "chaturbate.com/") { input = strings.TrimPrefix(input, "chaturbate.com/") } // alles nach dem ersten Slash abschneiden (Pfadteile, /, etc.) if idx := strings.IndexAny(input, "/?#"); idx != -1 { input = input[:idx] } // zur Sicherheit evtl. übrig gebliebene Slash/Backslash trimmen return strings.Trim(input, "/\\") } // Cookie-Hilfsfunktion (wie ParseCookies + AddCookie im DVR) func addCookiesFromString(req *http.Request, cookieStr string) { if cookieStr == "" { return } pairs := strings.Split(cookieStr, ";") for _, pair := range pairs { parts := strings.SplitN(strings.TrimSpace(pair), "=", 2) if len(parts) != 2 { continue } name := strings.TrimSpace(parts[0]) value := strings.TrimSpace(parts[1]) if name == "" { continue } req.AddCookie(&http.Cookie{ Name: name, Value: value, }) } } // ParseStream entspricht der DVR-Variante (roomDossier → hls_source) func ParseStream(html string) (string, error) { matches := roomDossierRegexp.FindStringSubmatch(html) if len(matches) == 0 { return "", errors.New("room dossier nicht gefunden") } // DVR-Style Unicode-Decode decoded, err := strconv.Unquote( strings.Replace(strconv.Quote(matches[1]), `\\u`, `\u`, -1), ) if err != nil { return "", fmt.Errorf("Unicode-decode failed: %w", err) } var rd struct { HLSSource string `json:"hls_source"` } if err := json.Unmarshal([]byte(decoded), &rd); err != nil { return "", fmt.Errorf("JSON-parse failed: %w", err) } if rd.HLSSource == "" { return "", errors.New("kein HLS-Quell-URL im JSON") } return rd.HLSSource, nil } // --- Playlist/WatchSegments wie gehabt --- type Playlist struct { PlaylistURL string RootURL string Resolution int Framerate int } type Resolution struct { Framerate map[int]string Width int } // 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 } // nutzt ebenfalls *HTTPClient func (p *Playlist) WatchSegments( ctx context.Context, hc *HTTPClient, httpCookie string, handler func([]byte, float64) error, ) error { var lastSeq int64 = -1 emptyRounds := 0 const maxEmptyRounds = 60 // statt 5 for { select { case <-ctx.Done(): return ctx.Err() default: } // Playlist holen req, err := hc.NewRequest(ctx, http.MethodGet, p.PlaylistURL, httpCookie) if err != nil { return fmt.Errorf("Fehler beim Erstellen der Playlist-Request: %w", err) } resp, err := hc.client.Do(req) if err != nil { emptyRounds++ if emptyRounds >= maxEmptyRounds { return errors.New("❌ Playlist nicht mehr erreichbar – Stream vermutlich offline") } time.Sleep(2 * time.Second) continue } playlist, listType, err := m3u8.DecodeFrom(resp.Body, true) resp.Body.Close() if err != nil || listType != m3u8.MEDIA { emptyRounds++ if emptyRounds >= maxEmptyRounds { return errors.New("❌ Fehlerhafte Playlist – möglicherweise offline") } time.Sleep(2 * time.Second) continue } media := playlist.(*m3u8.MediaPlaylist) newSegment := false for _, segment := range media.Segments { if segment == nil { continue } if int64(segment.SeqId) <= lastSeq { continue } lastSeq = int64(segment.SeqId) newSegment = true segmentURL := p.RootURL + segment.URI segReq, err := hc.NewRequest(ctx, http.MethodGet, segmentURL, httpCookie) if err != nil { continue } segResp, err := hc.client.Do(segReq) if err != nil { continue } data, err := io.ReadAll(segResp.Body) segResp.Body.Close() if err != nil || len(data) == 0 { continue } if err := handler(data, segment.Duration); err != nil { return err } } if newSegment { emptyRounds = 0 } else { emptyRounds++ if emptyRounds >= maxEmptyRounds { return errors.New("🛑 Keine neuen HLS-Segmente empfangen – Stream vermutlich beendet oder offline.") } } time.Sleep(1 * time.Second) } } /* ─────────────────────────────── MyFreeCams (übernommener Flow) ─────────────────────────────── */ type MyFreeCams struct { Username string Attrs map[string]string VideoURL string } func NewMyFreeCams(username string) *MyFreeCams { return &MyFreeCams{ Username: username, Attrs: map[string]string{}, } } func (m *MyFreeCams) GetWebsiteURL() string { return "https://www.myfreecams.com/#" + m.Username } func (m *MyFreeCams) GetVideoURL(refresh bool) (string, error) { if !refresh && m.VideoURL != "" { return m.VideoURL, nil } // Prüfen, ob alle benötigten Attribute vorhanden sind if _, ok := m.Attrs["data-cam-preview-model-id-value"]; !ok { return "", nil } sid := m.Attrs["data-cam-preview-server-id-value"] midBase := m.Attrs["data-cam-preview-model-id-value"] isWzobs := strings.ToLower(m.Attrs["data-cam-preview-is-wzobs-value"]) == "true" midInt, err := strconv.Atoi(midBase) if err != nil { return "", fmt.Errorf("model-id parse error: %w", err) } mid := 100000000 + midInt a := "" if isWzobs { a = "a_" } playlistURL := fmt.Sprintf( "https://previews.myfreecams.com/hls/NxServer/%s/ngrp:mfc_%s%d.f4v_mobile_mhp1080_previewurl/playlist.m3u8", sid, a, mid, ) // Validieren (HTTP 200) & ggf. auf gewünschte Auflösung verlinken u, err := getWantedResolutionPlaylist(playlistURL) if err != nil { return "", err } m.VideoURL = u return m.VideoURL, nil } func (m *MyFreeCams) GetStatus() (Status, error) { // 1) share-Seite prüfen (existiert/nicht existiert) shareURL := "https://share.myfreecams.com/" + m.Username resp, err := http.Get(shareURL) if err != nil { return StatusUnknown, err } defer resp.Body.Close() if resp.StatusCode == 404 { return StatusNotExist, nil } if resp.StatusCode != 200 { return StatusUnknown, fmt.Errorf("HTTP %d", resp.StatusCode) } // wir brauchen sowohl Bytes (für Suche) als auch Reader (für HTML) bodyBytes, err := io.ReadAll(resp.Body) if err != nil { return StatusUnknown, err } // 2) „tracking.php?“ suchen und prüfen, ob model_id vorhanden ist start := bytes.Index(bodyBytes, []byte("https://www.myfreecams.com/php/tracking.php?")) if start == -1 { // ohne tracking Parameter -> behandeln wie nicht existent return StatusNotExist, nil } end := bytes.IndexByte(bodyBytes[start:], '"') if end == -1 { return StatusUnknown, errors.New("tracking url parse failed") } raw := string(bodyBytes[start : start+end]) u, err := url.Parse(raw) if err != nil { return StatusUnknown, fmt.Errorf("tracking url invalid: %w", err) } qs := u.Query() if qs.Get("model_id") == "" { return StatusNotExist, nil } // 3) HTML parsen und
Attribute auslesen doc, err := goquery.NewDocumentFromReader(bytes.NewReader(bodyBytes)) if err != nil { return StatusUnknown, err } params := doc.Find(".campreview").First() if params.Length() == 0 { // keine campreview -> offline return StatusOffline, nil } attrs := map[string]string{} params.Each(func(_ int, s *goquery.Selection) { for _, a := range []string{ "data-cam-preview-server-id-value", "data-cam-preview-model-id-value", "data-cam-preview-is-wzobs-value", } { if v, ok := s.Attr(a); ok { attrs[a] = v } } }) m.Attrs = attrs // 4) Versuchen, VideoURL (Preview-HLS) zu ermitteln uStr, err := m.GetVideoURL(true) if err != nil { return StatusUnknown, err } if uStr != "" { return StatusPublic, nil } // campreview vorhanden, aber keine playable url -> „PRIVATE“ return StatusPrivate, nil } func runMFC(ctx context.Context, username string, outArg string) error { mfc := NewMyFreeCams(username) st, err := mfc.GetStatus() if err != nil { return err } if st != StatusPublic { return fmt.Errorf("Stream ist nicht öffentlich (Status: %s)", st) } m3u8URL, err := mfc.GetVideoURL(false) if err != nil { return err } if m3u8URL == "" { return errors.New("keine m3u8 URL gefunden") } return handleM3U8Mode(ctx, m3u8URL, outArg, nil) } /* ─────────────────────────────── Gemeinsame HLS/M3U8-Helper (MFC) ─────────────────────────────── */ func getWantedResolutionPlaylist(playlistURL string) (string, error) { // Holt eine URL; wenn MASTER, wähle beste Variante; wenn MEDIA, gib die URL zurück. resp, err := http.Get(playlistURL) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != 200 { return "", fmt.Errorf("HTTP %d beim Abruf der m3u8", resp.StatusCode) } playlist, listType, err := m3u8.DecodeFrom(resp.Body, true) if err != nil { return "", fmt.Errorf("m3u8 parse: %w", err) } if listType == m3u8.MEDIA { return playlistURL, nil } master := playlist.(*m3u8.MasterPlaylist) var bestURI string var bestWidth int var bestFramerate float64 for _, v := range master.Variants { if v == nil { continue } // Resolution kommt als "WxH" – wir nutzen die Höhe als Vergleichswert. w := 0 if v.Resolution != "" { parts := strings.Split(v.Resolution, "x") if len(parts) == 2 { if ww, err := strconv.Atoi(parts[1]); err == nil { w = ww } } } fr := 30.0 if v.FrameRate > 0 { fr = v.FrameRate } else if strings.Contains(v.Name, "FPS:60") { fr = 60 } if w > bestWidth || (w == bestWidth && fr > bestFramerate) { bestWidth = w bestFramerate = fr bestURI = v.URI } } if bestURI == "" { return "", errors.New("Master-Playlist ohne gültige Varianten") } // Absolutieren root := playlistURL[:strings.LastIndex(playlistURL, "/")+1] if strings.HasPrefix(bestURI, "http://") || strings.HasPrefix(bestURI, "https://") { return bestURI, nil } return root + bestURI, nil } func handleM3U8Mode(ctx context.Context, m3u8URL, outFile string, job *RecordJob) error { // Validierung u, err := url.Parse(m3u8URL) if err != nil || (u.Scheme != "http" && u.Scheme != "https") { return fmt.Errorf("ungültige URL: %q", m3u8URL) } // HTTP-Check MIT Context req, err := http.NewRequestWithContext(ctx, "GET", m3u8URL, nil) if err != nil { return err } resp, err := http.DefaultClient.Do(req) if err != nil { return err } io.Copy(io.Discard, resp.Body) resp.Body.Close() if resp.StatusCode != 200 { return fmt.Errorf("HTTP %d beim Abruf der m3u8", resp.StatusCode) } if strings.TrimSpace(outFile) == "" { return errors.New("output file path leer") } // ffmpeg mit Context (STOP FUNKTIONIERT HIER!) cmd := exec.CommandContext( ctx, ffmpegPath, "-y", "-hide_banner", "-nostats", "-loglevel", "warning", // alternativ: "error" (noch leiser) "-i", m3u8URL, "-c", "copy", outFile, ) // FFmpeg nicht direkt in stdout/stderr schreiben lassen -> sonst spammt es Log/Console var stderr bytes.Buffer cmd.Stdout = io.Discard cmd.Stderr = &stderr // ✅ live size polling während ffmpeg läuft stopStat := make(chan struct{}) defer close(stopStat) if job != nil { go func() { t := time.NewTicker(1 * time.Second) defer t.Stop() var last int64 for { select { case <-ctx.Done(): return case <-stopStat: return case <-t.C: fi, err := os.Stat(outFile) if err != nil { continue } sz := fi.Size() if sz > 0 && sz != last { jobsMu.Lock() job.SizeBytes = sz jobsMu.Unlock() notifyJobsChanged() last = sz } } } }() } return nil } /* ─────────────────────────────── Kleine Helper für MFC ─────────────────────────────── */ func extractMFCUsername(input string) string { if strings.Contains(input, "myfreecams.com/#") { i := strings.Index(input, "#") if i >= 0 && i < len(input)-1 { return strings.TrimSpace(input[i+1:]) } return "" } return strings.TrimSpace(input) } 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 }