block() no longer special-cases one download with a [bracketed] one-liner; it always draws the summary line, the column header, and one row per download. The non-TTY path emits the same summary for one or many. This drops lineOne and the dual rendering path it created, so a column or state change is made in one place, not two that could drift.
462 lines
14 KiB
Go
462 lines
14 KiB
Go
// 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 the summary line sparingly (and always
|
|
// the final one) to keep redirected output and logs readable.
|
|
var line string
|
|
if len(stats) > 0 {
|
|
line = r.summary(stats)
|
|
}
|
|
if line != "" && (final || now.Sub(r.lastLog) >= logEvery) {
|
|
fmt.Fprintln(r.w, line)
|
|
r.lastLog = now
|
|
}
|
|
}
|
|
|
|
// 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: a summary line, the column header,
|
|
// and one row per download — or just the one-line summary when the table would
|
|
// not fit the window. A single download renders the same way as several.
|
|
func (r *Reporter) block(stats []download.Stat) []string {
|
|
if len(stats) == 0 {
|
|
return nil
|
|
}
|
|
if 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)}
|
|
}
|
|
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
|
|
}
|