mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-06-17 15:05:39 +02:00
improve ffmpeg manifest progress rendering
This commit is contained in:
@@ -14,7 +14,6 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/vbauerster/mpb/v8"
|
"github.com/vbauerster/mpb/v8"
|
||||||
"github.com/vbauerster/mpb/v8/decor"
|
"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 {
|
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 {
|
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||||
return fmt.Errorf("ffmpeg not found for manifest stream: %w", err)
|
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 {
|
} else {
|
||||||
args = append(args, "-map", "0:a:0")
|
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...)
|
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
|
||||||
output, err := cmd.CombinedOutput()
|
stderr, err := cmd.StderrPipe()
|
||||||
if err != nil {
|
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)
|
_ = 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type scanState struct {
|
||||||
|
totalMS int64
|
||||||
|
currentMS int64
|
||||||
|
scanErr error
|
||||||
|
}
|
||||||
|
|
||||||
func isManifestResponse(contentType string, peek []byte) bool {
|
func isManifestResponse(contentType string, peek []byte) bool {
|
||||||
ct := strings.ToLower(contentType)
|
ct := strings.ToLower(contentType)
|
||||||
if strings.Contains(ct, "dash+xml") || strings.Contains(ct, "mpegurl") || strings.Contains(ct, "vnd.apple.mpegurl") {
|
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
|
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
|
const deezerBFChunkSize = 2048
|
||||||
|
|
||||||
var deezerBFIV = []byte{0, 1, 2, 3, 4, 5, 6, 7}
|
var deezerBFIV = []byte{0, 1, 2, 3, 4, 5, 6, 7}
|
||||||
|
|||||||
@@ -231,3 +231,32 @@ func TestStreamManifestWithFFmpegFailureRemovesPartialFile(t *testing.T) {
|
|||||||
t.Fatalf("expected no partial file after ffmpeg failure, stat err=%v", statErr)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user