render manifest ffmpeg progress like standard download bars

This commit is contained in:
2026-04-22 00:40:11 +02:00
parent 50ca5f564b
commit c1e89f5876
2 changed files with 217 additions and 36 deletions

View File

@@ -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}), decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}),
), ),
mpb.AppendDecorators( mpb.AppendDecorators(
decor.CurrentKibiByte("% .1f", decor.WCSyncWidthR),
decor.Name(" | ", decor.WCSyncWidth),
decor.Elapsed(decor.ET_STYLE_GO, decor.WCSyncWidthR), decor.Elapsed(decor.ET_STYLE_GO, decor.WCSyncWidthR),
decor.Name(" | probing stream", decor.WCSyncWidth),
), ),
mpb.BarRemoveOnComplete(), mpb.BarRemoveOnComplete(),
) )
@@ -364,18 +365,21 @@ func (d *Downloader) streamManifestWithFFmpeg(ctx context.Context, sourceURL, ou
scanDone := make(chan scanState, 1) scanDone := make(chan scanState, 1)
go func() { go func() {
state := scanState{} state := scanState{}
shownMS := int64(0) shownBytes := int64(0)
newFFmpegBar := func(totalMS int64) *mpb.Bar { barTotalBytes := int64(0)
newEstimatedBar := func(totalBytes int64) *mpb.Bar {
return d.progress.AddBar( return d.progress.AddBar(
totalMS, totalBytes,
mpb.PrependDecorators( mpb.PrependDecorators(
decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}), decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}),
decor.Percentage(decor.WCSyncWidthR), decor.Percentage(decor.WCSyncWidthR),
), ),
mpb.AppendDecorators( mpb.AppendDecorators(
decor.CountersKibiByte("% .1f / % .1f", decor.WCSyncWidthR),
decor.Name(" | ", decor.WCSyncWidth), decor.Name(" | ", decor.WCSyncWidth),
decor.Elapsed(decor.ET_STYLE_GO, decor.WCSyncWidthR), decor.AverageSpeed(decor.SizeB1024(0), "% .1f", decor.WCSyncWidthR),
decor.Name(" | ffmpeg stream", decor.WCSyncWidth), decor.Name(" | ETA ", decor.WCSyncWidth),
decor.AverageETA(decor.ET_STYLE_GO, decor.WCSyncWidthR),
), ),
mpb.BarRemoveOnComplete(), mpb.BarRemoveOnComplete(),
) )
@@ -389,45 +393,65 @@ func (d *Downloader) streamManifestWithFFmpeg(ctx context.Context, sourceURL, ou
ffOut.WriteString(line) ffOut.WriteString(line)
ffOut.WriteByte('\n') ffOut.WriteByte('\n')
} }
if state.totalMS == 0 { if totalMS, ok := parseFFmpegDurationLine(line); ok && totalMS > 0 {
if totalMS, ok := parseFFmpegDurationLine(line); ok && totalMS > 0 { state.totalMS = totalMS
state.totalMS = totalMS }
spinner.SetTotal(-1, true) if bitrateBPS, ok := parseFFmpegDurationBitrateBPS(line); ok && bitrateBPS > 0 {
bar = newFFmpegBar(state.totalMS) state.bitrateBPS = bitrateBPS
if state.currentMS > state.totalMS { }
state.currentMS = state.totalMS if bitrateBPS, ok := parseFFmpegProgressBitrateBPS(line); ok && bitrateBPS > 0 {
} state.bitrateBPS = bitrateBPS
if state.currentMS > shownMS { }
bar.IncrBy(int(state.currentMS - shownMS)) if totalSize, ok := parseFFmpegTotalSize(line); ok && totalSize > state.currentBytes {
shownMS = state.currentMS state.currentBytes = totalSize
}
}
} }
if currentMS, ok := parseFFmpegOutTime(line); ok { if currentMS, ok := parseFFmpegOutTime(line); ok {
if currentMS > state.currentMS { if currentMS > state.currentMS {
state.currentMS = currentMS state.currentMS = currentMS
} }
if bar != nil { }
targetMS := state.currentMS
if targetMS > state.totalMS { if bar == nil {
targetMS = state.totalMS estimatedTotal := int64(0)
} if state.totalMS > 0 && state.bitrateBPS > 0 {
if targetMS > shownMS { estimatedTotal = estimateTotalBytesFromBitrate(state.totalMS, state.bitrateBPS)
bar.IncrBy(int(targetMS - shownMS))
shownMS = targetMS
}
} }
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 bar != nil {
if shownMS < state.totalMS { if shownBytes < barTotalBytes {
bar.IncrBy(int(state.totalMS - shownMS)) bar.IncrBy(int(barTotalBytes - shownBytes))
} }
bar.SetTotal(state.totalMS, true) bar.SetTotal(barTotalBytes, true)
} else { } else {
spinner.SetTotal(-1, true) spinner.SetTotal(-1, true)
} }
state.currentMS = shownMS state.currentBytes = shownBytes
state.scanErr = scanner.Err() state.scanErr = scanner.Err()
scanDone <- state scanDone <- state
}() }()
@@ -467,9 +491,11 @@ func buildFFmpegStreamArgs(sourceURL, outputPath string, includeVideo bool) []st
} }
type scanState struct { type scanState struct {
totalMS int64 totalMS int64
currentMS int64 currentMS int64
scanErr error bitrateBPS int64
currentBytes int64
scanErr error
} }
func isManifestResponse(contentType string, peek []byte) bool { func isManifestResponse(contentType string, peek []byte) bool {
@@ -513,6 +539,118 @@ func parseFFmpegOutTime(line string) (int64, bool) {
return parseClockDurationMS(raw) 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) { func parseClockDurationMS(raw string) (int64, bool) {
parts := strings.Split(strings.TrimSpace(raw), ":") parts := strings.Split(strings.TrimSpace(raw), ":")
if len(parts) != 3 { if len(parts) != 3 {

View File

@@ -74,7 +74,6 @@ func TestDeezerBlowfishKeyDerivation(t *testing.T) {
} }
} }
func TestFileDeezerEncrypted(t *testing.T) { func TestFileDeezerEncrypted(t *testing.T) {
trackID := "3135556" trackID := "3135556"
plain := make([]byte, deezerBFChunkSize+777) 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) { func TestParseFFmpegOutTime(t *testing.T) {
currentMS, ok := parseFFmpegOutTime("out_time=00:01:02.340000") currentMS, ok := parseFFmpegOutTime("out_time=00:01:02.340000")
if !ok { 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) { func TestBuildFFmpegStreamArgsAudioOnly(t *testing.T) {
args := buildFFmpegStreamArgs("https://example.com/master.m3u8", "/tmp/out.m4a", false) args := buildFFmpegStreamArgs("https://example.com/master.m3u8", "/tmp/out.m4a", false)
if !containsArgPair(args, "-map", "0:a:0") { if !containsArgPair(args, "-map", "0:a:0") {