From 06b4ff2666423cb3120becd44a586eb9dd711ab6 Mon Sep 17 00:00:00 2001 From: Linrador <68631622+Linrador@users.noreply.github.com> Date: Thu, 28 May 2026 15:05:53 +0200 Subject: [PATCH] redesigned finished downloads --- backend/rating.go | 86 +- backend/record.go | 89 +- backend/record_stream_cb.go | 44 +- backend/record_stream_hls.go | 641 ++++++- backend/recorder.go | 153 +- backend/server.go | 2 + backend/startup_leftovers.go | 285 +++ backend/tasks_assets.go | 2 + backend/tasks_cleanup.go | 236 ++- backend/teaser.go | 192 +- frontend/src/App.tsx | 35 +- frontend/src/components/ui/Downloads.tsx | 4 +- .../src/components/ui/FinishedDownloads.tsx | 1551 ++++++++--------- frontend/src/components/ui/Slider.tsx | 4 +- frontend/src/components/ui/Tabs.tsx | 2 +- frontend/src/components/ui/TagFilterBar.tsx | 136 +- frontend/src/components/ui/TrainingTab.tsx | 8 +- 17 files changed, 2517 insertions(+), 953 deletions(-) create mode 100644 backend/startup_leftovers.go diff --git a/backend/rating.go b/backend/rating.go index b452720..6c9c5fb 100644 --- a/backend/rating.go +++ b/backend/rating.go @@ -987,6 +987,73 @@ func ratingConfidenceWeight(conf float64) float64 { return 0.58 + 0.42*n } +func ratingLinearGate(value float64, start float64, full float64) float64 { + if full <= start { + if value >= full { + return 1 + } + return 0 + } + + return ratingClamp01((value - start) / (full - start)) +} + +func ratingExcellenceBonus( + peakQuality float64, + positionEffectiveWeighted float64, + positionDensity float64, + weightedCoverageRatio float64, + totalFlagged float64, + longest float64, + avgConfidence float64, + segmentsPerMinute float64, + n int, +) float64 { + // Kein pauschaler Score-Shift: + // Der Bonus darf nur greifen, wenn wirklich mehrere starke Signale vorhanden sind. + if n < 2 { + return 0 + } + if positionEffectiveWeighted <= 0 { + return 0 + } + if totalFlagged < 12.0 { + return 0 + } + if peakQuality < 0.68 { + return 0 + } + if avgConfidence < 0.42 { + return 0 + } + + peakGate := ratingLinearGate(peakQuality, 0.68, 0.90) + positionGate := ratingLinearGate(positionDensity, 2.20, 7.00) + coverageGate := ratingLinearGate(weightedCoverageRatio, 0.045, 0.160) + durationGate := ratingLinearGate(totalFlagged, 12.0, 90.0) + longestGate := ratingLinearGate(longest, 8.0, 45.0) + confGate := ratingLinearGate(avgConfidence, 0.42, 0.78) + frequencyGate := ratingLinearGate(segmentsPerMinute, 0.25, 1.00) + + bonus := + 0.030*peakGate + + 0.022*positionGate + + 0.016*coverageGate + + 0.012*durationGate + + 0.012*longestGate + + 0.010*confGate + + 0.006*frequencyGate + + // Maximal +8.5 Scorepunkte. + // Dadurch werden starke 72-79 Scores 5-Sterne-fähig, + // aber mittelmäßige Scores springen nicht einfach pauschal hoch. + if bonus > 0.085 { + bonus = 0.085 + } + + return bonus +} + func starsFromHighlightScore(score float64) int { switch { case score < 18: @@ -1154,7 +1221,21 @@ func computeHighlightRatingWithUsername(segments []aiSegmentMeta, durationSec fl raw = math.Min(raw, 0.44) } - score := ratingRound(ratingClamp01(raw)*100, 1) + excellenceBonus := ratingExcellenceBonus( + peakQuality, + positionEffectiveWeighted, + positionDensity, + weightedCoverageRatio, + totalFlagged, + longest, + avgConfidence, + segmentsPerMinute, + n, + ) + + raw = ratingClamp01(raw + excellenceBonus) + + score := ratingRound(raw*100, 1) r.Score = score r.Stars = starsFromHighlightScore(score) @@ -1168,10 +1249,11 @@ func computeHighlightRatingWithUsername(segments []aiSegmentMeta, durationSec fl r.AvgConfidence = ratingRound(avgConfidence, 3) appLogf( - "✅ %s rating result score=%.1f stars=%d segments=%d flagged=%.2f weighted=%.2f coverage=%.4f weightedCoverage=%.4f longest=%.2f avgConf=%.3f", + "✅ %s rating result score=%.1f stars=%d bonus=%.1f segments=%d flagged=%.2f weighted=%.2f coverage=%.4f weightedCoverage=%.4f longest=%.2f avgConf=%.3f", ratingLogSubject(username), r.Score, r.Stars, + ratingRound(excellenceBonus*100, 1), r.Segments, r.FlaggedSeconds, r.WeightedFlaggedSeconds, diff --git a/backend/record.go b/backend/record.go index 4597536..6aa3647 100644 --- a/backend/record.go +++ b/backend/record.go @@ -1322,6 +1322,78 @@ func getEffectivePostworkState(job *RecordJob) string { return "none" } +func postworkAudioSidecarCandidatesForOutput(out string) []string { + out = strings.TrimSpace(out) + if out == "" { + return nil + } + + dir := filepath.Dir(out) + ext := filepath.Ext(out) + base := strings.TrimSuffix(filepath.Base(out), ext) + + candidates := []string{} + + // Standard-Pfad aus deiner Recorder-Logik: + // foo.ts -> .foo.audio.m4s + if p := strings.TrimSpace(streamSidecarPath(out, "audio", ".m4s")); p != "" { + candidates = append(candidates, p) + } + + // Explizite Fallbacks, falls canonicalAssetIDFromName anders normalisiert. + if base != "" { + candidates = append(candidates, filepath.Join(dir, "."+base+".audio.m4s")) + } + + if canonical := strings.TrimSpace(canonicalAssetIDFromName(base)); canonical != "" { + candidates = append(candidates, filepath.Join(dir, "."+canonical+".audio.m4s")) + } + + seen := map[string]bool{} + outUnique := make([]string, 0, len(candidates)) + + for _, p := range candidates { + p = strings.TrimSpace(p) + if p == "" { + continue + } + + cp := filepath.Clean(p) + if seen[cp] { + continue + } + seen[cp] = true + + outUnique = append(outUnique, cp) + } + + return outUnique +} + +func removePostworkAudioSidecarsForOutput(out string) []string { + removed := []string{} + + for _, p := range postworkAudioSidecarCandidatesForOutput(out) { + fi, err := os.Stat(p) + if err != nil { + continue + } + if fi == nil || fi.IsDir() { + continue + } + + if err := removeWithRetry(p); err != nil && !os.IsNotExist(err) { + appLogln("⚠️ postwork audio sidecar konnte nicht gelöscht werden:", filepath.Base(p), err) + continue + } + + removed = append(removed, p) + appLogln("🗑️ postwork audio sidecar gelöscht:", filepath.Base(p)) + } + + return removed +} + func recordRemoveQueuedPostwork(w http.ResponseWriter, r *http.Request) { if !mustMethod(w, r, http.MethodPost) { return @@ -1364,8 +1436,18 @@ func recordRemoveQueuedPostwork(w http.ResponseWriter, r *http.Request) { } out := strings.TrimSpace(job.Output) + removedAudioSidecars := []string{} + if out != "" { - _ = removeWithRetry(out) + // Erst passende Audio-Sidecars merken/löschen, solange der TS-Name noch bekannt ist. + removedAudioSidecars = removePostworkAudioSidecarsForOutput(out) + + if err := removeWithRetry(out); err != nil && !os.IsNotExist(err) { + appLogln("⚠️ postwork output konnte nicht gelöscht werden:", filepath.Base(out), err) + } else { + appLogln("🗑️ postwork output gelöscht:", filepath.Base(out)) + } + purgeDurationCacheForPath(out) id1 := canonicalAssetIDFromName(out) @@ -1382,8 +1464,9 @@ func recordRemoveQueuedPostwork(w http.ResponseWriter, r *http.Request) { notifyDoneChanged() respondJSON(w, map[string]any{ - "ok": true, - "id": id, + "ok": true, + "id": id, + "removedAudioSidecars": len(removedAudioSidecars), }) } diff --git a/backend/record_stream_cb.go b/backend/record_stream_cb.go index 36c7cf9..826012e 100644 --- a/backend/record_stream_cb.go +++ b/backend/record_stream_cb.go @@ -9,7 +9,6 @@ import ( "net/http" "net/url" "os" - "path/filepath" "strconv" "strings" "time" @@ -130,16 +129,18 @@ func RecordStream( wait = 8 * time.Second } - appLogln( - "⚠️ chaturbate hls refresh retry", - reason, - "attempt", - attempt, - "of", - maxAttempts, - "error:", - err, - ) + if verboseLogs() { + appLogln( + "⚠️ chaturbate hls refresh retry", + reason, + "attempt", + attempt, + "of", + maxAttempts, + "error:", + err, + ) + } select { case <-ctx.Done(): @@ -188,10 +189,12 @@ func RecordStream( updatePreviewFromStream(stream) - const maxPlaylistRefreshes = 30 + const maxPlaylistRefreshes = 10 refreshes := 0 attempt := 0 - useChunkMerge := strings.EqualFold(filepath.Ext(outputPath), ".ts") + // Der Segment-Recorder schreibt selbst fortlaufend in die Hauptdatei. + // Retry-Chunks würden nur .retry-Dateien im records-Ordner erzeugen. + useChunkMerge := false for { attempt++ @@ -258,10 +261,21 @@ func RecordStream( return appErrorf("cb refresh-limit erreicht nach %d versuchen: %w", refreshes-1, err) } + wait := 1500 * time.Millisecond + + // Wenn die Seite gerade keinen hls_source enthält, bringt schneller Spam nichts. + // Das passiert bei CB oft bei public/private-Flackern oder wenn der Room noch nicht + // vollständig initialisiert ist. + if strings.Contains(msg, "kein hls-quell-url") || + strings.Contains(msg, "room dossier nicht gefunden") || + strings.Contains(msg, "stream-parsing") { + wait = 10 * time.Second + } + select { case <-ctx.Done(): return ctx.Err() - case <-time.After(1500 * time.Millisecond): + case <-time.After(wait): } newStream, rerr := loadFreshHLSWithRetry(3, "after-ffmpeg-error") @@ -279,7 +293,7 @@ func RecordStream( } } -// ParseStream entspricht der DVR-Variante (roomDossier → hls_source) +// ParseStream (roomDossier → hls_source) func ParseStream(html string) (string, error) { matches := roomDossierRegexp.FindStringSubmatch(html) if len(matches) == 0 { diff --git a/backend/record_stream_hls.go b/backend/record_stream_hls.go index 6c2cbbe..1cef6a1 100644 --- a/backend/record_stream_hls.go +++ b/backend/record_stream_hls.go @@ -545,6 +545,271 @@ func hlsResolveURI(baseRaw string, refRaw string) (string, error) { return base.ResolveReference(ref).String(), nil } +func hlsWithoutBlockingReloadParams(rawURL string) string { + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + return "" + } + + u, err := url.Parse(rawURL) + if err != nil { + return rawURL + } + + q := u.Query() + q.Del("_HLS_msn") + q.Del("_HLS_part") + u.RawQuery = q.Encode() + + return u.String() +} + +func hlsWithBlockingReloadParams(rawURL string, msn int, part int) string { + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" || msn < 0 { + return rawURL + } + + u, err := url.Parse(rawURL) + if err != nil { + return rawURL + } + + q := u.Query() + q.Set("_HLS_msn", strconv.Itoa(msn)) + + if part >= 0 { + q.Set("_HLS_part", strconv.Itoa(part)) + } else { + q.Del("_HLS_part") + } + + u.RawQuery = q.Encode() + + return u.String() +} + +func hlsURLPathKey(rawURL string) string { + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + return "" + } + + u, err := url.Parse(rawURL) + if err != nil || u == nil { + return rawURL + } + + return strings.TrimSpace(u.Path) +} + +func hlsSamePlaylistPath(a string, b string) bool { + ak := hlsURLPathKey(a) + bk := hlsURLPathKey(b) + + return ak != "" && bk != "" && ak == bk +} + +func hlsParseChaturbateMediaSeqPart(uri string) (seq int, part int, isPart bool, ok bool) { + uri = strings.TrimSpace(uri) + if uri == "" { + return 0, 0, false, false + } + + if before, _, found := strings.Cut(uri, "?"); found { + uri = before + } + + if idx := strings.LastIndex(uri, "/"); idx >= 0 { + uri = uri[idx+1:] + } + + pieces := strings.Split(uri, "_") + for i, p := range pieces { + switch p { + case "seg": + // seg_6_4240_audio_... + if i+2 >= len(pieces) { + continue + } + + n, err := strconv.Atoi(strings.TrimSpace(pieces[i+2])) + if err != nil { + continue + } + + return n, -1, false, true + + case "part": + // part_6_4241_0_audio_... + if i+3 >= len(pieces) { + continue + } + + n, err1 := strconv.Atoi(strings.TrimSpace(pieces[i+2])) + pn, err2 := strconv.Atoi(strings.TrimSpace(pieces[i+3])) + if err1 != nil || err2 != nil { + continue + } + + return n, pn, true, true + } + } + + return 0, 0, false, false +} + +func hlsNextBlockingReloadParams(playlistText string) (msn int, part int, ok bool) { + maxSeg := -1 + maxPartSeq := -1 + maxPart := -1 + + for _, line := range strings.Split(playlistText, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + if strings.HasPrefix(line, "#EXT-X-PART:") { + uri := hlsAttrValue(line, "URI") + if seq, pn, isPart, ok := hlsParseChaturbateMediaSeqPart(uri); ok && isPart { + if seq > maxPartSeq || (seq == maxPartSeq && pn > maxPart) { + maxPartSeq = seq + maxPart = pn + } + } + continue + } + + if strings.HasPrefix(line, "#") { + continue + } + + if seq, _, isPart, ok := hlsParseChaturbateMediaSeqPart(line); ok && !isPart { + if seq > maxSeg { + maxSeg = seq + } + } + } + + // Wenn Parts sichtbar sind, beim nächsten Request nach dem nächsten Part fragen. + if maxPartSeq >= 0 && maxPart >= 0 { + return maxPartSeq, maxPart + 1, true + } + + // Sonst beim nächsten Media Sequence warten. + if maxSeg >= 0 { + return maxSeg + 1, 0, true + } + + return -1, -1, false +} + +func hlsRenditionReportPosition( + playlistText string, + basePlaylistURL string, + targetPlaylistURL string, +) (msn int, part int, ok bool) { + targetPlaylistURL = hlsWithoutBlockingReloadParams(targetPlaylistURL) + + for _, line := range strings.Split(playlistText, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "#EXT-X-RENDITION-REPORT:") { + continue + } + + attrText := strings.TrimPrefix(line, "#EXT-X-RENDITION-REPORT:") + attrs := parseHLSAttributeList(attrText) + + reportURI := strings.TrimSpace(attrs["URI"]) + if reportURI == "" { + continue + } + + reportURL, err := hlsResolveURI(basePlaylistURL, reportURI) + if err != nil { + continue + } + + reportURL = hlsWithoutBlockingReloadParams(reportURL) + + if !hlsSamePlaylistPath(reportURL, targetPlaylistURL) { + continue + } + + rawMSN := strings.TrimSpace(attrs["LAST-MSN"]) + if rawMSN == "" { + continue + } + + n, err := strconv.Atoi(rawMSN) + if err != nil { + continue + } + + pn := 0 + if rawPart := strings.TrimSpace(attrs["LAST-PART"]); rawPart != "" { + if v, err := strconv.Atoi(rawPart); err == nil { + pn = v + } + } + + return n, pn, true + } + + return -1, -1, false +} + +func hlsPrimeRenditionURLFromPlaylist( + ctx context.Context, + sourcePlaylistURL string, + targetPlaylistURL string, + httpCookie string, + userAgent string, + referer string, + logPrefix string, + kind string, +) string { + sourcePlaylistURL = strings.TrimSpace(sourcePlaylistURL) + targetPlaylistURL = strings.TrimSpace(targetPlaylistURL) + + if sourcePlaylistURL == "" || targetPlaylistURL == "" { + return targetPlaylistURL + } + + body, err := hlsGetBytesWithHeadersRetry( + ctx, + sourcePlaylistURL, + httpCookie, + userAgent, + referer, + 3, + ) + if err != nil { + if verboseLogs() { + appLogln("⚠️", logPrefix, "unable to prime", kind, "playlist:", err) + } + return targetPlaylistURL + } + + msn, part, ok := hlsRenditionReportPosition( + string(body), + sourcePlaylistURL, + targetPlaylistURL, + ) + if !ok { + return targetPlaylistURL + } + + primed := hlsWithBlockingReloadParams(targetPlaylistURL, msn, part) + + if verboseLogs() { + appLogln("🔁", logPrefix, "primed", kind, "playlist:", primed) + } + + return primed +} + func hlsGetBytesWithHeaders( ctx context.Context, rawURL string, @@ -616,6 +881,18 @@ func hlsGetBytesWithHeadersRetry( return nil, ctx.Err() } + msg := strings.ToLower(strings.TrimSpace(err.Error())) + + // 401/403/404/410 sind bei CB meistens kein kurzer Netzwerkhänger, + // sondern stale/ungültige Playlist-URL. Nicht 3x denselben URL hämmern, + // sondern direkt nach außen geben, damit RecordStream frische HLS-URLs holt. + if strings.Contains(msg, "http 401") || + strings.Contains(msg, "http 403") || + strings.Contains(msg, "http 404") || + strings.Contains(msg, "http 410") { + return nil, err + } + if i < attempts { select { case <-ctx.Done(): @@ -671,6 +948,141 @@ func streamLogPrefix(job *RecordJob, referer string, outFile string) string { return "[stream]" } +func streamSidecarPath(outFile string, suffix string, ext string) string { + outFile = strings.TrimSpace(outFile) + suffix = strings.Trim(strings.TrimSpace(suffix), ".") + ext = strings.TrimSpace(ext) + + if outFile == "" || suffix == "" { + return "" + } + if ext == "" { + ext = filepath.Ext(outFile) + } + if !strings.HasPrefix(ext, ".") { + ext = "." + ext + } + + dir := filepath.Dir(outFile) + base := strings.TrimSuffix(filepath.Base(outFile), filepath.Ext(outFile)) + + canonical := canonicalAssetIDFromName(base) + if canonical == "" { + canonical = base + } + + return filepath.Join(dir, "."+canonical+"."+suffix+ext) +} + +func muxAudioVideoSegmentFiles( + ctx context.Context, + videoPath string, + audioPath string, + outFile string, + logPrefix string, +) error { + videoPath = strings.TrimSpace(videoPath) + audioPath = strings.TrimSpace(audioPath) + outFile = strings.TrimSpace(outFile) + + if videoPath == "" || audioPath == "" || outFile == "" { + return appErrorf("%s mux paths empty", logPrefix) + } + if !fileExistsNonEmpty(videoPath) { + return appErrorf("%s mux video missing or empty", logPrefix) + } + if !fileExistsNonEmpty(audioPath) { + return appErrorf("%s mux audio missing or empty", logPrefix) + } + + ext := strings.ToLower(filepath.Ext(outFile)) + tmp := streamSidecarPath(outFile, "muxing", ext) + backup := streamSidecarPath(outFile, "video-original", ".bak") + + _ = removeWithRetry(tmp) + _ = removeWithRetry(backup) + + args := []string{ + "-y", + "-hide_banner", + "-loglevel", "error", + + "-fflags", "+genpts+discardcorrupt", + "-err_detect", "ignore_err", + + "-i", videoPath, + "-i", audioPath, + + "-map", "0:v:0", + "-map", "1:a:0", + "-dn", + "-ignore_unknown", + "-c", "copy", + } + + switch ext { + case ".ts": + args = append(args, "-f", "mpegts", tmp) + case ".mp4": + args = append(args, "-movflags", "+faststart", tmp) + default: + args = append(args, "-f", "mpegts", tmp) + } + + cmd := exec.CommandContext(ctx, ffmpegPath, args...) + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: 0x08000000, + } + + var stderr bytes.Buffer + cmd.Stdout = io.Discard + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + _ = removeWithRetry(tmp) + return appErrorf("%s mux failed: %w (%s)", logPrefix, err, strings.TrimSpace(stderr.String())) + } + + if err := requireNonEmptyRegularFile(tmp, "mux result"); err != nil { + _ = removeWithRetry(tmp) + return err + } + + sameOutputAsVideo := filepath.Clean(videoPath) == filepath.Clean(outFile) + + if sameOutputAsVideo { + // Recording-Fall: + // outFile ist die laufende Video-Datei selbst, also sicher ersetzen. + if err := os.Rename(outFile, backup); err != nil { + _ = removeWithRetry(tmp) + return appErrorf("%s mux backup rename failed: %w", logPrefix, err) + } + + if err := os.Rename(tmp, outFile); err != nil { + _ = os.Rename(backup, outFile) + _ = removeWithRetry(tmp) + return appErrorf("%s mux final rename failed: %w", logPrefix, err) + } + + _ = removeWithRetry(backup) + } else { + // Postwork-Fall: + // videoPath ist z.B. records/foo.ts, outFile ist eine neue temp/foo.mp4. + // Hier darf outFile noch nicht existieren. + _ = removeWithRetry(outFile) + + if err := os.Rename(tmp, outFile); err != nil { + _ = removeWithRetry(tmp) + return appErrorf("%s mux final rename failed: %w", logPrefix, err) + } + } + + _ = removeWithRetry(audioPath) + + return nil +} + func recordHLSPlaylistSegments( ctx context.Context, stream *selectedHLSStream, @@ -686,23 +1098,198 @@ func recordHLSPlaylistSegments( logPrefix := streamLogPrefix(job, referer, outFile) - mediaURL := strings.TrimSpace(stream.VideoURL) - if mediaURL == "" { - return appErrorf("%s media playlist url empty", logPrefix) + videoURL := strings.TrimSpace(stream.VideoURL) + audioURL := strings.TrimSpace(stream.AudioURL) + + if videoURL == "" { + return appErrorf("%s video media playlist url empty", logPrefix) } - outFile = strings.TrimSpace(outFile) - if outFile == "" { - return appErrorf("%s output path empty", logPrefix) + // Chaturbate LL-HLS: + // Die Website lädt Audio/Video-Playlists oft mit _HLS_msn/_HLS_part. + // Die rohen chunklist_...m3u8 URLs ohne diese Parameter liefern bei dir häufig 403. + // Deshalb primen wir die Audio-URL aus den RENDITION-REPORTs der Video-Playlist. + if audioURL != "" { + audioURL = hlsPrimeRenditionURLFromPlaylist( + ctx, + videoURL, + audioURL, + httpCookie, + userAgent, + referer, + logPrefix, + "audio", + ) } + // Kein separates Audio vorhanden: bisheriges Verhalten. + if audioURL == "" { + return recordHLSMediaPlaylistToFile( + ctx, + videoURL, + outFile, + job, + httpCookie, + userAgent, + referer, + logPrefix, + "video", + ) + } + + // Separates Audio vorhanden: Video in Hauptdatei, Audio in Sidecar-Datei. + audioOut := streamSidecarPath(outFile, "audio", ".m4s") + if audioOut == "" { + return appErrorf("%s audio sidecar path empty", logPrefix) + } + + if verboseLogs() { + appLogln("🎥", logPrefix, "recording separate video/audio playlists") + appLogln("🎥", logPrefix, "video playlist:", videoURL) + appLogln("🎧", logPrefix, "audio playlist:", audioURL) + } + + recCtx, cancel := context.WithCancel(ctx) + defer cancel() + + errCh := make(chan error, 2) + + go func() { + errCh <- recordHLSMediaPlaylistToFile( + recCtx, + videoURL, + outFile, + job, + httpCookie, + userAgent, + referer, + logPrefix, + "video", + ) + }() + + go func() { + errCh <- recordHLSMediaPlaylistToFile( + recCtx, + audioURL, + audioOut, + nil, + httpCookie, + userAgent, + referer, + logPrefix, + "audio", + ) + }() + + firstErr := <-errCh + cancel() + secondErr := <-errCh + + parentCanceled := ctx.Err() != nil + endOfStream := errors.Is(firstErr, io.EOF) || errors.Is(secondErr, io.EOF) + + // Wenn einer der beiden Streams wegen Fehler endet, canceln wir den anderen. + // Dessen "context canceled" ist dann nur Folgefehler und soll nicht das Log aufblasen. + finalErr := firstErr + if errors.Is(finalErr, context.Canceled) && !parentCanceled { + finalErr = secondErr + } + if errors.Is(secondErr, context.Canceled) && !parentCanceled { + secondErr = nil + } + + // Falls firstErr nil/context-canceled ist, aber secondErr der echte Fehler war. + if finalErr == nil || (errors.Is(finalErr, context.Canceled) && !parentCanceled) { + finalErr = secondErr + } + + shouldFinalize := parentCanceled || endOfStream || finalErr == nil + + if shouldFinalize { + if !fileExistsNonEmpty(outFile) { + if finalErr != nil { + return finalErr + } + return appErrorf("%s video output missing or empty", logPrefix) + } + + if !fileExistsNonEmpty(audioOut) { + if finalErr != nil { + return appErrorf("%s audio missing/empty; refusing video-only file: %w", logPrefix, finalErr) + } + return appErrorf("%s audio missing/empty; refusing video-only file", logPrefix) + } + + muxCtx, muxCancel := context.WithTimeout(context.Background(), 5*time.Minute) + muxErr := muxAudioVideoSegmentFiles(muxCtx, outFile, audioOut, outFile, logPrefix) + muxCancel() + + if muxErr != nil { + return appErrorf("%s audio/video mux failed; refusing video-only file: %w", logPrefix, muxErr) + } + + // Sicherheitscheck: Nach dem Mux muss wirklich Audio vorhanden sein. + if !videoHasAudioStream(context.Background(), outFile) { + return appErrorf("%s mux result has no audio stream; refusing video-only file", logPrefix) + } + + if job != nil { + if fi, statErr := os.Stat(outFile); statErr == nil && fi != nil && !fi.IsDir() { + jobsMu.Lock() + job.SizeBytes = fi.Size() + jobsMu.Unlock() + _ = publishJobSnapshot(job.ID) + } + } + + if verboseLogs() { + appLogln("✅", logPrefix, "audio/video mux succeeded:", filepath.Base(outFile)) + } + + if parentCanceled { + return ctx.Err() + } + if endOfStream { + return nil + } + } + + return finalErr +} + +func recordHLSMediaPlaylistToFile( + ctx context.Context, + mediaURL string, + outFile string, + job *RecordJob, + httpCookie string, + userAgent string, + referer string, + logPrefix string, + kind string, +) error { + mediaURL = strings.TrimSpace(mediaURL) + outFile = strings.TrimSpace(outFile) + kind = strings.TrimSpace(kind) + + if mediaURL == "" { + return appErrorf("%s %s playlist url empty", logPrefix, kind) + } + if outFile == "" { + return appErrorf("%s %s output path empty", logPrefix, kind) + } + + baseMediaURL := hlsWithoutBlockingReloadParams(mediaURL) + requestURL := mediaURL + if err := os.MkdirAll(filepath.Dir(outFile), 0o755); err != nil { - return appErrorf("%s mkdir failed: %w", logPrefix, err) + return appErrorf("%s %s mkdir failed: %w", logPrefix, kind, 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) + return appErrorf("%s %s open output failed: %w", logPrefix, kind, err) } defer f.Close() @@ -712,7 +1299,7 @@ func recordHLSPlaylistSegments( lastNewSegmentAt := time.Now() if verboseLogs() { - appLogln("🎥", logPrefix, "recording media playlist:", mediaURL) + appLogln("🎥", logPrefix, "recording", kind, "media playlist:", mediaURL) } for { @@ -720,19 +1307,24 @@ func recordHLSPlaylistSegments( return err } - body, err := hlsGetBytesWithHeaders(ctx, mediaURL, httpCookie, userAgent, referer) + body, err := hlsGetBytesWithHeadersRetry(ctx, requestURL, httpCookie, userAgent, referer, 3) if err != nil { - return appErrorf("%s get playlist: %w", logPrefix, err) + return appErrorf("%s %s get playlist: %w", logPrefix, kind, err) } playlistText := string(body) + if nextMSN, nextPart, ok := hlsNextBlockingReloadParams(playlistText); ok { + requestURL = hlsWithBlockingReloadParams(baseMediaURL, nextMSN, nextPart) + } else { + requestURL = baseMediaURL + } + if strings.Contains(playlistText, "#EXT-X-STREAM-INF") { - return appErrorf("%s expected media playlist, got master playlist", logPrefix) + return appErrorf("%s %s expected media playlist, got master playlist", logPrefix, kind) } segmentURIs := hlsMediaSegmentURIsFromText(playlistText) - if len(segmentURIs) == 0 { segmentURIs = hlsPartURIsFromText(playlistText) } @@ -744,13 +1336,13 @@ func recordHLSPlaylistSegments( if mapURI != "" { mapURL, err := hlsResolveURI(mediaURL, mapURI) if err != nil { - return appErrorf("%s resolve map uri: %w", logPrefix, err) + return appErrorf("%s %s resolve map uri: %w", logPrefix, kind, err) } if _, ok := writtenMaps[mapURL]; !ok { initBytes, err := hlsGetBytesWithHeadersRetry(ctx, mapURL, httpCookie, userAgent, referer, 3) if err != nil { - appLogln("⚠️", logPrefix, "init segment failed:", err) + appLogln("⚠️", logPrefix, kind, "init segment failed:", err) select { case <-ctx.Done(): @@ -762,7 +1354,7 @@ func recordHLSPlaylistSegments( } if err := writeStreamBytesAndPublish(f, job, initBytes); err != nil { - return appErrorf("%s write init: %w", logPrefix, err) + return appErrorf("%s %s write init: %w", logPrefix, kind, err) } writtenMaps[mapURL] = struct{}{} @@ -779,7 +1371,7 @@ func recordHLSPlaylistSegments( segmentURL, err := hlsResolveURI(mediaURL, uri) if err != nil { - appLogln("⚠️", logPrefix, "resolve segment failed:", uri, err) + appLogln("⚠️", logPrefix, kind, "resolve segment failed:", uri, err) continue } @@ -789,12 +1381,12 @@ func recordHLSPlaylistSegments( segBytes, err := hlsGetBytesWithHeadersRetry(ctx, segmentURL, httpCookie, userAgent, referer, 3) if err != nil { - appLogln("⚠️", logPrefix, "segment failed:", filepath.Base(uri), err) + appLogln("⚠️", logPrefix, kind, "segment failed:", filepath.Base(uri), err) continue } if err := writeStreamBytesAndPublish(f, job, segBytes); err != nil { - return appErrorf("%s write segment: %w", logPrefix, err) + return appErrorf("%s %s write segment: %w", logPrefix, kind, err) } seen[segmentURL] = struct{}{} @@ -944,7 +1536,16 @@ func handleM3U8Mode( args = append(args, "-i", inputURL, "-map", "0:v:0", - "-map", "0:a:0?", + ) + + if isChaturbate && stream.HasSeparateAudio { + // Bei CB mit separater Audio-Playlist darf keine stumme Datei entstehen. + args = append(args, "-map", "0:a:0") + } else { + args = append(args, "-map", "0:a:0?") + } + + args = append(args, "-dn", "-ignore_unknown", "-c", "copy", diff --git a/backend/recorder.go b/backend/recorder.go index 40718c5..dd0d409 100644 --- a/backend/recorder.go +++ b/backend/recorder.go @@ -448,6 +448,17 @@ func isLikelyNaturalStreamEndError(err error) bool { s := strings.ToLower(strings.TrimSpace(err.Error())) + // Wichtig: Wenn Audio Pflicht war und fehlt/fehlschlägt, + // darf das NICHT als normales Stream-Ende gelten. + if strings.Contains(s, "audio") && + (strings.Contains(s, "403") || + strings.Contains(s, "forbidden") || + strings.Contains(s, "no such stream") || + strings.Contains(s, "matches no streams") || + strings.Contains(s, "stream specifier")) { + return false + } + return strings.Contains(s, "eof") || strings.Contains(s, "unexpected eof") || strings.Contains(s, "stream ended") || @@ -726,6 +737,48 @@ func buildPostworkTempMP4Path(tsPath string) (string, error) { return uniqueDestPath(tmpDir, base) } +func findPostworkAudioSidecarForTS(tsPath string) (string, bool) { + tsPath = strings.TrimSpace(tsPath) + if tsPath == "" { + return "", false + } + + candidates := []string{} + + if p := strings.TrimSpace(streamSidecarPath(tsPath, "audio", ".m4s")); p != "" { + candidates = append(candidates, p) + } + + dir := filepath.Dir(tsPath) + base := strings.TrimSuffix(filepath.Base(tsPath), filepath.Ext(tsPath)) + candidates = append(candidates, + filepath.Join(dir, "."+base+".audio.m4s"), + filepath.Join(dir, "."+canonicalAssetIDFromName(base)+".audio.m4s"), + ) + + seen := map[string]bool{} + + for _, p := range candidates { + p = strings.TrimSpace(p) + if p == "" { + continue + } + + cp := filepath.Clean(p) + if seen[cp] { + continue + } + seen[cp] = true + + fi, err := os.Stat(cp) + if err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 { + return cp, true + } + } + + return "", false +} + func runPrimaryPostworkPipeline( ctx context.Context, job *RecordJob, @@ -761,43 +814,85 @@ func runPrimaryPostworkPipeline( } _ = removeWithRetry(mp4Path) - dur := 0.0 - dctx, cancel := context.WithTimeout(ctx, 6*time.Second) - if d, derr := durationSecondsCached(dctx, tsPath); derr == nil && d > 0 { - dur = d - } - cancel() + audioSidecar, hasAudioSidecar := findPostworkAudioSidecarForTS(tsPath) - err = remuxTSToMP4WithProgress( - ctx, - tsPath, - mp4Path, - dur, - inSize, - func(r float64) { - if setPhase == nil { - return - } - if r < 0 { - r = 0 - } - if r > 1 { - r = 1 - } - setPhase("remuxing", int(math.Round(r*100))) - }, - ) + if hasAudioSidecar { + if setPhase != nil { + setPhase("remuxing", 15) + } - if err != nil { - _ = removeWithRetry(mp4Path) - return tsPath, appErrorf("ts remux failed: %w", err) + logPrefix := "[" + strings.TrimSpace(modelNameFromFilename(strings.TrimSuffix(filepath.Base(tsPath), filepath.Ext(tsPath)))) + "]" + if strings.TrimSpace(logPrefix) == "[]" { + logPrefix = "[postwork]" + } + + appLogln( + "🎧", + logPrefix, + "found audio sidecar, muxing TS+audio to MP4:", + filepath.Base(tsPath), + "+", + filepath.Base(audioSidecar), + ) + + muxCtx, muxCancel := context.WithTimeout(ctx, 10*time.Minute) + err = muxAudioVideoSegmentFiles(muxCtx, tsPath, audioSidecar, mp4Path, logPrefix) + muxCancel() + + if err != nil { + _ = removeWithRetry(mp4Path) + return tsPath, appErrorf("ts+audio mux failed: %w", err) + } + + if setPhase != nil { + setPhase("remuxing", 100) + } + } else { + dur := 0.0 + dctx, cancel := context.WithTimeout(ctx, 6*time.Second) + if d, derr := durationSecondsCached(dctx, tsPath); derr == nil && d > 0 { + dur = d + } + cancel() + + err = remuxTSToMP4WithProgress( + ctx, + tsPath, + mp4Path, + dur, + inSize, + func(r float64) { + if setPhase == nil { + return + } + if r < 0 { + r = 0 + } + if r > 1 { + r = 1 + } + setPhase("remuxing", int(math.Round(r*100))) + }, + ) + + if err != nil { + _ = removeWithRetry(mp4Path) + return tsPath, appErrorf("ts remux failed: %w", err) + } } - if err := requireNonEmptyRegularFile(mp4Path, "remux result"); err != nil { + if err := requireNonEmptyRegularFile(mp4Path, "remux/mux result"); err != nil { _ = removeWithRetry(mp4Path) return tsPath, err } + if hasAudioSidecar || (job != nil && detectProvider(job.SourceURL) == "chaturbate") { + if !videoHasAudioStream(ctx, mp4Path) { + _ = removeWithRetry(mp4Path) + return tsPath, appErrorf("remux/mux result has no audio stream; refusing silent MP4") + } + } + if err := validateVideoDecodesNearEnd(ctx, mp4Path); err != nil { appLogln("⚠️ remux mp4 decode validation failed; keeping remux result without repair:", err) } diff --git a/backend/server.go b/backend/server.go index 0d2c494..c649442 100644 --- a/backend/server.go +++ b/backend/server.go @@ -682,6 +682,8 @@ func main() { startPostWorkStatusRefresher() startEnrichStatusRefresher() + startPostworkLeftoverScanOnStartup() + go startGeneratedGarbageCollector() // Altes NSFW-ONNX nicht mehr hart initialisieren. diff --git a/backend/startup_leftovers.go b/backend/startup_leftovers.go new file mode 100644 index 0000000..35e72a7 --- /dev/null +++ b/backend/startup_leftovers.go @@ -0,0 +1,285 @@ +// backend\startup_leftovers.go + +package main + +import ( + "os" + "path/filepath" + "strings" + "time" + + "github.com/google/uuid" +) + +const startupLeftoverMinAge = 60 * time.Second + +func startPostworkLeftoverScanOnStartup() { + go func() { + // Kurz warten, bis Settings, Queues und UI/SSE bereit sind. + time.Sleep(8 * time.Second) + + queued, skipped := enqueuePostworkLeftoversFromRecordDir() + if queued > 0 || skipped > 0 { + appLogln( + "🧩 [startup-postwork] leftover scan fertig:", + "queued=", queued, + "skipped=", skipped, + ) + } + }() +} + +func enqueuePostworkLeftoversFromRecordDir() (queued int, skipped int) { + s := getSettings() + + recordAbs, err := resolvePathRelativeToApp(s.RecordDir) + if err != nil || strings.TrimSpace(recordAbs) == "" { + recordAbs = strings.TrimSpace(s.RecordDir) + } + recordAbs = strings.TrimSpace(recordAbs) + if recordAbs == "" { + appLogln("⚠️ [startup-postwork] recordDir ist leer") + return 0, 0 + } + + entries, err := os.ReadDir(recordAbs) + if err != nil { + appLogln("⚠️ [startup-postwork] recordDir lesen fehlgeschlagen:", err) + return 0, 0 + } + + now := time.Now() + + skip := func(name string, reason string, extra ...any) { + skipped++ + + args := []any{ + "⏭️ [startup-postwork] skip:", + name, + "reason:", + reason, + } + args = append(args, extra...) + appLogln(args...) + } + + for _, e := range entries { + if e == nil || e.IsDir() { + continue + } + + name := strings.TrimSpace(e.Name()) + if name == "" { + continue + } + + if strings.HasPrefix(name, ".") { + continue + } + + if !strings.EqualFold(filepath.Ext(name), ".ts") { + continue + } + + tsPath := filepath.Join(recordAbs, name) + + tsInfo, err := os.Stat(tsPath) + if err != nil { + skip(name, "ts stat failed", err) + continue + } + if tsInfo == nil || tsInfo.IsDir() { + skip(name, "ts invalid") + continue + } + if tsInfo.Size() <= 0 { + skip(name, "ts empty") + continue + } + + tsAge := now.Sub(tsInfo.ModTime()) + if tsAge < startupLeftoverMinAge { + skip( + name, + "ts too fresh", + "age=", tsAge.Round(time.Second), + "min=", startupLeftoverMinAge, + ) + continue + } + + audioPath, ok := findPostworkAudioSidecarForTS(tsPath) + if !ok { + skip(name, "matching audio sidecar not found") + continue + } + + audioInfo, err := os.Stat(audioPath) + if err != nil { + skip(name, "audio stat failed", filepath.Base(audioPath), err) + continue + } + if audioInfo == nil || audioInfo.IsDir() { + skip(name, "audio invalid", filepath.Base(audioPath)) + continue + } + if audioInfo.Size() <= 0 { + skip(name, "audio empty", filepath.Base(audioPath)) + continue + } + + audioAge := now.Sub(audioInfo.ModTime()) + if audioAge < startupLeftoverMinAge { + skip( + name, + "audio too fresh", + filepath.Base(audioPath), + "age=", audioAge.Round(time.Second), + "min=", startupLeftoverMinAge, + ) + continue + } + + if postworkLeftoverAlreadyTracked(tsPath) { + skip(name, "already tracked as job") + continue + } + + if postworkLeftoverDoneMP4Exists(tsPath) { + skip(name, "done mp4 already exists") + continue + } + + if !postworkLeftoverFilesStable(tsPath, audioPath) { + skip(name, "files not stable", filepath.Base(audioPath)) + continue + } + + if enqueuePostworkLeftover(tsPath, tsInfo) { + queued++ + } else { + skip(name, "enqueue failed") + } + } + + return queued, skipped +} + +func postworkLeftoverAlreadyTracked(tsPath string) bool { + tsPath = filepath.Clean(strings.TrimSpace(tsPath)) + if tsPath == "" { + return false + } + + jobsMu.RLock() + defer jobsMu.RUnlock() + + for _, j := range jobs { + if j == nil { + continue + } + + out := filepath.Clean(strings.TrimSpace(j.Output)) + if out != "" && strings.EqualFold(out, tsPath) { + return true + } + } + + return false +} + +func postworkLeftoverDoneMP4Exists(tsPath string) bool { + tsPath = strings.TrimSpace(tsPath) + if tsPath == "" { + return false + } + + s := getSettings() + + doneAbs, err := resolvePathRelativeToApp(s.DoneDir) + if err != nil || strings.TrimSpace(doneAbs) == "" { + doneAbs = strings.TrimSpace(s.DoneDir) + } + doneAbs = strings.TrimSpace(doneAbs) + if doneAbs == "" { + return false + } + + base := strings.TrimSuffix(filepath.Base(tsPath), filepath.Ext(tsPath)) + ".mp4" + donePath := filepath.Join(doneAbs, base) + + fi, err := os.Stat(donePath) + return err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 +} + +func postworkLeftoverFilesStable(tsPath string, audioPath string) bool { + tsPath = strings.TrimSpace(tsPath) + audioPath = strings.TrimSpace(audioPath) + if tsPath == "" || audioPath == "" { + return false + } + + sizeOf := func(p string) (int64, bool) { + fi, err := os.Stat(p) + if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { + return 0, false + } + return fi.Size(), true + } + + ts1, ok1 := sizeOf(tsPath) + audio1, ok2 := sizeOf(audioPath) + if !ok1 || !ok2 { + return false + } + + time.Sleep(1500 * time.Millisecond) + + ts2, ok1 := sizeOf(tsPath) + audio2, ok2 := sizeOf(audioPath) + if !ok1 || !ok2 { + return false + } + + return ts1 == ts2 && audio1 == audio2 +} + +func enqueuePostworkLeftover(tsPath string, tsInfo os.FileInfo) bool { + tsPath = strings.TrimSpace(tsPath) + if tsPath == "" || tsInfo == nil { + return false + } + + now := time.Now() + startedAt := tsInfo.ModTime() + if startedAt.IsZero() { + startedAt = now + } + + job := &RecordJob{ + ID: uuid.NewString(), + SourceURL: "", + Status: JobFinished, + StartedAt: startedAt, + StartedAtMs: startedAt.UnixMilli(), + EndedAt: &now, + EndedAtMs: now.UnixMilli(), + Output: tsPath, + Phase: "postwork", + Progress: 0, + SizeBytes: tsInfo.Size(), + Hidden: false, + } + + jobsMu.Lock() + jobs[job.ID] = job + jobsMu.Unlock() + + appLogln( + "🧩 [startup-postwork] queued leftover:", + filepath.Base(tsPath), + ) + + enqueuePostworkOrFail(job, tsPath, JobFinished) + return true +} diff --git a/backend/tasks_assets.go b/backend/tasks_assets.go index a2a7e93..5525592 100644 --- a/backend/tasks_assets.go +++ b/backend/tasks_assets.go @@ -861,6 +861,7 @@ func runGenerateMissingAssetsJob(jobID string, ctx context.Context) { } if terr != nil { + appLogln("❌ teaser generation fehlgeschlagen für", it.path+":", terr) publishAssetsTaskPhase(it.name, "postwork", "teaser", "error", "Teaser fehlgeschlagen") } else if assetsTaskTruthForVideo(id, it.path).TeaserReady { if genTeaser { @@ -868,6 +869,7 @@ func runGenerateMissingAssetsJob(jobID string, ctx context.Context) { } publishAssetsTaskPhase(it.name, "postwork", "teaser", "done", "Teaser") } else { + appLogln("❌ teaser generation fehlgeschlagen für", it.path+":", "preview.mp4 fehlt oder ist leer") publishAssetsTaskPhase(it.name, "postwork", "teaser", "error", "Teaser fehlgeschlagen") } } diff --git a/backend/tasks_cleanup.go b/backend/tasks_cleanup.go index a24822c..0f375ad 100644 --- a/backend/tasks_cleanup.go +++ b/backend/tasks_cleanup.go @@ -25,13 +25,15 @@ type cleanupCandidate struct { type cleanupResp struct { // 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"` + ScannedFiles int `json:"scannedFiles"` + DeletedFiles int `json:"deletedFiles"` + DeletedBrokenFiles int `json:"deletedBrokenFiles"` + SkippedFiles int `json:"skippedFiles"` + DeletedBytes int64 `json:"deletedBytes"` + DeletedBytesHuman string `json:"deletedBytesHuman"` + DeletedRecordOrphans int `json:"deletedRecordOrphans"` + DeletedRecordOrphanBytes int64 `json:"deletedRecordOrphanBytes"` + ErrorCount int `json:"errorCount"` // Generated-GC GeneratedOrphansChecked int `json:"generatedOrphansChecked"` @@ -149,6 +151,11 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) { return } + recordAbs, err := resolvePathRelativeToApp(s.RecordDir) + if err != nil || strings.TrimSpace(recordAbs) == "" { + recordAbs = strings.TrimSpace(s.RecordDir) + } + mb := int(s.AutoDeleteSmallDownloadsBelowMB) var req cleanupReq @@ -188,7 +195,7 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) { publishTaskState() - go func(jobID string, ctx context.Context, doneAbs string, mb int) { + go func(jobID string, ctx context.Context, doneAbs string, recordAbs string, mb int) { if err := acquireExclusiveTask(ctx); err != nil { finishCleanupJob(jobID, "", err) return @@ -212,6 +219,11 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) { } } + if err := cleanupRecordDirOrphanAVFiles(ctx, jobID, recordAbs, &resp); err != nil { + finishCleanupJob(jobID, "", err) + return + } + select { case <-ctx.Done(): finishCleanupJob(jobID, "", ctx.Err()) @@ -237,7 +249,7 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) { cleanupProgressText(resp.DeletedFiles, resp.DeletedBrokenFiles, resp.ScannedFiles, resp.DeletedBytes), nil, ) - }(jobID, ctx, doneAbs, mb) + }(jobID, ctx, doneAbs, recordAbs, mb) writeJSON(w, http.StatusOK, st) return @@ -337,6 +349,212 @@ func cleanupVideoLooksBroken(ctx context.Context, path string) (bool, string) { return false, "" } +const cleanupRecordOrphanMinAge = 60 * time.Second + +func cleanupRecordFileIsActive(path string) bool { + path = filepath.Clean(strings.TrimSpace(path)) + if path == "" { + return false + } + + jobsMu.RLock() + defer jobsMu.RUnlock() + + for _, j := range jobs { + if j == nil { + continue + } + + out := filepath.Clean(strings.TrimSpace(j.Output)) + if out != "" && strings.EqualFold(out, path) { + return true + } + + for _, sidecar := range postworkAudioSidecarCandidatesForOutput(out) { + if sidecar != "" && strings.EqualFold(filepath.Clean(sidecar), path) { + return true + } + } + } + + return false +} + +func cleanupFileStable(path string) bool { + path = strings.TrimSpace(path) + if path == "" { + return false + } + + fi1, err := os.Stat(path) + if err != nil || fi1 == nil || fi1.IsDir() || fi1.Size() <= 0 { + return false + } + + time.Sleep(1200 * time.Millisecond) + + fi2, err := os.Stat(path) + if err != nil || fi2 == nil || fi2.IsDir() || fi2.Size() <= 0 { + return false + } + + return fi1.Size() == fi2.Size() +} + +func cleanupTSPathForAudioSidecar(audioPath string) string { + audioPath = strings.TrimSpace(audioPath) + if audioPath == "" { + return "" + } + + dir := filepath.Dir(audioPath) + name := filepath.Base(audioPath) + + if !strings.HasPrefix(name, ".") { + return "" + } + if !strings.HasSuffix(strings.ToLower(name), ".audio.m4s") { + return "" + } + + base := strings.TrimPrefix(name, ".") + base = strings.TrimSuffix(base, ".audio.m4s") + + if strings.TrimSpace(base) == "" { + return "" + } + + return filepath.Join(dir, base+".ts") +} + +func cleanupDeleteRecordOrphanFile(ctx context.Context, jobID string, path string, resp *cleanupResp, reason string) { + if resp == nil { + return + } + + select { + case <-ctx.Done(): + return + default: + } + + path = strings.TrimSpace(path) + if path == "" { + return + } + + fi, err := os.Stat(path) + if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { + return + } + + if time.Since(fi.ModTime()) < cleanupRecordOrphanMinAge { + resp.SkippedFiles++ + return + } + + if cleanupRecordFileIsActive(path) { + resp.SkippedFiles++ + return + } + + if !cleanupFileStable(path) { + resp.SkippedFiles++ + return + } + + updateCleanupJobState(jobID, func(st *CleanupTaskState) { + st.CurrentFile = filepath.Base(path) + st.Text = "Räume verwaiste Record-Dateien auf…" + }) + + if err := removeWithRetry(path); err != nil && !os.IsNotExist(err) { + resp.ErrorCount++ + appLogln("⚠️ cleanup record orphan konnte nicht gelöscht werden:", filepath.Base(path), reason, err) + return + } + + resp.DeletedFiles++ + resp.DeletedRecordOrphans++ + resp.DeletedBytes += fi.Size() + resp.DeletedRecordOrphanBytes += fi.Size() + + purgeDurationCacheForPath(path) + + base := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + base = strings.TrimPrefix(base, ".") + base = strings.TrimSuffix(base, ".audio") + + if strings.TrimSpace(base) != "" { + removeGeneratedForID(stripHotPrefix(base)) + } + + appLogln("🧹 cleanup deleted record orphan:", filepath.Base(path), "reason:", reason) +} + +func cleanupRecordDirOrphanAVFiles(ctx context.Context, jobID string, recordAbs string, resp *cleanupResp) error { + recordAbs = strings.TrimSpace(recordAbs) + if recordAbs == "" || resp == nil { + return nil + } + + entries, err := os.ReadDir(recordAbs) + if err != nil { + return nil + } + + for _, e := range entries { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + if e == nil || e.IsDir() { + continue + } + + name := strings.TrimSpace(e.Name()) + if name == "" { + continue + } + + full := filepath.Join(recordAbs, name) + low := strings.ToLower(name) + + // Fall 1: + // foo.ts existiert, aber .foo.audio.m4s fehlt. + if !strings.HasPrefix(name, ".") && strings.EqualFold(filepath.Ext(name), ".ts") { + resp.ScannedFiles++ + + if _, ok := findPostworkAudioSidecarForTS(full); ok { + continue + } + + cleanupDeleteRecordOrphanFile(ctx, jobID, full, resp, "missing audio sidecar") + continue + } + + // Fall 2: + // .foo.audio.m4s existiert, aber foo.ts fehlt. + if strings.HasPrefix(name, ".") && strings.HasSuffix(low, ".audio.m4s") { + resp.ScannedFiles++ + + tsPath := cleanupTSPathForAudioSidecar(full) + if strings.TrimSpace(tsPath) != "" { + if fi, err := os.Stat(tsPath); err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 { + continue + } + } + + cleanupDeleteRecordOrphanFile(ctx, jobID, full, resp, "missing ts file") + continue + } + } + + return nil +} + func cleanupSmallFiles(ctx context.Context, jobID string, doneAbs string, threshold int64, resp *cleanupResp) error { isCandidate := func(name string) bool { low := strings.ToLower(name) diff --git a/backend/teaser.go b/backend/teaser.go index 01cec9e..c12be03 100644 --- a/backend/teaser.go +++ b/backend/teaser.go @@ -105,13 +105,97 @@ func generateTeaserClipsMP4WithProgress( return generateTeaserPreviewMP4WithProgress(ctx, srcPath, outPath, opts, onRatio) } +func videoHasAudioStream(ctx context.Context, srcPath string) bool { + srcPath = strings.TrimSpace(srcPath) + if srcPath == "" { + return false + } + + probeCtx, cancel := context.WithTimeout(ctx, 8*time.Second) + defer cancel() + + cmd := exec.CommandContext( + probeCtx, + ffprobePath, + "-v", "error", + "-select_streams", "a:0", + "-show_entries", "stream=index", + "-of", "csv=p=0", + srcPath, + ) + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: 0x08000000, + } + + out, err := cmd.Output() + if err != nil { + return false + } + + return strings.TrimSpace(string(out)) != "" +} + +func teaserMissingAudioError(msg string) bool { + msg = strings.ToLower(strings.TrimSpace(msg)) + if msg == "" { + return false + } + + return strings.Contains(msg, "stream specifier ':a'") || + strings.Contains(msg, "matches no streams") || + strings.Contains(msg, "no such stream") || + (strings.Contains(msg, "stream specifier") && strings.Contains(msg, ":a")) +} + +func logTeaserGenerationFailure(srcPath string, outPath string, err error) { + if err == nil { + return + } + + srcPath = strings.TrimSpace(srcPath) + outPath = strings.TrimSpace(outPath) + + srcName := filepath.Base(srcPath) + if strings.TrimSpace(srcName) == "" || srcName == "." { + srcName = srcPath + } + + outName := filepath.Base(outPath) + if strings.TrimSpace(outName) == "" || outName == "." { + outName = outPath + } + + appLogln( + "❌ teaser generation fehlgeschlagen:", + srcName, + "->", + outName, + "error:", + err, + ) +} + +func logTeaserGenerationFallback(srcPath string, reason error) { + if reason == nil { + return + } + + appLogln( + "⚠️ teaser generation fallback ohne Audio:", + filepath.Base(strings.TrimSpace(srcPath)), + "reason:", + reason, + ) +} + func generateTeaserChunkMP4( ctx context.Context, src, out string, start, dur float64, opts TeaserPreviewOptions, ) error { - opts.Audio = true + hasAudio := true tmp := strings.TrimSuffix(out, ".mp4") + ".part.mp4" segDur := dur @@ -139,28 +223,39 @@ func generateTeaserChunkMP4( args = append(args, "-vsync", "2") } - args = append(args, - "-map", "0:a:0", - "-c:a", "aac", - "-b:a", opts.AudioBitrate, - "-ac", "2", - "-shortest", - ) + if hasAudio { + args = append(args, + "-map", "0:a:0", + "-c:a", "aac", + "-b:a", opts.AudioBitrate, + "-ac", "2", + "-shortest", + ) + } else { + args = append(args, "-an") + } args = append(args, "-movflags", "+faststart", tmp) cmd := exec.CommandContext(ctx, ffmpegPath, args...) cmd.SysProcAttr = &syscall.SysProcAttr{ HideWindow: true, - CreationFlags: 0x08000000, // CREATE_NO_WINDOW + CreationFlags: 0x08000000, } var stderr bytes.Buffer cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { _ = os.Remove(tmp) - return appErrorf("ffmpeg teaser chunk failed: %v (%s)", err, strings.TrimSpace(stderr.String())) + + msg := strings.TrimSpace(stderr.String()) + wrappedErr := appErrorf("ffmpeg teaser chunk failed: %v (%s)", err, msg) + + logTeaserGenerationFailure(src, out, wrappedErr) + return wrappedErr } + _ = os.Remove(out) return os.Rename(tmp, out) } @@ -211,7 +306,6 @@ func generateTeaserPreviewMP4WithProgress( opts TeaserPreviewOptions, onRatio func(r float64), ) error { - opts.Audio = true if opts.SegmentDuration <= 0 { opts.SegmentDuration = 1 } @@ -230,6 +324,7 @@ func generateTeaserPreviewMP4WithProgress( if opts.AudioBitrate == "" { opts.AudioBitrate = "128k" } + hasAudio := true segDur := opts.SegmentDuration if segDur < minSegmentDuration { segDur = minSegmentDuration @@ -265,17 +360,32 @@ func generateTeaserPreviewMP4WithProgress( } var fc strings.Builder + for i := range starts { fmt.Fprintf(&fc, "[%d:v]scale=%d:-2,setsar=1,setpts=PTS-STARTPTS[v%d];", i, opts.Width, i) - fmt.Fprintf(&fc, "[%d:a]aresample=48000,aformat=channel_layouts=stereo,asetpts=PTS-STARTPTS[a%d];", i, i) - } - 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()) - args = append(args, "-map", "[v]", "-map", "[a]") + if hasAudio { + fmt.Fprintf(&fc, "[%d:a]aresample=48000,aformat=channel_layouts=stereo,asetpts=PTS-STARTPTS[a%d];", i, i) + } + } + + for i := range starts { + if hasAudio { + fmt.Fprintf(&fc, "[v%d][a%d]", i, i) + } else { + fmt.Fprintf(&fc, "[v%d]", i) + } + } + + if hasAudio { + fmt.Fprintf(&fc, "concat=n=%d:v=1:a=1[v][a]", len(starts)) + args = append(args, "-filter_complex", fc.String()) + args = append(args, "-map", "[v]", "-map", "[a]") + } else { + fmt.Fprintf(&fc, "concat=n=%d:v=1:a=0[v]", len(starts)) + args = append(args, "-filter_complex", fc.String()) + args = append(args, "-map", "[v]", "-an") + } args = append(args, "-c:v", "libx264", "-pix_fmt", "yuv420p", @@ -288,12 +398,14 @@ func generateTeaserPreviewMP4WithProgress( if opts.UseVsync2 { args = append(args, "-vsync", "2") } - args = append(args, - "-c:a", "aac", - "-b:a", opts.AudioBitrate, - "-ac", "2", - "-shortest", - ) + if hasAudio { + args = append(args, + "-c:a", "aac", + "-b:a", opts.AudioBitrate, + "-ac", "2", + "-shortest", + ) + } args = append(args, "-movflags", "+faststart", tmp) cmd := exec.CommandContext(ctx, ffmpegPath, args...) @@ -377,7 +489,12 @@ func generateTeaserPreviewMP4WithProgress( if err := cmd.Wait(); err != nil { _ = os.Remove(tmp) - return appErrorf("ffmpeg teaser preview failed: %v (%s)", err, strings.TrimSpace(stderr.String())) + + msg := strings.TrimSpace(stderr.String()) + wrappedErr := appErrorf("ffmpeg teaser preview failed: %v (%s)", err, msg) + + logTeaserGenerationFailure(srcPath, outPath, wrappedErr) + return wrappedErr } _ = os.Remove(outPath) @@ -456,12 +573,15 @@ func generateTeaserMP4(ctx context.Context, srcPath, outPath string, startSec, d cmd := exec.CommandContext(ctx, ffmpegPath, args...) cmd.SysProcAttr = &syscall.SysProcAttr{ HideWindow: true, - CreationFlags: 0x08000000, // CREATE_NO_WINDOW + CreationFlags: 0x08000000, } if out, err := cmd.CombinedOutput(); err != nil { _ = os.Remove(tmp) - return appErrorf("ffmpeg teaser failed: %v (%s)", err, strings.TrimSpace(string(out))) + + wrappedErr := appErrorf("ffmpeg teaser failed: %v (%s)", err, strings.TrimSpace(string(out))) + logTeaserGenerationFailure(srcPath, outPath, wrappedErr) + return wrappedErr } _ = os.Remove(outPath) @@ -553,7 +673,23 @@ func generatedTeaser(w http.ResponseWriter, r *http.Request) { defer cancel() if err := generateTeaserClipsMP4(genCtx, outPath, previewPath, minSegmentDuration, defaultTeaserSegments); err != nil { + appLogln( + "⚠️ teaser clips generation fehlgeschlagen, versuche fallback:", + filepath.Base(outPath), + "error:", + err, + ) + if err2 := generateTeaserMP4(genCtx, outPath, previewPath, 0, 8); err2 != nil { + appLogln( + "❌ teaser generation endgültig fehlgeschlagen:", + filepath.Base(outPath), + "clips-error:", + err, + "fallback-error:", + err2, + ) + http.Error( w, "konnte preview nicht erzeugen: "+err.Error()+" (fallback ebenfalls fehlgeschlagen: "+err2.Error()+")", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c9879b4..93eb37d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3925,7 +3925,7 @@ export default function App() { } const headerShellClass = - 'rounded-2xl border border-gray-200/70 bg-white/65 p-2.5 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-white/[0.06] sm:p-3' + 'rounded-2xl border border-gray-200/70 bg-white/65 p-3 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-white/[0.06]' const headerStatClass = 'inline-flex h-8 items-center gap-1.5 rounded-xl border border-gray-200/70 bg-white/75 px-2.5 text-[11px] font-semibold text-gray-700 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/10 dark:text-gray-200' @@ -3959,9 +3959,7 @@ export default function App() { return (