// Package progress renders a live status display: a summary line over one // aligned row per download, collapsing to the summary alone when the rows would // not fit. 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" "strconv" "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 height int win map[string]*speedWindow // keyed by Stat.ID prevLines int // lines drawn in the previous TTY frame, redrawn in place 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, height: 24, 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) } } if r.tty { r.width = termWidth(r.width) r.height = termHeight(r.height) r.redraw(r.block(stats), final) return } // Not a TTY: no cursor control, so emit a single line sparingly (and always // the final one) to keep redirected output and logs readable. var line string switch { case len(stats) == 1: line = r.lineOne(stats[0]) case len(stats) > 1: line = r.summary(stats) } 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 ", elide(s.Name, nameW)) // 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", humanSize(dl, r.human)) } if seeding || ul > 0 { fmt.Fprintf(&b, " UL:%s", humanSize(ul, r.human)) } if !seeding && s.Total > 0 && dl > 0 { fmt.Fprintf(&b, " ETA:%s", secfmt(etaDuration(s.Total-s.Completed, dl))) } b.WriteByte(']') return b.String() } // nameW is the fixed rune width of the name column in the table, matching // elide's cap so a padded name lines the columns up. const nameW = 20 // block builds the lines of the live display: the single-download dashboard for // one download, a summary + column-header + one row each for several, or a // one-line summary when the table would not fit the window. func (r *Reporter) block(stats []download.Stat) []string { switch { case len(stats) == 0: return nil case len(stats) == 1: return []string{r.lineOne(stats[0])} case len(stats)+3 > r.height: // The block is repainted in place by moving the cursor up over it, which // breaks if printing the last row reaches the screen's bottom row and // scrolls the frame out from under the cursor. term height counts the row // the shell prompt occupies, so a summary + header + one row each // (len+2 lines) must still leave the bottom row free: fall back to the // single summary line once len+2 would fill to the bottom. return []string{r.summary(stats)} default: out := make([]string, 0, len(stats)+2) out = append(out, r.summary(stats), tableHeader()) for _, s := range stats { out = append(out, r.tableRow(s)) } return out } } // redraw repaints the block in place: move to the top of the previous frame, // clear it, and print the new lines clamped to the terminal width. A stray line // from stderr that lands in the block is painted over on the next tick. func (r *Reporter) redraw(lines []string, final bool) { if r.prevLines > 0 { if r.prevLines > 1 { fmt.Fprintf(r.w, "\x1b[%dA", r.prevLines-1) // up to the first line } fmt.Fprint(r.w, "\r\x1b[J") // column 0, clear to end of screen } for i, ln := range lines { if i > 0 { fmt.Fprint(r.w, "\n") } fmt.Fprint(r.w, clampRunes(ln, r.width)) } r.prevLines = len(lines) if final && len(lines) > 0 { fmt.Fprintln(r.w) // leave the cursor below the block for the OK/FAIL lines r.prevLines = 0 } } // summary is the header/aggregate line: a count of downloads in each state, the // total down/up speed, and a stalled count when any download has gone quiet. The // state breakdown replaces a single "active" tally so seeding and queued // downloads are not lumped in with the ones actually moving bytes. func (r *Reporter) summary(stats []download.Stat) string { if len(stats) == 0 { return "" } var totDL, totUL int64 var downloading, seeding, queued, stalled int for _, s := range stats { dl, ul := r.rates(s) totDL += dl totUL += ul switch s.Status { case download.Waiting: queued++ case download.Seeding: seeding++ default: downloading++ if r.isStalled(s, dl) { stalled++ } } } parts := []string{fmt.Sprintf("%d downloading", downloading)} if seeding > 0 { parts = append(parts, fmt.Sprintf("%d seeding", seeding)) } if queued > 0 { parts = append(parts, fmt.Sprintf("%d queued", queued)) } line := strings.Join(parts, " · ") + fmt.Sprintf(" DL %s UL %s", r.rateStr(totDL), r.rateStr(totUL)) if stalled > 0 { line += fmt.Sprintf(" (%d stalled)", stalled) } return line } // tableHeader names the columns once so the rows can drop the per-field DL:/UL: // labels. It uses tableRow's exact format string so the headings line up over // their columns. SIZE is completed/total; CN is the connection/peer count and SD // the connected seeders — the fields that tell a stalled download (no peers) // from a slow one (peers, no speed). func tableHeader() string { return fmt.Sprintf("%s %4s %-13s %-8s %3s %3s %s", padRune("NAME", nameW), "%", "SIZE", "RATE", "CN", "SD", "ETA") } // tableRow renders one download as aligned columns under tableHeader: // "name pct size rate cn sd tail". The name is middle-elided to keep its // distinguishing tail; percent, rate and the peer counts are fixed-width so the // digits scan straight down; size is completed/total; the rate column carries // the download speed or a state word (seeding/queued/...), and the tail carries // the ETA, the share ratio while seeding, or a stalled flag. func (r *Reporter) tableRow(s download.Stat) string { dl, ul := r.rates(s) rate, tail := r.rowMetric(s, dl, ul) cn, sd := peerFields(s) row := fmt.Sprintf("%s %4s %-13s %-8s %3s %3s %s", padRune(elide(s.Name, nameW), nameW), pctField(s), sizeField(s, r.human), rate, cn, sd, tail) // A queued row's trailing SIZE/CN/SD/ETA columns are blank; drop the trailing // spaces so the line ends where its content does. return strings.TrimRight(row, " ") } // sizeField is the absolute-progress column: completed/total (e.g. // "950MiB/5.0GiB"), just the completed bytes when the total is unknown (an HTTP // stream with no Content-Length, or a pre-metadata magnet), or blank for a // download that has not started. func sizeField(s download.Stat, human bool) string { switch { case s.Status == download.Waiting: return "" case s.Total > 0: return humanSize(s.Completed, human) + "/" + humanSize(s.Total, human) case s.Completed > 0: return humanSize(s.Completed, human) default: return "" } } // peerFields formats the connection (CN) and seeder (SD) columns. SD is blank // for HTTP, which has no seeders, and both are blank for a download with no live // connections (queued, done, failed) so the columns aren't noise where they // carry no meaning. func peerFields(s download.Stat) (cn, sd string) { switch s.Status { case download.Active, download.Seeding: cn = strconv.Itoa(s.Conns) if s.IsBT { sd = strconv.Itoa(s.Seeders) } } return cn, sd } // rowMetric returns a table row's rate column and its tail for the download's // state: the rate is the download speed or the state word (seeding/queued/done/ // failed); the tail is an ETA, the share ratio while seeding (the seed-stop // criterion, more telling than an idle upload rate), or a "stalled" flag. func (r *Reporter) rowMetric(s download.Stat, dl, ul int64) (rate, tail string) { switch s.Status { case download.Waiting: return "queued", "" case download.Seeding: return "seeding", fmt.Sprintf("r%.2f", ratio(s.Uploaded, s.Completed)) case download.Complete: return "done", "" case download.Errored: return "failed", "" default: rate = r.rateStr(dl) switch { case dl > 0 && s.Total > 0: tail = secfmt(etaDuration(s.Total-s.Completed, dl)) case r.isStalled(s, dl): tail = "stalled" } return rate, tail } } // rateStr formats a bytes/sec speed for display, e.g. "388KiB/s". func (r *Reporter) rateStr(bps int64) string { return humanSize(bps, r.human) + "/s" } // maxETASeconds caps an ETA before it becomes a Duration: a near-stalled download // yields an astronomically large seconds value, and time.Duration(secs) * // time.Second would overflow int64 and wrap to a bogus tiny ETA. secfmt renders // anything past 99h as "--", so clamping to 100h loses nothing. const maxETASeconds = 100 * 3600 func etaDuration(remaining, dl int64) time.Duration { secs := float64(remaining) / float64(dl) if secs > maxETASeconds { secs = maxETASeconds } return time.Duration(secs) * time.Second } // isStalled reports an active download that has gone a while with no new bytes, // so the table can flag it rather than leave the eye to read DL:0B. A download // too young to have a rate yet is not stalled. func (r *Reporter) isStalled(s download.Stat, dl int64) bool { if s.Status != download.Active || dl > 0 { return false } w := r.win[s.ID] if w == nil || len(w.samples) < 2 { return false } return w.samples[len(w.samples)-1].t.Sub(w.samples[0].t) >= 4*time.Second } // pctField is the percent column: a number, full for seeding/complete, or "--" // when the total is unknown. func pctField(s download.Stat) string { switch { case s.Status == download.Seeding || s.Status == download.Complete: return "100%" case s.Total > 0: return fmt.Sprintf("%3d%%", percent(s.Completed, s.Total)) default: return " --" } } // padRune right-pads s with spaces to w runes (it is never longer, having been // through shortName) so table columns line up on rune count. func padRune(s string, w int) string { if n := utf8.RuneCountInString(s); n < w { return s + strings.Repeat(" ", w-n) } return s } // 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) } // elide shortens s to at most w runes, keeping the head and the tail joined by a // single "…". Download names often share a long prefix (a release group, a // series) and differ only at the end (an episode or volume number), so dropping // the middle keeps what tells two downloads apart — where a leading-only // truncation would collapse them to the same string. A name within w prints // whole. It works on runes, never bytes, so a multibyte glyph (CJK, emoji) is // never split. func elide(s string, w int) string { r := []rune(s) if len(r) <= w { return s } if w <= 1 { return string(r[:w]) } head := w / 2 // the w-1 kept runes split around one "…", tail := w - 1 - head // head taking the extra rune when w is even return string(r[:head]) + "…" + string(r[len(r)-tail:]) } // 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 } func termHeight(prev int) int { if _, h, err := term.GetSize(int(os.Stdout.Fd())); err == nil && h > 0 { return h } if prev > 0 { return prev } return 24 }