275 lines
7.4 KiB
Go
275 lines
7.4 KiB
Go
// 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
|
|
}
|