Files
got/progress/progress.go
Hojun-Cho 7545b046af progress: render a single download with the same table as several
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.
2026-06-22 08:42:30 +09:00

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
}