// Package progress renders a live, single-line status display. One goroutine ticks // once a second, pulls a snapshot of the running downloads, and derives speeds // from the change since the previous tick. There are no locks here: the data // arrives by value through the snapshot function. package progress import ( "context" "fmt" "io" "os" "strings" "time" "unicode/utf8" "github.com/hanbok/got/download" "golang.org/x/term" ) // Reporter draws the progress line until its context is cancelled. type Reporter struct { snap func() []download.Stat human bool interval time.Duration w io.Writer tty bool width int win map[string]*speedWindow // keyed by Stat.ID lastLog time.Time // last non-TTY line emitted } // logEvery bounds how often progress is printed when output is not a TTY, so a // redirected or piped run does not get a line every second. const logEvery = 30 * time.Second // windowTime is the span of the sliding speed window (10s): the rate is the // byte delta across this window divided by its real duration, which smooths the // per-tick jitter. const windowTime = 10 * time.Second // sample is one (time, cumulative-counters) observation in a speedWindow. type sample struct { t time.Time completed, uploaded int64 } // speedWindow keeps the last ~windowTime of samples for one download so we can // derive down/up speed from the change across the window rather than the last // tick alone. Samples are appended in time order, so stale ones live at the // front. type speedWindow struct { samples []sample } // New returns a Reporter that reads live stats from snap. func New(snap func() []download.Stat, human bool) *Reporter { return &Reporter{ snap: snap, human: human, interval: time.Second, w: os.Stdout, tty: term.IsTerminal(int(os.Stdout.Fd())), width: 80, win: map[string]*speedWindow{}, } } // Run draws on every tick and clears the line when ctx is cancelled. func (r *Reporter) Run(ctx context.Context) { t := time.NewTicker(r.interval) defer t.Stop() for { select { case <-ctx.Done(): r.render(true) return case <-t.C: r.render(false) } } } func (r *Reporter) render(final bool) { stats := r.snap() now := time.Now() // Record one sample per download before deriving rates, and forget windows // for downloads that have dropped out of the snapshot. seen := make(map[string]bool, len(stats)) for _, s := range stats { seen[s.ID] = true w := r.win[s.ID] if w == nil { w = &speedWindow{} r.win[s.ID] = w } w.add(now, s.Completed, s.Uploaded) } for id := range r.win { if !seen[id] { delete(r.win, id) } } var line string switch len(stats) { case 0: if !final && r.tty { return // nothing active; leave the line as-is } case 1: line = r.lineOne(stats[0]) default: line = r.lineMany(stats) } if r.tty { r.width = termWidth(r.width) line = clampRunes(line, r.width) fmt.Fprint(r.w, "\r"+line+"\x1b[K") if final { fmt.Fprintln(r.w) } return } // Not a TTY: can't redraw one line, so print sparingly (and always the // final line) to keep redirected output and logs readable. if line != "" && (final || now.Sub(r.lastLog) >= logEvery) { fmt.Fprintln(r.w, line) r.lastLog = now } } func (r *Reporter) lineOne(s download.Stat) string { dl, ul := r.rates(s) seeding := s.Status == download.Seeding var b strings.Builder fmt.Fprintf(&b, "[%s ", shortName(s.Name)) // While seeding, report the share ratio in place of the size block and drop // the DL: field; otherwise show completed/total (or bare completed). if seeding { fmt.Fprintf(&b, "SEED(%.1f)", ratio(s.Uploaded, s.Completed)) } else if s.Total > 0 { fmt.Fprintf(&b, "%s/%s(%d%%)", humanSize(s.Completed, r.human), humanSize(s.Total, r.human), percent(s.Completed, s.Total)) } else { b.WriteString(humanSize(s.Completed, r.human)) } // CN is always shown (including HTTP); SD is shown for any torrent. fmt.Fprintf(&b, " CN:%d", s.Conns) if s.IsBT { fmt.Fprintf(&b, " SD:%d", s.Seeders) } if !seeding { fmt.Fprintf(&b, " DL:%s", speed(dl, r.human)) } if seeding || ul > 0 { fmt.Fprintf(&b, " UL:%s", speed(ul, r.human)) } if !seeding && s.Total > 0 && dl > 0 { eta := time.Duration(float64(s.Total-s.Completed)/float64(dl)) * time.Second fmt.Fprintf(&b, " ETA:%s", secfmt(eta)) } b.WriteByte(']') return b.String() } func (r *Reporter) lineMany(stats []download.Stat) string { var totDL, totUL int64 for _, s := range stats { dl, ul := r.rates(s) totDL += dl totUL += ul } var b strings.Builder fmt.Fprintf(&b, "[%d active DL:%s UL:%s]", len(stats), speed(totDL, r.human), speed(totUL, r.human)) for i, s := range stats { if i >= 4 { fmt.Fprintf(&b, "[+%d]", len(stats)-i) break } if s.Total > 0 { fmt.Fprintf(&b, "[%s %d%%]", shortName(s.Name), percent(s.Completed, s.Total)) } else { fmt.Fprintf(&b, "[%s %s]", shortName(s.Name), humanSize(s.Completed, r.human)) } } return b.String() } // add records a sample and drops any that have fallen out of the window. A // sample is only appended once per second, but the latest counters are folded // into the newest slot so the final cancel-render, which typically fires in the // same second as the preceding tick, still sees current totals. func (w *speedWindow) add(now time.Time, completed, uploaded int64) { // Drop samples older than the window, keeping the first one that still falls // inside it as the baseline for the delta. cut := now.Add(-windowTime) i := 0 for i < len(w.samples) && w.samples[i].t.Before(cut) { i++ } w.samples = w.samples[i:] if n := len(w.samples); n > 0 && now.Sub(w.samples[n-1].t) < time.Second { w.samples[n-1].completed = completed w.samples[n-1].uploaded = uploaded return } w.samples = append(w.samples, sample{now, completed, uploaded}) } // rates derives down/up speed in bytes/s from the change across the speed // window (windowBytes / windowSeconds). With a single sample we have no span // yet, so we report 0 rather than mistaking the cumulative bytes (e.g. resumed // data) for one window's worth. func (r *Reporter) rates(s download.Stat) (dl, ul int64) { w := r.win[s.ID] if w == nil || len(w.samples) < 2 { return 0, 0 } first, last := w.samples[0], w.samples[len(w.samples)-1] secs := last.t.Sub(first.t).Seconds() if secs <= 0 { return 0, 0 } dl = int64(float64(last.completed-first.completed) / secs) ul = int64(float64(last.uploaded-first.uploaded) / secs) if dl < 0 { dl = 0 } if ul < 0 { ul = 0 } return dl, ul } // ratio is uploaded/completed for the seeding readout; 0 when nothing has been // downloaded yet to avoid dividing by zero. func ratio(uploaded, completed int64) float64 { if completed <= 0 { return 0 } return float64(uploaded) / float64(completed) } func shortName(s string) string { const max = 20 // Truncate on runes, not bytes, so a multibyte name (CJK, emoji) is never // cut mid-rune. if utf8.RuneCountInString(s) > max { return string([]rune(s)[:max-1]) + "~" } return s } // clampRunes limits a line to width runes (not bytes) so multibyte glyphs are // never split when the terminal is narrow. A non-positive width leaves it whole. func clampRunes(s string, width int) string { if width <= 0 || utf8.RuneCountInString(s) <= width { return s } return string([]rune(s)[:width]) } func termWidth(prev int) int { if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 { return w } if prev > 0 { return prev } return 80 }