From c1e89f58767c65f596209c25dbb1402f3fdec1fc Mon Sep 17 00:00:00 2001 From: Joren Date: Wed, 22 Apr 2026 00:40:11 +0200 Subject: [PATCH] render manifest ffmpeg progress like standard download bars --- internal/download/downloader.go | 208 ++++++++++++++++++++++----- internal/download/downloader_test.go | 45 +++++- 2 files changed, 217 insertions(+), 36 deletions(-) diff --git a/internal/download/downloader.go b/internal/download/downloader.go index ef4c0a1..9e75bbc 100644 --- a/internal/download/downloader.go +++ b/internal/download/downloader.go @@ -343,8 +343,9 @@ func (d *Downloader) streamManifestWithFFmpeg(ctx context.Context, sourceURL, ou decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}), ), mpb.AppendDecorators( + decor.CurrentKibiByte("% .1f", decor.WCSyncWidthR), + decor.Name(" | ", decor.WCSyncWidth), decor.Elapsed(decor.ET_STYLE_GO, decor.WCSyncWidthR), - decor.Name(" | probing stream", decor.WCSyncWidth), ), mpb.BarRemoveOnComplete(), ) @@ -364,18 +365,21 @@ func (d *Downloader) streamManifestWithFFmpeg(ctx context.Context, sourceURL, ou scanDone := make(chan scanState, 1) go func() { state := scanState{} - shownMS := int64(0) - newFFmpegBar := func(totalMS int64) *mpb.Bar { + shownBytes := int64(0) + barTotalBytes := int64(0) + newEstimatedBar := func(totalBytes int64) *mpb.Bar { return d.progress.AddBar( - totalMS, + totalBytes, mpb.PrependDecorators( decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}), decor.Percentage(decor.WCSyncWidthR), ), mpb.AppendDecorators( + decor.CountersKibiByte("% .1f / % .1f", decor.WCSyncWidthR), decor.Name(" | ", decor.WCSyncWidth), - decor.Elapsed(decor.ET_STYLE_GO, decor.WCSyncWidthR), - decor.Name(" | ffmpeg stream", decor.WCSyncWidth), + decor.AverageSpeed(decor.SizeB1024(0), "% .1f", decor.WCSyncWidthR), + decor.Name(" | ETA ", decor.WCSyncWidth), + decor.AverageETA(decor.ET_STYLE_GO, decor.WCSyncWidthR), ), mpb.BarRemoveOnComplete(), ) @@ -389,45 +393,65 @@ func (d *Downloader) streamManifestWithFFmpeg(ctx context.Context, sourceURL, ou 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 totalMS, ok := parseFFmpegDurationLine(line); ok && totalMS > 0 { + state.totalMS = totalMS + } + if bitrateBPS, ok := parseFFmpegDurationBitrateBPS(line); ok && bitrateBPS > 0 { + state.bitrateBPS = bitrateBPS + } + if bitrateBPS, ok := parseFFmpegProgressBitrateBPS(line); ok && bitrateBPS > 0 { + state.bitrateBPS = bitrateBPS + } + if totalSize, ok := parseFFmpegTotalSize(line); ok && totalSize > state.currentBytes { + state.currentBytes = totalSize } 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 { + estimatedTotal := int64(0) + if state.totalMS > 0 && state.bitrateBPS > 0 { + estimatedTotal = estimateTotalBytesFromBitrate(state.totalMS, state.bitrateBPS) } + if estimatedTotal == 0 { + estimatedTotal = estimateTotalBytesFromProgress(state.totalMS, state.currentMS, state.currentBytes) + } + if estimatedTotal > 0 { + if estimatedTotal < state.currentBytes { + estimatedTotal = state.currentBytes + } + barTotalBytes = estimatedTotal + spinner.SetTotal(-1, true) + bar = newEstimatedBar(barTotalBytes) + } + } + + if state.currentBytes > shownBytes { + delta := state.currentBytes - shownBytes + if bar != nil { + bar.IncrBy(int(delta)) + if state.currentBytes > barTotalBytes { + barTotalBytes = state.currentBytes + bar.SetTotal(barTotalBytes, false) + } + } else { + spinner.IncrBy(int(delta)) + } + shownBytes = state.currentBytes } } if bar != nil { - if shownMS < state.totalMS { - bar.IncrBy(int(state.totalMS - shownMS)) + if shownBytes < barTotalBytes { + bar.IncrBy(int(barTotalBytes - shownBytes)) } - bar.SetTotal(state.totalMS, true) + bar.SetTotal(barTotalBytes, true) } else { spinner.SetTotal(-1, true) } - state.currentMS = shownMS + state.currentBytes = shownBytes state.scanErr = scanner.Err() scanDone <- state }() @@ -467,9 +491,11 @@ func buildFFmpegStreamArgs(sourceURL, outputPath string, includeVideo bool) []st } type scanState struct { - totalMS int64 - currentMS int64 - scanErr error + totalMS int64 + currentMS int64 + bitrateBPS int64 + currentBytes int64 + scanErr error } func isManifestResponse(contentType string, peek []byte) bool { @@ -513,6 +539,118 @@ func parseFFmpegOutTime(line string) (int64, bool) { return parseClockDurationMS(raw) } +func parseFFmpegTotalSize(line string) (int64, bool) { + if !strings.HasPrefix(line, "total_size=") { + return 0, false + } + raw := strings.TrimSpace(strings.TrimPrefix(line, "total_size=")) + if raw == "" || strings.EqualFold(raw, "N/A") { + return 0, false + } + v, err := strconv.ParseInt(raw, 10, 64) + if err != nil || v < 0 { + return 0, false + } + return v, true +} + +func parseFFmpegDurationBitrateBPS(line string) (int64, bool) { + idx := strings.Index(strings.ToLower(line), "bitrate:") + if idx < 0 { + return 0, false + } + raw := strings.TrimSpace(line[idx+len("bitrate:"):]) + if raw == "" { + return 0, false + } + return parseFFmpegBitrateBPS(raw) +} + +func parseFFmpegProgressBitrateBPS(line string) (int64, bool) { + if !strings.HasPrefix(line, "bitrate=") { + return 0, false + } + raw := strings.TrimSpace(strings.TrimPrefix(line, "bitrate=")) + if raw == "" || strings.EqualFold(raw, "N/A") { + return 0, false + } + return parseFFmpegBitrateBPS(raw) +} + +func parseFFmpegBitrateBPS(raw string) (int64, bool) { + raw = strings.TrimSpace(raw) + if raw == "" || strings.EqualFold(raw, "N/A") { + return 0, false + } + parts := strings.Fields(strings.ToLower(raw)) + if len(parts) == 0 { + return 0, false + } + numStr := parts[0] + unit := "" + if len(parts) > 1 { + unit = parts[1] + } else { + i := 0 + for i < len(numStr) { + c := numStr[i] + if (c >= '0' && c <= '9') || c == '.' { + i++ + continue + } + break + } + if i < len(numStr) { + unit = numStr[i:] + numStr = numStr[:i] + } + } + num, err := strconv.ParseFloat(strings.TrimSpace(numStr), 64) + if err != nil || num <= 0 { + return 0, false + } + unit = strings.TrimSpace(strings.Trim(unit, ",")) + mult := float64(1) + switch unit { + case "kb/s", "kbits/s", "kbit/s", "kbps": + mult = 1000 + case "mb/s", "mbits/s", "mbit/s", "mbps": + mult = 1000 * 1000 + case "gb/s", "gbits/s", "gbit/s", "gbps": + mult = 1000 * 1000 * 1000 + case "b/s", "bit/s", "bits/s", "": + mult = 1 + default: + return 0, false + } + bps := int64(num * mult) + if bps <= 0 { + return 0, false + } + return bps, true +} + +func estimateTotalBytesFromBitrate(totalMS, bitrateBPS int64) int64 { + if totalMS <= 0 || bitrateBPS <= 0 { + return 0 + } + return (totalMS * bitrateBPS) / 8000 +} + +func estimateTotalBytesFromProgress(totalMS, currentMS, currentBytes int64) int64 { + if totalMS <= 0 || currentMS <= 0 || currentBytes <= 0 { + return 0 + } + if currentMS > totalMS { + currentMS = totalMS + } + est := (currentBytes * totalMS) / currentMS + if est < currentBytes { + est = currentBytes + } + return est +} + func parseClockDurationMS(raw string) (int64, bool) { parts := strings.Split(strings.TrimSpace(raw), ":") if len(parts) != 3 { diff --git a/internal/download/downloader_test.go b/internal/download/downloader_test.go index 3f1407f..1e8c461 100644 --- a/internal/download/downloader_test.go +++ b/internal/download/downloader_test.go @@ -74,7 +74,6 @@ func TestDeezerBlowfishKeyDerivation(t *testing.T) { } } - func TestFileDeezerEncrypted(t *testing.T) { trackID := "3135556" plain := make([]byte, deezerBFChunkSize+777) @@ -221,6 +220,36 @@ func TestParseFFmpegDurationLine(t *testing.T) { } } +func TestParseFFmpegDurationBitrateBPS(t *testing.T) { + bps, ok := parseFFmpegDurationBitrateBPS(" Duration: 00:04:52.57, start: 0.000000, bitrate: 975 kb/s") + if !ok { + t.Fatalf("expected bitrate parse to succeed") + } + if want := int64(975000); bps != want { + t.Fatalf("unexpected bitrate: got=%d want=%d", bps, want) + } +} + +func TestParseFFmpegProgressBitrateBPS(t *testing.T) { + bps, ok := parseFFmpegProgressBitrateBPS("bitrate=1706.8kbits/s") + if !ok { + t.Fatalf("expected progress bitrate parse to succeed") + } + if want := int64(1706800); bps != want { + t.Fatalf("unexpected bitrate: got=%d want=%d", bps, want) + } +} + +func TestParseFFmpegTotalSize(t *testing.T) { + size, ok := parseFFmpegTotalSize("total_size=1234567") + if !ok { + t.Fatalf("expected total_size parse to succeed") + } + if want := int64(1234567); size != want { + t.Fatalf("unexpected total_size: got=%d want=%d", size, want) + } +} + func TestParseFFmpegOutTime(t *testing.T) { currentMS, ok := parseFFmpegOutTime("out_time=00:01:02.340000") if !ok { @@ -240,6 +269,20 @@ func TestParseClockDurationMSInvalid(t *testing.T) { } } +func TestEstimateTotalBytesFromBitrate(t *testing.T) { + total := estimateTotalBytesFromBitrate(10000, 1600000) + if want := int64(2000000); total != want { + t.Fatalf("unexpected estimate from bitrate: got=%d want=%d", total, want) + } +} + +func TestEstimateTotalBytesFromProgress(t *testing.T) { + total := estimateTotalBytesFromProgress(10000, 2500, 500000) + if want := int64(2000000); total != want { + t.Fatalf("unexpected estimate from progress: got=%d want=%d", total, want) + } +} + func TestBuildFFmpegStreamArgsAudioOnly(t *testing.T) { args := buildFFmpegStreamArgs("https://example.com/master.m3u8", "/tmp/out.m4a", false) if !containsArgPair(args, "-map", "0:a:0") {