From beb6ce6cbb4369772994a66aa4051f2b9687217f Mon Sep 17 00:00:00 2001 From: Joren Date: Tue, 21 Apr 2026 22:28:37 +0200 Subject: [PATCH] improve ffmpeg manifest progress rendering --- internal/download/downloader.go | 224 ++++++++++++++++++++++----- internal/download/downloader_test.go | 29 ++++ 2 files changed, 214 insertions(+), 39 deletions(-) diff --git a/internal/download/downloader.go b/internal/download/downloader.go index cf29574..273461b 100644 --- a/internal/download/downloader.go +++ b/internal/download/downloader.go @@ -14,7 +14,6 @@ import ( "strconv" "strings" "sync/atomic" - "time" "github.com/vbauerster/mpb/v8" "github.com/vbauerster/mpb/v8/decor" @@ -320,41 +319,6 @@ func shortenName(name string, max int) string { } func (d *Downloader) streamManifestWithFFmpeg(ctx context.Context, sourceURL, outputPath string, includeVideo bool) error { - stopSpinner := func() {} - if d.ProgressEnabled() { - d.barStarted.Store(1) - desc := shortenName(filepath.Base(outputPath), 54) - spin := d.progress.AddSpinner( - 0, - mpb.PrependDecorators( - decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}), - ), - mpb.AppendDecorators( - decor.Elapsed(decor.ET_STYLE_GO, decor.WCSyncWidthR), - decor.Name(" | ffmpeg stream", decor.WCSyncWidth), - ), - mpb.BarRemoveOnComplete(), - ) - done := make(chan struct{}) - go func() { - ticker := time.NewTicker(120 * time.Millisecond) - defer ticker.Stop() - for { - select { - case <-ticker.C: - spin.IncrBy(1) - case <-done: - return - } - } - }() - stopSpinner = func() { - close(done) - spin.SetTotal(-1, true) - } - } - defer stopSpinner() - if _, err := exec.LookPath("ffmpeg"); err != nil { return fmt.Errorf("ffmpeg not found for manifest stream: %w", err) } @@ -369,17 +333,137 @@ func (d *Downloader) streamManifestWithFFmpeg(ctx context.Context, sourceURL, ou } else { args = append(args, "-map", "0:a:0") } - args = append(args, "-c", "copy", outputPath) + args = append(args, "-c", "copy", "-hide_banner", "-nostats", "-progress", "pipe:2", outputPath) + + if !d.ProgressEnabled() { + cmd := exec.CommandContext(ctx, "ffmpeg", args...) + output, err := cmd.CombinedOutput() + if err != nil { + _ = os.Remove(outputPath) + return fmt.Errorf("ffmpeg stream copy failed: %w: %s", err, string(output)) + } + return nil + } + + d.barStarted.Store(1) + desc := shortenName(filepath.Base(outputPath), 54) + spinner := d.progress.AddSpinner( + 0, + mpb.PrependDecorators( + decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}), + ), + mpb.AppendDecorators( + decor.Elapsed(decor.ET_STYLE_GO, decor.WCSyncWidthR), + decor.Name(" | probing stream", decor.WCSyncWidth), + ), + mpb.BarRemoveOnComplete(), + ) cmd := exec.CommandContext(ctx, "ffmpeg", args...) - output, err := cmd.CombinedOutput() + stderr, err := cmd.StderrPipe() if err != nil { + spinner.SetTotal(-1, true) + return fmt.Errorf("ffmpeg stream setup failed: %w", err) + } + if err = cmd.Start(); err != nil { + spinner.SetTotal(-1, true) + return fmt.Errorf("ffmpeg stream setup failed: %w", err) + } + + var ffOut strings.Builder + scanDone := make(chan scanState, 1) + go func() { + state := scanState{} + shownMS := int64(0) + newFFmpegBar := func(totalMS int64) *mpb.Bar { + return d.progress.AddBar( + totalMS, + mpb.PrependDecorators( + decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}), + decor.Percentage(decor.WCSyncWidthR), + ), + mpb.AppendDecorators( + decor.Name(" | ", decor.WCSyncWidth), + decor.Elapsed(decor.ET_STYLE_GO, decor.WCSyncWidthR), + decor.Name(" | ffmpeg stream", decor.WCSyncWidth), + ), + mpb.BarRemoveOnComplete(), + ) + } + var bar *mpb.Bar + scanner := bufio.NewScanner(stderr) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + line := scanner.Text() + if ffOut.Len() < 256*1024 { + ffOut.WriteString(line) + ffOut.WriteByte('\n') + } + if state.totalMS == 0 { + if totalMS, ok := parseFFmpegDurationLine(line); ok && totalMS > 0 { + state.totalMS = totalMS + spinner.SetTotal(-1, true) + bar = newFFmpegBar(state.totalMS) + if state.currentMS > state.totalMS { + state.currentMS = state.totalMS + } + if state.currentMS > shownMS { + bar.IncrBy(int(state.currentMS - shownMS)) + shownMS = state.currentMS + } + } + } + if currentMS, ok := parseFFmpegOutTime(line); ok { + if currentMS > state.currentMS { + state.currentMS = currentMS + } + if bar != nil { + targetMS := state.currentMS + if targetMS > state.totalMS { + targetMS = state.totalMS + } + if targetMS > shownMS { + bar.IncrBy(int(targetMS - shownMS)) + shownMS = targetMS + } + } + } + } + if bar != nil { + if shownMS < state.totalMS { + bar.IncrBy(int(state.totalMS - shownMS)) + } + bar.SetTotal(state.totalMS, true) + } else { + spinner.SetTotal(-1, true) + } + state.currentMS = shownMS + state.scanErr = scanner.Err() + scanDone <- state + }() + + waitErr := cmd.Wait() + state := <-scanDone + + if waitErr != nil { _ = os.Remove(outputPath) - return fmt.Errorf("ffmpeg stream copy failed: %w: %s", err, string(output)) + if out := strings.TrimSpace(ffOut.String()); out != "" { + return fmt.Errorf("ffmpeg stream copy failed: %w: %s", waitErr, out) + } + return fmt.Errorf("ffmpeg stream copy failed: %w", waitErr) + } + if state.scanErr != nil { + return fmt.Errorf("ffmpeg stream output read failed: %w", state.scanErr) } return nil } +type scanState struct { + totalMS int64 + currentMS int64 + scanErr error +} + func isManifestResponse(contentType string, peek []byte) bool { ct := strings.ToLower(contentType) if strings.Contains(ct, "dash+xml") || strings.Contains(ct, "mpegurl") || strings.Contains(ct, "vnd.apple.mpegurl") { @@ -395,6 +479,68 @@ func isManifestResponse(contentType string, peek []byte) bool { return false } +func parseFFmpegDurationLine(line string) (int64, bool) { + idx := strings.Index(line, "Duration:") + if idx < 0 { + return 0, false + } + raw := strings.TrimSpace(line[idx+len("Duration:"):]) + if raw == "" { + return 0, false + } + if comma := strings.Index(raw, ","); comma >= 0 { + raw = raw[:comma] + } + return parseClockDurationMS(raw) +} + +func parseFFmpegOutTime(line string) (int64, bool) { + if !strings.HasPrefix(line, "out_time=") { + return 0, false + } + raw := strings.TrimSpace(strings.TrimPrefix(line, "out_time=")) + if raw == "" || strings.EqualFold(raw, "N/A") { + return 0, false + } + return parseClockDurationMS(raw) +} + +func parseClockDurationMS(raw string) (int64, bool) { + parts := strings.Split(strings.TrimSpace(raw), ":") + if len(parts) != 3 { + return 0, false + } + hours, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil || hours < 0 { + return 0, false + } + minutes, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil || minutes < 0 { + return 0, false + } + secPart := parts[2] + secRaw, fracRaw, hasFrac := strings.Cut(secPart, ".") + seconds, err := strconv.ParseInt(secRaw, 10, 64) + if err != nil || seconds < 0 { + return 0, false + } + ms := ((hours*60+minutes)*60 + seconds) * 1000 + if hasFrac { + if len(fracRaw) > 3 { + fracRaw = fracRaw[:3] + } + for len(fracRaw) < 3 { + fracRaw += "0" + } + fracMS, fracErr := strconv.ParseInt(fracRaw, 10, 64) + if fracErr != nil || fracMS < 0 { + return 0, false + } + ms += fracMS + } + return ms, true +} + const deezerBFChunkSize = 2048 var deezerBFIV = []byte{0, 1, 2, 3, 4, 5, 6, 7} diff --git a/internal/download/downloader_test.go b/internal/download/downloader_test.go index bec2109..a696d6c 100644 --- a/internal/download/downloader_test.go +++ b/internal/download/downloader_test.go @@ -231,3 +231,32 @@ func TestStreamManifestWithFFmpegFailureRemovesPartialFile(t *testing.T) { t.Fatalf("expected no partial file after ffmpeg failure, stat err=%v", statErr) } } + +func TestParseFFmpegDurationLine(t *testing.T) { + totalMS, ok := parseFFmpegDurationLine(" Duration: 00:04:52.57, start: 0.000000, bitrate: 975 kb/s") + if !ok { + t.Fatalf("expected duration parse to succeed") + } + if want := int64(292570); totalMS != want { + t.Fatalf("unexpected duration ms: got=%d want=%d", totalMS, want) + } +} + +func TestParseFFmpegOutTime(t *testing.T) { + currentMS, ok := parseFFmpegOutTime("out_time=00:01:02.340000") + if !ok { + t.Fatalf("expected out_time parse to succeed") + } + if want := int64(62340); currentMS != want { + t.Fatalf("unexpected out_time ms: got=%d want=%d", currentMS, want) + } +} + +func TestParseClockDurationMSInvalid(t *testing.T) { + if _, ok := parseClockDurationMS("bad"); ok { + t.Fatalf("expected invalid duration to fail") + } + if _, ok := parseClockDurationMS("00:12"); ok { + t.Fatalf("expected short duration to fail") + } +}