Compare commits
4 Commits
3d9ea7ce8b
...
d9de63f451
| Author | SHA1 | Date | |
|---|---|---|---|
| d9de63f451 | |||
| 11734af7fb | |||
| 16cbb97748 | |||
| ee66235c36 |
17
bt/bt.go
17
bt/bt.go
@@ -9,12 +9,16 @@ package bt
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
alog "github.com/anacrolix/log"
|
||||
missinggo "github.com/anacrolix/missinggo/v2"
|
||||
"github.com/anacrolix/torrent"
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
@@ -73,6 +77,14 @@ func ParsePorts(spec string) []int {
|
||||
return ports
|
||||
}
|
||||
|
||||
// quietSlogger filters anacrolix's structured logging down to Error and above,
|
||||
// so the routine webseed/peer/tracker warning chatter (the 403/429 noise) no
|
||||
// longer interleaves with — and corrupts — the live progress display. Genuine
|
||||
// errors still reach w.
|
||||
func quietSlogger(w io.Writer) *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(w, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
}
|
||||
|
||||
// NewClient builds the shared anacrolix client from the run-wide config. When a
|
||||
// listen-port spec is given it tries each candidate port in turn, advancing past
|
||||
// any that is already in use, and falls back to an OS-chosen port if none bind,
|
||||
@@ -91,6 +103,11 @@ func ParsePorts(spec string) []int {
|
||||
// prevention must come from a run-wide setting (e.g. --enable-dht=false).
|
||||
func NewClient(cfg ClientConfig) (*torrent.Client, error) {
|
||||
c := torrent.NewDefaultClientConfig()
|
||||
// Keep the library's own logging out of the readout. Webseed/peer warnings go
|
||||
// through slog, the rest through the legacy analog logger, so filter both to
|
||||
// Error and above (the analog default is Warning, which is what leaks today).
|
||||
c.Slogger = quietSlogger(os.Stderr)
|
||||
c.Logger = alog.Default.FilterLevel(alog.Error)
|
||||
c.DataDir = cfg.Dir
|
||||
if c.DataDir == "" {
|
||||
c.DataDir = "."
|
||||
|
||||
@@ -1,10 +1,38 @@
|
||||
package bt
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestQuietSloggerDropsWarnings: the library's warning chatter (the webseed
|
||||
// 403/429 noise) is filtered out so it can't corrupt the live display, while a
|
||||
// genuine error still gets through.
|
||||
func TestQuietSloggerDropsWarnings(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
lg := quietSlogger(&buf)
|
||||
|
||||
lg.Warn("webseed request error", "err", "429 Too Many Requests")
|
||||
if buf.Len() != 0 {
|
||||
t.Errorf("warning leaked into output: %q", buf.String())
|
||||
}
|
||||
// anacrolix's webseed code logs via Log(ctx, level, ...); confirm that path is
|
||||
// filtered too, not just the Warn helper.
|
||||
lg.Log(context.Background(), slog.LevelWarn, "another warning")
|
||||
if buf.Len() != 0 {
|
||||
t.Errorf("Log-at-Warn leaked: %q", buf.String())
|
||||
}
|
||||
|
||||
lg.Error("real failure")
|
||||
if !strings.Contains(buf.String(), "real failure") {
|
||||
t.Errorf("error was suppressed, want it kept: %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePorts(t *testing.T) {
|
||||
tests := []struct {
|
||||
spec string
|
||||
|
||||
@@ -91,6 +91,10 @@ func (e *Engine) track(d Download) {
|
||||
e.mu.Unlock()
|
||||
}
|
||||
|
||||
// untrack removes d from the active set. Every Download implementation is a
|
||||
// pointer type, so the == compares identities — never the fields of a value,
|
||||
// which could panic on a non-comparable one. (Keying on Stat().ID is not an
|
||||
// option: a torrent's ID changes from source to infohash once metadata loads.)
|
||||
func (e *Engine) untrack(d Download) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
2
go.mod
2
go.mod
@@ -3,6 +3,7 @@ module github.com/hanbok/got
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb
|
||||
github.com/anacrolix/missinggo/v2 v2.10.0
|
||||
github.com/anacrolix/torrent v1.61.0
|
||||
golang.org/x/sys v0.38.0
|
||||
@@ -19,7 +20,6 @@ require (
|
||||
github.com/anacrolix/envpprof v1.4.0 // indirect
|
||||
github.com/anacrolix/generics v0.1.1-0.20251125230353-15d98d46693b // indirect
|
||||
github.com/anacrolix/go-libutp v1.3.2 // indirect
|
||||
github.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb // indirect
|
||||
github.com/anacrolix/missinggo v1.3.0 // indirect
|
||||
github.com/anacrolix/missinggo/perf v1.0.0 // indirect
|
||||
github.com/anacrolix/mmsg v1.0.1 // indirect
|
||||
|
||||
@@ -536,29 +536,43 @@ func (d *Download) withRetries(ctx context.Context, what string, attempt func()
|
||||
return retriesExhausted(what, tries, lastErr)
|
||||
}
|
||||
|
||||
// overMirrors calls fn against each mirror URL in turn, starting at offset start
|
||||
// so callers can spread work across mirrors (segment index) and rotate on retry.
|
||||
// It returns nil on the first success, ctx.Err() if cancelled between mirrors, or
|
||||
// the last error once every mirror is spent.
|
||||
func (d *Download) overMirrors(ctx context.Context, start int, fn func(uri string) error) error {
|
||||
var lastErr error
|
||||
for i := range d.uris {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if err := fn(d.mirror(start + i)); err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// probeRetry runs probe within the shared retry policy (item [18]). The probe
|
||||
// result is captured through pr because withRetries only carries an error.
|
||||
func (d *Download) probeRetry(ctx context.Context) (probeResult, error) {
|
||||
// Probe mirrors in order (primary first); the first that answers anchors the
|
||||
// size and validators for the whole download. A dead/404 primary falls over
|
||||
// to the next mirror.
|
||||
var lastErr error
|
||||
for _, u := range d.uris {
|
||||
if ctx.Err() != nil {
|
||||
return probeResult{}, ctx.Err()
|
||||
}
|
||||
var pr probeResult
|
||||
err := d.withRetries(ctx, u, func() error {
|
||||
err := d.overMirrors(ctx, 0, func(uri string) error {
|
||||
return d.withRetries(ctx, uri, func() error {
|
||||
var perr error
|
||||
pr, perr = d.probe(ctx, u)
|
||||
pr, perr = d.probe(ctx, uri)
|
||||
return perr
|
||||
})
|
||||
if err == nil {
|
||||
})
|
||||
if err != nil {
|
||||
return probeResult{}, err
|
||||
}
|
||||
return pr, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return probeResult{}, lastErr
|
||||
}
|
||||
|
||||
// probe issues a one-byte ranged GET to learn the total size, whether the
|
||||
@@ -715,24 +729,14 @@ func (d *Download) fetchSeg(ctx context.Context, f *os.File, p *pool, s *seg, to
|
||||
// The segment fails only once every mirror is spent. Starting at s.index
|
||||
// spreads the segments across mirrors round-robin, and each attempt resumes
|
||||
// from s.offset().
|
||||
var lastErr error
|
||||
for i := range d.uris {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
uri := d.mirror(s.index + i)
|
||||
err := d.withRetries(ctx, fmt.Sprintf("segment %d", s.index), func() error {
|
||||
return d.overMirrors(ctx, s.index, func(uri string) error {
|
||||
return d.withRetries(ctx, fmt.Sprintf("segment %d", s.index), func() error {
|
||||
if s.done() {
|
||||
return nil
|
||||
}
|
||||
return d.fetchOnce(ctx, f, p, s, uri, total)
|
||||
})
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return lastErr
|
||||
})
|
||||
}
|
||||
|
||||
func (d *Download) fetchOnce(ctx context.Context, f *os.File, p *pool, s *seg, uri string, total int64) error {
|
||||
@@ -816,10 +820,9 @@ const speedStartupGrace = 10 * time.Second
|
||||
|
||||
// belowSpeed reports whether the bytes transferred between two cumulative
|
||||
// d.completed samples (prev -> now over one window) put the download at or
|
||||
// below limit. A negative delta is not a slow second: d.completed is not
|
||||
// strictly monotonic — singleOnce rewrites it with an absolute StoreInt64 and a
|
||||
// resumed Range answered by a 200 resets the offset to 0, so the counter can
|
||||
// jump backward on a retry. Treating that backward jump as "below limit" would
|
||||
// below limit. A negative delta is not a slow second: when a resumed Range is
|
||||
// answered by a 200 the transfer restarts from offset 0, so d.completed jumps
|
||||
// backward on that retry. Treating that backward jump as "below limit" would
|
||||
// false-abort a healthy download, so a reset (delta < 0) reports false; the
|
||||
// caller has already advanced its baseline, so the next full window measures the
|
||||
// real speed. Only a genuine non-negative delta at or under the limit aborts.
|
||||
@@ -964,23 +967,13 @@ func (d *Download) single(ctx context.Context, out string) error {
|
||||
first := true
|
||||
// One connection at a time, but try each mirror: a mirror that fails its
|
||||
// retry budget falls over to the next (resuming from what's already on disk).
|
||||
var lastErr error
|
||||
for i := range d.uris {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
uri := d.mirror(i)
|
||||
err := d.withRetries(ctx, uri, func() error {
|
||||
return d.overMirrors(ctx, 0, func(uri string) error {
|
||||
return d.withRetries(ctx, uri, func() error {
|
||||
resumeFromDisk := !first || foreignResume
|
||||
first = false
|
||||
return d.singleOnce(ctx, out, uri, resumeFromDisk)
|
||||
})
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return lastErr
|
||||
})
|
||||
}
|
||||
|
||||
// singleOnce performs one single-stream transfer attempt. When resumeFromDisk is
|
||||
@@ -1058,7 +1051,7 @@ func (d *Download) singleOnce(ctx context.Context, out, uri string, resumeFromDi
|
||||
}
|
||||
off += int64(rd)
|
||||
got += int64(rd)
|
||||
atomic.StoreInt64(&d.completed, off)
|
||||
atomic.AddInt64(&d.completed, int64(rd)) // add deltas, as pump does
|
||||
d.throttle(ctx, rd)
|
||||
}
|
||||
if err == io.EOF {
|
||||
|
||||
57
main.go
57
main.go
@@ -190,9 +190,9 @@ type failedDownload struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (f failedDownload) Name() string { return f.name }
|
||||
func (f failedDownload) Run(context.Context) error { return f.err }
|
||||
func (f failedDownload) Stat() download.Stat {
|
||||
func (f *failedDownload) Name() string { return f.name }
|
||||
func (f *failedDownload) Run(context.Context) error { return f.err }
|
||||
func (f *failedDownload) Stat() download.Stat {
|
||||
return download.Stat{Name: f.name, Status: download.Errored, Total: -1}
|
||||
}
|
||||
|
||||
@@ -224,6 +224,24 @@ func dedupeTorrents(uris []string, follow bool) map[string]string {
|
||||
return dup
|
||||
}
|
||||
|
||||
// markInfoHash records the v1 infohash of the .torrent at path in seen and
|
||||
// reports whether it repeats one already seen, returning the path first recorded
|
||||
// under that infohash. Callers fail the duplicate rather than let two jobs share
|
||||
// one torrent on the client (see bt.SourceInfoHash for why that aliasing is
|
||||
// unsafe). A v2-only or unreadable infohash is never a duplicate — the dedup
|
||||
// can't see it, so it runs.
|
||||
func markInfoHash(seen map[metainfo.Hash]string, path string) (first string, dup bool) {
|
||||
h, ok := bt.SourceInfoHash(path, true)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if first, dup = seen[h]; dup {
|
||||
return first, true
|
||||
}
|
||||
seen[h] = path
|
||||
return "", false
|
||||
}
|
||||
|
||||
func build(opts *cli.Options, targets []target) ([]job, *torrent.Client, error) {
|
||||
flat := allURIs(targets)
|
||||
// Two sources naming the same torrent (a magnet and its own .torrent, say)
|
||||
@@ -263,7 +281,7 @@ func build(opts *cli.Options, targets []target) ([]job, *torrent.Client, error)
|
||||
u := t.uris[0] // primary: classifies the target and names the session source
|
||||
src := strings.Join(t.uris, "\t")
|
||||
if first, isDup := dups[u]; isDup {
|
||||
out = append(out, job{source: src, dl: failedDownload{
|
||||
out = append(out, job{source: src, dl: &failedDownload{
|
||||
name: u,
|
||||
err: fmt.Errorf("%w: same torrent as %s", errDuplicate, first),
|
||||
}})
|
||||
@@ -358,6 +376,7 @@ func runJobs(ctx context.Context, eng *download.Engine, jobs []job) []download.R
|
||||
// so a regular download that merely ends in ".torrent" is left alone.
|
||||
func followUps(jobs []job, client *torrent.Client, bo bt.Options) []job {
|
||||
var out []job
|
||||
seen := map[metainfo.Hash]string{}
|
||||
for _, j := range jobs {
|
||||
h, ok := j.dl.(*httpdl.Download)
|
||||
if !ok || j.dl.Stat().Status != download.Complete {
|
||||
@@ -373,6 +392,17 @@ func followUps(jobs []job, client *torrent.Client, bo bt.Options) []job {
|
||||
if _, err := bt.Files(path); err != nil {
|
||||
continue // downloaded file is not a valid torrent
|
||||
}
|
||||
// Two fetched .torrent files that name the same torrent would share one
|
||||
// torrent on the refcount-less client: the first to finish Drops it out
|
||||
// from under the other, stranding it at 0%. build() dedupes pass-1 sources
|
||||
// but cannot reach a not-yet-fetched HTTP .torrent, so dedupe here too.
|
||||
if first, dup := markInfoHash(seen, path); dup {
|
||||
out = append(out, job{source: path, dl: &failedDownload{
|
||||
name: path,
|
||||
err: fmt.Errorf("%w: same torrent as %s", errDuplicate, first),
|
||||
}})
|
||||
continue
|
||||
}
|
||||
out = append(out, job{source: path, dl: bt.New(client, path, true, bo)})
|
||||
}
|
||||
return out
|
||||
@@ -408,22 +438,23 @@ func allURIs(ts []target) []string {
|
||||
// gatherURIs collects download targets from the command line, --torrent-file,
|
||||
// and --input-file. Plain command-line http/https URIs are grouped into ONE
|
||||
// mirrored download by default (every command-line URI names the same file);
|
||||
// -Z/--force-sequential makes each its own download. Torrents and magnets
|
||||
// are always one target each; --input-file groups TAB-separated URIs per line.
|
||||
// -Z/--force-sequential makes each its own download. Torrents, magnets, and
|
||||
// .torrent URLs (fetched over HTTP then followed) are always one target each,
|
||||
// since distinct torrents are not mirrors of one another; --input-file groups
|
||||
// TAB-separated URIs per line.
|
||||
func gatherURIs(opts *cli.Options, cmdURIs []string) []target {
|
||||
var ts []target
|
||||
follow := opts.Bool("follow-torrent")
|
||||
seq := opts.Bool("force-sequential")
|
||||
var httpGroup []string
|
||||
for _, u := range cmdURIs {
|
||||
switch uriKind(u, follow) {
|
||||
case kindHTTP, kindFollow:
|
||||
if seq {
|
||||
ts = append(ts, target{uris: []string{u}})
|
||||
} else {
|
||||
// Only plain HTTP URLs collapse into one mirror download (alternate sources
|
||||
// for the same file); -Z opts out. A .torrent URL (kindFollow), like a
|
||||
// torrent or magnet, is its own download — distinct torrents are never
|
||||
// mirrors of one another.
|
||||
if uriKind(u, follow) == kindHTTP && !seq {
|
||||
httpGroup = append(httpGroup, u)
|
||||
}
|
||||
default:
|
||||
} else {
|
||||
ts = append(ts, target{uris: []string{u}})
|
||||
}
|
||||
}
|
||||
|
||||
76
main_test.go
76
main_test.go
@@ -11,6 +11,9 @@ import (
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/anacrolix/torrent/bencode"
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
"github.com/hanbok/got/bt"
|
||||
"github.com/hanbok/got/cli"
|
||||
"github.com/hanbok/got/download"
|
||||
"github.com/hanbok/got/httpdl"
|
||||
@@ -112,8 +115,8 @@ func TestDedupeTorrents(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestGatherURIsGrouping checks the command-line grouping: plain http URIs
|
||||
// become one mirrored download by default, -Z splits them, and torrents/magnets
|
||||
// stay separate.
|
||||
// become one mirrored download by default, -Z splits them, and torrents,
|
||||
// magnets, and .torrent URLs stay separate.
|
||||
func TestGatherURIsGrouping(t *testing.T) {
|
||||
parse := func(args ...string) []target {
|
||||
res, err := cli.Parse(append([]string{"--no-conf"}, args...))
|
||||
@@ -131,6 +134,16 @@ func TestGatherURIsGrouping(t *testing.T) {
|
||||
if ts := parse("http://a/x", "magnet:?xt=urn:btih:abc"); len(ts) != 2 {
|
||||
t.Errorf("http + magnet: got %d targets, want 2 (magnet never mirrors)", len(ts))
|
||||
}
|
||||
// Distinct .torrent URLs are separate downloads, not mirrors of one file
|
||||
// (follow-torrent defaults on, so each is fetched then followed).
|
||||
if ts := parse("http://a/x.torrent", "http://b/y.torrent", "http://c/z.torrent"); len(ts) != 3 {
|
||||
t.Errorf(".torrent URLs: got %d targets, want 3 separate downloads", len(ts))
|
||||
}
|
||||
// A plain http URL still mirror-groups even alongside a .torrent URL: one
|
||||
// http group of 1 + one torrent target = 2.
|
||||
if ts := parse("http://a/x", "http://b/y.torrent"); len(ts) != 2 {
|
||||
t.Errorf("http + .torrent: got %d targets, want 2", len(ts))
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadInputFileTAB checks the input-file grouping: TAB-separated URIs on
|
||||
@@ -152,6 +165,65 @@ func TestReadInputFileTAB(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// writeTorrent creates a .torrent file at dir/name whose info dict is built from
|
||||
// a file of the given content and internal name. Two calls with the same
|
||||
// internalName and content produce the same infohash regardless of the output
|
||||
// filename, so the test can make two .torrent files that name one torrent.
|
||||
func writeTorrent(t *testing.T, dir, name, internalName string, content []byte) string {
|
||||
t.Helper()
|
||||
data := filepath.Join(dir, internalName)
|
||||
if err := os.WriteFile(data, content, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info := metainfo.Info{PieceLength: 32 * 1024}
|
||||
if err := info.BuildFromFilePath(data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := bencode.Marshal(info)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tp := filepath.Join(dir, name)
|
||||
f, err := os.Create(tp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
if err := (&metainfo.MetaInfo{InfoBytes: b}).Write(f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tp
|
||||
}
|
||||
|
||||
// TestFollowUpInfoHashDedup covers the fix for two fetched .torrent files that
|
||||
// name the same torrent: the second must be flagged a duplicate so followUps
|
||||
// fails it rather than letting both share — and Drop — one torrent on the client.
|
||||
func TestFollowUpInfoHashDedup(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a := writeTorrent(t, dir, "a.torrent", "same", []byte("AAAAAAAA"))
|
||||
b := writeTorrent(t, dir, "b.torrent", "same", []byte("AAAAAAAA")) // same infohash as a
|
||||
c := writeTorrent(t, dir, "c.torrent", "diff", []byte("BBBBBBBB")) // a distinct torrent
|
||||
|
||||
// Sanity-check the fixture: a and b really share an infohash, c differs.
|
||||
ha, _ := bt.SourceInfoHash(a, true)
|
||||
hb, _ := bt.SourceInfoHash(b, true)
|
||||
hc, _ := bt.SourceInfoHash(c, true)
|
||||
if ha != hb || ha == hc {
|
||||
t.Fatalf("fixture: want a==b!=c infohashes, got a=%v b=%v c=%v", ha, hb, hc)
|
||||
}
|
||||
|
||||
seen := map[metainfo.Hash]string{}
|
||||
if first, dup := markInfoHash(seen, a); dup {
|
||||
t.Fatalf("a is the first occurrence, wrongly flagged a duplicate of %s", first)
|
||||
}
|
||||
if first, dup := markInfoHash(seen, b); !dup || first != a {
|
||||
t.Errorf("b should duplicate a: got first=%q dup=%v, want %q true", first, dup, a)
|
||||
}
|
||||
if _, dup := markInfoHash(seen, c); dup {
|
||||
t.Errorf("c is a distinct torrent, wrongly flagged a duplicate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportExit(t *testing.T) {
|
||||
canceled := download.Result{Name: "a", Err: context.Canceled}
|
||||
done := download.Result{Name: "b", Err: nil}
|
||||
|
||||
@@ -25,8 +25,10 @@ type Reporter struct {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -62,6 +64,7 @@ func New(snap func() []download.Stat, human bool) *Reporter {
|
||||
w: os.Stdout,
|
||||
tty: term.IsTerminal(int(os.Stdout.Fd())),
|
||||
width: 80,
|
||||
height: 24,
|
||||
win: map[string]*speedWindow{},
|
||||
}
|
||||
}
|
||||
@@ -103,29 +106,21 @@ func (r *Reporter) render(final bool) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
r.height = termHeight(r.height)
|
||||
r.redraw(r.block(stats), final)
|
||||
return
|
||||
}
|
||||
// Not a TTY: can't redraw one line, so print sparingly (and always the
|
||||
// final line) to keep redirected output and logs readable.
|
||||
// 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
|
||||
@@ -165,27 +160,161 @@ func (r *Reporter) lineOne(s download.Stat) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (r *Reporter) lineMany(stats []download.Stat) string {
|
||||
// nameW is the fixed rune width of the name column in the table, matching
|
||||
// shortName'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 name/bar/speed/ETA table 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)+1 > r.height: // header + one row each would overflow the screen
|
||||
return []string{r.summary(stats)}
|
||||
default:
|
||||
out := make([]string, 0, len(stats)+1)
|
||||
out = append(out, r.summary(stats))
|
||||
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: the active count and total down/up
|
||||
// speed, plus a stalled count when any download has gone quiet.
|
||||
func (r *Reporter) summary(stats []download.Stat) string {
|
||||
if len(stats) == 0 {
|
||||
return ""
|
||||
}
|
||||
var totDL, totUL int64
|
||||
stalled := 0
|
||||
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))
|
||||
if r.isStalled(s, dl) {
|
||||
stalled++
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
line := fmt.Sprintf("%d active DL:%s UL:%s", len(stats), speed(totDL, r.human), speed(totUL, r.human))
|
||||
if stalled > 0 {
|
||||
line += fmt.Sprintf(" (%d stalled)", stalled)
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// tableRow renders one download as "name |bar| pct metric tail": the bar and
|
||||
// percent for progress, then the field that answers "stalled or fast" (DL speed
|
||||
// or seeding) and a tail (ETA, upload rate, or a stalled flag).
|
||||
func (r *Reporter) tableRow(s download.Stat) string {
|
||||
dl, ul := r.rates(s)
|
||||
metric, tail := r.rowMetric(s, dl, ul)
|
||||
return fmt.Sprintf(" %s %s %s %-9s %s", padRune(shortName(s.Name), nameW), barOf(s), pctField(s), metric, tail)
|
||||
}
|
||||
|
||||
// rowMetric returns a table row's two trailing fields for the download's state:
|
||||
// the primary metric (DL speed, "seeding", "done", "failed") and a tail (ETA,
|
||||
// upload rate, or a "stalled" flag).
|
||||
func (r *Reporter) rowMetric(s download.Stat, dl, ul int64) (metric, tail string) {
|
||||
switch s.Status {
|
||||
case download.Seeding:
|
||||
return "seeding", "UL:" + speed(ul, r.human)
|
||||
case download.Complete:
|
||||
return "done", ""
|
||||
case download.Errored:
|
||||
return "failed", ""
|
||||
default:
|
||||
metric = "DL:" + speed(dl, r.human)
|
||||
switch {
|
||||
case dl > 0 && s.Total > 0:
|
||||
eta := time.Duration(float64(s.Total-s.Completed)/float64(dl)) * time.Second
|
||||
tail = secfmt(eta)
|
||||
case r.isStalled(s, dl):
|
||||
tail = "stalled"
|
||||
}
|
||||
return metric, tail
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// barOf renders a fixed-width progress bar. A seeding or complete download is
|
||||
// full; an unknown total (no Content-Length, or pre-metadata) shows empty.
|
||||
func barOf(s download.Stat) string {
|
||||
const cells = 12
|
||||
k := 0
|
||||
switch {
|
||||
case s.Status == download.Seeding || s.Status == download.Complete:
|
||||
k = cells
|
||||
case s.Total > 0:
|
||||
k = int(int64(cells) * s.Completed / s.Total)
|
||||
if k > cells {
|
||||
k = cells
|
||||
}
|
||||
}
|
||||
return "|" + strings.Repeat("#", k) + strings.Repeat("-", cells-k) + "|"
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -272,3 +401,13 @@ func termWidth(prev int) int {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package progress
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -112,6 +113,100 @@ func TestLineOneCNSD(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBarOf(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
s download.Stat
|
||||
want string
|
||||
}{
|
||||
{"empty", download.Stat{Status: download.Active, Total: 100, Completed: 0}, "|------------|"},
|
||||
{"half", download.Stat{Status: download.Active, Total: 100, Completed: 50}, "|######------|"},
|
||||
{"full by complete", download.Stat{Status: download.Complete}, "|############|"},
|
||||
{"full by seeding", download.Stat{Status: download.Seeding}, "|############|"},
|
||||
{"unknown total is empty", download.Stat{Status: download.Active, Total: -1, Completed: 9}, "|------------|"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := barOf(tc.s); got != tc.want {
|
||||
t.Errorf("%s: barOf = %q, want %q", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPctField(t *testing.T) {
|
||||
if got := pctField(download.Stat{Status: download.Active, Total: 100, Completed: 50}); got != " 50%" {
|
||||
t.Errorf("active 50%% = %q, want ' 50%%'", got)
|
||||
}
|
||||
if got := pctField(download.Stat{Status: download.Seeding}); got != "100%" {
|
||||
t.Errorf("seeding = %q, want 100%%", got)
|
||||
}
|
||||
if got := pctField(download.Stat{Status: download.Active, Total: -1}); got != " --" {
|
||||
t.Errorf("unknown total = %q, want ' --'", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRowMetric: the two trailing fields per download state.
|
||||
func TestRowMetric(t *testing.T) {
|
||||
r := newReporter(true)
|
||||
if m, tail := r.rowMetric(download.Stat{Status: download.Active, Total: 100, Completed: 50}, 10, 0); !strings.HasPrefix(m, "DL:") || tail == "" {
|
||||
t.Errorf("active downloading: metric=%q tail=%q, want DL:… + an ETA", m, tail)
|
||||
}
|
||||
if m, tail := r.rowMetric(download.Stat{Status: download.Seeding}, 0, 128<<10); m != "seeding" || !strings.HasPrefix(tail, "UL:") {
|
||||
t.Errorf("seeding: metric=%q tail=%q, want seeding + UL:…", m, tail)
|
||||
}
|
||||
if m, _ := r.rowMetric(download.Stat{Status: download.Complete}, 0, 0); m != "done" {
|
||||
t.Errorf("complete metric=%q, want done", m)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBlockTableAndFallback: several downloads render as header+rows when they
|
||||
// fit, and collapse to a single summary line when the window is too short.
|
||||
func TestBlockTableAndFallback(t *testing.T) {
|
||||
r := newReporter(true)
|
||||
stats := []download.Stat{
|
||||
{ID: "a", Name: "a", Status: download.Active, Total: 100, Completed: 50},
|
||||
{ID: "b", Name: "b", Status: download.Active, Total: 100, Completed: 10},
|
||||
}
|
||||
r.height = 24
|
||||
if got := r.block(stats); len(got) != 3 { // header + 2 rows
|
||||
t.Errorf("table block = %d lines, want 3:\n%v", len(got), got)
|
||||
}
|
||||
r.height = 2 // header + 2 rows = 3 > 2
|
||||
if got := r.block(stats); len(got) != 1 {
|
||||
t.Errorf("overflow block = %d lines, want 1 summary:\n%v", len(got), got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderRedraw: the TTY path draws the block, then on the next frame moves
|
||||
// the cursor up over the previous frame and clears it before repainting.
|
||||
func TestRenderRedraw(t *testing.T) {
|
||||
snap := []download.Stat{
|
||||
{ID: "a", Name: "alpha", IsBT: true, Status: download.Active, Total: 100, Completed: 50},
|
||||
{ID: "b", Name: "beta", IsBT: true, Status: download.Seeding, Total: 100, Completed: 100, Uploaded: 200},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
r := &Reporter{
|
||||
snap: func() []download.Stat { return snap },
|
||||
human: true, w: &buf, tty: true, width: 200, height: 24,
|
||||
win: map[string]*speedWindow{},
|
||||
}
|
||||
r.render(false)
|
||||
first := buf.String()
|
||||
for _, want := range []string{"2 active", "alpha", "seeding", "|"} {
|
||||
if !strings.Contains(first, want) {
|
||||
t.Fatalf("first frame missing %q:\n%q", want, first)
|
||||
}
|
||||
}
|
||||
buf.Reset()
|
||||
r.render(false)
|
||||
second := buf.String()
|
||||
if !strings.Contains(second, "\x1b[2A") { // up prevLines-1 = 3-1
|
||||
t.Errorf("second frame should move cursor up 2 lines:\n%q", second)
|
||||
}
|
||||
if !strings.Contains(second, "\x1b[J") {
|
||||
t.Errorf("second frame should clear to end of screen:\n%q", second)
|
||||
}
|
||||
}
|
||||
|
||||
// TestShortNameRune ensures CJK names are truncated on runes, not bytes.
|
||||
func TestShortNameRune(t *testing.T) {
|
||||
name := strings.Repeat("あ", 30) // 30 runes, 90 bytes
|
||||
|
||||
Reference in New Issue
Block a user