From 831b51b4a742efca118cf4142c8fbb816f2c3072 Mon Sep 17 00:00:00 2001 From: Linrador <68631622+Linrador@users.noreply.github.com> Date: Sun, 24 May 2026 23:14:48 +0200 Subject: [PATCH] bugfixes --- backend/chaturbate_autostart.go | 50 +- backend/main.go | 44 ++ backend/record_stream_cb.go | 13 +- backend/record_stream_hls.go | 452 +++++++++++++++++- backend/record_stream_mfc.go | 12 +- backend/tasks_cleanup.go | 107 ++++- .../src/components/ui/RecorderSettings.tsx | 1 + 7 files changed, 646 insertions(+), 33 deletions(-) diff --git a/backend/chaturbate_autostart.go b/backend/chaturbate_autostart.go index 540f7b3..ecdf1e7 100644 --- a/backend/chaturbate_autostart.go +++ b/backend/chaturbate_autostart.go @@ -239,6 +239,39 @@ func chaturbateAbortIfNoOutput(jobID string, maxWait time.Duration, onOutput fun out := strings.TrimSpace(job.Output) wasVisible := !job.Hidden + // Sichtbare Jobs nicht endlos als "running" stehen lassen, + // wenn nach outputProbeMax keine Datei entstanden ist. + if wasVisible { + if recordCancel != nil { + recordCancel() + } + + now := time.Now() + + job.Status = JobFailed + job.EndedAt = &now + job.EndedAtMs = now.UnixMilli() + job.Phase = "" + job.Progress = 100 + job.Error = "Kein Output nach Start – ffmpeg konnte keine Datei schreiben" + job.PostWorkKey = "" + job.PostWork = nil + + jobsMu.Unlock() + + if verboseLogs() { + appLogln("❌ [autostart] no output after probe; marking failed:", jobID) + } + + _ = publishJobSnapshot(jobID) + + if onNoOutput != nil { + onNoOutput() + } + + return + } + jobsMu.Unlock() if previewCancel != nil { @@ -290,12 +323,15 @@ func startChaturbateAutoStartWorker(store *ModelStore) { const retryCooldown = 25 * time.Second // wie lange wir warten, ob eine echte Datei entsteht - const outputProbeMax = 20 * time.Second + const outputProbeMax = 60 * time.Second queue := make([]autoStartItem, 0, 64) queued := map[string]bool{} lastTry := map[string]time.Time{} + // verhindert Stop/Resume-Flackern bei kurzen API-Statuswechseln + nonPublicSince := map[string]time.Time{} + scanTicker := time.NewTicker(scanInterval) startTicker := time.NewTicker(startInterval) defer scanTicker.Stop() @@ -573,6 +609,18 @@ func startChaturbateAutoStartWorker(store *ModelStore) { show := chaturbateShowFromSnapshot(showByUser, user) if show != "private" && show != "hidden" && show != "away" { + delete(nonPublicSince, user) + continue + } + + firstSeen, ok := nonPublicSince[user] + if !ok { + nonPublicSince[user] = now + continue + } + + // Nicht sofort stoppen. CB/API kann kurz flackern. + if now.Sub(firstSeen) < 30*time.Second { continue } diff --git a/backend/main.go b/backend/main.go index 12768c3..e45638d 100644 --- a/backend/main.go +++ b/backend/main.go @@ -195,6 +195,50 @@ func publishJobUpsertSnapshot(s jobEventSnapshot) { } } +func publishJobSnapshot(jobID string) bool { + jobsMu.RLock() + job := jobs[jobID] + if job == nil { + jobsMu.RUnlock() + return false + } + + var endedAtCopy *time.Time + if job.EndedAt != nil { + t := *job.EndedAt + endedAtCopy = &t + } + + var pw *PostWorkKeyStatus + if job.PostWork != nil { + tmp := *job.PostWork + pw = &tmp + } + + snap := jobEventSnapshot{ + ID: job.ID, + SourceURL: job.SourceURL, + Output: job.Output, + Status: job.Status, + StartedAt: job.StartedAt, + StartedAtMs: job.StartedAtMs, + EndedAt: endedAtCopy, + EndedAtMs: job.EndedAtMs, + Error: job.Error, + Phase: job.Phase, + Progress: job.Progress, + SizeBytes: job.SizeBytes, + DurationSeconds: job.DurationSeconds, + PreviewState: job.PreviewState, + PostWorkKey: job.PostWorkKey, + PostWork: pw, + } + jobsMu.RUnlock() + + publishJobUpsertSnapshot(snap) + return true +} + func jobMatchesModelKey(j *RecordJob, modelKey string) bool { if j == nil { return false diff --git a/backend/record_stream_cb.go b/backend/record_stream_cb.go index 8bb3930..36c7cf9 100644 --- a/backend/record_stream_cb.go +++ b/backend/record_stream_cb.go @@ -188,7 +188,7 @@ func RecordStream( updatePreviewFromStream(stream) - const maxPlaylistRefreshes = 12 + const maxPlaylistRefreshes = 30 refreshes := 0 attempt := 0 useChunkMerge := strings.EqualFold(filepath.Ext(outputPath), ".ts") @@ -208,12 +208,21 @@ func RecordStream( mergeAfterRun = true } - err = handleM3U8Mode(ctx, stream, attemptOut, job, httpCookie, hc.userAgent, pageURL) + err = recordHLSPlaylistSegments(ctx, stream, attemptOut, job, httpCookie, hc.userAgent, pageURL) if mergeAfterRun && fileExistsNonEmpty(attemptOut) { if aerr := appendFileAndRemove(outputPath, attemptOut); aerr != nil { return appErrorf("retry-chunk merge failed: %w", aerr) } + + if job != nil { + if fi, statErr := os.Stat(outputPath); statErr == nil && fi != nil && !fi.IsDir() { + jobsMu.Lock() + job.SizeBytes = fi.Size() + jobsMu.Unlock() + _ = publishJobSnapshot(job.ID) + } + } } if err == nil { diff --git a/backend/record_stream_hls.go b/backend/record_stream_hls.go index a6ec58c..6c2cbbe 100644 --- a/backend/record_stream_hls.go +++ b/backend/record_stream_hls.go @@ -405,6 +405,416 @@ func appendFileAndRemove(dstPath, srcPath string) error { return nil } +func hlsAttrValue(line string, key string) string { + line = strings.TrimSpace(line) + key = strings.TrimSpace(key) + if line == "" || key == "" { + return "" + } + + needle := key + "=" + idx := strings.Index(line, needle) + if idx < 0 { + return "" + } + + rest := strings.TrimSpace(line[idx+len(needle):]) + if rest == "" { + return "" + } + + if strings.HasPrefix(rest, `"`) { + rest = rest[1:] + end := strings.IndexByte(rest, '"') + if end < 0 { + return strings.TrimSpace(rest) + } + return strings.TrimSpace(rest[:end]) + } + + if comma := strings.IndexByte(rest, ','); comma >= 0 { + rest = rest[:comma] + } + + return strings.Trim(strings.TrimSpace(rest), `"`) +} + +func hlsExtXMapURI(playlistText string) string { + for _, line := range strings.Split(playlistText, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "#EXT-X-MAP:") { + return hlsAttrValue(line, "URI") + } + } + return "" +} + +func hlsPartURIsFromText(playlistText string) []string { + out := make([]string, 0, 16) + + for _, line := range strings.Split(playlistText, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "#EXT-X-PART:") { + continue + } + + uri := hlsAttrValue(line, "URI") + if uri != "" { + out = append(out, uri) + } + } + + return out +} + +func hlsMediaSegmentURIsFromText(playlistText string) []string { + out := make([]string, 0, 16) + + for _, line := range strings.Split(playlistText, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if strings.HasPrefix(line, "#") { + continue + } + + out = append(out, line) + } + + return out +} + +func hlsRefreshDelay(playlistText string) time.Duration { + for _, line := range strings.Split(playlistText, "\n") { + line = strings.TrimSpace(line) + + if strings.HasPrefix(line, "#EXT-X-PART-INF:") { + raw := hlsAttrValue(line, "PART-TARGET") + if f, err := strconv.ParseFloat(raw, 64); err == nil && f > 0 { + d := time.Duration(f * float64(time.Second)) + if d < 500*time.Millisecond { + d = 500 * time.Millisecond + } + if d > 2*time.Second { + d = 2 * time.Second + } + return d + } + } + + if strings.HasPrefix(line, "#EXT-X-TARGETDURATION:") { + raw := strings.TrimSpace(strings.TrimPrefix(line, "#EXT-X-TARGETDURATION:")) + if f, err := strconv.ParseFloat(raw, 64); err == nil && f > 0 { + d := time.Duration(f * float64(time.Second)) + if d < 1*time.Second { + d = 1 * time.Second + } + if d > 3*time.Second { + d = 3 * time.Second + } + return d + } + } + } + + return 1 * time.Second +} + +func hlsResolveURI(baseRaw string, refRaw string) (string, error) { + baseRaw = strings.TrimSpace(baseRaw) + refRaw = strings.TrimSpace(refRaw) + + if refRaw == "" { + return "", appErrorf("empty hls uri") + } + + ref, err := url.Parse(refRaw) + if err != nil { + return "", err + } + if ref.IsAbs() { + return ref.String(), nil + } + + base, err := url.Parse(baseRaw) + if err != nil { + return "", err + } + + return base.ResolveReference(ref).String(), nil +} + +func hlsGetBytesWithHeaders( + ctx context.Context, + rawURL string, + httpCookie string, + userAgent string, + referer string, +) ([]byte, error) { + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + return nil, appErrorf("empty hls url") + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + + if strings.TrimSpace(userAgent) != "" { + req.Header.Set("User-Agent", strings.TrimSpace(userAgent)) + } + if strings.TrimSpace(httpCookie) != "" { + req.Header.Set("Cookie", strings.TrimSpace(httpCookie)) + } + if strings.TrimSpace(referer) != "" { + ref := strings.TrimSpace(referer) + req.Header.Set("Referer", ref) + + if ru, err := url.Parse(ref); err == nil && ru != nil && ru.Scheme != "" && ru.Host != "" { + req.Header.Set("Origin", ru.Scheme+"://"+ru.Host) + } + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, appErrorf("HTTP %d: %s", resp.StatusCode, rawURL) + } + + return io.ReadAll(resp.Body) +} + +func hlsGetBytesWithHeadersRetry( + ctx context.Context, + rawURL string, + httpCookie string, + userAgent string, + referer string, + attempts int, +) ([]byte, error) { + if attempts < 1 { + attempts = 1 + } + + var lastErr error + + for i := 1; i <= attempts; i++ { + b, err := hlsGetBytesWithHeaders(ctx, rawURL, httpCookie, userAgent, referer) + if err == nil { + return b, nil + } + + lastErr = err + + if ctx.Err() != nil { + return nil, ctx.Err() + } + + if i < attempts { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(600 * time.Millisecond): + } + } + } + + return nil, lastErr +} + +func writeStreamBytesAndPublish(f *os.File, job *RecordJob, b []byte) error { + if len(b) == 0 { + return nil + } + + if _, err := f.Write(b); err != nil { + return err + } + + if job != nil { + if fi, err := f.Stat(); err == nil && fi != nil && !fi.IsDir() { + jobsMu.Lock() + job.SizeBytes = fi.Size() + jobsMu.Unlock() + + _ = publishJobSnapshot(job.ID) + } + } + + return nil +} + +func streamLogPrefix(job *RecordJob, referer string, outFile string) string { + if s := strings.TrimSpace(hlsErrorModelName(job, referer)); s != "" { + return "[" + s + "]" + } + + outFile = strings.TrimSpace(outFile) + if outFile != "" { + stem := strings.TrimSuffix(filepath.Base(outFile), filepath.Ext(outFile)) + stem = stripHotPrefix(stem) + + if s := strings.TrimSpace(modelNameFromFilename(stem)); s != "" { + return "[" + s + "]" + } + if s := strings.TrimSpace(canonicalAssetIDFromName(stem)); s != "" { + return "[" + s + "]" + } + } + + return "[stream]" +} + +func recordHLSPlaylistSegments( + ctx context.Context, + stream *selectedHLSStream, + outFile string, + job *RecordJob, + httpCookie string, + userAgent string, + referer string, +) error { + if stream == nil { + return errors.New("stream config nil") + } + + logPrefix := streamLogPrefix(job, referer, outFile) + + mediaURL := strings.TrimSpace(stream.VideoURL) + if mediaURL == "" { + return appErrorf("%s media playlist url empty", logPrefix) + } + + outFile = strings.TrimSpace(outFile) + if outFile == "" { + return appErrorf("%s output path empty", logPrefix) + } + + if err := os.MkdirAll(filepath.Dir(outFile), 0o755); err != nil { + return appErrorf("%s mkdir failed: %w", logPrefix, err) + } + + f, err := os.OpenFile(outFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return appErrorf("%s open output failed: %w", logPrefix, err) + } + defer f.Close() + + seen := map[string]struct{}{} + writtenMaps := map[string]struct{}{} + + lastNewSegmentAt := time.Now() + + if verboseLogs() { + appLogln("🎥", logPrefix, "recording media playlist:", mediaURL) + } + + for { + if err := ctx.Err(); err != nil { + return err + } + + body, err := hlsGetBytesWithHeaders(ctx, mediaURL, httpCookie, userAgent, referer) + if err != nil { + return appErrorf("%s get playlist: %w", logPrefix, err) + } + + playlistText := string(body) + + if strings.Contains(playlistText, "#EXT-X-STREAM-INF") { + return appErrorf("%s expected media playlist, got master playlist", logPrefix) + } + + segmentURIs := hlsMediaSegmentURIsFromText(playlistText) + + if len(segmentURIs) == 0 { + segmentURIs = hlsPartURIsFromText(playlistText) + } + + wroteAny := false + + if len(segmentURIs) > 0 { + mapURI := hlsExtXMapURI(playlistText) + if mapURI != "" { + mapURL, err := hlsResolveURI(mediaURL, mapURI) + if err != nil { + return appErrorf("%s resolve map uri: %w", logPrefix, err) + } + + if _, ok := writtenMaps[mapURL]; !ok { + initBytes, err := hlsGetBytesWithHeadersRetry(ctx, mapURL, httpCookie, userAgent, referer, 3) + if err != nil { + appLogln("⚠️", logPrefix, "init segment failed:", err) + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(hlsRefreshDelay(playlistText)): + } + + continue + } + + if err := writeStreamBytesAndPublish(f, job, initBytes); err != nil { + return appErrorf("%s write init: %w", logPrefix, err) + } + + writtenMaps[mapURL] = struct{}{} + wroteAny = true + } + } + } + + for _, uri := range segmentURIs { + uri = strings.TrimSpace(uri) + if uri == "" { + continue + } + + segmentURL, err := hlsResolveURI(mediaURL, uri) + if err != nil { + appLogln("⚠️", logPrefix, "resolve segment failed:", uri, err) + continue + } + + if _, ok := seen[segmentURL]; ok { + continue + } + + segBytes, err := hlsGetBytesWithHeadersRetry(ctx, segmentURL, httpCookie, userAgent, referer, 3) + if err != nil { + appLogln("⚠️", logPrefix, "segment failed:", filepath.Base(uri), err) + continue + } + + if err := writeStreamBytesAndPublish(f, job, segBytes); err != nil { + return appErrorf("%s write segment: %w", logPrefix, err) + } + + seen[segmentURL] = struct{}{} + wroteAny = true + } + + if wroteAny { + lastNewSegmentAt = time.Now() + } else if time.Since(lastNewSegmentAt) > 75*time.Second { + return io.EOF + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(hlsRefreshDelay(playlistText)): + } + } +} + func isExitStatus(err error, code int) bool { var exitErr *exec.ExitError if errors.As(err, &exitErr) { @@ -429,12 +839,30 @@ func handleM3U8Mode( videoURL := strings.TrimSpace(stream.VideoURL) audioURL := strings.TrimSpace(stream.AudioURL) - u, err := url.Parse(videoURL) - if err != nil || (u.Scheme != "http" && u.Scheme != "https") { - return appErrorf("ungültige video URL: %q", videoURL) + isChaturbate := strings.Contains(strings.ToLower(strings.TrimSpace(referer)), "chaturbate.com") + + // Wichtig: + // Bei Chaturbate die MasterURL NICHT nochmal an ffmpeg geben. + // Die Master-Playlist wurde vorher schon in Go geöffnet/parst. + // Das erneute Öffnen der llhls.m3u8 führt bei dir häufig zu 403. + inputURL := videoURL + useSeparateAudioInput := false + + if isChaturbate { + // Stabilitäts-Test: + // Chaturbate erstmal NICHT mit separatem Audio-Input öffnen. + // Wenn danach sofort .ts-Dateien entstehen, war der separate Audio-Input der Blocker. + useSeparateAudioInput = false + } else if stream.HasSeparateAudio && strings.TrimSpace(stream.MasterURL) != "" { + inputURL = strings.TrimSpace(stream.MasterURL) } - if audioURL != "" { + u, err := url.Parse(inputURL) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") { + return appErrorf("ungültige input URL: %q", inputURL) + } + + if useSeparateAudioInput { au, err := url.Parse(audioURL) if err != nil || (au.Scheme != "http" && au.Scheme != "https") { return appErrorf("ungültige audio URL: %q", audioURL) @@ -471,15 +899,15 @@ func handleM3U8Mode( appendInputOptions := func(args []string) []string { args = append(args, - "-rw_timeout", "15000000", + // 30s statt 15s: CB/CDN hat gelegentlich kurze Hänger. + "-rw_timeout", "30000000", - // HLS-Segmente robuster laden: - // kurze Netzwerkhänger / einzelne kaputte Segment-Requests nicht sofort hart abbrechen. "-reconnect", "1", + "-reconnect_at_eof", "1", "-reconnect_streamed", "1", "-reconnect_on_network_error", "1", "-reconnect_on_http_error", "403,404,408,429,500,502,503,504", - "-reconnect_delay_max", "4", + "-reconnect_delay_max", "10", ) if ua != "" { @@ -497,7 +925,7 @@ func handleM3U8Mode( return args } - if audioURL != "" { + if useSeparateAudioInput { args = appendInputOptions(args) args = append(args, "-i", videoURL) @@ -514,7 +942,7 @@ func handleM3U8Mode( } else { args = appendInputOptions(args) args = append(args, - "-i", videoURL, + "-i", inputURL, "-map", "0:v:0", "-map", "0:a:0?", "-dn", @@ -574,7 +1002,7 @@ func handleM3U8Mode( jobsMu.Unlock() last = sz - _ = publishJob(job.ID) + _ = publishJobSnapshot(job.ID) } } } @@ -599,7 +1027,7 @@ func handleM3U8Mode( jobsMu.Lock() job.SizeBytes = fi.Size() jobsMu.Unlock() - _ = publishJob(job.ID) + _ = publishJobSnapshot(job.ID) } } diff --git a/backend/record_stream_mfc.go b/backend/record_stream_mfc.go index 2ab5163..a949836 100644 --- a/backend/record_stream_mfc.go +++ b/backend/record_stream_mfc.go @@ -30,7 +30,7 @@ func RecordStreamMFC( ) error { mfc := NewMyFreeCams(username) - const waitPublicMax = 20 * time.Second + const waitPublicMax = 90 * time.Second deadline := time.Now().Add(waitPublicMax) for { @@ -51,7 +51,7 @@ func RecordStreamMFC( } // ✅ erst jetzt die Video URL holen (weil public) - stream, err := mfc.GetSelectedStream(false) + stream, err := mfc.GetSelectedStream(ctx, false, hc.userAgent) if err != nil { return appErrorf("mfc get video url: %w", err) } @@ -134,7 +134,7 @@ func (m *MyFreeCams) GetVideoURL(refresh bool) (string, error) { return m.VideoURL, nil } -func (m *MyFreeCams) GetSelectedStream(refresh bool) (*selectedHLSStream, error) { +func (m *MyFreeCams) GetSelectedStream(ctx context.Context, refresh bool, userAgent string) (*selectedHLSStream, error) { m3u8URL, err := m.GetVideoURL(refresh) if err != nil { return nil, err @@ -144,10 +144,10 @@ func (m *MyFreeCams) GetSelectedStream(refresh bool) (*selectedHLSStream, error) } return getWantedResolutionPlaylistWithHeaders( - context.Background(), + ctx, m3u8URL, "", // MFC normalerweise ohne Cookies - "", // optionaler UA, hier leer lassen oder später ergänzen + userAgent, m.GetWebsiteURL(), ) } @@ -243,7 +243,7 @@ func runMFC(ctx context.Context, username string, outArg string) error { return appErrorf("Stream ist nicht live (Status: %s)", st) } - stream, err := mfc.GetSelectedStream(false) + stream, err := mfc.GetSelectedStream(ctx, false, "") if err != nil { return err } diff --git a/backend/tasks_cleanup.go b/backend/tasks_cleanup.go index 513c286..a24822c 100644 --- a/backend/tasks_cleanup.go +++ b/backend/tasks_cleanup.go @@ -24,13 +24,14 @@ type cleanupCandidate struct { } type cleanupResp struct { - // Small downloads cleanup - ScannedFiles int `json:"scannedFiles"` - DeletedFiles int `json:"deletedFiles"` - SkippedFiles int `json:"skippedFiles"` - DeletedBytes int64 `json:"deletedBytes"` - DeletedBytesHuman string `json:"deletedBytesHuman"` - ErrorCount int `json:"errorCount"` + // Small / broken downloads cleanup + ScannedFiles int `json:"scannedFiles"` + DeletedFiles int `json:"deletedFiles"` + DeletedBrokenFiles int `json:"deletedBrokenFiles"` + SkippedFiles int `json:"skippedFiles"` + DeletedBytes int64 `json:"deletedBytes"` + DeletedBytesHuman string `json:"deletedBytesHuman"` + ErrorCount int `json:"errorCount"` // Generated-GC GeneratedOrphansChecked int `json:"generatedOrphansChecked"` @@ -233,7 +234,7 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) { finishCleanupJob( jobID, - cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes), + cleanupProgressText(resp.DeletedFiles, resp.DeletedBrokenFiles, resp.ScannedFiles, resp.DeletedBytes), nil, ) }(jobID, ctx, doneAbs, mb) @@ -265,7 +266,17 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) { } } -func cleanupProgressText(deleted int, total int, freedBytes int64) string { +func cleanupProgressText(deleted int, broken int, total int, freedBytes int64) string { + if broken > 0 { + return fmt.Sprintf( + "gelöscht: %d/%d · fehlerhaft: %d · freigegeben: %s", + deleted, + total, + broken, + formatBytesSI(freedBytes), + ) + } + return fmt.Sprintf( "gelöscht: %d/%d · freigegeben: %s", deleted, @@ -274,6 +285,58 @@ func cleanupProgressText(deleted int, total int, freedBytes int64) string { ) } +func cleanupVideoLooksBroken(ctx context.Context, path string) (bool, string) { + path = strings.TrimSpace(path) + if path == "" { + return false, "" + } + + ext := strings.ToLower(filepath.Ext(path)) + if ext != ".mp4" && ext != ".ts" { + return false, "" + } + + fi, err := os.Stat(path) + if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { + return true, "file missing/empty" + } + + // Erst ffprobe/duration: wenn keine Dauer lesbar ist, ist die Datei meistens kaputt. + dctx, cancel := context.WithTimeout(ctx, 8*time.Second) + dur, derr := durationSecondsCached(dctx, path) + dctxErr := dctx.Err() + cancel() + + if errors.Is(dctxErr, context.DeadlineExceeded) || errors.Is(dctxErr, context.Canceled) { + // Timeout beim Prüfen nicht als "kaputt" werten, um keine guten Dateien wegen Last zu löschen. + return false, "duration check timeout" + } + + if derr != nil || dur <= 0 { + if derr != nil { + return true, "duration failed: " + derr.Error() + } + return true, "duration <= 0" + } + + // Danach konservative Decode-Prüfung am Ende. + // Diese Funktion nutzt du bereits nach dem Remux in recorder.go. + vctx, vcancel := context.WithTimeout(ctx, 20*time.Second) + verr := validateVideoDecodesNearEnd(vctx, path) + vctxErr := vctx.Err() + vcancel() + + if errors.Is(vctxErr, context.DeadlineExceeded) || errors.Is(vctxErr, context.Canceled) { + return false, "decode check timeout" + } + + if verr != nil { + return true, "decode failed: " + verr.Error() + } + + return false, "" +} + func cleanupSmallFiles(ctx context.Context, jobID string, doneAbs string, threshold int64, resp *cleanupResp) error { isCandidate := func(name string) bool { low := strings.ToLower(name) @@ -377,7 +440,7 @@ func cleanupSmallFiles(ctx context.Context, jobID string, doneAbs string, thresh st.Done = 0 st.Total = resp.ScannedFiles st.CurrentFile = "" - st.Text = cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes) + st.Text = cleanupProgressText(resp.DeletedFiles, resp.DeletedBrokenFiles, resp.ScannedFiles, resp.DeletedBytes) st.Error = "" st.FinishedAt = nil }) @@ -388,14 +451,34 @@ func cleanupSmallFiles(ctx context.Context, jobID string, doneAbs string, thresh return ctx.Err() default: } + deleteReason := "" + if c.size < threshold { + deleteReason = "small" + } else { + broken, why := cleanupVideoLooksBroken(ctx, c.path) + if broken { + deleteReason = "broken" + appLogln("🧹 cleanup deleting broken video:", filepath.Base(c.path), why) + } + } + + if deleteReason != "" { base := strings.TrimSuffix(filepath.Base(c.path), filepath.Ext(c.path)) id := stripHotPrefix(base) + // Stoppt ggf. laufende Asset-/Enrich-Tasks für genau diese Datei, + // damit Windows-Dateihandles nicht blockieren. + releaseFileForMutation(c.name) + if derr := removeWithRetry(c.path); derr == nil || os.IsNotExist(derr) { resp.DeletedFiles++ resp.DeletedBytes += c.size + if deleteReason == "broken" { + resp.DeletedBrokenFiles++ + } + if strings.TrimSpace(id) != "" { removeGeneratedForID(id) } @@ -414,8 +497,8 @@ func cleanupSmallFiles(ctx context.Context, jobID string, doneAbs string, thresh } st.Done = i + 1 st.Total = resp.ScannedFiles - st.CurrentFile = "" - st.Text = cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes) + st.CurrentFile = c.name + st.Text = cleanupProgressText(resp.DeletedFiles, resp.DeletedBrokenFiles, resp.ScannedFiles, resp.DeletedBytes) st.Error = "" st.FinishedAt = nil }) diff --git a/frontend/src/components/ui/RecorderSettings.tsx b/frontend/src/components/ui/RecorderSettings.tsx index e72aead..7e8d493 100644 --- a/frontend/src/components/ui/RecorderSettings.tsx +++ b/frontend/src/components/ui/RecorderSettings.tsx @@ -1103,6 +1103,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { const ok = window.confirm( `Aufräumen:\n` + `• Löscht Dateien in "${doneDir}" < ${mb} MB (Ordner "keep" wird übersprungen)\n` + + `• Löscht fehlerhafte/defekte MP4/TS-Dateien, wenn sie nicht lesbar sind\n` + `• Entfernt verwaiste Previews/Thumbs/Generated-Assets ohne passende Datei\n\n` + `Fortfahren?` )